Skip to content

Type the instant cell: one unit from storage to every protocol edge - #336

Merged
farhan-syah merged 21 commits into
mainfrom
fix/instant-unit-boundary
Sep 17, 2026
Merged

farhan-syah merged 21 commits into
mainfrom
fix/instant-unit-boundary

Conversation

@farhan-syah

Copy link
Copy Markdown
Member

Why

A TIMESTAMP cell's unit was decided by whichever path read it. The timeseries engine stores milliseconds; four Data Plane converters rescaled to microseconds on the way out, each keyed by a column-name list. A path without a converter emitted milliseconds, and the pgwire encoder read any number under a TIMESTAMP column as microseconds. A cross-node join compared a gathered remote side (microseconds) against a local side (milliseconds) and dropped every row; a time_bucket in a SELECT list saw milliseconds directly and microseconds through a join; strict INSERT ... RETURNING rendered 1970. The same flaw held for literals: an integer compared against an instant column was milliseconds in one pushdown, unordered on the row path, and never coerced on write or in a policy.

Closes #317

What changes

One typed instant, carried by the cell, from storage to every protocol edge.

Layer Shape
Wire cell msgpack fixext8, type 1 UTC / 2 naive, i64 epoch microseconds; nodedb_types::{InstantKind, write_instant, read_instant}; Value::DateTime / NaiveDateTime on both sides
Timeseries storage type ColumnType::Timestamp(TimeKind), TimeKind::{Millis, Instant(InstantKind)}; storage stays i64 ms, the emitter matches the kind
Emission columnar_read/convert.rs write_time_cell, TimeKind::cell_value, nodedb_types::columnar::ColumnType::time_cell — the four rescalers, instant_scale.rs, JoinParams.instant_columns, and ts_instant_columns are deleted
RETURNING cells RowsPayload.rows: Vec<Vec<NativeCell>> — typed Value, not pre-rendered text
Response path ShapedRow = BTreeMap<String, Value>; util/wire_json.rs value_to_wire_json is the one JSON edge; pgwire shape_encode/cell.rs encode_cell(Value, DdlColType)
Write literals nodedb-sql declared_type_coerce::coerce_to_instant: text is parsed, a number is epoch milliseconds, other kinds are refused naming the column
Predicate literals nodedb-sql planner/predicate_coerce.rs applies the same rule to WHERE / ON / BETWEEN / IN / UPDATE / DELETE; TimeKind::literal_ms lowers to the storage unit in both pushdowns
Bridge filter ScanFilter.value crosses as the tagged Value, not JSON; FilterOp::parse is fallible
Evaluators time_bucket, date_part, date_trunc, datetime, date_add/date_sub, date_diff accept and return typed instants

Behaviour changes

Path Before After
Cross-node join ON comparing two time keys matched nothing matches
Join ON between a timeseries time key and a document_strict TIMESTAMP matched nothing matches
time_bucket(...) in a SELECT list, grouped bucket column epoch milliseconds as an integer the instant, ISO-8601
date_part('year', ts) on a timeseries time key NULL the year
Strict INSERT ... RETURNING created_at 1970 the instant
Columnar TIMESTAMP from a flushed segment vs the live memtable integer vs ISO string the same typed instant
pgwire binary result format for TIMESTAMP / TIMESTAMPTZ text only binary supported, PostgreSQL epoch-2000 microseconds
Native protocol TIMESTAMP cell Value::String or Value::Integer by engine Value::NaiveDateTime / Value::DateTime
Integer under a TIMESTAMP column at the pgwire edge rendered as microseconds typed error naming the column
WHERE ts >= <integer> on the row path matched every row (cmp_coerced answered Equal) the integer is epoch milliseconds; an unordered pair matches nothing
WHERE f64_col = $1 with a bound FLOAT8 matched (the JSON hop flattened Decimal) matches — Decimal is a number in coerced comparison
NdbDateTime::parse of +05:30 / Z offsets seconds and offset dropped applied; trailing text refused
DEFAULT <literal> a column cannot hold accepted at CREATE, refused at INSERT (kv) or stored (others) refused at CREATE / ALTER ADD COLUMN, 42804 / 22003
Timeseries column DEFAULT dropped by the engine rule materialized in the planner like every engine; TIME_KEY DEFAULT is the row's time key
MERGE action literals stored untyped coerced like INSERT VALUES / UPDATE SET
Timeseries ingest time-key value the line cannot carry row stamped with the ingest clock refused naming the column
Restore of a timeseries collection onto a fresh node inferred an integer schema, stamped the clock Data Plane declaration re-registered before reissue; the instant round-trips
Timeseries aggregate whose WHERE the bitmask evaluator cannot lower empty result (memtable) or predicate ignored (partitions) fails the statement

