Files

386 lines
15 KiB
Go

package zip
import (
"cmp"
"fmt"
"slices"
"sync"
"time"
)
// Idle plugins cost what a running plugin costs, which is not nothing.
//
// A host that composes many services pays for every one it has ever served,
// forever: Lazy defers the first start, and nothing has ever stopped one. On a
// host running two dozen subsystems that is the dominant resident cost — each
// child holds its own Go heap, and the total tracks the SIZE OF THE CATALOG
// rather than the traffic. Measured on one such host: 24 children, ~150MiB of
// live heap each, ~4.4GiB resident, while three of them (the ones that do
// little) sat at 12-20MiB. The catalog was the bill.
//
// Eviction is the other half of Lazy, and it is deliberately not Unload:
// Unload means "stay down", eviction means "you may go, come back when asked".
// The next request through target() starts it again by the path that already
// exists, so the surface never changes — only the process is transient.
//
// WHY THIS IS SAFE AGAINST THE SUPERVISOR. supervise() restarts a child whose
// process exits, and distinguishes a deliberate stop by CAS-ing cur from the
// instance it supervises to nil: if that fails, someone else already claimed
// the retirement and it returns. Eviction clears cur BEFORE the child dies, so
// that CAS always fails and no restart storm follows. It is the same contract
// Unload and Reload use, minus the disabled flag.
// Evict reclaims plugin processes under two bounds and reports how many it
// stopped: first every lazy plugin that has not served for its IdleAfter, then,
// while more than warm are still running, the least recently used. Zero warm
// means only the first bound applies.
//
// One pass, no goroutine — the caller decides the cadence, so a host under test
// can drive it directly and a host in production can put it on the ticker it
// already has.
//
// THE COUNT IS NOT THIS FUNCTION'S TO ENFORCE ALONE, and an earlier version of
// this comment claimed otherwise: warm was an ARGUMENT here, on the reasoning
// that the number is a fact about the host and not the library. The reasoning
// held; the placement did not. A bound consulted once a minute is outrun by
// anything that starts faster, and a fan-out door that asks every subsystem at
// once does — a hundred children inside ninety seconds, so the process held them
// all until the next sweep, stopped answering its own liveness probe, and was
// killed with the ceiling never applied. Warm is state now and makeRoom applies
// it at the start path; this still runs it, because age and count are one sweep.
//
// Draining uses the plugin's own Drain, so a request in flight when the sweep
// lands finishes on the old process exactly as it would across a Reload.
func (a *App) Evict() int {
now := time.Now()
stopped := 0
for _, p := range a.pluginSet() {
if p.evictIfIdle(now) {
stopped++
}
}
return stopped + a.evictOver(a.warm, now)
}
// host is the ROOT app of the composition this plugin belongs to, which is the
// only app that can answer how many processes are running and what the budget is.
// Falls back to the defining app for a plugin whose composition was never built —
// a Load'ed service used directly in a test, where the two are the same thing.
func (p *plugin) host() *App {
if o := p.owner.Load(); o != nil {
return o
}
return p.app
}
// makeRoom is the ceiling, and it runs where a process is about to exist rather
// than on a ticker. Called from the start path with the starter's own lock held,
// so it must never consider the starter itself: evicting p here would deadlock on
// p.mu, and p is the one plugin about to be needed anyway.
//
// This is the half a sweep cannot do. Evict trims to warm once a minute, which is
// a bound only while starts arrive slower than that. Measured on a host whose MCP
// door asks every subsystem at once: ~100 children started inside 90 seconds, the
// pod stopped answering its own liveness probe, and the kubelet killed it — the
// "ceiling" observed only in the logs of a container that was already gone.
//
// A BUSY PROCESS IS NEVER THE ONE STOPPED. Ranking by when a request started, and
// not counting the ones in flight, stopped a subsystem two to eleven seconds into
// a request it was still answering: the caller read "http: read response: EOF" as
// a 502, and the host evicted 162 times in eight minutes, each stop the next
// start's cold miss. Candidates are idle instances, coldest by when their last
// request ended. When every candidate is busy the starter waits for one to finish,
// as long as its own start may take, and then gives up with an error — the caller
// answers 503 and nothing in flight is killed.
//
// On success the starter holds one unit of room until it calls started.
func (a *App) makeRoom(starter *plugin, deadline time.Time) error {
if a.warm <= 0 {
return nil
}
for {
room, live, busy := a.room(starter)
if room {
return nil
}
if time.Now().After(deadline) {
return fmt.Errorf("%d of %d processes running, %d busy and none idle", live, a.warm, busy)
}
time.Sleep(roomPoll)
}
}
// room is one look for room under roomMu, and reports whether it took one.
//
// Over the ceiling is admitted only when nothing could ever make room: no
// process this host may stop, busy or idle, and no start in flight, which
// becomes such a process once it runs. Admitting while starts are in flight let
// a cold burst of eight first requests start eight processes against a ceiling
// of two.
func (a *App) room(starter *plugin) (ok bool, live, busy int) {
a.roomMu.Lock()
defer a.roomMu.Unlock()
live, idle, busy := a.census(starter)
if live+a.starting < a.warm || len(idle) == 0 && busy == 0 && a.starting == 0 {
a.starting++
return true, live, busy
}
// Coldest first, and on to the next when one cannot be stopped: a plugin
// mid-Reload holds its lock, and the room behind it is still room.
for _, p := range idle {
seen := p.lastUse.Load()
if p.evict("room", time.Since(time.Unix(0, seen)), seen) {
a.starting++
return true, live, busy
}
}
return false, live, busy
}
// roomPoll is how often a starter waiting for room looks again. A request on the
// process it is waiting for takes far longer than this to finish.
const roomPoll = 10 * time.Millisecond
// started returns the unit of room makeRoom handed out: the process is now
// counted as running, or it failed to start and holds nothing.
func (a *App) started() {
if a.warm <= 0 {
return
}
a.roomMu.Lock()
a.starting--
a.roomMu.Unlock()
}
// census counts the running processes, and among the ones this host may stop
// returns the idle ones coldest first and how many are busy. The starter is
// never a candidate.
func (a *App) census(starter *plugin) (live int, idle []*plugin, busy int) {
for _, p := range a.pluginSet() {
in := p.cur.Load()
if in == nil {
continue
}
live++
if p == starter || !p.evictable() {
continue
}
if in.busy.Load() > 0 {
busy++
continue
}
idle = append(idle, p)
}
slices.SortFunc(idle, func(x, y *plugin) int {
return cmp.Compare(x.lastUse.Load(), y.lastUse.Load())
})
return live, idle, busy
}
// evictable is whether the ceiling and the age bound may stop p at all: only a
// lazy plugin with an idle bound, because the next request has to bring it back.
func (p *plugin) evictable() bool { return p.lazy && p.idle > 0 }
// pluginSet is every plugin this host and its composed hosts hold, read once
// under each host's lock. Both passes need the same set and neither may hold a
// lock while stopping a child, because retire() takes the plugin's own.
func (a *App) pluginSet() []*plugin {
var all []*plugin
for _, h := range a.hosts() {
h.plugMu.Lock()
for _, p := range h.plugins {
all = append(all, p)
}
h.plugMu.Unlock()
}
return all
}
// evictOver stops the least recently used evictable plugins until at most warm
// remain running. Zero means unbounded, which is the historical behaviour.
//
// AGE BOUNDS THE STEADY STATE AND COUNT BOUNDS THE BURST, which is why both
// exist. IdleAfter can only reclaim a plugin that has already gone quiet for
// its whole window, so during the minutes after a cold start — when every
// prefix that gets a request starts a child and none is old enough to be idle —
// it reclaims nothing at all. Measured on a host of this shape: 37 children at
// ~152MiB each in steady state, and an OOM kill six minutes after boot, well
// before the first plugin was 15 minutes idle. A count is a bound the burst
// cannot outrun.
//
// LEAST RECENT USE, because the plugin that has gone longest without a request
// is the one whose restart is least likely to be paid for by a caller waiting.
//
// The cap counts every RUNNING plugin, including the ones it may not stop: they
// hold the same memory, so counting only the evictable ones would authorise the
// budget twice. When the unevictable alone exceed warm this reclaims what it
// can and leaves the rest — a host is better over its budget than deprived of
// the identity or config service every other call goes through.
func (a *App) evictOver(warm int, now time.Time) int {
if warm <= 0 {
return 0
}
var (
live int
evictable []*plugin
)
for _, p := range a.pluginSet() {
in := p.cur.Load()
if in == nil {
continue
}
live++
if p.evictable() && in.busy.Load() == 0 {
evictable = append(evictable, p)
}
}
if live <= warm {
return 0
}
// Ascending by last use, so the front of the slice is the coldest. lastUse
// is stamped when a request ENDS, so a plugin that has been answering for a
// minute is not mistaken for one asked a minute ago and since forgotten.
slices.SortFunc(evictable, func(x, y *plugin) int {
return cmp.Compare(x.lastUse.Load(), y.lastUse.Load())
})
stopped := 0
for _, p := range evictable {
if live-stopped <= warm {
break
}
lruSeen := p.lastUse.Load()
if p.evict("lru", now.Sub(time.Unix(0, lruSeen)), lruSeen) {
stopped++
}
}
return stopped
}
// evictIfIdle stops p's current instance if it is lazy, running, and has been
// unused for at least its IdleAfter. It reports whether it stopped one.
func (p *plugin) evictIfIdle(now time.Time) bool {
after := p.idle
if after <= 0 || !p.lazy {
return false
}
// No lock: the swap that takes the child down happens under p.mu inside
// evict, and this read only decides whether to ask. A request landing
// between the two is safe either way — it is already being served by the
// instance retire() is about to DRAIN, or it arrives after the swap and
// starts a fresh child. So the check buys freshness, not correctness, and
// holding the lock across it would contend with the request path to learn
// something it cannot guarantee anyway.
last := p.lastUse.Load()
if last == 0 || now.Sub(time.Unix(0, last)) < after {
return false
}
return p.evict("idle", now.Sub(time.Unix(0, last)), last)
}
// evict stops p's current instance and reports whether it stopped one. It is
// the one place a plugin is reclaimed, so the two policies above cannot come to
// disagree about how a child is taken down; reason says which asked.
func (p *plugin) evict(reason string, idle time.Duration, seen int64) bool {
// A plugin whose lock is held is mid-transition — starting, reloading,
// unloading — and is nobody's candidate. Waiting for it would hold the room
// lock across a child's whole start.
if !p.mu.TryLock() {
return false
}
if p.closed || p.disabled.Load() {
p.mu.Unlock()
return false
}
// THE CLOCK IS RE-READ UNDER THE LOCK.
//
// The caller decided to evict from a lastUse it read outside the lock. A
// request arriving in that window does not just get served by the instance
// about to drain — it can START one, because target() brings a lazy plugin
// up with a CAS on p.cur. The swap below then takes that brand-new child
// down, and the caller which just started it waits out the drain and gets a
// 502. In production this read as "zip lazy plugin started on first request"
// and "zip idle plugin evicted · idle 2h37m58s" at the SAME timestamp, over
// and over: every Slack event 502'd after 43 to 64 seconds and no agent run
// ever began.
//
// lastUse moving is exactly the signal that someone used or started it since
// the decision, so the decision is stale and the eviction is refused. seen
// of 0 means the caller is not making an idleness claim.
if seen != 0 && p.lastUse.Load() != seen {
p.mu.Unlock()
return false
}
in := p.cur.Load()
if in == nil {
p.mu.Unlock()
return false // already down; nothing to stop
}
// NOTHING IN FLIGHT, decided against every request that will ever reach in.
// closing is raised before busy is read, and claim raises busy before it
// reads closing, so a request either sees closing and goes to the lock this
// holds, or is counted here and keeps the instance.
in.closing.Store(true)
if in.busy.Load() > 0 {
in.closing.Store(false)
p.mu.Unlock()
return false
}
// Swap to nil BEFORE the child dies so supervise()'s CAS fails and it does
// not treat this as a crash to recover from. Not disabled: the next request
// through target() is meant to bring it back.
if !p.cur.CompareAndSwap(in, nil) {
p.mu.Unlock()
return false // it exited on its own; the supervisor has it
}
drain := p.spec.Drain // read under the lock Reload writes spec under
p.mu.Unlock()
p.evictions.Add(1)
if p.app != nil {
p.app.logger.Info("zip idle plugin evicted",
"name", p.name, "pid", in.cmd.Process.Pid, "reason", reason,
"idle", idle.Round(time.Second).String())
}
p.retire(in, drain)
return true
}
// Reap runs Evict on a ticker until the returned function is called. A host
// that wants the behaviour writes one line; a host that does not is unaffected,
// because nothing here runs unless it is asked for.
func (a *App) Reap(every time.Duration) (stop func()) {
if every <= 0 {
every = time.Minute
}
done := make(chan struct{})
finished := make(chan struct{})
go func() {
defer close(finished)
t := time.NewTicker(every)
defer t.Stop()
for {
select {
case <-done:
return
case <-t.C:
a.Evict()
}
}
}()
// stop is SYNCHRONOUS: it returns only once a sweep in progress has
// finished. Signalling and returning is not enough — a sweep reads App
// state that Shutdown writes, so a reaper still running when the host began
// shutting down is a data race, and it is the one the race detector found.
//
// sync.Once, not a bool: a host that stops the reaper from two places (a
// defer and an explicit shutdown path) would otherwise race on the flag and
// double-close the channel. The second call still waits, which is correct —
// finished is closed by then, so it returns at once.
var once sync.Once
return func() {
once.Do(func() { close(done) })
<-finished
}
}