supertable 2.2.7__tar.gz → 2.2.8__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 (182) hide show
  1. {supertable-2.2.7/supertable.egg-info → supertable-2.2.8}/PKG-INFO +1 -1
  2. {supertable-2.2.7 → supertable-2.2.8}/pyproject.toml +1 -1
  3. {supertable-2.2.7 → supertable-2.2.8}/setup.py +1 -1
  4. {supertable-2.2.7 → supertable-2.2.8}/supertable/__init__.py +1 -1
  5. {supertable-2.2.7 → supertable-2.2.8}/supertable/config/settings.py +2 -2
  6. {supertable-2.2.7 → supertable-2.2.8}/supertable/engine/engine_common.py +330 -25
  7. {supertable-2.2.7 → supertable-2.2.8}/supertable/engine/engine_config.py +2 -2
  8. {supertable-2.2.7 → supertable-2.2.8}/supertable/engine/executor.py +50 -48
  9. supertable-2.2.8/supertable/engine/tests/test_engine_routing.py +192 -0
  10. {supertable-2.2.7 → supertable-2.2.8}/supertable/redis_catalog.py +16 -16
  11. {supertable-2.2.7 → supertable-2.2.8/supertable.egg-info}/PKG-INFO +1 -1
  12. supertable-2.2.7/supertable/engine/tests/test_engine_routing.py +0 -110
  13. {supertable-2.2.7 → supertable-2.2.8}/LICENSE +0 -0
  14. {supertable-2.2.7 → supertable-2.2.8}/README.md +0 -0
  15. {supertable-2.2.7 → supertable-2.2.8}/requirements.txt +0 -0
  16. {supertable-2.2.7 → supertable-2.2.8}/setup.cfg +0 -0
  17. {supertable-2.2.7 → supertable-2.2.8}/supertable/audit/__init__.py +0 -0
  18. {supertable-2.2.7 → supertable-2.2.8}/supertable/audit/admin.py +0 -0
  19. {supertable-2.2.7 → supertable-2.2.8}/supertable/audit/chain.py +0 -0
  20. {supertable-2.2.7 → supertable-2.2.8}/supertable/audit/consumers.py +0 -0
  21. {supertable-2.2.7 → supertable-2.2.8}/supertable/audit/crypto.py +0 -0
  22. {supertable-2.2.7 → supertable-2.2.8}/supertable/audit/events.py +0 -0
  23. {supertable-2.2.7 → supertable-2.2.8}/supertable/audit/export.py +0 -0
  24. {supertable-2.2.7 → supertable-2.2.8}/supertable/audit/logger.py +0 -0
  25. {supertable-2.2.7 → supertable-2.2.8}/supertable/audit/middleware.py +0 -0
  26. {supertable-2.2.7 → supertable-2.2.8}/supertable/audit/reader.py +0 -0
  27. {supertable-2.2.7 → supertable-2.2.8}/supertable/audit/retention.py +0 -0
  28. {supertable-2.2.7 → supertable-2.2.8}/supertable/audit/tests/__init__.py +0 -0
  29. {supertable-2.2.7 → supertable-2.2.8}/supertable/audit/tests/test_chain.py +0 -0
  30. {supertable-2.2.7 → supertable-2.2.8}/supertable/audit/tests/test_crypto.py +0 -0
  31. {supertable-2.2.7 → supertable-2.2.8}/supertable/audit/tests/test_emit.py +0 -0
  32. {supertable-2.2.7 → supertable-2.2.8}/supertable/audit/tests/test_events.py +0 -0
  33. {supertable-2.2.7 → supertable-2.2.8}/supertable/audit/tests/test_retention.py +0 -0
  34. {supertable-2.2.7 → supertable-2.2.8}/supertable/audit/writer_parquet.py +0 -0
  35. {supertable-2.2.7 → supertable-2.2.8}/supertable/audit/writer_redis.py +0 -0
  36. {supertable-2.2.7 → supertable-2.2.8}/supertable/config/__init__.py +0 -0
  37. {supertable-2.2.7 → supertable-2.2.8}/supertable/config/defaults.py +0 -0
  38. {supertable-2.2.7 → supertable-2.2.8}/supertable/config/homedir.py +0 -0
  39. {supertable-2.2.7 → supertable-2.2.8}/supertable/config/tests/__init__.py +0 -0
  40. {supertable-2.2.7 → supertable-2.2.8}/supertable/config/tests/test_defaults.py +0 -0
  41. {supertable-2.2.7 → supertable-2.2.8}/supertable/config/tests/test_homedir.py +0 -0
  42. {supertable-2.2.7 → supertable-2.2.8}/supertable/config/tests/test_settings.py +0 -0
  43. {supertable-2.2.7 → supertable-2.2.8}/supertable/data_classes.py +0 -0
  44. {supertable-2.2.7 → supertable-2.2.8}/supertable/data_reader.py +0 -0
  45. {supertable-2.2.7 → supertable-2.2.8}/supertable/data_writer.py +0 -0
  46. {supertable-2.2.7 → supertable-2.2.8}/supertable/demo/__init__.py +0 -0
  47. {supertable-2.2.7 → supertable-2.2.8}/supertable/demo/quickstart/__init__.py +0 -0
  48. {supertable-2.2.7 → supertable-2.2.8}/supertable/demo/quickstart/__main__.py +0 -0
  49. {supertable-2.2.7 → supertable-2.2.8}/supertable/demo/quickstart/check_filter_builder.py +0 -0
  50. {supertable-2.2.7 → supertable-2.2.8}/supertable/demo/quickstart/controller.py +0 -0
  51. {supertable-2.2.7 → supertable-2.2.8}/supertable/demo/quickstart/data_writer_helpers.py +0 -0
  52. {supertable-2.2.7 → supertable-2.2.8}/supertable/demo/quickstart/defaults.py +0 -0
  53. {supertable-2.2.7 → supertable-2.2.8}/supertable/demo/quickstart/dummy_data.py +0 -0
  54. {supertable-2.2.7 → supertable-2.2.8}/supertable/demo/quickstart/read_parquet_header.py +0 -0
  55. {supertable-2.2.7 → supertable-2.2.8}/supertable/demo/quickstart/s01_01_01_create_super_table.py +0 -0
  56. {supertable-2.2.7 → supertable-2.2.8}/supertable/demo/quickstart/s01_01_02_enable_mirroring_formats.py +0 -0
  57. {supertable-2.2.7 → supertable-2.2.8}/supertable/demo/quickstart/s01_02_create_roles.py +0 -0
  58. {supertable-2.2.7 → supertable-2.2.8}/supertable/demo/quickstart/s01_03_create_users.py +0 -0
  59. {supertable-2.2.7 → supertable-2.2.8}/supertable/demo/quickstart/s02_01_write_dummy_data.py +0 -0
  60. {supertable-2.2.7 → supertable-2.2.8}/supertable/demo/quickstart/s02_02_write_single_data.py +0 -0
  61. {supertable-2.2.7 → supertable-2.2.8}/supertable/demo/quickstart/s02_03_01_write_staging.py +0 -0
  62. {supertable-2.2.7 → supertable-2.2.8}/supertable/demo/quickstart/s02_03_02_create_pipe.py +0 -0
  63. {supertable-2.2.7 → supertable-2.2.8}/supertable/demo/quickstart/s02_04_01_write_monitoring_simple.py +0 -0
  64. {supertable-2.2.7 → supertable-2.2.8}/supertable/demo/quickstart/s02_04_02_write_monitoring_parallel.py +0 -0
  65. {supertable-2.2.7 → supertable-2.2.8}/supertable/demo/quickstart/s02_05_write_tombstone.py +0 -0
  66. {supertable-2.2.7 → supertable-2.2.8}/supertable/demo/quickstart/s03_01_read_data_error.py +0 -0
  67. {supertable-2.2.7 → supertable-2.2.8}/supertable/demo/quickstart/s03_02_01_read_super_data_ok.py +0 -0
  68. {supertable-2.2.7 → supertable-2.2.8}/supertable/demo/quickstart/s03_02_02_read_table_data_ok.py +0 -0
  69. {supertable-2.2.7 → supertable-2.2.8}/supertable/demo/quickstart/s03_03_read_meta.py +0 -0
  70. {supertable-2.2.7 → supertable-2.2.8}/supertable/demo/quickstart/s03_04_read_staging.py +0 -0
  71. {supertable-2.2.7 → supertable-2.2.8}/supertable/demo/quickstart/s03_06_01_read_roles.py +0 -0
  72. {supertable-2.2.7 → supertable-2.2.8}/supertable/demo/quickstart/s03_06_02_read_user.py +0 -0
  73. {supertable-2.2.7 → supertable-2.2.8}/supertable/demo/quickstart/s03_07_01_estimate_read.py +0 -0
  74. {supertable-2.2.7 → supertable-2.2.8}/supertable/demo/quickstart/s03_07_02_estimate_files.py +0 -0
  75. {supertable-2.2.7 → supertable-2.2.8}/supertable/demo/quickstart/s03_08_read_snapshot_history.py +0 -0
  76. {supertable-2.2.7 → supertable-2.2.8}/supertable/demo/quickstart/s04_01_03_delete_pipe.py +0 -0
  77. {supertable-2.2.7 → supertable-2.2.8}/supertable/demo/quickstart/s05_01_delete_table.py +0 -0
  78. {supertable-2.2.7 → supertable-2.2.8}/supertable/demo/quickstart/s05_02_delete_super_table.py +0 -0
  79. {supertable-2.2.7 → supertable-2.2.8}/supertable/demo/webshop/__init__.py +0 -0
  80. {supertable-2.2.7 → supertable-2.2.8}/supertable/demo/webshop/core.py +0 -0
  81. {supertable-2.2.7 → supertable-2.2.8}/supertable/demo/webshop/defaults.py +0 -0
  82. {supertable-2.2.7 → supertable-2.2.8}/supertable/demo/webshop/generate.py +0 -0
  83. {supertable-2.2.7 → supertable-2.2.8}/supertable/demo/webshop/load.py +0 -0
  84. {supertable-2.2.7 → supertable-2.2.8}/supertable/demo/webshop/topup.py +0 -0
  85. {supertable-2.2.7 → supertable-2.2.8}/supertable/engine/__init__.py +0 -0
  86. {supertable-2.2.7 → supertable-2.2.8}/supertable/engine/data_estimator.py +0 -0
  87. {supertable-2.2.7 → supertable-2.2.8}/supertable/engine/duckdb_lite.py +0 -0
  88. {supertable-2.2.7 → supertable-2.2.8}/supertable/engine/duckdb_pro.py +0 -0
  89. {supertable-2.2.7 → supertable-2.2.8}/supertable/engine/engine_enum.py +0 -0
  90. {supertable-2.2.7 → supertable-2.2.8}/supertable/engine/plan_stats.py +0 -0
  91. {supertable-2.2.7 → supertable-2.2.8}/supertable/engine/spark_thrift.py +0 -0
  92. {supertable-2.2.7 → supertable-2.2.8}/supertable/engine/tests/__init__.py +0 -0
  93. {supertable-2.2.7 → supertable-2.2.8}/supertable/engine/tests/conftest.py +0 -0
  94. {supertable-2.2.7 → supertable-2.2.8}/supertable/engine/tests/test_dedup_read.py +0 -0
  95. {supertable-2.2.7 → supertable-2.2.8}/supertable/engine/tests/test_engine.py +0 -0
  96. {supertable-2.2.7 → supertable-2.2.8}/supertable/engine/tests/test_engine_config.py +0 -0
  97. {supertable-2.2.7 → supertable-2.2.8}/supertable/engine/tests/test_engine_spill.py +0 -0
  98. {supertable-2.2.7 → supertable-2.2.8}/supertable/errors.py +0 -0
  99. {supertable-2.2.7 → supertable-2.2.8}/supertable/locking/__init__.py +0 -0
  100. {supertable-2.2.7 → supertable-2.2.8}/supertable/locking/benchmarks/__init__.py +0 -0
  101. {supertable-2.2.7 → supertable-2.2.8}/supertable/locking/benchmarks/benchmark_locking.py +0 -0
  102. {supertable-2.2.7 → supertable-2.2.8}/supertable/locking/benchmarks/measure_lock_speed.py +0 -0
  103. {supertable-2.2.7 → supertable-2.2.8}/supertable/locking/benchmarks/measure_lock_time.py +0 -0
  104. {supertable-2.2.7 → supertable-2.2.8}/supertable/locking/file_lock.py +0 -0
  105. {supertable-2.2.7 → supertable-2.2.8}/supertable/locking/redis_lock.py +0 -0
  106. {supertable-2.2.7 → supertable-2.2.8}/supertable/locking/tests/__init__.py +0 -0
  107. {supertable-2.2.7 → supertable-2.2.8}/supertable/locking/tests/test_file_lock.py +0 -0
  108. {supertable-2.2.7 → supertable-2.2.8}/supertable/locking/tests/test_redis_lock.py +0 -0
  109. {supertable-2.2.7 → supertable-2.2.8}/supertable/logging.py +0 -0
  110. {supertable-2.2.7 → supertable-2.2.8}/supertable/meta_reader.py +0 -0
  111. {supertable-2.2.7 → supertable-2.2.8}/supertable/mirroring/__init__.py +0 -0
  112. {supertable-2.2.7 → supertable-2.2.8}/supertable/mirroring/mirror_delta.py +0 -0
  113. {supertable-2.2.7 → supertable-2.2.8}/supertable/mirroring/mirror_formats.py +0 -0
  114. {supertable-2.2.7 → supertable-2.2.8}/supertable/mirroring/mirror_iceberg.py +0 -0
  115. {supertable-2.2.7 → supertable-2.2.8}/supertable/mirroring/mirror_parquet.py +0 -0
  116. {supertable-2.2.7 → supertable-2.2.8}/supertable/monitoring/__init__.py +0 -0
  117. {supertable-2.2.7 → supertable-2.2.8}/supertable/monitoring/partitions.py +0 -0
  118. {supertable-2.2.7 → supertable-2.2.8}/supertable/monitoring_writer.py +0 -0
  119. {supertable-2.2.7 → supertable-2.2.8}/supertable/plan_extender.py +0 -0
  120. {supertable-2.2.7 → supertable-2.2.8}/supertable/processing.py +0 -0
  121. {supertable-2.2.7 → supertable-2.2.8}/supertable/query_plan_manager.py +0 -0
  122. {supertable-2.2.7 → supertable-2.2.8}/supertable/rbac/__init__.py +0 -0
  123. {supertable-2.2.7 → supertable-2.2.8}/supertable/rbac/access_control.py +0 -0
  124. {supertable-2.2.7 → supertable-2.2.8}/supertable/rbac/filter_builder.py +0 -0
  125. {supertable-2.2.7 → supertable-2.2.8}/supertable/rbac/permissions.py +0 -0
  126. {supertable-2.2.7 → supertable-2.2.8}/supertable/rbac/role_manager.py +0 -0
  127. {supertable-2.2.7 → supertable-2.2.8}/supertable/rbac/row_column_security.py +0 -0
  128. {supertable-2.2.7 → supertable-2.2.8}/supertable/rbac/tests/test_filter_builder.py +0 -0
  129. {supertable-2.2.7 → supertable-2.2.8}/supertable/rbac/tests/test_rbac.py +0 -0
  130. {supertable-2.2.7 → supertable-2.2.8}/supertable/rbac/tests/test_rbac_per_table.py +0 -0
  131. {supertable-2.2.7 → supertable-2.2.8}/supertable/rbac/user_manager.py +0 -0
  132. {supertable-2.2.7 → supertable-2.2.8}/supertable/redis_connector.py +0 -0
  133. {supertable-2.2.7 → supertable-2.2.8}/supertable/redis_infra.py +0 -0
  134. {supertable-2.2.7 → supertable-2.2.8}/supertable/redis_keys.py +0 -0
  135. {supertable-2.2.7 → supertable-2.2.8}/supertable/simple_table.py +0 -0
  136. {supertable-2.2.7 → supertable-2.2.8}/supertable/staging_area.py +0 -0
  137. {supertable-2.2.7 → supertable-2.2.8}/supertable/storage/__init__.py +0 -0
  138. {supertable-2.2.7 → supertable-2.2.8}/supertable/storage/azure_storage.py +0 -0
  139. {supertable-2.2.7 → supertable-2.2.8}/supertable/storage/gcp_storage.py +0 -0
  140. {supertable-2.2.7 → supertable-2.2.8}/supertable/storage/local_storage.py +0 -0
  141. {supertable-2.2.7 → supertable-2.2.8}/supertable/storage/minio_storage.py +0 -0
  142. {supertable-2.2.7 → supertable-2.2.8}/supertable/storage/s3_storage.py +0 -0
  143. {supertable-2.2.7 → supertable-2.2.8}/supertable/storage/storage_factory.py +0 -0
  144. {supertable-2.2.7 → supertable-2.2.8}/supertable/storage/storage_interface.py +0 -0
  145. {supertable-2.2.7 → supertable-2.2.8}/supertable/storage/tests/test_storage.py +0 -0
  146. {supertable-2.2.7 → supertable-2.2.8}/supertable/super_pipe.py +0 -0
  147. {supertable-2.2.7 → supertable-2.2.8}/supertable/super_table.py +0 -0
  148. {supertable-2.2.7 → supertable-2.2.8}/supertable/tests/__init__.py +0 -0
  149. {supertable-2.2.7 → supertable-2.2.8}/supertable/tests/test_align_to_schema_fix.py +0 -0
  150. {supertable-2.2.7 → supertable-2.2.8}/supertable/tests/test_create_if_missing.py +0 -0
  151. {supertable-2.2.7 → supertable-2.2.8}/supertable/tests/test_data_reader.py +0 -0
  152. {supertable-2.2.7 → supertable-2.2.8}/supertable/tests/test_data_reader_preflight.py +0 -0
  153. {supertable-2.2.7 → supertable-2.2.8}/supertable/tests/test_data_writer.py +0 -0
  154. {supertable-2.2.7 → supertable-2.2.8}/supertable/tests/test_data_writer_compact.py +0 -0
  155. {supertable-2.2.7 → supertable-2.2.8}/supertable/tests/test_data_writer_comprehensive.py +0 -0
  156. {supertable-2.2.7 → supertable-2.2.8}/supertable/tests/test_data_writer_tombstones.py +0 -0
  157. {supertable-2.2.7 → supertable-2.2.8}/supertable/tests/test_dedup_on_read_write.py +0 -0
  158. {supertable-2.2.7 → supertable-2.2.8}/supertable/tests/test_errors.py +0 -0
  159. {supertable-2.2.7 → supertable-2.2.8}/supertable/tests/test_meta_reader.py +0 -0
  160. {supertable-2.2.7 → supertable-2.2.8}/supertable/tests/test_monitoring_partitions.py +0 -0
  161. {supertable-2.2.7 → supertable-2.2.8}/supertable/tests/test_monitoring_sink_guard.py +0 -0
  162. {supertable-2.2.7 → supertable-2.2.8}/supertable/tests/test_newer_than.py +0 -0
  163. {supertable-2.2.7 → supertable-2.2.8}/supertable/tests/test_process_delete_only.py +0 -0
  164. {supertable-2.2.7 → supertable-2.2.8}/supertable/tests/test_processing.py +0 -0
  165. {supertable-2.2.7 → supertable-2.2.8}/supertable/tests/test_processing_compact_resources.py +0 -0
  166. {supertable-2.2.7 → supertable-2.2.8}/supertable/tests/test_query_sql.py +0 -0
  167. {supertable-2.2.7 → supertable-2.2.8}/supertable/tests/test_redis_key_prefix.py +0 -0
  168. {supertable-2.2.7 → supertable-2.2.8}/supertable/tests/test_simple_table.py +0 -0
  169. {supertable-2.2.7 → supertable-2.2.8}/supertable/tests/test_small_file_compaction.py +0 -0
  170. {supertable-2.2.7 → supertable-2.2.8}/supertable/tests/test_super_table.py +0 -0
  171. {supertable-2.2.7 → supertable-2.2.8}/supertable/tests/test_supertable_all.py +0 -0
  172. {supertable-2.2.7 → supertable-2.2.8}/supertable/utils/__init__.py +0 -0
  173. {supertable-2.2.7 → supertable-2.2.8}/supertable/utils/helper.py +0 -0
  174. {supertable-2.2.7 → supertable-2.2.8}/supertable/utils/profiler.py +0 -0
  175. {supertable-2.2.7 → supertable-2.2.8}/supertable/utils/sql_parser.py +0 -0
  176. {supertable-2.2.7 → supertable-2.2.8}/supertable/utils/tests/test_sql_parser_columns.py +0 -0
  177. {supertable-2.2.7 → supertable-2.2.8}/supertable/utils/timer.py +0 -0
  178. {supertable-2.2.7 → supertable-2.2.8}/supertable.egg-info/SOURCES.txt +0 -0
  179. {supertable-2.2.7 → supertable-2.2.8}/supertable.egg-info/dependency_links.txt +0 -0
  180. {supertable-2.2.7 → supertable-2.2.8}/supertable.egg-info/entry_points.txt +0 -0
  181. {supertable-2.2.7 → supertable-2.2.8}/supertable.egg-info/requires.txt +0 -0
  182. {supertable-2.2.7 → supertable-2.2.8}/supertable.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: supertable
