diff options
| author | Adam Malczewski <[email protected]> | 2026-06-01 10:41:35 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-01 10:41:35 +0900 |
| commit | dbabca525ee70126b3d2264dad88c0dcca630b83 (patch) | |
| tree | 3682514520e2108f3b318f9a9c1896744a1da545 /packages/api/src | |
| parent | ee8af7448a72fbd49710d8df24ecda4c23a58a8d (diff) | |
| download | dispatch-dbabca525ee70126b3d2264dad88c0dcca630b83.tar.gz dispatch-dbabca525ee70126b3d2264dad88c0dcca630b83.zip | |
fix(api): wake-schedule — accept skewed toggles, atomic persist, boot-recovery reason
Three review-finding fixes in models.ts + regression tests:
1. POST /wake-schedule/toggle no longer rejects 'past' timestamps
(Gemini #1, High). Client-server clock skew + request latency
meant a freshly-computed nextOccurrenceAt(HH:MM) for an imminent
slot could land in the past by the time the server validated it,
silently failing the UI toggle. The scheduler's recoverScheduleEntry
already fires within MISSED_WAKE_GRACE_MS and rolls forward by
24h × N, so the strict <= now check was actively harmful. Kept
Number.isFinite + slot-present validation.
2. persistSchedule is now transactional (Gemini #3, Medium). The old
DELETE-then-N-INSERTs path, when an INSERT failed mid-loop, left
the table empty (DELETE had committed) and silently wiped the
user's schedule on next boot — the catch swallowed the error.
Wrapped both in db.transaction(...): on failure everything rolls
back, in-memory state is untouched, and the previously persisted
snapshot stays intact.
3. Boot-recovery reason no longer masked when boot recovery + due
slots coincide (Gemini #5, Nit). Capture bootFireRequested before
clearing the flag and append ' (boot recovery)' to the reason
so the lastWake/pendingRetry surface tells the truth.
Tests:
- Replaced 'POST toggle rejects past timestamp' (the bug-as-feature
test) with 'POST toggle ACCEPTS a slightly-past timestamp (clock
skew / latency)' regression guard.
- Added 'POST toggle rejects NaN / Infinity / non-number slot values'
to lock the malformed-input path.
- Added 'snapshot remains consistent across toggle round-trips
(persistSchedule atomicity)' — exercises GET/POST cycles to ensure
the transactional impl agrees with itself across add/remove.
All 427 tests pass; biome clean.
Diffstat (limited to 'packages/api/src')
| -rw-r--r-- | packages/api/src/routes/models.ts | 51 |
1 files changed, 35 insertions, 16 deletions
diff --git a/packages/api/src/routes/models.ts b/packages/api/src/routes/models.ts index 5f37a1f..5f47231 100644 --- a/packages/api/src/routes/models.ts +++ b/packages/api/src/routes/models.ts @@ -688,22 +688,31 @@ function persistSchedule(scheduleToSave?: WakeSchedule): void { try { const db = getDatabase(); const data = scheduleToSave ?? wakeSchedule; - db.run("DELETE FROM wake_schedule"); const insert = db.query( "INSERT INTO wake_schedule (hour, slot_minute, next_wake_at) VALUES ($hour, $slot, $nextWakeAt)", ); - for (const [hour, slots] of Object.entries(data)) { - for (const [slotMinute, nextWakeAt] of Object.entries(slots)) { - if (nextWakeAt === undefined) continue; - insert.run({ - $hour: Number(hour), - $slot: Number(slotMinute), - $nextWakeAt: nextWakeAt, - }); + // One atomic transaction: DELETE + every INSERT either all commit or all + // roll back. Without this, an INSERT failure (disk full, bad row, etc.) + // would leave the table empty — silently wiping the user's schedule on + // next boot since the DELETE has already committed. + const writeAll = db.transaction(() => { + db.run("DELETE FROM wake_schedule"); + for (const [hour, slots] of Object.entries(data)) { + for (const [slotMinute, nextWakeAt] of Object.entries(slots)) { + if (nextWakeAt === undefined) continue; + insert.run({ + $hour: Number(hour), + $slot: Number(slotMinute), + $nextWakeAt: nextWakeAt, + }); + } } - } + }); + writeAll(); } catch { - // Ignore DB errors — schedule still lives in-memory for this process. + // Ignore DB errors — schedule still lives in-memory for this process, + // and the previously persisted snapshot stays intact thanks to the + // transaction rollback above. } } @@ -825,7 +834,8 @@ async function schedulerTick(): Promise<void> { const due = collectDueSlots(now); let firedThisTick = false; - if (due.length > 0 || needsBootFire) { + const bootFireRequested = needsBootFire; + if (due.length > 0 || bootFireRequested) { needsBootFire = false; // Advance every due slot before firing — so a slow upstream call // can't cause us to re-fire the same slot on the next tick. @@ -836,8 +846,11 @@ async function schedulerTick(): Promise<void> { persistSchedule(); const reasonParts = due.map((d) => `${d.hour}:${String(d.minute).padStart(2, "0")}`); + const fromBoot = bootFireRequested ? " (boot recovery)" : ""; const reason = - reasonParts.length > 0 ? `scheduled probe(s) ${reasonParts.join(", ")}` : "boot recovery"; + reasonParts.length > 0 + ? `scheduled probe(s) ${reasonParts.join(", ")}${fromBoot}` + : "boot recovery"; firedThisTick = true; // COALESCED: one upstream call covers all slots due this tick. await fireWake(reason); @@ -912,12 +925,18 @@ modelsRoutes.post("/wake-schedule/toggle", async (c) => { 400, ); } - const now = Date.now(); const parsed: Partial<Record<ProbeSlotMinute, number>> = {}; for (const slot of PROBE_SLOT_MINUTES) { const raw = (timestamps as Record<string, unknown>)[String(slot)]; - if (typeof raw !== "number" || !Number.isFinite(raw) || raw <= now) { - return c.json({ error: `timestamps['${slot}'] must be a future Unix ms value` }, 400); + // Accept any finite Unix-ms number. We deliberately do NOT reject + // past timestamps: client-server clock skew + request latency mean + // a freshly-computed `nextOccurrenceAt(HH:MM)` for an imminent slot + // can land "in the past" by the time the server validates it. The + // scheduler tick handles past entries correctly via + // `recoverScheduleEntry` — fires within MISSED_WAKE_GRACE_MS, then + // advances by 24h * N to the next future occurrence. + if (typeof raw !== "number" || !Number.isFinite(raw)) { + return c.json({ error: `timestamps['${slot}'] must be a finite Unix ms value` }, 400); } parsed[slot] = raw; } |
