Source code for domyn_swarm.core.swarm

# SPDX-FileCopyrightText: 2025-2026 Domyn
# SPDX-License-Identifier: Apache-2.0

from __future__ import annotations

from collections.abc import Callable
import contextlib
import logging
import os
from pathlib import Path
from typing import TYPE_CHECKING, Any
import uuid
import warnings

from pydantic import (
    BaseModel,
    Field,
    PrivateAttr,
    computed_field,
)
from ulid import ULID

from domyn_swarm import utils
from domyn_swarm.config.plan import DeploymentContext, DeploymentPlan
from domyn_swarm.config.settings import get_settings
from domyn_swarm.config.swarm import DomynLLMSwarmConfig
from domyn_swarm.core.job_run import JobRunSpec
from domyn_swarm.deploy.deployment import Deployment
from domyn_swarm.helpers.io import to_path
from domyn_swarm.helpers.logger import setup_logger
from domyn_swarm.helpers.swarm import generate_swarm_name
from domyn_swarm.platform.protocols import (
    ComputeBackend,
    JobHandle,
    JobProbe,
    JobStatus,
    ServingHandle,
    ServingPhase,
    ServingStatus,
    coerce_job_status,
)

from ..core.state.state_manager import SwarmStateManager

if TYPE_CHECKING:
    from domyn_swarm.jobs import SwarmJob

logger = setup_logger(__name__, level=logging.INFO)


