Archilyzer · Source

archilyzer

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

commit 02318dfc0e426f2bc020b0c5ffb4ec5fed9e92f6
parent 7a0f18c35bad4fe920d9984d66b428cbb534b107
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Fri, 25 Sep 2026 18:47:40 -0400

common: a backoff merge takes until and fails each to their max

Both merges of a platform's backoff — the runner folding in the disk copy,
and recordDownloadBackoff folding the disk copy into the live object —
took one record whole (the later until). A short manual cooldown could
then carry a lost escalation count. mergeBackoffEntry merges the two
fields separately (release 8 review, S); platformBackoff.test.ts +1.

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

Diffstat:
Mcommon/controller/autoRunner.ts | 12++++++++----
Mcommon/jobs/downloadBackoff.ts | 14++++++++------
Mcommon/jobs/platformBackoff.test.ts | 19+++++++++++++++++++
Mcommon/jobs/platformBackoff.ts | 13+++++++++++++
4 files changed, 48 insertions(+), 10 deletions(-)

diff --git a/common/controller/autoRunner.ts b/common/controller/autoRunner.ts @@ -72,6 +72,7 @@ import { import { isCoolingDown, isVideoDeferred, + mergeBackoffEntry, pruneDeferred, pruneExpired, } from "../jobs/platformBackoff"; @@ -1612,14 +1613,17 @@ async function runLoop( if (kind === "download") { // Merge in any cooldown a manual sync/import wrote to the shared state // (read-modify-write from outside the runner) since our last persist. - // Take the later `until` so a sync-set 429 cooldown is honored even though - // our own next persist would otherwise clobber it. + // `until` and `fails` each merge to their max, so a sync-set 429 cooldown + // is honored even though our own next persist would otherwise clobber it, + // and neither side's escalation count is lost. try { const persisted = (await readAutoQueueState(paths)).download .platformBackoff; for (const [pf, e] of Object.entries(persisted)) { - const cur = kindState.platformBackoff[pf]; - if (!cur || e.until > cur.until) kindState.platformBackoff[pf] = e; + kindState.platformBackoff[pf] = mergeBackoffEntry( + kindState.platformBackoff[pf], + e, + ); } } catch { // Best-effort: a transient read failure just skips this iteration's merge. diff --git a/common/jobs/downloadBackoff.ts b/common/jobs/downloadBackoff.ts @@ -25,7 +25,7 @@ import { readAutoQueueState, writeAutoQueueState, } from "./autoQueueState"; -import { nextBackoff, pruneExpired } from "./platformBackoff"; +import { mergeBackoffEntry, nextBackoff, pruneExpired } from "./platformBackoff"; // Milliseconds remaining in the platform's current cooldown window, or 0 if it // is not cooling down. Prefers the live shared object; else reads the file. @@ -50,13 +50,15 @@ export async function recordDownloadBackoff( const live = await liveAutoQueueState(paths); let state: AutoQueueState; if (live) { - // Fold in whatever is on disk first (the later `until` wins, as the - // runner's own merge does), so a cooldown written while no runner held the - // object is escalated from, not forgotten. + // Fold in whatever is on disk first (`until` and `fails` each to their + // max, as the runner's own merge does), so a cooldown written while no + // runner held the object is escalated from, not forgotten. const onDisk = (await readAutoQueueState(paths)).download.platformBackoff; for (const [pf, e] of Object.entries(onDisk)) { - const cur = live.download.platformBackoff[pf]; - if (!cur || e.until > cur.until) live.download.platformBackoff[pf] = e; + live.download.platformBackoff[pf] = mergeBackoffEntry( + live.download.platformBackoff[pf], + e, + ); } state = live; } else { diff --git a/common/jobs/platformBackoff.test.ts b/common/jobs/platformBackoff.test.ts @@ -7,6 +7,7 @@ import { clearBackoff, coercePlatformBackoff, isCoolingDown, + mergeBackoffEntry, nextBackoff, pruneExpired, VIDEO_RATE_LIMIT_DEFER_MS, @@ -145,3 +146,21 @@ test("coerceVideoDeferrals tolerates corrupt/missing shapes", () => { { ok: { until: 5, channelSlug: "a" } }, ); }); + +// Release 8 review, S: the two records merge field by field. +test("mergeBackoffEntry takes the max of until and of fails separately", () => { + assert.deepEqual(mergeBackoffEntry(undefined, { until: 5, fails: 2 }), { + until: 5, + fails: 2, + }); + // A short manual cooldown on disk with a low count must not erase the live + // escalation, and a later disk `until` must not be lost either. + assert.deepEqual( + mergeBackoffEntry({ until: 100, fails: 13 }, { until: 200, fails: 1 }), + { until: 200, fails: 13 }, + ); + assert.deepEqual( + mergeBackoffEntry({ until: 300, fails: 1 }, { until: 200, fails: 7 }), + { until: 300, fails: 7 }, + ); +}); diff --git a/common/jobs/platformBackoff.ts b/common/jobs/platformBackoff.ts @@ -39,6 +39,19 @@ export function nextBackoff( return { until: now + Math.round(base * jitter), fails }; } +// Fold two records of the same platform's backoff — the live one and the one +// on disk — into one. `until` and `fails` merge SEPARATELY, each to its max: +// taking one record whole (the later `until`) could pair a short manual +// cooldown with a lost escalation count, so the next 429 restarted at the +// base delay (release 8 review, S). +export function mergeBackoffEntry( + a: PlatformBackoffEntry | undefined, + b: PlatformBackoffEntry, +): PlatformBackoffEntry { + if (!a) return b; + return { until: Math.max(a.until, b.until), fails: Math.max(a.fails, b.fails) }; +} + // True when the platform is currently in a cooldown window. export function isCoolingDown( state: PlatformBackoffState,