package scheduler import ( "fmt" "strconv" "strings" "time" ) // cronExpression ist ein ausgewerteter Cron-Ausdruck. // // Umgesetzt sind die fünf klassischen Felder: Minute, Stunde, Tag des Monats, // Monat, Wochentag. Sekunden fehlen ausdrücklich — ein Sicherungsauftrag im // Sekundentakt ist kein Zeitplan, sondern ein Tippfehler. // // Ein eigener Parser statt einer fremden Bibliothek: Cron ist klein, die // Fallstricke liegen nicht im Zerlegen, sondern in der Behandlung von // Zeitzonen und der Tag/Wochentag-Sonderregel. Beides muss ohnehin selbst // gelöst werden. type cronExpression struct { // minutes sind die zugelassenen Minuten. minutes map[int]bool // hours sind die zugelassenen Stunden. hours map[int]bool // monthDays sind die zugelassenen Tage des Monats. monthDays map[int]bool // months sind die zugelassenen Monate. months map[int]bool // weekdays sind die zugelassenen Wochentage. weekdays map[int]bool // monthDayRestricted meldet ein eingeschränktes Tagesfeld. // // Nötig für die Sonderregel, die unten erklärt ist. monthDayRestricted bool // weekdayRestricted meldet ein eingeschränktes Wochentagsfeld. weekdayRestricted bool } // cronFieldBounds sind die zulässigen Wertebereiche je Feld. var cronFieldBounds = []struct { // name benennt das Feld für Fehlermeldungen. name string // minimum ist der kleinste zulässige Wert. minimum int // maximum ist der größte zulässige Wert. maximum int }{ {"minute", 0, 59}, {"stunde", 0, 23}, {"tag des monats", 1, 31}, {"monat", 1, 12}, // Der Wochentag reicht bis 7: Sonntag darf sowohl 0 als auch 7 sein. Beide // Schreibweisen sind gebräuchlich, und wer die zweite ablehnt, lehnt einen // gültigen Cron-Ausdruck ab. {"wochentag", 0, 7}, } // cronNamedValues erlaubt Namen statt Zahlen in Monats- und Wochentagsfeldern. var cronNamedValues = map[string]int{ "jan": 1, "feb": 2, "mar": 3, "apr": 4, "may": 5, "jun": 6, "jul": 7, "aug": 8, "sep": 9, "oct": 10, "nov": 11, "dec": 12, "sun": 0, "mon": 1, "tue": 2, "wed": 3, "thu": 4, "fri": 5, "sat": 6, } // cronPredefinedExpressions sind die gebräuchlichen Kurzformen. var cronPredefinedExpressions = map[string]string{ "@yearly": "0 0 1 1 *", "@annually": "0 0 1 1 *", "@monthly": "0 0 1 * *", "@weekly": "0 0 * * 0", "@daily": "0 0 * * *", "@midnight": "0 0 * * *", "@hourly": "0 * * * *", } // parseCronExpression zerlegt einen Cron-Ausdruck. func parseCronExpression(expressionText string) (*cronExpression, error) { trimmedExpression := strings.TrimSpace(expressionText) if trimmedExpression == "" { return nil, fmt.Errorf("%w: der cron-ausdruck ist leer", ErrInvalidSchedule) } if replacement, isPredefined := cronPredefinedExpressions[strings.ToLower(trimmedExpression)]; isPredefined { trimmedExpression = replacement } expressionFields := strings.Fields(trimmedExpression) if len(expressionFields) != len(cronFieldBounds) { return nil, fmt.Errorf("%w: ein cron-ausdruck braucht %d felder (minute stunde tag monat wochentag), angegeben waren %d", ErrInvalidSchedule, len(cronFieldBounds), len(expressionFields)) } parsedFields := make([]map[int]bool, len(expressionFields)) for fieldIndex, fieldText := range expressionFields { fieldBounds := cronFieldBounds[fieldIndex] allowedValues, fieldError := parseCronField(fieldText, fieldBounds.minimum, fieldBounds.maximum) if fieldError != nil { return nil, fmt.Errorf("%w: das feld %q (%s) ist unbrauchbar: %v", ErrInvalidSchedule, fieldText, fieldBounds.name, fieldError) } parsedFields[fieldIndex] = allowedValues } return &cronExpression{ minutes: parsedFields[0], hours: parsedFields[1], monthDays: parsedFields[2], months: parsedFields[3], weekdays: parsedFields[4], monthDayRestricted: expressionFields[2] != "*", weekdayRestricted: expressionFields[4] != "*", }, nil } // parseCronField wertet ein einzelnes Feld aus. func parseCronField(fieldText string, minimumValue int, maximumValue int) (map[int]bool, error) { allowedValues := make(map[int]bool) for _, listPart := range strings.Split(fieldText, ",") { if listPart == "" { return nil, fmt.Errorf("leerer listeneintrag") } stepWidth := 1 rangePart := listPart // Schrittweite: "*/15" oder "0-30/5" if slashIndex := strings.Index(listPart, "/"); slashIndex >= 0 { rangePart = listPart[:slashIndex] parsedStep, stepError := strconv.Atoi(listPart[slashIndex+1:]) if stepError != nil || parsedStep <= 0 { return nil, fmt.Errorf("die schrittweite %q ist keine positive zahl", listPart[slashIndex+1:]) } stepWidth = parsedStep } rangeStart := minimumValue rangeEnd := maximumValue if rangePart != "*" { startText, endText, isRange := strings.Cut(rangePart, "-") parsedStart, startError := parseCronValue(startText) if startError != nil { return nil, startError } rangeStart = parsedStart if isRange { parsedEnd, endError := parseCronValue(endText) if endError != nil { return nil, endError } rangeEnd = parsedEnd } else if stepWidth == 1 { // Ein einzelner Wert ohne Schrittweite meint genau ihn. rangeEnd = parsedStart } } if rangeStart < minimumValue || rangeEnd > maximumValue || rangeStart > rangeEnd { return nil, fmt.Errorf("der bereich %d-%d liegt außerhalb von %d-%d", rangeStart, rangeEnd, minimumValue, maximumValue) } for currentValue := rangeStart; currentValue <= rangeEnd; currentValue += stepWidth { allowedValues[currentValue] = true } } if len(allowedValues) == 0 { return nil, fmt.Errorf("das feld ergibt keinen zulässigen wert") } return allowedValues, nil } // parseCronValue liest eine Zahl oder einen Namen. func parseCronValue(valueText string) (int, error) { trimmedValue := strings.TrimSpace(valueText) if namedValue, isNamed := cronNamedValues[strings.ToLower(trimmedValue)]; isNamed { return namedValue, nil } parsedValue, parseError := strconv.Atoi(trimmedValue) if parseError != nil { return 0, fmt.Errorf("%q ist weder eine zahl noch ein bekannter name", trimmedValue) } return parsedValue, nil } // matches prüft, ob ein Zeitpunkt zum Ausdruck passt. // // Die Tag/Wochentag-Sonderregel ist der eine Punkt, an dem Cron nicht der // Anschauung folgt: Sind **beide** Felder eingeschränkt, gilt ODER statt UND. // „0 0 13 * 5" bedeutet „am 13. **oder** freitags", nicht „an Freitagen, die // der 13. sind". Wer das als UND umsetzt, baut einen Zeitplan, der fast nie // läuft — und es fällt erst nach Monaten auf. func (expression *cronExpression) matches(candidateTime time.Time) bool { if !expression.minutes[candidateTime.Minute()] { return false } if !expression.hours[candidateTime.Hour()] { return false } if !expression.months[int(candidateTime.Month())] { return false } weekdayNumber := int(candidateTime.Weekday()) // Sonntag ist 0 und darf zusätzlich als 7 geschrieben sein. weekdayMatches := expression.weekdays[weekdayNumber] || (weekdayNumber == 0 && expression.weekdays[7]) monthDayMatches := expression.monthDays[candidateTime.Day()] if expression.monthDayRestricted && expression.weekdayRestricted { return monthDayMatches || weekdayMatches } return monthDayMatches && weekdayMatches } // nextCronRun sucht den nächsten passenden Zeitpunkt. // // Die Suche geht minutenweise. Bei einem Ausdruck wie „am 29. Februar" sind das // bis zu vier Jahre — deshalb die Obergrenze, die einen verständlichen Fehler // statt einer hängenden Schleife ergibt. func (schedule *Schedule) nextCronRun(localReference time.Time) (time.Time, error) { parsedExpression, parseError := parseCronExpression(schedule.CronExpression) if parseError != nil { return time.Time{}, parseError } // Sekunden und Nanosekunden fallen weg; die Suche beginnt in der nächsten // vollen Minute, damit der Zeitpunkt echt später liegt. candidateTime := localReference.Truncate(time.Minute).Add(time.Minute) const maximumSearchMinutes = maximumSearchDays * 24 * 60 for searchedMinutes := 0; searchedMinutes < maximumSearchMinutes; searchedMinutes++ { if parsedExpression.matches(candidateTime) { return candidateTime, nil } candidateTime = candidateTime.Add(time.Minute) } return time.Time{}, fmt.Errorf("%w: der ausdruck %q ergibt in den nächsten %d jahren keinen zeitpunkt", ErrNoNextRun, schedule.CronExpression, maximumSearchDays/366) }