Files
suaveplan/packages/foundation/timing/cron/src/next-run/next-run.ts
2026-05-27 23:47:53 +01:00

164 lines
5.3 KiB
TypeScript

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);
}