e6data-python-connector 3.0.0__py3-none-any.whl → 3.0.1__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 (27) hide show
  1. e6data_python_connector/async_cluster_manager.py +9 -4
  2. e6data_python_connector/async_connection.py +82 -9
  3. e6data_python_connector/async_connection_pool.py +4 -0
  4. e6data_python_connector/async_cursor.py +382 -21
  5. e6data_python_connector/async_dialect.py +2 -1
  6. e6data_python_connector/cluster_manager.py +61 -10
  7. e6data_python_connector/connection_pool.py +11 -9
  8. e6data_python_connector/dialect.py +7 -2
  9. e6data_python_connector/e6data_grpc.py +722 -110
  10. e6data_python_connector/result_batch.py +49 -0
  11. e6data_python_connector/result_decode.py +559 -0
  12. e6data_python_connector/result_decode_worker.py +50 -0
  13. e6data_python_connector/result_prefetch.py +421 -0
  14. e6data_python_connector/server/e6x_engine_pb2.py +149 -147
  15. e6data_python_connector/server/e6x_engine_pb2_grpc.py +43 -0
  16. {e6data_python_connector-3.0.0.dist-info → e6data_python_connector-3.0.1.dist-info}/METADATA +147 -2
  17. {e6data_python_connector-3.0.0.dist-info → e6data_python_connector-3.0.1.dist-info}/RECORD +27 -20
  18. test/integration/benchmark_result_batch_v2.py +149 -0
  19. test/integration/result_batch_benchmark_support.py +196 -0
  20. test/integration/support.py +3 -0
  21. test/integration/test_result_batch_v2.py +120 -0
  22. test/test_connection_pool.py +3 -0
  23. test/test_oauth_auto_resume.py +7 -2
  24. {e6data_python_connector-3.0.0.dist-info → e6data_python_connector-3.0.1.dist-info}/WHEEL +0 -0
  25. {e6data_python_connector-3.0.0.dist-info → e6data_python_connector-3.0.1.dist-info}/entry_points.txt +0 -0
  26. {e6data_python_connector-3.0.0.dist-info → e6data_python_connector-3.0.1.dist-info}/licenses/LICENSE +0 -0
  27. {e6data_python_connector-3.0.0.dist-info → e6data_python_connector-3.0.1.dist-info}/top_level.txt +0 -0
@@ -123,10 +123,15 @@ class AsyncClusterManager:
123
123
  options = dict(grpc_options or {})
124
124
  if any(key.removeprefix('grpc.') in ('ssl_target_name_override', 'default_authority') for key in options):
125
125
  raise ValueError('TLS authority overrides are unsupported.')
126
- limit = options.get('grpc.max_receive_message_length', 64 * 1024 * 1024)
127
- if isinstance(limit, bool) or not isinstance(limit, int) or limit <= 0:
128
- raise ValueError('A finite positive receive limit is required.')
129
- options['grpc.max_receive_message_length'] = limit
126
+ for name in ('max_receive_message_length', 'max_send_message_length'):
127
+ limits = [options.pop(key) for key in (name, 'grpc.' + name) if key in options]
128
+ for limit in limits:
129
+ if (isinstance(limit, bool) or not isinstance(limit, int)
130
+ or (limit != -1 and limit <= 0)):
131
+ raise ValueError(name + ' must be -1 (unlimited) or a positive integer.')
132
+ if len(limits) == 2 and limits[0] != limits[1]:
133
+ raise ValueError('Conflicting ' + name + ' options.')
134
+ options['grpc.' + name] = limits[0] if limits else -1
130
135
  self._host, self._port = host, port
131
136
  self._user, self._password = user, password
132
137
  self._secure_channel, self._ssl_cert = secure_channel, ssl_cert
@@ -14,6 +14,9 @@ from .oauth_common import validate_positive_timeout, validate_token_endpoint
14
14
  from .server import e6x_engine_pb2 as pb, e6x_engine_pb2_grpc as bindings
15
15
 
16
16
 
17
+ _UNSET_RECEIVE_MESSAGE_BYTES = object()
18
+
19
+
17
20
  @dataclass(frozen=True)
18
21
  class QueryRoute:
19
22
  target: str
@@ -50,7 +53,11 @@ class AsyncConnection:
50
53
  token_url=None, oauth_scope=None, access_token=None,
51
54
  client_auth_method='basic', *, operation_timeout=600.0,
52
55
  oauth_timeout=10.0, cleanup_timeout=10.0, auto_resume_timeout=300.0,
53
- max_receive_message_bytes=64 * 1024 * 1024):
56
+ max_receive_message_bytes=_UNSET_RECEIVE_MESSAGE_BYTES,
57
+ enable_result_batch_v2=False):
58
+ from .result_decode_worker import is_decode_worker
59
+ if is_decode_worker():
60
+ raise RuntimeError('A result decode worker cannot create a connection.')
54
61
  if sys.version_info < (3, 11):
55
62
  raise RuntimeError('The async API requires Python 3.11 or newer.')
56
63
  try:
