supertable 2.2.6__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.6/supertable.egg-info → supertable-2.2.8}/PKG-INFO +1 -1
- {supertable-2.2.6 → supertable-2.2.8}/pyproject.toml +1 -1
- {supertable-2.2.6 → supertable-2.2.8}/setup.py +1 -1
- {supertable-2.2.6 → supertable-2.2.8}/supertable/__init__.py +1 -1
- {supertable-2.2.6 → supertable-2.2.8}/supertable/config/settings.py +2 -2
- {supertable-2.2.6 → supertable-2.2.8}/supertable/data_reader.py +33 -2
- {supertable-2.2.6 → supertable-2.2.8}/supertable/engine/engine_common.py +351 -13
- {supertable-2.2.6 → supertable-2.2.8}/supertable/engine/engine_config.py +47 -4
- {supertable-2.2.6 → supertable-2.2.8}/supertable/engine/executor.py +62 -14
- supertable-2.2.8/supertable/engine/tests/test_engine_config.py +81 -0
- supertable-2.2.8/supertable/engine/tests/test_engine_routing.py +192 -0
- supertable-2.2.8/supertable/engine/tests/test_engine_spill.py +183 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/monitoring_writer.py +49 -4
- {supertable-2.2.6 → supertable-2.2.8}/supertable/redis_catalog.py +33 -16
- {supertable-2.2.6 → supertable-2.2.8}/supertable/tests/test_data_reader.py +30 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/tests/test_monitoring_partitions.py +82 -0
- {supertable-2.2.6 → supertable-2.2.8/supertable.egg-info}/PKG-INFO +1 -1
- {supertable-2.2.6 → supertable-2.2.8}/supertable.egg-info/SOURCES.txt +3 -0
- {supertable-2.2.6 → supertable-2.2.8}/LICENSE +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/README.md +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/requirements.txt +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/setup.cfg +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/audit/__init__.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/audit/admin.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/audit/chain.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/audit/consumers.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/audit/crypto.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/audit/events.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/audit/export.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/audit/logger.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/audit/middleware.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/audit/reader.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/audit/retention.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/audit/tests/__init__.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/audit/tests/test_chain.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/audit/tests/test_crypto.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/audit/tests/test_emit.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/audit/tests/test_events.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/audit/tests/test_retention.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/audit/writer_parquet.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/audit/writer_redis.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/config/__init__.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/config/defaults.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/config/homedir.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/config/tests/__init__.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/config/tests/test_defaults.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/config/tests/test_homedir.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/config/tests/test_settings.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/data_classes.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/data_writer.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/demo/__init__.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/demo/quickstart/__init__.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/demo/quickstart/__main__.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/demo/quickstart/check_filter_builder.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/demo/quickstart/controller.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/demo/quickstart/data_writer_helpers.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/demo/quickstart/defaults.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/demo/quickstart/dummy_data.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/demo/quickstart/read_parquet_header.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/demo/quickstart/s01_01_01_create_super_table.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/demo/quickstart/s01_01_02_enable_mirroring_formats.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/demo/quickstart/s01_02_create_roles.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/demo/quickstart/s01_03_create_users.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/demo/quickstart/s02_01_write_dummy_data.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/demo/quickstart/s02_02_write_single_data.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/demo/quickstart/s02_03_01_write_staging.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/demo/quickstart/s02_03_02_create_pipe.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/demo/quickstart/s02_04_01_write_monitoring_simple.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/demo/quickstart/s02_04_02_write_monitoring_parallel.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/demo/quickstart/s02_05_write_tombstone.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/demo/quickstart/s03_01_read_data_error.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/demo/quickstart/s03_02_01_read_super_data_ok.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/demo/quickstart/s03_02_02_read_table_data_ok.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/demo/quickstart/s03_03_read_meta.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/demo/quickstart/s03_04_read_staging.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/demo/quickstart/s03_06_01_read_roles.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/demo/quickstart/s03_06_02_read_user.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/demo/quickstart/s03_07_01_estimate_read.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/demo/quickstart/s03_07_02_estimate_files.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/demo/quickstart/s03_08_read_snapshot_history.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/demo/quickstart/s04_01_03_delete_pipe.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/demo/quickstart/s05_01_delete_table.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/demo/quickstart/s05_02_delete_super_table.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/demo/webshop/__init__.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/demo/webshop/core.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/demo/webshop/defaults.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/demo/webshop/generate.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/demo/webshop/load.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/demo/webshop/topup.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/engine/__init__.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/engine/data_estimator.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/engine/duckdb_lite.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/engine/duckdb_pro.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/engine/engine_enum.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/engine/plan_stats.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/engine/spark_thrift.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/engine/tests/__init__.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/engine/tests/conftest.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/engine/tests/test_dedup_read.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/engine/tests/test_engine.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/errors.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/locking/__init__.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/locking/benchmarks/__init__.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/locking/benchmarks/benchmark_locking.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/locking/benchmarks/measure_lock_speed.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/locking/benchmarks/measure_lock_time.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/locking/file_lock.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/locking/redis_lock.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/locking/tests/__init__.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/locking/tests/test_file_lock.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/locking/tests/test_redis_lock.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/logging.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/meta_reader.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/mirroring/__init__.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/mirroring/mirror_delta.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/mirroring/mirror_formats.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/mirroring/mirror_iceberg.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/mirroring/mirror_parquet.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/monitoring/__init__.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/monitoring/partitions.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/plan_extender.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/processing.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/query_plan_manager.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/rbac/__init__.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/rbac/access_control.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/rbac/filter_builder.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/rbac/permissions.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/rbac/role_manager.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/rbac/row_column_security.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/rbac/tests/test_filter_builder.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/rbac/tests/test_rbac.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/rbac/tests/test_rbac_per_table.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/rbac/user_manager.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/redis_connector.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/redis_infra.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/redis_keys.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/simple_table.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/staging_area.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/storage/__init__.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/storage/azure_storage.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/storage/gcp_storage.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/storage/local_storage.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/storage/minio_storage.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/storage/s3_storage.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/storage/storage_factory.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/storage/storage_interface.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/storage/tests/test_storage.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/super_pipe.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/super_table.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/tests/__init__.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/tests/test_align_to_schema_fix.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/tests/test_create_if_missing.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/tests/test_data_reader_preflight.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/tests/test_data_writer.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/tests/test_data_writer_compact.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/tests/test_data_writer_comprehensive.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/tests/test_data_writer_tombstones.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/tests/test_dedup_on_read_write.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/tests/test_errors.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/tests/test_meta_reader.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/tests/test_monitoring_sink_guard.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/tests/test_newer_than.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/tests/test_process_delete_only.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/tests/test_processing.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/tests/test_processing_compact_resources.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/tests/test_query_sql.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/tests/test_redis_key_prefix.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/tests/test_simple_table.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/tests/test_small_file_compaction.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/tests/test_super_table.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/tests/test_supertable_all.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/utils/__init__.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/utils/helper.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/utils/profiler.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/utils/sql_parser.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/utils/tests/test_sql_parser_columns.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable/utils/timer.py +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable.egg-info/dependency_links.txt +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable.egg-info/entry_points.txt +0 -0
- {supertable-2.2.6 → supertable-2.2.8}/supertable.egg-info/requires.txt +0 -0
- {supertable-2.2.6 → 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
|
|
|
@@ -44,10 +44,20 @@ class DataReader:
|
|
|
44
44
|
- Execution to Executor (DuckDB/Spark)
|
|
45
45
|
"""
|
|
46
46
|
|
|
47
|
-
def __init__(
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
super_name: str,
|
|
50
|
+
organization: str,
|
|
51
|
+
query: str,
|
|
52
|
+
source: str = "sdk",
|
|
53
|
+
):
|
|
48
54
|
self.super_name = super_name
|
|
49
55
|
self.organization = organization
|
|
50
56
|
self.query = query
|
|
57
|
+
# Query origin surfaced in the reads monitoring tab. "sdk" is the
|
|
58
|
+
# default for direct SDK callers; the API/OData/MCP entry points
|
|
59
|
+
# pass "api"/"odata"/"mcp" so each query records where it came from.
|
|
60
|
+
self.source = source
|
|
51
61
|
|
|
52
62
|
self.storage: StorageInterface = get_storage()
|
|
53
63
|
|
|
@@ -164,6 +174,9 @@ class DataReader:
|
|
|
164
174
|
current_meta_path="redis://meta/root",
|
|
165
175
|
query=parser.original_query,
|
|
166
176
|
)
|
|
177
|
+
# Stamp the call origin so plan_extender records it on the read
|
|
178
|
+
# monitoring entry (defaults to "api" downstream if unset).
|
|
179
|
+
self.query_plan_manager.source_type = self.source
|
|
167
180
|
self._log_ctx = f"[qid={self.query_plan_manager.query_id} qh={self.query_plan_manager.query_hash}] "
|
|
168
181
|
self.query_plan_manager.original_table = ", ".join(t.simple_name for t in physical_tables) if physical_tables else ""
|
|
169
182
|
|
|
@@ -311,16 +324,25 @@ def query_sql(
|
|
|
311
324
|
limit: int,
|
|
312
325
|
engine: Any,
|
|
313
326
|
role_name: str,
|
|
327
|
+
source: str = "sdk",
|
|
328
|
+
out: Optional[Dict[str, Any]] = None,
|
|
314
329
|
) -> Tuple[List[str], List[List[Any]], List[Dict[str, Any]]]:
|
|
315
330
|
"""
|
|
316
331
|
Execute SQL query and return results in the format expected by MCP server.
|
|
317
332
|
Returns: (columns, rows, columns_meta)
|
|
333
|
+
|
|
334
|
+
``source`` tags the query origin on the read monitoring entry
|
|
335
|
+
(defaults to "sdk"; the MCP server passes "mcp"). When an ``out``
|
|
336
|
+
dict is supplied it is populated with ``query_id``/``query_hash`` so
|
|
337
|
+
the caller can correlate its own audit log to this read record.
|
|
318
338
|
"""
|
|
319
339
|
# Safety guard: ensure a LIMIT is present so unbounded queries don't
|
|
320
340
|
# overwhelm the MCP response payload.
|
|
321
341
|
sql = _ensure_sql_limit(sql, default_limit=limit)
|
|
322
342
|
|
|
323
|
-
reader = DataReader(
|
|
343
|
+
reader = DataReader(
|
|
344
|
+
organization=organization, super_name=super_name, query=sql, source=source,
|
|
345
|
+
)
|
|
324
346
|
|
|
325
347
|
# Execute the query
|
|
326
348
|
result_df, status, message = reader.execute(
|
|
@@ -329,6 +351,15 @@ def query_sql(
|
|
|
329
351
|
with_scan=False,
|
|
330
352
|
)
|
|
331
353
|
|
|
354
|
+
# Expose the query identity so the caller (e.g. the MCP audit log) can
|
|
355
|
+
# link back to this read's monitoring entry. Populated even on error,
|
|
356
|
+
# since the QueryPlanManager is created before execution.
|
|
357
|
+
if out is not None:
|
|
358
|
+
qpm = reader.query_plan_manager
|
|
359
|
+
if qpm is not None:
|
|
360
|
+
out["query_id"] = qpm.query_id
|
|
361
|
+
out["query_hash"] = qpm.query_hash
|
|
362
|
+
|
|
332
363
|
if status == Status.ERROR:
|
|
333
364
|
raise RuntimeError(f"Query execution failed: {message}")
|
|
334
365
|
|
|
@@ -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
|
|
@@ -14,6 +14,7 @@ from sqlglot import exp
|
|
|
14
14
|
from supertable.config.defaults import logger
|
|
15
15
|
from supertable.config.settings import settings
|
|
16
16
|
from supertable.config.homedir import get_app_home
|
|
17
|
+
from supertable.engine.engine_config import normalize_memory_size
|
|
17
18
|
|
|
18
19
|
|
|
19
20
|
# =========================================================
|
|
@@ -281,11 +282,15 @@ def configure_httpfs_and_s3(
|
|
|
281
282
|
"true" if meta_cache_on else "false",
|
|
282
283
|
)
|
|
283
284
|
|
|
284
|
-
# External file cache
|
|
285
|
-
#
|
|
286
|
-
#
|
|
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.
|
|
287
291
|
cache_size = settings.SUPERTABLE_DUCKDB_EXTERNAL_CACHE_SIZE
|
|
288
|
-
|
|
292
|
+
can_cap = "external_file_cache_max_size" in supported
|
|
293
|
+
if cache_size and can_cap:
|
|
289
294
|
set_if_supported("enable_external_file_cache", "true")
|
|
290
295
|
set_if_supported("external_file_cache_max_size", f"'{cache_size}'")
|
|
291
296
|
cache_dir_raw = settings.SUPERTABLE_DUCKDB_EXTERNAL_CACHE_DIR
|
|
@@ -301,6 +306,15 @@ def configure_httpfs_and_s3(
|
|
|
301
306
|
+ (f" size={cache_size}" if cache_size else "")
|
|
302
307
|
+ f", dir={cache_dir}"
|
|
303
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
|
+
)
|
|
304
318
|
|
|
305
319
|
|
|
306
320
|
# =========================================================
|
|
@@ -554,6 +568,25 @@ def rewrite_query_with_hashed_tables(
|
|
|
554
568
|
# Connection initialization
|
|
555
569
|
# =========================================================
|
|
556
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
|
+
|
|
557
590
|
def init_connection(
|
|
558
591
|
con: duckdb.DuckDBPyConnection,
|
|
559
592
|
temp_dir: str,
|
|
@@ -578,8 +611,18 @@ def init_connection(
|
|
|
578
611
|
# Resolve memory limit.
|
|
579
612
|
# Single env var SUPERTABLE_DUCKDB_MEMORY_LIMIT controls both executors.
|
|
580
613
|
# The `memory_limit` argument is the caller's fallback when the env var is absent.
|
|
581
|
-
|
|
582
|
-
|
|
614
|
+
# normalize_memory_size guarantees a unit-suffixed value so DuckDB's parser
|
|
615
|
+
# never rejects a bare number (e.g. a UI-supplied "2" -> "2GB").
|
|
616
|
+
effective_memory_limit = normalize_memory_size(
|
|
617
|
+
settings.SUPERTABLE_DUCKDB_MEMORY_LIMIT or memory_limit, default="1GB"
|
|
618
|
+
)
|
|
619
|
+
try:
|
|
620
|
+
con.execute(f"PRAGMA memory_limit='{effective_memory_limit}';")
|
|
621
|
+
except Exception as e:
|
|
622
|
+
logger.warning(
|
|
623
|
+
f"[duckdb.init] memory_limit='{effective_memory_limit}' rejected: {e}; "
|
|
624
|
+
f"keeping DuckDB default"
|
|
625
|
+
)
|
|
583
626
|
|
|
584
627
|
# Absolute temp path is required for DuckDB to actually spill to disk.
|
|
585
628
|
# Prefer a path rooted under the app home (~/supertable), which is
|
|
@@ -605,6 +648,17 @@ def init_connection(
|
|
|
605
648
|
except Exception:
|
|
606
649
|
pass # older DuckDB builds may not support this setting
|
|
607
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
|
+
|
|
608
662
|
# Thread count.
|
|
609
663
|
# If SUPERTABLE_DUCKDB_THREADS is set explicitly, honour it exactly.
|
|
610
664
|
# Otherwise derive from the effective memory limit using the IO-thread
|
|
@@ -647,11 +701,14 @@ def apply_runtime_pragmas(con: duckdb.DuckDBPyConnection, cfg) -> None:
|
|
|
647
701
|
if cfg is None:
|
|
648
702
|
return
|
|
649
703
|
|
|
650
|
-
|
|
704
|
+
# normalize_memory_size guarantees a unit (UI sends a bare "2" -> "2GB");
|
|
705
|
+
# a rejected value is logged rather than silently swallowed so a bad
|
|
706
|
+
# config can never quietly leave the connection at the wrong limit.
|
|
707
|
+
memory_limit = normalize_memory_size(cfg.duckdb_memory_limit, default="1GB")
|
|
651
708
|
try:
|
|
652
709
|
con.execute(f"PRAGMA memory_limit='{memory_limit}';")
|
|
653
|
-
except Exception:
|
|
654
|
-
|
|
710
|
+
except Exception as e:
|
|
711
|
+
logger.warning(f"[duckdb.pragma] memory_limit='{memory_limit}' rejected: {e}")
|
|
655
712
|
|
|
656
713
|
# Explicit thread count wins; otherwise derive from the live memory limit
|
|
657
714
|
# and IO multiplier (same formula as init_connection).
|
|
@@ -671,15 +728,296 @@ def apply_runtime_pragmas(con: duckdb.DuckDBPyConnection, cfg) -> None:
|
|
|
671
728
|
except Exception:
|
|
672
729
|
pass
|
|
673
730
|
|
|
674
|
-
|
|
675
|
-
|
|
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.
|
|
735
|
+
cache_size = normalize_memory_size(cfg.duckdb_external_cache_size, default="")
|
|
736
|
+
if cache_size and _external_file_cache_cappable(con):
|
|
676
737
|
try:
|
|
677
738
|
con.execute("SET enable_external_file_cache=true;")
|
|
678
|
-
con.execute(
|
|
739
|
+
con.execute(
|
|
740
|
+
f"SET external_file_cache_max_size='{sanitize_sql_string(cache_size)}';"
|
|
741
|
+
)
|
|
742
|
+
except Exception as e:
|
|
743
|
+
logger.warning(f"[duckdb.pragma] external file cache config failed: {e}")
|
|
744
|
+
else:
|
|
745
|
+
try:
|
|
746
|
+
con.execute("SET enable_external_file_cache=false;")
|
|
679
747
|
except Exception:
|
|
680
748
|
pass
|
|
681
749
|
|
|
682
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
|
|
897
|
+
except Exception as e:
|
|
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
|
+
}
|
|
1019
|
+
|
|
1020
|
+
|
|
683
1021
|
# =========================================================
|
|
684
1022
|
# RBAC view creation
|
|
685
1023
|
# =========================================================
|
|
@@ -58,14 +58,57 @@ defaults, so behaviour is identical whether a value flows through ``settings``
|
|
|
58
58
|
from __future__ import annotations
|
|
59
59
|
|
|
60
60
|
import os
|
|
61
|
+
import re
|
|
61
62
|
from dataclasses import dataclass
|
|
62
63
|
from typing import Any, Dict, Optional, Tuple
|
|
63
64
|
|
|
64
65
|
|
|
66
|
+
# Optional decimal number followed by an optional byte unit (decimal KB/MB/GB/TB
|
|
67
|
+
# or binary KiB/MiB/GiB/TiB), case-insensitive, whitespace-tolerant.
|
|
68
|
+
_MEM_SIZE_RE = re.compile(r"^(\d+(?:\.\d+)?)\s*([KMGT]i?B)?$", re.IGNORECASE)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def normalize_memory_size(value: Any, default: str = "1GB", *, bare_unit: str = "GB") -> str:
|
|
72
|
+
"""Coerce a memory-size value into a DuckDB-valid, unit-suffixed string.
|
|
73
|
+
|
|
74
|
+
The engine UI collects a bare integer (interpreted as ``bare_unit`` — GB),
|
|
75
|
+
while env vars / older Redis docs may already carry a unit. DuckDB's
|
|
76
|
+
``PRAGMA memory_limit`` parser rejects a unit-less number
|
|
77
|
+
(``Unknown unit for memory: ''``), so this guarantees a value the parser
|
|
78
|
+
accepts before it is ever sent:
|
|
79
|
+
|
|
80
|
+
* bare number -> ``"<n>GB"`` ("2" -> "2GB")
|
|
81
|
+
* number + unit -> canonical casing ("2gib" -> "2GiB")
|
|
82
|
+
* empty / None -> ``default`` (""/None -> default)
|
|
83
|
+
* non-positive / garbage -> ``default`` ("0", "x" -> default)
|
|
84
|
+
|
|
85
|
+
``default`` is returned verbatim, so pass ``""`` when "unset" is the
|
|
86
|
+
desired fallback (e.g. the external file cache, where empty == disabled).
|
|
87
|
+
"""
|
|
88
|
+
s = ("" if value is None else str(value)).strip()
|
|
89
|
+
if not s:
|
|
90
|
+
return default
|
|
91
|
+
m = _MEM_SIZE_RE.match(s)
|
|
92
|
+
if not m:
|
|
93
|
+
return default
|
|
94
|
+
num, unit = m.group(1), m.group(2)
|
|
95
|
+
try:
|
|
96
|
+
if float(num) <= 0:
|
|
97
|
+
return default
|
|
98
|
+
except ValueError:
|
|
99
|
+
return default
|
|
100
|
+
if unit:
|
|
101
|
+
u = unit.upper()
|
|
102
|
+
unit = (u[0] + "iB") if u.endswith("IB") else u
|
|
103
|
+
else:
|
|
104
|
+
unit = bare_unit
|
|
105
|
+
return f"{num}{unit}"
|
|
106
|
+
|
|
107
|
+
|
|
65
108
|
# Shared auto-pick thresholds: key → (env var, built-in default as string).
|
|
66
109
|
_SHARED_SPEC: Dict[str, Tuple[str, str]] = {
|
|
67
110
|
"engine_lite_max_bytes": ("SUPERTABLE_ENGINE_LITE_MAX_BYTES", str(100 * 1024 * 1024)),
|
|
68
|
-
"engine_spark_min_bytes": ("SUPERTABLE_ENGINE_SPARK_MIN_BYTES", str(
|
|
111
|
+
"engine_spark_min_bytes": ("SUPERTABLE_ENGINE_SPARK_MIN_BYTES", str(0)),
|
|
69
112
|
"engine_freshness_sec": ("SUPERTABLE_ENGINE_FRESHNESS_SEC", "300"),
|
|
70
113
|
}
|
|
71
114
|
|
|
@@ -166,13 +209,13 @@ def _build_runtime(redis_cfg: Dict[str, Any], engine: str) -> EngineRuntimeConfi
|
|
|
166
209
|
|
|
167
210
|
return EngineRuntimeConfig(
|
|
168
211
|
engine_lite_max_bytes=_to_int(sv["engine_lite_max_bytes"], 100 * 1024 * 1024),
|
|
169
|
-
engine_spark_min_bytes=_to_int(sv["engine_spark_min_bytes"],
|
|
212
|
+
engine_spark_min_bytes=_to_int(sv["engine_spark_min_bytes"], 0),
|
|
170
213
|
engine_freshness_sec=_to_int(sv["engine_freshness_sec"], 300),
|
|
171
|
-
duckdb_memory_limit=dv["duckdb_memory_limit"]
|
|
214
|
+
duckdb_memory_limit=normalize_memory_size(dv["duckdb_memory_limit"], default="1GB"),
|
|
172
215
|
duckdb_io_multiplier=_to_float(dv["duckdb_io_multiplier"], 3.0),
|
|
173
216
|
duckdb_threads=(threads if threads > 0 else None),
|
|
174
217
|
duckdb_http_timeout=(http if http > 0 else None),
|
|
175
|
-
duckdb_external_cache_size=dv["duckdb_external_cache_size"]
|
|
218
|
+
duckdb_external_cache_size=normalize_memory_size(dv["duckdb_external_cache_size"], default=""),
|
|
176
219
|
)
|
|
177
220
|
|
|
178
221
|
|