Skip to content

Commit e34de3f

Browse files
committed
v4.0.7
Signed-off-by: TIANHE <[email protected]>
1 parent 740451c commit e34de3f

3 files changed

Lines changed: 360 additions & 15 deletions

File tree

backend_api_python/app/utils/db_postgres.py

Lines changed: 206 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
77
Pool tuning (all via env, safe defaults):
88
DB_POOL_MIN minconn default 5
9-
DB_POOL_MAX maxconn default 50
9+
DB_POOL_MAX maxconn or "auto" default auto
1010
DB_POOL_ACQUIRE_TIMEOUT seconds to wait on exhaustion default 10
1111
DB_POOL_HEALTH_CHECK "true" / "false" default "true"
1212
"""
@@ -43,6 +43,21 @@ def _env_int(key: str, default: int) -> int:
4343
return default
4444

4545

46+
def _env_optional_int(key: str) -> Optional[int]:
47+
raw = os.getenv(key)
48+
if raw is None:
49+
return None
50+
value = raw.strip().lower()
51+
if not value or value in ("auto", "default"):
52+
return None
53+
try:
54+
parsed = int(value)
55+
return parsed if parsed > 0 else None
56+
except Exception:
57+
logger.warning("Invalid %s=%r; using auto", key, raw)
58+
return None
59+
60+
4661
def _env_bool(key: str, default: bool) -> bool:
4762
v = os.getenv(key)
4863
if v is None:
@@ -51,9 +66,14 @@ def _env_bool(key: str, default: bool) -> bool:
5166

5267

5368
DB_POOL_MIN = _env_int("DB_POOL_MIN", 5)
54-
DB_POOL_MAX = _env_int("DB_POOL_MAX", 50)
69+
DB_POOL_MAX_CONFIGURED = _env_optional_int("DB_POOL_MAX")
70+
DB_POOL_AUTO_DEFAULT_MAX = _env_int("DB_POOL_AUTO_DEFAULT_MAX", 50)
71+
DB_POOL_MAX = DB_POOL_MAX_CONFIGURED or DB_POOL_AUTO_DEFAULT_MAX
5572
DB_POOL_ACQUIRE_TIMEOUT = _env_int("DB_POOL_ACQUIRE_TIMEOUT", 10)
5673
DB_POOL_HEALTH_CHECK = _env_bool("DB_POOL_HEALTH_CHECK", True)
74+
DB_POOL_AUTO_CAP = _env_bool("DB_POOL_AUTO_CAP", True)
75+
DB_POOL_RESERVE_FOR_OTHER_CLIENTS = _env_int("DB_POOL_RESERVE_FOR_OTHER_CLIENTS", 20)
76+
DB_APPLICATION_NAME = os.getenv("DB_APPLICATION_NAME", "quantdinger_api").strip() or "quantdinger_api"
5777

5878

5979
def _get_database_url() -> str:
@@ -126,16 +146,19 @@ def _get_connection_pool():
126146
if not params:
127147
raise RuntimeError(f"Invalid DATABASE_URL format: {db_url}")
128148

149+
effective_min, effective_max = _resolve_effective_pool_limits(params)
150+
129151
try:
130152
_connection_pool = pool.ThreadedConnectionPool(
131-
minconn=DB_POOL_MIN,
132-
maxconn=DB_POOL_MAX,
153+
minconn=effective_min,
154+
maxconn=effective_max,
133155
host=params.get('host', 'localhost'),
134156
port=params.get('port', 5432),
135157
user=params.get('user', 'quantdinger'),
136158
password=params.get('password', ''),
137159
dbname=params.get('dbname', 'quantdinger'),
138160
connect_timeout=10,
161+
application_name=DB_APPLICATION_NAME,
139162
# Apply timezone at connection establishment so we don't need
140163
# per-checkout SET TIME ZONE (which left connections in an
141164
# "idle in transaction" state when no explicit commit/rollback
@@ -150,7 +173,8 @@ def _get_connection_pool():
150173
logger.info(
151174
f"PostgreSQL connection pool created: "
152175
f"{params.get('host')}:{params.get('port')}/{params.get('dbname')} "
153-
f"(min={DB_POOL_MIN}, max={DB_POOL_MAX}, "
176+
f"(min={effective_min}, max={effective_max}, "
177+
f"configured_min={DB_POOL_MIN}, configured_max={_pool_max_config_label()}, "
154178
f"acquire_timeout={DB_POOL_ACQUIRE_TIMEOUT}s, "
155179
f"health_check={DB_POOL_HEALTH_CHECK})"
156180
)
@@ -161,6 +185,129 @@ def _get_connection_pool():
161185
return _connection_pool
162186

163187

188+
def _show_pg_int(conn, setting: str, default: int = 0) -> int:
189+
cur = conn.cursor()
190+
try:
191+
cur.execute(f"SHOW {setting}")
192+
row = cur.fetchone()
193+
return int(row[0]) if row else default
194+
except Exception:
195+
try:
196+
conn.rollback()
197+
except Exception:
198+
pass
199+
return default
200+
finally:
201+
try:
202+
cur.close()
203+
except Exception:
204+
pass
205+
206+
207+
def _probe_pg_connection_limit(params: Dict[str, Any]) -> Optional[Dict[str, int]]:
208+
"""Read PostgreSQL connection limits using a short-lived probe connection."""
209+
if not DB_POOL_AUTO_CAP:
210+
return None
211+
probe = None
212+
try:
213+
probe = psycopg2.connect(
214+
host=params.get('host', 'localhost'),
215+
port=params.get('port', 5432),
216+
user=params.get('user', 'quantdinger'),
217+
password=params.get('password', ''),
218+
dbname=params.get('dbname', 'quantdinger'),
219+
connect_timeout=5,
220+
application_name=f"{DB_APPLICATION_NAME}_pool_probe",
221+
options="-c timezone=UTC",
222+
)
223+
max_connections = _show_pg_int(probe, "max_connections", 0)
224+
superuser_reserved = _show_pg_int(probe, "superuser_reserved_connections", 0)
225+
reserved = _show_pg_int(probe, "reserved_connections", 0)
226+
if max_connections <= 0:
227+
return None
228+
return {
229+
"max_connections": max_connections,
230+
"superuser_reserved_connections": superuser_reserved,
231+
"reserved_connections": reserved,
232+
}
233+
except Exception as exc:
234+
logger.warning("Could not probe PostgreSQL max_connections; using configured DB pool limits: %s", exc)
235+
return None
236+
finally:
237+
if probe is not None:
238+
try:
239+
probe.close()
240+
except Exception:
241+
pass
242+
243+
244+
def _resolve_effective_pool_limits(params: Dict[str, Any]) -> tuple[int, int]:
245+
"""Cap per-process pool size so app pools cannot exceed PostgreSQL capacity.
246+
247+
psycopg2's pool max is per Python process. With Gunicorn, total possible
248+
DB connections is roughly GUNICORN_WORKERS * DB_POOL_MAX, plus pgAdmin,
249+
psql, migrations, and Postgres reserved slots. If DB_POOL_MAX is larger
250+
than server max_connections, PostgreSQL rejects new sockets with
251+
"sorry, too many clients already" before the application pool can queue.
252+
"""
253+
configured_min = max(1, DB_POOL_MIN)
254+
explicit_max = DB_POOL_MAX_CONFIGURED
255+
default_auto_max = max(configured_min, DB_POOL_AUTO_DEFAULT_MAX)
256+
limits = _probe_pg_connection_limit(params)
257+
if not limits:
258+
configured_max = max(configured_min, explicit_max or default_auto_max)
259+
if explicit_max is None:
260+
logger.info(
261+
"DB_POOL_MAX=auto selected fallback max=%s because PostgreSQL limits could not be probed.",
262+
configured_max,
263+
)
264+
return configured_min, configured_max
265+
266+
pg_max = int(limits.get("max_connections") or 0)
267+
pg_reserved = int(limits.get("superuser_reserved_connections") or 0)
268+
pg_reserved += int(limits.get("reserved_connections") or 0)
269+
workers = _env_int("GUNICORN_WORKERS", 1)
270+
usable_total = max(1, pg_max - pg_reserved - DB_POOL_RESERVE_FOR_OTHER_CLIENTS)
271+
per_process_cap = max(1, usable_total // max(1, workers))
272+
273+
if explicit_max is None:
274+
configured_max = default_auto_max
275+
effective_max = min(configured_max, per_process_cap)
276+
logger.info(
277+
"DB_POOL_MAX=auto selected max=%s "
278+
"(postgres max_connections=%s, reserved=%s, reserve_for_other_clients=%s, "
279+
"gunicorn_workers=%s, auto_default_max=%s).",
280+
effective_max,
281+
pg_max,
282+
pg_reserved,
283+
DB_POOL_RESERVE_FOR_OTHER_CLIENTS,
284+
workers,
285+
default_auto_max,
286+
)
287+
else:
288+
configured_max = max(configured_min, explicit_max)
289+
effective_max = min(configured_max, per_process_cap)
290+
effective_min = min(configured_min, effective_max)
291+
if explicit_max is not None and effective_max < configured_max:
292+
logger.warning(
293+
"DB_POOL_MAX=%s exceeds safe PostgreSQL capacity; using effective max=%s "
294+
"(postgres max_connections=%s, reserved=%s, reserve_for_other_clients=%s, "
295+
"gunicorn_workers=%s). Use DB_POOL_MAX=auto, lower DB_POOL_MAX/DB_POOL_RESERVE_FOR_OTHER_CLIENTS, "
296+
"or raise PostgreSQL max_connections if needed.",
297+
configured_max,
298+
effective_max,
299+
pg_max,
300+
pg_reserved,
301+
DB_POOL_RESERVE_FOR_OTHER_CLIENTS,
302+
workers,
303+
)
304+
return effective_min, effective_max
305+
306+
307+
def _pool_max_config_label() -> str:
308+
return str(DB_POOL_MAX_CONFIGURED) if DB_POOL_MAX_CONFIGURED is not None else "auto"
309+
310+
164311
def _is_connection_healthy(conn) -> bool:
165312
"""Quick health check: make sure the connection is not closed and can
166313
actually round-trip a trivial query. Used only when DB_POOL_HEALTH_CHECK
@@ -203,15 +350,45 @@ def _acquire_conn_with_wait(pg_pool):
203350
remaining = deadline - time.monotonic()
204351
if remaining <= 0:
205352
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."
353+
"PostgreSQL pool exhausted: all %s connections are in use and waiting %ss "
354+
"did not free any. stats=%s. Consider lowering request concurrency or "
355+
"investigating long-running DB sections.",
356+
getattr(pg_pool, "maxconn", DB_POOL_MAX),
357+
DB_POOL_ACQUIRE_TIMEOUT,
358+
_pool_stats(pg_pool),
359+
)
360+
raise
361+
if not warned:
362+
logger.warning(
363+
"PostgreSQL pool exhausted (%s in use); waiting up to %ss for a slot. stats=%s",
364+
getattr(pg_pool, "maxconn", DB_POOL_MAX),
365+
DB_POOL_ACQUIRE_TIMEOUT,
366+
_pool_stats(pg_pool),
367+
)
368+
warned = True
369+
time.sleep(min(backoff, max(0.0, remaining)))
370+
backoff = min(backoff * 2, 0.5)
371+
continue
372+
except OperationalError as e:
373+
last_err = e
374+
if "too many clients already" not in str(e).lower():
375+
raise
376+
remaining = deadline - time.monotonic()
377+
if remaining <= 0:
378+
logger.error(
379+
"PostgreSQL server refused connections for %ss: too many clients already. "
380+
"pool_stats=%s. Lower DB_POOL_MAX/request concurrency or raise PostgreSQL "
381+
"max_connections.",
382+
DB_POOL_ACQUIRE_TIMEOUT,
383+
_pool_stats(pg_pool),
209384
)
210385
raise
211386
if not warned:
212387
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..."
388+
"PostgreSQL server is at max_connections; waiting up to %ss before failing. "
389+
"pool_stats=%s",
390+
DB_POOL_ACQUIRE_TIMEOUT,
391+
_pool_stats(pg_pool),
215392
)
216393
warned = True
217394
time.sleep(min(backoff, max(0.0, remaining)))
@@ -234,6 +411,25 @@ def _acquire_conn_with_wait(pg_pool):
234411
return conn
235412

