Skip to content

Commit 396202b

Browse files
committed
fix(file-search): assert the backfill walk still plans as an index seek
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.
1 parent 1a3c140 commit 396202b

4 files changed

Lines changed: 50 additions & 4 deletions

File tree

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

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ describe('workspace file search dispatch PostgreSQL deadlines', () => {
4343
) {
4444
throw new Error('File search tests require a disposable local integration database')
4545
}
46+
/** Every statement the dispatcher issues, so a test can EXPLAIN the exact SQL it ran. */
47+
const statements: { query: string; params: readonly unknown[] }[] = []
4648
const connection = postgres(
4749
databaseUrl,
4850
withUtcTimestamps({
@@ -51,6 +53,9 @@ describe('workspace file search dispatch PostgreSQL deadlines', () => {
5153
fetch_types: false,
5254
connection: { search_path: schemaName },
5355
onnotice: () => {},
56+
debug: (_connection: unknown, query: string, params: readonly unknown[]) => {
57+
statements.push({ query, params })
58+
},
5459
})
5560
)
5661

@@ -60,8 +65,14 @@ describe('workspace file search dispatch PostgreSQL deadlines', () => {
6065
id text PRIMARY KEY, after_workspace_id text, after_file_id text,
6166
completed_at timestamp, updated_at timestamp NOT NULL
6267
)`
68+
/**
69+
* `workspace_id` is nullable here because it is nullable in production. Declaring it NOT NULL
70+
* lets PostgreSQL discard the walk's `workspace_id IS NOT NULL` clause as trivially true, after
71+
* which it can no longer prove the partial keyset index covers the query and silently stops
72+
* using it.
73+
*/
6374
await connection`CREATE TABLE workspace_files (
64-
id text PRIMARY KEY, workspace_id text NOT NULL, context text NOT NULL,
75+
id text PRIMARY KEY, workspace_id text, context text NOT NULL,
6576
deleted_at timestamp, content_updated_at timestamp NOT NULL
6677
)`
6778
await connection`CREATE TABLE workspace_file_search_revision (
@@ -218,6 +229,40 @@ describe('workspace file search dispatch PostgreSQL deadlines', () => {
218229
expect(skipped.missed).toBe(0)
219230
}, 30_000)
220231

232+
it('seeks the keyset index for the backfill cursor rather than filtering', async () => {
233+
await connection`INSERT INTO workspace_files (id, workspace_id, context, content_updated_at)
234+
SELECT md5(n::text), 'workspace-' || lpad((n % 7)::text, 2, '0'), 'workspace', '2026-09-16'
235+
FROM generate_series(1, ${2 * FILE_SEARCH_BACKFILL_PAGE_SIZE}) n`
236+
await connection`ANALYZE workspace_files`
237+
238+
/** The first page leaves a cursor behind; the second is the one that has to seek to it. */
239+
await prepareWorkspaceFileSearchDispatch()
240+
statements.length = 0
241+
await prepareWorkspaceFileSearchDispatch()
242+
243+
const walk = statements.find((statement) =>
244+
statement.query.includes('for share of "workspace_files"')
245+
)
246+
expect(walk).toBeDefined()
247+
expect(walk?.query).toContain('"workspace_files"."workspace_id", "workspace_files"."id") >')
248+
249+
const plan = await connection.begin(async (tx) => {
250+
/**
251+
* At fixture scale a sequential scan is genuinely cheapest, so the planner is pinned to the
252+
* choice production makes on a table where it is not. What is asserted is the shape the
253+
* planner can still only reach from a row-wise cursor: the `OR` spelling stays a filter under
254+
* these same settings, which is the regression this guards.
255+
*/
256+
await tx`SET LOCAL enable_seqscan = off`
257+
await tx`SET LOCAL enable_sort = off`
258+
const rows = await tx.unsafe(`EXPLAIN ${walk?.query}`, walk?.params as never[])
259+
return rows.map((row: Record<string, unknown>) => row['QUERY PLAN']).join('\n')
260+
})
261+
262+
expect(plan).toContain('workspace_files_workspace_active_keyset_idx')
263+
expect(plan).toMatch(/Index Cond:.*ROW\(/)
264+
}, 30_000)
265+
221266
it('fails on a locked backfill row and releases the dispatcher lock', async () => {
222267
let release = () => {}
223268
let locked = () => {}

‎packages/db/migrations/meta/0361_snapshot.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"id": "652fd0ee-d230-441d-9265-6dc83845e800",
2+
"id": "adb8500a-f0dc-4352-9153-aa71f94955a8",
33
"prevId": "8ad7811c-cbe2-4bac-bebf-4fef00f7d86f",
44
"version": "7",
55
"dialect": "postgresql",
@@ -26436,7 +26436,7 @@
2643626436
],
2643726437
"isUnique": false,
2643826438
"where": "\"workspace_files\".\"deleted_at\" IS NULL AND \"workspace_files\".\"context\" = 'workspace' AND \"workspace_files\".\"workspace_id\" IS NOT NULL",
26439-
"concurrently": false,
26439+
"concurrently": true,
2644026440
"method": "btree",
2644126441
"with": {}
2644226442
},

‎packages/db/migrations/meta/_journal.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2525,7 +2525,7 @@
25252525
{
25262526
"idx": 361,
25272527
"version": "7",
2528-
"when": 1789699416165,
2528+
"when": 1789700848145,
25292529
"tag": "0361_workspace_file_search_backfill_keyset",
25302530
"breakpoints": true
25312531
}

‎packages/db/schema.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2308,6 +2308,7 @@ export const workspaceFiles = pgTable(
23082308
*/
23092309
workspaceActiveKeysetIdx: index('workspace_files_workspace_active_keyset_idx')
23102310
.on(table.workspaceId, table.id)
2311+
.concurrently()
23112312
.where(
23122313
sql`${table.deletedAt} IS NULL AND ${table.context} = 'workspace' AND ${table.workspaceId} IS NOT NULL`
23132314
),

0 commit comments

Comments
 (0)