Skip to content

Commit 150bdf1

Browse files
authored
Timer: Remove periodic() and timeout() (#264)
The names are just too confusing and we'll never find a name that can convey all the intricacies of timers in the async world, so it is better to just remove `periodic()` and `timeout()` and force users to pass the missing ticks policies manually. Fixes #253.
2 parents 2267927 + cbccb5c commit 150bdf1

File tree

8 files changed

+54
-301
lines changed

8 files changed

+54
-301
lines changed

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ from frequenz.channels import (
110110
select,
111111
selected_from,
112112
)
113-
from frequenz.channels.timer import Timer
113+
from frequenz.channels.timer import SkipMissedAndDrift, Timer, TriggerAllMissed
114114

115115

116116
class Command(Enum):
@@ -135,7 +135,7 @@ async def send(
135135
) -> None:
136136
"""Send a counter value every second, until a stop command is received."""
137137
print(f"{sender}: Starting")
138-
timer = Timer.periodic(timedelta(seconds=1.0))
138+
timer = Timer(timedelta(seconds=1.0), TriggerAllMissed())
139139
counter = 0
140140
async for selected in select(timer, control_command):
141141
if selected_from(selected, timer):
@@ -163,7 +163,7 @@ async def receive(
163163
) -> None:
164164
"""Receive data from multiple channels, until no more data is received for 2 seconds."""
165165
print("receive: Starting")
166-
timer = Timer.timeout(timedelta(seconds=2.0))
166+
timer = Timer(timedelta(seconds=2.0), SkipMissedAndDrift())
167167
print(f"{timer=}")
168168
merged = merge(*receivers)
169169
async for selected in select(merged, timer, control_command):

RELEASE_NOTES.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,14 @@
9595

9696
This was removed alongside `Peekable` (it was only raised when using a `Receiver` that was converted into a `Peekable`).
9797

98+
- `Timer`:
99+
100+
- `periodic()` and `timeout()`: The names proved to be too confusing, please use `Timer()` and pass a missing ticks policy explicitly instead. In general you can update your code by doing:
101+
102+
* `Timer.periodic(interval)` / `Timer.periodic(interval, skip_missed_ticks=True)` -> `Timer(interval, TriggerAllMissed())`
103+
* `Timer.periodic(interval, skip_missed_ticks=False)` -> `Timer(interval, SkipMissedAndResync())`
104+
* `Timer.timeout(interval)` -> `Timer(interval, SkipMissedAndDrift())`
105+
98106
* `util`
99107

100108
The entire `util` package was removed and its symbols were either moved to the top-level package or to their own public modules (as noted above).

docs/user-guide/utilities/timers.md

Lines changed: 2 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@
4444
show_root_toc_entry: false
4545
show_source: false
4646

47-
## High-level Interface
47+
## Timer
4848

4949
::: frequenz.channels.timer.Timer
5050
options:
@@ -55,56 +55,7 @@
5555
show_root_toc_entry: false
5656
show_source: false
5757

58-
### Periodic Timers
59-
60-
::: frequenz.channels.timer.Timer.periodic
61-
options:
62-
inherited_members: []
63-
members: []
64-
show_bases: false
65-
show_root_heading: false
66-
show_root_toc_entry: false
67-
show_source: false
68-
show_docstring_attributes: false
69-
show_docstring_functions: false
70-
show_docstring_classes: false
71-
show_docstring_other_parameters: false
72-
show_docstring_parameters: false
73-
show_docstring_raises: false
74-
show_docstring_receives: false
75-
show_docstring_returns: false
76-
show_docstring_warns: false
77-
show_docstring_yields: false
78-
79-
### Timeouts
80-
81-
::: frequenz.channels.timer.Timer.timeout
82-
options:
83-
inherited_members: []
84-
members: []
85-
show_bases: false
86-
show_root_heading: false
87-
show_root_toc_entry: false
88-
show_source: false
89-
show_docstring_attributes: false
90-
show_docstring_functions: false
91-
show_docstring_classes: false
92-
show_docstring_other_parameters: false
93-
show_docstring_parameters: false
94-
show_docstring_raises: false
95-
show_docstring_receives: false
96-
show_docstring_returns: false
97-
show_docstring_warns: false
98-
show_docstring_yields: false
99-
100-
## Low-level Interface
101-
102-
A [`Timer`][frequenz.channels.timer.Timer] can be created using an arbitrary missed
103-
ticks policy by calling the [low-level
104-
constructor][frequenz.channels.timer.Timer.__init__] and passing the policy via the
105-
[`missed_tick_policy`][frequenz.channels.timer.Timer.missed_tick_policy] argument.
106-
107-
### Custom Missed Tick Policies
58+
## Custom Missed Tick Policies
10859

10960
::: frequenz.channels.timer.MissedTickPolicy
11061
options:

src/frequenz/channels/_select.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -400,10 +400,10 @@ async def select(*receivers: Receiver[Any]) -> AsyncIterator[Selected[Any]]:
400400
from typing import assert_never
401401
402402
from frequenz.channels import ReceiverStoppedError, select, selected_from
403-
from frequenz.channels.timer import Timer
403+
from frequenz.channels.timer import SkipMissedAndDrift, Timer, TriggerAllMissed
404404
405-
timer1 = Timer.periodic(datetime.timedelta(seconds=1))
406-
timer2 = Timer.timeout(datetime.timedelta(seconds=0.5))
405+
timer1 = Timer(datetime.timedelta(seconds=1), TriggerAllMissed())
406+
timer2 = Timer(datetime.timedelta(seconds=0.5), SkipMissedAndDrift())
407407
408408
async for selected in select(timer1, timer2):
409409
if selected_from(selected, timer1):

src/frequenz/channels/timer.py

Lines changed: 25 additions & 193 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,18 @@
55
66
# Quick Start
77
8+
Info: Important
9+
This quick start is provided to have a quick feeling of how to use this module, but
10+
it is extremely important to understand how timers behave when they are delayed.
11+
12+
We recommend emphatically to read about [missed ticks and
13+
drifting](#missed-ticks-and-drifting) before using timers in production.
14+
815
If you need to do something as periodically as possible (avoiding
9-
[drifts](#missed-ticks-and-drifting)), you can use use
10-
a [`periodic()`][frequenz.channels.timer.Timer.periodic] timer.
16+
[drifts](#missed-ticks-and-drifting)), you can use
17+
a [`Timer`][frequenz.channels.timer.Timer] like this:
1118
12-
Example: Periodic Timer
19+
Example: Periodic Timer Example
1320
```python
1421
import asyncio
1522
from datetime import datetime, timedelta
@@ -18,18 +25,23 @@
1825
1926
2027
async def main() -> None:
21-
async for drift in Timer.periodic(timedelta(seconds=1.0)):
28+
async for drift in Timer(timedelta(seconds=1.0), TriggerAllMissed()):
2229
print(f"The timer has triggered at {datetime.now()} with a drift of {drift}")
2330
2431
2532
asyncio.run(main())
2633
```
2734
35+
This timer will tick as close as every second as possible, even if the loop is busy
36+
doing something else for a good amount of time. In extreme cases, if the loop was
37+
busy for a few seconds, the timer will trigger a few times in a row to catch up, one
38+
for every missed tick.
39+
2840
If, instead, you need a timeout, for example to abort waiting for other receivers after
29-
a certain amount of time, you can use
30-
a [`timeout()`][frequenz.channels.timer.Timer.timeout] timer.
41+
a certain amount of time, you can use a [`Timer`][frequenz.channels.timer.Timer] like
42+
this:
3143
32-
Example: Timeout
44+
Example: Timeout Example
3345
```python
3446
import asyncio
3547
from datetime import timedelta
@@ -42,7 +54,7 @@ async def main() -> None:
4254
channel = Anycast[int](name="data-channel")
4355
data_receiver = channel.new_receiver()
4456
45-
timer = Timer.timeout(timedelta(seconds=1.0))
57+
timer = Timer(timedelta(seconds=1.0), SkipMissedAndDrift())
4658
4759
async for selected in select(data_receiver, timer):
4860
if selected_from(selected, data_receiver):
@@ -57,13 +69,10 @@ async def main() -> None:
5769
asyncio.run(main())
5870
```
5971
60-
This timer will *rearm* itself automatically after it was triggered, so it will trigger
61-
again after the selected interval, no matter what the current drift was.
62-
63-
Tip:
64-
It is extremely important to understand how timers behave when they are
65-
delayed, we recommned emphatically to read about [missed ticks and
66-
drifting](#missed-ticks-and-drifting) before using timers in production.
72+
This timer will *rearm* itself automatically after it was triggered, so it will
73+
trigger again after the selected interval, no matter what the current drift was. So
74+
if the loop was busy for a few seconds, the timer will trigger immediately and then
75+
wait for another second before triggering again. The missed ticks are skipped.
6776
6877
# Missed Ticks And Drifting
6978
@@ -472,14 +481,6 @@ class Timer(Receiver[timedelta]):
472481
[`missed_tick_policy`][frequenz.channels.timer.Timer.missed_tick_policy]. Missing
473482
ticks might or might not trigger a message and the drift could be accumulated or not
474483
depending on the chosen policy.
475-
476-
For the most common cases, a specialized constructor is provided:
477-
478-
* [`periodic()`][frequenz.channels.timer.Timer.periodic]:
479-
{{docstring_summary("frequenz.channels.timer.Timer.periodic")}}
480-
481-
* [`timeout()`][frequenz.channels.timer.Timer.timeout]:
482-
{{docstring_summary("frequenz.channels.timer.Timer.timeout")}}
483484
"""
484485

485486
def __init__( # pylint: disable=too-many-arguments
@@ -494,7 +495,7 @@ def __init__( # pylint: disable=too-many-arguments
494495
) -> None:
495496
"""Create an instance.
496497
497-
See the class documentation for details.
498+
See the [class documentation][frequenz.channels.timer.Timer] for details.
498499
499500
Args:
500501
interval: The time between timer ticks. Must be at least
@@ -580,175 +581,6 @@ def __init__( # pylint: disable=too-many-arguments
580581
if auto_start:
581582
self.reset(start_delay=start_delay)
582583

583-
# We need a noqa here because the docs have a Raises section but the documented
584-
# exceptions are raised indirectly.
585-
@classmethod
586-
def timeout( # noqa: DOC502
587-
cls,
588-
delay: timedelta,
589-
/,
590-
*,
591-
auto_start: bool = True,
592-
start_delay: timedelta = timedelta(0),
593-
loop: asyncio.AbstractEventLoop | None = None,
594-
) -> Timer:
595-
"""Create a timer useful for tracking timeouts.
596-
597-
A [timeout][frequenz.channels.timer.Timer.timeout] is
598-
a [`Timer`][frequenz.channels.timer.Timer] that
599-
[resets][frequenz.channels.timer.Timer.reset] automatically after it triggers,
600-
so it will trigger again after the selected interval, no matter what the current
601-
drift was. This means timeout timers will accumulate drift.
602-
603-
Tip:
604-
Timeouts are a shortcut to create
605-
a [`Timer`][frequenz.channels.timer.Timer] with the
606-
[`SkipMissedAndDrift`][frequenz.channels.timer.SkipMissedAndDrift] policy.
607-
608-
Example: Timeout example
609-
```python
610-
import asyncio
611-
from datetime import timedelta
612-
613-
from frequenz.channels import Anycast, select, selected_from
614-
from frequenz.channels.timer import Timer
615-
616-
617-
async def main() -> None:
618-
channel = Anycast[int](name="data-channel")
619-
data_receiver = channel.new_receiver()
620-
621-
timer = Timer.timeout(timedelta(seconds=1.0))
622-
623-
async for selected in select(data_receiver, timer):
624-
if selected_from(selected, data_receiver):
625-
print(f"Received data: {selected.value}")
626-
elif selected_from(selected, timer):
627-
drift = selected.value
628-
print(f"No data received for {timer.interval + drift} seconds, giving up")
629-
break
630-
631-
632-
asyncio.run(main())
633-
```
634-
635-
Args:
636-
delay: The time until the timer ticks. Must be at least
637-
1 microsecond.
638-
auto_start: Whether the timer should be started when the
639-
instance is created. This can only be `True` if there is
640-
already a running loop or an explicit `loop` that is running
641-
was passed.
642-
start_delay: The delay before the timer should start. If `auto_start` is
643-
`False`, an exception is raised. This has microseconds resolution,
644-
anything smaller than a microsecond means no delay.
645-
loop: The event loop to use to track time. If `None`,
646-
`asyncio.get_running_loop()` will be used.
647-
648-
Returns:
649-
The timer instance.
650-
651-
Raises:
652-
RuntimeError: if it was called without a loop and there is no
653-
running loop.
654-
ValueError: if `interval` is not positive or is smaller than 1
655-
microsecond; if `start_delay` is negative or `start_delay` was specified
656-
but `auto_start` is `False`.
657-
"""
658-
return Timer(
659-
delay,
660-
SkipMissedAndDrift(delay_tolerance=timedelta(0)),
661-
auto_start=auto_start,
662-
start_delay=start_delay,
663-
loop=loop,
664-
)
665-
666-
# We need a noqa here because the docs have a Raises section but the documented
667-
# exceptions are raised indirectly.
668-
@classmethod
669-
def periodic( # noqa: DOC502 pylint: disable=too-many-arguments
670-
cls,
671-
period: timedelta,
672-
/,
673-
*,
674-
skip_missed_ticks: bool = False,
675-
auto_start: bool = True,
676-
start_delay: timedelta = timedelta(0),
677-
loop: asyncio.AbstractEventLoop | None = None,
678-
) -> Timer:
679-
"""Create a periodic timer.
680-
681-
A [periodic timer][frequenz.channels.timer.Timer.periodic] is
682-
a [`Timer`][frequenz.channels.timer.Timer] that tries as hard as possible to
683-
trigger at regular intervals. This means that if the timer is delayed for any
684-
reason, it will trigger immediately and then try to catch up with the original
685-
schedule.
686-
687-
Optionally, a periodic timer can be configured to skip missed ticks and re-sync
688-
with the original schedule (`skip_missed_ticks` argument). This could be useful
689-
if you want the timer is as periodic as possible but if there are big delays you
690-
don't end up with big bursts.
691-
692-
Tip:
693-
Periodic timers are a shortcut to create
694-
a [`Timer`][frequenz.channels.timer.Timer] with either the
695-
[`TriggerAllMissed`][frequenz.channels.timer.TriggerAllMissed] policy (when
696-
`skip_missed_ticks` is `False`) or
697-
[`SkipMissedAndResync`][frequenz.channels.timer.SkipMissedAndResync]
698-
otherwise.
699-
700-
Example:
701-
```python
702-
import asyncio
703-
from datetime import datetime, timedelta
704-
705-
from frequenz.channels.timer import Timer
706-
707-
708-
async def main() -> None:
709-
async for drift in Timer.periodic(timedelta(seconds=1.0)):
710-
print(f"The timer has triggered at {datetime.now()} with a drift of {drift}")
711-
712-
713-
asyncio.run(main())
714-
```
715-
716-
Args:
717-
period: The time between timer ticks. Must be at least
718-
1 microsecond.
719-
skip_missed_ticks: Whether to skip missed ticks or trigger them
720-
all until it catches up.
721-
auto_start: Whether the timer should be started when the
722-
instance is created. This can only be `True` if there is
723-
already a running loop or an explicit `loop` that is running
724-
was passed.
725-
start_delay: The delay before the timer should start. If `auto_start` is
726-
`False`, an exception is raised. This has microseconds resolution,
727-
anything smaller than a microsecond means no delay.
728-
loop: The event loop to use to track time. If `None`,
729-
`asyncio.get_running_loop()` will be used.
730-
731-
Returns:
732-
The timer instance.
733-
734-
Raises:
735-
RuntimeError: if it was called without a loop and there is no
736-
running loop.
737-
ValueError: if `interval` is not positive or is smaller than 1
738-
microsecond; if `start_delay` is negative or `start_delay` was specified
739-
but `auto_start` is `False`.
740-
"""
741-
missed_tick_policy = (
742-
SkipMissedAndResync() if skip_missed_ticks else TriggerAllMissed()
743-
)
744-
return Timer(
745-
period,
746-
missed_tick_policy,
747-
auto_start=auto_start,
748-
start_delay=start_delay,
749-
loop=loop,
750-
)
751-
752584
@property
753585
def interval(self) -> timedelta:
754586
"""The interval between timer ticks.

0 commit comments

Comments
 (0)