e6data-python-connector 3.0.0__py3-none-any.whl → 3.0.1rc2__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.
Files changed (25) hide show
  1. e6data_python_connector/async_connection.py +53 -2
  2. e6data_python_connector/async_connection_pool.py +2 -0
  3. e6data_python_connector/async_cursor.py +379 -19
  4. e6data_python_connector/async_dialect.py +2 -1
  5. e6data_python_connector/cluster_manager.py +3 -1
  6. e6data_python_connector/connection_pool.py +11 -9
  7. e6data_python_connector/dialect.py +7 -2
  8. e6data_python_connector/e6data_grpc.py +715 -108
  9. e6data_python_connector/result_batch.py +49 -0
  10. e6data_python_connector/result_decode.py +469 -0
  11. e6data_python_connector/result_decode_worker.py +50 -0
  12. e6data_python_connector/result_prefetch.py +421 -0
  13. e6data_python_connector/server/e6x_engine_pb2.py +149 -147
  14. e6data_python_connector/server/e6x_engine_pb2_grpc.py +43 -0
  15. {e6data_python_connector-3.0.0.dist-info → e6data_python_connector-3.0.1rc2.dist-info}/METADATA +134 -2
  16. {e6data_python_connector-3.0.0.dist-info → e6data_python_connector-3.0.1rc2.dist-info}/RECORD +25 -18
  17. test/integration/benchmark_result_batch_v2.py +149 -0
  18. test/integration/result_batch_benchmark_support.py +196 -0
  19. test/integration/support.py +3 -0
  20. test/integration/test_result_batch_v2.py +120 -0
  21. test/test_connection_pool.py +3 -0
  22. {e6data_python_connector-3.0.0.dist-info → e6data_python_connector-3.0.1rc2.dist-info}/WHEEL +0 -0
  23. {e6data_python_connector-3.0.0.dist-info → e6data_python_connector-3.0.1rc2.dist-info}/entry_points.txt +0 -0
  24. {e6data_python_connector-3.0.0.dist-info → e6data_python_connector-3.0.1rc2.dist-info}/licenses/LICENSE +0 -0
  25. {e6data_python_connector-3.0.0.dist-info → e6data_python_connector-3.0.1rc2.dist-info}/top_level.txt +0 -0
@@ -50,7 +50,11 @@ class AsyncConnection:
50
50
  token_url=None, oauth_scope=None, access_token=None,
51
51
  client_auth_method='basic', *, operation_timeout=600.0,
52
52
  oauth_timeout=10.0, cleanup_timeout=10.0, auto_resume_timeout=300.0,
53
- max_receive_message_bytes=64 * 1024 * 1024):
53
+ max_receive_message_bytes=64 * 1024 * 1024,
54
+ enable_result_batch_v2=False):
55
+ from .result_decode_worker import is_decode_worker
56
+ if is_decode_worker():
57
+ raise RuntimeError('A result decode worker cannot create a connection.')
54
58
  if sys.version_info < (3, 11):
55
59
  raise RuntimeError('The async API requires Python 3.11 or newer.')
56
60
  try:
@@ -78,13 +82,19 @@ class AsyncConnection:
78
82
  raise ValueError('Async OAuth requires verified TLS (secure=True).')
79
83
  if ssl_cert is not None and not isinstance(ssl_cert, (str, bytes)):
80
84
  raise ValueError('ssl_cert must be a certificate path or PEM bytes.')
85
+ if not isinstance(enable_result_batch_v2, bool):
86
+ raise ValueError('enable_result_batch_v2 must be a boolean.')
87
+ if enable_result_batch_v2:
88
+ from .result_decode import validate_decode_runtime
89
+ validate_decode_runtime()
81
90
  settings = dict(host=host, port=port, username=username, password=password,
82
91
  catalog=catalog, database=database, cluster_name=cluster_name,
83
92
  secure=bool(secure), ssl_cert=ssl_cert, auto_resume=auto_resume,
84
93
  scheme=scheme, debug=debug, require_fastbinary=require_fastbinary,
85
94
  client_id=client_id, client_secret=client_secret, token_url=token_url,
86
95
  oauth_scope=oauth_scope, access_token=access_token,
87
- client_auth_method=client_auth_method)
96
+ client_auth_method=client_auth_method,
97
+ enable_result_batch_v2=enable_result_batch_v2)
88
98
  for name, value in [('operation_timeout', operation_timeout), ('oauth_timeout', oauth_timeout),
89
99
  ('cleanup_timeout', cleanup_timeout), ('auto_resume_timeout', auto_resume_timeout)]:
