supertable 2.2.5__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 (182) hide show
  1. {supertable-2.2.5/supertable.egg-info → supertable-2.2.7}/PKG-INFO +1 -1
  2. {supertable-2.2.5 → supertable-2.2.7}/pyproject.toml +1 -1
  3. {supertable-2.2.5 → supertable-2.2.7}/setup.py +1 -1
  4. {supertable-2.2.5 → supertable-2.2.7}/supertable/__init__.py +1 -1
  5. {supertable-2.2.5 → supertable-2.2.7}/supertable/data_reader.py +33 -2
  6. {supertable-2.2.5 → supertable-2.2.7}/supertable/engine/engine_common.py +42 -9
  7. supertable-2.2.7/supertable/engine/engine_config.py +271 -0
  8. {supertable-2.2.5 → supertable-2.2.7}/supertable/engine/executor.py +57 -7
  9. {supertable-2.2.5 → supertable-2.2.7}/supertable/engine/spark_thrift.py +9 -3
  10. supertable-2.2.7/supertable/engine/tests/test_engine_config.py +81 -0
  11. supertable-2.2.7/supertable/engine/tests/test_engine_routing.py +110 -0
  12. supertable-2.2.7/supertable/engine/tests/test_engine_spill.py +183 -0
  13. {supertable-2.2.5 → supertable-2.2.7}/supertable/monitoring_writer.py +49 -4
  14. {supertable-2.2.5 → supertable-2.2.7}/supertable/redis_catalog.py +52 -27
  15. {supertable-2.2.5 → supertable-2.2.7}/supertable/redis_keys.py +21 -12
  16. {supertable-2.2.5 → supertable-2.2.7}/supertable/tests/test_data_reader.py +30 -0
  17. {supertable-2.2.5 → supertable-2.2.7}/supertable/tests/test_monitoring_partitions.py +82 -0
  18. {supertable-2.2.5 → supertable-2.2.7}/supertable/tests/test_redis_key_prefix.py +3 -3
  19. {supertable-2.2.5 → supertable-2.2.7/supertable.egg-info}/PKG-INFO +1 -1
  20. {supertable-2.2.5 → supertable-2.2.7}/supertable.egg-info/SOURCES.txt +3 -0
  21. supertable-2.2.5/supertable/engine/engine_config.py +0 -153
  22. {supertable-2.2.5 → supertable-2.2.7}/LICENSE +0 -0
  23. {supertable-2.2.5 → supertable-2.2.7}/README.md +0 -0
  24. {supertable-2.2.5 → supertable-2.2.7}/requirements.txt +0 -0
  25. {supertable-2.2.5 → supertable-2.2.7}/setup.cfg +0 -0
  26. {supertable-2.2.5 → supertable-2.2.7}/supertable/audit/__init__.py +0 -0
  27. {supertable-2.2.5 → supertable-2.2.7}/supertable/audit/admin.py +0 -0
  28. {supertable-2.2.5 → supertable-2.2.7}/supertable/audit/chain.py +0 -0
  29. {supertable-2.2.5 → supertable-2.2.7}/supertable/audit/consumers.py +0 -0
  30. {supertable-2.2.5 → supertable-2.2.7}/supertable/audit/crypto.py +0 -0
  31. {supertable-2.2.5 → supertable-2.2.7}/supertable/audit/events.py +0 -0
  32. {supertable-2.2.5 → supertable-2.2.7}/supertable/audit/export.py +0 -0
  33. {supertable-2.2.5 → supertable-2.2.7}/supertable/audit/logger.py +0 -0
  34. {supertable-2.2.5 → supertable-2.2.7}/supertable/audit/middleware.py +0 -0
  35. {supertable-2.2.5 → supertable-2.2.7}/supertable/audit/reader.py +0 -0
  36. {supertable-2.2.5 → supertable-2.2.7}/supertable/audit/retention.py +0 -0
  37. {supertable-2.2.5 → supertable-2.2.7}/supertable/audit/tests/__init__.py +0 -0
  38. {supertable-2.2.5 → supertable-2.2.7}/supertable/audit/tests/test_chain.py +0 -0
  39. {supertable-2.2.5 → supertable-2.2.7}/supertable/audit/tests/test_crypto.py +0 -0
  40. {supertable-2.2.5 → supertable-2.2.7}/supertable/audit/tests/test_emit.py +0 -0
  41. {supertable-2.2.5 → supertable-2.2.7}/supertable/audit/tests/test_events.py +0 -0
  42. {supertable-2.2.5 → supertable-2.2.7}/supertable/audit/tests/test_retention.py +0 -0
  43. {supertable-2.2.5 → supertable-2.2.7}/supertable/audit/writer_parquet.py +0 -0
  44. {supertable-2.2.5 → supertable-2.2.7}/supertable/audit/writer_redis.py +0 -0
  45. {supertable-2.2.5 → supertable-2.2.7}/supertable/config/__init__.py +0 -0
  46. {supertable-2.2.5 → supertable-2.2.7}/supertable/config/defaults.py +0 -0
  47. {supertable-2.2.5 → supertable-2.2.7}/supertable/config/homedir.py +0 -0
  48. {supertable-2.2.5 → supertable-2.2.7}/supertable/config/settings.py +0 -0
  49. {supertable-2.2.5 → supertable-2.2.7}/supertable/config/tests/__init__.py +0 -0
  50. {supertable-2.2.5 → supertable-2.2.7}/supertable/config/tests/test_defaults.py +0 -0
  51. {supertable-2.2.5 → supertable-2.2.7}/supertable/config/tests/test_homedir.py +0 -0
  52. {supertable-2.2.5 → supertable-2.2.7}/supertable/config/tests/test_settings.py +0 -0
  53. {supertable-2.2.5 → supertable-2.2.7}/supertable/data_classes.py +0 -0
  54. {supertable-2.2.5 → supertable-2.2.7}/supertable/data_writer.py +0 -0
  55. {supertable-2.2.5 → supertable-2.2.7}/supertable/demo/__init__.py +0 -0
  56. {supertable-2.2.5 → supertable-2.2.7}/supertable/demo/quickstart/__init__.py +0 -0
  57. {supertable-2.2.5 → supertable-2.2.7}/supertable/demo/quickstart/__main__.py +0 -0
  58. {supertable-2.2.5 → supertable-2.2.7}/supertable/demo/quickstart/check_filter_builder.py +0 -0
  59. {supertable-2.2.5 → supertable-2.2.7}/supertable/demo/quickstart/controller.py +0 -0
  60. {supertable-2.2.5 → supertable-2.2.7}/supertable/demo/quickstart/data_writer_helpers.py +0 -0
  61. {supertable-2.2.5 → supertable-2.2.7}/supertable/demo/quickstart/defaults.py +0 -0
  62. {supertable-2.2.5 → supertable-2.2.7}/supertable/demo/quickstart/dummy_data.py +0 -0
  63. {supertable-2.2.5 → supertable-2.2.7}/supertable/demo/quickstart/read_parquet_header.py +0 -0
  64. {supertable-2.2.5 → supertable-2.2.7}/supertable/demo/quickstart/s01_01_01_create_super_table.py +0 -0
  65. {supertable-2.2.5 → supertable-2.2.7}/supertable/demo/quickstart/s01_01_02_enable_mirroring_formats.py +0 -0
  66. {supertable-2.2.5 → supertable-2.2.7}/supertable/demo/quickstart/s01_02_create_roles.py +0 -0
  67. {supertable-2.2.5 → supertable-2.2.7}/supertable/demo/quickstart/s01_03_create_users.py +0 -0
  68. {supertable-2.2.5 → supertable-2.2.7}/supertable/demo/quickstart/s02_01_write_dummy_data.py +0 -0
  69. {supertable-2.2.5 → supertable-2.2.7}/supertable/demo/quickstart/s02_02_write_single_data.py +0 -0
  70. {supertable-2.2.5 → supertable-2.2.7}/supertable/demo/quickstart/s02_03_01_write_staging.py +0 -0
  71. {supertable-2.2.5 → supertable-2.2.7}/supertable/demo/quickstart/s02_03_02_create_pipe.py +0 -0
  72. {supertable-2.2.5 → supertable-2.2.7}/supertable/demo/quickstart/s02_04_01_write_monitoring_simple.py +0 -0
  73. {supertable-2.2.5 → supertable-2.2.7}/supertable/demo/quickstart/s02_04_02_write_monitoring_parallel.py +0 -0
  74. {supertable-2.2.5 → supertable-2.2.7}/supertable/demo/quickstart/s02_05_write_tombstone.py +0 -0
  75. {supertable-2.2.5 → supertable-2.2.7}/supertable/demo/quickstart/s03_01_read_data_error.py +0 -0
  76. {supertable-2.2.5 → supertable-2.2.7}/supertable/demo/quickstart/s03_02_01_read_super_data_ok.py +0 -0
  77. {supertable-2.2.5 → supertable-2.2.7}/supertable/demo/quickstart/s03_02_02_read_table_data_ok.py +0 -0
  78. {supertable-2.2.5 → supertable-2.2.7}/supertable/demo/quickstart/s03_03_read_meta.py +0 -0
  79. {supertable-2.2.5 → supertable-2.2.7}/supertable/demo/quickstart/s03_04_read_staging.py +0 -0
  80. {supertable-2.2.5 → supertable-2.2.7}/supertable/demo/quickstart/s03_06_01_read_roles.py +0 -0
  81. {supertable-2.2.5 → supertable-2.2.7}/supertable/demo/quickstart/s03_06_02_read_user.py +0 -0
  82. {supertable-2.2.5 → supertable-2.2.7}/supertable/demo/quickstart/s03_07_01_estimate_read.py +0 -0
  83. {supertable-2.2.5 → supertable-2.2.7}/supertable/demo/quickstart/s03_07_02_estimate_files.py +0 -0
  84. {supertable-2.2.5 → supertable-2.2.7}/supertable/demo/quickstart/s03_08_read_snapshot_history.py +0 -0
  85. {supertable-2.2.5 → supertable-2.2.7}/supertable/demo/quickstart/s04_01_03_delete_pipe.py +0 -0
  86. {supertable-2.2.5 → supertable-2.2.7}/supertable/demo/quickstart/s05_01_delete_table.py +0 -0
  87. {supertable-2.2.5 → supertable-2.2.7}/supertable/demo/quickstart/s05_02_delete_super_table.py +0 -0
  88. {supertable-2.2.5 → supertable-2.2.7}/supertable/demo/webshop/__init__.py +0 -0
  89. {supertable-2.2.5 → supertable-2.2.7}/supertable/demo/webshop/core.py +0 -0
  90. {supertable-2.2.5 → supertable-2.2.7}/supertable/demo/webshop/defaults.py +0 -0
  91. {supertable-2.2.5 → supertable-2.2.7}/supertable/demo/webshop/generate.py +0 -0
  92. {supertable-2.2.5 → supertable-2.2.7}/supertable/demo/webshop/load.py +0 -0
  93. {supertable-2.2.5 → supertable-2.2.7}/supertable/demo/webshop/topup.py +0 -0
  94. {supertable-2.2.5 → supertable-2.2.7}/supertable/engine/__init__.py +0 -0
  95. {supertable-2.2.5 → supertable-2.2.7}/supertable/engine/data_estimator.py +0 -0
  96. {supertable-2.2.5 → supertable-2.2.7}/supertable/engine/duckdb_lite.py +0 -0
  97. {supertable-2.2.5 → supertable-2.2.7}/supertable/engine/duckdb_pro.py +0 -0
  98. {supertable-2.2.5 → supertable-2.2.7}/supertable/engine/engine_enum.py +0 -0
  99. {supertable-2.2.5 → supertable-2.2.7}/supertable/engine/plan_stats.py +0 -0
  100. {supertable-2.2.5 → supertable-2.2.7}/supertable/engine/tests/__init__.py +0 -0
  101. {supertable-2.2.5 → supertable-2.2.7}/supertable/engine/tests/conftest.py +0 -0
  102. {supertable-2.2.5 → supertable-2.2.7}/supertable/engine/tests/test_dedup_read.py +0 -0
  103. {supertable-2.2.5 → supertable-2.2.7}/supertable/engine/tests/test_engine.py +0 -0
  104. {supertable-2.2.5 → supertable-2.2.7}/supertable/errors.py +0 -0
  105. {supertable-2.2.5 → supertable-2.2.7}/supertable/locking/__init__.py +0 -0
  106. {supertable-2.2.5 → supertable-2.2.7}/supertable/locking/benchmarks/__init__.py +0 -0
  107. {supertable-2.2.5 → supertable-2.2.7}/supertable/locking/benchmarks/benchmark_locking.py +0 -0
  108. {supertable-2.2.5 → supertable-2.2.7}/supertable/locking/benchmarks/measure_lock_speed.py +0 -0
  109. {supertable-2.2.5 → supertable-2.2.7}/supertable/locking/benchmarks/measure_lock_time.py +0 -0
  110. {supertable-2.2.5 → supertable-2.2.7}/supertable/locking/file_lock.py +0 -0
  111. {supertable-2.2.5 → supertable-2.2.7}/supertable/locking/redis_lock.py +0 -0
  112. {supertable-2.2.5 → supertable-2.2.7}/supertable/locking/tests/__init__.py +0 -0
  113. {supertable-2.2.5 → supertable-2.2.7}/supertable/locking/tests/test_file_lock.py +0 -0
  114. {supertable-2.2.5 → supertable-2.2.7}/supertable/locking/tests/test_redis_lock.py +0 -0
  115. {supertable-2.2.5 → supertable-2.2.7}/supertable/logging.py +0 -0
  116. {supertable-2.2.5 → supertable-2.2.7}/supertable/meta_reader.py +0 -0
  117. {supertable-2.2.5 → supertable-2.2.7}/supertable/mirroring/__init__.py +0 -0
  118. {supertable-2.2.5 → supertable-2.2.7}/supertable/mirroring/mirror_delta.py +0 -0
  119. {supertable-2.2.5 → supertable-2.2.7}/supertable/mirroring/mirror_formats.py +0 -0
  120. {supertable-2.2.5 → supertable-2.2.7}/supertable/mirroring/mirror_iceberg.py +0 -0
  121. {supertable-2.2.5 → supertable-2.2.7}/supertable/mirroring/mirror_parquet.py +0 -0
  122. {supertable-2.2.5 → supertable-2.2.7}/supertable/monitoring/__init__.py +0 -0
  123. {supertable-2.2.5 → supertable-2.2.7}/supertable/monitoring/partitions.py +0 -0
  124. {supertable-2.2.5 → supertable-2.2.7}/supertable/plan_extender.py +0 -0
  125. {supertable-2.2.5 → supertable-2.2.7}/supertable/processing.py +0 -0
  126. {supertable-2.2.5 → supertable-2.2.7}/supertable/query_plan_manager.py +0 -0
  127. {supertable-2.2.5 → supertable-2.2.7}/supertable/rbac/__init__.py +0 -0
  128. {supertable-2.2.5 → supertable-2.2.7}/supertable/rbac/access_control.py +0 -0
  129. {supertable-2.2.5 → supertable-2.2.7}/supertable/rbac/filter_builder.py +0 -0
  130. {supertable-2.2.5 → supertable-2.2.7}/supertable/rbac/permissions.py +0 -0
  131. {supertable-2.2.5 → supertable-2.2.7}/supertable/rbac/role_manager.py +0 -0
  132. {supertable-2.2.5 → supertable-2.2.7}/supertable/rbac/row_column_security.py +0 -0
  133. {supertable-2.2.5 → supertable-2.2.7}/supertable/rbac/tests/test_filter_builder.py +0 -0
  134. {supertable-2.2.5 → supertable-2.2.7}/supertable/rbac/tests/test_rbac.py +0 -0
  135. {supertable-2.2.5 → supertable-2.2.7}/supertable/rbac/tests/test_rbac_per_table.py +0 -0
  136. {supertable-2.2.5 → supertable-2.2.7}/supertable/rbac/user_manager.py +0 -0
  137. {supertable-2.2.5 → supertable-2.2.7}/supertable/redis_connector.py +0 -0
  138. {supertable-2.2.5 → supertable-2.2.7}/supertable/redis_infra.py +0 -0
  139. {supertable-2.2.5 → supertable-2.2.7}/supertable/simple_table.py +0 -0
  140. {supertable-2.2.5 → supertable-2.2.7}/supertable/staging_area.py +0 -0
  141. {supertable-2.2.5 → supertable-2.2.7}/supertable/storage/__init__.py +0 -0
  142. {supertable-2.2.5 → supertable-2.2.7}/supertable/storage/azure_storage.py +0 -0
  143. {supertable-2.2.5 → supertable-2.2.7}/supertable/storage/gcp_storage.py +0 -0
  144. {supertable-2.2.5 → supertable-2.2.7}/supertable/storage/local_storage.py +0 -0
  145. {supertable-2.2.5 → supertable-2.2.7}/supertable/storage/minio_storage.py +0 -0
  146. {supertable-2.2.5 → supertable-2.2.7}/supertable/storage/s3_storage.py +0 -0
  147. {supertable-2.2.5 → supertable-2.2.7}/supertable/storage/storage_factory.py +0 -0
  148. {supertable-2.2.5 → supertable-2.2.7}/supertable/storage/storage_interface.py +0 -0
  149. {supertable-2.2.5 → supertable-2.2.7}/supertable/storage/tests/test_storage.py +0 -0
  150. {supertable-2.2.5 → supertable-2.2.7}/supertable/super_pipe.py +0 -0
  151. {supertable-2.2.5 → supertable-2.2.7}/supertable/super_table.py +0 -0
  152. {supertable-2.2.5 → supertable-2.2.7}/supertable/tests/__init__.py +0 -0
  153. {supertable-2.2.5 → supertable-2.2.7}/supertable/tests/test_align_to_schema_fix.py +0 -0
  154. {supertable-2.2.5 → supertable-2.2.7}/supertable/tests/test_create_if_missing.py +0 -0
  155. {supertable-2.2.5 → supertable-2.2.7}/supertable/tests/test_data_reader_preflight.py +0 -0
  156. {supertable-2.2.5 → supertable-2.2.7}/supertable/tests/test_data_writer.py +0 -0
  157. {supertable-2.2.5 → supertable-2.2.7}/supertable/tests/test_data_writer_compact.py +0 -0
  158. {supertable-2.2.5 → supertable-2.2.7}/supertable/tests/test_data_writer_comprehensive.py +0 -0
  159. {supertable-2.2.5 → supertable-2.2.7}/supertable/tests/test_data_writer_tombstones.py +0 -0
  160. {supertable-2.2.5 → supertable-2.2.7}/supertable/tests/test_dedup_on_read_write.py +0 -0
  161. {supertable-2.2.5 → supertable-2.2.7}/supertable/tests/test_errors.py +0 -0
  162. {supertable-2.2.5 → supertable-2.2.7}/supertable/tests/test_meta_reader.py +0 -0
  163. {supertable-2.2.5 → supertable-2.2.7}/supertable/tests/test_monitoring_sink_guard.py +0 -0
  164. {supertable-2.2.5 → supertable-2.2.7}/supertable/tests/test_newer_than.py +0 -0
  165. {supertable-2.2.5 → supertable-2.2.7}/supertable/tests/test_process_delete_only.py +0 -0
  166. {supertable-2.2.5 → supertable-2.2.7}/supertable/tests/test_processing.py +0 -0
  167. {supertable-2.2.5 → supertable-2.2.7}/supertable/tests/test_processing_compact_resources.py +0 -0
  168. {supertable-2.2.5 → supertable-2.2.7}/supertable/tests/test_query_sql.py +0 -0
  169. {supertable-2.2.5 → supertable-2.2.7}/supertable/tests/test_simple_table.py +0 -0
  170. {supertable-2.2.5 → supertable-2.2.7}/supertable/tests/test_small_file_compaction.py +0 -0
  171. {supertable-2.2.5 → supertable-2.2.7}/supertable/tests/test_super_table.py +0 -0
  172. {supertable-2.2.5 → supertable-2.2.7}/supertable/tests/test_supertable_all.py +0 -0
  173. {supertable-2.2.5 → supertable-2.2.7}/supertable/utils/__init__.py +0 -0
  174. {supertable-2.2.5 → supertable-2.2.7}/supertable/utils/helper.py +0 -0
  175. {supertable-2.2.5 → supertable-2.2.7}/supertable/utils/profiler.py +0 -0
  176. {supertable-2.2.5 → supertable-2.2.7}/supertable/utils/sql_parser.py +0 -0
  177. {supertable-2.2.5 → supertable-2.2.7}/supertable/utils/tests/test_sql_parser_columns.py +0 -0
  178. {supertable-2.2.5 → supertable-2.2.7}/supertable/utils/timer.py +0 -0
  179. {supertable-2.2.5 → supertable-2.2.7}/supertable.egg-info/dependency_links.txt +0 -0
  180. {supertable-2.2.5 → supertable-2.2.7}/supertable.egg-info/entry_points.txt +0 -0
  181. {supertable-2.2.5 → supertable-2.2.7}/supertable.egg-info/requires.txt +0 -0
  182. {supertable-2.2.5 → 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.5
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.5"
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.5",
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.5"
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
  # =========================================================
@@ -0,0 +1,271 @@
1
+ # route: supertable.engine.engine_config
2
+ """
3
+ Engine runtime configuration — single source of truth for resolution.
4
+
5
+ Engine config is stored once per organization at **system scope**
6
+ (``supertable:{org}:system:engine:duckdb``). It is global to the org, not
7
+ per-supertable, and is resolved **live on every query** (no caching) so that
8
+ changes made in the UI take effect on the next query without a restart or a
9
+ connection reset.
10
+
11
+ Two kinds of settings live in that document:
12
+
13
+ * **Shared auto-pick thresholds** (one value, used by the router):
14
+ ``engine_lite_max_bytes``, ``engine_spark_min_bytes``,
15
+ ``engine_freshness_sec``. These describe the *boundaries* between engines
16
+ so they cannot be per-engine.
17
+ * **Per-engine DuckDB runtime pragmas** (separate values for Lite and Pro):
18
+ ``duckdb_memory_limit``, ``duckdb_io_multiplier``, ``duckdb_threads``,
19
+ ``duckdb_http_timeout``, ``duckdb_external_cache_size``. Each DuckDB engine
20
+ carries its own copy under a ``"lite"`` / ``"pro"`` section, so tuning Lite
21
+ never changes Pro and vice-versa.
22
+
23
+ Stored document shape::
24
+
25
+ {
26
+ "engine_lite_max_bytes": "104857600",
27
+ "engine_spark_min_bytes": "10737418240",
28
+ "engine_freshness_sec": "300",
29
+ "lite": {"duckdb_memory_limit": "1GB", ...},
30
+ "pro": {"duckdb_memory_limit": "8GB", ...},
31
+ "modified_ms": 1750000000000
32
+ }
33
+
34
+ Resolution precedence, per field::
35
+
36
+ Redis (org system config) → environment variable → built-in default
37
+
38
+ The environment variable is a single global fallback shared by both engines;
39
+ per-engine differentiation therefore only happens once a value is stored in
40
+ Redis (via the UI).
41
+
42
+ Entry points (all share that precedence so engine and UI never disagree):
43
+
44
+ * :func:`resolve_engine_configs` → ``{"lite": cfg, "pro": cfg}`` in a
45
+ single Redis read; used by the
46
+ executor.
47
+ * :func:`resolve_engine_config` → typed :class:`EngineRuntimeConfig`
48
+ for one engine (convenience).
49
+ * :func:`resolve_engine_config_provenance` → ``{shared, lite, pro,
50
+ modified_ms}`` per-field
51
+ ``{value, source, env_var,
52
+ default}`` for the API / UI.
53
+
54
+ Built-in defaults mirror the :mod:`supertable.config.settings` dataclass
55
+ defaults, so behaviour is identical whether a value flows through ``settings``
56
+ (no Redis override) or through this resolver.
57
+ """
58
+ from __future__ import annotations
59
+
60
+ import os
61
+ import re
62
+ from dataclasses import dataclass
63
+ from typing import Any, Dict, Optional, Tuple
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
+
108
+ # Shared auto-pick thresholds: key → (env var, built-in default as string).
109
+ _SHARED_SPEC: Dict[str, Tuple[str, str]] = {
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)),
112
+ "engine_freshness_sec": ("SUPERTABLE_ENGINE_FRESHNESS_SEC", "300"),
113
+ }
114
+
115
+ # Per-engine DuckDB runtime pragmas: key → (env var, built-in default as string).
116
+ # Defaults mirror the settings dataclass defaults in supertable/config/settings.py.
117
+ _DUCKDB_SPEC: Dict[str, Tuple[str, str]] = {
118
+ "duckdb_memory_limit": ("SUPERTABLE_DUCKDB_MEMORY_LIMIT", "1GB"),
119
+ "duckdb_io_multiplier": ("SUPERTABLE_DUCKDB_IO_MULTIPLIER", "3"),
120
+ "duckdb_threads": ("SUPERTABLE_DUCKDB_THREADS", ""),
121
+ "duckdb_http_timeout": ("SUPERTABLE_DUCKDB_HTTP_TIMEOUT", ""),
122
+ "duckdb_external_cache_size": ("SUPERTABLE_DUCKDB_EXTERNAL_CACHE_SIZE", "5GB"),
123
+ }
124
+
125
+ # The two DuckDB engines that each carry their own runtime section.
126
+ DUCKDB_ENGINES: Tuple[str, ...] = ("lite", "pro")
127
+
128
+ # Public whitelists (for callers / catalog / tests).
129
+ SHARED_CONFIG_FIELDS: Tuple[str, ...] = tuple(_SHARED_SPEC.keys())
130
+ DUCKDB_CONFIG_FIELDS: Tuple[str, ...] = tuple(_DUCKDB_SPEC.keys())
131
+ ENGINE_CONFIG_FIELDS: Tuple[str, ...] = SHARED_CONFIG_FIELDS + DUCKDB_CONFIG_FIELDS
132
+
133
+
134
+ @dataclass(frozen=True)
135
+ class EngineRuntimeConfig:
136
+ """Effective, typed config for one DuckDB engine resolution.
137
+
138
+ Carries the shared auto-pick thresholds (identical across engines) plus the
139
+ DuckDB session pragmas for the engine it was resolved for.
140
+ """
141
+
142
+ # auto-pick thresholds (shared)
143
+ engine_lite_max_bytes: int
144
+ engine_spark_min_bytes: int
145
+ engine_freshness_sec: int
146
+
147
+ # duckdb session pragmas (per-engine, re-applied live per query)
148
+ duckdb_memory_limit: str # e.g. "1GB"
149
+ duckdb_io_multiplier: float
150
+ duckdb_threads: Optional[int] # None → auto-derive from memory limit
151
+ duckdb_http_timeout: Optional[int] # seconds; None → leave DuckDB default
152
+ duckdb_external_cache_size: str # "" → external file cache disabled
153
+
154
+
155
+ def _redis_cfg(org: str, catalog: Optional[Any]) -> Dict[str, Any]:
156
+ """Read the stored engine config for ``org`` (best effort).
157
+
158
+ Returns an empty dict when no catalog is supplied or Redis is unreachable,
159
+ so the resolver degrades cleanly to env vars + defaults.
160
+ """
161
+ if not (org and catalog is not None):
162
+ return {}
163
+ try:
164
+ return catalog.get_engine_config(org) or {}
165
+ except Exception:
166
+ return {}
167
+
168
+
169
+ def _effective(stored: Dict[str, Any], key: str, spec: Dict[str, Tuple[str, str]]) -> Tuple[str, str]:
170
+ """Return ``(value, source)`` for one field. source ∈ redis|env|default.
171
+
172
+ ``stored`` is the dict the value is looked up in directly: the top-level
173
+ document for shared fields, or the ``lite`` / ``pro`` sub-section for
174
+ per-engine fields.
175
+ """
176
+ env_var, default = spec[key]
177
+ redis_val = stored.get(key) if isinstance(stored, dict) else None
178
+ if redis_val is not None and str(redis_val).strip() != "":
179
+ return str(redis_val).strip(), "redis"
180
+ env_val = (os.getenv(env_var) or "").strip()
181
+ if env_val:
182
+ return env_val, "env"
183
+ return default, "default"
184
+
185
+
186
+ def _to_int(s: Any, fallback: int) -> int:
187
+ try:
188
+ return int(str(s).strip())
189
+ except (TypeError, ValueError):
190
+ return fallback
191
+
192
+
193
+ def _to_float(s: Any, fallback: float) -> float:
194
+ try:
195
+ return float(str(s).strip())
196
+ except (TypeError, ValueError):
197
+ return fallback
198
+
199
+
200
+ def _build_runtime(redis_cfg: Dict[str, Any], engine: str) -> EngineRuntimeConfig:
201
+ """Build the typed config for ``engine`` ("lite"/"pro") from a stored doc."""
202
+ section = redis_cfg.get(engine) if isinstance(redis_cfg.get(engine), dict) else {}
203
+
204
+ sv = {k: _effective(redis_cfg, k, _SHARED_SPEC)[0] for k in _SHARED_SPEC}
205
+ dv = {k: _effective(section, k, _DUCKDB_SPEC)[0] for k in _DUCKDB_SPEC}
206
+
207
+ threads = _to_int(dv["duckdb_threads"], 0) if dv["duckdb_threads"] else 0
208
+ http = _to_int(dv["duckdb_http_timeout"], 0) if dv["duckdb_http_timeout"] else 0
209
+
210
+ return EngineRuntimeConfig(
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),
213
+ engine_freshness_sec=_to_int(sv["engine_freshness_sec"], 300),
214
+ duckdb_memory_limit=normalize_memory_size(dv["duckdb_memory_limit"], default="1GB"),
215
+ duckdb_io_multiplier=_to_float(dv["duckdb_io_multiplier"], 3.0),
216
+ duckdb_threads=(threads if threads > 0 else None),
217
+ duckdb_http_timeout=(http if http > 0 else None),
218
+ duckdb_external_cache_size=normalize_memory_size(dv["duckdb_external_cache_size"], default=""),
219
+ )
220
+
221
+
222
+ def resolve_engine_configs(org: str, catalog: Optional[Any] = None) -> Dict[str, EngineRuntimeConfig]:
223
+ """Resolve both DuckDB engines in a single Redis read.
224
+
225
+ Returns ``{"lite": EngineRuntimeConfig, "pro": EngineRuntimeConfig}``. The
226
+ shared thresholds are identical in both; only the DuckDB pragmas differ.
227
+ """
228
+ cfg = _redis_cfg(org, catalog)
229
+ return {e: _build_runtime(cfg, e) for e in DUCKDB_ENGINES}
230
+
231
+
232
+ def resolve_engine_config(org: str, catalog: Optional[Any] = None, engine: str = "lite") -> EngineRuntimeConfig:
233
+ """Resolve effective config for a single ``engine`` (Redis → env → default).
234
+
235
+ ``catalog`` is an optional ``RedisCatalog``. When it is None or Redis is
236
+ unreachable the resolver falls back to environment variables and built-in
237
+ defaults, which keeps the engine usable without Redis (e.g. in unit tests).
238
+ """
239
+ engine = engine if engine in DUCKDB_ENGINES else "lite"
240
+ return _build_runtime(_redis_cfg(org, catalog), engine)
241
+
242
+
243
+ def resolve_engine_config_provenance(org: str, catalog: Optional[Any] = None) -> Dict[str, Any]:
244
+ """Per-field provenance for the UI.
245
+
246
+ Shape::
247
+
248
+ {
249
+ "shared": {field: {value, source, env_var, default}, ...},
250
+ "lite": {field: {value, source, env_var, default}, ...},
251
+ "pro": {field: {value, source, env_var, default}, ...},
252
+ "modified_ms": <int|None>,
253
+ }
254
+
255
+ ``source`` is one of ``redis`` / ``env`` / ``default``.
256
+ """
257
+ cfg = _redis_cfg(org, catalog)
258
+
259
+ def _block(stored: Dict[str, Any], spec: Dict[str, Tuple[str, str]]) -> Dict[str, Any]:
260
+ out: Dict[str, Any] = {}
261
+ for key, (env_var, default) in spec.items():
262
+ value, source = _effective(stored, key, spec)
263
+ out[key] = {"value": value, "source": source, "env_var": env_var, "default": default}
264
+ return out
265
+
266
+ result: Dict[str, Any] = {"shared": _block(cfg, _SHARED_SPEC)}
267
+ for engine in DUCKDB_ENGINES:
268
+ section = cfg.get(engine) if isinstance(cfg.get(engine), dict) else {}
269
+ result[engine] = _block(section, _DUCKDB_SPEC)
270
+ result["modified_ms"] = cfg.get("modified_ms")
271
+ return result
@@ -16,7 +16,7 @@ from supertable.utils.sql_parser import SQLParser
16
16
  from supertable.engine.engine_enum import Engine
