Skip to content

Commit ed2d9d8

Browse files
committed
Support dynamic run waiting CLI status with extra renderables
1 parent 6fd1f1b commit ed2d9d8

5 files changed

Lines changed: 169 additions & 6 deletions

File tree

src/dstack/_internal/cli/services/configurators/run.py

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,12 @@
2828
from dstack._internal.cli.services.resources import apply_resources_args, register_resources_args
2929
from dstack._internal.cli.utils.common import confirm_ask, console
3030
from dstack._internal.cli.utils.rich import MultiItemStatus
31-
from dstack._internal.cli.utils.run import get_runs_table, print_run_plan
31+
from dstack._internal.cli.utils.run import (
32+
RunWaitStatus,
33+
get_run_wait_status,
34+
get_runs_table,
35+
print_run_plan,
36+
)
3237
from dstack._internal.core.errors import (
3338
CLIError,
3439
ConfigurationError,
@@ -192,10 +197,14 @@ def apply_configuration(
192197
try:
193198
# We can attach to run multiple times if it goes from running to pending (retried).
194199
while True:
195-
with MultiItemStatus(f"Launching [code]{run.name}[/]...", console=console) as live:
200+
with MultiItemStatus(_get_apply_status(run), console=console) as live:
196201
while not _is_ready_to_attach(run):
197202
table = get_runs_table([run])
198-
live.update(table)
203+
live.update(
204+
table,
205+
*_get_apply_wait_renderables(run),
206+
status=_get_apply_status(run),
207+
)
199208
time.sleep(5)
200209
run.refresh()
201210

@@ -801,6 +810,26 @@ def _print_service_urls(run: Run) -> None:
801810
console.print()
802811

803812

813+
def _get_apply_status(run: Run) -> str:
814+
wait_status = get_run_wait_status(run._run)
815+
if wait_status is None:
816+
return f"Launching [code]{run.name}[/]..."
817+
return f"[code]{run.name}[/] is {wait_status.value}..."
818+
819+
820+
def _get_apply_wait_renderables(run: Run) -> list[str]:
821+
wait_status = get_run_wait_status(run._run)
822+
if wait_status is RunWaitStatus.WAITING_FOR_REQUESTS and run._run.service is not None:
823+
return [f"Service URL: [link={run.service_url}]{run.service_url}[/]"]
824+
if (
825+
wait_status is RunWaitStatus.WAITING_FOR_SCHEDULE
826+
and run._run.next_triggered_at is not None
827+
):
828+
next_run = run._run.next_triggered_at.astimezone().strftime("%Y-%m-%d %H:%M %Z")
829+
return [f"Next run: {next_run}"]
830+
return []
831+
832+
804833
def _print_dev_environment_connection_info(run: Run) -> None:
805834
if not FeatureFlags.CLI_PRINT_JOB_CONNECTION_INFO:
806835
return

src/dstack/_internal/cli/utils/rich.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,11 @@ def __init__(self, status: "RenderableType", *, console: Optional["Console"] = N
140140
transient=True,
141141
)
142142

143-
def update(self, *renderables: "RenderableType") -> None:
143+
def update(
144+
self, *renderables: "RenderableType", status: Optional["RenderableType"] = None
145+
) -> None:
146+
if status is not None:
147+
self._spinner.update(text=status)
144148
self._live.update(renderable=Group(self._spinner, *renderables))
145149

146150
def __enter__(self) -> "MultiItemStatus":

src/dstack/_internal/cli/utils/run.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import shutil
2+
from enum import Enum
23
from typing import Any, Dict, List, Optional
34

45
from rich.markup import escape
@@ -49,6 +50,11 @@
4950
from dstack.api import Run
5051

5152

53+
class RunWaitStatus(str, Enum):
54+
WAITING_FOR_REQUESTS = "waiting for requests"
55+
WAITING_FOR_SCHEDULE = "waiting for schedule"
56+
57+
5258
def print_offers_json(run_plan: RunPlan, run_spec):
5359
"""Print offers information in JSON format."""
5460
job_plan = run_plan.job_plans[0]
@@ -200,6 +206,40 @@ def th(s: str) -> str:
200206
console.print(NO_FLEETS_WARNING if no_fleets else NO_OFFERS_WARNING)
201207

202208

209+
def get_run_wait_status(run: CoreRun) -> Optional[RunWaitStatus]:
210+
# Only synthesize a CLI-specific waiting state when the server did not provide
211+
# a more specific run-level message such as "retrying".
212+
if run.status_message not in ("", run.status.value):
213+
return None
214+
215+
if run.status == RunStatus.PENDING and run.next_triggered_at is not None:
216+
return RunWaitStatus.WAITING_FOR_SCHEDULE
217+
218+
if _is_waiting_for_requests(run):
219+
return RunWaitStatus.WAITING_FOR_REQUESTS
220+
221+
return None
222+
223+
224+
def _is_waiting_for_requests(run: CoreRun) -> bool:
225+
if run.run_spec.configuration.type != "service":
226+
return False
227+
if run.service is None or run.next_triggered_at is not None:
228+
return False
229+
if run.status not in (RunStatus.SUBMITTED, RunStatus.PENDING):
230+
return False
231+
return not any(_is_job_active(job.job_submissions[-1].status) for job in run.jobs)
232+
233+
234+
def _is_job_active(status: JobStatus) -> bool:
235+
return status in (
236+
JobStatus.SUBMITTED,
237+
JobStatus.PROVISIONING,
238+
JobStatus.PULLING,
239+
JobStatus.RUNNING,
240+
)
241+
242+
203243
def _format_run_status(run) -> str:
204244
status_text = (
205245
run.latest_job_submission.status_message

src/tests/_internal/cli/services/configurators/test_run.py

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import argparse
2+
from datetime import datetime, timezone
23
from textwrap import dedent
34
from typing import List, Optional, Tuple
45
from unittest.mock import Mock
@@ -9,6 +10,8 @@
910
from dstack._internal.cli.services.configurators import get_run_configurator_class
1011
from dstack._internal.cli.services.configurators.run import (
1112
BaseRunConfigurator,
13+
_get_apply_status,
14+
_get_apply_wait_renderables,
1215
render_run_spec_diff,
1316
)
1417
from dstack._internal.core.errors import ConfigurationError
@@ -18,11 +21,25 @@
1821
BaseRunConfiguration,
1922
DevEnvironmentConfiguration,
2023
PortMapping,
24+
ScalingSpec,
25+
ServiceConfiguration,
2126
TaskConfiguration,
2227
)
2328
from dstack._internal.core.models.envs import Env
2429
from dstack._internal.core.models.profiles import Profile
25-
from dstack._internal.server.testing.common import get_run_spec
30+
from dstack._internal.core.models.resources import Range
31+
from dstack._internal.core.models.runs import RunStatus, ServiceSpec
32+
from dstack._internal.server.services import encryption # noqa: F401 # import for side-effect
33+
from dstack._internal.server.services.runs import run_model_to_run
34+
from dstack._internal.server.testing.common import (
35+
create_project,
36+
create_repo,
37+
create_run,
38+
create_user,
39+
get_run_spec,
40+
)
41+
from dstack.api import Run
42+
from dstack.api.server import APIClient
2643

2744

2845
class TestApplyArgs:
@@ -401,3 +418,74 @@ def test_no_diff(self):
401418
old = get_run_spec(run_name="test", repo_id="test")
402419
new = get_run_spec(run_name="test", repo_id="test")
403420
assert render_run_spec_diff(old, new) is None
421+
422+
423+
@pytest.mark.asyncio
424+
@pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True)
425+
class TestApplyStatusHelpers:
426+
async def test_waiting_for_requests_status_and_renderables(self, session):
427+
project = await create_project(session=session)
428+
user = await create_user(session=session)
429+
repo = await create_repo(session=session, project_id=project.id)
430+
run_spec = get_run_spec(
431+
run_name="service-run",
432+
repo_id=repo.name,
433+
configuration=ServiceConfiguration(
434+
type="service",
435+
image="ubuntu:latest",
436+
commands=["echo hello"],
437+
port=80,
438+
replicas=Range[int](min=0, max=1),
439+
scaling=ScalingSpec(metric="rps", target=1),
440+
),
441+
)
442+
run_model = await create_run(
443+
session=session,
444+
project=project,
445+
repo=repo,
446+
user=user,
447+
run_name="service-run",
448+
run_spec=run_spec,
449+
status=RunStatus.PENDING,
450+
)
451+
run_model.service_spec = ServiceSpec(url="/proxy/services/test/service-run/").json()
452+
await session.commit()
453+
await session.refresh(run_model)
454+
455+
api_run = Run(
456+
api_client=Mock(spec=APIClient, base_url="http://127.0.0.1:3000"),
457+
project=project.name,
458+
run=run_model_to_run(run_model),
459+
)
460+
461+
assert _get_apply_status(api_run) == "[code]service-run[/] is waiting for requests..."
462+
assert _get_apply_wait_renderables(api_run) == [
463+
"Service URL: [link=http://127.0.0.1:3000/proxy/services/test/service-run/]http://127.0.0.1:3000/proxy/services/test/service-run/[/]"
464+
]
465+
466+
async def test_waiting_for_schedule_status_and_renderables(self, session):
467+
project = await create_project(session=session)
468+
user = await create_user(session=session)
469+
repo = await create_repo(session=session, project_id=project.id)
470+
run_model = await create_run(
471+
session=session,
472+
project=project,
473+
repo=repo,
474+
user=user,
475+
run_name="scheduled-run",
476+
status=RunStatus.PENDING,
477+
next_triggered_at=datetime(2023, 1, 2, 3, 10, tzinfo=timezone.utc),
478+
)
479+
await session.refresh(run_model)
480+
481+
api_run = Run(
482+
api_client=Mock(spec=APIClient),
483+
project=project.name,
484+
run=run_model_to_run(run_model),
485+
)
486+
next_run = datetime(2023, 1, 2, 3, 10, tzinfo=timezone.utc)
487+
api_run._run.next_triggered_at = next_run
488+
489+
assert _get_apply_status(api_run) == "[code]scheduled-run[/] is waiting for schedule..."
490+
expected_next_run = next_run.astimezone().strftime("%Y-%m-%d %H:%M %Z")
491+
assert _get_apply_wait_renderables(api_run) == [f"Next run: {expected_next_run}"]

src/tests/_internal/cli/utils/test_run.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@
1010
from sqlalchemy.ext.asyncio import AsyncSession
1111
from sqlalchemy.orm import selectinload
1212

13-
from dstack._internal.cli.utils.run import get_runs_table
13+
from dstack._internal.cli.utils.run import (
14+
get_runs_table,
15+
)
1416
from dstack._internal.core.models.backends.base import BackendType
1517
from dstack._internal.core.models.configurations import (
1618
AnyRunConfiguration,

0 commit comments

Comments
 (0)