@@ -78,24 +85,47 @@ class AsyncConnection:
78
85
  raise ValueError('Async OAuth requires verified TLS (secure=True).')
79
86
  if ssl_cert is not None and not isinstance(ssl_cert, (str, bytes)):
80
87
  raise ValueError('ssl_cert must be a certificate path or PEM bytes.')
88
+ if not isinstance(enable_result_batch_v2, bool):
89
+ raise ValueError('enable_result_batch_v2 must be a boolean.')
90
+ if enable_result_batch_v2:
91
+ from .result_decode import validate_decode_runtime
92
+ validate_decode_runtime()
81
93
  settings = dict(host=host, port=port, username=username, password=password,
82
94
  catalog=catalog, database=database, cluster_name=cluster_name,
83
95
  secure=bool(secure), ssl_cert=ssl_cert, auto_resume=auto_resume,
84
96
  scheme=scheme, debug=debug, require_fastbinary=require_fastbinary,
85
97
  client_id=client_id, client_secret=client_secret, token_url=token_url,
86
98
  oauth_scope=oauth_scope, access_token=access_token,
87
- client_auth_method=client_auth_method)
99
+ client_auth_method=client_auth_method,
100
+ enable_result_batch_v2=enable_result_batch_v2)
88
101
  for name, value in [('operation_timeout', operation_timeout), ('oauth_timeout', oauth_timeout),
89
102
  ('cleanup_timeout', cleanup_timeout), ('auto_resume_timeout', auto_resume_timeout)]:
90
103
  settings[name] = validate_positive_timeout(value, name)
91
- if isinstance(max_receive_message_bytes, bool) or not isinstance(max_receive_message_bytes, int) or max_receive_message_bytes <= 0:
92
- raise ValueError('max_receive_message_bytes must be a positive integer.')
93
104
  options = dict(grpc_options or {})
94
- for name in ('max_receive_message_length', 'grpc.max_receive_message_length'):
95
- if name in options:
96
- if options[name] != max_receive_message_bytes:
97
- raise ValueError('Set the finite receive limit using max_receive_message_bytes.')
98
- del options[name]
105
+ receive_names = ('max_receive_message_length', 'grpc.max_receive_message_length')
106
+ receive_values = [options.pop(name) for name in receive_names if name in options]
107
+ for value in receive_values:
108
+ if (isinstance(value, bool) or not isinstance(value, int)
109
+ or (value != -1 and value <= 0)):
110
+ raise ValueError('max_receive_message_length must be -1 (unlimited) or a positive integer.')
111
+ if len(receive_values) == 2 and receive_values[0] != receive_values[1]:
112
+ raise ValueError('Conflicting max_receive_message_length options.')
113
+ if max_receive_message_bytes is _UNSET_RECEIVE_MESSAGE_BYTES:
114
+ max_receive_message_bytes = receive_values[0] if receive_values else -1
115
+ if (isinstance(max_receive_message_bytes, bool) or not isinstance(max_receive_message_bytes, int)
116
+ or (max_receive_message_bytes != -1 and max_receive_message_bytes <= 0)):
117
+ raise ValueError('max_receive_message_bytes must be -1 (unlimited) or a positive integer.')
118
+ if receive_values and receive_values[0] != max_receive_message_bytes:
119
+ raise ValueError('Set the receive limit using max_receive_message_bytes; supplied options must agree.')
120
+ send_names = ('max_send_message_length', 'grpc.max_send_message_length')
121
+ send_values = [options.pop(name) for name in send_names if name in options]
122
+ for value in send_values:
123
+ if (isinstance(value, bool) or not isinstance(value, int)
124
+ or (value != -1 and value <= 0)):
125
+ raise ValueError('max_send_message_length must be -1 (unlimited) or a positive integer.')
126
+ if len(send_values) == 2 and send_values[0] != send_values[1]:
127
+ raise ValueError('Conflicting max_send_message_length options.')
128
+ options['grpc.max_send_message_length'] = send_values[0] if send_values else -1
99
129
  # Authority overrides can disable meaningful server identity validation.
100
130
  if any(name.removeprefix('grpc.') in ('ssl_target_name_override', 'default_authority') for name in options):
101
131
  raise ValueError('TLS authority overrides are unsupported.')
@@ -103,6 +133,9 @@ class AsyncConnection:
103
133
  settings['grpc_options'] = MappingProxyType(options)
104
134
  settings['max_receive_message_bytes'] = max_receive_message_bytes
105
135
  self._config = MappingProxyType(settings)
136
+ if debug:
137
+ from .e6data_grpc import _configure_debug_logging
138
+ _configure_debug_logging()
106
139
  self._oauth_enabled = not modes[0]
107
140
  self._channel = self._client = self._token_provider = None
108
141
  self._owns_token_provider = True
@@ -125,6 +158,11 @@ class AsyncConnection:
125
158
  self._cleanup_error = None
126
159
  self._cluster_manager = None
127
160
  self._owns_cluster_manager = True
161
+ self._decoder_started = False
162
+ self._decoder_lease = None
163
+ if enable_result_batch_v2:
164
+ from .result_decode import DecoderLease
165
+ self._decoder_lease = DecoderLease()
128
166
 
129
167
  def __getattr__(self, name):
130
168
  config = self.__dict__.get('_config', {})
@@ -300,6 +338,24 @@ class AsyncConnection:
300
338
  self._check_owner(_cleanup=_cleanup, _internal=True)
301
339
  return metadata
302
340
 
341
+ async def _ensure_decoder(self, deadline):
342
+ self._check_owner()
343
+ if self._decoder_lease is not None and not self._decoder_started:
344
+ from .async_work import run_blocking
345
+ await run_blocking(self._decoder_lease.start, deadline, deadline=deadline)
346
+ self._check_owner()
347
+ self._decoder_started = True
348
+
349
+ async def _raw_result_rpc(self, method_name, request, metadata, deadline):
350
+ """Transport only: the owning task already froze identity and metadata."""
351
+ call = getattr(self._client, method_name)(
352
+ request, metadata=metadata, timeout=self._remaining(deadline))
353
+ self._calls.add(call)
354
+ try:
355
+ return await call
356
+ finally:
357
+ self._calls.discard(call)
358
+
303
359
  async def _rpc(self, method_name, request, *, deadline, route=None, safe_retry=False, _cleanup=False, _on_dispatch=None, _response_metadata=None):
304
360
  self._check_owner(_cleanup=_cleanup, _internal=True)
305
361
  refreshed = switched = False
@@ -349,6 +405,8 @@ class AsyncConnection:
349
405
 
350
406
  self._cluster_manager = AsyncClusterManager(
351
407
  self.host, self.port, user=self.username or '', password=self.password or '',
408
+ grpc_options={**self.grpc_options,
409
+ 'grpc.max_receive_message_length': self.max_receive_message_bytes},
352
410
  secure_channel=self.secure, cluster_uuid=self.cluster_name,
353
411
  ssl_cert=self.ssl_cert, metadata_provider=metadata_provider if self._oauth_enabled else None,
354
412
  initial_strategy=self.strategy, auto_resume_timeout=self.auto_resume_timeout,
@@ -500,6 +558,15 @@ class AsyncConnection:
500
558
  # Native channel close with no grace cancels transports without waiting.
501
559
  if self._channel is not None:
502
560
  await self._channel.close()
561
+ if self._decoder_lease is not None:
562
+ # Retirement must survive loop shutdown and saturated work slots.
563
+ self._decoder_lease.retire(deadline)
564
+ try:
565
+ async with asyncio.timeout_at(deadline):
566
+ while self._decoder_lease.cleanup_pending:
567
+ await asyncio.sleep(min(0.005, self._remaining(deadline)))
568
+ except (Exception, asyncio.CancelledError):
569
+ self._cleanup_error = OperationalError('Result decoder cleanup remains pending.')
503
570
  for resource, owned in ((self._cluster_manager, self._owns_cluster_manager),
504
571
  (self._token_provider, self._owns_token_provider)):
505
572
  if owned and resource is not None:
@@ -538,6 +605,12 @@ class AsyncConnection:
538
605
  if self._owner is not None and self._owner != (os.getpid(), threading.get_ident(), asyncio.get_running_loop()):
539
606
  raise ProgrammingError('Connection belongs to another owner.')
540
607
  await self.close()
608
+ if self._decoder_lease is not None and self._decoder_lease.cleanup_pending:
609
+ raise ProgrammingError('Cannot reopen while result decoder cleanup remains pending.')
610
+ if self.enable_result_batch_v2:
611
+ from .result_decode import DecoderLease
612
+ self._decoder_lease = DecoderLease()
613
+ self._decoder_started = False
541
614
  self._state = 'new'
542
615
  self._session_id = self._session_task = self._close_task = self._open_task = None
543
616
  if self._owns_token_provider:
@@ -189,6 +189,8 @@ class AsyncConnectionPool:
189
189
  from .async_cluster_manager import AsyncClusterManager
190
190
  self._cluster_manager = AsyncClusterManager(
191
191
  config.host, config.port, user=config.username or '', password=config.password or '',
192
+ grpc_options={**config.grpc_options,
193
+ 'grpc.max_receive_message_length': config.max_receive_message_bytes},
192
194
  secure_channel=config.secure, ssl_cert=config.ssl_cert,
193
195
  cluster_uuid=config.cluster_name, initial_strategy='blue',
194
196
  metadata_provider=self._resume_metadata if config._oauth_enabled else None,
@@ -316,6 +318,8 @@ class AsyncConnectionPool:
316
318
  healthy = healthy and cursor._state != 'SUBMISSION_UNKNOWN'
317
319
  await cursor._close_owned(deadline)
318
320
  healthy = healthy and cursor.cleanup_error is None
321
+ healthy = (healthy and cursor._pending_result is None
322
+ and not cursor._retired_results)
319
323
  healthy = (healthy and not connection._routes and not connection._ambiguous_submissions
320
324
  and connection._state == 'open')
321
325
  except (Exception, asyncio.CancelledError):