carlos / platform Public

schedule: converge — kind-aware baseline, drop rows on unit flip

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev a8636a03927fecfa5d3b07af199d8ed60f7cf38b parent 2f23ed8
2 files changed, +114 −6
  • internal/schedule/converge.go +33 −6
  • internal/schedule/converge_test.go +81 −0
diff --git a/internal/schedule/converge.go b/internal/schedule/converge.go
index dea12c171..803c9102b 100644
--- a/internal/schedule/converge.go
+++ b/internal/schedule/converge.go
@@ -146,9 +146,21 @@ func (c *Converger) convergeApp(ctx context.Context, acct, app string, rts []reg
// the host is unsupported and should be reported so).
func (c *Converger) convergeHost(rt registry.Route, decl Declaration, fireName string, now time.Time) (bool, error) {
if rt.Backing == registry.BackingUnit {
+ // A host can flip exec -> unit mid-lifetime (the app-capabilities
+ // migration door, registry.SetBacking): drop any schedule rows it
+ // still carries from before the flip, so "no rows" stays true for
+ // every unit-backed host, not just ones that were always unit-backed.
+ n, err := c.Reg.DeleteTimersExcept(rt.Host, KindSchedule, map[string]bool{})
+ if err != nil {
+ return false, err
+ }
+ changed := n > 0
// Reported once per host, and only when there is something to warn
// about — an undeclared app on a unit-backed host is silent.
- return len(decl.Schedules) > 0 && c.unsupportedUnreported(rt.Host), nil
+ if len(decl.Schedules) > 0 && c.unsupportedUnreported(rt.Host) {
+ changed = true
+ }
+ return changed, nil
}
before, err := c.Reg.TimersFor(rt.Host)
if err != nil {
@@ -163,20 +175,29 @@ func (c *Converger) convergeHost(rt registry.Route, decl Declaration, fireName s
continue
}
keep[sc.Name] = true
+ // prev is the ONLY-schedule-kind baseline: a same-named once-row
+ // (the instance's own, off its control socket) is never "previous
+ // state" for this comparison — a kind collision decides itself
+ // below, at the write, not here.
+ prev := findTimer(before, sc.Name, KindSchedule)
+ wouldChange := prev == nil || prev.Spec != sp.String() || prev.Path != sc.Path
var next int64
- prev := findTimer(before, sc.Name)
- if prev == nil || prev.Spec != sp.String() || prev.Path != sc.Path {
+ if wouldChange {
next = sp.Next(now).Unix()
- changed = true
}
err = c.Reg.UpsertTimer(registry.Timer{Host: rt.Host, Name: sc.Name, Kind: KindSchedule, Spec: sp.String(), Path: sc.Path, NextAt: next, CreatedAt: now.Unix()})
if errors.Is(err, registry.ErrTimerKind) {
+ // Nothing was written — a collision must not pin `changed`,
+ // or a permanently-colliding name reports every single pass.
log.Printf("schedule: %s: %q is a once-timer the instance registered; the declared schedule of that name is skipped", rt.Host, sc.Name)
continue
}
if err != nil {
return changed, err
}
+ if wouldChange {
+ changed = true
+ }
}
n, err := c.Reg.DeleteTimersExcept(rt.Host, KindSchedule, keep)
if err != nil {
@@ -208,9 +229,15 @@ func (c *Converger) unsupportedUnreported(host string) bool {
return true
}
-func findTimer(rows []registry.Timer, name string) *registry.Timer {
+// findTimer returns the row named name with the given kind, or nil. Kind
+// matters: a once-row and a declared schedule can share a name (the
+// once-row is the instance's own, registered off its control socket), and
+// only a same-kind row is ever "previous state" for the change comparison
+// below — a kind collision is a skip (ErrTimerKind, at the UpsertTimer
+// call), never a manufactured "changed".
+func findTimer(rows []registry.Timer, name, kind string) *registry.Timer {
for i := range rows {
- if rows[i].Name == name {
+ if rows[i].Name == name && rows[i].Kind == kind {
return &rows[i]
}
}
diff --git a/internal/schedule/converge_test.go b/internal/schedule/converge_test.go
index 305507c27..321d047f7 100644
--- a/internal/schedule/converge_test.go
+++ b/internal/schedule/converge_test.go
@@ -152,3 +152,84 @@ func TestConvergePrunesRemovedHosts(t *testing.T) {
t.Fatal("orphan rows survived")
}
}
+
+// TestConvergeNameCollisionWithOnceRowSkippedSilently pins the fix for a
+// found bug: a declared schedule sharing a name with an existing once-row
+// (the instance's own, registered off its control socket) must never be
+// treated as "previous state" for the changed comparison — the once-row's
+// spec text ("at ...") always differs from the declared schedule's, and
+// comparing against it pinned `changed` (and hence a Report) on every
+// single pass forever, never converging quiet even though nothing was
+// ever actually written.
+func TestConvergeNameCollisionWithOnceRowSkippedSilently(t *testing.T) {
+ reg, st := convergeFixture(t)
+ ctx := context.Background()
+ now := at("2026-08-23T13:07:00Z")
+ if err := reg.UpsertTimer(registry.Timer{Host: "a.bab.oncarlos.com", Name: "sync", Kind: KindOnce, Spec: "at 99999999999", Path: "/r", NextAt: 99999999999}); err != nil {
+ t.Fatal(err)
+ }
+ UpsertSchedule(ctx, st, "bab", "app", Schedule{Name: "sync", Every: "6h", Path: "/jobs/sync"}, now)
+ var reports []Status
+ c := NewConverger(reg, st, "box1", func(_ registry.Route, s Status) { reports = append(reports, s) })
+ c.Now = func() time.Time { return now }
+ if err := c.Pass(ctx); err != nil {
+ t.Fatal(err)
+ }
+ if rows, _ := reg.TimersFor("a.bab.oncarlos.com"); len(rows) != 1 || rows[0].Kind != KindOnce || rows[0].NextAt != 99999999999 {
+ t.Fatalf("once row not left untouched / a schedule row was created: %+v", rows)
+ }
+
+ reports = nil
+ if err := c.Pass(ctx); err != nil {
+ t.Fatal(err)
+ }
+ if rows, _ := reg.TimersFor("a.bab.oncarlos.com"); len(rows) != 1 || rows[0].Kind != KindOnce {
+ t.Fatalf("once row disturbed on the second pass: %+v", rows)
+ }
+ for _, r := range reports {
+ if r.Host == "a.bab.oncarlos.com" {
+ t.Fatalf("second pass reported the colliding host: %+v", r)
+ }
+ }
+}
+
+// TestConvergeDropsScheduleRowsOnUnitFlip pins the fix for a found bug: the
+// unit-backed early return skipped TimersFor/DeleteTimersExcept entirely,
+// so a host that flips from exec-backed to unit-backed (registry.SetBacking
+// — the app-capabilities migration door) kept its old schedule rows
+// forever, violating "a unit-backed host has no rows".
+func TestConvergeDropsScheduleRowsOnUnitFlip(t *testing.T) {
+ reg, st := convergeFixture(t)
+ ctx := context.Background()
+ now := at("2026-08-23T13:07:00Z")
+ UpsertSchedule(ctx, st, "bab", "app", Schedule{Name: "sync", Every: "6h", Path: "/jobs/sync"}, now)
+ c := NewConverger(reg, st, "box1", nil)
+ c.Now = func() time.Time { return now }
+ if err := c.Pass(ctx); err != nil {
+ t.Fatal(err)
+ }
+ if rows, _ := reg.TimersFor("a.bab.oncarlos.com"); len(rows) != 1 {
+ t.Fatalf("setup: expected one schedule row before the flip, got %+v", rows)
+ }
+
+ if err := reg.SetBacking("a.bab.oncarlos.com", registry.BackingUnit); err != nil {
+ t.Fatal(err)
+ }
+ var reports []Status
+ c.Report = func(_ registry.Route, s Status) { reports = append(reports, s) }
+ if err := c.Pass(ctx); err != nil {
+ t.Fatal(err)
+ }
+ if rows, _ := reg.TimersFor("a.bab.oncarlos.com"); len(rows) != 0 {
+ t.Fatalf("schedule rows survived a flip to unit-backed: %+v", rows)
+ }
+ var got *Status
+ for i := range reports {
+ if reports[i].Host == "a.bab.oncarlos.com" {
+ got = &reports[i]
+ }
+ }
+ if got == nil || got.Unsupported == "" {
+ t.Fatalf("no unsupported report for the flipped host: %+v", reports)
+ }
+}