Skip to content

Commit 3a57638

Browse files
committed
fix(file-search): claim a file write that lands on an owner the backfill already queued
Backfill reconciliation wake-ups enter the owner queue as if just dispatched so they rotate in behind owners woken for a write. A write that landed while such a row was still queued only advanced its updated_at through the file trigger's upsert, so it kept the reconciliation stamp and waited behind every older-stamped owner. The same held for an owner that was served, left queued with work, and then woken again. Claims now take owners woken since their last dispatch first: never dispatched, or updated_at later than last_dispatched_at, which every real wake-up (file write, released claim, reaped claim, artifact-ready requeue) advances and a reconciliation wake-up never does; it no longer touches an owner already queued. Within each group the dispatch-age rotation is unchanged. Queue timestamps the comparison reads come from the transaction clock, the same clock the trigger writes.
1 parent 06ddf2f commit 3a57638

4 files changed

Lines changed: 83 additions & 37 deletions

File tree

‎apps/sim/lib/workspace-files/search/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,6 @@ The design uses PostgreSQL's documented [TOAST behavior](https://www.postgresql.
7171

7272
Migration `0426_file_search_owner_scope.sql` adds canonical workspace/Project ownership to the existing build, revision, and chunk pipeline. Legacy workspace inserts are translated by triggers, and the existing workspace trigram index remains usable. Project rows use a separate owner-scoped trigram index on the same chunk table. Organizations and personal users are not enabled search owners.
7373

74-
The new `file_search_dispatch_queue` replaces the workspace-keyed scheduler because older workers cannot decode a Project without a workspace. During expansion, workspace mutations enqueue both queues, while the new dispatcher reads the owner queue. Both deployments share the dispatcher lock, revision claim token, build lease, and publication fence. Drain old app dispatchers and Trigger workers/retries before enabling Project dispatch. Retire the legacy queue and its dual enqueue in a later contract deploy; they are not a second indexing pipeline. The `file-search-owners-v3` cursor performs bounded owner/file keyset backfill and hourly reconciliation without rewriting extracted text in the migration. Each claim pass takes owners never dispatched first, so the owners a backfill page wakes enter the queue as if just dispatched: a page of reconciliation wake-ups rotates in by dispatch age behind the owners the file trigger woke for a write, instead of delaying them by several passes.
74+
The new `file_search_dispatch_queue` replaces the workspace-keyed scheduler because older workers cannot decode a Project without a workspace. During expansion, workspace mutations enqueue both queues, while the new dispatcher reads the owner queue. Both deployments share the dispatcher lock, revision claim token, build lease, and publication fence. Drain old app dispatchers and Trigger workers/retries before enabling Project dispatch. Retire the legacy queue and its dual enqueue in a later contract deploy; they are not a second indexing pipeline. The `file-search-owners-v3` cursor performs bounded owner/file keyset backfill and hourly reconciliation without rewriting extracted text in the migration. Claims take owners woken since their last dispatch first (`wokenSinceDispatch` in `dispatcher.ts`); backfill wake-ups enter as if just dispatched and never touch an owner already queued, so a page of them cannot delay a file write by several passes.
7575

7676
Generated document indexing never executes source. A cached artifact must match its owner and current input manifest. Each published build stores at most 500 input identities. Queries exclude a build when any input is changed, archived, deleted, or outside the owner, and report it as pending coverage. Input mutations wake their owner queue; the dispatcher retires stale manifests in bounded batches before rebuilding. Expired build cleanup also removes its dependency records. A cache miss indexes the stored generation source as text until an authorized rendering path creates a current artifact.

‎apps/sim/lib/workspace-files/search/artifact-ready.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -162,12 +162,13 @@ export async function markFileSearchArtifactReadyInTx(
162162
},
163163
setWhere: predicate,
164164
})
165+
// The dispatcher stamps queue rows with the transaction clock and claims rows touched since.
165166
await tx
166167
.insert(fileSearchDispatchQueue)
167-
.values({ ...owner, enqueuedAt: now, updatedAt: now })
168+
.values({ ...owner, enqueuedAt: now, updatedAt: sql`now()` })
168169
.onConflictDoUpdate({
169170
target: [fileSearchDispatchQueue.entityType, fileSearchDispatchQueue.entityId],
170-
set: { updatedAt: now },
171+
set: { updatedAt: sql`now()` },
171172
})
172173
return true
173174
}

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

Lines changed: 48 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -408,33 +408,66 @@ describe('workspace file search dispatch PostgreSQL deadlines', () => {
408408
})
409409
}
410410

