Skip to content

Commit 9ff50f8

Browse files
Merge pull request #6472 from MetRonnie/cylc-remove
Implement `cylc remove` proposal
2 parents 354c7d5 + 7d91a99 commit 9ff50f8

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

75 files changed

+2163
-786
lines changed

changes.d/6472.feat.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
`cylc remove` improvements:
2+
- It can now remove tasks that are no longer active, making it look like they never ran.
3+
- Removing a submitted/running task will kill it.
4+
- Added the `--flow` option.
5+
- Removed tasks are now demoted to `flow=none` but retained in the workflow database for provenance.

cylc/flow/command_validation.py

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,19 +24,34 @@
2424
)
2525

2626
from cylc.flow.exceptions import InputError
27-
from cylc.flow.id import IDTokens, Tokens
27+
from cylc.flow.flow_mgr import (
28+
FLOW_ALL,
29+
FLOW_NEW,
30+
FLOW_NONE,
31+
)
32+
from cylc.flow.id import (
33+
IDTokens,
34+
Tokens,
35+
)
2836
from cylc.flow.task_outputs import TASK_OUTPUT_SUCCEEDED
29-
from cylc.flow.flow_mgr import FLOW_ALL, FLOW_NEW, FLOW_NONE
3037

3138

32-
ERR_OPT_FLOW_VAL = "Flow values must be an integer, or 'all', 'new', or 'none'"
39+
ERR_OPT_FLOW_VAL = (
40+
f"Flow values must be integers, or '{FLOW_ALL}', '{FLOW_NEW}', "
41+
f"or '{FLOW_NONE}'"
42+
)
43+
ERR_OPT_FLOW_VAL_2 = f"Flow values must be integers, or '{FLOW_ALL}'"
3344
ERR_OPT_FLOW_COMBINE = "Cannot combine --flow={0} with other flow values"
3445
ERR_OPT_FLOW_WAIT = (
3546
f"--wait is not compatible with --flow={FLOW_NEW} or --flow={FLOW_NONE}"
3647
)
3748

3849

39-
def flow_opts(flows: List[str], flow_wait: bool) -> None:
50+
def flow_opts(
51+
flows: List[str],
52+
flow_wait: bool,
53+
allow_new_or_none: bool = True
54+
) -> None:
4055
"""Check validity of flow-related CLI options.
4156
4257
Note the schema defaults flows to [].
@@ -63,16 +78,23 @@ def flow_opts(flows: List[str], flow_wait: bool) -> None:
6378
cylc.flow.exceptions.InputError: --wait is not compatible with
6479
--flow=new or --flow=none
6580
81+
>>> flow_opts(["new"], False, allow_new_or_none=False)
82+
Traceback (most recent call last):
83+
cylc.flow.exceptions.InputError: ... must be integers, or 'all'
84+
6685
"""
6786
if not flows:
6887
return
6988

7089
flows = [val.strip() for val in flows]
7190

7291
for val in flows:
92+
val = val.strip()
7393
if val in {FLOW_NONE, FLOW_NEW, FLOW_ALL}:
7494
if len(flows) != 1:
7595
raise InputError(ERR_OPT_FLOW_COMBINE.format(val))
96+
if not allow_new_or_none and val in {FLOW_NEW, FLOW_NONE}:
97+
raise InputError(ERR_OPT_FLOW_VAL_2)
7698
else:
7799
try:
78100
int(val)

cylc/flow/commands.py

