wherobots-python-dbapi 0.28.0__tar.gz → 0.28.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {wherobots_python_dbapi-0.28.0 → wherobots_python_dbapi-0.28.2}/PKG-INFO +3 -2
- {wherobots_python_dbapi-0.28.0 → wherobots_python_dbapi-0.28.2}/pyproject.toml +2 -1
- wherobots_python_dbapi-0.28.2/wherobots/db/_transport.py +24 -0
- wherobots_python_dbapi-0.28.2/wherobots/db/connection.py +593 -0
- {wherobots_python_dbapi-0.28.0 → wherobots_python_dbapi-0.28.2}/wherobots/db/constants.py +2 -0
- {wherobots_python_dbapi-0.28.0 → wherobots_python_dbapi-0.28.2}/wherobots/db/cursor.py +51 -10
- {wherobots_python_dbapi-0.28.0 → wherobots_python_dbapi-0.28.2}/wherobots/db/driver.py +68 -4
- wherobots_python_dbapi-0.28.2/wherobots/db/redaction.py +78 -0
- wherobots_python_dbapi-0.28.0/wherobots/__init__.py +0 -0
- wherobots_python_dbapi-0.28.0/wherobots/db/connection.py +0 -341
- {wherobots_python_dbapi-0.28.0 → wherobots_python_dbapi-0.28.2}/.gitignore +0 -0
- {wherobots_python_dbapi-0.28.0 → wherobots_python_dbapi-0.28.2}/LICENSE +0 -0
- {wherobots_python_dbapi-0.28.0 → wherobots_python_dbapi-0.28.2}/README.md +0 -0
- {wherobots_python_dbapi-0.28.0 → wherobots_python_dbapi-0.28.2}/wherobots/db/__init__.py +0 -0
- {wherobots_python_dbapi-0.28.0 → wherobots_python_dbapi-0.28.2}/wherobots/db/errors.py +0 -0
- {wherobots_python_dbapi-0.28.0 → wherobots_python_dbapi-0.28.2}/wherobots/db/models.py +0 -0
- {wherobots_python_dbapi-0.28.0 → wherobots_python_dbapi-0.28.2}/wherobots/db/region.py +0 -0
- {wherobots_python_dbapi-0.28.0 → wherobots_python_dbapi-0.28.2}/wherobots/db/runtime.py +0 -0
- {wherobots_python_dbapi-0.28.0 → wherobots_python_dbapi-0.28.2}/wherobots/db/session_type.py +0 -0
- {wherobots_python_dbapi-0.28.0 → wherobots_python_dbapi-0.28.2}/wherobots/db/types.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: wherobots-python-dbapi
|
|
3
|
-
Version: 0.28.
|
|
3
|
+
Version: 0.28.2
|
|
4
4
|
Summary: Python DB-API driver for Wherobots DB
|
|
5
5
|
Project-URL: Homepage, https://github.com/wherobots/wherobots-python-dbapi-driver
|
|
6
6
|
Project-URL: Tracker, https://github.com/wherobots/wherobots-python-dbapi-driver/issues
|
|
@@ -14,6 +14,7 @@ Requires-Dist: pandas
|
|
|
14
14
|
Requires-Dist: pandas-stubs>=2.0.3.230814
|
|
15
15
|
Requires-Dist: pyarrow>=14.0.2
|
|
16
16
|
Requires-Dist: requests>=2.31.0
|
|
17
|
+
Requires-Dist: sqlparse>=0.5.0
|
|
17
18
|
Requires-Dist: strenum<0.5,>=0.4.15
|
|
18
19
|
Requires-Dist: tenacity>=8.2.3
|
|
19
20
|
Requires-Dist: types-requests>=2.31.0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "wherobots-python-dbapi"
|
|
3
|
-
version = "0.28.
|
|
3
|
+
version = "0.28.2"
|
|
4
4
|
description = "Python DB-API driver for Wherobots DB"
|
|
5
5
|
authors = [{ name = "Maxime Petazzoni", email = "max@wherobots.com" }]
|
|
6
6
|
requires-python = ">=3.10, <4"
|
|
@@ -17,6 +17,7 @@ dependencies = [
|
|
|
17
17
|
"pandas",
|
|
18
18
|
"types-requests>=2.31.0",
|
|
19
19
|
"pandas-stubs>=2.0.3.230814",
|
|
20
|
+
"sqlparse>=0.5.0",
|
|
20
21
|
]
|
|
21
22
|
|
|
22
23
|
[project.optional-dependencies]
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Transport termination independent of the WebSocket protocol's send lock."""
|
|
2
|
+
|
|
3
|
+
import errno
|
|
4
|
+
import socket
|
|
5
|
+
|
|
6
|
+
from websockets.sync.client import ClientConnection
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def abort_connection(ws: ClientConnection) -> None:
|
|
10
|
+
"""Disable further writes and wake blocked I/O before publishing failures.
|
|
11
|
+
|
|
12
|
+
ClientConnection.close() takes the library's protocol mutex, which a
|
|
13
|
+
stalled sendall() may hold. Shutdown the owned socket instead; the library's
|
|
14
|
+
receive thread will observe EOF/error and finish protocol cleanup itself.
|
|
15
|
+
Already transmitted bytes may still execute remotely.
|
|
16
|
+
"""
|
|
17
|
+
try:
|
|
18
|
+
ws.socket.shutdown(socket.SHUT_RDWR)
|
|
19
|
+
except OSError as exc:
|
|
20
|
+
if exc.errno not in (errno.ENOTCONN, errno.EBADF, errno.ECONNRESET):
|
|
21
|
+
raise
|
|
22
|
+
finally:
|
|
23
|
+
# Even if shutdown fails, prevent any later send on this socket object.
|
|
24
|
+
ws.socket.close()
|
|
@@ -0,0 +1,593 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
import math
|
|
4
|
+
import textwrap
|
|
5
|
+
import threading
|
|
6
|
+
import uuid
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Any, Callable, Dict
|
|
9
|
+
|
|
10
|
+
from wherobots.db.redaction import get_statement_type, redact_sql
|
|
11
|
+
|
|
12
|
+
import pandas
|
|
13
|
+
import pyarrow
|
|
14
|
+
import cbor2
|
|
15
|
+
import websockets.exceptions
|
|
16
|
+
import websockets.sync.client
|
|
17
|
+
|
|
18
|
+
from .constants import DEFAULT_READ_TIMEOUT_SECONDS
|
|
19
|
+
from ._transport import abort_connection
|
|
20
|
+
from .cursor import Cursor
|
|
21
|
+
from .errors import NotSupportedError, OperationalError
|
|
22
|
+
from .models import ExecutionResult, ProgressInfo, Store, StoreResult
|
|
23
|
+
from .types import (
|
|
24
|
+
RequestKind,
|
|
25
|
+
EventKind,
|
|
26
|
+
ExecutionState,
|
|
27
|
+
ResultsFormat,
|
|
28
|
+
DataCompression,
|
|
29
|
+
GeometryRepresentation,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
ProgressHandler = Callable[[ProgressInfo], None]
|
|
34
|
+
"""A callable invoked with a :class:`ProgressInfo` on every progress event."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class _TransportError(Exception):
|
|
38
|
+
"""An I/O failure raised while receiving from the WebSocket."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
_READER_JOIN_SECONDS = 1.0
|
|
42
|
+
"""How long close() waits for the reader thread once teardown has finished."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _handshake_bound(ws: Any) -> float | None:
|
|
46
|
+
"""The transport's ``close_timeout`` if it bounds the close handshake.
|
|
47
|
+
|
|
48
|
+
websockets documents ``close_timeout=None`` as disabling the timeout, which
|
|
49
|
+
would make a graceful close unbounded against an unresponsive peer. Return
|
|
50
|
+
``None`` for that, and for a missing, non-numeric (including ``bool`` and
|
|
51
|
+
mock), non-finite, non-positive, or too-large value; callers then abort
|
|
52
|
+
instead. Too large means the ``bound + 1s`` wait in ``close()`` would
|
|
53
|
+
exceed ``threading.TIMEOUT_MAX``.
|
|
54
|
+
Never raises ``Exception``: a failure here would strand pending queries.
|
|
55
|
+
"""
|
|
56
|
+
try:
|
|
57
|
+
value = getattr(ws, "close_timeout", None)
|
|
58
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
59
|
+
return None
|
|
60
|
+
# Range-check a plain float, so a subclass can't override the
|
|
61
|
+
# comparisons. float() raises OverflowError for a huge int.
|
|
62
|
+
value = float(value)
|
|
63
|
+
if not math.isfinite(value) or value <= 0:
|
|
64
|
+
return None
|
|
65
|
+
if value > threading.TIMEOUT_MAX - 1.0:
|
|
66
|
+
return None
|
|
67
|
+
return value
|
|
68
|
+
except Exception:
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass
|
|
73
|
+
class Query:
|
|
74
|
+
sql: str
|
|
75
|
+
execution_id: str
|
|
76
|
+
state: ExecutionState
|
|
77
|
+
handler: Callable[[Any], None]
|
|
78
|
+
store: Store | None = None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class Connection:
|
|
82
|
+
"""
|
|
83
|
+
A PEP-0249 compatible Connection object for Wherobots DB.
|
|
84
|
+
|
|
85
|
+
The connection is backed by the WebSocket connected to the Wherobots SQL session instance.
|
|
86
|
+
Transactions are not supported, so commit() and rollback() raise NotSupportedError.
|
|
87
|
+
|
|
88
|
+
This class handles all the interactions with the remote SQL session, and the details of the
|
|
89
|
+
Wherobots Spatial SQL API protocol. It supports multiple concurrent cursors, each one executing
|
|
90
|
+
a single query at a time.
|
|
91
|
+
|
|
92
|
+
A background thread listens for events from the SQL session, and handles update to the
|
|
93
|
+
corresponding query state. Queries are tracked by their unique execution ID.
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
def __init__(
|
|
97
|
+
self,
|
|
98
|
+
ws: websockets.sync.client.ClientConnection,
|
|
99
|
+
read_timeout: float = DEFAULT_READ_TIMEOUT_SECONDS,
|
|
100
|
+
results_format: ResultsFormat | None = None,
|
|
101
|
+
data_compression: DataCompression | None = None,
|
|
102
|
+
geometry_representation: GeometryRepresentation | None = None,
|
|
103
|
+
session_id: str | None = None,
|
|
104
|
+
):
|
|
105
|
+
self.__ws = ws
|
|
106
|
+
self.__read_timeout = read_timeout
|
|
107
|
+
self.__results_format = results_format
|
|
108
|
+
self.__data_compression = data_compression
|
|
109
|
+
self.__geometry_representation = geometry_representation
|
|
110
|
+
self.__progress_handler: ProgressHandler | None = None
|
|
111
|
+
|
|
112
|
+
self.__session_id = session_id
|
|
113
|
+
self.__lock = threading.Lock()
|
|
114
|
+
self.__send_lock = threading.Lock()
|
|
115
|
+
self.__shutdown_done = threading.Event()
|
|
116
|
+
self.__shutdown_owner: int | None = None
|
|
117
|
+
# Resolved when shutdown starts (websockets reads close_timeout live),
|
|
118
|
+
# so the graceful/abort decision and a second closer's wait agree.
|
|
119
|
+
self.__close_bound: float | None = None
|
|
120
|
+
self.__closed = False
|
|
121
|
+
self.__queries: dict[str, Query] = {}
|
|
122
|
+
self.__thread = threading.Thread(
|
|
123
|
+
target=self.__main_loop, daemon=True, name="wherobots-connection"
|
|
124
|
+
)
|
|
125
|
+
self.__thread.start()
|
|
126
|
+
|
|
127
|
+
def __enter__(self):
|
|
128
|
+
return self
|
|
129
|
+
|
|
130
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
131
|
+
self.close()
|
|
132
|
+
|
|
133
|
+
def close(self) -> None:
|
|
134
|
+
"""Close the transport, fail pending work, then wait for the reader.
|
|
135
|
+
|
|
136
|
+
The WebSocket close handshake is attempted when no send is in flight,
|
|
137
|
+
so the server can distinguish a client exit from a crash; otherwise the
|
|
138
|
+
socket is aborted. The handshake is bounded by the ``close_timeout`` of
|
|
139
|
+
the underlying ``ClientConnection`` (1s via ``connect``/
|
|
140
|
+
``connect_direct``; the library default of 10s for a caller-built
|
|
141
|
+
socket). If ``close_timeout`` doesn't bound it (``None``, missing, or
|
|
142
|
+
not a finite positive number), the socket is aborted instead.
|
|
143
|
+
|
|
144
|
+
Pending work has been failed by the time ``close()`` returns, also when
|
|
145
|
+
another thread is closing concurrently. After teardown, ``close()``
|
|
146
|
+
waits up to 1s for the reader thread, so a single call takes at most
|
|
147
|
+
about ``close_timeout`` + 1s. A second, concurrent ``close()`` can take
|
|
148
|
+
up to ``close_timeout`` + 2s (about 12s on a caller-built socket using
|
|
149
|
+
the library's 10s default).
|
|
150
|
+
|
|
151
|
+
Closing doesn't imply that server-side writes were rolled back. A
|
|
152
|
+
decoder or callback can outlive the bounded reader join.
|
|
153
|
+
"""
|
|
154
|
+
self.__fail_pending(graceful=True)
|
|
155
|
+
# A handler may close its own connection during terminal delivery.
|
|
156
|
+
if self.__shutdown_owner == threading.get_ident():
|
|
157
|
+
return
|
|
158
|
+
# Another thread may own shutdown and be mid-handshake, which is bounded
|
|
159
|
+
# by close_timeout; outlast it so close() never returns before pending
|
|
160
|
+
# work has been failed.
|
|
161
|
+
self.__shutdown_done.wait((self.__close_bound or 0.0) + 1.0)
|
|
162
|
+
# Timed from here, not from entry, so a slow handshake can't consume it.
|
|
163
|
+
if self.__thread is not threading.current_thread():
|
|
164
|
+
self.__thread.join(timeout=_READER_JOIN_SECONDS)
|
|
165
|
+
|
|
166
|
+
def commit(self) -> None:
|
|
167
|
+
raise NotSupportedError
|
|
168
|
+
|
|
169
|
+
def rollback(self) -> None:
|
|
170
|
+
raise NotSupportedError
|
|
171
|
+
|
|
172
|
+
def cursor(self) -> Cursor:
|
|
173
|
+
return Cursor(self.__execute_sql, self.__cancel_query)
|
|
174
|
+
|
|
175
|
+
def set_progress_handler(self, handler: ProgressHandler | None) -> None:
|
|
176
|
+
"""Register a callback invoked for execution progress events.
|
|
177
|
+
|
|
178
|
+
When a handler is set, every ``execute_sql`` request automatically
|
|
179
|
+
includes ``enable_progress_events: true`` so the SQL session streams
|
|
180
|
+
progress updates for running queries.
|
|
181
|
+
|
|
182
|
+
Pass ``None`` to disable progress reporting.
|
|
183
|
+
|
|
184
|
+
This follows the `sqlite3 Connection.set_progress_handler()
|
|
185
|
+
<https://docs.python.org/3/library/sqlite3.html#sqlite3.Connection.set_progress_handler>`_
|
|
186
|
+
pattern (PEP 249 vendor extension).
|
|
187
|
+
"""
|
|
188
|
+
self.__progress_handler = handler
|
|
189
|
+
|
|
190
|
+
def __main_loop(self) -> None:
|
|
191
|
+
"""Main background loop listening for messages from the SQL session."""
|
|
192
|
+
logging.info("Starting background connection handling loop...")
|
|
193
|
+
try:
|
|
194
|
+
self.__receive_loop()
|
|
195
|
+
finally:
|
|
196
|
+
self.__fail_pending()
|
|
197
|
+
|
|
198
|
+
def __receive_loop(self) -> None:
|
|
199
|
+
# recv drains buffered results before raising ConnectionClosed.
|
|
200
|
+
while True:
|
|
201
|
+
if self.__shutdown_done.is_set():
|
|
202
|
+
return
|
|
203
|
+
try:
|
|
204
|
+
self.__listen()
|
|
205
|
+
except TimeoutError:
|
|
206
|
+
# Expected, retry next time
|
|
207
|
+
continue
|
|
208
|
+
except websockets.exceptions.ConnectionClosed:
|
|
209
|
+
logging.info("Connection closed; stopping main loop.")
|
|
210
|
+
return
|
|
211
|
+
except _TransportError:
|
|
212
|
+
logging.exception("SQL session transport failed; stopping main loop")
|
|
213
|
+
return
|
|
214
|
+
except Exception as e:
|
|
215
|
+
logging.exception("Error handling message from SQL session", exc_info=e)
|
|
216
|
+
|
|
217
|
+
def __connection_error(self, execution_id: str) -> OperationalError:
|
|
218
|
+
message = (
|
|
219
|
+
f"SQL connection lost (session={self.__session_id or 'unknown'}, "
|
|
220
|
+
f"execution={execution_id}). Commit outcome is unknown; "
|
|
221
|
+
"verify the operation before retrying writes."
|
|
222
|
+
)
|
|
223
|
+
return OperationalError(message)
|
|
224
|
+
|
|
225
|
+
def __fail_pending(self, graceful: bool = False) -> None:
|
|
226
|
+
# Stop admission first. Do not wait for __send_lock: its owner may be
|
|
227
|
+
# blocked in network I/O. __closed means closing until shutdown_done.
|
|
228
|
+
# Resolve the bound before taking __lock: it reads a transport
|
|
229
|
+
# attribute, which may be a property, and nothing foreign may run under
|
|
230
|
+
# __lock or between the latch and the delivery try/finally.
|
|
231
|
+
bound = _handshake_bound(self.__ws)
|
|
232
|
+
with self.__lock:
|
|
233
|
+
if self.__closed:
|
|
234
|
+
return
|
|
235
|
+
self.__closed = True
|
|
236
|
+
self.__shutdown_owner = threading.get_ident()
|
|
237
|
+
self.__close_bound = bound
|
|
238
|
+
# __closed is a one-way latch: nothing below may be skipped, or pending
|
|
239
|
+
# queries are stranded with no path to recovery.
|
|
240
|
+
try:
|
|
241
|
+
self.__terminate_transport(graceful)
|
|
242
|
+
with self.__lock:
|
|
243
|
+
pending = list(self.__queries.values())
|
|
244
|
+
self.__queries.clear()
|
|
245
|
+
for query in pending:
|
|
246
|
+
try:
|
|
247
|
+
query.handler(
|
|
248
|
+
ExecutionResult(
|
|
249
|
+
error=self.__connection_error(query.execution_id)
|
|
250
|
+
)
|
|
251
|
+
)
|
|
252
|
+
except Exception:
|
|
253
|
+
logging.exception(
|
|
254
|
+
"Could not deliver connection failure to query handler"
|
|
255
|
+
)
|
|
256
|
+
finally:
|
|
257
|
+
self.__shutdown_owner = None
|
|
258
|
+
self.__shutdown_done.set()
|
|
259
|
+
|
|
260
|
+
def __terminate_transport(self, graceful: bool) -> None:
|
|
261
|
+
"""Disable the transport. Never raises: delivery must not be skipped."""
|
|
262
|
+
# A non-blocking acquire tells us whether a sender is inside ws.send()
|
|
263
|
+
# right now, possibly stalled holding the library's protocol mutex,
|
|
264
|
+
# which close() would deadlock on. Releasing immediately is safe: the
|
|
265
|
+
# __closed latch is already set, and __send re-checks it under __lock
|
|
266
|
+
# after taking __send_lock, so no sender can reach ws.send() from here
|
|
267
|
+
# on. Holding the lock across the handshake would only park unrelated
|
|
268
|
+
# senders (including the reader's own __request_results) for up to
|
|
269
|
+
# close_timeout instead of letting them fail fast. On a reader-side
|
|
270
|
+
# failure the transport is already broken and a close frame is
|
|
271
|
+
# pointless, so callers abort directly. Without a handshake bound the
|
|
272
|
+
# close could block forever, so abort then too.
|
|
273
|
+
if (
|
|
274
|
+
graceful
|
|
275
|
+
and self.__close_bound is not None
|
|
276
|
+
and self.__send_lock.acquire(blocking=False)
|
|
277
|
+
):
|
|
278
|
+
self.__send_lock.release()
|
|
279
|
+
try:
|
|
280
|
+
self.__ws.close()
|
|
281
|
+
return
|
|
282
|
+
except Exception:
|
|
283
|
+
logging.debug("Graceful close failed; aborting", exc_info=True)
|
|
284
|
+
try:
|
|
285
|
+
abort_connection(self.__ws)
|
|
286
|
+
except Exception:
|
|
287
|
+
# The adapter still closes the socket object in its finally block.
|
|
288
|
+
logging.exception("Could not abort SQL session transport")
|
|
289
|
+
|
|
290
|
+
def __listen(self) -> None:
|
|
291
|
+
"""Waits for the next message from the SQL session and processes it.
|
|
292
|
+
|
|
293
|
+
The code in this method is purposefully defensive to avoid unexpected situations killing the thread.
|
|
294
|
+
"""
|
|
295
|
+
message = self.__recv()
|
|
296
|
+
kind = message.get("kind")
|
|
297
|
+
execution_id = message.get("execution_id")
|
|
298
|
+
if not kind or not execution_id:
|
|
299
|
+
# Invalid event.
|
|
300
|
+
return
|
|
301
|
+
|
|
302
|
+
# Progress events are independent of the query state machine and don't
|
|
303
|
+
# require a tracked query — the handler is connection-level.
|
|
304
|
+
if kind == EventKind.EXECUTION_PROGRESS:
|
|
305
|
+
handler = self.__progress_handler
|
|
306
|
+
if handler is None:
|
|
307
|
+
return
|
|
308
|
+
try:
|
|
309
|
+
handler(
|
|
310
|
+
ProgressInfo(
|
|
311
|
+
execution_id=execution_id,
|
|
312
|
+
tasks_total=message.get("tasks_total", 0),
|
|
313
|
+
tasks_completed=message.get("tasks_completed", 0),
|
|
314
|
+
tasks_active=message.get("tasks_active", 0),
|
|
315
|
+
)
|
|
316
|
+
)
|
|
317
|
+
except Exception:
|
|
318
|
+
logging.exception("Progress handler raised an exception")
|
|
319
|
+
return
|
|
320
|
+
|
|
321
|
+
query = self.__queries.get(execution_id)
|
|
322
|
+
if not query:
|
|
323
|
+
logging.warning(
|
|
324
|
+
"Received %s event for unknown execution ID %s", kind, execution_id
|
|
325
|
+
)
|
|
326
|
+
return
|
|
327
|
+
|
|
328
|
+
def complete_query(result: ExecutionResult) -> None:
|
|
329
|
+
# Terminal delivery: stop tracking the query first. Keeping it in
|
|
330
|
+
# __queries would retain its handler — and the results the handler
|
|
331
|
+
# references — for the connection's lifetime (WBC-922).
|
|
332
|
+
with self.__lock:
|
|
333
|
+
claimed = self.__queries.pop(execution_id, None)
|
|
334
|
+
if claimed is not None:
|
|
335
|
+
claimed.handler(result)
|
|
336
|
+
|
|
337
|
+
def fail_query(action: str, error: Exception) -> None:
|
|
338
|
+
# This is a query-local failure, not evidence of transport loss.
|
|
339
|
+
# Exception text may contain result data; report only its type.
|
|
340
|
+
query.state = ExecutionState.FAILED
|
|
341
|
+
message = (
|
|
342
|
+
f"Could not {action} SQL results "
|
|
343
|
+
f"(session={self.__session_id or 'unknown'}, "
|
|
344
|
+
f"execution={execution_id}; {type(error).__name__}). "
|
|
345
|
+
"The statement may have completed; verify the operation before "
|
|
346
|
+
"retrying writes."
|
|
347
|
+
)
|
|
348
|
+
logging.error("%s", message)
|
|
349
|
+
complete_query(ExecutionResult(error=OperationalError(message)))
|
|
350
|
+
|
|
351
|
+
# Incoming state transitions are handled here.
|
|
352
|
+
if kind == EventKind.STATE_UPDATED or kind == EventKind.EXECUTION_RESULT:
|
|
353
|
+
try:
|
|
354
|
+
query.state = ExecutionState[message["state"].upper()]
|
|
355
|
+
logging.info("Query %s is now %s.", execution_id, query.state)
|
|
356
|
+
except (KeyError, AttributeError, TypeError) as error:
|
|
357
|
+
fail_query("interpret", error)
|
|
358
|
+
return
|
|
359
|
+
|
|
360
|
+
if query.state == ExecutionState.SUCCEEDED:
|
|
361
|
+
# On a state_updated event telling us the query succeeded,
|
|
362
|
+
# check if results are stored in cloud storage or need to be fetched.
|
|
363
|
+
if kind == EventKind.STATE_UPDATED:
|
|
364
|
+
result_uri = message.get("result_uri")
|
|
365
|
+
if result_uri:
|
|
366
|
+
# Results are stored in cloud storage
|
|
367
|
+
store_result = StoreResult(
|
|
368
|
+
result_uri=result_uri,
|
|
369
|
+
size=message.get("size"),
|
|
370
|
+
)
|
|
371
|
+
logging.info(
|
|
372
|
+
"Query %s results stored at: %s (size: %s)",
|
|
373
|
+
execution_id,
|
|
374
|
+
result_uri,
|
|
375
|
+
store_result.size,
|
|
376
|
+
)
|
|
377
|
+
query.state = ExecutionState.COMPLETED
|
|
378
|
+
complete_query(ExecutionResult(store_result=store_result))
|
|
379
|
+
return
|
|
380
|
+
|
|
381
|
+
if query.store is not None:
|
|
382
|
+
# Store was configured but produced no results (empty result set)
|
|
383
|
+
logging.info(
|
|
384
|
+
"Query %s completed with store configured but no results to store.",
|
|
385
|
+
execution_id,
|
|
386
|
+
)
|
|
387
|
+
query.state = ExecutionState.COMPLETED
|
|
388
|
+
complete_query(ExecutionResult())
|
|
389
|
+
return
|
|
390
|
+
|
|
391
|
+
# No store configured, request results normally
|
|
392
|
+
try:
|
|
393
|
+
self.__request_results(execution_id)
|
|
394
|
+
except Exception as error:
|
|
395
|
+
# Transport failures are handled by __send; a local
|
|
396
|
+
# retrieval failure must not orphan this execution.
|
|
397
|
+
fail_query("request", error)
|
|
398
|
+
return
|
|
399
|
+
|
|
400
|
+
# Otherwise, process the results from the execution_result event.
|
|
401
|
+
results = message.get("results")
|
|
402
|
+
if results is None or results == {}:
|
|
403
|
+
logging.warning("Got no results back from %s.", execution_id)
|
|
404
|
+
query.state = ExecutionState.COMPLETED
|
|
405
|
+
complete_query(ExecutionResult())
|
|
406
|
+
return
|
|
407
|
+
|
|
408
|
+
try:
|
|
409
|
+
if not isinstance(results, dict):
|
|
410
|
+
raise TypeError("Expected a result object")
|
|
411
|
+
decoded = self._handle_results(execution_id, results)
|
|
412
|
+
except Exception as error:
|
|
413
|
+
# Even OSError/TimeoutError here belong to decoding, not
|
|
414
|
+
# recv. Claim and deliver exactly once, including if close
|
|
415
|
+
# concurrently claims this execution.
|
|
416
|
+
fail_query("decode", error)
|
|
417
|
+
else:
|
|
418
|
+
query.state = ExecutionState.COMPLETED
|
|
419
|
+
complete_query(ExecutionResult(results=decoded))
|
|
420
|
+
elif query.state == ExecutionState.CANCELLED:
|
|
421
|
+
logging.info(
|
|
422
|
+
"Query %s has been cancelled; returning empty results.",
|
|
423
|
+
execution_id,
|
|
424
|
+
)
|
|
425
|
+
complete_query(ExecutionResult(results=pandas.DataFrame()))
|
|
426
|
+
elif query.state == ExecutionState.FAILED:
|
|
427
|
+
# Don't do anything here; the ERROR event is coming with more
|
|
428
|
+
# details.
|
|
429
|
+
pass
|
|
430
|
+
elif kind == EventKind.ERROR:
|
|
431
|
+
query.state = ExecutionState.FAILED
|
|
432
|
+
error = message.get("message")
|
|
433
|
+
complete_query(ExecutionResult(error=OperationalError(error)))
|
|
434
|
+
else:
|
|
435
|
+
logging.warning("Received unknown %s event!", kind)
|
|
436
|
+
|
|
437
|
+
def _handle_results(self, execution_id: str, results: Dict[str, Any]) -> Any:
|
|
438
|
+
result_bytes = results.get("result_bytes")
|
|
439
|
+
result_format = results.get("format")
|
|
440
|
+
result_compression = results.get("compression")
|
|
441
|
+
logging.info(
|
|
442
|
+
"Received %d bytes of %s-compressed %s results from %s.",
|
|
443
|
+
len(result_bytes),
|
|
444
|
+
result_compression,
|
|
445
|
+
result_format,
|
|
446
|
+
execution_id,
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
if result_format == ResultsFormat.JSON:
|
|
450
|
+
return json.loads(result_bytes.decode("utf-8"))
|
|
451
|
+
elif result_format == ResultsFormat.ARROW:
|
|
452
|
+
buffer = pyarrow.py_buffer(result_bytes)
|
|
453
|
+
stream = pyarrow.input_stream(buffer, result_compression)
|
|
454
|
+
with pyarrow.ipc.open_stream(stream) as reader:
|
|
455
|
+
return reader.read_pandas()
|
|
456
|
+
else:
|
|
457
|
+
raise NotSupportedError("Unsupported results format")
|
|
458
|
+
|
|
459
|
+
def __send(self, message: Dict[str, Any], query: Query | None = None) -> None:
|
|
460
|
+
# Serialization and redaction are local work. Fail before registration,
|
|
461
|
+
# without poisoning unrelated cursors or misreporting a transport loss.
|
|
462
|
+
request = json.dumps(message)
|
|
463
|
+
# Only compute the redacted request (json.dumps + sqlparse parse) when
|
|
464
|
+
# DEBUG is actually enabled; the log argument is evaluated eagerly, so an
|
|
465
|
+
# unguarded call would redact on every request even with DEBUG off.
|
|
466
|
+
if logging.getLogger().isEnabledFor(logging.DEBUG):
|
|
467
|
+
logging.debug("Request: %s", self.__redacted_request(message))
|
|
468
|
+
with self.__send_lock:
|
|
469
|
+
with self.__lock:
|
|
470
|
+
if self.__closed:
|
|
471
|
+
if query is not None:
|
|
472
|
+
raise self.__connection_error(query.execution_id)
|
|
473
|
+
return
|
|
474
|
+
if query is not None:
|
|
475
|
+
self.__queries[query.execution_id] = query
|
|
476
|
+
elif message.get("execution_id") not in self.__queries:
|
|
477
|
+
return
|
|
478
|
+
try:
|
|
479
|
+
self.__ws.send(request)
|
|
480
|
+
except (websockets.exceptions.ConnectionClosed, OSError):
|
|
481
|
+
pass # Terminate outside the send gate, before delivering errors.
|
|
482
|
+
except Exception:
|
|
483
|
+
# API/programming errors aren't evidence of connection loss.
|
|
484
|
+
if query is not None:
|
|
485
|
+
with self.__lock:
|
|
486
|
+
self.__queries.pop(query.execution_id, None)
|
|
487
|
+
raise
|
|
488
|
+
else:
|
|
489
|
+
return
|
|
490
|
+
self.__fail_pending()
|
|
491
|
+
|
|
492
|
+
@staticmethod
|
|
493
|
+
def __redacted_request(message: Dict[str, Any]) -> str:
|
|
494
|
+
"""Serialize a request for logging with any SQL statement redacted.
|
|
495
|
+
|
|
496
|
+
The wire payload (sent verbatim by ``__send``) carries the raw
|
|
497
|
+
``statement``; this driver is embedded by other services, so logging it
|
|
498
|
+
-- even at DEBUG -- would leak raw SQL into their log streams (WBC-139).
|
|
499
|
+
"""
|
|
500
|
+
statement = message.get("statement")
|
|
501
|
+
if isinstance(statement, str):
|
|
502
|
+
message = {**message, "statement": redact_sql(statement)}
|
|
503
|
+
return json.dumps(message)
|
|
504
|
+
|
|
505
|
+
def __recv(self) -> Dict[str, Any]:
|
|
506
|
+
try:
|
|
507
|
+
frame = self.__ws.recv(timeout=self.__read_timeout)
|
|
508
|
+
except TimeoutError:
|
|
509
|
+
raise # Idle polls are expected, not terminal I/O failures.
|
|
510
|
+
except OSError as e:
|
|
511
|
+
# Distinguish transport I/O failures from OSErrors raised later by
|
|
512
|
+
# protocol parsing or result decoding; only the former are terminal.
|
|
513
|
+
raise _TransportError from e
|
|
514
|
+
if isinstance(frame, str):
|
|
515
|
+
message = json.loads(frame)
|
|
516
|
+
elif isinstance(frame, bytes):
|
|
517
|
+
message = cbor2.loads(frame)
|
|
518
|
+
else:
|
|
519
|
+
raise ValueError("Unexpected frame type received")
|
|
520
|
+
return message
|
|
521
|
+
|
|
522
|
+
def __execute_sql(
|
|
523
|
+
self,
|
|
524
|
+
sql: str,
|
|
525
|
+
handler: Callable[[Any], None],
|
|
526
|
+
store: Store | None = None,
|
|
527
|
+
) -> str:
|
|
528
|
+
"""Triggers the execution of the given SQL query."""
|
|
529
|
+
execution_id = str(uuid.uuid4())
|
|
530
|
+
request = {
|
|
531
|
+
"kind": RequestKind.EXECUTE_SQL.value,
|
|
532
|
+
"execution_id": execution_id,
|
|
533
|
+
"statement": sql,
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
if self.__progress_handler is not None:
|
|
537
|
+
request["enable_progress_events"] = True
|
|
538
|
+
|
|
539
|
+
if store:
|
|
540
|
+
request["store"] = store.to_dict()
|
|
541
|
+
|
|
542
|
+
# Redact literal values before logging: this driver is embedded by other
|
|
543
|
+
# services, so raw SQL here would leak into their log streams (WBC-139).
|
|
544
|
+
logging.info(
|
|
545
|
+
"Executing SQL query %s (%s): %s",
|
|
546
|
+
execution_id,
|
|
547
|
+
get_statement_type(sql),
|
|
548
|
+
textwrap.shorten(redact_sql(sql), width=200),
|
|
549
|
+
)
|
|
550
|
+
self.__send(
|
|
551
|
+
request,
|
|
552
|
+
Query(
|
|
553
|
+
sql=sql,
|
|
554
|
+
execution_id=execution_id,
|
|
555
|
+
state=ExecutionState.EXECUTION_REQUESTED,
|
|
556
|
+
handler=handler,
|
|
557
|
+
store=store,
|
|
558
|
+
),
|
|
559
|
+
)
|
|
560
|
+
return execution_id
|
|
561
|
+
|
|
562
|
+
def __request_results(self, execution_id: str) -> None:
|
|
563
|
+
query = self.__queries.get(execution_id)
|
|
564
|
+
if not query:
|
|
565
|
+
return
|
|
566
|
+
|
|
567
|
+
request = {
|
|
568
|
+
"kind": RequestKind.RETRIEVE_RESULTS.value,
|
|
569
|
+
"execution_id": execution_id,
|
|
570
|
+
}
|
|
571
|
+
if self.__results_format:
|
|
572
|
+
request["format"] = self.__results_format.value
|
|
573
|
+
if self.__data_compression:
|
|
574
|
+
request["compression"] = self.__data_compression.value
|
|
575
|
+
if self.__geometry_representation:
|
|
576
|
+
request["geometry"] = self.__geometry_representation.value
|
|
577
|
+
|
|
578
|
+
query.state = ExecutionState.RESULTS_REQUESTED
|
|
579
|
+
logging.info("Requesting results from %s ...", execution_id)
|
|
580
|
+
self.__send(request)
|
|
581
|
+
|
|
582
|
+
def __cancel_query(self, execution_id: str) -> None:
|
|
583
|
+
"""Cancels the query with the given execution ID."""
|
|
584
|
+
query = self.__queries.get(execution_id)
|
|
585
|
+
if not query:
|
|
586
|
+
return
|
|
587
|
+
|
|
588
|
+
request = {
|
|
589
|
+
"kind": RequestKind.CANCEL.value,
|
|
590
|
+
"execution_id": execution_id,
|
|
591
|
+
}
|
|
592
|
+
logging.info("Cancelling query %s...", execution_id)
|
|
593
|
+
self.__send(request)
|
|
@@ -14,6 +14,8 @@ DEFAULT_REGION: Region = Region.AWS_US_WEST_2
|
|
|
14
14
|
DEFAULT_SESSION_TYPE: SessionType = SessionType.MULTI
|
|
15
15
|
DEFAULT_STORAGE_FORMAT: StorageFormat = StorageFormat.PARQUET
|
|
16
16
|
DEFAULT_READ_TIMEOUT_SECONDS: float = 0.25
|
|
17
|
+
# Bound on the WebSocket close handshake performed by a graceful close().
|
|
18
|
+
DEFAULT_CLOSE_TIMEOUT_SECONDS: float = 1.0
|
|
17
19
|
DEFAULT_SESSION_WAIT_TIMEOUT_SECONDS: float = 900
|
|
18
20
|
|
|
19
21
|
MAX_MESSAGE_SIZE: int = 100 * 2**20 # 100MiB
|