Skip to content

fix(surrealdb): cascade synapses brain-agnostically on every neuron-delete path#89

Open
RobertSigmundsson wants to merge 3 commits into
acidkill:mainfrom
RobertSigmundsson:upstream-pr/delete-neuron-brain-agnostic-cascade
Open

fix(surrealdb): cascade synapses brain-agnostically on every neuron-delete path#89
RobertSigmundsson wants to merge 3 commits into
acidkill:mainfrom
RobertSigmundsson:upstream-pr/delete-neuron-brain-agnostic-cascade

Conversation

@RobertSigmundsson

Copy link
Copy Markdown
Contributor

Problem

delete_neuron deletes the neuron's synapses with a brain_id-filtered predicate:

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}")

Some write paths create synapses with a NULL brain_id. Those rows do not match the filter, so
they survive the neuron they point at and become dangling orphans. The schema's
cascade_delete_synapses event does not save us either — it does not fire on the SDK conn.delete()
call used for the neuron record itself.

Change

Drop the brain_id term from the cascade. Matching the neuron endpoint alone is the correct
predicate: a synapse whose in/out points at a deleted neuron is dangling regardless of its own
brain_id.

await self._query(f"DELETE synapse WHERE in  = neuron:{sid}")
await self._query(f"DELETE synapse WHERE out = neuron:{sid}")

Second delete path with the same leak

cleanup_ephemeral_neurons() — the session-end ephemeral sweep behind smem_auto — did not go
through delete_neuron() at all. It had its own raw conn.delete(f"neuron:{nid}"), so it skipped both
the synapse cascade and the neuron_state cleanup, orphaning rows on every run. Fixing
delete_neuron alone would have left this path leaking, so the third commit routes it through
delete_neuron() instead of duplicating the delete logic:

-                await self._conn.delete(f"neuron:{nid}")
-                count += 1
+                if await self.delete_neuron(str(r.get("id", ""))):
+                    count += 1

Why upstream benefits

Fixes a real orphan leak with no downside: the predicate is narrower in what it can wrongly keep and
identical in what it deletes for correctly-tagged rows. It also preserves the performance property that
made the two-statement form necessary in the first place — each single-field DELETE hits its own
idx_synapse_in / idx_synapse_out index (~5 ms total), whereas a combined OR across the two fields
falls back to a full scan in SurrealDB 3.2.0's planner (~1.2 s), which was the dominant cost behind
prune timeouts on large brains. That measurement comment is retained in the code.

Evidence

Check Result
cherry-pick onto origin/main clean
import surreal_memory OK
ruff check All checks passed!
ruff format --check 703 files already formatted
mypy src/ --ignore-missing-imports Success: no issues found in 369 source files
pytest -m "not stress" -n auto 6539 passed, 84 skipped, 1 xfailed (baseline 6535 → +4)

New files: tests/unit/test_delete_neuron_cascade.py (+81) and
tests/unit/test_cleanup_ephemeral_neurons_cascade.py (+75);
tests/unit/test_surrealdb_synapse_queries.py updated to the brain-agnostic query shape.

Risks / notes for the reviewer

  • Intentional widening. If a synapse in brain A legitimately points at a neuron in brain B, this now
    deletes it. That situation is already inconsistent data — a cross-brain edge — but a reviewer who knows
    of a valid use for it should say so.
  • Second commit only realigns an existing test's expected query string; no behaviour change.
  • cleanup_ephemeral_neurons now performs a full delete_neuron per row instead of a raw delete. That is
    strictly more work per ephemeral neuron — correct, but a reviewer running very large ephemeral sweeps
    may want to confirm the cost is acceptable.
  • Backend-agnostic: SQLite storage untouched.

Robert Sigmundsson added 3 commits July 26, 2026 01:40
…udit DB-01)

delete_neuron deleted connected synapses with a 'brain_id = <brain> AND in/out = neuron'
filter. Some write paths create synapses with a NULL brain_id, which that predicate
silently skipped — orphaning them when the neuron was pruned. The schema's
cascade_delete_synapses event does not catch these either: it does not fire on the
SDK conn.delete() call. Match the neuron endpoint alone (still two index-friendly
single-field DELETEs); a synapse pointing at a deleted neuron must go regardless of brain.

Root-caused from live orphan synapses (7, all brain_id=NULL) found by the
check-graph-integrity regression guard. Adds a mock-based unit test asserting the
synapse-delete predicate is brain-agnostic + the delete error path.

Tests: 2 new pass; 34 related unit tests pass; ruff clean.
(cherry picked from commit 144c39d)
… shape (audit DB-01)

8e53b1f made delete_neuron cascade brain-agnostically (DELETE synapse WHERE
in/out = neuron:<id>, no brain_id filter) so NULL-brain_id orphan synapses are
caught, but did not update the pre-existing upstream test
test_delete_neuron_cascade_uses_in_out, which still asserted the old
"brain_id = '<brain>' AND in = neuron:<id>" shape. That test was already red on
rebase/2.10.5 (prod); the rebase onto v2.12.1 inherited it faithfully.

Update the assertions to the endpoint-only shape and add positive
brain-agnostic checks (no brain_id in either DELETE) to lock in the audit DB-01
intent — otherwise a future change re-adding a brain_id filter would silently
reintroduce the NULL-brain_id orphan-synapse leak.

(cherry picked from commit 2a43020)
…dit DB-01 re-accrual)

The 07-19 DB-01 fix (144c39d) made delete_neuron() cascade-delete synapses
brain-agnostically, but cleanup_ephemeral_neurons() (smem_auto's session-end
ephemeral sweep) had its own raw conn.delete() with no cascade at all,
orphaning both synapses and neuron_state rows every time it ran. Route it
through delete_neuron() instead of duplicating the delete logic.

(cherry picked from commit dff01dd63af5ddac24e315c346516573b53553e2)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant