supertable 2.3.7__tar.gz → 2.3.9__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 (194) hide show
  1. {supertable-2.3.7/supertable.egg-info → supertable-2.3.9}/PKG-INFO +1 -1
  2. {supertable-2.3.7 → supertable-2.3.9}/pyproject.toml +1 -1
  3. {supertable-2.3.7 → supertable-2.3.9}/setup.py +1 -1
  4. {supertable-2.3.7 → supertable-2.3.9}/supertable/__init__.py +1 -1
  5. {supertable-2.3.7 → supertable-2.3.9}/supertable/config/settings.py +9 -0
  6. {supertable-2.3.7 → supertable-2.3.9}/supertable/data_writer.py +76 -6
  7. {supertable-2.3.7 → supertable-2.3.9}/supertable/engine/engine_common.py +45 -3
  8. {supertable-2.3.7 → supertable-2.3.9}/supertable/engine/spark_thrift.py +188 -38
  9. {supertable-2.3.7 → supertable-2.3.9}/supertable/engine/tests/test_engine.py +244 -0
  10. {supertable-2.3.7 → supertable-2.3.9}/supertable/processing.py +59 -56
  11. supertable-2.3.9/supertable/quality/__init__.py +23 -0
  12. supertable-2.3.9/supertable/quality/anomaly.py +250 -0
  13. supertable-2.3.9/supertable/quality/checker.py +474 -0
  14. supertable-2.3.9/supertable/quality/config.py +401 -0
  15. supertable-2.3.9/supertable/quality/history.py +241 -0
  16. supertable-2.3.9/supertable/quality/scheduler.py +753 -0
  17. {supertable-2.3.7 → supertable-2.3.9}/supertable/redis_keys.py +1 -1
  18. supertable-2.3.9/supertable/tests/test_compaction_selection.py +335 -0
  19. {supertable-2.3.7 → supertable-2.3.9}/supertable/tests/test_processing.py +90 -1
  20. {supertable-2.3.7 → supertable-2.3.9/supertable.egg-info}/PKG-INFO +1 -1
  21. {supertable-2.3.7 → supertable-2.3.9}/supertable.egg-info/SOURCES.txt +7 -0
  22. {supertable-2.3.7 → supertable-2.3.9}/LICENSE +0 -0
  23. {supertable-2.3.7 → supertable-2.3.9}/README.md +0 -0
  24. {supertable-2.3.7 → supertable-2.3.9}/requirements.txt +0 -0
  25. {supertable-2.3.7 → supertable-2.3.9}/setup.cfg +0 -0
  26. {supertable-2.3.7 → supertable-2.3.9}/supertable/audit/__init__.py +0 -0
  27. {supertable-2.3.7 → supertable-2.3.9}/supertable/audit/admin.py +0 -0
  28. {supertable-2.3.7 → supertable-2.3.9}/supertable/audit/chain.py +0 -0
  29. {supertable-2.3.7 → supertable-2.3.9}/supertable/audit/consumers.py +0 -0
  30. {supertable-2.3.7 → supertable-2.3.9}/supertable/audit/crypto.py +0 -0
  31. {supertable-2.3.7 → supertable-2.3.9}/supertable/audit/events.py +0 -0
  32. {supertable-2.3.7 → supertable-2.3.9}/supertable/audit/export.py +0 -0
  33. {supertable-2.3.7 → supertable-2.3.9}/supertable/audit/logger.py +0 -0
  34. {supertable-2.3.7 → supertable-2.3.9}/supertable/audit/middleware.py +0 -0
  35. {supertable-2.3.7 → supertable-2.3.9}/supertable/audit/reader.py +0 -0
  36. {supertable-2.3.7 → supertable-2.3.9}/supertable/audit/retention.py +0 -0
  37. {supertable-2.3.7 → supertable-2.3.9}/supertable/audit/tests/__init__.py +0 -0
  38. {supertable-2.3.7 → supertable-2.3.9}/supertable/audit/tests/test_chain.py +0 -0
  39. {supertable-2.3.7 → supertable-2.3.9}/supertable/audit/tests/test_crypto.py +0 -0
  40. {supertable-2.3.7 → supertable-2.3.9}/supertable/audit/tests/test_emit.py +0 -0
  41. {supertable-2.3.7 → supertable-2.3.9}/supertable/audit/tests/test_events.py +0 -0
  42. {supertable-2.3.7 → supertable-2.3.9}/supertable/audit/tests/test_retention.py +0 -0
  43. {supertable-2.3.7 → supertable-2.3.9}/supertable/audit/writer_parquet.py +0 -0
  44. {supertable-2.3.7 → supertable-2.3.9}/supertable/audit/writer_redis.py +0 -0
  45. {supertable-2.3.7 → supertable-2.3.9}/supertable/config/__init__.py +0 -0
  46. {supertable-2.3.7 → supertable-2.3.9}/supertable/config/defaults.py +0 -0
  47. {supertable-2.3.7 → supertable-2.3.9}/supertable/config/homedir.py +0 -0
  48. {supertable-2.3.7 → supertable-2.3.9}/supertable/config/tests/__init__.py +0 -0
  49. {supertable-2.3.7 → supertable-2.3.9}/supertable/config/tests/test_defaults.py +0 -0
  50. {supertable-2.3.7 → supertable-2.3.9}/supertable/config/tests/test_homedir.py +0 -0
  51. {supertable-2.3.7 → supertable-2.3.9}/supertable/config/tests/test_settings.py +0 -0
  52. {supertable-2.3.7 → supertable-2.3.9}/supertable/data_classes.py +0 -0
  53. {supertable-2.3.7 → supertable-2.3.9}/supertable/data_reader.py +0 -0
  54. {supertable-2.3.7 → supertable-2.3.9}/supertable/demo/__init__.py +0 -0
  55. {supertable-2.3.7 → supertable-2.3.9}/supertable/demo/quickstart/__init__.py +0 -0
  56. {supertable-2.3.7 → supertable-2.3.9}/supertable/demo/quickstart/__main__.py +0 -0
  57. {supertable-2.3.7 → supertable-2.3.9}/supertable/demo/quickstart/check_filter_builder.py +0 -0
  58. {supertable-2.3.7 → supertable-2.3.9}/supertable/demo/quickstart/controller.py +0 -0
  59. {supertable-2.3.7 → supertable-2.3.9}/supertable/demo/quickstart/data_writer_helpers.py +0 -0
  60. {supertable-2.3.7 → supertable-2.3.9}/supertable/demo/quickstart/defaults.py +0 -0
  61. {supertable-2.3.7 → supertable-2.3.9}/supertable/demo/quickstart/dummy_data.py +0 -0
  62. {supertable-2.3.7 → supertable-2.3.9}/supertable/demo/quickstart/read_parquet_header.py +0 -0
  63. {supertable-2.3.7 → supertable-2.3.9}/supertable/demo/quickstart/s01_01_01_create_super_table.py +0 -0
  64. {supertable-2.3.7 → supertable-2.3.9}/supertable/demo/quickstart/s01_01_02_enable_mirroring_formats.py +0 -0
  65. {supertable-2.3.7 → supertable-2.3.9}/supertable/demo/quickstart/s01_02_create_roles.py +0 -0
  66. {supertable-2.3.7 → supertable-2.3.9}/supertable/demo/quickstart/s01_03_create_users.py +0 -0
  67. {supertable-2.3.7 → supertable-2.3.9}/supertable/demo/quickstart/s02_01_write_dummy_data.py +0 -0
  68. {supertable-2.3.7 → supertable-2.3.9}/supertable/demo/quickstart/s02_02_write_single_data.py +0 -0
  69. {supertable-2.3.7 → supertable-2.3.9}/supertable/demo/quickstart/s02_03_01_write_staging.py +0 -0
  70. {supertable-2.3.7 → supertable-2.3.9}/supertable/demo/quickstart/s02_03_02_create_pipe.py +0 -0
  71. {supertable-2.3.7 → supertable-2.3.9}/supertable/demo/quickstart/s02_04_01_write_monitoring_simple.py +0 -0
  72. {supertable-2.3.7 → supertable-2.3.9}/supertable/demo/quickstart/s02_04_02_write_monitoring_parallel.py +0 -0
  73. {supertable-2.3.7 → supertable-2.3.9}/supertable/demo/quickstart/s02_05_write_tombstone.py +0 -0
  74. {supertable-2.3.7 → supertable-2.3.9}/supertable/demo/quickstart/s03_01_read_data_error.py +0 -0
  75. {supertable-2.3.7 → supertable-2.3.9}/supertable/demo/quickstart/s03_02_01_read_super_data_ok.py +0 -0
  76. {supertable-2.3.7 → supertable-2.3.9}/supertable/demo/quickstart/s03_02_02_read_table_data_ok.py +0 -0
  77. {supertable-2.3.7 → supertable-2.3.9}/supertable/demo/quickstart/s03_03_read_meta.py +0 -0
  78. {supertable-2.3.7 → supertable-2.3.9}/supertable/demo/quickstart/s03_04_read_staging.py +0 -0
  79. {supertable-2.3.7 → supertable-2.3.9}/supertable/demo/quickstart/s03_06_01_read_roles.py +0 -0
  80. {supertable-2.3.7 → supertable-2.3.9}/supertable/demo/quickstart/s03_06_02_read_user.py +0 -0
  81. {supertable-2.3.7 → supertable-2.3.9}/supertable/demo/quickstart/s03_07_01_estimate_read.py +0 -0
  82. {supertable-2.3.7 → supertable-2.3.9}/supertable/demo/quickstart/s03_07_02_estimate_files.py +0 -0
  83. {supertable-2.3.7 → supertable-2.3.9}/supertable/demo/quickstart/s03_08_read_snapshot_history.py +0 -0
  84. {supertable-2.3.7 → supertable-2.3.9}/supertable/demo/quickstart/s04_01_03_delete_pipe.py +0 -0
  85. {supertable-2.3.7 → supertable-2.3.9}/supertable/demo/quickstart/s05_01_delete_table.py +0 -0
  86. {supertable-2.3.7 → supertable-2.3.9}/supertable/demo/quickstart/s05_02_delete_super_table.py +0 -0
  87. {supertable-2.3.7 → supertable-2.3.9}/supertable/demo/webshop/__init__.py +0 -0
  88. {supertable-2.3.7 → supertable-2.3.9}/supertable/demo/webshop/core.py +0 -0
  89. {supertable-2.3.7 → supertable-2.3.9}/supertable/demo/webshop/defaults.py +0 -0
  90. {supertable-2.3.7 → supertable-2.3.9}/supertable/demo/webshop/generate.py +0 -0
  91. {supertable-2.3.7 → supertable-2.3.9}/supertable/demo/webshop/load.py +0 -0
  92. {supertable-2.3.7 → supertable-2.3.9}/supertable/demo/webshop/topup.py +0 -0
  93. {supertable-2.3.7 → supertable-2.3.9}/supertable/engine/__init__.py +0 -0
  94. {supertable-2.3.7 → supertable-2.3.9}/supertable/engine/data_estimator.py +0 -0
  95. {supertable-2.3.7 → supertable-2.3.9}/supertable/engine/duckdb_lite.py +0 -0
  96. {supertable-2.3.7 → supertable-2.3.9}/supertable/engine/duckdb_pro.py +0 -0
  97. {supertable-2.3.7 → supertable-2.3.9}/supertable/engine/engine_config.py +0 -0
  98. {supertable-2.3.7 → supertable-2.3.9}/supertable/engine/engine_enum.py +0 -0
  99. {supertable-2.3.7 → supertable-2.3.9}/supertable/engine/executor.py +0 -0
  100. {supertable-2.3.7 → supertable-2.3.9}/supertable/engine/plan_stats.py +0 -0
  101. {supertable-2.3.7 → supertable-2.3.9}/supertable/engine/tests/__init__.py +0 -0
  102. {supertable-2.3.7 → supertable-2.3.9}/supertable/engine/tests/conftest.py +0 -0
  103. {supertable-2.3.7 → supertable-2.3.9}/supertable/engine/tests/test_engine_config.py +0 -0
  104. {supertable-2.3.7 → supertable-2.3.9}/supertable/engine/tests/test_engine_routing.py +0 -0
  105. {supertable-2.3.7 → supertable-2.3.9}/supertable/engine/tests/test_engine_spill.py +0 -0
  106. {supertable-2.3.7 → supertable-2.3.9}/supertable/errors.py +0 -0
  107. {supertable-2.3.7 → supertable-2.3.9}/supertable/locking/__init__.py +0 -0
  108. {supertable-2.3.7 → supertable-2.3.9}/supertable/locking/benchmarks/__init__.py +0 -0
  109. {supertable-2.3.7 → supertable-2.3.9}/supertable/locking/benchmarks/benchmark_locking.py +0 -0
  110. {supertable-2.3.7 → supertable-2.3.9}/supertable/locking/benchmarks/measure_lock_speed.py +0 -0
  111. {supertable-2.3.7 → supertable-2.3.9}/supertable/locking/benchmarks/measure_lock_time.py +0 -0
  112. {supertable-2.3.7 → supertable-2.3.9}/supertable/locking/file_lock.py +0 -0
  113. {supertable-2.3.7 → supertable-2.3.9}/supertable/locking/redis_lock.py +0 -0
  114. {supertable-2.3.7 → supertable-2.3.9}/supertable/locking/tests/__init__.py +0 -0
  115. {supertable-2.3.7 → supertable-2.3.9}/supertable/locking/tests/test_file_lock.py +0 -0
  116. {supertable-2.3.7 → supertable-2.3.9}/supertable/locking/tests/test_redis_lock.py +0 -0
  117. {supertable-2.3.7 → supertable-2.3.9}/supertable/logging.py +0 -0
  118. {supertable-2.3.7 → supertable-2.3.9}/supertable/meta_reader.py +0 -0
  119. {supertable-2.3.7 → supertable-2.3.9}/supertable/mirroring/__init__.py +0 -0
  120. {supertable-2.3.7 → supertable-2.3.9}/supertable/mirroring/mirror_delta.py +0 -0
  121. {supertable-2.3.7 → supertable-2.3.9}/supertable/mirroring/mirror_formats.py +0 -0
  122. {supertable-2.3.7 → supertable-2.3.9}/supertable/mirroring/mirror_iceberg.py +0 -0
  123. {supertable-2.3.7 → supertable-2.3.9}/supertable/mirroring/mirror_parquet.py +0 -0
  124. {supertable-2.3.7 → supertable-2.3.9}/supertable/monitoring/__init__.py +0 -0
  125. {supertable-2.3.7 → supertable-2.3.9}/supertable/monitoring/partitions.py +0 -0
  126. {supertable-2.3.7 → supertable-2.3.9}/supertable/monitoring_writer.py +0 -0
  127. {supertable-2.3.7 → supertable-2.3.9}/supertable/plan_extender.py +0 -0
  128. {supertable-2.3.7 → supertable-2.3.9}/supertable/query_plan_manager.py +0 -0
  129. {supertable-2.3.7 → supertable-2.3.9}/supertable/rbac/__init__.py +0 -0
  130. {supertable-2.3.7 → supertable-2.3.9}/supertable/rbac/access_control.py +0 -0
  131. {supertable-2.3.7 → supertable-2.3.9}/supertable/rbac/filter_builder.py +0 -0
  132. {supertable-2.3.7 → supertable-2.3.9}/supertable/rbac/permissions.py +0 -0
  133. {supertable-2.3.7 → supertable-2.3.9}/supertable/rbac/role_manager.py +0 -0
  134. {supertable-2.3.7 → supertable-2.3.9}/supertable/rbac/row_column_security.py +0 -0
  135. {supertable-2.3.7 → supertable-2.3.9}/supertable/rbac/tests/test_filter_builder.py +0 -0
  136. {supertable-2.3.7 → supertable-2.3.9}/supertable/rbac/tests/test_rbac.py +0 -0
  137. {supertable-2.3.7 → supertable-2.3.9}/supertable/rbac/tests/test_rbac_per_table.py +0 -0
  138. {supertable-2.3.7 → supertable-2.3.9}/supertable/rbac/user_manager.py +0 -0
  139. {supertable-2.3.7 → supertable-2.3.9}/supertable/redis_catalog.py +0 -0
  140. {supertable-2.3.7 → supertable-2.3.9}/supertable/redis_connector.py +0 -0
  141. {supertable-2.3.7 → supertable-2.3.9}/supertable/redis_infra.py +0 -0
  142. {supertable-2.3.7 → supertable-2.3.9}/supertable/simple_table.py +0 -0
  143. {supertable-2.3.7 → supertable-2.3.9}/supertable/staging_area.py +0 -0
  144. {supertable-2.3.7 → supertable-2.3.9}/supertable/storage/__init__.py +0 -0
  145. {supertable-2.3.7 → supertable-2.3.9}/supertable/storage/azure_storage.py +0 -0
  146. {supertable-2.3.7 → supertable-2.3.9}/supertable/storage/gcp_storage.py +0 -0
  147. {supertable-2.3.7 → supertable-2.3.9}/supertable/storage/local_storage.py +0 -0
  148. {supertable-2.3.7 → supertable-2.3.9}/supertable/storage/minio_storage.py +0 -0
  149. {supertable-2.3.7 → supertable-2.3.9}/supertable/storage/s3_storage.py +0 -0
  150. {supertable-2.3.7 → supertable-2.3.9}/supertable/storage/storage_factory.py +0 -0
  151. {supertable-2.3.7 → supertable-2.3.9}/supertable/storage/storage_interface.py +0 -0
  152. {supertable-2.3.7 → supertable-2.3.9}/supertable/storage/tests/test_storage.py +0 -0
  153. {supertable-2.3.7 → supertable-2.3.9}/supertable/super_pipe.py +0 -0
  154. {supertable-2.3.7 → supertable-2.3.9}/supertable/super_table.py +0 -0
  155. {supertable-2.3.7 → supertable-2.3.9}/supertable/system_query.py +0 -0
  156. {supertable-2.3.7 → supertable-2.3.9}/supertable/tests/__init__.py +0 -0
  157. {supertable-2.3.7 → supertable-2.3.9}/supertable/tests/test_align_to_schema_fix.py +0 -0
  158. {supertable-2.3.7 → supertable-2.3.9}/supertable/tests/test_create_if_missing.py +0 -0
  159. {supertable-2.3.7 → supertable-2.3.9}/supertable/tests/test_data_reader.py +0 -0
  160. {supertable-2.3.7 → supertable-2.3.9}/supertable/tests/test_data_reader_preflight.py +0 -0
  161. {supertable-2.3.7 → supertable-2.3.9}/supertable/tests/test_data_writer.py +0 -0
  162. {supertable-2.3.7 → supertable-2.3.9}/supertable/tests/test_data_writer_compact.py +0 -0
  163. {supertable-2.3.7 → supertable-2.3.9}/supertable/tests/test_data_writer_comprehensive.py +0 -0
  164. {supertable-2.3.7 → supertable-2.3.9}/supertable/tests/test_errors.py +0 -0
  165. {supertable-2.3.7 → supertable-2.3.9}/supertable/tests/test_meta_reader.py +0 -0
  166. {supertable-2.3.7 → supertable-2.3.9}/supertable/tests/test_monitoring_partitions.py +0 -0
  167. {supertable-2.3.7 → supertable-2.3.9}/supertable/tests/test_monitoring_sink_guard.py +0 -0
  168. {supertable-2.3.7 → supertable-2.3.9}/supertable/tests/test_newer_than.py +0 -0
  169. {supertable-2.3.7 → supertable-2.3.9}/supertable/tests/test_parquet_statistics.py +0 -0
  170. {supertable-2.3.7 → supertable-2.3.9}/supertable/tests/test_processing_compact_resources.py +0 -0
  171. {supertable-2.3.7 → supertable-2.3.9}/supertable/tests/test_processing_stats.py +0 -0
  172. {supertable-2.3.7 → supertable-2.3.9}/supertable/tests/test_query_sql.py +0 -0
  173. {supertable-2.3.7 → supertable-2.3.9}/supertable/tests/test_read_pruning_differential.py +0 -0
  174. {supertable-2.3.7 → supertable-2.3.9}/supertable/tests/test_read_pruning_integration.py +0 -0
  175. {supertable-2.3.7 → supertable-2.3.9}/supertable/tests/test_redis_key_prefix.py +0 -0
  176. {supertable-2.3.7 → supertable-2.3.9}/supertable/tests/test_resolve_overwrite_writes.py +0 -0
  177. {supertable-2.3.7 → supertable-2.3.9}/supertable/tests/test_simple_table.py +0 -0
  178. {supertable-2.3.7 → supertable-2.3.9}/supertable/tests/test_stats_cache.py +0 -0
  179. {supertable-2.3.7 → supertable-2.3.9}/supertable/tests/test_stats_pruning.py +0 -0
  180. {supertable-2.3.7 → supertable-2.3.9}/supertable/tests/test_stats_schema_snapshot.py +0 -0
  181. {supertable-2.3.7 → supertable-2.3.9}/supertable/tests/test_super_table.py +0 -0
  182. {supertable-2.3.7 → supertable-2.3.9}/supertable/tests/test_supertable_all.py +0 -0
  183. {supertable-2.3.7 → supertable-2.3.9}/supertable/tests/test_system_query.py +0 -0
  184. {supertable-2.3.7 → supertable-2.3.9}/supertable/tests/test_write_probe_gate.py +0 -0
  185. {supertable-2.3.7 → supertable-2.3.9}/supertable/utils/__init__.py +0 -0
  186. {supertable-2.3.7 → supertable-2.3.9}/supertable/utils/helper.py +0 -0
  187. {supertable-2.3.7 → supertable-2.3.9}/supertable/utils/profiler.py +0 -0
  188. {supertable-2.3.7 → supertable-2.3.9}/supertable/utils/sql_parser.py +0 -0
  189. {supertable-2.3.7 → supertable-2.3.9}/supertable/utils/tests/test_sql_parser_columns.py +0 -0
  190. {supertable-2.3.7 → supertable-2.3.9}/supertable/utils/timer.py +0 -0
  191. {supertable-2.3.7 → supertable-2.3.9}/supertable.egg-info/dependency_links.txt +0 -0
  192. {supertable-2.3.7 → supertable-2.3.9}/supertable.egg-info/entry_points.txt +0 -0
  193. {supertable-2.3.7 → supertable-2.3.9}/supertable.egg-info/requires.txt +0 -0
  194. {supertable-2.3.7 → supertable-2.3.9}/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.3.7
