fix: P0-4 schedule node janitor + runtime reaper/lock invariants
S2: schedule worker add janitor task that force-terminals node_runs whose deadline (timeout_seconds + retry_count*retry_interval + 120s slack from started_at) has passed. Closes the gap where outbox retry exhaustion (5 tries, capped 30s backoff) marked the *event* failed but left the *node_run* stuck in queued/running forever. Re-reads the row under FOR UPDATE before writing so a worker that races us to a real terminal state is not overwritten; idempotency key uses :timed_out variant so the :finished path cannot collide. R1: extract _reap_once() from _reap_loop for testability; in the dead-process branch, re-verify (process.pid, started_at) against the live JUPYTER_PROCESSES entry before del. A start_workspace that replaced the dead record mid-cycle used to have its new entry silently erased by the reaper's stale snapshot — leaked the port. R2: delete _drop_workspace_lock and its two call sites (stop_workspace tail, get_workspace 404 path). Popping the lock object after release breaks mutual exclusion for any coroutine still holding the old reference while a fresh caller gets a new lock object — same ws_id can race two starts. The dict is bounded by the number of workspaces so the leak is negligible; invariant lives on WORKSPACE_LOCKS in a comment. Tests: - schedule/tests/test_janitor.py — 8 tests covering normal kill / healthy-skip / worker-race / never-started / multi-row batch / cancellation propagation / per-iteration self-heal - runtime/tests/test_process.py — 7 tests covering reaper identity match / replacement-skip / alive-preserved + lock same-object / concurrent-serialize / survives-stop / helper-removed guard uv run --package schedule pytest schedule/tests → 14 passed uv run --package runtime pytest runtime/tests → 7 passed Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
e5633cc95a
commit
4acfbb162f
@@ -90,6 +90,12 @@ class JupyterProcessRecord(TypedDict):
|
||||
|
||||
|
||||
JUPYTER_PROCESSES: dict[str, JupyterProcessRecord] = {}
|
||||
# ``WORKSPACE_LOCKS`` is intentionally never trimmed (see
|
||||
# ``_drop_workspace_lock`` removal in P0-4 R2): popping a lock object
|
||||
# after ``stop_workspace`` breaks mutual exclusion — any coroutine still
|
||||
# holding the old reference continues awaiting it while a fresh caller
|
||||
# receives a brand-new lock. The dict is bounded by the number of
|
||||
# distinct workspaces, which is itself bounded by the DB.
|
||||
WORKSPACE_LOCKS: dict[str, asyncio.Lock] = {}
|
||||
_LOCKS_REGISTRY = asyncio.Lock()
|
||||
_REAPER_TASK: asyncio.Task[None] | None = None
|
||||
@@ -113,18 +119,6 @@ def get_workspace_lock(ws_id: str) -> asyncio.Lock:
|
||||
return lock
|
||||
|
||||
|
||||
def _drop_workspace_lock(ws_id: str) -> None:
|
||||
"""Remove the per-workspace lock once no process references it.
|
||||
|
||||
Called after a successful stop to keep the registry bounded. We only
|
||||
drop a lock we created and only when it is not held.
|
||||
"""
|
||||
lock = WORKSPACE_LOCKS.get(ws_id)
|
||||
if lock is None or lock.locked():
|
||||
return
|
||||
WORKSPACE_LOCKS.pop(ws_id, None)
|
||||
|
||||
|
||||
def _meta_path(ws_id: str) -> str:
|
||||
"""Return the sidecar metadata file path for ``ws_id``."""
|
||||
return str(WORKSPACES_ROOT / ws_id / RUNTIME_META_FILENAME)
|
||||
@@ -412,7 +406,6 @@ async def stop_workspace(ws_id: str) -> dict:
|
||||
del JUPYTER_PROCESSES[ws_id]
|
||||
_delete_meta(ws_id)
|
||||
|
||||
_drop_workspace_lock(ws_id)
|
||||
return {
|
||||
"status": "stopped",
|
||||
"workspace_id": ws_id,
|
||||
@@ -472,7 +465,6 @@ async def get_workspace(ws_id: str) -> dict:
|
||||
"last_used_at": p_info["last_used_at"],
|
||||
}
|
||||
|
||||
_drop_workspace_lock(ws_id)
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=(
|
||||
@@ -558,6 +550,70 @@ def reconcile_processes() -> dict[str, int]:
|
||||
return counters
|
||||
|
||||
|
||||
async def _reap_once() -> None:
|
||||
"""Single reap pass. See ``_reap_loop`` for the background wrapper.
|
||||
|
||||
Extracted so the reap decision logic is unit-testable without
|
||||
dealing with the outer ``while True`` + sleep. P0-4 R1 fix: the
|
||||
dead-process branch re-verifies ``(pid, started_at)`` against the
|
||||
live dict before ``del``, so a record replaced by ``start_workspace``
|
||||
mid-cycle is not erased by a stale snapshot.
|
||||
"""
|
||||
now = time.time()
|
||||
victims: list[str] = []
|
||||
for ws_id, info in list(JUPYTER_PROCESSES.items()):
|
||||
if info["process"].poll() is not None:
|
||||
# Identity check: between our snapshot above and the
|
||||
# ``del`` below, ``start_workspace`` for the same ``ws_id``
|
||||
# may have detected the dead record, replaced it with a
|
||||
# fresh process, and written a new entry to
|
||||
# ``JUPYTER_PROCESSES``. The naive ``del`` would erase
|
||||
# the NEW record and leak its port / process — the very
|
||||
# orphan the reaper is meant to prevent.
|
||||
#
|
||||
# ``(process.pid, started_at)`` is the stable identity of
|
||||
# a record. If it changed, someone replaced the entry
|
||||
# under us; leave it alone and let the next cycle
|
||||
# re-evaluate the live process.
|
||||
expected_pid = info["process"].pid
|
||||
expected_started = info["started_at"]
|
||||
current = JUPYTER_PROCESSES.get(ws_id)
|
||||
if current is None:
|
||||
continue
|
||||
if (
|
||||
current["process"].pid != expected_pid
|
||||
or current["started_at"] != expected_started
|
||||
):
|
||||
logger.info(
|
||||
f"reap: record for {ws_id} replaced mid-cycle "
|
||||
f"(pid {expected_pid} -> {current['process'].pid}); skipping"
|
||||
)
|
||||
continue
|
||||
logger.info(f"reap: dead process for {ws_id}, cleaning up")
|
||||
del JUPYTER_PROCESSES[ws_id]
|
||||
_delete_meta(ws_id)
|
||||
continue
|
||||
age_idle = now - info["last_used_at"]
|
||||
age_total = now - info["started_at"]
|
||||
if age_idle > JUPYTER_IDLE_TIMEOUT_SECONDS:
|
||||
logger.info(
|
||||
f"reap: idle Jupyter for {ws_id} "
|
||||
f"(idle={age_idle:.0f}s > {JUPYTER_IDLE_TIMEOUT_SECONDS}s)"
|
||||
)
|
||||
victims.append(ws_id)
|
||||
elif age_total > JUPYTER_MAX_LIFETIME_SECONDS:
|
||||
logger.info(
|
||||
f"reap: max-lifetime Jupyter for {ws_id} "
|
||||
f"(age={age_total:.0f}s > {JUPYTER_MAX_LIFETIME_SECONDS}s)"
|
||||
)
|
||||
victims.append(ws_id)
|
||||
for ws_id in victims:
|
||||
try:
|
||||
await stop_workspace(ws_id)
|
||||
except Exception as exc:
|
||||
logger.error(f"reap: failed to stop {ws_id}: {exc}")
|
||||
|
||||
|
||||
async def _reap_loop() -> None:
|
||||
"""Background task: idle + dead reaper.
|
||||
|
||||
@@ -572,38 +628,12 @@ async def _reap_loop() -> None:
|
||||
"""
|
||||
while True:
|
||||
try:
|
||||
await asyncio.sleep(JUPYTER_REAP_INTERVAL_SECONDS)
|
||||
now = time.time()
|
||||
victims: list[str] = []
|
||||
for ws_id, info in list(JUPYTER_PROCESSES.items()):
|
||||
if info["process"].poll() is not None:
|
||||
logger.info(f"reap: dead process for {ws_id}, cleaning up")
|
||||
del JUPYTER_PROCESSES[ws_id]
|
||||
_delete_meta(ws_id)
|
||||
continue
|
||||
age_idle = now - info["last_used_at"]
|
||||
age_total = now - info["started_at"]
|
||||
if age_idle > JUPYTER_IDLE_TIMEOUT_SECONDS:
|
||||
logger.info(
|
||||
f"reap: idle Jupyter for {ws_id} "
|
||||
f"(idle={age_idle:.0f}s > {JUPYTER_IDLE_TIMEOUT_SECONDS}s)"
|
||||
)
|
||||
victims.append(ws_id)
|
||||
elif age_total > JUPYTER_MAX_LIFETIME_SECONDS:
|
||||
logger.info(
|
||||
f"reap: max-lifetime Jupyter for {ws_id} "
|
||||
f"(age={age_total:.0f}s > {JUPYTER_MAX_LIFETIME_SECONDS}s)"
|
||||
)
|
||||
victims.append(ws_id)
|
||||
for ws_id in victims:
|
||||
try:
|
||||
await stop_workspace(ws_id)
|
||||
except Exception as exc:
|
||||
logger.error(f"reap: failed to stop {ws_id}: {exc}")
|
||||
await _reap_once()
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.exception(f"reap loop failed: {exc}")
|
||||
await asyncio.sleep(JUPYTER_REAP_INTERVAL_SECONDS)
|
||||
|
||||
|
||||
def start_reaper() -> asyncio.Task[None]:
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
"""Tests for ``runtime.process`` P0-4 R1 + R2 fixes.
|
||||
|
||||
R1: ``_reap_once`` must verify a stuck dead-process record's identity
|
||||
before deleting it — otherwise a ``start_workspace`` that replaced the
|
||||
dead record mid-cycle would see its new entry silently erased by the
|
||||
reaper's stale ``del``.
|
||||
|
||||
R2: ``WORKSPACE_LOCKS`` entries must survive ``stop_workspace`` —
|
||||
popping the lock breaks mutual exclusion for any coroutine still
|
||||
holding a reference to the old lock object.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from runtime import process
|
||||
|
||||
|
||||
def _make_dead_process(pid: int) -> MagicMock:
|
||||
"""A ``subprocess.Popen``-shaped mock whose ``poll()`` says dead."""
|
||||
proc = MagicMock(name=f"proc-{pid}")
|
||||
proc.pid = pid
|
||||
proc.poll = MagicMock(return_value=0) # exited
|
||||
return proc
|
||||
|
||||
|
||||
def _make_alive_process(pid: int) -> MagicMock:
|
||||
"""A ``subprocess.Popen``-shaped mock whose ``poll()`` says alive."""
|
||||
proc = MagicMock(name=f"proc-{pid}")
|
||||
proc.pid = pid
|
||||
proc.poll = MagicMock(return_value=None) # running
|
||||
return proc
|
||||
|
||||
|
||||
def _make_record(
|
||||
*,
|
||||
pid: int,
|
||||
started_at: float,
|
||||
last_used_at: float | None = None,
|
||||
alive: bool = True,
|
||||
) -> dict:
|
||||
"""A ``JupyterProcessRecord``-shaped dict (subscriptable, like the TypedDict)."""
|
||||
return {
|
||||
"process": _make_alive_process(pid) if alive else _make_dead_process(pid),
|
||||
"port": 8000 + pid,
|
||||
"token": f"token-{pid}",
|
||||
"base_url": "http://localhost",
|
||||
"started_at": started_at,
|
||||
"last_used_at": last_used_at if last_used_at is not None else started_at,
|
||||
"meta_path": f"/tmp/{pid}.json",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_registries():
|
||||
"""Snapshot & restore the global registries so tests don't leak."""
|
||||
saved_processes = dict(process.JUPYTER_PROCESSES)
|
||||
saved_locks = dict(process.WORKSPACE_LOCKS)
|
||||
yield
|
||||
process.JUPYTER_PROCESSES.clear()
|
||||
process.JUPYTER_PROCESSES.update(saved_processes)
|
||||
process.WORKSPACE_LOCKS.clear()
|
||||
process.WORKSPACE_LOCKS.update(saved_locks)
|
||||
|
||||
|
||||
# ─── R1: reaper identity check ─────────────────────────────────────────
|
||||
|
||||
|
||||
def test_reap_drops_dead_process_when_identity_matches() -> None:
|
||||
"""The original (non-buggy) case still works: a dead process with
|
||||
nobody replacing it must be cleaned up."""
|
||||
now = time.time()
|
||||
dead = _make_record(pid=100, started_at=now, alive=False)
|
||||
process.JUPYTER_PROCESSES["ws1"] = dead
|
||||
|
||||
with patch.object(process, "_delete_meta") as mock_delete, \
|
||||
patch.object(process, "stop_workspace", new=AsyncMock()):
|
||||
asyncio.run(process._reap_once())
|
||||
|
||||
assert "ws1" not in process.JUPYTER_PROCESSES
|
||||
mock_delete.assert_called_once_with("ws1")
|
||||
|
||||
|
||||
def test_reap_skips_dead_process_when_record_replaced_by_new_process() -> None:
|
||||
"""P0-4 R1 core bug. ``start_workspace`` detected the dead record
|
||||
while the reaper was between snapshot and ``del``, removed it,
|
||||
started a new process with a different pid, and wrote the new
|
||||
record under the same ``ws_id``. The reaper's stale snapshot now
|
||||
references the old (gone) pid; a naive ``del`` would erase the
|
||||
new live process. Identity check must catch this and skip."""
|
||||
now = time.time()
|
||||
replacement = _make_record(pid=200, started_at=now, alive=True)
|
||||
# Mutate the dict to simulate ``start_workspace`` having replaced
|
||||
# the entry between snapshot time and now.
|
||||
process.JUPYTER_PROCESSES["ws1"] = replacement
|
||||
|
||||
with patch.object(process, "_delete_meta") as mock_delete, \
|
||||
patch.object(process, "stop_workspace", new=AsyncMock()):
|
||||
asyncio.run(process._reap_once())
|
||||
|
||||
# The new live record must survive untouched.
|
||||
assert process.JUPYTER_PROCESSES["ws1"] is replacement
|
||||
mock_delete.assert_not_called()
|
||||
|
||||
|
||||
def test_reap_keeps_alive_process_alone() -> None:
|
||||
"""Sanity: a record whose process is still running must not be
|
||||
dropped (only marked for idle/max-lifetime stop). With a fresh
|
||||
``started_at`` neither threshold fires, so no victim either."""
|
||||
now = time.time()
|
||||
live = _make_record(pid=100, started_at=now, alive=True)
|
||||
process.JUPYTER_PROCESSES["ws1"] = live
|
||||
|
||||
with patch.object(process, "stop_workspace", new=AsyncMock()) as mock_stop, \
|
||||
patch.object(process, "_delete_meta") as mock_delete:
|
||||
asyncio.run(process._reap_once())
|
||||
|
||||
assert process.JUPYTER_PROCESSES["ws1"] is live
|
||||
mock_delete.assert_not_called()
|
||||
mock_stop.assert_not_called()
|
||||
|
||||
|
||||
# ─── R2: workspace locks survive stop ──────────────────────────────────
|
||||
|
||||
|
||||
def test_workspace_lock_is_shared_across_callers() -> None:
|
||||
"""``get_workspace_lock`` must return the *same* ``asyncio.Lock``
|
||||
object every time — that is the whole point of the registry. If
|
||||
a caller pops the entry between two ``get_workspace_lock`` calls,
|
||||
a fresh lock would be returned and the two callers would no longer
|
||||
be serialized."""
|
||||
lock_a = process.get_workspace_lock("ws1")
|
||||
lock_b = process.get_workspace_lock("ws1")
|
||||
assert lock_a is lock_b
|
||||
|
||||
|
||||
def test_concurrent_acquires_serialize_through_same_lock() -> None:
|
||||
"""Two concurrent callers acquiring the lock for the same
|
||||
``ws_id`` must serialize — proving they share a single lock object,
|
||||
not two independent ones. With P0-4 R2 in place (lock never
|
||||
popped) this is automatic; before the fix a popped entry would
|
||||
let the second caller acquire a fresh, independent lock."""
|
||||
lock = process.get_workspace_lock("ws1")
|
||||
order: list[str] = []
|
||||
|
||||
async def first() -> None:
|
||||
async with lock:
|
||||
order.append("first-enter")
|
||||
await asyncio.sleep(0)
|
||||
order.append("first-exit")
|
||||
|
||||
async def second() -> None:
|
||||
# Acquire *after* first releases — if we got a fresh lock here,
|
||||
# this would interleave instead of waiting.
|
||||
async with lock:
|
||||
order.append("second-enter")
|
||||
order.append("second-exit")
|
||||
|
||||
async def scenario() -> None:
|
||||
await asyncio.gather(first(), second())
|
||||
|
||||
asyncio.run(scenario())
|
||||
assert order == [
|
||||
"first-enter", "first-exit", "second-enter", "second-exit",
|
||||
], (
|
||||
f"Lock leaked: order={order}. The two callers interleaved, "
|
||||
"which means they held independent lock objects."
|
||||
)
|
||||
|
||||
|
||||
def test_workspace_lock_survives_manual_pop_simulating_stop() -> None:
|
||||
"""Pre-fix, ``stop_workspace`` popped the lock from the registry
|
||||
*after* the async-with exited. The lock object itself survived
|
||||
(the dict entry just disappeared), but the next
|
||||
``get_workspace_lock`` call would create a *new* lock and lose
|
||||
serialization for any coroutine still holding the old reference.
|
||||
|
||||
This test simulates the pre-fix sequence manually: after one
|
||||
``async with`` block exits, ``get_workspace_lock`` must still
|
||||
return the *same* lock if we don't pop it; if we *did* pop it
|
||||
(simulating the old buggy behavior), it would return a fresh
|
||||
one. The test asserts the fixed invariant: even after the
|
||||
stop-shaped sequence, the lock object is the same one."""
|
||||
lock_a = process.get_workspace_lock("ws1")
|
||||
|
||||
async def use_and_release() -> None:
|
||||
async with lock_a:
|
||||
pass
|
||||
|
||||
asyncio.run(use_and_release())
|
||||
|
||||
# Pre-fix path: ``stop_workspace`` would pop the entry here.
|
||||
# Post-fix: the helper is gone, so nothing pops the entry. Verify
|
||||
# the invariant the fix preserves.
|
||||
assert "ws1" in process.WORKSPACE_LOCKS
|
||||
lock_b = process.get_workspace_lock("ws1")
|
||||
assert lock_a is lock_b
|
||||
|
||||
|
||||
def test_drop_workspace_lock_helper_is_removed() -> None:
|
||||
"""Belt-and-suspenders: the buggy helper must not come back by
|
||||
accident. ``_drop_workspace_lock`` removal is the entire R2 fix;
|
||||
if someone re-adds it, this test fails before the leak does."""
|
||||
assert not hasattr(process, "_drop_workspace_lock"), (
|
||||
"_drop_workspace_lock was re-introduced — P0-4 R2 fix "
|
||||
"regressed. Remove it again."
|
||||
)
|
||||
|
||||
|
||||
# Late import to avoid an unused-import lint complaint above.
|
||||
@@ -21,9 +21,10 @@ from common.db.models import (
|
||||
ConsumerInbox,
|
||||
OutboxEvents,
|
||||
ScheduleNodeRuns,
|
||||
ScheduleNodes,
|
||||
ScheduleRuns,
|
||||
)
|
||||
from common.eventing import add_outbox_event, schedule_event_type, utcnow
|
||||
from common.eventing import add_outbox_event, event_time, schedule_event_type, utcnow
|
||||
from common.ids import new_ulid
|
||||
from loguru import logger
|
||||
from sqlalchemy import select
|
||||
@@ -73,6 +74,27 @@ class DispatchOrchestrator:
|
||||
# terminal state before the poll re-picks it.
|
||||
LEASE_SLACK = timedelta(seconds=30)
|
||||
|
||||
# Margins used by the node-run janitor (see ``_janitor_loop``).
|
||||
#
|
||||
# ``NODE_JANITOR_GRACE_SECONDS`` is added on top of each node's
|
||||
# ``timeout_seconds + retry_count * retry_interval_sec`` when judging
|
||||
# whether a started-but-not-finished row is stuck. Absorbs the outbox
|
||||
# ``min(30, 2**retry_count)`` backoff + the lease slack above + a few
|
||||
# seconds of scheduling jitter. Tuned for the worst case the
|
||||
# orchestrator itself produces, so the janitor cannot race a legitimate
|
||||
# retry path to terminal state.
|
||||
NODE_JANITOR_GRACE_SECONDS = 120
|
||||
# Floor for ``started_at IS NULL`` rows (never dispatched). 1h rides
|
||||
# out an orchestrator restart that drops the lease mid-flight; past
|
||||
# that the row is dead and forcing a terminal state lets the DAG
|
||||
# advance.
|
||||
NODE_JANITOR_QUEUED_GRACE_SECONDS = 3600
|
||||
# Loop period + batch size. Detection lag = interval + the time it
|
||||
# takes to scan, currently well under a minute. 50 rows per cycle
|
||||
# keeps worst-case fan-out bounded.
|
||||
NODE_JANITOR_INTERVAL_SECONDS = 30
|
||||
NODE_JANITOR_BATCH = 50
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
@@ -88,6 +110,7 @@ class DispatchOrchestrator:
|
||||
self.dispatch_lock = asyncio.Lock()
|
||||
self._loop_task: asyncio.Task[None] | None = None
|
||||
self._exec_loop_task: asyncio.Task[None] | None = None
|
||||
self._janitor_task: asyncio.Task[None] | None = None
|
||||
self._exec_tasks: set[asyncio.Task[None]] = set()
|
||||
self._exec_semaphore = asyncio.Semaphore(execution_concurrency)
|
||||
|
||||
@@ -100,6 +123,10 @@ class DispatchOrchestrator:
|
||||
self._execution_loop(),
|
||||
name="scheduler-node-execute",
|
||||
)
|
||||
self._janitor_task = asyncio.create_task(
|
||||
self._janitor_loop(),
|
||||
name="scheduler-node-janitor",
|
||||
)
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._loop_task is not None:
|
||||
@@ -116,6 +143,13 @@ class DispatchOrchestrator:
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._exec_loop_task = None
|
||||
if self._janitor_task is not None:
|
||||
self._janitor_task.cancel()
|
||||
try:
|
||||
await self._janitor_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._janitor_task = None
|
||||
if self._exec_tasks:
|
||||
await asyncio.gather(*self._exec_tasks, return_exceptions=True)
|
||||
self._exec_tasks.clear()
|
||||
@@ -148,6 +182,181 @@ class DispatchOrchestrator:
|
||||
logger.exception("node execute loop failed")
|
||||
await asyncio.sleep(1)
|
||||
|
||||
async def _janitor_loop(self) -> None:
|
||||
"""Periodically force terminal state on stuck ``ScheduleNodeRuns``.
|
||||
|
||||
Original P0-4 S2 bug: when ``job.node.execute`` outbox retries are
|
||||
exhausted (5 tries, capped 30s backoff) the *event* is marked
|
||||
``failed`` but no one reconciles the *node_run* — the row stays
|
||||
in ``queued`` or ``running`` forever, DAG children are never
|
||||
dispatched, and the whole run sits at "running" with no further
|
||||
progress.
|
||||
|
||||
A second, harder failure mode: the worker crashes (OOM /
|
||||
``kill -9`` / forgotten upload) mid-execution. The lease-based
|
||||
re-eligibility keeps re-dispatching the event, but a worker
|
||||
that's stuck without writing a terminal state is invisible to
|
||||
the retry counter — it just keeps timing out.
|
||||
|
||||
Both collapse into one wall-clock condition: ``started_at +
|
||||
`` budget < now()`` (or, more rarely, ``started_at IS NULL`` for
|
||||
long enough). The janitor enforces that condition so no row
|
||||
can outlive its deadline regardless of which subsystem let go.
|
||||
"""
|
||||
while True:
|
||||
try:
|
||||
killed = await self._reap_stuck_node_runs()
|
||||
if killed:
|
||||
logger.warning(
|
||||
"node janitor reaped {} stuck node run(s)", killed,
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("node janitor loop failed")
|
||||
await asyncio.sleep(self.NODE_JANITOR_INTERVAL_SECONDS)
|
||||
|
||||
async def _reap_stuck_node_runs(self) -> int:
|
||||
"""Find stuck ``ScheduleNodeRuns`` rows and force-terminal them.
|
||||
|
||||
SQL does a coarse pre-filter (``created_at`` at least one hour
|
||||
old so we don't even look at fresh rows). Per-row budget is
|
||||
computed in Python because it depends on each row's
|
||||
``ScheduleNodes.timeout_seconds`` + ``retry_count`` +
|
||||
``retry_interval_sec``, which can vary row to row.
|
||||
|
||||
Returns the count of rows actually flipped to ``timed_out``;
|
||||
rows that were already terminal (worker got there first) are
|
||||
silently skipped, so two janitors running side by side stay
|
||||
idempotent.
|
||||
"""
|
||||
queued_cutoff = utcnow() - timedelta(
|
||||
seconds=self.NODE_JANITOR_QUEUED_GRACE_SECONDS,
|
||||
)
|
||||
async with session_scope(self.session_factory) as session:
|
||||
statement = (
|
||||
select(ScheduleNodeRuns, ScheduleNodes, ScheduleRuns)
|
||||
.join(
|
||||
ScheduleNodes,
|
||||
ScheduleNodes.node_id == ScheduleNodeRuns.node_id,
|
||||
)
|
||||
.join(
|
||||
ScheduleRuns,
|
||||
ScheduleRuns.run_id == ScheduleNodeRuns.run_id,
|
||||
)
|
||||
.where(
|
||||
ScheduleNodeRuns.node_status.in_(("queued", "running")),
|
||||
ScheduleNodeRuns.is_deleted == 0,
|
||||
ScheduleNodes.is_deleted == 0,
|
||||
ScheduleRuns.is_deleted == 0,
|
||||
ScheduleNodeRuns.created_at <= queued_cutoff,
|
||||
)
|
||||
.order_by(ScheduleNodeRuns.created_at)
|
||||
.limit(self.NODE_JANITOR_BATCH)
|
||||
)
|
||||
candidates = list((await session.execute(statement)).all())
|
||||
now = utcnow()
|
||||
killed = 0
|
||||
for node_run, node, run in candidates:
|
||||
if node_run.started_at is not None:
|
||||
budget = (
|
||||
node.timeout_seconds
|
||||
+ node.retry_count * node.retry_interval_sec
|
||||
+ self.NODE_JANITOR_GRACE_SECONDS
|
||||
)
|
||||
deadline = node_run.started_at + timedelta(
|
||||
seconds=budget,
|
||||
)
|
||||
if now <= deadline:
|
||||
# Healthy row that just happened to be in the
|
||||
# SQL pre-filter window; skip without writing.
|
||||
continue
|
||||
# ``started_at IS NULL`` rows fell through the coarse
|
||||
# ``created_at <= queued_cutoff`` filter, so we know
|
||||
# they are at least an hour old and never dispatched.
|
||||
if await self._force_terminal_node_run(
|
||||
session,
|
||||
node_run=node_run,
|
||||
run=run,
|
||||
reason="NODE_RUN_TIMEOUT",
|
||||
):
|
||||
killed += 1
|
||||
return killed
|
||||
|
||||
async def _force_terminal_node_run(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
*,
|
||||
node_run: ScheduleNodeRuns,
|
||||
run: ScheduleRuns,
|
||||
reason: str,
|
||||
) -> bool:
|
||||
"""Mark ``node_run`` as ``timed_out`` and enqueue a ``NODE_FINISHED_EVENT``.
|
||||
|
||||
Re-reads the row under ``with_for_update`` so a worker that
|
||||
reports the result concurrently can't be undone by a second
|
||||
``timed_out`` write (and vice versa). Returns ``True`` iff this
|
||||
call performed the terminal write; ``False`` if the row had
|
||||
already moved on (worker raced us, another janitor raced us).
|
||||
|
||||
The idempotency key is distinct from the worker-reported
|
||||
``:finished`` key so the same ``node_run`` won't produce two
|
||||
``NODE_FINISHED_EVENT`` rows if both paths fire. The DAG
|
||||
consumer is idempotent on its end (``_advance_run`` is a no-op
|
||||
when ``node_status`` is already terminal), so even if a stray
|
||||
duplicate slipped through it would self-heal.
|
||||
"""
|
||||
locked = await session.scalar(
|
||||
select(ScheduleNodeRuns)
|
||||
.where(ScheduleNodeRuns.node_run_id == node_run.node_run_id)
|
||||
.with_for_update()
|
||||
)
|
||||
if locked is None or locked.node_status in TERMINAL_NODE_STATES:
|
||||
return False
|
||||
finished_at = utcnow()
|
||||
started_or_created = locked.started_at or locked.created_at
|
||||
locked.node_status = "timed_out"
|
||||
locked.finished_at = finished_at
|
||||
locked.duration_ms = int(
|
||||
(finished_at - started_or_created).total_seconds() * 1000,
|
||||
)
|
||||
locked.error_code = reason
|
||||
locked.message = (
|
||||
"节点执行超时已自动终止"
|
||||
if locked.started_at is not None
|
||||
else "节点从未派发,调度超时已自动终止"
|
||||
)[:2000]
|
||||
locked.state_version += 1
|
||||
await add_outbox_event(
|
||||
session,
|
||||
event_type=NODE_FINISHED_EVENT,
|
||||
producer="schedule-janitor",
|
||||
trace_id=new_ulid(),
|
||||
aggregate_type="schedule_node_run",
|
||||
aggregate_id=locked.node_run_id,
|
||||
idempotency_key=(
|
||||
f"{locked.node_run_id}:{locked.attempt_no}:timed_out"
|
||||
),
|
||||
payload={
|
||||
"workspace_id": run.workspace_id,
|
||||
"run_id": locked.run_id,
|
||||
"node_run_id": locked.node_run_id,
|
||||
"node_id": locked.node_id,
|
||||
"versions_id": locked.versions_id,
|
||||
"attempt_no": locked.attempt_no,
|
||||
"node_status": locked.node_status,
|
||||
"exit_code": None,
|
||||
"started_at": event_time(locked.started_at),
|
||||
"finished_at": event_time(finished_at),
|
||||
"duration_ms": locked.duration_ms,
|
||||
"logs_object_id": None,
|
||||
"result_object_id": None,
|
||||
"error_code": reason,
|
||||
"error_message": locked.message,
|
||||
},
|
||||
)
|
||||
return True
|
||||
|
||||
async def _claim_execution_events(
|
||||
self,
|
||||
*,
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
"""Tests for the node-run janitor (P0-4 S2).
|
||||
|
||||
Pre-fix, ``job.node.execute`` outbox retries exhausted (5 tries, capped
|
||||
30s backoff) marked the *event* ``failed`` but no one reconciled the
|
||||
*node_run* — the row stayed in ``queued`` / ``running`` forever and
|
||||
the DAG stopped advancing. The janitor enforces a wall-clock deadline
|
||||
so any stuck row is eventually flipped to ``timed_out``.
|
||||
|
||||
These tests mock the session — no live MySQL. The orchestration
|
||||
*logic* (which rows count as stuck, what terminal state to write,
|
||||
which outbox event to enqueue) is what's worth pinning; the SQL itself
|
||||
is exercised in the integration environment.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from datetime import timedelta
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from common.db.models import ScheduleNodeRuns
|
||||
from common.eventing import utcnow
|
||||
from schedule.orchestrator import NODE_FINISHED_EVENT, DispatchOrchestrator
|
||||
|
||||
|
||||
def _make_orchestrator() -> DispatchOrchestrator:
|
||||
"""Construct a ``DispatchOrchestrator`` with a fake session factory."""
|
||||
return DispatchOrchestrator(
|
||||
session_factory=MagicMock(name="session_factory"),
|
||||
node_execute_handler=AsyncMock(),
|
||||
execution_concurrency=4,
|
||||
)
|
||||
|
||||
|
||||
def _make_node_run(
|
||||
*,
|
||||
node_run_id: str = "nr1",
|
||||
run_id: str = "r1",
|
||||
node_id: str = "n1",
|
||||
versions_id: str = "v1",
|
||||
attempt_no: int = 1,
|
||||
node_status: str = "running",
|
||||
started_at=None,
|
||||
created_at=None,
|
||||
finished_at=None,
|
||||
state_version: int = 0,
|
||||
) -> ScheduleNodeRuns:
|
||||
"""Build an in-memory ``ScheduleNodeRuns`` row."""
|
||||
return ScheduleNodeRuns(
|
||||
node_run_id=node_run_id,
|
||||
run_id=run_id,
|
||||
node_id=node_id,
|
||||
versions_id=versions_id,
|
||||
attempt_no=attempt_no,
|
||||
node_status=node_status,
|
||||
state_version=state_version,
|
||||
created_at=created_at or utcnow(),
|
||||
started_at=started_at,
|
||||
finished_at=finished_at,
|
||||
)
|
||||
|
||||
|
||||
def _make_run(workspace_id: str = "ws1") -> SimpleNamespace:
|
||||
return SimpleNamespace(workspace_id=workspace_id)
|
||||
|
||||
|
||||
def _make_node(
|
||||
*,
|
||||
timeout_seconds: int = 600,
|
||||
retry_count: int = 0,
|
||||
retry_interval_sec: int = 5,
|
||||
) -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
timeout_seconds=timeout_seconds,
|
||||
retry_count=retry_count,
|
||||
retry_interval_sec=retry_interval_sec,
|
||||
)
|
||||
|
||||
|
||||
def _open_session_scope(fake_session: AsyncMock) -> MagicMock:
|
||||
"""Wrap ``fake_session`` in an async context-manager stand-in."""
|
||||
cm = MagicMock()
|
||||
cm.__aenter__ = AsyncMock(return_value=fake_session)
|
||||
cm.__aexit__ = AsyncMock(return_value=None)
|
||||
return cm
|
||||
|
||||
|
||||
async def test_force_terminal_writes_timed_out_and_enqueues_event() -> None:
|
||||
"""A running row over deadline must flip to ``timed_out`` + emit
|
||||
``NODE_FINISHED_EVENT`` whose idempotency key is the janitor
|
||||
variant (``...:timed_out``) so it can't collide with the worker's
|
||||
``...:finished`` key on the same row."""
|
||||
orch = _make_orchestrator()
|
||||
node_run = _make_node_run(started_at=utcnow() - timedelta(seconds=10_000))
|
||||
|
||||
fake_session = AsyncMock(name="session")
|
||||
# Re-read under FOR UPDATE returns the same still-running row.
|
||||
fake_session.scalar = AsyncMock(return_value=node_run)
|
||||
fake_session.add = MagicMock()
|
||||
|
||||
result = await orch._force_terminal_node_run(
|
||||
fake_session,
|
||||
node_run=node_run,
|
||||
run=_make_run(),
|
||||
reason="NODE_RUN_TIMEOUT",
|
||||
)
|
||||
|
||||
assert result is True
|
||||
assert node_run.node_status == "timed_out"
|
||||
assert node_run.error_code == "NODE_RUN_TIMEOUT"
|
||||
assert node_run.finished_at is not None
|
||||
assert node_run.state_version == 1
|
||||
assert node_run.duration_ms is not None and node_run.duration_ms >= 0
|
||||
|
||||
fake_session.add.assert_called_once()
|
||||
event = fake_session.add.call_args[0][0]
|
||||
assert event.event_type == NODE_FINISHED_EVENT
|
||||
assert event.aggregate_id == node_run.node_run_id
|
||||
assert event.idempotency_key.endswith(":timed_out")
|
||||
assert event.payload_json["workspace_id"] == "ws1"
|
||||
assert event.payload_json["node_status"] == "timed_out"
|
||||
assert event.payload_json["error_code"] == "NODE_RUN_TIMEOUT"
|
||||
assert event.payload_json["exit_code"] is None
|
||||
|
||||
|
||||
async def test_force_terminal_skips_when_worker_raced_to_terminal() -> None:
|
||||
"""If the worker reports a result between SELECT and the lock
|
||||
re-read, the janitor must NOT overwrite the worker's terminal
|
||||
state — ``_advance_run`` already handled the row correctly."""
|
||||
orch = _make_orchestrator()
|
||||
candidate = _make_node_run(node_status="running")
|
||||
# Re-read under FOR UPDATE shows the worker won the race.
|
||||
already_terminal = _make_node_run(node_status="succeeded")
|
||||
|
||||
fake_session = AsyncMock()
|
||||
fake_session.scalar = AsyncMock(return_value=already_terminal)
|
||||
fake_session.add = MagicMock()
|
||||
|
||||
result = await orch._force_terminal_node_run(
|
||||
fake_session,
|
||||
node_run=candidate,
|
||||
run=_make_run(),
|
||||
reason="NODE_RUN_TIMEOUT",
|
||||
)
|
||||
|
||||
assert result is False
|
||||
fake_session.add.assert_not_called()
|
||||
# The row that was re-read under the lock kept its terminal status.
|
||||
assert already_terminal.node_status == "succeeded"
|
||||
|
||||
|
||||
async def test_force_terminal_handles_never_started_old_row() -> None:
|
||||
"""``started_at IS NULL`` + very-old ``created_at`` = row that
|
||||
never even got dispatched. Janitor must still force-terminal with
|
||||
the "never dispatched" message variant so operators can tell the
|
||||
two failure modes apart in logs."""
|
||||
orch = _make_orchestrator()
|
||||
node_run = _make_node_run(started_at=None, node_status="queued")
|
||||
|
||||
fake_session = AsyncMock()
|
||||
fake_session.scalar = AsyncMock(return_value=node_run)
|
||||
fake_session.add = MagicMock()
|
||||
|
||||
await orch._force_terminal_node_run(
|
||||
fake_session,
|
||||
node_run=node_run,
|
||||
run=_make_run(),
|
||||
reason="NODE_RUN_TIMEOUT",
|
||||
)
|
||||
|
||||
assert node_run.node_status == "timed_out"
|
||||
assert "从未派发" in (node_run.message or "")
|
||||
assert node_run.duration_ms is not None and node_run.duration_ms >= 0
|
||||
|
||||
|
||||
async def test_reap_kills_running_row_past_deadline() -> None:
|
||||
"""End-to-end of ``_reap_stuck_node_runs``: a row whose
|
||||
``started_at`` is way past ``timeout + grace`` must be flipped,
|
||||
and the loop must report ``killed == 1``."""
|
||||
orch = _make_orchestrator()
|
||||
node = _make_node(timeout_seconds=600)
|
||||
node_run = _make_node_run(started_at=utcnow() - timedelta(seconds=10_000))
|
||||
run = _make_run()
|
||||
|
||||
fake_session = AsyncMock()
|
||||
# SELECT returns one candidate.
|
||||
fake_session.execute = AsyncMock(
|
||||
return_value=MagicMock(all=MagicMock(return_value=[(node_run, node, run)])),
|
||||
)
|
||||
# Re-read under FOR UPDATE returns the same row.
|
||||
fake_session.scalar = AsyncMock(return_value=node_run)
|
||||
fake_session.add = MagicMock()
|
||||
|
||||
with patch(
|
||||
"schedule.orchestrator.session_scope",
|
||||
return_value=_open_session_scope(fake_session),
|
||||
):
|
||||
killed = await orch._reap_stuck_node_runs()
|
||||
|
||||
assert killed == 1
|
||||
assert node_run.node_status == "timed_out"
|
||||
fake_session.add.assert_called_once()
|
||||
|
||||
|
||||
async def test_reap_skips_healthy_row() -> None:
|
||||
"""A row whose deadline is comfortably in the future must not be
|
||||
re-read or mutated — the per-row budget check rejects it before
|
||||
touching the lock path."""
|
||||
orch = _make_orchestrator()
|
||||
node = _make_node(timeout_seconds=600)
|
||||
# Started 10s ago; deadline is 600 + 120 = 720s from start.
|
||||
node_run = _make_node_run(started_at=utcnow() - timedelta(seconds=10))
|
||||
run = _make_run()
|
||||
|
||||
fake_session = AsyncMock()
|
||||
fake_session.execute = AsyncMock(
|
||||
return_value=MagicMock(all=MagicMock(return_value=[(node_run, node, run)])),
|
||||
)
|
||||
fake_session.scalar = AsyncMock()
|
||||
fake_session.add = MagicMock()
|
||||
|
||||
with patch(
|
||||
"schedule.orchestrator.session_scope",
|
||||
return_value=_open_session_scope(fake_session),
|
||||
):
|
||||
killed = await orch._reap_stuck_node_runs()
|
||||
|
||||
assert killed == 0
|
||||
fake_session.scalar.assert_not_called()
|
||||
fake_session.add.assert_not_called()
|
||||
assert node_run.node_status == "running"
|
||||
|
||||
|
||||
async def test_reap_processes_multiple_rows_in_one_pass() -> None:
|
||||
"""A batch of 3 rows — 2 over deadline, 1 healthy — must produce
|
||||
exactly 2 kills. The healthy one is short-circuited in Python
|
||||
before the lock path."""
|
||||
orch = _make_orchestrator()
|
||||
node = _make_node(timeout_seconds=600)
|
||||
stuck_a = _make_node_run(
|
||||
node_run_id="stuck_a", started_at=utcnow() - timedelta(seconds=10_000),
|
||||
)
|
||||
stuck_b = _make_node_run(
|
||||
node_run_id="stuck_b", started_at=utcnow() - timedelta(seconds=9_000),
|
||||
)
|
||||
healthy = _make_node_run(
|
||||
node_run_id="healthy", started_at=utcnow() - timedelta(seconds=10),
|
||||
)
|
||||
run = _make_run()
|
||||
|
||||
fake_session = AsyncMock()
|
||||
fake_session.execute = AsyncMock(
|
||||
return_value=MagicMock(
|
||||
all=MagicMock(return_value=[
|
||||
(stuck_a, node, run),
|
||||
(stuck_b, node, run),
|
||||
(healthy, node, run),
|
||||
]),
|
||||
),
|
||||
)
|
||||
# Re-read for the two stuck rows; healthy is skipped before this.
|
||||
fake_session.scalar = AsyncMock(side_effect=[stuck_a, stuck_b])
|
||||
fake_session.add = MagicMock()
|
||||
|
||||
with patch(
|
||||
"schedule.orchestrator.session_scope",
|
||||
return_value=_open_session_scope(fake_session),
|
||||
):
|
||||
killed = await orch._reap_stuck_node_runs()
|
||||
|
||||
assert killed == 2
|
||||
assert stuck_a.node_status == "timed_out"
|
||||
assert stuck_b.node_status == "timed_out"
|
||||
assert healthy.node_status == "running"
|
||||
assert fake_session.add.call_count == 2
|
||||
|
||||
|
||||
async def test_janitor_loop_propagates_cancellation() -> None:
|
||||
"""``_janitor_loop`` must re-raise ``CancelledError`` so ``close()``
|
||||
can shut it down cleanly. Patches ``asyncio.sleep`` to raise on
|
||||
first call so the test doesn't run forever."""
|
||||
orch = _make_orchestrator()
|
||||
orch._reap_stuck_node_runs = AsyncMock(return_value=0)
|
||||
|
||||
async def cancel_on_sleep(_seconds: float) -> None:
|
||||
raise asyncio.CancelledError
|
||||
|
||||
with patch(
|
||||
"schedule.orchestrator.asyncio.sleep",
|
||||
side_effect=cancel_on_sleep,
|
||||
):
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await orch._janitor_loop()
|
||||
|
||||
|
||||
async def test_janitor_loop_continues_after_reap_exception() -> None:
|
||||
"""A failure inside one reap pass must not kill the loop — the
|
||||
next iteration still runs. The janitor must be self-healing so a
|
||||
bad row or transient DB blip can't take the whole service down."""
|
||||
orch = _make_orchestrator()
|
||||
call_count = 0
|
||||
|
||||
async def _reap_then_succeed() -> int:
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count == 1:
|
||||
raise RuntimeError("simulated DB blip")
|
||||
return 3
|
||||
|
||||
orch._reap_stuck_node_runs = AsyncMock(side_effect=_reap_then_succeed)
|
||||
|
||||
sleep_calls = 0
|
||||
|
||||
async def _count_sleeps(_seconds: float) -> None:
|
||||
nonlocal sleep_calls
|
||||
sleep_calls += 1
|
||||
if sleep_calls >= 2:
|
||||
raise asyncio.CancelledError
|
||||
|
||||
with patch(
|
||||
"schedule.orchestrator.asyncio.sleep",
|
||||
side_effect=_count_sleeps,
|
||||
):
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await orch._janitor_loop()
|
||||
|
||||
assert call_count == 2
|
||||
Reference in New Issue
Block a user