redzed 25.12.30__py3-none-any.whl → 26.2.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- redzed/__init__.py +4 -4
- redzed/base_block.py +2 -2
- redzed/block.py +18 -19
- redzed/blocklib/counter.py +4 -0
- redzed/blocklib/fsm.py +81 -52
- redzed/blocklib/inputs.py +38 -66
- redzed/blocklib/outputs.py +109 -77
- redzed/blocklib/repeat.py +29 -13
- redzed/blocklib/timedate.py +3 -3
- redzed/blocklib/timeinterval.py +4 -0
- redzed/blocklib/timer.py +3 -4
- redzed/blocklib/validator.py +46 -0
- redzed/circuit.py +65 -40
- redzed/cron_service.py +137 -123
- redzed/debug.py +70 -53
- redzed/formula_trigger.py +4 -8
- redzed/initializers.py +6 -7
- redzed/py.typed +0 -0
- redzed/signal_shutdown.py +3 -3
- redzed/undef.py +2 -2
- redzed/utils/async_utils.py +1 -100
- redzed/utils/data_utils.py +20 -44
- {redzed-25.12.30.dist-info → redzed-26.2.4.dist-info}/METADATA +2 -2
- redzed-26.2.4.dist-info/RECORD +30 -0
- {redzed-25.12.30.dist-info → redzed-26.2.4.dist-info}/WHEEL +1 -1
- {redzed-25.12.30.dist-info → redzed-26.2.4.dist-info}/licenses/LICENSE.txt +1 -1
- redzed-25.12.30.dist-info/RECORD +0 -28
- {redzed-25.12.30.dist-info → redzed-26.2.4.dist-info}/top_level.txt +0 -0
redzed/circuit.py
CHANGED
|
@@ -2,15 +2,16 @@
|
|
|
2
2
|
The circuit runner.
|
|
3
3
|
- - - - - -
|
|
4
4
|
Part of the redzed package.
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
Docs: https://redzed.readthedocs.io/en/latest/
|
|
6
|
+
Project home: https://github.com/xitop/redzed/
|
|
7
7
|
"""
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
|
-
__all__ = [
|
|
10
|
+
__all__ = [
|
|
11
|
+
'CircuitState', 'stop_function', 'get_circuit', 'reset_circuit', 'run', 'unique_name']
|
|
11
12
|
|
|
12
13
|
import asyncio
|
|
13
|
-
from collections.abc import Coroutine, Iterable, MutableMapping, Sequence
|
|
14
|
+
from collections.abc import Callable, Coroutine, Iterable, MutableMapping, Sequence
|
|
14
15
|
import contextlib
|
|
15
16
|
import enum
|
|
16
17
|
import itertools
|
|
@@ -21,12 +22,12 @@ import typing as t
|
|
|
21
22
|
|
|
22
23
|
from .block import Block, PersistenceFlags
|
|
23
24
|
from .cron_service import Cron
|
|
24
|
-
from .debug import get_debug_level
|
|
25
|
+
from .debug import get_debug_level, set_debug_level
|
|
25
26
|
from .initializers import AsyncInitializer
|
|
26
27
|
from .formula_trigger import Formula, Trigger
|
|
27
28
|
from .signal_shutdown import TerminatingSignal
|
|
28
29
|
from .undef import UNDEF
|
|
29
|
-
from .utils import
|
|
30
|
+
from .utils import check_identifier, tasks_are_eager, time_period
|
|
30
31
|
|
|
31
32
|
_logger = logging.getLogger(__package__)
|
|
32
33
|
_current_circuit: Circuit|None = None
|
|
@@ -63,6 +64,13 @@ def unique_name(prefix: str = 'auto') -> str:
|
|
|
63
64
|
return get_circuit().rz_unique_name(prefix)
|
|
64
65
|
|
|
65
66
|
|
|
67
|
+
_StopFunction: t.TypeAlias = Callable[[], t.Any]
|
|
68
|
+
|
|
69
|
+
def stop_function(func: _StopFunction) -> _StopFunction:
|
|
70
|
+
get_circuit().rz_add_stop_function(func)
|
|
71
|
+
return func
|
|
72
|
+
|
|
73
|
+
|
|
66
74
|
class CircuitState(enum.IntEnum):
|
|
67
75
|
"""
|
|
68
76
|
Circuit state.
|
|
@@ -83,16 +91,16 @@ class _TerminateTaskGroup(Exception):
|
|
|
83
91
|
|
|
84
92
|
|
|
85
93
|
@contextlib.contextmanager
|
|
86
|
-
def error_debug(
|
|
94
|
+
def error_debug(exc_source: t.Any, suppress_error: bool = False) -> t.Iterator[None]:
|
|
87
95
|
"""Add a note to raised exception -or- log and suppress exception."""
|
|
88
96
|
try:
|
|
89
97
|
yield None
|
|
90
98
|
except Exception as err:
|
|
91
99
|
if not suppress_error:
|
|
92
|
-
err.add_note(f"This {type(err).__name__} occurred in {
|
|
100
|
+
err.add_note(f"This {type(err).__name__} occurred in {exc_source}")
|
|
93
101
|
raise
|
|
94
102
|
# errors should be suppressed only during the shutdown & cleanup
|
|
95
|
-
_logger.error("[Circuit] %s: Suppressing %s: %s",
|
|
103
|
+
_logger.error("[Circuit] %s: Suppressing %s: %s", exc_source, type(err).__name__, err)
|
|
96
104
|
|
|
97
105
|
|
|
98
106
|
class Circuit:
|
|
@@ -108,6 +116,7 @@ class Circuit:
|
|
|
108
116
|
self._blocks: dict[str, Block|Formula] = {}
|
|
109
117
|
# all Blocks and Formulas belonging to this circuit stored by name
|
|
110
118
|
self._triggers: list[Trigger] = [] # all triggers belonging to this circuit
|
|
119
|
+
self._stops: list[_StopFunction] = []
|
|
111
120
|
self._errors: list[Exception] = [] # exceptions occurred in the runner
|
|
112
121
|
self.rz_persistent_dict: MutableMapping[str, t.Any]|None = None
|
|
113
122
|
# persistent state data back-end
|
|
@@ -169,6 +178,9 @@ class Circuit:
|
|
|
169
178
|
|
|
170
179
|
# --- circuit components storage ---
|
|
171
180
|
|
|
181
|
+
def rz_add_stop_function(self, func: _StopFunction) -> None:
|
|
182
|
+
self._stops.append(func)
|
|
183
|
+
|
|
172
184
|
def rz_add_item(self, item: Block|Formula|Trigger) -> None:
|
|
173
185
|
"""Add a circuit item."""
|
|
174
186
|
self._check_not_started()
|
|
@@ -279,7 +291,7 @@ class Circuit:
|
|
|
279
291
|
|
|
280
292
|
def _check_not_started(self) -> None:
|
|
281
293
|
"""Raise an error if the circuit runner has started already."""
|
|
282
|
-
if self._state
|
|
294
|
+
if self._state is CircuitState.CLOSED:
|
|
283
295
|
# A circuit may be closed before start (see shutdown),
|
|
284
296
|
# let's use this message instead of the one below.
|
|
285
297
|
raise RuntimeError("The circuit was closed")
|
|
@@ -287,7 +299,7 @@ class Circuit:
|
|
|
287
299
|
if self._state > CircuitState.INIT_CIRCUIT:
|
|
288
300
|
raise RuntimeError("Not allowed after the start")
|
|
289
301
|
|
|
290
|
-
def
|
|
302
|
+
def is_shut_down(self) -> bool:
|
|
291
303
|
"""Test if we are past the shutdown() call."""
|
|
292
304
|
return self._state >= CircuitState.SHUTDOWN
|
|
293
305
|
|
|
@@ -444,8 +456,10 @@ class Circuit:
|
|
|
444
456
|
async def _runner_init(self) -> None:
|
|
445
457
|
"""Run the circuit during the initialization phase."""
|
|
446
458
|
self._set_state(CircuitState.INIT_CIRCUIT)
|
|
459
|
+
# ensure a logging handler (won't work in UNDER_CONSTRUCTION level)
|
|
460
|
+
set_debug_level(get_debug_level())
|
|
447
461
|
await asyncio.sleep(0) # allow reached_state() synchronization
|
|
448
|
-
if self.
|
|
462
|
+
if self.is_shut_down():
|
|
449
463
|
# It looks like a supporting task has failed immediately after the start
|
|
450
464
|
return
|
|
451
465
|
|
|
@@ -496,14 +510,24 @@ class Circuit:
|
|
|
496
510
|
self.save_persistent_state(blk, now)
|
|
497
511
|
|
|
498
512
|
stop_triggers = list(self.get_items(Trigger))
|
|
513
|
+
if stop_triggers:
|
|
514
|
+
self._log_debug2_blocks("Stopping triggers", stop_triggers)
|
|
515
|
+
for tstop in stop_triggers:
|
|
516
|
+
with error_debug(tstop, suppress_error=True):
|
|
517
|
+
tstop.rz_stop()
|
|
518
|
+
|
|
519
|
+
if self._stops:
|
|
520
|
+
self.log_debug2("Running %d stop function(s)", len(self._stops))
|
|
521
|
+
for fstop in self._stops:
|
|
522
|
+
with error_debug(f"Stop function {fstop.__name__}()", suppress_error=True):
|
|
523
|
+
fstop()
|
|
524
|
+
|
|
499
525
|
stop_blocks = [blk for blk in self.get_items(Block) if blk.has_method('rz_stop')]
|
|
500
526
|
if stop_blocks:
|
|
501
|
-
self._log_debug2_blocks("Stopping (sync)",
|
|
502
|
-
for
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
# union-attr: checked with .has_method()
|
|
506
|
-
stop.rz_stop() # type: ignore[union-attr]
|
|
527
|
+
self._log_debug2_blocks("Stopping (sync)", stop_blocks)
|
|
528
|
+
for bstop in stop_blocks:
|
|
529
|
+
with error_debug(bstop, suppress_error=True):
|
|
530
|
+
bstop.rz_stop()
|
|
507
531
|
|
|
508
532
|
if self._auto_cancel_tasks:
|
|
509
533
|
self.log_debug2("Cancelling %d service task(s)")
|
|
@@ -543,10 +567,6 @@ class Circuit:
|
|
|
543
567
|
|
|
544
568
|
When the runner terminates, it cannot be invoked again.
|
|
545
569
|
"""
|
|
546
|
-
if self._state == CircuitState.CLOSED:
|
|
547
|
-
raise RuntimeError("Cannot restart a closed circuit.")
|
|
548
|
-
if self._state != CircuitState.UNDER_CONSTRUCTION:
|
|
549
|
-
raise RuntimeError("The circuit is already running.")
|
|
550
570
|
if not self._blocks:
|
|
551
571
|
raise RuntimeError("The circuit is empty")
|
|
552
572
|
if tasks_are_eager():
|
|
@@ -560,7 +580,7 @@ class Circuit:
|
|
|
560
580
|
self.abort(err)
|
|
561
581
|
else:
|
|
562
582
|
# There might be errors reported with abort().
|
|
563
|
-
# In such case the state has been set to SHUTDOWN.
|
|
583
|
+
# In such case the state has been set to SHUTDOWN or even CLOSED.
|
|
564
584
|
# _set_state(RUNNING) will be silently ignored.
|
|
565
585
|
self._set_state(CircuitState.RUNNING)
|
|
566
586
|
# wait until cancelled from the task group; possible causes:
|
|
@@ -572,17 +592,18 @@ class Circuit:
|
|
|
572
592
|
# will be re-raised at the end if there won't be other exceptions
|
|
573
593
|
pass
|
|
574
594
|
# cancellation causes 2 and 3 do not modify the state
|
|
575
|
-
if not self.
|
|
595
|
+
if not self.is_shut_down():
|
|
576
596
|
self._set_state(CircuitState.SHUTDOWN)
|
|
577
597
|
await asyncio.sleep(0)
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
598
|
+
if self._state < CircuitState.CLOSED:
|
|
599
|
+
try:
|
|
600
|
+
await self._runner_shutdown()
|
|
601
|
+
except Exception as err:
|
|
602
|
+
# If an exception is propagated from _runner_shutdown, it is probably a bug.
|
|
603
|
+
# Calling abort is not necessary when shutting down, but the call will log
|
|
604
|
+
# and register the exception to be included in the final ExceptionGroup.
|
|
605
|
+
self.abort(err)
|
|
606
|
+
self._set_state(CircuitState.CLOSED)
|
|
586
607
|
|
|
587
608
|
if self._errors:
|
|
588
609
|
raise ExceptionGroup("_runner_core() errors", self._errors)
|
|
@@ -604,7 +625,7 @@ class Circuit:
|
|
|
604
625
|
# the same error may be reported from several places
|
|
605
626
|
return
|
|
606
627
|
self._errors.append(err)
|
|
607
|
-
if self.
|
|
628
|
+
if self.is_shut_down():
|
|
608
629
|
self.log_error("Unhandled error during shutdown: %r", err)
|
|
609
630
|
else:
|
|
610
631
|
self.log_warning("Aborting due to an exception: %r", err)
|
|
@@ -616,9 +637,9 @@ class Circuit:
|
|
|
616
637
|
|
|
617
638
|
Prevent the runner from starting if it wasn't started yet.
|
|
618
639
|
"""
|
|
619
|
-
if self.
|
|
640
|
+
if self.is_shut_down():
|
|
620
641
|
return
|
|
621
|
-
if self._state
|
|
642
|
+
if self._state <= CircuitState.INIT_CIRCUIT:
|
|
622
643
|
self._set_state(CircuitState.CLOSED)
|
|
623
644
|
return
|
|
624
645
|
self._set_state(CircuitState.SHUTDOWN)
|
|
@@ -655,7 +676,7 @@ class Circuit:
|
|
|
655
676
|
try:
|
|
656
677
|
await coro # return value of a service is ignored
|
|
657
678
|
except asyncio.CancelledError:
|
|
658
|
-
if self.
|
|
679
|
+
if self.is_shut_down():
|
|
659
680
|
self.log_debug1("%s was cancelled", longname)
|
|
660
681
|
raise
|
|
661
682
|
err = RuntimeError(f"{longname} was cancelled before shutdown")
|
|
@@ -665,7 +686,7 @@ class Circuit:
|
|
|
665
686
|
err.add_note(f"Error occurred in {longname}")
|
|
666
687
|
self.abort(err)
|
|
667
688
|
raise
|
|
668
|
-
if self.
|
|
689
|
+
if self.is_shut_down():
|
|
669
690
|
self.log_debug1("%s terminated", longname)
|
|
670
691
|
return
|
|
671
692
|
exc = RuntimeError(f"{longname} terminated before shutdown")
|
|
@@ -679,9 +700,8 @@ class Circuit:
|
|
|
679
700
|
**task_kwargs
|
|
680
701
|
) -> asyncio.Task[None]:
|
|
681
702
|
"""Create a service task for the circuit."""
|
|
682
|
-
if self.
|
|
703
|
+
if self.is_shut_down():
|
|
683
704
|
raise RuntimeError("Cannot create a service after shutdown")
|
|
684
|
-
check_async_coro(coro)
|
|
685
705
|
# Python 3.12 and 3.13 only: Eager tasks start to run before their name is set.
|
|
686
706
|
# As a workaround we tell the watchdog wrapper the name.
|
|
687
707
|
task = asyncio.create_task(
|
|
@@ -734,8 +754,13 @@ async def run(*coroutines: Coroutine[t.Any, t.Any, t.Any], catch_sigterm: bool =
|
|
|
734
754
|
|
|
735
755
|
If errors occur, raise an exception group with all exceptions.
|
|
736
756
|
"""
|
|
757
|
+
circuit = get_circuit()
|
|
758
|
+
state = circuit.get_state()
|
|
759
|
+
if state is CircuitState.CLOSED:
|
|
760
|
+
raise RuntimeError("Cannot restart a closed circuit.")
|
|
761
|
+
if state is not CircuitState.UNDER_CONSTRUCTION:
|
|
762
|
+
raise RuntimeError("The circuit is already running.")
|
|
737
763
|
with TerminatingSignal(signal.SIGTERM) if catch_sigterm else contextlib.nullcontext():
|
|
738
|
-
circuit = get_circuit()
|
|
739
764
|
try:
|
|
740
765
|
async with asyncio.TaskGroup() as tg:
|
|
741
766
|
tg.create_task(circuit.rz_runner(), name="Circuit runner")
|
redzed/cron_service.py
CHANGED
|
@@ -3,8 +3,8 @@ Call the .rz_cron_event() method of all registered blocks at given times of day.
|
|
|
3
3
|
|
|
4
4
|
Blocks acting on given time, date and weekdays are implemented
|
|
5
5
|
on top of this low-level service.
|
|
6
|
-
|
|
7
6
|
- - - - - -
|
|
7
|
+
Part of the redzed package.
|
|
8
8
|
Docs: https://redzed.readthedocs.io/en/latest/
|
|
9
9
|
Home: https://github.com/xitop/redzed/
|
|
10
10
|
"""
|
|
@@ -12,23 +12,41 @@ from __future__ import annotations
|
|
|
12
12
|
|
|
13
13
|
import asyncio
|
|
14
14
|
import bisect
|
|
15
|
+
from collections import deque
|
|
15
16
|
from collections.abc import Collection
|
|
16
17
|
import datetime as dt
|
|
17
|
-
import time
|
|
18
18
|
import typing as t
|
|
19
19
|
|
|
20
20
|
from .block import Block, EventData
|
|
21
21
|
from .debug import get_debug_level
|
|
22
22
|
from .utils import SEC_PER_HOUR, SEC_PER_MIN, SEC_PER_DAY
|
|
23
23
|
|
|
24
|
-
# time tracking
|
|
25
|
-
|
|
26
|
-
_TT_WARNING = 0.
|
|
27
|
-
_TT_ERROR = 2.5 #
|
|
28
|
-
|
|
24
|
+
# time tracking settings (in seconds)
|
|
25
|
+
_TT_OVERHEAD = 0.001 # asyncio sleep overhead estimation; real value will be measured
|
|
26
|
+
_TT_WARNING = 0.2 # timing difference threshold for a warning message
|
|
27
|
+
_TT_ERROR = 2.5 # timing difference threshold for a reset
|
|
28
|
+
|
|
29
|
+
# hourly wake ups for precise time tracking and early detection of DST changes
|
|
30
|
+
_SET24H = frozenset(dt.time(hour, 0, 0) for hour in range(24))
|
|
29
31
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
+
|
|
33
|
+
def _wait_time(t1: dt.time, t2: dt.time) -> float:
|
|
34
|
+
"""
|
|
35
|
+
Return seconds from *t1* to *t2* on a 24 hour clock.
|
|
36
|
+
|
|
37
|
+
The result is always between -1 and 23 hours (in seconds).
|
|
38
|
+
Positive values are normal wait times (when *t2* is after *t1*).
|
|
39
|
+
Negative values correspond to delays after a missed event
|
|
40
|
+
(when *t2* is less than 1 hour before *t1*).
|
|
41
|
+
"""
|
|
42
|
+
# datetime.time does not support time arithmetic
|
|
43
|
+
diff = (SEC_PER_HOUR*(t2.hour - t1.hour)
|
|
44
|
+
+ SEC_PER_MIN*(t2.minute - t1.minute)
|
|
45
|
+
+ (t2.second - t1.second)
|
|
46
|
+
+ (t2.microsecond - t1.microsecond) / 1_000_000.0)
|
|
47
|
+
if -SEC_PER_HOUR < diff <= 23 * SEC_PER_HOUR:
|
|
48
|
+
return diff
|
|
49
|
+
return diff + SEC_PER_DAY if diff < 0 else diff - SEC_PER_DAY
|
|
32
50
|
|
|
33
51
|
|
|
34
52
|
class Cron(Block):
|
|
@@ -44,7 +62,10 @@ class Cron(Block):
|
|
|
44
62
|
def __init__(self, *args, utc: bool, **kwargs) -> None:
|
|
45
63
|
super().__init__(*args, **kwargs)
|
|
46
64
|
self._utc = bool(utc)
|
|
47
|
-
self._alarms: dict[dt.time, set[Block]] = {}
|
|
65
|
+
self._alarms: dict[dt.time, set[Block]] = {tod: set() for tod in _SET24H}
|
|
66
|
+
self._timetable: list[dt.time] = sorted(_SET24H)
|
|
67
|
+
# timetable = sorted list (for bisection) of wake-up times (i.e. _alarms keys)
|
|
68
|
+
self._tt_len = 24
|
|
48
69
|
self._reversed: dict[Block, set[dt.time]] = {}
|
|
49
70
|
self._do_reload = asyncio.Event()
|
|
50
71
|
|
|
@@ -69,144 +90,137 @@ class Cron(Block):
|
|
|
69
90
|
raise ValueError("time_of_day must not contain timezone data")
|
|
70
91
|
|
|
71
92
|
def set_schedule(self, blk: Block, times_of_day: Collection[dt.time]) -> None:
|
|
72
|
-
"""
|
|
73
|
-
Add a block to be activated at given times or update its schedule.
|
|
74
|
-
|
|
75
|
-
The block's 'rz_cron_event' method will be called at given time
|
|
76
|
-
and also when this service is started, reset or reloaded.
|
|
77
|
-
|
|
78
|
-
A datetime.datetime object will be passed to the blk as its
|
|
79
|
-
only argument.
|
|
80
|
-
"""
|
|
93
|
+
"""Add a block to be activated at given times or update its schedule."""
|
|
81
94
|
if not hasattr(blk, 'rz_cron_event'):
|
|
82
95
|
raise TypeError(f"{blk} is not compatible with the cron service")
|
|
96
|
+
for tod in times_of_day:
|
|
97
|
+
self._check_tz(tod)
|
|
98
|
+
self.log_debug2("Got a new schedule for %s", blk)
|
|
99
|
+
times_of_day = set(times_of_day)
|
|
83
100
|
|
|
84
|
-
|
|
85
|
-
# remove old times of day
|
|
101
|
+
# remove old times of day; it is not necessary to notify the main loop
|
|
86
102
|
old_times = self._reversed.get(blk, set())
|
|
87
103
|
for tod in old_times - times_of_day:
|
|
88
104
|
self._alarms[tod].discard(blk)
|
|
89
|
-
|
|
90
|
-
do_reload = False
|
|
91
105
|
# add new times of day
|
|
106
|
+
do_reload = False
|
|
92
107
|
for tod in times_of_day - old_times:
|
|
93
|
-
self._check_tz(tod)
|
|
94
108
|
if tod in self._alarms:
|
|
95
109
|
self._alarms[tod].add(blk)
|
|
96
110
|
else:
|
|
97
111
|
self._alarms[tod] = {blk}
|
|
98
|
-
|
|
99
|
-
|
|
112
|
+
# new entry added to timetable; the main loop must be notified
|
|
113
|
+
do_reload = True
|
|
100
114
|
self._reversed[blk] = times_of_day
|
|
101
|
-
|
|
102
|
-
# cleanup
|
|
103
|
-
unused = [tod for tod, blkset in self._alarms.items() if not blkset]
|
|
104
|
-
for tod in unused:
|
|
105
|
-
del self._alarms[tod]
|
|
106
|
-
if tod not in _SET24:
|
|
107
|
-
do_reload = True # empty entry removed from the timetable
|
|
108
115
|
if do_reload:
|
|
109
116
|
self._do_reload.set()
|
|
117
|
+
# cleanup
|
|
118
|
+
for tod in [
|
|
119
|
+
tod for tod, blks in self._alarms.items() if tod not in _SET24H and not blks]:
|
|
120
|
+
del self._alarms[tod]
|
|
121
|
+
|
|
122
|
+
self._timetable = sorted(self._alarms)
|
|
123
|
+
self._tt_len = len(self._timetable)
|
|
124
|
+
|
|
110
125
|
|
|
111
126
|
async def _cron_daemon(self) -> t.NoReturn:
|
|
112
127
|
"""Recalculate registered blocks according to the schedule."""
|
|
113
|
-
overhead = _TT_OK # initial value, will be adjusted
|
|
114
|
-
# the sleeptime is reduced by this value
|
|
115
128
|
reset_flag = False
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
tlen = len(timetable)
|
|
122
|
-
self.log_debug1("time schedule reloaded")
|
|
123
|
-
index = None
|
|
124
|
-
reload_flag = False
|
|
129
|
+
overhead = _TT_OVERHEAD # an estimate to start with
|
|
130
|
+
measured_overheads: deque[float] = deque(maxlen=8)
|
|
131
|
+
prev_sleeptime: float
|
|
132
|
+
long_sleep: bool
|
|
133
|
+
wakeup: dt.time|None = None
|
|
125
134
|
|
|
135
|
+
while True:
|
|
126
136
|
nowdt = self.dtnow()
|
|
127
137
|
nowt = nowdt.time()
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
138
|
+
|
|
139
|
+
if wakeup is None or self._do_reload.is_set():
|
|
140
|
+
index = bisect.bisect_left(self._timetable, nowt)
|
|
141
|
+
next_wakeup = self._timetable[index % self._tt_len]
|
|
142
|
+
# next_wakeup is new and wakeup was not processed yet,
|
|
143
|
+
# all we need is to select which one comes first.
|
|
144
|
+
if wakeup is None \
|
|
145
|
+
or next_wakeup != wakeup and _wait_time(next_wakeup, wakeup) > 0:
|
|
146
|
+
wakeup = next_wakeup
|
|
147
|
+
# else: the current wakeup is confirmed
|
|
148
|
+
self._do_reload.clear()
|
|
149
|
+
self.log_debug1("wake-up time after reload: %s", wakeup)
|
|
150
|
+
else:
|
|
151
|
+
# return the next entry (in circular manner)
|
|
152
|
+
index = bisect.bisect_right(self._timetable, wakeup)
|
|
153
|
+
wakeup = self._timetable[index % self._tt_len]
|
|
154
|
+
self.log_debug1("wake-up time: %s", wakeup)
|
|
155
|
+
|
|
156
|
+
# sleep until the wake-up time:
|
|
157
|
+
# step 0: main sleep
|
|
158
|
+
# compute the delay until wake-up time and sleep
|
|
159
|
+
# steps 1 and 2: fine adjustment
|
|
160
|
+
# check the current time, adjust overhead estimate and
|
|
161
|
+
# - finish if the time is correct, or
|
|
162
|
+
# - add a tiny sleep if woken up too early, because
|
|
163
|
+
# that is not acceptable, or
|
|
164
|
+
# - do a reset if the time is way off
|
|
165
|
+
# steps 3 and 4: safety net
|
|
166
|
+
# like above, just for the case the computer clock
|
|
167
|
+
# does something unexpected
|
|
168
|
+
for step in range(5):
|
|
169
|
+
sleeptime = _wait_time(nowt, wakeup) # negative = we are late
|
|
170
|
+
debug2 = get_debug_level() >= 2
|
|
156
171
|
if step == 0:
|
|
157
|
-
|
|
158
|
-
|
|
172
|
+
if debug2:
|
|
173
|
+
self.log_debug("sleep until wake up: %.3f sec", sleeptime)
|
|
174
|
+
else:
|
|
159
175
|
diff = abs(sleeptime)
|
|
160
|
-
|
|
161
|
-
msg = 'BEFORE' if sleeptime > 0 else 'after'
|
|
162
|
-
self.log_debug(
|
|
163
|
-
"step %d, diff %.2f ms %s, estimated overhead: %.2f ms",
|
|
164
|
-
step, 1000*diff, msg, 1000*overhead)
|
|
176
|
+
after = sleeptime <= 0.0
|
|
165
177
|
if diff > _TT_WARNING:
|
|
166
178
|
self.log_warning(
|
|
167
|
-
"expected time: %s, current time: %s, diff: %.
|
|
168
|
-
wakeup, nowt,
|
|
169
|
-
|
|
179
|
+
"expected time: %s, current time: %s, diff: %.1f sec %s",
|
|
180
|
+
wakeup, nowt, diff, 'after' if after else 'BEFORE')
|
|
181
|
+
elif debug2:
|
|
182
|
+
self.log_debug(
|
|
183
|
+
"iteration %d, diff %.2f ms %s",
|
|
184
|
+
step, 1000*diff, 'after' if after else 'BEFORE')
|
|
185
|
+
# prev_sleeptime and long_sleep has been set in previous step
|
|
186
|
+
# pylint: disable=used-before-assignment
|
|
187
|
+
if diff > _TT_ERROR or sleeptime > prev_sleeptime or step == 4:
|
|
188
|
+
# something is wrong with the computer clock
|
|
170
189
|
reset_flag = True
|
|
171
|
-
if reset_flag:
|
|
172
190
|
break
|
|
173
|
-
if
|
|
174
|
-
|
|
175
|
-
|
|
191
|
+
if long_sleep:
|
|
192
|
+
saved = overhead
|
|
193
|
+
measured_overheads.append(overhead - sleeptime)
|
|
194
|
+
# Using smallest overhead recently measured, because to be late
|
|
195
|
+
# by a tiny amount is much better than to wake up early by a tiny
|
|
196
|
+
# amount. The latter case must be corrected by another sleep.
|
|
197
|
+
overhead = min(measured_overheads)
|
|
198
|
+
if debug2 and overhead != saved:
|
|
199
|
+
self.log_debug("estimated overhead >= %.2f ms", 1000*overhead)
|
|
200
|
+
if after:
|
|
176
201
|
break
|
|
177
|
-
if
|
|
202
|
+
if debug2:
|
|
178
203
|
self.log_debug("additional sleep %.2f ms", 1000*sleeptime)
|
|
204
|
+
prev_sleeptime = sleeptime
|
|
179
205
|
|
|
180
|
-
if sleeptime
|
|
181
|
-
pass # how likely is this?
|
|
182
|
-
elif sleeptime <= _SYNC_SLEEP:
|
|
183
|
-
short_sleep = True
|
|
184
|
-
# breaking the asyncio rules for max time tracking accuracy:
|
|
185
|
-
# doing a blocking sleep, but only for a fraction of a millisecond
|
|
186
|
-
time.sleep(sleeptime)
|
|
187
|
-
elif sleeptime <= overhead:
|
|
188
|
-
short_sleep = True
|
|
189
|
-
await asyncio.sleep(sleeptime)
|
|
190
|
-
else:
|
|
191
|
-
short_sleep = False
|
|
206
|
+
if (long_sleep := sleeptime > overhead):
|
|
192
207
|
try:
|
|
193
208
|
async with asyncio.timeout(sleeptime - overhead):
|
|
194
209
|
await self._do_reload.wait()
|
|
195
210
|
except TimeoutError:
|
|
196
|
-
pass
|
|
211
|
+
pass # no reload request
|
|
197
212
|
else:
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
213
|
+
break # reload request arrived
|
|
214
|
+
elif sleeptime > 0.0:
|
|
215
|
+
await asyncio.sleep(sleeptime)
|
|
201
216
|
nowdt = self.dtnow()
|
|
202
217
|
nowt = nowdt.time()
|
|
218
|
+
# --- end for loop ---
|
|
203
219
|
|
|
204
220
|
if reset_flag:
|
|
205
221
|
# DST begin/end or other computer clock related reason
|
|
206
|
-
if (not self._utc
|
|
207
|
-
and
|
|
208
|
-
and abs(diff - SEC_PER_HOUR) <= _TT_ERROR
|
|
209
|
-
):
|
|
222
|
+
if (not self._utc and nowdt.isoweekday() >= 6
|
|
223
|
+
and abs(diff - SEC_PER_HOUR) <= _TT_ERROR):
|
|
210
224
|
self.log_warning("Apparently a DST (summer time) clock change has occured.")
|
|
211
225
|
self.log_warning(
|
|
212
226
|
"Resetting due to a time tracking problem. "
|
|
@@ -215,29 +229,29 @@ class Cron(Block):
|
|
|
215
229
|
for blk in list(self._reversed): # all blocks
|
|
216
230
|
assert hasattr(blk, 'rz_cron_event')
|
|
217
231
|
blk.rz_cron_event(nowdt)
|
|
218
|
-
index = None
|
|
219
232
|
reset_flag = False
|
|
233
|
+
wakeup = None
|
|
220
234
|
continue
|
|
221
|
-
if
|
|
235
|
+
if self._do_reload.is_set():
|
|
222
236
|
continue
|
|
223
237
|
|
|
224
|
-
if wakeup in self._alarms:
|
|
225
|
-
#
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
238
|
+
if wakeup not in self._alarms:
|
|
239
|
+
continue # entry removed in the meantime
|
|
240
|
+
# .rz_cron_event() may alter the set we are iterating over
|
|
241
|
+
block_list = list(self._alarms[wakeup])
|
|
242
|
+
if get_debug_level() >= 1:
|
|
243
|
+
self.log_debug(
|
|
244
|
+
"Notifying block(s): %s", ", ".join(blk.name for blk in block_list))
|
|
245
|
+
for blk in block_list:
|
|
246
|
+
assert hasattr(blk, 'rz_cron_event')
|
|
247
|
+
blk.rz_cron_event(nowdt)
|
|
234
248
|
|
|
235
249
|
def _event__get_config(self, _edata: EventData) -> dict[str, dict[str, list[str]]]:
|
|
236
250
|
"""Return the internal scheduling data for debugging or monitoring."""
|
|
237
251
|
return {
|
|
238
252
|
'alarms': {
|
|
239
|
-
str(
|
|
240
|
-
for
|
|
241
|
-
'blocks': {blk.name: sorted(str(
|
|
242
|
-
for blk,
|
|
253
|
+
str(tod): sorted(blk.name for blk in blks)
|
|
254
|
+
for tod, blks in self._alarms.items() if blks},
|
|
255
|
+
'blocks': {blk.name: sorted(str(tod) for tod in tods)
|
|
256
|
+
for blk, tods in self._reversed.items()},
|
|
243
257
|
}
|