Skip to content

Commit 6a07fbe

Browse files
committed
fix(file-search): bound admission waits and propagate cancellation
1 parent 39595a2 commit 6a07fbe

7 files changed

Lines changed: 413 additions & 168 deletions

File tree

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
/** @vitest-environment node */
2+
import { beforeEach, describe, expect, it, vi } from 'vitest'
3+
4+
const mocks = vi.hoisted(() => ({
5+
load: vi.fn(),
6+
permission: vi.fn(),
7+
search: vi.fn(),
8+
folders: vi.fn(),
9+
}))
10+
vi.mock('@sim/platform-authz/workspace', () => ({
11+
permissionSatisfies: (actual: string | null) => actual !== null,
12+
resolveEffectiveWorkspacePermission: mocks.permission,
13+
}))
14+
vi.mock('@/lib/uploads/contexts/workspace', () => ({ loadActiveWorkspaceContext: mocks.load }))
15+
vi.mock('@/lib/workspace-files/search/repository', () => ({
16+
searchWorkspaceFileIndex: mocks.search,
17+
}))
18+
vi.mock('@/lib/workspace-files/resolve-folder-scope', () => ({
19+
resolveWorkspaceFolderScope: mocks.folders,
20+
}))
21+
22+
import { searchWorkspaceFileContent } from '@/lib/workspace-files/application/search-workspace-file-content'
23+
24+
const principal = { kind: 'session', userId: 'user-1', sessionId: 'session-1' } as const
25+
const input = {
26+
workspaceId: 'workspace-1',
27+
query: 'needle',
28+
mode: 'exact',
29+
maxResults: 10,
30+
} as const
31+
32+
describe('searchWorkspaceFileContent cancellation', () => {
33+
beforeEach(() => {
34+
vi.clearAllMocks()
35+
mocks.load.mockResolvedValue({
36+
workspaceId: 'workspace-1',
37+
workspaceOrganizationId: null,
38+
allowPersonalApiKeys: true,
39+
billedAccountUserId: 'user-1',
40+
})
41+
mocks.permission.mockResolvedValue('read')
42+
mocks.search.mockResolvedValue({ results: [] })
43+
})
44+
45+
it.each(['request', 'input'] as const)(
46+
'propagates the %s signal through the authorized application operation',
47+
async (source) => {
48+
const controller = new AbortController()
49+
await searchWorkspaceFileContent.execute({
50+
principal,
51+
input: { ...input, ...(source === 'input' ? { signal: controller.signal } : {}) },
52+
request: {
53+
headers: new Headers(),
54+
...(source === 'request' ? { signal: controller.signal } : {}),
55+
},
56+
})
57+
expect(mocks.search).toHaveBeenCalledWith(
58+
expect.objectContaining({ signal: controller.signal })
59+
)
60+
}
61+
)
62+
63+
it('does not resolve folders or enqueue database work for a cancelled HTTP request', async () => {
64+
const signal = AbortSignal.abort(new Error('cancelled'))
65+
await expect(
66+
searchWorkspaceFileContent.execute({
67+
principal,
68+
input: { ...input, folderPaths: ['/notes'] },
69+
request: { headers: new Headers(), signal },
70+
})
71+
).rejects.toBe(signal.reason)
72+
expect(mocks.folders).not.toHaveBeenCalled()
73+
expect(mocks.search).not.toHaveBeenCalled()
74+
})
75+
})