90
100
  settings[name] = validate_positive_timeout(value, name)
@@ -103,6 +113,9 @@ class AsyncConnection:
103
113
  settings['grpc_options'] = MappingProxyType(options)
104
114
  settings['max_receive_message_bytes'] = max_receive_message_bytes
105
115
  self._config = MappingProxyType(settings)
116
+ if debug:
117
+ from .e6data_grpc import _configure_debug_logging
118
+ _configure_debug_logging()
106
119
  self._oauth_enabled = not modes[0]
107
120
  self._channel = self._client = self._token_provider = None
108
121
  self._owns_token_provider = True
@@ -125,6 +138,11 @@ class AsyncConnection:
125
138
  self._cleanup_error = None
126
139
  self._cluster_manager = None
127
140
  self._owns_cluster_manager = True
141
+ self._decoder_started = False
142
+ self._decoder_lease = None
143
+ if enable_result_batch_v2:
144
+ from .result_decode import DecoderLease
145
+ self._decoder_lease = DecoderLease()
128
146
 
129
147
  def __getattr__(self, name):
130
148
  config = self.__dict__.get('_config', {})
@@ -300,6 +318,24 @@ class AsyncConnection:
300
318
  self._check_owner(_cleanup=_cleanup, _internal=True)
301
319
  return metadata
302
320
 
321
+ async def _ensure_decoder(self, deadline):
322
+ self._check_owner()
323
+ if self._decoder_lease is not None and not self._decoder_started:
324
+ from .async_work import run_blocking
325
+ await run_blocking(self._decoder_lease.start, deadline, deadline=deadline)
326
+ self._check_owner()
327
+ self._decoder_started = True
328
+
329
+ async def _raw_result_rpc(self, method_name, request, metadata, deadline):
330
+ """Transport only: the owning task already froze identity and metadata."""
331
+ call = getattr(self._client, method_name)(
332
+ request, metadata=metadata, timeout=self._remaining(deadline))
333
+ self._calls.add(call)
334
+ try:
335
+ return await call
336
+ finally:
337
+ self._calls.discard(call)
338
+
303
339
  async def _rpc(self, method_name, request, *, deadline, route=None, safe_retry=False, _cleanup=False, _on_dispatch=None, _response_metadata=None):
304
340
  self._check_owner(_cleanup=_cleanup, _internal=True)
305
341
  refreshed = switched = False
@@ -500,6 +536,15 @@ class AsyncConnection:
500
536
  # Native channel close with no grace cancels transports without waiting.
501
537
  if self._channel is not None:
502
538
  await self._channel.close()
539
+ if self._decoder_lease is not None:
540
+ # Retirement must survive loop shutdown and saturated work slots.
541
+ self._decoder_lease.retire(deadline)
542
+ try:
543
+ async with asyncio.timeout_at(deadline):
544
+ while self._decoder_lease.cleanup_pending:
545
+ await asyncio.sleep(min(0.005, self._remaining(deadline)))
546
+ except (Exception, asyncio.CancelledError):
547
+ self._cleanup_error = OperationalError('Result decoder cleanup remains pending.')
503
548
  for resource, owned in ((self._cluster_manager, self._owns_cluster_manager),
504
549
  (self._token_provider, self._owns_token_provider)):
505
550
  if owned and resource is not None:
@@ -538,6 +583,12 @@ class AsyncConnection:
538
583
  if self._owner is not None and self._owner != (os.getpid(), threading.get_ident(), asyncio.get_running_loop()):
539
584
  raise ProgrammingError('Connection belongs to another owner.')
540
585
  await self.close()
586
+ if self._decoder_lease is not None and self._decoder_lease.cleanup_pending:
587
+ raise ProgrammingError('Cannot reopen while result decoder cleanup remains pending.')
588
+ if self.enable_result_batch_v2:
589
+ from .result_decode import DecoderLease
590
+ self._decoder_lease = DecoderLease()
591
+ self._decoder_started = False
541
592
  self._state = 'new'
542
593
  self._session_id = self._session_task = self._close_task = self._open_task = None
543
594
  if self._owns_token_provider:
@@ -316,6 +316,8 @@ class AsyncConnectionPool:
316
316
  healthy = healthy and cursor._state != 'SUBMISSION_UNKNOWN'
