Source code for phenotypic.sdk_.slurm._dispatcher

"""Drip-feed dispatcher script generation for SLURM chunk chains.

Instead of submitting all array job chunks at once (which can exceed
``MaxSubmitJobsPerUser``), this module generates lightweight dispatcher
scripts that form a chain: when chunk N finishes, its dispatcher submits
chunk N+1 and the next dispatcher.  Queue occupancy stays at ~1 chunk
(``array_limit`` jobs) + 1 dispatcher (1 job) at any time.
"""

from __future__ import annotations

import logging
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple

logger = logging.getLogger(__name__)


def _extract_partition(slurm_args: Dict[str, Any]) -> str:
    """Extract the partition name from SLURM args.

    Args:
        slurm_args: SLURM parameters dict with CLI-style keys.

    Returns:
        Partition name, or ``"batch"`` as fallback.
    """
    for key in ("slurm_partition", "partition"):
        if key in slurm_args:
            return str(slurm_args[key])
    return "batch"


[docs] def generate_dispatcher_script( next_chunk_script: Path, next_dispatcher_script: Optional[Path], output_path: Path, slurm_args: Dict[str, Any], log_dir: Path, ) -> Path: """Generate a dispatcher script that submits the next chunk and dispatcher. The dispatcher requests minimal resources (1 CPU, 100M, 5 min) and runs two ``sbatch`` commands: one for the next processing chunk, and one for the next dispatcher (with ``--dependency`` on the chunk). Args: next_chunk_script: Path to the next array job chunk script. next_dispatcher_script: Path to the next dispatcher script, or ``None`` for the last chunk (no further dispatcher needed). output_path: Where to write the generated dispatcher script. slurm_args: SLURM parameters dict (used to extract partition). log_dir: Directory for dispatcher log files. Returns: Path to the generated dispatcher script. """ partition = _extract_partition(slurm_args) # Build the next-dispatcher block (empty for last chunk) if next_dispatcher_script is not None: next_dispatcher_block = ( f'DISPATCH_JOB=$(sbatch --parsable --dependency=afterany:$CHUNK_JOB {next_dispatcher_script})\n' f'echo "Submitted next dispatcher: $DISPATCH_JOB (depends on $CHUNK_JOB)"' ) else: next_dispatcher_block = 'echo "Last chunk — no further dispatcher needed"' script_content = f"""#!/bin/bash #SBATCH --job-name=dispatch #SBATCH --partition={partition} #SBATCH --time=00:05:00 #SBATCH --mem=100M #SBATCH --cpus-per-task=1 #SBATCH --output={log_dir}/dispatch_%j.log #SBATCH --error={log_dir}/dispatch_%j.log echo "Dispatcher: submitting next chunk" echo "Timestamp: $(date)" CHUNK_JOB=$(sbatch --parsable {next_chunk_script}) if [ $? -ne 0 ] || [ -z "$CHUNK_JOB" ]; then echo "ERROR: Failed to submit next chunk: {next_chunk_script}" exit 1 fi echo "Submitted chunk job: $CHUNK_JOB" # Submit next dispatcher (if not last chunk) {next_dispatcher_block} echo "Dispatch complete" """ output_path.parent.mkdir(parents=True, exist_ok=True) output_path.write_text(script_content) output_path.chmod(0o755) return output_path
[docs] def generate_dispatcher_chain( chunk_scripts: List[Path], output_dir: Path, slurm_args: Dict[str, Any], log_dir: Path, ) -> List[Path]: """Generate dispatcher scripts for a chain of chunk scripts. For *N* chunk scripts, generates *N-1* dispatcher scripts. Each dispatcher submits the next chunk and (if not last) the next dispatcher with ``--dependency=afterany`` on that chunk. Args: chunk_scripts: Ordered list of array job chunk script paths. output_dir: Directory to write dispatcher scripts into. slurm_args: SLURM parameters dict (partition, etc.). log_dir: Directory for dispatcher log files. Returns: List of dispatcher script paths (one fewer than ``chunk_scripts``, since the last chunk does not need a dispatcher). Empty if only one chunk exists. """ if len(chunk_scripts) <= 1: return [] log_dir.mkdir(parents=True, exist_ok=True) script_dir = output_dir / "slurm_scripts" script_dir.mkdir(parents=True, exist_ok=True) num_dispatchers = len(chunk_scripts) - 1 dispatcher_paths: List[Path] = [] # Build dispatcher scripts in forward order. Each dispatcher's path # is deterministic (dispatch_{idx}.sh), so we can reference the next # dispatcher by name without it existing on disk yet. for i in range(num_dispatchers): # Dispatcher i submits chunk_scripts[i+1] dispatcher_idx = i + 1 # 1-based naming: dispatch_1 submits chunk 1 dispatcher_path = script_dir / f"dispatch_{dispatcher_idx}.sh" # Next dispatcher (if any) if i + 1 < num_dispatchers: next_dispatcher = script_dir / f"dispatch_{dispatcher_idx + 1}.sh" else: next_dispatcher = None generate_dispatcher_script( next_chunk_script=chunk_scripts[i + 1], next_dispatcher_script=next_dispatcher, output_path=dispatcher_path, slurm_args=slurm_args, log_dir=log_dir, ) dispatcher_paths.append(dispatcher_path) return dispatcher_paths
[docs] def submit_drip_feed_start( chunk_scripts: List[Path], dispatcher_scripts: List[Path], ) -> Tuple[List[str], Optional[str]]: """Submit the first chunk and first dispatcher to start a drip-feed chain. Args: chunk_scripts: Ordered list of chunk script paths (must be non-empty). dispatcher_scripts: Dispatcher scripts from :func:`generate_dispatcher_chain` (may be empty for single-chunk). Returns: Tuple of (job_ids, warning_message). ``job_ids`` contains the submitted job IDs (1 or 2). ``warning_message`` is ``None`` on success, or a string with recovery instructions if the dispatcher submission failed (chunk 0 was still submitted). Raises: RuntimeError: If the first chunk submission fails. """ from ._sbatch import submit_script job_ids: List[str] = [] warning: Optional[str] = None chunk0_job = submit_script(chunk_scripts[0]) job_ids.append(chunk0_job) logger.info("Submitted chunk 0: Job %s", chunk0_job) if dispatcher_scripts: try: dispatch0_job = submit_script( dispatcher_scripts[0], dependency_job_id=chunk0_job, ) job_ids.append(dispatch0_job) logger.info( "Submitted dispatcher 1: Job %s (depends on %s)", dispatch0_job, chunk0_job, ) except RuntimeError as e: warning = ( f"Dispatcher submission failed: {e}\n" f"Only chunk 0 was submitted. To resume the chain " f"manually, run:\n" f" sbatch --dependency=afterany:{chunk0_job} " f"{dispatcher_scripts[0]}" ) logger.warning(warning) return job_ids, warning