fix(plugin): discard shadow vectors when the registration is retired mid-embed - #470
Merged
Merged
Conversation
…mid-embed processShadowQueueItem captured the shadow registration before awaiting the provider, then wrote the returned vectors under that captured registration with no re-check. A provider call can take seconds (minutes on a cold Synapse model load), so unregisterProjectShadowEmbedding could retire the shadow in between and the batch would still land: memory/commit rows and compartment chunks got vectors tagged with a registration that no longer exists. Re-arming the same model id afterwards would then read those stale-lane vectors as valid backfill and skip a real re-embed. Re-check the live registration generation after the provider round-trip in all three scopes (memory, commit, chunk) and refuse the write when the registration was retired or replaced. This mirrors the guard embedItemsForProject already applies to the primary lane. Adds 'registration_retired_during_embed' to ShadowBackfillWriteRefusalReason and a regression that retires the shadow from inside embedBatch: pre-fix it writes 3 stale vectors, post-fix it writes 0.
…ions unregisterProjectShadowEmbedding clears shadowBackfillLastWriteOutcomes for the project, but an item already in the worker loop could publish its outcome into that map afterwards. The stall detector reads the map, so a retired item's refusal could be attributed to a registration that replaced it — reporting a fresh lane as stalled_no_progress and leaving its remaining backfill unembedded. Record the outcome only while the registration the item started under is still the live one. Note: no test ships with this half. A re-arm regression was written and removed because it passed with and without the guard — the replacement registration publishes its own outcome under the same key, so the stale publish is not the value the detector reads in that path. The guard is defense-in-depth for the interleaving that is not reachable through the test seam, consistent with cubic's P2 ask.
Greptile P2 on cortexkit#470: the retirement regression exercised only the memory branch, while the fix independently changes the commit and chunk branches, which use different candidate preparation and write helpers. Adds a case for each remaining scope. Both fail without their branch's guard — commit writes 3 stale vectors, chunk writes 20 — and pass with it. Each carries a positive control so 'no rows written' cannot hold vacuously: - commit asserts the fixture actually produced committed rows to mirror - chunk asserts getShadowBackfillRemaining(...).chunk === 1 before the flush, and the provider factory branches per lane so the primary chunk lane embeds normally while only the shadow lane retires itself mid-embed The chunk fixture had to satisfy the real window contract: an FTS-mapped message row plus a seeded primary chunk embedding, since chunk candidates are built from FTS text and the shadow mirrors the primary lane's rows.
…case The lane-branching factory installed before the primary-lane seeding already covers both lanes: it retires the shadow on embed for provider === 'synapse' and returns a normal fake for the primary. The follow-up assignment could only ever run after seeding, so it changed nothing — the chunk write keys off registration.chunkModelId, not the factory's modelId. Single factory expresses the intent once: only the shadow lane retires itself mid-embed. Behavior unchanged: both scope tests still 2 pass / 4 expect() calls, full file 43 pass / 0 fail.
ualtinok
pushed a commit
that referenced
this pull request
Sep 19, 2026
Greptile P2 on #470: the retirement regression exercised only the memory branch, while the fix independently changes the commit and chunk branches, which use different candidate preparation and write helpers. Adds a case for each remaining scope. Both fail without their branch's guard — commit writes 3 stale vectors, chunk writes 20 — and pass with it. Each carries a positive control so 'no rows written' cannot hold vacuously: - commit asserts the fixture actually produced committed rows to mirror - chunk asserts getShadowBackfillRemaining(...).chunk === 1 before the flush, and the provider factory branches per lane so the primary chunk lane embeds normally while only the shadow lane retires itself mid-embed The chunk fixture had to satisfy the real window contract: an FTS-mapped message row plus a seeded primary chunk embedding, since chunk candidates are built from FTS text and the shadow mirrors the primary lane's rows.
ualtinok
added a commit
that referenced
this pull request
Sep 19, 2026
…e fixes) Co-authored-by: Alfonso <alfonso-magic-context@users.noreply.github.com>
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
Cubic flagged two issues on #462 after it merged. Both are real; this PR fixes them.
P2 (a) — vectors written for a retired registration.
processShadowQueueItemcaptures the shadow registration on entry, awaits the provider, then writes the returned vectors under the captured registration with no re-check:A provider call is a network round-trip (seconds), and on a cold Synapse model load it runs into tens of seconds. If
unregisterProjectShadowEmbedding(projectIdentity)runs inside that window, the batch still commits —memoryviasaveEmbeddingIfHashMatches,commitviasaveCommitEmbedding,chunkviareplaceCompartmentChunkEmbeddings, all carrying a registration that no longer exists. If the same model id is re-armed later, those stale-lane rows are read as valid backfill and the real re-embed is skipped, so rows keep vectors from a lane that was deliberately retired. That is the same silent-staleness class thesynapse:v1:pendingvector-space warning exists to prevent.The primary lane already guards this —
embedItemsForProjectre-reads the live registration after the await and returnsnullwhengenerationorruntimeFingerprintchanged. The shadow lane was missing the equivalent check.P2 (b) — stale worker state for a retired registration.
unregisterProjectShadowEmbeddingclearsshadowBackfillLastWriteOutcomes, but an item already in the worker loop could publish its outcome into that map afterwards. The stall detector reads that map, so a retired item's refusal could be attributed to a registration that replaced it — reporting a fresh lane asstalled_no_progressand leaving its remaining backfill unembedded.Fix
(a) Re-check the live registration generation after the provider round-trip in all three scopes (memory, commit, chunk) and refuse the write when the registration was retired or replaced:
generationis bumped byglobalRegistrationGenerationon every new shadow registration, so this covers a replaced registration (different model/fingerprint re-armed mid-flight), not just a deleted one.(b) Record the worker outcome only while the registration the item started under is still live.
Also adds
registration_retired_during_embedtoShadowBackfillWriteRefusalReasonplus itsdescribeShadowBackfillWriteRefusalcase, so the refusal surfaces through the existing stall reporting instead of the generic default text.Test
discards shadow vectors when the registration is retired while the provider call is in flightretires the shadow from insideembedBatch— precisely inside the await window — then assertsloadAllEmbeddings(db, projectIdentity, modelId).size === 0.Verified both directions:
Expected: 0 / Received: 3— the defect is real and the test bites.tsc --noEmitclean; full file 41 pass / 0 fail; biome clean on the changed content.Coverage note on P2 (b)
No test ships for the second half. A re-arm regression was written and then removed because it passed with and without the guard: in that path the replacement registration publishes its own outcome under the same key, so the stale publish is not the value the detector reads. Rather than ship a test that cannot fail, (b) is left as defense-in-depth for the interleaving that is not reachable through the test seam. Flagging that explicitly rather than implying coverage the suite does not have.
The PR appears safe to merge; the stale-vector race is guarded across all three scopes, and the previously identified outcome-state and coverage gaps are resolved.
Summary
This PR prevents asynchronous shadow embedding work from persisting vectors or worker outcomes after its registration has been retired or replaced.
Diagram
sequenceDiagram participant W as Shadow worker participant R as Registration registry participant P as Embedding provider participant DB as Vector storage W->>R: Capture registration and generation W->>P: Embed candidate items R-->>R: Registration retired or replaced P-->>W: Return vectors W->>R: Re-read live generation alt Registration is still current W->>DB: Persist vectors W->>W: Publish worker outcome else Registration is retired or replaced W-->>W: Discard vectors W-->>W: Suppress stale outcome endReviews (4) · Last reviewed commit: "test(plugin): drop the dead second facto..."