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:
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,