You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Neo4j: never delete by default; gate purge on --eager and scope it to this analyzer #213
BoltWriter.java:135-136 purges a changed module with two completely unlabelled statements:
tx.run("MATCH (x {_module: $m})-[r]->() DELETE r", Values.parameters("m", unit));
tx.run("MATCH (x {_module: $m}) WHERE NOT coalesce(x.id, x.file_key) IN $keys DETACH DELETE x", ...);
MATCH (x {_module: $m}) matches any node in the database carrying that _module value — not
just java's. codeanalyzer-typescript and codeanalyzer-python both set _module on their nodes
(_module is the file key in all three analyzers), so where a java module and a sibling analyzer's
module share a file key, a java push deletes that sibling's nodes and their relationships.
This repo already knows the hazard. CypherWriter.java:40-52 documents it at length for the
snapshot path:
"That other analyzer's own edges may be attached to the very node this wipe would DETACH DELETE, destroying data this tool cannot see and did not write — corruption neither detectable
nor repairable from here. (Tried once, reverted: see git history — widening this to reach :Artifact/:ConfigKey was implemented and shipped before this exact hazard was caught in
review.)"
That reasoning was applied to CypherWriter and not to BoltWriter.
Found while fixing the same class of bug in codeanalyzer-typescript
(codellm-devkit/codeanalyzer-typescript#116), where the equivalent statement deleted python and
java graphs outright. There it surfaced only as an out-of-memory error, because the delete was
large enough to exhaust dbms.memory.transaction.total.max and roll back. A smaller foreign graph
would have been deleted silently.
Scope boundary
The unlabelled matches in BoltWriter. Not in scope: CypherWriter, which already reasons about
this correctly.
Goals
Anchor both statements on java-owned labels so a sibling analyzer's nodes cannot match
A test asserting a foreign node with a colliding _module survives a push
Check BoltWriter.java:164's prune for the same exposure
Caveats and known risks
Anchoring needs a label that reliably means "java wrote this". If there is no single marker, the
predicate has to enumerate the J* labels — verbose but correct, and correctness wins here.
Silent data loss: there is no error and no log line when this fires, so any occurrence to date
would have gone unnoticed. Worth considering whether shipped graphs need auditing.
Definition of done
A java push into a database holding a typescript or python graph with an overlapping _module
value leaves that graph byte-identical.
codeanalyzer-typescript has taken a broader fix than "anchor the purge", and the same shape applies
here. Rather than making the deletes safer, it stopped doing them by default:
Default push: MERGE current nodes over whatever is in the graph, and delete nothing. The
per-module purge and the orphan prune do not run. A schema-version mismatch forces a full
re-UPSERT rather than a wipe.
--eager: purge this application's own nodes, then rebuild from scratch.
The reasoning is that database lifetime is the operator's call, not the analyzer's. An analyzer
that only ever adds and updates cannot corrupt anything it did not write, whatever else shares the
database. That property is worth more than the tidiness of automatically removing declarations that
vanished from a source file.
--eager already means "ignore the cache and rebuild everything" in all three analyzers
(codeanalyzer/__main__.py:177 here, CodeAnalyzer.java:149 in java), so extending it to mean
"rebuild the graph too" is consistent rather than a new concept.
The purge still needs anchoring
Gating on --eager shrinks the exposure; it does not remove it. An --eager run must still not
delete a sibling analyzer's nodes. TypeScript's purge is scoped twice:
MATCH (n:CanNode) WHEREn.idSTARTSWITH $prefixCALL{WITHnDETACHDELETEn}INTRANSACTIONSOF5000ROWS
a label only that analyzer applies → a sibling's graph cannot match
id STARTS WITH <application id> → a second application in the same database survives
batched → deleting a whole application in one transaction is what exhausted dbms.memory.transaction.total.max in the original report
Both scopes matter independently, and the batching is not optional at real sizes.
Consequence to weigh
Without a default purge, declarations removed from a source file — a deleted function, a renamed
class — linger in the graph until someone runs --eager. That is the accepted tradeoff: a stale
node is visible and recoverable, whereas another tool's silently deleted edges are neither.
codeanalyzer-java already reaches this exact conclusion for its snapshot path in CypherWriter.java:40-52.
Verified
TypeScript's version was tested against a live Neo4j: a python graph planted alongside a 1,841-module
TypeScript graph survived both a default push and an --eager push, where the previous code would
have deleted it in either case.
changed the title [-]BoltWriter's per-module purge is unlabelled — it deletes sibling analyzers' nodes[/-][+]Neo4j: never delete by default; gate purge on --eager and scope it to this analyzer[/+]on Sep 2, 2026
Activity
rahlk commentedon Sep 2, 2026
Widening this: deletion should be
--eager-onlycodeanalyzer-typescript has taken a broader fix than "anchor the purge", and the same shape applies
here. Rather than making the deletes safer, it stopped doing them by default:
per-module purge and the orphan prune do not run. A schema-version mismatch forces a full
re-UPSERT rather than a wipe.
--eager: purge this application's own nodes, then rebuild from scratch.The reasoning is that database lifetime is the operator's call, not the analyzer's. An analyzer
that only ever adds and updates cannot corrupt anything it did not write, whatever else shares the
database. That property is worth more than the tidiness of automatically removing declarations that
vanished from a source file.
--eageralready means "ignore the cache and rebuild everything" in all three analyzers(
codeanalyzer/__main__.py:177here,CodeAnalyzer.java:149in java), so extending it to mean"rebuild the graph too" is consistent rather than a new concept.
The purge still needs anchoring
Gating on
--eagershrinks the exposure; it does not remove it. An--eagerrun must still notdelete a sibling analyzer's nodes. TypeScript's purge is scoped twice:
id STARTS WITH <application id>→ a second application in the same database survivesdbms.memory.transaction.total.maxin the original reportBoth scopes matter independently, and the batching is not optional at real sizes.
Consequence to weigh
Without a default purge, declarations removed from a source file — a deleted function, a renamed
class — linger in the graph until someone runs
--eager. That is the accepted tradeoff: a stalenode is visible and recoverable, whereas another tool's silently deleted edges are neither.
codeanalyzer-java already reaches this exact conclusion for its snapshot path in
CypherWriter.java:40-52.Verified
TypeScript's version was tested against a live Neo4j: a python graph planted alongside a 1,841-module
TypeScript graph survived both a default push and an
--eagerpush, where the previous code wouldhave deleted it in either case.
[-]BoltWriter's per-module purge is unlabelled — it deletes sibling analyzers' nodes[/-][+]Neo4j: never delete by default; gate purge on --eager and scope it to this analyzer[/+]refactor(neo4j): drop the v1 purge path rather than carry two scoping…
fix(neo4j)!: scope the v2 purge on the can:// id prefix; retire _modu…