Skip to content

Commit 8948b54

Browse files
authored
perf(file-search): probe each backfill page's revisions by primary key (#9445)
The backfill walk checked each file for a revision with a correlated EXISTS in its select list. PostgreSQL plans that as an alternative subplan and picks between a per-row probe and a hashed subplan from the scan's own row estimate, before the page limit applies. Sized against every file left in the walk, the hashed form wins, so every page sequentially scanned and hashed the whole revision table to answer a thousand lookups. The page now left-joins the revision on its primary key, the file id, and reads hasRevision as the joined key being non-null. Under the limit the planner nests that join over the ordered keyset scan, one index probe per file returned. The key is unique, so the join cannot repeat a file, and the keyset predicate, order and limit are unchanged. On a production-sized replica the page went from about 500ms with a full scan of the revision table to about 12ms warm with index probes, with the keyset index scan on workspace_files unchanged. The new integration case walks a second page over a settled table and asserts the revision table is only read through its primary key, probed per file; it fails on the previous query with the hashed sequential scan.
1 parent 467236e commit 8948b54

2 files changed

Lines changed: 56 additions & 7 deletions

File tree

‎apps/sim/lib/workspace-files/search/dispatcher.integration.ts‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ interface QueryPlan {
4242
'Node Type': string
4343
'Relation Name'?: string
4444
'Index Name'?: string
45+
'Index Cond'?: string
4546
'Shared Hit Blocks': number
4647
'Shared Read Blocks': number
4748
Plans?: QueryPlan[]
@@ -478,6 +479,50 @@ describe('workspace file search dispatch PostgreSQL deadlines', () => {
478479
expect(plan).toMatch(/Index Cond:.*ROW\(/)
479480
}, 30_000)
480481

482+
it('probes each walked file for its revision rather than scanning every revision', async () => {
483+
const files = 20 * FILE_SEARCH_BACKFILL_PAGE_SIZE
484+
await connection`INSERT INTO workspace_files (id, workspace_id, context, content_updated_at)
485+
SELECT md5(n::text), 'workspace-' || lpad((n % 7)::text, 2, '0'), 'workspace', '2026-09-16'
486+
FROM generate_series(1, ${files}) n`
487+
await connection.begin(async (tx) => {
488+
await tx`SET LOCAL session_replication_role = replica`
489+
await tx`INSERT INTO workspace_file_search_revision
490+
(file_id, workspace_id, entity_type, entity_id, source_content_updated_at, status)
491+
SELECT id, workspace_id, 'workspace', workspace_id, content_updated_at, 'ready'
492+
FROM workspace_files`
493+
})
494+
await connection`VACUUM (ANALYZE) workspace_files, workspace_file_search_revision`
495+
496+
await prepareWorkspaceFileSearchDispatch()
497+
statements.length = 0
498+
await prepareWorkspaceFileSearchDispatch()
499+
500+
const walk = statements.find(
501+
(statement) =>
502+
statement.query.includes('from "workspace_files"') &&
503+
statement.query.includes('order by CASE WHEN')
504+
)
505+
expect(walk).toBeDefined()
506+
const [explained] = await connection.unsafe(
507+
`EXPLAIN (FORMAT JSON) ${walk?.query}`,
508+
walk?.params as never[]
509+
)
510+
const nodes = planNodes((explained['QUERY PLAN'] as { Plan: QueryPlan }[])[0].Plan)
511+
512+
/**
513+
* A page reads a thousand files, but a revision check the planner sizes against every file
514+
* left in the walk hashes the whole revision table on each page instead of probing it once
515+
* per file returned.
516+
*/
517+
expect(
518+
nodes
519+
.filter((node) => node['Relation Name'] === 'workspace_file_search_revision')
520+
.map((node) => ({ index: node['Index Name'], condition: node['Index Cond'] }))
521+
).toEqual([
522+
{ index: 'workspace_file_search_revision_pkey', condition: '(file_id = workspace_files.id)' },
523+
])
524+
}, 30_000)
525+
481526
it('claims from the ordered pending index rather than a hash join over the backlog', async () => {
482527
await seedQueue('workspace-1', 10_000)
483528
await connection`ANALYZE workspace_files`

‎apps/sim/lib/workspace-files/search/dispatcher.ts‎

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,7 @@ function staleClaim(now: Date): SQL | undefined {
217217
* Seeds one page of the backfill that walks every live workspace and project file into the
218218
* revision table.
219219
*
220-
* Two things keep this page cheap, and losing either one reintroduces a dispatch that times out.
220+
* Three things keep this page cheap, and losing any one reintroduces a dispatch that times out.
221221
*
222222
* `workspace_files_search_owner_keyset_idx` supplies the `(owner type, owner id, id)` order under
223223
* this exact filter, spelled with the same owner expressions. Without it nothing does, so the page
@@ -228,6 +228,11 @@ function staleClaim(now: Date): SQL | undefined {
228228
* can turn into an index condition; it stays a filter, so each page restarts at the low end of the
229229
* index and re-reads every page before it, making the walk quadratic in the file count.
230230
*
231+
* Each file's revision is then found by a join on the revision's primary key, which the page's
232+
* limit turns into one index probe per file returned. A correlated `EXISTS` in the select list is
233+
* planned against the rows the scan could return before that limit, so it hashes the whole
234+
* revision table on every page instead. The key is the file id, so the join never repeats a file.
235+
*
231236
* The walk itself reads without locks, because nearly every file it passes already has its
232237
* revision. Locking the whole page would write a row lock into every heap tuple of it each
233238
* pass, and so would inserting rows that only conflict: the revision's owner trigger takes
@@ -265,14 +270,13 @@ async function seedBackfillPage(tx: DbTransaction, now: Date): Promise<number> {
265270
.select({
266271
...owner,
267272
fileId: workspaceFiles.id,
268-
hasRevision: sql<boolean>`${exists(
269-
tx
270-
.select({ fileId: workspaceFileSearchRevision.fileId })
271-
.from(workspaceFileSearchRevision)
272-
.where(eq(workspaceFileSearchRevision.fileId, workspaceFiles.id))
273-
)}`,
273+
hasRevision: sql<boolean>`${workspaceFileSearchRevision.fileId} IS NOT NULL`,
274274
})
275275
.from(workspaceFiles)
276+
.leftJoin(
277+
workspaceFileSearchRevision,
278+
eq(workspaceFileSearchRevision.fileId, workspaceFiles.id)
279+
)
276280
.where(
277281
and(
278282
liveSearchableFile,

0 commit comments

Comments
 (0)