317
317
  await cursor._close_owned(deadline)
318
318
  healthy = healthy and cursor.cleanup_error is None
319
+ healthy = (healthy and cursor._pending_result is None
320
+ and not cursor._retired_results)
319
321
  healthy = (healthy and not connection._routes and not connection._ambiguous_submissions
320
322
  and connection._state == 'open')
321
323
  except (Exception, asyncio.CancelledError):
@@ -1,5 +1,7 @@
1
1
  """Native asynchronous query lifecycle, with no replay of consumed results."""
2
2
  import asyncio
3
+ import logging
4
+ import time
3
5
  from collections import deque
4
6
  from contextlib import contextmanager
5
7
  from io import BytesIO
@@ -11,6 +13,28 @@ from .e6data_grpc import _escaper
11
13
  from .exceptions import (AmbiguousSubmissionError, IncompleteResultError,
12
14
  NotSupportedError, OperationalError, ProgrammingError, OAuthError)
13
15
  from .server import e6x_engine_pb2 as pb
16
+ from .result_batch import ResultBatchBuffer, decode_result_batches
17
+ from .result_prefetch import ContinuousResultStream, PendingFetch
18
+
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ def _log_result_batch(protocol, rpc_seconds, status, response=None, decode_seconds=0.0):
24
+ chunk_count = ((len(response.resultBatches) if protocol == 'v2'
25
+ else int(bool(response.resultBatch))) if response else 0)
26
+ serialized_bytes = response.ByteSize() if response else 0
27
+ logger.debug(
28
+ 'Result batch fetch protocol=%s rpc_seconds=%.6f decode_seconds=%.6f '
29
+ 'chunk_count=%d serialized_bytes=%d status=%s',
30
+ protocol, rpc_seconds, decode_seconds, chunk_count, serialized_bytes, status, extra={
31
+ 'result_batch_protocol': protocol,
32
+ 'result_batch_rpc_seconds': rpc_seconds,
33
+ 'result_batch_chunk_count': chunk_count,
34
+ 'result_batch_serialized_bytes': serialized_bytes,
35
+ 'result_batch_decode_seconds': decode_seconds,
36
+ 'result_batch_status': status,
37
+ })
14
38
 
15
39
 
16
40
  def _metadata_columns(payload):
@@ -46,8 +70,20 @@ class AsyncCursor:
46
70
  self._active_call = None
47
71
  self._close_task = None
48
72
  self._cleanup_error = None
73
+ self._result_batches = ResultBatchBuffer()
74
+ self._pending_result = None
75
+ self._result_available = None
76
+ self._retired_results = []
77
+ self._decode_owner = None
78
+ self._reset_result_protocol()
49
79
  connection._cursors.add(self)
50
80
 
81
+ def _reset_result_protocol(self):
82
+ self._result_batch_v2 = self._connection.enable_result_batch_v2
83
+ self._result_protocol = 'v2' if self._result_batch_v2 else 'v1'
84
+ self._result_session_id = None
85
+ self._result_batches.clear()
86
+
51
87
  @property
52
88
  def connection(self):
53
89
  return self._public_connection
@@ -133,10 +169,14 @@ class AsyncCursor:
133
169
  self._active_call = None
134
170
 
135
171
  def _fail_result(self, reason):
172
+ self._retire_prefetch()
173
+ self._cancel_decode()
136
174
  if self._failure is None:
137
175
  self._failure = IncompleteResultError(reason, query_id=self.query_id)
138
176
  if self._state != 'CLOSED':
139
177
  self._state = 'RESULT_FAILED'
178
+ self._rows.clear()
179
+ self._result_batches.clear()
140
180
  return self._failure
141
181
 
142
182
  def _accept_rows(self, rows):
@@ -147,10 +187,18 @@ class AsyncCursor:
147
187
  self._rownumber += len(rows)
148
188
  return rows
149
189
 
150
- async def _query_request(self, cls, deadline):
190
+ async def _query_request(self, cls, deadline, *, owned=False):
151
191
  if self._route is None:
152
192
  raise ProgrammingError('No query handle is known.')
153
- session = await self._connection.get_session_id(deadline=deadline)
193
+ if self._result_batch_v2 and not self._connection._oauth_enabled and self._result_session_id:
194
+ session = self._result_session_id
195
+ elif owned:
196
+ # Owned cleanup cannot mint a replacement authentication session.
197
+ session = '' if self._connection._oauth_enabled else self._connection._session_id
198
+ if session is None:
199
+ raise OperationalError('Cleanup has no authenticated legacy session.')
200
+ else:
201
+ session = await self._connection.get_session_id(deadline=deadline)
154
202
  return cls(sessionId=session, queryId=self.query_id, engineIP=self._route.engine_ip)
