Skip to content

Commit ec03848

Browse files
ShreeBoharaclaude
andcommitted
Fix Neo4j delete: IN TRANSACTIONS is illegal via execute_query
The Cypher merged in the graph-store PR had never run against a server. It does not work. Neo.DatabaseError.Transaction.TransactionStartFailed A query with 'CALL { ... } IN TRANSACTIONS' can only be executed in an implicit transaction, but tried to execute in an explicit transaction. driver.execute_query() runs inside an explicit transaction and IN TRANSACTIONS is only legal in an implicit one, so delete_repository always raised. sync_repository calls it first, so the entire ingest path failed 100% of the time -- it only looked healthy because the caller swallows sync failures by design and falls back to SQL. delete_repository now loops bounded LIMIT batches instead. That keeps each transaction small without needing implicit-transaction semantics, and it also drops the CALL (n) { ... } scoped-variable form, which only exists from Neo4j 5.23. It returns the number of nodes deleted so a caller can tell the difference between "nothing there" and "did not run". WHY THE UNIT TESTS DID NOT CATCH THIS They assert `"DETACH DELETE" in query` against a fake driver. That still passes. A fake driver does not enforce transaction semantics, so no amount of mocking could have found it. Added tests/integration/test_neo4j_live.py: 9 tests against a real server, skipped unless NEO4J_TEST_URI and NEO4J_TEST_PASSWORD are set, so CI and a laptop without Docker stay green. They cover the regression directly plus degree, transitive traversal in both directions, shortest-path direction, cycle detection, re-sync replacing rather than unioning, cross-repository isolation, and a delete larger than one batch. Verified against neo4j:5-community (kernel 5.26.29): 9/9 live tests pass, and the full suite is 142 passed with the 9 live tests skipping cleanly when no server is configured. ruff clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent add5575 commit ec03848

3 files changed

Lines changed: 214 additions & 5 deletions

File tree

apps/api/src/core/graph/neo4j_store.py

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,9 @@
4242
# Batched so a large repository does not build one enormous transaction.
4343
_INGEST_BATCH = 500
4444

45+
# Nodes removed per delete transaction.
46+
_DELETE_BATCH = 1000
47+
4548
_MERGE_FILES = """
4649
UNWIND $rows AS row
4750
MERGE (f:File {repo_id: $repo_id, path: row.path})
@@ -109,9 +112,17 @@
109112
LIMIT $limit
110113
"""
111114

112-
_DELETE_REPO = """
115+
# Batched with LIMIT rather than "CALL { ... } IN TRANSACTIONS", which the server
116+
# rejects outright: driver.execute_query() runs inside an explicit transaction, and
117+
# IN TRANSACTIONS is only legal in an implicit one
118+
# (Neo.DatabaseError.Transaction.TransactionStartFailed). Looping bounded deletes keeps
119+
# each transaction small without needing implicit-transaction semantics, and avoids the
120+
# CALL (n) {...} scoped-variable syntax, which only exists from Neo4j 5.23.
121+
_DELETE_REPO_BATCH = """
113122
MATCH (n {repo_id: $repo_id})
114-
CALL (n) { DETACH DELETE n } IN TRANSACTIONS OF 1000 ROWS
123+
WITH n LIMIT $batch
124+
DETACH DELETE n
125+
RETURN count(n) AS deleted
115126
"""
116127

117128

@@ -182,9 +193,24 @@ async def sync_repository(
182193
)
183194
return {"files": len(files), "edges": len(edges)}
184195

185-
async def delete_repository(self, repo_id: str) -> None:
186-
"""Remove a repository's subgraph. Called on re-sync and on repo deletion."""
187-
await self._run(_DELETE_REPO, repo_id=repo_id)
196+
async def delete_repository(self, repo_id: str) -> int:
197+
"""
198+
Remove a repository's subgraph, in bounded batches.
199+
200+
Returns the number of nodes deleted. Loops because each call deletes at most
201+
_DELETE_BATCH nodes; a repository with more than that would otherwise be only
202+
partially removed, which on the re-sync path would silently leave stale edges.
203+
"""
204+
total = 0
205+
while True:
206+
rows = await self._run(
207+
_DELETE_REPO_BATCH, repo_id=repo_id, batch=_DELETE_BATCH
208+
)
209+
deleted = (rows[0].get("deleted") if rows else 0) or 0
210+
total += deleted
211+
if deleted < _DELETE_BATCH:
212+
break
213+
return total
188214