236413

414+
def _pool_stats(pg_pool) -> Dict[str, int]:
415+
try:
416+
idle = len(getattr(pg_pool, "_pool", []) or [])
417+
except Exception:
418+
idle = -1
419+
try:
420+
used = len(getattr(pg_pool, "_used", {}) or {})
421+
except Exception:
422+
used = -1
423+
opened = idle + used if idle >= 0 and used >= 0 else -1
424+
return {
425+
"min": int(getattr(pg_pool, "minconn", -1) or -1),
426+
"max": int(getattr(pg_pool, "maxconn", -1) or -1),
427+
"idle": idle,
428+
"used": used,
429+
"opened": opened,
430+
}
431+
432+
237433
class PostgresCursor:
238434
"""PostgreSQL cursor wrapper with placeholder conversion for backward compatibility"""
239435

backend_api_python/env.example

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -108,14 +108,17 @@ SKIP_AUTO_MIGRATE=false
108108
# =========================
109109
# Database connection pool (psycopg2 ThreadedConnectionPool)
110110
# =========================
111-
# Tune these if you see `psycopg2.pool.PoolError: connection pool exhausted`
112-
# or if you run many trading bots / portfolios concurrently.
113-
# Make sure PG `max_connections` (docker-compose: PG_MAX_CONNECTIONS) is
114-
# comfortably larger than DB_POOL_MAX.
111+
# DB_POOL_MAX defaults to auto. Most self-hosted deployments should leave it
112+
# as auto; the backend probes PostgreSQL max_connections and picks a safe
113+
# per-process pool size. Advanced operators may set an integer override.
115114
DB_POOL_MIN=5
116-
DB_POOL_MAX=50
115+
DB_POOL_MAX=auto
117116
DB_POOL_ACQUIRE_TIMEOUT=10
118117
DB_POOL_HEALTH_CHECK=true
118+
DB_POOL_AUTO_CAP=true
119+
DB_POOL_AUTO_DEFAULT_MAX=50
120+
DB_POOL_RESERVE_FOR_OTHER_CLIENTS=20
121+
DB_APPLICATION_NAME=quantdinger_api
119122

120123
# Route-level parallel fetch executors. Each worker may hold one DB
121124
# connection, so keep MARKET_EXECUTOR_WORKERS + PORTFOLIO_EXECUTOR_WORKERS

0 commit comments

Comments
 (0)