Skip to content

Commit 6724aca

Browse files
v3.0.2
Signed-off-by: Dinger <quantdinger@gmail.com>
1 parent db86796 commit 6724aca

6 files changed

Lines changed: 219 additions & 34 deletions

File tree

‎backend_api_python/app/routes/market.py‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
Provides watchlist, market metadata, symbol search, and pricing helpers for the frontend.
44
"""
55
from flask import Blueprint, request, jsonify, g
6+
import os
67
import traceback
78
import json
89
import time
@@ -27,8 +28,17 @@
2728
kline_service = KlineService()
2829
cache = CacheManager()
2930

30-
# Thread pool for parallel price fetching
31-
executor = ThreadPoolExecutor(max_workers=10)
31+
# Thread pool for parallel price fetching.
32+
# Each task may grab a DB connection, so keep this well below DB_POOL_MAX.
33+
# Tunable via MARKET_EXECUTOR_WORKERS env.
34+
def _market_executor_workers() -> int:
35+
try:
36+
v = int(os.getenv("MARKET_EXECUTOR_WORKERS", "6"))
37+
return v if v > 0 else 6
38+
except Exception:
39+
return 6
40+
41+
executor = ThreadPoolExecutor(max_workers=_market_executor_workers())
3242

3343
def _now_ts() -> int:
3444
return int(time.time())

‎backend_api_python/app/routes/portfolio.py‎

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
"""
55
from flask import Blueprint, request, jsonify, g
66
from datetime import date, datetime, timezone
7+
import os
78
import json
89
import traceback
910
import time
@@ -24,9 +25,18 @@
2425
kline_service = KlineService()
2526
cache = CacheManager()
2627

27-
# Thread pool for parallel price fetching
28-
# Lower concurrency to avoid triggering API limits (especially for forex/US stocks)
29-
executor = ThreadPoolExecutor(max_workers=3)
28+
# Thread pool for parallel price fetching.
29+
# Lower concurrency to avoid triggering API limits (especially for forex/US
30+
# stocks) and to keep pressure off the DB connection pool.
31+
# Tunable via PORTFOLIO_EXECUTOR_WORKERS env.
32+
def _portfolio_executor_workers() -> int:
33+
try:
34+
v = int(os.getenv("PORTFOLIO_EXECUTOR_WORKERS", "3"))
35+
return v if v > 0 else 3
36+
except Exception:
37+
return 3
38+
39+
executor = ThreadPoolExecutor(max_workers=_portfolio_executor_workers())
3040

3141
# Request interval (seconds) to avoid too frequent requests
3242
REQUEST_INTERVAL = 0.3

‎backend_api_python/app/utils/db_postgres.py‎

Lines changed: 148 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,15 @@
33
44
Supports multi-user mode with connection pooling.
55
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"
612
"""
713
import os
14+
import time
815
import threading
916
from typing import Optional, Any, List, Dict
1017
from contextlib import contextmanager
@@ -16,6 +23,7 @@
1623
try:
1724
import psycopg2
1825
from psycopg2 import pool
26+
from psycopg2 import OperationalError, InterfaceError
1927
from psycopg2.extras import RealDictCursor
2028
HAS_PSYCOPG2 = True
2129
except ImportError:
@@ -27,6 +35,27 @@
2735
_pool_lock = threading.Lock()
2836

2937

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+
3059
def _get_database_url() -> str:
3160
"""Get database connection URL from environment"""
3261
return os.getenv('DATABASE_URL', '').strip()
@@ -99,23 +128,112 @@ def _get_connection_pool():
99128

100129
try:
101130
_connection_pool = pool.ThreadedConnectionPool(
102-
minconn=2,
103-
maxconn=20,
131+
minconn=DB_POOL_MIN,
132+
maxconn=DB_POOL_MAX,
104133
host=params.get('host', 'localhost'),
105134
port=params.get('port', 5432),
106135
user=params.get('user', 'quantdinger'),
107136
password=params.get('password', ''),
108137
dbname=params.get('dbname', 'quantdinger'),
109138
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})"
110156
)
111-
logger.info(f"PostgreSQL connection pool created: {params.get('host')}:{params.get('port')}/{params.get('dbname')}")
112157
except Exception as e:
113158
logger.error(f"Failed to create PostgreSQL connection pool: {e}")
114159
raise
115160

116161
return _connection_pool
117162

118163

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+
119237
class PostgresCursor:
120238
"""PostgreSQL cursor wrapper with placeholder conversion for backward compatibility"""
121239

@@ -214,62 +332,65 @@ def rollback(self):
214332
self._conn.rollback()
215333

216334
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+
"""
218338
if self._pool and self._conn:
219339
try:
220-
self._pool.putconn(self._conn)
340+
broken = bool(getattr(self._conn, "closed", 0))
341+
self._pool.putconn(self._conn, close=broken)
221342
except Exception as e:
222343
logger.warning(f"Failed to return connection to pool: {e}")
223344

