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

View File

@@ -0,0 +1,250 @@
package conga
import (
"context"
"fmt"
"log/slog"
"strings"
"sync"
"testing"
"time"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bonfire"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
)
type schedulePlugin struct {
id string
schedule []pact.ScheduledCommand
}
func (p *schedulePlugin) ID() string { return p.id }
func (p *schedulePlugin) Requires() []string { return nil }
func (p *schedulePlugin) Register(*backpack.App) error { return nil }
func (p *schedulePlugin) Boot(*backpack.App) error { return nil }
func (p *schedulePlugin) Schedule() []pact.ScheduledCommand { return p.schedule }
func schedulePlugins(entries ...pact.ScheduledCommand) []party.Plugin {
return []party.Plugin{&schedulePlugin{id: "acme.test", schedule: entries}}
}
// captureHandler records log messages with their string attributes.
type captureHandler struct {
mu sync.Mutex
records []capturedRecord
}
type capturedRecord struct {
level slog.Level
msg string
attrs map[string]string
}
func (h *captureHandler) Enabled(context.Context, slog.Level) bool { return true }
func (h *captureHandler) WithAttrs([]slog.Attr) slog.Handler { return h }
func (h *captureHandler) WithGroup(string) slog.Handler { return h }
func (h *captureHandler) Handle(_ context.Context, r slog.Record) error {
rec := capturedRecord{level: r.Level, msg: r.Message, attrs: map[string]string{}}
r.Attrs(func(a slog.Attr) bool {
rec.attrs[a.Key] = a.Value.String()
return true
})
h.mu.Lock()
h.records = append(h.records, rec)
h.mu.Unlock()
return nil
}
// find returns the first record with msg whose attrs contain key=value.
func (h *captureHandler) find(level slog.Level, msg, key, value string) bool {
h.mu.Lock()
defer h.mu.Unlock()
for _, r := range h.records {
if r.level == level && r.msg == msg && r.attrs[key] == value {
return true
}
}
return false
}
func (h *captureHandler) waitFor(t *testing.T, level slog.Level, msg, key, value string) {
t.Helper()
deadline := time.Now().Add(10 * time.Second)
for !h.find(level, msg, key, value) {
if time.Now().After(deadline) {
t.Fatalf("no %s log %q with %s=%s", level, msg, key, value)
}
time.Sleep(25 * time.Millisecond)
}
}
func mustTime(t *testing.T, loc *time.Location, s string) time.Time {
t.Helper()
tm, err := time.ParseInLocation("2006-01-02 15:04:05", s, loc)
if err != nil {
t.Fatal(err)
}
return tm
}
// TestScheduleNext covers the CLI-04 adjacency edges and wall-clock DST
// behaviour of the Daily and Every schedules.
func TestScheduleNext(t *testing.T) {
utc := time.UTC
warsaw, err := time.LoadLocation("Europe/Warsaw")
if err != nil {
t.Fatal(err)
}
cases := []struct {
name string
s interface{ Next(time.Time) time.Time }
now time.Time
want time.Time
}{
{"daily_exactly_on_boundary_is_next_day", Daily{Loc: utc}, mustTime(t, utc, "2026-03-10 00:00:00"), mustTime(t, utc, "2026-03-11 00:00:00")},
{"daily_just_before", Daily{Loc: utc}, mustTime(t, utc, "2026-03-10 23:59:59"), mustTime(t, utc, "2026-03-11 00:00:00")},
{"daily_at_later_today", Daily{Hour: 3, Minute: 30, Loc: utc}, mustTime(t, utc, "2026-03-10 03:29:59"), mustTime(t, utc, "2026-03-10 03:30:00")},
{"daily_nil_loc_is_utc", Daily{Hour: 1}, mustTime(t, utc, "2026-03-10 01:00:00"), mustTime(t, utc, "2026-03-11 01:00:00")},
{"daily_converts_input_location", Daily{Loc: warsaw}, mustTime(t, utc, "2026-01-10 22:30:00"), mustTime(t, warsaw, "2026-01-11 00:00:00")},
{"daily_spring_forward_keeps_wall_clock", Daily{Hour: 12, Loc: warsaw}, mustTime(t, warsaw, "2026-03-28 12:00:00"), mustTime(t, warsaw, "2026-03-29 12:00:00")},
{"daily_fall_back_keeps_wall_clock", Daily{Hour: 12, Loc: warsaw}, mustTime(t, warsaw, "2026-10-24 12:00:00"), mustTime(t, warsaw, "2026-10-25 12:00:00")},
{"every_exactly_on_multiple_is_strictly_after", Every{Interval: 5 * time.Minute, Loc: utc}, mustTime(t, utc, "2026-03-10 10:05:00"), mustTime(t, utc, "2026-03-10 10:10:00")},
{"every_between_multiples", Every{Interval: 5 * time.Minute, Loc: utc}, mustTime(t, utc, "2026-03-10 10:07:13"), mustTime(t, utc, "2026-03-10 10:10:00")},
{"every_rolls_to_midnight", Every{Interval: 5 * time.Minute, Loc: utc}, mustTime(t, utc, "2026-03-10 23:57:00"), mustTime(t, utc, "2026-03-11 00:00:00")},
{"every_second_on_boundary", Every{Interval: time.Second, Loc: utc}, mustTime(t, utc, "2026-03-10 10:00:00"), mustTime(t, utc, "2026-03-10 10:00:01")},
{"every_hour_in_location", Every{Interval: time.Hour, Loc: warsaw}, mustTime(t, warsaw, "2026-01-10 09:15:00"), mustTime(t, warsaw, "2026-01-10 10:00:00")},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := c.s.Next(c.now)
if !got.Equal(c.want) {
t.Fatalf("Next(%s) = %s, want %s", c.now, got, c.want)
}
})
}
// The DST-day runs are 23h and 25h apart but stay at 12:00 local.
spring := Daily{Hour: 12, Loc: warsaw}.Next(mustTime(t, warsaw, "2026-03-28 12:00:00"))
if d := spring.Sub(mustTime(t, warsaw, "2026-03-28 12:00:00")); d != 23*time.Hour {
t.Fatalf("spring-forward gap = %s, want 23h", d)
}
if h, m, _ := spring.Clock(); h != 12 || m != 0 {
t.Fatalf("spring-forward wall clock = %02d:%02d", h, m)
}
for _, bad := range []pact.Cadence{{}, pact.Every(0), pact.Every(500 * time.Millisecond), pact.Every(7 * time.Minute), pact.DailyAt(24, 0), pact.DailyAt(0, 60)} {
if _, _, err := scheduleFor(bad, utc); err == nil {
t.Fatalf("scheduleFor(%+v) accepted an invalid cadence", bad)
}
}
if _, period, err := scheduleFor(pact.Daily(), utc); err != nil || period != 24*time.Hour {
t.Fatalf("daily period = %s, err %v", period, err)
}
if _, period, err := scheduleFor(pact.Every(15*time.Minute), utc); err != nil || period != 15*time.Minute {
t.Fatalf("every period = %s, err %v", period, err)
}
}
// TestScheduleEntries covers the CLI-04 empty, invalid and ordering edges.
func TestScheduleEntries(t *testing.T) {
app := backpack.New(nil)
jobs, table, err := periodicJobs(app, []party.Plugin{
&schedulePlugin{id: "acme.none"},
&schedulePlugin{id: "acme.empty", schedule: []pact.ScheduledCommand{}},
})
if err != nil || len(jobs) != 0 || len(table) != 0 {
t.Fatalf("empty schedules: jobs %d, table %d, err %v", len(jobs), len(table), err)
}
entries, err := scheduleEntries(app, []party.Plugin{
&schedulePlugin{id: "acme.b", schedule: []pact.ScheduledCommand{
{Command: "acme:one", Cadence: pact.Daily()},
{Command: "acme:two", Cadence: pact.Daily()},
}},
&schedulePlugin{id: "acme.a", schedule: []pact.ScheduledCommand{{Command: "acme:three", Cadence: pact.Every(time.Minute)}}},
})
if err != nil {
t.Fatal(err)
}
var ids []string
for _, e := range entries {
ids = append(ids, e.id)
}
if got := strings.Join(ids, ","); got != "acme.b[0]:acme:one,acme.b[1]:acme:two,acme.a[0]:acme:three" {
t.Fatalf("entry ids = %s", got)
}
for _, c := range []struct {
entry pact.ScheduledCommand
want string
}{
{pact.ScheduledCommand{Command: " ", Cadence: pact.Daily()}, "plugin acme.bad schedule entry 1"},
{pact.ScheduledCommand{Command: "acme:x"}, "plugin acme.bad schedule entry 1"},
{pact.ScheduledCommand{Command: "acme:x", Cadence: pact.Every(7 * time.Minute)}, "plugin acme.bad schedule entry 1"},
} {
_, _, err := periodicJobs(app, []party.Plugin{&schedulePlugin{id: "acme.bad", schedule: []pact.ScheduledCommand{
{Command: "acme:ok", Cadence: pact.Daily()}, c.entry,
}}})
if err == nil || !strings.Contains(err.Error(), c.want) {
t.Fatalf("entry %+v: err = %v, want %q", c.entry, err, c.want)
}
}
if _, err := appLocation(backpack.New(nil)); err != nil {
t.Fatal(err)
}
}
// TestScheduleRunsCommand covers CLI-04 end to end: an Every(1s) entry runs
// through a River periodic job and bonfire.Call inside a worker, and an
// unregistered command is skipped with a Warn log (user decision 5).
func TestScheduleRunsCommand(t *testing.T) {
db, dsn := migratedDB(t)
app, _ := testApp(t, db, dsn, nil)
logs := &captureHandler{}
if err := app.Publish(slog.New(logs)); err != nil {
t.Fatal(err)
}
ticked := make(chan []string, 16)
catalog := bonfire.NewCatalog([]bonfire.Command{{
Name: "acme:tick",
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
out.Printf("tick %s\n", strings.Join(in.Args(), " "))
select {
case ticked <- in.Args():
default:
}
return nil
},
}})
if err := app.Publish(catalog); err != nil {
t.Fatal(err)
}
w, err := StartWorker(t.Context(), app, schedulePlugins(
pact.ScheduledCommand{Command: "acme:tick", Args: []string{"a"}, Cadence: pact.Every(time.Second)},
pact.ScheduledCommand{Command: "acme:missing", Cadence: pact.Every(time.Second)},
), WorkerOptions{})
if err != nil {
t.Fatal(err)
}
defer func() {
if err := w.Stop(context.Background()); err != nil {
t.Error(err)
}
}()
if got := strings.Join(w.Queues(), ","); got != "default,scheduled" {
t.Fatalf("queues = %s", got)
}
select {
case args := <-ticked:
if fmt.Sprint(args) != "[a]" {
t.Fatalf("acme:tick args = %v", args)
}
case <-time.After(10 * time.Second):
t.Fatal("acme:tick did not run within 10s")
}
logs.waitFor(t, slog.LevelInfo, "tick a", "command", "acme:tick")
logs.waitFor(t, slog.LevelWarn, "schedule: command not registered; skipping", "command", "acme:missing")
}