Files
zip/idle_test.go
zeekayandHanzo Dev 0408d1b48c hold the plugin ceiling at the start, not on a ticker
Warm was an argument to Evict, so the count was TRIMMED once a minute rather than
held. That is a bound only while starts arrive slower than the sweep, and the
thing that starts fastest is exactly the thing that needs bounding: a host whose
fan-out door asks every subsystem at once started ~100 children inside 90
seconds, held all of them until the next sweep, stopped answering its own
liveness probe, and was killed. The ceiling appeared only in the logs of a
container that was already gone.

So Warm is Config now and makeRoom applies it in startOnDemand, before another
process exists: if the host is at its ceiling, the least recently used evictable
plugin is stopped to make room. Age keeps its own sweep — steady state and burst
are different bounds and both belong to the same policy.

Adoption is what makes it possible. A plugin's `app` is the definition that
Load'ed it, which for a composed service is a sub-app holding one plugin and no
ceiling, so asking it "how many are running" answers 1 and "what is the budget"
answers 0 — measured, and the reason a first attempt at this was inert. The root
of a composition is known in exactly one place, where the generation computes its
hosts, so that is where every plugin learns its owner.

Both halves are proven necessary by mutation: dropping the start-path call, or
dropping the adoption, each turns the burst test from peak 4 into peak 12 against
a ceiling of 4. Race-clean over two runs; full suite green.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-15 08:50:45 -07:00

167 lines
5.6 KiB
Go

