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