| index 2c6d6ba4c..5f64f4e28 100644 |
| --- a/internal/schedule/converge_test.go |
| +++ b/internal/schedule/converge_test.go |
| @@ -349,6 +349,145 @@ func TestConvergePassIsSafeUnderConcurrentCallers(t *testing.T) { |
| } |
| } |
| |
| +// The declaration object itself can go away (not just move to an empty |
| +// schedules list — that is TestConvergeHandlesPresentButEmptyDeclaration |
| +// below) and later reappear: e.g. hand-deleted from the bucket, or an app |
| +// re-created after deletion (spec §4.1: absence is legal forever). Its |
| +// absence must clear a host's rows exactly like an empty declaration does, |
| +// and the cached "" etag from the absent read must not be mistaken for a |
| +// match against whatever real etag the object comes back with. |
| +func TestConvergeDeclarationGoingAbsentThenPresentAgain(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: want one row, got %+v", rows) |
| + } |
| + |
| + if err := st.Delete(ctx, DeclarationKey("bab", "app")); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := c.Pass(ctx); err != nil { |
| + t.Fatal(err) |
| + } |
| + if rows, _ := reg.TimersFor("a.bab.oncarlos.com"); len(rows) != 0 { |
| + t.Fatalf("row survived the declaration going absent: %+v", rows) |
| + } |
| + |
| + UpsertSchedule(ctx, st, "bab", "app", Schedule{Name: "sync", Every: "6h", Path: "/jobs/sync"}, now) |
| + if err := c.Pass(ctx); err != nil { |
| + t.Fatal(err) |
| + } |
| + if rows, _ := reg.TimersFor("a.bab.oncarlos.com"); len(rows) != 1 { |
| + t.Fatalf("row not re-materialised once the declaration came back: %+v", rows) |
| + } |
| +} |
| + |
| +// RemoveSchedule rewrites schedules.json with an empty Schedules slice; it |
| +// never deletes the key (spec §4.1's "absence is legal forever" is about a |
| +// key that was never written, not one a member emptied out). A |
| +// present-but-empty declaration must prune existing rows exactly like an |
| +// absent one, without erroring on the zero-length Schedules slice. |
| +func TestConvergeHandlesPresentButEmptyDeclaration(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: want one row, got %+v", rows) |
| + } |
| + |
| + if _, present, err := RemoveSchedule(ctx, st, "bab", "app", "sync", now); err != nil || !present { |
| + t.Fatalf("remove: present=%v err=%v", present, err) |
| + } |
| + if _, etag, err := ReadDeclaration(ctx, st, "bab", "app"); err != nil || etag == "" { |
| + t.Fatalf("declaration should still be present (non-empty etag) after removing its only schedule: %q %v", etag, err) |
| + } |
| + |
| + if err := c.Pass(ctx); err != nil { |
| + t.Fatal(err) |
| + } |
| + if rows, _ := reg.TimersFor("a.bab.oncarlos.com"); len(rows) != 0 { |
| + t.Fatalf("row survived a present-but-empty declaration: %+v", rows) |
| + } |
| +} |
| + |
| +// The run signal object can itself go away between passes (no deletion API |
| +// is designed for it, but nothing stops a hand-edited bucket). Its absence |
| +// must read as "no movement" — not as a fire, and not as a poisoned cache |
| +// that can no longer recognise a later, genuinely new touch. |
| +func TestConvergeRunSignalGoingAbsentIsNotAFire(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 { // seed |
| + t.Fatal(err) |
| + } |
| + TouchRun(ctx, st, "bab", "app", "sync", now) |
| + if err := c.Pass(ctx); err != nil { |
| + t.Fatal(err) |
| + } |
| + reg.MarkTimerFired("a.bab.oncarlos.com", "sync", now.Unix(), "204", 1, 0, 777, 777) |
| + if tm, _ := reg.GetTimer("a.bab.oncarlos.com", "sync"); tm.NextAt != 777 { |
| + t.Fatalf("setup: run signal did not fire: %+v", tm) |
| + } |
| + |
| + if err := st.Delete(ctx, RunKey("bab", "app")); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := c.Pass(ctx); err != nil { |
| + t.Fatal(err) |
| + } |
| + if tm, _ := reg.GetTimer("a.bab.oncarlos.com", "sync"); tm.NextAt != 777 { |
| + t.Fatalf("run signal going absent misfired: %+v", tm) |
| + } |
| + |
| + // A later, genuinely new touch must still be recognised. |
| + TouchRun(ctx, st, "bab", "app", "sync", now) |
| + if err := c.Pass(ctx); err != nil { |
| + t.Fatal(err) |
| + } |
| + if tm, _ := reg.GetTimer("a.bab.oncarlos.com", "sync"); tm.NextAt != now.Unix() { |
| + t.Fatalf("run signal did not fire once the object came back: %+v", tm) |
| + } |
| +} |
| + |
| +// A registry with zero routes at all (nothing adopted yet, or every route |
| +// gone) must not be distinguished from "an app with no instance routes": |
| +// byApp never gains an entry, convergeApp is never called, and Pass must |
| +// still return cleanly rather than erroring on an empty registry. |
| +func TestConvergeZeroRouteRegistryIsANoop(t *testing.T) { |
| + reg, err := registry.Open(filepath.Join(t.TempDir(), "r.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { reg.Close() }) |
| + st := release.NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + now := at("2026-08-23T13:07:00Z") |
| + // A declaration exists for an app the registry has never heard of. |
| + 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.Fatalf("Pass on a route-less registry: %v", err) |
| + } |
| +} |
| + |
| // A declaration whose cron never matches (written past Validate — an older |
| // console, or by hand) must not become a row due at the zero time's unix |
| // value: it is parked at next_at=0 with last_status "never fires", and the |