Skip to content

Commit 0509b49

Browse files
committed
test(cron): assert scheduled-pass outcomes through a deduplicating Trigger fake; plan test on production indexes
1 parent e7892cd commit 0509b49

4 files changed

Lines changed: 108 additions & 62 deletions

File tree

‎apps/sim/lib/core/outbox/enqueue.test.ts‎

Lines changed: 16 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import { asyncJobsRegionMock } from '@sim/testing/mocks/async-jobs-region.mock'
22
import { setEnvFlags } from '@sim/testing/mocks/env-flags.mock'
33
import { outboxServiceMock, outboxServiceMockFns } from '@sim/testing/mocks/outbox-service.mock'
4+
import {
5+
createIdempotentTasksTrigger,
6+
triggerSdkMockFns,
7+
} from '@sim/testing/mocks/trigger-sdk.mock'
48
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
59

610
const hoisted = vi.hoisted(() => ({
@@ -10,12 +14,11 @@ vi.mock('@/lib/core/async-jobs/region', () => asyncJobsRegionMock)
1014
vi.mock('@/lib/core/outbox/processor', () => ({ runOutboxProcessor: hoisted.processor }))
1115
vi.mock('@/lib/core/outbox/service', () => outboxServiceMock)
1216

13-
import { tasks } from '@trigger.dev/sdk'
1417
import { enqueueOutboxProcessor } from '@/lib/core/outbox/enqueue'
1518

1619
const mocks = {
1720
...hoisted,
18-
trigger: vi.mocked(tasks.trigger),
21+
trigger: triggerSdkMockFns.mockTasksTrigger,
1922
hasDueWork: outboxServiceMockFns.mockHasDueOutboxWork,
2023
}
2124

@@ -48,36 +51,28 @@ describe('outbox processor enqueue', () => {
4851
vi.useFakeTimers()
4952
vi.setSystemTime(IDLE_MINUTE)
5053
setEnvFlags({ isTriggerDevEnabled: true })
51-
mocks.trigger.mockResolvedValue({ id: 'run-1' })
54+
mocks.trigger.mockImplementation(createIdempotentTasksTrigger())
5255
mocks.processor.mockReset()
5356
mocks.processor.mockResolvedValue(INLINE_OUTPUT)
5457
mocks.hasDueWork.mockReset()
5558
mocks.hasDueWork.mockResolvedValue(true)
5659
})
5760
afterEach(() => vi.useRealTimers())
5861

59-
it('deduplicates duplicate ticks while allowing the next minute to drain more work', async () => {
60-
await enqueueOutboxProcessor()
61-
await enqueueOutboxProcessor()
62-
vi.advanceTimersByTime(60_000)
63-
await enqueueOutboxProcessor()
64-
const keys = mocks.trigger.mock.calls.map((call) => call[2].idempotencyKey)
65-
expect(keys[0]).toBe(keys[1])
66-
expect(keys[2]).not.toBe(keys[0])
67-
})
68-
69-
it('keys the run by the window its tick was gated in when the work check outlasts it', async () => {
62+
it('folds ticks into the run of the minute their work check was made in', async () => {
63+
const first = await enqueueOutboxProcessor()
64+
const duplicate = await enqueueOutboxProcessor()
7065
mocks.hasDueWork.mockImplementationOnce(async () => {
7166
vi.advanceTimersByTime(60_000)
7267
return true
7368
})
74-
await enqueueOutboxProcessor()
75-
expect(mocks.trigger).toHaveBeenCalledWith('process-outbox', undefined, {
76-
idempotencyKey: 'process-outbox:29826034',
77-
idempotencyKeyTTL: '5m',
78-
maxDuration: 900,
79-
region: 'us-east-1',
80-
})
69+
const checkedAcrossTheBoundary = await enqueueOutboxProcessor()
70+
const nextMinute = await enqueueOutboxProcessor()
71+
72+
expect(first.jobId).toBe('run-1')
73+
expect(duplicate.jobId).toBe('run-1')
74+
expect(checkedAcrossTheBoundary.jobId).toBe('run-1')
75+
expect(nextMinute.jobId).toBe('run-2')
8176
})
8277

8378
it('fails closed on an enqueue error without starting concurrent inline work', async () => {

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

Lines changed: 43 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,6 @@ vi.mock('@/lib/workspace-files/search/indexing', () => ({
1818
indexWorkspaceFileForSearch: vi.fn(),
1919
markWorkspaceFileSearchIndexFailed: vi.fn(),
2020
}))
21-
vi.mock('@/lib/workspace-files/search/index-state', async (importOriginal) => ({
22-
...(await importOriginal<typeof import('@/lib/workspace-files/search/index-state')>()),
23-
cleanupFileSearchBuilds: vi.fn().mockResolvedValue(0),
24-
}))
2521
vi.mock('@trigger.dev/sdk', () => ({ tasks: { batchTrigger: mocks.batchTrigger } }))
2622
vi.mock('@/lib/core/config/env-flags', () => ({ isTriggerDevEnabled: true }))
2723
vi.mock('@/lib/core/async-jobs/region', () => ({ resolveTriggerRegion: async () => 'us-east-1' }))
@@ -39,6 +35,19 @@ import {
3935
prepareWorkspaceFileSearchDispatch,
4036
} from '@/lib/workspace-files/search/dispatcher'
4137

38+
interface QueryPlan {
39+
'Node Type': string
40+
'Relation Name'?: string
41+
'Index Name'?: string
42+
'Shared Hit Blocks': number
43+
'Shared Read Blocks': number
44+
Plans?: QueryPlan[]
45+
}
46+
47+
function planNodes(plan: QueryPlan): QueryPlan[] {
48+
return [plan, ...(plan.Plans ?? []).flatMap(planNodes)]
49+
}
50+
4251
describe('workspace file search dispatch PostgreSQL deadlines', () => {
4352
const schemaName = `dispatch_test_${generateId().replaceAll('-', '')}`
4453
const databaseUrl = readTestDatabaseUrl()
@@ -93,7 +102,8 @@ describe('workspace file search dispatch PostgreSQL deadlines', () => {
93102
ON workspace_file_search_revision
94103
(workspace_id, updated_at, file_id, source_content_updated_at)
95104
WHERE status = 'pending' AND dispatched_at IS NULL`
96-
await connection`CREATE INDEX ON workspace_file_search_revision (workspace_id, dispatched_at)
105+
await connection`CREATE INDEX workspace_file_search_revision_active_idx
106+
ON workspace_file_search_revision (dispatched_at, workspace_id)
97107
WHERE status = 'pending' AND dispatched_at IS NOT NULL`
98108
await connection`CREATE TABLE workspace_file_search_build (id text PRIMARY KEY, expires_at timestamp)`
99109
await connection`CREATE INDEX workspace_file_search_build_cleanup_idx
@@ -627,22 +637,39 @@ describe('workspace file search dispatch PostgreSQL deadlines', () => {
627637
await expect(hasWorkspaceFileSearchDispatchWork(new Date())).resolves.toBe(true)
628638
})
629639

630-
it('probes the cleanup and active-claim indexes rather than scanning', async () => {
640+
it('probes builds and claims through their indexes beside a large settled backlog', async () => {
631641
await seedIdleDeployment()
642+
await connection`INSERT INTO workspace_file_search_revision
643+
(file_id, workspace_id, source_content_updated_at, status)
644+
SELECT 'settled-' || n, 'workspace-' || (n % 50), '2026-09-16', 'ready'
645+
FROM generate_series(1, 100000) n`
646+
await connection`INSERT INTO workspace_file_search_build (id, expires_at)
647+
SELECT 'published-' || n, NULL FROM generate_series(1, 100000) n`
648+
await connection`VACUUM (ANALYZE) workspace_file_search_revision, workspace_file_search_build`
632649
statements.length = 0
633650
await expect(hasWorkspaceFileSearchDispatchWork(new Date())).resolves.toBe(false)
634651

635652
const probe = statements.find((statement) => statement.query.includes('"hasWork"'))
636653
expect(probe).toBeDefined()
637-
const plan = await connection.begin(async (tx) => {
638-
await tx`SET LOCAL enable_seqscan = off`
639-
const rows = await tx.unsafe(`EXPLAIN ${probe?.query}`, probe?.params as never[])
640-
return rows.map((row: Record<string, unknown>) => row['QUERY PLAN']).join('\n')
641-
})
642-
643-
expect(plan).toContain('workspace_file_search_build_cleanup_idx')
644-
expect(plan).toMatch(/using workspace_file_search_revision_workspace_id_dispatched_at_idx/)
645-
expect(plan).not.toMatch(/Seq Scan/)
646-
})
654+
const [explained] = await connection.unsafe(
655+
`EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) ${probe?.query}`,
656+
probe?.params as never[]
657+
)
658+
const plan = (explained['QUERY PLAN'] as { Plan: QueryPlan }[])[0].Plan
659+
const nodes = planNodes(plan)
660+
const indexes = nodes.map((node) => node['Index Name'])
661+
662+
expect(indexes).toContain('workspace_file_search_build_cleanup_idx')
663+
expect(indexes).toContain('workspace_file_search_revision_active_idx')
664+
expect(
665+
nodes.filter(
666+
(node) =>
667+
node['Node Type'] === 'Seq Scan' &&
668+
node['Relation Name'] !== 'workspace_file_search_dispatch_queue'
669+
)
670+
).toEqual([])
671+
/** Buffer work, unlike wall-clock time, catches a backlog scan even on a warm local database. */
672+
expect(plan['Shared Hit Blocks'] + plan['Shared Read Blocks']).toBeLessThan(100)
673+
}, 60_000)
647674
})
648675
})
Lines changed: 24 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,60 +1,61 @@
1-
import {
2-
asyncJobsRegionMock,
3-
asyncJobsRegionMockFns,
4-
} from '@sim/testing/mocks/async-jobs-region.mock'
5-
import { backgroundTaskMock, backgroundTaskMockFns } from '@sim/testing/mocks/background-task.mock'
1+
import { asyncJobsRegionMock } from '@sim/testing/mocks/async-jobs-region.mock'
2+
import { backgroundTaskMock } from '@sim/testing/mocks/background-task.mock'
63
import { resetEnvFlagsMock, setEnvFlags } from '@sim/testing/mocks/env-flags.mock'
4+
import {
5+
createIdempotentTasksTrigger,
6+
triggerSdkMockFns,
7+
} from '@sim/testing/mocks/trigger-sdk.mock'
78
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
89

910
const mocks = vi.hoisted(() => ({
10-
dispatch: vi.fn(),
1111
hasWork: vi.fn(),
1212
}))
1313

1414
vi.mock('@/lib/core/async-jobs/region', () => asyncJobsRegionMock)
1515
vi.mock('@/lib/core/utils/background', () => backgroundTaskMock)
1616
vi.mock('@/lib/workspace-files/search/dispatcher', () => ({
17-
dispatchWorkspaceFileSearchIndexJobs: mocks.dispatch,
17+
dispatchWorkspaceFileSearchIndexJobs: vi.fn(),
1818
hasWorkspaceFileSearchDispatchWork: mocks.hasWork,
1919
}))
2020

21-
import { tasks } from '@trigger.dev/sdk'
2221
import { enqueueWorkspaceFileSearchDispatch } from '@/lib/workspace-files/search/enqueue-dispatch'
2322

24-
const mockTrigger = vi.mocked(tasks.trigger)
25-
2623
afterAll(resetEnvFlagsMock)
2724

2825
describe('workspace file search dispatcher enqueue', () => {
2926
beforeEach(() => {
3027
vi.useFakeTimers()
3128
vi.setSystemTime(new Date('2026-08-29T12:34:45.000Z'))
3229
setEnvFlags({ isTriggerDevEnabled: true })
33-
asyncJobsRegionMockFns.mockResolveTriggerRegion.mockResolvedValue('us-east-1')
34-
mockTrigger.mockResolvedValue({ id: 'run-1' })
30+
triggerSdkMockFns.mockTasksTrigger.mockImplementation(createIdempotentTasksTrigger())
31+
mocks.hasWork.mockReset()
3532
mocks.hasWork.mockResolvedValue(true)
3633
})
3734

3835
afterEach(() => {
3936
vi.useRealTimers()
4037
})
4138

42-
it('waits only for durable Trigger.dev acceptance and does not run the dispatcher inline', async () => {
39+
it('returns the Trigger.dev run once it is accepted', async () => {
4340
await expect(enqueueWorkspaceFileSearchDispatch()).resolves.toEqual({
4441
triggered: true,
4542
backend: 'trigger-dev',
4643
jobId: 'run-1',
4744
})
45+
})
4846

49-
expect(mockTrigger).toHaveBeenCalledWith('workspace-file-search-dispatch', undefined, {
50-
idempotencyKey: 'workspace-file-search-dispatch:29800114',
51-
idempotencyKeyTTL: '5m',
52-
maxDuration: 60,
53-
region: 'us-east-1',
54-
ttl: '5m',
47+
it('folds ticks into the run of the window their work check was made in', async () => {
48+
const first = await enqueueWorkspaceFileSearchDispatch()
49+
mocks.hasWork.mockImplementationOnce(async () => {
50+
vi.advanceTimersByTime(60_000)
51+
return true
5552
})
56-
expect(mocks.dispatch).not.toHaveBeenCalled()
57-
expect(backgroundTaskMockFns.mockRunDetached).not.toHaveBeenCalled()
53+
const checkedAcrossTheBoundary = await enqueueWorkspaceFileSearchDispatch()
54+
const nextWindow = await enqueueWorkspaceFileSearchDispatch()
55+
56+
expect(first.jobId).toBe('run-1')
57+
expect(checkedAcrossTheBoundary.jobId).toBe('run-1')
58+
expect(nextWindow.jobId).toBe('run-2')
5859
})
5960

6061
it.each([
@@ -71,18 +72,16 @@ describe('workspace file search dispatcher enqueue', () => {
7172
backend: null,
7273
jobId: null,
7374
})
74-
expect(mockTrigger).not.toHaveBeenCalled()
75-
expect(backgroundTaskMockFns.mockRunDetached).not.toHaveBeenCalled()
7675
}
7776
)
7877

7978
it('still starts a dispatcher run when the work check fails', async () => {
8079
mocks.hasWork.mockRejectedValue(new Error('statement timeout'))
8180

82-
await expect(enqueueWorkspaceFileSearchDispatch()).resolves.toMatchObject({
81+
await expect(enqueueWorkspaceFileSearchDispatch()).resolves.toEqual({
8382
triggered: true,
8483
backend: 'trigger-dev',
84+
jobId: 'run-1',
8585
})
86-
expect(mockTrigger).toHaveBeenCalledTimes(1)
8786
})
8887
})

‎packages/testing/src/mocks/trigger-sdk.mock.ts‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,31 @@ function createMockTask<T extends object>(config: T) {
8383
}
8484
}
8585

86+
/**
87+
* A `tasks.trigger` implementation that deduplicates like Trigger.dev: a trigger whose task already
88+
* holds its `idempotencyKey` returns that run's id, and a new key (or no key) starts a new run
89+
* (`run-1`, `run-2`, …). Key TTLs are not modelled: a key is held for the fake's lifetime.
90+
*
91+
* @example
92+
* ```ts
93+
* triggerSdkMockFns.mockTasksTrigger.mockImplementation(createIdempotentTasksTrigger())
94+
* ```
95+
*/
96+
export function createIdempotentTasksTrigger() {
97+
const runIdByKey = new Map<string, string>()
98+
let runCount = 0
99+
return async (taskId: string, _payload?: unknown, options?: unknown): Promise<unknown> => {
100+
const key = (options as { idempotencyKey?: unknown } | undefined)?.idempotencyKey
101+
const scopedKey = typeof key === 'string' ? `${taskId}:${key}` : null
102+
const existing = scopedKey ? runIdByKey.get(scopedKey) : undefined
103+
if (existing) return { id: existing }
104+
runCount += 1
105+
const id = `run-${runCount}`
106+
if (scopedKey) runIdByKey.set(scopedKey, id)
107+
return { id }
108+
}
109+
}
110+
86111
/**
87112
* Controllable mock functions for `@trigger.dev/sdk` (globally mocked in `apps/sim/vitest.setup.ts`).
88113
*

0 commit comments

Comments
 (0)