e6data-python-connector 3.0.0__tar.gz → 3.0.1rc1__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 (123) hide show
  1. {e6data_python_connector-3.0.0/e6data_python_connector.egg-info → e6data_python_connector-3.0.1rc1}/PKG-INFO +83 -1
  2. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/README.md +82 -0
  3. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/e6data_python_connector/async_connection.py +6 -2
  4. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/e6data_python_connector/async_cursor.py +169 -13
  5. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/e6data_python_connector/async_dialect.py +2 -1
  6. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/e6data_python_connector/dialect.py +7 -2
  7. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/e6data_python_connector/e6data_grpc.py +276 -88
  8. e6data_python_connector-3.0.1rc1/e6data_python_connector/result_batch.py +49 -0
  9. e6data_python_connector-3.0.1rc1/e6data_python_connector/server/e6x_engine_pb2.py +209 -0
  10. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/e6data_python_connector/server/e6x_engine_pb2_grpc.py +43 -0
  11. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1/e6data_python_connector.egg-info}/PKG-INFO +83 -1
  12. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/e6data_python_connector.egg-info/SOURCES.txt +4 -0
  13. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/setup.py +1 -1
  14. e6data_python_connector-3.0.1rc1/test/integration/benchmark_result_batch_v2.py +149 -0
  15. e6data_python_connector-3.0.1rc1/test/integration/result_batch_benchmark_support.py +196 -0
  16. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/integration/support.py +3 -0
  17. e6data_python_connector-3.0.1rc1/test/integration/test_result_batch_v2.py +120 -0
  18. e6data_python_connector-3.0.0/e6data_python_connector/server/e6x_engine_pb2.py +0 -207
  19. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/LICENSE +0 -0
  20. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/MANIFEST.in +0 -0
  21. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/e6data_python_connector/__init__.py +0 -0
  22. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/e6data_python_connector/aio.py +0 -0
  23. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/e6data_python_connector/async_cluster_manager.py +0 -0
  24. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/e6data_python_connector/async_connection_pool.py +0 -0
  25. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/e6data_python_connector/async_oauth.py +0 -0
  26. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/e6data_python_connector/async_work.py +0 -0
  27. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/e6data_python_connector/cluster_manager.py +0 -0
  28. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/e6data_python_connector/cluster_server/__init__.py +0 -0
  29. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/e6data_python_connector/cluster_server/cluster_pb2.py +0 -0
  30. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/e6data_python_connector/cluster_server/cluster_pb2_grpc.py +0 -0
  31. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/e6data_python_connector/common.py +0 -0
  32. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/e6data_python_connector/connection_pool.py +0 -0
  33. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/e6data_python_connector/constants.py +0 -0
  34. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/e6data_python_connector/datainputstream.py +0 -0
  35. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/e6data_python_connector/date_time_utils.py +0 -0
  36. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/e6data_python_connector/e6x_vector/__init__.py +0 -0
  37. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/e6data_python_connector/e6x_vector/constants.py +0 -0
  38. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/e6data_python_connector/e6x_vector/ttypes.py +0 -0
  39. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/e6data_python_connector/exceptions.py +0 -0
  40. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/e6data_python_connector/oauth.py +0 -0
  41. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/e6data_python_connector/oauth_cache.py +0 -0
  42. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/e6data_python_connector/oauth_common.py +0 -0
  43. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/e6data_python_connector/server/QueryEngineService.py +0 -0
  44. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/e6data_python_connector/server/__init__.py +0 -0
  45. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/e6data_python_connector/server/constants.py +0 -0
  46. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/e6data_python_connector/server/e6x_engine_pb2.pyi +0 -0
  47. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/e6data_python_connector/server/ttypes.py +0 -0
  48. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/e6data_python_connector/strategy.py +0 -0
  49. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/e6data_python_connector/typeId.py +0 -0
  50. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/e6data_python_connector.egg-info/dependency_links.txt +0 -0
  51. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/e6data_python_connector.egg-info/entry_points.txt +0 -0
  52. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/e6data_python_connector.egg-info/requires.txt +0 -0
  53. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/e6data_python_connector.egg-info/top_level.txt +0 -0
  54. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/pyproject.toml +0 -0
  55. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/setup.cfg +0 -0
  56. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/__init__.py +0 -0
  57. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/analyze_38_nines.py +0 -0
  58. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/analyze_all_cases.py +0 -0
  59. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/analyze_binary.py +0 -0
  60. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/analyze_correct_value.py +0 -0
  61. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/analyze_fields.py +0 -0
  62. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/check_decimal_errors.py +0 -0
  63. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/cleanup_test_files.py +0 -0
  64. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/conftest.py +0 -0
  65. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/debug_38_nines.py +0 -0
  66. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/debug_binary.py +0 -0
  67. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/final_test.py +0 -0
  68. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/integration/__init__.py +0 -0
  69. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/integration/benchmark_preflight.py +0 -0
  70. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/integration/benchmark_sync_async.py +0 -0
  71. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/integration/expiry_preflight.py +0 -0
  72. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/integration/test_async_connection.py +0 -0
  73. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/integration/test_async_pool.py +0 -0
  74. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/integration/test_async_queries.py +0 -0
  75. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/integration/test_async_resume.py +0 -0
  76. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/integration/test_async_sqlalchemy.py +0 -0
  77. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/integration/test_async_token_lifecycle.py +0 -0
  78. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/integration/test_e2e_auth_boundary.py +0 -0
  79. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/integration/test_e2e_oauth_continuation.py +0 -0
  80. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/integration/test_e2e_readonly_matrix.py +0 -0
  81. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/integration/test_shared_token_issuance.py +0 -0
  82. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/integration/test_sync_oauth_lifecycle.py +0 -0
  83. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/integration/token_observation.py +0 -0
  84. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/move_tests.py +0 -0
  85. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/quick_test.py +0 -0
  86. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/test_38_nines.py +0 -0
  87. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/test_all_decimal128_cases.py +0 -0
  88. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/test_cluster_manager_efficiency.py +0 -0
  89. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/test_cluster_manager_none_strategy.py +0 -0
  90. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/test_cluster_manager_oauth.py +0 -0
  91. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/test_cluster_manager_strategy.py +0 -0
  92. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/test_code_optimizations.py +0 -0
  93. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/test_comprehensive.py +0 -0
  94. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/test_connection_pool.py +0 -0
  95. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/test_connection_pool_e2e.py +0 -0
  96. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/test_current_implementation.py +0 -0
  97. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/test_decimal128_binary_parsing.py +0 -0
  98. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/test_decimal128_parsing.py +0 -0
  99. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/test_dialect_columns.py +0 -0
  100. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/test_fix.py +0 -0
  101. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/test_improved_parsing.py +0 -0
  102. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/test_known_case.py +0 -0
  103. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/test_manual_analysis.py +0 -0
  104. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/test_mock_server.py +0 -0
  105. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/test_multiprocessing_fix.py +0 -0
  106. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/test_new_implementation.py +0 -0
  107. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/test_oauth_authentication.py +0 -0
  108. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/test_oauth_auto_resume.py +0 -0
  109. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/test_oauth_resume_integration.py +0 -0
  110. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/test_pool_concurrency_simple.py +0 -0
  111. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/test_pool_threading_multiprocessing.py +0 -0
  112. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/test_simple_connection_pool.py +0 -0
  113. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/test_specific_binary.py +0 -0
  114. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/test_strategy.py +0 -0
  115. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/test_strategy_logic.py +0 -0
  116. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/test_strategy_persistence_fix.py +0 -0
  117. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/test_strategy_sharing_fix.py +0 -0
  118. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/test_user_binary_value.py +0 -0
  119. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/tests.py +0 -0
  120. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/tests_grpc.py +0 -0
  121. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/validate_decimal128.py +0 -0
  122. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/test/validate_implementation.py +0 -0
  123. {e6data_python_connector-3.0.0 → e6data_python_connector-3.0.1rc1}/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.1rc1
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,88 @@ 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
+ `enable_result_batch_v2=True` lets a compatible planner return several result
475
+ chunks in one `getNextResultBatchV2` response. The default is `False`, which keeps
476
+ the existing V1 protocol. Increasing `arraysize` or `fetchmany(size)` changes how
477
+ many rows your application receives per call; it does not change the planner's
478
+ response size.
479
+
480
+ The planner must support V2 and have `ENABLE_GET_NEXT_RESULT_BATCH_V2` enabled.
481
+ `ENABLE_GET_NEXT_CHUNK_V2` controls the separate executor-to-planner boundary.
482
+ Record both flags when qualifying a deployment. V2 can reduce network round
483
+ trips, but it does not reduce the number of result bytes or extend the planner's
484
+ query lifetime. Completion of a 20-million-row result within 900 seconds must be
485
+ measured against the actual workload and deployment.
486
+
487
+ The following examples use your existing `connection_options`, `sql`, and
488
+ `consume` function. Choose a positive `result_receive_limit_bytes` from measured
489
+ V2 response sizes and the client's memory budget. The sync API preserves its
490
+ existing gRPC options, so supply a finite receive limit for a V2 rollout:
491
+
492
+ ```python
493
+ from e6data_python_connector import Connection
494
+
495
+ sync_options = {
496
+ **connection_options,
497
+ "enable_result_batch_v2": True,
498
+ "grpc_options": {
499
+ **connection_options.get("grpc_options", {}),
500
+ "max_receive_message_length": result_receive_limit_bytes,
501
+ },
502
+ }
503
+ with Connection(**sync_options) as connection:
504
+ with connection.cursor() as cursor:
505
+ cursor.execute(sql)
506
+ for rows in cursor.fetchall_buffer():
507
+ consume(rows)
508
+ ```
509
+
510
+ The async API keeps its finite 64 MiB default. Set
511
+ `max_receive_message_bytes` to change that limit. If `grpc_options` also contains
512
+ a receive limit, it must agree with this value.
513
+
514
+ ```python
515
+ from e6data_python_connector.aio import AsyncConnection
516
+
517
+ async_options = {
518
+ **connection_options,
519
+ "enable_result_batch_v2": True,
520
+ "max_receive_message_bytes": result_receive_limit_bytes,
521
+ }
522
+ async with AsyncConnection(**async_options) as connection:
523
+ async with connection.cursor() as cursor:
524
+ await cursor.execute(sql)
525
+ async for rows in cursor.fetchall_buffer():
526
+ consume(rows)
527
+ ```
528
+
529
+ SQLAlchemy accepts the same opt-in through `connect_args`:
530
+
531
+ ```python
532
+ from sqlalchemy import create_engine
533
+ from sqlalchemy.ext.asyncio import create_async_engine
534
+
535
+ engine = create_engine("e6data://", connect_args=sync_options)
536
+ async_engine = create_async_engine("e6data+asyncio://", connect_args=async_options)
537
+ ```
538
+
539
+ Fetch return shapes and row order stay the same. `fetchall_buffer()` yields
540
+ decoded chunks; a V2 response can contain several chunks. All chunks in an
541
+ envelope are decoded before any are exposed, so V2 may use more memory and delay
542
+ the first chunk compared with V1. `fetchall()` still retains the full result.
543
+ There is no prefetch or parallel fetching for a query.
544
+
545
+ An `UNIMPLEMENTED` response switches that query to V1. A later query may try V2
546
+ again. Other fetch failures do not trigger a protocol fallback or query replay.
547
+ To disable V2 for new connections, omit the option or set it to `False`.
548
+
549
+ The [result batch qualification instructions](test/README.md#result-batch-v2-qualification)
550
+ cover protocol parity and the explicit large-result benchmark. Real-engine
551
+ qualification and the 900-second acceptance measurement have not been run for
552
+ this change.
553
+
472
554
  ### Get Query Time Metrics
473
555
  ```python
474
556
  import json
@@ -423,6 +423,88 @@ 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
+ `enable_result_batch_v2=True` lets a compatible planner return several result
429
+ chunks in one `getNextResultBatchV2` response. The default is `False`, which keeps
430
+ the existing V1 protocol. Increasing `arraysize` or `fetchmany(size)` changes how
431
+ many rows your application receives per call; it does not change the planner's
432
+ response size.
433
+
434
+ The planner must support V2 and have `ENABLE_GET_NEXT_RESULT_BATCH_V2` enabled.
435
+ `ENABLE_GET_NEXT_CHUNK_V2` controls the separate executor-to-planner boundary.
436
+ Record both flags when qualifying a deployment. V2 can reduce network round
437
+ trips, but it does not reduce the number of result bytes or extend the planner's
438
+ query lifetime. Completion of a 20-million-row result within 900 seconds must be
439
+ measured against the actual workload and deployment.
440
+
441
+ The following examples use your existing `connection_options`, `sql`, and
442
+ `consume` function. Choose a positive `result_receive_limit_bytes` from measured
443
+ V2 response sizes and the client's memory budget. The sync API preserves its
444
+ existing gRPC options, so supply a finite receive limit for a V2 rollout:
445
+
446
+ ```python
447
+ from e6data_python_connector import Connection
448
+
449
+ sync_options = {
450
+ **connection_options,
451
+ "enable_result_batch_v2": True,
452
+ "grpc_options": {
453
+ **connection_options.get("grpc_options", {}),
454
+ "max_receive_message_length": result_receive_limit_bytes,
455
+ },
456
+ }
457
+ with Connection(**sync_options) as connection:
458
+ with connection.cursor() as cursor:
459
+ cursor.execute(sql)
460
+ for rows in cursor.fetchall_buffer():
461
+ consume(rows)
462
+ ```
463
+
464
+ The async API keeps its finite 64 MiB default. Set
465
+ `max_receive_message_bytes` to change that limit. If `grpc_options` also contains
466
+ a receive limit, it must agree with this value.
467
+
468
+ ```python
469
+ from e6data_python_connector.aio import AsyncConnection
470
+
471
+ async_options = {
472
+ **connection_options,
473
+ "enable_result_batch_v2": True,
474
+ "max_receive_message_bytes": result_receive_limit_bytes,
475
+ }
476
+ async with AsyncConnection(**async_options) as connection:
477
+ async with connection.cursor() as cursor:
478
+ await cursor.execute(sql)
479
+ async for rows in cursor.fetchall_buffer():
480
+ consume(rows)
481
+ ```
482
+
483
+ SQLAlchemy accepts the same opt-in through `connect_args`:
484
+
485
+ ```python
486
+ from sqlalchemy import create_engine
487
+ from sqlalchemy.ext.asyncio import create_async_engine
488
+
489
+ engine = create_engine("e6data://", connect_args=sync_options)
490
+ async_engine = create_async_engine("e6data+asyncio://", connect_args=async_options)
491
+ ```
492
+
493
+ Fetch return shapes and row order stay the same. `fetchall_buffer()` yields
494
+ decoded chunks; a V2 response can contain several chunks. All chunks in an
495
+ envelope are decoded before any are exposed, so V2 may use more memory and delay
496
+ the first chunk compared with V1. `fetchall()` still retains the full result.
497
+ There is no prefetch or parallel fetching for a query.
498
+
499
+ An `UNIMPLEMENTED` response switches that query to V1. A later query may try V2
500
+ again. Other fetch failures do not trigger a protocol fallback or query replay.
501
+ To disable V2 for new connections, omit the option or set it to `False`.
502
+
503
+ The [result batch qualification instructions](test/README.md#result-batch-v2-qualification)
504
+ cover protocol parity and the explicit large-result benchmark. Real-engine
505
+ qualification and the 900-second acceptance measurement have not been run for
506
+ this change.
507
+
426
508
  ### Get Query Time Metrics
427
509
  ```python
428
510
  import json
@@ -50,7 +50,8 @@ 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):
54
55
  if sys.version_info < (3, 11):
