Skip to content

Commit f95e995

Browse files
committed
feat(nodedb-crdt): add bitemporal CRDT sync with live-scoped constraints
Bitemporal collections carry valid-time (`_ts_valid_from`/`_ts_valid_until`) and system-time (`_ts_system`) metadata. Three behaviours had to change: UNIQUE scoping — a UNIQUE collision between a superseded row (finite `_ts_valid_until`) and a new live row is not a violation; they represent the same logical entity at different valid-times. `constraint_checks` now calls the new `field_value_exists_live` on bitemporal collections instead of the global scan. Receiver-stamped system time — `_ts_system` is always overwritten with the receiving node's wall clock during `validate_and_apply`, regardless of what the sender supplied. This keeps system-time receiver-authoritative so convergence does not depend on clock agreement between peers. New CrdtState helpers — `field_value_exists_live` and `live_row_ids` filter out superseded rows using the `row_is_live` predicate (`_ts_valid_until` absent, null, or `i64::MAX`). TenantCrdtEngine surface — `mark_bitemporal`, `is_bitemporal`, and `set_collection_policy_typed` allow in-process callers to configure collections without going through DDL JSON. Integration tests in `nodedb/tests/bitemporal_crdt_sync.rs` verify: - superseded email does not collide with a new live row - two live rows with the same UNIQUE value still collide - sender-supplied `_ts_system` is overwritten by the receiver
1 parent 65a444d commit f95e995

5 files changed

Lines changed: 260 additions & 7 deletions

File tree