224345

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-
235346
@contextmanager
236347
def get_pg_connection():
237348
"""
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.
239354
"""
240-
pool = _get_connection_pool()
355+
pg_pool = _get_connection_pool()
241356
conn = None
357+
broken = False
242358
try:
243-
conn = pool.getconn()
244-
_ensure_session_utc(conn)
359+
conn = _acquire_conn_with_wait(pg_pool)
245360
pg_conn = PostgresConnection(conn)
246361
yield pg_conn
247362
except Exception as e:
248-
if conn:
363+
if conn is not None:
249364
try:
250365
conn.rollback()
251366
except Exception:
252367
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
254372
error_msg = str(e) if e else repr(e)
255373
error_type = type(e).__name__
256374
logger.error(f"PostgreSQL operation error ({error_type}): {error_msg}", exc_info=True)
257375
raise
258376
finally:
259-
if conn:
377+
if conn is not None:
260378
try:
261-
pool.putconn(conn)
379+
pg_pool.putconn(conn, close=broken)
262380
except Exception:
263381
pass
264382

265383

266384
def get_pg_connection_sync() -> PostgresConnection:
267385
"""
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.
269391
"""
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)
273394
return PostgresConnection(conn)
274395

275396

‎backend_api_python/env.example‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,28 @@ DATABASE_URL=postgresql://quantdinger:quantdinger123@postgres:5432/quantdinger
2222
FRONTEND_URL=http://localhost:8888
2323
ENABLE_REGISTRATION=true
2424

25+
# =========================
26+
# Database connection pool (psycopg2 ThreadedConnectionPool)
27+
# =========================
28+
# Tune these if you see `psycopg2.pool.PoolError: connection pool exhausted`
29+
# or if you run many trading bots / portfolios concurrently.
30+
# Make sure PG `max_connections` (docker-compose: PG_MAX_CONNECTIONS) is
31+
# comfortably larger than DB_POOL_MAX.
32+
DB_POOL_MIN=5
33+
DB_POOL_MAX=50
34+
DB_POOL_ACQUIRE_TIMEOUT=10
35+
DB_POOL_HEALTH_CHECK=true
36+
37+
# Route-level parallel fetch executors. Each worker may hold one DB
38+
# connection, so keep MARKET_EXECUTOR_WORKERS + PORTFOLIO_EXECUTOR_WORKERS
39+
# well below DB_POOL_MAX.
40+
MARKET_EXECUTOR_WORKERS=6
41+
PORTFOLIO_EXECUTOR_WORKERS=3
42+
43+
# Gunicorn worker/thread model
44+
GUNICORN_WORKERS=1
45+
GUNICORN_THREADS=8
46+
2547
# =========================
2648
# AI / LLM (choose one provider)
2749
# =========================

‎docker-compose.yml‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,15 @@ services:
3333
POSTGRES_USER: ${POSTGRES_USER:-quantdinger}
3434
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-quantdinger123}
3535
TZ: ${TZ:-Asia/Shanghai}
36+
# Raise PostgreSQL's max_connections so the backend pool (default
37+
# DB_POOL_MAX=50) plus psql/admin connections never exceed PG's own
38+
# limit. PG default is 100; 150 gives headroom for future scaling.
39+
command:
40+
- "postgres"
41+
- "-c"
42+
- "max_connections=${PG_MAX_CONNECTIONS:-150}"
43+
- "-c"
44+
- "shared_buffers=${PG_SHARED_BUFFERS:-256MB}"
3645
volumes:
3746
- postgres_data:/var/lib/postgresql/data
3847
- ./backend_api_python/migrations/init.sql:/docker-entrypoint-initdb.d/01-init.sql
@@ -96,6 +105,19 @@ services:
96105
- REDIS_HOST=redis
97106
- REDIS_PORT=6379
98107
- CACHE_ENABLED=true
108+
# Database connection pool tuning (see app/utils/db_postgres.py).
109+
# Defaults are safe for ~50 concurrent DB users; raise if you run many
110+
# bots or see `connection pool exhausted` errors.
111+
- DB_POOL_MIN=${DB_POOL_MIN:-5}
112+
- DB_POOL_MAX=${DB_POOL_MAX:-50}
113+
- DB_POOL_ACQUIRE_TIMEOUT=${DB_POOL_ACQUIRE_TIMEOUT:-10}
114+
- DB_POOL_HEALTH_CHECK=${DB_POOL_HEALTH_CHECK:-true}
115+
# Route-level executor tuning (each worker may use one DB connection).
116+
- MARKET_EXECUTOR_WORKERS=${MARKET_EXECUTOR_WORKERS:-6}
117+
- PORTFOLIO_EXECUTOR_WORKERS=${PORTFOLIO_EXECUTOR_WORKERS:-3}
118+
# Gunicorn concurrency (threads within the single worker process).
119+
- GUNICORN_WORKERS=${GUNICORN_WORKERS:-1}
120+
- GUNICORN_THREADS=${GUNICORN_THREADS:-8}
99121
networks:
100122
- quantdinger-network
101123
healthcheck:

‎docs/CHANGELOG.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ This document records version updates, new features, bug fixes, and database mig
44

55
---
66

7-
## V3.0.4 (2026-04-11) — 多语言文件全量补齐(AI 自动翻译)
7+
## V3.0.2 (2026-04-11) — 多语言文件全量补齐(AI 自动翻译)
88

99
### 🌍 i18n
1010

@@ -52,7 +52,7 @@ This document records version updates, new features, bug fixes, and database mig
5252

5353
---
5454

55-
## V3.0.3 (2026-04-11) — 交易机器人全链路修复(Grid / Martingale / Trend / DCA)
55+
## V3.0.2 (2026-04-11) — 交易机器人全链路修复(Grid / Martingale / Trend / DCA)
5656

5757
### 🐛 Bug Fixes — 交易机器人
5858

0 commit comments

Comments
 (0)