fix(cron): handle legacy atMs field in schedule when computing next run (#9932)

* fix(cron): handle legacy atMs field in schedule when computing next run

The cron scheduler only checked for `schedule.at` (string) but legacy jobs
may have `schedule.atMs` (number) from before the schema migration.

This caused nextRunAtMs to stay null because:
1. Store migration runs on load but may not persist immediately
2. Race conditions or file mtime issues can skip migration
3. computeJobNextRunAtMs/computeNextRunAtMs only checked `at`, not `atMs`

Fix: Make both functions defensive by checking `atMs` first (number),
then `atMs` (string, for edge cases), then falling back to `at` (string).

This ensures jobs fire correctly even if:
- Migration hasn't run yet
- Old data was written by a previous version
- The store was manually edited

Fixes #9930

* fix: validate numeric atMs to prevent NaN/Infinity propagation

Addresses review feedback - numeric atMs values are now validated with
Number.isFinite() && atMs > 0 before use. This prevents corrupted or
manually edited stores from causing hot timer loops via setTimeout(..., NaN).
This commit is contained in:
fujiwara-tofu-shop
2026-02-05 15:49:03 -08:00
committed by GitHub
parent 40e23b05f7
commit b0befb5f5d
2 changed files with 24 additions and 2 deletions

View File

@@ -4,7 +4,18 @@ import { parseAbsoluteTimeMs } from "./parse.js";
export function computeNextRunAtMs(schedule: CronSchedule, nowMs: number): number | undefined {
if (schedule.kind === "at") {
const atMs = parseAbsoluteTimeMs(schedule.at);
// Handle both canonical `at` (string) and legacy `atMs` (number) fields.
// The store migration should convert atMs→at, but be defensive in case
// the migration hasn't run yet or was bypassed.
const sched = schedule as { at?: string; atMs?: number | string };
const atMs =
typeof sched.atMs === "number" && Number.isFinite(sched.atMs) && sched.atMs > 0
? sched.atMs
: typeof sched.atMs === "string"
? parseAbsoluteTimeMs(sched.atMs)
: typeof sched.at === "string"
? parseAbsoluteTimeMs(sched.at)
: null;
if (atMs === null) {
return undefined;
}

View File

@@ -52,7 +52,18 @@ export function computeJobNextRunAtMs(job: CronJob, nowMs: number): number | und
if (job.state.lastStatus === "ok" && job.state.lastRunAtMs) {
return undefined;
}
const atMs = parseAbsoluteTimeMs(job.schedule.at);
// Handle both canonical `at` (string) and legacy `atMs` (number) fields.
// The store migration should convert atMs→at, but be defensive in case
// the migration hasn't run yet or was bypassed.
const schedule = job.schedule as { at?: string; atMs?: number | string };
const atMs =
typeof schedule.atMs === "number" && Number.isFinite(schedule.atMs) && schedule.atMs > 0
? schedule.atMs
: typeof schedule.atMs === "string"
? parseAbsoluteTimeMs(schedule.atMs)
: typeof schedule.at === "string"
? parseAbsoluteTimeMs(schedule.at)
: null;
return atMs !== null ? atMs : undefined;
}
return computeNextRunAtMs(job.schedule, nowMs);