fix(sof): deterministic SQL view ordering with one final preview LIMIT - #1761
Gordex2014 wants to merge 2 commits into
Conversation
#1623) SQL-backed ViewDefinition runs now have a total, deterministic order, so a preview's single final LIMIT returns exactly the first N rows of the unlimited result on both PostgreSQL and SQLite. - Ordinary/expanded selects order by resource last_updated, id, then every expansion ordinal; unionAll and repeat keep the first visible column as primary key (explicit NULL placement) with deterministic resource, branch and traversal tie-breakers carried as hidden, positionally aliased columns behind an outer visible projection. - Runtime _since/patient/group filters are lowered structurally into every union branch and repeat seed; the SQL text heuristics are gone. - PostgreSQL applies one final output LIMIT to every shape (OutputLimitStrategy removed); oversized limits keep the client cap. - repeat gets a collision-free traversal identity; %rowIndex under repeat, indexed forEach and where() criteria now matches the evaluator (SQL targets only; MongoDB pipelines unchanged). - Indexed forEach honours membership and trailing where() filters and picks deterministically. - SQL-runner buffered formats and CSV/Parquet export shards use the declared column layout, so PostgreSQL NULL-first columns are kept. - unionAll ViewDefinitions can be SQLQuery dependencies (deduplicated dependency table columns). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Describe the deterministic result order of database-run ViewDefinitions, that _limit returns exactly the first N rows of the unlimited run, the declared-column guarantee for tabular output, and the compatibility impact on unlimited runs, $sql-export files and SQL Query dependencies. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
Follow-up issue to be created: unlimited-run performance This PR makes every SQL-backed ViewDefinition run deterministically ordered so previews push one final We accept this trade-off here and will open a separate issue to optimize unlimited runs without changing the limited-preview contract. Candidate directions: a more compact traversal identity for |
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
Closes #1623.
Outcome
SQL-backed ViewDefinition runs (PostgreSQL and SQLite in-DB runners) now have a total, deterministic order. A preview's
_limitis applied as one final SQLLIMITafter every filter, expansion, union and recursion, and returns exactly the first N rows of the unlimited result — for flat, expanded (forEach/forEachOrNull, nested, chained, cartesian),unionAll,repeatand indexed shapes.Visual explanation
https://vanish.gordex.dev/s/brave-atlas-pt/ — before/after preview query path, ordering contract, filter placement, benchmark results and trade-off. Temporary link, expires 2026-10-07T22:32:06Z.
Ordering contract (all keys ascending)
last_updated, resource id, every expansion occurrence ordinal (aforEachOrNullmiss sorts as-1)unionAllNULLS LAST, SQLiteNULLS FIRST), resource keys, shared enclosing ordinals, branch number, branch-local identityrepeatTie-breakers travel as hidden, positionally aliased columns behind an outer projection of the visible columns only — no helper columns reach results and no user column name is reserved. The contract is documented for users in the book ("Result Order and
_limitin HFS") and READMEs, and for developers in rustdoc (SofRunnerand thesof::emitmodule).What changed
_since, Patient and Group filters are placed in every union branch and everyrepeatseed (previously spliced as text before the lastORDER BY, which filtered only the last union branch and produced invalid SQL for somerepeatviews). SQL text heuristics (inject_before_order_by,strip_trailing_order_by) are removed.OutputLimitStrategyremoved). Limits abovei64::MAXkeep the client-side cap; intrinsicLIMIT 1 OFFSET Nfor indexed selections is unchanged.repeat: collision-free traversal identity (PostgreSQLbigint[], SQLite fixed-width binary-sorted text).%rowIndexunderrepeatis ranked over the full identity before post-repeat joins, fixing collisions (multi-answer children, repeated paths, multiple seeds) and PostgreSQL multi-path / post-repeat%rowIndex, which previously failed to prepare.forEach(x[N]): deterministic pick, membership filter honoured in every scope, trailingwhere()applied to the selected occurrence,%rowIndex= 0 as in the evaluator;x.where()[N]is rejected (422) instead of returning wrong rows.%rowIndexinsidewhere()criteria reads the enclosing iteration's index, matching the evaluator (SQL targets only).unionAllviews as SQLQuery/SQLView dependencies now materialize (deduplicated dependency-table columns) instead of failing with a duplicate-column 422.Compatibility
$sql-exportshards and SQLQuery dependency insertion order. Rows with different primary keys keep their relative order.%rowIndexvalues listed above; indexedforEachno longer emits rows for absent/rejected selections; runtime filters now apply to every union branch/seed; SQL-runner tabular formats carry all declared columns.Performance (PostgreSQL 16, 20,000 Patients / 2,000 QuestionnaireResponses; base vs this branch, same data)
Preview
_limit=50, server rows produced and execution (EXPLAIN ANALYZE of the executed statement):forEachOrNullunionAllrepeat(multi-path)End-to-end request, preview
_limit=50(median):unionAllrepeatIn the web UI ViewDefinitions playground (50-row preview) the displayed rows are identical before/after; displayed time drops from 84 → 3 ms (expansion) and 122 → 36 ms (union).
Known trade-off (follow-up issue): unlimited runs pay for the total order — warm, expansion 211 → 296 ms, union 188 → 222 ms,
repeat354 → 556 ms on this fixture (wider sort keys;repeatsort spills more).unionAll/repeatpreviews still evaluate their full input because the first visible column remains the primary key. Unlimited contents are identical (multiset-verified).Tests
last_updatedresources inserted in reverse id order), limits 0/1/50/large/oversized against the unlimited prefix, executed-SQL assertions (one finalLIMIT, server rows = returned rows viapg_stat_statements).%rowIndexand indexed results compared with the in-process evaluator;row_index.jsonunchanged.Checks run locally
Focused persistence, SOF and REST suites on SQLite, PostgreSQL and MongoDB; clippy (CI allow-list) for persistence, rest, sof and hfs feature sets; rustfmt;
git diff --check. CI pending.Review
Two independent adversarial reviews (lint/CI and issue alignment). Findings fixed: indexed
where()filter,%rowIndexscope in criteria, MongoDB isolation, rustdoc accuracy, test feature gates; cold measurements added. Remaining accepted trade-off: unlimited-run cost (follow-up issue).Out of scope (pre-existing, follow-ups)
where()on indexedforEach.selectunder an indexedforEachis not lowered.forEach is unknowninsideunionAll/nested selects.$thison emptyforEachOrNullcontext).