Skip to content

Commit 4ed4170

Browse files
peterschmidt85Andrey Cheptsovclaude
authored
Keep now on the metrics axis while a job is reporting (#4132)
* Keep `now` on the metrics axis while a job is reporting The axis labelled its right edge `now` only when the newest sample was under ten seconds old, borrowing `pretty_date`'s threshold. Collection runs every ten seconds, so samples routinely arrive older than that: polling a live job, three renders in ten showed an absolute time instead, and under `--watch` the edge alternated between the two. Thresholds on three collection intervals instead. A job whose instance goes unreachable still shows its last timestamp -- that gap is minutes, not seconds. Draws `now` bold, in the grey `no data` already uses, so a live run is distinguishable at a glance from one that stopped reporting. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Drop docstrings that summarise the code they sit on Five of the six in this module restated what the function below already said, or argued for the option taken over one that was not. The remaining one records that the server sends samples newest-first, which is a fact from outside this file and draws every chart backwards if missed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Name the rendering helpers after what they do `_axis` and `_stamp` said nothing about building a timeline row or formatting a timestamp for it, and `_span` sat next to `_window` meaning two different windows. Names that carry their meaning at the call site beat a docstring that only carries it at the definition: _axis -> _time_axis builds the timeline row _stamp -> _time_label formats one timestamp for it _span -> _shared_window the window all jobs are drawn against _window -> _job_window one job's own first and last sample _lead -> _blank_cells cells to blank before a job started _drawn -> _cells_drawn cells a job actually fills _cell -> _chart_cell a sparkline joined to its number _level_cell -> _capacity_cell memory as a fraction of capacity _latest -> _latest_value Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Say what each rendering helper does Renaming alone did not carry it: `_time_axis` still did not say it builds a row, `_time_label` hid that it returns `now` for a live job, and `_samples_num` did not say it takes the longest series. Each now has one line stating what it does. `_chart_cell` is gone -- it wrapped a single `Text.assemble` at three call sites and only added a name to look up. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Andrey Cheptsov <andrey.cheptsov@github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 085a56e commit 4ed4170

2 files changed

Lines changed: 75 additions & 73 deletions

File tree

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

Lines changed: 60 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from datetime import datetime, timedelta
1+
from datetime import datetime, timedelta, timezone
22
from typing import Any, List, Optional, Sequence
33

44
from rich.console import RenderableType
@@ -10,7 +10,6 @@
1010
from dstack._internal.core.models.instances import Resources
1111
from dstack._internal.core.models.metrics import JobMetrics
1212
from dstack._internal.core.models.runs import Job
13-
from dstack._internal.utils.common import pretty_date
1413

1514
MAX_SAMPLES = 1000
1615
"""A sample count, not a window: outruns the hour a running job retains, so a young run is
@@ -26,6 +25,9 @@
2625
"""What the server keeps for a running job, and so the widest window there can be."""
2726

2827
AXIS_RULE = "┄"
28+
NOW = "now"
29+
30+
LIVE_THRESHOLD = timedelta(seconds=3 * WATCH_INTERVAL_SECONDS)
2931
_FIXED_COLUMNS = 30
3032
"""Everything but the sparklines and the job label: the `gpu=N` column, both numbers, and
3133
the table's padding. Hand-measured against a `589GB/1480GB`-sized number; a wider one
@@ -47,7 +49,7 @@ def get_metrics_table(
4749
labels = job_labels(jobs)
4850
label_width = max((len(label) for label in labels), default=0)
4951
width = _spark_width(console_width or console.width, label_width)
50-
span = _span(metrics)
52+
window = _shared_window(metrics)
5153

5254
table = Table(box=None)
5355
# no headers: the cells read `replica=0` and `gpu=1`, which need no naming
@@ -59,24 +61,19 @@ def get_metrics_table(
5961
for index, (job, job_metrics) in enumerate(zip(jobs, metrics)):
6062
if index:
6163
table.add_row("", "", "", "")
62-
_add_job(table, job, job_metrics, width, labels[index], span)
64+
_add_job(table, job, job_metrics, width, labels[index], window)
6365

64-
if span is not None:
66+
if window is not None:
6567
table.add_row("", "", "", "")
6668
# the axis spans the widest chart drawn: a job with fewer samples than cells draws
6769
# one cell per sample and cannot fill its share
68-
axis = _axis(max(_drawn(m, span, width) for m in metrics), *span)
70+
axis = _time_axis(max(_cells_drawn(m, window, width) for m in metrics), *window)
6971
table.add_row("", "", axis, axis)
7072
return table
7173

7274

7375
def job_labels(jobs: Sequence[Job]) -> List[str]:
74-
"""`replica=`/`group=` only where they distinguish something, as `dstack ps` does --
75-
one replica across four nodes is `job=0..3`, not `replica=0 job=0..3`.
76-
77-
Unlike `ps`, `job=` is always printed. This table is keyed by job, so every row names
78-
one; `replica=` joins it only where there is more than one replica to tell apart.
79-
"""
76+
"""A label per job, naming only what tells them apart, as `dstack ps` does."""
8077
groups = {job.job_spec.replica_group for job in jobs}
8178
show_group = len(groups) > 1
8279
show_replica = len({job.job_spec.replica_num for job in jobs}) > 1
@@ -102,59 +99,60 @@ def _add_job(
10299
metrics: JobMetrics,
103100
width: int,
104101
label: str,
105-
span: Optional[tuple[datetime, datetime]],
102+
window: Optional[tuple[datetime, datetime]],
106103
) -> None:
107104
resources = _get_resources(job)
108-
lead = _lead(metrics, span, width)
109-
cells = width - lead
105+
blanks = _blank_cells(metrics, window, width)
106+
cells = width - blanks
110107
table.add_row(
111108
label,
112109
"cpu",
113-
_pad(_cpu_cell(metrics, resources, cells), lead),
114-
_pad(_memory_cell(metrics, resources, cells), lead),
110+
_pad(_cpu_cell(metrics, resources, cells), blanks),
111+
_pad(_memory_cell(metrics, resources, cells), blanks),
115112
)
116113
for index in range(_gpus_num(metrics, resources)):
117114
table.add_row(
118115
"",
119116
f"gpu={index}",
120-
_pad(_gpu_util_cell(metrics, index, cells), lead),
121-
_pad(_gpu_memory_cell(metrics, resources, index, cells), lead),
117+
_pad(_gpu_util_cell(metrics, index, cells), blanks),
118+
_pad(_gpu_memory_cell(metrics, resources, index, cells), blanks),
122119
)
123120

124121

125-
def _span(metrics: Sequence[JobMetrics]) -> Optional[tuple[datetime, datetime]]:
126-
"""The window every job is drawn against: always the full retention hour.
127-
128-
Fixed rather than fitted to the data, so a row means the same thing in every
129-
invocation and across every job. A job younger than the hour fills only its share of
130-
the row and the rest is blank -- which is the fact worth seeing about a replica that
131-
started two minutes ago.
132-
"""
133-
windows = [w for w in (_window(m) for m in metrics) if w is not None]
122+
def _shared_window(metrics: Sequence[JobMetrics]) -> Optional[tuple[datetime, datetime]]:
123+
"""The window every job is charted against: the newest sample back one retention hour."""
124+
windows = [w for w in (_job_window(m) for m in metrics) if w is not None]
134125
if not windows:
135126
return None
136127
latest, earliest = max(w[1] for w in windows), min(w[0] for w in windows)
137128
return min(earliest, latest - RETENTION), latest
138129

139130

140-
def _lead(metrics: JobMetrics, span: Optional[tuple[datetime, datetime]], width: int) -> int:
141-
"""Cells before this job's first sample -- time it was not running for."""
142-
window = _window(metrics)
143-
if window is None or span is None:
131+
def _blank_cells(
132+
metrics: JobMetrics, window: Optional[tuple[datetime, datetime]], width: int
133+
) -> int:
134+
"""How many cells to leave empty before a job's chart, so it starts where it started."""
135+
job_window = _job_window(metrics)
136+
if job_window is None or window is None:
144137
return 0
145-
total = (span[1] - span[0]).total_seconds()
138+
total = (window[1] - window[0]).total_seconds()
146139
if total <= 0:
147140
return 0
148-
return min(width - 1, max(0, round((window[0] - span[0]).total_seconds() / total * width)))
141+
started = (job_window[0] - window[0]).total_seconds()
142+
return min(width - 1, max(0, round(started / total * width)))
149143

150144

151-
def _drawn(metrics: JobMetrics, span: Optional[tuple[datetime, datetime]], width: int) -> int:
152-
lead = _lead(metrics, span, width)
153-
return lead + min(width - lead, _samples_num(metrics))
145+
def _cells_drawn(
146+
metrics: JobMetrics, window: Optional[tuple[datetime, datetime]], width: int
147+
) -> int:
148+
"""How many cells a job's chart occupies: its empty lead plus one per sample."""
149+
blanks = _blank_cells(metrics, window, width)
150+
return blanks + min(width - blanks, _samples_num(metrics))
154151

155152

156-
def _pad(cell: Text, lead: int) -> Text:
157-
return cell if lead <= 0 else Text.assemble(Text(" " * lead), cell)
153+
def _pad(cell: Text, blanks: int) -> Text:
154+
"""Prefix a chart cell with empty cells, for time before the job started."""
155+
return cell if blanks <= 0 else Text.assemble(Text(" " * blanks), cell)
158156

159157

160158
def _cpu_cell(job_metrics: JobMetrics, resources: Optional[Resources], width: int) -> Text:
@@ -166,15 +164,15 @@ def _cpu_cell(job_metrics: JobMetrics, resources: Optional[Resources], width: in
166164
values = [v / cpus for v in values]
167165
# no core count: the value is already normalised to it, and unlike memory there is no
168166
# total to give the number meaning
169-
return _cell(sparkline(values, width, HOST_RAMP), f"{values[-1]:.0f}%")
167+
return Text.assemble(sparkline(values, width, HOST_RAMP), " ", f"{values[-1]:.0f}%")
170168

171169

172170
def _memory_cell(job_metrics: JobMetrics, resources: Optional[Resources], width: int) -> Text:
173171
values = _metric_values(job_metrics, "memory_working_set_bytes")
174172
if not values:
175173
return no_data()
176174
total = resources.memory_mib * 1024 * 1024 if resources else None
177-
return _level_cell(values, total, width, HOST_RAMP)
175+
return _capacity_cell(values, total, width, HOST_RAMP)
178176

179177

180178
def _gpu_memory_cell(
@@ -189,65 +187,54 @@ def _gpu_memory_cell(
189187
total = None
190188
if resources and index < len(resources.gpus):
191189
total = resources.gpus[index].memory_mib * 1024 * 1024
192-
return _level_cell(values, total, width, GPU_RAMP)
190+
return _capacity_cell(values, total, width, GPU_RAMP)
193191

194192

195193
def _gpu_util_cell(job_metrics: JobMetrics, index: int, width: int) -> Text:
196194
values = _metric_values(job_metrics, f"gpu_util_percent_gpu{index}")
197195
if not values:
198196
return no_data()
199-
return _cell(sparkline(values, width, GPU_RAMP), f"{values[-1]:.0f}%")
197+
return Text.assemble(sparkline(values, width, GPU_RAMP), " ", f"{values[-1]:.0f}%")
200198

201199

202-
def _level_cell(values: List[float], total: Optional[float], width: int, ramp: Ramp) -> Text:
200+
def _capacity_cell(values: List[float], total: Optional[float], width: int, ramp: Ramp) -> Text:
201+
"""A memory chart drawn against capacity, labelled `used/total`."""
203202
percents = [v / total * 100 for v in values] if total else values
204203
label = format_memory(values[-1], 0)
205204
if total:
206205
label += f"/{format_memory(total, 0)}"
207-
return _cell(sparkline(percents, width, ramp), label)
208-
209-
210-
def _cell(spark: Text, label: str) -> Text:
211-
return Text.assemble(spark, " ", label)
212-
213-
214-
def _axis(width: int, first: datetime, last: datetime) -> Text:
215-
"""`<oldest> ┄┄┄ <newest>`, never wider than the sparkline above it.
206+
return Text.assemble(sparkline(percents, width, ramp), " ", label)
216207

217-
The rule is what pairs the two stamps. UTILIZATION and MEMORY each print one, so the
218-
row ends up holding four times, and with the rule left blank the only cue is spacing --
219-
which points the wrong way above 88 columns: at 200 there are 66 blanks between a
220-
column's own two stamps but only 13 between the columns, so each column's newest time
221-
reads as belonging to the next column's oldest.
222208

223-
A run draws one cell per sample, so for its first few minutes there are fewer cells
224-
than two dates need. Dropping the date keeps the axis inside its cell; overflowing
225-
instead widens the column and pulls MEMORY out of line with the charts.
226-
"""
227-
left, right = _stamp(first), _stamp(last)
209+
def _time_axis(width: int, first: datetime, last: datetime) -> Text:
210+
"""The timeline row printed under the charts, exactly `width` columns wide."""
211+
left, right = _time_label(first), _time_label(last)
228212
if len(left) + len(right) + 3 > width:
229-
left, right = _stamp(first, clock_only=True), _stamp(last, clock_only=True)
213+
left, right = _time_label(first, clock_only=True), _time_label(last, clock_only=True)
230214
if len(left) + len(right) + 2 > width:
231215
return Text("")
232216
fill = width - len(left) - len(right) - 2
233-
return Text(f"{left} " + AXIS_RULE * fill + f" {right}", style="grey42")
217+
axis = Text(f"{left} " + AXIS_RULE * fill + " ", style="grey42")
218+
axis.append(right, style="bold grey58" if right == NOW else "grey42")
219+
return axis
234220

235221

236-
def _stamp(moment: datetime, clock_only: bool = False) -> str:
237-
if pretty_date(moment) == "now":
238-
return "now"
222+
def _time_label(moment: datetime, clock_only: bool = False) -> str:
223+
"""One timestamp for the axis: `now` while a job is still reporting, a date otherwise."""
224+
if datetime.now(timezone.utc) - moment < LIVE_THRESHOLD:
225+
return NOW
239226
local = moment.astimezone()
240227
return f"{local:%H:%M}" if clock_only else f"{local.day} {local:%b %H:%M}"
241228

242229

243-
def _window(job_metrics: JobMetrics) -> Optional[tuple[datetime, datetime]]:
230+
def _job_window(job_metrics: JobMetrics) -> Optional[tuple[datetime, datetime]]:
231+
"""The oldest and newest sample timestamps of one job, or None if it has none."""
244232
stamps = [t for metric in job_metrics.metrics for t in metric.timestamps]
245233
return (min(stamps), max(stamps)) if stamps else None
246234

247235

248236
def _samples_num(job_metrics: JobMetrics) -> int:
249-
"""`slices` never draws more cells than it has samples, so the axis must stop there
250-
too -- else it claims a span nothing was measured over, and Rich widens the column."""
237+
"""How many samples the longest series holds."""
251238
return max((len(metric.timestamps) for metric in job_metrics.metrics), default=0)
252239

253240

@@ -260,15 +247,15 @@ def _metric_values(job_metrics: JobMetrics, name: str) -> List[Any]:
260247
return []
261248

262249

263-
def _latest(job_metrics: JobMetrics, name: str) -> Optional[Any]:
250+
def _latest_value(job_metrics: JobMetrics, name: str) -> Optional[Any]:
264251
values = _metric_values(job_metrics, name)
265252
return values[-1] if values else None
266253

267254

268255
def _gpus_num(job_metrics: JobMetrics, resources: Optional[Resources]) -> int:
269256
if resources is not None and resources.gpus:
270257
return len(resources.gpus)
271-
detected = _latest(job_metrics, "gpus_detected_num")
258+
detected = _latest_value(job_metrics, "gpus_detected_num")
272259
return int(detected) if detected else 0
273260

274261

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@
88
from rich.theme import Theme
99

1010
from dstack._internal.cli.utils.metrics import (
11+
_job_window,
12+
_time_axis,
13+
_time_label,
1114
format_memory,
1215
get_metrics_table,
1316
job_labels,
@@ -186,6 +189,18 @@ def test_a_finished_run_cannot_look_live(self, state: str, live: bool):
186189
if not live:
187190
assert ":" in axis # a real clock time, not an age
188191

192+
@pytest.mark.parametrize("age_seconds,live", [(13, True), (45, False)])
193+
def test_a_sample_may_lag_a_few_intervals_and_still_read_as_live(self, age_seconds, live):
194+
moment = datetime.now(timezone.utc) - timedelta(seconds=age_seconds)
195+
assert (_time_label(moment) == "now") == live
196+
197+
@pytest.mark.parametrize("state,emphasised", [("running", True), ("terminated", False)])
198+
def test_only_a_live_edge_is_emphasised(self, state: str, emphasised: bool):
199+
job, metrics = make_run("saturated", state=state)
200+
axis = _time_axis(60, *_job_window(metrics))
201+
styles = {str(span.style) for span in axis.spans}
202+
assert ("bold grey58" in styles) == emphasised
203+
189204

190205
class TestJobs:
191206
def test_every_job_is_shown_and_keyed(self):

0 commit comments

Comments
 (0)