Skip to content

Commit 01512b3

Browse files
committed
feat(nodedb): wire bitemporal strict collections through DDL and Data Plane
DDL: the pgwire `CREATE COLLECTION … BITEMPORAL TYPE strict` path now reads the BITEMPORAL flag before choosing the schema constructor, routing through `parse_typed_schema_with_flags` which calls `StrictSchema::new_bitemporal`. The flag is propagated to non-strict collection paths unchanged. Data Plane: add `bytes_to_binary_tuple_bitemporal` and `value_to_binary_tuple_bitemporal` helpers in `strict_format`. The put, update, and upsert handlers check `schema.bitemporal` and call the bitemporal encoder when appropriate, capturing `system_from_ms` once per operation so it is consistent between encoding and the versioned-store entry. Tests cover the value → binary-tuple → timestamp-extraction roundtrip and rejection of the bitemporal path on plain schemas.
1 parent b548e7e commit 01512b3

10 files changed

Lines changed: 210 additions & 20 deletions

File tree

nodedb/src/control/server/pgwire/ddl/collection/create/handler.rs

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,10 +78,18 @@ pub fn create_collection(
7878
// DataFusion needs all column names+types to build the Arrow schema for planning.
7979
let mut columnar_schema_columns: Vec<(String, String)> = Vec::new();
8080

81+
// `BITEMPORAL` flag is consulted here so strict schemas can reserve
82+
// tuple slots 0/1/2 for `__system_from_ms` / `__valid_from_ms` /
83+
// `__valid_until_ms`. The flag is re-read below for non-strict
84+
// collections which route through the versioned sparse table only.
85+
let bitemporal_flag = upper.contains("BITEMPORAL");
86+
8187
let collection_type = if upper.contains("TYPE") && upper.contains("KEY_VALUE") {
8288
super::super::super::kv::parse_kv_collection(sql, &upper)?
8389
} else if upper.contains("TYPE") && upper.contains("STRICT") {
84-
let schema = parse_typed_schema(sql).map_err(|e| sqlstate_error("42601", &e))?;
90+
let schema =
91+
super::super::helpers::parse_typed_schema_with_flags(sql, bitemporal_flag)
92+
.map_err(|e| sqlstate_error("42601", &e))?;
8593
nodedb_types::CollectionType::strict(schema)
8694
} else if upper.contains("TYPE") && upper.contains("COLUMNAR") {
8795
resolve_columnar(sql, &upper, &mut columnar_schema_columns)
@@ -115,7 +123,7 @@ pub fn create_collection(
115123
// version keyed by `system_from_ms` and reads use the versioned
116124
// table + Ceiling resolver. Enables `FOR SYSTEM_TIME AS OF` /
117125
// `FOR VALID_TIME` queries on this collection.
118-
let bitemporal = upper.contains("BITEMPORAL");
126+
let bitemporal = bitemporal_flag;
119127
if hash_chain && !append_only {
120128
return Err(sqlstate_error("42601", "HASH_CHAIN requires APPEND_ONLY"));
121129
}

nodedb/src/control/server/pgwire/ddl/collection/helpers.rs

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,16 @@ pub(super) fn extract_with_value(sql: &str, key: &str) -> Option<String> {
3838
/// Auto-generates `_rowid` PK if no PK column is declared.
3939
pub(in crate::control::server::pgwire::ddl) fn parse_typed_schema(
4040
sql: &str,
41+
) -> Result<nodedb_types::columnar::StrictSchema, String> {
42+
parse_typed_schema_with_flags(sql, false)
43+
}
44+
45+
/// Variant of `parse_typed_schema` that additionally flags the schema
46+
/// as bitemporal, prepending the three reserved fixed-Int64 slots
47+
/// (`__system_from_ms`, `__valid_from_ms`, `__valid_until_ms`).
48+
pub(in crate::control::server::pgwire::ddl) fn parse_typed_schema_with_flags(
49+
sql: &str,
50+
bitemporal: bool,
4151
) -> Result<nodedb_types::columnar::StrictSchema, String> {
4252
use nodedb_types::columnar::{ColumnDef, ColumnType, StrictSchema};
4353

@@ -100,7 +110,11 @@ pub(in crate::control::server::pgwire::ddl) fn parse_typed_schema(
100110
);
101111
}
102112

103-
StrictSchema::new(columns).map_err(|e| e.to_string())
113+
if bitemporal {
114+
StrictSchema::new_bitemporal(columns).map_err(|e| e.to_string())
115+
} else {
116+
StrictSchema::new(columns).map_err(|e| e.to_string())
117+
}
104118
}
105119

106120
/// Parse a single column definition: `name TYPE [NOT NULL] [PRIMARY KEY] [DEFAULT expr]`

nodedb/src/control/server/pgwire/ddl/convert.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ pub async fn convert_collection(
9393
columns,
9494
version: 1,
9595
dropped_columns: Vec::new(),
96+
bitemporal: false,
9697
};
9798
if target_type == "kv" {
9899
nodedb_types::CollectionType::kv(schema)

nodedb/src/data/executor/handlers/convert.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ impl CoreLoop {
106106
columns,
107107
version: 1,
108108
dropped_columns: Vec::new(),
109+
bitemporal: false,
109110
};
110111

111112
// Scan all existing documents.

nodedb/src/data/executor/handlers/document/read.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -733,6 +733,7 @@ mod tests {
733733
],
734734
version: 1,
735735
dropped_columns: Vec::new(),
736+
bitemporal: false,
736737
};
737738
let mut map = std::collections::HashMap::new();
738739
map.insert("id".into(), Value::String("u1".into()));

nodedb/src/data/executor/handlers/point/apply_put.rs

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -50,17 +50,31 @@ impl CoreLoop {
5050
};
5151
let value = &value;
5252

53+
let bitemporal = self.is_bitemporal(tid, collection);
54+
let sys_from_ms = self.bitemporal_now_ms();
55+
let valid_from_ms = i64::MIN;
56+
let valid_until_ms = i64::MAX;
57+
5358
// Check if this collection uses strict (Binary Tuple) encoding.
5459
let stored = if let Some(config) = self.doc_configs.get(&config_key)
5560
&& let crate::bridge::physical_plan::StorageMode::Strict { ref schema } =
5661
config.storage_mode
5762
{
58-
super::super::super::strict_format::bytes_to_binary_tuple(value, schema).map_err(
59-
|e| crate::Error::Serialization {
60-
format: "binary_tuple".into(),
61-
detail: e,
62-
},
63-
)?
63+
if bitemporal && schema.bitemporal {
64+
super::super::super::strict_format::bytes_to_binary_tuple_bitemporal(
65+
value,
66+
schema,
67+
sys_from_ms,
68+
valid_from_ms,
69+
valid_until_ms,
70+
)
71+
} else {
72+
super::super::super::strict_format::bytes_to_binary_tuple(value, schema)
73+
}
74+
.map_err(|e| crate::Error::Serialization {
75+
format: "binary_tuple".into(),
76+
detail: e,
77+
})?
6478
} else {
6579
value.to_vec()
6680
};
@@ -69,7 +83,6 @@ impl CoreLoop {
6983
// (pre-write) version for the `prior` slot, then append a new
7084
// version at `sys_from = now()`. Non-bitemporal collections use
7185
// the legacy overwrite path, returning the old bytes redb replaced.
72-
let bitemporal = self.is_bitemporal(tid, collection);
7386
let prior = if bitemporal {
7487
let current = self
7588
.sparse
@@ -80,9 +93,9 @@ impl CoreLoop {
8093
tenant: tid,
8194
coll: collection,
8295
doc_id: document_id,
83-
sys_from_ms: self.bitemporal_now_ms(),
84-
valid_from_ms: i64::MIN,
85-
valid_until_ms: i64::MAX,
96+
sys_from_ms,
97+
valid_from_ms,
98+
valid_until_ms,
8699
body: &stored,
87100
},
88101
)?;

nodedb/src/data/executor/handlers/point/update.rs

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,11 @@ impl CoreLoop {
5656
.any(|(_, v)| matches!(v, UpdateValue::Expr(_)));
5757

5858
let bitemporal = self.is_bitemporal(tid, collection);
59+
let sys_from_for_encode = if bitemporal {
60+
self.bitemporal_now_ms()
61+
} else {
62+
0
63+
};
5964
let get_result = if bitemporal {
6065
self.sparse
6166
.versioned_get_current(tid, collection, document_id)
@@ -181,9 +186,20 @@ impl CoreLoop {
181186
config.storage_mode
182187
{
183188
let ndb_val: nodedb_types::Value = doc.clone().into();
184-
match super::super::super::strict_format::value_to_binary_tuple(
185-
&ndb_val, schema,
186-
) {
189+
let result = if bitemporal && schema.bitemporal {
190+
super::super::super::strict_format::value_to_binary_tuple_bitemporal(
191+
&ndb_val,
192+
schema,
193+
sys_from_for_encode,
194+
i64::MIN,
195+
i64::MAX,
196+
)
197+
} else {
198+
super::super::super::strict_format::value_to_binary_tuple(
199+
&ndb_val, schema,
200+
)
201+
};
202+
match result {
187203
Ok(bytes) => bytes,
188204
Err(e) => {
189205
return self.response_error(
@@ -213,7 +229,7 @@ impl CoreLoop {
213229
tenant: tid,
214230
coll: collection,
215231
doc_id: document_id,
216-
sys_from_ms: self.bitemporal_now_ms(),
232+
sys_from_ms: sys_from_for_encode,
217233
valid_from_ms: i64::MIN,
218234
valid_until_ms: i64::MAX,
219235
body: &updated_bytes,

nodedb/src/data/executor/handlers/upsert.rs

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -120,10 +120,25 @@ impl CoreLoop {
120120
apply_on_conflict_updates(existing_val, &new_val, on_conflict_updates)
121121
};
122122

123+
let sys_from_ms = if bitemporal {
124+
self.bitemporal_now_ms()
125+
} else {
126+
0
127+
};
123128
// Encode merged value for storage.
124129
let stored_bytes = if let Some(ref schema) = strict_schema {
125-
// Strict: encode directly to binary tuple.
126-
match super::super::strict_format::value_to_binary_tuple(&merged, schema) {
130+
let result = if bitemporal && schema.bitemporal {
131+
super::super::strict_format::value_to_binary_tuple_bitemporal(
132+
&merged,
133+
schema,
134+
sys_from_ms,
135+
i64::MIN,
136+
i64::MAX,
137+
)
138+
} else {
139+
super::super::strict_format::value_to_binary_tuple(&merged, schema)
140+
};
141+
match result {
127142
Ok(bt) => bt,
128143
Err(e) => {
129144
return self.response_error(
@@ -160,7 +175,7 @@ impl CoreLoop {
160175
tenant: tid,
161176
coll: collection,
162177
doc_id: document_id,
163-
sys_from_ms: self.bitemporal_now_ms(),
178+
sys_from_ms,
164179
valid_from_ms: i64::MIN,
165180
valid_until_ms: i64::MAX,
166181
body: &stored_bytes,

nodedb/src/data/executor/strict_format.rs

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,79 @@ pub(super) fn value_to_binary_tuple(
6262
.map_err(|e| format!("Binary Tuple encode: {e}"))
6363
}
6464

65+
/// Bitemporal variant: decode msgpack to `Value`, then encode as a Binary
66+
/// Tuple with reserved slots 0/1/2 populated from the supplied timestamps.
67+
pub(super) fn bytes_to_binary_tuple_bitemporal(
68+
bytes: &[u8],
69+
schema: &StrictSchema,
70+
system_from_ms: i64,
71+
valid_from_ms: i64,
72+
valid_until_ms: i64,
73+
) -> Result<Vec<u8>, String> {
74+
let value =
75+
nodedb_types::value_from_msgpack(bytes).map_err(|e| format!("zerompk decode: {e}"))?;
76+
value_to_binary_tuple_bitemporal(
77+
&value,
78+
schema,
79+
system_from_ms,
80+
valid_from_ms,
81+
valid_until_ms,
82+
)
83+
}
84+
85+
/// Bitemporal variant: encode a user-supplied `Value::Object` together
86+
/// with the three reserved bitemporal timestamps.
87+
pub(super) fn value_to_binary_tuple_bitemporal(
88+
value: &Value,
89+
schema: &StrictSchema,
90+
system_from_ms: i64,
91+
valid_from_ms: i64,
92+
valid_until_ms: i64,
93+
) -> Result<Vec<u8>, String> {
94+
if !schema.bitemporal {
95+
return Err("schema is not bitemporal".into());
96+
}
97+
let map = match value {
98+
Value::Object(m) => m,
99+
_ => return Err("strict value must be an Object".to_string()),
100+
};
101+
102+
let user_columns = &schema.columns[3..];
103+
let user_names: std::collections::HashSet<&str> =
104+
user_columns.iter().map(|c| c.name.as_str()).collect();
105+
if let Some(unknown) = map.keys().find(|k| {
106+
!user_names.contains(k.as_str())
107+
&& !nodedb_types::columnar::BITEMPORAL_RESERVED_COLUMNS.contains(&k.as_str())
108+
}) {
109+
return Err(format!(
110+
"unknown field '{unknown}' not present in strict schema"
111+
));
112+
}
113+
114+
let mut user_values = Vec::with_capacity(user_columns.len());
115+
for col in user_columns {
116+
let field_val = map.get(&col.name);
117+
let typed = match field_val {
118+
None | Some(Value::Null) => {
119+
if !col.nullable {
120+
return Err(format!(
121+
"column '{}' is NOT NULL but no value provided",
122+
col.name
123+
));
124+
}
125+
Value::Null
126+
}
127+
Some(v) => coerce_value(v, &col.column_type, &col.name)?,
128+
};
129+
user_values.push(typed);
130+
}
131+
132+
let encoder = nodedb_strict::TupleEncoder::new(schema);
133+
encoder
134+
.encode_bitemporal(system_from_ms, valid_from_ms, valid_until_ms, &user_values)
135+
.map_err(|e| format!("Binary Tuple encode: {e}"))
136+
}
137+
65138
/// Decode a Binary Tuple to `nodedb_types::Value::Object` using the schema.
66139
///
67140
/// Returns `None` if the bytes are not a valid binary tuple (e.g., if they
@@ -411,6 +484,7 @@ mod tests {
411484
],
412485
version: 1,
413486
dropped_columns: Vec::new(),
487+
bitemporal: false,
414488
}
415489
}
416490

@@ -453,6 +527,52 @@ mod tests {
453527
assert!(result.unwrap_err().contains("NOT NULL"));
454528
}
455529

530+
#[test]
531+
fn bitemporal_value_roundtrip() {
532+
let schema = StrictSchema::new_bitemporal(vec![
533+
ColumnDef::required("id", ColumnType::String).with_primary_key(),
534+
ColumnDef::required("name", ColumnType::String),
535+
])
536+
.unwrap();
537+
let mut map = std::collections::HashMap::new();
538+
map.insert("id".into(), Value::String("u1".into()));
539+
map.insert("name".into(), Value::String("Alice".into()));
540+
541+
let tuple = value_to_binary_tuple_bitemporal(
542+
&Value::Object(map),
543+
&schema,
544+
1_700_000_000_000,
545+
0,
546+
i64::MAX,
547+
)
548+
.unwrap();
549+
550+
let decoder = nodedb_strict::TupleDecoder::new(&schema);
551+
let (sys, vf, vu) = decoder.extract_bitemporal_timestamps(&tuple).unwrap();
552+
assert_eq!(sys, 1_700_000_000_000);
553+
assert_eq!(vf, 0);
554+
assert_eq!(vu, i64::MAX);
555+
assert_eq!(
556+
decoder.extract_by_name(&tuple, "id").unwrap(),
557+
Value::String("u1".into())
558+
);
559+
}
560+
561+
#[test]
562+
fn bitemporal_encode_rejects_non_bitemporal_schema() {
563+
let schema = test_schema();
564+
let map = std::collections::HashMap::new();
565+
let result = value_to_binary_tuple_bitemporal(
566+
&Value::Object(map),
567+
&schema,
568+
0,
569+
0,
570+
0,
571+
);
572+
assert!(result.is_err());
573+
assert!(result.unwrap_err().contains("not bitemporal"));
574+
}
575+
456576
#[test]
457577
fn unknown_field_errors() {
458578
let schema = test_schema();

nodedb/tests/executor_tests/test_cross_type_join.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ fn document_scan_preserves_kv_rows_when_collection_has_strict_config() {
9797
],
9898
version: 1,
9999
dropped_columns: Vec::new(),
100+
bitemporal: false,
100101
},
101102
},
102103
enforcement: Box::new(EnforcementOptions::default()),

0 commit comments

Comments
 (0)