[NOTICKET][DB] perf(query): DB2 - pooled engine cache for user DBs
Browse filesDbExecutor 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 +1 -1
- src/database_client/engine.py +168 -0
- src/query/executor/db.py +21 -13
|
@@ -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** (
|
| 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 | `[ ]` |
|
|
@@ -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()
|
|
@@ -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(
|
|
|
|
|
|
|
| 89 |
timeout=_QUERY_TIMEOUT_SECONDS,
|
| 90 |
)
|
| 91 |
|
|
@@ -189,16 +191,22 @@ class DbExecutor(BaseExecutor):
|
|
| 189 |
)
|
| 190 |
|
| 191 |
@staticmethod
|
| 192 |
-
def _run_sync(
|
| 193 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 203 |
-
|
| 204 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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()]
|