55
56
  raise RuntimeError('The async API requires Python 3.11 or newer.')
56
57
  try:
@@ -78,13 +79,16 @@ class AsyncConnection:
78
79
  raise ValueError('Async OAuth requires verified TLS (secure=True).')
79
80
  if ssl_cert is not None and not isinstance(ssl_cert, (str, bytes)):
80
81
  raise ValueError('ssl_cert must be a certificate path or PEM bytes.')
82
+ if not isinstance(enable_result_batch_v2, bool):
83
+ raise ValueError('enable_result_batch_v2 must be a boolean.')
81
84
  settings = dict(host=host, port=port, username=username, password=password,
82
85
  catalog=catalog, database=database, cluster_name=cluster_name,
83
86
  secure=bool(secure), ssl_cert=ssl_cert, auto_resume=auto_resume,
84
87
  scheme=scheme, debug=debug, require_fastbinary=require_fastbinary,
85
88
  client_id=client_id, client_secret=client_secret, token_url=token_url,
86
89
  oauth_scope=oauth_scope, access_token=access_token,
87
- client_auth_method=client_auth_method)
90
+ client_auth_method=client_auth_method,
91
+ enable_result_batch_v2=enable_result_batch_v2)
88
92
  for name, value in [('operation_timeout', operation_timeout), ('oauth_timeout', oauth_timeout),
89
93
  ('cleanup_timeout', cleanup_timeout), ('auto_resume_timeout', auto_resume_timeout)]:
