messagefoundry 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 (142) hide show
  1. messagefoundry/__init__.py +108 -0
  2. messagefoundry/__main__.py +1155 -0
  3. messagefoundry/api/__init__.py +27 -0
  4. messagefoundry/api/app.py +1581 -0
  5. messagefoundry/api/approvals.py +184 -0
  6. messagefoundry/api/auth_models.py +211 -0
  7. messagefoundry/api/auth_routes.py +655 -0
  8. messagefoundry/api/field_authz.py +96 -0
  9. messagefoundry/api/models.py +374 -0
  10. messagefoundry/api/security.py +247 -0
  11. messagefoundry/api/tls.py +47 -0
  12. messagefoundry/auth/__init__.py +39 -0
  13. messagefoundry/auth/data/common_passwords.NOTICE +13 -0
  14. messagefoundry/auth/data/common_passwords.txt +10000 -0
  15. messagefoundry/auth/identity.py +71 -0
  16. messagefoundry/auth/ldap.py +264 -0
  17. messagefoundry/auth/notifications.py +68 -0
  18. messagefoundry/auth/passwords.py +53 -0
  19. messagefoundry/auth/permissions.py +120 -0
  20. messagefoundry/auth/policy.py +153 -0
  21. messagefoundry/auth/ratelimit.py +55 -0
  22. messagefoundry/auth/service.py +1323 -0
  23. messagefoundry/auth/tokens.py +26 -0
  24. messagefoundry/auth/totp.py +174 -0
  25. messagefoundry/checks.py +174 -0
  26. messagefoundry/config/__init__.py +30 -0
  27. messagefoundry/config/active_environment.py +80 -0
  28. messagefoundry/config/ai_policy.py +140 -0
  29. messagefoundry/config/code_sets.py +260 -0
  30. messagefoundry/config/connections_edit.py +200 -0
  31. messagefoundry/config/connections_file.py +287 -0
  32. messagefoundry/config/db_lookup.py +117 -0
  33. messagefoundry/config/environments.py +116 -0
  34. messagefoundry/config/ingest_time.py +83 -0
  35. messagefoundry/config/models.py +240 -0
  36. messagefoundry/config/reference.py +158 -0
  37. messagefoundry/config/response.py +83 -0
  38. messagefoundry/config/run_context.py +153 -0
  39. messagefoundry/config/settings.py +1311 -0
  40. messagefoundry/config/state.py +99 -0
  41. messagefoundry/config/tls_policy.py +110 -0
  42. messagefoundry/config/wiring.py +1918 -0
  43. messagefoundry/console/__init__.py +20 -0
  44. messagefoundry/console/__main__.py +274 -0
  45. messagefoundry/console/_async.py +107 -0
  46. messagefoundry/console/change_password.py +111 -0
  47. messagefoundry/console/client.py +552 -0
  48. messagefoundry/console/connections.py +324 -0
  49. messagefoundry/console/login.py +107 -0
  50. messagefoundry/console/mfa.py +205 -0
  51. messagefoundry/console/reauth.py +94 -0
  52. messagefoundry/console/search.py +57 -0
  53. messagefoundry/console/service_control.py +137 -0
  54. messagefoundry/console/sessions.py +122 -0
  55. messagefoundry/console/shell.py +410 -0
  56. messagefoundry/console/status.py +377 -0
  57. messagefoundry/console/users_page.py +282 -0
  58. messagefoundry/console/widgets.py +553 -0
  59. messagefoundry/generators/README.md +27 -0
  60. messagefoundry/generators/__init__.py +15 -0
  61. messagefoundry/generators/_core.py +589 -0
  62. messagefoundry/generators/_hl7data.py +428 -0
  63. messagefoundry/generators/adt.py +286 -0
  64. messagefoundry/generators/all_types.py +24 -0
  65. messagefoundry/generators/bar.py +28 -0
  66. messagefoundry/generators/dft.py +20 -0
  67. messagefoundry/generators/mdm.py +39 -0
  68. messagefoundry/generators/mfn.py +46 -0
  69. messagefoundry/generators/oml.py +32 -0
  70. messagefoundry/generators/orl.py +30 -0
  71. messagefoundry/generators/orm.py +23 -0
  72. messagefoundry/generators/oru.py +21 -0
  73. messagefoundry/generators/ras.py +20 -0
  74. messagefoundry/generators/rde.py +54 -0
  75. messagefoundry/generators/siu.py +64 -0
  76. messagefoundry/generators/vxu.py +20 -0
  77. messagefoundry/hl7schema.py +75 -0
  78. messagefoundry/last_resort.py +55 -0
  79. messagefoundry/logging_setup.py +332 -0
  80. messagefoundry/parsing/__init__.py +64 -0
  81. messagefoundry/parsing/consistency.py +166 -0
  82. messagefoundry/parsing/groups.py +228 -0
  83. messagefoundry/parsing/message.py +453 -0
  84. messagefoundry/parsing/peek.py +237 -0
  85. messagefoundry/parsing/split.py +120 -0
  86. messagefoundry/parsing/summary.py +46 -0
  87. messagefoundry/parsing/tree.py +128 -0
  88. messagefoundry/parsing/validate.py +95 -0
  89. messagefoundry/parsing/x12/__init__.py +46 -0
  90. messagefoundry/parsing/x12/delimiters.py +140 -0
  91. messagefoundry/parsing/x12/errors.py +30 -0
  92. messagefoundry/parsing/x12/interchange.py +232 -0
  93. messagefoundry/parsing/x12/message.py +200 -0
  94. messagefoundry/parsing/x12/peek.py +207 -0
  95. messagefoundry/pipeline/__init__.py +21 -0
  96. messagefoundry/pipeline/alert_sinks.py +486 -0
  97. messagefoundry/pipeline/alerts.py +100 -0
  98. messagefoundry/pipeline/cert_expiry.py +219 -0
  99. messagefoundry/pipeline/cluster.py +955 -0
  100. messagefoundry/pipeline/cluster_sqlserver.py +444 -0
  101. messagefoundry/pipeline/config_convergence.py +137 -0
  102. messagefoundry/pipeline/dryrun.py +450 -0
  103. messagefoundry/pipeline/engine.py +756 -0
  104. messagefoundry/pipeline/leader_tasks.py +158 -0
  105. messagefoundry/pipeline/reference_sync.py +369 -0
  106. messagefoundry/pipeline/retention.py +289 -0
  107. messagefoundry/pipeline/security_notify.py +168 -0
  108. messagefoundry/pipeline/state_convergence.py +143 -0
  109. messagefoundry/pipeline/wiring_runner.py +1722 -0
  110. messagefoundry/py.typed +0 -0
  111. messagefoundry/redaction.py +71 -0
  112. messagefoundry/scaffold.py +321 -0
  113. messagefoundry/secrets_dpapi.py +129 -0
  114. messagefoundry/store/__init__.py +46 -0
  115. messagefoundry/store/audit_tee.py +67 -0
  116. messagefoundry/store/base.py +758 -0
  117. messagefoundry/store/crypto.py +166 -0
  118. messagefoundry/store/keyprovider.py +192 -0
  119. messagefoundry/store/postgres.py +3447 -0
  120. messagefoundry/store/sqlserver.py +3014 -0
  121. messagefoundry/store/store.py +3790 -0
  122. messagefoundry/timezone.py +207 -0
  123. messagefoundry/transports/__init__.py +50 -0
  124. messagefoundry/transports/base.py +269 -0
  125. messagefoundry/transports/database.py +693 -0
  126. messagefoundry/transports/file.py +551 -0
  127. messagefoundry/transports/framing.py +164 -0
  128. messagefoundry/transports/loopback.py +53 -0
  129. messagefoundry/transports/mllp.py +644 -0
  130. messagefoundry/transports/remotefile.py +664 -0
  131. messagefoundry/transports/rest.py +281 -0
  132. messagefoundry/transports/signing.py +321 -0
  133. messagefoundry/transports/soap.py +507 -0
  134. messagefoundry/transports/tcp.py +307 -0
  135. messagefoundry/transports/timer.py +146 -0
  136. messagefoundry/transports/x12.py +323 -0
  137. messagefoundry-0.1.0.dist-info/METADATA +212 -0
  138. messagefoundry-0.1.0.dist-info/RECORD +142 -0
  139. messagefoundry-0.1.0.dist-info/WHEEL +4 -0
  140. messagefoundry-0.1.0.dist-info/entry_points.txt +2 -0
  141. messagefoundry-0.1.0.dist-info/licenses/LICENSE +662 -0
  142. messagefoundry-0.1.0.dist-info/licenses/NOTICE +27 -0
