supertable 2.2.6__tar.gz → 2.2.7__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.7}/PKG-INFO +1 -1
  2. {supertable-2.2.6 → supertable-2.2.7}/pyproject.toml +1 -1
  3. {supertable-2.2.6 → supertable-2.2.7}/setup.py +1 -1
  4. {supertable-2.2.6 → supertable-2.2.7}/supertable/__init__.py +1 -1
  5. {supertable-2.2.6 → supertable-2.2.7}/supertable/data_reader.py +33 -2
  6. {supertable-2.2.6 → supertable-2.2.7}/supertable/engine/engine_common.py +42 -9
  7. {supertable-2.2.6 → supertable-2.2.7}/supertable/engine/engine_config.py +45 -2
  8. {supertable-2.2.6 → supertable-2.2.7}/supertable/engine/executor.py +47 -1
  9. supertable-2.2.7/supertable/engine/tests/test_engine_config.py +81 -0
  10. supertable-2.2.7/supertable/engine/tests/test_engine_routing.py +110 -0
  11. supertable-2.2.7/supertable/engine/tests/test_engine_spill.py +183 -0
  12. {supertable-2.2.6 → supertable-2.2.7}/supertable/monitoring_writer.py +49 -4
  13. {supertable-2.2.6 → supertable-2.2.7}/supertable/redis_catalog.py +17 -0
  14. {supertable-2.2.6 → supertable-2.2.7}/supertable/tests/test_data_reader.py +30 -0
  15. {supertable-2.2.6 → supertable-2.2.7}/supertable/tests/test_monitoring_partitions.py +82 -0
  16. {supertable-2.2.6 → supertable-2.2.7/supertable.egg-info}/PKG-INFO +1 -1
  17. {supertable-2.2.6 → supertable-2.2.7}/supertable.egg-info/SOURCES.txt +3 -0
  18. {supertable-2.2.6 → supertable-2.2.7}/LICENSE +0 -0
  19. {supertable-2.2.6 → supertable-2.2.7}/README.md +0 -0
  20. {supertable-2.2.6 → supertable-2.2.7}/requirements.txt +0 -0
  21. {supertable-2.2.6 → supertable-2.2.7}/setup.cfg +0 -0
  22. {supertable-2.2.6 → supertable-2.2.7}/supertable/audit/__init__.py +0 -0
  23. {supertable-2.2.6 → supertable-2.2.7}/supertable/audit/admin.py +0 -0
  24. {supertable-2.2.6 → supertable-2.2.7}/supertable/audit/chain.py +0 -0
  25. {supertable-2.2.6 → supertable-2.2.7}/supertable/audit/consumers.py +0 -0
  26. {supertable-2.2.6 → supertable-2.2.7}/supertable/audit/crypto.py +0 -0
  27. {supertable-2.2.6 → supertable-2.2.7}/supertable/audit/events.py +0 -0
  28. {supertable-2.2.6 → supertable-2.2.7}/supertable/audit/export.py +0 -0
  29. {supertable-2.2.6 → supertable-2.2.7}/supertable/audit/logger.py +0 -0
  30. {supertable-2.2.6 → supertable-2.2.7}/supertable/audit/middleware.py +0 -0
  31. {supertable-2.2.6 → supertable-2.2.7}/supertable/audit/reader.py +0 -0
  32. {supertable-2.2.6 → supertable-2.2.7}/supertable/audit/retention.py +0 -0
  33. {supertable-2.2.6 → supertable-2.2.7}/supertable/audit/tests/__init__.py +0 -0
  34. {supertable-2.2.6 → supertable-2.2.7}/supertable/audit/tests/test_chain.py +0 -0
  35. {supertable-2.2.6 → supertable-2.2.7}/supertable/audit/tests/test_crypto.py +0 -0
  36. {supertable-2.2.6 → supertable-2.2.7}/supertable/audit/tests/test_emit.py +0 -0
  37. {supertable-2.2.6 → supertable-2.2.7}/supertable/audit/tests/test_events.py +0 -0
  38. {supertable-2.2.6 → supertable-2.2.7}/supertable/audit/tests/test_retention.py +0 -0
  39. {supertable-2.2.6 → supertable-2.2.7}/supertable/audit/writer_parquet.py +0 -0
  40. {supertable-2.2.6 → supertable-2.2.7}/supertable/audit/writer_redis.py +0 -0
  41. {supertable-2.2.6 → supertable-2.2.7}/supertable/config/__init__.py +0 -0
  42. {supertable-2.2.6 → supertable-2.2.7}/supertable/config/defaults.py +0 -0
  43. {supertable-2.2.6 → supertable-2.2.7}/supertable/config/homedir.py +0 -0
  44. {supertable-2.2.6 → supertable-2.2.7}/supertable/config/settings.py +0 -0
  45. {supertable-2.2.6 → supertable-2.2.7}/supertable/config/tests/__init__.py +0 -0
  46. {supertable-2.2.6 → supertable-2.2.7}/supertable/config/tests/test_defaults.py +0 -0
  47. {supertable-2.2.6 → supertable-2.2.7}/supertable/config/tests/test_homedir.py +0 -0
  48. {supertable-2.2.6 → supertable-2.2.7}/supertable/config/tests/test_settings.py +0 -0
  49. {supertable-2.2.6 → supertable-2.2.7}/supertable/data_classes.py +0 -0
  50. {supertable-2.2.6 → supertable-2.2.7}/supertable/data_writer.py +0 -0
  51. {supertable-2.2.6 → supertable-2.2.7}/supertable/demo/__init__.py +0 -0
  52. {supertable-2.2.6 → supertable-2.2.7}/supertable/demo/quickstart/__init__.py +0 -0
  53. {supertable-2.2.6 → supertable-2.2.7}/supertable/demo/quickstart/__main__.py +0 -0
  54. {supertable-2.2.6 → supertable-2.2.7}/supertable/demo/quickstart/check_filter_builder.py +0 -0
  55. {supertable-2.2.6 → supertable-2.2.7}/supertable/demo/quickstart/controller.py +0 -0
  56. {supertable-2.2.6 → supertable-2.2.7}/supertable/demo/quickstart/data_writer_helpers.py +0 -0
  57. {supertable-2.2.6 → supertable-2.2.7}/supertable/demo/quickstart/defaults.py +0 -0
  58. {supertable-2.2.6 → supertable-2.2.7}/supertable/demo/quickstart/dummy_data.py +0 -0
  59. {supertable-2.2.6 → supertable-2.2.7}/supertable/demo/quickstart/read_parquet_header.py +0 -0
  60. {supertable-2.2.6 → supertable-2.2.7}/supertable/demo/quickstart/s01_01_01_create_super_table.py +0 -0
  61. {supertable-2.2.6 → supertable-2.2.7}/supertable/demo/quickstart/s01_01_02_enable_mirroring_formats.py +0 -0
  62. {supertable-2.2.6 → supertable-2.2.7}/supertable/demo/quickstart/s01_02_create_roles.py +0 -0
  63. {supertable-2.2.6 → supertable-2.2.7}/supertable/demo/quickstart/s01_03_create_users.py +0 -0
  64. {supertable-2.2.6 → supertable-2.2.7}/supertable/demo/quickstart/s02_01_write_dummy_data.py +0 -0
  65. {supertable-2.2.6 → supertable-2.2.7}/supertable/demo/quickstart/s02_02_write_single_data.py +0 -0
  66. {supertable-2.2.6 → supertable-2.2.7}/supertable/demo/quickstart/s02_03_01_write_staging.py +0 -0
  67. {supertable-2.2.6 → supertable-2.2.7}/supertable/demo/quickstart/s02_03_02_create_pipe.py +0 -0
  68. {supertable-2.2.6 → supertable-2.2.7}/supertable/demo/quickstart/s02_04_01_write_monitoring_simple.py +0 -0
  69. {supertable-2.2.6 → supertable-2.2.7}/supertable/demo/quickstart/s02_04_02_write_monitoring_parallel.py +0 -0
  70. {supertable-2.2.6 → supertable-2.2.7}/supertable/demo/quickstart/s02_05_write_tombstone.py +0 -0
  71. {supertable-2.2.6 → supertable-2.2.7}/supertable/demo/quickstart/s03_01_read_data_error.py +0 -0
  72. {supertable-2.2.6 → supertable-2.2.7}/supertable/demo/quickstart/s03_02_01_read_super_data_ok.py +0 -0
  73. {supertable-2.2.6 → supertable-2.2.7}/supertable/demo/quickstart/s03_02_02_read_table_data_ok.py +0 -0
  74. {supertable-2.2.6 → supertable-2.2.7}/supertable/demo/quickstart/s03_03_read_meta.py +0 -0
  75. {supertable-2.2.6 → supertable-2.2.7}/supertable/demo/quickstart/s03_04_read_staging.py +0 -0
  76. {supertable-2.2.6 → supertable-2.2.7}/supertable/demo/quickstart/s03_06_01_read_roles.py +0 -0
  77. {supertable-2.2.6 → supertable-2.2.7}/supertable/demo/quickstart/s03_06_02_read_user.py +0 -0
  78. {supertable-2.2.6 → supertable-2.2.7}/supertable/demo/quickstart/s03_07_01_estimate_read.py +0 -0
  79. {supertable-2.2.6 → supertable-2.2.7}/supertable/demo/quickstart/s03_07_02_estimate_files.py +0 -0
  80. {supertable-2.2.6 → supertable-2.2.7}/supertable/demo/quickstart/s03_08_read_snapshot_history.py +0 -0
  81. {supertable-2.2.6 → supertable-2.2.7}/supertable/demo/quickstart/s04_01_03_delete_pipe.py +0 -0
  82. {supertable-2.2.6 → supertable-2.2.7}/supertable/demo/quickstart/s05_01_delete_table.py +0 -0
  83. {supertable-2.2.6 → supertable-2.2.7}/supertable/demo/quickstart/s05_02_delete_super_table.py +0 -0
  84. {supertable-2.2.6 → supertable-2.2.7}/supertable/demo/webshop/__init__.py +0 -0
  85. {supertable-2.2.6 → supertable-2.2.7}/supertable/demo/webshop/core.py +0 -0
  86. {supertable-2.2.6 → supertable-2.2.7}/supertable/demo/webshop/defaults.py +0 -0
  87. {supertable-2.2.6 → supertable-2.2.7}/supertable/demo/webshop/generate.py +0 -0
  88. {supertable-2.2.6 → supertable-2.2.7}/supertable/demo/webshop/load.py +0 -0
  89. {supertable-2.2.6 → supertable-2.2.7}/supertable/demo/webshop/topup.py +0 -0
  90. {supertable-2.2.6 → supertable-2.2.7}/supertable/engine/__init__.py +0 -0
  91. {supertable-2.2.6 → supertable-2.2.7}/supertable/engine/data_estimator.py +0 -0
  92. {supertable-2.2.6 → supertable-2.2.7}/supertable/engine/duckdb_lite.py +0 -0
  93. {supertable-2.2.6 → supertable-2.2.7}/supertable/engine/duckdb_pro.py +0 -0
  94. {supertable-2.2.6 → supertable-2.2.7}/supertable/engine/engine_enum.py +0 -0
  95. {supertable-2.2.6 → supertable-2.2.7}/supertable/engine/plan_stats.py +0 -0
  96. {supertable-2.2.6 → supertable-2.2.7}/supertable/engine/spark_thrift.py +0 -0
  97. {supertable-2.2.6 → supertable-2.2.7}/supertable/engine/tests/__init__.py +0 -0
  98. {supertable-2.2.6 → supertable-2.2.7}/supertable/engine/tests/conftest.py +0 -0
  99. {supertable-2.2.6 → supertable-2.2.7}/supertable/engine/tests/test_dedup_read.py +0 -0
  100. {supertable-2.2.6 → supertable-2.2.7}/supertable/engine/tests/test_engine.py +0 -0
  101. {supertable-2.2.6 → supertable-2.2.7}/supertable/errors.py +0 -0
  102. {supertable-2.2.6 → supertable-2.2.7}/supertable/locking/__init__.py +0 -0
  103. {supertable-2.2.6 → supertable-2.2.7}/supertable/locking/benchmarks/__init__.py +0 -0
  104. {supertable-2.2.6 → supertable-2.2.7}/supertable/locking/benchmarks/benchmark_locking.py +0 -0
  105. {supertable-2.2.6 → supertable-2.2.7}/supertable/locking/benchmarks/measure_lock_speed.py +0 -0
  106. {supertable-2.2.6 → supertable-2.2.7}/supertable/locking/benchmarks/measure_lock_time.py +0 -0
  107. {supertable-2.2.6 → supertable-2.2.7}/supertable/locking/file_lock.py +0 -0
  108. {supertable-2.2.6 → supertable-2.2.7}/supertable/locking/redis_lock.py +0 -0
  109. {supertable-2.2.6 → supertable-2.2.7}/supertable/locking/tests/__init__.py +0 -0
  110. {supertable-2.2.6 → supertable-2.2.7}/supertable/locking/tests/test_file_lock.py +0 -0
  111. {supertable-2.2.6 → supertable-2.2.7}/supertable/locking/tests/test_redis_lock.py +0 -0
  112. {supertable-2.2.6 → supertable-2.2.7}/supertable/logging.py +0 -0
  113. {supertable-2.2.6 → supertable-2.2.7}/supertable/meta_reader.py +0 -0
  114. {supertable-2.2.6 → supertable-2.2.7}/supertable/mirroring/__init__.py +0 -0
  115. {supertable-2.2.6 → supertable-2.2.7}/supertable/mirroring/mirror_delta.py +0 -0
  116. {supertable-2.2.6 → supertable-2.2.7}/supertable/mirroring/mirror_formats.py +0 -0
  117. {supertable-2.2.6 → supertable-2.2.7}/supertable/mirroring/mirror_iceberg.py +0 -0
  118. {supertable-2.2.6 → supertable-2.2.7}/supertable/mirroring/mirror_parquet.py +0 -0
  119. {supertable-2.2.6 → supertable-2.2.7}/supertable/monitoring/__init__.py +0 -0
  120. {supertable-2.2.6 → supertable-2.2.7}/supertable/monitoring/partitions.py +0 -0
  121. {supertable-2.2.6 → supertable-2.2.7}/supertable/plan_extender.py +0 -0
  122. {supertable-2.2.6 → supertable-2.2.7}/supertable/processing.py +0 -0
  123. {supertable-2.2.6 → supertable-2.2.7}/supertable/query_plan_manager.py +0 -0
  124. {supertable-2.2.6 → supertable-2.2.7}/supertable/rbac/__init__.py +0 -0
  125. {supertable-2.2.6 → supertable-2.2.7}/supertable/rbac/access_control.py +0 -0
  126. {supertable-2.2.6 → supertable-2.2.7}/supertable/rbac/filter_builder.py +0 -0
  127. {supertable-2.2.6 → supertable-2.2.7}/supertable/rbac/permissions.py +0 -0
  128. {supertable-2.2.6 → supertable-2.2.7}/supertable/rbac/role_manager.py +0 -0
  129. {supertable-2.2.6 → supertable-2.2.7}/supertable/rbac/row_column_security.py +0 -0
  130. {supertable-2.2.6 → supertable-2.2.7}/supertable/rbac/tests/test_filter_builder.py +0 -0
  131. {supertable-2.2.6 → supertable-2.2.7}/supertable/rbac/tests/test_rbac.py +0 -0
  132. {supertable-2.2.6 → supertable-2.2.7}/supertable/rbac/tests/test_rbac_per_table.py +0 -0
  133. {supertable-2.2.6 → supertable-2.2.7}/supertable/rbac/user_manager.py +0 -0
  134. {supertable-2.2.6 → supertable-2.2.7}/supertable/redis_connector.py +0 -0
  135. {supertable-2.2.6 → supertable-2.2.7}/supertable/redis_infra.py +0 -0
  136. {supertable-2.2.6 → supertable-2.2.7}/supertable/redis_keys.py +0 -0
  137. {supertable-2.2.6 → supertable-2.2.7}/supertable/simple_table.py +0 -0
  138. {supertable-2.2.6 → supertable-2.2.7}/supertable/staging_area.py +0 -0
  139. {supertable-2.2.6 → supertable-2.2.7}/supertable/storage/__init__.py +0 -0
  140. {supertable-2.2.6 → supertable-2.2.7}/supertable/storage/azure_storage.py +0 -0
  141. {supertable-2.2.6 → supertable-2.2.7}/supertable/storage/gcp_storage.py +0 -0
  142. {supertable-2.2.6 → supertable-2.2.7}/supertable/storage/local_storage.py +0 -0
  143. {supertable-2.2.6 → supertable-2.2.7}/supertable/storage/minio_storage.py +0 -0
  144. {supertable-2.2.6 → supertable-2.2.7}/supertable/storage/s3_storage.py +0 -0
  145. {supertable-2.2.6 → supertable-2.2.7}/supertable/storage/storage_factory.py +0 -0
  146. {supertable-2.2.6 → supertable-2.2.7}/supertable/storage/storage_interface.py +0 -0
  147. {supertable-2.2.6 → supertable-2.2.7}/supertable/storage/tests/test_storage.py +0 -0
  148. {supertable-2.2.6 → supertable-2.2.7}/supertable/super_pipe.py +0 -0
  149. {supertable-2.2.6 → supertable-2.2.7}/supertable/super_table.py +0 -0
  150. {supertable-2.2.6 → supertable-2.2.7}/supertable/tests/__init__.py +0 -0
  151. {supertable-2.2.6 → supertable-2.2.7}/supertable/tests/test_align_to_schema_fix.py +0 -0
  152. {supertable-2.2.6 → supertable-2.2.7}/supertable/tests/test_create_if_missing.py +0 -0
  153. {supertable-2.2.6 → supertable-2.2.7}/supertable/tests/test_data_reader_preflight.py +0 -0
  154. {supertable-2.2.6 → supertable-2.2.7}/supertable/tests/test_data_writer.py +0 -0
  155. {supertable-2.2.6 → supertable-2.2.7}/supertable/tests/test_data_writer_compact.py +0 -0
  156. {supertable-2.2.6 → supertable-2.2.7}/supertable/tests/test_data_writer_comprehensive.py +0 -0
  157. {supertable-2.2.6 → supertable-2.2.7}/supertable/tests/test_data_writer_tombstones.py +0 -0
  158. {supertable-2.2.6 → supertable-2.2.7}/supertable/tests/test_dedup_on_read_write.py +0 -0
  159. {supertable-2.2.6 → supertable-2.2.7}/supertable/tests/test_errors.py +0 -0
  160. {supertable-2.2.6 → supertable-2.2.7}/supertable/tests/test_meta_reader.py +0 -0
  161. {supertable-2.2.6 → supertable-2.2.7}/supertable/tests/test_monitoring_sink_guard.py +0 -0
  162. {supertable-2.2.6 → supertable-2.2.7}/supertable/tests/test_newer_than.py +0 -0
  163. {supertable-2.2.6 → supertable-2.2.7}/supertable/tests/test_process_delete_only.py +0 -0
  164. {supertable-2.2.6 → supertable-2.2.7}/supertable/tests/test_processing.py +0 -0
  165. {supertable-2.2.6 → supertable-2.2.7}/supertable/tests/test_processing_compact_resources.py +0 -0
  166. {supertable-2.2.6 → supertable-2.2.7}/supertable/tests/test_query_sql.py +0 -0
  167. {supertable-2.2.6 → supertable-2.2.7}/supertable/tests/test_redis_key_prefix.py +0 -0
  168. {supertable-2.2.6 → supertable-2.2.7}/supertable/tests/test_simple_table.py +0 -0
  169. {supertable-2.2.6 → supertable-2.2.7}/supertable/tests/test_small_file_compaction.py +0 -0
  170. {supertable-2.2.6 → supertable-2.2.7}/supertable/tests/test_super_table.py +0 -0
  171. {supertable-2.2.6 → supertable-2.2.7}/supertable/tests/test_supertable_all.py +0 -0
  172. {supertable-2.2.6 → supertable-2.2.7}/supertable/utils/__init__.py +0 -0
  173. {supertable-2.2.6 → supertable-2.2.7}/supertable/utils/helper.py +0 -0
  174. {supertable-2.2.6 → supertable-2.2.7}/supertable/utils/profiler.py +0 -0
  175. {supertable-2.2.6 → supertable-2.2.7}/supertable/utils/sql_parser.py +0 -0
  176. {supertable-2.2.6 → supertable-2.2.7}/supertable/utils/tests/test_sql_parser_columns.py +0 -0
  177. {supertable-2.2.6 → supertable-2.2.7}/supertable/utils/timer.py +0 -0
  178. {supertable-2.2.6 → supertable-2.2.7}/supertable.egg-info/dependency_links.txt +0 -0
  179. {supertable-2.2.6 → supertable-2.2.7}/supertable.egg-info/entry_points.txt +0 -0
  180. {supertable-2.2.6 → supertable-2.2.7}/supertable.egg-info/requires.txt +0 -0
  181. {supertable-2.2.6 → supertable-2.2.7}/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.7
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.7"
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.7",
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.7"
29
29
 