Fixed on the way

  • FOR READ policies now govern columnar, spatial, and timeseries scans, aggregates, joins, LIMIT, and the in-transaction overlay. Policy literals are typed against the declared column at CREATE RLS POLICY and recompiled from stored text on load and after ALTER. A policy that cannot compile or serialize installs deny-all, never admit-all. INTERSECTS emitted an operator name the wire decoder mapped to match-all; an unknown operator is now a decode error.
  • One rmpv ↔ Value converter (util/rmpv_value.rs) replaces four; one msgpack → Value decoder replaces two.
  • Memtable limit * 10 raw-row cap that silently truncated filtered columnar results is removed.
  • Splits: json_msgpack/reader/, msgpack_scan/reader/, columnar_memtable/memtable/, grouped_scan/strategies/, columnar_read/scan/, handlers/spatial/, response_shape/{compose,types}/, pgwire/handler/shape_encode/, sql_plan_convert/filter/, security/predicate_eval/, planner/{dml,merge}/.

Compatibility

Pre-1.0, no migration:

  • Plain-msgpack encoding of Value::DateTime / NaiveDateTime is fixext8, not an ISO string. Every reader in this workspace is updated; nodedb-lite must mirror it.
  • Timeseries partition schema names: instant_naive / instant_utc / timestamp. Memtable snapshot layout of ColumnType changed. A partition written before this reads back as Millis.
  • RowsPayload, ScanFilter, ShapedRows, StoredRlsPolicy (stores predicate_text, not a compiled predicate), SqlPlan::Insert / Upsert (volatile_defaults, no column_defaults), and FilterOp (no From<&str>) changed shape.
  • Secondary-index entries for indexed strict TIMESTAMP columns written as millisecond text no longer match.
  • Query functions listed above and EXTRACT-style scalars accept typed instants; an integer literal against an instant column is epoch milliseconds everywhere.

Unrelated defect found while tracing: #333 (timeseries aggregates drop what the native scan cannot express).

…eTime

Value::DateTime and Value::NaiveDateTime now serialize to a dedicated
fixext8 ext type (UTC vs naive) carrying epoch microseconds, instead
of a formatted string. This gives byte-decodable comparison and lets
the JSON transcoder render the value as ISO 8601 without a full
msgpack-to-Value decode.

- nodedb-types::json_msgpack::instant_ext defines the encode/decode
  and InstantKind, re-exported as write_instant/read_instant/InstantKind
  from nodedb-types; Value gains as_instant() to extract either variant.
- The transcoder recognizes the instant ext and writes an ISO 8601
  string; any other ext type still renders as null.
- nodedb-query's msgpack_scan writer/compare gain matching
  write_instant/write_kv_instant helpers and an instant type rank so
  raw-byte field comparison orders instants correctly (by kind, then
  signed micros) instead of falling into the generic ext bucket.
- Split the single-file json_msgpack/reader.rs and
  msgpack_scan/reader.rs into per-concern submodules (cursor/json/native
  and tags/scalar/skip/value respectively); no behavior change beyond
  the module boundary.
Add coverage that a TIMESTAMP time key or column denotes the same
stored instant regardless of the route that reads it: direct SELECT,
INSERT ... RETURNING, an INNER JOIN (local, shuffle, and cross-node),
time_bucket, date_part, and GROUP BY. Cases span a document_strict
TIMESTAMP column (strict_typed_column_rendering.rs, new), a
timeseries TIME_KEY joined against another timeseries collection or
a document_strict TIMESTAMP column, and single-node vs 3-node
cluster cross-node joins comparing TIME_KEY columns in their ON
predicate.
Datetime scalar functions (datetime, extract, date_trunc, date_add,
date_sub, date_diff, time_bucket) previously required string
arguments and always parsed/returned via string ISO 8601, discarding
whether the value was a UTC or naive instant. Add instant_arg to
accept Value::DateTime, Value::NaiveDateTime, or a parseable string,
and have each function return the same instant kind it received.
time_bucket now buckets typed instants on epoch microseconds (floored
toward negative infinity) while still supporting integer millisecond
timestamps bucketed on epoch milliseconds.
…trings

