|
3 | 3 |
|
4 | 4 | Supports multi-user mode with connection pooling. |
5 | 5 | Provides placeholder conversion for backward compatibility with legacy code. |
| 6 | +
|
| 7 | +Pool tuning (all via env, safe defaults): |
| 8 | + DB_POOL_MIN minconn default 5 |
| 9 | + DB_POOL_MAX maxconn default 50 |
| 10 | + DB_POOL_ACQUIRE_TIMEOUT seconds to wait on exhaustion default 10 |
| 11 | + DB_POOL_HEALTH_CHECK "true" / "false" default "true" |
6 | 12 | """ |
7 | 13 | import os |
| 14 | +import time |
8 | 15 | import threading |
9 | 16 | from typing import Optional, Any, List, Dict |
10 | 17 | from contextlib import contextmanager |
|
16 | 23 | try: |
17 | 24 | import psycopg2 |
18 | 25 | from psycopg2 import pool |
| 26 | + from psycopg2 import OperationalError, InterfaceError |
19 | 27 | from psycopg2.extras import RealDictCursor |
20 | 28 | HAS_PSYCOPG2 = True |
21 | 29 | except ImportError: |
|
27 | 35 | _pool_lock = threading.Lock() |
28 | 36 |
|
29 | 37 |
|
| 38 | +def _env_int(key: str, default: int) -> int: |
| 39 | + try: |
| 40 | + v = int(os.getenv(key, str(default))) |
| 41 | + return v if v > 0 else default |
| 42 | + except Exception: |
| 43 | + return default |
| 44 | + |
| 45 | + |
| 46 | +def _env_bool(key: str, default: bool) -> bool: |
| 47 | + v = os.getenv(key) |
| 48 | + if v is None: |
| 49 | + return default |
| 50 | + return v.strip().lower() in ("1", "true", "yes", "on") |
| 51 | + |
| 52 | + |
| 53 | +DB_POOL_MIN = _env_int("DB_POOL_MIN", 5) |
| 54 | +DB_POOL_MAX = _env_int("DB_POOL_MAX", 50) |
| 55 | +DB_POOL_ACQUIRE_TIMEOUT = _env_int("DB_POOL_ACQUIRE_TIMEOUT", 10) |
| 56 | +DB_POOL_HEALTH_CHECK = _env_bool("DB_POOL_HEALTH_CHECK", True) |
| 57 | + |
| 58 | + |
30 | 59 | def _get_database_url() -> str: |
31 | 60 | """Get database connection URL from environment""" |
32 | 61 | return os.getenv('DATABASE_URL', '').strip() |
@@ -99,23 +128,112 @@ def _get_connection_pool(): |
99 | 128 |
|
100 | 129 | try: |
101 | 130 | _connection_pool = pool.ThreadedConnectionPool( |
102 | | - minconn=2, |
103 | | - maxconn=20, |
| 131 | + minconn=DB_POOL_MIN, |
| 132 | + maxconn=DB_POOL_MAX, |
104 | 133 | host=params.get('host', 'localhost'), |
105 | 134 | port=params.get('port', 5432), |
106 | 135 | user=params.get('user', 'quantdinger'), |
107 | 136 | password=params.get('password', ''), |
108 | 137 | dbname=params.get('dbname', 'quantdinger'), |
109 | 138 | connect_timeout=10, |
| 139 | + # Apply timezone at connection establishment so we don't need |
| 140 | + # per-checkout SET TIME ZONE (which left connections in an |
| 141 | + # "idle in transaction" state when no explicit commit/rollback |
| 142 | + # followed). keepalives keep dead sockets from lingering in |
| 143 | + # the pool when the PG side or a NAT drops them. |
| 144 | + options="-c timezone=UTC", |
| 145 | + keepalives=1, |
| 146 | + keepalives_idle=30, |
| 147 | + keepalives_interval=10, |
| 148 | + keepalives_count=3, |
| 149 | + ) |
| 150 | + logger.info( |
| 151 | + f"PostgreSQL connection pool created: " |
| 152 | + f"{params.get('host')}:{params.get('port')}/{params.get('dbname')} " |
| 153 | + f"(min={DB_POOL_MIN}, max={DB_POOL_MAX}, " |
| 154 | + f"acquire_timeout={DB_POOL_ACQUIRE_TIMEOUT}s, " |
| 155 | + f"health_check={DB_POOL_HEALTH_CHECK})" |
110 | 156 | ) |
111 | | - logger.info(f"PostgreSQL connection pool created: {params.get('host')}:{params.get('port')}/{params.get('dbname')}") |
112 | 157 | except Exception as e: |
113 | 158 | logger.error(f"Failed to create PostgreSQL connection pool: {e}") |
114 | 159 | raise |
115 | 160 |
|
116 | 161 | return _connection_pool |
117 | 162 |
|
118 | 163 |
|
| 164 | +def _is_connection_healthy(conn) -> bool: |
| 165 | + """Quick health check: make sure the connection is not closed and can |
| 166 | + actually round-trip a trivial query. Used only when DB_POOL_HEALTH_CHECK |
| 167 | + is on, since SELECT 1 adds a small latency. |
| 168 | + """ |
| 169 | + if conn is None: |
| 170 | + return False |
| 171 | + # psycopg2 sets .closed to nonzero when the connection is closed. |
| 172 | + if getattr(conn, "closed", 0): |
| 173 | + return False |
| 174 | + try: |
| 175 | + cur = conn.cursor() |
| 176 | + cur.execute("SELECT 1") |
| 177 | + cur.fetchone() |
| 178 | + cur.close() |
| 179 | + return True |
| 180 | + except Exception: |
| 181 | + return False |
| 182 | + |
| 183 | + |
| 184 | +def _acquire_conn_with_wait(pg_pool): |
| 185 | + """Wrapper around pg_pool.getconn() that waits up to |
| 186 | + DB_POOL_ACQUIRE_TIMEOUT seconds instead of failing immediately when the |
| 187 | + pool is exhausted (psycopg2's default behaviour). Also performs a |
| 188 | + lightweight health check on the returned connection and discards dead |
| 189 | + connections back to the pool to let PG reopen fresh ones. |
| 190 | + """ |
| 191 | + if not HAS_PSYCOPG2: |
| 192 | + raise RuntimeError("psycopg2 is not installed. Cannot use PostgreSQL.") |
| 193 | + |
| 194 | + deadline = time.monotonic() + max(1, DB_POOL_ACQUIRE_TIMEOUT) |
| 195 | + backoff = 0.05 # start at 50ms |
| 196 | + last_err: Optional[Exception] = None |
| 197 | + warned = False |
| 198 | + while True: |
| 199 | + try: |
| 200 | + conn = pg_pool.getconn() |
| 201 | + except pool.PoolError as e: |
| 202 | + last_err = e |
| 203 | + remaining = deadline - time.monotonic() |
| 204 | + if remaining <= 0: |
| 205 | + logger.error( |
| 206 | + f"PostgreSQL pool exhausted: all {DB_POOL_MAX} connections are in use " |
| 207 | + f"and waiting {DB_POOL_ACQUIRE_TIMEOUT}s did not free any. " |
| 208 | + f"Consider raising DB_POOL_MAX or investigating long-running queries." |
| 209 | + ) |
| 210 | + raise |
| 211 | + if not warned: |
| 212 | + logger.warning( |
| 213 | + f"PostgreSQL pool exhausted ({DB_POOL_MAX} in use); " |
| 214 | + f"waiting up to {DB_POOL_ACQUIRE_TIMEOUT}s for a slot..." |
| 215 | + ) |
| 216 | + warned = True |
| 217 | + time.sleep(min(backoff, max(0.0, remaining))) |
| 218 | + backoff = min(backoff * 2, 0.5) |
| 219 | + continue |
| 220 | + |
| 221 | + if DB_POOL_HEALTH_CHECK and not _is_connection_healthy(conn): |
| 222 | + # Drop the dead connection and let the pool create a new one on |
| 223 | + # next attempt. putconn(close=True) asks the pool to discard it. |
| 224 | + try: |
| 225 | + pg_pool.putconn(conn, close=True) |
| 226 | + except Exception: |
| 227 | + pass |
| 228 | + remaining = deadline - time.monotonic() |
| 229 | + if remaining <= 0: |
| 230 | + raise last_err or RuntimeError("DB pool returned only dead connections") |
| 231 | + time.sleep(min(backoff, max(0.0, remaining))) |
| 232 | + continue |
| 233 | + |
| 234 | + return conn |
| 235 | + |
| 236 | + |
119 | 237 | class PostgresCursor: |
120 | 238 | """PostgreSQL cursor wrapper with placeholder conversion for backward compatibility""" |
121 | 239 |
|
@@ -214,62 +332,65 @@ def rollback(self): |
214 | 332 | self._conn.rollback() |
215 | 333 |
|
216 | 334 | def close(self): |
217 | | - """Return connection to pool""" |
| 335 | + """Return connection to pool. Broken connections are discarded so |
| 336 | + we don't poison the pool with closed sockets. |
| 337 | + """ |
218 | 338 | if self._pool and self._conn: |
219 | 339 | try: |
220 | | - self._pool.putconn(self._conn) |
| 340 | + broken = bool(getattr(self._conn, "closed", 0)) |
| 341 | + self._pool.putconn(self._conn, close=broken) |
221 | 342 | except Exception as e: |
222 | 343 | logger.warning(f"Failed to return connection to pool: {e}") |
223 | 344 |
|
224 | 345 |
|
225 | | -def _ensure_session_utc(conn) -> None: |
226 | | - """每条连接检出后固定为 UTC,与 API 序列化约定一致。""" |
227 | | - try: |
228 | | - cur = conn.cursor() |
229 | | - cur.execute("SET TIME ZONE 'UTC'") |
230 | | - cur.close() |
231 | | - except Exception as e: |
232 | | - logger.warning(f"Could not SET TIME ZONE UTC on connection: {e}") |
233 | | - |
234 | | - |
235 | 346 | @contextmanager |
236 | 347 | def get_pg_connection(): |
237 | 348 | """ |
238 | | - Get PostgreSQL database connection (Context Manager) |
| 349 | + Get PostgreSQL database connection (Context Manager). |
| 350 | +
|
| 351 | + Uses _acquire_conn_with_wait so a momentary pool exhaustion does not |
| 352 | + immediately fail the request; we wait up to DB_POOL_ACQUIRE_TIMEOUT |
| 353 | + seconds for a connection to be released. |
239 | 354 | """ |
240 | | - pool = _get_connection_pool() |
| 355 | + pg_pool = _get_connection_pool() |
241 | 356 | conn = None |
| 357 | + broken = False |
242 | 358 | try: |
243 | | - conn = pool.getconn() |
244 | | - _ensure_session_utc(conn) |
| 359 | + conn = _acquire_conn_with_wait(pg_pool) |
245 | 360 | pg_conn = PostgresConnection(conn) |
246 | 361 | yield pg_conn |
247 | 362 | except Exception as e: |
248 | | - if conn: |
| 363 | + if conn is not None: |
249 | 364 | try: |
250 | 365 | conn.rollback() |
251 | 366 | except Exception: |
252 | 367 | pass |
253 | | - # 记录更详细的错误信息 |
| 368 | + # If the connection itself died mid-request, discard it instead |
| 369 | + # of returning it to the pool. |
| 370 | + if isinstance(e, (OperationalError, InterfaceError)) or getattr(conn, "closed", 0): |
| 371 | + broken = True |
254 | 372 | error_msg = str(e) if e else repr(e) |
255 | 373 | error_type = type(e).__name__ |
256 | 374 | logger.error(f"PostgreSQL operation error ({error_type}): {error_msg}", exc_info=True) |
257 | 375 | raise |
258 | 376 | finally: |
259 | | - if conn: |
| 377 | + if conn is not None: |
260 | 378 | try: |
261 | | - pool.putconn(conn) |
| 379 | + pg_pool.putconn(conn, close=broken) |
262 | 380 | except Exception: |
263 | 381 | pass |
264 | 382 |
|
265 | 383 |
|
266 | 384 | def get_pg_connection_sync() -> PostgresConnection: |
267 | 385 | """ |
268 | | - Get connection synchronously (caller must close) |
| 386 | + Get connection synchronously (caller must close). |
| 387 | +
|
| 388 | + NOTE: this function leaks its connection if the caller forgets to call |
| 389 | + `.close()`. Prefer `get_pg_connection()` (context manager) whenever |
| 390 | + possible. |
269 | 391 | """ |
270 | | - pool = _get_connection_pool() |
271 | | - conn = pool.getconn() |
272 | | - _ensure_session_utc(conn) |
| 392 | + pg_pool = _get_connection_pool() |
| 393 | + conn = _acquire_conn_with_wait(pg_pool) |
273 | 394 | return PostgresConnection(conn) |
274 | 395 |
|
275 | 396 |
|
|
0 commit comments