Lines changed: 27 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -53,18 +53,24 @@
5353
"""
5454

5555
from contextlib import suppress
56-
from time import sleep, time
56+
from time import (
57+
sleep,
58+
time,
59+
)
5760
from typing import (
61+
TYPE_CHECKING,
5862
AsyncGenerator,
5963
Callable,
6064
Dict,
6165
Iterable,
6266
List,
6367
Optional,
64-
TYPE_CHECKING,
68+
TypeVar,
6569
Union,
6670
)
6771

72+
from metomi.isodatetime.parsers import TimePointParser
73+
6874
from cylc.flow import LOG
6975
import cylc.flow.command_validation as validate
7076
from cylc.flow.exceptions import (
@@ -73,38 +79,40 @@
7379
CylcConfigError,
7480
)
7581
import cylc.flow.flags
82+
from cylc.flow.flow_mgr import get_flow_nums_set
7683
from cylc.flow.log_level import log_level_to_verbosity
7784
from cylc.flow.network.schema import WorkflowStopMode
7885
from cylc.flow.parsec.exceptions import ParsecError
7986
from cylc.flow.task_id import TaskID
80-
from cylc.flow.workflow_status import RunMode, StopMode
87+
from cylc.flow.workflow_status import (
88+
RunMode,
89+
StopMode,
90+
)
8191

82-
from metomi.isodatetime.parsers import TimePointParser
8392

8493
if TYPE_CHECKING:
8594
from cylc.flow.scheduler import Scheduler
8695

8796
# define a type for command implementations
88-
Command = Callable[
89-
...,
90-
AsyncGenerator,
91-
]
97+
Command = Callable[..., AsyncGenerator]
98+
# define a generic type needed for the @_command decorator
99+
_TCommand = TypeVar('_TCommand', bound=Command)
92100

93101
# a directory of registered commands (populated on module import)
94102
COMMANDS: 'Dict[str, Command]' = {}
95103

96104

97105
def _command(name: str):
98106
"""Decorator to register a command."""
99-
def _command(fcn: 'Command'):
107+
def _command(fcn: '_TCommand') -> '_TCommand':
100108
nonlocal name
101109
COMMANDS[name] = fcn
102-
fcn.command_name = name # type: ignore
110+
fcn.command_name = name # type: ignore[attr-defined]
103111
return fcn
104112
return _command
105113

106114

107-
async def run_cmd(fcn, *args, **kwargs):
115+
async def run_cmd(bound_fcn: AsyncGenerator):
108116
"""Run a command outside of the scheduler's main loop.
109117
110118
Normally commands are run via the Scheduler's command_queue (which is
@@ -119,10 +127,9 @@ async def run_cmd(fcn, *args, **kwargs):
119127
For these purposes use "run_cmd", otherwise, queue commands via the
120128
scheduler as normal.
121129
"""
122-
cmd = fcn(*args, **kwargs)
123-
await cmd.__anext__() # validate
130+
await bound_fcn.__anext__() # validate
124131
with suppress(StopAsyncIteration):
125-
return await cmd.__anext__() # run
132+
return await bound_fcn.__anext__() # run
126133

127134

128135
@_command('set')
@@ -310,11 +317,15 @@ async def set_verbosity(schd: 'Scheduler', level: Union[int, str]):
310317

311318

312319
@_command('remove_tasks')
313-
async def remove_tasks(schd: 'Scheduler', tasks: Iterable[str]):
320+
async def remove_tasks(
321+
schd: 'Scheduler', tasks: Iterable[str], flow: List[str]
322+
):
314323
"""Remove tasks."""
315324
validate.is_tasks(tasks)
325+
validate.flow_opts(flow, flow_wait=False, allow_new_or_none=False)
316326
yield
317-
yield schd.pool.remove_tasks(tasks)
327+
flow_nums = get_flow_nums_set(flow)
328+
schd.remove_tasks(tasks, flow_nums)
318329

319330

320331
@_command('reload_workflow')

cylc/flow/data_store_mgr.py

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2356,22 +2356,42 @@ def delta_task_held(
23562356
self.updates_pending = True
23572357

23582358
def delta_task_flow_nums(self, itask: TaskProxy) -> None:
2359-
"""Create delta for change in task proxy flow_nums.
2359+
"""Create delta for change in task proxy flow numbers.
23602360
23612361
Args:
2362-
itask (cylc.flow.task_proxy.TaskProxy):
2363-
Update task-node from corresponding task proxy
2364-
objects from the workflow task pool.
2365-
2362+
itask: TaskProxy with updated flow numbers.
23662363
"""
23672364
tproxy: Optional[PbTaskProxy]
23682365
tp_id, tproxy = self.store_node_fetcher(itask.tokens)
23692366
if not tproxy:
23702367
return
2371-
tp_delta = self.updated[TASK_PROXIES].setdefault(
2372-
tp_id, PbTaskProxy(id=tp_id))
2368+
self._delta_task_flow_nums(tp_id, itask.flow_nums)
2369+
2370+
def delta_remove_task_flow_nums(
2371+
self, task: str, removed: 'FlowNums'
2372+
) -> None:
2373+
"""Create delta for removal of flow numbers from a task proxy.
2374+
2375+
Args:
2376+
task: Relative ID of task.
2377+
removed: Flow numbers to remove from the task proxy in the
2378+
data store.
2379+
"""
2380+
tproxy: Optional[PbTaskProxy]
2381+
tp_id, tproxy = self.store_node_fetcher(
2382+
Tokens(task, relative=True).duplicate(**self.id_)
2383+
)
2384+
if not tproxy:
2385+
return
2386+
new_flow_nums = deserialise_set(tproxy.flow_nums).difference(removed)
2387+
self._delta_task_flow_nums(tp_id, new_flow_nums)
2388+
2389+
def _delta_task_flow_nums(self, tp_id: str, flow_nums: 'FlowNums') -> None:
2390+
tp_delta: PbTaskProxy = self.updated[TASK_PROXIES].setdefault(
2391+
tp_id, PbTaskProxy(id=tp_id)
2392+
)
23732393
tp_delta.stamp = f'{tp_id}@{time()}'
2374-
tp_delta.flow_nums = serialise_set(itask.flow_nums)
2394+
tp_delta.flow_nums = serialise_set(flow_nums)
23752395
self.updates_pending = True
23762396

