wherobots-python-dbapi 0.28.1__py3-none-any.whl → 0.29.0__py3-none-any.whl

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