| 1 | package schedule |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "fmt" |
| 7 | "net/http" |
| 8 | "net/http/httptest" |
| 9 | "os" |
| 10 | "path/filepath" |
| 11 | "strings" |
| 12 | "sync" |
| 13 | "testing" |
| 14 | "time" |
| 15 | |
| 16 | "github.com/carlosframework/platform/internal/registry" |
| 17 | "github.com/carlosframework/platform/internal/release" |
| 18 | ) |
| 19 | |
| 20 | type fakeDeliverer struct { |
| 21 | mu sync.Mutex |
| 22 | status int |
| 23 | err error |
| 24 | calls []*http.Request |
| 25 | hosts []string |
| 26 | } |
| 27 | |
| 28 | func (f *fakeDeliverer) Deliver(_ context.Context, rt registry.Route, req *http.Request) (int, error) { |
| 29 | f.mu.Lock() |
| 30 | defer f.mu.Unlock() |
| 31 | f.calls = append(f.calls, req) |
| 32 | f.hosts = append(f.hosts, rt.Host) |
| 33 | return f.status, f.err |
| 34 | } |
| 35 | |
| 36 | // testClock is the Now seam for the tests that advance time WHILE a delivery |
| 37 | // goroutine is running: the plain `now := …; r.Now = func() …` shape those |
| 38 | // tests would otherwise use is a data race under -race, and the runner reads |
| 39 | // its clock from inside every host goroutine. |
| 40 | type testClock struct { |
| 41 | mu sync.Mutex |
| 42 | t time.Time |
| 43 | } |
| 44 | |
| 45 | func (c *testClock) now() time.Time { |
| 46 | c.mu.Lock() |
| 47 | defer c.mu.Unlock() |
| 48 | return c.t |
| 49 | } |
| 50 | |
| 51 | func (c *testClock) advance(d time.Duration) { |
| 52 | c.mu.Lock() |
| 53 | defer c.mu.Unlock() |
| 54 | c.t = c.t.Add(d) |
| 55 | } |
| 56 | |
| 57 | // firedHosts counts deliveries per host — the counting assertion the |
| 58 | // parallelism and exactly-once tests need. |
| 59 | func (f *fakeDeliverer) firedHosts() map[string]int { |
| 60 | f.mu.Lock() |
| 61 | defer f.mu.Unlock() |
| 62 | n := map[string]int{} |
| 63 | for _, h := range f.hosts { |
| 64 | n[h]++ |
| 65 | } |
| 66 | return n |
| 67 | } |
| 68 | |
| 69 | func runnerFixture(t *testing.T, d Deliverer) (*registry.Registry, *Runner) { |
| 70 | t.Helper() |
| 71 | reg, err := registry.Open(filepath.Join(t.TempDir(), "r.db")) |
| 72 | if err != nil { |
| 73 | t.Fatal(err) |
| 74 | } |
| 75 | t.Cleanup(func() { reg.Close() }) |
| 76 | if err := reg.Upsert(registry.Route{Host: "a.bab.oncarlos.com", Kind: "instance", Account: "bab", App: "app", Status: "ready", Hibernate: true}); err != nil { |
| 77 | t.Fatal(err) |
| 78 | } |
| 79 | r := &Runner{Reg: reg, Deliver: d, Timeout: time.Minute, Box: "box1", Build: "test", |
| 80 | Token: func(string) (string, error) { return "tok", nil }} |
| 81 | return reg, r |
| 82 | } |
| 83 | |
| 84 | func TestClassify(t *testing.T) { |
| 85 | cases := []struct { |
| 86 | status int |
| 87 | err error |
| 88 | want Outcome |
| 89 | }{ |
| 90 | {204, nil, Outcome{Status: "204", Retry: false}}, |
| 91 | {200, nil, Outcome{Status: "200", Retry: false}}, |
| 92 | {404, nil, Outcome{Status: "404 (app refused)", Retry: false}}, |
| 93 | {403, nil, Outcome{Status: "403 (app refused)", Retry: false}}, |
| 94 | {302, nil, Outcome{Status: "302 (app refused)", Retry: false}}, |
| 95 | {500, nil, Outcome{Status: "500", Retry: true}}, |
| 96 | {503, nil, Outcome{Status: "503", Retry: true}}, |
| 97 | {429, nil, Outcome{Status: "429", Retry: true}}, |
| 98 | {408, nil, Outcome{Status: "408", Retry: true}}, |
| 99 | {0, errors.New("instance failed to wake"), Outcome{Status: "503: instance failed to wake", Retry: true}}, |
| 100 | {0, ErrTimeout, Outcome{Status: "timeout after 1m0s", Retry: false}}, |
| 101 | } |
| 102 | for _, c := range cases { |
| 103 | if got := classify(c.status, c.err, time.Minute); got != c.want { |
| 104 | t.Errorf("classify(%d, %v) = %+v, want %+v", c.status, c.err, got, c.want) |
| 105 | } |
| 106 | } |
| 107 | } |
| 108 | |
| 109 | func TestBuildRequestCarriesTheContract(t *testing.T) { |
| 110 | rt := registry.Route{Host: "a.bab.oncarlos.com"} |
| 111 | tm := registry.Timer{Name: "sync", Kind: KindSchedule, Path: "/jobs/sync?x=1", NextAt: 1787004060, DueAt: 1787004000} |
| 112 | req, err := BuildRequest(rt, tm, "tok", "abc") |
| 113 | if err != nil { |
| 114 | t.Fatal(err) |
| 115 | } |
| 116 | if req.Method != http.MethodPost || req.Host != rt.Host || req.URL.Path != "/jobs/sync" || req.URL.RawQuery != "x=1" { |
| 117 | t.Fatalf("req = %s %s %s", req.Method, req.Host, req.URL) |
| 118 | } |
| 119 | h := req.Header |
| 120 | if h.Get("Authorization") != "Bearer tok" || h.Get("X-Carlos-Schedule") != "sync" || h.Get("X-Carlos-Schedule-Kind") != "schedule" || |
| 121 | h.Get("X-Carlos-Schedule-At") != "1787004000" || h.Get("User-Agent") != "carlos-schedule/abc" || req.ContentLength != 0 { |
| 122 | t.Fatalf("headers = %v", h) |
| 123 | } |
| 124 | // A row from before due_at existed carries 0 there: the header falls |
| 125 | // back to next_at, which at attempt 0 is the occurrence. |
| 126 | tm.DueAt = 0 |
| 127 | if req, _ := BuildRequest(rt, tm, "tok", "abc"); req.Header.Get("X-Carlos-Schedule-At") != "1787004060" { |
| 128 | t.Fatalf("legacy row At = %q", req.Header.Get("X-Carlos-Schedule-At")) |
| 129 | } |
| 130 | // A path the parser refuses is an error, never a POST to "/". |
| 131 | tm.Path = "/jobs/%zz" |
| 132 | if _, err := BuildRequest(rt, tm, "tok", "abc"); err == nil { |
| 133 | t.Fatal("bad path built a request") |
| 134 | } |
| 135 | } |
| 136 | |
| 137 | func TestTickFiresDueAdvancesScheduleAndFinishesOnce(t *testing.T) { |
| 138 | d := &fakeDeliverer{status: 204} |
| 139 | reg, r := runnerFixture(t, d) |
| 140 | now := at("2026-08-23T13:07:00Z") |
| 141 | r.Now = func() time.Time { return now } |
| 142 | reg.UpsertTimer(registry.Timer{Host: "a.bab.oncarlos.com", Name: "sync", Kind: KindSchedule, Spec: "every 6h0m0s", Path: "/jobs/sync", NextAt: now.Unix() - 5}) |
| 143 | reg.UpsertTimer(registry.Timer{Host: "a.bab.oncarlos.com", Name: "remind", Kind: KindOnce, Spec: "at 1", Path: "/r", NextAt: 1}) |
| 144 | reg.UpsertTimer(registry.Timer{Host: "a.bab.oncarlos.com", Name: "later", Kind: KindSchedule, Spec: "every 1h0m0s", Path: "/l", NextAt: now.Unix() + 60}) |
| 145 | var events []string |
| 146 | r.Event = func(_ registry.Route, level, msg string) { events = append(events, level+": "+msg) } |
| 147 | var reports int |
| 148 | r.Report = func(registry.Route, Status) { reports++ } |
| 149 | r.tick() |
| 150 | if len(d.calls) != 2 { |
| 151 | t.Fatalf("delivered %d, want 2 (not the future one)", len(d.calls)) |
| 152 | } |
| 153 | sync, _ := reg.GetTimer("a.bab.oncarlos.com", "sync") |
| 154 | if sync.LastStatus != "204" || sync.LastAt != now.Unix() || sync.NextAt != at("2026-08-23T18:00:00Z").Unix() || sync.Attempts != 0 { |
| 155 | t.Fatalf("sync = %+v", sync) |
| 156 | } |
| 157 | once, _ := reg.GetTimer("a.bab.oncarlos.com", "remind") |
| 158 | if once.NextAt != 0 || once.LastStatus != "204" { |
| 159 | t.Fatalf("once = %+v", once) |
| 160 | } |
| 161 | if len(events) != 2 || events[0][:5] != "info:" { |
| 162 | t.Fatalf("events = %v", events) |
| 163 | } |
| 164 | if reports != 1 { // one report per host per tick, not per timer |
| 165 | t.Fatalf("reports = %d", reports) |
| 166 | } |
| 167 | } |
| 168 | |
| 169 | func TestTickRetriesWithBackoffThenGivesUp(t *testing.T) { |
| 170 | d := &fakeDeliverer{status: 503} |
| 171 | reg, r := runnerFixture(t, d) |
| 172 | now := at("2026-08-23T13:07:00Z") |
| 173 | r.Now = func() time.Time { return now } |
| 174 | reg.UpsertTimer(registry.Timer{Host: "a.bab.oncarlos.com", Name: "sync", Kind: KindSchedule, Spec: "every 6h0m0s", Path: "/jobs/sync", NextAt: now.Unix()}) |
| 175 | wantBackoff := []time.Duration{time.Minute, 2 * time.Minute, 4 * time.Minute, 8 * time.Minute} |
| 176 | for i, b := range wantBackoff { |
| 177 | r.tick() |
| 178 | tm, _ := reg.GetTimer("a.bab.oncarlos.com", "sync") |
| 179 | if tm.Attempts != i+1 || tm.NextAt != now.Add(b).Unix() || tm.LastStatus != "503" { |
| 180 | t.Fatalf("attempt %d: %+v (want next=+%s)", i+1, tm, b) |
| 181 | } |
| 182 | now = now.Add(b) |
| 183 | } |
| 184 | r.tick() // 5th failure: give up, advance to the natural next |
| 185 | tm, _ := reg.GetTimer("a.bab.oncarlos.com", "sync") |
| 186 | if tm.Attempts != 0 || tm.LastStatus != "failed after 5 attempts: 503" || tm.NextAt != at("2026-08-23T18:00:00Z").Unix() { |
| 187 | t.Fatalf("after giving up: %+v", tm) |
| 188 | } |
| 189 | } |
| 190 | |
| 191 | func TestBackoffNeverPassesTheNaturalNext(t *testing.T) { |
| 192 | d := &fakeDeliverer{status: 503} |
| 193 | reg, r := runnerFixture(t, d) |
| 194 | now := at("2026-08-23T13:59:30Z") // next natural 1m fire is 14:00:00, 30s away |
| 195 | r.Now = func() time.Time { return now } |
| 196 | reg.UpsertTimer(registry.Timer{Host: "a.bab.oncarlos.com", Name: "m", Kind: KindSchedule, Spec: "every 1m0s", Path: "/m", NextAt: now.Unix()}) |
| 197 | r.tick() |
| 198 | tm, _ := reg.GetTimer("a.bab.oncarlos.com", "m") |
| 199 | if tm.NextAt != at("2026-08-23T14:00:00Z").Unix() { |
| 200 | t.Fatalf("next = %d, want the natural next, not now+1m", tm.NextAt) |
| 201 | } |
| 202 | } |
| 203 | |
| 204 | func TestAppRefusalDoesNotRetry(t *testing.T) { |
| 205 | d := &fakeDeliverer{status: 404} |
| 206 | reg, r := runnerFixture(t, d) |
| 207 | now := at("2026-08-23T13:07:00Z") |
| 208 | r.Now = func() time.Time { return now } |
| 209 | reg.UpsertTimer(registry.Timer{Host: "a.bab.oncarlos.com", Name: "o", Kind: KindOnce, Spec: "at 1", Path: "/o", NextAt: 1}) |
| 210 | var levels []string |
| 211 | r.Event = func(_ registry.Route, level, _ string) { levels = append(levels, level) } |
| 212 | r.tick() |
| 213 | tm, _ := reg.GetTimer("a.bab.oncarlos.com", "o") |
| 214 | if tm.NextAt != 0 || tm.LastStatus != "404 (app refused)" || tm.Attempts != 0 { |
| 215 | t.Fatalf("%+v", tm) |
| 216 | } |
| 217 | if len(levels) != 1 || levels[0] != "warn" { |
| 218 | t.Fatalf("levels = %v", levels) |
| 219 | } |
| 220 | } |
| 221 | |
| 222 | func TestTimeoutCountsAsDelivered(t *testing.T) { |
| 223 | d := &fakeDeliverer{err: ErrTimeout} |
| 224 | reg, r := runnerFixture(t, d) |
| 225 | now := at("2026-08-23T13:07:00Z") |
| 226 | r.Now = func() time.Time { return now } |
| 227 | reg.UpsertTimer(registry.Timer{Host: "a.bab.oncarlos.com", Name: "s", Kind: KindSchedule, Spec: "every 6h0m0s", Path: "/s", NextAt: now.Unix()}) |
| 228 | r.tick() |
| 229 | tm, _ := reg.GetTimer("a.bab.oncarlos.com", "s") |
| 230 | if tm.Attempts != 0 || tm.LastStatus != "timeout after 1m0s" || tm.NextAt != at("2026-08-23T18:00:00Z").Unix() { |
| 231 | t.Fatalf("%+v", tm) |
| 232 | } |
| 233 | } |
| 234 | |
| 235 | func TestNoTokenIsRecordedNotDelivered(t *testing.T) { |
| 236 | d := &fakeDeliverer{status: 204} |
| 237 | reg, r := runnerFixture(t, d) |
| 238 | r.Token = func(string) (string, error) { return "", errors.New("no CARLOS_ADMIN_TOKEN") } |
| 239 | now := at("2026-08-23T13:07:00Z") |
| 240 | r.Now = func() time.Time { return now } |
| 241 | reg.UpsertTimer(registry.Timer{Host: "a.bab.oncarlos.com", Name: "s", Kind: KindSchedule, Spec: "every 6h0m0s", Path: "/s", NextAt: now.Unix()}) |
| 242 | r.tick() |
| 243 | if len(d.calls) != 0 { |
| 244 | t.Fatal("delivered without a token") |
| 245 | } |
| 246 | tm, _ := reg.GetTimer("a.bab.oncarlos.com", "s") |
| 247 | if tm.Attempts != 1 || tm.LastStatus != "no instance token: no CARLOS_ADMIN_TOKEN" { |
| 248 | t.Fatalf("%+v", tm) |
| 249 | } |
| 250 | } |
| 251 | |
| 252 | func TestTickSkipsRowsWhoseRouteIsGone(t *testing.T) { |
| 253 | d := &fakeDeliverer{status: 204} |
| 254 | reg, r := runnerFixture(t, d) |
| 255 | reg.UpsertTimer(registry.Timer{Host: "ghost.bab.oncarlos.com", Name: "s", Kind: KindSchedule, Spec: "every 6h0m0s", Path: "/s", NextAt: 1}) |
| 256 | r.tick() |
| 257 | if len(d.calls) != 0 { |
| 258 | t.Fatal("delivered to a host with no route") |
| 259 | } |
| 260 | } |
| 261 | |
| 262 | // X-Carlos-Schedule-At is the OCCURRENCE, the key an app dedupes on (spec |
| 263 | // §6.3, §7.1): a retry re-sends the same value, a fire that advances the row |
| 264 | // moves it to the new natural occurrence, and a `run` makes now the |
| 265 | // occurrence. next_at is none of those things after the first retry. |
| 266 | func TestScheduleAtHeaderIsTheOccurrenceNotNextAt(t *testing.T) { |
| 267 | d := &fakeDeliverer{status: 503} |
| 268 | reg, r := runnerFixture(t, d) |
| 269 | now := at("2026-08-23T13:07:00Z") |
| 270 | r.Now = func() time.Time { return now } |
| 271 | occurrence := now.Unix() - 5 |
| 272 | reg.UpsertTimer(registry.Timer{Host: "a.bab.oncarlos.com", Name: "sync", Kind: KindSchedule, Spec: "every 6h0m0s", Path: "/jobs/sync", NextAt: occurrence, DueAt: occurrence}) |
| 273 | atHeader := func(i int) string { return d.calls[i].Header.Get("X-Carlos-Schedule-At") } |
| 274 | |
| 275 | r.tick() // fails → retry in 1m |
| 276 | tm, _ := reg.GetTimer("a.bab.oncarlos.com", "sync") |
| 277 | if tm.NextAt != now.Add(time.Minute).Unix() || tm.DueAt != occurrence { |
| 278 | t.Fatalf("after first failure: %+v", tm) |
| 279 | } |
| 280 | now = now.Add(time.Minute) |
| 281 | r.tick() // the retry: same occurrence on the wire |
| 282 | if len(d.calls) != 2 || atHeader(0) != itoa(occurrence) || atHeader(1) != itoa(occurrence) { |
| 283 | t.Fatalf("retry header = %q, first = %q, want both %d", atHeader(1), atHeader(0), occurrence) |
| 284 | } |
| 285 | |
| 286 | d.status = 204 |
| 287 | now = now.Add(2 * time.Minute) |
| 288 | r.tick() // succeeds → advances |
| 289 | tm, _ = reg.GetTimer("a.bab.oncarlos.com", "sync") |
| 290 | next := at("2026-08-23T18:00:00Z").Unix() |
| 291 | if tm.NextAt != next || tm.DueAt != next || atHeader(2) != itoa(occurrence) { |
| 292 | t.Fatalf("after advance: %+v (header %q)", tm, atHeader(2)) |
| 293 | } |
| 294 | |
| 295 | // `run`: FireTimerNow makes now the occurrence, and the tick sends it. |
| 296 | now = now.Add(time.Minute) |
| 297 | reg.FireTimerNow("a.bab.oncarlos.com", "sync", now.Unix()) |
| 298 | r.tick() |
| 299 | if len(d.calls) != 4 || atHeader(3) != itoa(now.Unix()) { |
| 300 | t.Fatalf("run header = %q, want %d", atHeader(3), now.Unix()) |
| 301 | } |
| 302 | } |
| 303 | |
| 304 | // A row whose path the URL parser refuses is recorded, not retried and not |
| 305 | // delivered anywhere else: ValidPath runs the same parser at declaration, so |
| 306 | // such a row was written some other way and is exactly as bad next time. |
| 307 | func TestBadPathIsRecordedNotDelivered(t *testing.T) { |
| 308 | d := &fakeDeliverer{status: 204} |
| 309 | reg, r := runnerFixture(t, d) |
| 310 | now := at("2026-08-23T13:07:00Z") |
| 311 | r.Now = func() time.Time { return now } |
| 312 | reg.UpsertTimer(registry.Timer{Host: "a.bab.oncarlos.com", Name: "s", Kind: KindSchedule, Spec: "every 6h0m0s", Path: "/jobs/%zz", NextAt: now.Unix()}) |
| 313 | r.tick() |
| 314 | if len(d.calls) != 0 { |
| 315 | t.Fatal("delivered a request the parser refused") |
| 316 | } |
| 317 | tm, _ := reg.GetTimer("a.bab.oncarlos.com", "s") |
| 318 | if tm.Attempts != 0 || !strings.HasPrefix(tm.LastStatus, "bad path: ") || tm.NextAt != at("2026-08-23T18:00:00Z").Unix() { |
| 319 | t.Fatalf("%+v", tm) |
| 320 | } |
| 321 | } |
| 322 | |
| 323 | // tick is Tick plus a wait for every host goroutine it launched — the shape |
| 324 | // every test here asserts against, since Tick itself returns as soon as the |
| 325 | // work is in flight. |
| 326 | func (r *Runner) tick() { |
| 327 | r.Tick(context.Background()) |
| 328 | r.wait() |
| 329 | } |
| 330 | |
| 331 | // blockingDeliverer answers 204 at once for every host except `slow`, whose |
| 332 | // delivery parks on `gate` until the test closes it — a 20-minute handler, |
| 333 | // in miniature. |
| 334 | type blockingDeliverer struct { |
| 335 | fakeDeliverer |
| 336 | slow string |
| 337 | gate chan struct{} |
| 338 | } |
| 339 | |
| 340 | func (b *blockingDeliverer) Deliver(ctx context.Context, rt registry.Route, req *http.Request) (int, error) { |
| 341 | if rt.Host == b.slow { |
| 342 | <-b.gate |
| 343 | } |
| 344 | return b.fakeDeliverer.Deliver(ctx, rt, req) |
| 345 | } |
| 346 | |
| 347 | // One long handler must not hold every other host's timers: the tick |
| 348 | // launches per-host work and returns; while one host is parked, the others |
| 349 | // all fire; and a later tick does NOT start a second delivery to the parked |
| 350 | // host (its rows are still due — per-host serialization spans ticks). |
| 351 | func TestOneSlowHostDoesNotBlockTheOthersOrRefire(t *testing.T) { |
| 352 | d := &blockingDeliverer{fakeDeliverer: fakeDeliverer{status: 204}, slow: "slow.bab.oncarlos.com", gate: make(chan struct{})} |
| 353 | reg, r := runnerFixture(t, d) |
| 354 | now := at("2026-08-23T13:07:00Z") |
| 355 | clock := &testClock{t: now} |
| 356 | r.Now = clock.now |
| 357 | hosts := []string{"slow.bab.oncarlos.com", "b.bab.oncarlos.com", "c.bab.oncarlos.com", "d.bab.oncarlos.com", "e.bab.oncarlos.com", "f.bab.oncarlos.com", "g.bab.oncarlos.com"} |
| 358 | for i, h := range hosts { |
| 359 | if err := reg.Upsert(registry.Route{Host: h, Kind: "instance", Account: "bab", App: "app", Status: "ready"}); err != nil { |
| 360 | t.Fatal(err) |
| 361 | } |
| 362 | // The slow host is due FIRST, so it takes a slot before anyone else. |
| 363 | reg.UpsertTimer(registry.Timer{Host: h, Name: "j", Kind: KindSchedule, Spec: "every 1h0m0s", Path: "/j", NextAt: now.Unix() - 100 + int64(i)}) |
| 364 | } |
| 365 | if len(hosts) <= MaxParallelHosts { |
| 366 | t.Fatalf("fixture has %d hosts; the test needs more than MaxParallelHosts (%d) so slots recycle under the parked one", len(hosts), MaxParallelHosts) |
| 367 | } |
| 368 | |
| 369 | done := make(chan struct{}) |
| 370 | go func() { r.Tick(context.Background()); close(done) }() |
| 371 | select { |
| 372 | case <-done: |
| 373 | case <-time.After(10 * time.Second): |
| 374 | t.Fatal("Tick did not return while one host was parked") |
| 375 | } |
| 376 | // Every other host fires AND is recorded while slow is parked — record |
| 377 | // writes the row after Deliver returns, so poll the recorded state |
| 378 | // itself rather than the delivery count (which races ahead of it). |
| 379 | deadline := time.Now().Add(10 * time.Second) |
| 380 | for { |
| 381 | recorded := 0 |
| 382 | for _, h := range hosts[1:] { |
| 383 | if tm, _ := reg.GetTimer(h, "j"); tm != nil && tm.LastStatus != "" { |
| 384 | recorded++ |
| 385 | } |
| 386 | } |
| 387 | if recorded == len(hosts)-1 && d.firedHosts()[d.slow] == 0 { |
| 388 | break |
| 389 | } |
| 390 | if time.Now().After(deadline) { |
| 391 | t.Fatalf("fired = %v, want every host but %s recorded", d.firedHosts(), d.slow) |
| 392 | } |
| 393 | time.Sleep(5 * time.Millisecond) |
| 394 | } |
| 395 | for _, h := range hosts[1:] { |
| 396 | if tm, _ := reg.GetTimer(h, "j"); tm.LastStatus != "204" { |
| 397 | t.Fatalf("%s not recorded while slow was parked: %+v", h, tm) |
| 398 | } |
| 399 | } |
| 400 | |
| 401 | // The parked host's row is still due; a second tick must skip it. |
| 402 | clock.advance(time.Minute) |
| 403 | r.Tick(context.Background()) |
| 404 | close(d.gate) |
| 405 | r.wait() |
| 406 | if n := d.firedHosts()[d.slow]; n != 1 { |
| 407 | t.Fatalf("slow host delivered %d times, want exactly 1 — a busy host was re-fired", n) |
| 408 | } |
| 409 | if tm, _ := reg.GetTimer(d.slow, "j"); tm.LastStatus != "204" || tm.NextAt != at("2026-08-23T14:00:00Z").Unix() { |
| 410 | t.Fatalf("slow row after release: %+v", tm) |
| 411 | } |
| 412 | // And once released, it is fair game again. |
| 413 | clock.advance(at("2026-08-23T14:00:01Z").Sub(clock.now())) |
| 414 | r.tick() |
| 415 | if n := d.firedHosts()[d.slow]; n != 2 { |
| 416 | t.Fatalf("released host delivered %d times, want 2", n) |
| 417 | } |
| 418 | } |
| 419 | |
| 420 | // The prune leg is the RUNNER's, not the converger's, because a box with the |
| 421 | // activator armed and no deploy store has no converger at all (spec §6.2) — |
| 422 | // and it is exactly the box whose rows arrive on the control socket. Without |
| 423 | // this leg its finished once-rows accumulate forever. |
| 424 | func TestRunnerPrunesWithoutAConverger(t *testing.T) { |
| 425 | d := &fakeDeliverer{status: 204} |
| 426 | reg, r := runnerFixture(t, d) |
| 427 | if r.Report != nil { |
| 428 | t.Fatal("fixture has a reporter — this test is the storeless box") |
| 429 | } |
| 430 | now := at("2026-08-23T13:07:00Z") |
| 431 | r.Now = func() time.Time { return now } |
| 432 | const host = "a.bab.oncarlos.com" |
| 433 | old := now.Add(-8 * 24 * time.Hour).Unix() |
| 434 | reg.UpsertTimer(registry.Timer{Host: host, Name: "done-long-ago", Kind: KindOnce, Spec: "at 1", Path: "/o", NextAt: 0, LastAt: old}) |
| 435 | reg.UpsertTimer(registry.Timer{Host: host, Name: "done-just-now", Kind: KindOnce, Spec: "at 1", Path: "/o", NextAt: 0, LastAt: now.Add(-time.Hour).Unix()}) |
| 436 | reg.UpsertTimer(registry.Timer{Host: "ghost.bab.oncarlos.com", Name: "s", Kind: KindSchedule, Spec: "every 1h0m0s", Path: "/s", NextAt: 0, LastAt: now.Unix()}) |
| 437 | r.tick() |
| 438 | if tm, _ := reg.GetTimer(host, "done-long-ago"); tm != nil { |
| 439 | t.Fatalf("a finished once-row 8 days old survived the runner's prune: %+v", tm) |
| 440 | } |
| 441 | if tm, _ := reg.GetTimer("ghost.bab.oncarlos.com", "s"); tm != nil { |
| 442 | t.Fatalf("a row for a host with no route survived: %+v", tm) |
| 443 | } |
| 444 | if tm, _ := reg.GetTimer(host, "done-just-now"); tm == nil { |
| 445 | t.Fatal("a finished once-row inside the retention window was pruned") |
| 446 | } |
| 447 | |
| 448 | // Daily, not every tick: the stamp is what keeps a 15s tick from running |
| 449 | // a DELETE 5,760 times a day. |
| 450 | reg.UpsertTimer(registry.Timer{Host: "ghost2.bab.oncarlos.com", Name: "s", Kind: KindSchedule, Spec: "every 1h0m0s", Path: "/s", NextAt: 0, LastAt: now.Unix()}) |
| 451 | now = now.Add(TickEvery) |
| 452 | r.tick() |
| 453 | if tm, _ := reg.GetTimer("ghost2.bab.oncarlos.com", "s"); tm == nil { |
| 454 | t.Fatal("the prune leg ran again 15s later — the stamp is not holding") |
| 455 | } |
| 456 | now = now.Add(pruneEvery) |
| 457 | r.tick() |
| 458 | if tm, _ := reg.GetTimer("ghost2.bab.oncarlos.com", "s"); tm != nil { |
| 459 | t.Fatalf("the prune leg never came round again: %+v", tm) |
| 460 | } |
| 461 | } |
| 462 | |
| 463 | // retainingDeliverer is a Deliverer that also keeps per-socket state — the |
| 464 | // agent's does (one connection pool per socket). |
| 465 | type retainingDeliverer struct { |
| 466 | fakeDeliverer |
| 467 | mu sync.Mutex |
| 468 | calls []map[string]bool |
| 469 | } |
| 470 | |
| 471 | func (d *retainingDeliverer) RetainSockets(live map[string]bool) { |
| 472 | d.mu.Lock() |
| 473 | defer d.mu.Unlock() |
| 474 | d.calls = append(d.calls, live) |
| 475 | } |
| 476 | |
| 477 | func (d *retainingDeliverer) sweeps() []map[string]bool { |
| 478 | d.mu.Lock() |
| 479 | defer d.mu.Unlock() |
| 480 | return append([]map[string]bool(nil), d.calls...) |
| 481 | } |
| 482 | |
| 483 | // The prune leg doubles as the delivery leg's eviction point: it has already |
| 484 | // read every route, so it hands the live SOCKET set to a Deliverer that keeps |
| 485 | // per-socket state. A retired route can be reclaimed no other way — nothing |
| 486 | // delivers to it, so nothing it holds can error its own way out. |
| 487 | func TestPruneSweepHandsTheLiveSocketsToTheDeliverer(t *testing.T) { |
| 488 | d := &retainingDeliverer{fakeDeliverer: fakeDeliverer{status: 204}} |
| 489 | reg, r := runnerFixture(t, d) |
| 490 | now := at("2026-08-23T13:07:00Z") |
| 491 | r.Now = func() time.Time { return now } |
| 492 | if err := reg.Upsert(registry.Route{Host: "a.bab.oncarlos.com", Kind: "instance", Account: "bab", App: "app", Status: "ready", Socket: "/run/carlos/a.sock"}); err != nil { |
| 493 | t.Fatal(err) |
| 494 | } |
| 495 | if err := reg.Upsert(registry.Route{Host: "site.bab.oncarlos.com", Kind: "static", Account: "bab", App: "site", Status: "ready"}); err != nil { |
| 496 | t.Fatal(err) |
| 497 | } |
| 498 | r.tick() |
| 499 | sweeps := d.sweeps() |
| 500 | if len(sweeps) != 1 { |
| 501 | t.Fatalf("%d sweeps, want the one the prune leg runs", len(sweeps)) |
| 502 | } |
| 503 | if !sweeps[0]["/run/carlos/a.sock"] || len(sweeps[0]) != 1 { |
| 504 | t.Fatalf("live sockets = %v, want exactly the routed instance's (a socketless route contributes nothing)", sweeps[0]) |
| 505 | } |
| 506 | // Daily, with the prune itself: not once per tick. |
| 507 | now = now.Add(TickEvery) |
| 508 | r.tick() |
| 509 | if got := len(d.sweeps()); got != 1 { |
| 510 | t.Fatalf("%d sweeps after a second tick, want still 1", got) |
| 511 | } |
| 512 | } |
| 513 | |
| 514 | // A busy host must not crowd another host's due timer out of the batch. The |
| 515 | // runner skips a host whose delivery is still running, so its rows are dead |
| 516 | // weight in a capped query: the exclusion belongs in DueTimers, before the |
| 517 | // LIMIT. Sized to the real batch so the test fails if dueBatch and the |
| 518 | // fixture ever drift apart. |
| 519 | func TestBusyHostDoesNotStarveAnotherHostsTimer(t *testing.T) { |
| 520 | d := &blockingDeliverer{fakeDeliverer: fakeDeliverer{status: 204}, slow: "slow.bab.oncarlos.com", gate: make(chan struct{})} |
| 521 | reg, r := runnerFixture(t, d) |
| 522 | clk := &testClock{t: at("2026-08-23T13:07:00Z")} |
| 523 | now := clk.now() |
| 524 | r.Now = clk.now |
| 525 | for _, h := range []string{d.slow, "quiet.bab.oncarlos.com"} { |
| 526 | if err := reg.Upsert(registry.Route{Host: h, Kind: "instance", Account: "bab", App: "app", Status: "ready"}); err != nil { |
| 527 | t.Fatal(err) |
| 528 | } |
| 529 | } |
| 530 | // A full batch of rows on the parked host, every one of them due BEFORE |
| 531 | // the quiet host's single row. |
| 532 | for i := 0; i < dueBatch; i++ { |
| 533 | reg.UpsertTimer(registry.Timer{Host: d.slow, Name: fmt.Sprintf("j%02d", i), Kind: KindSchedule, Spec: "every 1h0m0s", Path: "/j", NextAt: now.Unix() - 1000 + int64(i)}) |
| 534 | } |
| 535 | reg.UpsertTimer(registry.Timer{Host: "quiet.bab.oncarlos.com", Name: "j", Kind: KindSchedule, Spec: "every 1h0m0s", Path: "/j", NextAt: now.Unix() - 1}) |
| 536 | |
| 537 | // First tick: the parked host claims a slot and stops there. Tick claims |
| 538 | // synchronously, so the host is busy the moment it returns. |
| 539 | r.Tick(context.Background()) |
| 540 | if busy := r.busyHosts(); len(busy) != 1 || busy[0] != d.slow { |
| 541 | t.Fatalf("after the first tick busy = %v, want just the parked host", busy) |
| 542 | } |
| 543 | |
| 544 | // Second tick, while it is still parked: the quiet host's row is due and |
| 545 | // must be delivered and recorded. |
| 546 | clk.advance(TickEvery) |
| 547 | r.Tick(context.Background()) |
| 548 | deadline := time.Now().Add(10 * time.Second) |
| 549 | for time.Now().Before(deadline) { |
| 550 | if tm, _ := reg.GetTimer("quiet.bab.oncarlos.com", "j"); tm != nil && tm.LastStatus == "204" { |
| 551 | break |
| 552 | } |
| 553 | time.Sleep(time.Millisecond) |
| 554 | } |
| 555 | if tm, _ := reg.GetTimer("quiet.bab.oncarlos.com", "j"); tm == nil || tm.LastStatus != "204" { |
| 556 | t.Fatalf("the quiet host's timer never fired behind a full batch of a busy host's rows: %+v", tm) |
| 557 | } |
| 558 | close(d.gate) |
| 559 | r.wait() |
| 560 | } |
| 561 | |
| 562 | // countingDeliverer parks every delivery on a gate the test opens, and |
| 563 | // signals two things about how many ran at once: reached (exactly |
| 564 | // MaxParallelHosts in flight) and over (MORE than that — the regression). |
| 565 | type countingDeliverer struct { |
| 566 | fakeDeliverer |
| 567 | gate chan struct{} |
| 568 | mu2 sync.Mutex |
| 569 | cur int |
| 570 | max int |
| 571 | reached chan struct{} |
| 572 | over chan struct{} |
| 573 | onceFull sync.Once |
| 574 | onceOver sync.Once |
| 575 | } |
| 576 | |
| 577 | func (c *countingDeliverer) Deliver(ctx context.Context, rt registry.Route, req *http.Request) (int, error) { |
| 578 | c.mu2.Lock() |
| 579 | c.cur++ |
| 580 | if c.cur > c.max { |
| 581 | c.max = c.cur |
| 582 | } |
| 583 | full, over := c.cur == MaxParallelHosts, c.cur > MaxParallelHosts |
| 584 | c.mu2.Unlock() |
| 585 | if full { |
| 586 | c.onceFull.Do(func() { close(c.reached) }) |
| 587 | } |
| 588 | if over { |
| 589 | c.onceOver.Do(func() { close(c.over) }) |
| 590 | } |
| 591 | <-c.gate |
| 592 | c.mu2.Lock() |
| 593 | c.cur-- |
| 594 | c.mu2.Unlock() |
| 595 | return c.fakeDeliverer.Deliver(ctx, rt, req) |
| 596 | } |
| 597 | |
| 598 | func (c *countingDeliverer) peak() int { |
| 599 | c.mu2.Lock() |
| 600 | defer c.mu2.Unlock() |
| 601 | return c.max |
| 602 | } |
| 603 | |
| 604 | // boundWindow is how long the bound is watched with every delivery parked. |
| 605 | // It is a floor on how long we LOOK, never a deadline anything must meet: |
| 606 | // both assertions inside it are negative (a fifth delivery must not start, |
| 607 | // Tick must not return), so a slow machine can only make the test weaker, |
| 608 | // never redder. A widened bound trips both within microseconds. |
| 609 | const boundWindow = 500 * time.Millisecond |
| 610 | |
| 611 | // The ≤4-parallel path: with more due hosts than MaxParallelHosts, exactly |
| 612 | // four deliveries run at once — no fewer (they are genuinely parallel, not |
| 613 | // one host at a time) and no more (the semaphore is the bound) — and every |
| 614 | // host still completes once the slots recycle. |
| 615 | func TestDeliveriesAreParallelToTheBoundAndAllComplete(t *testing.T) { |
| 616 | d := &countingDeliverer{fakeDeliverer: fakeDeliverer{status: 204}, gate: make(chan struct{}), reached: make(chan struct{}), over: make(chan struct{})} |
| 617 | reg, r := runnerFixture(t, d) |
| 618 | now := at("2026-08-23T13:07:00Z") |
| 619 | r.Now = func() time.Time { return now } |
| 620 | const hosts = 2 * MaxParallelHosts |
| 621 | var names []string |
| 622 | for i := 0; i < hosts; i++ { |
| 623 | h := fmt.Sprintf("h%d.bab.oncarlos.com", i) |
| 624 | names = append(names, h) |
| 625 | if err := reg.Upsert(registry.Route{Host: h, Kind: "instance", Account: "bab", App: "app", Status: "ready"}); err != nil { |
| 626 | t.Fatal(err) |
| 627 | } |
| 628 | reg.UpsertTimer(registry.Timer{Host: h, Name: "j", Kind: KindSchedule, Spec: "every 1h0m0s", Path: "/j", NextAt: now.Unix() - 100 + int64(i)}) |
| 629 | } |
| 630 | |
| 631 | tickDone := make(chan struct{}) |
| 632 | go func() { r.Tick(context.Background()); close(tickDone) }() |
| 633 | select { |
| 634 | case <-d.reached: |
| 635 | case <-time.After(10 * time.Second): |
| 636 | t.Fatalf("only %d deliveries ever ran at once, want %d in parallel", d.peak(), MaxParallelHosts) |
| 637 | } |
| 638 | // The BOUND, watched while the gate is still shut and all four slots are |
| 639 | // held. Two independent ways a widened semaphore shows itself, and the |
| 640 | // test must be red on either: |
| 641 | // |
| 642 | // - a fifth delivery starts (`over`); |
| 643 | // - Tick RETURNS. With 8 hosts and 4 slots it cannot: it takes the slot |
| 644 | // before launching each host's goroutine, so it is parked on the |
| 645 | // semaphore until a delivery finishes — and none can while the gate |
| 646 | // is shut. |
| 647 | // |
| 648 | // Watching the peak after the fact cannot do this job: opening the gate |
| 649 | // drains `cur` faster than a fifth delivery would ever be observed, which |
| 650 | // is why the earlier shape passed 25/25 with the semaphore quadrupled. |
| 651 | select { |
| 652 | case <-d.over: |
| 653 | t.Fatalf("%d deliveries in flight at once, want at most MaxParallelHosts (%d)", d.peak(), MaxParallelHosts) |
| 654 | case <-tickDone: |
| 655 | t.Fatalf("Tick returned with every delivery still parked — it launched more than MaxParallelHosts (%d) hosts (peak %d)", MaxParallelHosts, d.peak()) |
| 656 | case <-time.After(boundWindow): |
| 657 | } |
| 658 | |
| 659 | close(d.gate) // every parked delivery finishes; the waiting hosts take the slots |
| 660 | select { |
| 661 | case <-tickDone: |
| 662 | case <-time.After(10 * time.Second): |
| 663 | t.Fatal("Tick never returned after the gate opened") |
| 664 | } |
| 665 | r.wait() |
| 666 | if got := d.peak(); got > MaxParallelHosts { // belt-and-braces; the window above is the real assertion |
| 667 | t.Fatalf("%d deliveries in flight at once, want at most MaxParallelHosts (%d)", got, MaxParallelHosts) |
| 668 | } |
| 669 | for _, h := range names { |
| 670 | tm, _ := reg.GetTimer(h, "j") |
| 671 | if tm == nil || tm.LastStatus != "204" { |
| 672 | t.Fatalf("%s did not complete: %+v", h, tm) |
| 673 | } |
| 674 | } |
| 675 | if fired := d.firedHosts(); len(fired) != hosts { |
| 676 | t.Fatalf("delivered to %d hosts, want %d", len(fired), hosts) |
| 677 | } |
| 678 | } |
| 679 | |
| 680 | type timerMutationDeliverer struct { |
| 681 | mutate func() |
| 682 | status int |
| 683 | } |
| 684 | |
| 685 | func (d timerMutationDeliverer) Deliver(context.Context, registry.Route, *http.Request) (int, error) { |
| 686 | d.mutate() |
| 687 | return d.status, nil |
| 688 | } |
| 689 | |
| 690 | func TestDeliveryCompletionPreservesReplacementRegistration(t *testing.T) { |
| 691 | for _, mutation := range []string{"identical", "timing", "path", "cancel", "recreate", "manual"} { |
| 692 | for _, status := range []int{204, 503} { |
| 693 | t.Run(fmt.Sprintf("%s/%d", mutation, status), func(t *testing.T) { |
| 694 | reg, runner := runnerFixture(t, nil) |
| 695 | now := at("2026-08-23T13:07:00Z") |
| 696 | runner.Now = func() time.Time { return now } |
| 697 | const host = "a.bab.oncarlos.com" |
| 698 | mux := http.NewServeMux() |
| 699 | Mount(mux, host, reg, func() (string, error) { return "tok", nil }, runner.Now) |
| 700 | put := func(when time.Time, path string) { |
| 701 | req := httptest.NewRequest(http.MethodPut, "/schedule/remind", strings.NewReader(fmt.Sprintf(`{"at":%q,"path":%q}`, when.Format(time.RFC3339), path))) |
| 702 | req.Header.Set("Authorization", "Bearer tok") |
| 703 | w := httptest.NewRecorder() |
| 704 | mux.ServeHTTP(w, req) |
| 705 | if w.Code != http.StatusOK { |
| 706 | t.Errorf("PUT: %d %s", w.Code, w.Body.String()) |
| 707 | } |
| 708 | } |
| 709 | put(now, "/jobs/remind") |
| 710 | before, err := reg.GetTimer(host, "remind") |
| 711 | if err != nil { |
| 712 | t.Fatal(err) |
| 713 | } |
| 714 | var replacement *registry.Timer |
| 715 | var events []string |
| 716 | runner.Event = func(_ registry.Route, _, msg string) { events = append(events, msg) } |
| 717 | runner.Deliver = timerMutationDeliverer{status: status, mutate: func() { |
| 718 | switch mutation { |
| 719 | case "identical": |
| 720 | put(now, "/jobs/remind") |
| 721 | case "timing": |
| 722 | put(now.Add(time.Hour), "/jobs/remind") |
| 723 | case "path": |
| 724 | put(now, "/jobs/changed") |
| 725 | case "cancel", "recreate": |
| 726 | req := httptest.NewRequest(http.MethodDelete, "/schedule/remind", nil) |
| 727 | req.Header.Set("Authorization", "Bearer tok") |
| 728 | w := httptest.NewRecorder() |
| 729 | mux.ServeHTTP(w, req) |
| 730 | if w.Code != http.StatusNoContent { |
| 731 | t.Errorf("DELETE: %d", w.Code) |
| 732 | } |
| 733 | if mutation == "recreate" { |
| 734 | put(now, "/jobs/remind") |
| 735 | } |
| 736 | case "manual": |
| 737 | if err := reg.FireTimerNow(host, "remind", now.Unix()); err != nil { |
| 738 | t.Error(err) |
| 739 | } |
| 740 | } |
| 741 | replacement, err = reg.GetTimer(host, "remind") |
| 742 | if mutation != "cancel" && err != nil { |
| 743 | t.Error(err) |
| 744 | } |
| 745 | }} |
| 746 | runner.tick() |
| 747 | if len(events) != 1 || strings.Contains(events[0], "retrying at") { |
| 748 | t.Fatalf("superseded delivery event: %v", events) |
| 749 | } |
| 750 | after, err := reg.GetTimer(host, "remind") |
| 751 | if mutation == "cancel" { |
| 752 | if !errors.Is(err, os.ErrNotExist) { |
| 753 | t.Fatalf("cancelled timer: %+v, %v", after, err) |
| 754 | } |
| 755 | return |
| 756 | } |
| 757 | if err != nil { |
| 758 | t.Fatal(err) |
| 759 | } |
| 760 | if replacement == nil { |
| 761 | t.Fatal("replacement missing") |
| 762 | } |
| 763 | if after.Registration == before.Registration || after.Registration == "" { |
| 764 | t.Fatalf("registration did not change: %+v", after) |
| 765 | } |
| 766 | if *after != *replacement { |
| 767 | t.Fatalf("completion changed replacement: got %+v, want %+v", after, replacement) |
| 768 | } |
| 769 | runner.Deliver = &fakeDeliverer{status: 204} |
| 770 | now = time.Unix(after.NextAt, 0) |
| 771 | runner.tick() |
| 772 | finished, err := reg.GetTimer(host, "remind") |
| 773 | if err != nil { |
| 774 | t.Fatal(err) |
| 775 | } |
| 776 | if finished.NextAt != 0 || finished.LastStatus != "204" { |
| 777 | t.Fatalf("replacement did not fire: %+v", finished) |
| 778 | } |
| 779 | }) |
| 780 | } |
| 781 | } |
| 782 | } |
| 783 | |
| 784 | func TestDeliveryCompletionSurvivesUnchangedDeclaration(t *testing.T) { |
| 785 | for _, status := range []int{204, 503} { |
| 786 | t.Run(fmt.Sprint(status), func(t *testing.T) { |
| 787 | reg, runner := runnerFixture(t, nil) |
| 788 | now := at("2026-08-23T13:07:00Z") |
| 789 | runner.Now = func() time.Time { return now } |
| 790 | tm := registry.Timer{Host: "a.bab.oncarlos.com", Name: "sync", Kind: KindSchedule, Spec: "every 6h0m0s", Path: "/jobs/sync", NextAt: now.Unix(), DueAt: now.Unix()} |
| 791 | if err := reg.UpsertTimer(tm); err != nil { |
| 792 | t.Fatal(err) |
| 793 | } |
| 794 | before, err := reg.GetTimer(tm.Host, tm.Name) |
| 795 | if err != nil { |
| 796 | t.Fatal(err) |
| 797 | } |
| 798 | tm.NextAt, tm.DueAt = 0, 0 |
| 799 | runner.Deliver = timerMutationDeliverer{status: status, mutate: func() { |
| 800 | if err := reg.UpsertTimer(tm); err != nil { |
| 801 | t.Error(err) |
| 802 | } |
| 803 | }} |
| 804 | runner.tick() |
| 805 | after, err := reg.GetTimer(tm.Host, tm.Name) |
| 806 | if err != nil { |
| 807 | t.Fatal(err) |
| 808 | } |
| 809 | if after.Registration != before.Registration || after.LastStatus != fmt.Sprint(status) { |
| 810 | t.Fatalf("completion missing: %+v", after) |
| 811 | } |
| 812 | if status == 503 && (after.Attempts != 1 || after.DueAt != before.DueAt || after.NextAt != now.Add(time.Minute).Unix()) { |
| 813 | t.Fatalf("retry changed: %+v", after) |
| 814 | } |
| 815 | if err := reg.UpsertTimer(tm); err != nil { |
| 816 | t.Fatal(err) |
| 817 | } |
| 818 | converged, err := reg.GetTimer(tm.Host, tm.Name) |
| 819 | if err != nil { |
| 820 | t.Fatal(err) |
| 821 | } |
| 822 | if *converged != *after { |
| 823 | t.Fatalf("converge changed completed state: got %+v, want %+v", converged, after) |
| 824 | } |
| 825 | }) |
| 826 | } |
| 827 | } |
| 828 | |
| 829 | func TestInvalidTimerSnapshotCannotDeleteReplacement(t *testing.T) { |
| 830 | reg, runner := runnerFixture(t, &fakeDeliverer{status: 204}) |
| 831 | tm := registry.Timer{Host: "a.bab.oncarlos.com", Name: "remind", Kind: KindOnce, Spec: "invalid", Path: "/jobs/remind", NextAt: 100} |
| 832 | if err := reg.UpsertTimer(tm); err != nil { |
| 833 | t.Fatal(err) |
| 834 | } |
| 835 | old, err := reg.GetTimer(tm.Host, tm.Name) |
| 836 | if err != nil { |
| 837 | t.Fatal(err) |
| 838 | } |
| 839 | tm.Spec = "at 100" |
| 840 | if err := reg.UpsertTimer(tm); err != nil { |
| 841 | t.Fatal(err) |
| 842 | } |
| 843 | replacement, err := reg.GetTimer(tm.Host, tm.Name) |
| 844 | if err != nil { |
| 845 | t.Fatal(err) |
| 846 | } |
| 847 | runner.fire(context.Background(), registry.Route{Host: tm.Host}, *old) |
| 848 | after, err := reg.GetTimer(tm.Host, tm.Name) |
| 849 | if err != nil { |
| 850 | t.Fatal(err) |
| 851 | } |
| 852 | if *after != *replacement { |
| 853 | t.Fatalf("invalid snapshot changed replacement: got %+v, want %+v", after, replacement) |
| 854 | } |
| 855 | } |
| 856 | |
| 857 | func TestBatchSkipsTimerChangedByEarlierDelivery(t *testing.T) { |
| 858 | for _, mutation := range []string{"cancel", "rearm"} { |
| 859 | t.Run(mutation, func(t *testing.T) { |
| 860 | reg, runner := runnerFixture(t, nil) |
| 861 | now := at("2026-08-23T13:07:00Z") |
| 862 | runner.Now = func() time.Time { return now } |
| 863 | const host = "a.bab.oncarlos.com" |
| 864 | for _, name := range []string{"first", "later"} { |
| 865 | if err := reg.UpsertTimer(registry.Timer{Host: host, Name: name, Kind: KindOnce, Spec: "at 100", Path: "/jobs/" + name, NextAt: 100}); err != nil { |
| 866 | t.Fatal(err) |
| 867 | } |
| 868 | } |
| 869 | calls := 0 |
| 870 | runner.Deliver = timerMutationDeliverer{status: 204, mutate: func() { |
| 871 | calls++ |
| 872 | if mutation == "cancel" { |
| 873 | if err := reg.DeleteTimer(host, "later"); err != nil { |
| 874 | t.Error(err) |
| 875 | } |
| 876 | } else { |
| 877 | if err := reg.FireTimerNow(host, "later", now.Add(time.Hour).Unix()); err != nil { |
| 878 | t.Error(err) |
| 879 | } |
| 880 | } |
| 881 | }} |
| 882 | runner.tick() |
| 883 | if calls != 1 { |
| 884 | t.Fatalf("delivered %d timers, want first only", calls) |
| 885 | } |
| 886 | }) |
| 887 | } |
| 888 | } |
| 889 | |
| 890 | func TestConcurrentTicksSkipSnapshotChangedDuringTokenLookup(t *testing.T) { |
| 891 | for _, mutation := range []string{"cancel", "rearm", "retry", "retry-still-due", "done"} { |
| 892 | t.Run(mutation, func(t *testing.T) { |
| 893 | deliver := &fakeDeliverer{status: 204} |
| 894 | reg, runner := runnerFixture(t, deliver) |
| 895 | now := at("2026-08-23T13:07:00Z") |
| 896 | runner.Now = func() time.Time { return now } |
| 897 | tm := registry.Timer{Host: "a.bab.oncarlos.com", Name: "remind", Kind: KindOnce, Spec: "at 100", Path: "/jobs/remind", NextAt: 100} |
| 898 | if err := reg.UpsertTimer(tm); err != nil { |
| 899 | t.Fatal(err) |
| 900 | } |
| 901 | before, err := reg.GetTimer(tm.Host, tm.Name) |
| 902 | if err != nil { |
| 903 | t.Fatal(err) |
| 904 | } |
| 905 | entered, release := make(chan struct{}), make(chan struct{}) |
| 906 | var tokenOnce sync.Once |
| 907 | runner.Token = func(string) (string, error) { tokenOnce.Do(func() { close(entered) }); <-release; return "tok", nil } |
| 908 | runner.Tick(context.Background()) |
| 909 | <-entered |
| 910 | switch mutation { |
| 911 | case "cancel": |
| 912 | err = reg.DeleteTimer(tm.Host, tm.Name) |
| 913 | case "rearm": |
| 914 | err = reg.FireTimerNow(tm.Host, tm.Name, now.Add(time.Hour).Unix()) |
| 915 | case "retry": |
| 916 | _, err = reg.MarkTimerFired(tm.Host, tm.Name, before.Registration, now.Unix(), "503", 0, 1, now.Add(time.Minute).Unix(), 100) |
| 917 | case "retry-still-due": |
| 918 | _, err = reg.MarkTimerFired(tm.Host, tm.Name, before.Registration, now.Unix(), "503", 0, 1, now.Unix()-1, 100) |
| 919 | case "done": |
| 920 | _, err = reg.MarkTimerFired(tm.Host, tm.Name, before.Registration, now.Unix(), "204", 0, 0, 0, 0) |
| 921 | } |
| 922 | if err != nil { |
| 923 | close(release) |
| 924 | runner.wait() |
| 925 | t.Fatal(err) |
| 926 | } |
| 927 | runner.Tick(context.Background()) |
| 928 | close(release) |
| 929 | runner.wait() |
| 930 | runner.fire(context.Background(), registry.Route{Host: tm.Host}, *before) |
| 931 | if len(deliver.calls) != 0 { |
| 932 | t.Fatalf("delivered stale snapshot %d times", len(deliver.calls)) |
| 933 | } |
| 934 | }) |
| 935 | } |
| 936 | } |
| 937 | |
| 938 | func TestNonpositiveOccurrenceDeliveredAndRetriedWithOriginalTimestamp(t *testing.T) { |
| 939 | for _, requested := range []int64{0, -1, -62135596800} { |
| 940 | t.Run(fmt.Sprint(requested), func(t *testing.T) { |
| 941 | deliver := &fakeDeliverer{status: 503} |
| 942 | reg, runner := runnerFixture(t, deliver) |
| 943 | now := at("2026-08-23T13:07:00Z") |
| 944 | runner.Now = func() time.Time { return now } |
| 945 | mux := http.NewServeMux() |
| 946 | Mount(mux, "a.bab.oncarlos.com", reg, func() (string, error) { return "tok", nil }, runner.Now) |
| 947 | rec := do(mux, "PUT", "/schedule/remind", "Bearer tok", `{"at":"`+time.Unix(requested, 0).UTC().Format(time.RFC3339)+`","path":"/jobs/remind"}`) |
| 948 | if rec.Code != http.StatusOK { |
| 949 | t.Fatalf("PUT: %d %s", rec.Code, rec.Body.String()) |
| 950 | } |
| 951 | runner.tick() |
| 952 | row, err := reg.GetTimer("a.bab.oncarlos.com", "remind") |
| 953 | if err != nil { |
| 954 | t.Fatal(err) |
| 955 | } |
| 956 | if row.Attempts != 1 || row.NextAt != now.Add(time.Minute).Unix() || row.DueAt != requested { |
| 957 | t.Fatalf("retry: %+v", row) |
| 958 | } |
| 959 | now = now.Add(time.Minute) |
| 960 | deliver.status = 204 |
| 961 | runner.tick() |
| 962 | if len(deliver.calls) != 2 { |
| 963 | t.Fatalf("deliveries: %d", len(deliver.calls)) |
| 964 | } |
| 965 | for _, req := range deliver.calls { |
| 966 | if got := req.Header.Get("X-Carlos-Schedule-At"); got != fmt.Sprint(requested) { |
| 967 | t.Fatalf("occurrence: %q, want %d", got, requested) |
| 968 | } |
| 969 | } |
| 970 | row, err = reg.GetTimer("a.bab.oncarlos.com", "remind") |
| 971 | if err != nil { |
| 972 | t.Fatal(err) |
| 973 | } |
| 974 | if row.NextAt != 0 || row.LastStatus != "204" { |
| 975 | t.Fatalf("completed: %+v", row) |
| 976 | } |
| 977 | }) |
| 978 | } |
| 979 | } |
| 980 | |
| 981 | // ---- move holds (moves spec §9.4) ---- |
| 982 | |
| 983 | // holdHost is the runner fixture's host, held by a move in these tests. |
| 984 | const holdHost = "a.bab.oncarlos.com" |
| 985 | |
| 986 | // timersOf is the host's whole timers table, for before/after comparisons. |
| 987 | func timersOf(t *testing.T, reg *registry.Registry, host string) []registry.Timer { |
| 988 | t.Helper() |
| 989 | rows, err := reg.TimersFor(host) |
| 990 | if err != nil { |
| 991 | t.Fatal(err) |
| 992 | } |
| 993 | return rows |
| 994 | } |
| 995 | |
| 996 | func sameTimers(a, b []registry.Timer) bool { |
| 997 | if len(a) != len(b) { |
| 998 | return false |
| 999 | } |
| 1000 | for i := range a { |
| 1001 | if a[i] != b[i] { |
| 1002 | return false |
| 1003 | } |
| 1004 | } |
| 1005 | return true |
| 1006 | } |
| 1007 | |
| 1008 | // Witness wTimerFreeze: a held row's due timers are not delivered and their |
| 1009 | // bookkeeping does not move (before this, a refused delivery advanced |
| 1010 | // attempts); a frozen host's declared schedule is neither upserted nor |
| 1011 | // pruned by Converger.Pass; once the hold lifts, both resume. |
| 1012 | func TestHeldRowsFreezeTimers(t *testing.T) { |
| 1013 | d := &fakeDeliverer{status: 503} // a delivery would retry and advance attempts |
| 1014 | reg, r := runnerFixture(t, d) |
| 1015 | now := at("2026-08-23T13:07:00Z") |
| 1016 | r.Now = func() time.Time { return now } |
| 1017 | r.Gate = &HostGate{} |
| 1018 | reg.UpsertTimer(registry.Timer{Host: holdHost, Name: "remind", Kind: KindOnce, Spec: "at 1", Path: "/r", NextAt: 1, DueAt: 1}) |
| 1019 | reg.UpsertTimer(registry.Timer{Host: holdHost, Name: "stale", Kind: KindSchedule, Spec: "every 1h0m0s", Path: "/s", NextAt: now.Unix() - 5}) |
| 1020 | if err := reg.SetHold(holdHost, "moving", "mv_a"); err != nil { |
| 1021 | t.Fatal(err) |
| 1022 | } |
| 1023 | before := timersOf(t, reg, holdHost) |
| 1024 | r.tick() |
| 1025 | if len(d.calls) != 0 { |
| 1026 | t.Fatalf("a held row's timers were delivered %d time(s)", len(d.calls)) |
| 1027 | } |
| 1028 | if got := timersOf(t, reg, holdHost); !sameTimers(got, before) { |
| 1029 | t.Fatalf("a held row's bookkeeping moved:\n got %+v\nwant %+v", got, before) |
| 1030 | } |
| 1031 | if r.Busy(holdHost) { |
| 1032 | t.Fatal("the runner kept a claim on a held host") |
| 1033 | } |
| 1034 | |
| 1035 | // The converger, with the host frozen: the declaration is not upserted |
| 1036 | // and the undeclared "stale" schedule is not pruned. |
| 1037 | st := release.NewDirStore(t.TempDir()) |
| 1038 | ctx := context.Background() |
| 1039 | UpsertSchedule(ctx, st, "bab", "app", Schedule{Name: "sync", Every: "6h", Path: "/jobs/sync"}, now) |
| 1040 | frozen := true |
| 1041 | c := NewConverger(reg, st, "box1", nil) |
| 1042 | c.Now = func() time.Time { return now } |
| 1043 | c.Gate = r.Gate |
| 1044 | c.Frozen = func(host string) bool { return host == holdHost && frozen } |
| 1045 | if err := c.Pass(ctx); err != nil { |
| 1046 | t.Fatal(err) |
| 1047 | } |
| 1048 | if got := timersOf(t, reg, holdHost); !sameTimers(got, before) { |
| 1049 | t.Fatalf("Converger.Pass wrote a frozen host's timers:\n got %+v\nwant %+v", got, before) |
| 1050 | } |
| 1051 | |
| 1052 | // The hold lifts: the runner delivers and the converger converges. |
| 1053 | if ok, err := reg.ReleaseHold(holdHost, "mv_a"); err != nil || !ok { |
| 1054 | t.Fatalf("release hold = %v %v", ok, err) |
| 1055 | } |
| 1056 | frozen = false |
| 1057 | r.tick() |
| 1058 | if len(d.calls) == 0 { |
| 1059 | t.Fatal("nothing was delivered after the hold lifted") |
| 1060 | } |
| 1061 | if err := c.Pass(ctx); err != nil { |
| 1062 | t.Fatal(err) |
| 1063 | } |
| 1064 | if _, err := reg.GetTimer(holdHost, "sync"); err != nil { |
| 1065 | t.Fatalf("the declaration did not converge after the freeze: %v", err) |
| 1066 | } |
| 1067 | if tm, _ := reg.GetTimer(holdHost, "stale"); tm != nil { |
| 1068 | t.Fatalf("the undeclared schedule survived the converge after the freeze: %+v", tm) |
| 1069 | } |
| 1070 | } |
| 1071 | |
| 1072 | // A hold written while a host's goroutine is delivering stops the rest of |
| 1073 | // that host's due timers: the next timer re-reads the row first. |
| 1074 | func TestHoldMidDeliveryStopsTheRest(t *testing.T) { |
| 1075 | reg, r := runnerFixture(t, nil) |
| 1076 | now := at("2026-08-23T13:07:00Z") |
| 1077 | r.Now = func() time.Time { return now } |
| 1078 | r.Gate = &HostGate{} // the hold wiring armed; off, both would be delivered (TestRunnerWithoutTheGateIsMain) |
| 1079 | var delivered []string |
| 1080 | r.Deliver = deliverFunc(func(rt registry.Route, req *http.Request) (int, error) { |
| 1081 | delivered = append(delivered, req.Header.Get("X-Carlos-Schedule")) |
| 1082 | if err := reg.SetHold(rt.Host, "moving", "mv_a"); err != nil { |
| 1083 | t.Error(err) |
| 1084 | } |
| 1085 | return 204, nil |
| 1086 | }) |
| 1087 | reg.UpsertTimer(registry.Timer{Host: holdHost, Name: "first", Kind: KindOnce, Spec: "at 1", Path: "/1", NextAt: 1}) |
| 1088 | reg.UpsertTimer(registry.Timer{Host: holdHost, Name: "second", Kind: KindOnce, Spec: "at 2", Path: "/2", NextAt: 2}) |
| 1089 | r.tick() |
| 1090 | if len(delivered) != 1 || delivered[0] != "first" { |
| 1091 | t.Fatalf("delivered %v, want only the first", delivered) |
| 1092 | } |
| 1093 | second, _ := reg.GetTimer(holdHost, "second") |
| 1094 | if second.NextAt != 2 || second.Attempts != 0 || second.LastAt != 0 { |
| 1095 | t.Fatalf("the second timer's bookkeeping moved under the hold: %+v", second) |
| 1096 | } |
| 1097 | } |
| 1098 | |
| 1099 | type deliverFunc func(rt registry.Route, req *http.Request) (int, error) |
| 1100 | |
| 1101 | func (f deliverFunc) Deliver(_ context.Context, rt registry.Route, req *http.Request) (int, error) { |
| 1102 | return f(rt, req) |
| 1103 | } |
| 1104 | |
| 1105 | // The cut's drain race: a tick reads the route while it is ready and pauses |
| 1106 | // before its claim; the cut sets the hold and finds the host not busy, and |
| 1107 | // its drain returns; the tick resumes, claims, re-reads the row inside the |
| 1108 | // claim, sees the hold and lets go. Nothing is delivered and attempts does |
| 1109 | // not move. |
| 1110 | func TestDrainRaceCannotAdvanceBookkeeping(t *testing.T) { |
| 1111 | d := &fakeDeliverer{status: 503} |
| 1112 | reg, r := runnerFixture(t, d) |
| 1113 | now := at("2026-08-23T13:07:00Z") |
| 1114 | r.Now = func() time.Time { return now } |
| 1115 | r.Gate = &HostGate{} |
| 1116 | reg.UpsertTimer(registry.Timer{Host: holdHost, Name: "remind", Kind: KindOnce, Spec: "at 1", Path: "/r", NextAt: 1}) |
| 1117 | before := timersOf(t, reg, holdHost) |
| 1118 | reached, resume := make(chan struct{}), make(chan struct{}) |
| 1119 | r.beforeClaim = func(host string) { |
| 1120 | if host == holdHost { |
| 1121 | close(reached) |
| 1122 | <-resume |
| 1123 | } |
| 1124 | } |
| 1125 | ticked := make(chan struct{}) |
| 1126 | go func() { r.Tick(context.Background()); close(ticked) }() |
| 1127 | <-reached |
| 1128 | // The cut: hold, then drain. |
| 1129 | if err := reg.SetHold(holdHost, "moving", "mv_a"); err != nil { |
| 1130 | t.Fatal(err) |
| 1131 | } |
| 1132 | if r.Busy(holdHost) { |
| 1133 | t.Fatal("the paused tick already held the claim") |
| 1134 | } |
| 1135 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) |
| 1136 | defer cancel() |
| 1137 | if err := r.Gate.Drain(ctx, holdHost); err != nil { |
| 1138 | t.Fatalf("drain = %v", err) |
| 1139 | } |
| 1140 | close(resume) |
| 1141 | <-ticked |
| 1142 | r.wait() |
| 1143 | if len(d.calls) != 0 { |
| 1144 | t.Fatalf("the paused tick delivered %d time(s) after the drain", len(d.calls)) |
| 1145 | } |
| 1146 | if got := timersOf(t, reg, holdHost); !sameTimers(got, before) { |
| 1147 | t.Fatalf("the paused tick moved bookkeeping after the drain:\n got %+v\nwant %+v", got, before) |
| 1148 | } |
| 1149 | if r.Busy(holdHost) { |
| 1150 | t.Fatal("the tick kept its claim after seeing the hold") |
| 1151 | } |
| 1152 | } |
| 1153 | |
| 1154 | // A delivery in flight when the hold is written holds up the drain until it |
| 1155 | // has recorded its outcome: the snapshot after the drain sees the |
| 1156 | // delivery's bookkeeping, never a write that lands after it. |
| 1157 | func TestDrainWaitsForTheDeliveryInFlight(t *testing.T) { |
| 1158 | d := &blockingDeliverer{fakeDeliverer: fakeDeliverer{status: 204}, slow: holdHost, gate: make(chan struct{})} |
| 1159 | reg, r := runnerFixture(t, d) |
| 1160 | now := at("2026-08-23T13:07:00Z") |
| 1161 | r.Now = func() time.Time { return now } |
| 1162 | r.Gate = &HostGate{} |
| 1163 | reg.UpsertTimer(registry.Timer{Host: holdHost, Name: "remind", Kind: KindOnce, Spec: "at 1", Path: "/r", NextAt: 1}) |
| 1164 | r.Tick(context.Background()) |
| 1165 | if !r.Busy(holdHost) { |
| 1166 | t.Fatal("the delivery is not in flight") |
| 1167 | } |
| 1168 | if err := reg.SetHold(holdHost, "moving", "mv_a"); err != nil { |
| 1169 | t.Fatal(err) |
| 1170 | } |
| 1171 | drained := make(chan error, 1) |
| 1172 | go func() { drained <- r.Gate.Drain(context.Background(), holdHost) }() |
| 1173 | select { |
| 1174 | case err := <-drained: |
| 1175 | t.Fatalf("the drain returned (%v) with a delivery in flight", err) |
| 1176 | case <-time.After(50 * time.Millisecond): |
| 1177 | } |
| 1178 | close(d.gate) |
| 1179 | if err := <-drained; err != nil { |
| 1180 | t.Fatal(err) |
| 1181 | } |
| 1182 | snap := timersOf(t, reg, holdHost) |
| 1183 | r.wait() |
| 1184 | if snap[0].LastStatus != "204" || snap[0].NextAt != 0 { |
| 1185 | t.Fatalf("the drain returned before the delivery recorded: %+v", snap[0]) |
| 1186 | } |
| 1187 | if got := timersOf(t, reg, holdHost); !sameTimers(got, snap) { |
| 1188 | t.Fatalf("the table changed after the drain:\n got %+v\nwant %+v", got, snap) |
| 1189 | } |
| 1190 | } |
| 1191 | |
| 1192 | // Fix round 1 (task-6-review, Important 1): a held host's rows must not |
| 1193 | // consume the due batch. DueTimers applies its LIMIT before the runner's |
| 1194 | // hold filter, so a full batch of held rows due before an unrelated host's |
| 1195 | // row would be selected on every tick and that row never seen. With the |
| 1196 | // hold wiring armed the query itself leaves held hosts out, and the |
| 1197 | // unrelated timer runs this tick. |
| 1198 | func TestHeldHostsDoNotStarveAnotherHostsTimer(t *testing.T) { |
| 1199 | d := &fakeDeliverer{status: 204} |
| 1200 | reg, r := runnerFixture(t, d) |
| 1201 | now := at("2026-08-23T13:07:00Z") |
| 1202 | r.Now = func() time.Time { return now } |
| 1203 | r.Gate = &HostGate{} |
| 1204 | const quiet = "quiet.bab.oncarlos.com" |
| 1205 | if err := reg.Upsert(registry.Route{Host: quiet, Kind: "instance", Account: "bab", App: "app", Status: "ready"}); err != nil { |
| 1206 | t.Fatal(err) |
| 1207 | } |
| 1208 | // A full batch of rows on the held host, every one due BEFORE the quiet |
| 1209 | // host's single row. |
| 1210 | for i := 0; i < dueBatch; i++ { |
| 1211 | reg.UpsertTimer(registry.Timer{Host: holdHost, Name: fmt.Sprintf("j%02d", i), Kind: KindSchedule, Spec: "every 1h0m0s", Path: "/j", NextAt: now.Unix() - 1000 + int64(i)}) |
| 1212 | } |
| 1213 | reg.UpsertTimer(registry.Timer{Host: quiet, Name: "j", Kind: KindSchedule, Spec: "every 1h0m0s", Path: "/j", NextAt: now.Unix() - 1}) |
| 1214 | if err := reg.SetHold(holdHost, "moving", "mv_a"); err != nil { |
| 1215 | t.Fatal(err) |
| 1216 | } |
| 1217 | before := timersOf(t, reg, holdHost) |
| 1218 | r.tick() |
| 1219 | if tm, _ := reg.GetTimer(quiet, "j"); tm == nil || tm.LastStatus != "204" { |
| 1220 | t.Fatalf("the quiet host's timer did not fire this tick behind a full batch of a held host's rows: %+v", tm) |
| 1221 | } |
| 1222 | for _, h := range d.hosts { |
| 1223 | if h != quiet { |
| 1224 | t.Fatalf("delivered to %s, a held host", h) |
| 1225 | } |
| 1226 | } |
| 1227 | if got := timersOf(t, reg, holdHost); !sameTimers(got, before) { |
| 1228 | t.Fatalf("the held host's bookkeeping moved:\n got %+v\nwant %+v", got, before) |
| 1229 | } |
| 1230 | } |
| 1231 | |
| 1232 | // Fix round 1 (task-6-review, Important 3): with no gate (the Phase 1 |
| 1233 | // switch off, or no hold wiring on this box) the runner is main's code |
| 1234 | // path. A row whose status is a hold is delivered like any other and its |
| 1235 | // bookkeeping moves: the batch includes it, the pre-filter and the decision |
| 1236 | // read do not look at the hold, and the between-timer check does not run. |
| 1237 | func TestRunnerWithoutTheGateIsMain(t *testing.T) { |
| 1238 | d := &fakeDeliverer{status: 503} |
| 1239 | reg, r := runnerFixture(t, d) |
| 1240 | now := at("2026-08-23T13:07:00Z") |
| 1241 | r.Now = func() time.Time { return now } |
| 1242 | reg.UpsertTimer(registry.Timer{Host: holdHost, Name: "first", Kind: KindOnce, Spec: "at 1", Path: "/1", NextAt: 1, DueAt: 1}) |
| 1243 | reg.UpsertTimer(registry.Timer{Host: holdHost, Name: "second", Kind: KindOnce, Spec: "at 2", Path: "/2", NextAt: 2, DueAt: 2}) |
| 1244 | if err := reg.SetHold(holdHost, "moving", "mv_a"); err != nil { |
| 1245 | t.Fatal(err) |
| 1246 | } |
| 1247 | r.tick() |
| 1248 | if len(d.calls) != 2 { |
| 1249 | t.Fatalf("with the hold wiring off a held row's due timers were delivered %d time(s), want both", len(d.calls)) |
| 1250 | } |
| 1251 | for _, name := range []string{"first", "second"} { |
| 1252 | tm, err := reg.GetTimer(holdHost, name) |
| 1253 | if err != nil { |
| 1254 | t.Fatal(err) |
| 1255 | } |
| 1256 | if tm.Attempts != 1 || tm.LastStatus != "503" || tm.NextAt != now.Add(backoffBase).Unix() { |
| 1257 | t.Fatalf("%s: bookkeeping did not advance as main's does: %+v", name, tm) |
| 1258 | } |
| 1259 | } |
| 1260 | } |
| 1261 | |
| 1262 | // ---- the import gate (seeded-instances spec §5.4) ---- |
| 1263 | |
| 1264 | const gateSeedID = "sd_aaaaaaaaaaaaaaaaaaaaaaaaaa" |
| 1265 | |
| 1266 | // gateForImport swaps host's row for a gated import row in one journal |
| 1267 | // transaction, the way the swap does. |
| 1268 | func gateForImport(t *testing.T, reg *registry.Registry, host string) { |
| 1269 | t.Helper() |
| 1270 | stored, err := reg.Get(host) |
| 1271 | if err != nil || stored == nil { |
| 1272 | t.Fatalf("get %s: %v %v", host, stored, err) |
| 1273 | } |
| 1274 | next := *stored |
| 1275 | next.Provisioned = true |
| 1276 | next.ImportGate = strings.Repeat("a", 64) |
| 1277 | next.SeedSource = `{"id":"` + gateSeedID + `","kind":"import","state":"swapped"}` |
| 1278 | if err := reg.SwapForImport(registry.ImportJournal{Host: host, SeedID: gateSeedID, Original: *stored, At: 1, |
| 1279 | RecordKey: "apps/bab/app/instances/" + host + ".json"}, next); err != nil { |
| 1280 | t.Fatal(err) |
| 1281 | } |
| 1282 | } |
| 1283 | |
| 1284 | // A gated row gets no ticks, whether or not the hold wiring is armed, and |
| 1285 | // its bookkeeping does not move; commit opens it. |
| 1286 | func TestTickSkipsGatedRows(t *testing.T) { |
| 1287 | for _, armed := range []bool{false, true} { |
| 1288 | t.Run(fmt.Sprintf("holds armed %v", armed), func(t *testing.T) { |
| 1289 | reg, r := runnerFixture(t, nil) |
| 1290 | now := at("2026-08-23T13:07:00Z") |
| 1291 | r.Now = func() time.Time { return now } |
| 1292 | if armed { |
| 1293 | r.Gate = &HostGate{} |
| 1294 | } |
| 1295 | delivered := 0 |
| 1296 | r.Deliver = deliverFunc(func(registry.Route, *http.Request) (int, error) { delivered++; return 204, nil }) |
| 1297 | gateForImport(t, reg, holdHost) |
| 1298 | reg.UpsertTimer(registry.Timer{Host: holdHost, Name: "remind", Kind: KindOnce, Spec: "at 1", Path: "/r", NextAt: 1}) |
| 1299 | before := timersOf(t, reg, holdHost) |
| 1300 | r.tick() |
| 1301 | if delivered != 0 { |
| 1302 | t.Fatalf("delivered %d tick(s) to a gated row", delivered) |
| 1303 | } |
| 1304 | if got := timersOf(t, reg, holdHost); !sameTimers(got, before) { |
| 1305 | t.Fatalf("a gated row's bookkeeping moved:\n got %+v\nwant %+v", got, before) |
| 1306 | } |
| 1307 | if r.Busy(holdHost) { |
| 1308 | t.Fatal("the runner kept a claim on a gated host") |
| 1309 | } |
| 1310 | if err := reg.CommitImport(holdHost, gateSeedID); err != nil { |
| 1311 | t.Fatal(err) |
| 1312 | } |
| 1313 | r.tick() |
| 1314 | if delivered != 1 { |
| 1315 | t.Fatalf("after commit delivered %d, want 1", delivered) |
| 1316 | } |
| 1317 | }) |
| 1318 | } |
| 1319 | } |
| 1320 | |
| 1321 | // M14: the swap lands between the tick's pre-filter read and its claim. The |
| 1322 | // decision read inside the claim sees the gate and lets go: nothing is |
| 1323 | // delivered and the bookkeeping does not move. |
| 1324 | func TestGateLandingBeforeTheClaimGetsNoTick(t *testing.T) { |
| 1325 | d := &fakeDeliverer{status: 204} |
| 1326 | reg, r := runnerFixture(t, d) |
| 1327 | now := at("2026-08-23T13:07:00Z") |
| 1328 | r.Now = func() time.Time { return now } |
| 1329 | r.Gate = &HostGate{} |
| 1330 | reg.UpsertTimer(registry.Timer{Host: holdHost, Name: "remind", Kind: KindOnce, Spec: "at 1", Path: "/r", NextAt: 1}) |
| 1331 | before := timersOf(t, reg, holdHost) |
| 1332 | swapped := false |
| 1333 | r.beforeClaim = func(host string) { |
| 1334 | if host == holdHost && !swapped { |
| 1335 | swapped = true |
| 1336 | gateForImport(t, reg, holdHost) |
| 1337 | } |
| 1338 | } |
| 1339 | r.tick() |
| 1340 | if !swapped { |
| 1341 | t.Fatal("test bug: the tick never reached its claim") |
| 1342 | } |
| 1343 | if len(d.calls) != 0 { |
| 1344 | t.Fatalf("a row gated before the claim got %d tick(s)", len(d.calls)) |
| 1345 | } |
| 1346 | if got := timersOf(t, reg, holdHost); !sameTimers(got, before) { |
| 1347 | t.Fatalf("the gated row's bookkeeping moved:\n got %+v\nwant %+v", got, before) |
| 1348 | } |
| 1349 | if r.Busy(holdHost) { |
| 1350 | t.Fatal("the tick kept its claim after seeing the gate") |
| 1351 | } |
| 1352 | } |
| 1353 | |
| 1354 | // Final review F5: 64 gated due timers (four uncommitted imports with 16 |
| 1355 | // each, due first) and one ordinary due timer on another host. The batch |
| 1356 | // leaves the gated rows out before its limit, so the ordinary timer is |
| 1357 | // delivered on the first tick, and the gated rows' bookkeeping does not |
| 1358 | // move. |
| 1359 | func TestGatedTimersDoNotStarveAnotherHost(t *testing.T) { |
| 1360 | for _, armed := range []bool{false, true} { |
| 1361 | t.Run(fmt.Sprintf("holds armed %v", armed), func(t *testing.T) { |
| 1362 | reg, r := runnerFixture(t, nil) |
| 1363 | now := at("2026-08-23T13:07:00Z") |
| 1364 | r.Now = func() time.Time { return now } |
| 1365 | if armed { |
| 1366 | r.Gate = &HostGate{} |
| 1367 | } |
| 1368 | var delivered []string |
| 1369 | r.Deliver = deliverFunc(func(rt registry.Route, _ *http.Request) (int, error) { |
| 1370 | delivered = append(delivered, rt.Host) |
| 1371 | return 204, nil |
| 1372 | }) |
| 1373 | var gated []string |
| 1374 | for i := range 4 { |
| 1375 | host := fmt.Sprintf("g%d.bab.oncarlos.com", i) |
| 1376 | gated = append(gated, host) |
| 1377 | if err := reg.Upsert(registry.Route{Host: host, Kind: "instance", Account: "bab", App: "app", Status: "ready", Hibernate: true}); err != nil { |
| 1378 | t.Fatal(err) |
| 1379 | } |
| 1380 | gateForImport(t, reg, host) |
| 1381 | for n := range dueBatch / 4 { |
| 1382 | reg.UpsertTimer(registry.Timer{Host: host, Name: fmt.Sprintf("t%d", n), Kind: KindOnce, Spec: "at 1", Path: "/r", NextAt: int64(1 + n)}) |
| 1383 | } |
| 1384 | } |
| 1385 | reg.UpsertTimer(registry.Timer{Host: holdHost, Name: "remind", Kind: KindOnce, Spec: "at 1", Path: "/r", NextAt: 100}) |
| 1386 | before := map[string][]registry.Timer{} |
| 1387 | for _, h := range gated { |
| 1388 | before[h] = timersOf(t, reg, h) |
| 1389 | } |
| 1390 | r.tick() |
| 1391 | r.wait() |
| 1392 | if len(delivered) != 1 || delivered[0] != holdHost { |
| 1393 | t.Fatalf("delivered %q, want the ordinary host's timer alone", delivered) |
| 1394 | } |
| 1395 | for _, h := range gated { |
| 1396 | if got := timersOf(t, reg, h); !sameTimers(got, before[h]) { |
| 1397 | t.Fatalf("%s's gated rows moved:\n got %+v\nwant %+v", h, got, before[h]) |
| 1398 | } |
| 1399 | } |
| 1400 | }) |
| 1401 | } |
| 1402 | } |
| 1403 | |
| 1404 | // Skip (harness Run 9, defect 8): a host the agent says not to touch, here a |
| 1405 | // held row with the move-hold wiring off (an offline move's source after |
| 1406 | // open), gets no token, no delivery and no bookkeeping. And a hold written |
| 1407 | // between the tick's route read and the fire is seen before the token, the |
| 1408 | // one write a tick makes on the instance's own ground. |
| 1409 | func TestSkipMintsNoTokenAndRecordsNothing(t *testing.T) { |
| 1410 | d := &fakeDeliverer{status: 204} |
| 1411 | reg, r := runnerFixture(t, d) |
| 1412 | now := at("2026-10-20T17:27:04Z") |
| 1413 | r.Now = func() time.Time { return now } |
| 1414 | tokens := 0 |
| 1415 | r.Token = func(string) (string, error) { tokens++; return "tok", nil } |
| 1416 | r.Skip = func(rt registry.Route) bool { return registry.IsHold(rt.Status) } |
| 1417 | if err := reg.UpsertTimer(registry.Timer{Host: holdHost, Name: "every-minute", Kind: KindSchedule, Spec: "every 1m0s", Path: "/tick", NextAt: now.Unix() - 5}); err != nil { |
| 1418 | t.Fatal(err) |
| 1419 | } |
| 1420 | if err := reg.SetHold(holdHost, "moving", "mv_a"); err != nil { |
| 1421 | t.Fatal(err) |
| 1422 | } |
| 1423 | before := timersOf(t, reg, holdHost) |
| 1424 | r.tick() |
| 1425 | if tokens != 0 || len(d.calls) != 0 || !sameTimers(timersOf(t, reg, holdHost), before) || r.Busy(holdHost) { |
| 1426 | t.Fatalf("a skipped host: %d tokens, %d deliveries, timers %+v (were %+v)", tokens, len(d.calls), timersOf(t, reg, holdHost), before) |
| 1427 | } |
| 1428 | |
| 1429 | // The row reads ready at the tick's read; the hold lands before the claim. |
| 1430 | if ok, err := reg.ReleaseHold(holdHost, "mv_a"); err != nil || !ok { |
| 1431 | t.Fatalf("release hold = %v %v", ok, err) |
| 1432 | } |
| 1433 | r.beforeClaim = func(host string) { |
| 1434 | if err := reg.SetHold(host, "moving", "mv_b"); err != nil { |
| 1435 | t.Error(err) |
| 1436 | } |
| 1437 | } |
| 1438 | r.tick() |
| 1439 | if tokens != 0 || len(d.calls) != 0 || !sameTimers(timersOf(t, reg, holdHost), before) { |
| 1440 | t.Fatalf("a hold written before the fire: %d tokens, %d deliveries", tokens, len(d.calls)) |
| 1441 | } |
| 1442 | |
| 1443 | // Ready again, nothing skipped: the tick fires as it always has. |
| 1444 | if ok, err := reg.ReleaseHold(holdHost, "mv_b"); err != nil || !ok { |
| 1445 | t.Fatalf("release hold = %v %v", ok, err) |
| 1446 | } |
| 1447 | r.beforeClaim = nil |
| 1448 | r.tick() |
| 1449 | if tokens != 1 || len(d.calls) != 1 { |
| 1450 | t.Fatalf("a ready host: %d tokens, %d deliveries", tokens, len(d.calls)) |
| 1451 | } |
| 1452 | } |
| 1453 | |
| 1454 | // Astra's review of moves-offline-fixes, finding 1: with the hold wiring off |
| 1455 | // (Gate nil, as on an offline move's boxes), a host Skip names must not fill |
| 1456 | // the due batch. 64 earlier due rows of a skipped host, held or not, and one |
| 1457 | // later row of an ordinary host: the ordinary one fires on the first tick. |
| 1458 | func TestSkippedHostsDoNotStarveAnotherHostsTimer(t *testing.T) { |
| 1459 | for _, held := range []bool{true, false} { |
| 1460 | d := &fakeDeliverer{status: 204} |
| 1461 | reg, r := runnerFixture(t, d) |
| 1462 | now := at("2026-10-20T17:27:04Z") |
| 1463 | r.Now = func() time.Time { return now } |
| 1464 | const other = "b.bab.oncarlos.com" |
| 1465 | if err := reg.Upsert(registry.Route{Host: other, Kind: "instance", Account: "bab", App: "app", Status: "ready", Hibernate: true}); err != nil { |
| 1466 | t.Fatal(err) |
| 1467 | } |
| 1468 | if held { |
| 1469 | if err := reg.SetHold(holdHost, "moving", "mv_a"); err != nil { |
| 1470 | t.Fatal(err) |
| 1471 | } |
| 1472 | r.Skip = func(rt registry.Route) bool { return registry.IsHold(rt.Status) } |
| 1473 | } else { |
| 1474 | // Ready, but named elsewhere (an offline move's record names |
| 1475 | // another region): only Skip knows. |
| 1476 | r.Skip = func(rt registry.Route) bool { return rt.Host == holdHost } |
| 1477 | } |
| 1478 | for i := range dueBatch { |
| 1479 | if err := reg.UpsertTimer(registry.Timer{Host: holdHost, Name: fmt.Sprintf("t%02d", i), Kind: KindSchedule, Spec: "every 1m0s", Path: "/tick", NextAt: now.Unix() - 1000 + int64(i)}); err != nil { |
| 1480 | t.Fatal(err) |
| 1481 | } |
| 1482 | } |
| 1483 | if err := reg.UpsertTimer(registry.Timer{Host: other, Name: "later", Kind: KindSchedule, Spec: "every 1m0s", Path: "/tick", NextAt: now.Unix() - 5}); err != nil { |
| 1484 | t.Fatal(err) |
| 1485 | } |
| 1486 | if r.Gate != nil { |
| 1487 | t.Fatal("fixture: the hold wiring is armed") |
| 1488 | } |
| 1489 | r.tick() |
| 1490 | if len(d.calls) != 1 || d.hosts[0] != other { |
| 1491 | t.Fatalf("held %v: deliveries to %v, want exactly one to %s", held, d.hosts, other) |
| 1492 | } |
| 1493 | } |
| 1494 | } |
| 1495 | |