- pact.HasSchedule with ScheduledCommand and Daily/DailyAt/Every cadences (no River import) - bonfire.Call, Catalog and ErrUnknownCommand for in-process command runs - conga Daily/Every wall-clock schedules in app.timezone, periodic jobs on every worker, scheduled queue (MaxAttempts 1, unique by args within the cadence period) - scheduled worker runs only entries matching the compiled table; unregistered commands are skipped with a Warn log - generated app main publishes bonfire.NewCatalog(commands); hello main regenerated
105 lines
3.0 KiB
Go
105 lines
3.0 KiB
Go
package conga
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
|
"git.golem15.com/golem15/summercms/modules/pact"
|
|
"github.com/riverqueue/river"
|
|
)
|
|
|
|
// Daily is a river.PeriodicSchedule that fires once a day at Hour:Minute:00
|
|
// wall-clock time in Loc (UTC when nil). Unlike PeriodicInterval(24h) it
|
|
// does not drift when the scheduler restarts.
|
|
type Daily struct {
|
|
Hour, Minute int
|
|
Loc *time.Location
|
|
}
|
|
|
|
// Next returns the first Hour:Minute:00 in Loc strictly after now. On a DST
|
|
// day a time inside the gap resolves the way time.Date normalizes it.
|
|
func (d Daily) Next(now time.Time) time.Time {
|
|
loc := orUTC(d.Loc)
|
|
n := now.In(loc)
|
|
t := time.Date(n.Year(), n.Month(), n.Day(), d.Hour, d.Minute, 0, 0, loc)
|
|
if !t.After(n) {
|
|
t = time.Date(n.Year(), n.Month(), n.Day()+1, d.Hour, d.Minute, 0, 0, loc)
|
|
}
|
|
return t
|
|
}
|
|
|
|
// Every is a river.PeriodicSchedule that fires at every multiple of Interval
|
|
// since local midnight in Loc (UTC when nil). Interval must be positive.
|
|
type Every struct {
|
|
Interval time.Duration
|
|
Loc *time.Location
|
|
}
|
|
|
|
// Next returns local midnight plus the smallest multiple of Interval
|
|
// strictly after now, and the next local midnight when that multiple falls
|
|
// on or after it.
|
|
func (e Every) Next(now time.Time) time.Time {
|
|
loc := orUTC(e.Loc)
|
|
n := now.In(loc)
|
|
midnight := time.Date(n.Year(), n.Month(), n.Day(), 0, 0, 0, 0, loc)
|
|
nextMidnight := time.Date(n.Year(), n.Month(), n.Day()+1, 0, 0, 0, 0, loc)
|
|
if e.Interval <= 0 {
|
|
return nextMidnight
|
|
}
|
|
k := n.Sub(midnight)/e.Interval + 1
|
|
t := midnight.Add(k * e.Interval)
|
|
if !t.Before(nextMidnight) {
|
|
return nextMidnight
|
|
}
|
|
return t
|
|
}
|
|
|
|
func orUTC(loc *time.Location) *time.Location {
|
|
if loc == nil {
|
|
return time.UTC
|
|
}
|
|
return loc
|
|
}
|
|
|
|
// scheduleFor returns the River schedule of a cadence in loc and its period.
|
|
func scheduleFor(c pact.Cadence, loc *time.Location) (river.PeriodicSchedule, time.Duration, error) {
|
|
if c.IsZero() {
|
|
return nil, 0, fmt.Errorf("cadence is zero")
|
|
}
|
|
if h, m, ok := c.At(); ok {
|
|
if h < 0 || h > 23 || m < 0 || m > 59 {
|
|
return nil, 0, fmt.Errorf("daily time %02d:%02d is out of range", h, m)
|
|
}
|
|
return Daily{Hour: h, Minute: m, Loc: loc}, 24 * time.Hour, nil
|
|
}
|
|
d := c.Interval()
|
|
if d <= 0 {
|
|
return nil, 0, fmt.Errorf("interval %s is not positive", d)
|
|
}
|
|
if d < time.Second {
|
|
return nil, 0, fmt.Errorf("interval %s is shorter than one second", d)
|
|
}
|
|
if (24*time.Hour)%d != 0 {
|
|
return nil, 0, fmt.Errorf("interval %s does not divide 24h evenly", d)
|
|
}
|
|
return Every{Interval: d, Loc: loc}, d, nil
|
|
}
|
|
|
|
// appLocation is the app.timezone location, UTC when unset.
|
|
func appLocation(app *backpack.App) (*time.Location, error) {
|
|
name := ""
|
|
if app != nil && app.Config != nil {
|
|
name = strings.TrimSpace(app.Config.String("app.timezone"))
|
|
}
|
|
if name == "" {
|
|
return time.UTC, nil
|
|
}
|
|
loc, err := time.LoadLocation(name)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("conga: app.timezone %q: %w", name, err)
|
|
}
|
|
return loc, nil
|
|
}
|