17
17
  from supertable.engine.duckdb_lite import DuckDBLite
18
18
  from supertable.engine.duckdb_pro import DuckDBPro
19
- from supertable.engine.engine_config import resolve_engine_config, EngineRuntimeConfig
19
+ from supertable.engine.engine_config import resolve_engine_configs, EngineRuntimeConfig
20
20
  from supertable.data_classes import Reflection
21
21
  from supertable.config.defaults import logger
22
22
 
@@ -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 ---
@@ -143,10 +189,14 @@ class Executor:
143
189
  log_prefix: str,
144
190
  ) -> Tuple[pd.DataFrame, str]:
145
191
  # Resolve engine config live (Redis → env → default) for this query so
146
- # UI changes take effect immediately without restart or cache.
147
- cfg = resolve_engine_config(self.organization, self._get_catalog())
192
+ # UI changes take effect immediately without restart or cache. Lite and
193
+ # Pro carry independent DuckDB pragmas; the shared auto-pick thresholds
194
+ # are identical in both, so either may drive the routing decision.
195
+ cfgs = resolve_engine_configs(self.organization, self._get_catalog())
196
+ lite_cfg = cfgs["lite"]
197
+ pro_cfg = cfgs["pro"]
148
198
 
149
- chosen = engine if engine != Engine.AUTO else self._auto_pick(reflection, cfg)
199
+ chosen = engine if engine != Engine.AUTO else self._auto_pick(reflection, lite_cfg)
150
200
 
