Back to home page

EIC code displayed by LXR

 
 

    


File indexing completed on 2026-08-12 08:24:56

0001 """Slurm-backed scheduler for command jobs."""
0002 
0003 from __future__ import annotations
0004 
0005 import json
0006 import logging
0007 import shlex
0008 import subprocess
0009 import time
0010 import uuid
0011 from pathlib import Path
0012 from typing import Any, Dict, List, Optional
0013 
0014 from aid2e.schedulers.Slurm.config import SlurmRunnerConfig
0015 from aid2e.schedulers.base import BaseScheduler, JobStatus, StageExecutionResult, StageStatus
0016 
0017 
0018 TERMINAL_STATUSES = {"completed", "failed", "cancelled"}
0019 
0020 STATE_MAP = {
0021     "PENDING": "queued",
0022     "CONFIGURING": "queued",
0023     "RUNNING": "running",
0024     "COMPLETING": "running",
0025     "COMPLETED": "completed",
0026     "FAILED": "failed",
0027     "TIMEOUT": "failed",
0028     "OUT_OF_MEMORY": "failed",
0029     "NODE_FAIL": "failed",
0030     "PREEMPTED": "failed",
0031     "BOOT_FAIL": "failed",
0032     "DEADLINE": "failed",
0033     "REVOKED": "failed",
0034     "CANCELLED": "cancelled",
0035 }
0036 
0037 RESOURCE_KEYS = (
0038     "partition",
0039     "account",
0040     "qos",
0041     "time",
0042     "nodes",
0043     "ntasks",
0044     "cpus_per_task",
0045     "mem",
0046     "gres",
0047     "constraint",
0048 )
0049 
0050 
0051 class SlurmScheduler(BaseScheduler):
0052     """Execute workflow stage jobs on Slurm using generated batch scripts."""
0053 
0054     def __init__(self, config: Optional[SlurmRunnerConfig] = None) -> None:
0055         super().__init__(config)
0056         self.config = config or SlurmRunnerConfig()
0057         self.logger = logging.getLogger("SlurmScheduler")
0058         self.stages: Dict[str, Dict[str, Any]] = {}
0059         self.jobs: Dict[str, Dict[str, Any]] = {}
0060 
0061     def run_stage(
0062         self,
0063         stage_name: str,
0064         job_definitions: List[Dict[str, Any]],
0065         parallelism_policy: Optional[Dict[str, Any]] = None,
0066         working_dir: Optional[str] = None,
0067     ) -> StageExecutionResult:
0068         """Submit a stage and block until it reaches a terminal state."""
0069 
0070         stage_id = self.submit_stage(stage_name, job_definitions, parallelism_policy, working_dir)
0071         poll_interval = int((parallelism_policy or {}).get("poll_interval", self.config.poll_interval))
0072 
0073         while True:
0074             status = self.check_stage_status(stage_id)
0075             if status.status in TERMINAL_STATUSES:
0076                 return self.get_stage_results(stage_id)
0077             time.sleep(poll_interval)
0078 
0079     def submit_stage(
0080         self,
0081         stage_name: str,
0082         job_definitions: List[Dict[str, Any]],
0083         parallelism_policy: Optional[Dict[str, Any]] = None,
0084         working_dir: Optional[str] = None,
0085     ) -> str:
0086         """Create scripts, submit them to Slurm, and return a stage id."""
0087 
0088         stage_id = uuid.uuid4().hex
0089         stage_root = self._resolve_submit_root(working_dir) / f"{stage_name}_{stage_id}"
0090         stage_root.mkdir(parents=True, exist_ok=True)
0091 
0092         stage_state: Dict[str, Any] = {
0093             "stage_id": stage_id,
0094             "stage_name": stage_name,
0095             "status": "queued",
0096             "created_at": time.time(),
0097             "parallelism_policy": dict(parallelism_policy or {}),
0098             "working_dir": str(stage_root),
0099             "job_ids": [],
0100             "result": None,
0101         }
0102         self.stages[stage_id] = stage_state
0103 
0104         for index, job_def in enumerate(job_definitions):
0105             self._validate_job_definition(job_def)
0106             job_name = job_def.get("name", f"job_{index}")
0107             job_id = job_def.get("job_id") or f"{stage_name}_{job_name}_{index}"
0108             job_root = stage_root / job_id
0109             job_root.mkdir(parents=True, exist_ok=True)
0110 
0111             script_path = job_root / "job.sbatch"
0112             runtime_dir = self._resolve_runtime_dir(job_def, job_root)
0113             output_dir = self._resolve_output_dir(job_def, job_root)
0114             runtime_dir.mkdir(parents=True, exist_ok=True)
0115             output_dir.mkdir(parents=True, exist_ok=True)
0116             stdout_path = output_dir / "stdout.log" if self.config.capture_stdout else None
0117             stderr_path = output_dir / "stderr.log" if self.config.capture_stderr else None
0118 
0119             script_text = self._build_batch_script(
0120                 job_id=job_id,
0121                 job_name=job_name,
0122                 job_def=job_def,
0123                 runtime_dir=runtime_dir,
0124                 stdout_path=stdout_path,
0125                 stderr_path=stderr_path,
0126             )
0127             script_path.write_text(script_text, encoding="utf-8")
0128 
0129             slurm_job_id = self._submit_script(script_path, stage_root)
0130             job_state = {
0131                 "stage_id": stage_id,
0132                 "job_id": job_id,
0133                 "job_name": job_name,
0134                 "job_root": str(job_root),
0135                 "runtime_dir": str(runtime_dir),
0136                 "output_dir": str(output_dir),
0137                 "script_path": str(script_path),
0138                 "stdout_path": str(stdout_path) if stdout_path else None,
0139                 "stderr_path": str(stderr_path) if stderr_path else None,
0140                 "slurm_job_id": slurm_job_id,
0141                 "status": "queued",
0142                 "raw_state": "PENDING",
0143                 "return_code": None,
0144                 "command": job_def.get("command", ""),
0145                 "outputs": list(job_def.get("outputs", [])),
0146                 "artifact_cache": None,
0147                 "last_update": time.time(),
0148             }
0149             self.jobs[job_id] = job_state
0150             stage_state["job_ids"].append(job_id)
0151 
0152         self._refresh_stage_status(stage_id)
0153         return stage_id
0154 
0155     def check_stage_status(self, stage_id: str) -> StageStatus:
0156         """Poll Slurm and return a stage-level status summary."""
0157 
0158         if stage_id not in self.stages:
0159             raise KeyError(f"Unknown stage_id: {stage_id}")
0160 
0161         self._refresh_stage_status(stage_id)
0162         stage_state = self.stages[stage_id]
0163         job_statuses = [self._job_status_from_state(self.jobs[job_id]) for job_id in stage_state["job_ids"]]
0164         completed_jobs = sum(1 for status in job_statuses if status.status in TERMINAL_STATUSES)
0165         total_jobs = len(job_statuses)
0166         progress = float(completed_jobs) / float(total_jobs) if total_jobs else 1.0
0167 
0168         return StageStatus(
0169             stage_id=stage_id,
0170             status=stage_state["status"],
0171             completed_jobs=completed_jobs,
0172             total_jobs=total_jobs,
0173             progress=progress,
0174             job_statuses=job_statuses,
0175         )
0176 
0177     def get_stage_results(self, stage_id: str) -> StageExecutionResult:
0178         """Return final stage results after collecting logs and artifacts."""
0179 
0180         if stage_id not in self.stages:
0181             raise KeyError(f"Unknown stage_id: {stage_id}")
0182 
0183         self._refresh_stage_status(stage_id)
0184         stage_state = self.stages[stage_id]
0185         if stage_state["status"] not in TERMINAL_STATUSES:
0186             raise RuntimeError(f"Stage {stage_id} is not yet finished (status={stage_state['status']})")
0187 
0188         cached = stage_state.get("result")
0189         if cached is not None:
0190             return cached
0191 
0192         job_statuses: List[JobStatus] = []
0193         artifacts: Dict[str, Any] = {}
0194         stage_success = True
0195 
0196         for job_id in stage_state["job_ids"]:
0197             job_state = self.jobs[job_id]
0198             job_outputs = self._collect_job_outputs(job_state)
0199             stdout = self._read_text(job_state.get("stdout_path"))
0200             stderr = self._read_text(job_state.get("stderr_path"))
0201             status = self._job_status_from_state(
0202                 job_state,
0203                 stdout=stdout,
0204                 stderr=stderr,
0205                 outputs=job_outputs,
0206             )
0207             job_statuses.append(status)
0208             if job_outputs:
0209                 artifacts.update(job_outputs)
0210             if status.status != "completed":
0211                 stage_success = False
0212 
0213         result = StageExecutionResult(
0214             stage_name=stage_state["stage_name"],
0215             job_statuses=job_statuses,
0216             artifacts=artifacts,
0217             success=stage_success,
0218             error_message=None if stage_success else f"Some jobs failed in stage '{stage_state['stage_name']}'",
0219         )
0220         stage_state["result"] = result
0221         return result
0222 
0223     def check_status(self, job_id: str) -> JobStatus:
0224         """Return the most recent cached job status."""
0225 
0226         if job_id not in self.jobs:
0227             raise KeyError(f"Unknown job_id: {job_id}")
0228         self._refresh_job_state(self.jobs[job_id])
0229         return self._job_status_from_state(self.jobs[job_id])
0230 
0231     def cancel_job(self, job_id: str) -> bool:
0232         """Request job cancellation through scancel."""
0233 
0234         if job_id not in self.jobs:
0235             self.logger.warning("Cannot cancel unknown job %s", job_id)
0236             return False
0237 
0238         job_state = self.jobs[job_id]
0239         slurm_job_id = job_state["slurm_job_id"]
0240         proc = subprocess.run(
0241             ["scancel", str(slurm_job_id)],
0242             capture_output=True,
0243             text=True,
0244             check=False,
0245         )
0246         if proc.returncode != 0:
0247             self.logger.warning("scancel failed for job %s (%s): %s", job_id, slurm_job_id, proc.stderr.strip())
0248             return False
0249 
0250         job_state["status"] = "cancelled"
0251         job_state["raw_state"] = "CANCELLED"
0252         job_state["return_code"] = -1
0253         self._refresh_stage_status(job_state["stage_id"])
0254         return True
0255 
0256     def _validate_job_definition(self, job_def: Dict[str, Any]) -> None:
0257         if job_def.get("function") is not None or job_def.get("params") is not None:
0258             raise ValueError("SlurmScheduler v1 supports command jobs only")
0259         command = str(job_def.get("command", "")).strip()
0260         if not command:
0261             raise ValueError("Job definition must include a non-empty 'command'")
0262 
0263     def _resolve_submit_root(self, working_dir: Optional[str]) -> Path:
0264         submit_root = self.config.submit_working_dir or working_dir or str(Path.cwd())
0265         return Path(submit_root).expanduser().resolve()
0266 
0267     def _resolve_runtime_dir(self, job_def: Dict[str, Any], default_job_root: Path) -> Path:
0268         if self.config.runtime_working_dir:
0269             return Path(self.config.runtime_working_dir).expanduser().resolve()
0270 
0271         payload = job_def.get("payload") or {}
0272         execution_dir = payload.get("execution_dir")
0273         if execution_dir:
0274             return Path(str(execution_dir)).expanduser().resolve()
0275 
0276         return default_job_root.resolve()
0277 
0278     def _resolve_output_dir(self, job_def: Dict[str, Any], default_job_root: Path) -> Path:
0279         payload = job_def.get("payload") or {}
0280         output_dir = payload.get("output_dir")
0281         if output_dir:
0282             return Path(str(output_dir)).expanduser().resolve()
0283         return default_job_root.resolve()
0284 
0285     def _resolve_job_resources(self, job_def: Dict[str, Any]) -> Dict[str, Any]:
0286         resolved: Dict[str, Any] = {key: getattr(self.config, key) for key in RESOURCE_KEYS}
0287         for key, value in (job_def.get("resources") or {}).items():
0288             if key in RESOURCE_KEYS and value is not None:
0289                 resolved[key] = value
0290         return resolved
0291 
0292     def _build_batch_script(
0293         self,
0294         job_id: str,
0295         job_name: str,
0296         job_def: Dict[str, Any],
0297         runtime_dir: Path,
0298         stdout_path: Optional[Path],
0299         stderr_path: Optional[Path],
0300     ) -> str:
0301         resources = self._resolve_job_resources(job_def)
0302         slurm_job_name = f"{self.config.job_name_prefix}_{job_name}"
0303         lines = ["#!/bin/bash", f"#SBATCH --job-name={slurm_job_name}"]
0304 
0305         if stdout_path is not None:
0306             lines.append(f"#SBATCH --output={stdout_path}")
0307         if stderr_path is not None:
0308             lines.append(f"#SBATCH --error={stderr_path}")
0309 
0310         directive_map = {
0311             "partition": "--partition",
0312             "account": "--account",
0313             "qos": "--qos",
0314             "time": "--time",
0315             "nodes": "--nodes",
0316             "ntasks": "--ntasks",
0317             "cpus_per_task": "--cpus-per-task",
0318             "mem": "--mem",
0319             "gres": "--gres",
0320             "constraint": "--constraint",
0321         }
0322         for key, flag in directive_map.items():
0323             value = resources.get(key)
0324             if value is not None:
0325                 lines.append(f"#SBATCH {flag}={value}")
0326 
0327         lines.extend(["", "set -euo pipefail"])
0328         lines.extend(self.config.setup_commands)
0329         lines.append(f"cd {shlex.quote(str(runtime_dir))}")
0330         lines.append(str(job_def["command"]))
0331         lines.append("")
0332         return "\n".join(lines)
0333 
0334     def _submit_script(self, script_path: Path, submit_root: Path) -> str:
0335         command = ["sbatch", "--parsable", *self.config.sbatch_extra_args, str(script_path)]
0336         proc = subprocess.run(
0337             command,
0338             cwd=str(submit_root),
0339             capture_output=True,
0340             text=True,
0341             check=False,
0342         )
0343         if proc.returncode != 0:
0344             raise RuntimeError(
0345                 f"sbatch failed for {script_path}: {proc.stderr.strip() or proc.stdout.strip()}"
0346             )
0347 
0348         stdout = proc.stdout.strip()
0349         job_id = stdout.split(";", 1)[0].strip()
0350         if not job_id:
0351             raise RuntimeError(f"Could not parse Slurm job id from sbatch output: {stdout!r}")
0352         return job_id
0353 
0354     def _refresh_stage_status(self, stage_id: str) -> None:
0355         stage_state = self.stages[stage_id]
0356         statuses = []
0357         for job_id in stage_state["job_ids"]:
0358             self._refresh_job_state(self.jobs[job_id])
0359             statuses.append(self.jobs[job_id]["status"])
0360 
0361         if not statuses:
0362             stage_state["status"] = "completed"
0363         elif all(status == "completed" for status in statuses):
0364             stage_state["status"] = "completed"
0365         elif any(status == "failed" for status in statuses):
0366             stage_state["status"] = "failed"
0367         elif any(status == "cancelled" for status in statuses):
0368             stage_state["status"] = "cancelled"
0369         elif any(status == "running" for status in statuses):
0370             stage_state["status"] = "running"
0371         else:
0372             stage_state["status"] = "queued"
0373 
0374     def _refresh_job_state(self, job_state: Dict[str, Any]) -> None:
0375         if job_state["status"] in TERMINAL_STATUSES:
0376             return
0377 
0378         slurm_job_id = job_state["slurm_job_id"]
0379         active_state = self._query_squeue_state(slurm_job_id)
0380         if active_state is not None:
0381             job_state["raw_state"] = active_state
0382             job_state["status"] = self._normalize_state(active_state)
0383             job_state["last_update"] = time.time()
0384             return
0385 
0386         account_state = self._query_sacct_state(slurm_job_id)
0387         if account_state is None:
0388             job_state["last_update"] = time.time()
0389             return
0390 
0391         raw_state = account_state["state"]
0392         job_state["raw_state"] = raw_state
0393         job_state["status"] = self._normalize_state(raw_state)
0394         job_state["return_code"] = self._parse_exit_code(account_state.get("exit_code"), job_state["status"])
0395         job_state["last_update"] = time.time()
0396 
0397     def _query_squeue_state(self, slurm_job_id: str) -> Optional[str]:
0398         proc = subprocess.run(
0399             ["squeue", "-h", "-j", str(slurm_job_id), "--format=%i|%T"],
0400             capture_output=True,
0401             text=True,
0402             check=False,
0403         )
0404         if proc.returncode != 0:
0405             self.logger.debug("squeue failed for %s: %s", slurm_job_id, proc.stderr.strip())
0406             return None
0407 
0408         for line in proc.stdout.splitlines():
0409             line = line.strip()
0410             if not line:
0411                 continue
0412             job_id, state = (line.split("|", 1) + [""])[:2]
0413             if job_id.strip() == str(slurm_job_id):
0414                 return state.strip()
0415         return None
0416 
0417     def _query_sacct_state(self, slurm_job_id: str) -> Optional[Dict[str, str]]:
0418         proc = subprocess.run(
0419             ["sacct", "-n", "-P", "-j", str(slurm_job_id), "--format=JobIDRaw,State,ExitCode"],
0420             capture_output=True,
0421             text=True,
0422             check=False,
0423         )
0424         if proc.returncode != 0:
0425             self.logger.debug("sacct failed for %s: %s", slurm_job_id, proc.stderr.strip())
0426             return None
0427 
0428         best_match: Optional[Dict[str, str]] = None
0429         for line in proc.stdout.splitlines():
0430             line = line.strip()
0431             if not line:
0432                 continue
0433             parts = line.split("|")
0434             if len(parts) < 3:
0435                 continue
0436             job_id_raw, state, exit_code = parts[0].strip(), parts[1].strip(), parts[2].strip()
0437             if job_id_raw == str(slurm_job_id):
0438                 best_match = {"state": state, "exit_code": exit_code}
0439                 break
0440         return best_match
0441 
0442     def _normalize_state(self, raw_state: Optional[str]) -> str:
0443         if not raw_state:
0444             return "unknown"
0445         token = raw_state.strip().upper().split()[0].rstrip("+")
0446         return STATE_MAP.get(token, "unknown")
0447 
0448     def _parse_exit_code(self, exit_code: Optional[str], normalized_status: str) -> Optional[int]:
0449         if normalized_status == "completed":
0450             return 0
0451         if not exit_code:
0452             return -1 if normalized_status in {"failed", "cancelled"} else None
0453         token = exit_code.split(":", 1)[0].strip()
0454         try:
0455             return int(token)
0456         except ValueError:
0457             return -1 if normalized_status in {"failed", "cancelled"} else None
0458 
0459     def _job_status_from_state(
0460         self,
0461         job_state: Dict[str, Any],
0462         stdout: Optional[str] = None,
0463         stderr: Optional[str] = None,
0464         outputs: Optional[Dict[str, Any]] = None,
0465     ) -> JobStatus:
0466         return JobStatus(
0467             job_id=job_state["job_id"],
0468             status=job_state["status"],
0469             return_code=job_state.get("return_code"),
0470             stdout=stdout,
0471             stderr=stderr,
0472             outputs=outputs,
0473             metrics={"slurm_job_id": job_state["slurm_job_id"], "raw_state": job_state.get("raw_state")},
0474         )
0475 
0476     def _collect_job_outputs(self, job_state: Dict[str, Any]) -> Dict[str, Any]:
0477         cached = job_state.get("artifact_cache")
0478         if cached is not None:
0479             return cached
0480 
0481         output_dir = Path(job_state.get("output_dir") or job_state["runtime_dir"])
0482         collected: Dict[str, Any] = {}
0483         for output_spec in job_state.get("outputs", []):
0484             output_path = self._get_output_spec_value(output_spec, "path")
0485             if not output_path:
0486                 continue
0487 
0488             path_obj = Path(str(output_path))
0489             full_path = path_obj if path_obj.is_absolute() else output_dir / path_obj
0490             if not full_path.exists():
0491                 self.logger.warning("Expected output artifact missing for %s: %s", job_state["job_id"], full_path)
0492                 continue
0493 
0494             fmt = str(self._get_output_spec_value(output_spec, "format") or "").lower()
0495             if fmt == "json":
0496                 with full_path.open("r", encoding="utf-8") as handle:
0497                     collected.update(json.load(handle))
0498             else:
0499                 collected[str(output_path)] = full_path.read_text(encoding="utf-8")
0500 
0501         job_state["artifact_cache"] = collected
0502         return collected
0503 
0504     def _get_output_spec_value(self, output_spec: Any, key: str) -> Any:
0505         if isinstance(output_spec, dict):
0506             return output_spec.get(key)
0507         return getattr(output_spec, key, None)
0508 
0509     def _read_text(self, path_str: Optional[str]) -> Optional[str]:
0510         if not path_str:
0511             return None
0512         path = Path(path_str)
0513         if not path.exists():
0514             return None
0515         return path.read_text(encoding="utf-8")