commit 797c76b9a5f3236cebf194b3397bd7b942e42dfc
parent 5064c1156bb25260e1cb70d39a5ca934d0c90387
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Fri, 25 Sep 2026 19:04:38 -0400
editor: the queued-sync e2e reproduces the live failure (detached release)
The first version queued a sync behind a --test-slow sync, which (a) held
the queue for minutes and (b) could not fail: the next job inherits the
async context of whatever finishes the one ahead, and a job an ops request
started carries that request's store. Live, the chain began at a job the
auto-runner started, with no store anywhere.
/api/test/stuck-job takes `releaseAfterMs`: the fake holder is finished
from a timer armed inside `workAsyncStorage.exit`, i.e. outside any
request, and the route reports `detached`. The spec holds platform:youtube
that way, queues an ops sync behind it and asserts `done`. Against the
pre-fix sync body it fails with the live log line — "[error] Invariant:
static generation store missing in revalidatePath /channels/slow-b",
status `failed` — and passes with safeRevalidate.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
2 files changed, 58 insertions(+), 32 deletions(-)
diff --git a/editor/app/api/test/stuck-job/route.ts b/editor/app/api/test/stuck-job/route.ts
@@ -57,5 +57,31 @@ export async function GET(request: Request) {
/* best-effort */
}
- return NextResponse.json({ id, queueKey });
+ // `releaseAfterMs`: finish the fake job after that long FROM OUTSIDE ANY
+ // REQUEST, the way a job the auto-runner or the sync heartbeat started
+ // finishes (release 9, B1). The scheduler starts the next queued job
+ // synchronously inside `complete()`, so that job inherits the async context
+ // the release runs in — and a job started with no Next work store is exactly
+ // the one whose revalidatePath threw "static generation store missing". A
+ // plain setTimeout here would inherit THIS request's store, so the timer is
+ // armed inside `workAsyncStorage.exit`. `detached` reports whether that
+ // worked, so a spec can refuse to pass for the wrong reason.
+ const releaseAfterMs = Number(url.searchParams.get("releaseAfterMs") ?? "");
+ let detached = false;
+ if (Number.isFinite(releaseAfterMs) && releaseAfterMs > 0) {
+ const release = () => registry.finalize(id, "done");
+ try {
+ // A Next internal (typed, but not public API) — test harness only.
+ const { workAsyncStorage } =
+ await import("next/dist/server/app-render/work-async-storage.external");
+ workAsyncStorage.exit(() => {
+ detached = workAsyncStorage.getStore() === undefined;
+ setTimeout(release, releaseAfterMs);
+ });
+ } catch {
+ setTimeout(release, releaseAfterMs);
+ }
+ }
+
+ return NextResponse.json({ id, queueKey, detached });
}
diff --git a/editor/e2e/ops-api.spec.ts b/editor/e2e/ops-api.spec.ts
@@ -432,20 +432,25 @@ test("metadata-scan starts a job, and the job says it is a metadata-scan", async
// A QUEUED JOB MUST NOT END `failed` FOR REVALIDATING (release 9, B1).
//
-// A job that starts at once runs its body inside the request that submitted
-// it. One that waits behind another on the same platform queue is started
-// later by the queue's drain loop, OUTSIDE any request, and there Next's
-// revalidatePath throws "static generation store missing". The sync body calls
-// it last, so the throw landed after the work was done: the job read `failed`
-// and `pnpm ops … --wait` exited 1 (the teamrcn sync, 2026-09-25). slow-a's
-// `--test-slow` holds platform:youtube for ~30 s; slow-b is submitted behind
-// it with no slow flag, so it is the job the drain loop starts.
+// The scheduler starts the next queued job synchronously when the one ahead
+// finishes, so the next job inherits the async context of whatever finished
+// it. When that is a job the auto-runner or the sync heartbeat started — no
+// request anywhere up the chain — Next has no work store, and revalidatePath
+// throws "static generation store missing". The sync body calls it last, so the
+// throw landed after the work was done: the job read `failed` and `pnpm ops …
+// --wait` exited 1 (the teamrcn sync, 2026-09-25).
+//
+// The holder is /api/test/stuck-job on platform:youtube, released after 4 s
+// from OUTSIDE any request (`releaseAfterMs`; `detached` says the harness
+// managed it). The sync is submitted behind it, so the release is what starts
+// it — with no store, as live.
test("a sync queued behind another platform job ends done, not failed", async ({
request,
}) => {
- test.setTimeout(150_000);
+ test.setTimeout(90_000);
await resetData("two-slow-channels");
await settings();
+ // slow-b without its --test-slow flag: a sync that finishes in seconds.
await writeChannelConfig("slow-b", {
name: "Slow B",
url: "https://www.youtube.com/@slow-b/videos",
@@ -455,36 +460,31 @@ test("a sync queued behind another platform job ends done, not failed", async ({
const meta = (id: string) =>
readJson<Meta>(`test-transcripts/.jobs/${id}.meta.json`).catch(() => null);
- const first = await ops(request, "sync", { slug: "slow-a" });
- expect(first.body.ok).toBe(true);
- await expect
- .poll(async () => (await meta(first.body.jobId!))?.status ?? null)
- .toBe("running");
-
- const second = await ops(request, "sync", { slug: "slow-b" });
- expect(second.body.ok).toBe(true);
- // Same platform queue, so it waits: this is the job the drain loop starts.
- await expect
- .poll(async () => (await meta(second.body.jobId!))?.status ?? null)
- .toBe("queued");
- expect((await meta(second.body.jobId!))?.queueKey).toBe(
- (await meta(first.body.jobId!))?.queueKey,
+ const hold = await request.get(
+ `${baseUrl}/api/test/stuck-job?queue=${encodeURIComponent("platform:youtube")}&releaseAfterMs=4000`,
);
+ expect(hold.ok()).toBe(true);
+ const holder = (await hold.json()) as { id: string; detached: boolean };
+ expect(holder.detached).toBe(true);
+
+ const sync = await ops(request, "sync", { slug: "slow-b" });
+ expect(sync.body.ok).toBe(true);
+ const jobId = sync.body.jobId!;
+ // Behind the holder on the same platform queue: the release starts it.
+ await expect.poll(async () => (await meta(jobId))?.status ?? null).toBe("queued");
+ expect((await meta(jobId))?.queueKey).toBe("platform:youtube");
await expect
- .poll(async () => (await meta(second.body.jobId!))?.status ?? null, {
- timeout: 120_000,
+ .poll(async () => (await meta(jobId))?.status ?? null, {
+ timeout: 60_000,
intervals: [1_000],
})
.toMatch(/^(done|failed|cancelled)$/);
- const log = await request.get(
- `${baseUrl}/api/jobs/${second.body.jobId}/log?from=0`,
- );
- const payload = (await log.json()) as { status: string; text?: string };
- expect(JSON.stringify(payload)).not.toContain(
+ const log = await request.get(`${baseUrl}/api/jobs/${jobId}/log?from=0`);
+ expect(JSON.stringify(await log.json())).not.toContain(
"static generation store missing",
);
- expect((await meta(second.body.jobId!))?.status).toBe("done");
+ expect((await meta(jobId))?.status).toBe("done");
});
test("refresh-report regenerates snapshot.json", async ({ request }) => {