Skip to content

Commit 39e3ad5

Browse files
authored
fix(appconfig): share the polled snapshot with Trigger.dev runs (#9053)
* fix(appconfig): share the polled snapshot with Trigger.dev runs Every Trigger.dev run starts a fresh process, and each new AppConfig session is billed for the full configuration it receives. Profiles that opt in (feature-flags, block-visibility, access-control) are now published to Redis by app processes (latest validatedAt wins), and a cold cache inside a Trigger.dev run adopts a snapshot under 120s old (tolerating 5s of clock skew) instead of opening a session. Missing, stale, unreadable, or unparseable snapshots fall back to polling as before. Trigger.dev runs never publish, and strict snapshots (outbound routing) never use the shared copy. * test(appconfig): guard the Redis URL, reuse flushMacrotask, await the older publish
1 parent 7ab2eef commit 39e3ad5

9 files changed

Lines changed: 376 additions & 9 deletions

File tree

‎apps/sim/lib/auth/access-control.test.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,8 @@ describe('getAccessControlConfig', () => {
9393
expect(result.blockedEmailMxHosts).toEqual([])
9494
expect(mockFetch).toHaveBeenCalledWith(
9595
{ application: 'sim-staging', environment: 'staging', profile: 'access-control' },
96-
expect.any(Function)
96+
expect.any(Function),
97+
{ shareWithTriggerRuns: true }
9798
)
9899
})
99100

‎apps/sim/lib/auth/access-control.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,8 @@ export async function getAccessControlConfig(): Promise<AccessControlConfig> {
100100
environment: env.APPCONFIG_ENVIRONMENT as string,
101101
profile: ACCESS_CONTROL_PROFILE,
102102
},
103-
parseConfig
103+
parseConfig,
104+
{ shareWithTriggerRuns: true }
104105
)
105106

106107
return value ?? fromEnv()
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
/**
2+
* The shared AppConfig snapshot against real Redis: a long-lived process publishes what it polled,
3+
* a fresh Trigger.dev run process adopts it without an AppConfig session, and an older snapshot
4+
* never replaces a newer one. Skipped without `TEST_REDIS_URL`.
5+
*/
6+
import { readTestRedisUrl } from '@sim/db/testing/test-infrastructure'
7+
import { redisConfigMock, redisConfigMockFns } from '@sim/testing/mocks/redis-config.mock'
8+
import { sleep } from '@sim/utils/helpers'
9+
import { generateId } from '@sim/utils/id'
10+
import Redis from 'ioredis'
11+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
12+
import {
13+
markInsideTriggerRun,
14+
resetInsideTriggerRunForTests,
15+
} from '@/lib/core/config/trigger-runtime'
16+
17+
const { mockSend } = vi.hoisted(() => ({ mockSend: vi.fn() }))
18+
19+
vi.mock('@/lib/core/config/redis', () => redisConfigMock)
20+
vi.mock('@aws-sdk/client-appconfigdata', () => ({
21+
AppConfigDataClient: class {
22+
send = mockSend
23+
},
24+
StartConfigurationSessionCommand: class {
25+
__type = 'start'
26+
},
27+
GetLatestConfigurationCommand: class {
28+
__type = 'get'
29+
},
30+
}))
31+
32+
const redisUrl = readTestRedisUrl()
33+
34+
/** Each import is a separate process's module-level cache. */
35+
async function freshProcess() {
36+
vi.resetModules()
37+
return import('@/lib/core/config/appconfig')
38+
}
39+
40+
function respondWith(revision: string) {
41+
mockSend.mockImplementation((command: { __type: string }) =>
42+
Promise.resolve(
43+
command.__type === 'start'
44+
? { InitialConfigurationToken: 'token' }
45+
: {
46+
Configuration: new TextEncoder().encode(JSON.stringify({ revision })),
47+
NextPollConfigurationToken: 'next',
48+
}
49+
)
50+
)
51+
}
52+
53+
describe.runIf(Boolean(redisUrl))('shared AppConfig snapshot with Redis', () => {
54+
let redis: Redis
55+
let ids: { application: string; environment: string; profile: string }
56+
const key = () => `appconfig:snapshot:v1:${ids.application}/${ids.environment}/${ids.profile}`
57+
58+
async function waitForPublished(revision: string) {
59+
for (let attempt = 0; attempt < 50; attempt++) {
60+
const stored = await redis.get(key())
61+
if (stored?.includes(revision)) return
62+
await sleep(20)
63+
}
64+
throw new Error(`snapshot ${revision} was never published`)
65+
}
66+
67+
beforeAll(async () => {
68+
if (!redisUrl) throw new Error('TEST_REDIS_URL is required for this suite')
69+
redis = new Redis(redisUrl, { lazyConnect: true, maxRetriesPerRequest: 0 })
70+
await redis.connect()
71+
})
72+
73+
beforeEach(() => {
74+
ids = { application: generateId(), environment: 'integration', profile: 'feature-flags' }
75+
redisConfigMockFns.mockGetRedisClient.mockReturnValue(redis)
76+
})
77+
78+
afterEach(async () => {
79+
resetInsideTriggerRunForTests()
80+
await redis.del(key())
81+
})
82+
83+
afterAll(async () => {
84+
await redis?.quit()
85+
})
86+
87+
it('lets a Trigger.dev run adopt what an app process polled, with no AppConfig session', async () => {
88+
respondWith('polled')
89+
const app = await freshProcess()
90+
expect(
91+
await app.fetchAppConfigProfile(ids, (json) => json, { shareWithTriggerRuns: true })
92+
).toEqual({ revision: 'polled' })
93+
await waitForPublished('polled')
94+
95+
mockSend.mockClear()
96+
markInsideTriggerRun()
97+
const run = await freshProcess()
98+
expect(
99+
await run.fetchAppConfigProfile(ids, (json) => json, { shareWithTriggerRuns: true })
100+
).toEqual({ revision: 'polled' })
101+
expect(mockSend).not.toHaveBeenCalled()
102+
})
103+
104+
it('keeps a newer snapshot when a process that polled earlier publishes', async () => {
105+
const newer = { raw: JSON.stringify({ revision: 'newer' }), validatedAt: Date.now() + 60_000 }
106+
await redis.set(key(), JSON.stringify(newer))
107+
108+
respondWith('older')
109+
const publish = vi.spyOn(redis, 'eval')
110+
const app = await freshProcess()
111+
expect(
112+
await app.fetchAppConfigProfile(ids, (json) => json, { shareWithTriggerRuns: true })
113+
).toEqual({ revision: 'older' })
114+
await vi.waitFor(() => expect(publish).toHaveBeenCalledTimes(1))
115+
expect(await publish.mock.results[0]?.value).toBe(0)
116+
117+
expect(JSON.parse((await redis.get(key())) ?? 'null')).toEqual(newer)
118+
})
119+
})

