blob: 9fd7fdbe86d586f258a62be473acd470c952a568 [file] [edit]
############################################################################
# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
############################################################################
"""Verify that reservation accounting records survive dynamic-node churn.
Regression test for ticket 25553: Each dynamic node creation or deletion
triggered the creation of a new resv_table record, even though the reservation
itself hadn't changed. For long-running reservations the number of rows could
grow enough to degrade sacct queries on jobs or users using those reservations.
Node list changes also close the current cluster node snapshot in the event
table, so this covers filtering reservations by node across such a change. Once
a reservation keeps a single record, that record can be older than any snapshot
a query looks at. Both consumers of that filter are covered, along with a
reservation that has not started yet, which no snapshot can describe, and the
documented behavior of asking about more than one cluster.
A reservation that has not started yet is also the only one kept in step with
the cluster node list as node churn happens, so that it is still matched by a
filter for its own node afterwards. That is covered separately from the
filtering cases above.
Reservations whose association list denies every user are covered as well,
since the list that gets stored with the record is then empty. So is a change
to those denials alone on a started reservation, which the stored list cannot
show but which must still add a record.
"""
import os
import time
import pytest
import atf
test_name = os.path.splitext(os.path.basename(__file__))[0]
dyn_node = f"dyn_{test_name}"
@pytest.fixture(scope="module", autouse=True)
def setup():
atf.require_version(
(26, 11),
"sbin/slurmctld",
reason="Ticket 25553: reservation re-record fix is 26.11 and later",
)
atf.require_auto_config("wants dynamic nodes and accounting")
atf.require_config_parameter("MaxNodeCount", 32)
atf.require_config_parameter("SelectType", "select/cons_tres")
atf.require_config_parameter("SelectTypeParameters", "CR_CPU")
atf.require_config_parameter("ResumeProgram", "/bin/true")
atf.require_config_parameter("SuspendProgram", "/bin/true")
atf.require_accounting(modify=True)
atf.require_nodes(1)
atf.require_slurm_running()
@pytest.fixture(scope="module")
def cluster_name():
return atf.get_config_parameter("ClusterName")
@pytest.fixture(scope="module")
def resv_node():
"""The static node that the reservations are created on."""
return next(iter(atf.get_nodes()))
@pytest.fixture
def create_reservation(request, resv_node):
"""Creates a permanent reservation named uniquely for the current test.
The accounting records of a deleted reservation outlive it, so each test
gets its own reservation name to keep the records of one from being picked
up by another. A test that creates more than one is given a distinct name
for each of them.
"""
slurm_user = atf.properties["slurm-user"]
created = []
def create(start="now", user=None):
res_name = f"res_{request.node.name}_{len(created)}"
# Create a permanent reservation on specific nodes, as in the reported
# scenario.
atf.run_command(
f"scontrol create reservationname={res_name} start={start} "
f"duration=UNLIMITED nodes={resv_node} "
f"user={user if user else atf.properties['test-user']} "
f"flags=FLEX,OVERLAP,IGNORE_JOBS,MAGNETIC",
user=slurm_user,
fatal=True,
)
created.append(res_name)
if start == "now":
for _ in atf.timer(fatal=True):
if atf.get_reservation_parameter(res_name, "State") == "ACTIVE":
break
return res_name
yield create
# The reservations are permanent and MAGNETIC on the cluster's only node,
# so one left behind would attract later tests' jobs. Collect the failures
# and report them after the loop: failing inside it would skip every
# reservation after the first failure, leaking the ones it was most
# important to remove.
errors = []
for res_name in created:
results = atf.run_command(
f"scontrol delete reservationname={res_name}",
user=slurm_user,
quiet=True,
)
if results["exit_code"] != 0:
errors.append(
f"Failed to delete reservation {res_name}: {results['stderr']}"
)
if errors:
pytest.fail("; ".join(errors))
@pytest.fixture
def assoc_for_test_user():
"""Gives the test user an association for a reservation to record.
Only users that have one are recorded with a reservation, so without this
a deny-only reservation has no list for the denials to be taken out of,
and the code that takes them out never runs at all.
"""
slurm_user = atf.properties["slurm-user"]
test_user = atf.properties["test-user"]
account = f"acct_{test_name}"
atf.run_command(
f"sacctmgr -i add account {account}",
user=slurm_user,
fatal=True,
)
atf.run_command(
f"sacctmgr -i add user {test_user} account={account}",
user=slurm_user,
fatal=True,
)
# A reservation records the associations that slurmctld knows of, so wait
# for the new one to reach it before any reservation is created.
for _ in atf.timer(fatal=True):
if account in atf.run_command_output(
f"scontrol show assoc_mgr users={test_user} flags=assoc",
quiet=True,
):
break
yield
atf.run_command(
f"sacctmgr -i remove user {test_user} account={account}",
user=slurm_user,
fatal=True,
quiet=True,
)
atf.run_command(
f"sacctmgr -i remove account {account}",
user=slurm_user,
fatal=True,
quiet=True,
)
@pytest.fixture
def extra_cluster():
"""Registers a second cluster in the accounting database.
Only the accounting tables of that cluster are needed, so nothing is run
for it. Naming a cluster that was never added would not do, since a query
then has no tables to read.
"""
slurm_user = atf.properties["slurm-user"]
cluster = f"cluster_{test_name}"
atf.run_command(
f"sacctmgr -i add cluster {cluster}",
user=slurm_user,
fatal=True,
)
yield cluster
atf.run_command(
f"sacctmgr -i remove cluster {cluster}",
user=slurm_user,
fatal=True,
quiet=True,
)
@pytest.fixture
def churn_dynamic_node():
slurm_user = atf.properties["slurm-user"]
def churn(cycles):
for _ in range(cycles):
atf.run_command(
f"scontrol create NodeName={dyn_node} State=CLOUD",
user=slurm_user,
fatal=True,
)
# Sleep at least 2 seconds after each node create/delete to result
# in a distinct (id_resv, time_start) value.
time.sleep(2)
atf.run_command(
f"scontrol delete NodeName={dyn_node}",
user=slurm_user,
fatal=True,
)
time.sleep(2)
yield churn
# In case a mid-cycle failure left the node behind
if dyn_node in atf.get_nodes(quiet=True):
atf.run_command(
f"scontrol delete NodeName={dyn_node}",
user=slurm_user,
fatal=True,
quiet=True,
)
@pytest.fixture
def sort_first_node():
"""Creates a dynamic node whose name sorts before every other node's.
Adding it shifts the position of every other node in the cluster's node
list, whatever the other nodes are named.
"""
slurm_user = atf.properties["slurm-user"]
node = f"0{dyn_node}"
def create():
atf.run_command(
f"scontrol create NodeName={node} State=CLOUD",
user=slurm_user,
fatal=True,
)
return node
yield create
if node in atf.get_nodes(quiet=True):
atf.run_command(
f"scontrol delete NodeName={node}",
user=slurm_user,
fatal=True,
quiet=True,
)
def get_dbd_resv_rows(res_name, end=None):
"""One output line per resv_table time-period row for a reservation.
An explicit start narrows the window past sacctmgr's default of 00:00:00 of
the previous day, which keeps out the rows of an earlier run that did
delete its reservations, since deleting one sets time_end to the delete
time. It does not keep out a row an aborted run left behind without
deleting its reservation: the window is an overlap test, not a containment
one, and a permanent reservation's time_end is a year out. The reservation
name is stable across runs, so such a row is still picked up.
Giving a start without an end ends the period now, so an end in the future
is needed to see a reservation that has not started yet.
"""
output = atf.run_command_output(
f"sacctmgr -Pn show reservation names={res_name} "
f"start=now-5minutes {f'end={end} ' if end else ''}"
"format=name,start,end",
fatal=True,
)
return [line for line in output.splitlines() if line.strip()]
def get_dbd_resv_rows_for_nodes(res_name, nodes, cluster, end=None):
"""Same, but filtered by node.
start=now limits the results to reservations that span now. A reservation
can span now and still have been recorded before the cluster node snapshot
that is open in the event table, which is the case that broke: the filter
recovers the node list from a node index, and that index only resolves
against the snapshot the record was written under. A cluster is required
because a node index means nothing against more than one.
"""
output = atf.run_command_output(
f"sacctmgr -Pn show reservation names={res_name} nodes={nodes} "
f"clusters={cluster} start=now {f'end={end} ' if end else ''}"
"format=name,start,nodenames",
fatal=True,
)
return [line for line in output.splitlines() if line.strip()]
def get_dbd_resv_assocs(res_name):
"""One output line per resv_table row, holding its association list.
The list is empty both for a reservation that never had one and for one
whose entries were all denials, so telling those apart takes a
reservation that is known to have a non-empty list to compare against.
"""
output = atf.run_command_output(
f"sacctmgr -Pn show reservation names={res_name} "
"start=now-5minutes format=name,associations",
fatal=True,
)
return [line for line in output.splitlines() if line.strip()]
def get_sreport_resv_rows(res_name, nodes, cluster, end):
"""One output line per reservation matched by an sreport node filter.
sreport is the other consumer of the node filter, and it always applies a
reporting period. Its default one ends at 23:59:59 of the previous day, so
the period has to be given to see a reservation that is running now.
"""
output = atf.run_command_output(
f"sreport -Pn reservation utilization cluster={cluster} "
f"names={res_name} nodes={nodes} start=now end={end} "
"format=Name,Nodes -tSeconds",
fatal=True,
)
return [line for line in output.splitlines() if line.strip()]
def test_node_churn_single_resv_record(create_reservation, churn_dynamic_node):
"""Node-list churn must not create new resv_table rows or move StartTime."""
res_name = create_reservation()
# Wait for the reservation's initial record to reach the database
for _ in atf.timer(fatal=True):
rows = get_dbd_resv_rows(res_name)
if len(rows) == 1:
break
initial_row = rows[0]
initial_start = atf.get_reservation_parameter(res_name, "StartTime")
# Node churn causes slurmctld to report the cluster node list to slurmdbd
# via DBD_CLUSTER_TRES, which returns ACCOUNTING_NODES_CHANGE_DB. Then
# slurmctld responds by re-recording every reservation.
churn_dynamic_node(3)
# Poll slurmdbd for a bit and verify that the reservation doesn't get any
# new time periods
for _ in atf.timer(timeout=10, xfail=True):
rows = get_dbd_resv_rows(res_name)
assert len(rows) == 1, (
f"Reservation {res_name} should have exactly one accounting "
f"record after node-list churn, got {len(rows)}: {rows}"
)
rows = get_dbd_resv_rows(res_name)
assert rows == [initial_row], (
f"The reservation accounting record should not be changed by "
f"node-list churn (was '{initial_row}', now: {rows})"
)
assert atf.get_reservation_parameter(res_name, "StartTime") == initial_start, (
f"The reservation's StartTime should not be changed by "
f"node-list churn (was {initial_start})"
)
def test_resv_change_adds_record(create_reservation):
"""A change to a started reservation itself must still add a record.
This is the control for node-list churn not adding one: what keeps churn
out of the table must not keep a real change out of it as well.
"""
slurm_user = atf.properties["slurm-user"]
res_name = create_reservation()
for _ in atf.timer(fatal=True):
rows = get_dbd_resv_rows(res_name)
if len(rows) == 1:
break
# A record is keyed on a start time in whole seconds, so a change in the
# same second as the start would update the record instead.
time.sleep(2)
atf.run_command(
f"scontrol update reservationname={res_name} comment={test_name}",
user=slurm_user,
fatal=True,
)
for _ in atf.timer(fatal=True):
rows = get_dbd_resv_rows(res_name)
if len(rows) != 1:
break
assert len(rows) == 2, (
f"Changing started reservation {res_name} should close its accounting "
f"record and add one more, got: {rows}"
)
def test_node_filter_after_node_churn(
create_reservation, churn_dynamic_node, resv_node, cluster_name
):
"""A node filter must match a reservation older than the node snapshot."""
res_name = create_reservation()
# Wait for the reservation's record to reach the database
for _ in atf.timer(fatal=True):
if len(get_dbd_resv_rows(res_name)) == 1:
break
# Each node create/delete closes the open cluster node snapshot in the
# event table and opens a new one, so the snapshot that was current when
# the reservation record was written is no longer the current one.
churn_dynamic_node(1)
start = atf.get_reservation_parameter(res_name, "StartTime")
rows = get_dbd_resv_rows_for_nodes(res_name, resv_node, cluster_name)
assert rows == [f"{res_name}|{start}|{resv_node}"], (
f"Reservation {res_name} is on {resv_node}, so it should still match a "
f"filter for that node after node-list churn, got: {rows}"
)
# Ask for a node that has never been part of the cluster. Without this the
# assertion above would also pass if node filtering were skipped entirely,
# which is what happened when an asked node was in no node snapshot.
rows = get_dbd_resv_rows_for_nodes(res_name, f"not{dyn_node}", cluster_name)
assert len(rows) == 0, (
f"Reservation {res_name} is on {resv_node}, so it should not match a "
f"filter for a node that does not exist, got: {rows}"
)
def test_sreport_node_filter_after_node_churn(
create_reservation, churn_dynamic_node, resv_node, cluster_name
):
"""sreport must match a reservation recorded before the snapshot too."""
res_name = create_reservation()
# Wait for the reservation's record to reach the database
for _ in atf.timer(fatal=True):
if len(get_dbd_resv_rows(res_name)) == 1:
break
churn_dynamic_node(1)
rows = get_sreport_resv_rows(res_name, resv_node, cluster_name, "now+1hour")
assert rows == [f"{res_name}|{resv_node}"], (
f"Reservation {res_name} is on {resv_node}, so sreport should still "
f"match a filter for that node after node-list churn, got: {rows}"
)
rows = get_sreport_resv_rows(res_name, f"not{dyn_node}", cluster_name, "now+1hour")
assert len(rows) == 0, (
f"Reservation {res_name} is on {resv_node}, so sreport should not "
f"match a filter for a node that does not exist, got: {rows}"
)
def test_sreport_node_filter_needs_one_cluster(
create_reservation, resv_node, cluster_name, extra_cluster
):
"""sreport refuses a node filter unless one cluster is asked about.
Naming several and asking about all of them are both refused, since a node
index only means something against one cluster's node list either way.
The refusal is checked by what comes back as well as by the exit code,
since a report that was never filtered looks like an ordinary one.
The refusal asserted here is printed by sreport, not by the controller the
module gates on, and client commands are upgraded last. An older sreport
sends the query and reports what it gets back in its own words.
"""
# Not a skipif: sreport -V fails until accounting is configured by setup().
atf.require_version(
(26, 11),
"bin/sreport",
reason="Ticket 25553: sreport refuses a multi-cluster node filter in 26.11+",
)
res_name = create_reservation()
for _ in atf.timer(fatal=True):
if len(get_dbd_resv_rows(res_name)) == 1:
break
# A second cluster is registered, so all_clusters really is more than one.
# It is a bare keyword rather than a value of cluster=, which is the only
# spelling sreport recognizes for it.
# No fatal=True: the failure is what is being asserted.
for asked in (f"cluster={cluster_name},{extra_cluster}", "all_clusters"):
result = atf.run_command(
f"sreport -Pn reservation utilization {asked} names={res_name} "
f"nodes={resv_node} start=now end=now+1hour format=Name,Nodes"
)
assert result["exit_code"] != 0, (
f"Asking sreport about more than one cluster with a node filter "
f"should fail ({asked}), got exit_code {result['exit_code']} and "
f"stdout: {result['stdout']}"
)
assert "one cluster" in result["stderr"], (
f"The refusal should say a node filter takes one cluster ({asked}), "
f"got: {result['stderr']}"
)
assert res_name not in result["stdout"], (
f"A refused query should report nothing ({asked}), but {res_name} "
f"was reported: {result['stdout']}"
)
def test_sreport_default_period_hides_running_reservation(
create_reservation, resv_node, cluster_name
):
"""sreport's default reporting period does not reach a running reservation.
The default period starts on the previous day and ends at 01:00 of the
current one, so a reservation that started later today is outside it. An
empty report then means the period rather than the nodes, which is worth
pinning because an empty report was also what the node filter bug produced.
"""
res_name = create_reservation()
start = atf.get_reservation_parameter(res_name, "StartTime")
for _ in atf.timer(fatal=True):
if len(get_dbd_resv_rows(res_name)) == 1:
break
rows = get_sreport_resv_rows(res_name, resv_node, cluster_name, "now+1hour")
assert rows == [f"{res_name}|{resv_node}"], (
f"Reservation {res_name} is on {resv_node}, so sreport should match a "
f"filter for that node when the period reaches it, got: {rows}"
)
# The same query without a period.
output = atf.run_command_output(
f"sreport -Pn reservation utilization cluster={cluster_name} "
f"names={res_name} nodes={resv_node} format=Name,Nodes -tSeconds",
fatal=True,
)
today = time.strftime("%Y-%m-%d")
if not start.startswith(today) or start.startswith(f"{today}T00:"):
pytest.skip(f"The default reporting period reaches {start}")
rows = [line for line in output.splitlines() if line.strip()]
assert len(rows) == 0, (
f"Reservation {res_name} started at {start}, so the default reporting "
f"period, which ends at 01:00 today, should not reach it, got: {rows}"
)
def test_node_filter_on_pending_reservation(
create_reservation, resv_node, cluster_name
):
"""A node filter must match a reservation that has not started yet."""
res_name = create_reservation(start="now+1hour")
# The query period has to reach the reservation for its record to be
# visible at all, since a period ending now excludes a future time period.
for _ in atf.timer(fatal=True):
if len(get_dbd_resv_rows(res_name, end="now+2hours")) == 1:
break
rows = get_dbd_resv_rows_for_nodes(
res_name, resv_node, cluster_name, end="now+2hours"
)
assert len(rows) == 1, (
f"Reservation {res_name} is on {resv_node}, so it should match a "
f"filter for that node before it starts, got: {rows}"
)
# Documented in sreport.1: sreport reduces an end later than now to now,
# but a period starting at the reservation's own start time still reaches
# it.
start = atf.get_reservation_parameter(res_name, "StartTime")
output = atf.run_command_output(
f"sreport -Pn reservation utilization cluster={cluster_name} "
f"names={res_name} nodes={resv_node} start={start} "
"format=Name,Nodes -tSeconds",
fatal=True,
)
rows = [line for line in output.splitlines() if line.strip()]
assert rows == [f"{res_name}|{resv_node}"], (
f"Reservation {res_name} is on {resv_node}, so sreport should match a "
f"filter for that node from its start time {start}, got: {rows}"
)
# Documented in sacctmgr.1: leaving the end of the period unset ends it
# now, which hides a reservation that has not started yet.
rows = get_dbd_resv_rows_for_nodes(res_name, resv_node, cluster_name)
assert len(rows) == 0, (
f"Reservation {res_name} starts in the future, so it should not be "
f"matched by a query period ending now, got: {rows}"
)
def test_node_filter_on_pending_reservation_after_node_churn(
create_reservation, sort_first_node, resv_node, cluster_name
):
"""A reservation that has not started still matches by node after churn.
A node index is a position in the cluster's node list, so adding a node
that sorts ahead of the reserved one moves it. A reservation that has not
started tracks the current list, and what that buys is asked for here the
way a user would ask: the reservation still matches a filter for the node
it is on. An index left at the old position would resolve against the new
list and name the node that took that position instead.
Note this guards behavior the node index fix deliberately kept, rather
than behavior it introduced, so it passes with or without that fix.
"""
res_name = create_reservation(start="now+1hour")
for _ in atf.timer(fatal=True):
if len(get_dbd_resv_rows(res_name, end="now+2hours")) == 1:
break
rows = get_dbd_resv_rows_for_nodes(
res_name, resv_node, cluster_name, end="now+2hours"
)
assert len(rows) == 1, (
f"Reservation {res_name} is on {resv_node}, so it should match a "
f"filter for that node before any churn, got: {rows}"
)
node = sort_first_node()
matched = []
for _ in atf.timer():
matched = get_dbd_resv_rows_for_nodes(
res_name, resv_node, cluster_name, end="now+2hours"
)
if len(matched) == 1:
break
assert len(matched) == 1, (
f"Reservation {res_name} has not started yet and is on {resv_node}, "
f"so it should still match a filter for that node after {node} moved "
f"its position, got: {matched}"
)
# The one record it had is the one that was updated, not a second one.
rows = get_dbd_resv_rows(res_name, end="now+2hours")
assert len(rows) == 1, (
f"Node-list churn on {res_name}, which has not started yet, should "
f"update its existing accounting record rather than add another, "
f"got: {rows}"
)
@pytest.mark.skipif(
atf.get_version("bin/sacctmgr") < (26, 11),
reason="Ticket 25553: sacctmgr refuses a multi-cluster node filter in 26.11+",
)
def test_node_filter_needs_one_cluster(
create_reservation, resv_node, cluster_name, extra_cluster
):
"""A node filter is refused unless one cluster is asked about.
The refusal asserted here is printed by sacctmgr, not by the controller the
module gates on, and client commands are upgraded last. An older sacctmgr
sends the query and reports what it gets back in its own words.
"""
res_name = create_reservation()
# Wait for the reservation's record to reach the database
for _ in atf.timer(fatal=True):
if len(get_dbd_resv_rows(res_name)) == 1:
break
# Asking one cluster about a node that has never been part of it excludes
# the reservation, so the node that is asked for is what matters below.
rows = get_dbd_resv_rows_for_nodes(res_name, f"not{dyn_node}", cluster_name)
assert len(rows) == 0, (
f"Reservation {res_name} is on {resv_node}, so it should not match a "
f"filter for a node that does not exist, got: {rows}"
)
# Documented in sacctmgr.1 and sreport.1: a node index only means anything
# against the node list of one cluster, so asking about more than one is
# refused rather than answered without the filter. sacctmgr refuses before
# querying, and slurmdbd refuses a request that reaches it anyway.
#
# No fatal=True here: the failure is what is being asserted, so failing the
# test on it would invert this case.
result = atf.run_command(
f"sacctmgr -Pn show reservation names={res_name} "
f"nodes=not{dyn_node} clusters={cluster_name},{extra_cluster} "
f"start=now format=name,start,nodenames"
)
assert result["exit_code"] != 0, (
f"Asking about more than one cluster with a node filter should fail, "
f"got exit_code {result['exit_code']} and stdout: {result['stdout']}"
)
assert "one cluster" in result["stderr"], (
f"The refusal should say a node filter takes one cluster, got: "
f"{result['stderr']}"
)
def test_deny_only_reservation_record(create_reservation, assoc_for_test_user):
"""A reservation whose association list is entirely denials is recorded."""
test_user = atf.properties["test-user"]
# An allowed user is recorded as an association. This is the control for
# the deny-only reservation below, whose list is empty because the
# denials were taken out of it rather than because it never had one.
allow_name = create_reservation()
for _ in atf.timer(fatal=True):
allow_rows = get_dbd_resv_assocs(allow_name)
if len(allow_rows) == 1:
break
assert allow_rows != [f"{allow_name}|"], (
f"Reservation {allow_name} allows {test_user}, who has an "
f"association, so one should be recorded with it, got: {allow_rows}"
)
# A deny-only user list leaves nothing behind once the denials are removed
# from the association list that is stored with the record, which used to
# run the length of that list negative.
deny_name = create_reservation(user=f"-{test_user}")
assert (
atf.get_reservation_parameter(deny_name, "Users") == f"-{test_user}"
), f"Reservation {deny_name} should deny {test_user}"
for _ in atf.timer(fatal=True):
deny_rows = get_dbd_resv_assocs(deny_name)
if len(deny_rows) == 1:
break
assert deny_rows == [f"{deny_name}|"], (
f"Reservation {deny_name} only denies {test_user}, so its recorded "
f"association list should be empty, got: {deny_rows}"
)
def test_deny_change_adds_record(create_reservation, assoc_for_test_user):
"""A change to the users a started reservation denies must add a record."""
slurm_user = atf.properties["slurm-user"]
test_user = atf.properties["test-user"]
# root has an association on every cluster, so denying it puts an id in
# the association list slurmctld keeps, even though the list stored with
# the record leaves denials out.
res_name = create_reservation(user="-root")
# Giving an end keeps a row that starts in the current second in view.
# Without one the query period ends now, and a record that the update
# below starts now would be missing from a read made in the same second.
for _ in atf.timer(fatal=True):
rows = get_dbd_resv_rows(res_name, end="now+1hour")
if len(rows) == 1:
break
initial_rows = rows
initial_start = rows[0].split("|")[1]
# A record is keyed on a start time in whole seconds, so a change in the
# same second as the start would update the record instead.
time.sleep(2)
# On a deny list, -user adds the user to it.
atf.run_command(
f"scontrol update reservationname={res_name} users=-{test_user}",
user=slurm_user,
fatal=True,
)
assert (
atf.get_reservation_parameter(res_name, "Users") == f"-root,-{test_user}"
), f"Reservation {res_name} should deny root and {test_user}"
# slurmctld counts a change to the denied users as a change and moves the
# accounting start of a started reservation to now, while the stored
# record sees nothing different. Updating that record in place would move
# its start forward too, losing the time it had already covered, so either
# a new record or a moved start ends the wait, and only the first is right.
for _ in atf.timer(fatal=True):
rows = get_dbd_resv_rows(res_name, end="now+1hour")
if rows != initial_rows:
break
assert len(rows) == 2, (
f"Changing the users started reservation {res_name} denies should "
f"close its accounting record and add one more, got: {rows} "
f"(was {initial_rows})"
)
# scontrol's StartTime is the first start and does not move, so the check
# that no time was lost is made on the records themselves.
old_start, old_end = rows[0].split("|")[1:3]
new_start = rows[1].split("|")[1]
assert old_start == initial_start and old_end == new_start, (
f"The first record of {res_name} should keep its start and end where "
f"the new one starts, got: {rows} (was {initial_rows})"
)