e6data-python-connector 3.0.0__tar.gz → 3.0.1__tar.gz

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 (126) hide show
  1. {e6data_python_connector-3.0.0/e6data_python_connector.egg-info → e6data_python_connector-3.0.1}/PKG-INFO +147 -2
  2. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/README.md +146 -1
  3. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/e6data_python_connector/async_cluster_manager.py +9 -4
  4. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/e6data_python_connector/async_connection.py +82 -9
  5. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/e6data_python_connector/async_connection_pool.py +4 -0
  6. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/e6data_python_connector/async_cursor.py +382 -21
  7. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/e6data_python_connector/async_dialect.py +2 -1
  8. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/e6data_python_connector/cluster_manager.py +61 -10
  9. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/e6data_python_connector/connection_pool.py +11 -9
  10. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/e6data_python_connector/dialect.py +7 -2
  11. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/e6data_python_connector/e6data_grpc.py +722 -110
  12. e6data_python_connector-3.0.1/e6data_python_connector/result_batch.py +49 -0
  13. e6data_python_connector-3.0.1/e6data_python_connector/result_decode.py +559 -0
  14. e6data_python_connector-3.0.1/e6data_python_connector/result_decode_worker.py +50 -0
  15. e6data_python_connector-3.0.1/e6data_python_connector/result_prefetch.py +421 -0
  16. e6data_python_connector-3.0.1/e6data_python_connector/server/e6x_engine_pb2.py +209 -0
  17. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/e6data_python_connector/server/e6x_engine_pb2_grpc.py +43 -0
  18. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1/e6data_python_connector.egg-info}/PKG-INFO +147 -2
  19. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/e6data_python_connector.egg-info/SOURCES.txt +7 -0
  20. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/setup.py +1 -1
  21. e6data_python_connector-3.0.1/test/integration/benchmark_result_batch_v2.py +149 -0
  22. e6data_python_connector-3.0.1/test/integration/result_batch_benchmark_support.py +196 -0
  23. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/test/integration/support.py +3 -0
  24. e6data_python_connector-3.0.1/test/integration/test_result_batch_v2.py +120 -0
  25. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/test/test_connection_pool.py +3 -0
  26. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/test/test_oauth_auto_resume.py +7 -2
  27. e6data_python_connector-3.0.0/e6data_python_connector/server/e6x_engine_pb2.py +0 -207
  28. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/LICENSE +0 -0
  29. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/MANIFEST.in +0 -0
  30. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/e6data_python_connector/__init__.py +0 -0
  31. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/e6data_python_connector/aio.py +0 -0
  32. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/e6data_python_connector/async_oauth.py +0 -0
  33. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/e6data_python_connector/async_work.py +0 -0
  34. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/e6data_python_connector/cluster_server/__init__.py +0 -0
  35. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/e6data_python_connector/cluster_server/cluster_pb2.py +0 -0
  36. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/e6data_python_connector/cluster_server/cluster_pb2_grpc.py +0 -0
  37. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/e6data_python_connector/common.py +0 -0
  38. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/e6data_python_connector/constants.py +0 -0
  39. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/e6data_python_connector/datainputstream.py +0 -0
  40. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/e6data_python_connector/date_time_utils.py +0 -0
  41. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/e6data_python_connector/e6x_vector/__init__.py +0 -0
  42. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/e6data_python_connector/e6x_vector/constants.py +0 -0
  43. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/e6data_python_connector/e6x_vector/ttypes.py +0 -0
  44. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/e6data_python_connector/exceptions.py +0 -0
  45. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/e6data_python_connector/oauth.py +0 -0
  46. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/e6data_python_connector/oauth_cache.py +0 -0
  47. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/e6data_python_connector/oauth_common.py +0 -0
  48. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/e6data_python_connector/server/QueryEngineService.py +0 -0
  49. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/e6data_python_connector/server/__init__.py +0 -0
  50. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/e6data_python_connector/server/constants.py +0 -0
  51. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/e6data_python_connector/server/e6x_engine_pb2.pyi +0 -0
  52. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/e6data_python_connector/server/ttypes.py +0 -0
  53. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/e6data_python_connector/strategy.py +0 -0
  54. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/e6data_python_connector/typeId.py +0 -0
  55. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/e6data_python_connector.egg-info/dependency_links.txt +0 -0
  56. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/e6data_python_connector.egg-info/entry_points.txt +0 -0
  57. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/e6data_python_connector.egg-info/requires.txt +0 -0
  58. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/e6data_python_connector.egg-info/top_level.txt +0 -0
  59. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/pyproject.toml +0 -0
  60. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/setup.cfg +0 -0
  61. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/test/__init__.py +0 -0
  62. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/test/analyze_38_nines.py +0 -0
  63. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/test/analyze_all_cases.py +0 -0
  64. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/test/analyze_binary.py +0 -0
  65. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/test/analyze_correct_value.py +0 -0
  66. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/test/analyze_fields.py +0 -0
  67. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/test/check_decimal_errors.py +0 -0
  68. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/test/cleanup_test_files.py +0 -0
  69. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/test/conftest.py +0 -0
  70. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/test/debug_38_nines.py +0 -0
  71. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/test/debug_binary.py +0 -0
  72. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/test/final_test.py +0 -0
  73. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/test/integration/__init__.py +0 -0
  74. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/test/integration/benchmark_preflight.py +0 -0
  75. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/test/integration/benchmark_sync_async.py +0 -0
  76. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/test/integration/expiry_preflight.py +0 -0
  77. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/test/integration/test_async_connection.py +0 -0
  78. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/test/integration/test_async_pool.py +0 -0
  79. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/test/integration/test_async_queries.py +0 -0
  80. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/test/integration/test_async_resume.py +0 -0
  81. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/test/integration/test_async_sqlalchemy.py +0 -0
  82. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/test/integration/test_async_token_lifecycle.py +0 -0
  83. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/test/integration/test_e2e_auth_boundary.py +0 -0
  84. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/test/integration/test_e2e_oauth_continuation.py +0 -0
  85. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/test/integration/test_e2e_readonly_matrix.py +0 -0
  86. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/test/integration/test_shared_token_issuance.py +0 -0
  87. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/test/integration/test_sync_oauth_lifecycle.py +0 -0
  88. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/test/integration/token_observation.py +0 -0
  89. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/test/move_tests.py +0 -0
  90. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/test/quick_test.py +0 -0
  91. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/test/test_38_nines.py +0 -0
  92. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/test/test_all_decimal128_cases.py +0 -0
  93. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/test/test_cluster_manager_efficiency.py +0 -0
  94. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/test/test_cluster_manager_none_strategy.py +0 -0
  95. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/test/test_cluster_manager_oauth.py +0 -0
  96. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/test/test_cluster_manager_strategy.py +0 -0
  97. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/test/test_code_optimizations.py +0 -0
  98. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/test/test_comprehensive.py +0 -0
  99. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/test/test_connection_pool_e2e.py +0 -0
  100. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/test/test_current_implementation.py +0 -0
  101. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/test/test_decimal128_binary_parsing.py +0 -0
  102. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/test/test_decimal128_parsing.py +0 -0
  103. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/test/test_dialect_columns.py +0 -0
  104. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/test/test_fix.py +0 -0
  105. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/test/test_improved_parsing.py +0 -0
  106. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/test/test_known_case.py +0 -0
  107. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/test/test_manual_analysis.py +0 -0
  108. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/test/test_mock_server.py +0 -0
  109. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/test/test_multiprocessing_fix.py +0 -0
  110. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/test/test_new_implementation.py +0 -0
  111. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/test/test_oauth_authentication.py +0 -0
  112. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/test/test_oauth_resume_integration.py +0 -0
  113. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/test/test_pool_concurrency_simple.py +0 -0
  114. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/test/test_pool_threading_multiprocessing.py +0 -0
  115. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/test/test_simple_connection_pool.py +0 -0
  116. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/test/test_specific_binary.py +0 -0
  117. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/test/test_strategy.py +0 -0
  118. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/test/test_strategy_logic.py +0 -0
  119. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/test/test_strategy_persistence_fix.py +0 -0
  120. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/test/test_strategy_sharing_fix.py +0 -0
  121. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/test/test_user_binary_value.py +0 -0
  122. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/test/tests.py +0 -0
  123. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/test/tests_grpc.py +0 -0
  124. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/test/validate_decimal128.py +0 -0
  125. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/test/validate_implementation.py +0 -0
  126. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1}/test/verify_decimal_fix.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: e6data-python-connector
