Skip to content

Commit 727302f

Browse files
committed
improvement(ci): overlap isolated recovery backoff checks
1 parent 22370a4 commit 727302f

2 files changed

Lines changed: 103 additions & 60 deletions

File tree

‎apps/sim/lib/mothership/request/session/recovery-storm.integration.ts‎

Lines changed: 102 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -10,45 +10,68 @@ import { deleteWorkspaceFixture, insertWorkspaceFixture } from '@sim/db/testing/
1010
import { authMock, authMockFns } from '@sim/testing/mocks/auth.mock'
1111
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
1212

13-
const { redisUrl, inheritedEnv, worker, faults } = await vi.hoisted(async () => {
13+
const { redisUrl, inheritedEnv, worker } = await vi.hoisted(async () => {
1414
const { readTestRedisUrl } = await import('@sim/db/testing/test-infrastructure')
15+
const { toRecord } = await import('@sim/utils/object')
1516
const { createServer } = await import('node:http')
16-
const faults = {
17-
/** Which leased appends fail, and how; `undefined` lets every append through. */
18-
append: undefined as
19-
| undefined
20-
| {
21-
frames: 'any_tool' | 'tool_result'
22-
effect: 'throw' | 'throw_once' | 'lose_lease'
23-
},
24-
/** Fails a recovering controller's read of the whole replay ring. */
25-
recoveryRead: false,
17+
interface RunFixture {
18+
mode: 'frames' | 'json500' | 'drop'
19+
frames: unknown[]
20+
posts: { path: string; at: number }[]
21+
append?: {
22+
frames: 'any_tool' | 'tool_result'
23+
effect: 'throw' | 'throw_once' | 'lose_lease'
24+
}
25+
recoveryRead: boolean
2626
}
2727
const worker = {
28-
posts: [] as Array<{ path: string; at: number }>,
29-
mode: 'frames' as 'frames' | 'json500' | 'drop',
30-
frames: [] as unknown[],
28+
runs: new Map<string, RunFixture>(),
29+
unexpectedRequests: [] as { path: string; reason: string }[],
3130
}
3231
const server = createServer(async (request, response) => {
33-
await new Promise((resolve) => request.on('end', resolve).resume())
34-
worker.posts.push({ path: request.url ?? '', at: Date.now() })
32+
let body: Record<string, unknown>
33+
try {
34+
let raw = ''
35+
request.setEncoding('utf8')
36+
for await (const chunk of request) raw += chunk
37+
body = toRecord(JSON.parse(raw))
38+
} catch {
39+
worker.unexpectedRequests.push({ path: request.url ?? '', reason: 'malformed_body' })
40+
response.writeHead(404, { 'content-type': 'application/json' })
41+
response.end(JSON.stringify({ error: 'Run not found' }))
42+
return
43+
}
44+
const fixture = typeof body.messageId === 'string' ? worker.runs.get(body.messageId) : undefined
45+
if (
46+
!fixture ||
47+
(request.url !== '/api/mothership' && request.url !== '/api/streams/explicit-abort')
48+
) {
49+
worker.unexpectedRequests.push({
50+
path: request.url ?? '',
51+
reason: fixture ? 'unknown_path' : 'unknown_stream',
52+
})
53+
response.writeHead(404, { 'content-type': 'application/json' })
54+
response.end(JSON.stringify({ error: 'Run not found' }))
55+
return
56+
}
57+
fixture.posts.push({ path: request.url, at: Date.now() })
3558
if (request.url === '/api/streams/explicit-abort') {
3659
response.writeHead(200, { 'content-type': 'application/json' })
3760
response.end(JSON.stringify({ settled: true }))
3861
return
3962
}
40-
if (worker.mode === 'json500') {
63+
if (fixture.mode === 'json500') {
4164
response.writeHead(500, { 'content-type': 'application/json' })
4265
response.end(JSON.stringify({ error: 'internal' }))
4366
return
4467
}
4568
response.writeHead(200, { 'content-type': 'text/event-stream' })
46-
if (worker.mode === 'drop') {
47-
response.write(`data: ${JSON.stringify(worker.frames[0])}\n\n`)
69+
if (fixture.mode === 'drop') {
70+
response.write(`data: ${JSON.stringify(fixture.frames[0])}\n\n`)
4871
setTimeout(() => response.socket?.destroy(), 20)
4972
return
5073
}
51-
for (const frame of worker.frames) response.write(`data: ${JSON.stringify(frame)}\n\n`)
74+
for (const frame of fixture.frames) response.write(`data: ${JSON.stringify(frame)}\n\n`)
5275
response.end('data: [DONE]\n\n')
5376
})
5477
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
@@ -61,7 +84,7 @@ const { redisUrl, inheritedEnv, worker, faults } = await vi.hoisted(async () =>
6184
/** The real Redis module and worker URL resolution read these at import. */
6285
process.env.REDIS_URL = url
6386
process.env.SIM_AGENT_API_URL = `http://127.0.0.1:${port}`
64-
return { redisUrl: url, inheritedEnv, worker: Object.assign(worker, { server }), faults }
87+
return { redisUrl: url, inheritedEnv, worker: Object.assign(worker, { server }) }
6588
})
6689

6790
vi.mock('@/lib/auth', () => authMock)
@@ -71,8 +94,15 @@ vi.mock('@/lib/mothership/request/session/buffer', async (importOriginal) => {
7194
return {
7295
...actual,
7396
appendEvents: async (...args: Parameters<typeof actual.appendEvents>) => {
74-
const [envelopes, , lease] = args
75-
const fault = faults.append
97+
const [envelopes, scope, lease] = args
98+
if (envelopes.length === 0) return actual.appendEvents(...args)
99+
const streamId = scope?.streamId ?? envelopes[0]?.stream.streamId
100+
const fixture = streamId ? worker.runs.get(streamId) : undefined
101+
if (!fixture) {
102+
worker.unexpectedRequests.push({ path: 'appendEvents', reason: 'unknown_append_stream' })
103+
throw new Error('Append has no registered recovery fixture')
104+
}
105+
const fault = fixture.append
76106
const hit =
77107
lease &&
78108
fault &&
@@ -85,16 +115,21 @@ vi.mock('@/lib/mothership/request/session/buffer', async (importOriginal) => {
85115
if (hit && fault.effect === 'throw') throw new Error('simulated Redis write failure')
86116
/** A Redis blip that outlasts the append retries, after which Redis is healthy again. */
87117
if (hit && fault.effect === 'throw_once') {
88-
faults.append = undefined
118+
fixture.append = undefined
89119
throw new Error('simulated transient Redis write failure')
90120
}
91121
/** The lock expires under a live controller, and nobody else holds it. */
92122
if (hit && fault.effect === 'lose_lease') await getRedisClient()!.del(lease.key)
93123
return actual.appendEvents(...args)
94124
},
95125
readEvents: async (...args: Parameters<typeof actual.readEvents>) => {
96-
const [, afterCursor] = args
97-
if (faults.recoveryRead && afterCursor === '0')
126+
const [streamId, afterCursor] = args
127+
const fixture = worker.runs.get(streamId)
128+
if (!fixture) {
129+
worker.unexpectedRequests.push({ path: 'readEvents', reason: 'unknown_read_stream' })
130+
throw new Error('Read has no registered recovery fixture')
131+
}
132+
if (fixture.recoveryRead && afterCursor === '0')
98133
throw new Error('simulated Redis read failure')
99134
return actual.readEvents(...args)
100135
},
@@ -141,6 +176,7 @@ async function orphanedRun(
141176
options: {
142177
status?: 'active' | 'paused_waiting_for_tool'
143178
recoveryBackoff?: Record<string, number>
179+
mode?: 'frames' | 'json500' | 'drop'
144180
} = {}
145181
) {
146182
const chatId = generateId()
@@ -180,6 +216,13 @@ async function orphanedRun(
180216
...(options.recoveryBackoff ? { recoveryBackoff: options.recoveryBackoff } : {}),
181217
},
182218
})
219+
const fixture: NonNullable<ReturnType<typeof worker.runs.get>> = {
220+
mode: options.mode ?? 'frames',
221+
frames: [],
222+
posts: [],
223+
recoveryRead: false,
224+
}
225+
worker.runs.set(streamId, fixture)
183226
await appendEvents(
184227
[1, 2].map((seq) =>
185228
createEvent({
@@ -201,7 +244,7 @@ async function orphanedRun(
201244
stream: { streamId, chatId },
202245
payload,
203246
})
204-
worker.frames = [
247+
fixture.frames = [
205248
frame(1, 'tool', {
206249
phase: 'call',
207250
toolCallId: 'go-call',
@@ -221,7 +264,7 @@ async function orphanedRun(
221264
}),
222265
frame(3, 'complete', { status: 'complete', textLength: 14 }),
223266
]
224-
return { chatId, streamId, runId }
267+
return { chatId, streamId, runId, fixture }
225268
}
226269

227270
async function storedRun(runId: string) {
@@ -245,8 +288,12 @@ async function storedRun(runId: string) {
245288
* lock released, or `windowMs` passes, and returns the worker requests made meanwhile.
246289
*/
247290
async function reconnect(
248-
{ chatId, streamId, runId }: { chatId: string; streamId: string; runId: string },
249-
{ tails = 1, windowMs }: { tails?: number; windowMs: number }
291+
{ chatId, streamId, runId, fixture }: Awaited<ReturnType<typeof orphanedRun>>,
292+
{
293+
tails = 1,
294+
windowMs,
295+
waitForLeaseRelease = true,
296+
}: { tails?: number; windowMs: number; waitForLeaseRelease?: boolean }
250297
) {
251298
const startedAt = Date.now()
252299
const controllers = Array.from({ length: tails }, () => new AbortController())
@@ -270,8 +317,9 @@ async function reconnect(
270317
}
271318
for (const controller of controllers) controller.abort()
272319
await Promise.allSettled(drains)
273-
for (let i = 0; i < 50 && (await redis().get(chatStreamLockKey(chatId))); i++) await sleep(100)
274-
const requests = worker.posts.filter((post) => post.at >= startedAt)
320+
if (waitForLeaseRelease)
321+
for (let i = 0; i < 50 && (await redis().get(chatStreamLockKey(chatId))); i++) await sleep(100)
322+
const requests = fixture.posts.filter((post) => post.at >= startedAt)
275323
return {
276324
startedAt,
277325
turns: requests.filter((post) => post.path === '/api/mothership'),
@@ -281,10 +329,12 @@ async function reconnect(
281329

282330
afterAll(async () => {
283331
await new Promise<void>((resolve) => worker.server.close(() => resolve()))
332+
worker.runs.clear()
284333
for (const [key, value] of Object.entries(inheritedEnv)) {
285334
if (value === undefined) delete process.env[key]
286335
else process.env[key] = value
287336
}
337+
expect(worker.unexpectedRequests).toEqual([])
288338
})
289339

290340
describe.runIf(Boolean(redisUrl))('reconnecting to an orphaned Chat run', () => {
@@ -332,7 +382,6 @@ describe.runIf(Boolean(redisUrl))('reconnecting to an orphaned Chat run', () =>
332382
it.each(['active', 'paused_waiting_for_tool'] as const)(
333383
'takes over an orphaned %s run at once and completes it',
334384
async (status) => {
335-
worker.mode = 'frames'
336385
const run = await orphanedRun({ status })
337386

338387
const { startedAt, turns } = await reconnect(run, { windowMs: 10_000 })
@@ -347,9 +396,8 @@ describe.runIf(Boolean(redisUrl))('reconnecting to an orphaned Chat run', () =>
347396
)
348397

349398
it('hands off a turn whose append fails once, and the next controller completes it', async () => {
350-
worker.mode = 'frames'
351399
const run = await orphanedRun()
352-
faults.append = { frames: 'any_tool', effect: 'throw_once' }
400+
run.fixture.append = { frames: 'any_tool', effect: 'throw_once' }
353401
try {
354402
const { turns, stops } = await reconnect(run, { windowMs: 15_000 })
355403

@@ -360,22 +408,22 @@ describe.runIf(Boolean(redisUrl))('reconnecting to an orphaned Chat run', () =>
360408
recoveryBackoff: { attempts: 2 },
361409
})
362410
} finally {
363-
faults.append = undefined
411+
run.fixture.append = undefined
364412
}
365413
}, 60_000)
366414

367-
it.each([
415+
it.concurrent.for([
368416
{ frames: 'any_tool', effect: 'throw', tails: 1 },
369417
{ frames: 'any_tool', effect: 'throw', tails: 3 },
370418
{ frames: 'tool_result', effect: 'throw', tails: 1 },
371419
{ frames: 'tool_result', effect: 'throw', tails: 3 },
372420
{ frames: 'any_tool', effect: 'lose_lease', tails: 3 },
373421
] as const)(
374422
'gives up on a run whose recovered controllers keep failing ($effect on $frames, $tails tails)',
375-
async ({ frames, effect, tails }) => {
376-
worker.mode = 'frames'
423+
{ timeout: 120_000 },
424+
async ({ frames, effect, tails }, { expect }) => {
377425
const run = await orphanedRun()
378-
faults.append = { frames, effect }
426+
run.fixture.append = { frames, effect }
379427
try {
380428
const { turns, stops } = await reconnect(run, { tails, windowMs: 60_000 })
381429

@@ -391,14 +439,12 @@ describe.runIf(Boolean(redisUrl))('reconnecting to an orphaned Chat run', () =>
391439
recoveryBackoff: { attempts: MAX_RECOVERY_ATTEMPTS + 1 },
392440
})
393441
} finally {
394-
faults.append = undefined
442+
run.fixture.append = undefined
395443
}
396-
},
397-
120_000
444+
}
398445
)
399446

400447
it('waits out the backoff of a recent takeover, then takes over and completes the run', async () => {
401-
worker.mode = 'frames'
402448
const now = Date.now()
403449
const notBefore = now + 3_000
404450
const run = await orphanedRun({
@@ -415,7 +461,6 @@ describe.runIf(Boolean(redisUrl))('reconnecting to an orphaned Chat run', () =>
415461
}, 30_000)
416462

417463
it('starts a fresh budget once the previous takeover is older than the reset window', async () => {
418-
worker.mode = 'frames'
419464
const claimedAt = Date.now() - RECOVERY_BUDGET_RESET_MS - 1_000
420465
const run = await orphanedRun({
421466
recoveryBackoff: { attempts: MAX_RECOVERY_ATTEMPTS, claimedAt, notBefore: claimedAt },
@@ -431,14 +476,13 @@ describe.runIf(Boolean(redisUrl))('reconnecting to an orphaned Chat run', () =>
431476
}, 30_000)
432477

433478
it('leaves the run untouched when a takeover fails before it starts, then ends it once exhausted', async () => {
434-
worker.mode = 'frames'
435479
const claimedAt = Date.now() - 1_000
436480
const recoveryBackoff = { attempts: MAX_RECOVERY_ATTEMPTS, claimedAt, notBefore: claimedAt }
437481
const run = await orphanedRun({ recoveryBackoff })
438482
const { controllerToken } = (await storedRun(run.runId)).requestContext as {
439483
controllerToken: string
440484
}
441-
faults.recoveryRead = true
485+
run.fixture.recoveryRead = true
442486
try {
443487
const { turns } = await reconnect(run, { windowMs: 2_000 })
444488

@@ -449,7 +493,7 @@ describe.runIf(Boolean(redisUrl))('reconnecting to an orphaned Chat run', () =>
449493
recoveryBackoff,
450494
})
451495
} finally {
452-
faults.recoveryRead = false
496+
run.fixture.recoveryRead = false
453497
}
454498

455499
const { turns, stops } = await reconnect(run, { windowMs: 10_000 })
@@ -464,17 +508,21 @@ describe.runIf(Boolean(redisUrl))('reconnecting to an orphaned Chat run', () =>
464508
}, 30_000)
465509

466510
it('never takes over a parked run whose controller still holds the chat lock', async () => {
467-
worker.mode = 'frames'
468511
const run = await orphanedRun({ status: 'paused_waiting_for_tool' })
469512
const liveController = `${run.streamId}\n${generateId()}`
470513
await redis().set(chatStreamLockKey(run.chatId), liveController, 'EX', 60)
471514
try {
472-
const { turns } = await reconnect(run, { tails: 3, windowMs: 3_000 })
515+
const { turns } = await reconnect(run, {
516+
tails: 3,
517+
windowMs: 3_000,
518+
waitForLeaseRelease: false,
519+
})
473520

474521
expect(turns).toHaveLength(0)
475522
const stored = await storedRun(run.runId)
476523
expect(stored.status).toBe('paused_waiting_for_tool')
477524
expect(stored.recoveryBackoff).toBeUndefined()
525+
expect(await redis().get(chatStreamLockKey(run.chatId))).toBe(liveController)
478526
} finally {
479527
await redis().del(chatStreamLockKey(run.chatId))
480528
}
@@ -483,16 +531,11 @@ describe.runIf(Boolean(redisUrl))('reconnecting to an orphaned Chat run', () =>
483531
it.each(['json500', 'drop'] as const)(
484532
'ends the run after its bounded retries when the worker fails every leg (%s)',
485533
async (mode) => {
486-
worker.mode = mode
487-
const run = await orphanedRun()
488-
try {
489-
const { turns } = await reconnect(run, { windowMs: 20_000 })
534+
const run = await orphanedRun({ mode })
535+
const { turns } = await reconnect(run, { windowMs: 20_000 })
490536

491-
expect(turns).toHaveLength(4)
492-
expect((await storedRun(run.runId)).status).toBe('error')
493-
} finally {
494-
worker.mode = 'frames'
495-
}
537+
expect(turns).toHaveLength(4)
538+
expect((await storedRun(run.runId)).status).toBe('error')
496539
},
497540
60_000
498541
)

‎vitest.integration-durations.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,7 @@
206206
"apps/sim/lib/mothership/request/lifecycle/file-owner-protocol.integration.ts": 2.2,
207207
"apps/sim/lib/mothership/request/lifecycle/unattended.integration.ts": 13.6,
208208
"apps/sim/lib/mothership/request/session/buffer-ttl.integration.ts": 12.4,
209-
"apps/sim/lib/mothership/request/session/recovery-storm.integration.ts": 259.1,
209+
"apps/sim/lib/mothership/request/session/recovery-storm.integration.ts": 100,
210210
"apps/sim/lib/mothership/request/session/replay-budget.integration.ts": 73.6,
211211
"apps/sim/lib/mothership/request/session/replay-gap.integration.ts": 36.9,
212212
"apps/sim/lib/mothership/request/session/stream-recovery.integration.ts": 13,

0 commit comments

Comments
 (0)