Skip to content

Commit 9ed1c0b

Browse files
committed
[feat.infra.Ω.D9+] make Motif GPU jobs cross the real Kueue boundary
GPU declarations were previously scheduler contracts without an InvocationService data plane. This adds a fenced ExecutionRequest worker path, Restricted-PSS PVC mounts, remote cancellation, source/version and artifact integrity checks, and deploy wiring so the public API reaches the admitted RTX worker while PostgreSQL remains authoritative.
1 parent 56edd4a commit 9ed1c0b

13 files changed

Lines changed: 921 additions & 19 deletions

‎.gitignore‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ dev-key.pem
3333

3434
data/sym/syminfo.lib
3535
backend/env/
36+
/.runtime/
3637

3738
# a tracked .pyc dirties the worktree on every import (it happened)
3839
__pycache__/

‎backend/executors/__init__.py‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
"""Scheduler adapters for fixed, versioned Dirac worker entrypoints."""
22

33
from .local_process import LocalProcessAdapter
4-
from .kubernetes_kueue import KubernetesKueueAdapter, KubernetesKueueConfig
4+
from .kubernetes_invocation import KubernetesInvocationExecutor
5+
from .kubernetes_kueue import (
6+
KubernetesKueueAdapter, KubernetesKueueConfig, StaticHostMount, StaticPvcMount)
57

