registry: timers table for scheduled work
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
3 files changed,
+300
−0
internal/registry/registry.go+17 −0internal/registry/timers.go+159 −0internal/registry/timers_test.go+124 −0
diff --git a/internal/registry/registry.go b/internal/registry/registry.go| index 08ac9554b..1796467ae 100644 |
| --- a/internal/registry/registry.go |
| +++ b/internal/registry/registry.go |
| @@ -418,6 +418,23 @@ CREATE TABLE IF NOT EXISTS wildcard_domains ( |
| );`); err != nil { |
| return err |
| } |
| + if _, err := r.sql.Exec(`CREATE TABLE IF NOT EXISTS timers ( |
| + host TEXT NOT NULL, |
| + name TEXT NOT NULL, |
| + kind TEXT NOT NULL, |
| + spec TEXT NOT NULL, |
| + path TEXT NOT NULL, |
| + next_at INTEGER NOT NULL, |
| + last_at INTEGER NOT NULL DEFAULT 0, |
| + last_status TEXT NOT NULL DEFAULT '', |
| + last_ms INTEGER NOT NULL DEFAULT 0, |
| + attempts INTEGER NOT NULL DEFAULT 0, |
| + created_at INTEGER NOT NULL, |
| + PRIMARY KEY (host, name) |
| +); |
| +CREATE INDEX IF NOT EXISTS timers_due ON timers (next_at) WHERE next_at > 0;`); err != nil { |
| + return err |
| + } |
| if err := r.migrateAccounts(); err != nil { |
| return err |
| } |
diff --git a/internal/registry/timers.go b/internal/registry/timers.go| new file mode 100644 |
| index 000000000..d1641457d |
| --- /dev/null |
| +++ b/internal/registry/timers.go |
| @@ -0,0 +1,159 @@ |
| +// The timers table: scheduled work materialized per (host, name) — spec |
| +// docs/superpowers/specs/2026-08-23-scheduled-work-design.md §5. Rows come |
| +// from two writers with disjoint columns: converge (schedule.Converge) and |
| +// the control socket own spec/path/kind; the runner owns next_at after the |
| +// first fire, last_*, attempts. UpsertTimer encodes that split so neither |
| +// writer can clobber the other. Deliberately NOT in Fingerprint(): a timer |
| +// changes nothing about routing. |
| +package registry |
| + |
| +import ( |
| + "database/sql" |
| + "errors" |
| + "os" |
| + "strings" |
| +) |
| + |
| +type Timer struct { |
| + Host, Name, Kind, Spec, Path string |
| + NextAt, LastAt int64 |
| + LastStatus string |
| + LastMs int64 |
| + Attempts int |
| + CreatedAt int64 |
| +} |
| + |
| +var ErrTimerKind = errors.New("registry: timer exists with a different kind") |
| + |
| +const timerCols = `host, name, kind, spec, path, next_at, last_at, last_status, last_ms, attempts, created_at` |
| + |
| +func scanTimer(row interface{ Scan(...any) error }) (Timer, error) { |
| + var t Timer |
| + err := row.Scan(&t.Host, &t.Name, &t.Kind, &t.Spec, &t.Path, &t.NextAt, &t.LastAt, &t.LastStatus, &t.LastMs, &t.Attempts, &t.CreatedAt) |
| + return t, err |
| +} |
| + |
| +// UpsertTimer inserts t, or updates an existing row's spec/path — moving |
| +// next_at only when spec or path changed or t.NextAt is non-zero — and |
| +// never touches the runner's columns on update. |
| +func (r *Registry) UpsertTimer(t Timer) error { |
| + existing, err := r.GetTimer(t.Host, t.Name) |
| + switch { |
| + case errors.Is(err, os.ErrNotExist): |
| + _, err := r.sql.Exec(`INSERT INTO timers (`+timerCols+`) VALUES (?,?,?,?,?,?,?,?,?,?,?)`, |
| + t.Host, t.Name, t.Kind, t.Spec, t.Path, t.NextAt, t.LastAt, t.LastStatus, t.LastMs, t.Attempts, t.CreatedAt) |
| + return err |
| + case err != nil: |
| + return err |
| + } |
| + if existing.Kind != t.Kind { |
| + return ErrTimerKind |
| + } |
| + next := existing.NextAt |
| + if existing.Spec != t.Spec || existing.Path != t.Path || t.NextAt != 0 { |
| + next = t.NextAt |
| + } |
| + _, err = r.sql.Exec(`UPDATE timers SET spec=?, path=?, next_at=? WHERE host=? AND name=?`, t.Spec, t.Path, next, t.Host, t.Name) |
| + return err |
| +} |
| + |
| +func (r *Registry) GetTimer(host, name string) (*Timer, error) { |
| + t, err := scanTimer(r.sql.QueryRow(`SELECT `+timerCols+` FROM timers WHERE host=? AND name=?`, host, name)) |
| + if errors.Is(err, sql.ErrNoRows) { |
| + return nil, os.ErrNotExist |
| + } |
| + if err != nil { |
| + return nil, err |
| + } |
| + return &t, nil |
| +} |
| + |
| +func (r *Registry) DeleteTimer(host, name string) error { |
| + _, err := r.sql.Exec(`DELETE FROM timers WHERE host=? AND name=?`, host, name) |
| + return err |
| +} |
| + |
| +func (r *Registry) queryTimers(q string, args ...any) ([]Timer, error) { |
| + rows, err := r.sql.Query(q, args...) |
| + if err != nil { |
| + return nil, err |
| + } |
| + defer rows.Close() |
| + var out []Timer |
| + for rows.Next() { |
| + t, err := scanTimer(rows) |
| + if err != nil { |
| + return nil, err |
| + } |
| + out = append(out, t) |
| + } |
| + return out, rows.Err() |
| +} |
| + |
| +func (r *Registry) TimersFor(host string) ([]Timer, error) { |
| + return r.queryTimers(`SELECT `+timerCols+` FROM timers WHERE host=? ORDER BY name`, host) |
| +} |
| + |
| +func (r *Registry) DueTimers(now int64, limit int) ([]Timer, error) { |
| + return r.queryTimers(`SELECT `+timerCols+` FROM timers WHERE next_at > 0 AND next_at <= ? ORDER BY next_at, host, name LIMIT ?`, now, limit) |
| +} |
| + |
| +func (r *Registry) FireTimerNow(host, name string, now int64) error { |
| + _, err := r.sql.Exec(`UPDATE timers SET next_at=? WHERE host=? AND name=?`, now, host, name) |
| + return err |
| +} |
| + |
| +func (r *Registry) MarkTimerFired(host, name string, at int64, status string, ms int64, attempts int, next int64) error { |
| + _, err := r.sql.Exec(`UPDATE timers SET last_at=?, last_status=?, last_ms=?, attempts=?, next_at=? WHERE host=? AND name=?`, |
| + at, status, ms, attempts, next, host, name) |
| + return err |
| +} |
| + |
| +func (r *Registry) CountTimers(host, kind string) (int, error) { |
| + var n int |
| + err := r.sql.QueryRow(`SELECT COUNT(*) FROM timers WHERE host=? AND kind=?`, host, kind).Scan(&n) |
| + return n, err |
| +} |
| + |
| +// PruneTimers: rows for hosts no longer routed here, and finished once-rows |
| +// older than doneBefore. An empty alive set deletes every row — callers pass |
| +// the registry's own host list, so that only happens on an empty registry. |
| +func (r *Registry) PruneTimers(alive map[string]bool, doneBefore int64) (int, error) { |
| + hosts := make([]any, 0, len(alive)) |
| + for h := range alive { |
| + hosts = append(hosts, h) |
| + } |
| + q := `DELETE FROM timers WHERE (kind='once' AND next_at=0 AND last_at < ?)` |
| + args := []any{doneBefore} |
| + if len(hosts) == 0 { |
| + q += ` OR 1=1` |
| + } else { |
| + q += ` OR host NOT IN (?` + strings.Repeat(",?", len(hosts)-1) + `)` |
| + args = append(args, hosts...) |
| + } |
| + res, err := r.sql.Exec(q, args...) |
| + if err != nil { |
| + return 0, err |
| + } |
| + n, _ := res.RowsAffected() |
| + return int(n), nil |
| +} |
| + |
| +func (r *Registry) DeleteTimersExcept(host, kind string, keep map[string]bool) (int, error) { |
| + names := make([]any, 0, len(keep)) |
| + for n := range keep { |
| + names = append(names, n) |
| + } |
| + q := `DELETE FROM timers WHERE host=? AND kind=?` |
| + args := []any{host, kind} |
| + if len(names) > 0 { |
| + q += ` AND name NOT IN (?` + strings.Repeat(",?", len(names)-1) + `)` |
| + args = append(args, names...) |
| + } |
| + res, err := r.sql.Exec(q, args...) |
| + if err != nil { |
| + return 0, err |
| + } |
| + n, _ := res.RowsAffected() |
| + return int(n), nil |
| +} |
diff --git a/internal/registry/timers_test.go b/internal/registry/timers_test.go| new file mode 100644 |
| index 000000000..15c098540 |
| --- /dev/null |
| +++ b/internal/registry/timers_test.go |
| @@ -0,0 +1,124 @@ |
| +package registry |
| + |
| +import ( |
| + "errors" |
| + "os" |
| + "path/filepath" |
| + "testing" |
| +) |
| + |
| +func openTimersReg(t *testing.T) *Registry { |
| + t.Helper() |
| + r, err := Open(filepath.Join(t.TempDir(), "registry.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { r.Close() }) |
| + return r |
| +} |
| + |
| +func TestTimersUpsertPreservesRunnerFields(t *testing.T) { |
| + r := openTimersReg(t) |
| + if err := r.UpsertTimer(Timer{Host: "h", Name: "sync", Kind: "schedule", Spec: "every 6h0m0s", Path: "/jobs/sync", NextAt: 100, CreatedAt: 1}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := r.MarkTimerFired("h", "sync", 100, "204", 300, 0, 21700); err != nil { |
| + t.Fatal(err) |
| + } |
| + // Converge sees the same declaration again: spec/path unchanged, next_at |
| + // passed along as "whatever it is now" (0 here means "don't touch"). |
| + if err := r.UpsertTimer(Timer{Host: "h", Name: "sync", Kind: "schedule", Spec: "every 6h0m0s", Path: "/jobs/sync", NextAt: 0, CreatedAt: 99}); err != nil { |
| + t.Fatal(err) |
| + } |
| + got, err := r.GetTimer("h", "sync") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if got.LastAt != 100 || got.LastStatus != "204" || got.LastMs != 300 || got.NextAt != 21700 || got.CreatedAt != 1 { |
| + t.Fatalf("runner fields clobbered: %+v", got) |
| + } |
| + // A changed spec DOES move next_at. |
| + if err := r.UpsertTimer(Timer{Host: "h", Name: "sync", Kind: "schedule", Spec: "every 12h0m0s", Path: "/jobs/sync", NextAt: 43200}); err != nil { |
| + t.Fatal(err) |
| + } |
| + got, _ = r.GetTimer("h", "sync") |
| + if got.NextAt != 43200 || got.Spec != "every 12h0m0s" || got.LastStatus != "204" { |
| + t.Fatalf("after spec change: %+v", got) |
| + } |
| + if err := r.UpsertTimer(Timer{Host: "h", Name: "sync", Kind: "once", Spec: "at 5", Path: "/p", NextAt: 5}); !errors.Is(err, ErrTimerKind) { |
| + t.Fatalf("kind change = %v, want ErrTimerKind", err) |
| + } |
| +} |
| + |
| +func TestTimersDueOrderingAndFireNow(t *testing.T) { |
| + r := openTimersReg(t) |
| + for _, tm := range []Timer{ |
| + {Host: "b", Name: "late", Kind: "schedule", Spec: "every 1h0m0s", Path: "/p", NextAt: 500}, |
| + {Host: "a", Name: "soon", Kind: "once", Spec: "at 10", Path: "/p", NextAt: 10}, |
| + {Host: "a", Name: "done", Kind: "once", Spec: "at 1", Path: "/p", NextAt: 0}, |
| + {Host: "c", Name: "future", Kind: "schedule", Spec: "every 1h0m0s", Path: "/p", NextAt: 9999}, |
| + } { |
| + if err := r.UpsertTimer(tm); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + due, err := r.DueTimers(600, 10) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(due) != 2 || due[0].Name != "soon" || due[1].Name != "late" { |
| + t.Fatalf("due = %+v", due) |
| + } |
| + if due, _ := r.DueTimers(600, 1); len(due) != 1 { |
| + t.Fatal("limit ignored") |
| + } |
| + if err := r.FireTimerNow("c", "future", 600); err != nil { |
| + t.Fatal(err) |
| + } |
| + if due, _ := r.DueTimers(600, 10); len(due) != 3 { |
| + t.Fatalf("after FireTimerNow: %d due", len(due)) |
| + } |
| + if err := r.FireTimerNow("nope", "x", 600); err != nil { |
| + t.Fatalf("FireTimerNow on absent = %v, want nil", err) |
| + } |
| +} |
| + |
| +func TestTimersPruneAndExcept(t *testing.T) { |
| + r := openTimersReg(t) |
| + for _, tm := range []Timer{ |
| + {Host: "alive", Name: "s1", Kind: "schedule", Spec: "every 1h0m0s", Path: "/p", NextAt: 1}, |
| + {Host: "alive", Name: "s2", Kind: "schedule", Spec: "every 1h0m0s", Path: "/p", NextAt: 1}, |
| + {Host: "alive", Name: "o1", Kind: "once", Spec: "at 1", Path: "/p", NextAt: 0, LastAt: 10}, |
| + {Host: "alive", Name: "o2", Kind: "once", Spec: "at 1", Path: "/p", NextAt: 0, LastAt: 500}, |
| + {Host: "gone", Name: "s1", Kind: "schedule", Spec: "every 1h0m0s", Path: "/p", NextAt: 1}, |
| + } { |
| + if err := r.UpsertTimer(tm); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + // o1 has last_at=10 via insert — MarkTimerFired is the real path but insert suffices to seed. |
| + n, err := r.PruneTimers(map[string]bool{"alive": true}, 100) |
| + if err != nil || n != 2 { // gone/s1 and alive/o1 |
| + t.Fatalf("prune = %d %v", n, err) |
| + } |
| + n, err = r.DeleteTimersExcept("alive", "schedule", map[string]bool{"s2": true}) |
| + if err != nil || n != 1 { |
| + t.Fatalf("except = %d %v", n, err) |
| + } |
| + left, _ := r.TimersFor("alive") |
| + if len(left) != 2 || left[0].Name != "o2" || left[1].Name != "s2" { |
| + t.Fatalf("left = %+v", left) |
| + } |
| + if c, _ := r.CountTimers("alive", "once"); c != 1 { |
| + t.Fatalf("count = %d", c) |
| + } |
| + if _, err := r.GetTimer("gone", "s1"); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatalf("gone = %v", err) |
| + } |
| + if err := r.DeleteTimer("alive", "s2"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := r.DeleteTimer("alive", "s2"); err != nil { |
| + t.Fatal("delete not idempotent") |
| + } |
| +} |