fix: support local storage runtime and schedule logs
This commit is contained in:
@@ -7,6 +7,7 @@ surface is two endpoints; everything else is lifespan orchestration.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Literal
|
||||
|
||||
@@ -48,7 +49,10 @@ async def lifespan(app: FastAPI):
|
||||
f"removed_meta={counters['removed_meta']} "
|
||||
f"live_in_registry={counters['live_in_registry']}"
|
||||
)
|
||||
await scan_workspaces()
|
||||
# Scanning a remote FUSE mount may take longer than the HTTP health-check
|
||||
# timeout. Keep recovery asynchronous so dependent services can start as
|
||||
# soon as the runtime API is ready.
|
||||
asyncio.create_task(scan_workspaces(), name="workspace-startup-scan")
|
||||
# P1-3: spawn background idle reaper. Stopped in the lifespan
|
||||
# finally block; awaits the cancellation to avoid a leaked task.
|
||||
start_reaper()
|
||||
@@ -112,4 +116,4 @@ async def handle_jupyter_action(req: JupyterActionRequest) -> dict:
|
||||
status_code=400,
|
||||
detail="'workspace_id' is required for action='get'",
|
||||
)
|
||||
return await get_workspace(req.workspace_id)
|
||||
return await get_workspace(req.workspace_id)
|
||||
|
||||
@@ -37,6 +37,7 @@ import asyncio
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import socket
|
||||
import subprocess
|
||||
import time
|
||||
from typing import TypedDict
|
||||
@@ -163,6 +164,37 @@ def _bump_last_used(record: JupyterProcessRecord) -> None:
|
||||
record["last_used_at"] = time.time()
|
||||
|
||||
|
||||
async def _wait_for_jupyter_ready(
|
||||
process: subprocess.Popen,
|
||||
port: int,
|
||||
*,
|
||||
timeout_seconds: float = 30,
|
||||
) -> None:
|
||||
"""Wait until a newly spawned Jupyter server accepts TCP connections.
|
||||
|
||||
Starting the subprocess only means that Python has been forked. Jupyter
|
||||
still needs several seconds to load extensions and bind its port. The
|
||||
runtime API must not advertise the workspace as ``running`` before that
|
||||
happens, otherwise callers immediately receive a transient connection
|
||||
failure while creating or opening the first notebook.
|
||||
"""
|
||||
deadline = time.monotonic() + timeout_seconds
|
||||
while time.monotonic() < deadline:
|
||||
if process.poll() is not None:
|
||||
raise RuntimeError(
|
||||
f"Jupyter exited during startup (exit code {process.returncode})"
|
||||
)
|
||||
try:
|
||||
with socket.create_connection(("127.0.0.1", port), timeout=0.2):
|
||||
return
|
||||
except OSError:
|
||||
await asyncio.sleep(0.2)
|
||||
|
||||
raise RuntimeError(
|
||||
f"Timed out after {timeout_seconds:g}s waiting for Jupyter on port {port}"
|
||||
)
|
||||
|
||||
|
||||
async def start_workspace(ws_id: str) -> dict:
|
||||
async with get_workspace_lock(ws_id):
|
||||
workspace_path = (WORKSPACES_ROOT / ws_id).resolve()
|
||||
@@ -297,6 +329,17 @@ async def start_workspace(ws_id: str) -> dict:
|
||||
detail=f"Failed to start Jupyter: {e}",
|
||||
)
|
||||
|
||||
try:
|
||||
await _wait_for_jupyter_ready(process, port)
|
||||
except Exception as exc:
|
||||
logger.exception(f"Jupyter did not become ready for workspace={ws_id}")
|
||||
if process.poll() is None:
|
||||
process.terminate()
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=f"Jupyter failed to start: {exc}",
|
||||
) from exc
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# Save process metadata
|
||||
# ---------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user