Skip to content

Commit 138e79a

Browse files
cursoragentDJLougen
andcommitted
fix(rust_brain): preserve HLC on snapshot restore and gossip receive
restore_from_file() dropped HLC timestamps from snapshots, assigning fresh (wall, 0, uuid) values instead. After disaster recovery, any causal successor write with the pre-crash HLC was rejected with TimestampRegression, causing silent data loss on replay/gossip. - Add _parse_hlc() for wire/snapshot normalisation with legacy fallback - restore_from_file: restore hlc, update global _hlc, hold lock during restore - bulk_write: pass through hlc from row payloads - gossip.receive: apply hlc, reject stale overwrites, skip missing-hlc updates Regression tests cover snapshot round-trip and gossip HLC ordering. Co-authored-by: Daniel <DJLougen@users.noreply.github.com>
1 parent 3b52d6b commit 138e79a

4 files changed

Lines changed: 128 additions & 20 deletions

File tree

hive/gossip.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,11 +122,28 @@ def receive(self, events: list[dict[str, Any]]) -> int:
122122
applied = 0
123123
for ev in events:
124124
try:
125+
key = ev["key"]
126+
raw_hlc = ev.get("hlc")
127+
if raw_hlc is None:
128+
# Without an HLC we cannot establish causal order for updates.
129+
if self._brain.get(key) is not None:
130+
_log.debug(
131+
"Skipping gossip update for %r: missing hlc on existing key",
132+
key,
133+
)
134+
continue
135+
hlc = None
136+
else:
137+
hlc = tuple(raw_hlc)
138+
self._brain.update_hlc(hlc)
139+
125140
self._brain.remember(
126-
ev["key"],
141+
key,
127142
ev["value"],
128143
trust=ev.get("trust", 1.0),
129144
tags=set(ev.get("tags", [])),
145+
ts_ns=ev.get("ts_ns"),
146+
hlc=hlc,
130147
)
131148
applied += 1
132149
except Exception as exc:

hive/rust_brain/__init__.py

Lines changed: 40 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,19 @@ def _now_ns() -> int:
125125
return time.time_ns() - HIVE_EPOCH_NS
126126

127127

128+
def _parse_hlc(raw: Any, *, ts_ns: int | None = None) -> tuple[int, int, str]:
129+
"""Normalise an HLC from wire/snapshot form (list or tuple).
130+
131+
Falls back to a synthetic HLC from ``ts_ns`` for pre-v0.6.0 snapshots.
132+
"""
133+
if raw is not None:
134+
wall, logical, node_id = raw
135+
return (int(wall), int(logical), str(node_id))
136+
if ts_ns is not None:
137+
return (ts_ns + HIVE_EPOCH_NS, 0, "legacy")
138+
return _hlc.now()
139+
140+
128141
# ---------------------------------------------------------------------------
129142
# Edge / node model
130143
# ---------------------------------------------------------------------------
@@ -389,6 +402,9 @@ def bulk_write(self, rows: Iterable[Mapping[str, Any]]) -> int:
389402
tags=row.get("tags", ()),
390403
edges=row.get("edges"),
391404
ts_ns=row.get("ts_ns"),
405+
hlc=_parse_hlc(row.get("hlc"), ts_ns=row.get("ts_ns"))
406+
if row.get("hlc") is not None or row.get("ts_ns") is not None
407+
else None,
392408
)
393409
n += 1
394410
return n
@@ -505,25 +521,30 @@ def restore_from_file(self, path: str) -> int:
505521
raise ValueError(
506522
"snapshot checksum mismatch: file is corrupt or tampered"
507523
)
508-
self._nodes.clear()
509-
self._order.clear()
510-
self._order_index.clear()
511-
for node_dict in nodes:
512-
node = MemoryNode(
513-
key=node_dict["key"],
514-
value=node_dict["value"],
515-
ts_ns=node_dict["ts_ns"],
516-
trust=node_dict.get("trust", 1.0),
517-
tags=set(node_dict.get("tags", [])),
518-
node_id=node_dict.get("id", uuid.uuid4().hex[:12]),
519-
)
520-
for kind, neighbours in node_dict.get("edges", {}).items():
521-
for n in neighbours:
522-
node.attach(kind, n)
523-
storage_key = self._prefix(node.key)
524-
self._nodes[storage_key] = node
525-
self._order_index[storage_key] = len(self._order)
526-
self._order.append(storage_key)
524+
with self._lock:
525+
self._nodes.clear()
526+
self._order.clear()
527+
self._order_index.clear()
528+
for node_dict in nodes:
529+
ts_ns = node_dict["ts_ns"]
530+
node_hlc = _parse_hlc(node_dict.get("hlc"), ts_ns=ts_ns)
531+
node = MemoryNode(
532+
key=node_dict["key"],
533+
value=node_dict["value"],
534+
ts_ns=ts_ns,
535+
hlc=node_hlc,
536+
trust=node_dict.get("trust", 1.0),
537+
tags=set(node_dict.get("tags", [])),
538+
node_id=node_dict.get("id", uuid.uuid4().hex[:12]),
539+
)
540+
for kind, neighbours in node_dict.get("edges", {}).items():
541+
for n in neighbours:
542+
node.attach(kind, n)
543+
storage_key = self._prefix(node.key)
544+
self._nodes[storage_key] = node
545+
self._order_index[storage_key] = len(self._order)
546+
self._order.append(storage_key)
547+
_hlc.update(node_hlc)
527548
return len(nodes)
528549

