Skip to content

Commit e42c0e3

Browse files
cursor[bot]cursoragentDJLougen
authored
fix(rust_brain): keep order index aligned during eviction and forget (#7)
Evicting from the front of _order without decrementing _order_index left stale indices. forget() then tombstoned the wrong slot, and subsequent evictions could drop live keys while leaving ghosts — silent data loss. Add _evict_oldest/_remove_from_order helpers and regression tests. Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Daniel <DJLougen@users.noreply.github.com>
1 parent 7f6fb2a commit e42c0e3

2 files changed

Lines changed: 56 additions & 14 deletions

File tree

hive/rust_brain/__init__.py

Lines changed: 29 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,31 @@ def __init__(self, *, tenant_id: str = "default", tenant_isolation: bool = True,
143143
self._order: list[str] = []
144144
self._order_index: dict[str, int] = {}
145145

146+
def _remove_order_slot(self, idx: int) -> None:
147+
"""Remove ``_order[idx]`` and shift down indices above ``idx``."""
148+
del self._order[idx]
149+
for k, i in list(self._order_index.items()):
150+
if i > idx:
151+
self._order_index[k] = i - 1
152+
153+
def _remove_from_order(self, storage_key: str) -> None:
154+
"""Drop a key from the insertion-order list without leaving tombstones."""
155+
idx = self._order_index.pop(storage_key, None)
156+
if idx is not None:
157+
self._remove_order_slot(idx)
158+
159+
def _evict_oldest(self) -> None:
160+
"""Evict the oldest entry from the store and keep order indices aligned."""
161+
if not self._order:
162+
return
163+
oldest_key = self._order[0]
164+
if oldest_key:
165+
self._nodes.pop(oldest_key, None)
166+
self._order_index.pop(oldest_key, None)
167+
self._order.pop(0)
168+
for k in list(self._order_index):
169+
self._order_index[k] -= 1
170+
146171
def _prefix(self, key: str) -> str:
147172
"""Return the internal storage key with tenant prefix when isolation is on."""
148173
if not self._tenant_isolation or key.startswith(f"{self._tenant_id}:"):
@@ -192,11 +217,7 @@ def remember(
192217
self._order.append(storage_key)
193218
# Evict oldest entries if over capacity
194219
while len(self._nodes) > self._max_nodes:
195-
oldest_key = self._order[0]
196-
if oldest_key:
197-
self._nodes.pop(oldest_key, None)
198-
self._order_index.pop(oldest_key, None)
199-
self._order.pop(0)
220+
self._evict_oldest()
200221
return node
201222

202223
def supersede(self, key: str, new_value: Any, **kwargs: Any) -> MemoryNode:
@@ -219,9 +240,7 @@ def forget(self, key: str) -> None:
219240
key = self._prefix(key)
220241
with self._lock:
221242
self._nodes.pop(key, None)
222-
idx = self._order_index.pop(key, None)
223-
if idx is not None:
224-
self._order[idx] = ""
243+
self._remove_from_order(key)
225244

226245
# -- read path ----------------------------------------------------------
227246

@@ -331,9 +350,7 @@ def gc_expired(self) -> int:
331350
age_s = (_now_ns() - node.ts_ns) / 1e9
332351
if age_s > self._default_ttl_s:
333352
self._nodes.pop(storage_key, None)
334-
idx = self._order_index.pop(storage_key, None)
335-
if idx is not None:
336-
self._order[idx] = ""
353+
self._remove_from_order(storage_key)
337354
removed += 1
338355
return removed
339356

@@ -349,9 +366,7 @@ def revoke_tenant(self, tenant_id: str | None = None) -> int:
349366
to_remove = [k for k in self._nodes if k.startswith(prefix)]
350367
for k in to_remove:
351368
self._nodes.pop(k, None)
352-
idx = self._order_index.pop(k, None)
353-
if idx is not None:
354-
self._order[idx] = ""
369+
self._remove_from_order(k)
355370
return len(to_remove)
356371

357372
def snapshot_to_file(self, path: str) -> dict[str, Any]:

tests/test_security_fixes.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,33 @@ def test_rustbrain_evicts_oldest_when_over_capacity():
9292
assert brain.recall("d") == 4
9393

9494

95+
def test_rustbrain_evicts_oldest_after_forget_and_overflow():
96+
"""Regression: stale _order_index must not evict the wrong live key."""
97+
brain = RustBrain(max_nodes=2)
98+
brain.remember("a", 1)
99+
brain.remember("b", 2)
100+
brain.remember("c", 3) # evicts "a"; leaves stale order indices
101+
brain.forget("b")
102+
103+
brain.remember("d", 4)
104+
brain.remember("e", 5) # must evict "c" (oldest survivor), not "d"
105+
106+
assert len(brain) == 2
107+
assert brain.recall("c") is None
108+
assert brain.recall("d") == 4
109+
assert brain.recall("e") == 5
110+
111+
112+
def test_rustbrain_forget_keeps_order_index_aligned():
113+
brain = RustBrain(max_nodes=3)
114+
for label in ("a", "b", "c", "d"):
115+
brain.remember(label, label)
116+
brain.forget("b")
117+
118+
assert brain._order == ["default:c", "default:d"]
119+
assert brain._order_index == {"default:c": 0, "default:d": 1}
120+
121+
95122
def test_rustbrain_max_nodes_default_is_10000():
96123
brain = RustBrain()
97124
assert brain._max_nodes == 10_000

0 commit comments

Comments
 (0)