* Fix resume training crash recovery and MLX checkpoints * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Address review: preserve interrupted stop-and-save output_dir, verify MLX checkpoint - finish_run: add clear_output_dir flag; preserve output_dir for stopped/error unless cancel explicitly clears it (fixes pump finalization wiping persisted path). - training pump: pass interrupted stop-and-save context into finalize_run_in_db. - MLX stop-and-save: verify resumable checkpoint exists before sending complete; return bool from _write_mlx_stop_checkpoint and add regression tests. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Address Codex review: MLX current-step checkpoint and cancel error finalize - Only skip MLX stop checkpoint write when checkpoint-{current_step} exists; stale periodic checkpoints no longer mask missing stop saves. - Pass clear_output_dir through error-event finalization so Stop-without-save cannot leave a persisted output_dir that still offers Resume. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Address review * Address more reviews * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * more reviews * clear in-memory output_dir on interrupted cancel * allow resuming errored runs at the final step * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Clear persisted output_dir in cancel watchdog path * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Write MLX stop checkpoint in stop path, keep output_dir on crash finalize * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fix(studio): harden resumable run finalization * fix(studio): defer safetensors checkpoint import * fix(studio): reject stale training cancellation * fix(studio): replay null resume targets * fix(studio): serialize terminal cancellation * Harden resume checkpoint validation and fix stop-save cleanup - Reject unrecognized shard formats and keep indexed shard paths inside the checkpoint dir - Require a non-empty tensor record when validating .pt/.bin optimizer and model state - Always finalize TensorBoard and W&B on stop-save-failure exits - Refuse writing an MLX stop checkpoint through a symlinked directory - Clarify the resume rejection message to cover errored runs * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Tighten resume/checkpoint comments * Recover resumability when a valid stop checkpoint landed - Re-validate the current-step checkpoint in the dead-worker and error finalization paths so a stop-and-save that actually wrote a valid checkpoint is not wrongly marked error/resume_blocked - Accept a valid tensor-free optimizer state (e.g. SGD without momentum); the model-state check still requires real tensors - Include errored runs in the frontend resume rejection message * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Lyxot <longyixing331@gmail.com> Co-authored-by: Lee Jackson <130007945+Imagineer99@users.noreply.github.com> Co-authored-by: danielhanchen <danielhanchen@gmail.com>
907 lines
35 KiB
Python
907 lines
35 KiB
Python
# SPDX-License-Identifier: AGPL-3.0-only
|
|
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
|
|
|
"""Stop-watchdog escalation for a stuck training stop.
|
|
|
|
A save-stop signals the worker and waits for it to save and exit. On some platforms the
|
|
worker saves but then wedges in post-save GPU/driver teardown and never exits, leaving the
|
|
run stuck in "Stopping..." forever. These tests pin the bounded recovery: the watchdog
|
|
escalates to force_terminate() a short grace after "complete" (save done) or after an
|
|
absolute timeout (hang during save), and never force-kills a worker that exits cleanly.
|
|
Fakes only; no GPU, network, or subprocess.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import logging
|
|
import queue
|
|
import sys
|
|
import threading
|
|
import time
|
|
import types as _types
|
|
from pathlib import Path
|
|
|
|
_BACKEND_DIR = str(Path(__file__).resolve().parent.parent)
|
|
if _BACKEND_DIR not in sys.path:
|
|
sys.path.insert(0, _BACKEND_DIR)
|
|
|
|
# Stub the heavy module-level imports of core/training/training.py so it imports
|
|
# under CPU-only/no-network, then restore them (see the restore loop below).
|
|
_SAVED: dict = {}
|
|
|
|
|
|
def _stub(name, mod):
|
|
_SAVED[name] = sys.modules.get(name)
|
|
sys.modules[name] = mod
|
|
|
|
|
|
_lg = _types.ModuleType("loggers")
|
|
_lg.get_logger = lambda name: logging.getLogger(name)
|
|
_stub("loggers", _lg)
|
|
_stub("structlog", _types.ModuleType("structlog"))
|
|
_mpl = _types.ModuleType("matplotlib")
|
|
_plt = _types.ModuleType("matplotlib.pyplot")
|
|
_plt.Figure = type("Figure", (), {}) # referenced in a class-def annotation
|
|
_mpl.pyplot = _plt
|
|
_stub("matplotlib", _mpl)
|
|
_stub("matplotlib.pyplot", _plt)
|
|
_hw = _types.ModuleType("utils.hardware")
|
|
_hw.prepare_gpu_selection = lambda *a, **k: (None, None)
|
|
_stub("utils.hardware", _hw)
|
|
_npl = _types.ModuleType("utils.native_path_leases")
|
|
_npl.native_path_secret_removed_for_child_start = lambda: contextlib.nullcontext()
|
|
_npl.run_without_native_path_secret = lambda fn: fn
|
|
_stub("utils.native_path_leases", _npl)
|
|
_pth = _types.ModuleType("utils.paths")
|
|
_pth.outputs_root = lambda *a, **k: "/tmp/outputs"
|
|
_stub("utils.paths", _pth)
|
|
|
|
# Whether core.training.training was already imported before this file ran; only
|
|
# evict it below if we were the one to create the (stub-bound) module instance.
|
|
_TRAINING_PRE_IMPORTED = "core.training.training" in sys.modules
|
|
|
|
from core.training.training import TrainingBackend
|
|
|
|
# Restore every stubbed module so this file never pollutes the shared session.
|
|
for _name in (
|
|
"loggers",
|
|
"structlog",
|
|
"matplotlib",
|
|
"matplotlib.pyplot",
|
|
"utils.hardware",
|
|
"utils.native_path_leases",
|
|
"utils.paths",
|
|
):
|
|
_prev = _SAVED.get(_name)
|
|
if _prev is None:
|
|
sys.modules.pop(_name, None)
|
|
else:
|
|
sys.modules[_name] = _prev
|
|
|
|
if not _TRAINING_PRE_IMPORTED:
|
|
sys.modules.pop("core.training.training", None)
|
|
sys.modules.pop("core.training", None)
|
|
|
|
# The module globals hold the escalation timeouts and are the watchdog's own
|
|
# namespace; patch them here so tests run in well under a second.
|
|
_G = TrainingBackend._stop_watchdog_loop.__globals__
|
|
|
|
|
|
class _FakeProc:
|
|
"""A subprocess handle whose liveness and kill calls the test observes."""
|
|
|
|
def __init__(self, alive: bool = True):
|
|
self._alive = alive
|
|
self.pid = 4321
|
|
self.terminated = False
|
|
self.killed = False
|
|
|
|
def is_alive(self):
|
|
return self._alive
|
|
|
|
def terminate(self):
|
|
self.terminated = True
|
|
|
|
def kill(self):
|
|
self.killed = True
|
|
|
|
def join(self, timeout = None):
|
|
pass
|
|
|
|
|
|
def _wait_until(predicate, timeout = 5.0):
|
|
deadline = time.time() + timeout
|
|
while time.time() < deadline:
|
|
if predicate():
|
|
return True
|
|
time.sleep(0.01)
|
|
return predicate()
|
|
|
|
|
|
def _record_force_terminate(monkeypatch, b):
|
|
"""Replace force_terminate + escalation finalize with recorders (no DB/OS)."""
|
|
calls: list = []
|
|
monkeypatch.setattr(b, "force_terminate", lambda target_proc = None: calls.append("force"))
|
|
monkeypatch.setattr(
|
|
b,
|
|
"_finalize_stopped_after_escalation",
|
|
lambda target_proc = None, watched_job_id = None: calls.append("final"),
|
|
)
|
|
return calls
|
|
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# (a) Escalate a short grace after "complete" (save done) if still alive.
|
|
# ----------------------------------------------------------------------------
|
|
|
|
|
|
def test_watchdog_escalates_after_grace_once_complete_seen(monkeypatch):
|
|
monkeypatch.setitem(_G, "_STOP_GRACE_S", 0.05)
|
|
monkeypatch.setitem(_G, "_STOP_TIMEOUT_S", 100.0) # ensure grace, not timeout, fires
|
|
b = TrainingBackend()
|
|
calls = _record_force_terminate(monkeypatch, b)
|
|
|
|
proc = _FakeProc(alive = True)
|
|
b._proc = proc
|
|
b._complete_seen.set() # worker reported "complete" -> save is done
|
|
|
|
b._start_stop_watchdog(cancel = False)
|
|
assert _wait_until(
|
|
lambda: calls == ["force", "final"]
|
|
), "watchdog must force_terminate a worker still alive after the post-save grace"
|
|
b._stop_watchdog.join(timeout = 5)
|
|
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# (b) The absolute cap is a last-resort backstop, not a save killer.
|
|
# ----------------------------------------------------------------------------
|
|
|
|
|
|
def test_watchdog_does_not_kill_save_still_saving_within_window(monkeypatch):
|
|
# save=True, no "complete" yet: a slow save in progress must not be force-killed
|
|
# inside the (long) absolute window.
|
|
monkeypatch.setitem(_G, "_STOP_GRACE_S", 100.0)
|
|
monkeypatch.setitem(_G, "_STOP_TIMEOUT_S", 100.0)
|
|
b = TrainingBackend()
|
|
calls = _record_force_terminate(monkeypatch, b)
|
|
|
|
proc = _FakeProc(alive = True)
|
|
b._proc = proc
|
|
b._start_stop_watchdog(cancel = False)
|
|
|
|
time.sleep(0.3)
|
|
assert calls == [], "an in-progress save must not be killed within the absolute window"
|
|
assert b._stop_watchdog.is_alive()
|
|
|
|
proc._alive = False
|
|
b._stop_watchdog.join(timeout = 5)
|
|
|
|
|
|
def test_watchdog_backstop_fires_for_save_after_absolute_timeout(monkeypatch):
|
|
# Past the long save=True cap with no completion: force-terminate as last resort.
|
|
monkeypatch.setitem(_G, "_STOP_GRACE_S", 100.0) # never trips (no complete)
|
|
monkeypatch.setitem(_G, "_STOP_TIMEOUT_S", 0.05)
|
|
b = TrainingBackend()
|
|
calls = _record_force_terminate(monkeypatch, b)
|
|
|
|
b._proc = _FakeProc(alive = True)
|
|
b._start_stop_watchdog(cancel = False)
|
|
assert _wait_until(
|
|
lambda: calls == ["force", "final"]
|
|
), "the absolute backstop must force_terminate a save that never completes"
|
|
b._stop_watchdog.join(timeout = 5)
|
|
|
|
|
|
def test_cancel_uses_shorter_absolute_timeout(monkeypatch):
|
|
# A cancel has nothing to save, so it escalates on the shorter cancel cap even before
|
|
# the long save cap elapses.
|
|
monkeypatch.setitem(_G, "_STOP_GRACE_S", 100.0)
|
|
monkeypatch.setitem(_G, "_STOP_TIMEOUT_S", 100.0) # save cap would not fire
|
|
monkeypatch.setitem(_G, "_CANCEL_TIMEOUT_S", 0.05)
|
|
b = TrainingBackend()
|
|
calls = _record_force_terminate(monkeypatch, b)
|
|
|
|
b._proc = _FakeProc(alive = True)
|
|
b._start_stop_watchdog(cancel = True)
|
|
assert _wait_until(
|
|
lambda: calls == ["force", "final"]
|
|
), "a cancel must escalate on the shorter cancel timeout"
|
|
b._stop_watchdog.join(timeout = 5)
|
|
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# (c) No force-kill when the worker exits cleanly and promptly.
|
|
# ----------------------------------------------------------------------------
|
|
|
|
|
|
def test_watchdog_no_op_on_clean_quick_exit(monkeypatch):
|
|
monkeypatch.setitem(_G, "_STOP_GRACE_S", 5.0)
|
|
monkeypatch.setitem(_G, "_STOP_TIMEOUT_S", 10.0)
|
|
b = TrainingBackend()
|
|
calls = _record_force_terminate(monkeypatch, b)
|
|
|
|
proc = _FakeProc(alive = True)
|
|
b._proc = proc
|
|
b._complete_seen.set() # save done; worker is about to exit on its own
|
|
|
|
b._start_stop_watchdog(cancel = False)
|
|
# Worker exits promptly, well before the grace period elapses.
|
|
time.sleep(0.1)
|
|
proc._alive = False
|
|
|
|
b._stop_watchdog.join(timeout = 5)
|
|
assert not b._stop_watchdog.is_alive()
|
|
assert calls == [], "a clean quick exit must not trigger force_terminate"
|
|
|
|
|
|
def test_watchdog_no_op_when_worker_superseded(monkeypatch):
|
|
# A stale watchdog from a prior run must never kill a new run's worker: once
|
|
# self._proc is replaced, it exits silently.
|
|
monkeypatch.setitem(_G, "_STOP_GRACE_S", 0.05)
|
|
monkeypatch.setitem(_G, "_STOP_TIMEOUT_S", 0.05)
|
|
b = TrainingBackend()
|
|
calls = _record_force_terminate(monkeypatch, b)
|
|
|
|
old_proc = _FakeProc(alive = True)
|
|
b._proc = old_proc
|
|
b._complete_seen.set()
|
|
b._start_stop_watchdog(cancel = False)
|
|
|
|
# A new run takes over the handle before the grace elapses.
|
|
b._proc = _FakeProc(alive = True)
|
|
|
|
b._stop_watchdog.join(timeout = 5)
|
|
assert calls == [], "watchdog must not force_terminate a superseded worker"
|
|
|
|
|
|
def test_new_run_gets_its_own_watchdog(monkeypatch):
|
|
# A stale watchdog sleeping on an old proc must not stop a new run's stop from
|
|
# creating its own watcher.
|
|
b = TrainingBackend()
|
|
started = []
|
|
release = threading.Event()
|
|
|
|
def _blocked_watchdog(
|
|
target_proc,
|
|
cancel,
|
|
watched_job_id = None,
|
|
):
|
|
started.append(target_proc)
|
|
# No timeout: the finally always releases this, so a superseded watchdog stays
|
|
# alive through the assertions regardless of load; as a daemon it can't hang exit.
|
|
release.wait()
|
|
|
|
monkeypatch.setattr(b, "_stop_watchdog_loop", _blocked_watchdog)
|
|
|
|
old_proc = _FakeProc(alive = True)
|
|
b._proc = old_proc
|
|
b._start_stop_watchdog(cancel = False)
|
|
first_wd = b._stop_watchdog
|
|
assert _wait_until(lambda: started == [old_proc])
|
|
|
|
# New run: fresh worker replaces the handle; its stop must get a new watcher
|
|
# even though the old (superseded) watchdog is still alive.
|
|
new_proc = _FakeProc(alive = True)
|
|
b._proc = new_proc
|
|
b._start_stop_watchdog(cancel = False)
|
|
second_wd = b._stop_watchdog
|
|
|
|
try:
|
|
assert _wait_until(lambda: started == [old_proc, new_proc])
|
|
assert first_wd.is_alive()
|
|
assert second_wd is not first_wd, "a new run must get its own watchdog"
|
|
assert b._stop_watchdog_proc is new_proc
|
|
finally:
|
|
release.set()
|
|
first_wd.join(timeout = 5)
|
|
second_wd.join(timeout = 5)
|
|
|
|
|
|
def test_force_terminate_targets_only_captured_proc():
|
|
# Superseded: force_terminate(target) must not touch a different current worker.
|
|
b = TrainingBackend()
|
|
old_proc = _FakeProc(alive = True)
|
|
new_proc = _FakeProc(alive = True)
|
|
b._proc = new_proc
|
|
b.force_terminate(target_proc = old_proc)
|
|
assert new_proc.terminated is False, "must not terminate the new run's worker"
|
|
assert old_proc.terminated is False, "must not terminate a handle that is not current"
|
|
|
|
# Matching: the captured handle is the current worker, so it is terminated.
|
|
p = _FakeProc(alive = True)
|
|
b._proc = p
|
|
b.force_terminate(target_proc = p)
|
|
assert p.terminated is True
|
|
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# Post-escalation finalize leaves the parent ready for a new run.
|
|
# ----------------------------------------------------------------------------
|
|
|
|
|
|
def test_finalize_runs_even_if_force_terminate_raises(monkeypatch):
|
|
# A wedged child can make force_terminate() raise; finalize must still run so the
|
|
# run does not stay stuck in "Stopping...".
|
|
monkeypatch.setitem(_G, "_STOP_GRACE_S", 0.05)
|
|
monkeypatch.setitem(_G, "_STOP_TIMEOUT_S", 100.0)
|
|
b = TrainingBackend()
|
|
|
|
def _boom(target_proc = None):
|
|
raise RuntimeError("kill() failed on wedged child")
|
|
|
|
finalized: list = []
|
|
monkeypatch.setattr(b, "force_terminate", _boom)
|
|
monkeypatch.setattr(
|
|
b,
|
|
"_finalize_stopped_after_escalation",
|
|
lambda target_proc = None, watched_job_id = None: finalized.append(True),
|
|
)
|
|
|
|
b._proc = _FakeProc(alive = True)
|
|
b._complete_seen.set()
|
|
b._start_stop_watchdog(cancel = False)
|
|
|
|
assert _wait_until(
|
|
lambda: finalized == [True]
|
|
), "finalize must run even when force_terminate raises"
|
|
b._stop_watchdog.join(timeout = 5)
|
|
|
|
|
|
def test_finalize_after_escalation_clears_state(monkeypatch):
|
|
# Even if the OS never reaps the wedged worker, the parent must report the run
|
|
# stopped so the UI leaves "Stopping..." and a new run can start.
|
|
b = TrainingBackend()
|
|
finstop: list = []
|
|
monkeypatch.setattr(b, "_finish_stopped_run", lambda *a, **k: finstop.append(a))
|
|
|
|
b._proc = _FakeProc(alive = True) # wedged: still reports alive
|
|
b._should_stop = True
|
|
b.current_job_id = "job_c"
|
|
b._db_run_created = True
|
|
b._progress.is_training = True
|
|
|
|
b._finalize_stopped_after_escalation(watched_job_id = "job_c")
|
|
|
|
assert b._proc is None, "the wedged handle must be dropped so is_training_active clears"
|
|
assert b._progress.is_training is False
|
|
assert "valid current-step checkpoint" in b._progress.status_message
|
|
assert finstop and finstop[0][0] == "job_c", "the captured run must be finalized by id"
|
|
assert b.is_training_active() is False
|
|
|
|
|
|
def test_finalize_after_escalation_preserves_output_dir(monkeypatch):
|
|
# A save-stop that already emitted "complete" has the checkpoint dir; run history
|
|
# must record it even if the watchdog wins the finalize race against the pump.
|
|
b = TrainingBackend()
|
|
finstop: list = []
|
|
monkeypatch.setattr(b, "_finish_stopped_run", lambda *a, **k: finstop.append(a))
|
|
|
|
b._proc = _FakeProc(alive = True)
|
|
b._should_stop = True
|
|
b.current_job_id = "job_c"
|
|
b._db_run_created = True
|
|
b._output_dir = "/tmp/outputs/run-123"
|
|
|
|
b._finalize_stopped_after_escalation(watched_job_id = "job_c")
|
|
|
|
# _finish_stopped_run(run_id, output_dir, batch, final_step, final_loss, duration, loss_history)
|
|
assert finstop and finstop[0][0] == "job_c"
|
|
assert finstop[0][1] == "/tmp/outputs/run-123"
|
|
|
|
|
|
def test_finalize_after_escalation_clears_output_dir_on_cancel(monkeypatch):
|
|
# Stop-without-saving promises no resume: a cancel that escalates through the
|
|
# watchdog clears the persisted output_dir, not a checkpoint path.
|
|
b = TrainingBackend()
|
|
finstop: list = []
|
|
monkeypatch.setattr(b, "_finish_stopped_run", lambda *a, **k: finstop.append((a, k)))
|
|
|
|
b._proc = _FakeProc(alive = True)
|
|
b._should_stop = True
|
|
b._cancel_requested = True
|
|
b.current_job_id = "job_c"
|
|
b._db_run_created = True
|
|
b._output_dir = "/tmp/outputs/run-123"
|
|
|
|
b._finalize_stopped_after_escalation(watched_job_id = "job_c")
|
|
|
|
assert finstop and finstop[0][0][0] == "job_c"
|
|
assert finstop[0][0][1] is None, "a cancelled run must not record a checkpoint path"
|
|
assert finstop[0][1].get("clear_output_dir") is True
|
|
assert b._output_dir is None, "/status must stop exposing the cancelled run's dir"
|
|
|
|
|
|
def test_stop_training_starts_watchdog_only_when_worker_alive(monkeypatch):
|
|
# No worker -> nothing to escalate; the watchdog must not spawn.
|
|
b = TrainingBackend()
|
|
b._proc = None
|
|
assert b.stop_training(save = True) is True
|
|
assert b._stop_watchdog is None
|
|
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# (d) A stale watchdog must never clobber a run that replaced its worker.
|
|
# ----------------------------------------------------------------------------
|
|
|
|
|
|
def test_finalize_after_escalation_no_ops_when_superseded(monkeypatch):
|
|
# A /start can slip in while the watchdog force-terminates the old worker
|
|
# (is_training_active() is False once _should_stop is set and the old proc is dead).
|
|
# The escalation finalize must then leave the NEW run untouched, not drop its handle.
|
|
b = TrainingBackend()
|
|
finstop: list = []
|
|
monkeypatch.setattr(b, "_finish_stopped_run", lambda *a, **k: finstop.append(a))
|
|
|
|
old_proc = _FakeProc(alive = False) # force-terminated worker we were watching
|
|
new_proc = _FakeProc(alive = True) # a new run already took over
|
|
b._proc = new_proc
|
|
b.current_job_id = "job_new"
|
|
b._db_run_created = True
|
|
b._progress.is_training = True
|
|
|
|
b._finalize_stopped_after_escalation(target_proc = old_proc)
|
|
|
|
assert b._proc is new_proc, "must not drop the new run's handle"
|
|
assert b._progress.is_training is True, "must not mark the new run stopped"
|
|
assert finstop == [], "must not finalize the new run in the DB"
|
|
|
|
|
|
def test_finalize_after_escalation_runs_for_its_own_worker(monkeypatch):
|
|
# Common case: the watched worker is still current, so finalize proceeds and
|
|
# finalizes the captured run by id.
|
|
b = TrainingBackend()
|
|
finstop: list = []
|
|
monkeypatch.setattr(b, "_finish_stopped_run", lambda *a, **k: finstop.append(a))
|
|
|
|
proc = _FakeProc(alive = False)
|
|
b._proc = proc
|
|
b.current_job_id = "job_a"
|
|
b._db_run_created = True
|
|
b._progress.is_training = True
|
|
|
|
b._finalize_stopped_after_escalation(target_proc = proc, watched_job_id = "job_a")
|
|
|
|
assert b._proc is None
|
|
assert b._progress.is_training is False
|
|
assert finstop and finstop[0][0] == "job_a", "must finalize the captured run by id"
|
|
|
|
|
|
def test_finalize_after_escalation_no_ops_on_job_change_during_startup(monkeypatch):
|
|
# start_training updates current_job_id BEFORE it installs the new _proc, so a stale
|
|
# watchdog can enter while _proc is still the old (dead) handle. The job-id guard must
|
|
# catch this even though the proc-only guard would not.
|
|
b = TrainingBackend()
|
|
finstop: list = []
|
|
monkeypatch.setattr(b, "_finish_stopped_run", lambda *a, **k: finstop.append(a))
|
|
|
|
old_proc = _FakeProc(alive = False) # old worker, dead; new _proc not installed yet
|
|
b._proc = old_proc # still the old handle (== target), so proc guard would pass
|
|
b.current_job_id = "job_new" # but the new run already claimed the job id
|
|
b._db_run_created = True
|
|
b._progress.is_training = True
|
|
|
|
b._finalize_stopped_after_escalation(target_proc = old_proc, watched_job_id = "job_old")
|
|
|
|
assert b._proc is old_proc, "must not drop the handle during a new run's startup"
|
|
assert b._progress.is_training is True, "must not mark the starting run stopped"
|
|
assert finstop == [], "must not finalize while a new run is starting up"
|
|
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# (e) A later cancel (save=False) tightens an in-flight save watchdog.
|
|
# ----------------------------------------------------------------------------
|
|
|
|
|
|
def test_later_cancel_tightens_watchdog_timeout(monkeypatch):
|
|
monkeypatch.setitem(_G, "_STOP_GRACE_S", 100.0) # never trips (no complete)
|
|
monkeypatch.setitem(_G, "_STOP_TIMEOUT_S", 100.0) # save cap would not fire
|
|
monkeypatch.setitem(_G, "_CANCEL_TIMEOUT_S", 0.05)
|
|
b = TrainingBackend()
|
|
calls = _record_force_terminate(monkeypatch, b)
|
|
|
|
b._proc = _FakeProc(alive = True)
|
|
b._start_stop_watchdog(cancel = False) # started as a save-stop with the long cap
|
|
time.sleep(0.15)
|
|
assert calls == [], "a save-stop must not escalate on the short cancel cap yet"
|
|
|
|
# The user now cancels the in-flight stop: the watchdog must tighten its cap.
|
|
b._cancel_requested = True
|
|
assert _wait_until(
|
|
lambda: calls == ["force", "final"]
|
|
), "a later cancel must tighten the watchdog to the shorter cancel cap"
|
|
b._stop_watchdog.join(timeout = 5)
|
|
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# (f) DB finalize/flush are safe when the watchdog and pump race (see Item 4).
|
|
# ----------------------------------------------------------------------------
|
|
|
|
|
|
def _install_fake_db(monkeypatch):
|
|
"""Stub storage.studio_db + utils.downsample so the real DB helpers run without
|
|
SQLite. Returns the recorder dict."""
|
|
recs = {"created": [], "finished": [], "inserted": [], "insert_ids": [], "progress_ids": []}
|
|
fake_storage = _types.ModuleType("storage")
|
|
fake_db = _types.ModuleType("storage.studio_db")
|
|
fake_db.create_run = lambda **kw: recs["created"].append(kw)
|
|
fake_db.finish_run = lambda **kw: recs["finished"].append(kw)
|
|
fake_db.insert_metrics_batch = lambda job_id, batch: (
|
|
recs["inserted"].extend(batch),
|
|
recs["insert_ids"].append(job_id),
|
|
)
|
|
fake_db.update_run_progress = lambda **kw: recs["progress_ids"].append(kw.get("id"))
|
|
fake_db.mark_run_cancel_requested = lambda _run_id: True
|
|
fake_storage.studio_db = fake_db
|
|
monkeypatch.setitem(sys.modules, "storage", fake_storage)
|
|
monkeypatch.setitem(sys.modules, "storage.studio_db", fake_db)
|
|
fake_ds = _types.ModuleType("utils.downsample")
|
|
fake_ds.downsample = lambda seq, n: list(seq)[:n]
|
|
monkeypatch.setitem(sys.modules, "utils.downsample", fake_ds)
|
|
return recs
|
|
|
|
|
|
def test_stop_without_save_creates_missing_row_before_signal(monkeypatch):
|
|
recs = _install_fake_db(monkeypatch)
|
|
b = TrainingBackend()
|
|
b.current_job_id, b._db_config = "job_missing", {"model_name": "m"}
|
|
b._stop_queue = queue.Queue()
|
|
assert b.stop_training(save = False) is True
|
|
assert [run["id"] for run in recs["created"]] == ["job_missing"]
|
|
assert b._stop_queue.get_nowait() == {"type": "stop", "save": False}
|
|
|
|
b._cancel_requested = b._should_stop = False
|
|
sys.modules["storage.studio_db"].mark_run_cancel_requested = lambda _run_id: False
|
|
assert b.stop_training(save = False) is False
|
|
assert not b._cancel_requested and b._stop_queue.empty()
|
|
|
|
new_queue = queue.Queue()
|
|
b.current_job_id, b._db_run_created = "job_old", True
|
|
b._cancel_requested = b._should_stop = False
|
|
|
|
def _supersede(_run_id):
|
|
b.current_job_id = "job_new"
|
|
b._stop_queue = new_queue
|
|
return True
|
|
|
|
sys.modules["storage.studio_db"].mark_run_cancel_requested = _supersede
|
|
assert b.stop_training(save = False) is False
|
|
assert not b._cancel_requested and new_queue.empty()
|
|
|
|
|
|
def test_finalize_run_in_db_single_winner_under_concurrency(monkeypatch):
|
|
# The watchdog and pump can both finalize; only one call may reach finish_run.
|
|
recs = _install_fake_db(monkeypatch)
|
|
monkeypatch.setitem(_G, "_DB_FINALIZE_RETRY_S", 0.0)
|
|
attempts = 0
|
|
|
|
def flaky_finish(**kw):
|
|
nonlocal attempts
|
|
attempts += 1
|
|
if attempts < 3:
|
|
raise RuntimeError("database is locked")
|
|
recs["finished"].append(kw)
|
|
|
|
sys.modules["storage.studio_db"].finish_run = flaky_finish
|
|
b = TrainingBackend()
|
|
b.current_job_id = "job_x"
|
|
b._db_run_created = True
|
|
b._run_finalized = False
|
|
|
|
start = threading.Barrier(8)
|
|
|
|
def worker():
|
|
start.wait()
|
|
b._finalize_run_in_db(status = "stopped")
|
|
|
|
threads = [threading.Thread(target = worker) for _ in range(8)]
|
|
for t in threads:
|
|
t.start()
|
|
for t in threads:
|
|
t.join(timeout = 5)
|
|
|
|
assert len(recs["finished"]) == 1, f"finalize must run once, got {len(recs['finished'])}"
|
|
assert attempts == 3
|
|
assert b._run_finalized is True
|
|
|
|
|
|
def test_finalize_run_in_db_no_ops_on_job_mismatch(monkeypatch):
|
|
# A finalize captured for an old job must not finalize the run that replaced it.
|
|
recs = _install_fake_db(monkeypatch)
|
|
b = TrainingBackend()
|
|
b.current_job_id = "job_new"
|
|
b._db_run_created = True
|
|
b._run_finalized = False
|
|
|
|
b._finalize_run_in_db(status = "stopped", expected_job_id = "job_old")
|
|
|
|
assert recs["finished"] == [], "a superseded job id must not finalize the current run"
|
|
assert b._run_finalized is False
|
|
|
|
|
|
def test_concurrent_flush_claims_each_metric_once(monkeypatch):
|
|
# Concurrent flushes (pump periodic flush vs watchdog finalize flush) must not
|
|
# double-remove or drop buffered metrics.
|
|
recs = _install_fake_db(monkeypatch)
|
|
b = TrainingBackend()
|
|
b.current_job_id = "job_y"
|
|
b._db_run_created = True
|
|
b._metric_buffer[:] = [{"step": i} for i in range(200)]
|
|
|
|
start = threading.Barrier(6)
|
|
|
|
def worker():
|
|
start.wait()
|
|
for _ in range(50):
|
|
b._flush_metrics_to_db()
|
|
|
|
threads = [threading.Thread(target = worker) for _ in range(6)]
|
|
for t in threads:
|
|
t.start()
|
|
for t in threads:
|
|
t.join(timeout = 5)
|
|
b._flush_metrics_to_db() # drain any remainder
|
|
|
|
steps = sorted(m["step"] for m in recs["inserted"])
|
|
assert steps == list(range(200)), "each metric must be inserted exactly once"
|
|
assert b._metric_buffer == [], "the buffer must be fully drained"
|
|
|
|
|
|
def test_flush_pins_to_passed_run_id(monkeypatch):
|
|
# A finalizer flushes to the run it captured, even if a new /start has already
|
|
# changed current_job_id.
|
|
recs = _install_fake_db(monkeypatch)
|
|
b = TrainingBackend()
|
|
b.current_job_id = "job_new" # a new run is already live
|
|
b._db_run_created = True
|
|
b._metric_buffer[:] = [{"step": 1}, {"step": 2}]
|
|
|
|
b._flush_metrics_to_db(run_id = "job_old")
|
|
|
|
assert recs["insert_ids"] == ["job_old"], "metrics must go to the captured run, not the new one"
|
|
assert recs["progress_ids"] == ["job_old"]
|
|
|
|
|
|
def test_finalize_uses_snapshot_run_id_across_new_run(monkeypatch):
|
|
# If a new /start changes current_job_id after the finalize claim but before the DB
|
|
# writes, finish_run must still target the run captured under the lock.
|
|
recs = _install_fake_db(monkeypatch)
|
|
b = TrainingBackend()
|
|
b.current_job_id = "job_x"
|
|
b._db_run_created = True
|
|
b._run_finalized = False
|
|
|
|
def hijack(run_id = None):
|
|
# Simulate a new run taking over during the flush (after the finalize claim).
|
|
b.current_job_id = "job_y"
|
|
|
|
monkeypatch.setattr(b, "_flush_metrics_to_db", hijack)
|
|
|
|
b._finalize_run_in_db(status = "stopped", expected_job_id = "job_x")
|
|
|
|
assert [f["id"] for f in recs["finished"]] == [
|
|
"job_x"
|
|
], "finish_run must target the captured run, not the run that replaced it"
|
|
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# (g) DB row creation must not be published before the insert commits.
|
|
# ----------------------------------------------------------------------------
|
|
|
|
|
|
def test_ensure_db_run_created_publishes_only_after_insert(monkeypatch):
|
|
# _db_run_created must stay False while create_run is in flight, so a concurrent
|
|
# finalize can't run finish_run (an UPDATE) against a not-yet-inserted row.
|
|
b = TrainingBackend()
|
|
b.current_job_id = "job_z"
|
|
b._db_config = {"model_name": "m"}
|
|
observed: dict = {}
|
|
|
|
fake_storage = _types.ModuleType("storage")
|
|
fake_db = _types.ModuleType("storage.studio_db")
|
|
|
|
def _create(**kw):
|
|
observed["flag_during_create"] = b._db_run_created
|
|
observed["in_progress_during_create"] = b._db_create_in_progress
|
|
|
|
fake_db.create_run = _create
|
|
fake_storage.studio_db = fake_db
|
|
monkeypatch.setitem(sys.modules, "storage", fake_storage)
|
|
monkeypatch.setitem(sys.modules, "storage.studio_db", fake_db)
|
|
|
|
b._run_intent_lock.acquire()
|
|
creator = threading.Thread(target = b._ensure_db_run_created)
|
|
creator.start()
|
|
time.sleep(0.02)
|
|
assert b._db_create_in_progress is False
|
|
b._run_intent_lock.release()
|
|
creator.join(timeout = 5)
|
|
|
|
assert observed["flag_during_create"] is False, "flag must not be published before insert"
|
|
assert observed["in_progress_during_create"] is True
|
|
assert b._db_run_created is True, "flag must be published after a successful insert"
|
|
assert b._db_create_in_progress is False
|
|
|
|
|
|
def test_ensure_db_run_created_stays_unpublished_on_failure(monkeypatch):
|
|
# If create_run raises, neither flag stays set, so a later caller can retry.
|
|
b = TrainingBackend()
|
|
b.current_job_id = "job_z"
|
|
b._db_config = {"model_name": "m"}
|
|
|
|
fake_storage = _types.ModuleType("storage")
|
|
fake_db = _types.ModuleType("storage.studio_db")
|
|
|
|
def _boom_create(**kw):
|
|
raise RuntimeError("insert failed")
|
|
|
|
fake_db.create_run = _boom_create
|
|
fake_storage.studio_db = fake_db
|
|
monkeypatch.setitem(sys.modules, "storage", fake_storage)
|
|
monkeypatch.setitem(sys.modules, "storage.studio_db", fake_db)
|
|
|
|
b._ensure_db_run_created()
|
|
|
|
assert b._db_run_created is False, "a failed insert must not publish the row as created"
|
|
assert b._db_create_in_progress is False, "the in-progress flag must be cleared on failure"
|
|
|
|
|
|
def test_ensure_db_run_created_does_not_publish_for_a_new_run(monkeypatch):
|
|
# A killed worker lets a new /start proceed while the watchdog is still creating the old
|
|
# run's row. The stale create must not publish the backend-wide flags against the new
|
|
# current_job_id, or the new run would skip inserting its own row.
|
|
b = TrainingBackend()
|
|
b.current_job_id = "job_old"
|
|
b._db_config = {"model_name": "m"}
|
|
b._db_run_created = False
|
|
b._db_create_in_progress = False
|
|
|
|
fake_storage = _types.ModuleType("storage")
|
|
fake_db = _types.ModuleType("storage.studio_db")
|
|
|
|
def _create(**kw):
|
|
b.current_job_id = "job_new" # a new run takes over during the slow create
|
|
|
|
fake_db.create_run = _create
|
|
fake_storage.studio_db = fake_db
|
|
monkeypatch.setitem(sys.modules, "storage", fake_storage)
|
|
monkeypatch.setitem(sys.modules, "storage.studio_db", fake_db)
|
|
|
|
b._ensure_db_run_created()
|
|
|
|
assert b._db_run_created is False, "must not publish the created flag against the new run"
|
|
# The stale claim is left for start_training to reset, not satisfied for the new run.
|
|
assert b._db_create_in_progress is True, "must not clear the claim once the run is not current"
|
|
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# (h) The escalation finalizes the watched run by id (so it is never left running).
|
|
# ----------------------------------------------------------------------------
|
|
|
|
|
|
def test_escalation_finalizes_watched_run_by_id_end_to_end(monkeypatch):
|
|
# Exercise the real _finish_stopped_run against a fake DB. The watched run is finalized
|
|
# by its captured id with its buffered metrics, so a new run that starts in the gap
|
|
# after the backend goes idle can never leave the stopped run recorded running.
|
|
recs = _install_fake_db(monkeypatch)
|
|
b = TrainingBackend()
|
|
b.current_job_id = "job_old"
|
|
b._db_run_created = True
|
|
b._should_stop = True
|
|
b._proc = _FakeProc(alive = False)
|
|
b._progress.is_training = True
|
|
b._progress.step = 42
|
|
b._metric_buffer[:] = [{"step": 41}, {"step": 42}]
|
|
|
|
b._finalize_stopped_after_escalation(target_proc = b._proc, watched_job_id = "job_old")
|
|
|
|
assert [f["id"] for f in recs["finished"]] == ["job_old"], "must finish the captured run by id"
|
|
assert recs["finished"][0]["status"] == "error"
|
|
assert recs["finished"][0]["resume_blocked"] is True
|
|
assert recs["insert_ids"] == ["job_old"], "buffered metrics must land on the captured run"
|
|
assert b._metric_buffer == [], "the captured batch must be drained"
|
|
|
|
|
|
def test_escalation_defers_when_row_cannot_be_created_here(monkeypatch):
|
|
# If the row does not exist and cannot be created here (no db_config, or the pump is
|
|
# mid-create), the escalation must not claim _run_finalized or call _finish_stopped_run,
|
|
# so the pump's create-then-finalize records the run. Parent state still clears.
|
|
b = TrainingBackend()
|
|
called: list = []
|
|
monkeypatch.setattr(b, "_finish_stopped_run", lambda *a, **k: called.append(a))
|
|
|
|
b._proc = _FakeProc(alive = False)
|
|
b.current_job_id = "job_q"
|
|
b._db_run_created = False # row not created yet
|
|
b._db_config = None # ... and cannot be created here
|
|
b._run_finalized = False
|
|
b._progress.is_training = True
|
|
|
|
b._finalize_stopped_after_escalation(target_proc = b._proc, watched_job_id = "job_q")
|
|
|
|
assert called == [], "must not finalize when the row can't be established here"
|
|
assert b._run_finalized is False, "must not claim the finalize the pump still owes"
|
|
assert b._progress.is_training is False, "parent state must still clear so the UI unsticks"
|
|
assert b._proc is None
|
|
|
|
|
|
def test_escalation_creates_row_then_finalizes_when_start_create_failed(monkeypatch):
|
|
# A wedged worker's pump can never finalize and would bail once _proc is dropped, so if
|
|
# the row was never created (start-time create failed) the escalation creates it and
|
|
# finalizes by id itself, recording the terminal state before dropping the handle.
|
|
recs = _install_fake_db(monkeypatch)
|
|
b = TrainingBackend()
|
|
b.current_job_id = "job_s"
|
|
b._db_config = {"model_name": "m"} # so _ensure_db_run_created can create the row
|
|
b._db_run_created = False # start-time create failed
|
|
b._proc = _FakeProc(alive = True) # wedged: still reports alive
|
|
b._should_stop = True
|
|
b._progress.is_training = True
|
|
|
|
b._finalize_stopped_after_escalation(target_proc = b._proc, watched_job_id = "job_s")
|
|
|
|
assert [c["id"] for c in recs["created"]] == ["job_s"], "must create the missing row"
|
|
assert [f["id"] for f in recs["finished"]] == ["job_s"], "must finish the created row by id"
|
|
assert b._proc is None, "handle dropped only after the terminal state is recorded"
|
|
assert b._db_run_created is True
|
|
|
|
|
|
def test_escalation_does_not_drop_a_new_runs_handle(monkeypatch):
|
|
# If a run replaces the worker while the finalize DB write is in flight, the final _proc
|
|
# drop must leave the new run's handle intact (re-guarded on target_proc).
|
|
b = TrainingBackend()
|
|
b.current_job_id = "job_old"
|
|
b._db_run_created = True
|
|
old_proc = _FakeProc(alive = False)
|
|
new_proc = _FakeProc(alive = True)
|
|
b._proc = old_proc
|
|
|
|
def hijack(*a, **k):
|
|
b._proc = new_proc # a new run takes over during the finalize
|
|
|
|
monkeypatch.setattr(b, "_finish_stopped_run", hijack)
|
|
|
|
b._finalize_stopped_after_escalation(target_proc = old_proc, watched_job_id = "job_old")
|
|
|
|
assert b._proc is new_proc, "must not drop the handle a new run installed during finalize"
|
|
|
|
|
|
def _make_finish_raise(monkeypatch, calls):
|
|
fn = sys.modules["storage.studio_db"]
|
|
|
|
def _boom(**kw):
|
|
calls.append(kw)
|
|
raise RuntimeError("database is locked")
|
|
|
|
fn.finish_run = _boom
|
|
|
|
|
|
def test_finish_stopped_run_retries_then_unclaims_on_db_error(monkeypatch):
|
|
# The watchdog is the sole finalizer once _proc is dropped, so a transient DB error is
|
|
# retried a few times; on final failure the finalize is unclaimed (run still current).
|
|
monkeypatch.setitem(_G, "_DB_FINALIZE_RETRY_S", 0.0)
|
|
_install_fake_db(monkeypatch)
|
|
tries: list = []
|
|
_make_finish_raise(monkeypatch, tries)
|
|
b = TrainingBackend()
|
|
b.current_job_id = "job_r"
|
|
b._run_finalized = True # the caller (escalation) already claimed
|
|
|
|
b._finish_stopped_run("job_r", None, [{"step": 1}], 1, None, None, [])
|
|
|
|
assert len(tries) == 3, "a transient DB error must be retried before giving up"
|
|
assert b._run_finalized is False, "a persistent DB error must unclaim the finalize"
|
|
|
|
|
|
def test_finish_stopped_run_error_leaves_new_run_untouched(monkeypatch):
|
|
# If the watched run was superseded, a DB error must not unclaim the new run's finalize.
|
|
monkeypatch.setitem(_G, "_DB_FINALIZE_RETRY_S", 0.0)
|
|
_install_fake_db(monkeypatch)
|
|
_make_finish_raise(monkeypatch, [])
|
|
b = TrainingBackend()
|
|
b.current_job_id = "job_new" # a new run is live
|
|
b._run_finalized = True # the new run's flag
|
|
|
|
b._finish_stopped_run("job_old", None, [{"step": 1}], 1, None, None, [])
|
|
|
|
assert b._run_finalized is True, "must not unclaim the new run's finalize"
|