feat(triggers): add schedule trigger kind + internal scheduler
Build / build (push) Successful in 10m42s
Build / build (push) Successful in 10m42s
Fourth trigger kind alongside registry/git/manual. Recurring time-interval fires driven by a new internal/scheduler tick loop (default 30s, clamped to 5m). Goes through the same webhook.Handler.FanOutForTrigger seam as inbound HTTP webhooks, so per-binding concurrency, outcome accounting, and config-merge semantics are identical. Schema: triggers.last_fired_at TEXT column (additive ALTER for existing DBs). Scheduler persists last_fired_at BEFORE dispatch so a panicking Match cannot wedge a tight loop; failed deploys wait one full interval before retry — correct trade-off for a periodic refresh trigger. Frontend: TriggerKindForm + /triggers/new + /triggers/[id] gain the schedule kind (4-col card grid, preset chips Hourly/Daily/Weekly, custom interval input matched to Go time.ParseDuration syntax, optional pinned reference). /triggers/[id] surfaces "last fired" on schedule rows. EN+RU i18n in parity. Review fixes from go-reviewer / security-reviewer / typescript-reviewer: - Scheduler Start/Stop wrapped in sync.Once (no goroutine leak / double- cancel panic on shutdown re-entry). - shouldFire rejects sub-MinInterval as defense-in-depth against hand-inserted rows that bypassed Validate. - fire() asserts trigger Kind=="schedule" before dispatching. - Aligned isValidInterval regex across all three frontend sites; reject the unsupported "d" unit (Go time.ParseDuration doesn't accept it). - formatLastFired falls back to lastFiredNever on malformed timestamps rather than leaking raw bytes into the UI. - main.go scheduler closure logs per-fire deployed/errored counts.
This commit is contained in:
@@ -0,0 +1,223 @@
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/alexei/tinyforge/internal/store"
|
||||
"github.com/alexei/tinyforge/internal/workload/plugin"
|
||||
)
|
||||
|
||||
// newTestStore opens an in-memory SQLite store. Each test gets its own
|
||||
// DSN so parallel runs do not collide on shared cache databases.
|
||||
func newTestStore(t *testing.T) *store.Store {
|
||||
t.Helper()
|
||||
st, err := store.New(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("open store: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = st.Close() })
|
||||
return st
|
||||
}
|
||||
|
||||
func seedScheduleTrigger(t *testing.T, st *store.Store, name, interval, lastFired string) store.Trigger {
|
||||
t.Helper()
|
||||
trg, err := st.CreateTrigger(store.Trigger{
|
||||
Kind: "schedule",
|
||||
Name: name,
|
||||
Config: `{"interval":"` + interval + `"}`,
|
||||
LastFiredAt: lastFired,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateTrigger: %v", err)
|
||||
}
|
||||
return trg
|
||||
}
|
||||
|
||||
func TestShouldFire(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
now := time.Date(2026, 5, 16, 12, 0, 0, 0, time.UTC)
|
||||
s := New(st, func(context.Context, store.Trigger, plugin.InboundEvent) error { return nil }, 0)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
interval string
|
||||
lastFired string
|
||||
want bool
|
||||
}{
|
||||
{"never fired fires", "1h", "", true},
|
||||
{"window not yet elapsed", "1h", now.Add(-30 * time.Minute).Format(time.RFC3339), false},
|
||||
{"window exactly elapsed fires", "1h", now.Add(-1 * time.Hour).Format(time.RFC3339), true},
|
||||
{"window long elapsed fires", "24h", now.Add(-48 * time.Hour).Format(time.RFC3339), true},
|
||||
{"bad interval suppressed", "banana", "", false},
|
||||
{"bad last_fired_at treated as never", "1h", "not-a-timestamp", true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
trg := store.Trigger{
|
||||
Config: `{"interval":"` + tc.interval + `"}`,
|
||||
LastFiredAt: tc.lastFired,
|
||||
}
|
||||
got := s.shouldFire(trg, now)
|
||||
if got != tc.want {
|
||||
t.Fatalf("shouldFire = %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTickOnce_FiresOverdueTriggers(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
now := time.Date(2026, 5, 16, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
// Three triggers: one overdue, one not yet due, one never-fired.
|
||||
overdue := seedScheduleTrigger(t, st, "overdue", "1h", now.Add(-2*time.Hour).Format(time.RFC3339))
|
||||
notDue := seedScheduleTrigger(t, st, "notdue", "1h", now.Add(-30*time.Minute).Format(time.RFC3339))
|
||||
never := seedScheduleTrigger(t, st, "never", "1h", "")
|
||||
|
||||
fired := make(map[string]int)
|
||||
s := New(st, func(_ context.Context, trg store.Trigger, _ plugin.InboundEvent) error {
|
||||
fired[trg.Name]++
|
||||
return nil
|
||||
}, 0)
|
||||
s.clock = func() time.Time { return now }
|
||||
|
||||
s.TickOnce(context.Background())
|
||||
|
||||
if fired["overdue"] != 1 {
|
||||
t.Errorf("overdue should fire once, got %d", fired["overdue"])
|
||||
}
|
||||
if fired["notdue"] != 0 {
|
||||
t.Errorf("notdue should not fire, got %d", fired["notdue"])
|
||||
}
|
||||
if fired["never"] != 1 {
|
||||
t.Errorf("never should fire once on first tick, got %d", fired["never"])
|
||||
}
|
||||
|
||||
// last_fired_at must advance for everyone we dispatched.
|
||||
for _, id := range []string{overdue.ID, never.ID} {
|
||||
row, err := st.GetTriggerByID(id)
|
||||
if err != nil {
|
||||
t.Fatalf("GetTriggerByID(%s): %v", id, err)
|
||||
}
|
||||
if row.LastFiredAt == "" {
|
||||
t.Errorf("last_fired_at not persisted for %s", row.Name)
|
||||
}
|
||||
}
|
||||
// not-due trigger's last_fired_at must NOT have changed.
|
||||
row, err := st.GetTriggerByID(notDue.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetTriggerByID(notdue): %v", err)
|
||||
}
|
||||
if row.LastFiredAt != notDue.LastFiredAt {
|
||||
t.Errorf("notdue last_fired_at changed: was %q now %q", notDue.LastFiredAt, row.LastFiredAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTickOnce_DispatchErrorDoesNotWedgeOthers(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
now := time.Date(2026, 5, 16, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
broken := seedScheduleTrigger(t, st, "broken", "1h", "")
|
||||
seedScheduleTrigger(t, st, "healthy", "1h", "")
|
||||
|
||||
fired := map[string]int{}
|
||||
s := New(st, func(_ context.Context, trg store.Trigger, _ plugin.InboundEvent) error {
|
||||
fired[trg.Name]++
|
||||
if trg.Name == "broken" {
|
||||
return context.Canceled
|
||||
}
|
||||
return nil
|
||||
}, 0)
|
||||
s.clock = func() time.Time { return now }
|
||||
|
||||
s.TickOnce(context.Background())
|
||||
|
||||
if fired["broken"] != 1 {
|
||||
t.Errorf("broken should be attempted once, got %d", fired["broken"])
|
||||
}
|
||||
if fired["healthy"] != 1 {
|
||||
t.Errorf("healthy should fire once, got %d", fired["healthy"])
|
||||
}
|
||||
|
||||
// Core persist-before-dispatch invariant: even though the broken
|
||||
// trigger's dispatcher returned an error, last_fired_at must have
|
||||
// advanced. Otherwise the scheduler would re-fire it on every tick.
|
||||
row, err := st.GetTriggerByID(broken.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetTriggerByID(broken): %v", err)
|
||||
}
|
||||
if row.LastFiredAt == "" {
|
||||
t.Fatalf("broken trigger last_fired_at must advance even on dispatch error")
|
||||
}
|
||||
|
||||
// And: a second TickOnce at the same `now` must not re-fire broken.
|
||||
s.TickOnce(context.Background())
|
||||
if fired["broken"] != 1 {
|
||||
t.Errorf("broken refired after persist; got %d (want 1)", fired["broken"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestTickOnce_PersistsLastFiredBeforeDispatch(t *testing.T) {
|
||||
// Documented behavior: last_fired_at is persisted before the
|
||||
// dispatcher runs so a panicking match cannot wedge a tight loop.
|
||||
st := newTestStore(t)
|
||||
now := time.Date(2026, 5, 16, 12, 0, 0, 0, time.UTC)
|
||||
trg := seedScheduleTrigger(t, st, "tick", "1h", "")
|
||||
|
||||
dispatched := false
|
||||
s := New(st, func(_ context.Context, t store.Trigger, _ plugin.InboundEvent) error {
|
||||
// At dispatch time the column must already be set.
|
||||
row, err := st.GetTriggerByID(t.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dispatched = row.LastFiredAt != ""
|
||||
return nil
|
||||
}, 0)
|
||||
s.clock = func() time.Time { return now }
|
||||
|
||||
s.TickOnce(context.Background())
|
||||
|
||||
if !dispatched {
|
||||
t.Fatalf("last_fired_at must be persisted before dispatcher runs")
|
||||
}
|
||||
row, err := st.GetTriggerByID(trg.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
if row.LastFiredAt != now.Format(time.RFC3339) {
|
||||
t.Errorf("last_fired_at = %q, want %q", row.LastFiredAt, now.Format(time.RFC3339))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLifecycle_StartStopIdempotent(t *testing.T) {
|
||||
// Start + Stop are wrapped in sync.Once. A second call must be a
|
||||
// no-op (no panic on double-cancel, no goroutine leak from double-
|
||||
// Start). This guards the shutdown path that runs Stop from both
|
||||
// defer and the signal-handler block in cmd/server/main.go.
|
||||
st := newTestStore(t)
|
||||
noop := func(context.Context, store.Trigger, plugin.InboundEvent) error { return nil }
|
||||
s := New(st, noop, 100*time.Millisecond)
|
||||
|
||||
s.Start(context.Background())
|
||||
s.Start(context.Background()) // second call: no goroutine spawned
|
||||
|
||||
s.Stop()
|
||||
s.Stop() // second call: no panic on closing already-cancelled context
|
||||
}
|
||||
|
||||
func TestNew_ClampsInterval(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
noop := func(context.Context, store.Trigger, plugin.InboundEvent) error { return nil }
|
||||
if got := New(st, noop, 0).tickInterval; got != 30*time.Second {
|
||||
t.Errorf("default = %s, want 30s", got)
|
||||
}
|
||||
if got := New(st, noop, 1*time.Hour).tickInterval; got != 5*time.Minute {
|
||||
t.Errorf("clamped = %s, want 5m", got)
|
||||
}
|
||||
if got := New(st, noop, 2*time.Minute).tickInterval; got != 2*time.Minute {
|
||||
t.Errorf("passthrough = %s, want 2m", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user