Skip to content

Commit 24e3c74

Browse files
committed
fix: bound recording retries and avoid wasted Drive downloads
1 parent cb82868 commit 24e3c74

6 files changed

Lines changed: 168 additions & 20 deletions

File tree

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

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -720,6 +720,65 @@ describe("retained-source retry policy", () => {
720720
});
721721
});
722722

723+
it("pauses a repeatedly failing recording and retains its source", async () => {
724+
const attempt = await createAttempt();
725+
await persistCommittedSource(attempt, source);
726+
Object.assign(rows.jobs[0], { attemptCount: 5 });
727+
expect(
728+
await scheduleRetry({
729+
...attempt,
730+
errorCode: "output-invalid",
731+
errorMessage: "Timeline mismatch",
732+
}),
733+
).toBe(true);
734+
expect(rows.jobs[0]).toMatchObject({
735+
state: "source-blocked",
736+
source,
737+
errorCode: "processing-retry-exhausted",
738+
errorMessage: "output-invalid: Timeline mismatch",
739+
});
740+
expect(rows.uploads[0]).toMatchObject({ phase: "error" });
741+
vi.setSystemTime(new Date(now.getTime() + 24 * 60 * 60_000));
742+
expect(
743+
await claimProcessingAttempt({ videoId, generation: attempt.generation }),
744+
).toBeNull();
745+
expect(rows.jobs[0]?.attemptCount).toBe(5);
746+
});
747+
748+
it("pauses an exhausted legacy job before downloading its source again", async () => {
749+
const attempt = await createAttempt();
750+
Object.assign(rows.jobs[0], {
751+
attemptCount: 220,
752+
state: "retry",
753+
leaseExpiresAt: null,
754+
nextRetryAt: now,
755+
});
756+
expect(
757+
await claimProcessingAttempt({ videoId, generation: attempt.generation }),
758+
).toBeNull();
759+
expect(rows.jobs[0]).toMatchObject({
760+
attemptCount: 220,
761+
errorCode: "processing-retry-exhausted",
762+
});
763+
await ensureSegmentProcessingJob({ videoId, userId });
764+
expect(rows.jobs[0]).toMatchObject({
765+
state: "source-blocked",
766+
errorCode: "processing-retry-exhausted",
767+
});
768+
});
769+
770+
it("does not interrupt an active final attempt", async () => {
771+
const attempt = await createAttempt();
772+
Object.assign(rows.jobs[0], { attemptCount: 5 });
773+
expect(
774+
await claimProcessingAttempt({ videoId, generation: attempt.generation }),
775+
).toBeNull();
776+
expect(rows.jobs[0]).toMatchObject({
777+
state: "committing",
778+
attemptId: attempt.attemptId,
779+
});
780+
});
781+
723782
it("recovers old jobs regardless of recording age or previous attempt count", async () => {
724783
const attempt = await createAttempt();
725784
const job: DesktopRecordingJob = {

apps/web/__tests__/unit/storage-object-verification.test.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,33 @@ describe("recording verification object reads", () => {
8282
expect(mocks.head).not.toHaveBeenCalled();
8383
});
8484

85+
it("cancels ordinary upstream downloads when the client disconnects", async () => {
86+
const controller = new AbortController();
87+
const req = new NextRequest(
88+
"https://cap.test/api/storage/object?videoId=video&key=owner/video/result.mp4&token=test",
89+
{ signal: controller.signal },
90+
);
91+
await GET(req);
92+
expect(mocks.read).toHaveBeenCalledWith("owner/video/result.mp4", null, {
93+
signal: req.signal,
94+
});
95+
controller.abort();
96+
expect(mocks.read.mock.calls.at(-1)?.[2].signal.aborted).toBe(true);
97+
});
98+
99+
it("answers ordinary HEAD requests without downloading the video", async () => {
100+
const response = await HEAD(
101+
new NextRequest(
102+
"https://cap.test/api/storage/object?videoId=video&key=owner/video/result.mp4&token=test",
103+
{ method: "HEAD" },
104+
),
105+
);
106+
expect(response.status).toBe(200);
107+
expect(response.headers.get("Content-Length")).toBe("100");
108+
expect(await response.text()).toBe("");
109+
expect(mocks.read).not.toHaveBeenCalled();
110+
});
111+
85112
it("serves content-bound HEAD without downloading media", async () => {
86113
const identity = `"cap-drive-content-v1:${"a".repeat(64)}"`;
87114
mocks.head.mockReturnValue(
@@ -261,7 +288,9 @@ describe("recording verification object reads", () => {
261288
mocks.video.source = { type: "desktopMP4", [field]: key };
262289
const response = await request({}, key);
263290
expect(response.status).toBe(206);
264-
expect(mocks.read).toHaveBeenCalledWith(key, null);
291+
expect(mocks.read).toHaveBeenCalledWith(key, null, {
292+
signal: expect.any(AbortSignal),
293+
});
265294
},
266295
);
267296
});

apps/web/app/api/storage/object/route.ts

Lines changed: 13 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -168,12 +168,14 @@ export async function GET(request: NextRequest) {
168168
const verificationRequested =
169169
request.headers.get("x-cap-recording-verification") === "1";
170170
const expectedIdentity = request.headers.get("if-match");
171-
const head = verificationRequested
172-
? yield* storage.headObject(key)
173-
: undefined;
174-
const identity = head
175-
? getRecordingObjectIdentity(head, expectedIdentity ?? undefined)
176-
: undefined;
171+
const head =
172+
verificationRequested || request.method === "HEAD"
173+
? yield* storage.headObject(key)
174+
: undefined;
175+
const identity =
176+
verificationRequested && head
177+
? getRecordingObjectIdentity(head, expectedIdentity ?? undefined)
178+
: undefined;
177179
if (verificationRequested && !identity) {
178180
return new Response("Object identity is unavailable", { status: 503 });
179181
}
@@ -184,14 +186,9 @@ export async function GET(request: NextRequest) {
184186
) {
185187
return new Response("Object changed", { status: 412 });
186188
}
187-
if (
188-
verificationRequested &&
189-
request.method === "HEAD" &&
190-
head &&
191-
identity
192-
) {
189+
if (request.method === "HEAD" && head) {
193190
const headers = new Headers(CACHE_CONTROL_HEADERS);
194-
headers.set("ETag", identity);
191+
if (identity) headers.set("ETag", identity);
195192
headers.set("Accept-Ranges", "bytes");
196193
if (head.ContentLength !== undefined)
197194
headers.set("Content-Length", String(head.ContentLength));
@@ -203,7 +200,9 @@ export async function GET(request: NextRequest) {
203200
objectIdentity: identity,
204201
signal: request.signal,
205202
})
206-
: yield* storage.getObjectResponse(key, request.headers.get("range"));
203+
: yield* storage.getObjectResponse(key, request.headers.get("range"), {
204+
signal: request.signal,
205+
});
207206
const headers = new Headers(CACHE_CONTROL_HEADERS);
208207
if (identity) headers.set("ETag", identity);
209208
copyHeader(upstream.headers, headers, "content-type", "Content-Type");

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

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@ export const DESKTOP_RECORDING_LEASE_MS = 5 * 60 * 1_000;
3333
export const DESKTOP_RECORDING_SOURCE_RETRY_MS = 60 * 60 * 1_000;
3434
export const DESKTOP_RECORDING_OUTPUT_REPLACED = "output-replaced";
3535
export const DESKTOP_RECORDING_DELETING = "video-deleting";
36+
export const DESKTOP_RECORDING_RETRY_EXHAUSTED = "processing-retry-exhausted";
37+
const MAX_AUTOMATIC_ATTEMPTS = 5;
38+
const RETRY_EXHAUSTED_MESSAGE =
39+
"Processing paused after repeated failures. Your uploaded recording is retained. Please contact support.";
3640

3741
const sourceSchema = z.object({
3842
version: z.literal(1),
@@ -158,6 +162,7 @@ export function isDesktopRecordingJobRecoverable(
158162
now: Date,
159163
) {
160164
if (job.state === "verified") return false;
165+
if (job.errorCode === DESKTOP_RECORDING_RETRY_EXHAUSTED) return false;
161166
if (job.errorCode === DESKTOP_RECORDING_OUTPUT_REPLACED) return false;
162167
if (job.errorCode === DESKTOP_RECORDING_DELETING) return false;
163168
if (job.state === "source-blocked" && job.source) return false;
@@ -333,7 +338,11 @@ export async function ensureSegmentProcessingJob({
333338
.where(eq(videoProcessingJobs.videoId, videoId));
334339
}
335340
}
336-
if (job.state === "source-blocked" && !job.source) {
341+
if (
342+
job.state === "source-blocked" &&
343+
!job.source &&
344+
job.errorCode !== DESKTOP_RECORDING_RETRY_EXHAUSTED
345+
) {
337346
job = {
338347
...job,
339348
state: "committing",
@@ -374,6 +383,33 @@ export async function getProcessingState({
374383
return row ? parseDesktopRecordingJob(row) : null;
375384
}
376385

386+
async function pauseExhaustedRecording(
387+
tx: Parameters<Parameters<ReturnType<typeof db>["transaction"]>[0]>[0],
388+
videoId: Video.VideoId,
389+
errorMessage: string,
390+
now: Date,
391+
) {
392+
await tx
393+
.update(videoProcessingJobs)
394+
.set({
395+
state: "source-blocked",
396+
leaseExpiresAt: null,
397+
errorCode: DESKTOP_RECORDING_RETRY_EXHAUSTED,
398+
errorMessage,
399+
updatedAt: now,
400+
})
401+
.where(eq(videoProcessingJobs.videoId, videoId));
402+
await tx
403+
.update(videoUploads)
404+
.set({
405+
phase: "error",
406+
processingMessage: RETRY_EXHAUSTED_MESSAGE,
407+
processingError: RETRY_EXHAUSTED_MESSAGE,
408+
updatedAt: now,
409+
})
410+
.where(eq(videoUploads.videoId, videoId));
411+
}
412+
377413
export async function claimProcessingAttempt({
378414
videoId,
379415
generation,
@@ -397,6 +433,15 @@ export async function claimProcessingAttempt({
397433
if (!row) return null;
398434
const job = parseDesktopRecordingJob(row);
399435
if (!isDesktopRecordingJobRecoverable(job, now)) return null;
436+
if (job.attemptCount >= MAX_AUTOMATIC_ATTEMPTS) {
437+
await pauseExhaustedRecording(
438+
tx,
439+
videoId,
440+
job.errorMessage ?? "Automatic processing attempt limit reached.",
441+
now,
442+
);
443+
return null;
444+
}
400445
const attempt: DesktopRecordingAttempt = {
401446
...job,
402447
state: job.source ? "processing" : "committing",
@@ -651,6 +696,15 @@ export async function scheduleRetry({
651696
.where(attemptCondition(fence))
652697
.for("update");
653698
if (!row) return false;
699+
if (row.attemptCount >= MAX_AUTOMATIC_ATTEMPTS) {
700+
await pauseExhaustedRecording(
701+
tx,
702+
fence.videoId,
703+
`${errorCode}: ${errorMessage}`,
704+
now,
705+
);
706+
return true;
707+
}
654708
await tx
655709
.update(videoProcessingJobs)
656710
.set({

packages/web-backend/src/Storage/GoogleDrive.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1373,6 +1373,7 @@ export const getGoogleDriveObjectResponse = (
13731373
fileId: string,
13741374
range?: string | null,
13751375
tokenStore?: GoogleDriveTokenStore,
1376+
signal?: AbortSignal,
13761377
) =>
13771378
Effect.gen(function* () {
13781379
const headers: Record<string, string> = {};
@@ -1382,7 +1383,7 @@ export const getGoogleDriveObjectResponse = (
13821383
appendSharedDriveCreateParams(
13831384
`${DRIVE_API_BASE}/files/${encodeURIComponent(fileId)}?alt=media`,
13841385
),
1385-
{ headers },
1386+
{ headers, signal },
13861387
tokenStore,
13871388
);
13881389

packages/web-backend/src/Storage/index.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1033,11 +1033,11 @@ const makeGoogleDriveAccess = ({
10331033
getObjectResponse: (
10341034
key: string,
10351035
range?: string | null,
1036-
verification?: GoogleDriveRecordingRead,
1036+
verification?: GoogleDriveRecordingRead | { signal: AbortSignal },
10371037
) =>
10381038
getObjectRecord(key).pipe(
10391039
Effect.flatMap((object) =>
1040-
verification
1040+
verification && "objectIdentity" in verification
10411041
? getGoogleDriveRecordingResponse(
10421042
config,
10431043
object.providerObjectId,
@@ -1046,7 +1046,13 @@ const makeGoogleDriveAccess = ({
10461046
tokenStore,
10471047
)
10481048
: withRecoveredDriveFile(key, object, (fileId) =>
1049-
getGoogleDriveObjectResponse(config, fileId, range, tokenStore),
1049+
getGoogleDriveObjectResponse(
1050+
config,
1051+
fileId,
1052+
range,
1053+
tokenStore,
1054+
verification?.signal,
1055+
),
10501056
),
10511057
),
10521058
),

0 commit comments

Comments
 (0)