Skip to content

Evaluate volatile column DEFAULTs per row instead of freezing them into cached plans - #316

Merged
farhan-syah merged 23 commits into
mainfrom
fix/sequence-accessors-and-volatile-defaults
Sep 10, 2026
Merged

Evaluate volatile column DEFAULTs per row instead of freezing them into cached plans#316
farhan-syah merged 23 commits into
mainfrom
fix/sequence-accessors-and-volatile-defaults

Conversation

@farhan-syah

@farhan-syah farhan-syah commented Sep 10, 2026

Copy link
Copy Markdown
Member

Non-deterministic expression evaluation ran at plan-construction time, and plans are cached by SQL text. Sequences were the loudest instance, not a special case.

DEFAULT UUID_V7() on a primary key raised SQLSTATE 23505 on the second identical INSERT. The cached plan replayed the first execution's frozen UUID. That defect was live on main and unfiled.

Reported defects fixed

Reported behaviour Resolution
nextval/currval/setval raised 42883 Registered, resolved through SqlCatalog against the sequence registry
currval read the node-wide counter Session-scoped. The registry method is renamed to name the node counter
evaluate_default_expr returned Option, and None dropped the column silently Returns a typed Result. Silent omission is not expressible
The schemaless catalog adapter hardcoded default: None, dropping every DEFAULT Recovers the DEFAULT like the columnar arm
DDL never validated a DEFAULT expression Gated at CREATE COLLECTION against the same FunctionRegistry the resolver uses
SERIAL created its sequence, and nothing linked the column to it Expands to DEFAULT nextval('<collection>_<field>_seq')
Volatile DEFAULTs froze into cached plans A plan holding a volatile call is no longer cache-eligible

Model adopted

Function volatility is a first-class axis, orthogonal to FunctionCategory. A volatile function never folds into a reusable plan. This follows PostgreSQL.

Defects found while fixing, also closed

These were not in the source report.

Defect Scope
Two sequence values allocated per row Columnar
UPSERT never materialized DEFAULTs Document engines
Column DEFAULTs skipped entirely Vector-primary inserts
A declared column type collapsed to string behind any trailing modifier Schemaless, columnar
A TIMESTAMP stored in milliseconds and read as microseconds Timeseries
DML RETURNING announced an empty OutputSchema, disagreeing with an equivalent SELECT on the same row pgwire
A time key rendered as raw storage milliseconds Join, GROUP BY
CONVERT COLLECTION c TO document_strict (name WIDGET) silently produced a String column DDL
A DEFAULT clause in a CONVERT column definition was parsed and discarded DDL
A typeguard DEFAULT no_such_function() wrote NULL on every row and reported nothing, because nodedb_query::functions::eval_function returns Ok(Value::Null) for an unknown name Typeguard

Consolidation

  • Declared-type classifiers: five duplicates route through one ColumnType::from_declared_type in nodedb-types.
  • DEFAULT validator: one validator serves CREATE COLLECTION, CONVERT and CREATE TYPEGUARD.
  • Write-route resolution: moved from nodedb into EngineRules, removing a match engine from outside nodedb-sql.
  • DEFAULT compilation: a DEFAULT compiles once per statement into a form whose evaluation takes no string. Per-row re-parsing is not representable.

Testing

16100 workspace tests pass. 367 cluster tests pass. cargo clippy --workspace --all-targets -- -D warnings is clean.

Coverage added in sql_sequences.rs, sql_default_expressions.rs, sql_default_volatility.rs, sql_declared_column_types.rs, sql_default_vector_primary.rs, timeseries_join_time_rendering.rs, sql_convert_column_defs.rs, sql_typeguard_default_gate.rs.

Known limits

Limit Effect
A remote TimeseriesScan arrives in microseconds while a local side is milliseconds A distributed join's ON compares mismatched units
A transforming computed expression over an instant evaluates on microseconds through a join and milliseconds through the direct scan A passthrough agrees. time_bucket over a join has no coverage
A cross-field typeguard guard such as VALUE LOWER(status) passes the column-DEFAULT gate Insert fails, because the DEFAULT evaluator const-folds it
nodedb-lite reads SqlPlan::Insert.engine and matches engine_type in lower_insert Needs route: _ at the next nodedb-sql version bump
A sequence accessor in a per-row context raises 0A000 SELECT nextval('s') FROM t is refused. Tracked by #314

