wherobots-python-dbapi 0.28.1__py3-none-any.whl → 0.29.0__py3-none-any.whl
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/db/_transport.py +57 -0
- wherobots/db/connection.py +586 -52
- wherobots/db/constants.py +4 -0
- wherobots/db/cursor.py +3 -0
- wherobots/db/driver.py +22 -0
- wherobots/db/types.py +3 -0
- {wherobots_python_dbapi-0.28.1.dist-info → wherobots_python_dbapi-0.29.0.dist-info}/METADATA +7 -2
- wherobots_python_dbapi-0.29.0.dist-info/RECORD +17 -0
- {wherobots_python_dbapi-0.28.1.dist-info → wherobots_python_dbapi-0.29.0.dist-info}/WHEEL +1 -1
- wherobots_python_dbapi-0.28.1.dist-info/RECORD +0 -16
- {wherobots_python_dbapi-0.28.1.dist-info → wherobots_python_dbapi-0.29.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Transport termination independent of the WebSocket protocol's send lock."""
|
|
2
|
+
|
|
3
|
+
import errno
|
|
4
|
+
import select
|
|
5
|
+
import selectors
|
|
6
|
+
import socket
|
|
7
|
+
|
|
8
|
+
from websockets.sync.client import ClientConnection
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def abort_connection(ws: ClientConnection) -> None:
|
|
12
|
+
"""Disable further writes and wake blocked I/O before publishing failures.
|
|
13
|
+
|
|
14
|
+
ClientConnection.close() takes the library's protocol mutex, which a
|
|
15
|
+
stalled sendall() may hold. Shutdown the owned socket instead; the library's
|
|
16
|
+
receive thread will observe EOF/error and finish protocol cleanup itself.
|
|
17
|
+
Already transmitted bytes may still execute remotely.
|
|
18
|
+
"""
|
|
19
|
+
try:
|
|
20
|
+
ws.socket.shutdown(socket.SHUT_RDWR)
|
|
21
|
+
except OSError as exc:
|
|
22
|
+
if exc.errno not in (errno.ENOTCONN, errno.EBADF, errno.ECONNRESET):
|
|
23
|
+
raise
|
|
24
|
+
finally:
|
|
25
|
+
# Even if shutdown fails, prevent any later send on this socket object.
|
|
26
|
+
ws.socket.close()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def socket_writable(ws: ClientConnection) -> bool:
|
|
30
|
+
"""Whether a small write to the transport's socket won't block right now.
|
|
31
|
+
|
|
32
|
+
For a send that must not stall the reader thread: a full send buffer
|
|
33
|
+
(a peer that keeps sending but stops reading) would park sendall(), which
|
|
34
|
+
also holds the library's protocol mutex, so even keepalive pings stall.
|
|
35
|
+
|
|
36
|
+
Never raises. Returns ``True`` (proceed, as without this check) on any
|
|
37
|
+
failure or for a transport without a real socket, such as a test fake:
|
|
38
|
+
``False`` would silently disable the caller's sends. Uses ``poll`` (or a
|
|
39
|
+
selector), not ``select.select``, which fails for fds above FD_SETSIZE.
|
|
40
|
+
"""
|
|
41
|
+
try:
|
|
42
|
+
sock = getattr(ws, "socket", None)
|
|
43
|
+
if not isinstance(sock, socket.socket):
|
|
44
|
+
return True
|
|
45
|
+
fd = sock.fileno()
|
|
46
|
+
if fd < 0:
|
|
47
|
+
return True # Closed: let the send itself report it.
|
|
48
|
+
if hasattr(select, "poll"):
|
|
49
|
+
poller = select.poll()
|
|
50
|
+
poller.register(fd, select.POLLOUT)
|
|
51
|
+
# POLLERR/POLLHUP count as writable: the send then fails fast.
|
|
52
|
+
return bool(poller.poll(0))
|
|
53
|
+
with selectors.DefaultSelector() as selector:
|
|
54
|
+
selector.register(fd, selectors.EVENT_WRITE)
|
|
55
|
+
return bool(selector.select(0))
|
|
56
|
+
except Exception:
|
|
57
|
+
return True
|
wherobots/db/connection.py
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import json
|
|
2
2
|
import logging
|
|
3
|
+
import math
|
|
3
4
|
import textwrap
|
|
4
5
|
import threading
|
|
6
|
+
import time
|
|
5
7
|
import uuid
|
|
6
|
-
from dataclasses import dataclass
|
|
8
|
+
from dataclasses import dataclass, field
|
|
7
9
|
from typing import Any, Callable, Dict
|
|
8
10
|
|
|
9
11
|
from wherobots.db.redaction import get_statement_type, redact_sql
|
|
@@ -12,10 +14,10 @@ import pandas
|
|
|
12
14
|
import pyarrow
|
|
13
15
|
import cbor2
|
|
14
16
|
import websockets.exceptions
|
|
15
|
-
import websockets.protocol
|
|
16
17
|
import websockets.sync.client
|
|
17
18
|
|
|
18
|
-
from .constants import DEFAULT_READ_TIMEOUT_SECONDS
|
|
19
|
+
from .constants import DEFAULT_READ_TIMEOUT_SECONDS, DEFAULT_STALE_QUERY_PROBE_SECONDS
|
|
20
|
+
from ._transport import abort_connection, socket_writable
|
|
19
21
|
from .cursor import Cursor
|
|
20
22
|
from .errors import NotSupportedError, OperationalError
|
|
21
23
|
from .models import ExecutionResult, ProgressInfo, Store, StoreResult
|
|
@@ -33,6 +35,151 @@ ProgressHandler = Callable[[ProgressInfo], None]
|
|
|
33
35
|
"""A callable invoked with a :class:`ProgressInfo` on every progress event."""
|
|
34
36
|
|
|
35
37
|
|
|
38
|
+
class _TransportError(Exception):
|
|
39
|
+
"""An I/O failure raised while receiving from the WebSocket."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
_READER_JOIN_SECONDS = 1.0
|
|
43
|
+
"""How long close() waits for the reader thread once teardown has finished."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _handshake_bound(ws: Any) -> float | None:
|
|
47
|
+
"""The transport's ``close_timeout`` if it bounds the close handshake.
|
|
48
|
+
|
|
49
|
+
websockets documents ``close_timeout=None`` as disabling the timeout, which
|
|
50
|
+
would make a graceful close unbounded against an unresponsive peer. Return
|
|
51
|
+
``None`` for that, and for a missing, non-numeric (including ``bool`` and
|
|
52
|
+
mock), non-finite, non-positive, or too-large value; callers then abort
|
|
53
|
+
instead. Too large means the ``bound + 1s`` wait in ``close()`` would
|
|
54
|
+
exceed ``threading.TIMEOUT_MAX``.
|
|
55
|
+
Never raises ``Exception``: a failure here would strand pending queries.
|
|
56
|
+
"""
|
|
57
|
+
try:
|
|
58
|
+
value = getattr(ws, "close_timeout", None)
|
|
59
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
60
|
+
return None
|
|
61
|
+
# Range-check a plain float, so a subclass can't override the
|
|
62
|
+
# comparisons. float() raises OverflowError for a huge int.
|
|
63
|
+
value = float(value)
|
|
64
|
+
if not math.isfinite(value) or value <= 0:
|
|
65
|
+
return None
|
|
66
|
+
if value > threading.TIMEOUT_MAX - 1.0:
|
|
67
|
+
return None
|
|
68
|
+
return value
|
|
69
|
+
except Exception:
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
_STALE_CHECK_MAX_INTERVAL_SECONDS = 1.0
|
|
74
|
+
"""Upper bound on how often the reader thread scans for stale queries."""
|
|
75
|
+
|
|
76
|
+
_MAX_PROBE_BACKOFF_DOUBLINGS = 3
|
|
77
|
+
"""Probe spacing doubles from N up to 8N (2**3 * N), then stays there."""
|
|
78
|
+
|
|
79
|
+
_RESULTS_PROBE_AFTER_FACTOR = 4
|
|
80
|
+
"""A query whose results were already requested gets one probe, after 4N.
|
|
81
|
+
|
|
82
|
+
Its terminal event has arrived; only the retrieve_results reply is missing.
|
|
83
|
+
The session re-serializes and re-sends the whole result (up to 100 MiB) for
|
|
84
|
+
every retrieve, synchronously on its event loop, so duplicates are costly. A
|
|
85
|
+
lost reply is also the rarer case: retrieve replies are sent inline on the
|
|
86
|
+
session's main loop, not pushed from a callback like state_updated.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
_EXECUTION_NOT_FOUND = "Execution not found"
|
|
90
|
+
"""sql-session's error for a retrieve_results it has no cache entry for.
|
|
91
|
+
|
|
92
|
+
Its execution cache is a small LRU that can evict a query that is still
|
|
93
|
+
running, whose terminal state_updated still arrives (it's pushed from the
|
|
94
|
+
query's own done-callback). So for a probe, this means "can't answer", not
|
|
95
|
+
"failed".
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
_PROBED_STATES = frozenset(
|
|
99
|
+
{
|
|
100
|
+
ExecutionState.EXECUTION_REQUESTED,
|
|
101
|
+
ExecutionState.PENDING,
|
|
102
|
+
ExecutionState.RUNNING,
|
|
103
|
+
ExecutionState.RESULTS_REQUESTED,
|
|
104
|
+
}
|
|
105
|
+
)
|
|
106
|
+
"""States in which the driver is waiting on a session event it can re-ask for."""
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _probe_interval(value: Any) -> float | None:
|
|
110
|
+
"""The validated stale-query probe interval, or ``None`` to disable it.
|
|
111
|
+
|
|
112
|
+
Like ``_handshake_bound``, returns ``None`` for a non-numeric (including
|
|
113
|
+
``bool``), non-finite or non-positive value. Never raises ``Exception``:
|
|
114
|
+
``connect()`` callers pass this through, and a bad value must not cost
|
|
115
|
+
them their connection.
|
|
116
|
+
"""
|
|
117
|
+
try:
|
|
118
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
119
|
+
return None
|
|
120
|
+
value = float(value)
|
|
121
|
+
if not math.isfinite(value) or value <= 0:
|
|
122
|
+
return None
|
|
123
|
+
return value
|
|
124
|
+
except Exception:
|
|
125
|
+
return None
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
_INVALID_VALUE_REPR_CHARS = 80
|
|
129
|
+
"""Longest repr of an invalid setting that a warning quotes."""
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _describe_invalid(value: Any) -> str:
|
|
133
|
+
"""A bounded ``repr`` of ``value`` for a warning. Never raises ``Exception``.
|
|
134
|
+
|
|
135
|
+
Truncated so a huge value (``10**400``) can't flood the log; falls back to
|
|
136
|
+
the type name if ``repr`` fails, since this runs in the constructor.
|
|
137
|
+
"""
|
|
138
|
+
try:
|
|
139
|
+
text = repr(value)
|
|
140
|
+
except Exception:
|
|
141
|
+
try:
|
|
142
|
+
return f"<unrepresentable {type(value).__name__}>"
|
|
143
|
+
except Exception:
|
|
144
|
+
return "<unrepresentable>"
|
|
145
|
+
if len(text) > _INVALID_VALUE_REPR_CHARS:
|
|
146
|
+
text = text[: _INVALID_VALUE_REPR_CHARS - 3] + "..."
|
|
147
|
+
return text
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@dataclass
|
|
151
|
+
class _Watch:
|
|
152
|
+
"""Stale-query watchdog bookkeeping for one query.
|
|
153
|
+
|
|
154
|
+
Only the reader thread touches it once the query is registered; ``__send``
|
|
155
|
+
stamps ``last_activity`` and ``last_event`` under ``__lock`` just before
|
|
156
|
+
registration.
|
|
157
|
+
"""
|
|
158
|
+
|
|
159
|
+
last_activity: float = field(default_factory=time.monotonic)
|
|
160
|
+
"""``time.monotonic()`` of the last inbound event (or probe) for the query.
|
|
161
|
+
|
|
162
|
+
Spaces the probes; see ``last_event`` for the true silence."""
|
|
163
|
+
last_event: float = field(default_factory=time.monotonic)
|
|
164
|
+
"""``time.monotonic()`` of the last inbound event (or registration).
|
|
165
|
+
|
|
166
|
+
Unlike ``last_activity``, probes don't update it, so the probe log reports
|
|
167
|
+
how long the query has really been silent."""
|
|
168
|
+
probes_sent: int = 0
|
|
169
|
+
"""Probes sent since the last event that wasn't a reply to a retrieve."""
|
|
170
|
+
probe_outstanding: bool = False
|
|
171
|
+
"""A probe was sent and no reply to a retrieve has arrived since.
|
|
172
|
+
|
|
173
|
+
Other events, a non-terminal ``state_updated`` included, leave it set: they
|
|
174
|
+
don't answer the probe, whose reply may still be on its way."""
|
|
175
|
+
probeable: bool = True
|
|
176
|
+
"""False once the session has said it can't answer probes for the query."""
|
|
177
|
+
results_requested: bool = False
|
|
178
|
+
"""The normal (non-probe) retrieve_results was sent."""
|
|
179
|
+
results_probed: bool = False
|
|
180
|
+
"""The one probe allowed after ``results_requested`` was sent."""
|
|
181
|
+
|
|
182
|
+
|
|
36
183
|
@dataclass
|
|
37
184
|
class Query:
|
|
38
185
|
sql: str
|
|
@@ -40,6 +187,7 @@ class Query:
|
|
|
40
187
|
state: ExecutionState
|
|
41
188
|
handler: Callable[[Any], None]
|
|
42
189
|
store: Store | None = None
|
|
190
|
+
watch: _Watch = field(default_factory=_Watch)
|
|
43
191
|
|
|
44
192
|
|
|
45
193
|
class Connection:
|
|
@@ -64,14 +212,40 @@ class Connection:
|
|
|
64
212
|
results_format: ResultsFormat | None = None,
|
|
65
213
|
data_compression: DataCompression | None = None,
|
|
66
214
|
geometry_representation: GeometryRepresentation | None = None,
|
|
215
|
+
session_id: str | None = None,
|
|
216
|
+
stale_query_probe_seconds: float | None = DEFAULT_STALE_QUERY_PROBE_SECONDS,
|
|
67
217
|
):
|
|
68
218
|
self.__ws = ws
|
|
69
|
-
self.__read_timeout = read_timeout
|
|
70
219
|
self.__results_format = results_format
|
|
71
220
|
self.__data_compression = data_compression
|
|
72
221
|
self.__geometry_representation = geometry_representation
|
|
73
222
|
self.__progress_handler: ProgressHandler | None = None
|
|
74
|
-
|
|
223
|
+
self.__probe_after = _probe_interval(stale_query_probe_seconds)
|
|
224
|
+
if self.__probe_after is None and stale_query_probe_seconds is not None:
|
|
225
|
+
logging.warning(
|
|
226
|
+
"Invalid stale_query_probe_seconds (%s); stale-query probes disabled.",
|
|
227
|
+
_describe_invalid(stale_query_probe_seconds),
|
|
228
|
+
)
|
|
229
|
+
self.__next_stale_check = 0.0
|
|
230
|
+
# recv() must wake up for the watchdog's checks, also when the caller
|
|
231
|
+
# asked for no read timeout (websockets accepts None) or a long one.
|
|
232
|
+
self.__recv_timeout = read_timeout
|
|
233
|
+
if self.__probe_after is not None:
|
|
234
|
+
check = min(_STALE_CHECK_MAX_INTERVAL_SECONDS, self.__probe_after)
|
|
235
|
+
if read_timeout is None or (
|
|
236
|
+
isinstance(read_timeout, (int, float)) and read_timeout > check
|
|
237
|
+
):
|
|
238
|
+
self.__recv_timeout = check
|
|
239
|
+
|
|
240
|
+
self.__session_id = session_id
|
|
241
|
+
self.__lock = threading.Lock()
|
|
242
|
+
self.__send_lock = threading.Lock()
|
|
243
|
+
self.__shutdown_done = threading.Event()
|
|
244
|
+
self.__shutdown_owner: int | None = None
|
|
245
|
+
# Resolved when shutdown starts (websockets reads close_timeout live),
|
|
246
|
+
# so the graceful/abort decision and a second closer's wait agree.
|
|
247
|
+
self.__close_bound: float | None = None
|
|
248
|
+
self.__closed = False
|
|
75
249
|
self.__queries: dict[str, Query] = {}
|
|
76
250
|
self.__thread = threading.Thread(
|
|
77
251
|
target=self.__main_loop, daemon=True, name="wherobots-connection"
|
|
@@ -85,7 +259,37 @@ class Connection:
|
|
|
85
259
|
self.close()
|
|
86
260
|
|
|
87
261
|
def close(self) -> None:
|
|
88
|
-
|
|
262
|
+
"""Close the transport, fail pending work, then wait for the reader.
|
|
263
|
+
|
|
264
|
+
The WebSocket close handshake is attempted when no send is in flight,
|
|
265
|
+
so the server can distinguish a client exit from a crash; otherwise the
|
|
266
|
+
socket is aborted. The handshake is bounded by the ``close_timeout`` of
|
|
267
|
+
the underlying ``ClientConnection`` (1s via ``connect``/
|
|
268
|
+
``connect_direct``; the library default of 10s for a caller-built
|
|
269
|
+
socket). If ``close_timeout`` doesn't bound it (``None``, missing, or
|
|
270
|
+
not a finite positive number), the socket is aborted instead.
|
|
271
|
+
|
|
272
|
+
Pending work has been failed by the time ``close()`` returns, also when
|
|
273
|
+
another thread is closing concurrently. After teardown, ``close()``
|
|
274
|
+
waits up to 1s for the reader thread, so a single call takes at most
|
|
275
|
+
about ``close_timeout`` + 1s. A second, concurrent ``close()`` can take
|
|
276
|
+
up to ``close_timeout`` + 2s (about 12s on a caller-built socket using
|
|
277
|
+
the library's 10s default).
|
|
278
|
+
|
|
279
|
+
Closing doesn't imply that server-side writes were rolled back. A
|
|
280
|
+
decoder or callback can outlive the bounded reader join.
|
|
281
|
+
"""
|
|
282
|
+
self.__fail_pending(graceful=True)
|
|
283
|
+
# A handler may close its own connection during terminal delivery.
|
|
284
|
+
if self.__shutdown_owner == threading.get_ident():
|
|
285
|
+
return
|
|
286
|
+
# Another thread may own shutdown and be mid-handshake, which is bounded
|
|
287
|
+
# by close_timeout; outlast it so close() never returns before pending
|
|
288
|
+
# work has been failed.
|
|
289
|
+
self.__shutdown_done.wait((self.__close_bound or 0.0) + 1.0)
|
|
290
|
+
# Timed from here, not from entry, so a slow handshake can't consume it.
|
|
291
|
+
if self.__thread is not threading.current_thread():
|
|
292
|
+
self.__thread.join(timeout=_READER_JOIN_SECONDS)
|
|
89
293
|
|
|
90
294
|
def commit(self) -> None:
|
|
91
295
|
raise NotSupportedError
|
|
@@ -114,18 +318,174 @@ class Connection:
|
|
|
114
318
|
def __main_loop(self) -> None:
|
|
115
319
|
"""Main background loop listening for messages from the SQL session."""
|
|
116
320
|
logging.info("Starting background connection handling loop...")
|
|
117
|
-
|
|
321
|
+
try:
|
|
322
|
+
self.__receive_loop()
|
|
323
|
+
finally:
|
|
324
|
+
self.__fail_pending()
|
|
325
|
+
|
|
326
|
+
def __receive_loop(self) -> None:
|
|
327
|
+
# recv drains buffered results before raising ConnectionClosed.
|
|
328
|
+
while True:
|
|
329
|
+
if self.__shutdown_done.is_set():
|
|
330
|
+
return
|
|
331
|
+
# Checked on every iteration, not only when recv() times out: on a
|
|
332
|
+
# busy connection, steady traffic for one query would otherwise
|
|
333
|
+
# starve another query's watchdog.
|
|
334
|
+
self.__probe_stale_queries()
|
|
118
335
|
try:
|
|
119
336
|
self.__listen()
|
|
120
337
|
except TimeoutError:
|
|
121
338
|
# Expected, retry next time
|
|
122
339
|
continue
|
|
123
|
-
except websockets.exceptions.
|
|
340
|
+
except websockets.exceptions.ConnectionClosed:
|
|
124
341
|
logging.info("Connection closed; stopping main loop.")
|
|
125
342
|
return
|
|
343
|
+
except _TransportError:
|
|
344
|
+
logging.exception("SQL session transport failed; stopping main loop")
|
|
345
|
+
return
|
|
126
346
|
except Exception as e:
|
|
127
347
|
logging.exception("Error handling message from SQL session", exc_info=e)
|
|
128
348
|
|
|
349
|
+
def __probe_stale_queries(self) -> None:
|
|
350
|
+
"""Re-ask the session about queries that have gone quiet.
|
|
351
|
+
|
|
352
|
+
Defence in depth for a lost terminal event: the driver otherwise trusts
|
|
353
|
+
a single pushed ``state_updated`` and would wait forever if it never
|
|
354
|
+
arrives. A probe is a ``retrieve_results`` request; the session answers
|
|
355
|
+
with the query's current state (and its results or result location
|
|
356
|
+
once it has succeeded), or with an error for an unknown execution.
|
|
357
|
+
|
|
358
|
+
Runs on the reader thread, which owns every ``Query``'s watchdog fields.
|
|
359
|
+
Never raises: a watchdog failure must not disturb the receive loop.
|
|
360
|
+
"""
|
|
361
|
+
probe_after = self.__probe_after
|
|
362
|
+
if probe_after is None:
|
|
363
|
+
return
|
|
364
|
+
try:
|
|
365
|
+
now = time.monotonic()
|
|
366
|
+
if now < self.__next_stale_check:
|
|
367
|
+
return
|
|
368
|
+
self.__next_stale_check = now + min(
|
|
369
|
+
_STALE_CHECK_MAX_INTERVAL_SECONDS, probe_after
|
|
370
|
+
)
|
|
371
|
+
with self.__lock:
|
|
372
|
+
if self.__closed:
|
|
373
|
+
return
|
|
374
|
+
candidates = list(self.__queries.values())
|
|
375
|
+
for query in candidates:
|
|
376
|
+
watch = query.watch
|
|
377
|
+
if not watch.probeable or query.state not in _PROBED_STATES:
|
|
378
|
+
continue
|
|
379
|
+
if watch.results_requested:
|
|
380
|
+
if watch.results_probed:
|
|
381
|
+
continue
|
|
382
|
+
wait = probe_after * _RESULTS_PROBE_AFTER_FACTOR
|
|
383
|
+
else:
|
|
384
|
+
doublings = min(watch.probes_sent, _MAX_PROBE_BACKOFF_DOUBLINGS)
|
|
385
|
+
wait = probe_after * 2**doublings
|
|
386
|
+
silence = now - watch.last_activity
|
|
387
|
+
if silence < wait:
|
|
388
|
+
continue
|
|
389
|
+
if not self.__request_results(query.execution_id, probe=True):
|
|
390
|
+
# A user thread is sending, possibly stalled, or the
|
|
391
|
+
# socket can't take a write. Don't wait: that would stop
|
|
392
|
+
# recv() for every query. Either holds for the rest too;
|
|
393
|
+
# retry at the next check.
|
|
394
|
+
break
|
|
395
|
+
# Only a reply to a retrieve clears the flag; other events,
|
|
396
|
+
# state_updated included, don't. Still set means the last
|
|
397
|
+
# probe went unanswered.
|
|
398
|
+
unanswered = watch.probe_outstanding
|
|
399
|
+
watch.probes_sent += 1
|
|
400
|
+
watch.probe_outstanding = True
|
|
401
|
+
watch.results_probed = watch.results_requested
|
|
402
|
+
# Space the next probe from this one, not from the last event.
|
|
403
|
+
watch.last_activity = now
|
|
404
|
+
logging.info(
|
|
405
|
+
"No events for query %s in %.1fs while %s; probed the SQL "
|
|
406
|
+
"session for its state (probe %d%s).",
|
|
407
|
+
query.execution_id,
|
|
408
|
+
now - watch.last_event,
|
|
409
|
+
query.state,
|
|
410
|
+
watch.probes_sent,
|
|
411
|
+
", previous probe unanswered" if unanswered else "",
|
|
412
|
+
)
|
|
413
|
+
except Exception:
|
|
414
|
+
logging.exception("Stale-query probe failed")
|
|
415
|
+
|
|
416
|
+
def __connection_error(self, execution_id: str) -> OperationalError:
|
|
417
|
+
message = (
|
|
418
|
+
f"SQL connection lost (session={self.__session_id or 'unknown'}, "
|
|
419
|
+
f"execution={execution_id}). Commit outcome is unknown; "
|
|
420
|
+
"verify the operation before retrying writes."
|
|
421
|
+
)
|
|
422
|
+
return OperationalError(message)
|
|
423
|
+
|
|
424
|
+
def __fail_pending(self, graceful: bool = False) -> None:
|
|
425
|
+
# Stop admission first. Do not wait for __send_lock: its owner may be
|
|
426
|
+
# blocked in network I/O. __closed means closing until shutdown_done.
|
|
427
|
+
# Resolve the bound before taking __lock: it reads a transport
|
|
428
|
+
# attribute, which may be a property, and nothing foreign may run under
|
|
429
|
+
# __lock or between the latch and the delivery try/finally.
|
|
430
|
+
bound = _handshake_bound(self.__ws)
|
|
431
|
+
with self.__lock:
|
|
432
|
+
if self.__closed:
|
|
433
|
+
return
|
|
434
|
+
self.__closed = True
|
|
435
|
+
self.__shutdown_owner = threading.get_ident()
|
|
436
|
+
self.__close_bound = bound
|
|
437
|
+
# __closed is a one-way latch: nothing below may be skipped, or pending
|
|
438
|
+
# queries are stranded with no path to recovery.
|
|
439
|
+
try:
|
|
440
|
+
self.__terminate_transport(graceful)
|
|
441
|
+
with self.__lock:
|
|
442
|
+
pending = list(self.__queries.values())
|
|
443
|
+
self.__queries.clear()
|
|
444
|
+
for query in pending:
|
|
445
|
+
try:
|
|
446
|
+
query.handler(
|
|
447
|
+
ExecutionResult(
|
|
448
|
+
error=self.__connection_error(query.execution_id)
|
|
449
|
+
)
|
|
450
|
+
)
|
|
451
|
+
except Exception:
|
|
452
|
+
logging.exception(
|
|
453
|
+
"Could not deliver connection failure to query handler"
|
|
454
|
+
)
|
|
455
|
+
finally:
|
|
456
|
+
self.__shutdown_owner = None
|
|
457
|
+
self.__shutdown_done.set()
|
|
458
|
+
|
|
459
|
+
def __terminate_transport(self, graceful: bool) -> None:
|
|
460
|
+
"""Disable the transport. Never raises: delivery must not be skipped."""
|
|
461
|
+
# A non-blocking acquire tells us whether a sender is inside ws.send()
|
|
462
|
+
# right now, possibly stalled holding the library's protocol mutex,
|
|
463
|
+
# which close() would deadlock on. Releasing immediately is safe: the
|
|
464
|
+
# __closed latch is already set, and __send re-checks it under __lock
|
|
465
|
+
# after taking __send_lock, so no sender can reach ws.send() from here
|
|
466
|
+
# on. Holding the lock across the handshake would only park unrelated
|
|
467
|
+
# senders (including the reader's own __request_results) for up to
|
|
468
|
+
# close_timeout instead of letting them fail fast. On a reader-side
|
|
469
|
+
# failure the transport is already broken and a close frame is
|
|
470
|
+
# pointless, so callers abort directly. Without a handshake bound the
|
|
471
|
+
# close could block forever, so abort then too.
|
|
472
|
+
if (
|
|
473
|
+
graceful
|
|
474
|
+
and self.__close_bound is not None
|
|
475
|
+
and self.__send_lock.acquire(blocking=False)
|
|
476
|
+
):
|
|
477
|
+
self.__send_lock.release()
|
|
478
|
+
try:
|
|
479
|
+
self.__ws.close()
|
|
480
|
+
return
|
|
481
|
+
except Exception:
|
|
482
|
+
logging.debug("Graceful close failed; aborting", exc_info=True)
|
|
483
|
+
try:
|
|
484
|
+
abort_connection(self.__ws)
|
|
485
|
+
except Exception:
|
|
486
|
+
# The adapter still closes the socket object in its finally block.
|
|
487
|
+
logging.exception("Could not abort SQL session transport")
|
|
488
|
+
|
|
129
489
|
def __listen(self) -> None:
|
|
130
490
|
"""Waits for the next message from the SQL session and processes it.
|
|
131
491
|
|
|
@@ -138,6 +498,27 @@ class Connection:
|
|
|
138
498
|
# Invalid event.
|
|
139
499
|
return
|
|
140
500
|
|
|
501
|
+
query = self.__queries.get(execution_id)
|
|
502
|
+
# Whether this event answers a probe (possibly along with the normal
|
|
503
|
+
# retrieve). Read before the bookkeeping below resets it.
|
|
504
|
+
probe_reply = False
|
|
505
|
+
if query is not None:
|
|
506
|
+
watch = query.watch
|
|
507
|
+
# Any inbound event, progress included, shows the query is alive.
|
|
508
|
+
watch.last_activity = watch.last_event = time.monotonic()
|
|
509
|
+
if kind == EventKind.EXECUTION_RESULT or kind == EventKind.ERROR:
|
|
510
|
+
# A reply to a retrieve_results, which may be our own probe: it
|
|
511
|
+
# resets the timer but not the backoff, so a long-running
|
|
512
|
+
# silent query isn't probed every N seconds forever.
|
|
513
|
+
probe_reply = watch.probe_outstanding
|
|
514
|
+
watch.probe_outstanding = False
|
|
515
|
+
else:
|
|
516
|
+
# A genuine event resets the backoff, but leaves an outstanding
|
|
517
|
+
# probe outstanding: a non-terminal state_updated can arrive
|
|
518
|
+
# between a probe and its reply, which must still be
|
|
519
|
+
# recognised as the probe's (see the not-found guard below).
|
|
520
|
+
watch.probes_sent = 0
|
|
521
|
+
|
|
141
522
|
# Progress events are independent of the query state machine and don't
|
|
142
523
|
# require a tracked query — the handler is connection-level.
|
|
143
524
|
if kind == EventKind.EXECUTION_PROGRESS:
|
|
@@ -157,7 +538,6 @@ class Connection:
|
|
|
157
538
|
logging.exception("Progress handler raised an exception")
|
|
158
539
|
return
|
|
159
540
|
|
|
160
|
-
query = self.__queries.get(execution_id)
|
|
161
541
|
if not query:
|
|
162
542
|
logging.warning(
|
|
163
543
|
"Received %s event for unknown execution ID %s", kind, execution_id
|
|
@@ -168,16 +548,56 @@ class Connection:
|
|
|
168
548
|
# Terminal delivery: stop tracking the query first. Keeping it in
|
|
169
549
|
# __queries would retain its handler — and the results the handler
|
|
170
550
|
# references — for the connection's lifetime (WBC-922).
|
|
171
|
-
self.
|
|
172
|
-
|
|
551
|
+
with self.__lock:
|
|
552
|
+
claimed = self.__queries.pop(execution_id, None)
|
|
553
|
+
if claimed is not None:
|
|
554
|
+
if probe_reply and result.error is None:
|
|
555
|
+
if claimed.watch.results_requested:
|
|
556
|
+
# The normal retrieve's reply may have won the race.
|
|
557
|
+
logging.info(
|
|
558
|
+
"Query %s results arrived after a stale-query probe.",
|
|
559
|
+
execution_id,
|
|
560
|
+
)
|
|
561
|
+
else:
|
|
562
|
+
logging.info(
|
|
563
|
+
"Query %s recovered from a stale-query probe reply.",
|
|
564
|
+
execution_id,
|
|
565
|
+
)
|
|
566
|
+
claimed.handler(result)
|
|
567
|
+
|
|
568
|
+
def fail_query(action: str, error: Exception) -> None:
|
|
569
|
+
# This is a query-local failure, not evidence of transport loss.
|
|
570
|
+
# Exception text may contain result data; report only its type.
|
|
571
|
+
query.state = ExecutionState.FAILED
|
|
572
|
+
message = (
|
|
573
|
+
f"Could not {action} SQL results "
|
|
574
|
+
f"(session={self.__session_id or 'unknown'}, "
|
|
575
|
+
f"execution={execution_id}; {type(error).__name__}). "
|
|
576
|
+
"The statement may have completed; verify the operation before "
|
|
577
|
+
"retrying writes."
|
|
578
|
+
)
|
|
579
|
+
logging.error("%s", message)
|
|
580
|
+
complete_query(ExecutionResult(error=OperationalError(message)))
|
|
581
|
+
|
|
582
|
+
def complete_with_store_result(result_uri: str) -> None:
|
|
583
|
+
# Results are stored in cloud storage
|
|
584
|
+
store_result = StoreResult(result_uri=result_uri, size=message.get("size"))
|
|
585
|
+
logging.info(
|
|
586
|
+
"Query %s results stored at: %s (size: %s)",
|
|
587
|
+
execution_id,
|
|
588
|
+
result_uri,
|
|
589
|
+
store_result.size,
|
|
590
|
+
)
|
|
591
|
+
query.state = ExecutionState.COMPLETED
|
|
592
|
+
complete_query(ExecutionResult(store_result=store_result))
|
|
173
593
|
|
|
174
594
|
# Incoming state transitions are handled here.
|
|
175
595
|
if kind == EventKind.STATE_UPDATED or kind == EventKind.EXECUTION_RESULT:
|
|
176
596
|
try:
|
|
177
597
|
query.state = ExecutionState[message["state"].upper()]
|
|
178
598
|
logging.info("Query %s is now %s.", execution_id, query.state)
|
|
179
|
-
except KeyError:
|
|
180
|
-
|
|
599
|
+
except (KeyError, AttributeError, TypeError) as error:
|
|
600
|
+
fail_query("interpret", error)
|
|
181
601
|
return
|
|
182
602
|
|
|
183
603
|
if query.state == ExecutionState.SUCCEEDED:
|
|
@@ -186,19 +606,7 @@ class Connection:
|
|
|
186
606
|
if kind == EventKind.STATE_UPDATED:
|
|
187
607
|
result_uri = message.get("result_uri")
|
|
188
608
|
if result_uri:
|
|
189
|
-
|
|
190
|
-
store_result = StoreResult(
|
|
191
|
-
result_uri=result_uri,
|
|
192
|
-
size=message.get("size"),
|
|
193
|
-
)
|
|
194
|
-
logging.info(
|
|
195
|
-
"Query %s results stored at: %s (size: %s)",
|
|
196
|
-
execution_id,
|
|
197
|
-
result_uri,
|
|
198
|
-
store_result.size,
|
|
199
|
-
)
|
|
200
|
-
query.state = ExecutionState.COMPLETED
|
|
201
|
-
complete_query(ExecutionResult(store_result=store_result))
|
|
609
|
+
complete_with_store_result(result_uri)
|
|
202
610
|
return
|
|
203
611
|
|
|
204
612
|
if query.store is not None:
|
|
@@ -212,21 +620,60 @@ class Connection:
|
|
|
212
620
|
return
|
|
213
621
|
|
|
214
622
|
# No store configured, request results normally
|
|
215
|
-
|
|
623
|
+
try:
|
|
624
|
+
self.__request_results(execution_id)
|
|
625
|
+
except Exception as error:
|
|
626
|
+
# Transport failures are handled by __send; a local
|
|
627
|
+
# retrieval failure must not orphan this execution.
|
|
628
|
+
fail_query("request", error)
|
|
629
|
+
return
|
|
630
|
+
|
|
631
|
+
# Otherwise, this execution_result answers a retrieve_results.
|
|
632
|
+
# For a store-configured query, that can only be a stale-query
|
|
633
|
+
# probe: the normal path never requests results for one.
|
|
634
|
+
# Sessions with the probe contract echo the store location.
|
|
635
|
+
result_uri = message.get("result_uri")
|
|
636
|
+
if result_uri:
|
|
637
|
+
complete_with_store_result(result_uri)
|
|
216
638
|
return
|
|
217
639
|
|
|
218
|
-
# Otherwise, process the results from the execution_result event.
|
|
219
640
|
results = message.get("results")
|
|
220
|
-
if not results or
|
|
641
|
+
if query.store is not None and (results is None or results == {}):
|
|
642
|
+
# Succeeded without a result location: either an older
|
|
643
|
+
# session that doesn't echo it, or an empty store result
|
|
644
|
+
# (no store_path). The two are indistinguishable, and
|
|
645
|
+
# completing empty would silently drop an old session's
|
|
646
|
+
# real result. Keep waiting for the genuine state_updated,
|
|
647
|
+
# exactly as without the watchdog, and don't probe again:
|
|
648
|
+
# the answer won't change. Known limitation: an empty store
|
|
649
|
+
# result whose terminal event was lost can't be recovered.
|
|
650
|
+
query.watch.probeable = False
|
|
651
|
+
logging.warning(
|
|
652
|
+
"Query %s succeeded, but the SQL session's reply has no "
|
|
653
|
+
"result location (older SQL session, or an empty store "
|
|
654
|
+
"result); cannot recover it, still waiting.",
|
|
655
|
+
execution_id,
|
|
656
|
+
)
|
|
657
|
+
return
|
|
658
|
+
|
|
659
|
+
if results is None or results == {}:
|
|
221
660
|
logging.warning("Got no results back from %s.", execution_id)
|
|
222
661
|
query.state = ExecutionState.COMPLETED
|
|
223
662
|
complete_query(ExecutionResult())
|
|
224
663
|
return
|
|
225
664
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
665
|
+
try:
|
|
666
|
+
if not isinstance(results, dict):
|
|
667
|
+
raise TypeError("Expected a result object")
|
|
668
|
+
decoded = self._handle_results(execution_id, results)
|
|
669
|
+
except Exception as error:
|
|
670
|
+
# Even OSError/TimeoutError here belong to decoding, not
|
|
671
|
+
# recv. Claim and deliver exactly once, including if close
|
|
672
|
+
# concurrently claims this execution.
|
|
673
|
+
fail_query("decode", error)
|
|
674
|
+
else:
|
|
675
|
+
query.state = ExecutionState.COMPLETED
|
|
676
|
+
complete_query(ExecutionResult(results=decoded))
|
|
230
677
|
elif query.state == ExecutionState.CANCELLED:
|
|
231
678
|
logging.info(
|
|
232
679
|
"Query %s has been cancelled; returning empty results.",
|
|
@@ -238,8 +685,28 @@ class Connection:
|
|
|
238
685
|
# details.
|
|
239
686
|
pass
|
|
240
687
|
elif kind == EventKind.ERROR:
|
|
241
|
-
query.state = ExecutionState.FAILED
|
|
242
688
|
error = message.get("message")
|
|
689
|
+
if (
|
|
690
|
+
probe_reply
|
|
691
|
+
and error == _EXECUTION_NOT_FOUND
|
|
692
|
+
and not query.watch.results_requested
|
|
693
|
+
and query.state != ExecutionState.FAILED
|
|
694
|
+
):
|
|
695
|
+
# The session evicted the query from its cache. If the query is
|
|
696
|
+
# still running, its terminal event will still arrive; if that
|
|
697
|
+
# event was already lost, nothing more can be done (still
|
|
698
|
+
# running and finished-with-a-lost-event look the same from
|
|
699
|
+
# here), so keep waiting either way. After the normal
|
|
700
|
+
# retrieve, though, there's nothing left to wait for.
|
|
701
|
+
query.watch.probeable = False
|
|
702
|
+
logging.warning(
|
|
703
|
+
"SQL session no longer tracks execution %s (likely evicted "
|
|
704
|
+
"from its result cache); cannot probe it, waiting for its "
|
|
705
|
+
"terminal event.",
|
|
706
|
+
execution_id,
|
|
707
|
+
)
|
|
708
|
+
return
|
|
709
|
+
query.state = ExecutionState.FAILED
|
|
243
710
|
complete_query(ExecutionResult(error=OperationalError(error)))
|
|
244
711
|
else:
|
|
245
712
|
logging.warning("Received unknown %s event!", kind)
|
|
@@ -264,16 +731,67 @@ class Connection:
|
|
|
264
731
|
with pyarrow.ipc.open_stream(stream) as reader:
|
|
265
732
|
return reader.read_pandas()
|
|
266
733
|
else:
|
|
267
|
-
|
|
734
|
+
raise NotSupportedError("Unsupported results format")
|
|
268
735
|
|
|
269
|
-
def __send(
|
|
736
|
+
def __send(
|
|
737
|
+
self,
|
|
738
|
+
message: Dict[str, Any],
|
|
739
|
+
query: Query | None = None,
|
|
740
|
+
blocking: bool = True,
|
|
741
|
+
) -> bool:
|
|
742
|
+
"""Send a request; return ``False`` only if non-blocking and busy.
|
|
743
|
+
|
|
744
|
+
Busy means another sender holds the send lock, or the socket's send
|
|
745
|
+
buffer is full.
|
|
746
|
+
"""
|
|
747
|
+
# Serialization and redaction are local work. Fail before registration,
|
|
748
|
+
# without poisoning unrelated cursors or misreporting a transport loss.
|
|
270
749
|
request = json.dumps(message)
|
|
271
750
|
# Only compute the redacted request (json.dumps + sqlparse parse) when
|
|
272
751
|
# DEBUG is actually enabled; the log argument is evaluated eagerly, so an
|
|
273
752
|
# unguarded call would redact on every request even with DEBUG off.
|
|
274
753
|
if logging.getLogger().isEnabledFor(logging.DEBUG):
|
|
275
754
|
logging.debug("Request: %s", self.__redacted_request(message))
|
|
276
|
-
self.
|
|
755
|
+
if not self.__send_lock.acquire(blocking=blocking):
|
|
756
|
+
return False
|
|
757
|
+
try:
|
|
758
|
+
with self.__lock:
|
|
759
|
+
if self.__closed:
|
|
760
|
+
if query is not None:
|
|
761
|
+
raise self.__connection_error(query.execution_id)
|
|
762
|
+
return True
|
|
763
|
+
if query is not None:
|
|
764
|
+
# The watchdog's clock starts at registration.
|
|
765
|
+
watch = query.watch
|
|
766
|
+
watch.last_activity = watch.last_event = time.monotonic()
|
|
767
|
+
self.__queries[query.execution_id] = query
|
|
768
|
+
elif message.get("execution_id") not in self.__queries:
|
|
769
|
+
return True
|
|
770
|
+
# A non-blocking send runs on the reader thread (a probe). Not
|
|
771
|
+
# waiting for the lock isn't enough: with the socket's send buffer
|
|
772
|
+
# full (a peer that keeps sending but stops reading), ws.send()
|
|
773
|
+
# itself would block, stopping recv() for every query, and would
|
|
774
|
+
# hold the library's protocol mutex, which keepalive pings need
|
|
775
|
+
# too. Treat it like a busy lock: skip, and retry at the next
|
|
776
|
+
# check. Checked outside __lock: it's a syscall on the transport.
|
|
777
|
+
if not blocking and not socket_writable(self.__ws):
|
|
778
|
+
return False
|
|
779
|
+
try:
|
|
780
|
+
self.__ws.send(request)
|
|
781
|
+
except (websockets.exceptions.ConnectionClosed, OSError):
|
|
782
|
+
pass # Terminate outside the send gate, before delivering errors.
|
|
783
|
+
except Exception:
|
|
784
|
+
# API/programming errors aren't evidence of connection loss.
|
|
785
|
+
if query is not None:
|
|
786
|
+
with self.__lock:
|
|
787
|
+
self.__queries.pop(query.execution_id, None)
|
|
788
|
+
raise
|
|
789
|
+
else:
|
|
790
|
+
return True
|
|
791
|
+
finally:
|
|
792
|
+
self.__send_lock.release()
|
|
793
|
+
self.__fail_pending()
|
|
794
|
+
return True
|
|
277
795
|
|
|
278
796
|
@staticmethod
|
|
279
797
|
def __redacted_request(message: Dict[str, Any]) -> str:
|
|
@@ -289,7 +807,14 @@ class Connection:
|
|
|
289
807
|
return json.dumps(message)
|
|
290
808
|
|
|
291
809
|
def __recv(self) -> Dict[str, Any]:
|
|
292
|
-
|
|
810
|
+
try:
|
|
811
|
+
frame = self.__ws.recv(timeout=self.__recv_timeout)
|
|
812
|
+
except TimeoutError:
|
|
813
|
+
raise # Idle polls are expected, not terminal I/O failures.
|
|
814
|
+
except OSError as e:
|
|
815
|
+
# Distinguish transport I/O failures from OSErrors raised later by
|
|
816
|
+
# protocol parsing or result decoding; only the former are terminal.
|
|
817
|
+
raise _TransportError from e
|
|
293
818
|
if isinstance(frame, str):
|
|
294
819
|
message = json.loads(frame)
|
|
295
820
|
elif isinstance(frame, bytes):
|
|
@@ -318,14 +843,6 @@ class Connection:
|
|
|
318
843
|
if store:
|
|
319
844
|
request["store"] = store.to_dict()
|
|
320
845
|
|
|
321
|
-
self.__queries[execution_id] = Query(
|
|
322
|
-
sql=sql,
|
|
323
|
-
execution_id=execution_id,
|
|
324
|
-
state=ExecutionState.EXECUTION_REQUESTED,
|
|
325
|
-
handler=handler,
|
|
326
|
-
store=store,
|
|
327
|
-
)
|
|
328
|
-
|
|
329
846
|
# Redact literal values before logging: this driver is embedded by other
|
|
330
847
|
# services, so raw SQL here would leak into their log streams (WBC-139).
|
|
331
848
|
logging.info(
|
|
@@ -334,13 +851,27 @@ class Connection:
|
|
|
334
851
|
get_statement_type(sql),
|
|
335
852
|
textwrap.shorten(redact_sql(sql), width=200),
|
|
336
853
|
)
|
|
337
|
-
self.__send(
|
|
854
|
+
self.__send(
|
|
855
|
+
request,
|
|
856
|
+
Query(
|
|
857
|
+
sql=sql,
|
|
858
|
+
execution_id=execution_id,
|
|
859
|
+
state=ExecutionState.EXECUTION_REQUESTED,
|
|
860
|
+
handler=handler,
|
|
861
|
+
store=store,
|
|
862
|
+
),
|
|
863
|
+
)
|
|
338
864
|
return execution_id
|
|
339
865
|
|
|
340
|
-
def __request_results(self, execution_id: str) ->
|
|
866
|
+
def __request_results(self, execution_id: str, probe: bool = False) -> bool:
|
|
867
|
+
"""Send retrieve_results; ``False`` if a probe found the sender busy.
|
|
868
|
+
|
|
869
|
+
A probe never waits for ``__send_lock`` or a full socket (see
|
|
870
|
+
``__probe_stale_queries`` and ``__send``).
|
|
871
|
+
"""
|
|
341
872
|
query = self.__queries.get(execution_id)
|
|
342
873
|
if not query:
|
|
343
|
-
return
|
|
874
|
+
return True
|
|
344
875
|
|
|
345
876
|
request = {
|
|
346
877
|
"kind": RequestKind.RETRIEVE_RESULTS.value,
|
|
@@ -353,9 +884,12 @@ class Connection:
|
|
|
353
884
|
if self.__geometry_representation:
|
|
354
885
|
request["geometry"] = self.__geometry_representation.value
|
|
355
886
|
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
887
|
+
if not probe:
|
|
888
|
+
# A probe only asks; the reply carries the query's actual state.
|
|
889
|
+
query.state = ExecutionState.RESULTS_REQUESTED
|
|
890
|
+
query.watch.results_requested = True
|
|
891
|
+
logging.info("Requesting results from %s ...", execution_id)
|
|
892
|
+
return self.__send(request, blocking=not probe)
|
|
359
893
|
|
|
360
894
|
def __cancel_query(self, execution_id: str) -> None:
|
|
361
895
|
"""Cancels the query with the given execution ID."""
|
wherobots/db/constants.py
CHANGED
|
@@ -14,7 +14,11 @@ 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
|
|
20
|
+
# Silence, per query, before the driver re-asks the session for its state.
|
|
21
|
+
DEFAULT_STALE_QUERY_PROBE_SECONDS: float = 30.0
|
|
18
22
|
|
|
19
23
|
MAX_MESSAGE_SIZE: int = 100 * 2**20 # 100MiB
|
|
20
24
|
PROTOCOL_VERSION: Version = Version("1.0.0")
|
wherobots/db/cursor.py
CHANGED
|
@@ -186,6 +186,9 @@ class Cursor:
|
|
|
186
186
|
self.__rowcount = -1
|
|
187
187
|
self.__description = None
|
|
188
188
|
|
|
189
|
+
# A rejected submission must not leave the previous execution ID paired
|
|
190
|
+
# with this new empty queue (which would make a later fetch wait forever).
|
|
191
|
+
self.__current_execution_id = None
|
|
189
192
|
self.__current_execution_id = self.__exec_fn(
|
|
190
193
|
_substitute_parameters(operation, parameters),
|
|
191
194
|
self.__queue.put,
|
wherobots/db/driver.py
CHANGED
|
@@ -20,10 +20,12 @@ import certifi
|
|
|
20
20
|
|
|
21
21
|
from .connection import Connection
|
|
22
22
|
from .constants import (
|
|
23
|
+
DEFAULT_CLOSE_TIMEOUT_SECONDS,
|
|
23
24
|
DEFAULT_ENDPOINT,
|
|
24
25
|
DEFAULT_READ_TIMEOUT_SECONDS,
|
|
25
26
|
DEFAULT_SESSION_TYPE,
|
|
26
27
|
DEFAULT_SESSION_WAIT_TIMEOUT_SECONDS,
|
|
28
|
+
DEFAULT_STALE_QUERY_PROBE_SECONDS,
|
|
27
29
|
MAX_MESSAGE_SIZE,
|
|
28
30
|
PARAM_STYLE,
|
|
29
31
|
PROTOCOL_VERSION,
|
|
@@ -124,6 +126,7 @@ def connect(
|
|
|
124
126
|
geometry_representation: Union[GeometryRepresentation, None] = None,
|
|
125
127
|
cancel_event: Union[threading.Event, None] = None,
|
|
126
128
|
extra_headers: Union[Dict[str, str], None] = None,
|
|
129
|
+
stale_query_probe_seconds: Union[float, None] = DEFAULT_STALE_QUERY_PROBE_SECONDS,
|
|
127
130
|
) -> Connection:
|
|
128
131
|
"""Create a connection to a Wherobots SQL session.
|
|
129
132
|
|
|
@@ -145,6 +148,12 @@ def connect(
|
|
|
145
148
|
own ``client=dbapi;ver=<version>`` hop to the right of it. This header
|
|
146
149
|
is advisory only and never affects authentication; ``extra_headers``
|
|
147
150
|
cannot be used to override the ``Authorization``/``X-API-Key`` headers.
|
|
151
|
+
:param stale_query_probe_seconds: How long a running query may go without
|
|
152
|
+
any event from the SQL session before the driver re-asks the session
|
|
153
|
+
for its state, in case its completion event was lost. Later probes
|
|
154
|
+
back off to at most 8 times this interval. ``None`` disables probing;
|
|
155
|
+
an invalid value (non-numeric, non-finite, or not positive) also
|
|
156
|
+
disables it.
|
|
148
157
|
"""
|
|
149
158
|
if not token and not api_key:
|
|
150
159
|
raise ValueError("At least one of `token` or `api_key` is required")
|
|
@@ -267,6 +276,10 @@ def connect(
|
|
|
267
276
|
data_compression=data_compression,
|
|
268
277
|
geometry_representation=geometry_representation,
|
|
269
278
|
cancel_event=cancel_event,
|
|
279
|
+
session_id=urllib.parse.urlparse(session_id_url)
|
|
280
|
+
.path.rstrip("/")
|
|
281
|
+
.rsplit("/", 1)[-1],
|
|
282
|
+
stale_query_probe_seconds=stale_query_probe_seconds,
|
|
270
283
|
)
|
|
271
284
|
|
|
272
285
|
|
|
@@ -294,7 +307,13 @@ def connect_direct(
|
|
|
294
307
|
data_compression: Union[DataCompression, None] = None,
|
|
295
308
|
geometry_representation: Union[GeometryRepresentation, None] = None,
|
|
296
309
|
cancel_event: Union[threading.Event, None] = None,
|
|
310
|
+
session_id: str | None = None,
|
|
311
|
+
stale_query_probe_seconds: Union[float, None] = DEFAULT_STALE_QUERY_PROBE_SECONDS,
|
|
297
312
|
) -> Connection:
|
|
313
|
+
"""Connect to a SQL session's WebSocket endpoint directly.
|
|
314
|
+
|
|
315
|
+
:param stale_query_probe_seconds: See :func:`connect`.
|
|
316
|
+
"""
|
|
298
317
|
uri_with_protocol = f"{uri}/{protocol}"
|
|
299
318
|
ssl_context = ssl.create_default_context()
|
|
300
319
|
ssl_context.load_verify_locations(certifi.where())
|
|
@@ -321,6 +340,7 @@ def connect_direct(
|
|
|
321
340
|
additional_headers=headers,
|
|
322
341
|
max_size=MAX_MESSAGE_SIZE,
|
|
323
342
|
open_timeout=DEFAULT_HTTP_TIMEOUT,
|
|
343
|
+
close_timeout=DEFAULT_CLOSE_TIMEOUT_SECONDS,
|
|
324
344
|
ssl=ssl_context,
|
|
325
345
|
)
|
|
326
346
|
|
|
@@ -337,4 +357,6 @@ def connect_direct(
|
|
|
337
357
|
results_format=results_format,
|
|
338
358
|
data_compression=data_compression,
|
|
339
359
|
geometry_representation=geometry_representation,
|
|
360
|
+
session_id=session_id,
|
|
361
|
+
stale_query_probe_seconds=stale_query_probe_seconds,
|
|
340
362
|
)
|
wherobots/db/types.py
CHANGED
|
@@ -9,6 +9,9 @@ class ExecutionState(LowercaseStrEnum):
|
|
|
9
9
|
EXECUTION_REQUESTED = auto()
|
|
10
10
|
"Execution of a query has been requested by the driver."
|
|
11
11
|
|
|
12
|
+
PENDING = auto()
|
|
13
|
+
"The SQL session has accepted the query but not started running it yet."
|
|
14
|
+
|
|
12
15
|
RUNNING = auto()
|
|
13
16
|
"The SQL session has reported the query is running."
|
|
14
17
|
|
{wherobots_python_dbapi-0.28.1.dist-info → wherobots_python_dbapi-0.29.0.dist-info}/METADATA
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: wherobots-python-dbapi
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.29.0
|
|
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
|
|
@@ -302,3 +302,8 @@ users may find useful:
|
|
|
302
302
|
caller needs to interrupt it (e.g. on client disconnect or timeout).
|
|
303
303
|
The event is checked before each HTTP request, between retry
|
|
304
304
|
attempts, and before the WebSocket handshake.
|
|
305
|
+
* `stale_query_probe_seconds`: how long a running query may go without
|
|
306
|
+
any message from the SQL session before the driver asks the session
|
|
307
|
+
for its state again, in case the query's completion event was lost.
|
|
308
|
+
Repeat probes back off to at most 8 times this interval. Defaults to
|
|
309
|
+
30 seconds; pass `None` to disable probing.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
wherobots/db/__init__.py,sha256=HN_uGOUMor80GB3rZIqnPcYWDAia7QAC-y9HfGKnegs,744
|
|
2
|
+
wherobots/db/_transport.py,sha256=eNe8YN_MqOTga3XNllCLFG69GQbzwEjMXBJsBLM3BEI,2256
|
|
3
|
+
wherobots/db/connection.py,sha256=0_ccXOL9Yq2yalZBbWOoeBaTkympZK30dDs4DWAXHjE,39395
|
|
4
|
+
wherobots/db/constants.py,sha256=m360b8GZ7mVgUcgLUgGu76gwQc1pOcEg7qTRTy9W3eg,984
|
|
5
|
+
wherobots/db/cursor.py,sha256=jF9v4UhfBqZ0iZEAmIYRxWYU4E8UbMFIrwfY_PlNI60,8701
|
|
6
|
+
wherobots/db/driver.py,sha256=1vlwf4RtjlCBeONqkU82zpwcZVT5FAsoL5Vuoxz_xmE,14113
|
|
7
|
+
wherobots/db/errors.py,sha256=G8a-DB6pgB0pUld59Wyq8PuywL-JWxGJfvrr-g8BPuU,310
|
|
8
|
+
wherobots/db/models.py,sha256=uk0EqZMH_RFkQusSw2AvyFivGvGJBu_WYP8FHa6JJGM,4178
|
|
9
|
+
wherobots/db/redaction.py,sha256=issl5e5QvZQbs4d5UGUJIW7SIZIYfQ04MIyuT0Eo5_g,3744
|
|
10
|
+
wherobots/db/region.py,sha256=GoFzazvgOjE1kVVT1QKAsRK88c_1XSAebR3ME4foSlQ,400
|
|
11
|
+
wherobots/db/runtime.py,sha256=eAR1oJEtssgkp-IRPTLOrlPI-_5SM_ASw6T6hQ_qF4I,985
|
|
12
|
+
wherobots/db/session_type.py,sha256=jZNwCl0nxvY-0reIn8RR_-fIYSSvIcaGx_4pRc71Lww,137
|
|
13
|
+
wherobots/db/types.py,sha256=uRobm9S8CERnV0a3uZA5hJ1cLxZGZcIHFZRcRQ6KX10,2615
|
|
14
|
+
wherobots_python_dbapi-0.29.0.dist-info/METADATA,sha256=zyJNqMu87nw1nKnZ-3fUB0znHEJ6OSaxH-oyRT5lPtQ,11509
|
|
15
|
+
wherobots_python_dbapi-0.29.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
16
|
+
wherobots_python_dbapi-0.29.0.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
|
|
17
|
+
wherobots_python_dbapi-0.29.0.dist-info/RECORD,,
|
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
wherobots/db/__init__.py,sha256=HN_uGOUMor80GB3rZIqnPcYWDAia7QAC-y9HfGKnegs,744
|
|
2
|
-
wherobots/db/connection.py,sha256=VqXqFoKeyy9DMXP8-t0GhijWBMYB154HevbDRvyMzF4,14352
|
|
3
|
-
wherobots/db/constants.py,sha256=qV-6CmOya7-f-DoRMzcDzlhcmV5PkLCcqG5TFIsxQ7o,744
|
|
4
|
-
wherobots/db/cursor.py,sha256=vaQY1WcTSehx-XmCkn-vRsv5YcTddxj0HR1ZaGAtEis,8495
|
|
5
|
-
wherobots/db/driver.py,sha256=5iZXEsR2TM0xVPdcPLwlOifIkJHsYHLM0zKkwMk4UTo,12975
|
|
6
|
-
wherobots/db/errors.py,sha256=G8a-DB6pgB0pUld59Wyq8PuywL-JWxGJfvrr-g8BPuU,310
|
|
7
|
-
wherobots/db/models.py,sha256=uk0EqZMH_RFkQusSw2AvyFivGvGJBu_WYP8FHa6JJGM,4178
|
|
8
|
-
wherobots/db/redaction.py,sha256=issl5e5QvZQbs4d5UGUJIW7SIZIYfQ04MIyuT0Eo5_g,3744
|
|
9
|
-
wherobots/db/region.py,sha256=GoFzazvgOjE1kVVT1QKAsRK88c_1XSAebR3ME4foSlQ,400
|
|
10
|
-
wherobots/db/runtime.py,sha256=eAR1oJEtssgkp-IRPTLOrlPI-_5SM_ASw6T6hQ_qF4I,985
|
|
11
|
-
wherobots/db/session_type.py,sha256=jZNwCl0nxvY-0reIn8RR_-fIYSSvIcaGx_4pRc71Lww,137
|
|
12
|
-
wherobots/db/types.py,sha256=WXKT_gbdQCH2_yXD_FECk0BpB7yfNWOLewCfax0U_Lk,2516
|
|
13
|
-
wherobots_python_dbapi-0.28.1.dist-info/METADATA,sha256=EuIBcH4k9NCG9eIQOkyJvowab-iD0LVDXbWFQ1jg28c,11173
|
|
14
|
-
wherobots_python_dbapi-0.28.1.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
15
|
-
wherobots_python_dbapi-0.28.1.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
|
|
16
|
-
wherobots_python_dbapi-0.28.1.dist-info/RECORD,,
|
{wherobots_python_dbapi-0.28.1.dist-info → wherobots_python_dbapi-0.29.0.dist-info}/licenses/LICENSE
RENAMED
|
File without changes
|