155
203
 
156
204
  async def _refresh_metadata(self, deadline):
@@ -168,11 +216,13 @@ class AsyncCursor:
168
216
  from .async_connection import QueryRoute
169
217
  if self._state != 'EMPTY':
170
218
  raise ProgrammingError('Clear the previous query before executing another.')
219
+ self._reset_result_protocol()
171
220
  if not isinstance(operation, str) or not operation.strip():
172
221
  raise ProgrammingError('operation must be a nonempty SQL string.')
173
222
  sql = operation.strip().removesuffix(';')
174
223
  if parameters is not None:
175
224
  sql = sql % _escaper.escape_args(parameters)
225
+ await self._connection._ensure_decoder(deadline)
176
226
  session = await self._connection.get_session_id(deadline=deadline)
177
227
  v2 = bool(self._catalog)
178
228
  request_type = pb.PrepareStatementV2Request if v2 else pb.PrepareStatementRequest
@@ -268,6 +318,8 @@ class AsyncCursor:
268
318
 
269
319
  async def _next_batch(self, deadline):
270
320
  from .async_work import reserve_work
321
+ if self._result_batch_v2:
322
+ return await self._next_v2_batch(deadline)
271
323
  if self._state == 'EXHAUSTED':
272
324
  return None
273
325
  if self._columns is None:
@@ -279,17 +331,31 @@ class AsyncCursor:
279
331
  def mark_dispatch():
280
332
  nonlocal consumed
281
333
  consumed = True
334
+ started = time.monotonic()
335
+ response = None
336
+ decode_seconds = 0.0
282
337
  try:
283
- response = await self._call('getNextResultBatch', request, deadline, on_dispatch=mark_dispatch)
338
+ try:
339
+ response = await self._call('getNextResultBatch', request, deadline, on_dispatch=mark_dispatch)
340
+ finally:
341
+ rpc_seconds = time.monotonic() - started
284
342
  payload = response.resultBatch
285
343
  if not payload:
286
344
  self._state = 'EXHAUSTED'
287
345
  return None
288
- return await self._decode_batch(payload, reservation, revision)
346
+ decode_started = time.monotonic()
347
+ try:
348
+ return await self._decode_batch(payload, reservation, revision)
349
+ finally:
350
+ decode_seconds = time.monotonic() - decode_started
289
351
  except asyncio.CancelledError:
290
352
  if consumed:
291
353
  self._fail_result('ambiguous_result')
292
354
  raise
355
+ except grpc.RpcError:
356
+ if consumed:
357
+ self._fail_result('ambiguous_result')
358
+ raise
293
359
  except IncompleteResultError:
294
360
  raise
295
361
  except Exception as error:
@@ -298,6 +364,281 @@ class AsyncCursor:
298
364
  raise
299
365
  finally:
300
366
  reservation.release()
