Add snapshot-epoch cursor so 3+-node gossip converges in order and across compaction
All checks were successful
CI / check (push) Successful in 5m24s

This commit is contained in:
Jean Chevronnet 2026-07-19 22:17:27 +00:00
parent 6a22bee2e1
commit 83851c794d
No known key found for this signature in database
6 changed files with 239 additions and 70 deletions

View file

@ -23,16 +23,19 @@ Each node owns an append-only event log. The log is split into two scopes:
Nodes sync via anti-entropy: each advertises a per-origin version vector, and Nodes sync via anti-entropy: each advertises a per-origin version vector, and
peers send whatever Global entries the other is missing. Delivery is idempotent, peers send whatever Global entries the other is missing. Delivery is idempotent,
so a re-sent entry is dropped, and each account/channel event is an idempotent so a re-sent entry is dropped, and each account event is an idempotent upsert.
upsert. A peer converges by accepting any entry newer than its version vector
(including the forward jump a compacted node's log begins at).
> **Known limitation (3+ nodes).** In a relay mesh, an entry can reach a node Entries are applied **strictly in order per origin**, tracked by a `(epoch, seq)`
> ahead of its predecessors via a longer path, and the current ingest advances the cursor. Each node's seq increments per event; a **compaction** bumps that node's
> version vector past that gap. With two nodes (direct delivery) this never `epoch` and resets its seq to 0. A receiver therefore distinguishes the two forward
> happens. Closing it for larger meshes needs a snapshot-epoch marker so a jumps cleanly:
> compaction jump is distinguishable from a live gap; until then, prefer 2-node
> topologies. This is dormant today (no peers). - **A relay gap** (same epoch, a seq ahead of the receiver's) is *deferred* — the
cursor doesn't advance, so the digest re-requests the missing seqs in order. This
keeps a 3+-node relay mesh from silently dropping updates.
- **A compaction** (a higher epoch beginning at seq 0) is *adopted* — the receiver
takes the reset as the new authoritative baseline, so a peer converges across a
compaction even if it was mid-sync.
## The trust model ## The trust model
@ -80,9 +83,9 @@ Rules:
all account data are on that wire. Echo warns loudly at startup if TLS is off. all account data are on that wire. Echo warns loudly at startup if TLS is off.
- **Firewall** the `bind` port to your peer nodes' IPs only. - **Firewall** the `bind` port to your peer nodes' IPs only.
- Use a strong random `secret` and rotate it periodically. - Use a strong random `secret` and rotate it periodically.
- **Prefer 2 nodes.** Direct delivery keeps replication in order; a 3+-node relay - 3+-node relay meshes converge correctly (out-of-order delivery is deferred and
mesh has a known out-of-order edge (see "Known limitation" above) that isn't re-requested; compaction is handled by the epoch cursor — see "How gossip
closed yet. works"). The 2-node path is still the most exercised, so test your topology.
## Tier C — cross-operator federation (per-origin signing) ## Tier C — cross-operator federation (per-origin signing)

View file

@ -740,6 +740,16 @@ impl ChannelInfo {
// and order it — the origin node, its per-node sequence (`origin:seq` is the // and order it — the origin node, its per-node sequence (`origin:seq` is the
// cluster-unique id), and a Lamport clock for causal ordering across nodes. // cluster-unique id), and a Lamport clock for causal ordering across nodes.
// Lines written before these existed default them in. // Lines written before these existed default them in.
fn is_zero(n: &u64) -> bool {
*n == 0
}
// Order two per-origin cursors: is `incoming` strictly newer than `have`? Epoch
// dominates (a compaction bumps it), then seq within an epoch.
fn cursor_newer(have: (u64, u64), incoming: (u64, u64)) -> bool {
incoming.0 > have.0 || (incoming.0 == have.0 && incoming.1 > have.1)
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LogEntry { pub struct LogEntry {
#[serde(default)] #[serde(default)]
@ -748,6 +758,12 @@ pub struct LogEntry {
seq: u64, seq: u64,
#[serde(default)] #[serde(default)]
lamport: u64, lamport: u64,
// Compaction generation for `origin`. Bumped each time that node compacts, which
// resets its seq to 0; lets a peer tell a compaction's forward jump (higher epoch,
// seq 0) from a live relay gap (same epoch, seq ahead). Omitted while 0, so a
// never-compacted / single-node log is byte-identical to before this field existed.
#[serde(default, skip_serializing_if = "is_zero")]
epoch: u64,
// Optional Ed25519 signature (base64) over the entry, for Tier C federation. When // Optional Ed25519 signature (base64) over the entry, for Tier C federation. When
// signing is off it's None and `skip_serializing_if` omits it entirely, so the log // signing is off it's None and `skip_serializing_if` omits it entirely, so the log
// format is byte-identical to an unsigned deployment. Must precede the flattened // format is byte-identical to an unsigned deployment. Must precede the flattened
@ -761,7 +777,10 @@ pub struct LogEntry {
#[cfg(test)] #[cfg(test)]
impl LogEntry { impl LogEntry {
pub(crate) fn for_test(origin: &str, seq: u64, lamport: u64, event: Event) -> Self { pub(crate) fn for_test(origin: &str, seq: u64, lamport: u64, event: Event) -> Self {
LogEntry { origin: origin.to_string(), seq, lamport, sig: None, event } LogEntry { origin: origin.to_string(), seq, lamport, epoch: 0, sig: None, event }
}
pub(crate) fn for_test_epoch(origin: &str, epoch: u64, seq: u64, lamport: u64, event: Event) -> Self {
LogEntry { origin: origin.to_string(), seq, lamport, epoch, sig: None, event }
} }
} }
@ -792,7 +811,8 @@ pub struct EventLog {
path: PathBuf, path: PathBuf,
origin: String, origin: String,
lamport: u64, // logical clock, ticked on every event lamport: u64, // logical clock, ticked on every event
versions: HashMap<String, u64>, // per-origin highest seq applied (version vector) epoch: u64, // our origin's current compaction generation
versions: HashMap<String, (u64, u64)>, // per-origin (epoch, seq) cursor (version vector)
entries: Vec<LogEntry>, // full log, kept so peers can pull what they lack entries: Vec<LogEntry>, // full log, kept so peers can pull what they lack
outbound: Option<broadcast::Sender<LogEntry>>, // push newly committed entries to peers outbound: Option<broadcast::Sender<LogEntry>>, // push newly committed entries to peers
// Operator lockdown (OperServ SET READONLY): while set, locally-authored // Operator lockdown (OperServ SET READONLY): while set, locally-authored
@ -810,7 +830,7 @@ pub struct EventLog {
impl EventLog { impl EventLog {
fn open(path: PathBuf, origin: String) -> (Self, Vec<Event>) { fn open(path: PathBuf, origin: String) -> (Self, Vec<Event>) {
let mut log = Self { path, origin, lamport: 0, versions: HashMap::new(), entries: Vec::new(), outbound: None, readonly: false, file: None, signing: None }; let mut log = Self { path, origin, lamport: 0, epoch: 0, versions: HashMap::new(), entries: Vec::new(), outbound: None, readonly: false, file: None, signing: None };
if let Ok(data) = std::fs::read_to_string(&log.path) { if let Ok(data) = std::fs::read_to_string(&log.path) {
for line in data.lines().filter(|l| !l.trim().is_empty()) { for line in data.lines().filter(|l| !l.trim().is_empty()) {
match serde_json::from_str::<LogEntry>(line) { match serde_json::from_str::<LogEntry>(line) {
@ -829,16 +849,35 @@ impl EventLog {
// Roll the clock and version vector forward over an entry. Local (channel) // Roll the clock and version vector forward over an entry. Local (channel)
// entries carry no gossip identity, so they never touch the vector or clock. // entries carry no gossip identity, so they never touch the vector or clock.
fn absorb(&mut self, entry: &LogEntry) { fn absorb(&mut self, entry: &LogEntry) {
// Recover our compaction epoch from ANY of our own entries, including Local
// ones — a compaction whose snapshot carried no Global events (a node with
// only channels, or with external accounts) would otherwise lose the epoch on
// restart, reset our seqs, and permanently diverge subsequent writes from peers.
if entry.origin == self.origin {
self.epoch = self.epoch.max(entry.epoch);
}
if entry.event.scope() != Scope::Global { if entry.event.scope() != Scope::Global {
return; return;
} }
self.lamport = self.lamport.max(entry.lamport); self.lamport = self.lamport.max(entry.lamport);
self.versions.entry(entry.origin.clone()).and_modify(|s| *s = (*s).max(entry.seq)).or_insert(entry.seq); let cur = (entry.epoch, entry.seq);
self.versions
.entry(entry.origin.clone())
.and_modify(|c| {
if cursor_newer(*c, cur) {
*c = cur;
}
})
.or_insert(cur);
} }
// Seq the next locally-authored event will carry (0-based, per our origin). // Seq the next locally-authored event will carry (0-based within the current
// epoch, per our origin). Compaction resets this to 0 by bumping the epoch.
fn next_seq(&self) -> u64 { fn next_seq(&self) -> u64 {
self.versions.get(&self.origin).map_or(0, |s| s + 1) match self.versions.get(&self.origin) {
Some(&(e, s)) if e == self.epoch => s + 1,
_ => 0,
}
} }
// Persist a locally-authored event. Global events get the next seq + a ticked // Persist a locally-authored event. Global events get the next seq + a ticked
@ -853,10 +892,10 @@ impl EventLog {
let entry = if global { let entry = if global {
self.lamport = self.lamport.saturating_add(1); self.lamport = self.lamport.saturating_add(1);
let seq = self.next_seq(); let seq = self.next_seq();
let sig = self.signing.as_ref().map(|s| s.sign(&self.origin, seq, self.lamport, &event)); let sig = self.signing.as_ref().map(|s| s.sign(&self.origin, self.epoch, seq, self.lamport, &event));
LogEntry { origin: self.origin.clone(), seq, lamport: self.lamport, sig, event } LogEntry { origin: self.origin.clone(), seq, lamport: self.lamport, epoch: self.epoch, sig, event }
} else { } else {
LogEntry { origin: self.origin.clone(), seq: 0, lamport: 0, sig: None, event } LogEntry { origin: self.origin.clone(), seq: 0, lamport: 0, epoch: 0, sig: None, event }
}; };
if let Err(e) = self.persist(&entry) { if let Err(e) = self.persist(&entry) {
// Surface the real cause (disk full, permissions, ...) here at the // Surface the real cause (disk full, permissions, ...) here at the
@ -865,7 +904,7 @@ impl EventLog {
return Err(e); return Err(e);
} }
if global { if global {
self.versions.insert(entry.origin.clone(), entry.seq); self.versions.insert(entry.origin.clone(), (entry.epoch, entry.seq));
} }
// Push every committed entry to subscribers: the gossip forwarder filters to // Push every committed entry to subscribers: the gossip forwarder filters to
// Global before sending to a peer, but the gRPC directory stream wants the // Global before sending to a peer, but the gRPC directory stream wants the
@ -890,24 +929,28 @@ impl EventLog {
// signature from a trusted origin key, or it's rejected before it can touch // signature from a trusted origin key, or it's rejected before it can touch
// the version vector. Inert when signing is off (flat-trust / Tier B). // the version vector. Inert when signing is off (flat-trust / Tier B).
if let Some(s) = &self.signing { if let Some(s) = &self.signing {
if !s.verify(&entry.origin, entry.seq, entry.lamport, &entry.event, entry.sig.as_deref()) { if !s.verify(&entry.origin, entry.epoch, entry.seq, entry.lamport, &entry.event, entry.sig.as_deref()) {
return Ok(None); return Ok(None);
} }
} }
// Accept any entry newer than what we hold for this origin. A forward jump is // Epoch-aware, strictly in-order acceptance. This distinguishes a COMPACTION's
// legitimate here: a peer syncing from a COMPACTED node receives seqs that // forward jump (a higher epoch beginning at seq 0 — accept and adopt the reset)
// start well above 0 (the intermediate ones were folded into the snapshot), // from a live RELAY GAP in a 3+-node mesh (same epoch, a seq ahead of ours —
// and the fold is an idempotent upsert. NOTE: this cannot detect an out-of- // drop, so the version vector never advances past the gap and the digest
// order relay in a 3+-node mesh (an entry delivered ahead of its predecessors // re-requests it in order). An entry from a superseded older epoch is dropped.
// advances the version past the gap). Fixing that safely needs a snapshot-epoch let (e, s) = (entry.epoch, entry.seq);
// marker so a compaction jump is distinguishable from a live gap — deferred; let accept = match self.versions.get(&entry.origin).copied() {
// see docs/federation.md. Not reachable today (no gossip peers). None => s == 0, // a new origin: start only from seq 0
if self.versions.get(&entry.origin).is_some_and(|&s| entry.seq <= s) { Some((eb, sb)) if e == eb => s == sb + 1, // contiguous within the epoch
return Ok(None); // already have it Some((eb, _)) if e > eb => s == 0, // a compaction: adopt its seq-0 reset
Some(_) => false, // older epoch, already superseded
};
if !accept {
return Ok(None);
} }
self.persist(&entry)?; self.persist(&entry)?;
self.lamport = self.lamport.max(entry.lamport).saturating_add(1); // Lamport receive rule self.lamport = self.lamport.max(entry.lamport).saturating_add(1); // Lamport receive rule
self.versions.insert(entry.origin.clone(), entry.seq); self.versions.insert(entry.origin.clone(), (e, s));
let event = entry.event.clone(); let event = entry.event.clone();
self.notify(&entry); self.notify(&entry);
self.entries.push(entry); self.entries.push(entry);
@ -938,17 +981,20 @@ impl EventLog {
} }
// Our version vector: highest seq applied per origin. // Our version vector: highest seq applied per origin.
fn version_vector(&self) -> HashMap<String, u64> { fn version_vector(&self) -> HashMap<String, (u64, u64)> {
self.versions.clone() self.versions.clone()
} }
// Global entries a peer is missing, given the version vector it advertised. // Global entries a peer is missing, given the (epoch, seq) cursor it advertised.
// Local (channel) entries are never offered — they don't leave the node. // Local (channel) entries are never offered — they don't leave the node.
fn missing_for(&self, peer: &HashMap<String, u64>) -> Vec<LogEntry> { fn missing_for(&self, peer: &HashMap<String, (u64, u64)>) -> Vec<LogEntry> {
self.entries self.entries
.iter() .iter()
.filter(|e| e.event.scope() == Scope::Global) .filter(|e| e.event.scope() == Scope::Global)
.filter(|e| peer.get(&e.origin).is_none_or(|&s| e.seq > s)) .filter(|e| match peer.get(&e.origin) {
None => true,
Some(&h) => cursor_newer(h, (e.epoch, e.seq)),
})
.cloned() .cloned()
.collect() .collect()
} }
@ -987,20 +1033,32 @@ impl EventLog {
// theirs on the next sync. Written to a temp file and renamed, so a crash // theirs on the next sync. Written to a temp file and renamed, so a crash
// leaves the old log. // leaves the old log.
fn compact(&mut self, events: Vec<Event>) -> std::io::Result<()> { fn compact(&mut self, events: Vec<Event>) -> std::io::Result<()> {
let mut seq = self.next_seq(); // An empty snapshot means no state to preserve; skip so the bumped epoch is
// never written onto an empty file (where a restart couldn't recover it).
if events.is_empty() {
return Ok(());
}
// A new compaction generation: bump the epoch and restart seqs at 0. A peer
// sees the higher epoch beginning at seq 0 and adopts it (accepting the jump)
// rather than mistaking it for a live gap.
self.epoch = self.epoch.saturating_add(1);
let mut seq = 0u64;
let mut last_global = None; let mut last_global = None;
let mut snapshot = Vec::with_capacity(events.len()); let mut snapshot = Vec::with_capacity(events.len());
for event in events { for event in events {
let entry = if event.scope() == Scope::Global { let entry = if event.scope() == Scope::Global {
self.lamport = self.lamport.saturating_add(1); self.lamport = self.lamport.saturating_add(1);
// Compaction re-seqs entries, so any signature must be recomputed. // Compaction re-seqs (and re-epochs) entries, so any signature must be recomputed.
let sig = self.signing.as_ref().map(|s| s.sign(&self.origin, seq, self.lamport, &event)); let sig = self.signing.as_ref().map(|s| s.sign(&self.origin, self.epoch, seq, self.lamport, &event));
let e = LogEntry { origin: self.origin.clone(), seq, lamport: self.lamport, sig, event }; let e = LogEntry { origin: self.origin.clone(), seq, lamport: self.lamport, epoch: self.epoch, sig, event };
last_global = Some(seq); last_global = Some(seq);
seq += 1; seq += 1;
e e
} else { } else {
LogEntry { origin: self.origin.clone(), seq: 0, lamport: 0, sig: None, event } // Stamp Local entries with the epoch too, so it survives a restart even
// when the snapshot carries no Global events (Local entries never gossip,
// so this only feeds `absorb`'s epoch recovery).
LogEntry { origin: self.origin.clone(), seq: 0, lamport: 0, epoch: self.epoch, sig: None, event }
}; };
snapshot.push(entry); snapshot.push(entry);
} }
@ -1018,7 +1076,7 @@ impl EventLog {
self.file = None; self.file = None;
self.versions = HashMap::new(); self.versions = HashMap::new();
if let Some(last) = last_global { if let Some(last) = last_global {
self.versions.insert(self.origin.clone(), last); self.versions.insert(self.origin.clone(), (self.epoch, last));
} }
self.entries = snapshot; self.entries = snapshot;
Ok(()) Ok(())
@ -1453,13 +1511,13 @@ impl Db {
self.log.events_from(mark) self.log.events_from(mark)
} }
/// Our version vector, advertised to peers so they can send what we lack. /// Our version vector — a per-origin (epoch, seq) cursor — advertised to peers.
pub fn version_vector(&self) -> HashMap<String, u64> { pub fn version_vector(&self) -> HashMap<String, (u64, u64)> {
self.log.version_vector() self.log.version_vector()
} }
/// The log entries a peer is missing, given the version vector it sent. /// The log entries a peer is missing, given the version vector it sent.
pub fn missing_for(&self, peer: &HashMap<String, u64>) -> Vec<LogEntry> { pub fn missing_for(&self, peer: &HashMap<String, (u64, u64)>) -> Vec<LogEntry> {
self.log.missing_for(peer) self.log.missing_for(peer)
} }

View file

@ -15,10 +15,11 @@ use super::Event;
// The bytes a signature covers: everything that fixes the entry's identity and // The bytes a signature covers: everything that fixes the entry's identity and
// content. `serde_json` is deterministic for the Global event set (all `Vec`/scalar // content. `serde_json` is deterministic for the Global event set (all `Vec`/scalar
// fields — no unordered maps), so sender and receiver derive the same bytes. // fields — no unordered maps), so sender and receiver derive the same bytes.
fn payload(origin: &str, seq: u64, lamport: u64, event: &Event) -> Vec<u8> { fn payload(origin: &str, epoch: u64, seq: u64, lamport: u64, event: &Event) -> Vec<u8> {
let mut v = Vec::new(); let mut v = Vec::new();
v.extend_from_slice(origin.as_bytes()); v.extend_from_slice(origin.as_bytes());
v.push(0); v.push(0);
v.extend_from_slice(&epoch.to_le_bytes());
v.extend_from_slice(&seq.to_le_bytes()); v.extend_from_slice(&seq.to_le_bytes());
v.extend_from_slice(&lamport.to_le_bytes()); v.extend_from_slice(&lamport.to_le_bytes());
v.push(0); v.push(0);
@ -51,19 +52,19 @@ impl Signing {
} }
// Sign an entry we're authoring; base64 of the 64-byte signature. // Sign an entry we're authoring; base64 of the 64-byte signature.
pub fn sign(&self, origin: &str, seq: u64, lamport: u64, event: &Event) -> String { pub fn sign(&self, origin: &str, epoch: u64, seq: u64, lamport: u64, event: &Event) -> String {
B64.encode(self.signer.sign(&payload(origin, seq, lamport, event)).to_bytes()) B64.encode(self.signer.sign(&payload(origin, epoch, seq, lamport, event)).to_bytes())
} }
// True if `sig` is a valid signature over the entry by the trusted key for its // True if `sig` is a valid signature over the entry by the trusted key for its
// origin. False if the origin isn't trusted, the signature is missing, or it // origin. False if the origin isn't trusted, the signature is missing, or it
// doesn't verify (`verify_strict` also rejects malleable/degenerate signatures). // doesn't verify (`verify_strict` also rejects malleable/degenerate signatures).
pub fn verify(&self, origin: &str, seq: u64, lamport: u64, event: &Event, sig: Option<&str>) -> bool { pub fn verify(&self, origin: &str, epoch: u64, seq: u64, lamport: u64, event: &Event, sig: Option<&str>) -> bool {
let Some(vk) = self.trust.get(origin) else { return false }; let Some(vk) = self.trust.get(origin) else { return false };
let Some(sig) = sig else { return false }; let Some(sig) = sig else { return false };
let Ok(raw) = B64.decode(sig.trim()) else { return false }; let Ok(raw) = B64.decode(sig.trim()) else { return false };
let Ok(bytes) = <[u8; 64]>::try_from(raw.as_slice()) else { return false }; let Ok(bytes) = <[u8; 64]>::try_from(raw.as_slice()) else { return false };
vk.verify_strict(&payload(origin, seq, lamport, event), &Signature::from_bytes(&bytes)).is_ok() vk.verify_strict(&payload(origin, epoch, seq, lamport, event), &Signature::from_bytes(&bytes)).is_ok()
} }
} }
@ -88,13 +89,14 @@ mod tests {
let trust = HashMap::from([("A".to_string(), pubk)]); let trust = HashMap::from([("A".to_string(), pubk)]);
let s = Signing::new(&sec, &trust).unwrap(); let s = Signing::new(&sec, &trust).unwrap();
let sig = s.sign("A", 3, 4, &ev()); let sig = s.sign("A", 5, 3, 4, &ev());
assert!(s.verify("A", 3, 4, &ev(), Some(&sig)), "a genuine signature verifies"); assert!(s.verify("A", 5, 3, 4, &ev(), Some(&sig)), "a genuine signature verifies");
// Any change to the covered fields invalidates it. // Any change to the covered fields invalidates it.
assert!(!s.verify("A", 4, 4, &ev(), Some(&sig)), "a different seq is rejected"); assert!(!s.verify("A", 5, 4, 4, &ev(), Some(&sig)), "a different seq is rejected");
assert!(!s.verify("A", 3, 4, &Event::AccountDropped { account: "bob".into() }, Some(&sig)), "a different event is rejected"); assert!(!s.verify("A", 6, 3, 4, &ev(), Some(&sig)), "a different epoch is rejected");
assert!(!s.verify("A", 3, 4, &ev(), None), "a missing signature is rejected"); assert!(!s.verify("A", 5, 3, 4, &Event::AccountDropped { account: "bob".into() }, Some(&sig)), "a different event is rejected");
assert!(!s.verify("B", 3, 4, &ev(), Some(&sig)), "an untrusted origin is rejected"); assert!(!s.verify("A", 5, 3, 4, &ev(), None), "a missing signature is rejected");
assert!(!s.verify("B", 5, 3, 4, &ev(), Some(&sig)), "an untrusted origin is rejected");
} }
#[test] #[test]
@ -104,7 +106,7 @@ mod tests {
// We trust A's key; B tries to forge an entry claiming origin A. // We trust A's key; B tries to forge an entry claiming origin A.
let s = Signing::new(&sec_a, &HashMap::from([("A".to_string(), pub_a)])).unwrap(); let s = Signing::new(&sec_a, &HashMap::from([("A".to_string(), pub_a)])).unwrap();
let forger = Signing::new(&sec_b, &HashMap::new()).unwrap(); let forger = Signing::new(&sec_b, &HashMap::new()).unwrap();
let forged = forger.sign("A", 1, 1, &ev()); let forged = forger.sign("A", 0, 1, 1, &ev());
assert!(!s.verify("A", 1, 1, &ev(), Some(&forged)), "a signature from the wrong key is rejected"); assert!(!s.verify("A", 0, 1, 1, &ev(), Some(&forged)), "a signature from the wrong key is rejected");
} }
} }

View file

@ -84,7 +84,7 @@
hide_status: false, snotice: false, vhost: None, vhost_request: None, last_seen: ts, noexpire: false, hide_status: false, snotice: false, vhost: None, vhost_request: None, last_seen: ts, noexpire: false,
expiry_warned: false, oper_note: None, expiry_warned: false, oper_note: None,
}; };
let reg = |origin: &str, a: Account| LogEntry::for_test(origin, 1, 1, Event::AccountRegistered(Box::new(a))); let reg = |origin: &str, a: Account| LogEntry::for_test(origin, 0, 1, Event::AccountRegistered(Box::new(a)));
// Same account, re-homed to B with the SAME verifier — not a takeover. // Same account, re-homed to B with the SAME verifier — not a takeover.
let mut db = Db::open(tmp("rehome-same"), "A"); let mut db = Db::open(tmp("rehome-same"), "A");
@ -108,7 +108,7 @@
db.register_channel("#c", "alice").unwrap(); // local db.register_channel("#c", "alice").unwrap(); // local
db.set_mlock("#c", "nt", "").unwrap(); // local db.set_mlock("#c", "nt", "").unwrap(); // local
// Only the account advances the version vector. // Only the account advances the version vector.
assert_eq!(db.version_vector().get("N1"), Some(&0), "channels don't advance the vector"); assert_eq!(db.version_vector().get("N1"), Some(&(0, 0)), "channels don't advance the vector");
// A fresh peer is offered the account, never the channel. // A fresh peer is offered the account, never the channel.
let missing = db.missing_for(&HashMap::new()); let missing = db.missing_for(&HashMap::new());
assert_eq!(missing.len(), 1, "only the global account entry is offered: {missing:?}"); assert_eq!(missing.len(), 1, "only the global account entry is offered: {missing:?}");
@ -118,7 +118,7 @@
let db = Db::open(&path, "N1"); let db = Db::open(&path, "N1");
assert!(db.exists("alice")); assert!(db.exists("alice"));
assert_eq!(db.channel("#c").map(|c| c.lock_on.clone()), Some("nt".to_string()), "channel state persists locally"); assert_eq!(db.channel("#c").map(|c| c.lock_on.clone()), Some("nt".to_string()), "channel state persists locally");
assert_eq!(db.version_vector().get("N1"), Some(&0), "vector unchanged after replay"); assert_eq!(db.version_vector().get("N1"), Some(&(0, 0)), "vector unchanged after replay");
} }
#[test] #[test]
@ -399,7 +399,7 @@
assert!(ev.is_empty()); assert!(ev.is_empty());
log.append(cert("x", "f1")).unwrap(); log.append(cert("x", "f1")).unwrap();
log.append(cert("x", "f2")).unwrap(); log.append(cert("x", "f2")).unwrap();
assert_eq!(log.versions.get("nodeA"), Some(&1)); // seqs 0 then 1 assert_eq!(log.versions.get("nodeA"), Some(&(0, 1))); // epoch 0, seqs 0 then 1
assert_eq!(log.lamport, 2); assert_eq!(log.lamport, 2);
assert_eq!(log.next_seq(), 2); assert_eq!(log.next_seq(), 2);
} }
@ -424,9 +424,9 @@
#[test] #[test]
fn ingest_is_idempotent() { fn ingest_is_idempotent() {
let (mut log, _) = EventLog::open(tmp("ingest"), "local".into()); let (mut log, _) = EventLog::open(tmp("ingest"), "local".into());
let entry = LogEntry { origin: "peer".into(), seq: 0, lamport: 5, sig: None, event: cert("x", "f") }; let entry = LogEntry { origin: "peer".into(), seq: 0, lamport: 5, epoch: 0, sig: None, event: cert("x", "f") };
assert!(log.ingest(entry.clone()).unwrap().is_some(), "first ingest applies"); assert!(log.ingest(entry.clone()).unwrap().is_some(), "first ingest applies");
assert_eq!(log.versions.get("peer"), Some(&0)); assert_eq!(log.versions.get("peer"), Some(&(0, 0)));
assert_eq!(log.lamport, 6, "receive rule: max(local, remote) + 1"); assert_eq!(log.lamport, 6, "receive rule: max(local, remote) + 1");
assert!(log.ingest(entry).unwrap().is_none(), "duplicate ingest is a no-op"); assert!(log.ingest(entry).unwrap().is_none(), "duplicate ingest is a no-op");
} }
@ -441,7 +441,7 @@
name: "bob".into(), email: None, name: "bob".into(), email: None,
ts: 0, home: "peer".into(), scram256: None, scram512: None, certfps: vec![], verified: true, ajoin: vec![], suspension: None, memos: vec![], memo_ignore: vec![], memo_notify: true, memo_limit: None, greet: String::new(), no_autoop: false, no_protect: false, hide_status: false, snotice: false, vhost: None, vhost_request: None, last_seen: 0, noexpire: false, expiry_warned: false, oper_note: None, ts: 0, home: "peer".into(), scram256: None, scram512: None, certfps: vec![], verified: true, ajoin: vec![], suspension: None, memos: vec![], memo_ignore: vec![], memo_notify: true, memo_limit: None, greet: String::new(), no_autoop: false, no_protect: false, hide_status: false, snotice: false, vhost: None, vhost_request: None, last_seen: 0, noexpire: false, expiry_warned: false, oper_note: None,
}; };
let entry = LogEntry { origin: "peer".into(), seq: 0, lamport: 1, sig: None, event: Event::AccountRegistered(Box::new(bob)) }; let entry = LogEntry { origin: "peer".into(), seq: 0, lamport: 1, epoch: 0, sig: None, event: Event::AccountRegistered(Box::new(bob)) };
db.ingest(entry).unwrap(); db.ingest(entry).unwrap();
assert!(db.exists("bob"), "peer's account is present locally"); assert!(db.exists("bob"), "peer's account is present locally");
assert!(db.exists("alice"), "local account still there"); assert!(db.exists("alice"), "local account still there");
@ -582,6 +582,112 @@
assert_eq!(b.certfps("alice"), &[fp][..], "certs arrive folded into the snapshot"); assert_eq!(b.certfps("alice"), &[fp][..], "certs arrive folded into the snapshot");
} }
fn akill(origin: &str, epoch: u64, seq: u64, mask: &str) -> LogEntry {
LogEntry::for_test_epoch(origin, epoch, seq, seq + 1, Event::AkillAdded {
kind: "G".into(), mask: mask.into(), setter: "op".into(), reason: "x".into(), ts: 0, expires: None,
})
}
// The snapshot-epoch fix, part 1: a peer holding an origin's PARTIAL state still
// converges across that origin's compaction (the forward epoch jump is accepted).
// This is exactly the case the earlier strict-ordering attempt broke.
#[test]
fn lagging_peer_converges_across_a_compaction() {
let mut a = Db::open(tmp("epochA"), "AAA");
a.scram_iterations = 4096;
a.register("u0", "pw", None).unwrap();
a.register("u1", "pw", None).unwrap();
// B syncs A's initial (epoch 0) state, then lags.
let mut b = Db::open(tmp("epochB"), "BBB");
b.scram_iterations = 4096;
for e in a.missing_for(&b.version_vector()) {
b.ingest(e).unwrap();
}
assert!(b.exists("u0") && b.exists("u1"), "B has A's initial state");
// A advances and COMPACTS (epoch 0 -> 1, seqs reset to 0).
a.register("u2", "pw", None).unwrap();
a.compact().unwrap();
// B, still at epoch 0, must converge across the epoch jump.
for e in a.missing_for(&b.version_vector()) {
b.ingest(e).unwrap();
}
assert!(b.exists("u2"), "B converges across A's compaction (epoch jump adopted)");
}
// Live-critical: after a compaction bumps the epoch and resets seqs, further local
// appends continue correctly, and the epoch/next-seq reconstruct across a reopen.
#[test]
fn append_after_compaction_continues_and_survives_reopen() {
let path = tmp("epochAppend");
{
let mut db = Db::open(&path, "AAA");
db.scram_iterations = 4096;
db.register("u0", "pw", None).unwrap();
db.compact().unwrap(); // epoch 0 -> 1, seqs reset
db.register("u1", "pw", None).unwrap();
assert!(db.exists("u0") && db.exists("u1"));
}
let mut db = Db::open(&path, "AAA");
db.scram_iterations = 4096;
db.register("u2", "pw", None).unwrap();
assert!(db.exists("u0") && db.exists("u1") && db.exists("u2"), "state survives compaction + reopen + append");
assert_eq!(db.version_vector().get("AAA").map(|c| c.0), Some(1), "epoch stays 1 after one compaction (reopen doesn't re-bump)");
}
// Durability: a compaction whose snapshot has NO Global events (only channels)
// must still persist the bumped epoch, or a restart resets it to 0 and later
// writes diverge from peers.
#[test]
fn epoch_survives_restart_after_an_all_local_compaction() {
let path = tmp("epochLocal");
{
let mut db = Db::open(&path, "AAA");
db.scram_iterations = 4096;
db.register_channel("#c", "founder").unwrap(); // Local only — no Global entry
db.compact().unwrap(); // epoch 0 -> 1, all-Local snapshot
}
let mut db = Db::open(&path, "AAA");
db.scram_iterations = 4096;
db.register("alice", "pw", None).unwrap(); // first Global entry after restart
assert_eq!(db.version_vector().get("AAA").map(|c| c.0), Some(1), "epoch recovered from Local entries, not reset to 0");
}
// The snapshot-epoch fix, part 2: an out-of-order relay WITHIN an epoch (a future
// seq ahead of ours) is deferred — the version vector doesn't advance past the
// gap — and the entries apply once the gap fills in order.
#[test]
fn out_of_order_within_an_epoch_is_deferred_then_fills() {
let mut b = Db::open(tmp("epochOOO"), "BBB");
b.ingest(akill("CCC", 0, 0, "*@a")).unwrap();
assert_eq!(b.version_vector().get("CCC"), Some(&(0, 0)));
// seq 2 before seq 1: dropped, version stays at (0,0).
b.ingest(akill("CCC", 0, 2, "*@c")).unwrap();
assert_eq!(b.version_vector().get("CCC"), Some(&(0, 0)), "a gap within the epoch is not applied");
// seq 1 fills the gap, then the re-delivered seq 2 is contiguous.
b.ingest(akill("CCC", 0, 1, "*@b")).unwrap();
assert_eq!(b.version_vector().get("CCC"), Some(&(0, 1)));
b.ingest(akill("CCC", 0, 2, "*@c")).unwrap();
assert_eq!(b.version_vector().get("CCC"), Some(&(0, 2)), "the gap filled, seq 2 applies");
}
// The snapshot-epoch fix, part 3: a higher epoch is adopted ONLY from its seq 0,
// so a relayed higher-epoch entry arriving before the snapshot's start is deferred.
#[test]
fn a_new_epoch_is_adopted_only_from_seq_zero() {
let mut b = Db::open(tmp("epochNew"), "BBB");
b.ingest(akill("CCC", 0, 0, "*@a")).unwrap();
assert_eq!(b.version_vector().get("CCC"), Some(&(0, 0)));
// Epoch 1 seq 3 arrives (relay ahead of the new snapshot's seq 0): deferred.
b.ingest(akill("CCC", 1, 3, "*@x")).unwrap();
assert_eq!(b.version_vector().get("CCC"), Some(&(0, 0)), "new epoch not adopted before its seq 0");
// Its seq 0 arrives: adopt the new epoch (the compaction reset).
b.ingest(akill("CCC", 1, 0, "*@y")).unwrap();
assert_eq!(b.version_vector().get("CCC"), Some(&(1, 0)), "new epoch adopted from seq 0");
}
// Tier C: a node accepts a signed entry from a trusted origin, and rejects one // Tier C: a node accepts a signed entry from a trusted origin, and rejects one
// from an origin it doesn't trust — even if that origin signed it validly. // from an origin it doesn't trust — even if that origin signed it validly.
#[test] #[test]

View file

@ -639,11 +639,11 @@ impl Engine {
} }
// Gossip pass-throughs to the account store, used by the replication layer. // Gossip pass-throughs to the account store, used by the replication layer.
pub fn gossip_digest(&self) -> HashMap<String, u64> { pub fn gossip_digest(&self) -> HashMap<String, (u64, u64)> {
self.db.version_vector() self.db.version_vector()
} }
pub fn gossip_missing(&self, peer: &HashMap<String, u64>) -> Vec<LogEntry> { pub fn gossip_missing(&self, peer: &HashMap<String, (u64, u64)>) -> Vec<LogEntry> {
self.db.missing_for(peer) self.db.missing_for(peer)
} }

View file

@ -65,7 +65,7 @@ enum Msg {
// A version vector. `reply` asks the peer to answer with its own digest, so a // A version vector. `reply` asks the peer to answer with its own digest, so a
// node that dropped a push can pull back exactly what the peer is missing. // node that dropped a push can pull back exactly what the peer is missing.
Digest { Digest {
versions: HashMap<String, u64>, versions: HashMap<String, (u64, u64)>,
#[serde(default)] #[serde(default)]
reply: bool, reply: bool,
}, },