@@ -0,0 +1,307 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (C) 2026 MessageFoundry Organization and contributors
3
+ """Raw-TCP transport with **configurable delimiter framing** — source + destination.
4
+
5
+ Built to relay **X12 (and other non-HL7) feeds over custom-framed TCP** opaquely: the payload
6
+ is carried as bytes and never parsed (no ISA/GS/ST inspection, no 997/TA1 acks — those are a
7
+ documented follow-up). Whether a received body is routed as a structured HL7
8
+ :class:`~messagefoundry.parsing.message.Message` or a
9
+ :class:`~messagefoundry.parsing.message.RawMessage` is decided by the **inbound's
10
+ ``content_type``** (set ``x12`` for these feeds), not by this connector (ADR 0004).
11
+
12
+ Framing is the shared :mod:`messagefoundry.transports.framing` codec, configured per connection
13
+ by a preset name (``stx_etx`` / ``vt_fs`` / ``mllp``) or explicit ``start``/``end``/``trailer``
14
+ delimiter byte ints. The 8 X12-over-TCP feeds we target split STX/ETX (``0x02``/``0x03``) and
15
+ VT/FS (the same bytes MLLP uses). The listener mirrors :class:`~messagefoundry.transports.mllp.MLLPSource`'s
16
+ DoS guards (``max_connections`` / ``receive_timeout`` / ``max_frame_bytes``) and cooperative stop.
17
+
18
+ **No HL7 ACK.** The source hands each deframed payload to the pipeline handler; if the handler
19
+ returns a non-``None`` reply it is framed and sent back on the same connection (so a Handler
20
+ *could* emit a framed reply), otherwise nothing is sent (fire-and-forget). The destination frames
21
+ and sends; with ``expect_reply`` it reads one framed reply (bounded by timeout + max-frame) and
22
+ treats any received frame as confirmation — it does **not** parse or validate the reply.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import asyncio
28
+ import logging
29
+ from collections.abc import Callable
30
+
31
+ from messagefoundry.config.models import ConnectorType, Destination, Source
32
+ from messagefoundry.redaction import safe_exc
33
+ from messagefoundry.transports.base import (
34
+ DeliveryError,
35
+ DeliveryResponse,
36
+ DestinationConnector,
37
+ InboundHandler,
38
+ SourceConnector,
39
+ peer_ip_allowed,
40
+ probe_tcp_reachable,
41
+ register_destination,
42
+ register_source,
43
+ )
44
+ from messagefoundry.transports.framing import FrameCodec, FrameError, codec_for
45
+ from messagefoundry.transports.mllp import (
46
+ DEFAULT_MAX_CONNECTIONS,
47
+ DEFAULT_MAX_FRAME_BYTES,
48
+ DEFAULT_RECEIVE_TIMEOUT,
49
+ )
50
+
51
+ __all__ = ["TcpSource", "TcpDestination"]
52
+
53
+ logger = logging.getLogger(__name__)
54
+
55
+ # Established clients are closed and their handlers given this long to finish an in-flight commit on
56
+ # stop()/reload before the connection tasks are cancelled (mirrors MLLPSource; bounds shutdown).
57
+ _CLIENT_SHUTDOWN_GRACE = 5.0
58
+
59
+
60
+ def _codec_from_settings(settings: dict[str, object]) -> FrameCodec:
61
+ """Resolve the connection's :class:`FrameCodec` from its settings (preset OR explicit bytes).
62
+
63
+ Validates at construction so a bad preset/byte fails when the connector is built (caught in
64
+ dry-run / ``messagefoundry check``), not deep in a read loop."""
65
+ framing = settings.get("framing")
66
+ try:
67
+ return codec_for(
68
+ None if framing is None else str(framing),
69
+ start=_opt_byte(settings.get("start")),
70
+ end=_opt_byte(settings.get("end")),
71
+ trailer=_opt_byte(settings.get("trailer")),
72
+ )
73
+ except ValueError as exc:
74
+ raise ValueError(f"TCP framing misconfigured: {exc}") from exc
75
+
76
+
77
+ def _opt_byte(value: object) -> int | None:
78
+ """Coerce an optional delimiter setting to an int (``None`` passes through). A non-int value
79
+ raises ``ValueError`` so a mistyped delimiter fails loud at construction."""
80
+ if value is None:
81
+ return None
82
+ if isinstance(value, bool) or not isinstance(value, (int, str)):
83
+ raise ValueError(f"delimiter byte must be an int in 0..255, got {value!r}")
84
+ return int(value)
85
+
86
+
87
+ # --- destination -------------------------------------------------------------
88
+
89
+
90
+ class TcpDestination(DestinationConnector):
91
+ """Send a payload to a raw-TCP receiver with the configured framing, relayed opaquely.
92
+
93
+ Opens a fresh connection per delivery (simple, robust to flaky peers; pooling can come later).
94
+ Any connect/IO/timeout raises :class:`DeliveryError`, so the pipeline retries. With
95
+ ``expect_reply`` it waits for one framed reply and treats receiving any frame as confirmation —
96
+ it does **not** parse the reply (no ACK/NAK semantics; X12 acks are deferred).
97
+
98
+ Note (at-least-once): a payload sent whose reply (when expected) is lost is re-delivered on
99
+ retry — the receiver may see a **duplicate**, so the outbound receiver must be idempotent.
100
+ """
101
+
102
+ def __init__(self, config: Destination) -> None:
103
+ s = config.settings
104
+ self.host: str = s.get("host", "127.0.0.1")
105
+ self.port: int = int(s["port"])
106
+ self.codec = _codec_from_settings(s)
107
+ self.timeout: float = float(s.get("timeout_seconds", 30.0))
108
+ self.connect_timeout: float = float(s.get("connect_timeout", 10.0))
109
+ self.encoding: str = s.get("encoding", "utf-8")
110
+ self.expect_reply: bool = bool(s.get("expect_reply", False))
111
+ mf = s.get("max_frame_bytes", DEFAULT_MAX_FRAME_BYTES)
112
+ self.max_frame_bytes: int | None = int(mf) if mf else None
113
+ # ADR 0013: capture the framed reply. Requires expect_reply=True (enforced at wiring). A missing
114
+ # reply is already a retryable DeliveryError (peer-close in _read_reply) and stays one — enabling
115
+ # capture does NOT change delivery semantics, it only returns the frame that was already read.
116
+ self.capture_response: bool = bool(s.get("capture_response", False))
117
+
118
+ async def test_connection(self) -> None:
119
+ # Reachability only: open + close a connection (no frame sent) so a test never delivers.
120
+ await probe_tcp_reachable(self.host, self.port, self.connect_timeout, "TCP")
121
+
122
+ async def send(self, payload: str) -> DeliveryResponse | None:
123
+ try:
124
+ reader, writer = await asyncio.wait_for(
125
+ asyncio.open_connection(self.host, self.port), self.connect_timeout
126
+ )
127
+ except (OSError, asyncio.TimeoutError) as exc:
128
+ raise DeliveryError(f"TCP connect to {self.host}:{self.port} failed: {exc}") from exc
129
+ reply: bytes | None = None
130
+ try:
131
+ writer.write(self.codec.frame(payload, self.encoding))
132
+ await asyncio.wait_for(writer.drain(), self.timeout)
133
+ if self.expect_reply:
134
+ reply = await asyncio.wait_for(self._read_reply(reader), self.timeout)
135
+ except asyncio.TimeoutError as exc:
136
+ raise DeliveryError("TCP timed out") from exc
137
+ except OSError as exc:
138
+ raise DeliveryError(f"TCP I/O error: {exc}") from exc
139
+ finally:
140
+ writer.close()
141
+ try:
142
+ await writer.wait_closed()
143
+ except OSError:
144
+ pass
145
+ if self.capture_response and reply is not None:
146
+ return DeliveryResponse(
147
+ body=reply.decode(self.encoding, errors="replace"), outcome="accepted"
148
+ )
149
+ return None
150
+
151
+ async def _read_reply(self, reader: asyncio.StreamReader) -> bytes:
152
+ """Read one framed reply; any frame counts as confirmation (the bytes are not inspected)."""
153
+ decoder = self.codec.decoder(max_frame_bytes=self.max_frame_bytes)
154
+ while True:
155
+ chunk = await reader.read(4096)
156
+ if not chunk:
157
+ raise DeliveryError("TCP peer closed before sending a reply")
158
+ try:
159
+ for message in decoder.feed(chunk):
160
+ return message
161
+ except FrameError as exc:
162
+ raise DeliveryError(f"reply exceeded max frame size: {exc}") from exc
163
+
164
+
165
+ # --- source ------------------------------------------------------------------
166
+
167
+
168
+ class TcpSource(SourceConnector):
169
+ """Listen for inbound raw-TCP connections, deframe each message with the configured codec, and
170
+ hand its **raw bytes** to the pipeline handler. No HL7 ACK: if the handler returns a non-``None``
171
+ reply, frame and send it on the same connection; otherwise send nothing (fire-and-forget)."""
172
+
173
+ def __init__(self, config: Source) -> None:
174
+ s = config.settings
175
+ # The bind interface is injected from the service's [inbound].bind_host (authors never set a
176
+ # host on an inbound). Fall back to loopback for a missing/None value — never bind all
177
+ # interfaces (0.0.0.0) by accident, since raw TCP has no transport auth. See docs/CONNECTIONS.md.
178
+ self.host: str = s.get("host") or "127.0.0.1"
179
+ self.port: int = int(s["port"])
180
+ self.codec = _codec_from_settings(s)
181
+ self.encoding: str = s.get("encoding", "utf-8")
182
+ # Caps below: key absent → secure default; present-but-falsy (None/0) → disabled.
183
+ mc = s.get("max_connections", DEFAULT_MAX_CONNECTIONS)
184
+ self.max_connections: int | None = int(mc) if mc else None
185
+ rt = s.get("receive_timeout", DEFAULT_RECEIVE_TIMEOUT)
186
+ self.receive_timeout: float | None = float(rt) if rt else None
187
+ mf = s.get("max_frame_bytes", DEFAULT_MAX_FRAME_BYTES)
188
+ self.max_frame_bytes: int | None = int(mf) if mf else None
189
+ # Per-connection peer-IP allowlist (Tier 4 operability): refuse a non-listed peer at accept.
190
+ # Absent/empty = no restriction. Mirrors MLLPSource.
191
+ sa = s.get("source_ip_allowlist")
192
+ self.source_ip_allowlist: list[str] | None = [str(x) for x in sa] if sa else None
193
+ self._server: asyncio.Server | None = None
194
+ self._handler: InboundHandler | None = None
195
+ self._active = 0
196
+ # Live client writers + handler tasks so stop()/reload can actively close established
197
+ # connections and bound the wait (mirrors MLLPSource, review H-2).
198
+ self._clients: set[asyncio.StreamWriter] = set()
199
+ self._client_tasks: set[asyncio.Task[None]] = set()
200
+
201
+ async def start(
202
+ self, handler: InboundHandler, *, leader_gate: Callable[[], bool] | None = None
203
+ ) -> None:
204
+ # leader_gate is ignored: a listen source runs on every node (each binds its own endpoint),
205
+ # so there is no shared-resource double-read to gate. Accepted only so the runner's call is
206
+ # uniform across all sources (mirrors MLLPSource).
207
+ self._handler = handler
208
+ self._server = await asyncio.start_server(self._on_client, self.host, self.port)
209
+
210
+ @property
211
+ def sockport(self) -> int:
212
+ """The actual bound port (useful when configured with port 0 in tests)."""
213
+ assert self._server is not None
214
+ port: int = self._server.sockets[0].getsockname()[1]
215
+ return port
216
+
217
+ async def stop(self) -> None:
218
+ if self._server is not None:
219
+ self._server.close()
220
+ # Close established clients BEFORE awaiting the server (server.wait_closed() hangs on
221
+ # py3.12.1+ waiting for in-flight handlers of a peer holding its connection open). A message
222
+ # mid-handler still finishes its commit (the body is durably stored before any reply, so
223
+ # at-least-once holds). Then await the connection tasks with a bounded grace (review H-2).
224
+ for writer in list(self._clients):
225
+ writer.close()
226
+ pending = [task for task in self._client_tasks if not task.done()]
227
+ if pending:
228
+ _done, still_running = await asyncio.wait(pending, timeout=_CLIENT_SHUTDOWN_GRACE)
229
+ for task in still_running:
230
+ task.cancel()
231
+ if still_running:
232
+ await asyncio.gather(*still_running, return_exceptions=True)
233
+ self._clients.clear()
234
+ self._client_tasks.clear()
235
+ if self._server is not None:
236
+ await self._server.wait_closed()
237
+ self._server = None
238
+
239
+ async def _on_client(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
240
+ assert self._handler is not None
241
+ # Register before anything else so stop() can always find + close this connection (H-2).
242
+ task = asyncio.current_task()
243
+ self._clients.add(writer)
244
+ if task is not None:
245
+ self._client_tasks.add(task)
246
+ try:
247
+ if self.source_ip_allowlist is not None:
248
+ peer = writer.get_extra_info("peername")
249
+ if not peer_ip_allowed(peer, self.source_ip_allowlist):
250
+ logger.warning(
251
+ "TCP connection from %s refused: not in source_ip_allowlist", peer
252
+ )
253
+ return # not allowlisted — refuse (closed in the outer finally; _active untouched)
254
+ if self.max_connections is not None and self._active >= self.max_connections:
255
+ return # at capacity — refuse the new client (closed in the outer finally)
256
+ self._active += 1
257
+ try:
258
+ decoder = self.codec.decoder(max_frame_bytes=self.max_frame_bytes)
259
+ while True:
260
+ if self.receive_timeout:
261
+ try:
262
+ chunk = await asyncio.wait_for(reader.read(4096), self.receive_timeout)
263
+ except asyncio.TimeoutError:
264
+ break # idle past receive_timeout — close the connection
265
+ else:
266
+ chunk = await reader.read(4096)
267
+ if not chunk:
268
+ break
269
+ try:
270
+ for message in decoder.feed(chunk):
271
+ reply = await self._handler(message)
272
+ if reply is not None:
273
+ writer.write(self.codec.frame(reply, self.encoding))
274
+ await writer.drain()
275
+ except FrameError as exc:
276
+ peer = writer.get_extra_info("peername")
277
+ logger.warning(
278
+ "TCP frame from %s over cap; closing connection: %s", peer, exc
279
+ )
280
+ break # drop the connection rather than buffer without bound
281
+ except OSError:
282
+ raise # peer reset / write failure → handled by the outer OSError catch (quiet)
283
+ except Exception as exc:
284
+ # Last-resort (ASVS 16.5.4): an unexpected handler/codec error must not let the
285
+ # per-connection task die silently or leak detail. Log redacted; drop the conn.
286
+ peer = writer.get_extra_info("peername")
287
+ logger.error(
288
+ "TCP connection from %s failed unexpectedly: %s", peer, safe_exc(exc)
289
+ )
290
+ break
291
+ except OSError:
292
+ pass # peer reset; nothing to do but drop the connection
293
+ finally:
294
+ self._active -= 1
295
+ finally:
296
+ self._clients.discard(writer)
297
+ if task is not None:
298
+ self._client_tasks.discard(task)
299
+ writer.close()
300
+ try:
301
+ await writer.wait_closed()
302
+ except OSError:
303
+ pass
304
+
305
+
306
+ register_destination(ConnectorType.TCP, TcpDestination)
307
+ register_source(ConnectorType.TCP, TcpSource)
@@ -0,0 +1,146 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (C) 2026 MessageFoundry Organization and contributors
3
+ """Timer / scheduled source — emit a configured body on a schedule (ADR 0011).
4
+
5
+ Unlike every other source, a timer reads **no external resource**: it *fires* on a clock and hands
6
+ an operator-configured ``body`` to the pipeline handler. It follows the File/Database poll skeleton
7
+ (a cooperatively-cancellable :mod:`asyncio` loop that logs-and-continues on error, never crashing the
8
+ connection) but drops the resource scan, content validation, and mark/move — each tick just emits the
9
+ body.
10
+
11
+ A timer is **leader-gated** (:attr:`polls_shared_resource` ``True``): the schedule is a *shared
12
+ trigger*, so in a cluster only the leader fires it — otherwise every node would emit each message. On
13
+ a single node ``NullCoordinator.is_leader()`` is always ``True``, so behaviour is byte-identical to an
14
+ ungated loop (the runner already passes ``leader_gate=is_leader`` to every source). See ADR 0011 for
15
+ the at-least-once / re-run contract: the body is committed to the ingress stage and frozen there, so
16
+ downstream re-runs stay pure; only the *timing* boundary is at-least-once (a clock, not a queue).
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import asyncio
22
+ import logging
23
+ from typing import Callable
24
+
25
+ from messagefoundry.config.models import ConnectorType, Source
26
+ from messagefoundry.transports.base import InboundHandler, SourceConnector, register_source
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+ # How often a not-yet-fired ``run_once`` timer re-checks leadership when no interval is configured
31
+ # (so a follower that wins leadership fires reasonably promptly). On a single node it fires on the
32
+ # first tick regardless, so this only matters in a cluster.
33
+ DEFAULT_RUN_ONCE_POLL_SECONDS = 1.0
34
+
35
+
36
+ class TimerSource(SourceConnector):
37
+ """Emit a configured ``body`` on an interval (or exactly once), feeding it to the pipeline handler."""
38
+
39
+ # A schedule is a shared trigger — leader-gate it so exactly one node fires (see module docstring).
40
+ polls_shared_resource = True
41
+
42
+ def __init__(self, config: Source) -> None:
43
+ s = config.settings
44
+ if "body" not in s:
45
+ raise ValueError("timer source requires a 'body' setting")
46
+ self.body = str(s["body"])
47
+ self.encoding = str(s.get("encoding", "utf-8"))
48
+ # Pre-encode once: the emitted bytes are deterministic across fires (re-run-stable, ADR 0011 §5).
49
+ self._body_bytes = self.body.encode(self.encoding)
50
+ self.run_once = bool(s.get("run_once", False))
51
+ iv = s.get("interval_seconds")
52
+ self.interval_seconds: float | None = float(iv) if iv is not None else None
53
+ if self.interval_seconds is not None and self.interval_seconds <= 0:
54
+ raise ValueError("timer source 'interval_seconds' must be > 0")
55
+ if s.get("cron_expression"):
56
+ # The loop is cron-shaped, but the schedule calculation is a deliberate follow-up — the MVP
57
+ # is interval + run_once with no scheduling dependency (ADR 0011 §2). Reserve the setting
58
+ # name and fail loud so a config asking for cron is caught at wiring / `messagefoundry check`.
59
+ raise ValueError(
60
+ "timer source 'cron_expression' is not yet implemented "
61
+ "(use 'interval_seconds' or 'run_once')"
62
+ )
63
+ if self.interval_seconds is None and not self.run_once:
64
+ raise ValueError("timer source requires 'interval_seconds' or 'run_once'")
65
+ # The loop's sleep cadence: the interval when set, else a short re-check for a run_once follower.
66
+ self._tick_seconds = (
67
+ self.interval_seconds
68
+ if self.interval_seconds is not None
69
+ else DEFAULT_RUN_ONCE_POLL_SECONDS
70
+ )
71
+ self._handler: InboundHandler | None = None
72
+ # Leader-gate (Track B Step 4b): when set, only fire while it returns True so exactly one node
73
+ # emits. None = always fire (single-node / direct callers / tests) — byte-identical.
74
+ self._leader_gate: Callable[[], bool] | None = None
75
+ self._skipping = False # whether the last tick was gated out (for a single transition log)
76
+ self._fired = False # has run_once already fired? (so it fires once, then idles until stop)
77
+ self._stop = asyncio.Event()
78
+ self._task: asyncio.Task[None] | None = None
79
+
80
+ async def start(
81
+ self, handler: InboundHandler, *, leader_gate: Callable[[], bool] | None = None
82
+ ) -> None:
83
+ """Begin firing in the background. Returns once the source is set up so the caller can rely
84
+ on it being live (consistent with the other sources)."""
85
+ self._handler = handler
86
+ self._leader_gate = leader_gate
87
+ self._stop.clear()
88
+ self._fired = False
89
+ self._task = asyncio.create_task(self._run())
90
+
91
+ async def stop(self) -> None:
92
+ self._stop.set()
93
+ if self._task is not None:
94
+ # return_exceptions: a faulted timer task must not re-raise here — stop() runs during reload
95
+ # quiesce, outside its rollback. _run already guards each fire; this is belt-and-suspenders.
96
+ await asyncio.gather(self._task, return_exceptions=True)
97
+ self._task = None
98
+
99
+ async def _run(self) -> None:
100
+ # Fire immediately on start (a heartbeat starts at t=0), then every interval. The first tick of
101
+ # each pass checks the leader gate; a follower skips and re-checks next tick (reactive-by-polling).
102
+ while not self._stop.is_set():
103
+ try:
104
+ if self._may_fire():
105
+ await self._fire()
106
+ self._fired = True
107
+ except asyncio.CancelledError:
108
+ raise
109
+ except Exception:
110
+ # A fire failure is an infrastructure error (the durable ingress write failed — DB
111
+ # locked, disk full): the handler records every message-level outcome itself. It must
112
+ # NOT kill the source (that would silently stop intake while still reporting running).
113
+ # Log and retry on the next tick (at-least-once). _fired stays False, so a run_once timer
114
+ # retries until it lands.
115
+ logger.exception("timer source fire failed; retrying next tick")
116
+ if self.run_once and self._fired:
117
+ # Single-shot done: idle until stop so the task joins cleanly rather than busy-ticking.
118
+ await self._stop.wait()
119
+ return
120
+ try:
121
+ await asyncio.wait_for(self._stop.wait(), self._tick_seconds)
122
+ except asyncio.TimeoutError:
123
+ pass # tick elapsed; fire / re-check again
124
+
125
+ def _may_fire(self) -> bool:
126
+ """Whether this tick may fire. False on a follower (leader-gated, Step 4b): a non-leader must
127
+ not emit, since the schedule is shared and two nodes firing would duplicate the message. The
128
+ loop still ticks, so a node that becomes leader fires on its next tick (no restart). When the
129
+ gate is None or True, behaves exactly as an ungated loop. Logged once per transition (never per
130
+ skipped tick — that would spam a follower's log every tick)."""
131
+ if self._leader_gate is None or self._leader_gate():
132
+ if self._skipping:
133
+ self._skipping = False
134
+ logger.debug("timer source resuming (now leader)")
135
+ return True
136
+ if not self._skipping:
137
+ self._skipping = True
138
+ logger.debug("timer source skipping fire (not leader; another node emits it)")
139
+ return False
140
+
141
+ async def _fire(self) -> None:
142
+ assert self._handler is not None
143
+ await self._handler(self._body_bytes)
144
+
145
+
146
+ register_source(ConnectorType.TIMER, TimerSource)