Source code for domyn_swarm.jobs.execution.dispatch

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

from __future__ import annotations

from collections.abc import Callable
from pathlib import Path
from typing import Any

from domyn_swarm.jobs.api.base import SwarmJob
from domyn_swarm.jobs.execution.arrow import _run_arrow
from domyn_swarm.jobs.execution.pandas import _run_pandas
from domyn_swarm.jobs.execution.polars import _run_polars
from domyn_swarm.jobs.execution.ray import run_ray_job
from domyn_swarm.jobs.io.backend import get_backend
from domyn_swarm.jobs.io.columns import _validate_required_id


def _has_new_api(job: SwarmJob) -> bool:
    """Return True if the job uses the new streaming API.

    Args:
        job: SwarmJob instance to inspect.

    Returns:
        True when the job is using the new API.
    """
    return resolve_job_api(job) == "new"


def _ensure_new_api(job: SwarmJob) -> None:
    """Validate that the job exposes the streaming API.

    Args:
        job: SwarmJob instance to validate.

    Raises:
        TypeError: If the job does not implement the streaming API.
    """
    if not _has_new_api(job):
        raise TypeError(
            "Job must implement the streaming API (transform_items/transform_streaming)."
        )


def _resolve_backend_name(job: SwarmJob, data_backend: str | None) -> str:
    """Resolve the backend name for a job run.

    Args:
        job: SwarmJob instance providing defaults.
        data_backend: Optional backend override.

    Returns:
        Backend name to use for IO and conversions.
    """
    return data_backend or getattr(job, "data_backend", None) or "pandas"


def _resolve_id_column(job: SwarmJob) -> tuple[str, bool]:
    """Resolve the id column name and whether it is required.

    Args:
        job: SwarmJob instance with optional id_column_name.

    Returns:
        Tuple of (id_col, require_id).

    Raises:
        ValueError: If the id_column_name is invalid.
    """
    raw_id_col = getattr(job, "id_column_name", None)
    if raw_id_col is not None:
        if not isinstance(raw_id_col, str):
            raise ValueError("id_column_name must be a string when provided.")
        if not raw_id_col.strip():
            raise ValueError("id_column_name must be a non-empty string.")
    return raw_id_col or "_row_id", raw_id_col is not None


def _resolve_ray_native(native_backend: bool | None) -> bool:
    """Resolve and validate native execution for the Ray backend.

    Args:
        native_backend: Optional native backend override.

    Returns:
        True when native execution is enabled.

    Raises:
        ValueError: If native execution is disabled for Ray.
    """
    native = bool(native_backend) if native_backend is not None else True
    if not native:
        raise ValueError("Ray backend requires native execution (native_backend=True).")
    return native


def _is_overridden(obj: object, method: str, base: type) -> bool:
    """Return True if `method` is implemented by `type(obj)` or an intermediate base.

    Args:
        obj: Object instance to inspect.
        method: Method name to check.
        base: Base class to compare against.

    Returns:
        True if the method is overridden by a subclass.
    """
    cls = type(obj)
    for c in cls.mro():
        if method in c.__dict__:
            return c is not base
    return False


def resolve_job_api(job: SwarmJob) -> str:
    """Resolve the job API version label for a SwarmJob.

    Args:
        job: SwarmJob instance to inspect.

    Returns:
        "new" for streaming API jobs, otherwise "old".
    """
    if getattr(type(job), "api_version", 1) >= 2:
        return "new"
    if _is_overridden(job, "transform_items", SwarmJob) or _is_overridden(
        job, "transform_streaming", SwarmJob
    ):
        return "new"
    if _is_overridden(job, "transform", SwarmJob) or _is_overridden(job, "run", SwarmJob):
        return "old"
    return "old"


[docs] async def run_job_unified( job_factory: Callable[[], Any], data: Any, *, input_col: str, output_cols: list[str] | None, nshards: int = 1, store_uri: str | None = None, checkpoint_every: int = 16, data_backend: str | None = None, native_backend: bool | None = None, checkpointing: bool = True, runner: str = "pandas", ray_address: str | None = None, output_path: Path | None = None, shard_output: bool = False, shard_mode: str = "id", global_resume: bool = False, ) -> Any: """Run a SwarmJob with backend-aware execution and checkpointing. Args: job_factory: Callable that returns a SwarmJob instance. data: Input dataset in backend-native form (pandas, polars, arrow, ray dataset, etc.). input_col: Column name to read inputs from. output_cols: Optional list of output column names (None for dict outputs). nshards: Number of shards to split input into for non-ray execution. store_uri: Base checkpoint store URI (required when checkpointing is enabled). checkpoint_every: Flush interval in items. data_backend: Backend name override (defaults to the job's `data_backend` or "pandas"). native_backend: Override for native execution (required for ray). checkpointing: Whether to read/write checkpoint state. runner: Runner implementation to use for non-ray backends ("pandas" or "arrow"). ray_address: Optional Ray cluster address (only used for ray backend). output_path: Optional output path used to enable direct shard writes when using the pandas runner and directory outputs. shard_output: If True and output_path is a directory, write one parquet file per shard (based on `nshards`) using checkpoint outputs as the source of truth when supported by the runner/backend (currently Polars). shard_mode: Sharding strategy ("id" for stable id hashing, "index" for legacy order). global_resume: Resume by filtering inputs with global done ids across shards. Returns: Backend-native result for non-ray runs, or the Ray runner result. Returns None when the pandas or polars paths write outputs directly to a directory. Raises: TypeError: If the job does not implement the streaming API. ValueError: If required id columns are missing or if checkpointing is misconfigured. RuntimeError: If the backend cannot be resolved. """ job_probe = job_factory() _ensure_new_api(job_probe) backend_name = _resolve_backend_name(job_probe, data_backend) id_col, require_id = _resolve_id_column(job_probe) backend = get_backend(backend_name) if backend.name == "ray": _resolve_ray_native(native_backend) if not require_id: raise ValueError("Ray backend requires a user-provided id column (use --id-column).") _validate_required_id(data, id_col) return await run_ray_job( job_factory, data, input_col=input_col, output_cols=output_cols or job_probe.default_output_cols, batch_size=getattr(job_probe, "native_batch_size", None) or checkpoint_every, output_mode=job_probe.output_mode, id_col=id_col, store_uri=store_uri, checkpointing=checkpointing, compact=True, ray_address=ray_address, ) if runner == "arrow" and backend.name == "polars": return await _run_polars( job_factory=job_factory, backend=backend, data=data, input_col=input_col, output_cols=output_cols or job_probe.default_output_cols, id_col=id_col, require_id=require_id, nshards=nshards, shard_mode=shard_mode, global_resume=global_resume, store_uri=store_uri, checkpoint_every=checkpoint_every, checkpointing=checkpointing, output_path=output_path, shard_output=shard_output, ) if runner == "arrow": result = await _run_arrow( job_factory=job_factory, backend=backend, data=data, input_col=input_col, output_cols=output_cols or job_probe.default_output_cols, id_col=id_col, require_id=require_id, nshards=nshards, shard_mode=shard_mode, global_resume=global_resume, store_uri=store_uri, checkpoint_every=checkpoint_every, checkpointing=checkpointing, ) return backend.from_arrow(result) return await _run_pandas( job_factory=job_factory, job_probe=job_probe, backend=backend, data=data, input_col=input_col, output_cols=output_cols, id_col=id_col, require_id=require_id, nshards=nshards, shard_mode=shard_mode, global_resume=global_resume, store_uri=store_uri, checkpoint_every=checkpoint_every, checkpointing=checkpointing, output_path=output_path, )