23772397
def delta_task_output(

cylc/flow/dbstatecheck.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
IntegerPoint,
2929
IntegerInterval
3030
)
31-
from cylc.flow.flow_mgr import stringify_flow_nums
31+
from cylc.flow.flow_mgr import repr_flow_nums
3232
from cylc.flow.pathutil import expand_path
3333
from cylc.flow.rundb import CylcWorkflowDAO
3434
from cylc.flow.task_outputs import (
@@ -318,7 +318,7 @@ def workflow_state_query(
318318
if flow_num is not None and flow_num not in flow_nums:
319319
# skip result, wrong flow
320320
continue
321-
fstr = stringify_flow_nums(flow_nums)
321+
fstr = repr_flow_nums(flow_nums)
322322
if fstr:
323323
res.append(fstr)
324324
db_res.append(res)

cylc/flow/flow_mgr.py

Lines changed: 49 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,15 @@
1616

1717
"""Manage flow counter and flow metadata."""
1818

19-
from typing import Dict, Set, Optional, TYPE_CHECKING
2019
import datetime
20+
from typing import (
21+
TYPE_CHECKING,
22+
Dict,
23+
Iterable,
24+
List,
25+
Optional,
26+
Set,
27+
)
2128

2229
from cylc.flow import LOG
2330

@@ -55,36 +62,62 @@ def add_flow_opts(parser):
5562
)
5663

5764

58-
def stringify_flow_nums(flow_nums: Set[int], full: bool = False) -> str:
59-
"""Return a string representation of a set of flow numbers
65+
def get_flow_nums_set(flow: List[str]) -> FlowNums:
66+
"""Return set of integer flow numbers from list of strings.
6067
61-
Return:
62-
- "none" for no flow
63-
- "" for the original flow (flows only matter if there are several)
64-
- otherwise e.g. "(flow=1,2,3)"
68+
Returns an empty set if the input is empty or contains only "all".
69+
70+
>>> get_flow_nums_set(["1", "2", "3"])
71+
{1, 2, 3}
72+
>>> get_flow_nums_set([])
73+
set()
74+
>>> get_flow_nums_set(["all"])
75+
set()
76+
"""
77+
if flow == [FLOW_ALL]:
78+
return set()
79+
return {int(val.strip()) for val in flow}
80+
81+
82+
def stringify_flow_nums(flow_nums: Iterable[int]) -> str:
83+
"""Return the canonical string for a set of flow numbers.
6584
6685
Examples:
86+
>>> stringify_flow_nums({1})
87+
'1'
88+
89+
>>> stringify_flow_nums({3, 1, 2})
90+
'1,2,3'
91+
6792
>>> stringify_flow_nums({})
93+
''
94+
95+
"""
96+
return ','.join(str(i) for i in sorted(flow_nums))
97+
98+
99+
def repr_flow_nums(flow_nums: FlowNums, full: bool = False) -> str:
100+
"""Return a representation of a set of flow numbers
101+
102+
If `full` is False, return an empty string for flows=1.
103+
104+
Examples:
105+
>>> repr_flow_nums({})
68106
'(flows=none)'
69107
70-
>>> stringify_flow_nums({1})
108+
>>> repr_flow_nums({1})
71109
''
72110
73-
>>> stringify_flow_nums({1}, True)
111+
>>> repr_flow_nums({1}, full=True)
74112
'(flows=1)'
75113
76-
>>> stringify_flow_nums({1,2,3})
114+
>>> repr_flow_nums({1,2,3})
77115
'(flows=1,2,3)'
78116
79117
"""
80118
if not full and flow_nums == {1}:
81119
return ""
82-
else:
83-
return (
84-
"(flows="
85-
f"{','.join(str(i) for i in flow_nums) or 'none'}"
86-
")"
87-
)
120+
return f"(flows={stringify_flow_nums(flow_nums) or 'none'})"
88121

89122

90123
class FlowMgr:

cylc/flow/id.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from enum import Enum
2323
import re
2424
from typing import (
25+
TYPE_CHECKING,
2526
Iterable,
2627
List,
2728
Optional,
@@ -33,6 +34,10 @@
3334
from cylc.flow import LOG
3435

3536

37+
if TYPE_CHECKING:
38+
from cylc.flow.cycling import PointBase
39+
40+
3641
class IDTokens(Enum):
3742
"""Cylc object identifier tokens."""
3843

@@ -524,14 +529,14 @@ def duplicate(
524529
)
525530

526531

527-
def quick_relative_detokenise(cycle, task):
532+
def quick_relative_id(cycle: Union[str, int, 'PointBase'], task: str) -> str:
528533
"""Generate a relative ID for a task.
529534
530535
This is a more efficient solution to `Tokens` for cases where
531536
you only want the ID string and don't have any use for a Tokens object.
532537
533538
Example:
534-
>>> q = quick_relative_detokenise
539+
>>> q = quick_relative_id
535540
>>> q('1', 'a') == Tokens(cycle='1', task='a').relative_id
536541
True
537542

0 commit comments

Comments
 (0)