Skip to content

Commit 98ded12

Browse files
committed
fix: fence recording workers and surface stalled processing
1 parent ff25b5a commit 98ded12

15 files changed

Lines changed: 1309 additions & 134 deletions
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
name: Recording Reliability
2+
3+
on:
4+
workflow_dispatch:
5+
pull_request:
6+
paths:
7+
- "apps/web/**"
8+
- "apps/media-server/**"
9+
- "packages/database/**"
10+
- "packages/web-*/**"
11+
- "crates/recording/**"
12+
- "pnpm-lock.yaml"
13+
- ".github/workflows/recording-reliability.yml"
14+
push:
15+
branches: [main]
16+
paths:
17+
- "apps/web/**"
18+
- "apps/media-server/**"
19+
- "packages/database/**"
20+
- "packages/web-*/**"
21+
- "crates/recording/**"
22+
- "pnpm-lock.yaml"
23+
- ".github/workflows/recording-reliability.yml"
24+
25+
permissions:
26+
contents: read
27+
28+
concurrency:
29+
group: recording-reliability-${{ github.head_ref || github.ref_name }}
30+
cancel-in-progress: true
31+
32+
jobs:
33+
recording-contract:
34+
name: Recording ownership, preservation, and recovery
35+
runs-on: ubuntu-24.04
36+
timeout-minutes: 15
37+
steps:
38+
- uses: actions/checkout@v4
39+
- uses: ./.github/actions/setup-js
40+
- name: Verify recording contracts
41+
working-directory: apps/web
42+
run: |
43+
pnpm exec vitest run \
44+
__tests__/unit/desktop-recording-*.test.ts \
45+
__tests__/unit/desktop-segments-*.test.ts \
46+
__tests__/unit/finalize-desktop-recording.test.ts \
47+
__tests__/unit/media-server-progress.test.ts
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2+
3+
const { recover, getHealth } = vi.hoisted(() => ({
4+
recover: vi.fn(),
5+
getHealth: vi.fn(),
6+
}));
7+
8+
vi.mock("@/lib/desktop-segments-recovery", () => ({
9+
recoverStaleDesktopSegments: recover,
10+
}));
11+
vi.mock("@/lib/desktop-recording-health", () => ({
12+
getDesktopRecordingHealth: getHealth,
13+
}));
14+
15+
import { GET } from "@/app/api/cron/finalize-stale-desktop-segments/route";
16+
17+
const healthy = {
18+
status: "healthy",
19+
checkedAt: "2026-09-04T14:00:00.000Z",
20+
scope: "unresolved",
21+
stalledWorkers: 0,
22+
stalledCommits: 0,
23+
retryLoops: 0,
24+
blockedCommittedSources: 0,
25+
changedSources: 0,
26+
};
27+
28+
function request(token = "test-cron-secret") {
29+
return new Request(
30+
"http://localhost/api/cron/finalize-stale-desktop-segments",
31+
{
32+
headers: { authorization: `Bearer ${token}` },
33+
},
34+
);
35+
}
36+
37+
beforeEach(() => {
38+
vi.stubEnv("CRON_SECRET", "test-cron-secret");
39+
recover.mockResolvedValue({ checked: 1, statuses: {}, results: [] });
40+
getHealth.mockResolvedValue(healthy);
41+
vi.spyOn(console, "error").mockImplementation(() => {});
42+
});
43+
44+
afterEach(() => vi.unstubAllEnvs());
45+
46+
describe("recording recovery health reporting", () => {
47+
it("does not inspect or recover recordings without cron authentication", async () => {
48+
expect((await GET(request("wrong"))).status).toBe(401);
49+
expect(recover).not.toHaveBeenCalled();
50+
expect(getHealth).not.toHaveBeenCalled();
51+
});
52+
53+
it("does not classify incomplete uploads as processing incidents", async () => {
54+
recover.mockResolvedValue({
55+
checked: 2,
56+
statuses: { "source-incomplete": 2 },
57+
results: [],
58+
});
59+
const response = await GET(request());
60+
expect(response.status).toBe(200);
61+
expect((await response.json()).health).toEqual(healthy);
62+
expect(console.error).not.toHaveBeenCalled();
63+
});
64+
65+
it("runs recovery before surfacing persistent retry loops as a failed cron", async () => {
66+
getHealth.mockResolvedValue({
67+
...healthy,
68+
status: "degraded",
69+
retryLoops: 2,
70+
});
71+
const response = await GET(request());
72+
expect(recover).toHaveBeenCalledOnce();
73+
expect(recover.mock.invocationCallOrder[0]).toBeLessThan(
74+
getHealth.mock.invocationCallOrder[0] ?? 0,
75+
);
76+
expect(response.status).toBe(503);
77+
expect(await response.json()).toMatchObject({
78+
success: false,
79+
health: { retryLoops: 2 },
80+
});
81+
expect(console.error).toHaveBeenCalledWith(
82+
"[recording-health] Processing needs attention",
83+
expect.objectContaining({ retryLoops: 2, recoveryFailures: 0 }),
84+
);
85+
});
86+
87+
it("surfaces failed recovery even when no aged jobs remain", async () => {
88+
recover.mockResolvedValue({
89+
checked: 1,
90+
statuses: { failed: 1 },
91+
results: [],
92+
});
93+
expect((await GET(request())).status).toBe(503);
94+
});
95+
96+
it("does not report healthy when the health query fails", async () => {
97+
getHealth.mockRejectedValue(new Error("Database unavailable"));
98+
await expect(GET(request())).rejects.toThrow("Database unavailable");
99+
expect(recover).toHaveBeenCalledOnce();
100+
});
101+
});
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
3+
const { select, from, where } = vi.hoisted(() => ({
4+
select: vi.fn(),
5+
from: vi.fn(),
6+
where: vi.fn(),
7+
}));
8+
9+
vi.mock("@cap/database", () => ({ db: () => ({ select }) }));
10+
11+
import { getDesktopRecordingHealth } from "@/lib/desktop-recording-health";
12+
13+
const counts = {
14+
stalledWorkers: 0,
15+
stalledCommits: 0,
16+
retryLoops: 0,
17+
blockedCommittedSources: 0,
18+
changedSources: 0,
19+
};
20+
21+
beforeEach(() => {
22+
select.mockReturnValue({ from });
23+
from.mockReturnValue({ where });
24+
where.mockResolvedValue([counts]);
25+
});
26+
27+
describe("recording health", () => {
28+
it("returns aggregate counts without recording identities or content", async () => {
29+
where.mockResolvedValue([{ ...counts, retryLoops: 2, ownerId: "private" }]);
30+
expect(await getDesktopRecordingHealth()).toMatchObject({
31+
status: "degraded",
32+
retryLoops: 2,
33+
});
34+
expect(await getDesktopRecordingHealth()).not.toHaveProperty("ownerId");
35+
});
36+
37+
it.each([
38+
undefined,
39+
{ ...counts, retryLoops: Number.NaN },
40+
{ ...counts, retryLoops: -1 },
41+
])(
42+
"fails closed when the database does not return valid health counts",
43+
async (row) => {
44+
where.mockResolvedValue(row ? [row] : []);
45+
await expect(getDesktopRecordingHealth()).rejects.toThrow();
46+
},
47+
);
48+
});

