supertable 2.3.7__tar.gz → 2.3.8__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (194) hide show
  1. {supertable-2.3.7/supertable.egg-info → supertable-2.3.8}/PKG-INFO +1 -1
  2. {supertable-2.3.7 → supertable-2.3.8}/pyproject.toml +1 -1
  3. {supertable-2.3.7 → supertable-2.3.8}/setup.py +1 -1
  4. {supertable-2.3.7 → supertable-2.3.8}/supertable/__init__.py +1 -1
  5. {supertable-2.3.7 → supertable-2.3.8}/supertable/data_writer.py +76 -6
  6. {supertable-2.3.7 → supertable-2.3.8}/supertable/engine/spark_thrift.py +188 -38
  7. {supertable-2.3.7 → supertable-2.3.8}/supertable/engine/tests/test_engine.py +244 -0
  8. {supertable-2.3.7 → supertable-2.3.8}/supertable/processing.py +59 -56
  9. supertable-2.3.8/supertable/quality/__init__.py +23 -0
  10. supertable-2.3.8/supertable/quality/anomaly.py +250 -0
  11. supertable-2.3.8/supertable/quality/checker.py +474 -0
  12. supertable-2.3.8/supertable/quality/config.py +401 -0
  13. supertable-2.3.8/supertable/quality/history.py +241 -0
  14. supertable-2.3.8/supertable/quality/scheduler.py +753 -0
  15. {supertable-2.3.7 → supertable-2.3.8}/supertable/redis_keys.py +1 -1
  16. supertable-2.3.8/supertable/tests/test_compaction_selection.py +335 -0
  17. {supertable-2.3.7 → supertable-2.3.8}/supertable/tests/test_processing.py +90 -1
  18. {supertable-2.3.7 → supertable-2.3.8/supertable.egg-info}/PKG-INFO +1 -1
  19. {supertable-2.3.7 → supertable-2.3.8}/supertable.egg-info/SOURCES.txt +7 -0
  20. {supertable-2.3.7 → supertable-2.3.8}/LICENSE +0 -0
  21. {supertable-2.3.7 → supertable-2.3.8}/README.md +0 -0
  22. {supertable-2.3.7 → supertable-2.3.8}/requirements.txt +0 -0
  23. {supertable-2.3.7 → supertable-2.3.8}/setup.cfg +0 -0
  24. {supertable-2.3.7 → supertable-2.3.8}/supertable/audit/__init__.py +0 -0
  25. {supertable-2.3.7 → supertable-2.3.8}/supertable/audit/admin.py +0 -0
  26. {supertable-2.3.7 → supertable-2.3.8}/supertable/audit/chain.py +0 -0
  27. {supertable-2.3.7 → supertable-2.3.8}/supertable/audit/consumers.py +0 -0
  28. {supertable-2.3.7 → supertable-2.3.8}/supertable/audit/crypto.py +0 -0
  29. {supertable-2.3.7 → supertable-2.3.8}/supertable/audit/events.py +0 -0
  30. {supertable-2.3.7 → supertable-2.3.8}/supertable/audit/export.py +0 -0
  31. {supertable-2.3.7 → supertable-2.3.8}/supertable/audit/logger.py +0 -0
  32. {supertable-2.3.7 → supertable-2.3.8}/supertable/audit/middleware.py +0 -0
  33. {supertable-2.3.7 → supertable-2.3.8}/supertable/audit/reader.py +0 -0
  34. {supertable-2.3.7 → supertable-2.3.8}/supertable/audit/retention.py +0 -0
  35. {supertable-2.3.7 → supertable-2.3.8}/supertable/audit/tests/__init__.py +0 -0
  36. {supertable-2.3.7 → supertable-2.3.8}/supertable/audit/tests/test_chain.py +0 -0
  37. {supertable-2.3.7 → supertable-2.3.8}/supertable/audit/tests/test_crypto.py +0 -0
  38. {supertable-2.3.7 → supertable-2.3.8}/supertable/audit/tests/test_emit.py +0 -0
  39. {supertable-2.3.7 → supertable-2.3.8}/supertable/audit/tests/test_events.py +0 -0
  40. {supertable-2.3.7 → supertable-2.3.8}/supertable/audit/tests/test_retention.py +0 -0
  41. {supertable-2.3.7 → supertable-2.3.8}/supertable/audit/writer_parquet.py +0 -0
  42. {supertable-2.3.7 → supertable-2.3.8}/supertable/audit/writer_redis.py +0 -0
  43. {supertable-2.3.7 → supertable-2.3.8}/supertable/config/__init__.py +0 -0
  44. {supertable-2.3.7 → supertable-2.3.8}/supertable/config/defaults.py +0 -0
  45. {supertable-2.3.7 → supertable-2.3.8}/supertable/config/homedir.py +0 -0
  46. {supertable-2.3.7 → supertable-2.3.8}/supertable/config/settings.py +0 -0
  47. {supertable-2.3.7 → supertable-2.3.8}/supertable/config/tests/__init__.py +0 -0
  48. {supertable-2.3.7 → supertable-2.3.8}/supertable/config/tests/test_defaults.py +0 -0
  49. {supertable-2.3.7 → supertable-2.3.8}/supertable/config/tests/test_homedir.py +0 -0
  50. {supertable-2.3.7 → supertable-2.3.8}/supertable/config/tests/test_settings.py +0 -0
  51. {supertable-2.3.7 → supertable-2.3.8}/supertable/data_classes.py +0 -0
  52. {supertable-2.3.7 → supertable-2.3.8}/supertable/data_reader.py +0 -0
  53. {supertable-2.3.7 → supertable-2.3.8}/supertable/demo/__init__.py +0 -0
  54. {supertable-2.3.7 → supertable-2.3.8}/supertable/demo/quickstart/__init__.py +0 -0
  55. {supertable-2.3.7 → supertable-2.3.8}/supertable/demo/quickstart/__main__.py +0 -0
  56. {supertable-2.3.7 → supertable-2.3.8}/supertable/demo/quickstart/check_filter_builder.py +0 -0
  57. {supertable-2.3.7 → supertable-2.3.8}/supertable/demo/quickstart/controller.py +0 -0
  58. {supertable-2.3.7 → supertable-2.3.8}/supertable/demo/quickstart/data_writer_helpers.py +0 -0
  59. {supertable-2.3.7 → supertable-2.3.8}/supertable/demo/quickstart/defaults.py +0 -0
  60. {supertable-2.3.7 → supertable-2.3.8}/supertable/demo/quickstart/dummy_data.py +0 -0
  61. {supertable-2.3.7 → supertable-2.3.8}/supertable/demo/quickstart/read_parquet_header.py +0 -0
  62. {supertable-2.3.7 → supertable-2.3.8}/supertable/demo/quickstart/s01_01_01_create_super_table.py +0 -0
  63. {supertable-2.3.7 → supertable-2.3.8}/supertable/demo/quickstart/s01_01_02_enable_mirroring_formats.py +0 -0
  64. {supertable-2.3.7 → supertable-2.3.8}/supertable/demo/quickstart/s01_02_create_roles.py +0 -0
  65. {supertable-2.3.7 → supertable-2.3.8}/supertable/demo/quickstart/s01_03_create_users.py +0 -0
  66. {supertable-2.3.7 → supertable-2.3.8}/supertable/demo/quickstart/s02_01_write_dummy_data.py +0 -0
  67. {supertable-2.3.7 → supertable-2.3.8}/supertable/demo/quickstart/s02_02_write_single_data.py +0 -0
  68. {supertable-2.3.7 → supertable-2.3.8}/supertable/demo/quickstart/s02_03_01_write_staging.py +0 -0
  69. {supertable-2.3.7 → supertable-2.3.8}/supertable/demo/quickstart/s02_03_02_create_pipe.py +0 -0
  70. {supertable-2.3.7 → supertable-2.3.8}/supertable/demo/quickstart/s02_04_01_write_monitoring_simple.py +0 -0
  71. {supertable-2.3.7 → supertable-2.3.8}/supertable/demo/quickstart/s02_04_02_write_monitoring_parallel.py +0 -0
  72. {supertable-2.3.7 → supertable-2.3.8}/supertable/demo/quickstart/s02_05_write_tombstone.py +0 -0
  73. {supertable-2.3.7 → supertable-2.3.8}/supertable/demo/quickstart/s03_01_read_data_error.py +0 -0
  74. {supertable-2.3.7 → supertable-2.3.8}/supertable/demo/quickstart/s03_02_01_read_super_data_ok.py +0 -0
  75. {supertable-2.3.7 → supertable-2.3.8}/supertable/demo/quickstart/s03_02_02_read_table_data_ok.py +0 -0
  76. {supertable-2.3.7 → supertable-2.3.8}/supertable/demo/quickstart/s03_03_read_meta.py +0 -0
  77. {supertable-2.3.7 → supertable-2.3.8}/supertable/demo/quickstart/s03_04_read_staging.py +0 -0
  78. {supertable-2.3.7 → supertable-2.3.8}/supertable/demo/quickstart/s03_06_01_read_roles.py +0 -0
  79. {supertable-2.3.7 → supertable-2.3.8}/supertable/demo/quickstart/s03_06_02_read_user.py +0 -0
  80. {supertable-2.3.7 → supertable-2.3.8}/supertable/demo/quickstart/s03_07_01_estimate_read.py +0 -0
  81. {supertable-2.3.7 → supertable-2.3.8}/supertable/demo/quickstart/s03_07_02_estimate_files.py +0 -0
  82. {supertable-2.3.7 → supertable-2.3.8}/supertable/demo/quickstart/s03_08_read_snapshot_history.py +0 -0
  83. {supertable-2.3.7 → supertable-2.3.8}/supertable/demo/quickstart/s04_01_03_delete_pipe.py +0 -0
  84. {supertable-2.3.7 → supertable-2.3.8}/supertable/demo/quickstart/s05_01_delete_table.py +0 -0
  85. {supertable-2.3.7 → supertable-2.3.8}/supertable/demo/quickstart/s05_02_delete_super_table.py +0 -0
  86. {supertable-2.3.7 → supertable-2.3.8}/supertable/demo/webshop/__init__.py +0 -0
  87. {supertable-2.3.7 → supertable-2.3.8}/supertable/demo/webshop/core.py +0 -0
  88. {supertable-2.3.7 → supertable-2.3.8}/supertable/demo/webshop/defaults.py +0 -0
  89. {supertable-2.3.7 → supertable-2.3.8}/supertable/demo/webshop/generate.py +0 -0
  90. {supertable-2.3.7 → supertable-2.3.8}/supertable/demo/webshop/load.py +0 -0
  91. {supertable-2.3.7 → supertable-2.3.8}/supertable/demo/webshop/topup.py +0 -0
  92. {supertable-2.3.7 → supertable-2.3.8}/supertable/engine/__init__.py +0 -0
  93. {supertable-2.3.7 → supertable-2.3.8}/supertable/engine/data_estimator.py +0 -0
  94. {supertable-2.3.7 → supertable-2.3.8}/supertable/engine/duckdb_lite.py +0 -0
  95. {supertable-2.3.7 → supertable-2.3.8}/supertable/engine/duckdb_pro.py +0 -0
  96. {supertable-2.3.7 → supertable-2.3.8}/supertable/engine/engine_common.py +0 -0
  97. {supertable-2.3.7 → supertable-2.3.8}/supertable/engine/engine_config.py +0 -0
  98. {supertable-2.3.7 → supertable-2.3.8}/supertable/engine/engine_enum.py +0 -0
  99. {supertable-2.3.7 → supertable-2.3.8}/supertable/engine/executor.py +0 -0
  100. {supertable-2.3.7 → supertable-2.3.8}/supertable/engine/plan_stats.py +0 -0
  101. {supertable-2.3.7 → supertable-2.3.8}/supertable/engine/tests/__init__.py +0 -0
  102. {supertable-2.3.7 → supertable-2.3.8}/supertable/engine/tests/conftest.py +0 -0
  103. {supertable-2.3.7 → supertable-2.3.8}/supertable/engine/tests/test_engine_config.py +0 -0
  104. {supertable-2.3.7 → supertable-2.3.8}/supertable/engine/tests/test_engine_routing.py +0 -0
  105. {supertable-2.3.7 → supertable-2.3.8}/supertable/engine/tests/test_engine_spill.py +0 -0
  106. {supertable-2.3.7 → supertable-2.3.8}/supertable/errors.py +0 -0
  107. {supertable-2.3.7 → supertable-2.3.8}/supertable/locking/__init__.py +0 -0
  108. {supertable-2.3.7 → supertable-2.3.8}/supertable/locking/benchmarks/__init__.py +0 -0
  109. {supertable-2.3.7 → supertable-2.3.8}/supertable/locking/benchmarks/benchmark_locking.py +0 -0
  110. {supertable-2.3.7 → supertable-2.3.8}/supertable/locking/benchmarks/measure_lock_speed.py +0 -0
  111. {supertable-2.3.7 → supertable-2.3.8}/supertable/locking/benchmarks/measure_lock_time.py +0 -0
  112. {supertable-2.3.7 → supertable-2.3.8}/supertable/locking/file_lock.py +0 -0
  113. {supertable-2.3.7 → supertable-2.3.8}/supertable/locking/redis_lock.py +0 -0
  114. {supertable-2.3.7 → supertable-2.3.8}/supertable/locking/tests/__init__.py +0 -0
  115. {supertable-2.3.7 → supertable-2.3.8}/supertable/locking/tests/test_file_lock.py +0 -0
  116. {supertable-2.3.7 → supertable-2.3.8}/supertable/locking/tests/test_redis_lock.py +0 -0
  117. {supertable-2.3.7 → supertable-2.3.8}/supertable/logging.py +0 -0
  118. {supertable-2.3.7 → supertable-2.3.8}/supertable/meta_reader.py +0 -0
  119. {supertable-2.3.7 → supertable-2.3.8}/supertable/mirroring/__init__.py +0 -0
  120. {supertable-2.3.7 → supertable-2.3.8}/supertable/mirroring/mirror_delta.py +0 -0
  121. {supertable-2.3.7 → supertable-2.3.8}/supertable/mirroring/mirror_formats.py +0 -0
  122. {supertable-2.3.7 → supertable-2.3.8}/supertable/mirroring/mirror_iceberg.py +0 -0
  123. {supertable-2.3.7 → supertable-2.3.8}/supertable/mirroring/mirror_parquet.py +0 -0
  124. {supertable-2.3.7 → supertable-2.3.8}/supertable/monitoring/__init__.py +0 -0
  125. {supertable-2.3.7 → supertable-2.3.8}/supertable/monitoring/partitions.py +0 -0
  126. {supertable-2.3.7 → supertable-2.3.8}/supertable/monitoring_writer.py +0 -0
  127. {supertable-2.3.7 → supertable-2.3.8}/supertable/plan_extender.py +0 -0
  128. {supertable-2.3.7 → supertable-2.3.8}/supertable/query_plan_manager.py +0 -0
  129. {supertable-2.3.7 → supertable-2.3.8}/supertable/rbac/__init__.py +0 -0
  130. {supertable-2.3.7 → supertable-2.3.8}/supertable/rbac/access_control.py +0 -0
  131. {supertable-2.3.7 → supertable-2.3.8}/supertable/rbac/filter_builder.py +0 -0
  132. {supertable-2.3.7 → supertable-2.3.8}/supertable/rbac/permissions.py +0 -0
  133. {supertable-2.3.7 → supertable-2.3.8}/supertable/rbac/role_manager.py +0 -0
  134. {supertable-2.3.7 → supertable-2.3.8}/supertable/rbac/row_column_security.py +0 -0
  135. {supertable-2.3.7 → supertable-2.3.8}/supertable/rbac/tests/test_filter_builder.py +0 -0
  136. {supertable-2.3.7 → supertable-2.3.8}/supertable/rbac/tests/test_rbac.py +0 -0
  137. {supertable-2.3.7 → supertable-2.3.8}/supertable/rbac/tests/test_rbac_per_table.py +0 -0
  138. {supertable-2.3.7 → supertable-2.3.8}/supertable/rbac/user_manager.py +0 -0
  139. {supertable-2.3.7 → supertable-2.3.8}/supertable/redis_catalog.py +0 -0
  140. {supertable-2.3.7 → supertable-2.3.8}/supertable/redis_connector.py +0 -0
  141. {supertable-2.3.7 → supertable-2.3.8}/supertable/redis_infra.py +0 -0
  142. {supertable-2.3.7 → supertable-2.3.8}/supertable/simple_table.py +0 -0
  143. {supertable-2.3.7 → supertable-2.3.8}/supertable/staging_area.py +0 -0
  144. {supertable-2.3.7 → supertable-2.3.8}/supertable/storage/__init__.py +0 -0
  145. {supertable-2.3.7 → supertable-2.3.8}/supertable/storage/azure_storage.py +0 -0
  146. {supertable-2.3.7 → supertable-2.3.8}/supertable/storage/gcp_storage.py +0 -0
  147. {supertable-2.3.7 → supertable-2.3.8}/supertable/storage/local_storage.py +0 -0
  148. {supertable-2.3.7 → supertable-2.3.8}/supertable/storage/minio_storage.py +0 -0
  149. {supertable-2.3.7 → supertable-2.3.8}/supertable/storage/s3_storage.py +0 -0
  150. {supertable-2.3.7 → supertable-2.3.8}/supertable/storage/storage_factory.py +0 -0
  151. {supertable-2.3.7 → supertable-2.3.8}/supertable/storage/storage_interface.py +0 -0
  152. {supertable-2.3.7 → supertable-2.3.8}/supertable/storage/tests/test_storage.py +0 -0
  153. {supertable-2.3.7 → supertable-2.3.8}/supertable/super_pipe.py +0 -0
  154. {supertable-2.3.7 → supertable-2.3.8}/supertable/super_table.py +0 -0
  155. {supertable-2.3.7 → supertable-2.3.8}/supertable/system_query.py +0 -0
  156. {supertable-2.3.7 → supertable-2.3.8}/supertable/tests/__init__.py +0 -0
  157. {supertable-2.3.7 → supertable-2.3.8}/supertable/tests/test_align_to_schema_fix.py +0 -0
  158. {supertable-2.3.7 → supertable-2.3.8}/supertable/tests/test_create_if_missing.py +0 -0
  159. {supertable-2.3.7 → supertable-2.3.8}/supertable/tests/test_data_reader.py +0 -0
  160. {supertable-2.3.7 → supertable-2.3.8}/supertable/tests/test_data_reader_preflight.py +0 -0
  161. {supertable-2.3.7 → supertable-2.3.8}/supertable/tests/test_data_writer.py +0 -0
  162. {supertable-2.3.7 → supertable-2.3.8}/supertable/tests/test_data_writer_compact.py +0 -0
  163. {supertable-2.3.7 → supertable-2.3.8}/supertable/tests/test_data_writer_comprehensive.py +0 -0
  164. {supertable-2.3.7 → supertable-2.3.8}/supertable/tests/test_errors.py +0 -0
  165. {supertable-2.3.7 → supertable-2.3.8}/supertable/tests/test_meta_reader.py +0 -0
  166. {supertable-2.3.7 → supertable-2.3.8}/supertable/tests/test_monitoring_partitions.py +0 -0
  167. {supertable-2.3.7 → supertable-2.3.8}/supertable/tests/test_monitoring_sink_guard.py +0 -0
  168. {supertable-2.3.7 → supertable-2.3.8}/supertable/tests/test_newer_than.py +0 -0
  169. {supertable-2.3.7 → supertable-2.3.8}/supertable/tests/test_parquet_statistics.py +0 -0
  170. {supertable-2.3.7 → supertable-2.3.8}/supertable/tests/test_processing_compact_resources.py +0 -0
  171. {supertable-2.3.7 → supertable-2.3.8}/supertable/tests/test_processing_stats.py +0 -0
  172. {supertable-2.3.7 → supertable-2.3.8}/supertable/tests/test_query_sql.py +0 -0
  173. {supertable-2.3.7 → supertable-2.3.8}/supertable/tests/test_read_pruning_differential.py +0 -0
  174. {supertable-2.3.7 → supertable-2.3.8}/supertable/tests/test_read_pruning_integration.py +0 -0
  175. {supertable-2.3.7 → supertable-2.3.8}/supertable/tests/test_redis_key_prefix.py +0 -0
  176. {supertable-2.3.7 → supertable-2.3.8}/supertable/tests/test_resolve_overwrite_writes.py +0 -0
  177. {supertable-2.3.7 → supertable-2.3.8}/supertable/tests/test_simple_table.py +0 -0
  178. {supertable-2.3.7 → supertable-2.3.8}/supertable/tests/test_stats_cache.py +0 -0
  179. {supertable-2.3.7 → supertable-2.3.8}/supertable/tests/test_stats_pruning.py +0 -0
  180. {supertable-2.3.7 → supertable-2.3.8}/supertable/tests/test_stats_schema_snapshot.py +0 -0
  181. {supertable-2.3.7 → supertable-2.3.8}/supertable/tests/test_super_table.py +0 -0
  182. {supertable-2.3.7 → supertable-2.3.8}/supertable/tests/test_supertable_all.py +0 -0
  183. {supertable-2.3.7 → supertable-2.3.8}/supertable/tests/test_system_query.py +0 -0
  184. {supertable-2.3.7 → supertable-2.3.8}/supertable/tests/test_write_probe_gate.py +0 -0
  185. {supertable-2.3.7 → supertable-2.3.8}/supertable/utils/__init__.py +0 -0
  186. {supertable-2.3.7 → supertable-2.3.8}/supertable/utils/helper.py +0 -0
  187. {supertable-2.3.7 → supertable-2.3.8}/supertable/utils/profiler.py +0 -0
  188. {supertable-2.3.7 → supertable-2.3.8}/supertable/utils/sql_parser.py +0 -0
  189. {supertable-2.3.7 → supertable-2.3.8}/supertable/utils/tests/test_sql_parser_columns.py +0 -0
  190. {supertable-2.3.7 → supertable-2.3.8}/supertable/utils/timer.py +0 -0
  191. {supertable-2.3.7 → supertable-2.3.8}/supertable.egg-info/dependency_links.txt +0 -0
  192. {supertable-2.3.7 → supertable-2.3.8}/supertable.egg-info/entry_points.txt +0 -0
  193. {supertable-2.3.7 → supertable-2.3.8}/supertable.egg-info/requires.txt +0 -0
  194. {supertable-2.3.7 → supertable-2.3.8}/supertable.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: supertable
