Skip to content

Commit 1899ca0

Browse files
authored
Merge branch 'main' into fix/issue-1664-stuck-drop-overlay
2 parents c44ba2c + b793919 commit 1899ca0

2 files changed

Lines changed: 80 additions & 12 deletions

File tree

backend/geolibre_server/geolibre_server/sedona_ops.py

Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,13 @@
1717

1818
import concurrent.futures
1919
import json
20+
import logging
2021
import math
2122
import re
2223
from typing import Any, Optional
2324

25+
logger = logging.getLogger(__name__)
26+
2427
WGS84 = "EPSG:4326"
2528

2629
# Wall-clock budget for a single SQL statement (execute + materialise). Mirrors
@@ -140,6 +143,7 @@ def run_sql(sql: str, layers: Optional[list[dict]] = None) -> dict:
140143
import geopandas as gpd # noqa: PLC0415
141144

142145
connection = sedona_db.connect()
146+
future = None
143147
try:
144148
for layer in layers or []:
145149
name = str(layer.get("name") or "").strip()
@@ -171,12 +175,12 @@ def _execute():
171175
try:
172176
frame = future.result(timeout=timeout_secs)
173177
except concurrent.futures.TimeoutError:
174-
close = getattr(connection, "close", None)
175-
if callable(close):
176-
try:
177-
close()
178-
except Exception: # noqa: BLE001
179-
pass
178+
# No cancellation attempt: the pool has a single worker and a
179+
# single task, so by the time this fires ``_execute`` is already
180+
# running and ``future.cancel()`` would return False. The Rust
181+
# engine exposes no way to interrupt an in-flight query, so the
182+
# statement runs to completion and the ``finally`` block below
183+
# defers closing the connection until it does.
180184
raise SqlTimeout(
181185
f"Spatial SQL timed out after {int(timeout_secs)} seconds"
182186
) from None
@@ -226,9 +230,17 @@ def _execute():
226230
finally:
227231
# SedonaDB connections are Rust-backed; release promptly rather than
228232
# waiting on GC. Tolerate bindings that expose no close().
229-
close = getattr(connection, "close", None)
230-
if callable(close):
231-
try:
232-
close()
233-
except Exception: # noqa: BLE001 - best-effort cleanup
234-
pass
233+
def _close_connection(*_args: object) -> None:
234+
close = getattr(connection, "close", None)
235+
if callable(close):
236+
try:
237+
close()
238+
except Exception: # noqa: BLE001 - best-effort cleanup
239+
# A failed close can strand Rust-backed resources, so give
240+
# operators a signal. The SQL text is deliberately omitted.
241+
logger.warning("Failed to close the SedonaDB connection", exc_info=True)
242+
243+
if future is not None and not future.done():
244+
future.add_done_callback(_close_connection)
245+
else:
246+
_close_connection()
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import time
2+
from unittest.mock import MagicMock, patch
3+
4+
import pytest
5+
6+
from geolibre_server.sedona_ops import SqlTimeout, run_sql
7+
8+
9+
@pytest.fixture
10+
def mock_sedona_db() -> MagicMock:
11+
with patch("geolibre_server.sedona_ops._import_sedona") as mock_import:
12+
mock_sedona = MagicMock()
13+
mock_conn = MagicMock()
14+
mock_sedona.connect.return_value = mock_conn
15+
mock_import.return_value = mock_sedona
16+
yield mock_conn
17+
18+
19+
def test_sql_timeout_graceful_shutdown(
20+
mock_sedona_db: MagicMock, monkeypatch: pytest.MonkeyPatch
21+
) -> None:
22+
"""
23+
Test that a SQL query exceeding the timeout raises SqlTimeout,
24+
but does NOT immediately close the database connection.
25+
Instead, it should attach a callback to close it when the future finishes.
26+
"""
27+
# Lower the timeout to 0.1 seconds for the test
28+
monkeypatch.setattr("geolibre_server.sedona_ops._STATEMENT_TIMEOUT_MS", 100)
29+
30+
# Mock the execute method to block longer than the timeout
31+
# We must patch the connection's sql method to block
32+
def _slow_sql(*_args: object, **_kwargs: object) -> MagicMock:
33+
time.sleep(0.3) # Blocks longer than the 0.1s timeout
34+
mock_df = MagicMock()
35+
mock_df.limit.return_value.to_pandas.return_value = MagicMock(columns=[])
36+
return mock_df
37+
38+
mock_sedona_db.sql = _slow_sql
39+
40+
with pytest.raises(SqlTimeout, match="timed out"):
41+
run_sql("SELECT 1")
42+
43+
# At this exact moment, the TimeoutError was caught and SqlTimeout raised.
44+
# The background thread is still sleeping (for another 0.2 seconds).
45+
# We must assert that the connection has NOT been closed yet!
46+
mock_sedona_db.close.assert_not_called()
47+
48+
# Wait for the background thread to finish. Poll instead of sleeping a fixed
49+
# interval: on a loaded CI host the 0.3s sleep can overrun any margin we
50+
# would pick, which would make the assertion below flaky.
51+
deadline = time.monotonic() + 5.0
52+
while time.monotonic() < deadline and not mock_sedona_db.close.called:
53+
time.sleep(0.01)
54+
55+
# Now the callback should have fired and closed the connection
56+
mock_sedona_db.close.assert_called_once()

0 commit comments

Comments
 (0)