Skip to content

Commit 382fe3e

Browse files
committed
fix: persist recording capacity waits and honor worker backoff
1 parent 459a073 commit 382fe3e

6 files changed

Lines changed: 202 additions & 10 deletions

apps/web/__tests__/unit/desktop-recording-jobs.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
persistSourceCommitCheckpoint,
1717
retireDesktopRecordingJobForOutputReplacement,
1818
scheduleRetry,
19+
waitForDesktopRecordingCapacity,
1920
} from "@/lib/desktop-recording-jobs";
2021
import type {
2122
RecordingUploadReceipt,
@@ -600,6 +601,58 @@ describe("late verification and source commitment", () => {
600601
});
601602

602603
describe("retained-source retry policy", () => {
604+
it("persists capacity waiting while retaining the attempt and extending its lease past backoff", async () => {
605+
const attempt = await createAttempt();
606+
Object.assign(getJobRow(), {
607+
state: "processing",
608+
source,
609+
remoteJobId: null,
610+
attemptCount: 5,
611+
});
612+
expect(
613+
await waitForDesktopRecordingCapacity({
614+
...attempt,
615+
now,
616+
retryAfterMs: 320_000,
617+
}),
618+
).toBe(true);
619+
expect(getJobRow()).toMatchObject({
620+
state: "processing",
621+
source,
622+
attemptId: attempt.attemptId,
623+
attemptCount: 5,
624+
output: { kind: "desktop-recording-capacity-wait" },
625+
nextRetryAt: new Date(now.getTime() + 320_000),
626+
});
627+
expect((getJobRow().leaseExpiresAt as Date).getTime()).toBeGreaterThan(
628+
now.getTime() + 320_000,
629+
);
630+
expect(rows.uploads?.[0]?.processingMessage).toContain(
631+
"Waiting for a processing slot",
632+
);
633+
});
634+
635+
it.each(["owned", "expired"])(
636+
"does not overwrite %s work with a capacity wait",
637+
async (condition) => {
638+
const attempt = await createAttempt();
639+
Object.assign(getJobRow(), {
640+
state: "processing",
641+
source,
642+
...(condition === "owned"
643+
? { remoteJobId: "worker" }
644+
: { leaseExpiresAt: now }),
645+
});
646+
expect(
647+
await waitForDesktopRecordingCapacity({
648+
...attempt,
649+
now,
650+
retryAfterMs: 30_000,
651+
}),
652+
).toBe(false);
653+
},
654+
);
655+
603656
it.each([null, source])(
604657
"does not recreate a recording while deletion is pending",
605658
async (retainedSource) => {

apps/web/__tests__/unit/finalize-desktop-recording.test.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ const mocks = vi.hoisted(() => ({
1313
ensure: vi.fn(),
1414
persist: vi.fn(),
1515
heartbeat: vi.fn(),
16+
waitCapacity: vi.fn(),
1617
blocked: vi.fn(),
1718
retry: vi.fn(),
1819
attach: vi.fn(),
@@ -59,6 +60,7 @@ vi.mock("@/lib/desktop-recording-jobs", () => ({
5960
initializeSourceCommitCheckpoint: mocks.checkpoint,
6061
persistSourceCommitCheckpoint: mocks.saveCheckpoint,
6162
heartbeatAttempt: mocks.heartbeat,
63+
waitForDesktopRecordingCapacity: mocks.waitCapacity,
6264
markSourceBlocked: mocks.blocked,
6365
scheduleRetry: mocks.retry,
6466
attachRemoteJob: mocks.attach,
@@ -179,6 +181,7 @@ beforeEach(() => {
179181
withCurrent({ source: savedSource, state: "processing" });
180182
return true;
181183
});
184+
mocks.waitCapacity.mockResolvedValue(true);
182185
mocks.heartbeat.mockImplementation(async () => {
183186
withCurrent({ leaseExpiresAt: new Date(Date.now() + 5 * 60_000) });
184187
return true;
@@ -517,7 +520,10 @@ describe("source commitment and media request compatibility", () => {
517520
withCurrent({ state: "retry", leaseExpiresAt: null, attemptCount: 4 });
518521
for (let index = 0; index < 8; index++)
519522
mocks.fetch.mockResolvedValueOnce(
520-
Response.json({ code: "SERVER_BUSY" }, { status: 503 }),
523+
Response.json(
524+
{ code: "SERVER_BUSY" },
525+
{ status: 503, headers: { "Retry-After": "60" } },
526+
),
521527
);
522528
await expect(
523529
finalizeDesktopRecordingWorkflow({
@@ -528,6 +534,14 @@ describe("source commitment and media request compatibility", () => {
528534
).resolves.toMatchObject({ success: true });
529535
expect(current?.attemptCount).toBe(5);
530536
expect(mocks.fetch).toHaveBeenCalledTimes(9);
537+
expect(mocks.waitCapacity).toHaveBeenCalledWith(
538+
expect.objectContaining({ retryAfterMs: expect.any(Number) }),
539+
);
540+
expect(
541+
mocks.sleep.mock.calls.filter(
542+
([delay]) => typeof delay === "number" && delay >= 60_000,
543+
),
544+
).toHaveLength(8);
531545
const attempts = mocks.reserveBudget.mock.calls.map(
532546
([input]) => input.attemptId,
533547
);
@@ -536,6 +550,18 @@ describe("source commitment and media request compatibility", () => {
536550
expect(mocks.blocked).not.toHaveBeenCalled();
537551
});
538552

553+
it("respects a persisted capacity wait when a dispatch step is replayed", async () => {
554+
withCurrent({
555+
output: { kind: "desktop-recording-capacity-wait" },
556+
nextRetryAt: new Date(Date.now() + 60_000),
557+
});
558+
await expect(startDesktopRecordingJob(fixture)).resolves.toMatchObject({
559+
status: "capacity",
560+
});
561+
expect(mocks.fetch).not.toHaveBeenCalled();
562+
expect(mocks.sourceUrls).not.toHaveBeenCalled();
563+
});
564+
539565
it("does not treat an ambiguous dispatch failure as a capacity refusal", async () => {
540566
mocks.fetch.mockResolvedValueOnce(
541567
Response.json({ code: "UPSTREAM_FAILURE" }, { status: 503 }),

apps/web/__tests__/unit/media-server-backpressure.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
22
import { RetryableError } from "workflow";
33
import {
44
createMediaServerCapacityError,
5+
getMediaServerCapacityDelay,
56
isMediaServerCapacityError,
67
} from "@/lib/media-server-backpressure";
78

@@ -61,3 +62,19 @@ describe("media server backpressure", () => {
6162
).toBe(15_000);
6263
});
6364
});
65+
66+
it.each(["Sun, 06 Sep 2026 19:40:00 GMT", "1.0001", "999999", "invalid"])(
67+
"bounds and rounds server backoff %s",
68+
(header) => {
69+
vi.useFakeTimers();
70+
vi.setSystemTime(new Date("2026-09-06T19:39:00Z"));
71+
const delay = getMediaServerCapacityDelay({
72+
response: new Response(null, { headers: { "Retry-After": header } }),
73+
videoId: "video",
74+
});
75+
expect(Number.isSafeInteger(delay)).toBe(true);
76+
expect(delay).toBeGreaterThan(0);
77+
expect(delay).toBeLessThanOrEqual(320_000);
78+
if (header.startsWith("Sun")) expect(delay).toBeGreaterThanOrEqual(60_000);
79+
},
80+
);

apps/web/lib/desktop-recording-jobs.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -677,6 +677,66 @@ export async function heartbeatAttempt({
677677
});
678678
}
679679

680+
export async function waitForDesktopRecordingCapacity({
681+
now = new Date(),
682+
retryAfterMs,
683+
...fence
684+
}: DesktopRecordingAttemptFence & {
685+
now?: Date;
686+
retryAfterMs: number;
687+
}): Promise<boolean> {
688+
if (
689+
!Number.isSafeInteger(retryAfterMs) ||
690+
retryAfterMs <= 0 ||
691+
retryAfterMs > 360_000
692+
)
693+
throw new Error("Invalid capacity retry delay");
694+
return db().transaction(async (tx) => {
695+
const condition = and(
696+
attemptCondition(fence),
697+
isNull(videoProcessingJobs.remoteJobId),
698+
gt(videoProcessingJobs.leaseExpiresAt, now),
699+
);
700+
const [row] = await tx
701+
.select()
702+
.from(videoProcessingJobs)
703+
.where(condition)
704+
.for("update");
705+
if (
706+
!row ||
707+
getDesktopRecordingWorkerCheckpoint(parseDesktopRecordingJob(row))
708+
)
709+
return false;
710+
const nextRetryAt = new Date(now.getTime() + retryAfterMs);
711+
await tx
712+
.update(videoProcessingJobs)
713+
.set({
714+
leaseExpiresAt: new Date(
715+
nextRetryAt.getTime() + DESKTOP_RECORDING_LEASE_MS,
716+
),
717+
nextRetryAt,
718+
output: {
719+
kind: "desktop-recording-capacity-wait",
720+
version: 1,
721+
retryAt: nextRetryAt.toISOString(),
722+
},
723+
updatedAt: now,
724+
})
725+
.where(condition);
726+
await tx
727+
.update(videoUploads)
728+
.set({
729+
phase: "processing",
730+
processingMessage:
731+
"Waiting for a processing slot. Your recording is safely stored.",
732+
processingError: null,
733+
updatedAt: now,
734+
})
735+
.where(eq(videoUploads.videoId, fence.videoId));
736+
return true;
737+
});
738+
}
739+
680740
export async function scheduleRetry({
681741
errorCode,
682742
errorMessage,

apps/web/lib/media-server-backpressure.ts

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,18 +18,36 @@ export function createMediaServerCapacityError({
1818
videoId: string;
1919
priority?: MediaServerJobPriority;
2020
}): RetryableError {
21-
const retryAfterSeconds = Number(response.headers.get("Retry-After"));
21+
return new RetryableError(message, {
22+
retryAfter: getMediaServerCapacityDelay({ response, videoId, priority }),
23+
});
24+
}
25+
26+
export function getMediaServerCapacityDelay({
27+
response,
28+
videoId,
29+
priority = "normal",
30+
}: {
31+
response: Response;
32+
videoId: string;
33+
priority?: MediaServerJobPriority;
34+
}): number {
35+
const header = response.headers.get("Retry-After");
36+
const retryAfterSeconds =
37+
header && /^\d+(?:\.\d+)?$/.test(header)
38+
? Number(header)
39+
: header
40+
? (Date.parse(header) - Date.now()) / 1000
41+
: NaN;
2242
const minimumDelayMs =
2343
Number.isFinite(retryAfterSeconds) && retryAfterSeconds > 0
24-
? Math.min(retryAfterSeconds, 300) * 1000
44+
? Math.ceil(Math.min(retryAfterSeconds, 300) * 1000)
2545
: 15_000;
2646
const stableOffset = Array.from(videoId).reduce(
2747
(total, character) => (total * 31 + character.charCodeAt(0)) % 20_000,
2848
0,
2949
);
3050
const priorityDelayMs = priority === "bulk" ? 15_000 : 0;
3151

32-
return new RetryableError(message, {
33-
retryAfter: minimumDelayMs + priorityDelayMs + stableOffset,
34-
});
52+
return minimumDelayMs + priorityDelayMs + stableOffset;
3553
}

apps/web/workflows/finalize-desktop-recording.ts

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
persistCommittedSource,
2323
persistSourceCommitCheckpoint,
2424
scheduleRetry,
25+
waitForDesktopRecordingCapacity,
2526
} from "@/lib/desktop-recording-jobs";
2627
import {
2728
advanceDesktopRecordingSourceCommit,
@@ -34,6 +35,7 @@ import {
3435
MediaProcessingBudgetError,
3536
reserveMediaProcessingBudget,
3637
} from "@/lib/media-processing-budget";
38+
import { getMediaServerCapacityDelay } from "@/lib/media-server-backpressure";
3739
import { transcribeVideo } from "@/lib/transcribe";
3840
import { decodeStorageVideo } from "@/lib/video-storage";
3941
import { runWorkflowPromise } from "@/lib/workflow-runtime";
@@ -142,7 +144,7 @@ export async function finalizeDesktopRecordingWorkflow(
142144
if (typeof started === "object") {
143145
if (started.status === "capacity") {
144146
retainedAttempt = attempt;
145-
await sleep(COMPLETION_POLL_INTERVAL_MS);
147+
await sleep(started.retryAfterMs);
146148
}
147149
continue;
148150
}
@@ -413,7 +415,12 @@ async function buildDesktopSegmentsOutput({
413415

414416
export async function startDesktopRecordingJob(
415417
attempt: DesktopRecordingAttempt,
416-
): Promise<string | undefined | { status: "deferred" | "capacity" }> {
418+
): Promise<
419+
| string
420+
| undefined
421+
| { status: "deferred" }
422+
| { status: "capacity"; retryAfterMs: number }
423+
> {
417424
"use step";
418425

419426
const current = await getProcessingState(attempt);
@@ -422,6 +429,13 @@ export async function startDesktopRecordingJob(
422429
}
423430
if (current.remoteJobId) return current.remoteJobId;
424431
if (current.state === "retry") return { status: "deferred" };
432+
const remainingCapacityWait = current.nextRetryAt.getTime() - Date.now();
433+
if (
434+
current.output?.kind === "desktop-recording-capacity-wait" &&
435+
remainingCapacityWait > 0
436+
) {
437+
return { status: "capacity", retryAfterMs: remainingCapacityWait };
438+
}
425439
const [video] = await db()
426440
.select()
427441
.from(videos)
@@ -542,14 +556,18 @@ export async function startDesktopRecordingJob(
542556
);
543557
if (response.status === 503) {
544558
const result: unknown = await response.json();
559+
const retryAfterMs = getMediaServerCapacityDelay({
560+
response,
561+
videoId: attempt.videoId,
562+
});
545563
if (
546564
result &&
547565
typeof result === "object" &&
548566
"code" in result &&
549567
result.code === "SERVER_BUSY" &&
550-
(await heartbeatAttempt(attempt))
568+
(await waitForDesktopRecordingCapacity({ ...attempt, retryAfterMs }))
551569
) {
552-
return { status: "capacity" };
570+
return { status: "capacity", retryAfterMs };
553571
}
554572
}
555573
if (response.ok) {

0 commit comments

Comments
 (0)