python3-commons 0.20.2__py3-none-any.whl → 0.20.4__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.
- python3_commons/db/__init__.py +191 -30
- {python3_commons-0.20.2.dist-info → python3_commons-0.20.4.dist-info}/METADATA +2 -2
- {python3_commons-0.20.2.dist-info → python3_commons-0.20.4.dist-info}/RECORD +7 -7
- {python3_commons-0.20.2.dist-info → python3_commons-0.20.4.dist-info}/WHEEL +0 -0
- {python3_commons-0.20.2.dist-info → python3_commons-0.20.4.dist-info}/licenses/AUTHORS.rst +0 -0
- {python3_commons-0.20.2.dist-info → python3_commons-0.20.4.dist-info}/licenses/LICENSE +0 -0
- {python3_commons-0.20.2.dist-info → python3_commons-0.20.4.dist-info}/top_level.txt +0 -0
python3_commons/db/__init__.py
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
|
+
import asyncio
|
|
1
2
|
import contextlib
|
|
2
3
|
import logging
|
|
4
|
+
import time
|
|
3
5
|
from collections.abc import AsyncGenerator, Callable, Mapping
|
|
4
6
|
from typing import TYPE_CHECKING
|
|
5
7
|
|
|
6
8
|
try:
|
|
7
|
-
from sqlalchemy import MetaData
|
|
9
|
+
from sqlalchemy import MetaData, text
|
|
10
|
+
from sqlalchemy.exc import OperationalError, SQLAlchemyError
|
|
11
|
+
from sqlalchemy.exc import TimeoutError as SATimeoutError
|
|
8
12
|
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_engine_from_config
|
|
9
13
|
from sqlalchemy.ext.asyncio.session import async_sessionmaker
|
|
10
14
|
from sqlalchemy.orm import declarative_base
|
|
@@ -16,86 +20,243 @@ if TYPE_CHECKING:
|
|
|
16
20
|
from python3_commons.conf import DBSettings
|
|
17
21
|
|
|
18
22
|
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
19
24
|
metadata = MetaData()
|
|
20
25
|
Base = declarative_base(metadata=metadata)
|
|
21
26
|
|
|
27
|
+
# How long (seconds) to wait for a connection from the pool before giving up.
|
|
28
|
+
# This is the main guard against silent hangs.
|
|
29
|
+
_DEFAULT_POOL_ACQUIRE_TIMEOUT = 10
|
|
30
|
+
# Timeout passed to asyncpg for the TCP connect itself.
|
|
31
|
+
_DEFAULT_CONNECT_TIMEOUT = 5
|
|
32
|
+
# Timeout for the health-check query.
|
|
33
|
+
_DEFAULT_HEALTH_CHECK_TIMEOUT = 5
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class DatabaseError(Exception):
|
|
37
|
+
"""Base class for session-manager errors."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class EngineCreationError(DatabaseError):
|
|
41
|
+
"""Raised when an engine cannot be created."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class SessionAcquireError(DatabaseError):
|
|
45
|
+
"""Raised when a session cannot be acquired within the allotted time."""
|
|
46
|
+
|
|
22
47
|
|
|
23
48
|
class AsyncSessionManager:
|
|
24
|
-
def __init__(
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
db_configs: Mapping[str, DBSettings],
|
|
52
|
+
pool_acquire_timeout: float = _DEFAULT_POOL_ACQUIRE_TIMEOUT,
|
|
53
|
+
) -> None:
|
|
25
54
|
self.db_configs: Mapping[str, DBSettings] = db_configs
|
|
55
|
+
self.pool_acquire_timeout = pool_acquire_timeout
|
|
26
56
|
self.engines: dict[str, AsyncEngine] = {}
|
|
27
|
-
self.session_makers: dict = {}
|
|
57
|
+
self.session_makers: dict[str, async_sessionmaker] = {}
|
|
28
58
|
|
|
29
59
|
def get_db_config(self, name: str) -> DBSettings:
|
|
30
60
|
try:
|
|
31
61
|
return self.db_configs[name]
|
|
32
62
|
except KeyError:
|
|
33
|
-
logger.exception('Missing database
|
|
34
|
-
|
|
63
|
+
logger.exception('Missing database config for key %r. Available: %s', name, list(self.db_configs))
|
|
35
64
|
raise
|
|
36
65
|
|
|
37
|
-
def
|
|
66
|
+
def _build_engine(self, name: str) -> AsyncEngine:
|
|
38
67
|
db_config = self.get_db_config(name)
|
|
68
|
+
dsn = db_config.dsn
|
|
69
|
+
|
|
70
|
+
logger.debug('Building engine for %r (dsn=%s)', name, dsn)
|
|
71
|
+
|
|
39
72
|
configuration = {
|
|
40
|
-
'url':
|
|
73
|
+
'url': dsn,
|
|
41
74
|
'echo': db_config.echo,
|
|
42
75
|
'pool_size': db_config.pool_size,
|
|
43
76
|
'max_overflow': db_config.max_overflow,
|
|
44
|
-
|
|
77
|
+
# pool_timeout: seconds to wait for a conn from the pool.
|
|
78
|
+
# Falls back to default so the pool never blocks forever.
|
|
79
|
+
'pool_timeout': getattr(db_config, 'pool_timeout', _DEFAULT_POOL_ACQUIRE_TIMEOUT),
|
|
45
80
|
'pool_recycle': db_config.pool_recycle,
|
|
46
81
|
'pool_pre_ping': db_config.pool_pre_ping,
|
|
47
82
|
}
|
|
83
|
+
connect_args = {'timeout': _DEFAULT_CONNECT_TIMEOUT}
|
|
84
|
+
# For asyncpg, command_timeout provides a per-statement timeout.
|
|
85
|
+
if 'postgresql' in dsn:
|
|
86
|
+
connect_args['command_timeout'] = float(db_config.statement_timeout)
|
|
48
87
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
88
|
+
try:
|
|
89
|
+
engine = async_engine_from_config(
|
|
90
|
+
configuration,
|
|
91
|
+
prefix='',
|
|
92
|
+
connect_args=connect_args,
|
|
93
|
+
)
|
|
94
|
+
except Exception as e:
|
|
95
|
+
logger.exception('Failed to create engine for %r', name)
|
|
96
|
+
|
|
97
|
+
msg = f'Could not create engine for {name!r}'
|
|
98
|
+
raise EngineCreationError(msg) from e
|
|
99
|
+
|
|
100
|
+
logger.info(
|
|
101
|
+
'Engine created for %r (pool_size=%s, max_overflow=%s, pool_timeout=%s)',
|
|
102
|
+
name,
|
|
103
|
+
configuration['pool_size'],
|
|
104
|
+
configuration['max_overflow'],
|
|
105
|
+
configuration['pool_timeout'],
|
|
55
106
|
)
|
|
56
107
|
|
|
108
|
+
return engine
|
|
109
|
+
|
|
57
110
|
def get_engine(self, name: str) -> AsyncEngine:
|
|
58
111
|
try:
|
|
59
|
-
|
|
112
|
+
return self.engines[name]
|
|
60
113
|
except KeyError:
|
|
61
|
-
|
|
62
|
-
engine = self.async_engine_from_db_config(name)
|
|
114
|
+
engine = self._build_engine(name)
|
|
63
115
|
self.engines[name] = engine
|
|
64
116
|
|
|
65
|
-
|
|
117
|
+
return engine
|
|
66
118
|
|
|
67
|
-
def
|
|
119
|
+
async def dispose_engine(self, name: str) -> None:
|
|
120
|
+
"""Gracefully close all pooled connections for *name*."""
|
|
121
|
+
engine = self.engines.pop(name, None)
|
|
122
|
+
self.session_makers.pop(name, None)
|
|
123
|
+
|
|
124
|
+
if engine is not None:
|
|
125
|
+
logger.info('Disposing engine for %r', name)
|
|
126
|
+
|
|
127
|
+
await engine.dispose()
|
|
128
|
+
|
|
129
|
+
async def dispose_all(self) -> None:
|
|
130
|
+
"""Gracefully close every managed engine."""
|
|
131
|
+
for name in list(self.engines):
|
|
132
|
+
await self.dispose_engine(name)
|
|
133
|
+
|
|
134
|
+
def get_session_maker(self, name: str) -> async_sessionmaker:
|
|
68
135
|
try:
|
|
69
|
-
|
|
136
|
+
return self.session_makers[name]
|
|
70
137
|
except KeyError:
|
|
71
|
-
logger.debug('Creating session maker
|
|
138
|
+
logger.debug('Creating session maker for %r', name)
|
|
72
139
|
engine = self.get_engine(name)
|
|
73
140
|
session_maker = async_sessionmaker(engine, expire_on_commit=False)
|
|
74
141
|
self.session_makers[name] = session_maker
|
|
75
142
|
|
|
76
|
-
|
|
143
|
+
return session_maker
|
|
77
144
|
|
|
78
145
|
def get_async_session(self, name: str) -> Callable[[], AsyncGenerator[AsyncSession]]:
|
|
146
|
+
"""
|
|
147
|
+
Return a dependency-injection-friendly async generator that yields a
|
|
148
|
+
session, wrapped in an outer timeout so it can never hang silently.
|
|
149
|
+
|
|
150
|
+
Usage (FastAPI / any DI framework):
|
|
151
|
+
session: AsyncSession = Depends(manager.get_async_session("default"))
|
|
152
|
+
"""
|
|
153
|
+
|
|
79
154
|
async def get_session() -> AsyncGenerator[AsyncSession]:
|
|
155
|
+
session_acquired = False
|
|
80
156
|
session_maker = self.get_session_maker(name)
|
|
157
|
+
t0 = time.monotonic()
|
|
158
|
+
logger.debug('Acquiring session for %r', name)
|
|
159
|
+
|
|
160
|
+
try:
|
|
161
|
+
async with asyncio.timeout(self.pool_acquire_timeout):
|
|
162
|
+
async with session_maker() as session:
|
|
163
|
+
session_acquired = True
|
|
164
|
+
elapsed = time.monotonic() - t0
|
|
165
|
+
logger.debug('Session acquired for %r in %.3fs', name, elapsed)
|
|
166
|
+
|
|
167
|
+
try:
|
|
168
|
+
yield session
|
|
169
|
+
except Exception:
|
|
170
|
+
logger.exception('Database communication error for %r; rolling back', name)
|
|
171
|
+
await session.rollback()
|
|
172
|
+
|
|
173
|
+
raise
|
|
174
|
+
except TimeoutError as e:
|
|
175
|
+
if session_acquired:
|
|
176
|
+
raise
|
|
177
|
+
|
|
178
|
+
elapsed = time.monotonic() - t0
|
|
179
|
+
logger.exception(
|
|
180
|
+
'Timed out waiting for a session for %r after %.3fs (limit=%.1fs)',
|
|
181
|
+
name,
|
|
182
|
+
elapsed,
|
|
183
|
+
self.pool_acquire_timeout,
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
msg = f'Could not acquire a session for {name!r} within {self.pool_acquire_timeout}s'
|
|
187
|
+
raise SessionAcquireError(msg) from e
|
|
188
|
+
except (OperationalError, SATimeoutError) as e:
|
|
189
|
+
if session_acquired:
|
|
190
|
+
raise
|
|
191
|
+
|
|
192
|
+
elapsed = time.monotonic() - t0
|
|
193
|
+
|
|
194
|
+
logger.exception('DB error acquiring session for %r after %.3fs', name, elapsed)
|
|
195
|
+
|
|
196
|
+
msg = f'DB error for {name!r}: {e}'
|
|
197
|
+
raise SessionAcquireError(msg) from e
|
|
198
|
+
except SQLAlchemyError as e:
|
|
199
|
+
if session_acquired:
|
|
200
|
+
raise
|
|
201
|
+
|
|
202
|
+
logger.exception('Unexpected SQLAlchemy error for %r', name)
|
|
203
|
+
|
|
204
|
+
msg = f'SQLAlchemy error for {name!r}'
|
|
81
205
|
|
|
82
|
-
|
|
83
|
-
yield session
|
|
206
|
+
raise SessionAcquireError(msg) from e
|
|
84
207
|
|
|
85
208
|
return get_session
|
|
86
209
|
|
|
87
210
|
def get_session_context(self, name: str):
|
|
88
|
-
|
|
211
|
+
"""
|
|
212
|
+
Return an async context manager that yields a session.
|
|
213
|
+
|
|
214
|
+
Usage:
|
|
215
|
+
async with manager.get_session_context("default") as session:
|
|
216
|
+
...
|
|
217
|
+
"""
|
|
89
218
|
return contextlib.asynccontextmanager(self.get_async_session(name))
|
|
90
219
|
|
|
91
220
|
|
|
92
|
-
async def is_healthy(
|
|
221
|
+
async def is_healthy(
|
|
222
|
+
engine: AsyncEngine,
|
|
223
|
+
timeout: float = _DEFAULT_HEALTH_CHECK_TIMEOUT,
|
|
224
|
+
) -> bool:
|
|
225
|
+
"""
|
|
226
|
+
Return True only if the engine can execute a trivial query within *timeout*
|
|
227
|
+
seconds. All failures are caught and logged; never raises.
|
|
228
|
+
"""
|
|
229
|
+
t0 = time.monotonic()
|
|
230
|
+
|
|
93
231
|
try:
|
|
94
|
-
async with
|
|
95
|
-
|
|
232
|
+
async with asyncio.timeout(timeout):
|
|
233
|
+
async with engine.begin() as conn:
|
|
234
|
+
result = await conn.execute(text('SELECT 1'))
|
|
235
|
+
healthy = result.scalar() == 1
|
|
236
|
+
|
|
237
|
+
elapsed = time.monotonic() - t0
|
|
238
|
+
|
|
239
|
+
if healthy:
|
|
240
|
+
logger.debug('Health check passed in %.3fs', elapsed)
|
|
241
|
+
else:
|
|
242
|
+
logger.warning('Health check query returned unexpected result')
|
|
243
|
+
except TimeoutError:
|
|
244
|
+
elapsed = time.monotonic() - t0
|
|
245
|
+
logger.exception('Health check timed out after %.3fs (limit=%.1fs)', elapsed, timeout)
|
|
246
|
+
|
|
247
|
+
return False
|
|
248
|
+
except OperationalError as e:
|
|
249
|
+
msg = f'Health check failed: DB not reachable: {e}'
|
|
250
|
+
logger.exception(msg)
|
|
251
|
+
|
|
252
|
+
return False
|
|
253
|
+
except SQLAlchemyError:
|
|
254
|
+
logger.exception('Health check failed: SQLAlchemy error')
|
|
96
255
|
|
|
97
|
-
|
|
256
|
+
return False
|
|
98
257
|
except Exception:
|
|
99
|
-
logger.exception('
|
|
258
|
+
logger.exception('Health check failed: unexpected error')
|
|
100
259
|
|
|
101
260
|
return False
|
|
261
|
+
|
|
262
|
+
return healthy
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python3-commons
|
|
3
|
-
Version: 0.20.
|
|
3
|
+
Version: 0.20.4
|
|
4
4
|
Summary: Re-usable Python3 code
|
|
5
5
|
Author-email: Oleg Korsak <kamikaze.is.waiting.you@gmail.com>
|
|
6
6
|
License-Expression: GPL-3.0
|
|
@@ -36,7 +36,7 @@ Provides-Extra: cache
|
|
|
36
36
|
Requires-Dist: valkey[libvalkey]~=6.1.1; extra == "cache"
|
|
37
37
|
Provides-Extra: database
|
|
38
38
|
Requires-Dist: asyncpg~=0.31.0; extra == "database"
|
|
39
|
-
Requires-Dist: SQLAlchemy[asyncio]~=2.0.
|
|
39
|
+
Requires-Dist: SQLAlchemy[asyncio]~=2.0.49; extra == "database"
|
|
40
40
|
Provides-Extra: object-storage
|
|
41
41
|
Requires-Dist: aiobotocore~=3.7.0; extra == "object-storage"
|
|
42
42
|
Requires-Dist: object-storage-client==0.0.30; extra == "object-storage"
|
|
@@ -12,7 +12,7 @@ python3_commons/helpers.py,sha256=d1xdxiOyskwq_rzSprB7e7Fo8Hp0uDlWSmRWFL4vC6A,49
|
|
|
12
12
|
python3_commons/object_storage.py,sha256=2l8v1mDB5WWN6jhxH2t5xmHBXaVPRJl0z44wExmlO80,7175
|
|
13
13
|
python3_commons/permissions.py,sha256=gaMKSWg0MgPQTdP1voll4ItXcblXku9BlD0Lq3Xv64U,1724
|
|
14
14
|
python3_commons/soap_client.py,sha256=w2lOyhhtkhwiNnHjvir8DfjGBO51XVg5rlDHyuudU2A,7169
|
|
15
|
-
python3_commons/db/__init__.py,sha256=
|
|
15
|
+
python3_commons/db/__init__.py,sha256=gNE8TQzVk99MHsuitpAFcDcLVKwHUC4rW_zB0t9UrLI,9131
|
|
16
16
|
python3_commons/db/helpers.py,sha256=xRpWs4aVkBge6HCLjb6OLSnWc1nlV6rKGyaVhZ_x12w,2001
|
|
17
17
|
python3_commons/db/models/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
18
18
|
python3_commons/db/models/auth.py,sha256=t6z_0c7l_1eB-CCdnPSV9OzJ_ymgkwD1cLpecNhQgoA,773
|
|
@@ -27,9 +27,9 @@ python3_commons/serializers/common.py,sha256=VkA7C6wODvHk0QBXVX_x2JieDstihx3U__U
|
|
|
27
27
|
python3_commons/serializers/json.py,sha256=UPkC3ps13x2C_NxwVV-K7Ewp4VjkVHSSUkJVw5k7Wiw,712
|
|
28
28
|
python3_commons/serializers/msgpack.py,sha256=zESFBX34GsZ8rDu6Zk5V6CLT6P0mPilU0r04Ka6TblI,1474
|
|
29
29
|
python3_commons/serializers/msgspec.py,sha256=upy5CBmK66-8hYnK5bAM_sZvZY5CAqZmzCw9GIF346I,2988
|
|
30
|
-
python3_commons-0.20.
|
|
31
|
-
python3_commons-0.20.
|
|
32
|
-
python3_commons-0.20.
|
|
33
|
-
python3_commons-0.20.
|
|
34
|
-
python3_commons-0.20.
|
|
35
|
-
python3_commons-0.20.
|
|
30
|
+
python3_commons-0.20.4.dist-info/licenses/AUTHORS.rst,sha256=3R9JnfjfjH5RoPWOeqKFJgxVShSSfzQPIrEr1nxIo9Q,90
|
|
31
|
+
python3_commons-0.20.4.dist-info/licenses/LICENSE,sha256=xxILuojHm4fKQOrMHPSslbyy6WuKAN2RiG74HbrYfzM,34575
|
|
32
|
+
python3_commons-0.20.4.dist-info/METADATA,sha256=PKBinHV5jCR_IXeqab_4SVkPLL23op1pnDqm7QrYRvk,2333
|
|
33
|
+
python3_commons-0.20.4.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
34
|
+
python3_commons-0.20.4.dist-info/top_level.txt,sha256=lJI6sCBf68eUHzupCnn2dzG10lH3jJKTWM_hrN1cQ7M,16
|
|
35
|
+
python3_commons-0.20.4.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|