fix(neo4j): scope get_node's fallback MATCH to :Node so it seeks instead of scanning - #98
Merged
Diego Colombo (colombod) merged 3 commits intoSep 9, 2026
Conversation
…ead of scanning label-free MATCH planned as AllNodesScan over 12.09M nodes on the hot touch_session -> get_node path, ~60s per call holding a pooled bolt connection, saturating the shared 50-connection pool and dead-lettering live events; now composed from the shared _NODE_MATCH_BY_ID prefix so it plans as NodeUniqueIndexSeek. Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
Extends the fix from get_node() label-free MATCH to other instances of the same anti-pattern across the codebase: - get_edge(): anchored MATCH ()-[r]->() on both :Node endpoints so the planner seeks the (node_id, workspace) unique index rather than scanning all relationships; property predicates retained. - find_delegation_by_sub_session(): added composite index idx_delegation_sub_session on :Delegation(sub_session_id, workspace) to seek rather than scanning all :Delegation nodes on the live ingest path. - _ensure_schema(): moved latch from per-store to process-wide, seeded by FastAPI lifespan to prevent every new session from running the full 12-statement catalog pass on first flush, and added retry backoff so failed passes do not starve the bolt connection pool. - Added source-level tripwire test: fails on any new label-free MATCH (n) or untyped MATCH ()-[r]-> in neo4j_store.py outside a documented allow-list, preventing silent regression. Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
This release ships production bug fixes (Neo4j query-plan fixes that resolved ingest stalls in #91, #94, #95, #96). The version bump restores per-build identifiability: four prior fix PRs merged without bumping, causing 6.7.1 to identify five distinct builds. /version and /status.server_version now correctly distinguish this deploy. Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
Diego Colombo (colombod)
deleted the
fix/get-node-label-scoped-index-seek
branch
September 9, 2026 18:13
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem —
get_node()full-scans the graph, exhausting the shared bolt poolget_node()'s Neo4j fallback issued a label-free MATCH:Neo4j property indexes are label-scoped, so a label-free MATCH cannot use any
index. On the production graph (12,091,131 nodes) the planner emits an
AllNodesScan:This is the hot per-event read (
touch_session→get_node→ this query), not arare path. Captured live via
SHOW TRANSACTIONSon the production instance:51 concurrent transactions, all this query, 48–63 s each. Each holds its
pooled bolt connection for the whole scan. Against the shared 50-connection pool
(
neo4j_max_connection_pool_size, default 50) that is total saturation, so everyfurther acquire died on the 30 s
connection_acquisition_timeout:Batches then burned
max_delivery_attemptsand landed in_handle_exhausted_batch, which dead-letters each line and commits the queueoffset past it — permanent loss of live activity records. Sampled rate: ~88
dead_letterlines in a 2-second window.Host impact: the Neo4j VM (
Standard_E8s_v5, 8 vCPU) sat at 99% CPU for ~41hours (onset 2026-09-07 21:00 UTC), load average 49, while committing only
~1.2 txn/s — the signature of enormous read work producing almost no throughput.
GC was not implicated (
VmPauseMonitorreported 205–305 ms pauses withgcTime=0, gcCount=0); checkpoints completed normally in ~0.6–1.1 s.This is the same bug class as #19, in the one place that was missed
neo4j_store.pyalready fixed this class everywhere else via the universal:Nodelabel:_NODE_MERGE_CYPHER(writes),_edge_merge_cypher(edgeendpoints), and
_NODE_MATCH_BY_ID(label SET / label-patch add/remove) allscope to
:Nodeand are guarded by plan assertions intests/neo4j/test_node_index_seek.py.get_node()hand-rolled its own querystring instead of reusing the shared prefix, so it inherited none of that —
and none of the existing guards covered it.
Every index is present and healthy (
SHOW INDEXES: allONLINE,populationPercent: 100.0), includingnode_node_id_workspace_uniqueon:Node(node_id, workspace), which covers this lookup exactly. The index wasnever missing — the query just never named a label the planner could use it for.
Fix
_NODE_GET_BY_ID_CYPHER, composed from the existing_NODE_MATCH_BY_IDprefix so it cannot drift back to a label-free MATCH — one logic home, the
same pattern the other three call sites already use.
get_node()uses it, binding$node_id(was$id) to match the shared prefix.Query-plan effect, verified against a live Neo4j 5.26:
AllNodesScanNodeUniqueIndexSeekUNIQUE n:Node(node_id, workspace)No schema change, no migration, no config change — every node already carries
:Nodeand the backing constraint already exists.Regression guards
Two, deliberately at different levels:
tests/test_neo4j_store.py::test_get_node_fallback_is_label_scoped_for_index_seek(unit) — asserts the issued query scopes to
:Node. The live plan tests aremarked
neo4jand are deselected from the default suite (90 deselected),so this is the guard that actually runs in ordinary CI.
tests/neo4j/test_node_index_seek.py::test_get_node_fallback_uses_index_seek_not_allnodesscan(live) —
EXPLAINs the real production query string and asserts noAllNodesScanand a presentIndexSeek, matching the three existing planguards in that file. Imported via its own
try/except(not folded into theexisting import block, which would make
_NODE_MATCH_BY_IDfall back andbreak the sibling tests for the wrong reason); the fallback is byte-identical
to the unfixed query so a revert still fails RED for the right reason.
test_get_node_fallback_queries_by_node_id_propertywas updated, not weakened:its assertion
"n.node_id" in querywas a textual proxy for "keys on thenode_idproperty", which the map-pattern syntax breaks while preserving theintent. It now asserts the intent robustly (
node_idpresent,n.idabsent) andadditionally checks the
$node_idparameter binding.Both guards verified to fail without the fix (source reverted to HEAD, tests
kept):
Test results
pytest -m "not neo4j"— 1971 passed, 7 skipped, 90 deselectedpytest tests/neo4j/test_node_index_seek.py -m neo4j(live container) — 6 passedruff check/ruff format --checkon changed files — no new findings(pre-existing counts in
tests/test_neo4j_store.pyunchanged: 20 before, 20 after)Deployment note
The currently-deployed revision is
context-intelligence-api--0000015, image tag80e289f1edc3833fddd09858252876b53bae23da(=mainHEAD, built 2026-09-01).PR #91 (shared bounded driver) is deployed and is not at fault — it converted
an unbounded driver leak into a bounded pool. This query was pathological either
way; #91 only changed which error the exhaustion surfaces as.
Follow-up (deliberately NOT in this PR)
Neo4jGraphStore._ensure_schema()runs on every flush until_schema_initializedlatches, and it latches only when every one of ~12catalog statements succeeds. Under pool starvation none of them can acquire a
connection, so the flag never latches and every flush on every session re-attempts
the full schema pass — adding pool pressure to an already-starved pool
(
ensure_neo4j_schema: could not create index (connectivity error)appeared 3×in a 2-second sample). It is self-healing by design and goes quiet once this fix
removes the starvation, but the retry has no backoff, which turns a connectivity
blip into a per-flush storm. Worth a separate change; out of scope for a hotfix.
Additional changes (second commit) — containing the bug class, not the instance
Review of the original fix asked two follow-up questions: does anything else in
this module scan, and what made one slow query take the whole service down. Both
had concrete answers, so they are addressed here rather than deferred.
Live measurement taken during review:
GET /versionon the deployedinstance — a route that touches no Neo4j at all — took 114.8 s (HTTP
200).
/statusand/cypherdid not return inside 60 s; unauthenticated calls401 instantly, so this is the backend, not the gateway. Ingest drain workers and
the HTTP API share one process and one event loop, so a saturated ingest path
starves every route, including ones with no graph dependency. That is why a
single unindexed read became a total API outage.
1. The remaining unindexed reads
get_node()was not the only read that could not use an index.get_edge()MATCH ()-[r]->() WHERE r.src_id = ...— relationship property indexes are type-scoped, and this names no type, so no index could back it: AllRelationshipsScan (a larger set than the AllNodesScan just fixed):Nodeidentity patterns → the planner seeks the(node_id, workspace)unique index and expands only that node's own relationshipsfind_delegation_by_sub_session()Delegation.sub_session_id, so it planned as a NodeByLabelScan across every:Delegationnodeidx_delegation_sub_sessionon:Delegation(sub_session_id, workspace)→NodeIndexSeekget_edge's relationship-property predicates are retained after the anchor.They now filter a handful of relationships incident to one node instead of every
relationship in the graph, so they cost nothing — and keeping them makes this a
strictly narrowing change. Any edge the old form returned and the new one does
not is one whose endpoints do not exist as
:Node, which the edge writer(
_edge_merge_cypher, which MERGEs both:Nodeendpoints) structurally cannotproduce.
find_delegation_by_sub_sessionis a live ingest path — the self-delegationresolver reaches it whenever the parent Delegation has already flushed out of the
in-memory buffer — and its cost grew with delegation volume.
get_edgehas noin-repo caller today, so it was latent rather than live; it is fixed anyway
because it is on the public
GraphStoreprotocol and is the worst-planning queryin the module.
2.
_ensure_schemaon the flush path — the amplifierThis is what kept the incident burning and prevented self-recovery.
_flush_bodyawaits_ensure_schema()before Phase 1, i.e. before any datais written. Its latch,
_schema_initialized, is per store instance — and astore is constructed per session (
registry.get_or_create). So:on its first flush. Continuous background DDL, proportional to session churn.
whole pass, each statement waiting out the full connection-acquisition timeout
against the very pool it was starving — with no backoff, retrying on the
immediately following flush.
In the server this work is pure redundancy:
main.pyalready runsensure_neo4j_schema(..., fail_on_data_conflict=True)during lifespan, beforeaccepting a single request, and refuses to boot otherwise. A per-flush pass can
only re-confirm what cold start already established.
Two changes:
_SCHEMA_READYis process-wide, seeded by the lifespan. In the server theper-flush call becomes a single boolean read. Contexts with no lifespan (tests,
CLI tools, direct store use) never seed it and keep today's behaviour — the
first store to fully establish the schema latches it for the process.
_SCHEMA_RETRY_BACKOFF_SECONDSputs a floor between un-latched attempts,so a connectivity blip can no longer become a per-flush storm. The self-heal is
delayed, never disabled.
The lifespan seeds the latch only on a fully-established pass.
fail_on_data_conflict=Truefails closed on a:Nodeconstraint data conflict,but a connectivity failure on any individual index/constraint is deliberately
swallowed and reported through the return value. Latching unconditionally would
mark a half-built schema ready and permanently disable the per-flush self-heal —
reopening the "constraint created once, never retried" gap. The seed is therefore
gated on the return value, with a WARNING on the unseeded path
(
TestLifespanSchemaLatchSeedingguards both branches).3. Source-level tripwire
get_node()broke because it hand-rolled a query string while three sibling callsites composed theirs from the shared
_NODE_MATCH_BY_IDprefix — so it inheritednone of their index-scoped-ness, and no existing guard covered it. Guarding one
call site per outage does not scale.
test_no_unindexed_scan_patterns_in_neo4j_store_sourcescans the module forlabel-free
MATCH (n)and untypedMATCH ()-[r]->patterns and fails on anythingoutside a documented allow-list. The allow-list is the complete reviewed inventory
of deliberately label-free statements — each either answered from Neo4j's O(1)
counts store or an explicitly O(graph-size)
doctor --fixrepair path, never onthe hot ingest path. Adding an entry is the review checkpoint.
This covers queries nobody has written yet, which is the part the per-call-site
guards cannot do.
Deployment safety
change, no data rewrite.
CREATE INDEX ... IF NOT EXISTSdoes not block boot. The statement returnsimmediately and the index populates in the background; population is bounded by
:Delegationcardinality, not graph size. Until it isONLINEthe plannersimply does not use it — the delegation query string is unchanged, so
pre-index behaviour is exactly today's behaviour.
DriverErroron anyindex/constraint is swallowed,
ensure_neo4j_schemareturnsFalse, the latchstays unset, and the flush path self-heals (now rate-limited). Boot proceeds.
is transparent to instances that do not know about it.
schema object dropped out of band after boot is no longer re-created by a new
session's first flush within that process's lifetime. Recovery is a restart or
doctor --fix. That per-session re-run is precisely the storm being removed, sothe tradeoff is deliberate.
Verification
pytest -m "not neo4j"— 1982 passed, 7 skipped, 92 deselectedpytest tests/neo4j/test_node_index_seek.py -m neo4j(live Neo4j container) —8 passed, including two new live plan guards:
test_get_edge_fallback_does_not_scan_all_relationships(noAllRelationshipsScan, noAllNodesScan,IndexSeekpresent) andtest_delegation_lookup_uses_index_seek_not_label_scan(noNodeByLabelScan,IndexSeekpresent).reverted, tests kept):
test_get_edge_fallback_is_node_anchored_not_all_relationships_scan,test_schema_creates_delegation_sub_session_index,test_no_unindexed_scan_patterns_in_neo4j_store_source,TestSchemaProcessLatch(both),TestSchemaRetryBackoff::test_failed_pass_does_not_retry_on_the_next_flush.ruff format --check— clean on all five changed files.ruff check— no newrule classes; the only count change is four additional
# noqadirectives ofthe same kinds the files already use (
E402,PLC0415).try/except ImportError) were added intests/conftest.pyandtests/test_neo4j_store.pyso the suite still collects against unfixedsource and the new guards fail for the reason they exist rather than at import
time. Each fallback is byte-identical to the unfixed production value.
Deliberately NOT in this PR
POST /cypher— out of scope bydirection. These changes make wide queries seek; they do not bound or
restrict what callers may run.
_handle_exhausted_batchtransient-vs-poison classification. It catchesbare
Exception, so aConnectionAcquisitionTimeoutErroris dead-lettered andthe queue offset committed past it exactly like a malformed line — which is what
turned an infrastructure stall into permanent loss of live activity records.
Fixing it means touching queue-commit semantics, which does not belong in the
same change as a hotfix. Worth doing next.