90
94
  settings[name] = validate_positive_timeout(value, name)
@@ -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,22 @@ 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
+
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ def _log_result_batch(protocol, rpc_seconds, status, response=None, decode_seconds=0.0):
23
+ logger.debug('Result batch fetch completed.', extra={
24
+ 'result_batch_protocol': protocol,
25
+ 'result_batch_rpc_seconds': rpc_seconds,
26
+ 'result_batch_chunk_count': (len(response.resultBatches) if protocol == 'v2'
27
+ else int(bool(response.resultBatch))) if response else 0,
28
+ 'result_batch_serialized_bytes': response.ByteSize() if response else 0,
29
+ 'result_batch_decode_seconds': decode_seconds,
30
+ 'result_batch_status': status,
31
+ })
14
32
 
15
33
 
16
34
  def _metadata_columns(payload):
@@ -46,8 +64,16 @@ class AsyncCursor:
46
64
  self._active_call = None
47
65
  self._close_task = None
48
66
  self._cleanup_error = None
67
+ self._result_batches = ResultBatchBuffer()
68
+ self._reset_result_protocol()
49
69
  connection._cursors.add(self)
50
70
 
71
+ def _reset_result_protocol(self):
72
+ self._result_batch_v2 = self._connection.enable_result_batch_v2
73
+ self._result_protocol = 'v2' if self._result_batch_v2 else 'v1'
74
+ self._result_session_id = None
75
+ self._result_batches.clear()
76
+
51
77
  @property
