Skip to content

Commit 54721a8

Browse files
authored
Backport timer reset() fix (#246)
- Clear release notes - Add test for `reset()` while a timer is being waited on - Fix reseting the timer while it is being waited on - Update the release notes - Fix typo in comment Fixes #242.
2 parents 19487b8 + 5fb38de commit 54721a8

File tree

3 files changed

+42
-89
lines changed

3 files changed

+42
-89
lines changed

RELEASE_NOTES.md

Lines changed: 2 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -1,91 +1,5 @@
11
# Frequenz Channels Release Notes
22

3-
## Summary
3+
## Bug Fixes
44

5-
The minimum Python supported version was bumped to 3.11 and the `Select` class replaced by the new `select()` function.
6-
7-
## Upgrading
8-
9-
* The minimum supported Python version was bumped to 3.11, downstream projects will need to upgrade too to use this version.
10-
11-
* The `Select` class was replaced by a new `select()` function, with the following improvements:
12-
13-
* Type-safe: proper type hinting by using the new helper type guard `selected_from()`.
14-
* Fixes potential starvation issues.
15-
* Simplifies the interface by providing values one-by-one.
16-
* Guarantees there are no dangling tasks left behind when used as an async context manager.
17-
18-
This new function is an [async iterator](https://docs.python.org/3.11/library/collections.abc.html#collections.abc.AsyncIterator), and makes sure no dangling tasks are left behind after a select loop is done.
19-
20-
Example:
21-
```python
22-
timer1 = Timer.periodic(datetime.timedelta(seconds=1))
23-
timer2 = Timer.timeout(datetime.timedelta(seconds=0.5))
24-
25-
async for selected in select(timer1, timer2):
26-
if selected_from(selected, timer1):
27-
# Beware: `selected.value` might raise an exception, you can always
28-
# check for exceptions with `selected.exception` first or use
29-
# a try-except block. You can also quickly check if the receiver was
30-
# stopped and let any other unexpected exceptions bubble up.
31-
if selected.was_stopped():
32-
print("timer1 was stopped")
33-
continue
34-
print(f"timer1: now={datetime.datetime.now()} drift={selected.value}")
35-
timer2.stop()
36-
elif selected_from(selected, timer2):
37-
# Explicitly handling of exceptions
38-
match selected.exception:
39-
case ReceiverStoppedError():
40-
print("timer2 was stopped")
41-
case Exception() as exception:
42-
print(f"timer2: exception={exception}")
43-
case None:
44-
# All good, no exception, we can use `selected.value` safely
45-
print(
46-
f"timer2: now={datetime.datetime.now()} "
47-
f"drift={selected.value}"
48-
)
49-
case _ as unhanded:
50-
assert_never(unhanded)
51-
else:
52-
# This is not necessary, as select() will check for exhaustiveness, but
53-
# it is good practice to have it in case you forgot to handle a new
54-
# receiver added to `select()` at a later point in time.
55-
assert False
56-
```
57-
58-
## New Features
59-
60-
* A new `select()` function was added, please look at the *Upgrading* section for details.
61-
62-
* A new `Event` utility receiver was added.
63-
64-
This receiver can be made ready manually. It is mainly useful for testing but can also become handy in scenarios where a simple, on-off signal needs to be sent to a select loop for example.
65-
66-
Example:
67-
68-
```python
69-
import asyncio
70-
from frequenz.channels import Receiver
71-
from frequenz.channels.util import Event, select, selected_from
72-
73-
other_receiver: Receiver[int] = ...
74-
exit_event = Event()
75-
76-
async def exit_after_10_seconds() -> None:
77-
asyncio.sleep(10)
78-
exit_event.set()
79-
80-
asyncio.ensure_future(exit_after_10_seconds())
81-
82-
async for selected in select(exit_event, other_receiver):
83-
if selected_from(selected, exit_event):
84-
break
85-
if selected_from(selected, other_receiver):
86-
print(selected.value)
87-
else:
88-
assert False, "Unknow receiver selected"
89-
```
90-
91-
* The `Timer` class now has more descriptive `__str__` and `__repr__` methods.
5+
* `Timer`: Fix bug that was causing calls to `reset()` to not reset the timer, if the timer was already being awaited.

src/frequenz/channels/util/_timer.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -652,10 +652,15 @@ async def ready(self) -> bool:
652652

653653
now = self._now()
654654
time_to_next_tick = self._next_tick_time - now
655+
655656
# If we didn't reach the tick yet, sleep until we do.
656-
if time_to_next_tick > 0:
657+
# We need to do this in a loop also reacting to the reset event, as the timer
658+
# could be reset while we are sleeping, in which case we need to recalculate
659+
# the time to the next tick and try again.
660+
while time_to_next_tick > 0:
657661
await asyncio.sleep(time_to_next_tick / 1_000_000)
658662
now = self._now()
663+
time_to_next_tick = self._next_tick_time - now
659664

660665
# If a stop was explicitly requested during the sleep, we bail out.
661666
if self._stopped:
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# License: MIT
2+
# Copyright © 2023 Frequenz Energy-as-a-Service GmbH
3+
4+
"""Integration tests for the timer."""
5+
6+
7+
import asyncio
8+
from datetime import timedelta
9+
10+
import async_solipsism
11+
import pytest
12+
13+
from frequenz.channels.util import Timer
14+
15+
16+
@pytest.mark.integration
17+
async def test_timer_timeout_reset(
18+
event_loop: async_solipsism.EventLoop, # pylint: disable=redefined-outer-name
19+
) -> None:
20+
"""Test that the receiving is properly adjusted after a reset."""
21+
22+
async def timer_wait(timer: Timer) -> None:
23+
await timer.receive()
24+
25+
async with asyncio.timeout(2.0):
26+
async with asyncio.TaskGroup() as task_group:
27+
timer = Timer.timeout(timedelta(seconds=1.0))
28+
start_time = event_loop.time()
29+
task_group.create_task(timer_wait(timer))
30+
await asyncio.sleep(0.5)
31+
timer.reset()
32+
33+
run_time = event_loop.time() - start_time
34+
assert run_time >= 1.5

0 commit comments

Comments
 (0)