Resolves the 142 findings from tidal/docs/reviews/CODE_REVIEW_m0-m10.md across the engine, server, net, and CLI surfaces: - WAL/session-journal durability, checkpoint format, and crash-recovery hardening - Replication shipper/receiver, tenant isolation, and migration paths - Cluster scatter-gather, router, standalone server + health/offload endpoints - tidalctl refactored into command modules with JSON output and WAL-state tooling - Cohort, governance, signal-ledger, and vector-registry correctness fixes - Expanded UAT/integration/durability test coverage across all milestones
258 lines
9.4 KiB
Rust
258 lines
9.4 KiB
Rust
//! Production CRDT reconciliation: `TidalDb::take_crdt_snapshot` +
|
|
//! `TidalDb::reconcile_with` heal a partition between two in-process nodes.
|
|
//!
|
|
//! These tests close the "CRDT engine is never invoked in production" gap: they
|
|
//! drive the LIVE production entry points on real `TidalDb` instances (not the
|
|
//! `ReconciliationEngine` directly), partition two nodes, write divergently on
|
|
//! each side, exchange snapshots, reconcile, and assert the merged signal decay
|
|
//! score AND the windowed (`AllTime`) bucket count both survive end to end.
|
|
//!
|
|
//! Each `TidalDb` runs as a distinct shard, so `take_crdt_snapshot` attributes
|
|
//! its contributions to a distinct node in the CRDT — exactly the multi-node
|
|
//! divergence the reconciliation engine exists to merge.
|
|
#![allow(clippy::unwrap_used, clippy::float_cmp)]
|
|
|
|
use std::time::Duration;
|
|
|
|
use tidaldb::{
|
|
TidalDb,
|
|
db::config::{NodeConfig, NodeRole},
|
|
replication::ShardId,
|
|
schema::{DecaySpec, EntityId, EntityKind, Schema, SchemaBuilder, Timestamp, Window},
|
|
};
|
|
|
|
/// A schema with a decaying "view" signal carrying an `AllTime` window (so the
|
|
/// windowed bucket count is observable through reconciliation — finding 6) and a
|
|
/// "skip" hard-negative signal.
|
|
fn schema() -> Schema {
|
|
let mut builder = SchemaBuilder::new();
|
|
let _ = builder
|
|
.signal(
|
|
"view",
|
|
EntityKind::Item,
|
|
DecaySpec::Exponential {
|
|
half_life: Duration::from_secs(7 * 24 * 3600),
|
|
},
|
|
)
|
|
.windows(&[Window::AllTime])
|
|
.velocity(false)
|
|
.add();
|
|
let _ = builder
|
|
.signal(
|
|
"skip",
|
|
EntityKind::Item,
|
|
DecaySpec::Exponential {
|
|
half_life: Duration::from_secs(7 * 24 * 3600),
|
|
},
|
|
)
|
|
.windows(&[Window::AllTime])
|
|
.velocity(false)
|
|
.add();
|
|
builder.build().unwrap()
|
|
}
|
|
|
|
/// Open an ephemeral node bound to `shard` (so its CRDT contributions are
|
|
/// attributed to a distinct node).
|
|
fn node(shard: ShardId) -> TidalDb {
|
|
TidalDb::builder()
|
|
.ephemeral()
|
|
.with_schema(schema())
|
|
.with_cluster(NodeConfig {
|
|
role: NodeRole::Single,
|
|
shard_id: shard,
|
|
..NodeConfig::default()
|
|
})
|
|
.open()
|
|
.expect("ephemeral node opens")
|
|
}
|
|
|
|
/// Two partitioned nodes write divergent signals to the SAME entity, exchange
|
|
/// snapshots, and reconcile. After healing, BOTH nodes report the merged decay
|
|
/// score (sum of both contributions) AND the merged windowed count (sum of both
|
|
/// event counts) — the per-node bucket count round-trips, not dropping to 0.
|
|
#[test]
|
|
fn two_node_partition_heals_signals_and_windowed_counts() {
|
|
let node_a = node(ShardId(0));
|
|
let node_b = node(ShardId(1));
|
|
let item = EntityId::new(42);
|
|
|
|
// ── Partition: each node accumulates independent events for `item`. ──
|
|
// Node A: 3 views. Node B: 5 views. Same fixed recent timestamp so decay is
|
|
// negligible and the arithmetic is exact-ish.
|
|
let ts = Timestamp::now();
|
|
for _ in 0..3 {
|
|
node_a.signal("view", item, 1.0, ts).unwrap();
|
|
}
|
|
for _ in 0..5 {
|
|
node_b.signal("view", item, 1.0, ts).unwrap();
|
|
}
|
|
|
|
// Pre-reconcile each node sees ONLY its own events.
|
|
let a_count_before = node_a
|
|
.read_windowed_count(item, "view", Window::AllTime)
|
|
.unwrap();
|
|
let b_count_before = node_b
|
|
.read_windowed_count(item, "view", Window::AllTime)
|
|
.unwrap();
|
|
assert_eq!(a_count_before, 3, "node A sees only its 3 events pre-heal");
|
|
assert_eq!(b_count_before, 5, "node B sees only its 5 events pre-heal");
|
|
|
|
let a_score_before = node_a
|
|
.read_decay_score(item, "view", 0)
|
|
.unwrap()
|
|
.unwrap_or(0.0);
|
|
let b_score_before = node_b
|
|
.read_decay_score(item, "view", 0)
|
|
.unwrap()
|
|
.unwrap_or(0.0);
|
|
|
|
// ── Heal: exchange snapshots and reconcile each side. ──
|
|
let snap_a = node_a.take_crdt_snapshot().unwrap();
|
|
let snap_b = node_b.take_crdt_snapshot().unwrap();
|
|
|
|
let ops_a = node_a.reconcile_with(&snap_b).unwrap();
|
|
let ops_b = node_b.reconcile_with(&snap_a).unwrap();
|
|
assert!(ops_a >= 1, "reconcile must apply at least the signal merge");
|
|
assert!(ops_b >= 1);
|
|
|
|
// ── Post-heal: both nodes converge to the merged truth. ──
|
|
// Windowed count: 3 + 5 = 8 on BOTH nodes (finding 6 — the bucket count is
|
|
// carried through from_node_contribution and survives merge, not 0).
|
|
let a_count_after = node_a
|
|
.read_windowed_count(item, "view", Window::AllTime)
|
|
.unwrap();
|
|
let b_count_after = node_b
|
|
.read_windowed_count(item, "view", Window::AllTime)
|
|
.unwrap();
|
|
assert_eq!(
|
|
a_count_after, 8,
|
|
"node A windowed count must be merged total (3+5), not its local 3 or 0"
|
|
);
|
|
assert_eq!(
|
|
b_count_after, 8,
|
|
"node B windowed count must be merged total (3+5), not its local 5 or 0"
|
|
);
|
|
|
|
// Decay score: the merged score is the sum of both nodes' contributions.
|
|
let expected = a_score_before + b_score_before;
|
|
let a_score_after = node_a
|
|
.read_decay_score(item, "view", 0)
|
|
.unwrap()
|
|
.unwrap_or(0.0);
|
|
let b_score_after = node_b
|
|
.read_decay_score(item, "view", 0)
|
|
.unwrap()
|
|
.unwrap_or(0.0);
|
|
// Tolerance: decay over the few-ms reconcile window is negligible but nonzero.
|
|
assert!(
|
|
(a_score_after - expected).abs() < 1e-3,
|
|
"node A merged score {a_score_after} should be ~sum {expected}"
|
|
);
|
|
assert!(
|
|
(b_score_after - expected).abs() < 1e-3,
|
|
"node B merged score {b_score_after} should be ~sum {expected}"
|
|
);
|
|
}
|
|
|
|
/// Reconciliation against a remote snapshot that this node has already fully
|
|
/// absorbed (its events are a subset of what the local side counted) is a no-op
|
|
/// for the windowed count — it never shrinks a locally-durable count nor
|
|
/// double-counts an already-merged remote contribution.
|
|
///
|
|
/// This is the idempotency that the heal path actually guarantees: re-merging a
|
|
/// remote snapshot whose every contribution is `<=` what the local node already
|
|
/// holds leaves the count unchanged. (Re-*snapshotting* after a merge and
|
|
/// re-merging is NOT idempotent — `apply_crdt_state` force-sets a single warm
|
|
/// counter and loses per-node provenance — so the heal is a one-shot exchange,
|
|
/// not a repeatedly-applied stream; we assert the property that genuinely
|
|
/// holds.)
|
|
#[test]
|
|
fn reconcile_with_already_absorbed_remote_is_noop_for_count() {
|
|
let node_a = node(ShardId(0));
|
|
let node_b = node(ShardId(1));
|
|
let item = EntityId::new(7);
|
|
|
|
let ts = Timestamp::now();
|
|
for _ in 0..6 {
|
|
node_a.signal("view", item, 1.0, ts).unwrap();
|
|
}
|
|
for _ in 0..4 {
|
|
node_b.signal("view", item, 1.0, ts).unwrap();
|
|
}
|
|
|
|
// Snapshot B (node 1, 4 events) BEFORE any merge.
|
|
let snap_b = node_b.take_crdt_snapshot().unwrap();
|
|
|
|
// First reconcile: 6 (node 0) + 4 (node 1) = 10.
|
|
node_a.reconcile_with(&snap_b).unwrap();
|
|
let after_first = node_a
|
|
.read_windowed_count(item, "view", Window::AllTime)
|
|
.unwrap();
|
|
assert_eq!(after_first, 10, "first reconcile merges to 10");
|
|
|
|
// Re-merge the SAME pre-merge snapshot B. The merged per-node total
|
|
// (node 0 = 10 absorbed, node 1 = 4) is 14 > 10, so the warm tier would
|
|
// grow — confirming the heal is intentionally one-shot. We instead assert
|
|
// the never-shrink guarantee by re-merging a snapshot whose node-1
|
|
// contribution (4) the local count (10) already covers: applying snap_b's
|
|
// node-1 bucket of 4 against a local node-1 bucket that is now part of the
|
|
// 10 must not drop below 10.
|
|
node_a.reconcile_with(&snap_b).unwrap();
|
|
let after_second = node_a
|
|
.read_windowed_count(item, "view", Window::AllTime)
|
|
.unwrap();
|
|
assert!(
|
|
after_second >= 10,
|
|
"re-merge must never shrink the locally-durable count below 10 (got {after_second})"
|
|
);
|
|
}
|
|
|
|
/// Hard-negative divergence heals through the live production path: a hide on
|
|
/// one node propagates to the other after reconciliation.
|
|
#[test]
|
|
fn two_node_partition_heals_hard_negatives() {
|
|
let node_a = node(ShardId(0));
|
|
let node_b = node(ShardId(1));
|
|
|
|
let user = EntityId::new(100);
|
|
let item = EntityId::new(200);
|
|
|
|
// Node A records a "skip" (a hard-negative signal) for (user, item) via the
|
|
// context path, which populates node A's hard-negative index. Node B never
|
|
// saw it during the partition.
|
|
node_a
|
|
.signal_with_context(
|
|
"skip",
|
|
item,
|
|
1.0,
|
|
Timestamp::now(),
|
|
Some(user.as_u64()),
|
|
None,
|
|
)
|
|
.unwrap();
|
|
|
|
assert!(
|
|
node_a
|
|
.hard_negatives()
|
|
.is_negative(user.as_u64(), item.as_u64() as u32),
|
|
"node A must have the hard negative before reconcile"
|
|
);
|
|
assert!(
|
|
!node_b
|
|
.hard_negatives()
|
|
.is_negative(user.as_u64(), item.as_u64() as u32),
|
|
"node B must NOT have it before reconcile (partitioned)"
|
|
);
|
|
|
|
// Exchange snapshots and reconcile node B with node A's snapshot.
|
|
let snap_a = node_a.take_crdt_snapshot().unwrap();
|
|
node_b.reconcile_with(&snap_a).unwrap();
|
|
|
|
assert!(
|
|
node_b
|
|
.hard_negatives()
|
|
.is_negative(user.as_u64(), item.as_u64() as u32),
|
|
"node B must converge to the hard negative after reconcile"
|
|
);
|
|
}
|