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 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",
@@ -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 DEFAULT_READ_TIMEOUT_SECONDS, DEFAULT_STALE_QUERY_PROBE_SECONDS
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 NotSupportedError, OperationalError
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
- def _probe_interval(value: Any) -> float | None:
110
- """The validated stale-query probe interval, or ``None`` to disable it.
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 = _probe_interval(stale_query_probe_seconds)
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
- self.__receive_loop()
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("Connection closed; stopping main loop.")
464
+ logging.info("SQL session connection closed.")
342
465
  return
343
466
  except _TransportError:
344
- logging.exception("SQL session transport failed; stopping main loop")
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
- if not self.__request_results(query.execution_id, probe=True):
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 __connection_error(self, execution_id: str) -> OperationalError:
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 OperationalError(message)
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 error == _EXECUTION_NOT_FOUND
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
- elif message.get("execution_id") not in self.__queries:
769
- return True
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(self.__ws):
1221
+ if not blocking and not socket_writable(ws):
778
1222
  return False
779
1223
  try:
780
- self.__ws.send(request)
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.__fail_pending()
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 __request_results(self, execution_id: str, probe: bool = False) -> bool:
867
- """Send retrieve_results; ``False`` if a probe found the sender busy.
868
-
869
- A probe never waits for ``__send_lock`` or a full socket (see
870
- ``__probe_stale_queries`` and ``__send``).
871
- """
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
- query = self.__queries.get(execution_id)
897
- if not query:
898
- return
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
- _check_cancelled(cancel_event)
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: wherobots-python-dbapi
3
- Version: 0.29.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,,