Skip to content

Commit 3afa080

Browse files
adding a timeout limit to web api queries
1 parent f4169b4 commit 3afa080

2 files changed

Lines changed: 65 additions & 1 deletion

File tree

core/pioreactor/web/app.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from base64 import b64decode
55
from datetime import datetime
66
from datetime import timezone
7+
from time import monotonic
78

89
from flask import Flask
910
from flask import g
@@ -27,6 +28,8 @@
2728
VERSION = __version__
2829
HOSTNAME = get_unit_name()
2930
NAME = f"pioreactor-{HOSTNAME}-api"
31+
APP_DATABASE_QUERY_TIMEOUT_SECONDS = 30.0
32+
APP_DATABASE_QUERY_PROGRESS_HANDLER_INSTRUCTIONS = 50_000
3033

3134

3235
# set up logging
@@ -232,12 +235,34 @@ def query_app_db(
232235
) -> dict[str, t.Any] | list[dict[str, t.Any]] | None:
233236
assert am_I_leader()
234237
con = _get_app_db_connection()
238+
cur: sqlite3.Cursor | None = None
239+
query_deadline = monotonic() + APP_DATABASE_QUERY_TIMEOUT_SECONDS
240+
query_timed_out = False
241+
242+
def query_exceeded_execution_deadline() -> int:
243+
nonlocal query_timed_out
244+
query_timed_out = monotonic() >= query_deadline
245+
return int(query_timed_out)
246+
235247
try:
236248
con.execute("PRAGMA query_only = 1")
249+
con.set_progress_handler(
250+
query_exceeded_execution_deadline,
251+
APP_DATABASE_QUERY_PROGRESS_HANDLER_INSTRUCTIONS,
252+
)
237253
cur = con.execute(query, args)
238254
rv = cur.fetchall()
239-
cur.close()
255+
except sqlite3.OperationalError as e:
256+
if query_timed_out:
257+
raise TimeoutError(
258+
f"Database query exceeded {APP_DATABASE_QUERY_TIMEOUT_SECONDS:g} seconds."
259+
) from e
260+
raise
240261
finally:
262+
con.set_progress_handler(None, 0)
263+
if cur is not None:
264+
cur.close()
265+
241266
# Restore to default to allow mutations via modify_app_db within same request
242267
try:
243268
con.execute("PRAGMA query_only = 0")

core/tests/web/test_db_querying.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,45 @@ def test_query_app_db_disallows_dml_via_query_only_pragma(app, tmp_path) -> None
5050
assert _count_rows(db_path) == 1
5151

5252

53+
def test_query_app_db_interrupts_queries_that_exceed_the_execution_deadline(app, monkeypatch) -> None:
54+
from pioreactor.web import app as web_app
55+
56+
monkeypatch.setattr(web_app, "APP_DATABASE_QUERY_TIMEOUT_SECONDS", 0.0, raising=False)
57+
58+
with app.app_context():
59+
connection = sqlite3.connect(":memory:")
60+
connection.row_factory = web_app._make_dicts
61+
g._app_database = connection
62+
63+
with pytest.raises(TimeoutError, match="Database query exceeded"):
64+
web_app.query_app_db(
65+
"""
66+
WITH RECURSIVE counter(value) AS (
67+
VALUES(0)
68+
UNION ALL
69+
SELECT value + 1 FROM counter WHERE value < 1000000
70+
)
71+
SELECT SUM(value) FROM counter
72+
"""
73+
)
74+
75+
# The interrupted statement must release the connection and remove its
76+
# progress handler so the same request can continue using SQLite.
77+
result = connection.execute(
78+
"""
79+
WITH RECURSIVE counter(value) AS (
80+
VALUES(0)
81+
UNION ALL
82+
SELECT value + 1 FROM counter WHERE value < 100000
83+
)
84+
SELECT SUM(value) AS total FROM counter
85+
"""
86+
).fetchone()
87+
88+
assert result == {"total": 5000050000}
89+
connection.execute("CREATE TABLE after_timeout (value INTEGER)")
90+
91+
5392
def test_modify_app_db_rolls_back_after_integrity_error(app, tmp_path) -> None:
5493
from pioreactor.web import app as web_app
5594

0 commit comments

Comments
 (0)