3
+ Version: 2.3.9
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.3.7"
7
+ version = "2.3.9"
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.3.7",
22
+ version="2.3.9",
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.3.7"
28
+ __version__ = "2.3.9"
29
29
 
30
30
  # Re-export the core public surface so users can do ``from supertable import …``
31
31
  # instead of remembering submodule paths.
@@ -157,6 +157,14 @@ class Settings:
157
157
  SUPERTABLE_DUCKDB_MATERIALIZE: str = "view" # SUPERTABLE_DUCKDB_MATERIALIZE
158
158
  SUPERTABLE_DUCKDB_PRESIGNED: bool = False # SUPERTABLE_DUCKDB_PRESIGNED
159
159
  SUPERTABLE_DUCKDB_USE_HTTPFS: bool = False # SUPERTABLE_DUCKDB_USE_HTTPFS
160
+ # Allow DuckDB to DOWNLOAD a missing extension (httpfs) from
161
+ # extensions.duckdb.org at query time. Default OFF: httpfs is baked into
162
+ # the image and seeded into the extension dir, and an implicit network
163
+ # install on an offline/firewalled node can hang for minutes instead of
164
+ # failing. When OFF, a genuinely missing extension raises immediately with
165
+ # an actionable message; set ON only on a networked box that must
166
+ # self-install.
167
+ SUPERTABLE_DUCKDB_ALLOW_EXTENSION_DOWNLOAD: bool = False # SUPERTABLE_DUCKDB_ALLOW_EXTENSION_DOWNLOAD
160
168
  # Write-path overwrite/delete resolution via the DuckDB pushdown probe.
