python3-commons 0.20.3__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.
@@ -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__(self, db_configs: Mapping[str, DBSettings]) -> None:
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 settings: %s', name)
34
-
63
+ logger.exception('Missing database config for key %r. Available: %s', name, list(self.db_configs))
35
64
  raise
36
65
 
37
- def async_engine_from_db_config(self, name):
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': str(db_config.dsn),
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
- 'pool_timeout': db_config.pool_timeout,
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
- return async_engine_from_config(
50
- configuration,
51
- prefix='',
52
- connect_args={
53
- 'timeout': 5, # asyncpg timeout
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
- engine = self.engines[name]
112
+ return self.engines[name]
60
113
  except KeyError:
61
- logger.debug('Creating engine: %s', name)
62
- engine = self.async_engine_from_db_config(name)
114
+ engine = self._build_engine(name)
63
115
  self.engines[name] = engine
64
116
 
65
- return engine
117
+ return engine
66
118
 
67
- def get_session_maker(self, name: str):
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
- session_maker = self.session_makers[name]
136
+ return self.session_makers[name]
70
137
  except KeyError:
71
- logger.debug('Creating session maker: %s', name)
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
- return session_maker
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
- async with session_maker() as session:
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
- # TODO: cache
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(engine: AsyncEngine) -> bool:
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 engine.begin() as conn:
95
- result = await conn.execute('SELECT 1;')
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
- return result.scalar() == 1
256
+ return False
98
257
  except Exception:
99
- logger.exception('Database connection is not healthy.')
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
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
@@ -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=Rbi6WBXTZMJzUPzyND-eASFCWaoBILkOU0-X5N_-6Jo,3285
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.3.dist-info/licenses/AUTHORS.rst,sha256=3R9JnfjfjH5RoPWOeqKFJgxVShSSfzQPIrEr1nxIo9Q,90
31
- python3_commons-0.20.3.dist-info/licenses/LICENSE,sha256=xxILuojHm4fKQOrMHPSslbyy6WuKAN2RiG74HbrYfzM,34575
32
- python3_commons-0.20.3.dist-info/METADATA,sha256=Ksf1lbFrjLbztxDN74zDiskRudjbD4edRDYP6MIbl-I,2333
33
- python3_commons-0.20.3.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
34
- python3_commons-0.20.3.dist-info/top_level.txt,sha256=lJI6sCBf68eUHzupCnn2dzG10lH3jJKTWM_hrN1cQ7M,16
35
- python3_commons-0.20.3.dist-info/RECORD,,
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,,