3
- Version: 3.0.0
3
+ Version: 3.0.1
4
4
  Summary: Client for the e6data distributed SQL Engine.
5
5
  Home-page: https://github.com/e6x-labs/e6data-python-connector
6
6
  Author: e6data, Inc.
@@ -469,6 +469,135 @@ database = '<new_database_name>' # Replace with the new database.
469
469
  cursor = conn.cursor(database, catalog_name)
470
470
  ```
471
471
 
472
+ ### Opt in to multi-chunk result batches
473
+
474
+ Set `enable_result_batch_v2=True` to enable V2 batches, continuous result downloading,
475
+ and bounded parallel chunk decoding together. It defaults to `False`. There
476
+ are no separate public prefetch or decoding switches. `arraysize` and
477
+ `fetchmany(size)` only change the number of rows returned to the application.
478
+
479
+ The planner must support V2 and have `ENABLE_GET_NEXT_RESULT_BATCH_V2` enabled.
480
+ `ENABLE_GET_NEXT_CHUNK_V2` controls a separate executor-to-planner boundary.
481
+ These flags do not extend the planner's query lifetime. Finishing a large result
482
+ within 900 seconds still needs a measurement on the target deployment.
483
+
484
+ The optimized path requires ordinary GIL-enabled CPython 3.11 through 3.13 and
485
+ an import-safe application entry point. Put application startup inside
486
+ `if __name__ == "__main__":`, including when using SQLAlchemy. Interactive,
487
+ daemon and frozen applications are rejected before query submission. Two
488
+ workers use the explicit `spawn` method; the application's global multiprocessing
489
+ start method is unchanged. An unguarded main script can run its other import
490
+ side effects, so keep all application startup inside the guard.
491
+
492
+ Both APIs default to unlimited gRPC send and receive message sizes (`-1`).
493
+ Explicit positive byte limits are still honored. For sync, set
494
+ `max_receive_message_length` and `max_send_message_length` in `grpc_options`.
495
+ For async, set the receive limit with `max_receive_message_bytes` or
496
+ `max_receive_message_length` in `grpc_options`; if both are supplied, they must
497
+ agree. Set `max_send_message_length` in `grpc_options` for the send limit.
498
+ The receive limit applies to each message after transport decompression, not the
499
+ total query result. Unlimited receiving does not limit client memory use;
500
+ continuous downloading can retain serialized batches while decoding catches up.
501
+
502
+ These examples use your existing `connection_options`, `sql`, and `consume`:
503
+
504
+ ```python
505
+ from e6data_python_connector import Connection
506
+
507
+
508
+ def main():
509
+ options = {**connection_options, "enable_result_batch_v2": True}
510
+ with Connection(**options) as connection:
511
+ with connection.cursor() as cursor:
512
+ cursor.execute(sql)
513
+ for rows in cursor.fetchall_buffer():
514
+ consume(rows)
515
+
516
+
517
+ if __name__ == "__main__":
518
+ main()
519
+ ```
520
+
521
+ ```python
522
+ import asyncio
523
+ from e6data_python_connector.aio import AsyncConnection
524
+
525
+
526
+ async def main():
527
+ options = {**connection_options, "enable_result_batch_v2": True}
528
+ async with AsyncConnection(**options) as connection:
529
+ async with connection.cursor() as cursor:
530
+ await cursor.execute(sql)
531
+ async for rows in cursor.fetchall_buffer():
532
+ consume(rows)
533
+
534
+
535
+ if __name__ == "__main__":
536
+ asyncio.run(main())
537
+ ```
538
+
539
+ SQLAlchemy accepts the same flag through `connect_args`. Create and use engines
540
+ inside the guarded application entry point:
541
+
542
+ ```python
543
+ connect_args = {**connection_options, "enable_result_batch_v2": True}
544
+ engine = create_engine("e6data://", connect_args=connect_args)
545
+ async_engine = create_async_engine("e6data+asyncio://", connect_args=connect_args)
546
+ ```
547
+
548
+ After the first V2 response, a download thread (sync) or task (async) keeps
549
+ fetching serialized batches until the engine returns end-of-stream. Decoding
550
+ runs concurrently and takes batches from the queue in order. Downloading does
551
+ not wait for decoding or application row processing. There is no queue-size
552
+ limit and no process-wide limit on active downloaders. Result RPCs for the same
553
+ query never overlap.
554
+
555
+ Each background RPC gets a fresh configured transport budget when it starts;
556
+ that deadline never extends after dispatch. An empty nonterminal response uses
557
+ backoff with a finite no-progress deadline. Completed responses can wait while
558
+ the application is idle. Public fetch deadlines still cover waiting and decoding,
559
+ and expiry or cancellation stops the downloader. Async `fetchall` keeps one
560
+ deadline for its entire operation.
561
+
562
+ Two shared worker processes can decode one multi-chunk envelope at a time.
563
+ Other envelopes use the sequential path. Workers receive chunk bytes and column
564
+ positions, not connections or credentials. Process startup and copying add cost,
565
+ so parallel decoding is not a promise of better performance for every result.
566
+
567
+ Fetch return shapes and row order stay the same. `fetchall_buffer()` yields the
568
+ original decoded chunks. All chunks in an envelope must decode successfully
569
+ before any are exposed. The serialized queue can hold the entire result when
570
+ downloading is faster than decoding or application reads. Workers add Python
571
+ objects and process copies; the receive limit is not a limit on total memory.
572
+ `fetchall()` still retains the full result.
573
+
574
+ If a decoder worker exits or its local pipe breaks, the connector stops the
575
+ failed workers and decodes that complete serialized envelope once in the parent
576
+ process. It uses the same deadline and makes no extra result RPC. Continuous
577
+ downloading stays active and uncapped during this recovery. Later envelopes use
578
+ sequential decoding until the decoder runtime is replaced through its normal
579
+ lifecycle. Cancellation, expired deadlines, invalid worker replies, and errors
580
+ reported while decoding data are still failures. Their original cause is kept
581
+ on the first error delivered to the caller.
582
+
583
+ Only a V2 `UNIMPLEMENTED` response switches that query to V1 and disables its
584
+ optimizations. Other RPC failures reach the caller as the original
585
+ `grpc.RpcError` or `grpc.aio.AioRpcError`. A failed pending RPC is raised when
586
+ its envelope is needed, after current valid chunks have been consumed. Failed
587
+ or cancelled queries are not replayed. Later fetches on an incomplete result
588
+ raise `IncompleteResultError`. Clear, cancel, close and pool return retire
589
+ pending work; unsafe connections must not be reused.
590
+
591
+ Use `debug=True` for payload-free RPC, decode and worker diagnostics. Serialized
592
+ Protobuf bytes are uncompressed payload size, not bytes sent over the network.
593
+
594
+ For rollback, close old cursors and connections, then omit
595
+ `enable_result_batch_v2` or set it to `False` on new connections. Closing the
596
+ last V2 connection also stops its shared workers. See the
597
+ [qualification instructions](test/README.md#result-batch-v2-qualification).
598
+ Local synthetic results do not prove customer performance or the 900-second
599
+ query requirement.
600
+
472
601
  ### Get Query Time Metrics
473
602
  ```python
