Repository: reframe-hpc/reframe

Issue:
Allow for skipping a flexible test if not enough nodes are available instead of failing
I would probably be also in favour of changing the default behaviour to be skipping the test and add a new command-line option to enforce the strict behaviour.

Discussion:

Patch:
diff --git a/docs/config_reference.rst b/docs/config_reference.rst
index 19d7d253..62f18093 100644
--- a/docs/config_reference.rst
+++ b/docs/config_reference.rst
@@ -976,7 +976,7 @@ They are associated with `system partitions <#system-partition-configuration>`__
    :default: ``{}``
 
    Scheduler resources associated with this environments.
-   
+
    This is the equivalent of a test's :attr:`~reframe.core.pipeline.RegressionTest.extra_resources`.
 
    .. versionadded:: 4.6
@@ -1632,14 +1632,6 @@ General Configuration
    .. versionadded:: 3.12.0
 
 
-.. py:attribute:: general.git_timeout
-
-  :required: No
-  :default: 5
-
-  Timeout value in seconds used when checking if a git repository exists.
-
-
 .. py:attribute:: general.dump_pipeline_progress
 
    Dump pipeline progress for the asynchronous execution policy in ``pipeline-progress.json``.
@@ -1651,6 +1643,24 @@ General Configuration
    .. versionadded:: 3.10.0
 
 
+.. py:attribute:: general.flex_alloc_strict
+
+   :required: No
+   :default: ``False``
+
+   Fail flexible tests if their minimum task requirement is not satisfied.
+
+   .. versionadded:: 4.7
+
+
+.. py:attribute:: general.git_timeout
+
+   :required: No
+   :default: 5
+
+   Timeout value in seconds used when checking if a git repository exists.
+
+
 .. py:attribute:: general.pipeline_timeout
 
    Timeout in seconds for advancing the pipeline in the asynchronous execution policy.
diff --git a/docs/manpage.rst b/docs/manpage.rst
index 02a47f1c..4cdd28f2 100644
--- a/docs/manpage.rst
+++ b/docs/manpage.rst
@@ -752,6 +752,18 @@ If no node can be selected, the test will be marked as a failure with an appropr
 
       Slurm OR constraints and parenthesized expressions are supported in flexible node allocation.
 
+   .. versionchanged:: 4.7
+      The test is not marked as a failure if not enough nodes are available, but it is skipped instead.
+      To enforce a failure, use :option:`--flex-alloc-strict`
+
+.. option:: --flex-alloc-strict
+
+   Fail flexible tests if their minimum task requirement is not satisfied.
+   Otherwise the tests will be skipped.
+
+   .. versionadded:: 4.7
+
+
 ---------------------------------------
 Options controlling ReFrame environment
 ---------------------------------------
@@ -1410,6 +1422,21 @@ Whenever an environment variable is associated with a configuration option, its
    .. versionadded:: 4.0.0
 
 
+.. envvar:: RFM_FLEX_ALLOC_STRICT
+
+   Fail flexible tests if their minimum task requirement is not satisfied.
+
+   .. table::
+      :align: left
+
+      ================================== ==================
+      Associated command line option     :option:`--flex-alloc-strict`
+      Associated configuration parameter :attr:`~config.general.flex_alloc_strict`
+      ================================== ==================
+
+   .. versionadded:: 4.7
+
+
 .. envvar:: RFM_GIT_TIMEOUT
 
    Timeout value in seconds used when checking if a git repository exists.