Filed as #317 (the first two limits), #318 (the typeguard guard) and #319 (a SERIAL DDL-surface mismatch found while fixing).

Registering the accessors makes them pass the plan-time existence gate. A per-row
SELECT-list has no evaluation for them, so the resolver refuses that scope rather
than letting nodedb_query::functions::eval_function return Ok(Value::Null) per
row. Per-row evaluation needs a post-Data-Plane stamping stage that does not exist
today. nodedb/src/control/gateway/fuser.rs concatenates payloads in arrival
order, not output-row order.

Breaking changes

  • BREAKING: Extended-query RowDescription OIDs narrow to the declared width. INTEGER announces INT4, not INT8.
  • BREAKING: DECIMAL/NUMERIC and VECTOR/GEOMETRY announce TEXT.
  • BREAKING: DML RETURNING on the simple-query path renders through the column's real type, so a whole float renders 1 rather than 1.0.

Issues closed

Closes #294 — the source report.
Closes #298 — a timeseries TIME_KEY declared TIMESTAMP announced OID 25 carrying raw epoch milliseconds. It announces OID 1114 and renders a timestamp.
Closes #313 — an unknown sequence in a DEFAULT raised 42601. It raises 42704.

#314 stays open. This branch preserves its 0A000 refusal and adds coverage pinning it.

…gines

Add coverage for nextval/currval/setval, SERIAL column allocation, and
DEFAULT expressions (nextval, currval, scalar functions, UUID_V7) across
strict, schemaless, KV, and columnar engines. Also cover the unknown-sequence
and unevaluable-DEFAULT error paths.
Add wire-level integration tests asserting UUID_V7(), NOW(), and
nextval() DEFAULT expressions produce fresh values per execution
rather than replaying a cached plan's frozen result, including
across repeated byte-identical INSERT statements.
The schemaless-document arm dropped every column's DEFAULT clause,
the primary key's included, when building catalog ColumnInfo. A
declared default never reached evaluation, so the column was written
absent and read back as NULL. Recover each field's default from its
declared type in stored.fields, the way the columnar arm already does.
Introduce nextval, currval, and setval as volatile scalar functions
routed at plan time to SqlCatalog sequence methods, with per-session
last-value tracking for currval.

Add a plan-level volatility scan and a Volatile variant so a plan
containing a volatile call (sequence functions, UUID/ULID/nanoid
generators, DEFAULT expressions) is excluded from the plan cache and
re-evaluated on every execution instead of freezing a value. Wrap
lowered physical plans in a LoweredPlan that carries this cache
verdict through the gateway, and thread the new SQLSTATEs and error
constructors needed to reject invalid sequence access.
nextval/currval DEFAULTs now resolve through the catalog instead of
being silently dropped. evaluate_default_expr and
materialize_row_defaults require a SqlCatalog handle, and
ConvertContext carries an optional Arc<dyn SqlCatalog> so INSERT and
UPSERT conversion can reach it after planning returns.

Consolidate the four drifted copies of the SqlError -> Control-Plane
Error mapping (only one mapped RetryableSchemaChanged) into a single
plan_error_map module shared by query planning and DEFAULT
materialization. Extract engine routing for row-shaped INSERT/UPSERT
converters into a dedicated route module, and DML helper parameters
into a params module.
…AULTs

SERIAL and BIGSERIAL columns now expand to their backing type plus a
DEFAULT nextval('{collection}_{field}_seq') naming the sequence the
caller creates, instead of only the bare type. parse_fields_clause and
parse_fields_clause_from_pairs take the collection name to build that
sequence name.

