Archilyzer · Source

archilyzer

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

commit c8f7e9d5e37cbadab7a9a64aca3c092e4b87a387
parent 45ad131f4f6b43c8316ca96cba428b3887c9c1d8
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Thu,  1 Oct 2026 20:59:33 -0400

common, editor: the status memo is keyed by the settings its fold reads (D0 review H1, N8, N9)

computeLeafPending reads each lane's policy and tree and channelPriority itself,
so the memo now takes a key — JSON of settings.autoQueue + settings.channelPriority
— and a rule, focus or pause edit misses it; time stays the only other expiry,
and a late computation under an old key never overwrites a newer one. The
comments say what is fresh and what can be one poll late; requestCache.ts names
the memo as its one bounded exception; the census comment on /operations/[id] no
longer claims to share the payload's listing.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Diffstat:
Mcommon/views/autoQueueStatus.test.ts | 45++++++++++++++++++++++++++++++++-------------
Mcommon/views/autoQueueStatus.ts | 56+++++++++++++++++++++++++++++++++-----------------------
Meditor/app/lib/requestCache.ts | 3+++
Meditor/app/operations/[id]/page.tsx | 7++++---
Meditor/app/operations/status.ts | 19+++++++++++++------
5 files changed, 85 insertions(+), 45 deletions(-)