52
78
  def connection(self):
53
79
  return self._public_connection
@@ -137,6 +163,9 @@ class AsyncCursor:
137
163
  self._failure = IncompleteResultError(reason, query_id=self.query_id)
138
164
  if self._state != 'CLOSED':
139
165
  self._state = 'RESULT_FAILED'
166
+ if self._result_batch_v2:
167
+ self._rows.clear()
168
+ self._result_batches.clear()
140
169
  return self._failure
141
170
 
142
171
  def _accept_rows(self, rows):
@@ -147,10 +176,18 @@ class AsyncCursor:
147
176
  self._rownumber += len(rows)
148
177
  return rows
149
178
 
150
- async def _query_request(self, cls, deadline):
179
+ async def _query_request(self, cls, deadline, *, owned=False):
151
180
  if self._route is None:
152
181
  raise ProgrammingError('No query handle is known.')
153
- session = await self._connection.get_session_id(deadline=deadline)
182
+ if self._result_batch_v2 and not self._connection._oauth_enabled and self._result_session_id:
183
+ session = self._result_session_id
184
+ elif owned:
185
+ # Owned cleanup cannot mint a replacement authentication session.
186
+ session = '' if self._connection._oauth_enabled else self._connection._session_id
187
+ if session is None:
188
+ raise OperationalError('Cleanup has no authenticated legacy session.')
189
+ else:
190
+ session = await self._connection.get_session_id(deadline=deadline)
154
191
  return cls(sessionId=session, queryId=self.query_id, engineIP=self._route.engine_ip)
