fix(cron): resolve DST gaps and repeated hours in backup schedules
Nonexistent wall times now run at the end of the spring-forward gap, hourly schedules walk real minutes so the fall-back hour never yields a past run, and schedule logs show the zone abbreviation instead of Local.
- commit
- 484c51565b5b074da7d372b6ed77b4e7887cdff9
- author
- brice · · 2026-10-17 05:51 UTC
- parent
- b1de2a8
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email - refs
- fix/backup-scheduling-and-safety
4 files · +94 -28
M docs/config/backups.md +1 -1
| @@ -21,7 +21,7 @@ Each backup flow runs on one installation-wide schedule. Apps only declare | |||
| 21 | 21 | | `weekly` | every Sunday | `03:00` | | |
| 22 | 22 | | `monthly` | the first day of each month | `04:00` | | |
| 23 | 23 | ||
| 24 | - | - `time` is `HH:MM` (24-hour) in the **server's local timezone** (`TZ`, or `/etc/localtime`). The run keeps the same wall-clock time across daylight-saving changes. | |
| 24 | + | - `time` is `HH:MM` (24-hour) in the **server's local timezone** (`TZ`, or `/etc/localtime`). The run keeps the same wall-clock time across daylight-saving changes. A time skipped by the spring-forward change runs when the gap ends. A time repeated by the fall-back change runs once. | |
| 25 | 25 | - The next run is computed from the current time when Gordon starts. A run that falls while Gordon is down is skipped, not caught up: no workload runs then, so there is nothing new to back up. | |
| 26 | 26 | - Gordon logs the schedule and `next_run` at startup (`backup scheduler enabled`, `volume backup scheduler enabled`). | |
| 27 | 27 | ||
| @@ -21,7 +21,7 @@ Each backup flow runs on one installation-wide schedule. Apps only declare | |||||
| 21 | | `weekly` | every Sunday | `03:00` | | 21 | | `weekly` | every Sunday | `03:00` | | ||
| 22 | | `monthly` | the first day of each month | `04:00` | | 22 | | `monthly` | the first day of each month | `04:00` | | ||
| 23 | 23 | ||||
| 24 | - | - `time` is `HH:MM` (24-hour) in the **server's local timezone** (`TZ`, or `/etc/localtime`). The run keeps the same wall-clock time across daylight-saving changes. | 24 | + | - `time` is `HH:MM` (24-hour) in the **server's local timezone** (`TZ`, or `/etc/localtime`). The run keeps the same wall-clock time across daylight-saving changes. A time skipped by the spring-forward change runs when the gap ends. A time repeated by the fall-back change runs once. |
| 25 | - The next run is computed from the current time when Gordon starts. A run that falls while Gordon is down is skipped, not caught up: no workload runs then, so there is nothing new to back up. | 25 | - The next run is computed from the current time when Gordon starts. A run that falls while Gordon is down is skipped, not caught up: no workload runs then, so there is nothing new to back up. | ||
| 26 | - Gordon logs the schedule and `next_run` at startup (`backup scheduler enabled`, `volume backup scheduler enabled`). | 26 | - Gordon logs the schedule and `next_run` at startup (`backup scheduler enabled`, `volume backup scheduler enabled`). | ||
| 27 | 27 | ||||
M internal/app/schedulers.go +3 -1
| @@ -247,7 +247,9 @@ func describeSchedule(s domain.CronSchedule) string { | |||
| 247 | 247 | } | |
| 248 | 248 | loc := "UTC" | |
| 249 | 249 | if s.Location != nil { | |
| 250 | - | loc = s.Location.String() | |
| 250 | + | // time.Local reports "Local"; the current zone abbreviation is | |
| 251 | + | // what an operator can read (e.g. CEST). | |
| 252 | + | loc, _ = time.Now().In(s.Location).Zone() | |
| 251 | 253 | } | |
| 252 | 254 | return fmt.Sprintf("%s at %s %s", s.Preset, at, loc) | |
| 253 | 255 | } | |
| @@ -247,7 +247,9 @@ func describeSchedule(s domain.CronSchedule) string { | |||||
| 247 | } | 247 | } | ||
| 248 | loc := "UTC" | 248 | loc := "UTC" | ||
| 249 | if s.Location != nil { | 249 | if s.Location != nil { | ||
| 250 | - | loc = s.Location.String() | 250 | + | // time.Local reports "Local"; the current zone abbreviation is |
| 251 | + | // what an operator can read (e.g. CEST). | |||
| 252 | + | loc, _ = time.Now().In(s.Location).Zone() | |||
| 251 | } | 253 | } | ||
| 252 | return fmt.Sprintf("%s at %s %s", s.Preset, at, loc) | 254 | return fmt.Sprintf("%s at %s %s", s.Preset, at, loc) | ||
| 253 | } | 255 | } | ||
M internal/usecase/cron/scheduler.go +46 -22
| @@ -289,8 +289,9 @@ var presetDefaultTimes = map[domain.BackupSchedule]domain.TimeOfDay{ | |||
| 289 | 289 | // | |
| 290 | 290 | // Times are wall-clock times in schedule.Location (UTC when nil), so a | |
| 291 | 291 | // daily 03:30 stays 03:30 across daylight-saving changes. A wall time that | |
| 292 | - | // does not exist on a given day (spring-forward gap) is normalized forward | |
| 293 | - | // by time.Date, so the run still happens exactly once that day. | |
| 292 | + | // does not exist on a given day (spring-forward gap) runs at the end of the | |
| 293 | + | // gap. A wall time that happens twice (fall-back) runs once, at its first | |
| 294 | + | // occurrence; hourly schedules follow real elapsed hours. | |
| 294 | 295 | // | |
| 295 | 296 | // The result only ever depends on now: a run missed while the process was | |
| 296 | 297 | // down is skipped, never caught up. | |
| @@ -311,30 +312,53 @@ func calculateNextRun(now time.Time, schedule domain.CronSchedule) (time.Time, e | |||
| 311 | 312 | loc = time.UTC | |
| 312 | 313 | } | |
| 313 | 314 | local := now.In(loc) | |
| 315 | + | y, m, d := local.Date() | |
| 314 | 316 | ||
| 315 | - | var next time.Time | |
| 316 | 317 | switch schedule.Preset { | |
| 317 | 318 | case domain.ScheduleHourly: | |
| 318 | - | next = time.Date(local.Year(), local.Month(), local.Day(), local.Hour(), at.Minute, 0, 0, loc) | |
| 319 | - | if !next.After(now) { | |
| 320 | - | next = next.Add(time.Hour) | |
| 321 | - | } | |
| 319 | + | return nextMinuteOfHour(now, at.Minute, loc), nil | |
| 322 | 320 | case domain.ScheduleDaily: | |
| 323 | - | next = time.Date(local.Year(), local.Month(), local.Day(), at.Hour, at.Minute, 0, 0, loc) | |
| 324 | - | if !next.After(now) { | |
| 325 | - | next = time.Date(local.Year(), local.Month(), local.Day()+1, at.Hour, at.Minute, 0, 0, loc) | |
| 326 | - | } | |
| 321 | + | return firstAfter(now, func(i int) time.Time { return wallTime(y, m, d+i, at, loc) }), nil | |
| 327 | 322 | case domain.ScheduleWeekly: | |
| 328 | - | daysUntilSunday := (7 - int(local.Weekday())) % 7 | |
| 329 | - | next = time.Date(local.Year(), local.Month(), local.Day()+daysUntilSunday, at.Hour, at.Minute, 0, 0, loc) | |
| 330 | - | if !next.After(now) { | |
| 331 | - | next = time.Date(local.Year(), local.Month(), local.Day()+daysUntilSunday+7, at.Hour, at.Minute, 0, 0, loc) | |
| 332 | - | } | |
| 333 | - | case domain.ScheduleMonthly: | |
| 334 | - | next = time.Date(local.Year(), local.Month(), 1, at.Hour, at.Minute, 0, 0, loc) | |
| 335 | - | if !next.After(now) { | |
| 336 | - | next = time.Date(local.Year(), local.Month()+1, 1, at.Hour, at.Minute, 0, 0, loc) | |
| 337 | - | } | |
| 323 | + | sunday := d + (7-int(local.Weekday()))%7 | |
| 324 | + | return firstAfter(now, func(i int) time.Time { return wallTime(y, m, sunday+7*i, at, loc) }), nil | |
| 325 | + | default: // domain.ScheduleMonthly | |
| 326 | + | return firstAfter(now, func(i int) time.Time { return wallTime(y, m+time.Month(i), 1, at, loc) }), nil | |
| 327 | + | } | |
| 328 | + | } | |
| 329 | + | ||
| 330 | + | // firstAfter returns candidate(0) or candidate(1), whichever is the first | |
| 331 | + | // strictly after now. | |
| 332 | + | func firstAfter(now time.Time, candidate func(i int) time.Time) time.Time { | |
| 333 | + | if next := candidate(0); next.After(now) { | |
| 334 | + | return next | |
| 335 | + | } | |
| 336 | + | return candidate(1) | |
| 337 | + | } | |
| 338 | + | ||
| 339 | + | // nextMinuteOfHour returns the first whole minute strictly after now whose | |
| 340 | + | // wall-clock minute in loc equals minute. Walking real minutes keeps hourly | |
| 341 | + | // runs one hour apart across DST changes. | |
| 342 | + | func nextMinuteOfHour(now time.Time, minute int, loc *time.Location) time.Time { | |
| 343 | + | t := now.Truncate(time.Minute).Add(time.Minute) | |
| 344 | + | for i := 0; i < 120 && t.In(loc).Minute() != minute; i++ { | |
| 345 | + | t = t.Add(time.Minute) | |
| 346 | + | } | |
| 347 | + | return t | |
| 348 | + | } | |
| 349 | + | ||
| 350 | + | // wallTime returns the instant of the given wall-clock time in loc. Day and | |
| 351 | + | // month overflow are normalized like time.Date. When the wall time falls in | |
| 352 | + | // a spring-forward gap, the end of the gap is returned. | |
| 353 | + | func wallTime(year int, month time.Month, day int, at domain.TimeOfDay, loc *time.Location) time.Time { | |
| 354 | + | t := time.Date(year, month, day, at.Hour, at.Minute, 0, 0, loc) | |
| 355 | + | if t.Hour() == at.Hour && t.Minute() == at.Minute { | |
| 356 | + | return t | |
| 338 | 357 | } | |
| 339 | - | return next, nil | |
| 358 | + | // Nonexistent wall time: interpret it with the offset in force before | |
| 359 | + | // the gap, which lands after the transition, then snap back to it. | |
| 360 | + | naive := time.Date(year, month, day, at.Hour, at.Minute, 0, 0, time.UTC) | |
| 361 | + | _, offsetBefore := naive.Add(-12 * time.Hour).In(loc).Zone() | |
| 362 | + | start, _ := naive.Add(-time.Duration(offsetBefore) * time.Second).In(loc).ZoneBounds() | |
| 363 | + | return start.In(loc) | |
| 340 | 364 | } | |
| @@ -289,8 +289,9 @@ var presetDefaultTimes = map[domain.BackupSchedule]domain.TimeOfDay{ | |||||
| 289 | // | 289 | // | ||
| 290 | // Times are wall-clock times in schedule.Location (UTC when nil), so a | 290 | // Times are wall-clock times in schedule.Location (UTC when nil), so a | ||
| 291 | // daily 03:30 stays 03:30 across daylight-saving changes. A wall time that | 291 | // daily 03:30 stays 03:30 across daylight-saving changes. A wall time that | ||
| 292 | - | // does not exist on a given day (spring-forward gap) is normalized forward | 292 | + | // does not exist on a given day (spring-forward gap) runs at the end of the |
| 293 | - | // by time.Date, so the run still happens exactly once that day. | 293 | + | // gap. A wall time that happens twice (fall-back) runs once, at its first |
| 294 | + | // occurrence; hourly schedules follow real elapsed hours. | |||
| 294 | // | 295 | // | ||
| 295 | // The result only ever depends on now: a run missed while the process was | 296 | // The result only ever depends on now: a run missed while the process was | ||
| 296 | // down is skipped, never caught up. | 297 | // down is skipped, never caught up. | ||
| @@ -311,30 +312,53 @@ func calculateNextRun(now time.Time, schedule domain.CronSchedule) (time.Time, e | |||||
| 311 | loc = time.UTC | 312 | loc = time.UTC | ||
| 312 | } | 313 | } | ||
| 313 | local := now.In(loc) | 314 | local := now.In(loc) | ||
| 315 | + | y, m, d := local.Date() | |||
| 314 | 316 | ||||
| 315 | - | var next time.Time | |||
| 316 | switch schedule.Preset { | 317 | switch schedule.Preset { | ||
| 317 | case domain.ScheduleHourly: | 318 | case domain.ScheduleHourly: | ||
| 318 | - | next = time.Date(local.Year(), local.Month(), local.Day(), local.Hour(), at.Minute, 0, 0, loc) | 319 | + | return nextMinuteOfHour(now, at.Minute, loc), nil |
| 319 | - | if !next.After(now) { | |||
| 320 | - | next = next.Add(time.Hour) | |||
| 321 | - | } | |||
| 322 | case domain.ScheduleDaily: | 320 | case domain.ScheduleDaily: | ||
| 323 | - | next = time.Date(local.Year(), local.Month(), local.Day(), at.Hour, at.Minute, 0, 0, loc) | 321 | + | return firstAfter(now, func(i int) time.Time { return wallTime(y, m, d+i, at, loc) }), nil |
| 324 | - | if !next.After(now) { | |||
| 325 | - | next = time.Date(local.Year(), local.Month(), local.Day()+1, at.Hour, at.Minute, 0, 0, loc) | |||
| 326 | - | } | |||
| 327 | case domain.ScheduleWeekly: | 322 | case domain.ScheduleWeekly: | ||
| 328 | - | daysUntilSunday := (7 - int(local.Weekday())) % 7 | 323 | + | sunday := d + (7-int(local.Weekday()))%7 |
| 329 | - | next = time.Date(local.Year(), local.Month(), local.Day()+daysUntilSunday, at.Hour, at.Minute, 0, 0, loc) | 324 | + | return firstAfter(now, func(i int) time.Time { return wallTime(y, m, sunday+7*i, at, loc) }), nil |
| 330 | - | if !next.After(now) { | 325 | + | default: // domain.ScheduleMonthly |
| 331 | - | next = time.Date(local.Year(), local.Month(), local.Day()+daysUntilSunday+7, at.Hour, at.Minute, 0, 0, loc) | 326 | + | return firstAfter(now, func(i int) time.Time { return wallTime(y, m+time.Month(i), 1, at, loc) }), nil |
| 332 | - | } | 327 | + | } |
| 333 | - | case domain.ScheduleMonthly: | 328 | + | } |
| 334 | - | next = time.Date(local.Year(), local.Month(), 1, at.Hour, at.Minute, 0, 0, loc) | 329 | + | |
| 335 | - | if !next.After(now) { | 330 | + | // firstAfter returns candidate(0) or candidate(1), whichever is the first |
| 336 | - | next = time.Date(local.Year(), local.Month()+1, 1, at.Hour, at.Minute, 0, 0, loc) | 331 | + | // strictly after now. |
| 337 | - | } | 332 | + | func firstAfter(now time.Time, candidate func(i int) time.Time) time.Time { |
| 333 | + | if next := candidate(0); next.After(now) { | |||
| 334 | + | return next | |||
| 335 | + | } | |||
| 336 | + | return candidate(1) | |||
| 337 | + | } | |||
| 338 | + | ||||
| 339 | + | // nextMinuteOfHour returns the first whole minute strictly after now whose | |||
| 340 | + | // wall-clock minute in loc equals minute. Walking real minutes keeps hourly | |||
| 341 | + | // runs one hour apart across DST changes. | |||
| 342 | + | func nextMinuteOfHour(now time.Time, minute int, loc *time.Location) time.Time { | |||
| 343 | + | t := now.Truncate(time.Minute).Add(time.Minute) | |||
| 344 | + | for i := 0; i < 120 && t.In(loc).Minute() != minute; i++ { | |||
| 345 | + | t = t.Add(time.Minute) | |||
| 346 | + | } | |||
| 347 | + | return t | |||
| 348 | + | } | |||
| 349 | + | ||||
| 350 | + | // wallTime returns the instant of the given wall-clock time in loc. Day and | |||
| 351 | + | // month overflow are normalized like time.Date. When the wall time falls in | |||
| 352 | + | // a spring-forward gap, the end of the gap is returned. | |||
| 353 | + | func wallTime(year int, month time.Month, day int, at domain.TimeOfDay, loc *time.Location) time.Time { | |||
| 354 | + | t := time.Date(year, month, day, at.Hour, at.Minute, 0, 0, loc) | |||
| 355 | + | if t.Hour() == at.Hour && t.Minute() == at.Minute { | |||
| 356 | + | return t | |||
| 338 | } | 357 | } | ||
| 339 | - | return next, nil | 358 | + | // Nonexistent wall time: interpret it with the offset in force before |
| 359 | + | // the gap, which lands after the transition, then snap back to it. | |||
| 360 | + | naive := time.Date(year, month, day, at.Hour, at.Minute, 0, 0, time.UTC) | |||
| 361 | + | _, offsetBefore := naive.Add(-12 * time.Hour).In(loc).Zone() | |||
| 362 | + | start, _ := naive.Add(-time.Duration(offsetBefore) * time.Second).In(loc).ZoneBounds() | |||
| 363 | + | return start.In(loc) | |||
| 340 | } | 364 | } | ||
M internal/usecase/cron/scheduler_test.go +44 -4
| @@ -97,10 +97,50 @@ func TestCalculateNextRunDSTGapSkipsForward(t *testing.T) { | |||
| 97 | 97 | Preset: domain.ScheduleDaily, At: &domain.TimeOfDay{Hour: 2, Minute: 30}, Location: paris, | |
| 98 | 98 | }) | |
| 99 | 99 | require.NoError(t, err) | |
| 100 | - | assert.Equal(t, 2026, next.Year()) | |
| 101 | - | assert.Equal(t, time.March, next.In(paris).Month()) | |
| 102 | - | assert.Equal(t, 29, next.In(paris).Day()) | |
| 103 | - | assert.True(t, next.After(now)) | |
| 100 | + | // First valid instant after the gap: 03:00 CEST = 01:00 UTC. | |
| 101 | + | assert.True(t, time.Date(2026, 3, 29, 1, 0, 0, 0, time.UTC).Equal(next), "got %s", next.In(paris)) | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | func TestCalculateNextRunDSTEdgeCasesNewYork(t *testing.T) { | |
| 105 | + | ny, err := time.LoadLocation("America/New_York") | |
| 106 | + | require.NoError(t, err) | |
| 107 | + | at := &domain.TimeOfDay{Hour: 2, Minute: 30} | |
| 108 | + | // 2026-03-08: New York jumps from 02:00 EST to 03:00 EDT (07:00 UTC). | |
| 109 | + | gapEnd := time.Date(2026, 3, 8, 7, 0, 0, 0, time.UTC) | |
| 110 | + | // 2026-11-01: 01:00-02:00 happens twice (EDT then EST). | |
| 111 | + | firstOneFortyFive := time.Date(2026, 11, 1, 5, 45, 0, 0, time.UTC) // 01:45 EDT | |
| 112 | + | secondOneFortyFive := time.Date(2026, 11, 1, 6, 45, 0, 0, time.UTC) // 01:45 EST | |
| 113 | + | ||
| 114 | + | tests := []struct { | |
| 115 | + | name string | |
| 116 | + | preset domain.BackupSchedule | |
| 117 | + | at *domain.TimeOfDay | |
| 118 | + | now time.Time | |
| 119 | + | expected time.Time | |
| 120 | + | }{ | |
| 121 | + | {"daily in gap from same morning runs at gap end", domain.ScheduleDaily, at, | |
| 122 | + | time.Date(2026, 3, 8, 6, 45, 0, 0, time.UTC), gapEnd}, // now 01:45 EST | |
| 123 | + | {"daily in gap from previous day runs at gap end", domain.ScheduleDaily, at, | |
| 124 | + | time.Date(2026, 3, 7, 17, 0, 0, 0, time.UTC), gapEnd}, | |
| 125 | + | {"daily after gap end runs next day", domain.ScheduleDaily, at, | |
| 126 | + | gapEnd, time.Date(2026, 3, 9, 6, 30, 0, 0, time.UTC)}, | |
| 127 | + | {"weekly in gap runs at gap end", domain.ScheduleWeekly, at, | |
| 128 | + | time.Date(2026, 3, 7, 17, 0, 0, 0, time.UTC), gapEnd}, | |
| 129 | + | {"hourly in repeated hour, first pass", domain.ScheduleHourly, &domain.TimeOfDay{Minute: 30}, | |
| 130 | + | firstOneFortyFive, time.Date(2026, 11, 1, 6, 30, 0, 0, time.UTC)}, // 01:30 EST | |
| 131 | + | {"hourly in repeated hour, second pass", domain.ScheduleHourly, &domain.TimeOfDay{Minute: 30}, | |
| 132 | + | secondOneFortyFive, time.Date(2026, 11, 1, 7, 30, 0, 0, time.UTC)}, // 02:30 EST | |
| 133 | + | {"daily in repeated hour runs next day", domain.ScheduleDaily, &domain.TimeOfDay{Hour: 1, Minute: 30}, | |
| 134 | + | secondOneFortyFive, time.Date(2026, 11, 2, 6, 30, 0, 0, time.UTC)}, | |
| 135 | + | } | |
| 136 | + | for _, tt := range tests { | |
| 137 | + | t.Run(tt.name, func(t *testing.T) { | |
| 138 | + | next, err := calculateNextRun(tt.now, domain.CronSchedule{Preset: tt.preset, At: tt.at, Location: ny}) | |
| 139 | + | require.NoError(t, err) | |
| 140 | + | assert.True(t, next.After(tt.now), "next %s must be after now %s", next, tt.now) | |
| 141 | + | assert.True(t, tt.expected.Equal(next), "want %s, got %s", tt.expected.In(ny), next.In(ny)) | |
| 142 | + | }) | |
| 143 | + | } | |
| 104 | 144 | } | |
| 105 | 145 | ||
| 106 | 146 | func TestCalculateNextRunRejectsMissingPreset(t *testing.T) { | |
| @@ -97,10 +97,50 @@ func TestCalculateNextRunDSTGapSkipsForward(t *testing.T) { | |||||
| 97 | Preset: domain.ScheduleDaily, At: &domain.TimeOfDay{Hour: 2, Minute: 30}, Location: paris, | 97 | Preset: domain.ScheduleDaily, At: &domain.TimeOfDay{Hour: 2, Minute: 30}, Location: paris, | ||
| 98 | }) | 98 | }) | ||
| 99 | require.NoError(t, err) | 99 | require.NoError(t, err) | ||
| 100 | - | assert.Equal(t, 2026, next.Year()) | 100 | + | // First valid instant after the gap: 03:00 CEST = 01:00 UTC. |
| 101 | - | assert.Equal(t, time.March, next.In(paris).Month()) | 101 | + | assert.True(t, time.Date(2026, 3, 29, 1, 0, 0, 0, time.UTC).Equal(next), "got %s", next.In(paris)) |
| 102 | - | assert.Equal(t, 29, next.In(paris).Day()) | 102 | + | } |
| 103 | - | assert.True(t, next.After(now)) | 103 | + | |
| 104 | + | func TestCalculateNextRunDSTEdgeCasesNewYork(t *testing.T) { | |||
| 105 | + | ny, err := time.LoadLocation("America/New_York") | |||
| 106 | + | require.NoError(t, err) | |||
| 107 | + | at := &domain.TimeOfDay{Hour: 2, Minute: 30} | |||
| 108 | + | // 2026-03-08: New York jumps from 02:00 EST to 03:00 EDT (07:00 UTC). | |||
| 109 | + | gapEnd := time.Date(2026, 3, 8, 7, 0, 0, 0, time.UTC) | |||
| 110 | + | // 2026-11-01: 01:00-02:00 happens twice (EDT then EST). | |||
| 111 | + | firstOneFortyFive := time.Date(2026, 11, 1, 5, 45, 0, 0, time.UTC) // 01:45 EDT | |||
| 112 | + | secondOneFortyFive := time.Date(2026, 11, 1, 6, 45, 0, 0, time.UTC) // 01:45 EST | |||
| 113 | + | ||||
| 114 | + | tests := []struct { | |||
| 115 | + | name string | |||
| 116 | + | preset domain.BackupSchedule | |||
| 117 | + | at *domain.TimeOfDay | |||
| 118 | + | now time.Time | |||
| 119 | + | expected time.Time | |||
| 120 | + | }{ | |||
| 121 | + | {"daily in gap from same morning runs at gap end", domain.ScheduleDaily, at, | |||
| 122 | + | time.Date(2026, 3, 8, 6, 45, 0, 0, time.UTC), gapEnd}, // now 01:45 EST | |||
| 123 | + | {"daily in gap from previous day runs at gap end", domain.ScheduleDaily, at, | |||
| 124 | + | time.Date(2026, 3, 7, 17, 0, 0, 0, time.UTC), gapEnd}, | |||
| 125 | + | {"daily after gap end runs next day", domain.ScheduleDaily, at, | |||
| 126 | + | gapEnd, time.Date(2026, 3, 9, 6, 30, 0, 0, time.UTC)}, | |||
| 127 | + | {"weekly in gap runs at gap end", domain.ScheduleWeekly, at, | |||
| 128 | + | time.Date(2026, 3, 7, 17, 0, 0, 0, time.UTC), gapEnd}, | |||
| 129 | + | {"hourly in repeated hour, first pass", domain.ScheduleHourly, &domain.TimeOfDay{Minute: 30}, | |||
| 130 | + | firstOneFortyFive, time.Date(2026, 11, 1, 6, 30, 0, 0, time.UTC)}, // 01:30 EST | |||
| 131 | + | {"hourly in repeated hour, second pass", domain.ScheduleHourly, &domain.TimeOfDay{Minute: 30}, | |||
| 132 | + | secondOneFortyFive, time.Date(2026, 11, 1, 7, 30, 0, 0, time.UTC)}, // 02:30 EST | |||
| 133 | + | {"daily in repeated hour runs next day", domain.ScheduleDaily, &domain.TimeOfDay{Hour: 1, Minute: 30}, | |||
| 134 | + | secondOneFortyFive, time.Date(2026, 11, 2, 6, 30, 0, 0, time.UTC)}, | |||
| 135 | + | } | |||
| 136 | + | for _, tt := range tests { | |||
| 137 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 138 | + | next, err := calculateNextRun(tt.now, domain.CronSchedule{Preset: tt.preset, At: tt.at, Location: ny}) | |||
| 139 | + | require.NoError(t, err) | |||
| 140 | + | assert.True(t, next.After(tt.now), "next %s must be after now %s", next, tt.now) | |||
| 141 | + | assert.True(t, tt.expected.Equal(next), "want %s, got %s", tt.expected.In(ny), next.In(ny)) | |||
| 142 | + | }) | |||
| 143 | + | } | |||
| 104 | } | 144 | } | ||
| 105 | 145 | ||||
| 106 | func TestCalculateNextRunRejectsMissingPreset(t *testing.T) { | 146 | func TestCalculateNextRunRejectsMissingPreset(t *testing.T) { | ||