Skip to content

Commit fb83a3f

Browse files
authored
An optional tick_at_start parameter has been added to Timer (#372)
When `True`, the timer will trigger immediately after starting, and then wait for the interval before triggering again.
2 parents 1f58302 + c309dde commit fb83a3f

File tree

3 files changed

+77
-3
lines changed

3 files changed

+77
-3
lines changed

RELEASE_NOTES.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111
## New Features
1212

13-
<!-- Here goes the main new features and examples or instructions on how to use them -->
13+
- An optional `tick_at_start` parameter has been added to `Timer`. When `True`, the timer will trigger immediately after starting, and then wait for the interval before triggering again.
1414

1515
## Bug Fixes
1616

src/frequenz/channels/timer.py

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -477,6 +477,7 @@ def __init__( # noqa: DOC503 pylint: disable=too-many-arguments
477477
*,
478478
auto_start: bool = True,
479479
start_delay: timedelta = timedelta(0),
480+
tick_at_start: bool = False,
480481
loop: asyncio.AbstractEventLoop | None = None,
481482
) -> None:
482483
"""Initialize this timer.
@@ -497,6 +498,13 @@ def __init__( # noqa: DOC503 pylint: disable=too-many-arguments
497498
start_delay: The delay before the timer should start. If `auto_start` is
498499
`False`, an exception is raised. This has microseconds resolution,
499500
anything smaller than a microsecond means no delay.
501+
tick_at_start: When `True`, the timer will trigger immediately after
502+
starting, and then wait for the interval before triggering
503+
again. When `False`, the timer will wait the interval before
504+
triggering for the first time. If `auto_start` is `False` and
505+
this is `True`, an exception is raised. If a `start_delay` is
506+
specified and this is `True`, the first trigger will be immediately
507+
after the `start_delay`.
500508
loop: The event loop to use to track time. If `None`,
501509
`asyncio.get_running_loop()` will be used.
502510
@@ -517,6 +525,9 @@ def __init__( # noqa: DOC503 pylint: disable=too-many-arguments
517525
"`auto_start` must be `True` if a `start_delay` is specified"
518526
)
519527

528+
if tick_at_start is True and auto_start is False:
529+
raise ValueError("`auto_start` must be `True` if `tick_at_start` is `True`")
530+
520531
self._interval: int = _to_microseconds(interval)
521532
"""The time to between timer ticks."""
522533

@@ -567,7 +578,7 @@ def __init__( # noqa: DOC503 pylint: disable=too-many-arguments
567578
"""
568579

569580
if auto_start:
570-
self.reset(start_delay=start_delay)
581+
self.reset(start_delay=start_delay, tick_at_start=tick_at_start)
571582

572583
@property
573584
def interval(self) -> timedelta:
@@ -595,6 +606,7 @@ def reset( # noqa: DOC503
595606
*,
596607
interval: timedelta | None = None,
597608
start_delay: timedelta = timedelta(0),
609+
tick_at_start: bool = False,
598610
) -> None:
599611
"""Reset the timer to start timing from now (plus an optional delay).
600612
@@ -608,6 +620,12 @@ def reset( # noqa: DOC503
608620
interval is kept.
609621
start_delay: The delay before the timer should start. This has microseconds
610622
resolution, anything smaller than a microsecond means no delay.
623+
tick_at_start: When `True`, the timer will trigger immediately after
624+
starting, and then wait for the interval before triggering
625+
again. When `False`, the timer will wait the interval before
626+
triggering for the first time. If a `start_delay` is specified
627+
and this is `True`, the first trigger will be immediately after
628+
the `start_delay`.
611629
612630
Raises:
613631
RuntimeError: If it was called without a running loop.
@@ -621,7 +639,10 @@ def reset( # noqa: DOC503
621639
if interval is not None:
622640
self._interval = _to_microseconds(interval)
623641

624-
self._next_tick_time = self._now() + start_delay_ms + self._interval
642+
self._next_tick_time = self._now() + start_delay_ms
643+
644+
if not tick_at_start:
645+
self._next_tick_time += self._interval
625646

626647
if self.is_running:
627648
self._reset_event.set()

tests/test_timer.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
import asyncio
88
import enum
9+
import re
910
from datetime import timedelta
1011

1112
import async_solipsism
@@ -331,6 +332,18 @@ async def test_timer_construction_wrong_args() -> None:
331332
loop=None,
332333
)
333334

335+
with pytest.raises(
336+
ValueError,
337+
match=re.escape("`auto_start` must be `True` if `tick_at_start` is `True`"),
338+
):
339+
_ = Timer(
340+
timedelta(seconds=5.0),
341+
SkipMissedAndResync(),
342+
auto_start=False,
343+
tick_at_start=True,
344+
loop=None,
345+
)
346+
334347

335348
async def test_timer_close_receiver() -> None:
336349
"""Test the autostart of a periodic timer."""
@@ -384,6 +397,46 @@ async def test_timer_autostart_with_delay() -> None:
384397
assert event_loop.time() == pytest.approx(2.5)
385398

386399

400+
async def test_timer_autostart_with_tick_at_start() -> None:
401+
"""Test the autostart of a periodic timer with a tick at start."""
402+
event_loop = asyncio.get_running_loop()
403+
404+
timer = Timer(timedelta(seconds=1.0), TriggerAllMissed(), tick_at_start=True)
405+
406+
# The first tick should be at 0.0, without any delay.
407+
drift = await timer.receive()
408+
assert drift == pytest.approx(timedelta(seconds=0.0))
409+
assert event_loop.time() == pytest.approx(0.0)
410+
411+
# The next tick should be at 1.0
412+
drift = await timer.receive()
413+
assert drift == pytest.approx(timedelta(seconds=0.0))
414+
assert event_loop.time() == pytest.approx(1.0)
415+
416+
417+
async def test_timer_autostart_with_delay_and_tick_at_start() -> None:
418+
"""Test the autostart of a periodic timer with a start delay and tick at start."""
419+
event_loop = asyncio.get_running_loop()
420+
421+
timer = Timer(
422+
timedelta(seconds=1.0),
423+
TriggerAllMissed(),
424+
tick_at_start=True,
425+
start_delay=timedelta(seconds=0.5),
426+
)
427+
428+
# The first tick should be at 0.5, as soon as the start delay is over.
429+
await asyncio.sleep(0.3)
430+
drift = await timer.receive()
431+
assert drift == pytest.approx(timedelta(seconds=0.0))
432+
assert event_loop.time() == pytest.approx(0.5)
433+
434+
# The next tick should be at 1.5
435+
drift = await timer.receive()
436+
assert drift == pytest.approx(timedelta(seconds=0.0))
437+
assert event_loop.time() == pytest.approx(1.5)
438+
439+
387440
class _StartMethod(enum.Enum):
388441
RESET = enum.auto()
389442
RECEIVE = enum.auto()

0 commit comments

Comments
 (0)