diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 96a0240b..ef8bce74 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -10,7 +10,7 @@ ## New Features - +- 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. ## Bug Fixes diff --git a/src/frequenz/channels/timer.py b/src/frequenz/channels/timer.py index 998430e9..73a76534 100644 --- a/src/frequenz/channels/timer.py +++ b/src/frequenz/channels/timer.py @@ -477,6 +477,7 @@ def __init__( # noqa: DOC503 pylint: disable=too-many-arguments *, auto_start: bool = True, start_delay: timedelta = timedelta(0), + tick_at_start: bool = False, loop: asyncio.AbstractEventLoop | None = None, ) -> None: """Initialize this timer. @@ -497,6 +498,13 @@ def __init__( # noqa: DOC503 pylint: disable=too-many-arguments start_delay: The delay before the timer should start. If `auto_start` is `False`, an exception is raised. This has microseconds resolution, anything smaller than a microsecond means no delay. + tick_at_start: When `True`, the timer will trigger immediately after + starting, and then wait for the interval before triggering + again. When `False`, the timer will wait the interval before + triggering for the first time. If `auto_start` is `False` and + this is `True`, an exception is raised. If a `start_delay` is + specified and this is `True`, the first trigger will be immediately + after the `start_delay`. loop: The event loop to use to track time. If `None`, `asyncio.get_running_loop()` will be used. @@ -517,6 +525,9 @@ def __init__( # noqa: DOC503 pylint: disable=too-many-arguments "`auto_start` must be `True` if a `start_delay` is specified" ) + if tick_at_start is True and auto_start is False: + raise ValueError("`auto_start` must be `True` if `tick_at_start` is `True`") + self._interval: int = _to_microseconds(interval) """The time to between timer ticks.""" @@ -567,7 +578,7 @@ def __init__( # noqa: DOC503 pylint: disable=too-many-arguments """ if auto_start: - self.reset(start_delay=start_delay) + self.reset(start_delay=start_delay, tick_at_start=tick_at_start) @property def interval(self) -> timedelta: @@ -595,6 +606,7 @@ def reset( # noqa: DOC503 *, interval: timedelta | None = None, start_delay: timedelta = timedelta(0), + tick_at_start: bool = False, ) -> None: """Reset the timer to start timing from now (plus an optional delay). @@ -608,6 +620,12 @@ def reset( # noqa: DOC503 interval is kept. start_delay: The delay before the timer should start. This has microseconds resolution, anything smaller than a microsecond means no delay. + tick_at_start: When `True`, the timer will trigger immediately after + starting, and then wait for the interval before triggering + again. When `False`, the timer will wait the interval before + triggering for the first time. If a `start_delay` is specified + and this is `True`, the first trigger will be immediately after + the `start_delay`. Raises: RuntimeError: If it was called without a running loop. @@ -621,7 +639,10 @@ def reset( # noqa: DOC503 if interval is not None: self._interval = _to_microseconds(interval) - self._next_tick_time = self._now() + start_delay_ms + self._interval + self._next_tick_time = self._now() + start_delay_ms + + if not tick_at_start: + self._next_tick_time += self._interval if self.is_running: self._reset_event.set() diff --git a/tests/test_timer.py b/tests/test_timer.py index 55295b29..cad7c4d9 100644 --- a/tests/test_timer.py +++ b/tests/test_timer.py @@ -6,6 +6,7 @@ import asyncio import enum +import re from datetime import timedelta import async_solipsism @@ -331,6 +332,18 @@ async def test_timer_construction_wrong_args() -> None: loop=None, ) + with pytest.raises( + ValueError, + match=re.escape("`auto_start` must be `True` if `tick_at_start` is `True`"), + ): + _ = Timer( + timedelta(seconds=5.0), + SkipMissedAndResync(), + auto_start=False, + tick_at_start=True, + loop=None, + ) + async def test_timer_close_receiver() -> None: """Test the autostart of a periodic timer.""" @@ -384,6 +397,46 @@ async def test_timer_autostart_with_delay() -> None: assert event_loop.time() == pytest.approx(2.5) +async def test_timer_autostart_with_tick_at_start() -> None: + """Test the autostart of a periodic timer with a tick at start.""" + event_loop = asyncio.get_running_loop() + + timer = Timer(timedelta(seconds=1.0), TriggerAllMissed(), tick_at_start=True) + + # The first tick should be at 0.0, without any delay. + drift = await timer.receive() + assert drift == pytest.approx(timedelta(seconds=0.0)) + assert event_loop.time() == pytest.approx(0.0) + + # The next tick should be at 1.0 + drift = await timer.receive() + assert drift == pytest.approx(timedelta(seconds=0.0)) + assert event_loop.time() == pytest.approx(1.0) + + +async def test_timer_autostart_with_delay_and_tick_at_start() -> None: + """Test the autostart of a periodic timer with a start delay and tick at start.""" + event_loop = asyncio.get_running_loop() + + timer = Timer( + timedelta(seconds=1.0), + TriggerAllMissed(), + tick_at_start=True, + start_delay=timedelta(seconds=0.5), + ) + + # The first tick should be at 0.5, as soon as the start delay is over. + await asyncio.sleep(0.3) + drift = await timer.receive() + assert drift == pytest.approx(timedelta(seconds=0.0)) + assert event_loop.time() == pytest.approx(0.5) + + # The next tick should be at 1.5 + drift = await timer.receive() + assert drift == pytest.approx(timedelta(seconds=0.0)) + assert event_loop.time() == pytest.approx(1.5) + + class _StartMethod(enum.Enum): RESET = enum.auto() RECEIVE = enum.auto()