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"}