Graph Node Lifecycle & Event-Loop Offloading Contract
On this page
ADR-071: Graph Node Lifecycle & Event-Loop Offloading Contract
Status: Accepted (2026-06-10) · Driver: VW-686 (VW-673 audit theme 7, final) · MR: !133 → 5eb589f6
Context
The PDG/code-knowledge graph layer served wrong or stale answers through four independent mechanisms, each silent:
- Phantom nodes:
falkordb_exporter.export_graphwas MERGE-only — deleted or renamed code stayed in the graph forever and kept appearing as dependents inget_impact_radius/get_shortest_path. (Edges already hadlast_seen_atfiltering from DAT Phase 2; nodes had no lifecycle at all.) - Event-loop blocking: every async MCP tool called the synchronous
_get_pdg()directly — a cold AST rebuild (54–150 s) froze the entire mcp-bridge, timing out unrelated tools (auto_search_vault, memory tools). - Permanent stale content: the knowledge-graph ingestion tracker stored an empty content hash, so edited files were never re-ingested; with VW-686’s first fix alone, an edited file with an unchanged chunk count would re-enter processing but skip every chunk forever (verifier-caught).
- Cache poisoning: the PDG disk cache ignored
root_path— a subdir-scoped rebuild overwrote the default-root cache.
Decision — four rules
1. Node lifecycle: stamp → sweep → filter
Before: nodes MERGE’d with created_at/updated_at only; never deleted.
After:
- Every node MERGE stamps
last_rebuild_at = $rebuild_start_ts(ON CREATE and ON MATCH);rebuild_start_tsis captured from FalkorDBtimestamp()before the export and written to PDGMeta with the same value. - After a clean full export (
stats.errorsempty), sweep:MATCH (n) WHERE NOT n:Community AND NOT n:Process AND NOT n:PDGMeta AND (n.last_rebuild_at IS NULL OR n.last_rebuild_at < $ts) DETACH DELETE n. A dirty export skips the sweep — a transiently-failed MERGE must not delete a still-valid node. get_impact_radius/get_shortest_pathfilter on the latest PDGMeta rebuild timestamp, mirroring the existing edgelast_seen_atfilter.
2. No blocking work on the bridge event loop
Before: pdg = _get_pdg(root) inline in async tools.
After: every blocking call inside an async MCP tool — PDG build/load, FalkorDB
export/sync, embedding encode (rootweaver-graph) — runs via
await asyncio.to_thread(...). Proven live: auto_search_vault answered during a
cold rebuild on the deployed vw-686 bridge. Accepted residual: two concurrent
calls may race a duplicate rebuild (wasted work, not corruption).
3. Ingestion progress is only valid for the content it was recorded against
Before: tracker stored hash: ""; resume returned stale chunk lists.
After: mark_chunk_done persists the real MD5 (same _content_hash the
needs_processing check uses); get_completed_chunks discards progress when the
stored hash is missing or no longer matches; mark_failed preserves chunk
bookkeeping so retries resume rather than duplicate.
4. Caches are keyed by their root
Before: PDGCache(DEFAULT_CACHE_DIR) regardless of root_path.
After: non-default roots get DEFAULT_CACHE_DIR/<sha1(root)[:12]>; the
default root keeps its byte-identical path. Missing index = “everything changed”
(rebuild), never “nothing changed” (VW-683).
Consequences
- Code intelligence answers reflect the code that exists now; deleted modules stop appearing as dependents after the next clean full export.
- mcp-bridge stays responsive during rebuilds — the operational requirement that motivated the bench/readonly bridge split no longer degrades the primary.
- The canonical graph name is
code_knowledge(GraphName.CODE aligned;code_intelligenceexisted only in the enum, docstrings, and a pre-broken Grafana dashboard — see VW-699 follow-ups). - Tests:
test_pdg_generator_vw686_graph_fixes.py,test_rootweaver_graph_vw686.py(+45 across the theme); regression greps + finder re-run in the master audit report’s verification block.
Related
ADR-036 (tri-store) · DAT Phase 2 edge staleness (HLD-02 §1c) · ADR-070 ·
VW-686 impl report 02-Active-Work/Implementation-Reports/2026-Q2/2026-06-10-VW-686-PDG-Graph-Theme-Remediation.md