66
77Pool 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+
4661def _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
5368DB_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
5572DB_POOL_ACQUIRE_TIMEOUT = _env_int ("DB_POOL_ACQUIRE_TIMEOUT" , 10 )
5673DB_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
5979def _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+
164311def _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+
237433class PostgresCursor :
238434 """PostgreSQL cursor wrapper with placeholder conversion for backward compatibility"""
239435
0 commit comments