Skip to content

fix(planner): remove probe state-pollution in heap top-K rewrite (#347, #335) - #349

Open
temporaryfix wants to merge 4 commits into
GrafeoDB:mainfrom
temporaryfix:fix/topk-probe-state-pollution
Open

fix(planner): remove probe state-pollution in heap top-K rewrite (#347, #335)#349
temporaryfix wants to merge 4 commits into
GrafeoDB:mainfrom
temporaryfix:fix/topk-probe-state-pollution

Conversation

@temporaryfix

@temporaryfix temporaryfix commented May 17, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes #347 and the broader bug class also surfaced in #335. try_heap_topk_rewrite planned the input subtree as a speculative probe, then fell through to the unfused path when sort-key resolution failed. The probe mutated Planner::scalar_columns via the side effects in plan_return_projection, and the unfused re-planning of the same Return then chose ProjectExpr::Column (raw passthrough) instead of ProjectExpr::NodeResolveMATCH (n) RETURN n ORDER BY <key> LIMIT k returned a raw Int64 NodeId instead of a resolved map for any sort key whose resolver column name wasn't materialised by Return.

Relation to the prior #335 fix

The earlier fix for #335 (PR #337, closed; the predicate change was cherry-picked into release/0.5.43 as 530edc5) patched the gate predicate sort_needs_augmenting_projection to return true for Property sort keys. That kept the probe from running for that one shape, but the underlying mechanism — the probe mutating planner state on fall-through — was untouched, and the same root cause re-fires for any other sort-key shape the predicate doesn't gate. #347 surfaced it with text_score (a FunctionCall); the audit in this PR found the same break for Case and Binary sort keys, neither of which has a GH issue but both reproduce on main today.

main does not currently contain the release/0.5.43 predicate cherry-pick, so this PR is what fixes #335 and #347 on main. The cherry-picked release-branch fix and this PR are not in conflict — this one removes the architectural condition that made the predicate-as-gate necessary in the first place, so future sort-key shapes don't need new carve-outs.

Changes (commit-by-commit)

  1. refactor(planner): centralize column-naming formulas — extracts two helpers in query/planner/common.rs:

    • output_column_name(alias, expr) for Return/Project item names.
    • resolved_column_name(expr) symmetric with resolve_expression_to_column's lookup.

    Replaces 15 duplicated alias.unwrap_or_else(|| expression_to_string(...)) and format!("{v}_{p}") / format!("__expr_{expr:?}") callsites across lpg/{aggregate,project}.rs, rdf/mod.rs, and common.rs. Pure refactor — no behavior change.

  2. fix(planner): remove probe state-pollution in try_heap_topk_rewrite (#347, #335) — replaces the probe with predictive column resolution. predict_subtree_columns derives Return's output columns from ret.items via the shared output_column_name helper (no planner mutation), and sort keys resolve against the prediction before any planning happens. A debug_assert_eq! between predicted and actual columns guards against future drift if a new shape is added. RETURN * is explicitly skipped (input columns aren't knowable without planning) — mirrors the same check in plan_return_projection.

  3. test(topk_rewrite): regression coverage for probe state-pollution — four tests, one per sort-key shape that triggered the bug class:

    Each asserts the entity Return item resolves to Value::Map. Verified to fail on the parent commit (without the fix) with expected Map, got Int64(0).

  4. fix(planner): restore TopK coverage for property-alias-mismatch caseRETURN v.p ORDER BY v.p LIMIT k was getting routed through the unfused path because Return outputs the column under "v.p" (via expression_to_string fallback) but the resolver looks up "v_p" (via resolved_column_name). plan_sort already handles this in its prelude; that prelude logic is now extracted as register_return_property_sort_aliases and called from both plan_sort (unchanged behavior) and try_heap_topk_rewrite (new — closes the coverage gap).

Test plan

  • cargo test -p grafeo-engine --no-default-features --features "lpg gql ai parallel" --test topk_rewrite — 11 tests pass (7 existing + 4 new regression).
  • Targeted sweep on the planner-adjacent suites — planner_lpg_coverage (26), planner_coverage (34), project_coverage (18), hybrid_query (34), spec_compliance (106), mutation_planning (39), factorized_aggregation_test, seam_aggregates_expressions (25), null_and_coercion (27), push_pipeline_integration (42), expression_and_projection (65) — all green.
  • Confirmed the 4 new regression tests fail on the refactor-only commit with the Value::Int64(0) symptom — proving the bug class extends beyond what was reported in [Bug]: ORDER BY + LIMIT changes returned result #335 and [Bug]: Order + Limit + text_score changes returned format #347.
  • Verified cypher_order_by_limit_uses_topk (RETURN n.r ORDER BY n.r DESC LIMIT 5) still passes — this is the shape that now fires TopK after the alias-mismatch fix.
  • Pre-existing failures on upstream/main (merge_rollback, query_correctness, regression_external compile errors; gql_spec_compliance LIKE tests) confirmed independent of this branch.

Notes for the reviewer

  • No API surface change. All edits are internal to query/planner/. Tests assert correctness via the existing session.execute() path.
  • A separate follow-up PR (fix(planner): distinct column names for unaliased complex expressions #350) addresses a related but independent issue — column-name collisions for unaliased complex expressions in RETURN. Submitted separately because it's a user-visible behavior change in column naming and deserves its own review and CHANGELOG note.

The two formulas that map LogicalExpression to a column name were
duplicated across the LPG and RDF planners:

- alias.unwrap_or_else(|| expression_to_string(...))    [4 sites]
- format!("{v}_{p}") / format!("__expr_{expr:?}")        [11 sites]

Producers (Aggregate/Sort augmenting projections, Return/Project items)
and the consumer (`resolve_expression_to_column`) all re-derived the
same strings by hand. Any drift between them silently breaks resolution.

Extract two helpers in `query/planner/common.rs`:

- `output_column_name(alias, expr)` — for Return/Project item names.
- `resolved_column_name(expr)` — symmetric with the lookup performed by
  `resolve_expression_to_column`, which now calls it internally.

Replace every duplicated call site with these helpers. Pure refactor:
no behavior change.
…rafeoDB#347, GrafeoDB#335)

`try_heap_topk_rewrite` planned the input subtree as a speculative probe,
then fell through to the unfused path when sort-key resolution failed.
The probe mutated `Planner::scalar_columns` (and `edge_columns`) via the
side effects in `plan_return_projection`: each output column gets
registered as scalar so an enclosing Apply doesn't try to re-resolve it
as a NodeId. When resolution failed, the caller re-planned the same
Return through `plan_sort`'s unfused path, but now with the polluted
state — and `plan_return_projection`'s `Variable(name)` arm flips from
`NodeResolve` to a raw `Column` passthrough when it sees the name
already in `scalar_columns`.

Result: `MATCH (n) RETURN n ORDER BY <key> LIMIT k` returned raw
`Int64` NodeIds instead of resolved maps for any sort key whose
resolver column name was not in the Return's output. Issue GrafeoDB#335
covered Property keys; issue GrafeoDB#347 covered FunctionCall (text_score)
keys. The class extends to Case, Binary, and IndexAccess sort keys —
none of which had reports, but all produce the same `Value::Int64(0)`
output (confirmed via the new regression tests, which fail without
this commit on a Variable Return item across all four key shapes).

PR GrafeoDB#337 patched the predicate `sort_needs_augmenting_projection` to
return `true` for Property keys, gating the probe off for that specific
shape. That fix was a partial mitigation: it widened the gate without
removing the state-pollution mechanism, so any sort key the predicate
considered "safe" continued to leak. The predicate-as-gate approach
also has the inverse footgun of save/restore — every new expression
form requires another carve-out, with no compiler enforcement.

This change removes the probe entirely. `try_heap_topk_rewrite` now:

1. Predicts Return's output columns from `ret.items` via the shared
   `output_column_name` helper (the same one `plan_return_projection`
   uses, so prediction can't drift from reality).
2. Resolves sort keys against the predicted columns — pure function,
   no planner state touched. The resolved `SortKey`s are held and
   reused after planning, so resolution runs once.
3. Plans for real only when resolution succeeds.

A `debug_assert_eq!` between predicted and actual columns guards
against future drift if a new shape is added to `predict_subtree_columns`.
`RETURN *` is explicitly skipped because its column count depends on
the input subtree, which can only be known by planning — mirrors the
same `Variable("*")` check in `plan_return_projection`.

`sort_needs_augmenting_projection` is no longer consulted by the
rewrite. It is retained for `plan_sort`'s pre-Return-vs-post-Return
augmenting decision, where its variable-membership rule answers the
correct question.

This also obviates PR GrafeoDB#337: the probe is gone, so the predicate-as-gate
that GrafeoDB#337 hardened is no longer reachable from the bug class.
Adds four tests, one per sort-key shape that triggered the bug class
fixed in the previous commit. Each asserts that `RETURN n ... LIMIT k`
yields a `Value::Map` for the entity column, never a raw `Int64`
NodeId — the symptom of the pollution failure mode.

- order_by_limit_property_key_yields_map     (issue GrafeoDB#335)
- order_by_limit_text_score_key_yields_map   (issue GrafeoDB#347, behind text-index)
- order_by_limit_case_key_yields_map         (Case expression)
- order_by_limit_binary_key_yields_map       (Binary expression)

Verified to fail on the parent commit reverted without the fix:

  row 0: expected Map, got Int64(0)
`RETURN v.p ORDER BY v.p LIMIT k` was getting routed through the
unfused path even though the heap top-K rewrite could safely handle
it. The cause: the resolver builds the column-name to look up via
`resolved_column_name` ("v_p"), but the Return outputs that column
under `output_column_name`'s `expression_to_string` fallback
("v.p"). The two formulas don't match, so the rewrite's predictive
resolve fails and we fall through.

`plan_sort` already handles this in its prelude: for each
`Property{v,p}` Return item, it registers an alias entry mapping
"v_p" -> the index of column "v.p". That prelude logic is now
extracted as `register_return_property_sort_aliases` and called from
both `plan_sort` (unchanged behavior) and `try_heap_topk_rewrite`
(new — closes the coverage gap).

`resolve_logical_to_physical_keys` now takes a pre-built
`&HashMap<String, usize>` instead of building one from a `&[String]`,
since callers need to inject the aliases before resolving.

Pure coverage win: every shape the rewrite already handled keeps
producing identical output; queries of the form `RETURN v.p ORDER BY
v.p LIMIT k` now fire TopK instead of full sort + limit (existing
`cypher_order_by_limit_uses_topk` exercises this shape — it still
passes, and now via the fused path). The §2.9 e2e benchmark in
`benches/topk_e2e.rs` is the silent-fall-through canary if this
regresses.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

No issues found across 6 files

Re-trigger cubic

@codecov

codecov Bot commented May 17, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.50549% with 5 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
crates/grafeo-engine/src/query/planner/common.rs 91.30% 2 Missing ⚠️
crates/grafeo-engine/src/query/planner/rdf/mod.rs 50.00% 2 Missing ⚠️
...tes/grafeo-engine/src/query/planner/lpg/project.rs 98.33% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

@codspeed-hq

codspeed-hq Bot commented May 17, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 70 untouched benchmarks


Comparing temporaryfix:fix/topk-probe-state-pollution (b444683) with main (4ebae02)

Open in CodSpeed

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.

[Bug]: Order + Limit + text_score changes returned format [Bug]: ORDER BY + LIMIT changes returned result

1 participant