Initial Commit
This commit is contained in:
163
packages/foundation/timing/cron/src/next-run/next-run.ts
Normal file
163
packages/foundation/timing/cron/src/next-run/next-run.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user