wherobots-python-dbapi 0.28.2__py3-none-any.whl → 0.30.0__py3-none-any.whl

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