30
30
  # Re-export the core public surface so users can do ``from supertable import …``
31
31
  # instead of remembering submodule paths.
@@ -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
 
@@ -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
  # =========================================================
@@ -578,8 +579,18 @@ def init_connection(
578
579
  # Resolve memory limit.
579
580
  # Single env var SUPERTABLE_DUCKDB_MEMORY_LIMIT controls both executors.
580
581
  # 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}';")
582
+ # normalize_memory_size guarantees a unit-suffixed value so DuckDB's parser
583
+ # never rejects a bare number (e.g. a UI-supplied "2" -> "2GB").
584
+ effective_memory_limit = normalize_memory_size(
585
+ settings.SUPERTABLE_DUCKDB_MEMORY_LIMIT or memory_limit, default="1GB"
586
+ )
587
+ try:
588
+ con.execute(f"PRAGMA memory_limit='{effective_memory_limit}';")
589
+ except Exception as e:
590
+ logger.warning(
591
+ f"[duckdb.init] memory_limit='{effective_memory_limit}' rejected: {e}; "
592
+ f"keeping DuckDB default"
593
+ )
583
594
 
584
595
  # Absolute temp path is required for DuckDB to actually spill to disk.
585
596
  # Prefer a path rooted under the app home (~/supertable), which is
@@ -647,11 +658,14 @@ def apply_runtime_pragmas(con: duckdb.DuckDBPyConnection, cfg) -> None:
647
658
  if cfg is None:
648
659
  return
649
660
 
650
- memory_limit = (cfg.duckdb_memory_limit or "").strip() or "1GB"
661
+ # normalize_memory_size guarantees a unit (UI sends a bare "2" -> "2GB");
662
+ # a rejected value is logged rather than silently swallowed so a bad
663
+ # config can never quietly leave the connection at the wrong limit.
664
+ memory_limit = normalize_memory_size(cfg.duckdb_memory_limit, default="1GB")
651
665
  try:
652
666
  con.execute(f"PRAGMA memory_limit='{memory_limit}';")
653
- except Exception:
654
- pass
667
+ except Exception as e:
668
+ logger.warning(f"[duckdb.pragma] memory_limit='{memory_limit}' rejected: {e}")
655
669
 
656
670
  # Explicit thread count wins; otherwise derive from the live memory limit
657
671
  # and IO multiplier (same formula as init_connection).
@@ -671,13 +685,32 @@ def apply_runtime_pragmas(con: duckdb.DuckDBPyConnection, cfg) -> None:
671
685
  except Exception:
672
686
  pass
673
687
 
674
- cache_size = (cfg.duckdb_external_cache_size or "").strip()
688
+ cache_size = normalize_memory_size(cfg.duckdb_external_cache_size, default="")
675
689
  if cache_size:
676
690
  try:
677
691
  con.execute("SET enable_external_file_cache=true;")
678
- con.execute(f"SET external_file_cache_max_size='{sanitize_sql_string(cache_size)}';")
679
- except Exception:
680
- pass
692
+ 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.
698
+ 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
+ )
712
+ except Exception as e:
713
+ logger.warning(f"[duckdb.pragma] external_file_cache_max_size failed: {e}")
681
714
 
