openframe-adapters-db-cockroachdb 0.1.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.
- openframe_adapters_db_cockroachdb-0.1.0/.gitignore +222 -0
- openframe_adapters_db_cockroachdb-0.1.0/PKG-INFO +340 -0
- openframe_adapters_db_cockroachdb-0.1.0/README.md +318 -0
- openframe_adapters_db_cockroachdb-0.1.0/openframe/adapters/db/cockroachdb/__init__.py +58 -0
- openframe_adapters_db_cockroachdb-0.1.0/openframe/adapters/db/cockroachdb/config.py +70 -0
- openframe_adapters_db_cockroachdb-0.1.0/openframe/adapters/db/cockroachdb/connection.py +138 -0
- openframe_adapters_db_cockroachdb-0.1.0/openframe/adapters/db/cockroachdb/plugin.py +240 -0
- openframe_adapters_db_cockroachdb-0.1.0/openframe/adapters/db/cockroachdb/repository.py +503 -0
- openframe_adapters_db_cockroachdb-0.1.0/pyproject.toml +45 -0
- openframe_adapters_db_cockroachdb-0.1.0/tests/conftest.py +64 -0
- openframe_adapters_db_cockroachdb-0.1.0/tests/test_config.py +62 -0
- openframe_adapters_db_cockroachdb-0.1.0/tests/test_connection.py +156 -0
- openframe_adapters_db_cockroachdb-0.1.0/tests/test_plugin.py +287 -0
- openframe_adapters_db_cockroachdb-0.1.0/tests/test_repository.py +382 -0
|
@@ -0,0 +1,222 @@
|
|
|
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
|
+
# Redis
|
|
135
|
+
*.rdb
|
|
136
|
+
*.aof
|
|
137
|
+
*.pid
|
|
138
|
+
|
|
139
|
+
# RabbitMQ
|
|
140
|
+
mnesia/
|
|
141
|
+
rabbitmq/
|
|
142
|
+
rabbitmq-data/
|
|
143
|
+
|
|
144
|
+
# ActiveMQ
|
|
145
|
+
activemq-data/
|
|
146
|
+
|
|
147
|
+
# SageMath parsed files
|
|
148
|
+
*.sage.py
|
|
149
|
+
|
|
150
|
+
# Environments
|
|
151
|
+
.env
|
|
152
|
+
.envrc
|
|
153
|
+
.venv
|
|
154
|
+
env/
|
|
155
|
+
venv/
|
|
156
|
+
ENV/
|
|
157
|
+
env.bak/
|
|
158
|
+
venv.bak/
|
|
159
|
+
|
|
160
|
+
# Spyder project settings
|
|
161
|
+
.spyderproject
|
|
162
|
+
.spyproject
|
|
163
|
+
|
|
164
|
+
# Rope project settings
|
|
165
|
+
.ropeproject
|
|
166
|
+
|
|
167
|
+
# mkdocs documentation
|
|
168
|
+
/site
|
|
169
|
+
|
|
170
|
+
# mypy
|
|
171
|
+
.mypy_cache/
|
|
172
|
+
.dmypy.json
|
|
173
|
+
dmypy.json
|
|
174
|
+
|
|
175
|
+
# Pyre type checker
|
|
176
|
+
.pyre/
|
|
177
|
+
|
|
178
|
+
# pytype static type analyzer
|
|
179
|
+
.pytype/
|
|
180
|
+
|
|
181
|
+
# Cython debug symbols
|
|
182
|
+
cython_debug/
|
|
183
|
+
|
|
184
|
+
# PyCharm
|
|
185
|
+
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
|
186
|
+
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
|
187
|
+
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
|
188
|
+
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
|
189
|
+
# .idea/
|
|
190
|
+
|
|
191
|
+
# Abstra
|
|
192
|
+
# Abstra is an AI-powered process automation framework.
|
|
193
|
+
# Ignore directories containing user credentials, local state, and settings.
|
|
194
|
+
# Learn more at https://abstra.io/docs
|
|
195
|
+
.abstra/
|
|
196
|
+
|
|
197
|
+
# Visual Studio Code
|
|
198
|
+
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
|
|
199
|
+
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
|
|
200
|
+
# and can be added to the global gitignore or merged into this file. However, if you prefer,
|
|
201
|
+
# you could uncomment the following to ignore the entire vscode folder
|
|
202
|
+
# .vscode/
|
|
203
|
+
# Temporary file for partial code execution
|
|
204
|
+
tempCodeRunnerFile.py
|
|
205
|
+
|
|
206
|
+
# Ruff stuff:
|
|
207
|
+
.ruff_cache/
|
|
208
|
+
|
|
209
|
+
# PyPI configuration file
|
|
210
|
+
.pypirc
|
|
211
|
+
|
|
212
|
+
# Marimo
|
|
213
|
+
marimo/_static/
|
|
214
|
+
marimo/_lsp/
|
|
215
|
+
__marimo__/
|
|
216
|
+
|
|
217
|
+
# Streamlit
|
|
218
|
+
.streamlit/secrets.toml
|
|
219
|
+
|
|
220
|
+
# Miscellaneous
|
|
221
|
+
.DS_Store
|
|
222
|
+
.claude/
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: openframe-adapters-db-cockroachdb
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: OpenFrame Microservice Suite — CockroachDB database adapter.
|
|
5
|
+
Project-URL: Homepage, https://github.com/Furious-Meteors/openframe-adapters
|
|
6
|
+
Project-URL: Documentation, https://furious-meteors.github.io/openframe-adapters/
|
|
7
|
+
Project-URL: Repository, https://github.com/Furious-Meteors/openframe-adapters
|
|
8
|
+
Project-URL: Changelog, https://github.com/Furious-Meteors/openframe-adapters/blob/production/.github/CHANGELOG.md
|
|
9
|
+
Project-URL: Bug Tracker, https://github.com/Furious-Meteors/openframe-adapters/issues
|
|
10
|
+
Author-email: Furious Meteors Engineering <engineering@furiousmeteors.dev>
|
|
11
|
+
Maintainer-email: Furious Meteors Engineering <engineering@furiousmeteors.dev>
|
|
12
|
+
License: MIT
|
|
13
|
+
Keywords: asyncpg,cockroachdb,hexagonal,microservice,openframe
|
|
14
|
+
Requires-Python: >=3.11
|
|
15
|
+
Requires-Dist: asyncpg>=0.29
|
|
16
|
+
Requires-Dist: openframe-core<4,>=3.3
|
|
17
|
+
Provides-Extra: dev
|
|
18
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
19
|
+
Requires-Dist: pytest-mock>=3.14; extra == 'dev'
|
|
20
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# openframe-adapters-db-cockroachdb
|
|
24
|
+
|
|
25
|
+
CockroachDB database adapter for the **OpenFrame Microservice Suite**.
|
|
26
|
+
|
|
27
|
+
Part of the `openframe-adapters` monorepo. Implements `BaseRepository[T]` and
|
|
28
|
+
`HealthCheck` from `openframe-core` using `asyncpg` — the same driver used by
|
|
29
|
+
`openframe-adapters-db-postgres`, because CockroachDB speaks the PostgreSQL
|
|
30
|
+
wire protocol. This package is an adaptation of the Postgres adapter, not a
|
|
31
|
+
from-scratch build; see "CockroachDB vs. Postgres differences" below for
|
|
32
|
+
everything that is genuinely different.
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## Installation
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install openframe-adapters-db-cockroachdb
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Required env var:
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
COCKROACHDB_URL=postgresql://user:password@host:26257/dbname
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Note the scheme is still `postgresql://` — asyncpg only speaks the wire
|
|
49
|
+
protocol, it has no notion of "CockroachDB" as a distinct backend. Point the
|
|
50
|
+
URL at your CockroachDB node or load balancer exactly as you would a
|
|
51
|
+
Postgres primary.
|
|
52
|
+
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
## Quick start
|
|
56
|
+
|
|
57
|
+
### Raw dict mode
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
from openframe.adapters.db.cockroachdb import CockroachdbSettings, CockroachdbRepository
|
|
61
|
+
|
|
62
|
+
settings = CockroachdbSettings() # reads COCKROACHDB_URL from env
|
|
63
|
+
repo = CockroachdbRepository(settings, table="items", id_column="id")
|
|
64
|
+
|
|
65
|
+
item = await repo.get("abc-123") # dict | None
|
|
66
|
+
items, total = await repo.list(10, 0) # ([dict, ...], int)
|
|
67
|
+
created = await repo.create({"name": "x"})
|
|
68
|
+
updated = await repo.update({"id": "abc-123", "name": "y"})
|
|
69
|
+
deleted = await repo.delete("abc-123") # bool
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### Typed domain mode
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
from dataclasses import dataclass
|
|
76
|
+
from openframe.adapters.db.cockroachdb import CockroachdbSettings, CockroachdbRepository
|
|
77
|
+
|
|
78
|
+
@dataclass
|
|
79
|
+
class Item:
|
|
80
|
+
id: str
|
|
81
|
+
name: str
|
|
82
|
+
|
|
83
|
+
class ItemRepository(CockroachdbRepository[Item]):
|
|
84
|
+
_table = "items"
|
|
85
|
+
_id_column = "id"
|
|
86
|
+
|
|
87
|
+
def _row_to_entity(self, row) -> Item:
|
|
88
|
+
return Item(**dict(row))
|
|
89
|
+
|
|
90
|
+
def _entity_to_row(self, entity: Item) -> dict:
|
|
91
|
+
return {"id": entity.id, "name": entity.name}
|
|
92
|
+
|
|
93
|
+
settings = CockroachdbSettings()
|
|
94
|
+
repo = ItemRepository(settings)
|
|
95
|
+
item: Item | None = await repo.get("abc-123")
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
## Wiring into an application
|
|
101
|
+
|
|
102
|
+
For a real service, wire `CockroachdbPlugin` (the `BasePort`-satisfying
|
|
103
|
+
plugin class) through `ApplicationBootstrap.compose()` from `openframe-core`.
|
|
104
|
+
This gives you proper lifecycle management — `initialize()` / `health()` /
|
|
105
|
+
`shutdown()` — for free, instead of constructing `CockroachdbRepository`
|
|
106
|
+
directly and managing the pool yourself:
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
from openframe.core.runtime import ApplicationBootstrap
|
|
110
|
+
from openframe.core.ports import Capability
|
|
111
|
+
from openframe.adapters.db.cockroachdb import CockroachdbPlugin, CockroachdbSettings
|
|
112
|
+
|
|
113
|
+
settings = CockroachdbSettings() # reads COCKROACHDB_URL from env
|
|
114
|
+
plugin = CockroachdbPlugin(settings, table="items", id_column="id")
|
|
115
|
+
|
|
116
|
+
async with ApplicationBootstrap.compose(plugin) as app:
|
|
117
|
+
repo = app.get(Capability.PERSISTENCE) # -> CockroachdbRepository
|
|
118
|
+
item = await repo.get("abc-123")
|
|
119
|
+
# pool is closed automatically on exit (plugin.shutdown() ran)
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
`compose()` calls `plugin.initialize()` on entry and `plugin.shutdown()` on
|
|
123
|
+
exit, so the pool is created, health-checked, and torn down without any
|
|
124
|
+
manual lifecycle code. Requires `openframe-core>=3.3`.
|
|
125
|
+
|
|
126
|
+
Reach for a subclassed `ApplicationBootstrap` (with a `configure()` method)
|
|
127
|
+
only when you need per-port `config=`/`init_timeout=` or conditional
|
|
128
|
+
registration order; use `app.registry` as an escape hatch for anything
|
|
129
|
+
neither tier covers. The `CockroachdbRepository(settings)` construction
|
|
130
|
+
shown above under "Quick start" remains valid for tests, scripts, or any
|
|
131
|
+
context that doesn't need plugin lifecycle management.
|
|
132
|
+
|
|
133
|
+
---
|
|
134
|
+
|
|
135
|
+
## Raw driver access for niche features
|
|
136
|
+
|
|
137
|
+
The adapter never hides asyncpg. Access it directly for anything the port
|
|
138
|
+
does not cover:
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
from openframe.adapters.db.cockroachdb import get_cockroachdb_pool
|
|
142
|
+
|
|
143
|
+
class OrderRepository(CockroachdbRepository[Order]):
|
|
144
|
+
_table = "orders"
|
|
145
|
+
_id_column = "id"
|
|
146
|
+
|
|
147
|
+
async def bulk_upsert(self, orders: list[Order]) -> None:
|
|
148
|
+
pool = await get_cockroachdb_pool(self._settings)
|
|
149
|
+
async with pool.acquire() as conn:
|
|
150
|
+
async with conn.transaction():
|
|
151
|
+
for o in orders:
|
|
152
|
+
await conn.execute(
|
|
153
|
+
"UPSERT INTO orders (id, total) VALUES ($1, $2)",
|
|
154
|
+
o.id, o.total,
|
|
155
|
+
)
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
---
|
|
159
|
+
|
|
160
|
+
## CockroachDB vs. Postgres differences
|
|
161
|
+
|
|
162
|
+
CockroachDB speaks the same wire protocol as Postgres, so connection
|
|
163
|
+
pooling, the asyncpg exception hierarchy used for connection-vs-query
|
|
164
|
+
classification, and health checks (`SELECT 1`) are all identical to the
|
|
165
|
+
Postgres adapter. Two real differences matter at the schema/transaction
|
|
166
|
+
boundary:
|
|
167
|
+
|
|
168
|
+
### No `SERIAL`/`BIGSERIAL`
|
|
169
|
+
|
|
170
|
+
CockroachDB does not implement Postgres's `SERIAL`/`BIGSERIAL`
|
|
171
|
+
auto-increment column types the same way. If your schema uses `SERIAL`
|
|
172
|
+
against a real CockroachDB cluster, it likely will not behave as you
|
|
173
|
+
expect. The idiomatic CockroachDB primary-key patterns are:
|
|
174
|
+
|
|
175
|
+
```sql
|
|
176
|
+
-- UUID primary key (recommended default)
|
|
177
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid()
|
|
178
|
+
|
|
179
|
+
-- or, if you want an integer key
|
|
180
|
+
id INT PRIMARY KEY DEFAULT unique_rowid()
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
This adapter does not translate or rewrite DDL — it only issues the DML
|
|
184
|
+
your repository methods construct against whatever schema already exists.
|
|
185
|
+
Design your `CREATE TABLE` statements with CockroachDB's own primary-key
|
|
186
|
+
conventions, not a copy-pasted Postgres schema.
|
|
187
|
+
|
|
188
|
+
### CockroachDB transaction retries (SQLSTATE 40001)
|
|
189
|
+
|
|
190
|
+
CockroachDB always runs at `SERIALIZABLE` isolation (there is no lower
|
|
191
|
+
isolation level to opt into). Under contention this can produce a
|
|
192
|
+
retryable "transaction retry error" — SQLSTATE `40001`, with
|
|
193
|
+
`restart transaction` in the error message — that requires the **client**
|
|
194
|
+
to retry the *entire* transaction, not just the failing statement.
|
|
195
|
+
|
|
196
|
+
This adapter's basic CRUD methods (`get`/`list`/`create`/`update`/`delete`)
|
|
197
|
+
each execute as an implicit single-statement transaction, so this class of
|
|
198
|
+
error is rare in practice for simple single-statement operations — there is
|
|
199
|
+
no multi-statement transaction for CockroachDB to need to restart.
|
|
200
|
+
|
|
201
|
+
However, if you use this package's "raw driver access" pattern (above) to
|
|
202
|
+
run your **own** explicit multi-statement transaction via
|
|
203
|
+
`conn.transaction()`, you are responsible for retrying it on SQLSTATE
|
|
204
|
+
`40001`:
|
|
205
|
+
|
|
206
|
+
```python
|
|
207
|
+
import asyncpg
|
|
208
|
+
|
|
209
|
+
async def transfer_funds(pool, from_id: str, to_id: str, amount: int) -> None:
|
|
210
|
+
for attempt in range(5):
|
|
211
|
+
try:
|
|
212
|
+
async with pool.acquire() as conn:
|
|
213
|
+
async with conn.transaction():
|
|
214
|
+
await conn.execute(
|
|
215
|
+
"UPDATE accounts SET balance = balance - $1 WHERE id = $2",
|
|
216
|
+
amount, from_id,
|
|
217
|
+
)
|
|
218
|
+
await conn.execute(
|
|
219
|
+
"UPDATE accounts SET balance = balance + $1 WHERE id = $2",
|
|
220
|
+
amount, to_id,
|
|
221
|
+
)
|
|
222
|
+
return
|
|
223
|
+
except asyncpg.PostgresError as exc:
|
|
224
|
+
if getattr(exc, "sqlstate", None) == "40001":
|
|
225
|
+
continue # retry the whole transaction
|
|
226
|
+
raise
|
|
227
|
+
raise RuntimeError("transfer_funds: exhausted retries on SQLSTATE 40001")
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
This adapter deliberately does **not** implement automatic retry inside its
|
|
231
|
+
own CRUD methods — that would be a surprising, undocumented behavior change
|
|
232
|
+
from how the Postgres adapter behaves for the same driver calls. The
|
|
233
|
+
difference is documented here so callers doing their own multi-statement
|
|
234
|
+
transactions know it exists and can implement the retry loop themselves.
|
|
235
|
+
|
|
236
|
+
Everything else about this adapter — connection pooling, asyncpg exception
|
|
237
|
+
classification, health checks — is unchanged from the Postgres adapter.
|
|
238
|
+
|
|
239
|
+
---
|
|
240
|
+
|
|
241
|
+
## Configuration
|
|
242
|
+
|
|
243
|
+
All settings are read from environment variables.
|
|
244
|
+
|
|
245
|
+
| Env var | Type | Default | Description |
|
|
246
|
+
|---|---|---|---|
|
|
247
|
+
| `COCKROACHDB_URL` | `str` | **required** | Full asyncpg DSN (`postgresql://` scheme) pointed at a CockroachDB cluster |
|
|
248
|
+
| `POOL_SIZE` | `int` | `10` | Pool min/max size |
|
|
249
|
+
| `POOL_MAX_INACTIVE_CONN_LIFETIME` | `float` | `300.0` | Idle connection TTL (s) |
|
|
250
|
+
| `POOL_COMMAND_TIMEOUT` | `float` | `60.0` | Per-statement timeout (s) |
|
|
251
|
+
| `POOL_MAX_QUERIES` | `int` | `50000` | Queries per connection before recycle |
|
|
252
|
+
| `CONNECTION_TIMEOUT` | `float` | `30.0` | Pool creation timeout (s) |
|
|
253
|
+
| `OPERATION_TIMEOUT` | `float` | `10.0` | Per-operation timeout (s) |
|
|
254
|
+
| `MAX_RETRIES` | `int` | `3` | Max retry attempts |
|
|
255
|
+
|
|
256
|
+
---
|
|
257
|
+
|
|
258
|
+
## Health checks
|
|
259
|
+
|
|
260
|
+
`CockroachdbRepository` implements the `HealthCheck` protocol from `openframe-core`.
|
|
261
|
+
|
|
262
|
+
```python
|
|
263
|
+
health = await repo.health() # PluginHealth snapshot — the sole health check, never raises
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
---
|
|
267
|
+
|
|
268
|
+
## Exception hierarchy
|
|
269
|
+
|
|
270
|
+
All exceptions are `AdapterError` subclasses from `openframe.core.exceptions`.
|
|
271
|
+
Raw `asyncpg` exceptions never escape the adapter.
|
|
272
|
+
|
|
273
|
+
| Situation | Exception |
|
|
274
|
+
|---|---|
|
|
275
|
+
| Cannot connect to CockroachDB | `AdapterConnectionError` |
|
|
276
|
+
| Invalid `COCKROACHDB_URL` catalog | `AdapterConfigurationError` |
|
|
277
|
+
| Query failed (constraint, syntax, SQLSTATE 40001, etc.) | `AdapterQueryError` |
|
|
278
|
+
| Entity not found | `AdapterNotFoundError` |
|
|
279
|
+
| Operation exceeded timeout | `AdapterTimeoutError` |
|
|
280
|
+
|
|
281
|
+
A CockroachDB SQLSTATE `40001` transaction-retry error surfaces as
|
|
282
|
+
`AdapterQueryError` like any other in-band query failure — see "CockroachDB
|
|
283
|
+
transaction retries" above for why this adapter does not retry it
|
|
284
|
+
automatically, and how to retry it yourself for explicit multi-statement
|
|
285
|
+
transactions.
|
|
286
|
+
|
|
287
|
+
---
|
|
288
|
+
|
|
289
|
+
## Development
|
|
290
|
+
|
|
291
|
+
```bash
|
|
292
|
+
# from the package directory
|
|
293
|
+
pip install -e ".[dev]"
|
|
294
|
+
python -m pytest tests/ -v
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
---
|
|
298
|
+
|
|
299
|
+
## Protocol conformance
|
|
300
|
+
|
|
301
|
+
```python
|
|
302
|
+
from openframe.core.ports import BaseRepository
|
|
303
|
+
from openframe.core.health import HealthCheck
|
|
304
|
+
|
|
305
|
+
repo = CockroachdbRepository(settings, table="items", id_column="id")
|
|
306
|
+
assert isinstance(repo, BaseRepository) # True — structural check
|
|
307
|
+
assert isinstance(repo, HealthCheck) # True — structural check
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
No inheritance from either Protocol is required or used.
|
|
311
|
+
|
|
312
|
+
---
|
|
313
|
+
|
|
314
|
+
## Resilience — circuit breaking under sustained failure
|
|
315
|
+
|
|
316
|
+
`openframe-core>=3.4` ships `openframe.core.resilience.CircuitBreakerProxy` —
|
|
317
|
+
wrap the repository to short-circuit calls after repeated failures instead
|
|
318
|
+
of blocking every caller until `operation_timeout` during a sustained
|
|
319
|
+
outage. No adapter code changes are needed to support this — it composes
|
|
320
|
+
from the outside exactly like `TracingProxy`:
|
|
321
|
+
|
|
322
|
+
```python
|
|
323
|
+
from openframe.core.resilience import CircuitBreakerProxy
|
|
324
|
+
from openframe.core.tracing import TracingProxy
|
|
325
|
+
|
|
326
|
+
repo = CircuitBreakerProxy(
|
|
327
|
+
TracingProxy(app.get(Capability.PERSISTENCE).get_repository(), prefix="repository.item"),
|
|
328
|
+
failure_threshold=5,
|
|
329
|
+
reset_timeout=30.0,
|
|
330
|
+
)
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
Wrap the traced repository, not the reverse — a short-circuited call never
|
|
334
|
+
reaches the adapter, so it shouldn't produce a misleading adapter span.
|
|
335
|
+
|
|
336
|
+
---
|
|
337
|
+
|
|
338
|
+
## License
|
|
339
|
+
|
|
340
|
+
MIT
|