nodedb-crdt/src/constraint_checks.rs

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,16 @@ impl Validator {
4141

4242
let value = &field_value.1;
4343

44-
// Check if this value already exists in another row.
45-
if state.field_value_exists(&change.collection, &constraint.field, value) {
44+
// Bitemporal collections: only consider live (non-superseded) rows,
45+
// so a new version of the same logical row with the same value does
46+
// not spuriously collide with its prior version.
47+
let exists = if self.is_bitemporal(&change.collection) {
48+
state.field_value_exists_live(&change.collection, &constraint.field, value)
49+
} else {
50+
state.field_value_exists(&change.collection, &constraint.field, value)
51+
};
52+
53+
if exists {
4654
let value_str = format!("{:?}", value);
4755
Some(Violation {
4856
constraint_name: constraint.name.clone(),

nodedb-crdt/src/state/core.rs

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,20 @@
33
use loro::{LoroDoc, LoroMap, LoroValue, ValueOrContainer};
44

55
use crate::error::{CrdtError, Result};
6+
use crate::validator::bitemporal::{VALID_UNTIL, VALID_UNTIL_OPEN};
7+
8+
/// A row is live when its `_ts_valid_until` field is absent, null, or the
9+
/// open sentinel (`i64::MAX`). Rows with any finite `_ts_valid_until` are
10+
/// treated as superseded, independent of wall-clock time — the write path
11+
/// sets finite `_ts_valid_until` only when explicitly terminating a version.
12+
fn row_is_live(row: &LoroMap) -> bool {
13+
match row.get(VALID_UNTIL) {
14+
None => true,
15+
Some(ValueOrContainer::Value(LoroValue::Null)) => true,
16+
Some(ValueOrContainer::Value(LoroValue::I64(n))) => n == VALID_UNTIL_OPEN,
17+
_ => true,
18+
}
19+
}
620

721
/// A CRDT state for a single tenant/namespace.
822
pub struct CrdtState {
@@ -137,6 +151,56 @@ impl CrdtState {
137151
false
138152
}
139153

154+
/// Bitemporal variant of [`field_value_exists`]: only considers rows
155+
/// whose `_ts_valid_until` is open (absent or `i64::MAX`).
156+
///
157+
/// A UNIQUE collision between a superseded version and a new live row
158+
/// is not a violation — both may share the same value because they
159+
/// represent the same logical entity at different valid-times.
160+
pub fn field_value_exists_live(
161+
&self,
162+
collection: &str,
163+
field: &str,
164+
value: &LoroValue,
165+
) -> bool {
166+
let coll = self.doc.get_map(collection);
167+
for key in coll.keys() {
168+
let row_map = match coll.get(&key) {
169+
Some(ValueOrContainer::Container(loro::Container::Map(m))) => m,
170+
_ => continue,
171+
};
172+
if !row_is_live(&row_map) {
173+
continue;
174+
}
175+
let field_val = match row_map.get(field) {
176+
Some(ValueOrContainer::Value(v)) => v,
177+
_ => continue,
178+
};
179+
if &field_val == value {
180+
return true;
181+
}
182+
}
183+
false
184+
}
185+
186+
/// Return row IDs currently "live" in a bitemporal collection
187+
/// (rows whose `_ts_valid_until` is open). For non-bitemporal
188+
/// collections every row is returned.
189+
pub fn live_row_ids(&self, collection: &str) -> Vec<String> {
190+
let coll = self.doc.get_map(collection);
191+
let mut out = Vec::new();
192+
for key in coll.keys() {
193+
let row_map = match coll.get(&key) {
194+
Some(ValueOrContainer::Container(loro::Container::Map(m))) => m,
195+
_ => continue,
196+
};
197+
if row_is_live(&row_map) {
198+
out.push(key.to_string());
199+
}
200+
}
201+
out
202+
}
203+
140204
/// Get the underlying LoroDoc for advanced operations.
141205
pub fn doc(&self) -> &LoroDoc {
142206
&self.doc

nodedb/src/engine/crdt/tenant_state/core.rs

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,11 @@ impl TenantCrdtEngine {
9090
///
9191
/// If constraints are violated, the delta is routed to the DLQ.
9292
/// Returns `Ok(())` on success, or the constraint violation error.
93+
///
94+
/// For bitemporal collections, `_ts_system` is always stamped with the
95+
/// receiving node's clock, overwriting any value the sender supplied.
96+
/// This keeps system-time receiver-authoritative so convergence does
97+
/// not depend on clock agreement between peers.
9398
pub fn validate_and_apply(
9499
&mut self,
95100
peer_id: u64,
@@ -101,20 +106,53 @@ impl TenantCrdtEngine {
101106
.validate_or_reject(&self.state, peer_id, auth, change, delta_bytes)
102107
.map_err(crate::Error::Crdt)?;
103108

104-
// Validation passed — apply to state.
105-
// In production this would apply the delta bytes, but for now
106-
// we upsert the fields directly since we have the ProposedChange.
107-
let fields: Vec<(&str, LoroValue)> = change
109+
let is_bitemporal = self.validator.is_bitemporal(&change.collection);
110+
let now_ms = std::time::SystemTime::now()
111+
.duration_since(std::time::UNIX_EPOCH)
112+
.unwrap_or_default()
113+
.as_millis() as i64;
114+
115+
let mut fields: Vec<(&str, LoroValue)> = change
108116
.fields
109117
.iter()
118+
.filter(|(k, _)| !(is_bitemporal && k == "_ts_system"))
110119
.map(|(k, v)| (k.as_str(), v.clone()))
111120
.collect();
112121

122+
if is_bitemporal {
123+
fields.push(("_ts_system", LoroValue::I64(now_ms)));
124+
}
125+
113126
self.state
114127
.upsert(&change.collection, &change.row_id, &fields)
115128
.map_err(crate::Error::Crdt)
116129
}
117130

131+
/// Set the conflict-resolution policy for a collection from a typed
132+
/// `CollectionPolicy`. The JSON-accepting variant in `policy.rs` is the
133+
/// DDL-facing path; this one is for in-process callers (tests, engine
134+
/// setup).
135+
pub fn set_collection_policy_typed(
136+
&mut self,
137+
collection: &str,
138+
policy: nodedb_crdt::policy::CollectionPolicy,
139+
) {
140+
self.validator.policies_mut().set(collection, policy);
141+
}
142+
143+
/// Register a collection as bitemporal on this tenant's validator.
144+
///
145+
/// Bitemporal collections get (a) UNIQUE constraints scoped to live
146+
/// rows only and (b) receiver-stamped `_ts_system` on apply.
147+
pub fn mark_bitemporal(&mut self, collection: impl Into<String>) {
148+
self.validator.mark_bitemporal(collection);
149+
}
150+
151+
/// Is the named collection bitemporal?
152+
pub fn is_bitemporal(&self, collection: &str) -> bool {
153+
self.validator.is_bitemporal(collection)
154+
}
155+
118156
/// Number of entries in the dead-letter queue.
119157
pub fn dlq_len(&self) -> usize {
120158
self.validator.dlq().len()
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
//! Bitemporal CRDT sync: version preservation + live-scoped UNIQUE +
2+
//! receiver-stamped system time.
3+
//!
4+
//! The CRDT engine must:
5+
//! - Scope UNIQUE checks to currently-live rows (`_ts_valid_until == MAX`)
6+
//! so a superseded version does not falsely collide with a new live row.
7+
//! - Stamp `_ts_system` with the receiving node's clock on `validate_and_apply`,
8+
//! ignoring whatever the sender supplied — keeping system-time receiver-
9+
//! authoritative for convergence.
10+
11+
use loro::LoroValue;
12+
13+
use nodedb::engine::crdt::tenant_state::TenantCrdtEngine;
14+
use nodedb::types::TenantId;
15+
use nodedb_crdt::CrdtAuthContext;
16+
use nodedb_crdt::constraint::ConstraintSet;
17+
use nodedb_crdt::policy::CollectionPolicy;
18+
use nodedb_crdt::validator::ProposedChange;
19+
use nodedb_crdt::validator::bitemporal::VALID_UNTIL_OPEN;
20+
21+
const USERS: &str = "users";
22+
23+
fn tenant() -> TenantCrdtEngine {
24+
let mut cs = ConstraintSet::new();
25+
cs.add_unique("users_email_unique", USERS, "email");
26+
let mut eng = TenantCrdtEngine::new(TenantId::new(1), 1, cs).unwrap();
27+
eng.mark_bitemporal(USERS);
28+
eng.set_collection_policy_typed(USERS, CollectionPolicy::strict());
29+
eng
30+
}
31+
32+
fn change(
33+
row_id: &str,
34+
email: &str,
35+
valid_from: i64,
36+
valid_until: i64,
37+
sender_system_ts: i64,
38+
) -> ProposedChange {
39+
ProposedChange {
40+
collection: USERS.into(),
41+
row_id: row_id.into(),
42+
fields: vec![
43+
("name".into(), LoroValue::String("Alice".into())),
44+
("email".into(), LoroValue::String(email.into())),
45+
("_ts_valid_from".into(), LoroValue::I64(valid_from)),
46+
("_ts_valid_until".into(), LoroValue::I64(valid_until)),
47+
("_ts_system".into(), LoroValue::I64(sender_system_ts)),
48+
],
49+
}
50+
}
51+
52+
/// A superseded version with the same email as a new live row should not
53+
/// trigger a UNIQUE collision: the old version is no longer live.
54+
#[test]
55+
fn bitemporal_unique_scoped_to_current() {
56+
let mut eng = tenant();
57+
58+
// Version 1: Alice at alice@old.com, terminated at t=1000.
59+
eng.validate_and_apply(
60+
1,
61+
CrdtAuthContext::default(),
62+
&change("u1-v1", "alice@old.com", 0, 1_000, 100),
63+
b"delta1".to_vec(),
64+
)
65+
.expect("v1 should accept");
66+
67+
// A second user insert at the SAME email (old one) — but the old row is
68+
// no longer live, so this must succeed, not collide.
69+
eng.validate_and_apply(
70+
1,
71+
CrdtAuthContext::default(),
72+
&change("u2-v1", "alice@old.com", 2_000, VALID_UNTIL_OPEN, 200),
73+
b"delta2".to_vec(),
74+
)
75+
.expect("reusing superseded email should not collide");
76+
}
77+
78+
/// Two currently-live rows with the same UNIQUE value MUST collide even in
79+
/// bitemporal mode — bitemporal relaxes only the old-vs-new axis.
80+
#[test]
81+
fn bitemporal_unique_still_collides_for_live_rows() {
82+
let mut eng = tenant();
83+
84+
eng.validate_and_apply(
85+
1,
86+
CrdtAuthContext::default(),
87+
&change("u1", "bob@example.com", 0, VALID_UNTIL_OPEN, 100),
88+
b"delta1".to_vec(),
89+
)
90+
.expect("first live insert accepted");
91+
92+
let err = eng.validate_and_apply(
93+
1,
94+
CrdtAuthContext::default(),
95+
&change("u2", "bob@example.com", 0, VALID_UNTIL_OPEN, 200),
96+
b"delta2".to_vec(),
97+
);
98+
assert!(
99+
err.is_err(),
100+
"two live rows with same UNIQUE value must collide"
101+
);
102+
}
103+
104+
/// The sender's `_ts_system` must be overwritten with receiver's clock on
105+
/// apply — receiver-authoritative system time.
106+
#[test]
107+
fn bitemporal_crdt_system_ts_receiver_stamped() {
108+
let mut eng = tenant();
109+
110+
let sender_ts = 100_000i64; // some arbitrary sender-supplied stamp
111+
let before_ms = std::time::SystemTime::now()
112+
.duration_since(std::time::UNIX_EPOCH)
113+
.unwrap()
114+
.as_millis() as i64;
115+
116+
eng.validate_and_apply(
117+
1,
118+
CrdtAuthContext::default(),
119+
&change("u1", "c@example.com", 0, VALID_UNTIL_OPEN, sender_ts),
120+
b"delta".to_vec(),
121+
)
122+
.unwrap();
123+
124+
let row = eng.read_row(USERS, "u1").expect("row present");
125+
let map = match row {
126+
LoroValue::Map(m) => m,
127+
other => panic!("expected map, got {other:?}"),
128+
};
129+
let stamped = match map.get("_ts_system") {
130+
Some(LoroValue::I64(n)) => *n,
131+
other => panic!("expected i64 _ts_system, got {other:?}"),
132+
};
133+
assert_ne!(
134+
stamped, sender_ts,
135+
"receiver must overwrite sender-supplied _ts_system"
136+
);
137+
assert!(
138+
stamped >= before_ms,
139+
"stamped ts ({stamped}) must be >= receiver clock at apply ({before_ms})"
140+
);
141+
}

nodedb/tests/checkpoint_durability.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,9 @@ fn timeseries_partition_registry_persist_is_durable() {
131131
// `PartitionRegistry::persist` writes the authoritative partition map via
132132
// the same tmp+rename pattern — recovery reads it on startup, so the
133133
// zero-file failure mode is identical to checkpoint writers.
134-
assert_durable_checkpoint_writer("nodedb/src/engine/timeseries/partition_registry/persistence.rs");
134+
assert_durable_checkpoint_writer(
135+
"nodedb/src/engine/timeseries/partition_registry/persistence.rs",
136+
);
135137
}
136138

137139
#[test]

0 commit comments

Comments
 (0)