Skip to content

Commit 691d994

Browse files
committed
fix: address 9 code-review findings in db timeout, keyring, and diagnostics
- Timeout detection: replace string-matching with pgcode/errno checks (psycopg3 pgcode 57014, pymysql errno 3024/1969) to avoid false positives - Separate set_statement_timeout try-block from query execution try-block so config failures are not misclassified as query errors - Reset statement timeout to 0 in finally block so pooled connections don't inherit the limit on subsequent queries - Log warning when interrupt() fails to stop the SQLite thread - Improve keyring failure log message; delete old entry before re-saving - Add _MAX_DIAG_SAMPLE_VALUES constant to replace magic 5 in diagnostics SQL - Document MySQL MAX_EXECUTION_TIME SELECT-only limitation - Add 12 new tests covering the above (322 total, all passing)
1 parent ae83da5 commit 691d994

5 files changed

Lines changed: 347 additions & 54 deletions

File tree

src/open_data_agent/db/connection.py

Lines changed: 11 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,14 @@
77

88
from __future__ import annotations
99

10+
import contextlib
1011
import logging
1112
import os
1213
import re
1314
from pathlib import Path
1415
from typing import Any
1516

17+
import keyring
1618
import yaml
1719

1820
from open_data_agent.config import get_config_dir
@@ -60,43 +62,37 @@ def _load_connections(self) -> dict[str, dict[str, Any]]:
6062
raise ConfigError(f"Failed to parse connections.yaml: {exc}") from exc
6163

6264
def _save_connections(self, connections: dict[str, dict[str, Any]]) -> None:
63-
"""Write connections.yaml atomically with 0o600 permissions."""
65+
"""Write connections.yaml with 0o600 permissions (owner read/write only)."""
6466
self._config_dir.mkdir(parents=True, exist_ok=True)
6567
path = str(self._connections_path)
6668
fd = os.open(path, os.O_CREAT | os.O_WRONLY | os.O_TRUNC, 0o600)
6769
with os.fdopen(fd, "w") as f:
6870
yaml.dump(connections, f, default_flow_style=False)
6971

7072
def _keyring_set(self, name: str, password: str) -> bool:
71-
"""Store password in OS keychain. Returns True on success, False if unavailable."""
73+
"""Store password in OS keychain. Returns True on success, False on failure."""
7274
try:
73-
import keyring
74-
import keyring.errors
75-
7675
keyring.set_password(_KEYRING_SERVICE, name, password)
7776
return True
7877
except Exception as exc: # noqa: BLE001
79-
logger.warning("keyring unavailable — storing password in plaintext: %s", exc)
78+
logger.warning(
79+
"keyring write failed for '%s' — password will be stored in plaintext: %s",
80+
name,
81+
exc,
82+
)
8083
return False
8184

8285
def _keyring_get(self, name: str) -> str | None:
8386
"""Retrieve password from OS keychain, or None if unavailable/not found."""
8487
try:
85-
import keyring
86-
8788
return keyring.get_password(_KEYRING_SERVICE, name)
8889
except Exception: # noqa: BLE001
8990
return None
9091

9192
def _keyring_delete(self, name: str) -> None:
9293
"""Delete keychain entry; silently no-ops if not found or unavailable."""
93-
try:
94-
import keyring
95-
import keyring.errors
96-
94+
with contextlib.suppress(Exception):
9795
keyring.delete_password(_KEYRING_SERVICE, name)
98-
except Exception: # noqa: BLE001
99-
pass
10096

10197
def save_connection(self, name: str, params: dict[str, Any]) -> None:
10298
"""Save a named connection. Raises ConfigError on missing/invalid fields."""
@@ -113,7 +109,7 @@ def save_connection(self, name: str, params: dict[str, Any]) -> None:
113109
stored = dict(params)
114110
password = str(stored.pop("password", ""))
115111

116-
# Try keyring first; fall back to plaintext if unavailable.
112+
self._keyring_delete(name)
117113
if self._keyring_set(name, password):
118114
stored["_keyring"] = True
119115
else:

src/open_data_agent/db/diagnostics.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@
1414

1515
__all__ = ["DiagnosticEngine"]
1616

17+
# Each (col, table) pair fires 2 live queries; cap combinations to bound diagnostic overhead.
18+
_MAX_DIAG_COLUMNS = 4
19+
_MAX_DIAG_TABLES = 2
20+
_MAX_DIAG_SAMPLE_VALUES = 5 # DISTINCT sample values fetched per (col, table) pair
21+
1722
logger = logging.getLogger("open_data_agent.db.diagnostics")
1823

