Skip to content

Commit

Permalink
Timer: Remove periodic() and timeout() (#264)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
llucax authored Jan 16, 2024
2 parents 2267927 + cbccb5c commit 150bdf1
Show file tree
Hide file tree
Showing 8 changed files with 54 additions and 301 deletions.
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ from frequenz.channels import (
select,
selected_from,
)
from frequenz.channels.timer import Timer
from frequenz.channels.timer import SkipMissedAndDrift, Timer, TriggerAllMissed


class Command(Enum):
Expand All @@ -135,7 +135,7 @@ async def send(
) -> None:
"""Send a counter value every second, until a stop command is received."""
print(f"{sender}: Starting")
timer = Timer.periodic(timedelta(seconds=1.0))
timer = Timer(timedelta(seconds=1.0), TriggerAllMissed())
counter = 0
async for selected in select(timer, control_command):
if selected_from(selected, timer):
Expand Down Expand Up @@ -163,7 +163,7 @@ async def receive(
) -> None:
"""Receive data from multiple channels, until no more data is received for 2 seconds."""
print("receive: Starting")
timer = Timer.timeout(timedelta(seconds=2.0))
timer = Timer(timedelta(seconds=2.0), SkipMissedAndDrift())
print(f"{timer=}")
merged = merge(*receivers)
async for selected in select(merged, timer, control_command):
Expand Down
8 changes: 8 additions & 0 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,14 @@

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

- `Timer`:

- `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:

* `Timer.periodic(interval)` / `Timer.periodic(interval, skip_missed_ticks=True)` -> `Timer(interval, TriggerAllMissed())`
* `Timer.periodic(interval, skip_missed_ticks=False)` -> `Timer(interval, SkipMissedAndResync())`
* `Timer.timeout(interval)` -> `Timer(interval, SkipMissedAndDrift())`

* `util`

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).
Expand Down
53 changes: 2 additions & 51 deletions docs/user-guide/utilities/timers.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@
show_root_toc_entry: false
show_source: false

## High-level Interface
## Timer

::: frequenz.channels.timer.Timer
options:
Expand All @@ -55,56 +55,7 @@
show_root_toc_entry: false
show_source: false

### Periodic Timers

::: frequenz.channels.timer.Timer.periodic
options:
inherited_members: []
members: []
show_bases: false
show_root_heading: false
show_root_toc_entry: false
show_source: false
show_docstring_attributes: false
show_docstring_functions: false
show_docstring_classes: false
show_docstring_other_parameters: false
show_docstring_parameters: false
show_docstring_raises: false
show_docstring_receives: false
show_docstring_returns: false
show_docstring_warns: false
show_docstring_yields: false

### Timeouts

::: frequenz.channels.timer.Timer.timeout
options:
inherited_members: []
members: []
show_bases: false
show_root_heading: false
show_root_toc_entry: false
show_source: false
show_docstring_attributes: false
show_docstring_functions: false
show_docstring_classes: false
show_docstring_other_parameters: false
show_docstring_parameters: false
show_docstring_raises: false
show_docstring_receives: false
show_docstring_returns: false
show_docstring_warns: false
show_docstring_yields: false

## Low-level Interface

A [`Timer`][frequenz.channels.timer.Timer] can be created using an arbitrary missed
ticks policy by calling the [low-level
constructor][frequenz.channels.timer.Timer.__init__] and passing the policy via the
[`missed_tick_policy`][frequenz.channels.timer.Timer.missed_tick_policy] argument.

### Custom Missed Tick Policies
## Custom Missed Tick Policies

::: frequenz.channels.timer.MissedTickPolicy
options:
Expand Down
6 changes: 3 additions & 3 deletions src/frequenz/channels/_select.py
Original file line number Diff line number Diff line change
Expand Up @@ -400,10 +400,10 @@ async def select(*receivers: Receiver[Any]) -> AsyncIterator[Selected[Any]]:
from typing import assert_never
from frequenz.channels import ReceiverStoppedError, select, selected_from
from frequenz.channels.timer import Timer
from frequenz.channels.timer import SkipMissedAndDrift, Timer, TriggerAllMissed
timer1 = Timer.periodic(datetime.timedelta(seconds=1))
timer2 = Timer.timeout(datetime.timedelta(seconds=0.5))
timer1 = Timer(datetime.timedelta(seconds=1), TriggerAllMissed())
timer2 = Timer(datetime.timedelta(seconds=0.5), SkipMissedAndDrift())
async for selected in select(timer1, timer2):
if selected_from(selected, timer1):
Expand Down
218 changes: 25 additions & 193 deletions src/frequenz/channels/timer.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,18 @@
# Quick Start
Info: Important
This quick start is provided to have a quick feeling of how to use this module, but
it is extremely important to understand how timers behave when they are delayed.
We recommend emphatically to read about [missed ticks and
drifting](#missed-ticks-and-drifting) before using timers in production.
If you need to do something as periodically as possible (avoiding
[drifts](#missed-ticks-and-drifting)), you can use use
a [`periodic()`][frequenz.channels.timer.Timer.periodic] timer.
[drifts](#missed-ticks-and-drifting)), you can use
a [`Timer`][frequenz.channels.timer.Timer] like this:
Example: Periodic Timer
Example: Periodic Timer Example
```python
import asyncio
from datetime import datetime, timedelta
Expand All @@ -18,18 +25,23 @@
async def main() -> None:
async for drift in Timer.periodic(timedelta(seconds=1.0)):
async for drift in Timer(timedelta(seconds=1.0), TriggerAllMissed()):
print(f"The timer has triggered at {datetime.now()} with a drift of {drift}")
asyncio.run(main())
```
This timer will tick as close as every second as possible, even if the loop is busy
doing something else for a good amount of time. In extreme cases, if the loop was
busy for a few seconds, the timer will trigger a few times in a row to catch up, one
for every missed tick.
If, instead, you need a timeout, for example to abort waiting for other receivers after
a certain amount of time, you can use
a [`timeout()`][frequenz.channels.timer.Timer.timeout] timer.
a certain amount of time, you can use a [`Timer`][frequenz.channels.timer.Timer] like
this:
Example: Timeout
Example: Timeout Example
```python
import asyncio
from datetime import timedelta
Expand All @@ -42,7 +54,7 @@ async def main() -> None:
channel = Anycast[int](name="data-channel")
data_receiver = channel.new_receiver()
timer = Timer.timeout(timedelta(seconds=1.0))
timer = Timer(timedelta(seconds=1.0), SkipMissedAndDrift())
async for selected in select(data_receiver, timer):
if selected_from(selected, data_receiver):
Expand All @@ -57,13 +69,10 @@ async def main() -> None:
asyncio.run(main())
```
This timer will *rearm* itself automatically after it was triggered, so it will trigger
again after the selected interval, no matter what the current drift was.
Tip:
It is extremely important to understand how timers behave when they are
delayed, we recommned emphatically to read about [missed ticks and
drifting](#missed-ticks-and-drifting) before using timers in production.
This timer will *rearm* itself automatically after it was triggered, so it will
trigger again after the selected interval, no matter what the current drift was. So
if the loop was busy for a few seconds, the timer will trigger immediately and then
wait for another second before triggering again. The missed ticks are skipped.
# Missed Ticks And Drifting
Expand Down Expand Up @@ -472,14 +481,6 @@ class Timer(Receiver[timedelta]):
[`missed_tick_policy`][frequenz.channels.timer.Timer.missed_tick_policy]. Missing
ticks might or might not trigger a message and the drift could be accumulated or not
depending on the chosen policy.
For the most common cases, a specialized constructor is provided:
* [`periodic()`][frequenz.channels.timer.Timer.periodic]:
{{docstring_summary("frequenz.channels.timer.Timer.periodic")}}
* [`timeout()`][frequenz.channels.timer.Timer.timeout]:
{{docstring_summary("frequenz.channels.timer.Timer.timeout")}}
"""

def __init__( # pylint: disable=too-many-arguments
Expand All @@ -494,7 +495,7 @@ def __init__( # pylint: disable=too-many-arguments
) -> None:
"""Create an instance.
See the class documentation for details.
See the [class documentation][frequenz.channels.timer.Timer] for details.
Args:
interval: The time between timer ticks. Must be at least
Expand Down Expand Up @@ -580,175 +581,6 @@ def __init__( # pylint: disable=too-many-arguments
if auto_start:
self.reset(start_delay=start_delay)

# We need a noqa here because the docs have a Raises section but the documented
# exceptions are raised indirectly.
@classmethod
def timeout( # noqa: DOC502
cls,
delay: timedelta,
/,
*,
auto_start: bool = True,
start_delay: timedelta = timedelta(0),
loop: asyncio.AbstractEventLoop | None = None,
) -> Timer:
"""Create a timer useful for tracking timeouts.
A [timeout][frequenz.channels.timer.Timer.timeout] is
a [`Timer`][frequenz.channels.timer.Timer] that
[resets][frequenz.channels.timer.Timer.reset] automatically after it triggers,
so it will trigger again after the selected interval, no matter what the current
drift was. This means timeout timers will accumulate drift.
Tip:
Timeouts are a shortcut to create
a [`Timer`][frequenz.channels.timer.Timer] with the
[`SkipMissedAndDrift`][frequenz.channels.timer.SkipMissedAndDrift] policy.
Example: Timeout example
```python
import asyncio
from datetime import timedelta
from frequenz.channels import Anycast, select, selected_from
from frequenz.channels.timer import Timer
async def main() -> None:
channel = Anycast[int](name="data-channel")
data_receiver = channel.new_receiver()
timer = Timer.timeout(timedelta(seconds=1.0))
async for selected in select(data_receiver, timer):
if selected_from(selected, data_receiver):
print(f"Received data: {selected.value}")
elif selected_from(selected, timer):
drift = selected.value
print(f"No data received for {timer.interval + drift} seconds, giving up")
break
asyncio.run(main())
```
Args:
delay: The time until the timer ticks. Must be at least
1 microsecond.
auto_start: Whether the timer should be started when the
instance is created. This can only be `True` if there is
already a running loop or an explicit `loop` that is running
was passed.
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.
loop: The event loop to use to track time. If `None`,
`asyncio.get_running_loop()` will be used.
Returns:
The timer instance.
Raises:
RuntimeError: if it was called without a loop and there is no
running loop.
ValueError: if `interval` is not positive or is smaller than 1
microsecond; if `start_delay` is negative or `start_delay` was specified
but `auto_start` is `False`.
"""
return Timer(
delay,
SkipMissedAndDrift(delay_tolerance=timedelta(0)),
auto_start=auto_start,
start_delay=start_delay,
loop=loop,
)

# We need a noqa here because the docs have a Raises section but the documented
# exceptions are raised indirectly.
@classmethod
def periodic( # noqa: DOC502 pylint: disable=too-many-arguments
cls,
period: timedelta,
/,
*,
skip_missed_ticks: bool = False,
auto_start: bool = True,
start_delay: timedelta = timedelta(0),
loop: asyncio.AbstractEventLoop | None = None,
) -> Timer:
"""Create a periodic timer.
A [periodic timer][frequenz.channels.timer.Timer.periodic] is
a [`Timer`][frequenz.channels.timer.Timer] that tries as hard as possible to
trigger at regular intervals. This means that if the timer is delayed for any
reason, it will trigger immediately and then try to catch up with the original
schedule.
Optionally, a periodic timer can be configured to skip missed ticks and re-sync
with the original schedule (`skip_missed_ticks` argument). This could be useful
if you want the timer is as periodic as possible but if there are big delays you
don't end up with big bursts.
Tip:
Periodic timers are a shortcut to create
a [`Timer`][frequenz.channels.timer.Timer] with either the
[`TriggerAllMissed`][frequenz.channels.timer.TriggerAllMissed] policy (when
`skip_missed_ticks` is `False`) or
[`SkipMissedAndResync`][frequenz.channels.timer.SkipMissedAndResync]
otherwise.
Example:
```python
import asyncio
from datetime import datetime, timedelta
from frequenz.channels.timer import Timer
async def main() -> None:
async for drift in Timer.periodic(timedelta(seconds=1.0)):
print(f"The timer has triggered at {datetime.now()} with a drift of {drift}")
asyncio.run(main())
```
Args:
period: The time between timer ticks. Must be at least
1 microsecond.
skip_missed_ticks: Whether to skip missed ticks or trigger them
all until it catches up.
auto_start: Whether the timer should be started when the
instance is created. This can only be `True` if there is
already a running loop or an explicit `loop` that is running
was passed.
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.
loop: The event loop to use to track time. If `None`,
`asyncio.get_running_loop()` will be used.
Returns:
The timer instance.
Raises:
RuntimeError: if it was called without a loop and there is no
running loop.
ValueError: if `interval` is not positive or is smaller than 1
microsecond; if `start_delay` is negative or `start_delay` was specified
but `auto_start` is `False`.
"""
missed_tick_policy = (
SkipMissedAndResync() if skip_missed_ticks else TriggerAllMissed()
)
return Timer(
period,
missed_tick_policy,
auto_start=auto_start,
start_delay=start_delay,
loop=loop,
)

@property
def interval(self) -> timedelta:
"""The interval between timer ticks.
Expand Down
Loading

0 comments on commit 150bdf1

Please sign in to comment.