hyperstudy-perception 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hyperstudy_perception-0.1.0.dist-info/METADATA +53 -0
- hyperstudy_perception-0.1.0.dist-info/RECORD +82 -0
- hyperstudy_perception-0.1.0.dist-info/WHEEL +4 -0
- hyperstudy_perception-0.1.0.dist-info/entry_points.txt +2 -0
- hyperstudy_perception-0.1.0.dist-info/licenses/LICENSE +21 -0
- perception/__init__.py +0 -0
- perception/_pipeline/RELEASE.json +59 -0
- perception/_pipeline/cloud/runpod/guard.sh +37 -0
- perception/_pipeline/cloud/runpod/image/Dockerfile +39 -0
- perception/_pipeline/cloud/runpod/image/start.sh +11 -0
- perception/_pipeline/cloud/runpod/image/tag.py +40 -0
- perception/_pipeline/cloud/skypilot/gpu-faces.yaml +39 -0
- perception/_pipeline/cloud/skypilot/gpu-tune.yaml +55 -0
- perception/_pipeline/cloud/skypilot/guard.sh +20 -0
- perception/_pipeline/cloud/skypilot/iam-perception-node.json +18 -0
- perception/_pipeline/cloud/skypilot/launch.sh +63 -0
- perception/_pipeline/cloud/skypilot/node-gpu.yaml +25 -0
- perception/_pipeline/cloud/skypilot/node-setup.sh +22 -0
- perception/_pipeline/cloud/skypilot/sky.yaml +7 -0
- perception/_pipeline/cloud/skypilot/smoke.yaml +28 -0
- perception/_pipeline/envs/api/pyproject.toml +12 -0
- perception/_pipeline/envs/api/uv.lock +276 -0
- perception/_pipeline/envs/audio/pyproject.toml +12 -0
- perception/_pipeline/envs/audio/uv.lock +575 -0
- perception/_pipeline/envs/core/pyproject.toml +12 -0
- perception/_pipeline/envs/core/uv.lock +157 -0
- perception/_pipeline/envs/faces/pyproject.toml +12 -0
- perception/_pipeline/envs/faces/uv.lock +1180 -0
- perception/_pipeline/graphs/decode.yaml +11 -0
- perception/_pipeline/graphs/faces.yaml +14 -0
- perception/_pipeline/graphs/full.yaml +31 -0
- perception/_pipeline/graphs/mvp-captions.yaml +27 -0
- perception/_pipeline/graphs/mvp.yaml +17 -0
- perception/_pipeline/nodes/audio/asr/manifest.yaml +20 -0
- perception/_pipeline/nodes/audio/asr/node.py +74 -0
- perception/_pipeline/nodes/audio/asr/prepare.py +12 -0
- perception/_pipeline/nodes/audio/asr/test_wav.py +38 -0
- perception/_pipeline/nodes/audio/asr/wav.py +19 -0
- perception/_pipeline/nodes/conftest.py +64 -0
- perception/_pipeline/nodes/core/decode/manifest.yaml +19 -0
- perception/_pipeline/nodes/core/decode/media.py +104 -0
- perception/_pipeline/nodes/core/decode/node.py +56 -0
- perception/_pipeline/nodes/core/decode/test_media.py +65 -0
- perception/_pipeline/nodes/testdata/README.md +10 -0
- perception/_pipeline/nodes/testdata/rotated-90.mp4 +0 -0
- perception/_pipeline/nodes/vision/face-track/frames.py +88 -0
- perception/_pipeline/nodes/vision/face-track/loader.py +18 -0
- perception/_pipeline/nodes/vision/face-track/manifest.yaml +30 -0
- perception/_pipeline/nodes/vision/face-track/node.py +117 -0
- perception/_pipeline/nodes/vision/face-track/prepare.py +8 -0
- perception/_pipeline/nodes/vision/face-track/test_frames.py +46 -0
- perception/_pipeline/nodes/vision/face-track/test_loader.py +16 -0
- perception/_pipeline/nodes/vision/face-track/test_thumbnails.py +66 -0
- perception/_pipeline/nodes/vision/face-track/thumbnails.py +68 -0
- perception/_pipeline/nodes/vision/face-track/tracking.py +150 -0
- perception/_pipeline/nodes/vlm/shot-captions/manifest.yaml +29 -0
- perception/_pipeline/nodes/vlm/shot-captions/node.py +185 -0
- perception/_pipeline/schema/README.md +54 -0
- perception/_pipeline/schema/bundle-0.1.schema.json +371 -0
- perception/_pipeline/sdk/pyproject.toml +13 -0
- perception/_pipeline/sdk/src/perception_node/__init__.py +157 -0
- perception/artifacts.py +180 -0
- perception/backends/__init__.py +0 -0
- perception/backends/base.py +179 -0
- perception/backends/cloud.py +214 -0
- perception/backends/native.py +140 -0
- perception/backends/probe.py +44 -0
- perception/backends/remote.py +140 -0
- perception/cli.py +780 -0
- perception/estimates.py +68 -0
- perception/export.py +100 -0
- perception/hosts.py +201 -0
- perception/ir.py +245 -0
- perception/keys.py +259 -0
- perception/manifests.py +169 -0
- perception/plan.py +170 -0
- perception/release.py +61 -0
- perception/remote_driver.py +990 -0
- perception/runpod.py +100 -0
- perception/scheduler.py +552 -0
- perception/setup.py +72 -0
- perception/store.py +219 -0
perception/runpod.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""RunPod's REST API (https://rest.runpod.io/v1), as perception uses it: create a pod, read it, delete it, list pods.
|
|
2
|
+
|
|
3
|
+
The account key comes from the user's own keys (RUNPOD_API_KEY in ~/.config/hyperstudy/*.env, or the environment) and is
|
|
4
|
+
used only on this machine: never written to a file, never sent to a pod. RunPod's own CLI and SkyPilot keep it in
|
|
5
|
+
~/.runpod/config.toml, which SkyPilot uploads to every machine it starts (AWS ones too), so that file is not used.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import http.client
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
import socket
|
|
15
|
+
import urllib.error
|
|
16
|
+
import urllib.request
|
|
17
|
+
|
|
18
|
+
API = "https://rest.runpod.io/v1"
|
|
19
|
+
# Cloudflare in front of the API refuses Python's default User-Agent (error 1010)
|
|
20
|
+
USER_AGENT = "hyperstudy-perception"
|
|
21
|
+
# a pod perception started: perception-<run>-<node>-<hash>-<attempt>-d<the deadline, as a Unix time>
|
|
22
|
+
POD_NAME = re.compile(r"^perception-[a-z0-9-]+-d(\d{9,11})$")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class RunPodError(Exception):
|
|
26
|
+
def __init__(self, status: int, message: str, sent: bool = True):
|
|
27
|
+
"""`status`: RunPod's HTTP status, or 0 for no whole answer. `sent`: whether the request may have reached RunPod
|
|
28
|
+
(not when its address could not be found or its connection was refused)."""
|
|
29
|
+
super().__init__(f"RunPod answered {status}: {message}" if status else f"RunPod could not be reached: {message}")
|
|
30
|
+
self.status, self.sent = status, sent
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def nothing_done(self) -> bool:
|
|
34
|
+
"""Whether the request is known to have done nothing: never sent, or a 4xx (not 429, a rate limit in front of it).
|
|
35
|
+
A 5xx, from RunPod or a gateway before it, may come after the work was done; no answer proves nothing either."""
|
|
36
|
+
return not self.sent or (400 <= self.status < 500 and self.status != 429)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class NoRedirects(urllib.request.HTTPRedirectHandler):
|
|
40
|
+
"""urllib would send the key on to wherever a redirect points (another host too): the API never redirects."""
|
|
41
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
|
42
|
+
return None # the 3xx is then raised as an HTTPError
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
OPENER = urllib.request.build_opener(NoRedirects)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def api_base() -> str:
|
|
49
|
+
return os.environ.get("PERCEPTION_RUNPOD_API", API) # the tests' stand-in server
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class RunPod:
|
|
53
|
+
def __init__(self, key: str, base: str | None = None, timeout: float = 60):
|
|
54
|
+
self.key, self.base, self.timeout = key, (base or api_base()).rstrip("/"), timeout
|
|
55
|
+
|
|
56
|
+
def call(self, method: str, path: str, body: dict | None = None):
|
|
57
|
+
req = urllib.request.Request(self.base + path, method=method, data=json.dumps(body).encode() if body is not None else None,
|
|
58
|
+
headers={"Authorization": f"Bearer {self.key}", "Content-Type": "application/json",
|
|
59
|
+
"User-Agent": USER_AGENT})
|
|
60
|
+
try:
|
|
61
|
+
with OPENER.open(req, timeout=self.timeout) as r:
|
|
62
|
+
text = r.read().decode()
|
|
63
|
+
except urllib.error.HTTPError as e:
|
|
64
|
+
try: # its body may be cut short too: the status is what counts
|
|
65
|
+
detail = e.read().decode(errors="replace").strip()[:1000] # a schema refusal names the field late
|
|
66
|
+
except (OSError, http.client.HTTPException) as cut:
|
|
67
|
+
detail = f"(its message cut short: {type(cut).__name__})"
|
|
68
|
+
raise RunPodError(e.code, detail) from None
|
|
69
|
+
# no whole answer (a timeout, a connection cut, an answer cut short: http.client's own errors are not OSErrors):
|
|
70
|
+
# the request may or may not have been done, unless it never left (no address, a refused connection)
|
|
71
|
+
except (urllib.error.URLError, OSError, http.client.HTTPException, UnicodeDecodeError) as e:
|
|
72
|
+
reason = getattr(e, "reason", e)
|
|
73
|
+
raise RunPodError(0, f"{type(e).__name__}: {reason}",
|
|
74
|
+
sent=not isinstance(reason, (socket.gaierror, ConnectionRefusedError))) from None
|
|
75
|
+
try:
|
|
76
|
+
return json.loads(text) if text.strip() else None
|
|
77
|
+
except json.JSONDecodeError:
|
|
78
|
+
raise RunPodError(0, f"an answer that is not JSON: {text[:200]!r}") from None
|
|
79
|
+
|
|
80
|
+
def create_pod(self, body: dict) -> dict:
|
|
81
|
+
return self.call("POST", "/pods", body)
|
|
82
|
+
|
|
83
|
+
def pod(self, pod_id: str) -> dict | None:
|
|
84
|
+
"""The pod, or None once it is gone (404)."""
|
|
85
|
+
try:
|
|
86
|
+
return self.call("GET", f"/pods/{pod_id}")
|
|
87
|
+
except RunPodError as e:
|
|
88
|
+
if e.status == 404:
|
|
89
|
+
return None
|
|
90
|
+
raise
|
|
91
|
+
|
|
92
|
+
def delete_pod(self, pod_id: str):
|
|
93
|
+
try:
|
|
94
|
+
self.call("DELETE", f"/pods/{pod_id}")
|
|
95
|
+
except RunPodError as e:
|
|
96
|
+
if e.status != 404: # already gone
|
|
97
|
+
raise
|
|
98
|
+
|
|
99
|
+
def pods(self) -> list[dict]:
|
|
100
|
+
return self.call("GET", "/pods") or []
|
perception/scheduler.py
ADDED
|
@@ -0,0 +1,552 @@
|
|
|
1
|
+
"""The coordinator: one per run, state in SQLite, so a run survives the coordinator.
|
|
2
|
+
|
|
3
|
+
Task states: pending (waiting on inputs or a retry delay) -> ready (inputs committed) -> running (submitted, leased
|
|
4
|
+
to this coordinator) -> succeeded | cached | failed | canceled. (`leased` is the brief state between choosing a
|
|
5
|
+
backend and the submit returning.)
|
|
6
|
+
|
|
7
|
+
Guarantees (spec, "Scheduler and execution policy"):
|
|
8
|
+
- A node is scheduled only when every input is committed.
|
|
9
|
+
- At-least-once execution, effectively-once publication: outputs are validated, then published under their content
|
|
10
|
+
hash, then the task row is updated, in that order. A crash between the steps leaves either nothing or an
|
|
11
|
+
unreferenced object, never a half-written artifact.
|
|
12
|
+
- A restarted coordinator (resume) looks at every task the previous one left running: if its process is still
|
|
13
|
+
alive it is adopted and waited for; if it finished, its response is collected; otherwise it is retried.
|
|
14
|
+
- Transient errors (and a process that died without a response) are retried with backoff, up to policy.retries.
|
|
15
|
+
input / schema / privacy / licence / internal errors are terminal.
|
|
16
|
+
- Independent nodes run in parallel within CPU, memory and GPU budgets; `gpu: exclusive` nodes run one at a time.
|
|
17
|
+
- A hard cost cap: a billable node is not submitted if its estimate would exceed what the run has left.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import asyncio
|
|
23
|
+
import hashlib
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
import stat
|
|
27
|
+
import time
|
|
28
|
+
import uuid
|
|
29
|
+
from dataclasses import dataclass
|
|
30
|
+
from pathlib import Path, PurePath
|
|
31
|
+
|
|
32
|
+
from . import artifacts
|
|
33
|
+
from .backends.base import Backend, child_env
|
|
34
|
+
from .ir import CompiledNode, Graph, Policy, compile_graph
|
|
35
|
+
from .manifests import Manifest, Registry, Runner
|
|
36
|
+
from .store import Artifact, Store, dumps
|
|
37
|
+
|
|
38
|
+
TERMINAL = {"succeeded", "cached", "failed", "canceled"}
|
|
39
|
+
DONE = {"succeeded", "cached"}
|
|
40
|
+
LICENCE_ORDER = ["commercial-ok", "api-terms", "needs-review", "academic-only"]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def output_path(out_dir: Path, rel) -> tuple[Path | None, str]:
|
|
44
|
+
"""Where output `rel` is, checked without trusting anything the node could have changed: `rel` must be a plain
|
|
45
|
+
relative path (checked before touching the disk), and no step from `out_dir` itself down to it may be a symlink.
|
|
46
|
+
Nothing is resolved: a node (or a container) can replace any of these entries with a link to a host path."""
|
|
47
|
+
parts = PurePath(rel).parts if isinstance(rel, str) and rel else ()
|
|
48
|
+
if not parts or PurePath(rel).is_absolute() or PurePath(rel).drive or any(p in (".", "..") for p in rel.replace("\\", "/").split("/")):
|
|
49
|
+
return None, f"{rel!r} is not a plain relative path"
|
|
50
|
+
steps = [out_dir]
|
|
51
|
+
for part in parts:
|
|
52
|
+
steps.append(steps[-1] / part)
|
|
53
|
+
for i, current in enumerate(steps):
|
|
54
|
+
try:
|
|
55
|
+
mode = os.lstat(current).st_mode
|
|
56
|
+
except FileNotFoundError:
|
|
57
|
+
return None, f"{rel} is missing"
|
|
58
|
+
if stat.S_ISLNK(mode):
|
|
59
|
+
return None, f"{current.name} is a symlink"
|
|
60
|
+
if i < len(steps) - 1 and not stat.S_ISDIR(mode):
|
|
61
|
+
return None, f"{current.name} is not a directory"
|
|
62
|
+
return steps[-1], ""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def manifest_cost(m: Manifest, config: dict, inputs: dict[str, Artifact]) -> float:
|
|
66
|
+
"""A billable node's upper estimate: its manifest's price per unit (for this config) times the units in its input."""
|
|
67
|
+
if m.cost is None:
|
|
68
|
+
return 0.0
|
|
69
|
+
if m.cost.per == "run":
|
|
70
|
+
return m.cost.unit_usd(config)
|
|
71
|
+
doc = json.loads(inputs[m.cost.input].path.read_text())
|
|
72
|
+
units = len(doc["shots"]) if m.cost.per == "shot" else doc["duration"] / 60
|
|
73
|
+
return round(m.cost.unit_usd(config) * units, 4)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class LeaseHeld(RuntimeError):
|
|
77
|
+
pass
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass
|
|
81
|
+
class Placement:
|
|
82
|
+
backend: Backend
|
|
83
|
+
runner: Runner
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def label(self) -> str:
|
|
87
|
+
"""Where it runs, as a graph's `place` and a task's runner name it: `native:mps`, `native:cpu`..."""
|
|
88
|
+
return f"{self.backend.kind}:{self.runner.accelerator}"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class Coordinator:
|
|
92
|
+
def __init__(self, store: Store, registry: Registry, backends: list[Backend], secrets: dict[str, str] | None = None,
|
|
93
|
+
lease_s: float = 30.0, tick_s: float = 0.2, backoff_s: float = 2.0):
|
|
94
|
+
self.store, self.registry = store, registry
|
|
95
|
+
self.backends = {b.kind: b for b in backends}
|
|
96
|
+
self.secrets = secrets or {}
|
|
97
|
+
self.lease_s, self.tick_s, self.backoff_s = lease_s, tick_s, backoff_s
|
|
98
|
+
self.owner = uuid.uuid4().hex
|
|
99
|
+
|
|
100
|
+
# --- runs -----------------------------------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
def create_run(self, graph: Graph, graph_doc: dict, allow_uploads: bool = False) -> str:
|
|
103
|
+
"""A run of `graph`. `allow_uploads`: its nodes may send data off the machine (within the graph's
|
|
104
|
+
policy.allow_upload); without it a node that would is failed before it starts, whenever it comes to run."""
|
|
105
|
+
run_id = time.strftime("%Y%m%d-%H%M%S-") + uuid.uuid4().hex[:6]
|
|
106
|
+
inputs = {name: self._graph_input(graph, name).to_json() for name in graph.spec.inputs}
|
|
107
|
+
with self.store.tx() as db:
|
|
108
|
+
db.execute("INSERT INTO runs (id, pipeline, graph, graph_path, inputs, status, created_at, max_cost_usd, allow_uploads) VALUES (?,?,?,?,?,?,?,?,?)",
|
|
109
|
+
(run_id, graph.spec.pipeline, dumps(graph_doc), str(graph.source) if graph.source else None,
|
|
110
|
+
dumps(inputs), "running", time.time(), graph.spec.policy.max_cost_usd, int(allow_uploads)))
|
|
111
|
+
for node_id in graph.nodes:
|
|
112
|
+
db.execute("INSERT INTO tasks (run_id, node_id, state) VALUES (?,?,?)", (run_id, node_id, "pending"))
|
|
113
|
+
self.store.event(run_id, None, "run.created", f"{len(graph.nodes)} nodes")
|
|
114
|
+
return run_id
|
|
115
|
+
|
|
116
|
+
def allow_uploads(self, run_id: str):
|
|
117
|
+
"""Let the run's nodes send data off the machine from now on (resume or rerun --allow-uploads)."""
|
|
118
|
+
with self.store.tx() as db:
|
|
119
|
+
db.execute("UPDATE runs SET allow_uploads=1 WHERE id=?", (run_id,))
|
|
120
|
+
|
|
121
|
+
def graph_of(self, run_id: str) -> Graph:
|
|
122
|
+
row = self.store.db.execute("SELECT graph, graph_path FROM runs WHERE id=?", (run_id,)).fetchone()
|
|
123
|
+
if row is None:
|
|
124
|
+
raise KeyError(f"no run {run_id}")
|
|
125
|
+
graph = compile_graph(json.loads(row["graph"]), self.registry)
|
|
126
|
+
graph.source = Path(row["graph_path"]) if row["graph_path"] else None
|
|
127
|
+
return graph
|
|
128
|
+
|
|
129
|
+
def rerun(self, run_id: str, from_node: str, use_cache: bool = True):
|
|
130
|
+
"""Reset `from_node` and everything downstream of it. Without the cache (`use_cache=False`) only `from_node`
|
|
131
|
+
is forced to run: downstream nodes run again only if its new outputs differ, and otherwise come from the
|
|
132
|
+
cache (spec: rerun only nodes whose declared inputs change)."""
|
|
133
|
+
graph = self.graph_of(run_id)
|
|
134
|
+
if from_node not in graph.nodes:
|
|
135
|
+
raise KeyError(f"run {run_id} has no node {from_node}")
|
|
136
|
+
reset = {from_node} | graph.descendants(from_node)
|
|
137
|
+
with self.store.tx() as db:
|
|
138
|
+
for node_id in reset:
|
|
139
|
+
db.execute("""UPDATE tasks SET state='pending', first_attempt=attempt+1, cache_key=NULL, runner=NULL, lease_owner=NULL,
|
|
140
|
+
lease_expires=NULL, handle=NULL, not_before=NULL, started_at=NULL, finished_at=NULL,
|
|
141
|
+
error_code=NULL, error=NULL, cost_usd=0, usage=NULL, outputs=NULL, skip_cache=?
|
|
142
|
+
WHERE run_id=? AND node_id=?""", (0 if use_cache or node_id != from_node else 1, run_id, node_id))
|
|
143
|
+
db.execute("UPDATE runs SET status='running', finished_at=NULL WHERE id=?", (run_id,))
|
|
144
|
+
self.store.event(run_id, None, "run.rerun", f"from {from_node}: {', '.join(sorted(reset))}")
|
|
145
|
+
|
|
146
|
+
def cancel(self, run_id: str):
|
|
147
|
+
"""Record the cancel first, then kill: once a task is canceled, a coordinator that sees its process die reports
|
|
148
|
+
nothing (_attempt_failed ignores tasks no longer running), so the kill is charged once, here."""
|
|
149
|
+
with self.store.tx() as db:
|
|
150
|
+
running = []
|
|
151
|
+
for t in self._tasks(run_id).values():
|
|
152
|
+
if t["state"] == "leased" and t["runner"]: # perhaps started before its coordinator recorded it running
|
|
153
|
+
handle = self.backends[t["runner"].split(":")[0]].recorded_handle(
|
|
154
|
+
self.store.root / "work" / run_id / t["node_id"] / str(t["attempt"]))
|
|
155
|
+
if handle:
|
|
156
|
+
t = {**t, "handle": json.dumps(handle)}
|
|
157
|
+
if t["state"] in ("running", "leased") and t["handle"]:
|
|
158
|
+
running.append(t)
|
|
159
|
+
# a killed attempt may have spent up to its reservation: it is charged that, as a crash is, and the
|
|
160
|
+
# reservation is used up (cost_usd 0), so nothing else charges it again
|
|
161
|
+
db.execute("UPDATE runs SET spent_usd = spent_usd + ? WHERE id=?", (sum(t["cost_usd"] or 0.0 for t in running), run_id))
|
|
162
|
+
for t in running:
|
|
163
|
+
db.execute("UPDATE tasks SET cost_usd=0 WHERE run_id=? AND node_id=?", (run_id, t["node_id"]))
|
|
164
|
+
db.execute("UPDATE tasks SET state='canceled', finished_at=? WHERE run_id=? AND state NOT IN ('succeeded','cached','failed')",
|
|
165
|
+
(time.time(), run_id))
|
|
166
|
+
db.execute("UPDATE runs SET status='canceled', finished_at=? WHERE id=?", (time.time(), run_id))
|
|
167
|
+
for t in running:
|
|
168
|
+
self.backends[t["runner"].split(":")[0]].cancel(json.loads(t["handle"]))
|
|
169
|
+
self.store.event(run_id, None, "run.canceled")
|
|
170
|
+
|
|
171
|
+
# --- the loop -------------------------------------------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
async def run(self, run_id: str) -> str:
|
|
174
|
+
graph = self.graph_of(run_id)
|
|
175
|
+
self._recover(run_id, graph)
|
|
176
|
+
last_heartbeat = 0.0
|
|
177
|
+
while True:
|
|
178
|
+
if self._canceled(run_id):
|
|
179
|
+
return self._stop_canceled(run_id)
|
|
180
|
+
tasks = self._tasks(run_id)
|
|
181
|
+
for t in tasks.values():
|
|
182
|
+
if t["state"] == "running" and t["lease_owner"] == self.owner:
|
|
183
|
+
self._check_running(run_id, graph, t)
|
|
184
|
+
tasks = self._tasks(run_id)
|
|
185
|
+
self._promote(run_id, graph, tasks)
|
|
186
|
+
tasks = self._tasks(run_id)
|
|
187
|
+
for node_id in graph.nodes: # topological order: upstream nodes are placed first
|
|
188
|
+
if tasks[node_id]["state"] == "ready":
|
|
189
|
+
self._start(run_id, graph, graph.nodes[node_id], tasks)
|
|
190
|
+
tasks = self._tasks(run_id)
|
|
191
|
+
if time.time() - last_heartbeat > self.lease_s / 3:
|
|
192
|
+
self.store.db.execute("UPDATE tasks SET lease_expires=? WHERE run_id=? AND lease_owner=? AND state='running'",
|
|
193
|
+
(time.time() + self.lease_s, run_id, self.owner))
|
|
194
|
+
last_heartbeat = time.time()
|
|
195
|
+
if all(t["state"] in TERMINAL for t in tasks.values()):
|
|
196
|
+
if self._canceled(run_id): # canceled since the top of this pass
|
|
197
|
+
return self._stop_canceled(run_id)
|
|
198
|
+
status = "succeeded" if all(t["state"] in DONE for t in tasks.values()) else "failed"
|
|
199
|
+
self.store.db.execute("UPDATE runs SET status=?, finished_at=? WHERE id=?", (status, time.time(), run_id))
|
|
200
|
+
self.store.event(run_id, None, f"run.{status}")
|
|
201
|
+
return status
|
|
202
|
+
await asyncio.sleep(self.tick_s) # the loop's own pace: node processes run independently of it
|
|
203
|
+
|
|
204
|
+
def _canceled(self, run_id: str) -> bool:
|
|
205
|
+
return self.store.db.execute("SELECT status FROM runs WHERE id=?", (run_id,)).fetchone()["status"] == "canceled"
|
|
206
|
+
|
|
207
|
+
def _stop_canceled(self, run_id: str) -> str:
|
|
208
|
+
"""The run was canceled, perhaps by another process (cancel() recorded it and said so). This coordinator works from
|
|
209
|
+
snapshots, so it may have retried or started a node since: stop what it has running, and leave every unfinished
|
|
210
|
+
task canceled."""
|
|
211
|
+
killed = 0.0
|
|
212
|
+
for t in self._tasks(run_id).values():
|
|
213
|
+
if t["state"] in ("running", "leased") and t["lease_owner"] == self.owner and t["handle"]:
|
|
214
|
+
self.backends[t["runner"].split(":")[0]].cancel(json.loads(t["handle"]))
|
|
215
|
+
killed += t["cost_usd"] or 0.0 # charged as cancel() charges what it kills
|
|
216
|
+
with self.store.tx():
|
|
217
|
+
self.store.db.execute("UPDATE runs SET spent_usd = spent_usd + ? WHERE id=?", (killed, run_id))
|
|
218
|
+
self.store.db.execute("UPDATE tasks SET state='canceled', finished_at=COALESCE(finished_at, ?) WHERE run_id=? AND state NOT IN ('succeeded','cached','failed','canceled')",
|
|
219
|
+
(time.time(), run_id))
|
|
220
|
+
return "canceled"
|
|
221
|
+
|
|
222
|
+
def _tasks(self, run_id: str) -> dict[str, dict]:
|
|
223
|
+
return {r["node_id"]: dict(r) for r in self.store.db.execute("SELECT * FROM tasks WHERE run_id=?", (run_id,))}
|
|
224
|
+
|
|
225
|
+
def _recover(self, run_id: str, graph: Graph):
|
|
226
|
+
"""Take over a run another coordinator left (it crashed, or was stopped). Only expired leases are taken: a
|
|
227
|
+
coordinator that is still alive renews its leases every lease_s / 3."""
|
|
228
|
+
tasks = self._tasks(run_id)
|
|
229
|
+
live = [t for t in tasks.values() if t["state"] in ("leased", "running") and t["lease_owner"] not in (None, self.owner)
|
|
230
|
+
and (t["lease_expires"] or 0) > time.time()]
|
|
231
|
+
if live:
|
|
232
|
+
wait = max(t["lease_expires"] for t in live) - time.time()
|
|
233
|
+
raise LeaseHeld(f"run {run_id} is held by another coordinator (its lease on {live[0]['node_id']} runs "
|
|
234
|
+
f"{wait:.0f} s more); if that process is gone, resume after the lease expires")
|
|
235
|
+
self.store.db.execute("UPDATE runs SET status='running', finished_at=NULL WHERE id=? AND status!='canceled'", (run_id,))
|
|
236
|
+
for t in tasks.values():
|
|
237
|
+
if t["state"] == "leased":
|
|
238
|
+
# chosen; perhaps started (its process recorded a handle) before the coordinator could record it running
|
|
239
|
+
handle = self.backends[t["runner"].split(":")[0]].recorded_handle(
|
|
240
|
+
self.store.root / "work" / run_id / t["node_id"] / str(t["attempt"])) if t["runner"] else None
|
|
241
|
+
if handle is None: # never submitted
|
|
242
|
+
self._set(run_id, t["node_id"], state="pending", lease_owner=None, lease_expires=None)
|
|
243
|
+
continue
|
|
244
|
+
self._set(run_id, t["node_id"], state="running", handle=dumps(handle), started_at=time.time())
|
|
245
|
+
t = self._tasks(run_id)[t["node_id"]]
|
|
246
|
+
if t["state"] == "running" and t["lease_owner"] != self.owner:
|
|
247
|
+
backend = self.backends[t["runner"].split(":")[0]]
|
|
248
|
+
handle = json.loads(t["handle"])
|
|
249
|
+
still = backend.poll(handle)
|
|
250
|
+
self._set(run_id, t["node_id"], lease_owner=self.owner, lease_expires=time.time() + self.lease_s)
|
|
251
|
+
self.store.event(run_id, t["node_id"], "task.adopted", f"attempt {t['attempt']} {'still running' if still == 'running' else 'finished while no coordinator watched'}")
|
|
252
|
+
|
|
253
|
+
def _promote(self, run_id: str, graph: Graph, tasks: dict[str, dict]):
|
|
254
|
+
now = time.time()
|
|
255
|
+
for node_id, node in graph.nodes.items():
|
|
256
|
+
t = tasks[node_id]
|
|
257
|
+
if t["state"] != "pending" or (t["not_before"] and t["not_before"] > now):
|
|
258
|
+
continue
|
|
259
|
+
ups = [tasks[u]["state"] for u in node.upstream]
|
|
260
|
+
if any(s in ("failed", "canceled") for s in ups):
|
|
261
|
+
self._set(run_id, node_id, state="canceled", error_code="upstream", error="an upstream node failed", finished_at=now)
|
|
262
|
+
self.store.event(run_id, node_id, "task.canceled", "upstream failed")
|
|
263
|
+
elif all(s in DONE for s in ups):
|
|
264
|
+
self._set(run_id, node_id, state="ready")
|
|
265
|
+
|
|
266
|
+
# --- starting a task ------------------------------------------------------------------------------------
|
|
267
|
+
|
|
268
|
+
def _inputs(self, run_id: str, graph: Graph, node: CompiledNode, tasks: dict[str, dict]) -> dict[str, Artifact]:
|
|
269
|
+
run_inputs = json.loads(self.store.db.execute("SELECT inputs FROM runs WHERE id=?", (run_id,)).fetchone()["inputs"])
|
|
270
|
+
out = {}
|
|
271
|
+
for name, ref in node.inputs.items():
|
|
272
|
+
if ref.node is None:
|
|
273
|
+
out[name] = Artifact.from_json(run_inputs[ref.name])
|
|
274
|
+
else:
|
|
275
|
+
out[name] = Artifact.from_json(json.loads(tasks[ref.node]["outputs"])[ref.name])
|
|
276
|
+
return out
|
|
277
|
+
|
|
278
|
+
def placements(self, manifest: Manifest, wanted: str = "auto", policy: Policy | None = None) -> tuple[list[Placement], str]:
|
|
279
|
+
"""Every place this node can run, best first: this machine's (accelerated runners before CPU ones), then other
|
|
280
|
+
machines' (lab servers, cloud), which a graph's policy must allow: they receive the node's inputs. Why none,
|
|
281
|
+
when none."""
|
|
282
|
+
options, reasons = self._placements(manifest, wanted, policy)
|
|
283
|
+
return options, "" if options else "; ".join(f"{k}: {v}" for k, v in reasons.items()) or \
|
|
284
|
+
f"no runner of kind {wanted if wanted != 'auto' else 'native or self'}"
|
|
285
|
+
|
|
286
|
+
def _placements(self, manifest: Manifest, wanted: str, policy: Policy | None) -> tuple[list[Placement], dict[str, str]]:
|
|
287
|
+
"""The places, and why each backend that offers none cannot run it (backends without its kind of runner aside)."""
|
|
288
|
+
local = [b for b in self.backends.values() if not b.remote]
|
|
289
|
+
remote = [b for b in self.backends.values() if b.remote]
|
|
290
|
+
chosen = [self.backends[wanted]] if wanted != "auto" and wanted in self.backends else local + remote if wanted == "auto" else []
|
|
291
|
+
options, reasons = [], {}
|
|
292
|
+
for backend in chosen:
|
|
293
|
+
runners, why = backend.runners_for(manifest)
|
|
294
|
+
# Apple silicon's memory is shared by CPU and GPU: a node that runs on the GPU there has no reason to be
|
|
295
|
+
# offered the CPU too (a CUDA machine's CPU fallback stays a choice: its GPU memory is its own)
|
|
296
|
+
if any(r.accelerator == "mps" for r in runners):
|
|
297
|
+
runners = [r for r in runners if r.accelerator != "cpu"]
|
|
298
|
+
if runners and backend.remote and (refusal := self.remote_policy(manifest, backend, policy)):
|
|
299
|
+
runners, why = [], refusal
|
|
300
|
+
options += [Placement(backend, r) for r in runners]
|
|
301
|
+
if not runners and not why.startswith("no "):
|
|
302
|
+
reasons[backend.kind] = why
|
|
303
|
+
return options, reasons
|
|
304
|
+
|
|
305
|
+
@staticmethod
|
|
306
|
+
def remote_policy(manifest: Manifest, backend: Backend, policy: Policy | None) -> str:
|
|
307
|
+
"""Why the graph's policy keeps this node off another machine, or "". Without a graph (doctor, nodes), only
|
|
308
|
+
what could run there is asked, not what may."""
|
|
309
|
+
if policy is None:
|
|
310
|
+
return ""
|
|
311
|
+
if policy.placement == "local" or policy.privacy == "local-only":
|
|
312
|
+
return "the graph keeps its nodes on this machine (policy.placement / policy.privacy)"
|
|
313
|
+
if not_allowed := [k for k in backend.sends(manifest) if k not in policy.allow_upload]:
|
|
314
|
+
return f"its inputs hold {', '.join(not_allowed)}, which policy.allow_upload does not let leave this machine"
|
|
315
|
+
return ""
|
|
316
|
+
|
|
317
|
+
def place(self, manifest: Manifest, wanted: str = "auto", choice: str | None = None,
|
|
318
|
+
policy: Policy | None = None) -> tuple[Placement | None, str]:
|
|
319
|
+
"""Where this node runs: the graph's choice (`place`, as `native:cpu` or `ssh-gpu1:cuda`), else the best place
|
|
320
|
+
on this machine. Another machine is only ever chosen, never a default: it receives the node's inputs."""
|
|
321
|
+
options, reasons = self._placements(manifest, wanted, policy)
|
|
322
|
+
why = "" if options else "; ".join(f"{k}: {v}" for k, v in reasons.items()) or f"no runner of kind {wanted}"
|
|
323
|
+
if choice is None:
|
|
324
|
+
here = [p for p in options if not p.backend.remote]
|
|
325
|
+
if here:
|
|
326
|
+
return here[0], ""
|
|
327
|
+
if options:
|
|
328
|
+
return None, f"it cannot run on this machine; it can run on {', '.join(p.label for p in options)} (choose one with --place)"
|
|
329
|
+
return None, why
|
|
330
|
+
chosen = next((p for p in options if p.label == choice), None)
|
|
331
|
+
if chosen is None:
|
|
332
|
+
listed = f" ({', '.join(p.label for p in options)})" if options else ""
|
|
333
|
+
reason = reasons.get(choice.split(":")[0]) or (why if not options else "")
|
|
334
|
+
return None, f"{choice} is not one of the places it can run{listed}" + (f": {reason}" if reason else "")
|
|
335
|
+
return chosen, ""
|
|
336
|
+
|
|
337
|
+
def uploads(self, manifest: Manifest, placement: Placement) -> tuple[list[str], str | None]:
|
|
338
|
+
"""The kinds of data that leave this machine when the node runs at `placement`, and where they go (None: nothing
|
|
339
|
+
leaves): to the API it calls (its manifest's privacy), or with its inputs to the other machine it runs on. Running
|
|
340
|
+
on another machine always sends something there, even inputs that hold none of the kinds."""
|
|
341
|
+
if manifest.privacy.external_upload:
|
|
342
|
+
return list(manifest.privacy.external_upload), manifest.service or "an API"
|
|
343
|
+
if placement.backend.remote:
|
|
344
|
+
return placement.backend.sends(manifest), placement.backend.title
|
|
345
|
+
return [], None
|
|
346
|
+
|
|
347
|
+
def cache_key(self, node: CompiledNode, placement: Placement, inputs: dict[str, Artifact]) -> str:
|
|
348
|
+
m = node.manifest
|
|
349
|
+
key = {
|
|
350
|
+
"node": m.ref,
|
|
351
|
+
"runner": placement.backend.identity(m, placement.runner),
|
|
352
|
+
"config": node.config,
|
|
353
|
+
"inputs": {name: [a.type, a.hash] for name, a in sorted(inputs.items())},
|
|
354
|
+
"outputs": m.outputs,
|
|
355
|
+
"partition": None,
|
|
356
|
+
}
|
|
357
|
+
return hashlib.sha256(artifacts.canonical(key).encode()).hexdigest()
|
|
358
|
+
|
|
359
|
+
def _start(self, run_id: str, graph: Graph, node: CompiledNode, tasks: dict[str, dict]):
|
|
360
|
+
t, m = tasks[node.id], node.manifest
|
|
361
|
+
placement, why = self.place(m, node.spec.backend, node.spec.place, graph.spec.policy)
|
|
362
|
+
if placement is None:
|
|
363
|
+
self._fail(run_id, node.id, "input", f"no backend can run {m.ref}: {why}")
|
|
364
|
+
return
|
|
365
|
+
inputs = self._inputs(run_id, graph, node, tasks)
|
|
366
|
+
key = self.cache_key(node, placement, inputs)
|
|
367
|
+
|
|
368
|
+
if not t["skip_cache"]:
|
|
369
|
+
hit = self.store.db.execute("SELECT outputs, usage FROM cache WHERE key=?", (key,)).fetchone()
|
|
370
|
+
if hit:
|
|
371
|
+
outs = {k: Artifact.from_json(v) for k, v in json.loads(hit["outputs"]).items()}
|
|
372
|
+
if all(self.store.has(a) for a in outs.values()):
|
|
373
|
+
self._set(run_id, node.id, state="cached", cache_key=key, outputs=dumps({k: a.to_json() for k, a in outs.items()}),
|
|
374
|
+
usage=hit["usage"], finished_at=time.time())
|
|
375
|
+
self.store.event(run_id, node.id, "task.cached", key[:12])
|
|
376
|
+
return
|
|
377
|
+
|
|
378
|
+
# consent to send data off is the run's (--allow-uploads), checked when the node is about to run: a resume or a
|
|
379
|
+
# rerun, or a cache that is gone, cannot make a node upload in a run that was not allowed to
|
|
380
|
+
sent, to = self.uploads(m, placement)
|
|
381
|
+
if to and not self.store.db.execute("SELECT allow_uploads FROM runs WHERE id=?", (run_id,)).fetchone()[0]:
|
|
382
|
+
self._fail(run_id, node.id, "policy", f"{m.ref} sends {', '.join(sent) or 'its inputs'} off this machine (to {to}), "
|
|
383
|
+
"and this run may not (--allow-uploads)")
|
|
384
|
+
return
|
|
385
|
+
if not self._fits(run_id, graph, m, placement, tasks):
|
|
386
|
+
return # stays ready; started when running nodes free their share
|
|
387
|
+
estimate = placement.backend.estimate(m, placement.runner, inputs)
|
|
388
|
+
estimate.cost_usd += manifest_cost(m, node.config, inputs)
|
|
389
|
+
run = self.store.db.execute("SELECT max_cost_usd, spent_usd FROM runs WHERE id=?", (run_id,)).fetchone()
|
|
390
|
+
reserved = sum(x["cost_usd"] or 0.0 for x in tasks.values() if x["state"] in ("leased", "running"))
|
|
391
|
+
if estimate.cost_usd > 0 and run["spent_usd"] + reserved + estimate.cost_usd > run["max_cost_usd"]:
|
|
392
|
+
self._fail(run_id, node.id, "budget", f"estimated ${estimate.cost_usd:.2f} would exceed the run's cap of ${run['max_cost_usd']:.2f}")
|
|
393
|
+
return
|
|
394
|
+
|
|
395
|
+
if self._canceled(run_id): # the last moment before anything starts or is reserved
|
|
396
|
+
return
|
|
397
|
+
attempt = t["attempt"] + 1
|
|
398
|
+
attempt_dir = self.store.root / "work" / run_id / node.id / str(attempt)
|
|
399
|
+
attempt_dir.mkdir(parents=True, exist_ok=False)
|
|
400
|
+
# reserved from the moment it is chosen: a coordinator that dies before recording it running leaves the
|
|
401
|
+
# reservation with the task that the next one adopts
|
|
402
|
+
self._set(run_id, node.id, state="leased", attempt=attempt, cache_key=key, lease_owner=self.owner,
|
|
403
|
+
lease_expires=time.time() + self.lease_s, runner=placement.label,
|
|
404
|
+
cost_usd=estimate.cost_usd)
|
|
405
|
+
request = {
|
|
406
|
+
"protocol": 1, "run": run_id, "node": node.id, "uses": m.ref, "attempt": attempt, "partition": None,
|
|
407
|
+
"accelerator": placement.runner.accelerator,
|
|
408
|
+
"inputs": placement.backend.stage_inputs(attempt_dir, inputs), "config": node.config,
|
|
409
|
+
"out_dir": str(placement.backend.output_dir(attempt_dir)), "secrets": m.secrets,
|
|
410
|
+
"producer": {"stage": m.stage or m.id, "stage_version": m.version, "models": [x.model_dump(exclude={"size_gb", "gated"}) for x in m.models]},
|
|
411
|
+
}
|
|
412
|
+
(attempt_dir / "request.json").write_text(json.dumps(request, indent=1))
|
|
413
|
+
try:
|
|
414
|
+
handle = placement.backend.submit(m, placement.runner, attempt_dir, child_env(m.secrets, self.secrets))
|
|
415
|
+
except Exception as e: # could not even start (a missing tool): transient, the retry policy decides
|
|
416
|
+
self._attempt_failed(run_id, graph, node.id, attempt, "transient", f"submit failed: {e}")
|
|
417
|
+
return
|
|
418
|
+
with self.store.tx(): # recorded running only if nothing (a cancel) changed the task while it was submitted
|
|
419
|
+
now = self._tasks(run_id)[node.id]
|
|
420
|
+
if now["state"] != "leased" or self._canceled(run_id):
|
|
421
|
+
placement.backend.cancel(handle) # canceled meanwhile: stop it now; it may have begun spending
|
|
422
|
+
# charged what is left of its reservation (cancel() charges it, and uses it up, when it saw the process)
|
|
423
|
+
self.store.db.execute("UPDATE runs SET spent_usd = spent_usd + ? WHERE id=?", (now["cost_usd"] or 0.0, run_id))
|
|
424
|
+
self._set(run_id, node.id, handle=dumps(handle), cost_usd=0)
|
|
425
|
+
return
|
|
426
|
+
self._set(run_id, node.id, state="running", handle=dumps(handle), started_at=time.time(), cost_usd=estimate.cost_usd)
|
|
427
|
+
self.store.event(run_id, node.id, "task.started", f"attempt {attempt} on {placement.backend.kind} ({placement.runner.accelerator})")
|
|
428
|
+
|
|
429
|
+
def _fits(self, run_id: str, graph: Graph, m: Manifest, placement: Placement, tasks: dict[str, dict]) -> bool:
|
|
430
|
+
"""Within the CPU, memory and GPU of the machine it would run on, beside the nodes already running there."""
|
|
431
|
+
machine = placement.backend.machine
|
|
432
|
+
if machine is None:
|
|
433
|
+
return True # a machine of its own
|
|
434
|
+
caps = placement.backend.probe_capabilities()
|
|
435
|
+
running = [graph.nodes[i].manifest.resources for i, x in tasks.items() if x["state"] in ("running", "leased")
|
|
436
|
+
and x["runner"] and self.backends[x["runner"].split(":")[0]].machine == machine]
|
|
437
|
+
if not running:
|
|
438
|
+
return True # a node bigger than the machine still runs, alone
|
|
439
|
+
r = m.resources
|
|
440
|
+
if r.gpu == "exclusive" and any(x.gpu != "none" for x in running):
|
|
441
|
+
return False
|
|
442
|
+
if r.gpu != "none" and any(x.gpu == "exclusive" for x in running):
|
|
443
|
+
return False
|
|
444
|
+
cpu = sum(x.cpu_cores for x in running) + r.cpu_cores
|
|
445
|
+
mem = sum(x.memory_gb for x in running) + r.memory_gb
|
|
446
|
+
return cpu <= caps.cpu_cores and mem <= caps.memory_gb * 0.8
|
|
447
|
+
|
|
448
|
+
# --- finishing a task -----------------------------------------------------------------------------------
|
|
449
|
+
|
|
450
|
+
def _check_running(self, run_id: str, graph: Graph, t: dict):
|
|
451
|
+
backend = self.backends[t["runner"].split(":")[0]]
|
|
452
|
+
handle = json.loads(t["handle"])
|
|
453
|
+
if backend.poll(handle) == "running":
|
|
454
|
+
return
|
|
455
|
+
attempt_dir = self.store.root / "work" / run_id / t["node_id"] / str(t["attempt"])
|
|
456
|
+
response = backend.collect(attempt_dir)
|
|
457
|
+
backend.cleanup(attempt_dir)
|
|
458
|
+
node = graph.nodes[t["node_id"]]
|
|
459
|
+
if response is None:
|
|
460
|
+
# it reported nothing, so it may have spent up to what was reserved for it: charge that
|
|
461
|
+
self._attempt_failed(run_id, graph, node.id, t["attempt"], "transient",
|
|
462
|
+
f"the node exited without a response (crashed or killed); see {attempt_dir / 'log.txt'}",
|
|
463
|
+
usd=t["cost_usd"] or 0.0)
|
|
464
|
+
return
|
|
465
|
+
spent = float(response.get("usage", {}).get("usd", 0.0)) # what a failed attempt spent is still spent
|
|
466
|
+
if not response["ok"]:
|
|
467
|
+
err = response["error"]
|
|
468
|
+
self._attempt_failed(run_id, graph, node.id, t["attempt"], err["code"], err["message"], usd=spent)
|
|
469
|
+
return
|
|
470
|
+
problems, outs = self._publish(node, backend.output_dir(attempt_dir), response)
|
|
471
|
+
if problems:
|
|
472
|
+
self._attempt_failed(run_id, graph, node.id, t["attempt"], problems[0][0], "; ".join(p for _, p in problems), usd=spent)
|
|
473
|
+
return
|
|
474
|
+
usage = {**response.get("usage", {}), "finished_at": time.strftime("%Y-%m-%dT%H:%M:%S+00:00", time.gmtime())}
|
|
475
|
+
cost = float(usage.get("usd", 0.0))
|
|
476
|
+
out_json = dumps({k: a.to_json() for k, a in outs.items()})
|
|
477
|
+
with self.store.tx() as db:
|
|
478
|
+
now = self._tasks(run_id)[node.id]
|
|
479
|
+
if now["state"] != "running" or now["attempt"] != t["attempt"]:
|
|
480
|
+
return # canceled (and charged its reservation) while it finished: it stays canceled
|
|
481
|
+
db.execute("INSERT OR REPLACE INTO cache VALUES (?,?,?,?,?)", (t["cache_key"], node.manifest.ref, out_json, dumps(usage), time.time()))
|
|
482
|
+
db.execute("""UPDATE tasks SET state='succeeded', outputs=?, usage=?, cost_usd=?, finished_at=?, lease_owner=NULL,
|
|
483
|
+
lease_expires=NULL, error_code=NULL, error=NULL WHERE run_id=? AND node_id=?""",
|
|
484
|
+
(out_json, dumps(usage), cost, time.time(), run_id, node.id))
|
|
485
|
+
db.execute("UPDATE runs SET spent_usd = spent_usd + ? WHERE id=?", (cost, run_id))
|
|
486
|
+
self.store.event(run_id, node.id, "task.succeeded", f"{usage.get('seconds', '?')} s")
|
|
487
|
+
|
|
488
|
+
def _publish(self, node: CompiledNode, out_dir: Path, response: dict) -> tuple[list[tuple[str, str]], dict[str, Artifact]]:
|
|
489
|
+
"""Validate every declared output, check the models it reports, then publish. Nothing is published unless all
|
|
490
|
+
of it is valid."""
|
|
491
|
+
m, problems, ready = node.manifest, [], {}
|
|
492
|
+
declared = {x.id: x.licence_mode for x in m.models}
|
|
493
|
+
for used in response.get("models", []):
|
|
494
|
+
if used["id"] not in declared:
|
|
495
|
+
problems.append(("licence", f"the node used {used['id']}, which its manifest does not declare"))
|
|
496
|
+
elif LICENCE_ORDER.index(used["licence_mode"]) > LICENCE_ORDER.index(declared[used["id"]]):
|
|
497
|
+
problems.append(("licence", f"{used['id']} reported {used['licence_mode']}, stricter than the manifest's {declared[used['id']]}"))
|
|
498
|
+
for name, type_id in m.outputs.items():
|
|
499
|
+
entry = response["outputs"].get(name)
|
|
500
|
+
if entry is None:
|
|
501
|
+
problems.append(("schema", f"output {name} was not written"))
|
|
502
|
+
continue
|
|
503
|
+
path, why = output_path(out_dir, entry.get("path"))
|
|
504
|
+
if path is None:
|
|
505
|
+
problems.append(("schema", f"output {name}: {why}"))
|
|
506
|
+
continue
|
|
507
|
+
special = artifacts.special_files(path)
|
|
508
|
+
if special:
|
|
509
|
+
problems.append(("schema", f"output {name}: contains a symlink or special file: {', '.join(special[:5])}"))
|
|
510
|
+
continue
|
|
511
|
+
for p in artifacts.get(type_id).validate(path):
|
|
512
|
+
problems.append(("schema", f"output {name} ({type_id}): {p}"))
|
|
513
|
+
ready[name] = (path, type_id)
|
|
514
|
+
if problems:
|
|
515
|
+
return problems, {}
|
|
516
|
+
return [], {name: self.store.publish(path, type_id) for name, (path, type_id) in ready.items()}
|
|
517
|
+
|
|
518
|
+
def _attempt_failed(self, run_id: str, graph: Graph, node_id: str, attempt: int, code: str, message: str, usd: float = 0.0):
|
|
519
|
+
"""Retry a transient failure within the policy, else fail. `usd`, what the attempt spent, is charged in the same
|
|
520
|
+
transaction: a coordinator that dies in between must not charge it again on resume."""
|
|
521
|
+
retries = graph.spec.policy.retries
|
|
522
|
+
with self.store.tx():
|
|
523
|
+
now = self._tasks(run_id)[node_id]
|
|
524
|
+
if now["state"] not in ("running", "leased") or now["attempt"] != attempt:
|
|
525
|
+
return # a stale report: the task was canceled (and charged) or has moved on since it was read
|
|
526
|
+
first = now["first_attempt"]
|
|
527
|
+
tried = attempt - first + 1
|
|
528
|
+
if usd:
|
|
529
|
+
self.store.db.execute("UPDATE runs SET spent_usd = spent_usd + ? WHERE id=?", (usd, run_id))
|
|
530
|
+
if code == "transient" and tried <= retries:
|
|
531
|
+
delay = self.backoff_s * 2 ** (tried - 1)
|
|
532
|
+
self._set(run_id, node_id, state="pending", not_before=time.time() + delay, lease_owner=None, lease_expires=None,
|
|
533
|
+
handle=None, error_code=code, error=message)
|
|
534
|
+
self.store.event(run_id, node_id, "task.retry", f"attempt {attempt} failed ({message}); retry in {delay:.0f} s")
|
|
535
|
+
else:
|
|
536
|
+
self._fail(run_id, node_id, code, message)
|
|
537
|
+
|
|
538
|
+
def _fail(self, run_id: str, node_id: str, code: str, message: str):
|
|
539
|
+
self._set(run_id, node_id, state="failed", error_code=code, error=message, finished_at=time.time(), lease_owner=None, lease_expires=None)
|
|
540
|
+
self.store.event(run_id, node_id, "task.failed", f"{code}: {message}")
|
|
541
|
+
|
|
542
|
+
def _set(self, run_id: str, node_id: str, **fields):
|
|
543
|
+
cols = ", ".join(f"{k}=?" for k in fields)
|
|
544
|
+
self.store.db.execute(f"UPDATE tasks SET {cols} WHERE run_id=? AND node_id=?", (*fields.values(), run_id, node_id))
|
|
545
|
+
|
|
546
|
+
def _graph_input(self, graph: Graph, name: str) -> Artifact:
|
|
547
|
+
spec = graph.spec.inputs[name]
|
|
548
|
+
path = graph.input_path(name)
|
|
549
|
+
if not path.exists():
|
|
550
|
+
raise FileNotFoundError(f"graph input {name}: {path} does not exist")
|
|
551
|
+
kind = artifacts.get(spec.type)
|
|
552
|
+
return self.store.source(path, spec.type) if kind.shape == "source" else self.store.publish(path, spec.type)
|