mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
fix(automations): step schedule scans by calendar day so DST changes don't skip dates (#19086)
The RRULE day scan stepped local midnight by a fixed 24h. Walking back over a 23-hour spring-forward day landed at 23:00 two dates earlier, so the latest-occurrence lookup used for catch-up runs skipped a date. It returned a run a day too old for daily schedules, and a week too old for weekly ones. Step with setDate via a shared addLocalDays helper, in both the RRULE scan and the cron feasibility scan. Add a regression test for America/New_York and Australia/Lord_Howe. The test is skipped on Windows, where unstubbing TZ doesn't restore the OS zone. Co-authored-by: liutian <liutian.6000@bytedance.com> Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
This commit is contained in:
@@ -1,6 +1,5 @@
|
||||
import type { ParsedCron } from './automation-schedule-parsing'
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000
|
||||
const CRON_SCAN_DAYS = 9 * 366
|
||||
|
||||
export function startOfLocalDay(timestamp: number): number {
|
||||
@@ -9,6 +8,14 @@ export function startOfLocalDay(timestamp: number): number {
|
||||
return date.getTime()
|
||||
}
|
||||
|
||||
// Why: calendar steps, not 24h ones; DST days are not 24 hours long.
|
||||
export function addLocalDays(day: number, days: number): number {
|
||||
const date = new Date(day)
|
||||
date.setDate(date.getDate() + days)
|
||||
date.setHours(0, 0, 0, 0)
|
||||
return date.getTime()
|
||||
}
|
||||
|
||||
export function floorToMinute(timestamp: number): number {
|
||||
const date = new Date(timestamp)
|
||||
date.setSeconds(0, 0)
|
||||
@@ -43,7 +50,7 @@ export function cronHasPossibleOccurrence(rule: ParsedCron, anchor: number): boo
|
||||
if (cronDateMatches(rule, day)) {
|
||||
return true
|
||||
}
|
||||
day += DAY_MS
|
||||
day = addLocalDays(day, 1)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { latestAutomationOccurrenceAtOrBefore } from './automation-schedule-occurrences'
|
||||
|
||||
afterEach(() => vi.unstubAllEnvs())
|
||||
|
||||
// Why: on Windows, unsetting a stubbed TZ doesn't restore the OS zone, so it would leak into later tests.
|
||||
describe.skipIf(process.platform === 'win32')('automation calendar days across DST', () => {
|
||||
it.each([
|
||||
{
|
||||
timezone: 'America/New_York',
|
||||
now: '2026-03-09T00:30:00-04:00',
|
||||
expected: '2026-03-08T23:30:00-04:00'
|
||||
},
|
||||
{
|
||||
timezone: 'Australia/Lord_Howe',
|
||||
now: '2026-10-05T00:15:00+11:00',
|
||||
expected: '2026-10-04T23:30:00+11:00'
|
||||
}
|
||||
])(
|
||||
'finds the previous calendar day after spring-forward in $timezone',
|
||||
({ timezone, now, expected }) => {
|
||||
vi.stubEnv('TZ', timezone)
|
||||
// Precondition: the stub took and the offset changed in the preceding two days.
|
||||
expect(new Date(now).getTimezoneOffset()).not.toBe(
|
||||
new Date(Date.parse(now) - 2 * 24 * 60 * 60 * 1000).getTimezoneOffset()
|
||||
)
|
||||
for (const rrule of [
|
||||
'FREQ=DAILY;BYHOUR=23;BYMINUTE=30',
|
||||
'FREQ=WEEKLY;BYDAY=SU;BYHOUR=23;BYMINUTE=30'
|
||||
]) {
|
||||
expect(
|
||||
latestAutomationOccurrenceAtOrBefore(
|
||||
rrule,
|
||||
Date.parse('2026-01-01T00:00:00Z'),
|
||||
Date.parse(now)
|
||||
)
|
||||
).toBe(Date.parse(expected))
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -1,8 +1,12 @@
|
||||
import type { AutomationSchedulePreset } from './automations-types'
|
||||
import { parseSchedule, type ParsedRrule } from './automation-schedule-parsing'
|
||||
import { cronMatches, floorToMinute, startOfLocalDay } from './automation-cron-occurrence'
|
||||
import {
|
||||
addLocalDays,
|
||||
cronMatches,
|
||||
floorToMinute,
|
||||
startOfLocalDay
|
||||
} from './automation-cron-occurrence'
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000
|
||||
const HOUR_MS = 60 * 60 * 1000
|
||||
const MINUTE_MS = 60 * 1000
|
||||
// Why: valid cron expressions like Feb 29 can have an 8-year gap across non-leap centuries.
|
||||
@@ -35,7 +39,7 @@ function scanDayCandidates(rule: ParsedRrule, anchor: number, direction: 1 | -1)
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
day += direction * DAY_MS
|
||||
day = addLocalDays(day, direction)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user