151
201
  def timer_capture(evt: str):
152
202
  timer.capture_and_reset_timing(evt)
@@ -158,7 +208,7 @@ class Executor:
158
208
  query_manager=query_manager,
159
209
  timer_capture=timer_capture,
160
210
  log_prefix=log_prefix,
161
- engine_config=cfg,
211
+ engine_config=lite_cfg,
162
212
  )
163
213
  used = "duckdb_lite"
164
214
 
@@ -170,7 +220,7 @@ class Executor:
170
220
  query_manager=query_manager,
171
221
  timer_capture=timer_capture,
172
222
  log_prefix=log_prefix,
173
- engine_config=cfg,
223
+ engine_config=pro_cfg,
174
224
  )
175
225
  used = "duckdb_pro"
176
226
 
@@ -25,7 +25,7 @@ from supertable.engine.engine_common import (
25
25
  escape_parquet_path,
26
26
  )
27
27
 
28
- from urllib.parse import urlparse
28
+ from urllib.parse import urlparse, unquote
29
29
 
30
30
  # Suppress verbose INFO logging from PyHive and Thrift libraries.
31
31
  # PyHive logs every SQL statement (CREATE VIEW, DROP VIEW, SET, etc.)
@@ -77,8 +77,14 @@ def _to_s3a_path(file_path: str) -> str:
77
77
 
78
78
  if file_path.startswith("http://") or file_path.startswith("https://"):
79
79
  parsed = urlparse(file_path)
80
- # path is /bucket/key — strip the leading slash, split into bucket + key
81
- path = parsed.path.lstrip("/")
80
+ # path is /bucket/key — strip the leading slash, split into bucket + key.
81
+ # urlparse leaves percent-escapes intact, and boto3 presigning encodes
82
+ # the key (Hive partition dirs like ``year=2006`` become ``year%3D2006``).
83
+ # Spark's S3A client expects the *raw* object key and re-encodes itself,
84
+ # so we must decode here — otherwise Ceph RGW, which matches keys
85
+ # literally, 404s on the ``%3D`` form. The presign query string is
86
+ # dropped on purpose: Spark authenticates with its own fs.s3a.* creds.
87
+ path = unquote(parsed.path).lstrip("/")
82
88
  if "/" in path:
83
89
  return f"s3a://{path}"
84
90
  # Degenerate case: only bucket, no key — return as-is