public inbox for [email protected]  
help / color / mirror / Atom feed
From: Arne Roland <[email protected]>
To: Tomas Vondra <[email protected]>
To: Joel Jacobson <[email protected]>
To: Laurenz Albe <[email protected]>
To: pgsql-hackers <[email protected]>
Cc: Anders Granlund <[email protected]>
Cc: Andreas Karlsson <[email protected]>
Cc: Vik Fearing <[email protected]>
Subject: Re: Key joins
Date: Fri, 12 Jun 2026 02:20:56 +0200
Message-ID: <[email protected]> (raw)
In-Reply-To: <[email protected]>
References: <[email protected]>
	<[email protected]>
	<[email protected]>
	<[email protected]>
	<[email protected]>
	<[email protected]>
	<[email protected]>
	<[email protected]>
	<[email protected]>
	<[email protected]>
	<[email protected]>

Hi Tomas,

thank you for checking it out!

On 2026-06-11 11:02 PM, Tomas Vondra wrote:
> Hi,
>
> I took a quick look at the patch over the past couple days. I don't have
> a perfect understanding of how it works, but let me share what I have so
> far, before I get distracted by other stuff.
>
> I'm interested in this patch because there seems to be a possible
> overlap with the overlap with the starjoin planning (in that maybe we
> could try reusing some of the derived information for that).
>
> It's a mix of random thoughts, high/low level, important/superficial in
> no particular order.
>
>
> 1) It does not compile, ATExecSetRowSecurity seems to be missing
> prev_rls or something like that. I simply commented this out, to get it
> to compile.

Sorry, I am confused. There is the self contained block of

> @@ -18879,6 +18898,7 @@ ATExecSetRowSecurity(Relation rel, bool rls)
>   	Relation	pg_class;
>   	Oid			relid;
>   	HeapTuple	tuple;
> +	bool		prev_rls;
>   
>   	relid = RelationGetRelid(rel);
>   
> @@ -18890,6 +18910,7 @@ ATExecSetRowSecurity(Relation rel, bool rls)
>   	if (!HeapTupleIsValid(tuple))
>   		elog(ERROR, "cache lookup failed for relation %u", relid);
>   
> +	prev_rls = ((Form_pg_class) GETSTRUCT(tuple))->relrowsecurity;
>   	((Form_pg_class) GETSTRUCT(tuple))->relrowsecurity = rls;
>   	CatalogTupleUpdate(pg_class, &tuple->t_self, tuple);
>   
> @@ -18898,6 +18919,21 @@ ATExecSetRowSecurity(Relation rel, bool rls)
>   
>   	table_close(pg_class, RowExclusiveLock);
>   	heap_freetuple(tuple);
> +
> +	/*
> +	 * Revalidate stored key-join proofs that depend on this relation.  The
> +	 * key-join base-fact computation refuses to expose facts for a relation
> +	 * with row-level security enabled (parse_key_join.c), so flipping
> +	 * relrowsecurity can leave a stored proof unprovable.  Revalidate
> +	 * whenever the flag actually changes; for the off-to-on transition the
> +	 * revalidation raises an error and aborts this DDL, while the on-to-off
> +	 * transition is a no-op for proofs that were already valid.
> +	 */
> +	if (rls != prev_rls)
> +	{
> +		CommandCounterIncrement();
> +		RevalidateDependentKeyJoinObjectsOnRelation(relid);
> +	}
>   }
Apart from that there shouldn't be any references to that. What 
definition was missing?
> 2) fk_referenced_selected in find_key_join_match should be marked as
> PG_USED_FOR_ASSERTS_ONLY, to fix compiler warning
Thank you for spotting that.
> 3) It'd be helpful if the commit message for 0001 explained why this
> change is needed. More clearly than now, I mean. The initial message in
> this thread points at another thread as the source of this, but that
> thread is huge so how are reviewers expected to find the explanation?
>
> Don't explain just what the commit does, but why it's needed. Say, give
> an example of how the old code fails.
In short we need that to prevent objects our proof relies on to be 
dropped or altered below us. I'll try to work that into the commit message.
> 4) I think there needs to be a README explaining how the feature works,
> i.e. the design and trade offs. Alternatively, it could be explained in
> a comment at the beginning of some .c file, but a README seems easier to
> find. Without this, reviewers have to piece it from the sgml "user"
> docs, and random comments all over the place.
>
>
> 5) It might be helpful if the README defined a couple terms introduced
> by the patch, but not really defined anywhere. I mean terms like: join
> point, proof, proof graph, fact, surface, "relation visible from". I can
> guess what each of these means, but maybe I got it wrong.
>
> Although, I now see "join point" was used in the docs before, so maybe
> it's a well-known term?

