- 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
251 lines
9.3 KiB
Go
251 lines
9.3 KiB
Go
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")
|
|
}
|