"""共用 PostgreSQL 同步連線池 — 關閉 F4 v2 SR-6 deferred。 【為什麼是 sync pool 不是 AsyncConnectionPool?】 ml-service 的 DB 呼叫本來就是 sync psycopg 透過 `asyncio.to_thread` 包起 來的(counterfeit_anchor / valuation_observations / brand_normalizer / xgboost_valuation 訓練 fetch)。改成 async pool 會逼所有 caller 重寫成 async,但 torch forward + joblib.dump 等 CPU-bound 工作仍要 to_thread, 沒有整體淨收益。Sync pool + to_thread 維持當前 async-discipline 不變。 【為什麼集中在 src/db.py 而非各模組各自開?】 `/api/v1/ml/valuation/predict` 帶 image_url 時: 1. counterfeit_anchor.query_anchor_similarity() → psycopg.connect() 一次 2. valuation_observations.record_observation() → psycopg.connect() 一次 兩條 path 平行 await,每次 request 開兩條 TCP 握手 + auth;Neon 的 connection limit 是 25(Free tier),併發 10 user 同時打就會觸發 ECONN / SSL handshake 排隊。Pool 把這個攤銷成 1-5 條長連線。 【閾值如何選?】 - ML_DB_POOL_MIN=1:首個請求不付握手延遲(~80ms 對 Neon) - ML_DB_POOL_MAX=5:counterfeit_anchor + valuation_observations 平行 + brand_normalizer 過期 reload + 偶發 retry buffer,5 對單 worker 足夠 - timeout=10s:超過代表 pool 飽和,fail fast 比排隊好(caller 是 fire-and-forget side-effect,drop 一筆 observation 比卡 event loop 好) 【驅動行為】 - pool.connection() context exit 自動 commit(成功)/ rollback(例外) - 不再呼叫 conn.commit(),原本 valuation_observations 寫入用的 explicit commit 是 `with psycopg.connect()` 雙重保險,pool 拿掉一層 """ from __future__ import annotations import logging import os from collections.abc import Iterator from contextlib import contextmanager from psycopg import Connection from psycopg_pool import ConnectionPool logger = logging.getLogger(__name__) DATABASE_URL = os.environ.get( "DATABASE_URL", "postgresql://wardrobe:wardrobe_dev@localhost:5432/wardrobe_os", ) _POOL_MIN = int(os.environ.get("ML_DB_POOL_MIN", "1")) _POOL_MAX = int(os.environ.get("ML_DB_POOL_MAX", "5")) _POOL_TIMEOUT_S = float(os.environ.get("ML_DB_POOL_TIMEOUT_S", "10.0")) _pool: ConnectionPool | None = None def get_pool() -> ConnectionPool: """Lazy singleton。第一次呼叫建立 + open(eager handshake)。 `open=True` 讓 pool 在初始化期就開 min_size 條連線,第一個 `get_conn()` 不付握手成本。如果 DB 暫時不可用,pool 會自己 retry 背景連線,acquire 時若仍無 conn 會在 timeout 後 raise。 【為什麼 local var 然後才 commit `_pool`?】 `ConnectionPool(..., open=True)` 失敗的話,部分驅動實作會在 `__init__` 把欄位都填好之後才在 `_open()` 階段 raise — 如果直接 `_pool = ConnectionPool(...)`,半破的 instance 已經被 commit 進 singleton,下次 `get_pool()` 看到 non-None 直接回 broken pool, 永遠卡在 `PoolTimeout`。Local var pattern 對齊 feedback memory `feedback_singleton_partial_load_poison.md`(CR conf 92, 8076f1a post-review)。 """ global _pool if _pool is None: pool = ConnectionPool( DATABASE_URL, min_size=_POOL_MIN, max_size=_POOL_MAX, timeout=_POOL_TIMEOUT_S, name="ml-service", open=True, ) _pool = pool return _pool @contextmanager def get_conn() -> Iterator[Connection]: """從 pool 拿一條連線,block 結束自動歸還。 用法: with get_conn() as conn: conn.execute("SELECT ...") 成功退出 context → autocommit;例外 → rollback;連線歸還而非關閉。 取代散落的 `with psycopg.connect(DATABASE_URL) as conn:` pattern。 """ pool = get_pool() with pool.connection() as conn: yield conn def close_pool() -> None: """Drain + close pool。FastAPI lifespan teardown 呼叫。 幂等:呼叫多次不會 raise(pool 內部記 closed state)。close_pool 之後 下次 get_pool 會建一個新的(測試用得到,prod 不應該發生)。 """ global _pool if _pool is not None: try: _pool.close() except Exception as exc: logger.warning("ml-service db pool close failed: %s", exc) _pool = None