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:
250
modules/conga/schedule_test.go
Normal file
250
modules/conga/schedule_test.go
Normal 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")
|
||||
}
|
||||
Reference in New Issue
Block a user