wherobots-python-dbapi 0.29.0__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/connection.py +498 -34
- wherobots/db/constants.py +8 -0
- wherobots/db/cursor.py +10 -0
- wherobots/db/driver.py +89 -17
- wherobots/db/errors.py +77 -0
- {wherobots_python_dbapi-0.29.0.dist-info → wherobots_python_dbapi-0.30.0.dist-info}/METADATA +57 -1
- wherobots_python_dbapi-0.30.0.dist-info/RECORD +17 -0
- wherobots_python_dbapi-0.29.0.dist-info/RECORD +0 -17
- {wherobots_python_dbapi-0.29.0.dist-info → wherobots_python_dbapi-0.30.0.dist-info}/WHEEL +0 -0
- {wherobots_python_dbapi-0.29.0.dist-info → wherobots_python_dbapi-0.30.0.dist-info}/licenses/LICENSE +0 -0
wherobots/db/__init__.py
CHANGED
|
@@ -3,7 +3,10 @@ from .cursor import Cursor
|
|
|
3
3
|
from .driver import connect, connect_direct
|
|
4
4
|
from .errors import (
|
|
5
5
|
Error,
|
|
6
|
+
ConnectionLostError,
|
|
6
7
|
DatabaseError,
|
|
8
|
+
ExecutionCancelledError,
|
|
9
|
+
ExecutionNotFoundError,
|
|
7
10
|
InternalError,
|
|
8
11
|
InterfaceError,
|
|
9
12
|
OperationalError,
|
|
@@ -26,6 +29,9 @@ __all__ = [
|
|
|
26
29
|
"InternalError",
|
|
27
30
|
"InterfaceError",
|
|
28
31
|
"OperationalError",
|
|
32
|
+
"ConnectionLostError",
|
|
33
|
+
"ExecutionCancelledError",
|
|
34
|
+
"ExecutionNotFoundError",
|
|
29
35
|
"ProgrammingError",
|
|
30
36
|
"NotSupportedError",
|
|
31
37
|
"Region",
|
wherobots/db/connection.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import json
|
|
2
2
|
import logging
|
|
3
3
|
import math
|
|
4
|
+
import random
|
|
4
5
|
import textwrap
|
|
5
6
|
import threading
|
|
6
7
|
import time
|
|
@@ -16,10 +17,21 @@ import cbor2
|
|
|
16
17
|
import websockets.exceptions
|
|
17
18
|
import websockets.sync.client
|
|
18
19
|
|
|
19
|
-
from .constants import
|
|
20
|
+
from .constants import (
|
|
21
|
+
DEFAULT_READ_TIMEOUT_SECONDS,
|
|
22
|
+
DEFAULT_RECONNECT_TIMEOUT_SECONDS,
|
|
23
|
+
DEFAULT_STALE_QUERY_PROBE_SECONDS,
|
|
24
|
+
)
|
|
20
25
|
from ._transport import abort_connection, socket_writable
|
|
21
26
|
from .cursor import Cursor
|
|
22
|
-
from .errors import
|
|
27
|
+
from .errors import (
|
|
28
|
+
ConnectionLostError,
|
|
29
|
+
ExecutionCancelledError,
|
|
30
|
+
ExecutionNotFoundError,
|
|
31
|
+
InterfaceError,
|
|
32
|
+
NotSupportedError,
|
|
33
|
+
OperationalError,
|
|
34
|
+
)
|
|
23
35
|
from .models import ExecutionResult, ProgressInfo, Store, StoreResult
|
|
24
36
|
from .types import (
|
|
25
37
|
RequestKind,
|
|
@@ -92,7 +104,7 @@ _EXECUTION_NOT_FOUND = "Execution not found"
|
|
|
92
104
|
Its execution cache is a small LRU that can evict a query that is still
|
|
93
105
|
running, whose terminal state_updated still arrives (it's pushed from the
|
|
94
106
|
query's own done-callback). So for a probe, this means "can't answer", not
|
|
95
|
-
"failed".
|
|
107
|
+
"failed". For a re-attach, it means the session doesn't know the execution.
|
|
96
108
|
"""
|
|
97
109
|
|
|
98
110
|
_PROBED_STATES = frozenset(
|
|
@@ -105,9 +117,20 @@ _PROBED_STATES = frozenset(
|
|
|
105
117
|
)
|
|
106
118
|
"""States in which the driver is waiting on a session event it can re-ask for."""
|
|
107
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."""
|
|
108
130
|
|
|
109
|
-
|
|
110
|
-
|
|
131
|
+
|
|
132
|
+
def _positive_seconds(value: Any) -> float | None:
|
|
133
|
+
"""A validated interval setting, or ``None`` to disable what it times.
|
|
111
134
|
|
|
112
135
|
Like ``_handshake_bound``, returns ``None`` for a non-numeric (including
|
|
113
136
|
``bool``), non-finite or non-positive value. Never raises ``Exception``:
|
|
@@ -180,6 +203,32 @@ class _Watch:
|
|
|
180
203
|
"""The one probe allowed after ``results_requested`` was sent."""
|
|
181
204
|
|
|
182
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
|
+
|
|
183
232
|
@dataclass
|
|
184
233
|
class Query:
|
|
185
234
|
sql: str
|
|
@@ -188,6 +237,17 @@ class Query:
|
|
|
188
237
|
handler: Callable[[Any], None]
|
|
189
238
|
store: Store | None = None
|
|
190
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."""
|
|
191
251
|
|
|
192
252
|
|
|
193
253
|
class Connection:
|
|
@@ -203,6 +263,12 @@ class Connection:
|
|
|
203
263
|
|
|
204
264
|
A background thread listens for events from the SQL session, and handles update to the
|
|
205
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``.
|
|
206
272
|
"""
|
|
207
273
|
|
|
208
274
|
def __init__(
|
|
@@ -214,13 +280,51 @@ class Connection:
|
|
|
214
280
|
geometry_representation: GeometryRepresentation | None = None,
|
|
215
281
|
session_id: str | None = None,
|
|
216
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,
|
|
217
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
|
+
"""
|
|
218
301
|
self.__ws = ws
|
|
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
|
|
219
323
|
self.__results_format = results_format
|
|
220
324
|
self.__data_compression = data_compression
|
|
221
325
|
self.__geometry_representation = geometry_representation
|
|
222
326
|
self.__progress_handler: ProgressHandler | None = None
|
|
223
|
-
self.__probe_after =
|
|
327
|
+
self.__probe_after = _positive_seconds(stale_query_probe_seconds)
|
|
224
328
|
if self.__probe_after is None and stale_query_probe_seconds is not None:
|
|
225
329
|
logging.warning(
|
|
226
330
|
"Invalid stale_query_probe_seconds (%s); stale-query probes disabled.",
|
|
@@ -238,6 +342,7 @@ class Connection:
|
|
|
238
342
|
self.__recv_timeout = check
|
|
239
343
|
|
|
240
344
|
self.__session_id = session_id
|
|
345
|
+
self.__session_fallback = session_fallback
|
|
241
346
|
self.__lock = threading.Lock()
|
|
242
347
|
self.__send_lock = threading.Lock()
|
|
243
348
|
self.__shutdown_done = threading.Event()
|
|
@@ -252,6 +357,19 @@ class Connection:
|
|
|
252
357
|
)
|
|
253
358
|
self.__thread.start()
|
|
254
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
|
+
|
|
255
373
|
def __enter__(self):
|
|
256
374
|
return self
|
|
257
375
|
|
|
@@ -319,7 +437,12 @@ class Connection:
|
|
|
319
437
|
"""Main background loop listening for messages from the SQL session."""
|
|
320
438
|
logging.info("Starting background connection handling loop...")
|
|
321
439
|
try:
|
|
322
|
-
|
|
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
|
|
323
446
|
finally:
|
|
324
447
|
self.__fail_pending()
|
|
325
448
|
|
|
@@ -338,10 +461,10 @@ class Connection:
|
|
|
338
461
|
# Expected, retry next time
|
|
339
462
|
continue
|
|
340
463
|
except websockets.exceptions.ConnectionClosed:
|
|
341
|
-
logging.info("
|
|
464
|
+
logging.info("SQL session connection closed.")
|
|
342
465
|
return
|
|
343
466
|
except _TransportError:
|
|
344
|
-
logging.exception("SQL session transport failed
|
|
467
|
+
logging.exception("SQL session transport failed")
|
|
345
468
|
return
|
|
346
469
|
except Exception as e:
|
|
347
470
|
logging.exception("Error handling message from SQL session", exc_info=e)
|
|
@@ -355,6 +478,10 @@ class Connection:
|
|
|
355
478
|
with the query's current state (and its results or result location
|
|
356
479
|
once it has succeeded), or with an error for an unknown execution.
|
|
357
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
|
+
|
|
358
485
|
Runs on the reader thread, which owns every ``Query``'s watchdog fields.
|
|
359
486
|
Never raises: a watchdog failure must not disturb the receive loop.
|
|
360
487
|
"""
|
|
@@ -376,17 +503,33 @@ class Connection:
|
|
|
376
503
|
watch = query.watch
|
|
377
504
|
if not watch.probeable or query.state not in _PROBED_STATES:
|
|
378
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
|
|
379
510
|
if watch.results_requested:
|
|
380
511
|
if watch.results_probed:
|
|
381
512
|
continue
|
|
382
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
|
|
383
521
|
else:
|
|
384
522
|
doublings = min(watch.probes_sent, _MAX_PROBE_BACKOFF_DOUBLINGS)
|
|
385
523
|
wait = probe_after * 2**doublings
|
|
386
524
|
silence = now - watch.last_activity
|
|
387
525
|
if silence < wait:
|
|
388
526
|
continue
|
|
389
|
-
|
|
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
|
+
):
|
|
390
533
|
# A user thread is sending, possibly stalled, or the
|
|
391
534
|
# socket can't take a write. Don't wait: that would stop
|
|
392
535
|
# recv() for every query. Either holds for the rest too;
|
|
@@ -399,6 +542,8 @@ class Connection:
|
|
|
399
542
|
watch.probes_sent += 1
|
|
400
543
|
watch.probe_outstanding = True
|
|
401
544
|
watch.results_probed = watch.results_requested
|
|
545
|
+
if attach is not None:
|
|
546
|
+
attach.probed = True
|
|
402
547
|
# Space the next probe from this one, not from the last event.
|
|
403
548
|
watch.last_activity = now
|
|
404
549
|
logging.info(
|
|
@@ -413,13 +558,173 @@ class Connection:
|
|
|
413
558
|
except Exception:
|
|
414
559
|
logging.exception("Stale-query probe failed")
|
|
415
560
|
|
|
416
|
-
def
|
|
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:
|
|
417
720
|
message = (
|
|
418
721
|
f"SQL connection lost (session={self.__session_id or 'unknown'}, "
|
|
419
722
|
f"execution={execution_id}). Commit outcome is unknown; "
|
|
420
723
|
"verify the operation before retrying writes."
|
|
421
724
|
)
|
|
422
|
-
return
|
|
725
|
+
return ConnectionLostError(
|
|
726
|
+
message, session_id=self.__session_id, execution_id=execution_id
|
|
727
|
+
)
|
|
423
728
|
|
|
424
729
|
def __fail_pending(self, graceful: bool = False) -> None:
|
|
425
730
|
# Stop admission first. Do not wait for __send_lock: its owner may be
|
|
@@ -502,6 +807,9 @@ class Connection:
|
|
|
502
807
|
# Whether this event answers a probe (possibly along with the normal
|
|
503
808
|
# retrieve). Read before the bookkeeping below resets it.
|
|
504
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
|
|
505
813
|
if query is not None:
|
|
506
814
|
watch = query.watch
|
|
507
815
|
# Any inbound event, progress included, shows the query is alive.
|
|
@@ -512,6 +820,13 @@ class Connection:
|
|
|
512
820
|
# silent query isn't probed every N seconds forever.
|
|
513
821
|
probe_reply = watch.probe_outstanding
|
|
514
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
|
|
515
830
|
else:
|
|
516
831
|
# A genuine event resets the backoff, but leaves an outstanding
|
|
517
832
|
# probe outstanding: a non-terminal state_updated can arrive
|
|
@@ -591,6 +906,13 @@ class Connection:
|
|
|
591
906
|
query.state = ExecutionState.COMPLETED
|
|
592
907
|
complete_query(ExecutionResult(store_result=store_result))
|
|
593
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
|
+
|
|
594
916
|
# Incoming state transitions are handled here.
|
|
595
917
|
if kind == EventKind.STATE_UPDATED or kind == EventKind.EXECUTION_RESULT:
|
|
596
918
|
try:
|
|
@@ -600,6 +922,30 @@ class Connection:
|
|
|
600
922
|
fail_query("interpret", error)
|
|
601
923
|
return
|
|
602
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
|
+
|
|
603
949
|
if query.state == ExecutionState.SUCCEEDED:
|
|
604
950
|
# On a state_updated event telling us the query succeeded,
|
|
605
951
|
# check if results are stored in cloud storage or need to be fetched.
|
|
@@ -638,6 +984,39 @@ class Connection:
|
|
|
638
984
|
return
|
|
639
985
|
|
|
640
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
|
+
|
|
641
1020
|
if query.store is not None and (results is None or results == {}):
|
|
642
1021
|
# Succeeded without a result location: either an older
|
|
643
1022
|
# session that doesn't echo it, or an empty store result
|
|
@@ -675,6 +1054,27 @@ class Connection:
|
|
|
675
1054
|
query.state = ExecutionState.COMPLETED
|
|
676
1055
|
complete_query(ExecutionResult(results=decoded))
|
|
677
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
|
|
678
1078
|
logging.info(
|
|
679
1079
|
"Query %s has been cancelled; returning empty results.",
|
|
680
1080
|
execution_id,
|
|
@@ -686,12 +1086,48 @@ class Connection:
|
|
|
686
1086
|
pass
|
|
687
1087
|
elif kind == EventKind.ERROR:
|
|
688
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.
|
|
689
1109
|
if (
|
|
690
|
-
probe_reply
|
|
691
|
-
and
|
|
1110
|
+
(probe_reply or attach is not None)
|
|
1111
|
+
and not_found
|
|
692
1112
|
and not query.watch.results_requested
|
|
693
1113
|
and query.state != ExecutionState.FAILED
|
|
694
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
|
|
695
1131
|
# The session evicted the query from its cache. If the query is
|
|
696
1132
|
# still running, its terminal event will still arrive; if that
|
|
697
1133
|
# event was already lost, nothing more can be done (still
|
|
@@ -742,7 +1178,8 @@ class Connection:
|
|
|
742
1178
|
"""Send a request; return ``False`` only if non-blocking and busy.
|
|
743
1179
|
|
|
744
1180
|
Busy means another sender holds the send lock, or the socket's send
|
|
745
|
-
buffer is full.
|
|
1181
|
+
buffer is full. While the reader reconnects, it holds the send lock, so
|
|
1182
|
+
a blocking send waits for that to finish.
|
|
746
1183
|
"""
|
|
747
1184
|
# Serialization and redaction are local work. Fail before registration,
|
|
748
1185
|
# without poisoning unrelated cursors or misreporting a transport loss.
|
|
@@ -765,8 +1202,15 @@ class Connection:
|
|
|
765
1202
|
watch = query.watch
|
|
766
1203
|
watch.last_activity = watch.last_event = time.monotonic()
|
|
767
1204
|
self.__queries[query.execution_id] = query
|
|
768
|
-
|
|
769
|
-
|
|
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
|
|
770
1214
|
# A non-blocking send runs on the reader thread (a probe). Not
|
|
771
1215
|
# waiting for the lock isn't enough: with the socket's send buffer
|
|
772
1216
|
# full (a peer that keeps sending but stops reading), ws.send()
|
|
@@ -774,10 +1218,10 @@ class Connection:
|
|
|
774
1218
|
# hold the library's protocol mutex, which keepalive pings need
|
|
775
1219
|
# too. Treat it like a busy lock: skip, and retry at the next
|
|
776
1220
|
# check. Checked outside __lock: it's a syscall on the transport.
|
|
777
|
-
if not blocking and not socket_writable(
|
|
1221
|
+
if not blocking and not socket_writable(ws):
|
|
778
1222
|
return False
|
|
779
1223
|
try:
|
|
780
|
-
|
|
1224
|
+
ws.send(request)
|
|
781
1225
|
except (websockets.exceptions.ConnectionClosed, OSError):
|
|
782
1226
|
pass # Terminate outside the send gate, before delivering errors.
|
|
783
1227
|
except Exception:
|
|
@@ -790,7 +1234,16 @@ class Connection:
|
|
|
790
1234
|
return True
|
|
791
1235
|
finally:
|
|
792
1236
|
self.__send_lock.release()
|
|
793
|
-
self.
|
|
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)
|
|
794
1247
|
return True
|
|
795
1248
|
|
|
796
1249
|
@staticmethod
|
|
@@ -863,17 +1316,10 @@ class Connection:
|
|
|
863
1316
|
)
|
|
864
1317
|
return execution_id
|
|
865
1318
|
|
|
866
|
-
def
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
``__probe_stale_queries`` and ``__send``).
|
|
871
|
-
"""
|
|
872
|
-
query = self.__queries.get(execution_id)
|
|
873
|
-
if not query:
|
|
874
|
-
return True
|
|
875
|
-
|
|
876
|
-
request = {
|
|
1319
|
+
def __retrieve_request(
|
|
1320
|
+
self, execution_id: str, attach: bool = False
|
|
1321
|
+
) -> Dict[str, Any]:
|
|
1322
|
+
request: Dict[str, Any] = {
|
|
877
1323
|
"kind": RequestKind.RETRIEVE_RESULTS.value,
|
|
878
1324
|
"execution_id": execution_id,
|
|
879
1325
|
}
|
|
@@ -883,6 +1329,24 @@ class Connection:
|
|
|
883
1329
|
request["compression"] = self.__data_compression.value
|
|
884
1330
|
if self.__geometry_representation:
|
|
885
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
|
|
1348
|
+
|
|
1349
|
+
request = self.__retrieve_request(execution_id, attach=attach)
|
|
886
1350
|
|
|
887
1351
|
if not probe:
|
|
888
1352
|
# A probe only asks; the reply carries the query's actual state.
|
|
@@ -893,9 +1357,9 @@ class Connection:
|
|
|
893
1357
|
|
|
894
1358
|
def __cancel_query(self, execution_id: str) -> None:
|
|
895
1359
|
"""Cancels the query with the given execution ID."""
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
1360
|
+
with self.__lock:
|
|
1361
|
+
if execution_id not in self.__queries:
|
|
1362
|
+
return
|
|
899
1363
|
|
|
900
1364
|
request = {
|
|
901
1365
|
"kind": RequestKind.CANCEL.value,
|
wherobots/db/constants.py
CHANGED
|
@@ -19,6 +19,14 @@ DEFAULT_CLOSE_TIMEOUT_SECONDS: float = 1.0
|
|
|
19
19
|
DEFAULT_SESSION_WAIT_TIMEOUT_SECONDS: float = 900
|
|
20
20
|
# Silence, per query, before the driver re-asks the session for its state.
|
|
21
21
|
DEFAULT_STALE_QUERY_PROBE_SECONDS: float = 30.0
|
|
22
|
+
# The API sets this on its answer to a session request when it gave the request
|
|
23
|
+
# a session under another key than its own, naming why ("quota").
|
|
24
|
+
SESSION_FALLBACK_HEADER: str = "X-Wherobots-Sql-Session-Fallback"
|
|
25
|
+
# How long the driver keeps trying to reconnect to the SQL session after losing
|
|
26
|
+
# the connection. It must fit, along with the up to ~40s that keepalive takes
|
|
27
|
+
# to notice a dead connection, in the session's grace period for orphaned
|
|
28
|
+
# executions (120s by default), after which the session cancels them.
|
|
29
|
+
DEFAULT_RECONNECT_TIMEOUT_SECONDS: float = 60.0
|
|
22
30
|
|
|
23
31
|
MAX_MESSAGE_SIZE: int = 100 * 2**20 # 100MiB
|
|
24
32
|
PROTOCOL_VERSION: Version = Version("1.0.0")
|
wherobots/db/cursor.py
CHANGED
|
@@ -95,6 +95,16 @@ class Cursor:
|
|
|
95
95
|
def rowcount(self) -> int:
|
|
96
96
|
return self.__rowcount
|
|
97
97
|
|
|
98
|
+
@property
|
|
99
|
+
def execution_id(self) -> str | None:
|
|
100
|
+
"""The ID of this cursor's current or last execution.
|
|
101
|
+
|
|
102
|
+
``None`` before the first ``execute()``, and after one that was
|
|
103
|
+
rejected before submission. Errors about an execution, such as
|
|
104
|
+
:class:`~wherobots.db.errors.ConnectionLostError`, carry the same ID.
|
|
105
|
+
"""
|
|
106
|
+
return self.__current_execution_id
|
|
107
|
+
|
|
98
108
|
def __in_flight_execution_id(self) -> str | None:
|
|
99
109
|
"""The current execution's id if its result has not yet arrived.
|
|
100
110
|
|
wherobots/db/driver.py
CHANGED
|
@@ -12,7 +12,7 @@ import platform
|
|
|
12
12
|
import requests
|
|
13
13
|
import tenacity
|
|
14
14
|
import threading
|
|
15
|
-
from typing import Final, Union, Dict
|
|
15
|
+
from typing import Any, Final, Union, Dict
|
|
16
16
|
import urllib.parse
|
|
17
17
|
import websockets.exceptions
|
|
18
18
|
import websockets.sync.client
|
|
@@ -23,12 +23,14 @@ from .constants import (
|
|
|
23
23
|
DEFAULT_CLOSE_TIMEOUT_SECONDS,
|
|
24
24
|
DEFAULT_ENDPOINT,
|
|
25
25
|
DEFAULT_READ_TIMEOUT_SECONDS,
|
|
26
|
+
DEFAULT_RECONNECT_TIMEOUT_SECONDS,
|
|
26
27
|
DEFAULT_SESSION_TYPE,
|
|
27
28
|
DEFAULT_SESSION_WAIT_TIMEOUT_SECONDS,
|
|
28
29
|
DEFAULT_STALE_QUERY_PROBE_SECONDS,
|
|
29
30
|
MAX_MESSAGE_SIZE,
|
|
30
31
|
PARAM_STYLE,
|
|
31
32
|
PROTOCOL_VERSION,
|
|
33
|
+
SESSION_FALLBACK_HEADER,
|
|
32
34
|
)
|
|
33
35
|
from .errors import (
|
|
34
36
|
InterfaceError,
|
|
@@ -127,6 +129,8 @@ def connect(
|
|
|
127
129
|
cancel_event: Union[threading.Event, None] = None,
|
|
128
130
|
extra_headers: Union[Dict[str, str], None] = None,
|
|
129
131
|
stale_query_probe_seconds: Union[float, None] = DEFAULT_STALE_QUERY_PROBE_SECONDS,
|
|
132
|
+
session_key: Union[str, None] = None,
|
|
133
|
+
reconnect_timeout_seconds: Union[float, None] = DEFAULT_RECONNECT_TIMEOUT_SECONDS,
|
|
130
134
|
) -> Connection:
|
|
131
135
|
"""Create a connection to a Wherobots SQL session.
|
|
132
136
|
|
|
@@ -154,6 +158,17 @@ def connect(
|
|
|
154
158
|
back off to at most 8 times this interval. ``None`` disables probing;
|
|
155
159
|
an invalid value (non-numeric, non-finite, or not positive) also
|
|
156
160
|
disables it.
|
|
161
|
+
:param session_key: Keeps this connection on SQL sessions of its own: the
|
|
162
|
+
API reuses a session only for connections that ask with the same key,
|
|
163
|
+
and never one opened without a key. When a session of the key's own
|
|
164
|
+
would exceed a quota, the API shares one under another key instead;
|
|
165
|
+
the connection's ``session_fallback`` then says so.
|
|
166
|
+
:param reconnect_timeout_seconds: How long the connection keeps trying to
|
|
167
|
+
reconnect to the same SQL session after losing its WebSocket, then
|
|
168
|
+
re-attaches its pending queries, whose results still arrive. Only if
|
|
169
|
+
it can't reconnect in time do they fail, with ``ConnectionLostError``.
|
|
170
|
+
``None`` disables reconnecting, as does an invalid value (non-numeric,
|
|
171
|
+
non-finite, or not positive): pending queries then fail at once.
|
|
157
172
|
"""
|
|
158
173
|
if not token and not api_key:
|
|
159
174
|
raise ValueError("At least one of `token` or `api_key` is required")
|
|
@@ -198,18 +213,22 @@ def connect(
|
|
|
198
213
|
|
|
199
214
|
_check_cancelled(cancel_event)
|
|
200
215
|
|
|
216
|
+
body: Dict[str, Any] = {
|
|
217
|
+
"runtimeId": runtime_id,
|
|
218
|
+
"shutdownAfterInactiveSeconds": shutdown_after_inactive_seconds,
|
|
219
|
+
"version": version,
|
|
220
|
+
"sessionType": session_type.value,
|
|
221
|
+
}
|
|
222
|
+
if session_key is not None:
|
|
223
|
+
body["sessionKey"] = session_key
|
|
224
|
+
|
|
201
225
|
try:
|
|
202
226
|
resp = requests.post(
|
|
203
227
|
url=f"{host}/sql/session",
|
|
204
228
|
# `requests` omits query params whose value is None, so an omitted
|
|
205
229
|
# region is simply not sent and the API applies the org default.
|
|
206
230
|
params={"region": region_name, "force_new": force_new},
|
|
207
|
-
json=
|
|
208
|
-
"runtimeId": runtime_id,
|
|
209
|
-
"shutdownAfterInactiveSeconds": shutdown_after_inactive_seconds,
|
|
210
|
-
"version": version,
|
|
211
|
-
"sessionType": session_type.value,
|
|
212
|
-
},
|
|
231
|
+
json=body,
|
|
213
232
|
headers=headers,
|
|
214
233
|
timeout=DEFAULT_HTTP_TIMEOUT,
|
|
215
234
|
)
|
|
@@ -228,6 +247,21 @@ def connect(
|
|
|
228
247
|
# At this point we've been redirected to /sql/session/{session_id}, which we'll need to keep polling until the
|
|
229
248
|
# session is in READY state.
|
|
230
249
|
session_id_url = resp.url
|
|
250
|
+
# The fallback header is on the API's 303, which `requests` followed. Other
|
|
251
|
+
# redirects (a 307 or 308 to a canonical endpoint) may come before it, so
|
|
252
|
+
# every hop is read, latest first.
|
|
253
|
+
session_fallback = next(
|
|
254
|
+
(
|
|
255
|
+
hop.headers[SESSION_FALLBACK_HEADER]
|
|
256
|
+
for hop in reversed([*resp.history, resp])
|
|
257
|
+
if hop.headers.get(SESSION_FALLBACK_HEADER)
|
|
258
|
+
),
|
|
259
|
+
None,
|
|
260
|
+
)
|
|
261
|
+
if session_fallback is not None:
|
|
262
|
+
logging.warning(
|
|
263
|
+
"The API shared a SQL session under another key (%s).", session_fallback
|
|
264
|
+
)
|
|
231
265
|
|
|
232
266
|
@tenacity.retry(
|
|
233
267
|
stop=tenacity.stop_after_delay(wait_timeout),
|
|
@@ -280,6 +314,8 @@ def connect(
|
|
|
280
314
|
.path.rstrip("/")
|
|
281
315
|
.rsplit("/", 1)[-1],
|
|
282
316
|
stale_query_probe_seconds=stale_query_probe_seconds,
|
|
317
|
+
session_fallback=session_fallback,
|
|
318
|
+
reconnect_timeout_seconds=reconnect_timeout_seconds,
|
|
283
319
|
)
|
|
284
320
|
|
|
285
321
|
|
|
@@ -309,15 +345,37 @@ def connect_direct(
|
|
|
309
345
|
cancel_event: Union[threading.Event, None] = None,
|
|
310
346
|
session_id: str | None = None,
|
|
311
347
|
stale_query_probe_seconds: Union[float, None] = DEFAULT_STALE_QUERY_PROBE_SECONDS,
|
|
348
|
+
session_fallback: str | None = None,
|
|
349
|
+
reconnect_timeout_seconds: Union[float, None] = DEFAULT_RECONNECT_TIMEOUT_SECONDS,
|
|
312
350
|
) -> Connection:
|
|
313
351
|
"""Connect to a SQL session's WebSocket endpoint directly.
|
|
314
352
|
|
|
353
|
+
After losing its WebSocket, the returned connection reconnects to the same
|
|
354
|
+
endpoint by itself, with the same headers, honouring ``cancel_event`` (see
|
|
355
|
+
``reconnect_timeout_seconds``).
|
|
356
|
+
|
|
315
357
|
:param stale_query_probe_seconds: See :func:`connect`.
|
|
358
|
+
:param session_fallback: See :attr:`Connection.session_fallback`.
|
|
359
|
+
:param reconnect_timeout_seconds: See :func:`connect`.
|
|
316
360
|
"""
|
|
317
361
|
uri_with_protocol = f"{uri}/{protocol}"
|
|
318
362
|
ssl_context = ssl.create_default_context()
|
|
319
363
|
ssl_context.load_verify_locations(certifi.where())
|
|
320
364
|
|
|
365
|
+
def open_ws(
|
|
366
|
+
headers: Union[Dict[str, str], None], open_timeout: float
|
|
367
|
+
) -> websockets.sync.client.ClientConnection:
|
|
368
|
+
_check_cancelled(cancel_event)
|
|
369
|
+
logging.info("Connecting to SQL session at %s ...", uri_with_protocol)
|
|
370
|
+
return websockets.sync.client.connect(
|
|
371
|
+
uri=uri_with_protocol,
|
|
372
|
+
additional_headers=headers,
|
|
373
|
+
max_size=MAX_MESSAGE_SIZE,
|
|
374
|
+
open_timeout=open_timeout,
|
|
375
|
+
close_timeout=DEFAULT_CLOSE_TIMEOUT_SECONDS,
|
|
376
|
+
ssl=ssl_context,
|
|
377
|
+
)
|
|
378
|
+
|
|
321
379
|
@tenacity.retry(
|
|
322
380
|
stop=tenacity.stop_after_attempt(5),
|
|
323
381
|
wait=tenacity.wait_exponential(multiplier=1, min=1, max=5),
|
|
@@ -333,16 +391,7 @@ def connect_direct(
|
|
|
333
391
|
reraise=True,
|
|
334
392
|
)
|
|
335
393
|
def ws_connect() -> websockets.sync.client.ClientConnection:
|
|
336
|
-
|
|
337
|
-
logging.info("Connecting to SQL session at %s ...", uri_with_protocol)
|
|
338
|
-
return websockets.sync.client.connect(
|
|
339
|
-
uri=uri_with_protocol,
|
|
340
|
-
additional_headers=headers,
|
|
341
|
-
max_size=MAX_MESSAGE_SIZE,
|
|
342
|
-
open_timeout=DEFAULT_HTTP_TIMEOUT,
|
|
343
|
-
close_timeout=DEFAULT_CLOSE_TIMEOUT_SECONDS,
|
|
344
|
-
ssl=ssl_context,
|
|
345
|
-
)
|
|
394
|
+
return open_ws(headers, DEFAULT_HTTP_TIMEOUT)
|
|
346
395
|
|
|
347
396
|
try:
|
|
348
397
|
ws = ws_connect()
|
|
@@ -351,6 +400,26 @@ def connect_direct(
|
|
|
351
400
|
except Exception as e:
|
|
352
401
|
raise InterfaceError("Failed to connect to SQL session!") from e
|
|
353
402
|
|
|
403
|
+
# Snapshot a dict of headers, so a reconnect sends what this connection
|
|
404
|
+
# sent even if the caller later mutates it.
|
|
405
|
+
reconnect_headers = dict(headers) if isinstance(headers, dict) else headers
|
|
406
|
+
|
|
407
|
+
def open_transport(timeout: float) -> websockets.sync.client.ClientConnection:
|
|
408
|
+
# A single attempt: the connection retries until its own deadline.
|
|
409
|
+
# Straight to the same session's WebSocket: connect() resolves the
|
|
410
|
+
# session through the REST API, which could start a new session when
|
|
411
|
+
# this one is gone. A dead session must fail fast instead.
|
|
412
|
+
try:
|
|
413
|
+
return open_ws(reconnect_headers, min(DEFAULT_HTTP_TIMEOUT, timeout))
|
|
414
|
+
except websockets.exceptions.InvalidStatus as e:
|
|
415
|
+
status = e.response.status_code
|
|
416
|
+
if 400 <= status < 500 and status not in (408, 429):
|
|
417
|
+
# Gone, or no longer authorized: retrying can't help.
|
|
418
|
+
raise InterfaceError(
|
|
419
|
+
f"SQL session refused the connection (HTTP {status})"
|
|
420
|
+
) from e
|
|
421
|
+
raise
|
|
422
|
+
|
|
354
423
|
return Connection(
|
|
355
424
|
ws,
|
|
356
425
|
read_timeout=read_timeout,
|
|
@@ -359,4 +428,7 @@ def connect_direct(
|
|
|
359
428
|
geometry_representation=geometry_representation,
|
|
360
429
|
session_id=session_id,
|
|
361
430
|
stale_query_probe_seconds=stale_query_probe_seconds,
|
|
431
|
+
session_fallback=session_fallback,
|
|
432
|
+
transport_factory=open_transport,
|
|
433
|
+
reconnect_timeout_seconds=reconnect_timeout_seconds,
|
|
362
434
|
)
|
wherobots/db/errors.py
CHANGED
|
@@ -24,3 +24,80 @@ class ProgrammingError(DatabaseError):
|
|
|
24
24
|
|
|
25
25
|
class NotSupportedError(DatabaseError):
|
|
26
26
|
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ConnectionLostError(OperationalError):
|
|
30
|
+
"""The connection to the SQL session was lost while a query was pending.
|
|
31
|
+
|
|
32
|
+
A connection from ``connect()`` or ``connect_direct()`` first reconnects
|
|
33
|
+
by itself and re-attaches its pending queries; this means that failed
|
|
34
|
+
within ``reconnect_timeout_seconds``, or that reconnecting is turned off.
|
|
35
|
+
The query may still have run, or be running, on the SQL session: its
|
|
36
|
+
outcome, for a write whether it committed, is unknown. Raised by
|
|
37
|
+
``Cursor.execute()`` itself, rather than by a fetch, it means the
|
|
38
|
+
connection was already lost and the statement was never sent.
|
|
39
|
+
|
|
40
|
+
Attributes:
|
|
41
|
+
session_id: The SQL session's ID, or None if the connection didn't
|
|
42
|
+
know it.
|
|
43
|
+
execution_id: The ID of the pending execution.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
message: str,
|
|
49
|
+
session_id: str | None = None,
|
|
50
|
+
execution_id: str | None = None,
|
|
51
|
+
) -> None:
|
|
52
|
+
super().__init__(message)
|
|
53
|
+
self.session_id = session_id
|
|
54
|
+
self.execution_id = execution_id
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class ExecutionCancelledError(OperationalError):
|
|
58
|
+
"""A re-attached execution was cancelled, and not by this connection.
|
|
59
|
+
|
|
60
|
+
The SQL session cancelled it before or while this connection re-attached
|
|
61
|
+
to it after reconnecting, for example because its grace period for a lost
|
|
62
|
+
connection ran out, or another client cancelled it. Its outcome is unknown: for a write, whether it
|
|
63
|
+
committed.
|
|
64
|
+
|
|
65
|
+
Attributes:
|
|
66
|
+
session_id: The SQL session's ID, or None if the connection didn't
|
|
67
|
+
know it.
|
|
68
|
+
execution_id: The ID of the execution.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
def __init__(
|
|
72
|
+
self,
|
|
73
|
+
message: str,
|
|
74
|
+
session_id: str | None = None,
|
|
75
|
+
execution_id: str | None = None,
|
|
76
|
+
) -> None:
|
|
77
|
+
super().__init__(message)
|
|
78
|
+
self.session_id = session_id
|
|
79
|
+
self.execution_id = execution_id
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class ExecutionNotFoundError(OperationalError):
|
|
83
|
+
"""The SQL session has no record of an execution to re-attach to.
|
|
84
|
+
|
|
85
|
+
After reconnecting, the session didn't know the execution: the statement
|
|
86
|
+
didn't reach it before the connection was lost, the session restarted, or
|
|
87
|
+
it no longer tracks the execution. Its outcome can't be recovered.
|
|
88
|
+
|
|
89
|
+
Attributes:
|
|
90
|
+
session_id: The SQL session's ID, or None if the connection didn't
|
|
91
|
+
know it.
|
|
92
|
+
execution_id: The ID of the execution.
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
def __init__(
|
|
96
|
+
self,
|
|
97
|
+
message: str,
|
|
98
|
+
session_id: str | None = None,
|
|
99
|
+
execution_id: str | None = None,
|
|
100
|
+
) -> None:
|
|
101
|
+
super().__init__(message)
|
|
102
|
+
self.session_id = session_id
|
|
103
|
+
self.execution_id = execution_id
|
{wherobots_python_dbapi-0.29.0.dist-info → wherobots_python_dbapi-0.30.0.dist-info}/METADATA
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: wherobots-python-dbapi
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.30.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
|
|
@@ -225,6 +225,49 @@ Progress events are best-effort and may not be available for all query
|
|
|
225
225
|
types or server versions. The handler is simply not invoked when no
|
|
226
226
|
progress information is available.
|
|
227
227
|
|
|
228
|
+
### Recovering from a lost connection
|
|
229
|
+
|
|
230
|
+
If the WebSocket to the SQL session drops while queries are running (a
|
|
231
|
+
network path failure, a keepalive timeout), the connection reconnects to
|
|
232
|
+
the same SQL session by itself and re-attaches every pending query, so
|
|
233
|
+
their results still arrive: there's nothing for your code to do. It keeps
|
|
234
|
+
trying, with backoff, for up to `reconnect_timeout_seconds` (60 seconds by
|
|
235
|
+
default; see below). It never starts a new session, and gives up at once
|
|
236
|
+
if the session refuses the connection, for example because it's gone.
|
|
237
|
+
While it reconnects, `execute()` and cancelling (closing a cursor, or
|
|
238
|
+
executing on it) wait for it to finish.
|
|
239
|
+
|
|
240
|
+
A re-attached query can still fail:
|
|
241
|
+
|
|
242
|
+
* If the SQL session doesn't know the execution (the statement didn't
|
|
243
|
+
reach it before the connection dropped, or the session restarted),
|
|
244
|
+
fetching raises `ExecutionNotFoundError`, a subclass of
|
|
245
|
+
`OperationalError`.
|
|
246
|
+
* If the SQL session cancelled the execution while the connection was
|
|
247
|
+
down (for example, its grace period for a lost connection ran out, or
|
|
248
|
+
another client cancelled it), fetching raises
|
|
249
|
+
`ExecutionCancelledError`, also an `OperationalError` subclass: its
|
|
250
|
+
outcome, including whether a write committed, is unknown. An execution
|
|
251
|
+
that you cancel yourself, by closing the cursor or executing on it,
|
|
252
|
+
completes with an empty result, as usual.
|
|
253
|
+
* An older SQL session that doesn't support attaching still answers with
|
|
254
|
+
the execution's state, and the driver polls it for the outcome (see
|
|
255
|
+
`stale_query_probe_seconds` below). If such a session reports no
|
|
256
|
+
location for a stored result, because it predates reporting it or
|
|
257
|
+
because the stored result is empty, the driver can't tell which, and
|
|
258
|
+
fails the query with an `OperationalError`. With probes disabled, a
|
|
259
|
+
running execution on such a session fails right away.
|
|
260
|
+
|
|
261
|
+
If the connection can't reconnect in time, or reconnecting is turned off
|
|
262
|
+
(`reconnect_timeout_seconds=None`), fetching a pending query's outcome
|
|
263
|
+
raises `ConnectionLostError`, another `OperationalError` subclass. The
|
|
264
|
+
query may still have run, or be running, on the SQL session: its outcome,
|
|
265
|
+
including whether a write committed, is unknown. The error's
|
|
266
|
+
`execution_id` and `session_id` attributes identify it, and a cursor's
|
|
267
|
+
`execution_id` property holds the ID of its current or last execution.
|
|
268
|
+
(When `execute()` itself raises `ConnectionLostError`, the connection was
|
|
269
|
+
already lost and the statement was never sent.)
|
|
270
|
+
|
|
228
271
|
### Runtime and region selection
|
|
229
272
|
|
|
230
273
|
You can chose the Wherobots runtime you want to use using the `runtime`
|
|
@@ -302,8 +345,21 @@ users may find useful:
|
|
|
302
345
|
caller needs to interrupt it (e.g. on client disconnect or timeout).
|
|
303
346
|
The event is checked before each HTTP request, between retry
|
|
304
347
|
attempts, and before the WebSocket handshake.
|
|
348
|
+
* `session_key`: keeps this connection on SQL sessions of its own. The
|
|
349
|
+
API reuses a session only for connections that ask with the same key,
|
|
350
|
+
and never one opened without a key, so a heavy query elsewhere never
|
|
351
|
+
holds the session your queries run on. When a new session would exceed
|
|
352
|
+
your organization's quota, the API shares another of your sessions
|
|
353
|
+
under another key instead, with or without `session_key`, and the
|
|
354
|
+
connection's `session_fallback` is `"quota"` (it is `None` otherwise).
|
|
355
|
+
`connection.session_id` names the session.
|
|
305
356
|
* `stale_query_probe_seconds`: how long a running query may go without
|
|
306
357
|
any message from the SQL session before the driver asks the session
|
|
307
358
|
for its state again, in case the query's completion event was lost.
|
|
308
359
|
Repeat probes back off to at most 8 times this interval. Defaults to
|
|
309
360
|
30 seconds; pass `None` to disable probing.
|
|
361
|
+
* `reconnect_timeout_seconds`: how long the connection keeps trying to
|
|
362
|
+
reconnect to the same SQL session after losing its WebSocket (see
|
|
363
|
+
"Recovering from a lost connection" above). Defaults to 60 seconds,
|
|
364
|
+
which fits in the SQL session's 120-second grace period for the
|
|
365
|
+
queries of a lost connection; pass `None` to turn reconnecting off.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
wherobots/db/__init__.py,sha256=JLjCvg6goYwPivVVQgFpRG3D_1Sa_roxvSisuW5-wJg,914
|
|
2
|
+
wherobots/db/_transport.py,sha256=eNe8YN_MqOTga3XNllCLFG69GQbzwEjMXBJsBLM3BEI,2256
|
|
3
|
+
wherobots/db/connection.py,sha256=NEHarzqeiernf4sKWISldzMvJ5JXR8Sr5UJLZe8nwHQ,61397
|
|
4
|
+
wherobots/db/constants.py,sha256=Y6O6g9oqvRIofRlOvB0U6qu9nEzM6V7fjVPw3nUxaMg,1546
|
|
5
|
+
wherobots/db/cursor.py,sha256=FPm6CcAivG7ouelbeVPesHvYDj8iisNs96wOKgvs5wQ,9097
|
|
6
|
+
wherobots/db/driver.py,sha256=DFN85nNTAktRhT__Rh_kJJfSzG0Gx8imOmskeMPlBRk,17745
|
|
7
|
+
wherobots/db/errors.py,sha256=gJ7GXLUSaHYSFFOeo6z0IZ3ag6ZkSimEZ3lyupx6TTI,2987
|
|
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.30.0.dist-info/METADATA,sha256=qUyuvhRROfEsCNTQ0T0Wva6jighxjhJYGF6zJNN7Vb4,14899
|
|
15
|
+
wherobots_python_dbapi-0.30.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
16
|
+
wherobots_python_dbapi-0.30.0.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
|
|
17
|
+
wherobots_python_dbapi-0.30.0.dist-info/RECORD,,
|
|
@@ -1,17 +0,0 @@
|
|
|
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,,
|
|
File without changes
|
{wherobots_python_dbapi-0.29.0.dist-info → wherobots_python_dbapi-0.30.0.dist-info}/licenses/LICENSE
RENAMED
|
File without changes
|