155
192
 
156
193
  async def _refresh_metadata(self, deadline):
@@ -168,6 +205,7 @@ class AsyncCursor:
168
205
  from .async_connection import QueryRoute
169
206
  if self._state != 'EMPTY':
170
207
  raise ProgrammingError('Clear the previous query before executing another.')
208
+ self._reset_result_protocol()
171
209
  if not isinstance(operation, str) or not operation.strip():
172
210
  raise ProgrammingError('operation must be a nonempty SQL string.')
173
211
  sql = operation.strip().removesuffix(';')
@@ -268,6 +306,8 @@ class AsyncCursor:
268
306
 
269
307
  async def _next_batch(self, deadline):
270
308
  from .async_work import reserve_work
309
+ if self._result_batch_v2:
310
+ return await self._next_v2_batch(deadline)
271
311
  if self._state == 'EXHAUSTED':
272
312
  return None
273
313
  if self._columns is None:
@@ -279,13 +319,23 @@ class AsyncCursor:
279
319
  def mark_dispatch():
280
320
  nonlocal consumed
281
321
  consumed = True
322
+ started = time.monotonic()
323
+ response = None
324
+ decode_seconds = 0.0
282
325
  try:
283
- response = await self._call('getNextResultBatch', request, deadline, on_dispatch=mark_dispatch)
326
+ try:
327
+ response = await self._call('getNextResultBatch', request, deadline, on_dispatch=mark_dispatch)
328
+ finally:
329
+ rpc_seconds = time.monotonic() - started
284
330
  payload = response.resultBatch
