// Package scheduler entscheidet, wann ein Sicherungsauftrag läuft. // // Der Kern ist bewusst frei von Datenbank und Netzwerk: Zeitpläne, // Wartungsfenster, Prioritäten und Wiederholungen sind reine Berechnungen und // damit vollständig prüfbar. Ein Zeitplan, der sich nur im Betrieb über Wochen // beobachten liesse, wäre nicht zu belegen. package scheduler import ( "errors" "fmt" "strconv" "strings" "time" ) // ScheduleType benennt die Art eines Zeitplans. type ScheduleType string const ( // ScheduleTypeManual läuft nur auf ausdrückliche Anforderung. ScheduleTypeManual ScheduleType = "manual" // ScheduleTypeInterval läuft in festen Abständen. ScheduleTypeInterval ScheduleType = "interval" // ScheduleTypeHourly läuft stündlich zu einer festen Minute. ScheduleTypeHourly ScheduleType = "hourly" // ScheduleTypeDaily läuft täglich zu einer festen Uhrzeit. ScheduleTypeDaily ScheduleType = "daily" // ScheduleTypeWeekly läuft an bestimmten Wochentagen. ScheduleTypeWeekly ScheduleType = "weekly" // ScheduleTypeMonthly läuft an bestimmten Tagen des Monats. ScheduleTypeMonthly ScheduleType = "monthly" // ScheduleTypeCron folgt einem Cron-Ausdruck mit fünf Feldern. ScheduleTypeCron ScheduleType = "cron" ) // lastDayOfMonth ist der Platzhalter für den letzten Tag eines Monats. // // Er wird gebraucht, weil „am 31." in kurzen Monaten nicht existiert. Wer eine // Monatssicherung zum Monatsende will, meint den letzten Tag — nicht „fällt in // vier Monaten des Jahres aus". const lastDayOfMonth = -1 // Schedule beschreibt, wann ein Auftrag laufen soll. type Schedule struct { // ScheduleType ist die Art des Zeitplans. ScheduleType ScheduleType `json:"type"` // Interval ist der Abstand bei ScheduleTypeInterval. Interval time.Duration `json:"interval,omitempty"` // Minute ist die Minute der Stunde (0–59). Minute int `json:"minute,omitempty"` // Hour ist die Stunde des Tages (0–23). Hour int `json:"hour,omitempty"` // Weekdays sind die Wochentage bei ScheduleTypeWeekly. Weekdays []time.Weekday `json:"weekdays,omitempty"` // MonthDays sind die Tage des Monats bei ScheduleTypeMonthly. // // Der Wert -1 bedeutet „letzter Tag des Monats". MonthDays []int `json:"month_days,omitempty"` // CronExpression ist der Ausdruck bei ScheduleTypeCron. CronExpression string `json:"cron_expression,omitempty"` // TimeZone ist die Zeitzone, in der die Uhrzeiten gelten. // // Ohne Angabe gilt UTC. Das ist eine bewusste Entscheidung: Ein Zeitplan // ohne Zeitzone in der Ortszeit des Servers auszuführen bedeutet, dass // dieselbe Konfiguration auf zwei Servern zu verschiedenen Zeiten läuft. TimeZone string `json:"time_zone,omitempty"` } // ErrInvalidSchedule meldet einen unbrauchbaren Zeitplan. var ErrInvalidSchedule = errors.New("der zeitplan ist unbrauchbar") // minimumInterval ist der kleinste zulässige Abstand. // // Kürzere Abstände sind kein sinnvoller Sicherungsplan, sondern fast immer ein // Tippfehler — und ein Auftrag, der sich alle paar Sekunden selbst anstößt, // legt die Anlage lahm. const minimumInterval = time.Minute // Validate prüft einen Zeitplan auf Ausführbarkeit. // // Die Prüfung geschieht beim Anlegen, nicht erst beim Ausführen. Ein Zeitplan, // dessen Fehler sich erst um 02:00 Uhr zeigt, fällt genau dann aus, wenn // niemand hinsieht. func (schedule *Schedule) Validate() error { if _, locationError := schedule.location(); locationError != nil { return locationError } switch schedule.ScheduleType { case ScheduleTypeManual: return nil case ScheduleTypeInterval: if schedule.Interval < minimumInterval { return fmt.Errorf("%w: der abstand muss mindestens %s betragen, angegeben war %s", ErrInvalidSchedule, minimumInterval, schedule.Interval) } return nil case ScheduleTypeHourly: return validateMinute(schedule.Minute) case ScheduleTypeDaily: return errors.Join(validateMinute(schedule.Minute), validateHour(schedule.Hour)) case ScheduleTypeWeekly: if len(schedule.Weekdays) == 0 { return fmt.Errorf("%w: ein wöchentlicher zeitplan braucht mindestens einen wochentag", ErrInvalidSchedule) } for _, weekday := range schedule.Weekdays { if weekday < time.Sunday || weekday > time.Saturday { return fmt.Errorf("%w: %d ist kein wochentag", ErrInvalidSchedule, weekday) } } return errors.Join(validateMinute(schedule.Minute), validateHour(schedule.Hour)) case ScheduleTypeMonthly: if len(schedule.MonthDays) == 0 { return fmt.Errorf("%w: ein monatlicher zeitplan braucht mindestens einen tag", ErrInvalidSchedule) } for _, monthDay := range schedule.MonthDays { if monthDay != lastDayOfMonth && (monthDay < 1 || monthDay > 31) { return fmt.Errorf("%w: %d ist kein tag des monats (-1 bedeutet letzter tag)", ErrInvalidSchedule, monthDay) } } return errors.Join(validateMinute(schedule.Minute), validateHour(schedule.Hour)) case ScheduleTypeCron: _, parseError := parseCronExpression(schedule.CronExpression) return parseError default: return fmt.Errorf("%w: die art %q ist unbekannt", ErrInvalidSchedule, schedule.ScheduleType) } } // validateMinute prüft eine Minutenangabe. func validateMinute(minuteValue int) error { if minuteValue < 0 || minuteValue > 59 { return fmt.Errorf("%w: %d ist keine minute", ErrInvalidSchedule, minuteValue) } return nil } // validateHour prüft eine Stundenangabe. func validateHour(hourValue int) error { if hourValue < 0 || hourValue > 23 { return fmt.Errorf("%w: %d ist keine stunde", ErrInvalidSchedule, hourValue) } return nil } // location liefert die Zeitzone des Zeitplans. func (schedule *Schedule) location() (*time.Location, error) { if strings.TrimSpace(schedule.TimeZone) == "" { return time.UTC, nil } loadedLocation, loadError := time.LoadLocation(schedule.TimeZone) if loadError != nil { return nil, fmt.Errorf("%w: die zeitzone %q ist unbekannt: %v", ErrInvalidSchedule, schedule.TimeZone, loadError) } return loadedLocation, nil } // maximumSearchDays begrenzt die Suche nach dem nächsten Zeitpunkt. // // Ein Zeitplan wie „31. Februar" hat keinen nächsten Zeitpunkt. Ohne Grenze // liefe die Suche endlos; mit ihr entsteht ein verständlicher Fehler. Vier // Jahre decken auch den 29. Februar ab. const maximumSearchDays = 366 * 4 // NextRun berechnet den nächsten Ausführungszeitpunkt nach einem Bezugspunkt. // // Der zurückgegebene Zeitpunkt liegt immer echt nach dem Bezugspunkt. Wäre er // gleich, liefe ein Auftrag unmittelbar nach seinem eigenen Ende erneut — eine // Endlosschleife, die sich als Zeitplan ausgibt. func (schedule *Schedule) NextRun(referenceTime time.Time) (time.Time, error) { if validationError := schedule.Validate(); validationError != nil { return time.Time{}, validationError } if schedule.ScheduleType == ScheduleTypeManual { return time.Time{}, ErrNoNextRun } scheduleLocation, _ := schedule.location() localReference := referenceTime.In(scheduleLocation) if schedule.ScheduleType == ScheduleTypeInterval { return localReference.Add(schedule.Interval), nil } if schedule.ScheduleType == ScheduleTypeHourly { return schedule.nextHourlyRun(localReference), nil } if schedule.ScheduleType == ScheduleTypeCron { return schedule.nextCronRun(localReference) } return schedule.nextCalendarRun(localReference, scheduleLocation) } // ErrNoNextRun meldet einen Zeitplan ohne künftigen Zeitpunkt. var ErrNoNextRun = errors.New("dieser zeitplan hat keinen nächsten ausführungszeitpunkt") // nextHourlyRun berechnet den nächsten stündlichen Zeitpunkt. func (schedule *Schedule) nextHourlyRun(localReference time.Time) time.Time { candidateTime := time.Date(localReference.Year(), localReference.Month(), localReference.Day(), localReference.Hour(), schedule.Minute, 0, 0, localReference.Location()) if !candidateTime.After(localReference) { candidateTime = candidateTime.Add(time.Hour) } return candidateTime } // nextCalendarRun berechnet den nächsten täglichen, wöchentlichen oder // monatlichen Zeitpunkt. // // Gesucht wird tageweise. Das ist langsamer als eine geschlossene Formel und // dafür richtig: Monatslängen, Schaltjahre und Zeitumstellungen fallen dabei // von selbst heraus, statt einzeln bedacht werden zu müssen. func (schedule *Schedule) nextCalendarRun(localReference time.Time, scheduleLocation *time.Location) (time.Time, error) { searchDay := time.Date(localReference.Year(), localReference.Month(), localReference.Day(), 0, 0, 0, 0, scheduleLocation) for dayOffset := 0; dayOffset <= maximumSearchDays; dayOffset++ { // AddDate statt Add(24h): An Umstellungstagen hat ein Tag 23 oder 25 // Stunden. Mit einer festen Stundenzahl verschöbe sich die Suche // zweimal im Jahr um eine Stunde. currentDay := searchDay.AddDate(0, 0, dayOffset) if !schedule.matchesDay(currentDay) { continue } candidateTime := resolveLocalTime(currentDay, schedule.Hour, schedule.Minute, scheduleLocation) if candidateTime.After(localReference) { return candidateTime, nil } } return time.Time{}, fmt.Errorf("%w: in den nächsten %d tagen gibt es keinen passenden zeitpunkt", ErrNoNextRun, maximumSearchDays) } // matchesDay prüft, ob ein Tag zum Zeitplan passt. func (schedule *Schedule) matchesDay(candidateDay time.Time) bool { switch schedule.ScheduleType { case ScheduleTypeDaily: return true case ScheduleTypeWeekly: for _, weekday := range schedule.Weekdays { if candidateDay.Weekday() == weekday { return true } } return false case ScheduleTypeMonthly: daysInMonth := daysInMonthOf(candidateDay) for _, monthDay := range schedule.MonthDays { if monthDay == lastDayOfMonth { if candidateDay.Day() == daysInMonth { return true } continue } if candidateDay.Day() == monthDay { return true } } return false default: return false } } // daysInMonthOf liefert die Zahl der Tage im Monat eines Zeitpunkts. func daysInMonthOf(referenceDay time.Time) int { // Der nullte Tag des Folgemonats ist der letzte des aktuellen. firstOfNextMonth := time.Date(referenceDay.Year(), referenceDay.Month(), 1, 0, 0, 0, 0, referenceDay.Location()). AddDate(0, 1, 0) return firstOfNextMonth.AddDate(0, 0, -1).Day() } // resolveLocalTime bildet eine Ortszeit und behandelt die Zeitumstellung. // // Das ist der heikelste Punkt der ganzen Zeitplanung, und er wird von vielen // Schedulern stillschweigend falsch gemacht. Zwei Fälle: // // 1. Die Uhrzeit existiert nicht. In Mitteleuropa springt die Uhr im Frühjahr // von 02:00 auf 03:00; ein Auftrag „täglich 02:30" hätte an diesem Tag // keinen Zeitpunkt. Go liefert für eine solche Angabe stillschweigend // 03:30 — was hier auch gewollt ist, aber ausdrücklich festgestellt wird. // Der Auftrag läuft, statt einmal im Jahr auszufallen. // // 2. Die Uhrzeit existiert zweimal. Im Herbst wird 02:00 bis 03:00 wiederholt; // „täglich 02:30" gäbe es an diesem Tag zweimal. Go wählt das erste // Vorkommen. Das ist die richtige Wahl: Ein Backup lieber eine Stunde // früher als eines, das zweimal läuft und zwei Ketten anlegt. // // Die Funktion setzt beide Entscheidungen ausdrücklich um, statt sie dem Zufall // des Standardverhaltens zu überlassen. func resolveLocalTime(candidateDay time.Time, targetHour int, targetMinute int, scheduleLocation *time.Location) time.Time { constructedTime := time.Date(candidateDay.Year(), candidateDay.Month(), candidateDay.Day(), targetHour, targetMinute, 0, 0, scheduleLocation) // Weicht die zurückgelesene Stunde ab, lag die gewünschte Uhrzeit in einer // übersprungenen Lücke. Go hat bereits auf den nächsten gültigen Zeitpunkt // gelegt — das wird übernommen, damit der Lauf nicht ausfällt. if constructedTime.Hour() != targetHour { return constructedTime } // Bei einer doppelt vorhandenen Stunde liefert Go das frühere Vorkommen. // Ein Auftrag darf an diesem Tag nur einmal laufen; die zweite Gelegenheit // liegt vor dem zurückgegebenen Zeitpunkt und wird nie erreicht, weil // NextRun stets einen echt späteren Zeitpunkt verlangt. return constructedTime } // Describe beschreibt einen Zeitplan in einem Satz. // // Die Beschreibung ist für die Oberfläche gedacht: Ein Cron-Ausdruck sagt einem // Anwender wenig, „täglich um 02:00 Uhr (Europe/Berlin)" dagegen alles. func (schedule *Schedule) Describe() string { timeZoneSuffix := "" if schedule.TimeZone != "" { timeZoneSuffix = fmt.Sprintf(" (%s)", schedule.TimeZone) } switch schedule.ScheduleType { case ScheduleTypeManual: return "nur auf Anforderung" case ScheduleTypeInterval: return fmt.Sprintf("alle %s", formatDuration(schedule.Interval)) case ScheduleTypeHourly: return fmt.Sprintf("stündlich zur Minute %02d%s", schedule.Minute, timeZoneSuffix) case ScheduleTypeDaily: return fmt.Sprintf("täglich um %02d:%02d Uhr%s", schedule.Hour, schedule.Minute, timeZoneSuffix) case ScheduleTypeWeekly: return fmt.Sprintf("%s um %02d:%02d Uhr%s", formatWeekdays(schedule.Weekdays), schedule.Hour, schedule.Minute, timeZoneSuffix) case ScheduleTypeMonthly: return fmt.Sprintf("%s um %02d:%02d Uhr%s", formatMonthDays(schedule.MonthDays), schedule.Hour, schedule.Minute, timeZoneSuffix) case ScheduleTypeCron: return fmt.Sprintf("nach Cron-Ausdruck %q%s", schedule.CronExpression, timeZoneSuffix) default: return "unbekannter Zeitplan" } } // germanWeekdayNames sind die deutschen Wochentagsnamen. var germanWeekdayNames = map[time.Weekday]string{ time.Monday: "montags", time.Tuesday: "dienstags", time.Wednesday: "mittwochs", time.Thursday: "donnerstags", time.Friday: "freitags", time.Saturday: "samstags", time.Sunday: "sonntags", } // formatWeekdays beschreibt eine Wochentagsliste. func formatWeekdays(weekdays []time.Weekday) string { dayNames := make([]string, 0, len(weekdays)) for _, weekday := range weekdays { dayNames = append(dayNames, germanWeekdayNames[weekday]) } return strings.Join(dayNames, ", ") } // formatMonthDays beschreibt eine Monatstagsliste. func formatMonthDays(monthDays []int) string { dayNames := make([]string, 0, len(monthDays)) for _, monthDay := range monthDays { if monthDay == lastDayOfMonth { dayNames = append(dayNames, "am letzten Tag des Monats") continue } dayNames = append(dayNames, fmt.Sprintf("am %d.", monthDay)) } return strings.Join(dayNames, " und ") } // formatDuration gibt eine Dauer lesbar aus. func formatDuration(duration time.Duration) string { switch { case duration >= 24*time.Hour && duration%(24*time.Hour) == 0: return strconv.Itoa(int(duration/(24*time.Hour))) + " Tage" case duration >= time.Hour && duration%time.Hour == 0: return strconv.Itoa(int(duration/time.Hour)) + " Stunden" case duration >= time.Minute: return strconv.Itoa(int(duration/time.Minute)) + " Minuten" default: return duration.String() } }