mirror of
https://github.com/stablyai/orca.git
synced 2026-09-23 16:02:24 +00:00
The semantic half of the cron repair. Both defects change what an already-saved schedule does, so they ship together and behind a decision. #15723: parseCronField set end = start for a bare numeric field even with a slash step, so 5/15 expanded to [5] and fired hourly instead of every fifteen minutes. N/step is the open-ended N-max/step sequence now. #15896: day restriction came from expanded set cardinality, so 1-31 read as unrestricted and */2 as restricted. Restriction is lexical now: a day field restricts iff no term of it ranges over a star, matching vixie cron and robfig/cron rather than crontab(5)'s prose. Verified differentially against robfig/cron v1.2.0 across 22 expressions, 424 days, zero divergences. The two cannot ship apart: 0 9 1/1 * 1 matches 124 days under the old parser, 104 under #15723 alone, and 730 under both, because the old cardinality flags react to the corrected expansion. describeAutomationScheduleDrift reads a saved expression under both semantics and reports the ones that moved, so neither direction is silent; the service names them once at startup. No expression Orca's own presets generate drifts. Fixes #15723 Fixes #15896
124 lines
4.2 KiB
TypeScript
124 lines
4.2 KiB
TypeScript
// Detects saved cron schedules whose meaning changed in the release that repaired the parser
|
|
// (#15723, #15896). Both repairs were correct, but a persisted cadence can now fire several
|
|
// times more — or several times less — than it did yesterday, with nothing to notice it by.
|
|
import {
|
|
getAutomationCronExpressionFields,
|
|
parseCronExpression,
|
|
type ParsedCron
|
|
} from './automation-schedule-parsing'
|
|
import { cronDateMatches } from './automation-cron-occurrence'
|
|
|
|
// Two years covers every day-of-month against day-of-week pairing a schedule can land on,
|
|
// which is the only part of matching that depends on the calendar rather than the sets.
|
|
const DRIFT_SCAN_DAYS = 730
|
|
|
|
export type AutomationScheduleDrift = {
|
|
expression: string
|
|
/** Runs a year under the cadence as it was read before the repair, and as it reads now. */
|
|
previousRunsPerYear: number
|
|
currentRunsPerYear: number
|
|
}
|
|
|
|
/**
|
|
* The pre-repair reading of a field: a bare value carrying a step lost the step, so `5/15`
|
|
* meant `5`. A star or a range kept its step, and is left alone.
|
|
*/
|
|
function toPreRepairField(field: string): string {
|
|
return field
|
|
.split(',')
|
|
.map((term) => {
|
|
const [range, step] = term.split('/')
|
|
if (step === undefined || range.includes('*') || range.includes('-')) {
|
|
return term
|
|
}
|
|
return range
|
|
})
|
|
.join(',')
|
|
}
|
|
|
|
/**
|
|
* The pre-repair reading of a whole expression. Day restriction came from how many values a
|
|
* field expanded to rather than from what the user wrote, so `1-31` read as unrestricted.
|
|
*/
|
|
function parsePreRepairCron(expression: string): ParsedCron {
|
|
const fields = getAutomationCronExpressionFields(expression, 6)
|
|
const parsed = parseCronExpression(fields.map(toPreRepairField).join(' '))
|
|
return {
|
|
...parsed,
|
|
dayOfMonthRestricted: parsed.daysOfMonth.size !== 31,
|
|
dayOfWeekRestricted: parsed.daysOfWeek.size !== 7
|
|
}
|
|
}
|
|
|
|
/** Walks both readings over the same calendar so the comparison is which days, not how many. */
|
|
function compareMatchingDays(
|
|
previous: ParsedCron,
|
|
current: ParsedCron,
|
|
anchor: number
|
|
): { previousDays: number; currentDays: number; sameDays: boolean } {
|
|
const cursor = new Date(anchor)
|
|
cursor.setHours(12, 0, 0, 0)
|
|
let previousDays = 0
|
|
let currentDays = 0
|
|
let sameDays = true
|
|
for (let i = 0; i < DRIFT_SCAN_DAYS; i += 1) {
|
|
const at = cursor.getTime()
|
|
const previousMatch = cronDateMatches(previous, at)
|
|
const currentMatch = cronDateMatches(current, at)
|
|
if (previousMatch) {
|
|
previousDays += 1
|
|
}
|
|
if (currentMatch) {
|
|
currentDays += 1
|
|
}
|
|
if (previousMatch !== currentMatch) {
|
|
sameDays = false
|
|
}
|
|
cursor.setDate(cursor.getDate() + 1)
|
|
}
|
|
return { previousDays, currentDays, sameDays }
|
|
}
|
|
|
|
function runsPerYear(rule: ParsedCron, days: number): number {
|
|
return Math.round((days / 2) * rule.hours.size * rule.minutes.size)
|
|
}
|
|
|
|
/**
|
|
* Null when the saved cadence still means what it did before the repair. Only cron schedules
|
|
* can drift; RRULE presets never went through the repaired field parser.
|
|
*/
|
|
export function describeAutomationScheduleDrift(
|
|
schedule: string,
|
|
anchor = Date.now()
|
|
): AutomationScheduleDrift | null {
|
|
const expression = schedule.trim()
|
|
if (expression.includes('=')) {
|
|
return null
|
|
}
|
|
let current: ParsedCron
|
|
let previous: ParsedCron
|
|
try {
|
|
current = parseCronExpression(expression)
|
|
previous = parsePreRepairCron(expression)
|
|
} catch {
|
|
// An unreadable schedule drifts nowhere; the tick reports it separately (#16303).
|
|
return null
|
|
}
|
|
const sameClock =
|
|
previous.minutes.size === current.minutes.size &&
|
|
previous.hours.size === current.hours.size &&
|
|
[...current.minutes].every((minute) => previous.minutes.has(minute)) &&
|
|
[...current.hours].every((hour) => previous.hours.has(hour))
|
|
const { previousDays, currentDays, sameDays } = compareMatchingDays(previous, current, anchor)
|
|
// Compare what the schedule fires, not how it parsed: the restriction flags move on
|
|
// expressions whose matched days do not, and those are not worth telling anyone about.
|
|
if (sameClock && sameDays) {
|
|
return null
|
|
}
|
|
return {
|
|
expression,
|
|
previousRunsPerYear: runsPerYear(previous, previousDays),
|
|
currentRunsPerYear: runsPerYear(current, currentDays)
|
|
}
|
|
}
|