189215
# --- reads -------------------------------------------------------------------
190216

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
"""
2+
Neo4j store against a REAL server.
3+
4+
These exist because the fake-driver unit tests cannot catch a class of bug that matters.
5+
The original delete query used "CALL { ... } IN TRANSACTIONS", which the server rejects
6+
when run through driver.execute_query() -- that method uses an explicit transaction, and
7+
IN TRANSACTIONS is only legal in an implicit one. The unit test asserted
8+
`"DETACH DELETE" in query` and passed happily, so sync_repository was broken 100% of the
9+
time while the suite was green. A fake driver does not enforce transaction semantics, so
10+
no amount of mocking would have found it.
11+
12+
Skipped unless a Neo4j is reachable, so CI and a laptop without Docker stay green:
13+
14+
docker run -d -p 7688:7687 -e NEO4J_AUTH=neo4j/verifypassword neo4j:5-community
15+
NEO4J_TEST_URI=bolt://localhost:7688 NEO4J_TEST_PASSWORD=verifypassword pytest
16+
"""
17+
18+
import os
19+
20+
import pytest
21+
22+
from src.core.graph.neo4j_store import Neo4jGraphStore
23+
24+
URI = os.getenv("NEO4J_TEST_URI")
25+
USER = os.getenv("NEO4J_TEST_USER", "neo4j")
26+
PASSWORD = os.getenv("NEO4J_TEST_PASSWORD")
27+
28+
pytestmark = pytest.mark.skipif(
29+
not (URI and PASSWORD),
30+
reason="set NEO4J_TEST_URI and NEO4J_TEST_PASSWORD to run the live Neo4j tests",
31+
)
32+
33+
34+
def _files(*names):
35+
return [
36+
{"path": f"src/{n}.ts", "filename": f"{n}.ts", "extension": ".ts",
37+
"language": "typescript", "loc": 10, "module_key": "src"}
38+
for n in names
39+
]
40+
41+
42+
def _edge(a, b):
43+
return {"source": f"src/{a}.ts", "target": f"src/{b}.ts",
44+
"relation": "imports", "weight": 1, "confidence": 0.9}
45+
46+
47+
@pytest.fixture()
48+
async def store():
49+
from neo4j import AsyncGraphDatabase
50+
51+
driver = AsyncGraphDatabase.driver(URI, auth=(USER, PASSWORD))
52+
s = Neo4jGraphStore(driver)
53+
await s.ensure_schema()
54+
yield s
55+
for repo in ("live-A", "live-B", "live-C"):
56+
await s.delete_repository(repo)
57+
await driver.close()
58+
59+
60+
@pytest.mark.asyncio
61+
async def test_schema_is_idempotent_against_a_real_server(store):
62+
"""IF NOT EXISTS has to actually hold, not just appear in the string."""
63+
await store.ensure_schema()
64+
await store.ensure_schema()
65+
assert await store.verify() is True
66+
67+
68+
@pytest.mark.asyncio
69+
async def test_sync_and_delete_execute(store):
70+
"""
71+
The regression. Both of these ran valid-looking Cypher that the server refused.
72+
sync_repository calls delete_repository first, so the delete bug broke all ingest.
73+
"""
74+
result = await store.sync_repository(
75+
"live-A", _files("app", "alpha", "beta"), [_edge("app", "alpha"), _edge("alpha", "beta")]
76+
)
77+
assert result == {"files": 3, "edges": 2}
78+
79+
deleted = await store.delete_repository("live-A")
80+
assert deleted == 3
81+
assert await store.nodes_with_degree("live-A") == []
82+
83+
84+
@pytest.mark.asyncio
85+
async def test_degree_comes_back_correct(store):
86+
await store.sync_repository(
87+
"live-A", _files("app", "alpha", "beta"), [_edge("app", "alpha"), _edge("alpha", "beta")]
88+
)
89+
by = {n["path"]: n for n in await store.nodes_with_degree("live-A")}
90+
assert by["src/app.ts"]["out_degree"] == 1
91+
assert by["src/app.ts"]["in_degree"] == 0
92+
assert by["src/alpha.ts"]["in_degree"] == 1
93+
assert by["src/beta.ts"]["in_degree"] == 1
94+
assert by["src/beta.ts"]["out_degree"] == 0
95+
96+
97+
@pytest.mark.asyncio
98+
async def test_traversal_is_transitive(store):
99+
"""The capability the SQL path cannot provide at all."""
100+
await store.sync_repository(
101+
"live-A", _files("app", "alpha", "beta"), [_edge("app", "alpha"), _edge("alpha", "beta")]
102+
)
103+
reach = {r["path"] for r in await store.reachable_from("live-A", "src/app.ts", hops=3)}
104+
assert reach == {"src/alpha.ts", "src/beta.ts"}, "two hops must be reachable"
105+
106+
blast = {r["path"] for r in await store.blast_radius("live-A", "src/beta.ts", hops=3)}
107+
assert blast == {"src/alpha.ts", "src/app.ts"}, "blast radius is the reverse direction"
108+
109+
110+
@pytest.mark.asyncio
111+
async def test_shortest_path_and_direction(store):
112+
await store.sync_repository(
113+
"live-A", _files("app", "alpha", "beta"), [_edge("app", "alpha"), _edge("alpha", "beta")]
114+
)
115+
hop = await store.shortest_path("live-A", "src/app.ts", "src/beta.ts")
116+
assert hop["distance"] == 2
117+
assert hop["path_nodes"] == ["src/app.ts", "src/alpha.ts", "src/beta.ts"]
118+
119+
# IMPORTS is directed, so the reverse must not resolve.
120+
assert await store.shortest_path("live-A", "src/beta.ts", "src/app.ts") is None
121+
122+
123+
@pytest.mark.asyncio
124+
async def test_import_cycles_are_detected(store):
125+
await store.sync_repository(
126+
"live-A", _files("cycle_a", "cycle_b"),
127+
[_edge("cycle_a", "cycle_b"), _edge("cycle_b", "cycle_a")],
128+
)
129+
cycles = await store.import_cycles("live-A")
130+
assert cycles, "a two-file mutual import is a cycle"
131+
132+
133+
@pytest.mark.asyncio
134+
async def test_resync_replaces_rather_than_unions(store):
135+
"""
136+
A MERGE-only sync would leave the dropped edge behind and drift into a union of every
137+
commit ever indexed.
138+
"""
139+
await store.sync_repository(
140+
"live-A", _files("app", "alpha", "beta"), [_edge("app", "alpha"), _edge("alpha", "beta")]
141+
)
142+
await store.sync_repository("live-A", _files("app", "alpha", "beta"), [_edge("app", "alpha")])
143+
assert len(await store.edges("live-A")) == 1
144+
145+
146+
@pytest.mark.asyncio
147+
async def test_repositories_are_isolated(store):
148+
"""Deleting one repo must not touch another's subgraph."""
149+
await store.sync_repository("live-B", _files("app", "alpha"), [_edge("app", "alpha")])
150+
await store.sync_repository("live-C", _files("app", "alpha"), [_edge("app", "alpha")])
151+
152+
await store.delete_repository("live-B")
153+
154+
assert await store.nodes_with_degree("live-B") == []
155+
assert len(await store.nodes_with_degree("live-C")) == 2
156+
157+
158+
@pytest.mark.asyncio
159+
async def test_delete_handles_more_nodes_than_one_batch(store):
160+
"""
161+
delete_repository loops in bounded batches. With a single unbatched delete this would
162+
pass trivially; the loop is what makes a repository larger than _DELETE_BATCH fully
163+
removed rather than partially.
164+
"""
165+
from src.core.graph.neo4j_store import _DELETE_BATCH
166+
167+
names = [f"f{i}" for i in range(_DELETE_BATCH + 25)]
168+
await store.sync_repository("live-A", _files(*names), [])
169+
170+
deleted = await store.delete_repository("live-A")
171+
assert deleted == len(names)
172+
assert await store.nodes_with_degree("live-A") == []

docker/README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,17 @@ NEO4J_PASSWORD=<something>
6565

6666
Re-index a repository to populate it, then browse at http://localhost:7474.
6767

68+
The Cypher in this store is exercised against a real server by
69+
apps/api/tests/integration/test_neo4j_live.py, which skips unless you point it at one:
70+
71+
```bash
72+
docker run -d -p 7688:7687 -e NEO4J_AUTH=neo4j/verifypassword neo4j:5-community
73+
NEO4J_TEST_URI=bolt://localhost:7688 NEO4J_TEST_PASSWORD=verifypassword pytest tests
74+
```
75+
76+
Worth running after any change to neo4j_store.py: the fake-driver unit tests cannot catch
77+
Cypher the server rejects, which is how a broken delete query once shipped green.
78+
6879
To confirm the projection matches SQL:
6980

7081
```cypher

0 commit comments

Comments
 (0)