mirror of
https://github.com/zap-proto/zip.git
synced 2026-10-19 15:38:57 +00:00
386 lines
15 KiB
Go
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
|
|
}
|
|
}
|