474
603
  import json
@@ -914,11 +1043,27 @@ conn = Connection(
914
1043
  ```
915
1044
 
916
1045
  When `debug=True`, the following features are enabled:
917
- - Python logging at DEBUG level for all operations
1046
+ - Connector logging at DEBUG level, for both sync and async connections. Existing
1047
+ application logging handlers and the root logger level are kept.
918
1048
  - Blue-green strategy transition logging
919
1049
  - Connection lifecycle logging
920
1050
  - Query execution detailed logging
921
1051
 
1052
+ Each result fetch writes a normal debug message with `protocol`, `rpc_seconds`,
1053
+ `decode_seconds`, `chunk_count`, `serialized_bytes`, and `status`. The timing values
1054
+ are seconds. `rpc_seconds` measures the client RPC call, including waiting for its
1055
+ response. `decode_seconds` measures client response processing. `serialized_bytes`
1056
+ is the protobuf response size, not the number of bytes on the network. A failed
1057
+ attempt reports zero response size and chunk count. The same values remain available
1058
+ as `result_batch_*` attributes for structured log handlers.
1059
+
1060
+ These metrics do not add SQL, credentials, session IDs, result values, or raw metadata
1061
+ to logs. `debug=False` keeps the normal quiet default. Applications can also enable
1062
+ DEBUG on the `e6data_python_connector` logger themselves. `debug=True` adds a connector
1063
+ stream handler only when no existing handler in its logger path accepts DEBUG.
1064
+ As with normal Python logging, enabling the package logger also affects other open
1065
+ connector connections in that process.
1066
+
922
1067
  ### gRPC Network Tracing
923
1068
 
924
1069
  For low-level gRPC network debugging (HTTP/2 frames, TCP events), set environment variables **before** running your Python script:
@@ -423,6 +423,135 @@ database = '<new_database_name>' # Replace with the new database.
423
423
  cursor = conn.cursor(database, catalog_name)
424
424
  ```
425
425
 
426
+ ### Opt in to multi-chunk result batches
427
+
428
+ Set `enable_result_batch_v2=True` to enable V2 batches, continuous result downloading,
429
+ and bounded parallel chunk decoding together. It defaults to `False`. There
430
+ are no separate public prefetch or decoding switches. `arraysize` and
431
+ `fetchmany(size)` only change the number of rows returned to the application.
432
+
433
+ The planner must support V2 and have `ENABLE_GET_NEXT_RESULT_BATCH_V2` enabled.
434
+ `ENABLE_GET_NEXT_CHUNK_V2` controls a separate executor-to-planner boundary.
435
+ These flags do not extend the planner's query lifetime. Finishing a large result
436
+ within 900 seconds still needs a measurement on the target deployment.
437
+
438
+ The optimized path requires ordinary GIL-enabled CPython 3.11 through 3.13 and
439
+ an import-safe application entry point. Put application startup inside
440
+ `if __name__ == "__main__":`, including when using SQLAlchemy. Interactive,
441
+ daemon and frozen applications are rejected before query submission. Two
442
+ workers use the explicit `spawn` method; the application's global multiprocessing
443
+ start method is unchanged. An unguarded main script can run its other import
444
+ side effects, so keep all application startup inside the guard.
445
+
446
+ Both APIs default to unlimited gRPC send and receive message sizes (`-1`).
447
+ Explicit positive byte limits are still honored. For sync, set
448
+ `max_receive_message_length` and `max_send_message_length` in `grpc_options`.
449
+ For async, set the receive limit with `max_receive_message_bytes` or
450
+ `max_receive_message_length` in `grpc_options`; if both are supplied, they must
451
+ agree. Set `max_send_message_length` in `grpc_options` for the send limit.
452
+ The receive limit applies to each message after transport decompression, not the
453
+ total query result. Unlimited receiving does not limit client memory use;
454
+ continuous downloading can retain serialized batches while decoding catches up.
455
+
456
+ These examples use your existing `connection_options`, `sql`, and `consume`:
457
+
458
+ ```python
459
+ from e6data_python_connector import Connection
460
+
461
+
462
+ def main():
463
+ options = {**connection_options, "enable_result_batch_v2": True}
464
+ with Connection(**options) as connection:
465
+ with connection.cursor() as cursor:
466
+ cursor.execute(sql)
467
+ for rows in cursor.fetchall_buffer():
468
+ consume(rows)
469
+
470
+
471
+ if __name__ == "__main__":
472
+ main()
473
+ ```
474
+
475
+ ```python
476
+ import asyncio
477
+ from e6data_python_connector.aio import AsyncConnection
478
+
479
+
480
+ async def main():
481
+ options = {**connection_options, "enable_result_batch_v2": True}
482
+ async with AsyncConnection(**options) as connection:
483
+ async with connection.cursor() as cursor:
484
+ await cursor.execute(sql)
485
+ async for rows in cursor.fetchall_buffer():
486
+ consume(rows)
487
+
488
+
489
+ if __name__ == "__main__":
490
+ asyncio.run(main())
491
+ ```
492
+
493
+ SQLAlchemy accepts the same flag through `connect_args`. Create and use engines
494
+ inside the guarded application entry point:
495
+
496
+ ```python
497
+ connect_args = {**connection_options, "enable_result_batch_v2": True}
498
+ engine = create_engine("e6data://", connect_args=connect_args)
499
+ async_engine = create_async_engine("e6data+asyncio://", connect_args=connect_args)
500
+ ```
501
+
502
+ After the first V2 response, a download thread (sync) or task (async) keeps
503
+ fetching serialized batches until the engine returns end-of-stream. Decoding
504
+ runs concurrently and takes batches from the queue in order. Downloading does
505
+ not wait for decoding or application row processing. There is no queue-size
506
+ limit and no process-wide limit on active downloaders. Result RPCs for the same
507
+ query never overlap.
508
+
509
+ Each background RPC gets a fresh configured transport budget when it starts;
510
+ that deadline never extends after dispatch. An empty nonterminal response uses
511
+ backoff with a finite no-progress deadline. Completed responses can wait while
512
+ the application is idle. Public fetch deadlines still cover waiting and decoding,
513
+ and expiry or cancellation stops the downloader. Async `fetchall` keeps one
514
+ deadline for its entire operation.
515
+
516
+ Two shared worker processes can decode one multi-chunk envelope at a time.
517
+ Other envelopes use the sequential path. Workers receive chunk bytes and column
518
+ positions, not connections or credentials. Process startup and copying add cost,
519
+ so parallel decoding is not a promise of better performance for every result.
520
+
521
+ Fetch return shapes and row order stay the same. `fetchall_buffer()` yields the
522
+ original decoded chunks. All chunks in an envelope must decode successfully
523
+ before any are exposed. The serialized queue can hold the entire result when
524
+ downloading is faster than decoding or application reads. Workers add Python
525
+ objects and process copies; the receive limit is not a limit on total memory.
526
+ `fetchall()` still retains the full result.
527
+
528
+ If a decoder worker exits or its local pipe breaks, the connector stops the
529
+ failed workers and decodes that complete serialized envelope once in the parent
530
+ process. It uses the same deadline and makes no extra result RPC. Continuous
531
+ downloading stays active and uncapped during this recovery. Later envelopes use
532
+ sequential decoding until the decoder runtime is replaced through its normal
533
+ lifecycle. Cancellation, expired deadlines, invalid worker replies, and errors
534
+ reported while decoding data are still failures. Their original cause is kept
535
+ on the first error delivered to the caller.
536
+
537
+ Only a V2 `UNIMPLEMENTED` response switches that query to V1 and disables its
538
+ optimizations. Other RPC failures reach the caller as the original
539
+ `grpc.RpcError` or `grpc.aio.AioRpcError`. A failed pending RPC is raised when
540
+ its envelope is needed, after current valid chunks have been consumed. Failed
541
+ or cancelled queries are not replayed. Later fetches on an incomplete result
542
+ raise `IncompleteResultError`. Clear, cancel, close and pool return retire
543
+ pending work; unsafe connections must not be reused.
544
+
545
+ Use `debug=True` for payload-free RPC, decode and worker diagnostics. Serialized
546
+ Protobuf bytes are uncompressed payload size, not bytes sent over the network.
547
+
548
+ For rollback, close old cursors and connections, then omit
549
+ `enable_result_batch_v2` or set it to `False` on new connections. Closing the
550
+ last V2 connection also stops its shared workers. See the
551
+ [qualification instructions](test/README.md#result-batch-v2-qualification).
552
+ Local synthetic results do not prove customer performance or the 900-second
553
+ query requirement.
554
+
426
555
  ### Get Query Time Metrics
427
556
  ```python
428
557
  import json
@@ -868,11 +997,27 @@ conn = Connection(
868
997
  ```
869
998
 
870
999
  When `debug=True`, the following features are enabled:
871
- - Python logging at DEBUG level for all operations
1000
+ - Connector logging at DEBUG level, for both sync and async connections. Existing
1001
+ application logging handlers and the root logger level are kept.
872
1002
  - Blue-green strategy transition logging
873
1003
  - Connection lifecycle logging
874
1004
  - Query execution detailed logging
875
1005
 
1006
+ Each result fetch writes a normal debug message with `protocol`, `rpc_seconds`,
1007
+ `decode_seconds`, `chunk_count`, `serialized_bytes`, and `status`. The timing values
1008
+ are seconds. `rpc_seconds` measures the client RPC call, including waiting for its
1009
+ response. `decode_seconds` measures client response processing. `serialized_bytes`
1010
+ is the protobuf response size, not the number of bytes on the network. A failed
1011
+ attempt reports zero response size and chunk count. The same values remain available
1012
+ as `result_batch_*` attributes for structured log handlers.
1013
+
1014
+ These metrics do not add SQL, credentials, session IDs, result values, or raw metadata
1015
+ to logs. `debug=False` keeps the normal quiet default. Applications can also enable
1016
+ DEBUG on the `e6data_python_connector` logger themselves. `debug=True` adds a connector
1017
+ stream handler only when no existing handler in its logger path accepts DEBUG.
1018
+ As with normal Python logging, enabling the package logger also affects other open
1019
+ connector connections in that process.
1020
+
876
1021
  ### gRPC Network Tracing
877
1022
 
878
1023
  For low-level gRPC network debugging (HTTP/2 frames, TCP events), set environment variables **before** running your Python script:
@@ -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):