Skip to content

Commit 5e8977f

Browse files
un-defclaude
andauthored
Fix services ending when replicas exit with 0 (#4482)
A service whose replicas exited with 0 either had them resubmitted by scaling or ended with ALL_JOBS_DONE, depending on whether all of them finished in the same tick. With `stop_criteria: master-done`, any replica reaching DONE could end the whole service, but only if the run pipeline saw the job after it left TERMINATING. - Services never end with ALL_JOBS_DONE. Replicas that exit with 0 are brought back by scaling, and an all-DONE tick moves the run to PENDING, so it is resubmitted with a backoff. - `master-done` applies within a replica: once the master job is DONE or finishing with DONE_BY_RUNNER, the replica counts as DONE. It has no effect on single-job service replicas. A worker failing in the same tick the master finishes no longer fails the run. Fixes: #4479 Fixes: #4481 Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 3b90ccb commit 5e8977f

4 files changed

Lines changed: 183 additions & 26 deletions

File tree

‎contributing/RUNS-AND-JOBS.md‎

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,12 @@ A run can spawn one or multiple jobs, depending on the configuration. A task tha
2121
- STEP 2: `RunPipeline` continuously processes unfinished runs.
2222
- For active runs, it derives the run status from the latest job states in priority order:
2323
1. If any non-retryable failure is present, the run becomes `TERMINATING` with the relevant `RunTerminationReason`.
24-
2. If `stop_criteria == MASTER_DONE` and the master job is done, the run becomes `TERMINATING` with `ALL_JOBS_DONE`.
25-
3. Otherwise, if any job is `RUNNING`, the run becomes `RUNNING`.
26-
4. Otherwise, if any job is `PROVISIONING` or `PULLING`, the run becomes `PROVISIONING`.
27-
5. Otherwise, if jobs are still waiting for placement or provisioning, the run stays `SUBMITTED`.
28-
6. Otherwise, if all contributing jobs are `DONE`, the run becomes `TERMINATING` with `ALL_JOBS_DONE`.
29-
7. Otherwise, if no active replicas remain and the run should be retried, the run becomes `PENDING`.
24+
2. Otherwise, if any job is `RUNNING`, the run becomes `RUNNING`.
25+
3. Otherwise, if any job is `PROVISIONING` or `PULLING`, the run becomes `PROVISIONING`.
26+
4. Otherwise, if jobs are still waiting for placement or provisioning, the run stays `SUBMITTED`.
27+
5. Otherwise, if all contributing jobs are `DONE`, the run becomes `TERMINATING` with `ALL_JOBS_DONE`.
28+
6. Otherwise, if no active replicas remain and the run should be retried, the run becomes `PENDING`.
29+
- `stop_criteria` applies within a replica. With `MASTER_DONE`, once the replica's master job (`job_num == 0`) is `DONE` or `TERMINATING` with `DONE_BY_RUNNER`, the whole replica counts as `DONE`: the other jobs are neither waited for nor retried, and their failures are ignored.
3030
- Retryable replica failures are handled before the final transition is applied:
3131
- If a replica fails with a retryable reason while other replicas are still active, `RunPipeline` creates a new `SUBMITTED` submission for that replica and terminates the old jobs in that replica.
3232
- If all remaining work is retryable, the run ends up in `PENDING`.
@@ -47,6 +47,7 @@ Services' run lifecycle has some modifications:
4747
- During STEP 1, the service itself is registered on the gateway or the in-server proxy. If the gateway is not accessible or the domain name is taken, submission fails.
4848
- During STEP 2, active run processing also computes desired replica counts from gateway stats and handles scale-up, scale-down, rolling deployment, and cleanup of removed replica groups.
4949
- During STEP 2, jobs already marked `SCALED_DOWN` do not contribute to the run status.
50+
- During STEP 2, a service never ends with `ALL_JOBS_DONE`. A replica that exits with 0 is inactive, so scaling resubmits it regardless of `retry`. If all replicas are `DONE` at once, the run becomes `PENDING` instead and is resubmitted as a whole after the backoff.
5051
- During STEP 3, a service can stay in `PENDING` when autoscaling currently wants zero replicas.
5152
- During STEP 5, the terminating phase of `RunPipeline` unregisters the service from the gateway.
5253

‎mkdocs/docs/concepts/services.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1388,6 +1388,8 @@ on a dedicated domain name by setting up a [gateway](gateways.md).
13881388

13891389
By default, if `dstack` can't find capacity, or the service exits with an error, or the instance is interrupted, the run will fail.
13901390

1391+
A service runs until it's stopped. If a replica exits with `0`, `dstack` restarts it regardless of the retry policy.
1392+
13911393
If you'd like `dstack` to automatically retry, configure the
13921394
[retry](../reference/dstack.yml/service.md#retry) property accordingly:
13931395
<!-- TODO: Add a relevant example -->

‎src/dstack/_internal/server/background/pipeline_tasks/runs/active.py‎

Lines changed: 24 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ async def process_active_run(context: ActiveContext) -> ActiveResult:
127127
run_spec = context.run_spec
128128

129129
fleet_id = _detect_fleet_id_from_jobs(run_model)
130-
analysis = await _analyze_active_run(run_model)
130+
analysis = await _analyze_active_run(run_model, run_spec.merged_profile.stop_criteria)
131131
transition = _get_active_run_transition(run_spec, run_model, analysis)
132132

133133
run_update_map = _build_run_update_map(run_model, run_spec, transition, fleet_id)
@@ -183,11 +183,14 @@ def _detect_fleet_id_from_jobs(run_model: RunModel) -> Optional[uuid.UUID]:
183183
return None
184184

185185

186-
async def _analyze_active_run(run_model: RunModel) -> _RunAnalysis:
186+
async def _analyze_active_run(
187+
run_model: RunModel, stop_criteria: Optional[StopCriteria]
188+
) -> _RunAnalysis:
187189
run_analysis = _RunAnalysis()
188190
for replica_num, job_models in group_jobs_by_replica_latest(run_model.jobs):
189191
replica_analysis = await _analyze_active_run_replica(
190192
run_model=run_model,
193+
stop_criteria=stop_criteria,
191194
replica_num=replica_num,
192195
job_models=job_models,
193196
)
@@ -197,9 +200,19 @@ async def _analyze_active_run(run_model: RunModel) -> _RunAnalysis:
197200

198201
async def _analyze_active_run_replica(
199202
run_model: RunModel,
203+
stop_criteria: Optional[StopCriteria],
200204
replica_num: int,
201205
job_models: List[JobModel],
202206
) -> _ReplicaAnalysis:
207+
if stop_criteria == StopCriteria.MASTER_DONE and _master_job_is_done(job_models):
208+
# The master decides the replica's outcome. The other jobs are neither waited for
209+
# nor retried, and their failures are ignored.
210+
return _ReplicaAnalysis(
211+
replica_num=replica_num,
212+
job_models=job_models,
213+
contributed_statuses={RunStatus.DONE},
214+
)
215+
203216
contributed_statuses: Set[RunStatus] = set()
204217
termination_reasons: Set[RunTerminationReason] = set()
205218
needs_retry = False
@@ -258,6 +271,10 @@ def _apply_replica_analysis(
258271
analysis.contributed_statuses.update(replica_analysis.contributed_statuses)
259272

260273

274+
def _master_job_is_done(job_models: List[JobModel]) -> bool:
275+
return any(j.job_num == 0 and _job_is_done_or_finishing_done(j) for j in job_models)
276+
277+
261278
def _job_is_done_or_finishing_done(job_model: JobModel) -> bool:
262279
return job_model.status == JobStatus.DONE or (
263280
job_model.status == JobStatus.TERMINATING
@@ -361,15 +378,6 @@ def _is_retry_duration_exceeded(job_model: JobModel, current_duration: timedelta
361378
return current_duration > timedelta(seconds=job_spec.retry.duration)
362379

363380

364-
def _should_stop_on_master_done(run_spec: RunSpec, run_model: RunModel) -> bool:
365-
if run_spec.merged_profile.stop_criteria != StopCriteria.MASTER_DONE:
366-
return False
367-
for job_model in run_model.jobs:
368-
if job_model.job_num == 0 and job_model.status == JobStatus.DONE:
369-
return True
370-
return False
371-
372-
373381
def _get_active_run_transition(
374382
run_spec: RunSpec,
375383
run_model: RunModel,
@@ -394,19 +402,18 @@ def _get_active_run_transition(
394402
termination_reason=RunTerminationReason.GATEWAY_ERROR,
395403
)
396404

397-
if _should_stop_on_master_done(run_spec, run_model):
398-
return _ActiveRunTransition(
399-
new_status=RunStatus.TERMINATING,
400-
termination_reason=RunTerminationReason.ALL_JOBS_DONE,
401-
)
402-
403405
if RunStatus.RUNNING in analysis.contributed_statuses:
404406
return _ActiveRunTransition(new_status=RunStatus.RUNNING)
405407
if RunStatus.PROVISIONING in analysis.contributed_statuses:
406408
return _ActiveRunTransition(new_status=RunStatus.PROVISIONING)
407409
if RunStatus.SUBMITTED in analysis.contributed_statuses:
408410
return _ActiveRunTransition(new_status=RunStatus.SUBMITTED)
409411
if RunStatus.DONE in analysis.contributed_statuses and not analysis.replicas_to_retry:
412+
if run_spec.configuration.type == "service":
413+
# A service runs until stopped. While other replicas keep the run active,
414+
# scaling brings back replicas that exit with 0. When none are left,
415+
# the run is resubmitted as a whole, with a backoff.
416+
return _ActiveRunTransition(new_status=RunStatus.PENDING)
410417
return _ActiveRunTransition(
411418
new_status=RunStatus.TERMINATING,
412419
termination_reason=RunTerminationReason.ALL_JOBS_DONE,

‎src/tests/_internal/server/background/pipeline_tasks/runs/test_active.py‎

Lines changed: 150 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import json
22
import uuid
33
from datetime import timedelta
4+
from typing import Optional
45
from unittest.mock import AsyncMock, patch
56

67
import gpuhunt
@@ -842,15 +843,35 @@ async def test_transitions_to_pending_when_retry_duration_exceeded(
842843
assert run.termination_reason == RunTerminationReason.RETRY_LIMIT_EXCEEDED
843844
assert run.lock_token is None
844845

846+
@pytest.mark.parametrize(
847+
("master_status", "worker_status", "worker_termination_reason"),
848+
[
849+
pytest.param(JobStatus.DONE, JobStatus.RUNNING, None, id="master-done"),
850+
pytest.param(JobStatus.TERMINATING, JobStatus.RUNNING, None, id="master-finishing"),
851+
pytest.param(
852+
JobStatus.DONE,
853+
JobStatus.FAILED,
854+
JobTerminationReason.CONTAINER_EXITED_WITH_ERROR,
855+
id="worker-failed",
856+
),
857+
],
858+
)
845859
async def test_stops_on_master_done(
846-
self, test_db, session: AsyncSession, worker: RunWorker
860+
self,
861+
test_db,
862+
session: AsyncSession,
863+
worker: RunWorker,
864+
master_status: JobStatus,
865+
worker_status: JobStatus,
866+
worker_termination_reason: Optional[JobTerminationReason],
847867
) -> None:
848868
project = await create_project(session=session)
849869
user = await create_user(session=session)
850870
repo = await create_repo(session=session, project_id=project.id)
851871
run_spec = get_run_spec(
852872
repo_id=repo.name,
853873
profile=Profile(name="default", stop_criteria=StopCriteria.MASTER_DONE),
874+
configuration=TaskConfiguration(commands=["echo hello"], nodes=2),
854875
)
855876
run = await create_run(
856877
session=session,
@@ -860,14 +881,20 @@ async def test_stops_on_master_done(
860881
run_spec=run_spec,
861882
status=RunStatus.RUNNING,
862883
)
863-
# Master job (job_num=0) is done
864884
await create_job(
865885
session=session,
866886
run=run,
867-
status=JobStatus.DONE,
887+
status=master_status,
868888
termination_reason=JobTerminationReason.DONE_BY_RUNNER,
869889
job_num=0,
870890
)
891+
await create_job(
892+
session=session,
893+
run=run,
894+
status=worker_status,
895+
termination_reason=worker_termination_reason,
896+
job_num=1,
897+
)
871898
lock_run(run)
872899
await session.commit()
873900

@@ -1111,6 +1138,126 @@ async def test_service_zero_scale_noop(
11111138
assert run.status == RunStatus.PENDING
11121139
assert run.lock_token is None
11131140

1141+
# `stop_criteria` is about jobs within a replica, and service replicas have a single job
1142+
@pytest.mark.parametrize("stop_criteria", [StopCriteria.ALL_DONE, StopCriteria.MASTER_DONE])
1143+
@pytest.mark.parametrize("done_job_status", [JobStatus.DONE, JobStatus.TERMINATING])
1144+
async def test_service_resubmits_done_replica(
1145+
self,
1146+
test_db,
1147+
session: AsyncSession,
1148+
worker: RunWorker,
1149+
stop_criteria: StopCriteria,
1150+
done_job_status: JobStatus,
1151+
) -> None:
1152+
project = await create_project(session=session)
1153+
user = await create_user(session=session)
1154+
repo = await create_repo(session=session, project_id=project.id)
1155+
run_spec = get_run_spec(
1156+
repo_id=repo.name,
1157+
run_name="service-run",
1158+
profile=Profile(name="default", stop_criteria=stop_criteria),
1159+
configuration=ServiceConfiguration(
1160+
port=8080,
1161+
commands=["echo Hi!"],
1162+
replicas=Range[int](min=2, max=2),
1163+
),
1164+
)
1165+
run = await create_run(
1166+
session=session,
1167+
project=project,
1168+
repo=repo,
1169+
user=user,
1170+
run_name="service-run",
1171+
run_spec=run_spec,
1172+
status=RunStatus.RUNNING,
1173+
)
1174+
await create_job(
1175+
session=session,
1176+
run=run,
1177+
status=done_job_status,
1178+
termination_reason=JobTerminationReason.DONE_BY_RUNNER,
1179+
replica_num=0,
1180+
)
1181+
await create_job(
1182+
session=session,
1183+
run=run,
1184+
status=JobStatus.RUNNING,
1185+
replica_num=1,
1186+
)
1187+
lock_run(run)
1188+
await session.commit()
1189+
1190+
await worker.process(run_to_pipeline_item(run))
1191+
1192+
await session.refresh(run)
1193+
assert run.status == RunStatus.RUNNING
1194+
res = await session.execute(
1195+
select(JobModel)
1196+
.where(JobModel.run_id == run.id)
1197+
.order_by(JobModel.replica_num, JobModel.submission_num)
1198+
)
1199+
jobs = list(res.scalars().all())
1200+
assert [(j.replica_num, j.submission_num, j.status) for j in jobs] == [
1201+
(0, 0, done_job_status),
1202+
(0, 1, JobStatus.SUBMITTED),
1203+
(1, 0, JobStatus.RUNNING),
1204+
]
1205+
1206+
@pytest.mark.parametrize("stop_criteria", [StopCriteria.ALL_DONE, StopCriteria.MASTER_DONE])
1207+
async def test_service_transitions_to_pending_when_all_replicas_done(
1208+
self,
1209+
test_db,
1210+
session: AsyncSession,
1211+
worker: RunWorker,
1212+
stop_criteria: StopCriteria,
1213+
) -> None:
1214+
project = await create_project(session=session)
1215+
user = await create_user(session=session)
1216+
repo = await create_repo(session=session, project_id=project.id)
1217+
run_spec = get_run_spec(
1218+
repo_id=repo.name,
1219+
run_name="service-run",
1220+
profile=Profile(name="default", stop_criteria=stop_criteria),
1221+
configuration=ServiceConfiguration(
1222+
port=8080,
1223+
commands=["echo Hi!"],
1224+
replicas=Range[int](min=2, max=2),
1225+
),
1226+
)
1227+
run = await create_run(
1228+
session=session,
1229+
project=project,
1230+
repo=repo,
1231+
user=user,
1232+
run_name="service-run",
1233+
run_spec=run_spec,
1234+
status=RunStatus.RUNNING,
1235+
)
1236+
await create_job(
1237+
session=session,
1238+
run=run,
1239+
status=JobStatus.DONE,
1240+
termination_reason=JobTerminationReason.DONE_BY_RUNNER,
1241+
replica_num=0,
1242+
)
1243+
await create_job(
1244+
session=session,
1245+
run=run,
1246+
status=JobStatus.TERMINATING,
1247+
termination_reason=JobTerminationReason.DONE_BY_RUNNER,
1248+
replica_num=1,
1249+
)
1250+
lock_run(run)
1251+
await session.commit()
1252+
1253+
await worker.process(run_to_pipeline_item(run))
1254+
1255+
await session.refresh(run)
1256+
assert run.status == RunStatus.PENDING
1257+
assert run.termination_reason is None
1258+
assert run.resubmission_attempt == 1
1259+
assert run.lock_token is None
1260+
11141261
async def test_noops_when_run_lock_changes_after_processing(
11151262
self, test_db, session: AsyncSession, worker: RunWorker
11161263
) -> None:

0 commit comments

Comments
 (0)