367
+ if consumed:
368
+ status = 'ok' if response is not None and self._failure is None else 'error'
369
+ _log_result_batch('v1', rpc_seconds,
370
+ status, response if status == 'ok' else None, decode_seconds if status == 'ok' else 0.0)
371
+
372
+ def _retain_result_session(self, response, revision):
373
+ self._publish(revision)
374
+ if self._result_batch_v2 and not self._connection._oauth_enabled and response.sessionId:
375
+ pending = self._pending_result[0] if self._pending_result is not None else None
376
+ self._result_session_id = (pending.session_id if isinstance(pending, ContinuousResultStream)
377
+ and pending.session_id else response.sessionId)
378
+
379
+ async def _decode_v2_response(self, response, reservation, revision, deadline=None):
380
+ self._retain_result_session(response, revision)
381
+ owner = None
382
+ try:
383
+ if self._connection._decoder_started:
384
+ owner = self._connection._decoder_lease.new_owner()
385
+ self._decode_owner = owner
386
+ chunks = await reservation.run(
387
+ self._connection._decoder_lease.decode, tuple(self._columns),
388
+ tuple(response.resultBatches), deadline, owner)
389
+ else:
390
+ chunks = await reservation.run(decode_result_batches, self._columns, response.resultBatches)
391
+ except asyncio.CancelledError:
392
+ self._cancel_decode()
393
+ raise
394
+ except Exception as error:
395
+ raise self._fail_result('decode_failed') from error
396
+ finally:
397
+ if self._decode_owner is owner:
398
+ self._decode_owner = None
399
+ self._publish(revision)
400
+ self._result_batches.accept(chunks, response.endOfStream)
401
+
402
+ def _cancel_decode(self):
403
+ if self._decode_owner is not None:
404
+ self._connection._decoder_lease.cancel(self._decode_owner)
405
+
406
+ def _retire_prefetch(self):
407
+ if self._pending_result is not None:
408
+ pending, _, _ = self._pending_result
409
+ self._pending_result = None
410
+ self._result_available = None
411
+ pending.retire()
412
+ self._retired_results.append(pending)
413
+
414
+ async def _dispatch_result(self, deadline, permit=None):
415
+ """Freeze identity in the owner; the background task only runs transport."""
416
+ revision, route = self._revision, self._route
417
+ request = await self._query_request(pb.GetNextResultBatchRequest, deadline)
418
+ metadata = await self._connection._metadata(deadline, route)
419
+ self._publish(revision)
420
+ self._connection._remaining(deadline)
421
+ method = 'getNextResultBatchV2' if self._result_protocol == 'v2' else 'getNextResultBatch'
422
+ task = asyncio.create_task(self._connection._raw_result_rpc(method, request, tuple(metadata), deadline))
423
+ pending = PendingFetch(task, permit, session_id=request.sessionId, deadline=deadline)
424
+ self._pending_result = (pending, revision, route)
425
+
426
+ async def _take_pending_result(self, deadline):
427
+ pending, revision, route = self._pending_result
428
+ if isinstance(pending, ContinuousResultStream):
429
+ event = self._result_available
430
+ while not pending.ready:
431
+ event.clear()
432
+ if pending.ready:
433
+ break
434
+ async with asyncio.timeout_at(deadline):
435
+ await event.wait()
436
+ else:
437
+ handle = pending.handle
438
+ if handle is not None and not pending.settled:
439
+ done, _ = await asyncio.wait({handle}, timeout=self._connection._remaining(deadline))
440
+ if not done:
441
+ raise TimeoutError('Result transport exceeded the operation deadline.')
442
+ self._publish(revision)
443
+ if route != self._route:
444
+ raise asyncio.CancelledError()
445
+ if not self._connection._oauth_enabled and pending.session_id:
446
+ self._result_session_id = pending.session_id
447
+ if not isinstance(pending, ContinuousResultStream):
448
+ self._pending_result = None
449
+ response = pending.take()
450
+ new_strategy = getattr(response, 'new_strategy', None)
451
+ if new_strategy in ('blue', 'green'):
452
+ self._connection._pending_strategy = new_strategy
453
+ self._connection._apply_pending_strategy()
454
+ return response, pending.elapsed
455
+
456
+ async def _start_prefetch(self, response, deadline):
457
+ if (self._result_protocol != 'v2' or response.endOfStream
458
+ or self._pending_result is not None):
459
+ return
460
+ revision, route = self._revision, self._route
461
+ try:
462
+ self._publish(revision)
463
+ self._connection._remaining(deadline)
464
+ except BaseException:
465
+ logger.debug('Continuous result download preparation failed.', extra={
466
+ 'result_batch_download_status': 'preparation_failed'})
467
+ raise
468
+ event = asyncio.Event()
469
+ stream = ContinuousResultStream(session_id=self._result_session_id, notify=event.set)
470
+ producer = asyncio.create_task(self._download_results(stream, revision, route))
471
+ stream.set_handle(producer)
472
+ self._result_available = event
473
+ self._pending_result = (stream, revision, route)
474
+ logger.debug('Continuous result download started.', extra={'result_batch_download_status': 'started'})
475
+
476
+ async def _download_results(self, stream, revision, route):
477
+ """Download ordered envelopes without using any decode/work reservation."""
478
+ budget = self._connection.operation_timeout
479
+ no_progress_deadline = time.monotonic() + budget
480
+ backoff = .01
481
+ rpc_started = None
482
+ try:
483
+ while not stream.retired:
484
+ deadline = min(time.monotonic() + budget, no_progress_deadline)
485
+ rpc_started = None
486
+ metadata = await self._connection._metadata(deadline, route)
487
+ if stream.retired or revision != self._revision or route != self._route:
488
+ stream.retire()
489
+ return
490
+ if self._lease_guard is not None:
491
+ self._lease_guard(internal=True)
492
+ self._connection._check_owner(_internal=True)
493
+ self._connection._remaining(deadline)
494
+ session = '' if self._connection._oauth_enabled else stream.session_id
495
+ if not self._connection._oauth_enabled and not session:
496
+ raise ProgrammingError('An active query session is required to download results.')
497
+ request = pb.GetNextResultBatchRequest(
498
+ sessionId=session, queryId=route.query_id, engineIP=route.engine_ip)
499
+ rpc_started = time.monotonic()
500
+ transport = asyncio.create_task(self._connection._raw_result_rpc(
501
+ 'getNextResultBatchV2', request, tuple(metadata), deadline))
502
+ stream.set_transport(transport)
503
+ try:
504
+ response = await asyncio.shield(transport)
505
+ finally:
506
+ if transport.done():
507
+ stream.clear_transport(transport)
508
+ stream.push(response, time.monotonic() - rpc_started)
509
+ if stream.retired or response.endOfStream:
510
+ return
511
+ if any(response.resultBatches):
512
+ no_progress_deadline = time.monotonic() + budget
513
+ backoff = .01
514
+ else:
515
+ await asyncio.sleep(min(backoff, self._connection._remaining(no_progress_deadline)))
516
+ backoff = min(backoff * 2, .1)
517
+ except asyncio.CancelledError:
518
+ stream.retire()
519
+ raise
520
+ except Exception as error:
521
+ stream.fail(error, 0.0 if rpc_started is None else time.monotonic() - rpc_started)
522
+ finally:
523
+ stream.finish()
524
+
525
+ async def _next_v2_batch(self, deadline):
526
+ from .async_work import reserve_work
527
+ batch = self._result_batches.pop()
528
+ if batch is not None:
529
+ return batch
530
+ if self._result_batches.finished or self._state == 'EXHAUSTED':
531
+ self._state = 'EXHAUSTED'
532
+ return None
533
+ consumed = self._pending_result is not None
534
+ backoff = .01
535
+ try:
536
+ await self._connection._ensure_decoder(deadline)
537
+ if self._columns is None:
538
+ await self._refresh_metadata(deadline)
539
+ while True:
540
+ consumed = consumed or self._pending_result is not None
541
+ reservation = None
542
+ revision = self._revision
543
+ protocol = self._result_protocol
544
+ previously_consumed = consumed
545
+ dispatched = False
546
+ response = None
547
+ status = 'error'
548
+ rpc_seconds = decode_seconds = 0.0
549
+ started = time.monotonic()
550
+ prefetched = self._pending_result is not None
551
+ transport_record = None
552
+ transport_status = 'error'
553
+
554
+ def mark_dispatch():
555
+ nonlocal consumed, dispatched
556
+ consumed = dispatched = True
557
+
558
+ try:
559
+ try:
560
+ if self._pending_result is None:
561
+ await self._dispatch_result(deadline)
562
+ transport_record = self._pending_result[0]
563
+ mark_dispatch()
564
+ response, rpc_seconds = await self._take_pending_result(deadline)
565
+ transport_status = 'ok'
566
+ except grpc.RpcError as error:
567
+ if protocol == 'v2' and error.code() == grpc.StatusCode.UNIMPLEMENTED:
568
+ consumed = previously_consumed
569
+ self._retire_prefetch()
570
+ # Settle V2 before V1 can advance the same query session.
571
+ async with asyncio.timeout_at(deadline):
572
+ await self._settle_retired_results()
573
+ self._publish(revision)
574
+ self._result_protocol = 'v1'
575
+ status = 'unimplemented'
576
+ logger.debug('Result batch compatibility fallback.',
577
+ extra={'result_batch_fallback': True})
578
+ continue
579
+ raise
580
+ finally:
581
+ wait_seconds = time.monotonic() - started
582
+ rpc_seconds = (transport_record.elapsed
583
+ if transport_record is not None and (
584
+ isinstance(transport_record, ContinuousResultStream)
585
+ or transport_record.settled)
586
+ else wait_seconds)
587
+ if prefetched:
588
+ logger.debug('Result prefetched RPC seconds=%.6f wait_seconds=%.6f status=%s',
589
+ rpc_seconds, wait_seconds, transport_status, extra={
590
+ 'result_batch_prefetched_rpc_seconds': rpc_seconds,
591
+ 'result_batch_prefetch_wait_seconds': wait_seconds,
592
+ 'result_batch_prefetch_status': transport_status})
593
+ decode_started = time.monotonic()
594
+ try:
595
+ if protocol == 'v2':
596
+ await self._start_prefetch(response, deadline)
597
+ reservation = await reserve_work(deadline=deadline)
598
+ await self._decode_v2_response(response, reservation, revision, deadline)
599
+ else:
600
+ self._retain_result_session(response, revision)
601
+ if response.resultBatch:
602
+ reservation = await reserve_work(deadline=deadline)
603
+ batch = await self._decode_batch(response.resultBatch, reservation, revision)
604
+ else:
605
+ self._state = 'EXHAUSTED'
606
+ batch = None
607
+ finally:
608
+ decode_seconds = time.monotonic() - decode_started
609
+ status = 'ok'
610
+ if protocol == 'v1':
611
+ return batch
612
+ batch = self._result_batches.pop()
613
+ if batch is not None:
614
+ return batch
615
+ if self._result_batches.finished:
616
+ self._state = 'EXHAUSTED'
617
+ return None
618
+ finally:
619
+ if reservation is not None:
620
+ reservation.release()
621
+ if dispatched:
622
+ _log_result_batch(protocol, rpc_seconds, status,
623
+ response if status == 'ok' else None,
624
+ decode_seconds if status == 'ok' else 0.0)
625
+ if not isinstance(transport_record, ContinuousResultStream):
626
+ await asyncio.sleep(min(backoff, self._connection._remaining(deadline)))
627
+ backoff = min(backoff * 2, .1)
628
+ except asyncio.CancelledError:
629
+ if consumed:
630
+ self._fail_result('ambiguous_result')
631
+ raise
632
+ except grpc.RpcError:
633
+ if consumed:
634
+ self._fail_result('ambiguous_result')
635
+ raise
636
+ except IncompleteResultError:
637
+ raise
638
+ except Exception as error:
639
+ if consumed:
640
+ raise self._fail_result('ambiguous_result') from error
641
+ raise
301
642
 