3
- Version: 2.2.7
3
+ Version: 2.2.8
4
4
  Summary: SuperTable — versioned data lake library for SQL analytics on Parquet + Redis.
5
5
  Author: Levente Kupas
6
6
  Author-email: Levente Kupas <lkupas@kladnasoft.com>
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "supertable"
7
- version = "2.2.7"
7
+ version = "2.2.8"
8
8
  description = "SuperTable — versioned data lake library for SQL analytics on Parquet + Redis."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -19,7 +19,7 @@ long_description = readme.read_text(encoding="utf-8") if readme.exists() else ""
19
19
 
20
20
  setup(
21
21
  name="supertable",
22
- version="2.2.7",
22
+ version="2.2.8",
23
23
  description="SuperTable — versioned data lake library for SQL analytics on Parquet + Redis.",
24
24
  long_description=long_description,
25
25
  long_description_content_type="text/markdown",
@@ -25,7 +25,7 @@ See the ``supertable.demo`` package for runnable end-to-end demos and the
25
25
  project documentation for the full API surface.
26
26
  """
27
27
 
28
- __version__ = "2.2.7"
28
+ __version__ = "2.2.8"
29
29
 
30
30
  # Re-export the core public surface so users can do ``from supertable import …``
31
31
  # instead of remembering submodule paths.
@@ -160,7 +160,7 @@ class Settings:
160
160
 
161
161
  # ── Engine Routing / Executor ────────────────────────────────────
162
162
  SUPERTABLE_ENGINE_LITE_MAX_BYTES: int = 100 * 1024 * 1024 # SUPERTABLE_ENGINE_LITE_MAX_BYTES (100 MB)
163
- SUPERTABLE_ENGINE_SPARK_MIN_BYTES: int = 10 * 1024 * 1024 * 1024 # SUPERTABLE_ENGINE_SPARK_MIN_BYTES (10 GB)
163
+ SUPERTABLE_ENGINE_SPARK_MIN_BYTES: int = 0 # fallback only; 0 = active fleet's min_bytes drives Spark routing
164
164
  SUPERTABLE_ENGINE_FRESHNESS_SEC: int = 300 # SUPERTABLE_ENGINE_FRESHNESS_SEC
165
165
  SUPERTABLE_DEFAULT_ENGINE: str = "AUTO" # SUPERTABLE_DEFAULT_ENGINE
166
166
 
@@ -410,7 +410,7 @@ def _build_settings() -> Settings:
410
410
 
411
411
  # ── Engine Routing ───────────────────────────────────────────
412
412
  SUPERTABLE_ENGINE_LITE_MAX_BYTES=_env_int("SUPERTABLE_ENGINE_LITE_MAX_BYTES", 100 * 1024 * 1024),
413
- SUPERTABLE_ENGINE_SPARK_MIN_BYTES=_env_int("SUPERTABLE_ENGINE_SPARK_MIN_BYTES", 10 * 1024 * 1024 * 1024),
413
+ SUPERTABLE_ENGINE_SPARK_MIN_BYTES=_env_int("SUPERTABLE_ENGINE_SPARK_MIN_BYTES", 0),
414
414
  SUPERTABLE_ENGINE_FRESHNESS_SEC=_env_int("SUPERTABLE_ENGINE_FRESHNESS_SEC", 300),
415
415
  SUPERTABLE_DEFAULT_ENGINE=_env_str("SUPERTABLE_DEFAULT_ENGINE", "AUTO"),
416
416
 
@@ -4,7 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import hashlib
6
6
  import os
7
- from typing import Dict, List, Optional
7
+ from typing import Any, Dict, List, Optional
8
8
  from urllib.parse import urlparse
9
9
 
10
10
  import duckdb
@@ -282,11 +282,15 @@ def configure_httpfs_and_s3(
282
282
  "true" if meta_cache_on else "false",
283
283
  )
284
284
 
285
- # External file cache (DuckDB >= 1.3) caches remote data blocks on local
286
- # disk so repeated queries do not re-download the same row groups.
287
- # Only enabled when SUPERTABLE_DUCKDB_EXTERNAL_CACHE_SIZE is set.
285
+ # External file cache an in-memory cache of remote data blocks so
286
+ # repeated queries do not re-download the same row groups. It is only
287
+ # enabled when a size cap can be enforced: DuckDB builds without
288
+ # external_file_cache_max_size (e.g. 1.5.x) cannot bound it, and an
289
+ # uncapped cache grows to memory_limit on the persistent connection.
290
+ # Without an enforceable cap we keep it OFF to protect memory.
288
291
  cache_size = settings.SUPERTABLE_DUCKDB_EXTERNAL_CACHE_SIZE
289
- if cache_size:
292
+ can_cap = "external_file_cache_max_size" in supported
293
+ if cache_size and can_cap:
290
294
  set_if_supported("enable_external_file_cache", "true")
291
295
  set_if_supported("external_file_cache_max_size", f"'{cache_size}'")
292
296
  cache_dir_raw = settings.SUPERTABLE_DUCKDB_EXTERNAL_CACHE_DIR
@@ -302,6 +306,15 @@ def configure_httpfs_and_s3(
302
306
  + (f" size={cache_size}" if cache_size else "")
303
307
  + f", dir={cache_dir}"
304
308
  )
309
+ else:
310
+ # Uncappable (or disabled via empty size) — turn it off explicitly so
311
+ # the DuckDB 1.5.x default-on cache cannot accumulate in memory.
312
+ set_if_supported("enable_external_file_cache", "false")
313
+ if cache_size and not can_cap:
314
+ logger.info(
315
+ "[duckdb.cache] external file cache disabled: this DuckDB build "
316
+ "cannot cap it (no external_file_cache_max_size)"
317
+ )
305
318
 
306
319
 
307
320
  # =========================================================
@@ -555,6 +568,25 @@ def rewrite_query_with_hashed_tables(
555
568
  # Connection initialization
556
569
  # =========================================================
557
570
 
571
+ def _external_file_cache_cappable(con: duckdb.DuckDBPyConnection) -> bool:
572
+ """True when this DuckDB build can bound the external file cache size.
573
+
574
+ DuckDB 1.5.x enables ``enable_external_file_cache`` by default but does
575
+ not expose ``external_file_cache_max_size``. An enabled-but-uncapped
576
+ cache is held in memory (not on disk) and grows to ``memory_limit`` on
577
+ the long-lived persistent connection — a sustained-memory / OOM hazard.
578
+ When this returns False the cache is disabled outright rather than left
579
+ running unbounded.
580
+ """
581
+ try:
582
+ return bool(con.execute(
583
+ "SELECT 1 FROM duckdb_settings() "
584
+ "WHERE name = 'external_file_cache_max_size'"
585
+ ).fetchone())
586
+ except Exception:
587
+ return False
588
+
589
+
558
590
  def init_connection(
559
591
  con: duckdb.DuckDBPyConnection,
560
592
  temp_dir: str,
@@ -616,6 +648,17 @@ def init_connection(
616
648
  except Exception:
617
649
  pass # older DuckDB builds may not support this setting
618
650
 
651
+ # External file cache baseline. DuckDB 1.5.x turns the cache ON by
652
+ # default but cannot cap it, so an uncapped in-memory cache accumulates
653
+ # remote data up to memory_limit on the persistent connection. Disable
654
+ # it here when uncappable; configure_httpfs_and_s3 / apply_runtime_pragmas
655
+ # re-enable it (capped) only on builds that support a size cap.
656
+ if not _external_file_cache_cappable(con):
657
+ try:
658
+ con.execute("SET enable_external_file_cache=false;")
659
+ except Exception:
660
+ pass
661
+
619
662
  # Thread count.
620
663
  # If SUPERTABLE_DUCKDB_THREADS is set explicitly, honour it exactly.
621
664
  # Otherwise derive from the effective memory limit using the IO-thread
@@ -685,32 +728,294 @@ def apply_runtime_pragmas(con: duckdb.DuckDBPyConnection, cfg) -> None:
685
728
  except Exception:
686
729
  pass
687
730
 
731
+ # External file cache: only run it when the size cap is enforceable.
732
+ # On DuckDB builds without external_file_cache_max_size (e.g. 1.5.x) an
733
+ # enabled cache is in-memory and unbounded — it grows to memory_limit on
734
+ # the persistent connection — so we disable it instead of running uncapped.
688
735
  cache_size = normalize_memory_size(cfg.duckdb_external_cache_size, default="")
689
- if cache_size:
736
+ if cache_size and _external_file_cache_cappable(con):
690
737
  try:
691
738
  con.execute("SET enable_external_file_cache=true;")
739
+ con.execute(
740
+ f"SET external_file_cache_max_size='{sanitize_sql_string(cache_size)}';"
741
+ )
692
742
  except Exception as e:
693
- logger.warning(f"[duckdb.pragma] enable_external_file_cache failed: {e}")
694
- # The size cap is version-dependent: ``external_file_cache_max_size``
695
- # does not exist on every DuckDB build (e.g. 1.5.x). Probe the
696
- # settings catalog and only set it when present so we neither raise
697
- # nor silently swallow a genuinely unsupported setting.
743
+ logger.warning(f"[duckdb.pragma] external file cache config failed: {e}")
744
+ else:
698
745
  try:
699
- has_cap = bool(con.execute(
700
- "SELECT 1 FROM duckdb_settings() "
701
- "WHERE name='external_file_cache_max_size'"
702
- ).fetchone())
703
- if has_cap:
704
- con.execute(
705
- f"SET external_file_cache_max_size='{sanitize_sql_string(cache_size)}';"
706
- )
707
- else:
708
- logger.debug(
709
- "[duckdb.pragma] external_file_cache_max_size unsupported on this "
710
- "DuckDB version; cache enabled without a size cap"
711
- )
746
+ con.execute("SET enable_external_file_cache=false;")
747
+ except Exception:
748
+ pass
749
+
750
+
751
+ # =========================================================
752
+ # Engine self-diagnostics (UI "Diagnose" button)
753
+ # =========================================================
754
+
755
+ def _filesystem_type(path: str) -> str:
756
+ """Best-effort filesystem type for ``path`` via /proc/mounts (Linux).
757
+
758
+ Used to warn when the spill directory is RAM-backed (tmpfs/ramfs), where
759
+ "spilling to disk" would actually consume memory instead of relieving it.
760
+ Returns "" when the type cannot be determined.
761
+ """
762
+ try:
763
+ target = os.path.abspath(path)
764
+ best_mp = ""
765
+ best_type = ""
766
+ with open("/proc/mounts", "r") as fh:
767
+ for line in fh:
768
+ parts = line.split()
769
+ if len(parts) < 3:
770
+ continue
771
+ mount_point, fstype = parts[1], parts[2]
772
+ if (
773
+ target == mount_point
774
+ or target.startswith(mount_point.rstrip("/") + "/")
775
+ or mount_point == "/"
776
+ ):
777
+ if len(mount_point) >= len(best_mp):
778
+ best_mp = mount_point
779
+ best_type = fstype
780
+ return best_type
781
+ except Exception:
782
+ return ""
783
+
784
+
785
+ def run_engine_diagnostics(cfg=None, engine: str = "lite") -> Dict[str, Any]:
786
+ """Deep runtime self-check for a DuckDB engine.
787
+
788
+ Unlike a connection "test", this exercises the runtime to confirm the
789
+ things that silently break in production:
790
+
791
+ * the memory limit is actually applied,
792
+ * the spill (``temp_directory``) exists, is writable, and is on real
793
+ disk (not a RAM-backed tmpfs),
794
+ * a query that exceeds memory genuinely spills to disk instead of OOMing,
795
+ * the external file cache is in a memory-safe state.
796
+
797
+ ``cfg`` is an ``EngineRuntimeConfig`` (or None to use init defaults); the
798
+ connection is configured exactly like a live Lite/Pro query via
799
+ ``init_connection`` + ``apply_runtime_pragmas``. Returns a JSON-serialisable
800
+ report and never raises.
801
+ """
802
+ import shutil
803
+ import time
804
+ import uuid
805
+
806
+ checks: List[Dict[str, Any]] = []
807
+
808
+ def add(cid, label, status, detail="", value=""):
809
+ checks.append({
810
+ "id": cid,
811
+ "label": label,
812
+ "status": status,
813
+ "detail": str(detail),
814
+ "value": "" if value is None else str(value),
815
+ })
816
+
817
+ # 1. Open + configure a connection the same way the engine does.
818
+ con = None
819
+ try:
820
+ con = duckdb.connect()
821
+ init_connection(con, temp_dir="diagnostics")
822
+ if cfg is not None:
823
+ apply_runtime_pragmas(con, cfg)
824
+ add("connect", "Engine connection", "ok",
825
+ "Opened and configured a DuckDB connection")
826
+ except Exception as e:
827
+ add("connect", "Engine connection", "fail", f"Could not initialise: {e}")
828
+ return {"engine": engine, "duckdb_version": "", "overall": "fail", "checks": checks}
829
+
830
+ # 2. DuckDB version + whether the file cache can be capped on this build.
831
+ version = ""
832
+ cappable = False
833
+ try:
834
+ version = con.execute("SELECT version()").fetchone()[0]
835
+ cappable = _external_file_cache_cappable(con)
836
+ add("version", "DuckDB version", "ok" if cappable else "warn",
837
+ ("external_file_cache_max_size supported — the file cache can be capped"
838
+ if cappable else
839
+ "this build has no external_file_cache_max_size — the file cache "
840
+ "cannot be capped, so it is disabled to stay memory-safe"),
841
+ version)
842
+ except Exception as e:
843
+ add("version", "DuckDB version", "warn", f"version() failed: {e}")
844
+
845
+ # 3. Memory limit effective?
846
+ try:
847
+ mem = con.execute("SELECT current_setting('memory_limit')").fetchone()[0]
848
+ low = str(mem).strip().lower()
849
+ if not mem or low in ("0 bytes", "0", "-1") or "unlimited" in low:
850
+ add("memory", "Memory limit", "warn",
851
+ "No effective memory limit — a heavy query can consume all RAM", mem)
852
+ else:
853
+ add("memory", "Memory limit", "ok", "PRAGMA memory_limit is active", mem)
854
+ except Exception as e:
855
+ add("memory", "Memory limit", "fail", f"Could not read memory_limit: {e}")
856
+
857
+ # 4. Thread count.
858
+ try:
859
+ th = con.execute("SELECT current_setting('threads')").fetchone()[0]
860
+ add("threads", "Worker threads", "ok",
861
+ "More threads add parallelism but also raise simultaneous memory use", th)
862
+ except Exception as e:
863
+ add("threads", "Worker threads", "warn", f"Could not read threads: {e}")
864
+
865
+ # 5. Spill (temp) directory: set, exists, writable, on real disk?
866
+ temp_dir = ""
867
+ try:
868
+ temp_dir = con.execute(
869
+ "SELECT current_setting('temp_directory')"
870
+ ).fetchone()[0] or ""
871
+ except Exception:
872
+ temp_dir = ""
873
+
874
+ if not temp_dir:
875
+ add("temp_dir", "Spill directory", "fail",
876
+ "temp_directory is empty — DuckDB cannot spill, so heavy queries OOM")
877
+ else:
878
+ mtds = ""
879
+ try:
880
+ mtds = con.execute(
881
+ "SELECT current_setting('max_temp_directory_size')"
882
+ ).fetchone()[0]
883
+ except Exception:
884
+ mtds = ""
885
+
886
+ writable = False
887
+ werr = ""
888
+ try:
889
+ os.makedirs(temp_dir, exist_ok=True)
890
+ probe = os.path.join(temp_dir, f".st_spill_probe_{uuid.uuid4().hex}")
891
+ with open(probe, "wb") as fh:
892
+ fh.write(b"\0" * (1024 * 1024)) # 1 MiB
893
+ fh.flush()
894
+ os.fsync(fh.fileno())
895
+ os.remove(probe)
896
+ writable = True
712
897
  except Exception as e:
713
- logger.warning(f"[duckdb.pragma] external_file_cache_max_size failed: {e}")
898
+ werr = str(e)
899
+
900
+ free_gb = None
901
+ try:
902
+ free_gb = shutil.disk_usage(temp_dir).free / (1024 ** 3)
903
+ except Exception:
904
+ free_gb = None
905
+ fstype = _filesystem_type(temp_dir)
906
+ ram_backed = fstype in ("tmpfs", "ramfs")
907
+
908
+ parts = [f"path={temp_dir}"]
909
+ if mtds:
910
+ parts.append(f"cap={mtds}")
911
+ if free_gb is not None:
912
+ parts.append(f"free={free_gb:.1f} GB")
913
+ if fstype:
914
+ parts.append(f"fs={fstype}")
915
+ summary = "; ".join(parts)
916
+
917
+ if not writable:
918
+ add("temp_dir", "Spill directory writable", "fail",
919
+ f"Cannot write to the spill directory — queries OOM instead of "
920
+ f"spilling. {werr} ({summary})", temp_dir)
921
+ elif ram_backed:
922
+ add("temp_dir", "Spill directory writable", "warn",
923
+ f"Writable but RAM-backed ({fstype}) — spilling here consumes memory "
924
+ f"instead of relieving it; mount a real disk volume. ({summary})",
925
+ temp_dir)
926
+ elif free_gb is not None and free_gb < 1.0:
927
+ add("temp_dir", "Spill directory writable", "warn",
928
+ f"Writable but low free space ({free_gb:.1f} GB) — large spills may "
929
+ f"fail. ({summary})", temp_dir)
930
+ else:
931
+ add("temp_dir", "Spill directory writable", "ok",
932
+ f"Wrote and removed a 1 MiB probe file. {summary}", temp_dir)
933
+
934
+ # 6. Force a real disk spill under memory pressure (end-to-end proof).
935
+ spill = None
936
+ try:
937
+ spill = duckdb.connect()
938
+ init_connection(spill, temp_dir="diagnostics")
939
+ spill.execute("PRAGMA memory_limit='256MB';")
940
+ spill.execute("SET threads=4;")
941
+ spill.execute("SET preserve_insertion_order=false;")
942
+ t0 = time.perf_counter()
943
+ # ~3M rows carrying a wide ~150-byte payload (~465 MB) sorted by a
944
+ # scrambled key under a 256 MB cap: the working set cannot fit in
945
+ # memory, so completion proves DuckDB spilled the payload to disk.
946
+ # The cheap integer sort key keeps it fast (<1 s) while the 256 MB cap
947
+ # sits far above the pinned-overhead floor, so a healthy disk never
948
+ # false-fails.
949
+ n = spill.execute(
950
+ "SELECT count(*) FROM ("
951
+ "SELECT hash(i) AS h, repeat('x', 140) || i::VARCHAR AS pad "
952
+ "FROM range(3000000) t(i) ORDER BY h"
953
+ ") q"
954
+ ).fetchone()[0]
955
+ ms = (time.perf_counter() - t0) * 1000.0
956
+ add("spill", "Disk spill under pressure", "ok",
957
+ f"Sorted {n:,} rows (~465 MB) under a 256 MB limit in {ms:.0f} ms — "
958
+ "DuckDB spilled to disk instead of failing", f"{n:,} rows")
959
+ except Exception as e:
960
+ msg = str(e)
961
+ if "out of memory" in msg.lower() or "failed to pin" in msg.lower():
962
+ add("spill", "Disk spill under pressure", "fail",
963
+ "A query that must spill ran out of memory instead — the spill "
964
+ f"directory is not usable for spilling. {msg}")
965
+ else:
966
+ add("spill", "Disk spill under pressure", "warn",
967
+ f"Spill probe did not complete: {msg}")
968
+ finally:
969
+ if spill is not None:
970
+ try:
971
+ spill.close()
972
+ except Exception:
973
+ pass
974
+
975
+ # 7. External file cache memory safety.
976
+ try:
977
+ efc = con.execute(
978
+ "SELECT current_setting('enable_external_file_cache')"
979
+ ).fetchone()[0]
980
+ efc_on = str(efc).strip().lower() in ("true", "1")
981
+ cache_cfg = ""
982
+ if cfg is not None:
983
+ cache_cfg = normalize_memory_size(
984
+ getattr(cfg, "duckdb_external_cache_size", ""), default=""
985
+ )
986
+ if efc_on and not cappable:
987
+ add("cache", "External file cache", "fail",
988
+ "Cache is ON but this build cannot cap it — it grows to the memory "
989
+ "limit and causes OOM", "on · uncapped")
990
+ elif efc_on and cappable:
991
+ add("cache", "External file cache", "ok",
992
+ f"Cache is ON and capped at {cache_cfg or 'the configured size'}",
993
+ "on · capped")
994
+ else:
995
+ add("cache", "External file cache", "ok",
996
+ "Cache is OFF — memory-safe; remote files are re-fetched per query "
997
+ "(set a Disk cache size on a cap-capable build to speed up repeats)",
998
+ "off")
999
+ except Exception as e:
1000
+ add("cache", "External file cache", "warn", f"Could not read cache state: {e}")
1001
+
1002
+ try:
1003
+ con.close()
1004
+ except Exception:
1005
+ pass
1006
+
1007
+ rank = {"ok": 0, "warn": 1, "fail": 2}
1008
+ overall = "ok"
1009
+ for c in checks:
1010
+ if rank.get(c["status"], 0) > rank.get(overall, 0):
1011
+ overall = c["status"]
1012
+
1013
+ return {
1014
+ "engine": engine,
1015
+ "duckdb_version": version,
1016
+ "overall": overall,
1017
+ "checks": checks,
1018
+ }
714
1019
 
715
1020
 
716
1021
  # =========================================================
@@ -108,7 +108,7 @@ def normalize_memory_size(value: Any, default: str = "1GB", *, bare_unit: str =
108
108
  # Shared auto-pick thresholds: key → (env var, built-in default as string).
109
109
  _SHARED_SPEC: Dict[str, Tuple[str, str]] = {
110
110
  "engine_lite_max_bytes": ("SUPERTABLE_ENGINE_LITE_MAX_BYTES", str(100 * 1024 * 1024)),
111
- "engine_spark_min_bytes": ("SUPERTABLE_ENGINE_SPARK_MIN_BYTES", str(10 * 1024 * 1024 * 1024)),
111
+ "engine_spark_min_bytes": ("SUPERTABLE_ENGINE_SPARK_MIN_BYTES", str(0)),
112
112
  "engine_freshness_sec": ("SUPERTABLE_ENGINE_FRESHNESS_SEC", "300"),
113
113
  }
114
114
 
@@ -209,7 +209,7 @@ def _build_runtime(redis_cfg: Dict[str, Any], engine: str) -> EngineRuntimeConfi
209
209
 
210
210
  return EngineRuntimeConfig(
211
211
  engine_lite_max_bytes=_to_int(sv["engine_lite_max_bytes"], 100 * 1024 * 1024),
212
- engine_spark_min_bytes=_to_int(sv["engine_spark_min_bytes"], 10 * 1024 * 1024 * 1024),
212
+ engine_spark_min_bytes=_to_int(sv["engine_spark_min_bytes"], 0),
213
213
  engine_freshness_sec=_to_int(sv["engine_freshness_sec"], 300),
214
214
  duckdb_memory_limit=normalize_memory_size(dv["duckdb_memory_limit"], default="1GB"),
215
215
  duckdb_io_multiplier=_to_float(dv["duckdb_io_multiplier"], 3.0),
@@ -62,46 +62,50 @@ class Executor:
62
62
  self._catalog = False # sentinel: construction failed, do not retry
63
63
  return self._catalog or None
64
64
 
65
- def _spark_min_bytes(self, cfg: EngineRuntimeConfig) -> int:
66
- """Effective lower byte bound for routing a query to Spark under AUTO.
67
-
68
- Couples the configured policy floor (``engine_spark_min_bytes``) with the
69
- registered Spark fleet so the boundary is::
70
-
71
- max(engine_spark_min_bytes, min(active cluster min_bytes))
72
-
73
- Rationale:
74
- * The ``max`` keeps the admin's global floor effective — keep
75
- ``engine_spark_min_bytes`` high to hold medium jobs on DuckDB even
76
- when a cluster would accept them (set it to 0 to let the fleet alone
77
- drive routing).
78
- * Folding in the fleet minimum means AUTO never routes a job *below*
79
- what any active cluster accepts — which would otherwise make
80
- ``select_spark_cluster`` return nothing and hard-fail the query
81
- (see spark_thrift.py ``_select_cluster``).
82
-
83
- Degrades to the policy floor alone when no catalog is reachable or no
84
- active clusters are registered.
65
+ def _active_spark_clusters(self) -> list:
66
+ """Active Spark Thrift clusters registered for this org (best-effort).
67
+
68
+ Returns ``[]`` when no catalog is reachable or none are active, which
69
+ makes AUTO stay on DuckDB instead of routing to a fleet that cannot run
70
+ the job.
85
71
  """
86
- policy = cfg.engine_spark_min_bytes
87
72
  catalog = self._get_catalog()
88
73
  if catalog is None:
89
- return policy
74
+ return []
90
75
  try:
91
76
  clusters = catalog.list_spark_clusters(self.organization) or []
92
77
  except Exception:
93
- return policy
78
+ return []
79
+ return [
80
+ c for c in clusters
81
+ if isinstance(c, dict) and c.get("status") == "active"
82
+ ]
83
+
84
+ def _spark_min_bytes(self, cfg: EngineRuntimeConfig, active_clusters: Optional[list] = None) -> int:
85
+ """Byte size at which AUTO hands a query to the Spark fleet.
86
+
87
+ Fleet-driven: the **smallest** ``min_bytes`` across active clusters —
88
+ the lowest job size any active cluster will accept. A job at or above
89
+ this triggers Spark; :meth:`RedisCatalog.select_spark_cluster` then
90
+ picks (at random) one of the clusters whose ``[min_bytes, max_bytes]``
91
+ window contains the job.
92
+
93
+ Falls back to the ``engine_spark_min_bytes`` policy value only when no
94
+ active cluster is known (catalog down / empty fleet). In that case
95
+ :meth:`_auto_pick` gates on an active cluster existing, so AUTO won't
96
+ route to Spark regardless of the returned bound.
97
+ """
98
+ if active_clusters is None:
99
+ active_clusters = self._active_spark_clusters()
94
100
  mins = []
95
- for c in clusters:
96
- if not isinstance(c, dict) or c.get("status") != "active":
97
- continue
101
+ for c in active_clusters:
98
102
  try:
99
103
  mins.append(int(c.get("min_bytes", 0)))
100
104
  except (TypeError, ValueError):
101
105
  continue
102
- if not mins:
103
- return policy
104
- return max(policy, min(mins))
106
+ if mins:
107
+ return min(mins)
108
+ return cfg.engine_spark_min_bytes
105
109
 
106
110
  def _auto_pick(self, reflection: Reflection, cfg: EngineRuntimeConfig) -> Engine:
107
111
  """Select the best engine based on data size and freshness.
@@ -118,28 +122,34 @@ class Executor:
118
122
  (lite–spk)│ cache would churn │ cache pays off │
119
123
  ├─────────────────────┼─────────────────────┤
120
124
  Large │ SPARK * │ SPARK * │
121
- (>=spark) │ too big for DuckDB too big for DuckDB
125
+ (>=spark) │ hand off to fleet hand off to fleet
122
126
  └─────────────────────┴─────────────────────┘
123
127
 
124
- * Spark only if pyspark is available; falls back to PRO otherwise.
128
+ * Spark is chosen only when an **active Spark cluster is registered**
129
+ for the org and the job reaches the fleet's minimum accepted size
130
+ (the smallest ``min_bytes`` across active clusters — see
131
+ :meth:`_spark_min_bytes`). With no active cluster, AUTO stays on
132
+ DuckDB regardless of size. The concrete cluster is chosen later by
133
+ :meth:`RedisCatalog.select_spark_cluster`, at random among the active
134
+ clusters whose ``[min_bytes, max_bytes]`` window contains the job.
125
135
 
126
136
  Env var overrides:
127
137
  SUPERTABLE_ENGINE_LITE_MAX_BYTES – upper bound for Lite (default 100 MB)
128
- SUPERTABLE_ENGINE_SPARK_MIN_BYTES – lower bound for Spark (default 10 GB)
138
+ SUPERTABLE_ENGINE_SPARK_MIN_BYTES – Spark floor used only when no
139
+ active cluster is registered
129
140
  SUPERTABLE_ENGINE_FRESHNESS_SEC – age threshold in seconds (default 300)
130
-
131
- The Spark lower bound is additionally coupled to the registered fleet
132
- (see :meth:`_spark_min_bytes`): the effective boundary is the larger of
133
- the configured floor and the smallest ``min_bytes`` across active
134
- clusters, so AUTO only routes to Spark when some cluster will take it.
135
141
  """
136
142
  bytes_total = reflection.reflection_bytes
137
143
 
138
144
  # --- thresholds (resolved live from org system config) ---
139
145
  lite_max = cfg.engine_lite_max_bytes
140
- spark_min = self._spark_min_bytes(cfg)
141
146
  freshness_threshold_s = cfg.engine_freshness_sec
142
147
 
148
+ # --- Spark fleet: the registered clusters decide availability + floor ---
149
+ active_clusters = self._active_spark_clusters()
150
+ spark_available = bool(active_clusters)
151
+ spark_min = self._spark_min_bytes(cfg, active_clusters)
152
+
143
153
  # --- freshness: how long ago was the most recent snapshot updated ---
144
154
  if reflection.freshness_ms > 0:
145
155
  age_s = (time.time() * 1000 - reflection.freshness_ms) / 1000.0
@@ -149,19 +159,11 @@ class Executor:
149
159
  age_s = -1
150
160
  data_is_fresh = False
151
161
 
152
- # --- Spark gate ---
153
- spark_available = False
154
- if bytes_total >= spark_min:
155
- try:
156
- from pyspark.sql import SparkSession # noqa: F401
157
- spark_available = True
158
- except Exception:
159
- pass
160
-
161
162
  # --- decision ---
162
163
  if spark_available and bytes_total >= spark_min:
163
164
  chosen = Engine.SPARK_SQL
164
- reason = f"bytes={bytes_total} >= spark_min={spark_min}"
165
+ reason = (f"bytes={bytes_total} >= fleet_min={spark_min} "
166
+ f"({len(active_clusters)} active cluster(s))")
165
167
  elif bytes_total <= lite_max:
166
168
  chosen = Engine.DUCKDB_LITE
167
169
  reason = f"bytes={bytes_total} <= lite_max={lite_max}"