diff --git a/common/views/autoQueueStatus.test.ts b/common/views/autoQueueStatus.test.ts @@ -228,7 +228,7 @@ test("memo: concurrent callers share the computation in flight", async () => { calls++; return d.promise; }; - const all = Promise.all([memo.get(compute), memo.get(compute), memo.get(compute)]); + const all = Promise.all([memo.get("k", compute), memo.get("k", compute), memo.get("k", compute)]); d.resolve(42); assert.deepEqual(await all, [42, 42, 42]); assert.equal(calls, 1); @@ -239,11 +239,11 @@ test("memo: a landed value is reused for the window, then recomputed", async () const memo = singleFlightMemo<number>({ now: () => t }); let calls = 0; const compute = async () => ++calls; - assert.equal(await memo.get(compute), 1); + assert.equal(await memo.get("k", compute), 1); t += AUTO_QUEUE_STATUS_MEMO_MS - 1; - assert.equal(await memo.get(compute), 1, "inside the window: the memo"); + assert.equal(await memo.get("k", 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(await memo.get("k", compute), 2, "at the window's end: computed again"); assert.equal(calls, 2); }); @@ -251,36 +251,55 @@ test("memo: the window runs from when the value LANDED, not when it started", as let t = 0; const memo = singleFlightMemo<number>({ ttlMs: 3_000, now: () => t }); const d = deferred<number>(); - const first = memo.get(() => d.promise); + const first = memo.get("k", () => 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); + assert.equal(await memo.get("k", 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); + const a = memo.get("k", () => d.promise); + const b = memo.get("k", 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); + assert.equal(await memo.get("k", 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"); + assert.equal(await memo.get("k", async () => "old fixture"), "old fixture"); memo.clear(); const slow = deferred<string>(); - const before = memo.get(() => slow.promise); + const before = memo.get("k", () => 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"); + assert.equal(await memo.get("k", 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"); + assert.equal(await memo.get("k", async () => "unused"), "new fixture"); +}); + +test("memo: a different key misses the memo and the computation in flight", async () => { + const memo = singleFlightMemo<string>({ now: () => 0 }); + assert.equal(await memo.get("tree A", async () => "counts under A"), "counts under A"); + // A rule added, a focus set: the settings the fold reads changed. + assert.equal(await memo.get("tree B", async () => "counts under B"), "counts under B"); + assert.equal(await memo.get("tree B", async () => "unused"), "counts under B"); + // Asking under A again is a miss too — the stored value is B's. + assert.equal(await memo.get("tree A", async () => "A again"), "A again"); + + // In flight: a caller with another key does not join it, and the older + // computation landing late does not overwrite the newer key's value. + const slowA = deferred<string>(); + const a = memo.get("tree A2", () => slowA.promise); + assert.equal(await memo.get("tree C", async () => "C"), "C"); + slowA.resolve("late A2"); + assert.equal(await a, "late A2"); + assert.equal(await memo.get("tree C", async () => "unused"), "C"); }); diff --git a/common/views/autoQueueStatus.ts b/common/views/autoQueueStatus.ts @@ -221,18 +221,25 @@ export function buildAutoQueueStatusPayload( // 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). +// KEYED BY THE SETTINGS THE FOLD READS, AND OTHERWISE BY TIME. The fold reads +// settings itself (`computeLeafPending`: each lane's policy and rule tree, +// and `channelPriority` — paused channels, the focus, the compiled leaf ids), +// so the shell passes a key built from those (`autoQueue` + `channelPriority`): +// a rule added, a focus set or a channel paused changes the key and misses the +// memo, so a count is never keyed by a tree that is no longer the one drawn. +// What nothing tells the memo about — a snapshot rewritten, the runner picking +// a video (its in-flight set is subtracted inside the fold) — is up to +// AUTO_QUEUE_STATUS_MEMO_MS behind; the next poll is the correction. A lane's +// hold, the runner's status and its picks are not behind it at all: the shell +// reads them on every call (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>; + // The memoized value while it is fresh AND was computed under `key`; else the + // computation in flight under `key`; else `compute()`, started now and shared + // with every caller asking with `key` until it settles. A rejection is not + // memoized: the next caller computes again. + get(key: string, 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). @@ -244,34 +251,37 @@ export function singleFlightMemo<T>( ): 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; + let value: { key: string; v: T; at: number } | null = null; + let inFlight: { key: string; p: Promise<T>; seq: number } | null = null; + // Every computation started gets the next number; only the latest started + // may store its value or clear the in-flight slot, so a computation under an + // old key that lands late never overwrites a newer one. clear() bumps it too. + let seq = 0; return { - get(compute) { - if (value && now() - value.at < ttlMs) return Promise.resolve(value.v); - if (inFlight) return inFlight; - const mine = epoch; + get(key, compute) { + if (value && value.key === key && now() - value.at < ttlMs) { + return Promise.resolve(value.v); + } + if (inFlight && inFlight.key === key) return inFlight.p; + const mine = ++seq; const p = compute().then( (v) => { - if (epoch === mine) { - value = { v, at: now() }; + if (seq === mine) { + value = { key, v, at: now() }; inFlight = null; } return v; }, (err: unknown) => { - if (epoch === mine) inFlight = null; + if (seq === mine) inFlight = null; throw err; }, ); - inFlight = p; + inFlight = { key, p, seq: mine }; return p; }, clear() { - epoch++; + seq++; value = null; inFlight = null; }, diff --git a/editor/app/lib/requestCache.ts b/editor/app/lib/requestCache.ts @@ -21,6 +21,9 @@ import { getSettings } from "yt-dlp-transcript-common/lib/settings"; // scheduler rewrites these files on a ~1 s debounce from job runners inside // common/, which cannot import next/cache to invalidate anything. A cache // nothing can invalidate is just a stale number with extra steps. +// ONE EXCEPTION, bounded: the operations status poll's 3-second memo of the +// lanes' pending counts (operations/status.ts, `autoQueueStatusMemo`), keyed by +// the settings it is folded from and otherwise expired by time. // Keyed on the `paths` argument by identity, which works because getPaths() // memoizes its result at module scope and hands back the same object every // call. Pass it straight through; don't spread or rebuild it. diff --git a/editor/app/operations/[id]/page.tsx b/editor/app/operations/[id]/page.tsx @@ -280,9 +280,10 @@ export default async function OperationPage({ const sections = sectionsFor(op.id as SectionConfig["operation"]); const channelWork = sections.length > 0 ? ( - // No extra disk walk: the census is built from the request-cached - // `getChannelBriefs` that `buildAutoQueueStatusPayload` already read - // above (operations/status.ts:38). Keyed for the same reason + // The census reads the request-cached `getChannelBriefs`. It is the + // same listing `buildAutoQueueStatusPayload` read above only when that + // call missed its memo (operations/status.ts); on a hit the payload's + // counts may be up to 3 s older than this table. Keyed for the same reason // settingsFormFor's elements are — a server element handed to a client // component lands in its children array with React's dev-only key check // still to run over it. diff --git a/editor/app/operations/status.ts b/editor/app/operations/status.ts @@ -35,10 +35,13 @@ import { readPriorityView } from "./channelPriorityView"; // 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. +// channel's snapshot, and the one every poller used to pay for separately. The +// memo is KEYED by the settings the fold reads (each lane's policy and tree in +// `autoQueue`, and `channelPriority`), so an operator's edit to either misses it +// and is never paired with counts from the tree before; otherwise it holds for +// AUTO_QUEUE_STATUS_MEMO_MS (3 s) — a snapshot rewritten or a video the runner +// just picked shows at most one poll late. That window is the one exception to +// requestCache.ts's "no cache longer than a request" rule, bounded by time. type SnapshotHalf = { briefs: ChannelBrief[]; pendingByKind: LeafPending[]; @@ -68,11 +71,15 @@ export async function buildAutoQueueStatusPayload(): Promise<AutoQueueStatusPayl 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. + // runners. The pending counts are memoized under a key of the settings they + // are folded from, so an edit to a policy, a tree or a priority recomputes + // them at once; only what changes without a settings write (a snapshot, the + // runner's in-flight set) can be up to 3 s behind. + const memoKey = JSON.stringify([settings.autoQueue, settings.channelPriority]); const [priority, state, { briefs, pendingByKind }] = await Promise.all([ readPriorityView(), readAutoQueueState(paths), - autoQueueStatusMemo<SnapshotHalf>().get(computeSnapshotHalf), + autoQueueStatusMemo<SnapshotHalf>().get(memoKey, computeSnapshotHalf), ]); const byLane = <T>(values: readonly T[]): Record<AutoQueueKind, T> => Object.fromEntries(LANES.map((lane, i) => [lane, values[i]])) as Record<