supertable 2.2.6__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 (181) hide show
  1. {supertable-2.2.6/supertable.egg-info → supertable-2.2.8}/PKG-INFO +1 -1
  2. {supertable-2.2.6 → supertable-2.2.8}/pyproject.toml +1 -1
  3. {supertable-2.2.6 → supertable-2.2.8}/setup.py +1 -1
  4. {supertable-2.2.6 → supertable-2.2.8}/supertable/__init__.py +1 -1
  5. {supertable-2.2.6 → supertable-2.2.8}/supertable/config/settings.py +2 -2
  6. {supertable-2.2.6 → supertable-2.2.8}/supertable/data_reader.py +33 -2
  7. {supertable-2.2.6 → supertable-2.2.8}/supertable/engine/engine_common.py +351 -13
  8. {supertable-2.2.6 → supertable-2.2.8}/supertable/engine/engine_config.py +47 -4
  9. {supertable-2.2.6 → supertable-2.2.8}/supertable/engine/executor.py +62 -14
  10. supertable-2.2.8/supertable/engine/tests/test_engine_config.py +81 -0
  11. supertable-2.2.8/supertable/engine/tests/test_engine_routing.py +192 -0
  12. supertable-2.2.8/supertable/engine/tests/test_engine_spill.py +183 -0
  13. {supertable-2.2.6 → supertable-2.2.8}/supertable/monitoring_writer.py +49 -4
  14. {supertable-2.2.6 → supertable-2.2.8}/supertable/redis_catalog.py +33 -16
  15. {supertable-2.2.6 → supertable-2.2.8}/supertable/tests/test_data_reader.py +30 -0
  16. {supertable-2.2.6 → supertable-2.2.8}/supertable/tests/test_monitoring_partitions.py +82 -0
  17. {supertable-2.2.6 → supertable-2.2.8/supertable.egg-info}/PKG-INFO +1 -1
  18. {supertable-2.2.6 → supertable-2.2.8}/supertable.egg-info/SOURCES.txt +3 -0
  19. {supertable-2.2.6 → supertable-2.2.8}/LICENSE +0 -0
  20. {supertable-2.2.6 → supertable-2.2.8}/README.md +0 -0
  21. {supertable-2.2.6 → supertable-2.2.8}/requirements.txt +0 -0
  22. {supertable-2.2.6 → supertable-2.2.8}/setup.cfg +0 -0
  23. {supertable-2.2.6 → supertable-2.2.8}/supertable/audit/__init__.py +0 -0
  24. {supertable-2.2.6 → supertable-2.2.8}/supertable/audit/admin.py +0 -0
  25. {supertable-2.2.6 → supertable-2.2.8}/supertable/audit/chain.py +0 -0
  26. {supertable-2.2.6 → supertable-2.2.8}/supertable/audit/consumers.py +0 -0
  27. {supertable-2.2.6 → supertable-2.2.8}/supertable/audit/crypto.py +0 -0
  28. {supertable-2.2.6 → supertable-2.2.8}/supertable/audit/events.py +0 -0
  29. {supertable-2.2.6 → supertable-2.2.8}/supertable/audit/export.py +0 -0
  30. {supertable-2.2.6 → supertable-2.2.8}/supertable/audit/logger.py +0 -0
  31. {supertable-2.2.6 → supertable-2.2.8}/supertable/audit/middleware.py +0 -0
  32. {supertable-2.2.6 → supertable-2.2.8}/supertable/audit/reader.py +0 -0
  33. {supertable-2.2.6 → supertable-2.2.8}/supertable/audit/retention.py +0 -0
  34. {supertable-2.2.6 → supertable-2.2.8}/supertable/audit/tests/__init__.py +0 -0
  35. {supertable-2.2.6 → supertable-2.2.8}/supertable/audit/tests/test_chain.py +0 -0
  36. {supertable-2.2.6 → supertable-2.2.8}/supertable/audit/tests/test_crypto.py +0 -0
  37. {supertable-2.2.6 → supertable-2.2.8}/supertable/audit/tests/test_emit.py +0 -0
  38. {supertable-2.2.6 → supertable-2.2.8}/supertable/audit/tests/test_events.py +0 -0
  39. {supertable-2.2.6 → supertable-2.2.8}/supertable/audit/tests/test_retention.py +0 -0
  40. {supertable-2.2.6 → supertable-2.2.8}/supertable/audit/writer_parquet.py +0 -0
  41. {supertable-2.2.6 → supertable-2.2.8}/supertable/audit/writer_redis.py +0 -0
  42. {supertable-2.2.6 → supertable-2.2.8}/supertable/config/__init__.py +0 -0
  43. {supertable-2.2.6 → supertable-2.2.8}/supertable/config/defaults.py +0 -0
  44. {supertable-2.2.6 → supertable-2.2.8}/supertable/config/homedir.py +0 -0
  45. {supertable-2.2.6 → supertable-2.2.8}/supertable/config/tests/__init__.py +0 -0
  46. {supertable-2.2.6 → supertable-2.2.8}/supertable/config/tests/test_defaults.py +0 -0
  47. {supertable-2.2.6 → supertable-2.2.8}/supertable/config/tests/test_homedir.py +0 -0
  48. {supertable-2.2.6 → supertable-2.2.8}/supertable/config/tests/test_settings.py +0 -0
  49. {supertable-2.2.6 → supertable-2.2.8}/supertable/data_classes.py +0 -0
  50. {supertable-2.2.6 → supertable-2.2.8}/supertable/data_writer.py +0 -0
  51. {supertable-2.2.6 → supertable-2.2.8}/supertable/demo/__init__.py +0 -0
  52. {supertable-2.2.6 → supertable-2.2.8}/supertable/demo/quickstart/__init__.py +0 -0
  53. {supertable-2.2.6 → supertable-2.2.8}/supertable/demo/quickstart/__main__.py +0 -0
  54. {supertable-2.2.6 → supertable-2.2.8}/supertable/demo/quickstart/check_filter_builder.py +0 -0
  55. {supertable-2.2.6 → supertable-2.2.8}/supertable/demo/quickstart/controller.py +0 -0
  56. {supertable-2.2.6 → supertable-2.2.8}/supertable/demo/quickstart/data_writer_helpers.py +0 -0
  57. {supertable-2.2.6 → supertable-2.2.8}/supertable/demo/quickstart/defaults.py +0 -0
  58. {supertable-2.2.6 → supertable-2.2.8}/supertable/demo/quickstart/dummy_data.py +0 -0
  59. {supertable-2.2.6 → supertable-2.2.8}/supertable/demo/quickstart/read_parquet_header.py +0 -0
  60. {supertable-2.2.6 → supertable-2.2.8}/supertable/demo/quickstart/s01_01_01_create_super_table.py +0 -0
  61. {supertable-2.2.6 → supertable-2.2.8}/supertable/demo/quickstart/s01_01_02_enable_mirroring_formats.py +0 -0
  62. {supertable-2.2.6 → supertable-2.2.8}/supertable/demo/quickstart/s01_02_create_roles.py +0 -0
  63. {supertable-2.2.6 → supertable-2.2.8}/supertable/demo/quickstart/s01_03_create_users.py +0 -0
  64. {supertable-2.2.6 → supertable-2.2.8}/supertable/demo/quickstart/s02_01_write_dummy_data.py +0 -0
  65. {supertable-2.2.6 → supertable-2.2.8}/supertable/demo/quickstart/s02_02_write_single_data.py +0 -0
  66. {supertable-2.2.6 → supertable-2.2.8}/supertable/demo/quickstart/s02_03_01_write_staging.py +0 -0
  67. {supertable-2.2.6 → supertable-2.2.8}/supertable/demo/quickstart/s02_03_02_create_pipe.py +0 -0
  68. {supertable-2.2.6 → supertable-2.2.8}/supertable/demo/quickstart/s02_04_01_write_monitoring_simple.py +0 -0
  69. {supertable-2.2.6 → supertable-2.2.8}/supertable/demo/quickstart/s02_04_02_write_monitoring_parallel.py +0 -0
  70. {supertable-2.2.6 → supertable-2.2.8}/supertable/demo/quickstart/s02_05_write_tombstone.py +0 -0
  71. {supertable-2.2.6 → supertable-2.2.8}/supertable/demo/quickstart/s03_01_read_data_error.py +0 -0
  72. {supertable-2.2.6 → supertable-2.2.8}/supertable/demo/quickstart/s03_02_01_read_super_data_ok.py +0 -0
  73. {supertable-2.2.6 → supertable-2.2.8}/supertable/demo/quickstart/s03_02_02_read_table_data_ok.py +0 -0
  74. {supertable-2.2.6 → supertable-2.2.8}/supertable/demo/quickstart/s03_03_read_meta.py +0 -0
  75. {supertable-2.2.6 → supertable-2.2.8}/supertable/demo/quickstart/s03_04_read_staging.py +0 -0
  76. {supertable-2.2.6 → supertable-2.2.8}/supertable/demo/quickstart/s03_06_01_read_roles.py +0 -0
  77. {supertable-2.2.6 → supertable-2.2.8}/supertable/demo/quickstart/s03_06_02_read_user.py +0 -0
  78. {supertable-2.2.6 → supertable-2.2.8}/supertable/demo/quickstart/s03_07_01_estimate_read.py +0 -0
  79. {supertable-2.2.6 → supertable-2.2.8}/supertable/demo/quickstart/s03_07_02_estimate_files.py +0 -0
  80. {supertable-2.2.6 → supertable-2.2.8}/supertable/demo/quickstart/s03_08_read_snapshot_history.py +0 -0
  81. {supertable-2.2.6 → supertable-2.2.8}/supertable/demo/quickstart/s04_01_03_delete_pipe.py +0 -0
  82. {supertable-2.2.6 → supertable-2.2.8}/supertable/demo/quickstart/s05_01_delete_table.py +0 -0
  83. {supertable-2.2.6 → supertable-2.2.8}/supertable/demo/quickstart/s05_02_delete_super_table.py +0 -0
  84. {supertable-2.2.6 → supertable-2.2.8}/supertable/demo/webshop/__init__.py +0 -0
  85. {supertable-2.2.6 → supertable-2.2.8}/supertable/demo/webshop/core.py +0 -0
  86. {supertable-2.2.6 → supertable-2.2.8}/supertable/demo/webshop/defaults.py +0 -0
  87. {supertable-2.2.6 → supertable-2.2.8}/supertable/demo/webshop/generate.py +0 -0
  88. {supertable-2.2.6 → supertable-2.2.8}/supertable/demo/webshop/load.py +0 -0
  89. {supertable-2.2.6 → supertable-2.2.8}/supertable/demo/webshop/topup.py +0 -0
  90. {supertable-2.2.6 → supertable-2.2.8}/supertable/engine/__init__.py +0 -0
  91. {supertable-2.2.6 → supertable-2.2.8}/supertable/engine/data_estimator.py +0 -0
  92. {supertable-2.2.6 → supertable-2.2.8}/supertable/engine/duckdb_lite.py +0 -0
  93. {supertable-2.2.6 → supertable-2.2.8}/supertable/engine/duckdb_pro.py +0 -0
  94. {supertable-2.2.6 → supertable-2.2.8}/supertable/engine/engine_enum.py +0 -0
  95. {supertable-2.2.6 → supertable-2.2.8}/supertable/engine/plan_stats.py +0 -0
  96. {supertable-2.2.6 → supertable-2.2.8}/supertable/engine/spark_thrift.py +0 -0
  97. {supertable-2.2.6 → supertable-2.2.8}/supertable/engine/tests/__init__.py +0 -0
  98. {supertable-2.2.6 → supertable-2.2.8}/supertable/engine/tests/conftest.py +0 -0
  99. {supertable-2.2.6 → supertable-2.2.8}/supertable/engine/tests/test_dedup_read.py +0 -0
  100. {supertable-2.2.6 → supertable-2.2.8}/supertable/engine/tests/test_engine.py +0 -0
  101. {supertable-2.2.6 → supertable-2.2.8}/supertable/errors.py +0 -0
  102. {supertable-2.2.6 → supertable-2.2.8}/supertable/locking/__init__.py +0 -0
  103. {supertable-2.2.6 → supertable-2.2.8}/supertable/locking/benchmarks/__init__.py +0 -0
  104. {supertable-2.2.6 → supertable-2.2.8}/supertable/locking/benchmarks/benchmark_locking.py +0 -0
  105. {supertable-2.2.6 → supertable-2.2.8}/supertable/locking/benchmarks/measure_lock_speed.py +0 -0
  106. {supertable-2.2.6 → supertable-2.2.8}/supertable/locking/benchmarks/measure_lock_time.py +0 -0
  107. {supertable-2.2.6 → supertable-2.2.8}/supertable/locking/file_lock.py +0 -0
  108. {supertable-2.2.6 → supertable-2.2.8}/supertable/locking/redis_lock.py +0 -0
  109. {supertable-2.2.6 → supertable-2.2.8}/supertable/locking/tests/__init__.py +0 -0
  110. {supertable-2.2.6 → supertable-2.2.8}/supertable/locking/tests/test_file_lock.py +0 -0
  111. {supertable-2.2.6 → supertable-2.2.8}/supertable/locking/tests/test_redis_lock.py +0 -0
  112. {supertable-2.2.6 → supertable-2.2.8}/supertable/logging.py +0 -0
  113. {supertable-2.2.6 → supertable-2.2.8}/supertable/meta_reader.py +0 -0
  114. {supertable-2.2.6 → supertable-2.2.8}/supertable/mirroring/__init__.py +0 -0
  115. {supertable-2.2.6 → supertable-2.2.8}/supertable/mirroring/mirror_delta.py +0 -0
  116. {supertable-2.2.6 → supertable-2.2.8}/supertable/mirroring/mirror_formats.py +0 -0
  117. {supertable-2.2.6 → supertable-2.2.8}/supertable/mirroring/mirror_iceberg.py +0 -0
  118. {supertable-2.2.6 → supertable-2.2.8}/supertable/mirroring/mirror_parquet.py +0 -0
  119. {supertable-2.2.6 → supertable-2.2.8}/supertable/monitoring/__init__.py +0 -0
  120. {supertable-2.2.6 → supertable-2.2.8}/supertable/monitoring/partitions.py +0 -0
  121. {supertable-2.2.6 → supertable-2.2.8}/supertable/plan_extender.py +0 -0
  122. {supertable-2.2.6 → supertable-2.2.8}/supertable/processing.py +0 -0
  123. {supertable-2.2.6 → supertable-2.2.8}/supertable/query_plan_manager.py +0 -0
  124. {supertable-2.2.6 → supertable-2.2.8}/supertable/rbac/__init__.py +0 -0
  125. {supertable-2.2.6 → supertable-2.2.8}/supertable/rbac/access_control.py +0 -0
  126. {supertable-2.2.6 → supertable-2.2.8}/supertable/rbac/filter_builder.py +0 -0
  127. {supertable-2.2.6 → supertable-2.2.8}/supertable/rbac/permissions.py +0 -0
  128. {supertable-2.2.6 → supertable-2.2.8}/supertable/rbac/role_manager.py +0 -0
  129. {supertable-2.2.6 → supertable-2.2.8}/supertable/rbac/row_column_security.py +0 -0
  130. {supertable-2.2.6 → supertable-2.2.8}/supertable/rbac/tests/test_filter_builder.py +0 -0
  131. {supertable-2.2.6 → supertable-2.2.8}/supertable/rbac/tests/test_rbac.py +0 -0
  132. {supertable-2.2.6 → supertable-2.2.8}/supertable/rbac/tests/test_rbac_per_table.py +0 -0
  133. {supertable-2.2.6 → supertable-2.2.8}/supertable/rbac/user_manager.py +0 -0
  134. {supertable-2.2.6 → supertable-2.2.8}/supertable/redis_connector.py +0 -0
  135. {supertable-2.2.6 → supertable-2.2.8}/supertable/redis_infra.py +0 -0
  136. {supertable-2.2.6 → supertable-2.2.8}/supertable/redis_keys.py +0 -0
  137. {supertable-2.2.6 → supertable-2.2.8}/supertable/simple_table.py +0 -0
  138. {supertable-2.2.6 → supertable-2.2.8}/supertable/staging_area.py +0 -0
  139. {supertable-2.2.6 → supertable-2.2.8}/supertable/storage/__init__.py +0 -0
  140. {supertable-2.2.6 → supertable-2.2.8}/supertable/storage/azure_storage.py +0 -0
  141. {supertable-2.2.6 → supertable-2.2.8}/supertable/storage/gcp_storage.py +0 -0
  142. {supertable-2.2.6 → supertable-2.2.8}/supertable/storage/local_storage.py +0 -0
  143. {supertable-2.2.6 → supertable-2.2.8}/supertable/storage/minio_storage.py +0 -0
  144. {supertable-2.2.6 → supertable-2.2.8}/supertable/storage/s3_storage.py +0 -0
  145. {supertable-2.2.6 → supertable-2.2.8}/supertable/storage/storage_factory.py +0 -0
  146. {supertable-2.2.6 → supertable-2.2.8}/supertable/storage/storage_interface.py +0 -0
  147. {supertable-2.2.6 → supertable-2.2.8}/supertable/storage/tests/test_storage.py +0 -0
  148. {supertable-2.2.6 → supertable-2.2.8}/supertable/super_pipe.py +0 -0
  149. {supertable-2.2.6 → supertable-2.2.8}/supertable/super_table.py +0 -0
  150. {supertable-2.2.6 → supertable-2.2.8}/supertable/tests/__init__.py +0 -0
  151. {supertable-2.2.6 → supertable-2.2.8}/supertable/tests/test_align_to_schema_fix.py +0 -0
  152. {supertable-2.2.6 → supertable-2.2.8}/supertable/tests/test_create_if_missing.py +0 -0
  153. {supertable-2.2.6 → supertable-2.2.8}/supertable/tests/test_data_reader_preflight.py +0 -0
  154. {supertable-2.2.6 → supertable-2.2.8}/supertable/tests/test_data_writer.py +0 -0
  155. {supertable-2.2.6 → supertable-2.2.8}/supertable/tests/test_data_writer_compact.py +0 -0
  156. {supertable-2.2.6 → supertable-2.2.8}/supertable/tests/test_data_writer_comprehensive.py +0 -0
  157. {supertable-2.2.6 → supertable-2.2.8}/supertable/tests/test_data_writer_tombstones.py +0 -0
  158. {supertable-2.2.6 → supertable-2.2.8}/supertable/tests/test_dedup_on_read_write.py +0 -0
  159. {supertable-2.2.6 → supertable-2.2.8}/supertable/tests/test_errors.py +0 -0
  160. {supertable-2.2.6 → supertable-2.2.8}/supertable/tests/test_meta_reader.py +0 -0
  161. {supertable-2.2.6 → supertable-2.2.8}/supertable/tests/test_monitoring_sink_guard.py +0 -0
  162. {supertable-2.2.6 → supertable-2.2.8}/supertable/tests/test_newer_than.py +0 -0
  163. {supertable-2.2.6 → supertable-2.2.8}/supertable/tests/test_process_delete_only.py +0 -0
  164. {supertable-2.2.6 → supertable-2.2.8}/supertable/tests/test_processing.py +0 -0
  165. {supertable-2.2.6 → supertable-2.2.8}/supertable/tests/test_processing_compact_resources.py +0 -0
  166. {supertable-2.2.6 → supertable-2.2.8}/supertable/tests/test_query_sql.py +0 -0
  167. {supertable-2.2.6 → supertable-2.2.8}/supertable/tests/test_redis_key_prefix.py +0 -0
  168. {supertable-2.2.6 → supertable-2.2.8}/supertable/tests/test_simple_table.py +0 -0
  169. {supertable-2.2.6 → supertable-2.2.8}/supertable/tests/test_small_file_compaction.py +0 -0
  170. {supertable-2.2.6 → supertable-2.2.8}/supertable/tests/test_super_table.py +0 -0
  171. {supertable-2.2.6 → supertable-2.2.8}/supertable/tests/test_supertable_all.py +0 -0
  172. {supertable-2.2.6 → supertable-2.2.8}/supertable/utils/__init__.py +0 -0
  173. {supertable-2.2.6 → supertable-2.2.8}/supertable/utils/helper.py +0 -0
  174. {supertable-2.2.6 → supertable-2.2.8}/supertable/utils/profiler.py +0 -0
  175. {supertable-2.2.6 → supertable-2.2.8}/supertable/utils/sql_parser.py +0 -0
  176. {supertable-2.2.6 → supertable-2.2.8}/supertable/utils/tests/test_sql_parser_columns.py +0 -0
  177. {supertable-2.2.6 → supertable-2.2.8}/supertable/utils/timer.py +0 -0
  178. {supertable-2.2.6 → supertable-2.2.8}/supertable.egg-info/dependency_links.txt +0 -0
  179. {supertable-2.2.6 → supertable-2.2.8}/supertable.egg-info/entry_points.txt +0 -0
  180. {supertable-2.2.6 → supertable-2.2.8}/supertable.egg-info/requires.txt +0 -0
  181. {supertable-2.2.6 → 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.6
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.6"
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.6",
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.6"
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
 
@@ -44,10 +44,20 @@ class DataReader:
44
44
  - Execution to Executor (DuckDB/Spark)
45
45
  """
46
46
 
47
- def __init__(self, super_name: str, organization: str, query: str):
47
+ def __init__(
48
+ self,
49
+ super_name: str,
50
+ organization: str,
51
+ query: str,
52
+ source: str = "sdk",
53
+ ):
48
54
  self.super_name = super_name
49
55
  self.organization = organization
50
56
  self.query = query
57
+ # Query origin surfaced in the reads monitoring tab. "sdk" is the
58
+ # default for direct SDK callers; the API/OData/MCP entry points
59
+ # pass "api"/"odata"/"mcp" so each query records where it came from.
60
+ self.source = source
51
61
 
52
62
  self.storage: StorageInterface = get_storage()
53
63
 
@@ -164,6 +174,9 @@ class DataReader:
164
174
  current_meta_path="redis://meta/root",
165
175
  query=parser.original_query,
166
176
  )
177
+ # Stamp the call origin so plan_extender records it on the read
178
+ # monitoring entry (defaults to "api" downstream if unset).
179
+ self.query_plan_manager.source_type = self.source
167
180
  self._log_ctx = f"[qid={self.query_plan_manager.query_id} qh={self.query_plan_manager.query_hash}] "
168
181
  self.query_plan_manager.original_table = ", ".join(t.simple_name for t in physical_tables) if physical_tables else ""
169
182
 
@@ -311,16 +324,25 @@ def query_sql(
311
324
  limit: int,
312
325
  engine: Any,
313
326
  role_name: str,
327
+ source: str = "sdk",
328
+ out: Optional[Dict[str, Any]] = None,
314
329
  ) -> Tuple[List[str], List[List[Any]], List[Dict[str, Any]]]:
315
330
  """
316
331
  Execute SQL query and return results in the format expected by MCP server.
317
332
  Returns: (columns, rows, columns_meta)
333
+
334
+ ``source`` tags the query origin on the read monitoring entry
335
+ (defaults to "sdk"; the MCP server passes "mcp"). When an ``out``
336
+ dict is supplied it is populated with ``query_id``/``query_hash`` so
337
+ the caller can correlate its own audit log to this read record.
318
338
  """
319
339
  # Safety guard: ensure a LIMIT is present so unbounded queries don't
320
340
  # overwhelm the MCP response payload.
321
341
  sql = _ensure_sql_limit(sql, default_limit=limit)
322
342
 
323
- reader = DataReader(organization=organization, super_name=super_name, query=sql)
343
+ reader = DataReader(
344
+ organization=organization, super_name=super_name, query=sql, source=source,
345
+ )
324
346
 
325
347
  # Execute the query
326
348
  result_df, status, message = reader.execute(
@@ -329,6 +351,15 @@ def query_sql(
329
351
  with_scan=False,
330
352
  )
331
353
 
354
+ # Expose the query identity so the caller (e.g. the MCP audit log) can
355
+ # link back to this read's monitoring entry. Populated even on error,
356
+ # since the QueryPlanManager is created before execution.
357
+ if out is not None:
358
+ qpm = reader.query_plan_manager
359
+ if qpm is not None:
360
+ out["query_id"] = qpm.query_id
361
+ out["query_hash"] = qpm.query_hash
362
+
332
363
  if status == Status.ERROR:
333
364
  raise RuntimeError(f"Query execution failed: {message}")
334
365
 
@@ -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
@@ -14,6 +14,7 @@ from sqlglot import exp
14
14
  from supertable.config.defaults import logger
15
15
  from supertable.config.settings import settings
16
16
  from supertable.config.homedir import get_app_home
17
+ from supertable.engine.engine_config import normalize_memory_size
17
18
 
18
19
 
19
20
  # =========================================================
@@ -281,11 +282,15 @@ def configure_httpfs_and_s3(
281
282
  "true" if meta_cache_on else "false",
282
283
  )
283
284
 
284
- # External file cache (DuckDB >= 1.3) caches remote data blocks on local
285
- # disk so repeated queries do not re-download the same row groups.
286
- # 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.
287
291
  cache_size = settings.SUPERTABLE_DUCKDB_EXTERNAL_CACHE_SIZE
288
- if cache_size:
292
+ can_cap = "external_file_cache_max_size" in supported
293
+ if cache_size and can_cap:
289
294
  set_if_supported("enable_external_file_cache", "true")
290
295
  set_if_supported("external_file_cache_max_size", f"'{cache_size}'")
291
296
  cache_dir_raw = settings.SUPERTABLE_DUCKDB_EXTERNAL_CACHE_DIR
@@ -301,6 +306,15 @@ def configure_httpfs_and_s3(
301
306
  + (f" size={cache_size}" if cache_size else "")
302
307
  + f", dir={cache_dir}"
303
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
+ )
304
318
 
305
319
 
306
320
  # =========================================================
@@ -554,6 +568,25 @@ def rewrite_query_with_hashed_tables(
554
568
  # Connection initialization
555
569
  # =========================================================
556
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
+
557
590
  def init_connection(
558
591
  con: duckdb.DuckDBPyConnection,
559
592
  temp_dir: str,
@@ -578,8 +611,18 @@ def init_connection(
578
611
  # Resolve memory limit.
579
612
  # Single env var SUPERTABLE_DUCKDB_MEMORY_LIMIT controls both executors.
580
613
  # The `memory_limit` argument is the caller's fallback when the env var is absent.
581
- effective_memory_limit = settings.SUPERTABLE_DUCKDB_MEMORY_LIMIT or memory_limit
582
- con.execute(f"PRAGMA memory_limit='{effective_memory_limit}';")
614
+ # normalize_memory_size guarantees a unit-suffixed value so DuckDB's parser
615
+ # never rejects a bare number (e.g. a UI-supplied "2" -> "2GB").
616
+ effective_memory_limit = normalize_memory_size(
617
+ settings.SUPERTABLE_DUCKDB_MEMORY_LIMIT or memory_limit, default="1GB"
618
+ )
619
+ try:
620
+ con.execute(f"PRAGMA memory_limit='{effective_memory_limit}';")
621
+ except Exception as e:
622
+ logger.warning(
623
+ f"[duckdb.init] memory_limit='{effective_memory_limit}' rejected: {e}; "
624
+ f"keeping DuckDB default"
625
+ )
583
626
 
584
627
  # Absolute temp path is required for DuckDB to actually spill to disk.
585
628
  # Prefer a path rooted under the app home (~/supertable), which is
@@ -605,6 +648,17 @@ def init_connection(
605
648
  except Exception:
606
649
  pass # older DuckDB builds may not support this setting
607
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
+
608
662
  # Thread count.
609
663
  # If SUPERTABLE_DUCKDB_THREADS is set explicitly, honour it exactly.
610
664
  # Otherwise derive from the effective memory limit using the IO-thread
@@ -647,11 +701,14 @@ def apply_runtime_pragmas(con: duckdb.DuckDBPyConnection, cfg) -> None:
647
701
  if cfg is None:
648
702
  return
649
703
 
650
- memory_limit = (cfg.duckdb_memory_limit or "").strip() or "1GB"
704
+ # normalize_memory_size guarantees a unit (UI sends a bare "2" -> "2GB");
705
+ # a rejected value is logged rather than silently swallowed so a bad
706
+ # config can never quietly leave the connection at the wrong limit.
707
+ memory_limit = normalize_memory_size(cfg.duckdb_memory_limit, default="1GB")
651
708
  try:
652
709
  con.execute(f"PRAGMA memory_limit='{memory_limit}';")
653
- except Exception:
654
- pass
710
+ except Exception as e:
711
+ logger.warning(f"[duckdb.pragma] memory_limit='{memory_limit}' rejected: {e}")
655
712
 
656
713
  # Explicit thread count wins; otherwise derive from the live memory limit
657
714
  # and IO multiplier (same formula as init_connection).
@@ -671,15 +728,296 @@ def apply_runtime_pragmas(con: duckdb.DuckDBPyConnection, cfg) -> None:
671
728
  except Exception:
672
729
  pass
673
730
 
674
- cache_size = (cfg.duckdb_external_cache_size or "").strip()
675
- if cache_size:
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.
735
+ cache_size = normalize_memory_size(cfg.duckdb_external_cache_size, default="")
736
+ if cache_size and _external_file_cache_cappable(con):
676
737
  try:
677
738
  con.execute("SET enable_external_file_cache=true;")
678
- con.execute(f"SET external_file_cache_max_size='{sanitize_sql_string(cache_size)}';")
739
+ con.execute(
740
+ f"SET external_file_cache_max_size='{sanitize_sql_string(cache_size)}';"
741
+ )
742
+ except Exception as e:
743
+ logger.warning(f"[duckdb.pragma] external file cache config failed: {e}")
744
+ else:
745
+ try:
746
+ con.execute("SET enable_external_file_cache=false;")
679
747
  except Exception:
680
748
  pass
681
749
 
682
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
897
+ except Exception as 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
+ }
1019
+
1020
+
683
1021
  # =========================================================
684
1022
  # RBAC view creation
685
1023
  # =========================================================
@@ -58,14 +58,57 @@ defaults, so behaviour is identical whether a value flows through ``settings``
58
58
  from __future__ import annotations
59
59
 
60
60
  import os
61
+ import re
61
62
  from dataclasses import dataclass
62
63
  from typing import Any, Dict, Optional, Tuple
63
64
 
64
65
 
66
+ # Optional decimal number followed by an optional byte unit (decimal KB/MB/GB/TB
67
+ # or binary KiB/MiB/GiB/TiB), case-insensitive, whitespace-tolerant.
68
+ _MEM_SIZE_RE = re.compile(r"^(\d+(?:\.\d+)?)\s*([KMGT]i?B)?$", re.IGNORECASE)
69
+
70
+
71
+ def normalize_memory_size(value: Any, default: str = "1GB", *, bare_unit: str = "GB") -> str:
72
+ """Coerce a memory-size value into a DuckDB-valid, unit-suffixed string.
73
+
74
+ The engine UI collects a bare integer (interpreted as ``bare_unit`` — GB),
75
+ while env vars / older Redis docs may already carry a unit. DuckDB's
76
+ ``PRAGMA memory_limit`` parser rejects a unit-less number
77
+ (``Unknown unit for memory: ''``), so this guarantees a value the parser
78
+ accepts before it is ever sent:
79
+
80
+ * bare number -> ``"<n>GB"`` ("2" -> "2GB")
81
+ * number + unit -> canonical casing ("2gib" -> "2GiB")
82
+ * empty / None -> ``default`` (""/None -> default)
83
+ * non-positive / garbage -> ``default`` ("0", "x" -> default)
84
+
85
+ ``default`` is returned verbatim, so pass ``""`` when "unset" is the
86
+ desired fallback (e.g. the external file cache, where empty == disabled).
87
+ """
88
+ s = ("" if value is None else str(value)).strip()
89
+ if not s:
90
+ return default
91
+ m = _MEM_SIZE_RE.match(s)
92
+ if not m:
93
+ return default
94
+ num, unit = m.group(1), m.group(2)
95
+ try:
96
+ if float(num) <= 0:
97
+ return default
98
+ except ValueError:
99
+ return default
100
+ if unit:
101
+ u = unit.upper()
102
+ unit = (u[0] + "iB") if u.endswith("IB") else u
103
+ else:
104
+ unit = bare_unit
105
+ return f"{num}{unit}"
106
+
107
+
65
108
  # Shared auto-pick thresholds: key → (env var, built-in default as string).
66
109
  _SHARED_SPEC: Dict[str, Tuple[str, str]] = {
67
110
  "engine_lite_max_bytes": ("SUPERTABLE_ENGINE_LITE_MAX_BYTES", str(100 * 1024 * 1024)),
68
- "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)),
69
112
  "engine_freshness_sec": ("SUPERTABLE_ENGINE_FRESHNESS_SEC", "300"),
70
113
  }
71
114
 
@@ -166,13 +209,13 @@ def _build_runtime(redis_cfg: Dict[str, Any], engine: str) -> EngineRuntimeConfi
166
209
 
167
210
  return EngineRuntimeConfig(
168
211
  engine_lite_max_bytes=_to_int(sv["engine_lite_max_bytes"], 100 * 1024 * 1024),
169
- 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),
170
213
  engine_freshness_sec=_to_int(sv["engine_freshness_sec"], 300),
171
- duckdb_memory_limit=dv["duckdb_memory_limit"].strip() or "1GB",
214
+ duckdb_memory_limit=normalize_memory_size(dv["duckdb_memory_limit"], default="1GB"),
172
215
  duckdb_io_multiplier=_to_float(dv["duckdb_io_multiplier"], 3.0),
173
216
  duckdb_threads=(threads if threads > 0 else None),
174
217
  duckdb_http_timeout=(http if http > 0 else None),
175
- duckdb_external_cache_size=dv["duckdb_external_cache_size"].strip(),
218
+ duckdb_external_cache_size=normalize_memory_size(dv["duckdb_external_cache_size"], default=""),
176
219
  )
177
220
 
178
221