package infra import ( "context" "time" ) // cronSchedule mirrors core.CronSchedule to avoid cross-package import. // In practice, callers should use core.ParseCronExpr and pass the result here. // The struct is duplicated to respect the registry rule of no cross-domain imports // between function packages. // // CronTickerSchedule is the schedule consumed by CronTicker. type CronTickerSchedule struct { Minute []int Hour []int DayOfMonth []int Month []int DayOfWeek []int } // CronTicker creates a channel that emits the current time whenever the given // schedule fires. It uses time.NewTimer internally, recalculating the next tick // after each emission. The channel is closed when ctx is cancelled. func CronTicker(schedule CronTickerSchedule, ctx context.Context) <-chan time.Time { ch := make(chan time.Time, 1) go func() { defer close(ch) for { next := cronTickerNext(schedule, time.Now()) if next.IsZero() { // Impossible schedule — nothing to emit. return } delay := time.Until(next) timer := time.NewTimer(delay) select { case <-ctx.Done(): timer.Stop() return case tick := <-timer.C: select { case ch <- tick: default: // Drop if consumer is not ready. } } } }() return ch } // cronTickerNext finds the next time after `after` that satisfies the schedule. // Returns zero time if no match within 366 days. func cronTickerNext(s CronTickerSchedule, after time.Time) time.Time { t := after.Truncate(time.Minute).Add(time.Minute) limit := after.Add(366 * 24 * time.Hour) for t.Before(limit) { if !cronIntIn(int(t.Month()), s.Month) { t = cronNextMonth(t, s.Month) if t.IsZero() { return time.Time{} } continue } domOK := cronIntIn(t.Day(), s.DayOfMonth) dowOK := cronIntIn(int(t.Weekday()), s.DayOfWeek) if !domOK || !dowOK { t = time.Date(t.Year(), t.Month(), t.Day()+1, 0, 0, 0, 0, t.Location()) continue } if !cronIntIn(t.Hour(), s.Hour) { next := cronNextHour(t, s.Hour) if next.IsZero() { t = time.Date(t.Year(), t.Month(), t.Day()+1, 0, 0, 0, 0, t.Location()) } else { t = next } continue } if !cronIntIn(t.Minute(), s.Minute) { next := cronNextMinute(t, s.Minute) if next.IsZero() { t = time.Date(t.Year(), t.Month(), t.Day(), t.Hour()+1, 0, 0, 0, t.Location()) } else { t = next } continue } return t } return time.Time{} } func cronIntIn(v int, s []int) bool { for _, x := range s { if x == v { return true } } return false } func cronNextMonth(t time.Time, months []int) time.Time { month := int(t.Month()) for _, m := range months { if m > month { return time.Date(t.Year(), time.Month(m), 1, 0, 0, 0, 0, t.Location()) } } if len(months) > 0 { return time.Date(t.Year()+1, time.Month(months[0]), 1, 0, 0, 0, 0, t.Location()) } return time.Time{} } func cronNextHour(t time.Time, hours []int) time.Time { h := t.Hour() for _, hh := range hours { if hh > h { return time.Date(t.Year(), t.Month(), t.Day(), hh, 0, 0, 0, t.Location()) } } return time.Time{} } func cronNextMinute(t time.Time, minutes []int) time.Time { m := t.Minute() for _, mm := range minutes { if mm > m { return time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), mm, 0, 0, t.Location()) } } return time.Time{} }