Skip to content

Commit e07e7ba

Browse files
authored
feat(knowledge): fill the projection source and ACL in shards (#8112)
1 parent b8d9902 commit e07e7ba

6 files changed

Lines changed: 386 additions & 33 deletions

File tree

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import { task, tasks } from '@trigger.dev/sdk'
22
import { resolveTriggerRegion } from '@/lib/core/async-jobs/region'
33
import {
4+
PROJECTION_SOURCE_ACL_BACKFILL_SHARDS,
45
PROJECTION_SOURCE_ACL_BACKFILL_TASK_ID,
56
type ProjectionSourceAclBackfillPayload,
7+
projectionSourceAclChainTag,
68
runProjectionSourceAclBackfill,
79
} from '@/lib/knowledge/search/projection-source-acl-backfill'
810

@@ -13,23 +15,30 @@ const RUN_BUDGET_MS = 60 * 60 * 1000
1315
* Trigger.dev wrapper around `runProjectionSourceAclBackfill`. A run fills unset rows for up to
1416
* {@link RUN_BUDGET_MS}, then triggers its continuation from the cursor it reached, so the whole
1517
* projection is filled across as many bounded runs as it takes. Retry-safe: every run writes only
16-
* rows still unset, so a retried or restarted run repeats no write. The queue admits one run at a
17-
* time, so two starts never fill the same pages against each other.
18+
* rows still unset, so a retried or restarted run repeats no write. A shard's continuation keeps
19+
* its shard, so a sliced fill stays sliced until every slice is done.
1820
*/
1921
export const projectionSourceAclBackfillTask = task({
2022
id: PROJECTION_SOURCE_ACL_BACKFILL_TASK_ID,
2123
machine: 'small-1x',
2224
retry: { maxAttempts: 3 },
25+
/**
26+
* One run per shard the id space may be sliced into. Shards fill disjoint ranges, so runs never
27+
* fill the same page against each other; an unsliced chain still runs one at a time because each
28+
* run triggers its continuation only as it ends.
29+
*/
2330
queue: {
2431
name: PROJECTION_SOURCE_ACL_BACKFILL_TASK_ID,
25-
concurrencyLimit: 1,
32+
concurrencyLimit: PROJECTION_SOURCE_ACL_BACKFILL_SHARDS,
2633
},
2734
run: async (payload: ProjectionSourceAclBackfillPayload) => {
2835
const cursor = await runProjectionSourceAclBackfill(payload, { budgetMs: RUN_BUDGET_MS })
2936
if (!cursor) return
3037
const continuation: ProjectionSourceAclBackfillPayload = { ...payload, cursor }
3138
await tasks.trigger(PROJECTION_SOURCE_ACL_BACKFILL_TASK_ID, continuation, {
3239
region: await resolveTriggerRegion(),
40+
/** The chain's tag rides on every continuation, so a start finds the chain wherever it is. */
41+
tags: [projectionSourceAclChainTag(payload.shard)],
3342
})
3443
},
3544
})

‎apps/sim/lib/knowledge/search/projection-source-acl-backfill.test.ts‎

Lines changed: 163 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,24 @@
33
*/
44
import { beforeEach, describe, expect, it, vi } from 'vitest'
55

6-
const { mockBackfill, mockEnd, mockPostgres, mockPrewarm, mockTasksTrigger } = vi.hoisted(() => ({
6+
const {
7+
mockBackfill,
8+
mockEnd,
9+
mockPostgres,
10+
mockPrewarm,
11+
mockRunsList,
12+
mockTasksTrigger,
13+
mockUnsafe,
14+
} = vi.hoisted(() => ({
715
mockBackfill: vi.fn(),
816
mockEnd: vi.fn(async () => undefined),
917
mockPostgres: vi.fn(),
1018
mockPrewarm: vi.fn(async () => []),
19+
mockRunsList: vi.fn(
20+
(_query: unknown): AsyncIterable<{ id: string; status: string }> => (async function* () {})()
21+
),
1122
mockTasksTrigger: vi.fn(async () => ({ id: 'run-1' })),
23+
mockUnsafe: vi.fn(async () => [{ unfilled: false }]),
1224
}))
1325

1426
vi.mock('@sim/db', () => ({ resolveDbUrl: () => 'postgres://localhost:5432/sim' }))
@@ -18,7 +30,10 @@ vi.mock('@sim/db/script-migrations/0021_embedding_search_connector', () => ({
1830
}))
1931
vi.mock('postgres', () => ({ default: mockPostgres }))
2032
vi.mock('@/lib/knowledge/search/prewarm', () => ({ prewarmSearchProjection: mockPrewarm }))
21-
vi.mock('@trigger.dev/sdk', () => ({ tasks: { trigger: mockTasksTrigger } }))
33+
vi.mock('@trigger.dev/sdk', () => ({
34+
runs: { list: mockRunsList },
35+
tasks: { trigger: mockTasksTrigger },
36+
}))
2237
vi.mock('@/lib/core/async-jobs/region', () => ({ resolveTriggerRegion: async () => 'us-east-1' }))
2338
vi.mock('@/lib/core/utils/background', () => ({
2439
runDetached: (_label: string, work: () => Promise<unknown>) => {
@@ -29,10 +44,11 @@ vi.mock('@/lib/core/utils/background', () => ({
2944
import {
3045
enqueueProjectionSourceAclBackfill,
3146
PROJECTION_PREWARM_BUDGET_MS,
47+
projectionSourceAclShardRange,
3248
runProjectionSourceAclBackfill,
3349
} from '@/lib/knowledge/search/projection-source-acl-backfill'
3450

35-
const connection = { end: mockEnd }
51+
const connection = { end: mockEnd, unsafe: mockUnsafe }
3652

3753
describe('runProjectionSourceAclBackfill', () => {
3854
beforeEach(() => {
@@ -60,8 +76,17 @@ describe('runProjectionSourceAclBackfill', () => {
6076
expect(mockEnd).toHaveBeenCalledTimes(1)
6177
})
6278

63-
it('warms the projections on the same connection once both are filled, before closing it', async () => {
79+
it('analyzes and warms the projections on the same connection once both are filled, before closing it', async () => {
6480
await runProjectionSourceAclBackfill({})
81+
/** A row whose document is gone is not the fill's to finish; the probe joins the document. */
82+
expect(
83+
mockUnsafe.mock.calls.some(([query]) =>
84+
String(query).includes('JOIN document d ON d.id = s.document_id WHERE s.acl IS NULL')
85+
)
86+
).toBe(true)
87+
expect(mockUnsafe.mock.calls.map(([query]) => query)).toEqual(
88+
expect.arrayContaining(['ANALYZE embedding_search', 'ANALYZE embedding_keyword_tin'])
89+
)
6590
expect(mockPrewarm).toHaveBeenCalledTimes(1)
6691
expect(mockPrewarm).toHaveBeenCalledWith(connection, { budgetMs: PROJECTION_PREWARM_BUDGET_MS })
6792
expect(mockPrewarm.mock.invocationCallOrder[0]).toBeLessThan(
@@ -101,11 +126,65 @@ describe('runProjectionSourceAclBackfill', () => {
101126
await expect(runProjectionSourceAclBackfill({})).rejects.toThrow('statement timeout')
102127
expect(mockEnd).toHaveBeenCalledTimes(1)
103128
})
129+
130+
it('fills only its shard of the id space in both projections', async () => {
131+
await runProjectionSourceAclBackfill({ shard: { index: 1, count: 4 } })
132+
for (const [, , options] of mockBackfill.mock.calls) {
133+
expect(options).toMatchObject({ afterId: '4', beforeId: '8' })
134+
}
135+
})
136+
137+
it('resumes a shard after its cursor and keeps its upper bound', async () => {
138+
await runProjectionSourceAclBackfill({
139+
shard: { index: 1, count: 4 },
140+
cursor: { projection: 'embedding_search', afterId: '5a' },
141+
})
142+
expect(mockBackfill.mock.calls[0][2]).toMatchObject({ afterId: '5a', beforeId: '8' })
143+
expect(mockBackfill.mock.calls[1][2]).toMatchObject({ afterId: '4', beforeId: '8' })
144+
})
145+
146+
it('leaves the analysis and the warm to whoever fills the rows another shard still holds', async () => {
147+
mockUnsafe.mockResolvedValueOnce([{ unfilled: true }])
148+
await expect(
149+
runProjectionSourceAclBackfill({ shard: { index: 0, count: 4 } })
150+
).resolves.toBeNull()
151+
expect(mockUnsafe.mock.calls.map(([query]) => query)).not.toContain('ANALYZE embedding_search')
152+
expect(mockPrewarm).not.toHaveBeenCalled()
153+
expect(mockEnd).toHaveBeenCalledTimes(1)
154+
})
155+
})
156+
157+
describe('projectionSourceAclShardRange', () => {
158+
it('slices the hex id space into contiguous ranges', () => {
159+
expect(projectionSourceAclShardRange({ index: 0, count: 4 })).toEqual({
160+
afterId: '',
161+
beforeId: '4',
162+
})
163+
expect(projectionSourceAclShardRange({ index: 3, count: 4 })).toEqual({
164+
afterId: 'c',
165+
beforeId: undefined,
166+
})
167+
expect(projectionSourceAclShardRange({ index: 0, count: 1 })).toEqual({
168+
afterId: '',
169+
beforeId: undefined,
170+
})
171+
})
172+
173+
it.each([
174+
[{ index: 0, count: 3 }, 'shard count must divide 16'],
175+
[{ index: 0, count: 8 }, 'shard count must be at most 4'],
176+
[{ index: 4, count: 4 }, 'shard index must be within 0..3'],
177+
[{ index: 0.5, count: 2 }, 'shard index must be within 0..1'],
178+
])('refuses %j', (shard, message) => {
179+
expect(() => projectionSourceAclShardRange(shard)).toThrow(message)
180+
})
104181
})
105182

106183
describe('enqueueProjectionSourceAclBackfill', () => {
107184
beforeEach(() => {
108185
vi.clearAllMocks()
186+
/** No chain in flight unless a case says so. */
187+
mockRunsList.mockImplementation(() => (async function* () {})())
109188
mockPostgres.mockReturnValue(connection)
110189
mockBackfill.mockResolvedValue({
111190
projection: 'embedding_search',
@@ -118,13 +197,91 @@ describe('enqueueProjectionSourceAclBackfill', () => {
118197

119198
it('hands the backfill to the Trigger.dev worker when one is configured', async () => {
120199
await expect(enqueueProjectionSourceAclBackfill({ pageSize: 25 })).resolves.toEqual({
121-
runId: 'run-1',
200+
runIds: ['run-1'],
201+
inFlight: [],
122202
})
203+
expect(mockRunsList).toHaveBeenCalledWith(
204+
expect.objectContaining({ tag: 'projection-source-acl-backfill:shard:0/1' })
205+
)
123206
expect(mockTasksTrigger).toHaveBeenCalledWith(
124207
'projection-source-acl-backfill',
125208
{ pageSize: 25 },
126-
{ region: 'us-east-1' }
209+
{
210+
region: 'us-east-1',
211+
tags: ['projection-source-acl-backfill:shard:0/1'],
212+
idempotencyKey: 'projection-source-acl-backfill:shard:0/1:after:none',
213+
idempotencyKeyTTL: '2m',
214+
}
127215
)
128216
expect(mockBackfill).not.toHaveBeenCalled()
129217
})
218+
219+
it('keys a start after a chain that ended on that chain, so a restart is its own start', async () => {
220+
mockRunsList.mockImplementation(() =>
221+
(async function* () {
222+
yield { id: 'run-done', status: 'COMPLETED' }
223+
})()
224+
)
225+
await expect(enqueueProjectionSourceAclBackfill({})).resolves.toEqual({
226+
runIds: ['run-1'],
227+
inFlight: [],
228+
})
229+
expect(mockTasksTrigger.mock.calls[0][2].idempotencyKey).toBe(
230+
'projection-source-acl-backfill:shard:0/1:after:run-done'
231+
)
232+
})
233+
234+
it('refuses a shard the id space cannot be sliced into before starting anything', async () => {
235+
await expect(
236+
enqueueProjectionSourceAclBackfill({ shard: { index: 5, count: 4 } })
237+
).rejects.toThrow('shard index must be within 0..3')
238+
expect(mockTasksTrigger).not.toHaveBeenCalled()
239+
})
240+
241+
it('leaves a range whose chain is still in flight to that chain', async () => {
242+
mockRunsList.mockImplementation((query: unknown) =>
243+
(async function* () {
244+
if ((query as { tag: string }).tag.endsWith(':shard:1/4'))
245+
yield { id: 'run-live', status: 'EXECUTING' }
246+
})()
247+
)
248+
await expect(enqueueProjectionSourceAclBackfill({}, 4)).resolves.toEqual({
249+
runIds: ['run-1', 'run-1', 'run-1'],
250+
inFlight: ['run-live'],
251+
})
252+
expect(mockTasksTrigger.mock.calls.map(([, payload]) => payload.shard?.index)).toEqual([
253+
0, 2, 3,
254+
])
255+
})
256+
257+
it('starts one run per shard, each on its own slice under its own chain tag', async () => {
258+
await expect(enqueueProjectionSourceAclBackfill({ pageSize: 25 }, 4)).resolves.toEqual({
259+
runIds: ['run-1', 'run-1', 'run-1', 'run-1'],
260+
inFlight: [],
261+
})
262+
expect(mockTasksTrigger.mock.calls.map(([, payload]) => payload)).toEqual(
263+
[0, 1, 2, 3].map((index) => ({ pageSize: 25, shard: { index, count: 4 } }))
264+
)
265+
expect(mockTasksTrigger.mock.calls.map(([, , options]) => options.tags)).toEqual(
266+
[0, 1, 2, 3].map((index) => [`projection-source-acl-backfill:shard:${index}/4`])
267+
)
268+
})
269+
270+
it.each([
271+
[3, 'must divide 16'],
272+
[8, 'must be at most 4'],
273+
])('refuses %s shards before starting anything', async (shards, message) => {
274+
await expect(enqueueProjectionSourceAclBackfill({}, shards)).rejects.toThrow(message)
275+
expect(mockTasksTrigger).not.toHaveBeenCalled()
276+
})
277+
278+
it('refuses to slice a start that carries a cursor, which belongs to one chain', async () => {
279+
await expect(
280+
enqueueProjectionSourceAclBackfill(
281+
{ cursor: { projection: 'embedding_search', afterId: '5a' } },
282+
4
283+
)
284+
).rejects.toThrow('cannot start from a cursor')
285+
expect(mockTasksTrigger).not.toHaveBeenCalled()
286+
})
130287
})

0 commit comments

Comments
 (0)