161
169
  # Disabled by default: the polars fallback reads only the projected key
162
170
  # columns through the storage SDK and needs no httpfs extension, so it works
@@ -444,6 +452,7 @@ def _build_settings() -> Settings:
444
452
  SUPERTABLE_DUCKDB_MATERIALIZE=_env_str("SUPERTABLE_DUCKDB_MATERIALIZE", "view"),
445
453
  SUPERTABLE_DUCKDB_PRESIGNED=_env_bool("SUPERTABLE_DUCKDB_PRESIGNED", False),
446
454
  SUPERTABLE_DUCKDB_USE_HTTPFS=_env_bool("SUPERTABLE_DUCKDB_USE_HTTPFS", False),
455
+ SUPERTABLE_DUCKDB_ALLOW_EXTENSION_DOWNLOAD=_env_bool("SUPERTABLE_DUCKDB_ALLOW_EXTENSION_DOWNLOAD", False),
447
456
  SUPERTABLE_DUCKDB_WRITE_PROBE=_env_bool("SUPERTABLE_DUCKDB_WRITE_PROBE", False),
448
457
  SUPERTABLE_DUCKDB_TOMBSTONE_CACHE_MAX_PER_TABLE=_env_int("SUPERTABLE_DUCKDB_TOMBSTONE_CACHE_MAX_PER_TABLE", 8),
