a request could ask for a warm floor but not for a ceiling (v0.4.33)

/v1/runpod/scale set a FLOOR. "Give me 8 pods" meant "hold at least 8
ready", and the autoscaler stayed free to grow the pool to the configured
max_pods for as long as work kept arriving. A client running a staged
batch -- 8 pods for the first stage, 32 for the next -- had no way to
bound a stage at all, and nothing in the reply said so.

Observed 2026-10-18: a run that asked for 8 pods was at 13 and climbing
toward 128. Every pod past the plan is ~700s of paid boot producing
nothing, which is why the realised cost per page came out 16.5x the
measured baseline while every component behaved exactly as written.

A lease may now also carry `max_pods`, under the same rule `gpu_types`
already follows there: it may only ever NARROW the operator's ceiling, a
larger number is clamped and reported in `notes`, and it dies with the
lease. `pods: 0` with a `max_pods` is a cap with no floor -- what a
client with a long tail of work wants; `pods: 0` alone releases both.

The ceiling has to be read by EVERY path that decides whether to rent,
or it is a race between the grower that honours it and the one that does
not: _pods_for_demand, _grow_reason, _pods_needed and the warm-floor
top-up all now read effective_max_pods(). A leased floor is clamped to
it too, since a floor above a ceiling is a fleet that oscillates for as
long as the lease lasts. A configured max_pods of 0 still means zero --
that is the operator having turned renting off, not a lease.

