Skip to content

fix(sql): SQL correctness across routing, copies, typeguards, aggregates and continuous aggregates - #417

Merged
farhan-syah merged 18 commits into
mainfrom
fix/sql-correctness
Oct 14, 2026
Merged

farhan-syah merged 18 commits into
mainfrom
fix/sql-correctness

Conversation

@farhan-syah

@farhan-syah farhan-syah commented Oct 14, 2026 •

Copy link
Copy Markdown
Member

Closes #311
Closes #318
Closes #333
Closes #349
Closes #398

#296 is a duplicate of #349 and is already closed.

What changed

Statement meaning from the AST (#349, #296)

  • nodedb-sql/src/parser/query_function.rs: query functions are recognized from the AST. A keyword inside a string literal or a comment no longer routes a statement.
  • The graph DSL parses with a clause cursor. A stray, misspelled or repeated clause returns an error (DEPTS 3, LABELS 'x').
  • ddl_ast/parse/clause/: one clause cursor serves every DDL parser that accepted keywords anywhere.
  • MATCH parser fixes. The router reads tokens (route_tokens.rs), not raw text.

INSERT…SELECT, MERGE and UPDATE FROM (#311)

  • A RowSource reads every engine as a source: document, KV, columnar, spatial, timeseries.
  • The silent 10,000-row caps are removed everywhere.
  • A copy into its own source reads each row once.
  • A copy of a redacted column is refused.
  • MERGE reports a duplicate key.
  • Multi-row INSERT lowers to BatchInsert. BatchInsert checks duplicate primary keys, with if_absent for ON CONFLICT DO NOTHING.
  • Join keys compare typed values (EqCoercedBucket).

Typeguards and generated columns (#318)

  • enforcement/typeguard/: the Data Plane enforces typeguards on every write form.
  • A guard VALUE becomes a GENERATED column on CONVERT.
  • A write that names a generated column is refused with 428C9.
  • CONVERT keeps the primary key.
  • Typeguard and period-lock DDL use the session database, not DatabaseId::DEFAULT.
  • State and transition constraints run on every update path.
  • BY ROLE roles travel in replicated writes, the redo record and sync.
  • SHOW / REWRITE PARTITIONS run on the Data Plane. partition_by splits flushes, committed through the manifest with roll-forward.
  • The shared expression parser accepts LIKE, IN, BETWEEN and IS NULL.

Aggregates and joins (#333)

  • Timeseries aggregates route to the native or the generic path. HAVING, FILTER (WHERE …), GROUP BY expressions and grouping sets work on both.
  • Aggregates inside a transaction read the transaction's own writes, on every engine.
  • stddev / variance use sample semantics, as in PostgreSQL. The _pop forms are population.
  • Joins: unqualified column resolution, CROSS JOIN, aggregates over joins, derived tables and CTEs as join sides. CTE columns are typed.
  • Rows gathered for a join or aggregate pass authorization (GatheredSource) and redaction.

Continuous aggregates (#398)

  • A continuous aggregate is a read-only relation (EngineType::ContinuousAggregate). It shares one namespace with collections.
  • Late samples below the watermark fold again. The state persists in a cagg.state image per root.
  • REFRESH CONTINUOUS AGGREGATE is a replicated cut on the source's data group. It runs on any node.
  • Retention waits until the aggregates are registered, on the temporal purge path too.

PromQL

  • PromQL reads stored timeseries data. Label columns merge into the catalog, so SQL WHERE job = … works.
  • Subqueries parse. quantile_over_time is fixed.

Timeseries storage

  • Partitions of one interval merge into one. Overlap compaction replaces o3_buffer.rs, merge/o3.rs and merge/cycle.rs.
  • A NULL text value stays NULL. NULL into an integer or time column is refused.
  • TimeseriesWalBatch and the legacy TimeseriesMemtable are removed.

KV

  • Secondary index equality probes answer from complete coverage only (IndexCoverage). A numeric probe matches.

Constraints

  • CRDT constraints install before DDL returns.
  • The install goes through the gateway to the leader of the collection's data group. A node outside the group installs correctly.
  • An install error after a committed DDL never fails the DDL. It is recorded, and the reconcile loop delivers it again. CRDT writes check the installed version, so no write skips a constraint.
  • The reconcile loop keys collections by database.

Fixes found by the full suite

  • control/surrogate/: surrogate binds commit once per statement, not once per row. Before this, every row cost one system.redb fsync and one WAL sync. A 10,500-row seed takes 5s on disk, down from 110s. This defect is on main too.
  • exchange/resolve/materialize.rs: a task forwarded through the gateway gets its provider rows filled first. Before this, a forwarded read of a catalog relation or a continuous aggregate returned no rows. It affected pgwire, WebSocket and NDJSON.
  • auth_lease/timing.rs: lapse_grace covers one renewal round in flight (renew_every + rpc_read_timeout). Before this, a statement was refused while a renewal that would succeed was still running.
  • bootstrap/core_stall_monitor.rs: the monitor judges full windows only. A late wake no longer reports idle cores as stalled.
  • sql_plan_convert/dml/kv_insert.rs: a multi-row KV INSERT is one KvOp::BatchPut, not one Raft entry per row. A 500-row INSERT took 5s on disk and hit the 30s deadline under load.
  • Multi-row document UPSERT / ON CONFLICT DO UPDATE, vector-primary INSERT / UPSERT, and KV ON CONFLICT DO UPDATE each lower to one replicated entry.
  • ddl/neutral/collection/dml/parse/: multi-row UPSERT INTO … VALUES (…), (…) and the array form INSERT INTO c [{…}, {…}] parse every row. Both returned 42601 on main.
  • KV ON CONFLICT DO NOTHING: WAL replay overwrote a stored row with the value the statement skipped. Skip-on-conflict writes now resolve before they are logged.
  • event/trigger/lane/fire.rs: the trigger firing cursor committed through a metadata Raft proposal on every write. It commits on its 2s cadence when no action fired.
  • nodedb-wal/src/record/surrogate_bind.rs: surrogate binds are one WAL record per batch (SurrogateBindBatch), split to fit a DWB slot. Before this, one record per row cost one synchronous O_DIRECT DWB write per row.
  • nodedb-wal/src/double_write/: a record stages its DWB slot in memory. A sync writes at most the 64 slots the ring keeps. Torn-write protection is unchanged.
  • planner/context/: sub-planners (object-literal and UPSERT handlers, triggers, procedures, COPY) carry the sequence registry. A nextval default there failed with 55000 on main.
  • Dependencies: quinn-proto 0.11.19 (RUSTSEC-2026-0338, -0340, -0343) and ruzstd 0.9.1 (RUSTSEC-2026-0344). deny.toml ignores RUSTSEC-2024-0449 (unsafe-libyaml, unmaintained, no fix). lindera parses YAML only from the config file in LINDERA_CONFIG_PATH.
  • parser/: no fixed-offset string slices, and no preallocation from an untrusted count.

Behaviour changes

  • An unqualified join column that two schemaless sides could own returns 42702. A declared side wins over a schemaless one.
  • engine='vector' means a vector-primary collection. A contradicting primary, or a missing vector_field / dim, returns 42601.
  • TYPE … spellings map to the engine they name. An unknown spelling returns 42601.
  • INSERT…SELECT and a native batch refuse a duplicate key with 23505.
  • NULL into a timeseries integer or time column is refused.
  • Aggregates inside a transaction count the transaction's staged rows.
  • A continuous aggregate and a collection cannot share a name: 42P07.
  • Fresh surrogates within one batch can be assigned in a different order. Uniqueness is unchanged.
  • A batch with a zero carried surrogate is refused before any row binds.
  • A multi-row INSERT or UPSERT on KV, document or vector-primary collections writes all rows or none. A failing row writes nothing; before this, earlier rows stayed.
  • A key repeated inside one statement: plain INSERT returns 23505, DO NOTHING skips it, a merge applies to the earlier row's result.
  • KV batch and document batch-upsert change events report one event per key, not one "*" event.
  • Multi-row document UPSERT returns the UPSERT n command tag.
  • The WAL record type SurrogateBind is SurrogateBindBatch (code 52 | 0x8000). No compatibility with older logs.

Removed

  • The legacy timeseries memtable features: series-cap eviction, hard-limit rejection and log entries. Nothing reached them.
  • Merge deferral for Manual continuous aggregates.

Lite follow-ups

This PR changes shared-crate APIs. Lite must handle each change below.

API changes

  • ContinuousAggregateDef.incarnation. Struct literals need ..Default::default().
  • New TimeseriesOp variants: RefreshAggregate, ReadAggregate, Partitions.
  • New MetaOp variants: AwaitContinuousAggregateRefresh, ContinuousAggregatesRegistered.
  • MetaOp fields: EnforceTimeseriesRetention.aggregates, TemporalPurgeColumnar.aggregates.
  • New EngineType::ContinuousAggregate, SqlError::WrongObjectType, SqlCatalogError::Unreadable.
  • AggregateExpr.filter / .arg_text. AggregateSpec.filter / .param (AggregateParam). TimeseriesScanPlan.having. TimeseriesOp::Scan.having / .tiering.
  • HashJoin post_group_by / post_aggregates are removed. NestedLoop and SortMerge gain left_input / right_input.
  • BatchInsert.if_absent. The insert_select visitor has no limit argument. UpdateFrom.source_alias.
  • ProviderScan.sources.
  • Removed: FlushedSeries, FlushedKind, TimeseriesWalBatch, Value::eq_coerced_exact_text.
  • New parser::query_function. parse_search_using_fusion returns Option<Result<..>>.
  • New KvBatchIntent { Put, Upsert, Insert, InsertIfAbsent, Merge { updates, rls_write_check } } and field KvOp::BatchPut.intent. Lite's adapter/kv/dispatch.rs:159 destructures BatchPut without .. and does not compile until it handles intent.
  • New DocumentOp::BatchUpsert and VectorOp::DirectBatchWrite, new struct VectorDirectRow, new method VectorOp::direct_batch_point_writes(). Lite's exhaustive matches need both arms.

Behaviour to handle or check

  • Graph DSL and DDL parsers return errors where they accepted stray clauses.
  • lower_aggregate handles FILTER and the STDDEV / VARIANCE family.
  • Join references come qualified. An ambiguous column returns 42702. CTE and derived-table bodies arrive as qualified computed columns.
  • lower_cte fails for any CTE reference.
  • A recursive CTE deduplicates its base rows.
  • Vector-primary rows render the vector column.
  • usize::MAX means an unbounded limit.
  • Lite cannot create engine='vector', and it accepts the strict / document aliases.
  • sync_interop_vector.rs needs engine='document_schemaless'.
  • The KV index coverage rule.
  • Check whether Lite stores a NULL timeseries text value as ''.
  • Carried over from fix: dispatch capacity refusals, KV sorted index ordering, array sync results, vector fixes #416: remove the MetaOp::CalvinFlush arm, rename hilbert_range to shard_bucket, and update three comments that quote the old dist_to_node text.

Docs (nodedb-docs, already on main)

  • Continuous aggregate DDL with BUCKET and AGGREGATE, and partitions.
  • SHOW PARTITIONS for change streams.
  • engine='vector' collection mode.
  • Typeguard write enforcement and conversion to strict.
  • Unqualified column resolution in joins, and derived tables as join sides.
  • Partition merging and its tuning keys.

How to check

  • cargo nextest run --workspace --exclude nodedb-cluster-tests --all-features --retries 0: 21792 of 21792 passed.
  • cargo nextest run -p nodedb-cluster-tests --all-features --retries 0: 504 of 504 passed.
  • Seeds of four tests run one multi-row statement per chunk, not one statement per row. Their times fell from 28–64s to 1–13s, so a slower CI runner stays far from the timeouts.
  • Calvin, backup, restore, constraint and lease cluster tests, run together 3 times: 116 of 116 passed each time.
  • Clippy is clean on the workspace with all features, and on nodedb-client with no features, native and remote.
  • Every scripts/ci/check_* gate passes, including --self-test.
  • Test data in these runs lived on disk, not on tmpfs. That exposes per-row fsync costs that tmpfs hides.

…uses

Document the refresh policies, retention option, supported aggregate
functions and the read-only per-bucket relation returned by a
continuous aggregate.
…aggregates natively

Continuous aggregates now keep persisted per-bucket state folded from
flushed partitions, survive restart, and are readable as read-only
relations with refresh policies and retention. Timeseries aggregation,
ordering and tier selection run natively in the data plane, with
partition rewrite and flush recovery.

Planner and executor gain aggregate FILTER clauses, aggregates over
joins, UPDATE ... FROM and MERGE row sources, unqualified join column
resolution, generated columns, state-transition checks by role and
typeguard defaults across write forms. PromQL gains remote read and
metric registration over the shared source layer.
An index registered without backfill over existing rows lacks those
rows, so an equality probe on it could miss matches. Track which
indexes cover the whole collection, probe only those, and read the
collection otherwise so results stay correct. Probe keys use the
coerced-equality bucket so an index finds every row a WHERE = matches.
Coverage is carried through checkpoint and restore.
Late rows flush into a new partition beside the one their interval
already has. A maintenance pass now merges those partitions in bounded
groups once the interval has been quiet, staging the merged partition
and committing it through a manifest that crash recovery finishes or
rolls back. Merge pacing is tuned by merge_groups_per_tick,
merge_max_rows and merge_quiet_ms.

Replaces the out-of-order buffer and the previous merge cycle.
CREATE and ALTER now install the collection's CRDT constraint version
on the applying replicas before they return, so a delta pushed right
after is not fenced with a retryable Gap. Reconcile and the metadata
proposer share the install path, and a failed DDL compensates it.
A prepared statement's result column now carries the key the executor
emits its value under, separate from its announced name. Join columns
and aliased column references no longer come back empty from Execute.
A derived table can be a JOIN side: the planner embeds its body as the
join input and qualifies its columns by the alias. CTE definitions that
read earlier definitions inline in dependency order, a CTE body can be
a join, and star expansion keeps aliased column names. Output column
types are resolved through derived tables and CTEs.
Replace direct string slicing in the escape-string terminator check and
the MATCH IN-target parser with non-panicking accessors. Stop sizing the
PromQL row vector from the untrusted array header count.
Use MissedTickBehavior::Delay so a late wake no longer fires a burst of
back-to-back ticks. Skip any sample taken less than one interval after
the previous one and keep the older baseline, because an idle core moves
its heartbeat once per poll and a shorter window reads it as stalled.
Binding N keys cost N catalog transactions, each with its own durable
sync. Add batched catalog ops that write the forward and reverse rows
of many keys of one collection in a single transaction, first-wins,
together with a due allocator watermark row.

Fresh draws, bound-key assignment, and identity binding for writes,
restore, snapshot apply, and the surrogate exchange now take whole
batches under one registry write lock. WAL bind records follow the
commit under the same lock.
The test slept a fixed 50ms before asserting the leader had applied
its no-op and the proposed entry. The log fsyncs to disk, so a loaded
host could miss that window. Poll with a 5s deadline instead.
A constraint install proposed directly on the local node's Raft proxy,
so a node holding no replica of the collection's data group could not
install. The install is now a SetConstraints plan routed through the
gateway to the group's leader, and the reconcile loop delivers the same
way, keyed per database.

The DDL is already committed when its install runs, so an install error
no longer fails the DDL. It is recorded as a diagnostic and left to the
reconcile loop. Constraint ops and refresh waits home to their
collection's vShard through a shared home_collection function used by
the router and the forwarded-plan executor.
REFRESH CONTINUOUS AGGREGATE proposed its cut and waited for the fold
on the local node, which fails when the node does not replicate the
root source's data group. The cut and the wait now run on a replica of
that group: locally when this node hosts it, through the gateway to the
group's leader otherwise. A wait that reaches a core not owning the
root answers at once.

A core holding a refresh wait or an aggregate read now folds its own
backlog on every tick, so the wait no longer depends on the
maintenance pass. Cluster test diagnostics report the first DDL error
and the server error of a failed read.
The node a gateway task forwards to has no caller identity, so a scan
of a provider relation answered no rows there. The pgwire, NDJSON and
WebSocket transports now materialize provider rows where the identity
is, before sending the task through the gateway.
A lease lapses when a renewal round runs long, and that round is
still in flight. The grace now covers one renewal interval for the
coverage wait plus the holder's read timeout for the reply, so a
round the leader answers in time admits the waiting statement.
Add a harness barrier that waits until every node holds an
authorization lease, and run it after cluster readiness and after
leader DDL, so the next statement is not refused as retryable.

The Calvin backup test now bounds each awaited step with a named
deadline. The snapshot constraint test reruns reconcile passes until
one delivers and waits for full apply convergence before dropping
the local constraint set, so a late-applied entry cannot reinstall
it.
@farhan-syah farhan-syah added the run-ci Opt this PR into the full test suite; re-add to force a re-run label Oct 14, 2026
A multi-row SQL INSERT or UPSERT now lowers to a single batched
operation per collection instead of one request per row. This covers
key-value (BatchPut with a per-statement conflict intent), schemaless
document upserts, and primary-vector inserts.

Surrogate bindings are logged as one batch record per assigner call,
split to fit a double-write slot, and the double-write buffer stages
slot images in memory so one flush covers many records. WAL replay,
replication encode/decode, transaction staging, RLS injection and
change-event extraction handle the batched forms. Wire and in-process
tests cover multi-row inserts, array object literals, and restart
behavior for skipped inserts.
@farhan-syah
farhan-syah merged commit 410ecae into main Oct 14, 2026
13 checks passed
@farhan-syah
farhan-syah deleted the fix/sql-correctness branch October 14, 2026 13:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment