fix: a lane at zero slots tried to empty a pool billiard will not empty (4295)
CI / lint (push) Successful in 4s
CI / extension-version (push) Successful in 4s
Build images / sign-extension (push) Successful in 5s
Build images / build-agent (push) Successful in 7s
CI / frontend-build (push) Successful in 24s
CI / backend-lint-and-test (push) Failing after 40s
Build images / build-web (push) Successful in 2m1s
CI / integration (push) Successful in 2m11s
Build images / smoke-web (push) Successful in 1m0s
Build images / promote (push) Skipped

Found on the operator's live deploy, not in CI:

    [scheduler] worker_control: ml reconciled 1 -> 0 slots
    [ml] pidbox command error:
         ValueError("Can't shrink pool. All processes busy!")

ML ships at 0 stored slots and disabled, and `gen_supervisord` starts every
lane at one process so `add_consumer` has something to reach. So the stored
value and the running pool disagreed by one, permanently: billiard will not
remove the last worker, and `set_lane_slots_sync` returns True on SENDING the
control message — the refusal happens later, on the worker. The reconcile
logged a successful correction and reported `changed: ['ml']` every tick,
forever, on the default configuration of every install.

Lesson #4183 in production: an enforcer whose target is unreachable re-does
its own work on every pass and says it worked.

The floor is now one PROCESS, in one place — `effective_slots()` — applied
wherever a target is COMPARED as well as wherever one is sent. Comparing
against the unclamped 0 sees a difference no control message can ever close,
which is the same non-convergence one layer up.

Zero slots still means zero WORK: the lane's consumers are cancelled, and the
idle process is what the enable switch lands on.

A cross-file guard ties the generator's starting concurrency to the same
function, so the two ends of the floor cannot drift apart again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR
This commit is contained in:
2026-09-23 10:58:58 -04:00
co-authored by Claude Opus 5
parent a987ca41ca
commit 86d6509936
3 changed files with 145 additions and 2 deletions
+42 -2
View File
@@ -66,6 +66,29 @@ log = logging.getLogger(__name__)
# report "not present", which is true, rather than hang the page.
CONTROL_TIMEOUT_SECONDS = 2.0
# A celery prefork pool cannot have ZERO processes, and a lane at zero slots
# is expressed by cancelling its consumers rather than by emptying its pool.
#
# The generator already starts every lane at one process for exactly this
# reason: `add_consumer` needs something to reach, so enabling a lane from the
# UI would be impossible if no process existed. The consequence was missed
# until the operator's live deploy, 2026-09-23:
#
# [scheduler] worker_control: ml reconciled 1 -> 0 slots
# [ml] pidbox command error: ValueError("Can't shrink pool. All processes
# busy!")
#
# billiard refuses to remove the last worker, so ml could never reach 0. And
# `set_lane_slots_sync` returns True on SENDING the control message — the
# failure happens later, on the worker — so the reconcile logged success and
# reported `changed: ['ml']` on every single tick. An enforcer with no
# reachable fixed point, re-doing its own work forever and saying it worked:
# lesson #4183, in production, on the default configuration of every install.
#
# So the floor is one process. Zero slots still means zero WORK, because the
# consumers are cancelled — the idle process is the switch's landing pad.
MIN_POOL_SLOTS = 1
# The WORST case of `inspect_lanes_sync`, for callers that need a deadline.
#
# One broadcast plus three targeted reads. The targeted three normally return
@@ -208,6 +231,16 @@ def inspect_lanes_sync() -> dict[str, LaneLiveState]:
return out
def effective_slots(target: int) -> int:
"""What a pool can actually be set to. Never below one process.
Used wherever a target is COMPARED as well as wherever one is sent: a
reconcile that compares against the unclamped number sees a difference
that no control message can ever close, and re-sends it every tick.
"""
return max(MIN_POOL_SLOTS, target)
def set_lane_slots_sync(
lane: Lane, target: int, live: LaneLiveState | None = None,
) -> tuple[bool, str | None]:
@@ -223,6 +256,7 @@ def set_lane_slots_sync(
stored the value; this says only that the live push did not land, and the
reconcile will carry it when the lane answers again.
"""
target = effective_slots(target)
try:
from ..celery_app import celery as celery_app
@@ -663,6 +697,10 @@ def reconcile_lanes_sync(
target_slots = slots
if lane.name in autoscaling and current is not None:
target_slots = max(slots, current)
# And the floor every lane has: a pool cannot be emptied. Applied to
# the COMPARISON, not just the send — against the raw 0 this sees a
# difference no control message can close and re-sends it every tick.
target_slots = effective_slots(target_slots)
if current is not None and current == target_slots:
continue
ok, err = set_lane_slots_sync(lane, target_slots, live=state)
@@ -817,8 +855,10 @@ def autoscale_lanes_sync(
lane.name, "held", current,
f"{backlog} waiting but the cap is {cap}",
))
elif current > configured and backlog <= AUTOSCALE_SHRINK_BELOW:
new = max(configured, current - AUTOSCALE_STEP)
elif current > effective_slots(configured) and (
backlog <= AUTOSCALE_SHRINK_BELOW
):
new = max(effective_slots(configured), current - AUTOSCALE_STEP)
ok, err = set_lane_slots_sync(lane, new, live=state)
out.append(AutoscaleDecision(
lane.name, "shrank" if ok else "held", new if ok else current,