Skip to content

Commit 459a073

Browse files
committed
fix: process recordings without a daily transfer allowance
1 parent bc53298 commit 459a073

6 files changed

Lines changed: 162 additions & 173 deletions

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

Lines changed: 0 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import {
55
claimProcessingAttempt,
66
type DesktopRecordingAttemptFence,
77
type DesktopRecordingJob,
8-
deferForDailyProcessingBudget,
98
ensureSegmentProcessingJob,
109
getDesktopRecordingRetryDelay,
1110
getDesktopRecordingWorkerCheckpoint,
@@ -601,69 +600,6 @@ describe("late verification and source commitment", () => {
601600
});
602601

603602
describe("retained-source retry policy", () => {
604-
it("waits for the next UTC allowance without spending the final attempt", async () => {
605-
await createAttempt();
606-
Object.assign(getJobRow(), {
607-
state: "processing",
608-
source,
609-
attemptId: "budget-attempt",
610-
attemptCount: 5,
611-
remoteJobId: null,
612-
});
613-
const fence = {
614-
videoId,
615-
generation: String(getJobRow().generation),
616-
attemptId: "budget-attempt",
617-
};
618-
expect(await deferForDailyProcessingBudget({ ...fence, now })).toBe(true);
619-
expect(await deferForDailyProcessingBudget({ ...fence, now })).toBe(false);
620-
expect(getJobRow()).toMatchObject({
621-
state: "retry",
622-
attemptCount: 4,
623-
source,
624-
nextRetryAt: new Date("2026-09-03T00:00:00Z"),
625-
leaseExpiresAt: null,
626-
});
627-
expect(
628-
await claimProcessingAttempt({
629-
videoId,
630-
generation: fence.generation,
631-
now,
632-
}),
633-
).toBeNull();
634-
expect(
635-
await claimProcessingAttempt({
636-
videoId,
637-
generation: fence.generation,
638-
now: new Date("2026-09-03T00:00:00Z"),
639-
}),
640-
).toMatchObject({ attemptCount: 5, state: "processing", source });
641-
});
642-
643-
it("does not defer an attempt already owned by a remote worker", async () => {
644-
await createAttempt();
645-
Object.assign(getJobRow(), {
646-
state: "processing",
647-
source,
648-
attemptId: "owned",
649-
attemptCount: 5,
650-
remoteJobId: "worker",
651-
});
652-
expect(
653-
await deferForDailyProcessingBudget({
654-
videoId,
655-
generation: String(getJobRow().generation),
656-
attemptId: "owned",
657-
now,
658-
}),
659-
).toBe(false);
660-
expect(getJobRow()).toMatchObject({
661-
state: "processing",
662-
attemptCount: 5,
663-
remoteJobId: "worker",
664-
});
665-
});
666-
667603
it.each([null, source])(
668604
"does not recreate a recording while deletion is pending",
669605
async (retainedSource) => {

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

Lines changed: 17 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ const mocks = vi.hoisted(() => ({
1717
retry: vi.fn(),
1818
attach: vi.fn(),
1919
defer: vi.fn(),
20-
deferBudget: vi.fn(),
2120
commitSource: vi.fn(),
2221
checkpoint: vi.fn(),
2322
saveCheckpoint: vi.fn(),
@@ -34,7 +33,7 @@ const mocks = vi.hoisted(() => ({
3433
vi.mock("@/lib/media-processing-budget", () => ({
3534
reserveMediaProcessingBudget: mocks.reserveBudget,
3635
MediaProcessingBudgetError: class extends Error {
37-
constructor(readonly scope: "daily" | "recording") {
36+
constructor(readonly scope: "recording") {
3837
super(scope);
3938
}
4039
},
@@ -64,7 +63,6 @@ vi.mock("@/lib/desktop-recording-jobs", () => ({
6463
scheduleRetry: mocks.retry,
6564
attachRemoteJob: mocks.attach,
6665
deferWithoutMediaServer: mocks.defer,
67-
deferForDailyProcessingBudget: mocks.deferBudget,
6866
}));
6967
vi.mock("@/lib/desktop-recording-source", () => ({
7068
advanceDesktopRecordingSourceCommit: mocks.commitSource,
@@ -203,17 +201,7 @@ beforeEach(() => {
203201
withCurrent({ remoteJobId });
204202
return true;
205203
});
206-
mocks.deferBudget.mockImplementation(async () => {
207-
const nextRetryAt = new Date();
208-
nextRetryAt.setUTCHours(24, 0, 0, 0);
209-
withCurrent({
210-
state: "retry",
211-
leaseExpiresAt: null,
212-
nextRetryAt,
213-
attemptCount: (current?.attemptCount ?? 1) - 1,
214-
});
215-
return true;
216-
});
204+
217205
mocks.defer.mockImplementation(async () => {
218206
withCurrent({ state: "queued", leaseExpiresAt: null });
219207
return true;
@@ -525,40 +513,35 @@ describe("source commitment and media request compatibility", () => {
525513
expect(mocks.fetch).not.toHaveBeenCalled();
526514
});
527515

528-
it("sleeps through exhausted days and resumes processing automatically", async () => {
516+
it("waits for capacity using the same attempt instead of exhausting processing retries", async () => {
529517
withCurrent({ state: "retry", leaseExpiresAt: null, attemptCount: 4 });
530-
for (let day = 0; day < 7; day++) {
531-
mocks.reserveBudget.mockRejectedValueOnce(
532-
new MediaProcessingBudgetError("daily"),
518+
for (let index = 0; index < 8; index++)
519+
mocks.fetch.mockResolvedValueOnce(
520+
Response.json({ code: "SERVER_BUSY" }, { status: 503 }),
533521
);
534-
}
535522
await expect(
536523
finalizeDesktopRecordingWorkflow({
537524
videoId,
538525
userId,
539526
generation: fixture.generation,
540527
}),
541528
).resolves.toMatchObject({ success: true });
542-
expect(mocks.deferBudget).toHaveBeenCalledTimes(7);
543-
expect(
544-
mocks.sleep.mock.calls.filter(([delay]) => delay instanceof Date),
545-
).toHaveLength(7);
546529
expect(current?.attemptCount).toBe(5);
547-
expect(mocks.fetch).toHaveBeenCalledTimes(1);
530+
expect(mocks.fetch).toHaveBeenCalledTimes(9);
531+
const attempts = mocks.reserveBudget.mock.calls.map(
532+
([input]) => input.attemptId,
533+
);
534+
expect(new Set(attempts).size).toBe(1);
535+
expect(mocks.retry).not.toHaveBeenCalled();
548536
expect(mocks.blocked).not.toHaveBeenCalled();
549537
});
550538

551-
it("defers daily exhaustion without dispatching or permanently blocking the source", async () => {
552-
mocks.reserveBudget.mockRejectedValueOnce(
553-
new MediaProcessingBudgetError("daily"),
539+
it("does not treat an ambiguous dispatch failure as a capacity refusal", async () => {
540+
mocks.fetch.mockResolvedValueOnce(
541+
Response.json({ code: "UPSTREAM_FAILURE" }, { status: 503 }),
554542
);
555-
await expect(startDesktopRecordingJob(fixture)).resolves.toEqual({
556-
status: "deferred",
557-
});
558-
expect(mocks.deferBudget).toHaveBeenCalledWith(fixture);
559-
expect(mocks.blocked).not.toHaveBeenCalled();
560-
expect(mocks.fetch).not.toHaveBeenCalled();
561-
expect(mocks.put).not.toHaveBeenCalled();
543+
await expect(startDesktopRecordingJob(fixture)).resolves.toBeUndefined();
544+
expect(mocks.heartbeat).not.toHaveBeenCalled();
562545
});
563546

564547
it("blocks an exhausted transfer budget before dispatching media work", async () => {
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import { beforeEach, expect, it, vi } from "vitest";
2+
import {
3+
getMediaProcessingReservation,
4+
reserveMediaProcessingBudget,
5+
} from "@/lib/media-processing-budget";
6+
7+
const state = vi.hoisted(() => ({
8+
rows: new Map<
9+
string,
10+
{ id: string; limitBytes: number; reservedBytes: number }
11+
>(),
12+
}));
13+
vi.mock("@cap/database/schema", () => ({
14+
mediaProcessingBudgets: {
15+
id: "id",
16+
reservedBytes: "reservedBytes",
17+
expiresAt: "expiresAt",
18+
},
19+
}));
20+
vi.mock("drizzle-orm", () => ({
21+
asc: (value: unknown) => value,
22+
lt: vi.fn(),
23+
inArray: (_column: unknown, ids: string[]) => ids,
24+
sql: (_strings: TemplateStringsArray, ...values: unknown[]) => values,
25+
}));
26+
vi.mock("@cap/database", () => ({
27+
db: () => ({
28+
transaction: async (run: (tx: unknown) => Promise<number>) =>
29+
run({
30+
insert: () => ({
31+
values: (rows: Array<{ id: string; limitBytes: number }>) => ({
32+
onDuplicateKeyUpdate: async () => {
33+
for (const row of rows)
34+
if (!state.rows.has(row.id))
35+
state.rows.set(row.id, { ...row, reservedBytes: 0 });
36+
},
37+
}),
38+
}),
39+
select: () => ({
40+
from: () => ({
41+
where: (ids: string[]) => ({
42+
orderBy: () => ({
43+
for: async () => ids.map((id) => state.rows.get(id)),
44+
}),
45+
}),
46+
}),
47+
}),
48+
update: () => ({
49+
set: (values: { reservedBytes: [unknown, number] }) => ({
50+
where: async (ids: string[]) => {
51+
for (const id of ids) {
52+
const row = state.rows.get(id);
53+
if (row) row.reservedBytes += values.reservedBytes[1];
54+
}
55+
},
56+
}),
57+
}),
58+
}),
59+
}),
60+
}));
61+
beforeEach(() => state.rows.clear());
62+
63+
it("processes unrelated recordings regardless of the former daily allowance", async () => {
64+
vi.stubEnv("MEDIA_PROCESSING_DAILY_BUDGET_GIB", "1");
65+
try {
66+
for (let index = 0; index < 10; index++) {
67+
await expect(
68+
reserveMediaProcessingBudget({
69+
videoId: `video-${index}`,
70+
generation: "generation",
71+
attemptId: "attempt",
72+
sourceBytes: 1024 ** 3,
73+
now: new Date("2026-09-06T23:59:59Z"),
74+
}),
75+
).resolves.toBe(getMediaProcessingReservation(1024 ** 3).attemptBytes);
76+
}
77+
expect(state.rows.size).toBe(20);
78+
} finally {
79+
vi.unstubAllEnvs();
80+
}
81+
});
82+
83+
it("reuses an attempt reservation across dispatch retries and midnight", async () => {
84+
const input = {
85+
videoId: "video",
86+
generation: "generation",
87+
attemptId: "attempt",
88+
sourceBytes: 1024 ** 3,
89+
};
90+
const first = await reserveMediaProcessingBudget({
91+
...input,
92+
now: new Date("2026-09-06T23:59:59Z"),
93+
});
94+
for (let index = 0; index < 10; index++)
95+
expect(
96+
await reserveMediaProcessingBudget({
97+
...input,
98+
now: new Date("2026-09-07T00:00:01Z"),
99+
}),
100+
).toBe(first);
101+
expect([...state.rows.values()].map((row) => row.reservedBytes)).toEqual([
102+
first,
103+
first,
104+
]);
105+
});
106+
107+
it("still bounds repeated processing of the same immutable source", async () => {
108+
const input = {
109+
videoId: "video",
110+
generation: "generation",
111+
sourceBytes: 1024 ** 3,
112+
};
113+
for (let index = 0; index < 3; index++)
114+
await reserveMediaProcessingBudget({
115+
...input,
116+
attemptId: `attempt-${index}`,
117+
});
118+
await expect(
119+
reserveMediaProcessingBudget({ ...input, attemptId: "attempt-4" }),
120+
).rejects.toThrow("recording transfer budget exhausted");
121+
});

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

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

680-
export async function deferForDailyProcessingBudget({
681-
now = new Date(),
682-
...fence
683-
}: DesktopRecordingAttemptFence & { now?: Date }): Promise<boolean> {
684-
return db().transaction(async (tx) => {
685-
const condition = and(
686-
attemptCondition(fence),
687-
isNull(videoProcessingJobs.remoteJobId),
688-
);
689-
const [row] = await tx
690-
.select()
691-
.from(videoProcessingJobs)
692-
.where(condition)
693-
.for("update");
694-
if (
695-
!row ||
696-
getDesktopRecordingWorkerCheckpoint(parseDesktopRecordingJob(row))
697-
)
698-
return false;
699-
const nextRetryAt = new Date(now);
700-
nextRetryAt.setUTCHours(24, 0, 0, 0);
701-
await tx
702-
.update(videoProcessingJobs)
703-
.set({
704-
state: "retry",
705-
attemptCount: Math.max(0, row.attemptCount - 1),
706-
leaseExpiresAt: null,
707-
nextRetryAt,
708-
errorCode: "processing-daily-budget-exhausted",
709-
errorMessage:
710-
"Processing will resume when the daily transfer allowance resets.",
711-
updatedAt: now,
712-
})
713-
.where(condition);
714-
await tx
715-
.update(videoUploads)
716-
.set({
717-
phase: "processing",
718-
processingMessage:
719-
"Waiting for processing capacity. Your recording is safely stored.",
720-
processingError: null,
721-
updatedAt: now,
722-
})
723-
.where(eq(videoUploads.videoId, fence.videoId));
724-
return true;
725-
});
726-
}
727-
728680
export async function scheduleRetry({
729681
errorCode,
730682
errorMessage,

0 commit comments

Comments
 (0)