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/circuit.py CHANGED
@@ -2,15 +2,16 @@
2
2
  The circuit runner.
3
3
  - - - - - -
4
4
  Part of the redzed package.
5
- # Docs: https://redzed.readthedocs.io/en/latest/
6
- # Project home: https://github.com/xitop/redzed/
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__ = ['CircuitState', 'get_circuit', 'reset_circuit', 'run', 'unique_name']
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 check_async_coro, check_identifier, tasks_are_eager, time_period
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(item: Block|Formula|Trigger, suppress_error: bool = False) -> t.Iterator[None]:
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 {item}")
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", item, type(err).__name__, err)
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 == CircuitState.CLOSED:
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 after_shutdown(self) -> bool:
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.after_shutdown():
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)", stop_triggers, stop_blocks)
502
- for stop in itertools.chain(stop_triggers, stop_blocks):
503
- assert isinstance(stop, (Block, Trigger)) # @mypy
504
- with error_debug(stop, suppress_error=True):
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.after_shutdown():
595
+ if not self.is_shut_down():
576
596
  self._set_state(CircuitState.SHUTDOWN)
577
597
  await asyncio.sleep(0)
578
- try:
579
- await self._runner_shutdown()
580
- except Exception as err:
581
- # If an exception is propagated from _runner_shutdown, it is probably a bug.
582
- # Calling abort is not necessary when shutting down, but the call will log
583
- # and register the exception to be included in the final ExceptionGroup.
584
- self.abort(err)
585
- self._set_state(CircuitState.CLOSED)
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.after_shutdown():
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.after_shutdown():
640
+ if self.is_shut_down():
620
641
  return
621
- if self._state == CircuitState.UNDER_CONSTRUCTION:
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.after_shutdown():
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.after_shutdown():
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.after_shutdown():
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 accuracy (in seconds)
25
- _TT_OK = 0.001 # desired accuracy
26
- _TT_WARNING = 0.1 # log a warning when exceeded
27
- _TT_ERROR = 2.5 # do a reset when exceeded
28
- _SYNC_SLEEP = 0.000_2 # sleeps <= 200 µs can be blocking for sake of accuracy
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
- # hourly wake-ups for precise time tracking and early detection of DST changes
31
- _SET24 = frozenset(dt.time(hour, 0, 0) for hour in range(24))
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
- times_of_day = set(times_of_day) # make a set, make a copy
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
- if tod not in _SET24:
99
- do_reload = True # new entry added to timetable
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
- reload_flag = True # reload will also initialize the index
117
- short_sleep = False # alternative sleep function used => do not compute overhead
118
- while True:
119
- if reload_flag:
120
- timetable = sorted(_SET24.union(self._alarms))
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
- if index is None:
129
- # reload is set before entering the loop -> "tlen" gets initialized
130
- # pylint: disable-next=possibly-used-before-assignment
131
- index = bisect.bisect_left(timetable, nowt) % tlen
132
- wakeup = timetable[index]
133
- self.log_debug1("wakeup time: %s", wakeup)
134
-
135
- # sleep until the wakeup time:
136
- # step 0 - compute the delay until wakeup time
137
- # - sleep
138
- # step 1 - check the current time, adjust overhead estimate,
139
- # A: finish if the time is correct, or
140
- # B: add a tiny sleep if woken up too early, because
141
- # continuing before wakeup time is not acceptable
142
- # C: do a reset if the time is way off
143
- # step 2 - check time after 1B,
144
- # A: finish if the time is correct
145
- # B: do a reset otherwise
146
- for step in range(3):
147
- # datetime.time does not support time arithmetic
148
- sleeptime = (SEC_PER_HOUR*(wakeup.hour - nowt.hour)
149
- + SEC_PER_MIN*(wakeup.minute - nowt.minute)
150
- + (wakeup.second - nowt.second)
151
- + (wakeup.microsecond - nowt.microsecond)/ 1_000_000.0)
152
- if nowt.hour == 23 and wakeup.hour == 0:
153
- # wrap around midnight (relying on hourly wakeups in SET24)
154
- sleeptime += SEC_PER_DAY
155
- # sleeptime: negative = after the alarm time; positive = before the alarm time
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
- self.log_debug2("sleep until wakeup: %.3f sec", sleeptime)
158
- if step >= 1 or sleeptime < 0:
172
+ if debug2:
173
+ self.log_debug("sleep until wake up: %.3f sec", sleeptime)
174
+ else:
159
175
  diff = abs(sleeptime)
160
- if get_debug_level() >= 2:
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: %.2f ms.",
168
- wakeup, nowt, 1000*diff)
169
- if (step == 2 and sleeptime > 0) or diff > _TT_ERROR:
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 step == 1 and not short_sleep and not -_TT_OK <= sleeptime <= 0:
174
- overhead -= (sleeptime + _TT_OK/2) / 2 # average of new and old
175
- if sleeptime <= 0:
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 get_debug_level() >= 2:
202
+ if debug2:
178
203
  self.log_debug("additional sleep %.2f ms", 1000*sleeptime)
204
+ prev_sleeptime = sleeptime
179
205
 
180
- if sleeptime == 0.0:
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
- self._do_reload.clear()
199
- reload_flag = True
200
- break
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 nowdt.isoweekday() >= 6
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 reload_flag:
235
+ if self._do_reload.is_set():
222
236
  continue
223
237
 
224
- if wakeup in self._alarms:
225
- # .rz_cron_event() may alter the set we are iterating over
226
- block_list = list(self._alarms[wakeup])
227
- if get_debug_level() >= 1:
228
- self.log_debug(
229
- "Notifying blocks: %s", ", ".join(blk.name for blk in block_list))
230
- for blk in block_list:
231
- assert hasattr(blk, 'rz_cron_event')
232
- blk.rz_cron_event(nowdt)
233
- index = (index + 1) % tlen
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(recalc_time): sorted(blk.name for blk in blk_set)
240
- for recalc_time, blk_set in self._alarms.items()},
241
- 'blocks': {blk.name: sorted(str(recalc_time) for recalc_time in times_set)
242
- for blk, times_set in self._reversed.items()},
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
  }