messagefoundry 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. messagefoundry/__init__.py +108 -0
  2. messagefoundry/__main__.py +1155 -0
  3. messagefoundry/api/__init__.py +27 -0
  4. messagefoundry/api/app.py +1581 -0
  5. messagefoundry/api/approvals.py +184 -0
  6. messagefoundry/api/auth_models.py +211 -0
  7. messagefoundry/api/auth_routes.py +655 -0
  8. messagefoundry/api/field_authz.py +96 -0
  9. messagefoundry/api/models.py +374 -0
  10. messagefoundry/api/security.py +247 -0
  11. messagefoundry/api/tls.py +47 -0
  12. messagefoundry/auth/__init__.py +39 -0
  13. messagefoundry/auth/data/common_passwords.NOTICE +13 -0
  14. messagefoundry/auth/data/common_passwords.txt +10000 -0
  15. messagefoundry/auth/identity.py +71 -0
  16. messagefoundry/auth/ldap.py +264 -0
  17. messagefoundry/auth/notifications.py +68 -0
  18. messagefoundry/auth/passwords.py +53 -0
  19. messagefoundry/auth/permissions.py +120 -0
  20. messagefoundry/auth/policy.py +153 -0
  21. messagefoundry/auth/ratelimit.py +55 -0
  22. messagefoundry/auth/service.py +1323 -0
  23. messagefoundry/auth/tokens.py +26 -0
  24. messagefoundry/auth/totp.py +174 -0
  25. messagefoundry/checks.py +174 -0
  26. messagefoundry/config/__init__.py +30 -0
  27. messagefoundry/config/active_environment.py +80 -0
  28. messagefoundry/config/ai_policy.py +140 -0
  29. messagefoundry/config/code_sets.py +260 -0
  30. messagefoundry/config/connections_edit.py +200 -0
  31. messagefoundry/config/connections_file.py +287 -0
  32. messagefoundry/config/db_lookup.py +117 -0
  33. messagefoundry/config/environments.py +116 -0
  34. messagefoundry/config/ingest_time.py +83 -0
  35. messagefoundry/config/models.py +240 -0
  36. messagefoundry/config/reference.py +158 -0
  37. messagefoundry/config/response.py +83 -0
  38. messagefoundry/config/run_context.py +153 -0
  39. messagefoundry/config/settings.py +1311 -0
  40. messagefoundry/config/state.py +99 -0
  41. messagefoundry/config/tls_policy.py +110 -0
  42. messagefoundry/config/wiring.py +1918 -0
  43. messagefoundry/console/__init__.py +20 -0
  44. messagefoundry/console/__main__.py +274 -0
  45. messagefoundry/console/_async.py +107 -0
  46. messagefoundry/console/change_password.py +111 -0
  47. messagefoundry/console/client.py +552 -0
  48. messagefoundry/console/connections.py +324 -0
  49. messagefoundry/console/login.py +107 -0
  50. messagefoundry/console/mfa.py +205 -0
  51. messagefoundry/console/reauth.py +94 -0
  52. messagefoundry/console/search.py +57 -0
  53. messagefoundry/console/service_control.py +137 -0
  54. messagefoundry/console/sessions.py +122 -0
  55. messagefoundry/console/shell.py +410 -0
  56. messagefoundry/console/status.py +377 -0
  57. messagefoundry/console/users_page.py +282 -0
  58. messagefoundry/console/widgets.py +553 -0
  59. messagefoundry/generators/README.md +27 -0
  60. messagefoundry/generators/__init__.py +15 -0
  61. messagefoundry/generators/_core.py +589 -0
  62. messagefoundry/generators/_hl7data.py +428 -0
  63. messagefoundry/generators/adt.py +286 -0
  64. messagefoundry/generators/all_types.py +24 -0
  65. messagefoundry/generators/bar.py +28 -0
  66. messagefoundry/generators/dft.py +20 -0
  67. messagefoundry/generators/mdm.py +39 -0
  68. messagefoundry/generators/mfn.py +46 -0
  69. messagefoundry/generators/oml.py +32 -0
  70. messagefoundry/generators/orl.py +30 -0
  71. messagefoundry/generators/orm.py +23 -0
  72. messagefoundry/generators/oru.py +21 -0
  73. messagefoundry/generators/ras.py +20 -0
  74. messagefoundry/generators/rde.py +54 -0
  75. messagefoundry/generators/siu.py +64 -0
  76. messagefoundry/generators/vxu.py +20 -0
  77. messagefoundry/hl7schema.py +75 -0
  78. messagefoundry/last_resort.py +55 -0
  79. messagefoundry/logging_setup.py +332 -0
  80. messagefoundry/parsing/__init__.py +64 -0
  81. messagefoundry/parsing/consistency.py +166 -0
  82. messagefoundry/parsing/groups.py +228 -0
  83. messagefoundry/parsing/message.py +453 -0
  84. messagefoundry/parsing/peek.py +237 -0
  85. messagefoundry/parsing/split.py +120 -0
  86. messagefoundry/parsing/summary.py +46 -0
  87. messagefoundry/parsing/tree.py +128 -0
  88. messagefoundry/parsing/validate.py +95 -0
  89. messagefoundry/parsing/x12/__init__.py +46 -0
  90. messagefoundry/parsing/x12/delimiters.py +140 -0
  91. messagefoundry/parsing/x12/errors.py +30 -0
  92. messagefoundry/parsing/x12/interchange.py +232 -0
  93. messagefoundry/parsing/x12/message.py +200 -0
  94. messagefoundry/parsing/x12/peek.py +207 -0
  95. messagefoundry/pipeline/__init__.py +21 -0
  96. messagefoundry/pipeline/alert_sinks.py +486 -0
  97. messagefoundry/pipeline/alerts.py +100 -0
  98. messagefoundry/pipeline/cert_expiry.py +219 -0
  99. messagefoundry/pipeline/cluster.py +955 -0
  100. messagefoundry/pipeline/cluster_sqlserver.py +444 -0
  101. messagefoundry/pipeline/config_convergence.py +137 -0
  102. messagefoundry/pipeline/dryrun.py +450 -0
  103. messagefoundry/pipeline/engine.py +756 -0
  104. messagefoundry/pipeline/leader_tasks.py +158 -0
  105. messagefoundry/pipeline/reference_sync.py +369 -0
  106. messagefoundry/pipeline/retention.py +289 -0
  107. messagefoundry/pipeline/security_notify.py +168 -0
  108. messagefoundry/pipeline/state_convergence.py +143 -0
  109. messagefoundry/pipeline/wiring_runner.py +1722 -0
  110. messagefoundry/py.typed +0 -0
  111. messagefoundry/redaction.py +71 -0
  112. messagefoundry/scaffold.py +321 -0
  113. messagefoundry/secrets_dpapi.py +129 -0
  114. messagefoundry/store/__init__.py +46 -0
  115. messagefoundry/store/audit_tee.py +67 -0
  116. messagefoundry/store/base.py +758 -0
  117. messagefoundry/store/crypto.py +166 -0
  118. messagefoundry/store/keyprovider.py +192 -0
  119. messagefoundry/store/postgres.py +3447 -0
  120. messagefoundry/store/sqlserver.py +3014 -0
  121. messagefoundry/store/store.py +3790 -0
  122. messagefoundry/timezone.py +207 -0
  123. messagefoundry/transports/__init__.py +50 -0
  124. messagefoundry/transports/base.py +269 -0
  125. messagefoundry/transports/database.py +693 -0
  126. messagefoundry/transports/file.py +551 -0
  127. messagefoundry/transports/framing.py +164 -0
  128. messagefoundry/transports/loopback.py +53 -0
  129. messagefoundry/transports/mllp.py +644 -0
  130. messagefoundry/transports/remotefile.py +664 -0
  131. messagefoundry/transports/rest.py +281 -0
  132. messagefoundry/transports/signing.py +321 -0
  133. messagefoundry/transports/soap.py +507 -0
  134. messagefoundry/transports/tcp.py +307 -0
  135. messagefoundry/transports/timer.py +146 -0
  136. messagefoundry/transports/x12.py +323 -0
  137. messagefoundry-0.1.0.dist-info/METADATA +212 -0
  138. messagefoundry-0.1.0.dist-info/RECORD +142 -0
  139. messagefoundry-0.1.0.dist-info/WHEEL +4 -0
  140. messagefoundry-0.1.0.dist-info/entry_points.txt +2 -0
  141. messagefoundry-0.1.0.dist-info/licenses/LICENSE +662 -0
  142. messagefoundry-0.1.0.dist-info/licenses/NOTICE +27 -0
@@ -0,0 +1,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)