Fix wait_for_active treating SCALED_TO_ZERO as a deployment failure - #2579
Fix wait_for_active treating SCALED_TO_ZERO as a deployment failure#2579FredLiu876 wants to merge 3 commits into
Conversation
…s when waiting for a deployment
|
|
| The status of the deployment. | ||
| """ | ||
| start_time = time.time() | ||
| for deployment_status in self._baseten_service.poll_deployment_status(): |
There was a problem hiding this comment.
From PR description:
The push succeeds, the deployment goes live, and ~20s later the action fails with:
So this polls once a second, does it not appear ACTIVE at some point before SCALED_TO_ZERO? Or is there a case where min_replicas means it never creates a replica at all? Just trying to understand if there's a real situation where a new model never reaches ACTIVE before scaled to zero.
There was a problem hiding this comment.
There was a problem hiding this comment.
It seems like there's a bug with the state machine
If this is considered a bug, can it be addressed server side?
|
No, it never reports
|
…ip test
A candidate deployment in a rolling promotion with max_unavailable_percent > 0
is created with initial_scale = 0 and reports SCALED_TO_ZERO seconds after
creation, long before the model has loaded. MODEL_RUNNING_STATES omitted that
status, so should_poll_again() returned False and truss push --tail /
truss logs --tail cut off before the model-load logs were emitted.
Also make READY_STATUSES and TERMINAL_FAILURE_STATUSES frozensets, and fix
("LOADING_MODEL") -- a string, so a substring test -- to a one-tuple.
🚀 What
truss.api.push(...).wait_for_active()raisesValueError: Deployment failed with status: SCALED_TO_ZEROwhile the push succeeds and the deployment goes on to serve normally.Reported against a
basetenlabs/action-truss-pushworkflow promoting to theproductionenvironment. The action failed ~13s in; the model served traffic 10 minutes later.It is not a race
The obvious reading is that a deployment blips through
SCALED_TO_ZEROon the way up and a 1s poller unluckily catches it. That is not what happens. Actual status history for the reported deployment:BUILDINGDEPLOYINGSCALED_TO_ZERO— client raises here, T+12.9sWAKING_UPACTIVE, first time everACTIVEwas 10m18s in the future when the client gave up. It was never observable. TheSCALED_TO_ZEROwindow was 11.4s and the client dies on the first read of it, so window length only affects how often this reproduces, not whether it does. Two other deployments on the same model show 10.0s and 50.8s windows.Why a new deployment reports SCALED_TO_ZERO having never had a replica
A rolling promotion with
max_unavailable_percent > 0creates the candidate deployment withinitial_scale = 0. The state machine takes it PENDING → ready-with-zero-replicas directly, without ever starting a pod; there is a backend integration test asserting that as correct behavior. The environment'smin_replicais applied only when the promotion completes, so in between the new deployment runs on its own default autoscaling and is scaled down for inactivity ~13s after creation.Two consequences:
min_replica: 1. This is the promotion path, not the autoscaling setting, so raisingmin_replicais not a workaround.SCALED_TO_ZEROis in bothRUNNING_ORACLE_STATUSESandPROMOTABLE_STATUSES, and the deployment is marked live. The truss client is the only component reading it as a failure.Separately,
SCALED_TO_ZEROis also the resting state of anymin_replica: 0model, sowait_for_activecan never succeed for one unless traffic happens to wake it.💻 How
truss/api/definitions.py:39-43is fail-closed with no explicit failure list:against
truss/remote/baseten/core.py:29-30:ACTIVEis success, those four keep polling, and literally everything else is a terminal failure.SCALED_TO_ZEROtrips it, and so doesWAKING_UP11s later. The deeper problem is the polarity: any status the backend adds is a hard failure by default, so this breaks again on the next status the state machine learns to emit.truss/cli/cli.py:1008/:1022(truss push --wait) has the identical bug against the same constants.Two new sets in
truss/remote/baseten/core.py, asfrozenset.DEPLOYING_STATUSESandACTIVE_STATUSare left intact —truss watch,truss chainsand the chains deployment client still import them.READY_STATUSES = {ACTIVE, SCALED_TO_ZERO}→ return successTERMINAL_FAILURE_STATUSES→ raiseApplied to both
wait_for_activeandtruss push --wait.SCALED_TO_ZEROis treated as ready, not as in-progress. The in-progress variant was already tried in #2162, which addedWAKING_UPandSCALED_TO_ZEROtoDEPLOYING_STATUSES; it closed unmerged. It would have made every scale-to-zero model hang until the timeout instead of failing fast — a worse failure, not a fix. Hence two sets rather than extending the existing one.The failure list is drawn from statuses that actually exist in
STATUS_TO_DISPLAYABLE(truss/remote/baseten/utils/status.py) rather than invented strings:BUILD_FAILED,BUILD_STOPPED,DEPLOY_FAILED,FAILED,INACTIVE,UNHEALTHY. That partitions all 14 public REST v1 statuses, with six left to keep polling:BUILDING,DEPLOYING,LOADING_MODEL,UPDATING,WAKING_UP,DEACTIVATING.DEACTIVATINGis deliberately in the polling bucket rather than the failure bucket. It is transient and converges toINACTIVE, which does raise, so this defers the error by one poll instead of dropping it.Also folded in
MODEL_RUNNING_STATES(truss/remote/baseten/utils/status.py:20) had the same gap on the same non-chains path: it includesWAKING_UPbut omittedSCALED_TO_ZERO, sotruss push --tailandtruss logs --tailstopped tailing the moment a promoting deployment scaled to zero — ~13s in, before any model-load output. Its only consumer isshould_poll_again()attruss/cli/logs/model_log_watcher.py:45, andACTIVEwas already in the list, so this adds no new non-terminating case.truss/cli/cli.py:1014haddeployment_status in ("LOADING_MODEL")— a string, not a tuple, so a substring test rather than a membership test. Harmless today because no REST v1 status is a substring of"LOADING_MODEL", but it is three lines from this diff and it is a real typo.Deliberately out of scope
chains_commands.py:428is still fail-closed. feat(chains): Add SCALED_TO_ZERO as a ready status so chain deploys don't fail if a chainlet falls asleep #2490 closed unmerged; feat(chains): Add --no-sleep to chains watch #2500 mitigated it with keepalive threads rather than fixing the classification. Worth a separate PR.MODEL_READYis not in the ready set. This path reads public REST v1, where the ready value isACTIVEandMODEL_READYdoes not exist on the wire. It appears only in the raw GraphQL status that the chains client reads untranslated, which is whyaction-truss-push'sCHAIN_READY_STATUSESincludes it and this does not.Known limitation
In the promotion case above this returns success at ~13s, before the model has loaded. That matches how the platform defines a completed deploy, but a caller that immediately sends a request hits a cold start.
Distinguishing the two cases needs
GET /v1/models/{id}/environments/{env}, which exposesin_progress_promotion/candidate_deployment/current_deployment: keep polling while a promotion is in flight, treatSCALED_TO_ZEROas ready otherwise. That is a larger change and only helps the environment-targeted path, so it is not in this PR. Happy to take it here instead if preferred.Prior art
This fix already exists in other wait loops; the model SDK path is the one that never got it.
truss/cli/utils/common.py:313-318—truss watchalready allowsDEPLOYING_STATUSES + ["SCALED_TO_ZERO", "WAKING_UP", "UPDATING"], though as keep-polling rather than ready.basetenlabs/action-truss-pushsrc/main.py:33—CHAIN_READY_STATUSES = {"ACTIVE", "SCALED_TO_ZERO", "MODEL_READY"}plus an explicitCHAIN_FAILED_STATUSES, added in2a75cf7("Fix chain wait: accept SCALED_TO_ZERO as ready"). Chains got both halves — ready-set and explicit failure list. The model path in that same action just callsdeployment.wait_for_active()(src/main.py:90-93) and inherits the bug from here.Rollout
action-truss-pushinstalls truss unpinned (pip install -q truss requests pyyaml,action.yml:93), so affected users pick this up on the next release with no change on their side.🔬 Testing
truss/tests/api/test_model_deployment.py(new) coverswait_for_active:SCALED_TO_ZEROreturns True,WAKING_UPkeeps polling then succeeds, everyTERMINAL_FAILURE_STATUSESentry still raises, an unrecognized status keeps polling instead of raising, and the timeout still fires.truss/tests/cli/test_model_log_watcher.py(new) coversshould_poll_again: True across the six original running states andSCALED_TO_ZERO, False for stopped states, plus aSCALED_TO_ZERO → INACTIVEtransition confirming the tail still terminates.truss/tests/cli/test_cli.py— four added cases:truss push --waitsucceeds onSCALED_TO_ZEROand keeps polling on an unknown status;--watchenters watch mode early onLOADING_MODEL; and a regression test for the tuple fix, verified to fail against the pre-fix line.Existing
DEPLOY_FAILEDtests pass unchanged.TERMINAL_FAILURE_STATUSESissorted()where it feeds@pytest.mark.parametrize, since frozenset iteration order varies with per-process string hash randomization and the repo shards withpytest-split.Full suite (
pytest -m 'not integration'): 1607 passed. Ruff, ruff-format and mypy clean via pre-commit.🚢 Release requirements
action-truss-pushpicks the fix up automatically on the next truss release.