1924
_TABLE_PATTERN = re.compile(
@@ -91,14 +96,14 @@ def diagnose(self, sql: str, result: QueryResult) -> str:
9196
lines.append(f"[diagnostic] Table '{table}': could not count rows.")
9297

9398
filter_cols = _extract_filter_columns(sql)
94-
for col in filter_cols[:4]: # limit to 4 filter columns
95-
for table in table_names[:2]:
99+
for col in filter_cols[:_MAX_DIAG_COLUMNS]:
100+
for table in table_names[:_MAX_DIAG_TABLES]:
96101
try:
97102
quoted_table = self._adapter.quote_identifier(table)
98103
quoted_col = self._adapter.quote_identifier(col)
99104
cursor = self._conn.execute(
100105
f"SELECT DISTINCT {quoted_col} FROM {quoted_table} "
101-
f"WHERE {quoted_col} IS NOT NULL LIMIT 5"
106+
f"WHERE {quoted_col} IS NOT NULL LIMIT {_MAX_DIAG_SAMPLE_VALUES}"
102107
)
103108
sample_vals = [str(r[0]) for r in cursor.fetchall()]
104109
cursor2 = self._conn.execute(

src/open_data_agent/db/dialect.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,14 @@ def introspect_columns_sql(self, schema: str, table: str) -> str:
6565
def normalize_column_row(self, row: dict[str, Any]) -> NormalizedColumn:
6666
"""Map a dialect-specific introspection row to NormalizedColumn."""
6767

68+
@property
69+
def supports_server_timeout(self) -> bool:
70+
"""True if the dialect supports a DB-level statement timeout (no thread needed)."""
71+
return False
72+
73+
def set_statement_timeout(self, conn: Any, timeout_seconds: int) -> None: # noqa: B027
74+
"""Set a DB-level statement timeout on *conn*. No-op for dialects that don't support it."""
75+
6876

6977
# ─────────────────────────────────────────────────────────────────────────────
7078
# SQLiteAdapter
@@ -211,6 +219,14 @@ def dangerous_patterns(self) -> list[str]:
211219
r"ALTER\s+SYSTEM",
212220
]
213221

222+
@property
223+
def supports_server_timeout(self) -> bool:
224+
return True
225+
226+
def set_statement_timeout(self, conn: Any, timeout_seconds: int) -> None:
227+
"""Set PostgreSQL statement_timeout (milliseconds)."""
228+
conn.execute(f"SET statement_timeout = {timeout_seconds * 1000}")
229+
214230
def introspect_schemas_sql(self) -> str:
215231
return (
216232
"SELECT schema_name "
@@ -322,6 +338,14 @@ def dangerous_patterns(self) -> list[str]:
322338
r"\bGRANT\b",
323339
]
324340

341+
@property
342+
def supports_server_timeout(self) -> bool:
343+
return True
344+
345+
def set_statement_timeout(self, conn: Any, timeout_seconds: int) -> None:
346+
"""Set MySQL MAX_EXECUTION_TIME (ms). NOTE: applies to SELECT only; 0 disables."""
347+
conn.execute(f"SET SESSION MAX_EXECUTION_TIME = {timeout_seconds * 1000}")
348+
325349
def introspect_schemas_sql(self) -> str:
326350
return (
327351
"SELECT schema_name "

src/open_data_agent/db/query.py

Lines changed: 88 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -89,42 +89,14 @@ def execute(self, sql: str, question: str | None = None) -> QueryResult:
8989
columns: list[str] = []
9090
rows: list[tuple[object, ...]] = []
9191

92-
result_holder: dict[str, Any] = {}
93-
exc_holder: list[Exception] = []
94-
95-
def _run() -> None:
96-
try:
97-
cursor = self._conn.execute(sql_with_limit)
98-
result_holder["columns"] = (
99-
[d[0] for d in cursor.description] if cursor.description else []
100-
)
101-
result_holder["rows"] = [tuple(r) for r in cursor.fetchall()]
102-
except Exception as exc:
103-
exc_holder.append(exc)
104-
105-
thread = threading.Thread(target=_run, daemon=True)
106-
thread.start()
107-
thread.join(timeout=timeout_seconds)
108-
109-
if thread.is_alive():
110-
# SQLite: interrupt(); psycopg v3: cancel(); pymysql: no-op (daemon reaped at exit).
111-
with contextlib.suppress(AttributeError):
112-
self._conn.interrupt()
113-
try:
114-
self._conn.cancel()
115-
except AttributeError:
116-
pass
117-
except Exception as _cancel_exc: # noqa: BLE001
118-
logger.debug("conn.cancel() raised: %s", _cancel_exc)
119-
thread.join(timeout=1.0)
120-
error = f"Query timed out after {timeout_seconds}s"
121-
logger.error("Query timed out: %s", sql_with_limit[:200])
122-
elif exc_holder:
123-
error = str(exc_holder[0])
124-
logger.error("Query execution failed: %s", exc_holder[0])
92+
if self._adapter.supports_server_timeout:
93+
columns, rows, error = self._execute_with_server_timeout(
94+
sql_with_limit, timeout_seconds
95+
)
12596
else:
126-
columns = result_holder.get("columns", [])
127-
rows = result_holder.get("rows", [])
97+
columns, rows, error = self._execute_with_thread_timeout(
98+
sql_with_limit, timeout_seconds
99+
)
128100

129101
duration_ms = (time.monotonic() - t0) * 1000
130102

@@ -152,6 +124,87 @@ def _run() -> None:
152124

153125
return result
154126

127+
def _execute_with_server_timeout(
128+
self, sql: str, timeout_seconds: int
129+
) -> tuple[list[str], list[tuple[object, ...]], str | None]:
130+
"""Execute using a DB-level statement timeout (PostgreSQL, MySQL)."""
131+
try:
132+
self._adapter.set_statement_timeout(self._conn, timeout_seconds)
133+
except Exception as exc: # noqa: BLE001
134+
logger.warning(
135+
"Failed to set DB-level statement timeout (%ss): %s — "
136+
"query will proceed without server-side timeout",
137+
timeout_seconds,
138+
exc,
139+
)
140+
141+
try:
142+
cursor = self._conn.execute(sql)
143+
cols = [d[0] for d in cursor.description] if cursor.description else []
144+
fetched = [tuple(r) for r in cursor.fetchall()]
145+
return cols, fetched, None
146+
except Exception as exc: # noqa: BLE001
147+
if self._is_timeout_exception(exc):
148+
logger.error("Query timed out: %s", sql[:200])
149+
return [], [], f"Query timed out after {timeout_seconds}s"
150+
logger.error("Query execution failed: %s", exc)
151+
return [], [], str(exc)
152+
finally:
153+
with contextlib.suppress(Exception):
154+
self._adapter.set_statement_timeout(self._conn, 0)
155+
156+
@staticmethod
157+
def _is_timeout_exception(exc: Exception) -> bool:
158+
"""True if *exc* is a DB-level statement timeout (pgcode 57014 or MySQL errno 3024/1969)."""
159+
pgcode = getattr(exc, "pgcode", None) or getattr(exc, "sqlstate", None)
160+
if pgcode == "57014":
161+
return True
162+
errno = getattr(exc, "args", (None,))[0] if exc.args else None
163+
return errno in (3024, 1969)
164+
165+
def _execute_with_thread_timeout(
166+
self, sql: str, timeout_seconds: int
167+
) -> tuple[list[str], list[tuple[object, ...]], str | None]:
168+
"""Execute in a daemon thread with a join timeout (SQLite fallback)."""
169+
result_holder: dict[str, Any] = {}
170+
exc_holder: list[Exception] = []
171+
172+
def _run() -> None:
173+
try:
174+
cursor = self._conn.execute(sql)
175+
result_holder["columns"] = (
176+
[d[0] for d in cursor.description] if cursor.description else []
177+
)
178+
result_holder["rows"] = [tuple(r) for r in cursor.fetchall()]
179+
except Exception as exc:
180+
exc_holder.append(exc)
181+
182+
thread = threading.Thread(target=_run, daemon=True)
183+
thread.start()
184+
thread.join(timeout=timeout_seconds)
185+
186+
if thread.is_alive():
187+
with contextlib.suppress(AttributeError):
188+
self._conn.interrupt()
189+
thread.join(timeout=1.0)
190+
if thread.is_alive():
191+
logger.warning(
192+
"Query thread did not stop after interrupt — thread may be leaked: %s",
193+
sql[:200],
194+
)
195+
logger.error("Query timed out: %s", sql[:200])
196+
return [], [], f"Query timed out after {timeout_seconds}s"
197+
198+
if exc_holder:
199+
logger.error("Query execution failed: %s", exc_holder[0])
200+
return [], [], str(exc_holder[0])
201+
202+
return (
203+
result_holder.get("columns", []),
204+
result_holder.get("rows", []),
205+
None,
206+
)
207+
155208
def _inject_limit(self, sql: str) -> tuple[str, int, bool]:
156209
"""Inject or clamp LIMIT; returns (sql_with_limit, effective_limit, was_truncated)."""
157210
config = self._config

0 commit comments

Comments
 (0)