Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

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:
Meditor/app/api/test/stuck-job/route.ts | 28+++++++++++++++++++++++++++-
Meditor/e2e/ops-api.spec.ts | 62+++++++++++++++++++++++++++++++-------------------------------
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 }) => {