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,158 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (C) 2026 MessageFoundry Organization and contributors
|
|
3
|
+
"""Leader-only store-maintenance tasks (Track B Step 4).
|
|
4
|
+
|
|
5
|
+
In a cluster, crashed-node recovery is the **leader's** job: instead of the single-node unconditional
|
|
6
|
+
``reset_stale_inflight`` (which ignores leases and would steal a live sibling's in-flight rows), the
|
|
7
|
+
leader periodically calls :meth:`Store.reclaim_expired_leases`, which returns to ``pending`` only rows
|
|
8
|
+
whose lease has actually **expired** — so a crashed node's work is recovered without disturbing a live
|
|
9
|
+
node's. :class:`LeaderMaintenanceRunner` is the engine-owned task that runs that sweep on cadence,
|
|
10
|
+
**gated on leadership each pass** (a follower no-ops while the loop keeps ticking, so when it later
|
|
11
|
+
becomes leader the next pass acts).
|
|
12
|
+
|
|
13
|
+
It mirrors :class:`~messagefoundry.pipeline.retention.RetentionRunner`'s shape: construct with the
|
|
14
|
+
store + coordinator, call :meth:`start`/:meth:`stop` for the supervised loop, or :meth:`sweep_once`
|
|
15
|
+
for a single deterministic pass (tests). The engine only spawns it in clustered mode (when the
|
|
16
|
+
coordinator's ``reclaims_inflight()`` is True), so single-node / SQLite never pays for it.
|
|
17
|
+
|
|
18
|
+
Engine-side and dependency-light (stdlib + the store/cluster seams only), so it never pulls the API or
|
|
19
|
+
console into the engine.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import asyncio
|
|
25
|
+
import logging
|
|
26
|
+
import time
|
|
27
|
+
from collections.abc import Callable
|
|
28
|
+
from typing import Protocol
|
|
29
|
+
|
|
30
|
+
from messagefoundry.pipeline.cluster import ClusterCoordinator
|
|
31
|
+
|
|
32
|
+
__all__ = ["LeaderMaintenanceRunner", "ReclaimingStore"]
|
|
33
|
+
|
|
34
|
+
log = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ReclaimingStore(Protocol):
|
|
38
|
+
"""The narrow store surface this runner needs: the multi-node-safe expired-lease reclaim.
|
|
39
|
+
|
|
40
|
+
``reclaim_expired_leases`` is a Postgres-only method (NOT on the base :class:`Store` protocol —
|
|
41
|
+
SQLite is single-node and never leases), and this runner is only ever spawned in clustered mode
|
|
42
|
+
against a Postgres store, so it depends on just this method rather than the whole store contract.
|
|
43
|
+
Backend-agnostic by structural typing: it never imports the concrete ``PostgresStore``."""
|
|
44
|
+
|
|
45
|
+
async def reclaim_expired_leases(
|
|
46
|
+
self, now: float | None = ..., *, stage: str | None = ...
|
|
47
|
+
) -> int: ...
|
|
48
|
+
|
|
49
|
+
async def recover_inflight_on_promotion(
|
|
50
|
+
self, *, lane_owner: str | None, now: float | None = ...
|
|
51
|
+
) -> int: ...
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class LeaderMaintenanceRunner:
|
|
55
|
+
"""Runs the leader's periodic lease-reclaim sweep on cadence, gated on :meth:`is_leader` each pass.
|
|
56
|
+
|
|
57
|
+
Construct with the store + coordinator + interval; call :meth:`start`/:meth:`stop` for the
|
|
58
|
+
supervised loop, or :meth:`sweep_once` for a single deterministic pass (tests). A non-leader pass
|
|
59
|
+
is a no-op (the gate short-circuits before any store write), so it is safe to run on every node —
|
|
60
|
+
only the current leader actually reclaims.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
def __init__(
|
|
64
|
+
self,
|
|
65
|
+
store: ReclaimingStore,
|
|
66
|
+
coordinator: ClusterCoordinator,
|
|
67
|
+
*,
|
|
68
|
+
interval_seconds: float,
|
|
69
|
+
clock: Callable[[], float] = time.time,
|
|
70
|
+
) -> None:
|
|
71
|
+
self._store = store
|
|
72
|
+
self._coordinator = coordinator
|
|
73
|
+
self._interval_seconds = interval_seconds
|
|
74
|
+
self._clock = clock
|
|
75
|
+
self._stop = asyncio.Event()
|
|
76
|
+
self._task: asyncio.Task[None] | None = None
|
|
77
|
+
|
|
78
|
+
# --- lifecycle -----------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
def start(self) -> None:
|
|
81
|
+
"""Spawn the supervised reclaim loop (idempotent: a second call while running is a no-op)."""
|
|
82
|
+
if self._task is not None:
|
|
83
|
+
return
|
|
84
|
+
self._stop.clear()
|
|
85
|
+
self._task = asyncio.create_task(self._run())
|
|
86
|
+
log.info(
|
|
87
|
+
"cluster leader maintenance enabled: lease-reclaim sweep every %gs (leader-gated)",
|
|
88
|
+
self._interval_seconds,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
async def stop(self) -> None:
|
|
92
|
+
"""Signal the loop and await its exit (idempotent)."""
|
|
93
|
+
self._stop.set()
|
|
94
|
+
task = self._task
|
|
95
|
+
self._task = None
|
|
96
|
+
if task is not None:
|
|
97
|
+
task.cancel()
|
|
98
|
+
try:
|
|
99
|
+
await task
|
|
100
|
+
except asyncio.CancelledError:
|
|
101
|
+
pass
|
|
102
|
+
|
|
103
|
+
async def _run(self) -> None:
|
|
104
|
+
# One isolated pass per interval; an error in a pass is logged and the loop continues (a
|
|
105
|
+
# reclaim hiccup must never take the engine down). Cooperatively cancellable via _stop.
|
|
106
|
+
while not self._stop.is_set():
|
|
107
|
+
try:
|
|
108
|
+
await self.sweep_once()
|
|
109
|
+
except asyncio.CancelledError:
|
|
110
|
+
raise
|
|
111
|
+
except Exception:
|
|
112
|
+
log.exception("cluster: leader reclaim sweep failed; will retry next interval")
|
|
113
|
+
await self._sleep(self._interval_seconds)
|
|
114
|
+
|
|
115
|
+
async def _sleep(self, delay: float) -> None:
|
|
116
|
+
"""Sleep up to ``delay``, waking immediately on stop (so shutdown isn't held by the interval)."""
|
|
117
|
+
try:
|
|
118
|
+
await asyncio.wait_for(self._stop.wait(), delay)
|
|
119
|
+
except asyncio.TimeoutError:
|
|
120
|
+
pass
|
|
121
|
+
|
|
122
|
+
# --- one pass ------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
async def sweep_once(self, now: float | None = None) -> int:
|
|
125
|
+
"""Run one reclaim sweep IF this node is the leader; otherwise no-op. Returns the number of
|
|
126
|
+
expired-lease rows returned to ``pending`` (0 when a follower or nothing was expired)."""
|
|
127
|
+
if not self._coordinator.is_leader():
|
|
128
|
+
return 0
|
|
129
|
+
now = self._clock() if now is None else now
|
|
130
|
+
# reclaim_expired_leases is multi-node-safe by construction: it only reclaims rows whose lease
|
|
131
|
+
# has expired (lease_expires_at < now), never a live sibling's in-flight row.
|
|
132
|
+
reclaimed = await self._store.reclaim_expired_leases(now=now)
|
|
133
|
+
if reclaimed:
|
|
134
|
+
log.info("cluster: leader reclaimed %d expired-lease row(s)", reclaimed)
|
|
135
|
+
return reclaimed
|
|
136
|
+
|
|
137
|
+
async def recover_on_promotion(self, now: float | None = None) -> int:
|
|
138
|
+
"""One-shot on-promotion recovery (#293): IF this node is the leader, IMMEDIATELY re-pend the
|
|
139
|
+
prior leader's stranded in-flight rows (owner-scoped, lease-blind) AND take over its lane leases,
|
|
140
|
+
so failover delivery resumes promptly instead of waiting ~``[store].lease_ttl_seconds`` for the
|
|
141
|
+
periodic expired-lease sweep + lane-lease expiry. No-op on a follower (the gate short-circuits
|
|
142
|
+
before any write). Safe only in active-passive — see the store method's contract.
|
|
143
|
+
|
|
144
|
+
Distinct from :meth:`sweep_once` (the recurring background sweep, which stays lease-gated and
|
|
145
|
+
multi-node-safe): this runs ONCE on promotion and is lease-BLIND but owner-scoped, recovering the
|
|
146
|
+
prior leader's residue whose per-row leases have not yet aged out."""
|
|
147
|
+
if not self._coordinator.is_leader():
|
|
148
|
+
return 0
|
|
149
|
+
now = self._clock() if now is None else now
|
|
150
|
+
recovered = await self._store.recover_inflight_on_promotion(
|
|
151
|
+
lane_owner=self._coordinator.lane_owner(), now=now
|
|
152
|
+
)
|
|
153
|
+
if recovered:
|
|
154
|
+
log.info(
|
|
155
|
+
"cluster: on promotion recovered %d stranded in-flight row(s) from the prior leader",
|
|
156
|
+
recovered,
|
|
157
|
+
)
|
|
158
|
+
return recovered
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (C) 2026 MessageFoundry Organization and contributors
|
|
3
|
+
"""Materializes reference sets off the message path (ADR 0006 Tier 1).
|
|
4
|
+
|
|
5
|
+
The :class:`ReferenceSyncRunner` is a supervised background loop (modelled on
|
|
6
|
+
:class:`~messagefoundry.pipeline.retention.RetentionRunner`) that, for each declared
|
|
7
|
+
:class:`~messagefoundry.config.wiring.ReferenceSpec`, periodically loads the external dataset from its
|
|
8
|
+
source and writes it into the store as a new versioned, encrypted snapshot
|
|
9
|
+
(:meth:`~messagefoundry.store.base.QueueStore.write_reference_snapshot`, build-new-then-atomic-flip).
|
|
10
|
+
A Handler then reads the snapshot **purely** via ``reference("name").get(key)`` — no per-message
|
|
11
|
+
external call, so the at-least-once re-run invariant holds by construction (ADR 0006).
|
|
12
|
+
|
|
13
|
+
Tier 1 ships the **file** source (a local CSV/TOML the engine re-reads on cadence — the path for an
|
|
14
|
+
externally-produced export). The **database** source (the engine querying SQL Server directly) is
|
|
15
|
+
ADR-0006 increment 2.
|
|
16
|
+
|
|
17
|
+
**Clustered (Track B Step 6).** Each pass splits in two: materialize-from-source runs **only on the
|
|
18
|
+
leader** (gated on the coordinator's ``is_leader()``), and — **only when clustered** — every node then
|
|
19
|
+
calls :meth:`~messagefoundry.store.base.QueueStore.converge_reference_cache` to read-through any newer
|
|
20
|
+
shared snapshot the leader wrote into its own in-process cache. So the external source is read once
|
|
21
|
+
(by the leader) and followers converge by reading the shared store — no N-fold source load, no stale
|
|
22
|
+
follower caches. Single-node is byte-identical: :class:`NullCoordinator` is always leader (so it
|
|
23
|
+
materializes from source every pass exactly as before) and reports ``is_clustered()`` ``False``, so the
|
|
24
|
+
convergence call is skipped entirely (not just a returns-``[]`` no-op) and no extra DB read happens per
|
|
25
|
+
pass.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import asyncio
|
|
31
|
+
import json
|
|
32
|
+
import logging
|
|
33
|
+
import time
|
|
34
|
+
from collections.abc import Callable, Iterable, Mapping
|
|
35
|
+
from dataclasses import dataclass
|
|
36
|
+
from typing import Any
|
|
37
|
+
from uuid import uuid4
|
|
38
|
+
|
|
39
|
+
from messagefoundry.config.code_sets import CodeSetError, load_code_set
|
|
40
|
+
from messagefoundry.config.settings import EgressSettings, ReferenceSettings
|
|
41
|
+
from messagefoundry.config.wiring import ReferenceSpec, resolve_env_settings
|
|
42
|
+
from messagefoundry.pipeline.alerts import AlertSink, LoggingAlertSink
|
|
43
|
+
from messagefoundry.pipeline.cluster import ClusterCoordinator, NullCoordinator
|
|
44
|
+
from messagefoundry.store import Store
|
|
45
|
+
|
|
46
|
+
__all__ = ["ReferenceSyncRunner", "ReferenceSyncError"]
|
|
47
|
+
|
|
48
|
+
log = logging.getLogger(__name__)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class ReferenceSyncError(RuntimeError):
|
|
52
|
+
"""A reference set's source could not be materialized (bad source kind, missing file, parse error)."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _egress_allows(host: str, port: object, allowed: list[str]) -> bool:
|
|
56
|
+
"""host[:port] membership in an ``[egress]`` allowlist entry (``"host"`` = any port, or ``"host:port"``).
|
|
57
|
+
|
|
58
|
+
Same matching as the connector egress gate; reimplemented here (a few lines) to avoid importing a
|
|
59
|
+
private symbol from the runner."""
|
|
60
|
+
host = host.lower()
|
|
61
|
+
for entry in allowed:
|
|
62
|
+
allow_host, _, allow_port = entry.partition(":")
|
|
63
|
+
if allow_host.strip().lower() == host and (
|
|
64
|
+
not allow_port or str(port) == allow_port.strip()
|
|
65
|
+
):
|
|
66
|
+
return True
|
|
67
|
+
return False
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True)
|
|
71
|
+
class _SyncPass:
|
|
72
|
+
"""What one :meth:`ReferenceSyncRunner.run_once` pass did."""
|
|
73
|
+
|
|
74
|
+
synced: int # sets re-materialized from source this pass (leader only)
|
|
75
|
+
failed: int # sets whose source failed (last-good snapshot kept)
|
|
76
|
+
converged: int = (
|
|
77
|
+
0 # sets a follower read-through'd from a newer shared snapshot (Track B Step 6)
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def did_work(self) -> bool:
|
|
82
|
+
return self.synced > 0 or self.failed > 0 or self.converged > 0
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _load_file_source(settings: Mapping[str, Any]) -> dict[str, Any]:
|
|
86
|
+
"""Load a file-backed reference source into a ``{key: value}`` dict (reuses the code-set loaders).
|
|
87
|
+
|
|
88
|
+
``settings`` is the resolved (env-substituted) ``FileRef`` settings: ``path`` (.csv/.toml, the
|
|
89
|
+
code-set format) + ``encoding`` (currently informational — the loaders read utf-8)."""
|
|
90
|
+
path = settings.get("path")
|
|
91
|
+
if not path:
|
|
92
|
+
raise ReferenceSyncError("file reference source requires a 'path'")
|
|
93
|
+
try:
|
|
94
|
+
return dict(load_code_set(path)) # CSV/TOML parse + dup-key check, shared with code sets
|
|
95
|
+
except CodeSetError as exc:
|
|
96
|
+
raise ReferenceSyncError(f"file reference source {path!r}: {exc}") from exc
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _cell(value: Any) -> Any:
|
|
100
|
+
"""Coerce a DB cell to a JSON-storable value (the snapshot value is ``json.dumps``'d at rest)."""
|
|
101
|
+
if value is None or isinstance(value, (str, int, float, bool)):
|
|
102
|
+
return value
|
|
103
|
+
from messagefoundry.transports.database import _json_default # lazy: avoids aioodbc import
|
|
104
|
+
|
|
105
|
+
return _json_default(value) # date/Decimal/bytes -> iso/str/base64
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
async def _load_database_source(
|
|
109
|
+
settings: Mapping[str, Any], egress: EgressSettings | None
|
|
110
|
+
) -> dict[str, Any]:
|
|
111
|
+
"""Materialize a SQL-backed reference source (ADR 0006 increment 2) into ``{key: value}``.
|
|
112
|
+
|
|
113
|
+
Runs the operator's read-only ``statement``; ``key_column`` is the key; ``value_column`` (if set)
|
|
114
|
+
is the value, else the value is a dict of the other columns. The dial-out is gated by the
|
|
115
|
+
fail-closed ``[egress].allowed_db`` allowlist (like a DATABASE poll source). Reuses
|
|
116
|
+
``transports/database.py`` for the DSN/pool (the SQL-Server ``[sqlserver]`` extra)."""
|
|
117
|
+
from messagefoundry.transports.database import _build_dsn, _make_pool
|
|
118
|
+
|
|
119
|
+
server = str(settings.get("server", ""))
|
|
120
|
+
if egress is not None:
|
|
121
|
+
# Under deny-by-default an empty allowed_db refuses the dial-out outright (parity with the
|
|
122
|
+
# DATABASE source / db_lookup gates in wiring_runner.py — this is the one dial-out path that
|
|
123
|
+
# otherwise ignored the flag).
|
|
124
|
+
if egress.deny_by_default and not egress.allowed_db:
|
|
125
|
+
raise ReferenceSyncError(
|
|
126
|
+
"DATABASE reference source: [egress].deny_by_default is set and [egress].allowed_db "
|
|
127
|
+
"is empty — list the reference server to permit it"
|
|
128
|
+
)
|
|
129
|
+
if egress.allowed_db and not _egress_allows(
|
|
130
|
+
server, settings.get("port", 1433), egress.allowed_db
|
|
131
|
+
):
|
|
132
|
+
raise ReferenceSyncError(
|
|
133
|
+
f"DATABASE reference server {server!r} is not in the [egress].allowed_db allowlist"
|
|
134
|
+
)
|
|
135
|
+
key_col = settings.get("key_column")
|
|
136
|
+
value_col = settings.get("value_column")
|
|
137
|
+
statement = str(settings.get("statement", ""))
|
|
138
|
+
if not key_col or not statement:
|
|
139
|
+
raise ReferenceSyncError("DATABASE reference source requires 'statement' and 'key_column'")
|
|
140
|
+
dsn = _build_dsn(dict(settings)) # fail-loud on weakened TLS / bad auth, before dialing
|
|
141
|
+
pool = await _make_pool(dsn, int(settings.get("pool_max", 5)), autocommit=True)
|
|
142
|
+
try:
|
|
143
|
+
conn = await pool.acquire()
|
|
144
|
+
try:
|
|
145
|
+
cur = await conn.cursor()
|
|
146
|
+
await cur.execute(statement)
|
|
147
|
+
columns = [d[0] for d in cur.description]
|
|
148
|
+
rows = list(await cur.fetchall())
|
|
149
|
+
finally:
|
|
150
|
+
await pool.release(conn)
|
|
151
|
+
finally:
|
|
152
|
+
pool.close()
|
|
153
|
+
await pool.wait_closed()
|
|
154
|
+
if key_col not in columns:
|
|
155
|
+
raise ReferenceSyncError(f"key_column {key_col!r} not in the statement's result columns")
|
|
156
|
+
out: dict[str, Any] = {}
|
|
157
|
+
for row in rows:
|
|
158
|
+
record = dict(zip(columns, row))
|
|
159
|
+
key = str(record[key_col])
|
|
160
|
+
if value_col is not None:
|
|
161
|
+
out[key] = _cell(record.get(value_col))
|
|
162
|
+
else:
|
|
163
|
+
out[key] = {c: _cell(record[c]) for c in columns if c != key_col}
|
|
164
|
+
return out
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class ReferenceSyncRunner:
|
|
168
|
+
"""Supervises periodic materialization of declared reference sets into store snapshots (ADR 0006).
|
|
169
|
+
|
|
170
|
+
``specs`` is a *provider* (re-read each pass) so a config reload's new declarations are picked up;
|
|
171
|
+
``env_values`` resolves any :func:`~messagefoundry.config.wiring.env` refs in a source's settings.
|
|
172
|
+
"""
|
|
173
|
+
|
|
174
|
+
def __init__(
|
|
175
|
+
self,
|
|
176
|
+
store: Store,
|
|
177
|
+
specs: Callable[[], Iterable[ReferenceSpec]],
|
|
178
|
+
settings: ReferenceSettings,
|
|
179
|
+
*,
|
|
180
|
+
env_values: Mapping[str, Any] | None = None,
|
|
181
|
+
egress: EgressSettings | None = None,
|
|
182
|
+
alert_sink: AlertSink | None = None,
|
|
183
|
+
coordinator: ClusterCoordinator | None = None,
|
|
184
|
+
clock: Callable[[], float] = time.time,
|
|
185
|
+
) -> None:
|
|
186
|
+
self._store = store
|
|
187
|
+
self._specs = specs
|
|
188
|
+
self._settings = settings
|
|
189
|
+
self._env_values = dict(env_values or {})
|
|
190
|
+
self._egress = egress
|
|
191
|
+
self._alert_sink: AlertSink = alert_sink or LoggingAlertSink()
|
|
192
|
+
# Cluster coordination seam (Track B Step 6). None → the no-op NullCoordinator, whose
|
|
193
|
+
# is_leader() is always True, so single-node materializes from source every pass EXACTLY as
|
|
194
|
+
# before. In a cluster, only the leader materializes from source; every node still converges its
|
|
195
|
+
# local read cache from the shared snapshot (the convergence call is a no-op on single-node).
|
|
196
|
+
self._coordinator: ClusterCoordinator = coordinator or NullCoordinator()
|
|
197
|
+
self._clock = clock
|
|
198
|
+
self._stop = asyncio.Event()
|
|
199
|
+
self._task: asyncio.Task[None] | None = None
|
|
200
|
+
# Per-set last successful sync time (single-task state) — drives per-spec refresh cadence.
|
|
201
|
+
self._last_sync: dict[str, float] = {}
|
|
202
|
+
|
|
203
|
+
@property
|
|
204
|
+
def enabled(self) -> bool:
|
|
205
|
+
"""True when at least one reference set is declared (else :meth:`start` spawns no task)."""
|
|
206
|
+
return any(True for _ in self._specs())
|
|
207
|
+
|
|
208
|
+
def start(self) -> None:
|
|
209
|
+
"""Spawn the supervised materialization loop (no-op when no reference sets are declared)."""
|
|
210
|
+
if self._task is not None:
|
|
211
|
+
return
|
|
212
|
+
if not self.enabled:
|
|
213
|
+
log.debug("no reference sets declared; reference sync not started")
|
|
214
|
+
return
|
|
215
|
+
self._stop.clear()
|
|
216
|
+
self._task = asyncio.create_task(self._run())
|
|
217
|
+
log.info(
|
|
218
|
+
"reference sync enabled: refresh_interval=%gs", self._settings.refresh_interval_seconds
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
async def stop(self) -> None:
|
|
222
|
+
"""Signal the loop and await its exit (idempotent)."""
|
|
223
|
+
self._stop.set()
|
|
224
|
+
task = self._task
|
|
225
|
+
self._task = None
|
|
226
|
+
if task is not None:
|
|
227
|
+
task.cancel()
|
|
228
|
+
try:
|
|
229
|
+
await task
|
|
230
|
+
except asyncio.CancelledError:
|
|
231
|
+
pass
|
|
232
|
+
|
|
233
|
+
async def _run(self) -> None:
|
|
234
|
+
# One isolated pass per interval; a pass error is logged and the loop continues (a sync hiccup
|
|
235
|
+
# must never take the engine down). Cooperatively cancellable via _stop.
|
|
236
|
+
while not self._stop.is_set():
|
|
237
|
+
try:
|
|
238
|
+
await self.run_once()
|
|
239
|
+
except asyncio.CancelledError:
|
|
240
|
+
raise
|
|
241
|
+
except Exception:
|
|
242
|
+
log.exception("reference sync pass failed; will retry next interval")
|
|
243
|
+
await self._sleep(self._settings.refresh_interval_seconds)
|
|
244
|
+
|
|
245
|
+
async def _sleep(self, delay: float) -> None:
|
|
246
|
+
"""Sleep up to ``delay``, waking immediately on stop."""
|
|
247
|
+
if delay <= 0:
|
|
248
|
+
delay = 3600.0
|
|
249
|
+
try:
|
|
250
|
+
await asyncio.wait_for(self._stop.wait(), delay)
|
|
251
|
+
except asyncio.TimeoutError:
|
|
252
|
+
pass
|
|
253
|
+
|
|
254
|
+
async def sync_all(self, now: float | None = None) -> _SyncPass:
|
|
255
|
+
"""Force a sync of **every** declared set regardless of cadence (the startup pass)."""
|
|
256
|
+
return await self.run_once(now=now, force=True)
|
|
257
|
+
|
|
258
|
+
async def run_once(self, now: float | None = None, *, force: bool = False) -> _SyncPass:
|
|
259
|
+
"""One reference-sync pass: (1) materialize-from-source the due sets ON THE LEADER ONLY, then
|
|
260
|
+
(2) ALWAYS converge this node's read cache from the shared store (Track B Step 6).
|
|
261
|
+
|
|
262
|
+
Step (1) materializes each declared set whose refresh is due (or all, if ``force``) by reading
|
|
263
|
+
its external source and writing a new shared snapshot — but only when this node is the leader, so
|
|
264
|
+
in a cluster the source is read once. A follower skips (1). :class:`NullCoordinator` is always
|
|
265
|
+
leader, so single-node materializes from source every pass exactly as before. A set's source
|
|
266
|
+
failure is isolated: logged + alerted and the last-good snapshot kept (the write is simply not
|
|
267
|
+
attempted), so one bad source never blocks the others.
|
|
268
|
+
|
|
269
|
+
Step (2) calls :meth:`~messagefoundry.store.base.QueueStore.converge_reference_cache` so a
|
|
270
|
+
follower picks up the leader's just-written snapshot into its own in-process read cache (the
|
|
271
|
+
read-through). It runs **only when clustered** (``coordinator.is_clustered()``): on a single
|
|
272
|
+
node this handle is the sole writer, so its cache is always current and the call is skipped
|
|
273
|
+
entirely — keeping single-node behaviour byte-identical (no extra DB round-trip per pass)."""
|
|
274
|
+
now = self._clock() if now is None else now
|
|
275
|
+
synced = failed = 0
|
|
276
|
+
if self._coordinator.is_leader():
|
|
277
|
+
# LEADER (or single-node): re-read the external source for the due sets and write the
|
|
278
|
+
# shared snapshot. A follower skips this entirely so the source is read once per cluster.
|
|
279
|
+
for spec in self._specs():
|
|
280
|
+
last = self._last_sync.get(spec.name) # None = never synced -> always due
|
|
281
|
+
if not force and last is not None and (now - last) < spec.refresh_seconds:
|
|
282
|
+
continue
|
|
283
|
+
try:
|
|
284
|
+
await self._sync_one(spec)
|
|
285
|
+
self._last_sync[spec.name] = now
|
|
286
|
+
synced += 1
|
|
287
|
+
except Exception as exc: # source failure → keep last-good, alert, continue
|
|
288
|
+
failed += 1
|
|
289
|
+
# Log/alert the set name + error CLASS only — never str(exc): a source error (e.g. a
|
|
290
|
+
# CSV duplicate-key) can embed a reference KEY, which may be PHI for a patient-keyed
|
|
291
|
+
# set (CLAUDE.md §9 / PHI.md §7 — no PHI in the general log). The operator knows which
|
|
292
|
+
# set failed and inspects the source themselves (the RetentionRunner "counts/category
|
|
293
|
+
# only" discipline). Full exception detail is intentionally not surfaced here.
|
|
294
|
+
kind = type(exc).__name__
|
|
295
|
+
log.warning(
|
|
296
|
+
"reference set %r sync failed (keeping last-good): %s", spec.name, kind
|
|
297
|
+
)
|
|
298
|
+
self._alert(spec.name, f"source sync failed ({kind})")
|
|
299
|
+
# CLUSTERED ONLY: read-through any newer shared snapshot into the local cache so a follower
|
|
300
|
+
# converges on what the leader materialized. Skipped entirely on a single node (is_clustered()
|
|
301
|
+
# False), where this handle is the SOLE writer of its reference snapshots — so its cache is
|
|
302
|
+
# always current and converge could never find a newer version. That short-circuit keeps
|
|
303
|
+
# single-node Postgres byte-identical (no extra periodic JOIN/decrypt round-trip per pass), not
|
|
304
|
+
# just SQLite/SQL Server (whose converge already returns []). The decrypt-failure isolation here
|
|
305
|
+
# mirrors step (1)'s per-set guard: one un-decryptable set (e.g. a cross-node key mismatch) must
|
|
306
|
+
# not abort the whole pass and the leader's materialization with it.
|
|
307
|
+
refreshed: list[str] = []
|
|
308
|
+
if self._coordinator.is_clustered():
|
|
309
|
+
try:
|
|
310
|
+
refreshed = await self._store.converge_reference_cache()
|
|
311
|
+
except asyncio.CancelledError:
|
|
312
|
+
raise
|
|
313
|
+
except Exception as exc:
|
|
314
|
+
# Convergence read/decrypt failed (e.g. a cross-node encryption-key mismatch). Log the
|
|
315
|
+
# error CLASS only (a decrypt failure could carry snapshot bytes / a PHI-bearing key —
|
|
316
|
+
# CLAUDE.md §9) and continue: keep the last-good local cache and retry next interval.
|
|
317
|
+
kind = type(exc).__name__
|
|
318
|
+
log.warning("reference cache convergence failed (keeping current cache): %s", kind)
|
|
319
|
+
self._alert("<converge>", f"cache convergence failed ({kind})")
|
|
320
|
+
if refreshed:
|
|
321
|
+
# Names only (a set name is operator config, not PHI) at INFO so an operator can see a
|
|
322
|
+
# follower converge; never the keys/values (which may be PHI).
|
|
323
|
+
log.info(
|
|
324
|
+
"reference cache converged %d set(s): %s", len(refreshed), ", ".join(refreshed)
|
|
325
|
+
)
|
|
326
|
+
result = _SyncPass(synced=synced, failed=failed, converged=len(refreshed))
|
|
327
|
+
if result.did_work:
|
|
328
|
+
await self._audit(result)
|
|
329
|
+
return result
|
|
330
|
+
|
|
331
|
+
async def _sync_one(self, spec: ReferenceSpec) -> None:
|
|
332
|
+
# Resolve env() refs in the source settings against this instance's environment.
|
|
333
|
+
settings = resolve_env_settings(spec.source.settings, self._env_values)
|
|
334
|
+
kind = spec.source.kind
|
|
335
|
+
if kind == "file":
|
|
336
|
+
rows = await asyncio.to_thread(
|
|
337
|
+
_load_file_source, settings
|
|
338
|
+
) # blocking file I/O off-loop
|
|
339
|
+
elif kind == "database":
|
|
340
|
+
rows = await _load_database_source(settings, self._egress) # async aioodbc dial
|
|
341
|
+
else:
|
|
342
|
+
raise ReferenceSyncError(
|
|
343
|
+
f"reference set {spec.name!r}: unknown source kind {kind!r} (file, database)"
|
|
344
|
+
)
|
|
345
|
+
# Unique per write regardless of sub-second timing: a uuid suffix on the wall-clock seconds, so
|
|
346
|
+
# two re-materializations of the SAME set inside one second still get DISTINCT versions. A
|
|
347
|
+
# follower's converge compares version strings, so an identical version would make it SKIP a
|
|
348
|
+
# genuine re-materialization and keep stale data (Track B Step 6 correctness). The leading
|
|
349
|
+
# seconds keep versions roughly time-ordered for an operator reading reference_version.
|
|
350
|
+
version = f"v{int(self._clock())}-{uuid4().hex[:8]}"
|
|
351
|
+
await self._store.write_reference_snapshot(name=spec.name, version=version, rows=rows)
|
|
352
|
+
log.info(
|
|
353
|
+
"reference set %r materialized: %d rows (version %s)", spec.name, len(rows), version
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
def _alert(self, name: str, detail: str) -> None:
|
|
357
|
+
# The AlertSink has no reference-specific event yet; use connection_stopped as the generic
|
|
358
|
+
# "a named component degraded" signal (never raises — be defensive anyway).
|
|
359
|
+
try:
|
|
360
|
+
self._alert_sink.connection_stopped(f"reference:{name}", detail=detail)
|
|
361
|
+
except Exception:
|
|
362
|
+
log.warning("reference sync alert sink failed", exc_info=True)
|
|
363
|
+
|
|
364
|
+
async def _audit(self, result: _SyncPass) -> None:
|
|
365
|
+
detail = json.dumps(
|
|
366
|
+
{"synced": result.synced, "failed": result.failed, "converged": result.converged},
|
|
367
|
+
sort_keys=True,
|
|
368
|
+
)
|
|
369
|
+
await self._store.record_audit("reference_sync", actor="system", detail=detail)
|