3tears-epoch 0.14.0__tar.gz

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.
@@ -0,0 +1,216 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[codz]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ lib64/
19
+ parts/
20
+ sdist/
21
+ var/
22
+ wheels/
23
+ share/python-wheels/
24
+ *.egg-info/
25
+ .installed.cfg
26
+ *.egg
27
+ MANIFEST
28
+
29
+ # PyInstaller
30
+ # Usually these files are written by a python script from a template
31
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
32
+ *.manifest
33
+ *.spec
34
+
35
+ # Installer logs
36
+ pip-log.txt
37
+ pip-delete-this-directory.txt
38
+
39
+ # Unit test / coverage reports
40
+ htmlcov/
41
+ .tox/
42
+ .nox/
43
+ .coverage
44
+ .coverage.*
45
+ .cache
46
+ nosetests.xml
47
+ coverage.xml
48
+ *.cover
49
+ *.py.cover
50
+ .hypothesis/
51
+ .pytest_cache/
52
+ cover/
53
+
54
+ # Translations
55
+ *.mo
56
+ *.pot
57
+
58
+ # Django stuff:
59
+ *.log
60
+ local_settings.py
61
+ db.sqlite3
62
+ db.sqlite3-journal
63
+
64
+ # Flask stuff:
65
+ instance/
66
+ .webassets-cache
67
+
68
+ # Scrapy stuff:
69
+ .scrapy
70
+
71
+ # Sphinx documentation
72
+ docs/_build/
73
+
74
+ # PyBuilder
75
+ .pybuilder/
76
+ target/
77
+
78
+ # Jupyter Notebook
79
+ .ipynb_checkpoints
80
+
81
+ # IPython
82
+ profile_default/
83
+ ipython_config.py
84
+
85
+ # pyenv
86
+ # For a library or package, you might want to ignore these files since the code is
87
+ # intended to run in multiple environments; otherwise, check them in:
88
+ # .python-version
89
+
90
+ # pipenv
91
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
92
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
93
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
94
+ # install all needed dependencies.
95
+ #Pipfile.lock
96
+
97
+ # UV
98
+ # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
99
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
100
+ # commonly ignored for libraries.
101
+ #uv.lock
102
+
103
+ # poetry
104
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
105
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
106
+ # commonly ignored for libraries.
107
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
108
+ #poetry.lock
109
+ #poetry.toml
110
+
111
+ # pdm
112
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
113
+ # pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
114
+ # https://pdm-project.org/en/latest/usage/project/#working-with-version-control
115
+ #pdm.lock
116
+ #pdm.toml
117
+ .pdm-python
118
+ .pdm-build/
119
+
120
+ # pixi
121
+ # Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
122
+ #pixi.lock
123
+ # Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
124
+ # in the .venv directory. It is recommended not to include this directory in version control.
125
+ .pixi
126
+
127
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
128
+ __pypackages__/
129
+
130
+ # Celery stuff
131
+ celerybeat-schedule
132
+ celerybeat.pid
133
+
134
+ # SageMath parsed files
135
+ *.sage.py
136
+
137
+ # Environments
138
+ .env
139
+ .envrc
140
+ .venv
141
+ env/
142
+ venv/
143
+ ENV/
144
+ env.bak/
145
+ venv.bak/
146
+
147
+ # Spyder project settings
148
+ .spyderproject
149
+ .spyproject
150
+
151
+ # Rope project settings
152
+ .ropeproject
153
+
154
+ # mkdocs documentation
155
+ /site
156
+
157
+ # mypy
158
+ .mypy_cache/
159
+ .dmypy.json
160
+ dmypy.json
161
+
162
+ # Pyre type checker
163
+ .pyre/
164
+
165
+ # pytype static type analyzer
166
+ .pytype/
167
+
168
+ # Cython debug symbols
169
+ cython_debug/
170
+
171
+ # PyCharm
172
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
173
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
174
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
175
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
176
+ #.idea/
177
+
178
+ # Abstra
179
+ # Abstra is an AI-powered process automation framework.
180
+ # Ignore directories containing user credentials, local state, and settings.
181
+ # Learn more at https://abstra.io/docs
182
+ .abstra/
183
+
184
+ # Visual Studio Code
185
+ # Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
186
+ # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
187
+ # and can be added to the global gitignore or merged into this file. However, if you prefer,
188
+ # you could uncomment the following to ignore the entire vscode folder
189
+ # .vscode/
190
+
191
+ # Ruff stuff:
192
+ .ruff_cache/
193
+
194
+ # PyPI configuration file
195
+ .pypirc
196
+
197
+ # Cursor
198
+ # Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to
199
+ # exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
200
+ # refer to https://docs.cursor.com/context/ignore-files
201
+ .cursorignore
202
+ .cursorindexingignore
203
+
204
+ # Marimo
205
+ marimo/_static/
206
+ marimo/_lsp/
207
+ __marimo__/
208
+
209
+ # Claude Code local state
210
+ .claude/
211
+
212
+ # prawduct session evidence (local governance artifacts, never shipped)
213
+ .prawduct/
214
+
215
+ # macOS folder metadata
216
+ .DS_Store
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mark Pace
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,74 @@
1
+ Metadata-Version: 2.4
2
+ Name: 3tears-epoch
3
+ Version: 0.14.0
4
+ Summary: Generation-stamped config epochs with NATS broadcast and per-message echo for cross-pod cache reload coherence
5
+ Project-URL: Repository, https://github.com/pacepace/3tears
6
+ Author: pace
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Framework :: AsyncIO
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.14
14
+ Classifier: Topic :: Software Development :: Libraries
15
+ Classifier: Typing :: Typed
16
+ Requires-Python: >=3.14
17
+ Requires-Dist: 3tears
18
+ Requires-Dist: 3tears-nats
19
+ Requires-Dist: 3tears-observe
20
+ Requires-Dist: pydantic>=2
21
+ Description-Content-Type: text/markdown
22
+
23
+ # 3tears-epoch
24
+
25
+ Generation-stamped configuration epochs with NATS broadcast and per-message echo for cross-pod cache-reload coherence.
26
+
27
+ ## Why
28
+
29
+ Multiple in-memory configuration caches across the platform need to stay coherent across pods on admin writes:
30
+
31
+ - a model capabilities registry (registered at startup from the `models` table)
32
+ - a catalog cache (`gateway_models` + `gateway_providers` + `gateway_credit_rates`)
33
+ - per-tool MCP RBAC grants
34
+
35
+ Pure NATS broadcast (push) ships with a missed-message hole: a pod that didn't receive the broadcast (subscriber blip, pod just started during the window, JetStream redelivery edge) stays stale. Pure polling (pull) is correct but expensive on hot paths.
36
+
37
+ This package combines both: a strictly-monotonic generation number (epoch) per *subject*, durable in Postgres, broadcast best-effort via NATS, and echoed in every relevant response message so consumers detect staleness on the next read and lazy-pull. Push for speed, pull for correctness.
38
+
39
+ This is the standard pattern from etcd `mod_revision` + watch, K8s `resourceVersion` + informer, Envoy xDS `version_info` + ACK, DNS SOA serial + secondary refresh.
40
+
41
+ ## Identity
42
+
43
+ The unit of identity is the **NATS subject path**. Each consumer:
44
+
45
+ 1. Defines or uses an existing `Subject` builder for the configuration domain it owns (e.g. `Subjects.capabilities_epoch()` -> `capabilities.epoch`).
46
+ 2. Calls `EpochClient.bump(subject, payload=...)` after committing the row mutation that motivates the reload.
47
+ 3. Subscribes via `EpochListener.subscribe(subject, on_bump=...)` from sibling pods.
48
+
49
+ The `platform.config_epochs` row PK is the subject path string. Postgres is the source of truth; the NATS broadcast is best-effort. A subscriber that missed every broadcast still catches up on the next request whose response echoes the higher epoch (per-message echo is consumer-side wiring; the framework supplies the building blocks).
50
+
51
+ ## Wire envelope
52
+
53
+ `EpochBumpMessage` is a frozen Pydantic v2 model:
54
+
55
+ - `subject_path: str` is the namespaced subject the bump targets (matches the row PK)
56
+ - `epoch: int` is the new strictly-monotonic value
57
+ - `payload: dict[str, Any] | None` is an opaque hint for the consumer's reload callback (e.g. `{"model_id": "...", "action": "create"}`)
58
+
59
+ The framework never inspects `payload`. Consumers parse if useful, ignore if not. The hint exists so a domain that only changes one row can avoid reloading the entire derived view.
60
+
61
+ ## Postgres
62
+
63
+ ```
64
+ CREATE TABLE IF NOT EXISTS config_epochs (
65
+ subject_path TEXT PRIMARY KEY,
66
+ epoch BIGINT NOT NULL DEFAULT 0,
67
+ payload JSONB,
68
+ date_updated TIMESTAMPTZ NOT NULL DEFAULT now()
69
+ );
70
+ ```
71
+
72
+ `bump(subject, payload)` runs `INSERT ... ON CONFLICT (subject_path) DO UPDATE SET epoch = config_epochs.epoch + 1, payload = $2, date_updated = now() RETURNING epoch`. Atomic; concurrent bumps from different writers serialize on the row lock.
73
+
74
+ Migration ships as a PLATFORM-scope `PackageMigrations` registration so consumers wire it via the canonical `MigrationRunner` alongside the rest of their platform tables.
@@ -0,0 +1,52 @@
1
+ # 3tears-epoch
2
+
3
+ Generation-stamped configuration epochs with NATS broadcast and per-message echo for cross-pod cache-reload coherence.
4
+
5
+ ## Why
6
+
7
+ Multiple in-memory configuration caches across the platform need to stay coherent across pods on admin writes:
8
+
9
+ - a model capabilities registry (registered at startup from the `models` table)
10
+ - a catalog cache (`gateway_models` + `gateway_providers` + `gateway_credit_rates`)
11
+ - per-tool MCP RBAC grants
12
+
13
+ Pure NATS broadcast (push) ships with a missed-message hole: a pod that didn't receive the broadcast (subscriber blip, pod just started during the window, JetStream redelivery edge) stays stale. Pure polling (pull) is correct but expensive on hot paths.
14
+
15
+ This package combines both: a strictly-monotonic generation number (epoch) per *subject*, durable in Postgres, broadcast best-effort via NATS, and echoed in every relevant response message so consumers detect staleness on the next read and lazy-pull. Push for speed, pull for correctness.
16
+
17
+ This is the standard pattern from etcd `mod_revision` + watch, K8s `resourceVersion` + informer, Envoy xDS `version_info` + ACK, DNS SOA serial + secondary refresh.
18
+
19
+ ## Identity
20
+
21
+ The unit of identity is the **NATS subject path**. Each consumer:
22
+
23
+ 1. Defines or uses an existing `Subject` builder for the configuration domain it owns (e.g. `Subjects.capabilities_epoch()` -> `capabilities.epoch`).
24
+ 2. Calls `EpochClient.bump(subject, payload=...)` after committing the row mutation that motivates the reload.
25
+ 3. Subscribes via `EpochListener.subscribe(subject, on_bump=...)` from sibling pods.
26
+
27
+ The `platform.config_epochs` row PK is the subject path string. Postgres is the source of truth; the NATS broadcast is best-effort. A subscriber that missed every broadcast still catches up on the next request whose response echoes the higher epoch (per-message echo is consumer-side wiring; the framework supplies the building blocks).
28
+
29
+ ## Wire envelope
30
+
31
+ `EpochBumpMessage` is a frozen Pydantic v2 model:
32
+
33
+ - `subject_path: str` is the namespaced subject the bump targets (matches the row PK)
34
+ - `epoch: int` is the new strictly-monotonic value
35
+ - `payload: dict[str, Any] | None` is an opaque hint for the consumer's reload callback (e.g. `{"model_id": "...", "action": "create"}`)
36
+
37
+ The framework never inspects `payload`. Consumers parse if useful, ignore if not. The hint exists so a domain that only changes one row can avoid reloading the entire derived view.
38
+
39
+ ## Postgres
40
+
41
+ ```
42
+ CREATE TABLE IF NOT EXISTS config_epochs (
43
+ subject_path TEXT PRIMARY KEY,
44
+ epoch BIGINT NOT NULL DEFAULT 0,
45
+ payload JSONB,
46
+ date_updated TIMESTAMPTZ NOT NULL DEFAULT now()
47
+ );
48
+ ```
49
+
50
+ `bump(subject, payload)` runs `INSERT ... ON CONFLICT (subject_path) DO UPDATE SET epoch = config_epochs.epoch + 1, payload = $2, date_updated = now() RETURNING epoch`. Atomic; concurrent bumps from different writers serialize on the row lock.
51
+
52
+ Migration ships as a PLATFORM-scope `PackageMigrations` registration so consumers wire it via the canonical `MigrationRunner` alongside the rest of their platform tables.
@@ -0,0 +1,52 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "3tears-epoch"
7
+ version = "0.14.0"
8
+ description = "Generation-stamped config epochs with NATS broadcast and per-message echo for cross-pod cache reload coherence"
9
+ readme = "README.md"
10
+ requires-python = ">=3.14"
11
+ authors = [{name = "pace"}]
12
+ license = "MIT"
13
+ license-files = ["LICENSE"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Framework :: AsyncIO",
17
+ "Intended Audience :: Developers",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.14",
20
+ "Topic :: Software Development :: Libraries",
21
+ "Typing :: Typed",
22
+ ]
23
+ dependencies = [
24
+ "3tears",
25
+ "3tears-nats",
26
+ "3tears-observe",
27
+ "pydantic>=2",
28
+ ]
29
+
30
+ [project.urls]
31
+ Repository = "https://github.com/pacepace/3tears"
32
+
33
+ [tool.hatch.build.targets.wheel]
34
+ packages = ["src/threetears"]
35
+
36
+ [tool.uv.sources]
37
+ 3tears = { workspace = true }
38
+ 3tears-nats = { workspace = true }
39
+ 3tears-observe = { workspace = true }
40
+
41
+ [tool.mypy]
42
+ strict = true
43
+ mypy_path = "src"
44
+ packages = ["threetears.epoch"]
45
+ explicit_package_bases = true
46
+ ignore_missing_imports = true
47
+
48
+ # no [tool.pytest.ini_options] block: pytest's rootdir detection
49
+ # picks the closest pyproject with [tool.pytest.ini_options], so a
50
+ # per-package block hides the workspace-level conftest.py and
51
+ # unregisters the canonical ``pytest_plugins = ["threetears.core.
52
+ # testing.fixtures"]`` line. inherit from the workspace instead.
@@ -0,0 +1,44 @@
1
+ """3tears-epoch: cross-pod config-epoch coherence.
2
+
3
+ generation-stamped configuration epochs with NATS broadcast (push) and
4
+ per-message epoch echo (pull-on-stale) to keep in-memory caches
5
+ coherent across pods on admin writes.
6
+
7
+ provides three modules:
8
+
9
+ - :mod:`threetears.epoch.wire` -- :class:`EpochBumpMessage` typed wire
10
+ envelope
11
+ - :mod:`threetears.epoch.client` -- :class:`EpochClient` publish-side
12
+ bump + current-read against ``config_epochs``
13
+ - :mod:`threetears.epoch.listener` -- :class:`EpochListener`
14
+ subscribe-side dispatcher with monotonic dedupe + echo helper
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ # Version derived from pyproject.toml so the metadata is the single
20
+ # source of truth -- a future release that bumps pyproject without
21
+ # updating ``__init__.py`` can't drift the runtime ``__version__``.
22
+ # The except guard handles the rare case where the package isn't
23
+ # installed via importlib.metadata (e.g. running directly from a
24
+ # checked-out source tree without ``uv sync``); the fallback keeps
25
+ # imports working but reports ``unknown`` rather than crashing.
26
+ from importlib.metadata import PackageNotFoundError as _PackageNotFoundError
27
+ from importlib.metadata import version as _version
28
+
29
+ try:
30
+ __version__ = _version("3tears-epoch")
31
+ except _PackageNotFoundError: # pragma: no cover - dev fallback
32
+ __version__ = "unknown"
33
+
34
+ from threetears.epoch.client import EpochClient, PoolLike
35
+ from threetears.epoch.listener import BumpCallback, EpochListener
36
+ from threetears.epoch.wire import EpochBumpMessage
37
+
38
+ __all__ = [
39
+ "BumpCallback",
40
+ "EpochBumpMessage",
41
+ "EpochClient",
42
+ "EpochListener",
43
+ "PoolLike",
44
+ ]
@@ -0,0 +1,214 @@
1
+ """epoch client -- atomic Postgres bump plus best-effort NATS broadcast.
2
+
3
+ :class:`EpochClient` is the publish-side companion to
4
+ :class:`~threetears.epoch.listener.EpochListener`. it owns one pair of
5
+ operations against the ``config_epochs`` table:
6
+
7
+ - :meth:`current` -- read the latest epoch for a subject (used by
8
+ listeners on cold start and by periodic catch-up ticks)
9
+ - :meth:`bump` -- atomically increment the epoch for a subject, then
10
+ publish an :class:`~threetears.epoch.wire.EpochBumpMessage` on the
11
+ same subject so sibling pods notice immediately
12
+
13
+ postgres is the source of truth. nats is the fast notify. a missed
14
+ broadcast is recovered by either the next periodic tick (calls
15
+ :meth:`current`) or by the next response that echoes a higher epoch
16
+ (per-message echo, consumer-side).
17
+
18
+ the row PK in ``config_epochs`` is the subject path string. the
19
+ publisher always knows its own current epoch (just returned by the
20
+ ``RETURNING epoch`` clause); subscribers learn it from broadcasts and
21
+ from echoes. this is the etcd ``mod_revision`` shape minus the
22
+ multi-key transaction support: every domain is independent.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import json
28
+ from typing import Any, Protocol
29
+
30
+ from threetears.nats import NatsClient
31
+ from threetears.nats.errors import PublishError
32
+ from threetears.nats.subjects import Subject
33
+ from threetears.observe import get_logger, traced
34
+
35
+ from threetears.epoch.wire import EpochBumpMessage
36
+
37
+ __all__ = [
38
+ "EpochClient",
39
+ "PoolLike",
40
+ ]
41
+
42
+ log = get_logger(__name__)
43
+
44
+
45
+ class PoolLike(Protocol):
46
+ """minimal pool surface :class:`EpochClient` needs.
47
+
48
+ matches the top-level ``fetchrow``/``fetchval`` methods that
49
+ :class:`asyncpg.Pool` exposes (the pool acquires + releases a
50
+ connection internally). callers pass an :class:`asyncpg.Pool`
51
+ directly in production; tests pass a duck-typed mock.
52
+
53
+ typed as a :class:`Protocol` so the package does not take a
54
+ runtime dependency on asyncpg.
55
+ """
56
+
57
+ async def fetchrow(self, query: str, *args: object) -> Any:
58
+ """run query and return the first row (or None if empty).
59
+
60
+ :param query: parameterized SQL with ``$N`` placeholders
61
+ :ptype query: str
62
+ :param args: positional parameter values
63
+ :ptype args: object
64
+ :return: row record or None
65
+ :rtype: Any
66
+ """
67
+ ...
68
+
69
+ async def fetchval(self, query: str, *args: object) -> Any:
70
+ """run query and return the first column of the first row.
71
+
72
+ :param query: parameterized SQL with ``$N`` placeholders
73
+ :ptype query: str
74
+ :param args: positional parameter values
75
+ :ptype args: object
76
+ :return: scalar value or None
77
+ :rtype: Any
78
+ """
79
+ ...
80
+
81
+
82
+ _BUMP_SQL = (
83
+ "INSERT INTO config_epochs (subject_path, epoch, payload) "
84
+ "VALUES ($1, 1, $2::jsonb) "
85
+ "ON CONFLICT (subject_path) DO UPDATE SET "
86
+ "epoch = config_epochs.epoch + 1, "
87
+ "payload = EXCLUDED.payload, "
88
+ "date_updated = now() "
89
+ "RETURNING epoch"
90
+ )
91
+
92
+ _CURRENT_SQL = "SELECT epoch FROM config_epochs WHERE subject_path = $1"
93
+
94
+
95
+ class EpochClient:
96
+ """publish-side client for cross-pod config-epoch coherence.
97
+
98
+ one instance per process; safe to call from multiple admin
99
+ handlers concurrently (the bump statement serializes on the row
100
+ lock). the client never caches the last-seen epoch -- it always
101
+ round-trips Postgres on :meth:`bump` because the
102
+ ``RETURNING`` value is the only guaranteed-monotonic answer
103
+ available to a single writer in a multi-writer system.
104
+
105
+ :param pool: asyncpg-compatible pool exposing ``fetchrow`` /
106
+ ``fetchval``; production passes :class:`asyncpg.Pool`
107
+ :ptype pool: PoolLike
108
+ :param nats_client: connected typed NATS wrapper for broadcast
109
+ :ptype nats_client: NatsClient
110
+ """
111
+
112
+ def __init__(self, pool: PoolLike, nats_client: NatsClient) -> None:
113
+ """capture pool + nats client; no I/O.
114
+
115
+ :param pool: postgres pool implementing :class:`PoolLike`
116
+ :ptype pool: PoolLike
117
+ :param nats_client: connected NatsClient
118
+ :ptype nats_client: NatsClient
119
+ :return: nothing
120
+ :rtype: None
121
+ """
122
+ self._pool = pool
123
+ self._nats = nats_client
124
+
125
+ @traced
126
+ async def current(self, subject: Subject) -> int:
127
+ """read the latest epoch recorded for a subject.
128
+
129
+ used by :class:`~threetears.epoch.listener.EpochListener` on
130
+ cold start to prime its last-seen, and by periodic catch-up
131
+ ticks. returns ``0`` when no row exists yet -- the bump-side
132
+ ``ON CONFLICT`` clause guarantees the first successful
133
+ :meth:`bump` returns ``1``, so a returned ``0`` here means
134
+ "nobody has bumped this domain in this database."
135
+
136
+ :param subject: target subject; the subject's ``path`` is
137
+ the row PK
138
+ :ptype subject: Subject
139
+ :return: latest epoch, or ``0`` if no row exists
140
+ :rtype: int
141
+ """
142
+ value = await self._pool.fetchval(_CURRENT_SQL, subject.path)
143
+ if value is None:
144
+ result = 0
145
+ else:
146
+ result = int(value)
147
+ return result
148
+
149
+ @traced
150
+ async def bump(
151
+ self,
152
+ subject: Subject,
153
+ payload: dict[str, Any] | None = None,
154
+ ) -> int:
155
+ """atomically increment the epoch for a subject, then broadcast.
156
+
157
+ the upsert is serialized on the row lock; concurrent bumps
158
+ from different writers wait briefly and each receive a
159
+ unique strictly-increasing epoch. broadcast is best-effort:
160
+ :class:`~threetears.nats.errors.PublishError` is logged and
161
+ swallowed because the row commit is the source of truth and
162
+ any subscriber that missed the broadcast catches up via
163
+ :meth:`current` on the next periodic tick or via a per-
164
+ message epoch echo.
165
+
166
+ callers MUST invoke after the row mutation that motivates
167
+ the bump has committed. bumping inside an open transaction
168
+ broadcasts a phantom epoch if the transaction rolls back.
169
+
170
+ :param subject: target subject; the subject's ``path`` is
171
+ the row PK and the broadcast subject
172
+ :ptype subject: Subject
173
+ :param payload: opaque hint forwarded to subscribers in the
174
+ broadcast envelope; framework never inspects
175
+ :ptype payload: dict[str, Any] | None
176
+ :return: the new epoch returned by ``RETURNING``
177
+ :rtype: int
178
+ :raises RuntimeError: if the upsert returns no row (should
179
+ never happen on a healthy database -- the ``RETURNING``
180
+ clause is unconditional)
181
+ """
182
+ # asyncpg does not auto-encode dict to jsonb without a per-pool
183
+ # type codec; serialize at the call site so callers do not have
184
+ # to register codecs to use this client. the ``$2::jsonb`` cast
185
+ # in the SQL parses the resulting text back to jsonb.
186
+ payload_json = json.dumps(payload) if payload is not None else None
187
+ row = await self._pool.fetchrow(_BUMP_SQL, subject.path, payload_json)
188
+ if row is None:
189
+ raise RuntimeError(
190
+ f"config_epochs upsert returned no row for subject={subject.path!r}",
191
+ )
192
+ new_epoch = int(row["epoch"])
193
+
194
+ message = EpochBumpMessage(
195
+ subject_path=subject.path,
196
+ epoch=new_epoch,
197
+ payload=payload,
198
+ )
199
+ try:
200
+ await self._nats.publish(subject=subject, message=message)
201
+ except PublishError as exc:
202
+ log.warning(
203
+ "epoch bump broadcast failed; row commit is durable, "
204
+ "subscribers will catch up via current() or per-message echo",
205
+ extra={
206
+ "extra_data": {
207
+ "subject": subject.path,
208
+ "epoch": new_epoch,
209
+ "error": str(exc),
210
+ },
211
+ },
212
+ )
213
+
214
+ return new_epoch