detached 484c515

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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
2121 | `weekly` | every Sunday | `03:00` |
2222 | `monthly` | the first day of each month | `04:00` |
2323
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.
2525 - 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.
2626 - Gordon logs the schedule and `next_run` at startup (`backup scheduler enabled`, `volume backup scheduler enabled`).
2727
@@ -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 {
247247 }
248248 loc := "UTC"
249249 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()
251253 }
252254 return fmt.Sprintf("%s at %s %s", s.Preset, at, loc)
253255 }
@@ -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{
289289 //
290290 // Times are wall-clock times in schedule.Location (UTC when nil), so a
291291 // 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.
294295 //
295296 // The result only ever depends on now: a run missed while the process was
296297 // down is skipped, never caught up.
@@ -311,30 +312,53 @@ func calculateNextRun(now time.Time, schedule domain.CronSchedule) (time.Time, e
311312 loc = time.UTC
312313 }
313314 local := now.In(loc)
315+ y, m, d := local.Date()
314316
315- var next time.Time
316317 switch schedule.Preset {
317318 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
322320 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
327322 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
338357 }
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)
340364 }
@@ -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 a290 // 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 that291 // 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 forward292+// 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 was296 // 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.UTC312 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())) % 7323+ 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, nil358+ // 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) {
9797 Preset: domain.ScheduleDaily, At: &domain.TimeOfDay{Hour: 2, Minute: 30}, Location: paris,
9898 })
9999 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+ }
104144 }
105145
106146 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) {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close