Add a DDL-time gate that classifies and parses every declared column
DEFAULT through the same paths evaluate_default_expr uses, so a call
to an unregistered function is refused at CREATE with SQLSTATE 42883
instead of surfacing at the first INSERT. The gate parses a setval or
sequence-accessor DEFAULT without evaluating it, so declaring a column
never advances a sequence.
Prepare and describe round trips for an INSERT with a nextval DEFAULT
must plan the statement without allocating from the sequence; only
executing the statement advances it.
A column declared with a numeric type keeps its OID, comparison
semantics, and exact integer round trip when followed by a DEFAULT
clause or a NOT NULL modifier, across the document (strict and
schemaless) and columnar engines.
A vector-primary insert splits each row into the vector field and a
payload map before the shared DEFAULT pass runs. Cover a nextval and
a UUID_V7 key DEFAULT and a non-key payload DEFAULT, each on a column
the insert omits.
…serts

Extract the KV engine's DEFAULT-materialization helper into a shared
declared_defaults module and reuse it for vector-primary collection
inserts, which previously bypassed EngineRules::plan_insert and never
expanded a declared DEFAULT. Materialization runs before the row's
declared-type coercion and range checks, so a default is validated
exactly like a supplied literal.

Track whether any materialized default came from a volatile
expression (e.g. nextval) on VectorPrimaryInsert, matching the KV
insert plan's existing gate, so a plan containing one is excluded
from the plan cache instead of replaying a frozen value.
A write plan's OutputSchema previously carried no columns, so the
simple-query protocol fell back to deriving a RowDescription from the
row payload rather than the catalog. Thread the parsed RETURNING spec
through every planning entry point down to build_output_schema, which
now announces the target collection's declared columns for a named
clause and defers to the row-derived shape only for RETURNING *.

Propagate the same announced schema through the Calvin dispatch and
response paths and the neutral DDL DML path, so a RETURNING write
renders identically regardless of which route it took.

Split output_schema.rs into a directory of build/columns/returning
modules along the way, and fix parse_type_str to strip DEFAULT/NOT
NULL modifiers off a catalog column's raw declared-type text before
resolving its wire type.

Propagate a vector-primary insert's msgpack serialization error
instead of discarding it into an empty payload.
The memtable stores every timeseries instant in epoch milliseconds,
but a client reads a declared TIMESTAMP/TIMESTAMPTZ cell as epoch
microseconds. Raw scans and RETURNING rows from ingest handed back
the raw millisecond count unscaled, understating the stored instant
by three orders of magnitude on read.

Add CoreLoop::ts_instant_columns to list a collection's declared
instant columns and scale_instant_cells to rescale those columns'
values from milliseconds to microseconds once rows leave a scan or
ingest. A BIGINT TIME_KEY shares the same storage column but is not a
declared instant, so it keeps the integer that was inserted.
Drop the separate result_fields_for_returning path and its private
SqlDataType-to-pg mapping. Describe now infers RETURNING columns
through the same build_output_schema call used for SELECT
projections, so the extended-query and simple-query paths can never
disagree on a column's type.

Update the wire test to read numeric RETURNING columns by their
announced OID (int2/int4/int8, float4/float8) instead of assuming
int8/float8, since OutputSchema reports the narrower declared width.
… scans

An output schema for a JOIN previously defaulted every projected
column to Text. It now resolves each column against the catalog of
the side it came from, keyed on the qualified name the join executor
emits, falling back to Text only for a bare name two sides declare
with different types.

A grouped, non-bucketed timeseries scan now announces its GROUP BY
keys with their catalog types instead of leaving the whole schema
untyped, matching the shape its aggregate encoder emits.
A join reads a timeseries collection through scan_collection, which
hands back its stored epoch-millisecond value, while a client reads a
TIMESTAMP cell as epoch microseconds. Hash, nested-loop, and
sort-merge joins now rescale the declared-instant cells they read from
their own local scans immediately before emission, after every
predicate has run so the join itself still compares milliseconds
against milliseconds.

A side supplied by a sub-plan is excluded: its rows were already
rescaled by the handler that produced them, so rescaling again would
double the correction.
A grouped timeseries scan reduces every key to a string, and the
aggregate encoder rendered every one of them back as text regardless
of the column's real type. It now resolves each GROUP BY column's
storage kind — declared instant, integer, float, or text — from the
collection's declared schema or its resident memtable schema, and
renders the key with that type. A declared TIMESTAMP key converts its
stored milliseconds to the microseconds a client expects, matching how
a direct SELECT of the same column renders it.
…UP BY

