Archilyzer · Source

archilyzer

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

commit 185ad18d52f584424567c52178adb5094bacd0cb
parent 639abe30a9563367759168705e9155103eed9121
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Fri, 25 Sep 2026 11:02:50 -0400

common: applyUnitOutcome — one seam for a finished unit's backoff and deferral

The body of the runner's per-unit outcome block moves to the pure
jobs/unitOutcome.ts and is extended: rate_limit now also defers the video
6 h (line "... <id> deferred 6h; next video after cooldown."), network is
backoff only (today's line), transcribed clears the cooldown, every branch
prunes lapsed deferrals. autoRunner.ts is the call + onLog + markCompleted;
persist() unchanged. unitOutcome.test.ts: 7 cases.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

Diffstat:
Mcommon/controller/autoRunner.ts | 56+++++++++++++++++++++++---------------------------------
Acommon/jobs/unitOutcome.test.ts | 105+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/jobs/unitOutcome.ts | 85+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 213 insertions(+), 33 deletions(-)

diff --git a/common/controller/autoRunner.ts b/common/controller/autoRunner.ts @@ -67,12 +67,8 @@ import { recordPick, writeAutoQueueState, } from "../jobs/autoQueueState"; -import { - clearBackoff, - isCoolingDown, - nextBackoff, - pruneExpired, -} from "../jobs/platformBackoff"; +import { isCoolingDown, pruneExpired } from "../jobs/platformBackoff"; +import { applyUnitOutcome } from "../jobs/unitOutcome"; import { type DownloadFailureClass } from "../lib/availability"; import { resolveCookiePolicy } from "../lib/cookiePolicy"; import { isAutoSubsOnly } from "../lib/subtitleProvenance"; @@ -1933,33 +1929,27 @@ async function runLoop( reportRunnerProgress(Math.max(0, lastPending - 1)); } - // Per-platform backoff: a rate-limit / network failure pauses the whole - // platform with an exponential cooldown, and the video is deliberately NOT - // marked completed so it's retried once the cooldown lapses. Any other - // outcome marks the video done-for-session; a success also clears the - // platform's cooldown. - const backoffHit = - unitPlatform !== null && - (result.failureClass === "rate_limit" || - result.failureClass === "network"); - if (backoffHit && unitPlatform) { - const entry = nextBackoff( - kindState.platformBackoff[unitPlatform], - Date.now(), - ); - kindState.platformBackoff[unitPlatform] = entry; - const secs = Math.round((entry.until - Date.now()) / 1000); - onLog( - `Auto-download: ${unitPlatform} ${result.failureClass} — backing off ${secs}s (attempt ${entry.fails}). ${pick.videoId} will retry after cooldown.`, - ); - } else { - if (unitPlatform && result.outcome === "transcribed") { - clearBackoff(kindState.platformBackoff, unitPlatform); - } - // An operation lane retires (operation, video) pairs inside - // runOperationPick — it is the only thing that knows WHICH operation - // ran — so a bare id here would be a key nothing ever reads. - if (!isOperationLane(kind)) markCompleted(pick.videoId); + // Pacing: a rate-limit / network failure backs the whole platform off, + // and a rate-limited video is also deferred so the next pick after the + // cooldown is a DIFFERENT video — see unitOutcome.ts. Any other outcome + // retires the video for the session; a success clears the cooldown. + const effect = applyUnitOutcome( + kindState, + { + platform: unitPlatform, + videoId: pick.videoId, + channelSlug, + outcome: result.outcome, + ...(result.failureClass ? { failureClass: result.failureClass } : {}), + }, + Date.now(), + ); + if (effect.line) onLog(effect.line); + // An operation lane retires (operation, video) pairs inside + // runOperationPick — it is the only thing that knows WHICH operation + // ran — so a bare id here would be a key nothing ever reads. + if (effect.markCompleted && !isOperationLane(kind)) { + markCompleted(pick.videoId); } // Persist so the cooldown (and reset) survive a restart. persist(); diff --git a/common/jobs/unitOutcome.test.ts b/common/jobs/unitOutcome.test.ts @@ -0,0 +1,105 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + BACKOFF_BASE_MS, + VIDEO_RATE_LIMIT_DEFER_MS, + isVideoDeferred, +} from "./platformBackoff"; +import { type UnitOutcomeState, applyUnitOutcome } from "./unitOutcome"; + +// Run with: pnpm --filter yt-dlp-transcript-common exec tsx --test jobs/unitOutcome.test.ts + +const noJitter = () => 0.5; +const empty = (): UnitOutcomeState => ({ platformBackoff: {}, videoDeferrals: {} }); +const unit = ( + videoId: string, + over: Partial<Parameters<typeof applyUnitOutcome>[1]> = {}, +) => ({ + platform: "youtube", + videoId, + channelSlug: "alpha", + outcome: "failed" as const, + ...over, +}); + +test("rate_limit backs the platform off AND defers the video 6h", () => { + const s = empty(); + const now = 1_000_000; + const r = applyUnitOutcome(s, unit("v1", { failureClass: "rate_limit" }), now, noJitter); + assert.equal(r.markCompleted, false); + assert.deepEqual(s.platformBackoff.youtube, { until: now + BACKOFF_BASE_MS, fails: 1 }); + assert.deepEqual(s.videoDeferrals.v1, { + until: now + VIDEO_RATE_LIMIT_DEFER_MS, + channelSlug: "alpha", + }); + assert.equal( + r.line, + "Auto-download: youtube rate_limit — backing off 60s (attempt 1). v1 deferred 6h; next video after cooldown.", + ); +}); + +test("network backs off only — no deferral, today's line", () => { + const s = empty(); + const r = applyUnitOutcome(s, unit("v1", { failureClass: "network" }), 0, noJitter); + assert.equal(r.markCompleted, false); + assert.equal(s.platformBackoff.youtube.fails, 1); + assert.deepEqual(s.videoDeferrals, {}); + assert.equal( + r.line, + "Auto-download: youtube network — backing off 60s (attempt 1). v1 will retry after cooldown.", + ); +}); + +test("transcribed clears the platform cooldown and retires the video", () => { + const s = empty(); + s.platformBackoff.youtube = { until: 10, fails: 3 }; + const r = applyUnitOutcome(s, unit("v1", { outcome: "transcribed" }), 0, noJitter); + assert.deepEqual(r, { markCompleted: true, line: null }); + assert.deepEqual(s.platformBackoff, {}); +}); + +test("any other failure retires the video and leaves the cooldown alone", () => { + const s = empty(); + s.platformBackoff.youtube = { until: 10, fails: 3 }; + const r = applyUnitOutcome(s, unit("v1", { failureClass: "per_video" }), 0, noJitter); + assert.deepEqual(r, { markCompleted: true, line: null }); + assert.deepEqual(s.platformBackoff.youtube, { until: 10, fails: 3 }); +}); + +test("a non-download unit (no platform) touches neither map", () => { + const s = empty(); + const r = applyUnitOutcome( + s, + unit("v1", { platform: null, failureClass: "rate_limit" }), + 0, + noJitter, + ); + assert.deepEqual(r, { markCompleted: true, line: null }); + assert.deepEqual(s, empty()); +}); + +test("every branch prunes lapsed deferrals", () => { + const s = empty(); + s.videoDeferrals = { + old: { until: 5, channelSlug: "a" }, + live: { until: 50, channelSlug: "a" }, + }; + applyUnitOutcome(s, unit("v1", { outcome: "skipped" }), 10, noJitter); + assert.deepEqual(Object.keys(s.videoDeferrals), ["live"]); +}); + +test("fails climbs only across DISTINCT ids: the deferred video is not re-picked", () => { + // The runner's pick skips a deferred id, so after a cooldown the next 429 is + // necessarily a different video — the escalation now measures the platform, + // not one stubborn video. + const s = empty(); + let now = 0; + const ids = ["v1", "v2", "v3"]; + for (const id of ids) { + assert.equal(isVideoDeferred(s.videoDeferrals, id, now), false); + applyUnitOutcome(s, unit(id, { failureClass: "rate_limit" }), now, noJitter); + now = s.platformBackoff.youtube.until; // the cooldown lapses + } + assert.equal(s.platformBackoff.youtube.fails, 3); + for (const id of ids) assert.equal(isVideoDeferred(s.videoDeferrals, id, now), true); +}); diff --git a/common/jobs/unitOutcome.ts b/common/jobs/unitOutcome.ts @@ -0,0 +1,85 @@ +// What one finished auto-queue unit does to the lane's persisted pacing state — +// the per-platform backoff and the per-video deferral. Pure: no I/O, `now` and +// `rand` injected, so the whole decision is unit-testable (the runner's loop +// that calls it is not exported). autoRunner.ts calls it once per unit, logs the +// line, retires the video when told to, and persists. +// +// - `rate_limit` (HTTP 429 / throttle): the platform backs off exponentially +// (nextBackoff) AND the video is deferred for VIDEO_RATE_LIMIT_DEFER_MS, so +// after the cooldown the runner moves on to the NEXT video instead of +// re-picking the same one. A 429 is per video on YouTube (the subtitle +// fetch), and with `order: "listed"` the same video sits at the head of the +// queue: without the deferral one video climbs `fails` to the 30-minute cap +// and holds the whole platform (2026-09-24, one Short retried 12x). +// - `network`: backoff only — the video is retried after the cooldown (today's +// behaviour; a network error says nothing about the video). +// - either: the video is NOT retired for the session (`markCompleted: false`). +// - anything else retires it; a success (`transcribed`) also clears the +// platform's cooldown. +// - every branch prunes lapsed deferrals, keeping the map bounded. + +import type { DownloadFailureClass } from "../lib/availability"; +import { + type PlatformBackoffState, + type VideoDeferralState, + clearBackoff, + deferVideo, + nextBackoff, + pruneDeferred, +} from "./platformBackoff"; + +export type UnitOutcomeState = { + platformBackoff: PlatformBackoffState; + videoDeferrals: VideoDeferralState; +}; + +export type FinishedUnit = { + // The download platform the unit ran against; null on a non-download lane. + platform: string | null; + videoId: string; + channelSlug: string; + outcome: "transcribed" | "skipped" | "failed"; + failureClass?: DownloadFailureClass; +}; + +export type UnitOutcomeEffect = { + // Retire the video for the session (the caller still skips this on an + // operation lane, which retires (operation, video) pairs itself). + markCompleted: boolean; + // The line to log, or null when there is nothing to say. + line: string | null; +}; + +export function applyUnitOutcome( + state: UnitOutcomeState, + unit: FinishedUnit, + now: number, + rand: () => number = Math.random, +): UnitOutcomeEffect { + pruneDeferred(state.videoDeferrals, now); + const pf = unit.platform; + if ( + pf !== null && + (unit.failureClass === "rate_limit" || unit.failureClass === "network") + ) { + const entry = nextBackoff(state.platformBackoff[pf], now, rand); + state.platformBackoff[pf] = entry; + const secs = Math.round((entry.until - now) / 1000); + const head = `Auto-download: ${pf} ${unit.failureClass} — backing off ${secs}s (attempt ${entry.fails}).`; + if (unit.failureClass === "rate_limit") { + deferVideo(state.videoDeferrals, unit.videoId, unit.channelSlug, now); + return { + markCompleted: false, + line: `${head} ${unit.videoId} deferred 6h; next video after cooldown.`, + }; + } + return { + markCompleted: false, + line: `${head} ${unit.videoId} will retry after cooldown.`, + }; + } + if (pf !== null && unit.outcome === "transcribed") { + clearBackoff(state.platformBackoff, pf); + } + return { markCompleted: true, line: null }; +}