sg-event-daemon 0.1.0__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.
- sg_event_daemon/__init__.py +9 -0
- sg_event_daemon/__main__.py +8 -0
- sg_event_daemon/_checkpoint.py +236 -0
- sg_event_daemon/_checkpoint_host.py +226 -0
- sg_event_daemon/_cli.py +990 -0
- sg_event_daemon/_config.py +1478 -0
- sg_event_daemon/_control.py +676 -0
- sg_event_daemon/_downstream.py +144 -0
- sg_event_daemon/_event_frame.py +60 -0
- sg_event_daemon/_events.py +452 -0
- sg_event_daemon/_fanout.py +868 -0
- sg_event_daemon/_group_delivery.py +729 -0
- sg_event_daemon/_group_errors.py +17 -0
- sg_event_daemon/_group_obligations.py +258 -0
- sg_event_daemon/_group_recovery.py +2791 -0
- sg_event_daemon/_group_state.py +1943 -0
- sg_event_daemon/_group_store.py +341 -0
- sg_event_daemon/_group_types.py +827 -0
- sg_event_daemon/_health.py +887 -0
- sg_event_daemon/_identity.py +68 -0
- sg_event_daemon/_ipc.py +5035 -0
- sg_event_daemon/_lane.py +1872 -0
- sg_event_daemon/_logging.py +402 -0
- sg_event_daemon/_platform.py +714 -0
- sg_event_daemon/_plugins.py +1307 -0
- sg_event_daemon/_probe.py +595 -0
- sg_event_daemon/_providers.py +376 -0
- sg_event_daemon/_resources.py +66 -0
- sg_event_daemon/_source.py +2484 -0
- sg_event_daemon/_source_obligations.py +652 -0
- sg_event_daemon/_source_schema.py +338 -0
- sg_event_daemon/_source_state.py +1095 -0
- sg_event_daemon/_source_store.py +1293 -0
- sg_event_daemon/_sqlite.py +394 -0
- sg_event_daemon/_state_paths.py +71 -0
- sg_event_daemon/_strict.py +1193 -0
- sg_event_daemon/_supervisor.py +5859 -0
- sg_event_daemon/_watchdog.py +337 -0
- sg_event_daemon/_worker.py +5519 -0
- sg_event_daemon/_write_lock.py +551 -0
- sg_event_daemon/py.typed +0 -0
- sg_event_daemon/testing.py +639 -0
- sg_event_daemon-0.1.0.dist-info/METADATA +224 -0
- sg_event_daemon-0.1.0.dist-info/RECORD +47 -0
- sg_event_daemon-0.1.0.dist-info/WHEEL +4 -0
- sg_event_daemon-0.1.0.dist-info/entry_points.txt +2 -0
- sg_event_daemon-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Package the sg-event-daemon service and its supported import paths.
|
|
2
|
+
|
|
3
|
+
Run the foreground service and operator commands through the `sg-event-daemon`
|
|
4
|
+
console script or `python -m sg_event_daemon`. Use `sg_event_daemon.testing` to
|
|
5
|
+
load and drive trusted plugins with synthetic events offline. The package root
|
|
6
|
+
exports no public names.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
__all__: list[str] = []
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""Classify passive WAL checkpoints and enforce their runtime bounds."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
import random
|
|
7
|
+
import sqlite3
|
|
8
|
+
import time
|
|
9
|
+
from collections import deque
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import TYPE_CHECKING, Literal, TypeAlias, TypedDict, Unpack
|
|
12
|
+
|
|
13
|
+
from sg_event_daemon._sqlite import DatabaseError
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from collections.abc import Callable
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
CheckpointOutcome: TypeAlias = Literal[
|
|
20
|
+
'complete',
|
|
21
|
+
'reset-observed',
|
|
22
|
+
'contended',
|
|
23
|
+
'partial-no-progress',
|
|
24
|
+
'partial-progress',
|
|
25
|
+
]
|
|
26
|
+
CheckpointResult: TypeAlias = tuple[int, int, int]
|
|
27
|
+
CheckpointState: TypeAlias = Literal['idle', 'complete', 'incomplete']
|
|
28
|
+
|
|
29
|
+
_WAL_IN_USE_LIMIT_BYTES = 256 * 1024 * 1024
|
|
30
|
+
_CHECKPOINT_FAILURE_INTERVALS = 5
|
|
31
|
+
_CLEARING_OUTCOMES: frozenset[CheckpointOutcome] = frozenset({'complete', 'reset-observed'})
|
|
32
|
+
_RECENT_ATTEMPTS = 8
|
|
33
|
+
_WAL_FRAME_HEADER_BYTES = 24
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class CheckpointAttempt:
|
|
38
|
+
"""Record one classified passive checkpoint attempt.
|
|
39
|
+
|
|
40
|
+
Attributes:
|
|
41
|
+
outcome: Classification under the checkpoint policy.
|
|
42
|
+
result: The `(busy, log, checkpointed)` row of the final try, which is
|
|
43
|
+
the retry's row when the first try was contended.
|
|
44
|
+
retried: Whether the first try was contended and retried.
|
|
45
|
+
finished_at: Monotonic time when the attempt was classified.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
outcome: CheckpointOutcome
|
|
49
|
+
result: CheckpointResult
|
|
50
|
+
retried: bool
|
|
51
|
+
finished_at: float
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class CheckpointBoundError(DatabaseError):
|
|
55
|
+
"""Report a runtime bound violation with its classified checkpoint attempt.
|
|
56
|
+
|
|
57
|
+
Attributes:
|
|
58
|
+
attempt: The observed attempt that triggered the bound.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
def __init__(self, message: str, attempt: CheckpointAttempt) -> None:
|
|
62
|
+
"""Retain the classified attempt alongside the diagnostic message."""
|
|
63
|
+
super().__init__(message)
|
|
64
|
+
self.attempt = attempt
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class _CheckpointerSeams(TypedDict, total=False):
|
|
68
|
+
"""Type optional clock, randomness, result, sleep, and bound replacements for tests."""
|
|
69
|
+
|
|
70
|
+
clock: Callable[[], float]
|
|
71
|
+
random_value: Callable[[], float]
|
|
72
|
+
run_checkpoint: Callable[[], CheckpointResult]
|
|
73
|
+
sleep: Callable[[float], None]
|
|
74
|
+
wal_limit_bytes: int
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class Checkpointer:
|
|
78
|
+
"""Run one process's passive checkpoints on one database file.
|
|
79
|
+
|
|
80
|
+
Each `run_once()` call makes one attempt; a contended first try is retried
|
|
81
|
+
once after a random delay of up to half an interval. Attempts are
|
|
82
|
+
classified as complete, reset observed, contended, or partial with or
|
|
83
|
+
without progress.
|
|
84
|
+
The checkpointer halts by raising `DatabaseError` when no complete or
|
|
85
|
+
reset-observed attempt has occurred for five intervals, measured from the
|
|
86
|
+
last such attempt or from construction, or when a non-contended attempt
|
|
87
|
+
reports more committed WAL frames than `_WAL_IN_USE_LIMIT_BYTES` occupy.
|
|
88
|
+
That limit is a tripwire on observed committed frames, not a ceiling on the
|
|
89
|
+
WAL file's size. Use it only from the thread that opened its connection.
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
def __init__(
|
|
93
|
+
self,
|
|
94
|
+
connection: sqlite3.Connection,
|
|
95
|
+
path: Path,
|
|
96
|
+
*,
|
|
97
|
+
interval_secs: float,
|
|
98
|
+
**seams: Unpack[_CheckpointerSeams],
|
|
99
|
+
) -> None:
|
|
100
|
+
"""Prepare a checkpointer for an open WAL connection.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
connection: Connection dedicated to checkpoints on this file.
|
|
104
|
+
path: Database path named in halt messages.
|
|
105
|
+
interval_secs: Scheduled time between attempts; the duration bound
|
|
106
|
+
and the retry delay derive from it.
|
|
107
|
+
**seams: Optional replacements for tests.
|
|
108
|
+
|
|
109
|
+
Raises:
|
|
110
|
+
ValueError: `interval_secs` is not finite and positive, or
|
|
111
|
+
`wal_limit_bytes` is not positive.
|
|
112
|
+
DatabaseError: SQLite cannot report the page size.
|
|
113
|
+
"""
|
|
114
|
+
if not math.isfinite(interval_secs) or interval_secs <= 0:
|
|
115
|
+
raise ValueError('checkpoint interval must be finite and positive')
|
|
116
|
+
wal_limit_bytes = seams.get('wal_limit_bytes', _WAL_IN_USE_LIMIT_BYTES)
|
|
117
|
+
if wal_limit_bytes <= 0:
|
|
118
|
+
raise ValueError('WAL limit must be positive')
|
|
119
|
+
self._connection = connection
|
|
120
|
+
self._path = path
|
|
121
|
+
self._interval_secs = interval_secs
|
|
122
|
+
self._clock = seams.get('clock', time.monotonic)
|
|
123
|
+
self._random_value = seams.get('random_value', random.random)
|
|
124
|
+
self._sleep = seams.get('sleep', time.sleep)
|
|
125
|
+
self._run_checkpoint = seams.get('run_checkpoint', self._passive_checkpoint)
|
|
126
|
+
self._wal_limit_bytes = wal_limit_bytes
|
|
127
|
+
self._frame_bytes = _page_size(connection) + _WAL_FRAME_HEADER_BYTES
|
|
128
|
+
self._cleared_at = self._clock()
|
|
129
|
+
self._previous_log: int | None = None
|
|
130
|
+
self._previous_checkpointed: int | None = None
|
|
131
|
+
self._recent: deque[CheckpointAttempt] = deque(maxlen=_RECENT_ATTEMPTS)
|
|
132
|
+
self._state: CheckpointState = 'idle'
|
|
133
|
+
|
|
134
|
+
def _classify(self, result: CheckpointResult) -> CheckpointOutcome:
|
|
135
|
+
"""Classify one final try against this checkpointer's previous sample."""
|
|
136
|
+
busy, log_frames, checkpointed = result
|
|
137
|
+
if busy != 0:
|
|
138
|
+
return 'contended'
|
|
139
|
+
if checkpointed >= log_frames:
|
|
140
|
+
return 'complete'
|
|
141
|
+
if self._previous_log is not None and log_frames < self._previous_log:
|
|
142
|
+
return 'reset-observed'
|
|
143
|
+
if self._previous_checkpointed is not None and checkpointed == self._previous_checkpointed:
|
|
144
|
+
return 'partial-no-progress'
|
|
145
|
+
return 'partial-progress'
|
|
146
|
+
|
|
147
|
+
def _halt(self, bound: str, detail: str) -> CheckpointBoundError:
|
|
148
|
+
"""Build the halt error naming the file, the bound, and the recent attempts."""
|
|
149
|
+
elapsed = self._clock() - self._cleared_at
|
|
150
|
+
recent = ', '.join(f'{attempt.outcome} {attempt.result}' for attempt in self._recent)
|
|
151
|
+
return CheckpointBoundError(
|
|
152
|
+
f'checkpoint {bound} bound exceeded for {self._path}: {detail}; '
|
|
153
|
+
f'{elapsed:.3f}s since the last complete or reset-observed attempt; '
|
|
154
|
+
f'recent attempts: {recent}',
|
|
155
|
+
self._recent[-1],
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
def _passive_checkpoint(self) -> CheckpointResult:
|
|
159
|
+
"""Run one passive checkpoint on the owned connection."""
|
|
160
|
+
try:
|
|
161
|
+
row = self._connection.execute('PRAGMA wal_checkpoint(PASSIVE)').fetchone()
|
|
162
|
+
except sqlite3.Error as exc:
|
|
163
|
+
raise DatabaseError(str(exc)) from exc
|
|
164
|
+
if row is None:
|
|
165
|
+
raise DatabaseError('SQLite returned no passive checkpoint result')
|
|
166
|
+
busy, log_frames, checkpointed = (int(value) for value in row)
|
|
167
|
+
return busy, log_frames, checkpointed
|
|
168
|
+
|
|
169
|
+
def _record(self, attempt: CheckpointAttempt) -> None:
|
|
170
|
+
"""Update this checkpointer's history and raise when a bound is exceeded."""
|
|
171
|
+
self._recent.append(attempt)
|
|
172
|
+
cleared = attempt.outcome in _CLEARING_OUTCOMES
|
|
173
|
+
self._state = 'complete' if cleared else 'incomplete'
|
|
174
|
+
if cleared:
|
|
175
|
+
self._cleared_at = attempt.finished_at
|
|
176
|
+
_busy, log_frames, checkpointed = attempt.result
|
|
177
|
+
if attempt.outcome != 'contended':
|
|
178
|
+
self._previous_log = log_frames
|
|
179
|
+
self._previous_checkpointed = checkpointed
|
|
180
|
+
if log_frames * self._frame_bytes > self._wal_limit_bytes:
|
|
181
|
+
raise self._halt(
|
|
182
|
+
'WAL growth',
|
|
183
|
+
f'{log_frames} committed frames exceed {self._wal_limit_bytes} bytes',
|
|
184
|
+
)
|
|
185
|
+
incomplete_secs = attempt.finished_at - self._cleared_at
|
|
186
|
+
if incomplete_secs >= _CHECKPOINT_FAILURE_INTERVALS * self._interval_secs:
|
|
187
|
+
raise self._halt(
|
|
188
|
+
'incomplete duration',
|
|
189
|
+
f'{_CHECKPOINT_FAILURE_INTERVALS} intervals of {self._interval_secs}s elapsed',
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
@property
|
|
193
|
+
def frame_bytes(self) -> int:
|
|
194
|
+
"""Return the bytes one WAL frame occupies: the page size plus its header."""
|
|
195
|
+
return self._frame_bytes
|
|
196
|
+
|
|
197
|
+
def run_once(self) -> CheckpointAttempt:
|
|
198
|
+
"""Make one checkpoint attempt and enforce both bounds.
|
|
199
|
+
|
|
200
|
+
Returns:
|
|
201
|
+
The classified attempt.
|
|
202
|
+
|
|
203
|
+
Raises:
|
|
204
|
+
CheckpointBoundError: The classified attempt exceeded the
|
|
205
|
+
incomplete-duration bound or the WAL growth tripwire.
|
|
206
|
+
DatabaseError: SQLite failed before an attempt could be classified.
|
|
207
|
+
"""
|
|
208
|
+
result = self._run_checkpoint()
|
|
209
|
+
retried = result[0] != 0
|
|
210
|
+
if retried:
|
|
211
|
+
self._sleep(self._random_value() * self._interval_secs / 2)
|
|
212
|
+
result = self._run_checkpoint()
|
|
213
|
+
attempt = CheckpointAttempt(
|
|
214
|
+
outcome=self._classify(result),
|
|
215
|
+
result=result,
|
|
216
|
+
retried=retried,
|
|
217
|
+
finished_at=self._clock(),
|
|
218
|
+
)
|
|
219
|
+
self._record(attempt)
|
|
220
|
+
return attempt
|
|
221
|
+
|
|
222
|
+
@property
|
|
223
|
+
def state(self) -> CheckpointState:
|
|
224
|
+
"""Return `idle` before the first attempt, then `complete` or `incomplete`."""
|
|
225
|
+
return self._state
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _page_size(connection: sqlite3.Connection) -> int:
|
|
229
|
+
"""Return the database page size that sizes each WAL frame."""
|
|
230
|
+
try:
|
|
231
|
+
row = connection.execute('PRAGMA page_size').fetchone()
|
|
232
|
+
except sqlite3.Error as exc:
|
|
233
|
+
raise DatabaseError(str(exc)) from exc
|
|
234
|
+
if row is None:
|
|
235
|
+
raise DatabaseError('SQLite returned no page size')
|
|
236
|
+
return int(row[0])
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""Own independently timed checkpoint connections for each process-local file."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
from typing import TYPE_CHECKING, TypedDict, Unpack
|
|
9
|
+
|
|
10
|
+
from ._checkpoint import Checkpointer, CheckpointState
|
|
11
|
+
from ._resources import close_resources
|
|
12
|
+
from ._sqlite import Schema, open_database
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from collections.abc import Callable
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from types import TracebackType
|
|
18
|
+
from typing import Self
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class _CheckpointFailure:
|
|
22
|
+
"""Report ordinary failures at the checkpoint thread ownership boundary."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, identity: str, on_failure: Callable[[str], None]) -> None:
|
|
25
|
+
"""Bind one file identity to the owning worker's fatal callback."""
|
|
26
|
+
self._identity = identity
|
|
27
|
+
self._on_failure = on_failure
|
|
28
|
+
|
|
29
|
+
def __enter__(self) -> Self:
|
|
30
|
+
"""Enter the checkpoint thread's failure boundary."""
|
|
31
|
+
return self
|
|
32
|
+
|
|
33
|
+
def __exit__(
|
|
34
|
+
self,
|
|
35
|
+
exc_type: type[BaseException] | None,
|
|
36
|
+
exc_value: BaseException | None,
|
|
37
|
+
traceback: TracebackType | None,
|
|
38
|
+
) -> bool:
|
|
39
|
+
"""Report ordinary errors once while propagating process-control errors."""
|
|
40
|
+
if not isinstance(exc_value, Exception):
|
|
41
|
+
return False
|
|
42
|
+
self._on_failure(
|
|
43
|
+
f'checkpoint:{self._identity} thread failed: {type(exc_value).__name__}: {exc_value}'
|
|
44
|
+
)
|
|
45
|
+
return True
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class _CheckpointStatus(TypedDict):
|
|
49
|
+
"""Share a lock and the latest state between the owner and its views."""
|
|
50
|
+
|
|
51
|
+
lock: threading.Lock
|
|
52
|
+
state: CheckpointState
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class CheckpointHostStoppedError(RuntimeError):
|
|
56
|
+
"""Report registration interrupted by the host's shutdown request."""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class CheckpointView:
|
|
60
|
+
"""Provide a thread-safe read-only view of one file's checkpoint state."""
|
|
61
|
+
|
|
62
|
+
def __init__(self, status: _CheckpointStatus | None = None) -> None:
|
|
63
|
+
"""Share a host-owned state cell, or represent an unregistered idle file."""
|
|
64
|
+
self._status: _CheckpointStatus = (
|
|
65
|
+
status if status is not None else {'lock': threading.Lock(), 'state': 'idle'}
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
def state(self) -> CheckpointState:
|
|
69
|
+
"""Return idle before the first attempt, then complete or incomplete."""
|
|
70
|
+
with self._status['lock']:
|
|
71
|
+
return self._status['state']
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class _CheckpointHostSeams(TypedDict, total=False):
|
|
75
|
+
"""Type the optional monotonic clock and checkpointer factory replacements."""
|
|
76
|
+
|
|
77
|
+
clock: Callable[[], float]
|
|
78
|
+
checkpointer_factory: Callable[..., Checkpointer]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class _CheckpointFile(TypedDict):
|
|
82
|
+
"""Hold one file's thread inputs and synchronized health output."""
|
|
83
|
+
|
|
84
|
+
path: Path
|
|
85
|
+
identity: str
|
|
86
|
+
schema: Schema
|
|
87
|
+
first_due: float
|
|
88
|
+
status: _CheckpointStatus
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class CheckpointHost:
|
|
92
|
+
"""Own one daemon checkpoint thread per resolved database path.
|
|
93
|
+
|
|
94
|
+
Register files after their owner initializes them. Each checkpoint thread
|
|
95
|
+
opens and closes its own existing-only connection. `close()` interrupts
|
|
96
|
+
timer waits and joins all threads; registration after close is rejected.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
def __init__(
|
|
100
|
+
self,
|
|
101
|
+
*,
|
|
102
|
+
interval_secs: float,
|
|
103
|
+
synchronous: str,
|
|
104
|
+
on_failure: Callable[[str], None],
|
|
105
|
+
**seams: Unpack[_CheckpointHostSeams],
|
|
106
|
+
) -> None:
|
|
107
|
+
"""Configure independent file timers and the fatal-failure callback.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
interval_secs: Finite positive time between checkpoint attempts.
|
|
111
|
+
synchronous: SQLite synchronous policy for each owned connection.
|
|
112
|
+
on_failure: Called once when a file's checkpoint thread fails.
|
|
113
|
+
**seams: Optional monotonic clock and checkpointer factory.
|
|
114
|
+
|
|
115
|
+
Raises:
|
|
116
|
+
ValueError: `interval_secs` is not finite and positive.
|
|
117
|
+
"""
|
|
118
|
+
if not math.isfinite(interval_secs) or interval_secs <= 0:
|
|
119
|
+
raise ValueError('checkpoint interval must be finite and positive')
|
|
120
|
+
self._interval_secs = interval_secs
|
|
121
|
+
self._synchronous = synchronous
|
|
122
|
+
self._on_failure = on_failure
|
|
123
|
+
self._clock = seams.get('clock', time.monotonic)
|
|
124
|
+
self._checkpointer_factory = seams.get('checkpointer_factory', Checkpointer)
|
|
125
|
+
self._lock = threading.Lock()
|
|
126
|
+
self._stop = threading.Event()
|
|
127
|
+
self._files: dict[Path, tuple[CheckpointView, threading.Thread]] = {}
|
|
128
|
+
|
|
129
|
+
def _run(self, file: _CheckpointFile) -> None:
|
|
130
|
+
"""Open, checkpoint, and close one file entirely on its owning thread."""
|
|
131
|
+
with _CheckpointFailure(file['identity'], self._on_failure):
|
|
132
|
+
database = open_database(
|
|
133
|
+
file['path'],
|
|
134
|
+
file['identity'],
|
|
135
|
+
file['schema'],
|
|
136
|
+
synchronous=self._synchronous,
|
|
137
|
+
create=False,
|
|
138
|
+
)
|
|
139
|
+
try:
|
|
140
|
+
checkpointer = self._checkpointer_factory(
|
|
141
|
+
database.connection,
|
|
142
|
+
file['path'],
|
|
143
|
+
interval_secs=self._interval_secs,
|
|
144
|
+
)
|
|
145
|
+
due = file['first_due']
|
|
146
|
+
while not self._stop.wait(max(0.0, due - self._clock())):
|
|
147
|
+
try:
|
|
148
|
+
checkpointer.run_once()
|
|
149
|
+
finally:
|
|
150
|
+
with file['status']['lock']:
|
|
151
|
+
file['status']['state'] = checkpointer.state
|
|
152
|
+
elapsed = max(0.0, self._clock() - due)
|
|
153
|
+
due += (int(elapsed // self._interval_secs) + 1) * self._interval_secs
|
|
154
|
+
except BaseException as error:
|
|
155
|
+
close_resources((database,), primary_error=error)
|
|
156
|
+
raise
|
|
157
|
+
else:
|
|
158
|
+
database.close()
|
|
159
|
+
|
|
160
|
+
def register(self, path: Path, identity: str, schema: Schema) -> CheckpointView:
|
|
161
|
+
"""Start one timer for an initialized file, reusing resolved-path views.
|
|
162
|
+
|
|
163
|
+
The first attempt waits one interval plus this registration's
|
|
164
|
+
`interval * index / count` offset. Later registrations do not change
|
|
165
|
+
existing timers.
|
|
166
|
+
|
|
167
|
+
Args:
|
|
168
|
+
path: Initialized database path; registration uses its resolved
|
|
169
|
+
path.
|
|
170
|
+
identity: Stable identity matching the database's stored identity.
|
|
171
|
+
A mismatch fails asynchronously through `on_failure`.
|
|
172
|
+
schema: Database schema used when the checkpoint thread opens it.
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
The checkpoint view for the resolved path. Re-registration returns
|
|
176
|
+
the existing view and ignores the new `identity` and `schema`.
|
|
177
|
+
|
|
178
|
+
Raises:
|
|
179
|
+
CheckpointHostStoppedError: This host is stopping or closed.
|
|
180
|
+
"""
|
|
181
|
+
path = path.resolve()
|
|
182
|
+
with self._lock:
|
|
183
|
+
if self._stop.is_set():
|
|
184
|
+
raise CheckpointHostStoppedError('checkpoint host is stopping or closed')
|
|
185
|
+
existing = self._files.get(path)
|
|
186
|
+
if existing is not None:
|
|
187
|
+
return existing[0]
|
|
188
|
+
index = len(self._files)
|
|
189
|
+
first_due = self._clock() + self._interval_secs * (1 + index / (index + 1))
|
|
190
|
+
file = _CheckpointFile(
|
|
191
|
+
path=path,
|
|
192
|
+
identity=identity,
|
|
193
|
+
schema=schema,
|
|
194
|
+
first_due=first_due,
|
|
195
|
+
status={'lock': threading.Lock(), 'state': 'idle'},
|
|
196
|
+
)
|
|
197
|
+
view = CheckpointView(file['status'])
|
|
198
|
+
thread = threading.Thread(
|
|
199
|
+
target=self._run,
|
|
200
|
+
args=(file,),
|
|
201
|
+
daemon=True,
|
|
202
|
+
name=f'sg-event-daemon:checkpoint:{identity}',
|
|
203
|
+
)
|
|
204
|
+
thread.start()
|
|
205
|
+
self._files[path] = view, thread
|
|
206
|
+
return view
|
|
207
|
+
|
|
208
|
+
def request_stop(self) -> None:
|
|
209
|
+
"""Interrupt schedule waits and reject new registrations without joining."""
|
|
210
|
+
self._stop.set()
|
|
211
|
+
|
|
212
|
+
def threads(self) -> tuple[threading.Thread, ...]:
|
|
213
|
+
"""Return a stable snapshot of started owners for monitored, bounded joins.
|
|
214
|
+
|
|
215
|
+
Each thread owns its database connection through final cleanup. A
|
|
216
|
+
stopped thread therefore has no remaining connection work.
|
|
217
|
+
"""
|
|
218
|
+
with self._lock:
|
|
219
|
+
return tuple(thread for _view, thread in self._files.values())
|
|
220
|
+
|
|
221
|
+
def close(self) -> None:
|
|
222
|
+
"""Interrupt timer waits and join threads after their connections close."""
|
|
223
|
+
self.request_stop()
|
|
224
|
+
for thread in self.threads():
|
|
225
|
+
if thread is not threading.current_thread():
|
|
226
|
+
thread.join()
|