Skip to content

Commit 3bccf00

Browse files
authored
Fix initial runahead limit, for offset recurrence start (#5708)
Fix runahead limit for offset recurrence start points.
1 parent 930758d commit 3bccf00

File tree

5 files changed

+221
-27
lines changed

5 files changed

+221
-27
lines changed

changes.d/5708.fix.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Fix runahead limit at start-up, with recurrences that start beyond the limit.

cylc/flow/cycling/__init__.py

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -320,8 +320,8 @@ class SequenceBase(metaclass=ABCMeta):
320320
They should also provide get_async_expr, get_interval,
321321
get_offset & set_offset (deprecated), is_on_sequence,
322322
get_nearest_prev_point, get_next_point,
323-
get_next_point_on_sequence, get_first_point, and
324-
get_stop_point.
323+
get_next_point_on_sequence, get_first_point
324+
get_start_point, and get_stop_point.
325325
326326
They should also provide a self.__eq__ implementation
327327
which should return whether a SequenceBase-derived object
@@ -405,11 +405,32 @@ def get_first_point(self, point):
405405
"""Return the first point >= to point, or None if out of bounds."""
406406
pass
407407

408+
@abstractmethod
409+
def get_start_point(self):
410+
"""Return the first point of this sequence."""
411+
pass
412+
408413
@abstractmethod
409414
def get_stop_point(self):
410-
"""Return the last point in this sequence, or None if unbounded."""
415+
"""Return the last point of this sequence, or None if unbounded."""
411416
pass
412417

418+
def get_first_n_points(self, n, point=None):
419+
"""Return a list of first n points of this sequence."""
420+
if point is None:
421+
p1 = self.get_start_point()
422+
else:
423+
p1 = self.get_first_point(point)
424+
if p1 is None:
425+
return []
426+
result = [p1]
427+
for _ in range(1, n):
428+
p1 = self.get_next_point_on_sequence(p1)
429+
if p1 is None:
430+
break
431+
result.append(p1)
432+
return result
433+
413434
@abstractmethod
414435
def __eq__(self, other) -> bool:
415436
# Return True if other (sequence) is equal to self.

cylc/flow/task_pool.py

Lines changed: 54 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -309,18 +309,54 @@ def compute_runahead(self, force=False) -> bool:
309309
With force=True we recompute the limit even if the base point has not
310310
changed (needed if max_future_offset changed, or on reload).
311311
"""
312+
313+
limit = self.config.runahead_limit # e.g. P2 or P2D
314+
count_cycles = False
315+
with suppress(TypeError):
316+
# Count cycles (integer cycling, and optional for datetime too).
317+
ilimit = int(limit) # type: ignore
318+
count_cycles = True
319+
320+
base_point: 'PointBase'
312321
points: List['PointBase'] = []
313-
sequence_points: Set['PointBase']
322+
314323
if not self.main_pool:
315-
# Start at first point in each sequence, after the initial point.
316-
points = [
317-
point
318-
for point in {
319-
seq.get_first_point(self.config.start_point)
320-
for seq in self.config.sequences
321-
}
322-
if point is not None
323-
]
324+
# No tasks yet, just consider sequence points.
325+
if count_cycles:
326+
# Get the first ilimit points in each sequence.
327+
# (After workflow start point - sequence may begin earlier).
328+
points = [
329+
point
330+
for plist in [
331+
seq.get_first_n_points(
332+
ilimit, self.config.start_point)
333+
for seq in self.config.sequences
334+
]
335+
for point in plist
336+
]
337+
# Drop points beyond the limit.
338+
points = sorted(points)[:ilimit + 1]
339+
base_point = min(points)
340+
341+
else:
342+
# Start at first point in each sequence.
343+
# (After workflow start point - sequence may begin earlier).
344+
points = [
345+
point
346+
for point in {
347+
seq.get_first_point(self.config.start_point)
348+
for seq in self.config.sequences
349+
}
350+
if point is not None
351+
]
352+
base_point = min(points)
353+
# Drop points beyond the limit.
354+
points = [
355+
point
356+
for point in points
357+
if point <= base_point + limit
358+
]
359+
324360
else:
325361
# Find the earliest point with unfinished tasks.
326362
for point, itasks in sorted(self.get_tasks_by_point().items()):
@@ -344,9 +380,10 @@ def compute_runahead(self, force=False) -> bool:
344380
)
345381
):
346382
points.append(point)
347-
if not points:
348-
return False
349-
base_point = min(points)
383+
384+
if not points:
385+
return False
386+
base_point = min(points)
350387

351388
if self._prev_runahead_base_point is None:
352389
self._prev_runahead_base_point = base_point
@@ -363,15 +400,8 @@ def compute_runahead(self, force=False) -> bool:
363400
# change or the runahead limit is already at stop point.
364401
return False
365402

366-
try:
367-
limit = int(self.config.runahead_limit) # type: ignore
368-
except TypeError:
369-
count_cycles = False
370-
limit = self.config.runahead_limit
371-
else:
372-
count_cycles = True
373-
374-
# Get all cycle points possible after the runahead base point.
403+
# Get all cycle points possible after the base point.
404+
sequence_points: Set['PointBase']
375405
if (
376406
not force
377407
and self._prev_runahead_sequence_points
@@ -388,7 +418,7 @@ def compute_runahead(self, force=False) -> bool:
388418
while seq_point is not None:
389419
if count_cycles:
390420
# P0 allows only the base cycle point to run.
391-
if count > 1 + limit:
421+
if count > 1 + ilimit:
392422
break
393423
else:
394424
# PT0H allows only the base cycle point to run.
@@ -404,7 +434,7 @@ def compute_runahead(self, force=False) -> bool:
404434

405435
if count_cycles:
406436
# Some sequences may have different intervals.
407-
limit_point = sorted(points)[:(limit + 1)][-1]
437+
limit_point = sorted(points)[:(ilimit + 1)][-1]
408438
else:
409439
# We already stopped at the runahead limit.
410440
limit_point = sorted(points)[-1]

tests/integration/test_task_pool.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
from cylc.flow import CYLC_LOG
2525
from cylc.flow.cycling import PointBase
2626
from cylc.flow.cycling.integer import IntegerPoint
27+
from cylc.flow.cycling.iso8601 import ISO8601Point
2728
from cylc.flow.data_store_mgr import TASK_PROXIES
2829
from cylc.flow.task_outputs import TASK_OUTPUT_SUCCEEDED
2930
from cylc.flow.scheduler import Scheduler
@@ -62,6 +63,22 @@
6263
}
6364

6465

66+
EXAMPLE_FLOW_2_CFG = {
67+
'scheduler': {
68+
'allow implicit tasks': True,
69+
'UTC mode': True
70+
},
71+
'scheduling': {
72+
'initial cycle point': '2001',
73+
'runahead limit': 'P3Y',
74+
'graph': {
75+
'P1Y': 'foo',
76+
'R/2025/P1Y': 'foo => bar',
77+
}
78+
},
79+
}
80+
81+
6582
def get_task_ids(
6683
name_point_list: Iterable[Tuple[str, Union[PointBase, str, int]]]
6784
) -> List[str]:
@@ -129,6 +146,22 @@ async def example_flow(
129146
yield schd
130147

131148

149+
@pytest.fixture(scope='module')
150+
async def mod_example_flow_2(
151+
mod_flow: Callable, mod_scheduler: Callable, mod_run: Callable
152+
) -> Scheduler:
153+
"""Return a scheduler for interrogating its task pool.
154+
155+
This is module-scoped so faster than example_flow, but should only be used
156+
where the test does not mutate the state of the scheduler or task pool.
157+
"""
158+
id_ = mod_flow(EXAMPLE_FLOW_2_CFG)
159+
schd: Scheduler = mod_scheduler(id_, paused_start=True)
160+
async with mod_run(schd):
161+
pass
162+
return schd
163+
164+
132165
@pytest.mark.parametrize(
133166
'items, expected_task_ids, expected_bad_items, expected_warnings',
134167
[
@@ -1157,3 +1190,14 @@ async def test_task_proxy_remove_from_queues(
11571190

11581191
assert queues_after['default'] == ['1/hidden_control']
11591192
assert queues_after['queue_two'] == ['1/control']
1193+
1194+
1195+
async def test_runahead_offset_start(
1196+
mod_example_flow_2: Scheduler
1197+
) -> None:
1198+
"""Late-start recurrences should not break the runahead limit at start-up.
1199+
1200+
See GitHub #5708
1201+
"""
1202+
task_pool = mod_example_flow_2.pool
1203+
assert task_pool.runahead_limit_point == ISO8601Point('2004')

tests/unit/cycling/test_cycling.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,21 @@
2323
parse_exclusion,
2424
)
2525

26+
from cylc.flow.cycling.integer import (
27+
IntegerPoint,
28+
IntegerSequence,
29+
)
30+
31+
from cylc.flow.cycling.iso8601 import (
32+
ISO8601Point,
33+
ISO8601Sequence,
34+
)
35+
36+
from cylc.flow.cycling.loader import (
37+
INTEGER_CYCLING_TYPE,
38+
ISO8601_CYCLING_TYPE,
39+
)
40+
2641

2742
def test_simple_abstract_class_test():
2843
"""Cannot instantiate abstract classes, they must be defined in
@@ -73,3 +88,86 @@ def test_parse_bad_exclusion(expression):
7388
"""Tests incorrectly formatted exclusions"""
7489
with pytest.raises(Exception):
7590
parse_exclusion(expression)
91+
92+
93+
@pytest.mark.parametrize(
94+
'sequence, wf_start_point, expected',
95+
(
96+
(
97+
('R/2/P2', 1),
98+
None,
99+
[2,4,6,8,10]
100+
),
101+
(
102+
('R/2/P2', 1),
103+
3,
104+
[4,6,8,10,12]
105+
),
106+
),
107+
)
108+
def test_get_first_n_points_integer(
109+
set_cycling_type,
110+
sequence, wf_start_point, expected
111+
):
112+
"""Test sequence get_first_n_points method.
113+
114+
(The method is implemented in the base class).
115+
"""
116+
set_cycling_type(INTEGER_CYCLING_TYPE)
117+
sequence = IntegerSequence(*sequence)
118+
if wf_start_point is not None:
119+
wf_start_point = IntegerPoint(wf_start_point)
120+
expected = [
121+
IntegerPoint(p)
122+
for p in expected
123+
]
124+
assert (
125+
expected == (
126+
sequence.get_first_n_points(
127+
len(expected),
128+
wf_start_point
129+
)
130+
)
131+
)
132+
133+
134+
@pytest.mark.parametrize(
135+
'sequence, wf_start_point, expected',
136+
(
137+
(
138+
('R/2008/P2Y', '2001'),
139+
None,
140+
['2008', '2010', '2012', '2014', '2016']
141+
),
142+
(
143+
('R/2008/P2Y', '2001'),
144+
'2009',
145+
['2010', '2012', '2014', '2016', '2018']
146+
),
147+
),
148+
)
149+
def test_get_first_n_points_iso8601(
150+
set_cycling_type,
151+
sequence, wf_start_point, expected
152+
):
153+
"""Test sequence get_first_n_points method.
154+
155+
(The method is implemented in the base class).
156+
"""
157+
set_cycling_type(ISO8601_CYCLING_TYPE, 'Z')
158+
sequence = ISO8601Sequence(*sequence)
159+
if wf_start_point is not None:
160+
wf_start_point = ISO8601Point(wf_start_point)
161+
expected = [
162+
ISO8601Point(p)
163+
for p in expected
164+
]
165+
166+
assert (
167+
expected == (
168+
sequence.get_first_n_points(
169+
len(expected),
170+
wf_start_point
171+
)
172+
)
173+
)

0 commit comments

Comments
 (0)