Skip to content

Commit 756577b

Browse files
committed
fix: prioritize interrupted recordings over incomplete upload backlog
1 parent 05e9bc6 commit 756577b

2 files changed

Lines changed: 112 additions & 50 deletions

File tree

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

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
heartbeatAttempt,
1212
initializeSourceCommitCheckpoint,
1313
isDesktopRecordingJobRecoverable,
14+
listRecoverableSegmentJobs,
1415
markSourceBlocked,
1516
persistCommittedSource,
1617
persistSourceCommitCheckpoint,
@@ -45,6 +46,9 @@ vi.mock("@cap/database/schema", () => {
4546
"nextRetryAt",
4647
"leaseExpiresAt",
4748
"remoteJobId",
49+
"errorCode",
50+
"verification",
51+
"output",
4852
]),
4953
};
5054
});
@@ -136,6 +140,12 @@ function createClient() {
136140
table = value.table;
137141
return query;
138142
},
143+
innerJoin() {
144+
return query;
145+
},
146+
orderBy() {
147+
return query;
148+
},
139149
where(value: Condition) {
140150
condition = value;
141151
return query;
@@ -883,3 +893,46 @@ describe("retained-source retry policy", () => {
883893
);
884894
});
885895
});
896+
897+
describe("recovery admission", () => {
898+
it("prioritizes interrupted new recordings over an older missing-source backlog", async () => {
899+
await createAttempt();
900+
const current = {
901+
...getJobRow(),
902+
state: "committing",
903+
source: null,
904+
leaseExpiresAt: new Date(now.getTime() - 1),
905+
nextRetryAt: now,
906+
};
907+
rows.jobs = Array.from({ length: 30 }, (_, index) => ({
908+
...current,
909+
videoId: `old-${index}`,
910+
state: "source-blocked",
911+
errorCode: "source-missing",
912+
nextRetryAt: new Date(now.getTime() - 24 * 60 * 60_000),
913+
}));
914+
rows.jobs.push(current);
915+
const selected = await listRecoverableSegmentJobs({ now, limit: 3 });
916+
expect(selected[0]?.videoId).toBe(videoId);
917+
expect(selected).toHaveLength(3);
918+
});
919+
920+
it("excludes exhausted and intentionally retired jobs before applying the recovery limit", async () => {
921+
await createAttempt();
922+
const current = {
923+
...getJobRow(),
924+
state: "retry",
925+
source: null,
926+
leaseExpiresAt: null,
927+
nextRetryAt: now,
928+
};
929+
rows.jobs = [
930+
"processing-retry-exhausted",
931+
"output-replaced",
932+
"video-deleting",
933+
].map((errorCode) => ({ ...current, videoId: errorCode, errorCode }));
934+
rows.jobs.push(current);
935+
const selected = await listRecoverableSegmentJobs({ now, limit: 1 });
936+
expect(selected.map((job) => job.videoId)).toEqual([videoId]);
937+
});
938+
});

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

