| ############################################################################ |
| # Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. |
| ############################################################################ |
| """Test 100.5: Accounting storage usage rollup performance. |
| |
| Seeds a synthetic cluster with a realistic workload directly in MySQL |
| (boundary-spanning, suspended, and reserved jobs), triggers sacctmgr rollup, |
| and asserts the rollup completes within a time budget. |
| |
| See test_115_1.py for sreport tests. |
| |
| Run this only against a throwaway accounting DB. sacctmgr rollup takes no |
| cluster argument, so every rollup here recomputes and overwrites usage rows |
| for every cluster in the DB over the window it covers. |
| """ |
| |
| import logging |
| import re |
| from datetime import datetime, timedelta |
| |
| import pytest |
| |
| import atf |
| |
| pytestmark = pytest.mark.slow |
| |
| # Global variables |
| cluster = "test1005rollup" |
| NO_VAL = 0xFFFFFFFE |
| |
| # total number of rows created for all tables in rollup |
| row_count_total = 1000000 |
| |
| # usage rollup for those rows should happen in less than this time |
| max_perf_time_default = 600 |
| |
| # 50% pre-rollup jobs filtered by WHERE, remaining 500k split across |
| # three time-clamp branches (inside/span/running). |
| id_assoc = "floor(2+pow(rand(), 3)*19999)" |
| id_qos = "floor(1+rand()*10)" |
| id_wckey = "floor(1+rand()*100)" |
| id_resv = "if(rand()<0.2, floor(1+rand()*5), 0)" |
| tres_alloc = "1=8,2=32000,4=1,5=1,1001=1" |
| tres_req = "1=8,2=32000,4=1,5=1,1001=1" |
| SUSPEND_MODULO = 20 |
| suspended_job_count = (row_count_total // 2) // SUSPEND_MODULO |
| time_suspended_in_period = f"if(@seq % {SUSPEND_MODULO} = 0, floor(60+rand()*1800), 0)" |
| |
| row_count_inside = 150000 |
| row_count_span = 250000 |
| row_count_running = 100000 |
| |
| time_inside_start = "@ps+floor(rand()*1800)" |
| time_inside_end = "@ps+1800+floor(rand()*1799)" |
| time_span_start = "@ps+floor(rand()*3600)" |
| time_span_end = "@pe+1+floor(rand()*3600)" |
| time_running_start = "@ps+floor(rand()*3600)" |
| time_running_end = "0" |
| |
| # Job state IDs: 1 = JOB_RUNNING, 3 = JOB_COMPLETE |
| state_complete = 3 |
| state_running = 1 |
| |
| table_gen_info = [ |
| { |
| "type": "Job", |
| "tables": [ |
| { |
| "name": f"{cluster}_job_table", |
| "sql": f"set job_db_inx=@seq,account='root',nodelist='n1',node_inx=0,time_submit=@ps-1,tres_req='{tres_req}', tres_alloc='{tres_alloc}',id_qos={id_qos},cpus_req=8,job_name='',id_assoc={id_assoc},id_job=@seq,id_resv={id_resv},id_wckey={id_wckey},id_user=0,id_group=0,het_job_id=0,het_job_offset={NO_VAL},state_reason_prev=0,nodes_alloc=1,\\`partition\\`='part1',priority=0,state={state_complete},time_start=@ps,time_eligible=@ps-1,time_end=@pe,env_hash_inx=0,script_hash_inx=0", |
| "pre": "yes", |
| "row_count": int(row_count_total / 2), |
| }, |
| { |
| "name": f"{cluster}_event_table", |
| "sql": f"set cluster_nodes='n1',time_start=@ps,time_end=0,tres='1={row_count_total * 32}',reason='Cluster proc count'", |
| "pre": "yes", |
| "row_count": 1, |
| }, |
| { |
| "name": f"{cluster}_resv_table", |
| "sql": ( |
| "set id_resv=@seq-500001," |
| "assoclist='1,2,3,4,5,6,7,8,9,10'," |
| "nodelist='n1'," |
| "resv_name=concat('resv',@seq-500001)," |
| "time_start=@ps,time_end=@pe," |
| "tres='1=100,2=512000,1001=8'" |
| ), |
| "pre": "no", |
| "row_count": 5, |
| }, |
| { |
| "name": f"{cluster}_job_table", |
| "sql": f"set job_db_inx=@seq,account='root',nodelist='n1',node_inx=0,time_submit=@ps-1,tres_req='{tres_req}', tres_alloc='{tres_alloc}',id_qos={id_qos},cpus_req=8,job_name='',id_assoc={id_assoc},id_job=@seq,id_resv={id_resv},id_wckey={id_wckey},id_user=0,id_group=0,het_job_id=0,het_job_offset={NO_VAL},state_reason_prev=0,nodes_alloc=1,\\`partition\\`='part1',priority=0,state={state_complete},time_start={time_inside_start},time_eligible=@ps-1,time_end={time_inside_end},time_suspended={time_suspended_in_period},env_hash_inx=0,script_hash_inx=0", |
| "pre": "no", |
| "row_count": row_count_inside, |
| }, |
| { |
| "name": f"{cluster}_job_table", |
| "sql": f"set job_db_inx=@seq,account='root',nodelist='n1',node_inx=0,time_submit=@ps-1,tres_req='{tres_req}', tres_alloc='{tres_alloc}',id_qos={id_qos},cpus_req=8,job_name='',id_assoc={id_assoc},id_job=@seq,id_resv={id_resv},id_wckey={id_wckey},id_user=0,id_group=0,het_job_id=0,het_job_offset={NO_VAL},state_reason_prev=0,nodes_alloc=1,\\`partition\\`='part1',priority=0,state={state_complete},time_start={time_span_start},time_eligible=@ps-1,time_end={time_span_end},time_suspended={time_suspended_in_period},env_hash_inx=0,script_hash_inx=0", |
| "pre": "no", |
| "row_count": row_count_span, |
| }, |
| { |
| "name": f"{cluster}_job_table", |
| "sql": f"set job_db_inx=@seq,account='root',nodelist='n1',node_inx=0,time_submit=@ps-1,tres_req='{tres_req}', tres_alloc='{tres_alloc}',id_qos={id_qos},cpus_req=8,job_name='',id_assoc={id_assoc},id_job=@seq,id_resv={id_resv},id_wckey={id_wckey},id_user=0,id_group=0,het_job_id=0,het_job_offset={NO_VAL},state_reason_prev=0,nodes_alloc=1,\\`partition\\`='part1',priority=0,state={state_running},time_start={time_running_start},time_eligible=@ps-1,time_end={time_running_end},time_suspended={time_suspended_in_period},env_hash_inx=0,script_hash_inx=0", |
| "pre": "no", |
| "row_count": row_count_running, |
| }, |
| { |
| "name": f"{cluster}_suspend_table", |
| "sql": ( |
| f"set job_db_inx=500000+(@seq-1000006)*{SUSPEND_MODULO}," |
| f"id_assoc=0," |
| f"time_start=@ps+300,time_end=@ps+600" |
| ), |
| "pre": "no", |
| "row_count": suspended_job_count, |
| }, |
| ], |
| "max_perf_time": max_perf_time_default, |
| }, |
| ] |
| |
| |
| @pytest.fixture(scope="module", autouse=True) |
| def setup(): |
| """Test setup with required configurations.""" |
| |
| atf.require_version((26, 11), "sbin/slurmdbd", reason="Ticket 24919 rollup perf") |
| atf.require_accounting(modify=True) |
| atf.require_config_parameter_includes("AccountingStorageEnforce", "associations") |
| atf.require_config_parameter("AllowNoDefAcct", None, source="slurmdbd") |
| # Without this the rollup skips the wckey id_usage batch entirely, leaving |
| # the wckey half of the chunked INSERT unexercised at any cardinality. |
| atf.require_config_parameter("TrackWCKey", "yes", source="slurmdbd") |
| |
| atf.require_config_parameter("PurgeEventAfter", None, source="slurmdbd") |
| atf.require_config_parameter("PurgeJobAfter", None, source="slurmdbd") |
| atf.require_config_parameter("PurgeResvAfter", None, source="slurmdbd") |
| atf.require_config_parameter("PurgeStepAfter", None, source="slurmdbd") |
| atf.require_config_parameter("PurgeSuspendAfter", None, source="slurmdbd") |
| atf.require_config_parameter("PurgeTXNAfter", None, source="slurmdbd") |
| atf.require_config_parameter("PurgeUsageAfter", None, source="slurmdbd") |
| |
| # Need debug level to capture "Everything rolled up in usec=N". |
| atf.require_config_parameter("DebugLevel", "debug", source="slurmdbd") |
| |
| atf.require_slurm_running() |
| |
| |
| @pytest.fixture(scope="function", autouse=True) |
| def setup_db(sql_statement_repeat): |
| |
| if not sql_statement_repeat: |
| pytest.skip( |
| "Ticket 24919: needs the conftest mysql helper, which requires auto-config" |
| ) |
| |
| slurm_user = atf.properties["slurm-user"] |
| |
| # Truncate before the remove. Running rows (time_end=0, |
| # state=JOB_RUNNING) left by an interrupted run count as active jobs and |
| # make the remove refuse, which would then fail the add below. The tables |
| # do not exist on a fresh database, so this is best effort. |
| for t in ["job_table", "event_table", "resv_table", "suspend_table"]: |
| atf.run_command( |
| f'{sql_statement_repeat} -e "TRUNCATE TABLE {cluster}_{t}"', |
| user=slurm_user, |
| quiet=True, |
| ) |
| atf.run_command( |
| f"sacctmgr -i remove cluster {cluster}", |
| user=slurm_user, |
| ) |
| atf.run_command( |
| f"sacctmgr -i add cluster {cluster}", |
| user=slurm_user, |
| fatal=True, |
| ) |
| |
| yield |
| |
| # Mark synthetic state=JOB_RUNNING rows as completed so the |
| # sacctmgr remove-cluster pre-check doesn't see them as active jobs. |
| atf.run_command( |
| f"{sql_statement_repeat} -e " |
| f'"update {cluster}_job_table set state=3 where state=1"', |
| user=slurm_user, |
| quiet=True, |
| fatal=True, |
| ) |
| |
| # Removing a cluster cascades a delete across every per-cluster table, and |
| # this one holds up to 1M seeded rows, so it needs longer than the default. |
| atf.run_command( |
| f"sacctmgr -i remove cluster {cluster}", |
| user=slurm_user, |
| timeout=600, |
| fatal=True, |
| ) |
| |
| |
| def _split_set_assignments(s): |
| """Split a comma-separated list of "col=expr" pairs, respecting parens, |
| backticks, and single/double-quoted string literals.""" |
| parts = [] |
| cur = [] |
| depth = 0 |
| in_sq = in_dq = in_bq = False |
| for ch in s: |
| if in_sq: |
| cur.append(ch) |
| if ch == "'": |
| in_sq = False |
| elif in_dq: |
| cur.append(ch) |
| if ch == '"': |
| in_dq = False |
| elif in_bq: |
| cur.append(ch) |
| if ch == "`": |
| in_bq = False |
| elif ch == "'": |
| in_sq = True |
| cur.append(ch) |
| elif ch == '"': |
| in_dq = True |
| cur.append(ch) |
| elif ch == "`": |
| in_bq = True |
| cur.append(ch) |
| elif ch == "(": |
| depth += 1 |
| cur.append(ch) |
| elif ch == ")": |
| depth -= 1 |
| cur.append(ch) |
| elif ch == "," and depth == 0: |
| parts.append("".join(cur)) |
| cur = [] |
| else: |
| cur.append(ch) |
| if cur: |
| parts.append("".join(cur)) |
| return [p.split("=", 1) for p in parts if "=" in p] |
| |
| |
| def _set_to_insert_select(set_clause, seq_start, seq_end): |
| """Convert 'set col=expr, ...' into '(col, ...) SELECT expr, ... FROM |
| seq_X_to_Y'. Replaces @seq references with the seq column from MariaDB's |
| sequence storage engine. Used on MariaDB where the sequence engine |
| produces ~5x faster bulk inserts than statement_repeat.""" |
| s = set_clause.strip() |
| if s.lower().startswith("set "): |
| s = s[4:].lstrip() |
| cols, exprs = [], [] |
| for col, expr in _split_set_assignments(s): |
| cols.append(col.strip()) |
| exprs.append(expr.strip().replace("@seq", "seq")) |
| return ( |
| f"({','.join(cols)}) SELECT {','.join(exprs)} " |
| f"FROM seq_{seq_start}_to_{seq_end}" |
| ) |
| |
| |
| def _detect_mariadb(sql_statement_repeat): |
| """Return True iff the backing SQL server is MariaDB (sequence engine |
| available); False for upstream MySQL or on detection failure.""" |
| result = atf.run_command( |
| f"{sql_statement_repeat} -Ns -e 'select version()'", |
| user=atf.properties["slurm-user"], |
| quiet=True, |
| ) |
| return result["exit_code"] == 0 and "MariaDB" in result.get("stdout", "") |
| |
| |
| def test_usage_rollup_via_sacctmgr(sql_statement_repeat): |
| """Usage rollup triggered via sacctmgr rollup.""" |
| |
| dbd_log = atf.get_config_parameter( |
| "LogFile", live=False, quiet=True, source="slurmdbd" |
| ) |
| log_present = dbd_log and atf.run_command_exit( |
| f"test -f {dbd_log}", user=atf.properties["slurm-user"], quiet=True |
| ) |
| if not dbd_log or log_present != 0: |
| pytest.skip( |
| "Ticket 24919: LogFile not configured or not present in slurmdbd.conf; " |
| "cannot measure rollup duration" |
| ) |
| |
| use_seq_engine = _detect_mariadb(sql_statement_repeat) |
| logging.info( |
| f"Bulk insert path: {'MariaDB seq engine' if use_seq_engine else 'statement_repeat'}" |
| ) |
| |
| for entry in table_gen_info: |
| row_count_tally = 0 |
| |
| # Compute the rollup window once: previous full hour. |
| rounded_last_hour = datetime.now().replace( |
| minute=0, second=0, microsecond=0 |
| ) - timedelta(hours=1) |
| in_period_start = int(rounded_last_hour.timestamp()) |
| in_period_end = in_period_start + 3600 |
| |
| # Pre-rollup data sits 3 hours back so it's filtered out by the |
| # rollup's WHERE clause. |
| pre_period_start = in_period_start - 2 * 3600 |
| pre_period_end = pre_period_start + 3600 |
| |
| for table_info in entry["tables"]: |
| row_count = int(table_info["row_count"]) |
| |
| if "pre" in table_info and table_info["pre"] == "yes": |
| seq_start = 1 |
| seq_end = row_count |
| per_start = pre_period_start |
| per_end = pre_period_end |
| else: |
| seq_start = row_count_tally + 1 |
| seq_end = seq_start + row_count - 1 |
| per_start = in_period_start |
| per_end = in_period_end |
| |
| if use_seq_engine: |
| insert_clause = _set_to_insert_select( |
| table_info["sql"], seq_start, seq_end |
| ) |
| mysql_command = ( |
| f"{sql_statement_repeat} -e " |
| f'"set @ps={per_start}; set @pe={per_end}; ' |
| f"insert into {table_info['name']} {insert_clause}\"" |
| ) |
| else: |
| mysql_command = sql_statement_repeat |
| mysql_command += f" -e \"set @ps={per_start}; set @pe={per_end}; call statement_repeat(\\\"insert into {table_info['name']} {table_info['sql']}\\\", {seq_start}, {seq_end}, 1, 1)\"" |
| logging.info( |
| f"Populating database with {row_count} rows in {table_info['name']}" |
| ) |
| # A single insert here seeds up to 500k rows, which takes longer |
| # than the default timeout on a loaded database. |
| atf.run_command( |
| mysql_command, |
| user=atf.properties["slurm-user"], |
| timeout=600, |
| fatal=True, |
| ) |
| |
| row_count_tally = row_count_tally + row_count |
| |
| logging.info( |
| f"Waiting {entry['max_perf_time']}s for {entry['type']} usage rollup to complete" |
| ) |
| |
| log_line_start = int( |
| atf.run_command_output( |
| f"wc -l < {dbd_log}", |
| user=atf.properties["slurm-user"], |
| fatal=True, |
| ).strip() |
| ) |
| |
| # Trigger the rollup synchronously. sacctmgr -i sets rollback_flag=0 |
| # so the "Would you like to commit rollup?" prompt is bypassed. |
| rollup_start_str = rounded_last_hour.strftime("%Y-%m-%dT%H:%M:%S") |
| rollup_end_str = (rounded_last_hour + timedelta(hours=1)).strftime( |
| "%Y-%m-%dT%H:%M:%S" |
| ) |
| atf.run_command( |
| f"sacctmgr -i rollup {rollup_start_str} {rollup_end_str}", |
| user=atf.properties["slurm-user"], |
| # Give the command timeout headroom over max_perf_time so a |
| # genuine perf regression trips the time_sec assertion below |
| # (with its useful message) instead of an opaque command |
| # timeout. |
| timeout=2 * entry["max_perf_time"], |
| fatal=True, |
| ) |
| |
| log_slice = atf.run_command_output( |
| f"tail -n +{log_line_start + 1} {dbd_log}", |
| user=atf.properties["slurm-user"], |
| quiet=True, |
| fatal=True, |
| ) |
| logging.info(f"Rollup lines from {dbd_log}:\n{log_slice.rstrip()}") |
| |
| err_lines = [ln for ln in log_slice.splitlines() if " error: " in ln] |
| assert not err_lines, "slurmdbd logged errors during rollup:\n" + "\n".join( |
| err_lines |
| ) |
| |
| # A concurrent periodic rollup could log its own "Everything rolled |
| # up" line in this window; taking the first match could measure that |
| # unrelated, likely much faster, rollup instead of the one this test |
| # triggered. Require exactly one match so a concurrent rollup surfaces |
| # as an explicit failure rather than a wrong measurement. |
| matches = re.findall(r"Everything rolled up in usec=(\d+)", log_slice) |
| assert len(matches) == 1, ( |
| f"expected exactly one 'Everything rolled up' log line within " |
| f"{entry['max_perf_time']}s, got {len(matches)}" |
| ) |
| rows_processed = row_count_inside + row_count_span + row_count_running |
| time_sec = int(matches[0]) / 1_000_000.0 |
| rate = int(rows_processed / time_sec) |
| |
| logging.info( |
| f"{entry['type']} usage rollup took {time_sec}s to process {rows_processed} job table rows ({rate} rows/sec)" |
| ) |
| assert ( |
| time_sec < entry["max_perf_time"] |
| ), f"rollup took {time_sec}s, expected < {entry['max_perf_time']}s" |