682
715
 
683
716
  # =========================================================
@@ -58,10 +58,53 @@ 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)),
@@ -168,11 +211,11 @@ def _build_runtime(redis_cfg: Dict[str, Any], engine: str) -> EngineRuntimeConfi
168
211
  engine_lite_max_bytes=_to_int(sv["engine_lite_max_bytes"], 100 * 1024 * 1024),
169
212
  engine_spark_min_bytes=_to_int(sv["engine_spark_min_bytes"], 10 * 1024 * 1024 * 1024),
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
 
@@ -62,6 +62,47 @@ 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.
85
+ """
86
+ policy = cfg.engine_spark_min_bytes
87
+ catalog = self._get_catalog()
88
+ if catalog is None:
89
+ return policy
90
+ try:
91
+ clusters = catalog.list_spark_clusters(self.organization) or []
92
+ except Exception:
93
+ return policy
94
+ mins = []
95
+ for c in clusters:
96
+ if not isinstance(c, dict) or c.get("status") != "active":
97
+ continue
98
+ try:
99
+ mins.append(int(c.get("min_bytes", 0)))
100
+ except (TypeError, ValueError):
101
+ continue
102
+ if not mins:
103
+ return policy
104
+ return max(policy, min(mins))
105
+
65
106
  def _auto_pick(self, reflection: Reflection, cfg: EngineRuntimeConfig) -> Engine:
66
107
  """Select the best engine based on data size and freshness.