6-
__all__ = ["KubernetesKueueAdapter", "KubernetesKueueConfig", "LocalProcessAdapter"]
8+
__all__ = [
9+
"KubernetesInvocationExecutor", "KubernetesKueueAdapter",
10+
"KubernetesKueueConfig", "LocalProcessAdapter", "StaticHostMount",
11+
"StaticPvcMount",
12+
]
Lines changed: 293 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,293 @@
1+
"""Bridge InvocationService's callable boundary to Kubernetes/Kueue.
2+
3+
The scheduler adapter intentionally knows nothing about Python callables. This
4+
executor turns the already-validated invocation into an immutable ExecutionRequest,
5+
waits for the fixed Motif worker, verifies its fenced result, and reconstructs the
6+
HandlerResult that InvocationService already knows how to validate, persist, cache,
7+
govern, and expose. No scientific completion logic is duplicated here.
8+
"""
9+
from __future__ import annotations
10+
11+
from concurrent.futures import Future, ThreadPoolExecutor
12+
from datetime import datetime, timedelta, timezone
13+
import hashlib
14+
import json
15+
from pathlib import Path
16+
import time
17+
from typing import Any, Callable
18+
from uuid import uuid4
19+
20+
import failures
21+
from execution_control.identity import sha256_digest
22+
from invocation import HandlerResult, InvocationContext
23+
24+
25+
_TERMINAL = {"succeeded", "failed", "cancelled", "unknown"}
26+
27+
28+
class KubernetesInvocationExecutor:
29+
"""Run GPU-class handlers in a fixed Kubernetes worker.
30+
31+
``submit`` only hosts the small control loop in a bounded thread. The handler
32+
itself runs in the Kueue-admitted Pod. CPU handlers remain local so switching
33+
the API to this executor does not turn cheap validation and governance commands
34+
into cluster jobs.
35+
"""
36+
37+
kind = "remote"
38+
adapter_kind = "kubernetes"
39+
supports_submission = True
40+
cancellation_capability = "cooperative+remote-hard"
41+
42+
def __init__(self, *, adapter: Any, exchange_root: Path,
43+
container_image: str, max_controllers: int = 4,
44+
poll_seconds: float = .25) -> None:
45+
self.adapter = adapter
46+
self.exchange_root = exchange_root.resolve()
47+
self.container_image = container_image
48+
self.poll_seconds = max(.05, float(poll_seconds))
49+
self._pool = ThreadPoolExecutor(
50+
max_workers=max(1, int(max_controllers)),
51+
thread_name_prefix="dirac-k8s-controller")
52+
for directory in (self.exchange_root / "inputs", self.exchange_root / "outputs"):
53+
directory.mkdir(parents=True, exist_ok=True)
54+
55+
def submit(self, fn: Callable[..., Any], *args, **kwargs) -> Future:
56+
return self._pool.submit(fn, *args, **kwargs)
57+
58+
def execute(self, handler: Callable[..., HandlerResult], payload: dict,
59+
ctx: InvocationContext) -> HandlerResult:
60+
if (ctx.spec is None
61+
or ctx.spec.execution.get("resource_class") != "gpu"):
62+
return handler(payload, ctx)
63+
return self._execute_remote(handler, payload, ctx)
64+
65+
@staticmethod
66+
def cancel(future: Future) -> bool:
67+
return future.cancel()
68+
69+
def shutdown(self, *, wait: bool = True) -> None:
70+
self._pool.shutdown(wait=wait)
71+
72+
def _execute_remote(self, handler: Callable[..., HandlerResult], payload: dict,
73+
ctx: InvocationContext) -> HandlerResult:
74+
if not ctx.job_id:
75+
raise failures.DiracInternal(
76+
"Kubernetes execution requires a durable public Job identity")
77+
if not ctx.execution_digest:
78+
raise failures.DiracInternal(
79+
"Kubernetes execution requires the Invocation execution digest")
80+
81+
attempt_id = str(uuid4())
82+
input_id = str(uuid4())
83+
now = datetime.now(timezone.utc)
84+
budget = max(1, int(ctx.budget_seconds or 3600))
85+
input_document = {
86+
"schema_version": "1.0",
87+
"method_id": ctx.method_id,
88+
"method_version": ctx.version,
89+
"execution_digest": ctx.execution_digest,
90+
"job_id": ctx.job_id,
91+
"attempt_id": attempt_id,
92+
"payload": payload,
93+
"budget_seconds": budget,
94+
}
95+
input_bytes = json.dumps(
96+
input_document, sort_keys=True, separators=(",", ":"),
97+
allow_nan=False).encode("utf-8")
98+
input_sha = hashlib.sha256(input_bytes).hexdigest()
99+
input_path = self.exchange_root / "inputs" / f"{input_id}.json"
100+
self._atomic_write(input_path, input_bytes)
101+
102+
request = self._request(
103+
ctx, payload, attempt_id=attempt_id, input_id=input_id,
104+
input_sha=input_sha, budget=budget, now=now)
105+
allocation_id: str | None = None
106+
try:
107+
status = self.adapter.submit(request)
108+
allocation_id = status.allocation_id
109+
while status.state not in _TERMINAL:
110+
if ctx.cancellation_token.requested:
111+
self.adapter.request_cancel(allocation_id, grace_seconds=5)
112+
raise failures.DiracCancelled(
113+
"Kubernetes Motif execution was cancelled",
114+
details={"allocation_id": allocation_id,
115+
"reason": ctx.cancellation_token.reason})
116+
if ctx.deadline is not None and time.time() > ctx.deadline:
117+
self.adapter.request_cancel(allocation_id, grace_seconds=5)
118+
raise failures.DiracBudgetExceeded(
119+
f"{ctx.method_id} exceeded its {ctx.budget_seconds}s budget",
120+
details={"allocation_id": allocation_id,
121+
"budget_seconds": ctx.budget_seconds})
122+
time.sleep(self.poll_seconds)
123+
status = self.adapter.inspect(allocation_id)
124+
125+
result_path = self.exchange_root / "outputs" / attempt_id / "worker-result.json"
126+
worker_result = self._load_result(result_path) if result_path.exists() else None
127+
if worker_result is not None:
128+
self._verify_result(worker_result, request)
129+
if not worker_result.get("ok"):
130+
error = worker_result.get("error") or {}
131+
raise failures.DiracFailure(
132+
str(error.get("code") or "INTERNAL"),
133+
str(error.get("message") or "Motif worker failed"),
134+
details=dict(error.get("details") or {}),
135+
hint=error.get("hint"))
136+
if status.state != "succeeded":
137+
logs = self._safe_logs(allocation_id)
138+
raise failures.DiracInternal(
139+
f"Kubernetes allocation {allocation_id} ended as {status.state}; "
140+
f"scheduler={status.scheduler_summary}; logs_tail={logs}")
141+
if worker_result is None:
142+
raise failures.DiracInternal(
143+
f"Kubernetes allocation {allocation_id} succeeded without a fenced result")
144+
return self._handler_result(worker_result, result_path.parent)
145+
finally:
146+
# InvocationService persists public artifacts only after this method
147+
# returns, so outputs must remain until a separate retention/GC policy
148+
# can prove persistence. Inputs are request-local and safe to remove.
149+
input_path.unlink(missing_ok=True)
150+
151+
def _request(self, ctx: InvocationContext, payload: dict, *, attempt_id: str,
152+
input_id: str, input_sha: str, budget: int,
153+
now: datetime) -> dict[str, Any]:
154+
spec = ctx.spec
155+
checkpointable = bool(spec.execution.get("checkpointable"))
156+
determinism = str(spec.execution.get("determinism") or "numeric_tolerant")
157+
if determinism not in {"bitwise", "numeric_tolerant", "statistical",
158+
"non_deterministic"}:
159+
determinism = "numeric_tolerant"
160+
seed = int(payload.get("seed", 0))
161+
output_digest = sha256_digest(json.dumps(
162+
spec.output_schema, sort_keys=True, separators=(",", ":")))
163+
return {
164+
"schema_version": "1.0",
165+
"execution_id": str(uuid4()),
166+
"job_id": ctx.job_id,
167+
"step_id": str(uuid4()),
168+
"attempt_id": attempt_id,
169+
"attempt": 1,
170+
"fencing_token": 1,
171+
"method_id": ctx.method_id,
172+
"execution_digest": ctx.execution_digest,
173+
"container_image": self.container_image,
174+
"entrypoint": ["motif-worker"],
175+
"input_manifest_artifact_id": input_id,
176+
"output_contract_digest": output_digest,
177+
"resource_request": {
178+
"cpu_cores": 4,
179+
"memory_bytes": 8 << 30,
180+
"gpus": 1,
181+
"gpu_arch": ["blackwell"],
182+
"gpu_memory_bytes_min": 1 << 30,
183+
"scratch_bytes": 8 << 30,
184+
"walltime_seconds": budget,
185+
"network": "none",
186+
"exclusive_gpu": True,
187+
},
188+
"placement": {
189+
"backend": "kubernetes", "site": "dirac-local-k3s",
190+
"queue": "motif", "topology": "single_node",
191+
"node_constraints": {}, "data_residency": ["local-node"],
192+
},
193+
"retry_policy": {
194+
"max_attempts": 1, "retryable_codes": [],
195+
"backoff": {"kind": "none", "initial_seconds": 0,
196+
"max_seconds": 0},
197+
"preserve_seed": True, "resume_from_checkpoint": False,
198+
},
199+
"checkpoint_policy": {
200+
"enabled": checkpointable, "interval_steps": 100 if checkpointable else None,
201+
"upload_mode": "sync", "retain_last": 1 if checkpointable else 0,
202+
"checkpoint_timeout_seconds": 120,
203+
},
204+
"security_context": {
205+
"actor": dict(ctx.actor or {"kind": "service", "id": "dirac-api"}),
206+
"project_scope": "dirac:motif",
207+
"artifact_read_ids": [input_id],
208+
"artifact_write_session": attempt_id,
209+
"credential_expires_at": (now + timedelta(seconds=budget + 300)).isoformat(),
210+
"network_policy": "deny_all",
211+
},
212+
"determinism": {
213+
"class": determinism, "root_seed": seed,
214+
"seed_scope_digest": sha256_digest(f"{ctx.execution_digest}:{seed}"),
215+
"numeric_mode": "fp32",
216+
},
217+
"environment": {
218+
"MOTIF_INPUT_SHA256": input_sha,
219+
"HOME": "/tmp", "XDG_CACHE_HOME": "/tmp/cache",
220+
"MPLCONFIGDIR": "/tmp/matplotlib", "PYTHONUNBUFFERED": "1",
221+
# Distroless-style base images intentionally have no passwd row
222+
# for the fixed non-root UID. PyTorch/getpass use these before
223+
# falling back to pwd.getpwuid(), so make that identity explicit.
224+
"USER": "dirac-worker", "LOGNAME": "dirac-worker",
225+
"TORCHINDUCTOR_CACHE_DIR": "/tmp/torchinductor",
226+
# The small immutable NVIDIA base intentionally omits desktop X11
227+
# libraries that the RDKit wheel links even in headless mode. The
228+
# runtime snapshot carries only that audited compatibility closure.
229+
"LD_LIBRARY_PATH": "/home/ivan/dirac/runtime-libs",
230+
},
231+
"created_at": now.isoformat(),
232+
"expires_at": (now + timedelta(seconds=budget + 300)).isoformat(),
233+
}
234+
235+
@staticmethod
236+
def _atomic_write(path: Path, data: bytes) -> None:
237+
path.parent.mkdir(parents=True, exist_ok=True)
238+
temporary = path.with_suffix(path.suffix + ".tmp")
239+
temporary.write_bytes(data)
240+
temporary.replace(path)
241+
242+
@staticmethod
243+
def _load_result(path: Path) -> dict[str, Any]:
244+
value = json.loads(path.read_text(encoding="utf-8"))
245+
if not isinstance(value, dict):
246+
raise failures.DiracInternal("Motif worker result is not a JSON object")
247+
return value
248+
249+
@staticmethod
250+
def _verify_result(result: dict[str, Any], request: dict[str, Any]) -> None:
251+
expected = {
252+
"job_id": request["job_id"], "attempt_id": request["attempt_id"],
253+
"fencing_token": request["fencing_token"],
254+
"execution_digest": request["execution_digest"],
255+
"method_id": request["method_id"],
256+
}
257+
actual = {key: result.get(key) for key in expected}
258+
if actual != expected:
259+
raise failures.DiracInternal(
260+
f"stale or foreign Motif worker result: expected={expected}, actual={actual}")
261+
262+
@staticmethod
263+
def _handler_result(result: dict[str, Any], directory: Path) -> HandlerResult:
264+
document = result.get("handler_result") or {}
265+
artifacts: list[tuple[str, bytes]] = []
266+
for item in result.get("artifacts") or []:
267+
filename = str(item.get("filename") or "")
268+
path = (directory / filename).resolve()
269+
if directory.resolve() not in path.parents:
270+
raise failures.DiracInternal("worker artifact escaped its attempt directory")
271+
data = path.read_bytes()
272+
digest = hashlib.sha256(data).hexdigest()
273+
if digest != item.get("sha256") or len(data) != item.get("size_bytes"):
274+
raise failures.DiracInternal(
275+
f"worker artifact integrity failure for {item.get('role')}")
276+
artifacts.append((str(item["role"]), data))
277+
return HandlerResult(
278+
result=dict(document.get("result") or {}), artifacts=artifacts,
279+
provenance=dict(document.get("provenance") or {}),
280+
warnings=list(document.get("warnings") or []),
281+
parameters_used=dict(document.get("parameters_used") or {}),
282+
cache=str(document.get("cache") or "computed"),
283+
cache_record=document.get("cache_record"),
284+
)
285+
286+
def _safe_logs(self, allocation_id: str) -> str:
287+
try:
288+
return self.adapter.logs(allocation_id, tail=80)[-4000:]
289+
except Exception as error: # noqa: BLE001
290+
return f"logs unavailable: {type(error).__name__}: {error}"
291+
292+
293+
__all__ = ["KubernetesInvocationExecutor"]

0 commit comments

Comments
 (0)