‎apps/sim/lib/core/config/appconfig.test.ts‎

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import { flushMacrotask } from '@sim/testing/helpers/async'
2+
import { redisConfigMockFns } from '@sim/testing/mocks/redis-config.mock'
3+
import { toRecord } from '@sim/utils/object'
14
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
25

36
const { mockSend } = vi.hoisted(() => ({
@@ -19,6 +22,10 @@ vi.mock('@aws-sdk/client-appconfigdata', () => ({
1922
}))
2023

2124
import { fetchAppConfigProfile, fetchAppConfigSnapshot } from '@/lib/core/config/appconfig'
25+
import {
26+
markInsideTriggerRun,
27+
resetInsideTriggerRunForTests,
28+
} from '@/lib/core/config/trigger-runtime'
2229

2330
const encode = (value: unknown) => new TextEncoder().encode(JSON.stringify(value))
2431

@@ -241,3 +248,114 @@ describe('fetchAppConfigSnapshot freshness', () => {
241248
expect(mockSend.mock.calls).toHaveLength(3)
242249
})
243250
})
251+
252+
describe('shared snapshot in Trigger.dev runs', () => {
253+
const shared = { shareWithTriggerRuns: true }
254+
const sessionResponses = (command: { __type: string }) =>
255+
Promise.resolve(
256+
command.__type === 'start'
257+
? { InitialConfigurationToken: 'token' }
258+
: { Configuration: encode({ revision: 'appconfig' }), NextPollConfigurationToken: 'next' }
259+
)
260+
const snapshotAt = (validatedAt: number) =>
261+
JSON.stringify({ raw: JSON.stringify({ revision: 'shared' }), validatedAt })
262+
const redisReturning = (stored: string | null) => {
263+
const redis = { get: vi.fn().mockResolvedValue(stored), eval: vi.fn().mockResolvedValue(1) }
264+
redisConfigMockFns.mockGetRedisClient.mockReturnValue(redis)
265+
return redis
266+
}
267+
268+
beforeEach(() => {
269+
mockSend.mockImplementation(sessionResponses)
270+
markInsideTriggerRun()
271+
})
272+
afterEach(() => resetInsideTriggerRunForTests())
273+
274+
it('adopts a fresh snapshot without opening an AppConfig session', async () => {
275+
redisReturning(snapshotAt(Date.now()))
276+
expect(await fetchAppConfigProfile(uniqueIds(), (json) => json, shared)).toEqual({
277+
revision: 'shared',
278+
})
279+
expect(mockSend).not.toHaveBeenCalled()
280+
})
281+
282+
it('tolerates a publisher clock a few seconds ahead, but not more', async () => {
283+
redisReturning(snapshotAt(Date.now() + 4_000))
284+
expect(await fetchAppConfigProfile(uniqueIds(), (json) => json, shared)).toEqual({
285+
revision: 'shared',
286+
})
287+
redisReturning(snapshotAt(Date.now() + 60_000))
288+
expect(await fetchAppConfigProfile(uniqueIds(), (json) => json, shared)).toEqual({
289+
revision: 'appconfig',
290+
})
291+
})
292+
293+
it('never reads the snapshot outside a Trigger.dev run', async () => {
294+
resetInsideTriggerRunForTests()
295+
redisReturning(snapshotAt(Date.now()))
296+
expect(await fetchAppConfigProfile(uniqueIds(), (json) => json, shared)).toEqual({
297+
revision: 'appconfig',
298+
})
299+
})
300+
301+
it('never reads or publishes a profile that did not opt in', async () => {
302+
const redis = redisReturning(snapshotAt(Date.now()))
303+
expect(await fetchAppConfigProfile(uniqueIds(), (json) => json)).toEqual({
304+
revision: 'appconfig',
305+
})
306+
resetInsideTriggerRunForTests()
307+
await fetchAppConfigProfile(uniqueIds(), (json) => json)
308+
await flushMacrotask()
309+
expect(redis.eval).not.toHaveBeenCalled()
310+
})
311+
312+
it('polls AppConfig when the snapshot is older than the adoption window', async () => {
313+
redisReturning(snapshotAt(Date.now() - 120_000))
314+
expect(await fetchAppConfigProfile(uniqueIds(), (json) => json, shared)).toEqual({
315+
revision: 'appconfig',
316+
})
317+
})
318+
319+
it('polls AppConfig when Redis fails or the snapshot does not parse', async () => {
320+
redisConfigMockFns.mockGetRedisClient.mockReturnValue({
321+
get: vi.fn().mockRejectedValue(new Error('redis down')),
322+
eval: vi.fn().mockRejectedValue(new Error('redis down')),
323+
})
324+
expect(await fetchAppConfigProfile(uniqueIds(), (json) => json, shared)).toEqual({
325+
revision: 'appconfig',
326+
})
327+
328+
redisReturning(snapshotAt(Date.now()))
329+
const rejectShared = (json: unknown) => {
330+
if (toRecord(json).revision === 'shared') throw new Error('invalid')
331+
return json
332+
}
333+
expect(await fetchAppConfigProfile(uniqueIds(), rejectShared, shared)).toEqual({
334+
revision: 'appconfig',
335+
})
336+
})
337+
338+
it('publishes only from app processes', async () => {
339+
const redis = redisReturning(null)
340+
expect(await fetchAppConfigProfile(uniqueIds(), (json) => json, shared)).toEqual({
341+
revision: 'appconfig',
342+
})
343+
await flushMacrotask()
344+
expect(redis.eval).not.toHaveBeenCalled()
345+
346+
resetInsideTriggerRunForTests()
347+
await fetchAppConfigProfile(uniqueIds(), (json) => json, shared)
348+
await vi.waitFor(() => expect(redis.eval).toHaveBeenCalledTimes(1))
349+
})
350+
351+
it('never serves or publishes a strict snapshot through the shared copy', async () => {
352+
const redis = redisReturning(snapshotAt(Date.now()))
353+
expect((await fetchAppConfigSnapshot(uniqueIds(), (json) => json)).value).toEqual({
354+
revision: 'appconfig',
355+
})
356+
resetInsideTriggerRunForTests()
357+
await fetchAppConfigSnapshot(uniqueIds(), (json) => json)
358+
await flushMacrotask()
359+
expect(redis.eval).not.toHaveBeenCalled()
360+
})
361+
})

0 commit comments

Comments
 (0)