diff --git a/.env.example b/.env.example index 193b782ff..be8ea45ec 100644 --- a/.env.example +++ b/.env.example @@ -276,6 +276,8 @@ DB_POOL_MIN=5 DB_POOL_MAX=auto DB_POOL_ACQUIRE_TIMEOUT=10 DB_POOL_HEALTH_CHECK=true +DB_IDLE_IN_TRANSACTION_TIMEOUT_MS=60000 +DB_LOCK_TIMEOUT_MS=10000 DB_POOL_AUTO_CAP=true DB_POOL_AUTO_DEFAULT_MAX=50 DB_POOL_RESERVE_FOR_OTHER_CLIENTS=20 diff --git a/backend_api_python/app/services/billing_service.py b/backend_api_python/app/services/billing_service.py index 66dad4f8e..7d88655ad 100644 --- a/backend_api_python/app/services/billing_service.py +++ b/backend_api_python/app/services/billing_service.py @@ -89,7 +89,6 @@ def get_user_vip_status(self, user_id: int) -> Tuple[bool, Optional[datetime]]: with get_db_connection() as db: cur = db.cursor() # Ensure lifetime membership monthly credits are granted (best-effort, silent on failure). - self._ensure_membership_schema_best_effort(cur) self._grant_lifetime_monthly_credits_best_effort(cur, user_id) try: db.commit() @@ -124,7 +123,6 @@ def get_membership_plans(self, include_inactive: bool = False) -> Dict[str, Any] try: with get_db_connection() as db: cur = db.cursor() - self._ensure_plan_schema(cur) self._seed_default_plans(cur) sql = """ SELECT code, name, description, price_usd, duration_days, @@ -187,7 +185,6 @@ def save_membership_plans(self, items) -> Tuple[bool, str, Dict[str, Any]]: try: with get_db_connection() as db: cur = db.cursor() - self._ensure_plan_schema(cur) for row in normalized: cur.execute( """ @@ -232,7 +229,6 @@ def delete_membership_plan(self, code: str) -> Tuple[bool, str, Dict[str, Any]]: try: with get_db_connection() as db: cur = db.cursor() - self._ensure_plan_schema(cur) cur.execute("SELECT code, is_active FROM qd_billing_plans WHERE code=? FOR UPDATE", (code,)) plan = cur.fetchone() or {} if not plan: @@ -273,32 +269,6 @@ def _serialize_plan(row: Dict[str, Any]) -> Dict[str, Any]: "stripe_price_id": row.get("stripe_price_id") or "", } - def _ensure_plan_schema(self, cur) -> None: - cur.execute( - """ - CREATE TABLE IF NOT EXISTS qd_billing_plans ( - code VARCHAR(64) PRIMARY KEY, - name VARCHAR(120) NOT NULL, - description TEXT NOT NULL DEFAULT '', - price_usd DECIMAL(12,2) NOT NULL DEFAULT 0, - duration_days INTEGER NOT NULL DEFAULT 0, - credits_once INTEGER NOT NULL DEFAULT 0, - credits_monthly INTEGER NOT NULL DEFAULT 0, - strategy_limit INTEGER NOT NULL DEFAULT 10, - referral_eligible BOOLEAN NOT NULL DEFAULT FALSE, - is_lifetime BOOLEAN NOT NULL DEFAULT FALSE, - is_active BOOLEAN NOT NULL DEFAULT TRUE, - is_popular BOOLEAN NOT NULL DEFAULT FALSE, - sort_order INTEGER NOT NULL DEFAULT 0, - stripe_price_id VARCHAR(255) NOT NULL DEFAULT '', - created_at TIMESTAMP DEFAULT NOW(), - updated_at TIMESTAMP DEFAULT NOW() - ) - """ - ) - cur.execute("ALTER TABLE qd_billing_plans ADD COLUMN IF NOT EXISTS strategy_limit INTEGER NOT NULL DEFAULT 10") - cur.execute("ALTER TABLE qd_billing_plans ADD COLUMN IF NOT EXISTS referral_eligible BOOLEAN NOT NULL DEFAULT FALSE") - def _seed_default_plans(self, cur) -> None: cur.execute("SELECT COUNT(*) AS count FROM qd_billing_plans") if int((cur.fetchone() or {}).get("count") or 0) > 0: @@ -342,13 +312,8 @@ def purchase_membership( try: with get_db_connection() as db: cur = db.cursor() - self._ensure_membership_schema_best_effort(cur) - if record_membership_order: - self._ensure_membership_orders_table_best_effort(cur) - fulfillment_ref = (fulfillment_ref or "").strip() if fulfillment_ref: - self._ensure_fulfillment_schema(cur) cur.execute( """ INSERT INTO qd_membership_fulfillments (provider_ref, user_id, plan, created_at) @@ -525,49 +490,6 @@ def purchase_membership( logger.error(f"purchase_membership failed: {e}", exc_info=True) return False, f"error:{str(e)}", {} - def _ensure_membership_schema_best_effort(self, cur): - """Best-effort schema upgrade for membership fields on qd_users.""" - try: - # vip_plan / vip_is_lifetime / vip_monthly_credits_last_grant - cur.execute("ALTER TABLE qd_users ADD COLUMN IF NOT EXISTS vip_plan VARCHAR(64) DEFAULT ''") - cur.execute("ALTER TABLE qd_users ALTER COLUMN vip_plan TYPE VARCHAR(64)") - cur.execute("ALTER TABLE qd_users ADD COLUMN IF NOT EXISTS vip_is_lifetime BOOLEAN DEFAULT FALSE") - cur.execute("ALTER TABLE qd_users ADD COLUMN IF NOT EXISTS vip_monthly_credits_last_grant TIMESTAMP") - except Exception: - # Ignore schema upgrade failures (e.g., insufficient privileges) - pass - - def _ensure_membership_orders_table_best_effort(self, cur): - """Best-effort create membership orders table (legacy; optional row per mock checkout).""" - try: - cur.execute( - """ - CREATE TABLE IF NOT EXISTS qd_membership_orders ( - id SERIAL PRIMARY KEY, - user_id INTEGER NOT NULL, - plan VARCHAR(64) NOT NULL, - price_usd DECIMAL(10,2) DEFAULT 0, - status VARCHAR(20) DEFAULT 'paid', - created_at TIMESTAMP DEFAULT NOW(), - paid_at TIMESTAMP - ) - """ - ) - except Exception: - pass - - def _ensure_fulfillment_schema(self, cur): - cur.execute( - """ - CREATE TABLE IF NOT EXISTS qd_membership_fulfillments ( - provider_ref VARCHAR(255) PRIMARY KEY, - user_id INTEGER NOT NULL, - plan VARCHAR(64) NOT NULL, - created_at TIMESTAMP DEFAULT NOW() - ) - """ - ) - def _add_credits_in_tx(self, cur, user_id: int, amount: int, action: str, remark: str, reference_id: str = ''): """Add credits within an existing DB transaction and write qd_credits_log.""" try: @@ -600,7 +522,6 @@ def _grant_lifetime_monthly_credits_best_effort(self, cur, user_id: int): return # Reuse the caller's transaction. Opening another pooled connection # here can deadlock a small deployment when all connections are busy. - self._ensure_plan_schema(cur) self._seed_default_plans(cur) cur.execute( "SELECT credits_monthly FROM qd_billing_plans WHERE code = ?", diff --git a/backend_api_python/app/services/user_preferences.py b/backend_api_python/app/services/user_preferences.py index edad4813d..64fa12789 100644 --- a/backend_api_python/app/services/user_preferences.py +++ b/backend_api_python/app/services/user_preferences.py @@ -15,20 +15,7 @@ def ensure_chart_templates_column() -> None: - """Add qd_users.chart_templates when upgrading existing databases.""" - try: - with get_db_connection() as db: - cur = db.cursor() - cur.execute( - """ - ALTER TABLE qd_users - ADD COLUMN IF NOT EXISTS chart_templates TEXT DEFAULT '' - """ - ) - db.commit() - cur.close() - except Exception as exc: - logger.warning("ensure chart_templates column skipped: %s", exc) + """Compatibility hook; canonical schema changes run in migrations.""" def get_notification_settings(user_id: int) -> dict | None: diff --git a/backend_api_python/app/services/user_service.py b/backend_api_python/app/services/user_service.py index 1743d170d..5f7ff52dd 100644 --- a/backend_api_python/app/services/user_service.py +++ b/backend_api_python/app/services/user_service.py @@ -92,36 +92,8 @@ def normalize_user_record(cls, user: Optional[Dict[str, Any]]) -> Optional[Dict[ return normalized def ensure_password_changed_column(self) -> None: - """Add password_changed_at if missing (idempotent).""" - if UserService._password_changed_column_ready: - return - try: - with get_db_connection() as db: - cur = db.cursor() - cur.execute( - """ - SELECT 1 FROM information_schema.columns - WHERE table_name = 'qd_users' AND column_name = 'password_changed_at' - """ - ) - if not cur.fetchone(): - cur.execute( - "ALTER TABLE qd_users ADD COLUMN password_changed_at TIMESTAMP NULL" - ) - # Existing accounts: assume they already passed initial setup. - cur.execute( - """ - UPDATE qd_users - SET password_changed_at = COALESCE(updated_at, created_at, NOW()) - WHERE password_changed_at IS NULL - """ - ) - db.commit() - logger.info("Added password_changed_at column to qd_users (existing rows backfilled)") - cur.close() - UserService._password_changed_column_ready = True - except Exception as e: - logger.warning(f"ensure_password_changed_column failed: {e}") + """Compatibility hook; canonical schema changes run in migrations.""" + UserService._password_changed_column_ready = True def mark_password_changed(self, user_id: int) -> None: """Record that the user has set a non-initial password.""" @@ -1245,27 +1217,28 @@ def ensure_admin_exists(self): cur.execute("SELECT COUNT(*) as count FROM qd_users") count = cur.fetchone()['count'] cur.close() - - if count == 0: - # Create admin using env credentials - from app.config.settings import Config - admin_user = os.getenv('ADMIN_USER', Config.ADMIN_USER) - admin_password = os.getenv('ADMIN_PASSWORD', Config.ADMIN_PASSWORD) - admin_email = os.getenv('ADMIN_EMAIL', 'admin@example.com') - - self.create_user({ - 'username': admin_user, - 'password': admin_password, - 'email': admin_email, - 'nickname': 'Administrator', - 'role': 'admin', - 'status': 'active', - 'email_verified': True # Admin email is pre-verified - }) - logger.info(f"Created admin user: {admin_user} ({admin_email})") - else: - self.sync_bootstrap_admin_credentials_from_env() - self.sync_admin_email_from_config(overwrite_existing=False) + + # Do not call helpers that acquire another pooled connection while + # the startup read transaction is still open. + if count == 0: + from app.config.settings import Config + admin_user = os.getenv('ADMIN_USER', Config.ADMIN_USER) + admin_password = os.getenv('ADMIN_PASSWORD', Config.ADMIN_PASSWORD) + admin_email = os.getenv('ADMIN_EMAIL', 'admin@example.com') + + self.create_user({ + 'username': admin_user, + 'password': admin_password, + 'email': admin_email, + 'nickname': 'Administrator', + 'role': 'admin', + 'status': 'active', + 'email_verified': True + }) + logger.info(f"Created admin user: {admin_user} ({admin_email})") + else: + self.sync_bootstrap_admin_credentials_from_env() + self.sync_admin_email_from_config(overwrite_existing=False) except Exception as e: logger.error(f"ensure_admin_exists failed: {e}") diff --git a/backend_api_python/app/utils/db_postgres.py b/backend_api_python/app/utils/db_postgres.py index cc28bcdeb..c3e130a02 100644 --- a/backend_api_python/app/utils/db_postgres.py +++ b/backend_api_python/app/utils/db_postgres.py @@ -9,6 +9,8 @@ DB_POOL_MAX maxconn or "auto" default auto DB_POOL_ACQUIRE_TIMEOUT seconds to wait on exhaustion default 10 DB_POOL_HEALTH_CHECK "true" / "false" default "true" + DB_IDLE_IN_TRANSACTION_TIMEOUT_MS server-side timeout default 60000 + DB_LOCK_TIMEOUT_MS server-side lock wait timeout default 10000 """ import os import time @@ -81,6 +83,8 @@ def _env_bool(key: str, default: bool) -> bool: DB_POOL_HEALTH_CHECK = _env_bool("DB_POOL_HEALTH_CHECK", True) DB_POOL_AUTO_CAP = _env_bool("DB_POOL_AUTO_CAP", True) DB_POOL_RESERVE_FOR_OTHER_CLIENTS = _env_int("DB_POOL_RESERVE_FOR_OTHER_CLIENTS", 20) +DB_IDLE_IN_TRANSACTION_TIMEOUT_MS = _env_int("DB_IDLE_IN_TRANSACTION_TIMEOUT_MS", 60_000) +DB_LOCK_TIMEOUT_MS = _env_int("DB_LOCK_TIMEOUT_MS", 10_000) DB_APPLICATION_NAME = os.getenv("DB_APPLICATION_NAME", "quantdinger_api").strip() or "quantdinger_api" @@ -172,7 +176,11 @@ def _get_connection_pool(): # "idle in transaction" state when no explicit commit/rollback # followed). keepalives keep dead sockets from lingering in # the pool when the PG side or a NAT drops them. - options="-c timezone=UTC", + options=( + "-c timezone=UTC " + f"-c idle_in_transaction_session_timeout={DB_IDLE_IN_TRANSACTION_TIMEOUT_MS} " + f"-c lock_timeout={DB_LOCK_TIMEOUT_MS}" + ), keepalives=1, keepalives_idle=30, keepalives_interval=10, @@ -317,6 +325,23 @@ def _pool_max_config_label() -> str: return str(DB_POOL_MAX_CONFIGURED) if DB_POOL_MAX_CONFIGURED is not None else "auto" +def _rollback_open_transaction(conn) -> bool: + """Return a pooled connection to PostgreSQL's idle transaction state.""" + if conn is None or getattr(conn, "closed", 0): + return False + + try: + status_reader = getattr(conn, "get_transaction_status", None) + if callable(status_reader): + idle_status = psycopg2.extensions.TRANSACTION_STATUS_IDLE + if status_reader() == idle_status: + return True + conn.rollback() + return True + except Exception: + return False + + def _is_connection_healthy(conn) -> bool: """Quick health check: make sure the connection is not closed and can actually round-trip a trivial query. Used only when DB_POOL_HEALTH_CHECK @@ -327,14 +352,28 @@ def _is_connection_healthy(conn) -> bool: # psycopg2 sets .closed to nonzero when the connection is closed. if getattr(conn, "closed", 0): return False + if not _rollback_open_transaction(conn): + return False + + probe_ok = False + cur = None try: cur = conn.cursor() cur.execute("SELECT 1") cur.fetchone() - cur.close() - return True + probe_ok = True except Exception: - return False + probe_ok = False + finally: + if cur is not None: + try: + cur.close() + except Exception: + probe_ok = False + + # psycopg2 starts a transaction even for SELECT 1. Never let the health + # probe itself put a pooled connection into idle-in-transaction state. + return _rollback_open_transaction(conn) and probe_ok def _acquire_conn_with_wait(pg_pool): @@ -413,7 +452,11 @@ def _acquire_conn_with_wait(pg_pool): backoff = min(backoff * 2, 0.5) continue - if DB_POOL_HEALTH_CHECK and not _is_connection_healthy(conn): + connection_ready = _rollback_open_transaction(conn) + if connection_ready and DB_POOL_HEALTH_CHECK: + connection_ready = _is_connection_healthy(conn) + + if not connection_ready: # Drop the dead connection and let the pool create a new one on # next attempt. putconn(close=True) asks the pool to discard it. try: @@ -658,10 +701,14 @@ def close(self): """Return connection to pool. Broken connections are discarded so we don't poison the pool with closed sockets. """ - if self._pool and self._conn: + conn = self._conn + self._conn = None + if self._pool and conn: try: - broken = bool(getattr(self._conn, "closed", 0)) - self._pool.putconn(self._conn, close=broken) + broken = bool(getattr(conn, "closed", 0)) + if not broken and not _rollback_open_transaction(conn): + broken = True + self._pool.putconn(conn, close=broken) _observe_pool_stats(self._pool) except Exception as e: logger.warning(f"Failed to return connection to pool: {e}") @@ -772,6 +819,8 @@ def get_pg_connection(): finally: if conn is not None: try: + if not broken and not _rollback_open_transaction(conn): + broken = True pg_pool.putconn(conn, close=broken) _observe_pool_stats(pg_pool) except Exception: diff --git a/backend_api_python/env.example b/backend_api_python/env.example index 4f7f44358..46b848e1a 100644 --- a/backend_api_python/env.example +++ b/backend_api_python/env.example @@ -153,6 +153,8 @@ DB_POOL_MIN=5 DB_POOL_MAX=auto DB_POOL_ACQUIRE_TIMEOUT=10 DB_POOL_HEALTH_CHECK=true +DB_IDLE_IN_TRANSACTION_TIMEOUT_MS=60000 +DB_LOCK_TIMEOUT_MS=10000 DB_POOL_AUTO_CAP=true DB_POOL_AUTO_DEFAULT_MAX=50 DB_POOL_RESERVE_FOR_OTHER_CLIENTS=20 diff --git a/backend_api_python/tests/test_database_lock_safety.py b/backend_api_python/tests/test_database_lock_safety.py new file mode 100644 index 000000000..dcfcfd3fd --- /dev/null +++ b/backend_api_python/tests/test_database_lock_safety.py @@ -0,0 +1,76 @@ +from contextlib import contextmanager +from pathlib import Path + +from app.services import user_service as user_service_module + + +def test_admin_bootstrap_releases_count_transaction_before_nested_helpers(monkeypatch): + connection_open = False + + class Cursor: + @staticmethod + def execute(_query): + return None + + @staticmethod + def fetchone(): + return {"count": 1} + + @staticmethod + def close(): + return None + + class Connection: + @staticmethod + def cursor(): + return Cursor() + + @contextmanager + def connection(): + nonlocal connection_open + connection_open = True + try: + yield Connection() + finally: + connection_open = False + + service = user_service_module.UserService() + monkeypatch.setattr(user_service_module, "get_db_connection", connection) + monkeypatch.setattr(service, "ensure_password_changed_column", lambda: None) + + calls = [] + + def sync_credentials(): + assert connection_open is False + calls.append("credentials") + + def sync_email(*, overwrite_existing): + assert connection_open is False + assert overwrite_existing is False + calls.append("email") + + monkeypatch.setattr(service, "sync_bootstrap_admin_credentials_from_env", sync_credentials) + monkeypatch.setattr(service, "sync_admin_email_from_config", sync_email) + + service.ensure_admin_exists() + + assert calls == ["credentials", "email"] + + +def test_central_user_table_has_no_runtime_ddl(): + app_root = Path(__file__).resolve().parents[1] / "app" + offenders = [] + for path in app_root.rglob("*.py"): + source = path.read_text(encoding="utf-8") + if "ALTER TABLE qd_users" in source: + offenders.append(str(path.relative_to(app_root))) + + assert offenders == [] + + +def test_billing_schema_is_migration_owned(): + service_path = Path(__file__).resolve().parents[1] / "app" / "services" / "billing_service.py" + source = service_path.read_text(encoding="utf-8") + + assert "ALTER TABLE" not in source + assert "CREATE TABLE" not in source diff --git a/backend_api_python/tests/test_db_pool_limits.py b/backend_api_python/tests/test_db_pool_limits.py index 69c2d1048..7e08cc820 100644 --- a/backend_api_python/tests/test_db_pool_limits.py +++ b/backend_api_python/tests/test_db_pool_limits.py @@ -174,3 +174,27 @@ def test_pool_metrics_publish_counts_and_utilization(monkeypatch): assert connections.values[(('application', 'test-api'), ('state', 'opened'))] == 5 assert connections.values[(('application', 'test-api'), ('state', 'max'))] == 77 assert utilization.values[(('application', 'test-api'),)] == 3 / 77 + + +def test_pool_connections_enforce_transaction_and_lock_timeouts(monkeypatch): + captured = {} + fake_pool = _FakePool() + + def create_pool(**kwargs): + captured.update(kwargs) + return fake_pool + + monkeypatch.setattr(db_postgres, "_connection_pool", None) + monkeypatch.setattr(db_postgres, "_resolve_effective_pool_limits", lambda _params: (2, 8)) + monkeypatch.setattr(db_postgres.pool, "ThreadedConnectionPool", create_pool) + monkeypatch.setattr(db_postgres, "_observe_pool_stats", lambda _pool: None) + monkeypatch.setattr(db_postgres, "DB_IDLE_IN_TRANSACTION_TIMEOUT_MS", 60_000) + monkeypatch.setattr(db_postgres, "DB_LOCK_TIMEOUT_MS", 10_000) + monkeypatch.setenv("DATABASE_URL", "postgresql://user:password@localhost:5432/quantdinger") + + assert db_postgres._get_connection_pool() is fake_pool + assert captured["options"] == ( + "-c timezone=UTC " + "-c idle_in_transaction_session_timeout=60000 " + "-c lock_timeout=10000" + ) diff --git a/backend_api_python/tests/test_db_transaction.py b/backend_api_python/tests/test_db_transaction.py index 80e1276d5..4cdbefa32 100644 --- a/backend_api_python/tests/test_db_transaction.py +++ b/backend_api_python/tests/test_db_transaction.py @@ -7,15 +7,57 @@ @pytest.fixture def connection(monkeypatch): - native = SimpleNamespace(commits=0, rollbacks=0) - native.commit = lambda: setattr(native, "commits", native.commits + 1) - native.rollback = lambda: setattr(native, "rollbacks", native.rollbacks + 1) + native = SimpleNamespace( + commits=0, + rollbacks=0, + transaction_status=db.psycopg2.extensions.TRANSACTION_STATUS_IDLE, + ) + + def commit(): + native.commits += 1 + native.transaction_status = db.psycopg2.extensions.TRANSACTION_STATUS_IDLE + + def rollback(): + native.rollbacks += 1 + native.transaction_status = db.psycopg2.extensions.TRANSACTION_STATUS_IDLE + + native.commit = commit + native.rollback = rollback + native.get_transaction_status = lambda: native.transaction_status pool = SimpleNamespace(putconn=lambda *a, **k: None) monkeypatch.setattr(db, "_get_connection_pool", lambda: pool) monkeypatch.setattr(db, "_acquire_conn_with_wait", lambda p: native) return native +def test_read_only_context_rolls_back_before_pool_return(connection): + with db.get_pg_connection(): + connection.transaction_status = db.psycopg2.extensions.TRANSACTION_STATUS_INTRANS + + assert connection.rollbacks == 1 + assert connection.transaction_status == db.psycopg2.extensions.TRANSACTION_STATUS_IDLE + + +def test_health_probe_does_not_leave_transaction_open(connection): + class Cursor: + def execute(self, _query): + connection.transaction_status = db.psycopg2.extensions.TRANSACTION_STATUS_INTRANS + + @staticmethod + def fetchone(): + return (1,) + + @staticmethod + def close(): + return None + + connection.cursor = Cursor + + assert db._is_connection_healthy(connection) is True + assert connection.rollbacks == 1 + assert connection.transaction_status == db.psycopg2.extensions.TRANSACTION_STATUS_IDLE + + def test_nested_helper_commits_wait_for_owner(connection): with db.get_pg_transaction() as transaction: with db.get_pg_connection() as helper: diff --git a/docker-compose.yml b/docker-compose.yml index dec46de0f..54df15489 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -69,6 +69,8 @@ x-worker-environment: &worker-environment DB_POOL_MAX: ${WORKER_DB_POOL_MAX:-12} DB_POOL_ACQUIRE_TIMEOUT: ${DB_POOL_ACQUIRE_TIMEOUT:-10} DB_POOL_HEALTH_CHECK: ${DB_POOL_HEALTH_CHECK:-true} + DB_IDLE_IN_TRANSACTION_TIMEOUT_MS: ${DB_IDLE_IN_TRANSACTION_TIMEOUT_MS:-60000} + DB_LOCK_TIMEOUT_MS: ${DB_LOCK_TIMEOUT_MS:-10000} DB_APPLICATION_NAME: quantdinger-worker SKIP_AUTO_MIGRATE: "true" MARKET_CATALOG_AUTO_SYNC: ${MARKET_CATALOG_AUTO_SYNC:-true} @@ -318,6 +320,8 @@ services: - DB_POOL_MAX=${DB_POOL_MAX:-12} - DB_POOL_ACQUIRE_TIMEOUT=${DB_POOL_ACQUIRE_TIMEOUT:-10} - DB_POOL_HEALTH_CHECK=${DB_POOL_HEALTH_CHECK:-true} + - DB_IDLE_IN_TRANSACTION_TIMEOUT_MS=${DB_IDLE_IN_TRANSACTION_TIMEOUT_MS:-60000} + - DB_LOCK_TIMEOUT_MS=${DB_LOCK_TIMEOUT_MS:-10000} - DB_APPLICATION_NAME=quantdinger-api # Route-level executor tuning (each worker may use one DB connection). - MARKET_EXECUTOR_WORKERS=${MARKET_EXECUTOR_WORKERS:-6}