[docs] class DomynLLMSwarm(BaseModel): """Context manager orchestrating a distributed LLM serving swarm. Provides a unified interface for deploying, managing and interacting with a large language model serving cluster on a compute backend such as Slurm or Lepton. It handles the whole lifecycle from resource allocation to cleanup, with state persistence and job submission built in. A swarm consists of a load balancer (nginx) distributing requests, several vLLM server instances serving the model, a head node coordinating jobs and running user scripts, and persisted state enabling recovery and reconnection. On Slurm the swarm is deployed as a job array with roles assigned by `SLURM_NODEID`: node 0 runs the load balancer and the user driver, nodes 1 to N run vLLM servers. Cloud platforms are reached through the deployment abstractions instead. State is persisted automatically - deployment metadata and resource handles, configuration, platform identifiers such as job IDs and node assignments, and endpoint URLs - which is what allows a swarm to be recovered after a failure or reattached from another process via `from_state`. Attributes: cfg: Deployment parameters, resource requirements and platform-specific settings. endpoint: Public URL of the deployed load balancer. Set once deployment succeeds through the context manager. delete_on_exit: Whether to clean up all allocated resources when leaving the context manager. Useful for temporary deployments. serving_handle: Platform-specific handle for the serving deployment, carrying metadata such as job IDs, node assignments and status. model: Name or path of the model being served. May be set after initialization to switch models. preflight: Whether to check the config's filesystem paths before deploying. Off for sites where they are visible only from the compute nodes. Raises: RuntimeError: If resource allocation fails; the message carries diagnostic information. subprocess.CalledProcessError: Propagated from a failed job submission. Note: The swarm must be used as a context manager for resources to be managed correctly. Startup waits up to `cfg.wait_endpoint_s` for the endpoint. Paths passed to job submission resolve relative to the execution environment, `ENDPOINT` and `MODEL` are set automatically for submitted jobs, and checkpoint directories are created as needed. Cleanup failures are logged but do not prevent the context from exiting. Example: Basic deployment, cleaned up on exit: :: cfg = DomynLLMSwarmConfig.read("config.yaml") with DomynLLMSwarm(cfg=cfg) as swarm: # Reachable at swarm.endpoint swarm.submit_job( my_job, run=JobRunSpec( input_path=Path("data.parquet"), output_path=Path("results.parquet"), ), ) Persistent deployment, reattached later from another process: :: swarm = DomynLLMSwarm(cfg=cfg, delete_on_exit=False) with swarm: pass # Resources remain allocated swarm = DomynLLMSwarm.from_state("my-deployment-abc123") Detached job submission: :: with DomynLLMSwarm(cfg=cfg) as swarm: handle = swarm.submit_job( job=LongRunningJob(), run=JobRunSpec( input_path=Path("large_dataset.parquet"), output_path=Path("results.parquet"), detach=True, ), ) print(handle.pid, handle.external_id) Running a script on the head node: :: with DomynLLMSwarm(cfg=cfg) as swarm: swarm.submit_script(Path("analysis.py"), extra_args=["--mode", "evaluation"]) See Also: SwarmJob: Base class for jobs executable within the swarm. DomynLLMSwarmConfig: Configuration schema and validation. """ cfg: DomynLLMSwarmConfig name: str = Field( default_factory=lambda data: data.get( "name", generate_swarm_name(data["cfg"].name, data["cfg"].backend.type) ), description="Unique name for this swarm deployment", ) endpoint: str | None = None # LB endpoint, set after job submission delete_on_exit: bool | None = ( False # Delete the resources for this cluster at the end of the job ) serving_handle: ServingHandle | None = None # ServingHandle, set after deployment preflight: bool = Field( default=True, description=( "Check that the config's image, model and mount paths exist before " "deploying. Turn off where they are visible only from the nodes." ), ) swarm_dir: utils.EnvPath = Field( description="Directory where swarm-related files are stored", default_factory=lambda data: data["cfg"].home_directory / "swarms" / data["name"], ) watchdog_db_path: utils.EnvPath = Field( description="Path to the watchdog SQLite database file", default_factory=lambda data: data["swarm_dir"] / "watchdog.db", ) _plan_cache: DeploymentPlan | None = PrivateAttr(default=None) _deployment_cache: Deployment | None = PrivateAttr(default=None) @computed_field @property def model(self) -> str: """ The model name, either from the config or the job submission. If not set, defaults to the config's model. """ return self.cfg.model @model.setter def model(self, value: str): """ Setter for the model name. This allows setting the model after the swarm has been created, e.g., when loading from a state file. """ self.cfg.model = value @property def platform(self) -> str: """The platform this swarm deploys to, e.g. ``"slurm"`` or ``"lepton"``. Derived from ``cfg.backend.type``, which is the authoritative value and is present in every persisted record. """ assert self.cfg.backend is not None, "Swarm config has no backend" return self.cfg.backend.type
[docs] def model_post_init(self, __context: Any) -> None: """Wire up in-memory collaborators. Performs no I/O.""" self._cleaned = False self._state_mgr = SwarmStateManager(self)
[docs] def ensure_directories(self) -> None: """Create this swarm's on-disk layout. Idempotent. Called before deploying (the sbatch templates reference these paths and Slurm rejects a submission whose output directory is missing) and before writing checkpoints on a swarm reattached via :meth:`from_state`, which does not pass through the context manager. """ for directory in ( self.swarm_dir, self.swarm_dir / "serving", self.swarm_dir / "jobs", self.swarm_dir / "checkpoints", self.swarm_dir / "logs" / "endpoint", self.swarm_dir / "logs" / "replicas", self.swarm_dir / "logs" / "slurm", ): os.makedirs(directory, exist_ok=True)
@property def _plan(self) -> DeploymentPlan: """The deployment plan, built on first access. Returns: The plan for this swarm's configured backend. Raises: RuntimeError: If the config carries no backend, so no plan exists. """ if self._plan_cache is None: plan = self.cfg.get_deployment_plan() if plan is None and self.cfg.backend is not None: plan = self.cfg.build_plan() if plan is None: raise RuntimeError("Swarm config has no backend, so no plan can be built") self._plan_cache = plan return self._plan_cache @property def _deployment(self) -> Deployment: """The deployment, built on first access from the plan. Returns: The (serving, compute) pair this swarm drives. """ if self._deployment_cache is None: plan = self._plan self._deployment_cache = Deployment( serving=plan.serving, compute=plan.compute, # type: ignore[arg-type] extras=plan.extras | {"swarm_directory": str(self.swarm_dir)}, ) return self._deployment_cache def __enter__(self): # Before anything is created on disk. Skipped when re-attaching: a # deployed swarm's resources exist whatever its images look like now. if self.serving_handle is None and self.preflight: from domyn_swarm.config.preflight import raise_on_path_problems raise_on_path_problems(self.cfg) self.ensure_directories() # If instantiating from state, the swarm will be already deployed, # so just return self if self.serving_handle is not None: return self assert self._deployment is not None serving_spec = dict(self._plan.serving_spec, swarm_directory=self.swarm_dir) ctx = DeploymentContext( serving_spec=serving_spec, job_resources=self._plan.job_resources, extras=self._plan.extras | {"swarm_directory": str(self.swarm_dir)}, timeout_s=self._plan.timeout_s or self.cfg.wait_endpoint_s, shared_env=self._plan.shared_env, image=self._plan.image, ) logger.info(f"Creating deployment [cyan]{self.name}[/cyan] on {self.platform}...") handle = self._deployment.up(self.name, ctx) # Record the handle and persist it *before* waiting for readiness. From # here on the platform holds real resources, so both this object and the # state DB must know about them -- otherwise a failure below would strand # the allocation with no way for `domyn-swarm down` to find it. self.serving_handle = handle self._persist(self.name) try: handle = self._deployment.wait_ready( timeout_s=ctx.timeout_s or self.cfg.wait_endpoint_s ) # Update the handle and deployment with the ready state self.serving_handle = handle self.endpoint = handle.url self._deployment.compute = self._make_compute_backend(handle) # Persist again now that the endpoint is known self._persist(self.name) except Exception: # KeyboardInterrupt is deliberately not caught: interrupting `up` is a # user decision, and the CLI asks whether to cancel or keep waiting. self._cleanup_failed_startup() raise return self def _cleanup_failed_startup(self) -> None: """Release resources allocated by a startup that did not complete. Never raises: a teardown failure must not mask the error that caused the startup to fail. When teardown does fail the state record is deliberately left in place so the deployment stays reachable via ``domyn-swarm down``. """ logger.warning(f"Deployment [cyan]{self.name}[/cyan] failed to start; releasing resources") try: self.cleanup() except Exception as exc: logger.error( f"Could not release resources for [cyan]{self.name}[/cyan]: {exc}. " f"The deployment is still recorded -- run `domyn-swarm down {self.name}` to retry." ) def __exit__(self, exc_type, exc, tb): if self.delete_on_exit: self.cleanup() def _persist(self, deployment_name: str): """Save the state. Args: deployment_name (str): Deployment name. """ self.cfg.persist(self.swarm_dir / "config.yaml") self._state_mgr.save(deployment_name)
[docs] @classmethod def from_state(cls, deployment_name: str) -> DomynLLMSwarm: """Initialize a swarm from a saved state. Args: deployment_name (str): Deployment name. Returns: DomynLLMSwarm: Loaded swarm. """ return SwarmStateManager.load(deployment_name)
[docs] @classmethod def from_record( cls, swarm_payload: dict, cfg_payload: dict, handle_payload: dict, ) -> DomynLLMSwarm: """Build a complete swarm from persisted state. Args: swarm_payload: The persisted ``swarm`` column, without ``cfg``. cfg_payload: The persisted ``cfg`` column. handle_payload: The persisted ``serving_handle`` column. Returns: A swarm with its handle adopted and its compute backend built. Raises: ValueError: If the record's serving handle is missing metadata the platform needs; see :meth:`~domyn_swarm.config.plan.DeploymentPlan.validate_serving_handle`. """ swarm = cls.model_validate({**swarm_payload, "cfg": cfg_payload}) handle = ServingHandle(**handle_payload) assert swarm._plan is not None, "Swarm was built without a deployment plan" swarm._plan.validate_serving_handle(handle) swarm.serving_handle = handle swarm._deployment.adopt(handle) swarm._deployment.compute = swarm._plan.make_compute_backend(handle) return swarm
def _delete_record(self) -> None: """Delete swarm from the DB Args: deployment_name (str): Deployment name. """ self._state_mgr.delete_record(self.name) def _record_job_submission( self, *, name: str, command: list[str], resources: dict | None, kind: str, status: JobStatus, external_id: str | None = None, error: str | None = None, ) -> str | None: """Persist job metadata in the local swarm DB. Args: name: Job name. command: Command argv list (no secrets). resources: Resource dict (if any). kind: Job kind (e.g., "step", "script"). status: Job status. external_id: Optional backend external identifier. error: Optional error message. Returns: Job record ID if created, else None. """ try: job_id = SwarmStateManager.create_job( deployment_name=self.name, provider=self.platform, kind=kind, status=status, external_id=external_id, name=name, command=command, resources=resources, error=error, ) return job_id except Exception as exc: logger.warning("Failed to persist job record: %s", exc) return None def _update_job_submission( self, *, job_id: str | None, status: JobStatus, external_id: str | None = None, log_paths: dict | None = None, error: str | None = None, ) -> None: """Update job submission state in the local swarm DB. Args: job_id: Internal persisted job ID. status: Job status. external_id: Optional backend external identifier. log_paths: Optional backend log path dictionary. error: Optional error message. """ if not job_id: return try: SwarmStateManager.update_job( job_id, status=status, external_id=external_id, log_paths=log_paths, error=error, ) except Exception as exc: logger.warning("Failed to update job record %s: %s", job_id, exc) def _submit_with_tracking( self, *, name: str, command: list[str], resources: dict | None, kind: str, submit: Callable[[], object | None], ) -> JobHandle: """Submit a compute job and persist lifecycle transitions. Args: name: Job name. command: Command argv list to persist. resources: Optional resource requirements. kind: Job kind (for persistence). submit: Backend submission callable. Returns: Submitted job handle. Raises: RuntimeError: If backend submission returns ``None``. Exception: Re-raises backend submission errors. """ job_record_id = self._record_job_submission( name=name, command=command, resources=resources, kind=kind, status=JobStatus.PENDING, ) try: raw_handle = submit() except Exception as exc: self._update_job_submission( job_id=job_record_id, status=JobStatus.FAILED, error=str(exc), ) raise if raw_handle is None: error_msg = "Failed to submit job to compute backend." self._update_job_submission( job_id=job_record_id, status=JobStatus.FAILED, error=error_msg, ) raise RuntimeError(error_msg) if isinstance(raw_handle, JobHandle): job_handle = raw_handle else: raw_meta = getattr(raw_handle, "meta", None) job_handle = JobHandle( id=str(getattr(raw_handle, "id", name)), status=coerce_job_status(getattr(raw_handle, "status", JobStatus.PENDING)), meta=dict(raw_meta) if isinstance(raw_meta, dict) else {}, ) normalized_status = coerce_job_status(job_handle.status) job_handle.status = normalized_status self._update_job_submission( job_id=job_record_id, status=normalized_status, external_id=job_handle.external_id, log_paths=job_handle.meta.get("log_paths"), ) if job_record_id: job_handle.meta["job_id"] = job_record_id return job_handle
[docs] def submit_job( self, job: SwarmJob, *, run: JobRunSpec, ) -> JobHandle: """Launch a serialized :class:`~domyn_swarm.SwarmJob` inside the current SLURM swarm allocation. The *job* object is converted to keyword arguments via :py:meth:`SwarmJob.to_kwargs`, transmitted to the head node (where ``SLURM_NODEID == 0``), reconstructed by ``domyn_swarm.jobs.cli.run``, and executed under ``srun``. Args: job: The job instance to execute. run: How the job should run: input/output paths, sharding, checkpointing, engine selection, and resources. See :class:`~domyn_swarm.core.job_run.JobRunSpec` for the full field list and their semantics. Returns: JobHandle: Compute job handle with normalized status and metadata. Raises: RuntimeError: The swarm is not ready (`self.serving_handle` or `self.endpoint` is ``None``). FileNotFoundError: `run.input_path` does not exist. subprocess.CalledProcessError: Propagated when the synchronous ``srun`` command exits with a non-zero status code. Note: The constructed command is logged with *rich* for transparency, e.g.:: srun --jobid=<...> --nodelist=<...> --ntasks=1 --overlap ... python -m domyn_swarm.jobs.cli.run --job-class=<module:Class> ... Example: :: swarm.submit_job( my_job, run=JobRunSpec( input_path=Path("batch.parquet"), output_path=Path("predictions.parquet"), num_shards=4, ), ) """ num_shards = run.num_shards if run.num_threads is not None: warnings.warn( "`num_threads` is deprecated and will be removed in a future release; " "use `num_shards`. The value has always been a shard count, not a " "thread count, and it is part of the checkpoint layout.", DeprecationWarning, stacklevel=2, ) num_shards = run.num_threads # A swarm reattached via `from_state` never passed through the context # manager, so its layout may not exist yet. self.ensure_directories() checkpoint_dir = ( self.swarm_dir / "checkpoints" if run.checkpoint_dir is None else to_path(run.checkpoint_dir) ) input_parquet = to_path(run.input_path) output_parquet = to_path(run.output_path) from domyn_swarm.jobs import JobBuilder job_class = JobBuilder.to_class_path(job) job_kwargs = JobBuilder.to_kwargs_json(job) python_interpreter, image, resources, env = self._compose_runtime() resources = self._merge_resources(resources, None, run.job_resources) env = self._augment_job_env(env, job, job_class, ray_address=run.ray_address) exe = self._build_job_command( job=job, job_class=job_class, job_kwargs=job_kwargs, input_parquet=input_parquet, output_parquet=output_parquet, num_shards=num_shards, checkpoint_dir=checkpoint_dir, checkpoint_interval=run.checkpoint_interval, runner=run.runner, engine=run.engine, no_resume=run.no_resume, no_checkpointing=run.no_checkpointing, checkpoint_tag=run.checkpoint_tag, shard_output=run.shard_output, shard_mode=run.shard_mode, global_resume=run.global_resume, limit=run.limit, ray_address=env.get("DOMYN_SWARM_RAY_ADDRESS"), python_interpreter=str(python_interpreter), ) job_name = job.name.lower() if job.name else f"{self.name}-job" ulid = str(ULID()) job_name = f"{self.name}-{job_name}-{ulid.lower()}" logger.info( f"Submitting {job.__class__.__name__} [cyan]{job_name}[/cyan] job " f"to swarm {self.name} on {self.platform}" ) return self._submit_with_tracking( name=job_name[:36], # type: ignore[arg-type] command=[*map(str, exe)], resources=resources, kind="step", submit=lambda: self._deployment.run( name=job_name[:36], # type: ignore[arg-type] image=image, command=exe, env=env, resources=resources, detach=run.detach, ), )
def _augment_job_env( self, env: dict, job: SwarmJob, job_class: str, *, ray_address: str | None, ) -> dict: """Augment the base environment for job submission. Args: env: Base environment variables. job: Job instance being submitted. job_class: Fully qualified job class name. ray_address: Optional Ray address override. Returns: Environment dictionary for the job. """ env = dict(env) env.update( { "ENDPOINT": self.endpoint, "MODEL": self.model, "JOB_CLASS": job_class, } ) token = get_settings().resolved_api_token if token: env["DOMYN_SWARM_API_TOKEN"] = token.get_secret_value() env["VLLM_API_KEY"] = token.get_secret_value() if getattr(job, "data_backend", None) == "ray": resolved_ray_address = ( ray_address or env.get("DOMYN_SWARM_RAY_ADDRESS") or env.get("RAY_ADDRESS") or os.environ.get("DOMYN_SWARM_RAY_ADDRESS") or os.environ.get("RAY_ADDRESS") ) if not resolved_ray_address: raise ValueError( "Ray backend requires an explicit ray address. Provide --ray-address or set " "DOMYN_SWARM_RAY_ADDRESS/RAY_ADDRESS in the swarm environment." ) env["DOMYN_SWARM_RAY_ADDRESS"] = resolved_ray_address return env def _build_job_command( self, *, job: SwarmJob, job_class: str, job_kwargs: str, input_parquet: Path, output_parquet: Path, num_shards: int, checkpoint_dir: str | Path, checkpoint_interval: int | None, runner: str, engine: str | None, no_resume: bool, no_checkpointing: bool, checkpoint_tag: str | None, shard_output: bool, shard_mode: str, global_resume: bool, limit: int | None, ray_address: str | None, python_interpreter: str, ) -> list[str]: """Build the job runner command to execute inside the swarm. Args: job: Job instance being submitted. job_class: Fully qualified job class name. job_kwargs: Serialized job kwargs JSON. input_parquet: Input dataset path. output_parquet: Output dataset path. num_shards: Number of shards to split the input into. checkpoint_dir: Checkpoint directory. checkpoint_interval: Checkpoint interval override. runner: Deprecated runner implementation name; consulted only when `engine` is not given. engine: Execution engine to use, emitted as `--engine=...` when given. no_resume: Whether to ignore existing checkpoints. no_checkpointing: Whether to disable checkpointing. checkpoint_tag: Optional checkpoint tag override. shard_output: Whether to write shard outputs to a directory. shard_mode: Sharding strategy. global_resume: Whether to enable global resume. limit: Optional input row limit. ray_address: Optional Ray address override. python_interpreter: Python interpreter path to use. Returns: Command list for job execution. """ exe = [ python_interpreter, "-m", "domyn_swarm.jobs.cli.run", f"--job-class={job_class}", f"--model={self.model}", f"--input-parquet={input_parquet}", f"--output-parquet={output_parquet}", f"--endpoint={self.endpoint}", f"--num-shards={num_shards}", f"--checkpoint-dir={checkpoint_dir}", f"--checkpoint-interval={checkpoint_interval or job.checkpoint_interval}", f"--runner={runner}", "--job-kwargs", job_kwargs, ] if no_resume: exe.append("--no-resume") if no_checkpointing: exe.append("--no-checkpointing") if checkpoint_tag: exe.append(f"--checkpoint-tag={checkpoint_tag}") if engine is not None: exe.append(f"--engine={engine}") if shard_output: exe.append("--shard-output") if shard_mode: exe.append(f"--shard-mode={shard_mode}") if global_resume: exe.append("--global-resume") if limit: exe.append(f"--limit={limit}") if ray_address: exe.append(f"--ray-address={ray_address}") return exe def _compose_runtime(self, extra_env: dict | None = None): """Build interpreter/image/resources/env in one place.""" self._deployment.ensure_ready() assert self._deployment.compute is not None, "Compute backend not initialized" compute = self._deployment.compute python_interpreter = compute.default_python(self.cfg) image = ( self._plan.image if self._plan and self._plan.image else compute.default_image(self.cfg.backend) ) resources = self._merge_resources( compute.default_resources(self.cfg.backend), self._plan.job_resources if self._plan else None, None, ) env = { "ENDPOINT": self.endpoint, "MODEL": self.model, } if self._plan and self._plan.shared_env: env.update(self._plan.shared_env) # Global backend env from config if getattr(self.cfg, "backend", None) and getattr(self.cfg.backend, "env", None): env.update(self.cfg.backend.env) # type: ignore[attr-defined] # Backend-specific overrides overrides = compute.default_env(self.cfg) if overrides: env.update(overrides) # Call-site extras if extra_env: env.update(extra_env) return str(python_interpreter), image, resources, env @staticmethod def _merge_resources( base: dict | None, plan_resources: dict | None, overrides: dict | None, ) -> dict | None: if not base and not plan_resources and not overrides: return None merged: dict = {} if base: merged.update(base) if plan_resources: merged.update(plan_resources) if overrides: merged.update(overrides) return merged
[docs] def submit_script( self, script_path: Path, detach: bool = False, extra_args: list[str] | None = None, job_resources: dict | None = None, ) -> JobHandle: """Submit a Python script to the compute backend for execution. This method validates the script path, composes the runtime environment, and submits the script for execution via the configured deployment backend. Args: script_path (Path): Path to the Python script to be executed. detach (bool, optional): If True, run the script in detached mode. If False, run synchronously. Defaults to False. extra_args (list[str] | None, optional): Additional command-line arguments to pass to the script. Defaults to None. job_resources: Scheduler resource overrides for the job step. They take precedence over the compute backend and plan defaults. Returns: JobHandle: Submitted job handle with normalized status and metadata. Raises: FileNotFoundError: If the script file does not exist (only checked for SLURM platform). RuntimeError: If the script submission to the compute backend fails. Example: >>> swarm = Swarm(...) >>> # Submit script synchronously >>> swarm.submit_script(Path("my_script.py")) >>> # Submit script in detached mode with arguments >>> handle = swarm.submit_script( ... Path("my_script.py"), detach=True, extra_args=["--config", "config.yaml"] ... ) >>> handle.pid """ # Basic validation if self.platform == "slurm" and not script_path.is_file(): raise FileNotFoundError(f"Script not found: {script_path}") # Compose runtime (interpreter/image/resources/env) once python_interpreter, image, resources, env = self._compose_runtime() resources = self._merge_resources(resources, None, job_resources) # Build the command args = extra_args or [] command = [python_interpreter, str(script_path), *args] script_name = f"{self.name.lower()}-script" return self._submit_with_tracking( name=script_name, command=[*map(str, command)], resources=resources, kind="script", submit=lambda: self._deployment.run( name=script_name, # type: ignore[arg-type] image=image, command=command, env=env, resources=resources, detach=detach, ), )
def _require_compute_backend(self): """Return the initialized compute backend. Raises: RuntimeError: If compute backend is not initialized. """ compute = self._deployment.compute if compute is None: raise RuntimeError("Compute backend is not initialized for this swarm.") return compute
[docs] def wait_job(self, handle: JobHandle, *, stream_logs: bool = True) -> JobStatus: """Wait for a submitted compute job to reach a terminal state. Args: handle: Job handle to wait on. stream_logs: Whether to stream backend logs while waiting. Returns: Normalized terminal job status. """ compute = self._require_compute_backend() status = compute.wait(handle, stream_logs=stream_logs) normalized_status = coerce_job_status(status) handle.status = normalized_status return normalized_status
[docs] def cancel_job(self, handle: JobHandle) -> JobStatus: """Cancel a submitted compute job. Args: handle: Job handle to cancel. Returns: Final normalized status after cancellation. """ compute = self._require_compute_backend() compute.cancel(handle) normalized_status = coerce_job_status( getattr(handle, "status", JobStatus.CANCELLED), default=JobStatus.CANCELLED, ) if normalized_status != JobStatus.CANCELLED: normalized_status = JobStatus.CANCELLED handle.status = normalized_status return normalized_status
[docs] def refresh_job_status(self, job_id: str) -> dict[str, Any]: """Refresh a persisted job status via backend probe (best effort). Args: job_id: Internal persisted job identifier. Returns: Updated job record payload, including transient refresh metadata: ``refresh_source`` and ``refresh_error``. """ record = SwarmStateManager.get_job(job_id) external_id = record.get("external_id") status = coerce_job_status(record.get("status", JobStatus.PENDING)) handle = JobHandle( id=str(external_id or job_id), status=status, meta={ "job_id": job_id, "external_id": external_id, }, ) if not external_id: record["refresh_source"] = "db" record["refresh_error"] = "Missing external_id; backend probe skipped." return record compute = self._require_compute_backend() probe: JobProbe try: probe = compute.probe(handle) except Exception as exc: refresh_error = str(exc) logger.warning("Job status probe failed for %s: %s", job_id, refresh_error) with contextlib.suppress(Exception): SwarmStateManager.update_job(job_id, error=refresh_error) refreshed = SwarmStateManager.get_job(job_id) refreshed["refresh_source"] = "backend" refreshed["refresh_error"] = refresh_error return refreshed try: SwarmStateManager.update_job( job_id, status=probe.status, raw_status=probe.raw_status, external_id=handle.external_id or external_id, error=probe.error, ) except Exception as exc: logger.warning("Failed to persist probed status for %s: %s", job_id, exc) refreshed = SwarmStateManager.get_job(job_id) refreshed["refresh_source"] = probe.source refreshed["refresh_error"] = probe.error return refreshed
[docs] def cleanup(self): """Release the swarm's resources and drop its state record. Idempotent: calling it again after a successful teardown is a no-op, so overlapping paths (a failed startup, ``__exit__``, an explicit ``down()``) cannot issue a second cancel against the platform. The record is dropped only once teardown succeeds -- if ``down`` raises, the record survives so the deployment stays reachable for a retry. """ if self._cleaned: return # Handle first: a swarm that never deployed needs no deployment built # just to decide there is nothing to tear down. if self.serving_handle and self._deployment: self._deployment.down(self.serving_handle) self._delete_record() self._cleaned = True
[docs] def down(self): """Manually clean up the swarm resources.""" self.cleanup()
def _deployment_name(self) -> str: unique_id = uuid.uuid4() short_id = str(unique_id)[:8] return f"{self.cfg.name}-{short_id}" def _make_compute_backend(self, handle: ServingHandle) -> ComputeBackend: """Build the compute backend for a ready serving handle.""" assert self._plan is not None return self._plan.make_compute_backend(handle)
[docs] def status(self) -> ServingStatus: """Get the current status of the swarm. Safe to call at any point in the lifecycle: a swarm that has not been deployed reports :attr:`ServingPhase.UNKNOWN` rather than raising. Returns: The backend-reported serving status, falling back to the endpoint this swarm already knows about when the backend reports none. """ if self._deployment is None: return ServingStatus(phase=ServingPhase.UNKNOWN, url=self.endpoint) status = self._deployment.status() if status.url is None: status.url = self.endpoint return status
[docs] def serving_status(self) -> ServingStatus: """Ask the serving backend for this swarm's live status. Unlike :meth:`status`, which reports what this object already knows, this queries the backend directly. Safe at any point in the lifecycle: a swarm that was never deployed reports :attr:`~domyn_swarm.platform.protocols.ServingPhase.UNKNOWN`. Returns: The backend-reported serving status. """ # Handle first, so a never-deployed swarm answers without building a # deployment (and importing its serving backend) to do it. if self.serving_handle is None or self._deployment is None: return ServingStatus(phase=ServingPhase.UNKNOWN, url=self.endpoint) return self._deployment.serving.status(self.serving_handle)