Skip to content

Commit 5c61d44

Browse files
committed
Add an optional start delay to Timer
`Timer()`, `Timer.timeout()`, `Timer.periodic()` and `Timer.reset()` now take an optional `start_delay` option to make the timer start after some delay. This can be useful, for example, if the timer needs to be *aligned* to a particular time. The alternative to this would be to `sleep()` for the time needed to align the timer, but if the `sleep()` call gets delayed because the event loop is busy, then a re-alignment is needed and this could go on for a while. The only way to guarantee a certain alignment (with a reasonable precision) is to delay the timer start. Signed-off-by: Leandro Lucarella <[email protected]>
1 parent 8f4c43f commit 5c61d44

File tree

3 files changed

+105
-11
lines changed

3 files changed

+105
-11
lines changed

RELEASE_NOTES.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,17 @@
22

33
## Summary
44

5-
<!-- Here goes a general summary of what this release is about -->
5+
The `Timer` now can be started with a delay.
66

77
## Upgrading
88

99
<!-- Here goes notes on how to upgrade from previous versions, including deprecations and what they should be replaced with -->
1010

1111
## New Features
1212

13-
<!-- Here goes the main new features and examples or instructions on how to use them -->
13+
* `Timer()`, `Timer.timeout()`, `Timer.periodic()` and `Timer.reset()` now take an optional `start_delay` option to make the timer start after some delay.
14+
15+
This can be useful, for example, if the timer needs to be *aligned* to a particular time. The alternative to this would be to `sleep()` for the time needed to align the timer, but if the `sleep()` call gets delayed because the event loop is busy, then a re-alignment is needed and this could go on for a while. The only way to guarantee a certain alignment (with a reasonable precision) is to delay the timer start.
1416

1517
## Bug Fixes
1618

src/frequenz/channels/util/_timer.py

Lines changed: 40 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -391,6 +391,7 @@ def __init__(
391391
/,
392392
*,
393393
auto_start: bool = True,
394+
start_delay: timedelta = timedelta(0),
394395
loop: asyncio.AbstractEventLoop | None = None,
395396
) -> None:
396397
"""Create an instance.
@@ -408,20 +409,29 @@ def __init__(
408409
instance is created. This can only be `True` if there is
409410
already a running loop or an explicit `loop` that is running
410411
was passed.
412+
start_delay: The delay before the timer should start. If `auto_start` is
413+
`False`, an exception is raised. This has microseconds resolution,
414+
anything smaller than a microsecond means no delay.
411415
loop: The event loop to use to track time. If `None`,
412416
`asyncio.get_running_loop()` will be used.
413417
414418
Raises:
415419
RuntimeError: if it was called without a loop and there is no
416420
running loop.
417421
ValueError: if `interval` is not positive or is smaller than 1
418-
microsecond.
422+
microsecond; if `start_delay` is negative or `start_delay` was specified
423+
but `auto_start` is `False`.
419424
"""
420425
if interval < timedelta(microseconds=1):
421426
raise ValueError(
422427
f"The `interval` must be positive and at least 1 microsecond, not {interval}"
423428
)
424429

430+
if start_delay > timedelta(0) and auto_start is False:
431+
raise ValueError(
432+
"`auto_start` must be `True` if a `start_delay` is specified"
433+
)
434+
425435
self._interval: int = _to_microseconds(interval)
426436
"""The time to between timer ticks."""
427437

