From f4dfcea8f7a87ee2a43b62a59eb2a2c62b811b6e Mon Sep 17 00:00:00 2001 From: webdevtodayjason Date: Wed, 19 Aug 2026 14:06:29 -0500 Subject: [PATCH] fix(models): retry an engine that dies on the way up during startup replay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rolling 0.5.4 onto spark-3 lost its model. The startup sweep killed the running engine and replay relaunched immediately, while the driver was still releasing the GPU from the container that had just died. The nvidia hook handed the new container no device (Can't initialize NVML, No CUDA runtime is found, Triton '0 active driver(s) found', No module named 'vllm._C') and it exited during weight load. Nothing retried, so the node came back advertising nothing and the model stayed missing until a human re-loaded it. Re-issuing the identical load a few minutes later worked with zero GPU-failure lines. Note this is NOT a failed launch check. The container reached Running, so start_solo() correctly returned True; the engine died minutes later during load. The gap was that nothing watched it afterwards. _ensure_serving() now waits for an engine to bind and, if it never does, waits 30s for the GPU to finish releasing and relaunches once. Applied to both the boot primary and each replayed stacked instance. The retry is deliberately single: a model that fails twice has a real problem and a retry loop would hide it. Diagnosable at all only because of 0.5.4 — dropping --rm left the corpse with readable logs. Before that this was silence. Tests: 6 new. 708 pass, ruff clean. --- FOLLOWUPS.md | 12 +++++ ainode/models/api_routes.py | 54 +++++++++++++++++++++-- tests/test_replay_retry.py | 87 +++++++++++++++++++++++++++++++++++++ 3 files changed, 150 insertions(+), 3 deletions(-) create mode 100644 tests/test_replay_retry.py diff --git a/FOLLOWUPS.md b/FOLLOWUPS.md index db0d8c0..7972fb7 100644 --- a/FOLLOWUPS.md +++ b/FOLLOWUPS.md @@ -6,6 +6,18 @@ - **Next action:** replace the switch (or at minimum its PSU); while at it, identify what owns `192.168.0.100` — still down after recovery, either on a dead port or powered off. Evidence + topology: `ops/runbooks/network-topology.md` outage note. - **Proof of closure:** new/verified switch in place; 2 weeks with no synchronized link-flaps in spark3/4 `journalctl -k`; `.100` owner identified and documented in the runbook. +## [ainode] ~~BUG: startup replay can lose an engine to a GPU-release race~~ — FIXED 2026-08-19 (branch `fable/0.5.5-replay-retry`) +- Fix: `_ensure_serving()` — replay now waits for each engine to bind and, if it died on the way up, waits 30s for the GPU to release and relaunches ONCE (both the boot primary and stacked instances). Single retry on purpose: a model that fails twice has a real problem and a loop would hide it. 6 tests. +- **Correction to the original diagnosis below:** the engine did NOT fail its launch check. It passed (container reached Running) and died minutes later during weight load, so `start_solo()` was right to return True. The gap was that nothing watched it afterwards. Original notes kept for the symptom detail. + +### original note (2026-08-19, during the 0.5.4 rollout) +- **Filed:** 2026-08-19, observed live on spark-3 upgrading 0.5.4-dev → released 0.5.4. +- **Symptom:** restarting the orchestrator sweeps orphan engine containers and immediately replays them from the manifest. The replayed engine came up with NO GPU — `Can't initialize NVML`, `No CUDA runtime is found`, `Triton ... 0 active driver(s) found (expected 1)`, `No module named 'vllm._C'` — and exited(1). Host `nvidia-smi` was healthy the whole time, and a container launched from inside the orchestrator saw the GPU fine. **Re-issuing the identical load a few minutes later worked with zero GPU-failure lines**, so the driver was still releasing from the just-killed engine when the nvidia hook ran for the new one. +- **Impact:** a node comes back from a restart advertising nothing, with the model silently absent until someone re-loads it. Cost ~15 min of Qwen3.8 downtime during the rollout. +- **Why it was diagnosable at all:** the 0.5.4 `--rm` removal left the corpse (`Exited (1)`) with readable logs, and the launch-confirmation change means the failure is no longer reported as success. Before 0.5.4 this would have been pure silence. +- **Next action:** replay should not fire the instant the sweep completes. Either wait for the GPU to report free before relaunching, or retry a failed replay launch once after ~30s (the launch already returns False correctly now, so a retry hook is cheap). Prefer the retry — it also covers other transient launch failures. +- **Proof of closure:** kill a running engine container and restart the orchestrator in a loop; the model returns every time without manual intervention. + ## [ainode] BUG: eject doesn't survive reboot + phantom rows (2 of 5 FIXED 2026-08-15) - **FIXED on `fable/0.5.4-native-engines`:** (a) engine containers no longer launch with `--rm`, so a crashed engine leaves a readable corpse (validated live: the entrypoint-collision crash left its "unrecognized arguments" error intact instead of self-erasing); (b) `start_solo()` now confirms the container reached Running and logs the engine's last output on failure, instead of returning True as soon as the docker CLI forked. - **ALSO FIXED 2026-08-15:** (c) eject now rewrites the instance manifest (it was memory-only, so replay resurrected ejected models on reboot) and clears `config.model` when the primary is ejected; (d) the boot path no longer launches the legacy host-venv engine when vLLM isn't importable — it uses the configured container backend instead of starting a guaranteed "No module named 'vllm'" failure behind an "Engine starting" banner. diff --git a/ainode/models/api_routes.py b/ainode/models/api_routes.py index f40c06d..2956379 100644 --- a/ainode/models/api_routes.py +++ b/ainode/models/api_routes.py @@ -375,6 +375,40 @@ def _probe() -> bool: return False +async def _ensure_serving(app, port: int, relaunch, label: str, timeout: float = 300.0) -> bool: + """Wait for an engine to bind, and if it died on the way up, relaunch ONCE. + + An engine can pass the launch check (its container reached Running) and then + die minutes later during weight load. Observed 2026-08-19 on spark-3: the + startup sweep killed the previous engine and the replacement launched while + the driver was still releasing the GPU, so the nvidia hook handed it no + device — `Can't initialize NVML`, `0 active driver(s) found` — and it exited + during load. Nothing retried, so the node came back advertising nothing and + the model stayed missing until a human re-loaded it. + + ``relaunch`` is a zero-arg callable that re-issues the launch. The retry is + deliberately single: a model that fails twice has a real problem, and a retry + loop would just hide it. + """ + if await _wait_port_ready(port, timeout=timeout): + return True + logger.warning("%s never bound on :%s — relaunching once", label, port) + # Give the GPU time to finish releasing before asking for it again. + await asyncio.sleep(30) + loop = asyncio.get_event_loop() + try: + ok = await loop.run_in_executor(None, relaunch) + except Exception: + logger.exception("%s relaunch raised", label) + return False + if not ok: + logger.error("%s relaunch failed to start", label) + return False + served = await _wait_port_ready(port, timeout=timeout) + logger.info("%s relaunch %s", label, "is serving" if served else "still not serving") + return served + + async def replay_instances_on_startup(app) -> None: """Always-on: after boot, re-load the persisted solo instance set so a node restart brings every previously-loaded model back with no manual step. The @@ -409,7 +443,14 @@ async def replay_instances_on_startup(app) -> None: logger.exception("orphan container sweep failed") # Wait for the boot primary to actually serve before stacking on top of it. - await _wait_port_ready(config.api_port, timeout=300) + # Retry once if it died on the way up — otherwise the node comes back with + # its main model silently missing. + boot_engine = app.get("engine") + if boot_engine is not None and getattr(config, "model", None): + await _ensure_serving(app, config.api_port, boot_engine.start, + f"boot primary {config.model}") + else: + await _wait_port_ready(config.api_port, timeout=300) manager = app.get("instances") have = {i.record.model for i in manager.instances()} if manager is not None else set() @@ -427,9 +468,16 @@ async def replay_instances_on_startup(app) -> None: lambda mm=m, g=e.get("gpu_memory_utilization"), ov={k: e[k] for k in _OVERRIDE_KEYS if k in e}: append_solo_instance(app, mm, g, overrides=ov, persist=False), ) have.add(m) - # Serialize: let this model bind before launching the next one. + # Serialize: let this model bind before launching the next one, and + # retry once if it died on the way up (same GPU-release race). if isinstance(res, dict) and res.get("ok") and res.get("api_port"): - await _wait_port_ready(res["api_port"], timeout=300) + inst = manager.by_model(m) if manager is not None else None + relaunch = (inst.backend.start if inst is not None + else (lambda mm=m, g=e.get("gpu_memory_utilization"), + ov={k: e[k] for k in _OVERRIDE_KEYS if k in e}: + bool(append_solo_instance(app, mm, g, overrides=ov, + persist=False).get("ok")))) + await _ensure_serving(app, res["api_port"], relaunch, f"replay {m}") except Exception: logger.exception("replay load failed for %s", m) diff --git a/tests/test_replay_retry.py b/tests/test_replay_retry.py new file mode 100644 index 0000000..0e8ed3c --- /dev/null +++ b/tests/test_replay_retry.py @@ -0,0 +1,87 @@ +"""Startup replay retries an engine that dies on the way up. + +An engine can pass the launch check (container reached Running) and then die +minutes later during weight load. Observed 2026-08-19 on spark-3: the startup +sweep killed the previous engine, the replacement launched while the driver was +still releasing the GPU, got no device, and exited during load. Nothing retried, +so the node came back advertising nothing. +""" + +from unittest import mock + +import pytest + +from ainode.models import api_routes + + +@pytest.fixture(autouse=True) +def _no_real_sleep(monkeypatch): + # The retry deliberately waits 30s for the GPU to release; don't in tests. + async def _fast(_): + return None + monkeypatch.setattr(api_routes.asyncio, "sleep", _fast) + + +def _ready_sequence(*results): + """_wait_port_ready stub returning the given results in order.""" + seq = list(results) + + async def _stub(port, timeout=300.0): + return seq.pop(0) if seq else False + return _stub + + +@pytest.mark.asyncio +async def test_no_retry_when_the_engine_binds_first_time(monkeypatch): + monkeypatch.setattr(api_routes, "_wait_port_ready", _ready_sequence(True)) + relaunch = mock.Mock(return_value=True) + ok = await api_routes._ensure_serving({}, 8000, relaunch, "x") + assert ok is True + relaunch.assert_not_called(), "a healthy engine must never be relaunched" + + +@pytest.mark.asyncio +async def test_relaunches_once_when_the_engine_never_binds(monkeypatch): + monkeypatch.setattr(api_routes, "_wait_port_ready", _ready_sequence(False, True)) + relaunch = mock.Mock(return_value=True) + ok = await api_routes._ensure_serving({}, 8000, relaunch, "x") + assert ok is True + assert relaunch.call_count == 1 + + +@pytest.mark.asyncio +async def test_gives_up_after_one_retry(monkeypatch): + # A model that fails twice has a real problem; looping would hide it. + monkeypatch.setattr(api_routes, "_wait_port_ready", _ready_sequence(False, False)) + relaunch = mock.Mock(return_value=True) + ok = await api_routes._ensure_serving({}, 8000, relaunch, "x") + assert ok is False + assert relaunch.call_count == 1 + + +@pytest.mark.asyncio +async def test_relaunch_that_fails_to_start_is_reported(monkeypatch): + monkeypatch.setattr(api_routes, "_wait_port_ready", _ready_sequence(False)) + relaunch = mock.Mock(return_value=False) + assert await api_routes._ensure_serving({}, 8000, relaunch, "x") is False + + +@pytest.mark.asyncio +async def test_a_raising_relaunch_does_not_crash_replay(monkeypatch): + monkeypatch.setattr(api_routes, "_wait_port_ready", _ready_sequence(False)) + relaunch = mock.Mock(side_effect=RuntimeError("docker gone")) + assert await api_routes._ensure_serving({}, 8000, relaunch, "x") is False + + +@pytest.mark.asyncio +async def test_retry_waits_before_reasking_for_the_gpu(monkeypatch): + # The delay is the point: the previous engine's device release is what the + # first attempt lost to. + slept = [] + + async def _record(sec): + slept.append(sec) + monkeypatch.setattr(api_routes.asyncio, "sleep", _record) + monkeypatch.setattr(api_routes, "_wait_port_ready", _ready_sequence(False, True)) + await api_routes._ensure_serving({}, 8000, mock.Mock(return_value=True), "x") + assert slept and max(slept) >= 30