Retain a combined history of the latest 50 updater runs

This commit is contained in:
Matt Isenhower
2026-09-13 12:54:30 -07:00
parent d92eaec75c
commit ef24b6183a
5 changed files with 160 additions and 9 deletions

View File

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

View File

@@ -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();

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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;
}