mirror of
https://github.com/misenhower/splatoon2.ink.git
synced 2026-09-28 04:06:52 -05:00
Retain a combined history of the latest 50 updater runs
This commit is contained in:
@@ -196,6 +196,8 @@ reported as failed rather than automatically replaying an uncertain social send.
|
||||
The hourly schedule is preserved. Paused scheduling also blocks manual runs.
|
||||
|
||||
The panel polls live application log lines every two seconds during a run and
|
||||
retains the latest 50 manual and scheduled runs in one history, including failed
|
||||
attempts. Older deployments contribute their two existing results. Each run
|
||||
stores up to the latest 200 lines (500 characters each, 32 KB total) with its
|
||||
final summary.
|
||||
Known secret values are redacted from captured lines. This includes updater,
|
||||
|
||||
@@ -159,6 +159,8 @@ describe('Scheduler', () => {
|
||||
vi.setSystemTime(status.retryAt);
|
||||
status = await runAlarmUntil(scheduler, s => s.retryAt === null);
|
||||
|
||||
expect(status.runHistory).toHaveLength(4);
|
||||
expect(status.runHistory.every(run => run.trigger === 'scheduled' && !run.ok)).toBe(true);
|
||||
expect(status.retries).toBe(0);
|
||||
expect(status.alarmAt).toBe(hourlyAt + HOUR_MS);
|
||||
});
|
||||
@@ -342,8 +344,93 @@ describe('Background manual runs', () => {
|
||||
|
||||
expect(status.lastManualRun).toMatchObject({ id: 'due', ok: true, mode: 'data' });
|
||||
expect(status.lastRun).toMatchObject({ ok: true, mode: 'both', social: { ok: true } });
|
||||
expect(status.runHistory.map(run => run.trigger)).toEqual(['scheduled', 'manual']);
|
||||
expect(status.hourlyAt).toBe(hourlyAt + HOUR_MS);
|
||||
});
|
||||
|
||||
it('preserves older results when starting a combined run history', async () => {
|
||||
const scheduler = stub();
|
||||
const now = Date.now();
|
||||
|
||||
await runInDurableObject(scheduler, async (_instance, ctx) => {
|
||||
await ctx.storage.put('state', {
|
||||
automaticSchedulingEnabled: false,
|
||||
lastRun: {
|
||||
startedAt: now - 2000,
|
||||
ok: false,
|
||||
mode: 'both',
|
||||
},
|
||||
});
|
||||
await ctx.storage.put('lastManualRun', {
|
||||
startedAt: now - 1000,
|
||||
ok: true,
|
||||
mode: 'data',
|
||||
});
|
||||
await ctx.storage.put('pendingManual', {
|
||||
id: 'interrupted',
|
||||
status: 'running',
|
||||
mode: 'social',
|
||||
startedAt: now,
|
||||
});
|
||||
});
|
||||
|
||||
await scheduler.ensureArmed();
|
||||
|
||||
expect((await scheduler.status()).runHistory.map(run => run.trigger))
|
||||
.toEqual(['manual', 'scheduled']);
|
||||
|
||||
const status = await runAlarmUntil(scheduler, s => s.lastManualRun?.id === 'interrupted');
|
||||
|
||||
expect(status.runHistory.map(run => [run.trigger, run.ok])).toEqual([
|
||||
['manual', false],
|
||||
['manual', true],
|
||||
['scheduled', false],
|
||||
]);
|
||||
expect((await scheduler.status()).runHistory).toEqual(status.runHistory);
|
||||
});
|
||||
|
||||
it('retains only the newest 50 runs, including their logs', async () => {
|
||||
const scheduler = stub();
|
||||
const now = Date.now();
|
||||
const logs = { lines: [{ text: 'x'.repeat(30000) }], omitted: 0 };
|
||||
|
||||
await runInDurableObject(scheduler, async (_instance, ctx) => {
|
||||
await ctx.storage.put('state', { automaticSchedulingEnabled: false });
|
||||
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const startedAt = now - 1000 + i;
|
||||
|
||||
await ctx.storage.put(`run:${String(startedAt).padStart(15, '0')}:${i}`, {
|
||||
id: String(i),
|
||||
startedAt,
|
||||
trigger: 'scheduled',
|
||||
ok: true,
|
||||
logs,
|
||||
});
|
||||
}
|
||||
|
||||
await ctx.storage.put('pendingManual', {
|
||||
id: 'latest',
|
||||
status: 'running',
|
||||
mode: 'data',
|
||||
startedAt: now,
|
||||
});
|
||||
});
|
||||
|
||||
await scheduler.ensureArmed();
|
||||
|
||||
const status = await runAlarmUntil(scheduler, s => s.lastManualRun?.id === 'latest');
|
||||
|
||||
expect(status.runHistory).toHaveLength(50);
|
||||
expect(status.runHistory[0].id).toBe('latest');
|
||||
expect(status.runHistory.at(-1).id).toBe('1');
|
||||
expect(status.runHistory[1].logs).toEqual(logs);
|
||||
|
||||
await runInDurableObject(scheduler, async (_instance, ctx) => {
|
||||
expect((await ctx.storage.list({ prefix: 'run:' })).size).toBe(50);
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects invalid modes and paused scheduling', async () => {
|
||||
const scheduler = stub();
|
||||
|
||||
|
||||
@@ -44,6 +44,11 @@ const state = {
|
||||
},
|
||||
};
|
||||
|
||||
state.runHistory = [
|
||||
{ ...state.lastRun, trigger: 'scheduled' },
|
||||
{ ...state.lastManualRun, trigger: 'manual' },
|
||||
];
|
||||
|
||||
createServer(async (request, response) => {
|
||||
response.setHeader('Cache-Control', 'no-store');
|
||||
|
||||
@@ -171,6 +176,8 @@ createServer(async (request, response) => {
|
||||
? { ok: true, skipped: true }
|
||||
: { ok: true, posts: [{ name: 'Schedule', ok: true, simulated: true }] },
|
||||
};
|
||||
state.runHistory.unshift({ ...state.lastManualRun, trigger: 'manual' });
|
||||
state.runHistory = state.runHistory.slice(0, 50);
|
||||
state.busy = false;
|
||||
state.pendingManual = null;
|
||||
state.activeRun = null;
|
||||
|
||||
@@ -10,6 +10,7 @@ import { createLogger, describeError } from './log.mjs';
|
||||
|
||||
const RETRY_DELAY_MS = 60 * 1000;
|
||||
const MAX_RETRIES = 3;
|
||||
const MAX_STORED_RUNS = 50;
|
||||
export const MANUAL_MODES = ['data', 'social', 'both'];
|
||||
|
||||
export class Scheduler extends DurableObject {
|
||||
@@ -39,6 +40,41 @@ export class Scheduler extends DurableObject {
|
||||
return (await this.ctx.storage.get('pendingManual')) ?? null;
|
||||
}
|
||||
|
||||
async #runHistory(storage = this.ctx.storage) {
|
||||
const records = await storage.list({ prefix: 'run:', reverse: true });
|
||||
|
||||
if (records.size)
|
||||
return [...records.values()];
|
||||
|
||||
// Keep the two results retained by older deployments when history starts.
|
||||
const state = await storage.get('state');
|
||||
const manual = await storage.get('lastManualRun');
|
||||
|
||||
return [
|
||||
state?.lastRun && { ...state.lastRun, trigger: 'scheduled' },
|
||||
manual && { ...manual, trigger: 'manual' },
|
||||
].filter(Boolean).sort((a, b) => b.startedAt - a.startedAt);
|
||||
}
|
||||
|
||||
async #recordRun(storage, result, trigger) {
|
||||
const records = await storage.list({ prefix: 'run:', reverse: true });
|
||||
const previous = records.size ? [] : await this.#runHistory(storage);
|
||||
const run = { ...result, trigger, id: result.id ?? crypto.randomUUID() };
|
||||
let sequence = Number(records.keys().next().value?.split(':')[1] ?? 0);
|
||||
|
||||
// Separate records keep each run's logs out of one growing storage value.
|
||||
for (const entry of [...previous.reverse(), run]) {
|
||||
const key = `run:${String(++sequence).padStart(15, '0')}`;
|
||||
await storage.put(key, entry);
|
||||
records.set(key, entry);
|
||||
}
|
||||
|
||||
const expired = [...records.keys()].sort().reverse().slice(MAX_STORED_RUNS);
|
||||
|
||||
if (expired.length)
|
||||
await storage.delete(expired);
|
||||
}
|
||||
|
||||
async ensureArmed() {
|
||||
if (this.#running)
|
||||
return { armed: false, busy: true };
|
||||
@@ -144,6 +180,7 @@ export class Scheduler extends DurableObject {
|
||||
...(await this.#state()),
|
||||
alarmAt: await this.ctx.storage.getAlarm(),
|
||||
lastManualRun: (await this.ctx.storage.get('lastManualRun')) ?? null,
|
||||
runHistory: await this.#runHistory(),
|
||||
pendingManual,
|
||||
activeRun: this.#activeRun,
|
||||
busy: this.#running || !!pendingManual,
|
||||
@@ -197,7 +234,10 @@ export class Scheduler extends DurableObject {
|
||||
|
||||
let result = await this.#execute(only);
|
||||
|
||||
await this.ctx.storage.put('lastManualRun', result);
|
||||
await this.ctx.storage.transaction(async txn => {
|
||||
await this.#recordRun(txn, result, 'manual');
|
||||
await txn.put('lastManualRun', result);
|
||||
});
|
||||
|
||||
return result;
|
||||
} finally {
|
||||
@@ -300,12 +340,15 @@ export class Scheduler extends DurableObject {
|
||||
}
|
||||
|
||||
await this.ctx.storage.transaction(async txn => {
|
||||
await txn.put('lastManualRun', {
|
||||
const completed = {
|
||||
...manual,
|
||||
...result,
|
||||
status: result.ok ? 'succeeded' : 'failed',
|
||||
finishedAt: Date.now(),
|
||||
});
|
||||
};
|
||||
|
||||
await this.#recordRun(txn, completed, 'manual');
|
||||
await txn.put('lastManualRun', completed);
|
||||
await txn.delete('pendingManual');
|
||||
});
|
||||
}
|
||||
@@ -320,10 +363,12 @@ export class Scheduler extends DurableObject {
|
||||
|
||||
let scheduledFor = state.retryAt ?? state.hourlyAt;
|
||||
|
||||
let scheduledRun;
|
||||
|
||||
if (Date.now() >= scheduledFor) {
|
||||
let result = await this.#execute();
|
||||
|
||||
state.lastRun = {
|
||||
scheduledRun = {
|
||||
...result,
|
||||
scheduledFor,
|
||||
driftMs: result.startedAt - scheduledFor,
|
||||
@@ -331,6 +376,8 @@ export class Scheduler extends DurableObject {
|
||||
retryCount: alarmInfo?.retryCount ?? 0,
|
||||
};
|
||||
|
||||
state.lastRun = scheduledRun;
|
||||
|
||||
if (result.ok || state.retries >= MAX_RETRIES) {
|
||||
state.hourlyAt = nextRunAt();
|
||||
state.retryAt = null;
|
||||
@@ -343,8 +390,13 @@ export class Scheduler extends DurableObject {
|
||||
|
||||
// Storage failures escape so the platform retries. Schedule and state are saved
|
||||
// together without external I/O in between.
|
||||
await this.ctx.storage.put('state', state);
|
||||
await this.ctx.storage.setAlarm(state.retryAt ?? state.hourlyAt);
|
||||
await this.ctx.storage.transaction(async txn => {
|
||||
if (scheduledRun)
|
||||
await this.#recordRun(txn, scheduledRun, 'scheduled');
|
||||
|
||||
await txn.put('state', state);
|
||||
await txn.setAlarm(state.retryAt ?? state.hourlyAt);
|
||||
});
|
||||
} finally {
|
||||
this.#running = false;
|
||||
}
|
||||
|
||||
@@ -720,12 +720,15 @@
|
||||
}
|
||||
|
||||
// Keep expanded details open during polling when the records have not changed.
|
||||
const signature = JSON.stringify([state.lastManualRun, state.lastRun]);
|
||||
const signature = JSON.stringify(state.runHistory);
|
||||
|
||||
if (element('history').dataset.signature !== signature) {
|
||||
element('history').replaceChildren(
|
||||
renderRunRecord('Last manual run', state.lastManualRun),
|
||||
renderRunRecord('Last scheduled run', state.lastRun),
|
||||
...(state.runHistory?.length
|
||||
? state.runHistory.map(run => renderRunRecord(
|
||||
run.trigger === 'scheduled' ? 'Scheduled run' : 'Manual run', run,
|
||||
))
|
||||
: [renderRunRecord('Run history', null)]),
|
||||
);
|
||||
element('history').dataset.signature = signature;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user