- Add BUILD.bazel across tidal, tidal-net, tidal-server, tidalctl for bzlmod build - Add tidal/ crate docs (README, CHANGELOG, CONTRIBUTING, AGENTS, CLAUDE, API, ARCHITECTURE) and ai-lookup reference - Add docker standalone/cluster/deploy images, compose, and prometheus config - Harden WAL (batch format, writer, dedup, diagnostics), text syncer/collectors, and vector registry - Expand tidalctl CLI and tests; restructure WAL/visibility integration test suites - Refine tidal-net transport/client/server and tidal-server cluster/scatter-gather
3.2 KiB
3.2 KiB
Task 02: PNCounter
Delivers
PNCounter in tidal/src/replication/crdt/pn_counter.rs. Per-node P and N vectors (backed by HashMap<ShardId, u64>). Supports increment, decrement, merge, value. Property tests verify commutativity, monotonicity, and associativity (CMA) across 100K random operations over 5 nodes.
Complexity: M
Dependencies
- Phase 8.1 (ShardId)
Technical Design
// tidal/src/replication/crdt/pn_counter.rs
/// Positive-Negative Counter CRDT.
///
/// Each node (ShardId) maintains its own P (increment) and N (decrement)
/// totals. The global value = sum(P) - sum(N). Merge takes the per-node
/// max of each component -- safe because values only ever increase within
/// a node.
///
/// Properties:
/// - Commutative: merge(A, B) == merge(B, A)
/// - Associative: merge(A, merge(B, C)) == merge(merge(A, B), C)
/// - Idempotent: merge(A, A) == A
#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct PNCounter {
positive: HashMap<ShardId, u64>,
negative: HashMap<ShardId, u64>,
}
impl PNCounter {
pub fn new() -> Self {
Self::default()
}
/// Increment by `amount` for this node.
pub fn increment(&mut self, node: ShardId, amount: u64) {
*self.positive.entry(node).or_default() += amount;
}
/// Decrement by `amount` for this node.
pub fn decrement(&mut self, node: ShardId, amount: u64) {
*self.negative.entry(node).or_default() += amount;
}
/// Merge another counter into this one.
///
/// Takes the per-node maximum of both P and N components.
/// Safe because each node's contribution only grows.
pub fn merge(&mut self, other: &PNCounter) {
for (&node, &val) in &other.positive {
let entry = self.positive.entry(node).or_default();
*entry = (*entry).max(val);
}
for (&node, &val) in &other.negative {
let entry = self.negative.entry(node).or_default();
*entry = (*entry).max(val);
}
}
/// Returns the current value: sum(P) - sum(N).
///
/// Saturates at 0 (never negative).
pub fn value(&self) -> u64 {
let p: u64 = self.positive.values().sum();
let n: u64 = self.negative.values().sum();
p.saturating_sub(n)
}
/// Total positive contributions across all nodes.
pub fn total_positive(&self) -> u64 {
self.positive.values().sum()
}
}
Acceptance Criteria
PNCounter::increment(node, amount)increases the P component fornodePNCounter::decrement(node, amount)increases the N component fornodePNCounter::value()returnssum(P) - sum(N), saturating at 0PNCounter::mergeis commutative:merge(A, B) == merge(B, A)(property test: 100K random sequences, 5 nodes)PNCounter::mergeis associative:merge(A, merge(B, C)) == merge(merge(A, B), C)(property test)PNCounter::mergeis idempotent:merge(A, A) == A(property test)- No double-counting: after merging two counters that each received N independent increments (no overlap),
value() == N * 2(property test) cargo clippy -D warningsandcargo fmtpass