Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 20 additions & 20 deletions src/surreal_memory/storage/surrealdb/store.py
Original file line number Diff line number Diff line change
Expand Up @@ -883,22 +883,21 @@ async def update_neuron_embeddings(self, pairs: list[tuple[str, list[float]]]) -

async def delete_neuron(self, neuron_id: str) -> bool:
conn = self._ensure_conn()
brain_id = self._get_brain_id()
safe_brain = _safe_brain_id(brain_id)
sid = _to_surreal_id(neuron_id)

# Delete connected synapses first (belt-and-braces: SurrealDB also auto-cleans
# edges when the neuron record is deleted). Endpoints are native in/out now.
#
# Measured live: a single "brain_id = $brain_id AND (in = ... OR out = ...)"
# query cost ~1.2s/call regardless of whether brain_id/the record id are
# inlined or param-bound — SurrealDB 3.2.0's planner doesn't use either
# `idx_synapse_in`/`idx_synapse_out` index across an OR of two different
# fields, so it falls back to a full scan. Splitting into two single-field
# DELETEs (each hits its own index) measured ~5ms total for both — this was
# the dominant cost behind prune's non-dry-run timeout on a large brain.
await self._query(f"DELETE synapse WHERE brain_id = '{safe_brain}' AND in = neuron:{sid}")
await self._query(f"DELETE synapse WHERE brain_id = '{safe_brain}' AND out = neuron:{sid}")
# Delete connected synapses first. This is brain-agnostic ON PURPOSE (audit
# DB-01): a synapse whose in/out points at this neuron becomes a dangling
# orphan once the neuron is gone, regardless of the synapse's own brain_id.
# Some write paths create synapses with a NULL brain_id, which the previous
# `brain_id = '<brain>' AND ...` filter silently skipped — leaving orphans that
# the schema's cascade_delete_synapses event did NOT catch either (that event
# does not fire on the SDK conn.delete() call below). Matching the neuron
# endpoint alone is correct AND index-friendly: two single-field DELETEs each
# hit their own `idx_synapse_in`/`idx_synapse_out` index (~5ms total); a
# combined OR across the two fields falls back to a full scan in SurrealDB
# 3.2.0's planner (~1.2s), which was the dominant cost behind prune timeouts.
await self._query(f"DELETE synapse WHERE in = neuron:{sid}")
await self._query(f"DELETE synapse WHERE out = neuron:{sid}")
# Delete state (record id is state_<sid>, matching the writer in add_neuron)
await self._query(f"DELETE neuron_state:state_{sid}")

Expand Down Expand Up @@ -2595,13 +2594,14 @@ async def cleanup_ephemeral_neurons(self, max_age_hours: float = 24.0) -> int:
)
count = 0
for r in rows:
# Re-sanitise the id read back from the DB rather than trusting the
# write-path invariant (defence in depth): _to_surreal_id strips the
# table prefix and folds, so ``neuron:{nid}`` stays a safe literal.
nid = _to_surreal_id(str(r.get("id", "")))
# Route through delete_neuron() (audit DB-01) instead of a raw
# conn.delete(): that raw call skipped the synapse cascade AND the
# neuron_state cleanup, leaving both as orphans once the ephemeral
# neuron aged out — the same bug already fixed for delete_neuron's
# other callers, just missed here.
try:
await self._conn.delete(f"neuron:{nid}")
count += 1
if await self.delete_neuron(str(r.get("id", ""))):
count += 1
except Exception:
pass
return count
Expand Down
75 changes: 75 additions & 0 deletions tests/unit/test_cleanup_ephemeral_neurons_cascade.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
"""Regression guard for audit finding DB-01 (re-accrual, 2026-07-26): the
2026-07-19 fix (144c39d) made delete_neuron() cascade synapse deletes
brain-agnostically, but cleanup_ephemeral_neurons() used its own raw
conn.delete() with no cascade at all — orphaning both synapses and
neuron_state rows every time smem_auto's session-end ephemeral sweep ran.
This test asserts cleanup_ephemeral_neurons() now routes through
delete_neuron() so the cascade applies here too.
"""

from __future__ import annotations

from typing import Any

from surreal_memory.storage.surrealdb.store import SurrealDBStorage


class _FakeConn:
"""Captures SDK conn.delete() record ids."""

def __init__(self) -> None:
self.deletes: list[str] = []

async def delete(self, record_id: str) -> None:
self.deletes.append(record_id)


class _FakeStore:
"""Fake `self` exposing only what cleanup_ephemeral_neurons + delete_neuron touch."""

def __init__(self, ephemeral_rows: list[dict[str, Any]]) -> None:
self._conn = _FakeConn()
self.queries: list[str] = []
self._ephemeral_rows = ephemeral_rows

def _ensure_conn(self) -> Any:
return self._conn

def _get_brain_id(self) -> str:
return "uruboros"

async def _query(self, sql: str, **params: Any) -> list[dict[str, Any]]:
self.queries.append(sql)
if "FROM neuron WHERE" in sql and "ephemeral" in sql:
return self._ephemeral_rows
return []

async def _record_change_internal(self, *args: Any, **kwargs: Any) -> None:
return None

async def delete_neuron(self, neuron_id: str) -> bool:
return await SurrealDBStorage.delete_neuron(self, neuron_id)


async def test_cleanup_ephemeral_neurons_cascades_synapses_and_state() -> None:
store = _FakeStore(ephemeral_rows=[{"id": "neuron:00012d0f_c36e_4cd5_b2b4_7538fa683a17"}])

