supertable 2.2.7__tar.gz → 2.2.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.2.7/supertable.egg-info → supertable-2.2.8}/PKG-INFO +1 -1
- {supertable-2.2.7 → supertable-2.2.8}/pyproject.toml +1 -1
- {supertable-2.2.7 → supertable-2.2.8}/setup.py +1 -1
- {supertable-2.2.7 → supertable-2.2.8}/supertable/__init__.py +1 -1
- {supertable-2.2.7 → supertable-2.2.8}/supertable/config/settings.py +2 -2
- {supertable-2.2.7 → supertable-2.2.8}/supertable/engine/engine_common.py +330 -25
- {supertable-2.2.7 → supertable-2.2.8}/supertable/engine/engine_config.py +2 -2
- {supertable-2.2.7 → supertable-2.2.8}/supertable/engine/executor.py +50 -48
- supertable-2.2.8/supertable/engine/tests/test_engine_routing.py +192 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/redis_catalog.py +16 -16
- {supertable-2.2.7 → supertable-2.2.8/supertable.egg-info}/PKG-INFO +1 -1
- supertable-2.2.7/supertable/engine/tests/test_engine_routing.py +0 -110
- {supertable-2.2.7 → supertable-2.2.8}/LICENSE +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/README.md +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/requirements.txt +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/setup.cfg +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/audit/__init__.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/audit/admin.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/audit/chain.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/audit/consumers.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/audit/crypto.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/audit/events.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/audit/export.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/audit/logger.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/audit/middleware.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/audit/reader.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/audit/retention.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/audit/tests/__init__.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/audit/tests/test_chain.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/audit/tests/test_crypto.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/audit/tests/test_emit.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/audit/tests/test_events.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/audit/tests/test_retention.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/audit/writer_parquet.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/audit/writer_redis.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/config/__init__.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/config/defaults.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/config/homedir.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/config/tests/__init__.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/config/tests/test_defaults.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/config/tests/test_homedir.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/config/tests/test_settings.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/data_classes.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/data_reader.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/data_writer.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/demo/__init__.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/demo/quickstart/__init__.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/demo/quickstart/__main__.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/demo/quickstart/check_filter_builder.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/demo/quickstart/controller.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/demo/quickstart/data_writer_helpers.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/demo/quickstart/defaults.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/demo/quickstart/dummy_data.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/demo/quickstart/read_parquet_header.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/demo/quickstart/s01_01_01_create_super_table.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/demo/quickstart/s01_01_02_enable_mirroring_formats.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/demo/quickstart/s01_02_create_roles.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/demo/quickstart/s01_03_create_users.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/demo/quickstart/s02_01_write_dummy_data.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/demo/quickstart/s02_02_write_single_data.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/demo/quickstart/s02_03_01_write_staging.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/demo/quickstart/s02_03_02_create_pipe.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/demo/quickstart/s02_04_01_write_monitoring_simple.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/demo/quickstart/s02_04_02_write_monitoring_parallel.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/demo/quickstart/s02_05_write_tombstone.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/demo/quickstart/s03_01_read_data_error.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/demo/quickstart/s03_02_01_read_super_data_ok.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/demo/quickstart/s03_02_02_read_table_data_ok.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/demo/quickstart/s03_03_read_meta.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/demo/quickstart/s03_04_read_staging.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/demo/quickstart/s03_06_01_read_roles.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/demo/quickstart/s03_06_02_read_user.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/demo/quickstart/s03_07_01_estimate_read.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/demo/quickstart/s03_07_02_estimate_files.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/demo/quickstart/s03_08_read_snapshot_history.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/demo/quickstart/s04_01_03_delete_pipe.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/demo/quickstart/s05_01_delete_table.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/demo/quickstart/s05_02_delete_super_table.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/demo/webshop/__init__.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/demo/webshop/core.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/demo/webshop/defaults.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/demo/webshop/generate.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/demo/webshop/load.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/demo/webshop/topup.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/engine/__init__.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/engine/data_estimator.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/engine/duckdb_lite.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/engine/duckdb_pro.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/engine/engine_enum.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/engine/plan_stats.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/engine/spark_thrift.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/engine/tests/__init__.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/engine/tests/conftest.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/engine/tests/test_dedup_read.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/engine/tests/test_engine.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/engine/tests/test_engine_config.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/engine/tests/test_engine_spill.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/errors.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/locking/__init__.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/locking/benchmarks/__init__.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/locking/benchmarks/benchmark_locking.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/locking/benchmarks/measure_lock_speed.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/locking/benchmarks/measure_lock_time.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/locking/file_lock.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/locking/redis_lock.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/locking/tests/__init__.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/locking/tests/test_file_lock.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/locking/tests/test_redis_lock.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/logging.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/meta_reader.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/mirroring/__init__.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/mirroring/mirror_delta.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/mirroring/mirror_formats.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/mirroring/mirror_iceberg.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/mirroring/mirror_parquet.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/monitoring/__init__.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/monitoring/partitions.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/monitoring_writer.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/plan_extender.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/processing.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/query_plan_manager.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/rbac/__init__.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/rbac/access_control.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/rbac/filter_builder.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/rbac/permissions.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/rbac/role_manager.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/rbac/row_column_security.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/rbac/tests/test_filter_builder.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/rbac/tests/test_rbac.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/rbac/tests/test_rbac_per_table.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/rbac/user_manager.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/redis_connector.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/redis_infra.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/redis_keys.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/simple_table.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/staging_area.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/storage/__init__.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/storage/azure_storage.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/storage/gcp_storage.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/storage/local_storage.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/storage/minio_storage.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/storage/s3_storage.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/storage/storage_factory.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/storage/storage_interface.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/storage/tests/test_storage.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/super_pipe.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/super_table.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/tests/__init__.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/tests/test_align_to_schema_fix.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/tests/test_create_if_missing.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/tests/test_data_reader.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/tests/test_data_reader_preflight.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/tests/test_data_writer.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/tests/test_data_writer_compact.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/tests/test_data_writer_comprehensive.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/tests/test_data_writer_tombstones.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/tests/test_dedup_on_read_write.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/tests/test_errors.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/tests/test_meta_reader.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/tests/test_monitoring_partitions.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/tests/test_monitoring_sink_guard.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/tests/test_newer_than.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/tests/test_process_delete_only.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/tests/test_processing.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/tests/test_processing_compact_resources.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/tests/test_query_sql.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/tests/test_redis_key_prefix.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/tests/test_simple_table.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/tests/test_small_file_compaction.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/tests/test_super_table.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/tests/test_supertable_all.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/utils/__init__.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/utils/helper.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/utils/profiler.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/utils/sql_parser.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/utils/tests/test_sql_parser_columns.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable/utils/timer.py +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable.egg-info/SOURCES.txt +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable.egg-info/dependency_links.txt +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable.egg-info/entry_points.txt +0 -0
- {supertable-2.2.7 → supertable-2.2.8}/supertable.egg-info/requires.txt +0 -0
- {supertable-2.2.7 → supertable-2.2.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.2.
|
|
22
|
+
version="2.2.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.2.
|
|
28
|
+
__version__ = "2.2.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.
|
|
@@ -160,7 +160,7 @@ class Settings:
|
|
|
160
160
|
|
|
161
161
|
# ── Engine Routing / Executor ────────────────────────────────────
|
|
162
162
|
SUPERTABLE_ENGINE_LITE_MAX_BYTES: int = 100 * 1024 * 1024 # SUPERTABLE_ENGINE_LITE_MAX_BYTES (100 MB)
|
|
163
|
-
SUPERTABLE_ENGINE_SPARK_MIN_BYTES: int =
|
|
163
|
+
SUPERTABLE_ENGINE_SPARK_MIN_BYTES: int = 0 # fallback only; 0 = active fleet's min_bytes drives Spark routing
|
|
164
164
|
SUPERTABLE_ENGINE_FRESHNESS_SEC: int = 300 # SUPERTABLE_ENGINE_FRESHNESS_SEC
|
|
165
165
|
SUPERTABLE_DEFAULT_ENGINE: str = "AUTO" # SUPERTABLE_DEFAULT_ENGINE
|
|
166
166
|
|
|
@@ -410,7 +410,7 @@ def _build_settings() -> Settings:
|
|
|
410
410
|
|
|
411
411
|
# ── Engine Routing ───────────────────────────────────────────
|
|
412
412
|
SUPERTABLE_ENGINE_LITE_MAX_BYTES=_env_int("SUPERTABLE_ENGINE_LITE_MAX_BYTES", 100 * 1024 * 1024),
|
|
413
|
-
SUPERTABLE_ENGINE_SPARK_MIN_BYTES=_env_int("SUPERTABLE_ENGINE_SPARK_MIN_BYTES",
|
|
413
|
+
SUPERTABLE_ENGINE_SPARK_MIN_BYTES=_env_int("SUPERTABLE_ENGINE_SPARK_MIN_BYTES", 0),
|
|
414
414
|
SUPERTABLE_ENGINE_FRESHNESS_SEC=_env_int("SUPERTABLE_ENGINE_FRESHNESS_SEC", 300),
|
|
415
415
|
SUPERTABLE_DEFAULT_ENGINE=_env_str("SUPERTABLE_DEFAULT_ENGINE", "AUTO"),
|
|
416
416
|
|
|
@@ -4,7 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import hashlib
|
|
6
6
|
import os
|
|
7
|
-
from typing import Dict, List, Optional
|
|
7
|
+
from typing import Any, Dict, List, Optional
|
|
8
8
|
from urllib.parse import urlparse
|
|
9
9
|
|
|
10
10
|
import duckdb
|
|
@@ -282,11 +282,15 @@ def configure_httpfs_and_s3(
|
|
|
282
282
|
"true" if meta_cache_on else "false",
|
|
283
283
|
)
|
|
284
284
|
|
|
285
|
-
# External file cache
|
|
286
|
-
#
|
|
287
|
-
#
|
|
285
|
+
# External file cache — an in-memory cache of remote data blocks so
|
|
286
|
+
# repeated queries do not re-download the same row groups. It is only
|
|
287
|
+
# enabled when a size cap can be enforced: DuckDB builds without
|
|
288
|
+
# external_file_cache_max_size (e.g. 1.5.x) cannot bound it, and an
|
|
289
|
+
# uncapped cache grows to memory_limit on the persistent connection.
|
|
290
|
+
# Without an enforceable cap we keep it OFF to protect memory.
|
|
288
291
|
cache_size = settings.SUPERTABLE_DUCKDB_EXTERNAL_CACHE_SIZE
|
|
289
|
-
|
|
292
|
+
can_cap = "external_file_cache_max_size" in supported
|
|
293
|
+
if cache_size and can_cap:
|
|
290
294
|
set_if_supported("enable_external_file_cache", "true")
|
|
291
295
|
set_if_supported("external_file_cache_max_size", f"'{cache_size}'")
|
|
292
296
|
cache_dir_raw = settings.SUPERTABLE_DUCKDB_EXTERNAL_CACHE_DIR
|
|
@@ -302,6 +306,15 @@ def configure_httpfs_and_s3(
|
|
|
302
306
|
+ (f" size={cache_size}" if cache_size else "")
|
|
303
307
|
+ f", dir={cache_dir}"
|
|
304
308
|
)
|
|
309
|
+
else:
|
|
310
|
+
# Uncappable (or disabled via empty size) — turn it off explicitly so
|
|
311
|
+
# the DuckDB 1.5.x default-on cache cannot accumulate in memory.
|
|
312
|
+
set_if_supported("enable_external_file_cache", "false")
|
|
313
|
+
if cache_size and not can_cap:
|
|
314
|
+
logger.info(
|
|
315
|
+
"[duckdb.cache] external file cache disabled: this DuckDB build "
|
|
316
|
+
"cannot cap it (no external_file_cache_max_size)"
|
|
317
|
+
)
|
|
305
318
|
|
|
306
319
|
|
|
307
320
|
# =========================================================
|
|
@@ -555,6 +568,25 @@ def rewrite_query_with_hashed_tables(
|
|
|
555
568
|
# Connection initialization
|
|
556
569
|
# =========================================================
|
|
557
570
|
|
|
571
|
+
def _external_file_cache_cappable(con: duckdb.DuckDBPyConnection) -> bool:
|
|
572
|
+
"""True when this DuckDB build can bound the external file cache size.
|
|
573
|
+
|
|
574
|
+
DuckDB 1.5.x enables ``enable_external_file_cache`` by default but does
|
|
575
|
+
not expose ``external_file_cache_max_size``. An enabled-but-uncapped
|
|
576
|
+
cache is held in memory (not on disk) and grows to ``memory_limit`` on
|
|
577
|
+
the long-lived persistent connection — a sustained-memory / OOM hazard.
|
|
578
|
+
When this returns False the cache is disabled outright rather than left
|
|
579
|
+
running unbounded.
|
|
580
|
+
"""
|
|
581
|
+
try:
|
|
582
|
+
return bool(con.execute(
|
|
583
|
+
"SELECT 1 FROM duckdb_settings() "
|
|
584
|
+
"WHERE name = 'external_file_cache_max_size'"
|
|
585
|
+
).fetchone())
|
|
586
|
+
except Exception:
|
|
587
|
+
return False
|
|
588
|
+
|
|
589
|
+
|
|
558
590
|
def init_connection(
|
|
559
591
|
con: duckdb.DuckDBPyConnection,
|
|
560
592
|
temp_dir: str,
|
|
@@ -616,6 +648,17 @@ def init_connection(
|
|
|
616
648
|
except Exception:
|
|
617
649
|
pass # older DuckDB builds may not support this setting
|
|
618
650
|
|
|
651
|
+
# External file cache baseline. DuckDB 1.5.x turns the cache ON by
|
|
652
|
+
# default but cannot cap it, so an uncapped in-memory cache accumulates
|
|
653
|
+
# remote data up to memory_limit on the persistent connection. Disable
|
|
654
|
+
# it here when uncappable; configure_httpfs_and_s3 / apply_runtime_pragmas
|
|
655
|
+
# re-enable it (capped) only on builds that support a size cap.
|
|
656
|
+
if not _external_file_cache_cappable(con):
|
|
657
|
+
try:
|
|
658
|
+
con.execute("SET enable_external_file_cache=false;")
|
|
659
|
+
except Exception:
|
|
660
|
+
pass
|
|
661
|
+
|
|
619
662
|
# Thread count.
|
|
620
663
|
# If SUPERTABLE_DUCKDB_THREADS is set explicitly, honour it exactly.
|
|
621
664
|
# Otherwise derive from the effective memory limit using the IO-thread
|
|
@@ -685,32 +728,294 @@ def apply_runtime_pragmas(con: duckdb.DuckDBPyConnection, cfg) -> None:
|
|
|
685
728
|
except Exception:
|
|
686
729
|
pass
|
|
687
730
|
|
|
731
|
+
# External file cache: only run it when the size cap is enforceable.
|
|
732
|
+
# On DuckDB builds without external_file_cache_max_size (e.g. 1.5.x) an
|
|
733
|
+
# enabled cache is in-memory and unbounded — it grows to memory_limit on
|
|
734
|
+
# the persistent connection — so we disable it instead of running uncapped.
|
|
688
735
|
cache_size = normalize_memory_size(cfg.duckdb_external_cache_size, default="")
|
|
689
|
-
if cache_size:
|
|
736
|
+
if cache_size and _external_file_cache_cappable(con):
|
|
690
737
|
try:
|
|
691
738
|
con.execute("SET enable_external_file_cache=true;")
|
|
739
|
+
con.execute(
|
|
740
|
+
f"SET external_file_cache_max_size='{sanitize_sql_string(cache_size)}';"
|
|
741
|
+
)
|
|
692
742
|
except Exception as e:
|
|
693
|
-
logger.warning(f"[duckdb.pragma]
|
|
694
|
-
|
|
695
|
-
# does not exist on every DuckDB build (e.g. 1.5.x). Probe the
|
|
696
|
-
# settings catalog and only set it when present so we neither raise
|
|
697
|
-
# nor silently swallow a genuinely unsupported setting.
|
|
743
|
+
logger.warning(f"[duckdb.pragma] external file cache config failed: {e}")
|
|
744
|
+
else:
|
|
698
745
|
try:
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
746
|
+
con.execute("SET enable_external_file_cache=false;")
|
|
747
|
+
except Exception:
|
|
748
|
+
pass
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
# =========================================================
|
|
752
|
+
# Engine self-diagnostics (UI "Diagnose" button)
|
|
753
|
+
# =========================================================
|
|
754
|
+
|
|
755
|
+
def _filesystem_type(path: str) -> str:
|
|
756
|
+
"""Best-effort filesystem type for ``path`` via /proc/mounts (Linux).
|
|
757
|
+
|
|
758
|
+
Used to warn when the spill directory is RAM-backed (tmpfs/ramfs), where
|
|
759
|
+
"spilling to disk" would actually consume memory instead of relieving it.
|
|
760
|
+
Returns "" when the type cannot be determined.
|
|
761
|
+
"""
|
|
762
|
+
try:
|
|
763
|
+
target = os.path.abspath(path)
|
|
764
|
+
best_mp = ""
|
|
765
|
+
best_type = ""
|
|
766
|
+
with open("/proc/mounts", "r") as fh:
|
|
767
|
+
for line in fh:
|
|
768
|
+
parts = line.split()
|
|
769
|
+
if len(parts) < 3:
|
|
770
|
+
continue
|
|
771
|
+
mount_point, fstype = parts[1], parts[2]
|
|
772
|
+
if (
|
|
773
|
+
target == mount_point
|
|
774
|
+
or target.startswith(mount_point.rstrip("/") + "/")
|
|
775
|
+
or mount_point == "/"
|
|
776
|
+
):
|
|
777
|
+
if len(mount_point) >= len(best_mp):
|
|
778
|
+
best_mp = mount_point
|
|
779
|
+
best_type = fstype
|
|
780
|
+
return best_type
|
|
781
|
+
except Exception:
|
|
782
|
+
return ""
|
|
783
|
+
|
|
784
|
+
|
|
785
|
+
def run_engine_diagnostics(cfg=None, engine: str = "lite") -> Dict[str, Any]:
|
|
786
|
+
"""Deep runtime self-check for a DuckDB engine.
|
|
787
|
+
|
|
788
|
+
Unlike a connection "test", this exercises the runtime to confirm the
|
|
789
|
+
things that silently break in production:
|
|
790
|
+
|
|
791
|
+
* the memory limit is actually applied,
|
|
792
|
+
* the spill (``temp_directory``) exists, is writable, and is on real
|
|
793
|
+
disk (not a RAM-backed tmpfs),
|
|
794
|
+
* a query that exceeds memory genuinely spills to disk instead of OOMing,
|
|
795
|
+
* the external file cache is in a memory-safe state.
|
|
796
|
+
|
|
797
|
+
``cfg`` is an ``EngineRuntimeConfig`` (or None to use init defaults); the
|
|
798
|
+
connection is configured exactly like a live Lite/Pro query via
|
|
799
|
+
``init_connection`` + ``apply_runtime_pragmas``. Returns a JSON-serialisable
|
|
800
|
+
report and never raises.
|
|
801
|
+
"""
|
|
802
|
+
import shutil
|
|
803
|
+
import time
|
|
804
|
+
import uuid
|
|
805
|
+
|
|
806
|
+
checks: List[Dict[str, Any]] = []
|
|
807
|
+
|
|
808
|
+
def add(cid, label, status, detail="", value=""):
|
|
809
|
+
checks.append({
|
|
810
|
+
"id": cid,
|
|
811
|
+
"label": label,
|
|
812
|
+
"status": status,
|
|
813
|
+
"detail": str(detail),
|
|
814
|
+
"value": "" if value is None else str(value),
|
|
815
|
+
})
|
|
816
|
+
|
|
817
|
+
# 1. Open + configure a connection the same way the engine does.
|
|
818
|
+
con = None
|
|
819
|
+
try:
|
|
820
|
+
con = duckdb.connect()
|
|
821
|
+
init_connection(con, temp_dir="diagnostics")
|
|
822
|
+
if cfg is not None:
|
|
823
|
+
apply_runtime_pragmas(con, cfg)
|
|
824
|
+
add("connect", "Engine connection", "ok",
|
|
825
|
+
"Opened and configured a DuckDB connection")
|
|
826
|
+
except Exception as e:
|
|
827
|
+
add("connect", "Engine connection", "fail", f"Could not initialise: {e}")
|
|
828
|
+
return {"engine": engine, "duckdb_version": "", "overall": "fail", "checks": checks}
|
|
829
|
+
|
|
830
|
+
# 2. DuckDB version + whether the file cache can be capped on this build.
|
|
831
|
+
version = ""
|
|
832
|
+
cappable = False
|
|
833
|
+
try:
|
|
834
|
+
version = con.execute("SELECT version()").fetchone()[0]
|
|
835
|
+
cappable = _external_file_cache_cappable(con)
|
|
836
|
+
add("version", "DuckDB version", "ok" if cappable else "warn",
|
|
837
|
+
("external_file_cache_max_size supported — the file cache can be capped"
|
|
838
|
+
if cappable else
|
|
839
|
+
"this build has no external_file_cache_max_size — the file cache "
|
|
840
|
+
"cannot be capped, so it is disabled to stay memory-safe"),
|
|
841
|
+
version)
|
|
842
|
+
except Exception as e:
|
|
843
|
+
add("version", "DuckDB version", "warn", f"version() failed: {e}")
|
|
844
|
+
|
|
845
|
+
# 3. Memory limit effective?
|
|
846
|
+
try:
|
|
847
|
+
mem = con.execute("SELECT current_setting('memory_limit')").fetchone()[0]
|
|
848
|
+
low = str(mem).strip().lower()
|
|
849
|
+
if not mem or low in ("0 bytes", "0", "-1") or "unlimited" in low:
|
|
850
|
+
add("memory", "Memory limit", "warn",
|
|
851
|
+
"No effective memory limit — a heavy query can consume all RAM", mem)
|
|
852
|
+
else:
|
|
853
|
+
add("memory", "Memory limit", "ok", "PRAGMA memory_limit is active", mem)
|
|
854
|
+
except Exception as e:
|
|
855
|
+
add("memory", "Memory limit", "fail", f"Could not read memory_limit: {e}")
|
|
856
|
+
|
|
857
|
+
# 4. Thread count.
|
|
858
|
+
try:
|
|
859
|
+
th = con.execute("SELECT current_setting('threads')").fetchone()[0]
|
|
860
|
+
add("threads", "Worker threads", "ok",
|
|
861
|
+
"More threads add parallelism but also raise simultaneous memory use", th)
|
|
862
|
+
except Exception as e:
|
|
863
|
+
add("threads", "Worker threads", "warn", f"Could not read threads: {e}")
|
|
864
|
+
|
|
865
|
+
# 5. Spill (temp) directory: set, exists, writable, on real disk?
|
|
866
|
+
temp_dir = ""
|
|
867
|
+
try:
|
|
868
|
+
temp_dir = con.execute(
|
|
869
|
+
"SELECT current_setting('temp_directory')"
|
|
870
|
+
).fetchone()[0] or ""
|
|
871
|
+
except Exception:
|
|
872
|
+
temp_dir = ""
|
|
873
|
+
|
|
874
|
+
if not temp_dir:
|
|
875
|
+
add("temp_dir", "Spill directory", "fail",
|
|
876
|
+
"temp_directory is empty — DuckDB cannot spill, so heavy queries OOM")
|
|
877
|
+
else:
|
|
878
|
+
mtds = ""
|
|
879
|
+
try:
|
|
880
|
+
mtds = con.execute(
|
|
881
|
+
"SELECT current_setting('max_temp_directory_size')"
|
|
882
|
+
).fetchone()[0]
|
|
883
|
+
except Exception:
|
|
884
|
+
mtds = ""
|
|
885
|
+
|
|
886
|
+
writable = False
|
|
887
|
+
werr = ""
|
|
888
|
+
try:
|
|
889
|
+
os.makedirs(temp_dir, exist_ok=True)
|
|
890
|
+
probe = os.path.join(temp_dir, f".st_spill_probe_{uuid.uuid4().hex}")
|
|
891
|
+
with open(probe, "wb") as fh:
|
|
892
|
+
fh.write(b"\0" * (1024 * 1024)) # 1 MiB
|
|
893
|
+
fh.flush()
|
|
894
|
+
os.fsync(fh.fileno())
|
|
895
|
+
os.remove(probe)
|
|
896
|
+
writable = True
|
|
712
897
|
except Exception as e:
|
|
713
|
-
|
|
898
|
+
werr = str(e)
|
|
899
|
+
|
|
900
|
+
free_gb = None
|
|
901
|
+
try:
|
|
902
|
+
free_gb = shutil.disk_usage(temp_dir).free / (1024 ** 3)
|
|
903
|
+
except Exception:
|
|
904
|
+
free_gb = None
|
|
905
|
+
fstype = _filesystem_type(temp_dir)
|
|
906
|
+
ram_backed = fstype in ("tmpfs", "ramfs")
|
|
907
|
+
|
|
908
|
+
parts = [f"path={temp_dir}"]
|
|
909
|
+
if mtds:
|
|
910
|
+
parts.append(f"cap={mtds}")
|
|
911
|
+
if free_gb is not None:
|
|
912
|
+
parts.append(f"free={free_gb:.1f} GB")
|
|
913
|
+
if fstype:
|
|
914
|
+
parts.append(f"fs={fstype}")
|
|
915
|
+
summary = "; ".join(parts)
|
|
916
|
+
|
|
917
|
+
if not writable:
|
|
918
|
+
add("temp_dir", "Spill directory writable", "fail",
|
|
919
|
+
f"Cannot write to the spill directory — queries OOM instead of "
|
|
920
|
+
f"spilling. {werr} ({summary})", temp_dir)
|
|
921
|
+
elif ram_backed:
|
|
922
|
+
add("temp_dir", "Spill directory writable", "warn",
|
|
923
|
+
f"Writable but RAM-backed ({fstype}) — spilling here consumes memory "
|
|
924
|
+
f"instead of relieving it; mount a real disk volume. ({summary})",
|
|
925
|
+
temp_dir)
|
|
926
|
+
elif free_gb is not None and free_gb < 1.0:
|
|
927
|
+
add("temp_dir", "Spill directory writable", "warn",
|
|
928
|
+
f"Writable but low free space ({free_gb:.1f} GB) — large spills may "
|
|
929
|
+
f"fail. ({summary})", temp_dir)
|
|
930
|
+
else:
|
|
931
|
+
add("temp_dir", "Spill directory writable", "ok",
|
|
932
|
+
f"Wrote and removed a 1 MiB probe file. {summary}", temp_dir)
|
|
933
|
+
|
|
934
|
+
# 6. Force a real disk spill under memory pressure (end-to-end proof).
|
|
935
|
+
spill = None
|
|
936
|
+
try:
|
|
937
|
+
spill = duckdb.connect()
|
|
938
|
+
init_connection(spill, temp_dir="diagnostics")
|
|
939
|
+
spill.execute("PRAGMA memory_limit='256MB';")
|
|
940
|
+
spill.execute("SET threads=4;")
|
|
941
|
+
spill.execute("SET preserve_insertion_order=false;")
|
|
942
|
+
t0 = time.perf_counter()
|
|
943
|
+
# ~3M rows carrying a wide ~150-byte payload (~465 MB) sorted by a
|
|
944
|
+
# scrambled key under a 256 MB cap: the working set cannot fit in
|
|
945
|
+
# memory, so completion proves DuckDB spilled the payload to disk.
|
|
946
|
+
# The cheap integer sort key keeps it fast (<1 s) while the 256 MB cap
|
|
947
|
+
# sits far above the pinned-overhead floor, so a healthy disk never
|
|
948
|
+
# false-fails.
|
|
949
|
+
n = spill.execute(
|
|
950
|
+
"SELECT count(*) FROM ("
|
|
951
|
+
"SELECT hash(i) AS h, repeat('x', 140) || i::VARCHAR AS pad "
|
|
952
|
+
"FROM range(3000000) t(i) ORDER BY h"
|
|
953
|
+
") q"
|
|
954
|
+
).fetchone()[0]
|
|
955
|
+
ms = (time.perf_counter() - t0) * 1000.0
|
|
956
|
+
add("spill", "Disk spill under pressure", "ok",
|
|
957
|
+
f"Sorted {n:,} rows (~465 MB) under a 256 MB limit in {ms:.0f} ms — "
|
|
958
|
+
"DuckDB spilled to disk instead of failing", f"{n:,} rows")
|
|
959
|
+
except Exception as e:
|
|
960
|
+
msg = str(e)
|
|
961
|
+
if "out of memory" in msg.lower() or "failed to pin" in msg.lower():
|
|
962
|
+
add("spill", "Disk spill under pressure", "fail",
|
|
963
|
+
"A query that must spill ran out of memory instead — the spill "
|
|
964
|
+
f"directory is not usable for spilling. {msg}")
|
|
965
|
+
else:
|
|
966
|
+
add("spill", "Disk spill under pressure", "warn",
|
|
967
|
+
f"Spill probe did not complete: {msg}")
|
|
968
|
+
finally:
|
|
969
|
+
if spill is not None:
|
|
970
|
+
try:
|
|
971
|
+
spill.close()
|
|
972
|
+
except Exception:
|
|
973
|
+
pass
|
|
974
|
+
|
|
975
|
+
# 7. External file cache memory safety.
|
|
976
|
+
try:
|
|
977
|
+
efc = con.execute(
|
|
978
|
+
"SELECT current_setting('enable_external_file_cache')"
|
|
979
|
+
).fetchone()[0]
|
|
980
|
+
efc_on = str(efc).strip().lower() in ("true", "1")
|
|
981
|
+
cache_cfg = ""
|
|
982
|
+
if cfg is not None:
|
|
983
|
+
cache_cfg = normalize_memory_size(
|
|
984
|
+
getattr(cfg, "duckdb_external_cache_size", ""), default=""
|
|
985
|
+
)
|
|
986
|
+
if efc_on and not cappable:
|
|
987
|
+
add("cache", "External file cache", "fail",
|
|
988
|
+
"Cache is ON but this build cannot cap it — it grows to the memory "
|
|
989
|
+
"limit and causes OOM", "on · uncapped")
|
|
990
|
+
elif efc_on and cappable:
|
|
991
|
+
add("cache", "External file cache", "ok",
|
|
992
|
+
f"Cache is ON and capped at {cache_cfg or 'the configured size'}",
|
|
993
|
+
"on · capped")
|
|
994
|
+
else:
|
|
995
|
+
add("cache", "External file cache", "ok",
|
|
996
|
+
"Cache is OFF — memory-safe; remote files are re-fetched per query "
|
|
997
|
+
"(set a Disk cache size on a cap-capable build to speed up repeats)",
|
|
998
|
+
"off")
|
|
999
|
+
except Exception as e:
|
|
1000
|
+
add("cache", "External file cache", "warn", f"Could not read cache state: {e}")
|
|
1001
|
+
|
|
1002
|
+
try:
|
|
1003
|
+
con.close()
|
|
1004
|
+
except Exception:
|
|
1005
|
+
pass
|
|
1006
|
+
|
|
1007
|
+
rank = {"ok": 0, "warn": 1, "fail": 2}
|
|
1008
|
+
overall = "ok"
|
|
1009
|
+
for c in checks:
|
|
1010
|
+
if rank.get(c["status"], 0) > rank.get(overall, 0):
|
|
1011
|
+
overall = c["status"]
|
|
1012
|
+
|
|
1013
|
+
return {
|
|
1014
|
+
"engine": engine,
|
|
1015
|
+
"duckdb_version": version,
|
|
1016
|
+
"overall": overall,
|
|
1017
|
+
"checks": checks,
|
|
1018
|
+
}
|
|
714
1019
|
|
|
715
1020
|
|
|
716
1021
|
# =========================================================
|
|
@@ -108,7 +108,7 @@ def normalize_memory_size(value: Any, default: str = "1GB", *, bare_unit: str =
|
|
|
108
108
|
# Shared auto-pick thresholds: key → (env var, built-in default as string).
|
|
109
109
|
_SHARED_SPEC: Dict[str, Tuple[str, str]] = {
|
|
110
110
|
"engine_lite_max_bytes": ("SUPERTABLE_ENGINE_LITE_MAX_BYTES", str(100 * 1024 * 1024)),
|
|
111
|
-
"engine_spark_min_bytes": ("SUPERTABLE_ENGINE_SPARK_MIN_BYTES", str(
|
|
111
|
+
"engine_spark_min_bytes": ("SUPERTABLE_ENGINE_SPARK_MIN_BYTES", str(0)),
|
|
112
112
|
"engine_freshness_sec": ("SUPERTABLE_ENGINE_FRESHNESS_SEC", "300"),
|
|
113
113
|
}
|
|
114
114
|
|
|
@@ -209,7 +209,7 @@ def _build_runtime(redis_cfg: Dict[str, Any], engine: str) -> EngineRuntimeConfi
|
|
|
209
209
|
|
|
210
210
|
return EngineRuntimeConfig(
|
|
211
211
|
engine_lite_max_bytes=_to_int(sv["engine_lite_max_bytes"], 100 * 1024 * 1024),
|
|
212
|
-
engine_spark_min_bytes=_to_int(sv["engine_spark_min_bytes"],
|
|
212
|
+
engine_spark_min_bytes=_to_int(sv["engine_spark_min_bytes"], 0),
|
|
213
213
|
engine_freshness_sec=_to_int(sv["engine_freshness_sec"], 300),
|
|
214
214
|
duckdb_memory_limit=normalize_memory_size(dv["duckdb_memory_limit"], default="1GB"),
|
|
215
215
|
duckdb_io_multiplier=_to_float(dv["duckdb_io_multiplier"], 3.0),
|
|
@@ -62,46 +62,50 @@ class Executor:
|
|
|
62
62
|
self._catalog = False # sentinel: construction failed, do not retry
|
|
63
63
|
return self._catalog or None
|
|
64
64
|
|
|
65
|
-
def
|
|
66
|
-
"""
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
max(engine_spark_min_bytes, min(active cluster min_bytes))
|
|
72
|
-
|
|
73
|
-
Rationale:
|
|
74
|
-
* The ``max`` keeps the admin's global floor effective — keep
|
|
75
|
-
``engine_spark_min_bytes`` high to hold medium jobs on DuckDB even
|
|
76
|
-
when a cluster would accept them (set it to 0 to let the fleet alone
|
|
77
|
-
drive routing).
|
|
78
|
-
* Folding in the fleet minimum means AUTO never routes a job *below*
|
|
79
|
-
what any active cluster accepts — which would otherwise make
|
|
80
|
-
``select_spark_cluster`` return nothing and hard-fail the query
|
|
81
|
-
(see spark_thrift.py ``_select_cluster``).
|
|
82
|
-
|
|
83
|
-
Degrades to the policy floor alone when no catalog is reachable or no
|
|
84
|
-
active clusters are registered.
|
|
65
|
+
def _active_spark_clusters(self) -> list:
|
|
66
|
+
"""Active Spark Thrift clusters registered for this org (best-effort).
|
|
67
|
+
|
|
68
|
+
Returns ``[]`` when no catalog is reachable or none are active, which
|
|
69
|
+
makes AUTO stay on DuckDB instead of routing to a fleet that cannot run
|
|
70
|
+
the job.
|
|
85
71
|
"""
|
|
86
|
-
policy = cfg.engine_spark_min_bytes
|
|
87
72
|
catalog = self._get_catalog()
|
|
88
73
|
if catalog is None:
|
|
89
|
-
return
|
|
74
|
+
return []
|
|
90
75
|
try:
|
|
91
76
|
clusters = catalog.list_spark_clusters(self.organization) or []
|
|
92
77
|
except Exception:
|
|
93
|
-
return
|
|
78
|
+
return []
|
|
79
|
+
return [
|
|
80
|
+
c for c in clusters
|
|
81
|
+
if isinstance(c, dict) and c.get("status") == "active"
|
|
82
|
+
]
|
|
83
|
+
|
|
84
|
+
def _spark_min_bytes(self, cfg: EngineRuntimeConfig, active_clusters: Optional[list] = None) -> int:
|
|
85
|
+
"""Byte size at which AUTO hands a query to the Spark fleet.
|
|
86
|
+
|
|
87
|
+
Fleet-driven: the **smallest** ``min_bytes`` across active clusters —
|
|
88
|
+
the lowest job size any active cluster will accept. A job at or above
|
|
89
|
+
this triggers Spark; :meth:`RedisCatalog.select_spark_cluster` then
|
|
90
|
+
picks (at random) one of the clusters whose ``[min_bytes, max_bytes]``
|
|
91
|
+
window contains the job.
|
|
92
|
+
|
|
93
|
+
Falls back to the ``engine_spark_min_bytes`` policy value only when no
|
|
94
|
+
active cluster is known (catalog down / empty fleet). In that case
|
|
95
|
+
:meth:`_auto_pick` gates on an active cluster existing, so AUTO won't
|
|
96
|
+
route to Spark regardless of the returned bound.
|
|
97
|
+
"""
|
|
98
|
+
if active_clusters is None:
|
|
99
|
+
active_clusters = self._active_spark_clusters()
|
|
94
100
|
mins = []
|
|
95
|
-
for c in
|
|
96
|
-
if not isinstance(c, dict) or c.get("status") != "active":
|
|
97
|
-
continue
|
|
101
|
+
for c in active_clusters:
|
|
98
102
|
try:
|
|
99
103
|
mins.append(int(c.get("min_bytes", 0)))
|
|
100
104
|
except (TypeError, ValueError):
|
|
101
105
|
continue
|
|
102
|
-
if
|
|
103
|
-
return
|
|
104
|
-
return
|
|
106
|
+
if mins:
|
|
107
|
+
return min(mins)
|
|
108
|
+
return cfg.engine_spark_min_bytes
|
|
105
109
|
|
|
106
110
|
def _auto_pick(self, reflection: Reflection, cfg: EngineRuntimeConfig) -> Engine:
|
|
107
111
|
"""Select the best engine based on data size and freshness.
|
|
@@ -118,28 +122,34 @@ class Executor:
|
|
|
118
122
|
(lite–spk)│ cache would churn │ cache pays off │
|
|
119
123
|
├─────────────────────┼─────────────────────┤
|
|
120
124
|
Large │ SPARK * │ SPARK * │
|
|
121
|
-
(>=spark) │
|
|
125
|
+
(>=spark) │ hand off to fleet │ hand off to fleet │
|
|
122
126
|
└─────────────────────┴─────────────────────┘
|
|
123
127
|
|
|
124
|
-
* Spark
|
|
128
|
+
* Spark is chosen only when an **active Spark cluster is registered**
|
|
129
|
+
for the org and the job reaches the fleet's minimum accepted size
|
|
130
|
+
(the smallest ``min_bytes`` across active clusters — see
|
|
131
|
+
:meth:`_spark_min_bytes`). With no active cluster, AUTO stays on
|
|
132
|
+
DuckDB regardless of size. The concrete cluster is chosen later by
|
|
133
|
+
:meth:`RedisCatalog.select_spark_cluster`, at random among the active
|
|
134
|
+
clusters whose ``[min_bytes, max_bytes]`` window contains the job.
|
|
125
135
|
|
|
126
136
|
Env var overrides:
|
|
127
137
|
SUPERTABLE_ENGINE_LITE_MAX_BYTES – upper bound for Lite (default 100 MB)
|
|
128
|
-
SUPERTABLE_ENGINE_SPARK_MIN_BYTES –
|
|
138
|
+
SUPERTABLE_ENGINE_SPARK_MIN_BYTES – Spark floor used only when no
|
|
139
|
+
active cluster is registered
|
|
129
140
|
SUPERTABLE_ENGINE_FRESHNESS_SEC – age threshold in seconds (default 300)
|
|
130
|
-
|
|
131
|
-
The Spark lower bound is additionally coupled to the registered fleet
|
|
132
|
-
(see :meth:`_spark_min_bytes`): the effective boundary is the larger of
|
|
133
|
-
the configured floor and the smallest ``min_bytes`` across active
|
|
134
|
-
clusters, so AUTO only routes to Spark when some cluster will take it.
|
|
135
141
|
"""
|
|
136
142
|
bytes_total = reflection.reflection_bytes
|
|
137
143
|
|
|
138
144
|
# --- thresholds (resolved live from org system config) ---
|
|
139
145
|
lite_max = cfg.engine_lite_max_bytes
|
|
140
|
-
spark_min = self._spark_min_bytes(cfg)
|
|
141
146
|
freshness_threshold_s = cfg.engine_freshness_sec
|
|
142
147
|
|
|
148
|
+
# --- Spark fleet: the registered clusters decide availability + floor ---
|
|
149
|
+
active_clusters = self._active_spark_clusters()
|
|
150
|
+
spark_available = bool(active_clusters)
|
|
151
|
+
spark_min = self._spark_min_bytes(cfg, active_clusters)
|
|
152
|
+
|
|
143
153
|
# --- freshness: how long ago was the most recent snapshot updated ---
|
|
144
154
|
if reflection.freshness_ms > 0:
|
|
145
155
|
age_s = (time.time() * 1000 - reflection.freshness_ms) / 1000.0
|
|
@@ -149,19 +159,11 @@ class Executor:
|
|
|
149
159
|
age_s = -1
|
|
150
160
|
data_is_fresh = False
|
|
151
161
|
|
|
152
|
-
# --- Spark gate ---
|
|
153
|
-
spark_available = False
|
|
154
|
-
if bytes_total >= spark_min:
|
|
155
|
-
try:
|
|
156
|
-
from pyspark.sql import SparkSession # noqa: F401
|
|
157
|
-
spark_available = True
|
|
158
|
-
except Exception:
|
|
159
|
-
pass
|
|
160
|
-
|
|
161
162
|
# --- decision ---
|
|
162
163
|
if spark_available and bytes_total >= spark_min:
|
|
163
164
|
chosen = Engine.SPARK_SQL
|
|
164
|
-
reason = f"bytes={bytes_total} >=
|
|
165
|
+
reason = (f"bytes={bytes_total} >= fleet_min={spark_min} "
|
|
166
|
+
f"({len(active_clusters)} active cluster(s))")
|
|
165
167
|
elif bytes_total <= lite_max:
|
|
166
168
|
chosen = Engine.DUCKDB_LITE
|
|
167
169
|
reason = f"bytes={bytes_total} <= lite_max={lite_max}"
|