Context
#16817 proved the heavy-maintenance fairness bound is cooperative: shouldYieldHeavyMaintenanceLease is a pure predicate the holder must choose to call, and withHeavyMaintenanceLease has no timer, no abort signal and no watchdog. A holder that never checkpoints is never preempted.
The obvious next question is whether kbSync — the holder observed on #16566 keeping the slot 13 hours — is such a holder. It is not. VectorService.embedChunks consults shouldYield() between outer batches (ai/services/knowledge-base/VectorService.mjs:644), the predicate is threaded live from ai/scripts/maintenance/syncKnowledgeBase.mjs:121, and completed batches are durably upserted into the shadow so a yield resumes rather than restarts.
So the checkpoint exists, fires, and preserves progress. What no one has multiplied is how far apart two consecutive checkpoints can be.
The Problem
The interval between two shouldYield() consultations is a product of four independently-declared leaves that no single site reads together:
| leaf |
value |
where |
openAiCompatible.batchEmbeddingTimeoutMs |
300000 (5 min) |
ai/configBase.mjs:681 |
openAiCompatible.unloadRetryCount |
3 |
ai/configBase.mjs:673 |
openAiCompatible.batchEmbeddingChunkSize |
5 |
ai/configBase.mjs:678 |
KB batchSize |
50 |
ai/mcp/server/knowledge-base/configBase.mjs:522 |
KB maxRetries |
5 |
ai/mcp/server/knowledge-base/configBase.mjs:545 |
orchestrator.heavyMaintenance.maxActiveHoldMs |
HOUR_MS / 2 (30 min) |
ai/configBase.mjs:1380 |
Worst case between two consultations:
(1 + unloadRetryCount) x batchEmbeddingTimeoutMs = 4 x 300s = 20 min per provider chunk
ceil(batchSize / batchEmbeddingChunkSize) = ceil(50/5) = 10 chunks per embedTexts call
maxRetries = x 5 attempts per outer batch
-----------
1000 min = 16 h 40 minAgainst a fairness bound of 30 minutes. A ratio of 33×. The observed 13-hour hold on #16566 sits inside that analytic bound.
Three mechanisms compose, none of them individually wrong:
ai/services/memory-core/TextEmbeddingService.mjs:1109 — #embedOpenAiCompatibleBatch loops over ceil(N / batchEmbeddingChunkSize) provider chunks. It has a natural checkpoint at operation.phase = 'batch-yield' (:1129) and consults nothing there.
ai/services/memory-core/TextEmbeddingService.mjs:888 — each chunk re-posts up to unloadRetryCount times on a model-load failure, each attempt carrying the full requestTimeoutMs. The leaf's own comment says the timeout exists so a request "must not hold the provider queue forever" — that is a per-request contract, and it is honoured. Nothing states an operation-level one.
ai/services/knowledge-base/VectorService.mjs:686,712 — the outer retry loop is a bare catch (err), so a timeout is retried like any other error, multiplying the whole inner cost by maxRetries.
A cooperative bound whose checkpoint interval exceeds the bound is not a bound. maxActiveHoldMs can be tuned to any value below 16 h 40 min and change nothing observable, because the first opportunity to honour it may not arrive until after it.
The Fix
The checkpoint that has to fire already exists one call-frame too high. Move the consultation to the boundary that is already there, and make the resulting inequality executable.
- Consult the yield predicate at the inner chunk boundary.
embedChunks already owns shouldYield as an injected predicate; thread it through embedTexts into #embedOpenAiCompatibleBatch and consult it at the existing batch-yield point. Sharing a predicate is ordinary reuse, not ADR-0019 B5 — no config value is threaded.
- Abandon with a typed error, not a partial array. A partial embedding array would silently misalign with
collection.upsert's ids. The inner loop throws a distinguishable yield error carrying how many chunks completed.
embedChunks must treat that error as a yield, never as a retry. Today's bare catch would swallow it and burn all maxRetries attempts — turning the fix into a 5× amplifier. This is the one place the repair can go wrong, and it needs its own fixture.
- Make the inequality executable. A spec that computes
(1 + unloadRetryCount) x batchEmbeddingTimeoutMs from the live leaves and asserts it is strictly less than maxActiveHoldMs. A future leaf change that reopens the gap fails CI instead of being discovered in a 13-hour hold.
After the repair the worst-case interval is one provider chunk — 20 min against a 30 min bound.
Acceptance Criteria
Out of Scope
- The re-embed ratio, the declared in-flight concurrency, and the public-surface disproportion (
#16780 AC-3/AC-5/AC-7) — the reporting half. This ticket is the bound. #16780 stays open for them.
- Why tenant ingestion fails at the embed stage, and the starvation it causes —
#16566.
- Preemption at the lease layer — rejected on
#16817/PR #16818: it abandons a holder mid-batch, and the whole reason a checkpoint is the right instrument is that the resume store already exists.
- Retuning
maxActiveHoldMs, batchSize or maxRetries. The defect is that the interval is unbounded relative to the bound, not that any single leaf holds a wrong number.
Avoided Traps
- Reading "the holder never checkpoints" from
#16817. It does checkpoint. The measurement had to be the interval, not the presence — and the presence is what a grep for shouldYield shows you.
- Putting the deadline on the lease. Already falsified on
#16818: a mutex-level timeout abandons a holder with no resumable checkpoint. The checkpoint is the instrument precisely because kbEmbeddingResumeStore exists.
- Putting a wall-clock deadline on the whole batch. A full 65k-document corpus is legitimately hours of work at a healthy 0.86 s/chunk. A total-duration deadline converts a slow-but-progressing sync into one that never finishes — the alarm-on-the-absolute trap
#16780 already names, one layer down.
- Treating the bare
catch as incidental. It is the mechanism that turns a correct yield into a 5× retry storm, so the fix is incomplete without it.
Related
#16817 / PR #16818 (the fairness bound is cooperative — the direct predecessor) · #16780 (the missing bound and undeclared concurrency — parent; the reporting half stays there) · #16566 (the 13-hour kbSync hold, and the ingestion failure itself) · #16561 (the lease had no fairness — where maxActiveHoldMs came from) · #16690 (KB ingest awaits embedding inline where Memory Core defers) · #16706 (deployment-readiness tracker)
Origin Session ID: 87f453f9-aa80-4487-9ed1-b5d91e052c43
Retrieval Hint: kbSync checkpoint interval fairness bound shouldYield provider chunk boundary unloadRetryCount batchEmbeddingTimeoutMs maxActiveHoldMs 33x
Context
#16817proved the heavy-maintenance fairness bound is cooperative:shouldYieldHeavyMaintenanceLeaseis a pure predicate the holder must choose to call, andwithHeavyMaintenanceLeasehas no timer, no abort signal and no watchdog. A holder that never checkpoints is never preempted.The obvious next question is whether
kbSync— the holder observed on#16566keeping the slot 13 hours — is such a holder. It is not.VectorService.embedChunksconsultsshouldYield()between outer batches (ai/services/knowledge-base/VectorService.mjs:644), the predicate is threaded live fromai/scripts/maintenance/syncKnowledgeBase.mjs:121, and completed batches are durably upserted into the shadow so a yield resumes rather than restarts.So the checkpoint exists, fires, and preserves progress. What no one has multiplied is how far apart two consecutive checkpoints can be.
The Problem
The interval between two
shouldYield()consultations is a product of four independently-declared leaves that no single site reads together:openAiCompatible.batchEmbeddingTimeoutMs300000(5 min)ai/configBase.mjs:681openAiCompatible.unloadRetryCount3ai/configBase.mjs:673openAiCompatible.batchEmbeddingChunkSize5ai/configBase.mjs:678batchSize50ai/mcp/server/knowledge-base/configBase.mjs:522maxRetries5ai/mcp/server/knowledge-base/configBase.mjs:545orchestrator.heavyMaintenance.maxActiveHoldMsHOUR_MS / 2(30 min)ai/configBase.mjs:1380Worst case between two consultations:
(1 + unloadRetryCount) x batchEmbeddingTimeoutMs = 4 x 300s = 20 min per provider chunk ceil(batchSize / batchEmbeddingChunkSize) = ceil(50/5) = 10 chunks per embedTexts call maxRetries = x 5 attempts per outer batch ----------- 1000 min = 16 h 40 minAgainst a fairness bound of 30 minutes. A ratio of 33×. The observed 13-hour hold on
#16566sits inside that analytic bound.Three mechanisms compose, none of them individually wrong:
ai/services/memory-core/TextEmbeddingService.mjs:1109—#embedOpenAiCompatibleBatchloops overceil(N / batchEmbeddingChunkSize)provider chunks. It has a natural checkpoint atoperation.phase = 'batch-yield'(:1129) and consults nothing there.ai/services/memory-core/TextEmbeddingService.mjs:888— each chunk re-posts up tounloadRetryCounttimes on a model-load failure, each attempt carrying the fullrequestTimeoutMs. The leaf's own comment says the timeout exists so a request "must not hold the provider queue forever" — that is a per-request contract, and it is honoured. Nothing states an operation-level one.ai/services/knowledge-base/VectorService.mjs:686,712— the outer retry loop is a barecatch (err), so a timeout is retried like any other error, multiplying the whole inner cost bymaxRetries.A cooperative bound whose checkpoint interval exceeds the bound is not a bound.
maxActiveHoldMscan be tuned to any value below 16 h 40 min and change nothing observable, because the first opportunity to honour it may not arrive until after it.The Fix
The checkpoint that has to fire already exists one call-frame too high. Move the consultation to the boundary that is already there, and make the resulting inequality executable.
embedChunksalready ownsshouldYieldas an injected predicate; thread it throughembedTextsinto#embedOpenAiCompatibleBatchand consult it at the existingbatch-yieldpoint. Sharing a predicate is ordinary reuse, not ADR-0019 B5 — no config value is threaded.collection.upsert'sids. The inner loop throws a distinguishable yield error carrying how many chunks completed.embedChunksmust treat that error as a yield, never as a retry. Today's barecatchwould swallow it and burn allmaxRetriesattempts — turning the fix into a 5× amplifier. This is the one place the repair can go wrong, and it needs its own fixture.(1 + unloadRetryCount) x batchEmbeddingTimeoutMsfrom the live leaves and asserts it is strictly less thanmaxActiveHoldMs. A future leaf change that reopens the gap fails CI instead of being discovered in a 13-hour hold.After the repair the worst-case interval is one provider chunk — 20 min against a 30 min bound.
Acceptance Criteria
embedTextsstops without issuing the remaining chunk requests.embedChunks— the outer loop must not re-attempt, andyieldedmust be reported to the caller so the lease is released.selectResumableChunks.(1 + unloadRetryCount) * batchEmbeddingTimeoutMs < maxActiveHoldMsagainst the resolved leaves, and it fails when any of the three is moved to reopen the gap.embedChunks's existing forward-progress guarantee (at least one unit lands per lease).Out of Scope
#16780AC-3/AC-5/AC-7) — the reporting half. This ticket is the bound.#16780stays open for them.#16566.#16817/PR #16818: it abandons a holder mid-batch, and the whole reason a checkpoint is the right instrument is that the resume store already exists.maxActiveHoldMs,batchSizeormaxRetries. The defect is that the interval is unbounded relative to the bound, not that any single leaf holds a wrong number.Avoided Traps
#16817. It does checkpoint. The measurement had to be the interval, not the presence — and the presence is what a grep forshouldYieldshows you.#16818: a mutex-level timeout abandons a holder with no resumable checkpoint. The checkpoint is the instrument precisely becausekbEmbeddingResumeStoreexists.#16780already names, one layer down.catchas incidental. It is the mechanism that turns a correct yield into a 5× retry storm, so the fix is incomplete without it.Related
#16817/PR #16818(the fairness bound is cooperative — the direct predecessor) ·#16780(the missing bound and undeclared concurrency — parent; the reporting half stays there) ·#16566(the 13-hourkbSynchold, and the ingestion failure itself) ·#16561(the lease had no fairness — wheremaxActiveHoldMscame from) ·#16690(KB ingest awaits embedding inline where Memory Core defers) ·#16706(deployment-readiness tracker)Origin Session ID: 87f453f9-aa80-4487-9ed1-b5d91e052c43
Retrieval Hint:
kbSync checkpoint interval fairness bound shouldYield provider chunk boundary unloadRetryCount batchEmbeddingTimeoutMs maxActiveHoldMs 33x