blob: 4177f520f6c1721d11b578406eb85ea2e79619ad [file] [edit]
############################################################################
# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
############################################################################
"""Test the jobcomp/lua job start event."""
import re
import pytest
import atf
jobcomp_log = None
# The event name is the second argument of slurm_jobcomp_log_record(). The
# job fields come from the job record table. state_desc goes last because
# it may contain spaces.
lua_script_template = """
function slurm_jobcomp_log_record(job, event)
local f = io.open("{log}", "a")
f:write(string.format(
"job_id=%d event=%s priority=%d restart_cnt=%d sluid=%s " ..
"state_reason=%s state_desc=%s\\n",
job.job_id, event, job.priority, job.restart_cnt, job.sluid,
tostring(job.state_reason), tostring(job.state_desc)))
f:close()
return slurm.SUCCESS
end
"""
@pytest.fixture(scope="module", autouse=True)
def setup():
global jobcomp_log
# require_config_file() can only verify .conf and .yaml files on a
# local-config site, so the jobcomp.lua install needs auto-config
atf.require_auto_config("wants to install a jobcomp.lua script")
atf.require_version(
(26, 11),
component="sbin/slurmctld",
reason="Issue 51114: jobcomp/lua job state transition events require 26.11+",
)
# jobcomp/lua is only built with lua support
atf.require_build_config("HAVE_LUA")
# slurmctld writes the log from its own working directory, so it needs
# an absolute path
jobcomp_log = atf.module_tmp_path / "jobcomp.log"
# The plugin loads jobcomp.lua from the directory of slurm.conf
atf.require_config_file("jobcomp.lua", lua_script_template.format(log=jobcomp_log))
atf.require_config_parameter("JobCompType", "jobcomp/lua")
# Only the start event is enabled, so a job must record exactly its start
# and its finish
atf.require_config_parameter_includes("JobCompParams", "enable_job_start")
# A requeued job is only eligible again after requeue_delay, which defaults
# to the AuthInfo cred_expire (120 seconds), and is only started by the
# next scheduling cycle. The (key, value) form replaces a value the site
# config already sets.
atf.require_config_parameter_includes("SchedulerParameters", ("requeue_delay", 0))
atf.require_config_parameter_includes("SchedulerParameters", ("sched_interval", 1))
atf.require_slurm_running()
def get_job_records(job_id):
"""Return the records the script logged for a job, as dicts."""
# The script creates the log on its first call, so it may not exist yet
output = atf.run_command_output(f"cat {jobcomp_log}", quiet=True)
records = []
for line in re.findall(rf"^job_id={job_id} .*$", output, flags=re.MULTILINE):
records.append(dict(re.findall(r"(\w+)=(.*?)(?= \w+=|$)", line)))
return records
def wait_for_records(job_id, expected_events):
"""Return the job's records once it has one per expected event.
Fails if the records do not show up in time or if their event names do
not match expected_events in order.
"""
records = []
for t in atf.timer():
records = get_job_records(job_id)
if len(records) >= len(expected_events):
break
events = [record["event"] for record in records]
assert (
events == expected_events
), f"Job {job_id} produced the events {events}, expected {expected_events}"
return records
def test_start_and_finish():
"""A job records its start when enable_job_start is set.
The job start event reaches the script with the "job start" name, after
which only the finish follows: the job is held and released before it
runs, but queue, hold, release, and requeue stay off since they are not
enabled.
"""
job_id = atf.submit_job_sbatch(
"--begin=now+1hour -t1 -o /dev/null --wrap true", fatal=True
)
atf.wait_for_job_state(job_id, "PENDING", fatal=True)
atf.run_command(f"scontrol hold {job_id}", fatal=True)
atf.run_command(f"scontrol release {job_id}", fatal=True)
atf.run_command(f"scontrol update JobId={job_id} StartTime=now", fatal=True)
atf.wait_for_job_state(job_id, "DONE", fatal=True)
start, finish = wait_for_records(job_id, ["job start", "job finish"])
assert int(start["priority"]) > 0, f"Start record: {start}"
assert start["sluid"] == finish["sluid"], f"Records of one run: {start} {finish}"
def test_start_on_each_run():
"""A requeued job records a start for each of its runs.
The run the requeue ended is reported by a finish event, then the second
run is recorded as a start of its own, carrying the new SLUID and run
number the requeue gave the job, so a start event stands for one run and
not for the job as a whole.
"""
job_id = atf.submit_job_sbatch(
"--requeue -t5 -o /dev/null --wrap 'sleep infinity'", fatal=True
)
atf.wait_for_job_state(job_id, "RUNNING", fatal=True)
atf.run_command(f"scontrol requeue {job_id}", fatal=True)
atf.wait_for_job_state(job_id, "RUNNING", fatal=True)
atf.cancel_jobs([job_id], fatal=True)
start1, finish1, start2, finish2 = wait_for_records(
job_id, ["job start", "job finish", "job start", "job finish"]
)
assert (
start2["sluid"] != start1["sluid"]
), f"Second run should start with a new SLUID: {start2['sluid']}"
assert (
start2["sluid"] == finish2["sluid"]
), f"Records of the second run: {start2} {finish2}"
assert (
int(start2["restart_cnt"]) == int(start1["restart_cnt"]) + 1
), f"Second run should carry the next run number: {start2['restart_cnt']}"