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.
- 3tears_epoch-0.14.0/.gitignore +216 -0
- 3tears_epoch-0.14.0/LICENSE +21 -0
- 3tears_epoch-0.14.0/PKG-INFO +74 -0
- 3tears_epoch-0.14.0/README.md +52 -0
- 3tears_epoch-0.14.0/pyproject.toml +52 -0
- 3tears_epoch-0.14.0/src/threetears/epoch/__init__.py +44 -0
- 3tears_epoch-0.14.0/src/threetears/epoch/client.py +214 -0
- 3tears_epoch-0.14.0/src/threetears/epoch/listener.py +269 -0
- 3tears_epoch-0.14.0/src/threetears/epoch/migrations/__init__.py +59 -0
- 3tears_epoch-0.14.0/src/threetears/epoch/migrations/v001_create_config_epochs.py +51 -0
- 3tears_epoch-0.14.0/src/threetears/epoch/py.typed +0 -0
- 3tears_epoch-0.14.0/src/threetears/epoch/wire.py +53 -0
- 3tears_epoch-0.14.0/tests/integration/conftest.py +11 -0
- 3tears_epoch-0.14.0/tests/integration/test_multi_pod.py +322 -0
- 3tears_epoch-0.14.0/tests/unit/test_client.py +159 -0
- 3tears_epoch-0.14.0/tests/unit/test_listener.py +342 -0
- 3tears_epoch-0.14.0/tests/unit/test_wire.py +97 -0
|
@@ -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
|