529550
def __repr__(self) -> str:

tests/test_enterprise_backup.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,34 @@ def test_version_mismatch():
149149
os.unlink(path)
150150

151151

152+
def test_restore_preserves_hlc_for_replication():
153+
"""HLC must survive snapshot round-trip so post-restore writes stay ordered."""
154+
from hive.rust_brain import TimestampRegression
155+
156+
brain = RustBrain()
157+
brain.remember("k", "v1", hlc=(5000, 10, "nodeA"))
158+
orig_hlc = brain.get("k").hlc
159+
160+
with tempfile.NamedTemporaryFile(delete=False, suffix=".gz") as f:
161+
path = f.name
162+
163+
try:
164+
brain.snapshot_to_file(path)
165+
brain2 = RustBrain()
166+
brain2.restore_from_file(path)
167+
assert brain2.get("k").hlc == orig_hlc
168+
169+
# Causal successor from the original writer should apply after restore.
170+
brain2.remember("k", "v2", hlc=(5000, 11, "nodeA"))
171+
assert brain2.recall("k") == "v2"
172+
173+
# Stale replay must still be rejected.
174+
with pytest.raises(TimestampRegression):
175+
brain2.remember("k", "stale", hlc=(5000, 5, "nodeA"))
176+
finally:
177+
os.unlink(path)
178+
179+
152180
def test_snapshot_with_tenant_isolation():
153181
brain = RustBrain(tenant_id="org_a", tenant_isolation=True)
154182
brain.remember("secret", "data")

tests/test_gossip.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,45 @@ def test_gossip_start_stop():
3232
gossip.start()
3333
gossip.stop()
3434
# No crash
35+
36+
37+
def test_gossip_rejects_stale_hlc_overwrite():
38+
brain = RustBrain()
39+
gossip = GossipProtocol(brain, peers=[])
40+
brain.remember("shared", "FRESH", hlc=(2000, 0, "local"))
41+
42+
applied = gossip.receive(
43+
[{"key": "shared", "value": "STALE", "hlc": [1000, 0, "remote"]}]
44+
)
45+
assert applied == 0
46+
assert brain.recall("shared") == "FRESH"
47+
48+
49+
def test_gossip_applies_newer_hlc():
50+
brain = RustBrain()
51+
gossip = GossipProtocol(brain, peers=[])
52+
brain.remember("shared", "OLD", hlc=(1000, 0, "local"))
53+
54+
applied = gossip.receive(
55+
[{"key": "shared", "value": "NEW", "hlc": [2000, 0, "remote"]}]
56+
)
57+
assert applied == 1
58+
assert brain.recall("shared") == "NEW"
59+
60+
61+
def test_gossip_skips_missing_hlc_on_existing_key():
62+
brain = RustBrain()
63+
gossip = GossipProtocol(brain, peers=[])
64+
brain.remember("k", "local")
65+
66+
applied = gossip.receive([{"key": "k", "value": "remote"}])
67+
assert applied == 0
68+
assert brain.recall("k") == "local"
69+
70+
71+
def test_gossip_allows_missing_hlc_for_new_key():
72+
brain = RustBrain()
73+
gossip = GossipProtocol(brain, peers=[])
74+
applied = gossip.receive([{"key": "new_key", "value": "remote"}])
75+
assert applied == 1
76+
assert brain.recall("new_key") == "remote"

0 commit comments

Comments
 (0)