RowsPayload cells (RETURNING, RLS row values, trigger batches, bulk
DML, merge, timeseries scan/sort/merge) move from pre-rendered
Option<String> TEXT cells to NativeCell, a Value written in plain
(untagged) msgpack. NativeCell shares its plain-msgpack reader/writer
with value_to_msgpack/value_from_msgpack via a native decoder now
generic over zerompk::Read, so both entry points produce identical
bytes and agree on decode.

RETURNING now retypes only text cells found under a numeric, bool or
timestamp column instead of every cell, since typed cells already
carry their number/bool/instant form.

nodedb/src/util/rmpv_value.rs centralizes the rmpv::Value <->
nodedb_types::Value conversion (including instant ext round-tripping)
used by timeseries rows, trigger batches and RETURNING projections,
replacing the ad-hoc converter that lived in the trigger batch
collector.
Replace the unit-less TsColumnType::Timestamp with Timestamp(TimeKind),
where TimeKind::Instant carries InstantKind::Naive/Utc and TimeKind::Millis
marks a declared integer time column. The columnar bridge, segment codec,
schema evolution, sparse index, grouped scan, ILP ingest, and detection
paths now key off this kind instead of assuming every timestamp column is
the same representation, so a UTC instant maps to Timestamptz and a millis
column keeps its Int64 storage instead of being coerced to a bare
timestamp.

Split columnar_memtable/memtable.rs and grouped_scan/strategies.rs into
per-concern submodule directories.
Time columns now render as their declared kind (typed instant or raw
milliseconds) at the point a scan emits them, using the column's own
TimeKind rather than the ts_instant_columns() name list. Columnar
memtable and partition row emission, aggregate/sort/ingest paths, and
msgpack filter/group-key/value-ops helpers thread this typed rendering
through.

This removes the join-side instant rescale pass entirely: a join no
longer needs to know which locally-scanned columns are instants and
rewrite them after emission, since every row already carries correctly
typed time cells by the time a join reads it. instant_scale.rs and its
threading through join params/dispatch are deleted.
The segment reader infers a column's physical kind from its codec, so
a declared TIMESTAMP/TIMESTAMPTZ column decodes as the same Int64 kind
as any other eight-byte integer column, losing the instant/millis
distinction the row emitter needs. ColumnType::instant_kind() and
time_cell() give every reader (segment decode, memtable access, PK
encoding) one place to type a stored time cell from the schema's
declared type: Timestamp yields a naive instant, Timestamptz a UTC
instant, and every other type (SystemTimestamp, Duration, the
bitemporal audit columns) keeps the raw integer.

decoded_col_to_value and ColumnData::get_value now take the column's
declared ColumnType and merge the Int64/Timestamp decode arms, since
both read the same physical storage. row_to_projected_json is renamed
to row_to_projected_value and returns a nodedb_types::Value directly
instead of routing through serde_json, since every caller already
works with typed row values and now reuses the shared decoder for the
DML predicate row read as well as the base scan.

DecodedColumn drops #[non_exhaustive] now that every reader matches it
exhaustively.

columnar_read/scan.rs is split into a scan/ directory (execute, order,
params, block_skip) to stay within the file size limit as its callers
change.
Row-level-security FOR READ policies now govern SELECT results on
columnar and spatial collections, not just columnar writes. The scan
decodes the policy's ScanFilter payload once up front and rejects a
statement outright when the payload is undecodable, rather than
treating it as no policy. Matching rows are filtered by the query's
WHERE predicates first, then the read policy, on flushed segments,
the live memtable, and the in-transaction overlay alike, so excluded
rows never consume a LIMIT slot or reach a join partner.