Lines changed: 59 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -948,60 +948,58 @@ export async function listRecoverableSegmentJobs({
948948
limit?: number;
949949
} = {}): Promise<DesktopRecordingJob[]> {
950950
const batchSize = Math.max(1, Math.min(limit, 100));
951-
const pending = await db()
952-
.select(getTableColumns(videoProcessingJobs))
953-
.from(videoProcessingJobs)
954-
.innerJoin(videos, eq(videos.id, videoProcessingJobs.videoId))
955-
.where(
956-
and(
957-
inArray(videoProcessingJobs.state, [
958-
"committing",
959-
"queued",
960-
"retry",
961-
"source-blocked",
962-
]),
963-
or(
964-
ne(videoProcessingJobs.state, "source-blocked"),
965-
isNull(videoProcessingJobs.source),
966-
),
967-
or(
968-
isNull(videoProcessingJobs.errorCode),
969-
and(
970-
ne(
971-
videoProcessingJobs.errorCode,
972-
DESKTOP_RECORDING_OUTPUT_REPLACED,
951+
const candidates = async (
952+
states: DesktopRecordingJob["state"][],
953+
candidateLimit: number,
954+
byLease = false,
955+
) =>
956+
db()
957+
.select(getTableColumns(videoProcessingJobs))
958+
.from(videoProcessingJobs)
959+
.innerJoin(videos, eq(videos.id, videoProcessingJobs.videoId))
960+
.where(
961+
and(
962+
inArray(videoProcessingJobs.state, states),
963+
or(
964+
ne(videoProcessingJobs.state, "source-blocked"),
965+
isNull(videoProcessingJobs.source),
966+
),
967+
or(
968+
isNull(videoProcessingJobs.errorCode),
969+
and(
970+
ne(
971+
videoProcessingJobs.errorCode,
972+
DESKTOP_RECORDING_OUTPUT_REPLACED,
973+
),
974+
ne(videoProcessingJobs.errorCode, DESKTOP_RECORDING_DELETING),
975+
ne(
976+
videoProcessingJobs.errorCode,
977+
DESKTOP_RECORDING_RETRY_EXHAUSTED,
978+
),
973979
),
974-
ne(videoProcessingJobs.errorCode, DESKTOP_RECORDING_DELETING),
980+
),
981+
lte(videoProcessingJobs.nextRetryAt, now),
982+
or(
983+
isNull(videoProcessingJobs.leaseExpiresAt),
984+
lte(videoProcessingJobs.leaseExpiresAt, now),
975985
),
976986
),
977-
lte(videoProcessingJobs.nextRetryAt, now),
978-
or(
979-
isNull(videoProcessingJobs.leaseExpiresAt),
980-
lte(videoProcessingJobs.leaseExpiresAt, now),
987+
)
988+
.orderBy(
989+
asc(
990+
byLease
991+
? videoProcessingJobs.leaseExpiresAt
992+
: videoProcessingJobs.nextRetryAt,
981993
),
982-
),
983-
)
984-
.orderBy(
985-
asc(videoProcessingJobs.nextRetryAt),
986-
asc(videoProcessingJobs.videoId),
987-
)
988-
.limit(batchSize);
989-
const expired = await db()
990-
.select(getTableColumns(videoProcessingJobs))
991-
.from(videoProcessingJobs)
992-
.innerJoin(videos, eq(videos.id, videoProcessingJobs.videoId))
993-
.where(
994-
and(
995-
eq(videoProcessingJobs.state, "processing"),
996-
lte(videoProcessingJobs.leaseExpiresAt, now),
997-
),
998-
)
999-
.orderBy(
1000-
asc(videoProcessingJobs.leaseExpiresAt),
1001-
asc(videoProcessingJobs.videoId),
1002-
)
1003-
.limit(batchSize);
1004-
return [...pending, ...expired]
994+
asc(videoProcessingJobs.videoId),
995+
)
996+
.limit(candidateLimit);
997+
const pending = await candidates(
998+
["committing", "queued", "retry"],
999+
batchSize,
1000+
);
1001+
const expired = await candidates(["processing"], batchSize, true);
1002+
const active = [...pending, ...expired]
10051003
.map(parseDesktopRecordingJob)
10061004
.filter((job) => isDesktopRecordingJobRecoverable(job, now))
10071005
.sort((left, right) => {
@@ -1013,4 +1011,15 @@ export async function listRecoverableSegmentJobs({
10131011
);
10141012
})
10151013
.slice(0, batchSize);
1014+
if (active.length === batchSize) return active;
1015+
const blocked = await candidates(
1016+
["source-blocked"],
1017+
batchSize - active.length,
1018+
);
1019+
return [
1020+
...active,
1021+
...blocked
1022+
.map(parseDesktopRecordingJob)
1023+
.filter((job) => isDesktopRecordingJobRecoverable(job, now)),
1024+
];
10161025
}

0 commit comments

Comments
 (0)