Scheduled ticks must not hold an instance awake (#371)
* spec: scheduled ticks must not hold an instance awake A 5-minute schedule on a 15-minute idle window means the instance never hibernates: the tick goes through ServeHTTP like a visitor, so every fire stamps lastActive and buys another full window for a 1ms 204. Found on the flagship, where jam.bac has been resident continuously since its schedule landed. Three parts: a non-stamping waking entry point for the deliverer, a guard so the fix doesn't trade one resident process for 288 wake-path traversals a day, and a holds_awake field so the member can see it. Deliberately inert on the flagship as configured — the behaviour change arrives with the separate 15m -> 90s idle-window branch. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NbP6qc2gf7BFZkQ8QLcm4C * spec: record why the guard must not read routes.next_due That column is written only at sleep and left stale on purpose while a host is awake, so a guard reading it would compare against a past timestamp and never sleep again. Looks like free coverage; is a wedge. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NbP6qc2gf7BFZkQ8QLcm4C * plan: scheduled ticks and hibernation, seven tasks Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NbP6qc2gf7BFZkQ8QLcm4C * plan: correct test scaffolding to the helpers that actually exist Pre-flight scan against the real test files: openTestRegistry not testRegistry, testActivator/testRoute/fakeClock/waitFor not invented fixtures, wakeFixture in internal/host, renderScheduleInstance in the CLI. Task 3 also picks up the one existing test the guard changes — the wake story's frozen 2026-08-23 clock makes every row permanently overdue against the activator's real one, which the guard would read as 'a tick is imminent, stay awake'. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NbP6qc2gf7BFZkQ8QLcm4C * registry: EarliestTimers, one query for the hibernation guard * activator: a scheduled tick wakes and holds, but earns no activity credit * activator: don't hibernate into a tick due inside the idle window * activator: guard falls through to convergence; fix imminent-tick test to cover a future tick Fix round 1: the guard's continue was skipping the build-convergence check, so a host held awake by a tight schedule would serve a stale binary forever after a ship (Bounce only fires on config change). Fall through instead — a tick-held host never reaches a next sleep, so it is in the same always-awake class the convergence arm already covers for sidecars. Add TestSweepHeldForTickStillConverges to pin it. Also fix TestSweepKeepsHostAwakeWhenATickIsImminent, which armed the timer 5 minutes in the past relative to sweep time (an overdue tick, already covered by TestSweepKeepsHostAwakeForAnOverdueTick) rather than a genuinely future one inside the window. Arm 21 minutes out before advancing the clock 16 minutes so the tick is 5 minutes in the future at sweep time. Update the spec and plan doc paragraphs that carried the overruled 'converges at its next genuine sleep' reasoning. * host: schedule ticks deliver through the non-stamping activator door * schedule: report when a schedule's interval holds its instance awake * schedule: gate holds_awake on the route's own hibernate flag, not just the box's idle window * cli: schedule ls says when a schedule holds its instance awake * docs: correct four claims the branch outgrew ---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
16 files changed,
+2040
−30
docs/superpowers/plans/2026-08-31-scheduled-ticks-and-hibernation.md+1029 −0docs/superpowers/specs/2026-08-30-scheduled-ticks-and-hibernation-design.md+315 −0internal/activator/activator.go+77 −11internal/activator/activator_test.go+253 −0internal/host/schedule_wake_test.go+74 −1internal/host/schedule_wiring.go+25 −6internal/registry/timers.go+27 −0internal/registry/timers_test.go+39 −0internal/schedule/control.go+11 −5internal/schedule/converge.go+61 −6internal/schedule/converge_test.go+56 −0internal/schedule/runner.go+2 −1internal/schedule/status.go+7 −0internal/schedule/status_test.go+14 −0schedule.go+7 −0schedule_test.go+43 −0
diff --git a/docs/superpowers/plans/2026-08-31-scheduled-ticks-and-hibernation.md b/docs/superpowers/plans/2026-08-31-scheduled-ticks-and-hibernation.md| new file mode 100644 |
| index 000000000..dd80b082c |
| --- /dev/null |
| +++ b/docs/superpowers/plans/2026-08-31-scheduled-ticks-and-hibernation.md |
| @@ -0,0 +1,1029 @@ |
| +# Scheduled ticks and hibernation — Implementation Plan |
| + |
| +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. |
| + |
| +**Goal:** Stop a platform-delivered scheduled tick from resetting an instance's idle clock, without trading one resident process for hundreds of wake-path traversals a day. |
| + |
| +**Architecture:** Three independent pieces. (1) The activator gains a non-stamping sibling of `ServeHTTP` that the schedule deliverer calls instead, so a tick wakes and holds the instance but never credits it as activity. (2) `sweepOnce` gains a guard that refuses to hibernate a host whose next platform timer is due sooner than the idle window, fed by one new pre-lock registry query. (3) The box computes whether a schedule's interval keeps its instance resident and reports it, so `carlos schedule ls` can say so. |
| + |
| +**Tech Stack:** Go 1.x, SQLite (`internal/registry`), no new dependencies. |
| + |
| +**Spec:** `docs/superpowers/specs/2026-08-30-scheduled-ticks-and-hibernation-design.md` |
| + |
| +## Global Constraints |
| + |
| +- **This change must be inert on the flagship as configured.** Idle is 15m, the tightest schedule on the fleet is 5m, and the guard's answer at those numbers is "stay awake". Any test or manual check that shows `jam.bac.oncarlos.com` beginning to hibernate on this branch is a bug, not a win. (Spec §4.) |
| +- **Wire shapes are additive.** A `schedule-status/<host>.json` written before this change must still parse. Never remove or retype an existing field. (AGENTS.md, "Rules this repo has paid for".) |
| +- **Build gate before any commit lands:** `go build ./...`, `go vet ./...`, `go test ./... -count=1`, `gofmt -l .` clean over the whole tree. `make test` covers the last three. |
| +- **Run Go commands with `TMPDIR=/tmp`**, or three unrelated socket tests fail with phantom errors (they fail on untouched `main` too). Sandbox note from AGENTS.md: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off`. |
| +- **CI is the `amadan` remote, not GitHub.** A branch pushed only to `origin` has been gated by nothing. Push to `amadan` and read the run before calling it green. |
| +- **`MinEvery = time.Minute`** (`internal/schedule/spec.go:20`) — the tightest schedule anyone can declare. Worst case for any calculation here is 1,440 ticks a day. |
| +- **Nothing in this plan adds a registry column, a tuning constant, a wire marker, or a `NextDue` change.** All four were considered and rejected in the spec (§2, §3.1, §5). |
| + |
| +--- |
| + |
| +### Task 1: `EarliestTimers` — one query the guard can afford |
| + |
| +The guard needs "when is the next platform tick due, per host" as a single read it can do *before* taking the activator's mutex. `sweepOnce` already establishes that pattern for `DueInstances` and says why: "it's a registry read, and nothing under a.mu should wait on SQLite" (`internal/activator/activator.go:911`). |
| + |
| +**Files:** |
| +- Modify: `internal/registry/timers.go` (append after `DueTimers`) |
| +- Test: `internal/registry/timers_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: nothing from earlier tasks. |
| +- Produces: `func (r *Registry) EarliestTimers() (map[string]int64, error)` — host → the smallest `next_at > 0` among that host's timer rows. Hosts with no armed rows are absent from the map. Task 3 consumes it. |
| + |
| +- [ ] **Step 1: Write the failing test** |
| + |
| +In `internal/registry/timers_test.go`, following the file's existing setup helper for a temp registry: |
| + |
| +```go |
| +func TestEarliestTimersPerHost(t *testing.T) { |
| + r := openTestRegistry(t) |
| + rows := []Timer{ |
| + // Two rows for one host: the earliest wins. |
| + {Host: "a.example", Name: "late", Kind: "schedule", Spec: "every 1h", Path: "/x", NextAt: 5000, DueAt: 5000}, |
| + {Host: "a.example", Name: "soon", Kind: "schedule", Spec: "every 5m", Path: "/y", NextAt: 1200, DueAt: 1200}, |
| + // A second host is reported independently. |
| + {Host: "b.example", Name: "only", Kind: "schedule", Spec: "every 5m", Path: "/z", NextAt: 3000, DueAt: 3000}, |
| + // next_at == 0 means "not armed" and must never appear. |
| + {Host: "c.example", Name: "parked", Kind: "schedule", Spec: "every 5m", Path: "/p", NextAt: 0, DueAt: 0}, |
| + } |
| + for _, tm := range rows { |
| + if err := r.UpsertTimer(tm); err != nil { |
| + t.Fatalf("UpsertTimer %s/%s: %v", tm.Host, tm.Name, err) |
| + } |
| + } |
| + |
| + got, err := r.EarliestTimers() |
| + if err != nil { |
| + t.Fatalf("EarliestTimers: %v", err) |
| + } |
| + want := map[string]int64{"a.example": 1200, "b.example": 3000} |
| + if !reflect.DeepEqual(got, want) { |
| + t.Errorf("EarliestTimers = %v, want %v", got, want) |
| + } |
| +} |
| + |
| +func TestEarliestTimersEmpty(t *testing.T) { |
| + r := openTestRegistry(t) |
| + got, err := r.EarliestTimers() |
| + if err != nil { |
| + t.Fatalf("EarliestTimers: %v", err) |
| + } |
| + if len(got) != 0 { |
| + t.Errorf("EarliestTimers on an empty table = %v, want empty", got) |
| + } |
| +} |
| +``` |
| + |
| +`openTestRegistry(t) *Registry` is the package's existing helper (`registry_test.go:469`) — verified, use it. There is no `must` helper in this package; the explicit error check above is the house style. `reflect` must be in the test file's imports. |
| + |
| +- [ ] **Step 2: Run the test and watch it fail** |
| + |
| +Run: `TMPDIR=/tmp go test ./internal/registry -run TestEarliestTimers -count=1 -v` |
| +Expected: FAIL — `r.EarliestTimers undefined (type *Registry has no field or method EarliestTimers)`. |
| + |
| +- [ ] **Step 3: Implement it** |
| + |
| +Append to `internal/registry/timers.go`: |
| + |
| +```go |
| +// EarliestTimers is the hibernation guard's batch: for every host with at |
| +// least one armed row, the soonest next_at among them. One query, because |
| +// the activator's sweep reads it BEFORE taking its mutex (activator.go's |
| +// "nothing under a.mu should wait on SQLite") and a per-entry query inside |
| +// the sweep loop would put SQLite exactly where that comment forbids it. |
| +// |
| +// next_at = 0 is "not armed" — a parked cron, or a row converge wrote and |
| +// the runner has not yet dated — and is excluded here rather than by the |
| +// caller, matching DueTimers' own `next_at > 0` gate. |
| +func (r *Registry) EarliestTimers() (map[string]int64, error) { |
| + rows, err := r.sql.Query(`SELECT host, MIN(next_at) FROM timers WHERE next_at > 0 GROUP BY host`) |
| + if err != nil { |
| + return nil, err |
| + } |
| + defer rows.Close() |
| + out := make(map[string]int64) |
| + for rows.Next() { |
| + var host string |
| + var next int64 |
| + if err := rows.Scan(&host, &next); err != nil { |
| + return nil, err |
| + } |
| + out[host] = next |
| + } |
| + return out, rows.Err() |
| +} |
| +``` |
| + |
| +- [ ] **Step 4: Run the test and watch it pass** |
| + |
| +Run: `TMPDIR=/tmp go test ./internal/registry -run TestEarliestTimers -count=1 -v` |
| +Expected: PASS, both tests. |
| + |
| +- [ ] **Step 5: Full package gate, then commit** |
| + |
| +```bash |
| +TMPDIR=/tmp go test ./internal/registry -count=1 |
| +gofmt -l internal/registry |
| +git add internal/registry/timers.go internal/registry/timers_test.go |
| +git commit -m "registry: EarliestTimers, one query for the hibernation guard" |
| +``` |
| + |
| +--- |
| + |
| +### Task 2: A tick that wakes but does not count |
| + |
| +`activatorDeliverer.Deliver` reaches the instance through `Service.ServeHTTP` (`internal/host/schedule_wiring.go:330`), the same door the edge uses, so `beginRequest`'s release stamps `lastActive` (`internal/activator/activator.go:594`) and every tick buys another full idle window. |
| + |
| +The fix mirrors `BeginSidecarRequest` (`activator.go:526`) — activity-invisible traffic — and differs in one way: a tick *wakes* a sleeping instance where a sidecar request is refused. |
| + |
| +**Files:** |
| +- Modify: `internal/activator/activator.go` (`beginRequest`, `releaseFunc`, `ServeHTTP`) |
| +- Test: `internal/activator/activator_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: nothing from earlier tasks. |
| +- Produces: |
| + - `func (a *Service) ServeScheduled(w http.ResponseWriter, r *http.Request, rt registry.Route, proxy http.Handler) bool` — same contract as `ServeHTTP` (returns false for a route it does not manage) but the request earns no activity credit. Task 4 consumes it. |
| + - Unexported: `func (a *Service) begin(ctx context.Context, rt registry.Route, stamp bool) (release func(), err error)` and `func (a *Service) serve(w http.ResponseWriter, r *http.Request, rt registry.Route, proxy http.Handler, stamp bool) bool`. |
| + |
| +- [ ] **Step 1: Write the failing test** |
| + |
| +In `internal/activator/activator_test.go`, using whatever harness the existing wake/sleep tests use to build a `*Service` with a fake backend and a controllable clock: |
| + |
| +```go |
| +// A scheduled tick must not move lastActive: it is the platform's own clock |
| +// poking the instance, not a visitor. A tick that counted would make any |
| +// schedule tighter than the idle window a permanent keep-awake. |
| +func TestServeScheduledDoesNotStampActivity(t *testing.T) { |
| + const host = "tick.bab.oncarlos.com" |
| + a, _, clk, _ := testActivator(t, time.Hour) |
| + rt := testRoute(host) |
| + arrangeAwake(t, a, rt) // a real beginRequest round trip, then release |
| + |
| + a.mu.Lock() |
| + before := a.entries[rt.Host].lastActive |
| + a.mu.Unlock() |
| + |
| + clk.advance(time.Minute) |
| + |
| + rec := httptest.NewRecorder() |
| + req := httptest.NewRequest("POST", "http://carlos/jobs/sweep", nil) |
| + if !a.ServeScheduled(rec, req, rt, okHandler()) { |
| + t.Fatal("ServeScheduled returned false for a managed route") |
| + } |
| + |
| + a.mu.Lock() |
| + after := a.entries[rt.Host].lastActive |
| + inflight := a.entries[rt.Host].inflight |
| + a.mu.Unlock() |
| + |
| + if !after.Equal(before) { |
| + t.Errorf("lastActive moved %v -> %v; a scheduled tick must not count as activity", before, after) |
| + } |
| + if inflight != 0 { |
| + t.Errorf("inflight = %d after delivery, want 0 (the hold is released even though the stamp is not written)", inflight) |
| + } |
| +} |
| + |
| +// The other half of the same coin, asserted against the first so the two |
| +// paths can never quietly become one again — which is the bug this fixes. |
| +func TestServeHTTPStillStampsActivity(t *testing.T) { |
| + const host = "visitor.bab.oncarlos.com" |
| + a, _, clk, _ := testActivator(t, time.Hour) |
| + rt := testRoute(host) |
| + arrangeAwake(t, a, rt) |
| + |
| + a.mu.Lock() |
| + before := a.entries[rt.Host].lastActive |
| + a.mu.Unlock() |
| + |
| + clk.advance(time.Minute) |
| + |
| + rec := httptest.NewRecorder() |
| + req := httptest.NewRequest("GET", "http://carlos/", nil) |
| + if !a.ServeHTTP(rec, req, rt, okHandler()) { |
| + t.Fatal("ServeHTTP returned false for a managed route") |
| + } |
| + |
| + a.mu.Lock() |
| + after := a.entries[rt.Host].lastActive |
| + a.mu.Unlock() |
| + |
| + if !after.After(before) { |
| + t.Errorf("lastActive did not move on a visitor request (%v -> %v)", before, after) |
| + } |
| +} |
| + |
| +// A tick is a wake source and must stay one: an instance asleep when its |
| +// timer comes due has to be woken by the delivery itself. |
| +func TestServeScheduledWakesASleepingInstance(t *testing.T) { |
| + const host = "asleep.bab.oncarlos.com" |
| + a, _, _, _ := testActivator(t, time.Hour) // no entries yet: the host is asleep |
| + rt := testRoute(host) |
| + |
| + rec := httptest.NewRecorder() |
| + req := httptest.NewRequest("POST", "http://carlos/jobs/sweep", nil) |
| + if !a.ServeScheduled(rec, req, rt, okHandler()) { |
| + t.Fatal("ServeScheduled returned false for a managed route") |
| + } |
| + if rec.Code != http.StatusOK { |
| + t.Fatalf("status %d, want 200 — the tick must wake the instance, not be refused", rec.Code) |
| + } |
| + a.mu.Lock() |
| + e := a.entries[rt.Host] |
| + a.mu.Unlock() |
| + if e == nil || e.state != actAwake { |
| + t.Fatal("instance is not awake after a scheduled delivery") |
| + } |
| +} |
| +``` |
| + |
| +**The helpers above are placeholders. These are the real ones, verified in `internal/activator/activator_test.go` — use them:** |
| + |
| +- `testActivator(t *testing.T, idle time.Duration) (*Service, *fakeBackend, *fakeClock, *registry.Registry)` (line 350) — builds the Service over the fake backend with `a.now = clk.now` already wired. |
| +- `testRoute(host string) registry.Route` (line 363) — a hibernate-flagged instance route. |
| +- `clk.advance(d)` (line 339) — moves the fake clock. |
| +- `waitFor(t, what string, cond func() bool)` (line 387) — poll for an async stop; `beginStopLocked` runs on its own goroutine, so never assert hibernation synchronously. |
| + |
| +To arrange an awake entry, do what `newTestServiceAwake` (line 375) does: a real `beginRequest` round trip, then `release()`. For the proxy argument, pass an inline `http.HandlerFunc` that writes the status you want to assert — do not dial `testRoute`'s placeholder socket. |
| + |
| +- [ ] **Step 2: Run the tests and watch them fail** |
| + |
| +Run: `TMPDIR=/tmp go test ./internal/activator -run 'TestServeScheduled|TestServeHTTPStillStamps' -count=1 -v` |
| +Expected: FAIL — `a.ServeScheduled undefined`. |
| + |
| +- [ ] **Step 3: Thread `stamp` through the three functions** |
| + |
| +In `internal/activator/activator.go`, rename `beginRequest` to `begin` and give it the flag. Keep a `beginRequest` wrapper so no other call site changes: |
| + |
| +```go |
| +// begin registers a request against rt's instance, waking it if asleep. |
| +// |
| +// stamp is what separates a visitor from the platform's own clock. Both take |
| +// the in-flight count — a delivery in progress must never be cut off by a |
| +// concurrent sweep — but only a visitor's release moves lastActive. A |
| +// scheduled tick that stamped would reset the idle window on every fire, so |
| +// any schedule tighter than that window would pin its instance awake forever |
| +// for however little work the handler does. |
| +func (a *Service) begin(ctx context.Context, rt registry.Route, stamp bool) (release func(), err error) { |
| + // ... body unchanged from beginRequest, except the actAwake arm: |
| + // return a.releaseFunc(e, stamp), nil |
| +} |
| + |
| +// beginRequest is begin for member traffic: activity counts. |
| +func (a *Service) beginRequest(ctx context.Context, rt registry.Route) (release func(), err error) { |
| + return a.begin(ctx, rt, true) |
| +} |
| +``` |
| + |
| +`releaseFunc` takes the flag and guards only the stamp — the decrement is unconditional: |
| + |
| +```go |
| +func (a *Service) releaseFunc(e *actEntry, stamp bool) func() { |
| + var once sync.Once |
| + return func() { |
| + once.Do(func() { |
| + a.mu.Lock() |
| + e.inflight-- |
| + if stamp { |
| + e.lastActive = a.now() |
| + } |
| + a.mu.Unlock() |
| + }) |
| + } |
| +} |
| +``` |
| + |
| +Then split `ServeHTTP`'s body into `serve` and give it two doors. Move the entire existing `ServeHTTP` body into `serve`, changing exactly one line — `a.beginRequest(r.Context(), rt)` becomes `a.begin(r.Context(), rt, stamp)`: |
| + |
| +```go |
| +func (a *Service) ServeHTTP(w http.ResponseWriter, r *http.Request, rt registry.Route, proxy http.Handler) bool { |
| + return a.serve(w, r, rt, proxy, true) |
| +} |
| + |
| +// ServeScheduled is ServeHTTP for a platform-delivered scheduled tick: same |
| +// wake, same in-flight hold, same error mapping — no activity credit. |
| +// |
| +// A dedicated method rather than a wire marker like SidecarHeader, and the |
| +// difference is the delivery path, not taste. The sidecar dials its |
| +// instance's public URL, so its traffic arrives through the edge and the |
| +// request is the only place a signal could ride. A tick never leaves the |
| +// process (schedule_wiring.go: "the tick never leaves the box"), so the call |
| +// site IS the signal, and there is no header for an outsider to send. |
| +func (a *Service) ServeScheduled(w http.ResponseWriter, r *http.Request, rt registry.Route, proxy http.Handler) bool { |
| + return a.serve(w, r, rt, proxy, false) |
| +} |
| + |
| +func (a *Service) serve(w http.ResponseWriter, r *http.Request, rt registry.Route, proxy http.Handler, stamp bool) bool { |
| + // ... today's ServeHTTP body verbatim, with the one call swapped. |
| +} |
| +``` |
| + |
| +Leave the sidecar branch inside `serve` exactly as it is. A tick cannot carry `SidecarHeader` (nothing sets it on the delivery request), so the branch is unreachable from `ServeScheduled` and costs nothing to keep in one place. |
| + |
| +- [ ] **Step 4: Run the tests and watch them pass** |
| + |
| +Run: `TMPDIR=/tmp go test ./internal/activator -run 'TestServeScheduled|TestServeHTTPStillStamps' -count=1 -v` |
| +Expected: PASS, all three. |
| + |
| +- [ ] **Step 5: Full package gate, then commit** |
| + |
| +The whole activator suite matters here — `begin` and `releaseFunc` are on every request path. |
| + |
| +```bash |
| +TMPDIR=/tmp go test ./internal/activator -count=1 |
| +gofmt -l internal/activator |
| +git add internal/activator/activator.go internal/activator/activator_test.go |
| +git commit -m "activator: a scheduled tick wakes and holds, but earns no activity credit" |
| +``` |
| + |
| +--- |
| + |
| +### Task 3: The guard — don't sleep into an imminent tick |
| + |
| +Task 2 alone would turn a 5-minute schedule into 288 wake/sleep cycles a day, each a full SQLite checkpoint-and-restore traversal, on the path where #200 records an unclosed integrity gap. The guard is what makes Task 2 safe. |
| + |
| +**Threshold is `cfg.Idle` itself** — no new constant (spec §3.2, Paul's ruling). If the gap to the next tick is shorter than the window we would have stayed awake for anyway, staying awake *is* the cheaper answer. |
| + |
| +**Files:** |
| +- Modify: `internal/activator/activator.go` (`DueStore` interface, `sweepOnce`) |
| +- Test: `internal/activator/activator_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: `(*registry.Registry).EarliestTimers() (map[string]int64, error)` from Task 1. |
| +- Produces: `DueStore` gains `EarliestTimers() (map[string]int64, error)`. Any test fake implementing `DueStore` must grow the method. |
| + |
| +- [ ] **Step 1: Write the failing test** |
| + |
| +```go |
| +// The guard: a host whose next platform tick is nearer than the idle window |
| +// stays resident. Sleeping would buy a few minutes of absence at the price of |
| +// a full checkpoint-and-restore round trip, and then an immediate wake. |
| +func TestSweepKeepsHostAwakeWhenATickIsImminent(t *testing.T) { |
| + const host = "imminent.bab.oncarlos.com" |
| + a, _, clk, reg := testActivator(t, 15*time.Minute) |
| + rt := testRoute(host) |
| + arrangeAwake(t, a, rt) |
| + |
| + // Next tick in 5 minutes: inside the window. |
| + armTimer(t, reg, host, clk.now().Add(5*time.Minute)) |
| + clk.advance(16 * time.Minute) // lastActive is now well past the window |
| + |
| + a.sweepOnce() |
| + |
| + a.mu.Lock() |
| + e := a.entries[rt.Host] |
| + a.mu.Unlock() |
| + if e == nil || e.state != actAwake { |
| + t.Fatal("host hibernated with a tick due inside the idle window") |
| + } |
| +} |
| + |
| +// Beyond the window, the sleep is worth taking. |
| +func TestSweepHibernatesWhenTheNextTickIsFarOff(t *testing.T) { |
| + const host = "faroff.bab.oncarlos.com" |
| + a, _, clk, reg := testActivator(t, 15*time.Minute) |
| + rt := testRoute(host) |
| + arrangeAwake(t, a, rt) |
| + |
| + // Next tick in an hour: well beyond the window. |
| + armTimer(t, reg, host, clk.now().Add(time.Hour)) |
| + clk.advance(16 * time.Minute) |
| + |
| + a.sweepOnce() |
| + |
| + waitFor(t, "the host to hibernate with its next tick an hour away", func() bool { |
| + a.mu.Lock() |
| + defer a.mu.Unlock() |
| + return a.entries[rt.Host] == nil |
| + }) |
| +} |
| + |
| +// A host with no timer rows is untouched by the guard — the overwhelming |
| +// majority of instances, and the case that must not regress. |
| +func TestSweepHibernatesAHostWithNoTimers(t *testing.T) { |
| + const host = "untimed.bab.oncarlos.com" |
| + a, _, clk, _ := testActivator(t, 15*time.Minute) // no timer rows at all |
| + rt := testRoute(host) |
| + arrangeAwake(t, a, rt) |
| + clk.advance(16 * time.Minute) |
| + |
| + a.sweepOnce() |
| + |
| + waitFor(t, "a host with no timers to hibernate as it always did", func() bool { |
| + a.mu.Lock() |
| + defer a.mu.Unlock() |
| + return a.entries[rt.Host] == nil |
| + }) |
| +} |
| + |
| +// An overdue row (next_at already in the past — the runner has not got to it |
| +// yet) is the most imminent tick there is, so it holds the host too. |
| +func TestSweepKeepsHostAwakeForAnOverdueTick(t *testing.T) { |
| + const host = "overdue.bab.oncarlos.com" |
| + a, _, clk, reg := testActivator(t, 15*time.Minute) |
| + rt := testRoute(host) |
| + arrangeAwake(t, a, rt) |
| + |
| + // next_at already behind us: the runner will fire it within 15s. |
| + armTimer(t, reg, host, clk.now().Add(-time.Minute)) |
| + clk.advance(16 * time.Minute) |
| + |
| + a.sweepOnce() |
| + |
| + a.mu.Lock() |
| + e := a.entries[rt.Host] |
| + a.mu.Unlock() |
| + if e == nil || e.state != actAwake { |
| + t.Fatal("host hibernated with an overdue tick pending") |
| + } |
| +} |
| +``` |
| + |
| +Write these two helpers once in `activator_test.go` and use them from all four tests (and from Task 2's, for `arrangeAwake`): |
| + |
| +```go |
| +// arrangeAwake drives rt's host to actAwake the way the rest of this file |
| +// does — a real beginRequest round trip — so lastActive is stamped and the |
| +// entry exists. |
| +func arrangeAwake(t *testing.T, a *Service, rt registry.Route) { |
| + t.Helper() |
| + release, err := a.beginRequest(context.Background(), rt) |
| + if err != nil { |
| + t.Fatalf("beginRequest %s: %v", rt.Host, err) |
| + } |
| + release() |
| +} |
| + |
| +// armTimer puts one armed schedule row on host, due at next. |
| +func armTimer(t *testing.T, reg *registry.Registry, host string, next time.Time) { |
| + t.Helper() |
| + if err := reg.UpsertTimer(registry.Timer{ |
| + Host: host, Name: "sweep", Kind: "schedule", Spec: "every 5m", |
| + Path: "/jobs/sweep", NextAt: next.Unix(), DueAt: next.Unix(), |
| + }); err != nil { |
| + t.Fatalf("UpsertTimer %s: %v", host, err) |
| + } |
| +} |
| +``` |
| + |
| +`okHandler()` is the same idea for the proxy argument — an `http.Handler` writing 200 — and belongs beside them. |
| + |
| +**There is no `DueStore` fake in this package, and you do not need one.** `testActivator` (line 350) already wires a real `*registry.Registry` as the activator's `DueStore` and returns it. Insert real rows with `reg.UpsertTimer(registry.Timer{...})` and let the guard read them through the `EarliestTimers` query Task 1 added — that makes this test a genuine integration of the two halves rather than a mock of one. Arrange the awake entry as in Task 2, use `clk.advance` to push past the idle window, and assert hibernation with `waitFor` (the stop path is asynchronous). |
| + |
| +- [ ] **Step 2: Run the tests and watch them fail** |
| + |
| +Run: `TMPDIR=/tmp go test ./internal/activator -run TestSweep -count=1 -v` |
| +Expected: FAIL — the fake does not satisfy `DueStore`, or the guard tests hibernate when they should not. |
| + |
| +- [ ] **Step 3: Extend the interface and the sweep** |
| + |
| +In `internal/activator/activator.go`, add to `DueStore` (around line 173): |
| + |
| +```go |
| +type DueStore interface { |
| + SetNextDue(host string, due int64) error |
| + DueInstances(now int64) ([]registry.Route, error) |
| + // EarliestTimers is the hibernation guard's input: host -> soonest armed |
| + // timer. Read with DueInstances, before the sweep takes a.mu. |
| + EarliestTimers() (map[string]int64, error) |
| +} |
| +``` |
| + |
| +In `sweepOnce`, join the existing pre-lock read (immediately after the `DueInstances` call, still above `a.mu.Lock()`): |
| + |
| +```go |
| + // The guard's input, read here for the same reason the due query is: |
| + // nothing under a.mu may wait on SQLite. A failure degrades to "no |
| + // timers", i.e. today's behaviour — a guard that cannot read must not |
| + // pin every instance on the box awake. |
| + earliest, err := a.due.EarliestTimers() |
| + if err != nil { |
| + log.Printf("activator: earliest-timer sweep: %v — hibernating without the schedule guard", err) |
| + earliest = nil |
| + } |
| +``` |
| + |
| +Then in the hibernate arm, replacing the existing `if e.inflight == 0 && ...` block: |
| + |
| +```go |
| + if e.inflight == 0 && now.Sub(e.lastActive) >= a.cfg.Idle { |
| + // Don't sleep into an imminent tick. A wake/sleep pair is a full |
| + // checkpoint-and-restore round trip; spending one to be absent |
| + // for less time than we would have idled anyway is a straight |
| + // loss. The threshold is the idle window itself rather than a |
| + // constant of its own: it is already the deployment's stated |
| + // opinion about how long residency is worth holding. |
| + if next, ok := earliest[e.host]; ok && time.Unix(next, 0).Sub(now) <= a.cfg.Idle { |
| + continue |
| + } |
| + log.Printf("activator: %s idle %s — hibernating", e.host, now.Sub(e.lastActive).Truncate(time.Second)) |
| + a.beginStopLocked(e) |
| + continue |
| + } |
| +``` |
| + |
| +**(overruled — Paul, review round 1, 2026-08-31)** The snippet above and the |
| +following paragraph originally had the guard's `continue` skip the |
| +convergence check below it, on the theory that a host held awake for a tick |
| +converges at its "next genuine sleep." That reasoning is wrong: a tick-held |
| +host, by construction, never reaches a next sleep — that is precisely what |
| +the guard guarantees — so it would serve a stale build forever after a |
| +`carlos ship`, with nothing else to close the gap (`Bounce` only fires on |
| +config change). The fix is to let the guard's decision fall through to the |
| +convergence check instead of skipping it: |
| + |
| +```go |
| + if e.inflight == 0 && now.Sub(e.lastActive) >= a.cfg.Idle { |
| + next, ok := earliest[e.host] |
| + heldForTick := ok && time.Unix(next, 0).Sub(now) <= a.cfg.Idle |
| + if !heldForTick { |
| + log.Printf("activator: %s idle %s — hibernating", e.host, now.Sub(e.lastActive).Truncate(time.Second)) |
| + a.beginStopLocked(e) |
| + continue |
| + } |
| + // Fall through to the convergence check below: a host held awake |
| + // for its schedule never reaches a "next wake" to converge on, so |
| + // it is now one of the always-awake hosts that arm was written |
| + // for — the same class as a sidecar host that never sleeps. |
| + } |
| +``` |
| + |
| +A tick-held host is, in other words, exactly the class the convergence arm's |
| +own comment already names: "the always-awake ones — notably every sidecar |
| +host, which never sleeps." It now reaches that arm on every sweep, the same |
| +as a sidecar, so a ship behind a tick-held host converges within one grace |
| +window rather than never. |
| + |
| +- [ ] **Step 4: Amend the one existing test the guard changes** |
| + |
| +`internal/host/schedule_wake_test.go`'s `TestScheduledTickWakesASleepingInstanceAndItHibernatesAgain` freezes the Runner's clock at `time.Date(2026, 8, 23, 13, 7, 0, 0, time.UTC)` while the activator in `wakeFixture` runs on real wall time. After the tick fires, `MarkTimerFired` advances the row to the next 6h occurrence *after that frozen clock* — a timestamp days in the past relative to the activator. The new guard reads that as "a tick is overdue, stay awake", and the test's step 6 ("the sweep hibernates it") would spin to its 30s deadline and fail. |
| + |
| +The guard is right and the fixture is not: in production an overdue row is the most imminent tick there is, because the runner fires it within 15s. So fix the clock, not the guard. Change the one line: |
| + |
| +```go |
| + now := time.Now().UTC() |
| +``` |
| + |
| +Every other assertion still holds — `occurrence` stays `now.Unix() - 5`, so the `X-Carlos-Schedule-At` check is unaffected, and step 5's `tm.NextAt > now.Unix()` still holds because the next 6h boundary after a real now is still ahead of it. |
| + |
| +Do not change anything else in that file; Task 4 owns it next. |
| + |
| +- [ ] **Step 5: Run the tests and watch them pass** |
| + |
| +Run: `TMPDIR=/tmp go test ./internal/activator -run TestSweep -count=1 -v` |
| +Expected: PASS, all four. |
| +Run: `TMPDIR=/tmp go test ./internal/host -run TestScheduledTickWakes -count=1 -v` |
| +Expected: PASS — the amended test still proves the whole wake story. |
| + |
| +- [ ] **Step 6: Full package gate, then commit** |
| + |
| +```bash |
| +TMPDIR=/tmp go test ./internal/activator ./internal/host -count=1 |
| +gofmt -l internal/activator internal/host |
| +git add internal/activator/activator.go internal/activator/activator_test.go internal/host/schedule_wake_test.go |
| +git commit -m "activator: don't hibernate into a tick due inside the idle window" |
| +``` |
| + |
| +--- |
| + |
| +### Task 4: Point the deliverer at the new door |
| + |
| +**Files:** |
| +- Modify: `internal/host/schedule_wiring.go:326-333` (the handler inside `Deliver`) |
| +- Test: `internal/host/schedule_wake_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: `(*activator.Service).ServeScheduled` from Task 2. |
| +- Produces: nothing new. |
| + |
| +- [ ] **Step 1: Write the failing test** |
| + |
| +In `internal/host/schedule_wake_test.go`. `lastActive` is unexported and lives in another package, so this test asserts the *behaviour* that flows from it rather than the field — which is the better test anyway, because it is the bug stated as an observation: |
| + |
| +```go |
| +// The bug, as a test: a schedule that fires more often than the idle window |
| +// used to pin its instance awake forever, because each tick stamped activity |
| +// and bought another full window. With the tick activity-invisible, the |
| +// instance sleeps between ticks and wakes again for the next one. |
| +// |
| +// No timer rows are armed here, deliberately: this exercises the delivery |
| +// leg alone. The sweep guard (which reads the timers table) would correctly |
| +// hold a host awake for a tick due this soon, and that is Task 3's test. |
| +func TestDeliveryDoesNotHoldTheInstanceAwake(t *testing.T) { |
| + const host = "ticky.bab.oncarlos.com" |
| + const idle = 300 * time.Millisecond |
| + reg, inst, svc, _ := wakeFixture(t, host, idle, func(w http.ResponseWriter, r *http.Request) { |
| + w.WriteHeader(http.StatusNoContent) |
| + }) |
| + rt, err := reg.Get(host) |
| + if err != nil { |
| + t.Fatalf("get route: %v", err) |
| + } |
| + d := &activatorDeliverer{svc: svc} |
| + |
| + // Tick twice per idle window for a few windows. Under the old accounting |
| + // the instance could never reach the sweep's threshold; now it does, |
| + // between ticks, and the next tick wakes it again. |
| + deadline := time.Now().Add(20 * time.Second) |
| + for inst.startCount() < 2 && time.Now().Before(deadline) { |
| + req := httptest.NewRequest(http.MethodPost, "http://carlos/jobs/sweep", nil) |
| + if _, err := d.Deliver(context.Background(), *rt, req); err != nil { |
| + t.Fatalf("Deliver: %v", err) |
| + } |
| + time.Sleep(idle / 2) |
| + } |
| + if n := inst.startCount(); n < 2 { |
| + t.Fatalf("the instance started %d time(s): it never hibernated between ticks, so the tick is still buying residency", n) |
| + } |
| +} |
| + |
| +// The app-facing half of the tick contract is untouched by any of this. |
| +func TestDeliveryStillCarriesScheduleAtHeader(t *testing.T) { |
| + const host = "hdr.bab.oncarlos.com" |
| + seen := make(chan string, 1) |
| + reg, _, svc, _ := wakeFixture(t, host, time.Hour, func(w http.ResponseWriter, r *http.Request) { |
| + select { |
| + case seen <- r.Header.Get("X-Carlos-Schedule-At"): |
| + default: |
| + } |
| + w.WriteHeader(http.StatusNoContent) |
| + }) |
| + rt, err := reg.Get(host) |
| + if err != nil { |
| + t.Fatalf("get route: %v", err) |
| + } |
| + d := &activatorDeliverer{svc: svc} |
| + |
| + req := httptest.NewRequest(http.MethodPost, "http://carlos/jobs/sweep", nil) |
| + req.Header.Set("X-Carlos-Schedule-At", "1788126300") |
| + status, err := d.Deliver(context.Background(), *rt, req) |
| + if err != nil { |
| + t.Fatalf("Deliver: %v", err) |
| + } |
| + if status != http.StatusNoContent { |
| + t.Fatalf("status = %d, want 204", status) |
| + } |
| + select { |
| + case got := <-seen: |
| + if got != "1788126300" { |
| + t.Errorf("handler saw X-Carlos-Schedule-At = %q, want it passed through unchanged", got) |
| + } |
| + case <-time.After(10 * time.Second): |
| + t.Fatal("the handler never ran") |
| + } |
| +} |
| +``` |
| + |
| +`wakeFixture(t, host, idle, handler) (*registry.Registry, *fakeInstance, *activator.Service, string)` (line 157) and `fakeInstance.startCount()` (line 108) are this file's existing helpers — verified, use them. `reg.Get(host)` returns `(*registry.Route, error)` (`internal/registry/registry.go:667`). |
| + |
| +- [ ] **Step 2: Run the tests and watch them fail** |
| + |
| +Run: `TMPDIR=/tmp go test ./internal/host -run 'TestDeliverer|TestDeliveryStill' -count=1 -v` |
| +Expected: FAIL on the first — activity is stamped, because `Deliver` still calls `ServeHTTP`. |
| + |
| +- [ ] **Step 3: Swap the call** |
| + |
| +In `internal/host/schedule_wiring.go`, inside `Deliver`'s handler: |
| + |
| +```go |
| + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + // ServeScheduled, not ServeHTTP: a tick wakes the instance and is |
| + // held in flight for its duration, but must not reset the idle |
| + // clock. Reports false for a route it does not manage (not an |
| + // instance, or not hibernate-flagged); that route is proxied |
| + // straight, the same call shape the edge itself uses. |
| + if d.svc == nil || !d.svc.ServeScheduled(w, r, rt, proxy) { |
| + proxy.ServeHTTP(w, r) |
| + } |
| + }) |
| +``` |
| + |
| +Then update the `activatorDeliverer` doc comment (`schedule_wiring.go:213-220`), whose first bullet now understates what happens — it should read that a hibernating instance wakes for its tick and is held awake for the tick's duration, **and that the tick alone does not keep it awake afterwards.** |
| + |
| +- [ ] **Step 4: Run the tests and watch them pass** |
| + |
| +Run: `TMPDIR=/tmp go test ./internal/host -run 'TestDeliverer|TestDeliveryStill' -count=1 -v` |
| +Expected: PASS, both. |
| + |
| +- [ ] **Step 5: Full package gate, then commit** |
| + |
| +```bash |
| +TMPDIR=/tmp go test ./internal/host -count=1 |
| +gofmt -l internal/host |
| +git add internal/host/schedule_wiring.go internal/host/schedule_wake_test.go |
| +git commit -m "host: schedule ticks deliver through the non-stamping activator door" |
| +``` |
| + |
| +--- |
| + |
| +### Task 5: `holds_awake` — let the box say what it knows |
| + |
| +The box is the only place that holds both numbers: the timer rows and the activator's idle window. So the box decides and reports; the CLI only renders. |
| + |
| +**Files:** |
| +- Modify: `internal/schedule/status.go:24` (add the field) |
| +- Modify: `internal/schedule/converge.go:313-334` (`StatusFor`, `toStatus`, plus a new `holdsAwake`) |
| +- Modify: `internal/schedule/converge.go` (`Converger` struct: an `Idle` field) and `internal/schedule/runner.go:53` (`Runner` struct: an `Idle` field) |
| +- Modify: `internal/host/schedule_wiring.go` (`newScheduleDeps`: pass the window through) |
| +- Modify: `internal/activator/activator.go` (expose the window) |
| +- Test: `internal/schedule/converge_test.go`, `internal/schedule/status_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: nothing from earlier tasks. |
| +- Produces: |
| + - `TimerStatus.HoldsAwake bool` with JSON tag `holds_awake`. Task 6 renders it. |
| + - `func (a *Service) IdleWindow() time.Duration`. |
| + - `StatusFor(reg *registry.Registry, rt registry.Route, box string, now time.Time, idle time.Duration) (Status, error)` — one added parameter. |
| + |
| +- [ ] **Step 1: Write the failing test** |
| + |
| +```go |
| +func TestHoldsAwakeWhenTheIntervalIsInsideTheIdleWindow(t *testing.T) { |
| + now := time.Unix(1788120000, 0).UTC() |
| + cases := []struct { |
| + name string |
| + kind string |
| + spec string |
| + idle time.Duration |
| + want bool |
| + }{ |
| + {"tighter than the window", KindSchedule, "every 5m", 15 * time.Minute, true}, |
| + {"exactly the window", KindSchedule, "every 15m", 15 * time.Minute, true}, |
| + {"wider than the window", KindSchedule, "every 1h", 15 * time.Minute, false}, |
| + {"tight, short window", KindSchedule, "every 5m", 90 * time.Second, false}, |
| + {"a once-timer never holds", KindOnce, "at 1788130000", 15 * time.Minute, false}, |
| + {"hibernation not armed", KindSchedule, "every 1m", 0, false}, |
| + {"hourly cron", KindSchedule, "cron 0 * * * *", 15 * time.Minute, false}, |
| + {"every-minute cron", KindSchedule, "cron * * * * *", 15 * time.Minute, true}, |
| + {"unparseable spec is not a claim", KindSchedule, "banana", 15 * time.Minute, false}, |
| + } |
| + for _, tc := range cases { |
| + t.Run(tc.name, func(t *testing.T) { |
| + got := holdsAwake(registry.Timer{Kind: tc.kind, Spec: tc.spec}, tc.idle, now) |
| + if got != tc.want { |
| + t.Errorf("holdsAwake(%q, idle=%s) = %v, want %v", tc.spec, tc.idle, got, tc.want) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// The wire shape is additive: an object written before this field existed |
| +// must still parse, and read back as false. |
| +func TestStatusWithoutHoldsAwakeStillParses(t *testing.T) { |
| + raw := []byte(`{"schema":1,"host":"h","box":"b","reported_at":"2026-08-30T21:40:05Z","timers":[{"name":"sweep","kind":"schedule","spec":"every 5m","path":"/jobs/sweep","next_at":1788126300}]}`) |
| + var st Status |
| + if err := json.Unmarshal(raw, &st); err != nil { |
| + t.Fatalf("unmarshal a pre-holds_awake status: %v", err) |
| + } |
| + if len(st.Timers) != 1 || st.Timers[0].HoldsAwake { |
| + t.Errorf("HoldsAwake = %v, want false for an object written before the field existed", st.Timers[0].HoldsAwake) |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run the tests and watch them fail** |
| + |
| +Run: `TMPDIR=/tmp go test ./internal/schedule -run 'TestHoldsAwake|TestStatusWithout' -count=1 -v` |
| +Expected: FAIL — `undefined: holdsAwake`, and `st.Timers[0].HoldsAwake` undefined. |
| + |
| +- [ ] **Step 3: Implement the field, the predicate and the plumbing** |
| + |
| +`internal/schedule/status.go`, appended to `TimerStatus` (additive, last field): |
| + |
| +```go |
| + // HoldsAwake is the box's warning: this row fires at least as often as |
| + // the deployment's idle window, so its instance never hibernates between |
| + // ticks and accrues instance-hours around the clock. Computed by the box, |
| + // which is the only place that holds both numbers. False from a box that |
| + // predates the field, and false wherever hibernation is not armed. |
| + HoldsAwake bool `json:"holds_awake,omitempty"` |
| +``` |
| + |
| +`internal/schedule/converge.go`, next to `toStatus`: |
| + |
| +```go |
| +// holdsAwake reports whether t's interval is tight enough that its instance |
| +// never gets to sleep: the gap between consecutive occurrences is no wider |
| +// than idle, so the activator's guard (and, before it, the tick's own wake) |
| +// keeps the process resident indefinitely. |
| +// |
| +// The gap comes from two Next calls rather than Spec.Every, so a cron gets a |
| +// real answer instead of a special case. idle <= 0 means hibernation is not |
| +// armed on this box: nothing holds anything awake, because nothing sleeps. |
| +// A once-timer fires once and cannot hold anything. An unparseable spec is |
| +// not evidence of a problem, so it makes no claim. |
| +func holdsAwake(t registry.Timer, idle time.Duration, now time.Time) bool { |
| + if idle <= 0 || t.Kind != KindSchedule { |
| + return false |
| + } |
| + sp, err := ParseSpec(t.Spec) |
| + if err != nil { |
| + return false |
| + } |
| + first := sp.Next(now) |
| + if first.IsZero() { |
| + return false |
| + } |
| + second := sp.Next(first) |
| + if second.IsZero() { |
| + return false |
| + } |
| + return second.Sub(first) <= idle |
| +} |
| +``` |
| + |
| +`toStatus` and `StatusFor` take the window: |
| + |
| +```go |
| +func StatusFor(reg *registry.Registry, rt registry.Route, box string, now time.Time, idle time.Duration) (Status, error) { |
| + // ... unchanged, except: |
| + // st.Timers = append(st.Timers, toStatus(r, idle, now)) |
| +} |
| + |
| +func toStatus(t registry.Timer, idle time.Duration, now time.Time) TimerStatus { |
| + return TimerStatus{ |
| + Name: t.Name, Kind: t.Kind, Spec: t.Spec, Path: t.Path, |
| + NextAt: t.NextAt, DueAt: t.DueAt, LastAt: t.LastAt, |
| + LastStatus: t.LastStatus, LastMs: t.LastMs, Attempts: t.Attempts, |
| + HoldsAwake: holdsAwake(t, idle, now), |
| + } |
| +} |
| +``` |
| + |
| +Add `Idle time.Duration` to both `Converger` (`converge.go:36` area) and `Runner` (`runner.go:53` area), and pass it at each `StatusFor` call site (`converge.go:174`, `runner.go:179`). |
| + |
| +Expose the window from the activator, in `internal/activator/activator.go`: |
| + |
| +```go |
| +// IdleWindow is cfg.Idle, for the box-side reporters that must tell a member |
| +// whether their schedule is tight enough to hold the instance resident. Read |
| +// only; the window is fixed at construction from CARLOS_HIBERNATE_IDLE. |
| +func (a *Service) IdleWindow() time.Duration { return a.cfg.Idle } |
| +``` |
| + |
| +And thread it in `newScheduleDeps` (`internal/host/schedule_wiring.go:66`), where `svc` may legitimately be nil: |
| + |
| +```go |
| + // Zero when no activator is armed on this box: nothing hibernates, so no |
| + // schedule can hold anything awake, and holdsAwake reports false for all |
| + // of them. |
| + var idle time.Duration |
| + if svc != nil { |
| + idle = svc.IdleWindow() |
| + } |
| +``` |
| + |
| +Set `Idle: idle` on both the `Runner` literal and the `NewConverger` call. If `NewConverger`'s signature has no room, add the field after construction — follow whichever shape that constructor already uses. |
| + |
| +- [ ] **Step 4: Run the tests and watch them pass** |
| + |
| +Run: `TMPDIR=/tmp go test ./internal/schedule -run 'TestHoldsAwake|TestStatusWithout' -count=1 -v` |
| +Expected: PASS, all cases. |
| + |
| +- [ ] **Step 5: Full gate across the three touched packages, then commit** |
| + |
| +```bash |
| +TMPDIR=/tmp go test ./internal/schedule ./internal/host ./internal/activator -count=1 |
| +gofmt -l internal/schedule internal/host internal/activator |
| +git add internal/schedule internal/host/schedule_wiring.go internal/activator/activator.go |
| +git commit -m "schedule: report when a schedule's interval holds its instance awake" |
| +``` |
| + |
| +--- |
| + |
| +### Task 6: Say it in `carlos schedule ls` |
| + |
| +**Files:** |
| +- Modify: `schedule.go:337-350` (the per-timer render loop) |
| +- Test: `schedule_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: `schedule.TimerStatus.HoldsAwake` from Task 5. |
| +- Produces: nothing. |
| + |
| +- [ ] **Step 1: Write the failing test** |
| + |
| +In `schedule_test.go`, following how the existing render tests capture output: |
| + |
| +```go |
| +func TestScheduleLsFlagsAHoldsAwakeRow(t *testing.T) { |
| + var b bytes.Buffer |
| + now := time.Unix(1788126000, 0).UTC() |
| + inst := schedule.Status{ |
| + Schema: 1, Host: "jam.bac.oncarlos.com", Box: "annie-welding", |
| + ReportedAt: now.Format(time.RFC3339), |
| + Timers: []schedule.TimerStatus{{ |
| + Name: "sweep", Kind: schedule.KindSchedule, Spec: "every 5m", |
| + Path: "/jobs/sweep", NextAt: now.Add(2 * time.Minute).Unix(), |
| + LastAt: now.Add(-3 * time.Minute).Unix(), LastStatus: "204", LastMs: 1, |
| + HoldsAwake: true, |
| + }}, |
| + } |
| + renderScheduleInstance(&b, inst, now) |
| + |
| + out := b.String() |
| + if !strings.Contains(out, "sweep") { |
| + t.Fatalf("row missing from output:\n%s", out) |
| + } |
| + if !strings.Contains(out, "never hibernates") { |
| + t.Errorf("no holds-awake warning in output:\n%s", out) |
| + } |
| +} |
| + |
| +func TestScheduleLsSaysNothingForAnOrdinaryRow(t *testing.T) { |
| + var b bytes.Buffer |
| + now := time.Unix(1788126000, 0).UTC() |
| + inst := schedule.Status{ |
| + Schema: 1, Host: "seapointish.bag.oncarlos.com", Box: "annie-welding", |
| + ReportedAt: now.Format(time.RFC3339), |
| + Timers: []schedule.TimerStatus{{ |
| + Name: "sync", Kind: schedule.KindSchedule, Spec: "every 6h", |
| + Path: "/jobs/sync", NextAt: now.Add(time.Hour).Unix(), |
| + }}, |
| + } |
| + renderScheduleInstance(&b, inst, now) |
| + |
| + if strings.Contains(b.String(), "never hibernates") { |
| + t.Errorf("warned about a 6-hourly schedule:\n%s", b.String()) |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run the tests and watch them fail** |
| + |
| +Run: `TMPDIR=/tmp go test . -run TestScheduleLs -count=1 -v` |
| +Expected: FAIL — the warning string is absent. |
| + |
| +- [ ] **Step 3: Render it** |
| + |
| +In `schedule.go`, inside the per-timer loop, after the existing `fmt.Fprintf(w, " %-*snext %s last %s\n", ...)`: |
| + |
| +```go |
| + if t.HoldsAwake { |
| + // The box worked this out; we only pass it on. Worth a whole |
| + // line rather than a marker on the row: the member's next |
| + // question is "so what?", and the answer is what they are being |
| + // billed for. |
| + fmt.Fprintf(w, " %-*sthis fires more often than the idle window, so the instance never hibernates between ticks\n", nameW+2, "") |
| + } |
| +``` |
| + |
| +- [ ] **Step 4: Run the tests and watch them pass** |
| + |
| +Run: `TMPDIR=/tmp go test . -run TestScheduleLs -count=1 -v` |
| +Expected: PASS, both. |
| + |
| +- [ ] **Step 5: Whole-repo gate, then commit** |
| + |
| +```bash |
| +TMPDIR=/tmp go build ./... && TMPDIR=/tmp go vet ./... && TMPDIR=/tmp make test |
| +gofmt -l . |
| +git add schedule.go schedule_test.go |
| +git commit -m "cli: schedule ls says when a schedule holds its instance awake" |
| +``` |
| + |
| +--- |
| + |
| +### Task 7: Gate on real CI and open the PR |
| + |
| +**Files:** none. |
| + |
| +- [ ] **Step 1: Push to the CI remote** |
| + |
| +```bash |
| +git push amadan HEAD:james-hogging |
| +``` |
| + |
| +- [ ] **Step 2: Read the run** |
| + |
| +`10-gate` is `go build ./...` then `make test`; `20-cross@5` adds darwin/windows/linux-arm64 compiles. Do not proceed until it is green — a branch pushed only to `origin` has been gated by nothing. |
| + |
| +- [ ] **Step 3: Confirm the change is inert, on paper** |
| + |
| +Before opening the PR, state in its body what the flagship will do after the roll and why nothing should visibly change: idle 15m, tightest schedule 5m, guard holds. Name the two apps to watch — `jam.bac.oncarlos.com` must stay resident, `seapointish.bag.oncarlos.com` must keep doing ~10 wake/sleep cycles a day. A change in either is a bug. |
| + |
| +- [ ] **Step 4: Open the PR** |
| + |
| +```bash |
| +git push origin james-hogging |
| +gh pr create --repo carlosframework/platform \ |
| + --title "Scheduled ticks must not hold an instance awake" \ |
| + --body "..." # spec link, the inert-on-roll argument, the two apps to watch |
| +``` |
| + |
| +--- |
| + |
| +## Self-Review |
| + |
| +**Spec coverage.** §3.1 → Tasks 2 and 4. §3.2 → Tasks 1 and 3. §3.3 → Tasks 5 and 6. §4 (inert on the flagship) → the Global Constraints and Task 7 Step 3. §5's exclusions are constraints, not tasks, and are listed in Global Constraints. §6's test list maps onto the tests in Tasks 2-5. §7 → Task 7. |
| + |
| +**Placeholders.** None. Every step carries the code it needs. Test helper names are explicitly flagged as stand-ins for whatever each file already provides, with the instruction to reuse rather than invent — that is a real instruction, not a deferral. |
| + |
| +**Type consistency.** `EarliestTimers() (map[string]int64, error)` is defined in Task 1, added to `DueStore` in Task 3, and called as `earliest[e.host]` there. `ServeScheduled` has the same four-argument signature as `ServeHTTP` in Tasks 2 and 4. `holdsAwake(registry.Timer, time.Duration, time.Time) bool` is defined and called with that order in Task 5. `HoldsAwake` is the field name in Tasks 5 and 6; `holds_awake` is the JSON tag in both. |
| + |
| +**One risk worth naming for the executor:** Task 2 renames a function on the hot path of every request (`beginRequest` → `begin` with a wrapper). If the package has other callers of `beginRequest` or `releaseFunc` beyond the ones named here, update them to the wrapper rather than threading `stamp` further — only the schedule deliverer should ever pass false. |
diff --git a/docs/superpowers/specs/2026-08-30-scheduled-ticks-and-hibernation-design.md b/docs/superpowers/specs/2026-08-30-scheduled-ticks-and-hibernation-design.md| new file mode 100644 |
| index 000000000..bdd713859 |
| --- /dev/null |
| +++ b/docs/superpowers/specs/2026-08-30-scheduled-ticks-and-hibernation-design.md |
| @@ -0,0 +1,315 @@ |
| +# Scheduled ticks must not hold an instance awake |
| + |
| +**Date:** 2026-08-30 |
| +**Status:** approved in principle (Paul, 2026-08-30, after the flagship |
| +measurement below). Two decisions were taken in the brainstorm and are marked |
| +**(ruling)**; everything else is marked **(decision)** and can be overturned |
| +cheaply. |
| + |
| +## 1. The problem, as found |
| + |
| +Asked whether one member was hogging resources on the flagship, the answer was |
| +no — and the way the answer was reached turned up a bug underneath it. |
| + |
| +`james@keymail.dev` (account `bac`, app `jam`) declared one schedule on |
| +2026-08-29: `sweep`, every 5 minutes, hitting `/jobs/sweep`. The handler |
| +answers `204` in **1 millisecond**. From the moment that schedule landed |
| +(2026-08-29 21:25Z) the instance has been resident **continuously** — present |
| +in every 60-second metrics sample since, 24 hours and counting, where the |
| +comparable tenant `seapointish` sleeps and wakes ten times a day. |
| + |
| +It is not the app's fault and it is not the schedule's fault. It is one line |
| +of accounting. |
| + |
| +Scheduled delivery is otherwise exactly right. `activatorDeliverer` sends the |
| +tick through the activator into the instance's unix socket, never through the |
| +public edge, and its doc comment states the property it was built for |
| +(`internal/host/schedule_wiring.go:216`): |
| + |
| +> a hibernating instance WAKES for its tick, exactly as it would for a |
| +> visitor, and stays awake for the tick's duration because the activator's |
| +> in-flight count holds it open. No separate keep-awake to get wrong. |
| + |
| +The tick reaches the instance through `Service.ServeHTTP` |
| +(`internal/activator/activator.go:1141`) — the same door the edge uses — which |
| +calls `beginRequest`, whose release stamps `e.lastActive = a.now()` |
| +(`activator.go:594`). The sweeper hibernates on |
| +`e.inflight == 0 && now.Sub(e.lastActive) >= a.cfg.Idle` (`activator.go:933`). |
| + |
| +`CARLOS_HIBERNATE_IDLE` defaults to 15m. The spec is 5m. The clock is reset |
| +three times before it can ever expire, so the sweeper's condition is |
| +unreachable for the life of the schedule. **Every tick buys fifteen minutes of |
| +residency for one millisecond of work.** |
| + |
| +### Why it is worth fixing rather than tolerating |
| + |
| +Three reasons, in ascending order of weight. |
| + |
| +**It bills.** `instance_hours` is a metered, priced quantity, and in |
| +rate-sheet mode it is the one term with no free-tier subtraction applied — |
| +compare `billable(...)` on egress, storage, apps and memory against the raw |
| +`meters[usage.MeterInstanceHours]` in `internal/economics/compute.go:120`, at |
| +`InstanceHourUSD` = $0.01. A 5-minute schedule accrues ~730 instance-hours a |
| +month: **$7.30 billed for roughly four seconds of work**. |
| + |
| +**It defeats the capacity model.** The 2026-08-30 sizing measurement put peak |
| +concurrent awake instances at 74 of the top 300 accounts on a 90s window. That |
| +number assumes instances sleep between requests. An app with any schedule |
| +tighter than the idle window never sleeps at all, so as schedules spread the |
| +model degrades toward "everything is always awake" — which is the assumption |
| +the r8g.2xlarge recommendation was chosen against. |
| + |
| +**It is invisible.** Nothing tells the member. `carlos schedule ls` reports |
| +what will fire and what fired last; nothing says "and this interval means your |
| +instance never hibernates". James could not have known, and neither could |
| +anyone reading the console. |
| + |
| +## 2. What is already right, and must stay right |
| + |
| +Worth stating, because two of these were nearly re-engineered before the code |
| +was read properly. |
| + |
| +**There are already two wake paths for scheduled work, and they are enough.** |
| +The runner polls `DueTimers` every 15 seconds and delivers, waking the |
| +instance through the activator; separately the sweeper reads `DueInstances` |
| +and wakes any sleeping host whose recorded due time has arrived |
| +(`activator.go:909-914`). Platform timers are covered by the first. |
| + |
| +**`NextDue` is therefore not the mechanism to change.** `GET /api/next-due` |
| +over the instance socket (`internal/activator/backend_exec.go:506`) asks the |
| +*app* about work the platform cannot see — its own in-process schedule. It is |
| +not the platform's timer index and should not become one. This spec adds no |
| +column, no `SetNextDue` change and no second waker. **(decision)** |
| + |
| +**The sidecar precedent already solves the accounting half.** |
| +`BeginSidecarRequest` (`activator.go:526`) passes traffic to an awake instance |
| +without registering activity — "no in-flight count, no lastActive stamp (the |
| +sidecar's own long-lived WebSocket must not hold its instance awake; that |
| +non-count is the whole reason the pair can sleep)". The shape needed here is |
| +that shape, differing in one respect: a tick must *wake* a sleeping instance, |
| +where a sidecar request is refused. |
| + |
| +## 3. The change |
| + |
| +Three parts. Together they are perhaps a hundred lines. |
| + |
| +### 3.1 A non-stamping, waking entry point |
| + |
| +Add `Service.ServeScheduled(w, r, rt, proxy) bool`, sibling to `ServeHTTP`, |
| +and a `beginScheduledRequest` behind it that: |
| + |
| +- wakes a sleeping instance, exactly as `beginRequest` does — the tick is the |
| + wake source, and that must not change; |
| +- takes the in-flight count for the duration, so a tick is never cut off |
| + mid-delivery by a concurrent sweep; |
| +- on release, decrements **without** stamping `lastActive`. |
| + |
| +`activatorDeliverer.Deliver` calls it instead of `ServeHTTP` |
| +(`schedule_wiring.go:317`). |
| + |
| +**No wire marker. (decision)** The sidecar identifies itself with |
| +`SidecarHeader` because it has to: it dials its instance's public URL and its |
| +traffic arrives through the edge, so the only place to carry the signal is the |
| +request. A scheduled tick never leaves the process — "the tick never leaves |
| +the box: no DNS, no TLS, no edge auth, nothing an outsider could aim at the |
| +same path" (`schedule_wiring.go:219`) — so the call site itself is the signal. |
| +A dedicated method has no spoofing surface at all, where a header, even one |
| +that is safe by construction, would have to be argued about. |
| + |
| +`X-Carlos-Schedule-At` is untouched. It is the app-facing half of the tick |
| +contract and continues to reach the handler. |
| + |
| +### 3.2 The guard: don't sleep into an imminent tick |
| + |
| +Without a guard, part 3.1 alone converts `jam` from one resident process into |
| +**288 wake/sleep cycles a day**. Each cycle is a full SQLite |
| +checkpoint-and-restore traversal, and the wake path is where |
| +`2026-08-15-sqlite-corruption-defence-design.md` (issue #200) records an |
| +unclosed integrity gap. Multiplying a scheduled app's traversals by ~28× to |
| +save 26 MB is a bad trade. |
| + |
| +So: in `sweepOnce`, before hibernating an otherwise-idle host, skip the sleep |
| +when this box holds a timer for that host due sooner than `cfg.Idle` away. |
| + |
| +``` |
| +hibernate when: inflight == 0 |
| + && now - lastActive >= cfg.Idle |
| + && (no timer for this host, or nextAt - now > cfg.Idle) |
| +``` |
| + |
| +**The threshold is `cfg.Idle` itself, not a new constant. (ruling — Paul, |
| +2026-08-30)** If the gap to the next tick is shorter than the window we would |
| +have stayed awake for anyway, staying awake *is* the cheaper answer. It adds |
| +no second tuning number to drift out of step with the first, and it composes |
| +with the idle window rather than fighting it: |
| + |
| +| Idle window | Schedule | Behaviour | |
| +|---|---|---| |
| +| 15m | every 5m | stays resident (today's behaviour, unchanged) | |
| +| 90s | every 5m | sleeps ~3.5 of every 5 minutes | |
| +| 90s | every 60s | stays resident | |
| +| 15m | every 1h | sleeps ~45 minutes | |
| + |
| +Residency is measured from each WAKE, not from each tick: a tick that wakes a |
| +sleeping instance stamps `lastActive` via the wake path's own stamp, so the |
| +instance gets one full idle window of residency starting at that wake before |
| +it becomes eligible to sleep again. That is why the 15m/1h row sleeps ~45 |
| +minutes (60 minus the 15-minute window it earns on waking), not 59 (60 minus |
| +the one-minute tick that would apply if the clock started at delivery). Rows |
| +2 and 3 were already computed this way; anyone extending this table — or the |
| +§1 billing arithmetic that reads off it — should keep using the wake, not the |
| +tick, as the start of the window. |
| + |
| +**Implementation constraint, from the code as it stands:** `sweepOnce` does |
| +its `DueInstances` read deliberately before taking `a.mu` — "it's a registry |
| +read, and nothing under a.mu should wait on SQLite" (`activator.go:911`). The |
| +earliest-due lookup joins that pre-lock batch as one query returning |
| +`host → earliest next_at`, never a per-entry query inside the loop. |
| + |
| +A host with no timers is unaffected: no row, no guard, sleep as today. |
| + |
| +**A tick-held host still converges.** The guard only skips the *sleep*; it |
| +must not skip the build-convergence check that runs after it. A host held |
| +awake by the guard never reaches a "next wake" to converge on — that is what |
| +the guard guarantees — so it is now in the same class as an always-awake |
| +sidecar host, and reaches the convergence arm on every sweep exactly as a |
| +sidecar does. Without this, a `carlos ship` behind a tight enough schedule |
| +would be served stale indefinitely. **(ruling — Paul, review round 1, |
| +2026-08-31, overruling an earlier "converges at its next genuine sleep" |
| +draft of this section that assumed a sleep the guard makes impossible.)** |
| + |
| +### 3.3 Tell the member |
| + |
| +The box is the only place that knows both numbers — it holds the timer rows |
| +and it constructed the activator with `cfg.Idle`. So the box decides and |
| +reports. |
| + |
| +Add `HoldsAwake bool \`json:"holds_awake"\`` to `schedule.TimerStatus` |
| +(`internal/schedule/status.go:24`) — true when this row's interval is shorter |
| +than the box's idle window, so the row's instance stays resident between |
| +ticks. Written |
| +wherever the status object is already written (after every fire and every |
| +converge that changed a host's rows). Additive, per the repo's wire-shape |
| +rule: anything written before this change must still parse, and a status |
| +object from an older box simply lacks the field. |
| + |
| +`carlos schedule ls` renders it — one line, in the member's own vocabulary: |
| +that this schedule fires more often than the deployment's idle window, so the |
| +instance stays awake between ticks and accrues instance-hours accordingly. |
| +`carlos schedule set` shows the same line once the converger has written the |
| +status. |
| + |
| +**Warn, never refuse. (decision)** A tight schedule is a legitimate thing to |
| +want, and the platform serves it correctly today. The defect is that it was |
| +silent, not that it was allowed. |
| + |
| +## 4. The consequence to be explicit about |
| + |
| +**Not every host is inert under this change — it depends which side of the |
| +idle window the host's schedule sits on.** |
| + |
| +A host whose schedule fires *more often* than the idle window — `jam`, 5m |
| +against the flagship's 15m window — is unaffected. The guard holds it |
| +resident for the same reason the accounting bug used to: its next tick is |
| +always inside the window, so the sweeper's answer is "stay awake" today and |
| +after this lands. `jam` will not begin sleeping when this ships. |
| + |
| +A host whose schedule fires *less often* than the idle window is not inert, |
| +if that host also takes visitor traffic. Before this branch, every tick — |
| +regardless of how far apart — reset `lastActive`, so a tick landing between |
| +two visits kept pushing hibernation out. After this branch a tick no longer |
| +resets the clock, so that host loses the extra runway its ticks used to buy |
| +it: it can now hibernate up to one full idle window earlier than before, |
| +measured from its last real visitor request rather than from whichever tick |
| +last happened to land. That is the fix working as designed — a tick was |
| +never meant to count as activity — but it is a real, observable behaviour |
| +change on *this* branch, not a case of "provably alters nothing". Whether the |
| +flagship carries any host in this shape today is exactly what §7's rollout |
| +check exists to establish before rolling. |
| + |
| +The sequencing that remains deliberate is narrower than "inert, then |
| +behaviour change": this change runs under every tenant instance on every |
| +box, so shipping it and confirming its effect matches §7's expectations |
| +before separately tightening `CARLOS_HIBERNATE_IDLE` 15m → 90s keeps the two |
| +changes distinguishable if something needs reverting. The idle-window |
| +tightening is the change that turns `jam` from "stays resident" into |
| +"sleeps and wakes"; this branch is the change that stops loose-schedule |
| +hosts getting undeserved extra runway from their ticks. |
| + |
| +**(ruling — Paul, 2026-08-30)** The two land as separate branches. |
| + |
| +## 5. What is not built |
| + |
| +- **No `NextDue` rework, no new registry column, no second waker.** §2. |
| +- **No per-route idle override.** One knob per deployment, plus a guard that |
| + reads it, covers every case found. A per-route window is a product decision |
| + nobody has asked for. |
| +- **No new tuning constant.** §3.2. |
| +- **No special case for `carlos schedule run`.** A manual fire is |
| + `FireTimerNow`, which moves `next_at` and nothing else |
| + (`internal/registry/timers.go:132`); the runner cannot tell it from a |
| + recurring fire without a new column. A schema change to buy "the instance |
| + stays up 15 minutes after you press run" is not worth it. Manual runs are |
| + ticks. **(decision — reversed during the brainstorm, after reading the |
| + code: the first instinct was to treat a human's press as visitor activity.)** |
| +- **No use of `routes.next_due` in the guard.** It looks like free extra |
| + coverage — the app's own declared due time, already in the registry, already |
| + read by `DueInstances` — and it is a trap. That column is written only at |
| + sleep and is deliberately not cleared on wake: "the registry's stale past |
| + value while the host is up is harmless" (`activator.go:1007`). A guard |
| + reading it on an awake host would compare against a timestamp in the past, |
| + conclude the next delivery is imminent forever, and wedge itself open. The |
| + value could only be made current by probing the app on every sweep pass, |
| + which is a socket round trip per idle host per tick. The guard covers |
| + platform timers; app-declared schedules stay uncovered, and are fine there |
| + because their intervals (hourly and up, in every case on the fleet today) |
| + are far wider than any plausible idle window. |
| +- **No change to sidecar accounting.** It is already correct and is the |
| + precedent this follows. |
| + |
| +## 6. Testing |
| + |
| +All of it runs without a box. |
| + |
| +`internal/activator`: |
| + |
| +- a scheduled delivery leaves `lastActive` untouched; a visitor request to the |
| + same host moves it — the two asserted against each other in one test, since |
| + the bug is precisely that they were the same path; |
| +- a tick against a *sleeping* host wakes it, and the in-flight count holds it |
| + through delivery (a sweep racing the tick must not stop it mid-flight); |
| +- the guard: a host idle past the window with a timer due inside `cfg.Idle` |
| + stays resident; the same host with the timer beyond `cfg.Idle` hibernates; |
| + a host with no timer rows hibernates as before; |
| +- the guard's query runs before `a.mu` — asserted structurally, the way the |
| + existing sweep tests pin their ordering. |
| + |
| +`internal/host`: |
| + |
| +- `activatorDeliverer.Deliver` routes through `ServeScheduled`, not |
| + `ServeHTTP`, and a delivery to a host that was asleep when the tick came due |
| + still returns the handler's status; |
| +- `X-Carlos-Schedule-At` still reaches the handler unchanged. |
| + |
| +`internal/schedule`: |
| + |
| +- a status object written by a box that computed the verdict round-trips, and |
| + one written without the field still parses. |
| + |
| +## 7. Rollout |
| + |
| +Ordinary: merge, roll the fleet, confirm the change lands where it should |
| +and nowhere else — see §4, this is not a blanket "confirm inert". Two checks: |
| + |
| +- **`jam`-shaped hosts — schedule interval shorter than the box's idle |
| + window — must not move at all.** `jam` itself must still be resident |
| + afterwards, and `seapointish` (no schedule) must still be doing its ten |
| + wake/sleep cycles a day. A change to either here is a bug, not a win. |
| +- **Before rolling, enumerate hosts whose schedule interval is longer than |
| + the idle window.** Expect their wake/sleep cycle counts to rise slightly — |
| + this branch stops their ticks buying free runway between real visits, so a |
| + small rise there is the change working as designed. Only a large or |
| + otherwise unexplained jump is worth investigating. |
| + |
| +The separate idle-window tightening (15m → 90s) is verified on the *next* |
| +branch, against the same apps. |
diff --git a/internal/activator/activator.go b/internal/activator/activator.go| index 609ccf743..426b8c5e9 100644 |
| --- a/internal/activator/activator.go |
| +++ b/internal/activator/activator.go |
| @@ -173,6 +173,9 @@ type Backend interface { |
| type DueStore interface { |
| SetNextDue(host string, due int64) error |
| DueInstances(now int64) ([]registry.Route, error) |
| + // EarliestTimers is the hibernation guard's input: host -> soonest armed |
| + // timer. Read with DueInstances, before the sweep takes a.mu. |
| + EarliestTimers() (map[string]int64, error) |
| } |
| // Lease is a held-but-not-yet-replicating lease (wake in progress; Eleven's |
| @@ -430,10 +433,15 @@ func New(cfg Config, backend Backend, due DueStore) *Service { |
| return a |
| } |
| -// beginRequest ensures the host's instance is awake and registers one in-flight |
| -// request against it. The returned release must be called when the request |
| -// finishes (it stamps last-activity, which starts the idle clock). |
| -func (a *Service) beginRequest(ctx context.Context, rt registry.Route) (release func(), err error) { |
| +// begin registers a request against rt's instance, waking it if asleep. |
| +// |
| +// stamp is what separates a visitor from the platform's own clock. Both take |
| +// the in-flight count — a delivery in progress must never be cut off by a |
| +// concurrent sweep — but only a visitor's release moves lastActive. A |
| +// scheduled tick that stamped would reset the idle window on every fire, so |
| +// any schedule tighter than that window would pin its instance awake forever |
| +// for however little work the handler does. |
| +func (a *Service) begin(ctx context.Context, rt registry.Route, stamp bool) (release func(), err error) { |
| deadline := time.NewTimer(wakeWaitBound) |
| defer deadline.Stop() |
| for { |
| @@ -475,7 +483,7 @@ func (a *Service) beginRequest(ctx context.Context, rt registry.Route) (release |
| } |
| e.inflight++ |
| a.mu.Unlock() |
| - return a.releaseFunc(e), nil |
| + return a.releaseFunc(e, stamp), nil |
| case actWaking: |
| ready := e.ready |
| @@ -510,6 +518,11 @@ func (a *Service) beginRequest(ctx context.Context, rt registry.Route) (release |
| } |
| } |
| +// beginRequest is begin for member traffic: activity counts. |
| +func (a *Service) beginRequest(ctx context.Context, rt registry.Route) (release func(), err error) { |
| + return a.begin(ctx, rt, true) |
| +} |
| + |
| // errSidecarAsleep: a sidecar-marked request arrived for a host with no awake |
| // instance. Sidecar traffic never wakes anything — the activator itself spawns |
| // the sidecar as part of a wake, so a marked request for a sleeping host is |
| @@ -585,13 +598,15 @@ func (a *Service) BeginSidecarRequest(ctx context.Context, rt registry.Route) er |
| // proxying it away would only trade a short wait for a loud lease race) — |
| // and awakeCount() for the fleet status page. |
| -func (a *Service) releaseFunc(e *actEntry) func() { |
| +func (a *Service) releaseFunc(e *actEntry, stamp bool) func() { |
| var once sync.Once |
| return func() { |
| once.Do(func() { |
| a.mu.Lock() |
| e.inflight-- |
| - e.lastActive = a.now() |
| + if stamp { |
| + e.lastActive = a.now() |
| + } |
| a.mu.Unlock() |
| }) |
| } |
| @@ -779,6 +794,11 @@ func (a *Service) Owns(host string) bool { |
| return a.entries[host] != nil |
| } |
| +// IdleWindow is cfg.Idle, for the box-side reporters that must tell a member |
| +// whether their schedule is tight enough to hold the instance resident. Read |
| +// only; the window is fixed at construction from CARLOS_HIBERNATE_IDLE. |
| +func (a *Service) IdleWindow() time.Duration { return a.cfg.Idle } |
| + |
| // Bounce initiates a graceful stop of host's RUNNING instance so that |
| // its next wake — the next request, or its schedule — respawns it with |
| // a freshly composed environment. This is config delivery's restart |
| @@ -914,6 +934,21 @@ func (a *Service) sweepOnce() { |
| if err != nil { |
| log.Printf("activator: due-time sweep: %v", err) |
| } |
| + // The guard's input, read here for the same reason the due query is: |
| + // nothing under a.mu may wait on SQLite. A failure does NOT degrade to |
| + // today's behaviour: earliest ends up nil, so no host is held, and a |
| + // host with a schedule tighter than the idle window flips from one |
| + // resident process into a wake/sleep pair every tick — a 5m schedule |
| + // becomes ~288 checkpoint-and-restore traversals a day, precisely the |
| + // trade the guard exists to refuse. Fail open anyway: refusing to |
| + // hibernate anything until the query recovers would hold every |
| + // instance on the box awake, which is worse than losing the guard for |
| + // the instances that have one. |
| + earliest, err := a.due.EarliestTimers() |
| + if err != nil { |
| + log.Printf("activator: earliest-timer sweep: %v — hibernating without the schedule guard", err) |
| + earliest = nil |
| + } |
| a.mu.Lock() |
| cycled := 0 // build-convergence cycles started this tick (rate-limited) |
| for _, e := range a.entries { |
| @@ -931,9 +966,23 @@ func (a *Service) sweepOnce() { |
| continue |
| } |
| if e.inflight == 0 && now.Sub(e.lastActive) >= a.cfg.Idle { |
| - log.Printf("activator: %s idle %s — hibernating", e.host, now.Sub(e.lastActive).Truncate(time.Second)) |
| - a.beginStopLocked(e) |
| - continue |
| + // Don't sleep into an imminent tick. A wake/sleep pair is a full |
| + // checkpoint-and-restore round trip; spending one to be absent |
| + // for less time than we would have idled anyway is a straight |
| + // loss. The threshold is the idle window itself rather than a |
| + // constant of its own: it is already the deployment's stated |
| + // opinion about how long residency is worth holding. |
| + next, ok := earliest[e.host] |
| + heldForTick := ok && time.Unix(next, 0).Sub(now) <= a.cfg.Idle |
| + if !heldForTick { |
| + log.Printf("activator: %s idle %s — hibernating", e.host, now.Sub(e.lastActive).Truncate(time.Second)) |
| + a.beginStopLocked(e) |
| + continue |
| + } |
| + // Fall through to the convergence check below: a host held awake |
| + // for its schedule never reaches a "next wake" to converge on, so |
| + // it is now one of the always-awake hosts that arm was written |
| + // for — the same class as a sidecar host that never sleeps. |
| } |
| // Converge onto a shipped build. A running child never re-reads |
| // live/<host>, so once a ship repoints the symlink this awake child is |
| @@ -1139,6 +1188,23 @@ func (a *Service) Shutdown(ctx context.Context) { |
| // it); false means "not activator-managed — proxy straight", so the edge can |
| // call it unconditionally. |
| func (a *Service) ServeHTTP(w http.ResponseWriter, r *http.Request, rt registry.Route, proxy http.Handler) bool { |
| + return a.serve(w, r, rt, proxy, true) |
| +} |
| + |
| +// ServeScheduled is ServeHTTP for a platform-delivered scheduled tick: same |
| +// wake, same in-flight hold, same error mapping — no activity credit. |
| +// |
| +// A dedicated method rather than a wire marker like SidecarHeader, and the |
| +// difference is the delivery path, not taste. The sidecar dials its |
| +// instance's public URL, so its traffic arrives through the edge and the |
| +// request is the only place a signal could ride. A tick never leaves the |
| +// process (schedule_wiring.go: "the tick never leaves the box"), so the call |
| +// site IS the signal, and there is no header for an outsider to send. |
| +func (a *Service) ServeScheduled(w http.ResponseWriter, r *http.Request, rt registry.Route, proxy http.Handler) bool { |
| + return a.serve(w, r, rt, proxy, false) |
| +} |
| + |
| +func (a *Service) serve(w http.ResponseWriter, r *http.Request, rt registry.Route, proxy http.Handler, stamp bool) bool { |
| // Per-host gating: only a hibernate-flagged instance is activator-managed; |
| // unflagged instances and sibling services are the edge's to proxy |
| // straight — the deploy-safety property that lets the bucket env ship |
| @@ -1181,7 +1247,7 @@ func (a *Service) ServeHTTP(w http.ResponseWriter, r *http.Request, rt registry. |
| // entry stayed local — placement converges at the next sleep boundary, |
| // since its state is already in S3. Single-box v1 always wakes locally. |
| - release, err := a.beginRequest(r.Context(), rt) |
| + release, err := a.begin(r.Context(), rt, stamp) |
| if err != nil { |
| var held *errLeaseHeld |
| switch { |
diff --git a/internal/activator/activator_test.go b/internal/activator/activator_test.go| index 7d0b9a92c..5893ae761 100644 |
| --- a/internal/activator/activator_test.go |
| +++ b/internal/activator/activator_test.go |
| @@ -383,6 +383,39 @@ func newTestServiceAwake(t *testing.T, host string) *Service { |
| return a |
| } |
| +// arrangeAwake drives rt's host to actAwake on an already-built Service via a |
| +// real beginRequest/release round trip — the same arrange block |
| +// newTestServiceAwake uses, but against a caller-supplied Service/route |
| +// instead of building its own. |
| +func arrangeAwake(t *testing.T, a *Service, rt registry.Route) { |
| + t.Helper() |
| + release, err := a.beginRequest(context.Background(), rt) |
| + if err != nil { |
| + t.Fatalf("beginRequest %s: %v", rt.Host, err) |
| + } |
| + release() |
| +} |
| + |
| +// okHandler is a proxy stand-in that writes 200 OK — for tests asserting |
| +// only that a request reached the handler, not what a real instance would |
| +// return. |
| +func okHandler() http.Handler { |
| + return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { |
| + w.WriteHeader(http.StatusOK) |
| + }) |
| +} |
| + |
| +// armTimer puts one armed schedule row on host, due at next. |
| +func armTimer(t *testing.T, reg *registry.Registry, host string, next time.Time) { |
| + t.Helper() |
| + if err := reg.UpsertTimer(registry.Timer{ |
| + Host: host, Name: "sweep", Kind: "schedule", Spec: "every 5m", |
| + Path: "/jobs/sweep", NextAt: next.Unix(), DueAt: next.Unix(), |
| + }); err != nil { |
| + t.Fatalf("UpsertTimer %s: %v", host, err) |
| + } |
| +} |
| + |
| // waitFor polls until a condition holds — the stop path runs on its own |
| // goroutine, so tests observe its completion rather than assuming it. |
| func waitFor(t *testing.T, what string, cond func() bool) { |
| @@ -1332,6 +1365,226 @@ func TestServeHTTPUnmanagedRouteReturnsFalse(t *testing.T) { |
| } |
| } |
| +// A scheduled tick must not move lastActive: it is the platform's own clock |
| +// poking the instance, not a visitor. A tick that counted would make any |
| +// schedule tighter than the idle window a permanent keep-awake. |
| +func TestServeScheduledDoesNotStampActivity(t *testing.T) { |
| + const host = "tick.bab.oncarlos.com" |
| + a, _, clk, _ := testActivator(t, time.Hour) |
| + rt := testRoute(host) |
| + arrangeAwake(t, a, rt) // a real beginRequest round trip, then release |
| + |
| + a.mu.Lock() |
| + before := a.entries[rt.Host].lastActive |
| + a.mu.Unlock() |
| + |
| + clk.advance(time.Minute) |
| + |
| + rec := httptest.NewRecorder() |
| + req := httptest.NewRequest("POST", "http://carlos/jobs/sweep", nil) |
| + if !a.ServeScheduled(rec, req, rt, okHandler()) { |
| + t.Fatal("ServeScheduled returned false for a managed route") |
| + } |
| + |
| + a.mu.Lock() |
| + after := a.entries[rt.Host].lastActive |
| + inflight := a.entries[rt.Host].inflight |
| + a.mu.Unlock() |
| + |
| + if !after.Equal(before) { |
| + t.Errorf("lastActive moved %v -> %v; a scheduled tick must not count as activity", before, after) |
| + } |
| + if inflight != 0 { |
| + t.Errorf("inflight = %d after delivery, want 0 (the hold is released even though the stamp is not written)", inflight) |
| + } |
| +} |
| + |
| +// The other half of the same coin, asserted against the first so the two |
| +// paths can never quietly become one again — which is the bug this fixes. |
| +func TestServeHTTPStillStampsActivity(t *testing.T) { |
| + const host = "visitor.bab.oncarlos.com" |
| + a, _, clk, _ := testActivator(t, time.Hour) |
| + rt := testRoute(host) |
| + arrangeAwake(t, a, rt) |
| + |
| + a.mu.Lock() |
| + before := a.entries[rt.Host].lastActive |
| + a.mu.Unlock() |
| + |
| + clk.advance(time.Minute) |
| + |
| + rec := httptest.NewRecorder() |
| + req := httptest.NewRequest("GET", "http://carlos/", nil) |
| + if !a.ServeHTTP(rec, req, rt, okHandler()) { |
| + t.Fatal("ServeHTTP returned false for a managed route") |
| + } |
| + |
| + a.mu.Lock() |
| + after := a.entries[rt.Host].lastActive |
| + a.mu.Unlock() |
| + |
| + if !after.After(before) { |
| + t.Errorf("lastActive did not move on a visitor request (%v -> %v)", before, after) |
| + } |
| +} |
| + |
| +// A tick is a wake source and must stay one: an instance asleep when its |
| +// timer comes due has to be woken by the delivery itself. |
| +func TestServeScheduledWakesASleepingInstance(t *testing.T) { |
| + const host = "asleep.bab.oncarlos.com" |
| + a, _, _, _ := testActivator(t, time.Hour) // no entries yet: the host is asleep |
| + rt := testRoute(host) |
| + |
| + rec := httptest.NewRecorder() |
| + req := httptest.NewRequest("POST", "http://carlos/jobs/sweep", nil) |
| + if !a.ServeScheduled(rec, req, rt, okHandler()) { |
| + t.Fatal("ServeScheduled returned false for a managed route") |
| + } |
| + if rec.Code != http.StatusOK { |
| + t.Fatalf("status %d, want 200 — the tick must wake the instance, not be refused", rec.Code) |
| + } |
| + a.mu.Lock() |
| + e := a.entries[rt.Host] |
| + a.mu.Unlock() |
| + if e == nil || e.state != actAwake { |
| + t.Fatal("instance is not awake after a scheduled delivery") |
| + } |
| +} |
| + |
| +// The guard: a host whose next platform tick is nearer than the idle window |
| +// stays resident. Sleeping would buy a few minutes of absence at the price of |
| +// a full checkpoint-and-restore round trip, and then an immediate wake. |
| +func TestSweepKeepsHostAwakeWhenATickIsImminent(t *testing.T) { |
| + const host = "imminent.bab.oncarlos.com" |
| + a, _, clk, reg := testActivator(t, 15*time.Minute) |
| + rt := testRoute(host) |
| + arrangeAwake(t, a, rt) |
| + |
| + // Arm 21 minutes out, then advance the clock 16 minutes: at sweep time |
| + // the tick is 5 minutes in the FUTURE — genuinely inside the window, not |
| + // merely overdue (that boundary is TestSweepKeepsHostAwakeForAnOverdueTick's |
| + // job). A guard that only checked next <= now would wrongly hibernate here. |
| + armTimer(t, reg, host, clk.now().Add(21*time.Minute)) |
| + clk.advance(16 * time.Minute) // lastActive is now well past the window |
| + |
| + a.sweepOnce() |
| + |
| + a.mu.Lock() |
| + e := a.entries[rt.Host] |
| + a.mu.Unlock() |
| + if e == nil || e.state != actAwake { |
| + t.Fatal("host hibernated with a tick due inside the idle window") |
| + } |
| +} |
| + |
| +// Beyond the window, the sleep is worth taking. |
| +func TestSweepHibernatesWhenTheNextTickIsFarOff(t *testing.T) { |
| + const host = "faroff.bab.oncarlos.com" |
| + a, _, clk, reg := testActivator(t, 15*time.Minute) |
| + rt := testRoute(host) |
| + arrangeAwake(t, a, rt) |
| + |
| + // Next tick in an hour: well beyond the window. |
| + armTimer(t, reg, host, clk.now().Add(time.Hour)) |
| + clk.advance(16 * time.Minute) |
| + |
| + a.sweepOnce() |
| + |
| + waitFor(t, "the host to hibernate with its next tick an hour away", func() bool { |
| + a.mu.Lock() |
| + defer a.mu.Unlock() |
| + return a.entries[rt.Host] == nil |
| + }) |
| +} |
| + |
| +// A host with no timer rows is untouched by the guard — the overwhelming |
| +// majority of instances, and the case that must not regress. |
| +func TestSweepHibernatesAHostWithNoTimers(t *testing.T) { |
| + const host = "untimed.bab.oncarlos.com" |
| + a, _, clk, _ := testActivator(t, 15*time.Minute) // no timer rows at all |
| + rt := testRoute(host) |
| + arrangeAwake(t, a, rt) |
| + clk.advance(16 * time.Minute) |
| + |
| + a.sweepOnce() |
| + |
| + waitFor(t, "a host with no timers to hibernate as it always did", func() bool { |
| + a.mu.Lock() |
| + defer a.mu.Unlock() |
| + return a.entries[rt.Host] == nil |
| + }) |
| +} |
| + |
| +// An overdue row (next_at already in the past — the runner has not got to it |
| +// yet) is the most imminent tick there is, so it holds the host too. |
| +func TestSweepKeepsHostAwakeForAnOverdueTick(t *testing.T) { |
| + const host = "overdue.bab.oncarlos.com" |
| + a, _, clk, reg := testActivator(t, 15*time.Minute) |
| + rt := testRoute(host) |
| + arrangeAwake(t, a, rt) |
| + |
| + // next_at already behind us: the runner will fire it within 15s. |
| + armTimer(t, reg, host, clk.now().Add(-time.Minute)) |
| + clk.advance(16 * time.Minute) |
| + |
| + a.sweepOnce() |
| + |
| + a.mu.Lock() |
| + e := a.entries[rt.Host] |
| + a.mu.Unlock() |
| + if e == nil || e.state != actAwake { |
| + t.Fatal("host hibernated with an overdue tick pending") |
| + } |
| +} |
| + |
| +// A host held awake by the guard never reaches a "next wake" to converge on |
| +// — the same gap the always-awake sidecar case closes. The guard's continue |
| +// must fall through to the convergence check, not skip it, or a `carlos |
| +// ship` behind a tick-held host is served stale indefinitely. |
| +func TestSweepHeldForTickStillConverges(t *testing.T) { |
| + const host = "tickheld.bab.oncarlos.com" |
| + a, b, clk, reg := testActivator(t, 15*time.Minute) |
| + var ver atomic.Value // the live/<host> symlink target; swapped to model a ship |
| + ver.Store("v1") |
| + a.liveVer = func(string) (string, error) { return ver.Load().(string), nil } |
| + b.execVerFn = func() string { return ver.Load().(string) } |
| + |
| + rt := testRoute(host) |
| + arrangeAwake(t, a, rt) |
| + |
| + // A tight schedule: next tick always inside the window, so the guard |
| + // holds the host awake on every sweep from here on (an overdue row is |
| + // still "inside the window" — more so, if anything). |
| + armTimer(t, reg, host, clk.now().Add(5*time.Minute)) |
| + clk.advance(16 * time.Minute) // idle timeout reached |
| + |
| + a.sweepOnce() |
| + a.mu.Lock() |
| + e := a.entries[rt.Host] |
| + a.mu.Unlock() |
| + if e == nil || e.state != actAwake { |
| + t.Fatal("host hibernated despite an imminent tick — guard not exercised") |
| + } |
| + |
| + // Ship: repoint the symlink while the guard still holds the host awake. |
| + ver.Store("v2") |
| + a.sweepOnce() // starts the grace clock only |
| + if a.entryCount() != 1 { |
| + t.Fatal("cycled inside the grace window") |
| + } |
| + |
| + // Past the grace window: the guard still holds (the tick is now even |
| + // more overdue), but convergence must still fire — that's the fix. |
| + clk.advance(ConvergeGrace + time.Second) |
| + a.sweepOnce() |
| + waitFor(t, "a tick-held host to still converge onto the new build", func() bool { |
| + return a.entryCount() == 0 |
| + }) |
| + if !b.lastProc().isStopped() { |
| + t.Fatal("stale instance's process was not stopped") |
| + } |
| +} |
| + |
| // A reload that finds an awake host no longer hibernate-flagged retires its |
| // child — stopped, final-checkpointed (replication closed), entry forgotten — |
| // the edge-process half of flipping hibernation off. And once the flag is |
diff --git a/internal/host/schedule_wake_test.go b/internal/host/schedule_wake_test.go| index f5ea89ece..ce1ee966d 100644 |
| --- a/internal/host/schedule_wake_test.go |
| +++ b/internal/host/schedule_wake_test.go |
| @@ -16,6 +16,7 @@ import ( |
| "errors" |
| "net" |
| "net/http" |
| + "net/http/httptest" |
| "net/http/httputil" |
| "path/filepath" |
| "strconv" |
| @@ -234,7 +235,7 @@ func TestScheduledTickWakesASleepingInstanceAndItHibernatesAgain(t *testing.T) { |
| w.WriteHeader(http.StatusNoContent) |
| }) |
| - now := time.Date(2026, 8, 23, 13, 7, 0, 0, time.UTC) |
| + now := time.Now().UTC() |
| occurrence := now.Unix() - 5 |
| r := &schedule.Runner{ |
| Reg: reg, Deliver: &activatorDeliverer{svc: svc}, Token: scheduleToken(dd), |
| @@ -530,3 +531,75 @@ func TestUnixProxyKeepsTheShapeCloseIdleNeeds(t *testing.T) { |
| t.Fatal("edge.UnixProxy's Transport is no longer an *http.Transport — closeIdle is now a no-op") |
| } |
| } |
| + |
| +// The bug, as a test: a schedule that fires more often than the idle window |
| +// used to pin its instance awake forever, because each tick stamped activity |
| +// and bought another full window. With the tick activity-invisible, the |
| +// instance sleeps between ticks and wakes again for the next one. |
| +// |
| +// No timer rows are armed here, deliberately: this exercises the delivery |
| +// leg alone. The sweep guard (which reads the timers table) would correctly |
| +// hold a host awake for a tick due this soon, and that is Task 3's test. |
| +func TestDeliveryDoesNotHoldTheInstanceAwake(t *testing.T) { |
| + const host = "ticky.bab.oncarlos.com" |
| + const idle = 300 * time.Millisecond |
| + reg, inst, svc, _ := wakeFixture(t, host, idle, func(w http.ResponseWriter, r *http.Request) { |
| + w.WriteHeader(http.StatusNoContent) |
| + }) |
| + rt, err := reg.Get(host) |
| + if err != nil { |
| + t.Fatalf("get route: %v", err) |
| + } |
| + d := &activatorDeliverer{svc: svc} |
| + |
| + // Tick twice per idle window for a few windows. Under the old accounting |
| + // the instance could never reach the sweep's threshold; now it does, |
| + // between ticks, and the next tick wakes it again. |
| + deadline := time.Now().Add(20 * time.Second) |
| + for inst.startCount() < 2 && time.Now().Before(deadline) { |
| + req := httptest.NewRequest(http.MethodPost, "http://carlos/jobs/sweep", nil) |
| + if _, err := d.Deliver(context.Background(), *rt, req); err != nil { |
| + t.Fatalf("Deliver: %v", err) |
| + } |
| + time.Sleep(idle / 2) |
| + } |
| + if n := inst.startCount(); n < 2 { |
| + t.Fatalf("the instance started %d time(s): it never hibernated between ticks, so the tick is still buying residency", n) |
| + } |
| +} |
| + |
| +// The app-facing half of the tick contract is untouched by any of this. |
| +func TestDeliveryStillCarriesScheduleAtHeader(t *testing.T) { |
| + const host = "hdr.bab.oncarlos.com" |
| + seen := make(chan string, 1) |
| + reg, _, svc, _ := wakeFixture(t, host, time.Hour, func(w http.ResponseWriter, r *http.Request) { |
| + select { |
| + case seen <- r.Header.Get("X-Carlos-Schedule-At"): |
| + default: |
| + } |
| + w.WriteHeader(http.StatusNoContent) |
| + }) |
| + rt, err := reg.Get(host) |
| + if err != nil { |
| + t.Fatalf("get route: %v", err) |
| + } |
| + d := &activatorDeliverer{svc: svc} |
| + |
| + req := httptest.NewRequest(http.MethodPost, "http://carlos/jobs/sweep", nil) |
| + req.Header.Set("X-Carlos-Schedule-At", "1788126300") |
| + status, err := d.Deliver(context.Background(), *rt, req) |
| + if err != nil { |
| + t.Fatalf("Deliver: %v", err) |
| + } |
| + if status != http.StatusNoContent { |
| + t.Fatalf("status = %d, want 204", status) |
| + } |
| + select { |
| + case got := <-seen: |
| + if got != "1788126300" { |
| + t.Errorf("handler saw X-Carlos-Schedule-At = %q, want it passed through unchanged", got) |
| + } |
| + case <-time.After(10 * time.Second): |
| + t.Fatal("the handler never ran") |
| + } |
| +} |
diff --git a/internal/host/schedule_wiring.go b/internal/host/schedule_wiring.go| index a0af2ac4a..266f3a434 100644 |
| --- a/internal/host/schedule_wiring.go |
| +++ b/internal/host/schedule_wiring.go |
| @@ -73,6 +73,13 @@ func newScheduleDeps(reg *registry.Registry, store release.Store, svc *activator |
| sink.Event(release.RouteAccount(rt), rt.App, rt.Host, level, msg) |
| } |
| } |
| + // Zero when no activator is armed on this box: nothing hibernates, so no |
| + // schedule can hold anything awake, and holdsAwake reports false for all |
| + // of them. |
| + var idle time.Duration |
| + if svc != nil { |
| + idle = svc.IdleWindow() |
| + } |
| sd := &scheduleDeps{ |
| runner: &schedule.Runner{ |
| Reg: reg, |
| @@ -81,6 +88,7 @@ func newScheduleDeps(reg *registry.Registry, store release.Store, svc *activator |
| Event: event, |
| Box: box, |
| Build: version.Build, |
| + Idle: idle, |
| }, |
| } |
| if store == nil { |
| @@ -98,6 +106,7 @@ func newScheduleDeps(reg *registry.Registry, store release.Store, svc *activator |
| } |
| } |
| sd.conv = schedule.NewConverger(reg, store, box, report) |
| + sd.conv.Idle = idle |
| sd.runner.Report = report |
| return sd |
| } |
| @@ -214,8 +223,16 @@ func scheduleLegFor(sd *scheduleDeps) func(ctx context.Context) { |
| // Two properties come from that and only that: |
| // |
| // - a hibernating instance WAKES for its tick, exactly as it would for a |
| -// visitor, and stays awake for the tick's duration because the activator's |
| -// in-flight count holds it open. No separate keep-awake to get wrong. |
| +// visitor, and is held awake for the tick's duration because the |
| +// activator's in-flight count holds it open — but the tick earns no |
| +// activity credit, so it does not by itself keep the instance awake |
| +// afterwards. Separately, the sweeper's schedule guard (activator.go, |
| +// sweepOnce) holds a host resident whenever its next tick is due sooner |
| +// than the idle window, so a schedule firing more often than the idle |
| +// window stays resident by that guard's design rather than thrashing |
| +// through a wake/sleep pair on every tick. A schedule looser than the |
| +// idle window is not covered by the guard and does sleep between ticks, |
| +// waking again for the next one. |
| // - the tick never leaves the box: no DNS, no TLS, no edge auth, nothing an |
| // outsider could aim at the same path. |
| type activatorDeliverer struct { |
| @@ -324,10 +341,12 @@ func (d *activatorDeliverer) Deliver(ctx context.Context, rt registry.Route, req |
| } |
| proxy := d.proxyFor(rt.Socket) |
| h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| - // ServeHTTP reports false for a route it does not manage (not an |
| - // instance, or not hibernate-flagged); that route is proxied straight, |
| - // the same call shape the edge itself uses. |
| - if d.svc == nil || !d.svc.ServeHTTP(w, r, rt, proxy) { |
| + // ServeScheduled, not ServeHTTP: a tick wakes the instance and is |
| + // held in flight for its duration, but must not reset the idle |
| + // clock. Reports false for a route it does not manage (not an |
| + // instance, or not hibernate-flagged); that route is proxied |
| + // straight, the same call shape the edge itself uses. |
| + if d.svc == nil || !d.svc.ServeScheduled(w, r, rt, proxy) { |
| proxy.ServeHTTP(w, r) |
| } |
| }) |
diff --git a/internal/registry/timers.go b/internal/registry/timers.go| index 0d3bba0ef..12cdde238 100644 |
| --- a/internal/registry/timers.go |
| +++ b/internal/registry/timers.go |
| @@ -214,3 +214,30 @@ func (r *Registry) DeleteTimersExcept(host, kind string, keep map[string]bool) ( |
| } |
| return int(n), nil |
| } |
| + |
| +// EarliestTimers is the hibernation guard's batch: for every host with at |
| +// least one armed row, the soonest next_at among them. One query, because |
| +// the activator's sweep reads it BEFORE taking its mutex (activator.go's |
| +// "nothing under a.mu should wait on SQLite") and a per-entry query inside |
| +// the sweep loop would put SQLite exactly where that comment forbids it. |
| +// |
| +// next_at = 0 is "not armed" — a parked cron, or a row converge wrote and |
| +// the runner has not yet dated — and is excluded here rather than by the |
| +// caller, matching DueTimers' own `next_at > 0` gate. |
| +func (r *Registry) EarliestTimers() (map[string]int64, error) { |
| + rows, err := r.sql.Query(`SELECT host, MIN(next_at) FROM timers WHERE next_at > 0 GROUP BY host`) |
| + if err != nil { |
| + return nil, err |
| + } |
| + defer rows.Close() |
| + out := make(map[string]int64) |
| + for rows.Next() { |
| + var host string |
| + var next int64 |
| + if err := rows.Scan(&host, &next); err != nil { |
| + return nil, err |
| + } |
| + out[host] = next |
| + } |
| + return out, rows.Err() |
| +} |
diff --git a/internal/registry/timers_test.go b/internal/registry/timers_test.go| index 3e68a127d..57b2eb85c 100644 |
| --- a/internal/registry/timers_test.go |
| +++ b/internal/registry/timers_test.go |
| @@ -5,6 +5,7 @@ import ( |
| "errors" |
| "os" |
| "path/filepath" |
| + "reflect" |
| "testing" |
| ) |
| @@ -249,3 +250,41 @@ func TestDueTimersExcludesBusyHostsBeforeTheLimit(t *testing.T) { |
| t.Fatalf("all hosts excluded = %+v, want none", due) |
| } |
| } |
| + |
| +func TestEarliestTimersPerHost(t *testing.T) { |
| + r := openTestRegistry(t) |
| + rows := []Timer{ |
| + // Two rows for one host: the earliest wins. |
| + {Host: "a.example", Name: "late", Kind: "schedule", Spec: "every 1h", Path: "/x", NextAt: 5000, DueAt: 5000}, |
| + {Host: "a.example", Name: "soon", Kind: "schedule", Spec: "every 5m", Path: "/y", NextAt: 1200, DueAt: 1200}, |
| + // A second host is reported independently. |
| + {Host: "b.example", Name: "only", Kind: "schedule", Spec: "every 5m", Path: "/z", NextAt: 3000, DueAt: 3000}, |
| + // next_at == 0 means "not armed" and must never appear. |
| + {Host: "c.example", Name: "parked", Kind: "schedule", Spec: "every 5m", Path: "/p", NextAt: 0, DueAt: 0}, |
| + } |
| + for _, tm := range rows { |
| + if err := r.UpsertTimer(tm); err != nil { |
| + t.Fatalf("UpsertTimer %s/%s: %v", tm.Host, tm.Name, err) |
| + } |
| + } |
| + |
| + got, err := r.EarliestTimers() |
| + if err != nil { |
| + t.Fatalf("EarliestTimers: %v", err) |
| + } |
| + want := map[string]int64{"a.example": 1200, "b.example": 3000} |
| + if !reflect.DeepEqual(got, want) { |
| + t.Errorf("EarliestTimers = %v, want %v", got, want) |
| + } |
| +} |
| + |
| +func TestEarliestTimersEmpty(t *testing.T) { |
| + r := openTestRegistry(t) |
| + got, err := r.EarliestTimers() |
| + if err != nil { |
| + t.Fatalf("EarliestTimers: %v", err) |
| + } |
| + if len(got) != 0 { |
| + t.Errorf("EarliestTimers on an empty table = %v, want empty", got) |
| + } |
| +} |
diff --git a/internal/schedule/control.go b/internal/schedule/control.go| index 6a0b7cf0a..14e663f5f 100644 |
| --- a/internal/schedule/control.go |
| +++ b/internal/schedule/control.go |
| @@ -122,7 +122,7 @@ func Mount(mux *http.ServeMux, host string, store TimerStore, token func() (stri |
| http.Error(w, "registry error", http.StatusInternalServerError) |
| return |
| } |
| - writeTimer(w, *row) |
| + writeTimer(w, *row, now()) |
| })) |
| mux.HandleFunc("DELETE /schedule/{name}", authed(func(w http.ResponseWriter, r *http.Request) { |
| @@ -156,7 +156,11 @@ func Mount(mux *http.ServeMux, host string, store TimerStore, token func() (stri |
| Timers []TimerStatus `json:"timers"` |
| }{Timers: []TimerStatus{}} |
| for _, row := range rows { |
| - out.Timers = append(out.Timers, toStatus(row)) |
| + // The control socket has no view of the activator's idle window |
| + // (it is reached from inside the exec child, not the box's |
| + // hibernation wiring), so it makes no holds_awake claim — same |
| + // as any box that predates the field. |
| + out.Timers = append(out.Timers, toStatus(row, 0, now())) |
| } |
| w.Header().Set("Content-Type", "application/json") |
| json.NewEncoder(w).Encode(out) |
| @@ -164,8 +168,10 @@ func Mount(mux *http.ServeMux, host string, store TimerStore, token func() (stri |
| } |
| // writeTimer writes one registry row as its wire TimerStatus (toStatus is |
| -// converge.go's — one row-to-status mapping for the whole package). |
| -func writeTimer(w http.ResponseWriter, r registry.Timer) { |
| +// converge.go's — one row-to-status mapping for the whole package). idle is |
| +// 0 here too, for the reason the /schedule GET handler above documents: the |
| +// control socket cannot see the activator's window. |
| +func writeTimer(w http.ResponseWriter, r registry.Timer, now time.Time) { |
| w.Header().Set("Content-Type", "application/json") |
| - json.NewEncoder(w).Encode(toStatus(r)) |
| + json.NewEncoder(w).Encode(toStatus(r, 0, now)) |
| } |
diff --git a/internal/schedule/converge.go b/internal/schedule/converge.go| index c17df56bd..adbfca188 100644 |
| --- a/internal/schedule/converge.go |
| +++ b/internal/schedule/converge.go |
| @@ -34,6 +34,7 @@ type Converger struct { |
| Now func() time.Time |
| Box string // reported in Status.Box |
| Report func(rt registry.Route, st Status) // called for every host whose rows changed or that is unsupported; nil = no reports |
| + Idle time.Duration // the activator's idle window; zero when no hibernation is armed on this box |
| declEtag map[[2]string]string // (acct, app) → last etag seen |
| declDoc map[[2]string]Declaration |
| @@ -171,7 +172,7 @@ func (c *Converger) convergeApp(ctx context.Context, acct, app string, rts []reg |
| continue |
| } |
| if changed && c.Report != nil { |
| - st, err := StatusFor(c.Reg, rt, c.Box, now) |
| + st, err := StatusFor(c.Reg, rt, c.Box, now, c.Idle) |
| if err != nil { |
| errs = append(errs, err) |
| continue |
| @@ -309,19 +310,31 @@ const UnsupportedUnit = "unit-backed instance — scheduled work delivers to exe |
| // refuses these at `set`; this is the defence for one that slipped past it). |
| const StatusNeverFires = "never fires" |
| -// StatusFor builds the host's report from its rows. |
| -func StatusFor(reg *registry.Registry, rt registry.Route, box string, now time.Time) (Status, error) { |
| +// StatusFor builds the host's report from its rows. idle is the activator's |
| +// idle window on this box, threaded through to holdsAwake — zero when no |
| +// hibernation is armed on the box at all. |
| +// |
| +// Hibernation is also armed PER ROUTE (rt.Hibernate — activator.go will not |
| +// sweep a host unless Hibernate && Status == "ready"), so a box-wide window |
| +// is not sufficient on its own: a route with Hibernate false was never |
| +// going to sleep, schedule or no schedule, and holds_awake must say so by |
| +// reporting false for every one of its rows, not by naming a tight timer |
| +// that changes nothing for it. |
| +func StatusFor(reg *registry.Registry, rt registry.Route, box string, now time.Time, idle time.Duration) (Status, error) { |
| st := Status{Schema: 1, Host: rt.Host, Box: box, ReportedAt: now.UTC().Format(time.RFC3339), Timers: []TimerStatus{}} |
| if rt.Backing == registry.BackingUnit { |
| st.Unsupported = UnsupportedUnit |
| return st, nil |
| } |
| + if !rt.Hibernate { |
| + idle = 0 |
| + } |
| rows, err := reg.TimersFor(rt.Host) |
| if err != nil { |
| return st, err |
| } |
| for _, r := range rows { |
| - st.Timers = append(st.Timers, toStatus(r)) |
| + st.Timers = append(st.Timers, toStatus(r, idle, now)) |
| } |
| return st, nil |
| } |
| @@ -329,6 +342,48 @@ func StatusFor(reg *registry.Registry, rt registry.Route, box string, now time.T |
| // toStatus maps one timers row onto its wire shape. Task 5 (the `carlos |
| // schedule ls` reader / the runner's own report path) reuses this so the |
| // row-to-status mapping is written once. |
| -func toStatus(t registry.Timer) TimerStatus { |
| - return TimerStatus{Name: t.Name, Kind: t.Kind, Spec: t.Spec, Path: t.Path, NextAt: t.NextAt, DueAt: t.DueAt, LastAt: t.LastAt, LastStatus: t.LastStatus, LastMs: t.LastMs, Attempts: t.Attempts} |
| +func toStatus(t registry.Timer, idle time.Duration, now time.Time) TimerStatus { |
| + return TimerStatus{ |
| + Name: t.Name, Kind: t.Kind, Spec: t.Spec, Path: t.Path, |
| + NextAt: t.NextAt, DueAt: t.DueAt, LastAt: t.LastAt, |
| + LastStatus: t.LastStatus, LastMs: t.LastMs, Attempts: t.Attempts, |
| + HoldsAwake: holdsAwake(t, idle, now), |
| + } |
| +} |
| + |
| +// holdsAwake reports whether t's interval is tight enough that its instance |
| +// never gets to sleep: the gap between consecutive occurrences is no wider |
| +// than idle, so the activator's guard (and, before it, the tick's own wake) |
| +// keeps the process resident indefinitely. |
| +// |
| +// The gap comes from two Next calls rather than Spec.Every, so a cron gets a |
| +// real answer instead of a special case — but for an irregular cron (e.g. |
| +// "0,5 * * * *": five minutes then fifty-five) that first gap is a SAMPLE, |
| +// not a bound: the same row can answer true at :59 and false at :01, |
| +// depending on when the report happens to be built. That is the direction |
| +// to err in — over-warning about a bill beats missing one — and is chosen |
| +// deliberately, not an oversight. |
| +// |
| +// idle <= 0 means hibernation is not armed — either the box has no |
| +// activator, or (StatusFor) the route itself has Hibernate off — so |
| +// nothing holds anything awake, because nothing sleeps. A once-timer fires |
| +// once and cannot hold anything. An unparseable spec is not evidence of a |
| +// problem, so it makes no claim. |
| +func holdsAwake(t registry.Timer, idle time.Duration, now time.Time) bool { |
| + if idle <= 0 || t.Kind != KindSchedule { |
| + return false |
| + } |
| + sp, err := ParseSpec(t.Spec) |
| + if err != nil { |
| + return false |
| + } |
| + first := sp.Next(now) |
| + if first.IsZero() { |
| + return false |
| + } |
| + second := sp.Next(first) |
| + if second.IsZero() { |
| + return false |
| + } |
| + return second.Sub(first) <= idle |
| } |
diff --git a/internal/schedule/converge_test.go b/internal/schedule/converge_test.go| index e451c2273..c8375bf39 100644 |
| --- a/internal/schedule/converge_test.go |
| +++ b/internal/schedule/converge_test.go |
| @@ -11,6 +11,62 @@ import ( |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| +func TestHoldsAwakeWhenTheIntervalIsInsideTheIdleWindow(t *testing.T) { |
| + now := time.Unix(1788120000, 0).UTC() |
| + cases := []struct { |
| + name string |
| + kind string |
| + spec string |
| + idle time.Duration |
| + want bool |
| + }{ |
| + {"tighter than the window", KindSchedule, "every 5m", 15 * time.Minute, true}, |
| + {"exactly the window", KindSchedule, "every 15m", 15 * time.Minute, true}, |
| + {"wider than the window", KindSchedule, "every 1h", 15 * time.Minute, false}, |
| + {"tight, short window", KindSchedule, "every 5m", 90 * time.Second, false}, |
| + {"a once-timer never holds", KindOnce, "at 1788130000", 15 * time.Minute, false}, |
| + {"hibernation not armed", KindSchedule, "every 1m", 0, false}, |
| + {"hourly cron", KindSchedule, "cron 0 * * * *", 15 * time.Minute, false}, |
| + {"every-minute cron", KindSchedule, "cron * * * * *", 15 * time.Minute, true}, |
| + {"unparseable spec is not a claim", KindSchedule, "banana", 15 * time.Minute, false}, |
| + } |
| + for _, tc := range cases { |
| + t.Run(tc.name, func(t *testing.T) { |
| + got := holdsAwake(registry.Timer{Kind: tc.kind, Spec: tc.spec}, tc.idle, now) |
| + if got != tc.want { |
| + t.Errorf("holdsAwake(%q, idle=%s) = %v, want %v", tc.spec, tc.idle, got, tc.want) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// Hibernation is armed PER ROUTE (rt.Hibernate), not just per box: a route |
| +// with Hibernate off was never going to sleep, so no schedule on it can |
| +// hold anything awake, however tight. StatusFor must zero the window before |
| +// holdsAwake ever sees it — a box-wide idle window alone is not enough. |
| +func TestStatusForNeverClaimsHoldsAwakeOffARouteWithHibernateOff(t *testing.T) { |
| + reg, err := registry.Open(filepath.Join(t.TempDir(), "r.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { reg.Close() }) |
| + rt := registry.Route{Host: "resident.bab.oncarlos.com", Kind: "instance", Account: "bab", App: "app", Status: "ready", Hibernate: false} |
| + if err := reg.Upsert(rt); err != nil { |
| + t.Fatal(err) |
| + } |
| + now := at("2026-08-23T13:07:00Z") |
| + if err := reg.UpsertTimer(registry.Timer{Host: rt.Host, Name: "sweep", Kind: KindSchedule, Spec: "every 5m", Path: "/jobs/sweep", CreatedAt: now.Unix()}); err != nil { |
| + t.Fatal(err) |
| + } |
| + st, err := StatusFor(reg, rt, "box1", now, 15*time.Minute) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(st.Timers) != 1 || st.Timers[0].HoldsAwake { |
| + t.Fatalf("HoldsAwake = %v on a Hibernate:false route, want false regardless of the box's idle window", st.Timers[0].HoldsAwake) |
| + } |
| +} |
| + |
| func convergeFixture(t *testing.T) (*registry.Registry, *release.DirStore) { |
| t.Helper() |
| reg, err := registry.Open(filepath.Join(t.TempDir(), "r.db")) |
diff --git a/internal/schedule/runner.go b/internal/schedule/runner.go| index e1e6501b8..e4b5c1e7b 100644 |
| --- a/internal/schedule/runner.go |
| +++ b/internal/schedule/runner.go |
| @@ -60,6 +60,7 @@ type Runner struct { |
| Report func(rt registry.Route, st Status) |
| Box string |
| Build string |
| + Idle time.Duration // the activator's idle window; zero when no hibernation is armed on this box |
| // Per-host work is DETACHED from the tick: Tick launches one goroutine |
| // per host with due rows and returns without waiting for any of them. |
| @@ -176,7 +177,7 @@ func (r *Runner) Tick(ctx context.Context) { |
| // A report that cannot be built is a silent hole in `carlos |
| // schedule ls` — the member's only view of this box. Say so: |
| // the fires themselves still landed on the rows. |
| - if st, err := StatusFor(r.Reg, rt, r.Box, r.now()); err != nil { |
| + if st, err := StatusFor(r.Reg, rt, r.Box, r.now(), r.Idle); err != nil { |
| log.Printf("schedule: %s: build status report: %v", rt.Host, err) |
| } else { |
| r.Report(rt, st) |
diff --git a/internal/schedule/status.go b/internal/schedule/status.go| index b70b352a3..cc51aa6f6 100644 |
| --- a/internal/schedule/status.go |
| +++ b/internal/schedule/status.go |
| @@ -32,6 +32,13 @@ type TimerStatus struct { |
| LastStatus string `json:"last_status"` |
| LastMs int64 `json:"last_ms"` |
| Attempts int `json:"attempts"` |
| + |
| + // HoldsAwake is the box's warning: this row fires at least as often as |
| + // the deployment's idle window, so its instance never hibernates between |
| + // ticks and accrues instance-hours around the clock. Computed by the box, |
| + // which is the only place that holds both numbers. False from a box that |
| + // predates the field, and false wherever hibernation is not armed. |
| + HoldsAwake bool `json:"holds_awake,omitempty"` |
| } |
| // Status is apps/<acct>/<app>/schedule-status/<host>.json (spec §4.3): |
diff --git a/internal/schedule/status_test.go b/internal/schedule/status_test.go| index e6d3c8e60..ac1f472e7 100644 |
| --- a/internal/schedule/status_test.go |
| +++ b/internal/schedule/status_test.go |
| @@ -2,11 +2,25 @@ package schedule |
| import ( |
| "context" |
| + "encoding/json" |
| "testing" |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| +// The wire shape is additive: an object written before this field existed |
| +// must still parse, and read back as false. |
| +func TestStatusWithoutHoldsAwakeStillParses(t *testing.T) { |
| + raw := []byte(`{"schema":1,"host":"h","box":"b","reported_at":"2026-08-30T21:40:05Z","timers":[{"name":"sweep","kind":"schedule","spec":"every 5m","path":"/jobs/sweep","next_at":1788126300}]}`) |
| + var st Status |
| + if err := json.Unmarshal(raw, &st); err != nil { |
| + t.Fatalf("unmarshal a pre-holds_awake status: %v", err) |
| + } |
| + if len(st.Timers) != 1 || st.Timers[0].HoldsAwake { |
| + t.Errorf("HoldsAwake = %v, want false for an object written before the field existed", st.Timers[0].HoldsAwake) |
| + } |
| +} |
| + |
| func TestWriteAndListStatus(t *testing.T) { |
| ctx := context.Background() |
| st := release.NewDirStore(t.TempDir()) |
diff --git a/schedule.go b/schedule.go| index 172a7db42..8d4af1f44 100644 |
| --- a/schedule.go |
| +++ b/schedule.go |
| @@ -351,6 +351,13 @@ func renderScheduleInstance(w io.Writer, inst schedule.Status, now time.Time) { |
| last += " (" + t.LastStatus + ")" |
| } |
| fmt.Fprintf(w, " %-*snext %s last %s\n", nameW+2, labels[i], next, last) |
| + if t.HoldsAwake { |
| + // The box worked this out; we only pass it on. Worth a whole |
| + // line rather than a marker on the row: the member's next |
| + // question is "so what?", and the answer is what they are being |
| + // billed for. |
| + fmt.Fprintf(w, " %-*sthis fires more often than the idle window, so the instance never hibernates between ticks\n", nameW+2, "") |
| + } |
| } |
| } |
diff --git a/schedule_test.go b/schedule_test.go| index baeda5a9b..0fbac0a45 100644 |
| --- a/schedule_test.go |
| +++ b/schedule_test.go |
| @@ -6,6 +6,7 @@ package main |
| // one client-side refusal (`-every 30s`) that must never reach the network. |
| import ( |
| + "bytes" |
| "context" |
| "encoding/json" |
| "net/http" |
| @@ -311,6 +312,48 @@ func TestRenderScheduleListNilSlices(t *testing.T) { |
| } |
| } |
| +func TestScheduleLsFlagsAHoldsAwakeRow(t *testing.T) { |
| + var b bytes.Buffer |
| + now := time.Unix(1788126000, 0).UTC() |
| + inst := schedule.Status{ |
| + Schema: 1, Host: "jam.bac.oncarlos.com", Box: "annie-welding", |
| + ReportedAt: now.Format(time.RFC3339), |
| + Timers: []schedule.TimerStatus{{ |
| + Name: "sweep", Kind: schedule.KindSchedule, Spec: "every 5m", |
| + Path: "/jobs/sweep", NextAt: now.Add(2 * time.Minute).Unix(), |
| + LastAt: now.Add(-3 * time.Minute).Unix(), LastStatus: "204", LastMs: 1, |
| + HoldsAwake: true, |
| + }}, |
| + } |
| + renderScheduleInstance(&b, inst, now) |
| + |
| + out := b.String() |
| + if !strings.Contains(out, "sweep") { |
| + t.Fatalf("row missing from output:\n%s", out) |
| + } |
| + if !strings.Contains(out, "never hibernates") { |
| + t.Errorf("no holds-awake warning in output:\n%s", out) |
| + } |
| +} |
| + |
| +func TestScheduleLsSaysNothingForAnOrdinaryRow(t *testing.T) { |
| + var b bytes.Buffer |
| + now := time.Unix(1788126000, 0).UTC() |
| + inst := schedule.Status{ |
| + Schema: 1, Host: "seapointish.bag.oncarlos.com", Box: "annie-welding", |
| + ReportedAt: now.Format(time.RFC3339), |
| + Timers: []schedule.TimerStatus{{ |
| + Name: "sync", Kind: schedule.KindSchedule, Spec: "every 6h", |
| + Path: "/jobs/sync", NextAt: now.Add(time.Hour).Unix(), |
| + }}, |
| + } |
| + renderScheduleInstance(&b, inst, now) |
| + |
| + if strings.Contains(b.String(), "never hibernates") { |
| + t.Errorf("warned about a 6-hourly schedule:\n%s", b.String()) |
| + } |
| +} |
| + |
| // TestScheduleVerbRefusals drives the real binary for the ways `carlos |
| // schedule` stops before it ever dials anything: direct bucket mode, and |
| // -every 30s (schedule.ValidEvery's "whole minutes only" rule) — run with |