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

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"


_VALID_ENGINES = ("pandas", "arrow", "polars", "ray")


def resolve_engine(
    *,
    engine: str | None,
    data_backend: str | None,
    runner: str,
    warn: bool = False,
) -> str:
    """Resolve the execution engine from the new or deprecated selectors.

    Args:
        engine: The engine name, when given explicitly. Takes precedence.
        data_backend: Resolved backend name (the value that already accounts for
            the job's own `data_backend` default), used only as part of the
            deprecated `runner`/`data_backend` pair when `engine` is not given.
        runner: Deprecated runner selector.
        warn: Whether to emit a DeprecationWarning when falling back to the pair.

    Returns:
        One of "pandas", "arrow", "polars" or "ray".

    Raises:
        ValueError: If `engine` is not a supported name.
    """
    if engine is not None:
        if engine not in _VALID_ENGINES:
            raise ValueError(f"Unknown engine {engine!r}; choose one of {_VALID_ENGINES}.")
        return engine

    if warn:
        warnings.warn(
            "`runner` is deprecated; pass `engine=` instead (one of "
            f"{_VALID_ENGINES}). `data_backend` is not deprecated and keeps "
            "choosing the IO/conversion backend, but no longer affects routing. "
            "`runner` will be removed in a future release.",
            DeprecationWarning,
            stacklevel=3,
        )

    if data_backend == "ray":
        return "ray"
    if data_backend == "polars" and runner == "arrow":
        return "polars"
    if runner == "arrow":
        return "arrow"
    return "pandas"


# Engines whose implementation assumes a specific resolved data backend and
# cannot be handed anything else: `run_ray_job` takes `data` directly and
# expects a Ray Dataset, and `_run_polars` expects a Polars DataFrame/LazyFrame.
# `engine` decouples routing from `data_backend`, so this is the guard that
# stops a caller from naming a combination that would fail deep inside Ray
# Data or Polars instead of at the call site.
_ENGINE_REQUIRED_BACKEND = {"ray": "ray", "polars": "polars"}


[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", engine: str | None = None, 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"). Continues to select the `DataBackend` used for IO and conversions; it no longer affects the routing decision, which is made by `engine`. native_backend: Override for native execution (required for ray). checkpointing: Whether to read/write checkpoint state. runner: Deprecated. Use `engine` instead. Runner implementation to use for non-ray backends ("pandas" or "arrow"), consulted only when `engine` is not given. engine: Execution engine to use ("pandas", "arrow", "polars" or "ray"). Takes precedence over the deprecated `runner`/`data_backend` pair. Defaults to the value resolved from that pair when not given. 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 directly from checkpoint outputs and return None, rather than assembling the merged result in memory and returning it. Read by the pandas and polars engines; ignored by the Arrow and Ray engines. On the pandas and polars engines this also makes `global_resume` a no-op: shards are cut from the full input and each shard's own checkpoint store already supplies resume, which is equivalent to global resume as long as `nshards` stays fixed across resumes of the same job. 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. Ignored on the pandas and polars engines when `shard_output` is True (see `shard_output`). 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, if checkpointing is misconfigured, or if the resolved engine requires a data backend other than the one resolved (e.g. `engine="ray"` with a non-ray backend, or `engine="polars"` with a non-polars backend). 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) resolved_engine = resolve_engine( engine=engine, data_backend=backend.name, runner=runner, warn=engine is None and runner != "pandas", ) required_backend = _ENGINE_REQUIRED_BACKEND.get(resolved_engine) if required_backend is not None and backend.name != required_backend: raise ValueError( f"engine={resolved_engine!r} requires the {required_backend!r} data backend, " f"but the resolved backend is {backend.name!r}. Pass " f"data_backend={required_backend!r} (or declare it on the job)." ) if resolved_engine == "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 resolved_engine == "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 resolved_engine == "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, shard_output=shard_output, )