449
458
  SUPERTABLE_DUCKDB_TOMBSTONE_CACHE_TTL_SEC=_env_int("SUPERTABLE_DUCKDB_TOMBSTONE_CACHE_TTL_SEC", 300),
@@ -724,6 +724,24 @@ class DataWriter:
724
724
  and combined_tombstone_df.height >= _max_tombstone_rows(table_config)
725
725
  )
726
726
 
727
+ # Snapshot the shared I/O counters BEFORE the compaction phases so
728
+ # the reads/writes they perform can be attributed precisely (the
729
+ # incoming-data write already ran above, so any delta below is
730
+ # purely compaction). Trackers default to "phase did not run".
731
+ _cio_files0 = profiler.counts.get("files_written", 0)
732
+ _cio_bw0 = profiler.counts.get("bytes_written", 0)
733
+ _cio_fr0 = profiler.counts.get("files_read", 0)
734
+ _cio_br0 = profiler.counts.get("bytes_read", 0)
735
+ _live_before = len(post_write_resources)
736
+ _tomb_phase_ran = False
737
+ _tomb_removed = 0
738
+ _tomb_files_touched = 0
739
+ _tomb_files_written = 0
740
+ _tomb_files_total = 0
741
+ _comp_considered = 0
742
+ _comp_files_written = 0
743
+ _comp_rows = 0
744
+
727
745
  # Phase A — drain the deletion-vector when either trigger fires
