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.
Files changed (47) hide show
  1. sg_event_daemon/__init__.py +9 -0
  2. sg_event_daemon/__main__.py +8 -0
  3. sg_event_daemon/_checkpoint.py +236 -0
  4. sg_event_daemon/_checkpoint_host.py +226 -0
  5. sg_event_daemon/_cli.py +990 -0
  6. sg_event_daemon/_config.py +1478 -0
  7. sg_event_daemon/_control.py +676 -0
  8. sg_event_daemon/_downstream.py +144 -0
  9. sg_event_daemon/_event_frame.py +60 -0
  10. sg_event_daemon/_events.py +452 -0
  11. sg_event_daemon/_fanout.py +868 -0
  12. sg_event_daemon/_group_delivery.py +729 -0
  13. sg_event_daemon/_group_errors.py +17 -0
  14. sg_event_daemon/_group_obligations.py +258 -0
  15. sg_event_daemon/_group_recovery.py +2791 -0
  16. sg_event_daemon/_group_state.py +1943 -0
  17. sg_event_daemon/_group_store.py +341 -0
  18. sg_event_daemon/_group_types.py +827 -0
  19. sg_event_daemon/_health.py +887 -0
  20. sg_event_daemon/_identity.py +68 -0
  21. sg_event_daemon/_ipc.py +5035 -0
  22. sg_event_daemon/_lane.py +1872 -0
  23. sg_event_daemon/_logging.py +402 -0
  24. sg_event_daemon/_platform.py +714 -0
  25. sg_event_daemon/_plugins.py +1307 -0
  26. sg_event_daemon/_probe.py +595 -0
  27. sg_event_daemon/_providers.py +376 -0
  28. sg_event_daemon/_resources.py +66 -0
  29. sg_event_daemon/_source.py +2484 -0
  30. sg_event_daemon/_source_obligations.py +652 -0
  31. sg_event_daemon/_source_schema.py +338 -0
  32. sg_event_daemon/_source_state.py +1095 -0
  33. sg_event_daemon/_source_store.py +1293 -0
  34. sg_event_daemon/_sqlite.py +394 -0
  35. sg_event_daemon/_state_paths.py +71 -0
  36. sg_event_daemon/_strict.py +1193 -0
  37. sg_event_daemon/_supervisor.py +5859 -0
  38. sg_event_daemon/_watchdog.py +337 -0
  39. sg_event_daemon/_worker.py +5519 -0
  40. sg_event_daemon/_write_lock.py +551 -0
  41. sg_event_daemon/py.typed +0 -0
  42. sg_event_daemon/testing.py +639 -0
  43. sg_event_daemon-0.1.0.dist-info/METADATA +224 -0
  44. sg_event_daemon-0.1.0.dist-info/RECORD +47 -0
  45. sg_event_daemon-0.1.0.dist-info/WHEEL +4 -0
  46. sg_event_daemon-0.1.0.dist-info/entry_points.txt +2 -0
  47. 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,8 @@
1
+ """Run the shared command shell with `python -m sg_event_daemon`."""
2
+
3
+ import sys
4
+
5
+ from sg_event_daemon._cli import main
6
+
7
+ if __name__ == '__main__':
8
+ sys.exit(main())
@@ -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()