activator: a plan can shorten an entry's idle window
SetIdleFor installs the hook; the sweep takes the smaller of the entry's own window and the plan's, ignores 0, and never reaches always-on workers. The agent wires planIdleFor from the plans cache.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
5 files changed,
+180
−0
internal/activator/activator.go+22 −0internal/activator/activator_idlefor_test.go+113 −0internal/host/agent.go+4 −0internal/host/plans_idle_test.go+29 −0internal/host/plans_pass.go+12 −0
diff --git a/internal/activator/activator.go b/internal/activator/activator.go| index a0d56891b..dbd881d66 100644 |
| --- a/internal/activator/activator.go |
| +++ b/internal/activator/activator.go |
| @@ -1139,6 +1139,11 @@ type Service struct { |
| // edge chose before recovery moved their upstream. |
| startupSeenAt atomic.Int64 |
| + // idleFor is the plans idle hook (plans 2), set through SetIdleFor: given an |
| + // entry's route it names the plan's idle window, or 0 for no opinion. It |
| + // can only shorten a window. nil means today's behaviour. |
| + idleFor atomic.Pointer[func(registry.Route) time.Duration] |
| + |
| cfg Config |
| backend Backend |
| due DueStore |
| @@ -4647,6 +4652,11 @@ func (a *Service) sweepOnce() { |
| if e.workerIdle > 0 { |
| idleWindow = e.workerIdle |
| } |
| + if f := a.idleFor.Load(); f != nil { |
| + if d := (*f)(e.route); d > 0 && d < idleWindow { |
| + idleWindow = d |
| + } |
| + } |
| if hibernates && e.inflight == 0 && e.cmd == nil && now.Sub(e.lastActive) >= idleWindow { |
| // Don't sleep into an imminent tick. A wake/sleep pair is a full |
| // checkpoint-and-restore round trip; spending one to be absent |
| @@ -5446,3 +5456,15 @@ func (a *Service) serveWakeError(w http.ResponseWriter, r *http.Request, rt regi |
| } |
| return true |
| } |
| + |
| +// SetIdleFor installs the plans idle hook (plans 2). A plan can only shorten |
| +// an entry's idle window: the sweep takes the smaller of the entry's own |
| +// window and the hook's answer, and ignores 0. Always-on workers have no idle |
| +// clock, so the hook never reaches them. nil removes the hook. |
| +func (a *Service) SetIdleFor(f func(registry.Route) time.Duration) { |
| + if f == nil { |
| + a.idleFor.Store(nil) |
| + return |
| + } |
| + a.idleFor.Store(&f) |
| +} |
diff --git a/internal/activator/activator_idlefor_test.go b/internal/activator/activator_idlefor_test.go| new file mode 100644 |
| index 000000000..7eb8f2835 |
| --- /dev/null |
| +++ b/internal/activator/activator_idlefor_test.go |
| @@ -0,0 +1,113 @@ |
| +package activator |
| + |
| +import ( |
| + "context" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +// The plans idle hook (plans 2): a plan may only shorten an entry's idle |
| +// window, never lengthen it, and never touches an always-on worker. |
| + |
| +func idleFor(d time.Duration) func(registry.Route) time.Duration { |
| + return func(registry.Route) time.Duration { return d } |
| +} |
| + |
| +func wakeInstance(t *testing.T, a *Service, host string) { |
| + t.Helper() |
| + release, err := a.beginRequest(context.Background(), testRoute(host)) |
| + if err != nil { |
| + t.Fatalf("beginRequest: %v", err) |
| + } |
| + release() |
| +} |
| + |
| +func TestIdleForShortensAnInstance(t *testing.T) { |
| + t.Parallel() |
| + a, _, clk, _ := testActivator(t, 15*time.Minute) |
| + a.SetIdleFor(idleFor(90 * time.Second)) |
| + wakeInstance(t, a, "one.example.com") |
| + clk.advance(80 * time.Second) |
| + a.sweepOnce() |
| + if a.entryCount() != 1 { |
| + t.Fatal("slept before the plan's 90s window") |
| + } |
| + clk.advance(11 * time.Second) |
| + a.sweepOnce() |
| + waitFor(t, "plan idle sleep", func() bool { return a.entryCount() == 0 }) |
| +} |
| + |
| +func TestPlanIdleOnlyShortens(t *testing.T) { |
| + t.Parallel() |
| + a, _, clk, _ := testActivator(t, 5*time.Minute) |
| + a.SetIdleFor(idleFor(15 * time.Minute)) |
| + wakeInstance(t, a, "one.example.com") |
| + clk.advance(5*time.Minute + time.Second) |
| + a.sweepOnce() |
| + waitFor(t, "box-default sleep", func() bool { return a.entryCount() == 0 }) |
| +} |
| + |
| +func TestIdleForShortensAWorkerToTheSmaller(t *testing.T) { |
| + t.Parallel() |
| + for _, c := range []struct { |
| + name string |
| + worker int |
| + plan, sleep time.Duration |
| + }{ |
| + {"plan shorter than the worker's own", 600, 90 * time.Second, 90 * time.Second}, |
| + {"worker's own shorter than the plan", 60, 90 * time.Second, 60 * time.Second}, |
| + } { |
| + a, _, clk, _ := testActivator(t, time.Hour) |
| + a.SetIdleFor(idleFor(c.plan)) |
| + rt := testWorkerRoute("bot.x.internal") |
| + rt.IdleSeconds = c.worker |
| + if err := a.WakeWorker(rt); err != nil { |
| + t.Fatalf("%s: WakeWorker: %v", c.name, err) |
| + } |
| + clk.advance(c.sleep - 5*time.Second) |
| + a.sweepOnce() |
| + if !a.entryAwake("bot.x.internal") { |
| + t.Fatalf("%s: slept early", c.name) |
| + } |
| + clk.advance(6 * time.Second) |
| + a.sweepOnce() |
| + waitFor(t, c.name, func() bool { return a.entryCount() == 0 }) |
| + } |
| +} |
| + |
| +func TestIdleForNilOrZeroChangesNothing(t *testing.T) { |
| + t.Parallel() |
| + for name, hook := range map[string]func(registry.Route) time.Duration{"nil": nil, "zero": idleFor(0)} { |
| + a, _, clk, _ := testActivator(t, 10*time.Second) |
| + a.SetIdleFor(hook) |
| + wakeInstance(t, a, "one.example.com") |
| + clk.advance(9 * time.Second) |
| + a.sweepOnce() |
| + if a.entryCount() != 1 { |
| + t.Fatalf("%s hook: slept before the box default", name) |
| + } |
| + clk.advance(2 * time.Second) |
| + a.sweepOnce() |
| + waitFor(t, name+" hook: default sleep", func() bool { return a.entryCount() == 0 }) |
| + } |
| +} |
| + |
| +func TestIdleForLeavesAlwaysOnWorkersAlone(t *testing.T) { |
| + t.Parallel() |
| + a, _, clk, _ := testActivator(t, time.Hour) |
| + a.SetIdleFor(idleFor(90 * time.Second)) |
| + rt := testWorkerRoute("bot.x.internal") |
| + rt.Hibernate = false |
| + if err := a.WakeWorker(rt); err != nil { |
| + t.Fatalf("WakeWorker: %v", err) |
| + } |
| + for i := 0; i < 5; i++ { |
| + clk.advance(time.Hour) |
| + a.sweepOnce() |
| + } |
| + if a.entryCount() != 1 { |
| + t.Fatal("an always-on worker was stopped by the plan's idle window") |
| + } |
| +} |
diff --git a/internal/host/agent.go b/internal/host/agent.go| index ee1b40ad7..3229da5c2 100644 |
| --- a/internal/host/agent.go |
| +++ b/internal/host/agent.go |
| @@ -495,6 +495,10 @@ func runAgent(args []string, buildClaims ClaimFactory) { |
| // (design §6.8). The registry satisfies RouteLookup with its own |
| // Get, exactly as DueStore above. |
| svc.Routes = reg |
| + // Plans 2: an account's plan can shorten its entries' idle window. With |
| + // plans off (no marker, CARLOS_PLAN_LIMITS=off, or no record) the hook |
| + // answers 0 and every window is the box's own. |
| + svc.SetIdleFor(planIdleFor) |
| // Settings spec §4.2: before any process runs a build against an |
| // instance's database, the build state names it, under the lease. A |
| // store that failed to start refuses every spawn (never "run |
diff --git a/internal/host/plans_idle_test.go b/internal/host/plans_idle_test.go| new file mode 100644 |
| index 000000000..867033b43 |
| --- /dev/null |
| +++ b/internal/host/plans_idle_test.go |
| @@ -0,0 +1,29 @@ |
| +package host |
| + |
| +// Serial: it writes the package-level plans cache. |
| + |
| +import ( |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/plans" |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +func TestPlanIdleFor(t *testing.T) { |
| + t.Cleanup(func() { planLimits.Store(nil) }) |
| + rt := registry.Route{Host: "a.example", Kind: "instance", Account: "bab", App: "web"} |
| + planLimits.Store(nil) |
| + if d := planIdleFor(rt); d != 0 { |
| + t.Fatalf("plans off: %v, want 0", d) |
| + } |
| + planLimits.Store(&planLimitsSnapshot{byAccount: map[string]plans.Limits{"bab": {IdleSeconds: 90}}}) |
| + if d := planIdleFor(rt); d != 90*time.Second { |
| + t.Fatalf("bab: %v, want 90s", d) |
| + } |
| + other := rt |
| + other.Account = "bdk" |
| + if d := planIdleFor(other); d != 0 { |
| + t.Fatalf("account with no record: %v, want 0", d) |
| + } |
| +} |
diff --git a/internal/host/plans_pass.go b/internal/host/plans_pass.go| index f2daf51aa..041e2bf63 100644 |
| --- a/internal/host/plans_pass.go |
| +++ b/internal/host/plans_pass.go |
| @@ -15,6 +15,7 @@ import ( |
| "os" |
| "strings" |
| "sync/atomic" |
| + "time" |
| "github.com/carlosframework/platform/internal/plans" |
| "github.com/carlosframework/platform/internal/registry" |
| @@ -115,3 +116,14 @@ func planLimitsFailed(format string, args ...any) { |
| log.Printf("agent: plans pass: "+format+" (keeping the last good limits)", args...) |
| } |
| } |
| + |
| +// planIdleFor is the activator's plans idle hook: the route's account's idle |
| +// window from its limits, or 0 (no opinion) when plans are off or the account |
| +// has no record. |
| +func planIdleFor(rt registry.Route) time.Duration { |
| + l, ok := planLimitsFor(release.RouteAccount(rt)) |
| + if !ok || l.IdleSeconds <= 0 { |
| + return 0 |
| + } |
| + return time.Duration(l.IdleSeconds) * time.Second |
| +} |