285
331
  if not payload:
286
332
  self._state = 'EXHAUSTED'
287
333
  return None
288
- return await self._decode_batch(payload, reservation, revision)
334
+ decode_started = time.monotonic()
335
+ try:
336
+ return await self._decode_batch(payload, reservation, revision)
337
+ finally:
338
+ decode_seconds = time.monotonic() - decode_started
289
339
  except asyncio.CancelledError:
290
340
  if consumed:
291
341
  self._fail_result('ambiguous_result')
@@ -298,6 +348,110 @@ class AsyncCursor:
298
348
  raise
299
349
  finally:
300
350
  reservation.release()
351
+ if consumed:
352
+ status = 'ok' if response is not None and self._failure is None else 'error'
353
+ _log_result_batch('v1', rpc_seconds,
354
+ status, response if status == 'ok' else None, decode_seconds if status == 'ok' else 0.0)
355
+
356
+ def _retain_result_session(self, response, revision):
357
+ self._publish(revision)
358
+ if self._result_batch_v2 and not self._connection._oauth_enabled and response.sessionId:
359
+ self._result_session_id = response.sessionId
360
+
361
+ async def _decode_v2_response(self, response, reservation, revision):
362
+ self._retain_result_session(response, revision)
363
+ try:
364
+ chunks = await reservation.run(decode_result_batches, self._columns, response.resultBatches)
365
+ except Exception as error:
366
+ raise self._fail_result('decode_failed') from error
367
+ self._publish(revision)
368
+ self._result_batches.accept(chunks, response.endOfStream)
369
+
370
+ async def _next_v2_batch(self, deadline):
371
+ from .async_work import reserve_work
372
+ batch = self._result_batches.pop()
373
+ if batch is not None:
374
+ return batch
375
+ if self._result_batches.finished or self._state == 'EXHAUSTED':
376
+ self._state = 'EXHAUSTED'
377
+ return None
378
+ if self._columns is None:
379
+ await self._refresh_metadata(deadline)
380
+ consumed = False
381
+ backoff = .01
382
+ try:
383
+ while True:
384
+ request = await self._query_request(pb.GetNextResultBatchRequest, deadline)
385
+ reservation = await reserve_work(deadline=deadline)
386
+ revision = self._revision
387
+ protocol = self._result_protocol
388
+ previously_consumed = consumed
389
+ dispatched = False
390
+ response = None
391
+ status = 'error'
392
+ rpc_seconds = decode_seconds = 0.0
393
+ started = time.monotonic()
394
+
395
+ def mark_dispatch():
396
+ nonlocal consumed, dispatched
397
+ consumed = dispatched = True
398
+
399
+ try:
400
+ try:
401
+ response = await self._call(
402
+ 'getNextResultBatchV2' if protocol == 'v2' else 'getNextResultBatch',
403
+ request, deadline, on_dispatch=mark_dispatch)
404
+ except grpc.RpcError as error:
405
+ if protocol == 'v2' and error.code() == grpc.StatusCode.UNIMPLEMENTED:
406
+ consumed = previously_consumed
407
+ self._result_protocol = 'v1'
408
+ status = 'unimplemented'
409
+ logger.debug('Result batch compatibility fallback.',
410
+ extra={'result_batch_fallback': True})
411
+ continue
412
+ raise
413
+ finally:
414
+ rpc_seconds = time.monotonic() - started
415
+ decode_started = time.monotonic()
416
+ try:
417
+ if protocol == 'v2':
418
+ await self._decode_v2_response(response, reservation, revision)
419
+ else:
420
+ self._retain_result_session(response, revision)
421
+ if response.resultBatch:
422
+ batch = await self._decode_batch(response.resultBatch, reservation, revision)
423
+ else:
424
+ self._state = 'EXHAUSTED'
425
+ batch = None
426
+ finally:
427
+ decode_seconds = time.monotonic() - decode_started
428
+ status = 'ok'
429
+ if protocol == 'v1':
430
+ return batch
431
+ batch = self._result_batches.pop()
432
+ if batch is not None:
433
+ return batch
434
+ if self._result_batches.finished:
435
+ self._state = 'EXHAUSTED'
436
+ return None
437
+ finally:
438
+ reservation.release()
439
+ if dispatched:
440
+ _log_result_batch(protocol, rpc_seconds, status,
441
+ response if status == 'ok' else None,
442
+ decode_seconds if status == 'ok' else 0.0)
443
+ await asyncio.sleep(min(backoff, self._connection._remaining(deadline)))
444
+ backoff = min(backoff * 2, .1)
445
+ except asyncio.CancelledError:
446
+ if consumed:
447
+ self._fail_result('ambiguous_result')
448
+ raise
449
+ except IncompleteResultError:
450
+ raise
451
+ except Exception as error:
452
+ if consumed:
453
+ raise self._fail_result('ambiguous_result') from error
454
+ raise
301
455
 
