package scheduler import ( "fmt" "sort" "time" ) // WindowKind benennt die Wirkung eines Wartungsfensters. type WindowKind string const ( // WindowKindBlackout unterdrückt Läufe im Fenster. // // Der gewöhnliche Fall: Während der Inventur oder des Monatsabschlusses // soll die Anlage nicht zusätzlich mit Sicherungen belastet werden. WindowKindBlackout WindowKind = "blackout" // WindowKindAllowed lässt Läufe ausschließlich im Fenster zu. // // Die Umkehrung: Manche Betreiber erlauben Sicherungen nur nachts. Ein // Auftrag, dessen Zeitpunkt außerhalb liegt, wartet auf das nächste // Fenster. WindowKindAllowed WindowKind = "allowed" ) // MaintenanceWindow ist ein Zeitraum mit besonderer Behandlung. type MaintenanceWindow struct { // Identifier ist die Kennung des Fensters. Identifier string `json:"identifier"` // Name ist die sprechende Bezeichnung. Name string `json:"name"` // WindowKind ist die Wirkung. WindowKind WindowKind `json:"kind"` // StartsAt ist der Beginn eines einmaligen Fensters in UTC. StartsAt time.Time `json:"starts_at,omitempty"` // EndsAt ist das Ende eines einmaligen Fensters in UTC. EndsAt time.Time `json:"ends_at,omitempty"` // Recurring beschreibt ein wiederkehrendes Fenster. // // Ist es gesetzt, gelten StartsAt und EndsAt nicht. Recurring *RecurringWindow `json:"recurring,omitempty"` // AppliesToJobIDs beschränkt das Fenster auf bestimmte Aufträge. // // Eine leere Liste bedeutet: gilt für alle. Das ist der sichere Standard — // wer ein Fenster einrichtet, meint in aller Regel die ganze Anlage. AppliesToJobIDs []string `json:"applies_to_job_ids,omitempty"` } // RecurringWindow ist ein wiederkehrender Zeitraum. type RecurringWindow struct { // Weekdays sind die Wochentage; leer bedeutet täglich. Weekdays []time.Weekday `json:"weekdays,omitempty"` // StartHour ist die Anfangsstunde in Ortszeit. StartHour int `json:"start_hour"` // StartMinute ist die Anfangsminute. StartMinute int `json:"start_minute"` // Duration ist die Länge des Fensters. // // Eine Länge statt einer Endzeit, damit ein Fenster über Mitternacht // hinausreichen kann. „22:00 bis 02:00" als zwei Uhrzeiten zu beschreiben // verlangt eine Sonderregel, die gern vergessen wird. Duration time.Duration `json:"duration"` // TimeZone ist die Zeitzone der Uhrzeiten. TimeZone string `json:"time_zone,omitempty"` } // Validate prüft ein Wartungsfenster. func (window *MaintenanceWindow) Validate() error { if window.WindowKind != WindowKindBlackout && window.WindowKind != WindowKindAllowed { return fmt.Errorf("%w: die wirkung %q ist unbekannt", ErrInvalidSchedule, window.WindowKind) } if window.Recurring == nil { if window.StartsAt.IsZero() || window.EndsAt.IsZero() { return fmt.Errorf("%w: ein einmaliges fenster braucht anfang und ende", ErrInvalidSchedule) } if !window.EndsAt.After(window.StartsAt) { return fmt.Errorf("%w: das ende des fensters liegt nicht nach seinem anfang", ErrInvalidSchedule) } return nil } recurringWindow := window.Recurring if recurringWindow.Duration <= 0 { return fmt.Errorf("%w: ein wiederkehrendes fenster braucht eine länge", ErrInvalidSchedule) } // Ein Fenster von mehr als einer Woche überdeckte sich mit sich selbst; die // Auswertung wäre nicht mehr eindeutig. if recurringWindow.Duration > 7*24*time.Hour { return fmt.Errorf("%w: ein wiederkehrendes fenster darf höchstens eine woche dauern", ErrInvalidSchedule) } if validationError := validateHour(recurringWindow.StartHour); validationError != nil { return validationError } if validationError := validateMinute(recurringWindow.StartMinute); validationError != nil { return validationError } if recurringWindow.TimeZone != "" { if _, loadError := time.LoadLocation(recurringWindow.TimeZone); loadError != nil { return fmt.Errorf("%w: die zeitzone %q ist unbekannt", ErrInvalidSchedule, recurringWindow.TimeZone) } } return nil } // AppliesTo meldet, ob das Fenster für einen Auftrag gilt. func (window *MaintenanceWindow) AppliesTo(jobIdentifier string) bool { if len(window.AppliesToJobIDs) == 0 { return true } for _, applicableJobID := range window.AppliesToJobIDs { if applicableJobID == jobIdentifier { return true } } return false } // Contains meldet, ob ein Zeitpunkt im Fenster liegt. func (window *MaintenanceWindow) Contains(candidateTime time.Time) bool { if window.Recurring == nil { // Der Anfang gehört dazu, das Ende nicht. Sonst gehörte ein Zeitpunkt // zwei aufeinanderfolgenden Fenstern an. return !candidateTime.Before(window.StartsAt) && candidateTime.Before(window.EndsAt) } return window.Recurring.contains(candidateTime) } // contains meldet, ob ein Zeitpunkt in einem wiederkehrenden Fenster liegt. // // Geprüft wird rückwärts über acht Tage. Der Grund ist die Länge: Ein Fenster, // das samstags um 22:00 beginnt und zwölf Stunden dauert, reicht bis Sonntag // 10:00. Wer nur den Tag des Zeitpunkts prüft, hielte Sonntag 09:00 für frei — // und liesse den Auftrag mitten in die Wartung laufen. func (recurringWindow *RecurringWindow) contains(candidateTime time.Time) bool { windowLocation := time.UTC if recurringWindow.TimeZone != "" { if loadedLocation, loadError := time.LoadLocation(recurringWindow.TimeZone); loadError == nil { windowLocation = loadedLocation } } localCandidate := candidateTime.In(windowLocation) // Acht Tage decken auch ein Fenster von voller Wochenlänge ab. const lookbackDays = 8 for dayOffset := 0; dayOffset < lookbackDays; dayOffset++ { windowDay := localCandidate.AddDate(0, 0, -dayOffset) if !recurringWindow.matchesWeekday(windowDay.Weekday()) { continue } windowStart := time.Date(windowDay.Year(), windowDay.Month(), windowDay.Day(), recurringWindow.StartHour, recurringWindow.StartMinute, 0, 0, windowLocation) windowEnd := windowStart.Add(recurringWindow.Duration) if !localCandidate.Before(windowStart) && localCandidate.Before(windowEnd) { return true } } return false } // matchesWeekday prüft den Wochentag eines Fensterbeginns. func (recurringWindow *RecurringWindow) matchesWeekday(candidateWeekday time.Weekday) bool { if len(recurringWindow.Weekdays) == 0 { return true } for _, allowedWeekday := range recurringWindow.Weekdays { if allowedWeekday == candidateWeekday { return true } } return false } // maximumWindowSearch begrenzt die Suche nach dem Fensterende. const maximumWindowSearch = 32 * 24 * time.Hour // windowSearchStep ist die Schrittweite bei der Suche nach dem Fensterende. // // Eine Minute genügt: Zeitpläne haben keine feinere Auflösung. const windowSearchStep = time.Minute // WindowSet ist eine Menge von Wartungsfenstern. type WindowSet struct { // windows sind die enthaltenen Fenster. windows []MaintenanceWindow } // NewWindowSet erzeugt eine geprüfte Fenstermenge. func NewWindowSet(maintenanceWindows []MaintenanceWindow) (*WindowSet, error) { for windowIndex := range maintenanceWindows { if validationError := maintenanceWindows[windowIndex].Validate(); validationError != nil { return nil, fmt.Errorf("das fenster %q ist unbrauchbar: %w", maintenanceWindows[windowIndex].Name, validationError) } } return &WindowSet{windows: maintenanceWindows}, nil } // BlockReason erklärt, warum ein Zeitpunkt gesperrt ist. type BlockReason struct { // WindowName ist das verantwortliche Fenster. WindowName string // WindowKind ist dessen Wirkung. WindowKind WindowKind // Explanation ist eine verständliche Begründung. Explanation string } // IsBlocked prüft, ob ein Zeitpunkt für einen Auftrag gesperrt ist. // // Zwei Regeln greifen nacheinander: // // 1. Liegt der Zeitpunkt in einem Sperrfenster, ist er gesperrt. // 2. Gibt es Erlaubnisfenster und liegt der Zeitpunkt in keinem davon, ist er // ebenfalls gesperrt. // // Die zweite Regel gilt nur, wenn überhaupt ein Erlaubnisfenster für den // Auftrag eingerichtet ist. Andernfalls wäre jeder Zeitpunkt gesperrt, sobald // irgendwo ein Erlaubnisfenster existiert — und alle Sicherungen fielen aus. func (windowSet *WindowSet) IsBlocked(candidateTime time.Time, jobIdentifier string) (bool, *BlockReason) { var hasAllowedWindow bool var isInsideAllowedWindow bool for windowIndex := range windowSet.windows { currentWindow := &windowSet.windows[windowIndex] if !currentWindow.AppliesTo(jobIdentifier) { continue } if currentWindow.WindowKind == WindowKindBlackout { if currentWindow.Contains(candidateTime) { return true, &BlockReason{ WindowName: currentWindow.Name, WindowKind: WindowKindBlackout, Explanation: fmt.Sprintf("Der Zeitpunkt liegt im Wartungsfenster %q.", currentWindow.Name), } } continue } hasAllowedWindow = true if currentWindow.Contains(candidateTime) { isInsideAllowedWindow = true } } if hasAllowedWindow && !isInsideAllowedWindow { return true, &BlockReason{ WindowKind: WindowKindAllowed, Explanation: "Der Zeitpunkt liegt außerhalb der Zeiträume, in denen Sicherungen zugelassen sind.", } } return false, nil } // NextAllowedTime sucht den nächsten freien Zeitpunkt ab einem Bezugspunkt. // // Ein durch ein Fenster verhinderter Lauf wird **verschoben, nicht übergangen**. // Ihn ausfallen zu lassen wäre der schlimmste Ausgang: Es entstünde eine Lücke // in der Sicherungskette, ohne dass jemand davon erführe. Dass ein Lauf // verspätet ist, sieht man; dass er fehlt, nicht. func (windowSet *WindowSet) NextAllowedTime(desiredTime time.Time, jobIdentifier string) (time.Time, error) { candidateTime := desiredTime searchDeadline := desiredTime.Add(maximumWindowSearch) for candidateTime.Before(searchDeadline) { isBlocked, _ := windowSet.IsBlocked(candidateTime, jobIdentifier) if !isBlocked { return candidateTime, nil } candidateTime = candidateTime.Add(windowSearchStep) } // Kein freier Zeitpunkt binnen eines Monats bedeutet fast immer eine // fehlerhafte Konfiguration — etwa ein Sperrfenster ohne Ende. Das ist ein // Fehler und kein stiller Ausfall. return time.Time{}, fmt.Errorf("für den auftrag %s gibt es in den nächsten %.0f tagen keinen zulässigen zeitpunkt; "+ "prüfen Sie die wartungsfenster", jobIdentifier, maximumWindowSearch.Hours()/24) } // ActiveWindows liefert die zu einem Zeitpunkt wirksamen Fenster. // // Für die Oberfläche: Der Anwender soll sehen, warum sein Auftrag wartet. func (windowSet *WindowSet) ActiveWindows(candidateTime time.Time, jobIdentifier string) []MaintenanceWindow { activeWindows := make([]MaintenanceWindow, 0) for windowIndex := range windowSet.windows { currentWindow := &windowSet.windows[windowIndex] if currentWindow.AppliesTo(jobIdentifier) && currentWindow.Contains(candidateTime) { activeWindows = append(activeWindows, *currentWindow) } } sort.Slice(activeWindows, func(firstIndex int, secondIndex int) bool { return activeWindows[firstIndex].Name < activeWindows[secondIndex].Name }) return activeWindows }