Skip to content

perf(parser): elide redundant static edge type filters - #2559

Open
linhongyu510 wants to merge 5 commits into
apache:masterfrom
linhongyu510:perf/elide-redundant-edge-type-filter-2481
Open

linhongyu510 wants to merge 5 commits into
apache:masterfrom
linhongyu510:perf/elide-redundant-edge-type-filter-2481

Conversation

@linhongyu510

Copy link
Copy Markdown

Summary

  • remove type(r) = "<label>" when the same fixed-length relationship variable is already constrained by MATCH ... [r:<label>]
  • preserve all non-redundant predicates and avoid mutating the original OPTIONAL MATCH WHERE tree
  • add positive, negative, conjunction, disjunction, optional-match, variable-length, and variable-reuse regression coverage

Correctness boundary

The rewrite is intentionally conservative. It only handles a direct equality whose left side is type(<single relationship variable>) and whose right side is the exact static relationship label. It does not rewrite OR expressions, unlabeled relationships, variable-length relationships, mismatched labels, or ambiguous repeated bindings.

Verification

  • RED on current master: the EXPLAIN regression failed because the P relationship scan still contained _label_name(...) = "P"
  • PostgreSQL 18.6 targeted regression: expr, cypher_match, and cypher_vle passed
  • PostgreSQL 18.6 full regression suite: 42/42 passed
  • build completed with GCC and LLVM bitcode generation

Benchmark

20,000 (:X)-[:P]->(:Y) relationships, 12 alternating query pairs in an isolated PostgreSQL 18.6 container:

Revision Plain typed MATCH median Redundant type(r) median Ratio
current master 2.854 ms 4.622 ms 1.620x
this PR 2.936 ms 2.925 ms 0.996x

All 24 measured queries returned 20000. This demonstrates removal of the redundant runtime filter in this environment; it does not claim universal absolute latency.

Closes #2481

Implementation and verification were AI-assisted. I reviewed the AST constraints, final diff, regression output, and benchmark results before submission.

Add an EXPLAIN regression that expects a statically typed relationship predicate to produce the same filter-free plan as the equivalent MATCH without the redundant type check.\n\nRefs apache#2481
Preserve non-redundant predicates and avoid mutating the original OPTIONAL MATCH WHERE tree.

Refs apache#2481

@MuhammadTahaNaveed MuhammadTahaNaveed left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed-by: GPT-6-Astra xhigh

43 regression tests pass on PostgreSQL 18.3, but the default-label case below returns incorrect results.

strcmp(rel->name, variable_name) != 0)
continue;

if (rel->varlen != NULL || rel->parsed_label == NULL)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please exclude IS_DEFAULT_LABEL_EDGE(rel->parsed_label) here. _ag_label_edge scans child labels, so this predicate isn't redundant. This returns "P" on the PR and no rows on master:

CREATE EXTENSION IF NOT EXISTS age;
LOAD 'age';
SET search_path = ag_catalog, public;

SELECT create_graph('type_filter_test');
SELECT * FROM cypher('type_filter_test', $$
    CREATE ()-[:P]->()
$$) AS (v agtype);

SELECT * FROM cypher('type_filter_test', $$
    MATCH ()-[r:_ag_label_edge]->()
    WHERE type(r) = '_ag_label_edge'
    RETURN type(r)
$$) AS (edge_type agtype);

Please cover this case in regress/sql/expr.sql.

