chore: else bug
This commit is contained in:
@@ -19,8 +19,11 @@ from runtime.process import (
|
||||
JUPYTER_PROCESSES,
|
||||
get_workspace,
|
||||
list_workspaces,
|
||||
reconcile_processes,
|
||||
scan_workspaces,
|
||||
start_reaper,
|
||||
start_workspace,
|
||||
stop_reaper,
|
||||
stop_workspace,
|
||||
)
|
||||
|
||||
@@ -37,12 +40,26 @@ async def lifespan(app: FastAPI):
|
||||
logger.info("Starting up Runtime Service...")
|
||||
start_rclone_mount()
|
||||
logger.info("Scanning workspaces")
|
||||
# P1-3: clean up stale sidecar metadata before we adopt any
|
||||
# workspace. Orphans from a previous runtime incarnation are not
|
||||
# re-adopted — see reconcile_processes() docstring.
|
||||
counters = reconcile_processes()
|
||||
logger.info(
|
||||
f"Reconcile: scanned={counters['scanned']} "
|
||||
f"removed_meta={counters['removed_meta']} "
|
||||
f"live_in_registry={counters['live_in_registry']}"
|
||||
)
|
||||
await scan_workspaces()
|
||||
# P1-3: spawn background idle reaper. Stopped in the lifespan
|
||||
# finally block; awaits the cancellation to avoid a leaked task.
|
||||
start_reaper()
|
||||
logger.info("Runtime Service started")
|
||||
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
logger.info("Service is shutting down. Stopping reaper...")
|
||||
await stop_reaper()
|
||||
logger.info(
|
||||
"Service is shutting down. Terminating all active Jupyter sub-processes..."
|
||||
)
|
||||
|
||||
@@ -4,11 +4,37 @@ Owns the in-memory ``JUPYTER_PROCESSES`` dict, the ``STATE_LOCK`` that
|
||||
serializes mutations to it, and the per-workspace start/stop/list/get
|
||||
operations. Workspace discovery (startup scan) lives here because it is
|
||||
a thin wrapper over ``start_workspace``.
|
||||
|
||||
Process reconciliation and idle reaping (P1-3):
|
||||
|
||||
* **On startup** ``reconcile_processes()`` walks the workspaces root
|
||||
and removes ``.runtime-meta.json`` files whose owning Jupyter
|
||||
process is no longer tracked. The runtime only knows about
|
||||
workspaces it has explicitly started in this lifetime; orphans from
|
||||
a previous process incarnation are simply not reaped — the
|
||||
workspace directory on disk is left alone, the runtime does not
|
||||
attempt to re-launch stray PIDs. This is the safe default: we never
|
||||
re-adopt a process we did not fork, because we cannot trust its
|
||||
runtime state.
|
||||
|
||||
* **Idle reaping** runs in a background task started by
|
||||
``runtime.main.lifespan``. Every 60s the reaper looks for processes
|
||||
whose ``last_used_at`` is older than ``JUPYTER_IDLE_TIMEOUT_SECONDS``
|
||||
(default 30 minutes) and gracefully terminates them. ``last_used_at``
|
||||
is bumped on ``start_workspace``, ``get_workspace``, and any
|
||||
Jupyter HTTP interaction routed through this registry. Because we
|
||||
don't see individual kernel API calls (those go straight through
|
||||
nginx), the reaper is conservative — a long-running notebook that
|
||||
is actually busy will still be torn down at the idle threshold. A
|
||||
future iteration could either (a) poll the Jupyter ``/api/status``
|
||||
endpoint to count active kernels, or (b) require the editor UI to
|
||||
send a heartbeat. Both are out of scope here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import subprocess
|
||||
@@ -24,6 +50,27 @@ from runtime.mount import WORKSPACES_ROOT
|
||||
|
||||
PUBLIC_BASE_URL = settings.public_base_url
|
||||
|
||||
# P1-3: idle reaping tuning knobs. The default 30 minutes matches the
|
||||
# "user stepped away" mental model; a power user can override via env
|
||||
# if they need to keep notebooks warm longer.
|
||||
JUPYTER_IDLE_TIMEOUT_SECONDS = int(
|
||||
os.environ.get("JUPYTER_IDLE_TIMEOUT_SECONDS", str(30 * 60))
|
||||
)
|
||||
JUPYTER_REAP_INTERVAL_SECONDS = int(
|
||||
os.environ.get("JUPYTER_REAP_INTERVAL_SECONDS", "60")
|
||||
)
|
||||
JUPYTER_MAX_LIFETIME_SECONDS = int(
|
||||
os.environ.get("JUPYTER_MAX_LIFETIME_SECONDS", str(24 * 3600))
|
||||
)
|
||||
|
||||
# Sidecar metadata file written next to the workspace directory. Holds
|
||||
# the runtime state we need to reconstruct the in-memory map after a
|
||||
# crash. We intentionally store only the bits that are safe to recover:
|
||||
# process pid, port, started_at. ``last_used_at`` is intentionally
|
||||
# NOT persisted — a fresh process inherits the "just started" state
|
||||
# and gets 30 minutes before the first reap.
|
||||
RUNTIME_META_FILENAME = ".runtime-meta.json"
|
||||
|
||||
|
||||
class JupyterProcessRecord(TypedDict):
|
||||
process: subprocess.Popen
|
||||
@@ -31,11 +78,14 @@ class JupyterProcessRecord(TypedDict):
|
||||
token: str
|
||||
base_url: str
|
||||
started_at: float
|
||||
last_used_at: float
|
||||
meta_path: str
|
||||
|
||||
|
||||
JUPYTER_PROCESSES: dict[str, JupyterProcessRecord] = {}
|
||||
WORKSPACE_LOCKS: dict[str, asyncio.Lock] = {}
|
||||
_LOCKS_REGISTRY = asyncio.Lock()
|
||||
_REAPER_TASK: asyncio.Task[None] | None = None
|
||||
|
||||
|
||||
def get_workspace_lock(ws_id: str) -> asyncio.Lock:
|
||||
@@ -68,18 +118,60 @@ def _drop_workspace_lock(ws_id: str) -> None:
|
||||
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)
|
||||
|
||||
|
||||
def _write_meta(ws_id: str, record: JupyterProcessRecord) -> None:
|
||||
"""Persist the bits of the record we can safely recover.
|
||||
|
||||
Best-effort — failures (read-only mount, vanished dir) are logged
|
||||
and swallowed; the in-memory state is the source of truth.
|
||||
"""
|
||||
payload = {
|
||||
"workspace_id": ws_id,
|
||||
"pid": record["process"].pid,
|
||||
"port": record["port"],
|
||||
"started_at": record["started_at"],
|
||||
}
|
||||
try:
|
||||
with open(record["meta_path"], "w", encoding="utf-8") as fp:
|
||||
json.dump(payload, fp)
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
logger.warning(f"failed to write runtime meta for {ws_id}: {exc}")
|
||||
|
||||
|
||||
def _delete_meta(ws_id: str) -> None:
|
||||
"""Best-effort remove of the sidecar metadata file."""
|
||||
try:
|
||||
os.unlink(_meta_path(ws_id))
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
logger.warning(f"failed to delete runtime meta for {ws_id}: {exc}")
|
||||
|
||||
|
||||
def _bump_last_used(record: JupyterProcessRecord) -> None:
|
||||
"""Touch ``last_used_at`` so the idle reaper does not eat active ws."""
|
||||
record["last_used_at"] = time.time()
|
||||
|
||||
|
||||
async def start_workspace(ws_id: str) -> dict:
|
||||
async with get_workspace_lock(ws_id):
|
||||
workspace_path = WORKSPACES_ROOT / ws_id
|
||||
workspace_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if ws_id in JUPYTER_PROCESSES:
|
||||
p_info = JUPYTER_PROCESSES[ws_id]
|
||||
if p_info["process"].poll() is None:
|
||||
if time.time() - p_info["started_at"] > 24 * 3600:
|
||||
if time.time() - p_info["started_at"] > JUPYTER_MAX_LIFETIME_SECONDS:
|
||||
logger.warning(
|
||||
f"Reusing Jupyter for {ws_id} older than 24h "
|
||||
f"Reusing Jupyter for {ws_id} older than "
|
||||
f"{JUPYTER_MAX_LIFETIME_SECONDS}s "
|
||||
f"(started_at={p_info['started_at']})"
|
||||
)
|
||||
_bump_last_used(p_info)
|
||||
return {
|
||||
"status": "running",
|
||||
"workspace_id": ws_id,
|
||||
@@ -92,6 +184,9 @@ async def start_workspace(ws_id: str) -> dict:
|
||||
f"?token={p_info['token']}"
|
||||
),
|
||||
}
|
||||
# Process died but we still hold a record — drop it and
|
||||
# start a fresh one.
|
||||
_delete_meta(ws_id)
|
||||
del JUPYTER_PROCESSES[ws_id]
|
||||
|
||||
port = get_free_port()
|
||||
@@ -124,14 +219,18 @@ async def start_workspace(ws_id: str) -> dict:
|
||||
)
|
||||
|
||||
full_url = f"{PUBLIC_BASE_URL}:{port}{base_path}?token={token}"
|
||||
|
||||
meta_path = _meta_path(ws_id)
|
||||
now = time.time()
|
||||
JUPYTER_PROCESSES[ws_id] = {
|
||||
"process": process,
|
||||
"base_url": PUBLIC_BASE_URL,
|
||||
"port": port,
|
||||
"token": token,
|
||||
"started_at": time.time(),
|
||||
"started_at": now,
|
||||
"last_used_at": now,
|
||||
"meta_path": meta_path,
|
||||
}
|
||||
_write_meta(ws_id, JUPYTER_PROCESSES[ws_id])
|
||||
|
||||
logger.info(
|
||||
f"Started Jupyter for workspace {ws_id} "
|
||||
@@ -175,6 +274,7 @@ async def stop_workspace(ws_id: str) -> dict:
|
||||
logger.error(f"Failed to kill Jupyter process for {ws_id}: {err}")
|
||||
|
||||
del JUPYTER_PROCESSES[ws_id]
|
||||
_delete_meta(ws_id)
|
||||
|
||||
_drop_workspace_lock(ws_id)
|
||||
return {
|
||||
@@ -197,6 +297,8 @@ async def list_workspaces() -> dict:
|
||||
f"?token={info['token']}"
|
||||
),
|
||||
"is_alive": info["process"].poll() is None,
|
||||
"last_used_at": info["last_used_at"],
|
||||
"started_at": info["started_at"],
|
||||
}
|
||||
for ws_id, info in snapshot.items()
|
||||
},
|
||||
@@ -216,7 +318,9 @@ async def get_workspace(ws_id: str) -> dict:
|
||||
|
||||
if not is_alive:
|
||||
del JUPYTER_PROCESSES[ws_id]
|
||||
_delete_meta(ws_id)
|
||||
else:
|
||||
_bump_last_used(p_info)
|
||||
return {
|
||||
"status": "running",
|
||||
"pid": p_info["process"].pid,
|
||||
@@ -229,6 +333,7 @@ async def get_workspace(ws_id: str) -> dict:
|
||||
f"?token={p_info['token']}"
|
||||
),
|
||||
"started_at": p_info["started_at"],
|
||||
"last_used_at": p_info["last_used_at"],
|
||||
}
|
||||
|
||||
_drop_workspace_lock(ws_id)
|
||||
@@ -241,6 +346,152 @@ async def get_workspace(ws_id: str) -> dict:
|
||||
)
|
||||
|
||||
|
||||
def reconcile_processes() -> dict[str, int]:
|
||||
"""P1-3 startup sweep.
|
||||
|
||||
Walks the workspaces root and inspects ``.runtime-meta.json``
|
||||
sidecars. A sidecar whose workspace directory has no live
|
||||
``JUPYTER_PROCESSES`` entry AND whose recorded pid is not
|
||||
running on this host is treated as a stale artifact and removed
|
||||
(the workspace directory itself is left intact — that is user
|
||||
data we have no right to touch).
|
||||
|
||||
We do NOT scan ``/proc`` to find orphan jupyter processes that
|
||||
have no sidecar at all. The runtime has no way to associate
|
||||
such a process with a workspace without the sidecar, and
|
||||
adopting a foreign process is unsafe. Stale orphans from a
|
||||
previous runtime incarnation will keep their port and
|
||||
workspace files; an operator can ``docker compose restart
|
||||
runtime`` to fully reset.
|
||||
|
||||
Returns a small counter dict so the caller can log it.
|
||||
"""
|
||||
counters = {"scanned": 0, "removed_meta": 0, "live_in_registry": 0}
|
||||
if not WORKSPACES_ROOT.exists():
|
||||
return counters
|
||||
try:
|
||||
entries = os.listdir(WORKSPACES_ROOT)
|
||||
except Exception as exc:
|
||||
logger.warning(f"reconcile_processes: cannot list workspaces: {exc}")
|
||||
return counters
|
||||
for entry in entries:
|
||||
ws_path = WORKSPACES_ROOT / entry
|
||||
if not ws_path.is_dir():
|
||||
continue
|
||||
meta_file = ws_path / RUNTIME_META_FILENAME
|
||||
if not meta_file.exists():
|
||||
continue
|
||||
counters["scanned"] += 1
|
||||
if entry in JUPYTER_PROCESSES:
|
||||
counters["live_in_registry"] += 1
|
||||
# Trust the in-memory record (this process is the one that
|
||||
# wrote the sidecar most recently). Refresh it.
|
||||
_write_meta(entry, JUPYTER_PROCESSES[entry])
|
||||
continue
|
||||
# Stale: this process did not start the Jupyter in question.
|
||||
# Verify the pid is dead; if it is, drop the sidecar so the
|
||||
# next scan is clean. If a live jupyter process with that pid
|
||||
# exists we leave the sidecar alone (it might be a sibling
|
||||
# runtime; the next call to start_workspace will reuse the
|
||||
# port if available).
|
||||
try:
|
||||
with open(meta_file, encoding="utf-8") as fp:
|
||||
meta = json.load(fp)
|
||||
pid = int(meta.get("pid", 0))
|
||||
except Exception as exc:
|
||||
logger.info(f"reconcile: removing malformed meta for {entry}: {exc}")
|
||||
_delete_meta(entry)
|
||||
counters["removed_meta"] += 1
|
||||
continue
|
||||
if pid <= 0:
|
||||
_delete_meta(entry)
|
||||
counters["removed_meta"] += 1
|
||||
continue
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
alive = True
|
||||
except ProcessLookupError:
|
||||
alive = False
|
||||
except PermissionError:
|
||||
alive = True # someone else's process, leave alone
|
||||
if not alive:
|
||||
logger.info(f"reconcile: dropping stale sidecar for {entry} (pid {pid} dead)")
|
||||
_delete_meta(entry)
|
||||
counters["removed_meta"] += 1
|
||||
return counters
|
||||
|
||||
|
||||
async def _reap_loop() -> None:
|
||||
"""Background task: idle + dead reaper.
|
||||
|
||||
Runs every ``JUPYTER_REAP_INTERVAL_SECONDS``; stops on
|
||||
:class:`asyncio.CancelledError`. Decisions:
|
||||
* process is dead (poll() != None) → drop the record + meta
|
||||
* process is alive but ``last_used_at`` is older than the
|
||||
idle timeout → graceful stop
|
||||
* process is alive but older than the max lifetime → graceful
|
||||
stop (defence in depth: prevents a workspace from holding a
|
||||
port forever)
|
||||
"""
|
||||
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}")
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.exception(f"reap loop failed: {exc}")
|
||||
|
||||
|
||||
def start_reaper() -> asyncio.Task[None]:
|
||||
"""Spawn the background idle reaper. Idempotent."""
|
||||
global _REAPER_TASK
|
||||
if _REAPER_TASK is not None and not _REAPER_TASK.done():
|
||||
return _REAPER_TASK
|
||||
_REAPER_TASK = asyncio.create_task(_reap_loop(), name="jupyter-idle-reaper")
|
||||
return _REAPER_TASK
|
||||
|
||||
|
||||
async def stop_reaper() -> None:
|
||||
"""Cancel the reaper and wait for it to finish (called from lifespan)."""
|
||||
global _REAPER_TASK
|
||||
task = _REAPER_TASK
|
||||
_REAPER_TASK = None
|
||||
if task is None or task.done():
|
||||
return
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
|
||||
async def scan_workspaces() -> None:
|
||||
if not WORKSPACES_ROOT.exists():
|
||||
return
|
||||
@@ -256,6 +507,10 @@ async def scan_workspaces() -> None:
|
||||
path = WORKSPACES_ROOT / entry
|
||||
if not path.is_dir():
|
||||
return
|
||||
# Skip workspace dirs that already have a live Jupyter tracked
|
||||
# in this process — saves a port allocation and a noop start.
|
||||
if entry in JUPYTER_PROCESSES:
|
||||
return
|
||||
logger.info(f"Found workspace: {entry}")
|
||||
logger.info(f"Auto-starting Jupyter for workspace ID: '{entry}'")
|
||||
async with sem:
|
||||
@@ -264,4 +519,4 @@ async def scan_workspaces() -> None:
|
||||
except Exception as err:
|
||||
logger.error(f"Startup failed for workspace '{entry}': {err}")
|
||||
|
||||
await asyncio.gather(*[_start(entry) for entry in entries])
|
||||
await asyncio.gather(*[_start(entry) for entry in entries])
|
||||
|
||||
@@ -503,13 +503,29 @@ class DispatchOrchestrator:
|
||||
if node_id in latest:
|
||||
continue
|
||||
parent_runs = [latest.get(parent) for parent in parents[node_id]]
|
||||
parent_failed = any(
|
||||
# Decide whether this node should be skipped. A node
|
||||
# is only skipped when we know it can never run:
|
||||
# * ``stop_all`` — the whole run was aborted on the
|
||||
# first failure, so any not-yet-dispatched node is
|
||||
# dropped;
|
||||
# * all parents are terminal AND at least one
|
||||
# failed — there is no remaining success path.
|
||||
# If even one parent is still ``queued`` or
|
||||
# ``running`` we keep waiting: under ``failure_policy
|
||||
# == 'continue'`` a sibling might still succeed and
|
||||
# the failed parent does not block that.
|
||||
parents_terminal = all(
|
||||
item is not None
|
||||
and item.node_status in TERMINAL_NODE_STATES
|
||||
and item.node_status != "succeeded"
|
||||
for item in parent_runs
|
||||
)
|
||||
if stop_all or parent_failed:
|
||||
any_parent_failed = any(
|
||||
item is not None
|
||||
and item.node_status in FAILED_NODE_STATES
|
||||
for item in parent_runs
|
||||
)
|
||||
parents_blocked = parents_terminal and any_parent_failed
|
||||
if stop_all or parents_blocked:
|
||||
skipped = ScheduleNodeRuns(
|
||||
node_run_id=new_ulid(),
|
||||
run_id=run.run_id,
|
||||
@@ -523,15 +539,24 @@ class DispatchOrchestrator:
|
||||
message=(
|
||||
"调度失败策略为 stop,未再启动"
|
||||
if stop_all
|
||||
else "上游节点未成功,已跳过"
|
||||
else "上游节点全部终止且至少一个失败,已跳过"
|
||||
),
|
||||
)
|
||||
session.add(skipped)
|
||||
latest[node_id] = skipped
|
||||
changed = True
|
||||
elif (
|
||||
all(
|
||||
item is not None and item.node_status == "succeeded"
|
||||
# Dispatch only when every parent has actually
|
||||
# run to completion successfully. A None parent
|
||||
# means the parent has not even been dispatched
|
||||
# yet (e.g. upstream is still queued); the existing
|
||||
# parents_blocked branch above handles the case
|
||||
# where every parent is terminal but at least one
|
||||
# failed.
|
||||
len(parent_runs) > 0
|
||||
and all(
|
||||
item is not None
|
||||
and item.node_status == "succeeded"
|
||||
for item in parent_runs
|
||||
)
|
||||
and active_count < max_concurrency
|
||||
@@ -554,13 +579,40 @@ class DispatchOrchestrator:
|
||||
for item in latest.values()
|
||||
):
|
||||
now = utcnow()
|
||||
succeeded = all(
|
||||
item.node_status == "succeeded" for item in latest.values()
|
||||
# Final run status depends on the schedule's
|
||||
# ``failure_policy``. ``stop`` keeps the legacy rule — any
|
||||
# non-success node fails the whole run. ``continue`` is
|
||||
# more lenient: the run is a success when at least one
|
||||
# root-level node succeeded and there is no remaining
|
||||
# ``failed`` / ``cancelled`` / ``timed_out`` node that
|
||||
# would have produced real artifacts had it run. Nodes
|
||||
# marked ``skipped`` count as "decided to not run" and do
|
||||
# not by themselves fail the run.
|
||||
failure_policy = snapshot.get("failure_policy", "stop")
|
||||
statuses = [item.node_status for item in latest.values()]
|
||||
any_real_failure = any(
|
||||
status in FAILED_NODE_STATES for status in statuses
|
||||
)
|
||||
any_success = any(
|
||||
status == "succeeded" for status in statuses
|
||||
)
|
||||
if failure_policy == "continue":
|
||||
# A run with mixed success/failure/skip outcomes is
|
||||
# only "succeeded" when at least one node actually ran
|
||||
# to completion and nothing hit a hard failure. A
|
||||
# run where every node was skipped or failed is
|
||||
# itself a failure.
|
||||
succeeded = any_success and not any_real_failure
|
||||
else:
|
||||
succeeded = all(
|
||||
status == "succeeded" for status in statuses
|
||||
)
|
||||
run.run_status = "succeeded" if succeeded else "failed"
|
||||
run.error_code = None if succeeded else "SCHEDULE_NODE_FAILED"
|
||||
run.error_message = (
|
||||
None if succeeded else "one or more schedule nodes did not succeed"
|
||||
None
|
||||
if succeeded
|
||||
else "one or more schedule nodes did not succeed"
|
||||
)
|
||||
run.finished_at = now
|
||||
if run.started_at:
|
||||
|
||||
Reference in New Issue
Block a user