diff --git a/CIME/XML/env_batch.py b/CIME/XML/env_batch.py index 00147b86b3c..69b6c1f82c8 100644 --- a/CIME/XML/env_batch.py +++ b/CIME/XML/env_batch.py @@ -482,7 +482,9 @@ def set_job_defaults(self, batch_jobs, case): ) walltime_format = self.get_value("walltime_format") - if walltime_format: + if self._batchtype == "flux": + walltime = self._flux_walltime(walltime, walltime_format) + elif walltime_format: seconds = convert_to_seconds(walltime) full_bab_time = convert_to_babylonian_time(seconds) walltime = format_time(walltime_format, "%H:%M:%S", full_bab_time) @@ -497,6 +499,56 @@ def set_job_defaults(self, batch_jobs, case): "Job {} queue {} walltime {}".format(job, self.text(queue), walltime) ) + @staticmethod + def _flux_walltime(walltime, walltime_format): + """Converts a walltime for Flux's ``-t/--time-limit``. + + Flux accepts minutes or Flux Standard Duration (FSD, RFC 23) + rather than ``HH:MM:SS``. Unlike :func:`CIME.utils.format_time`, + which rearranges fields positionally, ``%H``, ``%M`` and ``%S`` + here expand to the *total* duration expressed in that unit, so a + literal FSD suffix in the format yields a valid FSD value, e.g. + ``%Mm`` on ``01:10:00`` gives ``70m`` and ``%Hh`` gives ``1.17h``. + Partial values round up at two decimal places so the job is never + allotted less time than requested. Without a format, the default + is FSD minutes. + + Only formats producing values Flux accepts are permitted: ``%M`` + (bare minutes) or a specifier paired with its matching FSD suffix + (``%Ss``, ``%Mm``, ``%Hh``, ``%Dd``). Anything else, e.g. ``%H`` + (a bare number Flux would read as minutes) or ``%H:%M:%S``, is + rejected. + + Args: + walltime (str): Walltime in ``[[HH:]MM:]SS``. + walltime_format (str): Format string or None for the default. + + Returns: + str: Walltime valid for Flux's ``-t/--time-limit``. + + Raises: + CIMEError: If ``walltime_format`` cannot produce a value + valid for Flux. + """ + seconds = convert_to_seconds(walltime) + + if not walltime_format: + return "{:d}m".format(math.ceil(seconds / 60)) + + valid_formats = ("%M", "%Ss", "%Mm", "%Hh", "%Dd") + expect( + walltime_format in valid_formats, + "walltime_format {!r} is not valid for flux; expected one " + "of {}".format(walltime_format, ", ".join(valid_formats)), + ) + + def _total(match): + unit = {"H": 3600, "M": 60, "S": 1, "D": 86400}[match.group(1)] + value = math.ceil(seconds / unit * 100) / 100 + return "{:.2f}".format(value).rstrip("0").rstrip(".") + + return re.sub(r"%([HMSD])", _total, walltime_format) + def _match_attribs(self, attribs, case, queue): # check for matches with case-vars for attrib in attribs: diff --git a/CIME/tests/test_unit_xml_env_batch.py b/CIME/tests/test_unit_xml_env_batch.py index 5b1295c4a68..ceff839254c 100755 --- a/CIME/tests/test_unit_xml_env_batch.py +++ b/CIME/tests/test_unit_xml_env_batch.py @@ -6,6 +6,8 @@ from contextlib import ExitStack from unittest import mock +import pytest + from CIME.core.exceptions import CIMEError from CIME.utils import expect from CIME.XML.env_batch import EnvBatch, get_job_deps @@ -14,6 +16,46 @@ # pylint: disable=unused-argument + +@pytest.mark.parametrize( + "walltime,walltime_format,expected", + [ + ("01:10:00", None, "70m"), + ("00:10:30", None, "11m"), + ("01:10:00", "%M", "70"), + ("01:10:00", "%Mm", "70m"), + ("01:10:00", "%Hh", "1.17h"), + ("02:00:00", "%Hh", "2h"), + ("00:00:30", "%Ss", "30s"), + ("48:00:00", "%Dd", "2d"), + ("36:00:00", "%Dd", "1.5d"), + ], +) +def test_flux_walltime(walltime, walltime_format, expected): + # Act + output = EnvBatch._flux_walltime(walltime, walltime_format) + + # Assert + assert output == expected + + +@pytest.mark.parametrize( + "walltime_format", + [ + "%H", + "%S", + "%D", + "%H:%M:%S", + "%Hm", + "%Md", + "minutes", + ], +) +def test_flux_walltime_invalid_format(walltime_format): + with pytest.raises(CIMEError, match="not valid for flux"): + EnvBatch._flux_walltime("01:10:00", walltime_format) + + XML_BASE = b"""
@@ -812,6 +854,308 @@ def get_value(*args, **kwargs): "JOB_WALLCLOCK_TIME", "05:00:00", subgroup="case.run" ) + @mock.patch("CIME.XML.env_batch.EnvBatch.get_value", return_value=None) + @mock.patch("CIME.XML.env_batch.EnvBatch.text", return_value="default") + # nodemin, nodemax, jobname, walltimemin, walltimemax, jobmin, jobmax, strict + @mock.patch( + "CIME.XML.env_batch.EnvBatch.get_queue_specs", + return_value=[ + 1, + 1, + "case.run", + None, + None, + "12:00:00", + 1, + 1, + False, + ], + ) + @mock.patch("CIME.XML.env_batch.EnvBatch.select_best_queue") + @mock.patch("CIME.XML.env_batch.EnvBatch.get_default_queue") + def test_set_job_defaults_flux_walltime_minutes( + self, get_default_queue, select_best_queue, get_queue_specs, text, get_value + ): + # Context + case = mock.MagicMock() + + batch_jobs = [ + ( + "case.run", + { + "template": "template.case.run", + "prereq": "$BUILD_COMPLETE and not $TEST", + }, + ) + ] + + def case_get_value(*args, **kwargs): + if args[0] == "USER_REQUESTED_WALLTIME": + return "01:10:00" + + return mock.MagicMock() + + case.get_value = case_get_value + + case.get_env.return_value.get_jobs.return_value = ["case.run"] + + batch = EnvBatch() + batch.set_batch_system_type("flux") + + # Act + batch.set_job_defaults(batch_jobs, case) + + # Assert + env_workflow = case.get_env.return_value + + env_workflow.set_value.assert_any_call( + "JOB_WALLCLOCK_TIME", "70m", subgroup="case.run" + ) + + @mock.patch("CIME.XML.env_batch.EnvBatch.get_value", return_value=None) + @mock.patch("CIME.XML.env_batch.EnvBatch.text", return_value="default") + # nodemin, nodemax, jobname, walltimemin, walltimemax, jobmin, jobmax, strict + @mock.patch( + "CIME.XML.env_batch.EnvBatch.get_queue_specs", + return_value=[ + 1, + 1, + "case.run", + None, + None, + "12:00:00", + 1, + 1, + False, + ], + ) + @mock.patch("CIME.XML.env_batch.EnvBatch.select_best_queue") + @mock.patch("CIME.XML.env_batch.EnvBatch.get_default_queue") + def test_set_job_defaults_flux_walltime_partial_minute( + self, get_default_queue, select_best_queue, get_queue_specs, text, get_value + ): + # Context + case = mock.MagicMock() + + batch_jobs = [ + ( + "case.run", + { + "template": "template.case.run", + "prereq": "$BUILD_COMPLETE and not $TEST", + }, + ) + ] + + def case_get_value(*args, **kwargs): + if args[0] == "USER_REQUESTED_WALLTIME": + return "00:10:30" + + return mock.MagicMock() + + case.get_value = case_get_value + + case.get_env.return_value.get_jobs.return_value = ["case.run"] + + batch = EnvBatch() + batch.set_batch_system_type("flux") + + # Act + batch.set_job_defaults(batch_jobs, case) + + # Assert partial minutes round up so the job is not cut short + env_workflow = case.get_env.return_value + + env_workflow.set_value.assert_any_call( + "JOB_WALLCLOCK_TIME", "11m", subgroup="case.run" + ) + + @mock.patch("CIME.XML.env_batch.EnvBatch.get_value") + @mock.patch("CIME.XML.env_batch.EnvBatch.text", return_value="default") + # nodemin, nodemax, jobname, walltimemin, walltimemax, jobmin, jobmax, strict + @mock.patch( + "CIME.XML.env_batch.EnvBatch.get_queue_specs", + return_value=[ + 1, + 1, + "case.run", + None, + None, + "12:00:00", + 1, + 1, + False, + ], + ) + @mock.patch("CIME.XML.env_batch.EnvBatch.select_best_queue") + @mock.patch("CIME.XML.env_batch.EnvBatch.get_default_queue") + def test_set_job_defaults_flux_walltime_format_minutes( + self, get_default_queue, select_best_queue, get_queue_specs, text, get_value + ): + # Context, walltime_format %M emits bare total minutes for flux + get_value.side_effect = lambda name, *args, **kwargs: ( + "%M" if name == "walltime_format" else None + ) + + case = mock.MagicMock() + + batch_jobs = [ + ( + "case.run", + { + "template": "template.case.run", + "prereq": "$BUILD_COMPLETE and not $TEST", + }, + ) + ] + + def case_get_value(*args, **kwargs): + if args[0] == "USER_REQUESTED_WALLTIME": + return "01:10:00" + + return mock.MagicMock() + + case.get_value = case_get_value + + case.get_env.return_value.get_jobs.return_value = ["case.run"] + + batch = EnvBatch() + batch.set_batch_system_type("flux") + + # Act + batch.set_job_defaults(batch_jobs, case) + + # Assert + env_workflow = case.get_env.return_value + + env_workflow.set_value.assert_any_call( + "JOB_WALLCLOCK_TIME", "70", subgroup="case.run" + ) + + @mock.patch("CIME.XML.env_batch.EnvBatch.get_value") + @mock.patch("CIME.XML.env_batch.EnvBatch.text", return_value="default") + # nodemin, nodemax, jobname, walltimemin, walltimemax, jobmin, jobmax, strict + @mock.patch( + "CIME.XML.env_batch.EnvBatch.get_queue_specs", + return_value=[ + 1, + 1, + "case.run", + None, + None, + "12:00:00", + 1, + 1, + False, + ], + ) + @mock.patch("CIME.XML.env_batch.EnvBatch.select_best_queue") + @mock.patch("CIME.XML.env_batch.EnvBatch.get_default_queue") + def test_set_job_defaults_flux_walltime_format_fsd( + self, get_default_queue, select_best_queue, get_queue_specs, text, get_value + ): + # Context, walltime_format %Hh emits FSD hours for flux + get_value.side_effect = lambda name, *args, **kwargs: ( + "%Hh" if name == "walltime_format" else None + ) + + case = mock.MagicMock() + + batch_jobs = [ + ( + "case.run", + { + "template": "template.case.run", + "prereq": "$BUILD_COMPLETE and not $TEST", + }, + ) + ] + + def case_get_value(*args, **kwargs): + if args[0] == "USER_REQUESTED_WALLTIME": + return "01:10:00" + + return mock.MagicMock() + + case.get_value = case_get_value + + case.get_env.return_value.get_jobs.return_value = ["case.run"] + + batch = EnvBatch() + batch.set_batch_system_type("flux") + + # Act + batch.set_job_defaults(batch_jobs, case) + + # Assert + env_workflow = case.get_env.return_value + + env_workflow.set_value.assert_any_call( + "JOB_WALLCLOCK_TIME", "1.17h", subgroup="case.run" + ) + + @mock.patch("CIME.XML.env_batch.EnvBatch.get_value") + @mock.patch("CIME.XML.env_batch.EnvBatch.text", return_value="default") + # nodemin, nodemax, jobname, walltimemin, walltimemax, jobmin, jobmax, strict + @mock.patch( + "CIME.XML.env_batch.EnvBatch.get_queue_specs", + return_value=[ + 1, + 1, + "case.run", + None, + None, + "12:00:00", + 1, + 1, + False, + ], + ) + @mock.patch("CIME.XML.env_batch.EnvBatch.select_best_queue") + @mock.patch("CIME.XML.env_batch.EnvBatch.get_default_queue") + def test_set_job_defaults_flux_walltime_format_fsd_minutes( + self, get_default_queue, select_best_queue, get_queue_specs, text, get_value + ): + # Context, walltime_format %Mm emits FSD minutes for flux + get_value.side_effect = lambda name, *args, **kwargs: ( + "%Mm" if name == "walltime_format" else None + ) + + case = mock.MagicMock() + + batch_jobs = [ + ( + "case.run", + { + "template": "template.case.run", + "prereq": "$BUILD_COMPLETE and not $TEST", + }, + ) + ] + + def case_get_value(*args, **kwargs): + if args[0] == "USER_REQUESTED_WALLTIME": + return "01:10:00" + + return mock.MagicMock() + + case.get_value = case_get_value + + case.get_env.return_value.get_jobs.return_value = ["case.run"] + + batch = EnvBatch() + batch.set_batch_system_type("flux") + + # Act + batch.set_job_defaults(batch_jobs, case) + + # Assert + env_workflow = case.get_env.return_value + + env_workflow.set_value.assert_any_call( + "JOB_WALLCLOCK_TIME", "70m", subgroup="case.run" + ) + @mock.patch("CIME.XML.env_batch.EnvBatch.text", return_value="default") # nodemin, nodemax, jobname, walltimemax, jobmin, jobmax, strict @mock.patch( diff --git a/doc/source/ccs/model-configuration/variables/batch.rst b/doc/source/ccs/model-configuration/variables/batch.rst index 2b4dbf8c42a..874df07ce54 100644 --- a/doc/source/ccs/model-configuration/variables/batch.rst +++ b/doc/source/ccs/model-configuration/variables/batch.rst @@ -88,6 +88,59 @@ Contents -------- The following describes the contents of the ``config_batch.xml`` file. +Walltime Format +--------------- +The ``walltime_format`` element controls how a job's resolved walltime is +rendered before it is passed to the batch system, e.g. through +``$JOB_WALLCLOCK_TIME`` in ``submit_args``. + +For most batch systems the value is a positional format string using the +``%H``, ``%M``, and ``%S`` specifiers. The fields of the input walltime are +rearranged to match the format; they are **not** summed across units. For +example ``%H:%M`` renders ``01:10:00`` as ``01:10``. + +.. code-block:: xml + + %H:%M:%S + +Flux +:::: +Flux's ``-t/--time-limit`` option accepts minutes or a Flux Standard +Duration (FSD, RFC 23) — a number with a single unit suffix (``s``, ``m``, +``h``, or ``d``) — rather than ``HH:MM:SS``. When ``batch_system`` is of +type ``flux`` the specifiers therefore expand to the **total** duration +expressed in that unit, so a format pairing a specifier with its matching +FSD suffix produces a valid FSD value. + +Only the following formats are accepted; any other value raises an error +during case setup since it would produce a value Flux rejects or silently +misinterprets (e.g. a bare ``%H`` renders a number Flux would read as +minutes). + +=================== ============================================ ====================== +``walltime_format`` Description Example (``01:10:00``) +=================== ============================================ ====================== +*(unset)* Total minutes as FSD, the default. ``70m`` +``%M`` Total minutes, bare. ``70`` +``%Ss`` Total seconds as FSD. ``4200s`` +``%Mm`` Total minutes as FSD. ``70m`` +``%Hh`` Total hours as FSD. ``1.17h`` +``%Dd`` Total days as FSD. ``0.05d`` +=================== ============================================ ====================== + +Partial values are rounded up at two decimal places so a job is never +allotted less time than requested. + +.. code-block:: xml + + + flux batch + %Mm + + + + + Schema Definition ----------------- @@ -113,7 +166,7 @@ jobid_pattern Regex pattern to parse job id. depend_string Dependency string. depend_allow_string Dependency string if fails are allowed. depend_separator Separator for dependencies. -walltime_format Format used to parse walltime. +walltime_format Format used to render the walltime, see `Walltime Format`_. batch_mail_flag Mail flag to pass user. batch_mail_type_flag Mail type. batch_mail_default Default type if `batch_mail_type_flag` is not set.