67
108
 
@@ -86,12 +127,17 @@ class Executor:
86
127
  SUPERTABLE_ENGINE_LITE_MAX_BYTES – upper bound for Lite (default 100 MB)
87
128
  SUPERTABLE_ENGINE_SPARK_MIN_BYTES – lower bound for Spark (default 10 GB)
88
129
  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.
89
135
  """
90
136
  bytes_total = reflection.reflection_bytes
91
137
 
92
138
  # --- thresholds (resolved live from org system config) ---
93
139
  lite_max = cfg.engine_lite_max_bytes
94
- spark_min = cfg.engine_spark_min_bytes
140
+ spark_min = self._spark_min_bytes(cfg)
95
141
  freshness_threshold_s = cfg.engine_freshness_sec
96
142
 
97
143
  # --- freshness: how long ago was the most recent snapshot updated ---
@@ -0,0 +1,81 @@
1
+ # supertable/engine/tests/test_engine_config.py
2
+ """Tests for supertable.engine.engine_config.
3
+
4
+ Focus: ``normalize_memory_size`` (the unit "extender") and that the resolver
5
+ emits unit-suffixed, DuckDB-parseable memory values regardless of how the UI /
6
+ Redis stored them. The core guarantee is that a bare number can never reach
7
+ ``PRAGMA memory_limit`` and raise ``Unknown unit for memory: ''``.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import duckdb
13
+ import pytest
14
+
15
+ from supertable.engine.engine_config import (
16
+ normalize_memory_size,
17
+ resolve_engine_configs,
18
+ )
19
+
20
+
21
+ class TestNormalizeMemorySize:
22
+ @pytest.mark.parametrize(
23
+ "value,expected",
24
+ [
25
+ ("2", "2GB"), # bare number -> GB
26
+ ("8", "8GB"),
27
+ ("50", "50GB"),
28
+ (2, "2GB"), # non-string input
29
+ ("2.5", "2.5GB"),
30
+ ("2GB", "2GB"), # already valid -> passthrough
31
+ ("2gb", "2GB"), # lowercase -> canonical
32
+ ("2gib", "2GiB"), # binary unit canonicalised
33
+ ("512mb", "512MB"),
34
+ (" 4 GB ", "4GB"), # whitespace tolerant
35
+ ],
36
+ )
37
+ def test_extends_and_canonicalises(self, value, expected):
38
+ assert normalize_memory_size(value) == expected
39
+
40
+ @pytest.mark.parametrize("value", ["", " ", None, "0", "-5", "abc", "GB", "1XB"])
41
+ def test_invalid_falls_back_to_default(self, value):
42
+ assert normalize_memory_size(value, default="1GB") == "1GB"
43
+
44
+ def test_empty_default_for_cache(self):
45
+ # Cache uses ""(=disabled) as the fallback rather than a size.
46
+ assert normalize_memory_size("", default="") == ""
47
+ assert normalize_memory_size("0", default="") == ""
48
+ assert normalize_memory_size("50", default="") == "50GB"
49
+
50
+ @pytest.mark.parametrize(
51
+ "raw", ["2", "8", "50", "0", "", None, "abc", "2gib", "512mb", "-1"]
52
+ )
53
+ def test_output_always_accepted_by_duckdb(self, raw):
54
+ """The whole point: the normalized value never raises a ParserException."""
55
+ con = duckdb.connect(":memory:")
56
+ normalized = normalize_memory_size(raw, default="1GB")
57
+ con.execute(f"PRAGMA memory_limit='{normalized}';") # must not raise
58
+ con.close()
59
+
60
+
61
+ class TestResolverNormalizes:
62
+ def test_bare_numbers_become_unit_suffixed(self):
63
+ # Simulates the exact Redis doc the user reported (bare 2 / 8 / 50).
64
+ stored = {
65
+ "lite": {"duckdb_memory_limit": "2", "duckdb_external_cache_size": "50"},
66
+ "pro": {"duckdb_memory_limit": "8", "duckdb_external_cache_size": "50"},
67
+ }
68
+
69
+ class _Catalog:
70
+ def get_engine_config(self, org):
71
+ return stored
72
+
73
+ cfgs = resolve_engine_configs("kladna-soft", _Catalog())
74
+ assert cfgs["lite"].duckdb_memory_limit == "2GB"
75
+ assert cfgs["pro"].duckdb_memory_limit == "8GB"
76
+ assert cfgs["lite"].duckdb_external_cache_size == "50GB"
77
+
78
+ # And the resolved values are all DuckDB-parseable.
79
+ con = duckdb.connect(":memory:")
80
+ con.execute(f"PRAGMA memory_limit='{cfgs['pro'].duckdb_memory_limit}';")
81
+ con.close()
@@ -0,0 +1,110 @@
1
+ # supertable/engine/tests/test_engine_routing.py
2
+ """Tests for the fleet-aware Spark routing boundary.
3
+
4
+ ``Executor._spark_min_bytes`` decides the byte size at which AUTO hands a query
5
+ to Spark. It couples the configured policy floor with the registered Thrift
6
+ fleet so the boundary is ``max(engine_spark_min_bytes, min(active min_bytes))``:
7
+
8
+ * the policy floor is never undercut (medium jobs can be held on DuckDB), and
9
+ * AUTO never routes a job below what any active cluster accepts (which would
10
+ make ``select_spark_cluster`` return nothing and hard-fail the query).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from supertable.engine.executor import Executor
16
+ from supertable.engine.engine_config import EngineRuntimeConfig
17
+
18
+ GIB = 1024 ** 3
19
+
20
+
21
+ def _cfg(spark_min_bytes: int) -> EngineRuntimeConfig:
22
+ return EngineRuntimeConfig(
23
+ engine_lite_max_bytes=100 * 1024 * 1024,
24
+ engine_spark_min_bytes=spark_min_bytes,
25
+ engine_freshness_sec=300,
26
+ duckdb_memory_limit="1GB",
27
+ duckdb_io_multiplier=3.0,
28
+ duckdb_threads=None,
29
+ duckdb_http_timeout=None,
30
+ duckdb_external_cache_size="",
31
+ )
32
+
33
+
34
+ class _Catalog:
35
+ def __init__(self, clusters):
36
+ self._clusters = clusters
37
+
38
+ def list_spark_clusters(self, org):
39
+ return self._clusters
40
+
41
+
42
+ class _RaisingCatalog:
43
+ def list_spark_clusters(self, org):
44
+ raise RuntimeError("redis down")
45
+
46
+
47
+ def _executor(catalog) -> Executor:
48
+ # Bypass __init__ (which builds a DuckDBLite); we only exercise the boundary.
49
+ e = Executor.__new__(Executor)
50
+ e._catalog = catalog
51
+ e.organization = "kladna-soft"
52
+ return e
53
+
54
+
55
+ def _active(min_bytes, max_bytes=0):
56
+ return {"status": "active", "min_bytes": min_bytes, "max_bytes": max_bytes}
57
+
58
+
59
+ def test_no_catalog_uses_policy_floor():
60
+ assert _executor(None)._spark_min_bytes(_cfg(10 * GIB)) == 10 * GIB
61
+
62
+
63
+ def test_empty_fleet_uses_policy_floor():
64
+ assert _executor(_Catalog([]))._spark_min_bytes(_cfg(10 * GIB)) == 10 * GIB
65
+
66
+
67
+ def test_catalog_error_falls_back_to_policy():
68
+ assert _executor(_RaisingCatalog())._spark_min_bytes(_cfg(10 * GIB)) == 10 * GIB
69
+
70
+
71
+ def test_fleet_min_below_policy_keeps_policy():
72
+ # Cluster accepts >=1 GiB, but the admin floor is 10 GiB: floor wins.
73
+ cat = _Catalog([_active(1 * GIB)])
74
+ assert _executor(cat)._spark_min_bytes(_cfg(10 * GIB)) == 10 * GIB
75
+
76
+
77
+ def test_fleet_min_above_policy_raises_boundary():
78
+ # No cluster will take a job under 20 GiB, so AUTO must not route below it.
79
+ cat = _Catalog([_active(20 * GIB)])
80
+ assert _executor(cat)._spark_min_bytes(_cfg(10 * GIB)) == 20 * GIB
81
+
82
+
83
+ def test_smallest_active_cluster_drives_the_fleet_min():
84
+ cat = _Catalog([_active(50 * GIB), _active(20 * GIB), _active(30 * GIB)])
85
+ # min active = 20 GiB; policy 10 GiB -> 20 GiB.
86
+ assert _executor(cat)._spark_min_bytes(_cfg(10 * GIB)) == 20 * GIB
87
+
88
+
89
+ def test_inactive_clusters_are_ignored():
90
+ cat = _Catalog([
91
+ {"status": "draining", "min_bytes": 1 * GIB, "max_bytes": 0},
92
+ {"status": "offline", "min_bytes": 2 * GIB, "max_bytes": 0},
93
+ ])
94
+ # No active cluster -> policy floor.
95
+ assert _executor(cat)._spark_min_bytes(_cfg(10 * GIB)) == 10 * GIB
96
+
97
+
98
+ def test_zero_policy_lets_fleet_drive():
99
+ # engine_spark_min_bytes=0 -> pure fleet-min behaviour.
100
+ cat = _Catalog([_active(1 * GIB), _active(4 * GIB)])
101
+ assert _executor(cat)._spark_min_bytes(_cfg(0)) == 1 * GIB
102
+
103
+
104
+ def test_malformed_min_bytes_is_skipped_not_fatal():
105
+ cat = _Catalog([
106
+ {"status": "active", "min_bytes": "not-a-number", "max_bytes": 0},
107
+ _active(7 * GIB),
108
+ ])
109
+ # Bad row skipped; good row drives the fleet min. policy 1 GiB -> 7 GiB.
110
+ assert _executor(cat)._spark_min_bytes(_cfg(1 * GIB)) == 7 * GIB