Skip to content

Commit 87053aa

Browse files
committed
feat(timeseries): honor declared TIME_KEY from DDL instead of inferring it
Timeseries collections created through DDL now carry their column list and designated TIME_KEY from the catalog into the Data Plane's collection config, so ingest and scan use the name and shape DDL resolved rather than guessing from the first ingested batch or matching conventional column names (ts/timestamp/time). Only measurements with no DDL behind them (raw ILP protocol ingest) still fall back to inference. Time-range extraction for scan pruning moves from a heuristic WHERE-clause walk over recognized time-field names into a schema-aware path driven by the declared TIME_KEY, and lives in the shared scan_filter module so both planner and executor can use it. TimeseriesScan also gains ORDER BY pushdown (sort_keys) since the engine's natural scan order does not match requested sort order. The `ddl_ast::collection_type` module is split into a directory of focused submodules (build, designated, kv, strict, type_str) to keep the TIME_KEY resolution logic isolated from the surrounding DDL parsing.
1 parent a8c67d9 commit 87053aa

74 files changed

Lines changed: 2385 additions & 1026 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Cargo.lock

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

nodedb-physical/src/physical_plan/document/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ pub mod enforcement_types;
66
pub mod merge_types;
77
pub mod ollp_edge;
88
pub mod op;
9+
pub mod timeseries_schema;
910
pub mod types;
1011
pub mod update_value;
1112

@@ -15,6 +16,7 @@ pub use enforcement_types::{
1516
pub use merge_types::{MergeActionOp, MergeClauseKind as MergeClauseKindOp, MergeClauseOp};
1617
pub use ollp_edge::OllpPredictedEdge;
1718
pub use op::DocumentOp;
19+
pub use timeseries_schema::TimeseriesSchema;
1820
pub use types::{
1921
BalancedDef, EnforcementOptions, GeneratedColumnSpec, MaterializedSumBinding, PeriodLockConfig,
2022
RegisteredIndex, RegisteredIndexState, ReturningColumns, ReturningItem, ReturningSpec,

nodedb-physical/src/physical_plan/document/op.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ use nodedb_types::{Surrogate, SurrogateBitmap, SystemTimeScope};
44

55
use super::merge_types::MergeClauseOp;
66
use super::ollp_edge::OllpPredictedEdge;
7+
use super::timeseries_schema::TimeseriesSchema;
78
use super::types::{EnforcementOptions, RegisteredIndex, ReturningSpec, StorageMode, UpdateValue};
89

910
/// Document engine physical operations (schemaless + strict + DML).
@@ -184,6 +185,12 @@ pub enum DocumentOp {
184185
/// `None` = no explicit policy persisted; the registry falls back to
185186
/// the ephemeral default.
186187
conflict_policy: Option<String>,
188+
/// Declared columns + designated `TIME_KEY` for a timeseries
189+
/// collection. `Some` for every `engine='timeseries'` collection;
190+
/// `None` for every other engine. The Data Plane builds the
191+
/// collection's memtable schema from this instead of inferring one
192+
/// from the first ingested batch.
193+
timeseries: Option<Box<TimeseriesSchema>>,
187194
},
188195

189196
/// Lookup documents by secondary index value.
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
3+
//! The declared shape of a timeseries collection, propagated from the
4+
//! catalog to the Data Plane on collection registration.
5+
6+
/// A timeseries collection's DDL-declared column list plus its designated
7+
/// `TIME_KEY` column.
8+
///
9+
/// This is the authority for the collection's storage layout. Without it the
10+
/// Data Plane would have to derive a schema from whatever the first ingested
11+
/// batch happened to contain, which cannot recover the declared column order,
12+
/// the declared types, or — critically — which column is the time key. Every
13+
/// path that needs to know "which column carries this collection's time"
14+
/// reads [`Self::time_key`]; none may guess it from a column name.
15+
#[derive(
16+
Debug,
17+
Clone,
18+
PartialEq,
19+
Eq,
20+
serde::Serialize,
21+
serde::Deserialize,
22+
zerompk::ToMessagePack,
23+
zerompk::FromMessagePack,
24+
)]
25+
pub struct TimeseriesSchema {
26+
/// Name of the designated time-key column, exactly as declared.
27+
pub time_key: String,
28+
/// Declared `(column_name, type_str)` pairs in declaration order.
29+
pub columns: Vec<(String, String)>,
30+
}
31+
32+
impl TimeseriesSchema {
33+
/// Position of the time key in `columns`, or `None` when the declared
34+
/// column list does not contain it (an inconsistent catalog record).
35+
pub fn time_key_index(&self) -> Option<usize> {
36+
self.columns.iter().position(|(n, _)| *n == self.time_key)
37+
}
38+
}