apps/sim/lib/workspace-files/application/search-workspace-file-content.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,14 +35,16 @@ export const searchWorkspaceFileContent = defineAuthorizedWorkspaceFileUseCase({
3535
operation: fileOperations.searchContent,
3636
resolveContext: ({ input }: { input: SearchWorkspaceFileContentInput }) =>
3737
resolveSearchWorkspaceFileContext(input),
38-
execute: async ({ principal, input, context }) => {
39-
/*
38+
execute: async ({ principal, input, context, request }) => {
39+
const signal = input.signal ?? request?.signal
40+
signal?.throwIfAborted()
41+
/**
4042
* Resolved here rather than at the surface so every caller (the File
4143
* block, the v2 route) is confined by the same check. A folder tree
4244
* holding one subtree per user makes this scope the isolation boundary,
4345
* not a convenience filter.
4446
*/
45-
/*
47+
/**
4648
* `!== undefined`, not a length check: an explicitly empty list is a scope
4749
* that names no folder, which must match nothing. Treating it as "absent"
4850
* would answer a request for nothing with the whole workspace.
@@ -56,15 +58,15 @@ export const searchWorkspaceFileContent = defineAuthorizedWorkspaceFileUseCase({
5658
includeSubfolders: input.includeSubfolders,
5759
})
5860
: undefined
59-
input.signal?.throwIfAborted()
61+
signal?.throwIfAborted()
6062

6163
try {
6264
return await searchWorkspaceFileIndex({
6365
workspaceId: context.workspaceId,
6466
pattern: compileFileSearchPattern(input.query, input.mode),
6567
maxResults: input.maxResults,
6668
folderScope,
67-
signal: input.signal,
69+
signal,
6870
})
6971
} catch (error) {
7072
/**

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ Search joins the current file revision and resolved workspace/folder scope. A re
2222

2323
For regular chunks, PostgreSQL checks the pattern with newline-aware semantics, then verifies individual logical lines. Long-line fragments use only necessary three-character literals as a conservative prefilter, including all required alternation branches. Two-code-point overlap preserves those literals at every boundary. PostgreSQL reconstructs the complete candidate line and evaluates the original regex, so anchors, word boundaries, repetitions, and arbitrarily long match spans retain line semantics. Fixed overlap alone is never treated as proof of a match. The supported regex grammar and minimum literal requirement are unchanged.
2424

25-
Regular blocks are verified in batches of at most 16 (128 KiB of indexed text); long lines are reconstructed one at a time. Only bounded match-centered previews leave PostgreSQL: at most 201 rows to detect truncation, and at most 2 KiB per rendered result. A single search has a ten-second application deadline with per-statement guards. PostgreSQL 17 additionally enforces a total transaction timeout; PostgreSQL 16 uses the compatible idle-transaction guard. Transaction advisory locks admit at most 20 simultaneous searches per workspace and 5,000 globally per database. Search transactions use the dedicated `dbFor('search')` primary pool, with five connections per process, so they cannot occupy the application or execution client pools. Before acquiring a connection, a process admits five active searches and at most 100 waiting requests. Queued workspaces rotate after each grant; waiting requests expire after five seconds or leave immediately on cancellation. The local active budget comes from the same pool profile as the driver. A slot is released only when the transaction settles, including errors. The ten-second execution deadline starts after queueing; connecting to the database additionally uses the driver connection timeout. Busy and timed-out searches fail explicitly; they never report an incomplete scan as an authoritative empty result. These bounds apply identically to exact and regex search and do not change result or line-number semantics.
25+
Regular blocks are verified in batches of at most 16 (128 KiB of indexed text); long lines are reconstructed one at a time. Only bounded match-centered previews leave PostgreSQL: at most 201 rows to detect truncation, and at most 2 KiB per rendered result. A single search has a fifteen-second caller deadline covering queueing, connection acquisition, and execution; SQL runs for at most ten seconds within that budget, with per-statement guards. PostgreSQL 17 additionally enforces a total transaction timeout; PostgreSQL 16 uses the compatible idle-transaction guard. Transaction advisory locks admit at most 20 simultaneous searches per workspace and 5,000 globally per database. Search transactions use the dedicated `dbFor('search')` primary pool, with five connections per process, so they cannot occupy the application or execution client pools. Before acquiring a connection, a process admits five active searches and at most 100 waiting requests, capped at 20 waiting requests per workspace so one burst cannot fill the entire queue. Queued workspaces rotate after each grant; waiting requests expire after five seconds or leave immediately on cancellation. The local active budget comes from the same pool profile as the driver. A slot is released only when the transaction settles, including errors. Cancellation reaches this boundary from both HTTP requests and File-tool execution. The existing deadline helper ends the caller’s wait promptly even when connection acquisition stalls. A late transaction checks cancellation before running search SQL; its active slot remains reserved until the driver settles, so a timed-out request cannot start replacement work on top of a still-running query. The driver and upstream pooler still own physical connection cleanup; the application deadline does not cancel a queued PostgreSQL protocol command. Busy and timed-out searches fail explicitly; they never report an incomplete scan as an authoritative empty result. These bounds apply identically to exact and regex search and do not change result or line-number semantics.
2626

2727
The 20/workspace and 5,000/global advisory ceilings bound admitted transactions across processes; they are not promises of simultaneous execution or throughput. Local queues absorb short bursts without holding database connections. They are not durable jobs or a fleet-wide fair scheduler. The dedicated client pool isolates connection ownership, not PostgreSQL CPU, memory, I/O, or an upstream PgBouncer server pool. Its default URL is the process primary URL; any `DATABASE_URL_SEARCH` override must target the same primary database so current revisions and advisory admission remain coherent. Independent PgBouncer server budgets require separate database/user pool configuration. Total client connections can increase by five per participating process. Before raising execution capacity, measure the number of processes, backend pool budget, queue wait/rejection rates, search latency, and database resource headroom under representative exact and broad-regex workloads. More queueing cannot increase sustained throughput.
2828

apps/sim/lib/workspace-files/search/admission.test.ts

Lines changed: 82 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,13 @@ describe('FileSearchAdmission', () => {
77
beforeEach(() => vi.useFakeTimers())
88
afterEach(() => vi.useRealTimers())
99

10-
function createAdmission(concurrency = 1, maxPending = 3) {
11-
return new FileSearchAdmission({ concurrency, maxPending, timeoutMs: 5000 })
10+
function createAdmission(concurrency = 1, maxPending = 3, maxPendingPerWorkspace = maxPending) {
11+
return new FileSearchAdmission({
12+
concurrency,
13+
maxPending,
14+
maxPendingPerWorkspace,
15+
timeoutMs: 5000,
16+
})
1217
}
1318

1419
it('queues a burst without exceeding the active budget and releases each slot once', async () => {
@@ -129,4 +134,79 @@ describe('FileSearchAdmission', () => {
129134
first()
130135
await next
131136
})
137+
it('leaves waiting capacity for another workspace when one burst hits its own cap', async () => {
138+
const admission = createAdmission(1, 4, 2)
139+
const release = await admission.acquire('hot')
140+
const hot = [admission.acquire('hot'), admission.acquire('hot')]
141+
await expect(admission.acquire('hot')).rejects.toBeInstanceOf(
142+
WorkspaceFileSearchUnavailableError
143+
)
144+
const quiet = admission.acquire('quiet')
145+
release()
146+
;(await hot[0])()
147+
;(await quiet)()
148+
;(await hot[1])()
149+
;(await admission.acquire('hot'))()
150+
expect(vi.getTimerCount()).toBe(0)
151+
})
152+
153+
it.each(['timeout', 'abort'] as const)(
154+
'keeps a stalled operation counted after caller %s until the operation settles',
155+
async (reason) => {
156+
const admission = createAdmission()
157+
const controller = new AbortController()
158+
const dependency = Promise.withResolvers<void>()
159+
const lateWork = vi.fn()
160+
const running = admission.run(
161+
'a',
162+
async (signal) => {
163+
await dependency.promise
164+
signal.throwIfAborted()
165+
lateWork()
166+
},
167+
controller.signal
168+
)
169+
const rejected = expect(running).rejects.toThrow(
170+
reason === 'timeout' ? /timed out/ : /cancelled/
171+
)
172+
await vi.advanceTimersByTimeAsync(1)
173+
if (reason === 'timeout') await vi.advanceTimersByTimeAsync(15000)
174+
else controller.abort(new Error('cancelled'))
175+
await rejected
176+
const nextWork = vi.fn().mockResolvedValue('done')
177+
const next = admission.run('b', nextWork)
178+
await vi.advanceTimersByTimeAsync(1)
179+
expect(nextWork).not.toHaveBeenCalled()
180+
dependency.resolve()
181+
await expect(next).resolves.toBe('done')
182+
expect(lateWork).not.toHaveBeenCalled()
183+
expect(vi.getTimerCount()).toBe(0)
184+
}
185+
)
186+
187+
it('releases a lease after an operation throws synchronously', async () => {
188+
const admission = createAdmission()
189+
await expect(
190+
admission.run('a', () => {
191+
throw new Error('operation failed')
192+
})
193+
).rejects.toThrow('operation failed')
194+
await expect(admission.run('b', async () => 'done')).resolves.toBe('done')
195+
expect(vi.getTimerCount()).toBe(0)
196+
})
197+
198+
it('does not start an operation cancelled while waiting', async () => {
199+
const admission = createAdmission()
200+
const release = await admission.acquire('a')
201+
const controller = new AbortController()
202+
const operation = vi.fn()
203+
const waiting = expect(admission.run('b', operation, controller.signal)).rejects.toThrow(
204+
'cancelled'
205+
)
206+
controller.abort(new Error('cancelled'))
207+
await waiting
208+
release()
209+
expect(operation).not.toHaveBeenCalled()
210+
expect(vi.getTimerCount()).toBe(0)
211+
})
132212
})

apps/sim/lib/workspace-files/search/admission.ts

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import { DB_POOL_PROFILES } from '@sim/db/pool-profiles'
2+
import { DeadlineExceededError, withinDeadline } from '@/lib/core/utils/deadline'
23
import {
4+
FILE_SEARCH_QUERY_WORKSPACE_CONCURRENCY,
35
FILE_SEARCH_QUEUE_MAX_PENDING,
46
FILE_SEARCH_QUEUE_TIMEOUT_MS,
7+
FILE_SEARCH_STATEMENT_TIMEOUT_MS,
58
} from '@/lib/workspace-files/search/constants'
69
import { WorkspaceFileSearchUnavailableError } from '@/lib/workspace-files/search/errors'
710

@@ -19,13 +22,56 @@ export class FileSearchAdmission {
1922
private readonly workspaces = new Map<string, Set<Waiter>>()
2023

2124
constructor(
22-
private readonly options: { concurrency: number; maxPending: number; timeoutMs: number }
25+
private readonly options: {
26+
concurrency: number
27+
maxPending: number
28+
maxPendingPerWorkspace: number
29+
timeoutMs: number
30+
}
2331
) {}
2432

33+
/**
34+
* One caller deadline includes queueing, connection acquisition, and execution.
35+
* The lease belongs to the underlying operation, even if its caller stops waiting.
36+
*/
37+
async run<T>(
38+
workspaceId: string,
39+
operation: (signal: AbortSignal, deadlineAt: number) => Promise<T>,
40+
signal?: AbortSignal
41+
): Promise<T> {
42+
const deadlineAt = Date.now() + this.options.timeoutMs + FILE_SEARCH_STATEMENT_TIMEOUT_MS
43+
try {
44+
return await withinDeadline(
45+
async (operationSignal) => {
46+
const release = await this.acquire(workspaceId, operationSignal)
47+
try {
48+
operationSignal.throwIfAborted()
49+
return await operation(operationSignal, deadlineAt)
50+
} finally {
51+
release()
52+
}
53+
},
54+
deadlineAt,
55+
signal
56+
)
57+
} catch (error) {
58+
signal?.throwIfAborted()
59+
if (error instanceof DeadlineExceededError) {
60+
throw new WorkspaceFileSearchUnavailableError(
61+
'Workspace file search timed out. Retry shortly.'
62+
)
63+
}
64+
throw error
65+
}
66+
}
67+
2568
async acquire(workspaceId: string, signal?: AbortSignal): Promise<() => void> {
2669
signal?.throwIfAborted()
2770
if (this.active < this.options.concurrency) return this.claim()
28-
if (this.pending >= this.options.maxPending) {
71+
if (
72+
this.pending >= this.options.maxPending ||
73+
(this.workspaces.get(workspaceId)?.size ?? 0) >= this.options.maxPendingPerWorkspace
74+
) {
2975
throw new WorkspaceFileSearchUnavailableError('Workspace file search is busy. Retry shortly.')
3076
}
3177

@@ -92,5 +138,6 @@ export class FileSearchAdmission {
92138
export const fileSearchAdmission = new FileSearchAdmission({
93139
concurrency: DB_POOL_PROFILES.search.primaryMax,
94140
maxPending: FILE_SEARCH_QUEUE_MAX_PENDING,
141+
maxPendingPerWorkspace: FILE_SEARCH_QUERY_WORKSPACE_CONCURRENCY,
95142
timeoutMs: FILE_SEARCH_QUEUE_TIMEOUT_MS,
96143
})

apps/sim/lib/workspace-files/search/repository.test.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
*/
44
import { db } from '@sim/db'
55
import { dbChainMockFns, resetDbChainMock } from '@sim/testing'
6-
import { beforeEach, describe, expect, it } from 'vitest'
6+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
77
import { WorkspaceFileSearchUnavailableError } from '@/lib/workspace-files/search/errors'
88
import {
99
compileFileSearchPattern,
@@ -22,6 +22,7 @@ function driverError(code: string): Error {
2222
}
2323

2424
describe('searchWorkspaceFileIndex fault mapping', () => {
25+
afterEach(() => vi.useRealTimers())
2526
beforeEach(() => {
2627
resetDbChainMock()
2728
})
@@ -96,4 +97,26 @@ describe('searchWorkspaceFileIndex fault mapping', () => {
9697
expect(guards).toContain('lock_timeout')
9798
expect(db.transaction).toHaveBeenCalled()
9899
})
100+
it('bounds the caller while BEGIN is stalled and does not run late search SQL', async () => {
101+
vi.useFakeTimers()
102+
const acquired = Promise.withResolvers<void>()
103+
let transactionFinished!: Promise<unknown>
104+
dbChainMockFns.transaction.mockImplementationOnce((callback) => {
105+
transactionFinished = acquired.promise.then(() => callback(db))
106+
return transactionFinished
107+
})
108+
const waiting = expect(
109+
searchWorkspaceFileIndex({
110+
workspaceId: 'workspace-1',
111+
pattern: compileFileSearchPattern('needle', 'exact'),
112+
maxResults: 50,
113+
})
114+
).rejects.toBeInstanceOf(WorkspaceFileSearchUnavailableError)
115+
await vi.advanceTimersByTimeAsync(15000)
116+
await waiting
117+
expect(dbChainMockFns.execute).not.toHaveBeenCalled()
118+
acquired.resolve()
119+
await expect(transactionFinished).rejects.toThrow('Operation deadline expired')
120+
expect(dbChainMockFns.execute).not.toHaveBeenCalled()
121+
})
99122
})

0 commit comments

Comments
 (0)