3
- Version: 2.3.7
3
+ Version: 2.3.8
4
4
  Summary: SuperTable — versioned data lake library for SQL analytics on Parquet + Redis.
5
5
  Author: Levente Kupas
6
6
  Author-email: Levente Kupas <lkupas@kladnasoft.com>
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "supertable"
7
- version = "2.3.7"
7
+ version = "2.3.8"
8
8
  description = "SuperTable — versioned data lake library for SQL analytics on Parquet + Redis."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -19,7 +19,7 @@ long_description = readme.read_text(encoding="utf-8") if readme.exists() else ""
19
19
 
20
20
  setup(
21
21
  name="supertable",
22
- version="2.3.7",
22
+ version="2.3.8",
23
23
  description="SuperTable — versioned data lake library for SQL analytics on Parquet + Redis.",
24
24
  long_description=long_description,
25
25
  long_description_content_type="text/markdown",
@@ -25,7 +25,7 @@ See the ``supertable.demo`` package for runnable end-to-end demos and the
25
25
  project documentation for the full API surface.
26
26
  """
27
27
 
28
- __version__ = "2.3.7"
28
+ __version__ = "2.3.8"
29
29
 
30
30
  # Re-export the core public surface so users can do ``from supertable import …``
31
31
  # instead of remembering submodule paths.
@@ -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:
@@ -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