The spatial scan handler splits from a single file into full_scan,
prefilter, and rtree_scan modules to carry the new policy parameter
alongside the existing full-scan and R-tree-prefiltered paths.
Adds a wire test covering read-policy enforcement on columnar reads.
Row-level-security FOR READ policies now govern raw and grouped
timeseries reads, matching the enforcement already in place for
columnar and spatial. TimeseriesOp::Scan carries the caller's
rls_filters payload through dispatch; the handler decodes it once up
front and fails the statement outright on an undecodable payload
rather than treating it as no policy.

The raw scan evaluates the policy per row, after the WHERE
predicates and before a row takes a LIMIT slot, on the live
memtable, flushed partitions, and the in-transaction overlay alike.
The aggregate path instead joins the policy onto the WHERE
predicates and pushes the combined set into the grouped scan, so
excluded rows never reach an accumulator; the COUNT(*) metadata fast
path is skipped whenever a policy is present.

eval_filters_to_bitmask and the aggregate_memtable/aggregate_partition
callers now return Result instead of silently treating an unlowerable
predicate as "no filter": a policy shape the grouped scan cannot
express fails the aggregate rather than aggregating ungoverned rows.
UnsupportedPredicate wires into the crate's central error type.

Adds a wire test covering read-policy enforcement across the
memtable and flushed-partition paths, with harness support for
lowering the timeseries memtable budget so a test can force a flush.
Replace direct ShapedRows struct-literal construction across neutral
DDL handlers with the from_json_rows/text_rows/with_notice helpers,
removing repeated notice: None and column_types boilerplate at each
call site.
Route response shaping through nodedb_types::Value end to end instead
of serde_json::Value, so a cell keeps its typed form (instant, bytes,
decimal, etc.) until a protocol renders it at its own edge. Adds
decode_payload_value as the typed counterpart of decode_payload_to_json,
and a single value_to_wire_json/row_to_wire_json conversion in
util::wire_json + response_shape::cell that every protocol (pgwire,
HTTP, native) and the redaction path now render through.

Splits response_shape::compose and response_shape::types into
directories (compose/kernel.rs, compose/materialized.rs,
compose/array_slice.rs; types/plan_kind.rs, types/shaped.rs) to keep
each concern in its own file as the row representation changes.
Timestamp and Timestamptz columns now go through coerce_to_instant
alongside the existing Int64/Float64 coercion: a typed instant is
retagged to the declared kind, ISO-8601 text is parsed, and a numeric
literal is read as epoch milliseconds, the unit every engine's own
ingest path already expects. A literal with no representable instant
is refused at the statement, naming the column and the literal, and
this is the exact result of taking the coercion path once instead of
guessing at write or read time.
Splits handler/shape_encode.rs into a directory (cell.rs / response.rs
/ mod.rs) and adds a binary encode arm for Timestamp/Timestamptz, so a
client requesting binary results gets PostgreSQL binary timestamp
(microseconds since 2000-01-01) instead of a text-format downgrade.
result_format::binary_supported and resolve_result_formats now honour
that arm, and ddl_encode / response_shape::project route every cell
through the one shared encoder instead of a local JSON-to-text match.

response_shape::cell gains cell_text and instant_of, the typed
readings a timestamp column renders a cell through, and
response_shape::returning retypes announced-timestamp text into a
typed instant (NaiveDateTime for TIMESTAMP, DateTime for TIMESTAMPTZ)
instead of parsing it as an epoch integer.

nodedb_types::NdbDateTime::parse now accepts a trailing UTC offset
(+05:30, -0800, +02) in addition to Z, refuses partial or malformed
spellings that the previous parser silently truncated, and both
timestamp binary decoding and offset parsing are covered by new wire
and unit tests.
Value::cmp_coerced silently ordered any unordered pair (an integer
against an instant, text against a number, a NaN) as Equal, so a range
predicate over such a pair matched every row instead of none. Replace
it with partial_cmp_coerced, returning None for a pair with no defined
order, the row-level counterpart of PostgreSQL refusing to compare two
incompatible types. cmp_coerced remains for ORDER BY / MIN / MAX,
where an unordered pair now maps explicitly to Equal so a stable sort
keeps it in place.