22 tests, including a guard that no growth path reads mcfg.max_pods
directly, so a path added later cannot silently escape every cap.
Co-Authored-By: 's avatarClaude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KnfkxBpcZ1v6ypRFFyhrVM
parent 52a3fb1e
...@@ -16,7 +16,7 @@ ...@@ -16,7 +16,7 @@
# Canonical product version for CoderAI — single source of truth. Both the API # Canonical product version for CoderAI — single source of truth. Both the API
# metadata and the admin web UI read from here. # metadata and the admin web UI read from here.
__version__ = "0.4.32" __version__ = "0.4.33"
__tagline__ = "Complete orchestration, distribution and escalation of remotizable advanced inference." __tagline__ = "Complete orchestration, distribution and escalation of remotizable advanced inference."
# Configure the CUDA caching allocator BEFORE torch is imported anywhere. # Configure the CUDA caching allocator BEFORE torch is imported anywhere.
......
...@@ -962,6 +962,15 @@ class RunpodScaleRequest(BaseModel): ...@@ -962,6 +962,15 @@ class RunpodScaleRequest(BaseModel):
pods: int = Field(..., ge=0, pods: int = Field(..., ge=0,
description="Warm pods to hold, clamped to the model's max_pods. " description="Warm pods to hold, clamped to the model's max_pods. "
"0 releases the lease.") "0 releases the lease.")
max_pods: Optional[int] = Field(
None, ge=0,
description="Cap the fleet at this many pods for the life of this "
"lease. May only NARROW the model's configured max_pods: a "
"larger number is clamped to it and reported back in "
"`notes`. `pods` is a FLOOR and says nothing about how "
"large the pool may grow, so a staged batch that wants "
"8 pods for its first stage sets both. 0 drops this "
"lease's own cap; omit to leave it as it is.")
teardown: bool = Field(False, teardown: bool = Field(False,
description="Give the surplus pods back NOW rather than " description="Give the surplus pods back NOW rather than "
"waiting out scale_down_after_s. Never goes " "waiting out scale_down_after_s. Never goes "
...@@ -1034,8 +1043,19 @@ async def runpod_scale(body: RunpodScaleRequest, request: Request): ...@@ -1034,8 +1043,19 @@ async def runpod_scale(body: RunpodScaleRequest, request: Request):
Deliberately NOT admin-scoped: the gate is per model Deliberately NOT admin-scoped: the gate is per model
(``allow_client_scale``), the number is clamped to ``max_pods``, and the (``allow_client_scale``), the number is clamped to ``max_pods``, and the
lease expires (``client_scale_ttl_s``, default 30 min) so a client that dies ``max_pods`` is the other half of that, and the half that bounds what a
holding four A40s stops paying for them by itself. The account's $/hr and batch may cost. ``pods`` is a FLOOR: it says what to hold ready, and the
autoscaler is still free to grow the pool to the configured ``max_pods``
for as long as work keeps arriving. A client running a staged batch — 8
pods for the first stage, 32 for the next — needs to say how large the
fleet may BECOME, which it does by sending both. Like ``gpu_types`` it may
only ever narrow the operator's own number and it dies with the lease.
``pods: 0`` with a ``max_pods`` keeps the cap and holds nothing warm, which
is what a client with a long tail of work wants; ``pods: 0`` alone releases
the whole lease.
The lease expires (``client_scale_ttl_s``, default 30 min) so a client that
dies holding four A40s stops paying for them by itself. The account's $/hr and
spend caps still refuse to provision regardless of what is asked here. spend caps still refuse to provision regardless of what is asked here.
""" """
from codai.api import runpod_worker from codai.api import runpod_worker
...@@ -1051,7 +1071,8 @@ async def runpod_scale(body: RunpodScaleRequest, request: Request): ...@@ -1051,7 +1071,8 @@ async def runpod_scale(body: RunpodScaleRequest, request: Request):
task = asyncio.create_task(asyncio.to_thread( task = asyncio.create_task(asyncio.to_thread(
runpod_worker.set_client_pods, runpod_worker.set_client_pods,
body.model, body.pods, teardown=bool(body.teardown), body.model, body.pods, teardown=bool(body.teardown),
gpu_types=body.gpu_types, allow_other_gpus=body.allow_other_gpus)) gpu_types=body.gpu_types, allow_other_gpus=body.allow_other_gpus,
max_pods=body.max_pods))
def _done(t): def _done(t):
try: try:
......
This diff is collapsed.
This diff is collapsed.
...@@ -377,7 +377,9 @@ def test_the_demand_path_already_had_the_guard_and_keeps_it(): ...@@ -377,7 +377,9 @@ def test_the_demand_path_already_had_the_guard_and_keeps_it():
# meant the fleet could grow by one pod per boot however much was queued. # meant the fleet could grow by one pod per boot however much was queued.
# The guard is still here, it just reads how many rather than whether. # The guard is still here, it just reads how many rather than whether.
assert "self._booting >= parallel" in blk assert "self._booting >= parallel" in blk
assert "in_hand >= self.mcfg.max_pods" in blk # Reads the EFFECTIVE ceiling since v0.4.33: the configured max_pods
# narrowed by any client lease. Same guard, one indirection on.
assert "in_hand >= self.effective_max_pods()" in blk
def test_the_answer_names_the_model_the_client_asked_about(): def test_the_answer_names_the_model_the_client_asked_about():
......
...@@ -136,6 +136,14 @@ def test_effective_min_pods_drops_to_zero_outside_the_window(monkeypatch): ...@@ -136,6 +136,14 @@ def test_effective_min_pods_drops_to_zero_outside_the_window(monkeypatch):
_client_floor = 0 _client_floor = 0
_client_floor_until = 0.0 _client_floor_until = 0.0
_client_floor_logged = -1 _client_floor_logged = -1
# The floor is also clamped by the CEILING in force, which a lease may
# narrow (/v1/runpod/scale max_pods). No lease here either: this is the
# schedule.
effective_max_pods = rw.RunpodPodPool.effective_max_pods
client_cap = rw.RunpodPodPool.client_cap
_client_cap = 0
_client_cap_until = 0.0
_client_cap_logged = -1
pool = _Pool() pool = _Pool()
monkeypatch.setattr(rw, "schedule_state", lambda cfg, now=None: {"in_window": True}) monkeypatch.setattr(rw, "schedule_state", lambda cfg, now=None: {"in_window": True})
......
...@@ -105,7 +105,7 @@ def test_worker_uses_two_distinct_counts(): ...@@ -105,7 +105,7 @@ def test_worker_uses_two_distinct_counts():
src = open("codai/api/runpod_worker.py").read() src = open("codai/api/runpod_worker.py").read()
assert "billing_in_hand = len(self.pods) + self._booting" in src assert "billing_in_hand = len(self.pods) + self._booting" in src
assert "healthy_in_hand = len([p for p in self.pods if p.healthy]) + self._booting" in src assert "healthy_in_hand = len([p for p in self.pods if p.healthy]) + self._booting" in src
assert "max(0, int(self.mcfg.max_pods or 0) - billing_in_hand)" in src, \ assert "max(0, self.effective_max_pods() - billing_in_hand)" in src, \
"the max_pods cap must be tested against pods that bill" "the max_pods cap must be tested against pods that bill"
assert "want = min(keep_min - healthy_in_hand," in src, \ assert "want = min(keep_min - healthy_in_hand," in src, \
"the deficit must still be tested against pods that can serve" "the deficit must still be tested against pods that can serve"
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment