wherobots-python-dbapi 0.28.2__py3-none-any.whl → 0.30.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/__init__.py +6 -0
- wherobots/db/_transport.py +33 -0
- wherobots/db/connection.py +823 -47
- wherobots/db/constants.py +10 -0
- wherobots/db/cursor.py +10 -0
- wherobots/db/driver.py +104 -17
- wherobots/db/errors.py +77 -0
- wherobots/db/types.py +3 -0
- {wherobots_python_dbapi-0.28.2.dist-info → wherobots_python_dbapi-0.30.0.dist-info}/METADATA +62 -1
- wherobots_python_dbapi-0.30.0.dist-info/RECORD +17 -0
- wherobots_python_dbapi-0.28.2.dist-info/RECORD +0 -17
- {wherobots_python_dbapi-0.28.2.dist-info → wherobots_python_dbapi-0.30.0.dist-info}/WHEEL +0 -0
- {wherobots_python_dbapi-0.28.2.dist-info → wherobots_python_dbapi-0.30.0.dist-info}/licenses/LICENSE +0 -0
wherobots/db/connection.py
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import json
|
|
2
2
|
import logging
|
|
3
3
|
import math
|
|
4
|
+
import random
|
|
4
5
|
import textwrap
|
|
5
6
|
import threading
|
|
7
|
+
import time
|
|
6
8
|
import uuid
|
|
7
|
-
from dataclasses import dataclass
|
|
9
|
+
from dataclasses import dataclass, field
|
|
8
10
|
from typing import Any, Callable, Dict
|
|
9
11
|
|
|
10
12
|
from wherobots.db.redaction import get_statement_type, redact_sql
|
|
@@ -15,10 +17,21 @@ import cbor2
|
|
|
15
17
|
import websockets.exceptions
|
|
16
18
|
import websockets.sync.client
|
|
17
19
|
|
|
18
|
-
from .constants import
|
|
19
|
-
|
|
20
|
+
from .constants import (
|
|
21
|
+
DEFAULT_READ_TIMEOUT_SECONDS,
|
|
22
|
+
DEFAULT_RECONNECT_TIMEOUT_SECONDS,
|
|
23
|
+
DEFAULT_STALE_QUERY_PROBE_SECONDS,
|
|
24
|
+
)
|
|
25
|
+
from ._transport import abort_connection, socket_writable
|
|
20
26
|
from .cursor import Cursor
|
|
21
|
-
from .errors import
|
|
27
|
+
from .errors import (
|
|
28
|
+
ConnectionLostError,
|
|
29
|
+
ExecutionCancelledError,
|
|
30
|
+
ExecutionNotFoundError,
|
|
31
|
+
InterfaceError,
|
|
32
|
+
NotSupportedError,
|
|
33
|
+
OperationalError,
|
|
34
|
+
)
|
|
22
35
|
from .models import ExecutionResult, ProgressInfo, Store, StoreResult
|
|
23
36
|
from .types import (
|
|
24
37
|
RequestKind,
|
|
@@ -69,6 +82,153 @@ def _handshake_bound(ws: Any) -> float | None:
|
|
|
69
82
|
return None
|
|
70
83
|
|
|
71
84
|
|
|
85
|
+
_STALE_CHECK_MAX_INTERVAL_SECONDS = 1.0
|
|
86
|
+
"""Upper bound on how often the reader thread scans for stale queries."""
|
|
87
|
+
|
|
88
|
+
_MAX_PROBE_BACKOFF_DOUBLINGS = 3
|
|
89
|
+
"""Probe spacing doubles from N up to 8N (2**3 * N), then stays there."""
|
|
90
|
+
|
|
91
|
+
_RESULTS_PROBE_AFTER_FACTOR = 4
|
|
92
|
+
"""A query whose results were already requested gets one probe, after 4N.
|
|
93
|
+
|
|
94
|
+
Its terminal event has arrived; only the retrieve_results reply is missing.
|
|
95
|
+
The session re-serializes and re-sends the whole result (up to 100 MiB) for
|
|
96
|
+
every retrieve, synchronously on its event loop, so duplicates are costly. A
|
|
97
|
+
lost reply is also the rarer case: retrieve replies are sent inline on the
|
|
98
|
+
session's main loop, not pushed from a callback like state_updated.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
_EXECUTION_NOT_FOUND = "Execution not found"
|
|
102
|
+
"""sql-session's error for a retrieve_results it has no cache entry for.
|
|
103
|
+
|
|
104
|
+
Its execution cache is a small LRU that can evict a query that is still
|
|
105
|
+
running, whose terminal state_updated still arrives (it's pushed from the
|
|
106
|
+
query's own done-callback). So for a probe, this means "can't answer", not
|
|
107
|
+
"failed". For a re-attach, it means the session doesn't know the execution.
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
_PROBED_STATES = frozenset(
|
|
111
|
+
{
|
|
112
|
+
ExecutionState.EXECUTION_REQUESTED,
|
|
113
|
+
ExecutionState.PENDING,
|
|
114
|
+
ExecutionState.RUNNING,
|
|
115
|
+
ExecutionState.RESULTS_REQUESTED,
|
|
116
|
+
}
|
|
117
|
+
)
|
|
118
|
+
"""States in which the driver is waiting on a session event it can re-ask for."""
|
|
119
|
+
|
|
120
|
+
_RECONNECT_FIRST_DELAY_SECONDS = 0.5
|
|
121
|
+
"""Wait after the first failed reconnect attempt; it doubles per attempt."""
|
|
122
|
+
|
|
123
|
+
_RECONNECT_MAX_DELAY_SECONDS = 5.0
|
|
124
|
+
"""Upper bound on the wait between reconnect attempts."""
|
|
125
|
+
|
|
126
|
+
_RECONNECT_STABLE_SECONDS = 10.0
|
|
127
|
+
"""A connection lost again sooner than this after reconnecting continues the
|
|
128
|
+
same recovery, with its deadline and backoff. Otherwise a session that accepts
|
|
129
|
+
the connection and drops it at once would be reconnected to forever."""
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _positive_seconds(value: Any) -> float | None:
|
|
133
|
+
"""A validated interval setting, or ``None`` to disable what it times.
|
|
134
|
+
|
|
135
|
+
Like ``_handshake_bound``, returns ``None`` for a non-numeric (including
|
|
136
|
+
``bool``), non-finite or non-positive value. Never raises ``Exception``:
|
|
137
|
+
``connect()`` callers pass this through, and a bad value must not cost
|
|
138
|
+
them their connection.
|
|
139
|
+
"""
|
|
140
|
+
try:
|
|
141
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
142
|
+
return None
|
|
143
|
+
value = float(value)
|
|
144
|
+
if not math.isfinite(value) or value <= 0:
|
|
145
|
+
return None
|
|
146
|
+
return value
|
|
147
|
+
except Exception:
|
|
148
|
+
return None
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
_INVALID_VALUE_REPR_CHARS = 80
|
|
152
|
+
"""Longest repr of an invalid setting that a warning quotes."""
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _describe_invalid(value: Any) -> str:
|
|
156
|
+
"""A bounded ``repr`` of ``value`` for a warning. Never raises ``Exception``.
|
|
157
|
+
|
|
158
|
+
Truncated so a huge value (``10**400``) can't flood the log; falls back to
|
|
159
|
+
the type name if ``repr`` fails, since this runs in the constructor.
|
|
160
|
+
"""
|
|
161
|
+
try:
|
|
162
|
+
text = repr(value)
|
|
163
|
+
except Exception:
|
|
164
|
+
try:
|
|
165
|
+
return f"<unrepresentable {type(value).__name__}>"
|
|
166
|
+
except Exception:
|
|
167
|
+
return "<unrepresentable>"
|
|
168
|
+
if len(text) > _INVALID_VALUE_REPR_CHARS:
|
|
169
|
+
text = text[: _INVALID_VALUE_REPR_CHARS - 3] + "..."
|
|
170
|
+
return text
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@dataclass
|
|
174
|
+
class _Watch:
|
|
175
|
+
"""Stale-query watchdog bookkeeping for one query.
|
|
176
|
+
|
|
177
|
+
Only the reader thread touches it once the query is registered; ``__send``
|
|
178
|
+
stamps ``last_activity`` and ``last_event`` under ``__lock`` just before
|
|
179
|
+
registration.
|
|
180
|
+
"""
|
|
181
|
+
|
|
182
|
+
last_activity: float = field(default_factory=time.monotonic)
|
|
183
|
+
"""``time.monotonic()`` of the last inbound event (or probe) for the query.
|
|
184
|
+
|
|
185
|
+
Spaces the probes; see ``last_event`` for the true silence."""
|
|
186
|
+
last_event: float = field(default_factory=time.monotonic)
|
|
187
|
+
"""``time.monotonic()`` of the last inbound event (or registration).
|
|
188
|
+
|
|
189
|
+
Unlike ``last_activity``, probes don't update it, so the probe log reports
|
|
190
|
+
how long the query has really been silent."""
|
|
191
|
+
probes_sent: int = 0
|
|
192
|
+
"""Probes sent since the last event that wasn't a reply to a retrieve."""
|
|
193
|
+
probe_outstanding: bool = False
|
|
194
|
+
"""A probe was sent and no reply to a retrieve has arrived since.
|
|
195
|
+
|
|
196
|
+
Other events, a non-terminal ``state_updated`` included, leave it set: they
|
|
197
|
+
don't answer the probe, whose reply may still be on its way."""
|
|
198
|
+
probeable: bool = True
|
|
199
|
+
"""False once the session has said it can't answer probes for the query."""
|
|
200
|
+
results_requested: bool = False
|
|
201
|
+
"""The normal (non-probe) retrieve_results was sent."""
|
|
202
|
+
results_probed: bool = False
|
|
203
|
+
"""The one probe allowed after ``results_requested`` was sent."""
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@dataclass
|
|
207
|
+
class _Attach:
|
|
208
|
+
"""Bookkeeping for a query re-attached after reconnecting.
|
|
209
|
+
|
|
210
|
+
The session sent its events to the lost connection until then. Owned like
|
|
211
|
+
``_Watch``.
|
|
212
|
+
"""
|
|
213
|
+
|
|
214
|
+
reply_outstanding: bool = True
|
|
215
|
+
"""An attach request was sent and no reply to a retrieve has arrived since.
|
|
216
|
+
|
|
217
|
+
The attach is the query's first request on this transport, so the first
|
|
218
|
+
reply to a retrieve normally answers it. (After a pushed success, the
|
|
219
|
+
normal retrieve's reply can come first; it carries the results anyway.)
|
|
220
|
+
A probe sent while it's outstanding repeats the attach, so the order of
|
|
221
|
+
their replies doesn't matter."""
|
|
222
|
+
acknowledged: bool = False
|
|
223
|
+
"""The session answered ``attached: true``: it pushes the execution's
|
|
224
|
+
later events to this connection, and echoes stored result locations.
|
|
225
|
+
|
|
226
|
+
An older session ignores the attach and keeps sending the events to the
|
|
227
|
+
lost connection; only polling it can tell the outcome."""
|
|
228
|
+
probed: bool = False
|
|
229
|
+
"""The one probe allowed while ``reply_outstanding`` was sent."""
|
|
230
|
+
|
|
231
|
+
|
|
72
232
|
@dataclass
|
|
73
233
|
class Query:
|
|
74
234
|
sql: str
|
|
@@ -76,6 +236,18 @@ class Query:
|
|
|
76
236
|
state: ExecutionState
|
|
77
237
|
handler: Callable[[Any], None]
|
|
78
238
|
store: Store | None = None
|
|
239
|
+
watch: _Watch = field(default_factory=_Watch)
|
|
240
|
+
attach: _Attach | None = None
|
|
241
|
+
"""Set once the query is re-attached after reconnecting."""
|
|
242
|
+
cancel_requested: bool = False
|
|
243
|
+
"""This connection sent a cancel for the query.
|
|
244
|
+
|
|
245
|
+
Unlike the reader-owned fields, a user thread writes it, in ``__send``
|
|
246
|
+
under ``__lock``, once the cancel is admitted and just before it goes out:
|
|
247
|
+
so it's set by the time the session can report the cancellation, but not
|
|
248
|
+
while the cancel still waits (say, for a reconnect), when a cancellation
|
|
249
|
+
the session reports can't be this connection's. Read under ``__lock``
|
|
250
|
+
too."""
|
|
79
251
|
|
|
80
252
|
|
|
81
253
|
class Connection:
|
|
@@ -91,6 +263,12 @@ class Connection:
|
|
|
91
263
|
|
|
92
264
|
A background thread listens for events from the SQL session, and handles update to the
|
|
93
265
|
corresponding query state. Queries are tracked by their unique execution ID.
|
|
266
|
+
|
|
267
|
+
Given a ``transport_factory`` (``connect()`` and ``connect_direct()`` pass one), the
|
|
268
|
+
background thread also recovers from a lost WebSocket: it reconnects to the same SQL
|
|
269
|
+
session, for up to ``reconnect_timeout_seconds``, and re-attaches every pending query, so
|
|
270
|
+
their results still arrive. Only when that fails do pending queries fail, with
|
|
271
|
+
``ConnectionLostError``.
|
|
94
272
|
"""
|
|
95
273
|
|
|
96
274
|
def __init__(
|
|
@@ -101,15 +279,70 @@ class Connection:
|
|
|
101
279
|
data_compression: DataCompression | None = None,
|
|
102
280
|
geometry_representation: GeometryRepresentation | None = None,
|
|
103
281
|
session_id: str | None = None,
|
|
282
|
+
stale_query_probe_seconds: float | None = DEFAULT_STALE_QUERY_PROBE_SECONDS,
|
|
283
|
+
session_fallback: str | None = None,
|
|
284
|
+
*,
|
|
285
|
+
transport_factory: (
|
|
286
|
+
Callable[[float], websockets.sync.client.ClientConnection] | None
|
|
287
|
+
) = None,
|
|
288
|
+
reconnect_timeout_seconds: float | None = DEFAULT_RECONNECT_TIMEOUT_SECONDS,
|
|
104
289
|
):
|
|
290
|
+
"""
|
|
291
|
+
:param transport_factory: Opens a new WebSocket to the same SQL session, given
|
|
292
|
+
the seconds left to do so in, for automatic reconnection. It raises
|
|
293
|
+
``InterfaceError`` if reconnecting can't succeed (the session refused the
|
|
294
|
+
connection, or the caller cancelled), and any other exception for a
|
|
295
|
+
failed attempt worth retrying. Without it, a lost transport fails the
|
|
296
|
+
pending queries at once.
|
|
297
|
+
:param reconnect_timeout_seconds: How long to keep trying to reconnect after
|
|
298
|
+
losing the transport. ``None``, or an invalid value (non-numeric,
|
|
299
|
+
non-finite, or not positive), disables automatic reconnection.
|
|
300
|
+
"""
|
|
105
301
|
self.__ws = ws
|
|
106
|
-
self.
|
|
302
|
+
self.__transport_factory = transport_factory
|
|
303
|
+
self.__reconnect_within = (
|
|
304
|
+
_positive_seconds(reconnect_timeout_seconds)
|
|
305
|
+
if transport_factory is not None
|
|
306
|
+
else None
|
|
307
|
+
)
|
|
308
|
+
if (
|
|
309
|
+
transport_factory is not None
|
|
310
|
+
and self.__reconnect_within is None
|
|
311
|
+
and reconnect_timeout_seconds is not None
|
|
312
|
+
):
|
|
313
|
+
logging.warning(
|
|
314
|
+
"Invalid reconnect_timeout_seconds (%s); automatic reconnection "
|
|
315
|
+
"disabled.",
|
|
316
|
+
_describe_invalid(reconnect_timeout_seconds),
|
|
317
|
+
)
|
|
318
|
+
# The current recovery, which a loss soon after reconnecting continues.
|
|
319
|
+
# Only the reader thread touches these.
|
|
320
|
+
self.__recovery_deadline = 0.0
|
|
321
|
+
self.__reconnect_delay = _RECONNECT_FIRST_DELAY_SECONDS
|
|
322
|
+
self.__reconnected_at: float | None = None
|
|
107
323
|
self.__results_format = results_format
|
|
108
324
|
self.__data_compression = data_compression
|
|
109
325
|
self.__geometry_representation = geometry_representation
|
|
110
326
|
self.__progress_handler: ProgressHandler | None = None
|
|
327
|
+
self.__probe_after = _positive_seconds(stale_query_probe_seconds)
|
|
328
|
+
if self.__probe_after is None and stale_query_probe_seconds is not None:
|
|
329
|
+
logging.warning(
|
|
330
|
+
"Invalid stale_query_probe_seconds (%s); stale-query probes disabled.",
|
|
331
|
+
_describe_invalid(stale_query_probe_seconds),
|
|
332
|
+
)
|
|
333
|
+
self.__next_stale_check = 0.0
|
|
334
|
+
# recv() must wake up for the watchdog's checks, also when the caller
|
|
335
|
+
# asked for no read timeout (websockets accepts None) or a long one.
|
|
336
|
+
self.__recv_timeout = read_timeout
|
|
337
|
+
if self.__probe_after is not None:
|
|
338
|
+
check = min(_STALE_CHECK_MAX_INTERVAL_SECONDS, self.__probe_after)
|
|
339
|
+
if read_timeout is None or (
|
|
340
|
+
isinstance(read_timeout, (int, float)) and read_timeout > check
|
|
341
|
+
):
|
|
342
|
+
self.__recv_timeout = check
|
|
111
343
|
|
|
112
344
|
self.__session_id = session_id
|
|
345
|
+
self.__session_fallback = session_fallback
|
|
113
346
|
self.__lock = threading.Lock()
|
|
114
347
|
self.__send_lock = threading.Lock()
|
|
115
348
|
self.__shutdown_done = threading.Event()
|
|
@@ -124,6 +357,19 @@ class Connection:
|
|
|
124
357
|
)
|
|
125
358
|
self.__thread.start()
|
|
126
359
|
|
|
360
|
+
@property
|
|
361
|
+
def session_id(self) -> str | None:
|
|
362
|
+
"""The id of the SQL session this connection is on, when known."""
|
|
363
|
+
return self.__session_id
|
|
364
|
+
|
|
365
|
+
@property
|
|
366
|
+
def session_fallback(self) -> str | None:
|
|
367
|
+
"""Why the API gave this connection a session under another key than
|
|
368
|
+
the one it asked for, or None when it did not. ``"quota"``: a session
|
|
369
|
+
of the key's own would have exceeded a quota, so the connection shares
|
|
370
|
+
another of the user's sessions."""
|
|
371
|
+
return self.__session_fallback
|
|
372
|
+
|
|
127
373
|
def __enter__(self):
|
|
128
374
|
return self
|
|
129
375
|
|
|
@@ -191,7 +437,12 @@ class Connection:
|
|
|
191
437
|
"""Main background loop listening for messages from the SQL session."""
|
|
192
438
|
logging.info("Starting background connection handling loop...")
|
|
193
439
|
try:
|
|
194
|
-
|
|
440
|
+
# The receive loop returns when the transport is gone: carry on over
|
|
441
|
+
# a new one, or stop.
|
|
442
|
+
while True:
|
|
443
|
+
self.__receive_loop()
|
|
444
|
+
if not self.__reconnect():
|
|
445
|
+
return
|
|
195
446
|
finally:
|
|
196
447
|
self.__fail_pending()
|
|
197
448
|
|
|
@@ -200,27 +451,280 @@ class Connection:
|
|
|
200
451
|
while True:
|
|
201
452
|
if self.__shutdown_done.is_set():
|
|
202
453
|
return
|
|
454
|
+
# Checked on every iteration, not only when recv() times out: on a
|
|
455
|
+
# busy connection, steady traffic for one query would otherwise
|
|
456
|
+
# starve another query's watchdog.
|
|
457
|
+
self.__probe_stale_queries()
|
|
203
458
|
try:
|
|
204
459
|
self.__listen()
|
|
205
460
|
except TimeoutError:
|
|
206
461
|
# Expected, retry next time
|
|
207
462
|
continue
|
|
208
463
|
except websockets.exceptions.ConnectionClosed:
|
|
209
|
-
logging.info("
|
|
464
|
+
logging.info("SQL session connection closed.")
|
|
210
465
|
return
|
|
211
466
|
except _TransportError:
|
|
212
|
-
logging.exception("SQL session transport failed
|
|
467
|
+
logging.exception("SQL session transport failed")
|
|
213
468
|
return
|
|
214
469
|
except Exception as e:
|
|
215
470
|
logging.exception("Error handling message from SQL session", exc_info=e)
|
|
216
471
|
|
|
217
|
-
def
|
|
472
|
+
def __probe_stale_queries(self) -> None:
|
|
473
|
+
"""Re-ask the session about queries that have gone quiet.
|
|
474
|
+
|
|
475
|
+
Defence in depth for a lost terminal event: the driver otherwise trusts
|
|
476
|
+
a single pushed ``state_updated`` and would wait forever if it never
|
|
477
|
+
arrives. A probe is a ``retrieve_results`` request; the session answers
|
|
478
|
+
with the query's current state (and its results or result location
|
|
479
|
+
once it has succeeded), or with an error for an unknown execution.
|
|
480
|
+
|
|
481
|
+
A query re-attached after a reconnect is probed the same way, which is
|
|
482
|
+
how the driver learns its outcome from an older session that ignores
|
|
483
|
+
the attach.
|
|
484
|
+
|
|
485
|
+
Runs on the reader thread, which owns every ``Query``'s watchdog fields.
|
|
486
|
+
Never raises: a watchdog failure must not disturb the receive loop.
|
|
487
|
+
"""
|
|
488
|
+
probe_after = self.__probe_after
|
|
489
|
+
if probe_after is None:
|
|
490
|
+
return
|
|
491
|
+
try:
|
|
492
|
+
now = time.monotonic()
|
|
493
|
+
if now < self.__next_stale_check:
|
|
494
|
+
return
|
|
495
|
+
self.__next_stale_check = now + min(
|
|
496
|
+
_STALE_CHECK_MAX_INTERVAL_SECONDS, probe_after
|
|
497
|
+
)
|
|
498
|
+
with self.__lock:
|
|
499
|
+
if self.__closed:
|
|
500
|
+
return
|
|
501
|
+
candidates = list(self.__queries.values())
|
|
502
|
+
for query in candidates:
|
|
503
|
+
watch = query.watch
|
|
504
|
+
if not watch.probeable or query.state not in _PROBED_STATES:
|
|
505
|
+
continue
|
|
506
|
+
# The query's attach, while it's unanswered.
|
|
507
|
+
attach = query.attach
|
|
508
|
+
if attach is not None and not attach.reply_outstanding:
|
|
509
|
+
attach = None
|
|
510
|
+
if watch.results_requested:
|
|
511
|
+
if watch.results_probed:
|
|
512
|
+
continue
|
|
513
|
+
wait = probe_after * _RESULTS_PROBE_AFTER_FACTOR
|
|
514
|
+
elif attach is not None:
|
|
515
|
+
# The attach is a retrieve_results too, whose reply carries
|
|
516
|
+
# the whole result if the query has succeeded: like the
|
|
517
|
+
# normal retrieve, it gets one probe, after 4N.
|
|
518
|
+
if attach.probed:
|
|
519
|
+
continue
|
|
520
|
+
wait = probe_after * _RESULTS_PROBE_AFTER_FACTOR
|
|
521
|
+
else:
|
|
522
|
+
doublings = min(watch.probes_sent, _MAX_PROBE_BACKOFF_DOUBLINGS)
|
|
523
|
+
wait = probe_after * 2**doublings
|
|
524
|
+
silence = now - watch.last_activity
|
|
525
|
+
if silence < wait:
|
|
526
|
+
continue
|
|
527
|
+
# Until the attach is answered, the probe repeats it: the
|
|
528
|
+
# session may answer the two in either order, and either
|
|
529
|
+
# reply then says whether the session understood the attach.
|
|
530
|
+
if not self.__request_results(
|
|
531
|
+
query.execution_id, probe=True, attach=attach is not None
|
|
532
|
+
):
|
|
533
|
+
# A user thread is sending, possibly stalled, or the
|
|
534
|
+
# socket can't take a write. Don't wait: that would stop
|
|
535
|
+
# recv() for every query. Either holds for the rest too;
|
|
536
|
+
# retry at the next check.
|
|
537
|
+
break
|
|
538
|
+
# Only a reply to a retrieve clears the flag; other events,
|
|
539
|
+
# state_updated included, don't. Still set means the last
|
|
540
|
+
# probe went unanswered.
|
|
541
|
+
unanswered = watch.probe_outstanding
|
|
542
|
+
watch.probes_sent += 1
|
|
543
|
+
watch.probe_outstanding = True
|
|
544
|
+
watch.results_probed = watch.results_requested
|
|
545
|
+
if attach is not None:
|
|
546
|
+
attach.probed = True
|
|
547
|
+
# Space the next probe from this one, not from the last event.
|
|
548
|
+
watch.last_activity = now
|
|
549
|
+
logging.info(
|
|
550
|
+
"No events for query %s in %.1fs while %s; probed the SQL "
|
|
551
|
+
"session for its state (probe %d%s).",
|
|
552
|
+
query.execution_id,
|
|
553
|
+
now - watch.last_event,
|
|
554
|
+
query.state,
|
|
555
|
+
watch.probes_sent,
|
|
556
|
+
", previous probe unanswered" if unanswered else "",
|
|
557
|
+
)
|
|
558
|
+
except Exception:
|
|
559
|
+
logging.exception("Stale-query probe failed")
|
|
560
|
+
|
|
561
|
+
def __reconnect(self) -> bool:
|
|
562
|
+
"""Connect to the same SQL session again after losing the transport.
|
|
563
|
+
|
|
564
|
+
Runs on the reader thread once ``__receive_loop`` has returned. Tries,
|
|
565
|
+
with backoff, until ``reconnect_timeout_seconds`` has passed, then
|
|
566
|
+
re-attaches every pending query over the new transport (see
|
|
567
|
+
``__resume``). Returns ``False`` if the connection must close instead:
|
|
568
|
+
reconnecting is off, ``close()`` was called, the session refused the
|
|
569
|
+
connection, or time ran out.
|
|
570
|
+
|
|
571
|
+
Holds ``__send_lock`` throughout, so user threads' sends wait for the
|
|
572
|
+
outcome: an ``execute()`` then goes out over the new transport, after
|
|
573
|
+
the re-attaches, or fails with ``ConnectionLostError`` once the
|
|
574
|
+
connection is closed.
|
|
575
|
+
"""
|
|
576
|
+
within = self.__reconnect_within
|
|
577
|
+
factory = self.__transport_factory
|
|
578
|
+
if within is None or factory is None:
|
|
579
|
+
return False
|
|
580
|
+
with self.__lock:
|
|
581
|
+
if self.__closed:
|
|
582
|
+
return False
|
|
583
|
+
lost = self.__ws
|
|
584
|
+
pending = len(self.__queries)
|
|
585
|
+
now = time.monotonic()
|
|
586
|
+
# A transport lost soon after reconnecting continues that recovery,
|
|
587
|
+
# backing off before its first attempt.
|
|
588
|
+
continued = (
|
|
589
|
+
self.__reconnected_at is not None
|
|
590
|
+
and now - self.__reconnected_at < _RECONNECT_STABLE_SECONDS
|
|
591
|
+
)
|
|
592
|
+
if not continued:
|
|
593
|
+
self.__recovery_deadline = now + within
|
|
594
|
+
self.__reconnect_delay = _RECONNECT_FIRST_DELAY_SECONDS
|
|
595
|
+
logging.warning(
|
|
596
|
+
"Lost the connection to SQL session %s with %d pending queries; "
|
|
597
|
+
"reconnecting for up to %.0fs ...",
|
|
598
|
+
self.__session_id or "(unknown)",
|
|
599
|
+
pending,
|
|
600
|
+
max(0.0, self.__recovery_deadline - now),
|
|
601
|
+
)
|
|
602
|
+
# A sender stalled on the lost transport holds __send_lock: make its
|
|
603
|
+
# send fail now.
|
|
604
|
+
try:
|
|
605
|
+
abort_connection(lost)
|
|
606
|
+
except Exception:
|
|
607
|
+
logging.debug("Could not abort the lost transport", exc_info=True)
|
|
608
|
+
with self.__send_lock:
|
|
609
|
+
ws = self.__open_transport(factory, back_off_first=continued)
|
|
610
|
+
if ws is None:
|
|
611
|
+
return False
|
|
612
|
+
return self.__resume(ws)
|
|
613
|
+
|
|
614
|
+
def __open_transport(
|
|
615
|
+
self,
|
|
616
|
+
factory: Callable[[float], websockets.sync.client.ClientConnection],
|
|
617
|
+
back_off_first: bool,
|
|
618
|
+
) -> websockets.sync.client.ClientConnection | None:
|
|
619
|
+
"""Open a new transport, retrying until the recovery's deadline.
|
|
620
|
+
|
|
621
|
+
``None`` if it gave up, or the connection is being closed: ``close()``
|
|
622
|
+
cuts a backoff short, though not an attempt in progress.
|
|
623
|
+
"""
|
|
624
|
+
session = self.__session_id or "(unknown)"
|
|
625
|
+
attempts = 0
|
|
626
|
+
while True:
|
|
627
|
+
if attempts or back_off_first:
|
|
628
|
+
remaining = self.__recovery_deadline - time.monotonic()
|
|
629
|
+
if remaining > 0:
|
|
630
|
+
# Jittered, so clients dropped together don't retry together.
|
|
631
|
+
delay = self.__reconnect_delay * random.uniform(0.5, 1.0)
|
|
632
|
+
self.__reconnect_delay = min(
|
|
633
|
+
self.__reconnect_delay * 2, _RECONNECT_MAX_DELAY_SECONDS
|
|
634
|
+
)
|
|
635
|
+
if self.__shutdown_done.wait(min(delay, remaining)):
|
|
636
|
+
return None
|
|
637
|
+
with self.__lock:
|
|
638
|
+
if self.__closed:
|
|
639
|
+
return None
|
|
640
|
+
remaining = self.__recovery_deadline - time.monotonic()
|
|
641
|
+
if remaining <= 0:
|
|
642
|
+
logging.error(
|
|
643
|
+
"Could not reconnect to SQL session %s in time (%d attempts).",
|
|
644
|
+
session,
|
|
645
|
+
attempts,
|
|
646
|
+
)
|
|
647
|
+
return None
|
|
648
|
+
attempts += 1
|
|
649
|
+
try:
|
|
650
|
+
return factory(remaining)
|
|
651
|
+
except InterfaceError as error:
|
|
652
|
+
logging.error(
|
|
653
|
+
"Could not reconnect to SQL session %s: %s", session, error
|
|
654
|
+
)
|
|
655
|
+
return None
|
|
656
|
+
except Exception as error:
|
|
657
|
+
logging.info(
|
|
658
|
+
"Reconnect attempt %d to SQL session %s failed: %s",
|
|
659
|
+
attempts,
|
|
660
|
+
session,
|
|
661
|
+
error,
|
|
662
|
+
)
|
|
663
|
+
|
|
664
|
+
def __resume(self, ws: websockets.sync.client.ClientConnection) -> bool:
|
|
665
|
+
"""Carry on over a new transport, re-attaching every pending query.
|
|
666
|
+
|
|
667
|
+
Called on the reader thread with ``__send_lock`` held. Each pending
|
|
668
|
+
query gets a ``retrieve_results`` with ``attach``, keeping its handler
|
|
669
|
+
and store: a session that supports attaching makes this connection the
|
|
670
|
+
owner again, pushing the execution's later events here, and answers
|
|
671
|
+
with ``attached``; an older one ignores the flag, and is polled. A query this connection cancelled is
|
|
672
|
+
cancelled again after its attach, as the cancel may have been lost
|
|
673
|
+
with the transport.
|
|
674
|
+
|
|
675
|
+
``False`` if ``close()`` was called while connecting.
|
|
676
|
+
"""
|
|
677
|
+
with self.__lock:
|
|
678
|
+
closed = self.__closed
|
|
679
|
+
if not closed:
|
|
680
|
+
self.__ws = ws
|
|
681
|
+
pending = list(self.__queries.values())
|
|
682
|
+
for query in pending:
|
|
683
|
+
# The attach is a retrieve_results; its reply has the state.
|
|
684
|
+
query.state = ExecutionState.RESULTS_REQUESTED
|
|
685
|
+
query.watch = _Watch()
|
|
686
|
+
query.attach = _Attach()
|
|
687
|
+
cancelled = [q.execution_id for q in pending if q.cancel_requested]
|
|
688
|
+
if closed:
|
|
689
|
+
try:
|
|
690
|
+
abort_connection(ws)
|
|
691
|
+
except Exception:
|
|
692
|
+
logging.debug("Could not abort the new transport", exc_info=True)
|
|
693
|
+
return False
|
|
694
|
+
self.__reconnected_at = time.monotonic()
|
|
695
|
+
logging.info(
|
|
696
|
+
"Reconnected to SQL session %s; re-attaching %d pending queries.",
|
|
697
|
+
self.__session_id or "(unknown)",
|
|
698
|
+
len(pending),
|
|
699
|
+
)
|
|
700
|
+
requests = [
|
|
701
|
+
self.__retrieve_request(query.execution_id, attach=True)
|
|
702
|
+
for query in pending
|
|
703
|
+
] + [
|
|
704
|
+
{"kind": RequestKind.CANCEL.value, "execution_id": execution_id}
|
|
705
|
+
for execution_id in cancelled
|
|
706
|
+
]
|
|
707
|
+
try:
|
|
708
|
+
for request in requests:
|
|
709
|
+
ws.send(json.dumps(request))
|
|
710
|
+
except (websockets.exceptions.ConnectionClosed, OSError):
|
|
711
|
+
# Lost again already: the receive loop notices, and reconnects.
|
|
712
|
+
logging.warning("Lost the new SQL session connection while re-attaching.")
|
|
713
|
+
try:
|
|
714
|
+
abort_connection(ws)
|
|
715
|
+
except Exception:
|
|
716
|
+
logging.debug("Could not abort the new transport", exc_info=True)
|
|
717
|
+
return True
|
|
718
|
+
|
|
719
|
+
def __connection_error(self, execution_id: str) -> ConnectionLostError:
|
|
218
720
|
message = (
|
|
219
721
|
f"SQL connection lost (session={self.__session_id or 'unknown'}, "
|
|
220
722
|
f"execution={execution_id}). Commit outcome is unknown; "
|
|
221
723
|
"verify the operation before retrying writes."
|
|
222
724
|
)
|
|
223
|
-
return
|
|
725
|
+
return ConnectionLostError(
|
|
726
|
+
message, session_id=self.__session_id, execution_id=execution_id
|
|
727
|
+
)
|
|
224
728
|
|
|
225
729
|
def __fail_pending(self, graceful: bool = False) -> None:
|
|
226
730
|
# Stop admission first. Do not wait for __send_lock: its owner may be
|
|
@@ -299,6 +803,37 @@ class Connection:
|
|
|
299
803
|
# Invalid event.
|
|
300
804
|
return
|
|
301
805
|
|
|
806
|
+
query = self.__queries.get(execution_id)
|
|
807
|
+
# Whether this event answers a probe (possibly along with the normal
|
|
808
|
+
# retrieve). Read before the bookkeeping below resets it.
|
|
809
|
+
probe_reply = False
|
|
810
|
+
# Likewise, whether it answers the attach of a re-attached query.
|
|
811
|
+
attach_reply = False
|
|
812
|
+
attach = query.attach if query is not None else None
|
|
813
|
+
if query is not None:
|
|
814
|
+
watch = query.watch
|
|
815
|
+
# Any inbound event, progress included, shows the query is alive.
|
|
816
|
+
watch.last_activity = watch.last_event = time.monotonic()
|
|
817
|
+
if kind == EventKind.EXECUTION_RESULT or kind == EventKind.ERROR:
|
|
818
|
+
# A reply to a retrieve_results, which may be our own probe: it
|
|
819
|
+
# resets the timer but not the backoff, so a long-running
|
|
820
|
+
# silent query isn't probed every N seconds forever.
|
|
821
|
+
probe_reply = watch.probe_outstanding
|
|
822
|
+
watch.probe_outstanding = False
|
|
823
|
+
if attach is not None:
|
|
824
|
+
attach_reply = attach.reply_outstanding
|
|
825
|
+
attach.reply_outstanding = False
|
|
826
|
+
# Only a reply to an attach says so, and only from a
|
|
827
|
+
# session that supports it. Once said, it holds.
|
|
828
|
+
if message.get("attached") is True:
|
|
829
|
+
attach.acknowledged = True
|
|
830
|
+
else:
|
|
831
|
+
# A genuine event resets the backoff, but leaves an outstanding
|
|
832
|
+
# probe outstanding: a non-terminal state_updated can arrive
|
|
833
|
+
# between a probe and its reply, which must still be
|
|
834
|
+
# recognised as the probe's (see the not-found guard below).
|
|
835
|
+
watch.probes_sent = 0
|
|
836
|
+
|
|
302
837
|
# Progress events are independent of the query state machine and don't
|
|
303
838
|
# require a tracked query — the handler is connection-level.
|
|
304
839
|
if kind == EventKind.EXECUTION_PROGRESS:
|
|
@@ -318,7 +853,6 @@ class Connection:
|
|
|
318
853
|
logging.exception("Progress handler raised an exception")
|
|
319
854
|
return
|
|
320
855
|
|
|
321
|
-
query = self.__queries.get(execution_id)
|
|
322
856
|
if not query:
|
|
323
857
|
logging.warning(
|
|
324
858
|
"Received %s event for unknown execution ID %s", kind, execution_id
|
|
@@ -332,6 +866,18 @@ class Connection:
|
|
|
332
866
|
with self.__lock:
|
|
333
867
|
claimed = self.__queries.pop(execution_id, None)
|
|
334
868
|
if claimed is not None:
|
|
869
|
+
if probe_reply and result.error is None:
|
|
870
|
+
if claimed.watch.results_requested:
|
|
871
|
+
# The normal retrieve's reply may have won the race.
|
|
872
|
+
logging.info(
|
|
873
|
+
"Query %s results arrived after a stale-query probe.",
|
|
874
|
+
execution_id,
|
|
875
|
+
)
|
|
876
|
+
else:
|
|
877
|
+
logging.info(
|
|
878
|
+
"Query %s recovered from a stale-query probe reply.",
|
|
879
|
+
execution_id,
|
|
880
|
+
)
|
|
335
881
|
claimed.handler(result)
|
|
336
882
|
|
|
337
883
|
def fail_query(action: str, error: Exception) -> None:
|
|
@@ -348,6 +894,25 @@ class Connection:
|
|
|
348
894
|
logging.error("%s", message)
|
|
349
895
|
complete_query(ExecutionResult(error=OperationalError(message)))
|
|
350
896
|
|
|
897
|
+
def complete_with_store_result(result_uri: str) -> None:
|
|
898
|
+
# Results are stored in cloud storage
|
|
899
|
+
store_result = StoreResult(result_uri=result_uri, size=message.get("size"))
|
|
900
|
+
logging.info(
|
|
901
|
+
"Query %s results stored at: %s (size: %s)",
|
|
902
|
+
execution_id,
|
|
903
|
+
result_uri,
|
|
904
|
+
store_result.size,
|
|
905
|
+
)
|
|
906
|
+
query.state = ExecutionState.COMPLETED
|
|
907
|
+
complete_query(ExecutionResult(store_result=store_result))
|
|
908
|
+
|
|
909
|
+
def fail_attached(error: OperationalError) -> None:
|
|
910
|
+
# The session's answer to an attached query that leaves nothing
|
|
911
|
+
# to wait for on this connection.
|
|
912
|
+
query.state = ExecutionState.FAILED
|
|
913
|
+
logging.warning("%s", error)
|
|
914
|
+
complete_query(ExecutionResult(error=error))
|
|
915
|
+
|
|
351
916
|
# Incoming state transitions are handled here.
|
|
352
917
|
if kind == EventKind.STATE_UPDATED or kind == EventKind.EXECUTION_RESULT:
|
|
353
918
|
try:
|
|
@@ -357,25 +922,37 @@ class Connection:
|
|
|
357
922
|
fail_query("interpret", error)
|
|
358
923
|
return
|
|
359
924
|
|
|
925
|
+
if attach_reply and attach is not None:
|
|
926
|
+
if attach.acknowledged:
|
|
927
|
+
logging.info("Attached to execution %s.", execution_id)
|
|
928
|
+
elif query.state in (ExecutionState.PENDING, ExecutionState.RUNNING):
|
|
929
|
+
# An older session ignored the attach: the execution's
|
|
930
|
+
# later events go to the lost connection. Only probes can
|
|
931
|
+
# tell its outcome.
|
|
932
|
+
if self.__probe_after is None:
|
|
933
|
+
fail_attached(
|
|
934
|
+
OperationalError(
|
|
935
|
+
f"Execution {execution_id} is {query.state} on "
|
|
936
|
+
"a SQL session that doesn't support attaching "
|
|
937
|
+
f"(session={self.__session_id or 'unknown'}), and "
|
|
938
|
+
"stale-query probes are disabled, so this "
|
|
939
|
+
"connection can't learn its outcome."
|
|
940
|
+
)
|
|
941
|
+
)
|
|
942
|
+
return
|
|
943
|
+
logging.warning(
|
|
944
|
+
"SQL session doesn't support attaching; polling "
|
|
945
|
+
"execution %s for its outcome.",
|
|
946
|
+
execution_id,
|
|
947
|
+
)
|
|
948
|
+
|
|
360
949
|
if query.state == ExecutionState.SUCCEEDED:
|
|
361
950
|
# On a state_updated event telling us the query succeeded,
|
|
362
951
|
# check if results are stored in cloud storage or need to be fetched.
|
|
363
952
|
if kind == EventKind.STATE_UPDATED:
|
|
364
953
|
result_uri = message.get("result_uri")
|
|
365
954
|
if result_uri:
|
|
366
|
-
|
|
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))
|
|
955
|
+
complete_with_store_result(result_uri)
|
|
379
956
|
return
|
|
380
957
|
|
|
381
958
|
if query.store is not None:
|
|
@@ -397,8 +974,67 @@ class Connection:
|
|
|
397
974
|
fail_query("request", error)
|
|
398
975
|
return
|
|
399
976
|
|
|
400
|
-
# Otherwise,
|
|
977
|
+
# Otherwise, this execution_result answers a retrieve_results.
|
|
978
|
+
# For a store-configured query, that can only be a stale-query
|
|
979
|
+
# probe: the normal path never requests results for one.
|
|
980
|
+
# Sessions with the probe contract echo the store location.
|
|
981
|
+
result_uri = message.get("result_uri")
|
|
982
|
+
if result_uri:
|
|
983
|
+
complete_with_store_result(result_uri)
|
|
984
|
+
return
|
|
985
|
+
|
|
401
986
|
results = message.get("results")
|
|
987
|
+
if (
|
|
988
|
+
query.store is not None
|
|
989
|
+
and (results is None or results == {})
|
|
990
|
+
and attach is not None
|
|
991
|
+
):
|
|
992
|
+
if attach.acknowledged:
|
|
993
|
+
# A session that supports attaching also echoes the
|
|
994
|
+
# result location, so this is an empty store result.
|
|
995
|
+
logging.info(
|
|
996
|
+
"Query %s completed with store configured but no "
|
|
997
|
+
"results to store.",
|
|
998
|
+
execution_id,
|
|
999
|
+
)
|
|
1000
|
+
query.state = ExecutionState.COMPLETED
|
|
1001
|
+
complete_query(ExecutionResult())
|
|
1002
|
+
return
|
|
1003
|
+
# An older session: either it predates echoing the
|
|
1004
|
+
# location, which went to the lost connection with the
|
|
1005
|
+
# terminal state_updated, or the stored result is empty.
|
|
1006
|
+
# Nothing more will arrive here. Fail rather than wait
|
|
1007
|
+
# forever, or report a real result as empty.
|
|
1008
|
+
fail_attached(
|
|
1009
|
+
OperationalError(
|
|
1010
|
+
f"Execution {execution_id} succeeded, but the SQL "
|
|
1011
|
+
f"session (session={self.__session_id or 'unknown'}) "
|
|
1012
|
+
"doesn't support attaching and reported no stored "
|
|
1013
|
+
"result location: the location went to the lost "
|
|
1014
|
+
"connection, or the stored result is empty. It "
|
|
1015
|
+
"can't be recovered."
|
|
1016
|
+
)
|
|
1017
|
+
)
|
|
1018
|
+
return
|
|
1019
|
+
|
|
1020
|
+
if query.store is not None and (results is None or results == {}):
|
|
1021
|
+
# Succeeded without a result location: either an older
|
|
1022
|
+
# session that doesn't echo it, or an empty store result
|
|
1023
|
+
# (no store_path). The two are indistinguishable, and
|
|
1024
|
+
# completing empty would silently drop an old session's
|
|
1025
|
+
# real result. Keep waiting for the genuine state_updated,
|
|
1026
|
+
# exactly as without the watchdog, and don't probe again:
|
|
1027
|
+
# the answer won't change. Known limitation: an empty store
|
|
1028
|
+
# result whose terminal event was lost can't be recovered.
|
|
1029
|
+
query.watch.probeable = False
|
|
1030
|
+
logging.warning(
|
|
1031
|
+
"Query %s succeeded, but the SQL session's reply has no "
|
|
1032
|
+
"result location (older SQL session, or an empty store "
|
|
1033
|
+
"result); cannot recover it, still waiting.",
|
|
1034
|
+
execution_id,
|
|
1035
|
+
)
|
|
1036
|
+
return
|
|
1037
|
+
|
|
402
1038
|
if results is None or results == {}:
|
|
403
1039
|
logging.warning("Got no results back from %s.", execution_id)
|
|
404
1040
|
query.state = ExecutionState.COMPLETED
|
|
@@ -418,6 +1054,27 @@ class Connection:
|
|
|
418
1054
|
query.state = ExecutionState.COMPLETED
|
|
419
1055
|
complete_query(ExecutionResult(results=decoded))
|
|
420
1056
|
elif query.state == ExecutionState.CANCELLED:
|
|
1057
|
+
if attach is not None:
|
|
1058
|
+
with self.__lock:
|
|
1059
|
+
requested = query.cancel_requested
|
|
1060
|
+
if not requested:
|
|
1061
|
+
# Not this connection's cancel: the session cancelled
|
|
1062
|
+
# it before or while this connection re-attached.
|
|
1063
|
+
# Completing empty would report, say, a write
|
|
1064
|
+
# cancelled midway as a success.
|
|
1065
|
+
cancelled = ExecutionCancelledError(
|
|
1066
|
+
f"Execution {execution_id} was cancelled on SQL "
|
|
1067
|
+
f"session {self.__session_id or '(unknown)'} before "
|
|
1068
|
+
"or while this connection re-attached to it (for "
|
|
1069
|
+
"example, its orphan grace period ran out, or "
|
|
1070
|
+
"another client cancelled it). Its outcome is "
|
|
1071
|
+
"unknown: for a write, whether it committed.",
|
|
1072
|
+
session_id=self.__session_id,
|
|
1073
|
+
execution_id=execution_id,
|
|
1074
|
+
)
|
|
1075
|
+
logging.warning("%s", cancelled)
|
|
1076
|
+
complete_query(ExecutionResult(error=cancelled))
|
|
1077
|
+
return
|
|
421
1078
|
logging.info(
|
|
422
1079
|
"Query %s has been cancelled; returning empty results.",
|
|
423
1080
|
execution_id,
|
|
@@ -428,8 +1085,64 @@ class Connection:
|
|
|
428
1085
|
# details.
|
|
429
1086
|
pass
|
|
430
1087
|
elif kind == EventKind.ERROR:
|
|
431
|
-
query.state = ExecutionState.FAILED
|
|
432
1088
|
error = message.get("message")
|
|
1089
|
+
not_found = error == _EXECUTION_NOT_FOUND
|
|
1090
|
+
if attach_reply and not_found:
|
|
1091
|
+
# Unlike for a probe, there's no terminal event to wait for:
|
|
1092
|
+
# the session doesn't know the execution at all.
|
|
1093
|
+
fail_attached(
|
|
1094
|
+
ExecutionNotFoundError(
|
|
1095
|
+
f"Execution not found: SQL session "
|
|
1096
|
+
f"{self.__session_id or '(unknown)'} has no execution "
|
|
1097
|
+
f"{execution_id} after reconnecting. The statement "
|
|
1098
|
+
"didn't reach the session before the connection was "
|
|
1099
|
+
"lost, the session restarted, or the session no longer "
|
|
1100
|
+
"tracks it.",
|
|
1101
|
+
session_id=self.__session_id,
|
|
1102
|
+
execution_id=execution_id,
|
|
1103
|
+
)
|
|
1104
|
+
)
|
|
1105
|
+
return
|
|
1106
|
+
# For an attached query, any not-found while waiting is read as an
|
|
1107
|
+
# eviction, not just a probe's: with the attach and the probe that
|
|
1108
|
+
# repeats it both outstanding, the second reply has neither flag.
|
|
1109
|
+
if (
|
|
1110
|
+
(probe_reply or attach is not None)
|
|
1111
|
+
and not_found
|
|
1112
|
+
and not query.watch.results_requested
|
|
1113
|
+
and query.state != ExecutionState.FAILED
|
|
1114
|
+
):
|
|
1115
|
+
if attach is not None and not attach.acknowledged:
|
|
1116
|
+
# Evicted from an older session's cache, which sends the
|
|
1117
|
+
# terminal event to the lost connection: waiting for it
|
|
1118
|
+
# would wait forever.
|
|
1119
|
+
fail_attached(
|
|
1120
|
+
ExecutionNotFoundError(
|
|
1121
|
+
f"Execution not found: SQL session "
|
|
1122
|
+
f"{self.__session_id or '(unknown)'} no longer "
|
|
1123
|
+
f"tracks execution {execution_id}, and doesn't "
|
|
1124
|
+
"send its events to an attached connection (older "
|
|
1125
|
+
"SQL session). Its outcome can't be recovered.",
|
|
1126
|
+
session_id=self.__session_id,
|
|
1127
|
+
execution_id=execution_id,
|
|
1128
|
+
)
|
|
1129
|
+
)
|
|
1130
|
+
return
|
|
1131
|
+
# The session evicted the query from its cache. If the query is
|
|
1132
|
+
# still running, its terminal event will still arrive; if that
|
|
1133
|
+
# event was already lost, nothing more can be done (still
|
|
1134
|
+
# running and finished-with-a-lost-event look the same from
|
|
1135
|
+
# here), so keep waiting either way. After the normal
|
|
1136
|
+
# retrieve, though, there's nothing left to wait for.
|
|
1137
|
+
query.watch.probeable = False
|
|
1138
|
+
logging.warning(
|
|
1139
|
+
"SQL session no longer tracks execution %s (likely evicted "
|
|
1140
|
+
"from its result cache); cannot probe it, waiting for its "
|
|
1141
|
+
"terminal event.",
|
|
1142
|
+
execution_id,
|
|
1143
|
+
)
|
|
1144
|
+
return
|
|
1145
|
+
query.state = ExecutionState.FAILED
|
|
433
1146
|
complete_query(ExecutionResult(error=OperationalError(error)))
|
|
434
1147
|
else:
|
|
435
1148
|
logging.warning("Received unknown %s event!", kind)
|
|
@@ -456,7 +1169,18 @@ class Connection:
|
|
|
456
1169
|
else:
|
|
457
1170
|
raise NotSupportedError("Unsupported results format")
|
|
458
1171
|
|
|
459
|
-
def __send(
|
|
1172
|
+
def __send(
|
|
1173
|
+
self,
|
|
1174
|
+
message: Dict[str, Any],
|
|
1175
|
+
query: Query | None = None,
|
|
1176
|
+
blocking: bool = True,
|
|
1177
|
+
) -> bool:
|
|
1178
|
+
"""Send a request; return ``False`` only if non-blocking and busy.
|
|
1179
|
+
|
|
1180
|
+
Busy means another sender holds the send lock, or the socket's send
|
|
1181
|
+
buffer is full. While the reader reconnects, it holds the send lock, so
|
|
1182
|
+
a blocking send waits for that to finish.
|
|
1183
|
+
"""
|
|
460
1184
|
# Serialization and redaction are local work. Fail before registration,
|
|
461
1185
|
# without poisoning unrelated cursors or misreporting a transport loss.
|
|
462
1186
|
request = json.dumps(message)
|
|
@@ -465,18 +1189,39 @@ class Connection:
|
|
|
465
1189
|
# unguarded call would redact on every request even with DEBUG off.
|
|
466
1190
|
if logging.getLogger().isEnabledFor(logging.DEBUG):
|
|
467
1191
|
logging.debug("Request: %s", self.__redacted_request(message))
|
|
468
|
-
|
|
1192
|
+
if not self.__send_lock.acquire(blocking=blocking):
|
|
1193
|
+
return False
|
|
1194
|
+
try:
|
|
469
1195
|
with self.__lock:
|
|
470
1196
|
if self.__closed:
|
|
471
1197
|
if query is not None:
|
|
472
1198
|
raise self.__connection_error(query.execution_id)
|
|
473
|
-
return
|
|
1199
|
+
return True
|
|
474
1200
|
if query is not None:
|
|
1201
|
+
# The watchdog's clock starts at registration.
|
|
1202
|
+
watch = query.watch
|
|
1203
|
+
watch.last_activity = watch.last_event = time.monotonic()
|
|
475
1204
|
self.__queries[query.execution_id] = query
|
|
476
|
-
|
|
477
|
-
|
|
1205
|
+
else:
|
|
1206
|
+
tracked = self.__queries.get(message.get("execution_id"))
|
|
1207
|
+
if tracked is None:
|
|
1208
|
+
return True
|
|
1209
|
+
if message.get("kind") == RequestKind.CANCEL.value:
|
|
1210
|
+
# Only now that it's going out (see the field).
|
|
1211
|
+
tracked.cancel_requested = True
|
|
1212
|
+
# The reader replaces it only while holding the send lock.
|
|
1213
|
+
ws = self.__ws
|
|
1214
|
+
# A non-blocking send runs on the reader thread (a probe). Not
|
|
1215
|
+
# waiting for the lock isn't enough: with the socket's send buffer
|
|
1216
|
+
# full (a peer that keeps sending but stops reading), ws.send()
|
|
1217
|
+
# itself would block, stopping recv() for every query, and would
|
|
1218
|
+
# hold the library's protocol mutex, which keepalive pings need
|
|
1219
|
+
# too. Treat it like a busy lock: skip, and retry at the next
|
|
1220
|
+
# check. Checked outside __lock: it's a syscall on the transport.
|
|
1221
|
+
if not blocking and not socket_writable(ws):
|
|
1222
|
+
return False
|
|
478
1223
|
try:
|
|
479
|
-
|
|
1224
|
+
ws.send(request)
|
|
480
1225
|
except (websockets.exceptions.ConnectionClosed, OSError):
|
|
481
1226
|
pass # Terminate outside the send gate, before delivering errors.
|
|
482
1227
|
except Exception:
|
|
@@ -486,8 +1231,20 @@ class Connection:
|
|
|
486
1231
|
self.__queries.pop(query.execution_id, None)
|
|
487
1232
|
raise
|
|
488
1233
|
else:
|
|
489
|
-
return
|
|
490
|
-
|
|
1234
|
+
return True
|
|
1235
|
+
finally:
|
|
1236
|
+
self.__send_lock.release()
|
|
1237
|
+
if self.__reconnect_within is None:
|
|
1238
|
+
self.__fail_pending()
|
|
1239
|
+
return True
|
|
1240
|
+
# Make sure the reader notices the loss, so it reconnects. A query
|
|
1241
|
+
# registered above is then re-attached: if the request didn't reach the
|
|
1242
|
+
# session, that fails it with ExecutionNotFoundError.
|
|
1243
|
+
try:
|
|
1244
|
+
abort_connection(ws)
|
|
1245
|
+
except Exception:
|
|
1246
|
+
logging.debug("Could not abort the lost transport", exc_info=True)
|
|
1247
|
+
return True
|
|
491
1248
|
|
|
492
1249
|
@staticmethod
|
|
493
1250
|
def __redacted_request(message: Dict[str, Any]) -> str:
|
|
@@ -504,7 +1261,7 @@ class Connection:
|
|
|
504
1261
|
|
|
505
1262
|
def __recv(self) -> Dict[str, Any]:
|
|
506
1263
|
try:
|
|
507
|
-
frame = self.__ws.recv(timeout=self.
|
|
1264
|
+
frame = self.__ws.recv(timeout=self.__recv_timeout)
|
|
508
1265
|
except TimeoutError:
|
|
509
1266
|
raise # Idle polls are expected, not terminal I/O failures.
|
|
510
1267
|
except OSError as e:
|
|
@@ -559,12 +1316,10 @@ class Connection:
|
|
|
559
1316
|
)
|
|
560
1317
|
return execution_id
|
|
561
1318
|
|
|
562
|
-
def
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
request = {
|
|
1319
|
+
def __retrieve_request(
|
|
1320
|
+
self, execution_id: str, attach: bool = False
|
|
1321
|
+
) -> Dict[str, Any]:
|
|
1322
|
+
request: Dict[str, Any] = {
|
|
568
1323
|
"kind": RequestKind.RETRIEVE_RESULTS.value,
|
|
569
1324
|
"execution_id": execution_id,
|
|
570
1325
|
}
|
|
@@ -574,16 +1329,37 @@ class Connection:
|
|
|
574
1329
|
request["compression"] = self.__data_compression.value
|
|
575
1330
|
if self.__geometry_representation:
|
|
576
1331
|
request["geometry"] = self.__geometry_representation.value
|
|
1332
|
+
if attach:
|
|
1333
|
+
request["attach"] = True
|
|
1334
|
+
return request
|
|
1335
|
+
|
|
1336
|
+
def __request_results(
|
|
1337
|
+
self, execution_id: str, probe: bool = False, attach: bool = False
|
|
1338
|
+
) -> bool:
|
|
1339
|
+
"""Send retrieve_results; ``False`` if a probe found the sender busy.
|
|
1340
|
+
|
|
1341
|
+
A probe never waits for ``__send_lock`` or a full socket (see
|
|
1342
|
+
``__probe_stale_queries`` and ``__send``). ``attach`` repeats an
|
|
1343
|
+
unanswered attach.
|
|
1344
|
+
"""
|
|
1345
|
+
query = self.__queries.get(execution_id)
|
|
1346
|
+
if not query:
|
|
1347
|
+
return True
|
|
577
1348
|
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
1349
|
+
request = self.__retrieve_request(execution_id, attach=attach)
|
|
1350
|
+
|
|
1351
|
+
if not probe:
|
|
1352
|
+
# A probe only asks; the reply carries the query's actual state.
|
|
1353
|
+
query.state = ExecutionState.RESULTS_REQUESTED
|
|
1354
|
+
query.watch.results_requested = True
|
|
1355
|
+
logging.info("Requesting results from %s ...", execution_id)
|
|
1356
|
+
return self.__send(request, blocking=not probe)
|
|
581
1357
|
|
|
582
1358
|
def __cancel_query(self, execution_id: str) -> None:
|
|
583
1359
|
"""Cancels the query with the given execution ID."""
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
1360
|
+
with self.__lock:
|
|
1361
|
+
if execution_id not in self.__queries:
|
|
1362
|
+
return
|
|
587
1363
|
|
|
588
1364
|
request = {
|
|
589
1365
|
"kind": RequestKind.CANCEL.value,
|