fix(lua): bound prewarm by queue depth, not by the ceiling

Production oscillated between ~46 MB and ~56 MB RSS with the openai pool
cycling 1 -> 10..12 -> 1 states every couple of minutes, while peak_in_use
never went above 2.

Cause: the batch prewarm sized itself purely on the adapter's ceiling. With
max_concurrent summing to 76, growStep is 8, so any two overlapping requests
warmed 8 states — 6 more than anything was waiting for. A minute later the
janitor correctly reclaimed the surplus, the next pair of overlapping requests
warmed 8 again, and the pool churned boot/discard forever. The elasticity was
working; the growth signal was simply wrong.

Prewarm is now bounded by BOTH limits: the ceiling still caps the step, but the
batch never exceeds p.waiting, the number of goroutines actually blocked on the
pool. Overlapping-but-not-queued traffic (the common case) creates exactly the
states it uses; a genuinely queued burst still ramps in one jump.

TestContentionBatchPrewarms is rewritten to queue real waiters instead of
relying on the ceiling to imply demand, and TestNoPrewarmWithoutWaiters pins the
production shape: two overlapping requests against a 76-wide adapter must create
exactly 2 states.
This commit is contained in:
JianFeeeee
2026-08-30 08:25:18 +08:00
parent 25d8bd8632
commit 813de19bd0
2 changed files with 112 additions and 28 deletions

View File

@ -106,10 +106,11 @@ type adapterPool struct {
}
const (
// growDivisor turns the adapter's max concurrency into a growth step:
// step = ceil(maxW / growDivisor), clamped to [1, growStepCap]. A source set
// at max_concurrent=8 grows one state at a time; a 64-wide adapter warms 8
// at once instead of paying 8 sequential boots on a traffic spike.
// growDivisor turns the adapter's max concurrency into the growth step CAP:
// step <= clamp(ceil(maxW / growDivisor), 1, growStepCap). The actual batch
// is additionally bounded by how many callers are queued (see growPlanLocked),
// so a wide adapter may ramp in big jumps but never warms states nobody is
// waiting for.
growDivisor = 8
growStepCap = 8
// growCooldown keeps a burst of misses from batching repeatedly while the
@ -271,19 +272,34 @@ func (p *adapterPool) boot() (*worker, error) {
// growPlanLocked reserves capacity for a batch prewarm and returns how many
// EXTRA states to boot in the background (the caller already reserved one for
// itself). contended says the miss happened while every existing state was
// already checked out, which is the real ramp signal: sequential traffic keeps
// reusing one warm state and must never trigger a batch, while genuinely
// concurrent traffic warms a whole step at once instead of paying one boot per
// request all the way up the ramp. Caller holds p.mu.
// itself).
//
// Two independent limits apply, and BOTH matter:
//
// - the adapter's max concurrency caps the step (growStepLocked), so a
// high-concurrency adapter is allowed to ramp in bigger jumps than a
// narrow one;
// - the number of goroutines actually BLOCKED waiting for a state bounds it
// to real demand.
//
// Sizing on the ceiling alone over-provisions badly: an adapter with
// max_concurrent=76 has a step of 8, so two concurrent requests would warm 8
// states, and the janitor would throw 7 of them away a minute later — boot,
// discard, repeat, with RSS oscillating for no benefit. Prewarming only for
// goroutines that are genuinely queued keeps the ramp cheap without the churn.
//
// Caller holds p.mu.
func (p *adapterPool) growPlanLocked(contended bool) int {
if !contended {
if !contended || p.waiting <= 0 {
return 0
}
if time.Since(p.lastGrow) < growCooldown {
return 0
}
extra := p.growStepLocked() - 1
if extra > p.waiting {
extra = p.waiting // never warm more than the queue needs
}
if extra <= 0 {
return 0
}