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 | |
| 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')
| -rw-r--r-- | packages/api/src/routes/models.ts | 51 | ||||
| -rw-r--r-- | packages/api/tests/routes.test.ts | 85 |
2 files changed, 111 insertions, 25 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; } diff --git a/packages/api/tests/routes.test.ts b/packages/api/tests/routes.test.ts index 090131c..c44f473 100644 --- a/packages/api/tests/routes.test.ts +++ b/packages/api/tests/routes.test.ts @@ -481,16 +481,56 @@ describe("Wake schedule routes", () => { expect(res.status).toBe(400); }); - it("POST toggle rejects past timestamp in any slot", async () => { - const future = Date.now() + 60_000; - const badTimestamps: Record<string, number> = { - "0": future, - "15": Date.now() - 1000, // past - "30": future + 60_000, - "45": future + 120_000, + it("POST toggle ACCEPTS a slightly-past timestamp (clock skew / latency)", async () => { + // Regression guard for Gemini-review finding #1: the old code rejected + // any slot timestamp <= server now, which broke legitimate toggles when + // network latency made an imminent slot land "in the past". The HTTP + // layer must accept it; the scheduler then either fires it immediately + // (if within MISSED_WAKE_GRACE_MS) or rolls it forward by 24h × N. By + // the time the response returns, the scheduler tick has already run + // synchronously up to its first await — so the snapshot reflects the + // post-advance ts (strictly > now), not the original past ts. + await toggle({ hour: 22 }); // ensure clean + const now = Date.now(); + const timestamps: Record<string, number> = { + "0": now - 5_000, // 5s in the past — well within any plausible skew + "15": now + 60_000, + "30": now + 120_000, + "45": now + 180_000, }; - const res = await toggle({ hour: 7, timestamps: badTimestamps }); - expect(res.status).toBe(400); + const res = await toggle({ hour: 22, timestamps }); + expect(res.status).toBe(200); + const body = (await res.json()) as { + schedule: Record<string, Record<string, number>>; + }; + const slot0 = body.schedule["22"]?.["0"]; + expect(typeof slot0).toBe("number"); + expect((slot0 ?? 0) > now).toBe(true); // slot :00 was advanced by the tick + expect(body.schedule["22"]?.["15"]).toBe(now + 60_000); // future slot kept + await toggle({ hour: 22 }); // cleanup + }); + + it("POST toggle rejects NaN / Infinity / non-number slot values", async () => { + // Use a dedicated hour and ALWAYS clean it up, even on assertion failure, + // so we don't leak state into the next iteration (which would otherwise + // interpret the next toggle as a DELETE and return 200). + const hour = 23; + await toggle({ hour }); // ensure clean + for (const bad of [Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY, "x", null]) { + const now = Date.now(); + const timestamps: Record<string, unknown> = { + "0": now + 60_000, + "15": bad, + "30": now + 180_000, + "45": now + 240_000, + }; + const res = await toggle({ hour, timestamps }); + try { + expect(res.status, `bad value: ${String(bad)}`).toBe(400); + } finally { + await toggle({ hour }); // ensure clean for next iteration + } + } }); it("POST toggle rejects missing timestamps on add", async () => { @@ -549,4 +589,31 @@ describe("Wake schedule routes", () => { expect(body.schedule["5"]?.["45"]).toBe(base2 + 180_000); await toggle({ hour: 5 }); }); + + it("snapshot remains consistent across toggle round-trips (persistSchedule atomicity)", async () => { + // Regression guard for Gemini-review finding #3: persistSchedule + // originally did DELETE + N INSERTs without a transaction. A mid-loop + // failure would commit the DELETE and lose the schedule. We can't + // directly induce a SQLite mid-INSERT failure from here without + // monkey-patching getDatabase, but we CAN assert that the steady-state + // round-trip never drops rows — and a transactional impl must agree + // with itself across GET/POST cycles. + const base = Date.now() + 60 * 60 * 1000; + await toggle({ hour: 1, timestamps: buildTimestamps(base) }); + await toggle({ hour: 2, timestamps: buildTimestamps(base + 60_000) }); + await toggle({ hour: 3, timestamps: buildTimestamps(base + 120_000) }); + const snap = await getSchedule(); + for (const h of ["1", "2", "3"]) { + expect(Object.keys(snap.schedule[h] ?? {}).sort()).toEqual(["0", "15", "30", "45"]); + } + // Remove one; the others must be untouched. + await toggle({ hour: 2 }); + const snap2 = await getSchedule(); + expect(snap2.schedule["1"]).toBeDefined(); + expect(snap2.schedule["2"]).toBeUndefined(); + expect(snap2.schedule["3"]).toBeDefined(); + // Cleanup. + await toggle({ hour: 1 }); + await toggle({ hour: 3 }); + }); }); |
