160 lines
5.9 KiB
Rust
160 lines
5.9 KiB
Rust
//! Causal context as a dotted version vector (Chapter 6 §"Causal Context via
|
|
//! Dotted Version Vectors").
|
|
//!
|
|
//! Operations carry a *compact* causal context, not an exhaustive predecessor
|
|
//! list — exhaustive lists scale linearly with history and become untenable.
|
|
//! A [`CausalContext`] is a dotted version vector (DVV):
|
|
//!
|
|
//! * `vector`: for each replica the authoring replica knows, the highest
|
|
//! *contiguous* counter it has observed. `vector[r] = n` asserts that every
|
|
//! operation `(r, 0..=n)` is a causal predecessor. The counter floor is
|
|
//! **zero-based** and normative — RATIFIED by Pass 11 (item 3.4, P11-C7):
|
|
//! core_spec §"Causal Context via Dotted Version Vectors" now pins the
|
|
//! zero-based floor so a second implementation cannot pick a one-based floor
|
|
//! and diverge on pending detection.
|
|
//! * `dots`: individual [`OperationId`]s observed but not yet contiguous in the
|
|
//! vector — "known but not yet contiguous" predecessors.
|
|
//!
|
|
//! The canonical reduction order is causal-first (Chapter 6 §6.3.3). Although
|
|
//! correctly authored operations give causal predecessors strictly-lesser HLC
|
|
//! stamps, accepted remote envelopes may violate that authoring rule. Reduction
|
|
//! therefore topologically orders the DVV edges and uses HLC only among ready
|
|
//! operations — see [`crate::canonical_reduction_order`]. The DVV also drives
|
|
//! the *missing-causal-predecessor* rule (an operation whose predecessor is
|
|
//! absent, equivocated, or excluded is held pending) and the transaction
|
|
//! descriptor-precedence rule (Chapter 6 §6.7).
|
|
|
|
use std::collections::{BTreeMap, BTreeSet};
|
|
|
|
use epiphany_core::{OperationId, ReplicaId};
|
|
use epiphany_determinism::CanonicalEncode;
|
|
|
|
use crate::encode::{push_canon, push_len, push_u64};
|
|
|
|
/// A compact causal context: a dotted version vector (Chapter 6 §6.2).
|
|
///
|
|
/// Both members are canonically-ordered collections (`BTreeMap` keyed by
|
|
/// [`ReplicaId`], `BTreeSet` of [`OperationId`]), so iteration is already in the
|
|
/// Appendix-D normative order and the canonical encoding is order-independent
|
|
/// of how the context was built.
|
|
#[derive(Clone, PartialEq, Eq, Debug, Default)]
|
|
pub struct CausalContext {
|
|
/// Highest contiguous counter observed per replica.
|
|
pub vector: BTreeMap<ReplicaId, u64>,
|
|
/// Individual operations known but not yet contiguous in the vector.
|
|
pub dots: BTreeSet<OperationId>,
|
|
}
|
|
|
|
impl CausalContext {
|
|
/// The empty context: no observed predecessors (a root operation).
|
|
#[inline]
|
|
pub fn new() -> Self {
|
|
CausalContext::default()
|
|
}
|
|
|
|
/// Records that every operation of `replica` up to and including `counter`
|
|
/// has been observed (the contiguous-history assertion). A later, higher
|
|
/// value for the same replica replaces an earlier one.
|
|
pub fn with_seen(mut self, replica: ReplicaId, counter: u64) -> Self {
|
|
let slot = self.vector.entry(replica).or_insert(counter);
|
|
if counter > *slot {
|
|
*slot = counter;
|
|
}
|
|
self
|
|
}
|
|
|
|
/// Records a single non-contiguous predecessor (a "dot").
|
|
pub fn with_dot(mut self, op: OperationId) -> Self {
|
|
self.dots.insert(op);
|
|
self
|
|
}
|
|
|
|
/// Whether `op` is a (direct) causal predecessor under this context: either
|
|
/// its counter is within the contiguous range recorded for its replica, or
|
|
/// it appears among the dots.
|
|
#[inline]
|
|
pub fn covers(&self, op: OperationId) -> bool {
|
|
if let Some(&high) = self.vector.get(&op.replica) {
|
|
if op.counter <= high {
|
|
return true;
|
|
}
|
|
}
|
|
self.dots.contains(&op)
|
|
}
|
|
|
|
/// Whether this context references any predecessor at all.
|
|
#[inline]
|
|
pub fn is_empty(&self) -> bool {
|
|
self.vector.is_empty() && self.dots.is_empty()
|
|
}
|
|
|
|
/// The dots as a slice-free iterator, in canonical (ascending) order.
|
|
#[inline]
|
|
pub fn dots(&self) -> impl Iterator<Item = OperationId> + '_ {
|
|
self.dots.iter().copied()
|
|
}
|
|
}
|
|
|
|
impl CanonicalEncode for CausalContext {
|
|
fn encode_canonical(&self, out: &mut Vec<u8>) {
|
|
// Vector: count, then (replica big-endian, counter little-endian) in
|
|
// ascending replica order (BTreeMap iteration is already canonical).
|
|
push_len(out, self.vector.len());
|
|
for (replica, counter) in &self.vector {
|
|
out.extend_from_slice(&replica.to_be_bytes());
|
|
push_u64(out, *counter);
|
|
}
|
|
// Dots: count, then each OperationId's 16 canonical bytes in ascending
|
|
// order (BTreeSet iteration is already canonical).
|
|
push_len(out, self.dots.len());
|
|
for dot in &self.dots {
|
|
push_canon(out, dot);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn op(r: u64, c: u64) -> OperationId {
|
|
OperationId::new(ReplicaId(r), c)
|
|
}
|
|
|
|
#[test]
|
|
fn covers_uses_contiguous_range_and_dots() {
|
|
let ctx = CausalContext::new()
|
|
.with_seen(ReplicaId(1), 5)
|
|
.with_dot(op(2, 9));
|
|
assert!(ctx.covers(op(1, 0)));
|
|
assert!(ctx.covers(op(1, 5)));
|
|
assert!(!ctx.covers(op(1, 6)));
|
|
assert!(ctx.covers(op(2, 9)));
|
|
assert!(!ctx.covers(op(2, 8)));
|
|
assert!(!ctx.covers(op(3, 0)));
|
|
}
|
|
|
|
#[test]
|
|
fn with_seen_keeps_the_highest_counter() {
|
|
let ctx = CausalContext::new()
|
|
.with_seen(ReplicaId(1), 5)
|
|
.with_seen(ReplicaId(1), 3);
|
|
assert_eq!(ctx.vector.get(&ReplicaId(1)), Some(&5));
|
|
}
|
|
|
|
#[test]
|
|
fn canonical_encoding_is_build_order_independent() {
|
|
let a = CausalContext::new()
|
|
.with_seen(ReplicaId(2), 1)
|
|
.with_seen(ReplicaId(1), 7)
|
|
.with_dot(op(9, 9))
|
|
.with_dot(op(3, 3));
|
|
let b = CausalContext::new()
|
|
.with_dot(op(3, 3))
|
|
.with_seen(ReplicaId(1), 7)
|
|
.with_dot(op(9, 9))
|
|
.with_seen(ReplicaId(2), 1);
|
|
assert_eq!(a.to_canonical_bytes(), b.to_canonical_bytes());
|
|
}
|
|
}
|