1from __future__ import annotations 2 3import argparse 4import json 5import logging 6import os 7import re 8import signal 9import subprocess 10import sys 11import tempfile 12import threading 13import time 14from pathlib import Path 15from typing import Any 16 17from daggerml import Dml, Ref 18from daggerml.api import DmlRepoError 19 20logger = logging.getLogger(__name__) 21 22_CLOUDWATCH_LOG_GROUP = "dml" 23_CLOUDWATCH_MAX_BATCH_BYTES = 1_048_576 24_CLOUDWATCH_MAX_MESSAGE_BYTES = 1_048_576 25_CLOUDWATCH_EVENT_OVERHEAD_BYTES = 26 26_CLOUDWATCH_MAX_BATCH_COUNT = 10_000 27 28 29def _create_logs_client() -> Any: 30 from daggerml.util import get_client 31 32 return get_client("logs") 33 34 35def _resource_already_exists(exc: Exception) -> bool: 36 return getattr(exc, "response", {}).get("Error", {}).get("Code") == "ResourceAlreadyExistsException" 37 38 39class _CloudWatchStream: 40 def __init__(self, *, cache_key: str, execution_id: str, stream_kind: str): 41 self.cache_key = cache_key 42 self.execution_id = execution_id 43 self.stream_kind = stream_kind 44 self.stream_name = f"/run/{cache_key}/{stream_kind}" 45 self._client: Any | None = None 46 self._enabled = True 47 self._sequence_token: str | None = None 48 self._pending_events: list[dict[str, Any]] = [] 49 self._pending_bytes = 0 50 self._lock = threading.Lock() 51 self._init_client() 52 self.emit_lifecycle(event="start") 53 54 @staticmethod 55 def _event_bytes(message: str) -> int: 56 return len(message.encode("utf-8")) + _CLOUDWATCH_EVENT_OVERHEAD_BYTES 57 58 @staticmethod 59 def _split_message(message: str) -> list[str]: 60 encoded = message.encode("utf-8") 61 if len(encoded) <= _CLOUDWATCH_MAX_MESSAGE_BYTES: 62 return [message] 63 64 chunks: list[str] = [] 65 start = 0 66 while start < len(encoded): 67 end = min(start + _CLOUDWATCH_MAX_MESSAGE_BYTES, len(encoded)) 68 while end > start: 69 try: 70 chunks.append(encoded[start:end].decode("utf-8")) 71 start = end 72 break 73 except UnicodeDecodeError: 74 end -= 1 75 else: 76 raise AssertionError("failed to split UTF-8 message into valid chunks") 77 return chunks 78 79 def _flush_locked(self) -> None: 80 if not self._enabled or self._client is None or not self._pending_events: 81 return 82 params: dict[str, Any] = { 83 "logGroupName": _CLOUDWATCH_LOG_GROUP, 84 "logStreamName": self.stream_name, 85 "logEvents": list(self._pending_events), 86 } 87 if self._sequence_token is not None: 88 params["sequenceToken"] = self._sequence_token 89 try: 90 response = self._client.put_log_events(**params) 91 self._sequence_token = response.get("nextSequenceToken") 92 self._pending_events.clear() 93 self._pending_bytes = 0 94 except Exception as exc: 95 self._pending_events.clear() 96 self._pending_bytes = 0 97 self._disable(f"event delivery failed: {exc}") 98 99 def _init_client(self) -> None: 100 try: 101 client = _create_logs_client() 102 try: 103 client.create_log_group(logGroupName=_CLOUDWATCH_LOG_GROUP) 104 except Exception as exc: 105 if not _resource_already_exists(exc): 106 raise 107 try: 108 client.create_log_stream(logGroupName=_CLOUDWATCH_LOG_GROUP, logStreamName=self.stream_name) 109 except Exception as exc: 110 if not _resource_already_exists(exc): 111 raise 112 self._client = client 113 except Exception as exc: 114 self._disable(f"initialization failed: {exc}") 115 116 def _disable(self, reason: str) -> None: 117 if not self._enabled: 118 return 119 self._enabled = False 120 self._client = None 121 logger.warning("CloudWatch logging disabled for %s: %s", self.stream_name, reason) 122 123 def emit_lifecycle(self, *, event: str, terminal_status: str | None = None) -> None: 124 payload = { 125 "event": f"stream_{event}", 126 "execution_id": self.execution_id, 127 "cache_key": self.cache_key, 128 "stream": self.stream_kind, 129 } 130 if terminal_status is not None: 131 payload["terminal_status"] = terminal_status 132 self.emit(json.dumps(payload, sort_keys=True)) 133 134 def emit(self, message: str) -> None: 135 if not self._enabled or self._client is None: 136 return 137 messages = self._split_message(message) 138 with self._lock: 139 for chunk in messages: 140 event_bytes = self._event_bytes(chunk) 141 if self._pending_events and ( 142 len(self._pending_events) >= _CLOUDWATCH_MAX_BATCH_COUNT 143 or self._pending_bytes + event_bytes > _CLOUDWATCH_MAX_BATCH_BYTES 144 ): 145 self._flush_locked() 146 if not self._enabled: 147 return 148 event = {"timestamp": round(time.time() * 1000), "message": chunk} 149 self._pending_events.append(event) 150 self._pending_bytes += event_bytes 151 152 def close(self, *, terminal_status: str) -> None: 153 self.emit_lifecycle(event="end", terminal_status=terminal_status) 154 with self._lock: 155 self._flush_locked() 156 157 158def _drain_pipe(pipe: Any, *, local_path: Path, sink: _CloudWatchStream) -> None: 159 with local_path.open("w") as local_file: 160 for line in pipe: 161 local_file.write(line) 162 local_file.flush() 163 sink.emit(line) 164 pipe.close() 165 166 167def _parse_cmd_payload( 168 payload: dict[str, Any], 169) -> tuple[str, str, list[str], dict[str, str], dict[str, str]]: 170 allowed = {"version", "cache_key", "execution_id", "cmd", "remote", "env"} 171 unknown = sorted(set(payload) - allowed) 172 if unknown: 173 raise DmlRepoError(f"Supervisor payload has unknown fields: {', '.join(unknown)}") 174 175 version = payload.get("version") 176 if version != 0: 177 raise DmlRepoError("Supervisor payload version must be 0") 178 179 cache_key = payload.get("cache_key") 180 if not isinstance(cache_key, str) or not cache_key: 181 raise DmlRepoError("Supervisor payload cache_key must be a non-empty string") 182 183 execution_id = payload.get("execution_id") 184 if not isinstance(execution_id, str) or not execution_id: 185 raise DmlRepoError("Supervisor payload execution_id must be a non-empty string") 186 187 cmd = payload.get("cmd") 188 if not isinstance(cmd, list) or not cmd or not all(isinstance(x, str) and x for x in cmd): 189 raise DmlRepoError("Supervisor payload cmd must be a non-empty list[str]") 190 191 remote = payload.get("remote") 192 if not isinstance(remote, dict): 193 raise DmlRepoError("Supervisor payload remote must be a dict") 194 unknown_remote = sorted(set(remote) - {"root"}) 195 if unknown_remote: 196 raise DmlRepoError(f"Supervisor payload remote has unknown fields: {', '.join(unknown_remote)}") 197 if not isinstance(remote.get("root"), str): 198 raise DmlRepoError("Supervisor payload remote requires string root") 199 200 env = payload.get("env") or {} 201 if not isinstance(env, dict) or not all(isinstance(k, str) and isinstance(v, str) for k, v in env.items()): 202 raise DmlRepoError("Supervisor payload env must be a dict[str,str]") 203 204 merged_env = os.environ.copy() 205 merged_env.update(env) 206 return cache_key, execution_id, cmd, merged_env, {"root": remote["root"]} 207 208 209def _validate_output(result: Any) -> dict[str, Any]: 210 if not isinstance(result, dict): 211 raise DmlRepoError(f"Supervisor result must be a dict; received: {result!r}") 212 status = result.get("status") 213 if status not in {"succeeded", "failed"}: 214 raise DmlRepoError( 215 "Supervisor result status must be one of succeeded|failed after worker exit; " f"received: {result!r}" 216 ) 217 if status == "failed": 218 expected = {"status", "error"} 219 if set(result.keys()) != expected: 220 raise DmlRepoError(f"Supervisor failed result keys must be exactly: status, error; received: {result!r}") 221 error = result.get("error") 222 if error is None: 223 raise DmlRepoError(f"Supervisor result failed requires error; received: {result!r}") 224 return result 225 226 expected = {"status", "error", "dag_id"} 227 if set(result.keys()) != expected: 228 raise DmlRepoError( 229 f"Supervisor succeeded result keys must be exactly: status, error, dag_id; received: {result!r}" 230 ) 231 error = result.get("error") 232 if error is not None: 233 raise DmlRepoError(f"Supervisor result succeeded requires error=None; received: {result!r}") 234 dag_id = result.get("dag_id") 235 if not isinstance(dag_id, str) or not re.fullmatch(r"[0-9a-f]{64}", dag_id): 236 raise DmlRepoError(f"Supervisor result succeeded requires real dag_id; received: {result!r}") 237 return result 238 239 240def run(payload: dict[str, Any]) -> dict[str, Any]: 241 """Launch a worker subprocess, wait for it to exit, and return the terminal result.""" 242 cache_key, execution_id, cmd, env, remote = _parse_cmd_payload(payload) 243 workdir = tempfile.mkdtemp(prefix=f"dml-supervisor-{execution_id[:8]}-") 244 repo_dir = Path(workdir) / "repo" 245 repo_dir.mkdir(parents=True, exist_ok=True) 246 dml = Dml.init(str(repo_dir), remote_root=remote["root"], user="worker") 247 env = dict(env) 248 env["DML_PROJECT_HOME"] = str(repo_dir) 249 result_path = Path(workdir) / "result.json" 250 stdout_path = Path(workdir) / "stdout.log" 251 stderr_path = Path(workdir) / "stderr.log" 252 stdout_sink = _CloudWatchStream(cache_key=cache_key, execution_id=execution_id, stream_kind="stdout") 253 stderr_sink = _CloudWatchStream(cache_key=cache_key, execution_id=execution_id, stream_kind="stderr") 254 proc = subprocess.Popen( 255 cmd, 256 cwd=workdir, 257 env=env, 258 stdout=subprocess.PIPE, 259 stderr=subprocess.PIPE, 260 text=True, 261 encoding="utf-8", 262 errors="replace", 263 bufsize=1, 264 start_new_session=False, 265 close_fds=True, 266 ) 267 assert proc.stdout is not None 268 assert proc.stderr is not None 269 stdout_thread = threading.Thread( 270 target=_drain_pipe, 271 kwargs={"pipe": proc.stdout, "local_path": stdout_path, "sink": stdout_sink}, 272 name=f"dml-supervisor-{execution_id[:8]}-stdout", 273 ) 274 stderr_thread = threading.Thread( 275 target=_drain_pipe, 276 kwargs={"pipe": proc.stderr, "local_path": stderr_path, "sink": stderr_sink}, 277 name=f"dml-supervisor-{execution_id[:8]}-stderr", 278 ) 279 stdout_thread.start() 280 stderr_thread.start() 281 execution = Ref(f"index:{execution_id}") 282 while proc.poll() is None: 283 if dml.runtime.read_execution_record(execution)["state"]["lifecycle"].startswith("cancel"): 284 # log to log streams that we're cancelling 285 stdout_sink.emit_lifecycle(event="cancel") 286 stderr_sink.emit_lifecycle(event="cancel") 287 proc.terminate() 288 break 289 time.sleep(0.1) 290 proc.wait() 291 stdout_thread.join() 292 stderr_thread.join() 293 294 result: dict[str, Any] 295 if result_path.exists(): 296 try: 297 parsed = json.loads(result_path.read_text()) 298 result = _validate_output(parsed) 299 except Exception as e: 300 result = {"status": "failed", "error": f"Supervisor could not read worker result: {e}"} 301 elif proc.returncode is not None and proc.returncode < 0: 302 sig = -proc.returncode 303 try: 304 sig_name = signal.Signals(sig).name 305 except ValueError: 306 sig_name = str(sig) 307 result = {"status": "failed", "error": f"Worker killed by signal {sig_name}"} 308 else: 309 code = proc.returncode if proc.returncode is not None else -1 310 result = {"status": "failed", "error": f"Worker exited without result (code={code})"} 311 312 terminal_status = str(result.get("status", "failed")) 313 stdout_sink.close(terminal_status=terminal_status) 314 stderr_sink.close(terminal_status=terminal_status) 315 return result 316 317 318def _read(path: str) -> str: 319 if path == "-": 320 return sys.stdin.read() 321 return Path(path).read_text() 322 323 324def _write(path: str, data: str) -> None: 325 if path == "-": 326 sys.stdout.write(data) 327 if not data.endswith("\n"): 328 sys.stdout.write("\n") 329 sys.stdout.flush() 330 return 331 Path(path).write_text(data) 332 333 334def main(argv: list[str] | None = None) -> int: 335 parser = argparse.ArgumentParser(description="daggerml contrib supervisor") 336 parser.add_argument("-i", "--input", default="-") 337 parser.add_argument("-o", "--output", default="-") 338 args = parser.parse_args(argv or sys.argv[1:]) 339 payload = json.loads(_read(args.input)) 340 result = run(payload) 341 _write(args.output, json.dumps(result, separators=(",", ":"), sort_keys=True)) 342 return 0 343 344 345if __name__ == "__main__": 346 raise SystemExit(main())
241def run(payload: dict[str, Any]) -> dict[str, Any]: 242 """Launch a worker subprocess, wait for it to exit, and return the terminal result.""" 243 cache_key, execution_id, cmd, env, remote = _parse_cmd_payload(payload) 244 workdir = tempfile.mkdtemp(prefix=f"dml-supervisor-{execution_id[:8]}-") 245 repo_dir = Path(workdir) / "repo" 246 repo_dir.mkdir(parents=True, exist_ok=True) 247 dml = Dml.init(str(repo_dir), remote_root=remote["root"], user="worker") 248 env = dict(env) 249 env["DML_PROJECT_HOME"] = str(repo_dir) 250 result_path = Path(workdir) / "result.json" 251 stdout_path = Path(workdir) / "stdout.log" 252 stderr_path = Path(workdir) / "stderr.log" 253 stdout_sink = _CloudWatchStream(cache_key=cache_key, execution_id=execution_id, stream_kind="stdout") 254 stderr_sink = _CloudWatchStream(cache_key=cache_key, execution_id=execution_id, stream_kind="stderr") 255 proc = subprocess.Popen( 256 cmd, 257 cwd=workdir, 258 env=env, 259 stdout=subprocess.PIPE, 260 stderr=subprocess.PIPE, 261 text=True, 262 encoding="utf-8", 263 errors="replace", 264 bufsize=1, 265 start_new_session=False, 266 close_fds=True, 267 ) 268 assert proc.stdout is not None 269 assert proc.stderr is not None 270 stdout_thread = threading.Thread( 271 target=_drain_pipe, 272 kwargs={"pipe": proc.stdout, "local_path": stdout_path, "sink": stdout_sink}, 273 name=f"dml-supervisor-{execution_id[:8]}-stdout", 274 ) 275 stderr_thread = threading.Thread( 276 target=_drain_pipe, 277 kwargs={"pipe": proc.stderr, "local_path": stderr_path, "sink": stderr_sink}, 278 name=f"dml-supervisor-{execution_id[:8]}-stderr", 279 ) 280 stdout_thread.start() 281 stderr_thread.start() 282 execution = Ref(f"index:{execution_id}") 283 while proc.poll() is None: 284 if dml.runtime.read_execution_record(execution)["state"]["lifecycle"].startswith("cancel"): 285 # log to log streams that we're cancelling 286 stdout_sink.emit_lifecycle(event="cancel") 287 stderr_sink.emit_lifecycle(event="cancel") 288 proc.terminate() 289 break 290 time.sleep(0.1) 291 proc.wait() 292 stdout_thread.join() 293 stderr_thread.join() 294 295 result: dict[str, Any] 296 if result_path.exists(): 297 try: 298 parsed = json.loads(result_path.read_text()) 299 result = _validate_output(parsed) 300 except Exception as e: 301 result = {"status": "failed", "error": f"Supervisor could not read worker result: {e}"} 302 elif proc.returncode is not None and proc.returncode < 0: 303 sig = -proc.returncode 304 try: 305 sig_name = signal.Signals(sig).name 306 except ValueError: 307 sig_name = str(sig) 308 result = {"status": "failed", "error": f"Worker killed by signal {sig_name}"} 309 else: 310 code = proc.returncode if proc.returncode is not None else -1 311 result = {"status": "failed", "error": f"Worker exited without result (code={code})"} 312 313 terminal_status = str(result.get("status", "failed")) 314 stdout_sink.close(terminal_status=terminal_status) 315 stderr_sink.close(terminal_status=terminal_status) 316 return result
Launch a worker subprocess, wait for it to exit, and return the terminal result.
335def main(argv: list[str] | None = None) -> int: 336 parser = argparse.ArgumentParser(description="daggerml contrib supervisor") 337 parser.add_argument("-i", "--input", default="-") 338 parser.add_argument("-o", "--output", default="-") 339 args = parser.parse_args(argv or sys.argv[1:]) 340 payload = json.loads(_read(args.input)) 341 result = run(payload) 342 _write(args.output, json.dumps(result, separators=(",", ":"), sort_keys=True)) 343 return 0