Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
File size: 10,922 Bytes
da5cba1 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 | """Rollout records on disk: one folder per run, holding `run.json` and an append-only `events.jsonl`.
The folder is ./.local-runs when you run locally, and the private bucket mounted at /data on the
Space. Nothing here knows which: a mounted bucket is just a directory. Tokens are never written.
A rollout this process is running keeps its events on local disk first (appends and the run page's polls stay local)
and copies them to the store every FLUSH_EVERY seconds and when it ends, so a bucket isn't rewritten per event.
"""
from __future__ import annotations
import json
import shutil
import threading
import time
from pathlib import Path
from . import config
RUNS = config.STORAGE_DIR / "runs"
LOCAL = config.CACHE_DIR.parent / "live-runs" # events of the rollouts running here, before they reach the store
FLUSH_EVERY = 20.0
_local: dict[str, float] = {} # run id -> when its events last reached the store
_dirty: set[str] = set() # live rollouts with events the store hasn't got yet
_locks: dict[str, threading.Lock] = {}
_guard = threading.Lock()
def _lock(run_id: str) -> threading.Lock:
with _guard:
return _locks.setdefault(run_id, threading.Lock())
def _dir(run_id: str) -> Path:
if not run_id or "/" in run_id or ".." in run_id:
raise ValueError("bad run id")
return RUNS / run_id
def _write_json(path: Path, doc: dict) -> None:
"""Write-then-rename, so a crash mid-write never leaves a truncated run.json (the run would vanish)."""
tmp = path.with_name(f".{path.name}.{threading.get_ident()}.tmp")
tmp.write_text(json.dumps(doc, ensure_ascii=False, indent=1))
tmp.replace(path)
def create(run: dict) -> dict:
d = _dir(run["id"])
d.mkdir(parents=True, exist_ok=False)
run = {**run, "created_at": time.time(), "updated_at": time.time()}
_write_json(d / "run.json", run)
(d / "events.jsonl").touch()
if run.get("status", "queued") in ACTIVE: # this process runs it: its events start on local disk
(LOCAL / run["id"]).mkdir(parents=True, exist_ok=True)
(LOCAL / run["id"] / "events.jsonl").touch()
with _lock(run["id"]):
_local[run["id"]] = time.time()
_index_put(run)
return run
def get(run_id: str) -> dict | None:
p = _dir(run_id) / "run.json"
try:
return json.loads(p.read_text())
except (FileNotFoundError, json.JSONDecodeError):
return None
def update(run_id: str, **fields) -> dict:
with _lock(run_id):
run = get(run_id) or {}
run.update(fields, updated_at=time.time())
_write_json(_dir(run_id) / "run.json", run)
_index_put(run)
if run_id in _local and run.get("status") and run["status"] not in ACTIVE:
_end_local(run_id) # it ended: its events go to the store now, and stay there
return run
def _events(run_id: str) -> Path:
return LOCAL / run_id / "events.jsonl" if run_id in _local else _dir(run_id) / "events.jsonl"
def _flush(run_id: str, force: bool = False) -> None:
"""Copy a live rollout's events to the store (whole file, write-then-rename). Call with its lock held."""
last = _local.get(run_id)
if last is None or run_id not in _dirty or (not force and time.time() - last < FLUSH_EVERY):
return
src, dst = LOCAL / run_id / "events.jsonl", _dir(run_id) / "events.jsonl"
try:
tmp = dst.with_name(f".events.jsonl.{threading.get_ident()}.tmp")
shutil.copyfile(src, tmp)
tmp.replace(dst)
_local[run_id] = time.time()
_dirty.discard(run_id)
except FileNotFoundError:
pass
def _end_local(run_id: str) -> None:
with _lock(run_id):
if run_id not in _local:
return
_flush(run_id, force=True)
_local.pop(run_id, None)
_dirty.discard(run_id)
shutil.rmtree(LOCAL / run_id, ignore_errors=True)
def flush_due(force: bool = False) -> None:
"""Events of live rollouts that went quiet after their last append, to the store (the runner's watchdog calls this,
so another process reading the store, the admin Space, is never more than FLUSH_EVERY behind)."""
for run_id in list(_local):
with _lock(run_id):
_flush(run_id, force=force)
def _salvage(run_id: str) -> None:
"""A rollout this process ran before it stopped: what its local events had beyond the store's, kept."""
src = LOCAL / run_id / "events.jsonl"
try:
dst = _dir(run_id) / "events.jsonl"
if src.is_file() and src.stat().st_size > (dst.stat().st_size if dst.exists() else -1):
tmp = dst.with_name(f".events.jsonl.{threading.get_ident()}.tmp")
shutil.copyfile(src, tmp)
tmp.replace(dst)
except (OSError, ValueError):
return
shutil.rmtree(LOCAL / run_id, ignore_errors=True)
def append_events(run_id: str, events: list[dict]) -> None:
if not events:
return
with _lock(run_id):
with open(_events(run_id), "a", encoding="utf-8") as f:
for ev in events:
f.write(json.dumps(ev, ensure_ascii=False) + "\n")
if run_id in _local:
_dirty.add(run_id)
_flush(run_id)
def read_events(run_id: str, after: int = 0) -> list[dict]:
try:
with open(_events(run_id), encoding="utf-8") as f:
lines = f.readlines()
except FileNotFoundError:
return []
out = []
for line in lines[after:]:
try:
out.append(json.loads(line))
except json.JSONDecodeError:
break # a half-written last line: pick it up on the next poll
return out
def write_artifact(run_id: str, name: str, data: str | bytes) -> None:
p = _dir(run_id) / name
p.write_bytes(data if isinstance(data, bytes) else data.encode())
def has_artifact(run_id: str, name: str) -> bool:
if "/" in name or ".." in name:
return False
try:
return (_dir(run_id) / name).stat().st_size > 0
except (FileNotFoundError, ValueError):
return False
def read_artifact(run_id: str, name: str) -> bytes | None:
if "/" in name or ".." in name:
return None
try:
return (_dir(run_id) / name).read_bytes()
except FileNotFoundError:
return None
# An in-memory index of every run record. On the Space the store is a mounted bucket, where reading every run.json
# per request would be slow, so the index is read once and kept current: every write here updates it, and runs that
# can still change (running ones, or written in the last minutes) are re-read when their file changes, so a record
# another process writes (a test script, an older worker) never shows stale.
_index: dict[str, dict] | None = None
_mtimes: dict[str, float] = {}
_index_lock = threading.Lock()
_synced_at = 0.0
_full_at = 0.0
FRESH = 900 # seconds a finished run's record may still change; older ones are re-checked once a minute
def _mtime(run_id: str) -> float:
try:
return (_dir(run_id) / "run.json").stat().st_mtime
except (OSError, ValueError):
return 0.0
def _load_index() -> dict[str, dict]:
global _index
with _index_lock:
if _index is None:
idx = {}
if RUNS.is_dir():
for d in RUNS.iterdir():
r = get(d.name) if d.is_dir() and not d.name.startswith(".") else None
if r and r.get("id"):
idx[r["id"]] = r
_mtimes[r["id"]] = _mtime(r["id"])
_index = idx
return _index
def _sync(every: float = 3.0) -> None:
"""Pick up runs written by another process: new folders, and changed files of runs that can still change."""
global _synced_at, _full_at
now = time.time()
if now - _synced_at < every:
return
_synced_at = now
full = now - _full_at > 60
if full:
_full_at = now
idx = _load_index()
try:
names = [d.name for d in RUNS.iterdir() if not d.name.startswith(".")] if RUNS.is_dir() else []
except OSError:
return
for name in names:
r = idx.get(name)
if (not full and r is not None and r.get("status") not in ACTIVE and r.get("status") != "interrupted"
and now - (r.get("updated_at") or 0) > FRESH):
continue
m = _mtime(name)
if r is None or m != _mtimes.get(name):
fresh = get(name)
if fresh and fresh.get("id"):
with _index_lock:
idx[name] = fresh
_mtimes[name] = m
def refresh(max_age: float) -> None:
"""Re-read every run from disk if the index is older than `max_age` seconds: for a process that only reads runs
another one writes (the admin Space)."""
global _index, _synced_at
if time.time() - _synced_at > max_age:
with _index_lock:
_index = None
_load_index()
_synced_at = time.time()
def _index_put(run: dict) -> None:
if run.get("id"):
idx = _load_index()
with _index_lock:
idx[run["id"]] = run
_mtimes[run["id"]] = _mtime(run["id"])
def list_runs(user: str | None = None, task_id: str | None = None, limit: int = 200, public: bool | None = None,
domain: str | None = None, model: str | None = None) -> list[dict]:
_sync()
runs = [r for r in list(_load_index().values())
if (not user or r.get("user") == user) and (not task_id or r.get("task_id") == task_id)
and (public is None or (r.get("visibility") == "public") == public)
and (not domain or r.get("domain") == domain) and (not model or r.get("model") == model)]
runs.sort(key=lambda r: r.get("created_at", 0), reverse=True)
return runs[:limit]
STALE = 180 # a running rollout's record is touched every 20 s; one silent for this long has lost its worker
def mark_interrupted(live: set[str] | None = None) -> int:
"""Rollouts whose worker is gone (the process stopped): still 'active' on disk, not running here, and silent for
STALE seconds. Checked continuously, not only at startup, and never for a rollout another process is running."""
n = 0
now = time.time()
for r in list_runs(limit=100000):
if r.get("status") in ACTIVE and r["id"] not in (live or set()) and now - (r.get("updated_at") or 0) > STALE:
fresh = get(r["id"]) or r # the file, not the index: another process may have just touched it
if fresh.get("status") in ACTIVE and now - (fresh.get("updated_at") or 0) > STALE:
if r["id"] not in _local:
_salvage(r["id"])
update(r["id"], status="interrupted", error="The server restarted while this rollout was running, so it stopped. Run it again.")
n += 1
return n
ACTIVE = {"queued", "starting", "setup", "running", "verifying"}
|