Rifqi Hafizuddin Claude Opus 4.8 commited on
Commit
4c911cb
·
1 Parent(s): 9a539dc

[NOTICKET][DB] perf(query): DB2 - pooled engine cache for user DBs

Browse files

DbExecutor built a fresh engine and disposed it on every query, paying a full
TCP+TLS+auth handshake per call (~6-8s measured, dominating slow-path latency).
engine_scope's connect-once-dispose is right for ingestion, wrong for the query
path.

- New UserEngineCache (src/database_client/engine.py): process-wide cache of pooled
engines keyed by client_id + a hash of the decrypted creds (rotation auto-
invalidates the key). Bounded LRU (50) + 600s idle TTL; pool_pre_ping +
pool_recycle=300; small pool (1 + 2 overflow). invalidate(client_id) disposes
eagerly. Scope: postgres/supabase only; other db_types keep the legacy per-call
path, so nothing regresses.
- DbExecutor._run_sync reuses the warm connection (no per-query SET, no dispose).
- Read-only + statement_timeout moved from libpq startup `options` to a per-
connection `connect` event: Neon's transaction pooler rejects
default_transaction_read_only as a startup parameter (caught in a live run) but
accepts it as a SET. Best-effort; authoritative read-only is the SELECT-only
compiler + sqlglot guard. The per-request ownership/active check is unchanged.

Live-measured: warm query_structured 6.6-9.4s -> ~2.5s. First query per process
still cold (DB3 pre-connect would hide it). Tests: tests/database_client/test_engine.py.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

PROGRESS.md CHANGED
@@ -41,7 +41,7 @@ Verified against code before logging. Severity: **critical** / important / nice-
41
  | R2 | **Always compile a LIMIT** — `sql.py` now emits a bound for every query: explicit limit honored (clamped to `MAX_RESULT_ROWS=10000`), unbounded queries get `LIMIT cap+1` so an unbounded SELECT can't stream a whole table into memory. `CompiledSql.row_cap` carries the cap; `DbExecutor` caps + flags truncation from it (dropped its own `_ROW_HARD_CAP`). Tests updated (`test_sql.py`, +3 cases); `S608` restored to `tests/**` ruff ignore (was dropped). | **critical** | DB | `[x]` |
42
  | R3 | **Commit `tests/` + minimal CI** — `tests/` is gitignored; the 200+ tests cited as done exist only on laptops (already caused rename rot). GitHub origin carries tests; HF Space gets the Docker build (already doesn't COPY tests). | **critical (process)** | shared | `[ ]` |
43
  | DB1 | **In-memory `describe_source`** (request-scoped `MemoizingCatalogReader`, `reader.py`) + **LLM-client hoist** (shared module-level `ChatHandler` in `chat.py`). Measured live: `describe_source` 3.5s→~2.0s (structured read now served from the planner's cached snapshot; only the unstructured read remains a round-trip), catalog reads/request ~5→~2. External `query_structured` handshake unchanged (DB2's job) so total slow path is ~flat until DB2. Tests: `tests/catalog/test_reader.py`. | important | agent | `[x]` |
44
- | DB2 | **Keyed engine cache** (LRU, small pool, `pool_recycle`, `pool_pre_ping`) replacing per-call `engine_scope`; read-only + statement_timeout via `connect_args options` (zero SET round-trips, read-only-at-birth); invalidate on client update/delete. Extract to `src/database_client/engine.py`. **Land with/after R1.** | important | DB | `[ ]` |
45
  | DB3 | **Speculative pre-connect** overlapped with the ~4s Planner call (only if DB2 still shows first-touch handshake). | nice-to-have | DB | `[ ]` |
46
  | R4 | **Stream the Assembler + emit TaskRunner progress events** — slow path streams nothing for ~20s (`chat_handler.py:321` single chunk); proxy idle-timeout + UX risk. | important | agent | `[ ]` |
47
  | R5 | **Response cache**: key on `user_id` + catalog version; invalidate on ingest. Today `chat:{room_id}:{message}`, 24h TTL, no user (`chat.py:138`) → cross-room replay + stale answers. | important | B | `[ ]` |
 
41
  | R2 | **Always compile a LIMIT** — `sql.py` now emits a bound for every query: explicit limit honored (clamped to `MAX_RESULT_ROWS=10000`), unbounded queries get `LIMIT cap+1` so an unbounded SELECT can't stream a whole table into memory. `CompiledSql.row_cap` carries the cap; `DbExecutor` caps + flags truncation from it (dropped its own `_ROW_HARD_CAP`). Tests updated (`test_sql.py`, +3 cases); `S608` restored to `tests/**` ruff ignore (was dropped). | **critical** | DB | `[x]` |
42
  | R3 | **Commit `tests/` + minimal CI** — `tests/` is gitignored; the 200+ tests cited as done exist only on laptops (already caused rename rot). GitHub origin carries tests; HF Space gets the Docker build (already doesn't COPY tests). | **critical (process)** | shared | `[ ]` |
43
  | DB1 | **In-memory `describe_source`** (request-scoped `MemoizingCatalogReader`, `reader.py`) + **LLM-client hoist** (shared module-level `ChatHandler` in `chat.py`). Measured live: `describe_source` 3.5s→~2.0s (structured read now served from the planner's cached snapshot; only the unstructured read remains a round-trip), catalog reads/request ~5→~2. External `query_structured` handshake unchanged (DB2's job) so total slow path is ~flat until DB2. Tests: `tests/catalog/test_reader.py`. | important | agent | `[x]` |
44
+ | DB2 | **Keyed engine cache** — `src/database_client/engine.py::UserEngineCache` (process singleton): pooled engines keyed by `client_id + creds-hash` (rotation auto-invalidates), bounded LRU (50) + 600s idle TTL, `pool_pre_ping` + `pool_recycle=300`. `DbExecutor._run_sync` reuses the warm connection instead of `create_engine→connect→dispose` per query (postgres/supabase only; other db_types keep the legacy path — no regression). **Live-measured: warm `query_structured` 6.6–9.4s → ~2.5s** (the residual is the per-call catalog-DB client fetch + pre-ping, not the external handshake). **Finding:** Neon's transaction pooler REJECTS `default_transaction_read_only` as a libpq startup `option` — caught live; moved read-only + statement_timeout to a per-connection `connect` event (best-effort; authoritative read-only is the SELECT-only compiler + sqlglot guard, see R10). Per-request ownership/active check kept. Proceeded ahead of R1 per owner decision (marginal security delta over the existing no-auth state; auth tracked separately). Tests: `tests/database_client/test_engine.py`. First query/process still cold → DB3. | important | DB | `[x]` |
45
  | DB3 | **Speculative pre-connect** overlapped with the ~4s Planner call (only if DB2 still shows first-touch handshake). | nice-to-have | DB | `[ ]` |
46
  | R4 | **Stream the Assembler + emit TaskRunner progress events** — slow path streams nothing for ~20s (`chat_handler.py:321` single chunk); proxy idle-timeout + UX risk. | important | agent | `[ ]` |
47
  | R5 | **Response cache**: key on `user_id` + catalog version; invalidate on ingest. Today `chat:{room_id}:{message}`, 24h TTL, no user (`chat.py:138`) → cross-room replay + stale answers. | important | B | `[ ]` |
src/database_client/engine.py ADDED
@@ -0,0 +1,168 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """UserEngineCache — pooled, reused SQLAlchemy engines for users' external DBs.
2
+
3
+ The query path (`DbExecutor`) previously built a fresh engine and tore it down on
4
+ EVERY query (`db_pipeline_service.engine_scope`), paying a full TCP+TLS+auth
5
+ handshake per call (~6-8s measured, dominating slow-path latency). That helper's
6
+ connect-once-then-dispose semantics are correct for the *ingestion* pipeline
7
+ (infrequent, one connection per run) but wrong for the query path (frequent,
8
+ latency-sensitive, repeated to the same DB).
9
+
10
+ This module caches one pooled engine per external DB so connections stay warm
11
+ across queries. Scope: **postgres / supabase only** (the measured case and the
12
+ `schema` source type). Other db_types fall back to the legacy per-call path in
13
+ `DbExecutor`, so nothing regresses.
14
+
15
+ Safety / multi-tenancy:
16
+ - Key = client_id + a hash of the decrypted credentials, so a credential rotation
17
+ produces a new key (the stale engine idle-evicts) — a cached engine never serves
18
+ rotated creds.
19
+ - Read-only + statement_timeout are pinned at connection establishment via libpq
20
+ `options` (read-only-at-birth), so they can't be escaped by a reused pooled
21
+ connection and cost zero per-query round-trips.
22
+ - The caller still re-fetches the DatabaseClient row every query and re-checks
23
+ ownership + `active` status — caching the engine never bypasses authorization.
24
+ - Bounded LRU + idle TTL cap memory / file descriptors / connections held on the
25
+ user's DB. `invalidate(client_id)` disposes eagerly on client update/delete.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import hashlib
31
+ import json
32
+ import threading
33
+ import time
34
+ from collections import OrderedDict
35
+
36
+ from sqlalchemy import URL, create_engine, event
37
+ from sqlalchemy.engine import Engine
38
+
39
+ from src.middlewares.logging import get_logger
40
+
41
+ logger = get_logger("user_engine_cache")
42
+
43
+ _POSTGRES_LIKE = frozenset({"postgres", "supabase"})
44
+ _STATEMENT_TIMEOUT_MS = 30_000
45
+
46
+ # Pool sizing is deliberately small: this is a per-user external DB, often with a
47
+ # low max_connections, and we cache many of them. pool_pre_ping drops dead
48
+ # connections; pool_recycle bounds connection age so a serverless user DB can still
49
+ # autosuspend between bursts.
50
+ _POOL_SIZE = 1
51
+ _MAX_OVERFLOW = 2
52
+ _POOL_RECYCLE_SECONDS = 300
53
+
54
+ # Cache bounds across all users.
55
+ _MAX_ENGINES = 50
56
+ _IDLE_TTL_SECONDS = 600
57
+
58
+
59
+ def _creds_fingerprint(credentials: dict) -> str:
60
+ blob = json.dumps(credentials, sort_keys=True, default=str)
61
+ return hashlib.sha256(blob.encode("utf-8")).hexdigest()[:16]
62
+
63
+
64
+ class UserEngineCache:
65
+ """Process-wide cache of pooled engines for users' external Postgres DBs.
66
+
67
+ Thread-safe: `DbExecutor` runs sync DB work in `asyncio.to_thread` worker
68
+ threads, so concurrent requests can hit this from multiple threads.
69
+ """
70
+
71
+ def __init__(self) -> None:
72
+ # key -> (engine, last_used_monotonic)
73
+ self._engines: OrderedDict[str, tuple[Engine, float]] = OrderedDict()
74
+ self._lock = threading.Lock()
75
+
76
+ def get_engine(self, client_id: str, db_type: str, credentials: dict) -> Engine | None:
77
+ """Return a pooled engine for (client_id, creds), or None if unsupported.
78
+
79
+ None means "not a postgres-like DB" — the caller should use its legacy
80
+ per-call path for those (rare, unmeasured) db_types.
81
+ """
82
+ if db_type not in _POSTGRES_LIKE:
83
+ return None
84
+
85
+ key = f"{client_id}:{_creds_fingerprint(credentials)}"
86
+ now = time.monotonic()
87
+ with self._lock:
88
+ self._evict_idle(now)
89
+ entry = self._engines.get(key)
90
+ if entry is not None:
91
+ self._engines[key] = (entry[0], now)
92
+ self._engines.move_to_end(key)
93
+ return entry[0]
94
+
95
+ engine = self._build_engine(credentials)
96
+ self._engines[key] = (engine, now)
97
+ self._engines.move_to_end(key)
98
+ self._evict_overflow()
99
+ logger.info("user engine created", client_id=client_id, cached=len(self._engines))
100
+ return engine
101
+
102
+ def invalidate(self, client_id: str) -> None:
103
+ """Dispose + drop every cached engine for a client (creds rotated/deleted)."""
104
+ with self._lock:
105
+ stale = [k for k in self._engines if k.startswith(f"{client_id}:")]
106
+ for k in stale:
107
+ engine, _ = self._engines.pop(k)
108
+ engine.dispose()
109
+ if stale:
110
+ logger.info("user engine invalidated", client_id=client_id, disposed=len(stale))
111
+
112
+ # ------------------------------------------------------------------
113
+
114
+ @staticmethod
115
+ def _build_engine(credentials: dict) -> Engine:
116
+ # Mirrors db_pipeline_service.connect()'s postgres URL shape, plus a real pool.
117
+ query = {"sslmode": credentials["ssl_mode"]} if credentials.get("ssl_mode") else {}
118
+ url = URL.create(
119
+ drivername="postgresql+psycopg2",
120
+ username=credentials["username"],
121
+ password=credentials["password"],
122
+ host=credentials["host"],
123
+ port=credentials["port"],
124
+ database=credentials["database"],
125
+ query=query,
126
+ )
127
+ engine = create_engine(
128
+ url,
129
+ pool_size=_POOL_SIZE,
130
+ max_overflow=_MAX_OVERFLOW,
131
+ pool_recycle=_POOL_RECYCLE_SECONDS,
132
+ pool_pre_ping=True,
133
+ )
134
+
135
+ # Apply read-only + statement_timeout once per PHYSICAL connection via a
136
+ # connect event (not per query, so the pooling latency win stays). These are
137
+ # ordinary SET commands, NOT libpq startup `options` — Neon's transaction
138
+ # pooler rejects `default_transaction_read_only` as a startup parameter but
139
+ # accepts it as a SET. Best-effort: the authoritative read-only guarantee is
140
+ # the compiler (SELECT-only) + the sqlglot DML guard; statement_timeout is
141
+ # backed by the executor's asyncio.wait_for. So a failure here must not break
142
+ # the connection.
143
+ @event.listens_for(engine, "connect")
144
+ def _init_session(dbapi_conn, _record): # noqa: ANN001
145
+ try:
146
+ cur = dbapi_conn.cursor()
147
+ cur.execute(f"SET statement_timeout = {_STATEMENT_TIMEOUT_MS}")
148
+ cur.execute("SET default_transaction_read_only = on")
149
+ cur.close()
150
+ except Exception as exc: # noqa: BLE001 — best-effort session hardening
151
+ logger.warning("session init SET failed", error=str(exc))
152
+
153
+ return engine
154
+
155
+ def _evict_idle(self, now: float) -> None:
156
+ stale = [k for k, (_, ts) in self._engines.items() if now - ts > _IDLE_TTL_SECONDS]
157
+ for k in stale:
158
+ engine, _ = self._engines.pop(k)
159
+ engine.dispose()
160
+
161
+ def _evict_overflow(self) -> None:
162
+ while len(self._engines) > _MAX_ENGINES:
163
+ _, (engine, _) = self._engines.popitem(last=False) # LRU = oldest end
164
+ engine.dispose()
165
+
166
+
167
+ # Process-wide singleton consumed by DbExecutor.
168
+ user_engine_cache = UserEngineCache()
src/query/executor/db.py CHANGED
@@ -29,6 +29,7 @@ from sqlalchemy import text
29
 
30
  from ...catalog.models import Catalog, Source
31
  from ...database_client.database_client_service import database_client_service
 
32
  from ...db.postgres.connection import AsyncSessionLocal
33
  from ...middlewares.logging import get_logger
34
  from ...pipeline.db_pipeline import db_pipeline_service
@@ -41,7 +42,6 @@ logger = get_logger("db_executor")
41
 
42
  _QUERY_TIMEOUT_SECONDS = 30
43
  _DBCLIENT_PREFIX = "dbclient://"
44
- _POSTGRES_LIKE = frozenset({"postgres", "supabase"})
45
 
46
 
47
  class DbExecutor(BaseExecutor):
@@ -85,7 +85,9 @@ class DbExecutor(BaseExecutor):
85
  creds = decrypt_credentials_dict(client.credentials)
86
 
87
  columns, rows = await asyncio.wait_for(
88
- asyncio.to_thread(self._run_sync, client.db_type, creds, compiled),
 
 
89
  timeout=_QUERY_TIMEOUT_SECONDS,
90
  )
91
 
@@ -189,16 +191,22 @@ class DbExecutor(BaseExecutor):
189
  )
190
 
191
  @staticmethod
192
- def _run_sync(db_type: str, creds: dict, compiled: CompiledSql) -> tuple[list[str], list[dict]]:
193
- with db_pipeline_service.engine_scope(db_type, creds) as engine:
 
 
 
 
 
 
 
194
  with engine.connect() as conn:
195
- if db_type in _POSTGRES_LIKE:
196
- # session-level read-only + per-statement timeout (ms)
197
- conn.execute(text("SET default_transaction_read_only = on"))
198
- conn.execute(
199
- text(f"SET statement_timeout = {_QUERY_TIMEOUT_SECONDS * 1000}")
200
- )
201
  result = conn.execute(text(compiled.sql), compiled.params)
202
- columns = list(result.keys())
203
- rows = [dict(row) for row in result.mappings()]
204
- return columns, rows
 
 
 
 
 
 
29
 
30
  from ...catalog.models import Catalog, Source
31
  from ...database_client.database_client_service import database_client_service
32
+ from ...database_client.engine import user_engine_cache
33
  from ...db.postgres.connection import AsyncSessionLocal
34
  from ...middlewares.logging import get_logger
35
  from ...pipeline.db_pipeline import db_pipeline_service
 
42
 
43
  _QUERY_TIMEOUT_SECONDS = 30
44
  _DBCLIENT_PREFIX = "dbclient://"
 
45
 
46
 
47
  class DbExecutor(BaseExecutor):
 
85
  creds = decrypt_credentials_dict(client.credentials)
86
 
87
  columns, rows = await asyncio.wait_for(
88
+ asyncio.to_thread(
89
+ self._run_sync, client_id, client.db_type, creds, compiled
90
+ ),
91
  timeout=_QUERY_TIMEOUT_SECONDS,
92
  )
93
 
 
191
  )
192
 
193
  @staticmethod
194
+ def _run_sync(
195
+ client_id: str, db_type: str, creds: dict, compiled: CompiledSql
196
+ ) -> tuple[list[str], list[dict]]:
197
+ engine = user_engine_cache.get_engine(client_id, db_type, creds)
198
+ if engine is not None:
199
+ # Pooled, reused engine (postgres-like). Read-only + statement_timeout
200
+ # are pinned at connection establishment (connect_args options), so no
201
+ # per-query SET round-trips and no dispose — the connection returns to
202
+ # the pool warm for the next query.
203
  with engine.connect() as conn:
 
 
 
 
 
 
204
  result = conn.execute(text(compiled.sql), compiled.params)
205
+ return list(result.keys()), [dict(row) for row in result.mappings()]
206
+
207
+ # Legacy per-call path for non-postgres db_types (connect once, dispose).
208
+ # These never set read-only/timeout before, so behavior is unchanged.
209
+ with db_pipeline_service.engine_scope(db_type, creds) as eng:
210
+ with eng.connect() as conn:
211
+ result = conn.execute(text(compiled.sql), compiled.params)
212
+ return list(result.keys()), [dict(row) for row in result.mappings()]