728
746
  # and a vector is actually live (freshly built this write OR
729
747
  # carried forward from a prior one).
@@ -754,6 +772,15 @@ class DataWriter:
754
772
  sunset_files |= tomb_sunset
755
773
  tombstone_path = None # deletion-vector fully consumed
756
774
  tombstone_rows = 0
775
+ _tomb_phase_ran = True
776
+ _tomb_removed = removed
777
+ _tomb_files_touched = len(tomb_sunset)
778
+ _tomb_files_written = len(tomb_new)
779
+ # Distinct files named in the deletion-vector — set by
780
+ # compact_tombstones this call (one call per write).
781
+ _tomb_files_total = int(
782
+ profiler.counts.get("tombstone_files_total", 0)
783
+ )
757
784
  logger.info(lp(
758
785
  f"tombstone compaction removed {removed} rows "
759
786
  f"from {len(tomb_sunset)} files"
@@ -786,8 +813,12 @@ class DataWriter:
786
813
  compression_level=compression_level,
787
814
  table_config=table_config,
788
815
  small_only=True,
816
+ profiler=profiler,
789
817
  )
790
818
  if comp_new or comp_sunset:
819
+ _comp_considered = considered
820
+ _comp_files_written = len(comp_new)
821
+ _comp_rows = comp_rows
791
822
  sunset_files |= comp_sunset
792
823
  # A file written above (incoming or tombstone survivor)
793
824
  # may have been re-merged here; drop any new_resources
@@ -804,6 +835,40 @@ class DataWriter:
804
835
  ))
805
836
  mark("compact_small")
806
837
 
838
+ # Precise, single-line attribution of the compaction work done
839
+ # INSIDE this write: which gate fired, what each phase touched,
840
+ # and the I/O it cost. Only emitted when a phase actually ran so
841
+ # ordinary writes stay quiet. This is the line that makes a
842
+ # "compaction happened during the write" visible at a glance.
843
+ if _tomb_phase_ran or compaction_ran:
844
+ _final_live = len(
845
+ [r for r in (last_simple_table.get("resources") or [])
846
+ if r.get("file") not in sunset_files]
847
+ + [r for r in new_resources
848
+ if r.get("file") not in sunset_files]
849
+ )
850
+ _triggers = []
851
+ if tombstone_threshold_hit:
852
+ _triggers.append("tombstone_threshold")
853
+ if compaction_gate:
854
+ _triggers.append("small_file_gate")
855
+ _cio_files = profiler.counts.get("files_written", 0) - _cio_files0
856
+ _cio_bw = profiler.counts.get("bytes_written", 0) - _cio_bw0
857
+ _cio_fr = profiler.counts.get("files_read", 0) - _cio_fr0
858
+ _cio_br = profiler.counts.get("bytes_read", 0) - _cio_br0
859
+ logger.info(lp(
860
+ "compaction during write "
861
+ f"[trigger={'+'.join(_triggers) or 'none'}]: "
862
+ f"tombstone phase removed {_tomb_removed} row(s) from "
863
+ f"{_tomb_files_touched}/{_tomb_files_total} deletion-vector file(s), "
864
+ f"wrote {_tomb_files_written} file(s); "
865
+ f"small-file phase merged {_comp_considered} small file(s) -> "
866
+ f"{_comp_files_written} file(s) ({_comp_rows} row(s)); "
867
+ f"large files left untouched; live files {_live_before} -> {_final_live}; "
868
+ f"compaction io: read {_cio_fr} file(s)/{_cio_br / 1048576:.1f} MiB, "
869
+ f"wrote {_cio_files} file(s)/{_cio_bw / 1048576:.1f} MiB"
870
+ ))
871
+
807
872
  # 6. Carry forward + extend the external column-statistics parquet.
808
873
  # Read the footers of the newly written data files, drop the
809
874
  # rows of any sunset file, and append the new ones. No new
@@ -1030,19 +1095,24 @@ class DataWriter:
1030
1095
  logger.error(lp(f"monitoring enqueue failed: {me}"))
1031
1096
 
1032
1097
  # ---------- DATA QUALITY: notify scheduler of new data ----------
1033
- # Sets a debounced "pending" flag in Redis. The DQ scheduler will
1034
- # pick it up on the next tick, respecting lock + cooldown rules.
1035
- # Safe to call at any frequency — never blocks or fails the write.
1098
+ # Producer side of the DQ pipeline: set a debounced "pending" flag in
1099
+ # Redis so the background scheduler (started via
1100
+ # ``supertable.quality.start_scheduler``) picks this table up on its
1101
+ # next tick, respecting debounce + lock + cooldown. ``notify_ingest``
1102
+ # has its own internal guard and never raises; the outer guard here
1103
+ # only covers an unexpected import-time failure so a write can never
1104
+ # fail due to quality scheduling. We log (not silently ``pass``) so a
1105
+ # future packaging regression is visible instead of a dead no-op.
1036
1106
  try:
