Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 25 additions & 2 deletions snakemake_executor_plugin_slurm/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,17 @@ def _status_lookup_ids(external_jobid: str) -> List[str]:
class ExecutorSettings(ExecutorSettingsBase):
"""Settings for the SLURM executor plugin."""

_inherited_attempt: int = field(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm afraid, this will translate --slurm--inherited-attempt. The leading underscore does not hide an option.

I want to have hidden options, only for internal use, myself.

default=0,
metadata={
"help": "Internal field for passing attempt state to nested executors",
"env_var": False,
"required": False,
},
init=False,
repr=False,
)

array_jobs: Optional[str] = field(
default=None,
metadata={
Expand Down Expand Up @@ -636,7 +647,6 @@ def additional_general_args(self):
passed to `exec_job`.
"""
general_args = "--executor slurm-jobstep --jobs 1"
# need to pass
if self.workflow.executor_settings.pass_command_as_script:
general_args += " --slurm-jobstep-pass-command-as-script"
return general_args
Expand Down Expand Up @@ -770,6 +780,9 @@ def _report_job_error_threadsafe(self, job_info: SubmittedJobInfo, msg: str):

def run_array_jobs(self, jobs: List[JobExecutorInterface]):
try:
# Set inherited attempt for nested executor (use first job's attempt)
self.workflow.executor_settings._inherited_attempt = jobs[0].attempt

self.logger.debug(
f"Preparing to submit array job for rule {jobs[0].rule.name} "
f"with {len(jobs)} tasks."
Expand Down Expand Up @@ -884,7 +897,8 @@ def run_array_jobs(self, jobs: List[JobExecutorInterface]):
end_index = min(start_index + array_limit - 1, len(jobs))
# The first task of each chunk runs via the plain base command.
# Remaining tasks are dispatched from --slurm-jobstep-array-execs.
exec_job = self.format_job_exec(jobs[start_index - 1])
first_job = jobs[start_index - 1]
exec_job = self.format_job_exec(first_job)
sub_array_execs = {
str(i): array_execs[i]
for i in range(start_index + 1, end_index + 1)
Expand Down Expand Up @@ -1038,9 +1052,15 @@ def run_array_jobs(self, jobs: List[JobExecutorInterface]):
SubmittedJobInfo(job),
f"Array job submission failed with exception: {e}",
)
finally:
# Clean up inherited attempt
self.workflow.executor_settings._inherited_attempt = 0
Comment thread
coderabbitai[bot] marked this conversation as resolved.

def run_job(self, job: JobExecutorInterface):
try:
# Set inherited attempt for nested executor
self.workflow.executor_settings._inherited_attempt = job.attempt

group_or_rule = (
f"group_{job.name}" if job.is_group() else f"rule_{job.name}"
)
Expand Down Expand Up @@ -1221,6 +1241,9 @@ def run_job(self, job: JobExecutorInterface):
SubmittedJobInfo(job),
f"Job submission failed with exception: {e}",
)
finally:
# Clean up inherited attempt
self.workflow.executor_settings._inherited_attempt = 0

async def check_active_jobs(
self, active_jobs: List[SubmittedJobInfo]
Expand Down
158 changes: 158 additions & 0 deletions tests/test_attempt_sync.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
"""Tests for attempt synchronization between outer and nested executors."""

import pytest
from unittest.mock import MagicMock, patch
from snakemake_executor_plugin_slurm import ExecutorSettings


class TestAttemptInheritance:
"""Test _inherited_attempt field in ExecutorSettings."""

def test_inherited_attempt_field_exists(self):
"""Verify _inherited_attempt field is declared in ExecutorSettings."""
settings = ExecutorSettings()
assert hasattr(settings, "_inherited_attempt")

def test_inherited_attempt_not_in_init(self):
"""Verify _inherited_attempt cannot be set via __init__."""
# Should not be in __init__ signature due to init=False
settings = ExecutorSettings()
# Field should exist but start unset
assert hasattr(settings, "_inherited_attempt")

def test_inherited_attempt_can_be_set(self):
"""Verify _inherited_attempt can be set after initialization."""
settings = ExecutorSettings()
settings._inherited_attempt = 5
assert settings._inherited_attempt == 5

def test_inherited_attempt_not_in_repr(self):
"""Verify _inherited_attempt is excluded from repr."""
settings = ExecutorSettings()
settings._inherited_attempt = 3
repr_str = repr(settings)
assert "_inherited_attempt" not in repr_str


@pytest.fixture
def mock_executor():
"""Create a mock executor with minimal workflow setup."""
executor = MagicMock()
executor.workflow = MagicMock()
executor.workflow.executor_settings = ExecutorSettings()
return executor


@pytest.fixture
def mock_job():
"""Create a mock job with configurable attempt value."""

def _create_job(attempt=1):
job = MagicMock()
job.attempt = attempt
job.name = "test_job"
job.is_group.return_value = False
return job

return _create_job


class TestRunJobAttemptSync:
"""Test attempt synchronization in run_job method."""

@patch("snakemake_executor_plugin_slurm.Executor.run_job")
def test_run_job_sets_inherited_attempt(
self, mock_run_job, mock_executor, mock_job
):
"""Verify run_job sets _inherited_attempt before execution."""
job = mock_job(attempt=3)

# Simulate the key lines from run_job
mock_executor.workflow.executor_settings._inherited_attempt = job.attempt

assert mock_executor.workflow.executor_settings._inherited_attempt == 3

@patch("snakemake_executor_plugin_slurm.Executor.run_job")
def test_run_job_cleans_up_inherited_attempt(
self, mock_run_job, mock_executor, mock_job
):
"""Verify run_job cleans up _inherited_attempt in finally block."""
job = mock_job(attempt=3)

# Simulate setting and cleanup (using None as sentinel for cleanup)
mock_executor.workflow.executor_settings._inherited_attempt = job.attempt
assert mock_executor.workflow.executor_settings._inherited_attempt == 3

try:
pass # Simulate job execution
finally:
# Cleanup by resetting to 0 (matches actual implementation)
mock_executor.workflow.executor_settings._inherited_attempt = 0

# Should be cleaned up (reset to 0)
assert mock_executor.workflow.executor_settings._inherited_attempt == 0

def test_multiple_jobs_update_inherited_attempt(self, mock_executor, mock_job):
"""Verify _inherited_attempt updates correctly for sequential jobs."""
jobs = [mock_job(attempt=1), mock_job(attempt=2), mock_job(attempt=3)]

for job in jobs:
mock_executor.workflow.executor_settings._inherited_attempt = job.attempt
assert (
mock_executor.workflow.executor_settings._inherited_attempt
== job.attempt
)


class TestRunArrayJobsAttemptSync:
"""Test attempt synchronization in run_array_jobs method."""

def test_array_jobs_use_first_job_attempt(self, mock_executor, mock_job):
"""Verify run_array_jobs uses first job's attempt value."""
jobs = [mock_job(attempt=2), mock_job(attempt=2), mock_job(attempt=2)]

# Simulate array job submission
mock_executor.workflow.executor_settings._inherited_attempt = jobs[0].attempt

assert mock_executor.workflow.executor_settings._inherited_attempt == 2

def test_array_jobs_cleanup_inherited_attempt(self, mock_executor, mock_job):
"""Verify run_array_jobs cleans up _inherited_attempt."""
jobs = [mock_job(attempt=2)]

mock_executor.workflow.executor_settings._inherited_attempt = jobs[0].attempt
assert mock_executor.workflow.executor_settings._inherited_attempt == 2

try:
pass # Simulate array execution
finally:
# Cleanup by resetting to 0 (matches actual implementation)
mock_executor.workflow.executor_settings._inherited_attempt = 0

# Should be cleaned up (reset to 0)
assert mock_executor.workflow.executor_settings._inherited_attempt == 0


class TestNoEnvironmentVariableUsage:
"""Test that no environment variables are used for attempt passing."""

def test_no_snakemake_attempt_env_export(self, mock_executor, mock_job):
"""Verify SNAKEMAKE_ATTEMPT is not exported to environment."""
import os

job = mock_job(attempt=5)
mock_executor.workflow.executor_settings._inherited_attempt = job.attempt

# Verify environment variable is NOT set
assert "SNAKEMAKE_ATTEMPT" not in os.environ

@patch("snakemake_executor_plugin_slurm.Executor.additional_general_args")
def test_no_envvars_in_additional_args(self, mock_additional_args):
"""Verify --envvars does not include SNAKEMAKE_ATTEMPT."""
mock_additional_args.return_value = ""

result = mock_additional_args()

# Should not contain envvars argument for SNAKEMAKE_ATTEMPT
assert "--envvars SNAKEMAKE_ATTEMPT" not in result
assert "SNAKEMAKE_ATTEMPT" not in result
Loading