{
Node *arg = lfirst(cell);

if (!is_redundant_edge_type_qual(pattern, arg))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking: could this simplify nested AND expressions too? WHERE r.v > 0 AND (type(r) = 'P' AND r.v < 4) keeps the type filter, while the equivalent flat expression removes it. Reusing this helper recursively would make parentheses behave consistently.

The default label edge (_ag_label_edge) scans child labels, so a
`type(r) = '_ag_label_edge'` predicate is not redundant with the
pattern edge. Exclude it from elision so the _ag_label_edge regression
case returns rows consistently.

Address review from MuhammadTahaNaveed on apache#2559.
The default edge label (_ag_label_edge) scans every child edge label, so a
`type(r) = '_ag_label_edge'` predicate is not redundant with the pattern
edge. No edge is ever stored with this synthetic label, so the filtered
result must stay empty. This captures the case requested in review on apache#2559.

Before the parser fix this returned the child edge label ("P"); after it,
the result is empty (0 rows).
@linhongyu510

Copy link
Copy Markdown
Author

Thanks for the careful review — and for the exact reproduction. I verified the default-label case against a local build.

Root cause

In is_redundant_edge_type_qual(), a pattern edge on the default label _ag_label_edge was treated like a static-label edge, so type(r) = '_ag_label_edge' was judged redundant and elided. But _ag_label_edge is the catch-all edge label that scans every child edge label, so the predicate is not redundant: dropping it returns the actual child edge type instead of filtering to zero rows.

Fix

a8cb30bc fix(parser): do not elide type() qual on default label edge — is_redundant_edge_type_qual() now returns false when the pattern edge is the default label (IS_DEFAULT_LABEL_EDGE(static_label)), so the filter is retained.

Reproduction (your exact SQL)

CREATE EXTENSION IF NOT EXISTS age;
LOAD 'age';
SET search_path = ag_catalog, public;
SELECT create_graph('type_filter_test');
SELECT * FROM cypher('type_filter_test', $$ CREATE ()-[:P]->() $$) AS (v agtype);
SELECT * FROM cypher('type_filter_test', $$
    MATCH ()-[r:_ag_label_edge]->()
    WHERE type(r) = '_ag_label_edge'
    RETURN type(r)
$$) AS (edge_type agtype);

Built AGE against local PostgreSQL 18.6 (REL_18_STABLE), bison 3.8.2:

Build Result
4d5707d6 (before fix) edge_type = "P" — 1 row (the reported bug)
a8cb30bc / head (0 rows) — empty (correct, matches master)

Regression coverage

Added the default-label case to regress/sql/expr.sql in 3d3741b3 (with refreshed regress/expected/expr.out). It fails before the fix (returns "P") and passes after (0 rows).

make installcheck on head: 42/43 tests pass. The single local failure is age_load, which errors with You can only load files located in [/tmp/age/] — on macOS /tmp resolves to /private/tmp, so realpath no longer matches the hardcoded /tmp/age/ prefix. This is a platform/environment issue unrelated to this parser change (it passes on the Linux CI runner), and I did not modify that test.

Happy to adjust the test placement or wording. Could you re-review at head 3d3741b3?


Note: this diagnosis, the local reproduction/build, and the regression-test addition were performed with AI assistance; all results above are from measured local make installcheck runs and the exact psql queries shown.

@linhongyu510

Copy link
Copy Markdown
Author

Verified the default-label edge fix end-to-end in a clean Docker environment (real pg_regress, no AST/static shortcuts).

Environment

  • Image: postgres:18 (Debian), postgresql-server-dev-18, bison 3.8.2, flex 2.6.4
  • PostgreSQL 18.6; AGE built via make PG_CONFIG=/usr/bin/pg_config install; regression via the project harness pg_regress --load-extension=age --temp-instance ... (the expr suite).

Baseline (pre-fix 4d5707d + this PR's expr.sql/expr.out) — RED
The new MATCH ()-[r:_ag_label_edge]->() WHERE type(r)='_ag_label_edge' case is elided and returns a child edge label instead of no rows. regression.diffs:

-(0 rows)
+ "KNOWS"
+(1 row)

(1 of 42 tests failed: expr.)

Head 3d3741b3 — GREEN
With the IS_DEFAULT_LABEL_EDGE(static_label) guard, that case now returns the expected (0 rows) and expr passes. The only production-code delta between baseline and head is the guard in is_redundant_edge_type_qual; the regression SQL/expected is exactly the case requested in review.

Note on a separate flaky test
On this arm64 / PG 18.6 container, cypher_merge (issue_1446 label counts) prints group rows in a different order (hub/shared before X/Y/Z) with no ORDER BY. That query has no type() filter and is untouched by this change; it's pre-existing hash-aggregation ordering nondeterminism, not a regression from this PR.

@MuhammadTahaNaveed

Copy link
Copy Markdown
Member

@linhongyu510 what do you think of this #2559 (comment)?

@linhongyu510

Copy link
Copy Markdown
Author

Re: the non-blocking question on discussion_r4251546518 —

Good catch; I agree the asymmetry is real. Today the helper only inspects the top-level AND conjunction, so a type(r) = 'P' inside a parenthesized sub-AND (r.v > 0 AND (type(r) = 'P' AND r.v < 4)) is retained, while the semantically identical flat form is elided — a parentheses-dependent inconsistency, not a correctness rule.

My view: making it recursion-aware is the right fix. A type(r) = <static-label> predication is redundant iff it sits in an AND-conjunct already satisfied by the edge's static label, regardless of parenthesization. I'd recurse into BoolExpr/AND_EXPR but deliberately stop at OR_EXPR/NOT_EXPR boundaries — crossing into an OR branch would be over-elision.

This is a separate enhancement from the default-label-edge bug this PR fixes (IS_DEFAULT_LABEL_EDGE(static_label) guard). Happy to add it as its own commit with regress cases for nested + flat forms (and an OR-group guard case), or spin it out as a follow-up — your call. (Couldn't post inline on the thread; the org's OAuth restrictions blocked the review-comment reply.)

🤖 AI-assisted (Claude Code); assessment only, no code pushed for this suggestion.

@MuhammadTahaNaveed

MuhammadTahaNaveed commented Oct 14, 2026 •

Copy link
Copy Markdown
Member

Happy to add it as its own commit with regress cases for nested + flat forms (and an OR-group guard case), or spin it out as a follow-up — your call.

@linhongyu510 Better to add it as part of this PR

A  predicate is redundant with the edge's
static label whenever it sits in an AND-conjunct already implied by the
pattern edge, regardless of parenthesization. Previously only the
top-level AND args were inspected, so the semantically flat

    r.v > 0 AND type(r) = 'P' AND r.v < 4

elided the predicate while its parenthesized twin

    r.v > 0 AND (type(r) = 'P' AND r.v < 4)

kept it -- a parentheses-dependent asymmetry.

simplify_redundant_edge_type_qual now recurses into nested AND_EXPR
groups, while deliberately stopping at OR_EXPR/NOT_EXPR boundaries
(crossing an OR branch would be over-elision). A  flag tracks
elision that happens inside a nested group so the outer conjunction is
rebuilt even when its own arg count is unchanged.

Regress cases added to expr.sql: nested-AND count, nested-AND EXPLAIN
(shows type() elided, siblings preserved), and an OR-group guard.
@linhongyu510

Copy link
Copy Markdown
Author

Follow-up to the review comment on the nested-AND asymmetry — the recursion-aware elision is now pushed as a subsequent commit on top of the original blocking-label fix (new head 917b68ea).

The original bug-fix diff is unchanged and still minimal. simplify_redundant_edge_type_qual now:

  • recurses into nested AND_EXPR groups,
  • deliberately stops at OR_EXPR / NOT_EXPR boundaries (crossing an OR branch would be over-elision),
  • uses a changed flag so the outer conjunction is rebuilt only when a nested child actually elides, keeping the no-op path cheap.

Verification:

  • flat vs parenthesized EXPLAIN now produce identical Filter output,
  • pg_regress expr green on PG18,
  • reverting the C change makes expr fail (reverse proof).

Could you take another look? Happy to adjust the recursion boundary or split commits if preferred.


针对上一轮 review 提到的嵌套 AND 不对称问题,递归版本的冗余 edge type() 消除已作为后续提交推到原 blocking-label 修复之上(新 head 917b68ea)。原 bug-fix diff 未改动,仍保持最小。逻辑会递归进入嵌套 AND_EXPR,在 OR_EXPR/NOT_EXPR 边界停止;用 changed 标志仅在嵌套子句真的消除时才重建外层 AND。flat 与括号嵌套两种写法的 EXPLAIN 现在 Filter 一致,PG18 上 pg_regress expr 全绿,回退 C 改动会让 expr 失败(反向验证)。麻烦再 review 一次,谢谢。

Implementation and verification were AI-assisted; I reviewed the AST walk, the diff, and the regression output before pushing.(实现与验证有 AI 辅助,我复核后再推送。)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A redundant type(r) = 'P' check makes a statically typed relationship query about 6x slower

2 participants