164 lines
5.3 KiB
TypeScript
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);
|
|
}
|