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.
- {supertable-2.3.7/supertable.egg-info → supertable-2.3.8}/PKG-INFO +1 -1
- {supertable-2.3.7 → supertable-2.3.8}/pyproject.toml +1 -1
- {supertable-2.3.7 → supertable-2.3.8}/setup.py +1 -1
- {supertable-2.3.7 → supertable-2.3.8}/supertable/__init__.py +1 -1
- {supertable-2.3.7 → supertable-2.3.8}/supertable/data_writer.py +76 -6
- {supertable-2.3.7 → supertable-2.3.8}/supertable/engine/spark_thrift.py +188 -38
- {supertable-2.3.7 → supertable-2.3.8}/supertable/engine/tests/test_engine.py +244 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/processing.py +59 -56
- supertable-2.3.8/supertable/quality/__init__.py +23 -0
- supertable-2.3.8/supertable/quality/anomaly.py +250 -0
- supertable-2.3.8/supertable/quality/checker.py +474 -0
- supertable-2.3.8/supertable/quality/config.py +401 -0
- supertable-2.3.8/supertable/quality/history.py +241 -0
- supertable-2.3.8/supertable/quality/scheduler.py +753 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/redis_keys.py +1 -1
- supertable-2.3.8/supertable/tests/test_compaction_selection.py +335 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/tests/test_processing.py +90 -1
- {supertable-2.3.7 → supertable-2.3.8/supertable.egg-info}/PKG-INFO +1 -1
- {supertable-2.3.7 → supertable-2.3.8}/supertable.egg-info/SOURCES.txt +7 -0
- {supertable-2.3.7 → supertable-2.3.8}/LICENSE +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/README.md +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/requirements.txt +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/setup.cfg +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/audit/__init__.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/audit/admin.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/audit/chain.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/audit/consumers.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/audit/crypto.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/audit/events.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/audit/export.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/audit/logger.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/audit/middleware.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/audit/reader.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/audit/retention.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/audit/tests/__init__.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/audit/tests/test_chain.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/audit/tests/test_crypto.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/audit/tests/test_emit.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/audit/tests/test_events.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/audit/tests/test_retention.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/audit/writer_parquet.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/audit/writer_redis.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/config/__init__.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/config/defaults.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/config/homedir.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/config/settings.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/config/tests/__init__.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/config/tests/test_defaults.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/config/tests/test_homedir.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/config/tests/test_settings.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/data_classes.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/data_reader.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/demo/__init__.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/demo/quickstart/__init__.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/demo/quickstart/__main__.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/demo/quickstart/check_filter_builder.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/demo/quickstart/controller.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/demo/quickstart/data_writer_helpers.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/demo/quickstart/defaults.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/demo/quickstart/dummy_data.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/demo/quickstart/read_parquet_header.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/demo/quickstart/s01_01_01_create_super_table.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/demo/quickstart/s01_01_02_enable_mirroring_formats.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/demo/quickstart/s01_02_create_roles.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/demo/quickstart/s01_03_create_users.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/demo/quickstart/s02_01_write_dummy_data.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/demo/quickstart/s02_02_write_single_data.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/demo/quickstart/s02_03_01_write_staging.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/demo/quickstart/s02_03_02_create_pipe.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/demo/quickstart/s02_04_01_write_monitoring_simple.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/demo/quickstart/s02_04_02_write_monitoring_parallel.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/demo/quickstart/s02_05_write_tombstone.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/demo/quickstart/s03_01_read_data_error.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/demo/quickstart/s03_02_01_read_super_data_ok.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/demo/quickstart/s03_02_02_read_table_data_ok.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/demo/quickstart/s03_03_read_meta.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/demo/quickstart/s03_04_read_staging.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/demo/quickstart/s03_06_01_read_roles.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/demo/quickstart/s03_06_02_read_user.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/demo/quickstart/s03_07_01_estimate_read.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/demo/quickstart/s03_07_02_estimate_files.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/demo/quickstart/s03_08_read_snapshot_history.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/demo/quickstart/s04_01_03_delete_pipe.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/demo/quickstart/s05_01_delete_table.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/demo/quickstart/s05_02_delete_super_table.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/demo/webshop/__init__.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/demo/webshop/core.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/demo/webshop/defaults.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/demo/webshop/generate.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/demo/webshop/load.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/demo/webshop/topup.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/engine/__init__.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/engine/data_estimator.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/engine/duckdb_lite.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/engine/duckdb_pro.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/engine/engine_common.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/engine/engine_config.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/engine/engine_enum.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/engine/executor.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/engine/plan_stats.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/engine/tests/__init__.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/engine/tests/conftest.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/engine/tests/test_engine_config.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/engine/tests/test_engine_routing.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/engine/tests/test_engine_spill.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/errors.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/locking/__init__.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/locking/benchmarks/__init__.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/locking/benchmarks/benchmark_locking.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/locking/benchmarks/measure_lock_speed.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/locking/benchmarks/measure_lock_time.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/locking/file_lock.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/locking/redis_lock.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/locking/tests/__init__.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/locking/tests/test_file_lock.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/locking/tests/test_redis_lock.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/logging.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/meta_reader.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/mirroring/__init__.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/mirroring/mirror_delta.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/mirroring/mirror_formats.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/mirroring/mirror_iceberg.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/mirroring/mirror_parquet.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/monitoring/__init__.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/monitoring/partitions.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/monitoring_writer.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/plan_extender.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/query_plan_manager.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/rbac/__init__.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/rbac/access_control.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/rbac/filter_builder.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/rbac/permissions.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/rbac/role_manager.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/rbac/row_column_security.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/rbac/tests/test_filter_builder.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/rbac/tests/test_rbac.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/rbac/tests/test_rbac_per_table.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/rbac/user_manager.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/redis_catalog.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/redis_connector.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/redis_infra.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/simple_table.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/staging_area.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/storage/__init__.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/storage/azure_storage.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/storage/gcp_storage.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/storage/local_storage.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/storage/minio_storage.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/storage/s3_storage.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/storage/storage_factory.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/storage/storage_interface.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/storage/tests/test_storage.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/super_pipe.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/super_table.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/system_query.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/tests/__init__.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/tests/test_align_to_schema_fix.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/tests/test_create_if_missing.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/tests/test_data_reader.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/tests/test_data_reader_preflight.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/tests/test_data_writer.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/tests/test_data_writer_compact.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/tests/test_data_writer_comprehensive.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/tests/test_errors.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/tests/test_meta_reader.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/tests/test_monitoring_partitions.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/tests/test_monitoring_sink_guard.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/tests/test_newer_than.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/tests/test_parquet_statistics.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/tests/test_processing_compact_resources.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/tests/test_processing_stats.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/tests/test_query_sql.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/tests/test_read_pruning_differential.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/tests/test_read_pruning_integration.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/tests/test_redis_key_prefix.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/tests/test_resolve_overwrite_writes.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/tests/test_simple_table.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/tests/test_stats_cache.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/tests/test_stats_pruning.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/tests/test_stats_schema_snapshot.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/tests/test_super_table.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/tests/test_supertable_all.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/tests/test_system_query.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/tests/test_write_probe_gate.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/utils/__init__.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/utils/helper.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/utils/profiler.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/utils/sql_parser.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/utils/tests/test_sql_parser_columns.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable/utils/timer.py +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable.egg-info/dependency_links.txt +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable.egg-info/entry_points.txt +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable.egg-info/requires.txt +0 -0
- {supertable-2.3.7 → supertable-2.3.8}/supertable.egg-info/top_level.txt +0 -0
|
@@ -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.
|
|
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.
|
|
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
|
-
#
|
|
1034
|
-
#
|
|
1035
|
-
#
|
|
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.
|
|
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
|
-
|
|
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
|
|
830
|
+
# 4a. Wrap parquet views to convert nanosecond-epoch BIGINT columns
|
|
671
831
|
# back to TIMESTAMP. When nanosAsLong=true, Spark reads
|
|
672
|
-
# TIMESTAMP(NANOS
|
|
673
|
-
#
|
|
674
|
-
#
|
|
675
|
-
#
|
|
676
|
-
#
|
|
677
|
-
#
|
|
678
|
-
#
|
|
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
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
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
|
-
|
|
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]
|
|
722
|
-
f"converted {len(cast_cols)} column(s)
|
|
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
|
|
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
|
|
879
|
+
f"{log_prefix}[spark.thrift] timestamp wrapper "
|
|
730
880
|
f"failed for {table_name} (non-fatal): {cast_err}"
|
|
731
881
|
)
|
|
732
882
|
|