fix(file-search): stop cleanup starting a batch it cannot fund - #7965
Conversation
The batch loop admitted another batch whenever any budget remained, then passed that remainder through as the batch's statement timeout, clamped to 1ms. A batch admitted with a sliver left either runs past the budget it was given or aborts on its own statement timeout, which the caller reports as a cleanup failure rather than as work still to do. Stop once less than one batch's nominal share of the budget remains. The floor is derived from the budget and the batch cap rather than fixed, so retuning either cannot leave it admitting batches at more than their share again.
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
|
There was a problem hiding this comment.
All reported issues were addressed across 3 files
Reply with feedback, questions, or to request a fix.
Fix all with cubic | Re-trigger cubic
…held Hoisting the remaining-budget read out of the transaction callback made it describe the moment the batch was admitted rather than the moment its statements begin. Time spent waiting for a pooled connection then went unaccounted, and the batch installed a timeout larger than the budget actually left. Keep the cheap check before opening a transaction, and re-read once the connection is in hand so the installed timeout is the budget that remains.
|
Both findings are the same issue and both are correct — fixed in 5a00005. This was a regression I introduced. The original code computed the statement timeout inside the transaction callback ( It matters more than the general case suggests: under pool saturation, connection acquisition can take longer than the entire cleanup budget, so an admitted batch would install a timeout describing a budget that was already gone before its first statement ran. The fix keeps the cheap pre-transaction check, so the common path still avoids taking a connection at all, and re-reads once the connection is held: if (deadline - Date.now() < FILE_SEARCH_CLEANUP_MIN_BATCH_MS) break
const result = await db.transaction(async (tx) => {
/** Re-read: acquiring the connection can itself have spent the rest of the budget. */
const remainingBudget = deadline - Date.now()
if (remainingBudget < FILE_SEARCH_CLEANUP_MIN_BATCH_MS) return null
await configureFileSearchTransaction(tx, { statementTimeout: remainingBudget })Returning Added a second integration test covering the path the outer check cannot see — full budget at admission, none left once the connection is in hand. Verified it fails when only the inner guard is reverted (
|
|
@cubic-dev-ai review this PR |
@waleedlatif1 I have started the AI code review. It will take a few minutes to complete. |
What
cleanupFileSearchBuilds()drains expired search builds in batches under a 5s wall-clock budget, passing whatever budget remains as each batch'sstatement_timeout. The loop admitted another batch whenever any budget remained:So a batch could start with as little as 1ms of budget, and
Math.max(1, …)then handed that 1ms through as its statement timeout. Neither outcome is right: the batch either runs past the budget it was given, or aborts on its own statement timeout — and the call site can only report that as a cleanup failure rather than as work still to do.This stops once less than one batch's nominal share of the budget remains.
Why the floor is derived, not a literal
FILE_SEARCH_CLEANUP_MIN_BATCH_MSis exactlyFILE_SEARCH_CLEANUP_BUDGET_MS / FILE_SEARCH_CLEANUP_MAX_BATCHES— one batch's fair share. Writing it as500would keep that value if either input were retuned (sayMAX_BATCHESraised to drain faster), silently re-admitting batches at more than their share and reintroducing exactly what this removes.Scope
The guard makes this loop match the shape the outbox drain already uses (
lib/core/outbox/service.ts:433,minRemainingMs). A sweep of every otherBUDGET_MS/deadline loop — embeddings retry, connector deletion, outbox — found none with the unguarded shape, so nothing else is left owing the same fix.Deliberately unchanged:
.catch()at the call site. Cleanup is opportunistic work at the head of a dispatch run and must not block dispatch. It was only implicated because the loop was manufacturing spurious failures for it to log.cleanupBacklogged; a richer return type would add a weaker second source of truth.Honest scoping of the bug
This was found by inspection while investigating an unrelated production incident, and is confirmed by test — not by production telemetry. The
cleanup deferredlog lines that first drew attention to this function turned out to be fully explained by a PlanetScale-side stall (pool acquisition reaching 48.9s), not by this code. This fix is worth making on its merits; it is not the cause of that incident and does not claim to be.Testing
New integration test drives the real loop against PostgreSQL with the clock reporting a budget all but consumed, and asserts cleanup returns
0and deletes nothing rather than starting a batch. Verified it fails when the guard is reverted (expected 1000 to be +0— the old code ran a full 1000-row delete after its budget was spent).chunks.integration.ts+dispatcher.integration.ts: 47 passed, 1 skippedlib/workspace-files/search/: 134 passedbun run lint:check: 26/26 tasks pass;type-checkclean