pre-refactor 041426
This commit is contained in:
@@ -0,0 +1,402 @@
|
||||
"""
|
||||
HyperTower distributed job server.
|
||||
|
||||
Manages a SQLite job queue and a registry of connected clients.
|
||||
Clients poll for work, push status updates, and report completion.
|
||||
|
||||
Usage:
|
||||
python -m v3.distributed.server --port 8765 --token <secret>
|
||||
|
||||
Environment:
|
||||
HT_TOKEN — fallback if --token is not passed
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import Depends, FastAPI, Header, HTTPException
|
||||
import uvicorn
|
||||
|
||||
from .protocol import (
|
||||
ClientInfo,
|
||||
JobResult,
|
||||
JobSpec,
|
||||
JobSubmit,
|
||||
PollResponse,
|
||||
RegisterRequest,
|
||||
RegisterResponse,
|
||||
StatusPush,
|
||||
)
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Global state
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
_TOKEN: str = ""
|
||||
_DB_PATH: Path = Path("v3/distributed/jobs.db")
|
||||
_CLIENT_TTL: int = 120 # seconds before a client is considered gone
|
||||
_MAX_ATTEMPTS: int = 3 # max times a job is retried before being left as failed
|
||||
|
||||
_clients: dict[str, ClientInfo] = {}
|
||||
_clients_lock = threading.Lock()
|
||||
|
||||
|
||||
def _reap_stale_clients():
|
||||
"""Background thread: remove silent clients and re-queue their running jobs."""
|
||||
while True:
|
||||
time.sleep(30)
|
||||
cutoff = datetime.now(timezone.utc).timestamp() - _CLIENT_TTL
|
||||
|
||||
# Step 1: evict timed-out clients from registry
|
||||
with _clients_lock:
|
||||
stale = [
|
||||
cid for cid, c in _clients.items()
|
||||
if datetime.fromisoformat(c.last_seen).timestamp() < cutoff
|
||||
]
|
||||
for cid in stale:
|
||||
print(f"[server] reaped stale client {cid} ({_clients[cid].hostname})", flush=True)
|
||||
del _clients[cid]
|
||||
known_ids = set(_clients.keys())
|
||||
|
||||
# Step 2: reset any running job whose assigned client is no longer known
|
||||
with _db() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT job_id, assigned_to FROM jobs WHERE state='running'"
|
||||
).fetchall()
|
||||
for row in rows:
|
||||
if row["assigned_to"] not in known_ids:
|
||||
conn.execute(
|
||||
"UPDATE jobs SET state='pending', assigned_to=NULL, started_at=NULL "
|
||||
"WHERE job_id=?",
|
||||
(row["job_id"],)
|
||||
)
|
||||
print(f"[server] re-queued job {row['job_id']} "
|
||||
f"(client {row['assigned_to']} unknown)", flush=True)
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Database helpers
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
@contextmanager
|
||||
def _db():
|
||||
conn = sqlite3.connect(str(_DB_PATH))
|
||||
conn.row_factory = sqlite3.Row
|
||||
try:
|
||||
yield conn
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _init_db():
|
||||
_DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
with _db() as conn:
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS jobs (
|
||||
job_id TEXT PRIMARY KEY,
|
||||
run_name TEXT NOT NULL,
|
||||
module TEXT NOT NULL,
|
||||
args TEXT NOT NULL, -- JSON list
|
||||
output_dir TEXT NOT NULL DEFAULT 'v3/results',
|
||||
state TEXT NOT NULL DEFAULT 'pending',
|
||||
priority INTEGER NOT NULL DEFAULT 0,
|
||||
assigned_to TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
started_at TEXT,
|
||||
completed_at TEXT,
|
||||
error_msg TEXT,
|
||||
attempts INTEGER NOT NULL DEFAULT 0
|
||||
)
|
||||
""")
|
||||
# Add attempts column to existing DBs that predate this field
|
||||
try:
|
||||
conn.execute("ALTER TABLE jobs ADD COLUMN attempts INTEGER NOT NULL DEFAULT 0")
|
||||
except Exception:
|
||||
pass # column already exists
|
||||
# Note: running jobs are NOT reset on startup — active clients will re-register
|
||||
# via poll/status and the reaper will clean up any that don't reconnect within TTL.
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _ensure_client(client_id: str, hostname: str = "", gpu_info: str = "") -> bool:
|
||||
"""Re-register a client that survived a server restart.
|
||||
Returns True if the client was unknown (placeholder created) so the caller
|
||||
can ask the client to re-register with full info."""
|
||||
if client_id not in _clients:
|
||||
_clients[client_id] = ClientInfo(
|
||||
client_id=client_id,
|
||||
hostname=hostname or client_id,
|
||||
gpu_info=gpu_info or "unknown",
|
||||
status=StatusPush(state="idle"),
|
||||
last_seen=_now(),
|
||||
)
|
||||
print(f"[server] re-registered {client_id} (survived restart)", flush=True)
|
||||
return True
|
||||
return False
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# FastAPI app
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
app = FastAPI(title="HyperTower Job Server")
|
||||
|
||||
|
||||
def _check_token(x_token: str = Header(...)):
|
||||
if x_token != _TOKEN:
|
||||
raise HTTPException(status_code=403, detail="Invalid token")
|
||||
|
||||
|
||||
# ── Registration ──────────────────────────────────────────────
|
||||
|
||||
@app.post("/register", response_model=RegisterResponse,
|
||||
dependencies=[Depends(_check_token)])
|
||||
def register(req: RegisterRequest, reuse_id: Optional[str] = None):
|
||||
with _clients_lock:
|
||||
client_id = reuse_id if (reuse_id and reuse_id in _clients) else str(uuid.uuid4())[:8]
|
||||
existing_status = _clients[client_id].status if client_id in _clients else StatusPush(state="idle")
|
||||
_clients[client_id] = ClientInfo(
|
||||
client_id=client_id,
|
||||
hostname=req.hostname,
|
||||
gpu_info=req.gpu_info,
|
||||
status=existing_status,
|
||||
last_seen=_now(),
|
||||
)
|
||||
action = "re-registered" if reuse_id else "registered"
|
||||
print(f"[server] {action} {client_id} ({req.hostname} | {req.gpu_info})", flush=True)
|
||||
return RegisterResponse(client_id=client_id)
|
||||
|
||||
|
||||
# ── Job polling ───────────────────────────────────────────────
|
||||
|
||||
@app.post("/poll", response_model=PollResponse,
|
||||
dependencies=[Depends(_check_token)])
|
||||
def poll(client_id: str):
|
||||
with _clients_lock:
|
||||
needs_reregister = _ensure_client(client_id)
|
||||
_clients[client_id].last_seen = _now()
|
||||
|
||||
with _db() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM jobs WHERE state='pending' "
|
||||
"ORDER BY priority DESC, created_at ASC LIMIT 1"
|
||||
).fetchone()
|
||||
|
||||
if row is None:
|
||||
return PollResponse(job=None, please_reregister=needs_reregister)
|
||||
|
||||
job_id = row["job_id"]
|
||||
# Reset any other running jobs for this client — client can only work on one at a time.
|
||||
# This cleans up orphans left over from server restarts.
|
||||
cur = conn.execute(
|
||||
"UPDATE jobs SET state='pending', assigned_to=NULL, started_at=NULL "
|
||||
"WHERE assigned_to=? AND state='running' AND job_id!=?",
|
||||
(client_id, job_id),
|
||||
)
|
||||
if cur.rowcount:
|
||||
print(f"[server] reset {cur.rowcount} orphaned running job(s) for {client_id}", flush=True)
|
||||
conn.execute(
|
||||
"UPDATE jobs SET state='running', assigned_to=?, started_at=? WHERE job_id=?",
|
||||
(client_id, _now(), job_id),
|
||||
)
|
||||
|
||||
job = JobSpec(
|
||||
job_id=job_id,
|
||||
run_name=row["run_name"],
|
||||
module=row["module"],
|
||||
args=json.loads(row["args"]),
|
||||
output_dir=row["output_dir"],
|
||||
)
|
||||
|
||||
with _clients_lock:
|
||||
_clients[client_id].status = StatusPush(
|
||||
state="syncing", job_id=job_id, run_name=row["run_name"]
|
||||
)
|
||||
|
||||
print(f"[server] dispatched {job_id} ({row['run_name']}) → {client_id}", flush=True)
|
||||
return PollResponse(job=job, please_reregister=needs_reregister)
|
||||
|
||||
|
||||
# ── Status ────────────────────────────────────────────────────
|
||||
|
||||
@app.post("/status/{client_id}", dependencies=[Depends(_check_token)])
|
||||
def push_status(client_id: str, status: StatusPush):
|
||||
with _clients_lock:
|
||||
needs_reregister = _ensure_client(client_id)
|
||||
_clients[client_id].status = status
|
||||
_clients[client_id].last_seen = _now()
|
||||
return {"ok": True, "please_reregister": needs_reregister}
|
||||
|
||||
|
||||
@app.get("/clients", dependencies=[Depends(_check_token)])
|
||||
def list_clients():
|
||||
with _clients_lock:
|
||||
return list(_clients.values())
|
||||
|
||||
|
||||
@app.get("/clients/{client_id}", dependencies=[Depends(_check_token)])
|
||||
def get_client(client_id: str):
|
||||
with _clients_lock:
|
||||
if client_id not in _clients:
|
||||
raise HTTPException(status_code=404, detail="Unknown client")
|
||||
return _clients[client_id]
|
||||
|
||||
|
||||
# ── Job completion ────────────────────────────────────────────
|
||||
|
||||
@app.post("/complete", dependencies=[Depends(_check_token)])
|
||||
def complete(result: JobResult):
|
||||
with _db() as conn:
|
||||
if result.success:
|
||||
conn.execute(
|
||||
"UPDATE jobs SET state='done', completed_at=?, error_msg=NULL WHERE job_id=?",
|
||||
(_now(), result.job_id),
|
||||
)
|
||||
print(f"[server] job {result.job_id} → done", flush=True)
|
||||
|
||||
# Auto-clear run if all jobs for this run_name are now done
|
||||
run_row = conn.execute(
|
||||
"SELECT run_name FROM jobs WHERE job_id=?", (result.job_id,)
|
||||
).fetchone()
|
||||
if run_row:
|
||||
run_name = run_row["run_name"]
|
||||
remaining = conn.execute(
|
||||
"SELECT COUNT(*) FROM jobs WHERE run_name=? AND state != 'done'",
|
||||
(run_name,)
|
||||
).fetchone()[0]
|
||||
if remaining == 0:
|
||||
total = conn.execute(
|
||||
"SELECT COUNT(*) FROM jobs WHERE run_name=?", (run_name,)
|
||||
).fetchone()[0]
|
||||
conn.execute("DELETE FROM jobs WHERE run_name=?", (run_name,))
|
||||
print(f"[server] run '{run_name}' complete ({total} jobs) — cleared", flush=True)
|
||||
else:
|
||||
row = conn.execute(
|
||||
"SELECT attempts FROM jobs WHERE job_id=?", (result.job_id,)
|
||||
).fetchone()
|
||||
attempts = (row["attempts"] if row else 0) + 1
|
||||
if attempts < _MAX_ATTEMPTS:
|
||||
conn.execute(
|
||||
"UPDATE jobs SET state='pending', assigned_to=NULL, started_at=NULL, "
|
||||
"attempts=?, error_msg=? WHERE job_id=?",
|
||||
(attempts, result.error_msg, result.job_id),
|
||||
)
|
||||
print(f"[server] job {result.job_id} failed (attempt {attempts}/{_MAX_ATTEMPTS}), "
|
||||
f"re-queuing", flush=True)
|
||||
else:
|
||||
conn.execute(
|
||||
"UPDATE jobs SET state='failed', completed_at=?, attempts=?, error_msg=? "
|
||||
"WHERE job_id=?",
|
||||
(_now(), attempts, result.error_msg, result.job_id),
|
||||
)
|
||||
print(f"[server] job {result.job_id} failed permanently after "
|
||||
f"{attempts} attempts", flush=True)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── Job queue management ──────────────────────────────────────
|
||||
|
||||
@app.post("/jobs", dependencies=[Depends(_check_token)])
|
||||
def submit_job(job: JobSubmit):
|
||||
job_id = str(uuid.uuid4())[:12]
|
||||
with _db() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO jobs "
|
||||
"(job_id, run_name, module, args, output_dir, priority, created_at) "
|
||||
"VALUES (?,?,?,?,?,?,?)",
|
||||
(job_id, job.run_name, job.module, json.dumps(job.args),
|
||||
job.output_dir, job.priority, _now()),
|
||||
)
|
||||
print(f"[server] queued {job_id} ({job.run_name})", flush=True)
|
||||
return {"job_id": job_id}
|
||||
|
||||
|
||||
@app.get("/jobs", dependencies=[Depends(_check_token)])
|
||||
def list_jobs(state: Optional[str] = None):
|
||||
with _db() as conn:
|
||||
if state:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM jobs WHERE state=? ORDER BY created_at DESC", (state,)
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM jobs ORDER BY created_at DESC"
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
@app.post("/jobs/clear", dependencies=[Depends(_check_token)])
|
||||
def clear_jobs(body: dict):
|
||||
with _db() as conn:
|
||||
if body.get("all"):
|
||||
cur = conn.execute("DELETE FROM jobs")
|
||||
elif body.get("run_name"):
|
||||
cur = conn.execute("DELETE FROM jobs WHERE run_name=?", (body["run_name"],))
|
||||
else:
|
||||
states = body.get("states", ["done", "failed", "cancelled"])
|
||||
placeholders = ",".join("?" * len(states))
|
||||
cur = conn.execute(f"DELETE FROM jobs WHERE state IN ({placeholders})", states)
|
||||
print(f"[server] cleared {cur.rowcount} jobs", flush=True)
|
||||
return {"cleared": cur.rowcount}
|
||||
|
||||
|
||||
@app.delete("/jobs/{job_id}", dependencies=[Depends(_check_token)])
|
||||
def cancel_job(job_id: str):
|
||||
with _db() as conn:
|
||||
conn.execute(
|
||||
"UPDATE jobs SET state='cancelled' WHERE job_id=? AND state='pending'",
|
||||
(job_id,),
|
||||
)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Entry point
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("--port", type=int, default=8765)
|
||||
ap.add_argument("--host", default="0.0.0.0")
|
||||
ap.add_argument("--token", default=os.environ.get("HT_TOKEN", ""),
|
||||
help="Shared secret (or set HT_TOKEN env var)")
|
||||
ap.add_argument("--db", default="v3/distributed/jobs.db",
|
||||
help="Path to SQLite job database")
|
||||
ap.add_argument("--client-ttl", type=int, default=120,
|
||||
help="Seconds of silence before a client is reaped (default: 120)")
|
||||
ap.add_argument("--max-attempts", type=int, default=3,
|
||||
help="Max times a failed job is retried before being left as failed (default: 3)")
|
||||
args = ap.parse_args()
|
||||
|
||||
if not args.token:
|
||||
ap.error("--token is required (or set HT_TOKEN)")
|
||||
|
||||
global _TOKEN, _DB_PATH, _CLIENT_TTL, _MAX_ATTEMPTS
|
||||
_TOKEN = args.token
|
||||
_DB_PATH = Path(args.db)
|
||||
_CLIENT_TTL = args.client_ttl
|
||||
_MAX_ATTEMPTS = args.max_attempts
|
||||
_init_db()
|
||||
|
||||
reaper = threading.Thread(target=_reap_stale_clients, daemon=True)
|
||||
reaper.start()
|
||||
|
||||
print(f"[server] listening on {args.host}:{args.port} client_ttl={_CLIENT_TTL}s", flush=True)
|
||||
uvicorn.run(app, host=args.host, port=args.port, log_level="warning")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user