openframe-adapters-db-cockroachdb 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.
- openframe/adapters/db/cockroachdb/__init__.py +58 -0
- openframe/adapters/db/cockroachdb/config.py +70 -0
- openframe/adapters/db/cockroachdb/connection.py +138 -0
- openframe/adapters/db/cockroachdb/plugin.py +240 -0
- openframe/adapters/db/cockroachdb/repository.py +503 -0
- openframe_adapters_db_cockroachdb-0.1.0.dist-info/METADATA +340 -0
- openframe_adapters_db_cockroachdb-0.1.0.dist-info/RECORD +8 -0
- openframe_adapters_db_cockroachdb-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""
|
|
2
|
+
openframe.adapters.db.cockroachdb
|
|
3
|
+
====================================
|
|
4
|
+
CockroachDB database adapter for the OpenFrame Microservice Suite.
|
|
5
|
+
|
|
6
|
+
CockroachDB speaks the PostgreSQL wire protocol, so this package uses the
|
|
7
|
+
same ``asyncpg`` driver as ``openframe-adapters-db-postgres`` and shares its
|
|
8
|
+
connection pooling and exception-classification design. See
|
|
9
|
+
``repository.py``'s module docstring and this package's README for the real
|
|
10
|
+
SQL-dialect and transaction-semantics differences from Postgres that matter
|
|
11
|
+
at this adapter's boundary (no ``SERIAL``, default ``SERIALIZABLE``
|
|
12
|
+
isolation with client-side retry requirements for explicit multi-statement
|
|
13
|
+
transactions).
|
|
14
|
+
|
|
15
|
+
Public API:
|
|
16
|
+
|
|
17
|
+
CockroachdbSettings — Pydantic Settings subclass for connection config.
|
|
18
|
+
CockroachdbRepository — Generic async repository (BaseRepository).
|
|
19
|
+
get_cockroachdb_pool — Async factory that creates / returns the cached pool.
|
|
20
|
+
|
|
21
|
+
Quick start::
|
|
22
|
+
|
|
23
|
+
from openframe.adapters.db.cockroachdb import (
|
|
24
|
+
CockroachdbSettings,
|
|
25
|
+
CockroachdbRepository,
|
|
26
|
+
get_cockroachdb_pool,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
settings = CockroachdbSettings(cockroachdb_url="postgresql://user:pw@localhost:26257/db")
|
|
30
|
+
|
|
31
|
+
# Raw dict mode
|
|
32
|
+
repo = CockroachdbRepository(settings, table="items", id_column="id")
|
|
33
|
+
item = await repo.get("abc-123") # dict | None
|
|
34
|
+
|
|
35
|
+
# Typed mode — subclass and override mapping methods
|
|
36
|
+
class ItemRepository(CockroachdbRepository[Item]):
|
|
37
|
+
_table = "items"
|
|
38
|
+
_id_column = "id"
|
|
39
|
+
|
|
40
|
+
def _row_to_entity(self, row):
|
|
41
|
+
return Item(**dict(row))
|
|
42
|
+
|
|
43
|
+
def _entity_to_row(self, entity):
|
|
44
|
+
return entity.model_dump()
|
|
45
|
+
"""
|
|
46
|
+
from __future__ import annotations
|
|
47
|
+
|
|
48
|
+
from .config import CockroachdbSettings
|
|
49
|
+
from .connection import get_cockroachdb_pool
|
|
50
|
+
from .plugin import CockroachdbPlugin
|
|
51
|
+
from .repository import CockroachdbRepository
|
|
52
|
+
|
|
53
|
+
__all__ = [
|
|
54
|
+
"CockroachdbSettings",
|
|
55
|
+
"CockroachdbRepository",
|
|
56
|
+
"get_cockroachdb_pool",
|
|
57
|
+
"CockroachdbPlugin",
|
|
58
|
+
]
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""
|
|
2
|
+
openframe/adapters/db/cockroachdb/config.py
|
|
3
|
+
=============================================
|
|
4
|
+
CockroachDB adapter settings.
|
|
5
|
+
|
|
6
|
+
Reads all connection configuration from environment variables via Pydantic
|
|
7
|
+
Settings. Every field is validated at instantiation — missing required fields
|
|
8
|
+
raise ``pydantic_core.ValidationError`` immediately so misconfigured
|
|
9
|
+
deployments fail fast on startup.
|
|
10
|
+
|
|
11
|
+
CockroachDB speaks the PostgreSQL wire protocol, so this adapter uses the
|
|
12
|
+
same ``asyncpg`` driver as ``openframe-adapters-db-postgres``. The DSN
|
|
13
|
+
scheme is still ``postgresql://`` — asyncpg has no notion of "CockroachDB"
|
|
14
|
+
as a distinct backend, it only ever knows it is talking wire-protocol
|
|
15
|
+
Postgres. Point the URL at a CockroachDB node/load balancer the same way
|
|
16
|
+
you would a Postgres primary.
|
|
17
|
+
|
|
18
|
+
Required env vars:
|
|
19
|
+
COCKROACHDB_URL: Full asyncpg DSN pointed at a CockroachDB cluster.
|
|
20
|
+
Format: postgresql://user:pass@host:port/dbname
|
|
21
|
+
SSL: postgresql://user:pass@host:26257/dbname?sslmode=verify-full
|
|
22
|
+
|
|
23
|
+
Optional env vars (all have defaults):
|
|
24
|
+
POOL_SIZE: int = 10
|
|
25
|
+
POOL_MAX_INACTIVE_CONN_LIFETIME: float = 300.0
|
|
26
|
+
POOL_COMMAND_TIMEOUT: float = 60.0
|
|
27
|
+
POOL_MAX_QUERIES: int = 50000
|
|
28
|
+
COCKROACHDB_ADAPTER_NAME: str = "cockroachdb"
|
|
29
|
+
"""
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
from openframe.core.config import BaseAdapterSettings
|
|
33
|
+
|
|
34
|
+
__all__ = ["CockroachdbSettings"]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class CockroachdbSettings(BaseAdapterSettings):
|
|
38
|
+
"""
|
|
39
|
+
Settings for the CockroachDB adapter.
|
|
40
|
+
|
|
41
|
+
All fields read from environment variables. Missing required fields raise
|
|
42
|
+
``pydantic_core.ValidationError`` at instantiation time.
|
|
43
|
+
|
|
44
|
+
Inherits from ``BaseAdapterSettings``:
|
|
45
|
+
adapter_name: str = "cockroachdb" (overrides base default)
|
|
46
|
+
connection_timeout: float = 30.0
|
|
47
|
+
operation_timeout: float = 10.0
|
|
48
|
+
max_retries: int = 3
|
|
49
|
+
|
|
50
|
+
Attributes:
|
|
51
|
+
cockroachdb_url: Full asyncpg DSN pointed at a
|
|
52
|
+
CockroachDB cluster (required). The
|
|
53
|
+
URL scheme is ``postgresql://`` —
|
|
54
|
+
asyncpg only speaks the wire
|
|
55
|
+
protocol, not a vendor name.
|
|
56
|
+
pool_size: Pool min_size and max_size. Default 10.
|
|
57
|
+
pool_max_inactive_conn_lifetime: Seconds before idle connection is
|
|
58
|
+
closed. Default 300.0.
|
|
59
|
+
pool_command_timeout: Per-statement timeout in the pool.
|
|
60
|
+
Default 60.0.
|
|
61
|
+
pool_max_queries: Queries per connection before recycle.
|
|
62
|
+
Default 50 000.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
cockroachdb_url: str
|
|
66
|
+
pool_size: int = 10
|
|
67
|
+
pool_max_inactive_conn_lifetime: float = 300.0
|
|
68
|
+
pool_command_timeout: float = 60.0
|
|
69
|
+
pool_max_queries: int = 50_000
|
|
70
|
+
adapter_name: str = "cockroachdb"
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""
|
|
2
|
+
openframe/adapters/db/cockroachdb/connection.py
|
|
3
|
+
==================================================
|
|
4
|
+
asyncpg connection pool factory and cache for CockroachDB.
|
|
5
|
+
|
|
6
|
+
CockroachDB speaks the PostgreSQL wire protocol, so this module is
|
|
7
|
+
byte-for-byte the same pooling strategy as
|
|
8
|
+
``openframe-adapters-db-postgres``'s ``connection.py`` — same driver
|
|
9
|
+
(``asyncpg``), same pool-creation call shape, same exception hierarchy.
|
|
10
|
+
|
|
11
|
+
``get_cockroachdb_pool()`` is the single entry point for obtaining an
|
|
12
|
+
asyncpg pool. It creates the pool on first call and returns the cached
|
|
13
|
+
instance on every subsequent call with the same ``cockroachdb_url`` AND the
|
|
14
|
+
same pool-relevant settings (``pool_size``, ``pool_max_inactive_conn_lifetime``,
|
|
15
|
+
``pool_command_timeout``, ``pool_max_queries``). Multiple
|
|
16
|
+
``CockroachdbRepository`` instances constructed with matching settings share
|
|
17
|
+
the same pool.
|
|
18
|
+
|
|
19
|
+
Pool cache: ``_pool_cache`` is a module-level dict keyed by
|
|
20
|
+
``(cockroachdb_url, pool-config-tuple)`` — not the URL alone. Two
|
|
21
|
+
``CockroachdbSettings`` instances with the same ``cockroachdb_url`` but
|
|
22
|
+
different pool sizing get two distinct pools, rather than the second one
|
|
23
|
+
silently inheriting the first one's configuration (the bug this key shape
|
|
24
|
+
fixes — the same live bug found in the Postgres/Mongo/Redis adapters before
|
|
25
|
+
the ecosystem-wide checklist existed).
|
|
26
|
+
Do NOT replace this with ``@lru_cache`` — that decorator does not support
|
|
27
|
+
async functions and would create a new coroutine on each call.
|
|
28
|
+
"""
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import asyncio
|
|
32
|
+
from typing import Any
|
|
33
|
+
|
|
34
|
+
import asyncpg
|
|
35
|
+
|
|
36
|
+
from openframe.core.exceptions import (
|
|
37
|
+
AdapterConfigurationError,
|
|
38
|
+
AdapterConnectionError,
|
|
39
|
+
AdapterTimeoutError,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
from .config import CockroachdbSettings
|
|
43
|
+
|
|
44
|
+
__all__ = ["get_cockroachdb_pool", "_pool_cache", "_cache_key"]
|
|
45
|
+
|
|
46
|
+
_PoolCacheKey = tuple[str, tuple[int, float, float, int]]
|
|
47
|
+
|
|
48
|
+
_pool_cache: dict[_PoolCacheKey, asyncpg.Pool] = {} # type: ignore[type-arg]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _cache_key(settings: CockroachdbSettings) -> _PoolCacheKey:
|
|
52
|
+
"""
|
|
53
|
+
Cache key covering the URL plus every pool-shape setting.
|
|
54
|
+
|
|
55
|
+
Two ``CockroachdbSettings`` for the same ``cockroachdb_url`` but
|
|
56
|
+
different pool sizing must not share a pool — a shared key here would
|
|
57
|
+
mean the second caller silently gets the first caller's pool
|
|
58
|
+
configuration.
|
|
59
|
+
"""
|
|
60
|
+
return (
|
|
61
|
+
settings.cockroachdb_url,
|
|
62
|
+
(
|
|
63
|
+
settings.pool_size,
|
|
64
|
+
settings.pool_max_inactive_conn_lifetime,
|
|
65
|
+
settings.pool_command_timeout,
|
|
66
|
+
settings.pool_max_queries,
|
|
67
|
+
),
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
async def get_cockroachdb_pool(settings: CockroachdbSettings) -> asyncpg.Pool: # type: ignore[type-arg]
|
|
72
|
+
"""
|
|
73
|
+
Create or return the cached asyncpg connection pool.
|
|
74
|
+
|
|
75
|
+
Creates the pool on first call for a given ``(cockroachdb_url, pool
|
|
76
|
+
config)`` pair. Subsequent calls with matching URL and pool settings
|
|
77
|
+
return the cached pool without re-connecting. A different pool
|
|
78
|
+
configuration for the same URL gets its own pool rather than reusing
|
|
79
|
+
the first one's.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
settings: A fully-validated ``CockroachdbSettings`` instance.
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
An ``asyncpg.Pool`` that is ready to use.
|
|
86
|
+
|
|
87
|
+
Raises:
|
|
88
|
+
AdapterConnectionError: Pool creation failed — host unreachable,
|
|
89
|
+
bad credentials, TLS error, etc.
|
|
90
|
+
AdapterConfigurationError: ``COCKROACHDB_URL`` is syntactically
|
|
91
|
+
invalid (``asyncpg.InvalidCatalogNameError``).
|
|
92
|
+
AdapterTimeoutError: Pool creation exceeded
|
|
93
|
+
``settings.connection_timeout``.
|
|
94
|
+
"""
|
|
95
|
+
url = settings.cockroachdb_url
|
|
96
|
+
key = _cache_key(settings)
|
|
97
|
+
if key in _pool_cache:
|
|
98
|
+
return _pool_cache[key]
|
|
99
|
+
|
|
100
|
+
try:
|
|
101
|
+
async with asyncio.timeout(settings.connection_timeout):
|
|
102
|
+
pool: asyncpg.Pool = await asyncpg.create_pool( # type: ignore[assignment]
|
|
103
|
+
dsn=url,
|
|
104
|
+
min_size=settings.pool_size,
|
|
105
|
+
max_size=settings.pool_size,
|
|
106
|
+
max_inactive_connection_lifetime=settings.pool_max_inactive_conn_lifetime,
|
|
107
|
+
command_timeout=settings.pool_command_timeout,
|
|
108
|
+
max_queries=settings.pool_max_queries,
|
|
109
|
+
)
|
|
110
|
+
except asyncio.TimeoutError as exc:
|
|
111
|
+
raise AdapterTimeoutError(
|
|
112
|
+
f"Pool creation exceeded {settings.connection_timeout}s connection_timeout",
|
|
113
|
+
adapter=settings.adapter_name,
|
|
114
|
+
operation="connect",
|
|
115
|
+
cause=exc,
|
|
116
|
+
) from exc
|
|
117
|
+
except asyncpg.InvalidCatalogNameError as exc:
|
|
118
|
+
raise AdapterConfigurationError(
|
|
119
|
+
f"COCKROACHDB_URL references an invalid or non-existent catalog: {url!r}",
|
|
120
|
+
adapter=settings.adapter_name,
|
|
121
|
+
operation="init",
|
|
122
|
+
cause=exc,
|
|
123
|
+
) from exc
|
|
124
|
+
except (
|
|
125
|
+
asyncpg.InvalidPasswordError,
|
|
126
|
+
asyncpg.CannotConnectNowError,
|
|
127
|
+
asyncpg.TooManyConnectionsError,
|
|
128
|
+
OSError,
|
|
129
|
+
) as exc:
|
|
130
|
+
raise AdapterConnectionError(
|
|
131
|
+
f"Cannot connect to CockroachDB at {url!r}: {exc}",
|
|
132
|
+
adapter=settings.adapter_name,
|
|
133
|
+
operation="connect",
|
|
134
|
+
cause=exc,
|
|
135
|
+
) from exc
|
|
136
|
+
|
|
137
|
+
_pool_cache[key] = pool
|
|
138
|
+
return pool
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""
|
|
2
|
+
openframe/adapters/db/cockroachdb/plugin.py
|
|
3
|
+
==============================================
|
|
4
|
+
OpenFrame plugin wrapper for CockroachdbRepository.
|
|
5
|
+
|
|
6
|
+
Stability: beta
|
|
7
|
+
|
|
8
|
+
Recommended usage — ApplicationBootstrap.compose() (openframe-core>=3.3)::
|
|
9
|
+
|
|
10
|
+
from openframe.core.runtime import ApplicationBootstrap
|
|
11
|
+
from openframe.core.ports import Capability
|
|
12
|
+
from openframe.adapters.db.cockroachdb import CockroachdbPlugin, CockroachdbSettings
|
|
13
|
+
|
|
14
|
+
plugin = CockroachdbPlugin(CockroachdbSettings(), table="items", id_column="id")
|
|
15
|
+
|
|
16
|
+
async with ApplicationBootstrap.compose(plugin) as app:
|
|
17
|
+
repo = app.get(Capability.PERSISTENCE)
|
|
18
|
+
item = await repo.get("abc-123")
|
|
19
|
+
# plugin.shutdown() ran automatically on exit
|
|
20
|
+
|
|
21
|
+
Use a subclassed ApplicationBootstrap (configure()) instead when you need
|
|
22
|
+
per-port config=/init_timeout= or conditional registration order, and
|
|
23
|
+
app.registry (PluginRegistry escape hatch) only for what neither tier
|
|
24
|
+
covers.
|
|
25
|
+
|
|
26
|
+
For tests, scripts, or anywhere plugin lifecycle management isn't needed,
|
|
27
|
+
construct CockroachdbRepository directly (no plugin required)::
|
|
28
|
+
|
|
29
|
+
repo = CockroachdbRepository(CockroachdbSettings())
|
|
30
|
+
traced = TracingProxy(repo, prefix="repository.item")
|
|
31
|
+
"""
|
|
32
|
+
# Capability: "persistence"
|
|
33
|
+
# See capability taxonomy:
|
|
34
|
+
# https://furious-meteors.github.io/openframe-core/developer-guide/how-it-works/#choosing-a-wiring-pattern
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import logging
|
|
38
|
+
|
|
39
|
+
from openframe.core.ports import BasePort, Capability, PluginContext, PluginHealth, PluginStatus
|
|
40
|
+
from openframe.core.exceptions import AdapterConnectionError
|
|
41
|
+
|
|
42
|
+
from openframe.adapters.db.cockroachdb.config import CockroachdbSettings
|
|
43
|
+
from openframe.adapters.db.cockroachdb.connection import _pool_cache, get_cockroachdb_pool
|
|
44
|
+
from openframe.adapters.db.cockroachdb.repository import CockroachdbRepository
|
|
45
|
+
|
|
46
|
+
__all__ = ["CockroachdbPlugin"]
|
|
47
|
+
|
|
48
|
+
_logger = logging.getLogger(__name__)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class CockroachdbPlugin(BasePort):
|
|
52
|
+
"""
|
|
53
|
+
CockroachDB adapter plugin for the OpenFrame plugin registry.
|
|
54
|
+
|
|
55
|
+
Capability: "persistence"
|
|
56
|
+
|
|
57
|
+
Stability: beta
|
|
58
|
+
|
|
59
|
+
Lifecycle:
|
|
60
|
+
initialize() — creates the asyncpg connection pool and verifies
|
|
61
|
+
connectivity via the repository's health(). Raises
|
|
62
|
+
AdapterConnectionError if the database is unreachable.
|
|
63
|
+
shutdown() — closes the connection pool. Never raises.
|
|
64
|
+
health() — delegates to the repository's health() and returns
|
|
65
|
+
its PluginHealth. Never raises.
|
|
66
|
+
|
|
67
|
+
The plugin exposes get_repository() after initialization for use
|
|
68
|
+
in the composition root or ApplicationBootstrap.
|
|
69
|
+
|
|
70
|
+
By default constructs a plain CockroachdbRepository. To use a
|
|
71
|
+
domain-specific subclass, pass it via repository_class::
|
|
72
|
+
|
|
73
|
+
registry.register(CockroachdbPlugin(
|
|
74
|
+
CockroachdbSettings(),
|
|
75
|
+
table="items",
|
|
76
|
+
id_column="id",
|
|
77
|
+
repository_class=ItemCockroachdbRepository,
|
|
78
|
+
))
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
name: str = "openframe-cockroachdb"
|
|
82
|
+
version: str = "0.1.0"
|
|
83
|
+
capability: Capability = Capability.PERSISTENCE
|
|
84
|
+
|
|
85
|
+
def __init__(
|
|
86
|
+
self,
|
|
87
|
+
settings: CockroachdbSettings,
|
|
88
|
+
table: str = "",
|
|
89
|
+
id_column: str = "id",
|
|
90
|
+
repository_class: type[CockroachdbRepository] = CockroachdbRepository,
|
|
91
|
+
) -> None:
|
|
92
|
+
"""
|
|
93
|
+
Args:
|
|
94
|
+
settings: CockroachdbSettings instance.
|
|
95
|
+
table: Table name. If omitted the plugin acts as a
|
|
96
|
+
connection manager only (no get_repository()).
|
|
97
|
+
id_column: Primary key column name. Defaults to "id".
|
|
98
|
+
repository_class: The CockroachdbRepository subclass to construct.
|
|
99
|
+
Defaults to the base CockroachdbRepository. Pass a
|
|
100
|
+
domain-specific subclass here to get proper
|
|
101
|
+
entity mapping through get_repository().
|
|
102
|
+
|
|
103
|
+
Raises:
|
|
104
|
+
TypeError: repository_class is not a subclass of CockroachdbRepository.
|
|
105
|
+
"""
|
|
106
|
+
if not (isinstance(repository_class, type) and issubclass(repository_class, CockroachdbRepository)):
|
|
107
|
+
raise TypeError(
|
|
108
|
+
f"repository_class must be a subclass of CockroachdbRepository, "
|
|
109
|
+
f"got {repository_class!r}"
|
|
110
|
+
)
|
|
111
|
+
self._settings = settings
|
|
112
|
+
self._table = table
|
|
113
|
+
self._id_column = id_column
|
|
114
|
+
self._repository_class = repository_class
|
|
115
|
+
self._repo: CockroachdbRepository | None = None
|
|
116
|
+
self._status = PluginStatus.REGISTERED
|
|
117
|
+
|
|
118
|
+
async def initialize(self, context: PluginContext) -> None:
|
|
119
|
+
"""
|
|
120
|
+
Initialize the CockroachDB connection pool and verify connectivity.
|
|
121
|
+
|
|
122
|
+
If a ``table`` was passed to the constructor, a
|
|
123
|
+
:class:`CockroachdbRepository` is created and connectivity is
|
|
124
|
+
verified via ``repo.health()``. When no table is provided the plugin
|
|
125
|
+
is used purely as a connection manager: connectivity is verified
|
|
126
|
+
with a direct ``SELECT 1`` against the pool.
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
context: Plugin context (config, plugin_name). Unused here —
|
|
130
|
+
settings are provided at construction time.
|
|
131
|
+
|
|
132
|
+
Raises:
|
|
133
|
+
AdapterConnectionError: CockroachDB is unreachable or credentials
|
|
134
|
+
are invalid.
|
|
135
|
+
AdapterConfigurationError: COCKROACHDB_URL is malformed.
|
|
136
|
+
"""
|
|
137
|
+
self._status = PluginStatus.INITIALIZED
|
|
138
|
+
try:
|
|
139
|
+
pool = await get_cockroachdb_pool(self._settings)
|
|
140
|
+
if self._table:
|
|
141
|
+
self._repo = self._repository_class(
|
|
142
|
+
self._settings,
|
|
143
|
+
table=self._table,
|
|
144
|
+
id_column=self._id_column,
|
|
145
|
+
)
|
|
146
|
+
health = await self._repo.health()
|
|
147
|
+
if health.status != PluginStatus.READY:
|
|
148
|
+
raise AdapterConnectionError(
|
|
149
|
+
health.message or "CockroachDB health check failed after pool creation",
|
|
150
|
+
adapter="cockroachdb",
|
|
151
|
+
operation="initialize",
|
|
152
|
+
) from None
|
|
153
|
+
else:
|
|
154
|
+
# Verify pool connectivity without requiring a table.
|
|
155
|
+
try:
|
|
156
|
+
await pool.fetchval("SELECT 1")
|
|
157
|
+
except Exception as exc:
|
|
158
|
+
raise AdapterConnectionError(
|
|
159
|
+
f"CockroachDB connectivity check failed: {exc}",
|
|
160
|
+
adapter="cockroachdb",
|
|
161
|
+
operation="initialize",
|
|
162
|
+
) from exc
|
|
163
|
+
self._status = PluginStatus.READY
|
|
164
|
+
_logger.info(
|
|
165
|
+
"CockroachdbPlugin initialized — %s (repository_class=%s)",
|
|
166
|
+
self._settings.cockroachdb_url.split("@")[-1],
|
|
167
|
+
self._repository_class.__name__,
|
|
168
|
+
)
|
|
169
|
+
except Exception:
|
|
170
|
+
self._status = PluginStatus.FAILED
|
|
171
|
+
raise
|
|
172
|
+
|
|
173
|
+
async def shutdown(self) -> None:
|
|
174
|
+
"""
|
|
175
|
+
Close the CockroachDB connection pool.
|
|
176
|
+
|
|
177
|
+
Never raises — logs errors and continues.
|
|
178
|
+
"""
|
|
179
|
+
self._status = PluginStatus.STOPPING
|
|
180
|
+
try:
|
|
181
|
+
if self._repo is not None:
|
|
182
|
+
await self._repo.close()
|
|
183
|
+
_logger.info("CockroachdbPlugin shutdown complete.")
|
|
184
|
+
except Exception as exc:
|
|
185
|
+
_logger.error("CockroachdbPlugin shutdown error (ignored): %s", exc)
|
|
186
|
+
finally:
|
|
187
|
+
self._status = PluginStatus.STOPPED
|
|
188
|
+
|
|
189
|
+
async def health(self) -> PluginHealth:
|
|
190
|
+
"""
|
|
191
|
+
Return current health snapshot.
|
|
192
|
+
|
|
193
|
+
Delegates to the repository's own ``health()`` when one exists — no
|
|
194
|
+
translation needed, it already returns a ``PluginHealth``. When the
|
|
195
|
+
plugin was constructed without a table (connection-manager-only
|
|
196
|
+
mode, no repository), the pool is checked directly.
|
|
197
|
+
|
|
198
|
+
Never raises — returns FAILED status on any exception.
|
|
199
|
+
"""
|
|
200
|
+
try:
|
|
201
|
+
if self._status != PluginStatus.READY:
|
|
202
|
+
return PluginHealth(
|
|
203
|
+
status=PluginStatus.FAILED,
|
|
204
|
+
message=f"Plugin status: {self._status.name}",
|
|
205
|
+
)
|
|
206
|
+
if self._repo is not None:
|
|
207
|
+
return await self._repo.health()
|
|
208
|
+
# No repository — check the pool directly.
|
|
209
|
+
try:
|
|
210
|
+
pool = await get_cockroachdb_pool(self._settings)
|
|
211
|
+
await pool.fetchval("SELECT 1")
|
|
212
|
+
return PluginHealth(status=PluginStatus.READY, message="")
|
|
213
|
+
except Exception as exc:
|
|
214
|
+
return PluginHealth(status=PluginStatus.FAILED, message=str(exc))
|
|
215
|
+
except Exception as exc:
|
|
216
|
+
return PluginHealth(
|
|
217
|
+
status=PluginStatus.FAILED,
|
|
218
|
+
message=str(exc),
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
def get_repository(self) -> CockroachdbRepository:
|
|
222
|
+
"""
|
|
223
|
+
Return the initialized repository.
|
|
224
|
+
|
|
225
|
+
Only valid after registry.initialize_all() has been called.
|
|
226
|
+
|
|
227
|
+
Raises:
|
|
228
|
+
RuntimeError: Plugin not yet initialized.
|
|
229
|
+
"""
|
|
230
|
+
if self._status != PluginStatus.READY:
|
|
231
|
+
raise RuntimeError(
|
|
232
|
+
f"CockroachdbPlugin is not ready (status: {self._status.name}). "
|
|
233
|
+
"Call await registry.initialize_all() first."
|
|
234
|
+
)
|
|
235
|
+
if self._repo is None:
|
|
236
|
+
raise RuntimeError(
|
|
237
|
+
"CockroachdbPlugin was initialized without a table name. "
|
|
238
|
+
"Pass table=... to CockroachdbPlugin() to enable get_repository()."
|
|
239
|
+
)
|
|
240
|
+
return self._repo
|
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
"""
|
|
2
|
+
openframe/adapters/db/cockroachdb/repository.py
|
|
3
|
+
==================================================
|
|
4
|
+
Generic CockroachDB repository implementing ``BaseRepository[T]``
|
|
5
|
+
from ``openframe-core`` via structural subtyping.
|
|
6
|
+
|
|
7
|
+
CockroachDB speaks the PostgreSQL wire protocol and this adapter uses the
|
|
8
|
+
same ``asyncpg`` driver as ``openframe-adapters-db-postgres`` — connection
|
|
9
|
+
pooling, the asyncpg exception hierarchy used for connection-vs-query
|
|
10
|
+
classification, and health checks (``SELECT 1``) are all identical.
|
|
11
|
+
|
|
12
|
+
The base class works with raw ``dict[str, Any]`` rows. Domain adapters
|
|
13
|
+
subclass it and override ``_row_to_entity()`` / ``_entity_to_row()`` to map
|
|
14
|
+
between rows and typed domain objects.
|
|
15
|
+
|
|
16
|
+
Usage — raw dict mode (no subclassing needed):
|
|
17
|
+
|
|
18
|
+
repo = CockroachdbRepository(settings, table="items", id_column="id")
|
|
19
|
+
item: dict | None = await repo.get("abc-123")
|
|
20
|
+
|
|
21
|
+
Usage — typed domain mode (subclass):
|
|
22
|
+
|
|
23
|
+
class ItemRepository(CockroachdbRepository[Item]):
|
|
24
|
+
_table = "items"
|
|
25
|
+
_id_column = "id"
|
|
26
|
+
|
|
27
|
+
def _row_to_entity(self, row: asyncpg.Record) -> Item:
|
|
28
|
+
return Item(**dict(row))
|
|
29
|
+
|
|
30
|
+
def _entity_to_row(self, entity: Item) -> dict[str, Any]:
|
|
31
|
+
return entity.model_dump()
|
|
32
|
+
|
|
33
|
+
Structural conformance (no inheritance from Protocols required):
|
|
34
|
+
|
|
35
|
+
assert isinstance(repo, BaseRepository)
|
|
36
|
+
|
|
37
|
+
--------------------------------------------------------------------------
|
|
38
|
+
CockroachDB/Postgres schema differences — READ BEFORE DESIGNING A SCHEMA
|
|
39
|
+
--------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
1. **No ``SERIAL``/``BIGSERIAL``.** CockroachDB does not implement
|
|
42
|
+
Postgres's ``SERIAL``/``BIGSERIAL`` auto-increment column types the same
|
|
43
|
+
way (it accepts the syntax for compatibility in some versions but the
|
|
44
|
+
underlying generation strategy is different and not guaranteed to behave
|
|
45
|
+
like Postgres's sequence-backed columns). The idiomatic CockroachDB
|
|
46
|
+
primary-key patterns are:
|
|
47
|
+
|
|
48
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid()
|
|
49
|
+
-- or --
|
|
50
|
+
id INT PRIMARY KEY DEFAULT unique_rowid()
|
|
51
|
+
|
|
52
|
+
If your schema uses ``SERIAL`` against a real CockroachDB cluster, do not
|
|
53
|
+
assume it behaves like Postgres — verify the actual column definition
|
|
54
|
+
CockroachDB creates. This adapter does not translate or rewrite DDL; it
|
|
55
|
+
only issues the DML your repository methods construct (``INSERT``,
|
|
56
|
+
``SELECT``, ``UPDATE``, ``DELETE``) against whatever schema already
|
|
57
|
+
exists.
|
|
58
|
+
|
|
59
|
+
2. **Default ``SERIALIZABLE`` isolation and client-side transaction
|
|
60
|
+
retries.** CockroachDB always runs at ``SERIALIZABLE`` isolation. Under
|
|
61
|
+
contention it can raise a retryable "transaction retry error" — SQLSTATE
|
|
62
|
+
``40001``, with ``restart transaction`` in the message — that requires
|
|
63
|
+
the *client* to retry the entire transaction, not just the failing
|
|
64
|
+
statement. See this package's README ("CockroachDB transaction retries")
|
|
65
|
+
for the full explanation and why this adapter does not implement
|
|
66
|
+
automatic retry internally.
|
|
67
|
+
"""
|
|
68
|
+
from __future__ import annotations
|
|
69
|
+
|
|
70
|
+
import asyncio
|
|
71
|
+
from typing import Any, Generic, TypeVar
|
|
72
|
+
|
|
73
|
+
import asyncpg
|
|
74
|
+
|
|
75
|
+
from openframe.core.exceptions import (
|
|
76
|
+
AdapterConfigurationError,
|
|
77
|
+
AdapterConnectionError,
|
|
78
|
+
AdapterQueryError,
|
|
79
|
+
AdapterTimeoutError,
|
|
80
|
+
)
|
|
81
|
+
from openframe.core.ports import BaseRepository, Capability, PluginContext, PluginHealth, PluginStatus
|
|
82
|
+
|
|
83
|
+
from .config import CockroachdbSettings
|
|
84
|
+
from .connection import _cache_key, _pool_cache, get_cockroachdb_pool
|
|
85
|
+
|
|
86
|
+
__all__ = ["CockroachdbRepository"]
|
|
87
|
+
|
|
88
|
+
T = TypeVar("T")
|
|
89
|
+
|
|
90
|
+
# asyncpg exceptions that indicate a broken/lost connection rather than a
|
|
91
|
+
# query-level failure. Raised mid-query (e.g. the connection drops while a
|
|
92
|
+
# statement is in flight), distinct from the connect-time errors handled in
|
|
93
|
+
# connection.get_cockroachdb_pool(). Identical hierarchy to the Postgres
|
|
94
|
+
# adapter — same driver, same wire protocol.
|
|
95
|
+
_CONNECTION_ERRORS = (
|
|
96
|
+
asyncpg.ConnectionDoesNotExistError,
|
|
97
|
+
asyncpg.ConnectionFailureError,
|
|
98
|
+
asyncpg.InterfaceError,
|
|
99
|
+
asyncpg.TooManyConnectionsError,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class CockroachdbRepository(Generic[T]):
|
|
104
|
+
"""
|
|
105
|
+
Generic CockroachDB repository.
|
|
106
|
+
|
|
107
|
+
Implements ``BaseRepository[T]`` structurally — no
|
|
108
|
+
inheritance from the Protocol. All asyncpg exceptions are caught and
|
|
109
|
+
re-raised as ``AdapterError`` subclasses. Every operation wraps its
|
|
110
|
+
asyncpg call in ``asyncio.timeout(settings.operation_timeout)``.
|
|
111
|
+
|
|
112
|
+
Health check: ``health()`` is the sole health check on this repository.
|
|
113
|
+
It verifies backend connectivity and returns a ``PluginHealth``
|
|
114
|
+
snapshot describing the result — never raises.
|
|
115
|
+
|
|
116
|
+
Note on primary keys: if a subclass's schema was designed for Postgres
|
|
117
|
+
and uses ``SERIAL``/``BIGSERIAL``, see the module docstring above —
|
|
118
|
+
CockroachDB's idiomatic equivalent is
|
|
119
|
+
``UUID PRIMARY KEY DEFAULT gen_random_uuid()`` or
|
|
120
|
+
``INT PRIMARY KEY DEFAULT unique_rowid()``.
|
|
121
|
+
|
|
122
|
+
Note on transactions: each of ``get``/``list``/``create``/``update``/
|
|
123
|
+
``delete`` executes as an implicit single-statement transaction, so
|
|
124
|
+
CockroachDB's SQLSTATE ``40001`` "transaction retry" errors are rare in
|
|
125
|
+
practice for these methods. A caller doing explicit multi-statement
|
|
126
|
+
transactions via raw asyncpg access (``pool.acquire()`` + manual
|
|
127
|
+
``BEGIN``/``COMMIT``) needs to implement their own retry loop around
|
|
128
|
+
that SQLSTATE — see the README's "CockroachDB transaction retries"
|
|
129
|
+
section. This adapter does not retry automatically.
|
|
130
|
+
|
|
131
|
+
Class attributes (override in subclass):
|
|
132
|
+
_table: Table name used when no ``table`` argument is passed.
|
|
133
|
+
_id_column: Primary key column. Default ``"id"``.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
settings: A ``CockroachdbSettings`` instance.
|
|
137
|
+
table: Table name. Overrides the ``_table`` class attribute.
|
|
138
|
+
id_column: PK column name. Overrides the ``_id_column`` class attribute.
|
|
139
|
+
|
|
140
|
+
Raises:
|
|
141
|
+
AdapterConfigurationError: If neither ``table`` param nor ``_table``
|
|
142
|
+
class attribute is set.
|
|
143
|
+
"""
|
|
144
|
+
|
|
145
|
+
_table: str = ""
|
|
146
|
+
_id_column: str = "id"
|
|
147
|
+
|
|
148
|
+
name: str = "openframe-cockroachdb-repository"
|
|
149
|
+
version: str = "0.1.0"
|
|
150
|
+
capability: Capability = Capability.PERSISTENCE
|
|
151
|
+
|
|
152
|
+
def __init__(
|
|
153
|
+
self,
|
|
154
|
+
settings: CockroachdbSettings,
|
|
155
|
+
table: str | None = None,
|
|
156
|
+
id_column: str | None = None,
|
|
157
|
+
) -> None:
|
|
158
|
+
self._settings = settings
|
|
159
|
+
self._table = table or self.__class__._table
|
|
160
|
+
self._id_column = id_column or self.__class__._id_column
|
|
161
|
+
|
|
162
|
+
if not self._table:
|
|
163
|
+
raise AdapterConfigurationError(
|
|
164
|
+
"CockroachdbRepository requires a table name. "
|
|
165
|
+
"Pass table= to __init__ or set _table on the subclass.",
|
|
166
|
+
adapter=settings.adapter_name,
|
|
167
|
+
operation="init",
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
# ------------------------------------------------------------------
|
|
171
|
+
# Row ↔ entity mapping (override in typed subclasses)
|
|
172
|
+
# ------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
def _row_to_entity(self, row: asyncpg.Record) -> T: # type: ignore[type-arg]
|
|
175
|
+
"""
|
|
176
|
+
Convert an asyncpg Record to the entity type ``T``.
|
|
177
|
+
|
|
178
|
+
Base implementation returns ``dict(row)``. Subclasses override this
|
|
179
|
+
to return typed domain objects.
|
|
180
|
+
"""
|
|
181
|
+
return dict(row) # type: ignore[return-value]
|
|
182
|
+
|
|
183
|
+
def _entity_to_row(self, entity: T) -> dict[str, Any]:
|
|
184
|
+
"""
|
|
185
|
+
Convert the entity type ``T`` to a column→value dict for SQL.
|
|
186
|
+
|
|
187
|
+
Base implementation returns the entity unchanged if it is already a
|
|
188
|
+
dict, or falls back to ``vars(entity)`` for simple objects. Subclasses
|
|
189
|
+
override this to serialise typed domain objects correctly.
|
|
190
|
+
"""
|
|
191
|
+
if isinstance(entity, dict):
|
|
192
|
+
return entity
|
|
193
|
+
return vars(entity)
|
|
194
|
+
|
|
195
|
+
# ------------------------------------------------------------------
|
|
196
|
+
# Exception mapping helper
|
|
197
|
+
# ------------------------------------------------------------------
|
|
198
|
+
|
|
199
|
+
def _wrap_asyncpg(
|
|
200
|
+
self, exc: Exception, operation: str
|
|
201
|
+
) -> AdapterQueryError | AdapterConnectionError:
|
|
202
|
+
"""
|
|
203
|
+
Map an ``asyncpg.PostgresError`` to the appropriate ``AdapterError``
|
|
204
|
+
subclass.
|
|
205
|
+
|
|
206
|
+
Distinguishes a lost/broken connection (``AdapterConnectionError`` —
|
|
207
|
+
retryable) from an in-band query failure such as a constraint
|
|
208
|
+
violation or a CockroachDB SQLSTATE 40001 transaction-retry error
|
|
209
|
+
(``AdapterQueryError`` — not retried automatically by this adapter;
|
|
210
|
+
see the module docstring and README for why). Same distinction
|
|
211
|
+
``PostgresRepository``/``MongoRepository``/``RedisRepository``
|
|
212
|
+
already make. Caller must ``raise ... from exc`` at the call site.
|
|
213
|
+
"""
|
|
214
|
+
if isinstance(exc, _CONNECTION_ERRORS):
|
|
215
|
+
return AdapterConnectionError(
|
|
216
|
+
f"{operation} failed — connection to CockroachDB was lost: {exc}",
|
|
217
|
+
adapter=self._settings.adapter_name,
|
|
218
|
+
operation=operation,
|
|
219
|
+
cause=exc,
|
|
220
|
+
)
|
|
221
|
+
return AdapterQueryError(
|
|
222
|
+
f"{operation} failed on {self._table}: {exc}",
|
|
223
|
+
adapter=self._settings.adapter_name,
|
|
224
|
+
operation=operation,
|
|
225
|
+
cause=exc,
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
# ------------------------------------------------------------------
|
|
229
|
+
# BaseRepository[T] interface
|
|
230
|
+
# ------------------------------------------------------------------
|
|
231
|
+
|
|
232
|
+
async def get(self, entity_id: str) -> T | None:
|
|
233
|
+
"""
|
|
234
|
+
Retrieve a single row by primary key.
|
|
235
|
+
|
|
236
|
+
Args:
|
|
237
|
+
entity_id: Value of the ``_id_column`` to look up.
|
|
238
|
+
|
|
239
|
+
Returns:
|
|
240
|
+
The entity if a matching row exists, ``None`` otherwise.
|
|
241
|
+
|
|
242
|
+
Raises:
|
|
243
|
+
AdapterQueryError: Query failed after connection was established.
|
|
244
|
+
AdapterTimeoutError: Operation exceeded ``operation_timeout``.
|
|
245
|
+
"""
|
|
246
|
+
pool = await get_cockroachdb_pool(self._settings)
|
|
247
|
+
query = (
|
|
248
|
+
f"SELECT * FROM {self._table} "
|
|
249
|
+
f"WHERE {self._id_column} = $1 LIMIT 1"
|
|
250
|
+
)
|
|
251
|
+
try:
|
|
252
|
+
async with asyncio.timeout(self._settings.operation_timeout):
|
|
253
|
+
row = await pool.fetchrow(query, entity_id)
|
|
254
|
+
except asyncio.TimeoutError as exc:
|
|
255
|
+
raise AdapterTimeoutError(
|
|
256
|
+
f"get exceeded {self._settings.operation_timeout}s operation_timeout",
|
|
257
|
+
adapter=self._settings.adapter_name,
|
|
258
|
+
operation="get",
|
|
259
|
+
cause=exc,
|
|
260
|
+
) from exc
|
|
261
|
+
except (asyncpg.PostgresError, asyncpg.InterfaceError) as exc:
|
|
262
|
+
raise self._wrap_asyncpg(exc, "get") from exc
|
|
263
|
+
|
|
264
|
+
if row is None:
|
|
265
|
+
return None
|
|
266
|
+
return self._row_to_entity(row)
|
|
267
|
+
|
|
268
|
+
async def list(self, limit: int, offset: int) -> tuple[list[T], int]:
|
|
269
|
+
"""
|
|
270
|
+
Return a paginated slice of rows and the total row count.
|
|
271
|
+
|
|
272
|
+
Both queries run on a single connection acquired from the pool.
|
|
273
|
+
|
|
274
|
+
Args:
|
|
275
|
+
limit: Maximum number of rows to return.
|
|
276
|
+
offset: Number of rows to skip.
|
|
277
|
+
|
|
278
|
+
Returns:
|
|
279
|
+
A 2-tuple ``(entities, total_count)`` where ``total_count`` is
|
|
280
|
+
the number of all rows in the table (not just the slice).
|
|
281
|
+
|
|
282
|
+
Raises:
|
|
283
|
+
AdapterQueryError: Query failed after connection was established.
|
|
284
|
+
AdapterTimeoutError: Operation exceeded ``operation_timeout``.
|
|
285
|
+
"""
|
|
286
|
+
pool = await get_cockroachdb_pool(self._settings)
|
|
287
|
+
rows_query = (
|
|
288
|
+
f"SELECT * FROM {self._table} "
|
|
289
|
+
f"ORDER BY {self._id_column} LIMIT $1 OFFSET $2"
|
|
290
|
+
)
|
|
291
|
+
count_query = f"SELECT COUNT(*) FROM {self._table}"
|
|
292
|
+
try:
|
|
293
|
+
async with asyncio.timeout(self._settings.operation_timeout):
|
|
294
|
+
async with pool.acquire() as conn:
|
|
295
|
+
rows = await conn.fetch(rows_query, limit, offset)
|
|
296
|
+
count: int = await conn.fetchval(count_query)
|
|
297
|
+
except asyncio.TimeoutError as exc:
|
|
298
|
+
raise AdapterTimeoutError(
|
|
299
|
+
f"list exceeded {self._settings.operation_timeout}s operation_timeout",
|
|
300
|
+
adapter=self._settings.adapter_name,
|
|
301
|
+
operation="list",
|
|
302
|
+
cause=exc,
|
|
303
|
+
) from exc
|
|
304
|
+
except (asyncpg.PostgresError, asyncpg.InterfaceError) as exc:
|
|
305
|
+
raise self._wrap_asyncpg(exc, "list") from exc
|
|
306
|
+
|
|
307
|
+
entities = [self._row_to_entity(r) for r in rows]
|
|
308
|
+
return entities, count
|
|
309
|
+
|
|
310
|
+
async def create(self, entity: T) -> T:
|
|
311
|
+
"""
|
|
312
|
+
Insert a new row and return the stored row (with DB-generated fields).
|
|
313
|
+
|
|
314
|
+
Calls ``_entity_to_row(entity)`` to obtain the column dict, then
|
|
315
|
+
executes an ``INSERT … RETURNING *`` so that database-generated
|
|
316
|
+
fields (e.g. ``gen_random_uuid()``/``unique_rowid()`` PK,
|
|
317
|
+
``created_at``) are included in the returned entity.
|
|
318
|
+
|
|
319
|
+
Args:
|
|
320
|
+
entity: The entity to insert.
|
|
321
|
+
|
|
322
|
+
Returns:
|
|
323
|
+
The entity as stored, including any backend-assigned fields.
|
|
324
|
+
|
|
325
|
+
Raises:
|
|
326
|
+
AdapterQueryError: Insert failed (e.g. unique-constraint violation).
|
|
327
|
+
AdapterTimeoutError: Operation exceeded ``operation_timeout``.
|
|
328
|
+
"""
|
|
329
|
+
pool = await get_cockroachdb_pool(self._settings)
|
|
330
|
+
row_dict = self._entity_to_row(entity)
|
|
331
|
+
columns = list(row_dict.keys())
|
|
332
|
+
values = list(row_dict.values())
|
|
333
|
+
placeholders = ", ".join(f"${i + 1}" for i in range(len(columns)))
|
|
334
|
+
col_list = ", ".join(columns)
|
|
335
|
+
query = (
|
|
336
|
+
f"INSERT INTO {self._table} ({col_list}) "
|
|
337
|
+
f"VALUES ({placeholders}) RETURNING *"
|
|
338
|
+
)
|
|
339
|
+
try:
|
|
340
|
+
async with asyncio.timeout(self._settings.operation_timeout):
|
|
341
|
+
row = await pool.fetchrow(query, *values)
|
|
342
|
+
except asyncio.TimeoutError as exc:
|
|
343
|
+
raise AdapterTimeoutError(
|
|
344
|
+
f"create exceeded {self._settings.operation_timeout}s operation_timeout",
|
|
345
|
+
adapter=self._settings.adapter_name,
|
|
346
|
+
operation="create",
|
|
347
|
+
cause=exc,
|
|
348
|
+
) from exc
|
|
349
|
+
except (asyncpg.PostgresError, asyncpg.InterfaceError) as exc:
|
|
350
|
+
raise self._wrap_asyncpg(exc, "create") from exc
|
|
351
|
+
|
|
352
|
+
return self._row_to_entity(row) # type: ignore[arg-type]
|
|
353
|
+
|
|
354
|
+
async def update(self, entity: T) -> T | None:
|
|
355
|
+
"""
|
|
356
|
+
Update an existing row and return the stored row.
|
|
357
|
+
|
|
358
|
+
The ``_id_column`` value is extracted from the row dict and used in
|
|
359
|
+
the ``WHERE`` clause. All other columns form the ``SET`` clause.
|
|
360
|
+
|
|
361
|
+
Args:
|
|
362
|
+
entity: The entity with updated fields. Must contain ``_id_column``.
|
|
363
|
+
|
|
364
|
+
Returns:
|
|
365
|
+
The updated entity as stored, or ``None`` if no row was matched.
|
|
366
|
+
|
|
367
|
+
Raises:
|
|
368
|
+
AdapterQueryError: Update failed (e.g. constraint violation).
|
|
369
|
+
AdapterTimeoutError: Operation exceeded ``operation_timeout``.
|
|
370
|
+
"""
|
|
371
|
+
pool = await get_cockroachdb_pool(self._settings)
|
|
372
|
+
row_dict = self._entity_to_row(entity)
|
|
373
|
+
entity_id = row_dict.get(self._id_column)
|
|
374
|
+
|
|
375
|
+
update_cols = {k: v for k, v in row_dict.items() if k != self._id_column}
|
|
376
|
+
if not update_cols:
|
|
377
|
+
raise AdapterQueryError(
|
|
378
|
+
f"update on {self._table}: entity has no columns to update "
|
|
379
|
+
f"(only id column {self._id_column!r} found).",
|
|
380
|
+
adapter=self._settings.adapter_name,
|
|
381
|
+
operation="update",
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
set_parts = [
|
|
385
|
+
f"{col} = ${i + 1}" for i, col in enumerate(update_cols.keys())
|
|
386
|
+
]
|
|
387
|
+
set_clause = ", ".join(set_parts)
|
|
388
|
+
id_placeholder = f"${len(update_cols) + 1}"
|
|
389
|
+
query = (
|
|
390
|
+
f"UPDATE {self._table} SET {set_clause} "
|
|
391
|
+
f"WHERE {self._id_column} = {id_placeholder} RETURNING *"
|
|
392
|
+
)
|
|
393
|
+
values = list(update_cols.values()) + [entity_id]
|
|
394
|
+
try:
|
|
395
|
+
async with asyncio.timeout(self._settings.operation_timeout):
|
|
396
|
+
row = await pool.fetchrow(query, *values)
|
|
397
|
+
except asyncio.TimeoutError as exc:
|
|
398
|
+
raise AdapterTimeoutError(
|
|
399
|
+
f"update exceeded {self._settings.operation_timeout}s operation_timeout",
|
|
400
|
+
adapter=self._settings.adapter_name,
|
|
401
|
+
operation="update",
|
|
402
|
+
cause=exc,
|
|
403
|
+
) from exc
|
|
404
|
+
except (asyncpg.PostgresError, asyncpg.InterfaceError) as exc:
|
|
405
|
+
raise self._wrap_asyncpg(exc, "update") from exc
|
|
406
|
+
|
|
407
|
+
if row is None:
|
|
408
|
+
return None
|
|
409
|
+
return self._row_to_entity(row)
|
|
410
|
+
|
|
411
|
+
async def delete(self, entity_id: str) -> bool:
|
|
412
|
+
"""
|
|
413
|
+
Delete a row by primary key.
|
|
414
|
+
|
|
415
|
+
Args:
|
|
416
|
+
entity_id: Value of the ``_id_column`` to delete.
|
|
417
|
+
|
|
418
|
+
Returns:
|
|
419
|
+
``True`` if a row was deleted, ``False`` if no row matched.
|
|
420
|
+
|
|
421
|
+
Raises:
|
|
422
|
+
AdapterQueryError: Deletion failed.
|
|
423
|
+
AdapterTimeoutError: Operation exceeded ``operation_timeout``.
|
|
424
|
+
"""
|
|
425
|
+
pool = await get_cockroachdb_pool(self._settings)
|
|
426
|
+
query = f"DELETE FROM {self._table} WHERE {self._id_column} = $1"
|
|
427
|
+
try:
|
|
428
|
+
async with asyncio.timeout(self._settings.operation_timeout):
|
|
429
|
+
status: str = await pool.execute(query, entity_id)
|
|
430
|
+
except asyncio.TimeoutError as exc:
|
|
431
|
+
raise AdapterTimeoutError(
|
|
432
|
+
f"delete exceeded {self._settings.operation_timeout}s operation_timeout",
|
|
433
|
+
adapter=self._settings.adapter_name,
|
|
434
|
+
operation="delete",
|
|
435
|
+
cause=exc,
|
|
436
|
+
) from exc
|
|
437
|
+
except (asyncpg.PostgresError, asyncpg.InterfaceError) as exc:
|
|
438
|
+
raise self._wrap_asyncpg(exc, "delete") from exc
|
|
439
|
+
|
|
440
|
+
# asyncpg returns "DELETE N" where N is the number of deleted rows.
|
|
441
|
+
return status == "DELETE 1"
|
|
442
|
+
|
|
443
|
+
# ------------------------------------------------------------------
|
|
444
|
+
# Lifecycle
|
|
445
|
+
# ------------------------------------------------------------------
|
|
446
|
+
|
|
447
|
+
async def close(self) -> None:
|
|
448
|
+
"""
|
|
449
|
+
Close the connection pool and remove it from the cache.
|
|
450
|
+
|
|
451
|
+
Call once at application shutdown. After this returns the pool is
|
|
452
|
+
closed and a subsequent operation will create a new pool.
|
|
453
|
+
"""
|
|
454
|
+
key = _cache_key(self._settings)
|
|
455
|
+
pool = _pool_cache.get(key)
|
|
456
|
+
if pool is not None:
|
|
457
|
+
await pool.close()
|
|
458
|
+
_pool_cache.pop(key, None)
|
|
459
|
+
|
|
460
|
+
# ------------------------------------------------------------------
|
|
461
|
+
# BasePort (Identity + Lifecycle) interface
|
|
462
|
+
# ------------------------------------------------------------------
|
|
463
|
+
|
|
464
|
+
async def initialize(self, context: PluginContext) -> None:
|
|
465
|
+
"""
|
|
466
|
+
Establish the connection pool and verify connectivity.
|
|
467
|
+
|
|
468
|
+
BasePort lifecycle entry point. Reuses the same cached pool as
|
|
469
|
+
every other method on this repository.
|
|
470
|
+
|
|
471
|
+
Args:
|
|
472
|
+
context: Plugin context. Unused — settings are provided at
|
|
473
|
+
construction time.
|
|
474
|
+
|
|
475
|
+
Raises:
|
|
476
|
+
AdapterConnectionError: CockroachDB is unreachable.
|
|
477
|
+
"""
|
|
478
|
+
await get_cockroachdb_pool(self._settings)
|
|
479
|
+
health = await self.health()
|
|
480
|
+
if health.status != PluginStatus.READY:
|
|
481
|
+
raise AdapterConnectionError(
|
|
482
|
+
health.message or "CockroachDB connectivity check failed during initialize()",
|
|
483
|
+
adapter=self._settings.adapter_name,
|
|
484
|
+
operation="initialize",
|
|
485
|
+
)
|
|
486
|
+
|
|
487
|
+
async def shutdown(self) -> None:
|
|
488
|
+
"""BasePort lifecycle entry point — alias for close(). Never raises."""
|
|
489
|
+
await self.close()
|
|
490
|
+
|
|
491
|
+
async def health(self) -> PluginHealth:
|
|
492
|
+
"""
|
|
493
|
+
BasePort lifecycle entry point — returns a PluginHealth snapshot.
|
|
494
|
+
|
|
495
|
+
The sole connectivity check on this repository — a low-cost
|
|
496
|
+
``SELECT 1`` against the pool with a 5-second timeout. Never raises.
|
|
497
|
+
"""
|
|
498
|
+
try:
|
|
499
|
+
pool = await get_cockroachdb_pool(self._settings)
|
|
500
|
+
await asyncio.wait_for(pool.fetchval("SELECT 1"), timeout=5.0)
|
|
501
|
+
return PluginHealth(status=PluginStatus.READY, message="")
|
|
502
|
+
except Exception as exc: # noqa: BLE001
|
|
503
|
+
return PluginHealth(status=PluginStatus.FAILED, message=str(exc))
|
|
@@ -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
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
openframe/adapters/db/cockroachdb/__init__.py,sha256=iVReNvzfhWeOFHg7LxmVCP-X5D71wAvmv00N0ShtPW8,1991
|
|
2
|
+
openframe/adapters/db/cockroachdb/config.py,sha256=3aEOjgsLJjrRTEwih1wj1_VJlrQ00oy0AYvWWuprXC4,3043
|
|
3
|
+
openframe/adapters/db/cockroachdb/connection.py,sha256=8vh9t20_gaTBSFHATQ_fJTH4D4NmtWh5AKJ9vlPlluA,5250
|
|
4
|
+
openframe/adapters/db/cockroachdb/plugin.py,sha256=eRj0mBPAUWr739FhOaJMeOT0zpa8jHTgUK7f7geXOvw,9608
|
|
5
|
+
openframe/adapters/db/cockroachdb/repository.py,sha256=uTV29onz6mP8J4vZtl2YF1WLXSz37enOntWYuOPrfok,20360
|
|
6
|
+
openframe_adapters_db_cockroachdb-0.1.0.dist-info/METADATA,sha256=UT-HLwUPNvCXwqQp3MjqeqYqJ6L-e_l0HkHwpbq9rMk,12087
|
|
7
|
+
openframe_adapters_db_cockroachdb-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
8
|
+
openframe_adapters_db_cockroachdb-0.1.0.dist-info/RECORD,,
|