blob: 3e87558c16a49a474c47af5957109bbd393b1f6a [file] [edit]
############################################################################
# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
############################################################################
"""Tests for SchedulerParameters=time_min_as_soft_limit."""
from datetime import datetime, timedelta
import pytest
import atf
pytestmark = pytest.mark.slow
@pytest.fixture(scope="module", autouse=True)
def setup():
if atf.get_config_parameter("SelectType", live=False) == "select/linear":
atf.require_version(
(26, 11),
"sbin/slurmctld",
reason="Ticket 23101 (26.11+) added this planning improvement for select/linear",
)
else:
atf.require_version(
(25, 5, 4),
"sbin/slurmctld",
reason="Ticket 23426 (25.05.4+) standardized consideration of soft limit for job end times",
)
atf.require_config_parameter("SchedulerType", "sched/backfill")
# The planned start time is only checked if backfill plans a pending job,
# which it does only at or above bf_min_prio_reserve; the perf config
# raises it above our job's priority. Use 1 rather than the default 0: the
# backfill parser logs an explicit 0 as invalid, and 1 only excludes held
# (priority 0) jobs, which we have none.
atf.require_config_parameter_includes(
"SchedulerParameters", ("bf_min_prio_reserve", 1)
)
# An OverTimeLimit would delay the kill past the time this test waits for
atf.require_config_parameter("OverTimeLimit", 0)
atf.require_config_parameter_includes("SchedulerParameters", ("bf_interval", 1))
atf.require_config_parameter_includes("SchedulerParameters", ("sched_interval", 1))
atf.require_config_parameter_includes(
"SchedulerParameters", "time_min_as_soft_limit"
)
atf.require_nodes(1, [("CPUs", 2), ("RealMemory", 512)])
atf.require_config_parameter(
"PartitionName",
{
"lowprio": {"Nodes": "ALL", "Default": "YES"},
},
)
atf.require_slurm_running()
def _parse_time(time_str: str) -> datetime | None:
"""Parse a string into a datetime object, or into None if invalid
Args:
time_str (str): String that may be a valid datetime in ISO format
Returns:
datetime | None: datetime object if parsed successfully, or None on ValueError
"""
if time_str is None:
return None
elif time_str == "Unknown":
return None
else:
try:
result = datetime.fromisoformat(time_str)
return result
except ValueError:
return None
def _compare_times(
datetime1: datetime,
datetime2: datetime,
expected_delta: timedelta,
margin: timedelta,
) -> bool:
"""Test whether two times are close to an expected delta.
Args:
datetime1 (datetime): Smaller of the two times, in datetime object
datetime2 (datetime): Larger of the two times, in datetime object
expected_delta (timedelta): Expected delta between the two times
margin (timedelta): Margin by which the real delta may deviate from expected
Returns:
bool: Whether the two times are close to the expected delta
"""
real_delta = datetime2 - datetime1
early_enough = real_delta < (expected_delta + margin)
late_enough = real_delta > (expected_delta - margin)
return early_enough and late_enough
def test_time_min_as_soft_limit():
"""Verify that soft limits lead to proper future planning.
If soft limits are in play, pending jobs should plan against that limit,
not the hard limit, as long as the soft limit hasn't yet been reached.
Running jobs should not be killed until the hard limit is reached.
"""
soft_limit = timedelta(minutes=1)
hard_limit = timedelta(minutes=2)
# Jobs are checked every 30s for time limit violations, so this is the time
# by which we expect a job to be killed for exceeding the hard limit, plus
# a margin for scheduler latency on slow systems
kill_time = hard_limit + timedelta(seconds=40)
job_id1 = atf.submit_job_sbatch(
f'-c2 -o /dev/null --time-min={soft_limit} --time={hard_limit} --wrap "sleep infinity"',
fatal=True,
)
assert atf.wait_for_job_state(
job_id1, "RUNNING"
), f"First job ({job_id1}) did not start"
job_id2 = atf.submit_job_sbatch(
f'-c2 -o /dev/null --time-min={soft_limit} --time={hard_limit} --wrap "sleep infinity"',
fatal=True,
)
assert not atf.wait_for_job_state(
job_id2, "RUNNING", timeout=5, xfail=True
), f"Second job ({job_id2}) started earlier than expected"
for _ in atf.timer():
start_str1 = atf.get_job_parameter(job_id1, "StartTime", fatal=True)
start_dt1 = _parse_time(start_str1)
if start_dt1 is not None:
break
else:
pytest.fail(f"First job ({job_id1}) has no StartTime")
for _ in atf.timer(poll_interval=1):
start_str2 = atf.get_job_parameter(job_id2, "StartTime", fatal=True)
start_dt2 = _parse_time(start_str2)
if start_dt2 is not None:
break
else:
pytest.fail(f"Second job ({job_id2}) has no StartTime (received {start_str2})")
assert _compare_times(
start_dt1,
start_dt2,
expected_delta=soft_limit,
margin=timedelta(seconds=1),
), (
f"Second job ({job_id2}) start time ({start_dt2}) inconsistent with "
f"first job ({job_id1}) soft end time ({start_dt1} + {soft_limit})"
)
# Test job should be cancelled after hard limit plus periodic kill interval
assert atf.wait_for_job_state(
job_id1, "TIMEOUT", timeout=kill_time.total_seconds()
), f"Test job ({job_id1}) was not cancelled by reaching hard limit"
for _ in atf.timer():
end_str1 = atf.get_job_parameter(job_id1, "EndTime", fatal=True)
end_dt1 = _parse_time(end_str1)
if end_dt1 is not None:
break
else:
pytest.fail(f"First job ({job_id1}) has no EndTime")
# Use direct comparison here to ensure it ran for the full hard limit
assert end_dt1 - start_dt1 >= hard_limit, (
f"Test job ({job_id1}) ended before reaching hard limit "
f"(started {start_str1}, ended {end_str1}, hard limit {hard_limit})"
)