Also extends eq_coerced/ordering to compare a Decimal literal against
a Float or Integer through the same numeric path as the existing
Integer/Float/String coercions.
Splits the 500+ line file into a directory: expr_lower.rs (raw WHERE
SqlExpr -> ScanFilter reduction) and serialize.rs (Filter tree ->
ScanFilter msgpack encoding), with mod.rs re-exporting the entry
points. No behavior change.
A literal in WHERE, ON, or WHEN compared against a declared TIMESTAMP
/ TIMESTAMPTZ column reached the planner exactly as written: a numeric
literal stayed an untyped integer, and an instant column has no
defined order or equality against an integer, so `=` never matched
and `>=` matched nothing or matched by accident depending on the
evaluator. predicate_coerce::coerce_predicate_literals walks the
predicate tree (column <op> literal in either orientation, BETWEEN
bounds, IN lists, through AND/OR/NOT) and resolves each literal
against the column's declared type through the same coerce_value the
write side already uses, so an instant column is always compared
against an instant. A literal with no representable instant is
refused at the statement, naming the column and the literal, instead
of silently matching nothing.

The primary-key column and every non-instant declared type are left
untouched: the primary key derives identity from the literal's own
rendering on both sides, and every other type is already covered by
the Data Plane's coerced comparison.

Wired into convert_where_to_filters (the one choke point for SELECT,
UPDATE/DELETE, HAVING, LATERAL, and MERGE WHEN predicates) and into
join ON post-filters. declared_type_coerce::coerce_value is exposed to
the new module, and its type-mismatch messages are reworded to state
what a literal is, not what a column can't store.
… kind

FilterOp::parse_op (and the From<&str>/From<String> impls built on it)
silently mapped any unrecognized wire tag to MatchAll, so a typo or a
future operator name decoded as "match everything" instead of failing.
Replace it with a fallible FilterOp::parse returning UnknownFilterOp,
wired through serde deserialization and every msgpack/wire decode
path. FilterOp also drops its Default impl (MatchAll is no longer a
sentinel default), and value_ops::compare_values becomes
partial_compare_values, returning None for a pair with no defined
order per the new Value::partial_cmp_coerced, propagated through
binary-op evaluation and graph pattern predicate checks.

Adds TimeKind::literal_ms / cell_value (columnar_memtable::time_literal)
as the one place a time column's stored millisecond count is lowered
from, and read back to, the value its kind denotes: an Instant column
reads a typed datetime or datetime text, a Millis column reads an
integer, a truncated finite float, or datetime text. This replaces the
removed scan_filter::value_as_timestamp_ms, which guessed a single
unit for every time column regardless of declared kind, across the
columnar filter evaluator, the timeseries time-range narrowing prefilter,
grouped bitmask filtering, and scan materialization.

Every ScanFilter constructed with a string op literal (`op: "eq".into()`)
across the executor handlers, storage cold-filter pruning, and tests is
updated to the typed FilterOp variant, and cold_filter's row-group
pruning now matches on FilterOp directly with an exhaustive arm per
operator instead of a serde_json::Value round-trip.
RLS policies were stored as pre-compiled predicate JSON
(compiled_predicate_json), frozen at CREATE time: an ALTER COLLECTION
that added, dropped, or renamed a column left every existing policy
typed against the old schema, and a literal that failed to compile
was silently skipped with a warn! log, so a broken policy quietly
stopped restricting rows instead of refusing to install.

StoredRlsPolicy now stores predicate_text (plus the database_id its
declared columns live in) and recompiles it on every read through the
single entry point compile_policy_predicate: parse, validate $auth.*
references, then predicate_typing::type_predicate_literals types every
literal a Compare node pairs with a declared TIMESTAMP/TIMESTAMPTZ
column through nodedb_sql's coerce_read_literal, the same rule a
query's WHERE literal follows, so a policy and a query on the same
column agree on the instant. A literal the declared type cannot read
is a compile error naming the column and the literal.

