LearnNewsExamplesServices
Frontmatter
id14176
titleKB resume-shadow orphan leak on double-failure (embed-fail + resume-state-write-fail) — write-ahead marker
stateClosed
labels
bugai
assigneesneo-opus-ada
createdAtJun 27, 2026, 2:25 AM
updatedAtJun 27, 2026, 3:11 AM
githubUrlhttps://github.com/neomjs/neo/issues/14176
authorneo-opus-ada
commentsCount0
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtJun 27, 2026, 3:11 AM

KB resume-shadow orphan leak on double-failure (embed-fail + resume-state-write-fail) — write-ahead marker

neo-opus-ada
neo-opus-ada commented on Jun 27, 2026, 2:25 AM

Context

Surfaced during my same-family review of #14161 (the KB resumable shadow-swap, Resolves #14146). #14161 is correct and approved — this is a rare, non-blocking edge found in the deeper cross-cutting pass, filed so the friction is not lost (it is a net-new source of the abandoned shadows that #14079 cleans up).

The leak

VectorService.embedViaShadowSwap (post-#14161) on a transient embed failure preserves the shadow + records a resume-marker via writeResumeState. That write is wrapped:

} else {
    try {
        await writeResumeState({dir: stateDir, fingerprint, shadowName, attempts});
    } catch (preserveError) {
        logger.error(`...Failed to record resume-state for '${shadowName}': ...`);
    }
}

If the marker write also fails (disk-full / permission glitch), the shadow collection is preserved on disk with no marker pointing at it. The next run:

  1. readResumeStatenull
  2. decideResumeno-resume-shadow → fresh-build branch
  3. if (resumeState?.shadowName) { await this.discardResumeShadow(...) }no-op (resumeState is null)
  4. creates a new shadow → the prior shadow is now an unreferenced orphan

No external routine reclaims it: defragChromaDB deletes only its own atomically-scoped parkingName (verified — no *-shadow-* name-sweep), so the orphan persists until a manual defrag. Each double-failure leaks one shadow collection → feeds the #14079 Chroma bloat.

Why non-blocking

Requires a double failure (transient embed fail AND marker write fail). The marker is a tiny JSON to gitignored .neo-ai-data/kb-sync; its write fails ~only on disk-full, where the embed itself usually fails first. Slow leak, not a correctness bug — #14161's happy/transient/terminal paths are all correct.

Fix direction (pick one)

  • (A) Write-ahead marker (preferred): write the resume-state before embedChunks, so the marker is a reliable index of "a shadow exists." Then a fresh-build always finds + discardResumeShadows the prior one. Success path already clearResumeStates. One extra small write per embed.
  • (B) Marker-less sweep: on the fresh-build branch, listCollections() and discard any *-shadow-* not equal to the current resume-shadow. More aggressive; needs a guard so it never deletes a live resume-shadow named in the marker.

(A) is cleaner — it makes the marker the single source of truth for shadow existence, closing the window structurally rather than sweeping after the fact.

Acceptance Criteria

  • A double-failure (embed throws + resume-state write throws) leaves no unreferenced shadow collection after the subsequent fresh-build run.
  • A live resume-shadow (valid marker, within attempt-cap) is never deleted by the fix.
  • Unit coverage for the double-failure → next-run-fresh-build → no-orphan path (sibling to the existing resumableEmbedding / VectorService.WorkVolumeBranching specs).

Refs #14161 (the resumable shadow-swap this hardens), #14146 (the P0 it resolved), #14079 (the bloat epic this prevents a new source of).


🤖 Authored by Ada (@neo-opus-ada · Claude Opus 4.8, Claude Code) · origin session f2c722bf-9fb0-4925-8fbc-a9a0788f459c. Surfaced via #14161 same-family review.

tobiu referenced in commit e11119a - "fix(ai): write-ahead KB resume marker — no orphan shadow on double failure (#14176) (#14180) on Jun 27, 2026, 3:11 AM
tobiu closed this issue on Jun 27, 2026, 3:11 AM