runner: gpu AND cpu are now a node pool. Per-node cpu_slots (primary 3, helpers 2, nodes.json-overridable); net stays primary-only. Python-less ops (ffmpeg/ffprobe) now run remotely on the node's system python3. Verified: 9 concurrent ffmpeg_frames distributed 4 local / 2 m1 / 2 m4. hunyuan3d_mlx: default to Studio-quality (octree 384, texture 4096, remesh 120k) — 4096 bake verified watchdog-free on M3 Ultra; big quality gain (defined face, 120k faces). remesh_faces now a param. All param-overridable.
359 lines
17 KiB
Python
359 lines
17 KiB
Python
"""Job runner: executes operators as subprocesses across concurrency lanes.
|
|
|
|
Operator contract:
|
|
<python> run.py --input <path> [--input <path> ...] --outdir <dir> --params '<json>'
|
|
- inputs are passed in asset order (single-input ops get one --input)
|
|
- stdout/stderr are streamed into the job log (secret values redacted)
|
|
- operator writes outputs into outdir and (optionally) outdir/result.json:
|
|
{"outputs": [{"path": "rel/or/abs", "name": "...", "meta": {...}}, ...],
|
|
"summary": {...}}
|
|
- if result.json is absent, every top-level file in outdir is registered
|
|
Concurrency lanes (from manifest "resources"): gpu and cpu are a node pool
|
|
(distributed across the primary + remote workers — gpu 1/node, cpu N/node);
|
|
net=6 stays primary-only. Env from settings is injected; "requires_env" gates.
|
|
"""
|
|
import asyncio
|
|
import json
|
|
import os
|
|
import signal
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
from . import db, remote, settings as settings_mod, store
|
|
|
|
JOBS_DIR = db.DATA / "jobs"
|
|
# gpu AND cpu are a NODE POOL (this Mac + remote workers): gpu runs 1 job/node
|
|
# (one Metal device each), cpu runs per-node slots — LANE_LIMITS["cpu"] on the
|
|
# primary, fewer on helpers (nodes.json "cpu_slots" overrides). net stays a
|
|
# primary-only semaphore because the cloud API keys live only on the primary.
|
|
LANE_LIMITS = {"cpu": 3, "net": 6}
|
|
|
|
|
|
class Runner:
|
|
def __init__(self):
|
|
self.queue: asyncio.Queue[str] = asyncio.Queue()
|
|
self.subscribers: dict = {} # websocket -> user dict (for per-user scoping)
|
|
self.operators: dict[str, dict] = {}
|
|
self.lanes: dict[str, asyncio.Semaphore] = {}
|
|
self.gpu_nodes: list[dict] = [] # [{local}, {ssh,root,operators}, ...]
|
|
self.procs: dict = {} # job_id -> subprocess
|
|
self.cancelled: set[str] = set()
|
|
self._tasks: set = set()
|
|
self.get_settings = lambda: {} # set by main.py
|
|
|
|
# -- pubsub -------------------------------------------------------------
|
|
async def broadcast(self, message: dict):
|
|
# "job" events carry a payload injected straight into the client's list, so
|
|
# scope them: a guest only receives their own jobs (owner receives all).
|
|
# "assets_changed" just triggers a per-user re-fetch, so send to everyone.
|
|
job_uid = message.get("job", {}).get("user_id") if message.get("type") == "job" else None
|
|
dead = []
|
|
for ws, u in list(self.subscribers.items()):
|
|
if job_uid is not None and u.get("role") != "owner" and job_uid != u.get("id"):
|
|
continue
|
|
try:
|
|
await ws.send_json(message)
|
|
except Exception:
|
|
dead.append(ws)
|
|
for ws in dead:
|
|
self.subscribers.pop(ws, None)
|
|
|
|
def lane_of(self, operator: str) -> str:
|
|
op = self.operators.get(operator, {})
|
|
lane = op.get("resources", "cpu")
|
|
# gpu is a valid lane (the node pool) even though it isn't in LANE_LIMITS,
|
|
# which holds only the semaphore lanes (cpu/net).
|
|
return lane if lane in LANE_LIMITS or lane == "gpu" else "cpu"
|
|
|
|
# -- job lifecycle --------------------------------------------------------
|
|
def create_job(self, con, operator: str, asset_ids: list[str], params: dict,
|
|
user_id: str | None = None) -> dict:
|
|
job_id = db.new_id()
|
|
outdir = JOBS_DIR / job_id
|
|
outdir.mkdir(parents=True, exist_ok=True)
|
|
primary = asset_ids[0] if asset_ids else None
|
|
con.execute(
|
|
"INSERT INTO jobs (id, operator, status, asset_id, asset_ids, params, outdir, created_at, user_id) "
|
|
"VALUES (?, ?, 'queued', ?, ?, ?, ?, ?, ?)",
|
|
(job_id, operator, primary, json.dumps(asset_ids),
|
|
json.dumps(params), str(outdir), db.now(), user_id),
|
|
)
|
|
con.commit()
|
|
self.queue.put_nowait(job_id)
|
|
return self.get_job(con, job_id)
|
|
|
|
def active_job_count(self, con, user_id: str) -> int:
|
|
row = con.execute(
|
|
"SELECT COUNT(*) c FROM jobs WHERE user_id = ? AND status IN ('queued','running')",
|
|
(user_id,)).fetchone()
|
|
return row["c"]
|
|
|
|
def get_job(self, con, job_id: str) -> dict | None:
|
|
row = con.execute("SELECT * FROM jobs WHERE id = ?", (job_id,)).fetchone()
|
|
return db.row_to_dict(row) if row else None
|
|
|
|
def list_jobs(self, con) -> list[dict]:
|
|
rows = con.execute("SELECT * FROM jobs ORDER BY created_at DESC LIMIT 200").fetchall()
|
|
return [db.row_to_dict(r) for r in rows]
|
|
|
|
def input_paths(self, con, job: dict) -> list[str]:
|
|
ids = job.get("asset_ids") or ([job["asset_id"]] if job.get("asset_id") else [])
|
|
paths = []
|
|
for aid in ids:
|
|
asset = store.get_asset(con, aid)
|
|
if asset:
|
|
paths.append(asset["path"])
|
|
return paths
|
|
|
|
async def cancel(self, con, job_id: str) -> bool:
|
|
job = self.get_job(con, job_id)
|
|
if not job or job["status"] in ("done", "error", "cancelled"):
|
|
return False
|
|
self.cancelled.add(job_id)
|
|
proc = self.procs.get(job_id)
|
|
if proc and proc.returncode is None:
|
|
try:
|
|
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
|
|
except (ProcessLookupError, PermissionError):
|
|
pass
|
|
else: # queued but not yet started
|
|
await self._update(con, job_id, status="cancelled", finished_at=db.now())
|
|
return True
|
|
|
|
def delete_job(self, con, job_id: str) -> bool:
|
|
job = self.get_job(con, job_id)
|
|
if not job or job_id in self.procs:
|
|
return False
|
|
import shutil
|
|
if job.get("outdir"):
|
|
shutil.rmtree(job["outdir"], ignore_errors=True)
|
|
con.execute("DELETE FROM jobs WHERE id = ?", (job_id,))
|
|
con.commit()
|
|
return True
|
|
|
|
async def _update(self, con, job_id: str, **fields):
|
|
sets = ", ".join(f"{k} = ?" for k in fields)
|
|
con.execute(f"UPDATE jobs SET {sets} WHERE id = ?", (*fields.values(), job_id))
|
|
con.commit()
|
|
job = self.get_job(con, job_id)
|
|
await self.broadcast({"type": "job", "job": job})
|
|
|
|
# -- worker -----------------------------------------------------------------
|
|
async def worker(self):
|
|
con = db.connect()
|
|
for key in LANE_LIMITS:
|
|
self.lanes[key] = asyncio.Semaphore(LANE_LIMITS[key])
|
|
self.gpu_nodes = remote.load_gpu_nodes()
|
|
for n in self.gpu_nodes:
|
|
n.setdefault("inflight", {"gpu": 0, "cpu": 0})
|
|
names = ", ".join(n.get("name", n.get("ssh", "?")) for n in self.gpu_nodes)
|
|
print(f"[runner] node pool (gpu 1/node + cpu distributed): {names}", flush=True)
|
|
# re-queue jobs left running/queued by a previous process
|
|
for row in con.execute("SELECT id FROM jobs WHERE status IN ('queued','running')"):
|
|
con.execute("UPDATE jobs SET status='queued' WHERE id = ?", (row["id"],))
|
|
self.queue.put_nowait(row["id"])
|
|
con.commit()
|
|
while True:
|
|
job_id = await self.queue.get()
|
|
task = asyncio.create_task(self._run_lane(con, job_id))
|
|
self._tasks.add(task)
|
|
task.add_done_callback(self._tasks.discard)
|
|
|
|
async def _run_lane(self, con, job_id: str):
|
|
job = self.get_job(con, job_id)
|
|
if not job: # deleted while queued
|
|
self.cancelled.discard(job_id)
|
|
return
|
|
if job_id in self.cancelled:
|
|
self.cancelled.discard(job_id)
|
|
await self._update(con, job_id, status="cancelled", finished_at=db.now())
|
|
return
|
|
lane = self.lane_of(job["operator"])
|
|
if lane in ("gpu", "cpu"): # pooled lanes — distribute across the node pool
|
|
node = await self._acquire_node(job["operator"], lane)
|
|
try:
|
|
if job_id in self.cancelled:
|
|
self.cancelled.discard(job_id)
|
|
await self._update(con, job_id, status="cancelled", finished_at=db.now())
|
|
return
|
|
await self._run_job(con, job_id, node=node)
|
|
except Exception as e:
|
|
await self._update(con, job_id, status="error", error=str(e), finished_at=db.now())
|
|
finally:
|
|
self._release_node(node, lane)
|
|
return
|
|
async with self.lanes[lane]: # net: cloud keys live on the primary — not pooled
|
|
if job_id in self.cancelled:
|
|
self.cancelled.discard(job_id)
|
|
await self._update(con, job_id, status="cancelled", finished_at=db.now())
|
|
return
|
|
try:
|
|
await self._run_job(con, job_id)
|
|
except Exception as e:
|
|
await self._update(con, job_id, status="error", error=str(e), finished_at=db.now())
|
|
|
|
def _node_capacity(self, node: dict, lane: str) -> int:
|
|
"""Max concurrent jobs of this lane a node may run. gpu=1 (one Metal
|
|
device per Mac); cpu=per-node slots (small helper nodes get fewer),
|
|
overridable per node in nodes.json via "cpu_slots"; net is not pooled
|
|
(cloud keys live only on the primary)."""
|
|
if lane == "gpu":
|
|
return 1
|
|
if lane == "cpu":
|
|
default = LANE_LIMITS["cpu"] if node.get("local") else 2
|
|
return int(node.get("cpu_slots", default))
|
|
return 0
|
|
|
|
async def _acquire_node(self, op_id: str, lane: str) -> dict:
|
|
"""Return the first node with free capacity in this lane that supports the
|
|
operator (local always does; a remote must list it + be reachable). Polls."""
|
|
while True:
|
|
for n in self.gpu_nodes:
|
|
if not remote.node_supports(n, op_id):
|
|
continue
|
|
cap = self._node_capacity(n, lane)
|
|
if cap <= 0 or n["inflight"].get(lane, 0) >= cap:
|
|
continue
|
|
n["inflight"][lane] = n["inflight"].get(lane, 0) + 1 # reserve before the await
|
|
if not n.get("local") and not await remote.healthy(n):
|
|
n["inflight"][lane] -= 1 # unreachable right now; release and keep looking
|
|
continue
|
|
return n
|
|
await asyncio.sleep(0.25)
|
|
|
|
def _release_node(self, node: dict, lane: str) -> None:
|
|
if node is not None and "inflight" in node:
|
|
node["inflight"][lane] = max(0, node["inflight"].get(lane, 0) - 1)
|
|
|
|
async def _run_job(self, con, job_id: str, node: dict | None = None):
|
|
job = self.get_job(con, job_id)
|
|
if not job:
|
|
return
|
|
op = self.operators.get(job["operator"])
|
|
if not op:
|
|
await self._update(con, job_id, status="error",
|
|
error=f"unknown operator {job['operator']}", finished_at=db.now())
|
|
return
|
|
|
|
cur_settings = self.get_settings()
|
|
env = os.environ.copy()
|
|
# least privilege: only this operator's declared paid keys (+ HF/infra) reach
|
|
# the subprocess, so guest local jobs never get the owner's fal/OpenRouter keys
|
|
env.update(settings_mod.env_for_operator(cur_settings, op.get("requires_env")))
|
|
secrets = settings_mod.secret_values(cur_settings)
|
|
|
|
missing = [e for e in op.get("requires_env", []) if not env.get(e)]
|
|
if missing:
|
|
keys = ", ".join(sorted({k for k, v in settings_mod.ENV_MAP.items() if v in missing}))
|
|
await self._update(con, job_id, status="error",
|
|
error=f"missing setting(s): {keys} — add them in Settings",
|
|
finished_at=db.now())
|
|
return
|
|
|
|
outdir = Path(job["outdir"])
|
|
params_json = json.dumps(job["params"])
|
|
local_inputs = self.input_paths(con, job)
|
|
entry = Path(op["dir"]) / op.get("entry", "run.py")
|
|
# manifest "python" may be repo-relative (portable) or absolute (legacy)
|
|
op_py_rel = op.get("python")
|
|
op_py_abs = (str(db.ROOT / op_py_rel)
|
|
if op_py_rel and not Path(op_py_rel).is_absolute() else op_py_rel)
|
|
|
|
node = node or {"local": True, "name": "local"}
|
|
if node.get("local"):
|
|
python = op_py_abs or sys.executable
|
|
if op_py_abs and not Path(python).exists():
|
|
await self._update(con, job_id, status="error", finished_at=db.now(),
|
|
error=f"operator not installed — {python} missing. "
|
|
f"Run its install script under scripts/.")
|
|
return
|
|
cmd = [python, str(entry), "--outdir", str(outdir), "--params", params_json]
|
|
for p in local_inputs:
|
|
cmd += ["--input", p]
|
|
run_env = env
|
|
else: # remote node: rsync inputs out, run over ssh, rsync outputs back
|
|
if op_py_rel and Path(op_py_rel).is_absolute():
|
|
await self._update(con, job_id, status="error", finished_at=db.now(),
|
|
error="operator has an absolute python path — can't run remotely")
|
|
return
|
|
# python-less ops (ffmpeg/ffprobe wrappers) run on the node's system
|
|
# python3, resolved via the remote login shell (bash -lc in remote_cmd).
|
|
remote_python = op_py_rel or "python3"
|
|
try:
|
|
remote_inputs = await remote.prepare(node, job_id, local_inputs)
|
|
except Exception as e:
|
|
await self._update(con, job_id, status="error", finished_at=db.now(),
|
|
error=f"remote prepare failed: {e}")
|
|
return
|
|
entry_rel = os.path.relpath(str(entry), str(db.ROOT))
|
|
cmd = remote.remote_cmd(node, op["id"], remote_python, entry_rel,
|
|
job_id, params_json, remote_inputs)
|
|
run_env = os.environ.copy()
|
|
|
|
await self._update(con, job_id, status="running", started_at=db.now())
|
|
proc = await asyncio.create_subprocess_exec(
|
|
*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT,
|
|
start_new_session=True, env=run_env)
|
|
self.procs[job_id] = proc
|
|
log_lines: list[str] = [f"[node: {node.get('name', 'local')}]\n"]
|
|
assert proc.stdout
|
|
try:
|
|
async for raw in proc.stdout:
|
|
log_lines.append(raw.decode(errors="replace"))
|
|
if len(log_lines) % 5 == 0:
|
|
log = settings_mod.redact("".join(log_lines)[-100_000:], secrets)
|
|
await self._update(con, job_id, log=log)
|
|
code = await proc.wait()
|
|
finally:
|
|
self.procs.pop(job_id, None)
|
|
log = settings_mod.redact("".join(log_lines)[-100_000:], secrets)
|
|
|
|
if job_id in self.cancelled:
|
|
self.cancelled.discard(job_id)
|
|
await self._update(con, job_id, status="cancelled", log=log, finished_at=db.now())
|
|
return
|
|
if code != 0:
|
|
await self._update(con, job_id, status="error", log=log,
|
|
error=f"exit code {code}", finished_at=db.now())
|
|
return
|
|
|
|
if not node.get("local"): # pull the remote outputs into the local job dir
|
|
try:
|
|
await remote.collect(node, job_id, outdir)
|
|
except Exception as e:
|
|
await self._update(con, job_id, status="error", log=log,
|
|
error=f"remote collect failed: {e}", finished_at=db.now())
|
|
return
|
|
registered = self._register_outputs(con, outdir, job_id)
|
|
await self._update(con, job_id, status="done", log=log, finished_at=db.now())
|
|
await self.broadcast({"type": "assets_changed", "job_id": job_id, "assets": registered})
|
|
|
|
def _register_outputs(self, con, outdir: Path, job_id: str) -> list[str]:
|
|
result_path = outdir / "result.json"
|
|
registered = []
|
|
# outputs belong to whoever ran the job — guests see only their own
|
|
# assets, so registering without user_id hides a guest's results from them
|
|
row = con.execute("SELECT user_id FROM jobs WHERE id = ?", (job_id,)).fetchone()
|
|
owner_id = row["user_id"] if row else None
|
|
if result_path.exists():
|
|
result = json.loads(result_path.read_text())
|
|
for out in result.get("outputs", []):
|
|
p = Path(out["path"])
|
|
if not p.is_absolute():
|
|
p = outdir / p
|
|
if p.exists():
|
|
a = store.register_file(con, p, name=out.get("name"),
|
|
parent_job=job_id, move=True, meta=out.get("meta"),
|
|
user_id=owner_id)
|
|
registered.append(a["id"])
|
|
else:
|
|
for p in sorted(outdir.iterdir()):
|
|
if p.name == "result.json" or p.name.startswith("."):
|
|
continue
|
|
a = store.register_file(con, p, parent_job=job_id, move=True, user_id=owner_id)
|
|
registered.append(a["id"])
|
|
return registered
|
|
|
|
|
|
runner = Runner()
|