Skip to content

Commit 0ccc252

Browse files
Mokuroh54claude
andcommitted
fix(jobs): cloud log tail survives short replays and silent stalls (MT47)
The HF cloud log tail could stop delivering mid-run and never recover: progress, charts and ETA froze while the run completed normally (status polls independently; checkpoints list from the Hub). Hit live on runs from 2026-07-30/31 and 2026-08-07 (110-min log gap on a completed run). Introduced by a08ab03 (2026-05-12). Two independent defects, both unit-reproduced: - Reconnect accounting: a per-connection line index compared against a cross-connection counter silently drops every subsequent line forever once a reconnect replays fewer lines than already processed. Replaced with content-based bounded replay dedupe. - No silence timeout: a stalled stream stranded the tail thread unobservably. Stream iteration now runs on a reader thread with a 600s silence timeout raising into the existing reconnect path. Mitigation: _settle_terminal_metrics snaps done runs to total_steps and clears eta_seconds on all terminal states (never inventing progress for failed/interrupted); the frontend gates the ETA label on active runs. The existing wandb URL scrape is preserved through the rewrite and pinned by a dedicated test. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent d104ca8 commit 0ccc252

5 files changed

Lines changed: 423 additions & 15 deletions

File tree

frontend/src/components/training/monitoring/MonitoringStats.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,8 +138,13 @@ const MonitoringStats: React.FC<MonitoringStatsProps> = ({
138138
const stepLabel = isStarting
139139
? "Training starting…"
140140
: `${trainingStatus.current_step.toLocaleString()} / ${trainingStatus.total_steps.toLocaleString()}`;
141+
// Only a RUNNING job has time remaining. `eta_seconds` is the last value the
142+
// log parser extrapolated, and it survives on the record after the run ends —
143+
// so a finished job whose log stream died mid-flight (MT47) would otherwise
144+
// render a confident countdown next to its "Done" badge, which is how a stale
145+
// reading reads as a live one.
141146
const etaLabel =
142-
trainingStatus.eta_seconds != null
147+
trainingStatus.training_active && trainingStatus.eta_seconds != null
143148
? formatTime(trainingStatus.eta_seconds)
144149
: "—";
145150

makermodslab/jobs.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,34 @@ def _initial_metrics(config: TrainingRequest) -> TrainingMetrics:
312312
return TrainingMetrics(current_step=start, total_steps=config.steps)
313313

314314

315+
def _settle_terminal_metrics(record: JobRecord) -> None:
316+
"""Reconcile a finished run's progress with the fact that it finished.
317+
318+
`metrics` only ever advances when a log line is parsed, so a run whose log
319+
stream died mid-flight keeps the last frame it saw forever. That produced
320+
the MT47 symptom: a `done` run rendering "3,650 / 10,000" beside a live
321+
countdown ("00:53:05 remaining") while its step-10,000 checkpoint sat on the
322+
Hub — three surfaces of one record disagreeing.
323+
324+
Two changes, both about not asserting what we no longer believe:
325+
326+
* `done` means the trainer reached its target, so progress is the target.
327+
Only claimed when a target is actually known (`total_steps > 0`), and
328+
only for `done` — a `failed`/`interrupted` run genuinely stopped where
329+
the last frame said, and rounding that up to the target would invent
330+
training that never happened.
331+
* The ETA is cleared for EVERY terminal state. A finished run has no
332+
remaining time, whatever the last frame extrapolated.
333+
334+
A mitigation, not the fix: the root cause is the log tail going silent
335+
(MT47), and a repaired tail leaves this a no-op on a healthy run.
336+
"""
337+
if record.metrics.eta_seconds is not None:
338+
record.metrics.eta_seconds = None
339+
if record.state == "done" and record.metrics.total_steps > 0:
340+
record.metrics.current_step = record.metrics.total_steps
341+
342+
315343
def _read_log_metrics(path: Path, resume_total: int | None) -> builtins.list[MetricsHistoryPoint]:
316344
"""Parse one job's log.jsonl into (step, loss, lr, grad_norm) points.
317345
@@ -3262,6 +3290,7 @@ def _tick(self) -> None:
32623290
record.state = "done" if rc == 0 else "failed"
32633291
record.ended_at = time.time()
32643292
record.exit_code = rc
3293+
_settle_terminal_metrics(record)
32653294
if rc != 0 and record.error_message is None:
32663295
# Prefer a runner-supplied reason (e.g. HF Jobs'
32673296
# 'Job timeout') over the synthetic exit-code message.

makermodslab/runners/hf_cloud.py

Lines changed: 105 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
import threading
3535
import time
3636
import tomllib
37+
from collections import deque
3738
from importlib.metadata import requires
3839
from pathlib import Path
3940
from queue import Empty, Queue
@@ -574,6 +575,22 @@ def resolve_job_timeout(config: TrainingRequest) -> int | str:
574575
# (transient network blip during a long training).
575576
_TAIL_RECONNECT_BACKOFF_S = 5.0
576577

578+
# How long a single connection may deliver NOTHING before we abandon it and
579+
# reconnect (MT47). `fetch_job_logs(follow=True)` can block inside one read
580+
# forever — no line, no StopIteration, no exception — and a plain `for` over it
581+
# has no way to notice. Generous on purpose: a job still QUEUED/BUILDING is
582+
# legitimately silent for minutes, and a needless reconnect is cheap now that
583+
# the replay is deduped by content rather than by position.
584+
_TAIL_SILENCE_TIMEOUT_S = 600.0
585+
586+
# How many recently-emitted lines are remembered for replay de-duplication on
587+
# reconnect (MT47). Bounds memory while covering a realistic replay window: a
588+
# 2.5-hour cloud run's log.jsonl held ~275 lines. If a replay ever exceeds this,
589+
# the oldest lines fall out of the window and are re-emitted — duplicated log
590+
# lines, which is the DELIBERATE failure direction: the previous positional
591+
# scheme failed the other way and went permanently silent.
592+
_TAIL_DEDUPE_WINDOW = 1000
593+
577594

578595
def resolve_wandb_api_key() -> str | None:
579596
"""Look up the host's wandb API key for forwarding to a cloud job.
@@ -629,9 +646,12 @@ def __init__(
629646
# registry can surface it to the UI instead of a synthetic exit code.
630647
self._terminal_message: str | None = None
631648
self._wandb_run_url: str | None = None
632-
# Count of log lines processed across (possibly multiple) SSE
633-
# connections, so reconnects skip the replayed prefix.
634-
self._lines_processed: int = 0
649+
# The most recently emitted log lines, for de-duplicating the prefix an
650+
# SSE reconnect replays (MT47). Content, not position: `seen`-vs-total
651+
# counting assumed every reconnect replays the whole log from line 1,
652+
# and silently dropped every subsequent line whenever it didn't.
653+
self._recent_lines: deque[str] = deque(maxlen=_TAIL_DEDUPE_WINDOW)
654+
self._recent_line_set: set[str] = set()
635655

636656
def start(self, job_id: str, config: TrainingRequest, output_dir: str) -> None:
637657
# output_dir is the host-local path the registry pins for local jobs;
@@ -827,30 +847,101 @@ def _ensure_dataset_on_hub(self, repo_id: str) -> None:
827847
raise RuntimeError(msg) from exc
828848
self._log_line(f"[upload] dataset {repo_id} uploaded.")
829849

850+
def _is_replayed(self, stripped: str) -> bool:
851+
"""Whether this line was already emitted, so a reconnect's replayed
852+
prefix isn't teed to disk and the UI twice (MT47).
853+
854+
Content-based and bounded, deliberately replacing the positional
855+
`seen <= _lines_processed` scheme this used to use. That scheme was only
856+
correct if EVERY reconnect replayed the whole log from line 1; when a
857+
reconnect replayed less than that (or nothing at all, following from
858+
"now"), the per-connection counter never caught up with the
859+
cross-connection total and every subsequent line was skipped — silently,
860+
forever, while the job ran happily to completion.
861+
862+
The tradeoff runs the other way now: a line repeated legitimately within
863+
the window is dropped, and a replay longer than the window is re-emitted.
864+
Both are cosmetic. Going mute is not.
865+
"""
866+
if stripped in self._recent_line_set:
867+
return True
868+
evicted = self._recent_lines[0] if len(self._recent_lines) == self._recent_lines.maxlen else None
869+
self._recent_lines.append(stripped)
870+
# deque(maxlen=…) drops the oldest on append; mirror that in the set,
871+
# but only if the evicted text isn't still present later in the window.
872+
if evicted is not None and evicted not in self._recent_lines:
873+
self._recent_line_set.discard(evicted)
874+
self._recent_line_set.add(stripped)
875+
return False
876+
877+
def _iter_job_logs(self):
878+
"""Yield raw log lines, abandoning a connection that goes silent (MT47).
879+
880+
`fetch_job_logs(follow=True)` can block inside a single read
881+
indefinitely — no line, no StopIteration, no exception — when the SSE
882+
connection is half-open (NAT eviction, laptop sleep, proxy idle
883+
timeout). A plain `for` over that iterator has no way to notice: it
884+
cannot even observe `_stop_event`, because the loop body never runs.
885+
886+
So the blocking iteration happens on a reader thread and is consumed
887+
through a queue with a timeout. On silence we raise, which the caller
888+
already handles as "reconnect". The reader thread is abandoned rather
889+
than joined — it is stuck in exactly the read we gave up on — but it is
890+
a daemon and dies with the process. That is not a new leak: before this,
891+
a stalled read stranded the whole tail loop the same way, and stranded
892+
it permanently.
893+
"""
894+
assert self._hf_job_id is not None
895+
queue: Queue = Queue()
896+
done = object()
897+
898+
def _reader() -> None:
899+
try:
900+
for raw in self._api.fetch_job_logs(job_id=self._hf_job_id, follow=True):
901+
queue.put(raw)
902+
if self._stop_event.is_set():
903+
break
904+
except Exception as exc: # surfaced on the consuming thread
905+
queue.put(exc)
906+
finally:
907+
queue.put(done)
908+
909+
threading.Thread(target=_reader, name=f"hf-job-{self._hf_job_id}-sse", daemon=True).start()
910+
911+
while True:
912+
try:
913+
item = queue.get(timeout=_TAIL_SILENCE_TIMEOUT_S)
914+
except Empty as exc:
915+
raise TimeoutError(f"no log output for {_TAIL_SILENCE_TIMEOUT_S:.0f}s; reconnecting") from exc
916+
if item is done:
917+
return
918+
if isinstance(item, BaseException):
919+
raise item
920+
yield item
921+
830922
def _tail_loop(self) -> None:
831923
"""Stream HfApi.fetch_job_logs, teeing each line to disk and the
832-
in-memory queue. Reconnects on stream end or transient error while
833-
the status poller still says the job is alive — SSE death is no
834-
longer fatal. Exits when _stop_event is set (status poller saw a
835-
terminal stage, or stop() was called).
924+
in-memory queue. Reconnects on stream end, transient error, or a
925+
silent connection while the status poller still says the job is alive
926+
— SSE death is no longer fatal. Exits when _stop_event is set (status
927+
poller saw a terminal stage, or stop() was called).
836928
"""
837929
assert self._hf_job_id is not None
838930
try:
839931
while not self._stop_event.is_set():
840932
clean_end = False
841933
try:
842-
seen = 0
843-
for raw in self._api.fetch_job_logs(job_id=self._hf_job_id, follow=True):
934+
for raw in self._iter_job_logs():
844935
if self._stop_event.is_set():
845936
return
846-
seen += 1
847-
# Skip the replayed prefix from a reconnect.
848-
if seen <= self._lines_processed:
849-
continue
850-
self._lines_processed = seen
851937
stripped = raw.rstrip()
852938
if not stripped:
853939
continue
940+
# Drop the prefix a reconnect replayed. Deliberately
941+
# AFTER the blank-line skip and on the stripped text, so
942+
# the window holds exactly what was emitted.
943+
if self._is_replayed(stripped):
944+
continue
854945
parse_metrics_into(stripped, self._metrics)
855946
if self._wandb_run_url is None:
856947
url = extract_wandb_run_url(stripped)

tests/test_jobs.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2868,3 +2868,81 @@ def test_start_allows_matching_feature_space(tmp_path) -> None:
28682868
):
28692869
record = reg.start(cfg, JobTarget(runner="local"))
28702870
assert record.state == "running"
2871+
2872+
2873+
# ---------------------------------------------------------------------------
2874+
# MT47 mitigation: a terminal record must not advertise progress it no longer
2875+
# believes, nor a countdown for a run that has already stopped.
2876+
# ---------------------------------------------------------------------------
2877+
2878+
2879+
def _record_with_metrics(state, **metrics):
2880+
from makermodslab.jobs import JobRecord, TrainingMetrics
2881+
from makermodslab.train import TrainingRequest
2882+
2883+
return JobRecord(
2884+
id="J",
2885+
name="j",
2886+
state=state,
2887+
config=TrainingRequest(dataset_repo_id="d", steps=10000),
2888+
output_dir="/tmp/j",
2889+
started_at=0.0,
2890+
metrics=TrainingMetrics(**metrics),
2891+
)
2892+
2893+
2894+
def test_settle_terminal_metrics_snaps_a_done_run_to_its_target() -> None:
2895+
"""The live symptom: a `done` cloud run whose log stream died at step 3,650
2896+
kept rendering "3,650 / 10,000" beside a step-10,000 checkpoint. `done`
2897+
means the target was reached, so progress is the target."""
2898+
from makermodslab.jobs import _settle_terminal_metrics
2899+
2900+
record = _record_with_metrics("done", current_step=3650, total_steps=10000, eta_seconds=3185.0)
2901+
2902+
_settle_terminal_metrics(record)
2903+
2904+
assert record.metrics.current_step == 10000
2905+
assert record.metrics.eta_seconds is None
2906+
2907+
2908+
def test_settle_terminal_metrics_never_invents_progress_for_a_failed_run() -> None:
2909+
"""A failed or interrupted run genuinely stopped where the last frame said.
2910+
Rounding that up to the target would claim training that never happened —
2911+
and would poison the resume flow, which reads the step to decide what is
2912+
left to do."""
2913+
from makermodslab.jobs import _settle_terminal_metrics
2914+
2915+
for state in ("failed", "interrupted"):
2916+
record = _record_with_metrics(state, current_step=3650, total_steps=10000, eta_seconds=3185.0)
2917+
2918+
_settle_terminal_metrics(record)
2919+
2920+
assert record.metrics.current_step == 3650, state
2921+
# The ETA still goes: nothing terminal has time remaining.
2922+
assert record.metrics.eta_seconds is None, state
2923+
2924+
2925+
def test_settle_terminal_metrics_leaves_an_unknown_target_alone() -> None:
2926+
"""total_steps == 0 means tqdm never spoke, which the UI reads as
2927+
"Training starting…". Snapping to it would assert 0/0 as a finished run."""
2928+
from makermodslab.jobs import _settle_terminal_metrics
2929+
2930+
record = _record_with_metrics("done", current_step=0, total_steps=0)
2931+
2932+
_settle_terminal_metrics(record)
2933+
2934+
assert record.metrics.current_step == 0
2935+
assert record.metrics.total_steps == 0
2936+
2937+
2938+
def test_settle_terminal_metrics_is_a_noop_on_a_healthy_finished_run() -> None:
2939+
"""With the log tail repaired this is the normal case, and it must not
2940+
change anything — the mitigation exists for the broken-stream case only."""
2941+
from makermodslab.jobs import _settle_terminal_metrics
2942+
2943+
record = _record_with_metrics("done", current_step=10000, total_steps=10000, current_loss=0.04)
2944+
2945+
_settle_terminal_metrics(record)
2946+
2947+
assert record.metrics.current_step == 10000
2948+
assert record.metrics.current_loss == 0.04

0 commit comments

Comments
 (0)