411-
it('serves an owner woken by a file write ahead of a backfill page of reconciliation wake-ups', async () => {
412-
const wakeUps = FILE_SEARCH_INDEX_DISPATCH_WORKSPACES * 2 + 50
413-
await seedIndexedOwners('reconciled-', wakeUps)
414-
const first = await prepareWorkspaceFileSearchDispatch()
415-
expect(first.backfilledFiles).toBe(wakeUps)
416-
expect(first.payloads).toEqual([])
411+
/** Fills global capacity with claims of an unqueued owner, leaving `slots` free. */
412+
async function leaveSlots(slots: number) {
413+
await seedQueue('workspace-active', 0, FILE_SEARCH_INDEX_MAX_OUTSTANDING - slots)
414+
await connection`DELETE FROM file_search_dispatch_queue WHERE entity_id = 'workspace-active'`
415+
}
417416

418-
await writeFile('written-workspace')
419-
const next = await prepareWorkspaceFileSearchDispatch()
420-
expect(next.payloads.map((payload) => payload.fileId)).toEqual(['written-workspace-written'])
417+
const wakeUps = FILE_SEARCH_INDEX_DISPATCH_WORKSPACES * 2 + 50
418+
it.each([
419+
['an owner the page did not reach', 'written-workspace', 0],
420+
['an owner the page left queued', `reconciled-${String(wakeUps).padStart(4, '0')}`, 1],
421+
])(
422+
'serves a file write to %s ahead of a backfill page of reconciliation wake-ups',
423+
async (_, owner, queuedRows) => {
424+
await seedIndexedOwners('reconciled-', wakeUps)
425+
const first = await prepareWorkspaceFileSearchDispatch()
426+
expect(first.backfilledFiles).toBe(wakeUps)
427+
expect(first.payloads).toEqual([])
428+
// A queued owner's write takes the conflict path; an unqueued owner's takes the insert path.
429+
const [queued] = await connection`SELECT count(*)::int AS count
430+
FROM file_search_dispatch_queue WHERE entity_id = ${owner}`
431+
expect(queued.count).toBe(queuedRows)
432+
433+
await writeFile(owner)
434+
const next = await prepareWorkspaceFileSearchDispatch()
435+
expect(next.payloads.map((payload) => payload.fileId)).toEqual([`${owner}-written`])
436+
}
437+
)
438+
439+
it('keeps a queued reconciliation wake-up behind woken work when the backfill passes it again', async () => {
440+
await leaveSlots(1)
441+
// Queued by an earlier page, with nothing to claim; its stamp is older than the woken owner's.
442+
await seedIndexedOwners('reconciled-', 1)
443+
await connection`INSERT INTO file_search_dispatch_queue
444+
(entity_type, entity_id, enqueued_at, updated_at, last_dispatched_at)
445+
VALUES ('workspace', 'reconciled-0001', now() - interval '2 hours',
446+
now() - interval '2 hours', now() - interval '2 hours')`
447+
await seedQueue('woken-workspace', 1)
448+
await connection`UPDATE file_search_dispatch_queue
449+
SET last_dispatched_at = now() - interval '1 hour' WHERE entity_id = 'woken-workspace'`
450+
await connection`UPDATE workspace_file_search_backfill SET completed_at = NULL`
451+
452+
const result = await prepareWorkspaceFileSearchDispatch()
453+
expect(result.backfilledFiles).toBeGreaterThan(0)
454+
expect(result.payloads.map((payload) => payload.fileId)).toEqual(['woken-workspace-000001'])
421455
})
422456

423457
it('serves a fresh write before work the backfill found, then the backfill work', async () => {
424-
await seedQueue('workspace-active', 0, FILE_SEARCH_INDEX_MAX_OUTSTANDING)
425-
await connection`DELETE FROM file_search_dispatch_queue WHERE entity_id = 'workspace-active'`
458+
await leaveSlots(0)
426459
await connection`UPDATE workspace_file_search_backfill SET completed_at = NULL`
427460
await connection`INSERT INTO workspace_files (id, workspace_id, context, content_updated_at)
428461
VALUES ('backfill-found-file', 'backfill-found', 'workspace', '2026-09-16')`
429-
/** No capacity: the page seeds and queues the unindexed file's owner but claims nothing. */
462+
// No capacity: the page seeds and queues the unindexed file's owner but claims nothing.
430463
const seeded = await prepareWorkspaceFileSearchDispatch()
431464
expect(seeded.backfilledFiles).toBeGreaterThan(0)
432465
expect(seeded.payloads).toEqual([])
433466

434467
await writeFile('fresh-write')
435468
const served: string[] = []
436469
for (let pass = 0; pass < 2; pass++) {
437-
/** Free exactly one slot, so each pass claims for one owner. */
470+
// Free exactly one slot, so each pass claims for one owner.
438471
await connection`UPDATE workspace_file_search_revision SET status = 'ready'
439472
WHERE file_id = (SELECT file_id FROM workspace_file_search_revision
440473
WHERE status = 'pending' AND dispatched_at IS NOT NULL ORDER BY file_id LIMIT 1)`
@@ -445,9 +478,8 @@ describe('workspace file search dispatch PostgreSQL deadlines', () => {
445478
})
446479

447480
it('leaves an owner queued by a file write at the front when a backfill page passes it', async () => {
448-
await seedQueue('workspace-active', 0, FILE_SEARCH_INDEX_MAX_OUTSTANDING - 1)
449-
await connection`DELETE FROM file_search_dispatch_queue WHERE entity_id = 'workspace-active'`
450-
/** Queued first and served an hour ago: it wins the single slot only if the write is demoted. */
481+
await leaveSlots(1)
482+
// Queued first and served an hour ago: it wins the single slot only if the write is demoted.
451483
await seedQueue('earlier-served', 1)
452484
await connection`UPDATE file_search_dispatch_queue
453485
SET last_dispatched_at = now() - interval '1 hour' WHERE entity_id = 'earlier-served'`

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

Lines changed: 31 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -171,11 +171,25 @@ function queuedOwnerFilter(owners: readonly EditableFileOwner[]): SQL | undefine
171171
return or(...owners.map(queuedOwner))
172172
}
173173

174+
/**
175+
* The transaction's start time. Queue rows compare `updated_at` with `last_dispatched_at`, and
176+
* the `workspace_file_search_mark_pending` trigger writes `updated_at` the same way, so both sides
177+
* share one clock.
178+
*/
179+
const transactionNow = sql`now()`
180+
181+
/**
182+
* Whether an owner was woken after its last dispatch: never dispatched, or touched since. A file
183+
* write, a released claim, and a reaped claim advance `updated_at`; a claim stamps both columns
184+
* with one time, and a reconciliation wake-up enters with them equal. A write whose transaction
185+
* began before the claim that stamped its owner reads as earlier and waits one rotation.
186+
*/
187+
const wokenSinceDispatch = sql`(${fileSearchDispatchQueue.lastDispatchedAt} IS NULL OR ${fileSearchDispatchQueue.updatedAt} > ${fileSearchDispatchQueue.lastDispatchedAt})`
188+
174189
/**
175190
* Queues owners for the claim pass. A `reconciliation` wake-up, from the backfill walk, enters as
176-
* if just dispatched: claims take owners never dispatched first, so marking it served keeps a page
177-
* of owners with nothing new behind the owners the file trigger woke for a write, and its row still
178-
* rotates in by dispatch age. An owner already queued keeps its place either way.
191+
* if just dispatched and never touches an owner already queued, so a page of owners with nothing
192+
* new rotates in behind the owners woken for work.
179193
*/
180194
async function enqueueOwners(
181195
tx: DbTransaction,
@@ -185,20 +199,18 @@ async function enqueueOwners(
185199
): Promise<void> {
186200
const unique = [...new Map(owners.map((owner) => [JSON.stringify(owner), owner])).values()]
187201
if (unique.length === 0) return
188-
await tx
189-
.insert(fileSearchDispatchQueue)
190-
.values(
191-
unique.map((owner) => ({
192-
...owner,
193-
enqueuedAt: now,
194-
updatedAt: now,
195-
lastDispatchedAt: reconciliation ? now : null,
196-
}))
197-
)
198-
.onConflictDoUpdate({
199-
target: [fileSearchDispatchQueue.entityType, fileSearchDispatchQueue.entityId],
200-
set: { updatedAt: now },
201-
})
202+
const target = [fileSearchDispatchQueue.entityType, fileSearchDispatchQueue.entityId]
203+
const insert = tx.insert(fileSearchDispatchQueue).values(
204+
unique.map((owner) => ({
205+
...owner,
206+
enqueuedAt: now,
207+
updatedAt: transactionNow,
208+
lastDispatchedAt: reconciliation ? transactionNow : null,
209+
}))
210+
)
211+
await (reconciliation
212+
? insert.onConflictDoNothing({ target })
213+
: insert.onConflictDoUpdate({ target, set: { updatedAt: transactionNow } }))
202214
}
203215

204216
/**
@@ -675,6 +687,7 @@ export async function prepareWorkspaceFileSearchDispatch(
675687
})
676688
.from(fileSearchDispatchQueue)
677689
.orderBy(
690+
sql`${wokenSinceDispatch} DESC`,
678691
sql`${fileSearchDispatchQueue.lastDispatchedAt} ASC NULLS FIRST`,
679692
asc(fileSearchDispatchQueue.enqueuedAt),
680693
asc(fileSearchDispatchQueue.entityType),
@@ -687,7 +700,7 @@ export async function prepareWorkspaceFileSearchDispatch(
687700
// Rotate busy owners too; otherwise a full locked page starves every later owner.
688701
await tx
689702
.update(fileSearchDispatchQueue)
690-
.set({ lastDispatchedAt: now, updatedAt: now })
703+
.set({ lastDispatchedAt: transactionNow, updatedAt: transactionNow })
691704
.where(queuedOwnerFilter(candidates))
692705
}
693706
const owners = await lockFileSearchOwners(tx, candidates, { skipBusy: true })

0 commit comments

Comments
 (0)