@@ -470,7 +480,7 @@ def __init__(
470480
"""
471481

472482
if auto_start:
473-
self.reset()
483+
self.reset(start_delay=start_delay)
474484

475485
@classmethod
476486
def timeout(
@@ -479,6 +489,7 @@ def timeout(
479489
/,
480490
*,
481491
auto_start: bool = True,
492+
start_delay: timedelta = timedelta(0),
482493
loop: asyncio.AbstractEventLoop | None = None,
483494
) -> Timer:
484495
"""Create a timer useful for tracking timeouts.
@@ -495,6 +506,9 @@ def timeout(
495506
instance is created. This can only be `True` if there is
496507
already a running loop or an explicit `loop` that is running
497508
was passed.
509+
start_delay: The delay before the timer should start. If `auto_start` is
510+
`False`, an exception is raised. This has microseconds resolution,
511+
anything smaller than a microsecond means no delay.
498512
loop: The event loop to use to track time. If `None`,
499513
`asyncio.get_running_loop()` will be used.
500514
@@ -505,12 +519,14 @@ def timeout(
505519
RuntimeError: if it was called without a loop and there is no
506520
running loop.
507521
ValueError: if `interval` is not positive or is smaller than 1
508-
microsecond.
522+
microsecond; if `start_delay` is negative or `start_delay` was specified
523+
but `auto_start` is `False`.
509524
"""
510525
return Timer(
511526
delay,
512527
SkipMissedAndDrift(delay_tolerance=timedelta(0)),
513528
auto_start=auto_start,
529+
start_delay=start_delay,
514530
loop=loop,
515531
)
516532

@@ -522,6 +538,7 @@ def periodic(
522538
*,
523539
skip_missed_ticks: bool = False,
524540
auto_start: bool = True,
541+
start_delay: timedelta = timedelta(0),
525542
loop: asyncio.AbstractEventLoop | None = None,
526543
) -> Timer:
527544
"""Create a periodic timer.
@@ -541,6 +558,9 @@ def periodic(
541558
instance is created. This can only be `True` if there is
542559
already a running loop or an explicit `loop` that is running
543560
was passed.
561+
start_delay: The delay before the timer should start. If `auto_start` is
562+
`False`, an exception is raised. This has microseconds resolution,
563+
anything smaller than a microsecond means no delay.
544564
loop: The event loop to use to track time. If `None`,
545565
`asyncio.get_running_loop()` will be used.
546566
@@ -551,7 +571,8 @@ def periodic(
551571
RuntimeError: if it was called without a loop and there is no
552572
running loop.
553573
ValueError: if `interval` is not positive or is smaller than 1
554-
microsecond.
574+
microsecond; if `start_delay` is negative or `start_delay` was specified
575+
but `auto_start` is `False`.
555576
"""
556577
missed_tick_policy = (
557578
SkipMissedAndResync() if skip_missed_ticks else TriggerAllMissed()
@@ -560,6 +581,7 @@ def periodic(
560581
period,
561582
missed_tick_policy,
562583
auto_start=auto_start,
584+
start_delay=start_delay,
563585
loop=loop,
564586
)
565587

@@ -601,19 +623,28 @@ def is_running(self) -> bool:
601623
"""
602624
return not self._stopped
603625

604-
def reset(self) -> None:
605-
"""Reset the timer to start timing from now.
626+
def reset(self, *, start_delay: timedelta = timedelta(0)) -> None:
627+
"""Reset the timer to start timing from now (plus an optional delay).
606628
607629
If the timer was stopped, or not started yet, it will be started.
608630
609-
This can only be called with a running loop, see the class
610-
documentation for more details.
631+
This can only be called with a running loop, see the class documentation for
632+
more details.
633+
634+
Args:
635+
start_delay: The delay before the timer should start. This has microseconds
636+
resolution, anything smaller than a microsecond means no delay.
611637
612638
Raises:
613639
RuntimeError: if it was called without a running loop.
640+
ValueError: if `start_delay` is negative.
614641
"""
642+
start_delay_ms = _to_microseconds(start_delay)
643+
644+
if start_delay_ms < 0:
645+
raise ValueError(f"`start_delay` can't be negative, got {start_delay}")
615646
self._stopped = False
616-
self._next_tick_time = self._now() + self._interval
647+
self._next_tick_time = self._now() + start_delay_ms + self._interval
617648
self._current_drift = None
618649

619650
def stop(self) -> None:

tests/utils/test_timer.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,7 @@ async def test_timer_contruction_periodic_custom_args() -> None:
296296
timedelta(seconds=5.0),
297297
skip_missed_ticks=True,
298298
auto_start=True,
299+
start_delay=timedelta(seconds=1.0),
299300
loop=None,
300301
)
301302
assert timer.interval == timedelta(seconds=5.0)
@@ -304,6 +305,44 @@ async def test_timer_contruction_periodic_custom_args() -> None:
304305
assert timer.is_running is True
305306

306307

308+
async def test_timer_contruction_wrong_args() -> None:
309+
"""Test the construction of a timeout timer with wrong arguments."""
310+
with pytest.raises(
311+
ValueError,
312+
match="^The `interval` must be positive and at least 1 microsecond, not -1 day, 23:59:55$",
313+
):
314+
_ = Timer.periodic(
315+
timedelta(seconds=-5.0),
316+
skip_missed_ticks=True,
317+
auto_start=True,
318+
loop=None,
319+
)
320+
321+
with pytest.raises(
322+
ValueError,
323+
match="^`start_delay` can't be negative, got -1 day, 23:59:59$",
324+
):
325+
_ = Timer.periodic(
326+
timedelta(seconds=5.0),
327+
skip_missed_ticks=True,
328+
auto_start=True,
329+
start_delay=timedelta(seconds=-1.0),
330+
loop=None,
331+
)
332+
333+
with pytest.raises(
334+
ValueError,
335+
match="^`auto_start` must be `True` if a `start_delay` is specified$",
336+
):
337+
_ = Timer.periodic(
338+
timedelta(seconds=5.0),
339+
skip_missed_ticks=True,
340+
auto_start=False,
341+
start_delay=timedelta(seconds=1.0),
342+
loop=None,
343+
)
344+
345+
307346
async def test_timer_autostart(
308347
event_loop: async_solipsism.EventLoop, # pylint: disable=redefined-outer-name
309348
) -> None:
@@ -319,6 +358,28 @@ async def test_timer_autostart(
319358
assert event_loop.time() == pytest.approx(1.0)
320359

321360

361+
async def test_timer_autostart_with_delay(
362+
event_loop: async_solipsism.EventLoop, # pylint: disable=redefined-outer-name
363+
) -> None:
364+
"""Test the autostart of a periodic timer with a delay."""
365+
timer = Timer(
366+
timedelta(seconds=1.0), TriggerAllMissed(), start_delay=timedelta(seconds=0.5)
367+
)
368+
369+
# We sleep some time, less than the interval plus the delay, and then receive from
370+
# the timer, since it was automatically started at time 0.5, it should trigger at
371+
# time 1.5 without any drift
372+
await asyncio.sleep(1.2)
373+
drift = await timer.receive()
374+
assert drift == pytest.approx(timedelta(seconds=0.0))
375+
assert event_loop.time() == pytest.approx(1.5)
376+
377+
# Still the next tick should be at 2.5 (every second)
378+
drift = await timer.receive()
379+
assert drift == pytest.approx(timedelta(seconds=0.0))
380+
assert event_loop.time() == pytest.approx(2.5)
381+
382+
322383
class _StartMethod(enum.Enum):
323384
RESET = enum.auto()
324385
RECEIVE = enum.auto()

0 commit comments

Comments
 (0)