package zip_test
import (
"testing"
"time"
"github.com/zap-proto/zip"
)
// TestIdle_EvictsAndComesBack is the whole contract in one pass: a lazy plugin
// that has gone unused is stopped, and the next request starts it again.
//
// The second half matters more than the first. Stopping a process is easy; the
// reason this is eviction and not Unload is that the route still works
// afterwards, and the caller cannot tell except by the pid.
func TestIdle_EvictsAndComesBack(t *testing.T) {
bin := buildPlugin(t, "v1")
app := zip.New(zip.Config{AppName: "host", DisableStartupMessage: true})
app.Use(must(zip.Load(
zip.Plugin{
Name: "demo", Bin: bin, Dir: t.TempDir(), Lazy: true,
IdleAfter: 50 * time.Millisecond,
}, "/v1/demo",
)))
defer func() { _ = app.Shutdown() }()
if status, body := call(t, app, "GET", "/v1/demo/version", ""); status != 200 {
t.Fatalf("first request: status=%d body=%q", status, body)
}
first := app.Plugins()[0]
if !first.Running || first.PID == 0 {
t.Fatalf("plugin not running after a served request: %+v", first)
}
// Fresh from a request, so it is NOT idle. An evictor that stops a plugin
// that just served would be a request-latency bug that only shows in prod.
if n := app.Evict(); n != 0 {
t.Fatalf("evicted %d plugins that had just served", n)
}
if !app.Plugins()[0].Running {
t.Fatal("plugin stopped despite serving a moment ago")
}
time.Sleep(80 * time.Millisecond)
if n := app.Evict(); n != 1 {
t.Fatalf("Evict stopped %d, want 1 after idling past IdleAfter", n)
}
// retire() drains asynchronously; with Drain unset there is no grace to
// wait out, so this settles immediately in practice.
if !settles(2*time.Second, func() bool { return !app.Plugins()[0].Running }) {
t.Fatalf("plugin still running after eviction: %+v", app.Plugins()[0])
}
// The route still answers, and answers from a DIFFERENT process.
status, body := call(t, app, "GET", "/v1/demo/version", "")
if status != 200 {
t.Fatalf("request after eviction: status=%d body=%q — eviction must not take the route down", status, body)
}
second := app.Plugins()[0]
if !second.Running || second.PID == 0 {
t.Fatalf("plugin did not restart on demand: %+v", second)
}
if second.PID == first.PID {
t.Fatalf("same pid %d before and after eviction — nothing was actually stopped", first.PID)
}
}
// TestIdle_ZeroNeverEvicts is the default, and the setting an always-hot plugin
// wants: identity or config that every other call goes through must never make
// a caller pay a cold start.
func TestIdle_ZeroNeverEvicts(t *testing.T) {
bin := buildPlugin(t, "v1")
app := zip.New(zip.Config{AppName: "host", DisableStartupMessage: true})
app.Use(must(zip.Load(
zip.Plugin{Name: "demo", Bin: bin, Dir: t.TempDir(), Lazy: true}, // IdleAfter unset
"/v1/demo",
)))
defer func() { _ = app.Shutdown() }()
if status, _ := call(t, app, "GET", "/v1/demo/version", ""); status != 200 {
t.Fatal("first request failed")
}
pid := app.Plugins()[0].PID
time.Sleep(60 * time.Millisecond)
if n := app.Evict(); n != 0 {
t.Fatalf("evicted %d with IdleAfter unset — zero must mean never", n)
}
if got := app.Plugins()[0]; !got.Running || got.PID != pid {
t.Fatalf("plugin disturbed despite IdleAfter unset: %+v (was pid %d)", got, pid)
}
}
// TestIdle_EagerIsNeverEvicted: an eager plugin was started at Load on purpose.
// Stopping it would contradict the instruction that started it, and its next
// request would pay a start it was configured never to pay.
func TestIdle_EagerIsNeverEvicted(t *testing.T) {
bin := buildPlugin(t, "v1")
app := zip.New(zip.Config{AppName: "host", DisableStartupMessage: true})
app.Use(must(zip.Load(
zip.Plugin{
Name: "demo", Bin: bin, Dir: t.TempDir(), // Lazy false
IdleAfter: 10 * time.Millisecond,
}, "/v1/demo",
)))
defer func() { _ = app.Shutdown() }()
pid := app.Plugins()[0].PID
if pid == 0 {
t.Fatal("eager plugin did not start at Load")
}
// Serve one request first. Without it lastUse stays zero and evictIfIdle
// returns on that check before it ever consults Lazy — the test would pass
// whether or not the eager guard exists, which a mutation proved it did.
if status, _ := call(t, app, "GET", "/v1/demo/version", ""); status != 200 {
t.Fatal("eager plugin did not serve")
}
time.Sleep(40 * time.Millisecond)
if n := app.Evict(); n != 0 {
t.Fatalf("evicted %d eager plugins — IdleAfter applies to lazy only", n)
}
if got := app.Plugins()[0]; !got.Running || got.PID != pid {
t.Fatalf("eager plugin stopped: %+v (was pid %d)", got, pid)
}
}
// TestIdle_ReapRunsAndStops covers the ticker wrapper, including that its
// stop function is idempotent — a host calling it twice (defer plus an explicit
// shutdown path) must not panic on a closed channel.
func TestIdle_ReapRunsAndStops(t *testing.T) {
bin := buildPlugin(t, "v1")
app := zip.New(zip.Config{AppName: "host", DisableStartupMessage: true})
app.Use(must(zip.Load(
zip.Plugin{
Name: "demo", Bin: bin, Dir: t.TempDir(), Lazy: true,
IdleAfter: 20 * time.Millisecond,
}, "/v1/demo",
)))
defer func() { _ = app.Shutdown() }()
if status, _ := call(t, app, "GET", "/v1/demo/version", ""); status != 200 {
t.Fatal("first request failed")
}
stop := app.Reap(10 * time.Millisecond)
if !settles(3*time.Second, func() bool { return !app.Plugins()[0].Running }) {
t.Fatal("Reap never evicted an idle plugin")
}
stop()
stop() // idempotent
}
func settles(limit time.Duration, ok func() bool) bool {
deadline := time.Now().Add(limit)
for time.Now().Before(deadline) {
if ok() {
return true
}
time.Sleep(5 * time.Millisecond)
}
return ok()
}