apps/web/__tests__/unit/desktop-recording-job-status.test.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,115 @@ const input = {
1616
afterEach(() => vi.unstubAllGlobals());
1717

1818
describe("recording job completion reconciliation", () => {
19+
it("does not treat a replica-local 404 as worker death", async () => {
20+
const fetcher = vi
21+
.fn()
22+
.mockResolvedValue(new Response(null, { status: 404 }));
23+
vi.stubGlobal("fetch", fetcher);
24+
expect(await observeDesktopRecordingJob(input)).toEqual({
25+
status: "unavailable",
26+
delivered: false,
27+
});
28+
expect(fetcher).toHaveBeenCalledOnce();
29+
});
30+
31+
it("identifies workers whose lease can only be renewed by their own callback", async () => {
32+
vi.stubGlobal(
33+
"fetch",
34+
vi.fn().mockResolvedValue(
35+
Response.json({
36+
jobId: "job",
37+
videoId: "video",
38+
phase: "processing",
39+
recordingWorker: { version: 1, action: "progress", sequence: 3 },
40+
}),
41+
),
42+
);
43+
expect(await observeDesktopRecordingJob(input)).toEqual({
44+
status: "active",
45+
delivered: false,
46+
workerProtocol: 1,
47+
});
48+
});
49+
50+
it.each([
51+
{ success: true },
52+
{
53+
recordingWorker: {
54+
version: 1,
55+
status: "accepted",
56+
generation: "generation",
57+
attemptId: "attempt",
58+
jobId: "other",
59+
sequence: 4,
60+
},
61+
},
62+
{
63+
recordingWorker: {
64+
version: 1,
65+
status: "stale",
66+
generation: "generation",
67+
attemptId: "attempt",
68+
jobId: "job",
69+
sequence: 4,
70+
},
71+
},
72+
])(
73+
"requires an exact terminal acknowledgement for owned workers",
74+
async (ack) => {
75+
const fetcher = vi
76+
.fn()
77+
.mockResolvedValueOnce(
78+
Response.json({
79+
jobId: "job",
80+
videoId: "video",
81+
generation: "generation",
82+
attemptId: "attempt",
83+
phase: "complete",
84+
recordingWorker: { version: 1, action: "progress", sequence: 4 },
85+
}),
86+
)
87+
.mockResolvedValueOnce(Response.json(ack));
88+
vi.stubGlobal("fetch", fetcher);
89+
expect(await observeDesktopRecordingJob(input)).toEqual({
90+
status: "terminal",
91+
delivered: false,
92+
});
93+
},
94+
);
95+
96+
it("accepts an exact redelivery acknowledgement after the completion ACK was lost", async () => {
97+
const fence = {
98+
generation: "generation",
99+
attemptId: "attempt",
100+
jobId: "job",
101+
};
102+
vi.stubGlobal(
103+
"fetch",
104+
vi
105+
.fn()
106+
.mockResolvedValueOnce(
107+
Response.json({
108+
...fence,
109+
videoId: "video",
110+
phase: "complete",
111+
recordingWorker: { version: 1, action: "progress", sequence: 4 },
112+
}),
113+
)
114+
.mockResolvedValueOnce(
115+
Response.json({
116+
recordingWorker: {
117+
...fence,
118+
version: 1,
119+
status: "accepted",
120+
sequence: 4,
121+
},
122+
}),
123+
),
124+
);
125+
expect(await reconcileDesktopRecordingJob(input)).toBe(true);
126+
});
127+
19128
it("redelivers a lost completion through the validated webhook", async () => {
20129
const proof = {
21130
request: { version: 1 },

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

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
type DesktopRecordingJob,
88
ensureSegmentProcessingJob,
99
getDesktopRecordingRetryDelay,
10+
getDesktopRecordingWorkerCheckpoint,
1011
heartbeatAttempt,
1112
initializeSourceCommitCheckpoint,
1213
isDesktopRecordingJobRecoverable,
@@ -270,6 +271,60 @@ describe("durable recording job ownership", () => {
270271
expect(rows.jobs?.[0]?.attemptCount).toBe(1);
271272
});
272273

274+
it("keeps physical worker leases private and clears their checkpoint for a new attempt", async () => {
275+
const first = await createAttempt();
276+
expect(await persistCommittedSource(first, source)).toBe(true);
277+
const row = rows.jobs?.[0];
278+
if (!row) throw new Error("Missing processing job");
279+
Object.assign(row, {
280+
remoteJobId: "physical-worker",
281+
output: {
282+
version: 1,
283+
kind: "recording-worker",
284+
generation: first.generation,
285+
attemptId: first.attemptId,
286+
jobId: "physical-worker",
287+
sequence: 5,
288+
phase: "processing",
289+
progress: 60,
290+
payloadSha256: "d".repeat(64),
291+
updatedAt: now.toISOString(),
292+
stateChangedAt: now.toISOString(),
293+
},
294+
});
295+
expect(await heartbeatAttempt(first)).toBe(false);
296+
vi.setSystemTime(new Date(now.getTime() + 6 * 60_000));
297+
const next = await claimProcessingAttempt({
298+
videoId,
299+
generation: first.generation,
300+
});
301+
expect(next).toMatchObject({ source, remoteJobId: null, output: null });
302+
expect(next?.attemptId).not.toBe(first.attemptId);
303+
});
304+
305+
it("rejects a worker checkpoint transplanted from another physical attempt", async () => {
306+
const first = await createAttempt();
307+
expect(() =>
308+
getDesktopRecordingWorkerCheckpoint({
309+
...first,
310+
remoteJobId: "current-worker",
311+
output: {
312+
version: 1,
313+
kind: "recording-worker",
314+
generation: first.generation,
315+
attemptId: first.attemptId,
316+
jobId: "other-worker",
317+
sequence: 0,
318+
phase: "queued",
319+
progress: 0,
320+
payloadSha256: "d".repeat(64),
321+
updatedAt: now.toISOString(),
322+
stateChangedAt: now.toISOString(),
323+
},
324+
}),
325+
).toThrow("does not match its owner");
326+
});
327+
273328
it("rejects stale attempt heartbeats, remote jobs, and errors after a retry takes over", async () => {
274329
const old = await createAttempt();
275330
vi.setSystemTime(new Date(now.getTime() + 6 * 60_000));

0 commit comments

Comments
 (0)