/** * Timer events — the bus's scheduler surface * (designs/DISPATCHER_DAEMON_AND_TIMER_EVENTS.md B1). * * The running dispatcher calls `emitDueTimerTicks` every loop tick. * For each interval in the fixed menu it emits a `timer.tick.` * event when BOTH hold: * * 1. at least one subscriber pattern matches the type — no * subscribers means no junk events filling the bus, and * 2. the interval has elapsed since the last emission, tracked in * the `timer_state` table (NOT the retention-swept events table). * * Missed ticks are never backfilled: a bus that was down for a day * gets one `timer.tick.15m` when the dispatcher returns, not 96. * Modules consume ticks through ordinary manifest `subscriptions:`, * e.g. `{ pattern: timer.tick.15m, hook: refresh_registrations }`. * * The menu is deliberately fixed: arbitrary per-subscriber intervals * would mean parsing periods out of the subscriber table; five named * intervals keep the contract dumb and greppable. */ import type { Bus } from './bus'; export interface TimerInterval { /** Interval name as it appears in the event type (`timer.tick.`). */ name: string; ms: number; } export const TIMER_INTERVALS: readonly TimerInterval[] = [ // 1m exists for re-assertion against hardware that regenerates config on its // own schedule, where the coarser intervals leave too wide a wrong-value // window. Costs nothing until something subscribes (the loop skips any // interval with no matching subscriber), and the dispatcher polls at 1s so a // 1-minute tick is well inside its resolution. Reach for a coarser interval // unless the drift being corrected is genuinely faster — every subscriber // here runs 60x/hour forever. { name: '1m', ms: 60_000 }, { name: '5m', ms: 5 * 60_000 }, { name: '15m', ms: 15 * 60_000 }, { name: '1h', ms: 60 * 60_000 }, { name: '1d', ms: 24 * 60 * 60_000 }, ]; export interface TimerTickPayload { interval: string; scheduled_at: number; } /** * Emit every due, subscribed-to timer tick. Returns the event types * emitted (mainly for tests/logging). `now` is injectable for tests. */ export function emitDueTimerTicks(bus: Bus, now: number = Date.now()): string[] { const emitted: string[] = []; for (const { name, ms } of TIMER_INTERVALS) { const type = `timer.tick.${name}`; if (!bus.hasSubscriberMatching(type)) continue; // Claim the slot BEFORE emitting, in one statement. Read-then-emit-then-write // let two dispatchers both see a tick as due and emit it twice — two distinct // events, two deliveries, and a handler that genuinely runs twice per interval. // Per-delivery claiming can't catch that: each duplicate event is legitimately // its own delivery. The window is sub-millisecond, which is why it produced a // rare mystery rather than an obvious one (#580). The conditional upsert makes // the loser's `changes` 0, so only one dispatcher emits. const claimed = bus.db.run( `INSERT INTO timer_state (interval, last_emitted_at) VALUES (?, ?) ON CONFLICT(interval) DO UPDATE SET last_emitted_at = excluded.last_emitted_at WHERE excluded.last_emitted_at - timer_state.last_emitted_at >= ?`, [name, now, ms], ); if (claimed.changes === 0) continue; const payload: TimerTickPayload = { interval: name, scheduled_at: now }; bus.emitRaw(type, payload, { emittedBy: 'dispatcher-timer' }); emitted.push(type); } return emitted; }