nodedb-physical/src/physical_plan/mod.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ pub use crdt::CrdtOp;
3333
pub use document::{
3434
BalancedDef, DocumentOp, EnforcementOptions, GeneratedColumnSpec, MaterializedSumBinding,
3535
OllpPredictedEdge, PeriodLockConfig, RegisteredIndex, RegisteredIndexState, ReturningColumns,
36-
ReturningItem, ReturningSpec, StorageMode, UpdateValue,
36+
ReturningItem, ReturningSpec, StorageMode, TimeseriesSchema, UpdateValue,
3737
};
3838
pub use exchange::{ExchangeMode, ExchangeOp};
3939
pub use graph::{
@@ -45,7 +45,7 @@ pub use query::{AggregateSpec, GroupKeySpec, JoinProjection, QueryOp};
4545
pub use routing::plan_contains_cluster_partitioned_leaf;
4646
pub use spatial::{SpatialOp, SpatialPredicate};
4747
pub use text::TextOp;
48-
pub use timeseries::TimeseriesOp;
48+
pub use timeseries::{TimeseriesOp, UNBOUNDED_TIME_RANGE};
4949
pub use vector::VectorOp;
5050
pub use wire::{decode, encode};
5151

nodedb-physical/src/physical_plan/timeseries.rs

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,15 @@
44
55
use nodedb_types::{Surrogate, SystemTimeScope};
66

7+
/// An unconstrained `(min_ts_ms, max_ts_ms)` envelope.
8+
///
9+
/// The Control Plane always plans a timeseries scan unbounded: narrowing it
10+
/// requires knowing which column is the collection's declared `TIME_KEY`, and
11+
/// that is resolved in the Data Plane where the collection's registered schema
12+
/// lives. Internal callers that already know an exact window (retention,
13+
/// continuous-aggregate refresh) pass their own bounds instead.
14+
pub const UNBOUNDED_TIME_RANGE: (i64, i64) = (i64::MIN, i64::MAX);
15+
716
/// Timeseries engine physical operations.
817
#[derive(
918
Debug,
@@ -22,11 +31,18 @@ pub enum TimeseriesOp {
2231
/// memtable and sealed disk partitions.
2332
Scan {
2433
collection: String,
25-
/// `(min_ts_ms, max_ts_ms)`. (0, i64::MAX) = no time filter.
34+
/// `(min_ts_ms, max_ts_ms)` pruning envelope. The Data Plane narrows
35+
/// it further using the query's bounds on the declared `TIME_KEY`;
36+
/// see [`UNBOUNDED_TIME_RANGE`].
2637
time_range: (i64, i64),
2738
projection: Vec<String>,
2839
limit: usize,
2940
filters: Vec<u8>,
41+
/// `ORDER BY` keys as `(column, ascending)` in significance order.
42+
/// Empty = the engine's natural order. Applied to the materialized
43+
/// result before `limit` is enforced, so an ordered query returns the
44+
/// first `limit` rows of the ordering the client asked for.
45+
sort_keys: Vec<(String, bool)>,
3046
/// time_bucket interval in milliseconds. 0 = no bucketing.
3147
bucket_interval_ms: i64,
3248
/// GROUP BY column names (empty = no grouping or whole-table agg).

nodedb-query/src/scan_filter/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,11 @@
99
pub mod like;
1010
pub mod op;
1111
pub mod parse;
12+
pub mod timestamp;
1213
pub mod types;
1314

1415
pub use like::sql_like_match;
1516
pub use op::FilterOp;
1617
pub use parse::parse_simple_predicates;
18+
pub use timestamp::value_as_timestamp_ms;
1719
pub use types::ScanFilter;
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
3+
//! Coercion of a filter's comparison value to epoch milliseconds.
4+
//!
5+
//! A predicate against a timestamp column can arrive carrying any of the
6+
//! shapes SQL accepts for an instant: a datetime literal (`ts < '2020-03-05
7+
//! 10:00:00'`), an epoch-millisecond integer (`ts < 1583402400000`), or an
8+
//! already-typed datetime value. Timestamp columns store epoch milliseconds,
9+
//! so every one of those has to reduce to the same scalar before comparison —
10+
//! otherwise a perfectly ordinary predicate silently matches nothing.
11+
12+
use nodedb_types::Value;
13+
14+
/// Reduce a filter value to epoch milliseconds, or `None` when it cannot
15+
/// denote an instant.
16+
pub fn value_as_timestamp_ms(value: &Value) -> Option<i64> {
17+
match value {
18+
Value::Integer(ms) => Some(*ms),
19+
Value::Float(ms) => Some(*ms as i64),
20+
Value::DateTime(dt) | Value::NaiveDateTime(dt) => Some(dt.unix_millis()),
21+
Value::String(text) => nodedb_types::datetime::NdbDateTime::parse(text)
22+
.map(|dt| dt.unix_millis())
23+
.or_else(|| text.trim().parse::<i64>().ok()),
24+
_ => None,
25+
}
26+
}
27+
28+
#[cfg(test)]
29+
mod tests {
30+
use super::*;
31+
32+
/// 2020-03-05T10:00:00Z in epoch milliseconds.
33+
const MARCH_5_2020: i64 = 1_583_402_400_000;
34+
35+
#[test]
36+
fn epoch_millis_pass_through() {
37+
assert_eq!(
38+
value_as_timestamp_ms(&Value::Integer(MARCH_5_2020)),
39+
Some(MARCH_5_2020)
40+
);
41+
}
42+
43+
#[test]
44+
fn datetime_literals_are_parsed() {
45+
assert_eq!(
46+
value_as_timestamp_ms(&Value::String("2020-03-05 10:00:00".into())),
47+
Some(MARCH_5_2020)
48+
);
49+
assert_eq!(
50+
value_as_timestamp_ms(&Value::String("2020-03-05T10:00:00Z".into())),
51+
Some(MARCH_5_2020)
52+
);
53+
}
54+
55+
#[test]
56+
fn numeric_strings_are_read_as_epoch_millis() {
57+
assert_eq!(
58+
value_as_timestamp_ms(&Value::String(MARCH_5_2020.to_string())),
59+
Some(MARCH_5_2020)
60+
);
61+
}
62+
63+
#[test]
64+
fn typed_datetimes_are_accepted() {
65+
let dt = nodedb_types::NdbDateTime::from_millis(MARCH_5_2020).unwrap();
66+
assert_eq!(
67+
value_as_timestamp_ms(&Value::NaiveDateTime(dt)),
68+
Some(MARCH_5_2020)
69+
);
70+
assert_eq!(
71+
value_as_timestamp_ms(&Value::DateTime(dt)),
72+
Some(MARCH_5_2020)
73+
);
74+
}
75+
76+
#[test]
77+
fn non_instants_are_rejected() {
78+
assert_eq!(value_as_timestamp_ms(&Value::Null), None);
79+
assert_eq!(value_as_timestamp_ms(&Value::Bool(true)), None);
80+
assert_eq!(
81+
value_as_timestamp_ms(&Value::String("not a date".into())),
82+
None
83+
);
84+
}
85+
}

0 commit comments

Comments
 (0)