fix(file-search): seek the backfill cursor instead of rescanning each page - #7956
Conversation
… page The hourly backfill walks every live workspace file by `(workspace_id, id)`, but no index supplied that order under its predicate, so each page sorted the whole remaining set and the dispatcher's 10s statement timeout aborted the transaction before any page committed. Adds the matching partial index and compares the cursor row-wise. The previous `workspace_id > :ws OR (workspace_id = :ws AND id > :id)` spelling is only ever an index filter, never an index condition, so even with the index each page restarted at the low end and rescanned every page before it. On a prod-shaped fixture (5.5M files, 94k live) a full 95-page walk goes from 786ms to 42ms; the index alone accounts for 786ms -> 267ms and the row-wise cursor for the rest.
|
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 6 files
Tip: cubic can generate docs of your entire codebase and keep them up to date. Try it here.
Fix all with cubic | Re-trigger cubic
Bound the walk loop by the expected page count so a cursor regression fails on the first extra page instead of looping; keep the fixture size off a page multiple explicitly; drop a distinct-count assertion the primary key already guarantees. Separate the two coupled causes in the TSDoc so neither reads as a consequence of the other.
Addresses both cubic findings. Mark the index `.concurrently()` so a schema reconciliation outside the hand-written migration also builds it without blocking writes, matching how workflow_execution_logs_workspace_activity_idx is declared. Drizzle now emits CREATE INDEX CONCURRENTLY itself; the migration keeps its hand-written wrapper for lock_timeout and replay recovery. The walk test only proved logical pagination, which the OR spelling also satisfies. Record the statements the dispatcher issues and EXPLAIN the exact backfill SELECT, requiring the cursor to appear as a row-wise index condition. Reverting to the OR spelling now fails on the plan itself, not just on the SQL text. That assertion also exposed a third divergence in the fixture: workspace_id was declared NOT NULL where production has it nullable, which let PostgreSQL drop the walk's IS NOT NULL clause and then refuse to match the partial index at all.
|
Both findings were valid and are fixed in 396202b. P1 — Verified both provisioning paths against a fresh database with the CI extension set: P2 — the test only proved logical pagination. Correct; that was the honest limit of it. The fixture is too small for the planner to choose the index on cost, so instead of scaling the workload the test now records the statements the dispatcher issues, finds the actual backfill Confirmed it discriminates: reverting the cursor to That assertion also surfaced a third divergence in this fixture: |
What is happening
workspace-file-search-dispatchfails in prod on every run, inseedBackfillPage, at ~11.2s — the 10sFILE_SEARCH_DISPATCH_STATEMENT_TIMEOUT_MSplus connection overhead. The whole dispatch transaction rolls back, so no page ever commits and the chunk-search backfill (workspace-file-search-chunks-v2, ~94k live workspace files) makes zero progress, retrying hourly forever.Root cause
The backfill pages through live workspace files ordered by
(workspace_id, id). Two separate defects compound:1. No index supplies that order under that predicate. Prod's plan for the exact failing query:
Every page sorts the whole remaining set, with a heap fetch per candidate to evaluate
context. That alone exceeds the timeout.2. The keyset is a filter, not an index condition.
a > x OR (a = x AND b > y)is not something the planner converts into an index condition — verified by forcing the plan, it stays aFiltereven with a perfectly matching index. So each page restarts at the low end of the index and rescans every page before it. Buffer cost measured against cursor offset, with the index present in both cases:Linear in the offset versus flat — the O(offset) vs O(page) signature.
The fix
workspace_files_workspace_active_keyset_idxon(workspace_id, id), partial on exactly the walk's predicate.(workspace_id, id) > (:ws, :id), which the planner does turn intoIndex Cond: ROW(workspace_id, id) > ROW(...)— a true seek.Both halves are required; neither is sufficient. Full 95-page walk on a prod-shaped local fixture (5.5M files, 94k live, warm cache, measured in-session so process startup is excluded):
Prod's absolute latency is far worse than the fixture's because it is dominated by cold random heap I/O over a 5.5M-row table; the fixture reproduces the plan pathology and the cost-growth signature, not prod's wall clock. The direct evidence for prod is prod's own
EXPLAINabove.Migration safety
0361follows the established house pattern for indexing an existing hot table (0351,0355): leadingCOMMIT;,lock_timeout = 0,DROP INDEX CONCURRENTLY IF EXISTSthenCREATE INDEX CONCURRENTLY IF NOT EXISTS, restorelock_timeout.CONCURRENTLYnever blocks writes toworkspace_files, and the drop-then-create makes replay recover an interrupted build rather than leaving anINVALIDindex behind.Verified: full migration chain applies clean on a fresh database and the index lands
indisvalid/indisready; replaying0361twice more leaves exactly one valid index;bun run check:migrationsreports backward-compatible;drizzle-kit generateis a no-op against the committed snapshot.Preventing recurrence
ceil(files / page size)pages. Confirmed it fails (expected 2 to be 3) when the cursor comparison is mutated.seedBackfillPagerecords that the column order, the partial predicate and the row-wise spelling are coupled, and what breaks if any one of them drifts.drizzle-kit generateis not a no-op.Two pre-existing drifts in the integration fixture are corrected as a side effect, both of which masked this path:
workspace_file_search_revisionwas declared with a composite primary key and was missing five columns the real insert emits, and the backfill cursor was not reset between tests.Testing
dispatcher.integration.ts7/7 against real PostgreSQLlib/workspace-files/search/133/133type-checkclean inapps/simandpackages/db; biome clean