LearnNewsExamplesServices
Frontmatter
id10260
titlesyncCache must invalidate vicinity of both endpoints when invalidating edges (#10258 follow-up)
stateClosed
labels
bugaiarchitecturecore
assigneesneo-gemini-pro
createdAtApr 23, 2026, 11:03 PM
updatedAtJun 7, 2026, 7:20 PM
githubUrlhttps://github.com/neomjs/neo/issues/10260
authorneo-opus-ada
commentsCount0
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
blockedBy[]
blocking[]
closedAtApr 23, 2026, 11:28 PM

syncCache must invalidate vicinity of both endpoints when invalidating edges (#10258 follow-up)

Closed v13.0.0/archive-v13-0-0-chunk-5 bugaiarchitecturecore
neo-opus-ada
neo-opus-ada commented on Apr 23, 2026, 11:03 PM

Context

Empirically discovered this session: after PR #10258 merged (and both harnesses restarted), a follow-up round-trip revealed the cross-process coherence gap is not fully closed. Peer-written messages addressed directly to me still do not surface in my list_messages cache until a fresh MCP spawn.

Specific case: Gemini sent MESSAGE:d559987d-... from @neo-gemini-pro@neo-opus-ada at 20:57:09Z. SQLite has it. Her Antigravity harness wrote it cleanly (correct SENT_BY + SENT_TO edges per raw query). But my long-running Claude Code MCP process (uptime ~10min) cannot see it via list_messages even though #10258's vicinity-reload fix IS live in the running process.

Gemini saw my messages correctly on her side because she restarted post-#10258. I restarted too and saw her earlier broadcasts — but her LATER DM (sent while my process was still running) doesn't propagate to my cache.

The Problem

Database.syncCache() at ai/graph/Database.mjs:~78-103 invalidates cached entries based on GraphLog WAL delta. Current logic:

// Conceptual — actual code iterates delta entries
this.nodes.remove(delta.invalidNodes);
delta.invalidNodes.forEach(id => {
    this.vicinityLoadedNodes.delete(id);
    this.lastAccessMap.delete(id);
});
// similar for delta.invalidEdges — removes edges but does NOT invalidate vicinity of endpoints

The gap: when a peer process adds a NEW edge that terminates at an existing node in my cache, syncCache invalidates the new edge from cache but does NOT invalidate the vicinityLoadedNodes entry for the endpoint node. So when #10258's getAdjacentNodes(endpoint, direction) fires, it sees vicinityLoadedNodes.has(endpoint) === true (from an earlier load), skips loadNodeVicinitySync, and the new edge never re-hydrates into cache.

Concretely for my case:

  1. Gemini writes MESSAGE:d559987d with SENT_TO → @neo-opus-ada edge
  2. GraphLog gets 2 new entries (node + edge)
  3. My process's syncCache reads delta, invalidates d559987d (new node) + the edge ID
  4. vicinityLoadedNodes has @neo-opus-ada from a prior load (from earlier in this session when I seeded the vicinity)
  5. syncCache does NOT clear @neo-opus-ada from vicinityLoadedNodes (because @neo-opus-ada is not in delta.invalidNodes — it's an existing unchanged node)
  6. My list_messages calls getAdjacentNodes('@neo-opus-ada', 'inbound') → skips lazy-reload because vicinityLoadedNodes.has(target) is true
  7. Iteration over edges.items misses the new edge
  8. list_messages returns stale cache

The Architectural Reality

  • ai/graph/Database.mjssyncCache() implementation + vicinityLoadedNodes management
  • ai/graph/storage/SQLite.mjsload(lastSyncId) returns delta with invalidNodes + invalidEdges arrays
  • For a new edge <source, target, type>, the current delta-processing invalidates the edge but leaves both source and target endpoints' vicinity-loaded state untouched
  • loadNodeVicinitySync(nodeId) is the authoritative re-hydration primitive; it re-loads all edges where nodeId is source OR target

The Fix

Extend syncCache to invalidate vicinityLoadedNodes for BOTH endpoints of every invalidated edge:

// At the delta-processing site in Database.syncCache
delta.invalidEdges.forEach(edgeId => {
    const edge = this.edges.get(edgeId); // may already be null if not cached
    if (edge) {
        // Invalidate vicinity for both endpoints so the next getAdjacentNodes
        // call against either one triggers loadNodeVicinitySync, re-hydrating
        // the new/changed edge from SQLite.
        this.vicinityLoadedNodes.delete(edge.source);
        this.vicinityLoadedNodes.delete(edge.target);
    } else {
        // Edge not in local cache — we can't know its endpoints without a
        // SQLite lookup. For new peer-written edges, read the endpoint IDs
        // directly from storage (the delta load should already carry them).
        // Alternative: have storage.load() surface edgeEndpoints in the delta
        // payload so syncCache has the info without an extra SELECT.
    }
    this.edges.remove(edgeId);
});

Preferred direction: modify SQLite.load(lastSyncId) to return delta.invalidEdges as [{id, source, target}] objects instead of just IDs, so syncCache has the endpoint info for free. Backward-compat: check the shape, handle both.

Acceptance Criteria

  • Database.syncCache() invalidates vicinityLoadedNodes for both endpoints of each invalidated edge
  • SQLite.load() surfaces endpoint metadata in delta.invalidEdges (object shape, not just ID array)
  • Regression test in test/playwright/unit/ai/graph/Database.spec.mjs: two Database instances against the same file; instance A writes an edge, instance B's syncCache invalidates its endpoints, next getAdjacentNodes on either endpoint surfaces the new edge without explicit vicinity-clear
  • Empirical validation: two MCP processes (Claude Code + Antigravity), peer A sends a DM to peer B while B's process is running; B's list_messages surfaces the DM without harness restart

Out of Scope

  • Proactive vicinity pre-fetching or subscription-based cache warming — defer until empirical demand
  • Rewriting the GraphLog schema for edge-endpoint denormalization — current SQLite trigger records log entity-id only; if we need endpoint info without schema change, fetch from Edges table at delta-load time
  • Extending the fix to ChromaDB metadata coherence — orthogonal substrate

Avoided Traps

  • "Always re-run loadNodeVicinitySync on every getAdjacentNodes call" — rejected. Defeats the point of caching; every read becomes a SQLite roundtrip.
  • "Poll vicinityLoadedNodes TTL-based invalidation" — rejected. Timer-based invalidation introduces latency floor; delta-driven invalidation is strictly better.
  • "Fix this at the MailboxService layer by always calling loadNodeVicinitySync before iteration" — rejected. Wrong layer. The bug is in Database.syncCache's incomplete delta-processing, not in mailbox-specific logic. Fixing at mailbox would leave every other edge-type-scan consumer vulnerable to the same gap.

Related

  • #10258 — cross-process coherence for mailbox read paths. This ticket is the deeper substrate fix that closes the remaining gap #10258's MailboxService-layer fix couldn't address alone.
  • #10257 — origin ticket for the mailbox-layer coherence fix.
  • #10190 / PR #10221 — foundational syncCache substrate fix. This ticket extends the invalidation semantics.
  • #10139 — A2A mailbox primitive. Empirical "A2A works without restart" fully satisfied only once this lands.

Origin Session ID

Origin Session ID: 29f490c0-41ed-4846-a767-287552d5c3c4

Handoff Retrieval Hints

Retrieval Hint: "syncCache vicinityLoadedNodes edge endpoint invalidation gap" Retrieval Hint: "cross-process mailbox coherence #10258 follow-up" Commit-range anchor: dev HEAD 96ae5f866 at ticket filing

tobiu referenced in commit 4ecdac0 - "fix(ai): invalidate endpoint vicinity caches on edge updates (#10260) (#10261) on Apr 23, 2026, 11:28 PM
tobiu closed this issue on Apr 23, 2026, 11:28 PM