302
643
  async def _fetchmany(self, size, deadline):
303
644
  while len(self._rows) < size and self._state != 'EXHAUSTED':
@@ -317,7 +658,7 @@ class AsyncCursor:
317
658
  raise ProgrammingError('Fetch size must be a nonnegative integer.')
318
659
  try:
319
660
  return await self._fetchmany(size, self._connection._deadline(timeout))
320
- except (asyncio.CancelledError, OperationalError, ProgrammingError, OAuthError):
661
+ except (asyncio.CancelledError, grpc.RpcError, OperationalError, ProgrammingError, OAuthError):
321
662
  raise
322
663
  except Exception as error:
323
664
  raise _operational(error) from error
@@ -335,7 +676,7 @@ class AsyncCursor:
335
676
  if rows is not None:
336
677
  self._rownumber += len(rows)
337
678
  return rows
338
- except (asyncio.CancelledError, OperationalError, ProgrammingError, OAuthError):
679
+ except (asyncio.CancelledError, grpc.RpcError, OperationalError, ProgrammingError, OAuthError):
339
680
  raise
340
681
  except Exception as error:
341
682
  raise _operational(error) from error
@@ -353,6 +694,8 @@ class AsyncCursor:
353
694
  return rows
354
695
  rows.extend(batch)
355
696
  self._rownumber += len(batch)
