commit 90ea0a8ca0a60dbfc2af889269df14de36b3d857
parent 7116122aec85ffbe5b13c2150c279eba8e0ecddf
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Thu, 1 Oct 2026 16:51:47 -0400
common, editor: the auto-queue status poll shares one fold of the snapshots
singleFlightMemo (views/autoQueueStatus.ts): concurrent callers share the
computation in flight and a landed value is reused for 3 s
(AUTO_QUEUE_STATUS_MEMO_MS), expiring by time only. The shell memoizes the
snapshot-derived half (channel briefs + the four lanes' pending work) and reads
holds, runners, picks and the priority view fresh on every call. The e2e reset
route drops the memo with the other singletons.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
4 files changed, 205 insertions(+), 5 deletions(-)
diff --git a/common/views/autoQueueStatus.test.ts b/common/views/autoQueueStatus.test.ts
@@ -8,7 +8,9 @@ import type { AutoRunnerStatus, LeafPending } from "../controller/autoRunner";
import { buildAutoQueueLanes } from "./autoQueueLanes";
import type { PriorityView } from "./channelPriority";
import {
+ AUTO_QUEUE_STATUS_MEMO_MS,
buildAutoQueueStatusPayload,
+ singleFlightMemo,
type AutoQueueStatusInputs,
} from "./autoQueueStatus";
@@ -203,3 +205,82 @@ test("the pending fold is per lane and reaches the focus banner", () => {
// A lane with nothing pending is still an entry, with the same shape.
assert.deepEqual(payload.download.pendingByLeaf, {});
});
+
+// THE POLL'S MEMO (slice D0, release 17): N pollers of the 3 s poll cost one
+// fold of the snapshots, concurrent callers share the one in flight, and time
+// is the only thing that expires it.
+
+function deferred<T>() {
+ let resolve!: (v: T) => void;
+ let reject!: (e: unknown) => void;
+ const promise = new Promise<T>((res, rej) => {
+ resolve = res;
+ reject = rej;
+ });
+ return { promise, resolve, reject };
+}
+
+test("memo: concurrent callers share the computation in flight", async () => {
+ const memo = singleFlightMemo<number>({ now: () => 0 });
+ const d = deferred<number>();
+ let calls = 0;
+ const compute = () => {
+ calls++;
+ return d.promise;
+ };
+ const all = Promise.all([memo.get(compute), memo.get(compute), memo.get(compute)]);
+ d.resolve(42);
+ assert.deepEqual(await all, [42, 42, 42]);
+ assert.equal(calls, 1);
+});
+
+test("memo: a landed value is reused for the window, then recomputed", async () => {
+ let t = 1_000;
+ const memo = singleFlightMemo<number>({ now: () => t });
+ let calls = 0;
+ const compute = async () => ++calls;
+ assert.equal(await memo.get(compute), 1);
+ t += AUTO_QUEUE_STATUS_MEMO_MS - 1;
+ assert.equal(await memo.get(compute), 1, "inside the window: the memo");
+ t += 1;
+ assert.equal(await memo.get(compute), 2, "at the window's end: computed again");
+ assert.equal(calls, 2);
+});
+
+test("memo: the window runs from when the value LANDED, not when it started", async () => {
+ let t = 0;
+ const memo = singleFlightMemo<number>({ ttlMs: 3_000, now: () => t });
+ const d = deferred<number>();
+ const first = memo.get(() => d.promise);
+ t = 10_000; // a slow fold: ten seconds
+ d.resolve(7);
+ assert.equal(await first, 7);
+ t = 12_000;
+ assert.equal(await memo.get(async () => 8), 7);
+});
+
+test("memo: a rejection is shared by its waiters and never memoized", async () => {
+ const memo = singleFlightMemo<number>({ now: () => 0 });
+ const d = deferred<number>();
+ const a = memo.get(() => d.promise);
+ const b = memo.get(async () => 99);
+ d.reject(new Error("drive not answering"));
+ await assert.rejects(a, /drive not answering/);
+ await assert.rejects(b, /drive not answering/);
+ assert.equal(await memo.get(async () => 5), 5);
+});
+
+test("memo: clear() drops the value and detaches the computation in flight", async () => {
+ const memo = singleFlightMemo<string>({ now: () => 0 });
+ assert.equal(await memo.get(async () => "old fixture"), "old fixture");
+ memo.clear();
+ const slow = deferred<string>();
+ const before = memo.get(() => slow.promise);
+ memo.clear();
+ // A caller after the clear does not join the detached computation…
+ assert.equal(await memo.get(async () => "new fixture"), "new fixture");
+ // …and when it lands, it neither answers the memo nor evicts the new value.
+ slow.resolve("stale");
+ assert.equal(await before, "stale");
+ assert.equal(await memo.get(async () => "unused"), "new fixture");
+});
diff --git a/common/views/autoQueueStatus.ts b/common/views/autoQueueStatus.ts
@@ -208,3 +208,87 @@ export function buildAutoQueueStatusPayload(
lanes: inputs.lanes,
};
}
+
+// THE POLL'S EXPENSIVE HALF, SHARED: single-flight plus a short memo.
+//
+// The payload above is cheap; what it is built FROM is not. Each lane's
+// pending work is a fold over every channel's snapshot (the shell's
+// `computeLeafPending`, four lanes), and every surface that shows the lanes
+// polls it every 3 s (`useOperationsStatus`). Each poll used to compute its
+// own: on 2026-10-01, with the main thread busy regenerating a report, one
+// computation took 96 s, and every tab's poll started another behind it — a
+// queue that only grew. Now concurrent callers share the computation in flight,
+// and a result is reused for AUTO_QUEUE_STATUS_MEMO_MS after it lands, so N
+// pollers cost one fold per window.
+//
+// TIME IS THE ONLY INVALIDATION. Nothing tells the memo that a snapshot moved;
+// the window is short enough that the next poll is the correction. What the
+// operator toggles — a lane's hold, the runner, its picks — is NOT behind it:
+// the shell reads those fresh on every call and memoizes only the
+// snapshot-derived half (editor/app/operations/status.ts).
+export const AUTO_QUEUE_STATUS_MEMO_MS = 3_000;
+
+export type SingleFlightMemo<T> = {
+ // The memoized value while it is fresh; else the computation in flight; else
+ // `compute()`, started now and shared with every caller until it settles. A
+ // rejection is not memoized: the next caller computes again.
+ get(compute: () => Promise<T>): Promise<T>;
+ // Forget the value AND detach the computation in flight, whose result is
+ // then dropped rather than stored (a test reset must not be answered with
+ // the previous fixture's numbers).
+ clear(): void;
+};
+
+export function singleFlightMemo<T>(
+ opts: { ttlMs?: number; now?: () => number } = {},
+): SingleFlightMemo<T> {
+ const ttlMs = opts.ttlMs ?? AUTO_QUEUE_STATUS_MEMO_MS;
+ const now = opts.now ?? Date.now;
+ let value: { v: T; at: number } | null = null;
+ let inFlight: Promise<T> | null = null;
+ // Bumped by clear(): a computation started under an older epoch neither
+ // stores its value nor clears a newer computation's slot.
+ let epoch = 0;
+ return {
+ get(compute) {
+ if (value && now() - value.at < ttlMs) return Promise.resolve(value.v);
+ if (inFlight) return inFlight;
+ const mine = epoch;
+ const p = compute().then(
+ (v) => {
+ if (epoch === mine) {
+ value = { v, at: now() };
+ inFlight = null;
+ }
+ return v;
+ },
+ (err: unknown) => {
+ if (epoch === mine) inFlight = null;
+ throw err;
+ },
+ );
+ inFlight = p;
+ return p;
+ },
+ clear() {
+ epoch++;
+ value = null;
+ inFlight = null;
+ },
+ };
+}
+
+// The editor's one instance, on globalThis like the registry: the polled route
+// and the operations pages are separate bundles, and the e2e reset route
+// (editor/app/api/test/invalidate-cache) drops it through the same global.
+declare global {
+ // eslint-disable-next-line no-var
+ var __yttAutoQueueStatusMemo__: SingleFlightMemo<unknown> | undefined;
+}
+
+export function autoQueueStatusMemo<T>(): SingleFlightMemo<T> {
+ if (!globalThis.__yttAutoQueueStatusMemo__) {
+ globalThis.__yttAutoQueueStatusMemo__ = singleFlightMemo<unknown>();
+ }
+ return globalThis.__yttAutoQueueStatusMemo__ as SingleFlightMemo<T>;
+}
diff --git a/editor/app/api/test/invalidate-cache/route.ts b/editor/app/api/test/invalidate-cache/route.ts
@@ -125,6 +125,14 @@ function invalidate() {
// a spec that rewrites a title in place inside one mtime tick would otherwise
// see the previous spec's title.
resetVideoTitleMemo();
+ // And the auto-queue status poll's three-second memo of the snapshot-derived
+ // pending counts (common/views/autoQueueStatus.ts): the next spec's first
+ // poll must count ITS fixture, not the previous spec's. Dropped through the
+ // global like the singletons above, so this route imports nothing that
+ // reaches the runner; a computation still in flight lands in the dropped
+ // object.
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (globalThis as any).__yttAutoQueueStatusMemo__ = undefined;
revalidatePath("/", "layout");
return NextResponse.json({ ok: true });
}
diff --git a/editor/app/operations/status.ts b/editor/app/operations/status.ts
@@ -14,9 +14,11 @@ import { LANES } from "yt-dlp-transcript-common/lib/autoQueueTypes";
import { getWorkerPool } from "yt-dlp-transcript-common/jobs/workerPool";
import { buildAutoQueueLanes } from "yt-dlp-transcript-common/views/autoQueueLanes";
import {
+ autoQueueStatusMemo,
buildAutoQueueStatusPayload as build,
type AutoQueueStatusPayload,
} from "yt-dlp-transcript-common/views/autoQueueStatus";
+import type { ChannelBrief } from "yt-dlp-transcript-common/controller/channels";
import { getChannelBriefs } from "../lib/requestCache";
import { readPriorityView } from "./channelPriorityView";
@@ -29,12 +31,22 @@ import { readPriorityView } from "./channelPriorityView";
// times per poll, because `buildKind` did its own reading; and the channel
// briefs are shared with the lanes builder through the per-request cache, and
// with the four computeLeafPending calls through their `shared` argument.
-export async function buildAutoQueueStatusPayload(): Promise<AutoQueueStatusPayload> {
+
+// THE SNAPSHOT-DERIVED HALF, behind the shared single-flight memo
+// (`autoQueueStatusMemo`, common/views/autoQueueStatus.ts): the channel briefs
+// and the four lanes' pending work. It is the expensive half, a fold over every
+// channel's snapshot, and the one every poller used to pay for separately. A
+// window of AUTO_QUEUE_STATUS_MEMO_MS (3 s) is the one exception to
+// requestCache.ts's "no cache longer than a request" rule, and it is bounded by
+// time alone: a count up to 3 s old, on a payload polled every 3 s.
+type SnapshotHalf = {
+ briefs: ChannelBrief[];
+ pendingByKind: LeafPending[];
+};
+
+async function computeSnapshotHalf(): Promise<SnapshotHalf> {
const paths = getPaths();
- const settings = getSettings();
- const pool = getWorkerPool();
- const [priority, state, briefs] = await Promise.all([
- readPriorityView(),
+ const [state, briefs] = await Promise.all([
readAutoQueueState(paths),
getChannelBriefs(paths),
]);
@@ -47,6 +59,21 @@ export async function buildAutoQueueStatusPayload(): Promise<AutoQueueStatusPayl
computeLeafPending(lane, paths, { configs: briefs, state }),
),
);
+ return { briefs, pendingByKind };
+}
+
+export async function buildAutoQueueStatusPayload(): Promise<AutoQueueStatusPayload> {
+ const paths = getPaths();
+ const settings = getSettings();
+ const pool = getWorkerPool();
+ // FRESH on every call: the priority view, the state document (picks,
+ // cooldowns, deferrals), the settings (holds, policies), the pool and the
+ // runners — everything an operator's click changes.
+ const [priority, state, { briefs, pendingByKind }] = await Promise.all([
+ readPriorityView(),
+ readAutoQueueState(paths),
+ autoQueueStatusMemo<SnapshotHalf>().get(computeSnapshotHalf),
+ ]);
const byLane = <T>(values: readonly T[]): Record<AutoQueueKind, T> =>
Object.fromEntries(LANES.map((lane, i) => [lane, values[i]])) as Record<
AutoQueueKind,