Thread
-
[SQL/PGQ] Early pruning for GRAPH_TABLE path generation
Junwang Zhao <zhjwpku@gmail.com> — 2026-05-09T07:25:03Z
Hi hackers, In [1], Satya proposed a PoC to limit the total number of GRAPH_TABLE path combinations to prevent memory exhaustion. While I agree that path explosion is a real concern, I do not think we should impose such a limit before generate_queries_for_path_pattern_recurse(). In many cases, most candidate paths can be eliminated later by generate_query_for_graph_path(). The current implementation has a different problem: generate_queries_for_path_pattern_recurse() first enumerates complete path combinations, and only later does generate_query_for_graph_path() check whether the selected edge elements actually connect the adjacent vertex elements. We can improve this by pruning infeasible path prefixes early during DFS. This patch adds an early feasibility check during DFS path construction. After appending a new path_element to the current prefix, we verify whether the prefix can still satisfy edge-vertex adjacency constraints: - if the new element is an edge, check it against any already-selected elements in the current prefix - if the new element is a vertex, check only the immediately preceding edge The second case is sufficient because repeated vertex variables are merged into a single path_factor before DFS begins. So appending a vertex only adds a new constraint to the edge directly before it in the factorized path. Any later edge referencing the same factor will be checked when that edge itself is appended. This does not change query generation semantics. It only avoids exploring full-length paths that would later be rejected by generate_query_for_graph_path(). Using the property graph from [1] CREATE temp TABLE v1 (id int PRIMARY KEY, val int); CREATE temp TABLE v2 (id int PRIMARY KEY, val int); CREATE temp TABLE v3 (id int PRIMARY KEY, val int); CREATE temp TABLE v4 (id int PRIMARY KEY, val int); CREATE temp TABLE v5 (id int PRIMARY KEY, val int); CREATE temp TABLE v6 (id int PRIMARY KEY, val int); CREATE temp TABLE v7 (id int PRIMARY KEY, val int); CREATE temp TABLE v8 (id int PRIMARY KEY, val int); CREATE temp TABLE e1 (id int PRIMARY KEY, src int, dest int); CREATE temp TABLE e2 (id int PRIMARY KEY, src int, dest int); CREATE temp TABLE e3 (id int PRIMARY KEY, src int, dest int); CREATE temp TABLE e4 (id int PRIMARY KEY, src int, dest int); CREATE temp TABLE e5 (id int PRIMARY KEY, src int, dest int); CREATE temp TABLE e6 (id int PRIMARY KEY, src int, dest int); CREATE temp TABLE e7 (id int PRIMARY KEY, src int, dest int); CREATE temp TABLE e8 (id int PRIMARY KEY, src int, dest int); CREATE PROPERTY GRAPH g5 VERTEX TABLES ( v1 LABEL vl PROPERTIES (id, val) LABEL vl2 PROPERTIES (id, val), v2 LABEL vl PROPERTIES (id, val), v3 LABEL vl PROPERTIES (id, val), v4 LABEL vl PROPERTIES (id, val), v5 LABEL vl PROPERTIES (id, val), v6 LABEL vl PROPERTIES (id, val), v7 LABEL vl PROPERTIES (id, val), v8 LABEL vl PROPERTIES (id, val) ) EDGE TABLES ( e1 SOURCE KEY (src) REFERENCES v1 (id) DESTINATION KEY (dest) REFERENCES v2 (id) LABEL el PROPERTIES (id), e2 SOURCE KEY (src) REFERENCES v2 (id) DESTINATION KEY (dest) REFERENCES v3 (id) LABEL el PROPERTIES (id), e3 SOURCE KEY (src) REFERENCES v3 (id) DESTINATION KEY (dest) REFERENCES v4 (id) LABEL el PROPERTIES (id), e4 SOURCE KEY (src) REFERENCES v4 (id) DESTINATION KEY (dest) REFERENCES v5 (id) LABEL el PROPERTIES (id), e5 SOURCE KEY (src) REFERENCES v5 (id) DESTINATION KEY (dest) REFERENCES v6 (id) LABEL el PROPERTIES (id), e6 SOURCE KEY (src) REFERENCES v6 (id) DESTINATION KEY (dest) REFERENCES v7 (id) LABEL el PROPERTIES (id), e7 SOURCE KEY (src) REFERENCES v7 (id) DESTINATION KEY (dest) REFERENCES v8 (id) LABEL el PROPERTIES (id), e8 SOURCE KEY (src) REFERENCES v8 (id) DESTINATION KEY (dest) REFERENCES v1 (id) LABEL el PROPERTIES (id) ); SELECT count(*) FROM GRAPH_TABLE (g5 MATCH (a IS vl2)-[e1]->(b)-[e2]->(c)-[e3]->(d)-[e4]->(e) COLUMNS (a.id AS aid)); head: # SELECT count(*) FROM GRAPH_TABLE (g5 MATCH (a IS vl2)-[e1]->(b)-[e2]->(c)-[e3]->(d)-[e4]->(e) COLUMNS (a.id AS aid)); count ------- 0 (1 row) Time: 5388.629 ms (00:05.389) # SELECT count(*) FROM GRAPH_TABLE (g5 MATCH (a IS vl2)-[e1]->(b)-[e2]->(c)-[e3]->(d)-[e4]->(e) COLUMNS (a.id AS aid)); count ------- 0 (1 row) Time: 5195.827 ms (00:05.196) patched: [local] zjw@postgres:5432-5924=# SELECT count(*) FROM GRAPH_TABLE (g5 MATCH (a IS vl2)-[e1]->(b)-[e2]->(c)-[e3]->(d)-[e4]->(e) COLUMNS (a.id AS aid)); count ------- 0 (1 row) Time: 11.827 ms [local] zjw@postgres:5432-5924=# SELECT count(*) FROM GRAPH_TABLE (g5 MATCH (a IS vl2)-[e1]->(b)-[e2]->(c)-[e3]->(d)-[e4]->(e) COLUMNS (a.id AS aid)); count ------- 0 (1 row) Time: 2.924 ms As you can see, the results are quite promising. Unfortunately, I cannot attach the patch to this email because of company security policy restrictions (I will do so once I get my own Mac). If you are interested, please check [2] on GitHub. [1] https://www.postgresql.org/message-id/flat/CAHg%2BQDfDwcM4%3DDSiAV6Ly89YQ5EcMhzO1-9x%3DmGG1WJzODcAig%40mail.gmail.com [2] https://github.com/zhjwpku/postgres/commit/bdc1c7ea9729285f105e650c0f5bd032802a85d8 -- Regards Junwang Zhao -
Re: [SQL/PGQ] Early pruning for GRAPH_TABLE path generation
Henson Choi <assam258@gmail.com> — 2026-05-09T14:52:16Z
Hi Junwang, Thanks for an interesting thread, and for picking this up — it has been a useful occasion to re-examine assumptions I had carried over from a different graph model. My background is on AgensGraph, where labels form an inheritance hierarchy (rather than Neo4j-style multi-label tag sets), so a path pattern rewrites against a single parent table per position and the fan-out across labels is left to the planner's existing inheritance machinery. The rewriter never enumerates per-element combinations, so an N^K blow-up like Satya's was simply not on the radar there. SQL/PGQ instead designates graph identity through the schema itself, so each pattern position carries a set of candidate element tables and the rewriter has to materialize each schema-feasible join combination — a (vertex, edge, vertex, ...) sequence of element tables — as its own SELECT branch, with all such branches joined by UNION ALL. The number of *feasible* join combinations is bounded by the schema graph's connectivity and is usually small; the current rewriter's naive shape, however, is to enumerate the full N^K candidate space first and reject infeasible combinations only afterwards. The patch tackles exactly that part. To be clear, I do not mean this as a criticism of the original implementer. For sizeable features I tend to prefer a deliberately naive implementation first — getting the full functionality working end-to-end before optimizing — and that approach is even more valuable in team settings, since a working baseline lets additional contributors join much earlier. The current shape of generate_queries_for_path_pattern_recurse() reads exactly like such a baseline, and this patch is precisely the kind of focused follow-up improvement it invites. When I thought about this independently, I had reached for the dual formulation — "extend the prefix only along feasible edges" rather than "enumerate everything and prune" — and my intuition was that the two should produce the same surviving set, though I have not worked that through rigorously. Either way, your "early pruning" framing is the better one for the patch itself: it stays close to the existing code structure and lets the change read as a small refinement of generate_queries_for_path_pattern_recurse() rather than a reorganization of how candidates are walked. That makes review and incremental landing materially easier. The same reframing also explains, after the fact, why the discrepancy between Ashutosh's quick runs and Satya's 81 GB case was so large: the cost being measured is in large part infeasible enumeration, which a forward-moved check eliminates regardless of pattern length or schema size in realistic graphs. I have not gone through the patch at the code level yet, but the shape of the problem and of your fix is clear enough from the description. My personal take, with that caveat, is that early pruning, together with the CHECK_FOR_INTERRUPTS that Satya already proposed in the earlier thread [1], feels like the right pair to land first; the explicit hard limit, in turn, is probably best revisited only if a real case turns up where even those two combined are not enough. That keeps the immediate change narrow and semantics-preserving while leaving the policy question open for evidence rather than upfront estimation. There is a fairly recent precedent in PostgreSQL itself that I think supports this ordering. I have been following — and participating in — Tatsuo Ishii's Row Pattern Recognition thread [2] since earlier this year, and that work ran into a structurally similar concern: in its earlier implementation the engine could undergo a combinatorial explosion of pattern-candidate states. A memory-based limit was floated as one mitigation, but it never actually converged into a design — the conversation around an explicit limit simply faded out as a layered set of pruning and early-termination rules accumulated incrementally and brought the state space back under control. CFI and stack-depth checks, by contrast, were treated as independently necessary throughout and stayed in the design. The shape of this thread feels analogous, and the same staged approach (structural fix + CFI first, hard limit only if a residual case genuinely demands it) is, I suspect, what will hold up here as well. I'll set aside time separately to do a proper line-by-line review and follow up. Regards, Henson [1] https://www.postgresql.org/message-id/CAHg%2BQDe8JU%2BERqA2xwjrg_ZptvH_v0T6PS9_P_ZgyYzD5h-Grw%40mail.gmail.com [2] https://www.postgresql.org/message-id/flat/20230625.210509.1276733411677577841.t-ishii%40sranhm.sra.co.jp
-
Re: [SQL/PGQ] Early pruning for GRAPH_TABLE path generation
Junwang Zhao <zhjwpku@gmail.com> — 2026-05-12T00:57:09Z
Hi Henson, On Sat, May 9, 2026 at 10:52 PM Henson Choi <assam258@gmail.com> wrote: > > Hi Junwang, > > Thanks for an interesting thread, and for picking this up — it has > been a useful occasion to re-examine assumptions I had carried over > from a different graph model. > > My background is on AgensGraph, where labels form an inheritance > hierarchy (rather than Neo4j-style multi-label tag sets), so a path > pattern rewrites against a single parent table per position and the > fan-out across labels is left to the planner's existing inheritance > machinery. The rewriter never enumerates per-element combinations, so > an N^K blow-up like Satya's was simply not on the radar there. Thanks for the input, good to know. > > SQL/PGQ instead designates graph identity through the schema itself, > so each pattern position carries a set of candidate element tables > and the rewriter has to materialize each schema-feasible join > combination — a (vertex, edge, vertex, ...) sequence of element > tables — as its own SELECT branch, with all such branches joined by > UNION ALL. The number of *feasible* join combinations is bounded by > the schema graph's connectivity and is usually small; the current > rewriter's naive shape, however, is to enumerate the full N^K > candidate space first and reject infeasible combinations only > afterwards. The patch tackles exactly that part. > > To be clear, I do not mean this as a criticism of the original > implementer. For sizeable features I tend to prefer a deliberately > naive implementation first — getting the full functionality working > end-to-end before optimizing — and that approach is even more > valuable in team settings, since a working baseline lets additional > contributors join much earlier. The current shape of > generate_queries_for_path_pattern_recurse() reads exactly like such a > baseline, and this patch is precisely the kind of focused follow-up > improvement it invites. > > When I thought about this independently, I had reached for the dual > formulation — "extend the prefix only along feasible edges" rather > than "enumerate everything and prune" — and my intuition was that the > two should produce the same surviving set, though I have not worked > that through rigorously. Either way, your "early pruning" framing is > the better one for the patch itself: it stays close to the existing > code structure and lets the change read as a small refinement of > generate_queries_for_path_pattern_recurse() rather than a > reorganization of how candidates are walked. That makes review and > incremental landing materially easier. > > The same reframing also explains, after the fact, why the discrepancy > between Ashutosh's quick runs and Satya's 81 GB case was so large: > the cost being measured is in large part infeasible enumeration, > which a forward-moved check eliminates regardless of pattern length > or schema size in realistic graphs. > > I have not gone through the patch at the code level yet, but the > shape of the problem and of your fix is clear enough from the > description. My personal take, with that caveat, is that early > pruning, together with the CHECK_FOR_INTERRUPTS that Satya already > proposed in the earlier thread [1], feels like the right pair to > land first; the explicit hard limit, in turn, is probably best > revisited only if a real case turns up where even those two combined > are not enough. That keeps the immediate change narrow and > semantics-preserving while leaving the policy question open for > evidence rather than upfront estimation. Agreed, I add a CHECK_FOR_INTERRUPTS to the patch now, see the attached. > > There is a fairly recent precedent in PostgreSQL itself that I think > supports this ordering. I have been following — and participating in > — Tatsuo Ishii's Row Pattern Recognition thread [2] since earlier > this year, and that work ran into a structurally similar concern: in > its earlier implementation the engine could undergo a combinatorial > explosion of pattern-candidate states. A memory-based limit was > floated as one mitigation, but it never actually converged into a > design — the conversation around an explicit limit simply faded out > as a layered set of pruning and early-termination rules accumulated > incrementally and brought the state space back under control. CFI > and stack-depth checks, by contrast, were treated as independently > necessary throughout and stayed in the design. > > The shape of this thread feels analogous, and the same staged > approach (structural fix + CFI first, hard limit only if a residual > case genuinely demands it) is, I suspect, what will hold up > here as well. Thanks for the insightful analysis, it's extremely helpful. > > I'll set aside time separately to do a proper line-by-line review and > follow up. > > Regards, > Henson > > [1] https://www.postgresql.org/message-id/CAHg%2BQDe8JU%2BERqA2xwjrg_ZptvH_v0T6PS9_P_ZgyYzD5h-Grw%40mail.gmail.com > [2] https://www.postgresql.org/message-id/flat/20230625.210509.1276733411677577841.t-ishii%40sranhm.sra.co.jp -- Regards Junwang Zhao
-
Re: [SQL/PGQ] Early pruning for GRAPH_TABLE path generation
Henson Choi <assam258@gmail.com> — 2026-05-12T10:28:02Z
Hi Junwang, Thanks for the v1 patch -- I walked through it carefully. The direction holds up well; the notes below are non-blocking, with one concrete request and a handful of nits. ## On the approach "Enumerate and prune early" is the right shape for a first cut: the DFS keeps its existing structure, and we just stop extending a prefix as soon as the newly appended element makes adjacency impossible. Label/kind filtering already keeps slot sizes small in typical cases, so a structural fix at the DFS boundary captures the remaining blow-up without reshuffling the candidate set itself. Glad to see CHECK_FOR_INTERRUPTS folded in alongside the structural fix -- that pairing alone should be enough; a hard limit only needs to come back if a residual case demands it. ## On correctness The DFS is a nested loop: outer picks a vertex for the vertex slot, inner picks an edge for the adjacent edge slot. The new helper just compares vertex.elemoid against edge.srcvertexid/destvertexid -- both catalog-derived, fixed before the DFS starts. The pre-patch code evaluates the same equality at the end of the full N x M x ... expansion (via edge_qual == NULL in generate_query_for_graph_path); the patch hoists it into the inner loop body. Same equality, same surviving set. Direction handling is preserved the same way -- reverse_ok starts true only for EDGE_PATTERN_ANY, so the undirected case keeps its src<->dest cross comparison. The patch is also stack-neutral: both pre-existing recursive functions already carry check_stack_depth() at entry (generate_queries_for_path_pattern_recurse, generate_setop_from_pathqueries), and the new helpers are non-recursive. ## One request: regression test Three cases would together cover the new code paths well: 1. A long chain pattern that survives pruning but used to enumerate the full N^K product (the g5 chain from your benchmark is a natural fit). Not currently covered in src/test/regress/sql/graph_table.sql -- worth adding. 2. A cyclic variant whose closing edge has both endpoints already in the prefix -- this is the only path that fires both if-blocks of graph_path_edge_is_feasible() simultaneously. Already covered by the existing `(a)-[b]->(a)-[b]->(a)` tests in graph_table.sql, since same-name vertex patterns are merged into a single path factor before DFS; worth noting in the commit message rather than adding a new test. 3. A pattern where an edge slot has all its candidates pruned -- to cover the empty-pathqueries route into generate_query_for_empty_path_pattern(). Already covered by the reversed-direction `MATCH (o IS orders)-[IS customer_orders]-> (c IS customers)` test in graph_table.sql, which yields zero rows because no edge in the catalog satisfies that adjacency. Same note: worth flagging as newly reachable via pruning. ## Nits (all stylistic -- feel free to skip) 1. In graph_path_prefix_is_feasible_with_new_element(), the `if (!IS_EDGE_PATTERN(prev_pe->path_factor->kind)) return true;` branch is unreachable -- gram.y enforces V-E alternation, and the path-factor linkage code in generate_queries_for_path_pattern() re-asserts it. Tightening to `Assert(IS_EDGE_PATTERN(...))` would match the rest of the file. 2. `normal_ok` / `reverse_ok` reads slightly off from this file's diction. Consider `feasible` / `rev_feasible` -- the asymmetric `rev_` prefix matches the existing `edge_qual` / `rev_edge_qual` in generate_query_for_graph_path(), and echoes the function name itself. 3. A one-line comment above the V-E alternation check (whether kept as `if` or tightened per nit 1) citing parse analysis would save the next reader a trip through gram.y. 4. In the prune branch of generate_queries_for_path_pattern_recurse(), a one-liner noting that an exhausted level returns an empty list, which the caller routes to generate_query_for_empty_path_pattern(), would help -- pre-existing behavior, but far more reachable after the patch. ## On the trailing edge_qual guard The `if (edge_qual == NULL) return NULL;` in generate_query_for_graph_path() becomes unreachable after the patch, but worth keeping as a defensive backstop -- it is the literal dual of the helper's check, and removing it would only tighten coupling for no measurable gain. A short comment marking it as such would help. Regards, Henson 2026년 5월 12일 (화) 오전 9:57, Junwang Zhao <zhjwpku@gmail.com>님이 작성: > Hi Henson, > > On Sat, May 9, 2026 at 10:52 PM Henson Choi <assam258@gmail.com> wrote: > > > > Hi Junwang, > > > > Thanks for an interesting thread, and for picking this up — it has > > been a useful occasion to re-examine assumptions I had carried over > > from a different graph model. > > > > My background is on AgensGraph, where labels form an inheritance > > hierarchy (rather than Neo4j-style multi-label tag sets), so a path > > pattern rewrites against a single parent table per position and the > > fan-out across labels is left to the planner's existing inheritance > > machinery. The rewriter never enumerates per-element combinations, so > > an N^K blow-up like Satya's was simply not on the radar there. > > Thanks for the input, good to know. > > > > > SQL/PGQ instead designates graph identity through the schema itself, > > so each pattern position carries a set of candidate element tables > > and the rewriter has to materialize each schema-feasible join > > combination — a (vertex, edge, vertex, ...) sequence of element > > tables — as its own SELECT branch, with all such branches joined by > > UNION ALL. The number of *feasible* join combinations is bounded by > > the schema graph's connectivity and is usually small; the current > > rewriter's naive shape, however, is to enumerate the full N^K > > candidate space first and reject infeasible combinations only > > afterwards. The patch tackles exactly that part. > > > > To be clear, I do not mean this as a criticism of the original > > implementer. For sizeable features I tend to prefer a deliberately > > naive implementation first — getting the full functionality working > > end-to-end before optimizing — and that approach is even more > > valuable in team settings, since a working baseline lets additional > > contributors join much earlier. The current shape of > > generate_queries_for_path_pattern_recurse() reads exactly like such a > > baseline, and this patch is precisely the kind of focused follow-up > > improvement it invites. > > > > When I thought about this independently, I had reached for the dual > > formulation — "extend the prefix only along feasible edges" rather > > than "enumerate everything and prune" — and my intuition was that the > > two should produce the same surviving set, though I have not worked > > that through rigorously. Either way, your "early pruning" framing is > > the better one for the patch itself: it stays close to the existing > > code structure and lets the change read as a small refinement of > > generate_queries_for_path_pattern_recurse() rather than a > > reorganization of how candidates are walked. That makes review and > > incremental landing materially easier. > > > > The same reframing also explains, after the fact, why the discrepancy > > between Ashutosh's quick runs and Satya's 81 GB case was so large: > > the cost being measured is in large part infeasible enumeration, > > which a forward-moved check eliminates regardless of pattern length > > or schema size in realistic graphs. > > > > I have not gone through the patch at the code level yet, but the > > shape of the problem and of your fix is clear enough from the > > description. My personal take, with that caveat, is that early > > pruning, together with the CHECK_FOR_INTERRUPTS that Satya already > > proposed in the earlier thread [1], feels like the right pair to > > land first; the explicit hard limit, in turn, is probably best > > revisited only if a real case turns up where even those two combined > > are not enough. That keeps the immediate change narrow and > > semantics-preserving while leaving the policy question open for > > evidence rather than upfront estimation. > > Agreed, I add a CHECK_FOR_INTERRUPTS to the patch now, see > the attached. > > > > > There is a fairly recent precedent in PostgreSQL itself that I think > > supports this ordering. I have been following — and participating in > > — Tatsuo Ishii's Row Pattern Recognition thread [2] since earlier > > this year, and that work ran into a structurally similar concern: in > > its earlier implementation the engine could undergo a combinatorial > > explosion of pattern-candidate states. A memory-based limit was > > floated as one mitigation, but it never actually converged into a > > design — the conversation around an explicit limit simply faded out > > as a layered set of pruning and early-termination rules accumulated > > incrementally and brought the state space back under control. CFI > > and stack-depth checks, by contrast, were treated as independently > > necessary throughout and stayed in the design. > > > > The shape of this thread feels analogous, and the same staged > > approach (structural fix + CFI first, hard limit only if a residual > > case genuinely demands it) is, I suspect, what will hold up > > here as well. > > Thanks for the insightful analysis, it's extremely helpful. > > > > > I'll set aside time separately to do a proper line-by-line review and > > follow up. > > > > Regards, > > Henson > > > > [1] > https://www.postgresql.org/message-id/CAHg%2BQDe8JU%2BERqA2xwjrg_ZptvH_v0T6PS9_P_ZgyYzD5h-Grw%40mail.gmail.com > > [2] > https://www.postgresql.org/message-id/flat/20230625.210509.1276733411677577841.t-ishii%40sranhm.sra.co.jp > > > > -- > Regards > Junwang Zhao >
-
Re: [SQL/PGQ] Early pruning for GRAPH_TABLE path generation
Junwang Zhao <zhjwpku@gmail.com> — 2026-05-13T14:20:01Z
Hi Henson, On Tue, May 12, 2026 at 6:28 PM Henson Choi <assam258@gmail.com> wrote: > > Hi Junwang, > > Thanks for the v1 patch -- I walked through it carefully. The > direction holds up well; the notes below are non-blocking, with one > concrete request and a handful of nits. > > ## On the approach > > "Enumerate and prune early" is the right shape for a first cut: > the DFS keeps its existing structure, and we just stop extending a > prefix as soon as the newly appended element makes adjacency > impossible. Label/kind filtering already keeps slot sizes small in > typical cases, so a structural fix at the DFS boundary captures the > remaining blow-up without reshuffling the candidate set itself. > > Glad to see CHECK_FOR_INTERRUPTS folded in alongside the structural > fix -- that pairing alone should be enough; a hard limit only needs > to come back if a residual case demands it. > > ## On correctness > > The DFS is a nested loop: outer picks a vertex for the vertex slot, > inner picks an edge for the adjacent edge slot. The new helper just > compares vertex.elemoid against edge.srcvertexid/destvertexid -- both > catalog-derived, fixed before the DFS starts. The pre-patch code > evaluates the same equality at the end of the full N x M x ... > expansion (via edge_qual == NULL in generate_query_for_graph_path); > the patch hoists it into the inner loop body. Same equality, same > surviving set. Direction handling is preserved the same way -- > reverse_ok starts true only for EDGE_PATTERN_ANY, so the undirected > case keeps its src<->dest cross comparison. > > The patch is also stack-neutral: both pre-existing recursive > functions already carry check_stack_depth() at entry > (generate_queries_for_path_pattern_recurse, > generate_setop_from_pathqueries), and the new helpers are > non-recursive. Thanks for the correctness analysis. > > ## One request: regression test > > Three cases would together cover the new code paths well: > > 1. A long chain pattern that survives pruning but used to enumerate > the full N^K product (the g5 chain from your benchmark is a > natural fit). Not currently covered in > src/test/regress/sql/graph_table.sql -- worth adding. Added as v2-0002. > > 2. A cyclic variant whose closing edge has both endpoints already > in the prefix -- this is the only path that fires both if-blocks > of graph_path_edge_is_feasible() simultaneously. Already covered > by the existing `(a)-[b]->(a)-[b]->(a)` tests in graph_table.sql, > since same-name vertex patterns are merged into a single path > factor before DFS; worth noting in the commit message rather > than adding a new test. > > 3. A pattern where an edge slot has all its candidates pruned -- > to cover the empty-pathqueries route into > generate_query_for_empty_path_pattern(). Already covered by the > reversed-direction `MATCH (o IS orders)-[IS customer_orders]-> > (c IS customers)` test in graph_table.sql, which yields zero > rows because no edge in the catalog satisfies that adjacency. > Same note: worth flagging as newly reachable via pruning. I added the following paragraph to the commit message, feel free to recommend wording. The cyclic case where a closing edge has both endpoints already in the prefix is already exercised by the existing same-variable loop patterns in graph_table.sql (e.g. (a)-[b]->(a)-[b]->(a)), because repeated vertex names are merged into a single path factor before DFS. Likewise, the "all edge candidates pruned" path into generate_query_for_empty_path_pattern() is already hit by the MATCH (o IS orders)-[IS customer_orders]->(c IS customers) case, where no catalog edge matches the declared direction; pruning just makes those branches easier to reach. Neither case needs a dedicated new test beyond what is already there. > > ## Nits (all stylistic -- feel free to skip) > > 1. In graph_path_prefix_is_feasible_with_new_element(), the > `if (!IS_EDGE_PATTERN(prev_pe->path_factor->kind)) return true;` > branch is unreachable -- gram.y enforces V-E alternation, and > the path-factor linkage code in > generate_queries_for_path_pattern() re-asserts it. Tightening > to `Assert(IS_EDGE_PATTERN(...))` would match the rest of the > file. Yeah, for a pattern such as (a)-[e1]-(b)-[e2]-(a)-[e3]-(c), the path_factors is V(a), E(e1), V(b), E(e2), E(e3), V(c), though not totally V-E alternation, for a Vertex, we can sure that the prev_pe is always Edge, so changed to Assert. > > 2. `normal_ok` / `reverse_ok` reads slightly off from this file's > diction. Consider `feasible` / `rev_feasible` -- the asymmetric > `rev_` prefix matches the existing `edge_qual` / `rev_edge_qual` > in generate_query_for_graph_path(), and echoes the function name > itself. Changed. > > 3. A one-line comment above the V-E alternation check (whether > kept as `if` or tightened per nit 1) citing parse analysis would > save the next reader a trip through gram.y. I added the following, feel free to suggest wording. /* * Merged duplicate vertices only drop redundant factors from path_factors, * not from the DFS path; preceding slot is always an edge for a vertex. */ > > 4. In the prune branch of generate_queries_for_path_pattern_recurse(), > a one-liner noting that an exhausted level returns an empty list, > which the caller routes to generate_query_for_empty_path_pattern(), > would help -- pre-existing behavior, but far more reachable after > the patch. Added, feel free to suggest wording. > > ## On the trailing edge_qual guard > > The `if (edge_qual == NULL) return NULL;` in > generate_query_for_graph_path() becomes unreachable after the patch, > but worth keeping as a defensive backstop -- it is the literal dual > of the helper's check, and removing it would only tighten coupling > for no measurable gain. A short comment marking it as such would help. Added. > > Regards, > Henson > > 2026년 5월 12일 (화) 오전 9:57, Junwang Zhao <zhjwpku@gmail.com>님이 작성: >> >> Hi Henson, >> >> On Sat, May 9, 2026 at 10:52 PM Henson Choi <assam258@gmail.com> wrote: >> > >> > Hi Junwang, >> > >> > Thanks for an interesting thread, and for picking this up — it has >> > been a useful occasion to re-examine assumptions I had carried over >> > from a different graph model. >> > >> > My background is on AgensGraph, where labels form an inheritance >> > hierarchy (rather than Neo4j-style multi-label tag sets), so a path >> > pattern rewrites against a single parent table per position and the >> > fan-out across labels is left to the planner's existing inheritance >> > machinery. The rewriter never enumerates per-element combinations, so >> > an N^K blow-up like Satya's was simply not on the radar there. >> >> Thanks for the input, good to know. >> >> > >> > SQL/PGQ instead designates graph identity through the schema itself, >> > so each pattern position carries a set of candidate element tables >> > and the rewriter has to materialize each schema-feasible join >> > combination — a (vertex, edge, vertex, ...) sequence of element >> > tables — as its own SELECT branch, with all such branches joined by >> > UNION ALL. The number of *feasible* join combinations is bounded by >> > the schema graph's connectivity and is usually small; the current >> > rewriter's naive shape, however, is to enumerate the full N^K >> > candidate space first and reject infeasible combinations only >> > afterwards. The patch tackles exactly that part. >> > >> > To be clear, I do not mean this as a criticism of the original >> > implementer. For sizeable features I tend to prefer a deliberately >> > naive implementation first — getting the full functionality working >> > end-to-end before optimizing — and that approach is even more >> > valuable in team settings, since a working baseline lets additional >> > contributors join much earlier. The current shape of >> > generate_queries_for_path_pattern_recurse() reads exactly like such a >> > baseline, and this patch is precisely the kind of focused follow-up >> > improvement it invites. >> > >> > When I thought about this independently, I had reached for the dual >> > formulation — "extend the prefix only along feasible edges" rather >> > than "enumerate everything and prune" — and my intuition was that the >> > two should produce the same surviving set, though I have not worked >> > that through rigorously. Either way, your "early pruning" framing is >> > the better one for the patch itself: it stays close to the existing >> > code structure and lets the change read as a small refinement of >> > generate_queries_for_path_pattern_recurse() rather than a >> > reorganization of how candidates are walked. That makes review and >> > incremental landing materially easier. >> > >> > The same reframing also explains, after the fact, why the discrepancy >> > between Ashutosh's quick runs and Satya's 81 GB case was so large: >> > the cost being measured is in large part infeasible enumeration, >> > which a forward-moved check eliminates regardless of pattern length >> > or schema size in realistic graphs. >> > >> > I have not gone through the patch at the code level yet, but the >> > shape of the problem and of your fix is clear enough from the >> > description. My personal take, with that caveat, is that early >> > pruning, together with the CHECK_FOR_INTERRUPTS that Satya already >> > proposed in the earlier thread [1], feels like the right pair to >> > land first; the explicit hard limit, in turn, is probably best >> > revisited only if a real case turns up where even those two combined >> > are not enough. That keeps the immediate change narrow and >> > semantics-preserving while leaving the policy question open for >> > evidence rather than upfront estimation. >> >> Agreed, I add a CHECK_FOR_INTERRUPTS to the patch now, see >> the attached. >> >> > >> > There is a fairly recent precedent in PostgreSQL itself that I think >> > supports this ordering. I have been following — and participating in >> > — Tatsuo Ishii's Row Pattern Recognition thread [2] since earlier >> > this year, and that work ran into a structurally similar concern: in >> > its earlier implementation the engine could undergo a combinatorial >> > explosion of pattern-candidate states. A memory-based limit was >> > floated as one mitigation, but it never actually converged into a >> > design — the conversation around an explicit limit simply faded out >> > as a layered set of pruning and early-termination rules accumulated >> > incrementally and brought the state space back under control. CFI >> > and stack-depth checks, by contrast, were treated as independently >> > necessary throughout and stayed in the design. >> > >> > The shape of this thread feels analogous, and the same staged >> > approach (structural fix + CFI first, hard limit only if a residual >> > case genuinely demands it) is, I suspect, what will hold up >> > here as well. >> >> Thanks for the insightful analysis, it's extremely helpful. >> >> > >> > I'll set aside time separately to do a proper line-by-line review and >> > follow up. >> > >> > Regards, >> > Henson >> > >> > [1] https://www.postgresql.org/message-id/CAHg%2BQDe8JU%2BERqA2xwjrg_ZptvH_v0T6PS9_P_ZgyYzD5h-Grw%40mail.gmail.com >> > [2] https://www.postgresql.org/message-id/flat/20230625.210509.1276733411677577841.t-ishii%40sranhm.sra.co.jp >> >> >> >> -- >> Regards >> Junwang Zhao -- Regards Junwang Zhao -
Re: [SQL/PGQ] Early pruning for GRAPH_TABLE path generation
Henson Choi <assam258@gmail.com> — 2026-05-13T16:32:25Z
Hi Junwang, Thanks for the quick turnaround on v2. Walked through it against the prior notes -- everything reads well; one small thing on 0002 to fix before the next spin, inline below. > 1. A long chain pattern that survives pruning but used to enumerate > > the full N^K product (the g5 chain from your benchmark is a > > natural fit). Not currently covered in > > src/test/regress/sql/graph_table.sql -- worth adding. > > Added as v2-0002. > The g5 chain is a good fit. One nit: the comment above the SELECT has a U+2192 arrow in both the .sql and the .out -- unless a regress case is specifically exercising encoding, these stay ASCII-only, so please replace with a plain `->` in both files. > I added the following paragraph to the commit message, feel free to > recommend wording. > > The cyclic case where a closing edge has both endpoints already > in the prefix is already exercised by the existing same-variable > loop patterns in graph_table.sql (e.g. (a)-[b]->(a)-[b]->(a)), > because repeated vertex names are merged into a single path factor > before DFS. Likewise, the "all edge candidates pruned" path into > generate_query_for_empty_path_pattern() is already hit by the > MATCH (o IS orders)-[IS customer_orders]->(c IS customers) case, > where no catalog edge matches the declared direction; pruning just > makes those branches easier to reach. Neither case needs a > dedicated new test beyond what is already there. > Good. > > > > ## Nits (all stylistic -- feel free to skip) > > > > 1. In graph_path_prefix_is_feasible_with_new_element(), the > > `if (!IS_EDGE_PATTERN(prev_pe->path_factor->kind)) return true;` > > branch is unreachable -- gram.y enforces V-E alternation, and > > the path-factor linkage code in > > generate_queries_for_path_pattern() re-asserts it. Tightening > > to `Assert(IS_EDGE_PATTERN(...))` would match the rest of the > > file. > > Yeah, for a pattern such as (a)-[e1]-(b)-[e2]-(a)-[e3]-(c), the > path_factors > is V(a), E(e1), V(b), E(e2), E(e3), V(c), though not totally V-E > alternation, > for a Vertex, we can sure that the prev_pe is always Edge, so changed > to Assert. > Thanks -- I hadn't realized path_factors isn't strictly V-E alternating; your E(e2), E(e3) example pins down the actual invariant the Assert relies on. > > > > 2. `normal_ok` / `reverse_ok` reads slightly off from this file's > > diction. Consider `feasible` / `rev_feasible` -- the asymmetric > > `rev_` prefix matches the existing `edge_qual` / `rev_edge_qual` > > in generate_query_for_graph_path(), and echoes the function name > > itself. > > Changed. > > > > > 3. A one-line comment above the V-E alternation check (whether > > kept as `if` or tightened per nit 1) citing parse analysis would > > save the next reader a trip through gram.y. > > I added the following, feel free to suggest wording. > > /* > * Merged duplicate vertices only drop redundant factors from path_factors, > * not from the DFS path; preceding slot is always an edge for a vertex. > */ > +1, and actually a better framing than the gram.y pointer I originally suggested -- this is the invariant the Assert relies on. > > > > > 4. In the prune branch of generate_queries_for_path_pattern_recurse(), > > a one-liner noting that an exhausted level returns an empty list, > > which the caller routes to generate_query_for_empty_path_pattern(), > > would help -- pre-existing behavior, but far more reachable after > > the patch. > > Added, feel free to suggest wording. > Good. > > > > > ## On the trailing edge_qual guard > > > > The `if (edge_qual == NULL) return NULL;` in > > generate_query_for_graph_path() becomes unreachable after the patch, > > but worth keeping as a defensive backstop -- it is the literal dual > > of the helper's check, and removing it would only tighten coupling > > for no measurable gain. A short comment marking it as such would help. > > Added. > Thanks. With the ASCII fix in 0002, no further comments from my side. Regards, Henson
-
Re: [SQL/PGQ] Early pruning for GRAPH_TABLE path generation
Junwang Zhao <zhjwpku@gmail.com> — 2026-05-14T00:38:41Z
Hi Henson, On Thu, May 14, 2026 at 12:32 AM Henson Choi <assam258@gmail.com> wrote: > > Hi Junwang, > > Thanks for the quick turnaround on v2. Walked through it against the > prior notes -- everything reads well; one small thing on 0002 to fix > before the next spin, inline below. > >> > 1. A long chain pattern that survives pruning but used to enumerate >> > the full N^K product (the g5 chain from your benchmark is a >> > natural fit). Not currently covered in >> > src/test/regress/sql/graph_table.sql -- worth adding. >> >> Added as v2-0002. > > > The g5 chain is a good fit. One nit: the comment above the SELECT > has a U+2192 arrow in both the .sql and the .out -- unless a regress > case is specifically exercising encoding, these stay ASCII-only, so > please replace with a plain `->` in both files. Agreed, I've applied the change in my machine, will wait for some more comments and send a new version together later. > >> >> I added the following paragraph to the commit message, feel free to >> recommend wording. >> >> The cyclic case where a closing edge has both endpoints already >> in the prefix is already exercised by the existing same-variable >> loop patterns in graph_table.sql (e.g. (a)-[b]->(a)-[b]->(a)), >> because repeated vertex names are merged into a single path factor >> before DFS. Likewise, the "all edge candidates pruned" path into >> generate_query_for_empty_path_pattern() is already hit by the >> MATCH (o IS orders)-[IS customer_orders]->(c IS customers) case, >> where no catalog edge matches the declared direction; pruning just >> makes those branches easier to reach. Neither case needs a >> dedicated new test beyond what is already there. > > > Good. > >> >> > >> > ## Nits (all stylistic -- feel free to skip) >> > >> > 1. In graph_path_prefix_is_feasible_with_new_element(), the >> > `if (!IS_EDGE_PATTERN(prev_pe->path_factor->kind)) return true;` >> > branch is unreachable -- gram.y enforces V-E alternation, and >> > the path-factor linkage code in >> > generate_queries_for_path_pattern() re-asserts it. Tightening >> > to `Assert(IS_EDGE_PATTERN(...))` would match the rest of the >> > file. >> >> Yeah, for a pattern such as (a)-[e1]-(b)-[e2]-(a)-[e3]-(c), the path_factors >> is V(a), E(e1), V(b), E(e2), E(e3), V(c), though not totally V-E alternation, >> for a Vertex, we can sure that the prev_pe is always Edge, so changed >> to Assert. > > > Thanks -- I hadn't realized path_factors isn't strictly V-E > alternating; your E(e2), E(e3) example pins down the actual > invariant the Assert relies on. > >> >> > >> > 2. `normal_ok` / `reverse_ok` reads slightly off from this file's >> > diction. Consider `feasible` / `rev_feasible` -- the asymmetric >> > `rev_` prefix matches the existing `edge_qual` / `rev_edge_qual` >> > in generate_query_for_graph_path(), and echoes the function name >> > itself. >> >> Changed. >> >> > >> > 3. A one-line comment above the V-E alternation check (whether >> > kept as `if` or tightened per nit 1) citing parse analysis would >> > save the next reader a trip through gram.y. >> >> I added the following, feel free to suggest wording. >> >> /* >> * Merged duplicate vertices only drop redundant factors from path_factors, >> * not from the DFS path; preceding slot is always an edge for a vertex. >> */ > > > +1, and actually a better framing than the gram.y pointer I > originally suggested -- this is the invariant the Assert relies on. > >> >> >> > >> > 4. In the prune branch of generate_queries_for_path_pattern_recurse(), >> > a one-liner noting that an exhausted level returns an empty list, >> > which the caller routes to generate_query_for_empty_path_pattern(), >> > would help -- pre-existing behavior, but far more reachable after >> > the patch. >> >> Added, feel free to suggest wording. > > > Good. > >> >> >> > >> > ## On the trailing edge_qual guard >> > >> > The `if (edge_qual == NULL) return NULL;` in >> > generate_query_for_graph_path() becomes unreachable after the patch, >> > but worth keeping as a defensive backstop -- it is the literal dual >> > of the helper's check, and removing it would only tighten coupling >> > for no measurable gain. A short comment marking it as such would help. >> >> Added. > > > Thanks. > > With the ASCII fix in 0002, no further comments from my side. Will do, thanks :-) > > Regards, > Henson -- Regards Junwang Zhao
-
Re: [SQL/PGQ] Early pruning for GRAPH_TABLE path generation
Ashutosh Bapat <ashutosh.bapat.oss@gmail.com> — 2026-05-14T06:25:51Z
Hi Junwang, Thanks for working on this. The performance improvement is impressive. I haven't verified it myself though at this time. Henson said it right. The first version separately enumeration and conversion clearly to keep things simple. Things like variable length element patterns, embedded path patterns can change the way we enumerate the paths. Whatever the enumeration method is failing early is best strategy. However, the question is whether your implementation of "failing early" is going to make it difficult implement the above mentioned advanced features or simplify it or not affect those at all. We need to think and discuss a bit. Delaying "failing early" implementation till we implement those features is safest strategy. If the lack of it makes the feature prohibitively useless, we will need to implement it early and deal with the difficulties it brings. But the examples so far are mostly artificial, not practical. That doesn't make me feel like it's worth taking the risk. There are many other bugs that need to be fixed. I am very glad that this patch demonstrates that "failing early" improves things significantly. So we will incorporate this strategy sooner or later. On Thu, May 14, 2026 at 6:08 AM Junwang Zhao <zhjwpku@gmail.com> wrote: > Some cosmetic comments on v2 + if (list_length(graph_path) == 1) + return true; I would move this earlier in the function, to make it more readable. rev_feasible is clever, but may need more comments. How do we make sure that the edge direction checks in generate_query_for_graph_path and graph_path_edge_is_feasible remain consistent? What I had in mind instead was to start constructing Join tree while we are creating paths so that edge direction feasibility checks also create the edge connection quals avoiding the duplicate logic. However, we will need to make sure that any unusable objects we create during that process are discarded in time. It will be better to place CHECK_FOR_INTERRUPTS alongside stack depth check. But for it to be added there, we need an evidence that the function is really consuming a lot of time. Your earlier measurements hint towards that, but they are overall query times. Can you please share actual time spent in this function out of the total execution time? If you separate CHECK_FOR_INTERRUPTS change as a separate patch, it can be committed independent of the optimization. -- Best Wishes, Ashutosh Bapat
-
Re: [SQL/PGQ] Early pruning for GRAPH_TABLE path generation
Junwang Zhao <zhjwpku@gmail.com> — 2026-05-14T15:30:46Z
Hi Ashutosh, On Thu, May 14, 2026 at 2:26 PM Ashutosh Bapat <ashutosh.bapat.oss@gmail.com> wrote: > > Hi Junwang, > Thanks for working on this. The performance improvement is impressive. > I haven't verified it myself though at this time. > > Henson said it right. The first version separately enumeration and > conversion clearly to keep things simple. Things like variable length > element patterns, embedded path patterns can change the way we > enumerate the paths. Whatever the enumeration method is failing early > is best strategy. However, the question is whether your implementation > of "failing early" is going to make it difficult implement the above > mentioned advanced features or simplify it or not affect those at all. > We need to think and discuss a bit. Yeah, it makes sense to me. > > Delaying "failing early" implementation till we implement those > features is safest strategy. If the lack of it makes the feature > prohibitively useless, we will need to implement it early and deal > with the difficulties it brings. But the examples so far are mostly > artificial, not practical. That doesn't make me feel like it's worth > taking the risk. There are many other bugs that need to be fixed. > > I am very glad that this patch demonstrates that "failing early" > improves things significantly. So we will incorporate this strategy > sooner or later. Sure, let's find out what's the best way. > > On Thu, May 14, 2026 at 6:08 AM Junwang Zhao <zhjwpku@gmail.com> wrote: > > > > Some cosmetic comments on v2 > > + if (list_length(graph_path) == 1) > + return true; > > I would move this earlier in the function, to make it more readable. Done in attached v3. > > rev_feasible is clever, but may need more comments. Added. > > How do we make sure that the edge direction checks in > generate_query_for_graph_path and graph_path_edge_is_feasible remain > consistent? What I had in mind instead was to start constructing Join > tree while we are creating paths so that edge direction feasibility > checks also create the edge connection quals avoiding the duplicate > logic. However, we will need to make sure that any unusable objects we > create during that process are discarded in time. > > It will be better to place CHECK_FOR_INTERRUPTS alongside stack depth > check. But for it to be added there, we need an evidence that the > function is really consuming a lot of time. Your earlier measurements > hint towards that, but they are overall query times. Can you please > share actual time spent in this function out of the total execution > time? I added temporary time logs around the generate_queries_for_path_pattern_recurse in generate_queries_for_path_pattern, and got the path expansion time consuming. query time: [local] zjw@postgres:5432-97559=# SELECT count(*) FROM GRAPH_TABLE (g5 MATCH (a IS vl2)-[e1]->(b)-[e2]->(c)-[e3]->(d)-[e4]->(e) COLUMNS (a.id AS aid)); count ------- 0 (1 row) Time: 5577.000 ms (00:05.577) path expansion time: 2026-05-14 23:22:41.513 CST [97559] LOG: GRAPH_TABLE path expansion took 4648.482 ms and generated 1 path queries > > If you separate CHECK_FOR_INTERRUPTS change as a separate patch, it > can be committed independent of the optimization. That's v3-0001 now. Summary: v3-0001: adds CHECK_FOR_INTERRUPTS() in recursive graph path query generation to keep query cancellation responsive on complex patterns. v3-0002: adds temporary timing logs to measure performance during GRAPH_TABLE path expansion, not to be committed. v3-0003: the failing early(or early pruning) logic with comments resolved. v3-0004: adds a test which enumerates the full N^K combinations before the early pruning logic, with Henson's last comment resolved. > > -- > Best Wishes, > Ashutosh Bapat -- Regards Junwang Zhao