supertable 2.2.6__tar.gz → 2.2.7__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.7}/PKG-INFO +1 -1
- {supertable-2.2.6 → supertable-2.2.7}/pyproject.toml +1 -1
- {supertable-2.2.6 → supertable-2.2.7}/setup.py +1 -1
- {supertable-2.2.6 → supertable-2.2.7}/supertable/__init__.py +1 -1
- {supertable-2.2.6 → supertable-2.2.7}/supertable/data_reader.py +33 -2
- {supertable-2.2.6 → supertable-2.2.7}/supertable/engine/engine_common.py +42 -9
- {supertable-2.2.6 → supertable-2.2.7}/supertable/engine/engine_config.py +45 -2
- {supertable-2.2.6 → supertable-2.2.7}/supertable/engine/executor.py +47 -1
- supertable-2.2.7/supertable/engine/tests/test_engine_config.py +81 -0
- supertable-2.2.7/supertable/engine/tests/test_engine_routing.py +110 -0
- supertable-2.2.7/supertable/engine/tests/test_engine_spill.py +183 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/monitoring_writer.py +49 -4
- {supertable-2.2.6 → supertable-2.2.7}/supertable/redis_catalog.py +17 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/tests/test_data_reader.py +30 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/tests/test_monitoring_partitions.py +82 -0
- {supertable-2.2.6 → supertable-2.2.7/supertable.egg-info}/PKG-INFO +1 -1
- {supertable-2.2.6 → supertable-2.2.7}/supertable.egg-info/SOURCES.txt +3 -0
- {supertable-2.2.6 → supertable-2.2.7}/LICENSE +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/README.md +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/requirements.txt +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/setup.cfg +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/audit/__init__.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/audit/admin.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/audit/chain.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/audit/consumers.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/audit/crypto.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/audit/events.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/audit/export.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/audit/logger.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/audit/middleware.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/audit/reader.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/audit/retention.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/audit/tests/__init__.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/audit/tests/test_chain.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/audit/tests/test_crypto.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/audit/tests/test_emit.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/audit/tests/test_events.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/audit/tests/test_retention.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/audit/writer_parquet.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/audit/writer_redis.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/config/__init__.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/config/defaults.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/config/homedir.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/config/settings.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/config/tests/__init__.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/config/tests/test_defaults.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/config/tests/test_homedir.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/config/tests/test_settings.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/data_classes.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/data_writer.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/demo/__init__.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/demo/quickstart/__init__.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/demo/quickstart/__main__.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/demo/quickstart/check_filter_builder.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/demo/quickstart/controller.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/demo/quickstart/data_writer_helpers.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/demo/quickstart/defaults.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/demo/quickstart/dummy_data.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/demo/quickstart/read_parquet_header.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/demo/quickstart/s01_01_01_create_super_table.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/demo/quickstart/s01_01_02_enable_mirroring_formats.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/demo/quickstart/s01_02_create_roles.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/demo/quickstart/s01_03_create_users.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/demo/quickstart/s02_01_write_dummy_data.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/demo/quickstart/s02_02_write_single_data.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/demo/quickstart/s02_03_01_write_staging.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/demo/quickstart/s02_03_02_create_pipe.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/demo/quickstart/s02_04_01_write_monitoring_simple.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/demo/quickstart/s02_04_02_write_monitoring_parallel.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/demo/quickstart/s02_05_write_tombstone.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/demo/quickstart/s03_01_read_data_error.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/demo/quickstart/s03_02_01_read_super_data_ok.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/demo/quickstart/s03_02_02_read_table_data_ok.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/demo/quickstart/s03_03_read_meta.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/demo/quickstart/s03_04_read_staging.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/demo/quickstart/s03_06_01_read_roles.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/demo/quickstart/s03_06_02_read_user.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/demo/quickstart/s03_07_01_estimate_read.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/demo/quickstart/s03_07_02_estimate_files.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/demo/quickstart/s03_08_read_snapshot_history.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/demo/quickstart/s04_01_03_delete_pipe.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/demo/quickstart/s05_01_delete_table.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/demo/quickstart/s05_02_delete_super_table.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/demo/webshop/__init__.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/demo/webshop/core.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/demo/webshop/defaults.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/demo/webshop/generate.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/demo/webshop/load.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/demo/webshop/topup.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/engine/__init__.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/engine/data_estimator.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/engine/duckdb_lite.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/engine/duckdb_pro.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/engine/engine_enum.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/engine/plan_stats.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/engine/spark_thrift.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/engine/tests/__init__.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/engine/tests/conftest.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/engine/tests/test_dedup_read.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/engine/tests/test_engine.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/errors.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/locking/__init__.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/locking/benchmarks/__init__.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/locking/benchmarks/benchmark_locking.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/locking/benchmarks/measure_lock_speed.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/locking/benchmarks/measure_lock_time.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/locking/file_lock.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/locking/redis_lock.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/locking/tests/__init__.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/locking/tests/test_file_lock.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/locking/tests/test_redis_lock.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/logging.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/meta_reader.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/mirroring/__init__.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/mirroring/mirror_delta.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/mirroring/mirror_formats.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/mirroring/mirror_iceberg.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/mirroring/mirror_parquet.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/monitoring/__init__.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/monitoring/partitions.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/plan_extender.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/processing.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/query_plan_manager.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/rbac/__init__.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/rbac/access_control.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/rbac/filter_builder.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/rbac/permissions.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/rbac/role_manager.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/rbac/row_column_security.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/rbac/tests/test_filter_builder.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/rbac/tests/test_rbac.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/rbac/tests/test_rbac_per_table.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/rbac/user_manager.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/redis_connector.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/redis_infra.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/redis_keys.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/simple_table.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/staging_area.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/storage/__init__.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/storage/azure_storage.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/storage/gcp_storage.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/storage/local_storage.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/storage/minio_storage.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/storage/s3_storage.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/storage/storage_factory.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/storage/storage_interface.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/storage/tests/test_storage.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/super_pipe.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/super_table.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/tests/__init__.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/tests/test_align_to_schema_fix.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/tests/test_create_if_missing.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/tests/test_data_reader_preflight.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/tests/test_data_writer.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/tests/test_data_writer_compact.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/tests/test_data_writer_comprehensive.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/tests/test_data_writer_tombstones.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/tests/test_dedup_on_read_write.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/tests/test_errors.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/tests/test_meta_reader.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/tests/test_monitoring_sink_guard.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/tests/test_newer_than.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/tests/test_process_delete_only.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/tests/test_processing.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/tests/test_processing_compact_resources.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/tests/test_query_sql.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/tests/test_redis_key_prefix.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/tests/test_simple_table.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/tests/test_small_file_compaction.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/tests/test_super_table.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/tests/test_supertable_all.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/utils/__init__.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/utils/helper.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/utils/profiler.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/utils/sql_parser.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/utils/tests/test_sql_parser_columns.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable/utils/timer.py +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable.egg-info/dependency_links.txt +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable.egg-info/entry_points.txt +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/supertable.egg-info/requires.txt +0 -0
- {supertable-2.2.6 → supertable-2.2.7}/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.7",
|
|
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.7"
|
|
29
29
|
|
|
30
30
|
# Re-export the core public surface so users can do ``from supertable import …``
|
|
31
31
|
# instead of remembering submodule paths.
|
|
@@ -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
|
|
|
@@ -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
|
# =========================================================
|
|
@@ -578,8 +579,18 @@ def init_connection(
|
|
|
578
579
|
# Resolve memory limit.
|
|
579
580
|
# Single env var SUPERTABLE_DUCKDB_MEMORY_LIMIT controls both executors.
|
|
580
581
|
# The `memory_limit` argument is the caller's fallback when the env var is absent.
|
|
581
|
-
|
|
582
|
-
|
|
582
|
+
# normalize_memory_size guarantees a unit-suffixed value so DuckDB's parser
|
|
583
|
+
# never rejects a bare number (e.g. a UI-supplied "2" -> "2GB").
|
|
584
|
+
effective_memory_limit = normalize_memory_size(
|
|
585
|
+
settings.SUPERTABLE_DUCKDB_MEMORY_LIMIT or memory_limit, default="1GB"
|
|
586
|
+
)
|
|
587
|
+
try:
|
|
588
|
+
con.execute(f"PRAGMA memory_limit='{effective_memory_limit}';")
|
|
589
|
+
except Exception as e:
|
|
590
|
+
logger.warning(
|
|
591
|
+
f"[duckdb.init] memory_limit='{effective_memory_limit}' rejected: {e}; "
|
|
592
|
+
f"keeping DuckDB default"
|
|
593
|
+
)
|
|
583
594
|
|
|
584
595
|
# Absolute temp path is required for DuckDB to actually spill to disk.
|
|
585
596
|
# Prefer a path rooted under the app home (~/supertable), which is
|
|
@@ -647,11 +658,14 @@ def apply_runtime_pragmas(con: duckdb.DuckDBPyConnection, cfg) -> None:
|
|
|
647
658
|
if cfg is None:
|
|
648
659
|
return
|
|
649
660
|
|
|
650
|
-
|
|
661
|
+
# normalize_memory_size guarantees a unit (UI sends a bare "2" -> "2GB");
|
|
662
|
+
# a rejected value is logged rather than silently swallowed so a bad
|
|
663
|
+
# config can never quietly leave the connection at the wrong limit.
|
|
664
|
+
memory_limit = normalize_memory_size(cfg.duckdb_memory_limit, default="1GB")
|
|
651
665
|
try:
|
|
652
666
|
con.execute(f"PRAGMA memory_limit='{memory_limit}';")
|
|
653
|
-
except Exception:
|
|
654
|
-
|
|
667
|
+
except Exception as e:
|
|
668
|
+
logger.warning(f"[duckdb.pragma] memory_limit='{memory_limit}' rejected: {e}")
|
|
655
669
|
|
|
656
670
|
# Explicit thread count wins; otherwise derive from the live memory limit
|
|
657
671
|
# and IO multiplier (same formula as init_connection).
|
|
@@ -671,13 +685,32 @@ def apply_runtime_pragmas(con: duckdb.DuckDBPyConnection, cfg) -> None:
|
|
|
671
685
|
except Exception:
|
|
672
686
|
pass
|
|
673
687
|
|
|
674
|
-
cache_size = (cfg.duckdb_external_cache_size
|
|
688
|
+
cache_size = normalize_memory_size(cfg.duckdb_external_cache_size, default="")
|
|
675
689
|
if cache_size:
|
|
676
690
|
try:
|
|
677
691
|
con.execute("SET enable_external_file_cache=true;")
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
692
|
+
except Exception as e:
|
|
693
|
+
logger.warning(f"[duckdb.pragma] enable_external_file_cache failed: {e}")
|
|
694
|
+
# The size cap is version-dependent: ``external_file_cache_max_size``
|
|
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.
|
|
698
|
+
try:
|
|
699
|
+
has_cap = bool(con.execute(
|
|
700
|
+
"SELECT 1 FROM duckdb_settings() "
|
|
701
|
+
"WHERE name='external_file_cache_max_size'"
|
|
702
|
+
).fetchone())
|
|
703
|
+
if has_cap:
|
|
704
|
+
con.execute(
|
|
705
|
+
f"SET external_file_cache_max_size='{sanitize_sql_string(cache_size)}';"
|
|
706
|
+
)
|
|
707
|
+
else:
|
|
708
|
+
logger.debug(
|
|
709
|
+
"[duckdb.pragma] external_file_cache_max_size unsupported on this "
|
|
710
|
+
"DuckDB version; cache enabled without a size cap"
|
|
711
|
+
)
|
|
712
|
+
except Exception as e:
|
|
713
|
+
logger.warning(f"[duckdb.pragma] external_file_cache_max_size failed: {e}")
|
|
681
714
|
|
|
682
715
|
|
|
683
716
|
# =========================================================
|
|
@@ -58,10 +58,53 @@ 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)),
|
|
@@ -168,11 +211,11 @@ def _build_runtime(redis_cfg: Dict[str, Any], engine: str) -> EngineRuntimeConfi
|
|
|
168
211
|
engine_lite_max_bytes=_to_int(sv["engine_lite_max_bytes"], 100 * 1024 * 1024),
|
|
169
212
|
engine_spark_min_bytes=_to_int(sv["engine_spark_min_bytes"], 10 * 1024 * 1024 * 1024),
|
|
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
|
|
|
@@ -62,6 +62,47 @@ class Executor:
|
|
|
62
62
|
self._catalog = False # sentinel: construction failed, do not retry
|
|
63
63
|
return self._catalog or None
|
|
64
64
|
|
|
65
|
+
def _spark_min_bytes(self, cfg: EngineRuntimeConfig) -> int:
|
|
66
|
+
"""Effective lower byte bound for routing a query to Spark under AUTO.
|
|
67
|
+
|
|
68
|
+
Couples the configured policy floor (``engine_spark_min_bytes``) with the
|
|
69
|
+
registered Spark fleet so the boundary is::
|
|
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.
|
|
85
|
+
"""
|
|
86
|
+
policy = cfg.engine_spark_min_bytes
|
|
87
|
+
catalog = self._get_catalog()
|
|
88
|
+
if catalog is None:
|
|
89
|
+
return policy
|
|
90
|
+
try:
|
|
91
|
+
clusters = catalog.list_spark_clusters(self.organization) or []
|
|
92
|
+
except Exception:
|
|
93
|
+
return policy
|
|
94
|
+
mins = []
|
|
95
|
+
for c in clusters:
|
|
96
|
+
if not isinstance(c, dict) or c.get("status") != "active":
|
|
97
|
+
continue
|
|
98
|
+
try:
|
|
99
|
+
mins.append(int(c.get("min_bytes", 0)))
|
|
100
|
+
except (TypeError, ValueError):
|
|
101
|
+
continue
|
|
102
|
+
if not mins:
|
|
103
|
+
return policy
|
|
104
|
+
return max(policy, min(mins))
|
|
105
|
+
|
|
65
106
|
def _auto_pick(self, reflection: Reflection, cfg: EngineRuntimeConfig) -> Engine:
|
|
66
107
|
"""Select the best engine based on data size and freshness.
|
|
67
108
|
|
|
@@ -86,12 +127,17 @@ class Executor:
|
|
|
86
127
|
SUPERTABLE_ENGINE_LITE_MAX_BYTES – upper bound for Lite (default 100 MB)
|
|
87
128
|
SUPERTABLE_ENGINE_SPARK_MIN_BYTES – lower bound for Spark (default 10 GB)
|
|
88
129
|
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.
|
|
89
135
|
"""
|
|
90
136
|
bytes_total = reflection.reflection_bytes
|
|
91
137
|
|
|
92
138
|
# --- thresholds (resolved live from org system config) ---
|
|
93
139
|
lite_max = cfg.engine_lite_max_bytes
|
|
94
|
-
spark_min = cfg
|
|
140
|
+
spark_min = self._spark_min_bytes(cfg)
|
|
95
141
|
freshness_threshold_s = cfg.engine_freshness_sec
|
|
96
142
|
|
|
97
143
|
# --- freshness: how long ago was the most recent snapshot updated ---
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# supertable/engine/tests/test_engine_config.py
|
|
2
|
+
"""Tests for supertable.engine.engine_config.
|
|
3
|
+
|
|
4
|
+
Focus: ``normalize_memory_size`` (the unit "extender") and that the resolver
|
|
5
|
+
emits unit-suffixed, DuckDB-parseable memory values regardless of how the UI /
|
|
6
|
+
Redis stored them. The core guarantee is that a bare number can never reach
|
|
7
|
+
``PRAGMA memory_limit`` and raise ``Unknown unit for memory: ''``.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import duckdb
|
|
13
|
+
import pytest
|
|
14
|
+
|
|
15
|
+
from supertable.engine.engine_config import (
|
|
16
|
+
normalize_memory_size,
|
|
17
|
+
resolve_engine_configs,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class TestNormalizeMemorySize:
|
|
22
|
+
@pytest.mark.parametrize(
|
|
23
|
+
"value,expected",
|
|
24
|
+
[
|
|
25
|
+
("2", "2GB"), # bare number -> GB
|
|
26
|
+
("8", "8GB"),
|
|
27
|
+
("50", "50GB"),
|
|
28
|
+
(2, "2GB"), # non-string input
|
|
29
|
+
("2.5", "2.5GB"),
|
|
30
|
+
("2GB", "2GB"), # already valid -> passthrough
|
|
31
|
+
("2gb", "2GB"), # lowercase -> canonical
|
|
32
|
+
("2gib", "2GiB"), # binary unit canonicalised
|
|
33
|
+
("512mb", "512MB"),
|
|
34
|
+
(" 4 GB ", "4GB"), # whitespace tolerant
|
|
35
|
+
],
|
|
36
|
+
)
|
|
37
|
+
def test_extends_and_canonicalises(self, value, expected):
|
|
38
|
+
assert normalize_memory_size(value) == expected
|
|
39
|
+
|
|
40
|
+
@pytest.mark.parametrize("value", ["", " ", None, "0", "-5", "abc", "GB", "1XB"])
|
|
41
|
+
def test_invalid_falls_back_to_default(self, value):
|
|
42
|
+
assert normalize_memory_size(value, default="1GB") == "1GB"
|
|
43
|
+
|
|
44
|
+
def test_empty_default_for_cache(self):
|
|
45
|
+
# Cache uses ""(=disabled) as the fallback rather than a size.
|
|
46
|
+
assert normalize_memory_size("", default="") == ""
|
|
47
|
+
assert normalize_memory_size("0", default="") == ""
|
|
48
|
+
assert normalize_memory_size("50", default="") == "50GB"
|
|
49
|
+
|
|
50
|
+
@pytest.mark.parametrize(
|
|
51
|
+
"raw", ["2", "8", "50", "0", "", None, "abc", "2gib", "512mb", "-1"]
|
|
52
|
+
)
|
|
53
|
+
def test_output_always_accepted_by_duckdb(self, raw):
|
|
54
|
+
"""The whole point: the normalized value never raises a ParserException."""
|
|
55
|
+
con = duckdb.connect(":memory:")
|
|
56
|
+
normalized = normalize_memory_size(raw, default="1GB")
|
|
57
|
+
con.execute(f"PRAGMA memory_limit='{normalized}';") # must not raise
|
|
58
|
+
con.close()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class TestResolverNormalizes:
|
|
62
|
+
def test_bare_numbers_become_unit_suffixed(self):
|
|
63
|
+
# Simulates the exact Redis doc the user reported (bare 2 / 8 / 50).
|
|
64
|
+
stored = {
|
|
65
|
+
"lite": {"duckdb_memory_limit": "2", "duckdb_external_cache_size": "50"},
|
|
66
|
+
"pro": {"duckdb_memory_limit": "8", "duckdb_external_cache_size": "50"},
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
class _Catalog:
|
|
70
|
+
def get_engine_config(self, org):
|
|
71
|
+
return stored
|
|
72
|
+
|
|
73
|
+
cfgs = resolve_engine_configs("kladna-soft", _Catalog())
|
|
74
|
+
assert cfgs["lite"].duckdb_memory_limit == "2GB"
|
|
75
|
+
assert cfgs["pro"].duckdb_memory_limit == "8GB"
|
|
76
|
+
assert cfgs["lite"].duckdb_external_cache_size == "50GB"
|
|
77
|
+
|
|
78
|
+
# And the resolved values are all DuckDB-parseable.
|
|
79
|
+
con = duckdb.connect(":memory:")
|
|
80
|
+
con.execute(f"PRAGMA memory_limit='{cfgs['pro'].duckdb_memory_limit}';")
|
|
81
|
+
con.close()
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# supertable/engine/tests/test_engine_routing.py
|
|
2
|
+
"""Tests for the fleet-aware Spark routing boundary.
|
|
3
|
+
|
|
4
|
+
``Executor._spark_min_bytes`` decides the byte size at which AUTO hands a query
|
|
5
|
+
to Spark. It couples the configured policy floor with the registered Thrift
|
|
6
|
+
fleet so the boundary is ``max(engine_spark_min_bytes, min(active min_bytes))``:
|
|
7
|
+
|
|
8
|
+
* the policy floor is never undercut (medium jobs can be held on DuckDB), and
|
|
9
|
+
* AUTO never routes a job below what any active cluster accepts (which would
|
|
10
|
+
make ``select_spark_cluster`` return nothing and hard-fail the query).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from supertable.engine.executor import Executor
|
|
16
|
+
from supertable.engine.engine_config import EngineRuntimeConfig
|
|
17
|
+
|
|
18
|
+
GIB = 1024 ** 3
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _cfg(spark_min_bytes: int) -> EngineRuntimeConfig:
|
|
22
|
+
return EngineRuntimeConfig(
|
|
23
|
+
engine_lite_max_bytes=100 * 1024 * 1024,
|
|
24
|
+
engine_spark_min_bytes=spark_min_bytes,
|
|
25
|
+
engine_freshness_sec=300,
|
|
26
|
+
duckdb_memory_limit="1GB",
|
|
27
|
+
duckdb_io_multiplier=3.0,
|
|
28
|
+
duckdb_threads=None,
|
|
29
|
+
duckdb_http_timeout=None,
|
|
30
|
+
duckdb_external_cache_size="",
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class _Catalog:
|
|
35
|
+
def __init__(self, clusters):
|
|
36
|
+
self._clusters = clusters
|
|
37
|
+
|
|
38
|
+
def list_spark_clusters(self, org):
|
|
39
|
+
return self._clusters
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class _RaisingCatalog:
|
|
43
|
+
def list_spark_clusters(self, org):
|
|
44
|
+
raise RuntimeError("redis down")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _executor(catalog) -> Executor:
|
|
48
|
+
# Bypass __init__ (which builds a DuckDBLite); we only exercise the boundary.
|
|
49
|
+
e = Executor.__new__(Executor)
|
|
50
|
+
e._catalog = catalog
|
|
51
|
+
e.organization = "kladna-soft"
|
|
52
|
+
return e
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _active(min_bytes, max_bytes=0):
|
|
56
|
+
return {"status": "active", "min_bytes": min_bytes, "max_bytes": max_bytes}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_no_catalog_uses_policy_floor():
|
|
60
|
+
assert _executor(None)._spark_min_bytes(_cfg(10 * GIB)) == 10 * GIB
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_empty_fleet_uses_policy_floor():
|
|
64
|
+
assert _executor(_Catalog([]))._spark_min_bytes(_cfg(10 * GIB)) == 10 * GIB
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_catalog_error_falls_back_to_policy():
|
|
68
|
+
assert _executor(_RaisingCatalog())._spark_min_bytes(_cfg(10 * GIB)) == 10 * GIB
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_fleet_min_below_policy_keeps_policy():
|
|
72
|
+
# Cluster accepts >=1 GiB, but the admin floor is 10 GiB: floor wins.
|
|
73
|
+
cat = _Catalog([_active(1 * GIB)])
|
|
74
|
+
assert _executor(cat)._spark_min_bytes(_cfg(10 * GIB)) == 10 * GIB
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_fleet_min_above_policy_raises_boundary():
|
|
78
|
+
# No cluster will take a job under 20 GiB, so AUTO must not route below it.
|
|
79
|
+
cat = _Catalog([_active(20 * GIB)])
|
|
80
|
+
assert _executor(cat)._spark_min_bytes(_cfg(10 * GIB)) == 20 * GIB
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_smallest_active_cluster_drives_the_fleet_min():
|
|
84
|
+
cat = _Catalog([_active(50 * GIB), _active(20 * GIB), _active(30 * GIB)])
|
|
85
|
+
# min active = 20 GiB; policy 10 GiB -> 20 GiB.
|
|
86
|
+
assert _executor(cat)._spark_min_bytes(_cfg(10 * GIB)) == 20 * GIB
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_inactive_clusters_are_ignored():
|
|
90
|
+
cat = _Catalog([
|
|
91
|
+
{"status": "draining", "min_bytes": 1 * GIB, "max_bytes": 0},
|
|
92
|
+
{"status": "offline", "min_bytes": 2 * GIB, "max_bytes": 0},
|
|
93
|
+
])
|
|
94
|
+
# No active cluster -> policy floor.
|
|
95
|
+
assert _executor(cat)._spark_min_bytes(_cfg(10 * GIB)) == 10 * GIB
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_zero_policy_lets_fleet_drive():
|
|
99
|
+
# engine_spark_min_bytes=0 -> pure fleet-min behaviour.
|
|
100
|
+
cat = _Catalog([_active(1 * GIB), _active(4 * GIB)])
|
|
101
|
+
assert _executor(cat)._spark_min_bytes(_cfg(0)) == 1 * GIB
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_malformed_min_bytes_is_skipped_not_fatal():
|
|
105
|
+
cat = _Catalog([
|
|
106
|
+
{"status": "active", "min_bytes": "not-a-number", "max_bytes": 0},
|
|
107
|
+
_active(7 * GIB),
|
|
108
|
+
])
|
|
109
|
+
# Bad row skipped; good row drives the fleet min. policy 1 GiB -> 7 GiB.
|
|
110
|
+
assert _executor(cat)._spark_min_bytes(_cfg(1 * GIB)) == 7 * GIB
|