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.
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any