mirror of
https://github.com/zap-proto/zip.git
synced 2026-10-19 15:38:57 +00:00
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]>
135 lines
4.0 KiB
Go
135 lines
4.0 KiB
Go
package zip_test
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/zap-proto/zip"
|
|
)
|
|
|
|
// TestIdle_ReclaimsRealMemory is the claim measured rather than asserted: what
|
|
// eviction is FOR is resident memory, so this reads the operating system's
|
|
// number for the child processes, not a counter the code keeps about itself.
|
|
//
|
|
// It warms N plugins, records the resident set of every child, evicts, and
|
|
// records it again. A test that only checked Running would pass on an
|
|
// implementation that leaked the process while forgetting the pointer.
|
|
//
|
|
// Run with -v to see the numbers:
|
|
//
|
|
// go test -run TestIdle_ReclaimsRealMemory -v .
|
|
func TestIdle_ReclaimsRealMemory(t *testing.T) {
|
|
const plugins = 6
|
|
bin := buildPlugin(t, "v1")
|
|
|
|
app := zip.New(zip.Config{AppName: "host", DisableStartupMessage: true})
|
|
for i := 0; i < plugins; i++ {
|
|
name := fmt.Sprintf("demo%d", i)
|
|
app.Use(must(zip.Load(
|
|
zip.Plugin{
|
|
Name: name, Bin: bin, Dir: t.TempDir(), Lazy: true,
|
|
IdleAfter: 50 * time.Millisecond,
|
|
}, "/v1/"+name,
|
|
)))
|
|
}
|
|
defer func() { _ = app.Shutdown() }()
|
|
|
|
// Warm every one, the way a host serving its whole catalog would.
|
|
for i := 0; i < plugins; i++ {
|
|
if status, _ := call(t, app, "GET", fmt.Sprintf("/v1/demo%d/version", i), ""); status != 200 {
|
|
t.Fatalf("demo%d did not serve", i)
|
|
}
|
|
}
|
|
warmPIDs := runningPIDs(t, app)
|
|
if len(warmPIDs) != plugins {
|
|
t.Fatalf("warmed %d plugins, want %d", len(warmPIDs), plugins)
|
|
}
|
|
warmRSS := totalRSS(t, warmPIDs)
|
|
|
|
time.Sleep(80 * time.Millisecond)
|
|
if n := app.Evict(); n != plugins {
|
|
t.Fatalf("evicted %d of %d", n, plugins)
|
|
}
|
|
if !settles(3*time.Second, func() bool { return len(runningPIDs(t, app)) == 0 }) {
|
|
t.Fatalf("plugins still running after eviction: %v", runningPIDs(t, app))
|
|
}
|
|
|
|
// The processes are gone from the OS, not merely forgotten by the host.
|
|
// This is the half a Running check cannot make.
|
|
//
|
|
// It has to WAIT for them. retire() is asynchronous by design — the caller
|
|
// is not blocked for the drain — so Running goes false the moment cur is
|
|
// cleared, which is strictly before the child is reaped. Asserting
|
|
// residency at that instant tests the scheduler, not the code: it passed
|
|
// on one machine and failed on the next, which is how this comment came to
|
|
// be written.
|
|
alive := len(warmPIDs)
|
|
settles(5*time.Second, func() bool {
|
|
alive = 0
|
|
for _, pid := range warmPIDs {
|
|
if rssOf(pid) > 0 {
|
|
alive++
|
|
}
|
|
}
|
|
return alive == 0
|
|
})
|
|
if alive != 0 {
|
|
t.Errorf("%d of %d evicted processes still resident after 5s — the pointer was dropped but the process was not", alive, len(warmPIDs))
|
|
}
|
|
|
|
t.Logf("%d plugins warm: %.1f MiB resident across %d child processes",
|
|
plugins, float64(warmRSS)/1024, len(warmPIDs))
|
|
t.Logf("after eviction: 0 child processes, %.1f MiB reclaimed", float64(warmRSS)/1024)
|
|
t.Logf("per plugin: %.1f MiB — multiply by a catalog to see why this matters",
|
|
float64(warmRSS)/1024/float64(plugins))
|
|
|
|
// And the catalog still works: eviction is not a teardown.
|
|
if status, _ := call(t, app, "GET", "/v1/demo0/version", ""); status != 200 {
|
|
t.Fatal("a route stopped answering after its plugin was evicted")
|
|
}
|
|
}
|
|
|
|
func runningPIDs(t *testing.T, app *zip.App) []int {
|
|
t.Helper()
|
|
var out []int
|
|
for _, p := range app.Plugins() {
|
|
if p.Running && p.PID != 0 {
|
|
out = append(out, p.PID)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// rssOf reads VmRSS in KiB from /proc, or 0 if the process is gone. The OS is
|
|
// the authority here; anything the host reports about itself could be wrong in
|
|
// exactly the way this test exists to catch.
|
|
func rssOf(pid int) int {
|
|
b, err := os.ReadFile(fmt.Sprintf("/proc/%d/status", pid))
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
for _, line := range strings.Split(string(b), "\n") {
|
|
if strings.HasPrefix(line, "VmRSS:") {
|
|
f := strings.Fields(line)
|
|
if len(f) >= 2 {
|
|
kb, _ := strconv.Atoi(f[1])
|
|
return kb
|
|
}
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func totalRSS(t *testing.T, pids []int) int {
|
|
t.Helper()
|
|
sum := 0
|
|
for _, pid := range pids {
|
|
sum += rssOf(pid)
|
|
}
|
|
return sum
|
|
}
|