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.
- messagefoundry/__init__.py +108 -0
- messagefoundry/__main__.py +1155 -0
- messagefoundry/api/__init__.py +27 -0
- messagefoundry/api/app.py +1581 -0
- messagefoundry/api/approvals.py +184 -0
- messagefoundry/api/auth_models.py +211 -0
- messagefoundry/api/auth_routes.py +655 -0
- messagefoundry/api/field_authz.py +96 -0
- messagefoundry/api/models.py +374 -0
- messagefoundry/api/security.py +247 -0
- messagefoundry/api/tls.py +47 -0
- messagefoundry/auth/__init__.py +39 -0
- messagefoundry/auth/data/common_passwords.NOTICE +13 -0
- messagefoundry/auth/data/common_passwords.txt +10000 -0
- messagefoundry/auth/identity.py +71 -0
- messagefoundry/auth/ldap.py +264 -0
- messagefoundry/auth/notifications.py +68 -0
- messagefoundry/auth/passwords.py +53 -0
- messagefoundry/auth/permissions.py +120 -0
- messagefoundry/auth/policy.py +153 -0
- messagefoundry/auth/ratelimit.py +55 -0
- messagefoundry/auth/service.py +1323 -0
- messagefoundry/auth/tokens.py +26 -0
- messagefoundry/auth/totp.py +174 -0
- messagefoundry/checks.py +174 -0
- messagefoundry/config/__init__.py +30 -0
- messagefoundry/config/active_environment.py +80 -0
- messagefoundry/config/ai_policy.py +140 -0
- messagefoundry/config/code_sets.py +260 -0
- messagefoundry/config/connections_edit.py +200 -0
- messagefoundry/config/connections_file.py +287 -0
- messagefoundry/config/db_lookup.py +117 -0
- messagefoundry/config/environments.py +116 -0
- messagefoundry/config/ingest_time.py +83 -0
- messagefoundry/config/models.py +240 -0
- messagefoundry/config/reference.py +158 -0
- messagefoundry/config/response.py +83 -0
- messagefoundry/config/run_context.py +153 -0
- messagefoundry/config/settings.py +1311 -0
- messagefoundry/config/state.py +99 -0
- messagefoundry/config/tls_policy.py +110 -0
- messagefoundry/config/wiring.py +1918 -0
- messagefoundry/console/__init__.py +20 -0
- messagefoundry/console/__main__.py +274 -0
- messagefoundry/console/_async.py +107 -0
- messagefoundry/console/change_password.py +111 -0
- messagefoundry/console/client.py +552 -0
- messagefoundry/console/connections.py +324 -0
- messagefoundry/console/login.py +107 -0
- messagefoundry/console/mfa.py +205 -0
- messagefoundry/console/reauth.py +94 -0
- messagefoundry/console/search.py +57 -0
- messagefoundry/console/service_control.py +137 -0
- messagefoundry/console/sessions.py +122 -0
- messagefoundry/console/shell.py +410 -0
- messagefoundry/console/status.py +377 -0
- messagefoundry/console/users_page.py +282 -0
- messagefoundry/console/widgets.py +553 -0
- messagefoundry/generators/README.md +27 -0
- messagefoundry/generators/__init__.py +15 -0
- messagefoundry/generators/_core.py +589 -0
- messagefoundry/generators/_hl7data.py +428 -0
- messagefoundry/generators/adt.py +286 -0
- messagefoundry/generators/all_types.py +24 -0
- messagefoundry/generators/bar.py +28 -0
- messagefoundry/generators/dft.py +20 -0
- messagefoundry/generators/mdm.py +39 -0
- messagefoundry/generators/mfn.py +46 -0
- messagefoundry/generators/oml.py +32 -0
- messagefoundry/generators/orl.py +30 -0
- messagefoundry/generators/orm.py +23 -0
- messagefoundry/generators/oru.py +21 -0
- messagefoundry/generators/ras.py +20 -0
- messagefoundry/generators/rde.py +54 -0
- messagefoundry/generators/siu.py +64 -0
- messagefoundry/generators/vxu.py +20 -0
- messagefoundry/hl7schema.py +75 -0
- messagefoundry/last_resort.py +55 -0
- messagefoundry/logging_setup.py +332 -0
- messagefoundry/parsing/__init__.py +64 -0
- messagefoundry/parsing/consistency.py +166 -0
- messagefoundry/parsing/groups.py +228 -0
- messagefoundry/parsing/message.py +453 -0
- messagefoundry/parsing/peek.py +237 -0
- messagefoundry/parsing/split.py +120 -0
- messagefoundry/parsing/summary.py +46 -0
- messagefoundry/parsing/tree.py +128 -0
- messagefoundry/parsing/validate.py +95 -0
- messagefoundry/parsing/x12/__init__.py +46 -0
- messagefoundry/parsing/x12/delimiters.py +140 -0
- messagefoundry/parsing/x12/errors.py +30 -0
- messagefoundry/parsing/x12/interchange.py +232 -0
- messagefoundry/parsing/x12/message.py +200 -0
- messagefoundry/parsing/x12/peek.py +207 -0
- messagefoundry/pipeline/__init__.py +21 -0
- messagefoundry/pipeline/alert_sinks.py +486 -0
- messagefoundry/pipeline/alerts.py +100 -0
- messagefoundry/pipeline/cert_expiry.py +219 -0
- messagefoundry/pipeline/cluster.py +955 -0
- messagefoundry/pipeline/cluster_sqlserver.py +444 -0
- messagefoundry/pipeline/config_convergence.py +137 -0
- messagefoundry/pipeline/dryrun.py +450 -0
- messagefoundry/pipeline/engine.py +756 -0
- messagefoundry/pipeline/leader_tasks.py +158 -0
- messagefoundry/pipeline/reference_sync.py +369 -0
- messagefoundry/pipeline/retention.py +289 -0
- messagefoundry/pipeline/security_notify.py +168 -0
- messagefoundry/pipeline/state_convergence.py +143 -0
- messagefoundry/pipeline/wiring_runner.py +1722 -0
- messagefoundry/py.typed +0 -0
- messagefoundry/redaction.py +71 -0
- messagefoundry/scaffold.py +321 -0
- messagefoundry/secrets_dpapi.py +129 -0
- messagefoundry/store/__init__.py +46 -0
- messagefoundry/store/audit_tee.py +67 -0
- messagefoundry/store/base.py +758 -0
- messagefoundry/store/crypto.py +166 -0
- messagefoundry/store/keyprovider.py +192 -0
- messagefoundry/store/postgres.py +3447 -0
- messagefoundry/store/sqlserver.py +3014 -0
- messagefoundry/store/store.py +3790 -0
- messagefoundry/timezone.py +207 -0
- messagefoundry/transports/__init__.py +50 -0
- messagefoundry/transports/base.py +269 -0
- messagefoundry/transports/database.py +693 -0
- messagefoundry/transports/file.py +551 -0
- messagefoundry/transports/framing.py +164 -0
- messagefoundry/transports/loopback.py +53 -0
- messagefoundry/transports/mllp.py +644 -0
- messagefoundry/transports/remotefile.py +664 -0
- messagefoundry/transports/rest.py +281 -0
- messagefoundry/transports/signing.py +321 -0
- messagefoundry/transports/soap.py +507 -0
- messagefoundry/transports/tcp.py +307 -0
- messagefoundry/transports/timer.py +146 -0
- messagefoundry/transports/x12.py +323 -0
- messagefoundry-0.1.0.dist-info/METADATA +212 -0
- messagefoundry-0.1.0.dist-info/RECORD +142 -0
- messagefoundry-0.1.0.dist-info/WHEEL +4 -0
- messagefoundry-0.1.0.dist-info/entry_points.txt +2 -0
- messagefoundry-0.1.0.dist-info/licenses/LICENSE +662 -0
- 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
|
+
)
|