Harvester pauses user refreshes after AniList stops (BL-0051)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
23a87099c5
commit
552a599475
2 files changed
+116
-13
No files matched your search
@@ -148,7 +148,7 @@ test('shouldRunNightly', () => {
|
||||
});
|
||||
|
||||
// A run that waits until the test releases it, so queue state can be observed.
|
||||
function gatedHarvester({ onDone = async () => {}, now }) {
|
||||
function gatedHarvester({ onDone = async () => {}, now, load }) {
|
||||
const gates = [];
|
||||
const patches = [];
|
||||
const done = [];
|
||||
@@ -156,7 +156,7 @@ function gatedHarvester({ onDone = async () => {}, now }) {
|
||||
calls: [],
|
||||
async fresh() {
|
||||
this.calls.push('fresh');
|
||||
await new Promise((resolve) => gates.push(resolve));
|
||||
await new Promise((resolve, reject) => gates.push({ resolve, reject }));
|
||||
return [];
|
||||
},
|
||||
};
|
||||
@@ -165,17 +165,19 @@ function gatedHarvester({ onDone = async () => {}, now }) {
|
||||
const harvester = new Harvester({
|
||||
cache,
|
||||
client,
|
||||
store: { patch(value) { patches.push(structuredClone(value)); } },
|
||||
store: { patch(value) { patches.push(structuredClone(value)); }, ...(load ? { load } : {}) },
|
||||
targetsFor: (kind, user) => [{ user: user || 'all', anchors: [], mature: false }],
|
||||
onDone: async (job, result) => { done.push({ job, result }); await onDone(job, result); },
|
||||
now,
|
||||
});
|
||||
const release = async () => {
|
||||
// Lets the waiting request finish, or fail with `error`.
|
||||
const release = async (error) => {
|
||||
for (let i = 0; !gates.length; i++) {
|
||||
if (i > 1000) throw new Error('no harvest request is waiting');
|
||||
await new Promise(setImmediate);
|
||||
}
|
||||
gates.shift()();
|
||||
const gate = gates.shift();
|
||||
if (error) gate.reject(error); else gate.resolve();
|
||||
await new Promise(setImmediate);
|
||||
};
|
||||
return { harvester, patches, done, release, client };
|
||||
@@ -259,3 +261,82 @@ test('onDone failure does not stall the queue', async (t) => {
|
||||
assert.equal(h.harvester.current(), null);
|
||||
assert.ok(errors.length >= 2);
|
||||
});
|
||||
|
||||
test('AniList stop pauses user refreshes until an admin or nightly run', async () => {
|
||||
let clock = Date.parse('2026-10-25T10:00:00Z');
|
||||
const h = gatedHarvester({ now: () => clock });
|
||||
const { harvester, client } = h;
|
||||
harvester.request({ kind: 'user', user: 'ann' });
|
||||
harvester.request({ kind: 'user', user: 'bob' });
|
||||
assert.equal(harvester.state('bob').state, 'queued');
|
||||
|
||||
// (a) 429 while ann runs: bob's queued refresh is dropped without touching AniList.
|
||||
await h.release(new AniListStop('rate-limited'));
|
||||
await settle();
|
||||
assert.equal(client.calls.length, 1);
|
||||
assert.equal(harvester.current(), null);
|
||||
assert.deepEqual(harvester.state('bob'), { state: 'idle', blockedReason: 'rate-limited' });
|
||||
assert.deepEqual(h.patches.map((p) => [p.harvest.user, p.harvest.status, p.harvest.stopReason ?? null]),
|
||||
[['ann', 'running', null], ['ann', 'stopped', 'rate-limited']]);
|
||||
assert.deepEqual(h.done.map((d) => d.job.user), ['ann']);
|
||||
|
||||
// (b) a new refresh is refused while paused, even for a user outside any cooldown.
|
||||
clock += 2 * REFRESH_COOLDOWN_MS;
|
||||
assert.throws(() => harvester.request({ kind: 'user', user: 'cid' }), (error) => {
|
||||
assert.equal(error.status, 503);
|
||||
assert.equal(error.blockedReason, 'rate-limited');
|
||||
assert.match(error.message, /AniList paused the harvest: rate-limited/);
|
||||
return true;
|
||||
});
|
||||
assert.equal(client.calls.length, 1);
|
||||
|
||||
// (c) an accepted admin run is the allowed retry and lifts the pause.
|
||||
harvester.request({ kind: 'admin' });
|
||||
assert.equal(harvester.state('cid').blockedReason, undefined);
|
||||
await h.release();
|
||||
await settle();
|
||||
assert.equal(client.calls.length, 2);
|
||||
assert.equal(harvester.request({ kind: 'user', user: 'cid' }).position, 0);
|
||||
await h.release();
|
||||
await settle();
|
||||
});
|
||||
|
||||
test('pause survives a restart through the stored harvest record', () => {
|
||||
const load = () => ({ harvest: { status: 'stopped', stopReason: 'forbidden' } });
|
||||
const { harvester } = gatedHarvester({ now: () => 0, load });
|
||||
assert.throws(() => harvester.request({ kind: 'user', user: 'ann' }),
|
||||
(error) => error.status === 503 && error.blockedReason === 'forbidden');
|
||||
assert.equal(harvester.state('ann').blockedReason, 'forbidden');
|
||||
|
||||
const budgetStop = gatedHarvester({ now: () => 0, load: () => ({ harvest: { status: 'stopped', stopReason: 'budget' } }) });
|
||||
assert.deepEqual(budgetStop.harvester.state('ann'), { state: 'idle' });
|
||||
const broken = gatedHarvester({ now: () => 0, load: () => { throw new Error('corrupt'); } });
|
||||
assert.deepEqual(broken.harvester.state('ann'), { state: 'idle' });
|
||||
});
|
||||
|
||||
test('unknown kinds are refused', () => {
|
||||
const { harvester } = gatedHarvester({ now: () => 0 });
|
||||
for (const kind of ['constructor', 'toString', 'other'])
|
||||
assert.throws(() => harvester.request({ kind }), (error) => error.status === 400);
|
||||
});
|
||||
|
||||
test('local fault keeps the real request count and async targets work', async (t) => {
|
||||
const original = console.error;
|
||||
console.error = () => {};
|
||||
t.after(() => { console.error = original; });
|
||||
let puts = 0;
|
||||
const cache = { isFresh: () => false, get: () => null, put() { if (++puts === 2) throw new Error('disk full'); } };
|
||||
const patches = [];
|
||||
const done = [];
|
||||
const harvester = new Harvester({
|
||||
cache, client: fakeClient(), store: { patch(value) { patches.push(value); } },
|
||||
targetsFor: async () => [{ user: 'ann', anchors: [anchor(1)], mature: false }],
|
||||
onDone: async (job, result) => { done.push(result); },
|
||||
});
|
||||
harvester.request({ kind: 'admin' });
|
||||
await settle();
|
||||
assert.deepEqual(patches.at(-1).harvest.status, 'stopped');
|
||||
assert.equal(patches.at(-1).harvest.stopReason, 'error');
|
||||
assert.equal(patches.at(-1).harvest.requests, 2);
|
||||
assert.equal(done.length, 1);
|
||||
});
|
||||
Reference in new issue
Block a user