sqlalchemy-foundation-kit 0.0.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.
- sqlalchemy_foundation_kit/__init__.py +119 -0
- sqlalchemy_foundation_kit/__version__.py +1 -0
- sqlalchemy_foundation_kit/_typing.py +28 -0
- sqlalchemy_foundation_kit/base/__init__.py +46 -0
- sqlalchemy_foundation_kit/base/_optional.py +37 -0
- sqlalchemy_foundation_kit/base/engine.py +256 -0
- sqlalchemy_foundation_kit/base/metadata.py +57 -0
- sqlalchemy_foundation_kit/base/models.py +101 -0
- sqlalchemy_foundation_kit/base/serialization.py +98 -0
- sqlalchemy_foundation_kit/base/types.py +72 -0
- sqlalchemy_foundation_kit/config/__init__.py +17 -0
- sqlalchemy_foundation_kit/config/postgres.py +177 -0
- sqlalchemy_foundation_kit/contrib/__init__.py +5 -0
- sqlalchemy_foundation_kit/contrib/_metrics_utils.py +19 -0
- sqlalchemy_foundation_kit/contrib/dependency_injector/__init__.py +27 -0
- sqlalchemy_foundation_kit/contrib/dependency_injector/_base.py +27 -0
- sqlalchemy_foundation_kit/contrib/dependency_injector/_deps.py +37 -0
- sqlalchemy_foundation_kit/contrib/dependency_injector/database.py +196 -0
- sqlalchemy_foundation_kit/contrib/dependency_injector/metrics.py +85 -0
- sqlalchemy_foundation_kit/contrib/di/__init__.py +21 -0
- sqlalchemy_foundation_kit/contrib/di/_base.py +27 -0
- sqlalchemy_foundation_kit/contrib/di/_deps.py +32 -0
- sqlalchemy_foundation_kit/contrib/di/database.py +169 -0
- sqlalchemy_foundation_kit/contrib/di/metrics.py +71 -0
- sqlalchemy_foundation_kit/contrib/metrics/__init__.py +7 -0
- sqlalchemy_foundation_kit/contrib/metrics/postgres.py +149 -0
- sqlalchemy_foundation_kit/contrib/settings/__init__.py +19 -0
- sqlalchemy_foundation_kit/contrib/settings/postgres.py +183 -0
- sqlalchemy_foundation_kit/contrib/telemetry/__init__.py +17 -0
- sqlalchemy_foundation_kit/contrib/telemetry/instrumentations.py +101 -0
- sqlalchemy_foundation_kit/contrib/telemetry/uow.py +227 -0
- sqlalchemy_foundation_kit/protocols/__init__.py +21 -0
- sqlalchemy_foundation_kit/protocols/metrics.py +77 -0
- sqlalchemy_foundation_kit/py.typed +0 -0
- sqlalchemy_foundation_kit/session/__init__.py +27 -0
- sqlalchemy_foundation_kit/session/builder.py +293 -0
- sqlalchemy_foundation_kit/session/connection.py +33 -0
- sqlalchemy_foundation_kit/session/factories.py +104 -0
- sqlalchemy_foundation_kit/session/locks.py +68 -0
- sqlalchemy_foundation_kit/session/manager.py +252 -0
- sqlalchemy_foundation_kit/session/retry.py +82 -0
- sqlalchemy_foundation_kit/uow/__init__.py +21 -0
- sqlalchemy_foundation_kit/uow/enums.py +18 -0
- sqlalchemy_foundation_kit/uow/protocols.py +80 -0
- sqlalchemy_foundation_kit/uow/sqlalchemy.py +406 -0
- sqlalchemy_foundation_kit-0.0.0.dist-info/METADATA +624 -0
- sqlalchemy_foundation_kit-0.0.0.dist-info/RECORD +49 -0
- sqlalchemy_foundation_kit-0.0.0.dist-info/WHEEL +4 -0
- sqlalchemy_foundation_kit-0.0.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Base PostgreSQL configuration using pydantic-settings."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Literal
|
|
6
|
+
from urllib.parse import quote_plus
|
|
7
|
+
|
|
8
|
+
from ...base.engine import PoolClassStr
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
from pydantic import BaseModel, Field, SecretStr, model_validator
|
|
12
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
13
|
+
except ImportError as _e:
|
|
14
|
+
raise ImportError(
|
|
15
|
+
"pydantic and pydantic-settings are required for this functionality. "
|
|
16
|
+
"Install with: pip install 'sqlalchemy-foundation-kit[settings]'"
|
|
17
|
+
) from _e
|
|
18
|
+
|
|
19
|
+
PostgresIsolationLevel = Literal["READ UNCOMMITTED", "READ COMMITTED", "REPEATABLE READ", "SERIALIZABLE"]
|
|
20
|
+
PostgresJit = Literal["off", "on"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ConnectionSettings(BaseModel):
|
|
24
|
+
"""PostgreSQL connection configuration.
|
|
25
|
+
|
|
26
|
+
Groups all connection-related parameters: host, port, credentials, and database name.
|
|
27
|
+
|
|
28
|
+
Examples:
|
|
29
|
+
>>> connection = ConnectionSettings(
|
|
30
|
+
... host="localhost",
|
|
31
|
+
... port=5432,
|
|
32
|
+
... user="postgres",
|
|
33
|
+
... password=SecretStr("secret"),
|
|
34
|
+
... database="mydb",
|
|
35
|
+
... )
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
host: str = Field(default="localhost", description="PostgreSQL host")
|
|
39
|
+
port: int = Field(default=5432, ge=1, le=65535, description="PostgreSQL port")
|
|
40
|
+
user: str = Field(default="postgres", description="PostgreSQL user")
|
|
41
|
+
password: SecretStr = Field(description="PostgreSQL password")
|
|
42
|
+
database: str = Field(description="Database name")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class PoolSettings(BaseModel):
|
|
46
|
+
"""PostgreSQL connection pool configuration.
|
|
47
|
+
|
|
48
|
+
Groups all connection pool-related parameters for SQLAlchemy engine.
|
|
49
|
+
|
|
50
|
+
Examples:
|
|
51
|
+
>>> pool = PoolSettings(size=10, max_overflow=20)
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
kind: PoolClassStr = Field(
|
|
55
|
+
default="async_adapted_queue",
|
|
56
|
+
description="PostgreSQL pool implementation kind",
|
|
57
|
+
)
|
|
58
|
+
size: int = Field(default=10, ge=1, description="Connection pool size")
|
|
59
|
+
max_overflow: int = Field(default=20, ge=0, description="Additional connections when pool is exhausted")
|
|
60
|
+
pre_ping: bool = Field(default=True, description="Check connection health before use (pre-ping)")
|
|
61
|
+
recycle: int = Field(default=3600, ge=-1, description="Recycle connections after N seconds")
|
|
62
|
+
timeout: float = Field(
|
|
63
|
+
default=30.0,
|
|
64
|
+
ge=0,
|
|
65
|
+
description="Seconds to wait before giving up on getting connection",
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
@model_validator(mode="after")
|
|
69
|
+
def _validate_pool_settings(self) -> PoolSettings:
|
|
70
|
+
"""Validate pool configuration constraints."""
|
|
71
|
+
if self.kind == "static" and self.max_overflow > 0:
|
|
72
|
+
raise ValueError(f"max_overflow must be 0 for static pool, got {self.max_overflow}")
|
|
73
|
+
return self
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class QuerySettings(BaseModel):
|
|
77
|
+
"""PostgreSQL query and performance configuration.
|
|
78
|
+
|
|
79
|
+
Groups query execution, caching, and transaction isolation settings.
|
|
80
|
+
|
|
81
|
+
Examples:
|
|
82
|
+
>>> query = QuerySettings(echo=False, isolation_level="READ COMMITTED")
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
echo: bool = Field(default=False, description="Echo SQL queries")
|
|
86
|
+
statement_cache_size: int = Field(default=0, ge=0, description="Statement cache size")
|
|
87
|
+
prepared_statement_cache_size: int = Field(default=0, ge=0, description="Prepared statement cache size")
|
|
88
|
+
isolation_level: PostgresIsolationLevel | None = Field(default=None, description="Transaction isolation level")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class BasePostgresConfig(BaseSettings):
|
|
92
|
+
"""Base PostgreSQL configuration.
|
|
93
|
+
|
|
94
|
+
Organized configuration for PostgreSQL database connections with grouped settings.
|
|
95
|
+
|
|
96
|
+
Attributes:
|
|
97
|
+
connection: Connection parameters (host, port, credentials, database).
|
|
98
|
+
pool: Connection pool configuration (size, overflow, timeouts).
|
|
99
|
+
query: Query execution settings (echo, caching, isolation level).
|
|
100
|
+
application_name: Application name for connection identification.
|
|
101
|
+
db_schema: Optional PostgreSQL schema name.
|
|
102
|
+
use_orjson_serialization: Use orjson for JSON serialization (requires orjson).
|
|
103
|
+
jit: JIT compilation setting (off/on) for PgBouncer compatibility.
|
|
104
|
+
metrics_enabled: Enable connection pool metrics collection.
|
|
105
|
+
|
|
106
|
+
Examples:
|
|
107
|
+
>>> config = BasePostgresConfig(
|
|
108
|
+
... connection=ConnectionSettings(
|
|
109
|
+
... host="localhost",
|
|
110
|
+
... user="postgres",
|
|
111
|
+
... password=SecretStr("secret"),
|
|
112
|
+
... database="mydb",
|
|
113
|
+
... ),
|
|
114
|
+
... application_name="my-service",
|
|
115
|
+
... )
|
|
116
|
+
>>> dsn = config.to_dsn()
|
|
117
|
+
>>> host = config.connection.host
|
|
118
|
+
>>> pool_size = config.pool.size
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
# Grouped configuration
|
|
122
|
+
connection: ConnectionSettings = Field(description="PostgreSQL connection settings")
|
|
123
|
+
pool: PoolSettings = Field(default_factory=PoolSettings, description="Connection pool settings")
|
|
124
|
+
query: QuerySettings = Field(default_factory=QuerySettings, description="Query execution settings")
|
|
125
|
+
|
|
126
|
+
# Top-level settings
|
|
127
|
+
application_name: str = Field(description="Application name for PostgreSQL")
|
|
128
|
+
db_schema: str | None = Field(default=None, description="PostgreSQL schema name")
|
|
129
|
+
use_orjson_serialization: bool = Field(
|
|
130
|
+
default=True,
|
|
131
|
+
description="Use orjson for JSON serialization (requires orjson installed)",
|
|
132
|
+
)
|
|
133
|
+
jit: PostgresJit | None = Field(default="off", description="JIT setting (off/on)")
|
|
134
|
+
metrics_enabled: bool = Field(default=False, description="Enable PostgreSQL metrics")
|
|
135
|
+
|
|
136
|
+
def __repr__(self) -> str:
|
|
137
|
+
"""Return representation with masked password."""
|
|
138
|
+
return f"{self.__class__.__name__}(dsn={self.to_dsn(mask_password=True)!r})"
|
|
139
|
+
|
|
140
|
+
def to_dsn(self, driver: str | None = "asyncpg", mask_password: bool = False) -> str:
|
|
141
|
+
"""Build PostgreSQL DSN for async connections.
|
|
142
|
+
|
|
143
|
+
This library is async-only, so DSN always includes asyncpg driver by default.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
driver: Driver name (default: 'asyncpg' for async connections).
|
|
147
|
+
Pass None to omit driver suffix.
|
|
148
|
+
mask_password: If True, the password will be masked (default: False).
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
PostgreSQL connection string (e.g., 'postgresql+asyncpg://user:pass@host:5432/db').
|
|
152
|
+
|
|
153
|
+
Examples:
|
|
154
|
+
>>> config.to_dsn()
|
|
155
|
+
'postgresql+asyncpg://user:secret@localhost:5432/mydb'
|
|
156
|
+
>>> config.to_dsn(mask_password=True)
|
|
157
|
+
'postgresql+asyncpg://user:**********@localhost:5432/mydb'
|
|
158
|
+
"""
|
|
159
|
+
user = quote_plus(self.connection.user)
|
|
160
|
+
password = "**********" if mask_password else quote_plus(self.connection.password.get_secret_value())
|
|
161
|
+
scheme = f"postgresql+{driver}" if driver else "postgresql"
|
|
162
|
+
return f"{scheme}://{user}:{password}@{self.connection.host}:{self.connection.port}/{self.connection.database}"
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class BasePostgresMigrationsConfig(BaseSettings):
|
|
166
|
+
"""Base configuration for PostgreSQL database migrations."""
|
|
167
|
+
|
|
168
|
+
model_config = SettingsConfigDict(
|
|
169
|
+
extra="ignore",
|
|
170
|
+
env_nested_delimiter="__",
|
|
171
|
+
case_sensitive=False,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
postgres: BasePostgresConfig
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
__all__ = [
|
|
178
|
+
"BasePostgresConfig",
|
|
179
|
+
"BasePostgresMigrationsConfig",
|
|
180
|
+
"ConnectionSettings",
|
|
181
|
+
"PoolSettings",
|
|
182
|
+
"QuerySettings",
|
|
183
|
+
]
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""OpenTelemetry tracing integration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .instrumentations import (
|
|
6
|
+
instrument_asyncpg,
|
|
7
|
+
instrument_engine,
|
|
8
|
+
instrument_sqlalchemy,
|
|
9
|
+
)
|
|
10
|
+
from .uow import TracedAsyncUnitOfWork
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"TracedAsyncUnitOfWork",
|
|
14
|
+
"instrument_asyncpg",
|
|
15
|
+
"instrument_engine",
|
|
16
|
+
"instrument_sqlalchemy",
|
|
17
|
+
]
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""OpenTelemetry instrumentation functions for SQLAlchemy and asyncpg."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Any
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from sqlalchemy.ext.asyncio import AsyncEngine
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def instrument_sqlalchemy(engine: Any | None = None, **kwargs: Any) -> None:
|
|
12
|
+
"""Instrument SQLAlchemy engine for OpenTelemetry tracing.
|
|
13
|
+
|
|
14
|
+
Automatically traces all SQLAlchemy operations including queries,
|
|
15
|
+
commits, and rollbacks.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
engine: Optional SQLAlchemy engine to instrument. If None, all engines.
|
|
19
|
+
**kwargs: Additional keyword arguments passed to SQLAlchemyInstrumentor.
|
|
20
|
+
|
|
21
|
+
Raises:
|
|
22
|
+
ImportError: If opentelemetry-instrumentation-sqlalchemy is not installed.
|
|
23
|
+
|
|
24
|
+
Examples:
|
|
25
|
+
>>> from sqlalchemy import create_engine
|
|
26
|
+
>>> from sqlalchemy_foundation_kit.contrib.telemetry import instrument_sqlalchemy
|
|
27
|
+
>>> engine = create_engine("postgresql://...")
|
|
28
|
+
>>> instrument_sqlalchemy(engine=engine)
|
|
29
|
+
"""
|
|
30
|
+
try:
|
|
31
|
+
from opentelemetry.instrumentation.sqlalchemy import ( # noqa: PLC0415
|
|
32
|
+
SQLAlchemyInstrumentor,
|
|
33
|
+
)
|
|
34
|
+
except ImportError as e:
|
|
35
|
+
raise ImportError(
|
|
36
|
+
"opentelemetry-instrumentation-sqlalchemy not installed. "
|
|
37
|
+
"Install with: pip install 'sqlalchemy-foundation-kit[telemetry]'"
|
|
38
|
+
) from e
|
|
39
|
+
|
|
40
|
+
call_kwargs: dict[str, Any] = dict(kwargs)
|
|
41
|
+
if engine is not None:
|
|
42
|
+
call_kwargs["engine"] = engine
|
|
43
|
+
|
|
44
|
+
SQLAlchemyInstrumentor().instrument(**call_kwargs)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def instrument_engine(engine: AsyncEngine, **kwargs: Any) -> None:
|
|
48
|
+
"""Attach OpenTelemetry tracing to a specific async SQLAlchemy engine.
|
|
49
|
+
|
|
50
|
+
Designed to be passed as the ``on_engine_created`` hook of
|
|
51
|
+
:class:`~sqlalchemy_foundation_kit.session.AsyncSessionManager` or
|
|
52
|
+
:func:`~sqlalchemy_foundation_kit.session.create_async_session_manager`.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
engine: The AsyncEngine to instrument.
|
|
56
|
+
**kwargs: Additional keyword arguments passed to SQLAlchemyInstrumentor.
|
|
57
|
+
|
|
58
|
+
Raises:
|
|
59
|
+
ImportError: If opentelemetry-instrumentation-sqlalchemy is not installed.
|
|
60
|
+
|
|
61
|
+
Examples:
|
|
62
|
+
>>> from sqlalchemy_foundation_kit.session import create_async_session_manager
|
|
63
|
+
>>> from sqlalchemy_foundation_kit.contrib.telemetry import instrument_engine
|
|
64
|
+
>>> manager = create_async_session_manager(config, on_engine_created=instrument_engine)
|
|
65
|
+
"""
|
|
66
|
+
instrument_sqlalchemy(engine=engine.sync_engine, **kwargs)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def instrument_asyncpg(**kwargs: Any) -> None:
|
|
70
|
+
"""Instrument asyncpg connections for OpenTelemetry tracing.
|
|
71
|
+
|
|
72
|
+
Automatically traces all asyncpg database operations at the connection level.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
**kwargs: Additional keyword arguments passed to AsyncPGInstrumentor.
|
|
76
|
+
|
|
77
|
+
Raises:
|
|
78
|
+
ImportError: If opentelemetry-instrumentation-asyncpg is not installed.
|
|
79
|
+
|
|
80
|
+
Examples:
|
|
81
|
+
>>> from sqlalchemy_foundation_kit.contrib.telemetry import instrument_asyncpg
|
|
82
|
+
>>> instrument_asyncpg()
|
|
83
|
+
"""
|
|
84
|
+
try:
|
|
85
|
+
from opentelemetry.instrumentation.asyncpg import ( # noqa: PLC0415
|
|
86
|
+
AsyncPGInstrumentor,
|
|
87
|
+
)
|
|
88
|
+
except ImportError as e:
|
|
89
|
+
raise ImportError(
|
|
90
|
+
"opentelemetry-instrumentation-asyncpg not installed. "
|
|
91
|
+
"Install with: pip install 'sqlalchemy-foundation-kit[telemetry]'"
|
|
92
|
+
) from e
|
|
93
|
+
|
|
94
|
+
AsyncPGInstrumentor().instrument(**kwargs)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
__all__ = [
|
|
98
|
+
"instrument_asyncpg",
|
|
99
|
+
"instrument_engine",
|
|
100
|
+
"instrument_sqlalchemy",
|
|
101
|
+
]
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""Unit of Work with OpenTelemetry tracing support."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from collections.abc import AsyncIterator, Callable
|
|
7
|
+
from contextlib import AbstractAsyncContextManager, asynccontextmanager
|
|
8
|
+
from typing import TYPE_CHECKING, Generic
|
|
9
|
+
|
|
10
|
+
from ..._typing import T
|
|
11
|
+
from ...base._optional import require_optional
|
|
12
|
+
from ...uow import AsyncSQLAlchemyUnitOfWork, IsolationLevel
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from opentelemetry.trace import Span, Tracer
|
|
16
|
+
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
try:
|
|
21
|
+
trace = require_optional("opentelemetry.trace", "telemetry")
|
|
22
|
+
from opentelemetry.trace import Status, StatusCode
|
|
23
|
+
|
|
24
|
+
HAS_OTEL = True
|
|
25
|
+
except ImportError:
|
|
26
|
+
HAS_OTEL = False
|
|
27
|
+
trace = None # type: ignore[assignment]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class TracedAsyncUnitOfWork(AsyncSQLAlchemyUnitOfWork[T], Generic[T]):
|
|
31
|
+
"""Unit of Work with automatic OpenTelemetry tracing.
|
|
32
|
+
|
|
33
|
+
Automatically creates spans for transaction() and query() operations,
|
|
34
|
+
including transaction attributes (isolation level, duration, outcome).
|
|
35
|
+
|
|
36
|
+
Example:
|
|
37
|
+
from opentelemetry import trace
|
|
38
|
+
from opentelemetry.sdk.trace import TracerProvider
|
|
39
|
+
|
|
40
|
+
trace.set_tracer_provider(TracerProvider())
|
|
41
|
+
|
|
42
|
+
uow = TracedAsyncUnitOfWork(
|
|
43
|
+
session_maker=session_maker,
|
|
44
|
+
transaction_factory=MyTransaction,
|
|
45
|
+
service_name="my-service",
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
async with uow.transaction() as tx:
|
|
49
|
+
# This operation is automatically traced
|
|
50
|
+
user = await tx.users.create(...)
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
session_maker: async_sessionmaker[AsyncSession],
|
|
56
|
+
transaction_factory: Callable[[AsyncSession], T],
|
|
57
|
+
service_name: str = "sqlalchemy-foundation-kit",
|
|
58
|
+
*,
|
|
59
|
+
flush_before_commit: bool = True,
|
|
60
|
+
) -> None:
|
|
61
|
+
"""Initialize traced unit of work.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
session_maker: SQLAlchemy async session maker.
|
|
65
|
+
transaction_factory: Factory function to create transaction objects.
|
|
66
|
+
service_name: Service name for OpenTelemetry tracer.
|
|
67
|
+
flush_before_commit: Default ``flush_before_commit`` policy applied when
|
|
68
|
+
:meth:`transaction` is called without an explicit override.
|
|
69
|
+
"""
|
|
70
|
+
super().__init__(session_maker, transaction_factory, flush_before_commit=flush_before_commit)
|
|
71
|
+
self._tracer: Tracer | None = trace.get_tracer(service_name) if HAS_OTEL else None
|
|
72
|
+
|
|
73
|
+
@asynccontextmanager
|
|
74
|
+
async def _traced(
|
|
75
|
+
self,
|
|
76
|
+
operation: str,
|
|
77
|
+
context_manager: AbstractAsyncContextManager[T],
|
|
78
|
+
attributes: dict[str, str | bool] | None = None,
|
|
79
|
+
) -> AsyncIterator[T]:
|
|
80
|
+
"""Generic tracing wrapper for UoW operations.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
operation: Operation name (e.g., "transaction", "query").
|
|
84
|
+
context_manager: Async context manager to wrap with tracing.
|
|
85
|
+
attributes: Optional span attributes to set.
|
|
86
|
+
|
|
87
|
+
Yields:
|
|
88
|
+
Transaction object from the wrapped context manager.
|
|
89
|
+
"""
|
|
90
|
+
if not self._tracer:
|
|
91
|
+
# No tracing available, delegate to wrapped context manager
|
|
92
|
+
async with context_manager as result:
|
|
93
|
+
yield result
|
|
94
|
+
return
|
|
95
|
+
|
|
96
|
+
span: Span = self._tracer.start_span(f"uow.{operation}")
|
|
97
|
+
span.set_attribute("db.operation", operation)
|
|
98
|
+
|
|
99
|
+
if attributes:
|
|
100
|
+
for key, value in attributes.items():
|
|
101
|
+
span.set_attribute(key, value)
|
|
102
|
+
|
|
103
|
+
try:
|
|
104
|
+
async with context_manager as result:
|
|
105
|
+
yield result
|
|
106
|
+
|
|
107
|
+
span.set_status(Status(StatusCode.OK))
|
|
108
|
+
|
|
109
|
+
except Exception as e:
|
|
110
|
+
span.set_status(Status(StatusCode.ERROR, str(e)))
|
|
111
|
+
span.record_exception(e)
|
|
112
|
+
raise
|
|
113
|
+
|
|
114
|
+
finally:
|
|
115
|
+
span.end()
|
|
116
|
+
|
|
117
|
+
@asynccontextmanager
|
|
118
|
+
async def transaction(
|
|
119
|
+
self,
|
|
120
|
+
isolation_level: IsolationLevel | str | None = None,
|
|
121
|
+
flush_before_commit: bool | None = None,
|
|
122
|
+
) -> AsyncIterator[T]:
|
|
123
|
+
"""Create a new transaction context with tracing.
|
|
124
|
+
|
|
125
|
+
Automatically creates a span named "uow.transaction" with attributes:
|
|
126
|
+
- db.operation: "transaction"
|
|
127
|
+
- db.isolation_level: The isolation level (if specified)
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
isolation_level: Optional transaction isolation level.
|
|
131
|
+
flush_before_commit: If True, flush before commit.
|
|
132
|
+
|
|
133
|
+
Yields:
|
|
134
|
+
Transaction object with repositories.
|
|
135
|
+
"""
|
|
136
|
+
attributes: dict[str, str | bool] = {}
|
|
137
|
+
if isolation_level:
|
|
138
|
+
attributes["db.isolation_level"] = str(isolation_level)
|
|
139
|
+
|
|
140
|
+
async with self._traced(
|
|
141
|
+
operation="transaction",
|
|
142
|
+
context_manager=super().transaction(
|
|
143
|
+
isolation_level=isolation_level,
|
|
144
|
+
flush_before_commit=flush_before_commit,
|
|
145
|
+
),
|
|
146
|
+
attributes=attributes,
|
|
147
|
+
) as tx:
|
|
148
|
+
yield tx
|
|
149
|
+
|
|
150
|
+
@asynccontextmanager
|
|
151
|
+
async def managed_session(
|
|
152
|
+
self,
|
|
153
|
+
isolation_level: IsolationLevel | str | None = None,
|
|
154
|
+
) -> AsyncIterator[tuple[T, AsyncSession]]:
|
|
155
|
+
"""Create a session with manual transaction control and tracing.
|
|
156
|
+
|
|
157
|
+
Automatically creates a span named "uow.managed_session" with attributes:
|
|
158
|
+
- db.operation: "managed_session"
|
|
159
|
+
- db.isolation_level: The isolation level (if specified)
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
isolation_level: Optional transaction isolation level.
|
|
163
|
+
|
|
164
|
+
Yields:
|
|
165
|
+
Tuple of (transaction object, session) for manual control.
|
|
166
|
+
"""
|
|
167
|
+
attributes: dict[str, str | bool] = {}
|
|
168
|
+
if isolation_level:
|
|
169
|
+
attributes["db.isolation_level"] = str(isolation_level)
|
|
170
|
+
|
|
171
|
+
if not self._tracer:
|
|
172
|
+
# No tracing available, delegate directly
|
|
173
|
+
async with super().managed_session(isolation_level=isolation_level) as result:
|
|
174
|
+
yield result
|
|
175
|
+
return
|
|
176
|
+
|
|
177
|
+
span: Span = self._tracer.start_span("uow.managed_session")
|
|
178
|
+
span.set_attribute("db.operation", "managed_session")
|
|
179
|
+
|
|
180
|
+
if attributes:
|
|
181
|
+
for key, value in attributes.items():
|
|
182
|
+
span.set_attribute(key, value)
|
|
183
|
+
|
|
184
|
+
try:
|
|
185
|
+
async with super().managed_session(isolation_level=isolation_level) as result:
|
|
186
|
+
yield result
|
|
187
|
+
|
|
188
|
+
span.set_status(Status(StatusCode.OK))
|
|
189
|
+
|
|
190
|
+
except Exception as e:
|
|
191
|
+
span.set_status(Status(StatusCode.ERROR, str(e)))
|
|
192
|
+
span.record_exception(e)
|
|
193
|
+
raise
|
|
194
|
+
|
|
195
|
+
finally:
|
|
196
|
+
span.end()
|
|
197
|
+
|
|
198
|
+
@asynccontextmanager
|
|
199
|
+
async def query(
|
|
200
|
+
self,
|
|
201
|
+
isolation_level: IsolationLevel | str | None = None,
|
|
202
|
+
) -> AsyncIterator[T]:
|
|
203
|
+
"""Create a read-only query context with tracing.
|
|
204
|
+
|
|
205
|
+
Automatically creates a span named "uow.query" with attributes:
|
|
206
|
+
- db.operation: "query"
|
|
207
|
+
- db.isolation_level: The isolation level (if specified)
|
|
208
|
+
|
|
209
|
+
Args:
|
|
210
|
+
isolation_level: Optional transaction isolation level.
|
|
211
|
+
|
|
212
|
+
Yields:
|
|
213
|
+
Query object with repositories.
|
|
214
|
+
"""
|
|
215
|
+
attributes: dict[str, str | bool] = {}
|
|
216
|
+
if isolation_level:
|
|
217
|
+
attributes["db.isolation_level"] = str(isolation_level)
|
|
218
|
+
|
|
219
|
+
async with self._traced(
|
|
220
|
+
operation="query",
|
|
221
|
+
context_manager=super().query(isolation_level=isolation_level),
|
|
222
|
+
attributes=attributes,
|
|
223
|
+
) as qx:
|
|
224
|
+
yield qx
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
__all__ = ["TracedAsyncUnitOfWork"]
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Protocols for database infrastructure.
|
|
2
|
+
|
|
3
|
+
This module defines protocols (structural subtyping) for key abstractions,
|
|
4
|
+
allowing dependency inversion without tight coupling to specific implementations.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from .metrics import (
|
|
10
|
+
CheckoutRecorder,
|
|
11
|
+
ErrorRecorder,
|
|
12
|
+
PoolStatsRecorder,
|
|
13
|
+
PostgresMetricsProtocol,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"CheckoutRecorder",
|
|
18
|
+
"ErrorRecorder",
|
|
19
|
+
"PoolStatsRecorder",
|
|
20
|
+
"PostgresMetricsProtocol",
|
|
21
|
+
]
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Metrics protocols for database monitoring.
|
|
2
|
+
|
|
3
|
+
Protocols are split by capability (ISP). Implementations may satisfy
|
|
4
|
+
the narrow protocols selectively (e.g., only record errors), and
|
|
5
|
+
``PostgresMetricsProtocol`` aggregates them for convenience.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Protocol
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class PoolStatsRecorder(Protocol):
|
|
14
|
+
"""Capability protocol for recording pool statistics."""
|
|
15
|
+
|
|
16
|
+
def record_pool_stats(
|
|
17
|
+
self,
|
|
18
|
+
pool_size: int,
|
|
19
|
+
pool_checked_out: int,
|
|
20
|
+
pool_overflow: int,
|
|
21
|
+
) -> None:
|
|
22
|
+
"""Record database connection pool statistics.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
pool_size: Current total number of connections in the pool.
|
|
26
|
+
pool_checked_out: Number of connections currently in use.
|
|
27
|
+
pool_overflow: Number of connections over the configured pool_size.
|
|
28
|
+
"""
|
|
29
|
+
...
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class CheckoutRecorder(Protocol):
|
|
33
|
+
"""Capability protocol for recording connection checkout duration."""
|
|
34
|
+
|
|
35
|
+
def record_checkout(self, duration: float) -> None:
|
|
36
|
+
"""Record a database connection checkout from the pool.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
duration: Time taken to acquire the connection from the pool, in seconds.
|
|
40
|
+
"""
|
|
41
|
+
...
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ErrorRecorder(Protocol):
|
|
45
|
+
"""Capability protocol for recording database errors."""
|
|
46
|
+
|
|
47
|
+
def record_error(self, error_type: str, is_timeout: bool = False) -> None:
|
|
48
|
+
"""Record a database connection or execution error.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
error_type: The type of error that occurred (e.g., "OperationalError").
|
|
52
|
+
is_timeout: True if this error was specifically a connection checkout timeout.
|
|
53
|
+
"""
|
|
54
|
+
...
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class PostgresMetricsProtocol(PoolStatsRecorder, CheckoutRecorder, ErrorRecorder, Protocol):
|
|
58
|
+
"""Composite protocol covering all PostgreSQL metrics capabilities.
|
|
59
|
+
|
|
60
|
+
Aggregates the narrow capability protocols for convenience. Implementations
|
|
61
|
+
that only need a subset can implement the individual protocols directly.
|
|
62
|
+
|
|
63
|
+
Examples:
|
|
64
|
+
>>> class MyMetrics:
|
|
65
|
+
... def record_pool_stats(self, pool_size: int, pool_checked_out: int, pool_overflow: int) -> None: ...
|
|
66
|
+
... def record_checkout(self, duration: float) -> None: ...
|
|
67
|
+
... def record_error(self, error_type: str, is_timeout: bool = False) -> None: ...
|
|
68
|
+
>>> metrics: PostgresMetricsProtocol = MyMetrics()
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
__all__ = [
|
|
73
|
+
"CheckoutRecorder",
|
|
74
|
+
"ErrorRecorder",
|
|
75
|
+
"PoolStatsRecorder",
|
|
76
|
+
"PostgresMetricsProtocol",
|
|
77
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Async session management module."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .builder import AsyncSessionManagerBuilder
|
|
6
|
+
from .connection import AsyncCConnection
|
|
7
|
+
from .factories import create_async_session_manager
|
|
8
|
+
from .locks import try_advisory_xact_lock
|
|
9
|
+
from .manager import AsyncSessionManager
|
|
10
|
+
from .retry import (
|
|
11
|
+
DEFAULT_HEALTHCHECK_QUERY,
|
|
12
|
+
DEFAULT_RETRY_CONFIG,
|
|
13
|
+
RetryConfig,
|
|
14
|
+
retry_async_connection,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"DEFAULT_HEALTHCHECK_QUERY",
|
|
19
|
+
"DEFAULT_RETRY_CONFIG",
|
|
20
|
+
"AsyncCConnection",
|
|
21
|
+
"AsyncSessionManager",
|
|
22
|
+
"AsyncSessionManagerBuilder",
|
|
23
|
+
"RetryConfig",
|
|
24
|
+
"create_async_session_manager",
|
|
25
|
+
"retry_async_connection",
|
|
26
|
+
"try_advisory_xact_lock",
|
|
27
|
+
]
|