diff --git a/reframe/core/pipeline.py b/reframe/core/pipeline.py
index 1a779f38..33b8d58a 100644
--- a/reframe/core/pipeline.py
+++ b/reframe/core/pipeline.py
@@ -2018,6 +2018,7 @@ class RegressionTest(RegressionMixin, jsonext.JSONSerializable):
                 self._job.prepare(
                     commands, environs,
                     self._current_partition.prepare_cmds,
+                    rt.runtime().get_option('general/flex_alloc_strict'),
                     login=rt.runtime().get_option('general/0/use_login_shell'),
                     trap_errors=rt.runtime().get_option(
                         'general/0/trap_job_errors'
diff --git a/reframe/core/schedulers/__init__.py b/reframe/core/schedulers/__init__.py
index 0d3771d7..0266e432 100644
--- a/reframe/core/schedulers/__init__.py
+++ b/reframe/core/schedulers/__init__.py
@@ -15,7 +15,7 @@ import reframe.core.runtime as runtime
 import reframe.core.shell as shell
 import reframe.utility.jsonext as jsonext
 import reframe.utility.typecheck as typ
-from reframe.core.exceptions import JobError, JobNotStartedError
+from reframe.core.exceptions import JobError, JobNotStartedError, SkipTestError
 from reframe.core.launchers import JobLauncher
 from reframe.core.logging import getlogger, DEBUG2
 from reframe.core.meta import RegressionTestMeta
@@ -550,7 +550,8 @@ class Job(jsonext.JSONSerializable, metaclass=JobMeta):
         '''
         return self._submit_time
 
-    def prepare(self, commands, environs=None, prepare_cmds=None, **gen_opts):
+    def prepare(self, commands, environs=None, prepare_cmds=None,
+                strict_flex=False, **gen_opts):
         environs = environs or []
         if self.num_tasks is not None and self.num_tasks <= 0:
             getlogger().debug(f'[F] Flexible node allocation requested')
@@ -565,11 +566,12 @@ class Job(jsonext.JSONSerializable, metaclass=JobMeta):
                                'this scheduler backend') from e
 
             if guessed_num_tasks < min_num_tasks:
-                raise JobError(
-                    'could not satisfy the minimum task requirement: '
-                    'required %s, found %s' %
-                    (min_num_tasks, guessed_num_tasks)
-                )
+                msg = (f'could not satisfy the minimum task requirement: '
+                       f'required {min_num_tasks}, found {guessed_num_tasks}')
+                if strict_flex:
+                    raise JobError(msg)
+                else:
+                    raise SkipTestError(msg)
 
             self.num_tasks = guessed_num_tasks
             getlogger().debug(f'[F] Setting num_tasks to {self.num_tasks}')
diff --git a/reframe/frontend/cli.py b/reframe/frontend/cli.py
index f1d77cc2..c3229590 100644
--- a/reframe/frontend/cli.py
+++ b/reframe/frontend/cli.py
@@ -422,6 +422,12 @@ def main():
         dest='flex_alloc_nodes', metavar='{all|STATE|NUM}', default=None,
         help='Set strategy for the flexible node allocation (default: "idle").'
     )
+    run_options.add_argument(
+        '--flex-alloc-strict', action='store_true',
+        envvar='RFM_FLEX_ALLOC_STRICT',
+        configvar='general/flex_alloc_strict',
+        help='Fail the flexible tests if not enough nodes can be found'
+    )
     run_options.add_argument(
         '-J', '--job-option', action='append', metavar='OPT',
         dest='job_options', default=[],


Test patch:
diff --git a/unittests/test_schedulers.py b/unittests/test_schedulers.py
index 8c4ebea5..f5c95bbd 100644
--- a/unittests/test_schedulers.py
+++ b/unittests/test_schedulers.py
@@ -15,7 +15,7 @@ import unittests.utility as test_util
 from reframe.core.backends import (getlauncher, getscheduler)
 from reframe.core.environments import Environment
 from reframe.core.exceptions import (
-    ConfigError, JobError, JobNotStartedError, JobSchedulerError
+    ConfigError, JobError, JobNotStartedError, JobSchedulerError, SkipTestError
 )
 from reframe.core.schedulers import Job
 from reframe.core.schedulers.slurm import _SlurmNode, _create_nodes
@@ -124,7 +124,7 @@ def fake_job(make_job):
 
 def prepare_job(job, command='hostname',
                 pre_run=None, post_run=None,
-                prepare_cmds=None):
+                prepare_cmds=None, strict_flex=True):
     environs = [Environment(name='foo', modules=['testmod_foo'])]
     pre_run = pre_run or ['echo prerun']
     post_run = post_run or ['echo postrun']
@@ -137,7 +137,8 @@ def prepare_job(job, command='hostname',
                 post_run
             ],
             environs,
-            prepare_cmds
+            prepare_cmds,
+            strict_flex,
         )
 
 
@@ -1146,11 +1147,16 @@ def test_flex_alloc_no_num_tasks_per_node(make_flexible_job):
     assert job.num_tasks == 1
 
 
-def test_flex_alloc_not_enough_idle_nodes(make_flexible_job):
+@pytest.fixture(params=['skip', 'error'])
+def strict_flex(request):
+    return request.param == 'error'
+
+
+def test_flex_alloc_not_enough_idle_nodes(make_flexible_job, strict_flex):
     job = make_flexible_job('idle')
     job.num_tasks = -12
-    with pytest.raises(JobError):
-        prepare_job(job)
+    with pytest.raises(JobError if strict_flex else SkipTestError):
+        prepare_job(job, strict_flex=strict_flex)
 
 
 def test_flex_alloc_maintenance_nodes(make_flexible_job):