deleted = await SurrealDBStorage.cleanup_ephemeral_neurons(store, max_age_hours=24.0)

assert deleted == 1
synapse_deletes = [q for q in store.queries if "DELETE synapse" in q]
state_deletes = [q for q in store.queries if "DELETE neuron_state" in q]

assert any("in = neuron:" in q for q in synapse_deletes)
assert any("out = neuron:" in q for q in synapse_deletes)
assert len(state_deletes) == 1
assert store._conn.deletes == ["neuron:00012d0f_c36e_4cd5_b2b4_7538fa683a17"]


async def test_cleanup_ephemeral_neurons_skips_when_none_expired() -> None:
store = _FakeStore(ephemeral_rows=[])

deleted = await SurrealDBStorage.cleanup_ephemeral_neurons(store, max_age_hours=24.0)

assert deleted == 0
assert store._conn.deletes == []
81 changes: 81 additions & 0 deletions tests/unit/test_delete_neuron_cascade.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
"""Regression guard for audit finding DB-01: delete_neuron must remove ALL synapses
whose in/out points at the deleted neuron, regardless of the synapse's own brain_id.

Some write paths create synapses with a NULL brain_id. The previous implementation
deleted synapses with a `brain_id = '<brain>' AND in/out = neuron` filter, which
silently skipped NULL-brain synapses — orphaning them when the neuron was pruned
(the schema's cascade_delete_synapses event does not fire on the SDK conn.delete()).
This test asserts the synapse-delete predicate is brain-agnostic (endpoint only).
"""

from __future__ import annotations

from typing import Any

from surreal_memory.storage.surrealdb.store import SurrealDBStorage


class _FakeConn:
"""Captures SDK conn.delete() record ids."""

def __init__(self) -> None:
self.deletes: list[str] = []

async def delete(self, record_id: str) -> None:
self.deletes.append(record_id)


class _FakeStore:
"""Fake `self` exposing only what delete_neuron touches (no live DB, no full init)."""

def __init__(self) -> None:
self._conn = _FakeConn()
self.queries: list[str] = []

def _ensure_conn(self) -> Any:
return self._conn

def _get_brain_id(self) -> str:
return "uruboros"

async def _query(self, sql: str, **params: Any) -> list[dict[str, Any]]:
self.queries.append(sql)
return []

async def _record_change_internal(self, *args: Any, **kwargs: Any) -> None:
return None


async def test_delete_neuron_deletes_synapses_brain_agnostically() -> None:
store = _FakeStore()

ok = await SurrealDBStorage.delete_neuron(store, "00012d0f-c36e-4cd5-b2b4-7538fa683a17")

assert ok is True
synapse_deletes = [q for q in store.queries if "DELETE synapse" in q]

# Both endpoints are swept (in AND out), each as its own index-friendly single-field DELETE.
assert any("in = neuron:" in q for q in synapse_deletes)
assert any("out = neuron:" in q for q in synapse_deletes)

# DB-01 regression guard: NO brain_id predicate — that filter skipped NULL-brain
# synapses and left dangling orphans.
assert all("brain_id" not in q for q in synapse_deletes), synapse_deletes

# The neuron record itself is deleted via the SDK (underscore-normalised id).
sid = "00012d0f_c36e_4cd5_b2b4_7538fa683a17"
assert f"neuron:{sid}" in store._conn.deletes


async def test_delete_neuron_returns_false_when_delete_raises() -> None:
"""Error path: a failing conn.delete() is swallowed and reported as False."""

class _BoomConn(_FakeConn):
async def delete(self, record_id: str) -> None:
raise RuntimeError("delete boom")

store = _FakeStore()
store._conn = _BoomConn()

ok = await SurrealDBStorage.delete_neuron(store, "abc-def")
assert ok is False
17 changes: 14 additions & 3 deletions tests/unit/test_surrealdb_synapse_queries.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,19 +150,30 @@ async def test_get_neighbors_inlines_endpoints_to_kill_n_plus_1(self):

@pytest.mark.asyncio
async def test_delete_neuron_cascade_uses_in_out(self):
"""Two single-field DELETEs, not one OR query.
"""Two single-field DELETEs, not one OR query — and brain-agnostic.

A single "brain_id = ... AND (in = X OR out = X)" query measured
~1.2s/call live on SurrealDB 3.2.0 — the planner doesn't use either
idx_synapse_in/idx_synapse_out across an OR of two different fields.
Splitting into two single-field DELETEs (each hits its own index)
measured ~5ms total.

The DELETEs match the neuron endpoint ALONE, with no ``brain_id``
filter (audit DB-01): a synapse pointing at the deleted neuron becomes
a dangling orphan regardless of its own brain_id, and some write paths
create synapses with a NULL brain_id that a ``brain_id = '<brain>' AND``
filter silently skipped. Matching the endpoint alone is both correct and
index-friendly.
"""
st, conn = _store_with_mock_conn()
await st.delete_neuron("a")
in_query = _find_query(conn, "AND in = neuron:")
out_query = _find_query(conn, "AND out = neuron:")
in_query = _find_query(conn, "WHERE in = neuron:")
out_query = _find_query(conn, "WHERE out = neuron:")
assert in_query is not None
assert out_query is not None
assert " OR " not in in_query[0]
assert " OR " not in out_query[0]
# brain-agnostic on purpose (audit DB-01): no brain_id filter, or the
# NULL-brain_id orphan synapses this fix targets would be skipped again.
assert "brain_id" not in in_query[0]
assert "brain_id" not in out_query[0]
Loading