Skip to content

refactor(engine): make the plan the single source of truth for v2 reads - #769

Merged
azimafroozeh merged 4 commits into
ModernRelay:mainfrom
azimafroozeh:v2-plan-lower-trait
Sep 23, 2026
Merged

azimafroozeh merged 4 commits into
ModernRelay:mainfrom
azimafroozeh:v2-plan-lower-trait

Conversation

@azimafroozeh

Copy link
Copy Markdown
Collaborator

What & why

This PR makes the bound plan the single source of truth for engine v2 reads: once planning and binding finish, execution reads the plan and nothing else, so one plan means one execution.

  • Before: the Nearest, TextSearch and RankFuse nodes of feat(engine): introduce planned query execution #760 lowered to nothing; the ranking that ran was rebuilt from QueryIR::order_by into SearchMode after planning, so explain printed one plan and the engine ran another, and a planner pass on a ranking node changed no result.
  • Ranking is an access path of the Scan (ranked: kind, property, query expression, fetch, the declared overfetch ladder, nprobes); Nearest and TextSearch are deleted; RankFuse takes two arm subtrees, one ranked Scan each; the planner emits the score Sort and declares every Sort's tiebreak, so an id column is read only when something above the scan needs it.
  • BoundPlan is the plan plus its value table (resolved parameters including now(), query vectors by node id). gather is the one read of session settings and environment, before planning; the plan records assumptions (parameter names, settings, env caps, gate policy, memory limit, dataset pins by path, branch and version); execute takes a BoundPlan and no QueryIR or SessionSettings by type, and engine/ reads no environment outside plan_source.rs (a test greps).
  • Every run-time choice is a node or a field: a HashJoin node with its declared fallback, Expand.policy, RankedAccess.overfetch. The planner walks the plan post-order through a Lower trait the engine implements once per node kind; every operator carries the NodeId that built it, and the run reports per node (status, attempts[].{rung, ran, actual_rows, drained}) as profile rows and as id on physical explain rows.
  • Replay: a serialized BoundPlan runs again on a snapshot whose pinned datasets match, with equal rows and the same trace, covered for a hash join, a nearest ladder, an rrf fusion, a zero limit, a bulk anti-join, an aggregate, a bound now() and ann_nprobes across sessions.
  • ann_nprobes becomes a request setting (set ann_nprobes = N, the HTTP settings field, the CLI --set), because the plan now carries the cap as nprobes.
  • nearest() or bm25() ordering on a traversal destination is refused at planning as a user error; on v2 it failed at run time with a missing score column.

Backing issue / RFC

  • None: a refactor that removes the IR-derived shadow source of truth rather than adding one; it amends the accepted session-settings RFC (docs/rfcs/2026-09-16-session-settings.md) for the ann_nprobes scope.

Checklist

  • Change is focused (v2 read execution driven by the bound plan; the ann_nprobes scope flip is its consequence)
  • Tests added/updated for behavior changes (GQ logic cases ranked_access_on_the_scan, sort_tiebreak_declared, switch_ran_on_the_report, input_ann_nprobes, input_engine_route, input_index_build_state, search_order_on_an_unranked_binding_and_a_destination; planner lower_walk and bound_plan; engine engine_v2_plan_replay and engine_v2_scrubbed_replay; the existing v2 corpus passes with unchanged rows)
  • Public docs updated if user-facing surface changed (docs/user/queries/explain.md, docs/user/queries/index.md, docs/user/search/index.md, docs/releases/v0.12.0.md, the session-settings RFC; dev guides docs/dev/execution.md, docs/dev/testing.md)
  • Reviewed against docs/dev/invariants.md — no Hard Invariant weakened, no deny-list item hit (removes a side channel for query semantics; nearest miss: a Sort without fetch holds its whole input under the query pool and does not spill)

Local verification

  • cargo test --workspace --exclude omnigraph-gqt --exclude omnigraph-dst --locked --features omnigraph-engine/failpoints,omnigraph-cluster/failpoints — 110 targets green on the tree before the last review's five corrections; the only reds were the sandbox refusals (three CLI credential tests, the blob special-file test) and forbidden_apis on the two lines added at commit; after the corrections the touched suites were rerun green (planner, api-types, engine v2 replay and memory suites, CLI in-source tests, server data_routes), the full workspace run is owed before the commit
  • cargo test -p omnigraph-gqt --locked — 142/142 cases plus the runner's own tests green after the corrections
  • cargo clippy -p omnigraph-planner -p omnigraph-engine -p omnigraph-gqt -p omnigraph-api-types -p omnigraph-server -p omnigraph-cli --all-targets — 0 warnings after the corrections
  • cargo test -p omnigraph-server --test openapi — 104 green after regenerating openapi.json
  • cargo fmt --all --check — clean
  • python3 scripts/check-docs.py — OK, 145 files; bash scripts/check-agents-md.sh — OK
  • omnigraph-vocabulary-guard check --surface openapi --base 70abf65e — 20 current, 20 base, OK; --surface rust-string — fails on 779 merge base inventory entries at 70abf65e, none from this diff

Notes for reviewers

  • Default behavior: engine stays v1; everything here runs under set engine = v2 except the ann_nprobes scope, which v1 also accepts in a request and reads at execution without a plan (refusing it under v1 is a follow-up); an older server drops the HTTP field silently, accepted with the version gate and stated in the RFC.
  • explain_version stays 1 with the row schema unchanged; physical rows gain id, Scan gains ranked and nprobes, Sort gains tiebreak, HashJoin prints fallback, RankFuse has two inputs, one trailing assumptions row; the Nearest and TextSearch rows are gone, so a consumer keyed on node names changes.
  • Two pub #[doc(hidden)] doors on Session, both read-only: query_inspected (rows, plan, report in one run) and replay_bound_plan; the GQ logic-test runner and the replay tests are their callers.
  • RankFuse declares no tie-break yet; the operator orders deterministically (score, fused id, other ids); declaring it on the node is a follow-up.
  • Aggregate output batches are uncharged between AggregateExec and its consumer since the wrapper operator left; closing that is a follow-up.
  • Engine footprint: engine/lower.rs, search.rs and mod.rs rewritten around the Lower walk and BoundPlan; six omnigraph operators replace the DataFusion ones a node used to lower to (FilterExec, ProjectionExec, LimitExec, CrossJoinExec, SortExec, HashJoinExec), each counting its polls and charging what it allocates; planner gains bound.rs and mirror.rs (serde mirrors, because arrow's serde feature is not in the lockfile).

@azimafroozeh
azimafroozeh added this pull request to the merge queue Sep 23, 2026
Merged via the queue into ModernRelay:main with commit 4f87abb Sep 23, 2026
31 checks passed
@azimafroozeh
azimafroozeh deleted the v2-plan-lower-trait branch September 23, 2026 20:43
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.

1 participant