302
456
  async def _fetchmany(self, size, deadline):
303
457
  while len(self._rows) < size and self._state != 'EXHAUSTED':
@@ -451,27 +605,25 @@ class AsyncCursor:
451
605
  if self._route is None:
452
606
  if self._state == 'SUBMISSION_UNKNOWN' or self in self._connection._ambiguous_submissions:
453
607
  raise AmbiguousSubmissionError()
608
+ self._reset_query_state()
454
609
  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)
610
+ request = await self._query_request(pb.ClearOrCancelQueryRequest, deadline, owned=owned)
463
611
  response = await self._connection._rpc('clearOrCancelQuery', request, deadline=deadline,
464
612
  route=self._route, _cleanup=owned)
465
613
  self._connection._release_route(self.query_id)
466
614
  self._connection._ambiguous_submissions.discard(self)
615
+ self._reset_query_state()
616
+ return response
617
+
618
+ def _reset_query_state(self):
467
619
  self._route = None
468
620
  self._rows.clear()
621
+ self._reset_result_protocol()
469
622
  self._columns = self._description = None
470
623
  self._rowcount, self._rownumber = -1, 0
471
624
  self._failure = self._cleanup_error = None
472
625
  if self._state != 'CLOSED':
473
626
  self._state = 'EMPTY'