1037
- from supertable.services.quality.scheduler import notify_ingest
1107
+ from supertable.quality.scheduler import notify_ingest
1038
1108
  notify_ingest(
1039
1109
  self.catalog.r,
1040
1110
  self.super_table.organization,
1041
1111
  self.super_table.super_name,
1042
1112
  simple_name,
1043
1113
  )
1044
- except Exception:
1045
- pass # Never fail a write due to quality scheduling
1114
+ except Exception as qe:
1115
+ logger.warning(lp(f"data-quality notify_ingest skipped: {qe}"))
1046
1116
 
1047
1117
  # ---------- AUDIT LOG ----------
1048
1118
  try:
@@ -215,12 +215,43 @@ def configure_httpfs_and_s3(
215
215
  if not for_paths:
216
216
  return
217
217
 
218
- # Load httpfs; install only when not already available.
218
+ # Load httpfs. It is baked into the image and seeded into the DuckDB
219
+ # extension dir (see the container entrypoint), so LOAD normally succeeds
220
+ # with no network access.
221
+ #
222
+ # Why this is NOT a blind ``INSTALL`` fallback: ``INSTALL httpfs`` performs
223
+ # an HTTP GET to extensions.duckdb.org. On an offline / firewalled node
224
+ # that socket can stall for minutes — or hang indefinitely on a blackholed
225
+ # route — turning a should-be-instant failure into an unbounded query hang.
226
+ # So we fail fast instead:
227
+ # * SET autoinstall_known_extensions=false makes LOAD raise immediately
228
+ # when the extension is absent, rather than silently downloading it;
229
+ # * a network INSTALL is attempted ONLY when explicitly opted in via
230
+ # SUPERTABLE_DUCKDB_ALLOW_EXTENSION_DOWNLOAD;
231
+ # * otherwise we raise a clear, actionable error the caller returns.
219
232
  try:
220
- con.execute("LOAD httpfs;")
233
+ con.execute("SET autoinstall_known_extensions=false;")
221
234
  except Exception:
222
- con.execute("INSTALL httpfs;")
235
+ pass
236
+
237
+ try:
223
238
  con.execute("LOAD httpfs;")
239
+ except Exception as load_err:
240
+ if settings.SUPERTABLE_DUCKDB_ALLOW_EXTENSION_DOWNLOAD:
241
+ # Operator explicitly allowed reaching the network for a one-off
242
+ # install (e.g. an online dev box without a baked extension).
243
+ con.execute("INSTALL httpfs;")
244
+ con.execute("LOAD httpfs;")
245
+ else:
246
+ raise RuntimeError(
247
+ "DuckDB 'httpfs' extension is not available locally and network "
248
+ "auto-download is disabled, so this query cannot run. Bake/seed "
249
+ "httpfs into "
250
+ f"'{get_app_home()}/.duckdb/extensions/v<duckdb_version>/<platform>/' "
251
+ "(the container entrypoint restores it from /opt/duckdb-extensions), "
252
+ "or set SUPERTABLE_DUCKDB_ALLOW_EXTENSION_DOWNLOAD=true to permit a "
253
+ f"one-time online install. Underlying DuckDB error: {load_err}"
254
+ ) from load_err
224
255
 
225
256
  any_s3 = any(str(p).lower().startswith("s3://") for p in for_paths)
226
257
  any_http = any(str(p).lower().startswith(("http://", "https://")) for p in for_paths)
@@ -634,6 +665,17 @@ def init_connection(
634
665
  except Exception as e:
635
666
  logger.warning(f"[duckdb.init] home_directory pin failed: {e}")
636
667
 
668
+ # Never let DuckDB auto-DOWNLOAD an extension. Everything we need (httpfs)
669
+ # is baked/seeded into the local extension dir; an implicit network install
670
+ # would reach out to extensions.duckdb.org and can hang for minutes on an
671
+ # offline/firewalled node — turning a should-be-instant error into an
672
+ # unbounded query hang. configure_httpfs_and_s3() owns the explicit,
673
+ # opt-in install path (SUPERTABLE_DUCKDB_ALLOW_EXTENSION_DOWNLOAD).
674
+ try:
675
+ con.execute("SET autoinstall_known_extensions=false;")
676
+ except Exception as e:
677
+ logger.debug(f"[duckdb.init] disabling extension auto-install failed: {e}")
678
+
637
679
  # Resolve memory limit.
638
680
  # Single env var SUPERTABLE_DUCKDB_MEMORY_LIMIT controls both executors.
639
681
  # The `memory_limit` argument is the caller's fallback when the env var is absent.
@@ -259,6 +259,157 @@ def _spark_create_tombstone_view(
259
259
  cursor.execute(sql)
260
260
 
261
261
 
262
+ # ---------------------------------------------------------------------------
263
+ # Timestamp-cast wrapper: detect timestamp columns by PARQUET logical type
264
+ # ---------------------------------------------------------------------------
265
+ #
266
+ # When ``spark.sql.legacy.parquet.nanosAsLong=true`` Spark reads a
267
+ # TIMESTAMP(NANOS) parquet column as BIGINT (epoch nanoseconds) instead of
268
+ # failing, so we wrap the view to convert those back to TIMESTAMP. The columns
269
+ # to convert are found by their ACTUAL parquet logical type (read from the file
270
+ # footer), NEVER by name: an epoch-integer column merely *named* like a
271
+ # timestamp (``created_at`` holding epoch-ms, ``source_ts_ms``) is a plain INT64
272
+ # with no timestamp logical type, so it must stay BIGINT. Casting it — as the
273
+ # old name heuristic did — both corrupts its value and breaks expressions like
274
+ # ``FROM_UNIXTIME(created_at / 1000.0)`` that need a number, not a TIMESTAMP.
275
+
276
+
277
+ def _read_parquet_schema(storage, original_path: str):
278
+ """Read one parquet file's schema (footer only); return a ``pyarrow.Schema``.
279
+
280
+ Returns ``None`` if the schema can't be read. *original_path* is a snapshot
281
+ file path (``sup.files`` — a local path, an ``s3://``/``s3a://`` URL, or a
282
+ presigned ``http(s)://`` URL), NOT the s3a form handed to Spark.
283
+
284
+ * Local file → footer-only read straight from disk.
285
+ * Object store → the bucket/key is recovered from the path (reusing
286
+ :func:`_to_s3a_path`, which drops any presign query string and decodes
287
+ percent-escapes) and the object is pulled through *storage*, which holds
288
+ the endpoint/credentials. The whole object is fetched (the storage
289
+ abstraction exposes no range read), but only once per table.
290
+ """
291
+ if not original_path:
292
+ return None
293
+ try:
294
+ import pyarrow as pa
295
+ import pyarrow.parquet as pq
296
+ except Exception:
297
+ return None
298
+
299
+ s3a = _to_s3a_path(original_path)
300
+ if not s3a.startswith("s3a://"):
301
+ # Local filesystem path: pyarrow reads only the footer from the path.
302
+ try:
303
+ if os.path.isfile(original_path):
304
+ return pq.read_schema(original_path)
305
+ except Exception:
306
+ return None
307
+ return None
308
+
309
+ if storage is None:
310
+ return None
311
+ # s3a://bucket/full_key → bucket + object key (key already includes any
312
+ # base_prefix). ``storage.read_bytes`` re-applies base_prefix and uses its
313
+ # own bucket, so strip base_prefix from the key here to avoid doubling it.
314
+ rest = s3a[len("s3a://"):]
315
+ _bucket, _, full_key = rest.partition("/")
316
+ if not full_key:
317
+ return None
318
+ base = (getattr(storage, "base_prefix", "") or "").strip("/")
319
+ rel_key = full_key
320
+ if base and full_key.startswith(base + "/"):
321
+ rel_key = full_key[len(base) + 1:]
322
+ try:
323
+ data = storage.read_bytes(rel_key)
324
+ return pq.read_schema(pa.BufferReader(data))
325
+ except Exception:
326
+ return None
327
+
328
+
329
+ def _parquet_timestamp_units(storage, original_path: str) -> Optional[Dict[str, str]]:
330
+ """Map ``column -> parquet timestamp unit`` ('s'/'ms'/'us'/'ns') for every
331
+ column whose PARQUET logical type is a timestamp.
332
+
333
+ Returns ``None`` when the footer can't be read — the caller then casts only
334
+ ``__timestamp__`` (never the name heuristic).
335
+ """
336
+ schema = _read_parquet_schema(storage, original_path)
337
+ if schema is None:
338
+ return None
339
+ try:
340
+ import pyarrow.types as patypes
341
+ units: Dict[str, str] = {}
342
+ for i in range(len(schema)):
343
+ field = schema.field(i)
344
+ if patypes.is_timestamp(field.type):
345
+ units[field.name] = getattr(field.type, "unit", "us") or "us"
346
+ return units
347
+ except Exception:
348
+ return None
349
+
350
+
351
+ def _ts_to_timestamp_expr(col_name: str, unit: str) -> str:
352
+ """Spark expression reinterpreting a BIGINT epoch column as TIMESTAMP.
353
+
354
+ Uses the explicit ``timestamp_seconds/millis/micros`` builtins rather than
355
+ ``CAST(x AS TIMESTAMP)``: the cast's numeric→timestamp behaviour is both
356
+ seconds-based AND gated behind ``spark.sql.legacy.allowCastNumericToTimestamp``
357
+ in Spark 3.x, whereas the builtins are always enabled and unambiguous about
358
+ the source unit. Nanoseconds have no Spark timestamp type, so they are
359
+ reduced to microseconds with integer division (``DIV 1000``) first.
360
+ """
361
+ q = f"`{col_name}`"
362
+ if unit == "s":
363
+ expr = f"timestamp_seconds({q})"
364
+ elif unit == "ms":
365
+ expr = f"timestamp_millis({q})"
366
+ elif unit == "us":
367
+ expr = f"timestamp_micros({q})"
368
+ else: # 'ns' or unknown: nanoseconds is the only unit Spark surfaces as BIGINT
369
+ expr = f"timestamp_micros({q} DIV 1000)"
370
+ return f"{expr} AS {q}"
371
+
372
+
373
+ def _build_tscast_select(desc_rows, ts_units: Optional[Dict[str, str]]):
374
+ """Build the projection for the timestamp-cast wrapper view.
375
+
376
+ *desc_rows* is Spark ``DESCRIBE`` output (rows whose first two items are the
377
+ column name and Spark type). *ts_units* maps each genuine parquet-timestamp
378
+ column to its unit, or is ``None`` when the footer couldn't be read.
379
+
380
+ A column is converted back to TIMESTAMP iff Spark surfaces it as BIGINT AND
381
+ it is a genuine parquet timestamp (under ``nanosAsLong`` only NANOS columns
382
+ appear as BIGINT); every other column passes through untouched. When
383
+ *ts_units* is ``None`` the only column treated as a timestamp is the system
384
+ column ``__timestamp__`` (assumed nanoseconds — a non-nanos timestamp would
385
+ already read as TIMESTAMP, not BIGINT), never the name heuristic, so
386
+ epoch-integer columns are always left as BIGINT.
387
+
388
+ Returns ``(select_parts, cast_cols)``.
389
+ """
390
+ if ts_units is None:
391
+ ts_units = {"__timestamp__": "ns"}
392
+ select_parts: List[str] = []
393
+ cast_cols: List[str] = []
394
+ for row in desc_rows or []:
395
+ if not row:
396
+ continue
397
+ col_name = str(row[0])
398
+ if not col_name or col_name.startswith("#"):
399
+ # Skip blank / partition-metadata rows Spark DESCRIBE may append.
400
+ continue
401
+ col_type = (
402
+ str(row[1]).strip().upper()
403
+ if len(row) > 1 and row[1] is not None else ""
404
+ )
405
+ if col_type == "BIGINT" and col_name in ts_units:
406
+ select_parts.append(_ts_to_timestamp_expr(col_name, ts_units[col_name]))
407
+ cast_cols.append(col_name)
408
+ else:
409
+ select_parts.append(f"`{col_name}`")
410
+ return select_parts, cast_cols
411
+
412
+
262
413
  def _spark_rewrite_query(
263
414
  original_sql: str,
264
415
  alias_to_table: Dict[str, str],
@@ -635,6 +786,10 @@ class SparkThriftExecutor:
635
786
  }
636
787
  table_defs = parser.get_table_tuples()
637
788
  alias_to_table_name: Dict[str, str] = {}
789
+ # table_name -> a representative ORIGINAL (pre-s3a) snapshot file,
790
+ # used to read the parquet footer once per table for type-based
791
+ # timestamp detection in step 4a below.
792
+ table_repr_file: Dict[str, str] = {}
638
793
 
639
794
  for td in table_defs:
640
795
  key = (td.super_name, td.simple_name)
@@ -646,6 +801,11 @@ class SparkThriftExecutor:
646
801
  sup.super_name, sup.simple_name, sup.simple_version,
647
802
  )
648
803
 
804
+ # Keep one ORIGINAL (pre-s3a) file per table for the footer read
805
+ # that drives type-based timestamp detection (step 4a).
806
+ if sup.files and table_name not in table_repr_file:
807
+ table_repr_file[table_name] = sup.files[0]
808
+
649
809
  # Convert to s3a:// paths for Spark (handles s3://, presigned HTTP URLs, etc.)
650
810
  files = [_to_s3a_path(f) for f in sup.files]
651
811
 
@@ -667,47 +827,36 @@ class SparkThriftExecutor:
667
827
 
668
828
  timer_capture("CREATING_REFLECTION")
669
829
 
670
- # 4a. Wrap parquet views to CAST nanosecond-epoch BIGINT columns
830
+ # 4a. Wrap parquet views to convert nanosecond-epoch BIGINT columns
671
831
  # back to TIMESTAMP. When nanosAsLong=true, Spark reads
672
- # TIMESTAMP(NANOS, false) parquet columns as BIGINT (epoch
673
- # nanoseconds). Downstream queries expect DATE/TIMESTAMP
674
- # semantics (e.g. WHERE stat_date >= CURRENT_DATE - INTERVAL
675
- # '7' DAYS), so we create a wrapper view that converts them.
676
- # The wrapper replaces the original view name in alias_to_table_name
677
- # so all downstream logic (RBAC, dedup, tombstone, user query)
678
- # sees proper TIMESTAMP columns.
832
+ # TIMESTAMP(NANOS) parquet columns as BIGINT (epoch nanoseconds);
833
+ # genuine micros/millis timestamps already read as TIMESTAMP and
834
+ # need no wrapper. Downstream queries expect DATE/TIMESTAMP
835
+ # semantics (e.g. WHERE stat_date >= CURRENT_DATE - INTERVAL '7'
836
+ # DAYS), so we wrap the view to convert the BIGINT-surfaced ones.
837
+ # Conversion targets are detected by ACTUAL parquet logical type
838
+ # (read once per table from the file footer), never by name — so
839
+ # epoch-integer columns named like timestamps (created_at,
840
+ # source_ts_ms) stay BIGINT. If the footer can't be read we
841
+ # convert only the system column __timestamp__ (never the name
842
+ # heuristic). The wrapper replaces the view name in
843
+ # alias_to_table_name so all downstream logic (RBAC, dedup,
844
+ # tombstone, user query) sees proper TIMESTAMP columns.
845
+ _ts_units_cache: Dict[str, Optional[Dict[str, str]]] = {}
679
846
  for alias, table_name in list(alias_to_table_name.items()):
680
847
  try:
681
848
  cursor.execute(f"DESCRIBE {table_name}")
682
849
  desc_rows = cursor.fetchall()
683
850
 
684
- # Identify BIGINT columns that look like nanosecond timestamps.
685
- # Heuristic: column name contains 'date', 'time', 'timestamp',
686
- # '_at', '_ts', or is exactly '__timestamp__'.
687
- _ts_patterns = ('date', 'time', 'timestamp', '_at', '_ts')
688
- cast_cols = []
689
- for row in desc_rows:
690
- col_name = str(row[0])
691
- col_type = str(row[1]).strip().upper()
692
- if col_type != 'BIGINT':
693
- continue
694
- col_lower = col_name.lower()
695
- if col_lower == '__timestamp__' or any(p in col_lower for p in _ts_patterns):
696
- cast_cols.append(col_name)
851
+ if table_name not in _ts_units_cache:
852
+ _ts_units_cache[table_name] = _parquet_timestamp_units(
853
+ self.storage, table_repr_file.get(table_name),
854
+ )
855
+ ts_units = _ts_units_cache[table_name]
697
856
 
698
- if cast_cols:
699
- # Build SELECT with CASTs for timestamp columns, pass-through for others
700
- select_parts = []
701
- for row in desc_rows:
702
- col_name = str(row[0])
703
- if col_name in cast_cols:
704
- # nanos → micros → TIMESTAMP
705
- select_parts.append(
706
- f"CAST(`{col_name}` / 1000000 AS TIMESTAMP) AS `{col_name}`"
707
- )
708
- else:
709
- select_parts.append(f"`{col_name}`")
857
+ select_parts, cast_cols = _build_tscast_select(desc_rows, ts_units)
710
858
 
859
+ if cast_cols:
711
860
  wrapper_name = f"__{table_name}_tscast__"
712
861
  wrapper_sql = (
713
862
  f"CREATE OR REPLACE TEMPORARY VIEW {wrapper_name} AS "
@@ -718,15 +867,16 @@ class SparkThriftExecutor:
718
867
  alias_to_table_name[alias] = wrapper_name
719
868
 
720
869
  logger.debug(
721
- f"{log_prefix}[spark.thrift] CAST wrapper for {table_name}: "
722
- f"converted {len(cast_cols)} column(s): {cast_cols}"
870
+ f"{log_prefix}[spark.thrift] timestamp wrapper for {table_name}: "
871
+ f"converted {len(cast_cols)} parquet-timestamp column(s) "
872
+ f"{cast_cols} back to TIMESTAMP"
723
873
  )
724
874
  except Exception as cast_err:
725
- # Non-fatal: if DESCRIBE or CAST fails, the original view
726
- # stays in place. The query may still fail with a type
875
+ # Non-fatal: if DESCRIBE or the wrapper fails, the original
876
+ # view stays in place. The query may still fail with a type
727
877
  # mismatch, but at least we don't break the happy path.
728
878
  logger.debug(
729
- f"{log_prefix}[spark.thrift] timestamp CAST wrapper "
879
+ f"{log_prefix}[spark.thrift] timestamp wrapper "
730
880
  f"failed for {table_name} (non-fatal): {cast_err}"
731
881
  )
732
882