From 60a0c58c40bc25dd3ad82163ca78dfbbe789e66f Mon Sep 17 00:00:00 2001 From: Oliver Sanders Date: Wed, 4 Oct 2023 10:07:56 +0100 Subject: [PATCH 001/196] doc: clarify runahead limit --- cylc/flow/cfgspec/workflow.py | 30 +++++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/cylc/flow/cfgspec/workflow.py b/cylc/flow/cfgspec/workflow.py index a6d16bbe76d..a3049ee9660 100644 --- a/cylc/flow/cfgspec/workflow.py +++ b/cylc/flow/cfgspec/workflow.py @@ -626,21 +626,33 @@ def get_script_common_text(this: str, example: Optional[str] = None): 365 day (never a leap year) and 366 day (always a leap year). ''') Conf('runahead limit', VDR.V_STRING, 'P4', desc=''' - The scheduler runahead limit determines how many consecutive cycle - points can be active at once. The base point of the runahead - calculation is the lowest-valued point with :term:`active` or - :term:`incomplete` tasks present. + The runahead limit prevents a workflow from getting too far ahead + of the oldest :term:`active cycle`. - An integer interval value of ``Pn`` allows up to ``n+1`` cycle - points (including the base point) to be active at once. + A cycle is considered to be active if it contains any + :term:`active` tasks. - The default runahead limit is ``P4``, i.e. 5 active points - including the base point. + An integer interval value of ``Pn`` allows up to ``n+1`` cycles + to be active at once. + + The default runahead limit is ``P4``, which means there may be up + to 5 active cycles. Datetime cycling workflows can optionally use a datetime interval - value instead, in which case the number of active cycle points + value instead, in which case the number of cycles within the interval depends on the cycling intervals present. + Examples: + + ``P0`` + Only one cycle can be active at a time. + ``P2`` + The scheduler will run up to two cycles ahead of the oldest + active cycle. + ``P3D`` + The scheduler will run cycles up to three days of cycles ahead + of the oldest active cycle. + .. seealso:: :ref:`RunaheadLimit` From da9ac3a69eb7f85fc05d3d9f64f292c49cadc099 Mon Sep 17 00:00:00 2001 From: Hilary James Oliver Date: Sat, 13 Apr 2024 16:34:01 +1200 Subject: [PATCH 002/196] Distinguish never-spawned from never-submitted. --- cylc/flow/task_pool.py | 44 +++++++++++++++++++++++++++++++++--------- 1 file changed, 35 insertions(+), 9 deletions(-) diff --git a/cylc/flow/task_pool.py b/cylc/flow/task_pool.py index 2b214d70943..f8573923c46 100644 --- a/cylc/flow/task_pool.py +++ b/cylc/flow/task_pool.py @@ -1401,6 +1401,12 @@ def spawn_on_output(self, itask, output, forced=False): msg += " suiciding while active" self.remove(c_task, msg) + if suicide: + # Update the DB immediately to ensure a record exists in case of + # very quick removal and respawn, due to suicide triggers. + # See https://github.com/cylc/cylc-flow/issues/6066 + self.workflow_db_mgr.process_queued_ops() + self.remove_if_complete(itask, output) def remove_if_complete( @@ -1555,17 +1561,33 @@ def can_be_spawned(self, name: str, point: 'PointBase') -> bool: def _get_task_history( self, name: str, point: 'PointBase', flow_nums: Set[int] - ) -> Tuple[int, str, bool]: - """Get history of previous submits for this task.""" + ) -> Tuple[bool, int, str, bool]: + """Get history of previous submits for this task. + + Args: + name: task name + point: task cycle point + flow_nums: task flow numbers + Returns: + never_spawned: if task never spawned before + submit_num: submit number of previous submit + prev_status: task status of previous sumbit + prev_flow_wait: if previous submit was a flow-wait task + + """ info = self.workflow_db_mgr.pri_dao.select_prev_instances( name, str(point) ) try: submit_num: int = max(s[0] for s in info) except ValueError: - # never spawned before in any flow + # never spawned in any flow submit_num = 0 + never_spawned = True + else: + never_spawned = False + # (submit_num could still be zero, if removed before submit) prev_status: str = TASK_STATUS_WAITING prev_flow_wait = False @@ -1582,7 +1604,7 @@ def _get_task_history( # overlap due to merges (they'll have have same snum and # f_wait); keep going to find the finished one, if any. - return submit_num, prev_status, prev_flow_wait + return never_spawned, submit_num, prev_status, prev_flow_wait def _load_historical_outputs(self, itask): """Load a task's historical outputs from the DB.""" @@ -1619,10 +1641,15 @@ def spawn_task( if not self.can_be_spawned(name, point): return None - submit_num, prev_status, prev_flow_wait = ( + never_spawned, submit_num, prev_status, prev_flow_wait = ( self._get_task_history(name, point, flow_nums) ) + if not never_spawned and submit_num == 0: + # Previous spawn suicided before completing any outputs. + LOG.debug(f"{point}/{name} already spawned in this flow") + return None + itask = self._get_task_proxy_db_outputs( point, self.config.get_taskdef(name), @@ -1653,8 +1680,6 @@ def spawn_task( if itask.transient and not force: return None - # (else not previously finishedr, so run it) - if not itask.transient: if (name, point) in self.tasks_to_hold: LOG.info(f"[{itask}] holding (as requested earlier)") @@ -2117,8 +2142,9 @@ def force_trigger_tasks( if not self.can_be_spawned(name, point): continue - submit_num, _prev_status, prev_fwait = self._get_task_history( - name, point, flow_nums) + _, submit_num, _prev_status, prev_fwait = ( + self._get_task_history(name, point, flow_nums) + ) itask = TaskProxy( self.tokens, From b0292dbb627944e8cda1c54f0dee72c2824abd4f Mon Sep 17 00:00:00 2001 From: Hilary James Oliver Date: Tue, 16 Apr 2024 00:34:13 +1200 Subject: [PATCH 003/196] Tweak previous for flow-wait. --- cylc/flow/task_pool.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/cylc/flow/task_pool.py b/cylc/flow/task_pool.py index f8573923c46..88212fa8b8c 100644 --- a/cylc/flow/task_pool.py +++ b/cylc/flow/task_pool.py @@ -1645,7 +1645,11 @@ def spawn_task( self._get_task_history(name, point, flow_nums) ) - if not never_spawned and submit_num == 0: + if ( + not never_spawned and + not prev_flow_wait and + submit_num == 0 + ): # Previous spawn suicided before completing any outputs. LOG.debug(f"{point}/{name} already spawned in this flow") return None From d1645a4e1fa9fe4fc19e183351e020f1750ae3cc Mon Sep 17 00:00:00 2001 From: Hilary James Oliver Date: Thu, 18 Apr 2024 16:36:24 +1200 Subject: [PATCH 004/196] Improve suicide trigger logging. --- cylc/flow/task_pool.py | 30 ++++++++++--------- .../functional/triggering/18-suicide-active.t | 4 +-- 2 files changed, 18 insertions(+), 16 deletions(-) diff --git a/cylc/flow/task_pool.py b/cylc/flow/task_pool.py index 88212fa8b8c..67536918e97 100644 --- a/cylc/flow/task_pool.py +++ b/cylc/flow/task_pool.py @@ -103,7 +103,7 @@ class TaskPool: ERR_TMPL_NO_TASKID_MATCH = "No matching tasks found: {0}" ERR_PREFIX_TASK_NOT_ON_SEQUENCE = "Invalid cycle point for task: {0}, {1}" - SUICIDE_MSG = "suicide" + SUICIDE_MSG = "suicide trigger" def __init__( self, @@ -837,7 +837,17 @@ def remove(self, itask, reason=None): # Event-driven final update of task_states table. # TODO: same for datastore (still updated by scheduler loop) self.workflow_db_mgr.put_update_task_state(itask) - LOG.info(f"[{itask}] {msg}") + + level = logging.INFO + if itask.state( + TASK_STATUS_PREPARING, + TASK_STATUS_SUBMITTED, + TASK_STATUS_RUNNING, + ): + level = logging.WARNING + msg += " - active job orphaned" + + LOG.log(level, f"[{itask}] {msg}") del itask def get_tasks(self) -> List[TaskProxy]: @@ -1392,18 +1402,10 @@ def spawn_on_output(self, itask, output, forced=False): suicide.append(t) for c_task in suicide: - msg = self.__class__.SUICIDE_MSG - if c_task.state( - TASK_STATUS_PREPARING, - TASK_STATUS_SUBMITTED, - TASK_STATUS_RUNNING, - is_held=False): - msg += " suiciding while active" - self.remove(c_task, msg) + self.remove(c_task, self.__class__.SUICIDE_MSG) if suicide: - # Update the DB immediately to ensure a record exists in case of - # very quick removal and respawn, due to suicide triggers. + # Update DB now in case of very quick respawn attempt. # See https://github.com/cylc/cylc-flow/issues/6066 self.workflow_db_mgr.process_queued_ops() @@ -1650,8 +1652,8 @@ def spawn_task( not prev_flow_wait and submit_num == 0 ): - # Previous spawn suicided before completing any outputs. - LOG.debug(f"{point}/{name} already spawned in this flow") + # Previous instance removed before completing any outputs. + LOG.info(f"Not spawning {point}/{name}: already used in this flow") return None itask = self._get_task_proxy_db_outputs( diff --git a/tests/functional/triggering/18-suicide-active.t b/tests/functional/triggering/18-suicide-active.t index d4df07b1275..ee396803496 100644 --- a/tests/functional/triggering/18-suicide-active.t +++ b/tests/functional/triggering/18-suicide-active.t @@ -16,7 +16,7 @@ # along with this program. If not, see . #------------------------------------------------------------------------------- -# Test warning for "suiciding while active" +# Test for suicide while active warning. . "$(dirname "$0")/test_header" @@ -29,6 +29,6 @@ run_ok "${TEST_NAME_BASE}-validate" cylc validate "${WORKFLOW_NAME}" workflow_run_ok "${TEST_NAME_BASE}-run" \ cylc play --debug --no-detach "${WORKFLOW_NAME}" -grep_workflow_log_ok "${TEST_NAME_BASE}-grep" "suiciding while active" +grep_workflow_log_ok "${TEST_NAME_BASE}-grep" "(suicide trigger) - active job orphaned" purge From d1443abc8d8f77f9115c2b866e42182a5e063745 Mon Sep 17 00:00:00 2001 From: Hilary James Oliver Date: Thu, 18 Apr 2024 17:34:04 +1200 Subject: [PATCH 005/196] New integration test. --- cylc/flow/task_pool.py | 5 +-- tests/functional/cylc-set/02-off-flow-out.t | 6 ++-- tests/functional/hold-release/05-release.t | 2 +- .../spawn-on-demand/09-set-outputs/flow.cylc | 2 +- tests/integration/test_task_pool.py | 32 +++++++++++++++++-- 5 files changed, 37 insertions(+), 10 deletions(-) diff --git a/cylc/flow/task_pool.py b/cylc/flow/task_pool.py index 67536918e97..7e3b77275e8 100644 --- a/cylc/flow/task_pool.py +++ b/cylc/flow/task_pool.py @@ -807,10 +807,11 @@ def remove(self, itask, reason=None): itask.flow_nums ) + msg = "removed from active task pool" if reason is None: - msg = "task completed" + msg += ": completed" else: - msg = f"removed ({reason})" + msg += f": {reason}" if itask.is_xtrigger_sequential: self.xtrigger_mgr.sequential_spawn_next.discard(itask.identity) diff --git a/tests/functional/cylc-set/02-off-flow-out.t b/tests/functional/cylc-set/02-off-flow-out.t index a18d3e61fbf..cdb21fbb078 100644 --- a/tests/functional/cylc-set/02-off-flow-out.t +++ b/tests/functional/cylc-set/02-off-flow-out.t @@ -31,14 +31,14 @@ reftest_run grep_workflow_log_ok "${TEST_NAME_BASE}-grep-a1" '1/a_cold.* setting implied output: submitted' grep_workflow_log_ok "${TEST_NAME_BASE}-grep-a2" '1/a_cold.* setting implied output: started' -grep_workflow_log_ok "${TEST_NAME_BASE}-grep-a3" '1/a_cold.* task completed' +grep_workflow_log_ok "${TEST_NAME_BASE}-grep-a3" '1/a_cold.* completed' grep_workflow_log_ok "${TEST_NAME_BASE}-grep-a1" '1/b_cold.* setting implied output: submitted' grep_workflow_log_ok "${TEST_NAME_BASE}-grep-a2" '1/b_cold.* setting implied output: started' -grep_workflow_log_ok "${TEST_NAME_BASE}-grep-b3" '1/b_cold.* task completed' +grep_workflow_log_ok "${TEST_NAME_BASE}-grep-b3" '1/b_cold.* completed' grep_workflow_log_ok "${TEST_NAME_BASE}-grep-a1" '1/c_cold.* setting implied output: submitted' grep_workflow_log_ok "${TEST_NAME_BASE}-grep-a2" '1/c_cold.* setting implied output: started' -grep_workflow_log_ok "${TEST_NAME_BASE}-grep-c3" '1/c_cold.* task completed' +grep_workflow_log_ok "${TEST_NAME_BASE}-grep-c3" '1/c_cold.* completed' purge diff --git a/tests/functional/hold-release/05-release.t b/tests/functional/hold-release/05-release.t index 26f4e22d414..ea4cc3d95a5 100755 --- a/tests/functional/hold-release/05-release.t +++ b/tests/functional/hold-release/05-release.t @@ -65,7 +65,7 @@ init_workflow "${TEST_NAME_BASE}" <<'__FLOW_CONFIG__' inherit = STOP script = """ cylc__job__poll_grep_workflow_log -E \ - '1/dog1/01:succeeded.* task completed' + '1/dog1/01:succeeded.* completed' cylc stop "${CYLC_WORKFLOW_ID}" """ __FLOW_CONFIG__ diff --git a/tests/functional/spawn-on-demand/09-set-outputs/flow.cylc b/tests/functional/spawn-on-demand/09-set-outputs/flow.cylc index a40e6e9be33..85138715d1b 100644 --- a/tests/functional/spawn-on-demand/09-set-outputs/flow.cylc +++ b/tests/functional/spawn-on-demand/09-set-outputs/flow.cylc @@ -44,7 +44,7 @@ cylc set --flow=2 --output=out1 --output=out2 "${CYLC_WORKFLOW_ID}//1/foo" # Set bar outputs after it is gone from the pool. - cylc__job__poll_grep_workflow_log -E "1/bar.* task completed" + cylc__job__poll_grep_workflow_log -E "1/bar.* completed" cylc set --flow=2 --output=out1 --output=out2 "${CYLC_WORKFLOW_ID}//1/bar" """ [[qux, quw, fux, fuw]] diff --git a/tests/integration/test_task_pool.py b/tests/integration/test_task_pool.py index 3d75074cf15..3385f89a8a0 100644 --- a/tests/integration/test_task_pool.py +++ b/tests/integration/test_task_pool.py @@ -168,7 +168,7 @@ async def example_flow( caplog.set_level(logging.INFO, CYLC_LOG) id_ = flow(EXAMPLE_FLOW_CFG) schd: 'Scheduler' = scheduler(id_) - async with start(schd): + async with start(schd, level=logging.DEBUG): yield schd @@ -1212,7 +1212,7 @@ async def test_detect_incomplete_tasks( if itask.tdef.name == TASK_STATUS_EXPIRED: assert log_filter( log, - contains=f"[{itask}] removed (expired)" + contains=f"[{itask}] removed from active task pool: expired" ) # the task should have been removed assert itask not in schd.pool.get_tasks() @@ -1294,7 +1294,7 @@ async def test_set_failed_complete( schd.pool.set_prereqs_and_outputs([one.identity], None, None, ['all']) assert log_filter( - log, contains=f'[{one}] task completed') + log, contains=f'[{one}] removed from active task pool: completed') db_outputs = db_select( schd, True, 'task_outputs', 'outputs', @@ -1874,3 +1874,29 @@ def max_cycle(tasks): mod_blah.pool.compute_runahead() after = mod_blah.pool.runahead_limit_point assert bool(before != after) == expected + + +async def test_fast_respawn( + example_flow: 'Scheduler', + caplog: pytest.LogCaptureFixture, +) -> None: + """Immediate re-spawn of removed tasks is not allowed. + + An immediate DB update is required to stop the respawn. + https://github.com/cylc/cylc-flow/pull/6067 + + """ + task_pool = example_flow.pool + + # find task 1/foo in the pool + foo = task_pool.get_task(IntegerPoint("1"), "foo") + assert foo in task_pool.get_tasks() + + # remove it from the pool + task_pool.remove(foo) + assert foo not in task_pool.get_tasks() + + # attempt to spawn it again + itask = task_pool.spawn_task("foo", IntegerPoint("1"), {1}) + assert itask is None + assert "Not spawning 1/foo: already used in this flow" in caplog.text From a803092fc5c4ca61b14e7bc579fdaf535e3598c7 Mon Sep 17 00:00:00 2001 From: Hilary James Oliver Date: Thu, 18 Apr 2024 18:50:35 +1200 Subject: [PATCH 006/196] Fix func test. --- tests/functional/triggering/18-suicide-active.t | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/functional/triggering/18-suicide-active.t b/tests/functional/triggering/18-suicide-active.t index ee396803496..16b3269ee0f 100644 --- a/tests/functional/triggering/18-suicide-active.t +++ b/tests/functional/triggering/18-suicide-active.t @@ -29,6 +29,6 @@ run_ok "${TEST_NAME_BASE}-validate" cylc validate "${WORKFLOW_NAME}" workflow_run_ok "${TEST_NAME_BASE}-run" \ cylc play --debug --no-detach "${WORKFLOW_NAME}" -grep_workflow_log_ok "${TEST_NAME_BASE}-grep" "(suicide trigger) - active job orphaned" +grep_workflow_log_ok "${TEST_NAME_BASE}-grep" "suicide trigger - active job orphaned" purge From 3de678fe25184c9b289826c9163be70529f5e33e Mon Sep 17 00:00:00 2001 From: Hilary James Oliver Date: Thu, 18 Apr 2024 18:52:21 +1200 Subject: [PATCH 007/196] Change log. --- changes.d/6067.fix.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changes.d/6067.fix.md diff --git a/changes.d/6067.fix.md b/changes.d/6067.fix.md new file mode 100644 index 00000000000..74d296f1d17 --- /dev/null +++ b/changes.d/6067.fix.md @@ -0,0 +1 @@ +Fixed very quick respawn after task removal. From 2d3df623d7791db9497d1a727f8f032719061ecc Mon Sep 17 00:00:00 2001 From: Hilary James Oliver Date: Fri, 19 Apr 2024 00:09:07 +1200 Subject: [PATCH 008/196] Replace a couple of funcional tests. --- tests/functional/triggering/15-suicide.t | 24 -------- .../triggering/15-suicide/flow.cylc | 23 -------- .../triggering/15-suicide/reference.log | 5 -- .../functional/triggering/18-suicide-active.t | 34 ------------ .../triggering/18-suicide-active/flow.cylc | 11 ---- tests/integration/test_task_pool.py | 55 ++++++++++++++++++- 6 files changed, 53 insertions(+), 99 deletions(-) delete mode 100644 tests/functional/triggering/15-suicide.t delete mode 100644 tests/functional/triggering/15-suicide/flow.cylc delete mode 100644 tests/functional/triggering/15-suicide/reference.log delete mode 100644 tests/functional/triggering/18-suicide-active.t delete mode 100644 tests/functional/triggering/18-suicide-active/flow.cylc diff --git a/tests/functional/triggering/15-suicide.t b/tests/functional/triggering/15-suicide.t deleted file mode 100644 index 3027e742a76..00000000000 --- a/tests/functional/triggering/15-suicide.t +++ /dev/null @@ -1,24 +0,0 @@ -#!/usr/bin/env bash -# THIS FILE IS PART OF THE CYLC WORKFLOW ENGINE. -# Copyright (C) NIWA & British Crown (Met Office) & Contributors. -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . -#------------------------------------------------------------------------------- -# Test suicide triggering -# (this is currently just a copy of the tutorial suicide example, but I -# anticipate making it more fiendish at some point). -. "$(dirname "$0")/test_header" -set_test_number 2 -reftest -exit diff --git a/tests/functional/triggering/15-suicide/flow.cylc b/tests/functional/triggering/15-suicide/flow.cylc deleted file mode 100644 index eed1d934b8c..00000000000 --- a/tests/functional/triggering/15-suicide/flow.cylc +++ /dev/null @@ -1,23 +0,0 @@ -[meta] - title = "Hello, Goodbye, Suicide" -[scheduler] - allow implicit tasks = True - [[events]] - expected task failures = 1/goodbye - -[scheduling] - [[graph]] - R1 = """ - hello => goodbye? - goodbye:fail? => really_goodbye - goodbye? => !really_goodbye - really_goodbye => !goodbye - """ -[runtime] - [[hello]] - script = echo Hello World! - [[goodbye]] - script = """ - echo Goodbye ... oops! - false - """ diff --git a/tests/functional/triggering/15-suicide/reference.log b/tests/functional/triggering/15-suicide/reference.log deleted file mode 100644 index c58830189ab..00000000000 --- a/tests/functional/triggering/15-suicide/reference.log +++ /dev/null @@ -1,5 +0,0 @@ -Initial point: 1 -Final point: 1 -1/hello -triggered off [] -1/goodbye -triggered off ['1/hello'] -1/really_goodbye -triggered off ['1/goodbye'] diff --git a/tests/functional/triggering/18-suicide-active.t b/tests/functional/triggering/18-suicide-active.t deleted file mode 100644 index 16b3269ee0f..00000000000 --- a/tests/functional/triggering/18-suicide-active.t +++ /dev/null @@ -1,34 +0,0 @@ -#!/usr/bin/env bash -# THIS FILE IS PART OF THE CYLC WORKFLOW ENGINE. -# Copyright (C) NIWA & British Crown (Met Office) & Contributors. -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . -#------------------------------------------------------------------------------- - -# Test for suicide while active warning. - -. "$(dirname "$0")/test_header" - -set_test_number 3 - -install_workflow "${TEST_NAME_BASE}" "${TEST_NAME_BASE}" - -run_ok "${TEST_NAME_BASE}-validate" cylc validate "${WORKFLOW_NAME}" - -workflow_run_ok "${TEST_NAME_BASE}-run" \ - cylc play --debug --no-detach "${WORKFLOW_NAME}" - -grep_workflow_log_ok "${TEST_NAME_BASE}-grep" "suicide trigger - active job orphaned" - -purge diff --git a/tests/functional/triggering/18-suicide-active/flow.cylc b/tests/functional/triggering/18-suicide-active/flow.cylc deleted file mode 100644 index a76b82a15e6..00000000000 --- a/tests/functional/triggering/18-suicide-active/flow.cylc +++ /dev/null @@ -1,11 +0,0 @@ -# test "suiciding while active" warning -[scheduler] - [[events]] - inactivity timeout = PT20S - abort on inactivity timeout = True -[scheduling] - [[graph]] - R1 = "foo:start => !foo" -[runtime] - [[foo]] - script = sleep 10 diff --git a/tests/integration/test_task_pool.py b/tests/integration/test_task_pool.py index 3385f89a8a0..f92d822f39e 100644 --- a/tests/integration/test_task_pool.py +++ b/tests/integration/test_task_pool.py @@ -35,7 +35,8 @@ from cylc.flow.data_store_mgr import TASK_PROXIES from cylc.flow.task_events_mgr import TaskEventsManager from cylc.flow.task_outputs import ( - TASK_OUTPUT_SUCCEEDED + TASK_OUTPUT_SUCCEEDED, + TASK_OUTPUT_FAILED ) from cylc.flow.flow_mgr import FLOW_ALL, FLOW_NONE @@ -1890,7 +1891,6 @@ async def test_fast_respawn( # find task 1/foo in the pool foo = task_pool.get_task(IntegerPoint("1"), "foo") - assert foo in task_pool.get_tasks() # remove it from the pool task_pool.remove(foo) @@ -1900,3 +1900,54 @@ async def test_fast_respawn( itask = task_pool.spawn_task("foo", IntegerPoint("1"), {1}) assert itask is None assert "Not spawning 1/foo: already used in this flow" in caplog.text + + +async def test_remove_active_task( + example_flow: 'Scheduler', + caplog: pytest.LogCaptureFixture, +) -> None: + """Test warning on removing an active task.""" + + task_pool = example_flow.pool + + # find task 1/foo in the pool + foo = task_pool.get_task(IntegerPoint("1"), "foo") + + foo.state_reset(TASK_STATUS_RUNNING) + task_pool.remove(foo, "request") + assert foo not in task_pool.get_tasks() + + assert ( + "removed from active task pool: request - active job orphaned" + in caplog.text + ) + + +async def test_remove_by_suicide( + flow, + scheduler, + start, + log_filter +): + """Test task removal by suicide trigger.""" + id_ = flow({ + 'scheduler': {'allow implicit tasks': 'True'}, + 'scheduling': { + 'graph': { + 'R1': 'a? & b\n a:failed? => !b' + }, + } + }) + schd = scheduler(id_) + async with start(schd) as log: + # it should start up with 1/a and 1/b + assert pool_get_task_ids(schd.pool) == ["1/a", "1/b"] + + a = schd.pool.get_task(IntegerPoint("1"), "a") + + schd.pool.spawn_on_output(a, TASK_OUTPUT_FAILED) + assert log_filter( + log, + contains="removed from active task pool: suicide trigger" + ) + assert pool_get_task_ids(schd.pool) == ["1/a"] From a02b266f7d25f939c0edaa24e6550f58e14d1f18 Mon Sep 17 00:00:00 2001 From: Oliver Sanders Date: Thu, 27 Jul 2023 11:28:20 +0100 Subject: [PATCH 009/196] host select: move to new restricted_evaluator interface --- cylc/flow/host_select.py | 147 +++++++++++++--------------- cylc/flow/util.py | 203 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 270 insertions(+), 80 deletions(-) diff --git a/cylc/flow/host_select.py b/cylc/flow/host_select.py index 383855039fa..0eb34d088ca 100644 --- a/cylc/flow/host_select.py +++ b/cylc/flow/host_select.py @@ -14,7 +14,53 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -"""Functionality for selecting a host from pre-defined list.""" +"""Functionality for selecting a host from pre-defined list. + +Ranking/filtering hosts can be achieved using Python expressions which work +with the `psutil` interfaces. + +These expressions are used-defined, buy run a restricted evluation environment +where only certain whitelisted operations are permitted. + +Examples: + >>> RankingExpressionEvaluator('1 + 1') + 2 + >>> RankingExpressionEvaluator('1 * -1') + -1 + >>> RankingExpressionEvaluator('1 < a', a=2) + True + >>> RankingExpressionEvaluator('1 in (1, 2, 3)') + True + >>> import psutil + >>> RankingExpressionEvaluator( + ... 'a.available > 0', + ... a=psutil.virtual_memory() + ... ) + True + + If you try to get it to do something you're not allowed to: + >>> RankingExpressionEvaluator('open("foo")') + Traceback (most recent call last): + ValueError: Invalid expression: open("foo") + "Call" not permitted + + >>> RankingExpressionEvaluator('import sys') + Traceback (most recent call last): + ValueError: invalid syntax: import sys + + If you try to get hold of something you aren't supposed to: + >>> answer = 42 # only variables explicitly passed in should work + >>> RankingExpressionEvaluator('answer') + Traceback (most recent call last): + NameError: name 'answer' is not defined + + If you try to do something which doesn't make sense: + >>> RankingExpressionEvaluator('a.b.c') # no value "a.b.c" + Traceback (most recent call last): + NameError: name 'a' is not defined + +""" + import ast from collections import namedtuple from functools import lru_cache @@ -35,6 +81,22 @@ from cylc.flow.hostuserutil import get_fqdn_by_host, is_remote_host from cylc.flow.remote import run_cmd, cylc_server_cmd from cylc.flow.terminal import parse_dirty_json +from cylc.flow.util import restricted_evaluator + + +# evaluates ranking expressions +# (see module docstring for examples) +RankingExpressionEvaluator = restricted_evaluator( + ast.Expression, + # variables + ast.Name, ast.Load, ast.Attribute, ast.Subscript, ast.Index, + # opers + ast.BinOp, ast.operator, ast.UnaryOp, ast.unaryop, + # types + ast.Num, ast.Str, + # comparisons + ast.Compare, ast.cmpop, ast.List, ast.Tuple, +) GLBL_CFG_STR = 'global.cylc[scheduler][run hosts]ranking' @@ -301,7 +363,10 @@ def _filter_by_ranking(hosts, rankings, results, data=None): for key, expression in rankings: item = _reformat_expr(key, expression) try: - result = _simple_eval(expression, RESULT=results[host][key]) + result = RankingExpressionEvaluator( + expression, + RESULT=results[host][key], + ) except Exception as exc: raise GlobalConfigError( 'Invalid host ranking expression' @@ -334,84 +399,6 @@ def _filter_by_ranking(hosts, rankings, results, data=None): ) -class SimpleVisitor(ast.NodeVisitor): - """Abstract syntax tree node visitor for simple safe operations.""" - - def visit(self, node): - if not isinstance(node, self.whitelist): - # permit only whitelisted operations - raise ValueError(type(node)) - return super().visit(node) - - whitelist = ( - ast.Expression, - # variables - ast.Name, ast.Load, ast.Attribute, ast.Subscript, ast.Index, - # opers - ast.BinOp, ast.operator, ast.UnaryOp, ast.unaryop, - # types - ast.Num, ast.Str, - # comparisons - ast.Compare, ast.cmpop, ast.List, ast.Tuple, - ) - - -def _simple_eval(expr, **variables): - """Safely evaluates simple python expressions. - - Supports a minimal subset of Python operators: - * Binary operations - * Simple comparisons - - Supports a minimal subset of Python data types: - * Numbers - * Strings - * Tuples - * Lists - - Examples: - >>> _simple_eval('1 + 1') - 2 - >>> _simple_eval('1 * -1') - -1 - >>> _simple_eval('1 < a', a=2) - True - >>> _simple_eval('1 in (1, 2, 3)') - True - >>> import psutil - >>> _simple_eval('a.available > 0', a=psutil.virtual_memory()) - True - - If you try to get it to do something you're not allowed to: - >>> _simple_eval('open("foo")') - Traceback (most recent call last): - ValueError: - >>> _simple_eval('import sys') - Traceback (most recent call last): - SyntaxError: ... - - If you try to get hold of something you aren't supposed to: - >>> answer = 42 # only variables explicitly passed in should work - >>> _simple_eval('answer') - Traceback (most recent call last): - NameError: name 'answer' is not defined - - If you try to do something which doesn't make sense: - >>> _simple_eval('a.b.c') # no value "a.b.c" - Traceback (most recent call last): - NameError: name 'a' is not defined - - """ - node = ast.parse(expr.strip(), mode='eval') - SimpleVisitor().visit(node) - # acceptable use of eval due to restricted language features - return eval( # nosec - compile(node, '', 'eval'), - {'__builtins__': {}}, - variables - ) - - def _get_rankings(string): """Yield parsed ranking expressions. diff --git a/cylc/flow/util.py b/cylc/flow/util.py index 059237f4e16..b167cb33a84 100644 --- a/cylc/flow/util.py +++ b/cylc/flow/util.py @@ -15,12 +15,14 @@ # along with this program. If not, see . """Misc functionality.""" +import ast from contextlib import suppress from functools import partial import json import re from typing import ( Any, + Callable, List, Sequence, ) @@ -133,3 +135,204 @@ def serialise(flow_nums: set): def deserialise(flow_num_str: str): """Converts string to set.""" return set(json.loads(flow_num_str)) + + +def restricted_evaluator( + *whitelist: type, + error_class: Callable = ValueError, +) -> Callable: + """Returns a Python eval statement restricted to whitelisted operations. + + The "eval" function can be used to run arbitrary code. This is useful + but presents security issues. This returns an "eval" method which will + only allow whitelisted operations to be performed allowing it to be used + safely with user-provided input. + + The code passed into the evaluator will be parsed into an abstract syntax + tree (AST), then that tree will be executed using Python's internal logic. + The evaluator will check the type of each node before it is executed and + fail with a ValueError if it is not permitted. + + The node types are documented in the ast module: + https://docs.python.org/3/library/ast.html + + The evaluator returned is only as safe as the nodes you whitelist, read the + docs carefully. + + Note: + If you don't need to parse expressions, use ast.literal_eval instead. + + Args: + whitelist: + Types to permit e.g. `ast.Expression`, see the ast docs for + details. + error_class: + An Exception class or callable which returns an Exception instance. + This is called and its result raised in the event that an + expression contains non-whitelisted operations. It will be provided + with the error message as an argument, additionally the following + keyword arguments will be provided if defined: + expr: + The expression the evaluator was called with. + expr_node: + The AST node containing the parsed expression. + error_node: + The first non-whitelisted AST node in the expression. + E.G. `` for a `-` operator. + error_type: + error_node.__class__.__name__. + E.G. `Sub` for a `-` operator. + + Returns: + An "eval" function restricted to the whitelisted nodes. + + Examples: + Optionally, provide an error class to be raised in the event of + non-whitelisted syntax (or you'll get ValueError): + >>> class RestrictedSyntaxError(Exception): + ... def __init__(self, message, error_node): + ... self.args = (str(error_node.__class__),) + + Create an evaluator, whitelisting allowed node types: + >>> evaluator = restricted_evaluator( + ... ast.Expression, # required for all uses + ... ast.BinOp, # an operation (e.g. addition or division) + ... ast.Add, # the "+" operator + ... ast.Constant, # required for literals e.g. "1" + ... ast.Name, # required for using variables in expressions + ... ast.Load, # required for accessing variable values + ... ast.Num, # for Python 3.7 compatibility + ... error_class=RestrictedSyntaxError, # error to raise + ... ) + + This will correctly evaluate intended expressions: + >>> evaluator('1 + 1') + 2 + + But will fail if a non-whitelisted node type is present: + >>> evaluator('1 - 1') + Traceback (most recent call last): + RestrictedSyntaxError: + >>> evaluator('my_function()') + Traceback (most recent call last): + RestrictedSyntaxError: + >>> evaluator('__import__("os")') + Traceback (most recent call last): + RestrictedSyntaxError: + + The evaluator cannot see the containing scope: + >>> a = b = 1 + >>> evaluator('a + b') + Traceback (most recent call last): + NameError: name 'a' is not defined + + To use variables you must explicitly pass them in: + >>> evaluator('a + b', a=1, b=2) + 3 + + """ + # the node visitor is called for each node in the AST, + # this is the bit which rejects types which are not whitelisted + visitor = RestrictedNodeVisitor(whitelist) + + def _eval(expr, **variables): + nonlocal visitor + + # parse the expression + try: + expr_node = ast.parse(expr.strip(), mode='eval') + except SyntaxError as exc: + raise _get_exception( + error_class, + f'{exc.msg}: {exc.text}', + {'expr': expr} + ) + + # check against whitelisted types + try: + visitor.visit(expr_node) + except _RestrictedEvalError as exc: + # non-whitelisted node detected in expression + # => raise exception + error_node = exc.args[0] + raise _get_exception( + error_class, + ( + f'Invalid expression: {expr}' + f'\n"{error_node.__class__.__name__}" not permitted' + ), + { + 'expr': expr, + 'expr_node': expr_node, + 'error_node': error_node, + 'error_type': error_node.__class__.__name__, + }, + ) + + # run the expresion + # Note: this may raise runtime errors + return eval( # nosec + # acceptable use of eval as only whitelisted operations are + # permitted + compile(expr_node, '', 'eval'), + # deny access to builtins + {'__builtins__': {}}, + # provide access to explicitly provided variables + variables, + ) + + return _eval + + +class RestrictedNodeVisitor(ast.NodeVisitor): + """AST node visitor which errors on non-whitelisted syntax. + + Raises _RestrictedEvalError if a non-whitelisted node is visited. + """ + + def __init__(self, whitelist): + super().__init__() + self._whitelist: Tuple[type] = whitelist + + def visit(self, node): + if not isinstance(node, self._whitelist): + # only permit whitelisted operations + raise _RestrictedEvalError(node) + return super().visit(node) + + +class _RestrictedEvalError(Exception): + """For internal use. + + Raised in the event non-whitelisted syntax is detected in an expression. + """ + + def __init__(self, node): + self.node = node + + +def _get_exception( + error_class: Callable, + message: str, + context: dict +) -> Exception: + """Helper which returns exception instances. + + Filters the arguments in context by the parameters of the error_class. + + This allows the error_class to decide what fields it wants, and for us + to add/change these params in the future. + """ + import inspect # no need to import unless errors occur + try: + params = dict(inspect.signature(error_class).parameters) + except ValueError: + params = {} + + context = { + key: value + for key, value in context.items() + if key in params + } + + return error_class(message, **context) From 12d21d5bb56de77aa1adca95914bf781ee84e2e5 Mon Sep 17 00:00:00 2001 From: Oliver Sanders Date: Thu, 7 Mar 2024 16:13:27 +0000 Subject: [PATCH 010/196] unicode: blacklist names that conflict with internal workings --- cylc/flow/unicode_rules.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cylc/flow/unicode_rules.py b/cylc/flow/unicode_rules.py index 4608559798d..a6974888248 100644 --- a/cylc/flow/unicode_rules.py +++ b/cylc/flow/unicode_rules.py @@ -346,11 +346,11 @@ class TaskOutputValidator(UnicodeRuleChecker): RULES = [ # restrict outputs to sensible characters - allowed_characters(r'\w', r'\d', r'\-', r'\.'), + allowed_characters(r'\w', r'\d', r'\-'), # blacklist the _cylc prefix not_starts_with('_cylc'), # blacklist keywords - not_equals('required', 'optional', 'all'), + not_equals('required', 'optional', 'all', 'and', 'or'), # blacklist built-in task qualifiers and statuses (e.g. "waiting") not_equals(*sorted({*TASK_QUALIFIERS, *TASK_STATUSES_ORDERED})), ] From e09c31f05b1404f4f001b1c94b110cfbc0ddfb17 Mon Sep 17 00:00:00 2001 From: Oliver Sanders Date: Tue, 5 Mar 2024 17:02:17 +0000 Subject: [PATCH 011/196] optional outputs extension * Implement optional output extension proposal. * Closes https://github.com/cylc/cylc-flow/issues/5640 * Add a user-configurable completion expression. * Automatically define a default when not specified. * Change completion rules for the expire output. * Expose the completion expression to the data store * Add the completion expression to the protobuf and GraphQL schemas. * Make the completion expression visible to the data store. * Display the completion status in "cylc show". Co-authored-by: Hilary James Oliver --- changes.d/6046.break.md | 4 + changes.d/6046.feat.md | 4 + cylc/flow/cfgspec/workflow.py | 136 ++++ cylc/flow/config.py | 212 +++++- cylc/flow/data_messages.proto | 1 + cylc/flow/data_messages_pb2.py | 101 +-- cylc/flow/data_store_mgr.py | 20 +- cylc/flow/exceptions.py | 13 + cylc/flow/graph_parser.py | 11 +- cylc/flow/loggingutil.py | 16 +- cylc/flow/network/schema.py | 28 +- cylc/flow/scripts/show.py | 43 +- cylc/flow/task_events_mgr.py | 21 +- cylc/flow/task_outputs.py | 663 ++++++++++++------ cylc/flow/task_pool.py | 62 +- cylc/flow/task_proxy.py | 38 +- cylc/flow/taskdef.py | 16 +- cylc/flow/util.py | 64 +- cylc/flow/workflow_db_mgr.py | 7 +- tests/flakyfunctional/cylc-show/00-simple.t | 24 +- tests/flakyfunctional/cylc-show/04-multi.t | 60 +- .../job-submission/19-chatty.t | 24 +- .../job-submission/19-chatty/flow.cylc | 2 +- .../job-submission/19-chatty/reference.log | 22 + tests/functional/cylc-cat-log/00-local.t | 4 +- .../cylc-cat-log/00-local/flow.cylc | 2 +- .../cylc-cat-log/00-local/reference.log | 2 + .../cylc-config/00-simple/section2.stdout | 19 +- tests/functional/cylc-diff/00-basic.t | 4 + tests/functional/cylc-diff/03-icp.t | 4 + tests/functional/cylc-diff/04-icp-2.t | 4 + tests/functional/cylc-set/03-set-failed.t | 2 +- tests/functional/cylc-show/05-complex.t | 64 +- .../cylc-show/06-past-present-future.t | 8 +- .../events/26-workflow-stalled-dump-prereq.t | 5 +- .../27-workflow-stalled-dump-prereq-fam.t | 5 +- .../multiline_and_refs/c-ref | 8 +- .../multiline_and_refs/c-ref-2 | 8 +- .../graph-equivalence/splitline_refs/a-ref | 2 +- .../graph-equivalence/splitline_refs/b-ref | 6 +- .../graph-equivalence/splitline_refs/c-ref | 6 +- .../inheritance/00-namespace-list.t | 3 + .../06-from-platform-group-fails.t | 2 +- .../job-submission/06-garbage/flow.cylc | 2 +- .../09-activity-log-host-bad-submit/flow.cylc | 2 +- tests/functional/job-submission/16-timeout.t | 1 + .../job-submission/16-timeout/flow.cylc | 2 +- .../job-submission/19-platform_select.t | 3 +- .../19-platform_select/flow.cylc | 8 +- .../19-platform_select/reference.log | 8 + tests/functional/modes/05-sim-trigger.t | 2 +- .../n-window/01-past-present-future.t | 12 +- tests/functional/n-window/02-big-window.t | 8 +- .../optional-outputs/01-stall-on-incomplete.t | 2 +- .../07-finish-fail-c7-backcompat.t | 4 +- tests/functional/param_expand/01-basic.t | 2 + tests/functional/remote/05-remote-init.t | 5 +- .../restart/submit-failed/flow.cylc | 11 +- tests/functional/retries/submission/flow.cylc | 2 +- .../functional/spawn-on-demand/18-submitted.t | 2 +- .../spawn-on-demand/18-submitted/flow.cylc | 3 + .../spawn-on-demand/19-submitted-compat.t | 3 +- .../triggering/14-submit-fail/flow.cylc | 2 +- .../functional/triggering/16-fam-expansion.t | 14 +- tests/functional/xtriggers/03-sequence.t | 4 +- tests/integration/conftest.py | 22 + tests/integration/test_optional_outputs.py | 380 ++++++++++ tests/integration/test_simulation.py | 14 +- tests/integration/test_task_pool.py | 41 +- .../tui/screenshots/test_show.success.html | 18 +- tests/integration/validate/test_outputs.py | 120 +++- tests/unit/scripts/test_config.py | 2 + tests/unit/test_config.py | 22 - tests/unit/test_graph_parser.py | 21 +- tests/unit/test_subprocpool.py | 16 +- tests/unit/test_task_outputs.py | 308 +++++++- tests/unit/test_xtrigger_mgr.py | 6 +- 77 files changed, 2173 insertions(+), 649 deletions(-) create mode 100644 changes.d/6046.break.md create mode 100644 changes.d/6046.feat.md create mode 100644 tests/flakyfunctional/job-submission/19-chatty/reference.log create mode 100644 tests/functional/cylc-cat-log/00-local/reference.log create mode 100644 tests/functional/job-submission/19-platform_select/reference.log create mode 100644 tests/integration/test_optional_outputs.py diff --git a/changes.d/6046.break.md b/changes.d/6046.break.md new file mode 100644 index 00000000000..06503d9c987 --- /dev/null +++ b/changes.d/6046.break.md @@ -0,0 +1,4 @@ +The `submit-fail` and `expire` task outputs must now be +[optional](https://cylc.github.io/cylc-doc/stable/html/glossary.html#term-optional-output) +and can no longer be +[required](https://cylc.github.io/cylc-doc/stable/html/glossary.html#term-required-output). diff --git a/changes.d/6046.feat.md b/changes.d/6046.feat.md new file mode 100644 index 00000000000..fb731b872dd --- /dev/null +++ b/changes.d/6046.feat.md @@ -0,0 +1,4 @@ +The condition that Cylc uses to evaluate task output completion can now be +customized in the `[runtime]` section with the new `completion` configuration. +This provides a more advanced way to check that tasks generate their required +outputs when run. diff --git a/cylc/flow/cfgspec/workflow.py b/cylc/flow/cfgspec/workflow.py index d22f0f415bb..1e0a59a02ea 100644 --- a/cylc/flow/cfgspec/workflow.py +++ b/cylc/flow/cfgspec/workflow.py @@ -996,6 +996,142 @@ def get_script_common_text(this: str, example: Optional[str] = None): can be explicitly configured to provide or override default settings for all tasks in the workflow. '''): + Conf('completion', VDR.V_STRING, desc=''' + Define the condition for task output completion. + + The completion condition is evaluated when a task reaches + a final state - i.e. once it finished executing (``succeeded`` + or ``failed``) or it ``submit-failed``, or ``expired``. + It is a validation check which confirms that the + task has generated the outputs it was expected to. + + If the task fails this check its outputs are considered + :term:`incomplete` and a warning will be raised alerting you + that something has gone wrong which requires investigation. + + .. note:: + + An event hook for this warning will follow in a future + release of Cylc. + + By default, the completion condition ensures that all required + outputs, i.e. outputs which appear in the graph but are not + marked as optional with the ``?`` character, are completed. + + E.g., in this example, the task ``foo`` must generate the + required outputs ``succeeded`` and ``x`` and it may or may not + generate the optional output ``y``: + + .. code-block:: cylc-graph + + foo => bar + foo:x => x + foo:y? => y + + The default completion condition would be this: + + .. code-block:: python + + # the task must succeed and generate the custom output "x" + succeeded and x + + You can override this default to suit your needs. E.g., in this + example, the task ``foo`` has three optional outputs, ``x``, + ``y`` and ``z``: + + .. code-block:: cylc-graph + + foo:x? => x + foo:y? => y + foo:z? => z + x | y | z => bar + + Because all three of these outputs are optional, if none of + them are generated, the task's outputs will still be + considered complete. + + If you wanted to require that at least one of these outputs is + generated you can configure the completion condition like so: + + .. code-block:: python + + # the task must succeed and generate at least one of the + # outputs "x" or "y" or "z": + succeeded and (x or y or z) + + .. note:: + + For the completion expression, hyphens in task outputs + must be replaced with underscores to allow evaluation by + Python, e.g.: + + .. code-block:: cylc + + [runtime] + [[foo]] + completion = succeeded and my_output # underscore + [[[outputs]]] + my-output = 'my custom task output' # hyphen + + .. note:: + + In some cases the ``succeeded`` output might not explicitly + appear in the graph, e.g: + + .. code-block:: cylc-graph + + foo:x? => x + + In these cases succeess is presumed to be required unless + explicitly stated otherwise, either in the graph: + + .. code-block:: cylc-graph + + foo? + foo:x? => x + + Or in the completion expression: + + .. code-block:: cylc + + completion = x # no reference to succeeded here + + + .. hint:: + + If task outputs are optional in the graph they must also + be optional in the completion condition and vice versa. + + .. code-block:: cylc + + [scheduling] + [[graph]] + R1 = """ + # ERROR: this should be "a? => b" + a => b + """ + [runtime] + [[a]] + # this completion condition implies that the + # succeeded output is optional + completion = succeeded or failed + + .. rubric:: Examples + + ``succeeded`` + The task must succeed. + ``succeeded or (failed and my_error)`` + The task can fail, but only if it also yields the custom + output ``my_error``. + ``succeeded and (x or y or z)`` + The task must succeed and yield at least one of the + custom outputs, x, y or z. + ``(a and b) or (c and d)`` + One pair of these outputs must be yielded for the task + to be complete. + + .. versionadded:: 8.3.0 + ''') Conf('platform', VDR.V_STRING, desc=''' The name of a compute resource defined in :cylc:conf:`global.cylc[platforms]` or diff --git a/cylc/flow/config.py b/cylc/flow/config.py index e53dadcf178..f5ce67488e6 100644 --- a/cylc/flow/config.py +++ b/cylc/flow/config.py @@ -89,8 +89,13 @@ ) from cylc.flow.task_id import TaskID from cylc.flow.task_outputs import ( + TASK_OUTPUT_FAILED, + TASK_OUTPUT_FINISHED, TASK_OUTPUT_SUCCEEDED, - TaskOutputs + TaskOutputs, + get_completion_expression, + get_optional_outputs, + get_trigger_completion_variable_maps, ) from cylc.flow.task_trigger import TaskTrigger, Dependency from cylc.flow.taskdef import TaskDef @@ -520,6 +525,8 @@ def __init__( self.load_graph() self.mem_log("config.py: after load_graph()") + self._set_completion_expressions() + self.process_runahead_limit() run_mode = self.run_mode() @@ -1008,6 +1015,209 @@ def _check_sequence_bounds(self): ) LOG.warning(msg) + def _set_completion_expressions(self): + """Sets and checks completion expressions for each task. + + If a task does not have a user-defined completion expression, then set + one according to the default rules. + + If a task does have a used-defined completion expression, then ensure + it is consistent with the use of outputs in the graph. + """ + for name, taskdef in self.taskdefs.items(): + expr = taskdef.rtconfig['completion'] + if expr: + # check the user-defined expression + self._check_completion_expression(name, expr) + else: + # derive a completion expression for this taskdef + expr = get_completion_expression(taskdef) + + if name not in self.taskdefs: + # this is a family -> nothing more to do here + continue + + # update both the sparse and dense configs to make these values + # visible to "cylc config" to make the completion expression more + # transparent to users. + # NOTE: we have to update both because we are setting this value + # late on in the process after the dense copy has been made + self.pcfg.sparse.setdefault( + 'runtime', {} + ).setdefault( + name, {} + )['completion'] = expr + self.pcfg.dense['runtime'][name]['completion'] = expr + + # update the task's runtime config to make this value visible to + # the data store + # NOTE: we have to do this because we are setting this value late + # on after the TaskDef has been created + taskdef.rtconfig['completion'] = expr + + def _check_completion_expression(self, task_name: str, expr: str) -> None: + """Checks a user-defined completion expression. + + Args: + task_name: + The name of the task we are checking. + expr: + The completion expression as defined in the config. + + """ + # check completion expressions are not being used in compat mode + if cylc.flow.flags.cylc7_back_compat: + raise WorkflowConfigError( + '[runtime][]completion cannot be used' + ' in Cylc 7 compatibility mode.' + ) + + # check for invalid triggers in the expression + if 'submit-failed' in expr: + raise WorkflowConfigError( + f'Error in [runtime][{task_name}]completion:' + f'\nUse "submit_failed" rather than "submit-failed"' + ' in completion expressions.' + ) + elif '-' in expr: + raise WorkflowConfigError( + f'Error in [runtime][{task_name}]completion:' + f'\n {expr}' + '\nReplace hyphens with underscores in task outputs when' + ' used in completion expressions.' + ) + + # get the outputs and completion expression for this task + try: + outputs = self.taskdefs[task_name].outputs + except KeyError: + # this is a family -> we'll check integrity for each task that + # inherits from it + return + + ( + trigger_to_completion_variable, + completion_variable_to_trigger, + ) = get_trigger_completion_variable_maps(outputs.keys()) + + # get the optional/required outputs defined in the graph + graph_optionals = { + # completion_variable: is_optional + trigger_to_completion_variable[trigger]: ( + None if is_required is None else not is_required + ) + for trigger, (_, is_required) + in outputs.items() + } + if ( + graph_optionals[TASK_OUTPUT_SUCCEEDED] is True + and graph_optionals[TASK_OUTPUT_FAILED] is None + ): + # failed is implicitly optional if succeeded is optional + # https://github.com/cylc/cylc-flow/pull/6046#issuecomment-2059266086 + graph_optionals[TASK_OUTPUT_FAILED] = True + + # get the optional/required outputs defined in the expression + try: + # this involves running the expression which also validates it + expression_optionals = get_optional_outputs(expr, outputs) + except NameError as exc: + # expression references an output which has not been registered + error = exc.args[0][5:] + + if f"'{TASK_OUTPUT_FINISHED}'" in error: + # the finished output cannot be used in completion expressions + # see proposal point 5:: + # https://cylc.github.io/cylc-admin/proposal-optional-output-extension.html#proposal + raise WorkflowConfigError( + f'Error in [runtime][{task_name}]completion:' + f'\n {expr}' + '\nThe "finished" output cannot be used in completion' + ' expressions, use "succeeded or failed".' + ) + + raise WorkflowConfigError( + # NOTE: str(exc) == "name 'x' is not defined" tested in + # tests/integration/test_optional_outputs.py + f'Error in [runtime][{task_name}]completion:' + f'\nInput {error}' + ) + except Exception as exc: # includes InvalidCompletionExpression + # expression contains non-whitelisted syntax or any other error in + # the expression e.g. SyntaxError + raise WorkflowConfigError( + f'Error in [runtime][{task_name}]completion:' + f'\n{str(exc)}' + ) + + # ensure consistency between the graph and the completion expression + for compvar in ( + { + *graph_optionals, + *expression_optionals + } + ): + # is the output optional in the graph? + graph_opt = graph_optionals.get(compvar) + # is the output optional in the completion expression? + expr_opt = expression_optionals.get(compvar) + + # True = is optional + # False = is required + # None = is not referenced + + # graph_opt expr_opt + # True True ok + # True False not ok + # True None not ok [1] + # False True not ok [1] + # False False ok + # False None not ok + # None True ok + # None False ok + # None None ok + + # [1] applies only to "submit-failed" and "expired" + + trigger = completion_variable_to_trigger[compvar] + + if graph_opt is True and expr_opt is False: + raise WorkflowConfigError( + f'{task_name}:{trigger} is optional in the graph' + ' (? symbol), but required in the completion' + f' expression:\n{expr}' + ) + + if graph_opt is False and expr_opt is None: + raise WorkflowConfigError( + f'{task_name}:{trigger} is required in the graph,' + ' but not referenced in the completion' + f' expression\n{expr}' + ) + + if ( + graph_opt is True + and expr_opt is None + and compvar in {'submit_failed', 'expired'} + ): + raise WorkflowConfigError( + f'{task_name}:{trigger} is permitted in the graph' + ' but is not referenced in the completion' + ' expression (so is not permitted by it).' + f'\nTry: completion = "{expr} or {compvar}"' + ) + + if ( + graph_opt is False + and expr_opt is True + and compvar not in {'submit_failed', 'expired'} + ): + raise WorkflowConfigError( + f'{task_name}:{trigger} is required in the graph,' + ' but optional in the completion expression' + f'\n{expr}' + ) + def _expand_name_list(self, orig_names): """Expand any parameters in lists of names.""" name_expander = NameExpander(self.parameters) diff --git a/cylc/flow/data_messages.proto b/cylc/flow/data_messages.proto index 6068bb1c5df..bc0355e6d4c 100644 --- a/cylc/flow/data_messages.proto +++ b/cylc/flow/data_messages.proto @@ -127,6 +127,7 @@ message PbRuntime { optional string directives = 15; optional string environment = 16; optional string outputs = 17; + optional string completion = 18; } diff --git a/cylc/flow/data_messages_pb2.py b/cylc/flow/data_messages_pb2.py index 82c620bcacf..5ecb96fc122 100644 --- a/cylc/flow/data_messages_pb2.py +++ b/cylc/flow/data_messages_pb2.py @@ -2,6 +2,7 @@ # -*- coding: utf-8 -*- # Generated by the protocol buffer compiler. DO NOT EDIT! # source: data_messages.proto +# Protobuf Python Version: 4.25.3 """Generated protocol buffer code.""" from google.protobuf import descriptor as _descriptor from google.protobuf import descriptor_pool as _descriptor_pool @@ -14,7 +15,7 @@ -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x13\x64\x61ta_messages.proto\"\x96\x01\n\x06PbMeta\x12\x12\n\x05title\x18\x01 \x01(\tH\x00\x88\x01\x01\x12\x18\n\x0b\x64\x65scription\x18\x02 \x01(\tH\x01\x88\x01\x01\x12\x10\n\x03URL\x18\x03 \x01(\tH\x02\x88\x01\x01\x12\x19\n\x0cuser_defined\x18\x04 \x01(\tH\x03\x88\x01\x01\x42\x08\n\x06_titleB\x0e\n\x0c_descriptionB\x06\n\x04_URLB\x0f\n\r_user_defined\"\xaa\x01\n\nPbTimeZone\x12\x12\n\x05hours\x18\x01 \x01(\x05H\x00\x88\x01\x01\x12\x14\n\x07minutes\x18\x02 \x01(\x05H\x01\x88\x01\x01\x12\x19\n\x0cstring_basic\x18\x03 \x01(\tH\x02\x88\x01\x01\x12\x1c\n\x0fstring_extended\x18\x04 \x01(\tH\x03\x88\x01\x01\x42\x08\n\x06_hoursB\n\n\x08_minutesB\x0f\n\r_string_basicB\x12\n\x10_string_extended\"\'\n\x0fPbTaskProxyRefs\x12\x14\n\x0ctask_proxies\x18\x01 \x03(\t\"\xd4\x0c\n\nPbWorkflow\x12\x12\n\x05stamp\x18\x01 \x01(\tH\x00\x88\x01\x01\x12\x0f\n\x02id\x18\x02 \x01(\tH\x01\x88\x01\x01\x12\x11\n\x04name\x18\x03 \x01(\tH\x02\x88\x01\x01\x12\x13\n\x06status\x18\x04 \x01(\tH\x03\x88\x01\x01\x12\x11\n\x04host\x18\x05 \x01(\tH\x04\x88\x01\x01\x12\x11\n\x04port\x18\x06 \x01(\x05H\x05\x88\x01\x01\x12\x12\n\x05owner\x18\x07 \x01(\tH\x06\x88\x01\x01\x12\r\n\x05tasks\x18\x08 \x03(\t\x12\x10\n\x08\x66\x61milies\x18\t \x03(\t\x12\x1c\n\x05\x65\x64ges\x18\n \x01(\x0b\x32\x08.PbEdgesH\x07\x88\x01\x01\x12\x18\n\x0b\x61pi_version\x18\x0b \x01(\x05H\x08\x88\x01\x01\x12\x19\n\x0c\x63ylc_version\x18\x0c \x01(\tH\t\x88\x01\x01\x12\x19\n\x0clast_updated\x18\r \x01(\x01H\n\x88\x01\x01\x12\x1a\n\x04meta\x18\x0e \x01(\x0b\x32\x07.PbMetaH\x0b\x88\x01\x01\x12&\n\x19newest_active_cycle_point\x18\x10 \x01(\tH\x0c\x88\x01\x01\x12&\n\x19oldest_active_cycle_point\x18\x11 \x01(\tH\r\x88\x01\x01\x12\x15\n\x08reloaded\x18\x12 \x01(\x08H\x0e\x88\x01\x01\x12\x15\n\x08run_mode\x18\x13 \x01(\tH\x0f\x88\x01\x01\x12\x19\n\x0c\x63ycling_mode\x18\x14 \x01(\tH\x10\x88\x01\x01\x12\x32\n\x0cstate_totals\x18\x15 \x03(\x0b\x32\x1c.PbWorkflow.StateTotalsEntry\x12\x1d\n\x10workflow_log_dir\x18\x16 \x01(\tH\x11\x88\x01\x01\x12(\n\x0etime_zone_info\x18\x17 \x01(\x0b\x32\x0b.PbTimeZoneH\x12\x88\x01\x01\x12\x17\n\ntree_depth\x18\x18 \x01(\x05H\x13\x88\x01\x01\x12\x15\n\rjob_log_names\x18\x19 \x03(\t\x12\x14\n\x0cns_def_order\x18\x1a \x03(\t\x12\x0e\n\x06states\x18\x1b \x03(\t\x12\x14\n\x0ctask_proxies\x18\x1c \x03(\t\x12\x16\n\x0e\x66\x61mily_proxies\x18\x1d \x03(\t\x12\x17\n\nstatus_msg\x18\x1e \x01(\tH\x14\x88\x01\x01\x12\x1a\n\ris_held_total\x18\x1f \x01(\x05H\x15\x88\x01\x01\x12\x0c\n\x04jobs\x18 \x03(\t\x12\x15\n\x08pub_port\x18! \x01(\x05H\x16\x88\x01\x01\x12\x17\n\nbroadcasts\x18\" \x01(\tH\x17\x88\x01\x01\x12\x1c\n\x0fis_queued_total\x18# \x01(\x05H\x18\x88\x01\x01\x12=\n\x12latest_state_tasks\x18$ \x03(\x0b\x32!.PbWorkflow.LatestStateTasksEntry\x12\x13\n\x06pruned\x18% \x01(\x08H\x19\x88\x01\x01\x12\x1e\n\x11is_runahead_total\x18& \x01(\x05H\x1a\x88\x01\x01\x12\x1b\n\x0estates_updated\x18\' \x01(\x08H\x1b\x88\x01\x01\x12\x1c\n\x0fn_edge_distance\x18( \x01(\x05H\x1c\x88\x01\x01\x1a\x32\n\x10StateTotalsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\x05:\x02\x38\x01\x1aI\n\x15LatestStateTasksEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\x1f\n\x05value\x18\x02 \x01(\x0b\x32\x10.PbTaskProxyRefs:\x02\x38\x01\x42\x08\n\x06_stampB\x05\n\x03_idB\x07\n\x05_nameB\t\n\x07_statusB\x07\n\x05_hostB\x07\n\x05_portB\x08\n\x06_ownerB\x08\n\x06_edgesB\x0e\n\x0c_api_versionB\x0f\n\r_cylc_versionB\x0f\n\r_last_updatedB\x07\n\x05_metaB\x1c\n\x1a_newest_active_cycle_pointB\x1c\n\x1a_oldest_active_cycle_pointB\x0b\n\t_reloadedB\x0b\n\t_run_modeB\x0f\n\r_cycling_modeB\x13\n\x11_workflow_log_dirB\x11\n\x0f_time_zone_infoB\r\n\x0b_tree_depthB\r\n\x0b_status_msgB\x10\n\x0e_is_held_totalB\x0b\n\t_pub_portB\r\n\x0b_broadcastsB\x12\n\x10_is_queued_totalB\t\n\x07_prunedB\x14\n\x12_is_runahead_totalB\x11\n\x0f_states_updatedB\x12\n\x10_n_edge_distance\"\xb9\x06\n\tPbRuntime\x12\x15\n\x08platform\x18\x01 \x01(\tH\x00\x88\x01\x01\x12\x13\n\x06script\x18\x02 \x01(\tH\x01\x88\x01\x01\x12\x18\n\x0binit_script\x18\x03 \x01(\tH\x02\x88\x01\x01\x12\x17\n\nenv_script\x18\x04 \x01(\tH\x03\x88\x01\x01\x12\x17\n\nerr_script\x18\x05 \x01(\tH\x04\x88\x01\x01\x12\x18\n\x0b\x65xit_script\x18\x06 \x01(\tH\x05\x88\x01\x01\x12\x17\n\npre_script\x18\x07 \x01(\tH\x06\x88\x01\x01\x12\x18\n\x0bpost_script\x18\x08 \x01(\tH\x07\x88\x01\x01\x12\x19\n\x0cwork_sub_dir\x18\t \x01(\tH\x08\x88\x01\x01\x12(\n\x1b\x65xecution_polling_intervals\x18\n \x01(\tH\t\x88\x01\x01\x12#\n\x16\x65xecution_retry_delays\x18\x0b \x01(\tH\n\x88\x01\x01\x12!\n\x14\x65xecution_time_limit\x18\x0c \x01(\tH\x0b\x88\x01\x01\x12)\n\x1csubmission_polling_intervals\x18\r \x01(\tH\x0c\x88\x01\x01\x12$\n\x17submission_retry_delays\x18\x0e \x01(\tH\r\x88\x01\x01\x12\x17\n\ndirectives\x18\x0f \x01(\tH\x0e\x88\x01\x01\x12\x18\n\x0b\x65nvironment\x18\x10 \x01(\tH\x0f\x88\x01\x01\x12\x14\n\x07outputs\x18\x11 \x01(\tH\x10\x88\x01\x01\x42\x0b\n\t_platformB\t\n\x07_scriptB\x0e\n\x0c_init_scriptB\r\n\x0b_env_scriptB\r\n\x0b_err_scriptB\x0e\n\x0c_exit_scriptB\r\n\x0b_pre_scriptB\x0e\n\x0c_post_scriptB\x0f\n\r_work_sub_dirB\x1e\n\x1c_execution_polling_intervalsB\x19\n\x17_execution_retry_delaysB\x17\n\x15_execution_time_limitB\x1f\n\x1d_submission_polling_intervalsB\x1a\n\x18_submission_retry_delaysB\r\n\x0b_directivesB\x0e\n\x0c_environmentB\n\n\x08_outputs\"\x9d\x05\n\x05PbJob\x12\x12\n\x05stamp\x18\x01 \x01(\tH\x00\x88\x01\x01\x12\x0f\n\x02id\x18\x02 \x01(\tH\x01\x88\x01\x01\x12\x17\n\nsubmit_num\x18\x03 \x01(\x05H\x02\x88\x01\x01\x12\x12\n\x05state\x18\x04 \x01(\tH\x03\x88\x01\x01\x12\x17\n\ntask_proxy\x18\x05 \x01(\tH\x04\x88\x01\x01\x12\x1b\n\x0esubmitted_time\x18\x06 \x01(\tH\x05\x88\x01\x01\x12\x19\n\x0cstarted_time\x18\x07 \x01(\tH\x06\x88\x01\x01\x12\x1a\n\rfinished_time\x18\x08 \x01(\tH\x07\x88\x01\x01\x12\x13\n\x06job_id\x18\t \x01(\tH\x08\x88\x01\x01\x12\x1c\n\x0fjob_runner_name\x18\n \x01(\tH\t\x88\x01\x01\x12!\n\x14\x65xecution_time_limit\x18\x0e \x01(\x02H\n\x88\x01\x01\x12\x15\n\x08platform\x18\x0f \x01(\tH\x0b\x88\x01\x01\x12\x18\n\x0bjob_log_dir\x18\x11 \x01(\tH\x0c\x88\x01\x01\x12\x11\n\x04name\x18\x1e \x01(\tH\r\x88\x01\x01\x12\x18\n\x0b\x63ycle_point\x18\x1f \x01(\tH\x0e\x88\x01\x01\x12\x10\n\x08messages\x18 \x03(\t\x12 \n\x07runtime\x18! \x01(\x0b\x32\n.PbRuntimeH\x0f\x88\x01\x01\x42\x08\n\x06_stampB\x05\n\x03_idB\r\n\x0b_submit_numB\x08\n\x06_stateB\r\n\x0b_task_proxyB\x11\n\x0f_submitted_timeB\x0f\n\r_started_timeB\x10\n\x0e_finished_timeB\t\n\x07_job_idB\x12\n\x10_job_runner_nameB\x17\n\x15_execution_time_limitB\x0b\n\t_platformB\x0e\n\x0c_job_log_dirB\x07\n\x05_nameB\x0e\n\x0c_cycle_pointB\n\n\x08_runtimeJ\x04\x08\x1d\x10\x1e\"\xe2\x02\n\x06PbTask\x12\x12\n\x05stamp\x18\x01 \x01(\tH\x00\x88\x01\x01\x12\x0f\n\x02id\x18\x02 \x01(\tH\x01\x88\x01\x01\x12\x11\n\x04name\x18\x03 \x01(\tH\x02\x88\x01\x01\x12\x1a\n\x04meta\x18\x04 \x01(\x0b\x32\x07.PbMetaH\x03\x88\x01\x01\x12\x1e\n\x11mean_elapsed_time\x18\x05 \x01(\x02H\x04\x88\x01\x01\x12\x12\n\x05\x64\x65pth\x18\x06 \x01(\x05H\x05\x88\x01\x01\x12\x0f\n\x07proxies\x18\x07 \x03(\t\x12\x11\n\tnamespace\x18\x08 \x03(\t\x12\x0f\n\x07parents\x18\t \x03(\t\x12\x19\n\x0c\x66irst_parent\x18\n \x01(\tH\x06\x88\x01\x01\x12 \n\x07runtime\x18\x0b \x01(\x0b\x32\n.PbRuntimeH\x07\x88\x01\x01\x42\x08\n\x06_stampB\x05\n\x03_idB\x07\n\x05_nameB\x07\n\x05_metaB\x14\n\x12_mean_elapsed_timeB\x08\n\x06_depthB\x0f\n\r_first_parentB\n\n\x08_runtime\"\xd8\x01\n\nPbPollTask\x12\x18\n\x0blocal_proxy\x18\x01 \x01(\tH\x00\x88\x01\x01\x12\x15\n\x08workflow\x18\x02 \x01(\tH\x01\x88\x01\x01\x12\x19\n\x0cremote_proxy\x18\x03 \x01(\tH\x02\x88\x01\x01\x12\x16\n\treq_state\x18\x04 \x01(\tH\x03\x88\x01\x01\x12\x19\n\x0cgraph_string\x18\x05 \x01(\tH\x04\x88\x01\x01\x42\x0e\n\x0c_local_proxyB\x0b\n\t_workflowB\x0f\n\r_remote_proxyB\x0c\n\n_req_stateB\x0f\n\r_graph_string\"\xcb\x01\n\x0bPbCondition\x12\x17\n\ntask_proxy\x18\x01 \x01(\tH\x00\x88\x01\x01\x12\x17\n\nexpr_alias\x18\x02 \x01(\tH\x01\x88\x01\x01\x12\x16\n\treq_state\x18\x03 \x01(\tH\x02\x88\x01\x01\x12\x16\n\tsatisfied\x18\x04 \x01(\x08H\x03\x88\x01\x01\x12\x14\n\x07message\x18\x05 \x01(\tH\x04\x88\x01\x01\x42\r\n\x0b_task_proxyB\r\n\x0b_expr_aliasB\x0c\n\n_req_stateB\x0c\n\n_satisfiedB\n\n\x08_message\"\x96\x01\n\x0ePbPrerequisite\x12\x17\n\nexpression\x18\x01 \x01(\tH\x00\x88\x01\x01\x12 \n\nconditions\x18\x02 \x03(\x0b\x32\x0c.PbCondition\x12\x14\n\x0c\x63ycle_points\x18\x03 \x03(\t\x12\x16\n\tsatisfied\x18\x04 \x01(\x08H\x01\x88\x01\x01\x42\r\n\x0b_expressionB\x0c\n\n_satisfied\"\x8c\x01\n\x08PbOutput\x12\x12\n\x05label\x18\x01 \x01(\tH\x00\x88\x01\x01\x12\x14\n\x07message\x18\x02 \x01(\tH\x01\x88\x01\x01\x12\x16\n\tsatisfied\x18\x03 \x01(\x08H\x02\x88\x01\x01\x12\x11\n\x04time\x18\x04 \x01(\x01H\x03\x88\x01\x01\x42\x08\n\x06_labelB\n\n\x08_messageB\x0c\n\n_satisfiedB\x07\n\x05_time\"\xa5\x01\n\tPbTrigger\x12\x0f\n\x02id\x18\x01 \x01(\tH\x00\x88\x01\x01\x12\x12\n\x05label\x18\x02 \x01(\tH\x01\x88\x01\x01\x12\x14\n\x07message\x18\x03 \x01(\tH\x02\x88\x01\x01\x12\x16\n\tsatisfied\x18\x04 \x01(\x08H\x03\x88\x01\x01\x12\x11\n\x04time\x18\x05 \x01(\x01H\x04\x88\x01\x01\x42\x05\n\x03_idB\x08\n\x06_labelB\n\n\x08_messageB\x0c\n\n_satisfiedB\x07\n\x05_time\"\x91\x08\n\x0bPbTaskProxy\x12\x12\n\x05stamp\x18\x01 \x01(\tH\x00\x88\x01\x01\x12\x0f\n\x02id\x18\x02 \x01(\tH\x01\x88\x01\x01\x12\x11\n\x04task\x18\x03 \x01(\tH\x02\x88\x01\x01\x12\x12\n\x05state\x18\x04 \x01(\tH\x03\x88\x01\x01\x12\x18\n\x0b\x63ycle_point\x18\x05 \x01(\tH\x04\x88\x01\x01\x12\x12\n\x05\x64\x65pth\x18\x06 \x01(\x05H\x05\x88\x01\x01\x12\x18\n\x0bjob_submits\x18\x07 \x01(\x05H\x06\x88\x01\x01\x12*\n\x07outputs\x18\t \x03(\x0b\x32\x19.PbTaskProxy.OutputsEntry\x12\x11\n\tnamespace\x18\x0b \x03(\t\x12&\n\rprerequisites\x18\x0c \x03(\x0b\x32\x0f.PbPrerequisite\x12\x0c\n\x04jobs\x18\r \x03(\t\x12\x19\n\x0c\x66irst_parent\x18\x0f \x01(\tH\x07\x88\x01\x01\x12\x11\n\x04name\x18\x10 \x01(\tH\x08\x88\x01\x01\x12\x14\n\x07is_held\x18\x11 \x01(\x08H\t\x88\x01\x01\x12\r\n\x05\x65\x64ges\x18\x12 \x03(\t\x12\x11\n\tancestors\x18\x13 \x03(\t\x12\x16\n\tflow_nums\x18\x14 \x01(\tH\n\x88\x01\x01\x12=\n\x11\x65xternal_triggers\x18\x17 \x03(\x0b\x32\".PbTaskProxy.ExternalTriggersEntry\x12.\n\txtriggers\x18\x18 \x03(\x0b\x32\x1b.PbTaskProxy.XtriggersEntry\x12\x16\n\tis_queued\x18\x19 \x01(\x08H\x0b\x88\x01\x01\x12\x18\n\x0bis_runahead\x18\x1a \x01(\x08H\x0c\x88\x01\x01\x12\x16\n\tflow_wait\x18\x1b \x01(\x08H\r\x88\x01\x01\x12 \n\x07runtime\x18\x1c \x01(\x0b\x32\n.PbRuntimeH\x0e\x88\x01\x01\x12\x18\n\x0bgraph_depth\x18\x1d \x01(\x05H\x0f\x88\x01\x01\x1a\x39\n\x0cOutputsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\x18\n\x05value\x18\x02 \x01(\x0b\x32\t.PbOutput:\x02\x38\x01\x1a\x43\n\x15\x45xternalTriggersEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\x19\n\x05value\x18\x02 \x01(\x0b\x32\n.PbTrigger:\x02\x38\x01\x1a<\n\x0eXtriggersEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\x19\n\x05value\x18\x02 \x01(\x0b\x32\n.PbTrigger:\x02\x38\x01\x42\x08\n\x06_stampB\x05\n\x03_idB\x07\n\x05_taskB\x08\n\x06_stateB\x0e\n\x0c_cycle_pointB\x08\n\x06_depthB\x0e\n\x0c_job_submitsB\x0f\n\r_first_parentB\x07\n\x05_nameB\n\n\x08_is_heldB\x0c\n\n_flow_numsB\x0c\n\n_is_queuedB\x0e\n\x0c_is_runaheadB\x0c\n\n_flow_waitB\n\n\x08_runtimeB\x0e\n\x0c_graph_depth\"\xc8\x02\n\x08PbFamily\x12\x12\n\x05stamp\x18\x01 \x01(\tH\x00\x88\x01\x01\x12\x0f\n\x02id\x18\x02 \x01(\tH\x01\x88\x01\x01\x12\x11\n\x04name\x18\x03 \x01(\tH\x02\x88\x01\x01\x12\x1a\n\x04meta\x18\x04 \x01(\x0b\x32\x07.PbMetaH\x03\x88\x01\x01\x12\x12\n\x05\x64\x65pth\x18\x05 \x01(\x05H\x04\x88\x01\x01\x12\x0f\n\x07proxies\x18\x06 \x03(\t\x12\x0f\n\x07parents\x18\x07 \x03(\t\x12\x13\n\x0b\x63hild_tasks\x18\x08 \x03(\t\x12\x16\n\x0e\x63hild_families\x18\t \x03(\t\x12\x19\n\x0c\x66irst_parent\x18\n \x01(\tH\x05\x88\x01\x01\x12 \n\x07runtime\x18\x0b \x01(\x0b\x32\n.PbRuntimeH\x06\x88\x01\x01\x42\x08\n\x06_stampB\x05\n\x03_idB\x07\n\x05_nameB\x07\n\x05_metaB\x08\n\x06_depthB\x0f\n\r_first_parentB\n\n\x08_runtime\"\xae\x06\n\rPbFamilyProxy\x12\x12\n\x05stamp\x18\x01 \x01(\tH\x00\x88\x01\x01\x12\x0f\n\x02id\x18\x02 \x01(\tH\x01\x88\x01\x01\x12\x18\n\x0b\x63ycle_point\x18\x03 \x01(\tH\x02\x88\x01\x01\x12\x11\n\x04name\x18\x04 \x01(\tH\x03\x88\x01\x01\x12\x13\n\x06\x66\x61mily\x18\x05 \x01(\tH\x04\x88\x01\x01\x12\x12\n\x05state\x18\x06 \x01(\tH\x05\x88\x01\x01\x12\x12\n\x05\x64\x65pth\x18\x07 \x01(\x05H\x06\x88\x01\x01\x12\x19\n\x0c\x66irst_parent\x18\x08 \x01(\tH\x07\x88\x01\x01\x12\x13\n\x0b\x63hild_tasks\x18\n \x03(\t\x12\x16\n\x0e\x63hild_families\x18\x0b \x03(\t\x12\x14\n\x07is_held\x18\x0c \x01(\x08H\x08\x88\x01\x01\x12\x11\n\tancestors\x18\r \x03(\t\x12\x0e\n\x06states\x18\x0e \x03(\t\x12\x35\n\x0cstate_totals\x18\x0f \x03(\x0b\x32\x1f.PbFamilyProxy.StateTotalsEntry\x12\x1a\n\ris_held_total\x18\x10 \x01(\x05H\t\x88\x01\x01\x12\x16\n\tis_queued\x18\x11 \x01(\x08H\n\x88\x01\x01\x12\x1c\n\x0fis_queued_total\x18\x12 \x01(\x05H\x0b\x88\x01\x01\x12\x18\n\x0bis_runahead\x18\x13 \x01(\x08H\x0c\x88\x01\x01\x12\x1e\n\x11is_runahead_total\x18\x14 \x01(\x05H\r\x88\x01\x01\x12 \n\x07runtime\x18\x15 \x01(\x0b\x32\n.PbRuntimeH\x0e\x88\x01\x01\x12\x18\n\x0bgraph_depth\x18\x16 \x01(\x05H\x0f\x88\x01\x01\x1a\x32\n\x10StateTotalsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\x05:\x02\x38\x01\x42\x08\n\x06_stampB\x05\n\x03_idB\x0e\n\x0c_cycle_pointB\x07\n\x05_nameB\t\n\x07_familyB\x08\n\x06_stateB\x08\n\x06_depthB\x0f\n\r_first_parentB\n\n\x08_is_heldB\x10\n\x0e_is_held_totalB\x0c\n\n_is_queuedB\x12\n\x10_is_queued_totalB\x0e\n\x0c_is_runaheadB\x14\n\x12_is_runahead_totalB\n\n\x08_runtimeB\x0e\n\x0c_graph_depth\"\xbc\x01\n\x06PbEdge\x12\x12\n\x05stamp\x18\x01 \x01(\tH\x00\x88\x01\x01\x12\x0f\n\x02id\x18\x02 \x01(\tH\x01\x88\x01\x01\x12\x13\n\x06source\x18\x03 \x01(\tH\x02\x88\x01\x01\x12\x13\n\x06target\x18\x04 \x01(\tH\x03\x88\x01\x01\x12\x14\n\x07suicide\x18\x05 \x01(\x08H\x04\x88\x01\x01\x12\x11\n\x04\x63ond\x18\x06 \x01(\x08H\x05\x88\x01\x01\x42\x08\n\x06_stampB\x05\n\x03_idB\t\n\x07_sourceB\t\n\x07_targetB\n\n\x08_suicideB\x07\n\x05_cond\"{\n\x07PbEdges\x12\x0f\n\x02id\x18\x01 \x01(\tH\x00\x88\x01\x01\x12\r\n\x05\x65\x64ges\x18\x02 \x03(\t\x12+\n\x16workflow_polling_tasks\x18\x03 \x03(\x0b\x32\x0b.PbPollTask\x12\x0e\n\x06leaves\x18\x04 \x03(\t\x12\x0c\n\x04\x66\x65\x65t\x18\x05 \x03(\tB\x05\n\x03_id\"\xf2\x01\n\x10PbEntireWorkflow\x12\"\n\x08workflow\x18\x01 \x01(\x0b\x32\x0b.PbWorkflowH\x00\x88\x01\x01\x12\x16\n\x05tasks\x18\x02 \x03(\x0b\x32\x07.PbTask\x12\"\n\x0ctask_proxies\x18\x03 \x03(\x0b\x32\x0c.PbTaskProxy\x12\x14\n\x04jobs\x18\x04 \x03(\x0b\x32\x06.PbJob\x12\x1b\n\x08\x66\x61milies\x18\x05 \x03(\x0b\x32\t.PbFamily\x12&\n\x0e\x66\x61mily_proxies\x18\x06 \x03(\x0b\x32\x0e.PbFamilyProxy\x12\x16\n\x05\x65\x64ges\x18\x07 \x03(\x0b\x32\x07.PbEdgeB\x0b\n\t_workflow\"\xaf\x01\n\x07\x45\x44\x65ltas\x12\x11\n\x04time\x18\x01 \x01(\x01H\x00\x88\x01\x01\x12\x15\n\x08\x63hecksum\x18\x02 \x01(\x03H\x01\x88\x01\x01\x12\x16\n\x05\x61\x64\x64\x65\x64\x18\x03 \x03(\x0b\x32\x07.PbEdge\x12\x18\n\x07updated\x18\x04 \x03(\x0b\x32\x07.PbEdge\x12\x0e\n\x06pruned\x18\x05 \x03(\t\x12\x15\n\x08reloaded\x18\x06 \x01(\x08H\x02\x88\x01\x01\x42\x07\n\x05_timeB\x0b\n\t_checksumB\x0b\n\t_reloaded\"\xb3\x01\n\x07\x46\x44\x65ltas\x12\x11\n\x04time\x18\x01 \x01(\x01H\x00\x88\x01\x01\x12\x15\n\x08\x63hecksum\x18\x02 \x01(\x03H\x01\x88\x01\x01\x12\x18\n\x05\x61\x64\x64\x65\x64\x18\x03 \x03(\x0b\x32\t.PbFamily\x12\x1a\n\x07updated\x18\x04 \x03(\x0b\x32\t.PbFamily\x12\x0e\n\x06pruned\x18\x05 \x03(\t\x12\x15\n\x08reloaded\x18\x06 \x01(\x08H\x02\x88\x01\x01\x42\x07\n\x05_timeB\x0b\n\t_checksumB\x0b\n\t_reloaded\"\xbe\x01\n\x08\x46PDeltas\x12\x11\n\x04time\x18\x01 \x01(\x01H\x00\x88\x01\x01\x12\x15\n\x08\x63hecksum\x18\x02 \x01(\x03H\x01\x88\x01\x01\x12\x1d\n\x05\x61\x64\x64\x65\x64\x18\x03 \x03(\x0b\x32\x0e.PbFamilyProxy\x12\x1f\n\x07updated\x18\x04 \x03(\x0b\x32\x0e.PbFamilyProxy\x12\x0e\n\x06pruned\x18\x05 \x03(\t\x12\x15\n\x08reloaded\x18\x06 \x01(\x08H\x02\x88\x01\x01\x42\x07\n\x05_timeB\x0b\n\t_checksumB\x0b\n\t_reloaded\"\xad\x01\n\x07JDeltas\x12\x11\n\x04time\x18\x01 \x01(\x01H\x00\x88\x01\x01\x12\x15\n\x08\x63hecksum\x18\x02 \x01(\x03H\x01\x88\x01\x01\x12\x15\n\x05\x61\x64\x64\x65\x64\x18\x03 \x03(\x0b\x32\x06.PbJob\x12\x17\n\x07updated\x18\x04 \x03(\x0b\x32\x06.PbJob\x12\x0e\n\x06pruned\x18\x05 \x03(\t\x12\x15\n\x08reloaded\x18\x06 \x01(\x08H\x02\x88\x01\x01\x42\x07\n\x05_timeB\x0b\n\t_checksumB\x0b\n\t_reloaded\"\xaf\x01\n\x07TDeltas\x12\x11\n\x04time\x18\x01 \x01(\x01H\x00\x88\x01\x01\x12\x15\n\x08\x63hecksum\x18\x02 \x01(\x03H\x01\x88\x01\x01\x12\x16\n\x05\x61\x64\x64\x65\x64\x18\x03 \x03(\x0b\x32\x07.PbTask\x12\x18\n\x07updated\x18\x04 \x03(\x0b\x32\x07.PbTask\x12\x0e\n\x06pruned\x18\x05 \x03(\t\x12\x15\n\x08reloaded\x18\x06 \x01(\x08H\x02\x88\x01\x01\x42\x07\n\x05_timeB\x0b\n\t_checksumB\x0b\n\t_reloaded\"\xba\x01\n\x08TPDeltas\x12\x11\n\x04time\x18\x01 \x01(\x01H\x00\x88\x01\x01\x12\x15\n\x08\x63hecksum\x18\x02 \x01(\x03H\x01\x88\x01\x01\x12\x1b\n\x05\x61\x64\x64\x65\x64\x18\x03 \x03(\x0b\x32\x0c.PbTaskProxy\x12\x1d\n\x07updated\x18\x04 \x03(\x0b\x32\x0c.PbTaskProxy\x12\x0e\n\x06pruned\x18\x05 \x03(\t\x12\x15\n\x08reloaded\x18\x06 \x01(\x08H\x02\x88\x01\x01\x42\x07\n\x05_timeB\x0b\n\t_checksumB\x0b\n\t_reloaded\"\xc3\x01\n\x07WDeltas\x12\x11\n\x04time\x18\x01 \x01(\x01H\x00\x88\x01\x01\x12\x1f\n\x05\x61\x64\x64\x65\x64\x18\x02 \x01(\x0b\x32\x0b.PbWorkflowH\x01\x88\x01\x01\x12!\n\x07updated\x18\x03 \x01(\x0b\x32\x0b.PbWorkflowH\x02\x88\x01\x01\x12\x15\n\x08reloaded\x18\x04 \x01(\x08H\x03\x88\x01\x01\x12\x13\n\x06pruned\x18\x05 \x01(\tH\x04\x88\x01\x01\x42\x07\n\x05_timeB\x08\n\x06_addedB\n\n\x08_updatedB\x0b\n\t_reloadedB\t\n\x07_pruned\"\xd1\x01\n\tAllDeltas\x12\x1a\n\x08\x66\x61milies\x18\x01 \x01(\x0b\x32\x08.FDeltas\x12!\n\x0e\x66\x61mily_proxies\x18\x02 \x01(\x0b\x32\t.FPDeltas\x12\x16\n\x04jobs\x18\x03 \x01(\x0b\x32\x08.JDeltas\x12\x17\n\x05tasks\x18\x04 \x01(\x0b\x32\x08.TDeltas\x12\x1f\n\x0ctask_proxies\x18\x05 \x01(\x0b\x32\t.TPDeltas\x12\x17\n\x05\x65\x64ges\x18\x06 \x01(\x0b\x32\x08.EDeltas\x12\x1a\n\x08workflow\x18\x07 \x01(\x0b\x32\x08.WDeltasb\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x13\x64\x61ta_messages.proto\"\x96\x01\n\x06PbMeta\x12\x12\n\x05title\x18\x01 \x01(\tH\x00\x88\x01\x01\x12\x18\n\x0b\x64\x65scription\x18\x02 \x01(\tH\x01\x88\x01\x01\x12\x10\n\x03URL\x18\x03 \x01(\tH\x02\x88\x01\x01\x12\x19\n\x0cuser_defined\x18\x04 \x01(\tH\x03\x88\x01\x01\x42\x08\n\x06_titleB\x0e\n\x0c_descriptionB\x06\n\x04_URLB\x0f\n\r_user_defined\"\xaa\x01\n\nPbTimeZone\x12\x12\n\x05hours\x18\x01 \x01(\x05H\x00\x88\x01\x01\x12\x14\n\x07minutes\x18\x02 \x01(\x05H\x01\x88\x01\x01\x12\x19\n\x0cstring_basic\x18\x03 \x01(\tH\x02\x88\x01\x01\x12\x1c\n\x0fstring_extended\x18\x04 \x01(\tH\x03\x88\x01\x01\x42\x08\n\x06_hoursB\n\n\x08_minutesB\x0f\n\r_string_basicB\x12\n\x10_string_extended\"\'\n\x0fPbTaskProxyRefs\x12\x14\n\x0ctask_proxies\x18\x01 \x03(\t\"\xd4\x0c\n\nPbWorkflow\x12\x12\n\x05stamp\x18\x01 \x01(\tH\x00\x88\x01\x01\x12\x0f\n\x02id\x18\x02 \x01(\tH\x01\x88\x01\x01\x12\x11\n\x04name\x18\x03 \x01(\tH\x02\x88\x01\x01\x12\x13\n\x06status\x18\x04 \x01(\tH\x03\x88\x01\x01\x12\x11\n\x04host\x18\x05 \x01(\tH\x04\x88\x01\x01\x12\x11\n\x04port\x18\x06 \x01(\x05H\x05\x88\x01\x01\x12\x12\n\x05owner\x18\x07 \x01(\tH\x06\x88\x01\x01\x12\r\n\x05tasks\x18\x08 \x03(\t\x12\x10\n\x08\x66\x61milies\x18\t \x03(\t\x12\x1c\n\x05\x65\x64ges\x18\n \x01(\x0b\x32\x08.PbEdgesH\x07\x88\x01\x01\x12\x18\n\x0b\x61pi_version\x18\x0b \x01(\x05H\x08\x88\x01\x01\x12\x19\n\x0c\x63ylc_version\x18\x0c \x01(\tH\t\x88\x01\x01\x12\x19\n\x0clast_updated\x18\r \x01(\x01H\n\x88\x01\x01\x12\x1a\n\x04meta\x18\x0e \x01(\x0b\x32\x07.PbMetaH\x0b\x88\x01\x01\x12&\n\x19newest_active_cycle_point\x18\x10 \x01(\tH\x0c\x88\x01\x01\x12&\n\x19oldest_active_cycle_point\x18\x11 \x01(\tH\r\x88\x01\x01\x12\x15\n\x08reloaded\x18\x12 \x01(\x08H\x0e\x88\x01\x01\x12\x15\n\x08run_mode\x18\x13 \x01(\tH\x0f\x88\x01\x01\x12\x19\n\x0c\x63ycling_mode\x18\x14 \x01(\tH\x10\x88\x01\x01\x12\x32\n\x0cstate_totals\x18\x15 \x03(\x0b\x32\x1c.PbWorkflow.StateTotalsEntry\x12\x1d\n\x10workflow_log_dir\x18\x16 \x01(\tH\x11\x88\x01\x01\x12(\n\x0etime_zone_info\x18\x17 \x01(\x0b\x32\x0b.PbTimeZoneH\x12\x88\x01\x01\x12\x17\n\ntree_depth\x18\x18 \x01(\x05H\x13\x88\x01\x01\x12\x15\n\rjob_log_names\x18\x19 \x03(\t\x12\x14\n\x0cns_def_order\x18\x1a \x03(\t\x12\x0e\n\x06states\x18\x1b \x03(\t\x12\x14\n\x0ctask_proxies\x18\x1c \x03(\t\x12\x16\n\x0e\x66\x61mily_proxies\x18\x1d \x03(\t\x12\x17\n\nstatus_msg\x18\x1e \x01(\tH\x14\x88\x01\x01\x12\x1a\n\ris_held_total\x18\x1f \x01(\x05H\x15\x88\x01\x01\x12\x0c\n\x04jobs\x18 \x03(\t\x12\x15\n\x08pub_port\x18! \x01(\x05H\x16\x88\x01\x01\x12\x17\n\nbroadcasts\x18\" \x01(\tH\x17\x88\x01\x01\x12\x1c\n\x0fis_queued_total\x18# \x01(\x05H\x18\x88\x01\x01\x12=\n\x12latest_state_tasks\x18$ \x03(\x0b\x32!.PbWorkflow.LatestStateTasksEntry\x12\x13\n\x06pruned\x18% \x01(\x08H\x19\x88\x01\x01\x12\x1e\n\x11is_runahead_total\x18& \x01(\x05H\x1a\x88\x01\x01\x12\x1b\n\x0estates_updated\x18\' \x01(\x08H\x1b\x88\x01\x01\x12\x1c\n\x0fn_edge_distance\x18( \x01(\x05H\x1c\x88\x01\x01\x1a\x32\n\x10StateTotalsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\x05:\x02\x38\x01\x1aI\n\x15LatestStateTasksEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\x1f\n\x05value\x18\x02 \x01(\x0b\x32\x10.PbTaskProxyRefs:\x02\x38\x01\x42\x08\n\x06_stampB\x05\n\x03_idB\x07\n\x05_nameB\t\n\x07_statusB\x07\n\x05_hostB\x07\n\x05_portB\x08\n\x06_ownerB\x08\n\x06_edgesB\x0e\n\x0c_api_versionB\x0f\n\r_cylc_versionB\x0f\n\r_last_updatedB\x07\n\x05_metaB\x1c\n\x1a_newest_active_cycle_pointB\x1c\n\x1a_oldest_active_cycle_pointB\x0b\n\t_reloadedB\x0b\n\t_run_modeB\x0f\n\r_cycling_modeB\x13\n\x11_workflow_log_dirB\x11\n\x0f_time_zone_infoB\r\n\x0b_tree_depthB\r\n\x0b_status_msgB\x10\n\x0e_is_held_totalB\x0b\n\t_pub_portB\r\n\x0b_broadcastsB\x12\n\x10_is_queued_totalB\t\n\x07_prunedB\x14\n\x12_is_runahead_totalB\x11\n\x0f_states_updatedB\x12\n\x10_n_edge_distance\"\xe1\x06\n\tPbRuntime\x12\x15\n\x08platform\x18\x01 \x01(\tH\x00\x88\x01\x01\x12\x13\n\x06script\x18\x02 \x01(\tH\x01\x88\x01\x01\x12\x18\n\x0binit_script\x18\x03 \x01(\tH\x02\x88\x01\x01\x12\x17\n\nenv_script\x18\x04 \x01(\tH\x03\x88\x01\x01\x12\x17\n\nerr_script\x18\x05 \x01(\tH\x04\x88\x01\x01\x12\x18\n\x0b\x65xit_script\x18\x06 \x01(\tH\x05\x88\x01\x01\x12\x17\n\npre_script\x18\x07 \x01(\tH\x06\x88\x01\x01\x12\x18\n\x0bpost_script\x18\x08 \x01(\tH\x07\x88\x01\x01\x12\x19\n\x0cwork_sub_dir\x18\t \x01(\tH\x08\x88\x01\x01\x12(\n\x1b\x65xecution_polling_intervals\x18\n \x01(\tH\t\x88\x01\x01\x12#\n\x16\x65xecution_retry_delays\x18\x0b \x01(\tH\n\x88\x01\x01\x12!\n\x14\x65xecution_time_limit\x18\x0c \x01(\tH\x0b\x88\x01\x01\x12)\n\x1csubmission_polling_intervals\x18\r \x01(\tH\x0c\x88\x01\x01\x12$\n\x17submission_retry_delays\x18\x0e \x01(\tH\r\x88\x01\x01\x12\x17\n\ndirectives\x18\x0f \x01(\tH\x0e\x88\x01\x01\x12\x18\n\x0b\x65nvironment\x18\x10 \x01(\tH\x0f\x88\x01\x01\x12\x14\n\x07outputs\x18\x11 \x01(\tH\x10\x88\x01\x01\x12\x17\n\ncompletion\x18\x12 \x01(\tH\x11\x88\x01\x01\x42\x0b\n\t_platformB\t\n\x07_scriptB\x0e\n\x0c_init_scriptB\r\n\x0b_env_scriptB\r\n\x0b_err_scriptB\x0e\n\x0c_exit_scriptB\r\n\x0b_pre_scriptB\x0e\n\x0c_post_scriptB\x0f\n\r_work_sub_dirB\x1e\n\x1c_execution_polling_intervalsB\x19\n\x17_execution_retry_delaysB\x17\n\x15_execution_time_limitB\x1f\n\x1d_submission_polling_intervalsB\x1a\n\x18_submission_retry_delaysB\r\n\x0b_directivesB\x0e\n\x0c_environmentB\n\n\x08_outputsB\r\n\x0b_completion\"\x9d\x05\n\x05PbJob\x12\x12\n\x05stamp\x18\x01 \x01(\tH\x00\x88\x01\x01\x12\x0f\n\x02id\x18\x02 \x01(\tH\x01\x88\x01\x01\x12\x17\n\nsubmit_num\x18\x03 \x01(\x05H\x02\x88\x01\x01\x12\x12\n\x05state\x18\x04 \x01(\tH\x03\x88\x01\x01\x12\x17\n\ntask_proxy\x18\x05 \x01(\tH\x04\x88\x01\x01\x12\x1b\n\x0esubmitted_time\x18\x06 \x01(\tH\x05\x88\x01\x01\x12\x19\n\x0cstarted_time\x18\x07 \x01(\tH\x06\x88\x01\x01\x12\x1a\n\rfinished_time\x18\x08 \x01(\tH\x07\x88\x01\x01\x12\x13\n\x06job_id\x18\t \x01(\tH\x08\x88\x01\x01\x12\x1c\n\x0fjob_runner_name\x18\n \x01(\tH\t\x88\x01\x01\x12!\n\x14\x65xecution_time_limit\x18\x0e \x01(\x02H\n\x88\x01\x01\x12\x15\n\x08platform\x18\x0f \x01(\tH\x0b\x88\x01\x01\x12\x18\n\x0bjob_log_dir\x18\x11 \x01(\tH\x0c\x88\x01\x01\x12\x11\n\x04name\x18\x1e \x01(\tH\r\x88\x01\x01\x12\x18\n\x0b\x63ycle_point\x18\x1f \x01(\tH\x0e\x88\x01\x01\x12\x10\n\x08messages\x18 \x03(\t\x12 \n\x07runtime\x18! \x01(\x0b\x32\n.PbRuntimeH\x0f\x88\x01\x01\x42\x08\n\x06_stampB\x05\n\x03_idB\r\n\x0b_submit_numB\x08\n\x06_stateB\r\n\x0b_task_proxyB\x11\n\x0f_submitted_timeB\x0f\n\r_started_timeB\x10\n\x0e_finished_timeB\t\n\x07_job_idB\x12\n\x10_job_runner_nameB\x17\n\x15_execution_time_limitB\x0b\n\t_platformB\x0e\n\x0c_job_log_dirB\x07\n\x05_nameB\x0e\n\x0c_cycle_pointB\n\n\x08_runtimeJ\x04\x08\x1d\x10\x1e\"\xe2\x02\n\x06PbTask\x12\x12\n\x05stamp\x18\x01 \x01(\tH\x00\x88\x01\x01\x12\x0f\n\x02id\x18\x02 \x01(\tH\x01\x88\x01\x01\x12\x11\n\x04name\x18\x03 \x01(\tH\x02\x88\x01\x01\x12\x1a\n\x04meta\x18\x04 \x01(\x0b\x32\x07.PbMetaH\x03\x88\x01\x01\x12\x1e\n\x11mean_elapsed_time\x18\x05 \x01(\x02H\x04\x88\x01\x01\x12\x12\n\x05\x64\x65pth\x18\x06 \x01(\x05H\x05\x88\x01\x01\x12\x0f\n\x07proxies\x18\x07 \x03(\t\x12\x11\n\tnamespace\x18\x08 \x03(\t\x12\x0f\n\x07parents\x18\t \x03(\t\x12\x19\n\x0c\x66irst_parent\x18\n \x01(\tH\x06\x88\x01\x01\x12 \n\x07runtime\x18\x0b \x01(\x0b\x32\n.PbRuntimeH\x07\x88\x01\x01\x42\x08\n\x06_stampB\x05\n\x03_idB\x07\n\x05_nameB\x07\n\x05_metaB\x14\n\x12_mean_elapsed_timeB\x08\n\x06_depthB\x0f\n\r_first_parentB\n\n\x08_runtime\"\xd8\x01\n\nPbPollTask\x12\x18\n\x0blocal_proxy\x18\x01 \x01(\tH\x00\x88\x01\x01\x12\x15\n\x08workflow\x18\x02 \x01(\tH\x01\x88\x01\x01\x12\x19\n\x0cremote_proxy\x18\x03 \x01(\tH\x02\x88\x01\x01\x12\x16\n\treq_state\x18\x04 \x01(\tH\x03\x88\x01\x01\x12\x19\n\x0cgraph_string\x18\x05 \x01(\tH\x04\x88\x01\x01\x42\x0e\n\x0c_local_proxyB\x0b\n\t_workflowB\x0f\n\r_remote_proxyB\x0c\n\n_req_stateB\x0f\n\r_graph_string\"\xcb\x01\n\x0bPbCondition\x12\x17\n\ntask_proxy\x18\x01 \x01(\tH\x00\x88\x01\x01\x12\x17\n\nexpr_alias\x18\x02 \x01(\tH\x01\x88\x01\x01\x12\x16\n\treq_state\x18\x03 \x01(\tH\x02\x88\x01\x01\x12\x16\n\tsatisfied\x18\x04 \x01(\x08H\x03\x88\x01\x01\x12\x14\n\x07message\x18\x05 \x01(\tH\x04\x88\x01\x01\x42\r\n\x0b_task_proxyB\r\n\x0b_expr_aliasB\x0c\n\n_req_stateB\x0c\n\n_satisfiedB\n\n\x08_message\"\x96\x01\n\x0ePbPrerequisite\x12\x17\n\nexpression\x18\x01 \x01(\tH\x00\x88\x01\x01\x12 \n\nconditions\x18\x02 \x03(\x0b\x32\x0c.PbCondition\x12\x14\n\x0c\x63ycle_points\x18\x03 \x03(\t\x12\x16\n\tsatisfied\x18\x04 \x01(\x08H\x01\x88\x01\x01\x42\r\n\x0b_expressionB\x0c\n\n_satisfied\"\x8c\x01\n\x08PbOutput\x12\x12\n\x05label\x18\x01 \x01(\tH\x00\x88\x01\x01\x12\x14\n\x07message\x18\x02 \x01(\tH\x01\x88\x01\x01\x12\x16\n\tsatisfied\x18\x03 \x01(\x08H\x02\x88\x01\x01\x12\x11\n\x04time\x18\x04 \x01(\x01H\x03\x88\x01\x01\x42\x08\n\x06_labelB\n\n\x08_messageB\x0c\n\n_satisfiedB\x07\n\x05_time\"\xa5\x01\n\tPbTrigger\x12\x0f\n\x02id\x18\x01 \x01(\tH\x00\x88\x01\x01\x12\x12\n\x05label\x18\x02 \x01(\tH\x01\x88\x01\x01\x12\x14\n\x07message\x18\x03 \x01(\tH\x02\x88\x01\x01\x12\x16\n\tsatisfied\x18\x04 \x01(\x08H\x03\x88\x01\x01\x12\x11\n\x04time\x18\x05 \x01(\x01H\x04\x88\x01\x01\x42\x05\n\x03_idB\x08\n\x06_labelB\n\n\x08_messageB\x0c\n\n_satisfiedB\x07\n\x05_time\"\x91\x08\n\x0bPbTaskProxy\x12\x12\n\x05stamp\x18\x01 \x01(\tH\x00\x88\x01\x01\x12\x0f\n\x02id\x18\x02 \x01(\tH\x01\x88\x01\x01\x12\x11\n\x04task\x18\x03 \x01(\tH\x02\x88\x01\x01\x12\x12\n\x05state\x18\x04 \x01(\tH\x03\x88\x01\x01\x12\x18\n\x0b\x63ycle_point\x18\x05 \x01(\tH\x04\x88\x01\x01\x12\x12\n\x05\x64\x65pth\x18\x06 \x01(\x05H\x05\x88\x01\x01\x12\x18\n\x0bjob_submits\x18\x07 \x01(\x05H\x06\x88\x01\x01\x12*\n\x07outputs\x18\t \x03(\x0b\x32\x19.PbTaskProxy.OutputsEntry\x12\x11\n\tnamespace\x18\x0b \x03(\t\x12&\n\rprerequisites\x18\x0c \x03(\x0b\x32\x0f.PbPrerequisite\x12\x0c\n\x04jobs\x18\r \x03(\t\x12\x19\n\x0c\x66irst_parent\x18\x0f \x01(\tH\x07\x88\x01\x01\x12\x11\n\x04name\x18\x10 \x01(\tH\x08\x88\x01\x01\x12\x14\n\x07is_held\x18\x11 \x01(\x08H\t\x88\x01\x01\x12\r\n\x05\x65\x64ges\x18\x12 \x03(\t\x12\x11\n\tancestors\x18\x13 \x03(\t\x12\x16\n\tflow_nums\x18\x14 \x01(\tH\n\x88\x01\x01\x12=\n\x11\x65xternal_triggers\x18\x17 \x03(\x0b\x32\".PbTaskProxy.ExternalTriggersEntry\x12.\n\txtriggers\x18\x18 \x03(\x0b\x32\x1b.PbTaskProxy.XtriggersEntry\x12\x16\n\tis_queued\x18\x19 \x01(\x08H\x0b\x88\x01\x01\x12\x18\n\x0bis_runahead\x18\x1a \x01(\x08H\x0c\x88\x01\x01\x12\x16\n\tflow_wait\x18\x1b \x01(\x08H\r\x88\x01\x01\x12 \n\x07runtime\x18\x1c \x01(\x0b\x32\n.PbRuntimeH\x0e\x88\x01\x01\x12\x18\n\x0bgraph_depth\x18\x1d \x01(\x05H\x0f\x88\x01\x01\x1a\x39\n\x0cOutputsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\x18\n\x05value\x18\x02 \x01(\x0b\x32\t.PbOutput:\x02\x38\x01\x1a\x43\n\x15\x45xternalTriggersEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\x19\n\x05value\x18\x02 \x01(\x0b\x32\n.PbTrigger:\x02\x38\x01\x1a<\n\x0eXtriggersEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\x19\n\x05value\x18\x02 \x01(\x0b\x32\n.PbTrigger:\x02\x38\x01\x42\x08\n\x06_stampB\x05\n\x03_idB\x07\n\x05_taskB\x08\n\x06_stateB\x0e\n\x0c_cycle_pointB\x08\n\x06_depthB\x0e\n\x0c_job_submitsB\x0f\n\r_first_parentB\x07\n\x05_nameB\n\n\x08_is_heldB\x0c\n\n_flow_numsB\x0c\n\n_is_queuedB\x0e\n\x0c_is_runaheadB\x0c\n\n_flow_waitB\n\n\x08_runtimeB\x0e\n\x0c_graph_depth\"\xc8\x02\n\x08PbFamily\x12\x12\n\x05stamp\x18\x01 \x01(\tH\x00\x88\x01\x01\x12\x0f\n\x02id\x18\x02 \x01(\tH\x01\x88\x01\x01\x12\x11\n\x04name\x18\x03 \x01(\tH\x02\x88\x01\x01\x12\x1a\n\x04meta\x18\x04 \x01(\x0b\x32\x07.PbMetaH\x03\x88\x01\x01\x12\x12\n\x05\x64\x65pth\x18\x05 \x01(\x05H\x04\x88\x01\x01\x12\x0f\n\x07proxies\x18\x06 \x03(\t\x12\x0f\n\x07parents\x18\x07 \x03(\t\x12\x13\n\x0b\x63hild_tasks\x18\x08 \x03(\t\x12\x16\n\x0e\x63hild_families\x18\t \x03(\t\x12\x19\n\x0c\x66irst_parent\x18\n \x01(\tH\x05\x88\x01\x01\x12 \n\x07runtime\x18\x0b \x01(\x0b\x32\n.PbRuntimeH\x06\x88\x01\x01\x42\x08\n\x06_stampB\x05\n\x03_idB\x07\n\x05_nameB\x07\n\x05_metaB\x08\n\x06_depthB\x0f\n\r_first_parentB\n\n\x08_runtime\"\xae\x06\n\rPbFamilyProxy\x12\x12\n\x05stamp\x18\x01 \x01(\tH\x00\x88\x01\x01\x12\x0f\n\x02id\x18\x02 \x01(\tH\x01\x88\x01\x01\x12\x18\n\x0b\x63ycle_point\x18\x03 \x01(\tH\x02\x88\x01\x01\x12\x11\n\x04name\x18\x04 \x01(\tH\x03\x88\x01\x01\x12\x13\n\x06\x66\x61mily\x18\x05 \x01(\tH\x04\x88\x01\x01\x12\x12\n\x05state\x18\x06 \x01(\tH\x05\x88\x01\x01\x12\x12\n\x05\x64\x65pth\x18\x07 \x01(\x05H\x06\x88\x01\x01\x12\x19\n\x0c\x66irst_parent\x18\x08 \x01(\tH\x07\x88\x01\x01\x12\x13\n\x0b\x63hild_tasks\x18\n \x03(\t\x12\x16\n\x0e\x63hild_families\x18\x0b \x03(\t\x12\x14\n\x07is_held\x18\x0c \x01(\x08H\x08\x88\x01\x01\x12\x11\n\tancestors\x18\r \x03(\t\x12\x0e\n\x06states\x18\x0e \x03(\t\x12\x35\n\x0cstate_totals\x18\x0f \x03(\x0b\x32\x1f.PbFamilyProxy.StateTotalsEntry\x12\x1a\n\ris_held_total\x18\x10 \x01(\x05H\t\x88\x01\x01\x12\x16\n\tis_queued\x18\x11 \x01(\x08H\n\x88\x01\x01\x12\x1c\n\x0fis_queued_total\x18\x12 \x01(\x05H\x0b\x88\x01\x01\x12\x18\n\x0bis_runahead\x18\x13 \x01(\x08H\x0c\x88\x01\x01\x12\x1e\n\x11is_runahead_total\x18\x14 \x01(\x05H\r\x88\x01\x01\x12 \n\x07runtime\x18\x15 \x01(\x0b\x32\n.PbRuntimeH\x0e\x88\x01\x01\x12\x18\n\x0bgraph_depth\x18\x16 \x01(\x05H\x0f\x88\x01\x01\x1a\x32\n\x10StateTotalsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\x05:\x02\x38\x01\x42\x08\n\x06_stampB\x05\n\x03_idB\x0e\n\x0c_cycle_pointB\x07\n\x05_nameB\t\n\x07_familyB\x08\n\x06_stateB\x08\n\x06_depthB\x0f\n\r_first_parentB\n\n\x08_is_heldB\x10\n\x0e_is_held_totalB\x0c\n\n_is_queuedB\x12\n\x10_is_queued_totalB\x0e\n\x0c_is_runaheadB\x14\n\x12_is_runahead_totalB\n\n\x08_runtimeB\x0e\n\x0c_graph_depth\"\xbc\x01\n\x06PbEdge\x12\x12\n\x05stamp\x18\x01 \x01(\tH\x00\x88\x01\x01\x12\x0f\n\x02id\x18\x02 \x01(\tH\x01\x88\x01\x01\x12\x13\n\x06source\x18\x03 \x01(\tH\x02\x88\x01\x01\x12\x13\n\x06target\x18\x04 \x01(\tH\x03\x88\x01\x01\x12\x14\n\x07suicide\x18\x05 \x01(\x08H\x04\x88\x01\x01\x12\x11\n\x04\x63ond\x18\x06 \x01(\x08H\x05\x88\x01\x01\x42\x08\n\x06_stampB\x05\n\x03_idB\t\n\x07_sourceB\t\n\x07_targetB\n\n\x08_suicideB\x07\n\x05_cond\"{\n\x07PbEdges\x12\x0f\n\x02id\x18\x01 \x01(\tH\x00\x88\x01\x01\x12\r\n\x05\x65\x64ges\x18\x02 \x03(\t\x12+\n\x16workflow_polling_tasks\x18\x03 \x03(\x0b\x32\x0b.PbPollTask\x12\x0e\n\x06leaves\x18\x04 \x03(\t\x12\x0c\n\x04\x66\x65\x65t\x18\x05 \x03(\tB\x05\n\x03_id\"\xf2\x01\n\x10PbEntireWorkflow\x12\"\n\x08workflow\x18\x01 \x01(\x0b\x32\x0b.PbWorkflowH\x00\x88\x01\x01\x12\x16\n\x05tasks\x18\x02 \x03(\x0b\x32\x07.PbTask\x12\"\n\x0ctask_proxies\x18\x03 \x03(\x0b\x32\x0c.PbTaskProxy\x12\x14\n\x04jobs\x18\x04 \x03(\x0b\x32\x06.PbJob\x12\x1b\n\x08\x66\x61milies\x18\x05 \x03(\x0b\x32\t.PbFamily\x12&\n\x0e\x66\x61mily_proxies\x18\x06 \x03(\x0b\x32\x0e.PbFamilyProxy\x12\x16\n\x05\x65\x64ges\x18\x07 \x03(\x0b\x32\x07.PbEdgeB\x0b\n\t_workflow\"\xaf\x01\n\x07\x45\x44\x65ltas\x12\x11\n\x04time\x18\x01 \x01(\x01H\x00\x88\x01\x01\x12\x15\n\x08\x63hecksum\x18\x02 \x01(\x03H\x01\x88\x01\x01\x12\x16\n\x05\x61\x64\x64\x65\x64\x18\x03 \x03(\x0b\x32\x07.PbEdge\x12\x18\n\x07updated\x18\x04 \x03(\x0b\x32\x07.PbEdge\x12\x0e\n\x06pruned\x18\x05 \x03(\t\x12\x15\n\x08reloaded\x18\x06 \x01(\x08H\x02\x88\x01\x01\x42\x07\n\x05_timeB\x0b\n\t_checksumB\x0b\n\t_reloaded\"\xb3\x01\n\x07\x46\x44\x65ltas\x12\x11\n\x04time\x18\x01 \x01(\x01H\x00\x88\x01\x01\x12\x15\n\x08\x63hecksum\x18\x02 \x01(\x03H\x01\x88\x01\x01\x12\x18\n\x05\x61\x64\x64\x65\x64\x18\x03 \x03(\x0b\x32\t.PbFamily\x12\x1a\n\x07updated\x18\x04 \x03(\x0b\x32\t.PbFamily\x12\x0e\n\x06pruned\x18\x05 \x03(\t\x12\x15\n\x08reloaded\x18\x06 \x01(\x08H\x02\x88\x01\x01\x42\x07\n\x05_timeB\x0b\n\t_checksumB\x0b\n\t_reloaded\"\xbe\x01\n\x08\x46PDeltas\x12\x11\n\x04time\x18\x01 \x01(\x01H\x00\x88\x01\x01\x12\x15\n\x08\x63hecksum\x18\x02 \x01(\x03H\x01\x88\x01\x01\x12\x1d\n\x05\x61\x64\x64\x65\x64\x18\x03 \x03(\x0b\x32\x0e.PbFamilyProxy\x12\x1f\n\x07updated\x18\x04 \x03(\x0b\x32\x0e.PbFamilyProxy\x12\x0e\n\x06pruned\x18\x05 \x03(\t\x12\x15\n\x08reloaded\x18\x06 \x01(\x08H\x02\x88\x01\x01\x42\x07\n\x05_timeB\x0b\n\t_checksumB\x0b\n\t_reloaded\"\xad\x01\n\x07JDeltas\x12\x11\n\x04time\x18\x01 \x01(\x01H\x00\x88\x01\x01\x12\x15\n\x08\x63hecksum\x18\x02 \x01(\x03H\x01\x88\x01\x01\x12\x15\n\x05\x61\x64\x64\x65\x64\x18\x03 \x03(\x0b\x32\x06.PbJob\x12\x17\n\x07updated\x18\x04 \x03(\x0b\x32\x06.PbJob\x12\x0e\n\x06pruned\x18\x05 \x03(\t\x12\x15\n\x08reloaded\x18\x06 \x01(\x08H\x02\x88\x01\x01\x42\x07\n\x05_timeB\x0b\n\t_checksumB\x0b\n\t_reloaded\"\xaf\x01\n\x07TDeltas\x12\x11\n\x04time\x18\x01 \x01(\x01H\x00\x88\x01\x01\x12\x15\n\x08\x63hecksum\x18\x02 \x01(\x03H\x01\x88\x01\x01\x12\x16\n\x05\x61\x64\x64\x65\x64\x18\x03 \x03(\x0b\x32\x07.PbTask\x12\x18\n\x07updated\x18\x04 \x03(\x0b\x32\x07.PbTask\x12\x0e\n\x06pruned\x18\x05 \x03(\t\x12\x15\n\x08reloaded\x18\x06 \x01(\x08H\x02\x88\x01\x01\x42\x07\n\x05_timeB\x0b\n\t_checksumB\x0b\n\t_reloaded\"\xba\x01\n\x08TPDeltas\x12\x11\n\x04time\x18\x01 \x01(\x01H\x00\x88\x01\x01\x12\x15\n\x08\x63hecksum\x18\x02 \x01(\x03H\x01\x88\x01\x01\x12\x1b\n\x05\x61\x64\x64\x65\x64\x18\x03 \x03(\x0b\x32\x0c.PbTaskProxy\x12\x1d\n\x07updated\x18\x04 \x03(\x0b\x32\x0c.PbTaskProxy\x12\x0e\n\x06pruned\x18\x05 \x03(\t\x12\x15\n\x08reloaded\x18\x06 \x01(\x08H\x02\x88\x01\x01\x42\x07\n\x05_timeB\x0b\n\t_checksumB\x0b\n\t_reloaded\"\xc3\x01\n\x07WDeltas\x12\x11\n\x04time\x18\x01 \x01(\x01H\x00\x88\x01\x01\x12\x1f\n\x05\x61\x64\x64\x65\x64\x18\x02 \x01(\x0b\x32\x0b.PbWorkflowH\x01\x88\x01\x01\x12!\n\x07updated\x18\x03 \x01(\x0b\x32\x0b.PbWorkflowH\x02\x88\x01\x01\x12\x15\n\x08reloaded\x18\x04 \x01(\x08H\x03\x88\x01\x01\x12\x13\n\x06pruned\x18\x05 \x01(\tH\x04\x88\x01\x01\x42\x07\n\x05_timeB\x08\n\x06_addedB\n\n\x08_updatedB\x0b\n\t_reloadedB\t\n\x07_pruned\"\xd1\x01\n\tAllDeltas\x12\x1a\n\x08\x66\x61milies\x18\x01 \x01(\x0b\x32\x08.FDeltas\x12!\n\x0e\x66\x61mily_proxies\x18\x02 \x01(\x0b\x32\t.FPDeltas\x12\x16\n\x04jobs\x18\x03 \x01(\x0b\x32\x08.JDeltas\x12\x17\n\x05tasks\x18\x04 \x01(\x0b\x32\x08.TDeltas\x12\x1f\n\x0ctask_proxies\x18\x05 \x01(\x0b\x32\t.TPDeltas\x12\x17\n\x05\x65\x64ges\x18\x06 \x01(\x0b\x32\x08.EDeltas\x12\x1a\n\x08workflow\x18\x07 \x01(\x0b\x32\x08.WDeltasb\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) @@ -46,55 +47,55 @@ _globals['_PBWORKFLOW_LATESTSTATETASKSENTRY']._serialized_start=1493 _globals['_PBWORKFLOW_LATESTSTATETASKSENTRY']._serialized_end=1566 _globals['_PBRUNTIME']._serialized_start=2014 - _globals['_PBRUNTIME']._serialized_end=2839 - _globals['_PBJOB']._serialized_start=2842 - _globals['_PBJOB']._serialized_end=3511 - _globals['_PBTASK']._serialized_start=3514 - _globals['_PBTASK']._serialized_end=3868 - _globals['_PBPOLLTASK']._serialized_start=3871 - _globals['_PBPOLLTASK']._serialized_end=4087 - _globals['_PBCONDITION']._serialized_start=4090 - _globals['_PBCONDITION']._serialized_end=4293 - _globals['_PBPREREQUISITE']._serialized_start=4296 - _globals['_PBPREREQUISITE']._serialized_end=4446 - _globals['_PBOUTPUT']._serialized_start=4449 - _globals['_PBOUTPUT']._serialized_end=4589 - _globals['_PBTRIGGER']._serialized_start=4592 - _globals['_PBTRIGGER']._serialized_end=4757 - _globals['_PBTASKPROXY']._serialized_start=4760 - _globals['_PBTASKPROXY']._serialized_end=5801 - _globals['_PBTASKPROXY_OUTPUTSENTRY']._serialized_start=5411 - _globals['_PBTASKPROXY_OUTPUTSENTRY']._serialized_end=5468 - _globals['_PBTASKPROXY_EXTERNALTRIGGERSENTRY']._serialized_start=5470 - _globals['_PBTASKPROXY_EXTERNALTRIGGERSENTRY']._serialized_end=5537 - _globals['_PBTASKPROXY_XTRIGGERSENTRY']._serialized_start=5539 - _globals['_PBTASKPROXY_XTRIGGERSENTRY']._serialized_end=5599 - _globals['_PBFAMILY']._serialized_start=5804 - _globals['_PBFAMILY']._serialized_end=6132 - _globals['_PBFAMILYPROXY']._serialized_start=6135 - _globals['_PBFAMILYPROXY']._serialized_end=6949 + _globals['_PBRUNTIME']._serialized_end=2879 + _globals['_PBJOB']._serialized_start=2882 + _globals['_PBJOB']._serialized_end=3551 + _globals['_PBTASK']._serialized_start=3554 + _globals['_PBTASK']._serialized_end=3908 + _globals['_PBPOLLTASK']._serialized_start=3911 + _globals['_PBPOLLTASK']._serialized_end=4127 + _globals['_PBCONDITION']._serialized_start=4130 + _globals['_PBCONDITION']._serialized_end=4333 + _globals['_PBPREREQUISITE']._serialized_start=4336 + _globals['_PBPREREQUISITE']._serialized_end=4486 + _globals['_PBOUTPUT']._serialized_start=4489 + _globals['_PBOUTPUT']._serialized_end=4629 + _globals['_PBTRIGGER']._serialized_start=4632 + _globals['_PBTRIGGER']._serialized_end=4797 + _globals['_PBTASKPROXY']._serialized_start=4800 + _globals['_PBTASKPROXY']._serialized_end=5841 + _globals['_PBTASKPROXY_OUTPUTSENTRY']._serialized_start=5451 + _globals['_PBTASKPROXY_OUTPUTSENTRY']._serialized_end=5508 + _globals['_PBTASKPROXY_EXTERNALTRIGGERSENTRY']._serialized_start=5510 + _globals['_PBTASKPROXY_EXTERNALTRIGGERSENTRY']._serialized_end=5577 + _globals['_PBTASKPROXY_XTRIGGERSENTRY']._serialized_start=5579 + _globals['_PBTASKPROXY_XTRIGGERSENTRY']._serialized_end=5639 + _globals['_PBFAMILY']._serialized_start=5844 + _globals['_PBFAMILY']._serialized_end=6172 + _globals['_PBFAMILYPROXY']._serialized_start=6175 + _globals['_PBFAMILYPROXY']._serialized_end=6989 _globals['_PBFAMILYPROXY_STATETOTALSENTRY']._serialized_start=1441 _globals['_PBFAMILYPROXY_STATETOTALSENTRY']._serialized_end=1491 - _globals['_PBEDGE']._serialized_start=6952 - _globals['_PBEDGE']._serialized_end=7140 - _globals['_PBEDGES']._serialized_start=7142 - _globals['_PBEDGES']._serialized_end=7265 - _globals['_PBENTIREWORKFLOW']._serialized_start=7268 - _globals['_PBENTIREWORKFLOW']._serialized_end=7510 - _globals['_EDELTAS']._serialized_start=7513 - _globals['_EDELTAS']._serialized_end=7688 - _globals['_FDELTAS']._serialized_start=7691 - _globals['_FDELTAS']._serialized_end=7870 - _globals['_FPDELTAS']._serialized_start=7873 - _globals['_FPDELTAS']._serialized_end=8063 - _globals['_JDELTAS']._serialized_start=8066 - _globals['_JDELTAS']._serialized_end=8239 - _globals['_TDELTAS']._serialized_start=8242 - _globals['_TDELTAS']._serialized_end=8417 - _globals['_TPDELTAS']._serialized_start=8420 - _globals['_TPDELTAS']._serialized_end=8606 - _globals['_WDELTAS']._serialized_start=8609 - _globals['_WDELTAS']._serialized_end=8804 - _globals['_ALLDELTAS']._serialized_start=8807 - _globals['_ALLDELTAS']._serialized_end=9016 + _globals['_PBEDGE']._serialized_start=6992 + _globals['_PBEDGE']._serialized_end=7180 + _globals['_PBEDGES']._serialized_start=7182 + _globals['_PBEDGES']._serialized_end=7305 + _globals['_PBENTIREWORKFLOW']._serialized_start=7308 + _globals['_PBENTIREWORKFLOW']._serialized_end=7550 + _globals['_EDELTAS']._serialized_start=7553 + _globals['_EDELTAS']._serialized_end=7728 + _globals['_FDELTAS']._serialized_start=7731 + _globals['_FDELTAS']._serialized_end=7910 + _globals['_FPDELTAS']._serialized_start=7913 + _globals['_FPDELTAS']._serialized_end=8103 + _globals['_JDELTAS']._serialized_start=8106 + _globals['_JDELTAS']._serialized_end=8279 + _globals['_TDELTAS']._serialized_start=8282 + _globals['_TDELTAS']._serialized_end=8457 + _globals['_TPDELTAS']._serialized_start=8460 + _globals['_TPDELTAS']._serialized_end=8646 + _globals['_WDELTAS']._serialized_start=8649 + _globals['_WDELTAS']._serialized_end=8844 + _globals['_ALLDELTAS']._serialized_start=8847 + _globals['_ALLDELTAS']._serialized_end=9056 # @@protoc_insertion_point(module_scope) diff --git a/cylc/flow/data_store_mgr.py b/cylc/flow/data_store_mgr.py index 744daeb4dda..f49c5bd9eaa 100644 --- a/cylc/flow/data_store_mgr.py +++ b/cylc/flow/data_store_mgr.py @@ -247,6 +247,7 @@ def runtime_from_config(rtconfig): return PbRuntime( platform=platform, script=rtconfig['script'], + completion=rtconfig['completion'], init_script=rtconfig['init-script'], env_script=rtconfig['env-script'], err_script=rtconfig['err-script'], @@ -1440,7 +1441,7 @@ def apply_task_proxy_db_history(self): ) ): for message in json.loads(outputs_str): - itask.state.outputs.set_completion(message, True) + itask.state.outputs.set_message_complete(message) # Gather tasks with flow id. prereq_ids.add(f'{relative_id}/{flow_nums_str}') @@ -1502,7 +1503,7 @@ def _process_internal_task_proxy(self, itask, tproxy): del tproxy.prerequisites[:] tproxy.prerequisites.extend(prereq_list) - for label, message, satisfied in itask.state.outputs.get_all(): + for label, message, satisfied in itask.state.outputs: output = tproxy.outputs[label] output.label = label output.message = message @@ -2393,10 +2394,8 @@ def delta_task_output( tp_id, tproxy = self.store_node_fetcher(itask.tokens) if not tproxy: return - item = itask.state.outputs.get_item(message) - if item is None: - return - label, _, satisfied = item + outputs = itask.state.outputs + label = outputs.get_trigger(message) # update task instance update_time = time() tp_delta = self.updated[TASK_PROXIES].setdefault( @@ -2405,7 +2404,7 @@ def delta_task_output( output = tp_delta.outputs[label] output.label = label output.message = message - output.satisfied = satisfied + output.satisfied = outputs.is_message_complete(message) output.time = update_time self.updates_pending = True @@ -2425,9 +2424,10 @@ def delta_task_outputs(self, itask: TaskProxy) -> None: tp_delta = self.updated[TASK_PROXIES].setdefault( tp_id, PbTaskProxy(id=tp_id)) tp_delta.stamp = f'{tp_id}@{update_time}' - for label, _, satisfied in itask.state.outputs.get_all(): - output = tp_delta.outputs[label] - output.label = label + for trigger, message, satisfied in itask.state.outputs: + output = tp_delta.outputs[trigger] + output.label = trigger + output.message = message output.satisfied = satisfied output.time = update_time diff --git a/cylc/flow/exceptions.py b/cylc/flow/exceptions.py index 0800a914888..9881631484b 100644 --- a/cylc/flow/exceptions.py +++ b/cylc/flow/exceptions.py @@ -487,3 +487,16 @@ def __str__(self): ) else: return "Installed workflow is not compatible with Cylc 8." + + +class InvalidCompletionExpression(CylcError): + """For the [runtime][]completion configuration. + + Raised when non-whitelisted syntax is present. + """ + def __init__(self, message, expr=None): + self.message = message + self.expr = expr + + def __str__(self): + return self.message diff --git a/cylc/flow/graph_parser.py b/cylc/flow/graph_parser.py index 16334fc2a9a..64dcdecaf6f 100644 --- a/cylc/flow/graph_parser.py +++ b/cylc/flow/graph_parser.py @@ -760,9 +760,14 @@ def _set_output_opt( if suicide: return - if output == TASK_OUTPUT_EXPIRED and not optional: - raise GraphParseError( - f"Expired-output {name}:{output} must be optional") + if ( + output in {TASK_OUTPUT_EXPIRED, TASK_OUTPUT_SUBMIT_FAILED} + and not optional + ): + # ":expire" and ":submit-fail" cannot be required + # proposal point 4: + # https://cylc.github.io/cylc-admin/proposal-optional-output-extension.html#proposal + raise GraphParseError(f"{name}:{output} must be optional") if output == TASK_OUTPUT_FINISHED: # Interpret :finish pseudo-output diff --git a/cylc/flow/loggingutil.py b/cylc/flow/loggingutil.py index 35884729ea0..3d77bdcb037 100644 --- a/cylc/flow/loggingutil.py +++ b/cylc/flow/loggingutil.py @@ -33,7 +33,7 @@ import textwrap from typing import List, Optional, Union -from ansimarkup import parse as cparse +from ansimarkup import parse as cparse, strip as cstrip from cylc.flow.cfgspec.glbl_cfg import glbl_cfg from cylc.flow.wallclock import get_time_string_from_unix_time @@ -53,10 +53,10 @@ class CylcLogFormatter(logging.Formatter): """ COLORS = { - 'CRITICAL': cparse('{0}'), - 'ERROR': cparse('{0}'), - 'WARNING': cparse('{0}'), - 'DEBUG': cparse('{0}') + 'CRITICAL': '{0}', + 'ERROR': '{0}', + 'WARNING': '{0}', + 'DEBUG': '{0}' } # default hard-coded max width for log entries @@ -99,7 +99,9 @@ def format(self, record): # noqa: A003 (method name not local) if not self.timestamp: _, text = text.split(' ', 1) # ISO8601 time points have no spaces if self.color and record.levelname in self.COLORS: - text = self.COLORS[record.levelname].format(text) + text = cparse(self.COLORS[record.levelname].format(text)) + elif not self.color: + text = cstrip(text) if self.max_width: return '\n'.join( line @@ -329,7 +331,7 @@ def _filter(self, record): def re_formatter(log_string): """Read in an uncoloured log_string file and apply colour formatting.""" for sub, repl in LOG_LEVEL_REGEXES: - log_string = sub.sub(repl, log_string) + log_string = cparse(sub.sub(repl, log_string)) return log_string diff --git a/cylc/flow/network/schema.py b/cylc/flow/network/schema.py index a1ae2ea26f7..8da9f79f10e 100644 --- a/cylc/flow/network/schema.py +++ b/cylc/flow/network/schema.py @@ -20,7 +20,6 @@ from functools import partial import json from operator import attrgetter -from textwrap import dedent from typing import ( TYPE_CHECKING, AsyncGenerator, @@ -39,6 +38,10 @@ from cylc.flow import LOG_LEVELS from cylc.flow.broadcast_mgr import ALL_CYCLE_POINTS_STRS, addict +from cylc.flow.data_store_mgr import ( + FAMILIES, FAMILY_PROXIES, JOBS, TASKS, TASK_PROXIES, + DELTA_ADDED, DELTA_UPDATED +) from cylc.flow.flow_mgr import FLOW_ALL, FLOW_NEW, FLOW_NONE from cylc.flow.id import Tokens from cylc.flow.task_outputs import SORT_ORDERS @@ -54,10 +57,7 @@ TASK_STATUS_FAILED, TASK_STATUS_SUCCEEDED ) -from cylc.flow.data_store_mgr import ( - FAMILIES, FAMILY_PROXIES, JOBS, TASKS, TASK_PROXIES, - DELTA_ADDED, DELTA_UPDATED -) +from cylc.flow.util import sstrip from cylc.flow.workflow_status import StopMode if TYPE_CHECKING: @@ -65,23 +65,6 @@ from cylc.flow.network.resolvers import BaseResolvers -def sstrip(text): - """Simple function to dedent and strip text. - - Examples: - >>> print(sstrip(''' - ... foo - ... bar - ... baz - ... ''')) - foo - bar - baz - - """ - return dedent(text).strip() - - def sort_elements(elements, args): """Sort iterable of elements by given attribute.""" sort_args = args.get('sort') @@ -807,6 +790,7 @@ class Meta: """) platform = String(default_value=None) script = String(default_value=None) + completion = String(default_value=None) init_script = String(default_value=None) env_script = String(default_value=None) err_script = String(default_value=None) diff --git a/cylc/flow/scripts/show.py b/cylc/flow/scripts/show.py index dae42f637b8..b146f339e10 100755 --- a/cylc/flow/scripts/show.py +++ b/cylc/flow/scripts/show.py @@ -40,6 +40,7 @@ import re import json import sys +from textwrap import indent from typing import Any, Dict, TYPE_CHECKING from ansimarkup import ansiprint @@ -51,6 +52,7 @@ from cylc.flow.id import Tokens from cylc.flow.id_cli import parse_ids from cylc.flow.network.client_factory import get_client +from cylc.flow.task_outputs import TaskOutputs from cylc.flow.task_state import ( TASK_STATUSES_ORDERED, TASK_STATUS_RUNNING @@ -60,6 +62,7 @@ ID_MULTI_ARG_DOC, ) from cylc.flow.terminal import cli_function +from cylc.flow.util import BOOL_SYMBOLS if TYPE_CHECKING: @@ -135,16 +138,39 @@ label satisfied } + runtime { + completion + } } } ''' +SATISFIED = BOOL_SYMBOLS[True] +UNSATISFIED = BOOL_SYMBOLS[False] + + def print_msg_state(msg, state): if state: - ansiprint(f' + {msg}') + ansiprint(f' {SATISFIED} {msg}') else: - ansiprint(f' - {msg}') + ansiprint(f' {UNSATISFIED} {msg}') + + +def print_completion_state(t_proxy): + # create task outputs object + outputs = TaskOutputs(t_proxy["runtime"]["completion"]) + + for output in t_proxy['outputs']: + outputs.add(output['label'], output['message']) + if output['satisfied']: + outputs.set_message_complete(output['message']) + + ansiprint( + f'output completion:' + f' {"complete" if outputs.is_complete() else "incomplete"}' + f'\n{indent(outputs.format_completion_status(ansimarkup=2), " ")}' + ) def flatten_data(data, flat_data=None): @@ -316,14 +342,16 @@ async def prereqs_and_outputs_query( ansiprint(f"{pre_txt} (n/a for past tasks)") else: ansiprint( - f"{pre_txt} ('-': not satisfied)") + f"{pre_txt}" + f" ('{UNSATISFIED}': not satisfied)" + ) for _, prefix, msg, state in prereqs: print_msg_state(f'{prefix}{msg}', state) # outputs ansiprint( 'outputs:' - " ('-': not completed)") + f" ('{UNSATISFIED}': not completed)") if not t_proxy['outputs']: # (Not possible - standard outputs) print(' (None)') for output in t_proxy['outputs']: @@ -334,7 +362,9 @@ async def prereqs_and_outputs_query( or t_proxy['xtriggers'] ): ansiprint( - "other: ('-': not satisfied)") + "other:" + f" ('{UNSATISFIED}': not satisfied)" + ) for ext_trig in t_proxy['externalTriggers']: state = ext_trig['satisfied'] print_msg_state( @@ -346,6 +376,9 @@ async def prereqs_and_outputs_query( print_msg_state( f'xtrigger "{xtrig["label"]} = {label}"', state) + + print_completion_state(t_proxy) + if not results['taskProxies']: ansiprint( f"No matching active tasks found: {', '.join(ids_list)}", diff --git a/cylc/flow/task_events_mgr.py b/cylc/flow/task_events_mgr.py index 14f376dfb74..733faec1e94 100644 --- a/cylc/flow/task_events_mgr.py +++ b/cylc/flow/task_events_mgr.py @@ -661,6 +661,7 @@ def process_message( True: if polling is required to confirm a reversal of status. """ + # Log messages if event_time is None: event_time = get_current_time_string() @@ -696,10 +697,9 @@ def process_message( if message.startswith(ABORT_MESSAGE_PREFIX): msg0 = TASK_OUTPUT_FAILED - completed_output = None + completed_output: Optional[bool] = False if msg0 not in [TASK_OUTPUT_SUBMIT_FAILED, TASK_OUTPUT_FAILED]: - completed_output = itask.state.outputs.set_msg_trg_completion( - message=msg0, is_completed=True) + completed_output = itask.state.outputs.set_message_complete(msg0) if completed_output: self.data_store_mgr.delta_task_output(itask, msg0) @@ -832,8 +832,9 @@ def process_message( # Message of a custom task output. # No state change. # Log completion of o (not needed for standard outputs) - LOG.info(f"[{itask}] completed output {completed_output}") - self.setup_event_handlers(itask, completed_output, message) + trigger = itask.state.outputs.get_trigger(message) + LOG.info(f"[{itask}] completed output {trigger}") + self.setup_event_handlers(itask, trigger, message) self.spawn_children(itask, msg0) else: @@ -1315,8 +1316,7 @@ def _process_message_failed(self, itask, event_time, message, forced): if itask.state_reset(TASK_STATUS_FAILED, forced=forced): self.setup_event_handlers(itask, self.EVENT_FAILED, message) self.data_store_mgr.delta_task_state(itask) - itask.state.outputs.set_msg_trg_completion( - message=TASK_OUTPUT_FAILED, is_completed=True) + itask.state.outputs.set_message_complete(TASK_OUTPUT_FAILED) self.data_store_mgr.delta_task_output( itask, TASK_OUTPUT_FAILED) self.data_store_mgr.delta_task_state(itask) @@ -1417,8 +1417,9 @@ def _process_message_submit_failed( self.setup_event_handlers( itask, self.EVENT_SUBMIT_FAILED, f'job {self.EVENT_SUBMIT_FAILED}') - itask.state.outputs.set_msg_trg_completion( - message=TASK_OUTPUT_SUBMIT_FAILED, is_completed=True) + itask.state.outputs.set_message_complete( + TASK_OUTPUT_SUBMIT_FAILED + ) self.data_store_mgr.delta_task_output( itask, TASK_OUTPUT_SUBMIT_FAILED) self.data_store_mgr.delta_task_state(itask) @@ -1462,7 +1463,7 @@ def _process_message_submitted( itask.set_summary_time('started', event_time) if itask.state_reset(TASK_STATUS_RUNNING, forced=forced): self.data_store_mgr.delta_task_state(itask) - itask.state.outputs.set_completion(TASK_OUTPUT_STARTED, True) + itask.state.outputs.set_message_complete(TASK_OUTPUT_STARTED) self.data_store_mgr.delta_task_output(itask, TASK_OUTPUT_STARTED) else: diff --git a/cylc/flow/task_outputs.py b/cylc/flow/task_outputs.py index 644b7b0dd3c..66e56a5c44b 100644 --- a/cylc/flow/task_outputs.py +++ b/cylc/flow/task_outputs.py @@ -15,7 +15,29 @@ # along with this program. If not, see . """Task output message manager and constants.""" -from typing import List +import ast +import re +from typing import ( + Dict, + Iterable, + Iterator, + List, + Optional, + TYPE_CHECKING, + Tuple, + Union, +) + +from cylc.flow.exceptions import InvalidCompletionExpression +from cylc.flow.util import ( + BOOL_SYMBOLS, + get_variable_names, + restricted_evaluator, +) + +if TYPE_CHECKING: + from cylc.flow.taskdef import TaskDef + # Standard task output strings, used for triggering. TASK_OUTPUT_EXPIRED = "expired" @@ -32,7 +54,8 @@ TASK_OUTPUT_SUBMIT_FAILED, TASK_OUTPUT_STARTED, TASK_OUTPUT_SUCCEEDED, - TASK_OUTPUT_FAILED) + TASK_OUTPUT_FAILED, +) TASK_OUTPUTS = ( TASK_OUTPUT_EXPIRED, @@ -44,15 +67,204 @@ TASK_OUTPUT_FINISHED, ) -_TRIGGER = 0 -_MESSAGE = 1 -_IS_COMPLETED = 2 +# this evaluates task completion expressions +CompletionEvaluator = restricted_evaluator( + # expressions + ast.Expression, + # variables + ast.Name, ast.Load, + # operations + ast.BoolOp, ast.And, ast.Or, ast.BinOp, + error_class=InvalidCompletionExpression, +) + +# regex for splitting expressions into individual parts for formatting +RE_EXPR_SPLIT = re.compile(r'([\(\) ])') + + +def trigger_to_completion_variable(output: str) -> str: + """Turn a trigger into something that can be used in an expression. + + Examples: + >>> trigger_to_completion_variable('succeeded') + 'succeeded' + >>> trigger_to_completion_variable('submit-failed') + 'submit_failed' + + """ + return output.replace('-', '_') + + +def get_trigger_completion_variable_maps(triggers: Iterable[str]): + """Return a bi-map of trigger to completion variable. + + Args: + triggers: All triggers for a task. + + Returns: + (trigger_to_completion_variable, completion_variable_to_trigger) + + Tuple of mappings for converting in either direction. + + """ + _trigger_to_completion_variable = {} + _completion_variable_to_trigger = {} + for trigger in triggers: + compvar = trigger_to_completion_variable(trigger) + _trigger_to_completion_variable[trigger] = compvar + _completion_variable_to_trigger[compvar] = trigger + + return ( + _trigger_to_completion_variable, + _completion_variable_to_trigger, + ) + + +def get_completion_expression(tdef: 'TaskDef') -> str: + """Return a completion expression for this task definition. + + If there is *not* a user provided completion statement: + + 1. Create a completion expression that ensures all required ouputs are + completed. + 2. If success is optional add "or succeeded or failed" onto the end. + 3. If submission is optional add "or submit-failed" onto the end of it. + 4. If expiry is optional add "or expired" onto the end of it. + """ + # check if there is a user-configured completion expression + completion = tdef.rtconfig.get('completion') + if completion: + # completion expression is defined in the runtime -> return it + return completion + + # (1) start with an expression that ensures all required outputs are + # generated (if the task runs) + required = { + trigger_to_completion_variable(trigger) + for trigger, (_message, required) in tdef.outputs.items() + if required + } + parts = [] + if required: + _part = ' and '.join(sorted(required)) + if len(required) > 1: + # wrap the expression in brackets for clarity + parts.append(f'({_part})') + else: + parts.append(_part) + + # (2) handle optional success + if ( + tdef.outputs[TASK_OUTPUT_SUCCEEDED][1] is False + or tdef.outputs[TASK_OUTPUT_FAILED][1] is False + ): + # failure is tolerated -> ensure the task succeeds OR fails + if required: + # required outputs are required only if the task actually runs + parts = [ + f'({parts[0]} and {TASK_OUTPUT_SUCCEEDED})' + f' or {TASK_OUTPUT_FAILED}' + ] + else: + parts.append( + f'{TASK_OUTPUT_SUCCEEDED} or {TASK_OUTPUT_FAILED}' + ) + + # (3) handle optional submission + if ( + tdef.outputs[TASK_OUTPUT_SUBMITTED][1] is False + or tdef.outputs[TASK_OUTPUT_SUBMIT_FAILED][1] is False + ): + # submit-fail tolerated -> ensure the task executes OR submit-fails + parts.append( + trigger_to_completion_variable(TASK_OUTPUT_SUBMIT_FAILED) + ) + + # (4) handle optional expiry + if tdef.outputs[TASK_OUTPUT_EXPIRED][1] is False: + # expiry tolerated -> ensure the task executes OR expires + parts.append(TASK_OUTPUT_EXPIRED) + + return ' or '.join(parts) + + +def get_optional_outputs( + expression: str, + outputs: Iterable[str], +) -> Dict[str, Optional[bool]]: + """Determine which outputs in an expression are optional. + + Args: + expression: + The completion expression. + outputs: + All outputs that apply to this task. + + Returns: + dict: compvar: is_optional + + compvar: + The completion variable, i.e. the trigger as used in the completion + expression. + is_optional: + * True if var is optional. + * False if var is required. + * None if var is not referenced. + + Examples: + >>> sorted(get_optional_outputs( + ... '(succeeded and (x or y)) or failed', + ... {'succeeded', 'x', 'y', 'failed', 'expired'} + ... ).items()) + [('expired', None), ('failed', True), ('succeeded', True), + ('x', True), ('y', True)] + + >>> sorted(get_optional_outputs( + ... '(succeeded and x and y) or expired', + ... {'succeeded', 'x', 'y', 'failed', 'expired'} + ... ).items()) + [('expired', True), ('failed', None), ('succeeded', False), + ('x', False), ('y', False)] + + """ + # determine which triggers are used in the expression + used_compvars = get_variable_names(expression) + + # all completion variables which could appear in the expression + all_compvars = {trigger_to_completion_variable(out) for out in outputs} + + return { # output: is_optional + # the outputs that are used in the expression + **{ + output: CompletionEvaluator( + expression, + **{ + **{out: out != output for out in all_compvars}, + # don't consider pre-execution conditions as optional + # (pre-conditions are considered separately) + 'expired': False, + 'submit_failed': False, + }, + ) + for output in used_compvars + }, + # the outputs that are not used in the expression + **{ + output: None + for output in all_compvars - used_compvars + }, + } class TaskOutputs: - """Task output message manager. + """Represents a collection of outputs for a task. + + Task outputs have a trigger and a message: + * The trigger is used in the graph and with "cylc set". + * Messages map onto triggers and are used with "cylc message", they can + provide additional context to an output which will appear in the workflow + log. - Manage standard task outputs and custom outputs, e.g.: [scheduling] [[graph]] R1 = t1:trigger1 => t2 @@ -61,209 +273,139 @@ class TaskOutputs: [[[outputs]]] trigger1 = message 1 - Can search item by message string or by trigger string. - """ + Args: + tdef: + The task definition for the task these outputs represent. - # Memory optimization - constrain possible attributes to this list. - __slots__ = ["_by_message", "_by_trigger", "_required"] - - def __init__(self, tdef): - self._by_message = {} - self._by_trigger = {} - self._required = {} # trigger: message - - # Add outputs from task def. - for trigger, (message, required) in tdef.outputs.items(): - self._add(message, trigger, required=required) - - # Handle implicit submit requirement - if ( - # "submitted" is not declared as optional/required - tdef.outputs[TASK_OUTPUT_SUBMITTED][1] is None - # and "submit-failed" is not declared as optional/required - and tdef.outputs[TASK_OUTPUT_SUBMIT_FAILED][1] is None - ): - self._add( - TASK_OUTPUT_SUBMITTED, - TASK_OUTPUT_SUBMITTED, - required=True, - ) + For use outside of the scheduler, this argument can be completion + expression string. - def _add(self, message, trigger, is_completed=False, required=False): - """Add a new output message""" - self._by_message[message] = [trigger, message, is_completed] - self._by_trigger[trigger] = self._by_message[message] - if required: - self._required[trigger] = message - - def set_completed_by_msg(self, message): - """For flow trigger --wait: set completed outputs from the DB.""" - for trig, msg, _ in self._by_trigger.values(): - if message == msg: - self._add(message, trig, True, trig in self._required) - break - - def all_completed(self): - """Return True if all all outputs completed.""" - return all(val[_IS_COMPLETED] for val in self._by_message.values()) - - def exists(self, message=None, trigger=None): - """Return True if message/trigger is identified as an output.""" - try: - return self._get_item(message, trigger) is not None - except KeyError: - return False - - def get_all(self): - """Return an iterator for all output messages.""" - return sorted(self._by_message.values(), key=self.msg_sort_key) - - def get_completed(self): - """Return all completed output messages.""" - ret = [] - for value in self.get_all(): - if value[_IS_COMPLETED]: - ret.append(value[_MESSAGE]) - return ret - - def get_completed_all(self): - """Return all completed outputs. - - Return a list in this form: [(trigger1, message1), ...] - """ - ret = [] - for value in self.get_all(): - if value[_IS_COMPLETED]: - ret.append((value[_TRIGGER], value[_MESSAGE])) - return ret - - def has_custom_triggers(self): - """Return True if it has any custom triggers.""" - return any(key not in SORT_ORDERS for key in self._by_trigger) - - def _get_custom_triggers(self, required: bool = False) -> List[str]: - """Return list of all, or required, custom trigger messages.""" - custom = [ - out[1] for trg, out in self._by_trigger.items() - if trg not in SORT_ORDERS - ] - if required: - custom = [out for out in custom if out in self._required.values()] - return custom - - def get_not_completed(self): - """Return all not-completed output messages.""" - ret = [] - for value in self.get_all(): - if not value[_IS_COMPLETED]: - ret.append(value[_MESSAGE]) - return ret - - def is_completed(self, message=None, trigger=None): - """Return True if output of message is completed.""" - try: - return self._get_item(message, trigger)[_IS_COMPLETED] - except KeyError: - return False - - def remove(self, message=None, trigger=None): - """Remove an output by message, if it exists.""" - try: - trigger, message = self._get_item(message, trigger)[:2] - except KeyError: - pass - else: - del self._by_message[message] - del self._by_trigger[trigger] + """ - def set_all_completed(self): - """Set all outputs to complete.""" - for value in self._by_message.values(): - value[_IS_COMPLETED] = True + __slots__ = ( + "_message_to_trigger", + "_message_to_compvar", + "_completed", + "_completion_expression", + ) + + _message_to_trigger: Dict[str, str] # message: trigger + _message_to_compvar: Dict[str, str] # message: completion variable + _completed: Dict[str, bool] # message: is_complete + _completion_expression: str + + def __init__(self, tdef: 'Union[TaskDef, str]'): + self._message_to_trigger = {} + self._message_to_compvar = {} + self._completed = {} + + if isinstance(tdef, str): + # abnormal use e.g. from the "cylc show" command + self._completion_expression = tdef + else: + # normal use e.g. from within the scheduler + self._completion_expression = get_completion_expression(tdef) + for trigger, (message, _required) in tdef.outputs.items(): + self.add(trigger, message) + + def add(self, trigger: str, message: str) -> None: + """Register a new output. + + Note, normally outputs are listed automatically from the provided + TaskDef so there is no need to call this interface. It exists for cases + where TaskOutputs are used outside of the scheduler where there is no + TaskDef object handy so outputs must be listed manually. + """ + self._message_to_trigger[message] = trigger + self._message_to_compvar[message] = trigger_to_completion_variable( + trigger + ) + self._completed[message] = False - def set_all_incomplete(self): - """Set all outputs to incomplete.""" - for value in self._by_message.values(): - value[_IS_COMPLETED] = False + def get_trigger(self, message: str) -> str: + """Return the trigger associated with this message.""" + return self._message_to_trigger[message] - def set_completion(self, message, is_completed): - """Set output message completion status to is_completed (bool).""" - if message in self._by_message: - self._by_message[message][_IS_COMPLETED] = is_completed + def set_message_complete(self, message: str) -> Optional[bool]: + """Set the provided task message as complete. - def set_msg_trg_completion(self, message=None, trigger=None, - is_completed=True): - """Set the output identified by message/trigger to is_completed. + Args: + message: + The task output message to satisfy. - Return: - - Value of trigger (True) if completion flag is changed, - - False if completion is unchanged, or - - None if message/trigger is not found. + Returns: + True: + If the output was unset before. + False: + If the output was already set. + None + If the output does not apply. """ - try: - item = self._get_item(message, trigger) - old_is_completed = item[_IS_COMPLETED] - item[_IS_COMPLETED] = is_completed - except KeyError: + if message not in self._completed: + # no matching output return None - else: - if bool(old_is_completed) == bool(is_completed): - return False - else: - return item[_TRIGGER] - - def is_incomplete(self): - """Return True if any required outputs are not complete.""" - return any( - not completed - and trigger in self._required - for trigger, (_, _, completed) in self._by_trigger.items() - ) - - def get_incomplete(self): - """Return a list of required outputs that are not complete. - A task is incomplete if: + if self._completed[message] is False: + # output was incomplete + self._completed[message] = True + return True - * it finished executing without completing all required outputs - * or if job submission failed and the :submit output was not optional + # output was already completed + return False - https://github.com/cylc/cylc-admin/blob/master/docs/proposal-new-output-syntax.md#output-syntax + def is_message_complete(self, message: str) -> Optional[bool]: + """Return True if this message is complete. + Returns: + * True if the message is complete. + * False if the message is not complete. + * None if the message does not apply to these outputs. """ - return [ - trigger - for trigger, (_, _, is_completed) in self._by_trigger.items() - if not is_completed and trigger in self._required - ] + if message in self._completed: + return self._completed[message] + return None - def get_item(self, message): - """Return output item by message. + def iter_completed_messages(self) -> Iterator[str]: + """A generator that yields completed messages. - Args: - message (str): Output message. - - Returns: - item (tuple): - label (str), message (str), satisfied (bool) + Yields: + message: A completed task message. """ - if message in self._by_message: - return self._by_message[message] + for message, is_completed in self._completed.items(): + if is_completed: + yield message - def _get_item(self, message, trigger): - """Return self._by_trigger[trigger] or self._by_message[message]. + def __iter__(self) -> Iterator[Tuple[str, str, bool]]: + """A generator that yields all outputs. + + Yields: + (trigger, message, is_complete) + + trigger: + The output trigger. + message: + The output message. + is_complete: + True if the output is complete, else False. - whichever is relevant. """ - if message is None: - return self._by_trigger[trigger] - else: - return self._by_message[message] + for message, is_complete in self._completed.items(): + yield self._message_to_trigger[message], message, is_complete + + def is_complete(self) -> bool: + """Return True if the outputs are complete.""" + return CompletionEvaluator( + self._completion_expression, + **{ + self._message_to_compvar[message]: completed + for message, completed in self._completed.items() + }, + ) - def get_incomplete_implied(self, output: str) -> List[str]: - """Return an ordered list of incomplete implied outputs. + def get_incomplete_implied(self, message: str) -> List[str]: + """Return an ordered list of incomplete implied messages. Use to determined implied outputs to complete automatically. @@ -276,41 +418,156 @@ def get_incomplete_implied(self, output: str) -> List[str]: """ implied: List[str] = [] - if output in [TASK_OUTPUT_SUCCEEDED, TASK_OUTPUT_FAILED]: + if message in [TASK_OUTPUT_SUCCEEDED, TASK_OUTPUT_FAILED]: # Finished, so it must have submitted and started. implied = [TASK_OUTPUT_SUBMITTED, TASK_OUTPUT_STARTED] - - elif output == TASK_OUTPUT_STARTED: + elif message == TASK_OUTPUT_STARTED: # It must have submitted. implied = [TASK_OUTPUT_SUBMITTED] - return [out for out in implied if not self.is_completed(out)] + return [ + message + for message in implied + if not self.is_message_complete(message) + ] + + def format_completion_status( + self, + indent: int = 2, + gutter: int = 2, + ansimarkup: int = 0, + ) -> str: + """Return a text representation of the status of these outputs. + + Returns a multiline string representing the status of each output used + in the expression within the context of the expression itself. + + Args: + indent: + Number of spaces to indent each level of the expression. + gutter: + Number of spaces to pad the left column from the expression. + ansimarkup: + Turns on colour coding using ansimarkup tags. These will need + to be parsed before display. There are three options + + 0: + No colour coding. + 1: + Only success colours will be used. This is easier to read + in colour coded logs. + 2: + Both success and fail colours will be used. + + Returns: + A multiline textural representation of the completion status. + + """ + indent_space: str = ' ' * indent + _gutter: str = ' ' * gutter + + def color_wrap(string, is_complete): + nonlocal ansimarkup + if ansimarkup == 0: + return string + if is_complete: + return f'{string}' + if ansimarkup == 2: + return f'{string}' + return string + + ret: List[str] = [] + indent_level: int = 0 + op: Optional[str] = None + fence = '⦙' # U+2999 (dotted fence) + for part in RE_EXPR_SPLIT.split(self._completion_expression): + if not part.strip(): + continue + + if part in {'and', 'or'}: + op = part + continue + + elif part == '(': + if op: + ret.append( + f' {fence}{_gutter}{(indent_space * indent_level)}' + f'{op} {part}' + ) + else: + ret.append( + f' {fence}{_gutter}' + f'{(indent_space * indent_level)}{part}' + ) + indent_level += 1 + elif part == ')': + indent_level -= 1 + ret.append( + f' {fence}{_gutter}{(indent_space * indent_level)}{part}' + ) + + else: + _symbol = BOOL_SYMBOLS[bool(self._is_compvar_complete(part))] + is_complete = self._is_compvar_complete(part) + _pre = ( + f'{color_wrap(_symbol, is_complete)} {fence}' + f'{_gutter}{(indent_space * indent_level)}' + ) + if op: + ret.append(f'{_pre}{op} {color_wrap(part, is_complete)}') + else: + ret.append(f'{_pre}{color_wrap(part, is_complete)}') + + op = None + + return '\n'.join(ret) @staticmethod - def is_valid_std_name(name): + def is_valid_std_name(name: str) -> bool: """Check name is a valid standard output name.""" return name in SORT_ORDERS @staticmethod - def msg_sort_key(item): - """Compare by _MESSAGE.""" - try: - ind = SORT_ORDERS.index(item[_MESSAGE]) - except ValueError: - ind = 999 - return (ind, item[_MESSAGE] or '') - - @staticmethod - def output_sort_key(item): + def output_sort_key(item: Iterable[str]) -> float: """Compare by output order. Examples: - >>> this = TaskOutputs.output_sort_key >>> sorted(['finished', 'started', 'custom'], key=this) ['started', 'custom', 'finished'] + """ if item in TASK_OUTPUTS: return TASK_OUTPUTS.index(item) # Sort custom outputs after started. return TASK_OUTPUTS.index(TASK_OUTPUT_STARTED) + .5 + + def _is_compvar_complete(self, compvar: str) -> Optional[bool]: + """Return True if the completion variable is complete. + + Returns: + * True if var is optional. + * False if var is required. + * None if var is not referenced. + + """ + for message, _compvar in self._message_to_compvar.items(): + if _compvar == compvar: + return self.is_message_complete(message) + else: + raise KeyError(compvar) + + def iter_required_messages(self) -> Iterator[str]: + """Yield task messages that are required for this task to be complete. + + Note, in some cases tasks might not have any required messages, + e.g. "completion = succeeded or failed". + """ + for compvar, is_optional in get_optional_outputs( + self._completion_expression, + set(self._message_to_compvar.values()), + ).items(): + if is_optional is False: + for message, _compvar in self._message_to_compvar.items(): + if _compvar == compvar: + yield message diff --git a/cylc/flow/task_pool.py b/cylc/flow/task_pool.py index 2b214d70943..1c6eeb17272 100644 --- a/cylc/flow/task_pool.py +++ b/cylc/flow/task_pool.py @@ -19,6 +19,7 @@ from contextlib import suppress from collections import Counter import json +from textwrap import indent from typing import ( Dict, Iterable, @@ -526,7 +527,7 @@ def load_db_task_pool_for_restart(self, row_idx, row): TASK_STATUS_SUCCEEDED ): for message in json.loads(outputs_str): - itask.state.outputs.set_completion(message, True) + itask.state.outputs.set_message_complete(message) self.data_store_mgr.delta_task_output(itask, message) if platform_name and status != TASK_STATUS_WAITING: @@ -1146,15 +1147,22 @@ def log_incomplete_tasks(self) -> bool: for itask in self.get_tasks(): if not itask.state(*TASK_STATUSES_FINAL): continue - outputs = itask.state.outputs.get_incomplete() - if outputs: - incomplete.append((itask.identity, outputs)) + if not itask.state.outputs.is_complete(): + incomplete.append( + ( + itask.identity, + itask.state.outputs.format_completion_status( + ansimarkup=1 + ), + ) + ) if incomplete: LOG.error( "Incomplete tasks:\n" + "\n".join( - f" * {id_} did not complete required outputs: {outputs}" + f"* {id_} did not complete the required outputs:" + f"\n{indent(outputs, ' ')}" for id_, outputs in incomplete ) ) @@ -1441,22 +1449,17 @@ def remove_if_complete( self.release_runahead_tasks() return ret - if itask.state(TASK_STATUS_EXPIRED): - self.remove(itask, "expired") - if self.compute_runahead(): - self.release_runahead_tasks() - return True - - incomplete = itask.state.outputs.get_incomplete() - if incomplete: + if not itask.state.outputs.is_complete(): # Keep incomplete tasks in the pool. if output in TASK_STATUSES_FINAL: # Log based on the output, not the state, to avoid warnings # due to use of "cylc set" to set internal outputs on an # already-finished task. LOG.warning( - f"[{itask}] did not complete required outputs:" - f" {incomplete}" + f"[{itask}] did not complete the required outputs:\n" + + itask.state.outputs.format_completion_status( + ansimarkup=1 + ) ) return False @@ -1482,14 +1485,12 @@ def spawn_on_all_outputs( """ if not itask.flow_nums: return - if completed_only: - outputs = itask.state.outputs.get_completed() - else: - outputs = itask.state.outputs._by_message - for output in outputs: + for _trigger, message, is_completed in itask.state.outputs: + if completed_only and not is_completed: + continue try: - children = itask.graph_children[output] + children = itask.graph_children[message] except KeyError: continue @@ -1509,7 +1510,7 @@ def spawn_on_all_outputs( continue if completed_only: c_task.satisfy_me( - [itask.tokens.duplicate(task_sel=output)] + [itask.tokens.duplicate(task_sel=message)] ) self.data_store_mgr.delta_task_prerequisite(c_task) self.add_to_pool(c_task) @@ -1595,7 +1596,7 @@ def _load_historical_outputs(self, itask): for outputs_str, fnums in info.items(): if itask.flow_nums.intersection(fnums): for msg in json.loads(outputs_str): - itask.state.outputs.set_completed_by_msg(msg) + itask.state.outputs.set_message_complete(msg) def spawn_task( self, @@ -1744,7 +1745,7 @@ def _get_task_proxy_db_outputs( for outputs_str, fnums in info.items(): if flow_nums.intersection(fnums): for msg in json.loads(outputs_str): - itask.state.outputs.set_completed_by_msg(msg) + itask.state.outputs.set_message_complete(msg) return itask def _standardise_prereqs( @@ -1887,16 +1888,15 @@ def _set_outputs_itask( outputs: List[str], ) -> None: """Set requested outputs on a task proxy and spawn children.""" - if not outputs: - outputs = itask.tdef.get_required_output_messages() + outputs = list(itask.state.outputs.iter_required_messages()) else: outputs = self._standardise_outputs( - itask.point, itask.tdef, outputs) + itask.point, itask.tdef, outputs + ) - outputs = sorted(outputs, key=itask.state.outputs.output_sort_key) - for output in outputs: - if itask.state.outputs.is_completed(output): + for output in sorted(outputs, key=itask.state.outputs.output_sort_key): + if itask.state.outputs.is_message_complete(output): LOG.info(f"output {itask.identity}:{output} completed already") continue self.task_events_mgr.process_message( @@ -2410,7 +2410,7 @@ def merge_flows(self, itask: TaskProxy, flow_nums: 'FlowNums') -> None: if ( itask.state(*TASK_STATUSES_FINAL) - and itask.state.outputs.get_incomplete() + and not itask.state.outputs.is_complete() ): # Re-queue incomplete task to run again in the merged flow. LOG.info(f"[{itask}] incomplete task absorbed by new flow.") diff --git a/cylc/flow/task_proxy.py b/cylc/flow/task_proxy.py index 898017c8da1..dbb6efc6db9 100644 --- a/cylc/flow/task_proxy.py +++ b/cylc/flow/task_proxy.py @@ -38,19 +38,11 @@ from cylc.flow.flow_mgr import stringify_flow_nums from cylc.flow.platforms import get_platform from cylc.flow.task_action_timer import TimerFlags -from cylc.flow.task_outputs import ( - TASK_OUTPUT_FAILED, - TASK_OUTPUT_EXPIRED, - TASK_OUTPUT_SUCCEEDED, - TASK_OUTPUT_SUBMIT_FAILED -) from cylc.flow.task_state import ( TaskState, TASK_STATUS_WAITING, TASK_STATUS_EXPIRED, - TASK_STATUS_SUCCEEDED, - TASK_STATUS_SUBMIT_FAILED, - TASK_STATUS_FAILED + TASK_STATUSES_FINAL, ) from cylc.flow.taskdef import generate_graph_children from cylc.flow.wallclock import get_unix_time_from_time_string as str2time @@ -578,32 +570,8 @@ def clock_expire(self) -> bool: def is_finished(self) -> bool: """Return True if a final state achieved.""" - return ( - self.state( - TASK_STATUS_EXPIRED, - TASK_STATUS_SUBMIT_FAILED, - TASK_STATUS_FAILED, - TASK_STATUS_SUCCEEDED - ) - ) + return self.state(*TASK_STATUSES_FINAL) def is_complete(self) -> bool: """Return True if complete or expired.""" - return ( - self.state(TASK_STATUS_EXPIRED) - or not self.state.outputs.is_incomplete() - ) - - def set_state_by_outputs(self) -> None: - """Set state according to which final output is completed.""" - for output in ( - TASK_OUTPUT_EXPIRED, TASK_OUTPUT_SUBMIT_FAILED, - TASK_OUTPUT_FAILED, TASK_OUTPUT_SUCCEEDED - ): - if self.state.outputs.is_completed(output, output): - # This assumes status and output strings are the same: - self.state_reset( - status=output, - silent=True, is_queued=False, is_runahead=False - ) - break + return self.state.outputs.is_complete() diff --git a/cylc/flow/taskdef.py b/cylc/flow/taskdef.py index 1da5101306b..68f754277d8 100644 --- a/cylc/flow/taskdef.py +++ b/cylc/flow/taskdef.py @@ -23,9 +23,7 @@ from cylc.flow.exceptions import TaskDefError from cylc.flow.task_id import TaskID from cylc.flow.task_outputs import ( - TASK_OUTPUT_EXPIRED, TASK_OUTPUT_SUBMITTED, - TASK_OUTPUT_SUBMIT_FAILED, TASK_OUTPUT_SUCCEEDED, TASK_OUTPUT_FAILED, SORT_ORDERS @@ -75,7 +73,7 @@ def generate_graph_children(tdef, point): def generate_graph_parents(tdef, point, taskdefs): - """Determine concrent graph parents of task tdef at point. + """Determine concrete graph parents of task tdef at point. Infer parents be reversing upstream triggers that lead to point/task. """ @@ -198,27 +196,15 @@ def set_required_output(self, output, required): message, _ = self.outputs[output] self.outputs[output] = (message, required) - def get_required_output_messages(self): - """Return list of required outputs (as task messages).""" - return [msg for (msg, req) in self.outputs.values() if req] - def tweak_outputs(self): """Output consistency checking and tweaking.""" - # If :succeed or :fail not set, assume success is required. - # Unless submit (and submit-fail) is optional (don't stall - # because of missing succeed if submit is optional). if ( self.outputs[TASK_OUTPUT_SUCCEEDED][1] is None and self.outputs[TASK_OUTPUT_FAILED][1] is None - and self.outputs[TASK_OUTPUT_SUBMITTED][1] is not False - and self.outputs[TASK_OUTPUT_SUBMIT_FAILED][1] is not False ): self.set_required_output(TASK_OUTPUT_SUCCEEDED, True) - # Expired must be optional - self.set_required_output(TASK_OUTPUT_EXPIRED, False) - # In Cylc 7 back compat mode, make all success outputs required. if cylc.flow.flags.cylc7_back_compat: for output in [ diff --git a/cylc/flow/util.py b/cylc/flow/util.py index b167cb33a84..8b8b613787a 100644 --- a/cylc/flow/util.py +++ b/cylc/flow/util.py @@ -20,17 +20,43 @@ from functools import partial import json import re +from textwrap import dedent from typing import ( Any, Callable, + Dict, List, Sequence, + Tuple, ) +BOOL_SYMBOLS: Dict[bool, str] = { + # U+2A2F (vector cross product) + False: '⨯', + # U+2713 (check) + True: '✓' +} _NAT_SORT_SPLIT = re.compile(r'([\d\.]+)') +def sstrip(text): + """Simple function to dedent and strip text. + + Examples: + >>> print(sstrip(''' + ... foo + ... bar + ... baz + ... ''')) + foo + bar + baz + + """ + return dedent(text).strip() + + def natural_sort_key(key: str, fcns=(int, str)) -> List[Any]: """Returns a key suitable for sorting. @@ -212,13 +238,13 @@ def restricted_evaluator( But will fail if a non-whitelisted node type is present: >>> evaluator('1 - 1') Traceback (most recent call last): - RestrictedSyntaxError: + flow.util.RestrictedSyntaxError: >>> evaluator('my_function()') Traceback (most recent call last): - RestrictedSyntaxError: + flow.util.RestrictedSyntaxError: >>> evaluator('__import__("os")') Traceback (most recent call last): - RestrictedSyntaxError: + flow.util.RestrictedSyntaxError: The evaluator cannot see the containing scope: >>> a = b = 1 @@ -336,3 +362,35 @@ def _get_exception( } return error_class(message, **context) + + +class NameWalker(ast.NodeVisitor): + """AST node visitor which records all variable names in an expression. + + Examples: + >>> tree = ast.parse('(foo and bar) or baz or qux') + >>> walker = NameWalker() + >>> walker.visit(tree) + >>> sorted(walker.names) + ['bar', 'baz', 'foo', 'qux'] + + """ + + def __init__(self): + super().__init__() + self._names = set() + + def visit(self, node): + if isinstance(node, ast.Name): + self._names.add(node.id) + return super().visit(node) + + @property + def names(self): + return self._names + + +def get_variable_names(expression): + walker = NameWalker() + walker.visit(ast.parse(expression)) + return walker.names diff --git a/cylc/flow/workflow_db_mgr.py b/cylc/flow/workflow_db_mgr.py index 128cfd45126..0a92e7312bf 100644 --- a/cylc/flow/workflow_db_mgr.py +++ b/cylc/flow/workflow_db_mgr.py @@ -630,11 +630,10 @@ def put_update_task_jobs(self, itask, set_args): def put_update_task_outputs(self, itask): """Put UPDATE statement for task_outputs table.""" - outputs = [] - for _, message in itask.state.outputs.get_completed_all(): - outputs.append(message) set_args = { - "outputs": json.dumps(outputs) + "outputs": json.dumps( + list(itask.state.outputs.iter_completed_messages()) + ) } where_args = { "cycle": str(itask.point), diff --git a/tests/flakyfunctional/cylc-show/00-simple.t b/tests/flakyfunctional/cylc-show/00-simple.t index bc00175565d..8e6b9156924 100644 --- a/tests/flakyfunctional/cylc-show/00-simple.t +++ b/tests/flakyfunctional/cylc-show/00-simple.t @@ -54,15 +54,20 @@ description: jumped over the lazy dog baz: pub URL: (not given) state: running -prerequisites: ('-': not satisfied) - + 20141106T0900Z/bar succeeded -outputs: ('-': not completed) - - 20141106T0900Z/foo expired - + 20141106T0900Z/foo submitted - - 20141106T0900Z/foo submit-failed - + 20141106T0900Z/foo started - - 20141106T0900Z/foo succeeded - - 20141106T0900Z/foo failed +prerequisites: ('⨯': not satisfied) + ✓ 20141106T0900Z/bar succeeded +outputs: ('⨯': not completed) + ⨯ 20141106T0900Z/foo expired + ✓ 20141106T0900Z/foo submitted + ⨯ 20141106T0900Z/foo submit-failed + ✓ 20141106T0900Z/foo started + ⨯ 20141106T0900Z/foo succeeded + ⨯ 20141106T0900Z/foo failed +output completion: incomplete + ⦙ ( + ✓ ⦙ started + ⨯ ⦙ and succeeded + ⦙ ) __SHOW_OUTPUT__ #------------------------------------------------------------------------------- TEST_NAME="${TEST_NAME_BASE}-show-json" @@ -104,6 +109,7 @@ cmp_json "${TEST_NAME}-taskinstance" "${TEST_NAME}-taskinstance" \ } } }, + "runtime": {"completion": "(started and succeeded)"}, "prerequisites": [ { "expression": "c0", diff --git a/tests/flakyfunctional/cylc-show/04-multi.t b/tests/flakyfunctional/cylc-show/04-multi.t index b3ee401d546..eac79132aaf 100644 --- a/tests/flakyfunctional/cylc-show/04-multi.t +++ b/tests/flakyfunctional/cylc-show/04-multi.t @@ -34,45 +34,51 @@ title: (not given) description: (not given) URL: (not given) state: running -prerequisites: ('-': not satisfied) - + 2015/t1 started -outputs: ('-': not completed) - - 2016/t1 expired - + 2016/t1 submitted - - 2016/t1 submit-failed - + 2016/t1 started - - 2016/t1 succeeded - - 2016/t1 failed +prerequisites: ('⨯': not satisfied) + ✓ 2015/t1 started +outputs: ('⨯': not completed) + ⨯ 2016/t1 expired + ✓ 2016/t1 submitted + ⨯ 2016/t1 submit-failed + ✓ 2016/t1 started + ⨯ 2016/t1 succeeded + ⨯ 2016/t1 failed +output completion: incomplete + ⨯ ⦙ succeeded Task ID: 2017/t1 title: (not given) description: (not given) URL: (not given) state: running -prerequisites: ('-': not satisfied) - + 2016/t1 started -outputs: ('-': not completed) - - 2017/t1 expired - + 2017/t1 submitted - - 2017/t1 submit-failed - + 2017/t1 started - - 2017/t1 succeeded - - 2017/t1 failed +prerequisites: ('⨯': not satisfied) + ✓ 2016/t1 started +outputs: ('⨯': not completed) + ⨯ 2017/t1 expired + ✓ 2017/t1 submitted + ⨯ 2017/t1 submit-failed + ✓ 2017/t1 started + ⨯ 2017/t1 succeeded + ⨯ 2017/t1 failed +output completion: incomplete + ⨯ ⦙ succeeded Task ID: 2018/t1 title: (not given) description: (not given) URL: (not given) state: running -prerequisites: ('-': not satisfied) - + 2017/t1 started -outputs: ('-': not completed) - - 2018/t1 expired - + 2018/t1 submitted - - 2018/t1 submit-failed - + 2018/t1 started - - 2018/t1 succeeded - - 2018/t1 failed +prerequisites: ('⨯': not satisfied) + ✓ 2017/t1 started +outputs: ('⨯': not completed) + ⨯ 2018/t1 expired + ✓ 2018/t1 submitted + ⨯ 2018/t1 submit-failed + ✓ 2018/t1 started + ⨯ 2018/t1 succeeded + ⨯ 2018/t1 failed +output completion: incomplete + ⨯ ⦙ succeeded __TXT__ contains_ok "${RUND}/show2.txt" <<'__TXT__' diff --git a/tests/flakyfunctional/job-submission/19-chatty.t b/tests/flakyfunctional/job-submission/19-chatty.t index f7480b4469b..6f03593cdc4 100755 --- a/tests/flakyfunctional/job-submission/19-chatty.t +++ b/tests/flakyfunctional/job-submission/19-chatty.t @@ -41,7 +41,7 @@ install_workflow "${TEST_NAME_BASE}" "${TEST_NAME_BASE}" run_ok "${TEST_NAME_BASE}-validate" cylc validate "${WORKFLOW_NAME}" workflow_run_ok "${TEST_NAME_BASE}-workflow-run" \ - cylc play --debug --no-detach "${WORKFLOW_NAME}" + cylc play --debug --no-detach "${WORKFLOW_NAME}" --reference-test # Logged killed jobs-submit command cylc cat-log "${WORKFLOW_NAME}" | sed -n ' @@ -70,20 +70,20 @@ done # Task pool in database contains the correct states TEST_NAME="${TEST_NAME_BASE}-db-task-pool" DB_FILE="${WORKFLOW_RUN_DIR}/log/db" -QUERY='SELECT cycle, name, status, is_held FROM task_pool' +QUERY='SELECT cycle, name, status FROM task_states WHERE name LIKE "nh%"' run_ok "$TEST_NAME" sqlite3 "$DB_FILE" "$QUERY" sort "${TEST_NAME}.stdout" > "${TEST_NAME}.stdout.sorted" cmp_ok "${TEST_NAME}.stdout.sorted" << '__OUT__' -1|nh0|submit-failed|0 -1|nh1|submit-failed|0 -1|nh2|submit-failed|0 -1|nh3|submit-failed|0 -1|nh4|submit-failed|0 -1|nh5|submit-failed|0 -1|nh6|submit-failed|0 -1|nh7|submit-failed|0 -1|nh8|submit-failed|0 -1|nh9|submit-failed|0 +1|nh0|submit-failed +1|nh1|submit-failed +1|nh2|submit-failed +1|nh3|submit-failed +1|nh4|submit-failed +1|nh5|submit-failed +1|nh6|submit-failed +1|nh7|submit-failed +1|nh8|submit-failed +1|nh9|submit-failed __OUT__ purge diff --git a/tests/flakyfunctional/job-submission/19-chatty/flow.cylc b/tests/flakyfunctional/job-submission/19-chatty/flow.cylc index cf0eaf9f724..f67409217d1 100644 --- a/tests/flakyfunctional/job-submission/19-chatty/flow.cylc +++ b/tests/flakyfunctional/job-submission/19-chatty/flow.cylc @@ -22,7 +22,7 @@ R1 = "starter:start => NOHOPE" R1 = "starter => HOPEFUL" R1 = HOPEFUL:succeed-all - R1 = "NOHOPE:submit-fail-all => stopper" + R1 = "NOHOPE:submit-fail-all? => stopper" [runtime] [[starter]] diff --git a/tests/flakyfunctional/job-submission/19-chatty/reference.log b/tests/flakyfunctional/job-submission/19-chatty/reference.log new file mode 100644 index 00000000000..a08d9635e4b --- /dev/null +++ b/tests/flakyfunctional/job-submission/19-chatty/reference.log @@ -0,0 +1,22 @@ +1/starter -triggered off [] in flow 1 +1/nh2 -triggered off ['1/starter'] in flow 1 +1/nh3 -triggered off ['1/starter'] in flow 1 +1/nh1 -triggered off ['1/starter'] in flow 1 +1/nh5 -triggered off ['1/starter'] in flow 1 +1/nh0 -triggered off ['1/starter'] in flow 1 +1/nh7 -triggered off ['1/starter'] in flow 1 +1/nh9 -triggered off ['1/starter'] in flow 1 +1/nh4 -triggered off ['1/starter'] in flow 1 +1/nh6 -triggered off ['1/starter'] in flow 1 +1/nh8 -triggered off ['1/starter'] in flow 1 +1/h6 -triggered off ['1/starter'] in flow 1 +1/h7 -triggered off ['1/starter'] in flow 1 +1/h9 -triggered off ['1/starter'] in flow 1 +1/h1 -triggered off ['1/starter'] in flow 1 +1/h3 -triggered off ['1/starter'] in flow 1 +1/h5 -triggered off ['1/starter'] in flow 1 +1/h8 -triggered off ['1/starter'] in flow 1 +1/h0 -triggered off ['1/starter'] in flow 1 +1/h2 -triggered off ['1/starter'] in flow 1 +1/h4 -triggered off ['1/starter'] in flow 1 +1/stopper -triggered off ['1/nh0', '1/nh1', '1/nh2', '1/nh3', '1/nh4', '1/nh5', '1/nh6', '1/nh7', '1/nh8', '1/nh9'] in flow 1 diff --git a/tests/functional/cylc-cat-log/00-local.t b/tests/functional/cylc-cat-log/00-local.t index 5a5d20edb71..d816d20d744 100755 --- a/tests/functional/cylc-cat-log/00-local.t +++ b/tests/functional/cylc-cat-log/00-local.t @@ -24,7 +24,8 @@ install_workflow "${TEST_NAME_BASE}" "${TEST_NAME_BASE}" TEST_NAME="${TEST_NAME_BASE}-validate" run_ok "${TEST_NAME}" cylc validate "${WORKFLOW_NAME}" #------------------------------------------------------------------------------- -workflow_run_ok "${TEST_NAME_BASE}-run" cylc play --no-detach "${WORKFLOW_NAME}" +workflow_run_ok "${TEST_NAME_BASE}-run" \ + cylc play --no-detach "${WORKFLOW_NAME}" --reference-test #------------------------------------------------------------------------------- TEST_NAME=${TEST_NAME_BASE}-workflow-log-log run_ok "${TEST_NAME}" cylc cat-log "${WORKFLOW_NAME}" @@ -58,6 +59,7 @@ install/01-install.log scheduler/01-start-01.log scheduler/02-restart-02.log scheduler/03-restart-02.log +scheduler/reftest.log __END__ #------------------------------------------------------------------------------- TEST_NAME=${TEST_NAME_BASE}-task-out diff --git a/tests/functional/cylc-cat-log/00-local/flow.cylc b/tests/functional/cylc-cat-log/00-local/flow.cylc index 102fc8218df..a71ee32caea 100644 --- a/tests/functional/cylc-cat-log/00-local/flow.cylc +++ b/tests/functional/cylc-cat-log/00-local/flow.cylc @@ -6,7 +6,7 @@ inactivity timeout = PT3M [scheduling] [[graph]] - R1 = submit-failed:submit-failed => a-task + R1 = submit-failed:submit-failed? => a-task [runtime] [[a-task]] script = """ diff --git a/tests/functional/cylc-cat-log/00-local/reference.log b/tests/functional/cylc-cat-log/00-local/reference.log new file mode 100644 index 00000000000..1b10209edab --- /dev/null +++ b/tests/functional/cylc-cat-log/00-local/reference.log @@ -0,0 +1,2 @@ +1/submit-failed -triggered off [] in flow 1 +1/a-task -triggered off ['1/submit-failed'] in flow 1 diff --git a/tests/functional/cylc-config/00-simple/section2.stdout b/tests/functional/cylc-config/00-simple/section2.stdout index 4d3989c8387..f46992c8026 100644 --- a/tests/functional/cylc-config/00-simple/section2.stdout +++ b/tests/functional/cylc-config/00-simple/section2.stdout @@ -1,12 +1,13 @@ [[root]] + completion = platform = inherit = + script = init-script = env-script = err-script = exit-script = pre-script = - script = post-script = work sub-directory = execution polling intervals = @@ -76,6 +77,7 @@ [[[parameter environment templates]]] [[OPS]] script = echo "RUN: run-ops.sh" + completion = platform = inherit = init-script = @@ -152,6 +154,7 @@ [[[parameter environment templates]]] [[VAR]] script = echo "RUN: run-var.sh" + completion = platform = inherit = init-script = @@ -227,14 +230,15 @@ [[[outputs]]] [[[parameter environment templates]]] [[SERIAL]] + completion = platform = inherit = + script = init-script = env-script = err-script = exit-script = pre-script = - script = post-script = work sub-directory = execution polling intervals = @@ -304,14 +308,15 @@ [[[outputs]]] [[[parameter environment templates]]] [[PARALLEL]] + completion = platform = inherit = + script = init-script = env-script = err-script = exit-script = pre-script = - script = post-script = work sub-directory = execution polling intervals = @@ -383,6 +388,7 @@ [[ops_s1]] script = echo "RUN: run-ops.sh" inherit = OPS, SERIAL + completion = succeeded or failed platform = init-script = env-script = @@ -460,6 +466,7 @@ [[ops_s2]] script = echo "RUN: run-ops.sh" inherit = OPS, SERIAL + completion = succeeded or failed platform = init-script = env-script = @@ -537,6 +544,7 @@ [[ops_p1]] script = echo "RUN: run-ops.sh" inherit = OPS, PARALLEL + completion = succeeded or failed platform = init-script = env-script = @@ -614,6 +622,7 @@ [[ops_p2]] script = echo "RUN: run-ops.sh" inherit = OPS, PARALLEL + completion = succeeded or failed platform = init-script = env-script = @@ -691,6 +700,7 @@ [[var_s1]] script = echo "RUN: run-var.sh" inherit = VAR, SERIAL + completion = succeeded platform = init-script = env-script = @@ -768,6 +778,7 @@ [[var_s2]] script = echo "RUN: run-var.sh" inherit = VAR, SERIAL + completion = succeeded platform = init-script = env-script = @@ -845,6 +856,7 @@ [[var_p1]] script = echo "RUN: run-var.sh" inherit = VAR, PARALLEL + completion = succeeded platform = init-script = env-script = @@ -922,6 +934,7 @@ [[var_p2]] script = echo "RUN: run-var.sh" inherit = VAR, PARALLEL + completion = succeeded platform = init-script = env-script = diff --git a/tests/functional/cylc-diff/00-basic.t b/tests/functional/cylc-diff/00-basic.t index 18d57e07da9..20c387c044a 100755 --- a/tests/functional/cylc-diff/00-basic.t +++ b/tests/functional/cylc-diff/00-basic.t @@ -51,17 +51,21 @@ Workflow definitions ${WORKFLOW_NAME1} and ${WORKFLOW_NAME2} differ [runtime] [[foo]] < script = true + < completion = succeeded [runtime] [[bar]] < script = true + < completion = succeeded 2 items only in ${WORKFLOW_NAME2} (>) [runtime] [[food]] > script = true + > completion = succeeded [runtime] [[barley]] > script = true + > completion = succeeded 1 common items differ ${WORKFLOW_NAME1}(<) ${WORKFLOW_NAME2}(>) diff --git a/tests/functional/cylc-diff/03-icp.t b/tests/functional/cylc-diff/03-icp.t index a598ed985cd..7eea32d49f0 100755 --- a/tests/functional/cylc-diff/03-icp.t +++ b/tests/functional/cylc-diff/03-icp.t @@ -56,17 +56,21 @@ Workflow definitions ${WORKFLOW_NAME1} and ${WORKFLOW_NAME2} differ [runtime] [[foo]] < script = true + < completion = succeeded [runtime] [[bar]] < script = true + < completion = succeeded 2 items only in ${WORKFLOW_NAME2} (>) [runtime] [[food]] > script = true + > completion = succeeded [runtime] [[barley]] > script = true + > completion = succeeded 1 common items differ ${WORKFLOW_NAME1}(<) ${WORKFLOW_NAME2}(>) diff --git a/tests/functional/cylc-diff/04-icp-2.t b/tests/functional/cylc-diff/04-icp-2.t index 32405f7e06d..98c5a471369 100755 --- a/tests/functional/cylc-diff/04-icp-2.t +++ b/tests/functional/cylc-diff/04-icp-2.t @@ -57,17 +57,21 @@ Workflow definitions ${WORKFLOW_NAME1} and ${WORKFLOW_NAME2} differ [runtime] [[foo]] < script = true + < completion = succeeded [runtime] [[bar]] < script = true + < completion = succeeded 2 items only in ${WORKFLOW_NAME2} (>) [runtime] [[food]] > script = true + > completion = succeeded [runtime] [[barley]] > script = true + > completion = succeeded 1 common items differ ${WORKFLOW_NAME1}(<) ${WORKFLOW_NAME2}(>) diff --git a/tests/functional/cylc-set/03-set-failed.t b/tests/functional/cylc-set/03-set-failed.t index 1910e5b3120..4d561182b3a 100644 --- a/tests/functional/cylc-set/03-set-failed.t +++ b/tests/functional/cylc-set/03-set-failed.t @@ -36,7 +36,7 @@ cylc set -o failed "${WORKFLOW_NAME}//1/foo" # - implied outputs reported as already completed poll_grep_workflow_log -E "1/foo.* => failed" -poll_grep_workflow_log -E "1/foo.* did not complete required outputs" +poll_grep_workflow_log -E "1/foo.* did not complete the required outputs" cylc stop --now --now --interval=2 --max-polls=5 "${WORKFLOW_NAME}" diff --git a/tests/functional/cylc-show/05-complex.t b/tests/functional/cylc-show/05-complex.t index e49082d350f..d26c6b4f070 100644 --- a/tests/functional/cylc-show/05-complex.t +++ b/tests/functional/cylc-show/05-complex.t @@ -37,21 +37,23 @@ title: (not given) description: (not given) URL: (not given) state: running -prerequisites: ('-': not satisfied) - + 1 & 2 & (3 | (4 & 5)) & 0 - + 0 = 19991231T0000Z/f succeeded - + 1 = 20000101T0000Z/a succeeded - + 2 = 20000101T0000Z/b succeeded - + 3 = 20000101T0000Z/c succeeded - + 4 = 20000101T0000Z/d succeeded - + 5 = 20000101T0000Z/e succeeded -outputs: ('-': not completed) - - 20000101T0000Z/f expired - + 20000101T0000Z/f submitted - - 20000101T0000Z/f submit-failed - + 20000101T0000Z/f started - - 20000101T0000Z/f succeeded - - 20000101T0000Z/f failed +prerequisites: ('⨯': not satisfied) + ✓ 1 & 2 & (3 | (4 & 5)) & 0 + ✓ 0 = 19991231T0000Z/f succeeded + ✓ 1 = 20000101T0000Z/a succeeded + ✓ 2 = 20000101T0000Z/b succeeded + ✓ 3 = 20000101T0000Z/c succeeded + ✓ 4 = 20000101T0000Z/d succeeded + ✓ 5 = 20000101T0000Z/e succeeded +outputs: ('⨯': not completed) + ⨯ 20000101T0000Z/f expired + ✓ 20000101T0000Z/f submitted + ⨯ 20000101T0000Z/f submit-failed + ✓ 20000101T0000Z/f started + ⨯ 20000101T0000Z/f succeeded + ⨯ 20000101T0000Z/f failed +output completion: incomplete + ⨯ ⦙ succeeded 19991231T0000Z/f succeeded 20000101T0000Z/a succeeded 20000101T0000Z/b succeeded @@ -62,21 +64,23 @@ title: (not given) description: (not given) URL: (not given) state: running -prerequisites: ('-': not satisfied) - + 1 & 2 & (3 | (4 & 5)) & 0 - + 0 = 20000101T0000Z/f succeeded - + 1 = 20000102T0000Z/a succeeded - + 2 = 20000102T0000Z/b succeeded - + 3 = 20000102T0000Z/c succeeded - + 4 = 20000102T0000Z/d succeeded - + 5 = 20000102T0000Z/e succeeded -outputs: ('-': not completed) - - 20000102T0000Z/f expired - + 20000102T0000Z/f submitted - - 20000102T0000Z/f submit-failed - + 20000102T0000Z/f started - - 20000102T0000Z/f succeeded - - 20000102T0000Z/f failed +prerequisites: ('⨯': not satisfied) + ✓ 1 & 2 & (3 | (4 & 5)) & 0 + ✓ 0 = 20000101T0000Z/f succeeded + ✓ 1 = 20000102T0000Z/a succeeded + ✓ 2 = 20000102T0000Z/b succeeded + ✓ 3 = 20000102T0000Z/c succeeded + ✓ 4 = 20000102T0000Z/d succeeded + ✓ 5 = 20000102T0000Z/e succeeded +outputs: ('⨯': not completed) + ⨯ 20000102T0000Z/f expired + ✓ 20000102T0000Z/f submitted + ⨯ 20000102T0000Z/f submit-failed + ✓ 20000102T0000Z/f started + ⨯ 20000102T0000Z/f succeeded + ⨯ 20000102T0000Z/f failed +output completion: incomplete + ⨯ ⦙ succeeded 20000101T0000Z/f succeeded 20000102T0000Z/a succeeded 20000102T0000Z/b succeeded diff --git a/tests/functional/cylc-show/06-past-present-future.t b/tests/functional/cylc-show/06-past-present-future.t index bf6381cab65..7ff9762212d 100644 --- a/tests/functional/cylc-show/06-past-present-future.t +++ b/tests/functional/cylc-show/06-past-present-future.t @@ -45,15 +45,15 @@ __END__ TEST_NAME="${TEST_NAME_BASE}-show.present" contains_ok "${WORKFLOW_RUN_DIR}/show-c.txt" <<__END__ state: running -prerequisites: ('-': not satisfied) - + 1/b succeeded +prerequisites: ('⨯': not satisfied) + ✓ 1/b succeeded __END__ TEST_NAME="${TEST_NAME_BASE}-show.future" contains_ok "${WORKFLOW_RUN_DIR}/show-d.txt" <<__END__ state: waiting -prerequisites: ('-': not satisfied) - - 1/c succeeded +prerequisites: ('⨯': not satisfied) + ⨯ 1/c succeeded __END__ purge diff --git a/tests/functional/events/26-workflow-stalled-dump-prereq.t b/tests/functional/events/26-workflow-stalled-dump-prereq.t index 13d0b72579e..808ea8ae82f 100755 --- a/tests/functional/events/26-workflow-stalled-dump-prereq.t +++ b/tests/functional/events/26-workflow-stalled-dump-prereq.t @@ -30,8 +30,9 @@ grep_ok '"abort on stall timeout" is set' "${TEST_NAME_BASE}-run.stderr" grep_ok "ERROR - Incomplete tasks:" "${TEST_NAME_BASE}-run.stderr" -grep_ok "20100101T0000Z/bar did not complete required outputs: \['succeeded'\]" \ - "${TEST_NAME_BASE}-run.stderr" +grep_ok "20100101T0000Z/bar did not complete the required outputs:\n.*succeeded" \ + "${TEST_NAME_BASE}-run.stderr" \ + -Pizo grep_ok "WARNING - Partially satisfied prerequisites:" \ "${TEST_NAME_BASE}-run.stderr" diff --git a/tests/functional/events/27-workflow-stalled-dump-prereq-fam.t b/tests/functional/events/27-workflow-stalled-dump-prereq-fam.t index b75f31e86b1..03dd1b3dfef 100755 --- a/tests/functional/events/27-workflow-stalled-dump-prereq-fam.t +++ b/tests/functional/events/27-workflow-stalled-dump-prereq-fam.t @@ -31,8 +31,9 @@ grep_ok '"abort on stall timeout" is set' "${TEST_NAME_BASE}-run.stderr" grep_ok "ERROR - Incomplete tasks:" "${TEST_NAME_BASE}-run.stderr" -grep_ok "1/foo did not complete required outputs: \['succeeded'\]" \ - "${TEST_NAME_BASE}-run.stderr" +grep_ok "1/foo did not complete the required outputs:\n.*succeeded" \ + "${TEST_NAME_BASE}-run.stderr" \ + -Pizo grep_ok "WARNING - Partially satisfied prerequisites:" \ "${TEST_NAME_BASE}-run.stderr" diff --git a/tests/functional/graph-equivalence/multiline_and_refs/c-ref b/tests/functional/graph-equivalence/multiline_and_refs/c-ref index a9ea18051a0..3ff04f654a4 100644 --- a/tests/functional/graph-equivalence/multiline_and_refs/c-ref +++ b/tests/functional/graph-equivalence/multiline_and_refs/c-ref @@ -1,5 +1,5 @@ -prerequisites: ('-': not satisfied) - - 1/a succeeded - - 1/b succeeded -outputs: ('-': not completed) +prerequisites: ('⨯': not satisfied) + ⨯ 1/a succeeded + ⨯ 1/b succeeded +outputs: ('⨯': not completed) (None) diff --git a/tests/functional/graph-equivalence/multiline_and_refs/c-ref-2 b/tests/functional/graph-equivalence/multiline_and_refs/c-ref-2 index a9ea18051a0..3ff04f654a4 100644 --- a/tests/functional/graph-equivalence/multiline_and_refs/c-ref-2 +++ b/tests/functional/graph-equivalence/multiline_and_refs/c-ref-2 @@ -1,5 +1,5 @@ -prerequisites: ('-': not satisfied) - - 1/a succeeded - - 1/b succeeded -outputs: ('-': not completed) +prerequisites: ('⨯': not satisfied) + ⨯ 1/a succeeded + ⨯ 1/b succeeded +outputs: ('⨯': not completed) (None) diff --git a/tests/functional/graph-equivalence/splitline_refs/a-ref b/tests/functional/graph-equivalence/splitline_refs/a-ref index a5d954e5314..8f042683c0a 100644 --- a/tests/functional/graph-equivalence/splitline_refs/a-ref +++ b/tests/functional/graph-equivalence/splitline_refs/a-ref @@ -1,2 +1,2 @@ prerequisites: (None) -outputs: ('-': not completed) +outputs: ('⨯': not completed) diff --git a/tests/functional/graph-equivalence/splitline_refs/b-ref b/tests/functional/graph-equivalence/splitline_refs/b-ref index 1e9fd5768db..cad0fe89d0d 100644 --- a/tests/functional/graph-equivalence/splitline_refs/b-ref +++ b/tests/functional/graph-equivalence/splitline_refs/b-ref @@ -1,3 +1,3 @@ -prerequisites: ('-': not satisfied) - + 1/a succeeded -outputs: ('-': not completed) +prerequisites: ('⨯': not satisfied) + ✓ 1/a succeeded +outputs: ('⨯': not completed) diff --git a/tests/functional/graph-equivalence/splitline_refs/c-ref b/tests/functional/graph-equivalence/splitline_refs/c-ref index f3ec5407750..a0145574643 100644 --- a/tests/functional/graph-equivalence/splitline_refs/c-ref +++ b/tests/functional/graph-equivalence/splitline_refs/c-ref @@ -1,3 +1,3 @@ -prerequisites: ('-': not satisfied) - + 1/b succeeded -outputs: ('-': not completed) +prerequisites: ('⨯': not satisfied) + ✓ 1/b succeeded +outputs: ('⨯': not completed) diff --git a/tests/functional/inheritance/00-namespace-list.t b/tests/functional/inheritance/00-namespace-list.t index 43c204f3d4c..d1a58de401b 100755 --- a/tests/functional/inheritance/00-namespace-list.t +++ b/tests/functional/inheritance/00-namespace-list.t @@ -33,14 +33,17 @@ cmp_ok runtime.out <<'__DONE__' [[FAMILY]] [[m1]] inherit = FAMILY + completion = succeeded [[[environment]]] FOO = foo [[m2]] inherit = FAMILY + completion = succeeded [[[environment]]] FOO = bar [[m3]] inherit = FAMILY + completion = succeeded [[[environment]]] FOO = foo __DONE__ diff --git a/tests/functional/intelligent-host-selection/06-from-platform-group-fails.t b/tests/functional/intelligent-host-selection/06-from-platform-group-fails.t index 682a89e4e31..32eaf01d1e2 100644 --- a/tests/functional/intelligent-host-selection/06-from-platform-group-fails.t +++ b/tests/functional/intelligent-host-selection/06-from-platform-group-fails.t @@ -54,7 +54,7 @@ logfile="${WORKFLOW_RUN_DIR}/log/scheduler/log" # Check workflow fails for the reason we want it to fail named_grep_ok \ "Workflow stalled with 1/bad (submit-failed)" \ - "1/bad did not complete required outputs" \ + "1/bad did not complete the required outputs" \ "$logfile" # Look for message indicating that remote init has failed on each bad_host diff --git a/tests/functional/job-submission/06-garbage/flow.cylc b/tests/functional/job-submission/06-garbage/flow.cylc index bf5c4a55e3f..d994d840fd2 100644 --- a/tests/functional/job-submission/06-garbage/flow.cylc +++ b/tests/functional/job-submission/06-garbage/flow.cylc @@ -4,7 +4,7 @@ [scheduling] [[graph]] - R1 = """t1:submit-fail => t2""" + R1 = t1:submit-fail? => t2 [runtime] [[t1]] diff --git a/tests/functional/job-submission/09-activity-log-host-bad-submit/flow.cylc b/tests/functional/job-submission/09-activity-log-host-bad-submit/flow.cylc index 0a5bdb05b60..c76d8729cb2 100644 --- a/tests/functional/job-submission/09-activity-log-host-bad-submit/flow.cylc +++ b/tests/functional/job-submission/09-activity-log-host-bad-submit/flow.cylc @@ -9,7 +9,7 @@ initial cycle point=1999 final cycle point=1999 [[graph]] - P1Y = bad-submitter:submit-failed => grepper + P1Y = bad-submitter:submit-failed? => grepper [runtime] [[root]] diff --git a/tests/functional/job-submission/16-timeout.t b/tests/functional/job-submission/16-timeout.t index 266b04b0164..b6042b56dad 100755 --- a/tests/functional/job-submission/16-timeout.t +++ b/tests/functional/job-submission/16-timeout.t @@ -53,6 +53,7 @@ __END__ cylc workflow-state "${WORKFLOW_NAME}" > workflow-state.log +# make sure foo submit failed and the stopper ran contains_ok workflow-state.log << __END__ stopper, 1, succeeded foo, 1, submit-failed diff --git a/tests/functional/job-submission/16-timeout/flow.cylc b/tests/functional/job-submission/16-timeout/flow.cylc index 30883ea8048..0f37c3f6551 100644 --- a/tests/functional/job-submission/16-timeout/flow.cylc +++ b/tests/functional/job-submission/16-timeout/flow.cylc @@ -2,7 +2,7 @@ [scheduling] [[graph]] - R1 = "foo:submit-fail => stopper" + R1 = "foo:submit-fail? => stopper" [runtime] [[foo]] platform = {{ environ['CYLC_TEST_PLATFORM'] }} diff --git a/tests/functional/job-submission/19-platform_select.t b/tests/functional/job-submission/19-platform_select.t index c55c72a3c28..bcd8179426a 100755 --- a/tests/functional/job-submission/19-platform_select.t +++ b/tests/functional/job-submission/19-platform_select.t @@ -22,8 +22,7 @@ set_test_number 6 install_workflow "${TEST_NAME_BASE}" run_ok "${TEST_NAME_BASE}-validate" cylc validate "${WORKFLOW_NAME}" -run_ok "${TEST_NAME_BASE}-run" \ - cylc play --debug --no-detach "${WORKFLOW_NAME}" +reftest_run logfile="${WORKFLOW_RUN_DIR}/log/scheduler/log" diff --git a/tests/functional/job-submission/19-platform_select/flow.cylc b/tests/functional/job-submission/19-platform_select/flow.cylc index a6dcc446eec..5211df39699 100644 --- a/tests/functional/job-submission/19-platform_select/flow.cylc +++ b/tests/functional/job-submission/19-platform_select/flow.cylc @@ -15,10 +15,10 @@ purpose = """ R1 = """ host_no_subshell localhost_subshell - platform_subshell:submit-fail => fin_platform - platform_no_subshell:submit-fail => fin_platform - host_subshell:submit-fail => fin_host - host_subshell_backticks:submit-fail => fin_host + platform_subshell:submit-fail? => fin_platform + platform_no_subshell:submit-fail? => fin_platform + host_subshell:submit-fail? => fin_host + host_subshell_backticks:submit-fail? => fin_host """ [runtime] diff --git a/tests/functional/job-submission/19-platform_select/reference.log b/tests/functional/job-submission/19-platform_select/reference.log new file mode 100644 index 00000000000..b60c413e6cc --- /dev/null +++ b/tests/functional/job-submission/19-platform_select/reference.log @@ -0,0 +1,8 @@ +1/platform_no_subshell -triggered off [] in flow 1 +1/host_no_subshell -triggered off [] in flow 1 +1/platform_subshell -triggered off [] in flow 1 +1/host_subshell -triggered off [] in flow 1 +1/host_subshell_backticks -triggered off [] in flow 1 +1/localhost_subshell -triggered off [] in flow 1 +1/fin_platform -triggered off ['1/platform_no_subshell', '1/platform_subshell'] in flow 1 +1/fin_host -triggered off ['1/host_subshell', '1/host_subshell_backticks'] in flow 1 diff --git a/tests/functional/modes/05-sim-trigger.t b/tests/functional/modes/05-sim-trigger.t index 661a5843eda..7c7d1351db1 100644 --- a/tests/functional/modes/05-sim-trigger.t +++ b/tests/functional/modes/05-sim-trigger.t @@ -38,7 +38,7 @@ grep_ok '\[1/fail_fail_fail/01:running\] => failed' "${SCHD_LOG}" cylc trigger "${WORKFLOW_NAME}//1/fail_fail_fail" poll_grep_workflow_log -E \ - '1/fail_fail_fail/02.* did not complete required outputs' + '1/fail_fail_fail/02.* did not complete the required outputs' grep_ok '\[1/fail_fail_fail/02:running\] => failed' "${SCHD_LOG}" diff --git a/tests/functional/n-window/01-past-present-future.t b/tests/functional/n-window/01-past-present-future.t index d5ed27a8085..b3ad0edf23c 100644 --- a/tests/functional/n-window/01-past-present-future.t +++ b/tests/functional/n-window/01-past-present-future.t @@ -45,22 +45,22 @@ __END__ TEST_NAME="${TEST_NAME_BASE}-show-c.present" contains_ok "${WORKFLOW_RUN_DIR}/show-c.txt" <<__END__ -prerequisites: ('-': not satisfied) - + 1/b succeeded +prerequisites: ('⨯': not satisfied) + ✓ 1/b succeeded __END__ TEST_NAME="${TEST_NAME_BASE}-show-d.future" contains_ok "${WORKFLOW_RUN_DIR}/show-d.txt" <<__END__ state: waiting -prerequisites: ('-': not satisfied) - - 1/c succeeded +prerequisites: ('⨯': not satisfied) + ⨯ 1/c succeeded __END__ TEST_NAME="${TEST_NAME_BASE}-show-e.future" contains_ok "${WORKFLOW_RUN_DIR}/show-e.txt" <<__END__ state: waiting -prerequisites: ('-': not satisfied) - - 1/d succeeded +prerequisites: ('⨯': not satisfied) + ⨯ 1/d succeeded __END__ purge diff --git a/tests/functional/n-window/02-big-window.t b/tests/functional/n-window/02-big-window.t index e6aa45fae24..a084b6dd69b 100644 --- a/tests/functional/n-window/02-big-window.t +++ b/tests/functional/n-window/02-big-window.t @@ -42,15 +42,15 @@ __END__ TEST_NAME="${TEST_NAME_BASE}-show-j.parallel" contains_ok "${WORKFLOW_RUN_DIR}/show-j.txt" <<__END__ state: waiting -prerequisites: ('-': not satisfied) - - 1/i succeeded +prerequisites: ('⨯': not satisfied) + ⨯ 1/i succeeded __END__ TEST_NAME="${TEST_NAME_BASE}-show-h.future" contains_ok "${WORKFLOW_RUN_DIR}/show-h.txt" <<__END__ state: waiting -prerequisites: ('-': not satisfied) - - 1/g succeeded +prerequisites: ('⨯': not satisfied) + ⨯ 1/g succeeded __END__ purge diff --git a/tests/functional/optional-outputs/01-stall-on-incomplete.t b/tests/functional/optional-outputs/01-stall-on-incomplete.t index 81c71116471..7a460ffcaf9 100644 --- a/tests/functional/optional-outputs/01-stall-on-incomplete.t +++ b/tests/functional/optional-outputs/01-stall-on-incomplete.t @@ -30,7 +30,7 @@ workflow_run_fail "${TEST_NAME_BASE}-run" \ LOG="${WORKFLOW_RUN_DIR}/log/scheduler/log" grep_ok "Incomplete tasks" "${LOG}" -grep_ok "1/foo did not complete required outputs: \['y'\]" "${LOG}" +grep_ok '1/foo did not complete the required outputs:\n(.*\n){3}.*y\n' "${LOG}" -Pizo grep_ok "Workflow stalled" "${LOG}" purge diff --git a/tests/functional/optional-outputs/07-finish-fail-c7-backcompat.t b/tests/functional/optional-outputs/07-finish-fail-c7-backcompat.t index 37d13e2ee88..85b121ae0f9 100644 --- a/tests/functional/optional-outputs/07-finish-fail-c7-backcompat.t +++ b/tests/functional/optional-outputs/07-finish-fail-c7-backcompat.t @@ -37,7 +37,7 @@ workflow_run_fail "${TEST_NAME_BASE}-run" cylc play --no-detach --debug "${WORKF grep_workflow_log_ok grep-0 "Workflow stalled" grep_workflow_log_ok grep-1 "ERROR - Incomplete tasks:" -grep_workflow_log_ok grep-2 "1/foo did not complete required outputs" -grep_workflow_log_ok grep-3 "2/foo did not complete required outputs" +grep_workflow_log_ok grep-2 "1/foo did not complete the required outputs" +grep_workflow_log_ok grep-3 "2/foo did not complete the required outputs" purge diff --git a/tests/functional/param_expand/01-basic.t b/tests/functional/param_expand/01-basic.t index 20c06d5a3b9..e058224649a 100644 --- a/tests/functional/param_expand/01-basic.t +++ b/tests/functional/param_expand/01-basic.t @@ -390,10 +390,12 @@ cmp_ok '19.cylc' <<'__FLOW_CONFIG__' [[root]] [[c++]] script = true + completion = succeeded [[[environment]]] CC = gcc [[fortran-2008]] script = true + completion = succeeded [[[environment]]] FC = gfortran __FLOW_CONFIG__ diff --git a/tests/functional/remote/05-remote-init.t b/tests/functional/remote/05-remote-init.t index 37a28aac1da..f0f8ef65266 100644 --- a/tests/functional/remote/05-remote-init.t +++ b/tests/functional/remote/05-remote-init.t @@ -21,7 +21,7 @@ export REQUIRE_PLATFORM='loc:remote fs:indep comms:tcp' . "$(dirname "$0")/test_header" #------------------------------------------------------------------------------- -set_test_number 6 +set_test_number 5 create_test_global_config "" " [platforms] [[belle]] @@ -53,8 +53,7 @@ g|0|0|localhost __SELECT__ grep_ok "ERROR - Incomplete tasks:" "${TEST_NAME_BASE}-run.stderr" -grep_ok "1/a did not complete required outputs" "${TEST_NAME_BASE}-run.stderr" -grep_ok "1/b did not complete required outputs" "${TEST_NAME_BASE}-run.stderr" +grep_ok "1/a did not complete the required outputs" "${TEST_NAME_BASE}-run.stderr" purge exit diff --git a/tests/functional/restart/submit-failed/flow.cylc b/tests/functional/restart/submit-failed/flow.cylc index 6ad698828af..cdce1c692e9 100644 --- a/tests/functional/restart/submit-failed/flow.cylc +++ b/tests/functional/restart/submit-failed/flow.cylc @@ -14,7 +14,9 @@ final cycle point = 20130923T00 [[graph]] R1 = """ - submit_failed_task:submit-fail => shutdown + submit_failed_task:submit-fail? => shutdown + submit_failed_task:submitted? => error + shutdown => output_states output_states => remove => finish """ @@ -29,7 +31,12 @@ description = "Submit-failed task (runs before restart)" [[remove]] script = """ - cylc remove ${CYLC_WORKFLOW_ID} submit_failed_task.${CYLC_TASK_CYCLE_POINT} + cylc remove "${CYLC_WORKFLOW_ID}//${CYLC_TASK_CYCLE_POINT}/submit_failed_task" + """ + [[error]] + script = """ + cylc message -- "ERROR:this-task-should-have-submit-failed" + exit 1 """ {% include 'flow-runtime-restart.cylc' %} diff --git a/tests/functional/retries/submission/flow.cylc b/tests/functional/retries/submission/flow.cylc index 9d650a18b71..be4d9be1615 100644 --- a/tests/functional/retries/submission/flow.cylc +++ b/tests/functional/retries/submission/flow.cylc @@ -7,7 +7,7 @@ expected task failures = 1/foo [scheduling] [[graph]] - R1 = "foo:submit-fail => !foo" + R1 = "foo:submit-fail? => !foo" [runtime] [[foo]] script = true diff --git a/tests/functional/spawn-on-demand/18-submitted.t b/tests/functional/spawn-on-demand/18-submitted.t index 30f022ebafd..103fd1ad27f 100644 --- a/tests/functional/spawn-on-demand/18-submitted.t +++ b/tests/functional/spawn-on-demand/18-submitted.t @@ -40,7 +40,7 @@ reftest_run for number in 1 2 3; do grep_workflow_log_ok \ "${TEST_NAME_BASE}-a${number}" \ - "${number}/a${number}.* did not complete required outputs: \['submitted'\]" + "${number}/a${number}.* did not complete the required outputs:" done purge diff --git a/tests/functional/spawn-on-demand/18-submitted/flow.cylc b/tests/functional/spawn-on-demand/18-submitted/flow.cylc index 975f7827ffb..5c7494c9627 100644 --- a/tests/functional/spawn-on-demand/18-submitted/flow.cylc +++ b/tests/functional/spawn-on-demand/18-submitted/flow.cylc @@ -12,6 +12,7 @@ cycling mode = integer runahead limit = P10 [[graph]] + # tasks will finish with *in*complete outputs R/1 = """ # a1 should be incomplete (submission is implicitly required) a1? => b @@ -25,6 +26,8 @@ a3? => b a3:submitted => s """ + + # tasks will finish with complete outputs R/4 = """ # a4 should be complete (submission is explicitly optional) a4? => b diff --git a/tests/functional/spawn-on-demand/19-submitted-compat.t b/tests/functional/spawn-on-demand/19-submitted-compat.t index 98c603d55a7..20a7adbb765 100644 --- a/tests/functional/spawn-on-demand/19-submitted-compat.t +++ b/tests/functional/spawn-on-demand/19-submitted-compat.t @@ -54,6 +54,7 @@ grep_workflow_log_ok \ '\[1/a/01:running\] => succeeded' grep_workflow_log_ok \ "${TEST_NAME_BASE}-b-incomplete" \ - "1/b did not complete required outputs: \['submitted', 'succeeded'\]" + '1/b did not complete the required outputs:\n.*\n.*submitted.*\n.*succeeded\n' \ + -Pizoq purge diff --git a/tests/functional/triggering/14-submit-fail/flow.cylc b/tests/functional/triggering/14-submit-fail/flow.cylc index e2e865fa39c..ec28b82d49d 100644 --- a/tests/functional/triggering/14-submit-fail/flow.cylc +++ b/tests/functional/triggering/14-submit-fail/flow.cylc @@ -5,7 +5,7 @@ [scheduling] [[graph]] R1 = """ - foo:submit-fail => bar + foo:submit-fail? => bar bar => !foo """ [runtime] diff --git a/tests/functional/triggering/16-fam-expansion.t b/tests/functional/triggering/16-fam-expansion.t index 4b48161588c..809647a711e 100644 --- a/tests/functional/triggering/16-fam-expansion.t +++ b/tests/functional/triggering/16-fam-expansion.t @@ -31,13 +31,13 @@ workflow_run_ok "${TEST_NAME}" \ cylc play --debug --no-detach --set="SHOW_OUT='$SHOW_OUT'" "${WORKFLOW_NAME}" #------------------------------------------------------------------------------- contains_ok "$SHOW_OUT" <<'__SHOW_DUMP__' - + (((1 | 0) & (3 | 2) & (5 | 4)) & (0 | 2 | 4)) - + 0 = 1/foo1 failed - - 1 = 1/foo1 succeeded - + 2 = 1/foo2 failed - - 3 = 1/foo2 succeeded - + 4 = 1/foo3 failed - - 5 = 1/foo3 succeeded + ✓ (((1 | 0) & (3 | 2) & (5 | 4)) & (0 | 2 | 4)) + ✓ 0 = 1/foo1 failed + ⨯ 1 = 1/foo1 succeeded + ✓ 2 = 1/foo2 failed + ⨯ 3 = 1/foo2 succeeded + ✓ 4 = 1/foo3 failed + ⨯ 5 = 1/foo3 succeeded __SHOW_DUMP__ #------------------------------------------------------------------------------- purge diff --git a/tests/functional/xtriggers/03-sequence.t b/tests/functional/xtriggers/03-sequence.t index a41b970b3f1..1bb24d521a9 100644 --- a/tests/functional/xtriggers/03-sequence.t +++ b/tests/functional/xtriggers/03-sequence.t @@ -51,11 +51,11 @@ cylc play "${WORKFLOW_NAME}" poll_grep_workflow_log -E '2025/start.* => succeeded' -cylc show "${WORKFLOW_NAME}//2026/foo" | grep -E '^ - xtrigger' > 2026.foo.log +cylc show "${WORKFLOW_NAME}//2026/foo" | grep -E '^ ⨯ xtrigger' > 2026.foo.log # 2026/foo should get only xtrigger e2. cmp_ok 2026.foo.log - <<__END__ - - xtrigger "e2 = echo(name=alice, succeed=False)" + ⨯ xtrigger "e2 = echo(name=alice, succeed=False)" __END__ cylc stop --now --max-polls=10 --interval=2 "${WORKFLOW_NAME}" diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index d575f2a6af2..edfe56e2a1f 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -360,6 +360,28 @@ def _validate(id_: Union[str, Path], **kwargs) -> WorkflowConfig: return _validate +@pytest.fixture(scope='module') +def mod_validate(run_dir): + """Provides a function for validating workflow configurations. + + Attempts to load the configuration, will raise exceptions if there are + errors. + + Args: + id_ - The flow to validate + kwargs - Arguments to pass to ValidateOptions + """ + def _validate(id_: Union[str, Path], **kwargs) -> WorkflowConfig: + id_ = str(id_) + return WorkflowConfig( + id_, + str(Path(run_dir, id_, 'flow.cylc')), + ValidateOptions(**kwargs) + ) + + return _validate + + @pytest.fixture def capture_submission(): """Suppress job submission and capture submitted tasks. diff --git a/tests/integration/test_optional_outputs.py b/tests/integration/test_optional_outputs.py new file mode 100644 index 00000000000..d897fc4ce6d --- /dev/null +++ b/tests/integration/test_optional_outputs.py @@ -0,0 +1,380 @@ +# THIS FILE IS PART OF THE CYLC WORKFLOW ENGINE. +# Copyright (C) NIWA & British Crown (Met Office) & Contributors. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Tests optional output and task completion logic. + +This functionality is defined by the "optional-output-extension" proposal: + +https://cylc.github.io/cylc-admin/proposal-optional-output-extension.html#proposal +""" + +from itertools import combinations +from typing import TYPE_CHECKING + +import pytest + +from cylc.flow.cycling.integer import IntegerPoint +from cylc.flow.cycling.iso8601 import ISO8601Point +from cylc.flow.network.resolvers import TaskMsg +from cylc.flow.scheduler import Scheduler +from cylc.flow.task_events_mgr import ( + TaskEventsManager, +) +from cylc.flow.task_outputs import ( + TASK_OUTPUTS, + TASK_OUTPUT_EXPIRED, + TASK_OUTPUT_FINISHED, + TASK_OUTPUT_SUCCEEDED, + get_completion_expression, +) +from cylc.flow.task_state import ( + TASK_STATUS_EXPIRED, + TASK_STATUS_PREPARING, + TASK_STATUS_WAITING, +) + +if TYPE_CHECKING: + from cylc.flow.task_proxy import TaskProxy + + +def reset_outputs(itask: 'TaskProxy'): + """Undo the consequences of setting task outputs. + + This assumes you haven't completed the task. + """ + itask.state.outputs._completed = { + message: False + for message in itask.state.outputs._completed + } + itask.state_reset( + TASK_STATUS_WAITING, + is_queued=False, + is_held=False, + is_runahead=False, + ) + + +@pytest.mark.parametrize( + 'graph, completion_outputs', + [ + pytest.param( + 'a:x', + [{TASK_OUTPUT_SUCCEEDED, 'x'}], + id='1', + ), + pytest.param( + 'a\na:x\na:expired?', + [{TASK_OUTPUT_SUCCEEDED, 'x'}, {TASK_OUTPUT_EXPIRED}], + id='2', + ), + ], +) +async def test_task_completion( + flow, + scheduler, + start, + graph, + completion_outputs, + capcall, +): + """Ensure that task completion is watertight. + + Run through every possible permutation of outputs MINUS the ones that would + actually complete a task to ensure that task completion is correctly + handled. + + Note, the building and evaluation of completion expressions is also tested, + this is more of an end-to-end test to ensure everything is connected + properly. + """ + # prevent tasks from being removed from the pool when complete + capcall( + 'cylc.flow.task_pool.TaskPool.remove_if_complete' + ) + id_ = flow({ + 'scheduling': { + 'graph': {'R1': graph}, + }, + 'runtime': { + 'a': { + 'outputs': { + 'x': 'xxx', + }, + }, + }, + }) + schd = scheduler(id_) + all_outputs = { + # all built-in outputs + *TASK_OUTPUTS, + # all registered custom outputs + 'x' + # but not the finished psudo output + } - {TASK_OUTPUT_FINISHED} + + async with start(schd): + a1 = schd.pool.get_task(IntegerPoint('1'), 'a') + + # try every set of outputs that *shouldn't* complete the task + for combination in { + comb + # every possible combination of outputs + for _length in range(1, len(all_outputs)) + for comb in combinations(all_outputs, _length) + # that doesn't contain the outputs that would satisfy the task + if not any( + set(comb) & output_set == output_set + for output_set in completion_outputs + ) + }: + # set the combination of outputs + schd.pool.set_prereqs_and_outputs( + ['1/a'], + combination, + [], + ['1'], + ) + + # ensure these outputs do *not* complete the task + assert not a1.state.outputs.is_complete() + + # reset any changes + reset_outputs(a1) + + # now try the outputs that *should* satisfy the task + for combination in completion_outputs: + # set the combination of outputs + schd.pool.set_prereqs_and_outputs( + ['1/a'], + combination, + [], + ['1'], + ) + + # ensure the task *is* completed + assert a1.state.outputs.is_complete() + + # reset any changes + reset_outputs(a1) + + +async def test_expire_orthogonality(flow, scheduler, start): + """Ensure "expired?" does not infer "succeeded?". + + Asserts proposal point 2: + https://cylc.github.io/cylc-admin/proposal-optional-output-extension.html#proposal + """ + id_ = flow({ + 'scheduling': { + 'graph': { + 'R1': 'a:expire? => e' + }, + }, + }) + schd: 'Scheduler' = scheduler(id_, paused_start=False) + async with start(schd): + a_1 = schd.pool.get_task(IntegerPoint('1'), 'a') + + # wait for the task to submit + while not a_1.state(TASK_STATUS_WAITING, TASK_STATUS_PREPARING): + schd.release_queued_tasks() + + # NOTE: The submit number isn't presently incremented via this code + # pathway so we have to hack it here. If the task messages in this test + # get ignored because of some future change, then you can safely remove + # this line (it's not what this test is testing). + a_1.submit_num += 1 + + # tell the scheduler that the task *submit-failed* + schd.message_queue.put( + TaskMsg( + '1/a/01', + '2000-01-01T00:00:00+00', + 'INFO', + TaskEventsManager.EVENT_SUBMIT_FAILED + ), + ) + schd.process_queued_task_messages() + # ensure that the scheduler is stalled + assert not a_1.state.outputs.is_complete() + assert schd.pool.is_stalled() + + # tell the scheduler that the task *failed* + schd.message_queue.put( + TaskMsg( + '1/a/01', + '2000-01-01T00:00:00+00', + 'INFO', + TaskEventsManager.EVENT_FAILED, + ), + ) + schd.process_queued_task_messages() + # ensure that the scheduler is stalled + assert not a_1.state.outputs.is_complete() + assert schd.pool.is_stalled() + + # tell the scheduler that the task *expired* + schd.message_queue.put( + TaskMsg( + '1/a/01', + '2000-01-01T00:00:00+00', + 'INFO', + TaskEventsManager.EVENT_EXPIRED, + ), + ) + schd.process_queued_task_messages() + # ensure that the scheduler is *not* stalled + assert a_1.state.outputs.is_complete() + assert not schd.pool.is_stalled() + + +@pytest.fixture(scope='module') +def implicit_completion_config(mod_flow, mod_validate): + id_ = mod_flow({ + 'scheduling': { + 'graph': { + 'R1': ''' + a + + b? + + c:x + + d:x? + d:y? + d:z? + + e:x + e:y + e:z + + f? + f:x + + g:expired? + + h:succeeded? + h:expired? + + i:expired? + i:submitted + + j:expired? + j:submitted? + + k:submit-failed? + k:succeeded? + + l:expired? + l:submit-failed? + l:succeeded? + ''' + } + }, + 'runtime': { + 'root': { + 'outputs': { + 'x': 'xxx', + 'y': 'yyy', + 'z': 'zzz', + } + } + } + }) + return mod_validate(id_) + + +@pytest.mark.parametrize( + 'task, condition', + [ + pytest.param('a', 'succeeded', id='a'), + pytest.param('b', 'succeeded or failed', id='b'), + pytest.param('c', '(succeeded and x)', id='c'), + pytest.param('d', 'succeeded', id='d'), + pytest.param('e', '(succeeded and x and y and z)', id='e'), + pytest.param('f', '(x and succeeded) or failed', id='f'), + pytest.param('g', 'succeeded or expired', id='h'), + pytest.param('h', 'succeeded or failed or expired', id='h'), + pytest.param('i', '(submitted and succeeded) or expired', id='i'), + pytest.param('j', 'succeeded or submit_failed or expired', id='j'), + pytest.param('k', 'succeeded or failed or submit_failed', id='k'), + pytest.param( + 'l', 'succeeded or failed or submit_failed or expired', id='l' + ), + ], +) +async def test_implicit_completion_expression( + implicit_completion_config, + task, + condition, +): + """It should generate a completion expression from the graph. + + If no completion expression is provided in the runtime section, then it + should auto generate one inferring whether outputs are required or not from + the graph. + """ + completion_expression = get_completion_expression( + implicit_completion_config.taskdefs[task] + ) + assert completion_expression == condition + + +async def test_clock_expire_partially_satisfied_task( + flow, + scheduler, + start, +): + """Clock expire should take effect on a partially satisfied task. + + Tests proposal point 8: + https://cylc.github.io/cylc-admin/proposal-optional-output-extension.html#proposal + """ + id_ = flow({ + 'scheduling': { + 'initial cycle point': '2000', + 'runahead limit': 'P0', + 'special tasks': { + 'clock-expire': 'e', + }, + 'graph': { + 'P1D': ''' + # this prerequisite we will satisfy + a => e + + # this prerequisite we will leave unsatisfied creating a + # partially-satisfied task + b => e + ''' + }, + }, + }) + schd = scheduler(id_) + async with start(schd): + # satisfy one of the prerequisites + a = schd.pool.get_task(ISO8601Point('20000101T0000Z'), 'a') + assert a + schd.pool.spawn_on_output(a, TASK_OUTPUT_SUCCEEDED) + + # the task "e" should now be spawned + e = schd.pool.get_task(ISO8601Point('20000101T0000Z'), 'e') + assert e + + # check for clock-expired tasks + schd.pool.clock_expire_tasks() + + # the task should now be in the expired state + assert e.state(TASK_STATUS_EXPIRED) diff --git a/tests/integration/test_simulation.py b/tests/integration/test_simulation.py index 72cf23996a4..66842fade25 100644 --- a/tests/integration/test_simulation.py +++ b/tests/integration/test_simulation.py @@ -184,11 +184,10 @@ def test_task_finishes(sim_time_check_setup, monkeytime, caplog): # After simulation time is up it Fails and records custom outputs: assert sim_time_check(schd.task_events_mgr, [fail_all_1066], '') is True - outputs = { - o[0]: (o[1], o[2]) for o in fail_all_1066.state.outputs.get_all()} - assert outputs['succeeded'] == ('succeeded', False) - assert outputs['foo'] == ('bar', True) - assert outputs['failed'] == ('failed', True) + outputs = fail_all_1066.state.outputs + assert outputs.is_message_complete('succeeded') is False + assert outputs.is_message_complete('bar') is True + assert outputs.is_message_complete('failed') is True def test_task_sped_up(sim_time_check_setup, monkeytime): @@ -334,7 +333,7 @@ async def test_settings_reload( one_1066 = schd.pool.get_tasks()[0] itask = run_simjob(schd, one_1066.point, 'one') - assert ['failed', 'failed', False] in itask.state.outputs.get_all() + assert itask.state.outputs.is_message_complete('failed') is False # Modify config as if reinstall had taken place: conf_file = Path(schd.workflow_run_dir) / 'flow.cylc' @@ -346,8 +345,7 @@ async def test_settings_reload( # Submit second psuedo-job and "run" to success: itask = run_simjob(schd, one_1066.point, 'one') - assert [ - 'succeeded', 'succeeded', True] in itask.state.outputs.get_all() + assert itask.state.outputs.is_message_complete('succeeded') is True async def test_settings_broadcast( diff --git a/tests/integration/test_task_pool.py b/tests/integration/test_task_pool.py index 3d75074cf15..f323fdffdf3 100644 --- a/tests/integration/test_task_pool.py +++ b/tests/integration/test_task_pool.py @@ -1178,9 +1178,10 @@ async def test_detect_incomplete_tasks( start, log_filter, ): - """Finished but incomplete tasks should be retains as incomplete.""" - - final_task_states = { + """Finished but incomplete tasks should be retained as incomplete.""" + incomplete_final_task_states = { + # final task states that would leave a task with + # completion=succeeded incomplete TASK_STATUS_FAILED: TaskEventsManager.EVENT_FAILED, TASK_STATUS_EXPIRED: TaskEventsManager.EVENT_EXPIRED, TASK_STATUS_SUBMIT_FAILED: TaskEventsManager.EVENT_SUBMIT_FAILED @@ -1192,7 +1193,7 @@ async def test_detect_incomplete_tasks( 'scheduling': { 'graph': { # a workflow with one task for each of the final task states - 'R1': '\n'.join(final_task_states.keys()) + 'R1': '\n'.join(incomplete_final_task_states.keys()) } } }) @@ -1204,28 +1205,18 @@ async def test_detect_incomplete_tasks( # spawn the output corresponding to the task schd.pool.task_events_mgr.process_message( itask, 1, - final_task_states[itask.tdef.name] + incomplete_final_task_states[itask.tdef.name] ) # ensure that it is correctly identified as incomplete - assert itask.state.outputs.get_incomplete() - assert itask.state.outputs.is_incomplete() - if itask.tdef.name == TASK_STATUS_EXPIRED: - assert log_filter( - log, - contains=f"[{itask}] removed (expired)" - ) - # the task should have been removed - assert itask not in schd.pool.get_tasks() - else: - assert log_filter( - log, - contains=( - f"[{itask}] did not complete " - "required outputs:" - ) - ) - # the task should not have been removed - assert itask in schd.pool.get_tasks() + assert not itask.state.outputs.is_complete() + assert log_filter( + log, + contains=( + f"[{itask}] did not complete the required outputs:" + ), + ) + # the task should not have been removed + assert itask in schd.pool.get_tasks() async def test_future_trigger_final_point( @@ -1288,7 +1279,7 @@ async def test_set_failed_complete( assert log_filter( log, regex="1/one.* setting implied output: started") assert log_filter( - log, regex="failed.* did not complete required outputs") + log, regex="failed.* did not complete the required outputs") # Set failed task complete via default "set" args. schd.pool.set_prereqs_and_outputs([one.identity], None, None, ['all']) diff --git a/tests/integration/tui/screenshots/test_show.success.html b/tests/integration/tui/screenshots/test_show.success.html index 5f9c192b04b..b392130363b 100644 --- a/tests/integration/tui/screenshots/test_show.success.html +++ b/tests/integration/tui/screenshots/test_show.success.html @@ -8,7 +8,6 @@ - ──────────────────────────────────────────────── title: Foo description: The first metasyntactic @@ -16,13 +15,15 @@ URL: (not given) state: waiting prerequisites: (None) - outputs: ('-': not completed) - - 1/foo expired - - 1/foo submitted - - 1/foo submit-failed - - 1/foo started - - 1/foo succeeded - - 1/foo failed + outputs: ('⨯': not completed) + ⨯ 1/foo expired + ⨯ 1/foo submitted + ⨯ 1/foo submit-failed + ⨯ 1/foo started + ⨯ 1/foo succeeded + ⨯ 1/foo failed + output completion: incomplete + ⨯ ⦙ succeeded q to close @@ -35,7 +36,6 @@ - quit: q help: h context: enter tree: - ← + → navigation: ↑ ↓ ↥ ↧ Home End filter tasks: T f s r R filter workflows: W E p \ No newline at end of file diff --git a/tests/integration/validate/test_outputs.py b/tests/integration/validate/test_outputs.py index 5675372a09f..3ddeba0e96d 100644 --- a/tests/integration/validate/test_outputs.py +++ b/tests/integration/validate/test_outputs.py @@ -32,7 +32,6 @@ 'foo', 'foo-bar', 'foo_bar', - 'foo.bar', '0foo0', '123', ], @@ -152,7 +151,7 @@ def test_messages(messages, valid, flow, validate): 'runtime': { 'foo': { 'outputs': { - str(random()): message + str(random())[2:]: message for message in messages } } @@ -164,3 +163,120 @@ def test_messages(messages, valid, flow, validate): else: with pytest.raises(WorkflowConfigError): val() + + +@pytest.mark.parametrize( + 'graph, expression, message', [ + pytest.param( + 'foo:x', + 'succeeded and (x or y)', + r'foo:x is required in the graph.*' + r' but optional in the completion expression', + id='required-in-graph-optional-in-completion', + ), + pytest.param( + 'foo:x?', + 'succeeded and x', + r'foo:x is optional in the graph.*' + r' but required in the completion expression', + id='optional-in-graph-required-in-completion', + ), + pytest.param( + 'foo:x', + 'succeeded', + 'foo:x is required in the graph.*' + 'but not referenced in the completion expression', + id='required-in-graph-not-referenced-in-completion', + ), + pytest.param( + # tests proposal point 4: + # https://cylc.github.io/cylc-admin/proposal-optional-output-extension.html#proposal + 'foo:expired', + 'succeeded', + 'foo:expired must be optional', + id='expire-required-in-graph', + ), + pytest.param( + 'foo:expired?', + 'succeeded', + 'foo:expired is permitted in the graph.*' + '\nTry: completion = "succeeded or expired"', + id='expire-optional-in-graph-but-not-used-in-completion' + ), + pytest.param( + # tests part of proposal point 5: + # https://cylc.github.io/cylc-admin/proposal-optional-output-extension.html#proposal + 'foo', + 'finished and x', + '"finished" output cannot be used in completion expressions', + id='finished-output-used-in-completion-expression', + ), + pytest.param( + # https://github.com/cylc/cylc-flow/pull/6046#issuecomment-2059266086 + 'foo?', + 'x and failed', + 'foo:failed is optional in the graph.*' + 'but required in the completion expression', + id='failed-implicitly-optional-in-graph-required-in-completion', + ), + ] +) +def test_completion_expression_invalid( + flow, + validate, + graph, + expression, + message, +): + """It should ensure the completion is logically consistent with the graph. + + Tests proposal point 5: + https://cylc.github.io/cylc-admin/proposal-optional-output-extension.html#proposal + """ + id_ = flow({ + 'scheduling': { + 'graph': {'R1': graph}, + }, + 'runtime': { + 'foo': { + 'completion': expression, + 'outputs': { + 'x': 'xxx', + 'y': 'yyy', + }, + }, + }, + }) + with pytest.raises(WorkflowConfigError, match=message): + validate(id_) + + +@pytest.mark.parametrize( + 'graph, expression', [ + ('foo', 'succeeded and (x or y or z)'), + ('foo?', 'succeeded and (x or y or z) or failed or expired'), + ('foo', '(succeeded and x) or (expired and y)'), + ] +) +def test_completion_expression_valid( + flow, + validate, + graph, + expression, +): + id_ = flow({ + 'scheduling': { + 'graph': {'R1': graph}, + }, + 'runtime': { + 'foo': { + 'completion': expression, + 'outputs': { + 'x': 'xxx', + 'y': 'yyy', + 'z': 'zzz', + }, + }, + }, + }) + validate(id_) diff --git a/tests/unit/scripts/test_config.py b/tests/unit/scripts/test_config.py index 55d1b69dcdd..a7f67c4da69 100644 --- a/tests/unit/scripts/test_config.py +++ b/tests/unit/scripts/test_config.py @@ -239,4 +239,6 @@ def test_cylc_config_xtriggers(tmp_run_dir, capsys: pytest.CaptureFixture): R1 = @rotund => foo [runtime] [[root]] + [[foo]] + completion = succeeded """) diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 98d9fc2f4ce..a69ab176412 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -1317,28 +1317,6 @@ def test_implicit_success_required(tmp_flow_config, graph): assert cfg.taskdefs['foo'].outputs[TASK_OUTPUT_SUCCEEDED][1] -@pytest.mark.parametrize( - 'graph', - [ - "foo:submit? => bar", - "foo:submit-fail? => bar", - ] -) -def test_success_after_optional_submit(tmp_flow_config, graph): - """Check foo:succeed is not required if foo:submit is optional.""" - id_ = 'blargh' - flow_file = tmp_flow_config(id_, f""" - [scheduling] - [[graph]] - R1 = {graph} - [runtime] - [[bar]] - [[foo]] - """) - cfg = WorkflowConfig(workflow=id_, fpath=flow_file, options=None) - assert not cfg.taskdefs['foo'].outputs[TASK_OUTPUT_SUCCEEDED][1] - - @pytest.mark.parametrize( 'allow_implicit_tasks', [ diff --git a/tests/unit/test_graph_parser.py b/tests/unit/test_graph_parser.py index 69726a0730d..84a8e4611fd 100644 --- a/tests/unit/test_graph_parser.py +++ b/tests/unit/test_graph_parser.py @@ -735,7 +735,6 @@ def test_task_optional_outputs(): ('succeed', TASK_OUTPUT_SUCCEEDED), ('fail', TASK_OUTPUT_FAILED), ('submit', TASK_OUTPUT_SUBMITTED), - ('submit-fail', TASK_OUTPUT_SUBMIT_FAILED), ] ) def test_family_optional_outputs(qual, task_output): @@ -766,6 +765,26 @@ def test_family_optional_outputs(qual, task_output): assert gp.task_output_opt[(member, task_output)][0] == optional +def test_cannot_be_required(): + """Is should not allow :expired or :submit-failed to be required. + + See proposal point 4: + https://cylc.github.io/cylc-admin/proposal-optional-output-extension.html#proposal + """ + gp = GraphParser({}) + + # outputs can be optional + gp.parse_graph('a:expired? => b') + gp.parse_graph('a:submit-failed? => b') + + # but cannot be required + with pytest.raises(GraphParseError, match='must be optional'): + gp.parse_graph('a:expired => b') + with pytest.raises(GraphParseError, match='must be optional'): + gp.parse_graph('a:submit-failed => b') + + + @pytest.mark.parametrize( 'graph, error', [ diff --git a/tests/unit/test_subprocpool.py b/tests/unit/test_subprocpool.py index c72ffc4d094..7da14e9e73c 100644 --- a/tests/unit/test_subprocpool.py +++ b/tests/unit/test_subprocpool.py @@ -30,6 +30,13 @@ from cylc.flow.task_events_mgr import TaskJobLogsRetrieveContext from cylc.flow.subprocctx import SubProcContext from cylc.flow.subprocpool import SubProcPool, _XTRIG_FUNC_CACHE, _XTRIG_MOD_CACHE, get_xtrig_func +from cylc.flow.task_outputs import ( + TASK_OUTPUT_SUBMITTED, + TASK_OUTPUT_SUBMIT_FAILED, + TASK_OUTPUT_SUCCEEDED, + TASK_OUTPUT_FAILED, + TASK_OUTPUT_EXPIRED, +) from cylc.flow.task_proxy import TaskProxy @@ -316,8 +323,7 @@ def test__run_command_exit_add_to_badhosts(mock_ctx): def test__run_command_exit_add_to_badhosts_log(caplog, mock_ctx): - """It gets platform name from the callback args. - """ + """It gets platform name from the callback args.""" badhosts = {'foo', 'bar'} SubProcPool._run_command_exit( mock_ctx(cmd=['ssh']), @@ -330,7 +336,11 @@ def test__run_command_exit_add_to_badhosts_log(caplog, mock_ctx): external_triggers=[], xtrig_labels={}, expiration_offset=None, outputs={ - 'submitted': [None, None], 'submit-failed': [None, None] + TASK_OUTPUT_SUBMITTED: [None, None], + TASK_OUTPUT_SUBMIT_FAILED: [None, None], + TASK_OUTPUT_SUCCEEDED: [None, None], + TASK_OUTPUT_FAILED: [None, None], + TASK_OUTPUT_EXPIRED: [None, None], }, graph_children={}, rtconfig={'platform': 'foo'} diff --git a/tests/unit/test_task_outputs.py b/tests/unit/test_task_outputs.py index 4f61e696fbc..70a297edff5 100644 --- a/tests/unit/test_task_outputs.py +++ b/tests/unit/test_task_outputs.py @@ -13,30 +13,284 @@ # # You should have received a copy of the GNU General Public License # along with this program. If not, see . -import random -import unittest - -from cylc.flow.task_outputs import TaskOutputs - - -class TestMessageSorting(unittest.TestCase): - - TEST_MESSAGES = [ - ['expired', 'expired', False], - ['submitted', 'submitted', False], - ['submit-failed', 'submit-failed', False], - ['started', 'started', False], - ['succeeded', 'succeeded', False], - ['failed', 'failed', False], - [None, None, False], - ['foo', 'bar', False], - ['foot', 'bart', False], - # NOTE: [None, 'bar', False] is unstable under Python2 - ] - - def test_sorting(self): - messages = list(self.TEST_MESSAGES) - for _ in range(5): - random.shuffle(messages) - output = sorted(messages, key=TaskOutputs.msg_sort_key) - self.assertEqual(output, self.TEST_MESSAGES, output) + +from types import SimpleNamespace + +import pytest + +from cylc.flow.task_outputs import ( + TASK_OUTPUTS, + TASK_OUTPUT_EXPIRED, + TASK_OUTPUT_FAILED, + TASK_OUTPUT_SUBMITTED, + TASK_OUTPUT_SUBMIT_FAILED, + TASK_OUTPUT_SUCCEEDED, + TaskOutputs, + get_completion_expression, + get_trigger_completion_variable_maps, +) +from cylc.flow.util import sstrip + + +def tdef(required, optional, completion=None): + """Stub a task definition. + + Args: + required: Collection of required outputs. + optional: Collection of optional outputs. + completion: User defined execution completion expression. + + """ + return SimpleNamespace( + rtconfig={ + 'completion': completion, + }, + outputs={ + output: ( + output, + ( + # output is required: + True if output in required + # output is optional: + else False if output in optional + # output is ambiguous (i.e. not referenced in graph): + else None + ) + ) + for output in set(TASK_OUTPUTS) | set(required) | set(optional) + }, + ) + + +def test_completion_implicit(): + """It should generate a completion expression when none is provided. + + The outputs should be considered "complete" according to the logic in + proposal point 5: + https://cylc.github.io/cylc-admin/proposal-optional-output-extension.html#proposal + """ + # one required output - succeeded + outputs = TaskOutputs(tdef([TASK_OUTPUT_SUCCEEDED], [])) + + # the completion expression should only contain the one required output + assert outputs._completion_expression == 'succeeded' + # the outputs should be incomplete - it hasn't run yet + assert outputs.is_complete() is False + + # set the submit-failed output + outputs.set_message_complete(TASK_OUTPUT_SUBMIT_FAILED) + # the outputs should be incomplete - submited-failed is a "final" output + assert outputs.is_complete() is False + + # set the submitted and succeeded outputs + outputs.set_message_complete(TASK_OUTPUT_SUBMITTED) + outputs.set_message_complete(TASK_OUTPUT_SUCCEEDED) + # the outputs should be complete - it has run an succeedd + assert outputs.is_complete() is True + + # set the expired output + outputs.set_message_complete(TASK_OUTPUT_EXPIRED) + # the outputs should still be complete - it has run and succeeded + assert outputs.is_complete() is True + + +def test_completion_explicit(): + """It should use the provided completion expression. + + The outputs should be considered "complete" according to the logic in + proposal point 5: + https://cylc.github.io/cylc-admin/proposal-optional-output-extension.html#proposal + """ + outputs = TaskOutputs(tdef( + # no required outputs + [], + # four optional outputs + [ + TASK_OUTPUT_SUCCEEDED, + TASK_OUTPUT_FAILED, + 'x', + 'y', + ], + # one pair must be satisfied for the outputs to be complete + completion='(succeeded and x) or (failed and y)', + )) + + # the outputs should be incomplete - it hasn't run yet + assert outputs.is_complete() is False + + # set the succeeded and failed outputs + outputs.set_message_complete(TASK_OUTPUT_SUCCEEDED) + outputs.set_message_complete(TASK_OUTPUT_FAILED) + + # the task should be incomplete - it has executed but the completion + # expression is not satisfied + assert outputs.is_complete() is False + + # satisfy the (failed and y) pair + outputs.set_message_complete('y') + assert outputs.is_complete() is True + + # satisfy the (succeeded and x) pair + outputs._completed['y'] = False + outputs.set_message_complete('x') + assert outputs.is_complete() is True + + +@pytest.mark.parametrize( + 'required, optional, expression', [ + pytest.param( + {TASK_OUTPUT_SUCCEEDED}, + [], + 'succeeded', + id='0', + ), + pytest.param( + {TASK_OUTPUT_SUCCEEDED, 'x'}, + [], + '(succeeded and x)', + id='1', + ), + pytest.param( + [], + {TASK_OUTPUT_SUCCEEDED}, + 'succeeded or failed', + id='2', + ), + pytest.param( + {TASK_OUTPUT_SUCCEEDED}, + {TASK_OUTPUT_EXPIRED}, + 'succeeded or expired', + id='3', + ), + pytest.param( + [], + {TASK_OUTPUT_SUCCEEDED, TASK_OUTPUT_EXPIRED}, + 'succeeded or failed or expired', + id='4', + ), + pytest.param( + {TASK_OUTPUT_SUCCEEDED}, + {TASK_OUTPUT_EXPIRED, TASK_OUTPUT_SUBMITTED}, + 'succeeded or submit_failed or expired', + id='5', + ), + pytest.param( + {TASK_OUTPUT_SUCCEEDED, TASK_OUTPUT_SUBMITTED}, + {TASK_OUTPUT_EXPIRED}, + '(submitted and succeeded) or expired', + id='6', + ), + pytest.param( + [], + {TASK_OUTPUT_SUCCEEDED, TASK_OUTPUT_SUBMIT_FAILED}, + 'succeeded or failed or submit_failed', + id='7', + ), + pytest.param( + {'x'}, + { + TASK_OUTPUT_SUCCEEDED, + TASK_OUTPUT_SUBMIT_FAILED, + TASK_OUTPUT_EXPIRED, + }, + '(x and succeeded) or failed or submit_failed or expired', + id='8', + ), + ], +) +def test_get_completion_expression_implicit(required, optional, expression): + """It should generate a completion expression if none is provided.""" + assert get_completion_expression(tdef(required, optional)) == expression + + +def test_get_completion_expression_explicit(): + """If a completion expression is used, it should be used unmodified.""" + assert get_completion_expression(tdef( + {'x', 'y'}, + {TASK_OUTPUT_SUCCEEDED, TASK_OUTPUT_FAILED, TASK_OUTPUT_EXPIRED}, + '((failed and x) or (succeeded and y)) or expired' + )) == '((failed and x) or (succeeded and y)) or expired' + + +def test_format_completion_status(): + outputs = TaskOutputs( + tdef( + {TASK_OUTPUT_SUCCEEDED, 'x', 'y'}, + {TASK_OUTPUT_EXPIRED}, + ) + ) + assert outputs.format_completion_status( + indent=2, gutter=2 + ) == ' ' + sstrip( + ''' + ⦙ ( + ⨯ ⦙ succeeded + ⨯ ⦙ and x + ⨯ ⦙ and y + ⦙ ) + ⨯ ⦙ or expired + ''' + ) + outputs.set_message_complete('succeeded') + outputs.set_message_complete('x') + assert outputs.format_completion_status( + indent=2, gutter=2 + ) == ' ' + sstrip( + ''' + ⦙ ( + ✓ ⦙ succeeded + ✓ ⦙ and x + ⨯ ⦙ and y + ⦙ ) + ⨯ ⦙ or expired + ''' + ) + + +def test_iter_required_outputs(): + """It should yield required outputs only.""" + # this task has three required outputs and one optional output + outputs = TaskOutputs( + tdef( + {TASK_OUTPUT_SUCCEEDED, 'x', 'y'}, + {'z'} + ) + ) + assert set(outputs.iter_required_messages()) == { + TASK_OUTPUT_SUCCEEDED, + 'x', + 'y', + } + + # this task does not have any required outputs (besides the implicitly + # required submitted/started outputs) + outputs = TaskOutputs( + tdef( + # Note: validation should prevent this at the config level + {TASK_OUTPUT_SUCCEEDED, 'x', 'y'}, + {TASK_OUTPUT_FAILED}, # task may fail + ) + ) + assert set(outputs.iter_required_messages()) == set() + + # the preconditions expiry/submitted are excluded from this logic when + # defined as optional + outputs = TaskOutputs( + tdef( + {TASK_OUTPUT_SUCCEEDED, 'x', 'y'}, + {TASK_OUTPUT_EXPIRED}, # task may expire + ) + ) + assert outputs._completion_expression == '(succeeded and x and y) or expired' + assert set(outputs.iter_required_messages()) == { + TASK_OUTPUT_SUCCEEDED, + 'x', + 'y', + } + + +def test_get_trigger_completion_variable_maps(): + """It should return a bi-map of triggers to compvars.""" + t2c, c2t = get_trigger_completion_variable_maps(('a', 'b-b', 'c-c-c')) + assert t2c == {'a': 'a', 'b-b': 'b_b', 'c-c-c': 'c_c_c'} + assert c2t == {'a': 'a', 'b_b': 'b-b', 'c_c_c': 'c-c-c'} diff --git a/tests/unit/test_xtrigger_mgr.py b/tests/unit/test_xtrigger_mgr.py index 3cfee363d15..5192a7d3dd8 100644 --- a/tests/unit/test_xtrigger_mgr.py +++ b/tests/unit/test_xtrigger_mgr.py @@ -154,10 +154,10 @@ def test_housekeeping_with_xtrigger_satisfied(xtrigger_mgr): xtrig.out = "[\"True\", {\"name\": \"Yossarian\"}]" tdef = TaskDef( name="foo", - rtcfg=None, + rtcfg={'completion': None}, run_mode="live", start_point=1, - initial_point=1 + initial_point=1, ) init() sequence = ISO8601Sequence('P1D', '2019') @@ -197,7 +197,7 @@ def test__call_xtriggers_async(xtrigger_mgr): # create a task tdef = TaskDef( name="foo", - rtcfg=None, + rtcfg={'completion': None}, run_mode="live", start_point=1, initial_point=1 From 777c6c358f16e076f6dccd29246a4456bb177224 Mon Sep 17 00:00:00 2001 From: Oliver Sanders Date: Mon, 25 Mar 2024 10:43:25 +0000 Subject: [PATCH 012/196] clock-expire: exclude active tasks * Active tasks should not be considered for clock-expiry. Closes https://github.com/cylc/cylc-flow/issues/6025 * Manually triggered tasks should not be considered for clock-expiry. Implements proposal point 10 https://cylc.github.io/cylc-admin/proposal-optional-output-extension.html#proposal --- cylc/flow/task_pool.py | 23 ++++++-- tests/integration/test_optional_outputs.py | 63 ++++++++++++++++++++++ 2 files changed, 82 insertions(+), 4 deletions(-) diff --git a/cylc/flow/task_pool.py b/cylc/flow/task_pool.py index 1c6eeb17272..68aa79ccaf0 100644 --- a/cylc/flow/task_pool.py +++ b/cylc/flow/task_pool.py @@ -2161,10 +2161,25 @@ def spawn_parentless_sequential_xtriggers(self): def clock_expire_tasks(self): """Expire any tasks past their clock-expiry time.""" for itask in self.get_tasks(): - if not itask.clock_expire(): - continue - self.task_events_mgr.process_message( - itask, logging.WARNING, TASK_OUTPUT_EXPIRED) + if ( + # force triggered tasks can not clock-expire + # see proposal point 10: + # https://cylc.github.io/cylc-admin/proposal-optional-output-extension.html#proposal + not itask.is_manual_submit + + # only waiting tasks can clock-expire + # see https://github.com/cylc/cylc-flow/issues/6025 + # (note retrying tasks will be in the waiting state) + and itask.state(TASK_STATUS_WAITING) + + # check if this task is clock expired + and itask.clock_expire() + ): + self.task_events_mgr.process_message( + itask, + logging.WARNING, + TASK_OUTPUT_EXPIRED, + ) def task_succeeded(self, id_): """Return True if task with id_ is in the succeeded state.""" diff --git a/tests/integration/test_optional_outputs.py b/tests/integration/test_optional_outputs.py index d897fc4ce6d..afab3d9e8fa 100644 --- a/tests/integration/test_optional_outputs.py +++ b/tests/integration/test_optional_outputs.py @@ -44,6 +44,7 @@ TASK_STATUS_EXPIRED, TASK_STATUS_PREPARING, TASK_STATUS_WAITING, + TASK_STATUSES_ACTIVE, ) if TYPE_CHECKING: @@ -378,3 +379,65 @@ async def test_clock_expire_partially_satisfied_task( # the task should now be in the expired state assert e.state(TASK_STATUS_EXPIRED) + + +async def test_clock_expiry( + flow, + scheduler, + start, +): + """Waiting tasks should be considered for clock-expiry. + + Tests two things: + + * Manually triggered tasks should not be considered for clock-expiry. + + Tests proposal point 10: + https://cylc.github.io/cylc-admin/proposal-optional-output-extension.html#proposal + + * Active tasks should not be considered for clock-expiry. + + Closes https://github.com/cylc/cylc-flow/issues/6025 + """ + id_ = flow({ + 'scheduling': { + 'initial cycle point': '2000', + 'runahead limit': 'P1', + 'special tasks': { + 'clock-expire': 'x' + }, + 'graph': { + 'P1Y': 'x' + }, + }, + }) + schd = scheduler(id_) + async with start(schd): + # the first task (waiting) + one = schd.pool.get_task(ISO8601Point('20000101T0000Z'), 'x') + assert one + + # the second task (preparing) + two = schd.pool.get_task(ISO8601Point('20010101T0000Z'), 'x') + assert two + two.state_reset(TASK_STATUS_PREPARING) + + # the third task (force-triggered) + schd.pool.force_trigger_tasks(['20100101T0000Z/x'], ['1']) + three = schd.pool.get_task(ISO8601Point('20100101T0000Z'), 'x') + assert three + + # check for expiry + schd.pool.clock_expire_tasks() + + # the first task should be expired (it was waiting) + assert one.state(TASK_STATUS_EXPIRED) + assert one.state.outputs.is_message_complete(TASK_OUTPUT_EXPIRED) + + # the second task should *not* be expired (it was active) + assert not two.state(TASK_STATUS_EXPIRED) + assert not two.state.outputs.is_message_complete(TASK_OUTPUT_EXPIRED) + + # the third task should *not* be expired (it was a manual submit) + assert not three.state(TASK_STATUS_EXPIRED) + assert not three.state.outputs.is_message_complete(TASK_OUTPUT_EXPIRED) From f7d65ff60eed2929a0ab3ae7158aedfcc1982cc4 Mon Sep 17 00:00:00 2001 From: Oliver Sanders Date: Mon, 8 Apr 2024 10:40:26 +0100 Subject: [PATCH 013/196] outputs: handle tasks that have been removed from the config --- cylc/flow/task_outputs.py | 23 ++++++- tests/integration/test_optional_outputs.py | 71 +++++++++++++++++++++- 2 files changed, 91 insertions(+), 3 deletions(-) diff --git a/cylc/flow/task_outputs.py b/cylc/flow/task_outputs.py index 66e56a5c44b..a9af2d34dcc 100644 --- a/cylc/flow/task_outputs.py +++ b/cylc/flow/task_outputs.py @@ -256,6 +256,21 @@ def get_optional_outputs( } +# a completion expression that considers the outputs complete if any final task +# output is received +FINAL_OUTPUT_COMPLETION = ' or '.join( + map( + trigger_to_completion_variable, + [ + TASK_OUTPUT_SUCCEEDED, + TASK_OUTPUT_FAILED, + TASK_OUTPUT_SUBMIT_FAILED, + TASK_OUTPUT_EXPIRED, + ], + ) +) + + class TaskOutputs: """Represents a collection of outputs for a task. @@ -396,8 +411,14 @@ def __iter__(self) -> Iterator[Tuple[str, str, bool]]: def is_complete(self) -> bool: """Return True if the outputs are complete.""" + # NOTE: If a task has been removed from the workflow via restart / + # reload, then it is possible for the completion expression to be blank + # (empty string). In this case, we consider the task outputs to be + # complete when any final output has been generated. + # See https://github.com/cylc/cylc-flow/pull/5067 + expr = self._completion_expression or FINAL_OUTPUT_COMPLETION return CompletionEvaluator( - self._completion_expression, + expr, **{ self._message_to_compvar[message]: completed for message, completed in self._completed.items() diff --git a/tests/integration/test_optional_outputs.py b/tests/integration/test_optional_outputs.py index afab3d9e8fa..d5c4e41ce81 100644 --- a/tests/integration/test_optional_outputs.py +++ b/tests/integration/test_optional_outputs.py @@ -29,26 +29,28 @@ from cylc.flow.cycling.integer import IntegerPoint from cylc.flow.cycling.iso8601 import ISO8601Point from cylc.flow.network.resolvers import TaskMsg -from cylc.flow.scheduler import Scheduler from cylc.flow.task_events_mgr import ( TaskEventsManager, ) from cylc.flow.task_outputs import ( TASK_OUTPUTS, TASK_OUTPUT_EXPIRED, + TASK_OUTPUT_FAILED, TASK_OUTPUT_FINISHED, TASK_OUTPUT_SUCCEEDED, get_completion_expression, ) from cylc.flow.task_state import ( + TASK_STATUSES_ACTIVE, TASK_STATUS_EXPIRED, TASK_STATUS_PREPARING, + TASK_STATUS_RUNNING, TASK_STATUS_WAITING, - TASK_STATUSES_ACTIVE, ) if TYPE_CHECKING: from cylc.flow.task_proxy import TaskProxy + from cylc.flow.scheduler import Scheduler def reset_outputs(itask: 'TaskProxy'): @@ -441,3 +443,68 @@ async def test_clock_expiry( # the third task should *not* be expired (it was a manual submit) assert not three.state(TASK_STATUS_EXPIRED) assert not three.state.outputs.is_message_complete(TASK_OUTPUT_EXPIRED) + + +async def test_removed_taskdef( + flow, + scheduler, + start, +): + """It should handle tasks being removed from the config. + + If the config of an active task is removed from the config by restart / + reload, then we must provide a fallback completion expression, otherwise + the expression will be blank (task has no required or optional outputs). + + The fallback is to consider the outputs complete if *any* final output is + received. Since the task has been removed from the workflow its outputs + should be inconsequential. + + See: https://github.com/cylc/cylc-flow/issues/5057 + """ + id_ = flow({ + 'scheduling': { + 'graph': { + 'R1': 'a & z' + } + } + }) + + # start the workflow and mark the tasks as running + schd: 'Scheduler' = scheduler(id_) + async with start(schd): + for itask in schd.pool.get_tasks(): + itask.state_reset(TASK_STATUS_RUNNING) + assert itask.state.outputs._completion_expression == 'succeeded' + + # remove the task "z" from the config + id_ = flow({ + 'scheduling': { + 'graph': { + 'R1': 'a' + } + } + }, id_=id_) + + # restart the workflow + schd: 'Scheduler' = scheduler(id_) + async with start(schd): + # 1/a: + # * is still in the config + # * is should still have a sensible completion expression + # * its outputs should be incomplete if the task fails + a_1 = schd.pool.get_task(IntegerPoint('1'), 'a') + assert a_1 + assert a_1.state.outputs._completion_expression == 'succeeded' + a_1.state.outputs.set_message_complete(TASK_OUTPUT_FAILED) + assert not a_1.is_complete() + + # 1/z: + # * is no longer in the config + # * should have a blank completion expression + # * its outputs should be completed by any final output + z_1 = schd.pool.get_task(IntegerPoint('1'), 'z') + assert z_1 + assert z_1.state.outputs._completion_expression == '' + z_1.state.outputs.set_message_complete(TASK_OUTPUT_FAILED) + assert z_1.is_complete() From b32c6e0378c10073eee4a15758cb086c2310b1bb Mon Sep 17 00:00:00 2001 From: Oliver Sanders Date: Mon, 22 Apr 2024 10:26:15 +0100 Subject: [PATCH 014/196] remove: add integration tests * Ensure removed tasks are not automatically re-spawned by task pool logic. * Ensure removed tasks can be manually re-spawned by request. --- tests/integration/test_task_pool.py | 80 +++++++++++++++++++++++++++-- 1 file changed, 76 insertions(+), 4 deletions(-) diff --git a/tests/integration/test_task_pool.py b/tests/integration/test_task_pool.py index f92d822f39e..5c2ac56a257 100644 --- a/tests/integration/test_task_pool.py +++ b/tests/integration/test_task_pool.py @@ -1929,7 +1929,12 @@ async def test_remove_by_suicide( start, log_filter ): - """Test task removal by suicide trigger.""" + """Test task removal by suicide trigger. + + * Suicide triggers should remove tasks from the pool. + * It should be possible to bring them back by manually triggering them. + * Removing a task manually (cylc remove) should work the same. + """ id_ = flow({ 'scheduler': {'allow implicit tasks': 'True'}, 'scheduling': { @@ -1938,16 +1943,83 @@ async def test_remove_by_suicide( }, } }) - schd = scheduler(id_) + schd: 'Scheduler' = scheduler(id_) async with start(schd) as log: # it should start up with 1/a and 1/b assert pool_get_task_ids(schd.pool) == ["1/a", "1/b"] - a = schd.pool.get_task(IntegerPoint("1"), "a") + # mark 1/a as failed and ensure 1/b is removed by suicide trigger schd.pool.spawn_on_output(a, TASK_OUTPUT_FAILED) assert log_filter( log, - contains="removed from active task pool: suicide trigger" + regex="1/b.*removed from active task pool: suicide trigger" ) assert pool_get_task_ids(schd.pool) == ["1/a"] + + # ensure that we are able to bring 1/b back by triggering it + log.clear() + schd.pool.force_trigger_tasks(['1/b'], ['1']) + assert log_filter( + log, + regex='1/b.*added to active task pool', + ) + + # remove 1/b by request (cylc remove) + schd.command_remove_tasks(['1/b']) + assert log_filter( + log, + regex='1/b.*removed from active task pool: request', + ) + + # ensure that we are able to bring 1/b back by triggering it + log.clear() + schd.pool.force_trigger_tasks(['1/b'], ['1']) + assert log_filter( + log, + regex='1/b.*added to active task pool', + ) + + +async def test_remove_no_respawn(flow, scheduler, start, log_filter): + """Ensure that removed tasks stay removed. + + If a task is removed by suicide trigger or "cylc remove", then it should + not be automatically spawned at a later time. + """ + id_ = flow({ + 'scheduling': { + 'graph': { + 'R1': 'a & b => z', + }, + }, + }) + schd: 'Scheduler' = scheduler(id_) + async with start(schd) as log: + a1 = schd.pool.get_task(IntegerPoint("1"), "a") + b1 = schd.pool.get_task(IntegerPoint("1"), "b") + assert a1, '1/a should have been spawned on startup' + assert b1, '1/b should have been spawned on startup' + + # mark one of the upstream tasks as succeeded, 1/z should spawn + schd.pool.spawn_on_output(a1, TASK_OUTPUT_SUCCEEDED) + schd.workflow_db_mgr.process_queued_ops() + z1 = schd.pool.get_task(IntegerPoint("1"), "z") + assert z1, '1/z should have been spawned after 1/a succeeded' + + # manually remove 1/z, it should be removed from the pool + schd.command_remove_tasks(['1/z']) + schd.workflow_db_mgr.process_queued_ops() + z1 = schd.pool.get_task(IntegerPoint("1"), "z") + assert z1 is None, '1/z should have been removed (by request)' + + # mark the other upstream task as succeeded, 1/z should not be + # respawned as a result + schd.pool.spawn_on_output(b1, TASK_OUTPUT_SUCCEEDED) + assert log_filter( + log, contains='Not spawning 1/z: already used in this flow' + ) + z1 = schd.pool.get_task(IntegerPoint("1"), "z") + assert ( + z1 is None + ), '1/z should have stayed removed (but has been added back into the pool' From 68f8959743bb6d1cc8b3e6e873e060a69cd87d60 Mon Sep 17 00:00:00 2001 From: Hilary James Oliver Date: Tue, 23 Apr 2024 10:23:03 +1200 Subject: [PATCH 015/196] Update cylc/flow/task_pool.py --- cylc/flow/task_pool.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cylc/flow/task_pool.py b/cylc/flow/task_pool.py index 7e3b77275e8..b7f8c0601cb 100644 --- a/cylc/flow/task_pool.py +++ b/cylc/flow/task_pool.py @@ -1654,7 +1654,7 @@ def spawn_task( submit_num == 0 ): # Previous instance removed before completing any outputs. - LOG.info(f"Not spawning {point}/{name}: already used in this flow") + LOG.info(f"Flow stopping at {point}/{name} - task previously removed") return None itask = self._get_task_proxy_db_outputs( From 6cf018a858333744be91ea6e579c05f5b8a9a8a5 Mon Sep 17 00:00:00 2001 From: Hilary James Oliver Date: Tue, 23 Apr 2024 10:35:39 +1200 Subject: [PATCH 016/196] Update changes.d/6067.fix.md --- changes.d/6067.fix.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changes.d/6067.fix.md b/changes.d/6067.fix.md index 74d296f1d17..bea01066dae 100644 --- a/changes.d/6067.fix.md +++ b/changes.d/6067.fix.md @@ -1 +1 @@ -Fixed very quick respawn after task removal. +Fixed a bug that sometimes allowed suicide-triggered or manually removed tasks to be added back later. From 51aae7c159ea5207e9cf59c0bfab540e28d84cb8 Mon Sep 17 00:00:00 2001 From: Hilary James Oliver Date: Tue, 23 Apr 2024 11:25:18 +1200 Subject: [PATCH 017/196] Update tests/integration/test_task_pool.py Co-authored-by: Ronnie Dutta <61982285+MetRonnie@users.noreply.github.com> --- tests/integration/test_task_pool.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/integration/test_task_pool.py b/tests/integration/test_task_pool.py index 5c2ac56a257..2ad2e26a068 100644 --- a/tests/integration/test_task_pool.py +++ b/tests/integration/test_task_pool.py @@ -1939,7 +1939,10 @@ async def test_remove_by_suicide( 'scheduler': {'allow implicit tasks': 'True'}, 'scheduling': { 'graph': { - 'R1': 'a? & b\n a:failed? => !b' + 'R1': ''' + a? & b + a:failed? => !b + ''' }, } }) From d9294ef0128bb69bfa03d37c3aa078d448a5b8ea Mon Sep 17 00:00:00 2001 From: Oliver Sanders Date: Mon, 22 Apr 2024 12:58:04 +0100 Subject: [PATCH 018/196] main loop: add total memory to log memory output --- cylc/flow/main_loop/log_memory.py | 9 ++++++--- tests/unit/main_loop/test_log_memory.py | 6 ++++-- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/cylc/flow/main_loop/log_memory.py b/cylc/flow/main_loop/log_memory.py index f2136bbdaca..1766c305860 100644 --- a/cylc/flow/main_loop/log_memory.py +++ b/cylc/flow/main_loop/log_memory.py @@ -108,9 +108,12 @@ def _compute_sizes(obj, min_size=10000): raise Exception('Cannot find __dict__ reference') return { - item.name.split(':')[0][4:]: item.size - for item in ref.refs - if item.size > min_size + **{ + item.name.split(':')[0][4:]: item.size + for item in ref.refs + if item.size > min_size + }, + **{'total': size.size}, } diff --git a/tests/unit/main_loop/test_log_memory.py b/tests/unit/main_loop/test_log_memory.py index 9622f1d5733..2b5826dd9bd 100644 --- a/tests/unit/main_loop/test_log_memory.py +++ b/tests/unit/main_loop/test_log_memory.py @@ -46,7 +46,9 @@ def test_compute_sizes(): } test_object = Mock(**keys) # no fields should be larger than 10kb - assert _compute_sizes(test_object, 10000) == {} + sizes = _compute_sizes(test_object, 10000) + sizes.pop('total') + assert sizes == {} # all fields should be larger than 0kb ret = _compute_sizes(test_object, 0) assert { @@ -54,7 +56,7 @@ def test_compute_sizes(): for key, value in ret.items() # filter out mock fields if not key.startswith('_') - and key != 'method_calls' + and key not in ('method_calls', 'total') } == set(keys) From a88733a090b3e3dd83a50a357cb6a6c83b0d406e Mon Sep 17 00:00:00 2001 From: Oliver Sanders Date: Tue, 23 Apr 2024 10:04:10 +0100 Subject: [PATCH 019/196] outputs: better validation errors for alt qualifiers in completion exprs --- cylc/flow/config.py | 21 +++++++++++++++++---- tests/integration/validate/test_outputs.py | 12 ++++++++++++ 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/cylc/flow/config.py b/cylc/flow/config.py index f5ce67488e6..c4b340a8f0e 100644 --- a/cylc/flow/config.py +++ b/cylc/flow/config.py @@ -81,6 +81,7 @@ is_relative_to, ) from cylc.flow.print_tree import print_tree +from cylc.flow.task_qualifiers import ALT_QUALIFIERS from cylc.flow.simulation import configure_sim_modes from cylc.flow.subprocctx import SubFuncContext from cylc.flow.task_events_mgr import ( @@ -96,6 +97,7 @@ get_completion_expression, get_optional_outputs, get_trigger_completion_variable_maps, + trigger_to_completion_variable, ) from cylc.flow.task_trigger import TaskTrigger, Dependency from cylc.flow.taskdef import TaskDef @@ -1096,14 +1098,14 @@ def _check_completion_expression(self, task_name: str, expr: str) -> None: return ( - trigger_to_completion_variable, - completion_variable_to_trigger, + _trigger_to_completion_variable, + _completion_variable_to_trigger, ) = get_trigger_completion_variable_maps(outputs.keys()) # get the optional/required outputs defined in the graph graph_optionals = { # completion_variable: is_optional - trigger_to_completion_variable[trigger]: ( + _trigger_to_completion_variable[trigger]: ( None if is_required is None else not is_required ) for trigger, (_, is_required) @@ -1136,6 +1138,17 @@ def _check_completion_expression(self, task_name: str, expr: str) -> None: ' expressions, use "succeeded or failed".' ) + for alt_qualifier, qualifier in ALT_QUALIFIERS.items(): + _alt_compvar = trigger_to_completion_variable(alt_qualifier) + _compvar = trigger_to_completion_variable(qualifier) + if re.search(rf'\b{_alt_compvar}\b', error): + raise WorkflowConfigError( + f'Error in [runtime][{task_name}]completion:' + f'\n {expr}' + f'\nUse "{_compvar}" not "{_alt_compvar}" ' + 'in completion expressions.' + ) + raise WorkflowConfigError( # NOTE: str(exc) == "name 'x' is not defined" tested in # tests/integration/test_optional_outputs.py @@ -1179,7 +1192,7 @@ def _check_completion_expression(self, task_name: str, expr: str) -> None: # [1] applies only to "submit-failed" and "expired" - trigger = completion_variable_to_trigger[compvar] + trigger = _completion_variable_to_trigger[compvar] if graph_opt is True and expr_opt is False: raise WorkflowConfigError( diff --git a/tests/integration/validate/test_outputs.py b/tests/integration/validate/test_outputs.py index 3ddeba0e96d..a9fd55f0ef2 100644 --- a/tests/integration/validate/test_outputs.py +++ b/tests/integration/validate/test_outputs.py @@ -219,6 +219,18 @@ def test_messages(messages, valid, flow, validate): 'but required in the completion expression', id='failed-implicitly-optional-in-graph-required-in-completion', ), + pytest.param( + 'foo', + '(succeed and x) or failed', + 'Use "succeeded" not "succeed" in completion expressions', + id='alt-compvar1', + ), + pytest.param( + 'foo? & foo:submitted?', + 'submit_fail or succeeded', + 'Use "submit_failed" not "submit_fail" in completion expressions', + id='alt-compvar2', + ), ] ) def test_completion_expression_invalid( From ecf9e786bdf984a31bf4f0bd4ac8f183af98b8ff Mon Sep 17 00:00:00 2001 From: Ronnie Dutta <61982285+MetRonnie@users.noreply.github.com> Date: Tue, 23 Apr 2024 16:14:50 +0100 Subject: [PATCH 020/196] Fix traceback in `cylc config -i` (#6062) Fix traceback in `cylc config -i` Fixes bug when trying to get a setting in a `__MANY__` section when the setting is valid but not set in the config. Better handle `ItemNotFoundError` when printing platforms config --- cylc/flow/cfgspec/globalcfg.py | 17 +++-- cylc/flow/config.py | 25 +------ cylc/flow/context_node.py | 30 +++++++-- cylc/flow/parsec/config.py | 2 + cylc/flow/parsec/util.py | 15 +---- .../cylc-config/08-item-not-found.t | 30 +++++---- tests/functional/cylc-config/09-platforms.t | 1 - tests/unit/parsec/test_config.py | 65 ++++++++++++------- tests/unit/parsec/test_types.py | 2 +- tox.ini | 2 + 10 files changed, 104 insertions(+), 85 deletions(-) diff --git a/cylc/flow/cfgspec/globalcfg.py b/cylc/flow/cfgspec/globalcfg.py index 3c01ff52729..4fced5a2bb0 100644 --- a/cylc/flow/cfgspec/globalcfg.py +++ b/cylc/flow/cfgspec/globalcfg.py @@ -1994,21 +1994,23 @@ def platform_dump( """Print informations about platforms currently defined. """ if print_platform_names: - with suppress(ItemNotFoundError): - self.dump_platform_names(self) + self.dump_platform_names(self) if print_platforms: - with suppress(ItemNotFoundError): - self.dump_platform_details(self) + self.dump_platform_details(self) @staticmethod def dump_platform_names(cfg) -> None: """Print a list of defined platforms and groups. """ + # [platforms] is always defined with at least localhost platforms = '\n'.join(cfg.get(['platforms']).keys()) - platform_groups = '\n'.join(cfg.get(['platform groups']).keys()) print(f'{PLATFORM_REGEX_TEXT}\n\nPlatforms\n---------', file=stderr) print(platforms) - print('\n\nPlatform Groups\n--------------', file=stderr) + try: + platform_groups = '\n'.join(cfg.get(['platform groups']).keys()) + except ItemNotFoundError: + return + print('\nPlatform Groups\n--------------', file=stderr) print(platform_groups) @staticmethod @@ -2016,4 +2018,5 @@ def dump_platform_details(cfg) -> None: """Print platform and platform group configs. """ for config in ['platforms', 'platform groups']: - printcfg({config: cfg.get([config], sparse=True)}) + with suppress(ItemNotFoundError): + printcfg({config: cfg.get([config], sparse=True)}) diff --git a/cylc/flow/config.py b/cylc/flow/config.py index acd60ebbdeb..249a832c0d9 100644 --- a/cylc/flow/config.py +++ b/cylc/flow/config.py @@ -73,7 +73,7 @@ from cylc.flow.param_expand import NameExpander from cylc.flow.parsec.exceptions import ItemNotFoundError from cylc.flow.parsec.OrderedDict import OrderedDictWithDefaults -from cylc.flow.parsec.util import replicate +from cylc.flow.parsec.util import dequote, replicate from cylc.flow.pathutil import ( get_workflow_name_from_id, get_cylc_run_dir, @@ -198,29 +198,6 @@ def interpolate_template(tmpl, params_dict): raise ParamExpandError('bad template syntax') -def dequote(string): - """Strip quotes off a string. - - Examples: - >>> dequote('"foo"') - 'foo' - >>> dequote("'foo'") - 'foo' - >>> dequote('foo') - 'foo' - >>> dequote('"f') - '"f' - >>> dequote('f') - 'f' - - """ - if len(string) < 2: - return string - if (string[0] == string[-1]) and string.startswith(("'", '"')): - return string[1:-1] - return string - - class WorkflowConfig: """Class for workflow configuration items and derived quantities.""" diff --git a/cylc/flow/context_node.py b/cylc/flow/context_node.py index 9870c192d0b..02853028677 100644 --- a/cylc/flow/context_node.py +++ b/cylc/flow/context_node.py @@ -15,7 +15,13 @@ # along with this program. If not, see . -from typing import Optional +from typing import TYPE_CHECKING, Optional + +if TYPE_CHECKING: + # BACK COMPAT: typing_extensions.Self + # FROM: Python 3.7 + # TO: Python 3.11 + from typing_extensions import Self class ContextNode(): @@ -93,7 +99,7 @@ def __init__(self, name: str): self._children = None self.DATA[self] = set(self.DATA) - def __enter__(self): + def __enter__(self) -> 'Self': return self def __exit__(self, *args): @@ -129,24 +135,36 @@ def __iter__(self): def __contains__(self, name: str) -> bool: return name in self._children # type: ignore[operator] # TODO - def __getitem__(self, name: str): + def __getitem__(self, name: str) -> 'Self': if self._children: return self._children.__getitem__(name) raise TypeError('This is not a leaf node') - def get(self, *names: str): + def get(self, *names: str) -> 'Self': """Retrieve the node given by the list of names. Example: >>> with ContextNode('a') as a: - ... with ContextNode('b') as b: + ... with ContextNode('b'): ... c = ContextNode('c') >>> a.get('b', 'c') a/b/c + + >>> with ContextNode('a') as a: + ... with ContextNode('b'): + ... with ContextNode('__MANY__'): + ... c = ContextNode('c') + >>> a.get('b', 'foo', 'c') + a/b/__MANY__/c """ node = self for name in names: - node = node[name] + try: + node = node[name] + except KeyError as exc: + if '__MANY__' not in node: + raise exc + node = node['__MANY__'] return node def __str__(self) -> str: diff --git a/cylc/flow/parsec/config.py b/cylc/flow/parsec/config.py index 811d9560606..19f937d8e5b 100644 --- a/cylc/flow/parsec/config.py +++ b/cylc/flow/parsec/config.py @@ -145,7 +145,9 @@ def get(self, keys: Optional[Iterable[str]] = None, sparse: bool = False): cfg = cfg[key] except (KeyError, TypeError): if ( + # __MANY__ setting not present: parents in self.manyparents or + # setting not present in __MANY__ section: key in self.spec.get(*parents) ): raise ItemNotFoundError(itemstr(parents, key)) diff --git a/cylc/flow/parsec/util.py b/cylc/flow/parsec/util.py index d2104e63a58..785612639a3 100644 --- a/cylc/flow/parsec/util.py +++ b/cylc/flow/parsec/util.py @@ -406,16 +406,7 @@ def expand_many_section(config): """ ret = {} for section_name, section in config.items(): - expanded_names = [ - dequote(name.strip()).strip() - for name in SECTION_EXPAND_PATTERN.findall(section_name) - ] - for name in expanded_names: - if name in ret: - # already defined -> merge - replicate(ret[name], section) - - else: - ret[name] = {} - replicate(ret[name], section) + for name in SECTION_EXPAND_PATTERN.findall(section_name): + name = dequote(name.strip()).strip() + replicate(ret.setdefault(name, {}), section) return ret diff --git a/tests/functional/cylc-config/08-item-not-found.t b/tests/functional/cylc-config/08-item-not-found.t index 3f01e5fe417..ac51e924008 100755 --- a/tests/functional/cylc-config/08-item-not-found.t +++ b/tests/functional/cylc-config/08-item-not-found.t @@ -18,7 +18,7 @@ # Test cylc config . "$(dirname "$0")/test_header" #------------------------------------------------------------------------------- -set_test_number 7 +set_test_number 9 #------------------------------------------------------------------------------- cat >>'global.cylc' <<__HERE__ [platforms] @@ -29,21 +29,29 @@ OLD="$CYLC_CONF_PATH" export CYLC_CONF_PATH="${PWD}" # Control Run -run_ok "${TEST_NAME_BASE}-ok" cylc config -i "[platforms]foo" +run_ok "${TEST_NAME_BASE}-ok" cylc config -i "[platforms][foo]" # If item not settable in config (platforms is mis-spelled): -run_fail "${TEST_NAME_BASE}-not-in-config-spec" cylc config -i "[platfroms]foo" -grep_ok "InvalidConfigError" "${TEST_NAME_BASE}-not-in-config-spec.stderr" +run_fail "${TEST_NAME_BASE}-not-in-config-spec" cylc config -i "[platfroms][foo]" +cmp_ok "${TEST_NAME_BASE}-not-in-config-spec.stderr" << __HERE__ +InvalidConfigError: "platfroms" is not a valid configuration for global.cylc. +__HERE__ -# If item not defined, item not found. +# If item settable in config but not set. run_fail "${TEST_NAME_BASE}-not-defined" cylc config -i "[scheduler]" -grep_ok "ItemNotFoundError" "${TEST_NAME_BASE}-not-defined.stderr" +cmp_ok "${TEST_NAME_BASE}-not-defined.stderr" << __HERE__ +ItemNotFoundError: You have not set "scheduler" in this config. +__HERE__ + +run_fail "${TEST_NAME_BASE}-not-defined-2" cylc config -i "[platforms][bar]" +cmp_ok "${TEST_NAME_BASE}-not-defined-2.stderr" << __HERE__ +ItemNotFoundError: You have not set "[platforms]bar" in this config. +__HERE__ -# If item settable in config, item not found. -run_fail "${TEST_NAME_BASE}-not-defined__MULTI__" cylc config -i "[platforms]bar" -grep_ok "ItemNotFoundError" "${TEST_NAME_BASE}-not-defined__MULTI__.stderr" +run_fail "${TEST_NAME_BASE}-not-defined-3" cylc config -i "[platforms][foo]hosts" +cmp_ok "${TEST_NAME_BASE}-not-defined-3.stderr" << __HERE__ +ItemNotFoundError: You have not set "[platforms][foo]hosts" in this config. +__HERE__ rm global.cylc export CYLC_CONF_PATH="$OLD" - -exit diff --git a/tests/functional/cylc-config/09-platforms.t b/tests/functional/cylc-config/09-platforms.t index b0624db31c8..3fc90201aec 100755 --- a/tests/functional/cylc-config/09-platforms.t +++ b/tests/functional/cylc-config/09-platforms.t @@ -54,7 +54,6 @@ They are searched from the bottom up, until the first match is found. Platforms --------- - Platform Groups -------------- __HEREDOC__ diff --git a/tests/unit/parsec/test_config.py b/tests/unit/parsec/test_config.py index f2d7b6e72f9..5d8fc97f902 100644 --- a/tests/unit/parsec/test_config.py +++ b/tests/unit/parsec/test_config.py @@ -156,7 +156,7 @@ def test_validate(): :return: """ - with Conf('myconf') as spec: # noqa: SIM117 + with Conf('myconf') as spec: with Conf('section'): Conf('name', VDR.V_STRING) Conf('address', VDR.V_STRING) @@ -192,6 +192,10 @@ def parsec_config_2(tmp_path: Path): Conf('address', VDR.V_INTEGER_LIST) with Conf('allow_many'): Conf('', VDR.V_STRING, '') + with Conf('so_many'): + with Conf(''): + Conf('color', VDR.V_STRING) + Conf('horsepower', VDR.V_INTEGER) parsec_config = ParsecConfig(spec, validator=cylc_config_validate) conf_file = tmp_path / 'myconf' conf_file.write_text(""" @@ -199,6 +203,9 @@ def parsec_config_2(tmp_path: Path): name = test [allow_many] anything = yup + [so_many] + [[legs]] + horsepower = 123 """) parsec_config.loadcfg(conf_file, "1.0") return parsec_config @@ -213,25 +220,32 @@ def test_expand(parsec_config_2: ParsecConfig): def test_get(parsec_config_2: ParsecConfig): cfg = parsec_config_2.get(keys=None, sparse=False) - assert parsec_config_2.dense == cfg + assert cfg == parsec_config_2.dense cfg = parsec_config_2.get(keys=None, sparse=True) - assert parsec_config_2.sparse == cfg + assert cfg == parsec_config_2.sparse cfg = parsec_config_2.get(keys=['section'], sparse=True) - assert parsec_config_2.sparse['section'] == cfg - - with pytest.raises(InvalidConfigError): - parsec_config_2.get(keys=['alloy_many', 'a'], sparse=True) - - cfg = parsec_config_2.get(keys=['section', 'name'], sparse=True) - assert cfg == 'test' - - with pytest.raises(InvalidConfigError): - parsec_config_2.get(keys=['section', 'a'], sparse=True) - - with pytest.raises(ItemNotFoundError): - parsec_config_2.get(keys=['allow_many', 'a'], sparse=True) + assert cfg == parsec_config_2.sparse['section'] + + +@pytest.mark.parametrize('keys, expected', [ + (['section', 'name'], 'test'), + (['section', 'a'], InvalidConfigError), + (['alloy_many', 'anything'], InvalidConfigError), + (['allow_many', 'anything'], 'yup'), + (['allow_many', 'a'], ItemNotFoundError), + (['so_many', 'legs', 'horsepower'], 123), + (['so_many', 'legs', 'color'], ItemNotFoundError), + (['so_many', 'legs', 'a'], InvalidConfigError), + (['so_many', 'teeth', 'horsepower'], ItemNotFoundError), +]) +def test_get__sparse(parsec_config_2: ParsecConfig, keys, expected): + if isinstance(expected, type) and issubclass(expected, Exception): + with pytest.raises(expected): + parsec_config_2.get(keys, sparse=True) + else: + assert parsec_config_2.get(keys, sparse=True) == expected def test_mdump_none(config, sample_spec, capsys): @@ -288,12 +302,17 @@ def test_get_none(config, sample_spec): def test__get_namespace_parents(): """It returns a list of parents and nothing else""" - with Conf('myconfig') as myconf: - with Conf('some_parent'): # noqa: SIM117 - with Conf('manythings'): - Conf('') - with Conf('other_parent'): - Conf('other_thing') + with Conf('myconfig.cylc') as myconf: + with Conf('a'): + with Conf('b'): + with Conf(''): + with Conf('d'): + Conf('') + with Conf('x'): + Conf('y') cfg = ParsecConfig(myconf) - assert cfg.manyparents == [['some_parent', 'manythings']] + assert cfg.manyparents == [ + ['a', 'b'], + ['a', 'b', '__MANY__', 'd'], + ] diff --git a/tests/unit/parsec/test_types.py b/tests/unit/parsec/test_types.py index ad0a2c58563..cd108a42861 100644 --- a/tests/unit/parsec/test_types.py +++ b/tests/unit/parsec/test_types.py @@ -48,7 +48,7 @@ def _inner(typ, validator): cylc.flow.parsec.config.ConfigNode """ - with Conf('/') as myconf: # noqa: SIM117 + with Conf('/') as myconf: with Conf(typ): Conf('', validator) return myconf diff --git a/tox.ini b/tox.ini index 94fa6e57d52..95222eeb859 100644 --- a/tox.ini +++ b/tox.ini @@ -15,6 +15,8 @@ ignore= per-file-ignores= ; TYPE_CHECKING block suggestions tests/*: TC001 + ; for clarity we don't merge 'with Conf():' context trees + tests/unit/parsec/*: SIM117 exclude= build, From adea8f84b6a25f7be18f9c66dcaf9a0c508ff69e Mon Sep 17 00:00:00 2001 From: Oliver Sanders Date: Wed, 24 Apr 2024 11:09:58 +0100 Subject: [PATCH 021/196] xtriggers: docstring formatting fixes --- cylc/flow/xtriggers/echo.py | 6 +++--- cylc/flow/xtriggers/wall_clock.py | 4 ++++ cylc/flow/xtriggers/workflow_state.py | 1 + cylc/flow/xtriggers/xrandom.py | 26 +++++++++++++------------- 4 files changed, 21 insertions(+), 16 deletions(-) diff --git a/cylc/flow/xtriggers/echo.py b/cylc/flow/xtriggers/echo.py index d200e2389bb..83e6d66062f 100644 --- a/cylc/flow/xtriggers/echo.py +++ b/cylc/flow/xtriggers/echo.py @@ -30,6 +30,9 @@ def echo(*args, **kwargs) -> Tuple: *args: Print to stdout. **kwargs: Print to stdout, and return as output. + Returns: + (True/False, kwargs) + Examples: >>> echo('Breakfast Time', succeed=True, egg='poached') @@ -37,9 +40,6 @@ def echo(*args, **kwargs) -> Tuple: echo: KWARGS: {'succeed': True, 'egg': 'poached'} (True, {'succeed': True, 'egg': 'poached'}) - Returns - (True/False, kwargs) - """ print("echo: ARGS:", args) print("echo: KWARGS:", kwargs) diff --git a/cylc/flow/xtriggers/wall_clock.py b/cylc/flow/xtriggers/wall_clock.py index 91ba49a8961..a159ec3019f 100644 --- a/cylc/flow/xtriggers/wall_clock.py +++ b/cylc/flow/xtriggers/wall_clock.py @@ -34,6 +34,10 @@ def wall_clock(offset: str = 'PT0S', sequential: bool = True): ISO 8601 interval to wait after the cycle point is reached in real time before triggering. May be negative, in which case it will trigger before the real time reaches the cycle point. + sequential: + Wall-clock xtriggers are run sequentially by default. + See :ref:`Sequential Xtriggers` for more details. + """ # NOTE: This is just a placeholder for the actual implementation. # This is only used for validating the signature and for autodocs. diff --git a/cylc/flow/xtriggers/workflow_state.py b/cylc/flow/xtriggers/workflow_state.py index 340f7bd51c1..76755085aa6 100644 --- a/cylc/flow/xtriggers/workflow_state.py +++ b/cylc/flow/xtriggers/workflow_state.py @@ -54,6 +54,7 @@ def workflow_state( The task status required for this xtrigger to be satisfied. message: The custom task output required for this xtrigger to be satisfied. + .. note:: This cannot be specified in conjunction with ``status``. diff --git a/cylc/flow/xtriggers/xrandom.py b/cylc/flow/xtriggers/xrandom.py index 867035d46f8..218470e4d12 100644 --- a/cylc/flow/xtriggers/xrandom.py +++ b/cylc/flow/xtriggers/xrandom.py @@ -45,6 +45,19 @@ def xrandom( Used to allow users to specialize the trigger with extra parameters. + Returns: + tuple: (satisfied, results) + + satisfied: + True if ``satisfied`` else ``False``. + results: + A dictionary containing the following keys: + + ``COLOR`` + A random colour (e.g. red, orange, ...). + ``SIZE`` + A random size (e.g. small, medium, ...). + Examples: If the percent is zero, it returns that the trigger condition was not satisfied, and an empty dictionary. @@ -74,19 +87,6 @@ def xrandom( >>> xrandom(99.99, 0) (True, {'COLOR': 'orange', 'SIZE': 'small'}) - Returns: - tuple: (satisfied, results) - - satisfied: - True if ``satisfied`` else ``False``. - results: - A dictionary containing the following keys: - - ``COLOR`` - A random colour (e.g. red, orange, ...). - ``SIZE`` - A random size (e.g. small, medium, ...). - """ sleep(float(secs)) results = {} From a41cd9d4ba373284382314604d54cceeb3117714 Mon Sep 17 00:00:00 2001 From: Oliver Sanders Date: Thu, 25 Apr 2024 12:57:37 +0100 Subject: [PATCH 022/196] review feedback --- cylc/flow/cfgspec/workflow.py | 36 +++++++++++++++++++++-------------- 1 file changed, 22 insertions(+), 14 deletions(-) diff --git a/cylc/flow/cfgspec/workflow.py b/cylc/flow/cfgspec/workflow.py index 1e0a59a02ea..a0cf51005f4 100644 --- a/cylc/flow/cfgspec/workflow.py +++ b/cylc/flow/cfgspec/workflow.py @@ -1083,18 +1083,20 @@ def get_script_common_text(this: str, example: Optional[str] = None): foo:x? => x In these cases succeess is presumed to be required unless - explicitly stated otherwise, either in the graph: + explicitly stated otherwise, either in the graph e.g: .. code-block:: cylc-graph foo? foo:x? => x - Or in the completion expression: + Or in the completion expression e.g: .. code-block:: cylc - completion = x # no reference to succeeded here + completion = x # no reference to succeeded + # or + completion = succeeded or failed # success is optional .. hint:: @@ -1102,19 +1104,25 @@ def get_script_common_text(this: str, example: Optional[str] = None): If task outputs are optional in the graph they must also be optional in the completion condition and vice versa. + For example this graph conflicts with the completion + statement: + + .. code-block:: cylc-graph + + # "a" must succeed + a => b + .. code-block:: cylc - [scheduling] - [[graph]] - R1 = """ - # ERROR: this should be "a? => b" - a => b - """ - [runtime] - [[a]] - # this completion condition implies that the - # succeeded output is optional - completion = succeeded or failed + # "a" may either succeed or fail + completion = succeeded or failed + + Which could be fixed by ammending the graph like so: + + .. code-block:: cylc-graph + + # "a" may either succeed or fail + a? => b .. rubric:: Examples From 91604ac7717f642dab88a1e8ced4107b931e8445 Mon Sep 17 00:00:00 2001 From: Ronnie Dutta <61982285+MetRonnie@users.noreply.github.com> Date: Thu, 25 Apr 2024 17:37:40 +0100 Subject: [PATCH 023/196] Improve xrandom validate function docstring [skip ci] --- cylc/flow/xtriggers/xrandom.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/cylc/flow/xtriggers/xrandom.py b/cylc/flow/xtriggers/xrandom.py index 218470e4d12..d883645276f 100644 --- a/cylc/flow/xtriggers/xrandom.py +++ b/cylc/flow/xtriggers/xrandom.py @@ -100,9 +100,12 @@ def xrandom( def validate(args: Dict[str, Any]): - """Validate and manipulate args parsed from the workflow config. + """Validate the args that xrandom is called with. + + Cylc calls this function automatically when parsing the workflow. + + Here we specify the rules for args are: - The rules for args are: * percent: Must be 0 ≤ x ≤ 100 * secs: Must be an integer. """ From 821cddb7c9bd472e6adbb8dadcbcf100cc3eaa1b Mon Sep 17 00:00:00 2001 From: Hilary James Oliver Date: Fri, 26 Apr 2024 14:13:55 +1200 Subject: [PATCH 024/196] Added some integration tests. --- tests/integration/validate/test_outputs.py | 49 +++++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/tests/integration/validate/test_outputs.py b/tests/integration/validate/test_outputs.py index a9fd55f0ef2..b26bce529fb 100644 --- a/tests/integration/validate/test_outputs.py +++ b/tests/integration/validate/test_outputs.py @@ -231,7 +231,28 @@ def test_messages(messages, valid, flow, validate): 'Use "submit_failed" not "submit_fail" in completion expressions', id='alt-compvar2', ), - ] + pytest.param( + 'foo? & foo:submitted?', + 'submit-failed or succeeded', + 'Use "submit_failed" rather than "submit-failed"' + ' in completion expressions.', + id='submit-failed used in completion expression', + ), + pytest.param( + 'foo:file-1', + 'succeeded or file-1', + 'Replace hyphens with underscores in task outputs when' + ' used in completion expressions.', + id='Hyphen used in completion expression', + ), + pytest.param( + 'foo:x', + 'not succeeded or x', + 'Error in .*' + '\nInvalid expression', + id='Non-whitelisted syntax used in completion expression', + ), + ] ) def test_completion_expression_invalid( flow, @@ -292,3 +313,29 @@ def test_completion_expression_valid( }, }) validate(id_) + + +def test_completion_expression_cylc7_compat( + flow, + validate, + monkeypatch +): + id_ = flow({ + 'scheduling': { + 'graph': {'R1': 'foo'}, + }, + 'runtime': { + 'foo': { + 'completion': 'succeeded and x', + 'outputs': { + 'x': 'xxx', + }, + }, + }, + }) + monkeypatch.setattr('cylc.flow.flags.cylc7_back_compat', True) + with pytest.raises( + WorkflowConfigError, + match="completion cannot be used in Cylc 7 compatibility mode." + ): + validate(id_) From d7305c562703702bd96e3ee71bdf62706cdd0b38 Mon Sep 17 00:00:00 2001 From: Hilary James Oliver Date: Tue, 23 Apr 2024 11:41:53 +1200 Subject: [PATCH 025/196] Reduce logging verbosity, tweak tests. [skip ci] --- cylc/flow/task_events_mgr.py | 3 ++- cylc/flow/task_pool.py | 6 ++--- .../04-polling-intervals.t | 2 +- tests/integration/test_task_events_mgr.py | 3 ++- tests/integration/test_task_pool.py | 25 +++++++++++-------- 5 files changed, 23 insertions(+), 16 deletions(-) diff --git a/cylc/flow/task_events_mgr.py b/cylc/flow/task_events_mgr.py index 14f376dfb74..e3275d76aed 100644 --- a/cylc/flow/task_events_mgr.py +++ b/cylc/flow/task_events_mgr.py @@ -1788,6 +1788,7 @@ def _reset_job_timers(self, itask): itask.timeout = None itask.poll_timer = None return + ctx = (itask.submit_num, itask.state.status) if itask.poll_timer and itask.poll_timer.ctx == ctx: return @@ -1844,7 +1845,7 @@ def _reset_job_timers(self, itask): message += '%d*' % (num + 1) message += '%s,' % intvl_as_str(item) message += '...' - LOG.info(f"[{itask}] {message}") + LOG.debug(f"[{itask}] {message}") # Set next poll time self.check_poll_time(itask) diff --git a/cylc/flow/task_pool.py b/cylc/flow/task_pool.py index b7f8c0601cb..2f49de1e158 100644 --- a/cylc/flow/task_pool.py +++ b/cylc/flow/task_pool.py @@ -221,7 +221,7 @@ def add_to_pool(self, itask) -> None: self.active_tasks.setdefault(itask.point, {}) self.active_tasks[itask.point][itask.identity] = itask self.active_tasks_changed = True - LOG.info(f"[{itask}] added to active task pool") + LOG.debug(f"[{itask}] added to active task pool") self.create_data_store_elements(itask) @@ -839,7 +839,7 @@ def remove(self, itask, reason=None): # TODO: same for datastore (still updated by scheduler loop) self.workflow_db_mgr.put_update_task_state(itask) - level = logging.INFO + level = logging.DEBUG if itask.state( TASK_STATUS_PREPARING, TASK_STATUS_SUBMITTED, @@ -1654,7 +1654,7 @@ def spawn_task( submit_num == 0 ): # Previous instance removed before completing any outputs. - LOG.info(f"Flow stopping at {point}/{name} - task previously removed") + LOG.debug(f"Not spawning {point}/{name} - task removed") return None itask = self._get_task_proxy_db_outputs( diff --git a/tests/functional/execution-time-limit/04-polling-intervals.t b/tests/functional/execution-time-limit/04-polling-intervals.t index e1df403f155..f15e4e8a74d 100644 --- a/tests/functional/execution-time-limit/04-polling-intervals.t +++ b/tests/functional/execution-time-limit/04-polling-intervals.t @@ -41,7 +41,7 @@ __FLOW__ run_ok "${TEST_NAME_BASE}-validate" cylc validate "${WORKFLOW_NAME}" -cylc play "${WORKFLOW_NAME}" +cylc play --debug "${WORKFLOW_NAME}" poll_grep_workflow_log "INFO - DONE" diff --git a/tests/integration/test_task_events_mgr.py b/tests/integration/test_task_events_mgr.py index 7f3fa488162..62994487624 100644 --- a/tests/integration/test_task_events_mgr.py +++ b/tests/integration/test_task_events_mgr.py @@ -17,6 +17,7 @@ from cylc.flow.task_events_mgr import TaskJobLogsRetrieveContext from cylc.flow.scheduler import Scheduler +import logging from typing import Any as Fixture @@ -51,7 +52,7 @@ async def test__reset_job_timers( process_execution_polling_intervals. """ schd = scheduler(flow(one_conf)) - async with start(schd): + async with start(schd, level=logging.DEBUG): itask = schd.pool.get_tasks()[0] itask.state.status = 'running' itask.platform['execution polling intervals'] = [25] diff --git a/tests/integration/test_task_pool.py b/tests/integration/test_task_pool.py index 2ad2e26a068..43d6d50b520 100644 --- a/tests/integration/test_task_pool.py +++ b/tests/integration/test_task_pool.py @@ -148,7 +148,7 @@ async def mod_example_flow( """ id_ = mod_flow(EXAMPLE_FLOW_CFG) schd: 'Scheduler' = mod_scheduler(id_, paused_start=True) - async with mod_run(schd): + async with mod_run(schd, level=logging.DEBUG): yield schd @@ -1198,7 +1198,7 @@ async def test_detect_incomplete_tasks( } }) schd = scheduler(id_) - async with start(schd) as log: + async with start(schd, level=logging.DEBUG) as log: itasks = schd.pool.get_tasks() for itask in itasks: itask.state_reset(is_queued=False) @@ -1279,7 +1279,7 @@ async def test_set_failed_complete( """Test manual completion of an incomplete failed task.""" id_ = flow(one_conf) schd = scheduler(id_) - async with start(schd) as log: + async with start(schd, level=logging.DEBUG) as log: one = schd.pool.get_tasks()[0] one.state_reset(is_queued=False) @@ -1899,12 +1899,13 @@ async def test_fast_respawn( # attempt to spawn it again itask = task_pool.spawn_task("foo", IntegerPoint("1"), {1}) assert itask is None - assert "Not spawning 1/foo: already used in this flow" in caplog.text + assert "Not spawning 1/foo - task removed" in caplog.text async def test_remove_active_task( example_flow: 'Scheduler', caplog: pytest.LogCaptureFixture, + log_filter: Callable, ) -> None: """Test warning on removing an active task.""" @@ -1917,9 +1918,13 @@ async def test_remove_active_task( task_pool.remove(foo, "request") assert foo not in task_pool.get_tasks() - assert ( - "removed from active task pool: request - active job orphaned" - in caplog.text + assert log_filter( + caplog, + regex=( + "1/foo.*removed from active task pool:" + " request - active job orphaned" + ), + level=logging.WARNING ) @@ -1947,7 +1952,7 @@ async def test_remove_by_suicide( } }) schd: 'Scheduler' = scheduler(id_) - async with start(schd) as log: + async with start(schd, level=logging.DEBUG) as log: # it should start up with 1/a and 1/b assert pool_get_task_ids(schd.pool) == ["1/a", "1/b"] a = schd.pool.get_task(IntegerPoint("1"), "a") @@ -1998,7 +2003,7 @@ async def test_remove_no_respawn(flow, scheduler, start, log_filter): }, }) schd: 'Scheduler' = scheduler(id_) - async with start(schd) as log: + async with start(schd, level=logging.DEBUG) as log: a1 = schd.pool.get_task(IntegerPoint("1"), "a") b1 = schd.pool.get_task(IntegerPoint("1"), "b") assert a1, '1/a should have been spawned on startup' @@ -2020,7 +2025,7 @@ async def test_remove_no_respawn(flow, scheduler, start, log_filter): # respawned as a result schd.pool.spawn_on_output(b1, TASK_OUTPUT_SUCCEEDED) assert log_filter( - log, contains='Not spawning 1/z: already used in this flow' + log, contains='Not spawning 1/z - task removed' ) z1 = schd.pool.get_task(IntegerPoint("1"), "z") assert ( From 9cbb9017ed5892203220c6a48074c9417b667eb5 Mon Sep 17 00:00:00 2001 From: Hilary James Oliver Date: Mon, 29 Apr 2024 14:12:06 +1200 Subject: [PATCH 026/196] Remove dead code block. --- cylc/flow/config.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/cylc/flow/config.py b/cylc/flow/config.py index 623a8b47964..8207d2e9e9c 100644 --- a/cylc/flow/config.py +++ b/cylc/flow/config.py @@ -1012,10 +1012,6 @@ def _set_completion_expressions(self): # derive a completion expression for this taskdef expr = get_completion_expression(taskdef) - if name not in self.taskdefs: - # this is a family -> nothing more to do here - continue - # update both the sparse and dense configs to make these values # visible to "cylc config" to make the completion expression more # transparent to users. From 1d1d08af632a6349c8990c789a125c7d6bd83e12 Mon Sep 17 00:00:00 2001 From: Hilary James Oliver Date: Mon, 29 Apr 2024 14:14:15 +1200 Subject: [PATCH 027/196] Remove redundant method. --- cylc/flow/task_proxy.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/cylc/flow/task_proxy.py b/cylc/flow/task_proxy.py index dbb6efc6db9..4e7b60d6e0a 100644 --- a/cylc/flow/task_proxy.py +++ b/cylc/flow/task_proxy.py @@ -42,7 +42,6 @@ TaskState, TASK_STATUS_WAITING, TASK_STATUS_EXPIRED, - TASK_STATUSES_FINAL, ) from cylc.flow.taskdef import generate_graph_children from cylc.flow.wallclock import get_unix_time_from_time_string as str2time @@ -568,10 +567,6 @@ def clock_expire(self) -> bool: return False return True - def is_finished(self) -> bool: - """Return True if a final state achieved.""" - return self.state(*TASK_STATUSES_FINAL) - def is_complete(self) -> bool: """Return True if complete or expired.""" return self.state.outputs.is_complete() From a234874c1f87a60e5f8af8df4b6c5028821cd884 Mon Sep 17 00:00:00 2001 From: Hilary James Oliver Date: Mon, 29 Apr 2024 14:30:34 +1200 Subject: [PATCH 028/196] Temp: avoid pytest-8.2.0 --- setup.cfg | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index 45a66d81c88..5af5dcb98e5 100644 --- a/setup.cfg +++ b/setup.cfg @@ -123,7 +123,9 @@ tests = pytest-cov>=2.8.0 pytest-xdist>=2 pytest-mock>=3.7 - pytest>=6 + # pytest-8.2.0 causing integration test failures: + # AttributeError: 'FixtureDef' object has no attribute 'unittest' + pytest>=6,!=8.2.0 testfixtures>=6.11.0 towncrier>=23 # Type annotation stubs From 124fbf56ca5a23627c0241be6c00ee13e4feeb9d Mon Sep 17 00:00:00 2001 From: Hilary James Oliver Date: Mon, 29 Apr 2024 15:43:21 +1200 Subject: [PATCH 029/196] Tweak error msg. --- cylc/flow/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cylc/flow/config.py b/cylc/flow/config.py index 8207d2e9e9c..05e5fc2f3cb 100644 --- a/cylc/flow/config.py +++ b/cylc/flow/config.py @@ -1126,7 +1126,7 @@ def _check_completion_expression(self, task_name: str, expr: str) -> None: # NOTE: str(exc) == "name 'x' is not defined" tested in # tests/integration/test_optional_outputs.py f'Error in [runtime][{task_name}]completion:' - f'\nInput {error}' + f'\n{error}' ) except Exception as exc: # includes InvalidCompletionExpression # expression contains non-whitelisted syntax or any other error in From 724783804a414bf9f2a74431309b0f6c7e0c65a4 Mon Sep 17 00:00:00 2001 From: Hilary James Oliver Date: Mon, 29 Apr 2024 15:54:24 +1200 Subject: [PATCH 030/196] MacOS 3.7 -> 3.8 --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d1d76b4e4d1..674542d4e63 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -21,7 +21,7 @@ jobs: python: ['3.7', '3.8', '3.9', '3.10', '3.11'] include: - os: 'macos-latest' - python: '3.7' + python: '3.8' steps: - name: Checkout uses: actions/checkout@v4 From 2b695ab0376c90396f51cd7b906c101bfa6d3857 Mon Sep 17 00:00:00 2001 From: Oliver Sanders Date: Tue, 30 Apr 2024 14:16:30 +0100 Subject: [PATCH 031/196] docs: reference active tasks entry in the glossary --- cylc/flow/cfgspec/workflow.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/cylc/flow/cfgspec/workflow.py b/cylc/flow/cfgspec/workflow.py index a2f64f4dac5..b4b67cb4587 100644 --- a/cylc/flow/cfgspec/workflow.py +++ b/cylc/flow/cfgspec/workflow.py @@ -644,7 +644,7 @@ def get_script_common_text(this: str, example: Optional[str] = None): ''') Conf('runahead limit', VDR.V_STRING, 'P4', desc=''' The runahead limit prevents a workflow from getting too far ahead - of the oldest :term:`active cycle`. + of the oldest cycle with :term:`active tasks `. A cycle is considered to be active if it contains any :term:`active` tasks. @@ -686,7 +686,8 @@ def get_script_common_text(this: str, example: Optional[str] = None): Configuration of internal queues of tasks. This section will allow you to limit the number of simultaneously - active tasks (submitted or running) by assigning tasks to queues. + :term:`active tasks ` (submitted or running) by + assigning tasks to queues. By default, a single queue called ``default`` is defined, with all tasks assigned to it and no limit to the number of those @@ -712,8 +713,8 @@ def get_script_common_text(this: str, example: Optional[str] = None): Section heading for configuration of a single queue. ''') as Queue: Conf('limit', VDR.V_INTEGER, 0, desc=''' - The maximum number of active tasks allowed at any one - time, for this queue. + The maximum number of :term:`active tasks ` + allowed at any one time, for this queue. If set to 0 this queue is not limited. ''') @@ -728,8 +729,8 @@ def get_script_common_text(this: str, example: Optional[str] = None): The default queue for all tasks not assigned to other queues. '''): Conf('limit', VDR.V_INTEGER, 100, desc=''' - Controls the total number of active tasks in the default - queue. + Controls the total number of + :term:`active tasks ` in the default queue. .. seealso:: From fd51981de0f6a9d424f27b451baf7d7a4d291ff7 Mon Sep 17 00:00:00 2001 From: Tim Pillinger <26465611+wxtim@users.noreply.github.com> Date: Tue, 30 Apr 2024 14:25:07 +0100 Subject: [PATCH 032/196] Bump pytest-asyncio version (#6086) And change GH Actions build workflow python version for MacOS --- .github/workflows/build.yml | 2 +- .github/workflows/test_fast.yml | 2 +- setup.cfg | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 041b6e50a70..548cf0235fc 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -21,7 +21,7 @@ jobs: python: ['3.7', '3.8', '3.9', '3.10', '3.11'] include: - os: 'macos-latest' - python: '3.7' + python: '3.8' steps: - name: Checkout uses: actions/checkout@v3 diff --git a/.github/workflows/test_fast.yml b/.github/workflows/test_fast.yml index 53d6c475bc3..77db04f418c 100644 --- a/.github/workflows/test_fast.yml +++ b/.github/workflows/test_fast.yml @@ -24,7 +24,7 @@ jobs: include: # mac os test - os: 'macos-11' - python-version: '3.7' # oldest supported version + python-version: '3.8' # oldest supported version # non-utc timezone test - os: 'ubuntu-latest' python-version: '3.9' # not the oldest, not the most recent version diff --git a/setup.cfg b/setup.cfg index 15cfc9d6009..f95dc34ddb9 100644 --- a/setup.cfg +++ b/setup.cfg @@ -117,7 +117,7 @@ tests = flake8>=3.0.0 mypy>=0.910,<1.9 # https://github.com/pytest-dev/pytest-asyncio/issues/706 - pytest-asyncio>=0.17,!=0.23.* + pytest-asyncio>=0.21.2,!=0.23.* pytest-cov>=2.8.0 pytest-xdist>=2 pytest-env>=0.6.2 From 21d1ca5ec7d68c5e4df155a2198bdfbf19d9b974 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 2 May 2024 10:05:10 +0000 Subject: [PATCH 033/196] Prepare release 8.2.6 Workflow: Release stage 1 - create release PR (Cylc 8+ only), run: 35 --- CHANGES.md | 16 ++++++++++++++++ changes.d/6068.break.md | 1 - changes.d/6071.fix.md | 1 - changes.d/6072.feat.md | 1 - changes.d/6078.fix.md | 1 - cylc/flow/__init__.py | 2 +- 6 files changed, 17 insertions(+), 5 deletions(-) delete mode 100644 changes.d/6068.break.md delete mode 100644 changes.d/6071.fix.md delete mode 100644 changes.d/6072.feat.md delete mode 100644 changes.d/6078.fix.md diff --git a/CHANGES.md b/CHANGES.md index 72b1b4723b7..6ea79cf6231 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -11,6 +11,22 @@ $ towncrier create ..md --content "Short description" +## __cylc-8.2.6 (Released 2024-05-02)__ + +### ⚠ Breaking Changes + +[#6068](https://github.com/cylc/cylc-flow/pull/6068) - Removed the Rose Options (`-S`, `-O`, `-D`) from `cylc play`. If you need these use them with `cylc install`. + +### 🚀 Enhancements + +[#6072](https://github.com/cylc/cylc-flow/pull/6072) - Nano Syntax Highlighting now available. + +### 🔧 Fixes + +[#6071](https://github.com/cylc/cylc-flow/pull/6071) - `cylc config` now shows xtrigger function signatures. + +[#6078](https://github.com/cylc/cylc-flow/pull/6078) - Fixed bug where `cylc lint` could hang when checking `inherit` settings in `flow.cylc`. + ## __cylc-8.2.5 (Released 2024-04-04)__ ### 🔧 Fixes diff --git a/changes.d/6068.break.md b/changes.d/6068.break.md deleted file mode 100644 index 1d6814776f0..00000000000 --- a/changes.d/6068.break.md +++ /dev/null @@ -1 +0,0 @@ -Removed the Rose Options (`-S`, `-O`, `-D`) from `cylc play`. If you need these use them with `cylc install`. diff --git a/changes.d/6071.fix.md b/changes.d/6071.fix.md deleted file mode 100644 index 534b4241883..00000000000 --- a/changes.d/6071.fix.md +++ /dev/null @@ -1 +0,0 @@ -`cylc config` now shows xtrigger function signatures. diff --git a/changes.d/6072.feat.md b/changes.d/6072.feat.md deleted file mode 100644 index 1031386cda1..00000000000 --- a/changes.d/6072.feat.md +++ /dev/null @@ -1 +0,0 @@ -Nano Syntax Highlighting now available. diff --git a/changes.d/6078.fix.md b/changes.d/6078.fix.md deleted file mode 100644 index 7cbcc6a82e4..00000000000 --- a/changes.d/6078.fix.md +++ /dev/null @@ -1 +0,0 @@ -Fixed bug where `cylc lint` could hang when checking `inherit` settings in `flow.cylc`. diff --git a/cylc/flow/__init__.py b/cylc/flow/__init__.py index e11fbe0394c..62521548af2 100644 --- a/cylc/flow/__init__.py +++ b/cylc/flow/__init__.py @@ -53,7 +53,7 @@ def environ_init(): environ_init() -__version__ = '8.2.6.dev' +__version__ = '8.2.6' def iter_entry_points(entry_point_name): From 8f0bc1a1de02ad7e16373b851fffb1d7e1f14e9c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 2 May 2024 12:27:33 +0100 Subject: [PATCH 034/196] Bump dev version (#6092) Workflow: Release stage 2 - auto publish, run: 73 Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- cylc/flow/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cylc/flow/__init__.py b/cylc/flow/__init__.py index 62521548af2..22e6e49ab3f 100644 --- a/cylc/flow/__init__.py +++ b/cylc/flow/__init__.py @@ -53,7 +53,7 @@ def environ_init(): environ_init() -__version__ = '8.2.6' +__version__ = '8.2.7.dev' def iter_entry_points(entry_point_name): From 24c19ab075acc8e9c220a5ac8800912d29009fe0 Mon Sep 17 00:00:00 2001 From: Shixian Sheng Date: Thu, 2 May 2024 08:30:25 -0400 Subject: [PATCH 035/196] Updated README.md (#6093) * Update README.md * Update CONTRIBUTING.md --- CONTRIBUTING.md | 1 + README.md | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 75d6bbb86c9..30e81f26f93 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -94,6 +94,7 @@ requests_). - Cheng Da - Mark Dawson - Diquan Jabbour + - Shixian Sheng (All contributors are identifiable with email addresses in the git version diff --git a/README.md b/README.md index 3c803396a1e..08f8d5938d5 100644 --- a/README.md +++ b/README.md @@ -102,7 +102,7 @@ Cylc. If not, see [GNU licenses](http://www.gnu.org/licenses/). Contributions welcome: -* Read the [contributing](CONTRIBUTING.md) page. +* Read the [contributing](https://github.com/cylc/cylc-flow/blob/master/CONTRIBUTING.md) page. * Development setup instructions are in the [developer docs](https://cylc.github.io/cylc-admin/#cylc-8-developer-docs). * Involved change proposals can be found in the From b179a41539899a0b9fad515d41f19373ef91b0ae Mon Sep 17 00:00:00 2001 From: Ronnie Dutta <61982285+MetRonnie@users.noreply.github.com> Date: Wed, 8 May 2024 15:20:22 +0100 Subject: [PATCH 036/196] Add "valid for" states to schema def for `cylc set` --- cylc/flow/network/schema.py | 2 ++ tests/unit/network/test_schema.py | 24 ++++++++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/cylc/flow/network/schema.py b/cylc/flow/network/schema.py index 8da9f79f10e..9ae784d8a00 100644 --- a/cylc/flow/network/schema.py +++ b/cylc/flow/network/schema.py @@ -2103,6 +2103,8 @@ class Meta: - ``started`` implies ``submitted``. - ``succeeded`` and ``failed`` imply ``started``. - custom outputs and ``expired`` do not imply any other outputs. + + Valid for: paused, running, stopping workflows. """) resolver = partial(mutator, command='set') diff --git a/tests/unit/network/test_schema.py b/tests/unit/network/test_schema.py index d05a57bcfcd..5f92cb4a35c 100644 --- a/tests/unit/network/test_schema.py +++ b/tests/unit/network/test_schema.py @@ -16,16 +16,20 @@ from dataclasses import dataclass from inspect import isclass +import re +import graphene import pytest from cylc.flow.cfgspec.workflow import SPEC as WORKFLOW_SPEC from cylc.flow.network.schema import ( RUNTIME_FIELD_TO_CFG_MAP, + Mutations, Runtime, sort_elements, SortArgs, ) +from cylc.flow.workflow_status import WorkflowStatus @dataclass @@ -99,3 +103,23 @@ def test_runtime_field_to_cfg_map(field_name: str): cfg_name = RUNTIME_FIELD_TO_CFG_MAP[field_name] assert field_name in Runtime.__dict__ assert WORKFLOW_SPEC.get('runtime', '__MANY__', cfg_name) + + +@pytest.mark.parametrize('mutation', ( + pytest.param(attr, id=name) + for name, attr in Mutations.__dict__.items() + if isinstance(attr, graphene.Field) +)) +def test_mutations_valid_for(mutation): + """Check that all mutations have a "Valid for" in their description. + + This is needed by the UI to disable mutations that are not valid for the + workflow state. + """ + match = re.search( + r'Valid for:\s(.*)\sworkflows.', mutation.description + ) + assert match + valid_states = set(match.group(1).split(', ')) + assert valid_states + assert not valid_states.difference(i.value for i in WorkflowStatus) From 877f96e596fe9850d8998431bfb88f085df1dd39 Mon Sep 17 00:00:00 2001 From: David Sutherland Date: Mon, 13 May 2024 22:19:21 +1200 Subject: [PATCH 037/196] prune suicide edges fix (#6096) * prune suicide edges fix * Update cylc/flow/data_store_mgr.py Co-authored-by: Ronnie Dutta <61982285+MetRonnie@users.noreply.github.com> * Update cylc/flow/data_store_mgr.py Co-authored-by: Ronnie Dutta <61982285+MetRonnie@users.noreply.github.com> * Changelog [skip ci] --------- Co-authored-by: Ronnie Dutta <61982285+MetRonnie@users.noreply.github.com> --- changes.d/6096.fix.md | 1 + cylc/flow/data_store_mgr.py | 6 ++++-- 2 files changed, 5 insertions(+), 2 deletions(-) create mode 100644 changes.d/6096.fix.md diff --git a/changes.d/6096.fix.md b/changes.d/6096.fix.md new file mode 100644 index 00000000000..d70face335f --- /dev/null +++ b/changes.d/6096.fix.md @@ -0,0 +1 @@ +Fixed bug that caused graph arrows to go missing in the GUI when suicide triggers are present. \ No newline at end of file diff --git a/cylc/flow/data_store_mgr.py b/cylc/flow/data_store_mgr.py index 94354880349..d58cb2ac92d 100644 --- a/cylc/flow/data_store_mgr.py +++ b/cylc/flow/data_store_mgr.py @@ -1972,10 +1972,12 @@ def prune_pruned_updated_nodes(self): node = tp_added[tp_id] else: continue - for j_id in list(node.jobs) + list(tp_updated[tp_id].jobs): + update_node = tp_updated.pop(tp_id) + for j_id in list(node.jobs) + list(update_node.jobs): if j_id in j_updated: del j_updated[j_id] - del tp_updated[tp_id] + self.n_window_edges.difference_update(update_node.edges) + self.deltas[EDGES].pruned.extend(update_node.edges) self.pruned_task_proxies.clear() def update_family_proxies(self): From 877388dcd92311a76c1ac276a8e92c464af1d795 Mon Sep 17 00:00:00 2001 From: Tim Pillinger <26465611+wxtim@users.noreply.github.com> Date: Mon, 13 May 2024 18:22:37 +0100 Subject: [PATCH 038/196] Add an option to the workflow config to force use of compat mode (#6097) --- cylc/flow/config.py | 10 ++++++++-- cylc/flow/workflow_files.py | 12 +++++++++++- tests/unit/test_config.py | 18 ++++++++++++++++++ 3 files changed, 37 insertions(+), 3 deletions(-) diff --git a/cylc/flow/config.py b/cylc/flow/config.py index 05e5fc2f3cb..096d83d69ad 100644 --- a/cylc/flow/config.py +++ b/cylc/flow/config.py @@ -225,7 +225,8 @@ def __init__( run_dir: Optional[str] = None, log_dir: Optional[str] = None, work_dir: Optional[str] = None, - share_dir: Optional[str] = None + share_dir: Optional[str] = None, + force_compat_mode: bool = False, ) -> None: """ Initialize the workflow config object. @@ -234,8 +235,13 @@ def __init__( workflow: workflow ID fpath: workflow config file path options: CLI options + force_compat_mode: + If True, forces Cylc to use compatibility mode + overriding compatibility mode checks. + See https://github.com/cylc/cylc-rose/issues/319 + """ - check_deprecation(Path(fpath)) + check_deprecation(Path(fpath), force_compat_mode=force_compat_mode) self.mem_log = mem_log_func if self.mem_log is None: self.mem_log = lambda x: None diff --git a/cylc/flow/workflow_files.py b/cylc/flow/workflow_files.py index eb62db41c1a..44ae0d7742d 100644 --- a/cylc/flow/workflow_files.py +++ b/cylc/flow/workflow_files.py @@ -801,10 +801,19 @@ def get_workflow_title(id_): return title -def check_deprecation(path, warn=True): +def check_deprecation(path, warn=True, force_compat_mode=False): """Warn and turn on back-compat flag if Cylc 7 suite.rc detected. Path can point to config file or parent directory (i.e. workflow name). + + Args: + warn: + If True, then a warning will be logged when compatibility + mode is activated. + force_compat_mode: + If True, forces Cylc to use compatibility mode + overriding compatibility mode checks. + See https://github.com/cylc/cylc-rose/issues/319 """ if ( # Don't want to log if it's already been set True. @@ -812,6 +821,7 @@ def check_deprecation(path, warn=True): and ( path.resolve().name == WorkflowFiles.SUITE_RC or (path / WorkflowFiles.SUITE_RC).is_file() + or force_compat_mode ) ): cylc.flow.flags.cylc7_back_compat = True diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index a69ab176412..653c1c11f8b 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -31,6 +31,7 @@ from cylc.flow.cycling import loader from cylc.flow.cycling.loader import INTEGER_CYCLING_TYPE, ISO8601_CYCLING_TYPE from cylc.flow.exceptions import ( + GraphParseError, PointParsingError, InputError, WorkflowConfigError, @@ -1743,3 +1744,20 @@ def test_cylc_env_at_parsing( assert var in cylc_env else: assert var not in cylc_env + + +def test_force_workflow_compat_mode(tmp_path): + fpath = (tmp_path / 'flow.cylc') + from textwrap import dedent + fpath.write_text(dedent(""" + [scheduler] + allow implicit tasks = true + [scheduling] + [[graph]] + R1 = a:succeeded | a:failed => b + """)) + # It fails without compat mode: + with pytest.raises(GraphParseError, match='Opposite outputs'): + WorkflowConfig('foo', str(fpath), {}) + # It succeeds with compat mode: + WorkflowConfig('foo', str(fpath), {}, force_compat_mode=True) From 9c2ee9fc77c2fd96b561df9ee1551304ccfdd32c Mon Sep 17 00:00:00 2001 From: Ronnie Dutta <61982285+MetRonnie@users.noreply.github.com> Date: Tue, 14 May 2024 12:56:40 +0100 Subject: [PATCH 039/196] `cylc vip/vr`: do not pass rose options to `cylc play` --- cylc/flow/scripts/validate_install_play.py | 5 +---- cylc/flow/scripts/validate_reinstall.py | 5 +---- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/cylc/flow/scripts/validate_install_play.py b/cylc/flow/scripts/validate_install_play.py index 4ad66b5c454..5f3bdc45c2a 100644 --- a/cylc/flow/scripts/validate_install_play.py +++ b/cylc/flow/scripts/validate_install_play.py @@ -105,10 +105,7 @@ def main(parser: COP, options: 'Values', workflow_id: Optional[str] = None): workflow_id, options, compound_script_opts=VIP_OPTIONS, - script_opts=( - PLAY_OPTIONS + CYLC_ROSE_OPTIONS - + parser.get_std_options() - ), + script_opts=(*PLAY_OPTIONS, *parser.get_std_options()), source=orig_source, ) diff --git a/cylc/flow/scripts/validate_reinstall.py b/cylc/flow/scripts/validate_reinstall.py index 7a4472901ef..b82b7bfdc73 100644 --- a/cylc/flow/scripts/validate_reinstall.py +++ b/cylc/flow/scripts/validate_reinstall.py @@ -190,10 +190,7 @@ def vro_cli(parser: COP, options: 'Values', workflow_id: str): unparsed_wid, options, compound_script_opts=VR_OPTIONS, - script_opts=( - PLAY_OPTIONS + CYLC_ROSE_OPTIONS - + parser.get_std_options() - ), + script_opts=(*PLAY_OPTIONS, *parser.get_std_options()), source='', # Intentionally blank ) log_subcommand(*sys.argv[1:]) From 9616004cb0a2d691577a09be716e53fe054db34d Mon Sep 17 00:00:00 2001 From: Ronnie Dutta <61982285+MetRonnie@users.noreply.github.com> Date: Tue, 14 May 2024 12:57:17 +0100 Subject: [PATCH 040/196] Tidy & type annotations --- cylc/flow/option_parsers.py | 23 +++++++++++++---------- tests/unit/test_option_parsers.py | 31 ++++++++++++++++++------------- 2 files changed, 31 insertions(+), 23 deletions(-) diff --git a/cylc/flow/option_parsers.py b/cylc/flow/option_parsers.py index 6402641934e..9f416d0c0e8 100644 --- a/cylc/flow/option_parsers.py +++ b/cylc/flow/option_parsers.py @@ -33,7 +33,7 @@ import sys from textwrap import dedent -from typing import Any, Dict, Iterable, Optional, List, Tuple, Union +from typing import Any, Dict, Iterable, Optional, List, Set, Tuple, Union from cylc.flow import LOG from cylc.flow.terminal import supports_color, DIM @@ -62,7 +62,13 @@ class OptionSettings(): cylc.flow.option_parsers(thismodule).combine_options_pair. """ - def __init__(self, argslist, sources=None, useif=None, **kwargs): + def __init__( + self, + argslist: List[str], + sources: Optional[Set[str]] = None, + useif: str = '', + **kwargs + ): """Init function: Args: @@ -71,13 +77,10 @@ def __init__(self, argslist, sources=None, useif=None, **kwargs): useif: badge for use by Cylc optionparser. **kwargs: kwargs for optparse.option. """ - self.kwargs: Dict[str, str] = {} - self.sources: set = sources if sources is not None else set() - self.useif: str = useif if useif is not None else '' - - self.args: list[str] = argslist - for kwarg, value in kwargs.items(): - self.kwargs.update({kwarg: value}) + self.args: List[str] = argslist + self.kwargs: Dict[str, Any] = kwargs + self.sources: Set[str] = sources if sources is not None else set() + self.useif: str = useif def __eq__(self, other): """Args and Kwargs, but not other props equal. @@ -862,7 +865,7 @@ def cleanup_sysargv( # Get a list of unwanted args: unwanted_compound: List[str] = [] unwanted_simple: List[str] = [] - for unwanted_dest in (set(options.__dict__)) - set(script_opts_by_dest): + for unwanted_dest in set(options.__dict__) - set(script_opts_by_dest): for unwanted_arg in compound_opts_by_dest[unwanted_dest].args: if ( compound_opts_by_dest[unwanted_dest].kwargs.get('action', None) diff --git a/tests/unit/test_option_parsers.py b/tests/unit/test_option_parsers.py index 788aa391389..5a37d0bdc3d 100644 --- a/tests/unit/test_option_parsers.py +++ b/tests/unit/test_option_parsers.py @@ -322,7 +322,7 @@ def test_combine_options(inputs, expect): 'argv_before, kwargs, expect', [ param( - 'vip myworkflow -f something -b something_else --baz'.split(), + 'vip myworkflow -f something -b something_else --baz', { 'script_name': 'play', 'workflow_id': 'myworkflow', @@ -335,11 +335,11 @@ def test_combine_options(inputs, expect): OptionSettings(['--foo', '-f']), ] }, - 'play myworkflow -f something'.split(), + 'play myworkflow -f something', id='remove some opts' ), param( - 'vip myworkflow'.split(), + 'vip myworkflow', { 'script_name': 'play', 'workflow_id': 'myworkflow', @@ -350,11 +350,11 @@ def test_combine_options(inputs, expect): ], 'script_opts': [] }, - 'play myworkflow'.split(), + 'play myworkflow', id='no opts to keep' ), param( - 'vip ./myworkflow --foo something'.split(), + 'vip ./myworkflow --foo something', { 'script_name': 'play', 'workflow_id': 'myworkflow', @@ -365,11 +365,11 @@ def test_combine_options(inputs, expect): ], 'source': './myworkflow', }, - 'play --foo something myworkflow'.split(), + 'play --foo something myworkflow', id='replace path' ), param( - 'vip --foo something'.split(), + 'vip --foo something', { 'script_name': 'play', 'workflow_id': 'myworkflow', @@ -380,11 +380,11 @@ def test_combine_options(inputs, expect): ], 'source': './myworkflow', }, - 'play --foo something myworkflow'.split(), + 'play --foo something myworkflow', id='no path given' ), param( - 'vip -n myworkflow --no-run-name'.split(), + 'vip -n myworkflow --no-run-name', { 'script_name': 'play', 'workflow_id': 'myworkflow', @@ -396,17 +396,22 @@ def test_combine_options(inputs, expect): OptionSettings(['--not-used']), ] }, - 'play myworkflow'.split(), + 'play myworkflow', id='workflow-id-added' ), ] ) -def test_cleanup_sysargv(monkeypatch, argv_before, kwargs, expect): +def test_cleanup_sysargv( + monkeypatch: pytest.MonkeyPatch, + argv_before: str, + kwargs: dict, + expect: str +): """It replaces the contents of sysargv with Cylc Play argv items. """ # Fake up sys.argv: for this test. dummy_cylc_path = ['/pathto/my/cylc/bin/cylc'] - monkeypatch.setattr(sys, 'argv', dummy_cylc_path + argv_before) + monkeypatch.setattr(sys, 'argv', dummy_cylc_path + argv_before.split()) # Fake options too: opts = SimpleNamespace(**{ i.args[0].replace('--', ''): i for i in kwargs['compound_script_opts'] @@ -418,7 +423,7 @@ def test_cleanup_sysargv(monkeypatch, argv_before, kwargs, expect): # Test the script: cleanup_sysargv(**kwargs) - assert sys.argv == dummy_cylc_path + expect + assert sys.argv == dummy_cylc_path + expect.split() @pytest.mark.parametrize( From a5e598bb4e55f10ba3f76f9a75b73a7c103b8f60 Mon Sep 17 00:00:00 2001 From: Oliver Sanders Date: Tue, 14 May 2024 13:25:44 +0100 Subject: [PATCH 041/196] tests: reset global state before each test (#6101) --- tests/conftest.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/conftest.py b/tests/conftest.py index 4207a10f165..b9f794c19ed 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -25,6 +25,14 @@ from cylc.flow.cfgspec.globalcfg import SPEC from cylc.flow.parsec.config import ParsecConfig from cylc.flow.parsec.validate import cylc_config_validate +from cylc.flow import flags + + +@pytest.fixture(autouse=True) +def test_reset(): + """Reset global state before all tests.""" + flags.verbosity = 0 + flags.cylc7_back_compat = False @pytest.fixture(scope='module') From b9595f7c28375c1e33a512d2f645d368827da2e1 Mon Sep 17 00:00:00 2001 From: Ronnie Dutta <61982285+MetRonnie@users.noreply.github.com> Date: Tue, 14 May 2024 13:28:46 +0100 Subject: [PATCH 042/196] Changelog [skip ci] --- changes.d/6102.fix.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changes.d/6102.fix.md diff --git a/changes.d/6102.fix.md b/changes.d/6102.fix.md new file mode 100644 index 00000000000..cbc8ecc2bd8 --- /dev/null +++ b/changes.d/6102.fix.md @@ -0,0 +1 @@ +Fixed bug introduced in 8.2.6 in `cylc vip` & `cylc vr` when using cylc-rose options (`-S`, `-D`, `-O`). From b489af3115027951f5be2c5580832a55c5007188 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 15 May 2024 14:32:46 +0000 Subject: [PATCH 043/196] Prepare release 8.2.7 Workflow: Release stage 1 - create release PR (Cylc 8+ only), run: 36 --- CHANGES.md | 8 ++++++++ changes.d/6096.fix.md | 1 - changes.d/6102.fix.md | 1 - cylc/flow/__init__.py | 2 +- 4 files changed, 9 insertions(+), 3 deletions(-) delete mode 100644 changes.d/6096.fix.md delete mode 100644 changes.d/6102.fix.md diff --git a/CHANGES.md b/CHANGES.md index 6ea79cf6231..36280bda750 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -11,6 +11,14 @@ $ towncrier create ..md --content "Short description" +## __cylc-8.2.7 (Released 2024-05-15)__ + +### 🔧 Fixes + +[#6096](https://github.com/cylc/cylc-flow/pull/6096) - Fixed bug that caused graph arrows to go missing in the GUI when suicide triggers are present. + +[#6102](https://github.com/cylc/cylc-flow/pull/6102) - Fixed bug introduced in 8.2.6 in `cylc vip` & `cylc vr` when using cylc-rose options (`-S`, `-D`, `-O`). + ## __cylc-8.2.6 (Released 2024-05-02)__ ### ⚠ Breaking Changes diff --git a/changes.d/6096.fix.md b/changes.d/6096.fix.md deleted file mode 100644 index d70face335f..00000000000 --- a/changes.d/6096.fix.md +++ /dev/null @@ -1 +0,0 @@ -Fixed bug that caused graph arrows to go missing in the GUI when suicide triggers are present. \ No newline at end of file diff --git a/changes.d/6102.fix.md b/changes.d/6102.fix.md deleted file mode 100644 index cbc8ecc2bd8..00000000000 --- a/changes.d/6102.fix.md +++ /dev/null @@ -1 +0,0 @@ -Fixed bug introduced in 8.2.6 in `cylc vip` & `cylc vr` when using cylc-rose options (`-S`, `-D`, `-O`). diff --git a/cylc/flow/__init__.py b/cylc/flow/__init__.py index 22e6e49ab3f..d6b4fc5237e 100644 --- a/cylc/flow/__init__.py +++ b/cylc/flow/__init__.py @@ -53,7 +53,7 @@ def environ_init(): environ_init() -__version__ = '8.2.7.dev' +__version__ = '8.2.7' def iter_entry_points(entry_point_name): From 2ae50b22502b338915bedf6846e4e75f3983e1c2 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 15 May 2024 15:50:39 +0100 Subject: [PATCH 044/196] Bump dev version (#6107) --- cylc/flow/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cylc/flow/__init__.py b/cylc/flow/__init__.py index d6b4fc5237e..8a7c4b24ad1 100644 --- a/cylc/flow/__init__.py +++ b/cylc/flow/__init__.py @@ -53,7 +53,7 @@ def environ_init(): environ_init() -__version__ = '8.2.7' +__version__ = '8.2.8.dev' def iter_entry_points(entry_point_name): From 61e55e50396efcfb005ab65a08993784dc8df5ee Mon Sep 17 00:00:00 2001 From: Ronnie Dutta <61982285+MetRonnie@users.noreply.github.com> Date: Thu, 16 May 2024 11:55:27 +0100 Subject: [PATCH 045/196] GH Actions: print towncrier draft in job summary --- .github/workflows/test_fast.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test_fast.yml b/.github/workflows/test_fast.yml index d427466295b..d81e812b6da 100644 --- a/.github/workflows/test_fast.yml +++ b/.github/workflows/test_fast.yml @@ -121,8 +121,8 @@ jobs: - name: MyPy run: mypy - - name: Towncrier - run: towncrier build --draft + - name: Towncrier - draft changelog + uses: cylc/release-actions/towncrier-draft@v1 - name: Linkcheck run: pytest -m linkcheck --dist=load --color=yes -n 10 tests/unit/test_links.py From 887850569d9a2dda551a26486ef3f9d6f301722c Mon Sep 17 00:00:00 2001 From: Ronnie Dutta <61982285+MetRonnie@users.noreply.github.com> Date: Thu, 16 May 2024 12:38:43 +0100 Subject: [PATCH 046/196] Fix changelog fragment formatting --- changes.d/5873.feat.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/changes.d/5873.feat.md b/changes.d/5873.feat.md index 0b718922607..1f21646918e 100644 --- a/changes.d/5873.feat.md +++ b/changes.d/5873.feat.md @@ -1,3 +1,3 @@ -- Allow use of `#noqa: S001` comments to skip Cylc lint - checks for a single line. -- Stop Cylc lint objecting to `%include ` syntax. \ No newline at end of file +`cylc lint` improvements: +- Allow use of `#noqa: S001` comments to skip checks for a single line. +- Stop `cylc lint` objecting to `%include ` syntax. From 1a7f7979acbdb20515cc2e217c77d0b2045a99fe Mon Sep 17 00:00:00 2001 From: Ronnie Dutta <61982285+MetRonnie@users.noreply.github.com> Date: Tue, 21 May 2024 11:48:20 +0100 Subject: [PATCH 047/196] GraphQL: Improve schema error handling & tidy (#6026) --- cylc/flow/network/graphql.py | 50 ++++++------ cylc/flow/network/resolvers.py | 26 +++--- cylc/flow/network/schema.py | 139 ++++++++++++++++++--------------- 3 files changed, 115 insertions(+), 100 deletions(-) diff --git a/cylc/flow/network/graphql.py b/cylc/flow/network/graphql.py index 7650a58b154..1f49a6ee2fd 100644 --- a/cylc/flow/network/graphql.py +++ b/cylc/flow/network/graphql.py @@ -20,11 +20,10 @@ """ from functools import partial +from inspect import isclass, iscoroutinefunction import logging from typing import TYPE_CHECKING, Any, Tuple, Union -from inspect import isclass, iscoroutinefunction - from graphene.utils.str_converters import to_snake_case from graphql.execution.utils import ( get_operation_root_type, get_field_def @@ -35,16 +34,16 @@ from graphql.backend.base import GraphQLBackend, GraphQLDocument from graphql.backend.core import execute_and_validate from graphql.utils.base import type_from_ast -from graphql.type import get_named_type +from graphql.type.definition import get_named_type from promise import Promise from rx import Observable -from cylc.flow.network.schema import NODE_MAP, get_type_str +from cylc.flow.network.schema import NODE_MAP if TYPE_CHECKING: from graphql.execution import ExecutionResult from graphql.language.ast import Document - from graphql.type import GraphQLSchema + from graphql.type.schema import GraphQLSchema logger = logging.getLogger(__name__) @@ -376,18 +375,18 @@ def resolve(self, next_, root, info, **args): # Avoid using the protobuf default if field isn't set. if ( - hasattr(root, 'ListFields') - and hasattr(root, field_name) - and get_type_str(info.return_type) not in NODE_MAP + hasattr(root, 'ListFields') + and hasattr(root, field_name) + and get_named_type(info.return_type).name not in NODE_MAP ): # Gather fields set in root parent_path_string = f'{info.path[:-1:]}' stamp = getattr(root, 'stamp', '') if ( - parent_path_string not in self.field_sets - or self.field_sets[ - parent_path_string]['stamp'] != stamp + parent_path_string not in self.field_sets + or self.field_sets[ + parent_path_string]['stamp'] != stamp ): self.field_sets[parent_path_string] = { 'stamp': stamp, @@ -398,36 +397,33 @@ def resolve(self, next_, root, info, **args): } if ( - parent_path_string in self.field_sets - and field_name not in self.field_sets[ - parent_path_string]['fields'] + parent_path_string in self.field_sets + and field_name not in self.field_sets[ + parent_path_string]['fields'] ): return None # Do not resolve subfields of an empty type # by setting as null in parent/root. - elif ( - isinstance(root, dict) - and field_name in root - ): + elif isinstance(root, dict) and field_name in root: field_value = root[field_name] if ( - field_value in EMPTY_VALUES - or ( - hasattr(field_value, 'ListFields') - and not field_value.ListFields() - ) + field_value in EMPTY_VALUES + or ( + hasattr(field_value, 'ListFields') + and not field_value.ListFields() + ) ): return None if ( - info.operation.operation in self.ASYNC_OPS - or iscoroutinefunction(next_) + info.operation.operation in self.ASYNC_OPS + or iscoroutinefunction(next_) ): return self.async_null_setter(next_, root, info, **args) return null_setter(next_(root, info, **args)) if ( - info.operation.operation in self.ASYNC_OPS - or iscoroutinefunction(next_) + info.operation.operation in self.ASYNC_OPS + or iscoroutinefunction(next_) ): return self.async_resolve(next_, root, info, **args) return next_(root, info, **args) diff --git a/cylc/flow/network/resolvers.py b/cylc/flow/network/resolvers.py index 5451ef501aa..8bd9c2347ac 100644 --- a/cylc/flow/network/resolvers.py +++ b/cylc/flow/network/resolvers.py @@ -25,6 +25,7 @@ from time import time from typing import ( Any, + AsyncGenerator, Dict, List, NamedTuple, @@ -32,6 +33,7 @@ Tuple, TYPE_CHECKING, Union, + cast, ) from uuid import uuid4 @@ -58,6 +60,8 @@ from cylc.flow.data_store_mgr import DataStoreMgr from cylc.flow.scheduler import Scheduler + DeltaQueue = queue.Queue[Tuple[str, str, dict]] + class TaskMsg(NamedTuple): """Tuple for Scheduler.message_queue""" @@ -395,7 +399,7 @@ async def get_nodes_all(self, node_type, args): [ node for flow in await self.get_workflows_data(args) - for node in flow.get(node_type).values() + for node in flow[node_type].values() if node_filter( node, node_type, @@ -538,7 +542,9 @@ async def get_nodes_edges(self, root_nodes, args): nodes=sort_elements(nodes, args), edges=sort_elements(edges, args)) - async def subscribe_delta(self, root, info, args): + async def subscribe_delta( + self, root, info: 'ResolveInfo', args + ) -> AsyncGenerator[Any, None]: """Delta subscription async generator. Async generator mapping the incoming protobuf deltas to @@ -553,19 +559,19 @@ async def subscribe_delta(self, root, info, args): self.delta_store[sub_id] = {} op_id = root - if 'ops_queue' not in info.context: - info.context['ops_queue'] = {} - info.context['ops_queue'][op_id] = queue.Queue() - op_queue = info.context['ops_queue'][op_id] + op_queue: queue.Queue[Tuple[UUID, str]] = queue.Queue() + cast('dict', info.context).setdefault( + 'ops_queue', {} + )[op_id] = op_queue self.delta_processing_flows[sub_id] = set() delta_processing_flows = self.delta_processing_flows[sub_id] delta_queues = self.data_store_mgr.delta_queues - deltas_queue = queue.Queue() + deltas_queue: DeltaQueue = queue.Queue() - counters = {} - delta_yield_queue = queue.Queue() - flow_delta_queues = {} + counters: Dict[str, int] = {} + delta_yield_queue: DeltaQueue = queue.Queue() + flow_delta_queues: Dict[str, queue.Queue[Tuple[str, dict]]] = {} try: # Iterate over the queue yielding deltas w_ids = workflow_ids diff --git a/cylc/flow/network/schema.py b/cylc/flow/network/schema.py index 9ae784d8a00..3a8626e2c95 100644 --- a/cylc/flow/network/schema.py +++ b/cylc/flow/network/schema.py @@ -24,8 +24,12 @@ TYPE_CHECKING, AsyncGenerator, Any, + Dict, List, Optional, + Tuple, + Union, + cast, ) import graphene @@ -35,6 +39,7 @@ ) from graphene.types.generic import GenericScalar from graphene.utils.str_converters import to_snake_case +from graphql.type.definition import get_named_type from cylc.flow import LOG_LEVELS from cylc.flow.broadcast_mgr import ALL_CYCLE_POINTS_STRS, addict @@ -62,6 +67,11 @@ if TYPE_CHECKING: from graphql import ResolveInfo + from graphql.type.definition import ( + GraphQLNamedType, + GraphQLList, + GraphQLNonNull, + ) from cylc.flow.network.resolvers import BaseResolvers @@ -89,7 +99,7 @@ def sort_elements(elements, args): PROXY_NODES = 'proxy_nodes' - +# Mapping of GraphQL types to field names: NODE_MAP = { 'Task': TASKS, 'TaskProxy': TASK_PROXIES, @@ -99,22 +109,6 @@ def sort_elements(elements, args): 'Node': PROXY_NODES, } -CYCLING_TYPES = [ - 'family_proxies', - 'family_proxy', - 'jobs', - 'job', - 'task_proxies', - 'task_proxy', -] - -PROXY_TYPES = [ - 'family_proxies', - 'family_proxy', - 'task_proxies', - 'task_proxy', -] - DEF_TYPES = [ 'families', 'family', @@ -266,22 +260,35 @@ class SortArgs(InputObjectType): # Resolvers: -def get_type_str(obj_type): - """Iterate through the objects of_type to find the inner-most type.""" - pointer = obj_type - while hasattr(pointer, 'of_type'): - pointer = pointer.of_type - return str(pointer).replace('!', '') +def field_name_from_type( + obj_type: 'Union[GraphQLNamedType, GraphQLList, GraphQLNonNull]' +) -> str: + """Return the field name for given a GraphQL type. + + If the type is a list or non-null, the base field is extracted. + """ + named_type = cast('GraphQLNamedType', get_named_type(obj_type)) + try: + return NODE_MAP[named_type.name] + except KeyError: + raise ValueError(f"'{named_type.name}' is not a node type") + +def get_resolvers(info: 'ResolveInfo') -> 'BaseResolvers': + """Return the resolvers from the context.""" + return cast('dict', info.context)['resolvers'] -def process_resolver_info(root, info, args): + +def process_resolver_info( + root: Optional[Any], info: 'ResolveInfo', args: Dict[str, Any] +) -> Tuple[str, Optional[Any]]: """Set and gather info for resolver.""" # Add the subscription id to the resolver context # to know which delta-store to use.""" if 'backend_sub_id' in info.variable_values: args['sub_id'] = info.variable_values['backend_sub_id'] - field_name = to_snake_case(info.field_name) + field_name: str = to_snake_case(info.field_name) # root is the parent data object. # i.e. PbWorkflow or list of IDs graphene.List(String) if isinstance(root, dict): @@ -301,7 +308,7 @@ def get_native_ids(field_ids): return field_ids -async def get_workflows(root, info, **args): +async def get_workflows(root, info: 'ResolveInfo', **args): """Get filtered workflows.""" _, workflow = process_resolver_info(root, info, args) @@ -310,11 +317,11 @@ async def get_workflows(root, info, **args): args['workflows'] = [Tokens(w_id) for w_id in args['ids']] args['exworkflows'] = [Tokens(w_id) for w_id in args['exids']] - resolvers = info.context.get('resolvers') + resolvers = get_resolvers(info) return await resolvers.get_workflows(args) -async def get_workflow_by_id(root, info, **args): +async def get_workflow_by_id(root, info: 'ResolveInfo', **args): """Return single workflow element.""" _, workflow = process_resolver_info(root, info, args) @@ -322,11 +329,13 @@ async def get_workflow_by_id(root, info, **args): args['id'] = workflow.id args['workflow'] = args['id'] - resolvers = info.context.get('resolvers') + resolvers = get_resolvers(info) return await resolvers.get_workflow_by_id(args) -async def get_nodes_all(root, info, **args): +async def get_nodes_all( + root: Optional[Any], info: 'ResolveInfo', **args +): """Resolver for returning job, task, family nodes""" _, field_ids = process_resolver_info(root, info, args) @@ -340,10 +349,10 @@ async def get_nodes_all(root, info, **args): elif field_ids == []: return [] - node_type = NODE_MAP[get_type_str(info.return_type)] + node_field_name = field_name_from_type(info.return_type) for arg in ('ids', 'exids'): - if node_type in DEF_TYPES: + if node_field_name in DEF_TYPES: # namespace nodes don't fit into the universal ID scheme so must # be tokenised manually args[arg] = [ @@ -359,15 +368,17 @@ async def get_nodes_all(root, info, **args): args[arg] = [Tokens(n_id, relative=True) for n_id in args[arg]] for arg in ('workflows', 'exworkflows'): args[arg] = [Tokens(w_id) for w_id in args[arg]] - resolvers = info.context.get('resolvers') - return await resolvers.get_nodes_all(node_type, args) + resolvers = get_resolvers(info) + return await resolvers.get_nodes_all(node_field_name, args) -async def get_nodes_by_ids(root, info, **args): +async def get_nodes_by_ids( + root: Optional[Any], info: 'ResolveInfo', **args +): """Resolver for returning job, task, family node""" field_name, field_ids = process_resolver_info(root, info, args) - resolvers = info.context.get('resolvers') + resolvers = get_resolvers(info) if field_ids == []: parent_id = getattr(root, 'id', None) # Find node ids from parent @@ -376,10 +387,13 @@ async def get_nodes_by_ids(root, info, **args): parent_args.update( {'id': parent_id, 'delta_store': False} ) - parent_type = get_type_str(info.parent_type) - if parent_type in NODE_MAP: + parent_type = cast( + 'GraphQLNamedType', get_named_type(info.parent_type) + ) + if parent_type.name in NODE_MAP: parent = await resolvers.get_node_by_id( - NODE_MAP[parent_type], parent_args) + NODE_MAP[parent_type.name], parent_args + ) else: parent = await resolvers.get_workflow_by_id(parent_args) field_ids = getattr(parent, field_name, None) @@ -388,14 +402,16 @@ async def get_nodes_by_ids(root, info, **args): if field_ids: args['native_ids'] = get_native_ids(field_ids) - node_type = NODE_MAP[get_type_str(info.return_type)] + node_field_name = field_name_from_type(info.return_type) args['ids'] = [Tokens(n_id, relative=True) for n_id in args['ids']] args['exids'] = [Tokens(n_id, relative=True) for n_id in args['exids']] - return await resolvers.get_nodes_by_ids(node_type, args) + return await resolvers.get_nodes_by_ids(node_field_name, args) -async def get_node_by_id(root, info, **args): +async def get_node_by_id( + root: Optional[Any], info: 'ResolveInfo', **args +): """Resolver for returning job, task, family node""" field_name, field_id = process_resolver_info(root, info, args) @@ -405,7 +421,7 @@ async def get_node_by_id(root, info, **args): elif field_name == 'target_node': field_name = 'target' - resolvers = info.context.get('resolvers') + resolvers = get_resolvers(info) if args.get('id') is None: field_id = getattr(root, field_name, None) # Find node id from parent @@ -418,7 +434,7 @@ async def get_node_by_id(root, info, **args): ) args['id'] = parent_id parent = await resolvers.get_node_by_id( - NODE_MAP[get_type_str(info.parent_type)], + field_name_from_type(info.parent_type), parent_args ) field_id = getattr(parent, field_name, None) @@ -428,11 +444,11 @@ async def get_node_by_id(root, info, **args): return None return await resolvers.get_node_by_id( - NODE_MAP[get_type_str(info.return_type)], - args) + field_name_from_type(info.return_type), args + ) -async def get_edges_all(root, info, **args): +async def get_edges_all(root, info: 'ResolveInfo', **args): """Get all edges from the store filtered by args.""" process_resolver_info(root, info, args) @@ -443,11 +459,11 @@ async def get_edges_all(root, info, **args): args['exworkflows'] = [ Tokens(w_id) for w_id in args['exworkflows'] ] - resolvers = info.context.get('resolvers') + resolvers = get_resolvers(info) return await resolvers.get_edges_all(args) -async def get_edges_by_ids(root, info, **args): +async def get_edges_by_ids(root, info: 'ResolveInfo', **args): """Get all edges from the store by id lookup filtered by args.""" _, field_ids = process_resolver_info(root, info, args) @@ -457,11 +473,11 @@ async def get_edges_by_ids(root, info, **args): elif field_ids == []: return [] - resolvers = info.context.get('resolvers') + resolvers = get_resolvers(info) return await resolvers.get_edges_by_ids(args) -async def get_nodes_edges(root, info, **args): +async def get_nodes_edges(root, info: 'ResolveInfo', **args): """Resolver for returning job, task, family nodes""" process_resolver_info(root, info, args) @@ -477,12 +493,11 @@ async def get_nodes_edges(root, info, **args): Tokens(w_id) for w_id in args['exworkflows'] ] - node_type = NODE_MAP['TaskProxy'] args['ids'] = [Tokens(n_id) for n_id in args['ids']] args['exids'] = [Tokens(n_id) for n_id in args['exids']] - resolvers = info.context.get('resolvers') - root_nodes = await resolvers.get_nodes_all(node_type, args) + resolvers = get_resolvers(info) + root_nodes = await resolvers.get_nodes_all(TASK_PROXIES, args) return await resolvers.get_nodes_edges(root_nodes, args) @@ -502,18 +517,18 @@ def resolve_state_tasks(root, info, **args): if state in data} -async def resolve_broadcasts(root, info, **args): +async def resolve_broadcasts(root, info: 'ResolveInfo', **args): """Resolve and parse broadcasts from JSON.""" broadcasts = json.loads( getattr(root, to_snake_case(info.field_name), '{}')) - resolvers = info.context.get('resolvers') + resolvers = get_resolvers(info) if not args['ids']: return broadcasts - result = {} + result: Dict[str, dict] = {} t_type = NODE_MAP['Task'] - t_args = {'workflows': [Tokens(root.id)]} + t_args: Dict[str, list] = {'workflows': [Tokens(root.id)]} for n_id in args['ids']: tokens = Tokens(n_id) point_string = tokens['cycle'] @@ -1454,9 +1469,7 @@ async def mutator( if kwargs.get('args', False): kwargs.update(kwargs.get('args', {})) kwargs.pop('args') - resolvers: 'BaseResolvers' = ( - info.context.get('resolvers') # type: ignore[union-attr] - ) + resolvers = get_resolvers(info) meta = info.context.get('meta') # type: ignore[union-attr] res = await resolvers.mutator(info, command, w_args, kwargs, meta) return GenericResponse(result=res) @@ -2204,9 +2217,9 @@ class Mutations(ObjectType): } -def delta_subs(root, info, **args) -> AsyncGenerator[Any, None]: +def delta_subs(root, info: 'ResolveInfo', **args) -> AsyncGenerator[Any, None]: """Generates the root data from the async gen resolver.""" - return info.context.get('resolvers').subscribe_delta(root, info, args) + return get_resolvers(info).subscribe_delta(root, info, args) class Pruned(ObjectType): From e118655d478dcf7f4e6d47b9d099a70eec0a8c9e Mon Sep 17 00:00:00 2001 From: Ronnie Dutta <61982285+MetRonnie@users.noreply.github.com> Date: Thu, 23 May 2024 12:51:42 +0100 Subject: [PATCH 048/196] Data store: fix mangled flow nums reported for n>1 tasks --- cylc/flow/data_store_mgr.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cylc/flow/data_store_mgr.py b/cylc/flow/data_store_mgr.py index 0da3a765e2f..ff4d7f87ba1 100644 --- a/cylc/flow/data_store_mgr.py +++ b/cylc/flow/data_store_mgr.py @@ -1781,7 +1781,7 @@ def window_resize_rewalk(self): self.increment_graph_window( tokens, get_point(tokens['cycle']), - tproxy.flow_nums + deserialise(tproxy.flow_nums) ) # Flag difference between old and new window for pruning. self.prune_flagged_nodes.update( From 63a6a377982eb91875a260e6b734435b3ae00740 Mon Sep 17 00:00:00 2001 From: Ronnie Dutta <61982285+MetRonnie@users.noreply.github.com> Date: Thu, 23 May 2024 14:28:29 +0100 Subject: [PATCH 049/196] Type annotations & generate protobuf type stubs --- cylc/flow/data_messages.proto | 2 +- cylc/flow/data_messages_pb2.py | 1 - cylc/flow/data_messages_pb2.pyi | 630 ++++++++++++++++++++++++++++++++ cylc/flow/data_store_mgr.py | 84 +++-- cylc/flow/network/server.py | 4 +- cylc/flow/prerequisite.py | 2 +- cylc/flow/scripts/client.py | 6 +- mypy.ini | 4 - 8 files changed, 682 insertions(+), 51 deletions(-) create mode 100644 cylc/flow/data_messages_pb2.pyi diff --git a/cylc/flow/data_messages.proto b/cylc/flow/data_messages.proto index bc0355e6d4c..c0af5094c0d 100644 --- a/cylc/flow/data_messages.proto +++ b/cylc/flow/data_messages.proto @@ -25,7 +25,7 @@ syntax = "proto3"; * message modules. * * Command: - * $ protoc -I=./ --python_out=./ data_messages.proto && sed -i '1i# type: ignore' data_messages_pb2.py + * $ protoc -I=./ --python_out=./ --pyi_out=./ data_messages.proto * * Pre-compiled protoc binary may be download from: * https://github.com/protocolbuffers/protobuf/releases diff --git a/cylc/flow/data_messages_pb2.py b/cylc/flow/data_messages_pb2.py index 5ecb96fc122..7fb5ae84d24 100644 --- a/cylc/flow/data_messages_pb2.py +++ b/cylc/flow/data_messages_pb2.py @@ -1,4 +1,3 @@ -# type: ignore # -*- coding: utf-8 -*- # Generated by the protocol buffer compiler. DO NOT EDIT! # source: data_messages.proto diff --git a/cylc/flow/data_messages_pb2.pyi b/cylc/flow/data_messages_pb2.pyi new file mode 100644 index 00000000000..4e96c6ed2da --- /dev/null +++ b/cylc/flow/data_messages_pb2.pyi @@ -0,0 +1,630 @@ +from google.protobuf.internal import containers as _containers +from google.protobuf import descriptor as _descriptor +from google.protobuf import message as _message +from typing import ClassVar as _ClassVar, Iterable as _Iterable, Mapping as _Mapping, Optional as _Optional, Union as _Union + +DESCRIPTOR: _descriptor.FileDescriptor + +class PbMeta(_message.Message): + __slots__ = ["title", "description", "URL", "user_defined"] + TITLE_FIELD_NUMBER: _ClassVar[int] + DESCRIPTION_FIELD_NUMBER: _ClassVar[int] + URL_FIELD_NUMBER: _ClassVar[int] + USER_DEFINED_FIELD_NUMBER: _ClassVar[int] + title: str + description: str + URL: str + user_defined: str + def __init__(self, title: _Optional[str] = ..., description: _Optional[str] = ..., URL: _Optional[str] = ..., user_defined: _Optional[str] = ...) -> None: ... + +class PbTimeZone(_message.Message): + __slots__ = ["hours", "minutes", "string_basic", "string_extended"] + HOURS_FIELD_NUMBER: _ClassVar[int] + MINUTES_FIELD_NUMBER: _ClassVar[int] + STRING_BASIC_FIELD_NUMBER: _ClassVar[int] + STRING_EXTENDED_FIELD_NUMBER: _ClassVar[int] + hours: int + minutes: int + string_basic: str + string_extended: str + def __init__(self, hours: _Optional[int] = ..., minutes: _Optional[int] = ..., string_basic: _Optional[str] = ..., string_extended: _Optional[str] = ...) -> None: ... + +class PbTaskProxyRefs(_message.Message): + __slots__ = ["task_proxies"] + TASK_PROXIES_FIELD_NUMBER: _ClassVar[int] + task_proxies: _containers.RepeatedScalarFieldContainer[str] + def __init__(self, task_proxies: _Optional[_Iterable[str]] = ...) -> None: ... + +class PbWorkflow(_message.Message): + __slots__ = ["stamp", "id", "name", "status", "host", "port", "owner", "tasks", "families", "edges", "api_version", "cylc_version", "last_updated", "meta", "newest_active_cycle_point", "oldest_active_cycle_point", "reloaded", "run_mode", "cycling_mode", "state_totals", "workflow_log_dir", "time_zone_info", "tree_depth", "job_log_names", "ns_def_order", "states", "task_proxies", "family_proxies", "status_msg", "is_held_total", "jobs", "pub_port", "broadcasts", "is_queued_total", "latest_state_tasks", "pruned", "is_runahead_total", "states_updated", "n_edge_distance"] + class StateTotalsEntry(_message.Message): + __slots__ = ["key", "value"] + KEY_FIELD_NUMBER: _ClassVar[int] + VALUE_FIELD_NUMBER: _ClassVar[int] + key: str + value: int + def __init__(self, key: _Optional[str] = ..., value: _Optional[int] = ...) -> None: ... + class LatestStateTasksEntry(_message.Message): + __slots__ = ["key", "value"] + KEY_FIELD_NUMBER: _ClassVar[int] + VALUE_FIELD_NUMBER: _ClassVar[int] + key: str + value: PbTaskProxyRefs + def __init__(self, key: _Optional[str] = ..., value: _Optional[_Union[PbTaskProxyRefs, _Mapping]] = ...) -> None: ... + STAMP_FIELD_NUMBER: _ClassVar[int] + ID_FIELD_NUMBER: _ClassVar[int] + NAME_FIELD_NUMBER: _ClassVar[int] + STATUS_FIELD_NUMBER: _ClassVar[int] + HOST_FIELD_NUMBER: _ClassVar[int] + PORT_FIELD_NUMBER: _ClassVar[int] + OWNER_FIELD_NUMBER: _ClassVar[int] + TASKS_FIELD_NUMBER: _ClassVar[int] + FAMILIES_FIELD_NUMBER: _ClassVar[int] + EDGES_FIELD_NUMBER: _ClassVar[int] + API_VERSION_FIELD_NUMBER: _ClassVar[int] + CYLC_VERSION_FIELD_NUMBER: _ClassVar[int] + LAST_UPDATED_FIELD_NUMBER: _ClassVar[int] + META_FIELD_NUMBER: _ClassVar[int] + NEWEST_ACTIVE_CYCLE_POINT_FIELD_NUMBER: _ClassVar[int] + OLDEST_ACTIVE_CYCLE_POINT_FIELD_NUMBER: _ClassVar[int] + RELOADED_FIELD_NUMBER: _ClassVar[int] + RUN_MODE_FIELD_NUMBER: _ClassVar[int] + CYCLING_MODE_FIELD_NUMBER: _ClassVar[int] + STATE_TOTALS_FIELD_NUMBER: _ClassVar[int] + WORKFLOW_LOG_DIR_FIELD_NUMBER: _ClassVar[int] + TIME_ZONE_INFO_FIELD_NUMBER: _ClassVar[int] + TREE_DEPTH_FIELD_NUMBER: _ClassVar[int] + JOB_LOG_NAMES_FIELD_NUMBER: _ClassVar[int] + NS_DEF_ORDER_FIELD_NUMBER: _ClassVar[int] + STATES_FIELD_NUMBER: _ClassVar[int] + TASK_PROXIES_FIELD_NUMBER: _ClassVar[int] + FAMILY_PROXIES_FIELD_NUMBER: _ClassVar[int] + STATUS_MSG_FIELD_NUMBER: _ClassVar[int] + IS_HELD_TOTAL_FIELD_NUMBER: _ClassVar[int] + JOBS_FIELD_NUMBER: _ClassVar[int] + PUB_PORT_FIELD_NUMBER: _ClassVar[int] + BROADCASTS_FIELD_NUMBER: _ClassVar[int] + IS_QUEUED_TOTAL_FIELD_NUMBER: _ClassVar[int] + LATEST_STATE_TASKS_FIELD_NUMBER: _ClassVar[int] + PRUNED_FIELD_NUMBER: _ClassVar[int] + IS_RUNAHEAD_TOTAL_FIELD_NUMBER: _ClassVar[int] + STATES_UPDATED_FIELD_NUMBER: _ClassVar[int] + N_EDGE_DISTANCE_FIELD_NUMBER: _ClassVar[int] + stamp: str + id: str + name: str + status: str + host: str + port: int + owner: str + tasks: _containers.RepeatedScalarFieldContainer[str] + families: _containers.RepeatedScalarFieldContainer[str] + edges: PbEdges + api_version: int + cylc_version: str + last_updated: float + meta: PbMeta + newest_active_cycle_point: str + oldest_active_cycle_point: str + reloaded: bool + run_mode: str + cycling_mode: str + state_totals: _containers.ScalarMap[str, int] + workflow_log_dir: str + time_zone_info: PbTimeZone + tree_depth: int + job_log_names: _containers.RepeatedScalarFieldContainer[str] + ns_def_order: _containers.RepeatedScalarFieldContainer[str] + states: _containers.RepeatedScalarFieldContainer[str] + task_proxies: _containers.RepeatedScalarFieldContainer[str] + family_proxies: _containers.RepeatedScalarFieldContainer[str] + status_msg: str + is_held_total: int + jobs: _containers.RepeatedScalarFieldContainer[str] + pub_port: int + broadcasts: str + is_queued_total: int + latest_state_tasks: _containers.MessageMap[str, PbTaskProxyRefs] + pruned: bool + is_runahead_total: int + states_updated: bool + n_edge_distance: int + def __init__(self, stamp: _Optional[str] = ..., id: _Optional[str] = ..., name: _Optional[str] = ..., status: _Optional[str] = ..., host: _Optional[str] = ..., port: _Optional[int] = ..., owner: _Optional[str] = ..., tasks: _Optional[_Iterable[str]] = ..., families: _Optional[_Iterable[str]] = ..., edges: _Optional[_Union[PbEdges, _Mapping]] = ..., api_version: _Optional[int] = ..., cylc_version: _Optional[str] = ..., last_updated: _Optional[float] = ..., meta: _Optional[_Union[PbMeta, _Mapping]] = ..., newest_active_cycle_point: _Optional[str] = ..., oldest_active_cycle_point: _Optional[str] = ..., reloaded: bool = ..., run_mode: _Optional[str] = ..., cycling_mode: _Optional[str] = ..., state_totals: _Optional[_Mapping[str, int]] = ..., workflow_log_dir: _Optional[str] = ..., time_zone_info: _Optional[_Union[PbTimeZone, _Mapping]] = ..., tree_depth: _Optional[int] = ..., job_log_names: _Optional[_Iterable[str]] = ..., ns_def_order: _Optional[_Iterable[str]] = ..., states: _Optional[_Iterable[str]] = ..., task_proxies: _Optional[_Iterable[str]] = ..., family_proxies: _Optional[_Iterable[str]] = ..., status_msg: _Optional[str] = ..., is_held_total: _Optional[int] = ..., jobs: _Optional[_Iterable[str]] = ..., pub_port: _Optional[int] = ..., broadcasts: _Optional[str] = ..., is_queued_total: _Optional[int] = ..., latest_state_tasks: _Optional[_Mapping[str, PbTaskProxyRefs]] = ..., pruned: bool = ..., is_runahead_total: _Optional[int] = ..., states_updated: bool = ..., n_edge_distance: _Optional[int] = ...) -> None: ... + +class PbRuntime(_message.Message): + __slots__ = ["platform", "script", "init_script", "env_script", "err_script", "exit_script", "pre_script", "post_script", "work_sub_dir", "execution_polling_intervals", "execution_retry_delays", "execution_time_limit", "submission_polling_intervals", "submission_retry_delays", "directives", "environment", "outputs", "completion"] + PLATFORM_FIELD_NUMBER: _ClassVar[int] + SCRIPT_FIELD_NUMBER: _ClassVar[int] + INIT_SCRIPT_FIELD_NUMBER: _ClassVar[int] + ENV_SCRIPT_FIELD_NUMBER: _ClassVar[int] + ERR_SCRIPT_FIELD_NUMBER: _ClassVar[int] + EXIT_SCRIPT_FIELD_NUMBER: _ClassVar[int] + PRE_SCRIPT_FIELD_NUMBER: _ClassVar[int] + POST_SCRIPT_FIELD_NUMBER: _ClassVar[int] + WORK_SUB_DIR_FIELD_NUMBER: _ClassVar[int] + EXECUTION_POLLING_INTERVALS_FIELD_NUMBER: _ClassVar[int] + EXECUTION_RETRY_DELAYS_FIELD_NUMBER: _ClassVar[int] + EXECUTION_TIME_LIMIT_FIELD_NUMBER: _ClassVar[int] + SUBMISSION_POLLING_INTERVALS_FIELD_NUMBER: _ClassVar[int] + SUBMISSION_RETRY_DELAYS_FIELD_NUMBER: _ClassVar[int] + DIRECTIVES_FIELD_NUMBER: _ClassVar[int] + ENVIRONMENT_FIELD_NUMBER: _ClassVar[int] + OUTPUTS_FIELD_NUMBER: _ClassVar[int] + COMPLETION_FIELD_NUMBER: _ClassVar[int] + platform: str + script: str + init_script: str + env_script: str + err_script: str + exit_script: str + pre_script: str + post_script: str + work_sub_dir: str + execution_polling_intervals: str + execution_retry_delays: str + execution_time_limit: str + submission_polling_intervals: str + submission_retry_delays: str + directives: str + environment: str + outputs: str + completion: str + def __init__(self, platform: _Optional[str] = ..., script: _Optional[str] = ..., init_script: _Optional[str] = ..., env_script: _Optional[str] = ..., err_script: _Optional[str] = ..., exit_script: _Optional[str] = ..., pre_script: _Optional[str] = ..., post_script: _Optional[str] = ..., work_sub_dir: _Optional[str] = ..., execution_polling_intervals: _Optional[str] = ..., execution_retry_delays: _Optional[str] = ..., execution_time_limit: _Optional[str] = ..., submission_polling_intervals: _Optional[str] = ..., submission_retry_delays: _Optional[str] = ..., directives: _Optional[str] = ..., environment: _Optional[str] = ..., outputs: _Optional[str] = ..., completion: _Optional[str] = ...) -> None: ... + +class PbJob(_message.Message): + __slots__ = ["stamp", "id", "submit_num", "state", "task_proxy", "submitted_time", "started_time", "finished_time", "job_id", "job_runner_name", "execution_time_limit", "platform", "job_log_dir", "name", "cycle_point", "messages", "runtime"] + STAMP_FIELD_NUMBER: _ClassVar[int] + ID_FIELD_NUMBER: _ClassVar[int] + SUBMIT_NUM_FIELD_NUMBER: _ClassVar[int] + STATE_FIELD_NUMBER: _ClassVar[int] + TASK_PROXY_FIELD_NUMBER: _ClassVar[int] + SUBMITTED_TIME_FIELD_NUMBER: _ClassVar[int] + STARTED_TIME_FIELD_NUMBER: _ClassVar[int] + FINISHED_TIME_FIELD_NUMBER: _ClassVar[int] + JOB_ID_FIELD_NUMBER: _ClassVar[int] + JOB_RUNNER_NAME_FIELD_NUMBER: _ClassVar[int] + EXECUTION_TIME_LIMIT_FIELD_NUMBER: _ClassVar[int] + PLATFORM_FIELD_NUMBER: _ClassVar[int] + JOB_LOG_DIR_FIELD_NUMBER: _ClassVar[int] + NAME_FIELD_NUMBER: _ClassVar[int] + CYCLE_POINT_FIELD_NUMBER: _ClassVar[int] + MESSAGES_FIELD_NUMBER: _ClassVar[int] + RUNTIME_FIELD_NUMBER: _ClassVar[int] + stamp: str + id: str + submit_num: int + state: str + task_proxy: str + submitted_time: str + started_time: str + finished_time: str + job_id: str + job_runner_name: str + execution_time_limit: float + platform: str + job_log_dir: str + name: str + cycle_point: str + messages: _containers.RepeatedScalarFieldContainer[str] + runtime: PbRuntime + def __init__(self, stamp: _Optional[str] = ..., id: _Optional[str] = ..., submit_num: _Optional[int] = ..., state: _Optional[str] = ..., task_proxy: _Optional[str] = ..., submitted_time: _Optional[str] = ..., started_time: _Optional[str] = ..., finished_time: _Optional[str] = ..., job_id: _Optional[str] = ..., job_runner_name: _Optional[str] = ..., execution_time_limit: _Optional[float] = ..., platform: _Optional[str] = ..., job_log_dir: _Optional[str] = ..., name: _Optional[str] = ..., cycle_point: _Optional[str] = ..., messages: _Optional[_Iterable[str]] = ..., runtime: _Optional[_Union[PbRuntime, _Mapping]] = ...) -> None: ... + +class PbTask(_message.Message): + __slots__ = ["stamp", "id", "name", "meta", "mean_elapsed_time", "depth", "proxies", "namespace", "parents", "first_parent", "runtime"] + STAMP_FIELD_NUMBER: _ClassVar[int] + ID_FIELD_NUMBER: _ClassVar[int] + NAME_FIELD_NUMBER: _ClassVar[int] + META_FIELD_NUMBER: _ClassVar[int] + MEAN_ELAPSED_TIME_FIELD_NUMBER: _ClassVar[int] + DEPTH_FIELD_NUMBER: _ClassVar[int] + PROXIES_FIELD_NUMBER: _ClassVar[int] + NAMESPACE_FIELD_NUMBER: _ClassVar[int] + PARENTS_FIELD_NUMBER: _ClassVar[int] + FIRST_PARENT_FIELD_NUMBER: _ClassVar[int] + RUNTIME_FIELD_NUMBER: _ClassVar[int] + stamp: str + id: str + name: str + meta: PbMeta + mean_elapsed_time: float + depth: int + proxies: _containers.RepeatedScalarFieldContainer[str] + namespace: _containers.RepeatedScalarFieldContainer[str] + parents: _containers.RepeatedScalarFieldContainer[str] + first_parent: str + runtime: PbRuntime + def __init__(self, stamp: _Optional[str] = ..., id: _Optional[str] = ..., name: _Optional[str] = ..., meta: _Optional[_Union[PbMeta, _Mapping]] = ..., mean_elapsed_time: _Optional[float] = ..., depth: _Optional[int] = ..., proxies: _Optional[_Iterable[str]] = ..., namespace: _Optional[_Iterable[str]] = ..., parents: _Optional[_Iterable[str]] = ..., first_parent: _Optional[str] = ..., runtime: _Optional[_Union[PbRuntime, _Mapping]] = ...) -> None: ... + +class PbPollTask(_message.Message): + __slots__ = ["local_proxy", "workflow", "remote_proxy", "req_state", "graph_string"] + LOCAL_PROXY_FIELD_NUMBER: _ClassVar[int] + WORKFLOW_FIELD_NUMBER: _ClassVar[int] + REMOTE_PROXY_FIELD_NUMBER: _ClassVar[int] + REQ_STATE_FIELD_NUMBER: _ClassVar[int] + GRAPH_STRING_FIELD_NUMBER: _ClassVar[int] + local_proxy: str + workflow: str + remote_proxy: str + req_state: str + graph_string: str + def __init__(self, local_proxy: _Optional[str] = ..., workflow: _Optional[str] = ..., remote_proxy: _Optional[str] = ..., req_state: _Optional[str] = ..., graph_string: _Optional[str] = ...) -> None: ... + +class PbCondition(_message.Message): + __slots__ = ["task_proxy", "expr_alias", "req_state", "satisfied", "message"] + TASK_PROXY_FIELD_NUMBER: _ClassVar[int] + EXPR_ALIAS_FIELD_NUMBER: _ClassVar[int] + REQ_STATE_FIELD_NUMBER: _ClassVar[int] + SATISFIED_FIELD_NUMBER: _ClassVar[int] + MESSAGE_FIELD_NUMBER: _ClassVar[int] + task_proxy: str + expr_alias: str + req_state: str + satisfied: bool + message: str + def __init__(self, task_proxy: _Optional[str] = ..., expr_alias: _Optional[str] = ..., req_state: _Optional[str] = ..., satisfied: bool = ..., message: _Optional[str] = ...) -> None: ... + +class PbPrerequisite(_message.Message): + __slots__ = ["expression", "conditions", "cycle_points", "satisfied"] + EXPRESSION_FIELD_NUMBER: _ClassVar[int] + CONDITIONS_FIELD_NUMBER: _ClassVar[int] + CYCLE_POINTS_FIELD_NUMBER: _ClassVar[int] + SATISFIED_FIELD_NUMBER: _ClassVar[int] + expression: str + conditions: _containers.RepeatedCompositeFieldContainer[PbCondition] + cycle_points: _containers.RepeatedScalarFieldContainer[str] + satisfied: bool + def __init__(self, expression: _Optional[str] = ..., conditions: _Optional[_Iterable[_Union[PbCondition, _Mapping]]] = ..., cycle_points: _Optional[_Iterable[str]] = ..., satisfied: bool = ...) -> None: ... + +class PbOutput(_message.Message): + __slots__ = ["label", "message", "satisfied", "time"] + LABEL_FIELD_NUMBER: _ClassVar[int] + MESSAGE_FIELD_NUMBER: _ClassVar[int] + SATISFIED_FIELD_NUMBER: _ClassVar[int] + TIME_FIELD_NUMBER: _ClassVar[int] + label: str + message: str + satisfied: bool + time: float + def __init__(self, label: _Optional[str] = ..., message: _Optional[str] = ..., satisfied: bool = ..., time: _Optional[float] = ...) -> None: ... + +class PbTrigger(_message.Message): + __slots__ = ["id", "label", "message", "satisfied", "time"] + ID_FIELD_NUMBER: _ClassVar[int] + LABEL_FIELD_NUMBER: _ClassVar[int] + MESSAGE_FIELD_NUMBER: _ClassVar[int] + SATISFIED_FIELD_NUMBER: _ClassVar[int] + TIME_FIELD_NUMBER: _ClassVar[int] + id: str + label: str + message: str + satisfied: bool + time: float + def __init__(self, id: _Optional[str] = ..., label: _Optional[str] = ..., message: _Optional[str] = ..., satisfied: bool = ..., time: _Optional[float] = ...) -> None: ... + +class PbTaskProxy(_message.Message): + __slots__ = ["stamp", "id", "task", "state", "cycle_point", "depth", "job_submits", "outputs", "namespace", "prerequisites", "jobs", "first_parent", "name", "is_held", "edges", "ancestors", "flow_nums", "external_triggers", "xtriggers", "is_queued", "is_runahead", "flow_wait", "runtime", "graph_depth"] + class OutputsEntry(_message.Message): + __slots__ = ["key", "value"] + KEY_FIELD_NUMBER: _ClassVar[int] + VALUE_FIELD_NUMBER: _ClassVar[int] + key: str + value: PbOutput + def __init__(self, key: _Optional[str] = ..., value: _Optional[_Union[PbOutput, _Mapping]] = ...) -> None: ... + class ExternalTriggersEntry(_message.Message): + __slots__ = ["key", "value"] + KEY_FIELD_NUMBER: _ClassVar[int] + VALUE_FIELD_NUMBER: _ClassVar[int] + key: str + value: PbTrigger + def __init__(self, key: _Optional[str] = ..., value: _Optional[_Union[PbTrigger, _Mapping]] = ...) -> None: ... + class XtriggersEntry(_message.Message): + __slots__ = ["key", "value"] + KEY_FIELD_NUMBER: _ClassVar[int] + VALUE_FIELD_NUMBER: _ClassVar[int] + key: str + value: PbTrigger + def __init__(self, key: _Optional[str] = ..., value: _Optional[_Union[PbTrigger, _Mapping]] = ...) -> None: ... + STAMP_FIELD_NUMBER: _ClassVar[int] + ID_FIELD_NUMBER: _ClassVar[int] + TASK_FIELD_NUMBER: _ClassVar[int] + STATE_FIELD_NUMBER: _ClassVar[int] + CYCLE_POINT_FIELD_NUMBER: _ClassVar[int] + DEPTH_FIELD_NUMBER: _ClassVar[int] + JOB_SUBMITS_FIELD_NUMBER: _ClassVar[int] + OUTPUTS_FIELD_NUMBER: _ClassVar[int] + NAMESPACE_FIELD_NUMBER: _ClassVar[int] + PREREQUISITES_FIELD_NUMBER: _ClassVar[int] + JOBS_FIELD_NUMBER: _ClassVar[int] + FIRST_PARENT_FIELD_NUMBER: _ClassVar[int] + NAME_FIELD_NUMBER: _ClassVar[int] + IS_HELD_FIELD_NUMBER: _ClassVar[int] + EDGES_FIELD_NUMBER: _ClassVar[int] + ANCESTORS_FIELD_NUMBER: _ClassVar[int] + FLOW_NUMS_FIELD_NUMBER: _ClassVar[int] + EXTERNAL_TRIGGERS_FIELD_NUMBER: _ClassVar[int] + XTRIGGERS_FIELD_NUMBER: _ClassVar[int] + IS_QUEUED_FIELD_NUMBER: _ClassVar[int] + IS_RUNAHEAD_FIELD_NUMBER: _ClassVar[int] + FLOW_WAIT_FIELD_NUMBER: _ClassVar[int] + RUNTIME_FIELD_NUMBER: _ClassVar[int] + GRAPH_DEPTH_FIELD_NUMBER: _ClassVar[int] + stamp: str + id: str + task: str + state: str + cycle_point: str + depth: int + job_submits: int + outputs: _containers.MessageMap[str, PbOutput] + namespace: _containers.RepeatedScalarFieldContainer[str] + prerequisites: _containers.RepeatedCompositeFieldContainer[PbPrerequisite] + jobs: _containers.RepeatedScalarFieldContainer[str] + first_parent: str + name: str + is_held: bool + edges: _containers.RepeatedScalarFieldContainer[str] + ancestors: _containers.RepeatedScalarFieldContainer[str] + flow_nums: str + external_triggers: _containers.MessageMap[str, PbTrigger] + xtriggers: _containers.MessageMap[str, PbTrigger] + is_queued: bool + is_runahead: bool + flow_wait: bool + runtime: PbRuntime + graph_depth: int + def __init__(self, stamp: _Optional[str] = ..., id: _Optional[str] = ..., task: _Optional[str] = ..., state: _Optional[str] = ..., cycle_point: _Optional[str] = ..., depth: _Optional[int] = ..., job_submits: _Optional[int] = ..., outputs: _Optional[_Mapping[str, PbOutput]] = ..., namespace: _Optional[_Iterable[str]] = ..., prerequisites: _Optional[_Iterable[_Union[PbPrerequisite, _Mapping]]] = ..., jobs: _Optional[_Iterable[str]] = ..., first_parent: _Optional[str] = ..., name: _Optional[str] = ..., is_held: bool = ..., edges: _Optional[_Iterable[str]] = ..., ancestors: _Optional[_Iterable[str]] = ..., flow_nums: _Optional[str] = ..., external_triggers: _Optional[_Mapping[str, PbTrigger]] = ..., xtriggers: _Optional[_Mapping[str, PbTrigger]] = ..., is_queued: bool = ..., is_runahead: bool = ..., flow_wait: bool = ..., runtime: _Optional[_Union[PbRuntime, _Mapping]] = ..., graph_depth: _Optional[int] = ...) -> None: ... + +class PbFamily(_message.Message): + __slots__ = ["stamp", "id", "name", "meta", "depth", "proxies", "parents", "child_tasks", "child_families", "first_parent", "runtime"] + STAMP_FIELD_NUMBER: _ClassVar[int] + ID_FIELD_NUMBER: _ClassVar[int] + NAME_FIELD_NUMBER: _ClassVar[int] + META_FIELD_NUMBER: _ClassVar[int] + DEPTH_FIELD_NUMBER: _ClassVar[int] + PROXIES_FIELD_NUMBER: _ClassVar[int] + PARENTS_FIELD_NUMBER: _ClassVar[int] + CHILD_TASKS_FIELD_NUMBER: _ClassVar[int] + CHILD_FAMILIES_FIELD_NUMBER: _ClassVar[int] + FIRST_PARENT_FIELD_NUMBER: _ClassVar[int] + RUNTIME_FIELD_NUMBER: _ClassVar[int] + stamp: str + id: str + name: str + meta: PbMeta + depth: int + proxies: _containers.RepeatedScalarFieldContainer[str] + parents: _containers.RepeatedScalarFieldContainer[str] + child_tasks: _containers.RepeatedScalarFieldContainer[str] + child_families: _containers.RepeatedScalarFieldContainer[str] + first_parent: str + runtime: PbRuntime + def __init__(self, stamp: _Optional[str] = ..., id: _Optional[str] = ..., name: _Optional[str] = ..., meta: _Optional[_Union[PbMeta, _Mapping]] = ..., depth: _Optional[int] = ..., proxies: _Optional[_Iterable[str]] = ..., parents: _Optional[_Iterable[str]] = ..., child_tasks: _Optional[_Iterable[str]] = ..., child_families: _Optional[_Iterable[str]] = ..., first_parent: _Optional[str] = ..., runtime: _Optional[_Union[PbRuntime, _Mapping]] = ...) -> None: ... + +class PbFamilyProxy(_message.Message): + __slots__ = ["stamp", "id", "cycle_point", "name", "family", "state", "depth", "first_parent", "child_tasks", "child_families", "is_held", "ancestors", "states", "state_totals", "is_held_total", "is_queued", "is_queued_total", "is_runahead", "is_runahead_total", "runtime", "graph_depth"] + class StateTotalsEntry(_message.Message): + __slots__ = ["key", "value"] + KEY_FIELD_NUMBER: _ClassVar[int] + VALUE_FIELD_NUMBER: _ClassVar[int] + key: str + value: int + def __init__(self, key: _Optional[str] = ..., value: _Optional[int] = ...) -> None: ... + STAMP_FIELD_NUMBER: _ClassVar[int] + ID_FIELD_NUMBER: _ClassVar[int] + CYCLE_POINT_FIELD_NUMBER: _ClassVar[int] + NAME_FIELD_NUMBER: _ClassVar[int] + FAMILY_FIELD_NUMBER: _ClassVar[int] + STATE_FIELD_NUMBER: _ClassVar[int] + DEPTH_FIELD_NUMBER: _ClassVar[int] + FIRST_PARENT_FIELD_NUMBER: _ClassVar[int] + CHILD_TASKS_FIELD_NUMBER: _ClassVar[int] + CHILD_FAMILIES_FIELD_NUMBER: _ClassVar[int] + IS_HELD_FIELD_NUMBER: _ClassVar[int] + ANCESTORS_FIELD_NUMBER: _ClassVar[int] + STATES_FIELD_NUMBER: _ClassVar[int] + STATE_TOTALS_FIELD_NUMBER: _ClassVar[int] + IS_HELD_TOTAL_FIELD_NUMBER: _ClassVar[int] + IS_QUEUED_FIELD_NUMBER: _ClassVar[int] + IS_QUEUED_TOTAL_FIELD_NUMBER: _ClassVar[int] + IS_RUNAHEAD_FIELD_NUMBER: _ClassVar[int] + IS_RUNAHEAD_TOTAL_FIELD_NUMBER: _ClassVar[int] + RUNTIME_FIELD_NUMBER: _ClassVar[int] + GRAPH_DEPTH_FIELD_NUMBER: _ClassVar[int] + stamp: str + id: str + cycle_point: str + name: str + family: str + state: str + depth: int + first_parent: str + child_tasks: _containers.RepeatedScalarFieldContainer[str] + child_families: _containers.RepeatedScalarFieldContainer[str] + is_held: bool + ancestors: _containers.RepeatedScalarFieldContainer[str] + states: _containers.RepeatedScalarFieldContainer[str] + state_totals: _containers.ScalarMap[str, int] + is_held_total: int + is_queued: bool + is_queued_total: int + is_runahead: bool + is_runahead_total: int + runtime: PbRuntime + graph_depth: int + def __init__(self, stamp: _Optional[str] = ..., id: _Optional[str] = ..., cycle_point: _Optional[str] = ..., name: _Optional[str] = ..., family: _Optional[str] = ..., state: _Optional[str] = ..., depth: _Optional[int] = ..., first_parent: _Optional[str] = ..., child_tasks: _Optional[_Iterable[str]] = ..., child_families: _Optional[_Iterable[str]] = ..., is_held: bool = ..., ancestors: _Optional[_Iterable[str]] = ..., states: _Optional[_Iterable[str]] = ..., state_totals: _Optional[_Mapping[str, int]] = ..., is_held_total: _Optional[int] = ..., is_queued: bool = ..., is_queued_total: _Optional[int] = ..., is_runahead: bool = ..., is_runahead_total: _Optional[int] = ..., runtime: _Optional[_Union[PbRuntime, _Mapping]] = ..., graph_depth: _Optional[int] = ...) -> None: ... + +class PbEdge(_message.Message): + __slots__ = ["stamp", "id", "source", "target", "suicide", "cond"] + STAMP_FIELD_NUMBER: _ClassVar[int] + ID_FIELD_NUMBER: _ClassVar[int] + SOURCE_FIELD_NUMBER: _ClassVar[int] + TARGET_FIELD_NUMBER: _ClassVar[int] + SUICIDE_FIELD_NUMBER: _ClassVar[int] + COND_FIELD_NUMBER: _ClassVar[int] + stamp: str + id: str + source: str + target: str + suicide: bool + cond: bool + def __init__(self, stamp: _Optional[str] = ..., id: _Optional[str] = ..., source: _Optional[str] = ..., target: _Optional[str] = ..., suicide: bool = ..., cond: bool = ...) -> None: ... + +class PbEdges(_message.Message): + __slots__ = ["id", "edges", "workflow_polling_tasks", "leaves", "feet"] + ID_FIELD_NUMBER: _ClassVar[int] + EDGES_FIELD_NUMBER: _ClassVar[int] + WORKFLOW_POLLING_TASKS_FIELD_NUMBER: _ClassVar[int] + LEAVES_FIELD_NUMBER: _ClassVar[int] + FEET_FIELD_NUMBER: _ClassVar[int] + id: str + edges: _containers.RepeatedScalarFieldContainer[str] + workflow_polling_tasks: _containers.RepeatedCompositeFieldContainer[PbPollTask] + leaves: _containers.RepeatedScalarFieldContainer[str] + feet: _containers.RepeatedScalarFieldContainer[str] + def __init__(self, id: _Optional[str] = ..., edges: _Optional[_Iterable[str]] = ..., workflow_polling_tasks: _Optional[_Iterable[_Union[PbPollTask, _Mapping]]] = ..., leaves: _Optional[_Iterable[str]] = ..., feet: _Optional[_Iterable[str]] = ...) -> None: ... + +class PbEntireWorkflow(_message.Message): + __slots__ = ["workflow", "tasks", "task_proxies", "jobs", "families", "family_proxies", "edges"] + WORKFLOW_FIELD_NUMBER: _ClassVar[int] + TASKS_FIELD_NUMBER: _ClassVar[int] + TASK_PROXIES_FIELD_NUMBER: _ClassVar[int] + JOBS_FIELD_NUMBER: _ClassVar[int] + FAMILIES_FIELD_NUMBER: _ClassVar[int] + FAMILY_PROXIES_FIELD_NUMBER: _ClassVar[int] + EDGES_FIELD_NUMBER: _ClassVar[int] + workflow: PbWorkflow + tasks: _containers.RepeatedCompositeFieldContainer[PbTask] + task_proxies: _containers.RepeatedCompositeFieldContainer[PbTaskProxy] + jobs: _containers.RepeatedCompositeFieldContainer[PbJob] + families: _containers.RepeatedCompositeFieldContainer[PbFamily] + family_proxies: _containers.RepeatedCompositeFieldContainer[PbFamilyProxy] + edges: _containers.RepeatedCompositeFieldContainer[PbEdge] + def __init__(self, workflow: _Optional[_Union[PbWorkflow, _Mapping]] = ..., tasks: _Optional[_Iterable[_Union[PbTask, _Mapping]]] = ..., task_proxies: _Optional[_Iterable[_Union[PbTaskProxy, _Mapping]]] = ..., jobs: _Optional[_Iterable[_Union[PbJob, _Mapping]]] = ..., families: _Optional[_Iterable[_Union[PbFamily, _Mapping]]] = ..., family_proxies: _Optional[_Iterable[_Union[PbFamilyProxy, _Mapping]]] = ..., edges: _Optional[_Iterable[_Union[PbEdge, _Mapping]]] = ...) -> None: ... + +class EDeltas(_message.Message): + __slots__ = ["time", "checksum", "added", "updated", "pruned", "reloaded"] + TIME_FIELD_NUMBER: _ClassVar[int] + CHECKSUM_FIELD_NUMBER: _ClassVar[int] + ADDED_FIELD_NUMBER: _ClassVar[int] + UPDATED_FIELD_NUMBER: _ClassVar[int] + PRUNED_FIELD_NUMBER: _ClassVar[int] + RELOADED_FIELD_NUMBER: _ClassVar[int] + time: float + checksum: int + added: _containers.RepeatedCompositeFieldContainer[PbEdge] + updated: _containers.RepeatedCompositeFieldContainer[PbEdge] + pruned: _containers.RepeatedScalarFieldContainer[str] + reloaded: bool + def __init__(self, time: _Optional[float] = ..., checksum: _Optional[int] = ..., added: _Optional[_Iterable[_Union[PbEdge, _Mapping]]] = ..., updated: _Optional[_Iterable[_Union[PbEdge, _Mapping]]] = ..., pruned: _Optional[_Iterable[str]] = ..., reloaded: bool = ...) -> None: ... + +class FDeltas(_message.Message): + __slots__ = ["time", "checksum", "added", "updated", "pruned", "reloaded"] + TIME_FIELD_NUMBER: _ClassVar[int] + CHECKSUM_FIELD_NUMBER: _ClassVar[int] + ADDED_FIELD_NUMBER: _ClassVar[int] + UPDATED_FIELD_NUMBER: _ClassVar[int] + PRUNED_FIELD_NUMBER: _ClassVar[int] + RELOADED_FIELD_NUMBER: _ClassVar[int] + time: float + checksum: int + added: _containers.RepeatedCompositeFieldContainer[PbFamily] + updated: _containers.RepeatedCompositeFieldContainer[PbFamily] + pruned: _containers.RepeatedScalarFieldContainer[str] + reloaded: bool + def __init__(self, time: _Optional[float] = ..., checksum: _Optional[int] = ..., added: _Optional[_Iterable[_Union[PbFamily, _Mapping]]] = ..., updated: _Optional[_Iterable[_Union[PbFamily, _Mapping]]] = ..., pruned: _Optional[_Iterable[str]] = ..., reloaded: bool = ...) -> None: ... + +class FPDeltas(_message.Message): + __slots__ = ["time", "checksum", "added", "updated", "pruned", "reloaded"] + TIME_FIELD_NUMBER: _ClassVar[int] + CHECKSUM_FIELD_NUMBER: _ClassVar[int] + ADDED_FIELD_NUMBER: _ClassVar[int] + UPDATED_FIELD_NUMBER: _ClassVar[int] + PRUNED_FIELD_NUMBER: _ClassVar[int] + RELOADED_FIELD_NUMBER: _ClassVar[int] + time: float + checksum: int + added: _containers.RepeatedCompositeFieldContainer[PbFamilyProxy] + updated: _containers.RepeatedCompositeFieldContainer[PbFamilyProxy] + pruned: _containers.RepeatedScalarFieldContainer[str] + reloaded: bool + def __init__(self, time: _Optional[float] = ..., checksum: _Optional[int] = ..., added: _Optional[_Iterable[_Union[PbFamilyProxy, _Mapping]]] = ..., updated: _Optional[_Iterable[_Union[PbFamilyProxy, _Mapping]]] = ..., pruned: _Optional[_Iterable[str]] = ..., reloaded: bool = ...) -> None: ... + +class JDeltas(_message.Message): + __slots__ = ["time", "checksum", "added", "updated", "pruned", "reloaded"] + TIME_FIELD_NUMBER: _ClassVar[int] + CHECKSUM_FIELD_NUMBER: _ClassVar[int] + ADDED_FIELD_NUMBER: _ClassVar[int] + UPDATED_FIELD_NUMBER: _ClassVar[int] + PRUNED_FIELD_NUMBER: _ClassVar[int] + RELOADED_FIELD_NUMBER: _ClassVar[int] + time: float + checksum: int + added: _containers.RepeatedCompositeFieldContainer[PbJob] + updated: _containers.RepeatedCompositeFieldContainer[PbJob] + pruned: _containers.RepeatedScalarFieldContainer[str] + reloaded: bool + def __init__(self, time: _Optional[float] = ..., checksum: _Optional[int] = ..., added: _Optional[_Iterable[_Union[PbJob, _Mapping]]] = ..., updated: _Optional[_Iterable[_Union[PbJob, _Mapping]]] = ..., pruned: _Optional[_Iterable[str]] = ..., reloaded: bool = ...) -> None: ... + +class TDeltas(_message.Message): + __slots__ = ["time", "checksum", "added", "updated", "pruned", "reloaded"] + TIME_FIELD_NUMBER: _ClassVar[int] + CHECKSUM_FIELD_NUMBER: _ClassVar[int] + ADDED_FIELD_NUMBER: _ClassVar[int] + UPDATED_FIELD_NUMBER: _ClassVar[int] + PRUNED_FIELD_NUMBER: _ClassVar[int] + RELOADED_FIELD_NUMBER: _ClassVar[int] + time: float + checksum: int + added: _containers.RepeatedCompositeFieldContainer[PbTask] + updated: _containers.RepeatedCompositeFieldContainer[PbTask] + pruned: _containers.RepeatedScalarFieldContainer[str] + reloaded: bool + def __init__(self, time: _Optional[float] = ..., checksum: _Optional[int] = ..., added: _Optional[_Iterable[_Union[PbTask, _Mapping]]] = ..., updated: _Optional[_Iterable[_Union[PbTask, _Mapping]]] = ..., pruned: _Optional[_Iterable[str]] = ..., reloaded: bool = ...) -> None: ... + +class TPDeltas(_message.Message): + __slots__ = ["time", "checksum", "added", "updated", "pruned", "reloaded"] + TIME_FIELD_NUMBER: _ClassVar[int] + CHECKSUM_FIELD_NUMBER: _ClassVar[int] + ADDED_FIELD_NUMBER: _ClassVar[int] + UPDATED_FIELD_NUMBER: _ClassVar[int] + PRUNED_FIELD_NUMBER: _ClassVar[int] + RELOADED_FIELD_NUMBER: _ClassVar[int] + time: float + checksum: int + added: _containers.RepeatedCompositeFieldContainer[PbTaskProxy] + updated: _containers.RepeatedCompositeFieldContainer[PbTaskProxy] + pruned: _containers.RepeatedScalarFieldContainer[str] + reloaded: bool + def __init__(self, time: _Optional[float] = ..., checksum: _Optional[int] = ..., added: _Optional[_Iterable[_Union[PbTaskProxy, _Mapping]]] = ..., updated: _Optional[_Iterable[_Union[PbTaskProxy, _Mapping]]] = ..., pruned: _Optional[_Iterable[str]] = ..., reloaded: bool = ...) -> None: ... + +class WDeltas(_message.Message): + __slots__ = ["time", "added", "updated", "reloaded", "pruned"] + TIME_FIELD_NUMBER: _ClassVar[int] + ADDED_FIELD_NUMBER: _ClassVar[int] + UPDATED_FIELD_NUMBER: _ClassVar[int] + RELOADED_FIELD_NUMBER: _ClassVar[int] + PRUNED_FIELD_NUMBER: _ClassVar[int] + time: float + added: PbWorkflow + updated: PbWorkflow + reloaded: bool + pruned: str + def __init__(self, time: _Optional[float] = ..., added: _Optional[_Union[PbWorkflow, _Mapping]] = ..., updated: _Optional[_Union[PbWorkflow, _Mapping]] = ..., reloaded: bool = ..., pruned: _Optional[str] = ...) -> None: ... + +class AllDeltas(_message.Message): + __slots__ = ["families", "family_proxies", "jobs", "tasks", "task_proxies", "edges", "workflow"] + FAMILIES_FIELD_NUMBER: _ClassVar[int] + FAMILY_PROXIES_FIELD_NUMBER: _ClassVar[int] + JOBS_FIELD_NUMBER: _ClassVar[int] + TASKS_FIELD_NUMBER: _ClassVar[int] + TASK_PROXIES_FIELD_NUMBER: _ClassVar[int] + EDGES_FIELD_NUMBER: _ClassVar[int] + WORKFLOW_FIELD_NUMBER: _ClassVar[int] + families: FDeltas + family_proxies: FPDeltas + jobs: JDeltas + tasks: TDeltas + task_proxies: TPDeltas + edges: EDeltas + workflow: WDeltas + def __init__(self, families: _Optional[_Union[FDeltas, _Mapping]] = ..., family_proxies: _Optional[_Union[FPDeltas, _Mapping]] = ..., jobs: _Optional[_Union[JDeltas, _Mapping]] = ..., tasks: _Optional[_Union[TDeltas, _Mapping]] = ..., task_proxies: _Optional[_Union[TPDeltas, _Mapping]] = ..., edges: _Optional[_Union[EDeltas, _Mapping]] = ..., workflow: _Optional[_Union[WDeltas, _Mapping]] = ...) -> None: ... diff --git a/cylc/flow/data_store_mgr.py b/cylc/flow/data_store_mgr.py index ff4d7f87ba1..9b3b39509a2 100644 --- a/cylc/flow/data_store_mgr.py +++ b/cylc/flow/data_store_mgr.py @@ -73,7 +73,7 @@ from cylc.flow import __version__ as CYLC_VERSION, LOG from cylc.flow.cycling.loader import get_point -from cylc.flow.data_messages_pb2 import ( # type: ignore +from cylc.flow.data_messages_pb2 import ( PbEdge, PbEntireWorkflow, PbFamily, PbFamilyProxy, PbJob, PbTask, PbTaskProxy, PbWorkflow, PbRuntime, AllDeltas, EDeltas, FDeltas, FPDeltas, JDeltas, TDeltas, TPDeltas, WDeltas) @@ -112,7 +112,7 @@ if TYPE_CHECKING: from cylc.flow.cycling import PointBase - + from cylc.flow.flow_mgr import FlowNums EDGES = 'edges' FAMILIES = 'families' @@ -713,10 +713,10 @@ def generate_definition_elements(self): def increment_graph_window( self, source_tokens: Tokens, - point, - flow_nums, - is_manual_submit=False, - itask=None + point: 'PointBase', + flow_nums: 'FlowNums', + is_manual_submit: bool = False, + itask: Optional['TaskProxy'] = None ) -> None: """Generate graph window about active task proxy to n-edge-distance. @@ -731,16 +731,12 @@ def increment_graph_window( assessed for pruning eligibility. Args: - source_tokens (cylc.flow.id.Tokens) - point (PointBase) - flow_nums (set) - is_manual_submit (bool) - itask (cylc.flow.task_proxy.TaskProxy): + source_tokens + point + flow_nums + is_manual_submit + itask: Active/Other task proxy, passed in with pool invocation. - - Returns: - None - """ # common refrences @@ -1149,28 +1145,23 @@ def add_pool_node(self, name, point): def generate_ghost_task( self, tokens: Tokens, - point, - flow_nums, - is_parent=False, - itask=None, - n_depth=0, - ): + point: 'PointBase', + flow_nums: 'FlowNums', + is_parent: bool = False, + itask: Optional['TaskProxy'] = None, + n_depth: int = 0, + ) -> None: """Create task-point element populated with static data. Args: - source_tokens (cylc.flow.id.Tokens) - point (PointBase) - flow_nums (set) - is_parent (bool): + source_tokens + point + flow_nums + is_parent: Used to determine whether to load DB state. - itask (cylc.flow.task_proxy.TaskProxy): + itask: Update task-node from corresponding task proxy object. - n_depth (int): n-window graph edge distance. - - Returns: - - None - + n_depth: n-window graph edge distance. """ tp_id = tokens.id if ( @@ -1486,7 +1477,11 @@ def apply_task_proxy_db_history(self): self.db_load_task_proxies.clear() - def _process_internal_task_proxy(self, itask, tproxy): + def _process_internal_task_proxy( + self, + itask: 'TaskProxy', + tproxy: PbTaskProxy, + ): """Extract information from internal task proxy object.""" update_time = time() @@ -1569,6 +1564,7 @@ def insert_job(self, name, cycle_point, status, job_conf): cycle=str(cycle_point), task=name, ) + tproxy: Optional[PbTaskProxy] tp_id, tproxy = self.store_node_fetcher(tp_tokens) if not tproxy: return @@ -1642,6 +1638,7 @@ def insert_db_job(self, row_idx, row): cycle=point_string, task=name, ) + tproxy: Optional[PbTaskProxy] tp_id, tproxy = self.store_node_fetcher(tp_tokens) if not tproxy: return @@ -1761,9 +1758,8 @@ def update_workflow_states(self): self.apply_delta_checksum() self.publish_deltas = self.get_publish_deltas() - def window_resize_rewalk(self): + def window_resize_rewalk(self) -> None: """Re-create data-store n-window on resize.""" - tokens: Tokens # Gather pre-resize window nodes if not self.all_n_window_nodes: self.all_n_window_nodes = set().union(*( @@ -1777,7 +1773,8 @@ def window_resize_rewalk(self): self.n_window_node_walks.clear() for tp_id in self.all_task_pool: tokens = Tokens(tp_id) - tp_id, tproxy = self.store_node_fetcher(tokens) + tproxy: PbTaskProxy + _, tproxy = self.store_node_fetcher(tokens) self.increment_graph_window( tokens, get_point(tokens['cycle']), @@ -2279,6 +2276,7 @@ def delta_task_state(self, itask): objects from the workflow task pool. """ + tproxy: Optional[PbTaskProxy] tp_id, tproxy = self.store_node_fetcher(itask.tokens) if not tproxy: return @@ -2331,7 +2329,7 @@ def delta_task_held( task=name, cycle=str(cycle), ) - + tproxy: Optional[PbTaskProxy] tp_id, tproxy = self.store_node_fetcher(tokens) if not tproxy: return @@ -2351,6 +2349,7 @@ def delta_task_queued(self, itask: TaskProxy) -> None: objects from the workflow task pool. """ + tproxy: Optional[PbTaskProxy] tp_id, tproxy = self.store_node_fetcher(itask.tokens) if not tproxy: return @@ -2370,6 +2369,7 @@ def delta_task_runahead(self, itask: TaskProxy) -> None: objects from the workflow task pool. """ + tproxy: Optional[PbTaskProxy] tp_id, tproxy = self.store_node_fetcher(itask.tokens) if not tproxy: return @@ -2393,6 +2393,7 @@ def delta_task_output( objects from the workflow task pool. """ + tproxy: Optional[PbTaskProxy] tp_id, tproxy = self.store_node_fetcher(itask.tokens) if not tproxy: return @@ -2419,6 +2420,7 @@ def delta_task_outputs(self, itask: TaskProxy) -> None: objects from the workflow task pool. """ + tproxy: Optional[PbTaskProxy] tp_id, tproxy = self.store_node_fetcher(itask.tokens) if not tproxy: return @@ -2444,6 +2446,7 @@ def delta_task_prerequisite(self, itask: TaskProxy) -> None: objects from the workflow task pool. """ + tproxy: Optional[PbTaskProxy] tp_id, tproxy = self.store_node_fetcher(itask.tokens) if not tproxy: return @@ -2472,13 +2475,14 @@ def delta_task_ext_trigger( """Create delta for change in task proxy external_trigger. Args: - itask (cylc.flow.task_proxy.TaskProxy): + itask: Update task-node from corresponding task proxy objects from the workflow task pool. - trig (str): Trigger ID. - message (str): Trigger message. + trig: Trigger ID. + message: Trigger message. """ + tproxy: Optional[PbTaskProxy] tp_id, tproxy = self.store_node_fetcher(itask.tokens) if not tproxy: return diff --git a/cylc/flow/network/server.py b/cylc/flow/network/server.py index 5c070472025..2c170e61198 100644 --- a/cylc/flow/network/server.py +++ b/cylc/flow/network/server.py @@ -36,7 +36,7 @@ from cylc.flow.network.resolvers import Resolvers from cylc.flow.network.schema import schema from cylc.flow.data_store_mgr import DELTAS_MAP -from cylc.flow.data_messages_pb2 import PbEntireWorkflow # type: ignore +from cylc.flow.data_messages_pb2 import PbEntireWorkflow if TYPE_CHECKING: from cylc.flow.scheduler import Scheduler @@ -44,7 +44,7 @@ # maps server methods to the protobuf message (for client/UIS import) -PB_METHOD_MAP = { +PB_METHOD_MAP: Dict[str, Any] = { 'pb_entire_workflow': PbEntireWorkflow, 'pb_data_elements': DELTAS_MAP } diff --git a/cylc/flow/prerequisite.py b/cylc/flow/prerequisite.py index bfe5a50fc46..b388934e2de 100644 --- a/cylc/flow/prerequisite.py +++ b/cylc/flow/prerequisite.py @@ -22,7 +22,7 @@ from cylc.flow.cycling.loader import get_point from cylc.flow.exceptions import TriggerExpressionError -from cylc.flow.data_messages_pb2 import ( # type: ignore +from cylc.flow.data_messages_pb2 import ( PbPrerequisite, PbCondition, ) diff --git a/cylc/flow/scripts/client.py b/cylc/flow/scripts/client.py index 52bf6abd608..14fc98b51c3 100755 --- a/cylc/flow/scripts/client.py +++ b/cylc/flow/scripts/client.py @@ -27,7 +27,7 @@ from google.protobuf.json_format import MessageToDict import json import sys -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, cast from cylc.flow.id_cli import parse_id from cylc.flow.option_parsers import ( @@ -40,6 +40,7 @@ if TYPE_CHECKING: from optparse import Values + from google.protobuf.message import Message INTERNAL = True @@ -76,11 +77,12 @@ def main(_, options: 'Values', workflow_id: str, func: str) -> None: sys.stdin.close() res = pclient(func, kwargs) if func in PB_METHOD_MAP: + pb_msg: Message if 'element_type' in kwargs: pb_msg = PB_METHOD_MAP[func][kwargs['element_type']]() else: pb_msg = PB_METHOD_MAP[func]() - pb_msg.ParseFromString(res) + pb_msg.ParseFromString(cast('bytes', res)) res_msg: object = MessageToDict(pb_msg) else: res_msg = res diff --git a/mypy.ini b/mypy.ini index fe03c3bbe12..4c35cf1d53e 100644 --- a/mypy.ini +++ b/mypy.ini @@ -19,7 +19,3 @@ exclude= cylc/flow/etc/tutorial/.* # Suppress the following messages: # By default the bodies of untyped functions are not checked, consider using --check-untyped-defs disable_error_code = annotation-unchecked - -# For some reason, couldn't exclude this with the exclude directive above -[mypy-cylc.flow.data_messages_pb2] -ignore_errors = True From f257a39eab2c3fc10e52b7a5c248b46dcc3c46a6 Mon Sep 17 00:00:00 2001 From: Hilary James Oliver Date: Fri, 24 May 2024 10:05:36 +0000 Subject: [PATCH 050/196] Spelling fix. --- cylc/flow/cfgspec/workflow.py | 2 +- tests/unit/scripts/test_completion_server.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cylc/flow/cfgspec/workflow.py b/cylc/flow/cfgspec/workflow.py index b4b67cb4587..b913507ddee 100644 --- a/cylc/flow/cfgspec/workflow.py +++ b/cylc/flow/cfgspec/workflow.py @@ -1095,7 +1095,7 @@ def get_script_common_text(this: str, example: Optional[str] = None): foo:x? => x - In these cases succeess is presumed to be required unless + In these cases success is presumed to be required unless explicitly stated otherwise, either in the graph e.g: .. code-block:: cylc-graph diff --git a/tests/unit/scripts/test_completion_server.py b/tests/unit/scripts/test_completion_server.py index 975ac946da6..7130353ef82 100644 --- a/tests/unit/scripts/test_completion_server.py +++ b/tests/unit/scripts/test_completion_server.py @@ -721,7 +721,7 @@ def _get_current_completion_script_version(_script, lang): async def test_prereqs_and_outputs(): """Test the error cases for listing task prereqs/outputs. - The succeess cases are tested in an integration test (requires a running + The success cases are tested in an integration test (requires a running scheduler). """ # if no tokens are provided, no prereqs or outputs are returned From 450728ad966dcc83dcfecb2d857c51567aa22d11 Mon Sep 17 00:00:00 2001 From: Hilary James Oliver Date: Fri, 24 May 2024 10:10:36 +0000 Subject: [PATCH 051/196] Another spelling fix. --- cylc/flow/cfgspec/workflow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cylc/flow/cfgspec/workflow.py b/cylc/flow/cfgspec/workflow.py index b913507ddee..1532c6ad5ed 100644 --- a/cylc/flow/cfgspec/workflow.py +++ b/cylc/flow/cfgspec/workflow.py @@ -1130,7 +1130,7 @@ def get_script_common_text(this: str, example: Optional[str] = None): # "a" may either succeed or fail completion = succeeded or failed - Which could be fixed by ammending the graph like so: + Which could be fixed by amending the graph like so: .. code-block:: cylc-graph From da330fc1927a82738431df000e985b7b52f06004 Mon Sep 17 00:00:00 2001 From: Ronnie Dutta <61982285+MetRonnie@users.noreply.github.com> Date: Wed, 5 Jun 2024 16:52:26 +0100 Subject: [PATCH 052/196] Fix tests --- tests/integration/test_data_store_mgr.py | 5 +++-- tests/integration/test_graphql.py | 2 +- tests/integration/test_queues.py | 8 +++++--- tests/integration/test_task_pool.py | 10 ++++++---- 4 files changed, 15 insertions(+), 10 deletions(-) diff --git a/tests/integration/test_data_store_mgr.py b/tests/integration/test_data_store_mgr.py index 65105d4b8a3..4d808bacc0a 100644 --- a/tests/integration/test_data_store_mgr.py +++ b/tests/integration/test_data_store_mgr.py @@ -194,8 +194,9 @@ async def test_delta_task_state(harness): async def test_delta_task_held(harness): """Test update_data_structure. This method will generate and apply adeltas/updates given.""" + schd: Scheduler schd, data = harness - schd.pool.hold_tasks('*') + schd.pool.hold_tasks(['*']) await schd.update_data_structure() assert True in {t.is_held for t in data[TASK_PROXIES].values()} for itask in schd.pool.get_tasks(): @@ -270,7 +271,7 @@ async def test_update_data_structure(harness): schd, data = harness w_id = schd.data_store_mgr.workflow_id schd.data_store_mgr.data[w_id] = data - schd.pool.hold_tasks('*') + schd.pool.hold_tasks(['*']) await schd.update_data_structure() assert TASK_STATUS_FAILED not in set(collect_states(data, TASK_PROXIES)) assert TASK_STATUS_FAILED not in set(collect_states(data, FAMILY_PROXIES)) diff --git a/tests/integration/test_graphql.py b/tests/integration/test_graphql.py index b0a51c0b6b8..9b934f3fe40 100644 --- a/tests/integration/test_graphql.py +++ b/tests/integration/test_graphql.py @@ -94,7 +94,7 @@ async def harness(mod_flow, mod_scheduler, mod_run): schd: 'Scheduler' = mod_scheduler(id_) async with mod_run(schd): client = WorkflowRuntimeClient(id_) - schd.pool.hold_tasks('*') + schd.pool.hold_tasks(['*']) schd.resume_workflow() # Think this is needed to save the data state at first start (?) # Fails without it.. and a test needs to overwrite schd data with this. diff --git a/tests/integration/test_queues.py b/tests/integration/test_queues.py index dfb4ef80fbf..bf8def4e354 100644 --- a/tests/integration/test_queues.py +++ b/tests/integration/test_queues.py @@ -1,5 +1,7 @@ import pytest +from cylc.flow.scheduler import Scheduler + @pytest.fixture def param_workflow(flow, scheduler): @@ -86,7 +88,7 @@ async def test_queue_held_tasks( https://github.com/cylc/cylc-flow/issues/4628 """ - schd = param_workflow(paused_start=True, queue_limit=1) + schd: Scheduler = param_workflow(paused_start=True, queue_limit=1) async with start(schd): # capture task submissions (prevents real submissions) @@ -97,7 +99,7 @@ async def test_queue_held_tasks( # hold all tasks and resume the workflow # (nothing should have run yet because the workflow started paused) - schd.command_hold('*/*') + schd.command_hold(['*/*']) schd.resume_workflow() # release queued tasks @@ -106,7 +108,7 @@ async def test_queue_held_tasks( assert len(submitted_tasks) == 0 # un-hold tasks - schd.command_release('*/*') + schd.command_release(['*/*']) # release queued tasks # (tasks should now be released from the queues) diff --git a/tests/integration/test_task_pool.py b/tests/integration/test_task_pool.py index 4e72ed0bac4..5bfff271535 100644 --- a/tests/integration/test_task_pool.py +++ b/tests/integration/test_task_pool.py @@ -497,17 +497,19 @@ async def test_hold_point( (TASK_STATUS_SUCCEEDED, True), ] ) -async def test_trigger_states(status, should_trigger, one, start): +async def test_trigger_states( + status: str, should_trigger: bool, one: 'Scheduler', start: Callable +): """It should only trigger tasks in compatible states.""" async with start(one): - task = one.pool.filter_task_proxies('1/a')[0][0] + task = one.pool.filter_task_proxies(['1/one'])[0][0] # reset task a to the provided state task.state.reset(status) # try triggering the task - one.pool.force_trigger_tasks('1/a', [FLOW_ALL]) + one.pool.force_trigger_tasks(['1/one'], [FLOW_ALL]) # check whether the task triggered assert task.is_manual_submit == should_trigger @@ -1219,7 +1221,7 @@ async def test_detect_incomplete_tasks( # the task should not have been removed assert itask in schd.pool.get_tasks() - + async def test_future_trigger_final_point( flow, scheduler, From 895ad2f06186ddcadb5400ccb9f07b4a008ff744 Mon Sep 17 00:00:00 2001 From: Hilary James Oliver Date: Wed, 10 Apr 2024 16:50:14 +1200 Subject: [PATCH 053/196] Implement scheduler command method arg validation. --- cylc/flow/command_validation.py | 257 +++++++++++++++++++++++++++++ cylc/flow/flow_mgr.py | 29 ---- cylc/flow/network/multi.py | 26 ++- cylc/flow/network/resolvers.py | 12 +- cylc/flow/scheduler.py | 2 + cylc/flow/scripts/set.py | 213 +++--------------------- cylc/flow/scripts/trigger.py | 8 +- cylc/flow/terminal.py | 34 +++- tests/unit/scripts/test_trigger.py | 124 -------------- 9 files changed, 350 insertions(+), 355 deletions(-) create mode 100644 cylc/flow/command_validation.py delete mode 100644 tests/unit/scripts/test_trigger.py diff --git a/cylc/flow/command_validation.py b/cylc/flow/command_validation.py new file mode 100644 index 00000000000..ab8d47b019e --- /dev/null +++ b/cylc/flow/command_validation.py @@ -0,0 +1,257 @@ +# THIS FILE IS PART OF THE CYLC WORKFLOW ENGINE. +# Copyright (C) NIWA & British Crown (Met Office) & Contributors. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Cylc command argument validation logic.""" + + +from typing import ( + Callable, + List, + Optional, +) + +from cylc.flow.exceptions import InputError +from cylc.flow.id import Tokens +from cylc.flow.task_outputs import TASK_OUTPUT_SUCCEEDED +from cylc.flow.flow_mgr import FLOW_ALL, FLOW_NEW, FLOW_NONE + + +ERR_OPT_FLOW_VAL = "Flow values must be an integer, or 'all', 'new', or 'none'" +ERR_OPT_FLOW_INT = "Multiple flow options must all be integer valued" +ERR_OPT_FLOW_WAIT = ( + f"--wait is not compatible with --flow={FLOW_NEW} or --flow={FLOW_NONE}" +) + + +def validate(func: Callable): + """Decorate scheduler commands with a callable .validate attribute. + + """ + # TODO: properly handle "Callable has no attribute validate"? + func.validate = globals()[ # type: ignore + func.__name__.replace("command", "validate") + ] + return func + + +def validate_flow_opts(flows: List[str], flow_wait: bool) -> None: + """Check validity of flow-related CLI options. + + Note the schema defaults flows to ["all"]. + + Examples: + Good: + >>> validate_flow_opts(["new"], False) + >>> validate_flow_opts(["1", "2"], False) + >>> validate_flow_opts(["1", "2"], True) + + Bad: + >>> validate_flow_opts(["none", "1"], False) + Traceback (most recent call last): + cylc.flow.exceptions.InputError: ... must all be integer valued + + >>> validate_flow_opts(["cheese", "2"], True) + Traceback (most recent call last): + cylc.flow.exceptions.InputError: ... or 'all', 'new', or 'none' + + >>> validate_flow_opts(["new"], True) + Traceback (most recent call last): + cylc.flow.exceptions.InputError: ... + + """ + for val in flows: + val = val.strip() + if val in [FLOW_NONE, FLOW_NEW, FLOW_ALL]: + if len(flows) != 1: + raise InputError(ERR_OPT_FLOW_INT) + else: + try: + int(val) + except ValueError: + raise InputError(ERR_OPT_FLOW_VAL.format(val)) + + if flow_wait and flows[0] in [FLOW_NEW, FLOW_NONE]: + raise InputError(ERR_OPT_FLOW_WAIT) + + +def validate_prereqs(prereqs: Optional[List[str]]): + """Validate a list of prerequisites, add implicit ":succeeded". + + Comma-separated lists should be split already, client-side. + + Examples: + # Set multiple at once: + >>> validate_prereqs(['1/foo:bar', '2/foo:baz']) + ['1/foo:bar', '2/foo:baz'] + + # --pre=all + >>> validate_prereqs(["all"]) + ['all'] + + # implicit ":succeeded" + >>> validate_prereqs(["1/foo"]) + ['1/foo:succeeded'] + + # Error: invalid format: + >>> validate_prereqs(["fish"]) + Traceback (most recent call last): + cylc.flow.exceptions.InputError: ... + + # Error: invalid format: + >>> validate_prereqs(["1/foo::bar"]) + Traceback (most recent call last): + cylc.flow.exceptions.InputError: ... + + # Error: "all" must be used alone: + >>> validate_prereqs(["all", "2/foo:baz"]) + Traceback (most recent call last): + cylc.flow.exceptions.InputError: ... + + """ + if prereqs is None: + return [] + + prereqs2 = [] + bad: List[str] = [] + for pre in prereqs: + p = validate_prereq(pre) + if p is not None: + prereqs2.append(p) + else: + bad.append(pre) + if bad: + raise InputError( + "Use prerequisite format /:output\n" + "\n ".join(bad) + ) + + if len(prereqs2) > 1: # noqa SIM102 (anticipates "cylc set --pre=cycle") + if "all" in prereqs: + raise InputError("--pre=all must be used alone") + + return prereqs2 + + +def validate_prereq(prereq: str) -> Optional[str]: + """Return prereq (with :succeeded) if valid, else None. + + Format: cycle/task[:output] + + Examples: + >>> validate_prereq('1/foo:succeeded') + '1/foo:succeeded' + + >>> validate_prereq('1/foo') + '1/foo:succeeded' + + >>> validate_prereq('all') + 'all' + + # Error: + >>> validate_prereq('fish') + + """ + try: + tokens = Tokens(prereq, relative=True) + except ValueError: + return None + if ( + tokens["cycle"] == prereq + and prereq != "all" + ): + # Error: --pre= other than "all" + return None + + if prereq != "all" and tokens["task_sel"] is None: + prereq += f":{TASK_OUTPUT_SUCCEEDED}" + + return prereq + + +def validate_outputs(outputs: Optional[List[str]]): + """Validate outputs. + + Comma-separated lists should be split already, client-side. + + Examples: + Good: + >>> validate_outputs(['a', 'b']) + ['a', 'b'] + + >>> validate_outputs(["required"]) # "required" is explicit default + [] + + Bad: + >>> validate_outputs(["required", "a"]) + Traceback (most recent call last): + cylc.flow.exceptions.InputError: --out=required must be used alone + + >>> validate_outputs(["waiting"]) + Traceback (most recent call last): + cylc.flow.exceptions.InputError: Tasks cannot be set to waiting... + + """ + # If "required" is explicit just ditch it (same as the default) + if not outputs or outputs == ["required"]: + return [] + + if "required" in outputs: + raise InputError("--out=required must be used alone") + + if "waiting" in outputs: + raise InputError( + "Tasks cannot be set to waiting. Use trigger to re-run tasks." + ) + + return outputs + + +def validate_consistency( + outputs: Optional[List[str]], + prereqs: Optional[List[str]] +) -> None: + """Check global option consistency + + Examples: + >>> validate_consistency(["a"], None) # OK + + >>> validate_consistency(None, ["1/a:failed"]) #OK + + >>> validate_consistency(["a"], ["1/a:failed"]) + Traceback (most recent call last): + cylc.flow.exceptions.InputError: ... + + """ + if outputs and prereqs: + raise InputError("Use --prerequisite or --output, not both.") + + +def validate_set( + tasks: List[str], + flow: List[str], + outputs: Optional[List[str]] = None, + prerequisites: Optional[List[str]] = None, + flow_wait: bool = False, + flow_descr: Optional[str] = None +) -> None: + """Validate args of the scheduler "command_set" method. + + Raise InputError if validation fails. + """ + validate_consistency(outputs, prerequisites) + outputs = validate_outputs(outputs) + prerequisites = validate_prereqs(prerequisites) + validate_flow_opts(flow, flow_wait) diff --git a/cylc/flow/flow_mgr.py b/cylc/flow/flow_mgr.py index 7bb293bfc85..1cd1c1e8c70 100644 --- a/cylc/flow/flow_mgr.py +++ b/cylc/flow/flow_mgr.py @@ -20,7 +20,6 @@ import datetime from cylc.flow import LOG -from cylc.flow.exceptions import InputError if TYPE_CHECKING: @@ -32,13 +31,6 @@ FLOW_NEW = "new" FLOW_NONE = "none" -# For flow-related CLI options: -ERR_OPT_FLOW_VAL = "Flow values must be an integer, or 'all', 'new', or 'none'" -ERR_OPT_FLOW_INT = "Multiple flow options must all be integer valued" -ERR_OPT_FLOW_WAIT = ( - f"--wait is not compatible with --flow={FLOW_NEW} or --flow={FLOW_NONE}" -) - def add_flow_opts(parser): parser.add_option( @@ -63,27 +55,6 @@ def add_flow_opts(parser): ) -def validate_flow_opts(options): - """Check validity of flow-related CLI options.""" - if options.flow is None: - # Default to all active flows - options.flow = [FLOW_ALL] - - for val in options.flow: - val = val.strip() - if val in [FLOW_NONE, FLOW_NEW, FLOW_ALL]: - if len(options.flow) != 1: - raise InputError(ERR_OPT_FLOW_INT) - else: - try: - int(val) - except ValueError: - raise InputError(ERR_OPT_FLOW_VAL.format(val)) - - if options.flow_wait and options.flow[0] in [FLOW_NEW, FLOW_NONE]: - raise InputError(ERR_OPT_FLOW_WAIT) - - def stringify_flow_nums(flow_nums: Set[int], full: bool = False) -> str: """Return a string representation of a set of flow numbers diff --git a/cylc/flow/network/multi.py b/cylc/flow/network/multi.py index a11842d6391..8baa9f119bf 100644 --- a/cylc/flow/network/multi.py +++ b/cylc/flow/network/multi.py @@ -19,6 +19,30 @@ from cylc.flow.async_util import unordered_map from cylc.flow.id_cli import parse_ids_async +from cylc.flow.exceptions import InputError + + +def print_response(multi_results): + """Print server mutation response to stdout. + + The response will be either: + - (False, argument-validation-error) + - (True, ID-of-queued-command) + + Raise InputError if validation failed. + + """ + for multi_result in multi_results: + for _cmd, results in multi_result.items(): + for result in results.values(): + for wf_res in result: + wf_id = wf_res["id"] + response = wf_res["response"] + if not response[0]: + # Validation failure + raise InputError(response[1]) + else: + print(f"{wf_id}: command {response[1]} queued") def call_multi(*args, **kwargs): @@ -107,4 +131,4 @@ def _report_single(report, workflow, result): def _report(_): - print('Command submitted; the scheduler will log any problems.') + pass diff --git a/cylc/flow/network/resolvers.py b/cylc/flow/network/resolvers.py index 8bd9c2347ac..8456ccbb615 100644 --- a/cylc/flow/network/resolvers.py +++ b/cylc/flow/network/resolvers.py @@ -44,6 +44,7 @@ EDGES, FAMILY_PROXIES, TASK_PROXIES, WORKFLOW, DELTA_ADDED, create_delta_store ) +from cylc.flow.exceptions import InputError from cylc.flow.id import Tokens from cylc.flow.network.schema import ( DEF_TYPES, @@ -746,10 +747,19 @@ async def _mutation_mapper( return method(**kwargs) try: - self.schd.get_command_method(command) + meth = self.schd.get_command_method(command) except AttributeError: raise ValueError(f"Command '{command}' not found") + # If meth has a command validation function, call it. + try: + # TODO: properly handle "Callable has no attribute validate"? + meth.validate(**kwargs) # type: ignore + except AttributeError: + LOG.debug(f"No command validation for {command}") + except InputError as exc: + return (False, str(exc)) + # Queue the command to the scheduler, with a unique command ID cmd_uuid = str(uuid4()) LOG.info(f"{log1} ID={cmd_uuid}\n{log2}") diff --git a/cylc/flow/scheduler.py b/cylc/flow/scheduler.py index 7e082f08f2b..b8d5685b7ed 100644 --- a/cylc/flow/scheduler.py +++ b/cylc/flow/scheduler.py @@ -53,6 +53,7 @@ ) from cylc.flow.broadcast_mgr import BroadcastMgr from cylc.flow.cfgspec.glbl_cfg import glbl_cfg +from cylc.flow import command_validation from cylc.flow.config import WorkflowConfig from cylc.flow.data_store_mgr import DataStoreMgr from cylc.flow.id import Tokens @@ -2148,6 +2149,7 @@ def command_force_trigger_tasks( return self.pool.force_trigger_tasks( tasks, flow, flow_wait, flow_descr) + @command_validation.validate def command_set( self, tasks: List[str], diff --git a/cylc/flow/scripts/set.py b/cylc/flow/scripts/set.py index 4f0cd19522f..4f00b458038 100755 --- a/cylc/flow/scripts/set.py +++ b/cylc/flow/scripts/set.py @@ -89,26 +89,28 @@ """ from functools import partial -from typing import TYPE_CHECKING, List, Optional +from typing import Tuple, TYPE_CHECKING from cylc.flow.exceptions import InputError from cylc.flow.network.client_factory import get_client -from cylc.flow.network.multi import call_multi +from cylc.flow.network.multi import ( + call_multi, + print_response +) from cylc.flow.option_parsers import ( FULL_ID_MULTI_ARG_DOC, CylcOptionParser as COP, ) -from cylc.flow.id import Tokens -from cylc.flow.task_outputs import TASK_OUTPUT_SUCCEEDED -from cylc.flow.terminal import cli_function -from cylc.flow.flow_mgr import ( - add_flow_opts, - validate_flow_opts +from cylc.flow.terminal import ( + cli_function, + flatten_cli_lists ) +from cylc.flow.flow_mgr import add_flow_opts if TYPE_CHECKING: from optparse import Values + from cylc.flow.id import Tokens MUTATION = ''' @@ -177,189 +179,14 @@ def get_option_parser() -> COP: return parser -def validate_prereq(prereq: str) -> Optional[str]: - """Return prereq (with :succeeded) if valid, else None. - - Format: cycle/task[:output] - - Examples: - >>> validate_prereq('1/foo:succeeded') - '1/foo:succeeded' - - >>> validate_prereq('1/foo') - '1/foo:succeeded' - - >>> validate_prereq('all') - 'all' - - # Error: - >>> validate_prereq('fish') - - """ - try: - tokens = Tokens(prereq, relative=True) - except ValueError: - return None - if ( - tokens["cycle"] == prereq - and prereq != "all" - ): - # Error: --pre= other than "all" - return None - - if prereq != "all" and tokens["task_sel"] is None: - prereq += f":{TASK_OUTPUT_SUCCEEDED}" - - return prereq - - -def split_opts(options: List[str]): - """Return list from multi-use and comma-separated options. - - Examples: - # --out='a,b,c' - >>> split_opts(['a,b,c']) - ['a', 'b', 'c'] - - # --out='a' --out='a,b' - >>> split_opts(['a', 'b,c']) - ['a', 'b', 'c'] - - # --out='a' --out='a,b' - >>> split_opts(['a', 'a,b']) - ['a', 'b'] - - # --out=' a ' - >>> split_opts([' a ']) - ['a'] - - # --out='a, b, c , d' - >>> split_opts(['a, b, c , d']) - ['a', 'b', 'c', 'd'] - - """ - return sorted({ - item.strip() - for option in (options or []) - for item in option.strip().split(',') - }) - - -def get_prereq_opts(prereq_options: List[str]): - """Convert prerequisites to a flat list with output selectors. - - Examples: - # Set multiple at once: - >>> get_prereq_opts(['1/foo:bar', '2/foo:baz,3/foo:qux']) - ['1/foo:bar', '2/foo:baz', '3/foo:qux'] - - # --pre=all - >>> get_prereq_opts(["all"]) - ['all'] - - # implicit ":succeeded" - >>> get_prereq_opts(["1/foo"]) - ['1/foo:succeeded'] - - # Error: invalid format: - >>> get_prereq_opts(["fish"]) - Traceback (most recent call last): - cylc.flow.exceptions.InputError: ... - - # Error: invalid format: - >>> get_prereq_opts(["1/foo::bar"]) - Traceback (most recent call last): - cylc.flow.exceptions.InputError: ... - - # Error: "all" must be used alone: - >>> get_prereq_opts(["all", "2/foo:baz"]) - Traceback (most recent call last): - cylc.flow.exceptions.InputError: ... - - """ - prereqs = split_opts(prereq_options) - if not prereqs: - return [] - - prereqs2 = [] - bad: List[str] = [] - for pre in prereqs: - p = validate_prereq(pre) - if p is not None: - prereqs2.append(p) - else: - bad.append(pre) - if bad: - raise InputError( - "Use prerequisite format /:output\n" - "\n ".join(bad) - ) - - if len(prereqs2) > 1: # noqa SIM102 (anticipates "cylc set --pre=cycle") - if "all" in prereqs: - raise InputError("--pre=all must be used alone") - - return prereqs2 - - -def get_output_opts(output_options: List[str]): - """Convert outputs options to a flat list, and validate. - - Examples: - Good: - >>> get_output_opts(['a', 'b,c']) - ['a', 'b', 'c'] - >>> get_output_opts(["required"]) # "required" is explicit default - [] - - Bad: - >>> get_output_opts(["required", "a"]) # "required" must be used alone - Traceback (most recent call last): - cylc.flow.exceptions.InputError: --out=required must be used alone - >>> get_output_opts(["waiting"]) # cannot "reset" to waiting - Traceback (most recent call last): - cylc.flow.exceptions.InputError: Tasks cannot be set to waiting... - - """ - outputs = split_opts(output_options) - - # If "required" is explicit just ditch it (same as the default) - if not outputs or outputs == ["required"]: - return [] - - if "required" in outputs: - raise InputError("--out=required must be used alone") - if "waiting" in outputs: - raise InputError( - "Tasks cannot be set to waiting, use a new flow to re-run" - ) - - return outputs - - -def validate_opts(output_opt: List[str], prereq_opt: List[str]): - """Check global option consistency - - Examples: - >>> validate_opts(["a"], None) # OK - - >>> validate_opts(None, ["1/a:failed"]) #OK - - >>> validate_opts(["a"], ["1/a:failed"]) - Traceback (most recent call last): - cylc.flow.exceptions.InputError: ... - - """ - if output_opt and prereq_opt: - raise InputError("Use --prerequisite or --output, not both.") - - -def validate_tokens(tokens_list): +def validate_tokens(tokens_list: Tuple['Tokens']) -> None: """Check the cycles/tasks provided. This checks that cycle/task selectors have not been provided in the IDs. Examples: + >>> from cylc.flow.id import Tokens + Good: >>> validate_tokens([Tokens('w//c')]) >>> validate_tokens([Tokens('w//c/t')]) @@ -391,6 +218,7 @@ async def run( workflow_id: str, *tokens_list ) -> None: + validate_tokens(tokens_list) pclient = get_client(workflow_id, timeout=options.comms_timeout) @@ -403,22 +231,19 @@ async def run( tokens.relative_id_with_selectors for tokens in tokens_list ], - 'outputs': get_output_opts(options.outputs), - 'prerequisites': get_prereq_opts(options.prerequisites), + 'outputs': flatten_cli_lists(options.outputs), + 'prerequisites': flatten_cli_lists(options.prerequisites), 'flow': options.flow, 'flowWait': options.flow_wait, 'flowDescr': options.flow_descr } } - await pclient.async_request('graphql', mutation_kwargs) + return await pclient.async_request('graphql', mutation_kwargs) @cli_function(get_option_parser) def main(parser: COP, options: 'Values', *ids) -> None: - validate_opts(options.outputs, options.prerequisites) - validate_flow_opts(options) - call_multi( - partial(run, options), - *ids, + print_response( + call_multi(partial(run, options), *ids) ) diff --git a/cylc/flow/scripts/trigger.py b/cylc/flow/scripts/trigger.py index 9d27fc38bd1..f49c0d37e75 100755 --- a/cylc/flow/scripts/trigger.py +++ b/cylc/flow/scripts/trigger.py @@ -51,10 +51,8 @@ CylcOptionParser as COP, ) from cylc.flow.terminal import cli_function -from cylc.flow.flow_mgr import ( - add_flow_opts, - validate_flow_opts -) +from cylc.flow.flow_mgr import add_flow_opts +from cylc.flow.command_validation import validate_flow_opts if TYPE_CHECKING: @@ -116,7 +114,7 @@ async def run(options: 'Values', workflow_id: str, *tokens_list): @cli_function(get_option_parser) def main(parser: COP, options: 'Values', *ids: str): """CLI for "cylc trigger".""" - validate_flow_opts(options) + validate_flow_opts(options.flow or ['all'], options.flow_wait) call_multi( partial(run, options), *ids, diff --git a/cylc/flow/terminal.py b/cylc/flow/terminal.py index f515b7bc682..c7f2023b046 100644 --- a/cylc/flow/terminal.py +++ b/cylc/flow/terminal.py @@ -24,7 +24,7 @@ from subprocess import PIPE, Popen # nosec import sys from textwrap import wrap -from typing import Any, Callable, Optional, TYPE_CHECKING +from typing import Any, Callable, List, Optional, TYPE_CHECKING from ansimarkup import parse as cparse from colorama import init as color_init @@ -379,3 +379,35 @@ def prompt(message, options, default=None, process=None): if isinstance(options, dict): return options[usr] return usr + + +def flatten_cli_lists(lsts: List[str]) -> List[str]: + """Return a sorted flat list for multi-use CLI command options. + + Examples: + # --out='a,b,c' + >>> flatten_cli_lists(['a,b,c']) + ['a', 'b', 'c'] + + # --out='a' --out='a,b' + >>> flatten_cli_lists(['a', 'b,c']) + ['a', 'b', 'c'] + + # --out='a' --out='a,b' + >>> flatten_cli_lists(['a', 'a,b']) + ['a', 'b'] + + # --out=' a ' + >>> flatten_cli_lists([' a ']) + ['a'] + + # --out='a, b, c , d' + >>> flatten_cli_lists(['a, b, c , d']) + ['a', 'b', 'c', 'd'] + + """ + return sorted({ + item.strip() + for lst in (lsts or []) + for item in lst.strip().split(',') + }) diff --git a/tests/unit/scripts/test_trigger.py b/tests/unit/scripts/test_trigger.py deleted file mode 100644 index 87f392d73f8..00000000000 --- a/tests/unit/scripts/test_trigger.py +++ /dev/null @@ -1,124 +0,0 @@ -# THIS FILE IS PART OF THE CYLC WORKFLOW ENGINE. -# Copyright (C) NIWA & British Crown (Met Office) & Contributors. -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -"""Test logic in cylc-trigger script.""" - -from optparse import Values -import pytest -from typing import Optional, Tuple, Type - -from cylc.flow.exceptions import InputError -from cylc.flow.option_parsers import Options -from cylc.flow.flow_mgr import ( - FLOW_ALL, - FLOW_NEW, - FLOW_NONE, - validate_flow_opts -) -from cylc.flow.scripts.trigger import get_option_parser - - -Opts = Options(get_option_parser()) - - -@pytest.mark.parametrize( - 'opts, expected_err', - [ - ( - Opts( - flow=[FLOW_ALL], - flow_wait=False - ), - None - ), - ( - Opts( - flow=None, - flow_wait=False - ), - None - ), - ( - Opts( - flow=[FLOW_NEW], - flow_wait=False, - flow_descr="Denial is a deep river" - ), - None - ), - ( - Opts( - flow=[FLOW_ALL, "1"], - flow_wait=False - ), - ( - InputError, - "Multiple flow options must all be integer valued" - ) - ), - ( - Opts( - flow=["cheese"], - flow_wait=False - ), - ( - InputError, - "Flow values must be an integer, or 'all', 'new', or 'none'" - ) - ), - ( - Opts( - flow=[FLOW_NONE], - flow_wait=True - ), - ( - InputError, - "--wait is not compatible with --flow=new or --flow=none" - ) - ), - ( - Opts( - flow=[FLOW_ALL, "1"], - flow_wait=False - ), - ( - InputError, - "Multiple flow options must all be integer valued" - ) - ), - ( - Opts( - flow=[FLOW_ALL, "1"], - flow_wait=False - ), - ( - InputError, - "Multiple flow options must all be integer valued" - ) - ), - ] -) -def test_validate( - opts: Values, - expected_err: Optional[Tuple[Type[Exception], str]]): - - if expected_err: - err, msg = expected_err - with pytest.raises(err) as exc: - validate_flow_opts(opts) - assert msg in str(exc.value) - else: - validate_flow_opts(opts) From 24adfe0c8eb02d32b450d1e3d18b739e0f40c027 Mon Sep 17 00:00:00 2001 From: Oliver Sanders Date: Thu, 23 May 2024 16:01:48 +0100 Subject: [PATCH 054/196] scheduler commands: refactor * Separate scheduler commands from the main scheduler code. * Put all commands behind a decorator to protect against leakage. (e.g. an import cannot be mistaken for a command/validator). * Integrate command validators in with the commands themselves. This removes the duplicate command/validate function signatures and makes validators implicit (i.e. they are called as part of the command not searched for and called separately to the command). * Make all commands async and provide a blank validator for each (i.e. add a yield at the top of each function). Note this means that command args/kwargs are now validated as part of queueing the command itself by default. * Partially addresses #3329 by making commands generators capable of returning multiple messages. * Improve interface for multi-workflow commands: - Colour formatting for success/error cases. - One line per workflow (presuming single line output). - Exceptions in processing one workflow's response caught and logged rather than interrupting other workflow's output. - Commands exit 1 if any workflow returns an error response. * Add multi-workflow test for "cylc broadcast". --- cylc/flow/async_util.py | 36 +- cylc/flow/command_validation.py | 88 ++-- cylc/flow/commands.py | 434 ++++++++++++++++++ cylc/flow/network/multi.py | 208 +++++++-- cylc/flow/network/resolvers.py | 26 +- cylc/flow/scheduler.py | 352 ++------------ cylc/flow/scripts/broadcast.py | 28 +- cylc/flow/scripts/get_workflow_version.py | 6 +- cylc/flow/scripts/hold.py | 6 +- cylc/flow/scripts/kill.py | 6 +- cylc/flow/scripts/pause.py | 8 +- cylc/flow/scripts/ping.py | 22 +- cylc/flow/scripts/poll.py | 6 +- cylc/flow/scripts/reinstall.py | 5 +- cylc/flow/scripts/release.py | 6 +- cylc/flow/scripts/reload.py | 8 +- cylc/flow/scripts/remove.py | 6 +- cylc/flow/scripts/set.py | 13 +- cylc/flow/scripts/stop.py | 24 +- cylc/flow/scripts/trigger.py | 10 +- cylc/flow/scripts/verbosity.py | 8 +- tests/integration/scripts/test_broadcast.py | 79 ++++ tests/integration/test_client.py | 38 ++ tests/integration/test_queues.py | 27 +- tests/integration/test_reload.py | 5 +- tests/integration/test_resolvers.py | 7 +- tests/integration/test_scheduler.py | 11 +- tests/integration/test_simulation.py | 3 +- .../test_stop_after_cycle_point.py | 5 +- tests/integration/test_task_pool.py | 11 +- tests/integration/test_workflow_db_mgr.py | 5 +- tests/integration/tui/test_mutations.py | 18 +- tests/unit/network/test_multi.py | 132 ++++++ 33 files changed, 1116 insertions(+), 531 deletions(-) create mode 100644 cylc/flow/commands.py create mode 100644 tests/integration/scripts/test_broadcast.py create mode 100644 tests/unit/network/test_multi.py diff --git a/cylc/flow/async_util.py b/cylc/flow/async_util.py index 478c41dc297..aab44224639 100644 --- a/cylc/flow/async_util.py +++ b/cylc/flow/async_util.py @@ -420,12 +420,43 @@ async def asyncqgen(queue): yield await queue.get() -async def unordered_map(coroutine, iterator): +def wrap_exception(coroutine): + """Catch and return exceptions rather than raising them. + + Examples: + >>> async def myfcn(): + ... raise Exception('foo') + >>> mywrappedfcn = wrap_exception(myfcn) + >>> ret = asyncio.run(mywrappedfcn()) # the exception is not raised... + >>> ret # ...it is returned + Exception('foo') + + """ + async def _inner(*args, **kwargs): + nonlocal coroutine + try: + return await coroutine(*args, **kwargs) + except Exception as exc: + return exc + + return _inner + + +async def unordered_map(coroutine, iterator, wrap_exceptions=False): """An asynchronous map function which does not preserve order. Use in situations where you want results as they are completed rather than once they are all completed. + Args: + coroutine: + The async function you want to call. + iterator: + The arguments you want to call it with. + wrap_exceptions: + If True, then exceptions will be caught and returned rather than + raised. + Example: # define your async coroutine >>> async def square(x): return x**2 @@ -444,6 +475,9 @@ async def unordered_map(coroutine, iterator): [((0,), 0), ((1,), 1), ((2,), 4), ((3,), 9), ((4,), 16)] """ + if wrap_exceptions: + coroutine = wrap_exception(coroutine) + # create tasks pending = [] for args in iterator: diff --git a/cylc/flow/command_validation.py b/cylc/flow/command_validation.py index ab8d47b019e..56fd5f47861 100644 --- a/cylc/flow/command_validation.py +++ b/cylc/flow/command_validation.py @@ -18,7 +18,6 @@ from typing import ( - Callable, List, Optional, ) @@ -36,38 +35,27 @@ ) -def validate(func: Callable): - """Decorate scheduler commands with a callable .validate attribute. - - """ - # TODO: properly handle "Callable has no attribute validate"? - func.validate = globals()[ # type: ignore - func.__name__.replace("command", "validate") - ] - return func - - -def validate_flow_opts(flows: List[str], flow_wait: bool) -> None: +def flow_opts(flows: List[str], flow_wait: bool) -> None: """Check validity of flow-related CLI options. Note the schema defaults flows to ["all"]. Examples: Good: - >>> validate_flow_opts(["new"], False) - >>> validate_flow_opts(["1", "2"], False) - >>> validate_flow_opts(["1", "2"], True) + >>> flow_opts(["new"], False) + >>> flow_opts(["1", "2"], False) + >>> flow_opts(["1", "2"], True) Bad: - >>> validate_flow_opts(["none", "1"], False) + >>> flow_opts(["none", "1"], False) Traceback (most recent call last): cylc.flow.exceptions.InputError: ... must all be integer valued - >>> validate_flow_opts(["cheese", "2"], True) + >>> flow_opts(["cheese", "2"], True) Traceback (most recent call last): cylc.flow.exceptions.InputError: ... or 'all', 'new', or 'none' - >>> validate_flow_opts(["new"], True) + >>> flow_opts(["new"], True) Traceback (most recent call last): cylc.flow.exceptions.InputError: ... @@ -87,36 +75,36 @@ def validate_flow_opts(flows: List[str], flow_wait: bool) -> None: raise InputError(ERR_OPT_FLOW_WAIT) -def validate_prereqs(prereqs: Optional[List[str]]): +def prereqs(prereqs: Optional[List[str]]): """Validate a list of prerequisites, add implicit ":succeeded". Comma-separated lists should be split already, client-side. Examples: # Set multiple at once: - >>> validate_prereqs(['1/foo:bar', '2/foo:baz']) + >>> prereqs(['1/foo:bar', '2/foo:baz']) ['1/foo:bar', '2/foo:baz'] # --pre=all - >>> validate_prereqs(["all"]) + >>> prereqs(["all"]) ['all'] # implicit ":succeeded" - >>> validate_prereqs(["1/foo"]) + >>> prereqs(["1/foo"]) ['1/foo:succeeded'] # Error: invalid format: - >>> validate_prereqs(["fish"]) + >>> prereqs(["fish"]) Traceback (most recent call last): cylc.flow.exceptions.InputError: ... # Error: invalid format: - >>> validate_prereqs(["1/foo::bar"]) + >>> prereqs(["1/foo::bar"]) Traceback (most recent call last): cylc.flow.exceptions.InputError: ... # Error: "all" must be used alone: - >>> validate_prereqs(["all", "2/foo:baz"]) + >>> prereqs(["all", "2/foo:baz"]) Traceback (most recent call last): cylc.flow.exceptions.InputError: ... @@ -127,7 +115,7 @@ def validate_prereqs(prereqs: Optional[List[str]]): prereqs2 = [] bad: List[str] = [] for pre in prereqs: - p = validate_prereq(pre) + p = prereq(pre) if p is not None: prereqs2.append(p) else: @@ -145,23 +133,23 @@ def validate_prereqs(prereqs: Optional[List[str]]): return prereqs2 -def validate_prereq(prereq: str) -> Optional[str]: +def prereq(prereq: str) -> Optional[str]: """Return prereq (with :succeeded) if valid, else None. Format: cycle/task[:output] Examples: - >>> validate_prereq('1/foo:succeeded') + >>> prereq('1/foo:succeeded') '1/foo:succeeded' - >>> validate_prereq('1/foo') + >>> prereq('1/foo') '1/foo:succeeded' - >>> validate_prereq('all') + >>> prereq('all') 'all' # Error: - >>> validate_prereq('fish') + >>> prereq('fish') """ try: @@ -181,25 +169,25 @@ def validate_prereq(prereq: str) -> Optional[str]: return prereq -def validate_outputs(outputs: Optional[List[str]]): +def outputs(outputs: Optional[List[str]]): """Validate outputs. Comma-separated lists should be split already, client-side. Examples: Good: - >>> validate_outputs(['a', 'b']) + >>> outputs(['a', 'b']) ['a', 'b'] - >>> validate_outputs(["required"]) # "required" is explicit default + >>> outputs(["required"]) # "required" is explicit default [] Bad: - >>> validate_outputs(["required", "a"]) + >>> outputs(["required", "a"]) Traceback (most recent call last): cylc.flow.exceptions.InputError: --out=required must be used alone - >>> validate_outputs(["waiting"]) + >>> outputs(["waiting"]) Traceback (most recent call last): cylc.flow.exceptions.InputError: Tasks cannot be set to waiting... @@ -219,39 +207,21 @@ def validate_outputs(outputs: Optional[List[str]]): return outputs -def validate_consistency( +def consistency( outputs: Optional[List[str]], prereqs: Optional[List[str]] ) -> None: """Check global option consistency Examples: - >>> validate_consistency(["a"], None) # OK + >>> consistency(["a"], None) # OK - >>> validate_consistency(None, ["1/a:failed"]) #OK + >>> consistency(None, ["1/a:failed"]) #OK - >>> validate_consistency(["a"], ["1/a:failed"]) + >>> consistency(["a"], ["1/a:failed"]) Traceback (most recent call last): cylc.flow.exceptions.InputError: ... """ if outputs and prereqs: raise InputError("Use --prerequisite or --output, not both.") - - -def validate_set( - tasks: List[str], - flow: List[str], - outputs: Optional[List[str]] = None, - prerequisites: Optional[List[str]] = None, - flow_wait: bool = False, - flow_descr: Optional[str] = None -) -> None: - """Validate args of the scheduler "command_set" method. - - Raise InputError if validation fails. - """ - validate_consistency(outputs, prerequisites) - outputs = validate_outputs(outputs) - prerequisites = validate_prereqs(prerequisites) - validate_flow_opts(flow, flow_wait) diff --git a/cylc/flow/commands.py b/cylc/flow/commands.py new file mode 100644 index 00000000000..28de0d16ed5 --- /dev/null +++ b/cylc/flow/commands.py @@ -0,0 +1,434 @@ +# THIS FILE IS PART OF THE CYLC WORKFLOW ENGINE. +# Copyright (C) NIWA & British Crown (Met Office) & Contributors. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Cylc scheduler commands. + +These are the scripts which are actioned on the Scheduler instance when you +call a mutation. + +Each is an async generator providing functionalities for: + +* Validation: + * The generator is executed up to the first "yield" before the command is + queued. + * Validation and argument parsing can be performed at this stage. + * If the generator raises an Exception then the error message will be + communicated back to the user and the command will not be queued. + * If the execution string is not obvious to a user, catch the exception and + re-raise it as an InputError with a more obvious string. + * Any other exceptions will be treated as genuine errors. +* Execution: + * The generator is executed up to the second "yield" when the command is run + by the Scheduler's main-loop: + * The execution may also stop at a return or the end of the generator code. + * If the generator raises a CommandFailedError at this stage, the error will + be caught and logged. + * Any other exceptions will be treated as genuine errors. + +In the future we may change this interface to allow generators to "yield" any +arbitrary number of strings to serve the function of communicating command +progress back to the user. For example, the generator might yield the messages: + +* Command queued. +* Done 1/3 things +* Done 2/3 things +* Done 3/3 things +* Done + +For more info see: https://github.com/cylc/cylc-flow/issues/3329 + +""" + +from contextlib import suppress +from time import sleep, time +from typing import ( + AsyncGenerator, + Callable, + Dict, + Iterable, + List, + Optional, + TYPE_CHECKING, + Union, +) + +from cylc.flow import LOG +import cylc.flow.command_validation as validate +from cylc.flow.exceptions import ( + CommandFailedError, + CyclingError, + CylcConfigError, +) +import cylc.flow.flags +from cylc.flow.log_level import log_level_to_verbosity +from cylc.flow.network.schema import WorkflowStopMode +from cylc.flow.parsec.exceptions import ParsecError +from cylc.flow.task_id import TaskID +from cylc.flow.task_state import TASK_STATUSES_ACTIVE, TASK_STATUS_FAILED +from cylc.flow.workflow_status import RunMode, StopMode + +from metomi.isodatetime.parsers import TimePointParser + +if TYPE_CHECKING: + from cylc.flow.scheduler import Scheduler + + # define a type for command implementations + Command = Callable[ + ..., + AsyncGenerator, + ] + +# a directory of registered commands (populated on module import) +COMMANDS: 'Dict[str, Command]' = {} + + +def _command(name: str): + """Decorator to register a command.""" + def _command(fcn: 'Command'): + nonlocal name + COMMANDS[name] = fcn + fcn.command_name = name # type: ignore + return fcn + return _command + + +async def run_cmd(fcn, *args, **kwargs): + """Run a command outside of the scheduler's main loop. + + Normally commands are run via the Scheduler's command_queue (which is + correct), however, there are some use cases for running commands outside of + the loop: + + * Running synchronous commands within the scheduler itself (e.g. on + shutdown). + * Integration tests (where you may want to cut out the main loop and + command queueing mechanism for simplicity). + + For these purposes use "run_cmd", otherwise, queue commands via the + scheduler as normal. + """ + cmd = fcn(*args, **kwargs) + await cmd.__anext__() # validate + with suppress(StopAsyncIteration): + return await cmd.__anext__() # run + + +@_command('set') +async def set_prereqs_and_outputs( + schd: 'Scheduler', + tasks: List[str], + flow: List[str], + outputs: Optional[List[str]] = None, + prerequisites: Optional[List[str]] = None, + flow_wait: bool = False, + flow_descr: Optional[str] = None, +) -> AsyncGenerator: + """Force spawn task successors. + + Note, the "outputs" and "prerequisites" arguments might not be + populated in the mutation arguments so must provide defaults here. + """ + validate.consistency(outputs, prerequisites) + outputs = validate.outputs(outputs) + prerequisites = validate.prereqs(prerequisites) + validate.flow_opts(flow, flow_wait) + + yield + + if outputs is None: + outputs = [] + if prerequisites is None: + prerequisites = [] + yield schd.pool.set_prereqs_and_outputs( + tasks, + outputs, + prerequisites, + flow, + flow_wait, + flow_descr, + ) + + +@_command('stop') +async def stop( + schd: 'Scheduler', + mode: Union[str, 'StopMode'], + cycle_point: Optional[str] = None, + # NOTE clock_time YYYY/MM/DD-HH:mm back-compat removed + clock_time: Optional[str] = None, + task: Optional[str] = None, + flow_num: Optional[int] = None, +): + yield + if flow_num: + schd.pool.stop_flow(flow_num) + return + + if cycle_point is not None: + # schedule shutdown after tasks pass provided cycle point + point = TaskID.get_standardised_point(cycle_point) + if point is not None and schd.pool.set_stop_point(point): + schd.options.stopcp = str(point) + schd.config.stop_point = point + schd.workflow_db_mgr.put_workflow_stop_cycle_point( + schd.options.stopcp + ) + elif clock_time is not None: + # schedule shutdown after wallclock time passes provided time + parser = TimePointParser() + schd.set_stop_clock( + int(parser.parse(clock_time).seconds_since_unix_epoch) + ) + elif task is not None: + # schedule shutdown after task succeeds + task_id = TaskID.get_standardised_taskid(task) + schd.pool.set_stop_task(task_id) + else: + # immediate shutdown + with suppress(KeyError): + # By default, mode from mutation is a name from the + # WorkflowStopMode graphene.Enum, but we need the value + mode = WorkflowStopMode[mode] # type: ignore[misc] + try: + mode = StopMode(mode) + except ValueError: + raise CommandFailedError(f"Invalid stop mode: '{mode}'") + schd._set_stop(mode) + if mode is StopMode.REQUEST_KILL: + schd.time_next_kill = time() + + +@_command('release') +async def release(schd: 'Scheduler', tasks: Iterable[str]): + """Release held tasks.""" + yield + yield schd.pool.release_held_tasks(tasks) + + +@_command('release_hold_point') +async def release_hold_point(schd: 'Scheduler'): + """Release all held tasks and unset workflow hold after cycle point, + if set.""" + yield + LOG.info("Releasing all tasks and removing hold cycle point.") + schd.pool.release_hold_point() + + +@_command('resume') +async def resume(schd: 'Scheduler'): + """Resume paused workflow.""" + yield + schd.resume_workflow() + + +@_command('poll_tasks') +async def poll_tasks(schd: 'Scheduler', tasks: Iterable[str]): + """Poll pollable tasks or a task or family if options are provided.""" + yield + if schd.get_run_mode() == RunMode.SIMULATION: + yield 0 + itasks, _, bad_items = schd.pool.filter_task_proxies(tasks) + schd.task_job_mgr.poll_task_jobs(schd.workflow, itasks) + yield len(bad_items) + + +@_command('kill_tasks') +async def kill_tasks(schd: 'Scheduler', tasks: Iterable[str]): + """Kill all tasks or a task/family if options are provided.""" + yield + itasks, _, bad_items = schd.pool.filter_task_proxies(tasks) + if schd.get_run_mode() == RunMode.SIMULATION: + for itask in itasks: + if itask.state(*TASK_STATUSES_ACTIVE): + itask.state_reset(TASK_STATUS_FAILED) + schd.data_store_mgr.delta_task_state(itask) + yield len(bad_items) + else: + schd.task_job_mgr.kill_task_jobs(schd.workflow, itasks) + yield len(bad_items) + + +@_command('hold') +async def hold(schd: 'Scheduler', tasks: Iterable[str]): + """Hold specified tasks.""" + yield + yield schd.pool.hold_tasks(tasks) + + +@_command('set_hold_point') +async def set_hold_point(schd: 'Scheduler', point: str): + """Hold all tasks after the specified cycle point.""" + yield + cycle_point = TaskID.get_standardised_point(point) + if cycle_point is None: + raise CyclingError("Cannot set hold point to None") + LOG.info( + f"Setting hold cycle point: {cycle_point}\n" + "All tasks after this point will be held." + ) + schd.pool.set_hold_point(cycle_point) + + +@_command('pause') +async def pause(schd: 'Scheduler'): + """Pause the workflow.""" + yield + schd.pause_workflow() + + +@_command('set_verbosity') +async def set_verbosity(schd: 'Scheduler', level: Union[int, str]): + """Set workflow verbosity.""" + yield + try: + lvl = int(level) + LOG.setLevel(lvl) + except (TypeError, ValueError) as exc: + raise CommandFailedError(exc) + cylc.flow.flags.verbosity = log_level_to_verbosity(lvl) + + +@_command('remove_tasks') +async def remove_tasks(schd: 'Scheduler', tasks: Iterable[str]): + """Remove tasks.""" + yield + yield schd.pool.remove_tasks(tasks) + + +@_command('reload_workflow') +async def reload_workflow(schd: 'Scheduler'): + """Reload workflow configuration.""" + yield + # pause the workflow if not already + was_paused_before_reload = schd.is_paused + if not was_paused_before_reload: + schd.pause_workflow('Reloading workflow') + schd.process_workflow_db_queue() # see #5593 + + # flush out preparing tasks before attempting reload + schd.reload_pending = 'waiting for pending tasks to submit' + while schd.release_queued_tasks(): + # Run the subset of main-loop functionality required to push + # preparing through the submission pipeline and keep the workflow + # responsive (e.g. to the `cylc stop` command). + + # NOTE: this reload method was called by process_command_queue + # which is called synchronously in the main loop so this call is + # blocking to other main loop functions + + # subproc pool - for issueing/tracking remote-init commands + schd.proc_pool.process() + # task messages - for tracking task status changes + schd.process_queued_task_messages() + # command queue - keeps the scheduler responsive + await schd.process_command_queue() + # allows the scheduler to shutdown --now + await schd.workflow_shutdown() + # keep the data store up to date with what's going on + await schd.update_data_structure() + schd.update_data_store() + # give commands time to complete + sleep(1) # give any remove-init's time to complete + + # reload the workflow definition + schd.reload_pending = 'loading the workflow definition' + schd.update_data_store() # update workflow status msg + schd._update_workflow_state() + LOG.info("Reloading the workflow definition.") + try: + config = schd.load_flow_file(is_reload=True) + except (ParsecError, CylcConfigError) as exc: + if cylc.flow.flags.verbosity > 1: + # log full traceback in debug mode + LOG.exception(exc) + LOG.critical( + f'Reload failed - {exc.__class__.__name__}: {exc}' + '\nThis is probably due to an issue with the new' + ' configuration.' + '\nTo continue with the pre-reload config, un-pause the' + ' workflow.' + '\nOtherwise, fix the configuration and attempt to reload' + ' again.' + ) + else: + schd.reload_pending = 'applying the new config' + old_tasks = set(schd.config.get_task_name_list()) + # Things that can't change on workflow reload: + schd._set_workflow_params( + schd.workflow_db_mgr.pri_dao.select_workflow_params() + ) + schd.apply_new_config(config, is_reload=True) + schd.broadcast_mgr.linearized_ancestors = ( + schd.config.get_linearized_ancestors() + ) + + schd.task_events_mgr.mail_interval = schd.cylc_config['mail'][ + 'task event batch interval' + ] + schd.task_events_mgr.mail_smtp = schd._get_events_conf("smtp") + schd.task_events_mgr.mail_footer = schd._get_events_conf("footer") + + # Log tasks that have been added by the reload, removed tasks are + # logged by the TaskPool. + add = set(schd.config.get_task_name_list()) - old_tasks + for task in add: + LOG.warning(f"Added task: '{task}'") + schd.workflow_db_mgr.put_workflow_template_vars(schd.template_vars) + schd.workflow_db_mgr.put_runtime_inheritance(schd.config) + schd.workflow_db_mgr.put_workflow_params(schd) + schd.process_workflow_db_queue() # see #5593 + schd.is_updated = True + schd.is_reloaded = True + schd._update_workflow_state() + + # Re-initialise data model on reload + schd.data_store_mgr.initiate_data_model(schd.is_reloaded) + + # Reset the remote init map to trigger fresh file installation + schd.task_job_mgr.task_remote_mgr.remote_init_map.clear() + schd.task_job_mgr.task_remote_mgr.is_reload = True + schd.pool.reload_taskdefs(config) + # Load jobs from DB + schd.workflow_db_mgr.pri_dao.select_jobs_for_restart( + schd.data_store_mgr.insert_db_job + ) + if schd.pool.compute_runahead(force=True): + schd.pool.release_runahead_tasks() + schd.is_reloaded = True + schd.is_updated = True + + LOG.info("Reload completed.") + + # resume the workflow if previously paused + schd.reload_pending = False + schd.update_data_store() # update workflow status msg + schd._update_workflow_state() + if not was_paused_before_reload: + schd.resume_workflow() + schd.process_workflow_db_queue() # see #5593 + + +@_command('force_trigger_tasks') +async def force_trigger_tasks( + schd: 'Scheduler', + tasks: Iterable[str], + flow: List[str], + flow_wait: bool = False, + flow_descr: Optional[str] = None, +): + """Manual task trigger.""" + yield + yield schd.pool.force_trigger_tasks(tasks, flow, flow_wait, flow_descr) diff --git a/cylc/flow/network/multi.py b/cylc/flow/network/multi.py index 8baa9f119bf..724a8cbc368 100644 --- a/cylc/flow/network/multi.py +++ b/cylc/flow/network/multi.py @@ -16,33 +16,16 @@ import asyncio from functools import partial +import sys +from typing import Callable, Dict, List, Tuple, Optional, Union + +from ansimarkup import ansiprint from cylc.flow.async_util import unordered_map +from cylc.flow.exceptions import CylcError, WorkflowStopped +import cylc.flow.flags from cylc.flow.id_cli import parse_ids_async -from cylc.flow.exceptions import InputError - - -def print_response(multi_results): - """Print server mutation response to stdout. - - The response will be either: - - (False, argument-validation-error) - - (True, ID-of-queued-command) - - Raise InputError if validation failed. - - """ - for multi_result in multi_results: - for _cmd, results in multi_result.items(): - for result in results.values(): - for wf_res in result: - wf_id = wf_res["id"] - response = wf_res["response"] - if not response[0]: - # Validation failure - raise InputError(response[1]) - else: - print(f"{wf_id}: command {response[1]} queued") +from cylc.flow.terminal import DIM def call_multi(*args, **kwargs): @@ -57,10 +40,13 @@ async def call_multi_async( fcn, *ids, constraint='tasks', - report=None, + report: Optional[ + # report(response: dict) -> (stdout, stderr, success) + Callable[[dict], Tuple[Optional[str], Optional[str], bool]] + ] = None, max_workflows=None, max_tasks=None, -): +) -> Dict[str, bool]: """Call a function for each workflow in a list of IDs. Args: @@ -78,8 +64,16 @@ async def call_multi_async( mixed: No constraint. report: - Override the default stdout output. - This function is provided with the return value of fcn. + The default reporter inspects the returned GraphQL status + extracting command outcome from the "response" field. + This reporter can be overwritten using the report kwarg. + + Reporter functions are provided with the "response". They must + return the outcome of the operation and may also return stdout/err + text which will be written to the terminal. + + Returns: + {workflow_id: outcome} """ # parse ids @@ -101,34 +95,170 @@ async def call_multi_async( reporter = partial(_report_single, report) if constraint == 'workflows': - # TODO: this is silly, just standardise the responses workflow_args = { workflow_id: [] for workflow_id in workflow_args } # run coros - results = [] + results: Dict[str, bool] = {} async for (workflow_id, *args), result in unordered_map( fcn, ( (workflow_id, *args) for workflow_id, args in workflow_args.items() ), + # return exceptions rather than raising them + # (this way if one command errors, others may still run) + wrap_exceptions=True, ): - reporter(workflow_id, result) - results.append(result) + results[workflow_id] = reporter(workflow_id, result) return results -def _report_multi(report, workflow, result): - print(workflow) - report(result) +def _report_multi( + report: Callable, workflow: str, response: Union[dict, Exception] +) -> bool: + """Report a response for a multi-workflow operation. + + This is called once for each workflow the operation is called against. + """ + out, err, outcome = _process_response(report, response) + + msg = f'{workflow}:' + if out: + out = out.replace('\n', '\n ') # indent + msg += ' ' + out + ansiprint(msg) + + if err: + err = err.replace('\n', '\n ') # indent + if not out: + err = f'{msg} {err}' + ansiprint(err, file=sys.stdout) + + return outcome + + +def _report_single( + report: Callable, _workflow: str, response: Union[dict, Exception] +) -> bool: + """Report the response for a single-workflow operation.""" + out, err, outcome = _process_response(report, response) + + if out: + ansiprint(out) + if err: + ansiprint(err, file=sys.stderr) + + return outcome + + +def _process_response( + report: Callable, + response: Union[dict, Exception], +) -> Tuple[Optional[str], Optional[str], bool]: + """Handle exceptions and return processed results. + + If the response is an exception, return an appropriate error message, + otherwise run the reporter and return the result. + + Args: + response: + The GraphQL response. + report: + The reporter function for extracting the result from the provided + response. + + Returns: + (stdout, stderr, outcome) + + """ + if isinstance(response, WorkflowStopped): + # workflow stopped -> can't do anything + out = None + err = f'{response.__class__.__name__}: {response}' + outcome = False + elif isinstance(response, CylcError): + # exception -> report error + if cylc.flow.flags.verbosity > 1: # debug mode + raise response from None + out = None + err = f'{response.__class__.__name__}: {response}' + outcome = False + elif isinstance(response, Exception): + # unexpected error -> raise + raise response + else: + try: + # run the reporter to extract the operation outcome + out, err, outcome = report(response) + except Exception as exc: + # an exception was raised in the reporter -> report this error the + # same was as an error in the response + return _process_response(report, exc) + + return out, err, outcome + + +def _report( + response: dict, +) -> Tuple[Optional[str], Optional[str], bool]: + """Report the result of a GraphQL operation. + + This analyses GraphQL mutation responses to determine the outcome. + + Args: + response: The GraphQL response. + + Returns: + (stdout, stderr, outcome) + + """ + try: + ret: List[Tuple[Optional[str], Optional[str], bool]] = [] + for _mutation_name, mutation_response in response.items(): + # extract the result of each mutation result in the response + success, msg = mutation_response['result'][0]['response'] + out = None + err = None + if success: + # mutation succeeded + out = 'Command queued' + if cylc.flow.flags.verbosity > 0: # verbose mode + out += f' <{DIM}>id={msg}' + else: + # mutation failed + err = f'{msg}' + ret.append((out, err, success)) + if len(ret) > 1: + # NOTE: at present we only support one mutation per operation at + # cylc-flow, however, multi-mutation operations can be actioned via + # cylc-uiserver + raise NotImplementedError( + 'Cannot process multiple mutations in one operation.' + ) -def _report_single(report, workflow, result): - report(result) + if len(ret) == 1: + return ret[0] + # error extracting result from GraphQL response + raise Exception(response) -def _report(_): - pass + except Exception as exc: + # response returned is not in the expected format - this shouldn't + # happen but we need to protect against it + err_msg = '' + if cylc.flow.flags.verbosity > 1: # debug mode + # print the full result to stderr + err_msg += f'\n <{DIM}>response={response}' + return ( + None, + ( + 'Error processing command:\n' + + f' {exc.__class__.__name__}: {exc}' + + err_msg + ), + False, + ) diff --git a/cylc/flow/network/resolvers.py b/cylc/flow/network/resolvers.py index 8456ccbb615..65983042532 100644 --- a/cylc/flow/network/resolvers.py +++ b/cylc/flow/network/resolvers.py @@ -40,11 +40,12 @@ from graphene.utils.str_converters import to_snake_case from cylc.flow import LOG +from cylc.flow.commands import COMMANDS from cylc.flow.data_store_mgr import ( EDGES, FAMILY_PROXIES, TASK_PROXIES, WORKFLOW, DELTA_ADDED, create_delta_store ) -from cylc.flow.exceptions import InputError +import cylc.flow.flags from cylc.flow.id import Tokens from cylc.flow.network.schema import ( DEF_TYPES, @@ -747,17 +748,21 @@ async def _mutation_mapper( return method(**kwargs) try: - meth = self.schd.get_command_method(command) - except AttributeError: + meth = COMMANDS[command] + except KeyError: raise ValueError(f"Command '{command}' not found") - # If meth has a command validation function, call it. try: - # TODO: properly handle "Callable has no attribute validate"? - meth.validate(**kwargs) # type: ignore - except AttributeError: - LOG.debug(f"No command validation for {command}") - except InputError as exc: + # Initiate the command. Validation may be performed at this point, + # validators may raise Exceptions (preferably InputErrors) to + # communicate errors. + cmd = meth(**kwargs, schd=self.schd) + await cmd.__anext__() + except Exception as exc: + # NOTE: keep this exception vague to prevent a bad command taking + # down the scheduler + if cylc.flow.flags.verbosity > 1: + LOG.exception(exc) # log full traceback in debug mode return (False, str(exc)) # Queue the command to the scheduler, with a unique command ID @@ -767,8 +772,7 @@ async def _mutation_mapper( ( cmd_uuid, command, - [], - kwargs, + cmd, ) ) return (True, cmd_uuid) diff --git a/cylc/flow/scheduler.py b/cylc/flow/scheduler.py index b8d5685b7ed..8074bfbce8a 100644 --- a/cylc/flow/scheduler.py +++ b/cylc/flow/scheduler.py @@ -16,22 +16,21 @@ """Cylc scheduler server.""" import asyncio -from contextlib import suppress from collections import deque +from contextlib import suppress import os -import inspect from pathlib import Path from queue import Empty, Queue from shlex import quote from socket import gaierror -from subprocess import Popen, PIPE, DEVNULL +from subprocess import DEVNULL, PIPE, Popen import sys from threading import Barrier, Thread from time import sleep, time import traceback from typing import ( - TYPE_CHECKING, Any, + AsyncGenerator, Callable, Dict, Iterable, @@ -39,6 +38,7 @@ NoReturn, Optional, Set, + TYPE_CHECKING, Tuple, Union, ) @@ -46,26 +46,24 @@ import psutil -from metomi.isodatetime.parsers import TimePointParser - from cylc.flow import ( - LOG, main_loop, __version__ as CYLC_VERSION + LOG, + __version__ as CYLC_VERSION, + main_loop, ) +from cylc.flow import workflow_files from cylc.flow.broadcast_mgr import BroadcastMgr from cylc.flow.cfgspec.glbl_cfg import glbl_cfg -from cylc.flow import command_validation from cylc.flow.config import WorkflowConfig +from cylc.flow import commands from cylc.flow.data_store_mgr import DataStoreMgr -from cylc.flow.id import Tokens -from cylc.flow.flow_mgr import FLOW_NONE, FlowMgr, FLOW_NEW from cylc.flow.exceptions import ( CommandFailedError, - CyclingError, - CylcConfigError, CylcError, InputError, ) import cylc.flow.flags +from cylc.flow.flow_mgr import FLOW_NEW, FLOW_NONE, FlowMgr from cylc.flow.host_select import ( HostSelectException, select_workflow_host, @@ -73,81 +71,76 @@ from cylc.flow.hostuserutil import ( get_host, get_user, - is_remote_platform + is_remote_platform, ) +from cylc.flow.id import Tokens +from cylc.flow.log_level import verbosity_to_env, verbosity_to_opts from cylc.flow.loggingutil import ( - RotatingLogFileHandler, ReferenceLogFileHandler, + RotatingLogFileHandler, get_next_log_number, get_reload_start_number, get_sorted_logs_by_time, - patch_log_level -) -from cylc.flow.timer import Timer -from cylc.flow.log_level import ( - log_level_to_verbosity, - verbosity_to_env, - verbosity_to_opts, + patch_log_level, ) from cylc.flow.network import API from cylc.flow.network.authentication import key_housekeeping -from cylc.flow.network.schema import WorkflowStopMode from cylc.flow.network.server import WorkflowRuntimeServer -from cylc.flow.parsec.exceptions import ParsecError from cylc.flow.parsec.OrderedDict import DictTree +from cylc.flow.parsec.exceptions import ParsecError from cylc.flow.parsec.validate import DurationFloat from cylc.flow.pathutil import ( + get_workflow_name_from_id, + get_workflow_run_config_log_dir, get_workflow_run_dir, get_workflow_run_scheduler_log_dir, - get_workflow_run_config_log_dir, get_workflow_run_share_dir, get_workflow_run_work_dir, get_workflow_test_log_path, make_workflow_run_tree, - get_workflow_name_from_id ) from cylc.flow.platforms import ( get_install_target_from_platform, get_localhost_install_target, get_platform, - is_platform_with_target_in_list + is_platform_with_target_in_list, ) from cylc.flow.profiler import Profiler from cylc.flow.resources import get_resources from cylc.flow.simulation import sim_time_check from cylc.flow.subprocpool import SubProcPool -from cylc.flow.templatevars import eval_var -from cylc.flow.workflow_db_mgr import WorkflowDatabaseManager -from cylc.flow.workflow_events import WorkflowEventHandler -from cylc.flow.workflow_status import RunMode, StopMode, AutoRestartMode -from cylc.flow import workflow_files -from cylc.flow.taskdef import TaskDef from cylc.flow.task_events_mgr import TaskEventsManager -from cylc.flow.task_id import TaskID from cylc.flow.task_job_mgr import TaskJobManager from cylc.flow.task_pool import TaskPool from cylc.flow.task_remote_mgr import ( REMOTE_FILE_INSTALL_255, REMOTE_FILE_INSTALL_DONE, + REMOTE_FILE_INSTALL_FAILED, REMOTE_INIT_255, REMOTE_INIT_DONE, - REMOTE_FILE_INSTALL_FAILED, - REMOTE_INIT_FAILED + REMOTE_INIT_FAILED, ) from cylc.flow.task_state import ( TASK_STATUSES_ACTIVE, TASK_STATUSES_NEVER_ACTIVE, TASK_STATUS_PREPARING, - TASK_STATUS_SUBMITTED, TASK_STATUS_RUNNING, + TASK_STATUS_SUBMITTED, TASK_STATUS_WAITING, - TASK_STATUS_FAILED) +) +from cylc.flow.taskdef import TaskDef +from cylc.flow.templatevars import eval_var from cylc.flow.templatevars import get_template_vars +from cylc.flow.timer import Timer from cylc.flow.util import cli_format from cylc.flow.wallclock import ( get_current_time_string, get_time_string_from_unix_time as time2str, - get_utc_mode) + get_utc_mode, +) +from cylc.flow.workflow_db_mgr import WorkflowDatabaseManager +from cylc.flow.workflow_events import WorkflowEventHandler +from cylc.flow.workflow_status import AutoRestartMode, RunMode, StopMode from cylc.flow.xtrigger_mgr import XtriggerManager if TYPE_CHECKING: @@ -224,7 +217,7 @@ class Scheduler: flow_mgr: FlowMgr # queues - command_queue: 'Queue[Tuple[str, str, list, dict]]' + command_queue: 'Queue[Tuple[str, str, AsyncGenerator]]' message_queue: 'Queue[TaskMsg]' ext_trigger_queue: Queue @@ -550,7 +543,7 @@ async def configure(self, params): elif self.config.cfg['scheduling']['hold after cycle point']: holdcp = self.config.cfg['scheduling']['hold after cycle point'] if holdcp is not None: - self.command_set_hold_point(holdcp) + await commands.run_cmd(commands.set_hold_point, self, holdcp) if self.options.paused_start: self.pause_workflow('Paused on start up') @@ -640,7 +633,7 @@ async def run_scheduler(self) -> None: if self.pool.get_tasks(): # (If we're not restarting a finished workflow) self.restart_remote_init() - self.command_poll_tasks(['*/*']) + await commands.run_cmd(commands.poll_tasks, self, ['*/*']) self.run_event_handlers(self.EVENT_STARTUP, 'workflow starting') await asyncio.gather( @@ -943,20 +936,16 @@ async def process_command_queue(self) -> None: while True: uuid: str name: str - args: list - kwargs: dict + cmd: AsyncGenerator try: - uuid, name, args, kwargs = self.command_queue.get(False) + uuid, name, cmd = self.command_queue.get(False) except Empty: break msg = f'Command "{name}" ' + '{result}' + f'. ID={uuid}' try: - fcn = self.get_command_method(name) - n_warnings: Optional[int] - if inspect.iscoroutinefunction(fcn): - n_warnings = await fcn(*args, **kwargs) - else: - n_warnings = fcn(*args, **kwargs) + n_warnings: Optional[int] = None + with suppress(StopAsyncIteration): + n_warnings = await cmd.__anext__() except Exception as exc: # Don't let a bad command bring the workflow down. if ( @@ -989,231 +978,12 @@ def info_get_graph_raw(self, cto, ctn, grouping=None): self.config.feet ) - def command_stop( - self, - mode: Union[str, 'StopMode'], - cycle_point: Optional[str] = None, - # NOTE clock_time YYYY/MM/DD-HH:mm back-compat removed - clock_time: Optional[str] = None, - task: Optional[str] = None, - flow_num: Optional[int] = None - ) -> None: - if flow_num: - self.pool.stop_flow(flow_num) - return - - if cycle_point is not None: - # schedule shutdown after tasks pass provided cycle point - point = TaskID.get_standardised_point(cycle_point) - if point is not None and self.pool.set_stop_point(point): - self.options.stopcp = str(point) - self.config.stop_point = point - self.workflow_db_mgr.put_workflow_stop_cycle_point( - self.options.stopcp) - elif clock_time is not None: - # schedule shutdown after wallclock time passes provided time - parser = TimePointParser() - self.set_stop_clock( - int(parser.parse(clock_time).seconds_since_unix_epoch) - ) - elif task is not None: - # schedule shutdown after task succeeds - task_id = TaskID.get_standardised_taskid(task) - self.pool.set_stop_task(task_id) - else: - # immediate shutdown - with suppress(KeyError): - # By default, mode from mutation is a name from the - # WorkflowStopMode graphene.Enum, but we need the value - mode = WorkflowStopMode[mode] # type: ignore[misc] - try: - mode = StopMode(mode) - except ValueError: - raise CommandFailedError(f"Invalid stop mode: '{mode}'") - self._set_stop(mode) - if mode is StopMode.REQUEST_KILL: - self.time_next_kill = time() - def _set_stop(self, stop_mode: Optional[StopMode] = None) -> None: """Set shutdown mode.""" self.proc_pool.set_stopping() self.stop_mode = stop_mode self.update_data_store() - def command_release(self, tasks: Iterable[str]) -> int: - """Release held tasks.""" - return self.pool.release_held_tasks(tasks) - - def command_release_hold_point(self) -> None: - """Release all held tasks and unset workflow hold after cycle point, - if set.""" - LOG.info("Releasing all tasks and removing hold cycle point.") - self.pool.release_hold_point() - - def command_resume(self) -> None: - """Resume paused workflow.""" - self.resume_workflow() - - def command_poll_tasks(self, tasks: Iterable[str]) -> int: - """Poll pollable tasks or a task or family if options are provided.""" - if self.get_run_mode() == RunMode.SIMULATION: - return 0 - itasks, _, bad_items = self.pool.filter_task_proxies(tasks) - self.task_job_mgr.poll_task_jobs(self.workflow, itasks) - return len(bad_items) - - def command_kill_tasks(self, tasks: Iterable[str]) -> int: - """Kill all tasks or a task/family if options are provided.""" - itasks, _, bad_items = self.pool.filter_task_proxies(tasks) - if self.get_run_mode() == RunMode.SIMULATION: - for itask in itasks: - if itask.state(*TASK_STATUSES_ACTIVE): - itask.state_reset(TASK_STATUS_FAILED) - self.data_store_mgr.delta_task_state(itask) - return len(bad_items) - self.task_job_mgr.kill_task_jobs(self.workflow, itasks) - return len(bad_items) - - def command_hold(self, tasks: Iterable[str]) -> int: - """Hold specified tasks.""" - return self.pool.hold_tasks(tasks) - - def command_set_hold_point(self, point: str) -> None: - """Hold all tasks after the specified cycle point.""" - cycle_point = TaskID.get_standardised_point(point) - if cycle_point is None: - raise CyclingError("Cannot set hold point to None") - LOG.info( - f"Setting hold cycle point: {cycle_point}\n" - "All tasks after this point will be held.") - self.pool.set_hold_point(cycle_point) - - def command_pause(self) -> None: - """Pause the workflow.""" - self.pause_workflow() - - @staticmethod - def command_set_verbosity(level: Union[int, str]) -> None: - """Set workflow verbosity.""" - try: - lvl = int(level) - LOG.setLevel(lvl) - except (TypeError, ValueError) as exc: - raise CommandFailedError(exc) - cylc.flow.flags.verbosity = log_level_to_verbosity(lvl) - - def command_remove_tasks(self, tasks: Iterable[str]) -> int: - """Remove tasks.""" - return self.pool.remove_tasks(tasks) - - async def command_reload_workflow(self) -> None: - """Reload workflow configuration.""" - # pause the workflow if not already - was_paused_before_reload = self.is_paused - if not was_paused_before_reload: - self.pause_workflow('Reloading workflow') - self.process_workflow_db_queue() # see #5593 - - # flush out preparing tasks before attempting reload - self.reload_pending = 'waiting for pending tasks to submit' - while self.release_queued_tasks(): - # Run the subset of main-loop functionality required to push - # preparing through the submission pipeline and keep the workflow - # responsive (e.g. to the `cylc stop` command). - - # NOTE: this reload method was called by process_command_queue - # which is called synchronously in the main loop so this call is - # blocking to other main loop functions - - # subproc pool - for issueing/tracking remote-init commands - self.proc_pool.process() - # task messages - for tracking task status changes - self.process_queued_task_messages() - # command queue - keeps the scheduler responsive - await self.process_command_queue() - # allows the scheduler to shutdown --now - await self.workflow_shutdown() - # keep the data store up to date with what's going on - await self.update_data_structure() - self.update_data_store() - # give commands time to complete - sleep(1) # give any remove-init's time to complete - - # reload the workflow definition - self.reload_pending = 'loading the workflow definition' - self.update_data_store() # update workflow status msg - self._update_workflow_state() - LOG.info("Reloading the workflow definition.") - try: - config = self.load_flow_file(is_reload=True) - except (ParsecError, CylcConfigError) as exc: - if cylc.flow.flags.verbosity > 1: - # log full traceback in debug mode - LOG.exception(exc) - LOG.critical( - f'Reload failed - {exc.__class__.__name__}: {exc}' - '\nThis is probably due to an issue with the new' - ' configuration.' - '\nTo continue with the pre-reload config, un-pause the' - ' workflow.' - '\nOtherwise, fix the configuration and attempt to reload' - ' again.' - ) - else: - self.reload_pending = 'applying the new config' - old_tasks = set(self.config.get_task_name_list()) - # Things that can't change on workflow reload: - self._set_workflow_params( - self.workflow_db_mgr.pri_dao.select_workflow_params() - ) - self.apply_new_config(config, is_reload=True) - self.broadcast_mgr.linearized_ancestors = ( - self.config.get_linearized_ancestors()) - - self.task_events_mgr.mail_interval = self.cylc_config['mail'][ - 'task event batch interval'] - self.task_events_mgr.mail_smtp = self._get_events_conf("smtp") - self.task_events_mgr.mail_footer = self._get_events_conf("footer") - - # Log tasks that have been added by the reload, removed tasks are - # logged by the TaskPool. - add = set(self.config.get_task_name_list()) - old_tasks - for task in add: - LOG.warning(f"Added task: '{task}'") - self.workflow_db_mgr.put_workflow_template_vars(self.template_vars) - self.workflow_db_mgr.put_runtime_inheritance(self.config) - self.workflow_db_mgr.put_workflow_params(self) - self.process_workflow_db_queue() # see #5593 - self.is_updated = True - self.is_reloaded = True - self._update_workflow_state() - - # Re-initialise data model on reload - self.data_store_mgr.initiate_data_model(self.is_reloaded) - - # Reset the remote init map to trigger fresh file installation - self.task_job_mgr.task_remote_mgr.remote_init_map.clear() - self.task_job_mgr.task_remote_mgr.is_reload = True - self.pool.reload_taskdefs(config) - # Load jobs from DB - self.workflow_db_mgr.pri_dao.select_jobs_for_restart( - self.data_store_mgr.insert_db_job - ) - if self.pool.compute_runahead(force=True): - self.pool.release_runahead_tasks() - self.is_reloaded = True - self.is_updated = True - - LOG.info("Reload completed.") - - # resume the workflow if previously paused - self.reload_pending = False - self.update_data_store() # update workflow status msg - self._update_workflow_state() - if not was_paused_before_reload: - self.resume_workflow() - self.process_workflow_db_queue() # see #5593 - def get_restart_num(self) -> int: """Return the number of the restart, else 0 if not a restart. @@ -1592,10 +1362,12 @@ async def workflow_shutdown(self): raise SchedulerError(self.stop_mode.value) else: raise SchedulerStop(self.stop_mode.value) - elif (self.time_next_kill is not None and - time() > self.time_next_kill): - self.command_poll_tasks(['*/*']) - self.command_kill_tasks(['*/*']) + elif ( + self.time_next_kill is not None + and time() > self.time_next_kill + ): + await commands.run_cmd(commands.poll_tasks, self, ['*/*']) + await commands.run_cmd(commands.kill_tasks, self, ['*/*']) self.time_next_kill = time() + self.INTERVAL_STOP_KILL # Is the workflow set to auto stop [+restart] now ... @@ -2138,40 +1910,6 @@ def resume_workflow(self, quiet: bool = False) -> None: self.workflow_db_mgr.put_workflow_paused(False) self.update_data_store() - def command_force_trigger_tasks( - self, - tasks: Iterable[str], - flow: List[str], - flow_wait: bool = False, - flow_descr: Optional[str] = None - ): - """Manual task trigger.""" - return self.pool.force_trigger_tasks( - tasks, flow, flow_wait, flow_descr) - - @command_validation.validate - def command_set( - self, - tasks: List[str], - flow: List[str], - outputs: Optional[List[str]] = None, - prerequisites: Optional[List[str]] = None, - flow_wait: bool = False, - flow_descr: Optional[str] = None - ): - """Force spawn task successors. - - Note, the "outputs" and "prerequisites" arguments might not be - populated in the mutation arguments so must provide defaults here. - """ - if outputs is None: - outputs = [] - if prerequisites is None: - prerequisites = [] - return self.pool.set_prereqs_and_outputs( - tasks, outputs, prerequisites, flow, flow_wait, flow_descr - ) - def _update_profile_info(self, category, amount, amount_format="%s"): """Update the 1, 5, 15 minute dt averages for a given category.""" now = time() diff --git a/cylc/flow/scripts/broadcast.py b/cylc/flow/scripts/broadcast.py index 28bd3bae4e3..c7e5d2a4f3b 100755 --- a/cylc/flow/scripts/broadcast.py +++ b/cylc/flow/scripts/broadcast.py @@ -80,6 +80,7 @@ """ from ansimarkup import parse as cparse +import asyncio from copy import deepcopy from functools import partial import os.path @@ -95,7 +96,7 @@ from cylc.flow.cfgspec.workflow import SPEC, upg from cylc.flow.exceptions import InputError from cylc.flow.network.client_factory import get_client -from cylc.flow.network.multi import call_multi +from cylc.flow.network.multi import call_multi_async from cylc.flow.option_parsers import ( WORKFLOW_ID_MULTI_ARG_DOC, CylcOptionParser as COP, @@ -451,25 +452,24 @@ async def run(options: 'Values', workflow_id): return ret -def report(ret): - for line in ret['stdout']: - print(line) - for line in ret['stderr']: - if line is not None: - print(line, file=sys.stderr) +def report(response): + return ( + '\n'.join(response['stdout']), + '\n'.join(line for line in response['stderr'] if line is not None), + response['exit'] == 0, + ) @cli_function(get_option_parser) def main(_, options: 'Values', *ids) -> None: - rets = call_multi( + rets = asyncio.run(_main(options, *ids)) + sys.exit(all(rets.values()) is False) + + +async def _main(options: 'Values', *ids): + return await call_multi_async( partial(run, options), *ids, constraint='workflows', report=report, ) - if all( - ret['exit'] == 0 - for ret in rets - ): - sys.exit(0) - sys.exit(1) diff --git a/cylc/flow/scripts/get_workflow_version.py b/cylc/flow/scripts/get_workflow_version.py index a4b30cb4b47..b96a8b07cec 100755 --- a/cylc/flow/scripts/get_workflow_version.py +++ b/cylc/flow/scripts/get_workflow_version.py @@ -25,6 +25,7 @@ from functools import partial from typing import TYPE_CHECKING +import sys from cylc.flow.network.client_factory import get_client from cylc.flow.network.multi import call_multi @@ -76,11 +77,12 @@ async def run(options, workflow_id, *_): @cli_function(get_option_parser) def main(parser: COP, options: 'Values', workflow_id: str) -> None: - call_multi( + rets = call_multi( partial(run, options), workflow_id, - report=print, + report=lambda x: (x, None, True), # we need the mixed format for call_multi but don't want any tasks constraint='mixed', max_tasks=0, ) + sys.exit(all(rets.values()) is False) diff --git a/cylc/flow/scripts/hold.py b/cylc/flow/scripts/hold.py index 8a7ade3c2e5..85d247355e9 100755 --- a/cylc/flow/scripts/hold.py +++ b/cylc/flow/scripts/hold.py @@ -62,6 +62,7 @@ """ from functools import partial +import sys from typing import TYPE_CHECKING from cylc.flow.exceptions import InputError @@ -161,13 +162,14 @@ async def run(options, workflow_id, *tokens_list): } } - await pclient.async_request('graphql', mutation_kwargs) + return await pclient.async_request('graphql', mutation_kwargs) @cli_function(get_option_parser) def main(parser: COP, options: 'Values', *ids): - call_multi( + rets = call_multi( partial(run, options), *ids, constraint='mixed', ) + sys.exit(all(rets.values()) is False) diff --git a/cylc/flow/scripts/kill.py b/cylc/flow/scripts/kill.py index 2af4c34200e..986fe749c4f 100755 --- a/cylc/flow/scripts/kill.py +++ b/cylc/flow/scripts/kill.py @@ -32,6 +32,7 @@ """ from functools import partial +import sys from typing import TYPE_CHECKING from cylc.flow.network.client_factory import get_client @@ -88,13 +89,14 @@ async def run(options: 'Values', workflow_id: str, *tokens_list: 'Tokens'): } } - await pclient.async_request('graphql', mutation_kwargs) + return await pclient.async_request('graphql', mutation_kwargs) @cli_function(get_option_parser) def main(parser: 'COP', options: 'Values', *ids: str): """CLI of "cylc kill".""" - call_multi( + rets = call_multi( partial(run, options), *ids, ) + sys.exit(all(rets.values()) is False) diff --git a/cylc/flow/scripts/pause.py b/cylc/flow/scripts/pause.py index e31781227cf..7e0d3014326 100644 --- a/cylc/flow/scripts/pause.py +++ b/cylc/flow/scripts/pause.py @@ -35,6 +35,7 @@ from functools import partial from typing import TYPE_CHECKING +import sys from cylc.flow.option_parsers import ( WORKFLOW_ID_MULTI_ARG_DOC, @@ -72,7 +73,7 @@ def get_option_parser() -> COP: return parser -async def run(options: 'Values', workflow_id: str) -> None: +async def run(options: 'Values', workflow_id: str): pclient = WorkflowRuntimeClient(workflow_id, timeout=options.comms_timeout) mutation_kwargs = { @@ -82,13 +83,14 @@ async def run(options: 'Values', workflow_id: str) -> None: } } - await pclient.async_request('graphql', mutation_kwargs) + return await pclient.async_request('graphql', mutation_kwargs) @cli_function(get_option_parser) def main(parser: COP, options: 'Values', *ids) -> None: - call_multi( + rets = call_multi( partial(run, options), *ids, constraint='workflows', ) + sys.exit(all(rets.values()) is False) diff --git a/cylc/flow/scripts/ping.py b/cylc/flow/scripts/ping.py index ce906b651e8..899151b1977 100755 --- a/cylc/flow/scripts/ping.py +++ b/cylc/flow/scripts/ping.py @@ -24,7 +24,6 @@ exit with success status, else exit with error status. """ -from ansimarkup import parse as cparse from functools import partial import sys from typing import Any, Dict, TYPE_CHECKING @@ -121,17 +120,18 @@ async def run( elif task_result['taskProxy']['state'] != TASK_STATUS_RUNNING: msg = f"task not {TASK_STATUS_RUNNING}: {string_id}" if msg: - ret['stderr'].append(cparse(f'{msg}')) + ret['stderr'].append(msg) ret['exit'] = 1 return ret -def report(ret): - for line in ret['stdout']: - print(line) - for line in ret['stderr']: - print(line, file=sys.stderr) +def report(response): + return ( + '\n'.join(response['stdout']), + '\n'.join(response['stderr']), + response['exit'] == 0, + ) @cli_function(get_option_parser) @@ -146,10 +146,4 @@ def main( report=report, constraint='mixed', ) - - if all( - ret['exit'] == 0 - for ret in rets - ): - sys.exit(0) - sys.exit(1) + sys.exit(all(rets.values()) is False) diff --git a/cylc/flow/scripts/poll.py b/cylc/flow/scripts/poll.py index 65ebb488f37..158aa3ada5b 100755 --- a/cylc/flow/scripts/poll.py +++ b/cylc/flow/scripts/poll.py @@ -34,6 +34,7 @@ """ from functools import partial +import sys from typing import TYPE_CHECKING from cylc.flow.network.client_factory import get_client @@ -88,12 +89,13 @@ async def run(options: 'Values', workflow_id: str, *tokens_list): } } - await pclient.async_request('graphql', mutation_kwargs) + return await pclient.async_request('graphql', mutation_kwargs) @cli_function(get_option_parser) def main(parser: COP, options: 'Values', *ids): - call_multi( + rets = call_multi( partial(run, options), *ids, ) + sys.exit(all(rets.values()) is False) diff --git a/cylc/flow/scripts/reinstall.py b/cylc/flow/scripts/reinstall.py index 58fd22bfc21..1c545944be4 100644 --- a/cylc/flow/scripts/reinstall.py +++ b/cylc/flow/scripts/reinstall.py @@ -156,12 +156,13 @@ def main( *ids: str ) -> None: """CLI wrapper.""" - call_multi( + rets = call_multi( partial(reinstall_cli, opts), *ids, constraint='workflows', - report=lambda x: print('Done') + report=lambda x: ('Done', None, True), ) + sys.exit(all(rets.values()) is False) async def reinstall_cli( diff --git a/cylc/flow/scripts/release.py b/cylc/flow/scripts/release.py index 2ecd8c6ba0b..6b6bb93370b 100755 --- a/cylc/flow/scripts/release.py +++ b/cylc/flow/scripts/release.py @@ -39,6 +39,7 @@ """ from functools import partial +import sys from typing import TYPE_CHECKING from cylc.flow.exceptions import InputError @@ -137,13 +138,14 @@ async def run(options: 'Values', workflow_id, *tokens_list): } } - await pclient.async_request('graphql', mutation_kwargs) + return await pclient.async_request('graphql', mutation_kwargs) @cli_function(get_option_parser) def main(parser: COP, options: 'Values', *ids): - call_multi( + rets = call_multi( partial(run, options), *ids, constraint='mixed', ) + sys.exit(all(rets.values()) is False) diff --git a/cylc/flow/scripts/reload.py b/cylc/flow/scripts/reload.py index 384b5e6579c..ca11062e9b4 100755 --- a/cylc/flow/scripts/reload.py +++ b/cylc/flow/scripts/reload.py @@ -52,6 +52,7 @@ """ from functools import partial +import sys from typing import TYPE_CHECKING from cylc.flow.network.client_factory import get_client @@ -89,7 +90,7 @@ def get_option_parser(): return parser -async def run(options: 'Values', workflow_id: str) -> None: +async def run(options: 'Values', workflow_id: str): pclient = get_client(workflow_id, timeout=options.comms_timeout) mutation_kwargs = { @@ -99,7 +100,7 @@ async def run(options: 'Values', workflow_id: str) -> None: } } - await pclient.async_request('graphql', mutation_kwargs) + return await pclient.async_request('graphql', mutation_kwargs) @cli_function(get_option_parser) @@ -108,8 +109,9 @@ def main(parser: COP, options: 'Values', *ids) -> None: def reload_cli(options: 'Values', *ids) -> None: - call_multi( + rets = call_multi( partial(run, options), *ids, constraint='workflows', ) + sys.exit(all(rets.values()) is False) diff --git a/cylc/flow/scripts/remove.py b/cylc/flow/scripts/remove.py index c9b9419c37b..ef4c74d02c8 100755 --- a/cylc/flow/scripts/remove.py +++ b/cylc/flow/scripts/remove.py @@ -22,6 +22,7 @@ """ from functools import partial +import sys from typing import TYPE_CHECKING from cylc.flow.network.client_factory import get_client @@ -77,12 +78,13 @@ async def run(options: 'Values', workflow_id: str, *tokens_list): } } - await pclient.async_request('graphql', mutation_kwargs) + return await pclient.async_request('graphql', mutation_kwargs) @cli_function(get_option_parser) def main(parser: COP, options: 'Values', *ids: str): - call_multi( + rets = call_multi( partial(run, options), *ids, ) + sys.exit(all(rets.values()) is False) diff --git a/cylc/flow/scripts/set.py b/cylc/flow/scripts/set.py index 4f00b458038..1c27bc81f20 100755 --- a/cylc/flow/scripts/set.py +++ b/cylc/flow/scripts/set.py @@ -89,14 +89,12 @@ """ from functools import partial +import sys from typing import Tuple, TYPE_CHECKING from cylc.flow.exceptions import InputError from cylc.flow.network.client_factory import get_client -from cylc.flow.network.multi import ( - call_multi, - print_response -) +from cylc.flow.network.multi import call_multi from cylc.flow.option_parsers import ( FULL_ID_MULTI_ARG_DOC, CylcOptionParser as COP, @@ -217,7 +215,7 @@ async def run( options: 'Values', workflow_id: str, *tokens_list -) -> None: +): validate_tokens(tokens_list) @@ -244,6 +242,5 @@ async def run( @cli_function(get_option_parser) def main(parser: COP, options: 'Values', *ids) -> None: - print_response( - call_multi(partial(run, options), *ids) - ) + rets = call_multi(partial(run, options), *ids) + sys.exit(all(rets.values()) is False) diff --git a/cylc/flow/scripts/stop.py b/cylc/flow/scripts/stop.py index 0b839bdea18..8154e52dc80 100755 --- a/cylc/flow/scripts/stop.py +++ b/cylc/flow/scripts/stop.py @@ -71,7 +71,6 @@ ClientTimeout, CylcError, InputError, - WorkflowStopped, ) from cylc.flow.network.client_factory import get_client from cylc.flow.network.multi import call_multi @@ -207,7 +206,7 @@ async def run( options: 'Values', workflow_id, *tokens_list, -) -> int: +) -> object: # parse the stop-task or stop-cycle if provided stop_task = stop_cycle = None if tokens_list: @@ -219,11 +218,7 @@ async def run( _validate(options, stop_task, stop_cycle, *tokens_list) - try: - pclient = get_client(workflow_id, timeout=options.comms_timeout) - except WorkflowStopped: - # nothing to do, return a success code - return 0 + pclient = get_client(workflow_id, timeout=options.comms_timeout) if int(options.max_polls) > 0: # (test to avoid the "nothing to do" warning for # --max-polls=0) @@ -258,12 +253,14 @@ async def run( } } - await pclient.async_request('graphql', mutation_kwargs) + ret = await pclient.async_request('graphql', mutation_kwargs) if int(options.max_polls) > 0 and not await spoller.poll(): # (test to avoid the "nothing to do" warning for # --max-polls=0) - return 1 - return 0 + raise CylcError( + f'Workflow did not shut down after {options.max_poll} polls' + ) + return ret @cli_function(get_option_parser) @@ -278,9 +275,4 @@ def main( constraint='mixed', max_tasks=1, ) - if all( - ret == 0 - for ret in rets - ): - sys.exit(0) - sys.exit(1) + sys.exit(all(rets.values()) is False) diff --git a/cylc/flow/scripts/trigger.py b/cylc/flow/scripts/trigger.py index f49c0d37e75..58c2f2a3939 100755 --- a/cylc/flow/scripts/trigger.py +++ b/cylc/flow/scripts/trigger.py @@ -42,8 +42,10 @@ """ from functools import partial +import sys from typing import TYPE_CHECKING +from cylc.flow import command_validation from cylc.flow.network.client_factory import get_client from cylc.flow.network.multi import call_multi from cylc.flow.option_parsers import ( @@ -52,7 +54,6 @@ ) from cylc.flow.terminal import cli_function from cylc.flow.flow_mgr import add_flow_opts -from cylc.flow.command_validation import validate_flow_opts if TYPE_CHECKING: @@ -108,14 +109,15 @@ async def run(options: 'Values', workflow_id: str, *tokens_list): 'flowDescr': options.flow_descr, } } - await pclient.async_request('graphql', mutation_kwargs) + return await pclient.async_request('graphql', mutation_kwargs) @cli_function(get_option_parser) def main(parser: COP, options: 'Values', *ids: str): """CLI for "cylc trigger".""" - validate_flow_opts(options.flow or ['all'], options.flow_wait) - call_multi( + command_validation.flow_opts(options.flow or ['all'], options.flow_wait) + rets = call_multi( partial(run, options), *ids, ) + sys.exit(all(rets.values()) is False) diff --git a/cylc/flow/scripts/verbosity.py b/cylc/flow/scripts/verbosity.py index 0479625c951..76c3e9af1d3 100755 --- a/cylc/flow/scripts/verbosity.py +++ b/cylc/flow/scripts/verbosity.py @@ -25,6 +25,7 @@ """ from functools import partial +import sys from typing import TYPE_CHECKING from cylc.flow import LOG_LEVELS @@ -69,7 +70,7 @@ def get_option_parser() -> COP: return parser -async def run(options: 'Values', severity: str, workflow_id: str) -> None: +async def run(options: 'Values', severity: str, workflow_id: str): pclient = get_client(workflow_id, timeout=options.comms_timeout) mutation_kwargs = { @@ -80,15 +81,16 @@ async def run(options: 'Values', severity: str, workflow_id: str) -> None: } } - await pclient.async_request('graphql', mutation_kwargs) + return await pclient.async_request('graphql', mutation_kwargs) @cli_function(get_option_parser) def main(parser: COP, options: 'Values', severity: str, *ids: str) -> None: if severity not in LOG_LEVELS: raise InputError(f"Illegal logging level, {severity}") - call_multi( + rets = call_multi( partial(run, options, severity), *ids, constraint='workflows', ) + sys.exit(all(rets.values()) is False) diff --git a/tests/integration/scripts/test_broadcast.py b/tests/integration/scripts/test_broadcast.py new file mode 100644 index 00000000000..67a79448041 --- /dev/null +++ b/tests/integration/scripts/test_broadcast.py @@ -0,0 +1,79 @@ +# THIS FILE IS PART OF THE CYLC WORKFLOW ENGINE. +# Copyright (C) NIWA & British Crown (Met Office) & Contributors. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from cylc.flow.option_parsers import Options +from cylc.flow.scripts.broadcast import _main, get_option_parser + + +BroadcastOptions = Options(get_option_parser()) + + +async def test_broadcast_multi( + one_conf, + flow, + scheduler, + start, + run_dir, + test_dir, + capsys, +): + """Test a multi-workflow broadcast command.""" + # create three workflows + one = scheduler(flow(one_conf)) + two = scheduler(flow(one_conf)) + thr = scheduler(flow(one_conf)) + + # the ID under which all three are installed + id_base = test_dir.relative_to(run_dir) + + async with start(one): + async with start(two): + async with start(thr): + capsys.readouterr() + + # test a successful broadcast command + rets = await _main( + BroadcastOptions(settings=['script=true']), f'{id_base}*' + ) + + # all three broadcasts should have succeeded + assert list(rets.values()) == [True, True, True] + + out, err = capsys.readouterr() + assert '[*/root] script=true' in out + assert err == '' + + # test an unsuccessful broadcast command + rets = await _main( + BroadcastOptions( + namespaces=['*'], + settings=['script=true'], + ), + f'{id_base}*', + ) + + # all three broadcasts should have failed + assert list(rets.values()) == [False, False, False] + + out, err = capsys.readouterr() + assert '[*/root] script=true' not in out + assert ( + # NOTE: for historical reasons this message goes to stdout + # not stderr + 'Rejected broadcast:' + ' settings are not compatible with the workflow' + ) in out + assert err == '' diff --git a/tests/integration/test_client.py b/tests/integration/test_client.py index 013d929e820..2195d3a112b 100644 --- a/tests/integration/test_client.py +++ b/tests/integration/test_client.py @@ -50,3 +50,41 @@ async def test_protobuf(harness): pb_data = PB_METHOD_MAP['pb_entire_workflow']() pb_data.ParseFromString(ret) assert schd.workflow in pb_data.workflow.id + + +async def test_command_validation_failure(harness): + """It should send the correct response if a command fails validation. + + Command arguments are validated before the command is queued. Any issues at + this stage will be communicated back via the mutation "result". + + See https://github.com/cylc/cylc-flow/pull/6112 + """ + schd, client = harness + + # run a mutation that will fail validation + response = await client.async_request( + 'graphql', + { + 'request_string': ''' + mutation { + set( + workflows: ["*"], + tasks: ["*"], + # this list of prerequisites fails validation: + prerequisites: ["1/a", "all"] + ) { + result + } + } + ''' + }, + ) + + # the validation error should be returned to the client + assert response['set']['result'] == [ + { + 'id': schd.id, + 'response': [False, '--pre=all must be used alone'], + } + ] diff --git a/tests/integration/test_queues.py b/tests/integration/test_queues.py index bf8def4e354..fc94c4c4a3d 100644 --- a/tests/integration/test_queues.py +++ b/tests/integration/test_queues.py @@ -1,6 +1,27 @@ +# THIS FILE IS PART OF THE CYLC WORKFLOW ENGINE. +# Copyright (C) NIWA & British Crown (Met Office) & Contributors. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from typing import TYPE_CHECKING + import pytest -from cylc.flow.scheduler import Scheduler +from cylc.flow import commands + +if TYPE_CHECKING: + from cylc.flow.scheduler import Scheduler @pytest.fixture @@ -99,7 +120,7 @@ async def test_queue_held_tasks( # hold all tasks and resume the workflow # (nothing should have run yet because the workflow started paused) - schd.command_hold(['*/*']) + await commands.run_cmd(commands.hold, schd, ['*/*']) schd.resume_workflow() # release queued tasks @@ -108,7 +129,7 @@ async def test_queue_held_tasks( assert len(submitted_tasks) == 0 # un-hold tasks - schd.command_release(['*/*']) + await commands.run_cmd(commands.release, schd, ['*/*']) # release queued tasks # (tasks should now be released from the queues) diff --git a/tests/integration/test_reload.py b/tests/integration/test_reload.py index 5bd07c17af4..65960ffcdb7 100644 --- a/tests/integration/test_reload.py +++ b/tests/integration/test_reload.py @@ -18,6 +18,7 @@ from contextlib import suppress +from cylc.flow import commands from cylc.flow.task_state import ( TASK_STATUS_WAITING, TASK_STATUS_PREPARING, @@ -88,7 +89,7 @@ def change_state(_=0): change_state() # reload the workflow - await schd.command_reload_workflow() + await commands.run_cmd(commands.reload_workflow, schd) # the task should end in the submitted state assert foo.state(TASK_STATUS_SUBMITTED) @@ -132,7 +133,7 @@ async def test_reload_failure( flow(two_conf, id_=id_) # reload the workflow - await schd.command_reload_workflow() + await commands.run_cmd(commands.reload_workflow, schd) # the reload should have failed but the workflow should still be # running diff --git a/tests/integration/test_resolvers.py b/tests/integration/test_resolvers.py index 17e09983dc3..cfdc8cafc3d 100644 --- a/tests/integration/test_resolvers.py +++ b/tests/integration/test_resolvers.py @@ -25,6 +25,7 @@ from cylc.flow import CYLC_LOG from cylc.flow.network.resolvers import Resolvers from cylc.flow.scheduler import Scheduler +from cylc.flow.workflow_status import StopMode @pytest.fixture @@ -228,7 +229,11 @@ async def test_command_logging(mock_flow, caplog, log_filter): caplog.set_level(logging.INFO, CYLC_LOG) - await mock_flow.resolvers._mutation_mapper("stop", {}, meta) + await mock_flow.resolvers._mutation_mapper( + "stop", + {'mode': StopMode.REQUEST_CLEAN.value}, + meta, + ) assert log_filter(caplog, contains='Command "stop" received') # put_messages: only log for owner diff --git a/tests/integration/test_scheduler.py b/tests/integration/test_scheduler.py index 49c5e07a9fc..502c13af8f6 100644 --- a/tests/integration/test_scheduler.py +++ b/tests/integration/test_scheduler.py @@ -21,6 +21,7 @@ import re from typing import Any, Callable +from cylc.flow import commands from cylc.flow.exceptions import CylcError from cylc.flow.parsec.exceptions import ParsecError from cylc.flow.scheduler import Scheduler, SchedulerStop @@ -172,7 +173,7 @@ async def test_holding_tasks_whilst_scheduler_paused( assert submitted_tasks == set() # hold all tasks & resume the workflow - one.command_hold(['*/*']) + await commands.run_cmd(commands.hold, one, ['*/*']) one.resume_workflow() # release queued tasks @@ -181,7 +182,7 @@ async def test_holding_tasks_whilst_scheduler_paused( assert submitted_tasks == set() # release all tasks - one.command_release(['*/*']) + await commands.run_cmd(commands.release, one, ['*/*']) # release queued tasks # (the task should be submitted) @@ -217,12 +218,12 @@ async def test_no_poll_waiting_tasks( polled_tasks = capture_polling(one) # Waiting tasks should not be polled. - one.command_poll_tasks(['*/*']) + await commands.run_cmd(commands.poll_tasks, one, ['*/*']) assert polled_tasks == set() # Even if they have a submit number. task.submit_num = 1 - one.command_poll_tasks(['*/*']) + await commands.run_cmd(commands.poll_tasks, one, ['*/*']) assert len(polled_tasks) == 0 # But these states should be: @@ -233,7 +234,7 @@ async def test_no_poll_waiting_tasks( TASK_STATUS_RUNNING ]: task.state.status = state - one.command_poll_tasks(['*/*']) + await commands.run_cmd(commands.poll_tasks, one, ['*/*']) assert len(polled_tasks) == 1 polled_tasks.clear() diff --git a/tests/integration/test_simulation.py b/tests/integration/test_simulation.py index 66842fade25..c7e1b42fe27 100644 --- a/tests/integration/test_simulation.py +++ b/tests/integration/test_simulation.py @@ -18,6 +18,7 @@ import pytest from pytest import param +from cylc.flow import commands from cylc.flow.cycling.iso8601 import ISO8601Point from cylc.flow.simulation import sim_time_check @@ -341,7 +342,7 @@ async def test_settings_reload( conf_file.read_text().replace('False', 'True')) # Reload Workflow: - await schd.command_reload_workflow() + await commands.run_cmd(commands.reload_workflow, schd) # Submit second psuedo-job and "run" to success: itask = run_simjob(schd, one_1066.point, 'one') diff --git a/tests/integration/test_stop_after_cycle_point.py b/tests/integration/test_stop_after_cycle_point.py index 663e03649f8..f92e8d449f0 100644 --- a/tests/integration/test_stop_after_cycle_point.py +++ b/tests/integration/test_stop_after_cycle_point.py @@ -27,6 +27,7 @@ from typing import Optional +from cylc.flow import commands from cylc.flow.cycling.integer import IntegerPoint from cylc.flow.id import Tokens from cylc.flow.workflow_status import StopMode @@ -117,7 +118,9 @@ def get_db_value(schd) -> Optional[str]: assert schd.config.stop_point == IntegerPoint('2') # override this value whilst the workflow is running - schd.command_stop( + await commands.run_cmd( + commands.stop, + schd, cycle_point=IntegerPoint('4'), mode=StopMode.REQUEST_CLEAN, ) diff --git a/tests/integration/test_task_pool.py b/tests/integration/test_task_pool.py index 5bfff271535..183532e0609 100644 --- a/tests/integration/test_task_pool.py +++ b/tests/integration/test_task_pool.py @@ -30,6 +30,7 @@ from json import loads from cylc.flow import CYLC_LOG +from cylc.flow import commands from cylc.flow.cycling.integer import IntegerPoint from cylc.flow.cycling.iso8601 import ISO8601Point from cylc.flow.data_store_mgr import TASK_PROXIES @@ -568,7 +569,7 @@ async def test_reload_stopcp( schd: 'Scheduler' = scheduler(flow(cfg)) async with start(schd): assert str(schd.pool.stop_point) == '2020' - await schd.command_reload_workflow() + await commands.run_cmd(commands.reload_workflow, schd) assert str(schd.pool.stop_point) == '2020' @@ -839,7 +840,7 @@ async def test_reload_prereqs( flow(conf, id_=id_) # Reload the workflow config - await schd.command_reload_workflow() + await commands.run_cmd(commands.reload_workflow, schd) assert list_tasks(schd) == expected_3 # Check resulting dependencies of task z @@ -970,7 +971,7 @@ async def test_graph_change_prereq_satisfaction( flow(conf, id_=id_) # Reload the workflow config - await schd.command_reload_workflow() + await commands.run_cmd(commands.reload_workflow, schd) await test.asend(schd) @@ -1967,7 +1968,7 @@ async def test_remove_by_suicide( ) # remove 1/b by request (cylc remove) - schd.command_remove_tasks(['1/b']) + await commands.run_cmd(commands.remove_tasks, schd, ['1/b']) assert log_filter( log, regex='1/b.*removed from active task pool: request', @@ -2009,7 +2010,7 @@ async def test_remove_no_respawn(flow, scheduler, start, log_filter): assert z1, '1/z should have been spawned after 1/a succeeded' # manually remove 1/z, it should be removed from the pool - schd.command_remove_tasks(['1/z']) + await commands.run_cmd(commands.remove_tasks, schd, ['1/z']) schd.workflow_db_mgr.process_queued_ops() z1 = schd.pool.get_task(IntegerPoint("1"), "z") assert z1 is None, '1/z should have been removed (by request)' diff --git a/tests/integration/test_workflow_db_mgr.py b/tests/integration/test_workflow_db_mgr.py index cf4fca7e064..774d8c21fac 100644 --- a/tests/integration/test_workflow_db_mgr.py +++ b/tests/integration/test_workflow_db_mgr.py @@ -14,12 +14,11 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -import asyncio import pytest import sqlite3 from typing import TYPE_CHECKING -from cylc.flow.cycling.iso8601 import ISO8601Point +from cylc.flow import commands if TYPE_CHECKING: from cylc.flow.scheduler import Scheduler @@ -37,7 +36,7 @@ async def test(expected_restart_num: int, do_reload: bool = False): schd: 'Scheduler' = scheduler(id_, paused_start=True) async with start(schd) as log: if do_reload: - schd.command_reload_workflow() + await commands.run_cmd(commands.reload_workflow, schd) assert schd.workflow_db_mgr.n_restart == expected_restart_num assert log_filter( log, contains=f"(re)start number={expected_restart_num + 1}" diff --git a/tests/integration/tui/test_mutations.py b/tests/integration/tui/test_mutations.py index 844a0654ab1..e9a41466d70 100644 --- a/tests/integration/tui/test_mutations.py +++ b/tests/integration/tui/test_mutations.py @@ -22,15 +22,6 @@ from cylc.flow.exceptions import ClientError -async def gen_commands(schd): - """Yield commands from the scheduler's command queue.""" - while True: - await asyncio.sleep(0.1) - if not schd.command_queue.empty(): - # (ignore first item: command UUID) - yield schd.command_queue.get()[1:] - - async def process_command(schd, tries=10, interval=0.1): """Wait for command(s) to be queued and run. @@ -62,12 +53,13 @@ async def test_online_mutation( start, rakiura, monkeypatch, + log_filter, ): """Test a simple workflow with one task.""" id_ = flow(one_conf, name='one') schd = scheduler(id_) with rakiura(size='80,15') as rk: - async with start(schd): + async with start(schd) as schd_log: await schd.update_data_structure() assert schd.command_queue.empty() @@ -98,10 +90,8 @@ async def test_online_mutation( rk.user_input('enter') # the mutation should be in the scheduler's command_queue - command = None - async for command in gen_commands(schd): - break - assert command == ('hold', [], {'tasks': ['1/one']}) + await asyncio.sleep(0) + assert log_filter(schd_log, contains="hold(tasks=['1/one'])") # close the dialogue and re-run the hold mutation rk.user_input('q', 'q', 'enter') diff --git a/tests/unit/network/test_multi.py b/tests/unit/network/test_multi.py new file mode 100644 index 00000000000..81e999b0c22 --- /dev/null +++ b/tests/unit/network/test_multi.py @@ -0,0 +1,132 @@ +# THIS FILE IS PART OF THE CYLC WORKFLOW ENGINE. +# Copyright (C) NIWA & British Crown (Met Office) & Contributors. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from functools import partial + +import pytest + +from cylc.flow.exceptions import CylcError, WorkflowStopped +from cylc.flow.network.multi import _report, _process_response +from cylc.flow.terminal import DIM + + +def response(success, msg, operation='set'): + return { + operation: { + 'result': [{'id': '~user/workflow', 'response': [success, msg]}] + } + } + + +def test_report_valid(monkeypatch): + """It should report command outcome.""" + monkeypatch.setattr('cylc.flow.flags.verbosity', 0) + + assert _report(response(False, 'MyError')) == (None, 'MyError', False) + assert _report(response(True, '12345')) == ('Command queued', None, True) + + monkeypatch.setattr('cylc.flow.flags.verbosity', 1) + assert ( + _report(response(True, '12345')) + == (f'Command queued <{DIM}>id=12345', None, True) + ) + + +def test_report_invalid(monkeypatch): + """It should report invalid responses. + + Tests that the code behaves as well as can be expected when confronted with + responses which should not be possible. + """ + # test "None" response + monkeypatch.setattr('cylc.flow.flags.verbosity', 0) + assert _report({'set': None}) == ( + None, + 'Error processing command:' + "\n TypeError: 'NoneType' object is not subscriptable", + False, + ) + + # test "None" response in debug mode + monkeypatch.setattr('cylc.flow.flags.verbosity', 2) + assert _report({'set': None}) == ( + None, + 'Error processing command:' + "\n TypeError: 'NoneType' object is not subscriptable" + # the response should be output in debug mode + "\n response={'set': None}", + False, + ) + + # test multiple mutations in one operation (not supported) + monkeypatch.setattr('cylc.flow.flags.verbosity', 0) + assert _report( + { + **response(True, '12345'), + **response(True, '23456', 'trigger'), + } + ) == ( + None, + 'Error processing command:' + '\n NotImplementedError:' + ' Cannot process multiple mutations in one operation.', + False, + ) + + # test zero mutations in the operation + assert _report( + {} + ) == ( + None, + 'Error processing command:' + '\n Exception: {}', + False, + ) + + +def test_process_response(monkeypatch): + """It should handle exceptions and return processed results.""" + def report(exception_class, _response): + raise exception_class('xxx') + + class Foo(Exception): + pass + + # WorkflowStopped -> expected error, log it + monkeypatch.setattr('cylc.flow.flags.verbosity', 0) + assert _process_response(partial(report, WorkflowStopped), {}) == ( + None, + 'WorkflowStopped: xxx is not running', + False, + ) + + # CylcError -> expected error, log it + monkeypatch.setattr('cylc.flow.flags.verbosity', 0) + assert _process_response(partial(report, CylcError), {}) == ( + None, + 'CylcError: xxx', + False, + ) + + # CylcError -> expected error, raise it (debug mode) + monkeypatch.setattr('cylc.flow.flags.verbosity', 2) + with pytest.raises(CylcError): + _process_response(partial(report, CylcError), {}) + + # Exception -> unexpected error, raise it + monkeypatch.setattr('cylc.flow.flags.verbosity', 0) + with pytest.raises(Foo): + _process_response(partial(report, Foo), {}) From eb97b2fedcba2fc2c5673623422fb04a02ec5246 Mon Sep 17 00:00:00 2001 From: Oliver Sanders Date: Thu, 6 Jun 2024 15:48:01 +0100 Subject: [PATCH 055/196] stop: handle WorkflowStopped exception differently --- cylc/flow/network/multi.py | 60 +++++++++++++++++++++----------- cylc/flow/scripts/stop.py | 10 ++++++ tests/unit/network/test_multi.py | 39 +++++++++++++++++---- 3 files changed, 82 insertions(+), 27 deletions(-) diff --git a/cylc/flow/network/multi.py b/cylc/flow/network/multi.py index 724a8cbc368..2b9ea418976 100644 --- a/cylc/flow/network/multi.py +++ b/cylc/flow/network/multi.py @@ -15,9 +15,8 @@ # along with this program. If not, see . import asyncio -from functools import partial import sys -from typing import Callable, Dict, List, Tuple, Optional, Union +from typing import Callable, Dict, List, Tuple, Optional, Union, Type from ansimarkup import ansiprint @@ -46,6 +45,7 @@ async def call_multi_async( ] = None, max_workflows=None, max_tasks=None, + success_exceptions: Optional[Tuple[Type]] = None, ) -> Dict[str, bool]: """Call a function for each workflow in a list of IDs. @@ -71,6 +71,10 @@ async def call_multi_async( Reporter functions are provided with the "response". They must return the outcome of the operation and may also return stdout/err text which will be written to the terminal. + success_exceptions: + An optional tuple of exceptions that can convey success outcomes. + E.G. a "WorkflowStopped" exception indicates an error state for + "cylc broadcast" but a success state for "cylc stop". Returns: {workflow_id: outcome} @@ -90,9 +94,9 @@ async def call_multi_async( if not report: report = _report if multi_mode: - reporter = partial(_report_multi, report) + reporter = _report_multi else: - reporter = partial(_report_single, report) + reporter = _report_single if constraint == 'workflows': workflow_args = { @@ -102,7 +106,7 @@ async def call_multi_async( # run coros results: Dict[str, bool] = {} - async for (workflow_id, *args), result in unordered_map( + async for (workflow_id, *args), response in unordered_map( fcn, ( (workflow_id, *args) @@ -112,19 +116,24 @@ async def call_multi_async( # (this way if one command errors, others may still run) wrap_exceptions=True, ): - results[workflow_id] = reporter(workflow_id, result) + # get outcome + out, err, outcome = _process_response( + report, response, success_exceptions + ) + # report outcome + reporter(workflow_id, out, err) + results[workflow_id] = outcome + return results def _report_multi( - report: Callable, workflow: str, response: Union[dict, Exception] -) -> bool: + workflow: str, out: Optional[str], err: Optional[str] +) -> None: """Report a response for a multi-workflow operation. This is called once for each workflow the operation is called against. """ - out, err, outcome = _process_response(report, response) - msg = f'{workflow}:' if out: out = out.replace('\n', '\n ') # indent @@ -137,26 +146,21 @@ def _report_multi( err = f'{msg} {err}' ansiprint(err, file=sys.stdout) - return outcome - def _report_single( - report: Callable, _workflow: str, response: Union[dict, Exception] -) -> bool: + workflow: str, out: Optional[str], err: Optional[str] +) -> None: """Report the response for a single-workflow operation.""" - out, err, outcome = _process_response(report, response) - if out: ansiprint(out) if err: ansiprint(err, file=sys.stderr) - return outcome - def _process_response( report: Callable, response: Union[dict, Exception], + success_exceptions: Optional[Tuple[Type]] = None, ) -> Tuple[Optional[str], Optional[str], bool]: """Handle exceptions and return processed results. @@ -169,16 +173,28 @@ def _process_response( report: The reporter function for extracting the result from the provided response. + success_exceptions: + An optional tuple of exceptions that can convey success outcomes. + E.G. a "WorkflowStopped" exception indicates an error state for + "cylc broadcast" but a success state for "cylc stop". Returns: (stdout, stderr, outcome) """ - if isinstance(response, WorkflowStopped): - # workflow stopped -> can't do anything + if success_exceptions and isinstance(response, success_exceptions): + # an exception was raised, however, that exception indicates a success + # outcome in this case + out = f'{response.__class__.__name__}: {response}' + err = None + outcome = True + + elif isinstance(response, WorkflowStopped): + # workflow stopped -> report differently to other CylcErrors out = None err = f'{response.__class__.__name__}: {response}' outcome = False + elif isinstance(response, CylcError): # exception -> report error if cylc.flow.flags.verbosity > 1: # debug mode @@ -186,9 +202,11 @@ def _process_response( out = None err = f'{response.__class__.__name__}: {response}' outcome = False + elif isinstance(response, Exception): # unexpected error -> raise raise response + else: try: # run the reporter to extract the operation outcome @@ -196,7 +214,7 @@ def _process_response( except Exception as exc: # an exception was raised in the reporter -> report this error the # same was as an error in the response - return _process_response(report, exc) + return _process_response(report, exc, success_exceptions) return out, err, outcome diff --git a/cylc/flow/scripts/stop.py b/cylc/flow/scripts/stop.py index 8154e52dc80..543bb257dea 100755 --- a/cylc/flow/scripts/stop.py +++ b/cylc/flow/scripts/stop.py @@ -71,6 +71,7 @@ ClientTimeout, CylcError, InputError, + WorkflowStopped, ) from cylc.flow.network.client_factory import get_client from cylc.flow.network.multi import call_multi @@ -206,6 +207,14 @@ async def run( options: 'Values', workflow_id, *tokens_list, +) -> object: + return await _run(options, workflow_id, *tokens_list) + + +async def _run( + options: 'Values', + workflow_id, + *tokens_list, ) -> object: # parse the stop-task or stop-cycle if provided stop_task = stop_cycle = None @@ -274,5 +283,6 @@ def main( *ids, constraint='mixed', max_tasks=1, + success_exceptions=(WorkflowStopped,), ) sys.exit(all(rets.values()) is False) diff --git a/tests/unit/network/test_multi.py b/tests/unit/network/test_multi.py index 81e999b0c22..e7d0f7a344a 100644 --- a/tests/unit/network/test_multi.py +++ b/tests/unit/network/test_multi.py @@ -35,13 +35,26 @@ def test_report_valid(monkeypatch): """It should report command outcome.""" monkeypatch.setattr('cylc.flow.flags.verbosity', 0) - assert _report(response(False, 'MyError')) == (None, 'MyError', False) - assert _report(response(True, '12345')) == ('Command queued', None, True) + # fail case + assert _report(response(False, 'MyError')) == ( + None, + 'MyError', + False, + ) + + # success case + assert _report(response(True, '12345')) == ( + 'Command queued', + None, + True, + ) + # success case (debug mode) monkeypatch.setattr('cylc.flow.flags.verbosity', 1) - assert ( - _report(response(True, '12345')) - == (f'Command queued <{DIM}>id=12345', None, True) + assert _report(response(True, '12345')) == ( + f'Command queued <{DIM}>id=12345', + None, + True, ) @@ -105,7 +118,7 @@ def report(exception_class, _response): class Foo(Exception): pass - # WorkflowStopped -> expected error, log it + # WorkflowStopped -> fail case monkeypatch.setattr('cylc.flow.flags.verbosity', 0) assert _process_response(partial(report, WorkflowStopped), {}) == ( None, @@ -113,6 +126,20 @@ class Foo(Exception): False, ) + # WorkflowStopped -> success case for this command + monkeypatch.setattr('cylc.flow.flags.verbosity', 0) + assert _process_response( + partial(report, WorkflowStopped), + {}, + # this overrides the default interpretation of "WorkflowStopped" as a + # fail case + success_exceptions=(WorkflowStopped,), + ) == ( + 'WorkflowStopped: xxx is not running', + None, + True, # success outcome + ) + # CylcError -> expected error, log it monkeypatch.setattr('cylc.flow.flags.verbosity', 0) assert _process_response(partial(report, CylcError), {}) == ( From e1f7205e4bdf83330f715a8edd47d0bc9d829538 Mon Sep 17 00:00:00 2001 From: Ronnie Dutta <61982285+MetRonnie@users.noreply.github.com> Date: Thu, 6 Jun 2024 15:13:48 +0100 Subject: [PATCH 056/196] Handle long-format ISO 8601 cycle points in IDs on the CLI --- changes.d/6123.fix.md | 1 + cylc/flow/id.py | 2 +- cylc/flow/id_cli.py | 38 ++++++++++++++++++++++++++++++++++++-- tests/unit/test_id.py | 2 +- tests/unit/test_id_cli.py | 35 +++++++++++++++++++++++++++++++++++ 5 files changed, 74 insertions(+), 4 deletions(-) create mode 100644 changes.d/6123.fix.md diff --git a/changes.d/6123.fix.md b/changes.d/6123.fix.md new file mode 100644 index 00000000000..cef4f24a709 --- /dev/null +++ b/changes.d/6123.fix.md @@ -0,0 +1 @@ +Allow long-format datetime cycle points in IDs used on the command line. \ No newline at end of file diff --git a/cylc/flow/id.py b/cylc/flow/id.py index cba3c483366..58fff7fa7bc 100644 --- a/cylc/flow/id.py +++ b/cylc/flow/id.py @@ -403,7 +403,7 @@ def duplicate( # //cycle[:sel][/task[:sel][/job[:sel]]] RELATIVE_PATTERN = rf''' // - (?P<{IDTokens.Cycle.value}>[^~\/:\n]+) + (?P<{IDTokens.Cycle.value}>[^~\/:\n][^~\/\n]*?) (?: : (?P<{IDTokens.Cycle.value}_sel>[^\/:\n]+) diff --git a/cylc/flow/id_cli.py b/cylc/flow/id_cli.py index ecce6517d3b..ef767cecc7f 100644 --- a/cylc/flow/id_cli.py +++ b/cylc/flow/id_cli.py @@ -20,6 +20,9 @@ import re from typing import Optional, Dict, List, Tuple, Any +from metomi.isodatetime.parsers import TimePointParser +from metomi.isodatetime.exceptions import ISO8601SyntaxError + from cylc.flow import LOG from cylc.flow.exceptions import ( InputError, @@ -28,6 +31,7 @@ from cylc.flow.id import ( Tokens, contains_multiple_workflows, + tokenise, upgrade_legacy_ids, ) from cylc.flow.pathutil import EXPLICIT_RELATIVE_PATH_REGEX @@ -43,6 +47,36 @@ FN_CHARS = re.compile(r'[\*\?\[\]\!]') +TP_PARSER = TimePointParser() + + +def cli_tokenise(id_: str) -> Tokens: + """Tokenise with support for long-format datetimes. + + If a cycle selector is present, it could be part of a long-format + ISO 8601 datetime that was erroneously split. Re-attach it if it + results in a valid datetime. + + Examples: + >>> f = lambda t: {k: v for k, v in t.items() if v is not None} + >>> f(cli_tokenise('foo//2021-01-01T00:00Z')) + {'workflow': 'foo', 'cycle': '2021-01-01T00:00Z'} + >>> f(cli_tokenise('foo//2021-01-01T00:horse')) + {'workflow': 'foo', 'cycle': '2021-01-01T00', 'cycle_sel': 'horse'} + """ + tokens = tokenise(id_) + cycle = tokens['cycle'] + cycle_sel = tokens['cycle_sel'] + if not (cycle and cycle_sel) or '-' not in cycle: + return tokens + cycle = f'{cycle}:{cycle_sel}' + try: + TP_PARSER.parse(cycle) + except ISO8601SyntaxError: + return tokens + dict.__setitem__(tokens, 'cycle', cycle) + del tokens['cycle_sel'] + return tokens def _parse_cli(*ids: str) -> List[Tokens]: @@ -124,14 +158,14 @@ def _parse_cli(*ids: str) -> List[Tokens]: tokens_list: List[Tokens] = [] for id_ in ids: try: - tokens = Tokens(id_) + tokens = cli_tokenise(id_) except ValueError: if id_.endswith('/') and not id_.endswith('//'): # noqa: SIM106 # tolerate IDs that end in a single slash on the CLI # (e.g. CLI auto completion) try: # this ID is invalid with or without the trailing slash - tokens = Tokens(id_[:-1]) + tokens = cli_tokenise(id_[:-1]) except ValueError: raise InputError(f'Invalid ID: {id_}') else: diff --git a/tests/unit/test_id.py b/tests/unit/test_id.py index 4d46bebf725..2d50c9a2706 100644 --- a/tests/unit/test_id.py +++ b/tests/unit/test_id.py @@ -186,7 +186,7 @@ def test_universal_id_matches_hierarchical(identifier): '//~', '//:', '//workflow//cycle', - '//task:task_sel:task_sel' + '//cycle/task:task_sel:task_sel' ] ) def test_relative_id_illegal(identifier): diff --git a/tests/unit/test_id_cli.py b/tests/unit/test_id_cli.py index fa8489f2465..8905bf3c4c4 100644 --- a/tests/unit/test_id_cli.py +++ b/tests/unit/test_id_cli.py @@ -30,6 +30,7 @@ _validate_constraint, _validate_workflow_ids, _validate_number, + cli_tokenise, parse_ids_async, ) from cylc.flow.pathutil import get_cylc_run_dir @@ -607,3 +608,37 @@ async def test_expand_workflow_tokens_impl_selector(no_scan): tokens = tokens.duplicate(workflow_sel='stopped') with pytest.raises(InputError): await _expand_workflow_tokens([tokens]) + + +@pytest.mark.parametrize('identifier, expected', [ + ( + '//2024-01-01T00:fail/a', + {'cycle': '2024-01-01T00', 'cycle_sel': 'fail', 'task': 'a'} + ), + ( + '//2024-01-01T00:00Z/a', + {'cycle': '2024-01-01T00:00Z', 'task': 'a'} + ), + ( + '//2024-01-01T00:00Z:fail/a', + {'cycle': '2024-01-01T00:00Z', 'cycle_sel': 'fail', 'task': 'a'} + ), + ( + '//2024-01-01T00:00:00+05:30/a', + {'cycle': '2024-01-01T00:00:00+05:30', 'task': 'a'} + ), + ( + '//2024-01-01T00:00:00+05:30:f/a', + {'cycle': '2024-01-01T00:00:00+05:30', 'cycle_sel': 'f', 'task': 'a'} + ), + ( + # Nonsensical example, but whatever... + '//2024-01-01T00:00Z:00Z/a', + {'cycle': '2024-01-01T00:00Z', 'cycle_sel': '00Z', 'task': 'a'} + ) +]) +def test_iso_long_fmt(identifier, expected): + assert { + k: v for k, v in cli_tokenise(identifier).items() + if v is not None + } == expected From 8690b03b0c7f6c30f5fa319ad82243ad2863daca Mon Sep 17 00:00:00 2001 From: Ronnie Dutta <61982285+MetRonnie@users.noreply.github.com> Date: Tue, 11 Jun 2024 16:55:30 +0100 Subject: [PATCH 057/196] Improve event handler doc (#6124) --- cylc/flow/cfgspec/workflow.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/cylc/flow/cfgspec/workflow.py b/cylc/flow/cfgspec/workflow.py index 1532c6ad5ed..1e1fb73f712 100644 --- a/cylc/flow/cfgspec/workflow.py +++ b/cylc/flow/cfgspec/workflow.py @@ -413,9 +413,10 @@ def get_script_common_text(this: str, example: Optional[str] = None): {item} = echo %(workflow)s # configure multiple event handlers + # (which will run in parallel) {item} = \\ - 'echo %(workflow)s, %(event)s', \\ - 'my_exe %(event)s %(message)s' \\ + 'echo %(workflow)s %(event)s', \\ + 'my_exe %(event)s %(message)s', \\ 'curl -X PUT -d event=%(event)s host:port' ''') elif item.startswith("abort on"): From 9c3cd49912d7c006572b62b6ae81320aca612e1a Mon Sep 17 00:00:00 2001 From: Tim Pillinger <26465611+wxtim@users.noreply.github.com> Date: Wed, 12 Jun 2024 12:17:22 +0100 Subject: [PATCH 058/196] platforms: fix unreachable hosts not reset on platform group failure (#6109) --- changes.d/fix.6109.md | 1 + cylc/flow/exceptions.py | 9 ++++- cylc/flow/platforms.py | 9 +++-- cylc/flow/task_job_mgr.py | 13 ++++++- cylc/flow/task_remote_mgr.py | 5 ++- tests/integration/test_platforms.py | 53 +++++++++++++++++++++++++++++ 6 files changed, 85 insertions(+), 5 deletions(-) create mode 100644 changes.d/fix.6109.md create mode 100644 tests/integration/test_platforms.py diff --git a/changes.d/fix.6109.md b/changes.d/fix.6109.md new file mode 100644 index 00000000000..36f22c3d4fc --- /dev/null +++ b/changes.d/fix.6109.md @@ -0,0 +1 @@ +Fixed bug affecting job submission where the list of bad hosts was not always reset correctly. \ No newline at end of file diff --git a/cylc/flow/exceptions.py b/cylc/flow/exceptions.py index 79a726d7bbe..1f9092c9a29 100644 --- a/cylc/flow/exceptions.py +++ b/cylc/flow/exceptions.py @@ -21,6 +21,7 @@ Callable, Dict, Iterable, + Set, NoReturn, Optional, Tuple, @@ -444,15 +445,21 @@ class NoPlatformsError(PlatformLookupError): Args: identity: The name of the platform group or install target + hosts_consumed: Hosts which have already been tried. set_type: Whether the set of platforms is a platform group or an install target place: Where the attempt to get the platform failed. """ def __init__( - self, identity: str, set_type: str = 'group', place: str = '' + self, + identity: str, + hosts_consumed: Set[str], + set_type: str = 'group', + place: str = '', ): self.identity = identity self.type = set_type + self.hosts_consumed = hosts_consumed if place: self.place = f' during {place}.' else: diff --git a/cylc/flow/platforms.py b/cylc/flow/platforms.py index 72ccebffe52..d06c84ade92 100644 --- a/cylc/flow/platforms.py +++ b/cylc/flow/platforms.py @@ -302,9 +302,14 @@ def get_platform_from_group( else: platform_names = group['platforms'] - # Return False if there are no platforms available to be selected. + # If there are no platforms available to be selected: if not platform_names: - raise NoPlatformsError(group_name) + hosts_consumed = { + host + for platform in group['platforms'] + for host in platform_from_name(platform)['hosts']} + raise NoPlatformsError( + group_name, hosts_consumed) # Get the selection method method = group['selection']['method'] diff --git a/cylc/flow/task_job_mgr.py b/cylc/flow/task_job_mgr.py index e41e05dfd30..019de580ea6 100644 --- a/cylc/flow/task_job_mgr.py +++ b/cylc/flow/task_job_mgr.py @@ -267,11 +267,13 @@ def submit_task_jobs(self, workflow, itasks, curve_auth, # Prepare tasks for job submission prepared_tasks, bad_tasks = self.prep_submit_task_jobs( workflow, itasks) + # Reset consumed host selection results self.task_remote_mgr.subshell_eval_reset() if not prepared_tasks: return bad_tasks + auth_itasks = {} # {platform: [itask, ...], ...} for itask in prepared_tasks: @@ -279,6 +281,7 @@ def submit_task_jobs(self, workflow, itasks, curve_auth, auth_itasks.setdefault(platform_name, []) auth_itasks[platform_name].append(itask) # Submit task jobs for each platform + # Non-prepared tasks can be considered done for now: done_tasks = bad_tasks for _, itasks in sorted(auth_itasks.items()): @@ -1087,7 +1090,7 @@ def _prep_submit_task_job( Returns: * itask - preparation complete. * None - preparation in progress. - * False - perparation failed. + * False - preparation failed. """ if itask.local_job_file_path: @@ -1181,6 +1184,14 @@ def _prep_submit_task_job( itask.summary['platforms_used'][itask.submit_num] = '' # Retry delays, needed for the try_num self._create_job_log_path(workflow, itask) + if isinstance(exc, NoPlatformsError): + # Clear all hosts from all platforms in group from + # bad_hosts: + self.bad_hosts -= exc.hosts_consumed + self._set_retry_timers(itask, rtconfig) + self._prep_submit_task_job_error( + workflow, itask, '(no platforms available)', exc) + return False self._prep_submit_task_job_error( workflow, itask, '(platform not defined)', exc) return False diff --git a/cylc/flow/task_remote_mgr.py b/cylc/flow/task_remote_mgr.py index 2797672617b..5d7c092336b 100644 --- a/cylc/flow/task_remote_mgr.py +++ b/cylc/flow/task_remote_mgr.py @@ -388,7 +388,10 @@ def remote_tidy(self) -> None: else: LOG.error( NoPlatformsError( - install_target, 'install target', 'remote tidy')) + install_target, + set(), + 'install target', + 'remote tidy')) # Wait for commands to complete for a max of 10 seconds timeout = time() + 10.0 while queue and time() < timeout: diff --git a/tests/integration/test_platforms.py b/tests/integration/test_platforms.py new file mode 100644 index 00000000000..f8187227ceb --- /dev/null +++ b/tests/integration/test_platforms.py @@ -0,0 +1,53 @@ +# THIS FILE IS PART OF THE CYLC WORKFLOW ENGINE. +# Copyright (C) NIWA & British Crown (Met Office) & Contributors. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +"""Integration testing for platforms functionality.""" + + +async def test_prep_submit_task_tries_multiple_platforms( + flow, scheduler, start, mock_glbl_cfg +): + """Preparation tries multiple platforms within a group if the + task platform setting matches a group, and that after all platforms + have been tried that the hosts matching that platform group are + cleared. + + See https://github.com/cylc/cylc-flow/pull/6109 + """ + global_conf = ''' + [platforms] + [[myplatform]] + hosts = broken + [[anotherbad]] + hosts = broken2 + [platform groups] + [[mygroup]] + platforms = myplatform, anotherbad''' + mock_glbl_cfg('cylc.flow.platforms.glbl_cfg', global_conf) + + wid = flow({ + "scheduling": {"graph": {"R1": "foo"}}, + "runtime": {"foo": {"platform": "mygroup"}} + }) + schd = scheduler(wid, run_mode='live') + async with start(schd): + itask = schd.pool.get_tasks()[0] + itask.submit_num = 1 + # simulate failed attempts to contact the job hosts + schd.task_job_mgr.bad_hosts = {'broken', 'broken2'} + res = schd.task_job_mgr._prep_submit_task_job(schd.workflow, itask) + assert res is False + # ensure the bad hosts have been cleared + assert not schd.task_job_mgr.bad_hosts From 48ece7d134e1f2fa3c95e47b5ec93fe8a9c18dcf Mon Sep 17 00:00:00 2001 From: Tim Pillinger <26465611+wxtim@users.noreply.github.com> Date: Wed, 12 Jun 2024 16:49:31 +0100 Subject: [PATCH 059/196] Update exceptions.py --- cylc/flow/exceptions.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/cylc/flow/exceptions.py b/cylc/flow/exceptions.py index 63ecca9afce..05efc541600 100644 --- a/cylc/flow/exceptions.py +++ b/cylc/flow/exceptions.py @@ -78,9 +78,7 @@ class InputError(CylcError): class CylcConfigError(CylcError): """Generic exception to handle an error in a Cylc configuration file.""" - # TODO: reference the configuration el - - ement causing the problem + # TODO: reference the configuration element causing the problem class WorkflowConfigError(CylcConfigError): From 9ed0cc989847abe837094609ea0f39023bdd9abe Mon Sep 17 00:00:00 2001 From: Tim Pillinger <26465611+wxtim@users.noreply.github.com> Date: Wed, 12 Jun 2024 16:52:20 +0100 Subject: [PATCH 060/196] Update exceptions.py --- cylc/flow/exceptions.py | 1 + 1 file changed, 1 insertion(+) diff --git a/cylc/flow/exceptions.py b/cylc/flow/exceptions.py index 05efc541600..7235455cace 100644 --- a/cylc/flow/exceptions.py +++ b/cylc/flow/exceptions.py @@ -21,6 +21,7 @@ Dict, Optional, Sequence, + Set, Union, TYPE_CHECKING, ) From bf84ad4fdc9d9ec642546c10d47580bf9013058b Mon Sep 17 00:00:00 2001 From: Oliver Sanders Date: Fri, 14 Jun 2024 09:31:55 +0100 Subject: [PATCH 061/196] set: fix validation issue --- cylc/flow/command_validation.py | 11 +++++++---- cylc/flow/scripts/set.py | 1 - 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/cylc/flow/command_validation.py b/cylc/flow/command_validation.py index 56fd5f47861..c7a9b2762dc 100644 --- a/cylc/flow/command_validation.py +++ b/cylc/flow/command_validation.py @@ -94,19 +94,22 @@ def prereqs(prereqs: Optional[List[str]]): ['1/foo:succeeded'] # Error: invalid format: - >>> prereqs(["fish"]) + >>> prereqs(["fish", "dog"]) Traceback (most recent call last): cylc.flow.exceptions.InputError: ... + * fish + * dog # Error: invalid format: >>> prereqs(["1/foo::bar"]) Traceback (most recent call last): cylc.flow.exceptions.InputError: ... + * 1/foo::bar # Error: "all" must be used alone: >>> prereqs(["all", "2/foo:baz"]) Traceback (most recent call last): - cylc.flow.exceptions.InputError: ... + cylc.flow.exceptions.InputError: --pre=all must be used alone """ if prereqs is None: @@ -122,8 +125,8 @@ def prereqs(prereqs: Optional[List[str]]): bad.append(pre) if bad: raise InputError( - "Use prerequisite format /:output\n" - "\n ".join(bad) + "Use prerequisite format /:output\n * " + + "\n * ".join(bad) ) if len(prereqs2) > 1: # noqa SIM102 (anticipates "cylc set --pre=cycle") diff --git a/cylc/flow/scripts/set.py b/cylc/flow/scripts/set.py index 1c27bc81f20..6c69d7235fd 100755 --- a/cylc/flow/scripts/set.py +++ b/cylc/flow/scripts/set.py @@ -216,7 +216,6 @@ async def run( workflow_id: str, *tokens_list ): - validate_tokens(tokens_list) pclient = get_client(workflow_id, timeout=options.comms_timeout) From 81653d810033e2b321e41f303db67ccfa91f2b98 Mon Sep 17 00:00:00 2001 From: Hilary James Oliver Date: Fri, 14 Jun 2024 23:05:58 +1200 Subject: [PATCH 062/196] Fix ID workflow count. (#6134) --- cylc/flow/id_cli.py | 8 ++++---- tests/unit/test_id_cli.py | 7 ++++++- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/cylc/flow/id_cli.py b/cylc/flow/id_cli.py index ef767cecc7f..9c7493fd612 100644 --- a/cylc/flow/id_cli.py +++ b/cylc/flow/id_cli.py @@ -465,14 +465,14 @@ def _infer_latest_runs(tokens_list, src_path, alt_run_dir=None): def _validate_number(*tokens_list, max_workflows=None, max_tasks=None): if not max_workflows and not max_tasks: return - workflows_count = 0 + workflows_seen = set() tasks_count = 0 for tokens in tokens_list: if tokens.is_task_like: tasks_count += 1 - else: - workflows_count += 1 - if max_workflows and workflows_count > max_workflows: + if tokens["workflow"] is not None: + workflows_seen.add(tokens["workflow"]) + if max_workflows and len(workflows_seen) > max_workflows: raise InputError( f'IDs contain too many workflows (max {max_workflows})' ) diff --git a/tests/unit/test_id_cli.py b/tests/unit/test_id_cli.py index 8905bf3c4c4..66c7c151e67 100644 --- a/tests/unit/test_id_cli.py +++ b/tests/unit/test_id_cli.py @@ -587,7 +587,12 @@ def test_validate_number(): _validate_number(t1, max_tasks=1) with pytest.raises(InputError): _validate_number(t1, t2, max_tasks=1) - + _validate_number(t1, max_tasks=1) + _validate_number(Tokens('a//1'), Tokens('a//2'), max_workflows=1) + _validate_number(Tokens('a'), Tokens('//2'), Tokens('//3'), max_workflows=1) + with pytest.raises(InputError): + _validate_number(Tokens('a//1'), Tokens('b//1'), max_workflows=1) + _validate_number(Tokens('a//1'), Tokens('b//1'), max_workflows=2) @pytest.fixture def no_scan(monkeypatch): From 8170b853529f8c7b53907a9453a7ce5e3fdc565f Mon Sep 17 00:00:00 2001 From: Oliver Sanders Date: Fri, 14 Jun 2024 15:57:47 +0100 Subject: [PATCH 063/196] comms: fix SSH comms * SSH->TCP client was missing the `ssh forward environment variables` key. * This PR gives it an empty placeholder value for now. --- cylc/flow/network/ssh_client.py | 1 + 1 file changed, 1 insertion(+) diff --git a/cylc/flow/network/ssh_client.py b/cylc/flow/network/ssh_client.py index f5065fc43a2..455b6ce7f2b 100644 --- a/cylc/flow/network/ssh_client.py +++ b/cylc/flow/network/ssh_client.py @@ -67,6 +67,7 @@ async def async_request( 'ssh command': ssh_cmd, 'cylc path': cylc_path, 'use login shell': login_sh, + 'ssh forward environment variables': [], } # NOTE: this can not raise NoHostsError # because we have provided the host From cd10adf8c18026415357f4f631a7b15e1654a6c5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 17 Jun 2024 12:49:16 +0100 Subject: [PATCH 064/196] Bump pypa/gh-action-pypi-publish from 1.8.14 to 1.9.0 (#6146) [skip ci] --- .github/workflows/2_auto_publish_release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/2_auto_publish_release.yml b/.github/workflows/2_auto_publish_release.yml index 08ad961a81c..b63a1e35920 100644 --- a/.github/workflows/2_auto_publish_release.yml +++ b/.github/workflows/2_auto_publish_release.yml @@ -38,7 +38,7 @@ jobs: uses: cylc/release-actions/build-python-package@v1 - name: Publish distribution to PyPI - uses: pypa/gh-action-pypi-publish@v1.8.14 + uses: pypa/gh-action-pypi-publish@v1.9.0 with: user: __token__ # uses the API token feature of PyPI - least permissions possible password: ${{ secrets.PYPI_TOKEN }} From 6eb8f61c5b9777cb21ac68eff13aa4f0722f691a Mon Sep 17 00:00:00 2001 From: Hilary James Oliver Date: Mon, 17 Jun 2024 13:53:10 +0000 Subject: [PATCH 065/196] Fix workflow-state command and xtrigger. (#5809) undefined --- changes.d/5809.feat.d | 1 + changes.d/5809.fix.d | 2 + cylc/flow/cfgspec/workflow.py | 88 ++- cylc/flow/command_polling.py | 73 +-- cylc/flow/config.py | 81 ++- cylc/flow/cycling/util.py | 7 +- cylc/flow/data_store_mgr.py | 12 +- cylc/flow/dbstatecheck.py | 346 ++++++++--- .../examples/event-driven-cycling/.validate | 7 +- .../inter-workflow-triggers/.validate | 7 +- .../downstream/flow.cylc | 2 +- cylc/flow/exceptions.py | 9 +- cylc/flow/graph_parser.py | 9 +- cylc/flow/option_parsers.py | 13 +- cylc/flow/parsec/upgrade.py | 22 +- cylc/flow/parsec/validate.py | 5 +- cylc/flow/rundb.py | 24 +- cylc/flow/scheduler.py | 1 - cylc/flow/scripts/function_run.py | 9 +- cylc/flow/scripts/validate_install_play.py | 5 +- cylc/flow/scripts/validate_reinstall.py | 4 + cylc/flow/scripts/workflow_state.py | 570 ++++++++++++------ cylc/flow/subprocctx.py | 8 +- cylc/flow/subprocpool.py | 4 +- cylc/flow/task_events_mgr.py | 10 +- cylc/flow/task_job_mgr.py | 6 +- cylc/flow/task_outputs.py | 57 +- cylc/flow/task_pool.py | 85 ++- cylc/flow/util.py | 21 +- cylc/flow/workflow_db_mgr.py | 34 +- cylc/flow/xtrigger_mgr.py | 458 ++++++++------ cylc/flow/xtriggers/suite_state.py | 12 +- cylc/flow/xtriggers/workflow_state.py | 212 +++++-- .../cylc-poll/16-execution-time-limit.t | 2 +- tests/flakyfunctional/events/44-timeout.t | 2 +- .../xtriggers/01-workflow_state.t | 27 +- .../xtriggers/01-workflow_state/flow.cylc | 9 +- .../01-workflow_state/upstream/flow.cylc | 4 +- tests/functional/cylc-cat-log/04-local-tail.t | 2 +- tests/functional/cylc-config/00-simple.t | 5 +- .../cylc-config/00-simple/section2.stdout | 52 +- .../cylc-play/07-timezones-compat.t | 5 +- .../cylc-set/00-set-succeeded/flow.cylc | 8 +- tests/functional/cylc-set/05-expire.t | 2 +- .../data-store/00-prune-optional-break.t | 4 +- .../01-cylc8-basic/validation.stderr | 7 +- .../functional/flow-triggers/11-wait-merge.t | 16 +- tests/functional/job-submission/16-timeout.t | 2 +- tests/functional/logging/04-dev_mode.t | 4 +- .../optional-outputs/08-finish-fail-c7-c8.t | 2 +- tests/functional/queues/02-queueorder.t | 2 +- tests/functional/queues/qsize/flow.cylc | 8 +- tests/functional/reload/03-queues/flow.cylc | 6 +- .../reload/22-remove-task-cycling.t | 2 +- tests/functional/restart/30-outputs.t | 2 +- tests/functional/restart/30-outputs/flow.cylc | 4 +- .../restart/34-auto-restart-basic.t | 14 +- .../restart/38-auto-restart-stopping.t | 3 +- .../restart/41-auto-restart-local-jobs.t | 5 +- tests/functional/workflow-state/00-polling.t | 19 +- tests/functional/workflow-state/01-polling.t | 8 +- tests/functional/workflow-state/05-message.t | 34 -- tests/functional/workflow-state/05-output.t | 32 + tests/functional/workflow-state/06-format.t | 24 +- .../functional/workflow-state/06a-noformat.t | 29 +- tests/functional/workflow-state/07-message2.t | 11 +- tests/functional/workflow-state/08-integer.t | 82 +++ tests/functional/workflow-state/09-datetime.t | 121 ++++ .../functional/workflow-state/10-backcompat.t | 54 ++ tests/functional/workflow-state/11-multi.t | 130 ++++ .../functional/workflow-state/11-multi/c7.sql | 39 ++ .../workflow-state/11-multi/c8a.sql | 48 ++ .../workflow-state/11-multi/c8b.sql | 48 ++ .../workflow-state/11-multi/flow.cylc | 69 +++ .../workflow-state/11-multi/reference.log | 17 + .../workflow-state/11-multi/upstream/suite.rc | 17 + .../workflow-state/backcompat/schema-1.sql | 49 ++ .../workflow-state/backcompat/schema-2.sql | 49 ++ .../workflow-state/backcompat/suite.rc | 16 + .../workflow-state/datetime/flow.cylc | 21 + .../workflow-state/integer/flow.cylc | 14 + .../workflow-state/options/flow.cylc | 6 +- .../{message => output}/flow.cylc | 0 .../{message => output}/reference.log | 0 .../workflow-state/polling/flow.cylc | 5 +- .../workflow-state/template_ref/flow.cylc | 13 - .../workflow-state/template_ref/reference.log | 4 - tests/functional/xtriggers/03-sequence.t | 1 - tests/functional/xtriggers/04-sequential.t | 17 +- tests/integration/conftest.py | 124 ++-- .../scripts/test_validate_integration.py | 4 +- tests/integration/test_config.py | 8 +- tests/integration/test_dbstatecheck.py | 139 +++++ .../integration/test_sequential_xtriggers.py | 2 +- tests/integration/test_xtrigger_mgr.py | 43 ++ tests/unit/cycling/test_util.py | 8 +- tests/unit/test_config.py | 16 +- tests/unit/test_db_compat.py | 4 +- tests/unit/test_graph_parser.py | 3 +- tests/unit/test_util.py | 6 +- tests/unit/test_xtrigger_mgr.py | 118 ++-- tests/unit/xtriggers/test_workflow_state.py | 244 ++++++-- tox.ini | 4 +- 103 files changed, 2940 insertions(+), 1163 deletions(-) create mode 100644 changes.d/5809.feat.d create mode 100644 changes.d/5809.fix.d delete mode 100755 tests/functional/workflow-state/05-message.t create mode 100755 tests/functional/workflow-state/05-output.t create mode 100755 tests/functional/workflow-state/08-integer.t create mode 100755 tests/functional/workflow-state/09-datetime.t create mode 100755 tests/functional/workflow-state/10-backcompat.t create mode 100644 tests/functional/workflow-state/11-multi.t create mode 100644 tests/functional/workflow-state/11-multi/c7.sql create mode 100644 tests/functional/workflow-state/11-multi/c8a.sql create mode 100644 tests/functional/workflow-state/11-multi/c8b.sql create mode 100644 tests/functional/workflow-state/11-multi/flow.cylc create mode 100644 tests/functional/workflow-state/11-multi/reference.log create mode 100644 tests/functional/workflow-state/11-multi/upstream/suite.rc create mode 100644 tests/functional/workflow-state/backcompat/schema-1.sql create mode 100644 tests/functional/workflow-state/backcompat/schema-2.sql create mode 100644 tests/functional/workflow-state/backcompat/suite.rc create mode 100644 tests/functional/workflow-state/datetime/flow.cylc create mode 100644 tests/functional/workflow-state/integer/flow.cylc rename tests/functional/workflow-state/{message => output}/flow.cylc (100%) rename tests/functional/workflow-state/{message => output}/reference.log (100%) delete mode 100644 tests/functional/workflow-state/template_ref/flow.cylc delete mode 100644 tests/functional/workflow-state/template_ref/reference.log create mode 100644 tests/integration/test_dbstatecheck.py diff --git a/changes.d/5809.feat.d b/changes.d/5809.feat.d new file mode 100644 index 00000000000..c5e7d0afe5e --- /dev/null +++ b/changes.d/5809.feat.d @@ -0,0 +1 @@ +The workflow-state command and xtrigger are now flow-aware and take universal IDs instead of separate arguments for cycle point, task name, etc. (which are still supported, but deprecated). diff --git a/changes.d/5809.fix.d b/changes.d/5809.fix.d new file mode 100644 index 00000000000..36fc5fbf481 --- /dev/null +++ b/changes.d/5809.fix.d @@ -0,0 +1,2 @@ +Fix bug where the "cylc workflow-state" command only polled for +task-specific status queries and custom outputs. diff --git a/cylc/flow/cfgspec/workflow.py b/cylc/flow/cfgspec/workflow.py index 1e1fb73f712..934897bdbb4 100644 --- a/cylc/flow/cfgspec/workflow.py +++ b/cylc/flow/cfgspec/workflow.py @@ -1020,8 +1020,9 @@ def get_script_common_text(this: str, example: Optional[str] = None): task has generated the outputs it was expected to. If the task fails this check its outputs are considered - :term:`incomplete` and a warning will be raised alerting you - that something has gone wrong which requires investigation. + :term:`incomplete ` and a warning will be + raised alerting you that something has gone wrong which + requires investigation. .. note:: @@ -1731,57 +1732,34 @@ def get_script_common_text(this: str, example: Optional[str] = None): ''') with Conf('workflow state polling', desc=f''' - Configure automatic workflow polling tasks as described in - :ref:`WorkflowStatePolling`. - - The items in this section reflect - options and defaults of the ``cylc workflow-state`` command, - except that the target workflow ID and the - ``--task``, ``--cycle``, and ``--status`` options are - taken from the graph notation. + Deprecated support for automatic workflow state polling tasks + as described in :ref:`WorkflowStatePolling`. Note the Cylc 7 + "user" and "host" config items are not supported. .. versionchanged:: 8.0.0 {REPLACES}``[runtime][]suite state polling``. - '''): - Conf('user', VDR.V_STRING, desc=''' - Username of your account on the workflow host. - The polling - ``cylc workflow-state`` command will be - run on the remote account. - ''') - Conf('host', VDR.V_STRING, desc=''' - The hostname of the target workflow. + .. deprecated:: 8.3.0 - The polling - ``cylc workflow-state`` command will be run there. - ''') + Please use the :ref:`workflow_state xtrigger + ` instead. + '''): Conf('interval', VDR.V_INTERVAL, desc=''' Polling interval. ''') Conf('max-polls', VDR.V_INTEGER, desc=''' - The maximum number of polls before timing out and entering - the "failed" state. + Maximum number of polls to attempt before the task fails. ''') Conf('message', VDR.V_STRING, desc=''' - Wait for the task in the target workflow to receive a - specified message rather than achieve a state. + Target task output (task message, not trigger name). ''') - Conf('run-dir', VDR.V_STRING, desc=''' - Specify the location of the top level cylc-run directory - for the other workflow. - - For your own workflows, there is no need to set this as it - is always ``~/cylc-run/``. But for other workflows, - (e.g those owned by others), or mirrored workflow databases - use this item to specify the location of the top level - cylc run directory (the database should be in a the same - place relative to this location for each workflow). + Conf('alt-cylc-run-dir', VDR.V_STRING, desc=''' + The cylc-run directory location of the target workflow. + Use to poll workflows owned by other users. ''') Conf('verbose mode', VDR.V_BOOLEAN, desc=''' - Run the polling ``cylc workflow-state`` command in verbose - output mode. + Run the ``cylc workflow-state`` command in verbose mode. ''') with Conf('environment', desc=''' @@ -1958,9 +1936,10 @@ def upg(cfg, descr): """ u = upgrader(cfg, descr) + u.obsolete( - '7.8.0', - ['runtime', '__MANY__', 'suite state polling', 'template']) + '7.8.0', ['runtime', '__MANY__', 'suite state polling', 'template'] + ) u.obsolete('7.8.1', ['cylc', 'events', 'reset timer']) u.obsolete('7.8.1', ['cylc', 'events', 'reset inactivity timer']) u.obsolete('8.0.0', ['cylc', 'force run mode']) @@ -1996,6 +1975,25 @@ def upg(cfg, descr): ['cylc', 'mail', 'task event batch interval'], silent=cylc.flow.flags.cylc7_back_compat, ) + u.deprecate( + '8.0.0', + ['runtime', '__MANY__', 'suite state polling'], + ['runtime', '__MANY__', 'workflow state polling'], + silent=cylc.flow.flags.cylc7_back_compat, + is_section=True, + ) + u.obsolete( + '8.0.0', ['runtime', '__MANY__', 'workflow state polling', 'host']) + u.obsolete( + '8.0.0', ['runtime', '__MANY__', 'workflow state polling', 'user']) + + u.deprecate( + '8.3.0', + ['runtime', '__MANY__', 'workflow state polling', 'run-dir'], + ['runtime', '__MANY__', 'workflow state polling', 'alt-cylc-run-dir'], + silent=cylc.flow.flags.cylc7_back_compat, + ) + u.deprecate( '8.0.0', ['cylc', 'parameters'], @@ -2063,14 +2061,6 @@ def upg(cfg, descr): silent=cylc.flow.flags.cylc7_back_compat, ) - u.deprecate( - '8.0.0', - ['runtime', '__MANY__', 'suite state polling'], - ['runtime', '__MANY__', 'workflow state polling'], - silent=cylc.flow.flags.cylc7_back_compat, - is_section=True - ) - for job_setting in [ 'execution polling intervals', 'execution retry delays', @@ -2196,7 +2186,7 @@ def upgrade_graph_section(cfg: Dict[str, Any], descr: str) -> None: keys.add(key) if keys and not cylc.flow.flags.cylc7_back_compat: msg = ( - 'deprecated graph items were automatically upgraded ' + 'graph items were automatically upgraded ' f'in "{descr}":\n' f' * (8.0.0) {msg_old} -> {msg_new}' ) diff --git a/cylc/flow/command_polling.py b/cylc/flow/command_polling.py index dcf186edbd9..1c70e7c59a9 100644 --- a/cylc/flow/command_polling.py +++ b/cylc/flow/command_polling.py @@ -17,6 +17,7 @@ import sys from time import sleep +from cylc.flow import LOG class Poller: @@ -25,39 +26,30 @@ class Poller: @classmethod def add_to_cmd_options(cls, parser, d_interval=60, d_max_polls=10): - """Add command line options for commands that can do polling""" + """Add command line options for commands that can do polling.""" parser.add_option( "--max-polls", help=r"Maximum number of polls (default: %default).", + type="int", metavar="INT", action="store", dest="max_polls", - default=d_max_polls) + default=d_max_polls + ) parser.add_option( "--interval", help=r"Polling interval in seconds (default: %default).", + type="int", metavar="SECS", action="store", dest="interval", - default=d_interval) + default=d_interval + ) def __init__(self, condition, interval, max_polls, args): - self.condition = condition # e.g. "workflow stopped" - - # check max_polls is an int - try: - self.max_polls = int(max_polls) - except ValueError: - sys.exit("max_polls must be an int") - - # check interval is an int - try: - self.interval = int(interval) - except ValueError: - sys.exit("interval must be an integer") - - self.n_polls = 0 + self.interval = interval + self.max_polls = max_polls or 1 # no point in zero polls self.args = args # any extra parameters needed by check() async def check(self): @@ -66,29 +58,28 @@ async def check(self): async def poll(self): """Poll for the condition embodied by self.check(). - Return True if condition met, or False if polling exhausted.""" - if self.max_polls == 0: - # exit 1 as we can't know if the condition is satisfied - sys.exit("WARNING: nothing to do (--max-polls=0)") - elif self.max_polls == 1: - sys.stdout.write("checking for '%s'" % self.condition) - else: - sys.stdout.write("polling for '%s'" % self.condition) + Return True if condition met, or False if polling exhausted. + + """ + n_polls = 0 + result = False + + while True: + n_polls += 1 + result = await self.check() + if self.max_polls != 1: + sys.stderr.write(".") + sys.stderr.flush() + if result or n_polls >= self.max_polls: + if self.max_polls != 1: + sys.stderr.write("\n") + sys.stderr.flush() + break + sleep(self.interval) - while self.n_polls < self.max_polls: - self.n_polls += 1 - if await self.check(): - sys.stdout.write(": satisfied\n") - return True - if self.max_polls > 1: - sys.stdout.write(".") - sleep(self.interval) - sys.stdout.write("\n") - if self.max_polls > 1: - sys.stderr.write( - "ERROR: condition not satisfied after %d polls\n" % - self.max_polls) + if result: + return True else: - sys.stderr.write("ERROR: condition not satisfied\n") - return False + LOG.error(f"failed after {n_polls} polls") + return False diff --git a/cylc/flow/config.py b/cylc/flow/config.py index 096d83d69ad..5b7738f3e6c 100644 --- a/cylc/flow/config.py +++ b/cylc/flow/config.py @@ -115,7 +115,7 @@ check_deprecation, ) from cylc.flow.workflow_status import RunMode -from cylc.flow.xtrigger_mgr import XtriggerManager +from cylc.flow.xtrigger_mgr import XtriggerCollator if TYPE_CHECKING: from optparse import Values @@ -220,7 +220,6 @@ def __init__( options: 'Values', template_vars: Optional[Mapping[str, Any]] = None, output_fname: Optional[str] = None, - xtrigger_mgr: Optional[XtriggerManager] = None, mem_log_func: Optional[Callable[[str], None]] = None, run_dir: Optional[str] = None, log_dir: Optional[str] = None, @@ -262,7 +261,7 @@ def __init__( self.taskdefs: Dict[str, TaskDef] = {} self.expiration_offsets = {} self.ext_triggers = {} # Old external triggers (client/server) - self.xtrigger_mgr = xtrigger_mgr + self.xtrigger_collator = XtriggerCollator() self.workflow_polling_tasks = {} # type: ignore # TODO figure out type self.initial_point: 'PointBase' @@ -1513,6 +1512,19 @@ def adopt_orphans(self, orphans): self.runtime['linearized ancestors'][orphan] = [orphan, 'root'] def configure_workflow_state_polling_tasks(self): + + # Deprecation warning - automatic workflow state polling tasks don't + # necessarily have deprecated config items outside the graph string. + if ( + self.workflow_polling_tasks and + getattr(self.options, 'is_validate', False) + ): + LOG.warning( + "Workflow state polling tasks are deprecated." + " Please convert to workflow_state xtriggers:\n * " + + "\n * ".join(self.workflow_polling_tasks) + ) + # Check custom script not defined for automatic workflow polling tasks. for l_task in self.workflow_polling_tasks: try: @@ -1531,25 +1543,42 @@ def configure_workflow_state_polling_tasks(self): continue rtc = tdef.rtconfig comstr = ( - "cylc workflow-state" - f" --task={tdef.workflow_polling_cfg['task']}" - " --point=$CYLC_TASK_CYCLE_POINT" + "cylc workflow-state " + f"{tdef.workflow_polling_cfg['workflow']}//" + "$CYLC_TASK_CYCLE_POINT/" + f"{tdef.workflow_polling_cfg['task']}" ) + graph_selector = tdef.workflow_polling_cfg['status'] + config_message = rtc['workflow state polling']['message'] + if ( + graph_selector is not None and + ( + config_message is not None + ) and ( + graph_selector != config_message + ) + ): + raise WorkflowConfigError( + f'Polling task "{name}" must configure a target status or' + f' output message in the graph (:{graph_selector}) or task' + f' definition (message = "{config_message}") but not both.' + ) + if graph_selector is not None: + comstr += f":{graph_selector}" + elif config_message is not None: + # quote: may contain spaces + comstr += f':"{config_message}" --messages' + else: + # default to :succeeded + comstr += f":{TASK_OUTPUT_SUCCEEDED}" + for key, fmt in [ - ('user', ' --%s=%s'), - ('host', ' --%s=%s'), ('interval', ' --%s=%d'), ('max-polls', ' --%s=%s'), - ('run-dir', ' --%s=%s')]: + ('alt-cylc-run-dir', ' --%s=%s')]: if rtc['workflow state polling'][key]: comstr += fmt % (key, rtc['workflow state polling'][key]) - if rtc['workflow state polling']['message']: - comstr += " --message='%s'" % ( - rtc['workflow state polling']['message']) - else: - comstr += " --status=" + tdef.workflow_polling_cfg['status'] - comstr += " " + tdef.workflow_polling_cfg['workflow'] - script = "echo " + comstr + "\n" + comstr + script = f"echo {comstr}\n{comstr}" rtc['script'] = script def get_parent_lists(self): @@ -1905,10 +1934,9 @@ def generate_triggers(self, lexpression, left_nodes, right, seq, f'Invalid xtrigger name "{label}" - {msg}' ) - if self.xtrigger_mgr is not None: - self.xtrigger_mgr.sequential_xtriggers_default = ( - self.cfg['scheduling']['sequential xtriggers'] - ) + self.xtrigger_collator.sequential_xtriggers_default = ( + self.cfg['scheduling']['sequential xtriggers'] + ) for label in xtrig_labels: try: xtrig = xtrigs[label] @@ -1928,13 +1956,7 @@ def generate_triggers(self, lexpression, left_nodes, right, seq, f" {label} = {xtrig.get_signature()}" ) - # Generic xtrigger validation. - XtriggerManager.check_xtrigger(label, xtrig, self.fdir) - - if self.xtrigger_mgr: - # (not available during validation) - self.xtrigger_mgr.add_trig(label, xtrig, self.fdir) - + self.xtrigger_collator.add_trig(label, xtrig, self.fdir) self.taskdefs[right].add_xtrig_label(label, seq) def get_actual_first_point(self, start_point): @@ -2624,10 +2646,7 @@ def upgrade_clock_triggers(self): # Define the xtrigger function. args = [] if offset is None else [offset] xtrig = SubFuncContext(label, 'wall_clock', args, {}) - if self.xtrigger_mgr is None: - XtriggerManager.check_xtrigger(label, xtrig, self.fdir) - else: - self.xtrigger_mgr.add_trig(label, xtrig, self.fdir) + self.xtrigger_collator.add_trig(label, xtrig, self.fdir) # Add it to the task, for each sequence that the task appears in. taskdef = self.get_taskdef(task_name) for seq in taskdef.sequences: diff --git a/cylc/flow/cycling/util.py b/cylc/flow/cycling/util.py index 7f22d43a600..b47a8aae886 100644 --- a/cylc/flow/cycling/util.py +++ b/cylc/flow/cycling/util.py @@ -18,14 +18,17 @@ from metomi.isodatetime.parsers import TimePointParser, DurationParser -def add_offset(cycle_point, offset): +def add_offset(cycle_point, offset, dmp_fmt=None): """Add a (positive or negative) offset to a cycle point. Return the result. """ my_parser = TimePointParser() - my_target_point = my_parser.parse(cycle_point, dump_as_parsed=True) + if dmp_fmt is None: + my_target_point = my_parser.parse(cycle_point, dump_as_parsed=True) + else: + my_target_point = my_parser.parse(cycle_point, dump_format=dmp_fmt) my_offset_parser = DurationParser() oper = "+" diff --git a/cylc/flow/data_store_mgr.py b/cylc/flow/data_store_mgr.py index 9b3b39509a2..c59fa7b6c62 100644 --- a/cylc/flow/data_store_mgr.py +++ b/cylc/flow/data_store_mgr.py @@ -101,8 +101,8 @@ from cylc.flow.taskdef import generate_graph_parents, generate_graph_children from cylc.flow.task_state import TASK_STATUSES_FINAL from cylc.flow.util import ( - serialise, - deserialise + serialise_set, + deserialise_set ) from cylc.flow.wallclock import ( TIME_ZONE_LOCAL_INFO, @@ -1186,7 +1186,7 @@ def generate_ghost_task( submit_num=0, data_mode=True, sequential_xtrigger_labels=( - self.schd.xtrigger_mgr.sequential_xtrigger_labels + self.schd.xtrigger_mgr.xtriggers.sequential_xtrigger_labels ), ) @@ -1411,7 +1411,7 @@ def apply_task_proxy_db_history(self): relative_id = tokens.relative_id itask, is_parent = self.db_load_task_proxies[relative_id] itask.submit_num = submit_num - flow_nums = deserialise(flow_nums_str) + flow_nums = deserialise_set(flow_nums_str) # Do not set states and outputs for future tasks in flow. if ( itask.flow_nums and @@ -1487,7 +1487,7 @@ def _process_internal_task_proxy( update_time = time() tproxy.state = itask.state.status - tproxy.flow_nums = serialise(itask.flow_nums) + tproxy.flow_nums = serialise_set(itask.flow_nums) prereq_list = [] for prereq in itask.state.prerequisites: @@ -1778,7 +1778,7 @@ def window_resize_rewalk(self) -> None: self.increment_graph_window( tokens, get_point(tokens['cycle']), - deserialise(tproxy.flow_nums) + deserialise_set(tproxy.flow_nums) ) # Flag difference between old and new window for pruning. self.prune_flagged_nodes.update( diff --git a/cylc/flow/dbstatecheck.py b/cylc/flow/dbstatecheck.py index ca45b5deba6..1fae3e0feb5 100644 --- a/cylc/flow/dbstatecheck.py +++ b/cylc/flow/dbstatecheck.py @@ -19,42 +19,48 @@ import os import sqlite3 import sys +from contextlib import suppress +from typing import Dict, Iterable, Optional, List, Union +from cylc.flow.exceptions import InputError +from cylc.flow.cycling.util import add_offset +from cylc.flow.cycling.integer import ( + IntegerPoint, + IntegerInterval +) +from cylc.flow.flow_mgr import stringify_flow_nums from cylc.flow.pathutil import expand_path from cylc.flow.rundb import CylcWorkflowDAO -from cylc.flow.task_state import ( - TASK_STATUS_SUBMITTED, - TASK_STATUS_RUNNING, - TASK_STATUS_SUCCEEDED, - TASK_STATUS_FAILED +from cylc.flow.task_outputs import ( + TASK_OUTPUT_SUCCEEDED, + TASK_OUTPUT_FAILED, + TASK_OUTPUT_FINISHED, +) +from cylc.flow.util import deserialise_set +from metomi.isodatetime.parsers import TimePointParser +from metomi.isodatetime.exceptions import ISO8601SyntaxError + + +output_fallback_msg = ( + "Unable to filter by task output label for tasks run in Cylc versions " + "between 8.0.0-8.3.0. Falling back to filtering by task message instead." ) class CylcWorkflowDBChecker: - """Object for querying a workflow database""" - STATE_ALIASES = { - 'finish': [ - TASK_STATUS_FAILED, - TASK_STATUS_SUCCEEDED - ], - 'start': [ - TASK_STATUS_RUNNING, - TASK_STATUS_SUCCEEDED, - TASK_STATUS_FAILED - ], - 'submit': [ - TASK_STATUS_SUBMITTED, - TASK_STATUS_RUNNING, - TASK_STATUS_SUCCEEDED, - TASK_STATUS_FAILED - ], - 'fail': [ - TASK_STATUS_FAILED - ], - 'succeed': [ - TASK_STATUS_SUCCEEDED - ], - } + """Object for querying task status or outputs from a workflow database. + + Back-compat and task outputs: + # Cylc 7 stored {trigger: message} for custom outputs only. + 1|foo|{"x": "the quick brown"} + + # Cylc 8 (pre-8.3.0) stored [message] only, for all outputs. + 1|foo|[1]|["submitted", "started", "succeeded", "the quick brown"] + + # Cylc 8 (8.3.0+) stores {trigger: message} for all ouputs. + 1|foo|[1]|{"submitted": "submitted", "started": "started", + "succeeded": "succeeded", "x": "the quick brown"} + """ def __init__(self, rund, workflow, db_path=None): # (Explicit dp_path arg is to make testing easier). @@ -65,17 +71,93 @@ def __init__(self, rund, workflow, db_path=None): ) if not os.path.exists(db_path): raise OSError(errno.ENOENT, os.strerror(errno.ENOENT), db_path) + self.conn = sqlite3.connect(db_path, timeout=10.0) + # Get workflow point format. + try: + self.db_point_fmt = self._get_db_point_format() + self.c7_back_compat_mode = False + except sqlite3.OperationalError as exc: + # BACK COMPAT: Cylc 7 DB (see method below). + try: + self.db_point_fmt = self._get_db_point_format_compat() + self.c7_back_compat_mode = True + except sqlite3.OperationalError: + raise exc # original error + + def adjust_point_to_db(self, cycle, offset): + """Adjust a cycle point (with offset) to the DB point format. + + Cycle point queries have to match in the DB as string literals, + so we convert given cycle points (e.g., from the command line) + to the DB point format before making the query. + + """ + if cycle is None or "*" in cycle: + if offset is not None: + raise InputError( + f'Cycle point "{cycle}" is not compatible with an offset.' + ) + # Nothing to do + return cycle + + if offset is not None: + if self.db_point_fmt is None: + # integer cycling + cycle = str( + IntegerPoint(cycle) + + IntegerInterval(offset) + ) + else: + cycle = str( + add_offset(cycle, offset) + ) + + if self.db_point_fmt is None: + return cycle + + # Convert cycle point to DB format. + try: + cycle = str( + TimePointParser().parse( + cycle, dump_format=self.db_point_fmt + ) + ) + except ISO8601SyntaxError: + raise InputError( + f'Cycle point "{cycle}" is not compatible' + f' with DB point format "{self.db_point_fmt}"' + ) + return cycle + @staticmethod - def display_maps(res): + def display_maps(res, old_format=False, pretty_print=False): if not res: sys.stderr.write("INFO: No results to display.\n") else: for row in res: - sys.stdout.write((", ").join(row) + "\n") + if old_format: + sys.stdout.write(', '.join(row) + '\n') + else: + out = f"{row[1]}/{row[0]}:" # cycle/task: + status_or_outputs = row[2] + if pretty_print: + with suppress(json.decoder.JSONDecodeError): + status_or_outputs = ( + json.dumps( + json.loads( + status_or_outputs.replace("'", '"') + ), + indent=4 + ) + ) + out += status_or_outputs + if len(row) == 4: + out += row[3] # flow + sys.stdout.write(out + "\n") - def get_remote_point_format(self): + def _get_db_point_format(self): """Query a workflow database for a 'cycle point format' entry""" for row in self.conn.execute( rf''' @@ -90,11 +172,16 @@ def get_remote_point_format(self): ): return row[0] - def get_remote_point_format_compat(self): - """Query a Cylc 7 suite database for a 'cycle point format' entry. - - Back compat for Cylc 8 workflow state triggers targeting Cylc 7 DBs. - """ + def _get_db_point_format_compat(self): + """Query a Cylc 7 suite database for 'cycle point format'.""" + # BACK COMPAT: Cylc 7 DB + # Workflows parameters table name change. + # from: + # 8.0.x + # to: + # 8.1.x + # remove at: + # 8.x for row in self.conn.execute( rf''' SELECT @@ -108,27 +195,56 @@ def get_remote_point_format_compat(self): ): return row[0] - def state_lookup(self, state): - """allows for multiple states to be searched via a status alias""" - if state in self.STATE_ALIASES: - return self.STATE_ALIASES[state] - else: - return [state] - def workflow_state_query( - self, task, cycle, status=None, message=None, mask=None): - """run a query on the workflow database""" + self, + task: Optional[str] = None, + cycle: Optional[str] = None, + selector: Optional[str] = None, + is_trigger: Optional[bool] = False, + is_message: Optional[bool] = False, + flow_num: Optional[int] = None, + print_outputs: bool = False + ) -> List[List[str]]: + """Query task status or outputs (by trigger or message) in a database. + + Args: + task: + task name + cycle: + cycle point + selector: + task status, trigger name, or message + is_trigger: + intpret the selector as a trigger + is_message: + interpret the selector as a task message + + Return: + A list of results for all tasks that match the query. + [ + [name, cycle, result, [flow]], + ... + ] + + "result" is single string: + - for status queries: the task status + - for output queries: a serialized dict of completed outputs + {trigger: message} + + """ stmt_args = [] stmt_wheres = [] - if mask is None: - mask = "name, cycle, status" - - if message: + if is_trigger or is_message: target_table = CylcWorkflowDAO.TABLE_TASK_OUTPUTS - mask = "outputs" + mask = "name, cycle, outputs" else: target_table = CylcWorkflowDAO.TABLE_TASK_STATES + mask = "name, cycle, status" + + if not self.c7_back_compat_mode: + # Cylc 8 DBs only + mask += ", flow_nums" stmt = rf''' SELECT @@ -138,49 +254,103 @@ def workflow_state_query( ''' # nosec # * mask is hardcoded # * target_table is a code constant - if task is not None: - stmt_wheres.append("name==?") + + # Select from DB by name, cycle, status. + # (Outputs and flow_nums are serialised). + if task: + if '*' in task: + # Replace Cylc ID wildcard with Sqlite query wildcard. + task = task.replace('*', '%') + stmt_wheres.append("name like ?") + else: + stmt_wheres.append("name==?") stmt_args.append(task) - if cycle is not None: - stmt_wheres.append("cycle==?") + + if cycle: + if '*' in cycle: + # Replace Cylc ID wildcard with Sqlite query wildcard. + cycle = cycle.replace('*', '%') + stmt_wheres.append("cycle like ?") + else: + stmt_wheres.append("cycle==?") stmt_args.append(cycle) - if status: - stmt_frags = [] - for state in self.state_lookup(status): - stmt_args.append(state) - stmt_frags.append("status==?") - stmt_wheres.append("(" + (" OR ").join(stmt_frags) + ")") + if ( + selector is not None + and target_table == CylcWorkflowDAO.TABLE_TASK_STATES + ): + # Can select by status in the DB but not outputs. + stmt_wheres.append("status==?") + stmt_args.append(selector) + if stmt_wheres: - stmt += " where " + (" AND ").join(stmt_wheres) + stmt += "WHERE\n " + (" AND ").join(stmt_wheres) + + if target_table == CylcWorkflowDAO.TABLE_TASK_STATES: + # (outputs table doesn't record submit number) + stmt += r"ORDER BY submit_num" - res = [] + # Query the DB and drop incompatible rows. + db_res = [] for row in self.conn.execute(stmt, stmt_args): - if not all(v is None for v in row): - res.append(list(row)) - - return res - - def task_state_getter(self, task, cycle): - """used to get the state of a particular task at a particular cycle""" - return self.workflow_state_query(task, cycle, mask="status")[0] - - def task_state_met(self, task, cycle, status=None, message=None): - """used to check if a task is in a particular state""" - res = self.workflow_state_query(task, cycle, status, message) - if status: - return bool(res) - elif message: - return any( - message == value - for outputs_str, in res - for value in json.loads(outputs_str) - ) + # name, cycle, status_or_outputs, [flow_nums] + res = list(row[:3]) + if row[2] is None: + # status can be None in Cylc 7 DBs + continue + if not self.c7_back_compat_mode: + flow_nums = deserialise_set(row[3]) + if flow_num is not None and flow_num not in flow_nums: + # skip result, wrong flow + continue + fstr = stringify_flow_nums(flow_nums) + if fstr: + res.append(fstr) + db_res.append(res) + + if target_table == CylcWorkflowDAO.TABLE_TASK_STATES: + return db_res + + warn_output_fallback = is_trigger + results = [] + for row in db_res: + outputs: Union[Dict[str, str], List[str]] = json.loads(row[2]) + if isinstance(outputs, dict): + messages: Iterable[str] = outputs.values() + else: + # Cylc 8 pre 8.3.0 back-compat: list of output messages + messages = outputs + if warn_output_fallback: + print(f"WARNING - {output_fallback_msg}", file=sys.stderr) + warn_output_fallback = False + if ( + selector is None or + (is_message and selector in messages) or + (is_trigger and self._selector_in_outputs(selector, outputs)) + ): + results.append(row[:2] + [str(outputs)] + row[3:]) + + return results @staticmethod - def validate_mask(mask): - fieldnames = ["name", "status", "cycle"] # extract from rundb.py? - return all( - term.strip(' ') in fieldnames - for term in mask.split(',') + def _selector_in_outputs(selector: str, outputs: Iterable[str]) -> bool: + """Check if a selector, including "finished", is in the outputs. + + Examples: + >>> this = CylcWorkflowDBChecker._selector_in_outputs + >>> this('moop', ['started', 'moop']) + True + >>> this('moop', ['started']) + False + >>> this('finished', ['succeeded']) + True + >>> this('finish', ['failed']) + True + """ + return selector in outputs or ( + selector in (TASK_OUTPUT_FINISHED, "finish") + and ( + TASK_OUTPUT_SUCCEEDED in outputs + or TASK_OUTPUT_FAILED in outputs + ) ) diff --git a/cylc/flow/etc/examples/event-driven-cycling/.validate b/cylc/flow/etc/examples/event-driven-cycling/.validate index ef224cd9e2c..54e4db0a74c 100755 --- a/cylc/flow/etc/examples/event-driven-cycling/.validate +++ b/cylc/flow/etc/examples/event-driven-cycling/.validate @@ -27,12 +27,7 @@ sleep 1 # give it a reasonable chance to start up ./bin/trigger "$ID" WORLD=earth # wait for it to complete -cylc workflow-state "$ID" \ - --task=run \ - --point=1 \ - --status=succeeded \ - --max-polls=60 \ - --interval=1 +cylc workflow-state "$ID//1/run:succeeded" --max-polls=60 --interval=1 # check the job received the environment variable we provided grep 'Hello earth' "$HOME/cylc-run/$ID/log/job/1/run/NN/job.out" diff --git a/cylc/flow/etc/examples/inter-workflow-triggers/.validate b/cylc/flow/etc/examples/inter-workflow-triggers/.validate index bdd414e275d..ef21cb95ae4 100755 --- a/cylc/flow/etc/examples/inter-workflow-triggers/.validate +++ b/cylc/flow/etc/examples/inter-workflow-triggers/.validate @@ -37,12 +37,7 @@ cylc vip \ ./downstream # wait for the first task in the downstream to succeed -cylc workflow-state "$DOID" \ - --task=process \ - --point="$ICP" \ - --status=succeeded \ - --max-polls=60 \ - --interval=1 +cylc workflow-state "$DOID//$ICP/process:succeeded" --max-polls=60 --interval=1 # stop the workflows cylc stop --kill --max-polls=10 --interval=2 "$UPID" diff --git a/cylc/flow/etc/examples/inter-workflow-triggers/downstream/flow.cylc b/cylc/flow/etc/examples/inter-workflow-triggers/downstream/flow.cylc index 115ecd94755..45d88eb61b5 100644 --- a/cylc/flow/etc/examples/inter-workflow-triggers/downstream/flow.cylc +++ b/cylc/flow/etc/examples/inter-workflow-triggers/downstream/flow.cylc @@ -10,7 +10,7 @@ [[xtriggers]] # this is an "xtrigger" - it will wait for the task "b" in the same # cycle from the workflow "upstream" - upstream = workflow_state(workflow="inter-workflow-triggers/upstream", task="b", point="%(point)s") + upstream = workflow_state(workflow_task_id="inter-workflow-triggers/upstream//%(point)s/b") [[graph]] PT1H = """ @upstream => process diff --git a/cylc/flow/exceptions.py b/cylc/flow/exceptions.py index 7235455cace..802cfaaa9cd 100644 --- a/cylc/flow/exceptions.py +++ b/cylc/flow/exceptions.py @@ -242,12 +242,13 @@ class XtriggerConfigError(WorkflowConfigError): """ - def __init__(self, label: str, message: str): - self.label: str = label - self.message: str = message + def __init__(self, label: str, func: str, message: Union[str, Exception]): + self.label = label + self.func = func + self.message = message def __str__(self) -> str: - return f'[@{self.label}] {self.message}' + return f'[@{self.label}] {self.func}\n{self.message}' class ClientError(CylcError): diff --git a/cylc/flow/graph_parser.py b/cylc/flow/graph_parser.py index 64dcdecaf6f..efbce16fb36 100644 --- a/cylc/flow/graph_parser.py +++ b/cylc/flow/graph_parser.py @@ -380,11 +380,10 @@ def parse_graph(self, graph_string: str) -> None: full_line = self.__class__.REC_WORKFLOW_STATE.sub(repl, full_line) for item in repl.match_groups: l_task, r_all, r_workflow, r_task, r_status = item - if r_status: - r_status = r_status.strip(self.__class__.QUALIFIER) - r_status = TaskTrigger.standardise_name(r_status) - else: - r_status = TASK_OUTPUT_SUCCEEDED + if r_status is not None: + r_status = TaskTrigger.standardise_name( + r_status.strip(self.__class__.QUALIFIER) + ) self.workflow_state_polling_tasks[l_task] = ( r_workflow, r_task, r_status, r_all ) diff --git a/cylc/flow/option_parsers.py b/cylc/flow/option_parsers.py index f058a684f77..a4ef2d97b3d 100644 --- a/cylc/flow/option_parsers.py +++ b/cylc/flow/option_parsers.py @@ -51,6 +51,7 @@ OPT_WORKFLOW_ID_ARG_DOC = ('[WORKFLOW]', 'Workflow ID') WORKFLOW_ID_MULTI_ARG_DOC = ('WORKFLOW ...', 'Workflow ID(s)') WORKFLOW_ID_OR_PATH_ARG_DOC = ('WORKFLOW | PATH', 'Workflow ID or path') +ID_SEL_ARG_DOC = ('ID[:sel]', 'WORKFLOW-ID[[//CYCLE[/TASK]]:selector]') ID_MULTI_ARG_DOC = ('ID ...', 'Workflow/Cycle/Family/Task ID(s)') FULL_ID_MULTI_ARG_DOC = ('ID ...', 'Cycle/Family/Task ID(s)') @@ -290,9 +291,15 @@ class CylcOptionParser(OptionParser): ['--debug'], help='Equivalent to -v -v', dest='verbosity', action='store_const', const=2, useif='all'), OptionSettings( - ['--no-timestamp'], help='Don\'t timestamp logged messages.', - action='store_false', dest='log_timestamp', - default=True, useif='all'), + ['--timestamp'], + help='Add a timestamp to messages logged to the terminal.', + action='store_true', dest='log_timestamp', + default=False, useif='all'), + OptionSettings( + ['--no-timestamp'], help="Don't add a timestamp to messages logged" + " to the terminal (this does nothing - it is now the default.", + action='store_false', dest='_noop', + default=False, useif='all'), OptionSettings( ['--color', '--colour'], metavar='WHEN', action='store', default='auto', choices=['never', 'auto', 'always'], diff --git a/cylc/flow/parsec/upgrade.py b/cylc/flow/parsec/upgrade.py index 62f3e2e8d0d..0f75732f6d5 100644 --- a/cylc/flow/parsec/upgrade.py +++ b/cylc/flow/parsec/upgrade.py @@ -194,6 +194,8 @@ def expand(self, upg): def upgrade(self): warnings = OrderedDict() + deprecations = False + obsoletions = False for vn, upgs in self.upgrades.items(): for u in upgs: try: @@ -212,6 +214,9 @@ def upgrade(self): if upg['new']: msg += ' -> ' + self.show_keys(upg['new'], upg['is_section']) + deprecations = True + else: + obsoletions = True msg += " - " + upg['cvt'].describe().format( old=old, new=upg['cvt'].convert(old) @@ -236,7 +241,6 @@ def upgrade(self): self.put_item(upg['new'], upg['cvt'].convert(old)) if warnings: - level = WARNING if self.descr == self.SITE_CONFIG: # Site level configuration, user cannot easily fix. # Only log at debug level. @@ -245,9 +249,19 @@ def upgrade(self): # User level configuration, user should be able to fix. # Log at warning level. level = WARNING - LOG.log(level, - 'deprecated items were automatically upgraded in ' - f'"{self.descr}"') + if obsoletions: + LOG.log( + level, + "Obsolete config items were automatically deleted." + " Please check your workflow and remove them permanently." + ) + if deprecations: + LOG.log( + level, + "Deprecated config items were automatically upgraded." + " Please alter your workflow to use the new syntax." + ) + for vn, msgs in warnings.items(): for msg in msgs: LOG.log(level, ' * (%s) %s', vn, msg) diff --git a/cylc/flow/parsec/validate.py b/cylc/flow/parsec/validate.py index 29e2c8a59c9..18c19596a63 100644 --- a/cylc/flow/parsec/validate.py +++ b/cylc/flow/parsec/validate.py @@ -1136,7 +1136,7 @@ def coerce_xtrigger(cls, value, keys): @classmethod def _coerce_type(cls, value): - """Convert value to int, float, or bool, if possible. + """Convert value to int, float, bool, or None, if possible. Examples: >>> CylcConfigValidator._coerce_type('1') @@ -1147,6 +1147,7 @@ def _coerce_type(cls, value): True >>> CylcConfigValidator._coerce_type('abc') 'abc' + >>> CylcConfigValidator._coerce_type('None') """ try: @@ -1159,6 +1160,8 @@ def _coerce_type(cls, value): val = False elif value == 'True': val = True + elif value == 'None': + val = None else: # Leave as string. val = cls.strip_and_unquote([], value) diff --git a/cylc/flow/rundb.py b/cylc/flow/rundb.py index 78e1813d45b..70bca7c8354 100644 --- a/cylc/flow/rundb.py +++ b/cylc/flow/rundb.py @@ -23,6 +23,7 @@ import traceback from typing import ( TYPE_CHECKING, + Dict, Iterable, List, Set, @@ -33,11 +34,12 @@ from cylc.flow import LOG from cylc.flow.exceptions import PlatformLookupError -from cylc.flow.util import deserialise +from cylc.flow.util import deserialise_set import cylc.flow.flags if TYPE_CHECKING: from pathlib import Path + from cylc.flow.flow_mgr import FlowNums @dataclass @@ -790,7 +792,7 @@ def select_prev_instances( ( submit_num, flow_wait == 1, - deserialise(flow_nums_str), + deserialise_set(flow_nums_str), status ) for flow_nums_str, submit_num, flow_wait, status in ( @@ -804,12 +806,14 @@ def select_latest_flow_nums(self): SELECT flow_nums, MAX(time_created) FROM {self.TABLE_TASK_STATES} ''' # nosec (table name is code constant) flow_nums_str = list(self.connect().execute(stmt))[0][0] - return deserialise(flow_nums_str) + return deserialise_set(flow_nums_str) - def select_task_outputs(self, name, point): + def select_task_outputs( + self, name: str, point: str + ) -> 'Dict[str, FlowNums]': """Select task outputs for each flow. - Return: {outputs_list: flow_nums_set} + Return: {outputs_dict_str: flow_nums_set} """ stmt = rf''' @@ -820,10 +824,12 @@ def select_task_outputs(self, name, point): WHERE name==? AND cycle==? ''' # nosec (table name is code constant) - ret = {} - for flow_nums, outputs in self.connect().execute(stmt, (name, point,)): - ret[outputs] = deserialise(flow_nums) - return ret + return { + outputs: deserialise_set(flow_nums) + for flow_nums, outputs in self.connect().execute( + stmt, (name, point,) + ) + } def select_xtriggers_for_restart(self, callback): stmt = rf''' diff --git a/cylc/flow/scheduler.py b/cylc/flow/scheduler.py index 8074bfbce8a..ff593648e7b 100644 --- a/cylc/flow/scheduler.py +++ b/cylc/flow/scheduler.py @@ -1057,7 +1057,6 @@ def load_flow_file(self, is_reload=False): self.flow_file, self.options, self.template_vars, - xtrigger_mgr=self.xtrigger_mgr, mem_log_func=self.profiler.log_memory, output_fname=os.path.join( self.workflow_run_dir, 'log', 'config', diff --git a/cylc/flow/scripts/function_run.py b/cylc/flow/scripts/function_run.py index 63029a97782..f517963d708 100755 --- a/cylc/flow/scripts/function_run.py +++ b/cylc/flow/scripts/function_run.py @@ -14,13 +14,12 @@ # # You should have received a copy of the GNU General Public License # along with this program. If not, see . -"""USAGE: cylc function-run +"""USAGE: cylc function-run (This command is for internal use.) Run a Python xtrigger function "(*args, **kwargs)" in the process pool. -It must be in a module of the same name. Positional and keyword arguments must -be passed in as JSON strings. +Positional and keyword arguments must be passed in as JSON strings. Python entry points are the preferred way to make xtriggers available to the scheduler, but local xtriggers can be stored in . @@ -38,7 +37,7 @@ def main(*api_args): args = [None] + list(api_args) else: args = sys.argv - if args[1] in ["help", "--help"] or len(args) != 5: + if args[1] in ["help", "--help"] or len(args) != 6: print(__doc__) sys.exit(0) - run_function(args[1], args[2], args[3], args[4]) + run_function(*args[1:]) diff --git a/cylc/flow/scripts/validate_install_play.py b/cylc/flow/scripts/validate_install_play.py index 9343289f987..d701eb02315 100644 --- a/cylc/flow/scripts/validate_install_play.py +++ b/cylc/flow/scripts/validate_install_play.py @@ -86,7 +86,7 @@ def get_option_parser() -> COP: # no sense in a VIP context. if option.kwargs.get('dest') != 'against_source': parser.add_option(*option.args, **option.kwargs) - + parser.set_defaults(is_validate=True) return parser @@ -103,6 +103,9 @@ def main(parser: COP, options: 'Values', workflow_id: Optional[str] = None): log_subcommand('validate', source) asyncio.run(cylc_validate(parser, options, str(source))) + # Unset is validate after validation. + delattr(options, 'is_validate') + log_subcommand('install', source) _, workflow_id = asyncio.run(cylc_install(options, workflow_id)) diff --git a/cylc/flow/scripts/validate_reinstall.py b/cylc/flow/scripts/validate_reinstall.py index 0733512ddad..de1be6dea82 100644 --- a/cylc/flow/scripts/validate_reinstall.py +++ b/cylc/flow/scripts/validate_reinstall.py @@ -97,6 +97,7 @@ def get_option_parser() -> COP: ) for option in VR_OPTIONS: parser.add_option(*option.args, **option.kwargs) + parser.set_defaults(is_validate=True) return parser @@ -169,6 +170,9 @@ async def vr_cli(parser: COP, options: 'Values', workflow_id: str): log_subcommand('validate --against-source', workflow_id) await cylc_validate(parser, options, workflow_id) + # Unset is validate after validation. + delattr(options, 'is_validate') + log_subcommand('reinstall', workflow_id) reinstall_ok = await cylc_reinstall( options, workflow_id, diff --git a/cylc/flow/scripts/workflow_state.py b/cylc/flow/scripts/workflow_state.py index ea544771c2d..f6350110223 100755 --- a/cylc/flow/scripts/workflow_state.py +++ b/cylc/flow/scripts/workflow_state.py @@ -18,252 +18,434 @@ r"""cylc workflow-state [OPTIONS] ARGS -Retrieve task states from the workflow database. +Check or poll a workflow database for task statuses or completed outputs. -Print task states retrieved from a workflow database; or (with --task, ---point, and --status) poll until a given task reaches a given state; or (with ---task, --point, and --message) poll until a task receives a given message. -Polling is configurable with --interval and --max-polls; for a one-off -check use --max-polls=1. The workflow database does not need to exist at -the time polling commences but allocated polls are consumed waiting for -it (consider max-polls*interval as an overall timeout). +The ID argument can target a workflow, or a cycle point, or a specific +task, with an optional selector on cycle or task to match task status, +output trigger (if not a status, or with --trigger) or output message +(with --message). All matching results will be printed. -Note for non-cycling tasks --point=1 must be provided. +If no results match, the command will repeatedly check (poll) until a match +is found or polling is exhausted (see --max-polls and --interval). For a +one-off check set --max-polls=1. -For your own workflows the database location is determined by your -site/user config. For other workflows, e.g. those owned by others, or -mirrored workflow databases, use --run-dir=DIR to specify the location. +If the database does not exist at first, polls are consumed waiting for it +so you can start checking before the target workflow is started. + +Legacy (pre-8.3.0) options are supported, but deprecated, for existing scripts: + cylc workflow-state --task=NAME --point=CYCLE --status=STATUS + --output=MESSAGE --message=MESSAGE --task-point WORKFLOW +(Note from 8.0 until 8.3.0 --output and --message both match task messages). + +In "cycle/task:selector" the selector will match task statuses, unless: + - if it is not a known status, it will match task output triggers + (Cylc 8 DB) or task ouput messages (Cylc 7 DB) + - with --triggers, it will only match task output triggers + - with --messages (deprecated), it will only match task output messages. + Triggers are more robust - they match manually and naturally set outputs. + +Selector does not default to "succeeded". If omitted, any status will match. + +The "finished" pseudo-output is an alias for "succeeded or failed". + +In the ID, both cycle and task can include "*" to match any sequence of zero +or more characters. Quote the pattern to protect it from shell expansion. + +Note tasks get recorded in the DB once they enter the active window (n=0). + +Flow numbers are only printed for flow numbers > 1. + +USE IN TASK SCRIPTING: + - To poll a task at the same cycle point in another workflow, just use + $CYLC_TASK_CYCLE_POINT in the ID. + - To poll a task at an offset cycle point, use the --offset option to + have Cylc do the datetime arithmetic for you. + - However, see also the workflow_state xtrigger for this use case. + +WARNINGS: + - Typos in the workflow or task ID will result in fruitless polling. + - To avoid missing transient states ("submitted", "running") poll for the + corresponding output trigger instead ("submitted", "started"). + - Cycle points are auto-converted to the DB point format (and UTC mode). + - Task outputs manually completed by "cylc set" have "(force-completed)" + recorded as the task message in the DB, so it is best to query trigger + names, not messages, unless specifically interested in forced outputs. Examples: - $ cylc workflow-state WORKFLOW_ID --task=TASK --point=POINT --status=STATUS - # returns 0 if TASK.POINT reaches STATUS before the maximum number of - # polls, otherwise returns 1. - - $ cylc workflow-state WORKFLOW_ID --task=TASK --point=POINT --status=STATUS \ - > --offset=PT6H - # adds 6 hours to the value of CYCLE for carrying out the polling operation. - - $ cylc workflow-state WORKFLOW_ID --task=TASK --status=STATUS --task-point - # uses CYLC_TASK_CYCLE_POINT environment variable as the value for the - # CYCLE to poll. This is useful when you want to use cylc workflow-state in a - # cylc task. + + # Print the status of all tasks in WORKFLOW: + $ cylc workflow-state WORKFLOW + + # Print the status of all tasks in cycle point 2033: + $ cylc workflow-state WORKFLOW//2033 + + # Print the status of all tasks named foo: + $ cylc workflow-state "WORKFLOW//*/foo" + + # Print all succeeded tasks: + $ cylc workflow-state "WORKFLOW//*/*:succeeded" + + # Print all tasks foo that completed output (trigger name) file1: + $ cylc workflow-state "WORKFLOW//*/foo:file1" + + # Print if task 2033/foo completed output (trigger name) file1: + $ cylc workflow-state WORKFLOW//2033/foo:file1 + +See also: + - the workflow_state xtrigger, for state polling within workflows + - "cylc dump -t", to query a scheduler for current statuses + - "cylc show", to query a scheduler for task prerequisites and outputs """ import asyncio import os import sqlite3 import sys -from time import sleep -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, List, Optional -from cylc.flow.exceptions import CylcError, InputError -import cylc.flow.flags +from cylc.flow.pathutil import get_cylc_run_dir +from cylc.flow.id import Tokens +from cylc.flow.exceptions import InputError from cylc.flow.option_parsers import ( - WORKFLOW_ID_ARG_DOC, + ID_SEL_ARG_DOC, CylcOptionParser as COP, ) -from cylc.flow.dbstatecheck import CylcWorkflowDBChecker +from cylc.flow import LOG from cylc.flow.command_polling import Poller -from cylc.flow.task_state import TASK_STATUSES_ORDERED +from cylc.flow.dbstatecheck import CylcWorkflowDBChecker from cylc.flow.terminal import cli_function -from cylc.flow.cycling.util import add_offset -from cylc.flow.pathutil import get_cylc_run_dir from cylc.flow.workflow_files import infer_latest_run_from_id - -from metomi.isodatetime.parsers import TimePointParser +from cylc.flow.task_state import TASK_STATUSES_ORDERED if TYPE_CHECKING: from optparse import Values +WILDCARD = "*" + +# polling defaults +MAX_POLLS = 12 +INTERVAL = 5 + +OPT_DEPR_MSG = "DEPRECATED, use ID" +OPT_DEPR_MSG1 = 'DEPRECATED, use "ID:STATUS"' +OPT_DEPR_MSG2 = 'DEPRECATED, use "ID:MSG"' + + +def unquote(s: str) -> str: + """Remove leading & trailing quotes from a string. + + Examples: + >>> unquote('"foo"') + 'foo' + >>> unquote("'foo'") + 'foo' + >>> unquote('foo') + 'foo' + >>> unquote("'tis a fine morning") + "'tis a fine morning" + """ + if ( + s.startswith('"') and s.endswith('"') + or s.startswith("'") and s.endswith("'") + ): + return s[1:-1] + return s + + class WorkflowPoller(Poller): - """A polling object that checks workflow state.""" - - def connect(self): - """Connect to the workflow db, polling if necessary in case the - workflow has not been started up yet.""" - - # Returns True if connected, otherwise (one-off failed to - # connect, or max number of polls exhausted) False - connected = False - - if cylc.flow.flags.verbosity > 0: - sys.stderr.write( - "connecting to workflow db for " + - self.args['run_dir'] + "/" + self.args['workflow_id']) - - # Attempt db connection even if no polls for condition are - # requested, as failure to connect is useful information. - max_polls = self.max_polls or 1 - # max_polls*interval is equivalent to a timeout, and we - # include time taken to connect to the run db in this... - while not connected: - self.n_polls += 1 + """An object that polls for task states or outputs in a workflow DB.""" + + def __init__( + self, + id_: str, + offset: Optional[str], + flow_num: Optional[int], + alt_cylc_run_dir: Optional[str], + default_status: Optional[str], + is_trigger: bool, + is_message: bool, + old_format: bool = False, + pretty_print: bool = False, + **kwargs + ): + self.id_ = id_ + self.offset = offset + self.flow_num = flow_num + self.alt_cylc_run_dir = alt_cylc_run_dir + self.old_format = old_format + self.pretty_print = pretty_print + + try: + tokens = Tokens(self.id_) + except ValueError as exc: + raise InputError(exc) + + self.workflow_id_raw = tokens.workflow_id + self.selector = ( + tokens["cycle_sel"] or + tokens["task_sel"] or + default_status + ) + if self.selector: + self.selector = unquote(self.selector) + self.cycle_raw = tokens["cycle"] + self.task = tokens["task"] + + self.workflow_id: Optional[str] = None + self.cycle: Optional[str] = None + self.result: Optional[List[List[str]]] = None + self._db_checker: Optional[CylcWorkflowDBChecker] = None + + self.is_message = is_message + if is_message: + self.is_trigger = False + else: + self.is_trigger = ( + is_trigger or + ( + self.selector is not None and + self.selector not in TASK_STATUSES_ORDERED + ) + ) + super().__init__(**kwargs) + + def _find_workflow(self) -> bool: + """Find workflow and infer run directory, return True if found.""" + try: + self.workflow_id = infer_latest_run_from_id( + self.workflow_id_raw, + self.alt_cylc_run_dir + ) + except InputError: + LOG.debug("Workflow not found") + return False + + if self.workflow_id != self.workflow_id_raw: + # Print inferred ID. + sys.stderr.write(f"Inferred workflow ID: {self.workflow_id}\n") + return True + + @property + def db_checker(self) -> Optional[CylcWorkflowDBChecker]: + """Connect to workflow DB if not already connected. + + Returns DB checker if connected. + """ + if not self._db_checker: try: - self.checker = CylcWorkflowDBChecker( - self.args['run_dir'], self.args['workflow_id']) - connected = True - # ... but ensure at least one poll after connection: - self.n_polls -= 1 + self._db_checker = CylcWorkflowDBChecker( + get_cylc_run_dir(self.alt_cylc_run_dir), + self.workflow_id + ) except (OSError, sqlite3.Error): - if self.n_polls >= max_polls: - raise - if cylc.flow.flags.verbosity > 0: - sys.stderr.write('.') - sleep(self.interval) - if cylc.flow.flags.verbosity > 0: - sys.stderr.write('\n') - - if connected and self.args['cycle']: - try: - fmt = self.checker.get_remote_point_format() - except sqlite3.OperationalError as exc: - try: - fmt = self.checker.get_remote_point_format_compat() - except sqlite3.OperationalError: - raise exc # original error - if fmt: - my_parser = TimePointParser() - my_point = my_parser.parse(self.args['cycle'], dump_format=fmt) - self.args['cycle'] = str(my_point) - return connected, self.args['cycle'] - - async def check(self): - """Return True if desired workflow state achieved, else False""" - return self.checker.task_state_met( - self.args['task'], self.args['cycle'], - self.args['status'], self.args['message']) + LOG.debug("DB not connected") + return None + + return self._db_checker + + async def check(self) -> bool: + """Return True if requested state achieved, else False. + + Called once per poll by super() so only find and connect once. + + Store self.result for external access. + + """ + if self.workflow_id is None and not self._find_workflow(): + return False + + if self.db_checker is None: + return False + + if self.cycle is None: + # Adjust target cycle point to the DB format. + self.cycle = self.db_checker.adjust_point_to_db( + self.cycle_raw, self.offset) + + self.result = self.db_checker.workflow_state_query( + self.task, self.cycle, self.selector, self.is_trigger, + self.is_message, self.flow_num + ) + if self.result: + # End the polling dot stream and print inferred runN workflow ID. + self.db_checker.display_maps( + self.result, self.old_format, self.pretty_print) + + return bool(self.result) def get_option_parser() -> COP: parser = COP( __doc__, - argdoc=[WORKFLOW_ID_ARG_DOC] + argdoc=[ID_SEL_ARG_DOC] ) + # --run-dir for pre-8.3.0 back-compat parser.add_option( - "-t", "--task", help="Specify a task to check the state of.", - action="store", dest="task", default=None) + "-d", "--alt-cylc-run-dir", "--run-dir", + help="Alternate cylc-run directory, e.g. for other users' workflows.", + metavar="DIR", action="store", dest="alt_cylc_run_dir", default=None) parser.add_option( - "-p", "--point", - help="Specify the cycle point to check task states for.", - action="store", dest="cycle", default=None) + "-s", "--offset", + help="Offset from ID cycle point as an ISO8601 duration for datetime" + " cycling (e.g. 'PT30M' for 30 minutes) or an integer interval for" + " integer cycling (e.g. 'P2'). This can be used in task job scripts" + " to poll offset cycle points without doing the cycle arithmetic" + " yourself - but see also the workflow_state xtrigger.", + action="store", dest="offset", metavar="DURATION", default=None) parser.add_option( - "-T", "--task-point", - help="Use the CYLC_TASK_CYCLE_POINT environment variable as the " - "cycle point to check task states for. " - "Shorthand for --point=$CYLC_TASK_CYCLE_POINT", - action="store_true", dest="use_task_point", default=False) + "--flow", + help="Flow number, for target tasks. By default, any flow.", + action="store", type="int", dest="flow_num", default=None) parser.add_option( - "-d", "--run-dir", - help="The top level cylc run directory if non-standard. The " - "database should be DIR/WORKFLOW_ID/log/db. Use to interrogate " - "workflows owned by others, etc.; see note above.", - metavar="DIR", action="store", dest="alt_run_dir", default=None) + "--triggers", + help="Task selector should match output triggers rather than status." + " (Note this is not needed for custom outputs).", + action="store_true", dest="is_trigger", default=False) parser.add_option( - "-s", "--offset", - help="Specify an offset to add to the targeted cycle point", - action="store", dest="offset", default=None) - - conds = ("Valid triggering conditions to check for include: '" + - ("', '").join( - sorted(CylcWorkflowDBChecker.STATE_ALIASES.keys())[:-1]) + - "' and '" + sorted( - CylcWorkflowDBChecker.STATE_ALIASES.keys())[-1] + "'. ") - states = ("Valid states to check for include: '" + - ("', '").join(TASK_STATUSES_ORDERED[:-1]) + - "' and '" + TASK_STATUSES_ORDERED[-1] + "'.") + "--messages", + help="Task selector should match output messages rather than status.", + action="store_true", dest="is_message", default=False) + + parser.add_option( + "--pretty", + help="Pretty-print outputs (the default is single-line output).", + action="store_true", dest="pretty_print", default=False) + + parser.add_option( + "--old-format", + help="Print results in legacy comma-separated format.", + action="store_true", dest="old_format", default=False) + + # Back-compat support for pre-8.3.0 command line options. + parser.add_option( + "-t", "--task", help=f"Task name. {OPT_DEPR_MSG}.", + metavar="NAME", + action="store", dest="depr_task", default=None) + + parser.add_option( + "-p", "--point", metavar="CYCLE", + help=f"Cycle point. {OPT_DEPR_MSG}.", + action="store", dest="depr_point", default=None) + + parser.add_option( + "-T", "--task-point", + help="Get cycle point from the environment variable" + " $CYLC_TASK_CYCLE_POINT (e.g. in task job scripts)", + action="store_true", dest="depr_env_point", default=False) parser.add_option( "-S", "--status", - help="Specify a particular status or triggering condition to " - f"check for. {conds}{states}", - action="store", dest="status", default=None) + metavar="STATUS", + help=f"Task status. {OPT_DEPR_MSG1}.", + action="store", dest="depr_status", default=None) + # Prior to 8.3.0 --output was just an alias for --message parser.add_option( "-O", "--output", "-m", "--message", - help="Check custom task output by message string or trigger string.", - action="store", dest="msg", default=None) - - WorkflowPoller.add_to_cmd_options(parser) + metavar="MSG", + help=f"Task output message. {OPT_DEPR_MSG2}.", + action="store", dest="depr_msg", default=None) + + WorkflowPoller.add_to_cmd_options( + parser, + d_interval=INTERVAL, + d_max_polls=MAX_POLLS + ) return parser @cli_function(get_option_parser, remove_opts=["--db"]) -def main(parser: COP, options: 'Values', workflow_id: str) -> None: - - if options.use_task_point and options.cycle: - raise InputError( - "cannot specify a cycle point and use environment variable") - - if options.use_task_point: - if "CYLC_TASK_CYCLE_POINT" not in os.environ: - raise InputError("CYLC_TASK_CYCLE_POINT is not defined") - options.cycle = os.environ["CYLC_TASK_CYCLE_POINT"] - - if options.offset and not options.cycle: - raise InputError( - "You must target a cycle point to use an offset") - - # Attempt to apply specified offset to the targeted cycle - if options.offset: - options.cycle = str(add_offset(options.cycle, options.offset)) - - # Exit if both task state and message are to being polled - if options.status and options.msg: - raise InputError("cannot poll both status and custom output") - - if options.msg and not options.task and not options.cycle: - raise InputError("need a taskname and cyclepoint") - - # Exit if an invalid status is requested - if (options.status and - options.status not in TASK_STATUSES_ORDERED and - options.status not in CylcWorkflowDBChecker.STATE_ALIASES): - raise InputError(f"invalid status '{options.status}'") - - workflow_id = infer_latest_run_from_id(workflow_id, options.alt_run_dir) - - pollargs = { - 'workflow_id': workflow_id, - 'run_dir': get_cylc_run_dir(alt_run_dir=options.alt_run_dir), - 'task': options.task, - 'cycle': options.cycle, - 'status': options.status, - 'message': options.msg, - } - - spoller = WorkflowPoller( - "requested state", - options.interval, - options.max_polls, - args=pollargs, +def main(parser: COP, options: 'Values', *ids: str) -> None: + + # Note it would be cleaner to use 'id_cli.parse_ids()' here to get the + # workflow ID and tokens, but that function infers run number and fails + # if the workflow is not installed yet. We want to be able to start polling + # before the workflow is installed, which makes it easier to get a set of + # interdependent workflows up and running, so runN inference is done inside + # the poller. TODO: consider using id_cli.parse_ids inside the poller. + # (Note this applies to polling tasks, which use the CLI, not xtriggers). + + id_ = ids[0].rstrip('/') # might get 'id/' due to autcomplete + + if any( + [ + options.depr_task, + options.depr_status, + options.depr_msg, # --message and --trigger + options.depr_point, + options.depr_env_point + ] + ): + depr_opts = ( + "--task, --status, --message, --output, --point, --task-point" + ) + + if id_ != Tokens(id_)["workflow"]: + raise InputError( + f"with deprecated {depr_opts}, the argument must be a" + " plain workflow ID (i.e. no cycle, task, or :selector)." + ) + + if options.depr_status and options.depr_msg: + raise InputError("set --status or --message, not both.") + + if options.depr_env_point: + if options.depr_point: + raise InputError( + "set --task-point or --point=CYCLE, not both.") + try: + options.depr_point = os.environ["CYLC_TASK_CYCLE_POINT"] + except KeyError: + raise InputError( + "--task-point: $CYLC_TASK_CYCLE_POINT is not defined") + + if options.depr_point is not None: + id_ += f"//{options.depr_point}" + elif ( + options.depr_task is not None or + options.depr_status is not None or + options.depr_msg is not None + ): + id_ += "//*" + if options.depr_task is not None: + id_ += f"/{options.depr_task}" + if options.depr_status is not None: + id_ += f":{options.depr_status}" + elif options.depr_msg is not None: + id_ += f":{options.depr_msg}" + options.is_message = True + + msg = f"{depr_opts} are deprecated. Please use an ID: " + if not options.depr_env_point: + msg += id_ + else: + msg += id_.replace(options.depr_point, "$CYLC_TASK_CYCLE_POINT") + LOG.warning(msg) + + poller = WorkflowPoller( + id_, + options.offset, + options.flow_num, + options.alt_cylc_run_dir, + default_status=None, + is_trigger=options.is_trigger, + is_message=options.is_message, + old_format=options.old_format, + pretty_print=options.pretty_print, + condition=id_, + interval=options.interval, + max_polls=options.max_polls, + args=None ) - connected, formatted_pt = spoller.connect() - - if not connected: - raise CylcError(f"Cannot connect to the {workflow_id} DB") - - if options.status and options.task and options.cycle: - # check a task status - spoller.condition = options.status - if not asyncio.run(spoller.poll()): - sys.exit(1) - elif options.msg: - # Check for a custom task output - spoller.condition = "output: %s" % options.msg - if not asyncio.run(spoller.poll()): - sys.exit(1) - else: - # just display query results - spoller.checker.display_maps( - spoller.checker.workflow_state_query( - task=options.task, - cycle=formatted_pt, - status=options.status)) + if not asyncio.run( + poller.poll() + ): + sys.exit(1) diff --git a/cylc/flow/subprocctx.py b/cylc/flow/subprocctx.py index 45b4cb2095e..41b735a71c5 100644 --- a/cylc/flow/subprocctx.py +++ b/cylc/flow/subprocctx.py @@ -166,10 +166,12 @@ def __init__( func_name: str, func_args: List[Any], func_kwargs: Dict[str, Any], - intvl: Union[float, str] = DEFAULT_INTVL + intvl: Union[float, str] = DEFAULT_INTVL, + mod_name: Optional[str] = None ): """Initialize a function context.""" self.label = label + self.mod_name = mod_name or func_name self.func_name = func_name self.func_kwargs = func_kwargs self.func_args = func_args @@ -186,7 +188,9 @@ def __init__( def update_command(self, workflow_run_dir): """Update the function wrap command after changes.""" - self.cmd = ['cylc', 'function-run', self.func_name, + self.cmd = ['cylc', 'function-run', + self.mod_name, + self.func_name, json.dumps(self.func_args), json.dumps(self.func_kwargs), workflow_run_dir] diff --git a/cylc/flow/subprocpool.py b/cylc/flow/subprocpool.py index 29a236b2f9c..864071332b8 100644 --- a/cylc/flow/subprocpool.py +++ b/cylc/flow/subprocpool.py @@ -126,7 +126,7 @@ def get_xtrig_func(mod_name, func_name, src_dir): return _XTRIG_FUNC_CACHE[(mod_name, func_name)] -def run_function(func_name, json_args, json_kwargs, src_dir): +def run_function(mod_name, func_name, json_args, json_kwargs, src_dir): """Run a Python function in the process pool. func_name(*func_args, **func_kwargs) @@ -142,7 +142,7 @@ def run_function(func_name, json_args, json_kwargs, src_dir): func_kwargs = json.loads(json_kwargs) # Find and import then function. - func = get_xtrig_func(func_name, func_name, src_dir) + func = get_xtrig_func(mod_name, func_name, src_dir) # Redirect stdout to stderr. orig_stdout = sys.stdout diff --git a/cylc/flow/task_events_mgr.py b/cylc/flow/task_events_mgr.py index 82960a312bf..0f89d2122dd 100644 --- a/cylc/flow/task_events_mgr.py +++ b/cylc/flow/task_events_mgr.py @@ -699,7 +699,9 @@ def process_message( completed_output: Optional[bool] = False if msg0 not in [TASK_OUTPUT_SUBMIT_FAILED, TASK_OUTPUT_FAILED]: - completed_output = itask.state.outputs.set_message_complete(msg0) + completed_output = ( + itask.state.outputs.set_message_complete(msg0, forced) + ) if completed_output: self.data_store_mgr.delta_task_output(itask, msg0) @@ -716,8 +718,8 @@ def process_message( if message == self.EVENT_STARTED: if ( - flag == self.FLAG_RECEIVED - and itask.state.is_gt(TASK_STATUS_RUNNING) + flag == self.FLAG_RECEIVED + and itask.state.is_gt(TASK_STATUS_RUNNING) ): # Already running. return True @@ -1280,7 +1282,7 @@ def _retry_task(self, itask, wallclock_time, submit_retry=False): [], kwargs ) - self.xtrigger_mgr.add_trig( + self.xtrigger_mgr.xtriggers.add_trig( label, xtrig, os.getenv("CYLC_WORKFLOW_RUN_DIR") diff --git a/cylc/flow/task_job_mgr.py b/cylc/flow/task_job_mgr.py index 4ac90d28aeb..185966ff12d 100644 --- a/cylc/flow/task_job_mgr.py +++ b/cylc/flow/task_job_mgr.py @@ -111,7 +111,7 @@ get_utc_mode ) from cylc.flow.cfgspec.globalcfg import SYSPATH -from cylc.flow.util import serialise +from cylc.flow.util import serialise_set if TYPE_CHECKING: from cylc.flow.task_proxy import TaskProxy @@ -443,7 +443,7 @@ def submit_task_jobs(self, workflow, itasks, curve_auth, # Log and persist LOG.debug(f"[{itask}] host={host}") self.workflow_db_mgr.put_insert_task_jobs(itask, { - 'flow_nums': serialise(itask.flow_nums), + 'flow_nums': serialise_set(itask.flow_nums), 'is_manual_submit': itask.is_manual_submit, 'try_num': itask.get_try_num(), 'time_submit': get_current_time_string(), @@ -1272,7 +1272,7 @@ def _prep_submit_task_job_error(self, workflow, itask, action, exc): self.workflow_db_mgr.put_insert_task_jobs( itask, { - 'flow_nums': serialise(itask.flow_nums), + 'flow_nums': serialise_set(itask.flow_nums), 'job_id': itask.summary.get('submit_method_id'), 'is_manual_submit': itask.is_manual_submit, 'try_num': itask.get_try_num(), diff --git a/cylc/flow/task_outputs.py b/cylc/flow/task_outputs.py index a9af2d34dcc..39da3750b59 100644 --- a/cylc/flow/task_outputs.py +++ b/cylc/flow/task_outputs.py @@ -67,6 +67,9 @@ TASK_OUTPUT_FINISHED, ) +# DB output message for forced completion +FORCED_COMPLETION_MSG = "(manually completed)" + # this evaluates task completion expressions CompletionEvaluator = restricted_evaluator( # expressions @@ -296,23 +299,25 @@ class TaskOutputs: expression string. """ - __slots__ = ( "_message_to_trigger", "_message_to_compvar", "_completed", "_completion_expression", + "_forced", ) _message_to_trigger: Dict[str, str] # message: trigger _message_to_compvar: Dict[str, str] # message: completion variable _completed: Dict[str, bool] # message: is_complete _completion_expression: str + _forced: List[str] # list of messages of force-completed outputs def __init__(self, tdef: 'Union[TaskDef, str]'): self._message_to_trigger = {} self._message_to_compvar = {} self._completed = {} + self._forced = [] if isinstance(tdef, str): # abnormal use e.g. from the "cylc show" command @@ -341,7 +346,32 @@ def get_trigger(self, message: str) -> str: """Return the trigger associated with this message.""" return self._message_to_trigger[message] - def set_message_complete(self, message: str) -> Optional[bool]: + def set_trigger_complete( + self, trigger: str, forced=False + ) -> Optional[bool]: + """Set the provided output trigger as complete. + + Args: + trigger: + The task output trigger to satisfy. + + Returns: + True: + If the output was unset before. + False: + If the output was already set. + None + If the output does not apply. + + """ + trg_to_msg = { + v: k for k, v in self._message_to_trigger.items() + } + return self.set_message_complete(trg_to_msg[trigger], forced) + + def set_message_complete( + self, message: str, forced=False + ) -> Optional[bool]: """Set the provided task message as complete. Args: @@ -364,6 +394,8 @@ def set_message_complete(self, message: str) -> Optional[bool]: if self._completed[message] is False: # output was incomplete self._completed[message] = True + if forced: + self._forced.append(message) return True # output was already completed @@ -381,16 +413,23 @@ def is_message_complete(self, message: str) -> Optional[bool]: return self._completed[message] return None - def iter_completed_messages(self) -> Iterator[str]: - """A generator that yields completed messages. + def get_completed_outputs(self) -> Dict[str, str]: + """Return a dict {trigger: message} of completed outputs. - Yields: - message: A completed task message. + Replace message with "forced" if the output was forced. """ - for message, is_completed in self._completed.items(): - if is_completed: - yield message + def _get_msg(message): + if message in self._forced: + return FORCED_COMPLETION_MSG + else: + return message + + return { + self._message_to_trigger[message]: _get_msg(message) + for message, is_completed in self._completed.items() + if is_completed + } def __iter__(self) -> Iterator[Tuple[str, str, bool]]: """A generator that yields all outputs. diff --git a/cylc/flow/task_pool.py b/cylc/flow/task_pool.py index d37049f11ae..21893103f86 100644 --- a/cylc/flow/task_pool.py +++ b/cylc/flow/task_pool.py @@ -65,8 +65,8 @@ ) from cylc.flow.task_trigger import TaskTrigger from cylc.flow.util import ( - serialise, - deserialise + serialise_set, + deserialise_set ) from cylc.flow.wallclock import get_current_time_string from cylc.flow.platforms import get_platform @@ -123,6 +123,7 @@ def __init__( self.task_events_mgr: 'TaskEventsManager' = task_events_mgr self.task_events_mgr.spawn_func = self.spawn_on_output self.xtrigger_mgr: 'XtriggerManager' = xtrigger_mgr + self.xtrigger_mgr.add_xtriggers(self.config.xtrigger_collator) self.data_store_mgr: 'DataStoreMgr' = data_store_mgr self.flow_mgr: 'FlowMgr' = flow_mgr @@ -208,7 +209,7 @@ def db_add_new_flow_rows(self, itask: TaskProxy) -> None: "time_created": now, "time_updated": now, "status": itask.state.status, - "flow_nums": serialise(itask.flow_nums), + "flow_nums": serialise_set(itask.flow_nums), "flow_wait": itask.flow_wait, "is_manual_submit": itask.is_manual_submit } @@ -433,7 +434,7 @@ def check_task_output( self, cycle: str, task: str, - output: str, + output_msg: str, flow_nums: 'FlowNums', ) -> Union[str, bool]: """Returns truthy if the specified output is satisfied in the DB.""" @@ -443,10 +444,20 @@ def check_task_output( # loop through matching tasks if flow_nums.intersection(task_flow_nums): # this task is in the right flow - task_outputs = json.loads(task_outputs) + # BACK COMPAT: In Cylc >8.0.0,<8.3.0, only the task + # messages were stored in the DB as a list. + # from: 8.0.0 + # to: 8.3.0 + outputs: Union[ + Dict[str, str], List[str] + ] = json.loads(task_outputs) + messages = ( + outputs.values() if isinstance(outputs, dict) + else outputs + ) return ( 'satisfied from database' - if output in task_outputs + if output_msg in messages else False ) else: @@ -475,7 +486,7 @@ def load_db_task_pool_for_restart(self, row_idx, row): self.tokens, self.config.get_taskdef(name), get_point(cycle), - deserialise(flow_nums), + deserialise_set(flow_nums), status=status, is_held=is_held, submit_num=submit_num, @@ -483,7 +494,7 @@ def load_db_task_pool_for_restart(self, row_idx, row): flow_wait=bool(flow_wait), is_manual_submit=bool(is_manual_submit), sequential_xtrigger_labels=( - self.xtrigger_mgr.sequential_xtrigger_labels + self.xtrigger_mgr.xtriggers.sequential_xtrigger_labels ), ) @@ -538,14 +549,14 @@ def load_db_task_pool_for_restart(self, row_idx, row): # Update prerequisite satisfaction status from DB sat = {} - for prereq_name, prereq_cycle, prereq_output, satisfied in ( + for prereq_name, prereq_cycle, prereq_output_msg, satisfied in ( self.workflow_db_mgr.pri_dao.select_task_prerequisites( cycle, name, flow_nums, ) ): # Prereq satisfaction as recorded in the DB. sat[ - (prereq_cycle, prereq_name, prereq_output) + (prereq_cycle, prereq_name, prereq_output_msg) ] = satisfied if satisfied != '0' else False for itask_prereq in itask.state.prerequisites: @@ -557,12 +568,12 @@ def load_db_task_pool_for_restart(self, row_idx, row): # added to an already-spawned task before restart. # Look through task outputs to see if is has been # satisfied - prereq_cycle, prereq_task, prereq_output = key + prereq_cycle, prereq_task, prereq_output_msg = key itask_prereq.satisfied[key] = ( self.check_task_output( prereq_cycle, prereq_task, - prereq_output, + prereq_output_msg, itask.flow_nums, ) ) @@ -735,7 +746,8 @@ def get_or_spawn_task( if ntask is not None: is_xtrig_sequential = ntask.is_xtrigger_sequential elif any( - xtrig_label in self.xtrigger_mgr.sequential_xtrigger_labels + xtrig_label in ( + self.xtrigger_mgr.xtriggers.sequential_xtrigger_labels) for sequence, xtrig_labels in tdef.xtrig_labels.items() for xtrig_label in xtrig_labels if sequence.is_valid(point) @@ -1025,7 +1037,7 @@ def reload_taskdefs(self, config: 'WorkflowConfig') -> None: itask.flow_nums, itask.state.status, sequential_xtrigger_labels=( - self.xtrigger_mgr.sequential_xtrigger_labels + self.xtrigger_mgr.xtriggers.sequential_xtrigger_labels ), ) itask.copy_to_reload_successor( @@ -1350,14 +1362,15 @@ def spawn_on_output(self, itask, output, forced=False): with suppress(KeyError): children = itask.graph_children[output] + if itask.flow_wait and children: + LOG.warning( + f"[{itask}] not spawning on {output}: flow wait requested") + self.remove_if_complete(itask, output) + return + suicide = [] for c_name, c_point, is_abs in children: - if itask.flow_wait: - LOG.warning( - f"[{itask}] not spawning on {output}: flow wait requested") - continue - if is_abs: self.abs_outputs_done.add( (str(itask.point), itask.tdef.name, output)) @@ -1610,7 +1623,7 @@ def _get_task_history( return never_spawned, submit_num, prev_status, prev_flow_wait - def _load_historical_outputs(self, itask): + def _load_historical_outputs(self, itask: 'TaskProxy') -> None: """Load a task's historical outputs from the DB.""" info = self.workflow_db_mgr.pri_dao.select_task_outputs( itask.tdef.name, str(itask.point)) @@ -1620,8 +1633,22 @@ def _load_historical_outputs(self, itask): else: for outputs_str, fnums in info.items(): if itask.flow_nums.intersection(fnums): - for msg in json.loads(outputs_str): - itask.state.outputs.set_message_complete(msg) + # BACK COMPAT: In Cylc >8.0.0,<8.3.0, only the task + # messages were stored in the DB as a list. + # from: 8.0.0 + # to: 8.3.0 + outputs: Union[ + Dict[str, str], List[str] + ] = json.loads(outputs_str) + if isinstance(outputs, dict): + # {trigger: message} - match triggers, not messages. + # DB may record forced completion rather than message. + for trigger in outputs.keys(): + itask.state.outputs.set_trigger_complete(trigger) + else: + # [message] - always the full task message + for msg in outputs: + itask.state.outputs.set_message_complete(msg) def spawn_task( self, @@ -1762,22 +1789,14 @@ def _get_task_proxy_db_outputs( transient=transient, is_manual_submit=is_manual_submit, sequential_xtrigger_labels=( - self.xtrigger_mgr.sequential_xtrigger_labels + self.xtrigger_mgr.xtriggers.sequential_xtrigger_labels ), ) if itask is None: return None # Update it with outputs that were already completed. - info = self.workflow_db_mgr.pri_dao.select_task_outputs( - itask.tdef.name, str(itask.point)) - if not info: - # (Note still need this if task not run before) - self.db_add_new_flow_rows(itask) - for outputs_str, fnums in info.items(): - if flow_nums.intersection(fnums): - for msg in json.loads(outputs_str): - itask.state.outputs.set_message_complete(msg) + self._load_historical_outputs(itask) return itask def _standardise_prereqs( @@ -2161,7 +2180,7 @@ def force_trigger_tasks( flow_wait=flow_wait, submit_num=submit_num, sequential_xtrigger_labels=( - self.xtrigger_mgr.sequential_xtrigger_labels + self.xtrigger_mgr.xtriggers.sequential_xtrigger_labels ), ) if itask is None: diff --git a/cylc/flow/util.py b/cylc/flow/util.py index 8b8b613787a..74a11d0505a 100644 --- a/cylc/flow/util.py +++ b/cylc/flow/util.py @@ -148,18 +148,27 @@ def cli_format(cmd: List[str]): return ' '.join(cmd) -def serialise(flow_nums: set): - """Convert set to json. +def serialise_set(flow_nums: set) -> str: + """Convert set to json, sorted. + For use when a sorted result is needed for consistency. + Example: - >>> serialise({'3','2'}) + >>> serialise_set({'3','2'}) '["2", "3"]' -""" + + """ return json.dumps(sorted(flow_nums)) -def deserialise(flow_num_str: str): - """Converts string to set.""" +def deserialise_set(flow_num_str: str) -> set: + """Convert json string to set. + + Example: + >>> sorted(deserialise_set('[2, 3]')) + [2, 3] + + """ return set(json.loads(flow_num_str)) diff --git a/cylc/flow/workflow_db_mgr.py b/cylc/flow/workflow_db_mgr.py index 0a92e7312bf..8e57d19292b 100644 --- a/cylc/flow/workflow_db_mgr.py +++ b/cylc/flow/workflow_db_mgr.py @@ -40,7 +40,7 @@ from cylc.flow import __version__ as CYLC_VERSION from cylc.flow.wallclock import get_current_time_string, get_utc_mode from cylc.flow.exceptions import CylcError, ServiceFileError -from cylc.flow.util import serialise +from cylc.flow.util import serialise_set, deserialise_set if TYPE_CHECKING: from pathlib import Path @@ -48,6 +48,7 @@ from cylc.flow.scheduler import Scheduler from cylc.flow.task_pool import TaskPool from cylc.flow.task_events_mgr import EventKey + from cylc.flow.task_proxy import TaskProxy Version = Any # TODO: narrow down Any (should be str | int) after implementing type @@ -429,7 +430,7 @@ def put_update_task_state(self, itask): where_args = { "cycle": str(itask.point), "name": itask.tdef.name, - "flow_nums": serialise(itask.flow_nums), + "flow_nums": serialise_set(itask.flow_nums), } # Note tasks_states table rows are for latest submit_num only # (not one row per submit). @@ -451,7 +452,7 @@ def put_update_task_flow_wait(self, itask): where_args = { "cycle": str(itask.point), "name": itask.tdef.name, - "flow_nums": serialise(itask.flow_nums), + "flow_nums": serialise_set(itask.flow_nums), } self.db_updates_map.setdefault(self.TABLE_TASK_STATES, []) self.db_updates_map[self.TABLE_TASK_STATES].append( @@ -481,7 +482,7 @@ def put_task_pool(self, pool: 'TaskPool') -> None: prereq.satisfied.items() ): self.put_insert_task_prerequisites(itask, { - "flow_nums": serialise(itask.flow_nums), + "flow_nums": serialise_set(itask.flow_nums), "prereq_name": p_name, "prereq_cycle": p_cycle, "prereq_output": p_output, @@ -490,7 +491,7 @@ def put_task_pool(self, pool: 'TaskPool') -> None: self.db_inserts_map[self.TABLE_TASK_POOL].append({ "name": itask.tdef.name, "cycle": str(itask.point), - "flow_nums": serialise(itask.flow_nums), + "flow_nums": serialise_set(itask.flow_nums), "status": itask.state.status, "is_held": itask.state.is_held }) @@ -535,7 +536,7 @@ def put_task_pool(self, pool: 'TaskPool') -> None: where_args = { "cycle": str(itask.point), "name": itask.tdef.name, - "flow_nums": serialise(itask.flow_nums) + "flow_nums": serialise_set(itask.flow_nums) } self.db_updates_map.setdefault(self.TABLE_TASK_STATES, []) self.db_updates_map[self.TABLE_TASK_STATES].append( @@ -585,8 +586,8 @@ def put_insert_task_outputs(self, itask): CylcWorkflowDAO.TABLE_TASK_OUTPUTS, itask, { - "flow_nums": serialise(itask.flow_nums), - "outputs": json.dumps([]) + "flow_nums": serialise_set(itask.flow_nums), + "outputs": json.dumps({}) } ) @@ -628,21 +629,21 @@ def put_update_task_jobs(self, itask, set_args): self._put_update_task_x( CylcWorkflowDAO.TABLE_TASK_JOBS, itask, set_args) - def put_update_task_outputs(self, itask): + def put_update_task_outputs(self, itask: 'TaskProxy') -> None: """Put UPDATE statement for task_outputs table.""" set_args = { "outputs": json.dumps( - list(itask.state.outputs.iter_completed_messages()) + itask.state.outputs.get_completed_outputs() ) } where_args = { "cycle": str(itask.point), "name": itask.tdef.name, - "flow_nums": serialise(itask.flow_nums), + "flow_nums": serialise_set(itask.flow_nums), } - self.db_updates_map.setdefault(self.TABLE_TASK_OUTPUTS, []) - self.db_updates_map[self.TABLE_TASK_OUTPUTS].append( - (set_args, where_args)) + self.db_updates_map.setdefault(self.TABLE_TASK_OUTPUTS, []).append( + (set_args, where_args) + ) def _put_update_task_x(self, table_name, itask, set_args): """Put UPDATE statement for a task_* table.""" @@ -652,7 +653,7 @@ def _put_update_task_x(self, table_name, itask, set_args): if "submit_num" not in set_args: where_args["submit_num"] = itask.submit_num if "flow_nums" not in set_args: - where_args["flow_nums"] = serialise(itask.flow_nums) + where_args["flow_nums"] = serialise_set(itask.flow_nums) self.db_updates_map.setdefault(table_name, []) self.db_updates_map[table_name].append((set_args, where_args)) @@ -742,8 +743,7 @@ def upgrade_pre_810(pri_dao: CylcWorkflowDAO) -> None: # We can't upgrade if the flow_nums in task_states are not # distinct. - from cylc.flow.util import deserialise - flow_nums = deserialise(conn.execute( + flow_nums = deserialise_set(conn.execute( 'SELECT DISTINCT flow_nums FROM task_states;').fetchall()[0][0]) if len(flow_nums) != 1: raise CylcError( diff --git a/cylc/flow/xtrigger_mgr.py b/cylc/flow/xtrigger_mgr.py index f6595b400a3..d23bc936203 100644 --- a/cylc/flow/xtrigger_mgr.py +++ b/cylc/flow/xtrigger_mgr.py @@ -37,9 +37,14 @@ from cylc.flow.subprocctx import add_kwarg_to_sig from cylc.flow.subprocpool import get_xtrig_func from cylc.flow.xtriggers.wall_clock import _wall_clock +from cylc.flow.xtriggers.workflow_state import ( + workflow_state, + _workflow_state_backcompat, + _upgrade_workflow_state_sig, +) if TYPE_CHECKING: - from inspect import BoundArguments + from inspect import BoundArguments, Signature from cylc.flow.broadcast_mgr import BroadcastMgr from cylc.flow.data_store_mgr import DataStoreMgr from cylc.flow.subprocctx import SubFuncContext @@ -159,6 +164,245 @@ class TemplateVariables(Enum): RE_STR_TMPL = re.compile(r'(? None: + """Add a new xtrigger function. + + Args: + label: xtrigger label + fctx: function context + fdir: module directory + + """ + if label in self.functx_map: + # we've already seen this one + return + + if ( + not label.startswith('_cylc_retry_') and not + label.startswith('_cylc_submit_retry_') + ): + # (the "_wall_clock" function fails "wall_clock" validation) + self._validate(label, fctx, fdir) + + self.functx_map[label] = fctx + + if fctx.func_kwargs.pop( + 'sequential', + self.sequential_xtriggers_default + ): + self.sequential_xtrigger_labels.add(label) + + if fctx.func_name == "wall_clock": + self.wall_clock_labels.add(label) + + @classmethod + def _validate( + cls, + label: str, + fctx: 'SubFuncContext', + fdir: str, + ) -> None: + """Check xtrigger existence, string templates and function signature. + + Also call a specific xtrigger argument validation function, "validate", + if defined in the xtrigger module. + + Args: + label: xtrigger label + fctx: function context + fdir: function directory + + Raises: + XtriggerConfigError: + * If the function module was not found. + * If the function was not found in the xtrigger module. + * If the function is not callable. + * If any string template in the function context + arguments are not present in the expected template values. + * If the arguments do not match the function signature. + + """ + sig_str = fctx.get_signature() + + try: + func = get_xtrig_func(fctx.mod_name, fctx.func_name, fdir) + except (ImportError, AttributeError) as exc: + raise XtriggerConfigError(label, sig_str, exc) + try: + sig = signature(func) + except TypeError as exc: + # not callable + raise XtriggerConfigError(label, sig_str, exc) + + sig = cls._handle_sequential_kwarg(label, fctx, sig) + + # Validate args and kwargs against the function signature + try: + bound_args = sig.bind(*fctx.func_args, **fctx.func_kwargs) + except TypeError as exc: + err = XtriggerConfigError(label, sig_str, exc) + if func is workflow_state: + bound_args = cls._try_workflow_state_backcompat( + label, fctx, err + ) + else: + raise err + + # Specific xtrigger.validate(), if available. + # Note arg string templating has not been done at this point. + cls._try_xtrig_validate_func( + label, fctx, fdir, bound_args, sig_str + ) + + # Check any string templates in the function arg values (note this + # won't catch bad task-specific values - which are added dynamically). + template_vars = set() + for argv in fctx.func_args + list(fctx.func_kwargs.values()): + if not isinstance(argv, str): + # Not a string arg. + continue + + # check template variables are valid + for match in RE_STR_TMPL.findall(argv): + try: + template_vars.add(TemplateVariables(match)) + except ValueError: + raise XtriggerConfigError( + label, sig_str, + f"Illegal template in xtrigger: {match}", + ) + + # check for deprecated template variables + deprecated_variables = template_vars & { + TemplateVariables.WorkflowName, + TemplateVariables.SuiteName, + TemplateVariables.SuiteRunDir, + TemplateVariables.SuiteShareDir, + } + if deprecated_variables: + LOG.warning( + f'Xtrigger "{label}" uses deprecated template variables:' + f' {", ".join(t.value for t in deprecated_variables)}' + ) + + @staticmethod + def _handle_sequential_kwarg( + label: str, fctx: 'SubFuncContext', sig: 'Signature' + ) -> 'Signature': + """Handle reserved 'sequential' kwarg in xtrigger functions.""" + sequential_param = sig.parameters.get('sequential', None) + if sequential_param: + if not isinstance(sequential_param.default, bool): + raise XtriggerConfigError( + label, fctx.func_name, + ( + "xtrigger has a reserved argument" + " 'sequential' with no boolean default" + ) + ) + fctx.func_kwargs.setdefault('sequential', sequential_param.default) + + elif 'sequential' in fctx.func_kwargs: + # xtrig marked as sequential, so add 'sequential' arg to signature + sig = add_kwarg_to_sig( + sig, 'sequential', fctx.func_kwargs['sequential'] + ) + return sig + + @staticmethod + def _try_xtrig_validate_func( + label: str, + fctx: 'SubFuncContext', + fdir: str, + bound_args: 'BoundArguments', + signature_str: str, + ): + """Call an xtrigger's `validate()` function if it is implemented. + + Raise XtriggerConfigError if validation fails. + + """ + vname = "validate" + if fctx.func_name == _workflow_state_backcompat.__name__: + vname = "_validate_backcompat" + + try: + xtrig_validate_func = get_xtrig_func(fctx.mod_name, vname, fdir) + except (AttributeError, ImportError): + return + bound_args.apply_defaults() + try: + xtrig_validate_func(bound_args.arguments) + except Exception as exc: # Note: catch all errors + raise XtriggerConfigError(label, signature_str, exc) + + # BACK COMPAT: workflow_state_backcompat + # from: 8.0.0 + # to: 8.3.0 + # remove at: 8.x + @classmethod + def _try_workflow_state_backcompat( + cls, + label: str, + fctx: 'SubFuncContext', + err: XtriggerConfigError, + ) -> 'BoundArguments': + """Try to validate args against the old workflow_state signature. + + Raise the original signature check error if this signature check fails. + + Returns the bound arguments for the old signature. + """ + sig = cls._handle_sequential_kwarg( + label, fctx, signature(_workflow_state_backcompat) + ) + try: + bound_args = sig.bind(*fctx.func_args, **fctx.func_kwargs) + except TypeError: + # failed signature check for backcompat function + raise err # original signature check error + + old_sig_str = fctx.get_signature() + upg_sig_str = "workflow_state({})".format( + ", ".join( + f'{k}={v}' for k, v in + _upgrade_workflow_state_sig(bound_args.arguments).items() + if v is not None + ) + ) + LOG.warning( + "(8.3.0) Deprecated function signature used for " + "workflow_state xtrigger was automatically upgraded. Please " + "alter your workflow to use the new syntax:\n" + f" {old_sig_str} --> {upg_sig_str}" + ) + fctx.func_name = _workflow_state_backcompat.__name__ + return bound_args + + class XtriggerManager: """Manage clock triggers and xtrigger functions. @@ -168,8 +412,8 @@ class XtriggerManager: clock_0 = wall_clock() # offset PT0H clock_1 = wall_clock(offset=PT1H) # or wall_clock(PT1H) - workflow_x = workflow_state(workflow=other, - point=%(task_cycle_point)s):PT30S + workflow_x = workflow_state( + workflow_task_id=other, point=%(task_cycle_point)s):PT30S [[graph]] PT1H = ''' @clock_1 & @workflow_x => foo & bar @@ -209,9 +453,7 @@ class XtriggerManager: # "sequential=False" here overrides workflow and function default. clock_0 = wall_clock(sequential=False) workflow_x = workflow_state( - workflow=other, - point=%(task_cycle_point)s, - ):PT30S + workflow_task_id=other, point=%(task_cycle_point)s):PT30S [[graph]] PT1H = ''' @workflow_x => foo & bar # spawned on workflow_x satisfaction @@ -240,8 +482,6 @@ def __init__( workflow_run_dir: Optional[str] = None, workflow_share_dir: Optional[str] = None, ): - # Workflow function and clock triggers by label. - self.functx_map: 'Dict[str, SubFuncContext]' = {} # When next to call a function, by signature. self.t_next_call: dict = {} # Satisfied triggers and their function results, by signature. @@ -249,13 +489,6 @@ def __init__( # Signatures of active functions (waiting on callback). self.active: list = [] - # Clock labels, to avoid repeated string comparisons - self.wall_clock_labels: Set[str] = set() - - # Workflow wide default, used when not specified in xtrigger kwargs. - self.sequential_xtriggers_default = False - # Labels whose xtriggers are sequentially checked. - self.sequential_xtrigger_labels: Set[str] = set() # Gather parentless tasks whose xtrigger(s) have been satisfied # (these will be used to spawn the next occurrence). self.sequential_spawn_next: Set[str] = set() @@ -284,163 +517,17 @@ def __init__( self.broadcast_mgr = broadcast_mgr self.data_store_mgr = data_store_mgr self.do_housekeeping = False + self.xtriggers = XtriggerCollator() - @staticmethod - def check_xtrigger( - label: str, - fctx: 'SubFuncContext', - fdir: str, - ) -> None: - """Generic xtrigger validation: check existence, string templates and - function signature. - - Xtrigger modules may also supply a specific `validate` function - which will be run here. - - Args: - label: xtrigger label - fctx: function context - fdir: function directory - - Raises: - XtriggerConfigError: - * If the function module was not found. - * If the function was not found in the xtrigger module. - * If the function is not callable. - * If any string template in the function context - arguments are not present in the expected template values. - * If the arguments do not match the function signature. - - """ - fname: str = fctx.func_name - - try: - func = get_xtrig_func(fname, fname, fdir) - except ImportError: - raise XtriggerConfigError( - label, f"xtrigger module '{fname}' not found", - ) - except AttributeError: - raise XtriggerConfigError( - label, f"'{fname}' not found in xtrigger module '{fname}'", - ) - - if not callable(func): - raise XtriggerConfigError( - label, f"'{fname}' not callable in xtrigger module '{fname}'", - ) - - sig = signature(func) - sig_str = fctx.get_signature() - - # Handle reserved 'sequential' kwarg: - sequential_param = sig.parameters.get('sequential', None) - if sequential_param: - if not isinstance(sequential_param.default, bool): - raise XtriggerConfigError( - label, - ( - f"xtrigger '{fname}' function definition contains " - "reserved argument 'sequential' that has no " - "boolean default" - ) - ) - fctx.func_kwargs.setdefault('sequential', sequential_param.default) - elif 'sequential' in fctx.func_kwargs: - # xtrig call marked as sequential; add 'sequential' arg to - # signature for validation - sig = add_kwarg_to_sig( - sig, 'sequential', fctx.func_kwargs['sequential'] - ) - - # Validate args and kwargs against the function signature - try: - bound_args = sig.bind( - *fctx.func_args, **fctx.func_kwargs - ) - except TypeError as exc: - raise XtriggerConfigError(label, f"{sig_str}: {exc}") - # Specific xtrigger.validate(), if available. - XtriggerManager.try_xtrig_validate_func( - label, fname, fdir, bound_args, sig_str + def add_xtriggers(self, xtriggers: 'XtriggerCollator'): + """Add pre-collated and validated xtriggers.""" + self.xtriggers.update(xtriggers) + self.xtriggers.sequential_xtriggers_default = ( + xtriggers.sequential_xtriggers_default ) - # Check any string templates in the function arg values (note this - # won't catch bad task-specific values - which are added dynamically). - template_vars = set() - for argv in fctx.func_args + list(fctx.func_kwargs.values()): - if not isinstance(argv, str): - # Not a string arg. - continue - - # check template variables are valid - for match in RE_STR_TMPL.findall(argv): - try: - template_vars.add(TemplateVariables(match)) - except ValueError: - raise XtriggerConfigError( - label, f"Illegal template in xtrigger: {match}", - ) - - # check for deprecated template variables - deprecated_variables = template_vars & { - TemplateVariables.WorkflowName, - TemplateVariables.SuiteName, - TemplateVariables.SuiteRunDir, - TemplateVariables.SuiteShareDir, - } - if deprecated_variables: - LOG.warning( - f'Xtrigger "{label}" uses deprecated template variables:' - f' {", ".join(t.value for t in deprecated_variables)}' - ) - - @staticmethod - def try_xtrig_validate_func( - label: str, - fname: str, - fdir: str, - bound_args: 'BoundArguments', - signature_str: str, - ): - """Call an xtrigger's `validate()` function if it is implemented. - - Raise XtriggerConfigError if validation fails. - """ - try: - xtrig_validate_func = get_xtrig_func(fname, 'validate', fdir) - except (AttributeError, ImportError): - return - bound_args.apply_defaults() - try: - xtrig_validate_func(bound_args.arguments) - except Exception as exc: # Note: catch all errors - raise XtriggerConfigError( - label, f"{signature_str} validation failed: {exc}" - ) - - def add_trig(self, label: str, fctx: 'SubFuncContext', fdir: str) -> None: - """Add a new xtrigger function. - - Call check_xtrigger before this, during validation. - - Args: - label: xtrigger label - fctx: function context - fdir: function module directory - - """ - self.functx_map[label] = fctx - if fctx.func_kwargs.pop( - 'sequential', - self.sequential_xtriggers_default - ): - self.sequential_xtrigger_labels.add(label) - if fctx.func_name == "wall_clock": - self.wall_clock_labels.add(label) - def mutate_trig(self, label, kwargs): - self.functx_map[label].func_kwargs.update(kwargs) + self.xtriggers.functx_map[label].func_kwargs.update(kwargs) def load_xtrigger_for_restart(self, row_idx: int, row: Tuple[str, str]): """Load satisfied xtrigger results from workflow DB. @@ -502,11 +589,11 @@ def get_xtrig_ctx( TemplateVariables.TaskID.value: str(itask.identity) } farg_templ.update(self.farg_templ) - ctx = deepcopy(self.functx_map[label]) + ctx = deepcopy(self.xtriggers.functx_map[label]) args = [] kwargs = {} - if label in self.wall_clock_labels: + if label in self.xtriggers.wall_clock_labels: if "trigger_time" in ctx.func_kwargs: # noqa: SIM401 (readabilty) # Internal (retry timer): trigger_time already set. kwargs["trigger_time"] = ctx.func_kwargs["trigger_time"] @@ -545,7 +632,7 @@ def call_xtriggers_async(self, itask: 'TaskProxy'): itask: task proxy to check. """ for label, sig, ctx, _ in self._get_xtrigs(itask, unsat_only=True): - if label in self.wall_clock_labels: + if label in self.xtriggers.wall_clock_labels: # Special case: quick synchronous clock check. if sig in self.sat_xtrig: # Already satisfied, just update the task @@ -615,7 +702,7 @@ def all_task_seq_xtriggers_satisfied(self, itask: 'TaskProxy') -> bool: return itask.is_xtrigger_sequential and all( itask.state.xtriggers[label] for label in itask.state.xtriggers - if label in self.sequential_xtrigger_labels + if label in self.xtriggers.sequential_xtrigger_labels ) def callback(self, ctx: 'SubFuncContext'): @@ -623,6 +710,9 @@ def callback(self, ctx: 'SubFuncContext'): Record satisfaction status and function results dict. + Log a warning if the xtrigger functions errors, to distinguish + errors from not-satisfied. + Args: ctx (SubFuncContext): function context Raises: @@ -630,15 +720,25 @@ def callback(self, ctx: 'SubFuncContext'): """ sig = ctx.get_signature() self.active.remove(sig) + + if ctx.ret_code != 0: + msg = f"ERROR in xtrigger {sig}" + if ctx.err: + msg += f"\n{ctx.err}" + LOG.warning(msg) + try: satisfied, results = json.loads(ctx.out) except (ValueError, TypeError): return + LOG.debug('%s: returned %s', sig, results) - if satisfied: - # Newly satisfied - self.data_store_mgr.delta_task_xtrigger(sig, True) - self.workflow_db_mgr.put_xtriggers({sig: results}) - LOG.info('xtrigger satisfied: %s = %s', ctx.label, sig) - self.sat_xtrig[sig] = results - self.do_housekeeping = True + if not satisfied: + return + + # Newly satisfied + self.data_store_mgr.delta_task_xtrigger(sig, True) + self.workflow_db_mgr.put_xtriggers({sig: results}) + LOG.info('xtrigger satisfied: %s = %s', ctx.label, sig) + self.sat_xtrig[sig] = results + self.do_housekeeping = True diff --git a/cylc/flow/xtriggers/suite_state.py b/cylc/flow/xtriggers/suite_state.py index 45a8418a832..b2ce3783c32 100644 --- a/cylc/flow/xtriggers/suite_state.py +++ b/cylc/flow/xtriggers/suite_state.py @@ -16,7 +16,7 @@ from cylc.flow import LOG import cylc.flow.flags -from cylc.flow.xtriggers.workflow_state import workflow_state +from cylc.flow.xtriggers.workflow_state import _workflow_state_backcompat if not cylc.flow.flags.cylc7_back_compat: LOG.warning( @@ -72,12 +72,6 @@ def suite_state(suite, task, point, offset=None, status='succeeded', to this xtrigger. """ - return workflow_state( - workflow=suite, - task=task, - point=point, - offset=offset, - status=status, - message=message, - cylc_run_dir=cylc_run_dir + return _workflow_state_backcompat( + suite, task, point, offset, status, message, cylc_run_dir ) diff --git a/cylc/flow/xtriggers/workflow_state.py b/cylc/flow/xtriggers/workflow_state.py index 76755085aa6..a2b7d2f1946 100644 --- a/cylc/flow/xtriggers/workflow_state.py +++ b/cylc/flow/xtriggers/workflow_state.py @@ -14,18 +14,133 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -import sqlite3 -from typing import Dict, Optional, Tuple +from typing import Dict, Optional, Tuple, Any +import asyncio +from inspect import signature -from metomi.isodatetime.parsers import TimePointParser - -from cylc.flow.cycling.util import add_offset -from cylc.flow.dbstatecheck import CylcWorkflowDBChecker -from cylc.flow.pathutil import get_cylc_run_dir -from cylc.flow.workflow_files import infer_latest_run_from_id +from cylc.flow.scripts.workflow_state import WorkflowPoller +from cylc.flow.id import tokenise +from cylc.flow.exceptions import WorkflowConfigError +from cylc.flow.task_state import TASK_STATUS_SUCCEEDED def workflow_state( + workflow_task_id: str, + offset: Optional[str] = None, + flow_num: Optional[int] = None, + is_trigger: bool = False, + is_message: bool = False, + alt_cylc_run_dir: Optional[str] = None, +) -> Tuple[bool, Dict[str, Any]]: + """Connect to a workflow DB and check a task status or output. + + If the status or output has been achieved, return {True, result}. + + Arg: + workflow_task_id: + ID (workflow//point/task:selector) of the target task. + offset: + Offset from cycle point as an ISO8601 or integer duration, + e.g. PT1H (1 hour) or P1 (1 integer cycle) + flow_num: + Flow number of the target task. + is_message: + Interpret the task:selector as a task output message + (the default is a task status or trigger) + is_trigger: + Interpret the task:selector as a task trigger name + (only needed if it is also a valid status name) + alt_cylc_run_dir: + Alternate cylc-run directory, e.g. for another user. + + Returns: + tuple: (satisfied, result) + + satisfied: + True if ``satisfied`` else ``False``. + result: + Dict of workflow, task, point, offset, + status, message, trigger, flow_num, run_dir + + """ + poller = WorkflowPoller( + workflow_task_id, + offset, + flow_num, + alt_cylc_run_dir, + TASK_STATUS_SUCCEEDED, + is_trigger, is_message, + old_format=False, + condition=workflow_task_id, + max_polls=1, # (for xtriggers the scheduler does the polling) + interval=0, # irrelevant for 1 poll + args=[] + ) + + # NOTE the results dict item names remain compatible with older usage. + + if asyncio.run(poller.poll()): + results = { + 'workflow': poller.workflow_id, + 'task': poller.task, + 'point': poller.cycle, + } + if poller.alt_cylc_run_dir is not None: + results['cylc_run_dir'] = poller.alt_cylc_run_dir + + if offset is not None: + results['offset'] = poller.offset + + if flow_num is not None: + results["flow_num"] = poller.flow_num + + if poller.is_message: + results['message'] = poller.selector + elif poller.is_trigger: + results['trigger'] = poller.selector + else: + results['status'] = poller.selector + + return (True, results) + else: + return (False, {}) + + +def validate(args: Dict[str, Any]): + """Validate workflow_state xtrigger function args. + + Arguments: + workflow_task_id: + full workflow//cycle/task[:selector] + offset: + must be a valid status + flow_num: + must be an integer + alt_cylc_run_dir: + must be a valid path + + """ + tokens = tokenise(args["workflow_task_id"]) + + if any( + tokens[token] is None + for token in ("workflow", "cycle", "task") + ): + raise WorkflowConfigError( + "Full ID needed: workflow//cycle/task[:selector].") + + if ( + args["flow_num"] is not None and + not isinstance(args["flow_num"], int) + ): + raise WorkflowConfigError("flow_num must be an integer if given.") + + +# BACK COMPAT: workflow_state_backcompat +# from: 8.0.0 +# to: 8.3.0 +# remove at: 8.x +def _workflow_state_backcompat( workflow: str, task: str, point: str, @@ -34,10 +149,9 @@ def workflow_state( message: Optional[str] = None, cylc_run_dir: Optional[str] = None ) -> Tuple[bool, Optional[Dict[str, Optional[str]]]]: - """Connect to a workflow DB and query the requested task state. + """Back-compat wrapper for the workflow_state xtrigger. - * Reports satisfied only if the remote workflow state has been achieved. - * Returns all workflow state args to pass on to triggering tasks. + Note Cylc 7 DBs only stored custom task outputs, not standard ones. Arguments: workflow: @@ -58,15 +172,10 @@ def workflow_state( .. note:: This cannot be specified in conjunction with ``status``. + cylc_run_dir: Alternate cylc-run directory, e.g. for another user. - .. note:: - - This only needs to be supplied if the workflow is running in a - different location to what is specified in the global - configuration (usually ``~/cylc-run``). - Returns: tuple: (satisfied, results) @@ -77,32 +186,7 @@ def workflow_state( to this xtrigger. """ - workflow = infer_latest_run_from_id(workflow, cylc_run_dir) - cylc_run_dir = get_cylc_run_dir(cylc_run_dir) - - if offset is not None: - point = str(add_offset(point, offset)) - - try: - checker = CylcWorkflowDBChecker(cylc_run_dir, workflow) - except (OSError, sqlite3.Error): - # Failed to connect to DB; target workflow may not be started. - return (False, None) - try: - fmt = checker.get_remote_point_format() - except sqlite3.OperationalError as exc: - try: - fmt = checker.get_remote_point_format_compat() - except sqlite3.OperationalError: - raise exc # original error - if fmt: - my_parser = TimePointParser() - point = str(my_parser.parse(point, dump_format=fmt)) - if message is not None: - satisfied = checker.task_state_met(task, point, message=message) - else: - satisfied = checker.task_state_met(task, point, status=status) - results = { + args = { 'workflow': workflow, 'task': task, 'point': point, @@ -111,4 +195,44 @@ def workflow_state( 'message': message, 'cylc_run_dir': cylc_run_dir } - return satisfied, results + upg_args = _upgrade_workflow_state_sig(args) + satisfied, _results = workflow_state(**upg_args) + + return (satisfied, args) + + +# BACK COMPAT: workflow_state_backcompat +# from: 8.0.0 +# to: 8.3.0 +# remove at: 8.x +def _upgrade_workflow_state_sig(args: Dict[str, Any]) -> Dict[str, Any]: + """Return upgraded args for workflow_state, given the deprecated args.""" + is_message = False + workflow_task_id = f"{args['workflow']}//{args['point']}/{args['task']}" + status = args.get('status') + message = args.get('message') + if status is not None: + workflow_task_id += f":{status}" + elif message is not None: + is_message = True + workflow_task_id += f":{message}" + return { + 'workflow_task_id': workflow_task_id, + 'offset': args.get('offset'), + 'alt_cylc_run_dir': args.get('cylc_run_dir'), + 'is_message': is_message, + } + + +# BACK COMPAT: workflow_state_backcompat +# from: 8.0.0 +# to: 8.3.0 +# remove at: 8.x +def _validate_backcompat(args: Dict[str, Any]): + """Validate old workflow_state xtrigger function args. + """ + bound_args = signature(workflow_state).bind( + **_upgrade_workflow_state_sig(args) + ) + bound_args.apply_defaults() + validate(bound_args.arguments) diff --git a/tests/flakyfunctional/cylc-poll/16-execution-time-limit.t b/tests/flakyfunctional/cylc-poll/16-execution-time-limit.t index c437867251d..a01d42e2ab3 100755 --- a/tests/flakyfunctional/cylc-poll/16-execution-time-limit.t +++ b/tests/flakyfunctional/cylc-poll/16-execution-time-limit.t @@ -31,7 +31,7 @@ install_workflow "${TEST_NAME_BASE}" "${TEST_NAME_BASE}" #------------------------------------------------------------------------------- run_ok "${TEST_NAME_BASE}-validate" cylc validate "${WORKFLOW_NAME}" workflow_run_ok "${TEST_NAME_BASE}-run" \ - cylc play --reference-test -v --no-detach "${WORKFLOW_NAME}" + cylc play --reference-test -v --no-detach "${WORKFLOW_NAME}" --timestamp #------------------------------------------------------------------------------- cmp_times () { # Test if the times $1 and $2 are within $3 seconds of each other. diff --git a/tests/flakyfunctional/events/44-timeout.t b/tests/flakyfunctional/events/44-timeout.t index c28557194d8..35da69e3185 100755 --- a/tests/flakyfunctional/events/44-timeout.t +++ b/tests/flakyfunctional/events/44-timeout.t @@ -43,7 +43,7 @@ ${LOG_INDENT}[(('event-handler-00', 'started'), 1) err] killed on timeout (PT10S WARNING - 1/foo/01 handler:event-handler-00 for task event:started failed __END__ -cylc workflow-state "${WORKFLOW_NAME}" >'workflow-state.log' +cylc workflow-state --old-format "${WORKFLOW_NAME}" >'workflow-state.log' contains_ok 'workflow-state.log' << __END__ stopper, 1, succeeded diff --git a/tests/flakyfunctional/xtriggers/01-workflow_state.t b/tests/flakyfunctional/xtriggers/01-workflow_state.t index 63cc34c1161..a809e7e7975 100644 --- a/tests/flakyfunctional/xtriggers/01-workflow_state.t +++ b/tests/flakyfunctional/xtriggers/01-workflow_state.t @@ -45,7 +45,7 @@ WORKFLOW_LOG="$(cylc cat-log -m 'p' "${WORKFLOW_NAME}")" grep_ok 'WARNING - inactivity timer timed out after PT20S' "${WORKFLOW_LOG}" # ... with 2016/foo succeeded and 2016/FAM waiting. -cylc workflow-state -p '2016' "${WORKFLOW_NAME}" >'workflow_state.out' +cylc workflow-state --old-format "${WORKFLOW_NAME}//2016" >'workflow_state.out' contains_ok 'workflow_state.out' << __END__ foo, 2016, succeeded f3, 2016, waiting @@ -56,12 +56,10 @@ __END__ # Check broadcast of xtrigger outputs to dependent tasks. JOB_LOG="$(cylc cat-log -f 'j' -m 'p' "${WORKFLOW_NAME}//2015/f1")" contains_ok "${JOB_LOG}" << __END__ + upstream_workflow="${WORKFLOW_NAME_UPSTREAM}" upstream_task="foo" upstream_point="2015" - upstream_status="succeeded" - upstream_message="data ready" - upstream_offset="None" - upstream_workflow="${WORKFLOW_NAME_UPSTREAM}" + upstream_trigger="data_ready" __END__ # Check broadcast of xtrigger outputs is recorded: 1) in the workflow log... @@ -73,14 +71,11 @@ contains_ok "${WORKFLOW_LOG}" << __LOG_BROADCASTS__ ${LOG_INDENT}+ [2015/f1] [environment]upstream_workflow=${WORKFLOW_NAME_UPSTREAM} ${LOG_INDENT}+ [2015/f1] [environment]upstream_task=foo ${LOG_INDENT}+ [2015/f1] [environment]upstream_point=2015 -${LOG_INDENT}+ [2015/f1] [environment]upstream_offset=None -${LOG_INDENT}+ [2015/f1] [environment]upstream_status=succeeded -${LOG_INDENT}+ [2015/f1] [environment]upstream_message=data ready +${LOG_INDENT}+ [2015/f1] [environment]upstream_trigger=data_ready ${LOG_INDENT}- [2015/f1] [environment]upstream_workflow=${WORKFLOW_NAME_UPSTREAM} ${LOG_INDENT}- [2015/f1] [environment]upstream_task=foo ${LOG_INDENT}- [2015/f1] [environment]upstream_point=2015 -${LOG_INDENT}- [2015/f1] [environment]upstream_status=succeeded -${LOG_INDENT}- [2015/f1] [environment]upstream_message=data ready +${LOG_INDENT}- [2015/f1] [environment]upstream_trigger=data_ready __LOG_BROADCASTS__ # ... and 2) in the DB. TEST_NAME="${TEST_NAME_BASE}-check-broadcast-in-db" @@ -93,17 +88,14 @@ sqlite3 "${DB_FILE}" \ 'SELECT change, point, namespace, key, value FROM broadcast_events ORDER BY time, change, point, namespace, key' >"${NAME}" contains_ok "${NAME}" << __DB_BROADCASTS__ -+|2015|f1|[environment]upstream_message|data ready -+|2015|f1|[environment]upstream_offset|None -+|2015|f1|[environment]upstream_point|2015 -+|2015|f1|[environment]upstream_status|succeeded +|2015|f1|[environment]upstream_workflow|${WORKFLOW_NAME_UPSTREAM} +|2015|f1|[environment]upstream_task|foo --|2015|f1|[environment]upstream_message|data ready --|2015|f1|[environment]upstream_point|2015 --|2015|f1|[environment]upstream_status|succeeded ++|2015|f1|[environment]upstream_point|2015 ++|2015|f1|[environment]upstream_trigger|data_ready -|2015|f1|[environment]upstream_workflow|${WORKFLOW_NAME_UPSTREAM} -|2015|f1|[environment]upstream_task|foo +-|2015|f1|[environment]upstream_point|2015 +-|2015|f1|[environment]upstream_trigger|data_ready __DB_BROADCASTS__ purge @@ -112,4 +104,3 @@ purge cylc stop --now "${WORKFLOW_NAME_UPSTREAM}" --max-polls=20 --interval=2 \ >'/dev/null' 2>&1 purge "${WORKFLOW_NAME_UPSTREAM}" -exit diff --git a/tests/flakyfunctional/xtriggers/01-workflow_state/flow.cylc b/tests/flakyfunctional/xtriggers/01-workflow_state/flow.cylc index 9502a4bf82f..609b0be3a05 100644 --- a/tests/flakyfunctional/xtriggers/01-workflow_state/flow.cylc +++ b/tests/flakyfunctional/xtriggers/01-workflow_state/flow.cylc @@ -1,15 +1,14 @@ #!Jinja2 [scheduler] - cycle point format = %Y + cycle point format = %Y [[events]] - inactivity timeout = PT20S - abort on inactivity timeout = True + inactivity timeout = PT20S + abort on inactivity timeout = True [scheduling] initial cycle point = 2011 final cycle point = 2016 [[xtriggers]] - upstream = workflow_state(workflow={{UPSTREAM}}, task=foo,\ - point=%(point)s, message='data ready'):PT1S + upstream = workflow_state("{{UPSTREAM}}//%(point)s/foo:data_ready"):PT1S [[graph]] P1Y = """ foo diff --git a/tests/flakyfunctional/xtriggers/01-workflow_state/upstream/flow.cylc b/tests/flakyfunctional/xtriggers/01-workflow_state/upstream/flow.cylc index 5787f0d29ba..19eb3214844 100644 --- a/tests/flakyfunctional/xtriggers/01-workflow_state/upstream/flow.cylc +++ b/tests/flakyfunctional/xtriggers/01-workflow_state/upstream/flow.cylc @@ -4,7 +4,7 @@ initial cycle point = 2010 final cycle point = 2015 [[graph]] - P1Y = "foo:x => bar" + P1Y = "foo:data_ready => bar" [runtime] [[root]] script = true @@ -12,4 +12,4 @@ [[foo]] script = cylc message "data ready" [[[outputs]]] - x = "data ready" + data_ready = "data ready" diff --git a/tests/functional/cylc-cat-log/04-local-tail.t b/tests/functional/cylc-cat-log/04-local-tail.t index 741e8bb58a2..2bed3cf4162 100755 --- a/tests/functional/cylc-cat-log/04-local-tail.t +++ b/tests/functional/cylc-cat-log/04-local-tail.t @@ -29,7 +29,7 @@ create_test_global_config "" " TEST_NAME="${TEST_NAME_BASE}-validate" run_ok "${TEST_NAME}" cylc validate "${WORKFLOW_NAME}" workflow_run_ok "${TEST_NAME_BASE}-run" cylc play "${WORKFLOW_NAME}" -cylc workflow-state "${WORKFLOW_NAME}" -t 'foo' -p '1' -S 'start' --interval=1 +cylc workflow-state "${WORKFLOW_NAME}//1/foo:started" --interval=1 sleep 1 TEST_NAME=${TEST_NAME_BASE}-cat-log cylc cat-log "${WORKFLOW_NAME}//1/foo" -f o -m t > "${TEST_NAME}.out" diff --git a/tests/functional/cylc-config/00-simple.t b/tests/functional/cylc-config/00-simple.t index 89c3713628d..00981edc7d3 100755 --- a/tests/functional/cylc-config/00-simple.t +++ b/tests/functional/cylc-config/00-simple.t @@ -52,10 +52,7 @@ cmp_ok "${TEST_NAME}.stderr" - stdout.1 -sort "$TEST_SOURCE_DIR/${TEST_NAME_BASE}/section2.stdout" > stdout.2 -cmp_ok stdout.1 stdout.2 +cmp_ok "${TEST_NAME}.stdout" "$TEST_SOURCE_DIR/${TEST_NAME_BASE}/section2.stdout" cmp_ok "${TEST_NAME}.stderr" - db-bar.2 cmp_ok "db-bar.2" - << __OUT__ -["expired"] +{"expired": "(manually completed)"} __OUT__ purge diff --git a/tests/functional/data-store/00-prune-optional-break.t b/tests/functional/data-store/00-prune-optional-break.t index 39de0225e34..9b09ac8d156 100755 --- a/tests/functional/data-store/00-prune-optional-break.t +++ b/tests/functional/data-store/00-prune-optional-break.t @@ -37,7 +37,7 @@ d => e script = false [[d]] script = """ -cylc workflow-state \$CYLC_WORKFLOW_ID --task=b --point=1 --status=failed --interval=2 +cylc workflow-state \${CYLC_WORKFLOW_ID}//1/b:failed --interval=2 cylc pause \$CYLC_WORKFLOW_ID """ __FLOW__ @@ -45,7 +45,7 @@ __FLOW__ # run workflow run_ok "${TEST_NAME_BASE}-run" cylc play "${WORKFLOW_NAME}" -cylc workflow-state "${WORKFLOW_NAME}" --task=d --point=1 --status=succeeded --interval=2 --max-polls=60 +cylc workflow-state "${WORKFLOW_NAME}/1/d:succeeded" --interval=2 --max-polls=60 # query workflow TEST_NAME="${TEST_NAME_BASE}-prune-optional-break" diff --git a/tests/functional/deprecations/01-cylc8-basic/validation.stderr b/tests/functional/deprecations/01-cylc8-basic/validation.stderr index 288df3f98e2..034031f3a71 100644 --- a/tests/functional/deprecations/01-cylc8-basic/validation.stderr +++ b/tests/functional/deprecations/01-cylc8-basic/validation.stderr @@ -1,4 +1,5 @@ -WARNING - deprecated items were automatically upgraded in "workflow definition" +WARNING - Obsolete config items were automatically deleted. Please check your workflow and remove them permanently. +WARNING - Deprecated config items were automatically upgraded. Please alter your workflow to use the new syntax. WARNING - * (8.0.0) [cylc]force run mode - DELETED (OBSOLETE) WARNING - * (8.0.0) [cylc][authentication] - DELETED (OBSOLETE) WARNING - * (8.0.0) [cylc]log resolved dependencies - DELETED (OBSOLETE) @@ -13,6 +14,7 @@ WARNING - * (8.0.0) [cylc][reference test] - DELETED (OBSOLETE) WARNING - * (8.0.0) [cylc][simulation]disable suite event handlers - DELETED (OBSOLETE) WARNING - * (8.0.0) [cylc][simulation] - DELETED (OBSOLETE) WARNING - * (8.0.0) [cylc]task event mail interval -> [cylc][mail]task event batch interval - value unchanged +WARNING - * (8.0.0) [runtime][foo, cat, dog][suite state polling] -> [runtime][foo, cat, dog][workflow state polling] - value unchanged WARNING - * (8.0.0) [cylc][parameters] -> [task parameters] - value unchanged WARNING - * (8.0.0) [cylc][parameter templates] -> [task parameters][templates] - value unchanged WARNING - * (8.0.0) [cylc][events]mail to -> [cylc][mail]to - value unchanged @@ -24,7 +26,6 @@ WARNING - * (8.0.0) [cylc][events]mail smtp - DELETED (OBSOLETE) - use "global. WARNING - * (8.0.0) [runtime][foo, cat, dog][events]mail smtp - DELETED (OBSOLETE) - use "global.cylc[scheduler][mail]smtp" instead WARNING - * (8.0.0) [scheduling]max active cycle points -> [scheduling]runahead limit - "2" -> "P1" WARNING - * (8.0.0) [scheduling]hold after point -> [scheduling]hold after cycle point - value unchanged -WARNING - * (8.0.0) [runtime][foo, cat, dog][suite state polling] -> [runtime][foo, cat, dog][workflow state polling] - value unchanged WARNING - * (8.0.0) [runtime][foo, cat, dog][job]execution polling intervals -> [runtime][foo, cat, dog]execution polling intervals - value unchanged WARNING - * (8.0.0) [runtime][foo, cat, dog][job]execution retry delays -> [runtime][foo, cat, dog]execution retry delays - value unchanged WARNING - * (8.0.0) [runtime][foo, cat, dog][job]execution time limit -> [runtime][foo, cat, dog]execution time limit - value unchanged @@ -47,6 +48,6 @@ WARNING - * (8.0.0) [cylc][events]abort if timeout handler fails - DELETED (OBS WARNING - * (8.0.0) [cylc][events]abort if inactivity handler fails - DELETED (OBSOLETE) WARNING - * (8.0.0) [cylc][events]abort if stalled handler fails - DELETED (OBSOLETE) WARNING - * (8.0.0) [cylc] -> [scheduler] - value unchanged -WARNING - deprecated graph items were automatically upgraded in "workflow definition": +WARNING - graph items were automatically upgraded in "workflow definition": * (8.0.0) [scheduling][dependencies][X]graph -> [scheduling][graph]X - for X in: P1D diff --git a/tests/functional/flow-triggers/11-wait-merge.t b/tests/functional/flow-triggers/11-wait-merge.t index cb3218ae463..d869cc19600 100644 --- a/tests/functional/flow-triggers/11-wait-merge.t +++ b/tests/functional/flow-triggers/11-wait-merge.t @@ -30,14 +30,14 @@ QUERY="SELECT cycle, name,flow_nums,outputs FROM task_outputs;" run_ok "${TEST_NAME}" sqlite3 "${DB}" "$QUERY" cmp_ok "${TEST_NAME}.stdout" <<\__END__ -1|a|[1]|["submitted", "started", "succeeded"] -1|b|[1]|["submitted", "started", "succeeded"] -1|a|[2]|["submitted", "started", "succeeded"] -1|c|[2]|["submitted", "started", "x"] -1|c|[1, 2]|["submitted", "started", "succeeded", "x"] -1|x|[1, 2]|["submitted", "started", "succeeded"] -1|d|[1, 2]|["submitted", "started", "succeeded"] -1|b|[2]|["submitted", "started", "succeeded"] +1|a|[1]|{"submitted": "submitted", "started": "started", "succeeded": "succeeded"} +1|b|[1]|{"submitted": "submitted", "started": "started", "succeeded": "succeeded"} +1|a|[2]|{"submitted": "submitted", "started": "started", "succeeded": "succeeded"} +1|c|[2]|{"submitted": "submitted", "started": "started", "x": "x"} +1|c|[1, 2]|{"submitted": "submitted", "started": "started", "succeeded": "succeeded", "x": "x"} +1|x|[1, 2]|{"submitted": "submitted", "started": "started", "succeeded": "succeeded"} +1|d|[1, 2]|{"submitted": "submitted", "started": "started", "succeeded": "succeeded"} +1|b|[2]|{"submitted": "submitted", "started": "started", "succeeded": "succeeded"} __END__ purge diff --git a/tests/functional/job-submission/16-timeout.t b/tests/functional/job-submission/16-timeout.t index b6042b56dad..10410cb7cb7 100755 --- a/tests/functional/job-submission/16-timeout.t +++ b/tests/functional/job-submission/16-timeout.t @@ -51,7 +51,7 @@ ERROR - [jobs-submit cmd] cylc jobs-submit --debug ${DEFAULT_PATHS} -- '${JOB_LO [jobs-submit err] killed on timeout (PT10S) __END__ -cylc workflow-state "${WORKFLOW_NAME}" > workflow-state.log +cylc workflow-state --old-format "${WORKFLOW_NAME}" > workflow-state.log # make sure foo submit failed and the stopper ran contains_ok workflow-state.log << __END__ diff --git a/tests/functional/logging/04-dev_mode.t b/tests/functional/logging/04-dev_mode.t index 45b59720875..2ff83ddb4aa 100644 --- a/tests/functional/logging/04-dev_mode.t +++ b/tests/functional/logging/04-dev_mode.t @@ -35,12 +35,12 @@ run_ok "${TEST_NAME_BASE}-validate-plain" \ cylc validate "${WORKFLOW_NAME}" run_ok "${TEST_NAME_BASE}-validate-vvv" \ - cylc validate -vvv "${WORKFLOW_NAME}" + cylc validate --timestamp -vvv "${WORKFLOW_NAME}" grep_ok " DEBUG - \[config:.*\]" "${TEST_NAME_BASE}-validate-vvv.stderr" run_ok "${TEST_NAME_BASE}-validate-vvv--no-timestamp" \ - cylc validate -vvv --no-timestamp "${WORKFLOW_NAME}" + cylc validate -vvv "${WORKFLOW_NAME}" grep_ok "^DEBUG - \[config:.*\]" "${TEST_NAME_BASE}-validate-vvv--no-timestamp.stderr" purge diff --git a/tests/functional/optional-outputs/08-finish-fail-c7-c8.t b/tests/functional/optional-outputs/08-finish-fail-c7-c8.t index e9cfbf831b1..0200dfc5e35 100644 --- a/tests/functional/optional-outputs/08-finish-fail-c7-c8.t +++ b/tests/functional/optional-outputs/08-finish-fail-c7-c8.t @@ -32,7 +32,7 @@ mv "${WORKFLOW_RUN_DIR}/suite.rc" "${WORKFLOW_RUN_DIR}/flow.cylc" TEST_NAME="${TEST_NAME_BASE}-validate_as_c8" run_ok "${TEST_NAME}" cylc validate "${WORKFLOW_NAME}" -DEPR_MSG="deprecated graph items were automatically upgraded" # (not back-compat) +DEPR_MSG="graph items were automatically upgraded" # (not back-compat) grep_ok "${DEPR_MSG}" "${TEST_NAME}.stderr" # No stall expected. diff --git a/tests/functional/queues/02-queueorder.t b/tests/functional/queues/02-queueorder.t index ba269eb0cfc..c2babbfc784 100644 --- a/tests/functional/queues/02-queueorder.t +++ b/tests/functional/queues/02-queueorder.t @@ -22,7 +22,7 @@ set_test_number 3 install_workflow "${TEST_NAME_BASE}" "${TEST_NAME_BASE}" run_ok "${TEST_NAME_BASE}-validate" cylc validate "${WORKFLOW_NAME}" run_ok "${TEST_NAME_BASE}-run" \ - cylc play "${WORKFLOW_NAME}" --reference-test --debug --no-detach + cylc play "${WORKFLOW_NAME}" --reference-test --debug --no-detach --timestamp run_ok "${TEST_NAME_BASE}-test" bash -o pipefail -c " cylc cat-log '${WORKFLOW_NAME}' | grep 'proc_n.*submitted at' | diff --git a/tests/functional/queues/qsize/flow.cylc b/tests/functional/queues/qsize/flow.cylc index 4cfce86d0a5..e8f65d6816f 100644 --- a/tests/functional/queues/qsize/flow.cylc +++ b/tests/functional/queues/qsize/flow.cylc @@ -13,11 +13,11 @@ inherit = FAM [[monitor]] script = """ - N_SUCCEDED=0 - while ((N_SUCCEDED < 12)); do + N_SUCCEEDED=0 + while ((N_SUCCEEDED < 12)); do sleep 1 - N_RUNNING=$(cylc workflow-state $CYLC_WORKFLOW_ID -S running | wc -l) + N_RUNNING=$(cylc dump -t $CYLC_WORKFLOW_ID | grep running | wc -l) ((N_RUNNING <= {{q_size}})) # check - N_SUCCEDED=$(cylc workflow-state $CYLC_WORKFLOW_ID -S succeeded | wc -l) + N_SUCCEEDED=$(cylc workflow-state "${CYLC_WORKFLOW_ID}//*/*:succeeded" | wc -l) done """ diff --git a/tests/functional/reload/03-queues/flow.cylc b/tests/functional/reload/03-queues/flow.cylc index 21af35196c9..8520a61e15a 100644 --- a/tests/functional/reload/03-queues/flow.cylc +++ b/tests/functional/reload/03-queues/flow.cylc @@ -28,13 +28,15 @@ cylc__job__poll_grep_workflow_log 'Reload completed' script = """ cylc__job__wait_cylc_message_started while true; do - RUNNING=$(cylc workflow-state $CYLC_WORKFLOW_ID -S running | wc -l) + RUNNING=$(cylc dump -t "${CYLC_WORKFLOW_ID}" | grep running | wc -l) # Should be max of: monitor plus 3 members of q1 + echo "RUNNING $RUNNING" if ((RUNNING > 4)); then break fi sleep 1 - SUCCEEDED=$(cylc workflow-state $CYLC_WORKFLOW_ID -S succeeded | wc -l) + SUCCEEDED=$(cylc workflow-state "${CYLC_WORKFLOW_ID}//*/*:succeeded" --max-polls=1 | wc -l) + echo "SUCCEEDED $SUCCEEDED" if ((SUCCEEDED==13)); then break fi diff --git a/tests/functional/reload/22-remove-task-cycling.t b/tests/functional/reload/22-remove-task-cycling.t index 9936857ac2f..2db87601d77 100644 --- a/tests/functional/reload/22-remove-task-cycling.t +++ b/tests/functional/reload/22-remove-task-cycling.t @@ -73,7 +73,7 @@ TEST_NAME="${TEST_NAME_BASE}-run" workflow_run_ok "${TEST_NAME}" cylc play --debug --no-detach "${WORKFLOW_NAME}" TEST_NAME="${TEST_NAME_BASE}-result" -cylc workflow-state "${WORKFLOW_NAME}" > workflow-state.log +cylc workflow-state --old-format "${WORKFLOW_NAME}" > workflow-state.log contains_ok workflow-state.log << __END__ foo, 1, succeeded bar, 1, succeeded diff --git a/tests/functional/restart/30-outputs.t b/tests/functional/restart/30-outputs.t index 997b30031d4..fbf4d887cc1 100755 --- a/tests/functional/restart/30-outputs.t +++ b/tests/functional/restart/30-outputs.t @@ -28,7 +28,7 @@ run_ok "${TEST_NAME_BASE}-validate" cylc validate "${WORKFLOW_NAME}" workflow_run_ok "${TEST_NAME_BASE}-run" cylc play --no-detach "${WORKFLOW_NAME}" sqlite3 "${WORKFLOW_RUN_DIR}/log/db" \ 'SELECT outputs FROM task_outputs WHERE name IS "t1"' >'sqlite3.out' -cmp_json 'sqlite3.out' 'sqlite3.out' <<<'["submitted", "started", "succeeded", "hello"]' +cmp_json 'sqlite3.out' 'sqlite3.out' <<<'{"submitted": "submitted", "started": "started", "succeeded": "succeeded", "hello": "hi there"}' sqlite3 "${WORKFLOW_RUN_DIR}/log/db" 'SELECT * FROM task_pool' >'task-pool.out' cmp_ok 'task-pool.out' <'/dev/null' diff --git a/tests/functional/restart/30-outputs/flow.cylc b/tests/functional/restart/30-outputs/flow.cylc index 7a2168dac6a..51d7dc90c66 100644 --- a/tests/functional/restart/30-outputs/flow.cylc +++ b/tests/functional/restart/30-outputs/flow.cylc @@ -13,9 +13,9 @@ t1:greet? => t3 """ [runtime] [[t1]] - script = cylc message 'hello' + script = cylc message -- 'hi there' [[[outputs]]] - hello = hello + hello = "hi there" greet = greeting [[t2, t3]] script = true diff --git a/tests/functional/restart/34-auto-restart-basic.t b/tests/functional/restart/34-auto-restart-basic.t index 62b6677fc7e..63e6f2f2c94 100644 --- a/tests/functional/restart/34-auto-restart-basic.t +++ b/tests/functional/restart/34-auto-restart-basic.t @@ -51,10 +51,8 @@ __FLOW_CONFIG__ # run workflow on localhost normally create_test_global_config '' "${BASE_GLOBAL_CONFIG}" -run_ok "${TEST_NAME}-workflow-start" \ - cylc play "${WORKFLOW_NAME}" --host=localhost -s 'FOO="foo"' -v -cylc workflow-state "${WORKFLOW_NAME}" --task='task_foo01' \ - --status='succeeded' --point=1 --interval=1 --max-polls=20 >& $ERR +run_ok "${TEST_NAME}-workflow-start" cylc play "${WORKFLOW_NAME}" --host=localhost -s 'FOO="foo"' -v +cylc workflow-state "${WORKFLOW_NAME}//1/task_foo01:succeeded" --interval=1 --max-polls=20 >& $ERR # condemn localhost create_test_global_config '' " @@ -70,7 +68,7 @@ log_scan "${TEST_NAME}-shutdown-log-scan" "${FILE}" 20 1 \ 'Workflow shutting down - REQUEST(NOW-NOW)' \ "Attempting to restart on \"${CYLC_TEST_HOST}\"" \ "Workflow now running on \"${CYLC_TEST_HOST}\"" -LATEST_TASK=$(cylc workflow-state "${WORKFLOW_NAME}" -S succeeded \ +LATEST_TASK=$(cylc workflow-state --old-format "${WORKFLOW_NAME}//*/*:succeeded" \ | cut -d ',' -f 1 | sort | tail -n 1 | sed 's/task_foo//') # test restart procedure - scan the second log file created on restart @@ -78,9 +76,9 @@ poll_workflow_restart FILE=$(cylc cat-log "${WORKFLOW_NAME}" -m p |xargs readlink -f) log_scan "${TEST_NAME}-restart-log-scan" "${FILE}" 20 1 \ "Scheduler: url=tcp://$(get_fqdn "${CYLC_TEST_HOST}")" -run_ok "${TEST_NAME}-restart-success" cylc workflow-state "${WORKFLOW_NAME}" \ - --task="$(printf 'task_foo%02d' $(( LATEST_TASK + 3 )))" \ - --status='succeeded' --point=1 --interval=1 --max-polls=60 +run_ok "${TEST_NAME}-restart-success" \ + cylc workflow-state "${WORKFLOW_NAME}//1/$(printf 'task_foo%02d' $(( LATEST_TASK + 3 ))):succeeded" \ + --interval=1 --max-polls=60 # check the command the workflow has been restarted with run_ok "${TEST_NAME}-contact" cylc get-contact "${WORKFLOW_NAME}" diff --git a/tests/functional/restart/38-auto-restart-stopping.t b/tests/functional/restart/38-auto-restart-stopping.t index 6e6ebf2020e..92a3c9c0677 100644 --- a/tests/functional/restart/38-auto-restart-stopping.t +++ b/tests/functional/restart/38-auto-restart-stopping.t @@ -52,8 +52,7 @@ ${BASE_GLOBAL_CONFIG} " run_ok "${TEST_NAME}-workflow-start" cylc play "${WORKFLOW_NAME}" --host=localhost -cylc workflow-state "${WORKFLOW_NAME}" --task='foo' --status='running' --point=1 \ - --interval=1 --max-polls=20 >& $ERR +cylc workflow-state "${WORKFLOW_NAME}//1/foo:running" --interval=1 --max-polls=20 >& $ERR # condemn localhost create_test_global_config '' " diff --git a/tests/functional/restart/41-auto-restart-local-jobs.t b/tests/functional/restart/41-auto-restart-local-jobs.t index 0ee771a982d..bfd8161ef3f 100644 --- a/tests/functional/restart/41-auto-restart-local-jobs.t +++ b/tests/functional/restart/41-auto-restart-local-jobs.t @@ -62,8 +62,7 @@ cylc play "${WORKFLOW_NAME}" # ensure the workflow WAITS for local jobs to complete before restarting TEST_NAME="${TEST_NAME_BASE}-normal-mode" -cylc workflow-state "${WORKFLOW_NAME}" --task='foo' --status='running' --point=1 \ - --interval=1 --max-polls=20 >& $ERR +cylc workflow-state "${WORKFLOW_NAME}//1/foo:running" --interval=1 --max-polls=20 >& $ERR create_test_global_config '' " ${BASE_GLOBAL_CONFIG} @@ -93,7 +92,7 @@ log_scan "${TEST_NAME}-restart-log-scan" "$LOG_FILE" 20 1 \ TEST_NAME="${TEST_NAME_BASE}-force-mode" cylc trigger "${WORKFLOW_NAME}//1/bar" -cylc workflow-state "${WORKFLOW_NAME}" --task='bar' --status='running' --point=1 \ +cylc workflow-state "${WORKFLOW_NAME}//1/bar:running" --point=1 \ --interval=1 --max-polls=20 >& $ERR create_test_global_config '' " diff --git a/tests/functional/workflow-state/00-polling.t b/tests/functional/workflow-state/00-polling.t index f073f6e3c02..cafea36cd9e 100644 --- a/tests/functional/workflow-state/00-polling.t +++ b/tests/functional/workflow-state/00-polling.t @@ -20,7 +20,7 @@ . "$(dirname "$0")/test_header" #------------------------------------------------------------------------------- -set_test_number 5 +set_test_number 7 #------------------------------------------------------------------------------- install_workflow "${TEST_NAME_BASE}" 'polling' #------------------------------------------------------------------------------- @@ -34,6 +34,15 @@ cylc install "${TEST_DIR}/upstream" --workflow-name="${UPSTREAM}" --no-run-name TEST_NAME="${TEST_NAME_BASE}-validate-upstream" run_ok "${TEST_NAME}" cylc validate --debug "${UPSTREAM}" +TEST_NAME=${TEST_NAME_BASE}-validate-polling-y +run_fail "${TEST_NAME}" \ + cylc validate --set="UPSTREAM='${UPSTREAM}'" --set="OUTPUT=':y'" "${WORKFLOW_NAME}" + +contains_ok "${TEST_NAME}.stderr" <<__ERR__ +WorkflowConfigError: Polling task "l-mess" must configure a target status or output message in \ +the graph (:y) or task definition (message = "the quick brown fox") but not both. +__ERR__ + TEST_NAME=${TEST_NAME_BASE}-validate-polling run_ok "${TEST_NAME}" \ cylc validate --debug --set="UPSTREAM='${UPSTREAM}'" "${WORKFLOW_NAME}" @@ -48,8 +57,8 @@ cylc config -d \ --set="UPSTREAM='${UPSTREAM}'" -i '[runtime][lbad]script' "${WORKFLOW_NAME}" \ >'lbad.script' cmp_ok 'lbad.script' << __END__ -echo cylc workflow-state --task=bad --point=\$CYLC_TASK_CYCLE_POINT --interval=2 --max-polls=20 --status=failed ${UPSTREAM} -cylc workflow-state --task=bad --point=\$CYLC_TASK_CYCLE_POINT --interval=2 --max-polls=20 --status=failed ${UPSTREAM} +echo cylc workflow-state ${UPSTREAM}//\$CYLC_TASK_CYCLE_POINT/bad:failed --interval=2 --max-polls=20 +cylc workflow-state ${UPSTREAM}//\$CYLC_TASK_CYCLE_POINT/bad:failed --interval=2 --max-polls=20 __END__ # check auto-generated task script for l-good @@ -57,8 +66,8 @@ cylc config -d \ --set="UPSTREAM='${UPSTREAM}'" -i '[runtime][l-good]script' "${WORKFLOW_NAME}" \ >'l-good.script' cmp_ok 'l-good.script' << __END__ -echo cylc workflow-state --task=good-stuff --point=\$CYLC_TASK_CYCLE_POINT --interval=2 --max-polls=20 --status=succeeded ${UPSTREAM} -cylc workflow-state --task=good-stuff --point=\$CYLC_TASK_CYCLE_POINT --interval=2 --max-polls=20 --status=succeeded ${UPSTREAM} +echo cylc workflow-state ${UPSTREAM}//\$CYLC_TASK_CYCLE_POINT/good-stuff:succeeded --interval=2 --max-polls=20 +cylc workflow-state ${UPSTREAM}//\$CYLC_TASK_CYCLE_POINT/good-stuff:succeeded --interval=2 --max-polls=20 __END__ #------------------------------------------------------------------------------- diff --git a/tests/functional/workflow-state/01-polling.t b/tests/functional/workflow-state/01-polling.t index b4694c35a6c..099df9e3a9b 100644 --- a/tests/functional/workflow-state/01-polling.t +++ b/tests/functional/workflow-state/01-polling.t @@ -44,8 +44,8 @@ cylc config -d \ --set="UPSTREAM='${UPSTREAM}'" \ -i '[runtime][lbad]script' "${WORKFLOW_NAME}" >'lbad.script' cmp_ok 'lbad.script' << __END__ -echo cylc workflow-state --task=bad --point=\$CYLC_TASK_CYCLE_POINT --interval=2 --max-polls=20 --status=failed ${UPSTREAM} -cylc workflow-state --task=bad --point=\$CYLC_TASK_CYCLE_POINT --interval=2 --max-polls=20 --status=failed ${UPSTREAM} +echo cylc workflow-state ${UPSTREAM}//\$CYLC_TASK_CYCLE_POINT/bad:failed --interval=2 --max-polls=20 +cylc workflow-state ${UPSTREAM}//\$CYLC_TASK_CYCLE_POINT/bad:failed --interval=2 --max-polls=20 __END__ # check auto-generated task script for l-good @@ -53,8 +53,8 @@ cylc config -d \ --set="UPSTREAM='${UPSTREAM}'" \ -i '[runtime][l-good]script' "${WORKFLOW_NAME}" >'l-good.script' cmp_ok 'l-good.script' << __END__ -echo cylc workflow-state --task=good-stuff --point=\$CYLC_TASK_CYCLE_POINT --interval=2 --max-polls=20 --status=succeeded ${UPSTREAM} -cylc workflow-state --task=good-stuff --point=\$CYLC_TASK_CYCLE_POINT --interval=2 --max-polls=20 --status=succeeded ${UPSTREAM} +echo cylc workflow-state ${UPSTREAM}//\$CYLC_TASK_CYCLE_POINT/good-stuff:succeeded --interval=2 --max-polls=20 +cylc workflow-state ${UPSTREAM}//\$CYLC_TASK_CYCLE_POINT/good-stuff:succeeded --interval=2 --max-polls=20 __END__ #------------------------------------------------------------------------------- diff --git a/tests/functional/workflow-state/05-message.t b/tests/functional/workflow-state/05-message.t deleted file mode 100755 index 89acd6d83e3..00000000000 --- a/tests/functional/workflow-state/05-message.t +++ /dev/null @@ -1,34 +0,0 @@ -#!/usr/bin/env bash -# THIS FILE IS PART OF THE CYLC WORKFLOW ENGINE. -# Copyright (C) NIWA & British Crown (Met Office) & Contributors. -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . -#------------------------------------------------------------------------------- -# Test cylc workflow-state "template" option -. "$(dirname "$0")/test_header" -#------------------------------------------------------------------------------- -set_test_number 2 -#------------------------------------------------------------------------------- -install_workflow "${TEST_NAME_BASE}" message -#------------------------------------------------------------------------------- -TEST_NAME="${TEST_NAME_BASE}-run" -workflow_run_ok "${TEST_NAME}" cylc play --reference-test --debug --no-detach "${WORKFLOW_NAME}" -#------------------------------------------------------------------------------- -TEST_NAME=${TEST_NAME_BASE}-cli-template -run_ok "${TEST_NAME}" cylc workflow-state "${WORKFLOW_NAME}" -p 20100101T0000Z \ - --message=hello --task=t1 --max-polls=1 -#------------------------------------------------------------------------------- -purge -#------------------------------------------------------------------------------- -exit 0 diff --git a/tests/functional/workflow-state/05-output.t b/tests/functional/workflow-state/05-output.t new file mode 100755 index 00000000000..d95bc179518 --- /dev/null +++ b/tests/functional/workflow-state/05-output.t @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# THIS FILE IS PART OF THE CYLC WORKFLOW ENGINE. +# Copyright (C) NIWA & British Crown (Met Office) & Contributors. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +# Test cylc workflow-state for outputs (as opposed to statuses) +. "$(dirname "$0")/test_header" + +set_test_number 2 + +install_workflow "${TEST_NAME_BASE}" output + +TEST_NAME="${TEST_NAME_BASE}-run" +workflow_run_ok "${TEST_NAME}" \ + cylc play --reference-test --debug --no-detach "${WORKFLOW_NAME}" + +TEST_NAME=${TEST_NAME_BASE}-cli-check +run_ok "${TEST_NAME}" cylc workflow-state "${WORKFLOW_NAME}//20100101T0000Z/t1:out1" --max-polls=1 + +purge diff --git a/tests/functional/workflow-state/06-format.t b/tests/functional/workflow-state/06-format.t index f43460cfbb4..3994822438f 100755 --- a/tests/functional/workflow-state/06-format.t +++ b/tests/functional/workflow-state/06-format.t @@ -14,13 +14,13 @@ # # You should have received a copy of the GNU General Public License # along with this program. If not, see . -#------------------------------------------------------------------------------- + # Test "cylc workflow-state" cycle point format conversion, when the target workflow # sets an explicit cycle point format, and the CLI does not. . "$(dirname "$0")/test_header" -#------------------------------------------------------------------------------- + set_test_number 5 -#------------------------------------------------------------------------------- + init_workflow "${TEST_NAME_BASE}" <<'__FLOW_CONFIG__' [scheduler] UTC mode = True @@ -33,23 +33,21 @@ init_workflow "${TEST_NAME_BASE}" <<'__FLOW_CONFIG__' [[foo]] script = true __FLOW_CONFIG__ -#------------------------------------------------------------------------------- + TEST_NAME="${TEST_NAME_BASE}-run" workflow_run_ok "${TEST_NAME}" cylc play --debug --no-detach "${WORKFLOW_NAME}" -#------------------------------------------------------------------------------- + TEST_NAME=${TEST_NAME_BASE}-cli-poll -run_ok "${TEST_NAME}" cylc workflow-state "${WORKFLOW_NAME}" -p 20100101T0000Z \ - --task=foo --status=succeeded +run_ok "${TEST_NAME}" cylc workflow-state "${WORKFLOW_NAME}//20100101T0000Z/foo:succeeded" --max-polls=1 contains_ok "${TEST_NAME}.stdout" <<__OUT__ -polling for 'succeeded': satisfied +2010-01-01/foo:succeeded __OUT__ -#------------------------------------------------------------------------------- + TEST_NAME=${TEST_NAME_BASE}-cli-dump -run_ok "${TEST_NAME}" cylc workflow-state "${WORKFLOW_NAME}" -p 20100101T0000Z +run_ok "${TEST_NAME}" cylc workflow-state --old-format "${WORKFLOW_NAME}//20100101T0000Z" --max-polls=1 contains_ok "${TEST_NAME}.stdout" <<__OUT__ foo, 2010-01-01, succeeded __OUT__ -#------------------------------------------------------------------------------- + purge -#------------------------------------------------------------------------------- -exit 0 + diff --git a/tests/functional/workflow-state/06a-noformat.t b/tests/functional/workflow-state/06a-noformat.t index 6bf800e27f2..eed64f6addd 100755 --- a/tests/functional/workflow-state/06a-noformat.t +++ b/tests/functional/workflow-state/06a-noformat.t @@ -14,14 +14,13 @@ # # You should have received a copy of the GNU General Public License # along with this program. If not, see . -#------------------------------------------------------------------------------- + # Test "cylc workflow-state" cycle point format conversion, when the target workflow # sets no explicit cycle point format, and the CLI does (the reverse of 06.t). - . "$(dirname "$0")/test_header" -#------------------------------------------------------------------------------- -set_test_number 5 -#------------------------------------------------------------------------------- + +set_test_number 3 + init_workflow "${TEST_NAME_BASE}" <<'__FLOW_CONFIG__' [scheduler] UTC mode = True @@ -34,23 +33,15 @@ init_workflow "${TEST_NAME_BASE}" <<'__FLOW_CONFIG__' [[foo]] script = true __FLOW_CONFIG__ -#------------------------------------------------------------------------------- + TEST_NAME="${TEST_NAME_BASE}-run" workflow_run_ok "${TEST_NAME}" cylc play --debug --no-detach "${WORKFLOW_NAME}" -#------------------------------------------------------------------------------- + TEST_NAME=${TEST_NAME_BASE}-cli-poll -run_ok "${TEST_NAME}" cylc workflow-state "${WORKFLOW_NAME}" -p 2010-01-01T00:00Z \ - --task=foo --status=succeeded -contains_ok "${TEST_NAME}.stdout" <<__OUT__ -polling for 'succeeded': satisfied -__OUT__ -#------------------------------------------------------------------------------- -TEST_NAME=${TEST_NAME_BASE}-cli-dump -run_ok "${TEST_NAME}" cylc workflow-state "${WORKFLOW_NAME}" -p 2010-01-01T00:00Z +run_ok "${TEST_NAME}" cylc workflow-state "${WORKFLOW_NAME}//2010-01-01T00+00" contains_ok "${TEST_NAME}.stdout" <<__OUT__ -foo, 20100101T0000Z, succeeded +20100101T0000Z/foo:succeeded __OUT__ -#------------------------------------------------------------------------------- + purge -#------------------------------------------------------------------------------- -exit 0 + diff --git a/tests/functional/workflow-state/07-message2.t b/tests/functional/workflow-state/07-message2.t index cebdeecb13f..ef666714220 100755 --- a/tests/functional/workflow-state/07-message2.t +++ b/tests/functional/workflow-state/07-message2.t @@ -15,7 +15,8 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -# Test workflow-state message query on a waiting task - GitHub #2440. +# Originally (Cylc 7): test workflow-state query on a waiting task - GitHub #2440. +# Now (Cylc 8): test result of a failed workflow-state query. . "$(dirname "$0")/test_header" set_test_number 4 @@ -24,12 +25,12 @@ install_workflow "${TEST_NAME_BASE}" "${TEST_NAME_BASE}" run_ok "${TEST_NAME_BASE}-val" cylc validate "${WORKFLOW_NAME}" -workflow_run_ok "${TEST_NAME_BASE}-run" cylc play --debug --no-detach "${WORKFLOW_NAME}" +workflow_run_ok "${TEST_NAME_BASE}-run" \ + cylc play --debug --no-detach "${WORKFLOW_NAME}" TEST_NAME=${TEST_NAME_BASE}-query -run_fail "${TEST_NAME}" cylc workflow-state \ - "${WORKFLOW_NAME}" -p 2013 -t foo --max-polls=1 -m "the quick brown fox" +run_fail "${TEST_NAME}" cylc workflow-state "${WORKFLOW_NAME}//2013/foo:x" --max-polls=1 -grep_ok "ERROR: condition not satisfied" "${TEST_NAME}.stderr" +grep_ok "failed after 1 polls" "${TEST_NAME}.stderr" purge diff --git a/tests/functional/workflow-state/08-integer.t b/tests/functional/workflow-state/08-integer.t new file mode 100755 index 00000000000..ff95c81a27b --- /dev/null +++ b/tests/functional/workflow-state/08-integer.t @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +# THIS FILE IS PART OF THE CYLC WORKFLOW ENGINE. +# Copyright (C) NIWA & British Crown (Met Office) & Contributors. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +. "$(dirname "$0")/test_header" + +set_test_number 15 + +install_workflow "${TEST_NAME_BASE}" integer + +# run one cycle +TEST_NAME="${TEST_NAME_BASE}_run_1" +workflow_run_ok "${TEST_NAME}" cylc play --debug --no-detach --stopcp=1 "${WORKFLOW_NAME}" + +# too many args +TEST_NAME="${TEST_NAME_BASE}_cl_error" +run_fail "${TEST_NAME}" cylc workflow-state --max-polls=1 "${WORKFLOW_NAME}-a" "${WORKFLOW_NAME}-b" + +TEST_NAME="${TEST_NAME_BASE}_check_1_status" +run_ok "${TEST_NAME}" cylc workflow-state --max-polls=1 "${WORKFLOW_NAME}" + +contains_ok "${TEST_NAME}.stdout" <<__END__ +2/foo:waiting +1/foo:succeeded +1/bar:succeeded +__END__ + +TEST_NAME="${TEST_NAME_BASE}_check_1_outputs" +run_ok "${TEST_NAME}" cylc workflow-state --max-polls=1 --triggers "${WORKFLOW_NAME}" + +contains_ok "${TEST_NAME}.stdout" <<__END__ +1/foo:{'submitted': 'submitted', 'started': 'started', 'succeeded': 'succeeded', 'x': 'hello'} +2/foo:{} +1/bar:{'submitted': 'submitted', 'started': 'started', 'succeeded': 'succeeded'} +__END__ + +TEST_NAME="${TEST_NAME_BASE}_poll_fail" +run_fail "${TEST_NAME}" cylc workflow-state --max-polls=2 --interval=1 "${WORKFLOW_NAME}//2/foo:succeeded" + +contains_ok "${TEST_NAME}.stderr" <<__END__ +ERROR - failed after 2 polls +__END__ + +# finish the run +TEST_NAME="${TEST_NAME_BASE}_run_2" +workflow_run_ok "${TEST_NAME}" cylc play --debug --no-detach "${WORKFLOW_NAME}" + +TEST_NAME="${TEST_NAME_BASE}_poll_succeed" +run_ok "${TEST_NAME}" cylc workflow-state --max-polls=1 "${WORKFLOW_NAME}//2/foo:succeeded" + +contains_ok "${TEST_NAME}.stdout" <<__END__ +2/foo:succeeded +__END__ + +TEST_NAME="${TEST_NAME_BASE}_int_offset" +run_ok "${TEST_NAME}" cylc workflow-state --max-polls=1 "${WORKFLOW_NAME}//1/foo:succeeded" --offset=P1 + +contains_ok "${TEST_NAME}.stdout" <<__END__ +2/foo:succeeded +__END__ + +TEST_NAME="${TEST_NAME_BASE}_wildcard_offset" +run_fail "${TEST_NAME}" cylc workflow-state --max-polls=1 "${WORKFLOW_NAME}//*/foo:succeeded" --offset=P1 + +contains_ok "${TEST_NAME}.stderr" <<__END__ +InputError: Cycle point "*" is not compatible with an offset. +__END__ + +purge diff --git a/tests/functional/workflow-state/09-datetime.t b/tests/functional/workflow-state/09-datetime.t new file mode 100755 index 00000000000..d6e82a6a6ac --- /dev/null +++ b/tests/functional/workflow-state/09-datetime.t @@ -0,0 +1,121 @@ +#!/usr/bin/env bash +# THIS FILE IS PART OF THE CYLC WORKFLOW ENGINE. +# Copyright (C) NIWA & British Crown (Met Office) & Contributors. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +. "$(dirname "$0")/test_header" + +set_test_number 24 + +install_workflow "${TEST_NAME_BASE}" datetime + +# run one cycle +TEST_NAME="${TEST_NAME_BASE}_run_1" +workflow_run_ok "${TEST_NAME}" cylc play --debug --no-detach --stopcp=2051 "${WORKFLOW_NAME}" + +TEST_NAME="${TEST_NAME_BASE}_check_1_status" +run_ok "${TEST_NAME}" cylc workflow-state --max-polls=1 "${WORKFLOW_NAME}" + +contains_ok "${TEST_NAME}.stdout" <<__END__ +2052/foo:waiting +2051/foo:succeeded +2051/bar:succeeded +__END__ + +TEST_NAME="${TEST_NAME_BASE}_check_1_status_old_fmt" +run_ok "${TEST_NAME}" cylc workflow-state --old-format --max-polls=1 "${WORKFLOW_NAME}" + +contains_ok "${TEST_NAME}.stdout" <<__END__ +foo, 2052, waiting +foo, 2051, succeeded +bar, 2051, succeeded +__END__ + +TEST_NAME="${TEST_NAME_BASE}_check_1_outputs" +run_ok "${TEST_NAME}" cylc workflow-state --max-polls=1 --triggers "${WORKFLOW_NAME}" + +contains_ok "${TEST_NAME}.stdout" <<__END__ +2051/foo:{'submitted': 'submitted', 'started': 'started', 'succeeded': 'succeeded', 'x': 'hello'} +2052/foo:{} +2051/bar:{'submitted': 'submitted', 'started': 'started', 'succeeded': 'succeeded'} +__END__ + +TEST_NAME="${TEST_NAME_BASE}_poll_fail" +run_fail "${TEST_NAME}" cylc workflow-state --max-polls=2 --interval=1 "${WORKFLOW_NAME}//2052/foo:succeeded" + +contains_ok "${TEST_NAME}.stderr" <<__END__ +ERROR - failed after 2 polls +__END__ + +# finish the run +TEST_NAME="${TEST_NAME_BASE}_run_2" +workflow_run_ok "${TEST_NAME}" cylc play --debug --no-detach "${WORKFLOW_NAME}" + +TEST_NAME="${TEST_NAME_BASE}_check_1_status_2" +run_ok "${TEST_NAME}" cylc workflow-state --max-polls=1 "${WORKFLOW_NAME}" + +contains_ok "${TEST_NAME}.stdout" <<__END__ +2051/foo:succeeded +2052/foo:succeeded +2051/bar:succeeded +2052/bar:succeeded +2052/foo:succeeded(flows=2) +2052/bar:succeeded(flows=2) +__END__ + +TEST_NAME="${TEST_NAME_BASE}_check_1_status_3" +run_ok "${TEST_NAME}" cylc workflow-state --flow=2 --max-polls=1 "${WORKFLOW_NAME}" + +contains_ok "${TEST_NAME}.stdout" <<__END__ +2052/foo:succeeded(flows=2) +2052/bar:succeeded(flows=2) +__END__ + +TEST_NAME="${TEST_NAME_BASE}_check_1_wildcard" +run_ok "${TEST_NAME}" cylc workflow-state --flow=1 --max-polls=1 "${WORKFLOW_NAME}//*/foo" + +contains_ok "${TEST_NAME}.stdout" <<__END__ +2051/foo:succeeded +__END__ + +TEST_NAME="${TEST_NAME_BASE}_poll_succeed" +run_ok "${TEST_NAME}" cylc workflow-state --max-polls=1 "${WORKFLOW_NAME}//2052/foo:succeeded" + +contains_ok "${TEST_NAME}.stdout" <<__END__ +2052/foo:succeeded +__END__ + +TEST_NAME="${TEST_NAME_BASE}_datetime_offset" +run_ok "${TEST_NAME}" cylc workflow-state --max-polls=1 "${WORKFLOW_NAME}//2051/foo:succeeded" --offset=P1Y + +contains_ok "${TEST_NAME}.stdout" <<__END__ +2052/foo:succeeded +__END__ + +TEST_NAME="${TEST_NAME_BASE}_datetime_format" +run_ok "${TEST_NAME}" cylc workflow-state --max-polls=1 "${WORKFLOW_NAME}//20510101T0000Z/foo:succeeded" --offset=P1Y + +contains_ok "${TEST_NAME}.stdout" <<__END__ +2052/foo:succeeded +__END__ + +TEST_NAME="${TEST_NAME_BASE}_bad_point" +run_fail "${TEST_NAME}" cylc workflow-state --max-polls=1 "${WORKFLOW_NAME}//205/foo:succeeded" + +contains_ok "${TEST_NAME}.stderr" <<__END__ +InputError: Cycle point "205" is not compatible with DB point format "CCYY" +__END__ + +purge diff --git a/tests/functional/workflow-state/10-backcompat.t b/tests/functional/workflow-state/10-backcompat.t new file mode 100755 index 00000000000..8a026c97c1e --- /dev/null +++ b/tests/functional/workflow-state/10-backcompat.t @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# THIS FILE IS PART OF THE CYLC WORKFLOW ENGINE. +# Copyright (C) NIWA & British Crown (Met Office) & Contributors. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +. "$(dirname "$0")/test_header" + +set_test_number 8 + +install_workflow "${TEST_NAME_BASE}" backcompat + +# create Cylc 7 DB +run_ok "create-db" sqlite3 "${WORKFLOW_RUN_DIR}/log/db" < schema-1.sql + +TEST_NAME="${TEST_NAME_BASE}_compat_1" +run_ok "${TEST_NAME}" cylc workflow-state --max-polls=1 "${WORKFLOW_NAME}" + +contains_ok "${TEST_NAME}.stdout" <<__END__ +2051/foo:succeeded +2051/bar:succeeded +__END__ + +# recreate Cylc 7 DB with one NULL status +rm "${WORKFLOW_RUN_DIR}/log/db" +run_ok "create-db" sqlite3 "${WORKFLOW_RUN_DIR}/log/db" < schema-2.sql + +TEST_NAME="${TEST_NAME_BASE}_compat_2" +run_ok "${TEST_NAME}" cylc workflow-state --max-polls=1 "${WORKFLOW_NAME}" + +contains_ok "${TEST_NAME}.stdout" <<__END__ +2051/foo:succeeded +__END__ + +# Cylc 7 DB only contains custom outputs +TEST_NAME="${TEST_NAME_BASE}_outputs" +run_ok "${TEST_NAME}" cylc workflow-state --max-polls=1 --messages "${WORKFLOW_NAME}" + +contains_ok "${TEST_NAME}.stdout" <<__END__ +2051/foo:{'x': 'the quick brown fox'} +__END__ + +purge diff --git a/tests/functional/workflow-state/11-multi.t b/tests/functional/workflow-state/11-multi.t new file mode 100644 index 00000000000..a80b61f2016 --- /dev/null +++ b/tests/functional/workflow-state/11-multi.t @@ -0,0 +1,130 @@ +#!/usr/bin/env bash +# THIS FILE IS PART OF THE CYLC WORKFLOW ENGINE. +# Copyright (C) NIWA & British Crown (Met Office) & Contributors. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +#------------------------------------------------------------------------------- +# Test all kinds of workflow-state DB checking. + +# shellcheck disable=SC2086 + +. "$(dirname "$0")/test_header" + +set_test_number 42 + +install_workflow "${TEST_NAME_BASE}" "${TEST_NAME_BASE}" + +# Create Cylc 7, 8 (pre-8.3.0), and 8(8.3.0+) DBs for workflow-state checking. +DBDIR="${WORKFLOW_RUN_DIR}/dbs" +for x in c7 c8a c8b; do + mkdir -p "${DBDIR}/${x}/log" + sqlite3 "${DBDIR}/${x}/log/db" < "${x}.sql" +done + +run_ok "${TEST_NAME_BASE}-validate" \ + cylc validate "${WORKFLOW_NAME}" --set="ALT=\"${DBDIR}\"" + +grep_ok \ + "WARNING - (8.3.0) Deprecated function signature used for workflow_state xtrigger was automatically upgraded" \ + "${TEST_NAME_BASE}-validate.stderr" + +TEST_NAME="${TEST_NAME_BASE}-run" +workflow_run_ok "${TEST_NAME}" \ + cylc play "${WORKFLOW_NAME}" --set="ALT=\"${DBDIR}\"" \ + --reference-test --debug --no-detach + +# Single poll. +CMD="cylc workflow-state --run-dir=$DBDIR --max-polls=1" + +# Content of the c8b DB: +# "select * from task_outputs" +# 1|foo|[1]|{"submitted": "submitted", "started": "started", "succeeded": "succeeded", "x": "the quick brown"} +# "select * from task_states" +# foo|1|[1]|2024-06-05T16:34:02+12:00|2024-06-05T16:34:04+12:00|1|succeeded|0|0 + +#--------------- +# Test the new-format command line (pre-8.3.0). +T=${TEST_NAME_BASE}-cli-c8b +run_ok "${T}-1" $CMD c8b +run_ok "${T}-2" $CMD c8b//1 +run_ok "${T}-3" $CMD c8b//1/foo +run_ok "${T}-4" $CMD c8b//1/foo:succeeded +run_ok "${T}-5" $CMD "c8b//1/foo:the quick brown" --messages +run_ok "${T}-6" $CMD "c8b//1/foo:x" --triggers +run_ok "${T}-7" $CMD "c8b//1/foo:x" # default to trigger if not a status +run_ok "${T}-8" $CMD c8b//1 +run_ok "${T}-9" $CMD c8b//1:succeeded + +run_fail "${T}-3" $CMD c8b//1/foo:failed +run_fail "${T}-5" $CMD "c8b//1/foo:the quick brown" --triggers +run_fail "${T}-5" $CMD "c8b//1/foo:x" --messages +run_fail "${T}-1" $CMD c8b//1:failed +run_fail "${T}-1" $CMD c8b//2 +run_fail "${T}-1" $CMD c8b//2:failed + +#--------------- +T=${TEST_NAME_BASE}-cli-c8a +run_ok "${T}-1" $CMD "c8a//1/foo:the quick brown" --messages +run_ok "${T}-2" $CMD "c8a//1/foo:the quick brown" --triggers # OK for 8.0 <= 8.3 +run_fail "${T}-3" $CMD "c8a//1/foo:x" --triggers # not possible for 8.0 <= 8.3 + +#--------------- +T=${TEST_NAME_BASE}-cli-c7 +run_ok "${T}-1" $CMD "c7//1/foo:the quick brown" --messages +run_fail "${T}-2" $CMD "c7//1/foo:the quick brown" --triggers +run_ok "${T}-3" $CMD "c7//1/foo:x" --triggers + +#--------------- +# Test the old-format command line (8.3.0+). +T=${TEST_NAME_BASE}-cli-8b-compat +run_ok "${T}-1" $CMD c8b +run_ok "${T}-2" $CMD c8b --point=1 +run_ok "${T}-3" $CMD c8b --point=1 --task=foo +run_ok "${T}-4" $CMD c8b --point=1 --task=foo --status=succeeded +run_ok "${T}-5" $CMD c8b --point=1 --task=foo --message="the quick brown" +run_ok "${T}-6" $CMD c8b --point=1 --task=foo --output="the quick brown" + +run_fail "${T}-7" $CMD c8b --point=1 --task=foo --status=failed +run_fail "${T}-8" $CMD c8b --point=1 --task=foo --message="x" +run_fail "${T}-9" $CMD c8b --point=1 --task=foo --output="x" +run_fail "${T}-10" $CMD c8b --point=2 +run_fail "${T}-11" $CMD c8b --point=2 --task=foo --status="succeeded" + +#--------------- +T=${TEST_NAME_BASE}-bad-cli + +TEST_NAME="${T}-1" +run_fail "$TEST_NAME" $CMD c8b --status=succeeded --message="the quick brown" +cmp_ok "${TEST_NAME}.stderr" <<__ERR__ +InputError: set --status or --message, not both. +__ERR__ + +TEST_NAME="${T}-2" +run_fail "$TEST_NAME" $CMD c8b --task-point --point=1 +cmp_ok "${TEST_NAME}.stderr" <<__ERR__ +InputError: set --task-point or --point=CYCLE, not both. +__ERR__ + + +TEST_NAME="${T}-3" +run_fail "$TEST_NAME" $CMD c8b --task-point +cmp_ok "${TEST_NAME}.stderr" << "__ERR__" +InputError: --task-point: $CYLC_TASK_CYCLE_POINT is not defined +__ERR__ + +export CYLC_TASK_CYCLE_POINT=1 +TEST_NAME="${T}-3" +run_ok "$TEST_NAME" $CMD c8b --task-point + +purge diff --git a/tests/functional/workflow-state/11-multi/c7.sql b/tests/functional/workflow-state/11-multi/c7.sql new file mode 100644 index 00000000000..e912d533992 --- /dev/null +++ b/tests/functional/workflow-state/11-multi/c7.sql @@ -0,0 +1,39 @@ +PRAGMA foreign_keys=OFF; +BEGIN TRANSACTION; +CREATE TABLE suite_params(key TEXT, value TEXT, PRIMARY KEY(key)); +INSERT INTO suite_params VALUES('uuid_str','814ef90e-31a2-45e7-904b-fb3c6dcb87a9'); +INSERT INTO suite_params VALUES('run_mode','live'); +INSERT INTO suite_params VALUES('cylc_version','7.9.9'); +INSERT INTO suite_params VALUES('UTC_mode','0'); +INSERT INTO suite_params VALUES('cycle_point_tz','+1200'); +CREATE TABLE task_jobs(cycle TEXT, name TEXT, submit_num INTEGER, is_manual_submit INTEGER, try_num INTEGER, time_submit TEXT, time_submit_exit TEXT, submit_status INTEGER, time_run TEXT, time_run_exit TEXT, run_signal TEXT, run_status INTEGER, user_at_host TEXT, batch_sys_name TEXT, batch_sys_job_id TEXT, PRIMARY KEY(cycle, name, submit_num)); +INSERT INTO task_jobs VALUES('1','foo',1,0,1,'2024-06-05T16:31:01+12:00','2024-06-05T16:31:02+12:00',0,'2024-06-05T16:31:02+12:00','2024-06-05T16:31:02+12:00',NULL,0,'NIWA-1022450.niwa.local','background','19328'); +CREATE TABLE task_late_flags(cycle TEXT, name TEXT, value INTEGER, PRIMARY KEY(cycle, name)); +CREATE TABLE broadcast_states_checkpoints(id INTEGER, point TEXT, namespace TEXT, key TEXT, value TEXT, PRIMARY KEY(id, point, namespace, key)); +CREATE TABLE checkpoint_id(id INTEGER, time TEXT, event TEXT, PRIMARY KEY(id)); +INSERT INTO checkpoint_id VALUES(0,'2024-06-05T16:31:02+12:00','latest'); +CREATE TABLE inheritance(namespace TEXT, inheritance TEXT, PRIMARY KEY(namespace)); +INSERT INTO inheritance VALUES('root','["root"]'); +INSERT INTO inheritance VALUES('foo','["foo", "root"]'); +CREATE TABLE suite_params_checkpoints(id INTEGER, key TEXT, value TEXT, PRIMARY KEY(id, key)); +CREATE TABLE task_pool_checkpoints(id INTEGER, cycle TEXT, name TEXT, spawned INTEGER, status TEXT, hold_swap TEXT, PRIMARY KEY(id, cycle, name)); +CREATE TABLE task_outputs(cycle TEXT, name TEXT, outputs TEXT, PRIMARY KEY(cycle, name)); +INSERT INTO task_outputs VALUES('1','foo','{"x": "the quick brown"}'); +CREATE TABLE broadcast_states(point TEXT, namespace TEXT, key TEXT, value TEXT, PRIMARY KEY(point, namespace, key)); +CREATE TABLE task_timeout_timers(cycle TEXT, name TEXT, timeout REAL, PRIMARY KEY(cycle, name)); +CREATE TABLE task_states(name TEXT, cycle TEXT, time_created TEXT, time_updated TEXT, submit_num INTEGER, status TEXT, PRIMARY KEY(name, cycle)); +INSERT INTO task_states VALUES('foo','1','2024-06-05T16:31:01+12:00','2024-06-05T16:31:02+12:00',1,'succeeded'); +CREATE TABLE broadcast_events(time TEXT, change TEXT, point TEXT, namespace TEXT, key TEXT, value TEXT); +CREATE TABLE task_events(name TEXT, cycle TEXT, time TEXT, submit_num INTEGER, event TEXT, message TEXT); +INSERT INTO task_events VALUES('foo','1','2024-06-05T16:31:02+12:00',1,'submitted',''); +INSERT INTO task_events VALUES('foo','1','2024-06-05T16:31:02+12:00',1,'started',''); +INSERT INTO task_events VALUES('foo','1','2024-06-05T16:31:02+12:00',1,'x','the quick brown'); +INSERT INTO task_events VALUES('foo','1','2024-06-05T16:31:02+12:00',1,'succeeded',''); +CREATE TABLE suite_template_vars(key TEXT, value TEXT, PRIMARY KEY(key)); +CREATE TABLE task_pool(cycle TEXT, name TEXT, spawned INTEGER, status TEXT, hold_swap TEXT, PRIMARY KEY(cycle, name)); +INSERT INTO task_pool VALUES('1','foo',1,'succeeded',NULL); +CREATE TABLE xtriggers(signature TEXT, results TEXT, PRIMARY KEY(signature)); +CREATE TABLE task_action_timers(cycle TEXT, name TEXT, ctx_key TEXT, ctx TEXT, delays TEXT, num INTEGER, delay TEXT, timeout TEXT, PRIMARY KEY(cycle, name, ctx_key)); +INSERT INTO task_action_timers VALUES('1','foo','["try_timers", "retrying"]','null','[]',0,NULL,NULL); +INSERT INTO task_action_timers VALUES('1','foo','["try_timers", "submit-retrying"]','null','[]',0,NULL,NULL); +COMMIT; diff --git a/tests/functional/workflow-state/11-multi/c8a.sql b/tests/functional/workflow-state/11-multi/c8a.sql new file mode 100644 index 00000000000..3335d4dd3aa --- /dev/null +++ b/tests/functional/workflow-state/11-multi/c8a.sql @@ -0,0 +1,48 @@ +PRAGMA foreign_keys=OFF; +BEGIN TRANSACTION; +CREATE TABLE absolute_outputs(cycle TEXT, name TEXT, output TEXT); +CREATE TABLE broadcast_events(time TEXT, change TEXT, point TEXT, namespace TEXT, key TEXT, value TEXT); +CREATE TABLE broadcast_states(point TEXT, namespace TEXT, key TEXT, value TEXT, PRIMARY KEY(point, namespace, key)); +CREATE TABLE inheritance(namespace TEXT, inheritance TEXT, PRIMARY KEY(namespace)); +INSERT INTO inheritance VALUES('root','["root"]'); +INSERT INTO inheritance VALUES('foo','["foo", "root"]'); +CREATE TABLE task_action_timers(cycle TEXT, name TEXT, ctx_key TEXT, ctx TEXT, delays TEXT, num INTEGER, delay TEXT, timeout TEXT, PRIMARY KEY(cycle, name, ctx_key)); +INSERT INTO task_action_timers VALUES('1','foo','"poll_timer"','["tuple", [[1, "running"]]]','[900.0]',1,'900.0','1717563116.69952'); +INSERT INTO task_action_timers VALUES('1','foo','["try_timers", "submission-retry"]','null','[]',0,NULL,NULL); +INSERT INTO task_action_timers VALUES('1','foo','["try_timers", "execution-retry"]','null','[]',0,NULL,NULL); +CREATE TABLE task_events(name TEXT, cycle TEXT, time TEXT, submit_num INTEGER, event TEXT, message TEXT); +INSERT INTO task_events VALUES('foo','1','2024-06-05T16:36:56+12:00',1,'submitted',''); +INSERT INTO task_events VALUES('foo','1','2024-06-05T16:36:56+12:00',1,'started',''); +INSERT INTO task_events VALUES('foo','1','2024-06-05T16:36:56+12:00',1,'x','the quick brown'); +INSERT INTO task_events VALUES('foo','1','2024-06-05T16:36:57+12:00',1,'succeeded',''); +CREATE TABLE task_jobs(cycle TEXT, name TEXT, submit_num INTEGER, flow_nums TEXT, is_manual_submit INTEGER, try_num INTEGER, time_submit TEXT, time_submit_exit TEXT, submit_status INTEGER, time_run TEXT, time_run_exit TEXT, run_signal TEXT, run_status INTEGER, platform_name TEXT, job_runner_name TEXT, job_id TEXT, PRIMARY KEY(cycle, name, submit_num)); +INSERT INTO task_jobs VALUES('1','foo',1,'[1]',0,1,'2024-06-05T16:36:55+12:00','2024-06-05T16:36:56+12:00',0,'2024-06-05T16:36:56+12:00','2024-06-05T16:36:56+12:00',NULL,0,'localhost','background','21511'); +CREATE TABLE task_late_flags(cycle TEXT, name TEXT, value INTEGER, PRIMARY KEY(cycle, name)); +CREATE TABLE task_outputs(cycle TEXT, name TEXT, flow_nums TEXT, outputs TEXT, PRIMARY KEY(cycle, name, flow_nums)); +INSERT INTO task_outputs VALUES('1','foo','[1]','["submitted", "started", "succeeded", "the quick brown"]'); +CREATE TABLE task_pool(cycle TEXT, name TEXT, flow_nums TEXT, status TEXT, is_held INTEGER, PRIMARY KEY(cycle, name, flow_nums)); +CREATE TABLE task_prerequisites(cycle TEXT, name TEXT, flow_nums TEXT, prereq_name TEXT, prereq_cycle TEXT, prereq_output TEXT, satisfied TEXT, PRIMARY KEY(cycle, name, flow_nums, prereq_name, prereq_cycle, prereq_output)); +CREATE TABLE task_states(name TEXT, cycle TEXT, flow_nums TEXT, time_created TEXT, time_updated TEXT, submit_num INTEGER, status TEXT, flow_wait INTEGER, is_manual_submit INTEGER, PRIMARY KEY(name, cycle, flow_nums)); +INSERT INTO task_states VALUES('foo','1','[1]','2024-06-05T16:36:55+12:00','2024-06-05T16:36:57+12:00',1,'succeeded',0,0); +CREATE TABLE task_timeout_timers(cycle TEXT, name TEXT, timeout REAL, PRIMARY KEY(cycle, name)); +CREATE TABLE tasks_to_hold(name TEXT, cycle TEXT); +CREATE TABLE workflow_flows(flow_num INTEGER, start_time TEXT, description TEXT, PRIMARY KEY(flow_num)); +INSERT INTO workflow_flows VALUES(1,'2024-06-05T16:36:55','original flow from 1'); +CREATE TABLE workflow_params(key TEXT, value TEXT, PRIMARY KEY(key)); +INSERT INTO workflow_params VALUES('uuid_str','cabb2bd8-bb36-4c7a-9c51-d2b1d456bc4e'); +INSERT INTO workflow_params VALUES('cylc_version','8.3.0.dev'); +INSERT INTO workflow_params VALUES('UTC_mode','0'); +INSERT INTO workflow_params VALUES('n_restart','0'); +INSERT INTO workflow_params VALUES('cycle_point_format',NULL); +INSERT INTO workflow_params VALUES('is_paused','0'); +INSERT INTO workflow_params VALUES('stop_clock_time',NULL); +INSERT INTO workflow_params VALUES('stop_task',NULL); +INSERT INTO workflow_params VALUES('icp',NULL); +INSERT INTO workflow_params VALUES('fcp',NULL); +INSERT INTO workflow_params VALUES('startcp',NULL); +INSERT INTO workflow_params VALUES('stopcp',NULL); +INSERT INTO workflow_params VALUES('run_mode',NULL); +INSERT INTO workflow_params VALUES('cycle_point_tz','+1200'); +CREATE TABLE workflow_template_vars(key TEXT, value TEXT, PRIMARY KEY(key)); +CREATE TABLE xtriggers(signature TEXT, results TEXT, PRIMARY KEY(signature)); +COMMIT; diff --git a/tests/functional/workflow-state/11-multi/c8b.sql b/tests/functional/workflow-state/11-multi/c8b.sql new file mode 100644 index 00000000000..ca8fe74fa6f --- /dev/null +++ b/tests/functional/workflow-state/11-multi/c8b.sql @@ -0,0 +1,48 @@ +PRAGMA foreign_keys=OFF; +BEGIN TRANSACTION; +CREATE TABLE absolute_outputs(cycle TEXT, name TEXT, output TEXT); +CREATE TABLE broadcast_events(time TEXT, change TEXT, point TEXT, namespace TEXT, key TEXT, value TEXT); +CREATE TABLE broadcast_states(point TEXT, namespace TEXT, key TEXT, value TEXT, PRIMARY KEY(point, namespace, key)); +CREATE TABLE inheritance(namespace TEXT, inheritance TEXT, PRIMARY KEY(namespace)); +INSERT INTO inheritance VALUES('root','["root"]'); +INSERT INTO inheritance VALUES('foo','["foo", "root"]'); +CREATE TABLE task_action_timers(cycle TEXT, name TEXT, ctx_key TEXT, ctx TEXT, delays TEXT, num INTEGER, delay TEXT, timeout TEXT, PRIMARY KEY(cycle, name, ctx_key)); +INSERT INTO task_action_timers VALUES('1','foo','"poll_timer"','["tuple", [[1, "running"]]]','[900.0]',1,'900.0','1717562943.77014'); +INSERT INTO task_action_timers VALUES('1','foo','["try_timers", "submission-retry"]','null','[]',0,NULL,NULL); +INSERT INTO task_action_timers VALUES('1','foo','["try_timers", "execution-retry"]','null','[]',0,NULL,NULL); +CREATE TABLE task_events(name TEXT, cycle TEXT, time TEXT, submit_num INTEGER, event TEXT, message TEXT); +INSERT INTO task_events VALUES('foo','1','2024-06-05T16:34:03+12:00',1,'submitted',''); +INSERT INTO task_events VALUES('foo','1','2024-06-05T16:34:03+12:00',1,'started',''); +INSERT INTO task_events VALUES('foo','1','2024-06-05T16:34:03+12:00',1,'x','the quick brown'); +INSERT INTO task_events VALUES('foo','1','2024-06-05T16:34:04+12:00',1,'succeeded',''); +CREATE TABLE task_jobs(cycle TEXT, name TEXT, submit_num INTEGER, flow_nums TEXT, is_manual_submit INTEGER, try_num INTEGER, time_submit TEXT, time_submit_exit TEXT, submit_status INTEGER, time_run TEXT, time_run_exit TEXT, run_signal TEXT, run_status INTEGER, platform_name TEXT, job_runner_name TEXT, job_id TEXT, PRIMARY KEY(cycle, name, submit_num)); +INSERT INTO task_jobs VALUES('1','foo',1,'[1]',0,1,'2024-06-05T16:34:02+12:00','2024-06-05T16:34:03+12:00',0,'2024-06-05T16:34:03+12:00','2024-06-05T16:34:03+12:00',NULL,0,'localhost','background','20985'); +CREATE TABLE task_late_flags(cycle TEXT, name TEXT, value INTEGER, PRIMARY KEY(cycle, name)); +CREATE TABLE task_outputs(cycle TEXT, name TEXT, flow_nums TEXT, outputs TEXT, PRIMARY KEY(cycle, name, flow_nums)); +INSERT INTO task_outputs VALUES('1','foo','[1]','{"submitted": "submitted", "started": "started", "succeeded": "succeeded", "x": "the quick brown"}'); +CREATE TABLE task_pool(cycle TEXT, name TEXT, flow_nums TEXT, status TEXT, is_held INTEGER, PRIMARY KEY(cycle, name, flow_nums)); +CREATE TABLE task_prerequisites(cycle TEXT, name TEXT, flow_nums TEXT, prereq_name TEXT, prereq_cycle TEXT, prereq_output TEXT, satisfied TEXT, PRIMARY KEY(cycle, name, flow_nums, prereq_name, prereq_cycle, prereq_output)); +CREATE TABLE task_states(name TEXT, cycle TEXT, flow_nums TEXT, time_created TEXT, time_updated TEXT, submit_num INTEGER, status TEXT, flow_wait INTEGER, is_manual_submit INTEGER, PRIMARY KEY(name, cycle, flow_nums)); +INSERT INTO task_states VALUES('foo','1','[1]','2024-06-05T16:34:02+12:00','2024-06-05T16:34:04+12:00',1,'succeeded',0,0); +CREATE TABLE task_timeout_timers(cycle TEXT, name TEXT, timeout REAL, PRIMARY KEY(cycle, name)); +CREATE TABLE tasks_to_hold(name TEXT, cycle TEXT); +CREATE TABLE workflow_flows(flow_num INTEGER, start_time TEXT, description TEXT, PRIMARY KEY(flow_num)); +INSERT INTO workflow_flows VALUES(1,'2024-06-05T16:34:02','original flow from 1'); +CREATE TABLE workflow_params(key TEXT, value TEXT, PRIMARY KEY(key)); +INSERT INTO workflow_params VALUES('uuid_str','4185a45a-8faa-491f-ad35-2d221e780efa'); +INSERT INTO workflow_params VALUES('cylc_version','8.3.0.dev'); +INSERT INTO workflow_params VALUES('UTC_mode','0'); +INSERT INTO workflow_params VALUES('n_restart','0'); +INSERT INTO workflow_params VALUES('cycle_point_format',NULL); +INSERT INTO workflow_params VALUES('is_paused','0'); +INSERT INTO workflow_params VALUES('stop_clock_time',NULL); +INSERT INTO workflow_params VALUES('stop_task',NULL); +INSERT INTO workflow_params VALUES('icp',NULL); +INSERT INTO workflow_params VALUES('fcp',NULL); +INSERT INTO workflow_params VALUES('startcp',NULL); +INSERT INTO workflow_params VALUES('stopcp',NULL); +INSERT INTO workflow_params VALUES('run_mode',NULL); +INSERT INTO workflow_params VALUES('cycle_point_tz','+1200'); +CREATE TABLE workflow_template_vars(key TEXT, value TEXT, PRIMARY KEY(key)); +CREATE TABLE xtriggers(signature TEXT, results TEXT, PRIMARY KEY(signature)); +COMMIT; diff --git a/tests/functional/workflow-state/11-multi/flow.cylc b/tests/functional/workflow-state/11-multi/flow.cylc new file mode 100644 index 00000000000..a0ab61e9312 --- /dev/null +++ b/tests/functional/workflow-state/11-multi/flow.cylc @@ -0,0 +1,69 @@ +#!Jinja2 + +{# alt-cylc-run-dir default for easy validation #} +{% set ALT = ALT | default("alt") %} + +[scheduling] + cycling mode = integer + initial cycle point = 1 + final cycle point = 2 + [[xtriggers]] + # Cylc 7 back compat + z1 = suite_state(c7, foo, 1, offset=P0, cylc_run_dir={{ALT}}):PT1S # status=succeeded + z2 = suite_state(c7, foo, 1, offset=P0, message="the quick brown", cylc_run_dir={{ALT}}):PT1S + + # Cylc 7 xtrigger, Cylc 8 DB + a1 = suite_state(c8b, foo, 1, offset=P0, cylc_run_dir={{ALT}}):PT1S # status=succeeded + a2 = suite_state(c8b, foo, 1, offset=P0, message="the quick brown", cylc_run_dir={{ALT}}):PT1S + + # Cylc 8 back compat (pre-8.3.0) + b1 = workflow_state(c8a, foo, 1, offset=P0, status=succeeded, cylc_run_dir={{ALT}}):PT1S + b2 = workflow_state(c8a, foo, 1, offset=P0, message="the quick brown", cylc_run_dir={{ALT}}):PT1S + + # Cylc 8 new (from 8.3.0) + c1 = workflow_state(c8b//1/foo, offset=P0, alt_cylc_run_dir={{ALT}}):PT1S + c2 = workflow_state(c8b//1/foo:succeeded, offset=P0, alt_cylc_run_dir={{ALT}}):PT1S + c3 = workflow_state(c8b//1/foo:x, offset=P0, alt_cylc_run_dir={{ALT}}):PT1S + c4 = workflow_state(c8b//1/foo:"the quick brown", offset=P0, is_message=True, alt_cylc_run_dir={{ALT}}):PT1S + + [[graph]] + R1 = """ + # Deprecated workflow-state polling tasks. + # (does not support %(suite_name)s templates or offsets + # or output triggers - just messages) + + # status + bar1 => g1 + bar2 => g2 + + # output + baz2 => g4 # message given in task definition + qux2 => g7 # message given in task definition + + @z1 => x1 + @z2 => x2 + + @a1 => f1 + @a2 => f2 + + @b1 => f3 + @b2 => f4 + + @c1 => f5 + @c2 => f6 + @c3 => f7 + + """ +[runtime] + [[bar1, bar2]] + [[[workflow state polling]]] + alt-cylc-run-dir = {{ALT}} + + [[qux2, baz2]] + [[[workflow state polling]]] + message = "the quick brown" + alt-cylc-run-dir = {{ALT}} + + [[x1, x2]] + [[f1, f2, f3, f4, f5, f6, f7]] + [[g1, g2, g4, g7]] diff --git a/tests/functional/workflow-state/11-multi/reference.log b/tests/functional/workflow-state/11-multi/reference.log new file mode 100644 index 00000000000..5f1e79866b6 --- /dev/null +++ b/tests/functional/workflow-state/11-multi/reference.log @@ -0,0 +1,17 @@ +1/bar1 -triggered off [] in flow 1 +1/qux2 -triggered off [] in flow 1 +1/bar2 -triggered off [] in flow 1 +1/baz2 -triggered off [] in flow 1 +1/f4 -triggered off [] in flow 1 +1/f1 -triggered off [] in flow 1 +1/f2 -triggered off [] in flow 1 +1/f3 -triggered off [] in flow 1 +1/f5 -triggered off [] in flow 1 +1/x1 -triggered off [] in flow 1 +1/f6 -triggered off [] in flow 1 +1/f7 -triggered off [] in flow 1 +1/x2 -triggered off [] in flow 1 +1/g4 -triggered off ['1/baz2'] in flow 1 +1/g2 -triggered off ['1/bar2'] in flow 1 +1/g7 -triggered off ['1/qux2'] in flow 1 +1/g1 -triggered off ['1/bar1'] in flow 1 diff --git a/tests/functional/workflow-state/11-multi/upstream/suite.rc b/tests/functional/workflow-state/11-multi/upstream/suite.rc new file mode 100644 index 00000000000..250e0655b7d --- /dev/null +++ b/tests/functional/workflow-state/11-multi/upstream/suite.rc @@ -0,0 +1,17 @@ +# Run this with Cylc 7, 8 (pre-8.3.0), and 8 (8.3.0+) +# to generate DBs for workflow state checks. +# (The task_outputs table is different in each case). + +[scheduling] + cycling mode = integer + initial cycle point = 1 + [[dependencies]] + [[[R1]]] + graph = """ + foo + """ +[runtime] + [[foo]] + script = "cylc message - 'the quick brown'" + [[[outputs]]] + x = "the quick brown" diff --git a/tests/functional/workflow-state/backcompat/schema-1.sql b/tests/functional/workflow-state/backcompat/schema-1.sql new file mode 100644 index 00000000000..2375c9fd0b1 --- /dev/null +++ b/tests/functional/workflow-state/backcompat/schema-1.sql @@ -0,0 +1,49 @@ +PRAGMA foreign_keys=OFF; +BEGIN TRANSACTION; +CREATE TABLE suite_params(key TEXT, value TEXT, PRIMARY KEY(key)); +INSERT INTO suite_params VALUES('uuid_str','0d0bf7e8-4543-4aeb-8bc6-397e3a03ee19'); +INSERT INTO suite_params VALUES('run_mode','live'); +INSERT INTO suite_params VALUES('cylc_version','7.9.9'); +INSERT INTO suite_params VALUES('UTC_mode','0'); +INSERT INTO suite_params VALUES('cycle_point_format','CCYY'); +INSERT INTO suite_params VALUES('cycle_point_tz','+1200'); +CREATE TABLE task_jobs(cycle TEXT, name TEXT, submit_num INTEGER, is_manual_submit INTEGER, try_num INTEGER, time_submit TEXT, time_submit_exit TEXT, submit_status INTEGER, time_run TEXT, time_run_exit TEXT, run_signal TEXT, run_status INTEGER, user_at_host TEXT, batch_sys_name TEXT, batch_sys_job_id TEXT, PRIMARY KEY(cycle, name, submit_num)); +INSERT INTO task_jobs VALUES('2051','foo',1,0,1,'2024-05-30T14:11:40+12:00','2024-05-30T14:11:40+12:00',0,'2024-05-30T14:11:40+12:00','2024-05-30T14:11:40+12:00',NULL,0,'NIWA-1022450.niwa.local','background','12272'); +INSERT INTO task_jobs VALUES('2051','bar',1,0,1,'2024-05-30T14:11:42+12:00','2024-05-30T14:11:42+12:00',0,'2024-05-30T14:11:42+12:00','2024-05-30T14:11:42+12:00',NULL,0,'NIWA-1022450.niwa.local','background','12327'); +CREATE TABLE task_late_flags(cycle TEXT, name TEXT, value INTEGER, PRIMARY KEY(cycle, name)); +CREATE TABLE broadcast_states_checkpoints(id INTEGER, point TEXT, namespace TEXT, key TEXT, value TEXT, PRIMARY KEY(id, point, namespace, key)); +CREATE TABLE checkpoint_id(id INTEGER, time TEXT, event TEXT, PRIMARY KEY(id)); +INSERT INTO checkpoint_id VALUES(0,'2024-05-30T14:11:43+12:00','latest'); +CREATE TABLE inheritance(namespace TEXT, inheritance TEXT, PRIMARY KEY(namespace)); +INSERT INTO inheritance VALUES('root','["root"]'); +INSERT INTO inheritance VALUES('foo','["foo", "root"]'); +INSERT INTO inheritance VALUES('bar','["bar", "root"]'); +CREATE TABLE suite_params_checkpoints(id INTEGER, key TEXT, value TEXT, PRIMARY KEY(id, key)); +CREATE TABLE task_pool_checkpoints(id INTEGER, cycle TEXT, name TEXT, spawned INTEGER, status TEXT, hold_swap TEXT, PRIMARY KEY(id, cycle, name)); +CREATE TABLE task_outputs(cycle TEXT, name TEXT, outputs TEXT, PRIMARY KEY(cycle, name)); +INSERT INTO task_outputs VALUES('2051','foo','{"x": "the quick brown fox"}'); +CREATE TABLE broadcast_states(point TEXT, namespace TEXT, key TEXT, value TEXT, PRIMARY KEY(point, namespace, key)); +CREATE TABLE task_timeout_timers(cycle TEXT, name TEXT, timeout REAL, PRIMARY KEY(cycle, name)); +CREATE TABLE task_states(name TEXT, cycle TEXT, time_created TEXT, time_updated TEXT, submit_num INTEGER, status TEXT, PRIMARY KEY(name, cycle)); +INSERT INTO task_states VALUES('foo','2051','2024-05-30T14:11:40+12:00','2024-05-30T14:11:41+12:00',1,'succeeded'); +INSERT INTO task_states VALUES('bar','2051','2024-05-30T14:11:40+12:00','2024-05-30T14:11:43+12:00',1,'succeeded'); +CREATE TABLE broadcast_events(time TEXT, change TEXT, point TEXT, namespace TEXT, key TEXT, value TEXT); +CREATE TABLE task_events(name TEXT, cycle TEXT, time TEXT, submit_num INTEGER, event TEXT, message TEXT); +INSERT INTO task_events VALUES('foo','2051','2024-05-30T14:11:41+12:00',1,'submitted',''); +INSERT INTO task_events VALUES('foo','2051','2024-05-30T14:11:41+12:00',1,'started',''); +INSERT INTO task_events VALUES('foo','2051','2024-05-30T14:11:41+12:00',1,'x','the quick brown fox'); +INSERT INTO task_events VALUES('foo','2051','2024-05-30T14:11:41+12:00',1,'succeeded',''); +INSERT INTO task_events VALUES('bar','2051','2024-05-30T14:11:43+12:00',1,'submitted',''); +INSERT INTO task_events VALUES('bar','2051','2024-05-30T14:11:43+12:00',1,'started',''); +INSERT INTO task_events VALUES('bar','2051','2024-05-30T14:11:43+12:00',1,'succeeded',''); +CREATE TABLE suite_template_vars(key TEXT, value TEXT, PRIMARY KEY(key)); +CREATE TABLE task_pool(cycle TEXT, name TEXT, spawned INTEGER, status TEXT, hold_swap TEXT, PRIMARY KEY(cycle, name)); +INSERT INTO task_pool VALUES('2051','foo',1,'succeeded',NULL); +INSERT INTO task_pool VALUES('2051','bar',1,'succeeded',NULL); +CREATE TABLE xtriggers(signature TEXT, results TEXT, PRIMARY KEY(signature)); +CREATE TABLE task_action_timers(cycle TEXT, name TEXT, ctx_key TEXT, ctx TEXT, delays TEXT, num INTEGER, delay TEXT, timeout TEXT, PRIMARY KEY(cycle, name, ctx_key)); +INSERT INTO task_action_timers VALUES('2051','foo','["try_timers", "retrying"]','null','[]',0,NULL,NULL); +INSERT INTO task_action_timers VALUES('2051','foo','["try_timers", "submit-retrying"]','null','[]',0,NULL,NULL); +INSERT INTO task_action_timers VALUES('2051','bar','["try_timers", "retrying"]','null','[]',0,NULL,NULL); +INSERT INTO task_action_timers VALUES('2051','bar','["try_timers", "submit-retrying"]','null','[]',0,NULL,NULL); +COMMIT; diff --git a/tests/functional/workflow-state/backcompat/schema-2.sql b/tests/functional/workflow-state/backcompat/schema-2.sql new file mode 100644 index 00000000000..ff12c2e9fdd --- /dev/null +++ b/tests/functional/workflow-state/backcompat/schema-2.sql @@ -0,0 +1,49 @@ +PRAGMA foreign_keys=OFF; +BEGIN TRANSACTION; +CREATE TABLE suite_params(key TEXT, value TEXT, PRIMARY KEY(key)); +INSERT INTO suite_params VALUES('uuid_str','0d0bf7e8-4543-4aeb-8bc6-397e3a03ee19'); +INSERT INTO suite_params VALUES('run_mode','live'); +INSERT INTO suite_params VALUES('cylc_version','7.9.9'); +INSERT INTO suite_params VALUES('UTC_mode','0'); +INSERT INTO suite_params VALUES('cycle_point_format','CCYY'); +INSERT INTO suite_params VALUES('cycle_point_tz','+1200'); +CREATE TABLE task_jobs(cycle TEXT, name TEXT, submit_num INTEGER, is_manual_submit INTEGER, try_num INTEGER, time_submit TEXT, time_submit_exit TEXT, submit_status INTEGER, time_run TEXT, time_run_exit TEXT, run_signal TEXT, run_status INTEGER, user_at_host TEXT, batch_sys_name TEXT, batch_sys_job_id TEXT, PRIMARY KEY(cycle, name, submit_num)); +INSERT INTO task_jobs VALUES('2051','foo',1,0,1,'2024-05-30T14:11:40+12:00','2024-05-30T14:11:40+12:00',0,'2024-05-30T14:11:40+12:00','2024-05-30T14:11:40+12:00',NULL,0,'NIWA-1022450.niwa.local','background','12272'); +INSERT INTO task_jobs VALUES('2051','bar',1,0,1,'2024-05-30T14:11:42+12:00','2024-05-30T14:11:42+12:00',0,'2024-05-30T14:11:42+12:00','2024-05-30T14:11:42+12:00',NULL,0,'NIWA-1022450.niwa.local','background','12327'); +CREATE TABLE task_late_flags(cycle TEXT, name TEXT, value INTEGER, PRIMARY KEY(cycle, name)); +CREATE TABLE broadcast_states_checkpoints(id INTEGER, point TEXT, namespace TEXT, key TEXT, value TEXT, PRIMARY KEY(id, point, namespace, key)); +CREATE TABLE checkpoint_id(id INTEGER, time TEXT, event TEXT, PRIMARY KEY(id)); +INSERT INTO checkpoint_id VALUES(0,'2024-05-30T14:11:43+12:00','latest'); +CREATE TABLE inheritance(namespace TEXT, inheritance TEXT, PRIMARY KEY(namespace)); +INSERT INTO inheritance VALUES('root','["root"]'); +INSERT INTO inheritance VALUES('foo','["foo", "root"]'); +INSERT INTO inheritance VALUES('bar','["bar", "root"]'); +CREATE TABLE suite_params_checkpoints(id INTEGER, key TEXT, value TEXT, PRIMARY KEY(id, key)); +CREATE TABLE task_pool_checkpoints(id INTEGER, cycle TEXT, name TEXT, spawned INTEGER, status TEXT, hold_swap TEXT, PRIMARY KEY(id, cycle, name)); +CREATE TABLE task_outputs(cycle TEXT, name TEXT, outputs TEXT, PRIMARY KEY(cycle, name)); +INSERT INTO task_outputs VALUES('2051','foo','{"x": "the quick brown fox"}'); +CREATE TABLE broadcast_states(point TEXT, namespace TEXT, key TEXT, value TEXT, PRIMARY KEY(point, namespace, key)); +CREATE TABLE task_timeout_timers(cycle TEXT, name TEXT, timeout REAL, PRIMARY KEY(cycle, name)); +CREATE TABLE task_states(name TEXT, cycle TEXT, time_created TEXT, time_updated TEXT, submit_num INTEGER, status TEXT, PRIMARY KEY(name, cycle)); +INSERT INTO task_states VALUES('foo','2051','2024-05-30T14:11:40+12:00','2024-05-30T14:11:41+12:00',1,'succeeded'); +INSERT INTO task_states VALUES('bar','2051','2024-05-30T14:11:40+12:00','2024-05-30T14:11:43+12:00',1,NULL); +CREATE TABLE broadcast_events(time TEXT, change TEXT, point TEXT, namespace TEXT, key TEXT, value TEXT); +CREATE TABLE task_events(name TEXT, cycle TEXT, time TEXT, submit_num INTEGER, event TEXT, message TEXT); +INSERT INTO task_events VALUES('foo','2051','2024-05-30T14:11:41+12:00',1,'submitted',''); +INSERT INTO task_events VALUES('foo','2051','2024-05-30T14:11:41+12:00',1,'started',''); +INSERT INTO task_events VALUES('foo','2051','2024-05-30T14:11:41+12:00',1,'x','the quick brown fox'); +INSERT INTO task_events VALUES('foo','2051','2024-05-30T14:11:41+12:00',1,'succeeded',''); +INSERT INTO task_events VALUES('bar','2051','2024-05-30T14:11:43+12:00',1,'submitted',''); +INSERT INTO task_events VALUES('bar','2051','2024-05-30T14:11:43+12:00',1,'started',''); +INSERT INTO task_events VALUES('bar','2051','2024-05-30T14:11:43+12:00',1,'succeeded',''); +CREATE TABLE suite_template_vars(key TEXT, value TEXT, PRIMARY KEY(key)); +CREATE TABLE task_pool(cycle TEXT, name TEXT, spawned INTEGER, status TEXT, hold_swap TEXT, PRIMARY KEY(cycle, name)); +INSERT INTO task_pool VALUES('2051','foo',1,'succeeded',NULL); +INSERT INTO task_pool VALUES('2051','bar',1,'succeeded',NULL); +CREATE TABLE xtriggers(signature TEXT, results TEXT, PRIMARY KEY(signature)); +CREATE TABLE task_action_timers(cycle TEXT, name TEXT, ctx_key TEXT, ctx TEXT, delays TEXT, num INTEGER, delay TEXT, timeout TEXT, PRIMARY KEY(cycle, name, ctx_key)); +INSERT INTO task_action_timers VALUES('2051','foo','["try_timers", "retrying"]','null','[]',0,NULL,NULL); +INSERT INTO task_action_timers VALUES('2051','foo','["try_timers", "submit-retrying"]','null','[]',0,NULL,NULL); +INSERT INTO task_action_timers VALUES('2051','bar','["try_timers", "retrying"]','null','[]',0,NULL,NULL); +INSERT INTO task_action_timers VALUES('2051','bar','["try_timers", "submit-retrying"]','null','[]',0,NULL,NULL); +COMMIT; diff --git a/tests/functional/workflow-state/backcompat/suite.rc b/tests/functional/workflow-state/backcompat/suite.rc new file mode 100644 index 00000000000..2d9fdb846ea --- /dev/null +++ b/tests/functional/workflow-state/backcompat/suite.rc @@ -0,0 +1,16 @@ +[cylc] + cycle point format = CCYY +[scheduling] + initial cycle point = 2051 + [[dependencies]] + [[[R1]]] + graph = """ + foo:x => bar + """ +[runtime] + [[foo]] + script = "cylc message 'the quick brown fox'" + [[[outputs]]] + x = "the quick brown fox" + [[bar]] + diff --git a/tests/functional/workflow-state/datetime/flow.cylc b/tests/functional/workflow-state/datetime/flow.cylc new file mode 100644 index 00000000000..e00b4db3334 --- /dev/null +++ b/tests/functional/workflow-state/datetime/flow.cylc @@ -0,0 +1,21 @@ +[scheduler] + cycle point format = CCYY +[scheduling] + initial cycle point = 2051 + final cycle point = 2052 + [[graph]] + P1Y = """ + foo:x => bar + """ +[runtime] + [[foo]] + script = cylc message "hello" + [[[outputs]]] + x = "hello" + [[bar]] + script = """ + if (( CYLC_TASK_CYCLE_POINT == 2052 )) && (( CYLC_TASK_SUBMIT_NUMBER == 1 )) + then + cylc trigger --flow=new $CYLC_WORKFLOW_ID//2052/foo + fi + """ diff --git a/tests/functional/workflow-state/integer/flow.cylc b/tests/functional/workflow-state/integer/flow.cylc new file mode 100644 index 00000000000..3ca3eb462b8 --- /dev/null +++ b/tests/functional/workflow-state/integer/flow.cylc @@ -0,0 +1,14 @@ +[scheduling] + cycling mode = integer + initial cycle point = 1 + final cycle point = 2 + [[graph]] + P1 = """ + foo:x => bar + """ +[runtime] + [[foo]] + script = cylc message "hello" + [[[outputs]]] + x = "hello" + [[bar]] diff --git a/tests/functional/workflow-state/options/flow.cylc b/tests/functional/workflow-state/options/flow.cylc index a52bcc9970a..f1fa4000233 100644 --- a/tests/functional/workflow-state/options/flow.cylc +++ b/tests/functional/workflow-state/options/flow.cylc @@ -15,8 +15,8 @@ [[foo]] script = true [[env_polling]] - script = cylc workflow-state $CYLC_WORKFLOW_ID --task=foo --task-point -S succeeded + script = cylc workflow-state ${CYLC_WORKFLOW_ID}//$CYLC_TASK_CYCLE_POINT/foo:succeeded [[offset_polling]] - script = cylc workflow-state $CYLC_WORKFLOW_ID --task=foo -p 20100101T0000Z --offset=P1D + script = cylc workflow-state ${CYLC_WORKFLOW_ID}//20100102T0000Z/foo --offset=P1D [[offset_polling2]] - script = cylc workflow-state $CYLC_WORKFLOW_ID --task=foo -p 20100101T0000Z --offset=-P1D + script = cylc workflow-state ${CYLC_WORKFLOW_ID}//20100102T0000Z/foo --offset=-P1D diff --git a/tests/functional/workflow-state/message/flow.cylc b/tests/functional/workflow-state/output/flow.cylc similarity index 100% rename from tests/functional/workflow-state/message/flow.cylc rename to tests/functional/workflow-state/output/flow.cylc diff --git a/tests/functional/workflow-state/message/reference.log b/tests/functional/workflow-state/output/reference.log similarity index 100% rename from tests/functional/workflow-state/message/reference.log rename to tests/functional/workflow-state/output/reference.log diff --git a/tests/functional/workflow-state/polling/flow.cylc b/tests/functional/workflow-state/polling/flow.cylc index 82dccc9bc07..23f4891569b 100644 --- a/tests/functional/workflow-state/polling/flow.cylc +++ b/tests/functional/workflow-state/polling/flow.cylc @@ -1,5 +1,8 @@ #!jinja2 +{# e.g. set OUTPUT = ":x" #} +{% set OUTPUT = OUTPUT | default("") %} + [meta] title = "polls for success and failure tasks in another workflow" [scheduler] @@ -8,7 +11,7 @@ [[graph]] R1 = """ l-good<{{UPSTREAM}}::good-stuff> & lbad<{{UPSTREAM}}::bad:fail> - l-mess<{{UPSTREAM}}::messenger> => done + l-mess<{{UPSTREAM}}::messenger{{OUTPUT}}> => done """ [runtime] [[l-good,lbad]] diff --git a/tests/functional/workflow-state/template_ref/flow.cylc b/tests/functional/workflow-state/template_ref/flow.cylc deleted file mode 100644 index a01c722c1dc..00000000000 --- a/tests/functional/workflow-state/template_ref/flow.cylc +++ /dev/null @@ -1,13 +0,0 @@ -[scheduler] - UTC mode = True - cycle point format = %Y - -[scheduling] - initial cycle point = 2010 - final cycle point = 2011 - [[graph]] - P1Y = foo - -[runtime] - [[foo]] - script = true diff --git a/tests/functional/workflow-state/template_ref/reference.log b/tests/functional/workflow-state/template_ref/reference.log deleted file mode 100644 index 97101910d54..00000000000 --- a/tests/functional/workflow-state/template_ref/reference.log +++ /dev/null @@ -1,4 +0,0 @@ -Initial point: 2010 -Final point: 2011 -2010/foo -triggered off [] -2011/foo -triggered off [] diff --git a/tests/functional/xtriggers/03-sequence.t b/tests/functional/xtriggers/03-sequence.t index 1bb24d521a9..d8abdb2906b 100644 --- a/tests/functional/xtriggers/03-sequence.t +++ b/tests/functional/xtriggers/03-sequence.t @@ -60,4 +60,3 @@ __END__ cylc stop --now --max-polls=10 --interval=2 "${WORKFLOW_NAME}" purge -exit diff --git a/tests/functional/xtriggers/04-sequential.t b/tests/functional/xtriggers/04-sequential.t index 211aa47277f..f3837590b9b 100644 --- a/tests/functional/xtriggers/04-sequential.t +++ b/tests/functional/xtriggers/04-sequential.t @@ -35,11 +35,7 @@ init_workflow "${TEST_NAME_BASE}" << '__FLOW_CONFIG__' clock_1 = wall_clock(offset=P2Y, sequential=False) clock_2 = wall_clock() up_1 = workflow_state(\ - workflow=%(workflow)s, \ - task=b, \ - point=%(point)s, \ - offset=-P1Y, \ - sequential=False \ + workflow_task_id=%(workflow)s//%(point)s/b:succeeded, offset=-P1Y, sequential=False \ ):PT1S [[graph]] R1 = """ @@ -65,8 +61,7 @@ cylc stop --max-polls=10 --interval=2 "${WORKFLOW_NAME}" cylc play "${WORKFLOW_NAME}" cylc show "${WORKFLOW_NAME}//3001/a" | grep -E 'state: ' > 3001.a.log -cylc show "${WORKFLOW_NAME}//3002/a" 2>&1 >/dev/null \ - | grep -E 'No matching' > 3002.a.log +cylc show "${WORKFLOW_NAME}//3002/a" 2>&1 >/dev/null | grep -E 'No matching' > 3002.a.log # 3001/a should be spawned at both 3000/3001. cmp_ok 3001.a.log - <<__END__ @@ -81,9 +76,10 @@ cylc reload "${WORKFLOW_NAME}" cylc remove "${WORKFLOW_NAME}//3001/b" +poll_grep_workflow_log 'Command "remove_tasks" actioned.' + cylc show "${WORKFLOW_NAME}//3002/b" | grep -E 'state: ' > 3002.b.log -cylc show "${WORKFLOW_NAME}//3003/b" 2>&1 >/dev/null \ - | grep -E 'No matching' > 3003.b.log +cylc show "${WORKFLOW_NAME}//3003/b" 2>&1 >/dev/null | grep -E 'No matching' > 3003.b.log # 3002/b should be only at 3002. cmp_ok 3002.b.log - <<__END__ @@ -104,7 +100,6 @@ cmp_ok 3005.c.log - <<__END__ state: waiting __END__ - cylc stop --now --max-polls=10 --interval=2 "${WORKFLOW_NAME}" + purge -exit diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index edfe56e2a1f..518ef40f018 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -33,7 +33,7 @@ install as cylc_install, get_option_parser as install_gop ) -from cylc.flow.util import serialise +from cylc.flow.util import serialise_set from cylc.flow.wallclock import get_current_time_string from cylc.flow.workflow_files import infer_latest_run_from_id from cylc.flow.workflow_status import StopMode @@ -545,7 +545,7 @@ def _submit_task_jobs(*args, **kwargs): deps = tuple(sorted(itask.state.get_resolved_dependencies())) if flow_nums: triggers.add( - (itask.identity, serialise(itask.flow_nums), deps or None) + (itask.identity, serialise_set(itask.flow_nums), deps or None) ) else: triggers.add((itask.identity, deps or None)) @@ -558,8 +558,12 @@ def _submit_task_jobs(*args, **kwargs): return _reflog -@pytest.fixture -def complete(): +async def _complete( + schd, + *tokens_list: Union[Tokens, str], + stop_mode=StopMode.AUTO, + timeout: int = 60, +) -> None: """Wait for the workflow, or tasks within it to complete. Args: @@ -584,65 +588,67 @@ def complete(): async_timeout (handles shutdown logic more cleanly). """ - async def _complete( - schd, - *tokens_list: Union[Tokens, str], - stop_mode=StopMode.AUTO, - timeout: int = 60, - ) -> None: - start_time = time() - - _tokens_list: List[Tokens] = [] - for tokens in tokens_list: - if isinstance(tokens, str): - tokens = Tokens(tokens, relative=True) - _tokens_list.append(tokens.task) - - # capture task completion - remove_if_complete = schd.pool.remove_if_complete - - def _remove_if_complete(itask, output=None): - nonlocal _tokens_list - ret = remove_if_complete(itask) - if ret and itask.tokens.task in _tokens_list: - _tokens_list.remove(itask.tokens.task) - return ret - - schd.pool.remove_if_complete = _remove_if_complete - - # capture workflow shutdown - set_stop = schd._set_stop - has_shutdown = False - - def _set_stop(mode=None): - nonlocal has_shutdown, stop_mode - if mode == stop_mode: - has_shutdown = True - return set_stop(mode) - else: - set_stop(mode) - raise Exception(f'Workflow bailed with stop mode = {mode}') - - schd._set_stop = _set_stop - - # determine the completion condition - if _tokens_list: - condition = lambda: bool(_tokens_list) + start_time = time() + + _tokens_list: List[Tokens] = [] + for tokens in tokens_list: + if isinstance(tokens, str): + tokens = Tokens(tokens, relative=True) + _tokens_list.append(tokens.task) + + # capture task completion + remove_if_complete = schd.pool.remove_if_complete + + def _remove_if_complete(itask, output=None): + nonlocal _tokens_list + ret = remove_if_complete(itask) + if ret and itask.tokens.task in _tokens_list: + _tokens_list.remove(itask.tokens.task) + return ret + + schd.pool.remove_if_complete = _remove_if_complete + + # capture workflow shutdown + set_stop = schd._set_stop + has_shutdown = False + + def _set_stop(mode=None): + nonlocal has_shutdown, stop_mode + if mode == stop_mode: + has_shutdown = True + return set_stop(mode) else: - condition = lambda: bool(not has_shutdown) + set_stop(mode) + raise Exception(f'Workflow bailed with stop mode = {mode}') + + schd._set_stop = _set_stop + + # determine the completion condition + if _tokens_list: + condition = lambda: bool(_tokens_list) + else: + condition = lambda: bool(not has_shutdown) + + # wait for the condition to be met + while condition(): + # allow the main loop to advance + await asyncio.sleep(0) + if (time() - start_time) > timeout: + raise Exception( + f'Timeout waiting for {", ".join(map(str, _tokens_list))}' + ) + + # restore regular shutdown logic + schd._set_stop = set_stop + - # wait for the condition to be met - while condition(): - # allow the main loop to advance - await asyncio.sleep(0) - if (time() - start_time) > timeout: - raise Exception( - f'Timeout waiting for {", ".join(map(str, _tokens_list))}' - ) +@pytest.fixture +def complete(): + return _complete - # restore regular shutdown logic - schd._set_stop = set_stop +@pytest.fixture(scope='module') +def mod_complete(): return _complete diff --git a/tests/integration/scripts/test_validate_integration.py b/tests/integration/scripts/test_validate_integration.py index bd94a9d60cd..dcf697aac36 100644 --- a/tests/integration/scripts/test_validate_integration.py +++ b/tests/integration/scripts/test_validate_integration.py @@ -105,7 +105,7 @@ def test_validate_simple_graph(flow, validate, caplog): }) validate(id_) expect = ( - 'deprecated graph items were automatically upgraded' + 'graph items were automatically upgraded' ' in "workflow definition":' '\n * (8.0.0) [scheduling][dependencies]graph -> [scheduling][graph]R1' ) @@ -205,7 +205,7 @@ def test_graph_upgrade_msg_graph_equals2(flow, validate, caplog): }) validate(id_) expect = ( - 'deprecated graph items were automatically upgraded in' + 'graph items were automatically upgraded in' ' "workflow definition":' '\n * (8.0.0) [scheduling][dependencies][X]graph' ' -> [scheduling][graph]X - for X in:' diff --git a/tests/integration/test_config.py b/tests/integration/test_config.py index 0a186aa41bd..6e7454f1293 100644 --- a/tests/integration/test_config.py +++ b/tests/integration/test_config.py @@ -357,7 +357,7 @@ def test_xtrig_validation_wall_clock( } }) with pytest.raises(WorkflowConfigError, match=( - r'\[@myxt\] wall_clock\(offset=PT7MH\) validation failed: ' + r'\[@myxt\] wall_clock\(offset=PT7MH\)\n' r'Invalid offset: PT7MH' )): validate(id_) @@ -392,7 +392,7 @@ def test_xtrig_validation_echo( }) with pytest.raises( WorkflowConfigError, - match=r'echo.* Requires \'succeed=True/False\' arg' + match=r'Requires \'succeed=True/False\' arg' ): validate(id_) @@ -461,12 +461,12 @@ def kustom_validate(args): @pytest.mark.parametrize('xtrig_call, expected_msg', [ pytest.param( 'xrandom()', - r"xrandom.* missing a required argument: 'percent'", + r"missing a required argument: 'percent'", id="missing-arg" ), pytest.param( 'wall_clock(alan_grant=1)', - r"wall_clock.* unexpected keyword argument 'alan_grant'", + r"unexpected keyword argument 'alan_grant'", id="unexpected-arg" ), ]) diff --git a/tests/integration/test_dbstatecheck.py b/tests/integration/test_dbstatecheck.py new file mode 100644 index 00000000000..a6da4348ffb --- /dev/null +++ b/tests/integration/test_dbstatecheck.py @@ -0,0 +1,139 @@ +# THIS FILE IS PART OF THE CYLC WORKFLOW ENGINE. +# Copyright (C) NIWA & British Crown (Met Office) & Contributors. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Tests for the backend method of workflow_state""" + + +from asyncio import sleep +import pytest +from textwrap import dedent +from typing import TYPE_CHECKING + +from cylc.flow.dbstatecheck import CylcWorkflowDBChecker as Checker + + +if TYPE_CHECKING: + from cylc.flow.dbstatecheck import CylcWorkflowDBChecker + + +@pytest.fixture(scope='module') +async def checker( + mod_flow, mod_scheduler, mod_run, mod_complete +) -> 'CylcWorkflowDBChecker': + """Make a real world database. + + We could just write the database manually but this is a better + test of the overall working of the function under test. + """ + wid = mod_flow({ + 'scheduling': { + 'graph': {'P1Y': dedent(''' + good:succeeded + bad:failed? + output:custom_output + ''')}, + 'initial cycle point': '1000', + 'final cycle point': '1001' + }, + 'runtime': { + 'bad': {'simulation': {'fail cycle points': '1000'}}, + 'output': {'outputs': {'trigger': 'message'}} + } + }) + schd = mod_scheduler(wid, paused_start=False) + async with mod_run(schd): + await mod_complete(schd) + schd.pool.force_trigger_tasks(['1000/good'], [2]) + # Allow a cycle of the main loop to pass so that flow 2 can be + # added to db + await sleep(1) + yield Checker( + 'somestring', 'utterbunkum', + schd.workflow_db_mgr.pub_path + ) + + +def test_basic(checker): + """Pass no args, get unfiltered output""" + result = checker.workflow_state_query() + expect = [ + ['bad', '10000101T0000Z', 'failed'], + ['bad', '10010101T0000Z', 'succeeded'], + ['good', '10000101T0000Z', 'succeeded'], + ['good', '10010101T0000Z', 'succeeded'], + ['output', '10000101T0000Z', 'succeeded'], + ['output', '10010101T0000Z', 'succeeded'], + ['good', '10000101T0000Z', 'waiting', '(flows=2)'], + ] + assert result == expect + + +def test_task(checker): + """Filter by task name""" + result = checker.workflow_state_query(task='bad') + assert result == [ + ['bad', '10000101T0000Z', 'failed'], + ['bad', '10010101T0000Z', 'succeeded'] + ] + + +def test_point(checker): + """Filter by point""" + result = checker.workflow_state_query(cycle='10000101T0000Z') + assert result == [ + ['bad', '10000101T0000Z', 'failed'], + ['good', '10000101T0000Z', 'succeeded'], + ['output', '10000101T0000Z', 'succeeded'], + ['good', '10000101T0000Z', 'waiting', '(flows=2)'], + ] + + +def test_status(checker): + """Filter by status""" + result = checker.workflow_state_query(selector='failed') + expect = [ + ['bad', '10000101T0000Z', 'failed'], + ] + assert result == expect + + +def test_output(checker): + """Filter by flow number""" + result = checker.workflow_state_query(selector='message', is_message=True) + expect = [ + [ + 'output', + '10000101T0000Z', + "{'submitted': 'submitted', 'started': 'started', 'succeeded': " + "'succeeded', 'trigger': 'message'}", + ], + [ + 'output', + '10010101T0000Z', + "{'submitted': 'submitted', 'started': 'started', 'succeeded': " + "'succeeded', 'trigger': 'message'}", + ], + ] + assert result == expect + + +def test_flownum(checker): + """Pass no args, get unfiltered output""" + result = checker.workflow_state_query(flow_num=2) + expect = [ + ['good', '10000101T0000Z', 'waiting', '(flows=2)'], + ] + assert result == expect diff --git a/tests/integration/test_sequential_xtriggers.py b/tests/integration/test_sequential_xtriggers.py index cbe0051d084..8d3b6129044 100644 --- a/tests/integration/test_sequential_xtriggers.py +++ b/tests/integration/test_sequential_xtriggers.py @@ -190,7 +190,7 @@ def xtrig2(x, sequential='True'): with pytest.raises(XtriggerConfigError) as excinfo: validate(wid) assert ( - "reserved argument 'sequential' that has no boolean default" + "reserved argument 'sequential' with no boolean default" ) in str(excinfo.value) diff --git a/tests/integration/test_xtrigger_mgr.py b/tests/integration/test_xtrigger_mgr.py index 612049163cc..3bf425650c4 100644 --- a/tests/integration/test_xtrigger_mgr.py +++ b/tests/integration/test_xtrigger_mgr.py @@ -188,3 +188,46 @@ def mytrig(*args, **kwargs): # check the DB to ensure no additional entries have been created assert db_select(schd, True, 'xtriggers') == db_xtriggers + + +async def test_error_in_xtrigger(flow, start, scheduler): + """Failure in an xtrigger is handled nicely. + """ + id_ = flow({ + 'scheduler': { + 'allow implicit tasks': 'True' + }, + 'scheduling': { + 'xtriggers': { + 'mytrig': 'mytrig()' + }, + 'graph': { + 'R1': '@mytrig => foo' + }, + } + }) + + # add a custom xtrigger to the workflow + run_dir = Path(get_workflow_run_dir(id_)) + xtrig_dir = run_dir / 'lib/python' + xtrig_dir.mkdir(parents=True) + (xtrig_dir / 'mytrig.py').write_text(dedent(''' + def mytrig(*args, **kwargs): + raise Exception('This Xtrigger is broken') + ''')) + + schd = scheduler(id_) + async with start(schd) as log: + foo = schd.pool.get_tasks()[0] + schd.xtrigger_mgr.call_xtriggers_async(foo) + for _ in range(50): + await asyncio.sleep(0.1) + schd.proc_pool.process() + if len(schd.proc_pool.runnings) == 0: + break + else: + raise Exception('Process pool did not clear') + + error = log.messages[-1].split('\n') + assert error[-2] == 'Exception: This Xtrigger is broken' + assert error[0] == 'ERROR in xtrigger mytrig()' diff --git a/tests/unit/cycling/test_util.py b/tests/unit/cycling/test_util.py index 2cec1278cfe..fce316c6de5 100644 --- a/tests/unit/cycling/test_util.py +++ b/tests/unit/cycling/test_util.py @@ -24,10 +24,12 @@ def test_add_offset(): """Test socket start.""" orig_point = '20200202T0000Z' plus_offset = '+PT02H02M' - print(add_offset(orig_point, plus_offset)) assert str(add_offset(orig_point, plus_offset)) == '20200202T0202Z' minus_offset = '-P1MT22H59M' assert str(add_offset(orig_point, minus_offset)) == '20200101T0101Z' + assert str( + add_offset(orig_point, minus_offset, dmp_fmt="CCYY-MM-DDThh:mmZ") + ) == '2020-01-01T01:01Z' bad_offset = '+foo' - with pytest.raises(ValueError, match=r'ERROR, bad offset format') as exc: - bad_point = add_offset(orig_point, bad_offset) + with pytest.raises(ValueError, match=r'ERROR, bad offset format'): + add_offset(orig_point, bad_offset) diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 653c1c11f8b..9cdcee89003 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -14,7 +14,6 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from copy import deepcopy import os import sys from optparse import Values @@ -23,7 +22,6 @@ import pytest import logging from types import SimpleNamespace -from unittest.mock import Mock from contextlib import suppress from cylc.flow import CYLC_LOG @@ -40,10 +38,8 @@ from cylc.flow.parsec.exceptions import Jinja2Error, EmPyError from cylc.flow.scheduler_cli import RunOptions from cylc.flow.scripts.validate import ValidateOptions -from cylc.flow.simulation import configure_sim_modes from cylc.flow.workflow_files import WorkflowFiles from cylc.flow.wallclock import get_utc_mode, set_utc_mode -from cylc.flow.xtrigger_mgr import XtriggerManager from cylc.flow.task_outputs import ( TASK_OUTPUT_SUBMITTED, TASK_OUTPUT_SUCCEEDED, @@ -86,8 +82,7 @@ class TestWorkflowConfig: """Test class for the Cylc WorkflowConfig object.""" def test_xfunction_imports( - self, mock_glbl_cfg: Fixture, tmp_path: Path, - xtrigger_mgr: XtriggerManager): + self, mock_glbl_cfg: Fixture, tmp_path: Path): """Test for a workflow configuration with valid xtriggers""" mock_glbl_cfg( 'cylc.flow.platforms.glbl_cfg', @@ -115,10 +110,9 @@ def test_xfunction_imports( """ flow_file.write_text(flow_config) workflow_config = WorkflowConfig( - workflow="name_a_tree", fpath=flow_file, options=SimpleNamespace(), - xtrigger_mgr=xtrigger_mgr + workflow="name_a_tree", fpath=flow_file, options=SimpleNamespace() ) - assert 'tree' in workflow_config.xtrigger_mgr.functx_map + assert 'tree' in workflow_config.xtrigger_collator.functx_map def test_xfunction_import_error(self, mock_glbl_cfg, tmp_path): """Test for error when a xtrigger function cannot be imported.""" @@ -151,7 +145,7 @@ def test_xfunction_import_error(self, mock_glbl_cfg, tmp_path): fpath=flow_file, options=SimpleNamespace() ) - assert "not found" in str(excinfo.value) + assert "No module named 'piranha'" in str(excinfo.value) def test_xfunction_attribute_error(self, mock_glbl_cfg, tmp_path): """Test for error when a xtrigger function cannot be imported.""" @@ -181,7 +175,7 @@ def test_xfunction_attribute_error(self, mock_glbl_cfg, tmp_path): with pytest.raises(XtriggerConfigError) as excinfo: WorkflowConfig(workflow="capybara_workflow", fpath=flow_file, options=SimpleNamespace()) - assert "not found" in str(excinfo.value) + assert "module 'capybara' has no attribute 'capybara'" in str(excinfo.value) def test_xfunction_not_callable(self, mock_glbl_cfg, tmp_path): """Test for error when a xtrigger function is not callable.""" diff --git a/tests/unit/test_db_compat.py b/tests/unit/test_db_compat.py index 1cb31173371..5393e85e67f 100644 --- a/tests/unit/test_db_compat.py +++ b/tests/unit/test_db_compat.py @@ -134,9 +134,9 @@ def test_cylc_7_db_wflow_params_table(_setup_db): with pytest.raises( sqlite3.OperationalError, match="no such table: workflow_params" ): - checker.get_remote_point_format() + checker._get_db_point_format() - assert checker.get_remote_point_format_compat() == ptformat + assert checker.db_point_fmt == ptformat def test_pre_830_task_action_timers(_setup_db): diff --git a/tests/unit/test_graph_parser.py b/tests/unit/test_graph_parser.py index 84a8e4611fd..75a0bf95a83 100644 --- a/tests/unit/test_graph_parser.py +++ b/tests/unit/test_graph_parser.py @@ -314,8 +314,9 @@ def test_inter_workflow_dependence_simple(): 'a': ( 'WORKFLOW', 'TASK', 'failed', '' ), + # Default to "succeeded" is done in config module. 'c': ( - 'WORKFLOW', 'TASK', 'succeeded', '' + 'WORKFLOW', 'TASK', None, '' ) } ) diff --git a/tests/unit/test_util.py b/tests/unit/test_util.py index 6da9b28ca24..1f8c98897a4 100644 --- a/tests/unit/test_util.py +++ b/tests/unit/test_util.py @@ -14,10 +14,10 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from cylc.flow.util import deserialise +from cylc.flow.util import deserialise_set -def test_deserialise(): - actual = deserialise('["2", "3"]') +def test_deserialise_set(): + actual = deserialise_set('["2", "3"]') expected = {'2', '3'} assert actual == expected diff --git a/tests/unit/test_xtrigger_mgr.py b/tests/unit/test_xtrigger_mgr.py index 5192a7d3dd8..276fd354a95 100644 --- a/tests/unit/test_xtrigger_mgr.py +++ b/tests/unit/test_xtrigger_mgr.py @@ -24,13 +24,7 @@ from cylc.flow.subprocctx import SubFuncContext from cylc.flow.task_proxy import TaskProxy from cylc.flow.taskdef import TaskDef -from cylc.flow.xtrigger_mgr import RE_STR_TMPL, XtriggerManager - - -def test_constructor(xtrigger_mgr): - """Test creating an XtriggerManager, and its initial state.""" - # the dict with normal xtriggers starts empty - assert not xtrigger_mgr.functx_map +from cylc.flow.xtrigger_mgr import RE_STR_TMPL, XtriggerCollator def test_extract_templates(): @@ -44,59 +38,82 @@ def test_extract_templates(): ) -def test_add_xtrigger(xtrigger_mgr): - """Test for adding an xtrigger.""" +def test_add_missing_func(): + """Test for adding an xtrigger that can't be found.""" + xtriggers = XtriggerCollator() xtrig = SubFuncContext( - label="echo", - func_name="echo", + label="fooble", + func_name="fooble123", # no such module func_args=["name", "age"], func_kwargs={"location": "soweto"} ) - xtrigger_mgr.add_trig("xtrig", xtrig, 'fdir') - assert xtrig == xtrigger_mgr.functx_map["xtrig"] + with pytest.raises( + XtriggerConfigError, + match=r"\[@xtrig\] fooble123\(.*\)\nNo module named 'fooble123'" + ): + xtriggers.add_trig("xtrig", xtrig, 'fdir') -def test_add_xtrigger_with_params(xtrigger_mgr): - """Test for adding an xtrigger.""" +def test_add_xtrigger(): + """Test for adding and validating an xtrigger.""" + xtriggers = XtriggerCollator() xtrig = SubFuncContext( label="echo", func_name="echo", - func_args=["name", "%(point)s"], - func_kwargs={"%(location)s": "soweto"} # no problem with the key! + func_args=["name", "age"], + func_kwargs={"location": "soweto"} ) - xtrigger_mgr.add_trig("xtrig", xtrig, 'fdir') - assert xtrig == xtrigger_mgr.functx_map["xtrig"] + with pytest.raises( + XtriggerConfigError, + match="Requires 'succeed=True/False' arg" + ): + xtriggers.add_trig("xtrig", xtrig, 'fdir') + xtrig = SubFuncContext( + label="echo", + func_name="echo", + func_args=["name", "age"], + func_kwargs={"location": "soweto", "succeed": True} + ) + xtriggers.add_trig("xtrig", xtrig, 'fdir') + assert xtrig == xtriggers.functx_map["xtrig"] -def test_check_xtrigger_with_unknown_params(): - """Test for adding an xtrigger with an unknown parameter. - The XTriggerManager contains a list of specific parameters that are - available in the function template. +def test_add_xtrigger_with_template_good(): + """Test adding an xtrigger with a valid string template arg value.""" + xtriggers = XtriggerCollator() + xtrig = SubFuncContext( + label="echo", + func_name="echo", + func_args=["name", "%(point)s"], # valid template + func_kwargs={"location": "soweto", "succeed": True} + ) + xtriggers.add_trig("xtrig", xtrig, 'fdir') + assert xtrig == xtriggers.functx_map["xtrig"] - Values that are not strings raise a TypeError during regex matching, but - are ignored, so we should not have any issue with TypeError. - If a value in the format %(foo)s appears in the parameters, and 'foo' - is not in this list of parameters, then a ValueError is expected. - """ +def test_add_xtrigger_with_template_bad(): + """Test adding an xtrigger with an invalid string template arg value.""" + xtriggers = XtriggerCollator() xtrig = SubFuncContext( label="echo", func_name="echo", - func_args=[1, "name", "%(what_is_this)s"], - func_kwargs={"succeed": True} + func_args=["name", "%(point)s"], + # invalid template: + func_kwargs={"location": "%(what_is_this)s", "succeed": True} ) with pytest.raises( XtriggerConfigError, match="Illegal template in xtrigger: what_is_this" ): - XtriggerManager.check_xtrigger("xtrig", xtrig, 'fdir') + xtriggers.add_trig("xtrig", xtrig, 'fdir') -def test_check_xtrigger_with_deprecated_params( +def test_add_xtrigger_with_deprecated_params( caplog: pytest.LogCaptureFixture ): """It should flag deprecated template variables.""" + xtriggers = XtriggerCollator() xtrig = SubFuncContext( label="echo", func_name="echo", @@ -104,7 +121,7 @@ def test_check_xtrigger_with_deprecated_params( func_kwargs={"succeed": True} ) caplog.set_level(logging.WARNING, CYLC_LOG) - XtriggerManager.check_xtrigger("xtrig", xtrig, 'fdir') + xtriggers.add_trig("xtrig", xtrig, 'fdir') assert caplog.messages == [ 'Xtrigger "xtrig" uses deprecated template variables: suite_name' ] @@ -135,6 +152,7 @@ def test_housekeeping_nothing_satisfied(xtrigger_mgr): are kept.""" row = "get_name", "{\"name\": \"function\"}" # now XtriggerManager#sat_xtrigger will contain the get_name xtrigger + xtrigger_mgr.add_xtriggers(XtriggerCollator()) xtrigger_mgr.load_xtrigger_for_restart(row_idx=0, row=row) assert xtrigger_mgr.sat_xtrig xtrigger_mgr.housekeep([]) @@ -144,13 +162,18 @@ def test_housekeeping_nothing_satisfied(xtrigger_mgr): def test_housekeeping_with_xtrigger_satisfied(xtrigger_mgr): """The housekeeping method makes sure only satisfied xtrigger function are kept.""" + + xtriggers = XtriggerCollator() + xtrig = SubFuncContext( label="get_name", - func_name="get_name", + func_name="echo", func_args=[], - func_kwargs={} + func_kwargs={"succeed": True} ) - xtrigger_mgr.add_trig("get_name", xtrig, 'fdir') + xtriggers.add_trig("get_name", xtrig, 'fdir') + xtrigger_mgr.add_xtriggers(xtriggers) + xtrig.out = "[\"True\", {\"name\": \"Yossarian\"}]" tdef = TaskDef( name="foo", @@ -159,15 +182,19 @@ def test_housekeeping_with_xtrigger_satisfied(xtrigger_mgr): start_point=1, initial_point=1, ) + init() sequence = ISO8601Sequence('P1D', '2019') tdef.xtrig_labels[sequence] = ["get_name"] start_point = ISO8601Point('2019') itask = TaskProxy(Tokens('~user/workflow'), tdef, start_point) # pretend the function has been activated + xtrigger_mgr.active.append(xtrig.get_signature()) + xtrigger_mgr.callback(xtrig) assert xtrigger_mgr.sat_xtrig + xtrigger_mgr.housekeep([itask]) # here we still have the same number as before assert xtrigger_mgr.sat_xtrig @@ -175,25 +202,32 @@ def test_housekeeping_with_xtrigger_satisfied(xtrigger_mgr): def test__call_xtriggers_async(xtrigger_mgr): """Test _call_xtriggers_async""" + + xtriggers = XtriggerCollator() + # the echo1 xtrig (not satisfied) echo1_xtrig = SubFuncContext( label="echo1", - func_name="echo1", + func_name="echo", func_args=[], - func_kwargs={} + func_kwargs={"succeed": False} ) echo1_xtrig.out = "[\"True\", {\"name\": \"herminia\"}]" - xtrigger_mgr.add_trig("echo1", echo1_xtrig, "fdir") + xtriggers.add_trig("echo1", echo1_xtrig, "fdir") + # the echo2 xtrig (satisfied through callback later) echo2_xtrig = SubFuncContext( label="echo2", - func_name="echo2", + func_name="echo", func_args=[], - func_kwargs={} + func_kwargs={"succeed": True} ) echo2_xtrig.out = "[\"True\", {\"name\": \"herminia\"}]" - xtrigger_mgr.add_trig("echo2", echo2_xtrig, "fdir") + xtriggers.add_trig("echo2", echo2_xtrig, "fdir") + + xtrigger_mgr.add_xtriggers(xtriggers) + # create a task tdef = TaskDef( name="foo", diff --git a/tests/unit/xtriggers/test_workflow_state.py b/tests/unit/xtriggers/test_workflow_state.py index bb5228984c9..5420a4fd909 100644 --- a/tests/unit/xtriggers/test_workflow_state.py +++ b/tests/unit/xtriggers/test_workflow_state.py @@ -15,35 +15,37 @@ # along with this program. If not, see . from pathlib import Path -import pytest import sqlite3 -from typing import Callable -from unittest.mock import Mock +from typing import Any, Callable from shutil import copytree, rmtree -from cylc.flow.exceptions import InputError -from cylc.flow.pathutil import get_cylc_run_dir +import pytest + +from cylc.flow.dbstatecheck import output_fallback_msg +from cylc.flow.exceptions import WorkflowConfigError +from cylc.flow.rundb import CylcWorkflowDAO from cylc.flow.workflow_files import WorkflowFiles -from cylc.flow.xtriggers.workflow_state import workflow_state -from ..conftest import MonkeyMock +from cylc.flow.xtriggers.workflow_state import ( + _workflow_state_backcompat, + workflow_state, + validate, +) +from cylc.flow.xtriggers.suite_state import suite_state + + +def test_inferred_run(tmp_run_dir: 'Callable', capsys: pytest.CaptureFixture): + """Test that the workflow_state xtrigger infers the run number. + Method: the faked run-dir has no DB to connect to, but the WorkflowPoller + prints inferred ID to stderr if the run-dir exists. -def test_inferred_run(tmp_run_dir: Callable, monkeymock: MonkeyMock): - """Test that the workflow_state xtrigger infers the run number""" + """ id_ = 'isildur' expected_workflow_id = f'{id_}/run1' cylc_run_dir = str(tmp_run_dir()) tmp_run_dir(expected_workflow_id, installed=True, named=True) - mock_db_checker = monkeymock( - 'cylc.flow.xtriggers.workflow_state.CylcWorkflowDBChecker', - return_value=Mock( - get_remote_point_format=lambda: 'CCYY', - ) - ) - - _, results = workflow_state(id_, task='precious', point='3000') - mock_db_checker.assert_called_once_with(cylc_run_dir, expected_workflow_id) - assert results['workflow'] == expected_workflow_id + workflow_state(id_ + '//3000/precious') + assert expected_workflow_id in capsys.readouterr().err # Now test we can see workflows in alternate cylc-run directories # e.g. for `cylc workflow-state` or xtriggers targetting another user. @@ -54,19 +56,15 @@ def test_inferred_run(tmp_run_dir: Callable, monkeymock: MonkeyMock): rmtree(cylc_run_dir) # It can no longer parse IDs in the original cylc-run location. - with pytest.raises(InputError): - _, results = workflow_state(id_, task='precious', point='3000') + workflow_state(id_) + assert expected_workflow_id not in capsys.readouterr().err # But it can via an explicit alternate run directory. - mock_db_checker.reset_mock() - _, results = workflow_state( - id_, task='precious', point='3000', cylc_run_dir=alt_cylc_run_dir) - mock_db_checker.assert_called_once_with( - alt_cylc_run_dir, expected_workflow_id) - assert results['workflow'] == expected_workflow_id + workflow_state(id_, alt_cylc_run_dir=alt_cylc_run_dir) + assert expected_workflow_id in capsys.readouterr().err -def test_back_compat(tmp_run_dir, caplog): +def test_c7_db_back_compat(tmp_run_dir: 'Callable'): """Test workflow_state xtrigger backwards compatibility with Cylc 7 database.""" id_ = 'celebrimbor' @@ -88,6 +86,11 @@ def test_back_compat(tmp_run_dir, caplog): submit_num INTEGER, status TEXT, PRIMARY KEY(name, cycle) ); """) + conn.execute(r""" + CREATE TABLE task_outputs( + cycle TEXT, name TEXT, outputs TEXT, PRIMARY KEY(cycle, name) + ); + """) conn.executemany( r'INSERT INTO "suite_params" VALUES(?,?);', [('cylc_version', '7.8.12'), @@ -95,9 +98,14 @@ def test_back_compat(tmp_run_dir, caplog): ('cycle_point_tz', 'Z')] ) conn.execute(r""" - INSERT INTO "task_states" VALUES( - 'mithril','2012','2023-01-30T18:19:15Z','2023-01-30T18:19:15Z', - 0,'succeeded' + INSERT INTO "task_states" VALUES( + 'mithril','2012','2023-01-30T18:19:15Z','2023-01-30T18:19:15Z', + 0,'succeeded' + ); + """) + conn.execute(r""" + INSERT INTO "task_outputs" VALUES( + '2012','mithril','{"frodo": "bag end"}' ); """) conn.commit() @@ -105,14 +113,182 @@ def test_back_compat(tmp_run_dir, caplog): conn.close() # Test workflow_state function - satisfied, _ = workflow_state(id_, task='mithril', point='2012') + satisfied, _ = workflow_state(f'{id_}//2012/mithril') + assert satisfied + satisfied, _ = workflow_state(f'{id_}//2012/mithril:succeeded') assert satisfied - satisfied, _ = workflow_state(id_, task='arkenstone', point='2012') + satisfied, _ = workflow_state( + f'{id_}//2012/mithril:frodo', is_trigger=True + ) + assert satisfied + satisfied, _ = workflow_state( + f'{id_}//2012/mithril:"bag end"', is_message=True + ) + assert satisfied + satisfied, _ = workflow_state(f'{id_}//2012/mithril:pippin') + assert not satisfied + satisfied, _ = workflow_state(id_ + '//2012/arkenstone') assert not satisfied # Test back-compat (old suite_state function) - from cylc.flow.xtriggers.suite_state import suite_state satisfied, _ = suite_state(suite=id_, task='mithril', point='2012') assert satisfied + satisfied, _ = suite_state( + suite=id_, task='mithril', point='2012', status='succeeded' + ) + assert satisfied + satisfied, _ = suite_state( + suite=id_, task='mithril', point='2012', message='bag end' + ) + assert satisfied satisfied, _ = suite_state(suite=id_, task='arkenstone', point='2012') assert not satisfied + + +def test_c8_db_back_compat( + tmp_run_dir: 'Callable', + capsys: pytest.CaptureFixture, +): + """Test workflow_state xtrigger backwards compatibility with Cylc < 8.3.0 + database.""" + id_ = 'nazgul' + run_dir: Path = tmp_run_dir(id_) + db_file = run_dir / 'log' / 'db' + db_file.parent.mkdir(exist_ok=True) + # Note: don't use CylcWorkflowDAO here as DB should be frozen + conn = sqlite3.connect(str(db_file)) + try: + conn.execute(r""" + CREATE TABLE workflow_params( + key TEXT, value TEXT, PRIMARY KEY(key) + ); + """) + conn.execute(r""" + CREATE TABLE task_states( + name TEXT, cycle TEXT, flow_nums TEXT, time_created TEXT, + time_updated TEXT, submit_num INTEGER, status TEXT, + flow_wait INTEGER, is_manual_submit INTEGER, + PRIMARY KEY(name, cycle, flow_nums) + ); + """) + conn.execute(r""" + CREATE TABLE task_outputs( + cycle TEXT, name TEXT, flow_nums TEXT, outputs TEXT, + PRIMARY KEY(cycle, name, flow_nums) + ); + """) + conn.executemany( + r'INSERT INTO "workflow_params" VALUES(?,?);', + [('cylc_version', '8.2.7'), + ('cycle_point_format', '%Y'), + ('cycle_point_tz', 'Z')] + ) + conn.execute(r""" + INSERT INTO "task_states" VALUES( + 'gimli','2012','[1]','2023-01-30T18:19:15Z', + '2023-01-30T18:19:15Z',1,'succeeded',0,0 + ); + """) + conn.execute(r""" + INSERT INTO "task_outputs" VALUES( + '2012','gimli','[1]', + '["submitted", "started", "succeeded", "axe"]' + ); + """) + conn.commit() + finally: + conn.close() + + gimli = f'{id_}//2012/gimli' + + satisfied, _ = workflow_state(gimli) + assert satisfied + satisfied, _ = workflow_state(f'{gimli}:succeeded') + assert satisfied + satisfied, _ = workflow_state(f'{gimli}:axe', is_message=True) + assert satisfied + _, err = capsys.readouterr() + assert not err + # Output label selector falls back to message + # (won't work if messsage != output label) + satisfied, _ = workflow_state(f'{gimli}:axe', is_trigger=True) + assert satisfied + _, err = capsys.readouterr() + assert output_fallback_msg in err + + +def test__workflow_state_backcompat(tmp_run_dir: 'Callable'): + """Test the _workflow_state_backcompat & suite_state functions on a + *current* Cylc database.""" + id_ = 'dune' + run_dir: Path = tmp_run_dir(id_) + db_file = run_dir / 'log' / 'db' + db_file.parent.mkdir(exist_ok=True) + with CylcWorkflowDAO(db_file, create_tables=True) as dao: + conn = dao.connect() + conn.executemany( + r'INSERT INTO "workflow_params" VALUES(?,?);', + [('cylc_version', '8.3.0'), + ('cycle_point_format', '%Y'), + ('cycle_point_tz', 'Z')] + ) + conn.execute(r""" + INSERT INTO "task_states" VALUES( + 'arrakis','2012','[1]','2023-01-30T18:19:15Z', + '2023-01-30T18:19:15Z',1,'succeeded',0,0 + ); + """) + conn.execute(r""" + INSERT INTO "task_outputs" VALUES( + '2012','arrakis','[1]', + '{"submitted": "submitted", "started": "started", "succeeded": "succeeded", "paul": "lisan al-gaib"}' + ); + """) + conn.commit() + + func: Any + for func in (_workflow_state_backcompat, suite_state): + satisfied, _ = func(id_, 'arrakis', '2012') + assert satisfied + satisfied, _ = func(id_, 'arrakis', '2012', status='succeeded') + assert satisfied + # Both output label and message work + satisfied, _ = func(id_, 'arrakis', '2012', message='paul') + assert satisfied + satisfied, _ = func(id_, 'arrakis', '2012', message='lisan al-gaib') + assert satisfied + + +def test_validate_ok(): + """Validate returns ok with valid args.""" + validate({ + 'workflow_task_id': 'foo//1/bar', + 'offset': 'PT1H', + 'flow_num': 44, + }) + + +@pytest.mark.parametrize( + 'id_', (('foo//1'),) +) +def test_validate_fail_bad_id(id_): + """Validation failure for bad id""" + with pytest.raises(WorkflowConfigError, match='Full ID needed'): + validate({ + 'workflow_task_id': id_, + 'offset': 'PT1H', + 'flow_num': 44, + }) + + +@pytest.mark.parametrize( + 'flow_num', ((4.25260), ('Belguim')) +) +def test_validate_fail_non_int_flow(flow_num): + """Validate failure for non integer flow numbers.""" + with pytest.raises(WorkflowConfigError, match='must be an integer'): + validate({ + 'workflow_task_id': 'foo//1/bar', + 'offset': 'PT1H', + 'flow_num': flow_num, + }) diff --git a/tox.ini b/tox.ini index 95222eeb859..d9954dbb7e8 100644 --- a/tox.ini +++ b/tox.ini @@ -14,9 +14,9 @@ ignore= per-file-ignores= ; TYPE_CHECKING block suggestions - tests/*: TC001 + tests/*: TC001, TC002, TC003 ; for clarity we don't merge 'with Conf():' context trees - tests/unit/parsec/*: SIM117 + tests/unit/parsec/*: SIM117, TC001, TC002, TC003 exclude= build, From f426bc4e4f0c28d7bf2f6655d9a5f271e8bc3c47 Mon Sep 17 00:00:00 2001 From: Oliver Sanders Date: Mon, 17 Jun 2024 14:54:42 +0100 Subject: [PATCH 066/196] mypy: attempt to fix lint issue (#6148) --- cylc/flow/network/ssh_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cylc/flow/network/ssh_client.py b/cylc/flow/network/ssh_client.py index 455b6ce7f2b..d1dd4fb6da4 100644 --- a/cylc/flow/network/ssh_client.py +++ b/cylc/flow/network/ssh_client.py @@ -63,7 +63,7 @@ async def async_request( cmd, ssh_cmd, login_sh, cylc_path, msg = self.prepare_command( command, args, timeout ) - platform = { + platform: dict = { 'ssh command': ssh_cmd, 'cylc path': cylc_path, 'use login shell': login_sh, From 587a2b793f54f0574034d304deba2fc5c0dbf956 Mon Sep 17 00:00:00 2001 From: Ronnie Dutta <61982285+MetRonnie@users.noreply.github.com> Date: Thu, 13 Jun 2024 17:20:58 +0100 Subject: [PATCH 067/196] Fix gap in validation of xtrigger sequential argument --- cylc/flow/xtrigger_mgr.py | 15 +++++++---- .../integration/test_sequential_xtriggers.py | 27 ++++++++++++++++--- 2 files changed, 33 insertions(+), 9 deletions(-) diff --git a/cylc/flow/xtrigger_mgr.py b/cylc/flow/xtrigger_mgr.py index d23bc936203..aba2d5cabde 100644 --- a/cylc/flow/xtrigger_mgr.py +++ b/cylc/flow/xtrigger_mgr.py @@ -325,11 +325,16 @@ def _handle_sequential_kwarg( ) fctx.func_kwargs.setdefault('sequential', sequential_param.default) - elif 'sequential' in fctx.func_kwargs: - # xtrig marked as sequential, so add 'sequential' arg to signature - sig = add_kwarg_to_sig( - sig, 'sequential', fctx.func_kwargs['sequential'] - ) + if 'sequential' in fctx.func_kwargs: + # xtrig marked as sequential in function call + value = fctx.func_kwargs['sequential'] + if not isinstance(value, bool): + raise XtriggerConfigError( + label, fctx.func_name, + f"invalid argument 'sequential={value}' - must be boolean" + ) + if not sequential_param: + sig = add_kwarg_to_sig(sig, 'sequential', value) return sig @staticmethod diff --git a/tests/integration/test_sequential_xtriggers.py b/tests/integration/test_sequential_xtriggers.py index 8d3b6129044..d8bfb99aa92 100644 --- a/tests/integration/test_sequential_xtriggers.py +++ b/tests/integration/test_sequential_xtriggers.py @@ -159,10 +159,8 @@ async def test_sequential_arg_ok( assert len(list_cycles(schd)) == expected_num_cycles -def test_sequential_arg_bad( - flow, validate -): - """Test validation of 'sequential' arg for custom xtriggers""" +def test_sequential_arg_bad(flow, validate): + """Test validation of 'sequential' arg for custom xtrigger function def""" wid = flow({ 'scheduling': { 'xtriggers': { @@ -194,6 +192,27 @@ def xtrig2(x, sequential='True'): ) in str(excinfo.value) +def test_sequential_arg_bad2(flow, validate): + """Test validation of 'sequential' arg for xtrigger calls""" + wid = flow({ + 'scheduling': { + 'initial cycle point': '2000', + 'xtriggers': { + 'clock': 'wall_clock(sequential=3)', + }, + 'graph': { + 'R1': '@clock => foo', + }, + }, + }) + + with pytest.raises(XtriggerConfigError) as excinfo: + validate(wid) + assert ( + "invalid argument 'sequential=3' - must be boolean" + ) in str(excinfo.value) + + @pytest.mark.parametrize('is_sequential', [True, False]) async def test_any_sequential(flow, scheduler, start, is_sequential: bool): """Test that a task is marked as sequential if any of its xtriggers are.""" From 061a8be814bd6fda6253f79f20dd68ce323491f3 Mon Sep 17 00:00:00 2001 From: Ronnie Dutta <61982285+MetRonnie@users.noreply.github.com> Date: Thu, 13 Jun 2024 18:18:35 +0100 Subject: [PATCH 068/196] Xtrigger doc improvements --- cylc/flow/xtriggers/wall_clock.py | 3 +++ cylc/flow/xtriggers/workflow_state.py | 9 ++++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/cylc/flow/xtriggers/wall_clock.py b/cylc/flow/xtriggers/wall_clock.py index a159ec3019f..f000319d1ff 100644 --- a/cylc/flow/xtriggers/wall_clock.py +++ b/cylc/flow/xtriggers/wall_clock.py @@ -38,6 +38,9 @@ def wall_clock(offset: str = 'PT0S', sequential: bool = True): Wall-clock xtriggers are run sequentially by default. See :ref:`Sequential Xtriggers` for more details. + .. versionchanged:: 8.3.0 + + The ``sequential`` argument was added. """ # NOTE: This is just a placeholder for the actual implementation. # This is only used for validating the signature and for autodocs. diff --git a/cylc/flow/xtriggers/workflow_state.py b/cylc/flow/xtriggers/workflow_state.py index a2b7d2f1946..86a44a49608 100644 --- a/cylc/flow/xtriggers/workflow_state.py +++ b/cylc/flow/xtriggers/workflow_state.py @@ -36,7 +36,7 @@ def workflow_state( If the status or output has been achieved, return {True, result}. - Arg: + Args: workflow_task_id: ID (workflow//point/task:selector) of the target task. offset: @@ -62,6 +62,13 @@ def workflow_state( Dict of workflow, task, point, offset, status, message, trigger, flow_num, run_dir + .. versionchanged:: 8.3.0 + + The ``workflow_task_id`` argument was introduced to replace the + separate ``workflow``, ``point``, ``task``, ``status``, and ``message`` + arguments (which are still supported for backwards compatibility). + The ``flow_num`` argument was added. The ``cylc_run_dir`` argument + was renamed to ``alt_cylc_run_dir``. """ poller = WorkflowPoller( workflow_task_id, From f2fb28d92f762c5b32ba89f8ca2c991064f00153 Mon Sep 17 00:00:00 2001 From: Ronnie Dutta <61982285+MetRonnie@users.noreply.github.com> Date: Fri, 14 Jun 2024 15:57:33 +0100 Subject: [PATCH 069/196] Tidy --- cylc/flow/task_outputs.py | 10 +++------- tests/integration/test_dbstatecheck.py | 19 +++++++------------ 2 files changed, 10 insertions(+), 19 deletions(-) diff --git a/cylc/flow/task_outputs.py b/cylc/flow/task_outputs.py index 39da3750b59..40b9d991b30 100644 --- a/cylc/flow/task_outputs.py +++ b/cylc/flow/task_outputs.py @@ -419,14 +419,10 @@ def get_completed_outputs(self) -> Dict[str, str]: Replace message with "forced" if the output was forced. """ - def _get_msg(message): - if message in self._forced: - return FORCED_COMPLETION_MSG - else: - return message - return { - self._message_to_trigger[message]: _get_msg(message) + self._message_to_trigger[message]: ( + FORCED_COMPLETION_MSG if message in self._forced else message + ) for message, is_completed in self._completed.items() if is_completed } diff --git a/tests/integration/test_dbstatecheck.py b/tests/integration/test_dbstatecheck.py index a6da4348ffb..33db2ec4c2a 100644 --- a/tests/integration/test_dbstatecheck.py +++ b/tests/integration/test_dbstatecheck.py @@ -20,19 +20,15 @@ from asyncio import sleep import pytest from textwrap import dedent -from typing import TYPE_CHECKING -from cylc.flow.dbstatecheck import CylcWorkflowDBChecker as Checker - - -if TYPE_CHECKING: - from cylc.flow.dbstatecheck import CylcWorkflowDBChecker +from cylc.flow.dbstatecheck import CylcWorkflowDBChecker +from cylc.flow.scheduler import Scheduler @pytest.fixture(scope='module') async def checker( mod_flow, mod_scheduler, mod_run, mod_complete -) -> 'CylcWorkflowDBChecker': +): """Make a real world database. We could just write the database manually but this is a better @@ -53,17 +49,16 @@ async def checker( 'output': {'outputs': {'trigger': 'message'}} } }) - schd = mod_scheduler(wid, paused_start=False) + schd: Scheduler = mod_scheduler(wid, paused_start=False) async with mod_run(schd): await mod_complete(schd) schd.pool.force_trigger_tasks(['1000/good'], [2]) # Allow a cycle of the main loop to pass so that flow 2 can be # added to db await sleep(1) - yield Checker( - 'somestring', 'utterbunkum', - schd.workflow_db_mgr.pub_path - ) + yield CylcWorkflowDBChecker( + 'somestring', 'utterbunkum', schd.workflow_db_mgr.pub_path + ) def test_basic(checker): From 0393a039a8612696f4fca848869d9aa7c43bc7a5 Mon Sep 17 00:00:00 2001 From: Ronnie Dutta <61982285+MetRonnie@users.noreply.github.com> Date: Mon, 17 Jun 2024 12:43:17 +0100 Subject: [PATCH 070/196] Fix integration test teardown error If you don't close DB connections when you're done with them, I will scream --- cylc/flow/dbstatecheck.py | 11 ++++++++++- tests/integration/test_dbstatecheck.py | 7 ++++--- tests/unit/test_db_compat.py | 13 ++++++------- 3 files changed, 20 insertions(+), 11 deletions(-) diff --git a/cylc/flow/dbstatecheck.py b/cylc/flow/dbstatecheck.py index 1fae3e0feb5..b38c394ccdb 100644 --- a/cylc/flow/dbstatecheck.py +++ b/cylc/flow/dbstatecheck.py @@ -72,7 +72,7 @@ def __init__(self, rund, workflow, db_path=None): if not os.path.exists(db_path): raise OSError(errno.ENOENT, os.strerror(errno.ENOENT), db_path) - self.conn = sqlite3.connect(db_path, timeout=10.0) + self.conn: sqlite3.Connection = sqlite3.connect(db_path, timeout=10.0) # Get workflow point format. try: @@ -84,8 +84,17 @@ def __init__(self, rund, workflow, db_path=None): self.db_point_fmt = self._get_db_point_format_compat() self.c7_back_compat_mode = True except sqlite3.OperationalError: + with suppress(Exception): + self.conn.close() raise exc # original error + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_value, traceback): + """Close DB connection when leaving context manager.""" + self.conn.close() + def adjust_point_to_db(self, cycle, offset): """Adjust a cycle point (with offset) to the DB point format. diff --git a/tests/integration/test_dbstatecheck.py b/tests/integration/test_dbstatecheck.py index 33db2ec4c2a..08fd59aa0e4 100644 --- a/tests/integration/test_dbstatecheck.py +++ b/tests/integration/test_dbstatecheck.py @@ -52,13 +52,14 @@ async def checker( schd: Scheduler = mod_scheduler(wid, paused_start=False) async with mod_run(schd): await mod_complete(schd) - schd.pool.force_trigger_tasks(['1000/good'], [2]) + schd.pool.force_trigger_tasks(['1000/good'], ['2']) # Allow a cycle of the main loop to pass so that flow 2 can be # added to db await sleep(1) - yield CylcWorkflowDBChecker( + with CylcWorkflowDBChecker( 'somestring', 'utterbunkum', schd.workflow_db_mgr.pub_path - ) + ) as _checker: + yield _checker def test_basic(checker): diff --git a/tests/unit/test_db_compat.py b/tests/unit/test_db_compat.py index 5393e85e67f..0a04bea0b1f 100644 --- a/tests/unit/test_db_compat.py +++ b/tests/unit/test_db_compat.py @@ -129,14 +129,13 @@ def test_cylc_7_db_wflow_params_table(_setup_db): rf'("cycle_point_format", "{ptformat}")' ) db_file_name = _setup_db([create, insert]) - checker = CylcWorkflowDBChecker('foo', 'bar', db_path=db_file_name) + with CylcWorkflowDBChecker('foo', 'bar', db_path=db_file_name) as checker: + with pytest.raises( + sqlite3.OperationalError, match="no such table: workflow_params" + ): + checker._get_db_point_format() - with pytest.raises( - sqlite3.OperationalError, match="no such table: workflow_params" - ): - checker._get_db_point_format() - - assert checker.db_point_fmt == ptformat + assert checker.db_point_fmt == ptformat def test_pre_830_task_action_timers(_setup_db): From 2e562dfa987b8b81b3e99d924527b86a1872bdfe Mon Sep 17 00:00:00 2001 From: Oliver Sanders Date: Fri, 14 Jun 2024 16:24:13 +0100 Subject: [PATCH 071/196] tests/f: fix reload/24 * This is testing remote-fileinstallation but the test was configured to run on hosts with shared filesystems where remote-fileinstallation is not required --- tests/functional/reload/24-reload-file-install.t | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/functional/reload/24-reload-file-install.t b/tests/functional/reload/24-reload-file-install.t index 256390ca8fc..3b44a5e3531 100644 --- a/tests/functional/reload/24-reload-file-install.t +++ b/tests/functional/reload/24-reload-file-install.t @@ -17,7 +17,7 @@ #------------------------------------------------------------------------------- # Test reload triggers a fresh file install -export REQUIRE_PLATFORM='loc:remote comms:?(tcp|ssh)' +export REQUIRE_PLATFORM='loc:remote fs:indep comms:?(tcp|ssh)' . "$(dirname "$0")/test_header" set_test_number 4 create_test_global_config "" " From b7bf9bb9ff8a4ff80b793baef174152467a66a98 Mon Sep 17 00:00:00 2001 From: Oliver Sanders Date: Mon, 17 Jun 2024 13:20:35 +0100 Subject: [PATCH 072/196] tests/f: fix cylc-cat-log/01 --- tests/functional/cylc-cat-log/01-remote.t | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/functional/cylc-cat-log/01-remote.t b/tests/functional/cylc-cat-log/01-remote.t index 1e61c6ca2c9..7d4971eb925 100755 --- a/tests/functional/cylc-cat-log/01-remote.t +++ b/tests/functional/cylc-cat-log/01-remote.t @@ -58,7 +58,7 @@ grep_ok "jumped over the lazy dog" "${TEST_NAME}.out" # remote TEST_NAME=${TEST_NAME_BASE}-task-status cylc cat-log -f s "${WORKFLOW_NAME}//1/a-task" >"${TEST_NAME}.out" -grep_ok "CYLC_JOB_RUNNER_NAME=at" "${TEST_NAME}.out" +grep_ok "CYLC_JOB_RUNNER_NAME=$CYLC_TEST_JOB_RUNNER" "${TEST_NAME}.out" #------------------------------------------------------------------------------- # local TEST_NAME=${TEST_NAME_BASE}-task-activity From 884e3642ee13675b1d25749ca7c851fd68a33e4f Mon Sep 17 00:00:00 2001 From: Oliver Sanders Date: Mon, 17 Jun 2024 13:28:49 +0100 Subject: [PATCH 073/196] tests/f: fix events/11 * Testing job log retrieval which requires a non-shared filesystem --- tests/functional/events/11-cycle-task-event-job-logs-retrieve.t | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/functional/events/11-cycle-task-event-job-logs-retrieve.t b/tests/functional/events/11-cycle-task-event-job-logs-retrieve.t index 6301629e6b8..47d4a189c4d 100755 --- a/tests/functional/events/11-cycle-task-event-job-logs-retrieve.t +++ b/tests/functional/events/11-cycle-task-event-job-logs-retrieve.t @@ -17,7 +17,7 @@ #------------------------------------------------------------------------------- # Test remote job logs retrieval, requires compatible version of cylc on remote # job host. -export REQUIRE_PLATFORM='loc:remote' +export REQUIRE_PLATFORM='loc:remote fs:indep' . "$(dirname "$0")/test_header" set_test_number 3 From 3962f3d4df171f7ffde4f233e8859abedb3ad451 Mon Sep 17 00:00:00 2001 From: Oliver Sanders Date: Tue, 18 Jun 2024 10:33:10 +0100 Subject: [PATCH 074/196] xtriggers: doc change (#6152) --- cylc/flow/xtriggers/workflow_state.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/cylc/flow/xtriggers/workflow_state.py b/cylc/flow/xtriggers/workflow_state.py index 86a44a49608..e6025a561f8 100644 --- a/cylc/flow/xtriggers/workflow_state.py +++ b/cylc/flow/xtriggers/workflow_state.py @@ -59,8 +59,17 @@ def workflow_state( satisfied: True if ``satisfied`` else ``False``. result: - Dict of workflow, task, point, offset, - status, message, trigger, flow_num, run_dir + Dict containing the keys: + + * ``workflow`` + * ``task`` + * ``point`` + * ``offset`` + * ``status`` + * ``message`` + * ``trigger`` + * ``flow_num`` + * ``run_dir`` .. versionchanged:: 8.3.0 From 37ca2bf310851b79ccced74c97aadd7b0287c1eb Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 18 Jun 2024 11:51:03 +0000 Subject: [PATCH 075/196] Prepare release 8.3.0 Workflow: Release stage 1 - create release PR (Cylc 8+ only), run: 37 --- CHANGES.md | 80 +++++++++++++++++++++++++++++++++++++++++ changes.d/5571.feat.md | 1 - changes.d/5600.break.md | 3 -- changes.d/5658.feat.md | 1 - changes.d/5709.feat.md | 1 - changes.d/5721.feat.md | 1 - changes.d/5727.break.md | 1 - changes.d/5731.feat.md | 1 - changes.d/5738.feat.md | 1 - changes.d/5769.feat.md | 1 - changes.d/5794.break.md | 1 - changes.d/5803.feat.md | 1 - changes.d/5809.feat.d | 1 - changes.d/5809.fix.d | 2 -- changes.d/5831.feat.md | 1 - changes.d/5836.break.md | 1 - changes.d/5864.feat.md | 1 - changes.d/5872.feat.md | 1 - changes.d/5873.feat.md | 3 -- changes.d/5879.feat.md | 1 - changes.d/5890.feat.md | 2 -- changes.d/5943.feat.md | 1 - changes.d/5955.feat.md | 1 - changes.d/5956.break.md | 1 - changes.d/6008.fix.md | 1 - changes.d/6029.feat.md | 1 - changes.d/6036.fix.md | 1 - changes.d/6046.break.md | 4 --- changes.d/6046.feat.md | 4 --- changes.d/6067.fix.md | 1 - changes.d/6123.fix.md | 1 - cylc/flow/__init__.py | 2 +- 32 files changed, 81 insertions(+), 43 deletions(-) delete mode 100644 changes.d/5571.feat.md delete mode 100644 changes.d/5600.break.md delete mode 100644 changes.d/5658.feat.md delete mode 100644 changes.d/5709.feat.md delete mode 100644 changes.d/5721.feat.md delete mode 100644 changes.d/5727.break.md delete mode 100644 changes.d/5731.feat.md delete mode 100644 changes.d/5738.feat.md delete mode 100644 changes.d/5769.feat.md delete mode 100644 changes.d/5794.break.md delete mode 100644 changes.d/5803.feat.md delete mode 100644 changes.d/5809.feat.d delete mode 100644 changes.d/5809.fix.d delete mode 100644 changes.d/5831.feat.md delete mode 100644 changes.d/5836.break.md delete mode 100644 changes.d/5864.feat.md delete mode 100644 changes.d/5872.feat.md delete mode 100644 changes.d/5873.feat.md delete mode 100644 changes.d/5879.feat.md delete mode 100644 changes.d/5890.feat.md delete mode 100644 changes.d/5943.feat.md delete mode 100644 changes.d/5955.feat.md delete mode 100644 changes.d/5956.break.md delete mode 100644 changes.d/6008.fix.md delete mode 100644 changes.d/6029.feat.md delete mode 100644 changes.d/6036.fix.md delete mode 100644 changes.d/6046.break.md delete mode 100644 changes.d/6046.feat.md delete mode 100644 changes.d/6067.fix.md delete mode 100644 changes.d/6123.fix.md diff --git a/CHANGES.md b/CHANGES.md index 36280bda750..aa2e43827b6 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -11,6 +11,86 @@ $ towncrier create ..md --content "Short description" +## __cylc-8.3.0 (Released 2024-06-18)__ + +### ⚠ Breaking Changes + +[#5600](https://github.com/cylc/cylc-flow/pull/5600) - The `cylc dump` command now only shows active tasks (e.g. running & queued + tasks). This restores its behaviour of only showing the tasks which currently + exist in the pool as it did in Cylc 7 and earlier versions of Cylc 8. + +[#5727](https://github.com/cylc/cylc-flow/pull/5727) - Cylc now ignores `PYTHONPATH` to make it more robust to task environments which set this value. If you want to add to the Cylc environment itself, e.g. to install a Cylc extension, use `CYLC_PYTHONPATH`. + +[#5794](https://github.com/cylc/cylc-flow/pull/5794) - Remove `cylc report-timings` from automatic installation with `pip install cylc-flow[all]`. If you now wish to install it use `pip install cylc-flow[report-timings]`. `cylc report-timings` is incompatible with Python 3.12. + +[#5836](https://github.com/cylc/cylc-flow/pull/5836) - Removed the 'CYLC_TASK_DEPENDENCIES' environment variable + +[#5956](https://github.com/cylc/cylc-flow/pull/5956) - `cylc lint`: deprecated `[cylc-lint]` section in favour of `[tool.cylc.lint]` in `pyproject.toml` + +[#6046](https://github.com/cylc/cylc-flow/pull/6046) - The `submit-fail` and `expire` task outputs must now be + [optional](https://cylc.github.io/cylc-doc/stable/html/glossary.html#term-optional-output) + and can no longer be + [required](https://cylc.github.io/cylc-doc/stable/html/glossary.html#term-required-output). + +### 🚀 Enhancements + +[#5571](https://github.com/cylc/cylc-flow/pull/5571) - Make workflow `CYLC_` variables available to the template processor during parsing. + +[#5658](https://github.com/cylc/cylc-flow/pull/5658) - New "cylc set" command for setting task prerequisites and outputs. + +[#5709](https://github.com/cylc/cylc-flow/pull/5709) - Forward arbitrary environment variables over SSH connections + +[#5721](https://github.com/cylc/cylc-flow/pull/5721) - Allow task simulation mode settings to be changed dynamically using `cylc broadcast`. + +[#5731](https://github.com/cylc/cylc-flow/pull/5731) - Major upgrade to `cylc tui` which now supports larger workflows and can browse installed workflows. + +[#5738](https://github.com/cylc/cylc-flow/pull/5738) - Optionally spawn parentless xtriggered tasks sequentially - i.e., one at a time, after the previous xtrigger is satisfied, instead of all at once out to the runahead limit. The `wall_clock` xtrigger is now sequential by default. + +[#5769](https://github.com/cylc/cylc-flow/pull/5769) - Include task messages and workflow port as appropriate in emails configured by "mail events". + +[#5803](https://github.com/cylc/cylc-flow/pull/5803) - Updated 'reinstall' functionality to support multiple workflows + +[#5809](https://github.com/cylc/cylc-flow/pull/5809) - The workflow-state command and xtrigger are now flow-aware and take universal IDs instead of separate arguments for cycle point, task name, etc. (which are still supported, but deprecated). + +[#5831](https://github.com/cylc/cylc-flow/pull/5831) - Add capability to install xtriggers via a new cylc.xtriggers entry point + +[#5864](https://github.com/cylc/cylc-flow/pull/5864) - Reimplemented the `suite-state` xtrigger for interoperability with Cylc 7. + +[#5872](https://github.com/cylc/cylc-flow/pull/5872) - Improvements to `cylc clean` remote timeout handling. + +[#5873](https://github.com/cylc/cylc-flow/pull/5873) - `cylc lint` improvements: + - Allow use of `#noqa: S001` comments to skip checks for a single line. + - Stop `cylc lint` objecting to `%include ` syntax. + +[#5879](https://github.com/cylc/cylc-flow/pull/5879) - `cylc lint` now warns of use of old templated items such as `%(suite)s` + +[#5890](https://github.com/cylc/cylc-flow/pull/5890) - Lint: Warn users that setting ``CYLC_VERSION``, ``ROSE_VERSION`` or + ``FCM_VERSION`` in the workflow config is deprecated. + +[#5943](https://github.com/cylc/cylc-flow/pull/5943) - The `stop after cycle point` can now be specified as an offset from the inital cycle point. + +[#5955](https://github.com/cylc/cylc-flow/pull/5955) - Support xtrigger argument validation. + +[#6029](https://github.com/cylc/cylc-flow/pull/6029) - Workflow graph window extent is now preserved on reload. + +[#6046](https://github.com/cylc/cylc-flow/pull/6046) - The condition that Cylc uses to evaluate task output completion can now be + customized in the `[runtime]` section with the new `completion` configuration. + This provides a more advanced way to check that tasks generate their required + outputs when run. + +### 🔧 Fixes + +[#5809](https://github.com/cylc/cylc-flow/pull/5809) - Fix bug where the "cylc workflow-state" command only polled for + task-specific status queries and custom outputs. + +[#6008](https://github.com/cylc/cylc-flow/pull/6008) - Fixed bug where the `[scheduler][mail]to/from` settings did not apply as defaults for task event mail. + +[#6036](https://github.com/cylc/cylc-flow/pull/6036) - Fixed bug in simulation mode where repeated submissions were not displaying correctly in TUI/GUI. + +[#6067](https://github.com/cylc/cylc-flow/pull/6067) - Fixed a bug that sometimes allowed suicide-triggered or manually removed tasks to be added back later. + +[#6123](https://github.com/cylc/cylc-flow/pull/6123) - Allow long-format datetime cycle points in IDs used on the command line. + ## __cylc-8.2.7 (Released 2024-05-15)__ ### 🔧 Fixes diff --git a/changes.d/5571.feat.md b/changes.d/5571.feat.md deleted file mode 100644 index 4bda6c6af4b..00000000000 --- a/changes.d/5571.feat.md +++ /dev/null @@ -1 +0,0 @@ -Make workflow `CYLC_` variables available to the template processor during parsing. diff --git a/changes.d/5600.break.md b/changes.d/5600.break.md deleted file mode 100644 index fd11f650ff5..00000000000 --- a/changes.d/5600.break.md +++ /dev/null @@ -1,3 +0,0 @@ -The `cylc dump` command now only shows active tasks (e.g. running & queued -tasks). This restores its behaviour of only showing the tasks which currently -exist in the pool as it did in Cylc 7 and earlier versions of Cylc 8. diff --git a/changes.d/5658.feat.md b/changes.d/5658.feat.md deleted file mode 100644 index ed33a09b529..00000000000 --- a/changes.d/5658.feat.md +++ /dev/null @@ -1 +0,0 @@ -New "cylc set" command for setting task prerequisites and outputs. diff --git a/changes.d/5709.feat.md b/changes.d/5709.feat.md deleted file mode 100644 index 11aeabcf81d..00000000000 --- a/changes.d/5709.feat.md +++ /dev/null @@ -1 +0,0 @@ -Forward arbitrary environment variables over SSH connections diff --git a/changes.d/5721.feat.md b/changes.d/5721.feat.md deleted file mode 100644 index bdeb27c7844..00000000000 --- a/changes.d/5721.feat.md +++ /dev/null @@ -1 +0,0 @@ -Allow task simulation mode settings to be changed dynamically using `cylc broadcast`. \ No newline at end of file diff --git a/changes.d/5727.break.md b/changes.d/5727.break.md deleted file mode 100644 index 06cb196216d..00000000000 --- a/changes.d/5727.break.md +++ /dev/null @@ -1 +0,0 @@ -Cylc now ignores `PYTHONPATH` to make it more robust to task environments which set this value. If you want to add to the Cylc environment itself, e.g. to install a Cylc extension, use `CYLC_PYTHONPATH`. \ No newline at end of file diff --git a/changes.d/5731.feat.md b/changes.d/5731.feat.md deleted file mode 100644 index b0c28a01ac1..00000000000 --- a/changes.d/5731.feat.md +++ /dev/null @@ -1 +0,0 @@ -Major upgrade to `cylc tui` which now supports larger workflows and can browse installed workflows. diff --git a/changes.d/5738.feat.md b/changes.d/5738.feat.md deleted file mode 100644 index 09ff84bec93..00000000000 --- a/changes.d/5738.feat.md +++ /dev/null @@ -1 +0,0 @@ -Optionally spawn parentless xtriggered tasks sequentially - i.e., one at a time, after the previous xtrigger is satisfied, instead of all at once out to the runahead limit. The `wall_clock` xtrigger is now sequential by default. diff --git a/changes.d/5769.feat.md b/changes.d/5769.feat.md deleted file mode 100644 index e0bda65a22c..00000000000 --- a/changes.d/5769.feat.md +++ /dev/null @@ -1 +0,0 @@ -Include task messages and workflow port as appropriate in emails configured by "mail events". diff --git a/changes.d/5794.break.md b/changes.d/5794.break.md deleted file mode 100644 index 53c5315b013..00000000000 --- a/changes.d/5794.break.md +++ /dev/null @@ -1 +0,0 @@ -Remove `cylc report-timings` from automatic installation with `pip install cylc-flow[all]`. If you now wish to install it use `pip install cylc-flow[report-timings]`. `cylc report-timings` is incompatible with Python 3.12. \ No newline at end of file diff --git a/changes.d/5803.feat.md b/changes.d/5803.feat.md deleted file mode 100644 index a4bc0f1b898..00000000000 --- a/changes.d/5803.feat.md +++ /dev/null @@ -1 +0,0 @@ -Updated 'reinstall' functionality to support multiple workflows \ No newline at end of file diff --git a/changes.d/5809.feat.d b/changes.d/5809.feat.d deleted file mode 100644 index c5e7d0afe5e..00000000000 --- a/changes.d/5809.feat.d +++ /dev/null @@ -1 +0,0 @@ -The workflow-state command and xtrigger are now flow-aware and take universal IDs instead of separate arguments for cycle point, task name, etc. (which are still supported, but deprecated). diff --git a/changes.d/5809.fix.d b/changes.d/5809.fix.d deleted file mode 100644 index 36fc5fbf481..00000000000 --- a/changes.d/5809.fix.d +++ /dev/null @@ -1,2 +0,0 @@ -Fix bug where the "cylc workflow-state" command only polled for -task-specific status queries and custom outputs. diff --git a/changes.d/5831.feat.md b/changes.d/5831.feat.md deleted file mode 100644 index daecc5e7a87..00000000000 --- a/changes.d/5831.feat.md +++ /dev/null @@ -1 +0,0 @@ -Add capability to install xtriggers via a new cylc.xtriggers entry point diff --git a/changes.d/5836.break.md b/changes.d/5836.break.md deleted file mode 100644 index 8c14b101f63..00000000000 --- a/changes.d/5836.break.md +++ /dev/null @@ -1 +0,0 @@ -Removed the 'CYLC_TASK_DEPENDENCIES' environment variable \ No newline at end of file diff --git a/changes.d/5864.feat.md b/changes.d/5864.feat.md deleted file mode 100644 index 905b6b9dadd..00000000000 --- a/changes.d/5864.feat.md +++ /dev/null @@ -1 +0,0 @@ -Reimplemented the `suite-state` xtrigger for interoperability with Cylc 7. diff --git a/changes.d/5872.feat.md b/changes.d/5872.feat.md deleted file mode 100644 index d88b0dd8116..00000000000 --- a/changes.d/5872.feat.md +++ /dev/null @@ -1 +0,0 @@ -Improvements to `cylc clean` remote timeout handling. diff --git a/changes.d/5873.feat.md b/changes.d/5873.feat.md deleted file mode 100644 index 1f21646918e..00000000000 --- a/changes.d/5873.feat.md +++ /dev/null @@ -1,3 +0,0 @@ -`cylc lint` improvements: -- Allow use of `#noqa: S001` comments to skip checks for a single line. -- Stop `cylc lint` objecting to `%include ` syntax. diff --git a/changes.d/5879.feat.md b/changes.d/5879.feat.md deleted file mode 100644 index be4c7e14e94..00000000000 --- a/changes.d/5879.feat.md +++ /dev/null @@ -1 +0,0 @@ -`cylc lint` now warns of use of old templated items such as `%(suite)s` diff --git a/changes.d/5890.feat.md b/changes.d/5890.feat.md deleted file mode 100644 index 5e6fce66c05..00000000000 --- a/changes.d/5890.feat.md +++ /dev/null @@ -1,2 +0,0 @@ -Lint: Warn users that setting ``CYLC_VERSION``, ``ROSE_VERSION`` or -``FCM_VERSION`` in the workflow config is deprecated. \ No newline at end of file diff --git a/changes.d/5943.feat.md b/changes.d/5943.feat.md deleted file mode 100644 index 6db31d952be..00000000000 --- a/changes.d/5943.feat.md +++ /dev/null @@ -1 +0,0 @@ -The `stop after cycle point` can now be specified as an offset from the inital cycle point. diff --git a/changes.d/5955.feat.md b/changes.d/5955.feat.md deleted file mode 100644 index c9fc9b4d879..00000000000 --- a/changes.d/5955.feat.md +++ /dev/null @@ -1 +0,0 @@ -Support xtrigger argument validation. diff --git a/changes.d/5956.break.md b/changes.d/5956.break.md deleted file mode 100644 index 642c805814d..00000000000 --- a/changes.d/5956.break.md +++ /dev/null @@ -1 +0,0 @@ -`cylc lint`: deprecated `[cylc-lint]` section in favour of `[tool.cylc.lint]` in `pyproject.toml` diff --git a/changes.d/6008.fix.md b/changes.d/6008.fix.md deleted file mode 100644 index 7741792de27..00000000000 --- a/changes.d/6008.fix.md +++ /dev/null @@ -1 +0,0 @@ -Fixed bug where the `[scheduler][mail]to/from` settings did not apply as defaults for task event mail. diff --git a/changes.d/6029.feat.md b/changes.d/6029.feat.md deleted file mode 100644 index 140424f6d03..00000000000 --- a/changes.d/6029.feat.md +++ /dev/null @@ -1 +0,0 @@ -Workflow graph window extent is now preserved on reload. diff --git a/changes.d/6036.fix.md b/changes.d/6036.fix.md deleted file mode 100644 index 6609a349505..00000000000 --- a/changes.d/6036.fix.md +++ /dev/null @@ -1 +0,0 @@ -Fixed bug in simulation mode where repeated submissions were not displaying correctly in TUI/GUI. \ No newline at end of file diff --git a/changes.d/6046.break.md b/changes.d/6046.break.md deleted file mode 100644 index 06503d9c987..00000000000 --- a/changes.d/6046.break.md +++ /dev/null @@ -1,4 +0,0 @@ -The `submit-fail` and `expire` task outputs must now be -[optional](https://cylc.github.io/cylc-doc/stable/html/glossary.html#term-optional-output) -and can no longer be -[required](https://cylc.github.io/cylc-doc/stable/html/glossary.html#term-required-output). diff --git a/changes.d/6046.feat.md b/changes.d/6046.feat.md deleted file mode 100644 index fb731b872dd..00000000000 --- a/changes.d/6046.feat.md +++ /dev/null @@ -1,4 +0,0 @@ -The condition that Cylc uses to evaluate task output completion can now be -customized in the `[runtime]` section with the new `completion` configuration. -This provides a more advanced way to check that tasks generate their required -outputs when run. diff --git a/changes.d/6067.fix.md b/changes.d/6067.fix.md deleted file mode 100644 index bea01066dae..00000000000 --- a/changes.d/6067.fix.md +++ /dev/null @@ -1 +0,0 @@ -Fixed a bug that sometimes allowed suicide-triggered or manually removed tasks to be added back later. diff --git a/changes.d/6123.fix.md b/changes.d/6123.fix.md deleted file mode 100644 index cef4f24a709..00000000000 --- a/changes.d/6123.fix.md +++ /dev/null @@ -1 +0,0 @@ -Allow long-format datetime cycle points in IDs used on the command line. \ No newline at end of file diff --git a/cylc/flow/__init__.py b/cylc/flow/__init__.py index d4192f72766..5a68da9bfed 100644 --- a/cylc/flow/__init__.py +++ b/cylc/flow/__init__.py @@ -53,7 +53,7 @@ def environ_init(): environ_init() -__version__ = '8.3.0.dev' +__version__ = '8.3.0' def iter_entry_points(entry_point_name): From 2732f539d2a3b013cd8c78e50b7273ed6a74a989 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 18 Jun 2024 14:22:15 +0100 Subject: [PATCH 076/196] Bump dev version (#6155) Workflow: Release stage 2 - auto publish, run: 77 Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- cylc/flow/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cylc/flow/__init__.py b/cylc/flow/__init__.py index 5a68da9bfed..40718ebdf7e 100644 --- a/cylc/flow/__init__.py +++ b/cylc/flow/__init__.py @@ -53,7 +53,7 @@ def environ_init(): environ_init() -__version__ = '8.3.0' +__version__ = '8.3.1.dev' def iter_entry_points(entry_point_name): From 7bdd9e41b91ccb476491df5ff39ea737cc9fd75c Mon Sep 17 00:00:00 2001 From: Tim Pillinger <26465611+wxtim@users.noreply.github.com> Date: Wed, 19 Jun 2024 10:23:06 +0100 Subject: [PATCH 077/196] Improve error logging for contact file checks (#6156) --- cylc/flow/workflow_files.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/cylc/flow/workflow_files.py b/cylc/flow/workflow_files.py index 44ae0d7742d..023d481324c 100644 --- a/cylc/flow/workflow_files.py +++ b/cylc/flow/workflow_files.py @@ -424,7 +424,8 @@ def _is_process_running( out, err = proc.communicate(timeout=10, input=metric) except TimeoutExpired: raise CylcError( - f'Cannot determine whether workflow is running on {host}.' + f'Attempt to determine whether workflow is running on {host}' + ' timed out after 10 seconds.' ) if proc.returncode == 2: @@ -435,7 +436,7 @@ def _is_process_running( error = False if proc.returncode: # the psutil call failed in some other way e.g. network issues - LOG.debug( + LOG.warning( f'$ {cli_format(cmd)} # returned {proc.returncode}\n{err}' ) error = True From 61c1f28472a0f9f6d0ec81fef127105218e4241c Mon Sep 17 00:00:00 2001 From: Oliver Sanders Date: Wed, 19 Jun 2024 15:56:58 +0100 Subject: [PATCH 078/196] setup: fix colorama upper pin (#6158) [skip ci] --- setup.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index 0ebc12bb795..be1e9383431 100644 --- a/setup.cfg +++ b/setup.cfg @@ -64,7 +64,7 @@ python_requires = >=3.7 install_requires = ansimarkup>=1.0.0 async-timeout>=3.0.0 - colorama>=0.4,<=1 + colorama>=0.4,<1 graphene>=2.1,<3 # Note: can't pin jinja2 any higher than this until we give up on Cylc 7 back-compat jinja2==3.0.* From 5b215901e336f4f8b64738fca3402e4548eef035 Mon Sep 17 00:00:00 2001 From: Ronnie Dutta <61982285+MetRonnie@users.noreply.github.com> Date: Wed, 19 Jun 2024 18:27:19 +0100 Subject: [PATCH 079/196] `cylc version --long`: ensure correct path is printed for `cylc` executable --- cylc/flow/scripts/cylc.py | 2 +- tests/functional/cli/01-help.t | 7 +------ 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/cylc/flow/scripts/cylc.py b/cylc/flow/scripts/cylc.py index cabcec553e9..4ca411f8e9b 100644 --- a/cylc/flow/scripts/cylc.py +++ b/cylc/flow/scripts/cylc.py @@ -88,7 +88,7 @@ def get_version(long=False): from pathlib import Path version = f"{__version__}" if long: - version += f" ({Path(sys.executable).parent.parent})" + version += f" ({Path(sys.argv[0])})" return version diff --git a/tests/functional/cli/01-help.t b/tests/functional/cli/01-help.t index 935864e1873..b8f1bd38583 100755 --- a/tests/functional/cli/01-help.t +++ b/tests/functional/cli/01-help.t @@ -79,12 +79,7 @@ run_ok "${TEST_NAME_BASE}-id" cylc help id # Check "cylc version --long" output is correct. cylc version --long | head -n 1 > long1 -WHICH="$(command -v cylc)" -PARENT1="$(dirname "${WHICH}")" -PARENT2="$(dirname "${PARENT1}")" -echo "$(cylc version) (${PARENT2})" > long2 -# the concise version of the above is a bash quoting nightmare: -# echo "$(cylc version) ($(dirname $(dirname $(which cylc))))" > long2 +echo "$(cylc version) ($(command -v cylc))" > long2 cmp_ok long1 long2 # --help with no DISPLAY From 62527e0871cc3fc36653e55f01160e1b1d75bc61 Mon Sep 17 00:00:00 2001 From: Ronnie Dutta <61982285+MetRonnie@users.noreply.github.com> Date: Thu, 20 Jun 2024 12:54:23 +0100 Subject: [PATCH 080/196] Xtrigger validation: log traceback for unexpected user validation errors (#6160) --- cylc/flow/xtrigger_mgr.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/cylc/flow/xtrigger_mgr.py b/cylc/flow/xtrigger_mgr.py index aba2d5cabde..4f33ec7f3cc 100644 --- a/cylc/flow/xtrigger_mgr.py +++ b/cylc/flow/xtrigger_mgr.py @@ -31,7 +31,7 @@ ) from cylc.flow import LOG -from cylc.flow.exceptions import XtriggerConfigError +from cylc.flow.exceptions import WorkflowConfigError, XtriggerConfigError import cylc.flow.flags from cylc.flow.hostuserutil import get_user from cylc.flow.subprocctx import add_kwarg_to_sig @@ -362,6 +362,8 @@ def _try_xtrig_validate_func( try: xtrig_validate_func(bound_args.arguments) except Exception as exc: # Note: catch all errors + if not isinstance(exc, WorkflowConfigError): + LOG.exception(exc) raise XtriggerConfigError(label, signature_str, exc) # BACK COMPAT: workflow_state_backcompat From d715aff2a9a3f6a9b3379e20f0de6d8c74180fe1 Mon Sep 17 00:00:00 2001 From: Tim Pillinger <26465611+wxtim@users.noreply.github.com> Date: Mon, 24 Jun 2024 13:56:51 +0100 Subject: [PATCH 081/196] Prevent commands which take Tasks IDs taking Job IDs. (#6130) --- changes.d/6130.fix.md | 1 + cylc/flow/command_validation.py | 35 ++++++++++++++++++++++++++++++++- cylc/flow/commands.py | 9 +++++++++ 3 files changed, 44 insertions(+), 1 deletion(-) create mode 100644 changes.d/6130.fix.md diff --git a/changes.d/6130.fix.md b/changes.d/6130.fix.md new file mode 100644 index 00000000000..8423bfdcd40 --- /dev/null +++ b/changes.d/6130.fix.md @@ -0,0 +1 @@ +Prevent commands accepting job IDs where it doesn't make sense. \ No newline at end of file diff --git a/cylc/flow/command_validation.py b/cylc/flow/command_validation.py index c7a9b2762dc..1c57d452c43 100644 --- a/cylc/flow/command_validation.py +++ b/cylc/flow/command_validation.py @@ -18,12 +18,13 @@ from typing import ( + Iterable, List, Optional, ) from cylc.flow.exceptions import InputError -from cylc.flow.id import Tokens +from cylc.flow.id import IDTokens, Tokens from cylc.flow.task_outputs import TASK_OUTPUT_SUCCEEDED from cylc.flow.flow_mgr import FLOW_ALL, FLOW_NEW, FLOW_NONE @@ -228,3 +229,35 @@ def consistency( """ if outputs and prereqs: raise InputError("Use --prerequisite or --output, not both.") + + +def is_tasks(tasks: Iterable[str]): + """All tasks in a list of tasks are task ID's without trailing job ID. + + Examples: + # All legal + >>> is_tasks(['1/foo', '1/bar', '*/baz', '*/*']) + + # Some legal + >>> is_tasks(['1/foo/NN', '1/bar', '*/baz', '*/*/42']) + Traceback (most recent call last): + ... + cylc.flow.exceptions.InputError: This command does not take job ids: + * 1/foo/NN + * */*/42 + + # None legal + >>> is_tasks(['*/baz/12']) + Traceback (most recent call last): + ... + cylc.flow.exceptions.InputError: This command does not take job ids: + * */baz/12 + """ + bad_tasks: List[str] = [] + for task in tasks: + tokens = Tokens('//' + task) + if tokens.lowest_token == IDTokens.Job.value: + bad_tasks.append(task) + if bad_tasks: + msg = 'This command does not take job ids:\n * ' + raise InputError(msg + '\n * '.join(bad_tasks)) diff --git a/cylc/flow/commands.py b/cylc/flow/commands.py index 28de0d16ed5..a4ea43df5cf 100644 --- a/cylc/flow/commands.py +++ b/cylc/flow/commands.py @@ -145,6 +145,7 @@ async def set_prereqs_and_outputs( outputs = validate.outputs(outputs) prerequisites = validate.prereqs(prerequisites) validate.flow_opts(flow, flow_wait) + validate.is_tasks(tasks) yield @@ -172,6 +173,8 @@ async def stop( task: Optional[str] = None, flow_num: Optional[int] = None, ): + if task: + validate.is_tasks([task]) yield if flow_num: schd.pool.stop_flow(flow_num) @@ -214,6 +217,7 @@ async def stop( @_command('release') async def release(schd: 'Scheduler', tasks: Iterable[str]): """Release held tasks.""" + validate.is_tasks(tasks) yield yield schd.pool.release_held_tasks(tasks) @@ -237,6 +241,7 @@ async def resume(schd: 'Scheduler'): @_command('poll_tasks') async def poll_tasks(schd: 'Scheduler', tasks: Iterable[str]): """Poll pollable tasks or a task or family if options are provided.""" + validate.is_tasks(tasks) yield if schd.get_run_mode() == RunMode.SIMULATION: yield 0 @@ -248,6 +253,7 @@ async def poll_tasks(schd: 'Scheduler', tasks: Iterable[str]): @_command('kill_tasks') async def kill_tasks(schd: 'Scheduler', tasks: Iterable[str]): """Kill all tasks or a task/family if options are provided.""" + validate.is_tasks(tasks) yield itasks, _, bad_items = schd.pool.filter_task_proxies(tasks) if schd.get_run_mode() == RunMode.SIMULATION: @@ -264,6 +270,7 @@ async def kill_tasks(schd: 'Scheduler', tasks: Iterable[str]): @_command('hold') async def hold(schd: 'Scheduler', tasks: Iterable[str]): """Hold specified tasks.""" + validate.is_tasks(tasks) yield yield schd.pool.hold_tasks(tasks) @@ -304,6 +311,7 @@ async def set_verbosity(schd: 'Scheduler', level: Union[int, str]): @_command('remove_tasks') async def remove_tasks(schd: 'Scheduler', tasks: Iterable[str]): """Remove tasks.""" + validate.is_tasks(tasks) yield yield schd.pool.remove_tasks(tasks) @@ -430,5 +438,6 @@ async def force_trigger_tasks( flow_descr: Optional[str] = None, ): """Manual task trigger.""" + validate.is_tasks(tasks) yield yield schd.pool.force_trigger_tasks(tasks, flow, flow_wait, flow_descr) From 35c1ce01036288cca4ffbb0d39a00ff55cd3db26 Mon Sep 17 00:00:00 2001 From: Oliver Sanders Date: Tue, 14 Mar 2023 13:38:55 +0000 Subject: [PATCH 082/196] hostuserutil: permit arpa address * The arpa address returned by `socket.getfqdn` on Mac OS is different with Python 3.9 than 3.7 (conda-forge). --- cylc/flow/hostuserutil.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/cylc/flow/hostuserutil.py b/cylc/flow/hostuserutil.py index 34e033b2d7b..8eadbdb574f 100644 --- a/cylc/flow/hostuserutil.py +++ b/cylc/flow/hostuserutil.py @@ -120,10 +120,11 @@ def _get_host_info(self, target=None): target = socket.getfqdn() if ( IS_MAC_OS - and target == ( + and target in { '1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.' - '0.0.0.0.0.0.ip6.arpa' - ) + '0.0.0.0.0.0.ip6.arpa', + '1.0.0.127.in-addr.arpa' + } ): # Python's socket bindings don't play nicely with mac os # so by default we get the above ip6.arpa address from From 2b6a70592e6f31b4f4c4f1738761074bd1b3f8c7 Mon Sep 17 00:00:00 2001 From: Ronnie Dutta <61982285+MetRonnie@users.noreply.github.com> Date: Thu, 27 Jun 2024 17:43:51 +0100 Subject: [PATCH 083/196] Tests: fix `contains_ok` UTF-8 issue See https://stackoverflow.com/q/78678882/3217306 --- tests/functional/lib/bash/test_header | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/functional/lib/bash/test_header b/tests/functional/lib/bash/test_header index 915821c0889..cc0f6e0af43 100644 --- a/tests/functional/lib/bash/test_header +++ b/tests/functional/lib/bash/test_header @@ -386,7 +386,7 @@ contains_ok() { local FILE_CONTROL="${2:--}" local TEST_NAME TEST_NAME="$(basename "${FILE_TEST}")-contains-ok" - comm -13 <(sort "${FILE_TEST}") <(sort "${FILE_CONTROL}") \ + LANG=C comm -13 <(sort "${FILE_TEST}") <(sort "${FILE_CONTROL}") \ 1>"${TEST_NAME}.stdout" 2>"${TEST_NAME}.stderr" if [[ -s "${TEST_NAME}.stdout" ]]; then mkdir -p "${TEST_LOG_DIR}" From 747e2af67064854cc1406c311cf85c3eb4d976a4 Mon Sep 17 00:00:00 2001 From: Oliver Sanders Date: Thu, 27 Jun 2024 17:48:18 +0100 Subject: [PATCH 084/196] play: print the logo to the user's terminal only (#6170) --- changes.d/fix.6170.md | 1 + cylc/flow/scheduler_cli.py | 12 ++++++++---- 2 files changed, 9 insertions(+), 4 deletions(-) create mode 100644 changes.d/fix.6170.md diff --git a/changes.d/fix.6170.md b/changes.d/fix.6170.md new file mode 100644 index 00000000000..1675c7dfe7c --- /dev/null +++ b/changes.d/fix.6170.md @@ -0,0 +1 @@ +Fix an issue where the Cylc logo could appear in the workflow log. diff --git a/cylc/flow/scheduler_cli.py b/cylc/flow/scheduler_cli.py index d25be809325..c014697160a 100644 --- a/cylc/flow/scheduler_cli.py +++ b/cylc/flow/scheduler_cli.py @@ -400,12 +400,12 @@ async def scheduler_cli( # upgrade the workflow DB (after user has confirmed upgrade) _upgrade_database(db_file) - # re-execute on another host if required - _distribute(options.host, workflow_id_raw, workflow_id, options.color) - # print the start message _print_startup_message(options) + # re-execute on another host if required + _distribute(options.host, workflow_id_raw, workflow_id, options.color) + # setup the scheduler # NOTE: asyncio.run opens an event loop, runs your coro, # then shutdown async generators and closes the event loop @@ -561,10 +561,14 @@ def _upgrade_database(db_file: Path) -> None: def _print_startup_message(options): - """Print the Cylc header including the CLI logo.""" + """Print the Cylc header including the CLI logo to the user's terminal.""" if ( cylc.flow.flags.verbosity > -1 and (options.no_detach or options.format == 'plain') + # don't print the startup message on reinvocation (note + # --host=localhost is the best indication we have that reinvokation has + # happened) + and options.host != 'localhost' ): print( cparse( From 33aeb2c12a2f50141fa28eb6e235af594f740327 Mon Sep 17 00:00:00 2001 From: Oliver Sanders Date: Fri, 28 Jun 2024 09:47:19 +0100 Subject: [PATCH 085/196] tui: prevent hang on shutdown * Add a timeout on the updater shutdown to prevent any hanging operation stopping Tui from exiting. --- changes.d/fix.6178.md | 1 + cylc/flow/tui/app.py | 5 ++++- 2 files changed, 5 insertions(+), 1 deletion(-) create mode 100644 changes.d/fix.6178.md diff --git a/changes.d/fix.6178.md b/changes.d/fix.6178.md new file mode 100644 index 00000000000..7d1b9b0f3f6 --- /dev/null +++ b/changes.d/fix.6178.md @@ -0,0 +1 @@ +Fix an issue where Tui could hang when closing. diff --git a/cylc/flow/tui/app.py b/cylc/flow/tui/app.py index 783f1217cc8..eb9f8dec09c 100644 --- a/cylc/flow/tui/app.py +++ b/cylc/flow/tui/app.py @@ -216,7 +216,10 @@ def updater_subproc(filters, client_timeout): yield updater finally: updater.terminate() - p.join() + p.join(4) # timeout of 4 seconds + if p.exitcode is None: + # updater did not exit within timeout -> kill it + p.terminate() class TuiApp: From 7042afa601f7d1fce2051e5be7dc27e3152f3199 Mon Sep 17 00:00:00 2001 From: Tim Pillinger <26465611+wxtim@users.noreply.github.com> Date: Fri, 28 Jun 2024 12:32:23 +0100 Subject: [PATCH 086/196] Fix mistake in towncrier usage (#6179) --- CHANGES.md | 4 ++++ changes.d/{fix.6170.md => 6170.fix.md} | 0 changes.d/fix.5924.md | 1 - changes.d/fix.6109.md | 1 - 4 files changed, 4 insertions(+), 2 deletions(-) rename changes.d/{fix.6170.md => 6170.fix.md} (100%) delete mode 100644 changes.d/fix.5924.md delete mode 100644 changes.d/fix.6109.md diff --git a/CHANGES.md b/CHANGES.md index aa2e43827b6..43b40c86eed 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -89,6 +89,8 @@ $ towncrier create ..md --content "Short description" [#6067](https://github.com/cylc/cylc-flow/pull/6067) - Fixed a bug that sometimes allowed suicide-triggered or manually removed tasks to be added back later. +[#6109](https://github.com/cylc/cylc-flow/pull/6109) - Fixed bug affecting job submission where the list of bad hosts was not always reset correctly. + [#6123](https://github.com/cylc/cylc-flow/pull/6123) - Allow long-format datetime cycle points in IDs used on the command line. ## __cylc-8.2.7 (Released 2024-05-15)__ @@ -119,6 +121,8 @@ $ towncrier create ..md --content "Short description" ### 🔧 Fixes +[#5924](https://github.com/cylc/cylc-flow/pull/5924) - Validation: a cycle offset can only appear on the right of a dependency if the task's cycling is defined elsewhere with no offset. + [#5933](https://github.com/cylc/cylc-flow/pull/5933) - Fixed bug in `cylc broadcast` (and the GUI Edit Runtime command) where everything after a `#` character in a setting would be stripped out. [#5959](https://github.com/cylc/cylc-flow/pull/5959) - Fix an issue where workflow "timeout" events were not fired in all situations when they should have been. diff --git a/changes.d/fix.6170.md b/changes.d/6170.fix.md similarity index 100% rename from changes.d/fix.6170.md rename to changes.d/6170.fix.md diff --git a/changes.d/fix.5924.md b/changes.d/fix.5924.md deleted file mode 100644 index 7ce2caf7777..00000000000 --- a/changes.d/fix.5924.md +++ /dev/null @@ -1 +0,0 @@ -Validation: a cycle offset can only appear on the right of a dependency if the task's cycling is defined elsewhere with no offset. \ No newline at end of file diff --git a/changes.d/fix.6109.md b/changes.d/fix.6109.md deleted file mode 100644 index 36f22c3d4fc..00000000000 --- a/changes.d/fix.6109.md +++ /dev/null @@ -1 +0,0 @@ -Fixed bug affecting job submission where the list of bad hosts was not always reset correctly. \ No newline at end of file From 74a77ac1c94cf83c08ab95b88518e0670503641c Mon Sep 17 00:00:00 2001 From: Ronnie Dutta <61982285+MetRonnie@users.noreply.github.com> Date: Wed, 19 Jun 2024 13:25:02 +0100 Subject: [PATCH 087/196] `cylc stop --now`: fix flaky test & clarify docs --- cylc/flow/scripts/stop.py | 6 +++--- tests/functional/shutdown/08-now1.t | 6 +----- tests/functional/shutdown/08-now1/flow.cylc | 3 +-- 3 files changed, 5 insertions(+), 10 deletions(-) diff --git a/cylc/flow/scripts/stop.py b/cylc/flow/scripts/stop.py index 543bb257dea..d49a47aea1e 100755 --- a/cylc/flow/scripts/stop.py +++ b/cylc/flow/scripts/stop.py @@ -53,9 +53,9 @@ CCYYMMDDThh:mm, CCYY-MM-DDThh, etc). Tasks that become ready after the shutdown is ordered will be submitted -immediately if the workflow is restarted. Remaining task event handlers and -job poll and kill commands, however, will be executed prior to shutdown, unless ---now is used. +immediately if the workflow is restarted. Remaining task event handlers and +job poll and kill commands will be executed prior to shutdown, unless +--now is used twice. This command exits immediately unless --max-polls is greater than zero, in which case it polls to wait for workflow shutdown. diff --git a/tests/functional/shutdown/08-now1.t b/tests/functional/shutdown/08-now1.t index 77a822f5cc2..01a4eafdeb5 100755 --- a/tests/functional/shutdown/08-now1.t +++ b/tests/functional/shutdown/08-now1.t @@ -18,7 +18,7 @@ # Test "cylc stop --now" will wait for event handler. . "$(dirname "$0")/test_header" -set_test_number 6 +set_test_number 5 install_workflow "${TEST_NAME_BASE}" "${TEST_NAME_BASE}" run_ok "${TEST_NAME_BASE}-validate" cylc validate "${WORKFLOW_NAME}" @@ -27,10 +27,6 @@ LOGD="$RUN_DIR/${WORKFLOW_NAME}/log" grep_ok 'INFO - Workflow shutting down - REQUEST(NOW)' "${LOGD}/scheduler/log" JLOGD="${LOGD}/job/1/t1/01" # Check that 1/t1 event handler runs -run_ok "${TEST_NAME_BASE}-activity-log-succeeded" \ - grep -q -F \ - "[(('event-handler-00', 'succeeded'), 1) out] Well done 1/t1 succeeded" \ - "${JLOGD}/job-activity.log" run_ok "${TEST_NAME_BASE}-activity-log-started" \ grep -q -F \ "[(('event-handler-00', 'started'), 1) out] Hello 1/t1 started" \ diff --git a/tests/functional/shutdown/08-now1/flow.cylc b/tests/functional/shutdown/08-now1/flow.cylc index a37eea505e2..793d4427ca4 100644 --- a/tests/functional/shutdown/08-now1/flow.cylc +++ b/tests/functional/shutdown/08-now1/flow.cylc @@ -3,7 +3,7 @@ abort on stall timeout = True stall timeout = PT0S abort on inactivity timeout = True - inactivity timeout = PT3M + inactivity timeout = PT1M [scheduling] [[graph]] @@ -14,6 +14,5 @@ script = cylc__job__wait_cylc_message_started; cylc stop --now "${CYLC_WORKFLOW_ID}" [[[events]]] started handlers = sleep 10 && echo 'Hello %(id)s %(event)s' - succeeded handlers = echo 'Well done %(id)s %(event)s' [[t2]] script = true From cd18952ce52f77f294e76633f363c0a91b029951 Mon Sep 17 00:00:00 2001 From: Ronnie Dutta <61982285+MetRonnie@users.noreply.github.com> Date: Mon, 1 Jul 2024 12:22:36 +0100 Subject: [PATCH 088/196] flake8-comprehensions --- cylc/flow/network/schema.py | 2 +- cylc/flow/task_outputs.py | 6 +----- cylc/flow/tui/updater.py | 7 ++----- 3 files changed, 4 insertions(+), 11 deletions(-) diff --git a/cylc/flow/network/schema.py b/cylc/flow/network/schema.py index 3a8626e2c95..70e40232c1d 100644 --- a/cylc/flow/network/schema.py +++ b/cylc/flow/network/schema.py @@ -502,7 +502,7 @@ async def get_nodes_edges(root, info: 'ResolveInfo', **args): def resolve_state_totals(root, info, **args): - state_totals = {state: 0 for state in TASK_STATUSES_ORDERED} + state_totals = dict.fromkeys(TASK_STATUSES_ORDERED, 0) # Update with converted protobuf map container state_totals.update( dict(getattr(root, to_snake_case(info.field_name), {}))) diff --git a/cylc/flow/task_outputs.py b/cylc/flow/task_outputs.py index 40b9d991b30..1af37e1554e 100644 --- a/cylc/flow/task_outputs.py +++ b/cylc/flow/task_outputs.py @@ -251,11 +251,7 @@ def get_optional_outputs( ) for output in used_compvars }, - # the outputs that are not used in the expression - **{ - output: None - for output in all_compvars - used_compvars - }, + **dict.fromkeys(all_compvars - used_compvars), } diff --git a/cylc/flow/tui/updater.py b/cylc/flow/tui/updater.py index 26a8614b1a3..2a28b5d7906 100644 --- a/cylc/flow/tui/updater.py +++ b/cylc/flow/tui/updater.py @@ -68,11 +68,8 @@ def get_default_filters(): These filters show everything. """ return { - 'tasks': { - # filtered task statuses - state: True - for state in TASK_STATUSES_ORDERED - }, + # filtered task statuses + 'tasks': dict.fromkeys(TASK_STATUSES_ORDERED, True), 'workflows': { # filtered workflow statuses **{ From 6d311118ebe3c41bdb6a2a5e95167264f8db7a45 Mon Sep 17 00:00:00 2001 From: Oliver Sanders Date: Tue, 2 Jul 2024 10:47:01 +0100 Subject: [PATCH 089/196] commands: log command validation errors (#6164) --- cylc/flow/network/resolvers.py | 1 + tests/integration/test_resolvers.py | 56 +++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/cylc/flow/network/resolvers.py b/cylc/flow/network/resolvers.py index 65983042532..11eafd8bea5 100644 --- a/cylc/flow/network/resolvers.py +++ b/cylc/flow/network/resolvers.py @@ -761,6 +761,7 @@ async def _mutation_mapper( except Exception as exc: # NOTE: keep this exception vague to prevent a bad command taking # down the scheduler + LOG.warning(f'{log1}\n{exc.__class__.__name__}: {exc}') if cylc.flow.flags.verbosity > 1: LOG.exception(exc) # log full traceback in debug mode return (False, str(exc)) diff --git a/tests/integration/test_resolvers.py b/tests/integration/test_resolvers.py index cfdc8cafc3d..981237a4d2a 100644 --- a/tests/integration/test_resolvers.py +++ b/tests/integration/test_resolvers.py @@ -250,3 +250,59 @@ async def test_command_logging(mock_flow, caplog, log_filter): await mock_flow.resolvers._mutation_mapper("put_messages", kwargs, meta) assert log_filter( caplog, contains='Command "put_messages" received from Dr Spock') + + +async def test_command_validation_failure( + mock_flow, + caplog, + flow_args, + monkeypatch, +): + """It should log command validation failures server side.""" + caplog.set_level(logging.DEBUG, None) + flow_args['workflows'].append( + { + 'user': mock_flow.owner, + 'workflow': mock_flow.name, + 'workflow_sel': None, + } + ) + + # submit a command with invalid arguments: + async def submit_invalid_command(verbosity=0): + nonlocal caplog, mock_flow, flow_args + monkeypatch.setattr('cylc.flow.flags.verbosity', verbosity) + caplog.clear() + return await mock_flow.resolvers.mutator( + None, + 'stop', + flow_args, + {'task': 'cycle/task/job', 'mode': 'not-a-mode'}, + {}, + ) + + # submitting the invalid command should result in this error + msg = 'This command does not take job ids:\n * cycle/task/job' + + # test submitting the command at *default* verbosity + response = await submit_invalid_command() + + # the error should be sent back to the client: + assert response[0]['response'][1] == msg + # it should also be logged by the server: + assert caplog.records[-1].levelno == logging.WARNING + assert msg in caplog.records[-1].message + + # test submitting the command at *debug* verbosity + response = await submit_invalid_command(verbosity=2) + + # the error should be sent back to the client: + assert response[0]['response'][1] == msg + # it should be logged at the server + assert caplog.records[-2].levelno == logging.WARNING + assert msg in caplog.records[-2].message + # the traceback should also be logged + # (note traceback gets logged at the ERROR level and shows up funny in + # caplog) + assert caplog.records[-1].levelno == logging.ERROR + assert msg in caplog.records[-1].message From e65e8ed08f575dfad4bba6eb1761e6afbabddb0e Mon Sep 17 00:00:00 2001 From: Oliver Sanders Date: Thu, 4 Jul 2024 09:39:06 +0100 Subject: [PATCH 090/196] tui: improve --comms-timeout documentation (#6182) * tui: improve --comms-timeout documentation * Correct default in CLI docs. * Mention the "--comms-timeout" option when timeouts occur. * spelling correction --------- Co-authored-by: Mark Dawson --- cylc/flow/scripts/tui.py | 4 +-- cylc/flow/tui/updater.py | 68 ++++++++++++++++++++++++++++++---------- 2 files changed, 53 insertions(+), 19 deletions(-) diff --git a/cylc/flow/scripts/tui.py b/cylc/flow/scripts/tui.py index 2d48e649ef2..1958fc05132 100644 --- a/cylc/flow/scripts/tui.py +++ b/cylc/flow/scripts/tui.py @@ -25,7 +25,7 @@ to the GUI. Press "h" whilst running Tui to bring up the help screen, use the arrow -keys to navigage. +keys to navigate. """ @@ -66,7 +66,7 @@ def get_option_parser() -> COP: metavar='SEC', help=( "Set a timeout for network connections" - " to the running workflow. The default is no timeout." + " to the running workflow. The default is 3 seconds." " For task messaging connections see" " site/user config file documentation." ), diff --git a/cylc/flow/tui/updater.py b/cylc/flow/tui/updater.py index 2a28b5d7906..cc52cff89f6 100644 --- a/cylc/flow/tui/updater.py +++ b/cylc/flow/tui/updater.py @@ -82,6 +82,36 @@ def get_default_filters(): } +def set_message(data, workflow_id, message, prefix='Error - '): + """Set a message to display instead of the workflow contents. + + This is for critical errors that mean we are unable to load a workflow. + + Args: + data: + The updater data. + workflow_id: + The ID of the workflow to set the error for. + message: + A message string or an Exception instance to use for the error + text. If a string is provided, it may not contain newlines. + prefix: + A string that will be prepended to the message. + + """ + if isinstance(message, Exception): + # use the first line of the error message. + message = str(message).splitlines()[0] + for workflow in data['workflows']: + # find the workflow in the data + if workflow['id'] == workflow_id: + # use the _tui_data field to hold the message + workflow['_tui_data'] = ( + f'{prefix}{message}' + ) + break + + class Updater(): """The bit of Tui which provides the data. @@ -266,17 +296,19 @@ async def _update_workflow(self, w_id, client, data): 'id': w_id, 'status': 'stopped', }) + except ClientTimeout: + self._clients[w_id] = None + set_message( + data, + w_id, + 'Timeout communicating with workflow.' + ' Use "--comms-timeout" to increase the timeout', + ) except (CylcError, ZMQError) as exc: # something went wrong :( # remove the client on any error, we'll reconnect next time self._clients[w_id] = None - for workflow in data['workflows']: - if workflow['id'] == w_id: - workflow['_tui_data'] = ( - f'Error - {str(exc).splitlines()[0]}' - ) - break - + set_message(data, w_id, exc) else: # the data arrived, add it to the update workflow_data = workflow_update['workflows'][0] @@ -295,16 +327,18 @@ def _connect(self, data): timeout=self.client_timeout, ) except WorkflowStopped: - for workflow in data['workflows']: - if workflow['id'] == w_id: - workflow['_tui_data'] = 'Workflow is not running' - except (ZMQError, ClientError, ClientTimeout) as exc: - for workflow in data['workflows']: - if workflow['id'] == w_id: - workflow['_tui_data'] = ( - f'Error - {str(exc).splitlines()[0]}' - ) - break + set_message( + data, w_id, 'Workflow is not running', prefix='' + ) + except ClientTimeout: + set_message( + data, + w_id, + 'Timeout connecting to workflow.' + ' Use "--comms-timeout" to increase the timeout', + ) + except (ZMQError, ClientError) as exc: + set_message(data, w_id, exc) async def _scan(self): """Scan for workflows on the filesystem.""" From d0bc4cb21624bf51fb371aed83bdedf477e2e28d Mon Sep 17 00:00:00 2001 From: Tim Pillinger <26465611+wxtim@users.noreply.github.com> Date: Thu, 4 Jul 2024 15:15:52 +0100 Subject: [PATCH 091/196] Ensure jobs are created if task is in waiting state (#6176) --- changes.d/6176.fix.md | 1 + cylc/flow/task_events_mgr.py | 12 +++- tests/integration/test_task_events_mgr.py | 77 ++++++++++++++++++++++- 3 files changed, 86 insertions(+), 4 deletions(-) create mode 100644 changes.d/6176.fix.md diff --git a/changes.d/6176.fix.md b/changes.d/6176.fix.md new file mode 100644 index 00000000000..e6f6de8886d --- /dev/null +++ b/changes.d/6176.fix.md @@ -0,0 +1 @@ +Fix bug where jobs which fail to submit are not shown in GUI/TUI if submission retries are set. \ No newline at end of file diff --git a/cylc/flow/task_events_mgr.py b/cylc/flow/task_events_mgr.py index 0f89d2122dd..bf9c2ba3a9b 100644 --- a/cylc/flow/task_events_mgr.py +++ b/cylc/flow/task_events_mgr.py @@ -1535,11 +1535,21 @@ def _insert_task_job( else: job_conf = itask.jobs[-1] + # Job status should be task status unless task is awaiting a + # retry: + if itask.state.status == TASK_STATUS_WAITING and itask.try_timers: + job_status = ( + TASK_STATUS_SUBMITTED if submit_status == 0 + else TASK_STATUS_SUBMIT_FAILED + ) + else: + job_status = itask.state.status + # insert job into data store self.data_store_mgr.insert_job( itask.tdef.name, itask.point, - itask.state.status, + job_status, { **job_conf, # NOTE: the platform name may have changed since task diff --git a/tests/integration/test_task_events_mgr.py b/tests/integration/test_task_events_mgr.py index 62994487624..7ac12274d7b 100644 --- a/tests/integration/test_task_events_mgr.py +++ b/tests/integration/test_task_events_mgr.py @@ -14,12 +14,19 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from cylc.flow.task_events_mgr import TaskJobLogsRetrieveContext -from cylc.flow.scheduler import Scheduler - +from itertools import product import logging from typing import Any as Fixture +from cylc.flow.task_events_mgr import TaskJobLogsRetrieveContext +from cylc.flow.scheduler import Scheduler +from cylc.flow.data_store_mgr import ( + JOBS, + TASK_STATUSES_ORDERED, + TASK_STATUS_WAITING, + TASK_STATUS_SUBMIT_FAILED, +) + async def test_process_job_logs_retrieval_warns_no_platform( one_conf: Fixture, flow: Fixture, scheduler: Fixture, run: Fixture, @@ -99,3 +106,67 @@ async def test__insert_task_job(flow, one_conf, scheduler, start, validate): i.submit_num for i in schd.data_store_mgr.added['jobs'].values() ] == [1, 2] + + +async def test__always_insert_task_job( + flow, scheduler, mock_glbl_cfg, start, run +): + """Insert Task Job _Always_ inserts a task into the data store. + + Bug https://github.com/cylc/cylc-flow/issues/6172 was caused + by passing task state to data_store_mgr.insert_job: Where + a submission retry was in progress the task state would be + "waiting" which caused the data_store_mgr.insert_job + to return without adding the task to the data store. + This is testing two different cases: + + * Could not select host from platform + * Could not select host from platform group + """ + global_config = """ + [platforms] + [[broken1]] + hosts = no-such-host-1 + [[broken2]] + hosts = no-such-host-2 + [platform groups] + [[broken]] + platforms = broken1 + """ + mock_glbl_cfg('cylc.flow.platforms.glbl_cfg', global_config) + + id_ = flow({ + 'scheduling': {'graph': {'R1': 'broken & broken2'}}, + 'runtime': { + 'root': {'submission retry delays': 'PT10M'}, + 'broken': {'platform': 'broken'}, + 'broken2': {'platform': 'broken2'} + } + }) + + schd = scheduler(id_, run_mode='live') + schd.bad_hosts = {'no-such-host-1', 'no-such-host-2'} + async with start(schd): + schd.task_job_mgr.submit_task_jobs( + schd.workflow, + schd.pool.get_tasks(), + schd.server.curve_auth, + schd.server.client_pub_key_dir, + is_simulation=False + ) + + # Both tasks are in a waiting state: + assert all( + i.state.status == TASK_STATUS_WAITING + for i in schd.pool.get_tasks()) + + # Both tasks have updated the data store with info + # about a failed job: + updates = { + k.split('//')[-1]: v.state + for k, v in schd.data_store_mgr.updated[JOBS].items() + } + assert updates == { + '1/broken/01': 'submit-failed', + '1/broken2/01': 'submit-failed' + } From 594d80406f96d31b7431f49e786494fee7d57d2b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 4 Jul 2024 14:26:28 +0000 Subject: [PATCH 092/196] Prepare release 8.3.1 Workflow: Release stage 1 - create release PR (Cylc 8+ only), run: 39 --- CHANGES.md | 10 ++++++++++ changes.d/6130.fix.md | 1 - changes.d/6170.fix.md | 1 - changes.d/6176.fix.md | 1 - cylc/flow/__init__.py | 2 +- 5 files changed, 11 insertions(+), 4 deletions(-) delete mode 100644 changes.d/6130.fix.md delete mode 100644 changes.d/6170.fix.md delete mode 100644 changes.d/6176.fix.md diff --git a/CHANGES.md b/CHANGES.md index 43b40c86eed..1edbb2b04d6 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -11,6 +11,16 @@ $ towncrier create ..md --content "Short description" +## __cylc-8.3.1 (Released 2024-07-04)__ + +### 🔧 Fixes + +[#6130](https://github.com/cylc/cylc-flow/pull/6130) - Prevent commands accepting job IDs where it doesn't make sense. + +[#6170](https://github.com/cylc/cylc-flow/pull/6170) - Fix an issue where the Cylc logo could appear in the workflow log. + +[#6176](https://github.com/cylc/cylc-flow/pull/6176) - Fix bug where jobs which fail to submit are not shown in GUI/TUI if submission retries are set. + ## __cylc-8.3.0 (Released 2024-06-18)__ ### ⚠ Breaking Changes diff --git a/changes.d/6130.fix.md b/changes.d/6130.fix.md deleted file mode 100644 index 8423bfdcd40..00000000000 --- a/changes.d/6130.fix.md +++ /dev/null @@ -1 +0,0 @@ -Prevent commands accepting job IDs where it doesn't make sense. \ No newline at end of file diff --git a/changes.d/6170.fix.md b/changes.d/6170.fix.md deleted file mode 100644 index 1675c7dfe7c..00000000000 --- a/changes.d/6170.fix.md +++ /dev/null @@ -1 +0,0 @@ -Fix an issue where the Cylc logo could appear in the workflow log. diff --git a/changes.d/6176.fix.md b/changes.d/6176.fix.md deleted file mode 100644 index e6f6de8886d..00000000000 --- a/changes.d/6176.fix.md +++ /dev/null @@ -1 +0,0 @@ -Fix bug where jobs which fail to submit are not shown in GUI/TUI if submission retries are set. \ No newline at end of file diff --git a/cylc/flow/__init__.py b/cylc/flow/__init__.py index 40718ebdf7e..96d02a90c11 100644 --- a/cylc/flow/__init__.py +++ b/cylc/flow/__init__.py @@ -53,7 +53,7 @@ def environ_init(): environ_init() -__version__ = '8.3.1.dev' +__version__ = '8.3.1' def iter_entry_points(entry_point_name): From e84bce554c465566fc5db322896b629434ab0b53 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 4 Jul 2024 14:44:19 +0000 Subject: [PATCH 093/196] Bump dev version Workflow: Release stage 2 - auto publish, run: 81 --- cylc/flow/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cylc/flow/__init__.py b/cylc/flow/__init__.py index 96d02a90c11..2bb0860793c 100644 --- a/cylc/flow/__init__.py +++ b/cylc/flow/__init__.py @@ -53,7 +53,7 @@ def environ_init(): environ_init() -__version__ = '8.3.1' +__version__ = '8.3.2.dev' def iter_entry_points(entry_point_name): From d2094fbc9194c65c88920cfc8d4127fd4d87f6a8 Mon Sep 17 00:00:00 2001 From: Ronnie Dutta <61982285+MetRonnie@users.noreply.github.com> Date: Wed, 3 Jul 2024 16:19:41 +0100 Subject: [PATCH 094/196] Command validation: include more --- cylc/flow/command_validation.py | 7 +++++++ cylc/flow/commands.py | 5 +++-- cylc/flow/scripts/trigger.py | 2 -- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/cylc/flow/command_validation.py b/cylc/flow/command_validation.py index 1c57d452c43..34b7a0f1460 100644 --- a/cylc/flow/command_validation.py +++ b/cylc/flow/command_validation.py @@ -252,7 +252,14 @@ def is_tasks(tasks: Iterable[str]): ... cylc.flow.exceptions.InputError: This command does not take job ids: * */baz/12 + + >>> is_tasks([]) + Traceback (most recent call last): + ... + cylc.flow.exceptions.InputError: No tasks specified """ + if not tasks: + raise InputError("No tasks specified") bad_tasks: List[str] = [] for task in tasks: tokens = Tokens('//' + task) diff --git a/cylc/flow/commands.py b/cylc/flow/commands.py index a4ea43df5cf..86cea277bed 100644 --- a/cylc/flow/commands.py +++ b/cylc/flow/commands.py @@ -278,10 +278,10 @@ async def hold(schd: 'Scheduler', tasks: Iterable[str]): @_command('set_hold_point') async def set_hold_point(schd: 'Scheduler', point: str): """Hold all tasks after the specified cycle point.""" - yield cycle_point = TaskID.get_standardised_point(point) if cycle_point is None: raise CyclingError("Cannot set hold point to None") + yield LOG.info( f"Setting hold cycle point: {cycle_point}\n" "All tasks after this point will be held." @@ -299,13 +299,13 @@ async def pause(schd: 'Scheduler'): @_command('set_verbosity') async def set_verbosity(schd: 'Scheduler', level: Union[int, str]): """Set workflow verbosity.""" - yield try: lvl = int(level) LOG.setLevel(lvl) except (TypeError, ValueError) as exc: raise CommandFailedError(exc) cylc.flow.flags.verbosity = log_level_to_verbosity(lvl) + yield @_command('remove_tasks') @@ -439,5 +439,6 @@ async def force_trigger_tasks( ): """Manual task trigger.""" validate.is_tasks(tasks) + validate.flow_opts(flow, flow_wait) yield yield schd.pool.force_trigger_tasks(tasks, flow, flow_wait, flow_descr) diff --git a/cylc/flow/scripts/trigger.py b/cylc/flow/scripts/trigger.py index 58c2f2a3939..de788481cfe 100755 --- a/cylc/flow/scripts/trigger.py +++ b/cylc/flow/scripts/trigger.py @@ -45,7 +45,6 @@ import sys from typing import TYPE_CHECKING -from cylc.flow import command_validation from cylc.flow.network.client_factory import get_client from cylc.flow.network.multi import call_multi from cylc.flow.option_parsers import ( @@ -115,7 +114,6 @@ async def run(options: 'Values', workflow_id: str, *tokens_list): @cli_function(get_option_parser) def main(parser: COP, options: 'Values', *ids: str): """CLI for "cylc trigger".""" - command_validation.flow_opts(options.flow or ['all'], options.flow_wait) rets = call_multi( partial(run, options), *ids, From 9d56622d3ceb990ffd1f1ba295378bc34f6e9648 Mon Sep 17 00:00:00 2001 From: Ronnie Dutta <61982285+MetRonnie@users.noreply.github.com> Date: Thu, 4 Jul 2024 19:15:05 +0100 Subject: [PATCH 095/196] Fix bug where a stalled paused workflow would have a status of running --- changes.d/6200.fix.md | 1 + cylc/flow/data_store_mgr.py | 9 ++-- cylc/flow/scheduler.py | 2 +- cylc/flow/workflow_status.py | 87 ++++++++++++------------------ tests/unit/test_workflow_status.py | 25 +++++---- 5 files changed, 56 insertions(+), 68 deletions(-) create mode 100644 changes.d/6200.fix.md diff --git a/changes.d/6200.fix.md b/changes.d/6200.fix.md new file mode 100644 index 00000000000..3b4cf8012cf --- /dev/null +++ b/changes.d/6200.fix.md @@ -0,0 +1 @@ +Fixed bug where a stalled paused workflow would be incorrectly reported as running, not paused \ No newline at end of file diff --git a/cylc/flow/data_store_mgr.py b/cylc/flow/data_store_mgr.py index c59fa7b6c62..0befc1b4dad 100644 --- a/cylc/flow/data_store_mgr.py +++ b/cylc/flow/data_store_mgr.py @@ -85,7 +85,10 @@ pdeepcopy, poverride ) -from cylc.flow.workflow_status import get_workflow_status +from cylc.flow.workflow_status import ( + get_workflow_status, + get_workflow_status_msg, +) from cylc.flow.task_job_logs import JOB_LOG_OPTS, get_task_job_log from cylc.flow.task_proxy import TaskProxy from cylc.flow.task_state import ( @@ -2174,8 +2177,8 @@ def update_workflow(self, reloaded=False): w_delta.latest_state_tasks[state].task_proxies[:] = tp_queue # Set status & msg if changed. - status, status_msg = map( - str, get_workflow_status(self.schd)) + status = get_workflow_status(self.schd).value + status_msg = get_workflow_status_msg(self.schd) if w_data.status != status or w_data.status_msg != status_msg: w_delta.status = status w_delta.status_msg = status_msg diff --git a/cylc/flow/scheduler.py b/cylc/flow/scheduler.py index ff593648e7b..b3bbbd23d7d 100644 --- a/cylc/flow/scheduler.py +++ b/cylc/flow/scheduler.py @@ -2000,7 +2000,7 @@ def update_data_store(self): Call this method whenever the Scheduler's state has changed in a way that requires a data store update. - See cylc.flow.workflow_status.get_workflow_status() for a + See cylc.flow.workflow_status.get_workflow_status_msg() for a (non-exhaustive?) list of properties that if changed will require this update. diff --git a/cylc/flow/workflow_status.py b/cylc/flow/workflow_status.py index 02f42717ed3..6ed1a8f3071 100644 --- a/cylc/flow/workflow_status.py +++ b/cylc/flow/workflow_status.py @@ -16,7 +16,7 @@ """Workflow status constants.""" from enum import Enum -from typing import Tuple, TYPE_CHECKING +from typing import TYPE_CHECKING from cylc.flow.wallclock import get_time_string_from_unix_time as time2str @@ -143,62 +143,41 @@ class AutoRestartMode(Enum): """Workflow will stop immediately but *not* attempt to restart.""" -def get_workflow_status(schd: 'Scheduler') -> Tuple[str, str]: - """Return the status of the provided workflow. - - This should be a short, concise description of the workflow state. - - Args: - schd: The running workflow - - Returns: - tuple - (state, state_msg) - - state: - The WorkflowState. - state_msg: - Text describing the current state (may be an empty string). +def get_workflow_status(schd: 'Scheduler') -> WorkflowStatus: + """Return the status of the provided workflow.""" + if schd.stop_mode is not None: + return WorkflowStatus.STOPPING + if schd.is_paused or schd.reload_pending: + return WorkflowStatus.PAUSED + return WorkflowStatus.RUNNING - """ - status = WorkflowStatus.RUNNING - status_msg = '' +def get_workflow_status_msg(schd: 'Scheduler') -> str: + """Return a short, concise status message for the provided workflow.""" if schd.stop_mode is not None: - status = WorkflowStatus.STOPPING - status_msg = f'stopping: {schd.stop_mode.explain()}' - elif schd.reload_pending: - status = WorkflowStatus.PAUSED - status_msg = f'reloading: {schd.reload_pending}' - elif schd.is_stalled: - status_msg = 'stalled' - elif schd.is_paused: - status = WorkflowStatus.PAUSED - status_msg = 'paused' - elif schd.pool.hold_point: - status_msg = ( - WORKFLOW_STATUS_RUNNING_TO_HOLD % - schd.pool.hold_point) - elif schd.pool.stop_point: - status_msg = ( - WORKFLOW_STATUS_RUNNING_TO_STOP % - schd.pool.stop_point) - elif schd.stop_clock_time is not None: - status_msg = ( - WORKFLOW_STATUS_RUNNING_TO_STOP % - time2str(schd.stop_clock_time)) - elif schd.pool.stop_task_id: - status_msg = ( - WORKFLOW_STATUS_RUNNING_TO_STOP % - schd.pool.stop_task_id) - elif schd.config and schd.config.final_point: - status_msg = ( - WORKFLOW_STATUS_RUNNING_TO_STOP % - schd.config.final_point) - else: - # fallback - running indefinitely - status_msg = 'running' - - return (status.value, status_msg) + return f'stopping: {schd.stop_mode.explain()}' + if schd.reload_pending: + return f'reloading: {schd.reload_pending}' + if schd.is_stalled: + if schd.is_paused: + return 'stalled (paused)' + return 'stalled' + if schd.is_paused: + return 'paused' + if schd.pool.hold_point: + return WORKFLOW_STATUS_RUNNING_TO_HOLD % schd.pool.hold_point + if schd.pool.stop_point: + return WORKFLOW_STATUS_RUNNING_TO_STOP % schd.pool.stop_point + if schd.stop_clock_time is not None: + return WORKFLOW_STATUS_RUNNING_TO_STOP % time2str( + schd.stop_clock_time + ) + if schd.pool.stop_task_id: + return WORKFLOW_STATUS_RUNNING_TO_STOP % schd.pool.stop_task_id + if schd.config and schd.config.final_point: + return WORKFLOW_STATUS_RUNNING_TO_STOP % schd.config.final_point + # fallback - running indefinitely + return 'running' class RunMode: diff --git a/tests/unit/test_workflow_status.py b/tests/unit/test_workflow_status.py index af88de3daab..0dc074a2f4b 100644 --- a/tests/unit/test_workflow_status.py +++ b/tests/unit/test_workflow_status.py @@ -17,6 +17,7 @@ from types import SimpleNamespace import pytest +from metomi.isodatetime.data import TimePoint from cylc.flow.workflow_status import ( StopMode, @@ -24,9 +25,13 @@ WORKFLOW_STATUS_RUNNING_TO_HOLD, WORKFLOW_STATUS_RUNNING_TO_STOP, get_workflow_status, + get_workflow_status_msg, ) +STOP_TIME = TimePoint(year=2006).to_local_time_zone() + + def schd( final_point=None, hold_point=None, @@ -50,6 +55,7 @@ def schd( stop_task_id=stop_task_id, ), config=SimpleNamespace(final_point=final_point), + options=SimpleNamespace(utc_mode=True), ) @@ -83,9 +89,9 @@ def schd( WORKFLOW_STATUS_RUNNING_TO_STOP % 'point' ), ( - {'stop_clock_time': 1234}, + {'stop_clock_time': int(STOP_TIME.seconds_since_unix_epoch)}, WorkflowStatus.RUNNING, - WORKFLOW_STATUS_RUNNING_TO_STOP % '' + WORKFLOW_STATUS_RUNNING_TO_STOP % str(STOP_TIME) ), ( {'stop_task_id': 'foo'}, @@ -112,22 +118,21 @@ def schd( ( # stopping should trump stalled, paused & running { - 'stop_mode': StopMode.AUTO, + 'stop_mode': StopMode.REQUEST_NOW, 'is_stalled': True, 'is_paused': True }, WorkflowStatus.STOPPING, - 'stopping' + 'stopping: shutting down' ), ( - # stalled should trump paused & running {'is_stalled': True, 'is_paused': True}, - WorkflowStatus.RUNNING, - 'stalled' + WorkflowStatus.PAUSED, + 'stalled (paused)' ), ] ) def test_get_workflow_status(kwargs, state, message): - state_, message_ = get_workflow_status(schd(**kwargs)) - assert state_ == state.value - assert message in message_ + scheduler = schd(**kwargs) + assert get_workflow_status(scheduler) == state + assert get_workflow_status_msg(scheduler) == message From 7b2cf8eef27f0ed9fece8a2a1489f96e2a5cd83c Mon Sep 17 00:00:00 2001 From: Ronnie Dutta <61982285+MetRonnie@users.noreply.github.com> Date: Thu, 4 Jul 2024 19:15:10 +0100 Subject: [PATCH 096/196] Tidy & fix deprecation warning --- cylc/flow/tui/util.py | 13 ------------- cylc/flow/wallclock.py | 4 ++-- 2 files changed, 2 insertions(+), 15 deletions(-) diff --git a/cylc/flow/tui/util.py b/cylc/flow/tui/util.py index 88e960e249a..33494f9fb06 100644 --- a/cylc/flow/tui/util.py +++ b/cylc/flow/tui/util.py @@ -384,19 +384,6 @@ def get_task_status_summary(flow): ] -def get_workflow_status_str(flow): - """Return a workflow status string for the header. - - Arguments: - flow (dict): - GraphQL JSON response for this workflow. - - Returns: - list - Text list for the urwid.Text widget. - - """ - - def _render_user(node, data): return f'~{ME}' diff --git a/cylc/flow/wallclock.py b/cylc/flow/wallclock.py index 46c5d2487ce..5c3479a07db 100644 --- a/cylc/flow/wallclock.py +++ b/cylc/flow/wallclock.py @@ -16,7 +16,7 @@ """Wall clock related utilities.""" from calendar import timegm -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from metomi.isodatetime.timezone import ( get_local_time_zone_format, get_local_time_zone, TimeZoneFormatMode) @@ -209,7 +209,7 @@ def get_time_string_from_unix_time(unix_time, display_sub_seconds=False, to use as the time zone designator. """ - date_time = datetime.utcfromtimestamp(unix_time) + date_time = datetime.fromtimestamp(unix_time, timezone.utc) return get_time_string(date_time, display_sub_seconds=display_sub_seconds, use_basic_format=use_basic_format, From 47b74aebf90be466db29ddd234d44f6b27c7cc2d Mon Sep 17 00:00:00 2001 From: Ronnie Dutta <61982285+MetRonnie@users.noreply.github.com> Date: Fri, 5 Jul 2024 17:52:30 +0100 Subject: [PATCH 097/196] Ensure workflow status updates when stop/hold point set --- cylc/flow/commands.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/cylc/flow/commands.py b/cylc/flow/commands.py index a4ea43df5cf..134681bdfd5 100644 --- a/cylc/flow/commands.py +++ b/cylc/flow/commands.py @@ -189,16 +189,19 @@ async def stop( schd.workflow_db_mgr.put_workflow_stop_cycle_point( schd.options.stopcp ) + schd._update_workflow_state() elif clock_time is not None: # schedule shutdown after wallclock time passes provided time parser = TimePointParser() schd.set_stop_clock( int(parser.parse(clock_time).seconds_since_unix_epoch) ) + schd._update_workflow_state() elif task is not None: # schedule shutdown after task succeeds task_id = TaskID.get_standardised_taskid(task) schd.pool.set_stop_task(task_id) + schd._update_workflow_state() else: # immediate shutdown with suppress(KeyError): @@ -229,6 +232,7 @@ async def release_hold_point(schd: 'Scheduler'): yield LOG.info("Releasing all tasks and removing hold cycle point.") schd.pool.release_hold_point() + schd._update_workflow_state() @_command('resume') @@ -287,6 +291,7 @@ async def set_hold_point(schd: 'Scheduler', point: str): "All tasks after this point will be held." ) schd.pool.set_hold_point(cycle_point) + schd._update_workflow_state() @_command('pause') From a0ee209bd306bd9d7c45da43a4995ec86a58ec1f Mon Sep 17 00:00:00 2001 From: Ronnie Dutta <61982285+MetRonnie@users.noreply.github.com> Date: Fri, 5 Jul 2024 17:54:03 +0100 Subject: [PATCH 098/196] Ensure workflow status shows the earliest of the stop point, hold point or stop task --- cylc/flow/workflow_status.py | 40 +++++++++++++++---- tests/unit/test_workflow_status.py | 63 ++++++++++++++++++++++++------ 2 files changed, 82 insertions(+), 21 deletions(-) diff --git a/cylc/flow/workflow_status.py b/cylc/flow/workflow_status.py index 6ed1a8f3071..d6d6fb587dc 100644 --- a/cylc/flow/workflow_status.py +++ b/cylc/flow/workflow_status.py @@ -16,13 +16,18 @@ """Workflow status constants.""" from enum import Enum -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Optional, Union +from cylc.flow.cycling.loader import get_point +from cylc.flow.id import tokenise from cylc.flow.wallclock import get_time_string_from_unix_time as time2str if TYPE_CHECKING: from optparse import Values + + from cylc.flow.cycling import PointBase from cylc.flow.scheduler import Scheduler + from cylc.flow.task_pool import TaskPool # Keys for identify API call KEY_GROUP = "group" @@ -160,26 +165,45 @@ def get_workflow_status_msg(schd: 'Scheduler') -> str: return f'reloading: {schd.reload_pending}' if schd.is_stalled: if schd.is_paused: - return 'stalled (paused)' + return 'stalled and paused' return 'stalled' if schd.is_paused: return 'paused' - if schd.pool.hold_point: - return WORKFLOW_STATUS_RUNNING_TO_HOLD % schd.pool.hold_point - if schd.pool.stop_point: - return WORKFLOW_STATUS_RUNNING_TO_STOP % schd.pool.stop_point if schd.stop_clock_time is not None: return WORKFLOW_STATUS_RUNNING_TO_STOP % time2str( schd.stop_clock_time ) - if schd.pool.stop_task_id: - return WORKFLOW_STATUS_RUNNING_TO_STOP % schd.pool.stop_task_id + stop_point_msg = _get_earliest_stop_point_status_msg(schd.pool) + if stop_point_msg is not None: + return stop_point_msg if schd.config and schd.config.final_point: return WORKFLOW_STATUS_RUNNING_TO_STOP % schd.config.final_point # fallback - running indefinitely return 'running' +def _get_earliest_stop_point_status_msg(pool: 'TaskPool') -> Optional[str]: + """Return the status message for the earliest stop point in the pool, + if any.""" + template = WORKFLOW_STATUS_RUNNING_TO_STOP + prop: Union[PointBase, str, None] = pool.stop_task_id + min_point: Optional[PointBase] = get_point( + tokenise(pool.stop_task_id, relative=True)['cycle'] + if pool.stop_task_id else None + ) + for point, tmpl in ( + (pool.stop_point, WORKFLOW_STATUS_RUNNING_TO_STOP), + (pool.hold_point, WORKFLOW_STATUS_RUNNING_TO_HOLD) + ): + if point is not None and (min_point is None or point < min_point): + template = tmpl + min_point = point + prop = point + if prop is None: + return None + return template % prop + + class RunMode: """The possible run modes of a workflow.""" diff --git a/tests/unit/test_workflow_status.py b/tests/unit/test_workflow_status.py index 0dc074a2f4b..f0c92fd1530 100644 --- a/tests/unit/test_workflow_status.py +++ b/tests/unit/test_workflow_status.py @@ -19,16 +19,16 @@ import pytest from metomi.isodatetime.data import TimePoint +from cylc.flow.cycling.integer import IntegerPoint from cylc.flow.workflow_status import ( - StopMode, - WorkflowStatus, WORKFLOW_STATUS_RUNNING_TO_HOLD, WORKFLOW_STATUS_RUNNING_TO_STOP, + StopMode, + WorkflowStatus, get_workflow_status, get_workflow_status_msg, ) - STOP_TIME = TimePoint(year=2006).to_local_time_zone() @@ -79,14 +79,14 @@ def schd( 'stopping: waiting for active jobs to complete' ), ( - {'hold_point': 'point'}, + {'hold_point': 2}, WorkflowStatus.RUNNING, - WORKFLOW_STATUS_RUNNING_TO_HOLD % 'point' + WORKFLOW_STATUS_RUNNING_TO_HOLD % 2 ), ( - {'stop_point': 'point'}, + {'stop_point': 4}, WorkflowStatus.RUNNING, - WORKFLOW_STATUS_RUNNING_TO_STOP % 'point' + WORKFLOW_STATUS_RUNNING_TO_STOP % 4 ), ( {'stop_clock_time': int(STOP_TIME.seconds_since_unix_epoch)}, @@ -94,14 +94,14 @@ def schd( WORKFLOW_STATUS_RUNNING_TO_STOP % str(STOP_TIME) ), ( - {'stop_task_id': 'foo'}, + {'stop_task_id': '6/foo'}, WorkflowStatus.RUNNING, - WORKFLOW_STATUS_RUNNING_TO_STOP % 'foo' + WORKFLOW_STATUS_RUNNING_TO_STOP % '6/foo' ), ( - {'final_point': 'point'}, + {'final_point': 8}, WorkflowStatus.RUNNING, - WORKFLOW_STATUS_RUNNING_TO_STOP % 'point' + WORKFLOW_STATUS_RUNNING_TO_STOP % 8 ), ( {'is_stalled': True}, @@ -128,11 +128,48 @@ def schd( ( {'is_stalled': True, 'is_paused': True}, WorkflowStatus.PAUSED, - 'stalled (paused)' + 'stalled and paused', + ), + ( + # earliest of stop point, hold point and stop task id + { + 'stop_point': IntegerPoint(4), + 'hold_point': IntegerPoint(2), + 'stop_task_id': '6/foo', + }, + WorkflowStatus.RUNNING, + WORKFLOW_STATUS_RUNNING_TO_HOLD % 2, + ), + ( + { + 'stop_point': IntegerPoint(11), + 'hold_point': IntegerPoint(15), + 'stop_task_id': '9/bar', + }, + WorkflowStatus.RUNNING, + WORKFLOW_STATUS_RUNNING_TO_STOP % '9/bar', + ), + ( + { + 'stop_point': IntegerPoint(3), + 'hold_point': IntegerPoint(3), + }, + WorkflowStatus.RUNNING, + WORKFLOW_STATUS_RUNNING_TO_STOP % 3, + ), + ( + # stop point trumps final point + { + 'stop_point': IntegerPoint(1), + 'final_point': IntegerPoint(2), + }, + WorkflowStatus.RUNNING, + WORKFLOW_STATUS_RUNNING_TO_STOP % 1, ), ] ) -def test_get_workflow_status(kwargs, state, message): +def test_get_workflow_status(kwargs, state, message, set_cycling_type): + set_cycling_type() scheduler = schd(**kwargs) assert get_workflow_status(scheduler) == state assert get_workflow_status_msg(scheduler) == message From 6ff547e3bdd09ffe47475603751ff49073322aa9 Mon Sep 17 00:00:00 2001 From: Ronnie Dutta <61982285+MetRonnie@users.noreply.github.com> Date: Mon, 8 Jul 2024 14:28:29 +0100 Subject: [PATCH 099/196] Update MacOS version on GH Actions (#6187) * actions: patch DNS issues on Mac OS runners * Small fix for host info logic * GH Actions: update MacOS version * Reduce test flakiness --------- Co-authored-by: Oliver Sanders --- .github/workflows/test_fast.yml | 7 ++- .github/workflows/test_functional.yml | 58 ++++++++++++------- cylc/flow/hostuserutil.py | 34 +++++------ .../cylc-set/00-set-succeeded/flow.cylc | 26 ++++----- .../cylc-set/00-set-succeeded/reference.log | 2 +- .../cylc-set/03-set-failed/flow.cylc | 2 +- tests/functional/cylc-set/04-switch/flow.cylc | 2 +- tests/functional/cylc-set/05-expire/flow.cylc | 2 +- .../cylc-trigger/06-already-active/flow.cylc | 3 +- .../15-garbage-platform-command-2/flow.cylc | 2 +- .../functional/restart/50-two-flows/flow.cylc | 14 ++--- .../restart/59-retart-timeout/flow.cylc | 2 +- .../runahead/default-future/flow.cylc | 2 +- tests/functional/runahead/no_final/flow.cylc | 2 +- tests/functional/shutdown/08-now1.t | 2 +- .../11-hold-not-spawned/flow.cylc | 2 +- .../spawn-on-demand/15-stop-flow-3/flow.cylc | 2 +- 17 files changed, 91 insertions(+), 73 deletions(-) diff --git a/.github/workflows/test_fast.yml b/.github/workflows/test_fast.yml index d81e812b6da..db1a7335553 100644 --- a/.github/workflows/test_fast.yml +++ b/.github/workflows/test_fast.yml @@ -23,8 +23,8 @@ jobs: python-version: ['3.7', '3.8', '3.10', '3.11', '3'] include: # mac os test - - os: 'macos-11' - python-version: '3.7' # oldest supported version + - os: 'macos-latest' + python-version: '3.9' # oldest supported version # non-utc timezone test - os: 'ubuntu-latest' python-version: '3.9' # not the oldest, not the most recent version @@ -49,6 +49,9 @@ jobs: sudo apt-get update sudo apt-get install -y sqlite3 + - name: Patch DNS + uses: cylc/release-actions/patch-dns@v1 + - name: Install run: | pip install -e ."[all]" diff --git a/.github/workflows/test_functional.yml b/.github/workflows/test_functional.yml index f54f3b3d710..7831fef64f0 100644 --- a/.github/workflows/test_functional.yml +++ b/.github/workflows/test_functional.yml @@ -74,14 +74,14 @@ jobs: platform: '_remote_background_indep_tcp _remote_at_indep_tcp' # macos - name: 'macos 1/5' - os: 'macos-11' - python-version: '3.7' + os: 'macos-latest' + python-version: '3.9' test-base: 'tests/f' chunk: '1/5' platform: '_local_background*' - name: 'macos 2/5' - os: 'macos-11' - python-version: '3.7' + os: 'macos-latest' + python-version: '3.9' test-base: 'tests/f' chunk: '2/5' platform: '_local_background*' @@ -103,6 +103,29 @@ jobs: with: python-version: ${{ matrix.python-version }} + - name: Create global config + run: | + CONF_PATH="$HOME/.cylc/flow/8" + mkdir -p "$CONF_PATH" + touch "$CONF_PATH/global.cylc" + ln -s "$CONF_PATH/global.cylc" "$CONF_PATH/global-tests.cylc" + echo "GLOBAL_CFG_PATH=${CONF_PATH}/global.cylc" >> "$GITHUB_ENV" + + - name: Patch DNS + uses: cylc/release-actions/patch-dns@v1 + + - name: Add localhost entries to global config + if: startsWith(runner.os, 'macos') + run: | + cat >> "$GLOBAL_CFG_PATH" <<__HERE__ + [platforms] + [[localhost, $(hostname -f), $(hostname -s)]] + hosts = localhost + install target = localhost + ssh command = ssh -oBatchMode=yes -oConnectTimeout=8 -oStrictHostKeyChecking=no + __HERE__ + cat "$GLOBAL_CFG_PATH" + - name: Brew Install if: startsWith(matrix.os, 'macos') run: | @@ -112,19 +135,15 @@ jobs: # add GNU coreutils and sed to the user PATH # (see instructions in brew install output) - echo \ - "$(brew --prefix)/opt/coreutils/libexec/gnubin" \ - >> "${GITHUB_PATH}" - echo \ - "/usr/local/opt/gnu-sed/libexec/gnubin" \ - >> "${GITHUB_PATH}" - echo \ - "/usr/local/opt/grep/libexec/gnubin" \ - >> "${GITHUB_PATH}" + echo "$(brew --prefix)/opt/coreutils/libexec/gnubin" >> "${GITHUB_PATH}" + echo "$(brew --prefix)/opt/grep/libexec/gnubin" >> "${GITHUB_PATH}" + echo "$(brew --prefix)/opt/gnu-sed/libexec/gnubin" >> "${GITHUB_PATH}" # add coreutils to the bashrc too (for jobs) cat >> "${HOME}/.bashrc" <<__HERE__ - PATH="$(brew --prefix)/opt/coreutils/libexec/gnubin:/usr/local/opt/gnu-sed/libexec/gnubin:$PATH" + PATH="$(brew --prefix)/opt/coreutils/libexec/gnubin:$PATH" + PATH="$(brew --prefix)/opt/grep/libexec/gnubin:$PATH" + PATH="$(brew --prefix)/opt/gnu-sed/libexec/gnubin:$PATH" export PATH __HERE__ @@ -146,16 +165,13 @@ jobs: - name: Configure Atrun if: contains(matrix.platform, '_local_at') run: | - PTH="$HOME/.cylc/flow/" - mkdir -p "${PTH}" - cat > "${PTH}/global.cylc" << __HERE__ + cat >> "$GLOBAL_CFG_PATH" << __HERE__ [platforms] [[_local_at_indep_tcp]] hosts = localhost install target = localhost job runner = at __HERE__ - cp "${PTH}/global.cylc" "${PTH}/global-tests.cylc" - name: Swarm Configure run: | @@ -244,11 +260,11 @@ jobs: timeout-minutes: 1 run: | find "$HOME/cylc-run" -name '*.err' -type f \ - -exec echo '====== {} ======' \; -exec cat '{}' \; + -exec echo \; -exec echo '====== {} ======' \; -exec cat '{}' \; find "$HOME/cylc-run" -name '*.log' -type f \ - -exec echo '====== {} ======' \; -exec cat '{}' \; + -exec echo \; -exec echo '====== {} ======' \; -exec cat '{}' \; find "${TMPDIR:-/tmp}/${USER}/cylctb-"* -type f \ - -exec echo '====== {} ======' \; -exec cat '{}' \; + -exec echo \; -exec echo '====== {} ======' \; -exec cat '{}' \; - name: Set artifact upload name if: always() diff --git a/cylc/flow/hostuserutil.py b/cylc/flow/hostuserutil.py index 8eadbdb574f..108a9b3b842 100644 --- a/cylc/flow/hostuserutil.py +++ b/cylc/flow/hostuserutil.py @@ -50,6 +50,7 @@ import socket import sys from time import time +from typing import List, Optional, Tuple from cylc.flow.cfgspec.glbl_cfg import glbl_cfg @@ -113,25 +114,24 @@ def get_host_ip_by_name(target): """Return internal IP address of target.""" return socket.gethostbyname(target) - def _get_host_info(self, target=None): + def _get_host_info( + self, target: Optional[str] = None + ) -> Tuple[str, List[str], List[str]]: """Return the extended info of the current host.""" + if target is None: + target = socket.getfqdn() + if IS_MAC_OS and target in { + '1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.' + '0.0.0.0.0.0.ip6.arpa', + '1.0.0.127.in-addr.arpa', + }: + # Python's socket bindings don't play nicely with mac os + # so by default we get the above ip6.arpa address from + # socket.getfqdn, note this does *not* match `hostname -f`. + # https://github.com/cylc/cylc-flow/issues/2689 + # https://github.com/cylc/cylc-flow/issues/3595 + target = socket.gethostname() if target not in self._host_exs: - if target is None: - target = socket.getfqdn() - if ( - IS_MAC_OS - and target in { - '1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.' - '0.0.0.0.0.0.ip6.arpa', - '1.0.0.127.in-addr.arpa' - } - ): - # Python's socket bindings don't play nicely with mac os - # so by default we get the above ip6.arpa address from - # socket.getfqdn, note this does *not* match `hostname -f`. - # https://github.com/cylc/cylc-flow/issues/2689 - # https://github.com/cylc/cylc-flow/issues/3595 - target = socket.gethostname() try: self._host_exs[target] = socket.gethostbyname_ex(target) except IOError as exc: diff --git a/tests/functional/cylc-set/00-set-succeeded/flow.cylc b/tests/functional/cylc-set/00-set-succeeded/flow.cylc index 9ef5b517a17..3666c91b11f 100644 --- a/tests/functional/cylc-set/00-set-succeeded/flow.cylc +++ b/tests/functional/cylc-set/00-set-succeeded/flow.cylc @@ -15,23 +15,23 @@ [[graph]] R1 = """ foo & bar => post - setter + foo:started & bar:started => setter """ [runtime] [[post]] [[foo, bar]] script = false [[setter]] - script = """ - # wait for foo and bar to fail. - for TASK in foo bar - do - cylc workflow-state \ + script = """ + # wait for foo and bar to fail. + for TASK in foo bar + do + cylc workflow-state \ ${CYLC_WORKFLOW_ID}//${CYLC_TASK_CYCLE_POINT}/${TASK}:failed \ - --max-polls=10 --interval=1 - done - # set foo succeeded (via --output) - cylc set -o succeeded $CYLC_WORKFLOW_ID//$CYLC_TASK_CYCLE_POINT/foo - # set bar succeeded (via default) - cylc set $CYLC_WORKFLOW_ID//$CYLC_TASK_CYCLE_POINT/bar - """ + --max-polls=20 --interval=1 + done + # set foo succeeded (via --output) + cylc set -o succeeded $CYLC_WORKFLOW_ID//$CYLC_TASK_CYCLE_POINT/foo + # set bar succeeded (via default) + cylc set $CYLC_WORKFLOW_ID//$CYLC_TASK_CYCLE_POINT/bar + """ diff --git a/tests/functional/cylc-set/00-set-succeeded/reference.log b/tests/functional/cylc-set/00-set-succeeded/reference.log index 26468845a5c..68d7b8e729e 100644 --- a/tests/functional/cylc-set/00-set-succeeded/reference.log +++ b/tests/functional/cylc-set/00-set-succeeded/reference.log @@ -1,4 +1,4 @@ -1/setter -triggered off [] in flow 1 +1/setter -triggered off ['1/bar', '1/foo'] in flow 1 1/foo -triggered off [] in flow 1 1/bar -triggered off [] in flow 1 1/post_m1 -triggered off ['1/bar', '1/foo'] in flow 1 diff --git a/tests/functional/cylc-set/03-set-failed/flow.cylc b/tests/functional/cylc-set/03-set-failed/flow.cylc index 9d7514ccb83..06c30c6ddd5 100644 --- a/tests/functional/cylc-set/03-set-failed/flow.cylc +++ b/tests/functional/cylc-set/03-set-failed/flow.cylc @@ -2,7 +2,7 @@ [scheduler] [[events]] - inactivity timeout = PT20S + inactivity timeout = PT1M abort on inactivity timeout = True [scheduling] diff --git a/tests/functional/cylc-set/04-switch/flow.cylc b/tests/functional/cylc-set/04-switch/flow.cylc index 8a0ded59ce0..18402c7b64c 100644 --- a/tests/functional/cylc-set/04-switch/flow.cylc +++ b/tests/functional/cylc-set/04-switch/flow.cylc @@ -2,7 +2,7 @@ [scheduler] [[events]] - inactivity timeout = PT20S + inactivity timeout = PT1M abort on inactivity timeout = True stall timeout = PT0S abort on stall timeout = True diff --git a/tests/functional/cylc-set/05-expire/flow.cylc b/tests/functional/cylc-set/05-expire/flow.cylc index 57d94dbb99e..9717664132f 100644 --- a/tests/functional/cylc-set/05-expire/flow.cylc +++ b/tests/functional/cylc-set/05-expire/flow.cylc @@ -2,7 +2,7 @@ [scheduler] [[events]] - inactivity timeout = PT20S + inactivity timeout = PT1M abort on inactivity timeout = True stall timeout = PT0S abort on stall timeout = True diff --git a/tests/functional/cylc-trigger/06-already-active/flow.cylc b/tests/functional/cylc-trigger/06-already-active/flow.cylc index c7d99f6a6a8..9f02110b207 100644 --- a/tests/functional/cylc-trigger/06-already-active/flow.cylc +++ b/tests/functional/cylc-trigger/06-already-active/flow.cylc @@ -1,7 +1,7 @@ # test triggering an already active task [scheduler] [[events]] - inactivity timeout = PT20S + inactivity timeout = PT1M abort on inactivity timeout = True [scheduling] [[graph]] @@ -19,4 +19,3 @@ cylc__job__poll_grep_workflow_log \ "1/triggeree.* ignoring trigger - already active" -E """ - diff --git a/tests/functional/job-submission/15-garbage-platform-command-2/flow.cylc b/tests/functional/job-submission/15-garbage-platform-command-2/flow.cylc index 088c29a3bc1..ea961a672b4 100644 --- a/tests/functional/job-submission/15-garbage-platform-command-2/flow.cylc +++ b/tests/functional/job-submission/15-garbage-platform-command-2/flow.cylc @@ -1,6 +1,6 @@ [scheduler] [[events]] - inactivity timeout = PT20S + inactivity timeout = PT1M abort on inactivity timeout = True stall timeout = PT20S abort on stall timeout = True diff --git a/tests/functional/restart/50-two-flows/flow.cylc b/tests/functional/restart/50-two-flows/flow.cylc index bd9de46c8b4..3d56fe9cd9b 100644 --- a/tests/functional/restart/50-two-flows/flow.cylc +++ b/tests/functional/restart/50-two-flows/flow.cylc @@ -3,7 +3,7 @@ [scheduler] [[events]] - inactivity timeout = PT20S + inactivity timeout = PT1M abort on inactivity timeout = True [scheduling] [[graph]] @@ -20,10 +20,10 @@ [[b, d]] [[c]] script = """ - if ((CYLC_TASK_FLOW_NUMBERS == 1)); then - cylc trigger --flow=new --meta="cheese wizard" \ - "$CYLC_WORKFLOW_ID//1/a" - cylc__job__poll_grep_workflow_log -E "\[1/a/02\(flows=2\):submitted\] => running" - cylc stop $CYLC_WORKFLOW_ID - fi + if ((CYLC_TASK_FLOW_NUMBERS == 1)); then + cylc trigger --flow=new --meta="cheese wizard" \ + "$CYLC_WORKFLOW_ID//1/a" + cylc__job__poll_grep_workflow_log -E "\[1/a/02\(flows=2\):submitted\] => running" + cylc stop $CYLC_WORKFLOW_ID + fi """ diff --git a/tests/functional/restart/59-retart-timeout/flow.cylc b/tests/functional/restart/59-retart-timeout/flow.cylc index 787e2e00f04..9c2d08be054 100644 --- a/tests/functional/restart/59-retart-timeout/flow.cylc +++ b/tests/functional/restart/59-retart-timeout/flow.cylc @@ -1,6 +1,6 @@ [scheduler] [[events]] - inactivity timeout = PT20S + inactivity timeout = PT1M abort on inactivity timeout = True [scheduling] [[graph]] diff --git a/tests/functional/runahead/default-future/flow.cylc b/tests/functional/runahead/default-future/flow.cylc index c78522d7a8c..c925e54be68 100644 --- a/tests/functional/runahead/default-future/flow.cylc +++ b/tests/functional/runahead/default-future/flow.cylc @@ -3,7 +3,7 @@ UTC mode = True allow implicit tasks = True [[events]] - inactivity timeout = PT20S + inactivity timeout = PT1M abort on inactivity timeout = True [scheduling] initial cycle point = 20100101T00 diff --git a/tests/functional/runahead/no_final/flow.cylc b/tests/functional/runahead/no_final/flow.cylc index 3dd1d7e9734..39cc6bc37f5 100644 --- a/tests/functional/runahead/no_final/flow.cylc +++ b/tests/functional/runahead/no_final/flow.cylc @@ -5,7 +5,7 @@ abort on stall timeout = True stall timeout = PT0S abort on inactivity timeout = True - inactivity timeout = PT20S + inactivity timeout = PT1M [scheduling] runahead limit = P1 initial cycle point = 20100101T00 diff --git a/tests/functional/shutdown/08-now1.t b/tests/functional/shutdown/08-now1.t index 01a4eafdeb5..d8a325645da 100755 --- a/tests/functional/shutdown/08-now1.t +++ b/tests/functional/shutdown/08-now1.t @@ -22,7 +22,7 @@ set_test_number 5 install_workflow "${TEST_NAME_BASE}" "${TEST_NAME_BASE}" run_ok "${TEST_NAME_BASE}-validate" cylc validate "${WORKFLOW_NAME}" -workflow_run_ok "${TEST_NAME_BASE}-run" cylc play --no-detach "${WORKFLOW_NAME}" +workflow_run_ok "${TEST_NAME_BASE}-run" cylc play -v --no-detach "${WORKFLOW_NAME}" LOGD="$RUN_DIR/${WORKFLOW_NAME}/log" grep_ok 'INFO - Workflow shutting down - REQUEST(NOW)' "${LOGD}/scheduler/log" JLOGD="${LOGD}/job/1/t1/01" diff --git a/tests/functional/spawn-on-demand/11-hold-not-spawned/flow.cylc b/tests/functional/spawn-on-demand/11-hold-not-spawned/flow.cylc index c47ca3c93c4..d4517f4aec4 100644 --- a/tests/functional/spawn-on-demand/11-hold-not-spawned/flow.cylc +++ b/tests/functional/spawn-on-demand/11-hold-not-spawned/flow.cylc @@ -1,7 +1,7 @@ # Test holding a task that hasn't spawned yet. [scheduler] [[events]] - inactivity timeout = PT20S + inactivity timeout = PT1M abort on inactivity timeout = True [scheduling] [[graph]] diff --git a/tests/functional/spawn-on-demand/15-stop-flow-3/flow.cylc b/tests/functional/spawn-on-demand/15-stop-flow-3/flow.cylc index 4731fee49ea..348e344ac1a 100644 --- a/tests/functional/spawn-on-demand/15-stop-flow-3/flow.cylc +++ b/tests/functional/spawn-on-demand/15-stop-flow-3/flow.cylc @@ -8,7 +8,7 @@ [scheduler] allow implicit tasks = True [[events]] - inactivity timeout = PT20S + inactivity timeout = PT1M abort on inactivity timeout = True [scheduling] [[xtriggers]] From ae6a70daefb6ffe970c39ae88859b48de9cbb3ca Mon Sep 17 00:00:00 2001 From: David Sutherland Date: Tue, 9 Jul 2024 21:32:28 +1200 Subject: [PATCH 100/196] sequential clock spawning fix (#6206) sequential clock spawning fix --------- Co-authored-by: Ronnie Dutta <61982285+MetRonnie@users.noreply.github.com> --- changes.d/6206.fix.md | 1 + cylc/flow/xtrigger_mgr.py | 2 + tests/integration/test_xtrigger_mgr.py | 70 ++++++++++++++++---------- 3 files changed, 47 insertions(+), 26 deletions(-) create mode 100644 changes.d/6206.fix.md diff --git a/changes.d/6206.fix.md b/changes.d/6206.fix.md new file mode 100644 index 00000000000..fef5fb1ec24 --- /dev/null +++ b/changes.d/6206.fix.md @@ -0,0 +1 @@ +Fixes the spawning of multiple parentless tasks off the same sequential wall-clock xtrigger. \ No newline at end of file diff --git a/cylc/flow/xtrigger_mgr.py b/cylc/flow/xtrigger_mgr.py index 4f33ec7f3cc..5ba90738a01 100644 --- a/cylc/flow/xtrigger_mgr.py +++ b/cylc/flow/xtrigger_mgr.py @@ -644,6 +644,8 @@ def call_xtriggers_async(self, itask: 'TaskProxy'): if sig in self.sat_xtrig: # Already satisfied, just update the task itask.state.xtriggers[label] = True + if self.all_task_seq_xtriggers_satisfied(itask): + self.sequential_spawn_next.add(itask.identity) elif _wall_clock(*ctx.func_args, **ctx.func_kwargs): # Newly satisfied itask.state.xtriggers[label] = True diff --git a/tests/integration/test_xtrigger_mgr.py b/tests/integration/test_xtrigger_mgr.py index 3bf425650c4..d04c202bd43 100644 --- a/tests/integration/test_xtrigger_mgr.py +++ b/tests/integration/test_xtrigger_mgr.py @@ -18,8 +18,14 @@ import asyncio from pathlib import Path from textwrap import dedent +from typing import Set from cylc.flow.pathutil import get_workflow_run_dir +from cylc.flow.scheduler import Scheduler + + +def get_task_ids(schd: Scheduler) -> Set[str]: + return {task.identity for task in schd.pool.get_tasks()} async def test_2_xtriggers(flow, start, scheduler, monkeypatch): @@ -38,9 +44,6 @@ async def test_2_xtriggers(flow, start, scheduler, monkeypatch): lambda: ten_years_ahead - 1 ) id_ = flow({ - 'scheduler': { - 'allow implicit tasks': True - }, 'scheduling': { 'initial cycle point': '2020-05-05', 'xtriggers': { @@ -72,31 +75,19 @@ async def test_2_xtriggers(flow, start, scheduler, monkeypatch): } -async def test_1_xtrigger_2_tasks(flow, start, scheduler, monkeypatch, mocker): +async def test_1_xtrigger_2_tasks(flow, start, scheduler, mocker): """ If multiple tasks depend on the same satisfied xtrigger, the DB mgr method - put_xtriggers should only be called once - when the xtrigger gets satisfied. + put_xtriggers should only be called once - when the xtrigger gets satisfied See [GitHub #5908](https://github.com/cylc/cylc-flow/pull/5908) """ - task_point = 1588636800 # 2020-05-05 - ten_years_ahead = 1904169600 # 2030-05-05 - monkeypatch.setattr( - 'cylc.flow.xtriggers.wall_clock.time', - lambda: ten_years_ahead - 1 - ) id_ = flow({ - 'scheduler': { - 'allow implicit tasks': True - }, 'scheduling': { - 'initial cycle point': '2020-05-05', - 'xtriggers': { - 'clock_1': 'wall_clock()', - }, + 'initial cycle point': '2020', 'graph': { - 'R1': '@clock_1 => foo & bar' + 'R1': '@wall_clock => foo & bar' } } }) @@ -112,7 +103,7 @@ async def test_1_xtrigger_2_tasks(flow, start, scheduler, monkeypatch, mocker): schd.xtrigger_mgr.call_xtriggers_async(task) # It should now be satisfied. - assert task.state.xtriggers == {'clock_1': True} + assert task.state.xtriggers == {'wall_clock': True} # Check one put_xtriggers call only, not two. assert spy.call_count == 1 @@ -128,9 +119,6 @@ async def test_xtriggers_restart(flow, start, scheduler, db_select): """It should write xtrigger results to the DB and load them on restart.""" # define a workflow which uses a custom xtrigger id_ = flow({ - 'scheduler': { - 'allow implicit tasks': 'True' - }, 'scheduling': { 'xtriggers': { 'mytrig': 'mytrig()' @@ -194,9 +182,6 @@ async def test_error_in_xtrigger(flow, start, scheduler): """Failure in an xtrigger is handled nicely. """ id_ = flow({ - 'scheduler': { - 'allow implicit tasks': 'True' - }, 'scheduling': { 'xtriggers': { 'mytrig': 'mytrig()' @@ -231,3 +216,36 @@ def mytrig(*args, **kwargs): error = log.messages[-1].split('\n') assert error[-2] == 'Exception: This Xtrigger is broken' assert error[0] == 'ERROR in xtrigger mytrig()' + + +async def test_1_seq_clock_trigger_2_tasks(flow, start, scheduler): + """Test that all tasks dependent on a sequential clock trigger continue to + spawn after the first cycle. + + See https://github.com/cylc/cylc-flow/issues/6204 + """ + id_ = flow({ + 'scheduler': { + 'cycle point format': 'CCYY', + }, + 'scheduling': { + 'initial cycle point': '1990', + 'graph': { + 'P1Y': '@wall_clock => foo & bar', + }, + }, + }) + schd: Scheduler = scheduler(id_) + + async with start(schd): + start_task_pool = get_task_ids(schd) + assert start_task_pool == {'1990/foo', '1990/bar'} + + for _ in range(3): + await schd._main_loop() + + assert get_task_ids(schd) == start_task_pool.union( + f'{year}/{name}' + for year in range(1991, 1994) + for name in ('foo', 'bar') + ) From 29d26cf492c4db4c13242b845e6b1c3543a9aa08 Mon Sep 17 00:00:00 2001 From: David Sutherland Date: Tue, 9 Jul 2024 23:39:49 +1200 Subject: [PATCH 101/196] flow-nums data-store fix (#6115) Replace flow nums for ghost tasks where necessary --- cylc/flow/data_store_mgr.py | 36 +++++++++++++++---- .../cylc-show/06-past-present-future.t | 2 +- 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/cylc/flow/data_store_mgr.py b/cylc/flow/data_store_mgr.py index 0befc1b4dad..46279cfb2bf 100644 --- a/cylc/flow/data_store_mgr.py +++ b/cylc/flow/data_store_mgr.py @@ -792,8 +792,9 @@ def increment_graph_window( source_tokens, point, flow_nums, - False, - itask + is_parent=False, + itask=itask, + replace_existing=True, ) # Pre-populate from previous walks @@ -1153,6 +1154,7 @@ def generate_ghost_task( is_parent: bool = False, itask: Optional['TaskProxy'] = None, n_depth: int = 0, + replace_existing: bool = False, ) -> None: """Create task-point element populated with static data. @@ -1160,17 +1162,19 @@ def generate_ghost_task( source_tokens point flow_nums - is_parent: - Used to determine whether to load DB state. - itask: - Update task-node from corresponding task proxy object. + is_parent: Used to determine whether to load DB state. + itask: Update task-node from corresponding task proxy object. n_depth: n-window graph edge distance. + replace_existing: Replace any existing data for task as it may + be out of date (e.g. flow nums). """ tp_id = tokens.id if ( tp_id in self.data[self.workflow_id][TASK_PROXIES] or tp_id in self.added[TASK_PROXIES] ): + if replace_existing and itask is not None: + self.delta_from_task_proxy(itask) return name = tokens['task'] @@ -2525,6 +2529,26 @@ def delta_task_xtrigger(self, sig, satisfied): xtrigger.time = update_time self.updates_pending = True + def delta_from_task_proxy(self, itask: TaskProxy) -> None: + """Create delta from existing pool task proxy. + + Args: + itask (cylc.flow.task_proxy.TaskProxy): + Update task-node from corresponding task proxy + objects from the workflow task pool. + + """ + tproxy: Optional[PbTaskProxy] + tp_id, tproxy = self.store_node_fetcher(itask.tokens) + if not tproxy: + return + update_time = time() + tp_delta = self.updated[TASK_PROXIES].setdefault( + tp_id, PbTaskProxy(id=tp_id)) + tp_delta.stamp = f'{tp_id}@{update_time}' + self._process_internal_task_proxy(itask, tp_delta) + self.updates_pending = True + # ----------- # Job Deltas # ----------- diff --git a/tests/functional/cylc-show/06-past-present-future.t b/tests/functional/cylc-show/06-past-present-future.t index 7ff9762212d..a67636bc613 100644 --- a/tests/functional/cylc-show/06-past-present-future.t +++ b/tests/functional/cylc-show/06-past-present-future.t @@ -46,7 +46,7 @@ TEST_NAME="${TEST_NAME_BASE}-show.present" contains_ok "${WORKFLOW_RUN_DIR}/show-c.txt" <<__END__ state: running prerequisites: ('⨯': not satisfied) - ✓ 1/b succeeded + ⨯ 1/b succeeded __END__ TEST_NAME="${TEST_NAME_BASE}-show.future" From 954f65e55255ff3e257a67fcf336d506d665909d Mon Sep 17 00:00:00 2001 From: Hilary James Oliver Date: Wed, 10 Jul 2024 10:22:42 +0000 Subject: [PATCH 102/196] `cylc set` bug fix for new flows (#6186) --- changes.d/6186.fix.md | 1 + cylc/flow/scripts/set.py | 6 +- cylc/flow/task_pool.py | 97 ++++++++++++++------------ tests/integration/test_dbstatecheck.py | 3 +- tests/integration/test_task_pool.py | 45 +++++++++++- 5 files changed, 100 insertions(+), 52 deletions(-) create mode 100644 changes.d/6186.fix.md diff --git a/changes.d/6186.fix.md b/changes.d/6186.fix.md new file mode 100644 index 00000000000..ded5517c0e8 --- /dev/null +++ b/changes.d/6186.fix.md @@ -0,0 +1 @@ +Fixed bug where using flow numbers with `cylc set` would not work correctly. \ No newline at end of file diff --git a/cylc/flow/scripts/set.py b/cylc/flow/scripts/set.py index 6c69d7235fd..b64cf74aba0 100755 --- a/cylc/flow/scripts/set.py +++ b/cylc/flow/scripts/set.py @@ -90,7 +90,7 @@ from functools import partial import sys -from typing import Tuple, TYPE_CHECKING +from typing import Iterable, TYPE_CHECKING from cylc.flow.exceptions import InputError from cylc.flow.network.client_factory import get_client @@ -177,7 +177,7 @@ def get_option_parser() -> COP: return parser -def validate_tokens(tokens_list: Tuple['Tokens']) -> None: +def validate_tokens(tokens_list: Iterable['Tokens']) -> None: """Check the cycles/tasks provided. This checks that cycle/task selectors have not been provided in the IDs. @@ -214,7 +214,7 @@ def validate_tokens(tokens_list: Tuple['Tokens']) -> None: async def run( options: 'Values', workflow_id: str, - *tokens_list + *tokens_list: 'Tokens' ): validate_tokens(tokens_list) diff --git a/cylc/flow/task_pool.py b/cylc/flow/task_pool.py index 21893103f86..7bdca66a1c5 100644 --- a/cylc/flow/task_pool.py +++ b/cylc/flow/task_pool.py @@ -1578,8 +1578,8 @@ def can_be_spawned(self, name: str, point: 'PointBase') -> bool: def _get_task_history( self, name: str, point: 'PointBase', flow_nums: Set[int] - ) -> Tuple[bool, int, str, bool]: - """Get history of previous submits for this task. + ) -> Tuple[int, Optional[str], bool]: + """Get submit_num, status, flow_wait for point/name in flow_nums. Args: name: task name @@ -1587,41 +1587,33 @@ def _get_task_history( flow_nums: task flow numbers Returns: - never_spawned: if task never spawned before - submit_num: submit number of previous submit - prev_status: task status of previous sumbit - prev_flow_wait: if previous submit was a flow-wait task + (submit_num, status, flow_wait) + If no matching history, status will be None """ + submit_num: int = 0 + status: Optional[str] = None + flow_wait = False + info = self.workflow_db_mgr.pri_dao.select_prev_instances( name, str(point) ) - try: - submit_num: int = max(s[0] for s in info) - except ValueError: - # never spawned in any flow - submit_num = 0 - never_spawned = True - else: - never_spawned = False - # (submit_num could still be zero, if removed before submit) - - prev_status: str = TASK_STATUS_WAITING - prev_flow_wait = False + with suppress(ValueError): + submit_num = max(s[0] for s in info) - for _snum, f_wait, old_fnums, status in info: + for _snum, f_wait, old_fnums, old_status in info: if set.intersection(flow_nums, old_fnums): # matching flows - prev_status = status - prev_flow_wait = f_wait - if prev_status in TASK_STATUSES_FINAL: + status = old_status + flow_wait = f_wait + if status in TASK_STATUSES_FINAL: # task finished break # Else continue: there may be multiple entries with flow # overlap due to merges (they'll have have same snum and # f_wait); keep going to find the finished one, if any. - return never_spawned, submit_num, prev_status, prev_flow_wait + return submit_num, status, flow_wait def _load_historical_outputs(self, itask: 'TaskProxy') -> None: """Load a task's historical outputs from the DB.""" @@ -1631,8 +1623,11 @@ def _load_historical_outputs(self, itask: 'TaskProxy') -> None: # task never ran before self.db_add_new_flow_rows(itask) else: + flow_seen = False for outputs_str, fnums in info.items(): if itask.flow_nums.intersection(fnums): + # DB row has overlap with itask's flows + flow_seen = True # BACK COMPAT: In Cylc >8.0.0,<8.3.0, only the task # messages were stored in the DB as a list. # from: 8.0.0 @@ -1649,6 +1644,9 @@ def _load_historical_outputs(self, itask: 'TaskProxy') -> None: # [message] - always the full task message for msg in outputs: itask.state.outputs.set_message_complete(msg) + if not flow_seen: + # itask never ran before in its assigned flows + self.db_add_new_flow_rows(itask) def spawn_task( self, @@ -1658,44 +1656,52 @@ def spawn_task( force: bool = False, flow_wait: bool = False, ) -> Optional[TaskProxy]: - """Return task proxy if not completed in this flow, or if forced. + """Return a new task proxy for the given flow if possible. - If finished previously with flow wait, just try to spawn children. + We need to hit the DB for: + - submit number + - task status + - flow-wait + - completed outputs (e.g. via "cylc set") - Note finished tasks may be incomplete, but we don't automatically - re-run incomplete tasks in the same flow. + If history records a final task status (for this flow): + - if not flow wait, don't spawn (return None) + - if flow wait, don't spawn (return None) but do spawn children + - if outputs are incomplete, don't auto-rerun it (return None) - For every task spawned, we need a DB lookup for submit number, - and flow-wait. + Otherwise, spawn the task and load any completed outputs. """ - if not self.can_be_spawned(name, point): - return None - - never_spawned, submit_num, prev_status, prev_flow_wait = ( + submit_num, prev_status, prev_flow_wait = ( self._get_task_history(name, point, flow_nums) ) - if ( - not never_spawned and - not prev_flow_wait and - submit_num == 0 - ): - # Previous instance removed before completing any outputs. - LOG.debug(f"Not spawning {point}/{name} - task removed") - return None - + # Create the task proxy with any completed outputs loaded. itask = self._get_task_proxy_db_outputs( point, self.config.get_taskdef(name), flow_nums, - status=prev_status, + status=prev_status or TASK_STATUS_WAITING, submit_num=submit_num, flow_wait=flow_wait, ) if itask is None: return None + if ( + prev_status is not None + and not itask.state.outputs.get_completed_outputs() + ): + # If itask has any history in this flow but no completed outputs + # we can infer it was deliberately removed, so don't respawn it. + # TODO (follow-up work): + # - this logic fails if task removed after some outputs completed + # - this is does not conform to future "cylc remove" flow-erasure + # behaviour which would result in respawning of the removed task + # See github.com/cylc/cylc-flow/pull/6186/#discussion_r1669727292 + LOG.debug(f"Not respawning {point}/{name} - task was removed") + return None + if prev_status in TASK_STATUSES_FINAL: # Task finished previously. msg = f"[{point}/{name}:{prev_status}] already finished" @@ -1878,7 +1884,6 @@ def set_prereqs_and_outputs( - future tasks must be specified individually - family names are not expanded to members - Uses a transient task proxy to spawn children. (Even if parent was previously spawned in this flow its children might not have been). @@ -1963,6 +1968,7 @@ def _set_outputs_itask( self.data_store_mgr.delta_task_outputs(itask) self.workflow_db_mgr.put_update_task_state(itask) self.workflow_db_mgr.put_update_task_outputs(itask) + self.workflow_db_mgr.process_queued_ops() def _set_prereqs_itask( self, @@ -2168,10 +2174,9 @@ def force_trigger_tasks( if not self.can_be_spawned(name, point): continue - _, submit_num, _prev_status, prev_fwait = ( + submit_num, _, prev_fwait = ( self._get_task_history(name, point, flow_nums) ) - itask = TaskProxy( self.tokens, self.config.get_taskdef(name), diff --git a/tests/integration/test_dbstatecheck.py b/tests/integration/test_dbstatecheck.py index 08fd59aa0e4..3aa2ad9d584 100644 --- a/tests/integration/test_dbstatecheck.py +++ b/tests/integration/test_dbstatecheck.py @@ -73,7 +73,7 @@ def test_basic(checker): ['output', '10000101T0000Z', 'succeeded'], ['output', '10010101T0000Z', 'succeeded'], ['good', '10000101T0000Z', 'waiting', '(flows=2)'], - ] + ['good', '10010101T0000Z', 'waiting', '(flows=2)'], ] assert result == expect @@ -131,5 +131,6 @@ def test_flownum(checker): result = checker.workflow_state_query(flow_num=2) expect = [ ['good', '10000101T0000Z', 'waiting', '(flows=2)'], + ['good', '10010101T0000Z', 'waiting', '(flows=2)'], ] assert result == expect diff --git a/tests/integration/test_task_pool.py b/tests/integration/test_task_pool.py index 183532e0609..a7d205c1350 100644 --- a/tests/integration/test_task_pool.py +++ b/tests/integration/test_task_pool.py @@ -1893,7 +1893,7 @@ async def test_fast_respawn( # attempt to spawn it again itask = task_pool.spawn_task("foo", IntegerPoint("1"), {1}) assert itask is None - assert "Not spawning 1/foo - task removed" in caplog.text + assert "Not respawning 1/foo - task was removed" in caplog.text async def test_remove_active_task( @@ -2019,9 +2019,50 @@ async def test_remove_no_respawn(flow, scheduler, start, log_filter): # respawned as a result schd.pool.spawn_on_output(b1, TASK_OUTPUT_SUCCEEDED) assert log_filter( - log, contains='Not spawning 1/z - task removed' + log, contains='Not respawning 1/z - task was removed' ) z1 = schd.pool.get_task(IntegerPoint("1"), "z") assert ( z1 is None ), '1/z should have stayed removed (but has been added back into the pool' + + +async def test_set_future_flow(flow, scheduler, start, log_filter): + """Manually-set outputs for new flow num must be recorded in the DB. + + See https://github.com/cylc/cylc-flow/pull/6186 + + To trigger the bug, the flow must be new but the task must have been + spawned before in an earlier flow. + + """ + # Scenario: after flow 1, set c1:succeeded in a future flow so + # when b succeeds in the new flow it will spawn c2 but not c1. + id_ = flow({ + 'scheduler': { + 'allow implicit tasks': True + }, + 'scheduling': { + 'cycling mode': 'integer', + 'graph': { + 'R1': 'b => c1 & c2', + }, + }, + }) + schd: 'Scheduler' = scheduler(id_) + async with start(schd, level=logging.DEBUG) as log: + + assert schd.pool.get_task(IntegerPoint("1"), "b") is not None, '1/b should be spawned on startup' + + # set b, c1, c2 succeeded in flow 1 + schd.pool.set_prereqs_and_outputs(['1/b', '1/c1', '1/c2'], prereqs=[], outputs=[], flow=[1]) + schd.workflow_db_mgr.process_queued_ops() + + # set task c1:succeeded in flow 2 + schd.pool.set_prereqs_and_outputs(['1/c1'], prereqs=[], outputs=[], flow=[2]) + schd.workflow_db_mgr.process_queued_ops() + + # set b:succeeded in flow 2 and check downstream spawning + schd.pool.set_prereqs_and_outputs(['1/b'], prereqs=[], outputs=[], flow=[2]) + assert schd.pool.get_task(IntegerPoint("1"), "c1") is None, '1/c1 (flow 2) should not be spawned after 1/b:succeeded' + assert schd.pool.get_task(IntegerPoint("1"), "c2") is not None, '1/c2 (flow 2) should be spawned after 1/b:succeeded' From 87ef3a056c73937a3d55cbe1e133b34305ff370b Mon Sep 17 00:00:00 2001 From: Tim Pillinger <26465611+wxtim@users.noreply.github.com> Date: Wed, 10 Jul 2024 11:32:56 +0100 Subject: [PATCH 103/196] fix towncrier message (#6215) --- changes.d/{fix.6178.md => 6178.fix.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changes.d/{fix.6178.md => 6178.fix.md} (100%) diff --git a/changes.d/fix.6178.md b/changes.d/6178.fix.md similarity index 100% rename from changes.d/fix.6178.md rename to changes.d/6178.fix.md From a4c462fa73fbff4461665e388512c43da854633d Mon Sep 17 00:00:00 2001 From: Oliver Sanders Date: Wed, 10 Jul 2024 11:56:17 +0100 Subject: [PATCH 104/196] tests/i: remove sleep (#6212) Replace a sleep with a call to update the database. --- tests/integration/test_dbstatecheck.py | 29 +++++++++++++++----------- 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/tests/integration/test_dbstatecheck.py b/tests/integration/test_dbstatecheck.py index 3aa2ad9d584..94de81fbef0 100644 --- a/tests/integration/test_dbstatecheck.py +++ b/tests/integration/test_dbstatecheck.py @@ -16,10 +16,7 @@ """Tests for the backend method of workflow_state""" - -from asyncio import sleep import pytest -from textwrap import dedent from cylc.flow.dbstatecheck import CylcWorkflowDBChecker from cylc.flow.scheduler import Scheduler @@ -36,13 +33,15 @@ async def checker( """ wid = mod_flow({ 'scheduling': { - 'graph': {'P1Y': dedent(''' - good:succeeded - bad:failed? - output:custom_output - ''')}, 'initial cycle point': '1000', - 'final cycle point': '1001' + 'final cycle point': '1001', + 'graph': { + 'P1Y': ''' + good:succeeded + bad:failed? + output:custom_output + ''' + }, }, 'runtime': { 'bad': {'simulation': {'fail cycle points': '1000'}}, @@ -51,11 +50,17 @@ async def checker( }) schd: Scheduler = mod_scheduler(wid, paused_start=False) async with mod_run(schd): + # allow a cycle of the main loop to pass so that flow 2 can be + # added to db await mod_complete(schd) + + # trigger a new task in flow 2 schd.pool.force_trigger_tasks(['1000/good'], ['2']) - # Allow a cycle of the main loop to pass so that flow 2 can be - # added to db - await sleep(1) + + # update the database + schd.process_workflow_db_queue() + + # yield a DB checker with CylcWorkflowDBChecker( 'somestring', 'utterbunkum', schd.workflow_db_mgr.pub_path ) as _checker: From 74bbdbf6c6d0714914ecd41a15a83cef85bbfe89 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 10 Jul 2024 11:31:55 +0000 Subject: [PATCH 105/196] Prepare release 8.3.2 Workflow: Release stage 1 - create release PR (Cylc 8+ only), run: 41 --- CHANGES.md | 12 ++++++++++++ changes.d/6178.fix.md | 1 - changes.d/6186.fix.md | 1 - changes.d/6200.fix.md | 1 - changes.d/6206.fix.md | 1 - cylc/flow/__init__.py | 2 +- 6 files changed, 13 insertions(+), 5 deletions(-) delete mode 100644 changes.d/6178.fix.md delete mode 100644 changes.d/6186.fix.md delete mode 100644 changes.d/6200.fix.md delete mode 100644 changes.d/6206.fix.md diff --git a/CHANGES.md b/CHANGES.md index 1edbb2b04d6..061d935a010 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -11,6 +11,18 @@ $ towncrier create ..md --content "Short description" +## __cylc-8.3.2 (Released 2024-07-10)__ + +### 🔧 Fixes + +[#6178](https://github.com/cylc/cylc-flow/pull/6178) - Fix an issue where Tui could hang when closing. + +[#6186](https://github.com/cylc/cylc-flow/pull/6186) - Fixed bug where using flow numbers with `cylc set` would not work correctly. + +[#6200](https://github.com/cylc/cylc-flow/pull/6200) - Fixed bug where a stalled paused workflow would be incorrectly reported as running, not paused + +[#6206](https://github.com/cylc/cylc-flow/pull/6206) - Fixes the spawning of multiple parentless tasks off the same sequential wall-clock xtrigger. + ## __cylc-8.3.1 (Released 2024-07-04)__ ### 🔧 Fixes diff --git a/changes.d/6178.fix.md b/changes.d/6178.fix.md deleted file mode 100644 index 7d1b9b0f3f6..00000000000 --- a/changes.d/6178.fix.md +++ /dev/null @@ -1 +0,0 @@ -Fix an issue where Tui could hang when closing. diff --git a/changes.d/6186.fix.md b/changes.d/6186.fix.md deleted file mode 100644 index ded5517c0e8..00000000000 --- a/changes.d/6186.fix.md +++ /dev/null @@ -1 +0,0 @@ -Fixed bug where using flow numbers with `cylc set` would not work correctly. \ No newline at end of file diff --git a/changes.d/6200.fix.md b/changes.d/6200.fix.md deleted file mode 100644 index 3b4cf8012cf..00000000000 --- a/changes.d/6200.fix.md +++ /dev/null @@ -1 +0,0 @@ -Fixed bug where a stalled paused workflow would be incorrectly reported as running, not paused \ No newline at end of file diff --git a/changes.d/6206.fix.md b/changes.d/6206.fix.md deleted file mode 100644 index fef5fb1ec24..00000000000 --- a/changes.d/6206.fix.md +++ /dev/null @@ -1 +0,0 @@ -Fixes the spawning of multiple parentless tasks off the same sequential wall-clock xtrigger. \ No newline at end of file diff --git a/cylc/flow/__init__.py b/cylc/flow/__init__.py index 2bb0860793c..9bd0a272457 100644 --- a/cylc/flow/__init__.py +++ b/cylc/flow/__init__.py @@ -53,7 +53,7 @@ def environ_init(): environ_init() -__version__ = '8.3.2.dev' +__version__ = '8.3.2' def iter_entry_points(entry_point_name): From 75971ea61c091c50307ad89e3cfa11ee4d28f9ad Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 10 Jul 2024 11:43:43 +0000 Subject: [PATCH 106/196] Bump dev version Workflow: Release stage 2 - auto publish, run: 83 --- cylc/flow/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cylc/flow/__init__.py b/cylc/flow/__init__.py index 9bd0a272457..5112d295c05 100644 --- a/cylc/flow/__init__.py +++ b/cylc/flow/__init__.py @@ -53,7 +53,7 @@ def environ_init(): environ_init() -__version__ = '8.3.2' +__version__ = '8.3.3.dev' def iter_entry_points(entry_point_name): From c734e7dd4e94bfa8da3a9659bdadddcb32ccc404 Mon Sep 17 00:00:00 2001 From: Oliver Sanders Date: Thu, 11 Jul 2024 18:11:47 +0100 Subject: [PATCH 107/196] tests/f: make shutdown/08 more explicit (#6185) * Test relied on an action occurring within an arbitrary sleep period which is potentially flaky. * Replaced the sleep with a poller to make it more reliable. --- tests/functional/shutdown/08-now1/flow.cylc | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/tests/functional/shutdown/08-now1/flow.cylc b/tests/functional/shutdown/08-now1/flow.cylc index 793d4427ca4..ccf416f6faf 100644 --- a/tests/functional/shutdown/08-now1/flow.cylc +++ b/tests/functional/shutdown/08-now1/flow.cylc @@ -7,12 +7,26 @@ [scheduling] [[graph]] - R1 = t1:finish => t2 + R1 = t1 => t2 [runtime] [[t1]] - script = cylc__job__wait_cylc_message_started; cylc stop --now "${CYLC_WORKFLOW_ID}" + script = """ + # wait for the started message to be sent + cylc__job__wait_cylc_message_started; + # issue the stop command + cylc stop --now "${CYLC_WORKFLOW_ID}" + # wait for the stop command to be recieved + cylc__job__poll_grep_workflow_log 'Command "stop" received' + # send a message telling the started handler to exit + cylc message -- stopping + """ [[[events]]] - started handlers = sleep 10 && echo 'Hello %(id)s %(event)s' + # wait for the stopping message, sleep a bit, then echo some stuff + started handlers = """ + cylc workflow-state %(workflow)s//%(point)s/%(name)s:stopping >/dev/null && sleep 1 && echo 'Hello %(id)s %(event)s' + """ + [[[outputs]]] + stopping = stopping [[t2]] script = true From ae2b3d77697f1b10ea8064571aac6c977f592ed0 Mon Sep 17 00:00:00 2001 From: Oliver Sanders Date: Fri, 12 Jul 2024 12:48:18 +0100 Subject: [PATCH 108/196] install: fix colour formatting * Closes #6227 --- cylc/flow/scripts/install.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cylc/flow/scripts/install.py b/cylc/flow/scripts/install.py index d81dcb2ca0c..c4cea8fd319 100755 --- a/cylc/flow/scripts/install.py +++ b/cylc/flow/scripts/install.py @@ -234,7 +234,7 @@ async def scan(wf_name: str, ping: bool = True) -> None: if n > 1 else ["", "is", "it"] ) - print( + cprint( CylcLogFormatter.COLORS['WARNING'].format( f'NOTE: {n} run%s of "{wf_name}"' ' %s already active:' % tuple(grammar[:2]) From fdd503ce271138eaf09a5d77e3e10724f9581394 Mon Sep 17 00:00:00 2001 From: Oliver Sanders Date: Fri, 12 Jul 2024 13:27:07 +0100 Subject: [PATCH 109/196] runahead: handle a workflow with an empty graph * Closes #6225 --- cylc/flow/task_pool.py | 15 +++++++++------ tests/integration/test_task_pool.py | 20 ++++++++++++++++++++ 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/cylc/flow/task_pool.py b/cylc/flow/task_pool.py index 7bdca66a1c5..f1a6942f0ab 100644 --- a/cylc/flow/task_pool.py +++ b/cylc/flow/task_pool.py @@ -319,12 +319,15 @@ def compute_runahead(self, force=False) -> bool: if not self.active_tasks: # Find the earliest sequence point beyond the workflow start point. base_point = min( - point - for point in { - seq.get_first_point(self.config.start_point) - for seq in self.config.sequences - } - if point is not None + ( + point + for point in { + seq.get_first_point(self.config.start_point) + for seq in self.config.sequences + } + if point is not None + ), + default=None, ) else: # Find the earliest point with incomplete tasks. diff --git a/tests/integration/test_task_pool.py b/tests/integration/test_task_pool.py index a7d205c1350..25bd1978f77 100644 --- a/tests/integration/test_task_pool.py +++ b/tests/integration/test_task_pool.py @@ -1750,6 +1750,26 @@ async def test_compute_runahead( assert int(str(schd.pool.runahead_limit_point)) == 5 # +1 +async def test_compute_runahead_with_no_tasks(flow, scheduler, run): + """It should handle the case of an empty workflow. + + See https://github.com/cylc/cylc-flow/issues/6225 + """ + id_ = flow( + { + 'scheduling': { + 'initial cycle point': '2000', + 'graph': {'R1': 'foo'}, + }, + } + ) + schd = scheduler(id_, startcp='2002', paused_start=False) + async with run(schd): + assert schd.pool.compute_runahead() is False + assert schd.pool.runahead_limit_point is None + assert schd.pool.get_tasks() == [] + + @pytest.mark.parametrize('rhlimit', ['P2D', 'P2']) @pytest.mark.parametrize('compat_mode', ['compat-mode', 'normal-mode']) async def test_runahead_future_trigger( From 95977862ea50dbbbac51e963bdcd16f3dc5939e8 Mon Sep 17 00:00:00 2001 From: Oliver Sanders Date: Mon, 15 Jul 2024 13:55:28 +0100 Subject: [PATCH 110/196] stop: remove reference to Cylc 7 --mode option --- cylc/flow/scripts/stop.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cylc/flow/scripts/stop.py b/cylc/flow/scripts/stop.py index d49a47aea1e..ebdb6380cd7 100755 --- a/cylc/flow/scripts/stop.py +++ b/cylc/flow/scripts/stop.py @@ -40,7 +40,8 @@ $ cylc stop my_workflow//1234/foo By default stopping workflows wait for submitted and running tasks to complete -before shutting down. You can change this behaviour with the --mode option. +before shutting down. You can change this behaviour with the --now or +--kill options. There are several shutdown methods: From fbcd902cab5267c6faae679d0f32c3582ef4f632 Mon Sep 17 00:00:00 2001 From: Oliver Sanders Date: Mon, 15 Jul 2024 14:44:14 +0100 Subject: [PATCH 111/196] clean: change DB corrpution advice * Reccommend using `--local-only` rather than removing the DB. --- cylc/flow/clean.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/cylc/flow/clean.py b/cylc/flow/clean.py index aec2c4efeb4..b38f01b12fc 100644 --- a/cylc/flow/clean.py +++ b/cylc/flow/clean.py @@ -182,7 +182,9 @@ def init_clean(id_: str, opts: 'Values') -> None: ' this version of "cylc clean".' '\nTry using the version of Cylc the workflow was last ran' ' with to remove it.' - '\nOtherwise please delete the database file.' + '\nOtherwise, use the "--local-only" option to remove' + ' local files (you may need to remove files on other' + ' platforms manually).' ) raise ServiceFileError(f"Cannot clean {id_} - {exc}") From 6858181c71559e9cf89c0c0a46de7960f728e2f9 Mon Sep 17 00:00:00 2001 From: Oliver Sanders Date: Mon, 15 Jul 2024 15:11:13 +0100 Subject: [PATCH 112/196] messages: enforce unique task messages * Closes https://github.com/cylc/cylc-flow/issues/6056 --- cylc/flow/config.py | 9 ++++++ tests/integration/validate/test_outputs.py | 34 ++++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/cylc/flow/config.py b/cylc/flow/config.py index 5b7738f3e6c..d29537ce7ab 100644 --- a/cylc/flow/config.py +++ b/cylc/flow/config.py @@ -2434,9 +2434,18 @@ def get_taskdef( raise WorkflowConfigError(str(exc)) else: # Record custom message outputs from [runtime]. + messages = set(self.cfg['runtime'][name]['outputs'].values()) for output, message in ( self.cfg['runtime'][name]['outputs'].items() ): + try: + messages.remove(message) + except KeyError: + raise WorkflowConfigError( + 'Duplicate task message in' + f' "[runtime][{name}][outputs]' + f'{output} = {message}" - messages must be unique' + ) valid, msg = TaskOutputValidator.validate(output) if not valid: raise WorkflowConfigError( diff --git a/tests/integration/validate/test_outputs.py b/tests/integration/validate/test_outputs.py index b26bce529fb..1a8177cd35b 100644 --- a/tests/integration/validate/test_outputs.py +++ b/tests/integration/validate/test_outputs.py @@ -339,3 +339,37 @@ def test_completion_expression_cylc7_compat( match="completion cannot be used in Cylc 7 compatibility mode." ): validate(id_) + + +def test_unique_messages( + flow, + validate +): + """Task messages must be unique in the [outputs] section. + + See: https://github.com/cylc/cylc-flow/issues/6056 + """ + id_ = flow({ + 'scheduling': { + 'graph': {'R1': 'foo'} + }, + 'runtime': { + 'foo': { + 'outputs': { + 'a': 'foo', + 'b': 'bar', + 'c': 'baz', + 'd': 'foo', + } + }, + } + }) + + with pytest.raises( + WorkflowConfigError, + match=( + r'"\[runtime\]\[foo\]\[outputs\]d = foo"' + ' - messages must be unique' + ), + ): + validate(id_) From 5e6e741f9c135b09a1bec7761b3d0cbc1917fabc Mon Sep 17 00:00:00 2001 From: Oliver Sanders Date: Tue, 14 May 2024 13:54:34 +0100 Subject: [PATCH 113/196] data store: include absolute graph edges * Closes #5845 --- 6103.fix.md | 2 ++ cylc/flow/taskdef.py | 6 +---- tests/integration/test_data_store_mgr.py | 34 ++++++++++++++++++++++++ 3 files changed, 37 insertions(+), 5 deletions(-) create mode 100644 6103.fix.md diff --git a/6103.fix.md b/6103.fix.md new file mode 100644 index 00000000000..e969f9ae2bf --- /dev/null +++ b/6103.fix.md @@ -0,0 +1,2 @@ +Absolute dependencies (dependenies on tasks in a specified cycle rather than at a specified offset) are now visible in the GUI beyond the specified cycle. + diff --git a/cylc/flow/taskdef.py b/cylc/flow/taskdef.py index 68f754277d8..448844c8cc3 100644 --- a/cylc/flow/taskdef.py +++ b/cylc/flow/taskdef.py @@ -101,11 +101,7 @@ def generate_graph_parents(tdef, point, taskdefs): # where (point -Px) does not land on a valid point for woo. # TODO ideally validation would flag this as an error. continue - is_abs = (trigger.offset_is_absolute or - trigger.offset_is_from_icp) - if is_abs and parent_point != point: - # If 'foo[^] => bar' only spawn off of '^'. - continue + is_abs = trigger.offset_is_absolute or trigger.offset_is_from_icp graph_parents[seq].append((parent_name, parent_point, is_abs)) if tdef.sequential: diff --git a/tests/integration/test_data_store_mgr.py b/tests/integration/test_data_store_mgr.py index 4d808bacc0a..906b1ac052d 100644 --- a/tests/integration/test_data_store_mgr.py +++ b/tests/integration/test_data_store_mgr.py @@ -18,6 +18,7 @@ from typing import TYPE_CHECKING from cylc.flow.data_store_mgr import ( + EDGES, FAMILY_PROXIES, JOBS, TASKS, @@ -316,3 +317,36 @@ def test_delta_task_prerequisite(harness): p.satisfied for t in schd.data_store_mgr.updated[TASK_PROXIES].values() for p in t.prerequisites}) + + +async def test_absolute_graph_edges(flow, scheduler, start): + """It should add absolute graph edges to the store. + + See: https://github.com/cylc/cylc-flow/issues/5845 + """ + runahead_cycles = 1 + id_ = flow({ + 'scheduling': { + 'initial cycle point': '1', + 'cycling mode': 'integer', + 'runahead limit': f'P{runahead_cycles}', + 'graph': { + 'R1': 'build', + 'P1': 'build[^] => run', + }, + }, + }) + schd = scheduler(id_) + + async with start(schd): + await schd.update_data_structure() + + assert { + (Tokens(edge.source).relative_id, Tokens(edge.target).relative_id) + for edge in schd.data_store_mgr.data[schd.id][EDGES].values() + } == { + ('1/build', f'{cycle}/run') + # +1 for Python's range() + # +2 for Cylc's runahead + for cycle in range(1, runahead_cycles + 3) + } From 0c6314b0004559e7a8ad3cd0625b835627b6b113 Mon Sep 17 00:00:00 2001 From: David Sutherland Date: Wed, 17 Jul 2024 17:26:44 +1200 Subject: [PATCH 114/196] add share/bin to env path --- cylc/flow/config.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/cylc/flow/config.py b/cylc/flow/config.py index 5b7738f3e6c..b03a8f5358a 100644 --- a/cylc/flow/config.py +++ b/cylc/flow/config.py @@ -1723,8 +1723,21 @@ def process_config_env(self): os.environ['CYLC_CYCLING_MODE'] = self.cfg['scheduling'][ 'cycling mode'] # Add workflow bin directory to PATH for workflow and event handlers - os.environ['PATH'] = os.pathsep.join([ - os.path.join(self.fdir, 'bin'), os.environ['PATH']]) + if self.share_dir is not None: + os.environ['PATH'] = os.pathsep.join( + [ + os.path.join(self.share_dir, 'bin'), + os.path.join(self.fdir, 'bin'), + os.environ['PATH'] + ] + ) + else: + os.environ['PATH'] = os.pathsep.join( + [ + os.path.join(self.fdir, 'bin'), + os.environ['PATH'] + ] + ) def run_mode(self) -> str: """Return the run mode.""" From d97c1421ecbb481cc117fa37ea845ef1961aeb75 Mon Sep 17 00:00:00 2001 From: David Sutherland Date: Thu, 18 Jul 2024 16:07:09 +1200 Subject: [PATCH 115/196] change log entry added --- changes.d/6242.fix.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changes.d/6242.fix.md diff --git a/changes.d/6242.fix.md b/changes.d/6242.fix.md new file mode 100644 index 00000000000..87edde1efab --- /dev/null +++ b/changes.d/6242.fix.md @@ -0,0 +1 @@ +Put `share/bin` in the `PATH` of scheduler environment, event handlers therein will now be found. \ No newline at end of file From 48fed325cc03ebe3c086b3b587d67bcbb4955e00 Mon Sep 17 00:00:00 2001 From: Oliver Sanders Date: Thu, 18 Jul 2024 16:21:41 +0100 Subject: [PATCH 116/196] Update 6103.fix.md Co-authored-by: Tim Pillinger <26465611+wxtim@users.noreply.github.com> --- 6103.fix.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/6103.fix.md b/6103.fix.md index e969f9ae2bf..47844cd1ea6 100644 --- a/6103.fix.md +++ b/6103.fix.md @@ -1,2 +1,2 @@ -Absolute dependencies (dependenies on tasks in a specified cycle rather than at a specified offset) are now visible in the GUI beyond the specified cycle. +Absolute dependencies (dependencies on tasks in a specified cycle rather than at a specified offset) are now visible in the GUI beyond the specified cycle. From c029d6be35c8f6f4961e6d16150dcd267d1e1aec Mon Sep 17 00:00:00 2001 From: Hilary James Oliver Date: Fri, 19 Jul 2024 14:02:07 +0000 Subject: [PATCH 117/196] flow merge for force-triggered n=0 (e.g. queued) tasks (#6241) * flow merge for force-triggered n= (e.g. queued) tasks * Add change log entry. * Added an integration test. * Update tests/integration/test_task_pool.py Co-authored-by: Oliver Sanders --------- Co-authored-by: Oliver Sanders --- changes.d/6241.fix.md | 1 + cylc/flow/data_store_mgr.py | 19 +++++++++++++++++++ cylc/flow/task_pool.py | 10 ++++++++-- tests/integration/test_task_pool.py | 23 +++++++++++++++++++++++ 4 files changed, 51 insertions(+), 2 deletions(-) create mode 100644 changes.d/6241.fix.md diff --git a/changes.d/6241.fix.md b/changes.d/6241.fix.md new file mode 100644 index 00000000000..13bd11925dd --- /dev/null +++ b/changes.d/6241.fix.md @@ -0,0 +1 @@ +Allow flow-merge when triggering n=0 tasks. diff --git a/cylc/flow/data_store_mgr.py b/cylc/flow/data_store_mgr.py index 46279cfb2bf..b98a055f882 100644 --- a/cylc/flow/data_store_mgr.py +++ b/cylc/flow/data_store_mgr.py @@ -2367,6 +2367,25 @@ def delta_task_queued(self, itask: TaskProxy) -> None: self.state_update_families.add(tproxy.first_parent) self.updates_pending = True + def delta_task_flow_nums(self, itask: TaskProxy) -> None: + """Create delta for change in task proxy flow_nums. + + Args: + itask (cylc.flow.task_proxy.TaskProxy): + Update task-node from corresponding task proxy + objects from the workflow task pool. + + """ + tproxy: Optional[PbTaskProxy] + tp_id, tproxy = self.store_node_fetcher(itask.tokens) + if not tproxy: + return + tp_delta = self.updated[TASK_PROXIES].setdefault( + tp_id, PbTaskProxy(id=tp_id)) + tp_delta.stamp = f'{tp_id}@{time()}' + tp_delta.flow_nums = serialise_set(itask.flow_nums) + self.updates_pending = True + def delta_task_runahead(self, itask: TaskProxy) -> None: """Create delta for change in task proxy runahead state. diff --git a/cylc/flow/task_pool.py b/cylc/flow/task_pool.py index f1a6942f0ab..9c27dcd232d 100644 --- a/cylc/flow/task_pool.py +++ b/cylc/flow/task_pool.py @@ -1387,6 +1387,7 @@ def spawn_on_output(self, itask, output, forced=False): ).relative_id c_task = self._get_task_by_id(c_taskid) + in_pool = c_task is not None if c_task is not None and c_task != itask: # (Avoid self-suicide: A => !A) @@ -1411,10 +1412,12 @@ def spawn_on_output(self, itask, output, forced=False): tasks.append(c_task) else: tasks = [c_task] + for t in tasks: t.satisfy_me([itask.tokens.duplicate(task_sel=output)]) self.data_store_mgr.delta_task_prerequisite(t) - self.add_to_pool(t) + if not in_pool: + self.add_to_pool(t) if t.point <= self.runahead_limit_point: self.rh_release_and_queue(t) @@ -2169,6 +2172,7 @@ def force_trigger_tasks( if itask.state(TASK_STATUS_PREPARING, *TASK_STATUSES_ACTIVE): LOG.warning(f"[{itask}] ignoring trigger - already active") continue + self.merge_flows(itask, flow_nums) self._force_trigger(itask) # Spawn and trigger future tasks. @@ -2471,7 +2475,7 @@ def merge_flows(self, itask: TaskProxy, flow_nums: 'FlowNums') -> None: if not flow_nums or (flow_nums == itask.flow_nums): # Don't do anything if: # 1. merging from a no-flow task, or - # 2. trying to spawn the same task in the same flow. This arises + # 2. same flow (no merge needed); can arise # downstream of an AND trigger (if "A & B => C" # and A spawns C first, B will find C is already in the pool), # and via suicide triggers ("A =>!A": A tries to spawn itself). @@ -2480,6 +2484,8 @@ def merge_flows(self, itask: TaskProxy, flow_nums: 'FlowNums') -> None: merge_with_no_flow = not itask.flow_nums itask.merge_flows(flow_nums) + self.data_store_mgr.delta_task_flow_nums(itask) + # Merged tasks get a new row in the db task_states table. self.db_add_new_flow_rows(itask) diff --git a/tests/integration/test_task_pool.py b/tests/integration/test_task_pool.py index 25bd1978f77..beba9075bd3 100644 --- a/tests/integration/test_task_pool.py +++ b/tests/integration/test_task_pool.py @@ -2086,3 +2086,26 @@ async def test_set_future_flow(flow, scheduler, start, log_filter): schd.pool.set_prereqs_and_outputs(['1/b'], prereqs=[], outputs=[], flow=[2]) assert schd.pool.get_task(IntegerPoint("1"), "c1") is None, '1/c1 (flow 2) should not be spawned after 1/b:succeeded' assert schd.pool.get_task(IntegerPoint("1"), "c2") is not None, '1/c2 (flow 2) should be spawned after 1/b:succeeded' + + +async def test_trigger_queue(one, run, db_select, complete): + """It should handle triggering tasks in the queued state. + + Triggering a queued task with a new flow number should result in the + task running with merged flow numbers. + + See https://github.com/cylc/cylc-flow/pull/6241 + """ + async with run(one): + # the workflow should start up with one task in the original flow + task = one.pool.get_tasks()[0] + assert task.state(TASK_STATUS_WAITING, is_queued=True) + assert task.flow_nums == {1} + + # trigger this task even though is already queued in flow 1 + one.pool.force_trigger_tasks([task.identity], '2') + + # the merged flow should continue + one.resume_workflow() + await complete(one, timeout=2) + assert db_select(one, False, 'task_outputs', 'flow_nums') == [('[1, 2]',), ('[1]',)] From c5db3488ce30dac7a994479e1e7991ed51320b79 Mon Sep 17 00:00:00 2001 From: Oliver Sanders Date: Fri, 19 Jul 2024 13:38:46 +0100 Subject: [PATCH 118/196] glbl_cfg: reload without mutating the original * Use the new `reload` kwarg rather than calling the `.load()` method. * Fixes https://github.com/cylc/cylc-flow/issues/6244 * The `.load()` method mutates the existing config, due to the use of logging within (and outside of) this routine and the use of `glbl_cfg` in the logging, this created a race condition. * The new `reload` kwarg creates a new config instance, then sets this as the default. --- changes.d/6249.fix.md | 1 + cylc/flow/cfgspec/glbl_cfg.py | 4 +- cylc/flow/cfgspec/globalcfg.py | 35 +++++++------ tests/integration/test_config.py | 90 ++++++++++++++++++++++++++++++++ 4 files changed, 113 insertions(+), 17 deletions(-) create mode 100644 changes.d/6249.fix.md diff --git a/changes.d/6249.fix.md b/changes.d/6249.fix.md new file mode 100644 index 00000000000..5bcdbba75c9 --- /dev/null +++ b/changes.d/6249.fix.md @@ -0,0 +1 @@ +Fix a race condition between global config reload and debug logging that caused "platform not defined" errors when running workflows that contained a "rose-suite.conf" file in vebose or debug mode. diff --git a/cylc/flow/cfgspec/glbl_cfg.py b/cylc/flow/cfgspec/glbl_cfg.py index 136a21e2df1..b2ce8503f8c 100644 --- a/cylc/flow/cfgspec/glbl_cfg.py +++ b/cylc/flow/cfgspec/glbl_cfg.py @@ -16,7 +16,7 @@ """Allow lazy loading of `cylc.flow.cfgspec.globalcfg`.""" -def glbl_cfg(cached=True): +def glbl_cfg(**kwargs): """Load and return the global configuration singleton instance.""" from cylc.flow.cfgspec.globalcfg import GlobalConfig - return GlobalConfig.get_inst(cached=cached) + return GlobalConfig.get_inst(**kwargs) diff --git a/cylc/flow/cfgspec/globalcfg.py b/cylc/flow/cfgspec/globalcfg.py index db8c7bdd5ff..7dc04c99a02 100644 --- a/cylc/flow/cfgspec/globalcfg.py +++ b/cylc/flow/cfgspec/globalcfg.py @@ -1978,24 +1978,29 @@ def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) @classmethod - def get_inst(cls, cached: bool = True) -> 'GlobalConfig': + def get_inst( + cls, cached: bool = True, reload: bool = False + ) -> 'GlobalConfig': """Return a GlobalConfig instance. Args: - cached (bool): - If cached create if necessary and return the singleton - instance, else return a new instance. + cached: + If True, return a cached instance if present. If False, return + a new instance. + reload: + If true, reload the cached instance (implies cached=True). + """ - if not cached: - # Return an up-to-date global config without affecting the - # singleton. - new_instance = cls(SPEC, upg, validator=cylc_config_validate) - new_instance.load() - return new_instance - elif not cls._DEFAULT: - cls._DEFAULT = cls(SPEC, upg, validator=cylc_config_validate) - cls._DEFAULT.load() - return cls._DEFAULT + if cached and cls._DEFAULT and not reload: + return cls._DEFAULT + + new_instance = cls(SPEC, upg, validator=cylc_config_validate) + new_instance.load() + + if cached or reload: + cls._DEFAULT = new_instance + + return new_instance def _load(self, fname: Union[Path, str], conf_type: str) -> None: if not os.access(fname, os.F_OK | os.R_OK): @@ -2008,7 +2013,7 @@ def _load(self, fname: Union[Path, str], conf_type: str) -> None: raise def load(self) -> None: - """Load or reload configuration from files.""" + """Load configuration from files.""" self.sparse.clear() self.dense.clear() LOG.debug("Loading site/user config files") diff --git a/tests/integration/test_config.py b/tests/integration/test_config.py index 6e7454f1293..231fb12f804 100644 --- a/tests/integration/test_config.py +++ b/tests/integration/test_config.py @@ -14,11 +14,14 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . +import logging from pathlib import Path import sqlite3 from typing import Any import pytest +from cylc.flow.cfgspec.glbl_cfg import glbl_cfg +from cylc.flow.cfgspec.globalcfg import GlobalConfig from cylc.flow.exceptions import ( ServiceFileError, WorkflowConfigError, @@ -503,3 +506,90 @@ def test_special_task_non_word_names(flow: Fixture, validate: Fixture): }, }) validate(wid) + + +async def test_glbl_cfg(monkeypatch, tmp_path, caplog): + """Test accessing the global config via the glbl_cfg wrapper. + + Test the "cached" and "reload" kwargs to glbl_cfg. + + Also assert that accessing the global config during a reload operation does + not cause issues. See https://github.com/cylc/cylc-flow/issues/6244 + """ + # wipe any previously cached config + monkeypatch.setattr( + 'cylc.flow.cfgspec.globalcfg.GlobalConfig._DEFAULT', None + ) + # load the global config from the test tmp directory + monkeypatch.setenv('CYLC_CONF_PATH', str(tmp_path)) + + def write_global_config(cfg_str): + """Write the global.cylc file.""" + Path(tmp_path, 'global.cylc').write_text(cfg_str) + + def get_platforms(cfg_obj): + """Return the platforms defined in the provided config instance.""" + return {p for p in cfg_obj.get(['platforms']).keys()} + + def expect_platforms_during_reload(platforms): + """Test the platforms defined in glbl_cfg() during reload. + + Assert that the platforms defined in glbl_cfg() match the expected + value, whilst the global config is in the process of being reloaded. + + In other words, this tests that the cached instance is not changed + until after the reload has completed. + + See https://github.com/cylc/cylc-flow/issues/6244 + """ + caplog.set_level(logging.INFO) + + def _capture(fcn): + def _inner(*args, **kwargs): + cfg = glbl_cfg() + assert get_platforms(cfg) == platforms + logging.getLogger('test').info( + 'ran expect_platforms_during_reload test' + ) + return fcn(*args, **kwargs) + return _inner + + monkeypatch.setattr( + 'cylc.flow.cfgspec.globalcfg.GlobalConfig._load', + _capture(GlobalConfig._load) + ) + + # write a global config + write_global_config(''' + [platforms] + [[foo]] + ''') + + # test the platforms defined in it + assert get_platforms(glbl_cfg()) == {'localhost', 'foo'} + + # add a new platform the config + write_global_config(''' + [platforms] + [[foo]] + [[bar]] + ''') + + # the new platform should not appear (due to the cached instance) + assert get_platforms(glbl_cfg()) == {'localhost', 'foo'} + + # if we request an uncached instance, the new platform should appear + assert get_platforms(glbl_cfg(cached=False)) == {'localhost', 'foo', 'bar'} + + # however, this should not affect the cached instance + assert get_platforms(glbl_cfg()) == {'localhost', 'foo'} + + # * if we reload the cached instance, the new platform should appear + # * but during the reload itself, the old config should persist + # see https://github.com/cylc/cylc-flow/issues/6244 + expect_platforms_during_reload({'localhost', 'foo'}) + assert get_platforms(glbl_cfg(reload=True)) == {'localhost', 'foo', 'bar'} + assert 'ran expect_platforms_during_reload test' in caplog.messages + + # the cache should have been updated by the reload + assert get_platforms(glbl_cfg()) == {'localhost', 'foo', 'bar'} From 0e7c61fd38bff6d4fb328c6bf712d5859fa18123 Mon Sep 17 00:00:00 2001 From: Ronnie Dutta <61982285+MetRonnie@users.noreply.github.com> Date: Mon, 22 Jul 2024 12:33:02 +0100 Subject: [PATCH 119/196] Tidy --- changes.d/6249.fix.md | 2 +- tests/integration/test_config.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/changes.d/6249.fix.md b/changes.d/6249.fix.md index 5bcdbba75c9..a20b65b9008 100644 --- a/changes.d/6249.fix.md +++ b/changes.d/6249.fix.md @@ -1 +1 @@ -Fix a race condition between global config reload and debug logging that caused "platform not defined" errors when running workflows that contained a "rose-suite.conf" file in vebose or debug mode. +Fix a race condition between global config reload and debug logging that caused "platform not defined" errors when running workflows that contained a "rose-suite.conf" file in verbose or debug mode. diff --git a/tests/integration/test_config.py b/tests/integration/test_config.py index 231fb12f804..6a1099bd6eb 100644 --- a/tests/integration/test_config.py +++ b/tests/integration/test_config.py @@ -529,7 +529,7 @@ def write_global_config(cfg_str): def get_platforms(cfg_obj): """Return the platforms defined in the provided config instance.""" - return {p for p in cfg_obj.get(['platforms']).keys()} + return set(cfg_obj.get(['platforms']).keys()) def expect_platforms_during_reload(platforms): """Test the platforms defined in glbl_cfg() during reload. From 4d6430a7f0905201c1206f18c2f348d26a82b32e Mon Sep 17 00:00:00 2001 From: Ronnie Dutta <61982285+MetRonnie@users.noreply.github.com> Date: Tue, 23 Jul 2024 10:57:53 +0100 Subject: [PATCH 120/196] Logging: do not access global config object during emit (#6252) A `GlobalConfig.get()` call can have the side effect of expanding the global config object, so should be avoided in emitting a log message --- cylc/flow/loggingutil.py | 29 ++++--- tests/unit/test_loggingutil.py | 148 ++++++++++++++++++++++----------- 2 files changed, 114 insertions(+), 63 deletions(-) diff --git a/cylc/flow/loggingutil.py b/cylc/flow/loggingutil.py index 3d77bdcb037..cb645a929ff 100644 --- a/cylc/flow/loggingutil.py +++ b/cylc/flow/loggingutil.py @@ -138,7 +138,6 @@ class RotatingLogFileHandler(logging.FileHandler): FILE_HEADER_FLAG = 'cylc_log_file_header' ROLLOVER_NUM = 'cylc_log_num' - MIN_BYTES = 1024 header_extra = {FILE_HEADER_FLAG: True} """Use to indicate the log msg is a header that should be logged on @@ -146,7 +145,7 @@ class RotatingLogFileHandler(logging.FileHandler): def __init__( self, - log_file_path: str, + log_file_path: Union[Path, str], no_detach: bool = False, restart_num: int = 0, timestamp: bool = True, @@ -155,10 +154,20 @@ def __init__( self.no_detach = no_detach self.formatter = CylcLogFormatter(timestamp=timestamp) # Header records get appended to when calling - # LOG.info(extra=RotatingLogFileHandler.[rollover_]header_extra) + # `LOG.info(extra=RotatingLogFileHandler.[rollover_]header_extra)`: self.header_records: List[logging.LogRecord] = [] self.restart_num = restart_num self.log_num: Optional[int] = None # null value until log file created + # Get & cache properties from global config (note: we should not access + # the global config object when emitting log messages as as doing so + # can have the side effect of expanding the global config): + self.max_bytes: int = max( + glbl_cfg().get(['scheduler', 'logging', 'maximum size in bytes']), + 1024 # Max size must be >= 1KB + ) + self.arch_len: Optional[int] = glbl_cfg().get([ + 'scheduler', 'logging', 'rolling archive length' + ]) def emit(self, record): """Emit a record, rollover log if necessary.""" @@ -196,10 +205,6 @@ def should_rollover(self, record: logging.LogRecord) -> bool: """Should rollover?""" if self.log_num is None or self.stream is None: return True - max_bytes = glbl_cfg().get( - ['scheduler', 'logging', 'maximum size in bytes']) - if max_bytes < self.MIN_BYTES: # No silly value - max_bytes = self.MIN_BYTES msg = "%s\n" % self.format(record) try: # due to non-posix-compliant Windows feature @@ -207,7 +212,7 @@ def should_rollover(self, record: logging.LogRecord) -> bool: except ValueError as exc: # intended to catch - ValueError: I/O operation on closed file raise SystemExit(exc) - return self.stream.tell() + len(msg.encode('utf8')) >= max_bytes + return self.stream.tell() + len(msg.encode('utf8')) >= self.max_bytes @property def load_type(self) -> str: @@ -221,10 +226,8 @@ def do_rollover(self) -> None: # Create new log file self.new_log_file() # Housekeep old log files - arch_len = glbl_cfg().get( - ['scheduler', 'logging', 'rolling archive length']) - if arch_len: - self.update_log_archive(arch_len) + if self.arch_len: + self.update_log_archive(self.arch_len) # Reopen stream, redirect STDOUT and STDERR to log if self.stream: self.stream.close() @@ -246,7 +249,7 @@ def do_rollover(self) -> None: ) logging.FileHandler.emit(self, header_record) - def update_log_archive(self, arch_len): + def update_log_archive(self, arch_len: int) -> None: """Maintain configured log file archive. - Sort logs by file modification time - Delete old log files in line with archive length configured in diff --git a/tests/unit/test_loggingutil.py b/tests/unit/test_loggingutil.py index c22886b517d..27447e48f2d 100644 --- a/tests/unit/test_loggingutil.py +++ b/tests/unit/test_loggingutil.py @@ -15,77 +15,95 @@ # along with this program. If not, see . import logging +import re +import sys +from io import TextIOWrapper from pathlib import Path -import tempfile from time import sleep +from typing import Callable, cast +from unittest import mock + import pytest from pytest import param -import re -import sys -from typing import Callable -from unittest import mock from cylc.flow import LOG +from cylc.flow.cfgspec.glbl_cfg import glbl_cfg +from cylc.flow.cfgspec.globalcfg import GlobalConfig from cylc.flow.loggingutil import ( - RotatingLogFileHandler, CylcLogFormatter, + RotatingLogFileHandler, get_reload_start_number, get_sorted_logs_by_time, set_timestamps, ) +@pytest.fixture +def rotating_log_file_handler(tmp_path: Path): + """Fixture to create a RotatingLogFileHandler for testing.""" + log_file = tmp_path / "log" + log_file.touch() + + handler = cast('RotatingLogFileHandler', None) + orig_stream = cast('TextIOWrapper', None) + + def inner( + *args, level: int = logging.INFO, **kwargs + ) -> RotatingLogFileHandler: + nonlocal handler, orig_stream + handler = RotatingLogFileHandler(log_file, *args, **kwargs) + orig_stream = handler.stream + # next line is important as pytest can have a "Bad file descriptor" + # due to a FileHandler with default "a" (pytest tries to r/w). + handler.mode = "a+" + + # enable the logger + LOG.setLevel(level) + LOG.addHandler(handler) + + return handler + + yield inner + + # clean up + LOG.setLevel(logging.INFO) + LOG.removeHandler(handler) + + @mock.patch("cylc.flow.loggingutil.glbl_cfg") def test_value_error_raises_system_exit( - mocked_glbl_cfg, + mocked_glbl_cfg, rotating_log_file_handler ): """Test that a ValueError when writing to a log stream won't result in multiple exceptions (what could lead to infinite loop in some occasions. Instead, it **must** raise a SystemExit""" - with tempfile.NamedTemporaryFile() as tf: - # mock objects used when creating the file handler - mocked = mock.MagicMock() - mocked_glbl_cfg.return_value = mocked - mocked.get.return_value = 100 - file_handler = RotatingLogFileHandler(tf.name, False) - # next line is important as pytest can have a "Bad file descriptor" - # due to a FileHandler with default "a" (pytest tries to r/w). - file_handler.mode = "a+" + # mock objects used when creating the file handler + mocked = mock.MagicMock() + mocked_glbl_cfg.return_value = mocked + mocked.get.return_value = 100 + file_handler = rotating_log_file_handler(level=logging.INFO) - # enable the logger - LOG.setLevel(logging.INFO) - LOG.addHandler(file_handler) - - # Disable raising uncaught exceptions in logging, due to file - # handler using stdin.fileno. See the following links for more. - # https://github.com/pytest-dev/pytest/issues/2276 & - # https://github.com/pytest-dev/pytest/issues/1585 - logging.raiseExceptions = False - - # first message will initialize the stream and the handler - LOG.info("What could go") - - # here we change the stream of the handler - old_stream = file_handler.stream - file_handler.stream = mock.MagicMock() - file_handler.stream.seek = mock.MagicMock() - # in case where - file_handler.stream.seek.side_effect = ValueError - - try: - # next call will call the emit method and use the mocked stream - LOG.info("wrong?!") - raise Exception("Exception SystemError was not raised") - except SystemExit: - pass - finally: - # clean up - file_handler.stream = old_stream - # for log_handler in LOG.handlers: - # log_handler.close() - file_handler.close() - LOG.removeHandler(file_handler) - logging.raiseExceptions = True + # Disable raising uncaught exceptions in logging, due to file + # handler using stdin.fileno. See the following links for more. + # https://github.com/pytest-dev/pytest/issues/2276 & + # https://github.com/pytest-dev/pytest/issues/1585 + logging.raiseExceptions = False + + # first message will initialize the stream and the handler + LOG.info("What could go") + + # here we change the stream of the handler + file_handler.stream = mock.MagicMock() + file_handler.stream.seek = mock.MagicMock() + # in case where + file_handler.stream.seek.side_effect = ValueError + + with pytest.raises(SystemExit): + # next call will call the emit method and use the mocked stream + LOG.info("wrong?!") + + # clean up + logging.raiseExceptions = True @pytest.mark.parametrize( @@ -197,3 +215,33 @@ def test_set_timestamps(capsys): assert re.match('^[0-9]{4}', errors[0]) assert re.match('^WARNING - bar', errors[1]) assert re.match('^[0-9]{4}', errors[2]) + + LOG.removeHandler(log_handler) + + +def test_log_emit_and_glbl_cfg( + monkeypatch: pytest.MonkeyPatch, rotating_log_file_handler +): + """Test that log calls do not access the global config object. + + Doing so can have the side effect of expanding the global config object + so should be avoided - see https://github.com/cylc/cylc-flow/issues/6244 + """ + rotating_log_file_handler(level=logging.DEBUG) + mock_cfg = mock.Mock(spec=GlobalConfig) + monkeypatch.setattr( + 'cylc.flow.cfgspec.globalcfg.GlobalConfig', + mock.Mock( + spec=GlobalConfig, + get_inst=lambda *a, **k: mock_cfg + ) + ) + + # Check mocking is correct: + glbl_cfg().get(['kinesis']) + assert mock_cfg.get.call_args_list == [mock.call(['kinesis'])] + mock_cfg.reset_mock() + + # Check log emit does not access global config object: + LOG.debug("Entering zero gravity") + assert mock_cfg.get.call_args_list == [] From 71c7d823fb852ec0bc8e79e6d19a8b756097a5f9 Mon Sep 17 00:00:00 2001 From: Tim Pillinger <26465611+wxtim@users.noreply.github.com> Date: Tue, 23 Jul 2024 16:34:08 +0100 Subject: [PATCH 121/196] Turn off "against source" mode after validation step of Cylc VR (#6213) remove the `--against-source` option from `cylc vr` - it should _always_ be true test that `cylc vr` unsets the `--against-source` mode after validation. --- changes.d/6213.fix.md | 1 + cylc/flow/scripts/validate.py | 9 +- cylc/flow/scripts/validate_reinstall.py | 10 +- .../08-vr-against-src.t | 95 +++++++++++++++++++ .../vr_workflow_fail_on_play/flow.cylc | 19 ++++ tests/integration/test_config.py | 25 +++++ 6 files changed, 152 insertions(+), 7 deletions(-) create mode 100644 changes.d/6213.fix.md create mode 100644 tests/functional/cylc-combination-scripts/08-vr-against-src.t create mode 100644 tests/functional/cylc-combination-scripts/vr_workflow_fail_on_play/flow.cylc diff --git a/changes.d/6213.fix.md b/changes.d/6213.fix.md new file mode 100644 index 00000000000..8765a262023 --- /dev/null +++ b/changes.d/6213.fix.md @@ -0,0 +1 @@ +Fix bug where the `-S`, `-O` and `-D` options in `cylc vr` would not be applied correctly when restarting a workflow. diff --git a/cylc/flow/scripts/validate.py b/cylc/flow/scripts/validate.py index a224302c1b0..ee359b51847 100755 --- a/cylc/flow/scripts/validate.py +++ b/cylc/flow/scripts/validate.py @@ -100,7 +100,6 @@ ), VALIDATE_RUN_MODE, VALIDATE_ICP_OPTION, - VALIDATE_AGAINST_SOURCE_OPTION, ] @@ -111,9 +110,11 @@ def get_option_parser(): argdoc=[WORKFLOW_ID_OR_PATH_ARG_DOC], ) - validate_options = parser.get_cylc_rose_options() + VALIDATE_OPTIONS - - for option in validate_options: + for option in [ + *parser.get_cylc_rose_options(), + *VALIDATE_OPTIONS, + VALIDATE_AGAINST_SOURCE_OPTION, + ]: parser.add_option(*option.args, **option.kwargs) parser.set_defaults(is_validate=True) diff --git a/cylc/flow/scripts/validate_reinstall.py b/cylc/flow/scripts/validate_reinstall.py index de1be6dea82..fa53461b2c3 100644 --- a/cylc/flow/scripts/validate_reinstall.py +++ b/cylc/flow/scripts/validate_reinstall.py @@ -62,6 +62,7 @@ from cylc.flow.scheduler_cli import PLAY_OPTIONS, scheduler_cli from cylc.flow.scripts.validate import ( VALIDATE_OPTIONS, + VALIDATE_AGAINST_SOURCE_OPTION, run as cylc_validate, ) from cylc.flow.scripts.reinstall import ( @@ -166,11 +167,14 @@ async def vr_cli(parser: COP, options: 'Values', workflow_id: str): return 1 # Force on the against_source option: - options.against_source = True # Make validate check against source. + options.against_source = True + + # Run cylc validate log_subcommand('validate --against-source', workflow_id) await cylc_validate(parser, options, workflow_id) - # Unset is validate after validation. + # Unset options that do not apply after validation: + delattr(options, 'against_source') delattr(options, 'is_validate') log_subcommand('reinstall', workflow_id) @@ -198,7 +202,7 @@ async def vr_cli(parser: COP, options: 'Values', workflow_id: str): 'play', unparsed_wid, options, - compound_script_opts=VR_OPTIONS, + compound_script_opts=[*VR_OPTIONS, VALIDATE_AGAINST_SOURCE_OPTION], script_opts=(*PLAY_OPTIONS, *parser.get_std_options()), source='', # Intentionally blank ) diff --git a/tests/functional/cylc-combination-scripts/08-vr-against-src.t b/tests/functional/cylc-combination-scripts/08-vr-against-src.t new file mode 100644 index 00000000000..be1b0920093 --- /dev/null +++ b/tests/functional/cylc-combination-scripts/08-vr-against-src.t @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +# THIS FILE IS PART OF THE CYLC WORKFLOW ENGINE. +# Copyright (C) NIWA & British Crown (Met Office) & Contributors. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +#------------------------------------------------------------------------------ +# Test `cylc vr` (Validate Reinstall restart) +# Tests that VR doesn't modify the source directory for Cylc play. +# See https://github.com/cylc/cylc-flow/issues/6209 + +. "$(dirname "$0")/test_header" +set_test_number 9 + +# Setup (Run VIP, check that the play step fails in the correct way): +WORKFLOW_NAME="cylctb-x$(< /dev/urandom tr -dc _A-Z-a-z-0-9 | head -c6)" +cp "${TEST_SOURCE_DIR}/vr_workflow_fail_on_play/flow.cylc" . + +# Run Cylc VIP +run_fail "setup (vip)" \ + cylc vip --debug \ + --workflow-name "${WORKFLOW_NAME}" \ + --no-run-name +validate1="$(grep -Po "WARNING - vip:(.*)" "setup (vip).stderr" | awk -F ':' '{print $2}')" +play1="$(grep -Po "WARNING - play:(.*)" "setup (vip).stderr" | awk -F ':' '{print $2}')" + +# Change the workflow to make the reinstall happen: +echo "" >> flow.cylc + +# Run Cylc VR: +TEST_NAME="${TEST_NAME_BASE}" +run_fail "${TEST_NAME}" cylc vr "${WORKFLOW_NAME}" +validate2="$(grep -Po "WARNING - vr:(.*)" "${TEST_NAME_BASE}.stderr" | awk -F ':' '{print $2}')" +play2="$(grep -Po "WARNING - play:(.*)" "${TEST_NAME_BASE}.stderr" | awk -F ':' '{print $2}')" + +# Test that the correct source directory is openened at different +# stages of Cylc VIP & VR +TEST_NAME="outputs-created" +if [[ -n $validate1 && -n $validate2 && -n $play1 && -n $play2 ]]; then + ok "${TEST_NAME}" +else + fail "${TEST_NAME}" +fi + + +TEST_NAME="vip validate and play operate on different folders" +if [[ $validate1 != "${play1}" ]]; then + ok "${TEST_NAME}" +else + fail "${TEST_NAME}" +fi + +TEST_NAME="vr & vip validate operate on the same folder" +if [[ $validate1 == "${validate2}" ]]; then + ok "${TEST_NAME}" +else + fail "${TEST_NAME}" +fi + +TEST_NAME="vr validate and play operate on different folders" +if [[ $validate2 != "${play2}" ]]; then + ok "${TEST_NAME}" +else + fail "${TEST_NAME}" +fi + +TEST_NAME="vip play loads from a cylc-run subdir" +if [[ "${play2}" =~ cylc-run ]]; then + ok "${TEST_NAME}" +else + fail "${TEST_NAME}" +fi + +TEST_NAME="vr play loads from a cylc-run subdir" +if [[ "${play2}" =~ cylc-run ]]; then + ok "${TEST_NAME}" +else + fail "${TEST_NAME}" +fi + +# Clean Up: +run_ok "${TEST_NAME_BASE}-stop cylc stop ${WORKFLOW_NAME} --now --now" +purge +exit 0 diff --git a/tests/functional/cylc-combination-scripts/vr_workflow_fail_on_play/flow.cylc b/tests/functional/cylc-combination-scripts/vr_workflow_fail_on_play/flow.cylc new file mode 100644 index 00000000000..23e88620ef6 --- /dev/null +++ b/tests/functional/cylc-combination-scripts/vr_workflow_fail_on_play/flow.cylc @@ -0,0 +1,19 @@ +#!jinja2 +{% from "sys" import argv %} +{% from "cylc.flow" import LOG %} +{% from "pathlib" import Path %} +{% if argv[1] == "play" %} + this = should cause cylc play to fail +{% endif %} + +{% set SPATH = Path.cwd().__str__() %} +{% do LOG.warning(argv[1] + ":" + SPATH) %} + + +[scheduling] + initial cycle point = 1500 + [[graph]] + P1Y = foo + +[runtime] + [[foo]] diff --git a/tests/integration/test_config.py b/tests/integration/test_config.py index 6a1099bd6eb..c75797e9cbb 100644 --- a/tests/integration/test_config.py +++ b/tests/integration/test_config.py @@ -23,6 +23,7 @@ from cylc.flow.cfgspec.glbl_cfg import glbl_cfg from cylc.flow.cfgspec.globalcfg import GlobalConfig from cylc.flow.exceptions import ( + PointParsingError, ServiceFileError, WorkflowConfigError, XtriggerConfigError, @@ -593,3 +594,27 @@ def _inner(*args, **kwargs): # the cache should have been updated by the reload assert get_platforms(glbl_cfg()) == {'localhost', 'foo', 'bar'} + + +def test_validate_run_mode(flow: Fixture, validate: Fixture): + """Test that Cylc validate will only check simulation mode settings + if validate --mode simulation or dummy. + + Discovered in: + https://github.com/cylc/cylc-flow/pull/6213#issuecomment-2225365825 + """ + wid = flow({ + 'scheduling': {'graph': {'R1': 'mytask'}}, + 'runtime': {'mytask': {'simulation': {'fail cycle points': 'alll'}}} + }) + + # It's fine with run mode live + validate(wid) + + # It fails with run mode simulation: + with pytest.raises(PointParsingError, match='Incompatible value'): + validate(wid, run_mode='simulation') + + # It fails with run mode dummy: + with pytest.raises(PointParsingError, match='Incompatible value'): + validate(wid, run_mode='dummy') From a04b529aab72fd16952d67c74f9ce612233585b8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 23 Jul 2024 15:43:43 +0000 Subject: [PATCH 122/196] Prepare release 8.3.3 Workflow: Release stage 1 - create release PR (Cylc 8+ only), run: 42 --- CHANGES.md | 12 ++++++++++++ changes.d/6213.fix.md | 1 - changes.d/6241.fix.md | 1 - changes.d/6242.fix.md | 1 - changes.d/6249.fix.md | 1 - cylc/flow/__init__.py | 2 +- 6 files changed, 13 insertions(+), 5 deletions(-) delete mode 100644 changes.d/6213.fix.md delete mode 100644 changes.d/6241.fix.md delete mode 100644 changes.d/6242.fix.md delete mode 100644 changes.d/6249.fix.md diff --git a/CHANGES.md b/CHANGES.md index 061d935a010..4a55a3a1e1d 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -11,6 +11,18 @@ $ towncrier create ..md --content "Short description" +## __cylc-8.3.3 (Released 2024-07-23)__ + +### 🔧 Fixes + +[#6213](https://github.com/cylc/cylc-flow/pull/6213) - Fix bug where the `-S`, `-O` and `-D` options in `cylc vr` would not be applied correctly when restarting a workflow. + +[#6241](https://github.com/cylc/cylc-flow/pull/6241) - Allow flow-merge when triggering n=0 tasks. + +[#6242](https://github.com/cylc/cylc-flow/pull/6242) - Put `share/bin` in the `PATH` of scheduler environment, event handlers therein will now be found. + +[#6249](https://github.com/cylc/cylc-flow/pull/6249) - Fix a race condition between global config reload and debug logging that caused "platform not defined" errors when running workflows that contained a "rose-suite.conf" file in verbose or debug mode. + ## __cylc-8.3.2 (Released 2024-07-10)__ ### 🔧 Fixes diff --git a/changes.d/6213.fix.md b/changes.d/6213.fix.md deleted file mode 100644 index 8765a262023..00000000000 --- a/changes.d/6213.fix.md +++ /dev/null @@ -1 +0,0 @@ -Fix bug where the `-S`, `-O` and `-D` options in `cylc vr` would not be applied correctly when restarting a workflow. diff --git a/changes.d/6241.fix.md b/changes.d/6241.fix.md deleted file mode 100644 index 13bd11925dd..00000000000 --- a/changes.d/6241.fix.md +++ /dev/null @@ -1 +0,0 @@ -Allow flow-merge when triggering n=0 tasks. diff --git a/changes.d/6242.fix.md b/changes.d/6242.fix.md deleted file mode 100644 index 87edde1efab..00000000000 --- a/changes.d/6242.fix.md +++ /dev/null @@ -1 +0,0 @@ -Put `share/bin` in the `PATH` of scheduler environment, event handlers therein will now be found. \ No newline at end of file diff --git a/changes.d/6249.fix.md b/changes.d/6249.fix.md deleted file mode 100644 index a20b65b9008..00000000000 --- a/changes.d/6249.fix.md +++ /dev/null @@ -1 +0,0 @@ -Fix a race condition between global config reload and debug logging that caused "platform not defined" errors when running workflows that contained a "rose-suite.conf" file in verbose or debug mode. diff --git a/cylc/flow/__init__.py b/cylc/flow/__init__.py index 5112d295c05..eaa6d468b9d 100644 --- a/cylc/flow/__init__.py +++ b/cylc/flow/__init__.py @@ -53,7 +53,7 @@ def environ_init(): environ_init() -__version__ = '8.3.3.dev' +__version__ = '8.3.3' def iter_entry_points(entry_point_name): From e7888c9603c7afdc230cffc603e83f857a2f3f16 Mon Sep 17 00:00:00 2001 From: Ronnie Dutta <61982285+MetRonnie@users.noreply.github.com> Date: Tue, 23 Jul 2024 17:12:56 +0100 Subject: [PATCH 123/196] Fix changelog --- 6103.fix.md | 2 -- CHANGES.md | 4 +++- 2 files changed, 3 insertions(+), 3 deletions(-) delete mode 100644 6103.fix.md diff --git a/6103.fix.md b/6103.fix.md deleted file mode 100644 index 47844cd1ea6..00000000000 --- a/6103.fix.md +++ /dev/null @@ -1,2 +0,0 @@ -Absolute dependencies (dependencies on tasks in a specified cycle rather than at a specified offset) are now visible in the GUI beyond the specified cycle. - diff --git a/CHANGES.md b/CHANGES.md index 4a55a3a1e1d..0c33e6ce915 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -15,13 +15,15 @@ $ towncrier create ..md --content "Short description" ### 🔧 Fixes +[#6103](https://github.com/cylc/cylc-flow/pull/6103) - Absolute dependencies (dependencies on tasks in a specified cycle rather than at a specified offset) are now visible in the GUI beyond the specified cycle. + [#6213](https://github.com/cylc/cylc-flow/pull/6213) - Fix bug where the `-S`, `-O` and `-D` options in `cylc vr` would not be applied correctly when restarting a workflow. [#6241](https://github.com/cylc/cylc-flow/pull/6241) - Allow flow-merge when triggering n=0 tasks. [#6242](https://github.com/cylc/cylc-flow/pull/6242) - Put `share/bin` in the `PATH` of scheduler environment, event handlers therein will now be found. -[#6249](https://github.com/cylc/cylc-flow/pull/6249) - Fix a race condition between global config reload and debug logging that caused "platform not defined" errors when running workflows that contained a "rose-suite.conf" file in verbose or debug mode. +[#6249](https://github.com/cylc/cylc-flow/pull/6249), [#6252](https://github.com/cylc/cylc-flow/pull/6252) - Fix a race condition between global config reload and debug logging that caused "platform not defined" errors when running workflows that contained a "rose-suite.conf" file in verbose or debug mode. ## __cylc-8.3.2 (Released 2024-07-10)__ From f68e8b97b68377bfc77711317e4f113dded26826 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 23 Jul 2024 17:50:34 +0100 Subject: [PATCH 124/196] Bump dev version (#6256) [skip ci] --- cylc/flow/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cylc/flow/__init__.py b/cylc/flow/__init__.py index eaa6d468b9d..3f0bfe5c0b1 100644 --- a/cylc/flow/__init__.py +++ b/cylc/flow/__init__.py @@ -53,7 +53,7 @@ def environ_init(): environ_init() -__version__ = '8.3.3' +__version__ = '8.3.4.dev' def iter_entry_points(entry_point_name): From e63613c61df3b22ea164b69b1463a7ecdfcd6578 Mon Sep 17 00:00:00 2001 From: Ronnie Dutta <61982285+MetRonnie@users.noreply.github.com> Date: Tue, 23 Jul 2024 18:03:04 +0100 Subject: [PATCH 125/196] Integration reftests: handle unexpected shutdown better --- tests/integration/conftest.py | 69 ++++++++++++++++++----------------- 1 file changed, 36 insertions(+), 33 deletions(-) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 518ef40f018..bce6ea64e9f 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -559,8 +559,8 @@ def _submit_task_jobs(*args, **kwargs): async def _complete( - schd, - *tokens_list: Union[Tokens, str], + schd: 'Scheduler', + *wait_tokens: Union[Tokens, str], stop_mode=StopMode.AUTO, timeout: int = 60, ) -> None: @@ -569,7 +569,7 @@ async def _complete( Args: schd: The scheduler to await. - tokens_list: + wait_tokens: If specified, this will wait for the tasks represented by these tokens to be marked as completed by the task pool. Can use relative task ids as strings (e.g. '1/a') rather than tokens for @@ -590,56 +590,59 @@ async def _complete( """ start_time = time() - _tokens_list: List[Tokens] = [] - for tokens in tokens_list: + tokens_list: List[Tokens] = [] + for tokens in wait_tokens: if isinstance(tokens, str): tokens = Tokens(tokens, relative=True) - _tokens_list.append(tokens.task) + tokens_list.append(tokens.task) # capture task completion remove_if_complete = schd.pool.remove_if_complete def _remove_if_complete(itask, output=None): - nonlocal _tokens_list + nonlocal tokens_list ret = remove_if_complete(itask) - if ret and itask.tokens.task in _tokens_list: - _tokens_list.remove(itask.tokens.task) + if ret and itask.tokens.task in tokens_list: + tokens_list.remove(itask.tokens.task) return ret - schd.pool.remove_if_complete = _remove_if_complete - - # capture workflow shutdown + # capture workflow shutdown request set_stop = schd._set_stop - has_shutdown = False + stop_requested = False def _set_stop(mode=None): - nonlocal has_shutdown, stop_mode + nonlocal stop_requested, stop_mode if mode == stop_mode: - has_shutdown = True + stop_requested = True return set_stop(mode) else: set_stop(mode) raise Exception(f'Workflow bailed with stop mode = {mode}') - schd._set_stop = _set_stop - # determine the completion condition - if _tokens_list: - condition = lambda: bool(_tokens_list) - else: - condition = lambda: bool(not has_shutdown) - - # wait for the condition to be met - while condition(): - # allow the main loop to advance - await asyncio.sleep(0) - if (time() - start_time) > timeout: - raise Exception( - f'Timeout waiting for {", ".join(map(str, _tokens_list))}' - ) - - # restore regular shutdown logic - schd._set_stop = set_stop + def done(): + if wait_tokens: + return not tokens_list + # otherwise wait for the scheduler to shut down + if not schd.contact_data: + return True + return stop_requested + + with pytest.MonkeyPatch.context() as mp: + mp.setattr(schd.pool, 'remove_if_complete', _remove_if_complete) + mp.setattr(schd, '_set_stop', _set_stop) + + # wait for the condition to be met + while not done(): + # allow the main loop to advance + await asyncio.sleep(0) + if (time() - start_time) > timeout: + msg = "Timeout waiting for " + if wait_tokens: + msg += ", ".join(map(str, tokens_list)) + else: + msg += "workflow to shut down" + raise Exception(msg) @pytest.fixture From b31f479588fea82efb615c5b7c8d9e501c72a579 Mon Sep 17 00:00:00 2001 From: Ronnie Dutta <61982285+MetRonnie@users.noreply.github.com> Date: Thu, 25 Jul 2024 17:22:36 +0100 Subject: [PATCH 126/196] Type annotations --- cylc/flow/terminal.py | 55 +++++++++++++++++++++++++++++++++++++------ 1 file changed, 48 insertions(+), 7 deletions(-) diff --git a/cylc/flow/terminal.py b/cylc/flow/terminal.py index c7f2023b046..0a9d30ac549 100644 --- a/cylc/flow/terminal.py +++ b/cylc/flow/terminal.py @@ -16,28 +16,44 @@ """Functionality to assist working with terminals""" -from functools import wraps import inspect import json import logging import os -from subprocess import PIPE, Popen # nosec import sys +from functools import wraps +from subprocess import PIPE, Popen # nosec from textwrap import wrap -from typing import Any, Callable, List, Optional, TYPE_CHECKING +from typing import ( + TYPE_CHECKING, + Any, + Callable, + Dict, + List, + Optional, + Sequence, + TypeVar, + Union, + cast, + overload, +) from ansimarkup import parse as cparse from colorama import init as color_init +import cylc.flow.flags from cylc.flow import CYLC_LOG from cylc.flow.exceptions import CylcError -import cylc.flow.flags from cylc.flow.loggingutil import CylcLogFormatter from cylc.flow.parsec.exceptions import ParsecError + if TYPE_CHECKING: from optparse import OptionParser, Values + T = TypeVar('T') + StrFunc = Callable[[str], str] + # CLI exception message format EXC_EXIT = cparse('{name}: {exc}') @@ -341,7 +357,32 @@ def wrapper(*api_args: str) -> None: return inner -def prompt(message, options, default=None, process=None): +@overload +def prompt( + message: str, + options: Sequence[str], + default: Optional[str] = None, + process: Optional['StrFunc'] = None, +) -> str: + ... + + +@overload +def prompt( + message: str, + options: Dict[str, 'T'], + default: Optional[str] = None, + process: Optional['StrFunc'] = None, +) -> 'T': + ... + + +def prompt( + message: str, + options: Union[Sequence[str], Dict[str, 'T']], + default: Optional[str] = None, + process: Optional['StrFunc'] = None, +) -> Union[str, 'T']: """Dead simple CLI textual prompting. Args: @@ -369,10 +410,10 @@ def prompt(message, options, default=None, process=None): if default: default_ = f'[{default}] ' message += f': {default_}{",".join(options)}? ' - usr = None + usr = cast('str', None) while usr not in options: usr = input(f'{message}') - if default is not None and usr == '': + if default is not None and not usr: usr = default if process: usr = process(usr) From 2584a5154691259662e045c42e449e7f5359cbe7 Mon Sep 17 00:00:00 2001 From: Ronnie Dutta <61982285+MetRonnie@users.noreply.github.com> Date: Tue, 30 Jul 2024 17:54:49 +0100 Subject: [PATCH 127/196] Fix color formatting in the individual help text for CLI options --- cylc/flow/option_parsers.py | 56 +++++++++++++++++++++---------------- 1 file changed, 32 insertions(+), 24 deletions(-) diff --git a/cylc/flow/option_parsers.py b/cylc/flow/option_parsers.py index a4ef2d97b3d..8a4245a6a94 100644 --- a/cylc/flow/option_parsers.py +++ b/cylc/flow/option_parsers.py @@ -170,11 +170,7 @@ def format_help_headings(string): """Put "headings" in bold. Where "headings" are lines with no indentation which are followed by a - colon e.g: - - Examples: - ... - + colon. """ return cparse( re.sub( @@ -200,6 +196,9 @@ def take_action(self, action, dest, opt, value, values, parser): class CylcHelpFormatter(IndentedHelpFormatter): + """This formatter handles colour in help text, and automatically + colourises headings & shell examples.""" + def _format(self, text: str) -> str: """Format help (usage) text on the fly to handle coloring. @@ -214,21 +213,28 @@ def _format(self, text: str) -> str: """ if should_use_color(self.parser.values): # Add color formatting to examples text. - text = format_shell_examples( + return format_shell_examples( format_help_headings(text) ) - else: - # Strip any hardwired formatting - text = cstrip(text) - return text + # Else strip any hardwired formatting + return cstrip(text) - def format_usage(self, usage): + def format_usage(self, usage: str) -> str: return super().format_usage(self._format(usage)) # If we start using "description" as well as "usage" (also epilog): # def format_description(self, description): # return super().format_description(self._format(description)) + def format_option(self, option: Option) -> str: + """Format help text for options.""" + if option.help: + if should_use_color(self.parser.values): + option.help = cparse(option.help) + else: + option.help = cstrip(option.help) + return super().format_option(option) + class CylcOptionParser(OptionParser): @@ -693,20 +699,21 @@ def combine_options_pair(first_list, second_list): return output -def add_sources_to_helps(options, modify=None): - """Prettify format of list of CLI commands this option applies to +def add_sources_to_helps( + options: Iterable[OptionSettings], modify: Optional[dict] = None +) -> None: + """Get list of CLI commands this option applies to and prepend that list to the start of help. Arguments: - Options: - Options dicts to modify help upon. + options: + List of OptionSettings to modify help upon. modify: Dict of items to substitute: Intended to allow one to replace cylc-rose with the names of the sub-commands cylc rose options apply to. """ modify = {} if modify is None else modify - cformat = cparse if should_use_color(options) else cstrip for option in options: if hasattr(option, 'sources'): sources = list(option.sources) @@ -715,25 +722,26 @@ def add_sources_to_helps(options, modify=None): sources.append(sub) sources.remove(match) - option.kwargs['help'] = cformat( + option.kwargs['help'] = ( f'[{", ".join(sources)}]' f' {option.kwargs["help"]}' ) - return options -def combine_options(*args, modify=None): - """Combine a list of argument dicts. +def combine_options( + *args: List[OptionSettings], modify: Optional[dict] = None +) -> List[OptionSettings]: + """Combine lists of Cylc options. Ordering should be irrelevant because combine_options_pair should be commutative, and the overall order of args is not relevant. """ - list_ = list(args) - output = list_[0] - for arg in list_[1:]: + output = args[0] + for arg in args[1:]: output = combine_options_pair(arg, output) - return add_sources_to_helps(output, modify) + add_sources_to_helps(output, modify) + return output def cleanup_sysargv( From fd25604251623811987cdd57d5dfc18f4031b2f0 Mon Sep 17 00:00:00 2001 From: Ronnie Dutta <61982285+MetRonnie@users.noreply.github.com> Date: Wed, 31 Jul 2024 14:53:47 +0100 Subject: [PATCH 128/196] Upgrade towncrier (#6269) --- pyproject.toml | 1 + setup.cfg | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 6bbfe0507a1..0f8f095e557 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,6 +7,7 @@ template = "changes.d/changelog-template.jinja" underlines = ["", "", ""] title_format = "## __cylc-{version} (Released {project_date})__" issue_format = "[#{issue}](https://github.com/cylc/cylc-flow/pull/{issue})" +ignore = ["changelog-template.jinja"] # These changelog sections will be shown in the defined order: [[tool.towncrier.type]] diff --git a/setup.cfg b/setup.cfg index be1e9383431..d2a66c1b4e0 100644 --- a/setup.cfg +++ b/setup.cfg @@ -125,7 +125,7 @@ tests = pytest-mock>=3.7 pytest>=6 testfixtures>=6.11.0 - towncrier>=23 + towncrier>=24.7.0; python_version > "3.7" # Type annotation stubs # http://mypy-lang.blogspot.com/2021/05/the-upcoming-switch-to-modular-typeshed.html types-Jinja2>=0.1.3 From e37d27277988f0ddc55784bf57d08af63602752f Mon Sep 17 00:00:00 2001 From: Ronnie Dutta <61982285+MetRonnie@users.noreply.github.com> Date: Wed, 31 Jul 2024 18:07:50 +0100 Subject: [PATCH 129/196] Fix state leakage between integration tests --- tests/conftest.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index b9f794c19ed..a7b1d19ca2c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -14,18 +14,19 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from pathlib import Path import re +from pathlib import Path from shutil import rmtree from typing import List, Optional, Tuple import pytest +from cylc.flow import flags from cylc.flow.cfgspec.glbl_cfg import glbl_cfg from cylc.flow.cfgspec.globalcfg import SPEC +from cylc.flow.graphnode import GraphNodeParser from cylc.flow.parsec.config import ParsecConfig from cylc.flow.parsec.validate import cylc_config_validate -from cylc.flow import flags @pytest.fixture(autouse=True) @@ -33,6 +34,8 @@ def test_reset(): """Reset global state before all tests.""" flags.verbosity = 0 flags.cylc7_back_compat = False + # Reset graph node parser singleton: + GraphNodeParser.get_inst().clear() @pytest.fixture(scope='module') From 1e89d73641f250b7f69e0c9d56ce1c97330d1e35 Mon Sep 17 00:00:00 2001 From: Ronnie Dutta <61982285+MetRonnie@users.noreply.github.com> Date: Wed, 31 Jul 2024 18:11:45 +0100 Subject: [PATCH 130/196] GH Actions: avoid running functional tests when not needed --- .github/workflows/test_functional.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/test_functional.yml b/.github/workflows/test_functional.yml index 7831fef64f0..116620cdf35 100644 --- a/.github/workflows/test_functional.yml +++ b/.github/workflows/test_functional.yml @@ -8,6 +8,7 @@ on: - '!.github/workflows/test_functional.yml' - 'cylc/flow/etc/syntax/**' - 'etc/syntax/**' + - 'tests/conftest.py' - 'tests/unit/**' - 'tests/integration/**' - '**.md' @@ -21,6 +22,7 @@ on: - '!.github/workflows/test_functional.yml' - 'cylc/flow/etc/syntax/**' - 'etc/syntax/**' + - 'tests/conftest.py' - 'tests/unit/**' - 'tests/integration/**' - '**.md' From 99baebbb230b63fd13aead5c5d823294a655143c Mon Sep 17 00:00:00 2001 From: Ronnie Dutta <61982285+MetRonnie@users.noreply.github.com> Date: Thu, 1 Aug 2024 10:49:45 +0100 Subject: [PATCH 131/196] Skip some failing tests on MacOS GH Actions runner https://github.com/cylc/cylc-flow/issues/6276 --- tests/functional/flow-triggers/11-wait-merge.t | 3 ++- tests/functional/lib/bash/test_header | 7 +++++++ tests/functional/modes/04-simulation-runtime.t | 1 + 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/tests/functional/flow-triggers/11-wait-merge.t b/tests/functional/flow-triggers/11-wait-merge.t index d869cc19600..053b768d724 100644 --- a/tests/functional/flow-triggers/11-wait-merge.t +++ b/tests/functional/flow-triggers/11-wait-merge.t @@ -1,7 +1,7 @@ #!/usr/bin/env bash # THIS FILE IS PART OF THE CYLC WORKFLOW ENGINE. # Copyright (C) NIWA & British Crown (Met Office) & Contributors. -# +# # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or @@ -17,6 +17,7 @@ #------------------------------------------------------------------------------- . "$(dirname "$0")/test_header" +skip_macos_gh_actions set_test_number 4 install_and_validate "${TEST_NAME_BASE}" "${TEST_NAME_BASE}" true diff --git a/tests/functional/lib/bash/test_header b/tests/functional/lib/bash/test_header index cc0f6e0af43..016b77635f0 100644 --- a/tests/functional/lib/bash/test_header +++ b/tests/functional/lib/bash/test_header @@ -782,6 +782,13 @@ skip_all() { exit } +skip_macos_gh_actions() { + # https://github.com/cylc/cylc-flow/issues/6276 + if [[ "$CI" && "$OSTYPE" == "darwin"* ]]; then + skip_all "Skipped due to performance issues on GH Actions MacOS runner" + fi +} + ssh_install_cylc() { local RHOST="$1" local RHOST_CYLC_DIR= diff --git a/tests/functional/modes/04-simulation-runtime.t b/tests/functional/modes/04-simulation-runtime.t index 1ce3850f202..f702680274c 100644 --- a/tests/functional/modes/04-simulation-runtime.t +++ b/tests/functional/modes/04-simulation-runtime.t @@ -18,6 +18,7 @@ # Test that we can broadcast an alteration to simulation mode. . "$(dirname "$0")/test_header" +skip_macos_gh_actions set_test_number 7 install_workflow "${TEST_NAME_BASE}" "${TEST_NAME_BASE}" From 2a4401c49de52d5300e26bde682e02496879d39c Mon Sep 17 00:00:00 2001 From: Tom Coleman <15375218+ColemanTom@users.noreply.github.com> Date: Fri, 2 Aug 2024 15:10:37 +1000 Subject: [PATCH 132/196] Sort taskProxies in cylc show It was possible for taskProxies when doing 'cylc show' to be in an odd order: Task ID: 20240728T0000Z/b Task ID: 20240727T0000Z/b Task ID: 20240729T0000Z/b After this change, they are always sorted by the ID of the task: Task ID: 20240727T0000Z/b Task ID: 20240728T0000Z/b Task ID: 20240729T0000Z/b --- cylc/flow/scripts/show.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/cylc/flow/scripts/show.py b/cylc/flow/scripts/show.py index b146f339e10..9713ed78fac 100755 --- a/cylc/flow/scripts/show.py +++ b/cylc/flow/scripts/show.py @@ -288,8 +288,10 @@ async def prereqs_and_outputs_query( } } results = await pclient.async_request('graphql', tp_kwargs) - multi = len(results['taskProxies']) > 1 - for t_proxy in results['taskProxies']: + task_proxies = sorted(results['taskProxies'], + key=lambda proxy: proxy['id']) + multi = len(task_proxies) > 1 + for t_proxy in task_proxies: task_id = Tokens(t_proxy['id']).relative_id state = t_proxy['state'] if options.json: @@ -379,7 +381,7 @@ async def prereqs_and_outputs_query( print_completion_state(t_proxy) - if not results['taskProxies']: + if not task_proxies: ansiprint( f"No matching active tasks found: {', '.join(ids_list)}", file=sys.stderr) From fa526dde166fa50fb63e822a1ebb82af2bd108e8 Mon Sep 17 00:00:00 2001 From: Tom Coleman <15375218+ColemanTom@users.noreply.github.com> Date: Fri, 2 Aug 2024 15:11:34 +1000 Subject: [PATCH 133/196] Add a changelog entry for sorting 'cylc show' task proxies --- changes.d/6266.feat.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changes.d/6266.feat.md diff --git a/changes.d/6266.feat.md b/changes.d/6266.feat.md new file mode 100644 index 00000000000..4ddd44b83b4 --- /dev/null +++ b/changes.d/6266.feat.md @@ -0,0 +1 @@ +'cylc show' task output is now sorted by the task id From 6039eba1d1e6a44d6334246033f401393861ea37 Mon Sep 17 00:00:00 2001 From: Hilary Oliver Date: Tue, 6 Aug 2024 17:38:33 +1200 Subject: [PATCH 134/196] Add a test for task sorting. --- tests/integration/scripts/test_show.py | 43 ++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/tests/integration/scripts/test_show.py b/tests/integration/scripts/test_show.py index e1a8280d97b..d0e2c5ad6f8 100644 --- a/tests/integration/scripts/test_show.py +++ b/tests/integration/scripts/test_show.py @@ -150,3 +150,46 @@ async def test_task_meta_query(mod_my_schd, capsys): 'URL': 'http://hasthelargehadroncolliderdestroyedtheworldyet.com/', } } + + +async def test_task_instance_query( + flow, scheduler, start, capsys +): + """It should fetch task instance data, sorted by task name.""" + + colour_init(strip=True, autoreset=True) + opts = SimpleNamespace( + comms_timeout=5, + json=False, + task_defs=None, + list_prereqs=False, + ) + schd = scheduler( + flow( + { + 'scheduling': { + 'graph': {'R1': 'zed & dog & cat & ant'}, + }, + } + ), + paused_start=False + ) + async with start(schd): + await schd.update_data_structure() + ret = await show( + schd.workflow, + [Tokens('//1/*')], + opts, + ) + assert ret == 0 + + out, _ = capsys.readouterr() + assert [ + line for line in out.splitlines() + if line.startswith("Task ID") + ] == [ # results should be sorted + 'Task ID: 1/ant', + 'Task ID: 1/cat', + 'Task ID: 1/dog', + 'Task ID: 1/zed', + ] From a433765d4f7b9de447bc39184ba991514f8de946 Mon Sep 17 00:00:00 2001 From: Ronnie Dutta <61982285+MetRonnie@users.noreply.github.com> Date: Tue, 6 Aug 2024 10:58:51 +0100 Subject: [PATCH 135/196] `cylc play` (restart): fix bug affecting run host reinvocation after interactive upgrade (#6267) Ensure reinvocation works for interactively upgraded workflow --- changes.d/6267.fix.md | 1 + cylc/flow/scheduler_cli.py | 35 +++++++-------- tests/unit/test_scheduler_cli.py | 74 ++++++++++++++++++++++++++------ 3 files changed, 79 insertions(+), 31 deletions(-) create mode 100644 changes.d/6267.fix.md diff --git a/changes.d/6267.fix.md b/changes.d/6267.fix.md new file mode 100644 index 00000000000..9842ba095a6 --- /dev/null +++ b/changes.d/6267.fix.md @@ -0,0 +1 @@ +Fixed bug in `cylc play` affecting run host reinvocation after interactively upgrading the workflow to a new Cylc version. \ No newline at end of file diff --git a/cylc/flow/scheduler_cli.py b/cylc/flow/scheduler_cli.py index c014697160a..0594820685a 100644 --- a/cylc/flow/scheduler_cli.py +++ b/cylc/flow/scheduler_cli.py @@ -390,11 +390,7 @@ async def scheduler_cli( # check the workflow can be safely restarted with this version of Cylc db_file = Path(get_workflow_srv_dir(workflow_id), 'db') - if not _version_check( - db_file, - options.upgrade, - options.downgrade, - ): + if not _version_check(db_file, options): sys.exit(1) # upgrade the workflow DB (after user has confirmed upgrade) @@ -404,7 +400,7 @@ async def scheduler_cli( _print_startup_message(options) # re-execute on another host if required - _distribute(options.host, workflow_id_raw, workflow_id, options.color) + _distribute(workflow_id_raw, workflow_id, options) # setup the scheduler # NOTE: asyncio.run opens an event loop, runs your coro, @@ -474,8 +470,7 @@ async def _resume(workflow_id, options): def _version_check( db_file: Path, - can_upgrade: bool, - can_downgrade: bool + options: 'Values', ) -> bool: """Check the workflow can be safely restarted with this version of Cylc.""" if not db_file.is_file(): @@ -491,7 +486,7 @@ def _version_check( )): if this < that: # restart would REDUCE the Cylc version - if can_downgrade: + if options.downgrade: # permission to downgrade given in CLI flags LOG.warning( 'Restarting with an older version of Cylc' @@ -517,7 +512,7 @@ def _version_check( return False elif itt < 2 and this > that: # restart would INCREASE the Cylc version in a big way - if can_upgrade: + if options.upgrade: # permission to upgrade given in CLI flags LOG.warning( 'Restarting with a newer version of Cylc' @@ -531,7 +526,7 @@ def _version_check( )) if is_terminal(): # we are in interactive mode, ask the user if this is ok - return prompt( + options.upgrade = prompt( cparse( 'Are you sure you want to upgrade from' f' {last_run_version}' @@ -540,6 +535,7 @@ def _version_check( {'y': True, 'n': False}, process=str.lower, ) + return options.upgrade # we are in non-interactive mode, abort abort abort print('Use "--upgrade" to upgrade the workflow.', file=sys.stderr) return False @@ -580,22 +576,23 @@ def _print_startup_message(options): LOG.warning(SUITERC_DEPR_MSG) -def _distribute(host, workflow_id_raw, workflow_id, color): +def _distribute( + workflow_id_raw: str, workflow_id: str, options: 'Values' +) -> None: """Re-invoke this command on a different host if requested. Args: - host: - The remote host to re-invoke on. workflow_id_raw: The workflow ID as it appears in the CLI arguments. workflow_id: The workflow ID after it has gone through the CLI. This may be different (i.e. the run name may have been inferred). + options: + The CLI options. """ # Check whether a run host is explicitly specified, else select one. - if not host: - host = select_workflow_host()[0] + host = options.host or select_workflow_host()[0] if is_remote_host(host): # Protect command args from second shell interpretation cmd = list(map(quote, sys.argv[1:])) @@ -612,8 +609,12 @@ def _distribute(host, workflow_id_raw, workflow_id, color): # Prevent recursive host selection cmd.append("--host=localhost") + # Ensure interactive upgrade carries over: + if options.upgrade and '--upgrade' not in cmd: + cmd.append('--upgrade') + # Preserve CLI colour - if is_terminal() and color != 'never': + if is_terminal() and options.color != 'never': # the detached process doesn't pass the is_terminal test # so we have to explicitly tell Cylc to use color cmd.append('--color=always') diff --git a/tests/unit/test_scheduler_cli.py b/tests/unit/test_scheduler_cli.py index a08e4d8e161..85cc80d539b 100644 --- a/tests/unit/test_scheduler_cli.py +++ b/tests/unit/test_scheduler_cli.py @@ -14,16 +14,15 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from contextlib import contextmanager import sqlite3 +from contextlib import contextmanager import pytest from cylc.flow.exceptions import ServiceFileError -from cylc.flow.scheduler_cli import ( - _distribute, - _version_check, -) +from cylc.flow.scheduler_cli import RunOptions, _distribute, _version_check + +from .conftest import MonkeyMock @pytest.fixture @@ -184,7 +183,28 @@ def test_version_check_interactive( db_file = stopped_workflow_db(before) set_cylc_version(after) with answer(response): - assert _version_check(db_file, False, downgrade) is outcome + assert ( + _version_check( + db_file, RunOptions(downgrade=downgrade) + ) + is outcome + ) + + +def test_version_check_interactive_upgrade( + stopped_workflow_db, + set_cylc_version, + interactive, + answer, +): + """If a user interactively upgrades, it should set the upgrade option.""" + db_file = stopped_workflow_db('8.0.0') + set_cylc_version('8.1.0') + opts = RunOptions() + assert opts.upgrade is False + with answer(True): + assert _version_check(db_file, opts) is True + assert opts.upgrade is True def test_version_check_non_interactive( @@ -200,15 +220,19 @@ def test_version_check_non_interactive( # upgrade db_file = stopped_workflow_db('8.0.0') set_cylc_version('8.1.0') - assert _version_check(db_file, False, False) is False - assert _version_check(db_file, True, False) is True # CLI --upgrade + assert _version_check(db_file, RunOptions()) is False + assert ( + _version_check(db_file, RunOptions(upgrade=True)) is True + ) # CLI --upgrade # downgrade db_file.unlink() db_file = stopped_workflow_db('8.1.0') set_cylc_version('8.0.0') - assert _version_check(db_file, False, False) is False - assert _version_check(db_file, False, True) is True # CLI --downgrade + assert _version_check(db_file, RunOptions()) is False + assert ( + _version_check(db_file, RunOptions(downgrade=True)) is True + ) # CLI --downgrade def test_version_check_incompat(tmp_path): @@ -216,13 +240,13 @@ def test_version_check_incompat(tmp_path): db_file = tmp_path / 'db' # invalid DB file db_file.touch() with pytest.raises(ServiceFileError): - _version_check(db_file, False, False) + _version_check(db_file, RunOptions()) def test_version_check_no_db(tmp_path): """It should pass if there is no DB file (e.g. on workflow first start).""" db_file = tmp_path / 'db' # non-existent file - assert _version_check(db_file, False, False) + assert _version_check(db_file, RunOptions()) @pytest.mark.parametrize( @@ -253,9 +277,31 @@ def test_distribute_colour( See https://github.com/cylc/cylc-flow/issues/5159 """ - monkeymock('cylc.flow.scheduler_cli.sys.exit') _is_terminal = monkeymock('cylc.flow.scheduler_cli.is_terminal') _is_terminal.return_value = is_terminal _cylc_server_cmd = monkeymock('cylc.flow.scheduler_cli.cylc_server_cmd') - _distribute('myhost', 'foo', 'foo/run1', cli_colour) + opts = RunOptions(host='myhost', color=cli_colour) + with pytest.raises(SystemExit) as excinfo: + _distribute('foo', 'foo/run1', opts) + assert excinfo.value.code == 0 assert distribute_colour in _cylc_server_cmd.call_args[0][0] + + +def test_distribute_upgrade( + monkeymock: MonkeyMock, monkeypatch: pytest.MonkeyPatch +): + """It should start detached workflows with the --upgrade option if the user + has interactively chosen to upgrade (typed 'y' at prompt). + """ + monkeypatch.setattr( + 'sys.argv', ['cylc', 'play', 'foo'] # no upgrade option here + ) + _cylc_server_cmd = monkeymock('cylc.flow.scheduler_cli.cylc_server_cmd') + opts = RunOptions( + host='myhost', + upgrade=True, # added by interactive upgrade + ) + with pytest.raises(SystemExit) as excinfo: + _distribute('foo', 'foo/run1', opts) + assert excinfo.value.code == 0 + assert '--upgrade' in _cylc_server_cmd.call_args[0][0] From 72352aaa5e808f3a266ed237e150e336960b4f29 Mon Sep 17 00:00:00 2001 From: Ronnie Dutta <61982285+MetRonnie@users.noreply.github.com> Date: Thu, 8 Aug 2024 13:02:08 +0100 Subject: [PATCH 136/196] Functional tests: fix `contains_ok` --- tests/functional/lib/bash/test_header | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/functional/lib/bash/test_header b/tests/functional/lib/bash/test_header index 016b77635f0..1ac1e2cd504 100644 --- a/tests/functional/lib/bash/test_header +++ b/tests/functional/lib/bash/test_header @@ -386,7 +386,7 @@ contains_ok() { local FILE_CONTROL="${2:--}" local TEST_NAME TEST_NAME="$(basename "${FILE_TEST}")-contains-ok" - LANG=C comm -13 <(sort "${FILE_TEST}") <(sort "${FILE_CONTROL}") \ + LANG=C comm -13 <(LANG=C sort "${FILE_TEST}") <(LANG=C sort "${FILE_CONTROL}") \ 1>"${TEST_NAME}.stdout" 2>"${TEST_NAME}.stderr" if [[ -s "${TEST_NAME}.stdout" ]]; then mkdir -p "${TEST_LOG_DIR}" From 9dc786c47ca8cd4af2fc8b0da288350322ea36a1 Mon Sep 17 00:00:00 2001 From: Ronnie Dutta <61982285+MetRonnie@users.noreply.github.com> Date: Thu, 8 Aug 2024 14:14:48 +0100 Subject: [PATCH 137/196] Logging: demote "timer stopped" warnings to info level --- cylc/flow/timer.py | 2 +- tests/unit/test_timer.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/cylc/flow/timer.py b/cylc/flow/timer.py index cbc4a644af6..80c68a0e696 100644 --- a/cylc/flow/timer.py +++ b/cylc/flow/timer.py @@ -52,7 +52,7 @@ def stop(self) -> None: if self.timeout is None: return self.timeout = None - LOG.warning(f"{self.name} stopped") + LOG.info(f"{self.name} stopped") def timed_out(self) -> bool: """Return whether timed out yet.""" diff --git a/tests/unit/test_timer.py b/tests/unit/test_timer.py index 3cd6704752e..d1d5abf7758 100644 --- a/tests/unit/test_timer.py +++ b/tests/unit/test_timer.py @@ -14,6 +14,7 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . +import logging from time import sleep import pytest @@ -23,7 +24,7 @@ def test_Timer(caplog: pytest.LogCaptureFixture): """Test the Timer class.""" - caplog.set_level('WARNING') + caplog.set_level(logging.INFO) timer = Timer("bob timeout", 1.0) # timer attributes From a3a87df323b4060f2fbc4f821ac89d633f85e8f0 Mon Sep 17 00:00:00 2001 From: Oliver Sanders Date: Thu, 27 Jun 2024 15:55:52 +0100 Subject: [PATCH 138/196] workflow_state: reject invalid configurations * Do not allow users to poll for transient statuses. * Reject invalid task states. * Reject polling waiting and preparing tasks (not reliably pollable). * Closes #6157 --- changes.d/6175.fix.md | 1 + cylc/flow/dbstatecheck.py | 28 ++++++++++++ cylc/flow/scripts/workflow_state.py | 39 ++++++++++------ cylc/flow/task_state.py | 28 ++++++++---- cylc/flow/xtriggers/workflow_state.py | 17 ++++++- .../xtriggers/01-workflow_state/flow.cylc | 2 +- .../workflow-state/11-multi/flow.cylc | 2 +- tests/unit/test_dbstatecheck.py | 44 +++++++++++++++++++ tests/unit/xtriggers/test_workflow_state.py | 32 ++++++++++++++ 9 files changed, 167 insertions(+), 26 deletions(-) create mode 100644 changes.d/6175.fix.md create mode 100644 tests/unit/test_dbstatecheck.py diff --git a/changes.d/6175.fix.md b/changes.d/6175.fix.md new file mode 100644 index 00000000000..b135207930e --- /dev/null +++ b/changes.d/6175.fix.md @@ -0,0 +1 @@ +The workflow-state command and xtrigger will now reject invalid polling arguments. diff --git a/cylc/flow/dbstatecheck.py b/cylc/flow/dbstatecheck.py index b38c394ccdb..2de4bd7b3b5 100644 --- a/cylc/flow/dbstatecheck.py +++ b/cylc/flow/dbstatecheck.py @@ -36,6 +36,10 @@ TASK_OUTPUT_FAILED, TASK_OUTPUT_FINISHED, ) +from cylc.flow.task_state import ( + TASK_STATE_MAP, + TASK_STATUSES_FINAL, +) from cylc.flow.util import deserialise_set from metomi.isodatetime.parsers import TimePointParser from metomi.isodatetime.exceptions import ISO8601SyntaxError @@ -244,6 +248,8 @@ def workflow_state_query( stmt_args = [] stmt_wheres = [] + check_polling_config(selector, is_trigger, is_message) + if is_trigger or is_message: target_table = CylcWorkflowDAO.TABLE_TASK_OUTPUTS mask = "name, cycle, outputs" @@ -363,3 +369,25 @@ def _selector_in_outputs(selector: str, outputs: Iterable[str]) -> bool: or TASK_OUTPUT_FAILED in outputs ) ) + + +def check_polling_config(selector, is_trigger, is_message): + """Check for invalid or unreliable polling configurations.""" + if selector and not (is_trigger or is_message): + # we are using task status polling + try: + trigger = TASK_STATE_MAP[selector] + except KeyError: + raise InputError(f'No such task state "{selector}"') + else: + if trigger is None: + raise InputError( + f'Cannot poll for the "{selector}" task state' + ) + + if selector not in TASK_STATUSES_FINAL: + raise InputError( + f'Polling for the "{selector}" task status is not' + ' reliable as it is a transient state.' + f'\nPoll for the "{trigger}" trigger instead.' + ) diff --git a/cylc/flow/scripts/workflow_state.py b/cylc/flow/scripts/workflow_state.py index f6350110223..eed189a7113 100755 --- a/cylc/flow/scripts/workflow_state.py +++ b/cylc/flow/scripts/workflow_state.py @@ -33,8 +33,8 @@ so you can start checking before the target workflow is started. Legacy (pre-8.3.0) options are supported, but deprecated, for existing scripts: - cylc workflow-state --task=NAME --point=CYCLE --status=STATUS - --output=MESSAGE --message=MESSAGE --task-point WORKFLOW + cylc workflow-state --task=NAME --point=CYCLE --status=STATUS + --output=MESSAGE --message=MESSAGE --task-point WORKFLOW (Note from 8.0 until 8.3.0 --output and --message both match task messages). In "cycle/task:selector" the selector will match task statuses, unless: @@ -55,24 +55,23 @@ Flow numbers are only printed for flow numbers > 1. -USE IN TASK SCRIPTING: +Use in task scripting: - To poll a task at the same cycle point in another workflow, just use $CYLC_TASK_CYCLE_POINT in the ID. - To poll a task at an offset cycle point, use the --offset option to have Cylc do the datetime arithmetic for you. - However, see also the workflow_state xtrigger for this use case. -WARNINGS: - - Typos in the workflow or task ID will result in fruitless polling. - - To avoid missing transient states ("submitted", "running") poll for the - corresponding output trigger instead ("submitted", "started"). - - Cycle points are auto-converted to the DB point format (and UTC mode). - - Task outputs manually completed by "cylc set" have "(force-completed)" - recorded as the task message in the DB, so it is best to query trigger - names, not messages, unless specifically interested in forced outputs. +Warnings: + - Typos in the workflow or task ID will result in fruitless polling. + - To avoid missing transient states ("submitted", "running") poll for the + corresponding output trigger instead ("submitted", "started"). + - Cycle points are auto-converted to the DB point format (and UTC mode). + - Task outputs manually completed by "cylc set" have "(force-completed)" + recorded as the task message in the DB, so it is best to query trigger + names, not messages, unless specifically interested in forced outputs. Examples: - # Print the status of all tasks in WORKFLOW: $ cylc workflow-state WORKFLOW @@ -115,7 +114,11 @@ from cylc.flow.dbstatecheck import CylcWorkflowDBChecker from cylc.flow.terminal import cli_function from cylc.flow.workflow_files import infer_latest_run_from_id -from cylc.flow.task_state import TASK_STATUSES_ORDERED +from cylc.flow.task_state import ( + TASK_STATUSES_ORDERED, + TASK_STATUSES_FINAL, + TASK_STATUSES_ALL, +) if TYPE_CHECKING: from optparse import Values @@ -363,7 +366,6 @@ def get_option_parser() -> COP: @cli_function(get_option_parser, remove_opts=["--db"]) def main(parser: COP, options: 'Values', *ids: str) -> None: - # Note it would be cleaner to use 'id_cli.parse_ids()' here to get the # workflow ID and tokens, but that function infers run number and fails # if the workflow is not installed yet. We want to be able to start polling @@ -427,6 +429,15 @@ def main(parser: COP, options: 'Values', *ids: str) -> None: msg += id_ else: msg += id_.replace(options.depr_point, "$CYLC_TASK_CYCLE_POINT") + + if ( + options.depr_status + and options.depr_status in TASK_STATUSES_ALL + and options.depr_status not in TASK_STATUSES_FINAL + ): + # polling for non-final task statuses is flaky + msg += ' and the --triggers option' + LOG.warning(msg) poller = WorkflowPoller( diff --git a/cylc/flow/task_state.py b/cylc/flow/task_state.py index ebb3dbc985b..5b77aedf79f 100644 --- a/cylc/flow/task_state.py +++ b/cylc/flow/task_state.py @@ -19,7 +19,15 @@ from typing import List, Iterable, Set, TYPE_CHECKING from cylc.flow.prerequisite import Prerequisite -from cylc.flow.task_outputs import TaskOutputs +from cylc.flow.task_outputs import ( + TASK_OUTPUT_EXPIRED, + TASK_OUTPUT_FAILED, + TASK_OUTPUT_STARTED, + TASK_OUTPUT_SUBMITTED, + TASK_OUTPUT_SUBMIT_FAILED, + TASK_OUTPUT_SUCCEEDED, + TaskOutputs, +) from cylc.flow.wallclock import get_current_time_string if TYPE_CHECKING: @@ -144,13 +152,17 @@ TASK_STATUS_RUNNING, } -# Task statuses that can be manually triggered. -TASK_STATUSES_TRIGGERABLE = { - TASK_STATUS_WAITING, - TASK_STATUS_EXPIRED, - TASK_STATUS_SUBMIT_FAILED, - TASK_STATUS_SUCCEEDED, - TASK_STATUS_FAILED, +# Mapping between task outputs and their corresponding states +TASK_STATE_MAP = { + # status: trigger + TASK_STATUS_WAITING: None, + TASK_STATUS_EXPIRED: TASK_OUTPUT_EXPIRED, + TASK_STATUS_PREPARING: None, + TASK_STATUS_SUBMIT_FAILED: TASK_OUTPUT_SUBMIT_FAILED, + TASK_STATUS_SUBMITTED: TASK_OUTPUT_SUBMITTED, + TASK_STATUS_RUNNING: TASK_OUTPUT_STARTED, + TASK_STATUS_FAILED: TASK_OUTPUT_FAILED, + TASK_STATUS_SUCCEEDED: TASK_OUTPUT_SUCCEEDED, } diff --git a/cylc/flow/xtriggers/workflow_state.py b/cylc/flow/xtriggers/workflow_state.py index e6025a561f8..14948a43390 100644 --- a/cylc/flow/xtriggers/workflow_state.py +++ b/cylc/flow/xtriggers/workflow_state.py @@ -20,8 +20,12 @@ from cylc.flow.scripts.workflow_state import WorkflowPoller from cylc.flow.id import tokenise -from cylc.flow.exceptions import WorkflowConfigError +from cylc.flow.exceptions import WorkflowConfigError, InputError from cylc.flow.task_state import TASK_STATUS_SUCCEEDED +from cylc.flow.dbstatecheck import check_polling_config + + +DEFAULT_STATUS = TASK_STATUS_SUCCEEDED def workflow_state( @@ -84,7 +88,7 @@ def workflow_state( offset, flow_num, alt_cylc_run_dir, - TASK_STATUS_SUCCEEDED, + DEFAULT_STATUS, is_trigger, is_message, old_format=False, condition=workflow_task_id, @@ -151,6 +155,15 @@ def validate(args: Dict[str, Any]): ): raise WorkflowConfigError("flow_num must be an integer if given.") + try: + check_polling_config( + tokens['cycle_sel'] or tokens['task_sel'] or DEFAULT_STATUS, + args['is_trigger'], + args['is_message'], + ) + except InputError as exc: + raise WorkflowConfigError(str(exc)) from None + # BACK COMPAT: workflow_state_backcompat # from: 8.0.0 diff --git a/tests/flakyfunctional/xtriggers/01-workflow_state/flow.cylc b/tests/flakyfunctional/xtriggers/01-workflow_state/flow.cylc index 609b0be3a05..76b4efd2813 100644 --- a/tests/flakyfunctional/xtriggers/01-workflow_state/flow.cylc +++ b/tests/flakyfunctional/xtriggers/01-workflow_state/flow.cylc @@ -8,7 +8,7 @@ initial cycle point = 2011 final cycle point = 2016 [[xtriggers]] - upstream = workflow_state("{{UPSTREAM}}//%(point)s/foo:data_ready"):PT1S + upstream = workflow_state("{{UPSTREAM}}//%(point)s/foo:data_ready", is_trigger=True):PT1S [[graph]] P1Y = """ foo diff --git a/tests/functional/workflow-state/11-multi/flow.cylc b/tests/functional/workflow-state/11-multi/flow.cylc index a0ab61e9312..fc8928646ab 100644 --- a/tests/functional/workflow-state/11-multi/flow.cylc +++ b/tests/functional/workflow-state/11-multi/flow.cylc @@ -23,7 +23,7 @@ # Cylc 8 new (from 8.3.0) c1 = workflow_state(c8b//1/foo, offset=P0, alt_cylc_run_dir={{ALT}}):PT1S c2 = workflow_state(c8b//1/foo:succeeded, offset=P0, alt_cylc_run_dir={{ALT}}):PT1S - c3 = workflow_state(c8b//1/foo:x, offset=P0, alt_cylc_run_dir={{ALT}}):PT1S + c3 = workflow_state(c8b//1/foo:x, offset=P0, alt_cylc_run_dir={{ALT}}, is_trigger=True):PT1S c4 = workflow_state(c8b//1/foo:"the quick brown", offset=P0, is_message=True, alt_cylc_run_dir={{ALT}}):PT1S [[graph]] diff --git a/tests/unit/test_dbstatecheck.py b/tests/unit/test_dbstatecheck.py new file mode 100644 index 00000000000..7fd1de94edd --- /dev/null +++ b/tests/unit/test_dbstatecheck.py @@ -0,0 +1,44 @@ +# THIS FILE IS PART OF THE CYLC WORKFLOW ENGINE. +# Copyright (C) NIWA & British Crown (Met Office) & Contributors. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from cylc.flow.dbstatecheck import check_polling_config +from cylc.flow.exceptions import InputError + +import pytest + + +def test_check_polling_config(): + """It should reject invalid or unreliable polling configurations. + + See https://github.com/cylc/cylc-flow/issues/6157 + """ + # invalid polling use cases + with pytest.raises(InputError, match='No such task state'): + check_polling_config('elephant', False, False) + + with pytest.raises(InputError, match='Cannot poll for'): + check_polling_config('waiting', False, False) + + with pytest.raises(InputError, match='is not reliable'): + check_polling_config('running', False, False) + + # valid polling use cases + check_polling_config('started', True, False) + check_polling_config('started', False, True) + + # valid query use cases + check_polling_config(None, False, True) + check_polling_config(None, False, False) diff --git a/tests/unit/xtriggers/test_workflow_state.py b/tests/unit/xtriggers/test_workflow_state.py index 5420a4fd909..d376da6c727 100644 --- a/tests/unit/xtriggers/test_workflow_state.py +++ b/tests/unit/xtriggers/test_workflow_state.py @@ -263,6 +263,8 @@ def test_validate_ok(): """Validate returns ok with valid args.""" validate({ 'workflow_task_id': 'foo//1/bar', + 'is_trigger': False, + 'is_message': False, 'offset': 'PT1H', 'flow_num': 44, }) @@ -292,3 +294,33 @@ def test_validate_fail_non_int_flow(flow_num): 'offset': 'PT1H', 'flow_num': flow_num, }) + + +def test_validate_polling_config(): + """It should reject invalid or unreliable polling configurations. + + See https://github.com/cylc/cylc-flow/issues/6157 + """ + with pytest.raises(WorkflowConfigError, match='No such task state'): + validate({ + 'workflow_task_id': 'foo//1/bar:elephant', + 'is_trigger': False, + 'is_message': False, + 'flow_num': 44, + }) + + with pytest.raises(WorkflowConfigError, match='Cannot poll for'): + validate({ + 'workflow_task_id': 'foo//1/bar:waiting', + 'is_trigger': False, + 'is_message': False, + 'flow_num': 44, + }) + + with pytest.raises(WorkflowConfigError, match='is not reliable'): + validate({ + 'workflow_task_id': 'foo//1/bar:submitted', + 'is_trigger': False, + 'is_message': False, + 'flow_num': 44, + }) From 00ba50d624a1fe601668ea538a0b82f6e60ef24b Mon Sep 17 00:00:00 2001 From: Tim Pillinger <26465611+wxtim@users.noreply.github.com> Date: Fri, 9 Aug 2024 16:22:32 +0100 Subject: [PATCH 139/196] Check run name as well as workflow name in Cylc install (#6264) --- changes.d/6264.fix.md | 1 + cylc/flow/install.py | 9 +++++--- tests/unit/test_install.py | 45 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 52 insertions(+), 3 deletions(-) create mode 100644 changes.d/6264.fix.md diff --git a/changes.d/6264.fix.md b/changes.d/6264.fix.md new file mode 100644 index 00000000000..3cea3d78cf6 --- /dev/null +++ b/changes.d/6264.fix.md @@ -0,0 +1 @@ +Fix bug where `cylc install` failed to prevent invalid run names. diff --git a/cylc/flow/install.py b/cylc/flow/install.py index 27810f72e97..eb59fdeb3d4 100644 --- a/cylc/flow/install.py +++ b/cylc/flow/install.py @@ -65,7 +65,6 @@ check_flow_file, get_cylc_run_abs_path, is_valid_run_dir, - check_reserved_dir_names, validate_workflow_name, ) @@ -285,13 +284,17 @@ def install_workflow( source = Path(expand_path(source)).resolve() if not workflow_name: workflow_name = get_source_workflow_name(source) - validate_workflow_name(workflow_name, check_reserved_names=True) if run_name is not None: if len(Path(run_name).parts) != 1: raise WorkflowFilesError( f'Run name cannot be a path. (You used {run_name})' ) - check_reserved_dir_names(run_name) + validate_workflow_name( + os.path.join(workflow_name, run_name), + check_reserved_names=True + ) + else: + validate_workflow_name(workflow_name, check_reserved_names=True) validate_source_dir(source, workflow_name) run_path_base = Path(get_workflow_run_dir(workflow_name)) relink, run_num, rundir = get_run_dir_info( diff --git a/tests/unit/test_install.py b/tests/unit/test_install.py index 1b8ad505d38..3230eea5d87 100644 --- a/tests/unit/test_install.py +++ b/tests/unit/test_install.py @@ -542,3 +542,48 @@ def test_validate_source_dir(tmp_run_dir: Callable, tmp_src_dir: Callable): with pytest.raises(WorkflowFilesError) as exc_info: validate_source_dir(src_dir, 'ajay') assert "exists in source directory" in str(exc_info.value) + + +def test_install_workflow_failif_name_name(tmp_src_dir): + """If a run_name is given validate_workflow_name is called on + the workflow and the run name in combination. + """ + src_dir: Path = tmp_src_dir('ludlow') + # It only has a workflow name: + with pytest.raises(WorkflowFilesError, match='can only contain'): + install_workflow(src_dir, workflow_name='foo?') + # It only has a run name: + with pytest.raises(WorkflowFilesError, match='can only contain'): + install_workflow(src_dir, run_name='foo?') + # It has a legal workflow name, but an invalid run name: + with pytest.raises(WorkflowFilesError, match='can only contain'): + install_workflow(src_dir, workflow_name='foo', run_name='bar?') + + +def test_install_workflow_failif_reserved_name(tmp_src_dir): + """Reserved names cause install validation failure. + + n.b. manually defined to avoid test dependency on workflow_files. + """ + src_dir = tmp_src_dir('ludlow') + is_reserved = '(that filename is reserved)' + reserved_names = { + 'share', + 'log', + 'runN', + 'suite.rc', + 'work', + '_cylc-install', + 'flow.cylc', + # .service fails because starting a workflow/run can't start with "." + # And that check fails first. + # '.service' + } + install_workflow(src_dir, workflow_name='ok', run_name='also_ok') + for name in reserved_names: + with pytest.raises(WorkflowFilesError, match=is_reserved): + install_workflow(src_dir, workflow_name='ok', run_name=name) + with pytest.raises(WorkflowFilesError, match=is_reserved): + install_workflow(src_dir, workflow_name=name) + with pytest.raises(WorkflowFilesError, match=is_reserved): + install_workflow(src_dir, workflow_name=name, run_name='ok') From 80cb5848e5eb39658829eb337fc57123c122bb5a Mon Sep 17 00:00:00 2001 From: Ronnie Dutta <61982285+MetRonnie@users.noreply.github.com> Date: Tue, 13 Aug 2024 16:31:52 +0100 Subject: [PATCH 140/196] Fix unbound env var failure --- tests/functional/lib/bash/test_header | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/functional/lib/bash/test_header b/tests/functional/lib/bash/test_header index 1ac1e2cd504..f9b58a35f75 100644 --- a/tests/functional/lib/bash/test_header +++ b/tests/functional/lib/bash/test_header @@ -784,7 +784,7 @@ skip_all() { skip_macos_gh_actions() { # https://github.com/cylc/cylc-flow/issues/6276 - if [[ "$CI" && "$OSTYPE" == "darwin"* ]]; then + if [[ "${CI:-}" && "$OSTYPE" == "darwin"* ]]; then skip_all "Skipped due to performance issues on GH Actions MacOS runner" fi } From f4413b007ac857e04599f28dc01884cd6d54eafd Mon Sep 17 00:00:00 2001 From: Ronnie Dutta <61982285+MetRonnie@users.noreply.github.com> Date: Tue, 20 Aug 2024 14:52:19 +0100 Subject: [PATCH 141/196] GH Actions: include hidden files when uploading `~/cylc-run` artifact --- .github/workflows/bash.yml | 1 + .github/workflows/test_fast.yml | 1 + .github/workflows/test_functional.yml | 1 + 3 files changed, 3 insertions(+) diff --git a/.github/workflows/bash.yml b/.github/workflows/bash.yml index eb34e3ad76d..9938c4c4ec1 100644 --- a/.github/workflows/bash.yml +++ b/.github/workflows/bash.yml @@ -89,3 +89,4 @@ jobs: with: name: 'cylc-run (bash-${{ matrix.bash-version }})' path: cylc-run + include-hidden-files: true diff --git a/.github/workflows/test_fast.yml b/.github/workflows/test_fast.yml index db1a7335553..b58bc50ed99 100644 --- a/.github/workflows/test_fast.yml +++ b/.github/workflows/test_fast.yml @@ -75,6 +75,7 @@ jobs: with: name: cylc-run (${{ matrix.os }} py-${{ matrix.python-version }}) path: ~/cylc-run/ + include-hidden-files: true - name: Coverage report run: | diff --git a/.github/workflows/test_functional.yml b/.github/workflows/test_functional.yml index 116620cdf35..f055353d904 100644 --- a/.github/workflows/test_functional.yml +++ b/.github/workflows/test_functional.yml @@ -282,6 +282,7 @@ jobs: with: name: cylc-run (${{ steps.uploadname.outputs.uploadname }}) path: ~/cylc-run/ + include-hidden-files: true - name: Fetch Remote Coverage if: env.REMOTE_PLATFORM == 'true' From 70aca62140e23a56a3834ac757875de18ffd3ef9 Mon Sep 17 00:00:00 2001 From: Oliver Sanders Date: Tue, 20 Aug 2024 16:12:01 +0100 Subject: [PATCH 142/196] play: fix spurious traceback observed on some systems * Closes #6291 * The `cylc play` command would sometimes produce traceback when detaching workflows (the default unless `--no-detach` is used). * This traceback does not appear to have had any ill effects, but may have suppressed the normal Python session teardown logic. * It was only reported on Mac OS, but may potentially occur on other systems. * This PR mitigates the circumstances under which the traceback occurred by separating the asyncio event loops that are run before and after daemonization. --- changes.d/6310.fix.md | 1 + cylc/flow/scheduler_cli.py | 67 +++++++++++++++++----- cylc/flow/scripts/validate_install_play.py | 4 +- cylc/flow/scripts/validate_reinstall.py | 42 ++++++++++---- 4 files changed, 88 insertions(+), 26 deletions(-) create mode 100644 changes.d/6310.fix.md diff --git a/changes.d/6310.fix.md b/changes.d/6310.fix.md new file mode 100644 index 00000000000..66e8c8557d4 --- /dev/null +++ b/changes.d/6310.fix.md @@ -0,0 +1 @@ +Fix a spurious traceback that could occur when running the `cylc play` command on Mac OS. diff --git a/cylc/flow/scheduler_cli.py b/cylc/flow/scheduler_cli.py index 0594820685a..390eba52338 100644 --- a/cylc/flow/scheduler_cli.py +++ b/cylc/flow/scheduler_cli.py @@ -23,7 +23,7 @@ from pathlib import Path from shlex import quote import sys -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Tuple from packaging.version import Version @@ -351,12 +351,12 @@ def _open_logs(id_: str, no_detach: bool, restart_num: int) -> None: ) -async def scheduler_cli( +async def _scheduler_cli_1( options: 'Values', workflow_id_raw: str, parse_workflow_id: bool = True -) -> None: - """Run the workflow. +) -> Tuple[Scheduler, str]: + """Run the workflow (part 1 - async). This function should contain all of the command line facing functionality of the Scheduler, exit codes, logging, etc. @@ -408,6 +408,14 @@ async def scheduler_cli( scheduler = Scheduler(workflow_id, options) await _setup(scheduler) + return scheduler, workflow_id + + +def _scheduler_cli_2( + options: 'Values', + scheduler: Scheduler, +) -> None: + """Run the workflow (part 2 - sync).""" # daemonize if requested # NOTE: asyncio event loops cannot persist across daemonization # ensure you have tidied up all threads etc before daemonizing @@ -415,6 +423,13 @@ async def scheduler_cli( from cylc.flow.daemonize import daemonize daemonize(scheduler) + +async def _scheduler_cli_3( + options: 'Values', + workflow_id: str, + scheduler: Scheduler, +) -> None: + """Run the workflow (part 3 - async).""" # setup loggers _open_logs( workflow_id, @@ -423,14 +438,7 @@ async def scheduler_cli( ) # run the workflow - if options.no_detach: - ret = await _run(scheduler) - else: - # Note: The daemonization messes with asyncio so we have to start a - # new event loop if detaching - ret = asyncio.run( - _run(scheduler) - ) + ret = await _run(scheduler) # exit # NOTE: we must clean up all asyncio / threading stuff before exiting @@ -658,5 +666,36 @@ async def _run(scheduler: Scheduler) -> int: @cli_function(get_option_parser) def play(parser: COP, options: 'Values', id_: str): - """Implement cylc play.""" - return asyncio.run(scheduler_cli(options, id_)) + cylc_play(options, id_) + + +def cylc_play(options: 'Values', id_: str, parse_workflow_id=True) -> None: + """Implement cylc play. + + Raises: + CylcError: + If this function is called whilst an asyncio event loop is running. + + Because the scheduler process can be daemonised, this must not be + called whilst an asyncio event loop is active as memory associated + with this event loop will also exist in the new fork leading to + potentially strange problems. + + See https://github.com/cylc/cylc-flow/issues/6291 + + """ + try: + # try opening an event loop to make sure there isn't one already open + asyncio.get_running_loop() + except RuntimeError: + # start/restart/resume the workflow + scheduler, workflow_id = asyncio.run( + _scheduler_cli_1(options, id_, parse_workflow_id=parse_workflow_id) + ) + _scheduler_cli_2(options, scheduler) + asyncio.run(_scheduler_cli_3(options, workflow_id, scheduler)) + else: + # if this line every gets hit then there is a bug within Cylc + raise CylcError( + 'cylc_play called whilst asyncio event loop is running' + ) from None diff --git a/cylc/flow/scripts/validate_install_play.py b/cylc/flow/scripts/validate_install_play.py index d701eb02315..3312ff90a6b 100644 --- a/cylc/flow/scripts/validate_install_play.py +++ b/cylc/flow/scripts/validate_install_play.py @@ -40,7 +40,7 @@ cleanup_sysargv, log_subcommand, ) -from cylc.flow.scheduler_cli import scheduler_cli as cylc_play +from cylc.flow.scheduler_cli import cylc_play from cylc.flow.scripts.validate import ( VALIDATE_OPTIONS, run as cylc_validate, @@ -120,4 +120,4 @@ def main(parser: COP, options: 'Values', workflow_id: Optional[str] = None): set_timestamps(LOG, options.log_timestamp) log_subcommand(*sys.argv[1:]) - asyncio.run(cylc_play(options, workflow_id)) + cylc_play(options, workflow_id) diff --git a/cylc/flow/scripts/validate_reinstall.py b/cylc/flow/scripts/validate_reinstall.py index fa53461b2c3..1385ad20bc1 100644 --- a/cylc/flow/scripts/validate_reinstall.py +++ b/cylc/flow/scripts/validate_reinstall.py @@ -39,8 +39,9 @@ in the installed workflow to ensure the change can be safely applied. """ +import asyncio import sys -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Union if TYPE_CHECKING: from optparse import Values @@ -59,7 +60,7 @@ log_subcommand, cleanup_sysargv ) -from cylc.flow.scheduler_cli import PLAY_OPTIONS, scheduler_cli +from cylc.flow.scheduler_cli import PLAY_OPTIONS, cylc_play from cylc.flow.scripts.validate import ( VALIDATE_OPTIONS, VALIDATE_AGAINST_SOURCE_OPTION, @@ -76,8 +77,6 @@ from cylc.flow.terminal import cli_function from cylc.flow.workflow_files import detect_old_contact_file -import asyncio - CYLC_ROSE_OPTIONS = COP.get_cylc_rose_options() VR_OPTIONS = combine_options( VALIDATE_OPTIONS, @@ -127,11 +126,32 @@ def check_tvars_and_workflow_stopped( @cli_function(get_option_parser) def main(parser: COP, options: 'Values', workflow_id: str): - sys.exit(asyncio.run(vr_cli(parser, options, workflow_id))) + ret = asyncio.run(vr_cli(parser, options, workflow_id)) + if isinstance(ret, str): + # NOTE: cylc_play must be called from sync code (not async code) + cylc_play(options, ret, parse_workflow_id=False) + elif ret is False: + sys.exit(1) + + +async def vr_cli( + parser: COP, options: 'Values', workflow_id: str +) -> Union[bool, str]: + """Validate and reinstall and optionally reload workflow. + + Runs: + * Validate + * Reinstall + * Reload (if the workflow is already running) + Returns: + The workflow_id or a True/False outcome. -async def vr_cli(parser: COP, options: 'Values', workflow_id: str): - """Run Cylc (re)validate - reinstall - reload in sequence.""" + workflow_id: If the workflow is stopped and requires restarting. + True: If workflow is running and does not require restarting. + False: If this command should "exit 1". + + """ # Attempt to work out whether the workflow is running. # We are trying to avoid reinstalling then subsequently being # unable to play or reload because we cannot identify workflow state. @@ -164,7 +184,7 @@ async def vr_cli(parser: COP, options: 'Values', workflow_id: str): if not check_tvars_and_workflow_stopped( workflow_running, options.templatevars, options.templatevars_file ): - return 1 + return False # Force on the against_source option: options.against_source = True @@ -188,12 +208,13 @@ async def vr_cli(parser: COP, options: 'Values', workflow_id: str): 'No changes to source: No reinstall or' f' {"reload" if workflow_running else "play"} required.' ) - return 1 + return False # Run reload if workflow is running or paused: if workflow_running: log_subcommand('reload', workflow_id) await cylc_reload(options, workflow_id) + return True # run play anyway, to play a stopped workflow: else: @@ -206,5 +227,6 @@ async def vr_cli(parser: COP, options: 'Values', workflow_id: str): script_opts=(*PLAY_OPTIONS, *parser.get_std_options()), source='', # Intentionally blank ) + log_subcommand(*sys.argv[1:]) - await scheduler_cli(options, workflow_id, parse_workflow_id=False) + return workflow_id From c087dc2f81aad8a865d572da03c3fd4efeb5bca3 Mon Sep 17 00:00:00 2001 From: Ronnie Dutta <61982285+MetRonnie@users.noreply.github.com> Date: Wed, 21 Aug 2024 11:01:56 +0100 Subject: [PATCH 143/196] Flake8: remove wrongly ignored error code (#6311) --- tox.ini | 3 --- 1 file changed, 3 deletions(-) diff --git a/tox.ini b/tox.ini index d9954dbb7e8..87d23419730 100644 --- a/tox.ini +++ b/tox.ini @@ -8,9 +8,6 @@ ignore= W504 ; "experimental" SIM9xx rules (flake8-simplify) SIM9 - ; suggests using f"{!r}" instead of manual quotes (flake8-bugbear) - ; Doesn't work at 3.7 - B028 per-file-ignores= ; TYPE_CHECKING block suggestions From 14d4ec008d0be9d545fd376c2e5f7be166ae435d Mon Sep 17 00:00:00 2001 From: Tim Pillinger <26465611+wxtim@users.noreply.github.com> Date: Thu, 22 Aug 2024 12:29:42 +0100 Subject: [PATCH 144/196] Fix `cylc lint` U013/U015 missing info (#6214) --- changes.d/6214.fix.md | 1 + cylc/flow/scripts/lint.py | 44 +++++++---- tests/unit/scripts/test_lint_checkers.py | 95 ++++++++++++++++++++++++ 3 files changed, 125 insertions(+), 15 deletions(-) create mode 100644 changes.d/6214.fix.md create mode 100644 tests/unit/scripts/test_lint_checkers.py diff --git a/changes.d/6214.fix.md b/changes.d/6214.fix.md new file mode 100644 index 00000000000..5c77f3bedcd --- /dev/null +++ b/changes.d/6214.fix.md @@ -0,0 +1 @@ +`cylc lint` rules U013 & U015 now tell you which deprecated variables you are using diff --git a/cylc/flow/scripts/lint.py b/cylc/flow/scripts/lint.py index d045ab70838..e9ef1db742e 100755 --- a/cylc/flow/scripts/lint.py +++ b/cylc/flow/scripts/lint.py @@ -150,7 +150,7 @@ } -LIST_ITEM = ' * ' +LIST_ITEM = '\n * ' deprecated_string_templates = { @@ -264,7 +264,12 @@ def check_for_suicide_triggers( def check_for_deprecated_environment_variables( line: str ) -> Union[bool, dict]: - """Warn that environment variables with SUITE in are deprecated""" + """Warn that environment variables with SUITE in are deprecated + + Examples: + >>> check_for_deprecated_environment_variables('CYLC_SUITE_HOST') + {'vars': ['CYLC_SUITE_HOST: CYLC_WORKFLOW_HOST']} + """ vars_found = [ f'{k}: {v}' for k, v in DEPRECATED_ENV_VARS.items() if k in line @@ -277,16 +282,21 @@ def check_for_deprecated_environment_variables( return False -def check_for_obsolete_environment_variables(line: str) -> List[str]: +def check_for_obsolete_environment_variables(line: str) -> Dict[str, List]: """Warn that environment variables are obsolete. Examples: >>> this = check_for_obsolete_environment_variables - >>> this('CYLC_SUITE_DEF_PATH') - ['CYLC_SUITE_DEF_PATH'] + >>> this('script = echo $CYLC_SUITE_DEF_PATH') + {'vars': ['CYLC_SUITE_DEF_PATH']} + >>> this('script = echo "irrelevent"') + {} """ - return [i for i in OBSOLETE_ENV_VARS if i in line] + vars_found = [i for i in OBSOLETE_ENV_VARS if i in line] + if vars_found: + return {'vars': vars_found} + return {} def check_for_deprecated_task_event_template_vars( @@ -298,13 +308,10 @@ def check_for_deprecated_task_event_template_vars( >>> this = check_for_deprecated_task_event_template_vars >>> this('hello = "My name is %(suite)s"') - {'list': ' * %(suite)s ⇒ %(workflow)s'} + {'suggest': '\\n * %(suite)s ⇒ %(workflow)s'} - >>> expect = {'list': ( - ... ' * %(suite)s ⇒ %(workflow)s * %(task_url)s' - ... ' - get ``URL`` (if set in :cylc:conf:`[meta]URL`)')} - >>> this('hello = "My name is %(suite)s, %(task_url)s"') == expect - True + >>> this('hello = "My name is %(suite)s, %(task_url)s"') + {'suggest': '\\n * %(suite)s ⇒ %(workf...cylc:conf:`[meta]URL`)'} """ result = [] for key, (regex, replacement) in deprecated_string_templates.items(): @@ -315,7 +322,7 @@ def check_for_deprecated_task_event_template_vars( result.append(f'%({key})s ⇒ %({replacement})s') if result: - return {'list': LIST_ITEM + LIST_ITEM.join(result)} + return {'suggest': LIST_ITEM + LIST_ITEM.join(result)} return None @@ -370,7 +377,14 @@ def check_indentation(line: str) -> bool: def check_lowercase_family_names(line: str) -> bool: - """Check for lowercase in family names.""" + """Check for lowercase in family names. + + Examples: + >>> check_lowercase_family_names(' inherit = FOO') + False + >>> check_lowercase_family_names(' inherit = foo') + True + """ match = INHERIT_REGEX.match(line) if not match: return False @@ -673,7 +687,7 @@ def list_wrapper(line: str, check: Callable) -> Optional[Dict[str, str]]: }, 'U015': { 'short': ( - 'Deprecated template variables.'), + 'Deprecated template variables: {suggest}'), 'rst': ( 'The following template variables, mostly used in event handlers,' 'are deprecated, and should be replaced:' diff --git a/tests/unit/scripts/test_lint_checkers.py b/tests/unit/scripts/test_lint_checkers.py new file mode 100644 index 00000000000..48b80733966 --- /dev/null +++ b/tests/unit/scripts/test_lint_checkers.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +# THIS FILE IS PART OF THE CYLC WORKFLOW ENGINE. +# Copyright (C) NIWA & British Crown (Met Office) & Contributors. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +"""Test check functions in the `cylc lint` CLI Utility.""" + +import doctest +import json +import pytest +import re + +from cylc.flow.scripts import lint + +VARS = re.compile(r'\{(.*)\}') + +# Functions in Cylc Lint defined with "check_* +CHECKERS = [ + getattr(lint, i) for i in lint.__dir__() if i.startswith('check_')] +# List of checks defined as checks by Cylc Lint +ALL_CHECKS = [ + *lint.MANUAL_DEPRECATIONS.values(), + *lint.STYLE_CHECKS.values(), +] + +finder = doctest.DocTestFinder() + + +@pytest.mark.parametrize( + 'check', + # Those checks that have custom checker functions + # and a short message with variables to insert: + [ + pytest.param(c, id=c.get('function').__name__) for c in ALL_CHECKS + if c.get('function') in CHECKERS + ] +) +def test_custom_checker_doctests(check): + """All check functions have at least one failure doctest + + By forcing each check function to have valid doctests + for the case that linting has failed we are able to + check that the function outputs the correct information + for formatting the short formats. + """ + doctests = finder.find(check['function'])[0] + + msg = f'{check["function"].__name__}: No failure examples in doctest' + assert any(i.want for i in doctests.examples if i.want), msg + + +@pytest.mark.parametrize( + 'ref, check', + # Those checks that have custom checker functions + # and a short message with variables to insert: + [ + (c.get('function'), c) for c in ALL_CHECKS + if c.get('function') in CHECKERS + and VARS.findall(c['short']) + ] +) +def test_custom_checkers_short_formatters(ref, check): + """If a check message has a format string assert that the checker + function will return a dict to be used in + ``check['short'].format(**kwargs)``, based on doctest output. + + ref is useful to allow us to identify the check, even + though not used in the test. + """ + doctests = finder.find(check['function'])[0] + + # Filter doctest examples for cases where there is a json parsable + # want. + examples = [ + eg for eg in [ + json.loads(e.want.replace("'", '"')) + for e in doctests.examples if e.want + ] + if eg + ] + + # Formatting using the example output changes the check short text: + for example in examples: + assert check['short'].format(**example) != check['short'] From ecf7879c965eeb0c2f43423742cba690146cad68 Mon Sep 17 00:00:00 2001 From: Ronnie Dutta <61982285+MetRonnie@users.noreply.github.com> Date: Thu, 22 Aug 2024 16:40:41 +0100 Subject: [PATCH 145/196] Tests: make `tmp_run_dir` fixture automatically patch `~/cylc-run` without having to call it --- tests/unit/conftest.py | 50 +++++++++++++++++++------------------- tests/unit/test_clean.py | 2 -- tests/unit/test_install.py | 2 -- 3 files changed, 25 insertions(+), 29 deletions(-) diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index 09dcbcd40ad..924b1295998 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -82,36 +82,36 @@ def _tmp_run_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): # Or: cylc_run_dir = tmp_run_dir() """ + cylc_run_dir = tmp_path / 'cylc-run' + cylc_run_dir.mkdir(exist_ok=True) + monkeypatch.setattr('cylc.flow.pathutil._CYLC_RUN_DIR', cylc_run_dir) + def __tmp_run_dir( id_: Optional[str] = None, installed: bool = False, named: bool = False ) -> Path: - nonlocal tmp_path - nonlocal monkeypatch - cylc_run_dir = tmp_path / 'cylc-run' - cylc_run_dir.mkdir(exist_ok=True) - monkeypatch.setattr('cylc.flow.pathutil._CYLC_RUN_DIR', cylc_run_dir) - if id_: - run_dir = cylc_run_dir.joinpath(id_) - run_dir.mkdir(parents=True, exist_ok=True) - (run_dir / WorkflowFiles.FLOW_FILE).touch(exist_ok=True) - (run_dir / WorkflowFiles.Service.DIRNAME).mkdir(exist_ok=True) - if run_dir.name.startswith('run'): - unlink_runN(run_dir.parent) - link_runN(run_dir) - if installed: - if named: - if len(Path(id_).parts) < 2: - raise ValueError("Named run requires two-level id_") - (run_dir.parent / WorkflowFiles.Install.DIRNAME).mkdir( - exist_ok=True) - else: - (run_dir / WorkflowFiles.Install.DIRNAME).mkdir( - exist_ok=True) - - return run_dir - return cylc_run_dir + if not id_: + return cylc_run_dir + + run_dir = cylc_run_dir.joinpath(id_) + run_dir.mkdir(parents=True, exist_ok=True) + (run_dir / WorkflowFiles.FLOW_FILE).touch(exist_ok=True) + (run_dir / WorkflowFiles.Service.DIRNAME).mkdir(exist_ok=True) + if run_dir.name.startswith('run'): + unlink_runN(run_dir.parent) + link_runN(run_dir) + if installed: + if named: + if len(Path(id_).parts) < 2: + raise ValueError("Named run requires two-level id_") + (run_dir.parent / WorkflowFiles.Install.DIRNAME).mkdir( + exist_ok=True) + else: + (run_dir / WorkflowFiles.Install.DIRNAME).mkdir(exist_ok=True) + + return run_dir + return __tmp_run_dir diff --git a/tests/unit/test_clean.py b/tests/unit/test_clean.py index a0c0ccdda1e..2a67daf4bf4 100644 --- a/tests/unit/test_clean.py +++ b/tests/unit/test_clean.py @@ -182,7 +182,6 @@ def test_init_clean__no_dir( ) -> None: """Test init_clean() when the run dir doesn't exist""" caplog.set_level(logging.INFO, CYLC_LOG) - tmp_run_dir() mock_clean = monkeymock('cylc.flow.clean.clean') mock_remote_clean = monkeymock('cylc.flow.clean.remote_clean') @@ -754,7 +753,6 @@ def test_clean__targeted( """ # --- Setup --- caplog.set_level(logging.DEBUG, CYLC_LOG) - tmp_run_dir() id_ = 'foo/bar' run_dir: Path files_to_delete: List[str] diff --git a/tests/unit/test_install.py b/tests/unit/test_install.py index 3230eea5d87..d5677b78eeb 100644 --- a/tests/unit/test_install.py +++ b/tests/unit/test_install.py @@ -84,7 +84,6 @@ def test_install_workflow__max_depth( prevent_symlinking, ): """Test that trying to install beyond max depth fails.""" - tmp_run_dir() src_dir = tmp_src_dir('bar') if err_expected: with pytest.raises(WorkflowFilesError) as exc_info: @@ -138,7 +137,6 @@ def test_install_workflow__symlink_target_exists( already exists.""" id_ = 'smeagol' src_dir: Path = tmp_src_dir(id_) - tmp_run_dir() sym_run = tmp_path / 'sym-run' sym_log = tmp_path / 'sym-log' mock_glbl_cfg( From 83b7db91d1659508d20108a2a586d70a21d89889 Mon Sep 17 00:00:00 2001 From: Ronnie Dutta <61982285+MetRonnie@users.noreply.github.com> Date: Thu, 22 Aug 2024 16:43:46 +0100 Subject: [PATCH 146/196] Prevent a couple of unit tests from installing into `~/cylc-run` --- tests/unit/test_install.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/test_install.py b/tests/unit/test_install.py index d5677b78eeb..b15ba3286f8 100644 --- a/tests/unit/test_install.py +++ b/tests/unit/test_install.py @@ -542,7 +542,7 @@ def test_validate_source_dir(tmp_run_dir: Callable, tmp_src_dir: Callable): assert "exists in source directory" in str(exc_info.value) -def test_install_workflow_failif_name_name(tmp_src_dir): +def test_install_workflow_failif_name_name(tmp_src_dir, tmp_run_dir): """If a run_name is given validate_workflow_name is called on the workflow and the run name in combination. """ @@ -558,7 +558,7 @@ def test_install_workflow_failif_name_name(tmp_src_dir): install_workflow(src_dir, workflow_name='foo', run_name='bar?') -def test_install_workflow_failif_reserved_name(tmp_src_dir): +def test_install_workflow_failif_reserved_name(tmp_src_dir, tmp_run_dir): """Reserved names cause install validation failure. n.b. manually defined to avoid test dependency on workflow_files. From 7e147521a67b11ef457d3739123dd832364df530 Mon Sep 17 00:00:00 2001 From: Ronnie Dutta <61982285+MetRonnie@users.noreply.github.com> Date: Fri, 23 Aug 2024 21:17:27 +0100 Subject: [PATCH 147/196] Remove pointless `if` statement --- cylc/flow/install.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/cylc/flow/install.py b/cylc/flow/install.py index eb59fdeb3d4..8ab619a323d 100644 --- a/cylc/flow/install.py +++ b/cylc/flow/install.py @@ -90,8 +90,7 @@ def _get_logger(rund, log_name, open_file=True): """ logger = logging.getLogger(log_name) - if logger.getEffectiveLevel != logging.INFO: - logger.setLevel(logging.INFO) + logger.setLevel(logging.INFO) if open_file and not logger.hasHandlers(): _open_install_log(rund, logger) return logger From 6856cee05b4cb688cb90db7c81eb632f0459b1a9 Mon Sep 17 00:00:00 2001 From: Ronnie Dutta <61982285+MetRonnie@users.noreply.github.com> Date: Fri, 23 Aug 2024 21:29:11 +0100 Subject: [PATCH 148/196] Correctly reset log level `Logger.getEffectiveLevel()` will return the root logger level if `Logger.level` is `NOTSET`. In pytest the root logger level is `WARNING`, so in the tests, there were cases where the Cylc logger was being incorrectly reset to `WARNING` instead of `NOTSET` --- cylc/flow/loggingutil.py | 2 +- cylc/flow/tui/util.py | 4 ++-- tests/unit/test_loggingutil.py | 30 ++++++++++++++++++++++++++++++ 3 files changed, 33 insertions(+), 3 deletions(-) diff --git a/cylc/flow/loggingutil.py b/cylc/flow/loggingutil.py index cb645a929ff..3c6f63ee294 100644 --- a/cylc/flow/loggingutil.py +++ b/cylc/flow/loggingutil.py @@ -430,7 +430,7 @@ def patch_log_level(logger: logging.Logger, level: int = logging.INFO): Defaults to INFO. """ - orig_level = logger.getEffectiveLevel() + orig_level = logger.level if level < orig_level: logger.setLevel(level) yield diff --git a/cylc/flow/tui/util.py b/cylc/flow/tui/util.py index 33494f9fb06..3a1e0951a88 100644 --- a/cylc/flow/tui/util.py +++ b/cylc/flow/tui/util.py @@ -51,10 +51,10 @@ def suppress_logging(): silly for the duration of this context manager then set it back again afterwards. """ - level = LOG.getEffectiveLevel() + orig_level = LOG.level LOG.setLevel(99999) yield - LOG.setLevel(level) + LOG.setLevel(orig_level) def get_task_icon( diff --git a/tests/unit/test_loggingutil.py b/tests/unit/test_loggingutil.py index 27447e48f2d..3181f851ed1 100644 --- a/tests/unit/test_loggingutil.py +++ b/tests/unit/test_loggingutil.py @@ -34,6 +34,7 @@ RotatingLogFileHandler, get_reload_start_number, get_sorted_logs_by_time, + patch_log_level, set_timestamps, ) @@ -245,3 +246,32 @@ def test_log_emit_and_glbl_cfg( # Check log emit does not access global config object: LOG.debug("Entering zero gravity") assert mock_cfg.get.call_args_list == [] + + +def test_patch_log_level(caplog: pytest.LogCaptureFixture): + """Test patch_log_level temporarily changes the log level.""" + caplog.set_level(logging.DEBUG) + logger = logging.getLogger("forest") + assert logger.level == logging.NOTSET + logger.setLevel(logging.ERROR) + logger.info("nope") + assert not caplog.records + with patch_log_level(logger, logging.INFO): + LOG.info("yep") + assert len(caplog.records) == 1 + logger.info("nope") + assert len(caplog.records) == 1 + + +def test_patch_log_level__reset(caplog: pytest.LogCaptureFixture): + """Test patch_log_level resets the log level correctly after use.""" + caplog.set_level(logging.ERROR) + logger = logging.getLogger("woods") + assert logger.level == logging.NOTSET + with patch_log_level(logger, logging.INFO): + logger.info("emitted but not captured, as caplog is at ERROR level") + assert not caplog.records + caplog.set_level(logging.INFO) + logger.info("yep") + assert len(caplog.records) == 1 + assert logger.level == logging.NOTSET From 347921fdee5b3e8d0f553f81bb5220a8618caa84 Mon Sep 17 00:00:00 2001 From: Ronnie Dutta <61982285+MetRonnie@users.noreply.github.com> Date: Thu, 15 Aug 2024 15:58:12 +0100 Subject: [PATCH 149/196] Remove `c` char from prereq API dump --- cylc/flow/prerequisite.py | 5 ++--- tests/flakyfunctional/cylc-show/00-simple.t | 4 ++-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/cylc/flow/prerequisite.py b/cylc/flow/prerequisite.py index b388934e2de..50d2280a204 100644 --- a/cylc/flow/prerequisite.py +++ b/cylc/flow/prerequisite.py @@ -16,7 +16,6 @@ """Functionality for expressing and evaluating logical triggers.""" -import math import re from typing import Iterable, Set, TYPE_CHECKING @@ -232,11 +231,11 @@ def api_dump(self): for s_msg in self.satisfied ) conds = [] - num_length = math.ceil(len(self.satisfied) / 10) + num_length = len(str(len(self.satisfied))) for ind, message_tuple in enumerate(sorted(self.satisfied)): point, name = message_tuple[0:2] t_id = quick_relative_detokenise(point, name) - char = 'c%.{0}d'.format(num_length) % ind + char = str(ind).zfill(num_length) c_msg = self.MESSAGE_TEMPLATE % message_tuple c_val = self.satisfied[message_tuple] c_bool = bool(c_val) diff --git a/tests/flakyfunctional/cylc-show/00-simple.t b/tests/flakyfunctional/cylc-show/00-simple.t index 8e6b9156924..f96a1129268 100644 --- a/tests/flakyfunctional/cylc-show/00-simple.t +++ b/tests/flakyfunctional/cylc-show/00-simple.t @@ -112,10 +112,10 @@ cmp_json "${TEST_NAME}-taskinstance" "${TEST_NAME}-taskinstance" \ "runtime": {"completion": "(started and succeeded)"}, "prerequisites": [ { - "expression": "c0", + "expression": "0", "conditions": [ { - "exprAlias": "c0", + "exprAlias": "0", "taskId": "20141106T0900Z/bar", "reqState": "succeeded", "message": "satisfied naturally", From 8f6912f7b4708e3077838d6df6099b69df407f6a Mon Sep 17 00:00:00 2001 From: umw111 Date: Tue, 27 Aug 2024 20:24:34 -0400 Subject: [PATCH 150/196] Added f to format string --- cylc/flow/graph_parser.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cylc/flow/graph_parser.py b/cylc/flow/graph_parser.py index efbce16fb36..64665c96983 100644 --- a/cylc/flow/graph_parser.py +++ b/cylc/flow/graph_parser.py @@ -643,7 +643,7 @@ def _proc_dep_pair( # Not a family. if trig in self.__class__.fam_to_mem_trigger_map: raise GraphParseError( - "family trigger on non-family namespace {expr}") + f"family trigger on non-family namespace {expr}") # remove '?' from expr (not needed in logical trigger evaluation) expr = re.sub(self.__class__._RE_OPT, '', expr) From 5df5253f3bfcf80fe57e7fff5bb5b65e8f68b290 Mon Sep 17 00:00:00 2001 From: Oliver Sanders Date: Wed, 28 Aug 2024 10:39:08 +0100 Subject: [PATCH 151/196] broadcast: remove duplicate namespaces * Fix an issue that could cause issues when broadcasting "coerced" configurations to multiple namespaces. * Specifying the same namesapce multiple times doesn't make sense, we should strip duplicates earlier on in the process. * Closes #6334 --- changes.d/6335.fix.md | 1 + cylc/flow/broadcast_mgr.py | 29 +++++----- cylc/flow/scripts/broadcast.py | 7 ++- tests/integration/scripts/test_broadcast.py | 59 ++++++++++++++++++++- 4 files changed, 81 insertions(+), 15 deletions(-) create mode 100644 changes.d/6335.fix.md diff --git a/changes.d/6335.fix.md b/changes.d/6335.fix.md new file mode 100644 index 00000000000..2057bae0f7c --- /dev/null +++ b/changes.d/6335.fix.md @@ -0,0 +1 @@ +Fix an issue that could cause broadcasts made to multiple namespaces to fail. diff --git a/cylc/flow/broadcast_mgr.py b/cylc/flow/broadcast_mgr.py index 6cd007ee25a..b9329114302 100644 --- a/cylc/flow/broadcast_mgr.py +++ b/cylc/flow/broadcast_mgr.py @@ -280,8 +280,16 @@ def put_broadcast( bad_namespaces = [] with self.lock: - for setting in settings: - for point_string in point_strings: + for setting in settings or []: + # Coerce setting to cylc runtime object, + # i.e. str to DurationFloat. + coerced_setting = deepcopy(setting) + BroadcastConfigValidator().validate( + coerced_setting, + SPEC['runtime']['__MANY__'], + ) + + for point_string in point_strings or []: # Standardise the point and check its validity. bad_point = False try: @@ -292,26 +300,23 @@ def put_broadcast( bad_point = True if not bad_point and point_string not in self.broadcasts: self.broadcasts[point_string] = {} - for namespace in namespaces: + for namespace in namespaces or []: if namespace not in self.linearized_ancestors: bad_namespaces.append(namespace) elif not bad_point: if namespace not in self.broadcasts[point_string]: self.broadcasts[point_string][namespace] = {} + # Keep saved/reported setting as workflow - # config format. + # config format: modified_settings.append( - (point_string, namespace, deepcopy(setting)) - ) - # Coerce setting to cylc runtime object, - # i.e. str to DurationFloat. - BroadcastConfigValidator().validate( - setting, - SPEC['runtime']['__MANY__'] + (point_string, namespace, setting) ) + + # Apply the broadcast with the "coerced" format: addict( self.broadcasts[point_string][namespace], - setting + coerced_setting, ) # Log the broadcast diff --git a/cylc/flow/scripts/broadcast.py b/cylc/flow/scripts/broadcast.py index c7e5d2a4f3b..2cf068309b7 100755 --- a/cylc/flow/scripts/broadcast.py +++ b/cylc/flow/scripts/broadcast.py @@ -325,6 +325,10 @@ async def run(options: 'Values', workflow_id): """Implement cylc broadcast.""" pclient = get_client(workflow_id, timeout=options.comms_timeout) + # remove any duplicate namespaces + # see https://github.com/cylc/cylc-flow/issues/6334 + namespaces = list(set(options.namespaces)) + ret: Dict[str, Any] = { 'stdout': [], 'stderr': [], @@ -337,7 +341,7 @@ async def run(options: 'Values', workflow_id): 'wFlows': [workflow_id], 'bMode': 'Set', 'cPoints': options.point_strings, - 'nSpaces': options.namespaces, + 'nSpaces': namespaces, 'bSettings': options.settings, 'bCutoff': options.expire, } @@ -382,7 +386,6 @@ async def run(options: 'Values', workflow_id): mutation_kwargs['variables']['bMode'] = 'Expire' # implement namespace and cycle point defaults here - namespaces = options.namespaces if not namespaces: namespaces = ["root"] point_strings = options.point_strings diff --git a/tests/integration/scripts/test_broadcast.py b/tests/integration/scripts/test_broadcast.py index 67a79448041..163d48e552e 100644 --- a/tests/integration/scripts/test_broadcast.py +++ b/tests/integration/scripts/test_broadcast.py @@ -15,13 +15,14 @@ # along with this program. If not, see . from cylc.flow.option_parsers import Options +from cylc.flow.rundb import CylcWorkflowDAO from cylc.flow.scripts.broadcast import _main, get_option_parser BroadcastOptions = Options(get_option_parser()) -async def test_broadcast_multi( +async def test_broadcast_multi_workflow( one_conf, flow, scheduler, @@ -77,3 +78,59 @@ async def test_broadcast_multi( ' settings are not compatible with the workflow' ) in out assert err == '' + + +async def test_broadcast_multi_namespace( + flow, + scheduler, + start, + db_select, +): + """Test a multi-namespace broadcast command. + + See https://github.com/cylc/cylc-flow/issues/6334 + """ + id_ = flow( + { + 'scheduling': { + 'graph': {'R1': 'a & b & c & fin'}, + }, + 'runtime': { + 'root': {'execution time limit': 'PT1S'}, + 'VOWELS': {'execution time limit': 'PT2S'}, + 'CONSONANTS': {'execution time limit': 'PT3S'}, + 'a': {'inherit': 'VOWELS'}, + 'b': {'inherit': 'CONSONANTS'}, + 'c': {'inherit': 'CONSONANTS'}, + }, + } + ) + schd = scheduler(id_) + + async with start(schd): + # issue a broadcast to multiple namespaces + rets = await _main( + BroadcastOptions( + settings=['execution time limit = PT5S'], + namespaces=['root', 'VOWELS', 'CONSONANTS'], + ), + schd.workflow, + ) + + # the broadcast should succeed + assert list(rets.values()) == [True] + + # the broadcast manager should store the "coerced" setting + for task in ['a', 'b', 'c', 'fin']: + assert schd.broadcast_mgr.get_broadcast( + schd.tokens.duplicate(cycle='1', task=task) + ) == {'execution time limit': 5.0} + + # the database should store the "raw" setting + assert sorted( + db_select(schd, True, CylcWorkflowDAO.TABLE_BROADCAST_STATES) + ) == [ + ('*', 'CONSONANTS', 'execution time limit', 'PT5S'), + ('*', 'VOWELS', 'execution time limit', 'PT5S'), + ('*', 'root', 'execution time limit', 'PT5S'), + ] From 4aa977773bfc7c14573d7ab886843b9961c9c44b Mon Sep 17 00:00:00 2001 From: umw111 Date: Wed, 28 Aug 2024 22:37:34 -0400 Subject: [PATCH 152/196] Added name to contributers list, added entry to changelog and mailmap --- .mailmap | 1 + CONTRIBUTING.md | 1 + changes.d/6332.fix.md | 1 + 3 files changed, 3 insertions(+) create mode 100644 changes.d/6332.fix.md diff --git a/.mailmap b/.mailmap index 17c44ef97b1..d10234a19a1 100644 --- a/.mailmap +++ b/.mailmap @@ -52,6 +52,7 @@ Tim Whitcomb Tim Whitcomb trwhitcomb Tomek Trzeciak Tomek Trzeciak TomekTrzeciak +Utheri Wagura umw111 github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> github-actions[bot] GitHub Action Diquan Jabbour <165976689+Diquan-BOM@users.noreply.github.com> diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 30e81f26f93..a4e77fd82a7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -95,6 +95,7 @@ requests_). - Mark Dawson - Diquan Jabbour - Shixian Sheng + - Utheri Wagura (All contributors are identifiable with email addresses in the git version diff --git a/changes.d/6332.fix.md b/changes.d/6332.fix.md new file mode 100644 index 00000000000..6fa937c4d33 --- /dev/null +++ b/changes.d/6332.fix.md @@ -0,0 +1 @@ + Fixes unformatted string From 7523bc5be61c4b4cf482bb9306eafff2c32b6af0 Mon Sep 17 00:00:00 2001 From: Oliver Sanders Date: Thu, 29 Aug 2024 11:04:44 +0100 Subject: [PATCH 153/196] update mailmap --- .mailmap | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.mailmap b/.mailmap index d10234a19a1..71a84fdefc6 100644 --- a/.mailmap +++ b/.mailmap @@ -1,4 +1,4 @@ -# FORMAT: +# FORMAT: # Omit commit-name or commit-email if same as proper Alex Reinecke P. A. Reinecke @@ -52,7 +52,8 @@ Tim Whitcomb Tim Whitcomb trwhitcomb Tomek Trzeciak Tomek Trzeciak TomekTrzeciak -Utheri Wagura umw111 -github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> +Utheri Wagura +Utheri Wagura <36386988+uwagura@users.noreply.github.com> +github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> github-actions[bot] GitHub Action Diquan Jabbour <165976689+Diquan-BOM@users.noreply.github.com> From cad65e4bb14d9a75a37478a838dc4c3b994e0596 Mon Sep 17 00:00:00 2001 From: Ronnie Dutta <61982285+MetRonnie@users.noreply.github.com> Date: Thu, 29 Aug 2024 12:04:08 +0100 Subject: [PATCH 154/196] Minor test follow-up --- tests/unit/test_loggingutil.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/unit/test_loggingutil.py b/tests/unit/test_loggingutil.py index 3181f851ed1..fb9c36d5dc8 100644 --- a/tests/unit/test_loggingutil.py +++ b/tests/unit/test_loggingutil.py @@ -252,7 +252,6 @@ def test_patch_log_level(caplog: pytest.LogCaptureFixture): """Test patch_log_level temporarily changes the log level.""" caplog.set_level(logging.DEBUG) logger = logging.getLogger("forest") - assert logger.level == logging.NOTSET logger.setLevel(logging.ERROR) logger.info("nope") assert not caplog.records @@ -264,7 +263,10 @@ def test_patch_log_level(caplog: pytest.LogCaptureFixture): def test_patch_log_level__reset(caplog: pytest.LogCaptureFixture): - """Test patch_log_level resets the log level correctly after use.""" + """Test patch_log_level resets the log level correctly after + use, not affected by the parent logger level - see + https://github.com/cylc/cylc-flow/pull/6327 + """ caplog.set_level(logging.ERROR) logger = logging.getLogger("woods") assert logger.level == logging.NOTSET From 22c3b526a916fb3f4b69613d45426f14dca64a34 Mon Sep 17 00:00:00 2001 From: Oliver Sanders Date: Wed, 7 Aug 2024 13:46:54 +0100 Subject: [PATCH 155/196] workflow-state: disable implcit triggers on the CLI * Polling a trigger (rather than a state) on the CLI now requires the `--triggers` option to be specified. --- cylc/flow/scripts/workflow_state.py | 14 ++------------ tests/functional/shutdown/08-now1/flow.cylc | 2 +- tests/functional/workflow-state/05-output.t | 2 +- tests/functional/workflow-state/07-message2.t | 2 +- tests/functional/workflow-state/11-multi.t | 6 +++--- tests/unit/xtriggers/test_workflow_state.py | 8 +++++--- 6 files changed, 13 insertions(+), 21 deletions(-) diff --git a/cylc/flow/scripts/workflow_state.py b/cylc/flow/scripts/workflow_state.py index eed189a7113..c444c6d1f65 100755 --- a/cylc/flow/scripts/workflow_state.py +++ b/cylc/flow/scripts/workflow_state.py @@ -115,7 +115,6 @@ from cylc.flow.terminal import cli_function from cylc.flow.workflow_files import infer_latest_run_from_id from cylc.flow.task_state import ( - TASK_STATUSES_ORDERED, TASK_STATUSES_FINAL, TASK_STATUSES_ALL, ) @@ -178,6 +177,8 @@ def __init__( self.alt_cylc_run_dir = alt_cylc_run_dir self.old_format = old_format self.pretty_print = pretty_print + self.is_message = is_message + self.is_trigger = is_trigger try: tokens = Tokens(self.id_) @@ -200,17 +201,6 @@ def __init__( self.result: Optional[List[List[str]]] = None self._db_checker: Optional[CylcWorkflowDBChecker] = None - self.is_message = is_message - if is_message: - self.is_trigger = False - else: - self.is_trigger = ( - is_trigger or - ( - self.selector is not None and - self.selector not in TASK_STATUSES_ORDERED - ) - ) super().__init__(**kwargs) def _find_workflow(self) -> bool: diff --git a/tests/functional/shutdown/08-now1/flow.cylc b/tests/functional/shutdown/08-now1/flow.cylc index ccf416f6faf..21366968825 100644 --- a/tests/functional/shutdown/08-now1/flow.cylc +++ b/tests/functional/shutdown/08-now1/flow.cylc @@ -24,7 +24,7 @@ [[[events]]] # wait for the stopping message, sleep a bit, then echo some stuff started handlers = """ - cylc workflow-state %(workflow)s//%(point)s/%(name)s:stopping >/dev/null && sleep 1 && echo 'Hello %(id)s %(event)s' + cylc workflow-state %(workflow)s//%(point)s/%(name)s:stopping --triggers >/dev/null && sleep 1 && echo 'Hello %(id)s %(event)s' """ [[[outputs]]] stopping = stopping diff --git a/tests/functional/workflow-state/05-output.t b/tests/functional/workflow-state/05-output.t index d95bc179518..6ddcd68f857 100755 --- a/tests/functional/workflow-state/05-output.t +++ b/tests/functional/workflow-state/05-output.t @@ -27,6 +27,6 @@ workflow_run_ok "${TEST_NAME}" \ cylc play --reference-test --debug --no-detach "${WORKFLOW_NAME}" TEST_NAME=${TEST_NAME_BASE}-cli-check -run_ok "${TEST_NAME}" cylc workflow-state "${WORKFLOW_NAME}//20100101T0000Z/t1:out1" --max-polls=1 +run_ok "${TEST_NAME}" cylc workflow-state "${WORKFLOW_NAME}//20100101T0000Z/t1:out1" --triggers --max-polls=1 purge diff --git a/tests/functional/workflow-state/07-message2.t b/tests/functional/workflow-state/07-message2.t index ef666714220..2c0c61a655b 100755 --- a/tests/functional/workflow-state/07-message2.t +++ b/tests/functional/workflow-state/07-message2.t @@ -29,7 +29,7 @@ workflow_run_ok "${TEST_NAME_BASE}-run" \ cylc play --debug --no-detach "${WORKFLOW_NAME}" TEST_NAME=${TEST_NAME_BASE}-query -run_fail "${TEST_NAME}" cylc workflow-state "${WORKFLOW_NAME}//2013/foo:x" --max-polls=1 +run_fail "${TEST_NAME}" cylc workflow-state "${WORKFLOW_NAME}//2013/foo:x" --triggers --max-polls=1 grep_ok "failed after 1 polls" "${TEST_NAME}.stderr" diff --git a/tests/functional/workflow-state/11-multi.t b/tests/functional/workflow-state/11-multi.t index a80b61f2016..7a4270f971a 100644 --- a/tests/functional/workflow-state/11-multi.t +++ b/tests/functional/workflow-state/11-multi.t @@ -54,15 +54,15 @@ CMD="cylc workflow-state --run-dir=$DBDIR --max-polls=1" # foo|1|[1]|2024-06-05T16:34:02+12:00|2024-06-05T16:34:04+12:00|1|succeeded|0|0 #--------------- -# Test the new-format command line (pre-8.3.0). +# Test the new-format command line (8.3.0+). T=${TEST_NAME_BASE}-cli-c8b run_ok "${T}-1" $CMD c8b run_ok "${T}-2" $CMD c8b//1 run_ok "${T}-3" $CMD c8b//1/foo +run_fail "${T}-4" $CMD c8b//1/foo:waiting run_ok "${T}-4" $CMD c8b//1/foo:succeeded run_ok "${T}-5" $CMD "c8b//1/foo:the quick brown" --messages run_ok "${T}-6" $CMD "c8b//1/foo:x" --triggers -run_ok "${T}-7" $CMD "c8b//1/foo:x" # default to trigger if not a status run_ok "${T}-8" $CMD c8b//1 run_ok "${T}-9" $CMD c8b//1:succeeded @@ -86,7 +86,7 @@ run_fail "${T}-2" $CMD "c7//1/foo:the quick brown" --triggers run_ok "${T}-3" $CMD "c7//1/foo:x" --triggers #--------------- -# Test the old-format command line (8.3.0+). +# Test the old-format command line (pre-8.3.0). T=${TEST_NAME_BASE}-cli-8b-compat run_ok "${T}-1" $CMD c8b run_ok "${T}-2" $CMD c8b --point=1 diff --git a/tests/unit/xtriggers/test_workflow_state.py b/tests/unit/xtriggers/test_workflow_state.py index d376da6c727..1b70500797c 100644 --- a/tests/unit/xtriggers/test_workflow_state.py +++ b/tests/unit/xtriggers/test_workflow_state.py @@ -22,7 +22,7 @@ import pytest from cylc.flow.dbstatecheck import output_fallback_msg -from cylc.flow.exceptions import WorkflowConfigError +from cylc.flow.exceptions import WorkflowConfigError, InputError from cylc.flow.rundb import CylcWorkflowDAO from cylc.flow.workflow_files import WorkflowFiles from cylc.flow.xtriggers.workflow_state import ( @@ -125,8 +125,10 @@ def test_c7_db_back_compat(tmp_run_dir: 'Callable'): f'{id_}//2012/mithril:"bag end"', is_message=True ) assert satisfied - satisfied, _ = workflow_state(f'{id_}//2012/mithril:pippin') - assert not satisfied + + with pytest.raises(InputError, match='No such task state "pippin"'): + workflow_state(f'{id_}//2012/mithril:pippin') + satisfied, _ = workflow_state(id_ + '//2012/arkenstone') assert not satisfied From 1c5db24c7f98cf9ce749959200732e93431f89d8 Mon Sep 17 00:00:00 2001 From: Hilary James Oliver Date: Fri, 30 Aug 2024 01:18:50 +1200 Subject: [PATCH 156/196] Fix duplicate job submissions. (#6337) Fix duplicate job submissions. --- changes.d/6337.fix.md | 1 + cylc/flow/task_queues/__init__.py | 4 +-- cylc/flow/task_queues/independent.py | 12 ++++---- tests/integration/test_task_pool.py | 43 +++++++++++++++++++++++++++- 4 files changed, 50 insertions(+), 10 deletions(-) create mode 100644 changes.d/6337.fix.md diff --git a/changes.d/6337.fix.md b/changes.d/6337.fix.md new file mode 100644 index 00000000000..6a6fd72757f --- /dev/null +++ b/changes.d/6337.fix.md @@ -0,0 +1 @@ +Fix potential duplicate job submissions when manually triggering unqueued active tasks. diff --git a/cylc/flow/task_queues/__init__.py b/cylc/flow/task_queues/__init__.py index 32983789632..e19b68c25e7 100644 --- a/cylc/flow/task_queues/__init__.py +++ b/cylc/flow/task_queues/__init__.py @@ -53,8 +53,8 @@ def release_tasks(self, active: Counter[str]) -> 'List[TaskProxy]': pass @abstractmethod - def remove_task(self, itask: 'TaskProxy') -> None: - """Remove a task from the queueing system.""" + def remove_task(self, itask: 'TaskProxy') -> bool: + """Try to remove a task from the queues. Return True if done.""" pass @abstractmethod diff --git a/cylc/flow/task_queues/independent.py b/cylc/flow/task_queues/independent.py index 185edee4f2b..3bac42fe0f5 100644 --- a/cylc/flow/task_queues/independent.py +++ b/cylc/flow/task_queues/independent.py @@ -130,19 +130,17 @@ def release_tasks(self, active: Counter[str]) -> List['TaskProxy']: self.force_released = set() return released - def remove_task(self, itask: 'TaskProxy') -> None: - """Remove a task from whichever queue it belongs to.""" - for queue in self.queues.values(): - if queue.remove(itask): - break + def remove_task(self, itask: 'TaskProxy') -> bool: + """Try to remove a task from the queues. Return True if done.""" + return any(queue.remove(itask) for queue in self.queues.values()) def force_release_task(self, itask: 'TaskProxy') -> None: """Remove a task from whichever queue it belongs to. To be returned when release_tasks() is next called. """ - self.remove_task(itask) - self.force_released.add(itask) + if self.remove_task(itask): + self.force_released.add(itask) def adopt_tasks(self, orphans: List[str]) -> None: """Adopt orphaned tasks to the default group.""" diff --git a/tests/integration/test_task_pool.py b/tests/integration/test_task_pool.py index beba9075bd3..9f349c7e78c 100644 --- a/tests/integration/test_task_pool.py +++ b/tests/integration/test_task_pool.py @@ -2085,7 +2085,7 @@ async def test_set_future_flow(flow, scheduler, start, log_filter): # set b:succeeded in flow 2 and check downstream spawning schd.pool.set_prereqs_and_outputs(['1/b'], prereqs=[], outputs=[], flow=[2]) assert schd.pool.get_task(IntegerPoint("1"), "c1") is None, '1/c1 (flow 2) should not be spawned after 1/b:succeeded' - assert schd.pool.get_task(IntegerPoint("1"), "c2") is not None, '1/c2 (flow 2) should be spawned after 1/b:succeeded' + assert schd.pool.get_task(IntegerPoint("1"), "c2") is not None, '1/c2 (flow 2) should be spawned after 1/b:succeeded' async def test_trigger_queue(one, run, db_select, complete): @@ -2109,3 +2109,44 @@ async def test_trigger_queue(one, run, db_select, complete): one.resume_workflow() await complete(one, timeout=2) assert db_select(one, False, 'task_outputs', 'flow_nums') == [('[1, 2]',), ('[1]',)] + + +async def test_trigger_unqueued(flow, scheduler, start): + """Test triggering an unqueued active task. + + It should not add to the force_released list. + See https://github.com/cylc/cylc-flow/pull/6337 + + """ + conf = { + 'scheduler': {'allow implicit tasks': 'True'}, + 'scheduling': { + 'graph': { + 'R1': 'a & b => c' + } + } + } + schd = scheduler( + flow(conf), + run_mode='simulation', + paused_start=False + ) + + async with start(schd): + # Release tasks 1/a and 1/b + schd.pool.release_runahead_tasks() + schd.release_queued_tasks() + assert pool_get_task_ids(schd.pool) == ['1/a', '1/b'] + + # Mark 1/a as succeeded and spawn 1/c + task_a = schd.pool.get_task(IntegerPoint("1"), "a") + schd.pool.task_events_mgr.process_message(task_a, 1, 'succeeded') + assert pool_get_task_ids(schd.pool) == ['1/b', '1/c'] + + # Trigger the partially satisified (and not queued) task 1/c + schd.pool.force_trigger_tasks(['1/c'], [FLOW_ALL]) + + # It should not add to the queue managers force_released list. + assert not schd.pool.task_queue_mgr.force_released, ( + "Triggering an unqueued task should not affect the force_released list" + ) From ff80575164303eda2e705a6205c307933edf4ac2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 2 Sep 2024 11:45:52 +0100 Subject: [PATCH 157/196] Bump pypa/gh-action-pypi-publish from 1.9.0 to 1.10.0 (#6346) Bumps [pypa/gh-action-pypi-publish](https://github.com/pypa/gh-action-pypi-publish) from 1.9.0 to 1.10.0. - [Release notes](https://github.com/pypa/gh-action-pypi-publish/releases) - [Commits](https://github.com/pypa/gh-action-pypi-publish/compare/v1.9.0...v1.10.0) --- updated-dependencies: - dependency-name: pypa/gh-action-pypi-publish dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/2_auto_publish_release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/2_auto_publish_release.yml b/.github/workflows/2_auto_publish_release.yml index b63a1e35920..345d0a85d1e 100644 --- a/.github/workflows/2_auto_publish_release.yml +++ b/.github/workflows/2_auto_publish_release.yml @@ -38,7 +38,7 @@ jobs: uses: cylc/release-actions/build-python-package@v1 - name: Publish distribution to PyPI - uses: pypa/gh-action-pypi-publish@v1.9.0 + uses: pypa/gh-action-pypi-publish@v1.10.0 with: user: __token__ # uses the API token feature of PyPI - least permissions possible password: ${{ secrets.PYPI_TOKEN }} From 3c001bbc6ce7f9612efa8114a43eac15ba068906 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 3 Sep 2024 11:11:57 +0100 Subject: [PATCH 158/196] Bump pypa/gh-action-pypi-publish from 1.9.0 to 1.10.1 (#6350) Bumps [pypa/gh-action-pypi-publish](https://github.com/pypa/gh-action-pypi-publish) from 1.9.0 to 1.10.1. - [Release notes](https://github.com/pypa/gh-action-pypi-publish/releases) - [Commits](https://github.com/pypa/gh-action-pypi-publish/compare/v1.9.0...v1.10.1) --- updated-dependencies: - dependency-name: pypa/gh-action-pypi-publish dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/2_auto_publish_release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/2_auto_publish_release.yml b/.github/workflows/2_auto_publish_release.yml index 345d0a85d1e..15b4a590a69 100644 --- a/.github/workflows/2_auto_publish_release.yml +++ b/.github/workflows/2_auto_publish_release.yml @@ -38,7 +38,7 @@ jobs: uses: cylc/release-actions/build-python-package@v1 - name: Publish distribution to PyPI - uses: pypa/gh-action-pypi-publish@v1.10.0 + uses: pypa/gh-action-pypi-publish@v1.10.1 with: user: __token__ # uses the API token feature of PyPI - least permissions possible password: ${{ secrets.PYPI_TOKEN }} From 44c00c026fecb06f79b050aac73cfb8b36895533 Mon Sep 17 00:00:00 2001 From: Hilary Oliver Date: Wed, 4 Sep 2024 16:11:39 +1200 Subject: [PATCH 159/196] Avoid duplicate job submissions before reload. --- changes.d/6345.fix.md | 1 + cylc/flow/task_job_mgr.py | 11 ++++++++++- 2 files changed, 11 insertions(+), 1 deletion(-) create mode 100644 changes.d/6345.fix.md diff --git a/changes.d/6345.fix.md b/changes.d/6345.fix.md new file mode 100644 index 00000000000..a629eb6c8ed --- /dev/null +++ b/changes.d/6345.fix.md @@ -0,0 +1 @@ +Fix duplicate job submissions of tasks in the preparing state before reload. diff --git a/cylc/flow/task_job_mgr.py b/cylc/flow/task_job_mgr.py index 185966ff12d..d5c98288294 100644 --- a/cylc/flow/task_job_mgr.py +++ b/cylc/flow/task_job_mgr.py @@ -536,6 +536,12 @@ def submit_task_jobs(self, workflow, itasks, curve_auth, stdin_files = [] job_log_dirs = [] for itask in itasks_batch: + if not itask.waiting_on_job_prep: + # Avoid duplicate job submissions when flushing + # preparing tasks before a reload. See + # https://github.com/cylc/cylc-flow/pull/6345 + continue + if remote_mode: stdin_files.append( os.path.expandvars( @@ -554,8 +560,11 @@ def submit_task_jobs(self, workflow, itasks, curve_auth, # write flag so that subsequent manual retrigger will # generate a new job file. itask.local_job_file_path = None - itask.waiting_on_job_prep = False + + if not job_log_dirs: + continue + self.proc_pool.put_command( SubProcContext( self.JOBS_SUBMIT, From dcedd2bbe8e16725798c9c829c462e5a50598b5e Mon Sep 17 00:00:00 2001 From: Hilary Oliver Date: Wed, 4 Sep 2024 17:19:37 +1200 Subject: [PATCH 160/196] Added a functional test. --- tests/functional/reload/28-preparing.t | 43 +++++++++++++++++++ .../functional/reload/28-preparing/flow.cylc | 8 ++++ 2 files changed, 51 insertions(+) create mode 100644 tests/functional/reload/28-preparing.t create mode 100644 tests/functional/reload/28-preparing/flow.cylc diff --git a/tests/functional/reload/28-preparing.t b/tests/functional/reload/28-preparing.t new file mode 100644 index 00000000000..08f4ba911ac --- /dev/null +++ b/tests/functional/reload/28-preparing.t @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# THIS FILE IS PART OF THE CYLC WORKFLOW ENGINE. +# Copyright (C) NIWA & British Crown (Met Office) & Contributors. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +#------------------------------------------------------------------------------- + +# Test for duplicate job submissions when preparing tasks get flushed +# prior to reload - see https://github.com/cylc/cylc-flow/pull/6345 + +. "$(dirname "$0")/test_header" +set_test_number 4 + +# Strap the process pool size down to 1, so that the first task is stuck +# in the preparing state until the startup event handler finishes. + +create_test_global_config "" " +[scheduler] + process pool size = 1 +" + +# install and play the workflow, then reload it and wait for it to finish. +install_workflow "${TEST_NAME_BASE}" "${TEST_NAME_BASE}" +run_ok "${TEST_NAME_BASE}-vip" cylc validate "${WORKFLOW_NAME}" +run_ok "${TEST_NAME_BASE}-reload" cylc play "${WORKFLOW_NAME}" +run_ok "${TEST_NAME_BASE}-reload" cylc reload "${WORKFLOW_NAME}" +poll_grep_workflow_log -F 'INFO - DONE' + +# check that task `foo` was only submitted once. +count_ok "1/foo.*submitted to" "${WORKFLOW_RUN_DIR}/log/scheduler/log" 1 + +purge diff --git a/tests/functional/reload/28-preparing/flow.cylc b/tests/functional/reload/28-preparing/flow.cylc new file mode 100644 index 00000000000..27a9f1013e9 --- /dev/null +++ b/tests/functional/reload/28-preparing/flow.cylc @@ -0,0 +1,8 @@ +[scheduler] + [[events]] + startup handlers = "sleep 10; echo" +[scheduling] + [[graph]] + R1 = "foo => bar" +[runtime] + [[foo, bar]] From 650f9872e2c3badf0c9b8d18c6012c9a1bf44cd1 Mon Sep 17 00:00:00 2001 From: Tim Pillinger <26465611+wxtim@users.noreply.github.com> Date: Tue, 3 Sep 2024 10:26:11 +0100 Subject: [PATCH 161/196] Fix bug where sim mode didn't produce started output. --- changes.d/6351.fix.md | 1 + cylc/flow/task_events_mgr.py | 3 ++ .../integration/run_modes/test_simulation.py | 34 +++++++++++++++++++ 3 files changed, 38 insertions(+) create mode 100644 changes.d/6351.fix.md create mode 100644 tests/integration/run_modes/test_simulation.py diff --git a/changes.d/6351.fix.md b/changes.d/6351.fix.md new file mode 100644 index 00000000000..7e95da24378 --- /dev/null +++ b/changes.d/6351.fix.md @@ -0,0 +1 @@ +Fix a bug where simulation mode tasks were not spawning children of task:started. diff --git a/cylc/flow/task_events_mgr.py b/cylc/flow/task_events_mgr.py index bf9c2ba3a9b..f9b4d4c3243 100644 --- a/cylc/flow/task_events_mgr.py +++ b/cylc/flow/task_events_mgr.py @@ -776,6 +776,9 @@ def process_message( ) self.data_store_mgr.delta_job_attr( job_tokens, 'job_id', itask.summary['submit_method_id']) + else: + # In simulation mode submitted implies started: + self.spawn_children(itask, TASK_OUTPUT_STARTED) elif message.startswith(FAIL_MESSAGE_PREFIX): # Task received signal. diff --git a/tests/integration/run_modes/test_simulation.py b/tests/integration/run_modes/test_simulation.py new file mode 100644 index 00000000000..4d1cd0b7ed9 --- /dev/null +++ b/tests/integration/run_modes/test_simulation.py @@ -0,0 +1,34 @@ +# THIS FILE IS PART OF THE CYLC WORKFLOW ENGINE. +# Copyright (C) NIWA & British Crown (Met Office) & Contributors. + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Test the workings of simulation mode""" + + +async def test_started_trigger(flow, reftest, scheduler): + """Does the started task output trigger downstream tasks + in sim mode? + + Long standing Bug discovered in Skip Mode work. + https://github.com/cylc/cylc-flow/pull/6039#issuecomment-2321147445 + """ + schd = scheduler(flow({ + 'scheduler': {'events': {'stall timeout': 'PT0S', 'abort on stall timeout': True}}, + 'scheduling': {'graph': {'R1': 'a:started => b'}} + }), paused_start=False) + assert await reftest(schd) == { + ('1/a', None), + ('1/b', ('1/a',)) + } From 3216d9dc4ef49ccece8c68be6dc28bdc112cc59a Mon Sep 17 00:00:00 2001 From: Oliver Sanders Date: Tue, 3 Sep 2024 17:08:56 +0100 Subject: [PATCH 162/196] expiry: prevent expired tasks from retrying automatically * Closes https://github.com/cylc/cylc-flow/issues/6284 --- changes.d/6353.fix.md | 1 + cylc/flow/task_pool.py | 1 + tests/integration/test_task_pool.py | 48 +++++++++++++++++++++++++++++ 3 files changed, 50 insertions(+) create mode 100644 changes.d/6353.fix.md diff --git a/changes.d/6353.fix.md b/changes.d/6353.fix.md new file mode 100644 index 00000000000..f6a1622d03a --- /dev/null +++ b/changes.d/6353.fix.md @@ -0,0 +1 @@ +Prevent clock-expired tasks from being automatically retried. diff --git a/cylc/flow/task_pool.py b/cylc/flow/task_pool.py index 9c27dcd232d..4003046bb54 100644 --- a/cylc/flow/task_pool.py +++ b/cylc/flow/task_pool.py @@ -2239,6 +2239,7 @@ def clock_expire_tasks(self): # check if this task is clock expired and itask.clock_expire() ): + self.task_queue_mgr.remove_task(itask) self.task_events_mgr.process_message( itask, logging.WARNING, diff --git a/tests/integration/test_task_pool.py b/tests/integration/test_task_pool.py index 9f349c7e78c..a69e6b0cb72 100644 --- a/tests/integration/test_task_pool.py +++ b/tests/integration/test_task_pool.py @@ -2150,3 +2150,51 @@ async def test_trigger_unqueued(flow, scheduler, start): assert not schd.pool.task_queue_mgr.force_released, ( "Triggering an unqueued task should not affect the force_released list" ) + + +@pytest.mark.parametrize('expire_type', ['clock-expire', 'manual']) +async def test_expire_dequeue_with_retries(flow, scheduler, start, expire_type): + """An expired waiting task should be removed from any queues. + + See https://github.com/cylc/cylc-flow/issues/6284 + """ + conf = { + 'scheduling': { + 'initial cycle point': '2000', + + 'graph': { + 'R1': 'foo' + }, + }, + 'runtime': { + 'foo': { + 'execution retry delays': 'PT0S' + } + } + } + + if expire_type == 'clock-expire': + conf['scheduling']['special tasks'] = {'clock-expire': 'foo(PT0S)'} + method = lambda schd: schd.pool.clock_expire_tasks() + else: + method = lambda schd: schd.pool.set_prereqs_and_outputs( + ['2000/foo'], prereqs=[], outputs=['expired'], flow=['1'] + ) + + id_ = flow(conf) + schd = scheduler(id_) + schd: Scheduler + async with start(schd): + itask = schd.pool.get_tasks()[0] + + # the task should start as "waiting(queued)" + assert itask.state(TASK_STATUS_WAITING, is_queued=True) + + # expire the task via whichever method we are testing + method(schd) + + # the task should enter the "expired" state + assert itask.state(TASK_STATUS_EXPIRED, is_queued=False) + + # the task should also have been removed from the queue + assert not schd.pool.task_queue_mgr.remove_task(itask) From ddddfcdcb49fab6c9f800c02b572e31255dc953d Mon Sep 17 00:00:00 2001 From: Tim Pillinger <26465611+wxtim@users.noreply.github.com> Date: Tue, 10 Sep 2024 16:14:47 +0100 Subject: [PATCH 163/196] Ensure that platform from group selection checks broadcast manager (#6330) --- changes.d/6330.fix.md | 1 + cylc/flow/task_job_mgr.py | 25 +++++----- .../07-cylc7-badhost.t | 2 +- tests/integration/test_task_job_mgr.py | 49 +++++++++++++++++++ 4 files changed, 64 insertions(+), 13 deletions(-) create mode 100644 changes.d/6330.fix.md diff --git a/changes.d/6330.fix.md b/changes.d/6330.fix.md new file mode 100644 index 00000000000..190a9637a93 --- /dev/null +++ b/changes.d/6330.fix.md @@ -0,0 +1 @@ +Fix bug where broadcasting failed to change platform selected after host selection failure. \ No newline at end of file diff --git a/cylc/flow/task_job_mgr.py b/cylc/flow/task_job_mgr.py index 185966ff12d..e8b77f14d85 100644 --- a/cylc/flow/task_job_mgr.py +++ b/cylc/flow/task_job_mgr.py @@ -306,18 +306,19 @@ def submit_task_jobs(self, workflow, itasks, curve_auth, # Get another platform, if task config platform is a group use_next_platform_in_group = False - if itask.tdef.rtconfig['platform']: - try: - platform = get_platform( - itask.tdef.rtconfig['platform'], - bad_hosts=self.bad_hosts - ) - except PlatformLookupError: - pass - else: - # If were able to select a new platform; - if platform and platform != itask.platform: - use_next_platform_in_group = True + bc_mgr = self.task_events_mgr.broadcast_mgr + rtconf = bc_mgr.get_updated_rtconfig(itask) + try: + platform = get_platform( + rtconf, + bad_hosts=self.bad_hosts + ) + except PlatformLookupError: + pass + else: + # If were able to select a new platform; + if platform and platform != itask.platform: + use_next_platform_in_group = True if use_next_platform_in_group: # store the previous platform's hosts so that when diff --git a/tests/functional/intelligent-host-selection/07-cylc7-badhost.t b/tests/functional/intelligent-host-selection/07-cylc7-badhost.t index 29c72e6c5a5..a35f81c8d5a 100644 --- a/tests/functional/intelligent-host-selection/07-cylc7-badhost.t +++ b/tests/functional/intelligent-host-selection/07-cylc7-badhost.t @@ -52,7 +52,7 @@ workflow_run_fail "${TEST_NAME_BASE}-run" \ cylc play --no-detach "${WORKFLOW_NAME}" grep_workflow_log_ok "${TEST_NAME_BASE}-grep-1" \ - "platform: badhostplatform - initialisation did not complete (no hosts were reachable)" + "platform: badhostplatform - initialisation did not complete" grep_workflow_log_ok "${TEST_NAME_BASE}-grep-2" "CRITICAL - Workflow stalled" diff --git a/tests/integration/test_task_job_mgr.py b/tests/integration/test_task_job_mgr.py index 9265f2198dc..48a49eb30aa 100644 --- a/tests/integration/test_task_job_mgr.py +++ b/tests/integration/test_task_job_mgr.py @@ -187,3 +187,52 @@ async def test__prep_submit_task_job_impl_handles_execution_time_limit( schd.task_job_mgr._prep_submit_task_job( schd.workflow, task_a) assert not task_a.summary.get('execution_time_limit', '') + + +async def test_broadcast_platform_change( + mock_glbl_cfg, + flow, + scheduler, + start, + log_filter, +): + """Broadcast can change task platform. + + Even after host selection failure. + + see https://github.com/cylc/cylc-flow/issues/6320 + """ + mock_glbl_cfg( + 'cylc.flow.platforms.glbl_cfg', + ''' + [platforms] + [[foo]] + hosts = food + ''') + + id_ = flow({ + "scheduling": {"graph": {"R1": "mytask"}}, + # Platform = None doesn't cause this issue! + "runtime": {"mytask": {"platform": "localhost"}}}) + + schd = scheduler(id_, run_mode='live') + + async with start(schd) as log: + # Change the task platform with broadcast: + schd.broadcast_mgr.put_broadcast( + ['1'], ['mytask'], [{'platform': 'foo'}]) + + # Simulate prior failure to contact hosts: + schd.task_job_mgr.task_remote_mgr.bad_hosts = {'food'} + + # Attempt job submission: + schd.task_job_mgr.submit_task_jobs( + schd.workflow, + schd.pool.get_tasks(), + schd.server.curve_auth, + schd.server.client_pub_key_dir) + + # Check that task platform hasn't become "localhost": + assert schd.pool.get_tasks()[0].platform['name'] == 'foo' + # ... and that remote init failed because all hosts bad: + assert log_filter(log, contains="(no hosts were reachable)") From 6ff47c2217d3cf93215a1371af0faa43c8b20c0f Mon Sep 17 00:00:00 2001 From: Ronnie Dutta <61982285+MetRonnie@users.noreply.github.com> Date: Wed, 4 Sep 2024 17:40:13 +0100 Subject: [PATCH 164/196] Fix potential type error --- cylc/flow/task_pool.py | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/cylc/flow/task_pool.py b/cylc/flow/task_pool.py index 4003046bb54..58a0134d3b5 100644 --- a/cylc/flow/task_pool.py +++ b/cylc/flow/task_pool.py @@ -764,7 +764,12 @@ def get_or_spawn_task( # ntask may still be None return ntask, is_in_pool, is_xtrig_sequential - def spawn_to_rh_limit(self, tdef, point, flow_nums) -> None: + def spawn_to_rh_limit( + self, + tdef: 'TaskDef', + point: Optional['PointBase'], + flow_nums: 'FlowNums', + ) -> None: """Spawn parentless task instances from point to runahead limit. Sequentially checked xtriggers will spawn the next occurrence of their @@ -779,16 +784,14 @@ def spawn_to_rh_limit(self, tdef, point, flow_nums) -> None: return if self.runahead_limit_point is None: self.compute_runahead() + if self.runahead_limit_point is None: + return is_xtrig_sequential = False while point is not None and (point <= self.runahead_limit_point): if tdef.is_parentless(point): ntask, is_in_pool, is_xtrig_sequential = ( - self.get_or_spawn_task( - point, - tdef, - flow_nums - ) + self.get_or_spawn_task(point, tdef, flow_nums) ) if ntask is not None: if not is_in_pool: @@ -1329,7 +1332,9 @@ def check_abort_on_task_fails(self): """ return self.abort_task_failed - def spawn_on_output(self, itask, output, forced=False): + def spawn_on_output( + self, itask: TaskProxy, output: str, forced: bool = False + ) -> None: """Spawn child-tasks of given output, into the pool. Remove the parent task from the pool if complete. @@ -1419,7 +1424,10 @@ def spawn_on_output(self, itask, output, forced=False): if not in_pool: self.add_to_pool(t) - if t.point <= self.runahead_limit_point: + if ( + self.runahead_limit_point is not None + and t.point <= self.runahead_limit_point + ): self.rh_release_and_queue(t) # Event-driven suicide. From f1b543aff6716292137c0841f096d50526065b8c Mon Sep 17 00:00:00 2001 From: Ronnie Dutta <61982285+MetRonnie@users.noreply.github.com> Date: Thu, 5 Sep 2024 15:19:17 +0100 Subject: [PATCH 165/196] Remove unused argument --- cylc/flow/task_events_mgr.py | 8 +++++--- cylc/flow/task_pool.py | 12 +++--------- tests/integration/test_task_pool.py | 28 +++++++++------------------- 3 files changed, 17 insertions(+), 31 deletions(-) diff --git a/cylc/flow/task_events_mgr.py b/cylc/flow/task_events_mgr.py index f9b4d4c3243..0a65baea1a9 100644 --- a/cylc/flow/task_events_mgr.py +++ b/cylc/flow/task_events_mgr.py @@ -35,6 +35,7 @@ from typing import ( TYPE_CHECKING, Any, + Callable, Dict, List, NamedTuple, @@ -437,6 +438,9 @@ class TaskEventsManager(): workflow_cfg: Dict[str, Any] uuid_str: str + # To be set by the task pool: + spawn_func: Callable[['TaskProxy', str], Any] + mail_interval: float = 0 mail_smtp: Optional[str] = None mail_footer: Optional[str] = None @@ -459,8 +463,6 @@ def __init__( self._event_timers: Dict[EventKey, Any] = {} # NOTE: flag for DB use self.event_timers_updated = True - # To be set by the task pool: - self.spawn_func = None self.timestamp = timestamp self.bad_hosts = bad_hosts @@ -1966,7 +1968,7 @@ def reset_bad_hosts(self): ) self.bad_hosts.clear() - def spawn_children(self, itask, output): + def spawn_children(self, itask: 'TaskProxy', output: str) -> None: # update DB task outputs self.workflow_db_mgr.put_update_task_outputs(itask) # spawn child-tasks diff --git a/cylc/flow/task_pool.py b/cylc/flow/task_pool.py index 58a0134d3b5..d012f32b7c0 100644 --- a/cylc/flow/task_pool.py +++ b/cylc/flow/task_pool.py @@ -1332,9 +1332,7 @@ def check_abort_on_task_fails(self): """ return self.abort_task_failed - def spawn_on_output( - self, itask: TaskProxy, output: str, forced: bool = False - ) -> None: + def spawn_on_output(self, itask: TaskProxy, output: str) -> None: """Spawn child-tasks of given output, into the pool. Remove the parent task from the pool if complete. @@ -1355,7 +1353,6 @@ def spawn_on_output( Args: output: output to spawn on. - forced: True if called from manual set task command """ if ( @@ -1366,7 +1363,7 @@ def spawn_on_output( self.abort_task_failed = True children = [] - if itask.flow_nums or forced: + if itask.flow_nums: with suppress(KeyError): children = itask.graph_children[output] @@ -1397,10 +1394,7 @@ def spawn_on_output( if c_task is not None and c_task != itask: # (Avoid self-suicide: A => !A) self.merge_flows(c_task, itask.flow_nums) - elif ( - c_task is None - and (itask.flow_nums or forced) - ): + elif c_task is None and itask.flow_nums: # If child is not in the pool already, and parent belongs to a # flow (so it can spawn children), and parent is not waiting # for an upcoming flow merge before spawning ... then spawn it. diff --git a/tests/integration/test_task_pool.py b/tests/integration/test_task_pool.py index a69e6b0cb72..1064edb874a 100644 --- a/tests/integration/test_task_pool.py +++ b/tests/integration/test_task_pool.py @@ -1085,12 +1085,9 @@ async def test_no_flow_tasks_dont_spawn( 'R1': 'a => b => c' } }, - 'scheduler': { - 'allow implicit tasks': 'true', - }, }) - schd = scheduler(id_) + schd: Scheduler = scheduler(id_) async with start(schd): task_a = schd.pool.get_tasks()[0] @@ -1099,29 +1096,22 @@ async def test_no_flow_tasks_dont_spawn( # Set as completed: should not spawn children. schd.pool.set_prereqs_and_outputs( - [task_a.identity], None, None, [FLOW_NONE]) + [task_a.identity], [], [], [FLOW_NONE] + ) + assert not schd.pool.get_tasks() - for flow_nums, force, pool in ( + for flow_nums, expected_pool in ( # outputs yielded from a no-flow task should not spawn downstreams - (set(), False, []), - # forced spawning downstream of a no-flow task should spawn - # downstreams with flow_nums={} - (set(), True, [('1/b', set())]), + (set(), []), # outputs yielded from a task with flow numbers should spawn # downstreams in the same flow - ({1}, False, [('1/b', {1})]), - # forced spawning should work in the same way - ({1}, True, [('1/b', {1})]), + ({1}, [('1/b', {1})]), ): # set the flow-nums on 1/a task_a.flow_nums = flow_nums # spawn on the succeeded output - schd.pool.spawn_on_output( - task_a, - TASK_OUTPUT_SUCCEEDED, - forced=force, - ) + schd.pool.spawn_on_output(task_a, TASK_OUTPUT_SUCCEEDED) schd.pool.spawn_on_all_outputs(task_a) @@ -1129,7 +1119,7 @@ async def test_no_flow_tasks_dont_spawn( assert [ (itask.identity, itask.flow_nums) for itask in schd.pool.get_tasks() - ] == pool + ] == expected_pool async def test_task_proxy_remove_from_queues( From a88b8d60acb70d781ef8063cf5a134fb80e14120 Mon Sep 17 00:00:00 2001 From: Ronnie Dutta <61982285+MetRonnie@users.noreply.github.com> Date: Thu, 5 Sep 2024 16:09:02 +0100 Subject: [PATCH 166/196] Fix mixed up args --- cylc/flow/task_pool.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/cylc/flow/task_pool.py b/cylc/flow/task_pool.py index d012f32b7c0..082745e43e9 100644 --- a/cylc/flow/task_pool.py +++ b/cylc/flow/task_pool.py @@ -742,7 +742,9 @@ def get_or_spawn_task( is_xtrig_sequential = False if ntask is None: # ntask does not exist: spawn it in the flow. - ntask = self.spawn_task(tdef.name, point, flow_nums, flow_wait) + ntask = self.spawn_task( + tdef.name, point, flow_nums, flow_wait=flow_wait + ) # if the task was found set xtrigger checking type. # otherwise find the xtrigger type if it can't spawn # for whatever reason. @@ -1661,7 +1663,6 @@ def spawn_task( name: str, point: 'PointBase', flow_nums: Set[int], - force: bool = False, flow_wait: bool = False, ) -> Optional[TaskProxy]: """Return a new task proxy for the given flow if possible. @@ -1726,7 +1727,7 @@ def spawn_task( if prev_flow_wait: self._spawn_after_flow_wait(itask) - if itask.transient and not force: + if itask.transient: return None if not itask.transient: @@ -2019,7 +2020,9 @@ def _set_prereqs_tdef( ): """Spawn a future task and set prerequisites on it.""" - itask = self.spawn_task(taskdef.name, point, flow_nums, flow_wait) + itask = self.spawn_task( + taskdef.name, point, flow_nums, flow_wait=flow_wait + ) if itask is None: return if self._set_prereqs_itask(itask, prereqs, flow_nums): From 94799a666bd46b10652df0891b4d17f0be0e9878 Mon Sep 17 00:00:00 2001 From: Ronnie Dutta <61982285+MetRonnie@users.noreply.github.com> Date: Fri, 6 Sep 2024 16:37:00 +0100 Subject: [PATCH 167/196] Data store: fix missing task proxy flow nums --- cylc/flow/data_store_mgr.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/cylc/flow/data_store_mgr.py b/cylc/flow/data_store_mgr.py index b98a055f882..bfadadfb55a 100644 --- a/cylc/flow/data_store_mgr.py +++ b/cylc/flow/data_store_mgr.py @@ -116,6 +116,7 @@ if TYPE_CHECKING: from cylc.flow.cycling import PointBase from cylc.flow.flow_mgr import FlowNums + from cylc.flow.scheduler import Scheduler EDGES = 'edges' FAMILIES = 'families' @@ -468,7 +469,7 @@ class DataStoreMgr: ERR_PREFIX_JOB_NOT_ON_SEQUENCE = 'Invalid cycle point for job: ' def __init__(self, schd, n_edge_distance=1): - self.schd = schd + self.schd: Scheduler = schd self.id_ = Tokens( user=self.schd.owner, workflow=self.schd.workflow, @@ -1182,10 +1183,7 @@ def generate_ghost_task( t_id = self.definition_id(name) if itask is None: - itask = self.schd.pool.get_task(point_string, name) - - if itask is None: - itask = TaskProxy( + itask = self.schd.pool.get_task(point_string, name) or TaskProxy( self.id_, self.schd.config.get_taskdef(name), point, @@ -1226,6 +1224,7 @@ def generate_ghost_task( depth=task_def.depth, graph_depth=n_depth, name=name, + flow_nums=serialise_set(flow_nums), ) self.all_n_window_nodes.add(tp_id) self.n_window_depths.setdefault(n_depth, set()).add(tp_id) From 09d4a51988af911e7f06d90ffb1b751848b3335e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 12 Sep 2024 14:47:52 +0000 Subject: [PATCH 168/196] Prepare release 8.3.4 Workflow: Release stage 1 - create release PR (Cylc 8+ only), run: 43 --- CHANGES.md | 32 ++++++++++++++++++++++++++++++++ changes.d/6175.fix.md | 1 - changes.d/6214.fix.md | 1 - changes.d/6264.fix.md | 1 - changes.d/6266.feat.md | 1 - changes.d/6267.fix.md | 1 - changes.d/6310.fix.md | 1 - changes.d/6330.fix.md | 1 - changes.d/6332.fix.md | 1 - changes.d/6335.fix.md | 1 - changes.d/6337.fix.md | 1 - changes.d/6345.fix.md | 1 - changes.d/6351.fix.md | 1 - changes.d/6353.fix.md | 1 - cylc/flow/__init__.py | 2 +- 15 files changed, 33 insertions(+), 14 deletions(-) delete mode 100644 changes.d/6175.fix.md delete mode 100644 changes.d/6214.fix.md delete mode 100644 changes.d/6264.fix.md delete mode 100644 changes.d/6266.feat.md delete mode 100644 changes.d/6267.fix.md delete mode 100644 changes.d/6310.fix.md delete mode 100644 changes.d/6330.fix.md delete mode 100644 changes.d/6332.fix.md delete mode 100644 changes.d/6335.fix.md delete mode 100644 changes.d/6337.fix.md delete mode 100644 changes.d/6345.fix.md delete mode 100644 changes.d/6351.fix.md delete mode 100644 changes.d/6353.fix.md diff --git a/CHANGES.md b/CHANGES.md index 0c33e6ce915..18911f06b28 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -11,6 +11,38 @@ $ towncrier create ..md --content "Short description" +## __cylc-8.3.4 (Released 2024-09-12)__ + +### 🚀 Enhancements + +[#6266](https://github.com/cylc/cylc-flow/pull/6266) - 'cylc show' task output is now sorted by the task id + +### 🔧 Fixes + +[#6175](https://github.com/cylc/cylc-flow/pull/6175) - The workflow-state command and xtrigger will now reject invalid polling arguments. + +[#6214](https://github.com/cylc/cylc-flow/pull/6214) - `cylc lint` rules U013 & U015 now tell you which deprecated variables you are using + +[#6264](https://github.com/cylc/cylc-flow/pull/6264) - Fix bug where `cylc install` failed to prevent invalid run names. + +[#6267](https://github.com/cylc/cylc-flow/pull/6267) - Fixed bug in `cylc play` affecting run host reinvocation after interactively upgrading the workflow to a new Cylc version. + +[#6310](https://github.com/cylc/cylc-flow/pull/6310) - Fix a spurious traceback that could occur when running the `cylc play` command on Mac OS. + +[#6330](https://github.com/cylc/cylc-flow/pull/6330) - Fix bug where broadcasting failed to change platform selected after host selection failure. + +[#6332](https://github.com/cylc/cylc-flow/pull/6332) - Fixes unformatted string + +[#6335](https://github.com/cylc/cylc-flow/pull/6335) - Fix an issue that could cause broadcasts made to multiple namespaces to fail. + +[#6337](https://github.com/cylc/cylc-flow/pull/6337) - Fix potential duplicate job submissions when manually triggering unqueued active tasks. + +[#6345](https://github.com/cylc/cylc-flow/pull/6345) - Fix duplicate job submissions of tasks in the preparing state before reload. + +[#6351](https://github.com/cylc/cylc-flow/pull/6351) - Fix a bug where simulation mode tasks were not spawning children of task:started. + +[#6353](https://github.com/cylc/cylc-flow/pull/6353) - Prevent clock-expired tasks from being automatically retried. + ## __cylc-8.3.3 (Released 2024-07-23)__ ### 🔧 Fixes diff --git a/changes.d/6175.fix.md b/changes.d/6175.fix.md deleted file mode 100644 index b135207930e..00000000000 --- a/changes.d/6175.fix.md +++ /dev/null @@ -1 +0,0 @@ -The workflow-state command and xtrigger will now reject invalid polling arguments. diff --git a/changes.d/6214.fix.md b/changes.d/6214.fix.md deleted file mode 100644 index 5c77f3bedcd..00000000000 --- a/changes.d/6214.fix.md +++ /dev/null @@ -1 +0,0 @@ -`cylc lint` rules U013 & U015 now tell you which deprecated variables you are using diff --git a/changes.d/6264.fix.md b/changes.d/6264.fix.md deleted file mode 100644 index 3cea3d78cf6..00000000000 --- a/changes.d/6264.fix.md +++ /dev/null @@ -1 +0,0 @@ -Fix bug where `cylc install` failed to prevent invalid run names. diff --git a/changes.d/6266.feat.md b/changes.d/6266.feat.md deleted file mode 100644 index 4ddd44b83b4..00000000000 --- a/changes.d/6266.feat.md +++ /dev/null @@ -1 +0,0 @@ -'cylc show' task output is now sorted by the task id diff --git a/changes.d/6267.fix.md b/changes.d/6267.fix.md deleted file mode 100644 index 9842ba095a6..00000000000 --- a/changes.d/6267.fix.md +++ /dev/null @@ -1 +0,0 @@ -Fixed bug in `cylc play` affecting run host reinvocation after interactively upgrading the workflow to a new Cylc version. \ No newline at end of file diff --git a/changes.d/6310.fix.md b/changes.d/6310.fix.md deleted file mode 100644 index 66e8c8557d4..00000000000 --- a/changes.d/6310.fix.md +++ /dev/null @@ -1 +0,0 @@ -Fix a spurious traceback that could occur when running the `cylc play` command on Mac OS. diff --git a/changes.d/6330.fix.md b/changes.d/6330.fix.md deleted file mode 100644 index 190a9637a93..00000000000 --- a/changes.d/6330.fix.md +++ /dev/null @@ -1 +0,0 @@ -Fix bug where broadcasting failed to change platform selected after host selection failure. \ No newline at end of file diff --git a/changes.d/6332.fix.md b/changes.d/6332.fix.md deleted file mode 100644 index 6fa937c4d33..00000000000 --- a/changes.d/6332.fix.md +++ /dev/null @@ -1 +0,0 @@ - Fixes unformatted string diff --git a/changes.d/6335.fix.md b/changes.d/6335.fix.md deleted file mode 100644 index 2057bae0f7c..00000000000 --- a/changes.d/6335.fix.md +++ /dev/null @@ -1 +0,0 @@ -Fix an issue that could cause broadcasts made to multiple namespaces to fail. diff --git a/changes.d/6337.fix.md b/changes.d/6337.fix.md deleted file mode 100644 index 6a6fd72757f..00000000000 --- a/changes.d/6337.fix.md +++ /dev/null @@ -1 +0,0 @@ -Fix potential duplicate job submissions when manually triggering unqueued active tasks. diff --git a/changes.d/6345.fix.md b/changes.d/6345.fix.md deleted file mode 100644 index a629eb6c8ed..00000000000 --- a/changes.d/6345.fix.md +++ /dev/null @@ -1 +0,0 @@ -Fix duplicate job submissions of tasks in the preparing state before reload. diff --git a/changes.d/6351.fix.md b/changes.d/6351.fix.md deleted file mode 100644 index 7e95da24378..00000000000 --- a/changes.d/6351.fix.md +++ /dev/null @@ -1 +0,0 @@ -Fix a bug where simulation mode tasks were not spawning children of task:started. diff --git a/changes.d/6353.fix.md b/changes.d/6353.fix.md deleted file mode 100644 index f6a1622d03a..00000000000 --- a/changes.d/6353.fix.md +++ /dev/null @@ -1 +0,0 @@ -Prevent clock-expired tasks from being automatically retried. diff --git a/cylc/flow/__init__.py b/cylc/flow/__init__.py index 3f0bfe5c0b1..3d83b97a265 100644 --- a/cylc/flow/__init__.py +++ b/cylc/flow/__init__.py @@ -53,7 +53,7 @@ def environ_init(): environ_init() -__version__ = '8.3.4.dev' +__version__ = '8.3.4' def iter_entry_points(entry_point_name): From 441c789501b7f0b8c5e239475b867146417d7c16 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 12 Sep 2024 17:15:03 +0100 Subject: [PATCH 169/196] Bump dev version (#6374) --- cylc/flow/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cylc/flow/__init__.py b/cylc/flow/__init__.py index 3d83b97a265..d8671066b08 100644 --- a/cylc/flow/__init__.py +++ b/cylc/flow/__init__.py @@ -53,7 +53,7 @@ def environ_init(): environ_init() -__version__ = '8.3.4' +__version__ = '8.3.5.dev' def iter_entry_points(entry_point_name): From c142d1a544ddad7b6822a7f63660bd261ec4467d Mon Sep 17 00:00:00 2001 From: Ronnie Dutta <61982285+MetRonnie@users.noreply.github.com> Date: Fri, 27 Sep 2024 17:27:31 +0100 Subject: [PATCH 170/196] Use flake8-implicit-str-concat --- setup.cfg | 1 + tox.ini | 2 ++ 2 files changed, 3 insertions(+) diff --git a/setup.cfg b/setup.cfg index d2a66c1b4e0..4e3b2f65200 100644 --- a/setup.cfg +++ b/setup.cfg @@ -113,6 +113,7 @@ tests = flake8-builtins>=1.5.0 flake8-comprehensions>=3.5.0 flake8-debugger>=4.0.0 + flake8-implicit-str-concat>=0.4 flake8-mutable>=1.2.0 flake8-simplify>=0.14.0 flake8-type-checking; python_version > "3.7" diff --git a/tox.ini b/tox.ini index 87d23419730..b6b77d7f422 100644 --- a/tox.ini +++ b/tox.ini @@ -8,6 +8,8 @@ ignore= W504 ; "experimental" SIM9xx rules (flake8-simplify) SIM9 + ; explicitly concatenated strings (flake8-implicit-str-concat) + ISC003 per-file-ignores= ; TYPE_CHECKING block suggestions From 372e01ed6ec49f1df4e5aab85f204e670d2efb0f Mon Sep 17 00:00:00 2001 From: Ronnie Dutta <61982285+MetRonnie@users.noreply.github.com> Date: Wed, 2 Oct 2024 12:34:13 +0100 Subject: [PATCH 171/196] Fix simulation mode submit num bug (#6362) --- changes.d/6362.fix.md | 1 + cylc/flow/workflow_db_mgr.py | 5 +++-- tests/integration/test_simulation.py | 14 ++++++++++++++ 3 files changed, 18 insertions(+), 2 deletions(-) create mode 100644 changes.d/6362.fix.md diff --git a/changes.d/6362.fix.md b/changes.d/6362.fix.md new file mode 100644 index 00000000000..d469ede6b95 --- /dev/null +++ b/changes.d/6362.fix.md @@ -0,0 +1 @@ +Fixed simulation mode bug where the task submit number would not increment diff --git a/cylc/flow/workflow_db_mgr.py b/cylc/flow/workflow_db_mgr.py index 8e57d19292b..3bd627ad5b0 100644 --- a/cylc/flow/workflow_db_mgr.py +++ b/cylc/flow/workflow_db_mgr.py @@ -426,14 +426,15 @@ def put_update_task_state(self, itask): "status": itask.state.status, "flow_wait": itask.flow_wait, "is_manual_submit": itask.is_manual_submit, + "submit_num": itask.submit_num, } + # Note tasks_states table rows are for latest submit_num only + # (not one row per submit). where_args = { "cycle": str(itask.point), "name": itask.tdef.name, "flow_nums": serialise_set(itask.flow_nums), } - # Note tasks_states table rows are for latest submit_num only - # (not one row per submit). self.db_updates_map.setdefault(self.TABLE_TASK_STATES, []) self.db_updates_map[self.TABLE_TASK_STATES].append( (set_args, where_args)) diff --git a/tests/integration/test_simulation.py b/tests/integration/test_simulation.py index c7e1b42fe27..49bc76ce5e2 100644 --- a/tests/integration/test_simulation.py +++ b/tests/integration/test_simulation.py @@ -15,11 +15,13 @@ # along with this program. If not, see . from pathlib import Path + import pytest from pytest import param from cylc.flow import commands from cylc.flow.cycling.iso8601 import ISO8601Point +from cylc.flow.scheduler import Scheduler from cylc.flow.simulation import sim_time_check @@ -443,3 +445,15 @@ async def test_settings_broadcast( assert itask.try_timers['execution-retry'].delays == [2.0, 2.0, 2.0] # n.b. rtconfig should remain unchanged, lest we cancel broadcasts: assert itask.tdef.rtconfig['execution retry delays'] == [5.0, 5.0] + + +async def test_db_submit_num( + flow, one_conf, scheduler, run, complete, db_select +): + """Test simulation mode correctly increments the submit_num in the DB.""" + schd: Scheduler = scheduler(flow(one_conf), paused_start=False) + async with run(schd): + await complete(schd, '1/one') + assert db_select(schd, False, 'task_states', 'submit_num', 'status') == [ + (1, 'succeeded'), + ] From e7742b134781a2344ccbd5568b986591b511b352 Mon Sep 17 00:00:00 2001 From: Tim Pillinger <26465611+wxtim@users.noreply.github.com> Date: Tue, 8 Oct 2024 15:13:33 +0100 Subject: [PATCH 172/196] `cylc vr`: fix bug with icp = now/next/previous (#6316) --- changes.d/6316.fix.md | 1 + cylc/flow/config.py | 7 ++-- cylc/flow/workflow_db_mgr.py | 10 ++++- tests/flakyfunctional/database/00-simple.t | 2 +- .../cylc-combination-scripts/09-vr-icp-now.t | 39 +++++++++++++++++++ .../09-vr-icp-now/flow.cylc | 9 +++++ .../data-store/00-prune-optional-break.t | 14 +++---- .../flow-triggers/10-specific-flow.t | 3 +- .../flow-triggers/10-specific-flow/flow.cylc | 8 +++- tests/functional/reload/26-stalled.t | 2 +- .../functional/triggering/08-fam-finish-any.t | 3 +- tests/unit/test_config.py | 14 +++---- 12 files changed, 88 insertions(+), 24 deletions(-) create mode 100644 changes.d/6316.fix.md create mode 100644 tests/functional/cylc-combination-scripts/09-vr-icp-now.t create mode 100644 tests/functional/cylc-combination-scripts/09-vr-icp-now/flow.cylc diff --git a/changes.d/6316.fix.md b/changes.d/6316.fix.md new file mode 100644 index 00000000000..4f6346f8e96 --- /dev/null +++ b/changes.d/6316.fix.md @@ -0,0 +1 @@ +Fixed bug in `cylc vr` where an initial cycle point of `now`/`next()`/`previous()` would result in an error. diff --git a/cylc/flow/config.py b/cylc/flow/config.py index c5e82d74d97..9cb19ac7d2c 100644 --- a/cylc/flow/config.py +++ b/cylc/flow/config.py @@ -690,7 +690,7 @@ def process_initial_cycle_point(self) -> None: Sets: self.initial_point self.cfg['scheduling']['initial cycle point'] - self.options.icp + self.evaluated_icp Raises: WorkflowConfigError - if it fails to validate """ @@ -710,10 +710,11 @@ def process_initial_cycle_point(self) -> None: icp = ingest_time(orig_icp, get_current_time_string()) except IsodatetimeError as exc: raise WorkflowConfigError(str(exc)) - if orig_icp != icp: + self.evaluated_icp = None + if icp != orig_icp: # now/next()/previous() was used, need to store # evaluated point in DB - self.options.icp = icp + self.evaluated_icp = icp self.initial_point = get_point(icp).standardise() self.cfg['scheduling']['initial cycle point'] = str(self.initial_point) diff --git a/cylc/flow/workflow_db_mgr.py b/cylc/flow/workflow_db_mgr.py index 3bd627ad5b0..b833b994d00 100644 --- a/cylc/flow/workflow_db_mgr.py +++ b/cylc/flow/workflow_db_mgr.py @@ -327,8 +327,16 @@ def put_workflow_params(self, schd: 'Scheduler') -> None: {"key": self.KEY_STOP_CLOCK_TIME, "value": schd.stop_clock_time}, {"key": self.KEY_STOP_TASK, "value": schd.stop_task}, ]) - for key in ( + + # Store raw initial cycle point in the DB. + value = schd.config.evaluated_icp + value = None if value == 'reload' else value + self.put_workflow_params_1( self.KEY_INITIAL_CYCLE_POINT, + value or str(schd.config.initial_point) + ) + + for key in ( self.KEY_FINAL_CYCLE_POINT, self.KEY_START_CYCLE_POINT, self.KEY_STOP_CYCLE_POINT diff --git a/tests/flakyfunctional/database/00-simple.t b/tests/flakyfunctional/database/00-simple.t index edf86107e51..c3f1ad19faf 100644 --- a/tests/flakyfunctional/database/00-simple.t +++ b/tests/flakyfunctional/database/00-simple.t @@ -46,7 +46,7 @@ UTC_mode|0 cycle_point_format| cylc_version|$(cylc --version) fcp| -icp| +icp|1 is_paused|0 n_restart|0 run_mode| diff --git a/tests/functional/cylc-combination-scripts/09-vr-icp-now.t b/tests/functional/cylc-combination-scripts/09-vr-icp-now.t new file mode 100644 index 00000000000..932e735fff1 --- /dev/null +++ b/tests/functional/cylc-combination-scripts/09-vr-icp-now.t @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# THIS FILE IS PART OF THE CYLC WORKFLOW ENGINE. +# Copyright (C) NIWA & British Crown (Met Office) & Contributors. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +#------------------------------------------------------------------------------ +# Ensure that validate step of Cylc VR cannot change the options object. +# See https://github.com/cylc/cylc-flow/issues/6262 + +. "$(dirname "$0")/test_header" +set_test_number 2 + +WORKFLOW_ID=$(workflow_id) + +cp -r "${TEST_SOURCE_DIR}/${TEST_NAME_BASE}/flow.cylc" . + +run_ok "${TEST_NAME_BASE}-vip" \ + cylc vip . \ + --workflow-name "${WORKFLOW_ID}" \ + --no-detach \ + --no-run-name + +echo "# Some Comment" >> flow.cylc + +run_ok "${TEST_NAME_BASE}-vr" \ + cylc vr "${WORKFLOW_ID}" \ + --stop-cycle-point 2020-01-01T00:02Z diff --git a/tests/functional/cylc-combination-scripts/09-vr-icp-now/flow.cylc b/tests/functional/cylc-combination-scripts/09-vr-icp-now/flow.cylc new file mode 100644 index 00000000000..e9f6284769e --- /dev/null +++ b/tests/functional/cylc-combination-scripts/09-vr-icp-now/flow.cylc @@ -0,0 +1,9 @@ +[scheduling] + initial cycle point = 2020 + stop after cycle point = 2020-01-01T00:01Z + [[graph]] + PT1M = foo +[runtime] + [[foo]] + [[[simulation]]] + default run length = PT0S diff --git a/tests/functional/data-store/00-prune-optional-break.t b/tests/functional/data-store/00-prune-optional-break.t index 9b09ac8d156..b50e5a51664 100755 --- a/tests/functional/data-store/00-prune-optional-break.t +++ b/tests/functional/data-store/00-prune-optional-break.t @@ -27,9 +27,9 @@ init_workflow "${TEST_NAME_BASE}" << __FLOW__ final cycle point = 1 [[graph]] P1 = """ -a? => b? => c? -d => e -""" + a => b? => c? + a => d => e + """ [runtime] [[a,c,e]] script = true @@ -37,15 +37,15 @@ d => e script = false [[d]] script = """ -cylc workflow-state \${CYLC_WORKFLOW_ID}//1/b:failed --interval=2 -cylc pause \$CYLC_WORKFLOW_ID -""" + cylc workflow-state \${CYLC_WORKFLOW_ID}//1/b:failed --interval=2 --max-polls=20 -v + cylc pause \$CYLC_WORKFLOW_ID + """ __FLOW__ # run workflow run_ok "${TEST_NAME_BASE}-run" cylc play "${WORKFLOW_NAME}" -cylc workflow-state "${WORKFLOW_NAME}/1/d:succeeded" --interval=2 --max-polls=60 +cylc workflow-state "${WORKFLOW_NAME}//1/d:succeeded" --interval=2 --max-polls=60 -v # query workflow TEST_NAME="${TEST_NAME_BASE}-prune-optional-break" diff --git a/tests/functional/flow-triggers/10-specific-flow.t b/tests/functional/flow-triggers/10-specific-flow.t index ce5e80a6c68..238f1e12670 100644 --- a/tests/functional/flow-triggers/10-specific-flow.t +++ b/tests/functional/flow-triggers/10-specific-flow.t @@ -1,7 +1,7 @@ #!/usr/bin/env bash # THIS FILE IS PART OF THE CYLC WORKFLOW ENGINE. # Copyright (C) NIWA & British Crown (Met Office) & Contributors. -# +# # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or @@ -15,6 +15,7 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . #------------------------------------------------------------------------------- +# Test targeting a specific flow, with trigger --wait. . "$(dirname "$0")/test_header" set_test_number 2 diff --git a/tests/functional/flow-triggers/10-specific-flow/flow.cylc b/tests/functional/flow-triggers/10-specific-flow/flow.cylc index 46ba6dab4c1..ff6196a6871 100644 --- a/tests/functional/flow-triggers/10-specific-flow/flow.cylc +++ b/tests/functional/flow-triggers/10-specific-flow/flow.cylc @@ -17,6 +17,10 @@ [[trigger-happy]] script = """ cylc trigger --flow=2 --wait ${CYLC_WORKFLOW_ID}//1/f - cylc__job__poll_grep_workflow_log "1/d/01:submitted.*running" - cylc trigger --flow=2 ${CYLC_WORKFLOW_ID}//1/b + """ + [[d]] + script = """ + if [[ "$CYLC_TASK_SUBMIT_NUMBER" == "1" ]]; then + cylc trigger --flow=2 ${CYLC_WORKFLOW_ID}//1/b + fi """ diff --git a/tests/functional/reload/26-stalled.t b/tests/functional/reload/26-stalled.t index 63dabb2ba81..8f6e7594a48 100644 --- a/tests/functional/reload/26-stalled.t +++ b/tests/functional/reload/26-stalled.t @@ -26,7 +26,7 @@ init_workflow "${TEST_NAME_BASE}" <<'__FLOW__' [scheduler] [[events]] stall handlers = cylc reload %(workflow)s - stall timeout = PT10S + stall timeout = PT30S abort on stall timeout = True # Prevent infinite loop if the bug resurfaces workflow timeout = PT3M diff --git a/tests/functional/triggering/08-fam-finish-any.t b/tests/functional/triggering/08-fam-finish-any.t index 2dda6132723..6849ee4a1c2 100644 --- a/tests/functional/triggering/08-fam-finish-any.t +++ b/tests/functional/triggering/08-fam-finish-any.t @@ -1,7 +1,7 @@ #!/usr/bin/env bash # THIS FILE IS PART OF THE CYLC WORKFLOW ENGINE. # Copyright (C) NIWA & British Crown (Met Office) & Contributors. -# +# # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or @@ -17,6 +17,7 @@ #------------------------------------------------------------------------------- # Test correct expansion of 'FAM:finish-any' . "$(dirname "$0")/test_header" +skip_macos_gh_actions set_test_number 2 reftest exit diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 9cdcee89003..4a820fdb126 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -267,7 +267,7 @@ def test_family_inheritance_and_quotes( @pytest.mark.parametrize( - ('cycling_type', 'scheduling_cfg', 'expected_icp', 'expected_opt_icp', + ('cycling_type', 'scheduling_cfg', 'expected_icp', 'expected_eval_icp', 'expected_err'), [ pytest.param( @@ -356,7 +356,7 @@ def test_process_icp( cycling_type: str, scheduling_cfg: Dict[str, Any], expected_icp: Optional[str], - expected_opt_icp: Optional[str], + expected_eval_icp: Optional[str], expected_err: Optional[Tuple[Type[Exception], str]], monkeypatch: pytest.MonkeyPatch, set_cycling_type: Fixture ) -> None: @@ -368,7 +368,7 @@ def test_process_icp( cycling_type: Workflow cycling type. scheduling_cfg: 'scheduling' section of workflow config. expected_icp: The expected icp value that gets set. - expected_opt_icp: The expected value of options.icp that gets set + expected_eval_icp: The expected value of options.icp that gets set (this gets stored in the workflow DB). expected_err: Exception class expected to be raised plus the message. """ @@ -396,10 +396,10 @@ def test_process_icp( assert mocked_config.cfg[ 'scheduling']['initial cycle point'] == expected_icp assert str(mocked_config.initial_point) == expected_icp - opt_icp = mocked_config.options.icp - if opt_icp is not None: - opt_icp = str(loader.get_point(opt_icp).standardise()) - assert opt_icp == expected_opt_icp + eval_icp = mocked_config.evaluated_icp + if eval_icp is not None: + eval_icp = str(loader.get_point(eval_icp).standardise()) + assert eval_icp == expected_eval_icp @pytest.mark.parametrize( From 2cde7ae125f34ec247af5481e4d9d8232f8616a7 Mon Sep 17 00:00:00 2001 From: Oliver Sanders Date: Tue, 8 Oct 2024 15:27:55 +0100 Subject: [PATCH 173/196] broadcast: fix dictionary changed size during iteration error * Closes #6222 --- changes.d/6397.fix.md | 1 + cylc/flow/data_store_mgr.py | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) create mode 100644 changes.d/6397.fix.md diff --git a/changes.d/6397.fix.md b/changes.d/6397.fix.md new file mode 100644 index 00000000000..589c29dbb11 --- /dev/null +++ b/changes.d/6397.fix.md @@ -0,0 +1 @@ +Fix "dictionary changed size during iteration error" which could occur with broadcasts. diff --git a/cylc/flow/data_store_mgr.py b/cylc/flow/data_store_mgr.py index bfadadfb55a..3b49050f0ff 100644 --- a/cylc/flow/data_store_mgr.py +++ b/cylc/flow/data_store_mgr.py @@ -2251,7 +2251,9 @@ def delta_broadcast(self): def _generate_broadcast_node_deltas(self, node_data, node_type): cfg = self.schd.config.cfg - for node_id, node in node_data.items(): + # NOTE: node_data may change during operation so make a copy + # see https://github.com/cylc/cylc-flow/pull/6397 + for node_id, node in list(node_data.items()): tokens = Tokens(node_id) new_runtime = runtime_from_config( self._apply_broadcasts_to_runtime( From d1761dbfe1f481a72f816e58e85e969403cd7b61 Mon Sep 17 00:00:00 2001 From: Ronnie Dutta <61982285+MetRonnie@users.noreply.github.com> Date: Thu, 10 Oct 2024 12:23:22 +0100 Subject: [PATCH 174/196] Tutorial workflow: validate domain range --- .../lib/python/util.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/cylc/flow/etc/tutorial/cylc-forecasting-workflow/lib/python/util.py b/cylc/flow/etc/tutorial/cylc-forecasting-workflow/lib/python/util.py index 6f24b28cbe0..6450bbc161d 100644 --- a/cylc/flow/etc/tutorial/cylc-forecasting-workflow/lib/python/util.py +++ b/cylc/flow/etc/tutorial/cylc-forecasting-workflow/lib/python/util.py @@ -282,13 +282,18 @@ def __call__(self, grid_x, grid_y): return z_val -def parse_domain(domain): - bbox = list(map(float, domain.split(','))) +def parse_domain(domain: str): + lng1, lat1, lng2, lat2 = list(map(float, domain.split(','))) + msg = "Invalid domain '{}' ({} {} >= {})" + if lng1 >= lng2: + raise ValueError(msg.format(domain, 'longitude', lng1, lng2)) + if lat1 >= lat2: + raise ValueError(msg.format(domain, 'latitude', lat1, lat2)) return { - 'lng1': bbox[0], - 'lat1': bbox[1], - 'lng2': bbox[2], - 'lat2': bbox[3] + 'lng1': lng1, + 'lat1': lat1, + 'lng2': lng2, + 'lat2': lat2, } From 322a894ed7e7b7552ee3d55dd43c39c76686d4ba Mon Sep 17 00:00:00 2001 From: Ronnie Dutta <61982285+MetRonnie@users.noreply.github.com> Date: Fri, 11 Oct 2024 12:21:28 +0100 Subject: [PATCH 175/196] Tidy & add type annotations --- cylc/flow/taskdef.py | 86 +++++++++++++++++++++++++++++--------------- 1 file changed, 58 insertions(+), 28 deletions(-) diff --git a/cylc/flow/taskdef.py b/cylc/flow/taskdef.py index 448844c8cc3..e0adcaac686 100644 --- a/cylc/flow/taskdef.py +++ b/cylc/flow/taskdef.py @@ -17,10 +17,17 @@ """Task definition.""" from collections import deque -from typing import TYPE_CHECKING +from typing import ( + TYPE_CHECKING, + Dict, + List, + NamedTuple, + Set, + Tuple, +) -import cylc.flow.flags from cylc.flow.exceptions import TaskDefError +import cylc.flow.flags from cylc.flow.task_id import TaskID from cylc.flow.task_outputs import ( TASK_OUTPUT_SUBMITTED, @@ -30,16 +37,26 @@ ) if TYPE_CHECKING: - from cylc.flow.cycling import PointBase + from cylc.flow.cycling import ( + PointBase, + SequenceBase, + ) + from cylc.flow.task_trigger import TaskTrigger -def generate_graph_children(tdef, point): +class TaskTuple(NamedTuple): + name: str + point: 'PointBase' + is_abs: bool + + +def generate_graph_children( + tdef: 'TaskDef', point: 'PointBase' +) -> Dict[str, List[TaskTuple]]: """Determine graph children of this task at point.""" - graph_children = {} + graph_children: Dict[str, List[TaskTuple]] = {} for seq, dout in tdef.graph_children.items(): for output, downs in dout.items(): - if output not in graph_children: - graph_children[output] = [] for name, trigger in downs: child_point = trigger.get_child_point(point, seq) is_abs = ( @@ -53,7 +70,9 @@ def generate_graph_children(tdef, point): # E.g.: foo should trigger only on T06: # PT6H = "waz" # T06 = "waz[-PT6H] => foo" - graph_children[output].append((name, child_point, is_abs)) + graph_children.setdefault(output, []).append( + TaskTuple(name, child_point, is_abs) + ) if tdef.sequential: # Add next-instance child. @@ -64,20 +83,21 @@ def generate_graph_children(tdef, point): # Within sequence bounds. nexts.append(nxt) if nexts: - if TASK_OUTPUT_SUCCEEDED not in graph_children: - graph_children[TASK_OUTPUT_SUCCEEDED] = [] - graph_children[TASK_OUTPUT_SUCCEEDED].append( - (tdef.name, min(nexts), False)) + graph_children.setdefault(TASK_OUTPUT_SUCCEEDED, []).append( + TaskTuple(tdef.name, min(nexts), False) + ) return graph_children -def generate_graph_parents(tdef, point, taskdefs): +def generate_graph_parents( + tdef: 'TaskDef', point: 'PointBase', taskdefs: Dict[str, 'TaskDef'] +) -> Dict['SequenceBase', List[TaskTuple]]: """Determine concrete graph parents of task tdef at point. Infer parents be reversing upstream triggers that lead to point/task. """ - graph_parents = {} + graph_parents: Dict['SequenceBase', List[TaskTuple]] = {} for seq, triggers in tdef.graph_parents.items(): if not seq.is_valid(point): # Don't infer parents if the trigger belongs to a sequence that @@ -102,7 +122,9 @@ def generate_graph_parents(tdef, point, taskdefs): # TODO ideally validation would flag this as an error. continue is_abs = trigger.offset_is_absolute or trigger.offset_is_from_icp - graph_parents[seq].append((parent_name, parent_point, is_abs)) + graph_parents[seq].append( + TaskTuple(parent_name, parent_point, is_abs) + ) if tdef.sequential: # Add implicit previous-instance parent. @@ -113,9 +135,9 @@ def generate_graph_parents(tdef, point, taskdefs): # Within sequence bounds. prevs.append(prev) if prevs: - if seq not in graph_parents: - graph_parents[seq] = [] - graph_parents[seq].append((tdef.name, min(prevs), False)) + graph_parents.setdefault(seq, []).append( + TaskTuple(tdef.name, min(prevs), False) + ) return graph_parents @@ -157,8 +179,12 @@ def __init__(self, name, rtcfg, run_mode, start_point, initial_point): self.namespace_hierarchy = [] self.dependencies = {} self.outputs = {} # {output: (message, is_required)} - self.graph_children = {} - self.graph_parents = {} + self.graph_children: Dict[ + SequenceBase, Dict[str, List[Tuple[str, TaskTrigger]]] + ] = {} + self.graph_parents: Dict[ + SequenceBase, Set[Tuple[str, TaskTrigger]] + ] = {} self.param_var = {} self.external_triggers = [] self.xtrig_labels = {} # {sequence: [labels]} @@ -209,7 +235,9 @@ def tweak_outputs(self): ]: self.set_required_output(output, True) - def add_graph_child(self, trigger, taskname, sequence): + def add_graph_child( + self, trigger: 'TaskTrigger', taskname: str, sequence: 'SequenceBase' + ) -> None: """Record child task instances that depend on my outputs. {sequence: { @@ -218,18 +246,20 @@ def add_graph_child(self, trigger, taskname, sequence): } """ self.graph_children.setdefault( - sequence, {}).setdefault( - trigger.output, []).append((taskname, trigger)) - - def add_graph_parent(self, trigger, parent, sequence): + sequence, {} + ).setdefault( + trigger.output, [] + ).append((taskname, trigger)) + + def add_graph_parent( + self, trigger: 'TaskTrigger', parent: str, sequence: 'SequenceBase' + ) -> None: """Record task instances that I depend on. { sequence: set([(a,t1), (b,t2), ...]) # (task-name, trigger) } """ - if sequence not in self.graph_parents: - self.graph_parents[sequence] = set() - self.graph_parents[sequence].add((parent, trigger)) + self.graph_parents.setdefault(sequence, set()).add((parent, trigger)) def add_dependency(self, dependency, sequence): """Add a dependency to a named sequence. From 2790b5f77e75ef579ec747d13e42c3aff02ecd76 Mon Sep 17 00:00:00 2001 From: Ronnie Dutta <61982285+MetRonnie@users.noreply.github.com> Date: Fri, 11 Oct 2024 13:26:09 +0100 Subject: [PATCH 176/196] Fix possible type error --- cylc/flow/data_store_mgr.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cylc/flow/data_store_mgr.py b/cylc/flow/data_store_mgr.py index bfadadfb55a..3be29bc81a9 100644 --- a/cylc/flow/data_store_mgr.py +++ b/cylc/flow/data_store_mgr.py @@ -947,7 +947,7 @@ def increment_graph_window( ) for items in graph_children.values(): for child_name, child_point, _ in items: - if child_point > final_point: + if final_point and child_point > final_point: continue child_tokens = self.id_.duplicate( cycle=str(child_point), @@ -977,7 +977,7 @@ def increment_graph_window( taskdefs ).values(): for parent_name, parent_point, _ in items: - if parent_point > final_point: + if final_point and parent_point > final_point: continue parent_tokens = self.id_.duplicate( cycle=str(parent_point), From 2206a9b0aa22b7174637b0b654083cf03c3888fe Mon Sep 17 00:00:00 2001 From: Ronnie Dutta <61982285+MetRonnie@users.noreply.github.com> Date: Mon, 14 Oct 2024 16:13:43 +0100 Subject: [PATCH 177/196] GH Actions: use ubuntu-22.04 to keep using python 3.7 --- .github/workflows/build.yml | 4 +++- .github/workflows/test_fast.yml | 5 +++-- .github/workflows/test_functional.yml | 8 ++++---- .github/workflows/test_tutorial_workflow.yml | 8 ++++++-- 4 files changed, 16 insertions(+), 9 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 674542d4e63..acc86458619 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -18,8 +18,10 @@ jobs: strategy: matrix: os: ['ubuntu-latest'] - python: ['3.7', '3.8', '3.9', '3.10', '3.11'] + python: ['3.8', '3.9', '3.10', '3.11'] include: + - os: 'ubuntu-22.04' + python: '3.7' - os: 'macos-latest' python: '3.8' steps: diff --git a/.github/workflows/test_fast.yml b/.github/workflows/test_fast.yml index b58bc50ed99..dd65adc5e52 100644 --- a/.github/workflows/test_fast.yml +++ b/.github/workflows/test_fast.yml @@ -20,9 +20,10 @@ jobs: fail-fast: false # don't stop on first failure matrix: os: ['ubuntu-latest'] - python-version: ['3.7', '3.8', '3.10', '3.11', '3'] + python-version: ['3.8', '3.10', '3.11', '3'] include: - # mac os test + - os: 'ubuntu-22.04' + python-version: '3.7' - os: 'macos-latest' python-version: '3.9' # oldest supported version # non-utc timezone test diff --git a/.github/workflows/test_functional.yml b/.github/workflows/test_functional.yml index f055353d904..309e0f8b6fe 100644 --- a/.github/workflows/test_functional.yml +++ b/.github/workflows/test_functional.yml @@ -40,7 +40,7 @@ jobs: strategy: fail-fast: false matrix: - os: ['ubuntu-latest'] + os: ['ubuntu-22.04'] python-version: ['3.7'] test-base: ['tests/f'] chunk: ['1/4', '2/4', '3/4', '4/4'] @@ -56,20 +56,20 @@ jobs: platform: '_local_background*' # tests/k - name: 'flaky' - os: 'ubuntu-latest' + os: 'ubuntu-22.04' python-version: '3.7' test-base: 'tests/k' chunk: '1/1' platform: '_local_background* _local_at*' # remote platforms - name: '_remote_background_indep_poll' - os: 'ubuntu-latest' + os: 'ubuntu-22.04' python-version: '3.7' test-base: 'tests/f tests/k' chunk: '1/1' platform: '_remote_background_indep_poll _remote_at_indep_poll' - name: '_remote_background_indep_tcp' - os: 'ubuntu-latest' + os: 'ubuntu-22.04' test-base: 'tests/f tests/k' python-version: '3.7' chunk: '1/1' diff --git a/.github/workflows/test_tutorial_workflow.yml b/.github/workflows/test_tutorial_workflow.yml index 7859b8588e2..a0d2ec777b5 100644 --- a/.github/workflows/test_tutorial_workflow.yml +++ b/.github/workflows/test_tutorial_workflow.yml @@ -21,8 +21,12 @@ jobs: test: strategy: matrix: - python-version: ['3.7', '3'] - runs-on: ubuntu-latest + include: + - os: 'ubuntu-latest' + python-version: '3' + - os: 'ubuntu-22.04' + python-version: '3.7' + runs-on: ${{ matrix.os }} timeout-minutes: 10 steps: - name: configure python From 2a3e47957321950c8ec372f6ad6f6e3a9da9d7f9 Mon Sep 17 00:00:00 2001 From: Ronnie Dutta <61982285+MetRonnie@users.noreply.github.com> Date: Mon, 14 Oct 2024 16:13:55 +0100 Subject: [PATCH 178/196] Shellcheck --- etc/bin/swarm | 2 -- tests/flakyfunctional/cylc-poll/16-execution-time-limit.t | 1 + tests/flakyfunctional/xtriggers/00-wall_clock.t | 1 + tests/functional/lib/bash/test_header | 1 - tests/functional/reload/17-graphing-change.t | 1 + 5 files changed, 3 insertions(+), 3 deletions(-) diff --git a/etc/bin/swarm b/etc/bin/swarm index 6e8c814d49e..77d5beba251 100755 --- a/etc/bin/swarm +++ b/etc/bin/swarm @@ -145,11 +145,9 @@ prompt () { case $USR in [Yy]) return 0 - break ;; [Nn]) return 1 - break ;; esac done diff --git a/tests/flakyfunctional/cylc-poll/16-execution-time-limit.t b/tests/flakyfunctional/cylc-poll/16-execution-time-limit.t index a01d42e2ab3..a7711318690 100755 --- a/tests/flakyfunctional/cylc-poll/16-execution-time-limit.t +++ b/tests/flakyfunctional/cylc-poll/16-execution-time-limit.t @@ -33,6 +33,7 @@ run_ok "${TEST_NAME_BASE}-validate" cylc validate "${WORKFLOW_NAME}" workflow_run_ok "${TEST_NAME_BASE}-run" \ cylc play --reference-test -v --no-detach "${WORKFLOW_NAME}" --timestamp #------------------------------------------------------------------------------- +# shellcheck disable=SC2317 cmp_times () { # Test if the times $1 and $2 are within $3 seconds of each other. python3 -u - "$@" <<'__PYTHON__' diff --git a/tests/flakyfunctional/xtriggers/00-wall_clock.t b/tests/flakyfunctional/xtriggers/00-wall_clock.t index 9e966f8cd52..49d9bbe20b8 100644 --- a/tests/flakyfunctional/xtriggers/00-wall_clock.t +++ b/tests/flakyfunctional/xtriggers/00-wall_clock.t @@ -18,6 +18,7 @@ # Test clock xtriggers . "$(dirname "$0")/test_header" +# shellcheck disable=SC2317 run_workflow() { cylc play --no-detach --debug "$1" \ -s "START='$2'" -s "HOUR='$3'" -s "OFFSET='$4'" diff --git a/tests/functional/lib/bash/test_header b/tests/functional/lib/bash/test_header index f9b58a35f75..ce0dd6165f7 100644 --- a/tests/functional/lib/bash/test_header +++ b/tests/functional/lib/bash/test_header @@ -1160,7 +1160,6 @@ for SKIP in ${CYLC_TEST_SKIP}; do # Deliberately print variable substitution syntax unexpanded # shellcheck disable=SC2016 skip_all 'this test is in $CYLC_TEST_SKIP.' - break fi done diff --git a/tests/functional/reload/17-graphing-change.t b/tests/functional/reload/17-graphing-change.t index 9df561384ff..41e0b4697c9 100755 --- a/tests/functional/reload/17-graphing-change.t +++ b/tests/functional/reload/17-graphing-change.t @@ -20,6 +20,7 @@ #------------------------------------------------------------------------------- set_test_number 12 +# shellcheck disable=SC2317 grep_workflow_log_n_times() { TEXT="$1" N_TIMES="$2" From 49af4060989de575586d662992cdd5c51351c5ca Mon Sep 17 00:00:00 2001 From: Oliver Sanders Date: Mon, 14 Oct 2024 16:43:26 +0100 Subject: [PATCH 179/196] pool: update DB after removing a task * Closes https://github.com/cylc/cylc-flow/issues/6315 * If the DB is not updated after a task is removed, then it can be respawned in its previous state as the result of upstream output completion. --- cylc/flow/task_pool.py | 5 +++ tests/integration/test_task_pool.py | 55 +++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/cylc/flow/task_pool.py b/cylc/flow/task_pool.py index 082745e43e9..ff75eb67d7d 100644 --- a/cylc/flow/task_pool.py +++ b/cylc/flow/task_pool.py @@ -870,6 +870,11 @@ def remove(self, itask, reason=None): msg += " - active job orphaned" LOG.log(level, f"[{itask}] {msg}") + + # ensure this task is written to the DB before moving on + # https://github.com/cylc/cylc-flow/issues/6315 + self.workflow_db_mgr.process_queued_ops() + del itask def get_tasks(self) -> List[TaskProxy]: diff --git a/tests/integration/test_task_pool.py b/tests/integration/test_task_pool.py index 1064edb874a..35ead99264f 100644 --- a/tests/integration/test_task_pool.py +++ b/tests/integration/test_task_pool.py @@ -2188,3 +2188,58 @@ async def test_expire_dequeue_with_retries(flow, scheduler, start, expire_type): # the task should also have been removed from the queue assert not schd.pool.task_queue_mgr.remove_task(itask) + + +async def test_downstream_complete_before_upstream( + flow, scheduler, start, db_select +): + """It should handle an upstream task completing before a downstream task. + + See https://github.com/cylc/cylc-flow/issues/6315 + """ + id_ = flow( + { + 'scheduling': { + 'graph': { + 'R1': 'a => b', + }, + }, + } + ) + schd = scheduler(id_) + async with start(schd): + # 1/a should be pre-spawned (parentless) + a_1 = schd.pool.get_task(IntegerPoint('1'), 'a') + assert a_1 + + # spawn 1/b (this can happens as the result of request e.g. trigger) + b_1 = schd.pool.spawn_task('b', IntegerPoint('1'), {1}) + schd.pool.add_to_pool(b_1) + assert b_1 + + # mark 1/b as succeeded + schd.task_events_mgr.process_message(b_1, 'INFO', 'succeeded') + + # 1/b should be removed from the pool (completed) + assert schd.pool.get_tasks() == [a_1] + + # as a side effect the DB should have been updated + assert ( + TASK_OUTPUT_SUCCEEDED + in db_select( + schd, + # "False" means "do not run the DB update before checking it" + False, # do not change this to "True" + 'task_outputs', + 'outputs', + name='b', + cycle='1', + )[0][0] + ) + + # mark 1/a as succeeded + schd.task_events_mgr.process_message(a_1, 'INFO', 'succeeded') + + # 1/a should be removed from the pool (completed) + # 1/b should not be re-spawned by the success of 1/a + assert schd.pool.get_tasks() == [] From e1741c5d27dc8e9e7290007035e19d4304c00105 Mon Sep 17 00:00:00 2001 From: Hilary James Oliver Date: Tue, 15 Oct 2024 22:11:49 +1300 Subject: [PATCH 180/196] Trigger and set: fix default flow assignment for n=0 tasks. (#6367) --- changes.d/6367.fix.md | 1 + cylc/flow/command_validation.py | 25 ++- cylc/flow/data_store_mgr.py | 2 +- cylc/flow/network/schema.py | 23 +-- cylc/flow/scripts/trigger.py | 24 ++- cylc/flow/task_pool.py | 117 ++++++------- tests/functional/cylc-set/04-switch/flow.cylc | 2 +- tests/functional/cylc-set/05-expire/flow.cylc | 2 +- .../triggering/08-fam-finish-any/flow.cylc | 15 +- tests/integration/test_data_store_mgr.py | 2 +- tests/integration/test_flow_assignment.py | 155 ++++++++++++++++++ tests/integration/test_task_pool.py | 12 +- tests/integration/test_trigger.py | 73 --------- tests/unit/test_command_validation.py | 41 +++++ 14 files changed, 317 insertions(+), 177 deletions(-) create mode 100644 changes.d/6367.fix.md create mode 100644 tests/integration/test_flow_assignment.py delete mode 100644 tests/integration/test_trigger.py create mode 100644 tests/unit/test_command_validation.py diff --git a/changes.d/6367.fix.md b/changes.d/6367.fix.md new file mode 100644 index 00000000000..44045a632a6 --- /dev/null +++ b/changes.d/6367.fix.md @@ -0,0 +1 @@ +Fix bug where `cylc trigger` and `cylc set` would assign active flows to existing tasks by default. diff --git a/cylc/flow/command_validation.py b/cylc/flow/command_validation.py index 34b7a0f1460..d87c0711a8d 100644 --- a/cylc/flow/command_validation.py +++ b/cylc/flow/command_validation.py @@ -30,7 +30,7 @@ ERR_OPT_FLOW_VAL = "Flow values must be an integer, or 'all', 'new', or 'none'" -ERR_OPT_FLOW_INT = "Multiple flow options must all be integer valued" +ERR_OPT_FLOW_COMBINE = "Cannot combine --flow={0} with other flow values" ERR_OPT_FLOW_WAIT = ( f"--wait is not compatible with --flow={FLOW_NEW} or --flow={FLOW_NONE}" ) @@ -39,10 +39,11 @@ def flow_opts(flows: List[str], flow_wait: bool) -> None: """Check validity of flow-related CLI options. - Note the schema defaults flows to ["all"]. + Note the schema defaults flows to []. Examples: Good: + >>> flow_opts([], False) >>> flow_opts(["new"], False) >>> flow_opts(["1", "2"], False) >>> flow_opts(["1", "2"], True) @@ -50,7 +51,8 @@ def flow_opts(flows: List[str], flow_wait: bool) -> None: Bad: >>> flow_opts(["none", "1"], False) Traceback (most recent call last): - cylc.flow.exceptions.InputError: ... must all be integer valued + cylc.flow.exceptions.InputError: Cannot combine --flow=none with other + flow values >>> flow_opts(["cheese", "2"], True) Traceback (most recent call last): @@ -58,21 +60,26 @@ def flow_opts(flows: List[str], flow_wait: bool) -> None: >>> flow_opts(["new"], True) Traceback (most recent call last): - cylc.flow.exceptions.InputError: ... + cylc.flow.exceptions.InputError: --wait is not compatible with + --flow=new or --flow=none """ + if not flows: + return + + flows = [val.strip() for val in flows] + for val in flows: - val = val.strip() - if val in [FLOW_NONE, FLOW_NEW, FLOW_ALL]: + if val in {FLOW_NONE, FLOW_NEW, FLOW_ALL}: if len(flows) != 1: - raise InputError(ERR_OPT_FLOW_INT) + raise InputError(ERR_OPT_FLOW_COMBINE.format(val)) else: try: int(val) except ValueError: - raise InputError(ERR_OPT_FLOW_VAL.format(val)) + raise InputError(ERR_OPT_FLOW_VAL) - if flow_wait and flows[0] in [FLOW_NEW, FLOW_NONE]: + if flow_wait and flows[0] in {FLOW_NEW, FLOW_NONE}: raise InputError(ERR_OPT_FLOW_WAIT) diff --git a/cylc/flow/data_store_mgr.py b/cylc/flow/data_store_mgr.py index 3b49050f0ff..8d65adfe305 100644 --- a/cylc/flow/data_store_mgr.py +++ b/cylc/flow/data_store_mgr.py @@ -1418,7 +1418,7 @@ def apply_task_proxy_db_history(self): itask, is_parent = self.db_load_task_proxies[relative_id] itask.submit_num = submit_num flow_nums = deserialise_set(flow_nums_str) - # Do not set states and outputs for future tasks in flow. + # Do not set states and outputs for inactive tasks in flow. if ( itask.flow_nums and flow_nums != itask.flow_nums and diff --git a/cylc/flow/network/schema.py b/cylc/flow/network/schema.py index 70e40232c1d..5fc277fb607 100644 --- a/cylc/flow/network/schema.py +++ b/cylc/flow/network/schema.py @@ -1998,17 +1998,20 @@ class Arguments: class FlowMutationArguments: flow = graphene.List( graphene.NonNull(Flow), - default_value=[FLOW_ALL], + default_value=[], description=sstrip(f''' - The flow(s) to trigger these tasks in. - - This should be a list of flow numbers OR a single-item list - containing one of the following three strings: - - * {FLOW_ALL} - Triggered tasks belong to all active flows - (default). - * {FLOW_NEW} - Triggered tasks are assigned to a new flow. - * {FLOW_NONE} - Triggered tasks do not belong to any flow. + The flow(s) to trigger/set these tasks in. + + By default: + * active tasks (n=0) keep their existing flow assignment + * inactive tasks (n>0) get assigned all active flows + + Otherwise you can assign (inactive tasks) or add to (active tasks): + * a list of integer flow numbers + or one of the following strings: + * {FLOW_ALL} - all active flows + * {FLOW_NEW} - an automatically generated new flow number + * {FLOW_NONE} - (ignored for active tasks): no flow ''') ) flow_wait = Boolean( diff --git a/cylc/flow/scripts/trigger.py b/cylc/flow/scripts/trigger.py index de788481cfe..1e6ef913696 100755 --- a/cylc/flow/scripts/trigger.py +++ b/cylc/flow/scripts/trigger.py @@ -17,19 +17,12 @@ """cylc trigger [OPTIONS] ARGS -Force tasks to run despite unsatisfied prerequisites. +Force tasks to run regardless of prerequisites. * Triggering an unqueued waiting task queues it, regardless of prerequisites. * Triggering a queued task submits it, regardless of queue limiting. * Triggering an active task has no effect (it already triggered). -Incomplete and active-waiting tasks in the n=0 window already belong to a flow. -Triggering them queues them to run (or rerun) in the same flow. - -Beyond n=0, triggered tasks get all current active flow numbers by default, or -specified flow numbers via the --flow option. Those flows - if/when they catch -up - will see tasks that ran after triggering event as having run already. - Examples: # trigger task foo in cycle 1234 in test $ cylc trigger test//1234/foo @@ -39,6 +32,21 @@ # start a new flow by triggering 1234/foo in test $ cylc trigger --flow=new test//1234/foo + +Flows: + Active tasks (in the n=0 window) already belong to a flow. + * by default, if triggered, they run in the same flow + * or with --flow=all, they are assigned all active flows + * or with --flow=INT or --flow=new, the original and new flows are merged + * (--flow=none is ignored for active tasks) + + Inactive tasks (n>0) do not already belong to a flow. + * by default they are assigned all active flows + * otherwise, they are assigned the --flow value + + Note --flow=new increments the global flow counter with each use. If it + takes multiple commands to start a new flow use the actual flow number + after the first command (you can read it from the scheduler log). """ from functools import partial diff --git a/cylc/flow/task_pool.py b/cylc/flow/task_pool.py index ff75eb67d7d..3ddf991cb9c 100644 --- a/cylc/flow/task_pool.py +++ b/cylc/flow/task_pool.py @@ -1290,17 +1290,17 @@ def set_hold_point(self, point: 'PointBase') -> None: def hold_tasks(self, items: Iterable[str]) -> int: """Hold tasks with IDs matching the specified items.""" # Hold active tasks: - itasks, future_tasks, unmatched = self.filter_task_proxies( + itasks, inactive_tasks, unmatched = self.filter_task_proxies( items, warn=False, future=True, ) for itask in itasks: self.hold_active_task(itask) - # Set future tasks to be held: - for name, cycle in future_tasks: + # Set inactive tasks to be held: + for name, cycle in inactive_tasks: self.data_store_mgr.delta_task_held((name, cycle, True)) - self.tasks_to_hold.update(future_tasks) + self.tasks_to_hold.update(inactive_tasks) self.workflow_db_mgr.put_tasks_to_hold(self.tasks_to_hold) LOG.debug(f"Tasks to hold: {self.tasks_to_hold}") return len(unmatched) @@ -1308,17 +1308,17 @@ def hold_tasks(self, items: Iterable[str]) -> int: def release_held_tasks(self, items: Iterable[str]) -> int: """Release held tasks with IDs matching any specified items.""" # Release active tasks: - itasks, future_tasks, unmatched = self.filter_task_proxies( + itasks, inactive_tasks, unmatched = self.filter_task_proxies( items, warn=False, future=True, ) for itask in itasks: self.release_held_active_task(itask) - # Unhold future tasks: - for name, cycle in future_tasks: + # Unhold inactive tasks: + for name, cycle in inactive_tasks: self.data_store_mgr.delta_task_held((name, cycle, False)) - self.tasks_to_hold.difference_update(future_tasks) + self.tasks_to_hold.difference_update(inactive_tasks) self.workflow_db_mgr.put_tasks_to_hold(self.tasks_to_hold) LOG.debug(f"Tasks to hold: {self.tasks_to_hold}") return len(unmatched) @@ -1895,7 +1895,7 @@ def set_prereqs_and_outputs( Task matching restrictions (for now): - globs (cycle and name) only match in the pool - - future tasks must be specified individually + - inactive tasks must be specified individually - family names are not expanded to members Uses a transient task proxy to spawn children. (Even if parent was @@ -1916,27 +1916,28 @@ def set_prereqs_and_outputs( flow_descr: description of new flow """ - flow_nums = self._get_flow_nums(flow, flow_descr) - if flow_nums is None: - # Illegal flow command opts - return - - # Get matching pool tasks and future task definitions. - itasks, future_tasks, unmatched = self.filter_task_proxies( + # Get matching pool tasks and inactive task definitions. + itasks, inactive_tasks, unmatched = self.filter_task_proxies( items, future=True, warn=False, ) + flow_nums = self._get_flow_nums(flow, flow_descr) + + # Set existing task proxies. for itask in itasks: - # Existing task proxies. self.merge_flows(itask, flow_nums) if prereqs: self._set_prereqs_itask(itask, prereqs, flow_nums) else: self._set_outputs_itask(itask, outputs) - for name, point in future_tasks: + # Spawn and set inactive tasks. + if not flow: + # default: assign to all active flows + flow_nums = self._get_active_flow_nums() + for name, point in inactive_tasks: tdef = self.config.get_taskdef(name) if prereqs: self._set_prereqs_tdef( @@ -2023,7 +2024,7 @@ def _set_prereqs_itask( def _set_prereqs_tdef( self, point, taskdef, prereqs, flow_nums, flow_wait ): - """Spawn a future task and set prerequisites on it.""" + """Spawn an inactive task and set prerequisites on it.""" itask = self.spawn_task( taskdef.name, point, flow_nums, flow_wait=flow_wait @@ -2073,38 +2074,30 @@ def remove_tasks(self, items): return len(bad_items) def _get_flow_nums( - self, - flow: List[str], - meta: Optional[str] = None, - ) -> Optional[Set[int]]: - """Get correct flow numbers given user command options.""" - if set(flow).intersection({FLOW_ALL, FLOW_NEW, FLOW_NONE}): - if len(flow) != 1: - LOG.warning( - f'The "flow" values {FLOW_ALL}, {FLOW_NEW} & {FLOW_NONE}' - ' cannot be used in combination with integer flow numbers.' - ) - return None - if flow[0] == FLOW_ALL: - flow_nums = self._get_active_flow_nums() - elif flow[0] == FLOW_NEW: - flow_nums = {self.flow_mgr.get_flow_num(meta=meta)} - elif flow[0] == FLOW_NONE: - flow_nums = set() - else: - try: - flow_nums = { - self.flow_mgr.get_flow_num( - flow_num=int(n), meta=meta - ) - for n in flow - } - except ValueError: - LOG.warning( - f"Ignoring command: illegal flow values {flow}" - ) - return None - return flow_nums + self, + flow: List[str], + meta: Optional[str] = None, + ) -> Set[int]: + """Return flow numbers corresponding to user command options. + + Arg should have been validated already during command validation. + + In the default case (--flow option not provided), stick with the + existing flows (so return empty set) - NOTE this only applies for + active tasks. + + """ + if flow == [FLOW_NONE]: + return set() + if flow == [FLOW_ALL]: + return self._get_active_flow_nums() + if flow == [FLOW_NEW]: + return {self.flow_mgr.get_flow_num(meta=meta)} + # else specific flow numbers: + return { + self.flow_mgr.get_flow_num(flow_num=int(n), meta=meta) + for n in flow + } def _force_trigger(self, itask): """Assumes task is in the pool""" @@ -2167,17 +2160,14 @@ def force_trigger_tasks( unless flow-wait is set. """ - # Get flow numbers for the tasks to be triggered. - flow_nums = self._get_flow_nums(flow, flow_descr) - if flow_nums is None: - return - - # Get matching tasks proxies, and matching future task IDs. + # Get matching tasks proxies, and matching inactive task IDs. existing_tasks, future_ids, unmatched = self.filter_task_proxies( items, future=True, warn=False, ) - # Trigger existing tasks. + flow_nums = self._get_flow_nums(flow, flow_descr) + + # Trigger active tasks. for itask in existing_tasks: if itask.state(TASK_STATUS_PREPARING, *TASK_STATUSES_ACTIVE): LOG.warning(f"[{itask}] ignoring trigger - already active") @@ -2185,12 +2175,13 @@ def force_trigger_tasks( self.merge_flows(itask, flow_nums) self._force_trigger(itask) - # Spawn and trigger future tasks. + # Spawn and trigger inactive tasks. + if not flow: + # default: assign to all active flows + flow_nums = self._get_active_flow_nums() for name, point in future_ids: - if not self.can_be_spawned(name, point): continue - submit_num, _, prev_fwait = ( self._get_task_history(name, point, flow_nums) ) @@ -2331,13 +2322,13 @@ def filter_task_proxies( ) future_matched: 'Set[Tuple[str, PointBase]]' = set() if future and unmatched: - future_matched, unmatched = self.match_future_tasks( + future_matched, unmatched = self.match_inactive_tasks( unmatched ) return matched, future_matched, unmatched - def match_future_tasks( + def match_inactive_tasks( self, ids: Iterable[str], ) -> Tuple[Set[Tuple[str, 'PointBase']], List[str]]: diff --git a/tests/functional/cylc-set/04-switch/flow.cylc b/tests/functional/cylc-set/04-switch/flow.cylc index 18402c7b64c..8f7c4329af6 100644 --- a/tests/functional/cylc-set/04-switch/flow.cylc +++ b/tests/functional/cylc-set/04-switch/flow.cylc @@ -1,4 +1,4 @@ -# Set outputs of future task to direct the flow at an optional branch point. +# Set outputs of inactive task to direct the flow at an optional branch point. [scheduler] [[events]] diff --git a/tests/functional/cylc-set/05-expire/flow.cylc b/tests/functional/cylc-set/05-expire/flow.cylc index 9717664132f..4e5ca9f0608 100644 --- a/tests/functional/cylc-set/05-expire/flow.cylc +++ b/tests/functional/cylc-set/05-expire/flow.cylc @@ -1,4 +1,4 @@ -# Expire a future task, so it won't run. +# Expire an inactive task, so it won't run. [scheduler] [[events]] diff --git a/tests/functional/triggering/08-fam-finish-any/flow.cylc b/tests/functional/triggering/08-fam-finish-any/flow.cylc index 6d8790a829f..6ecb0bf9781 100644 --- a/tests/functional/triggering/08-fam-finish-any/flow.cylc +++ b/tests/functional/triggering/08-fam-finish-any/flow.cylc @@ -2,12 +2,19 @@ [[graph]] R1 = """FAM:finish-any => foo""" [runtime] + [[root]] + script = true [[FAM]] - script = sleep 10 - [[a,c]] + [[a]] inherit = FAM + script = """ + cylc__job__poll_grep_workflow_log -E "1/b.*succeeded" + """ [[b]] inherit = FAM - script = true + [[c]] + inherit = FAM + script = """ + cylc__job__poll_grep_workflow_log -E "1/b.*succeeded" + """ [[foo]] - script = true diff --git a/tests/integration/test_data_store_mgr.py b/tests/integration/test_data_store_mgr.py index 906b1ac052d..f50333b6944 100644 --- a/tests/integration/test_data_store_mgr.py +++ b/tests/integration/test_data_store_mgr.py @@ -301,7 +301,7 @@ def test_delta_task_prerequisite(harness): [t.identity for t in schd.pool.get_tasks()], [(TASK_STATUS_SUCCEEDED,)], [], - "all" + flow=[] ) assert all({ p.satisfied diff --git a/tests/integration/test_flow_assignment.py b/tests/integration/test_flow_assignment.py new file mode 100644 index 00000000000..5816b08527f --- /dev/null +++ b/tests/integration/test_flow_assignment.py @@ -0,0 +1,155 @@ +# THIS FILE IS PART OF THE CYLC WORKFLOW ENGINE. +# Copyright (C) NIWA & British Crown (Met Office) & Contributors. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Test for flow-assignment in triggered/set tasks.""" + +import functools +import time +from typing import Callable + +import pytest + +from cylc.flow.flow_mgr import FLOW_ALL, FLOW_NEW, FLOW_NONE +from cylc.flow.scheduler import Scheduler + + +async def test_trigger_no_flows(one, start): + """Test triggering a task with no flows present. + + It should get the flow numbers of the most recent active tasks. + """ + async with start(one): + + # Remove the task (flow 1) --> pool empty + task = one.pool.get_tasks()[0] + one.pool.remove(task) + assert len(one.pool.get_tasks()) == 0 + + # Trigger the task, with new flow nums. + time.sleep(2) # The flows need different timestamps! + one.pool.force_trigger_tasks([task.identity], flow=['5', '9']) + assert len(one.pool.get_tasks()) == 1 + + # Ensure the new flow is in the db. + one.pool.workflow_db_mgr.process_queued_ops() + + # Remove the task --> pool empty + task = one.pool.get_tasks()[0] + one.pool.remove(task) + assert len(one.pool.get_tasks()) == 0 + + # Trigger the task; it should get flow nums 5, 9 + one.pool.force_trigger_tasks([task.identity], [FLOW_ALL]) + assert len(one.pool.get_tasks()) == 1 + task = one.pool.get_tasks()[0] + assert task.flow_nums == {5, 9} + + +async def test_get_flow_nums(one: Scheduler, start): + """Test the task pool _get_flow_nums() method.""" + async with start(one): + # flow 1 is already present + task = one.pool.get_tasks()[0] + assert one.pool._get_flow_nums([FLOW_NEW]) == {2} + one.pool.merge_flows(task, {2}) + # now we have flows {1, 2}: + + assert one.pool._get_flow_nums([FLOW_NONE]) == set() + assert one.pool._get_flow_nums([FLOW_ALL]) == {1, 2} + assert one.pool._get_flow_nums([FLOW_NEW]) == {3} + assert one.pool._get_flow_nums(['4', '5']) == {4, 5} + # the only active task still only has flows {1, 2} + assert one.pool._get_flow_nums([FLOW_ALL]) == {1, 2} + + +@pytest.mark.parametrize('command', ['trigger', 'set']) +async def test_flow_assignment(flow, scheduler, start, command: str): + """Test flow assignment when triggering/setting tasks. + + Active tasks: + By default keep existing flows, else merge with requested flows. + Inactive tasks: + By default assign active flows; else assign requested flows. + + """ + conf = { + 'scheduler': { + 'allow implicit tasks': 'True' + }, + 'scheduling': { + 'graph': { + 'R1': "foo & bar => a & b & c & d & e" + } + }, + 'runtime': { + 'foo': { + 'outputs': {'x': 'x'} + } + }, + } + id_ = flow(conf) + schd: Scheduler = scheduler(id_, run_mode='simulation', paused_start=True) + async with start(schd): + if command == 'set': + do_command: Callable = functools.partial( + schd.pool.set_prereqs_and_outputs, outputs=['x'], prereqs=[] + ) + else: + do_command = schd.pool.force_trigger_tasks + + active_a, active_b = schd.pool.get_tasks() + schd.pool.merge_flows(active_b, schd.pool._get_flow_nums([FLOW_NEW])) + assert active_a.flow_nums == {1} + assert active_b.flow_nums == {1, 2} + + # -----(1. Test active tasks)----- + + # By default active tasks keep existing flow assignment. + do_command([active_a.identity], flow=[]) + assert active_a.flow_nums == {1} + + # Else merge existing flow with requested flows. + do_command([active_a.identity], flow=[FLOW_ALL]) + assert active_a.flow_nums == {1, 2} + + # (no-flow is ignored for active tasks) + do_command([active_a.identity], flow=[FLOW_NONE]) + assert active_a.flow_nums == {1, 2} + + do_command([active_a.identity], flow=[FLOW_NEW]) + assert active_a.flow_nums == {1, 2, 3} + + # -----(2. Test inactive tasks)----- + if command == 'set': + do_command = functools.partial( + schd.pool.set_prereqs_and_outputs, outputs=[], prereqs=['all'] + ) + + # By default inactive tasks get all active flows. + do_command(['1/a'], flow=[]) + assert schd.pool._get_task_by_id('1/a').flow_nums == {1, 2, 3} + + # Else assign requested flows. + do_command(['1/b'], flow=[FLOW_NONE]) + assert schd.pool._get_task_by_id('1/b').flow_nums == set() + + do_command(['1/c'], flow=[FLOW_NEW]) + assert schd.pool._get_task_by_id('1/c').flow_nums == {4} + + do_command(['1/d'], flow=[FLOW_ALL]) + assert schd.pool._get_task_by_id('1/d').flow_nums == {1, 2, 3, 4} + do_command(['1/e'], flow=[7]) + assert schd.pool._get_task_by_id('1/e').flow_nums == {7} diff --git a/tests/integration/test_task_pool.py b/tests/integration/test_task_pool.py index 35ead99264f..c8ac305c09a 100644 --- a/tests/integration/test_task_pool.py +++ b/tests/integration/test_task_pool.py @@ -342,7 +342,7 @@ async def test_match_taskdefs( [ param( ['1/foo', '3/asd'], ['1/foo', '3/asd'], [], - id="Active & future tasks" + id="Active & inactive tasks" ), param( ['1/*', '2/*', '3/*', '6/*'], @@ -367,7 +367,7 @@ async def test_match_taskdefs( ['1/foo:waiting', '1/foo:failed', '6/bar:waiting'], ['1/foo'], ["No active tasks matching: 1/foo:failed", "No active tasks matching: 6/bar:waiting"], - id="Specifying task state works for active tasks, not future tasks" + id="Specifying task state works for active tasks, not inactive tasks" ) ] ) @@ -412,7 +412,7 @@ async def test_release_held_tasks( ) -> None: """Test TaskPool.release_held_tasks(). - For a workflow with held active tasks 1/foo & 1/bar, and held future task + For a workflow with held active tasks 1/foo & 1/bar, and held inactive task 3/asd. We skip testing the matching logic here because it would be slow using the @@ -1347,7 +1347,7 @@ async def test_set_prereqs( "20400101T0000Z/foo"] ) - # set one prereq of future task 20400101T0000Z/qux + # set one prereq of inactive task 20400101T0000Z/qux schd.pool.set_prereqs_and_outputs( ["20400101T0000Z/qux"], None, @@ -1526,7 +1526,7 @@ async def test_set_outputs_future( start, log_filter, ): - """Check manual setting of future task outputs. + """Check manual setting of inactive task outputs. """ id_ = flow( @@ -1556,7 +1556,7 @@ async def test_set_outputs_future( # it should start up with just 1/a assert pool_get_task_ids(schd.pool) == ["1/a"] - # setting future task b succeeded should spawn c but not b + # setting inactive task b succeeded should spawn c but not b schd.pool.set_prereqs_and_outputs( ["1/b"], ["succeeded"], None, ['all']) assert ( diff --git a/tests/integration/test_trigger.py b/tests/integration/test_trigger.py deleted file mode 100644 index 30ae3404ed8..00000000000 --- a/tests/integration/test_trigger.py +++ /dev/null @@ -1,73 +0,0 @@ -# THIS FILE IS PART OF THE CYLC WORKFLOW ENGINE. -# Copyright (C) NIWA & British Crown (Met Office) & Contributors. -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -import logging - -from cylc.flow.flow_mgr import FLOW_ALL, FLOW_NEW, FLOW_NONE - -import pytest -import time - - -@pytest.mark.parametrize( - 'flow_strs', - ( - [FLOW_ALL, '1'], - ['1', FLOW_ALL], - [FLOW_NEW, '1'], - [FLOW_NONE, '1'], - ['a'], - ['1', 'a'], - ) -) -async def test_trigger_invalid(mod_one, start, log_filter, flow_strs): - """Ensure invalid flow values are rejected.""" - async with start(mod_one) as log: - log.clear() - assert mod_one.pool.force_trigger_tasks(['*'], flow_strs) is None - assert len(log_filter(log, level=logging.WARN)) == 1 - - -async def test_trigger_no_flows(one, start, log_filter): - """Test triggering a task with no flows present. - - It should get the flow numbers of the most recent active tasks. - """ - async with start(one): - - # Remove the task (flow 1) --> pool empty - task = one.pool.get_tasks()[0] - one.pool.remove(task) - assert len(one.pool.get_tasks()) == 0 - - # Trigger the task, with new flow nums. - time.sleep(2) # The flows need different timestamps! - one.pool.force_trigger_tasks([task.identity], [5, 9]) - assert len(one.pool.get_tasks()) == 1 - - # Ensure the new flow is in the db. - one.pool.workflow_db_mgr.process_queued_ops() - - # Remove the task --> pool empty - task = one.pool.get_tasks()[0] - one.pool.remove(task) - assert len(one.pool.get_tasks()) == 0 - - # Trigger the task; it should get flow nums 5, 9 - one.pool.force_trigger_tasks([task.identity], [FLOW_ALL]) - assert len(one.pool.get_tasks()) == 1 - task = one.pool.get_tasks()[0] - assert task.flow_nums == {5, 9} diff --git a/tests/unit/test_command_validation.py b/tests/unit/test_command_validation.py new file mode 100644 index 00000000000..42fdda5aedf --- /dev/null +++ b/tests/unit/test_command_validation.py @@ -0,0 +1,41 @@ +# THIS FILE IS PART OF THE CYLC WORKFLOW ENGINE. +# Copyright (C) NIWA & British Crown (Met Office) & Contributors. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import pytest + +from cylc.flow.command_validation import ( + ERR_OPT_FLOW_COMBINE, + ERR_OPT_FLOW_VAL, + flow_opts, +) +from cylc.flow.exceptions import InputError +from cylc.flow.flow_mgr import FLOW_ALL, FLOW_NEW, FLOW_NONE + + +@pytest.mark.parametrize('flow_strs, expected_msg', [ + ([FLOW_ALL, '1'], ERR_OPT_FLOW_COMBINE.format(FLOW_ALL)), + (['1', FLOW_ALL], ERR_OPT_FLOW_COMBINE.format(FLOW_ALL)), + ([FLOW_NEW, '1'], ERR_OPT_FLOW_COMBINE.format(FLOW_NEW)), + ([FLOW_NONE, '1'], ERR_OPT_FLOW_COMBINE.format(FLOW_NONE)), + ([FLOW_NONE, FLOW_ALL], ERR_OPT_FLOW_COMBINE.format(FLOW_NONE)), + (['a'], ERR_OPT_FLOW_VAL), + (['1', 'a'], ERR_OPT_FLOW_VAL), +]) +async def test_trigger_invalid(flow_strs, expected_msg): + """Ensure invalid flow values are rejected during command validation.""" + with pytest.raises(InputError) as exc_info: + flow_opts(flow_strs, False) + assert str(exc_info.value) == expected_msg From 55fb3457be96cb6ae5a90c4a1d6c85eabf785978 Mon Sep 17 00:00:00 2001 From: Oliver Sanders Date: Tue, 15 Oct 2024 12:40:28 +0100 Subject: [PATCH 181/196] tui: sort workflows in natural order --- cylc/flow/tui/updater.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cylc/flow/tui/updater.py b/cylc/flow/tui/updater.py index cc52cff89f6..84ad7e786bd 100644 --- a/cylc/flow/tui/updater.py +++ b/cylc/flow/tui/updater.py @@ -51,6 +51,7 @@ QUERY ) from cylc.flow.tui.util import ( + NaturalSort, compute_tree, suppress_logging, ) @@ -363,5 +364,5 @@ async def _scan(self): 'stateTotals': {}, }) - data['workflows'].sort(key=lambda x: x['id']) + data['workflows'].sort(key=lambda x: NaturalSort(x['id'])) return data From 5c2db0d243e3068c2003493d78e2c44a63795b2a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 15 Oct 2024 14:45:55 +0000 Subject: [PATCH 182/196] Prepare release 8.3.5 Workflow: Release stage 1 - create release PR (Cylc 8+ only), run: 44 --- CHANGES.md | 12 ++++++++++++ changes.d/6316.fix.md | 1 - changes.d/6362.fix.md | 1 - changes.d/6367.fix.md | 1 - changes.d/6397.fix.md | 1 - cylc/flow/__init__.py | 2 +- 6 files changed, 13 insertions(+), 5 deletions(-) delete mode 100644 changes.d/6316.fix.md delete mode 100644 changes.d/6362.fix.md delete mode 100644 changes.d/6367.fix.md delete mode 100644 changes.d/6397.fix.md diff --git a/CHANGES.md b/CHANGES.md index 18911f06b28..4e43277f95a 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -11,6 +11,18 @@ $ towncrier create ..md --content "Short description" +## __cylc-8.3.5 (Released 2024-10-15)__ + +### 🔧 Fixes + +[#6316](https://github.com/cylc/cylc-flow/pull/6316) - Fixed bug in `cylc vr` where an initial cycle point of `now`/`next()`/`previous()` would result in an error. + +[#6362](https://github.com/cylc/cylc-flow/pull/6362) - Fixed simulation mode bug where the task submit number would not increment + +[#6367](https://github.com/cylc/cylc-flow/pull/6367) - Fix bug where `cylc trigger` and `cylc set` would assign active flows to existing tasks by default. + +[#6397](https://github.com/cylc/cylc-flow/pull/6397) - Fix "dictionary changed size during iteration error" which could occur with broadcasts. + ## __cylc-8.3.4 (Released 2024-09-12)__ ### 🚀 Enhancements diff --git a/changes.d/6316.fix.md b/changes.d/6316.fix.md deleted file mode 100644 index 4f6346f8e96..00000000000 --- a/changes.d/6316.fix.md +++ /dev/null @@ -1 +0,0 @@ -Fixed bug in `cylc vr` where an initial cycle point of `now`/`next()`/`previous()` would result in an error. diff --git a/changes.d/6362.fix.md b/changes.d/6362.fix.md deleted file mode 100644 index d469ede6b95..00000000000 --- a/changes.d/6362.fix.md +++ /dev/null @@ -1 +0,0 @@ -Fixed simulation mode bug where the task submit number would not increment diff --git a/changes.d/6367.fix.md b/changes.d/6367.fix.md deleted file mode 100644 index 44045a632a6..00000000000 --- a/changes.d/6367.fix.md +++ /dev/null @@ -1 +0,0 @@ -Fix bug where `cylc trigger` and `cylc set` would assign active flows to existing tasks by default. diff --git a/changes.d/6397.fix.md b/changes.d/6397.fix.md deleted file mode 100644 index 589c29dbb11..00000000000 --- a/changes.d/6397.fix.md +++ /dev/null @@ -1 +0,0 @@ -Fix "dictionary changed size during iteration error" which could occur with broadcasts. diff --git a/cylc/flow/__init__.py b/cylc/flow/__init__.py index d8671066b08..ba9a559b4de 100644 --- a/cylc/flow/__init__.py +++ b/cylc/flow/__init__.py @@ -53,7 +53,7 @@ def environ_init(): environ_init() -__version__ = '8.3.5.dev' +__version__ = '8.3.5' def iter_entry_points(entry_point_name): From a07f1607846b7886446acac87aa2db105cf484f3 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 15 Oct 2024 16:31:56 +0100 Subject: [PATCH 183/196] Bump dev version (#6418) --- cylc/flow/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cylc/flow/__init__.py b/cylc/flow/__init__.py index ba9a559b4de..ec2562ddaee 100644 --- a/cylc/flow/__init__.py +++ b/cylc/flow/__init__.py @@ -53,7 +53,7 @@ def environ_init(): environ_init() -__version__ = '8.3.5' +__version__ = '8.3.6.dev' def iter_entry_points(entry_point_name): From 768bfc4d04e4772047fc68eb00f0b70927e831a3 Mon Sep 17 00:00:00 2001 From: Ronnie Dutta <61982285+MetRonnie@users.noreply.github.com> Date: Tue, 15 Oct 2024 17:13:38 +0100 Subject: [PATCH 184/196] Fix unclosed color tag (#6415) --- cylc/flow/scripts/show.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/cylc/flow/scripts/show.py b/cylc/flow/scripts/show.py index 9713ed78fac..a17372746f3 100755 --- a/cylc/flow/scripts/show.py +++ b/cylc/flow/scripts/show.py @@ -383,8 +383,10 @@ async def prereqs_and_outputs_query( if not task_proxies: ansiprint( - f"No matching active tasks found: {', '.join(ids_list)}", - file=sys.stderr) + "No matching active tasks found: " + f"{', '.join(ids_list)}", + file=sys.stderr, + ) return 1 return 0 From 9b4bac38ffff958eaa437f5bcede432d71506a97 Mon Sep 17 00:00:00 2001 From: Oliver Sanders Date: Wed, 16 Oct 2024 12:28:18 +0100 Subject: [PATCH 185/196] broadcast: reject truncated cycle points (#6414) --- changes.d/6414.fix.md | 1 + cylc/flow/cycling/__init__.py | 11 +++++-- cylc/flow/cycling/integer.py | 2 +- cylc/flow/cycling/iso8601.py | 9 +++++- cylc/flow/cycling/loader.py | 4 +-- tests/integration/scripts/test_broadcast.py | 35 +++++++++++++++++++++ 6 files changed, 56 insertions(+), 6 deletions(-) create mode 100644 changes.d/6414.fix.md diff --git a/changes.d/6414.fix.md b/changes.d/6414.fix.md new file mode 100644 index 00000000000..cecc178dd07 --- /dev/null +++ b/changes.d/6414.fix.md @@ -0,0 +1 @@ +Broadcast will now reject truncated cycle points to aviod runtime errors. diff --git a/cylc/flow/cycling/__init__.py b/cylc/flow/cycling/__init__.py index c47d242b5c5..7c644263189 100644 --- a/cylc/flow/cycling/__init__.py +++ b/cylc/flow/cycling/__init__.py @@ -91,8 +91,15 @@ def _cmp(self, other) -> int: """Compare self to other point, returning a 'cmp'-like result.""" pass - def standardise(self) -> 'PointBase': - """Format self.value into a standard representation and check it.""" + def standardise(self, allow_truncated: bool = True) -> 'PointBase': + """Format self.value into a standard representation and check it. + + Args: + allow_truncated: + If True, then truncated points (i.e. any point with context + missing off the front) will be tollerated, if False, truncated + points will cause an exception to be raised. + """ return self @abstractmethod diff --git a/cylc/flow/cycling/integer.py b/cylc/flow/cycling/integer.py index 749c651fc08..c963804a7d5 100644 --- a/cylc/flow/cycling/integer.py +++ b/cylc/flow/cycling/integer.py @@ -145,7 +145,7 @@ def sub(self, other): return IntegerInterval.from_integer(int(self) - int(other)) return IntegerPoint(int(self) - int(other)) - def standardise(self): + def standardise(self, allow_truncated=True): """Format self.value into a standard representation and check it.""" try: self.value = str(int(self)) diff --git a/cylc/flow/cycling/iso8601.py b/cylc/flow/cycling/iso8601.py index a66ce3f5ba0..3d7cf42bc3e 100644 --- a/cylc/flow/cycling/iso8601.py +++ b/cylc/flow/cycling/iso8601.py @@ -92,9 +92,16 @@ def add(self, other): self.value, other.value, CALENDAR.mode )) - def standardise(self): + def standardise(self, allow_truncated=True): """Reformat self.value into a standard representation.""" try: + point = point_parse(self.value) + if not allow_truncated and point.truncated: + raise PointParsingError( + type(self), + self.value, + 'Truncated ISO8601 dates are not permitted', + ) self.value = str(point_parse(self.value)) except IsodatetimeError as exc: if self.value.startswith("+") or self.value.startswith("-"): diff --git a/cylc/flow/cycling/loader.py b/cylc/flow/cycling/loader.py index 71c175eb97a..11b86f0819c 100644 --- a/cylc/flow/cycling/loader.py +++ b/cylc/flow/cycling/loader.py @@ -158,13 +158,13 @@ def standardise_point_string( def standardise_point_string( - point_string: Optional[str], cycling_type: Optional[str] = None + point_string: Optional[str], cycling_type: Optional[str] = None, ) -> Optional[str]: """Return a standardised version of point_string.""" if point_string is None: return None point = get_point(point_string, cycling_type=cycling_type) if point is not None: - point.standardise() + point.standardise(allow_truncated=False) point_string = str(point) return point_string diff --git a/tests/integration/scripts/test_broadcast.py b/tests/integration/scripts/test_broadcast.py index 163d48e552e..6d3b4cd0db8 100644 --- a/tests/integration/scripts/test_broadcast.py +++ b/tests/integration/scripts/test_broadcast.py @@ -134,3 +134,38 @@ async def test_broadcast_multi_namespace( ('*', 'VOWELS', 'execution time limit', 'PT5S'), ('*', 'root', 'execution time limit', 'PT5S'), ] + + +async def test_broadcast_truncated_datetime(flow, scheduler, start, capsys): + """It should reject truncated datetime cycle points. + + See https://github.com/cylc/cylc-flow/issues/6407 + """ + id_ = flow({ + 'scheduling': { + 'initial cycle point': '2000', + 'graph': { + 'R1': 'foo', + }, + } + }) + schd = scheduler(id_) + async with start(schd): + # attempt an invalid broadcast + rets = await _main( + BroadcastOptions( + settings=['[environment]FOO=bar'], + point_strings=['050101T0000Z'], # <== truncated + ), + schd.workflow, + ) + + # the broadcast should fail + assert list(rets.values()) == [False] + + # an error should be recorded + _out, err = capsys.readouterr() + assert ( + 'Rejected broadcast:' + ' settings are not compatible with the workflow' + ) in err From c68b22372a2e35a0874b39fbe1b7157f9a2553ef Mon Sep 17 00:00:00 2001 From: Ronnie Dutta <61982285+MetRonnie@users.noreply.github.com> Date: Tue, 10 Sep 2024 18:15:56 +0100 Subject: [PATCH 186/196] Tidy & add unit test --- cylc/flow/clean.py | 5 +- cylc/flow/pathutil.py | 3 +- tests/unit/filetree.py | 222 ++++++++++++++++++++++++------------ tests/unit/test_clean.py | 41 ++++--- tests/unit/test_pathutil.py | 16 +++ 5 files changed, 195 insertions(+), 92 deletions(-) diff --git a/cylc/flow/clean.py b/cylc/flow/clean.py index b38f01b12fc..f73b0364766 100644 --- a/cylc/flow/clean.py +++ b/cylc/flow/clean.py @@ -259,7 +259,7 @@ def glob_in_run_dir( """Execute a (recursive) glob search in the given run directory. Returns list of any absolute paths that match the pattern. However: - * Does not follow symlinks (apart from the spcedified symlink dirs). + * Does not follow symlinks (apart from the specified symlink dirs). * Also does not return matching subpaths of matching directories (because that would be redundant). @@ -281,6 +281,9 @@ def glob_in_run_dir( results: List[Path] = [] subpath_excludes: Set[Path] = set() for path in matches: + # Iterate down through ancestors (starting at the run dir) to + # weed out redundant subpaths of matched directories and subpaths of + # non-standard symlinks for rel_ancestor in reversed(path.relative_to(run_dir).parents): ancestor = run_dir / rel_ancestor if ancestor in subpath_excludes: diff --git a/cylc/flow/pathutil.py b/cylc/flow/pathutil.py index aa097a2e680..99f4c4e518f 100644 --- a/cylc/flow/pathutil.py +++ b/cylc/flow/pathutil.py @@ -481,7 +481,8 @@ def parse_rm_dirs(rm_dirs: Iterable[str]) -> Set[str]: def is_relative_to(path1: Union[Path, str], path2: Union[Path, str]) -> bool: - """Return whether or not path1 is relative to path2. + """Return whether or not path1 is relative to path2 (including if they are + the same path). Normalizes both paths to avoid trickery with paths containing `..` somewhere in them. diff --git a/tests/unit/filetree.py b/tests/unit/filetree.py index bef09e4e556..d1830b51cd3 100644 --- a/tests/unit/filetree.py +++ b/tests/unit/filetree.py @@ -27,16 +27,21 @@ 'file.txt': None } }, - # Symlinks are represented by pathlib.Path, with the target represented - # by the relative path from the tmp_path directory: - 'symlink': Path('dir/another-dir') + # Symlinks are represented by the Symlink class, with the target + # represented by the relative path from the tmp_path directory: + 'symlink': Symlink('dir/another-dir') } """ -from pathlib import Path +from pathlib import Path, PosixPath from typing import Any, Dict, List +class Symlink(PosixPath): + """A class to represent a symlink target.""" + ... + + def create_filetree( filetree: Dict[str, Any], location: Path, root: Path ) -> None: @@ -53,10 +58,9 @@ def create_filetree( if isinstance(entry, dict): path.mkdir(exist_ok=True) create_filetree(entry, path, root) - elif isinstance(entry, Path): + elif isinstance(entry, Symlink): path.symlink_to(root / entry) else: - path.touch() @@ -79,89 +83,155 @@ def get_filetree_as_list( FILETREE_1 = { - 'cylc-run': {'foo': {'bar': { - '.service': {'db': None}, - 'flow.cylc': None, - 'log': Path('sym/cylc-run/foo/bar/log'), - 'mirkwood': Path('you-shall-not-pass/mirkwood'), - 'rincewind.txt': Path('you-shall-not-pass/rincewind.txt') - }}}, - 'sym': {'cylc-run': {'foo': {'bar': { - 'log': { - 'darmok': Path('you-shall-not-pass/darmok'), - 'temba.txt': Path('you-shall-not-pass/temba.txt'), - 'bib': { - 'fortuna.txt': None - } - } - }}}}, + 'cylc-run': { + 'foo': { + 'bar': { + '.service': { + 'db': None, + }, + 'flow.cylc': None, + 'log': Symlink('sym/cylc-run/foo/bar/log'), + 'mirkwood': Symlink('you-shall-not-pass/mirkwood'), + 'rincewind.txt': Symlink('you-shall-not-pass/rincewind.txt'), + }, + }, + }, + 'sym': { + 'cylc-run': { + 'foo': { + 'bar': { + 'log': { + 'darmok': Symlink('you-shall-not-pass/darmok'), + 'temba.txt': Symlink('you-shall-not-pass/temba.txt'), + 'bib': { + 'fortuna.txt': None, + }, + }, + }, + }, + }, + }, 'you-shall-not-pass': { # Nothing in here should get deleted 'darmok': { - 'jalad.txt': None + 'jalad.txt': None, }, 'mirkwood': { - 'spiders.txt': None + 'spiders.txt': None, }, 'rincewind.txt': None, - 'temba.txt': None - } + 'temba.txt': None, + }, } FILETREE_2 = { - 'cylc-run': {'foo': {'bar': Path('sym-run/cylc-run/foo/bar')}}, - 'sym-run': {'cylc-run': {'foo': {'bar': { - '.service': {'db': None}, - 'flow.cylc': None, - 'share': Path('sym-share/cylc-run/foo/bar/share') - }}}}, - 'sym-share': {'cylc-run': {'foo': {'bar': { - 'share': { - 'cycle': Path('sym-cycle/cylc-run/foo/bar/share/cycle') - } - }}}}, - 'sym-cycle': {'cylc-run': {'foo': {'bar': { - 'share': { - 'cycle': { - 'macklunkey.txt': None - } - } - }}}}, - 'you-shall-not-pass': {} + 'cylc-run': {'foo': {'bar': Symlink('sym-run/cylc-run/foo/bar')}}, + 'sym-run': { + 'cylc-run': { + 'foo': { + 'bar': { + '.service': { + 'db': None, + }, + 'flow.cylc': None, + 'share': Symlink('sym-share/cylc-run/foo/bar/share'), + }, + }, + }, + }, + 'sym-share': { + 'cylc-run': { + 'foo': { + 'bar': { + 'share': { + 'cycle': Symlink( + 'sym-cycle/cylc-run/foo/bar/share/cycle' + ), + }, + }, + }, + }, + }, + 'sym-cycle': { + 'cylc-run': { + 'foo': { + 'bar': { + 'share': { + 'cycle': { + 'macklunkey.txt': None, + }, + }, + }, + }, + }, + }, + 'you-shall-not-pass': {}, } FILETREE_3 = { - 'cylc-run': {'foo': {'bar': Path('sym-run/cylc-run/foo/bar')}}, - 'sym-run': {'cylc-run': {'foo': {'bar': { - '.service': {'db': None}, - 'flow.cylc': None, - 'share': { - 'cycle': Path('sym-cycle/cylc-run/foo/bar/share/cycle') - } - }}}}, - 'sym-cycle': {'cylc-run': {'foo': {'bar': { - 'share': { - 'cycle': { - 'sokath.txt': None - } - } - }}}}, - 'you-shall-not-pass': {} + 'cylc-run': { + 'foo': { + 'bar': Symlink('sym-run/cylc-run/foo/bar'), + }, + }, + 'sym-run': { + 'cylc-run': { + 'foo': { + 'bar': { + '.service': { + 'db': None, + }, + 'flow.cylc': None, + 'share': { + 'cycle': Symlink( + 'sym-cycle/cylc-run/foo/bar/share/cycle' + ), + }, + }, + }, + }, + }, + 'sym-cycle': { + 'cylc-run': { + 'foo': { + 'bar': { + 'share': { + 'cycle': { + 'sokath.txt': None, + }, + }, + }, + }, + }, + }, + 'you-shall-not-pass': {}, } FILETREE_4 = { - 'cylc-run': {'foo': {'bar': { - '.service': {'db': None}, - 'flow.cylc': None, - 'share': { - 'cycle': Path('sym-cycle/cylc-run/foo/bar/share/cycle') - } - }}}, - 'sym-cycle': {'cylc-run': {'foo': {'bar': { - 'share': { - 'cycle': { - 'kiazi.txt': None - } - } - }}}}, - 'you-shall-not-pass': {} + 'cylc-run': { + 'foo': { + 'bar': { + '.service': { + 'db': None, + }, + 'flow.cylc': None, + 'share': { + 'cycle': Symlink('sym-cycle/cylc-run/foo/bar/share/cycle'), + }, + }, + }, + }, + 'sym-cycle': { + 'cylc-run': { + 'foo': { + 'bar': { + 'share': { + 'cycle': { + 'kiazi.txt': None, + }, + }, + }, + }, + }, + }, + 'you-shall-not-pass': {}, } diff --git a/tests/unit/test_clean.py b/tests/unit/test_clean.py index 2a67daf4bf4..5227e9d3876 100644 --- a/tests/unit/test_clean.py +++ b/tests/unit/test_clean.py @@ -34,8 +34,7 @@ import pytest -from cylc.flow import CYLC_LOG -from cylc.flow import clean as cylc_clean +from cylc.flow import CYLC_LOG, clean as cylc_clean from cylc.flow.clean import ( _clean_using_glob, _remote_clean_cmd, @@ -64,10 +63,12 @@ FILETREE_2, FILETREE_3, FILETREE_4, + Symlink, create_filetree, get_filetree_as_list, ) + NonCallableFixture = Any @@ -536,7 +537,7 @@ def _filetree_for_testing_cylc_clean( 'cylc-run': {'foo': {'bar': { '.service': {'db': None}, 'flow.cylc': None, - 'rincewind.txt': Path('whatever') + 'rincewind.txt': Symlink('whatever') }}}, 'sym': {'cylc-run': {'foo': {'bar': {}}}} } @@ -548,12 +549,12 @@ def _filetree_for_testing_cylc_clean( 'cylc-run': {'foo': {'bar': { '.service': {'db': None}, 'flow.cylc': None, - 'log': Path('whatever'), - 'mirkwood': Path('whatever') + 'log': Symlink('whatever'), + 'mirkwood': Symlink('whatever') }}}, 'sym': {'cylc-run': {'foo': {'bar': { 'log': { - 'darmok': Path('whatever'), + 'darmok': Symlink('whatever'), 'bib': {} } }}}} @@ -612,7 +613,7 @@ def test__clean_using_glob( 'cylc-run': {'foo': {'bar': { '.service': {'db': None}, 'flow.cylc': None, - 'rincewind.txt': Path('whatever') + 'rincewind.txt': Symlink('whatever') }}}, 'sym': {'cylc-run': {}} }, @@ -625,12 +626,12 @@ def test__clean_using_glob( 'cylc-run': {'foo': {'bar': { '.service': {'db': None}, 'flow.cylc': None, - 'log': Path('whatever'), - 'mirkwood': Path('whatever') + 'log': Symlink('whatever'), + 'mirkwood': Symlink('whatever') }}}, 'sym': {'cylc-run': {'foo': {'bar': { 'log': { - 'darmok': Path('whatever'), + 'darmok': Symlink('whatever'), 'bib': {} } }}}} @@ -641,11 +642,13 @@ def test__clean_using_glob( {'**/cycle'}, FILETREE_2, { - 'cylc-run': {'foo': {'bar': Path('sym-run/cylc-run/foo/bar')}}, + 'cylc-run': {'foo': { + 'bar': Symlink('sym-run/cylc-run/foo/bar') + }}, 'sym-run': {'cylc-run': {'foo': {'bar': { '.service': {'db': None}, 'flow.cylc': None, - 'share': Path('sym-share/cylc-run/foo/bar/share') + 'share': Symlink('sym-share/cylc-run/foo/bar/share') }}}}, 'sym-share': {'cylc-run': {'foo': {'bar': { 'share': {} @@ -658,7 +661,9 @@ def test__clean_using_glob( {'share'}, FILETREE_2, { - 'cylc-run': {'foo': {'bar': Path('sym-run/cylc-run/foo/bar')}}, + 'cylc-run': {'foo': { + 'bar': Symlink('sym-run/cylc-run/foo/bar') + }}, 'sym-run': {'cylc-run': {'foo': {'bar': { '.service': {'db': None}, 'flow.cylc': None, @@ -689,7 +694,9 @@ def test__clean_using_glob( {'*'}, FILETREE_2, { - 'cylc-run': {'foo': {'bar': Path('sym-run/cylc-run/foo/bar')}}, + 'cylc-run': {'foo': { + 'bar': Symlink('sym-run/cylc-run/foo/bar') + }}, 'sym-run': {'cylc-run': {'foo': {'bar': { '.service': {'db': None}, }}}}, @@ -1088,6 +1095,12 @@ def test_clean_top_level(tmp_run_dir: Callable): 'cylc-run/foo/bar/share/cycle'], id="filetree2 **" ), + pytest.param( + 'share', + FILETREE_2, + ['cylc-run/foo/bar/share'], + id="filetree2 share" + ), pytest.param( '**', FILETREE_3, diff --git a/tests/unit/test_pathutil.py b/tests/unit/test_pathutil.py index 6ed9e9ec120..41a47a71409 100644 --- a/tests/unit/test_pathutil.py +++ b/tests/unit/test_pathutil.py @@ -40,6 +40,7 @@ get_workflow_run_share_dir, get_workflow_run_work_dir, get_workflow_test_log_path, + is_relative_to, make_localhost_symlinks, make_workflow_run_tree, parse_rm_dirs, @@ -576,3 +577,18 @@ def test_get_workflow_name_from_id( result = get_workflow_name_from_id(id_) assert result == name + + +@pytest.mark.parametrize('abs_path', [True, False]) +@pytest.mark.parametrize('path1, path2, expected', [ + param('/foo/bar/baz', '/foo/bar', True, id="child"), + param('/foo/bar', '/foo/bar/baz', False, id="parent"), + param('/foo/bar', '/foo/bar', True, id="same-path"), + param('/cat/dog', '/hat/bog', False, id="different"), + param('/a/b/c/../x/y', '/a/b/x', True, id="trickery"), +]) +def test_is_relative_to(abs_path, path1, path2, expected): + if not abs_path: # absolute & relative versions of same test + path1.lstrip('/') + path2.lstrip('/') + assert is_relative_to(path1, path2) == expected From a637aa334ac47aa005f16c20eae76fd27386c2ce Mon Sep 17 00:00:00 2001 From: Ronnie Dutta <61982285+MetRonnie@users.noreply.github.com> Date: Tue, 10 Sep 2024 18:43:51 +0100 Subject: [PATCH 187/196] `cylc clean`: fix bug where `--rm share` would not clean `share/cycle` symlink first --- changes.d/6364.fix.md | 1 + cylc/flow/clean.py | 17 ++++++++++++----- tests/unit/test_clean.py | 16 ++-------------- 3 files changed, 15 insertions(+), 19 deletions(-) create mode 100644 changes.d/6364.fix.md diff --git a/changes.d/6364.fix.md b/changes.d/6364.fix.md new file mode 100644 index 00000000000..9ff613e2c18 --- /dev/null +++ b/changes.d/6364.fix.md @@ -0,0 +1 @@ +Fixed bug where `cylc clean --rm share` would not take care of removing the target of the `share/cycle` symlink directory. diff --git a/cylc/flow/clean.py b/cylc/flow/clean.py index f73b0364766..fda1d4f1dd8 100644 --- a/cylc/flow/clean.py +++ b/cylc/flow/clean.py @@ -57,6 +57,7 @@ ) from cylc.flow.pathutil import ( get_workflow_run_dir, + is_relative_to, parse_rm_dirs, remove_dir_and_target, remove_dir_or_file, @@ -329,13 +330,19 @@ def _clean_using_glob( LOG.info(f"No files matching '{pattern}' in {run_dir}") return # First clean any matching symlink dirs - for path in abs_symlink_dirs: - if path in matches: - remove_dir_and_target(path) - if path == run_dir: + for symlink_dir in abs_symlink_dirs: + # Note: must clean e.g. share/cycle/ before share/ if the former + # is a symlink even if only the latter was specified. + if ( + any(is_relative_to(symlink_dir, path) for path in matches) + and symlink_dir.is_symlink() + ): + remove_dir_and_target(symlink_dir) + if symlink_dir == run_dir: # We have deleted the run dir return - matches.remove(path) + if symlink_dir in matches: + matches.remove(symlink_dir) # Now clean the rest for path in matches: remove_dir_or_file(path) diff --git a/tests/unit/test_clean.py b/tests/unit/test_clean.py index 5227e9d3876..285bfa6a23f 100644 --- a/tests/unit/test_clean.py +++ b/tests/unit/test_clean.py @@ -669,13 +669,7 @@ def test__clean_using_glob( 'flow.cylc': None, }}}}, 'sym-share': {'cylc-run': {}}, - 'sym-cycle': {'cylc-run': {'foo': {'bar': { - 'share': { - 'cycle': { - 'macklunkey.txt': None - } - } - }}}} + 'sym-cycle': {'cylc-run': {}}, }, id="filetree2 share" ), @@ -701,13 +695,7 @@ def test__clean_using_glob( '.service': {'db': None}, }}}}, 'sym-share': {'cylc-run': {}}, - 'sym-cycle': {'cylc-run': {'foo': {'bar': { - 'share': { - 'cycle': { - 'macklunkey.txt': None - } - } - }}}} + 'sym-cycle': {'cylc-run': {}}, }, id="filetree2 *" ), From aac9f9c1f32dfb40f7229379bc44a2f955567c4d Mon Sep 17 00:00:00 2001 From: Ronnie Dutta <61982285+MetRonnie@users.noreply.github.com> Date: Tue, 10 Sep 2024 18:57:00 +0100 Subject: [PATCH 188/196] Simplify `cylc clean` reinvocation check --- cylc/flow/clean.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/cylc/flow/clean.py b/cylc/flow/clean.py index fda1d4f1dd8..72c23e1e3e2 100644 --- a/cylc/flow/clean.py +++ b/cylc/flow/clean.py @@ -117,12 +117,8 @@ def _clean_check(opts: 'Values', id_: str, run_dir: Path) -> None: # Thing to clean must be a dir or broken symlink: if not run_dir.is_dir() and not run_dir.is_symlink(): raise FileNotFoundError(f"No directory to clean at {run_dir}") - db_path = ( - run_dir / WorkflowFiles.Service.DIRNAME / WorkflowFiles.Service.DB - ) - if opts.local_only and not db_path.is_file(): - # Will reach here if this is cylc clean re-invoked on remote host - # (workflow DB only exists on scheduler host); don't need to worry + if opts.no_scan: + # This is cylc clean re-invoked on remote host; don't need to worry # about contact file. return try: From b9504e04052aec26823f34634569cbeebe97d05e Mon Sep 17 00:00:00 2001 From: Oliver Sanders Date: Wed, 18 Sep 2024 12:53:50 +0100 Subject: [PATCH 189/196] subprocpool: populate missing 255 callback arguments * In some situations, these missing arguments could cause tasks to submit fail as the result of a host outage rather than moving onto the next host for the platform. --- changes.d/6376.fix.md | 1 + cylc/flow/subprocpool.py | 13 +++++++++++-- 2 files changed, 12 insertions(+), 2 deletions(-) create mode 100644 changes.d/6376.fix.md diff --git a/changes.d/6376.fix.md b/changes.d/6376.fix.md new file mode 100644 index 00000000000..686f7eef945 --- /dev/null +++ b/changes.d/6376.fix.md @@ -0,0 +1 @@ +Fixes an issue that could cause Cylc to ignore the remaining hosts in a platform in response to an `ssh` error in some niche circumstances. diff --git a/cylc/flow/subprocpool.py b/cylc/flow/subprocpool.py index 864071332b8..5af8b897ac1 100644 --- a/cylc/flow/subprocpool.py +++ b/cylc/flow/subprocpool.py @@ -291,8 +291,17 @@ def process(self): ) continue # Command still running, see if STDOUT/STDERR are readable or not - runnings.append([ - proc, ctx, bad_hosts, callback, callback_args, None, None]) + runnings.append( + [ + proc, + ctx, + bad_hosts, + callback, + callback_args, + callback_255, + callback_255_args, + ] + ) # Unblock proc's STDOUT/STDERR if necessary. Otherwise, a full # STDOUT or STDERR may stop command from proceeding. self._poll_proc_pipes(proc, ctx) From 0a88f7aad9b619e8728f9f3bc0be69101a260ca8 Mon Sep 17 00:00:00 2001 From: David Sutherland Date: Mon, 21 Oct 2024 22:34:14 +1300 Subject: [PATCH 190/196] fix CPF wall_clock offset precision constraint (#6431) --- changes.d/6431.fix.md | 1 + cylc/flow/task_proxy.py | 15 +++++++++------ tests/integration/test_xtrigger_mgr.py | 20 +++++++++++++++++--- 3 files changed, 27 insertions(+), 9 deletions(-) create mode 100644 changes.d/6431.fix.md diff --git a/changes.d/6431.fix.md b/changes.d/6431.fix.md new file mode 100644 index 00000000000..900bfee61d7 --- /dev/null +++ b/changes.d/6431.fix.md @@ -0,0 +1 @@ +The `cycle point format` was imposing an undesirable constraint on `wall_clock` offsets, this has been fixed. diff --git a/cylc/flow/task_proxy.py b/cylc/flow/task_proxy.py index 4e7b60d6e0a..0ba67438fcd 100644 --- a/cylc/flow/task_proxy.py +++ b/cylc/flow/task_proxy.py @@ -48,7 +48,6 @@ from cylc.flow.cycling.iso8601 import ( point_parse, interval_parse, - ISO8601Interval ) if TYPE_CHECKING: @@ -424,14 +423,18 @@ def get_clock_trigger_time( """ offset_str = offset_str if offset_str else 'P0Y' if offset_str not in self.clock_trigger_times: + # Convert ISO8601Point into metomi-isodatetime TimePoint at full + # second precision (N.B. it still dumps at the same precision + # as workflow cycle point format): + point_time = point_parse(str(point)) if offset_str == 'P0Y': - trigger_time = point + trigger_time = point_time else: - trigger_time = point + ISO8601Interval(offset_str) + trigger_time = point_time + interval_parse(offset_str) - offset = int( - point_parse(str(trigger_time)).seconds_since_unix_epoch) - self.clock_trigger_times[offset_str] = offset + self.clock_trigger_times[offset_str] = int( + trigger_time.seconds_since_unix_epoch + ) return self.clock_trigger_times[offset_str] def get_try_num(self): diff --git a/tests/integration/test_xtrigger_mgr.py b/tests/integration/test_xtrigger_mgr.py index d04c202bd43..73dd66fc2c5 100644 --- a/tests/integration/test_xtrigger_mgr.py +++ b/tests/integration/test_xtrigger_mgr.py @@ -29,30 +29,38 @@ def get_task_ids(schd: Scheduler) -> Set[str]: async def test_2_xtriggers(flow, start, scheduler, monkeypatch): - """Test that if an itask has 2 wall_clock triggers with different - offsets that xtrigger manager gets both of them. + """Test that if an itask has 4 wall_clock triggers with different + offsets that xtrigger manager gets all of them. https://github.com/cylc/cylc-flow/issues/5783 n.b. Clock 3 exists to check the memoization path is followed, and causing this test to give greater coverage. + Clock 4 & 5 test higher precision offsets than the CPF. """ task_point = 1588636800 # 2020-05-05 ten_years_ahead = 1904169600 # 2030-05-05 + PT2H35M31S_ahead = 1588646131 # 2020-05-05 02:35:31 + PT2H35M31S_behind = 1588627469 # 2020-05-04 21:24:29 monkeypatch.setattr( 'cylc.flow.xtriggers.wall_clock.time', lambda: ten_years_ahead - 1 ) id_ = flow({ + 'scheduler': { + 'cycle point format': 'CCYY-MM-DD', + }, 'scheduling': { 'initial cycle point': '2020-05-05', 'xtriggers': { 'clock_1': 'wall_clock()', 'clock_2': 'wall_clock(offset=P10Y)', 'clock_3': 'wall_clock(offset=P10Y)', + 'clock_4': 'wall_clock(offset=PT2H35M31S)', + 'clock_5': 'wall_clock(offset=-PT2H35M31S)', }, 'graph': { - 'R1': '@clock_1 & @clock_2 & @clock_3 => foo' + 'R1': '@clock_1 & @clock_2 & @clock_3 & @clock_4 & @clock_5 => foo' } } }) @@ -62,16 +70,22 @@ async def test_2_xtriggers(flow, start, scheduler, monkeypatch): clock_1_ctx = schd.xtrigger_mgr.get_xtrig_ctx(foo_proxy, 'clock_1') clock_2_ctx = schd.xtrigger_mgr.get_xtrig_ctx(foo_proxy, 'clock_2') clock_3_ctx = schd.xtrigger_mgr.get_xtrig_ctx(foo_proxy, 'clock_2') + clock_4_ctx = schd.xtrigger_mgr.get_xtrig_ctx(foo_proxy, 'clock_4') + clock_5_ctx = schd.xtrigger_mgr.get_xtrig_ctx(foo_proxy, 'clock_5') assert clock_1_ctx.func_kwargs['trigger_time'] == task_point assert clock_2_ctx.func_kwargs['trigger_time'] == ten_years_ahead assert clock_3_ctx.func_kwargs['trigger_time'] == ten_years_ahead + assert clock_4_ctx.func_kwargs['trigger_time'] == PT2H35M31S_ahead + assert clock_5_ctx.func_kwargs['trigger_time'] == PT2H35M31S_behind schd.xtrigger_mgr.call_xtriggers_async(foo_proxy) assert foo_proxy.state.xtriggers == { 'clock_1': True, 'clock_2': False, 'clock_3': False, + 'clock_4': True, + 'clock_5': True, } From 1edb6dd097c12581d1b4123115ce6b76ca6eba5d Mon Sep 17 00:00:00 2001 From: Ronnie Dutta <61982285+MetRonnie@users.noreply.github.com> Date: Fri, 18 Oct 2024 17:48:11 +0100 Subject: [PATCH 191/196] Replace unicode character --- cylc/flow/task_outputs.py | 2 +- tests/flakyfunctional/cylc-show/00-simple.t | 8 +++---- tests/flakyfunctional/cylc-show/04-multi.t | 6 ++--- tests/functional/cylc-show/05-complex.t | 4 ++-- .../tui/screenshots/test_show.success.html | 2 +- tests/unit/test_task_outputs.py | 24 +++++++++---------- 6 files changed, 23 insertions(+), 23 deletions(-) diff --git a/cylc/flow/task_outputs.py b/cylc/flow/task_outputs.py index 1af37e1554e..b8363a50ada 100644 --- a/cylc/flow/task_outputs.py +++ b/cylc/flow/task_outputs.py @@ -531,7 +531,7 @@ def color_wrap(string, is_complete): ret: List[str] = [] indent_level: int = 0 op: Optional[str] = None - fence = '⦙' # U+2999 (dotted fence) + fence = '┆' # U+2506 (Box Drawings Light Triple Dash Vertical) for part in RE_EXPR_SPLIT.split(self._completion_expression): if not part.strip(): continue diff --git a/tests/flakyfunctional/cylc-show/00-simple.t b/tests/flakyfunctional/cylc-show/00-simple.t index f96a1129268..2fa5f496607 100644 --- a/tests/flakyfunctional/cylc-show/00-simple.t +++ b/tests/flakyfunctional/cylc-show/00-simple.t @@ -64,10 +64,10 @@ outputs: ('⨯': not completed) ⨯ 20141106T0900Z/foo succeeded ⨯ 20141106T0900Z/foo failed output completion: incomplete - ⦙ ( - ✓ ⦙ started - ⨯ ⦙ and succeeded - ⦙ ) + ┆ ( + ✓ ┆ started + ⨯ ┆ and succeeded + ┆ ) __SHOW_OUTPUT__ #------------------------------------------------------------------------------- TEST_NAME="${TEST_NAME_BASE}-show-json" diff --git a/tests/flakyfunctional/cylc-show/04-multi.t b/tests/flakyfunctional/cylc-show/04-multi.t index eac79132aaf..c01091891d4 100644 --- a/tests/flakyfunctional/cylc-show/04-multi.t +++ b/tests/flakyfunctional/cylc-show/04-multi.t @@ -44,7 +44,7 @@ outputs: ('⨯': not completed) ⨯ 2016/t1 succeeded ⨯ 2016/t1 failed output completion: incomplete - ⨯ ⦙ succeeded + ⨯ ┆ succeeded Task ID: 2017/t1 title: (not given) @@ -61,7 +61,7 @@ outputs: ('⨯': not completed) ⨯ 2017/t1 succeeded ⨯ 2017/t1 failed output completion: incomplete - ⨯ ⦙ succeeded + ⨯ ┆ succeeded Task ID: 2018/t1 title: (not given) @@ -78,7 +78,7 @@ outputs: ('⨯': not completed) ⨯ 2018/t1 succeeded ⨯ 2018/t1 failed output completion: incomplete - ⨯ ⦙ succeeded + ⨯ ┆ succeeded __TXT__ contains_ok "${RUND}/show2.txt" <<'__TXT__' diff --git a/tests/functional/cylc-show/05-complex.t b/tests/functional/cylc-show/05-complex.t index d26c6b4f070..0cc7e624247 100644 --- a/tests/functional/cylc-show/05-complex.t +++ b/tests/functional/cylc-show/05-complex.t @@ -53,7 +53,7 @@ outputs: ('⨯': not completed) ⨯ 20000101T0000Z/f succeeded ⨯ 20000101T0000Z/f failed output completion: incomplete - ⨯ ⦙ succeeded + ⨯ ┆ succeeded 19991231T0000Z/f succeeded 20000101T0000Z/a succeeded 20000101T0000Z/b succeeded @@ -80,7 +80,7 @@ outputs: ('⨯': not completed) ⨯ 20000102T0000Z/f succeeded ⨯ 20000102T0000Z/f failed output completion: incomplete - ⨯ ⦙ succeeded + ⨯ ┆ succeeded 20000101T0000Z/f succeeded 20000102T0000Z/a succeeded 20000102T0000Z/b succeeded diff --git a/tests/integration/tui/screenshots/test_show.success.html b/tests/integration/tui/screenshots/test_show.success.html index b392130363b..bf0afb00344 100644 --- a/tests/integration/tui/screenshots/test_show.success.html +++ b/tests/integration/tui/screenshots/test_show.success.html @@ -23,7 +23,7 @@ ⨯ 1/foo succeeded ⨯ 1/foo failed output completion: incomplete - ⨯ ⦙ succeeded + ⨯ ┆ succeeded q to close diff --git a/tests/unit/test_task_outputs.py b/tests/unit/test_task_outputs.py index 70a297edff5..2dbe684f04e 100644 --- a/tests/unit/test_task_outputs.py +++ b/tests/unit/test_task_outputs.py @@ -223,12 +223,12 @@ def test_format_completion_status(): indent=2, gutter=2 ) == ' ' + sstrip( ''' - ⦙ ( - ⨯ ⦙ succeeded - ⨯ ⦙ and x - ⨯ ⦙ and y - ⦙ ) - ⨯ ⦙ or expired + ┆ ( + ⨯ ┆ succeeded + ⨯ ┆ and x + ⨯ ┆ and y + ┆ ) + ⨯ ┆ or expired ''' ) outputs.set_message_complete('succeeded') @@ -237,12 +237,12 @@ def test_format_completion_status(): indent=2, gutter=2 ) == ' ' + sstrip( ''' - ⦙ ( - ✓ ⦙ succeeded - ✓ ⦙ and x - ⨯ ⦙ and y - ⦙ ) - ⨯ ⦙ or expired + ┆ ( + ✓ ┆ succeeded + ✓ ┆ and x + ⨯ ┆ and y + ┆ ) + ⨯ ┆ or expired ''' ) From 957be706cb3622aeb04f6b97debac2ff1000c43b Mon Sep 17 00:00:00 2001 From: Hilary James Oliver Date: Mon, 21 Oct 2024 12:15:12 +1300 Subject: [PATCH 192/196] Don't trigger n=0 tasks with flow=none. --- cylc/flow/task_pool.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/cylc/flow/task_pool.py b/cylc/flow/task_pool.py index 3ddf991cb9c..68d359d5dac 100644 --- a/cylc/flow/task_pool.py +++ b/cylc/flow/task_pool.py @@ -1927,6 +1927,12 @@ def set_prereqs_and_outputs( # Set existing task proxies. for itask in itasks: + if flow == ['none'] and itask.flow_nums != set(): + LOG.warning( + f"[{itask}] ignoring 'flow=none' set: task already has" + f" {stringify_flow_nums(itask.flow_nums, full=True)}" + ) + continue self.merge_flows(itask, flow_nums) if prereqs: self._set_prereqs_itask(itask, prereqs, flow_nums) @@ -2169,6 +2175,12 @@ def force_trigger_tasks( # Trigger active tasks. for itask in existing_tasks: + if flow == ['none'] and itask.flow_nums != set(): + LOG.warning( + f"[{itask}] ignoring 'flow=none' trigger: task already has" + f" {stringify_flow_nums(itask.flow_nums, full=True)}" + ) + continue if itask.state(TASK_STATUS_PREPARING, *TASK_STATUSES_ACTIVE): LOG.warning(f"[{itask}] ignoring trigger - already active") continue From fcfbe4355fe7edd6df7eb7e3e01b34633f3e8217 Mon Sep 17 00:00:00 2001 From: Hilary James Oliver Date: Mon, 21 Oct 2024 14:33:34 +1300 Subject: [PATCH 193/196] Tweak tests. --- tests/integration/test_flow_assignment.py | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/tests/integration/test_flow_assignment.py b/tests/integration/test_flow_assignment.py index 5816b08527f..50ac3d77de7 100644 --- a/tests/integration/test_flow_assignment.py +++ b/tests/integration/test_flow_assignment.py @@ -17,12 +17,18 @@ """Test for flow-assignment in triggered/set tasks.""" import functools +import logging import time from typing import Callable import pytest -from cylc.flow.flow_mgr import FLOW_ALL, FLOW_NEW, FLOW_NONE +from cylc.flow.flow_mgr import ( + FLOW_ALL, + FLOW_NEW, + FLOW_NONE, + stringify_flow_nums +) from cylc.flow.scheduler import Scheduler @@ -76,7 +82,9 @@ async def test_get_flow_nums(one: Scheduler, start): @pytest.mark.parametrize('command', ['trigger', 'set']) -async def test_flow_assignment(flow, scheduler, start, command: str): +async def test_flow_assignment( + flow, scheduler, start, command: str, log_filter: Callable +): """Test flow assignment when triggering/setting tasks. Active tasks: @@ -102,7 +110,7 @@ async def test_flow_assignment(flow, scheduler, start, command: str): } id_ = flow(conf) schd: Scheduler = scheduler(id_, run_mode='simulation', paused_start=True) - async with start(schd): + async with start(schd) as log: if command == 'set': do_command: Callable = functools.partial( schd.pool.set_prereqs_and_outputs, outputs=['x'], prereqs=[] @@ -128,6 +136,14 @@ async def test_flow_assignment(flow, scheduler, start, command: str): # (no-flow is ignored for active tasks) do_command([active_a.identity], flow=[FLOW_NONE]) assert active_a.flow_nums == {1, 2} + assert log_filter( + log, + contains=( + f'[{active_a}] ignoring \'flow=none\' {command}: ' + f'task already has {stringify_flow_nums(active_a.flow_nums)}' + ), + level=logging.WARNING + ) do_command([active_a.identity], flow=[FLOW_NEW]) assert active_a.flow_nums == {1, 2, 3} From 3dea1c4d0d51f9764ff1b881f2a052e04bccb436 Mon Sep 17 00:00:00 2001 From: Hilary James Oliver Date: Mon, 21 Oct 2024 14:34:50 +1300 Subject: [PATCH 194/196] Update change log. --- changes.d/6433.fix.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changes.d/6433.fix.md diff --git a/changes.d/6433.fix.md b/changes.d/6433.fix.md new file mode 100644 index 00000000000..92531f0d89a --- /dev/null +++ b/changes.d/6433.fix.md @@ -0,0 +1 @@ +Ignore requests to trigger or set active tasks with --flow=none. From 28a3f92a2093ee1cbbbb33201de58c7138514046 Mon Sep 17 00:00:00 2001 From: Hilary James Oliver Date: Tue, 22 Oct 2024 09:49:39 +1300 Subject: [PATCH 195/196] Upgrade trigger warning to error. --- cylc/flow/task_pool.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cylc/flow/task_pool.py b/cylc/flow/task_pool.py index 68d359d5dac..e149630d322 100644 --- a/cylc/flow/task_pool.py +++ b/cylc/flow/task_pool.py @@ -1928,7 +1928,7 @@ def set_prereqs_and_outputs( # Set existing task proxies. for itask in itasks: if flow == ['none'] and itask.flow_nums != set(): - LOG.warning( + LOG.error( f"[{itask}] ignoring 'flow=none' set: task already has" f" {stringify_flow_nums(itask.flow_nums, full=True)}" ) @@ -2176,13 +2176,13 @@ def force_trigger_tasks( # Trigger active tasks. for itask in existing_tasks: if flow == ['none'] and itask.flow_nums != set(): - LOG.warning( + LOG.error( f"[{itask}] ignoring 'flow=none' trigger: task already has" f" {stringify_flow_nums(itask.flow_nums, full=True)}" ) continue if itask.state(TASK_STATUS_PREPARING, *TASK_STATUSES_ACTIVE): - LOG.warning(f"[{itask}] ignoring trigger - already active") + LOG.error(f"[{itask}] ignoring trigger - already active") continue self.merge_flows(itask, flow_nums) self._force_trigger(itask) From df89a772d25cc61cf3a0c06f29f1dfaa10034720 Mon Sep 17 00:00:00 2001 From: Hilary James Oliver Date: Tue, 22 Oct 2024 11:44:38 +1300 Subject: [PATCH 196/196] Fix test. --- tests/integration/test_flow_assignment.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/test_flow_assignment.py b/tests/integration/test_flow_assignment.py index 50ac3d77de7..6c0c58a8758 100644 --- a/tests/integration/test_flow_assignment.py +++ b/tests/integration/test_flow_assignment.py @@ -142,7 +142,7 @@ async def test_flow_assignment( f'[{active_a}] ignoring \'flow=none\' {command}: ' f'task already has {stringify_flow_nums(active_a.flow_nums)}' ), - level=logging.WARNING + level=logging.ERROR ) do_command([active_a.identity], flow=[FLOW_NEW])