StoredRlsPolicy::rehydrate (catalog/rls.rs, via rls/compile.rs) is the
one path that turns stored text into a runtime RlsPolicy: on a compile
failure it installs a restrictive deny-all instead of skipping the
policy, so an unenforceable policy fails closed rather than silently
admitting every row. Boot replay, Raft post-apply, and recovery reload
all call it uniformly. recompile_for_collection reruns it for every
policy on a collection whenever that collection's declared columns
change (ALTER ADD COLUMN, strict schema changes, and the Raft
post-apply of a collection catalog entry), keeping policies current
with the schema without a restart.

combined_read_predicate_with_auth and combined_write_predicate_with_auth
now return a Result: encoding a compiled predicate to ScanFilter bytes
can fail, and every caller (read-gate checks, EXPLAIN visibility,
graph index creation, the planner's RLS injection) propagates that
error instead of treating it as "no policy".

predicate_eval.rs splits into a directory (filters.rs for the two
constant ScanFilter shapes, sets.rs, substitute.rs) alongside the new
predicate_typing.rs and rls/compile.rs.
DEFAULT materialization moves from a per-engine step in the control
plane (sql_plan_convert::value::defaults::expand_row_defaults) into
the planner itself: every write plan (Insert, Upsert, TimeseriesIngest)
now carries rows with every declared DEFAULT already materialized and
every literal already coerced to its column's declared type, plus a
volatile_defaults flag replacing the old column_defaults text pairs so
the plan cache still refuses a plan holding a fresh-per-row generator.
Conversion no longer re-reads the catalog's DEFAULT text, and
default_expr_is_volatile/defaults_are_volatile move out of
volatility_scan into the planner's own defaults module.

declared_type_coerce::coerce_write_literal is the new entry point a
caller outside the planner uses for a literal that will reach storage
under a column: it applies the same write-side coercion and range
check a VALUES literal gets, exempting the primary key exactly as
every VALUES/SET path does. The DDL gate (column_default.rs) now
calls it for every column DEFAULT that spells a bare literal, so
`DEFAULT 'not a date'` on a TIMESTAMP column and `DEFAULT 999999` on a
SMALLINT column are refused at CREATE/ALTER instead of only surfacing
once a row is inserted. CompiledDefault::literal() exposes the bare
literal a DEFAULT spells; a generator or parsed expression has none to
check yet. A refused literal DEFAULT raises the new sqlstate 42804
(datatype_mismatch) or the existing numeric-range SQLSTATE, mapped to
BAD_REQUEST.

catalog_adapter::declared_column_info centralizes the ColumnInfo a raw
(name, type_str) DDL declaration resolves to, used both by the
schemaless/columnar-family catalog arms and by the DDL gate, so a
DEFAULT is judged against exactly the type its column will carry at
INSERT time.

planner/dml.rs and planner/dml_update_delete.rs split into a dml/
directory (insert, target, update_delete, upsert); planner/merge.rs
splits into a merge/ directory (actions, plan) grouping the DML/MERGE
planners with the write-side literal coercion they now perform.
Register a restored collection with the Data Plane before reissuing
its rows, instead of relying on the catalog row alone: without the
Data Plane declaration, a restored timeseries collection had no
doc_configs entry, so reissued rows were ingested with an inferred
shape, the declared time key as a plain integer, and the
restore-time clock instead of the original timestamp.

Carry a declared TIMESTAMP/TIMESTAMPTZ time key as a typed fixext8
instant on the timeseries ingest payload, rather than the ISO 8601
text used by other engines, and decode it on the Data Plane side
back into the correct instant kind. Reject a time column whose
stored value the line-protocol timestamp cannot carry, rather than
silently dropping it or stamping the row with the ingest clock.

Read a memtable or flushed-partition time cell back typed by its
declared kind during restore reissue, returning an error for a
millisecond count outside the instant's representable range.
@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 17, 2026
Comment thread nodedb/src/control/server/response_shape/cell.rs Fixed
Index into scalars by reference instead of consuming the vector, and
include the scalar's index in the assertion message so a failure
identifies which case broke.
@farhan-syah
farhan-syah merged commit d3d73be into main Sep 17, 2026
13 checks passed
@farhan-syah
farhan-syah deleted the fix/instant-unit-boundary branch September 17, 2026 10:53
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

Development

Successfully merging this pull request may close these issues.

Timestamp unit conversion is per-path, so a join or a cross-node scan compares mismatched units

2 participants