697
+ except grpc.RpcError:
698
+ raise
356
699
  except asyncio.CancelledError:
357
700
  if rows:
358
701
  self._fail_result('aggregation_failed')
@@ -440,38 +783,50 @@ class AsyncCursor:
440
783
  return await self._connection.get_schema_names(self._catalog, timeout=timeout)
441
784
 
442
785
  async def _stop_active(self):
786
+ self._cancel_decode()
787
+ self._retire_prefetch()
443
788
  self._revision += 1
444
789
  task = self._operation_task or self._active_call
445
790
  if task is not None and task is not asyncio.current_task():
446
- task.cancel()
447
- await asyncio.gather(task, return_exceptions=True)
791
+ task.cancel()
792
+ await asyncio.gather(task, return_exceptions=True)
793
+ await self._settle_retired_results()
794
+
795
+ async def _settle_retired_results(self):
796
+ for pending in tuple(self._retired_results):
797
+ handle = pending.handle
798
+ if handle is not None and not pending.settled:
799
+ await asyncio.gather(asyncio.shield(handle), return_exceptions=True)
800
+ while not pending.settled:
801
+ await asyncio.sleep(.005)
802
+ if not self._connection._oauth_enabled and pending.session_id:
803
+ self._result_session_id = pending.session_id
804
+ self._retired_results.remove(pending)
448
805
 