Thank you, I will try to come up with some better document explaining 
the concept. For now I can mainly point you only to 
https://keyjoin.org/#sec7.1, which gives a birds eye overview over how 
the implementation and it's artifacts work.

Seeing your confusion, I think we should definitely reduce the amount of 
terminology used. For instance "proof graph" is shorthand for the slice 
of the catalog dependency graph contributed by key-join proofs 
(depending object → proof object), i.e. the deptype='k' subgraph of the 
dependency graph. I don't think we need to introduce this word as a new 
concept. Thank you, this input is very valuable to improve this.

> 6) Does it always have to be a "foreign key traversal"? Consider an
> example like this:
>
>      create table dim (id serial primary key);
>      create table f (id serial primary key, did int);
>      select * from f left join dim for key (id) <- f(did);
>
> Currently this fails because of no matching foreign key constraint, but
> isn't that pretty much the same thing as if the table actually had the
> foreign key (at least considering the cardinality of the join - it won't
> change it by adding/removing rows).
>
> I realize that'd contradict the "FOR KEY" part of this patch, but it's
> also one of the things that might be beneficial for the starjoin
> planning (in that we could maybe extend it to more cases). Although,
> maybe we could check those constraints during planning, just like we
> check the fkey_list.
This is a very different feature avoiding far less bugs than the 
original key join. To give just once, consider

SELECT *
FROM customers cu
LEFT JOIN FOR KEY orders (id) <- cu (id)
WHERE customers.id = 122354;


We decided, this (among other) error classes, was important enough, we 
pushed for stronger error guarantees, than just the uniqueness of the 
referenced side. I still am convinced, this more save way of writing 
queries is better for it's additional safety. I do think caring about 
the foreign key gives the better syntax.

Do you think such constructions are very common for your customers?

Our proof framework with proof facts could potentially be leveraged to 
proof such constructions too. We opted to keep this patch as minimal as 
possible, since it's already huge. In the end our conquest is to get to 
something, which is eventually commitable. This patch has a lot things 
to get wrong. But I think as a second step adding support of other 
schemas and handing this information of to the planer, should be a 
fairly straight forward thing to do. Although it would add a few lines 
on it's own, it's step, that I seems easier than landing a minimal 
version of this patch.

> 7) The patch invents a new "FILTER" clause for joins:
>
>     [ FILTER (WHERE join_filter) ]
>
> I understand why it's done - there may be additional join conditions, on
> top of the FK equality. But I think it'll be rather confusing. We
> already have a "Join Filter", which is used for join clauses that happen
> to not be used as "proper" join conditions (e.g. Hash/Merge Cond), and
> has to be evaluated "after" the join itself. And now we'd have another
> kind of "join filter" ...
>
> Is there a different that'd make this work without the FILTER? For
> example, we might encode the "key join" information in the existing ON
> (...) clause:
>
>      ON (KEY (oi.order_id) -> (o.id) AND ... other clauses ...)
>
> but it seems not as readable. Or maybe just use something else than
> "FILTER"? Not sure.
This word has the benefit of being already a keyword and it solves the 
problem rather clear cut. Do you think, there is something we could do 
to make this better?
> 8) I don't understand this change in functioncmds:
>
>      /* Routine kind cannot change for an existing pg_proc OID. */
>      Assert(procForm->prokind != PROKIND_AGGREGATE);
>
> The comment says we can't change OID, but the assert checks it's not an
> aggregate functions. Isn't that misleading?
Maybe it would be cleaner to mention additionally, that "aggregates were 
already rejected on the pre-lock tuple"? I actually think, that comment 
is indeed stating a helpful invariant to explain why we are concurrency 
save here.
> 9) DefineView now adjusts the query ID. Why is that needed? Isn't that a
> bit weird?
Sorry, does it? Could you point out where exactly? I might be too tired 
to see it right now.
> 10) checkWellFormedRecursionWalker now recurses, but why is that needed?

Didn't in already recurse before? Didn't check the blame, but I do think 
it's recursing for a long time.

I just see an added line to check the new filter clause for subqueries 
or something to recurse into.

> 11) It's not clear to me why we need p_creating_stored_object, and it's
> not explained anywhere. Maybe it's obvious, but not to me.

Sorry, that's my oversight. We need to take stronger locks, if we store 
something materially, to prevent concurrent changes, while we store it.

Where do you recon a better comment would be helpful? A longer comment 
directly in the struct definition?

> 12) I'm very skeptical anyone can meaningfully review parse_key_join.c.
> It's 150KB with ~5200 lines (very dense, with pretty minimal comments).
> That's a massive file. The chance of me declaring this committable is
> about 0%, simply because I wouldn't believe I understand it.
>
> I think it needs to be broken up into smaller pieces, somehow. I don't
> know how, but perhaps it's possible to extract a "minimal feature"
> handling some limited subset of cases, and then gradually expand it?

I think there is serious complexity involved in just getting the basic 
the architecture. That being said, I am very open to any idea of a 
stepping stone to this.

It's something I thought a lot about during the last weeks. I am toying 
with the thought of ripping out all changes related to anything stored 
like views and sql functions as a minimal, minimal version. It's so bare 
bone, I'm not terribly exited about doing that, but maybe it's a 
necessary evil. Having thought about a lot of edge cases related to that 
storing, As I see it, we wouldn't save a lot of lines of code with this, 
but it makes reasoning about correctness easier. I can testament to the 
complexity in reasoning and correctness it adds. Do you have any other 
ideas for a stepping stone, that could reasonably land?

I think this one of the most fundamental questions to be answered, 
before we optimize one of the paths. What is the first consumer of the 
surface fact framework and how can we stage a minimal commit around it. 
We need a committer to feel save to commit something with this 
framework. If you have something notably smaller than this, I am all ears.

> Furthermore, it seems a lot of that file is duplicate with code we
> already have elsewhere. For example, couldn't it use a bunch of list
> functions instead of writing a local version?
>
>    list_contains_equal_node -> list_member
>    append_dependencies_unique -> list_concat_unique
>    append_dependency_unique -> list_append_unique
>    ...
>
> There may be more such cases, I haven't looked for them.
Thanks for bringing it up. I will look into that, once I've properly 
gone through the other suggestions. I suspect there won't be many line 
to save, but I'll have a look.
> 13) I think the main question is whether parse-analyze is the right
> place to handle this. I don't know. I assume one of the reasons for
> doing that is to get an error when defining a view, or when altering an
> object - e.g. like when dropping a function used by a view. Which seems
> reasonable, people would not like views silently broken by DDL.
>
> But it seems to be this also leads to a lot of code duplication, because
> the parse analysis now has to "reimplement" a lot of the stuff already
> done in later stages, after parse analyze. For example, we have the
> root->fkey_list thing, but that's not available yet.
>
> There's probably more such information - like innerrel_is_unique,
> rel_is_distinct_for, relation_has_unique_index_for etc.
>
> Maybe it's not worth it? What if we did this in the planner instead? How
> much simpler would it get? My intuition is it'd get much smaller, but
> maybe I'm wrong.
>
> It'd probably mean it's not necessary to expand views during parse,
> which was one of the problems mentioned at the beginning of this thread.
>
> I suppose we'd need to keep additional information in the plan to know
> which joins to check, etc.

If we want to use this for correctness and for proofs, we can't do it 
plan time. We have DDL-writes to store proofs. In it's nature this is a 
compile time feature.

Even if we don't include all of that in the first patch, this places 
very severe limitations like the DDL issue, which would be just tooo 
limiting to get to as an end place. I do think it's more reasonable how 
to cut this up into more digestible pieces.

Even though the planner is messy, I feel more at home there than here in 
the parser. I could see a world were we clean up some of those out of 
the planner to make them available earlier some time in parsing. We 
could push those variables forward to the planner to avoid redoing the 
work. Each of these steps would need a careful performance check and I 
am not sure how the real stepping stones would look like.

> 14) If we wanted to use some of this for the starjoin planning stuff,
> we'd need to propagate more information too, I think. But I'm not sure
> about this - we already have the information about foreign keys, so if
> key joins are tied to foreign keys, there would not be no new useful
> information I suppose.
>
> Plus, I don't want to make that patch dependent on people using new
> syntax. If that can give us *additional* information, that would be a
> different thing.

As I said above, if we figure out what information we want to propagate 
this will be helpful. We will be able to propagate this. And if we can 
get in a base patch with this architecture, adding this should be 
straight forward.

One thing, that sounds fairly simple to derive from our proof, is the 
proof graph. This means in every node we denote, this node can be 
treated as cardinality preserving joined onto a different node. For 
planning we need something more global, because we need to consider 
something WHERE/HAVING, but the existence of such a clause shouldn't be 
complex to check for.

> [...]
>
> 16) I was wondering what performance impact this has, roughly. So I
> created a simple 4-way join with fact + 3 dimensions:
>
>    create table dim1 (id serial primary key, val text);
>    create table dim2 (id serial primary key, val text);
>    create table dim3 (id serial primary key, val text);
>    create table f (id serial primary key,
>                    d1 int not null references dim1(id),
>                    d2 int not null references dim2(id),
>                    d3 int not null references dim3(id));
>
> and then measured throughput with 2 queries
>
>    select * from f
>             join dim1 on (dim1.id = d1)
>             join dim2 on (dim2.id = d2)
>             join dim3 on (dim3.id = d3);
>
>    select * from f
>             join dim1 for key (id) <- f (d1)
>             join dim2 for key (id) <- f (d2)
>             join dim3 for key (id) <- f (d3);
>
> which is the same query, except for the FOR KEY syntax. And I got this
> (under explain, to only do the planning):
>
>                patched    master
>    -----------------------------
>       join       25251     24906
>    keyjoin       17159
>
> So that's ~30% regression compared to master / regular joins. I also
> tried with join_collapse_limit=1 to eliminate the join order planning:
>
>                patched    master
>    -----------------------------
>       join       31476     31252
>    keyjoin       19353
>
> That's 40% regression. Of course, this is just explain - once there is
> data in the table, and actual execution, the differences will be much
> smaller. Still, it's not great.
>
> I haven't looked very closely, but based on some quick profiling it
> seems ensure_key_join_surface_facts / compute_key_join_relation_facts is
> doing expensive stuff like findNotNullConstraintAttnum or
> get_index_constraint, both of which do systable scans. That should
> probably go through syscache or something like that.
I'm not too surprised about ensure_key_join_surface_facts holding most 
of the regression, since it's doing all the work. Since I spent no time 
or thought on optimizing runtime thus far, I think there a lot of long 
hanging fruits left. I'd prefer to sort out the architecture first, but 
I think, we should be able to improve the parsetime of this feature by a 
sizeable amount. I fully agree: Before committing this, we should hit 
some of the relevant functions for a quick win.
> regards
>
I will probably work my way through this incrementally. I hope to have a 
version incorporating your vast feedback next week.

Regards
Arne








view thread (26+ messages)  latest in thread

reply

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Reply to all the recipients using the --to and --cc options:
  reply via email

  To: [email protected]
  Cc: [email protected], [email protected], [email protected], [email protected], [email protected], [email protected], [email protected]
  Subject: Re: Key joins
  In-Reply-To: <[email protected]>

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

This inbox is served by agora; see mirroring instructions
for how to clone and mirror all data and code used for this inbox