Skip to content

Commit 68eb88e

Browse files
authored
Timer: Add support for rearming timer with new interval (#321)
Ugly alternative to #320 >They eat our timers, our start_delays!
2 parents 62c2248 + dc9571c commit 68eb88e

File tree

3 files changed

+92
-3
lines changed

3 files changed

+92
-3
lines changed

RELEASE_NOTES.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# Frequenz channels Release Notes
22

3+
## New Features
4+
5+
- `Timer.reset()` now supports setting the interval and will restart the timer with the new interval.
6+
37
## Bug Fixes
48

59
- `FileWatcher`: Fixed `ready()` method to return False when an error occurs. Before this fix, `select()` (and other code using `ready()`) never detected the `FileWatcher` was stopped and the `select()` loop was continuously waking up to inform the receiver was ready.
10+
11+
- `Timer.stop()` and `Timer.reset()` now immediately stop the timer if it is running. Before this fix, the timer would continue to run until the next interval.

src/frequenz/channels/timer.py

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -523,6 +523,8 @@ def __init__( # pylint: disable=too-many-arguments
523523
See the documentation of `MissedTickPolicy` for details.
524524
"""
525525

526+
self._reset_event = asyncio.Event()
527+
526528
self._loop: asyncio.AbstractEventLoop = (
527529
loop if loop is not None else asyncio.get_running_loop()
528530
)
@@ -584,7 +586,12 @@ def is_running(self) -> bool:
584586
"""Whether the timer is running."""
585587
return not self._stopped
586588

587-
def reset(self, *, start_delay: timedelta = timedelta(0)) -> None:
589+
def reset(
590+
self,
591+
*,
592+
interval: timedelta | None = None,
593+
start_delay: timedelta = timedelta(0),
594+
) -> None:
588595
"""Reset the timer to start timing from now (plus an optional delay).
589596
590597
If the timer was stopped, or not started yet, it will be started.
@@ -593,6 +600,8 @@ def reset(self, *, start_delay: timedelta = timedelta(0)) -> None:
593600
more details.
594601
595602
Args:
603+
interval: The new interval between ticks. If `None`, the current
604+
interval is kept.
596605
start_delay: The delay before the timer should start. This has microseconds
597606
resolution, anything smaller than a microsecond means no delay.
598607
@@ -604,8 +613,16 @@ def reset(self, *, start_delay: timedelta = timedelta(0)) -> None:
604613

605614
if start_delay_ms < 0:
606615
raise ValueError(f"`start_delay` can't be negative, got {start_delay}")
607-
self._stopped = False
616+
617+
if interval is not None:
618+
self._interval = _to_microseconds(interval)
619+
608620
self._next_tick_time = self._now() + start_delay_ms + self._interval
621+
622+
if self.is_running:
623+
self._reset_event.set()
624+
625+
self._stopped = False
609626
self._current_drift = None
610627

611628
def stop(self) -> None:
@@ -621,6 +638,7 @@ def stop(self) -> None:
621638
self._stopped = True
622639
# We need to make sure it's not None, otherwise `ready()` will start it
623640
self._next_tick_time = self._now()
641+
self._reset_event.set()
624642

625643
# We need a noqa here because the docs have a Raises section but the documented
626644
# exceptions are raised indirectly.
@@ -664,7 +682,15 @@ async def ready(self) -> bool: # noqa: DOC502
664682
# could be reset while we are sleeping, in which case we need to recalculate
665683
# the time to the next tick and try again.
666684
while time_to_next_tick > 0:
667-
await asyncio.sleep(time_to_next_tick / 1_000_000)
685+
await next(
686+
asyncio.as_completed(
687+
[
688+
asyncio.sleep(time_to_next_tick / 1_000_000),
689+
self._reset_event.wait(),
690+
]
691+
)
692+
)
693+
self._reset_event.clear()
668694
now = self._now()
669695
time_to_next_tick = self._next_tick_time - now
670696

tests/test_timer.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -558,3 +558,60 @@ async def test_timer_skip_missed_and_drift(
558558
drift = await timer.receive()
559559
assert event_loop.time() == pytest.approx(interval * 14 + tolerance * 3 + 0.001)
560560
assert drift == pytest.approx(timedelta(seconds=0.0))
561+
562+
563+
async def test_timer_reset_with_new_interval(
564+
event_loop: async_solipsism.EventLoop, # pylint: disable=redefined-outer-name
565+
) -> None:
566+
"""Test resetting the timer with a new interval."""
567+
initial_interval = timedelta(seconds=1.0)
568+
new_interval = timedelta(seconds=2.0)
569+
timer = Timer(initial_interval, TriggerAllMissed())
570+
571+
# Wait for the first tick
572+
drift = await timer.receive()
573+
assert drift == timedelta(seconds=0.0)
574+
assert event_loop.time() == pytest.approx(1.0)
575+
576+
# Reset the timer with a new interval
577+
timer.reset(interval=new_interval)
578+
579+
# The next tick should occur after the new interval
580+
drift = await timer.receive()
581+
assert drift == timedelta(seconds=0.0)
582+
assert event_loop.time() == pytest.approx(3.0)
583+
584+
# Ensure the timer continues with the new interval
585+
drift = await timer.receive()
586+
assert drift == timedelta(seconds=0.0)
587+
assert event_loop.time() == pytest.approx(5.0)
588+
589+
590+
async def test_timer_immediate_interruption_on_reset() -> None:
591+
"""Test that the timer is interrupted immediately upon reset."""
592+
timer1 = Timer(timedelta(seconds=5.0), TriggerAllMissed())
593+
timer2 = Timer(timedelta(seconds=1.0), TriggerAllMissed())
594+
timer3 = Timer(timedelta(seconds=4.0), TriggerAllMissed())
595+
596+
timer_trigger_order = []
597+
598+
async def reset_timer1() -> None:
599+
await timer2.receive()
600+
timer_trigger_order.append(2)
601+
timer1.reset(interval=timedelta(seconds=1.0))
602+
timer2.stop()
603+
604+
async def receive_timer2() -> None:
605+
await timer1.receive()
606+
timer_trigger_order.append(1)
607+
608+
async def receive_timer3() -> None:
609+
await timer3.receive()
610+
timer_trigger_order.append(3)
611+
612+
task1 = asyncio.create_task(reset_timer1())
613+
task2 = asyncio.create_task(receive_timer2())
614+
task3 = asyncio.create_task(receive_timer3())
615+
616+
await asyncio.wait([task1, task2, task3])
617+
assert timer_trigger_order == [2, 1, 3]

0 commit comments

Comments
 (0)