import { CRON_CODES, CronError } from "../errors/cron-error.js"; import { recordDstTransition, recordNextRun } from "../otel/cron-otel.js"; import { fieldsMatchWall } from "../parse/parse-cron.js"; import type { CronFields } from "../parse/parse-cron.types.js"; import { detectDstTransition, fromWallClock, toWallClock, type WallClock, } from "../timezone/timezone.js"; import { advanceUntilDayMatches, advanceUntilMonthMatches, advanceUntilYearMatches, climbFields, climbFieldsBackward, copyWall, HORIZON_YEARS, MS_PER_MINUTE, MS_PER_SECOND, type MutableWall, yearMatches, } from "./next-run-fields.js"; function startCursor(after: number, useSeconds: boolean): number { return useSeconds ? after + MS_PER_SECOND : after + MS_PER_MINUTE; } function isWithinHorizon(wall: MutableWall, baseYear: number): boolean { return wall.year - baseYear <= HORIZON_YEARS; } function snapForward( fields: CronFields, wall: MutableWall, baseYear: number ): void { if (!yearMatches(fields, wall.year)) { if (!advanceUntilYearMatches(wall, fields)) { throw new CronError( CRON_CODES.NEXT_RUN_NOT_FOUND, "cron.next_run_not_found", { context: { reason: "year horizon exhausted" } } ); } } climbFields(wall, fields); advanceUntilMonthMatches(wall, fields); advanceUntilDayMatches(wall, fields); if (!isWithinHorizon(wall, baseYear)) { throw new CronError( CRON_CODES.NEXT_RUN_NOT_FOUND, "cron.next_run_not_found", { context: { reason: "search horizon exhausted", baseYear, finalYear: wall.year, }, } ); } } function isAcceptable( fields: CronFields, candidate: number, observed: WallClock, after: number ): boolean { if (candidate <= after) return false; return fieldsMatchWall(fields, observed); } function searchForward(fields: CronFields, after: number, tz: string): number { let cursor = startCursor(after, fields.seconds !== undefined); const baseYear = toWallClock(after, tz).year; while (true) { const wall = copyWall(toWallClock(cursor, tz)); snapForward(fields, wall, baseYear); const candidate = fromWallClock(wall, tz); const observed = toWallClock(candidate, tz); if (isAcceptable(fields, candidate, observed, after)) { const transition = detectDstTransition(after, candidate, tz); if (transition !== null) recordDstTransition(transition, tz); return candidate; } // Failure path covers both DST-gap collisions (observed wall // disagrees with requested wall) and round-trip stalls (candidate // ≤ cursor). Advance by a full hour past the larger of (cursor, // candidate) so the next iteration cannot loop back into the // same skipped or already-visited window. const advanceFrom = candidate > cursor ? candidate : cursor; cursor = advanceFrom + 60 * MS_PER_MINUTE; } } function searchBackward( fields: CronFields, before: number, tz: string ): number { let cursor = before - MS_PER_MINUTE; const baseYear = toWallClock(before, tz).year; while (true) { const wall = copyWall(toWallClock(cursor, tz)); if (baseYear - wall.year > HORIZON_YEARS) { throw new CronError( CRON_CODES.NEXT_RUN_NOT_FOUND, "cron.next_run_not_found", { context: { reason: "backward horizon exhausted", baseYear, finalYear: wall.year, }, } ); } climbFieldsBackward(wall, fields); const candidate = fromWallClock(wall, tz); const observed = toWallClock(candidate, tz); // `searchBackward` only ever steps cursor backward, so candidate // < before holds by construction; field membership is the sole // acceptance criterion. if (candidate < before && fieldsMatchWall(fields, observed)) { return candidate; } // Step the cursor back a full hour from its current position; the // candidate from the previous iteration is never further forward // because `climbFieldsBackward` only moves time backward. cursor -= 60 * MS_PER_MINUTE; } } /** Compute the next epoch instant strictly greater than `after` matching * the cron field set. Throws `CronError(CRON.NEXT_RUN_NOT_FOUND)` on * horizon exhaustion. */ export function computeNextRun( fields: CronFields, after: number, tz: string ): number { const t0 = Date.now(); try { const result = searchForward(fields, after, tz); recordNextRun(Date.now() - t0, "success", tz); return result; } catch (err) { recordNextRun(Date.now() - t0, "error", tz); throw err; } } /** Compute the previous epoch instant strictly less than `before` matching * the cron field set. */ export function computePreviousRun( fields: CronFields, before: number, tz: string ): number { return searchBackward(fields, before, tz); }