449
806
  async def _clear(self, deadline, owned=False):
450
807
  await self._stop_active()
451
808
  if self._route is None:
452
809
  if self._state == 'SUBMISSION_UNKNOWN' or self in self._connection._ambiguous_submissions:
453
810
  raise AmbiguousSubmissionError()
811
+ self._reset_query_state()
454
812
  return None
455
- if owned:
456
- # Owned cleanup has no authority to mint a new legacy session silently.
457
- session = '' if self._connection._oauth_enabled else self._connection._session_id
458
- if session is None:
459
- raise OperationalError('Cleanup has no authenticated legacy session.')
460
- else:
461
- session = await self._connection.get_session_id(deadline=deadline)
462
- request = pb.ClearOrCancelQueryRequest(sessionId=session, queryId=self.query_id, engineIP=self._route.engine_ip)
813
+ request = await self._query_request(pb.ClearOrCancelQueryRequest, deadline, owned=owned)
463
814
  response = await self._connection._rpc('clearOrCancelQuery', request, deadline=deadline,
464
815
  route=self._route, _cleanup=owned)
465
816
  self._connection._release_route(self.query_id)
466
817
  self._connection._ambiguous_submissions.discard(self)
818
+ self._reset_query_state()
819
+ return response
820
+
821
+ def _reset_query_state(self):
467
822
  self._route = None
468
823
  self._rows.clear()
824
+ self._reset_result_protocol()
469
825
  self._columns = self._description = None
470
826
  self._rowcount, self._rownumber = -1, 0
471
827
  self._failure = self._cleanup_error = None
472
828
  if self._state != 'CLOSED':
473
829
  self._state = 'EMPTY'
474
- return response
475
830
 
476
831
  async def clear(self, query_id=None, *, timeout=None):
477
832
  self._check()
@@ -496,6 +851,8 @@ class AsyncCursor:
496
851
  try:
497
852
  async with asyncio.timeout_at(deadline):
498
853
  await self._stop_active()
854
+ if self._result_batch_v2:
855
+ self._fail_result('cancelled_result')
499
856
  request = await self._query_request(pb.CancelQueryRequest, deadline)
500
857
  await self._connection._rpc('cancelQuery', request, deadline=deadline, route=self._route)
501
858
  self._fail_result('cancelled_result')
@@ -505,7 +862,7 @@ class AsyncCursor:
505
862
  raise _operational(error) from error
506
863
 
507
864
  async def _close_owned(self, deadline):
508
- if self._state == 'CLOSED':
865
+ if self._state == 'CLOSED' and self._route is None and not self._retired_results:
509
866
  return
510
867
  unknown_without_handle = self._state == 'SUBMISSION_UNKNOWN' and self._route is None
511
868
  if unknown_without_handle:
@@ -524,7 +881,10 @@ class AsyncCursor:
524
881
  self._cleanup_error = OperationalError('Query cleanup is unconfirmed.')
525
882
  finally:
526
883
  self._rows.clear()
527
- self._connection._cursors.discard(self)
884
+ self._result_batches.clear()
885
+ if self._route is None and not self._retired_results:
886
+ self._result_session_id = None
887
+ self._connection._cursors.discard(self)
528
888
 
529
889
  async def close(self):
530
890
  if self._lease_guard is not None:
@@ -216,7 +216,8 @@ class E6dataAsyncDialect(E6dataDialect):
216
216
  aliases = {'schema': 'database', 'cluster-name': 'cluster_name',
217
217
  'cluster-uuid': 'cluster_name', 'auto-resume': 'auto_resume'}
218
218
  strings = {'catalog', 'database', 'cluster_name', 'ssl_cert'}
219
- booleans = {'secure', 'auto_resume', 'debug', 'require_fastbinary'}
219
+ booleans = {'secure', 'auto_resume', 'debug', 'require_fastbinary',
220
+ 'enable_result_batch_v2'}
220
221
  floats = {'operation_timeout', 'cleanup_timeout', 'oauth_timeout', 'auto_resume_timeout'}
221
222
  options = {}
222
223
  if url.host is not None: