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,955 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (C) 2026 MessageFoundry Organization and contributors
3
+ """Cluster coordination seam (Track B Steps 3-7).
4
+
5
+ Horizontal scale-out runs the engine as several nodes against one shared server-DB store. Two
6
+ coordination questions arise once there is more than one node: (1) which node runs the **singleton**
7
+ tasks that must not double-execute (retention purges, the lease-reclaim sweep — *leader election*,
8
+ Step 4), and (2) which node may **claim a given FIFO lane** so per-lane order survives across nodes
9
+ (*lane ownership*, Step 5). This module answers both. Single-node operation (SQLite and single-node
10
+ Postgres alike) stays byte-identical: :class:`NullCoordinator` reports leader ``True`` and
11
+ ``lane_owner()`` ``None``, so the claim path takes its unchanged no-owner branch.
12
+
13
+ The contract is deliberately tiny and the hot-path gates (:meth:`ClusterCoordinator.is_leader` /
14
+ :meth:`ClusterCoordinator.owns_lane` / :meth:`ClusterCoordinator.lane_owner`) are **synchronous and
15
+ cheap** — they read cached in-memory state / a plain attribute so a per-message gate check adds no
16
+ ``await``. :class:`NullCoordinator` is the default used everywhere on a single node; :class:`DbCoordinator`
17
+ is the Postgres-backed implementation that registers the node in a ``nodes`` table, heartbeats, runs
18
+ (Step 4) **real leader election** via a **self-fencing leadership lease** (a single ``leader_lease``
19
+ row with a DB-clock TTL) so exactly one node reports ``is_leader()`` at a time — and, for active-passive
20
+ HA (Workstream A2), a partitioned old leader **self-fences** before a standby can acquire it — and
21
+ (Step 5) maintains a cached owned-lane set. :func:`build_coordinator`
22
+ picks between them defensively — a non-Postgres or not-``[cluster].enabled`` store always gets the
23
+ :class:`NullCoordinator`.
24
+
25
+ **Steps 4 + 4b + 5 add leader election, leader-gated poll-source intake, and per-lane FIFO ownership:**
26
+ ``is_leader()`` reflects the held leadership lease, the engine gates its leader-only WRITE singletons
27
+ (retention, the lease-reclaim sweep) on it, and the runner threads ``is_leader`` as a plain predicate
28
+ into each source so only the leader polls a **shared external resource** (a directory / DB table /
29
+ remote dir) — listen sources (MLLP/TCP) ignore it and run on every node. For ordering, the runner
30
+ threads :meth:`lane_owner` into each FIFO claim so :meth:`Store.claim_next_fifo` atomically leases the
31
+ lane to a single node at claim time — a FIFO lane is processed by exactly one node at a time, so strict
32
+ per-lane FIFO holds ACROSS nodes with **zero reorder window** (the claim, not the cached
33
+ :meth:`owns_lane` hint, is the authority). Single-node operation stays byte-identical because
34
+ :class:`NullCoordinator`'s ``is_leader()`` is always ``True`` and its ``lane_owner()`` is always
35
+ ``None`` (no lane leasing → the claim takes its unchanged no-owner path).
36
+
37
+ **Step 6 / 6b add cross-node CONVERGENCE.** :meth:`is_clustered` (``True`` on :class:`DbCoordinator`,
38
+ ``False`` on :class:`NullCoordinator`) gates the engine's config-convergence loop and whether an
39
+ operator reload bumps the shared config version. :meth:`config_version` / :meth:`config_version_cached`
40
+ / :meth:`bump_config_version` carry a single-row ``cluster_config`` version token: an operator reload
41
+ on one node bumps it and every other node's convergence loop reloads its own config dir to converge.
42
+ Reference-set convergence is the runner's job (the leader materializes from source; followers
43
+ read-through the shared snapshot). Transform-STATE convergence (Step 6b) follows the same shape: a
44
+ clustered write bumps a per-namespace ``state_version`` token in-txn and every node's
45
+ ``StateConvergenceRunner`` read-throughs newer namespaces into its own state cache, so a sibling's
46
+ transform-state write reaches all nodes.
47
+
48
+ **Step 7 adds the read-only OBSERVABILITY API.** The scale-out feature set is now complete, so the
49
+ coordinator no longer hides behind an "experimental" banner. :meth:`cluster_members` returns one
50
+ :class:`ClusterMember` per known node (liveness + derived leadership) for the engine's ``/cluster/nodes``
51
+ endpoint; ``/cluster/status`` reads the cheap in-memory gates (:meth:`node_id` / :meth:`is_clustered` /
52
+ :meth:`is_leader` / :meth:`config_version_cached`). Cluster-wide leadership is derived from a per-node
53
+ ``is_leader`` flag folded into the existing ``nodes`` heartbeat (one extra column, zero extra writes):
54
+ ``cluster_members`` reports leader on the **single freshest** node whose flag is set and whose
55
+ ``last_seen`` is within the node timeout, so a crashed ex-leader's lingering flag is never reported as
56
+ the live leader and a failover window (an old leader's flag not yet cleared while a new leader's flag is
57
+ already set) can never surface two leaders — the live, still-beating node wins. Leadership itself is the
58
+ ``leader_lease`` row (Workstream A2's self-fencing lease); the ``nodes.is_leader`` flag mirrors it for
59
+ the observability API. :class:`NullCoordinator` synthesizes a single self-entry (single node, always leader).
60
+
61
+ Backend-agnostic by design: :class:`DbCoordinator` takes a raw asyncpg pool (typed ``Any``,
62
+ duck-typed) and never imports :class:`~messagefoundry.store.postgres.PostgresStore`, so this module
63
+ stays importable without the optional ``asyncpg`` extra.
64
+ """
65
+
66
+ from __future__ import annotations
67
+
68
+ import asyncio
69
+ import logging
70
+ import os
71
+ import socket
72
+ import time
73
+ from collections.abc import Callable
74
+ from dataclasses import dataclass
75
+ from typing import Any, Protocol, runtime_checkable
76
+ from uuid import uuid4
77
+
78
+ from messagefoundry.redaction import safe_exc
79
+
80
+ log = logging.getLogger(__name__)
81
+
82
+ __all__ = [
83
+ "ClusterCoordinator",
84
+ "ClusterMember",
85
+ "NullCoordinator",
86
+ "DbCoordinator",
87
+ "build_coordinator",
88
+ "default_node_id",
89
+ ]
90
+
91
+ # Advisory-lock classid for the nodes-table DDL, gated the same way PostgresStore._migrate_lease_columns
92
+ # is: serialize concurrent opens so two nodes can't race the CREATE TABLE. A distinct integer
93
+ # `classid` keeps this key in its own hashtext namespace, never colliding with the store's audit/
94
+ # schema/finalize lock families (which use 1/2/3 — see store/postgres.py). The text key is
95
+ # schema-namespaced per node (see DbCoordinator._lock_key), matching PostgresStore._lock_key.
96
+ _LOCK_CLASS_CLUSTER = 4
97
+
98
+ # Leader election (Track B Step 4 / Workstream A2) is a **self-fencing lease**, not an advisory lock.
99
+ # A single ``leader_lease`` row carries ``(lease_key, owner, lease_expires_at)``; the leader renews it
100
+ # every heartbeat to ``DB_now + leader_lease_ttl`` and a standby may acquire ONLY once the lease has
101
+ # expired (per the DB's own clock — ``clock_timestamp()`` — so inter-node clock skew is irrelevant to
102
+ # correctness). A leader that cannot renew within ``leader_fence_timeout`` (measured on its own
103
+ # monotonic clock, with no DB I/O) halts its leader work BEFORE the lease can expire, so a partitioned
104
+ # old leader stops processing before any standby can take over (the split-brain guard). The lease
105
+ # replaces the earlier session-level advisory lock, which gave fast crash-release but could not enforce
106
+ # the "wait out the TTL" fence a standby needs to be safe.
107
+
108
+
109
+ def default_node_id() -> str:
110
+ """This node's stable identity: ``host:pid:hex`` — the same shape as
111
+ :attr:`PostgresStore._owner`, so when the factory reuses ``store._owner`` the cluster node-id and
112
+ the row-lease owner-id are one value (a useful invariant for Steps 4/5)."""
113
+ return f"{socket.gethostname()}:{os.getpid()}:{uuid4().hex[:8]}"
114
+
115
+
116
+ @dataclass(frozen=True)
117
+ class ClusterMember:
118
+ """One node's membership snapshot for the observability API (Track B Step 7). A plain frozen
119
+ dataclass (no API import) so the coordinator stays free of FastAPI/Pydantic — the API maps it to a
120
+ :class:`~messagefoundry.api.models.ClusterNode` at the boundary. ``is_leader`` is the DERIVED
121
+ leadership: at most one member carries it — the single freshest node whose durable ``nodes.is_leader``
122
+ heartbeat flag is set AND is fresh (``last_seen`` within ``node_timeout_seconds``) — so a crashed
123
+ ex-leader's stale flag is never reported as the live leader and a failover window cannot surface two
124
+ leaders. ``last_seen``/``started_at`` are epoch seconds, ``None`` only on the
125
+ :class:`NullCoordinator` synthetic self-entry (no DB)."""
126
+
127
+ node_id: str
128
+ host: str | None
129
+ pid: int | None
130
+ started_at: float | None
131
+ last_seen: float | None
132
+ status: str
133
+ is_leader: bool
134
+
135
+
136
+ @runtime_checkable
137
+ class ClusterCoordinator(Protocol):
138
+ """The coordination contract every backend (null today, DB-backed later) implements.
139
+
140
+ :attr:`node_id` is this node's stable identity. :meth:`start`/:meth:`stop` own any background
141
+ membership task (idempotent — safe to call twice). :meth:`is_leader` and :meth:`owns_lane` are the
142
+ **cheap, synchronous** gates Steps 4/5 will consult on the hot path — they must read cached state
143
+ and never block or ``await``.
144
+ """
145
+
146
+ node_id: str
147
+
148
+ async def start(self) -> None: ...
149
+
150
+ async def stop(self) -> None: ...
151
+
152
+ def is_leader(self) -> bool:
153
+ """Whether this node runs the leader-only singletons (retention, lease reclaim). Cheap/cached
154
+ — never an ``await`` or a DB round-trip. Always ``True`` until Step 4 builds election."""
155
+ ...
156
+
157
+ def owns_lane(self, lane_key: str) -> bool:
158
+ """Whether this node currently holds the FIFO lane ``lane_key`` (a stage:destination/channel
159
+ lease). Cheap/cached. **An eventually-consistent HINT for observability/reporting, NOT the
160
+ correctness gate** — the authoritative single-owner-per-lane enforcement is the claim-time
161
+ atomic lease acquire in :meth:`Store.claim_next_fifo` (Track B Step 5, Deliverable 2). On
162
+ :class:`DbCoordinator` it reflects a per-tick cache refresh; ``True`` everywhere on
163
+ :class:`NullCoordinator` (single-node owns every lane)."""
164
+ ...
165
+
166
+ def lane_owner(self) -> str | None:
167
+ """The owner identity to gate a FIFO claim by (Track B Step 5): this node's ``node_id`` when
168
+ clustered (so :meth:`Store.claim_next_fifo` atomically leases the lane to this node), or
169
+ ``None`` single-node (no lane leasing → the byte-identical claim path). The runner threads this
170
+ into every FIFO claim. Cheap/synchronous — a plain attribute read, no DB."""
171
+ ...
172
+
173
+ def reclaims_inflight(self) -> bool:
174
+ """Whether crashed-node in-flight recovery is the **leader's periodic reclaim sweep** (True) or
175
+ the engine's **unconditional startup reset** (False).
176
+
177
+ This decides which recovery path the engine runs at startup (and whether it spawns the leader
178
+ lease-reclaim task), and it is a property of the *backend*, not of who is currently leader:
179
+
180
+ * :class:`DbCoordinator` → ``True``. In a cluster the engine must NOT run the unconditional
181
+ :meth:`Store.reset_stale_inflight` at startup — it ignores leases and would steal a live
182
+ sibling's in-flight rows. Recovery instead comes from the leader periodically calling
183
+ :meth:`Store.reclaim_expired_leases`, which only reclaims rows whose lease has expired.
184
+ * :class:`NullCoordinator` → ``False``. Single-node keeps the unconditional startup reset —
185
+ immediate self-recovery of its own crash residue, byte-identical to before this seam.
186
+ """
187
+ ...
188
+
189
+ def is_clustered(self) -> bool:
190
+ """Whether this is a real multi-node deployment (``True`` on :class:`DbCoordinator`) or the
191
+ single-node no-op (``False`` on :class:`NullCoordinator`). The engine consults it to decide
192
+ whether to spawn the config-convergence loop (Track B Step 6) and whether an operator reload
193
+ should bump the cluster-wide config version — so single-node never spawns the loop and never
194
+ touches the version token. Cheap + synchronous (a plain backend property, not who-is-leader)."""
195
+ ...
196
+
197
+ async def config_version(self) -> int:
198
+ """The current cluster-wide config-reload version (Track B Step 6). :class:`DbCoordinator` reads
199
+ ``cluster_config`` (initializing the single row to 0 if absent) and caches it; the engine reads
200
+ it once at startup to seed ``_applied_config_version`` so a fresh node doesn't self-reload.
201
+ :class:`NullCoordinator` returns 0 (single-node has no shared token)."""
202
+ ...
203
+
204
+ def config_version_cached(self) -> int:
205
+ """The cached cluster-wide config version for the convergence loop to poll cheaply each tick
206
+ (no DB round-trip). :class:`DbCoordinator` refreshes it every maintenance tick; reads of a value
207
+ bumped on THIS node are immediate (:meth:`bump_config_version` updates the cache). Cheap +
208
+ synchronous. :class:`NullCoordinator` returns 0."""
209
+ ...
210
+
211
+ async def bump_config_version(self) -> int:
212
+ """Atomically increment the cluster-wide config version and return the new value (Track B
213
+ Step 6). Called when an OPERATOR reload succeeds on this node, so every OTHER node's convergence
214
+ loop sees the higher version and reloads its own config dir. :class:`DbCoordinator` does an
215
+ ``INSERT ... ON CONFLICT DO UPDATE ... RETURNING`` and updates its cache; :class:`NullCoordinator`
216
+ is a no-op returning 0 (single-node has nothing to coordinate)."""
217
+ ...
218
+
219
+ async def cluster_members(self) -> list[ClusterMember]:
220
+ """Cluster membership for the observability API (Track B Step 7): one entry per known node with
221
+ its liveness + derived leadership. :class:`DbCoordinator` reads the shared ``nodes`` table;
222
+ :class:`NullCoordinator` returns a single synthetic self-entry (single node, always leader). One
223
+ DB read on the clustered path, none single-node — off the message hot path (operator-driven)."""
224
+ ...
225
+
226
+ async def leadership_lease(self) -> tuple[str | None, float | None]:
227
+ """The current leadership-lease state for the observability API (Workstream A5): ``(owner,
228
+ lease_expires_at)`` — who holds the self-fencing leadership lease and the DB-clock epoch at which
229
+ it expires (when a standby could acquire if the leader stops renewing). :class:`DbCoordinator`
230
+ reads the single ``leader_lease`` row (one DB read, off the hot path); ``(None, None)`` before any
231
+ lease exists. :class:`NullCoordinator` returns ``(node_id, None)`` — single-node is permanently
232
+ leader with no lease/expiry."""
233
+ ...
234
+
235
+
236
+ class NullCoordinator:
237
+ """The single-node default (SQLite and single-node Postgres). Every gate is ``True``, there is no
238
+ DB and no background task, so the engine behaves exactly as it did before this seam existed.
239
+
240
+ :meth:`start`/:meth:`stop` are no-ops and idempotent.
241
+ """
242
+
243
+ def __init__(self, node_id: str | None = None) -> None:
244
+ self.node_id = node_id or default_node_id()
245
+
246
+ async def start(self) -> None:
247
+ return None
248
+
249
+ async def stop(self) -> None:
250
+ return None
251
+
252
+ def is_leader(self) -> bool:
253
+ return True
254
+
255
+ def owns_lane(self, lane_key: str) -> bool:
256
+ # Single-node owns every lane (and there are no lane leases). Byte-identical to before.
257
+ return True
258
+
259
+ def lane_owner(self) -> str | None:
260
+ # Single-node: no lane leasing — claim_next_fifo runs its byte-identical no-owner path.
261
+ return None
262
+
263
+ def reclaims_inflight(self) -> bool:
264
+ # Single-node: the engine keeps the unconditional startup reset (immediate self-recovery of
265
+ # this node's own crash residue). Byte-identical to before this seam existed.
266
+ return False
267
+
268
+ def is_clustered(self) -> bool:
269
+ # Single-node: NOT a cluster, so the engine spawns no config-convergence loop and an operator
270
+ # reload never bumps a shared version token. Byte-identical to before Step 6.
271
+ return False
272
+
273
+ async def config_version(self) -> int:
274
+ # Single-node: no shared config token.
275
+ return 0
276
+
277
+ def config_version_cached(self) -> int:
278
+ # Single-node: no shared config token.
279
+ return 0
280
+
281
+ async def bump_config_version(self) -> int:
282
+ # Single-node: nothing to coordinate (no other node converges), so this is a no-op.
283
+ return 0
284
+
285
+ async def cluster_members(self) -> list[ClusterMember]:
286
+ # Single-node: synthesize one self-entry so /cluster/nodes is byte-identical to a real cluster's
287
+ # shape. No DB, always leader; started_at/last_seen are None (there is no heartbeat to record).
288
+ return [
289
+ ClusterMember(
290
+ node_id=self.node_id,
291
+ host=socket.gethostname(),
292
+ pid=os.getpid(),
293
+ started_at=None,
294
+ last_seen=None,
295
+ status="active",
296
+ is_leader=True,
297
+ )
298
+ ]
299
+
300
+ async def leadership_lease(self) -> tuple[str | None, float | None]:
301
+ # Single-node: permanently leader, no lease row / expiry. Report self as the holder with no
302
+ # expiry so /cluster/nodes is byte-identical in shape to a real cluster's.
303
+ return (self.node_id, None)
304
+
305
+
306
+ # One-time-per-process info guard: the scale-out feature set is COMPLETE — election (Step 4),
307
+ # leader-gated WRITE singletons, leader-gated poll-source intake (Step 4b), per-lane FIFO ownership
308
+ # (Step 5), cross-node convergence (Step 6 — leader-materialized reference sets read-through by
309
+ # followers + a config-reload version token; Step 6b — transform-STATE writes read-through by followers
310
+ # via a per-namespace version token), and the read-only observability API (Step 7 — /cluster/status +
311
+ # /cluster/nodes). So a cluster no longer double-runs singletons, double-ingests a shared poll source,
312
+ # interleaves a FIFO lane across nodes, or leaves followers on stale reference/config/state, and an
313
+ # operator can now SEE membership + leadership. The banner is therefore a one-time INFO (not a WARNING)
314
+ # that states the feature set is built and summarizes the operational assumptions operators must honor.
315
+ # Logged once so the log isn't spammed when several stores/coordinators open in one process (e.g. tests).
316
+ _logged_cluster_enabled = False
317
+
318
+
319
+ class DbCoordinator:
320
+ """Postgres-backed cluster membership + **leader election** (Track B Steps 3-7).
321
+
322
+ On :meth:`start` it idempotently creates the ``nodes`` + ``leader_lease`` tables, upserts this
323
+ node's row, and spawns two cooperatively-cancellable tasks: a **maintenance** task that each tick
324
+ (a) refreshes ``last_seen`` and (b) maintains leadership via a **self-fencing lease** (the single
325
+ ``leader_lease`` row, renewed to ``DB_now + leader_lease_ttl``; a standby acquires only once that
326
+ lease has expired per the DB clock), and a **fence watchdog** task that does NO DB I/O and demotes
327
+ this node if it has not renewed within ``leader_fence_timeout`` (< the TTL) — so a partitioned old
328
+ leader stops reporting :meth:`is_leader` ``True`` before any standby can acquire the lease (the
329
+ split-brain guard, Workstream A2). Exactly one node holds the lease, so exactly one reports
330
+ :meth:`is_leader` ``True``. :meth:`stop` releases the lease, cancels both tasks, and marks this node
331
+ left.
332
+
333
+ Lane ownership (Track B Step 5) IS built: :meth:`lane_owner` returns this node's identity, the
334
+ runner threads it into each FIFO claim, and :meth:`Store.claim_next_fifo` atomically leases the
335
+ lane to a single node at claim time — so cross-node per-lane FIFO order is preserved with zero
336
+ reorder window. :meth:`owns_lane` is the eventually-consistent observability hint over a per-tick
337
+ cache of the lanes this node holds (NOT the correctness gate — the claim is). Leader-gated
338
+ poll-source intake (Step 4b) IS built: the runner threads :meth:`is_leader` into each source as a
339
+ plain predicate and the poll sources skip their scan on a follower, so a shared directory / DB table
340
+ / remote dir is ingested by exactly one node. Cross-node CONVERGENCE (Steps 6 + 6b) IS built:
341
+ :meth:`is_clustered` gates the engine's config-convergence loop, :meth:`config_version` /
342
+ :meth:`config_version_cached` / :meth:`bump_config_version` carry the ``cluster_config`` version token so
343
+ an operator reload on one node propagates cluster-wide, and transform-STATE writes bump a per-namespace
344
+ ``state_version`` token that every node's ``StateConvergenceRunner`` read-throughs into its own cache. The
345
+ read-only OBSERVABILITY API (Step 7) IS built: a per-node ``is_leader`` flag is folded into the
346
+ ``nodes`` heartbeat (one column, zero extra writes) and :meth:`cluster_members` reads the table and
347
+ derives the **single live** leader (the freshest fresh-flagged node) for the engine's
348
+ ``/cluster/nodes`` endpoint — so the scale-out feature set is complete and a one-time INFO (not a
349
+ warning) records the operational assumptions operators must honor.
350
+
351
+ Backend-agnostic: it holds a raw asyncpg ``pool`` (duck-typed ``Any``) and never imports the
352
+ concrete store, so this module imports cleanly without the optional ``asyncpg`` extra.
353
+ """
354
+
355
+ def __init__(
356
+ self,
357
+ pool: Any,
358
+ node_id: str,
359
+ *,
360
+ heartbeat_seconds: float = 10.0,
361
+ node_timeout_seconds: float = 30.0,
362
+ leader_lease_ttl_seconds: float = 30.0,
363
+ leader_fence_timeout_seconds: float = 20.0,
364
+ db_schema: str | None = None,
365
+ monotonic: Callable[[], float] = time.monotonic,
366
+ ) -> None:
367
+ self._pool = pool
368
+ self.node_id = node_id
369
+ self._heartbeat_seconds = heartbeat_seconds
370
+ # A node is considered dead when its last_seen is older than this. Consulted by
371
+ # cluster_members() (Step 7) as the freshness filter that discards a crashed ex-leader's stale
372
+ # is_leader flag (and bounds the failover-overlap window). It is NOT what transfers leadership —
373
+ # the leadership lease is (a standby acquires only once the lease has expired); lowering this
374
+ # shrinks the window in which a just-beaten node can still count toward the derived leader,
375
+ # raising it lets a just-crashed ex-leader's row stay "fresh" (and thus a leader candidate) longer.
376
+ self._node_timeout_seconds = node_timeout_seconds
377
+ # The leadership LEASE TTL (Workstream A2): the leader renews the lease to DB_now + this every
378
+ # heartbeat; a standby may acquire only once the lease has expired (per the DB clock), so it
379
+ # always waits out the full TTL before taking over.
380
+ self._lease_ttl = leader_lease_ttl_seconds
381
+ # The SELF-FENCE timeout: a leader that has not renewed within this many seconds (its own
382
+ # monotonic clock, no DB I/O) demotes itself. MUST be < the TTL so the old leader stops before
383
+ # the lease can expire and a standby acquire — the split-brain guard.
384
+ self._fence_timeout = leader_fence_timeout_seconds
385
+ # The fence watchdog polls this often; small relative to the fence timeout so a fence fires
386
+ # promptly (well before the lease TTL). Pure in-memory check — no DB.
387
+ self._fence_tick = max(0.05, min(1.0, leader_fence_timeout_seconds / 5.0))
388
+ # Monotonic clock for the fence (injectable for deterministic tests). Distinct from the DB clock
389
+ # the lease uses: the fence measures a node-local elapsed duration (skew-free by construction),
390
+ # the lease compares against the DB's own clock_timestamp() (so inter-node skew is irrelevant).
391
+ self._monotonic = monotonic
392
+ # Namespace the nodes-DDL advisory lock by schema, exactly as PostgresStore._lock_key does:
393
+ # advisory locks are database-scoped (not schema-scoped), so two deployments sharing one
394
+ # database via different db_schema values must not contend on this lock. The nodes table
395
+ # itself lands in the right schema via the pool's search_path; the lock key must match.
396
+ self._lock_key = f"{db_schema or 'public'}:mefor_cluster_nodes"
397
+ # The leadership-lease KEY (the single leader_lease row's primary key). Schema-namespaced so two
398
+ # deployments sharing one database via different schemas elect leaders independently.
399
+ self._lease_key = f"{db_schema or 'public'}:mefor_cluster_leader"
400
+ self._host = socket.gethostname()
401
+ self._pid = os.getpid()
402
+ self._heartbeat_task: asyncio.Task[None] | None = None
403
+ self._fence_task: asyncio.Task[None] | None = None
404
+ self._stop = asyncio.Event()
405
+ # Cached leadership state read by the cheap/synchronous is_leader() gate (no DB round-trip on
406
+ # the hot path). Maintained by the maintenance task (acquire/renew/lose) and the fence watchdog
407
+ # (self-fence on a stalled renew).
408
+ self._is_leader: bool = False
409
+ # The monotonic time of the last CONFIRMED lease hold (acquire or successful renew), or None if
410
+ # this node has never held the lease. The fence watchdog demotes when now - this > fence_timeout.
411
+ self._last_renew_ok: float | None = None
412
+ # Cached set of FIFO lanes this node currently holds, read by the cheap/synchronous owns_lane()
413
+ # HINT (Track B Step 5). Refreshed once per maintenance tick from lane_leases; it is NOT the
414
+ # correctness gate (the claim-time atomic lease acquire in claim_next_fifo is) — just an
415
+ # eventually-consistent observability signal, so a tick of staleness is harmless.
416
+ self._owned_lanes: set[str] = set()
417
+ # Cached cluster-wide config-reload version (Track B Step 6), read by the cheap/synchronous
418
+ # config_version_cached() the engine's convergence loop polls. Refreshed once per maintenance
419
+ # tick and updated immediately by bump_config_version() (so the node that bumps sees its own new
420
+ # value at once and does not re-converge). 0 until the first read/refresh.
421
+ self._config_version: int = 0
422
+
423
+ async def start(self) -> None:
424
+ """Register this node and begin heartbeating. Idempotent: a second call is a no-op while the
425
+ heartbeat is already running (the row upsert is also idempotent on its own)."""
426
+ if self._heartbeat_task is not None:
427
+ return # already started — don't spawn a second heartbeat
428
+ self._log_cluster_enabled_once()
429
+ await self._ensure_nodes_table()
430
+ await self._register()
431
+ self._stop.clear()
432
+ self._heartbeat_task = asyncio.create_task(self._heartbeat_loop())
433
+ # The fence watchdog runs SEPARATELY from the maintenance loop and does NO DB I/O, so a hung DB
434
+ # (which would block the maintenance loop mid-await) can never block self-fencing.
435
+ self._fence_task = asyncio.create_task(self._fence_watchdog_loop())
436
+
437
+ async def stop(self) -> None:
438
+ """Release leadership, cancel both background tasks, and mark this node left. Idempotent and
439
+ safe even if :meth:`start` raised before the tasks existed (then there's nothing to tear down).
440
+ Ordered so the tasks are stopped BEFORE the lease is released, so a still-running tick can't
441
+ re-acquire after we release."""
442
+ self._stop.set()
443
+ tasks = [t for t in (self._heartbeat_task, self._fence_task) if t is not None]
444
+ self._heartbeat_task = None
445
+ self._fence_task = None
446
+ for t in tasks:
447
+ t.cancel()
448
+ if tasks:
449
+ # Absorb the cancellation (and any error a loop stored) so stop() never raises.
450
+ await asyncio.gather(*tasks, return_exceptions=True)
451
+ # Drop leadership: demote the cached gate FIRST so any concurrent is_leader() reader sees "not
452
+ # leader" the instant we begin releasing, then expire the lease row so a standby can take over
453
+ # immediately on a clean shutdown (best-effort — a failed release just lets the lease age out).
454
+ await self._release_leadership()
455
+ # Mark the row left rather than DELETE it: keeping a 'left' tombstone gives an operator a
456
+ # visible "this node shut down cleanly" signal (vs a crashed node whose row goes stale), which
457
+ # Step 4's election/diagnostics will distinguish. The row is re-activated by the next start().
458
+ try:
459
+ # Also clear is_leader so a clean shutdown immediately stops reporting this node as leader
460
+ # (Step 7). A hard crash skips this UPDATE and leaves the flag stale — the freshness filter in
461
+ # cluster_members() handles that case by AND-ing the flag with a live last_seen.
462
+ await self._pool.execute(
463
+ "UPDATE nodes SET status=$1, last_seen=$2, is_leader=FALSE WHERE node_id=$3",
464
+ "left",
465
+ time.time(),
466
+ self.node_id,
467
+ )
468
+ except Exception as exc: # the pool may already be closing on shutdown — log, don't raise
469
+ # safe_exc keeps the exception type + a redacted/bounded message: this is a connectivity
470
+ # error (no PHI), but route it through the same redactor used everywhere for consistency.
471
+ log.warning("cluster: failed to mark node %s left: %s", self.node_id, safe_exc(exc))
472
+
473
+ def is_leader(self) -> bool:
474
+ # Cheap + synchronous: read the cached state the maintenance loop + fence watchdog maintain (no
475
+ # DB round-trip on the hot path). True only while this node holds the leadership lease.
476
+ return self._is_leader
477
+
478
+ def owns_lane(self, lane_key: str) -> bool:
479
+ # Cheap + synchronous set membership on the per-tick lane-lease cache (no DB on the call). This
480
+ # is an eventually-consistent HINT for observability — the AUTHORITATIVE single-owner-per-lane
481
+ # enforcement is the claim-time atomic lease acquire in claim_next_fifo (Track B Step 5).
482
+ return lane_key in self._owned_lanes
483
+
484
+ def lane_owner(self) -> str | None:
485
+ # Clustered: gate FIFO claims by this node's identity so claim_next_fifo atomically leases the
486
+ # lane to us (one node per lane → strict FIFO across nodes). Cheap + synchronous attribute read.
487
+ return self.node_id
488
+
489
+ def reclaims_inflight(self) -> bool:
490
+ # Clustered: the leader's periodic reclaim_expired_leases sweep recovers crashed nodes' in-
491
+ # flight rows. The engine must therefore NOT run the unconditional startup reset_stale_inflight,
492
+ # which ignores leases and would steal a live sibling's in-flight rows. See the Protocol method.
493
+ return True
494
+
495
+ def is_clustered(self) -> bool:
496
+ # A real multi-node deployment: the engine spawns the config-convergence loop and an operator
497
+ # reload bumps the shared config version so siblings converge. Cheap + synchronous.
498
+ return True
499
+
500
+ def config_version_cached(self) -> int:
501
+ # Cheap + synchronous: read the value the maintenance loop refreshes each tick (and that
502
+ # bump_config_version updates immediately on this node). No DB round-trip on the poll path.
503
+ return self._config_version
504
+
505
+ async def config_version(self) -> int:
506
+ """Read (and cache) the current shared config version, initializing the single ``cluster_config``
507
+ row to 0 if absent. Used at engine startup to seed ``_applied_config_version`` so a fresh node
508
+ doesn't immediately self-reload, and as the maintenance-tick refresh of the cached value."""
509
+ row = await self._pool.fetchrow(
510
+ "INSERT INTO cluster_config (id, config_version, updated_at) VALUES (1, 0, $1) "
511
+ "ON CONFLICT (id) DO UPDATE SET id = cluster_config.id " # no-op update → RETURNING current
512
+ "RETURNING config_version",
513
+ time.time(),
514
+ )
515
+ # An INSERT ... ON CONFLICT ... RETURNING always yields a row; a None here is an impossible
516
+ # state. Assert rather than fall back to 0 — a silent 0 would RESET the cached version
517
+ # mid-cluster (worse than raising, since it could trigger redundant follower reloads).
518
+ assert row is not None, "cluster_config upsert returned no row"
519
+ self._config_version = int(row["config_version"])
520
+ return self._config_version
521
+
522
+ async def bump_config_version(self) -> int:
523
+ """Atomically increment the shared config version and return the new value. Called when an
524
+ operator reload succeeds on THIS node, so every other node's convergence loop sees the higher
525
+ version and reloads its own config dir. Updates the cache immediately so this node's own loop
526
+ sees no change (feedback-avoidance — the initiator does not re-reload)."""
527
+ row = await self._pool.fetchrow(
528
+ "INSERT INTO cluster_config (id, config_version, updated_at) VALUES (1, 1, $1) "
529
+ "ON CONFLICT (id) DO UPDATE SET "
530
+ "config_version = cluster_config.config_version + 1, updated_at = excluded.updated_at "
531
+ "RETURNING config_version",
532
+ time.time(),
533
+ )
534
+ # RETURNING always yields a row; assert rather than fall back to 0 (a silent 0 would reset the
535
+ # cached version mid-cluster — see :meth:`config_version`).
536
+ assert row is not None, "cluster_config upsert returned no row"
537
+ self._config_version = int(row["config_version"])
538
+ return self._config_version
539
+
540
+ async def cluster_members(self) -> list[ClusterMember]:
541
+ """Read the shared ``nodes`` table and return one :class:`ClusterMember` per node (Track B
542
+ Step 7). Leadership is DERIVED so that **at most one** node is ever reported as leader, and it is
543
+ always a *live* one:
544
+
545
+ * A freshness filter (``last_seen`` within ``node_timeout_seconds``) discards a fully-stale
546
+ crashed ex-leader's lingering ``is_leader=true`` flag outright.
547
+ * Among the rows that still carry ``is_leader=true`` AND are fresh, only the **single freshest**
548
+ (largest ``last_seen``) is reported as leader. During a failover window two rows can briefly
549
+ both be fresh-and-flagged — the crashed ex-leader whose ``last_seen`` is frozen at the crash
550
+ instant, and the newly-promoted leader whose ``last_seen`` keeps advancing. Picking the
551
+ freshest collapses that overlap to the one node that is actually beating (the live leader), so
552
+ ``/cluster/nodes`` never shows two leaders and never names the dead node as leader.
553
+
554
+ One DB read, returned ordered by ``node_id`` for a stable listing; off the message hot path
555
+ (operator-driven)."""
556
+ rows = await self._pool.fetch(
557
+ "SELECT node_id, host, pid, started_at, last_seen, status, is_leader "
558
+ "FROM nodes ORDER BY node_id"
559
+ )
560
+ now = time.time()
561
+ # First pass: which rows carry a *fresh* leader flag, and which of those is the freshest. The
562
+ # freshest fresh-flagged row is the single derived leader (it is the one still heartbeating).
563
+ leader_node_id: str | None = None
564
+ leader_last_seen: float = -1.0
565
+ for r in rows:
566
+ last_seen = r["last_seen"]
567
+ fresh = last_seen is not None and (now - last_seen) <= self._node_timeout_seconds
568
+ if r["is_leader"] and fresh and last_seen > leader_last_seen:
569
+ leader_last_seen = last_seen
570
+ leader_node_id = r["node_id"]
571
+ members: list[ClusterMember] = []
572
+ for r in rows:
573
+ members.append(
574
+ ClusterMember(
575
+ node_id=r["node_id"],
576
+ host=r["host"],
577
+ pid=r["pid"],
578
+ started_at=r["started_at"],
579
+ last_seen=r["last_seen"],
580
+ status=r["status"],
581
+ # Single derived leader: the freshest fresh-flagged node only. A stale ex-leader's
582
+ # flag is filtered out (not fresh), and a not-yet-cleared ex-leader that overlaps a
583
+ # new leader loses to the new leader's more recent last_seen.
584
+ is_leader=(r["node_id"] == leader_node_id),
585
+ )
586
+ )
587
+ return members
588
+
589
+ async def leadership_lease(self) -> tuple[str | None, float | None]:
590
+ """Read the single ``leader_lease`` row — (owner, DB-clock expiry) — for the observability API
591
+ (Workstream A5). One DB read, off the message hot path; ``(None, None)`` before any lease row
592
+ exists. This is the AUTHORITATIVE lease state (the source of truth for who may process), distinct
593
+ from the ``nodes.is_leader`` heartbeat flag :meth:`cluster_members` derives from."""
594
+ row = await self._pool.fetchrow(
595
+ "SELECT owner, lease_expires_at FROM leader_lease WHERE lease_key = $1",
596
+ self._lease_key,
597
+ )
598
+ if row is None:
599
+ return (None, None)
600
+ return (row["owner"], row["lease_expires_at"])
601
+
602
+ # --- internals -----------------------------------------------------------
603
+
604
+ def _log_cluster_enabled_once(self) -> None:
605
+ global _logged_cluster_enabled
606
+ if _logged_cluster_enabled:
607
+ return
608
+ _logged_cluster_enabled = True
609
+ log.info(
610
+ "cluster coordination is ENABLED ([cluster].enabled); the scale-out feature set is BUILT: "
611
+ "leader election (Track B Step 4), the leader-gated WRITE singletons (retention, lease "
612
+ "reclaim), leader-gated poll-source intake (Step 4b — only the leader polls a shared "
613
+ "directory / DB table / remote dir), per-lane FIFO ownership (Step 5 — a FIFO lane is "
614
+ "claimed by exactly one node at a time via an atomic claim-time lane lease, so cross-node "
615
+ "per-lane FIFO ORDER is PRESERVED), cross-node CONVERGENCE (Step 6 — the leader materializes "
616
+ "each reference set from its source and followers read-through the shared snapshot; an "
617
+ "operator config reload propagates cluster-wide via a version token; Step 6b — transform-"
618
+ "STATE writes bump a per-namespace version token and every node read-throughs newer "
619
+ "namespaces into its own state cache), and the read-only observability API (Step 7 — "
620
+ "/cluster/status + /cluster/nodes). OPERATIONAL ASSUMPTIONS to honor: (a) keep node clocks "
621
+ "reasonably synced (NTP) — lane/row leases are wall-clock; (b) run IDENTICAL config dirs on "
622
+ "every node; (c) apply config changes via a COORDINATED (not rolling) restart. See "
623
+ "docs/CLUSTERING.md."
624
+ )
625
+
626
+ async def _ensure_nodes_table(self) -> None:
627
+ """Create the ``nodes`` + ``leader_lease`` tables IF NOT EXISTS, serialized across concurrent
628
+ opens by a transaction-scoped advisory lock (auto-released at commit) — the same guard the store
629
+ uses for its own schema DDL, so two nodes opening at once can't race the CREATE. The lock key is
630
+ schema-namespaced (see :attr:`_lock_key`), matching :meth:`PostgresStore._lock_key`, so two
631
+ deployments sharing one database via different schemas don't contend on it."""
632
+ async with self._pool.acquire() as conn:
633
+ async with conn.transaction():
634
+ await conn.execute(
635
+ "SELECT pg_advisory_xact_lock($1, hashtext($2))",
636
+ _LOCK_CLASS_CLUSTER,
637
+ self._lock_key,
638
+ )
639
+ await conn.execute(
640
+ "CREATE TABLE IF NOT EXISTS nodes ("
641
+ " node_id TEXT PRIMARY KEY,"
642
+ " host TEXT,"
643
+ " pid INTEGER,"
644
+ " started_at DOUBLE PRECISION,"
645
+ " last_seen DOUBLE PRECISION,"
646
+ " status TEXT,"
647
+ " is_leader BOOLEAN NOT NULL DEFAULT FALSE" # Step 7: derived-leader observability
648
+ ")"
649
+ )
650
+ # Idempotent migration for a pre-Step-7 nodes table created without is_leader (a cluster
651
+ # upgraded in place): add the column if it is absent. Still under the DDL advisory lock,
652
+ # so two nodes opening at once can't race it. ADD COLUMN IF NOT EXISTS is a no-op on the
653
+ # fresh CREATE above and on any node that already migrated.
654
+ await conn.execute(
655
+ "ALTER TABLE nodes ADD COLUMN IF NOT EXISTS is_leader BOOLEAN NOT NULL DEFAULT FALSE"
656
+ )
657
+ # The self-fencing leadership lease (Workstream A2): a single row per cluster (keyed by
658
+ # the schema-namespaced lease_key) holding the current leader + its DB-clock expiry. The
659
+ # leader renews lease_expires_at every heartbeat; a standby acquires only once it has
660
+ # expired. Created under the same DDL lock so concurrent opens can't race it.
661
+ await conn.execute(
662
+ "CREATE TABLE IF NOT EXISTS leader_lease ("
663
+ " lease_key TEXT PRIMARY KEY,"
664
+ " owner TEXT,"
665
+ " lease_expires_at DOUBLE PRECISION NOT NULL"
666
+ ")"
667
+ )
668
+
669
+ async def _register(self) -> None:
670
+ """Upsert this node's row as ``active`` (a restart re-activates a prior 'left' tombstone). The
671
+ ``is_leader`` flag is reset to FALSE on both insert and the conflict-update: a freshly
672
+ (re)registered node holds no leadership until it acquires the advisory lock on its first
673
+ maintenance tick (the heartbeat then folds the true value in)."""
674
+ now = time.time()
675
+ await self._pool.execute(
676
+ "INSERT INTO nodes (node_id, host, pid, started_at, last_seen, status, is_leader)"
677
+ " VALUES ($1,$2,$3,$4,$5,$6,FALSE)"
678
+ " ON CONFLICT (node_id) DO UPDATE SET"
679
+ " host=excluded.host, pid=excluded.pid, started_at=excluded.started_at,"
680
+ " last_seen=excluded.last_seen, status=excluded.status, is_leader=FALSE",
681
+ self.node_id,
682
+ self._host,
683
+ self._pid,
684
+ now,
685
+ now,
686
+ "active",
687
+ )
688
+
689
+ async def heartbeat_once(self) -> None:
690
+ """Refresh this node's ``last_seen`` (the membership liveness signal Step 4's election reads)
691
+ and fold this node's current leadership into ``is_leader`` for the Step-7 observability API. A
692
+ discrete coroutine (the loop's single beat) so a test can advance the heartbeat deterministically
693
+ without racing the loop's sleep.
694
+
695
+ The folded flag rides the EXISTING heartbeat UPDATE — zero extra writes. It lags by at most one
696
+ tick because the loop beats BEFORE :meth:`_maintain_leadership` runs, so a just-acquired/just-lost
697
+ leadership is reflected on the next beat; that one-tick lag is fine for an observability endpoint
698
+ (and a clean :meth:`stop` clears the flag immediately, while a crash leaves it stale for the
699
+ freshness filter in :meth:`cluster_members` to discard)."""
700
+ await self._pool.execute(
701
+ "UPDATE nodes SET last_seen=$1, status=$2, is_leader=$3 WHERE node_id=$4",
702
+ time.time(),
703
+ "active",
704
+ self._is_leader,
705
+ self.node_id,
706
+ )
707
+
708
+ async def _heartbeat_loop(self) -> None:
709
+ """The unified per-tick maintenance loop: each ``heartbeat_seconds`` it (1) refreshes
710
+ ``last_seen`` and (2) maintains leadership (acquire when not leader, liveness-check when
711
+ leader). A DB error in either is logged and the loop keeps going (a transient blip mustn't kill
712
+ membership); it exits promptly on stop by waiting on the stop event rather than a bare sleep."""
713
+ while not self._stop.is_set():
714
+ try:
715
+ await self.heartbeat_once()
716
+ except asyncio.CancelledError:
717
+ raise
718
+ except Exception as exc:
719
+ # A heartbeat miss is recoverable (the leader sweep tolerates a late beat within the
720
+ # node_timeout); log and retry next tick rather than tearing down membership. Include
721
+ # the redacted exception so a persistent failure (pool closed, auth) is diagnosable;
722
+ # the message is bounded by the configured interval.
723
+ log.warning(
724
+ "cluster: heartbeat failed for node %s; will retry: %s",
725
+ self.node_id,
726
+ safe_exc(exc),
727
+ )
728
+ try:
729
+ await self._maintain_leadership()
730
+ except asyncio.CancelledError:
731
+ raise
732
+ except Exception as exc:
733
+ # Lease maintenance is best-effort per tick (e.g. a momentary pool hiccup). We do NOT
734
+ # demote here on a DB error — _last_renew_ok simply isn't advanced, so the fence
735
+ # watchdog demotes us if the failure persists past the fence timeout. Just log + retry.
736
+ log.warning(
737
+ "cluster: leadership maintenance failed for node %s; will retry: %s",
738
+ self.node_id,
739
+ safe_exc(exc),
740
+ )
741
+ try:
742
+ await self._refresh_owned_lanes()
743
+ except asyncio.CancelledError:
744
+ raise
745
+ except Exception as exc:
746
+ # The owned-lane cache feeds the owns_lane() HINT only (not the correctness gate, which
747
+ # is the claim-time atomic lease) — a stale tick is harmless, so log and retry.
748
+ log.warning(
749
+ "cluster: owned-lane refresh failed for node %s; will retry: %s",
750
+ self.node_id,
751
+ safe_exc(exc),
752
+ )
753
+ try:
754
+ # Track B Step 6: refresh the cached cluster-wide config version so the engine's
755
+ # convergence loop polls it cheaply (config_version_cached) without a DB round-trip. A
756
+ # stale tick just delays a follower's reload by one interval (harmless), so log + retry.
757
+ await self.config_version()
758
+ except asyncio.CancelledError:
759
+ raise
760
+ except Exception as exc:
761
+ log.warning(
762
+ "cluster: config-version refresh failed for node %s; will retry: %s",
763
+ self.node_id,
764
+ safe_exc(exc),
765
+ )
766
+ try:
767
+ # Wake immediately on stop() instead of sleeping out the full interval — cooperative
768
+ # cancellation without relying on Task.cancel alone.
769
+ await asyncio.wait_for(self._stop.wait(), timeout=self._heartbeat_seconds)
770
+ except asyncio.TimeoutError:
771
+ continue # interval elapsed — beat again
772
+
773
+ # --- lane-ownership cache (Track B Step 5) -------------------------------
774
+
775
+ async def _refresh_owned_lanes(self) -> None:
776
+ """Refresh the cached set of lanes this node currently holds (unexpired), read by owns_lane().
777
+ Once per maintenance tick (bounded, off the message hot path). This is the eventually-consistent
778
+ observability HINT only — the AUTHORITATIVE single-owner-per-lane enforcement is the claim-time
779
+ atomic lane-lease acquire in :meth:`PostgresStore.claim_next_fifo`."""
780
+ rows = await self._pool.fetch(
781
+ "SELECT lane FROM lane_leases WHERE owner=$1 AND lease_expires_at > $2",
782
+ self.node_id,
783
+ time.time(),
784
+ )
785
+ self._owned_lanes = {r["lane"] for r in rows}
786
+
787
+ # --- leader election: self-fencing lease (Track B Step 4 / Workstream A2) ----
788
+
789
+ async def _maintain_leadership(self) -> None:
790
+ """One tick of leadership maintenance: try to acquire-or-renew the lease, then reconcile the
791
+ cached gate. A single atomic statement either takes a free/expired lease, renews ours, or no-ops
792
+ if another node holds a live lease (see :meth:`_claim_or_renew_lease`).
793
+
794
+ On hold: stamp ``_last_renew_ok`` (monotonic) so the fence watchdog knows we are fresh, and
795
+ promote if we weren't already leader. On not-hold: demote if we were leader (someone else holds
796
+ it / our lease expired). A DB error propagates to the loop, which logs and retries — we do NOT
797
+ demote on an error here; ``_last_renew_ok`` simply isn't advanced, so the fence watchdog demotes
798
+ us only if the failure persists past the fence timeout (and always before the lease can expire).
799
+ """
800
+ held = await self._claim_or_renew_lease()
801
+ if held:
802
+ self._last_renew_ok = self._monotonic()
803
+ if not self._is_leader:
804
+ self._is_leader = True
805
+ log.info("cluster: node %s acquired leadership (lease)", self.node_id)
806
+ elif self._is_leader:
807
+ # The lease is held by another node (or expired and taken over) — we are no longer leader.
808
+ self._is_leader = False
809
+ log.info("cluster: node %s lost leadership (lease taken or expired)", self.node_id)
810
+
811
+ async def _claim_or_renew_lease(self) -> bool:
812
+ """Atomically acquire OR renew the leadership lease and return whether this node now holds it.
813
+
814
+ One statement covers all cases against the DB's own clock (``clock_timestamp()`` — so node
815
+ clock skew never affects who may hold the lease): INSERT the row if absent (we acquire); on
816
+ conflict, UPDATE owner + expiry **only if** we already own it (renew) OR the existing lease has
817
+ expired (take over a dead leader). If another node holds a live lease the WHERE is false, the
818
+ UPDATE no-ops, ``RETURNING`` yields nothing, and we report not-held."""
819
+ row = await self._pool.fetchrow(
820
+ "INSERT INTO leader_lease (lease_key, owner, lease_expires_at) "
821
+ "VALUES ($1, $2, EXTRACT(EPOCH FROM clock_timestamp()) + $3) "
822
+ "ON CONFLICT (lease_key) DO UPDATE SET "
823
+ "owner = EXCLUDED.owner, lease_expires_at = EXCLUDED.lease_expires_at "
824
+ "WHERE leader_lease.owner = $2 "
825
+ "OR leader_lease.lease_expires_at < EXTRACT(EPOCH FROM clock_timestamp()) "
826
+ "RETURNING owner",
827
+ self._lease_key,
828
+ self.node_id,
829
+ self._lease_ttl,
830
+ )
831
+ return row is not None and row["owner"] == self.node_id
832
+
833
+ async def _fence_watchdog_loop(self) -> None:
834
+ """Self-fence watchdog (Workstream A2). Wakes every ``_fence_tick`` and, doing **no DB I/O**,
835
+ demotes this node if it has not confirmed a lease hold within ``_fence_timeout`` (monotonic).
836
+ Because it never awaits the pool, a hung/partitioned DB — which would block the maintenance loop
837
+ mid-await — cannot stop it from fencing. ``_fence_timeout < lease_ttl`` guarantees a partitioned
838
+ old leader stops reporting leader BEFORE its lease can expire and a standby acquire it."""
839
+ while not self._stop.is_set():
840
+ try:
841
+ await asyncio.wait_for(self._stop.wait(), timeout=self._fence_tick)
842
+ return # stop requested
843
+ except asyncio.TimeoutError:
844
+ pass
845
+ self._check_fence()
846
+
847
+ def _check_fence(self) -> None:
848
+ """Demote (self-fence) if we are leader but haven't confirmed a lease hold within the fence
849
+ timeout. Pure in-memory; called by the watchdog (and directly by tests)."""
850
+ if not self._is_leader:
851
+ return
852
+ last = self._last_renew_ok
853
+ if last is None:
854
+ return # leader is only set alongside _last_renew_ok; defensive no-op
855
+ if self._monotonic() - last > self._fence_timeout:
856
+ self._is_leader = False
857
+ log.warning(
858
+ "cluster: node %s SELF-FENCED — leadership lease not renewed within %.1fs (fence "
859
+ "timeout); halting leader work before the lease (TTL %.1fs) can expire",
860
+ self.node_id,
861
+ self._fence_timeout,
862
+ self._lease_ttl,
863
+ )
864
+
865
+ async def _release_leadership(self) -> None:
866
+ """Best-effort clean release: demote the cached gate first (so a concurrent is_leader() reader
867
+ never sees a stale True), then expire our lease row so a standby can acquire immediately on a
868
+ clean shutdown. Safe to call when never elected (the UPDATE simply matches no owned row)."""
869
+ was_leader = self._is_leader
870
+ self._is_leader = False
871
+ self._last_renew_ok = None
872
+ if not was_leader:
873
+ return
874
+ try:
875
+ # Expire the lease (set it to the epoch) only if we still own it, so a standby's next
876
+ # acquire tick takes over at once instead of waiting out the full TTL.
877
+ await self._pool.execute(
878
+ "UPDATE leader_lease SET lease_expires_at = 0 WHERE lease_key = $1 AND owner = $2",
879
+ self._lease_key,
880
+ self.node_id,
881
+ )
882
+ except Exception as exc: # the pool may already be closing on shutdown — log, don't raise
883
+ log.warning(
884
+ "cluster: node %s failed to release the leadership lease (it will expire on its "
885
+ "own): %s",
886
+ self.node_id,
887
+ safe_exc(exc),
888
+ )
889
+
890
+
891
+ def build_coordinator(store: Any, cluster_settings: Any) -> ClusterCoordinator:
892
+ """Pick the coordinator for ``store`` + ``cluster_settings`` — defensively.
893
+
894
+ Returns a :class:`NullCoordinator` (the byte-identical single-node default) whenever
895
+ ``cluster_settings`` is ``None`` / not ``enabled``, or the store is not a Postgres-backed store
896
+ (no ``_pool`` to drive a :class:`DbCoordinator`). Only an **enabled** ``[cluster]`` on a Postgres
897
+ store yields a :class:`DbCoordinator`.
898
+
899
+ Postgres detection is duck-typed (``getattr(store, "_pool", None)``) so this never hard-imports
900
+ ``asyncpg`` — a SQLite-only install with no extra still imports and runs this fine.
901
+ """
902
+ if cluster_settings is None or not getattr(cluster_settings, "enabled", False):
903
+ return NullCoordinator()
904
+ pool = getattr(store, "_pool", None)
905
+ if pool is None:
906
+ # [cluster].enabled is gated to backend=postgres by ServiceSettings, but stay defensive: a
907
+ # non-Postgres store (or one without a pool) can't run the DB coordinator, so fall back to the
908
+ # safe single-node null rather than crash.
909
+ log.warning(
910
+ "cluster coordination is enabled but the store has no Postgres pool; using the "
911
+ "single-node null coordinator"
912
+ )
913
+ return NullCoordinator()
914
+ # Reuse store._owner as the node-id so the cluster node-id == the row-lease owner-id (Track B
915
+ # Step 2's identity), unless the operator pinned [cluster].node_id (stable identity / tests). That
916
+ # shared id lets Steps 4/5 correlate a node's membership row with the leases it holds.
917
+ node_id = (
918
+ getattr(cluster_settings, "node_id", None)
919
+ or getattr(store, "_owner", None)
920
+ or default_node_id()
921
+ )
922
+ # Reach the store's configured schema (duck-typed) so the coordinator's nodes-DDL advisory lock is
923
+ # namespaced identically to the store's own lock keys. Defaults to 'public' inside DbCoordinator
924
+ # when the store has no _settings (a non-Postgres path never reaches here).
925
+ settings = getattr(store, "_settings", None)
926
+ db_schema = getattr(settings, "db_schema", None)
927
+ # The SQL Server store ALSO exposes a `_pool` (aioodbc), but DbCoordinator drives the asyncpg API, so
928
+ # dispatch a SQL Server store to its own active-passive coordinator instead. Backend is duck-typed off
929
+ # the settings enum's value (no StoreBackend import → no config dependency here); the import is local
930
+ # to avoid a cluster.py <-> cluster_sqlserver.py cycle (cluster_sqlserver imports this module).
931
+ backend = getattr(settings, "backend", None)
932
+ if getattr(backend, "value", backend) == "sqlserver":
933
+ from messagefoundry.pipeline.cluster_sqlserver import SqlServerCoordinator
934
+
935
+ return SqlServerCoordinator(
936
+ store,
937
+ node_id,
938
+ heartbeat_seconds=getattr(cluster_settings, "heartbeat_seconds", 10.0),
939
+ node_timeout_seconds=getattr(cluster_settings, "node_timeout_seconds", 30.0),
940
+ leader_lease_ttl_seconds=getattr(cluster_settings, "leader_lease_ttl_seconds", 30.0),
941
+ leader_fence_timeout_seconds=getattr(
942
+ cluster_settings, "leader_fence_timeout_seconds", 20.0
943
+ ),
944
+ )
945
+ return DbCoordinator(
946
+ pool,
947
+ node_id,
948
+ heartbeat_seconds=getattr(cluster_settings, "heartbeat_seconds", 10.0),
949
+ node_timeout_seconds=getattr(cluster_settings, "node_timeout_seconds", 30.0),
950
+ leader_lease_ttl_seconds=getattr(cluster_settings, "leader_lease_ttl_seconds", 30.0),
951
+ leader_fence_timeout_seconds=getattr(
952
+ cluster_settings, "leader_fence_timeout_seconds", 20.0
953
+ ),
954
+ db_schema=db_schema,
955
+ )