From b0befb5f5d03ce8660668122e00cddc2db3ab312 Mon Sep 17 00:00:00 2001 From: fujiwara-tofu-shop Date: Thu, 5 Feb 2026 15:49:03 -0800 Subject: [PATCH] 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). --- src/cron/schedule.ts | 13 ++++++++++++- src/cron/service/jobs.ts | 13 ++++++++++++- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/cron/schedule.ts b/src/cron/schedule.ts index 1be95acaa..252d29bab 100644 --- a/src/cron/schedule.ts +++ b/src/cron/schedule.ts @@ -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; } diff --git a/src/cron/service/jobs.ts b/src/cron/service/jobs.ts index a9eda476c..a01475224 100644 --- a/src/cron/service/jobs.ts +++ b/src/cron/service/jobs.ts @@ -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);