474
- return response
475
627
 
476
628
  async def clear(self, query_id=None, *, timeout=None):
477
629
  self._check()
@@ -496,6 +648,8 @@ class AsyncCursor:
496
648
  try:
497
649
  async with asyncio.timeout_at(deadline):
498
650
  await self._stop_active()
651
+ if self._result_batch_v2:
652
+ self._fail_result('cancelled_result')
499
653
  request = await self._query_request(pb.CancelQueryRequest, deadline)
500
654
  await self._connection._rpc('cancelQuery', request, deadline=deadline, route=self._route)
501
655
  self._fail_result('cancelled_result')
@@ -524,6 +678,8 @@ class AsyncCursor:
524
678
  self._cleanup_error = OperationalError('Query cleanup is unconfirmed.')
525
679
  finally:
526
680
  self._rows.clear()
681
+ self._result_batches.clear()
682
+ self._result_session_id = None
527
683
  self._connection._cursors.discard(self)
528
684
 
529
685
  async def close(self):
@@ -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:
@@ -248,6 +248,9 @@ class E6dataDialect(default.DefaultDialect):
248
248
  self.secure = url.query.get("secure") == "true"
249
249
  self.auto_resume = url.query.get("auto-resume", "true") == "true" # default to True
250
250
  self.debug = url.query.get("debug", "false") == "true" # default to True
251
+ result_batch_v2 = url.query.get('enable_result_batch_v2', 'false')
252
+ if result_batch_v2 not in ('true', 'false'):
253
+ raise ValueError('enable_result_batch_v2 must be true or false.')
251
254
  if not self.catalog_name:
252
255
  raise Exception('Please specify catalog in query parameter.')
253
256
 
@@ -255,7 +258,7 @@ class E6dataDialect(default.DefaultDialect):
255
258
  grpc_options = {}
256
259
  for key, value in url.query.items():
257
260
  # Skip known parameters that are not grpc_options
258
- if key not in {"schema", "catalog", "cluster-uuid", "secure", "auto-resume"}:
261
+ if key not in {"schema", "catalog", "cluster-uuid", "secure", "auto-resume", "enable_result_batch_v2"}:
259
262
  grpc_options[key] = value
260
263
 
261
264
  kwargs = {
@@ -270,8 +273,10 @@ class E6dataDialect(default.DefaultDialect):
270
273
  'secure': self.secure,
271
274
  'auto_resume': self.auto_resume,
272
275
  'grpc_options': grpc_options,
273
- 'debug': self.debug
276
+ 'debug': self.debug,
274
277
  }
278
+ if 'enable_result_batch_v2' in url.query:
279
+ kwargs['enable_result_batch_v2'] = result_batch_v2 == 'true'
275
280
  return [], kwargs
276
281
 
277
282
  def get_schema_names(self, connection, **kw):