A stored TIMESTAMP time key can be read directly, projected through a
JOIN, or used as a GROUP BY key, each through its own scan and
encoder. Cover that all three render one stored instant identically,
anchored against the instant the INSERT actually supplied.
Each engine's EngineRules now picks the WriteRoute (Document or
ColumnarFamily) when it builds an Insert or Upsert plan, carrying it
on the SqlPlan variant instead of re-deriving it later from the engine
type. The conversion layer reads the route directly, removing the
separate route-resolution module.
A projection that renames a joined declared-instant cell (an alias or
a computed expression) hid it from the millisecond-to-microsecond
rescale, which matched on the emitted output name. Rescale now runs on
the merged row against the keys the join itself wrote, before any
projection or computed column touches it, so a rename or computation
downstream cannot bypass the scale.
ColumnType::from_declared_type resolves a catalog fields entry's raw
DDL text (e.g. "INT DEFAULT 5") to its ColumnType through one shared
classifier, and ColumnType::is_instant answers whether a column carries
an instant. The Control Plane's parse_type_str and the Data Plane's
declared_type_is_instant / memtable_column_type now both defer to
these instead of keeping their own copies of the integer/float keyword
lists and timestamp-variant matches, so the two planes cannot drift on
which columns are instants or which spellings resolve to which type.

DECLARED_INT_KEYWORDS and DECLARED_FLOAT_KEYWORDS name the accepted
PostgreSQL wire-width spellings once, shared with the corresponding
IntWidth/FloatWidth classifiers.
Split planner::defaults into a module with a ColumnDefaults /
CompiledDefault pair: declaration text is classified and parsed once
into a DefaultKind (generator, literal, or parsed expression), and
each row then evaluates the compiled form without re-parsing. The KV
insert path, declared-defaults materialization, and the columnar/
document row-expansion path (expand_row_defaults) all build one
ColumnDefaults outside their row loops instead of re-invoking
evaluate_default_expr per row per column.

Drop Volatility::Stable, which named a per-statement reuse boundary
nothing in the planner ever used; Volatility now distinguishes only
Immutable (foldable at plan time) from Volatile (fresh per call).
Route the TYPEGUARD type-expression parser through the same ColumnType
resolver CONVERT and CREATE use, so one declared spelling (VARCHAR(n),
TIMESTAMP WITH/WITHOUT TIME ZONE, SYSTEM_TIMESTAMP, DECIMAL(p, s), and
every registered numeric alias) resolves to the same type everywhere,
and a trailing word after a keyword is rejected instead of silently
dropped.

Generalize the CREATE-time column DEFAULT gate into a shared
column_default module and reuse it from CONVERT's column list and
from a new typeguard DEFAULT/VALUE gate, so a declaration naming an
unregistered or non-deterministic function is refused at DDL time
across all three surfaces instead of failing at first write.

Split the CONVERT COLLECTION handler out of its single file into a
directory of driver, column-defs, type-map, and typeguard-columns
modules to hold the added validation without growing an already large
file.
@farhan-syah farhan-syah added the run-ci Opt this PR into the full test suite; re-add to force a re-run label Sep 10, 2026
nextval/currval/setval only evaluate through SqlCatalog's sequence
state at plan time, in a FROM-less SELECT or a column DEFAULT. A call
over a FROM relation instead reached the row evaluator, which holds no
sequence state and silently returned NULL for every row.

Name the three accessor names once in a shared sequence_accessor
module and use it from both the constant folder and the new resolver
gate. TableScope::is_row_scope and ColumnScope::is_row_scope report
whether an expression sits behind a relation; convert_function_depth
refuses a sequence accessor there with the new
SqlError::SequencePerRowUnsupported.

Wire the refusal through to SQLSTATE 0A000 (feature_not_supported) on
both the plan-error and pgwire error-mapping paths via a new
crate::Error::FeatureNotSupported, and through error_classify for the
native surface.
@farhan-syah
farhan-syah deleted the fix/sequence-accessors-and-volatile-defaults branch September 10, 2026 11:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

run-ci Opt this PR into the full test suite; re-add to force a re-run

Projects

None yet

1 participant