feat(11-02): run plugin schedules as River periodic jobs through bonfire.Call

- 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
This commit is contained in:
Jakub Zych
2026-09-29 19:47:51 +02:00
parent 77b8ff177a
commit d9f939a1ea
15 changed files with 890 additions and 12 deletions

104
modules/conga/schedule.go Normal file
View File

@@ -0,0 +1,104 @@
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
}