supertable 2.2.5__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.5/supertable.egg-info → supertable-2.2.7}/PKG-INFO +1 -1
- {supertable-2.2.5 → supertable-2.2.7}/pyproject.toml +1 -1
- {supertable-2.2.5 → supertable-2.2.7}/setup.py +1 -1
- {supertable-2.2.5 → supertable-2.2.7}/supertable/__init__.py +1 -1
- {supertable-2.2.5 → supertable-2.2.7}/supertable/data_reader.py +33 -2
- {supertable-2.2.5 → supertable-2.2.7}/supertable/engine/engine_common.py +42 -9
- supertable-2.2.7/supertable/engine/engine_config.py +271 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/engine/executor.py +57 -7
- {supertable-2.2.5 → supertable-2.2.7}/supertable/engine/spark_thrift.py +9 -3
- 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.5 → supertable-2.2.7}/supertable/monitoring_writer.py +49 -4
- {supertable-2.2.5 → supertable-2.2.7}/supertable/redis_catalog.py +52 -27
- {supertable-2.2.5 → supertable-2.2.7}/supertable/redis_keys.py +21 -12
- {supertable-2.2.5 → supertable-2.2.7}/supertable/tests/test_data_reader.py +30 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/tests/test_monitoring_partitions.py +82 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/tests/test_redis_key_prefix.py +3 -3
- {supertable-2.2.5 → supertable-2.2.7/supertable.egg-info}/PKG-INFO +1 -1
- {supertable-2.2.5 → supertable-2.2.7}/supertable.egg-info/SOURCES.txt +3 -0
- supertable-2.2.5/supertable/engine/engine_config.py +0 -153
- {supertable-2.2.5 → supertable-2.2.7}/LICENSE +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/README.md +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/requirements.txt +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/setup.cfg +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/audit/__init__.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/audit/admin.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/audit/chain.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/audit/consumers.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/audit/crypto.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/audit/events.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/audit/export.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/audit/logger.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/audit/middleware.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/audit/reader.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/audit/retention.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/audit/tests/__init__.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/audit/tests/test_chain.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/audit/tests/test_crypto.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/audit/tests/test_emit.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/audit/tests/test_events.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/audit/tests/test_retention.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/audit/writer_parquet.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/audit/writer_redis.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/config/__init__.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/config/defaults.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/config/homedir.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/config/settings.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/config/tests/__init__.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/config/tests/test_defaults.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/config/tests/test_homedir.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/config/tests/test_settings.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/data_classes.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/data_writer.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/demo/__init__.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/demo/quickstart/__init__.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/demo/quickstart/__main__.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/demo/quickstart/check_filter_builder.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/demo/quickstart/controller.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/demo/quickstart/data_writer_helpers.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/demo/quickstart/defaults.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/demo/quickstart/dummy_data.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/demo/quickstart/read_parquet_header.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/demo/quickstart/s01_01_01_create_super_table.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/demo/quickstart/s01_01_02_enable_mirroring_formats.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/demo/quickstart/s01_02_create_roles.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/demo/quickstart/s01_03_create_users.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/demo/quickstart/s02_01_write_dummy_data.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/demo/quickstart/s02_02_write_single_data.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/demo/quickstart/s02_03_01_write_staging.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/demo/quickstart/s02_03_02_create_pipe.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/demo/quickstart/s02_04_01_write_monitoring_simple.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/demo/quickstart/s02_04_02_write_monitoring_parallel.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/demo/quickstart/s02_05_write_tombstone.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/demo/quickstart/s03_01_read_data_error.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/demo/quickstart/s03_02_01_read_super_data_ok.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/demo/quickstart/s03_02_02_read_table_data_ok.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/demo/quickstart/s03_03_read_meta.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/demo/quickstart/s03_04_read_staging.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/demo/quickstart/s03_06_01_read_roles.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/demo/quickstart/s03_06_02_read_user.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/demo/quickstart/s03_07_01_estimate_read.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/demo/quickstart/s03_07_02_estimate_files.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/demo/quickstart/s03_08_read_snapshot_history.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/demo/quickstart/s04_01_03_delete_pipe.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/demo/quickstart/s05_01_delete_table.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/demo/quickstart/s05_02_delete_super_table.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/demo/webshop/__init__.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/demo/webshop/core.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/demo/webshop/defaults.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/demo/webshop/generate.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/demo/webshop/load.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/demo/webshop/topup.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/engine/__init__.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/engine/data_estimator.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/engine/duckdb_lite.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/engine/duckdb_pro.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/engine/engine_enum.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/engine/plan_stats.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/engine/tests/__init__.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/engine/tests/conftest.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/engine/tests/test_dedup_read.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/engine/tests/test_engine.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/errors.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/locking/__init__.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/locking/benchmarks/__init__.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/locking/benchmarks/benchmark_locking.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/locking/benchmarks/measure_lock_speed.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/locking/benchmarks/measure_lock_time.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/locking/file_lock.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/locking/redis_lock.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/locking/tests/__init__.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/locking/tests/test_file_lock.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/locking/tests/test_redis_lock.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/logging.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/meta_reader.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/mirroring/__init__.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/mirroring/mirror_delta.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/mirroring/mirror_formats.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/mirroring/mirror_iceberg.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/mirroring/mirror_parquet.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/monitoring/__init__.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/monitoring/partitions.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/plan_extender.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/processing.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/query_plan_manager.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/rbac/__init__.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/rbac/access_control.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/rbac/filter_builder.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/rbac/permissions.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/rbac/role_manager.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/rbac/row_column_security.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/rbac/tests/test_filter_builder.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/rbac/tests/test_rbac.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/rbac/tests/test_rbac_per_table.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/rbac/user_manager.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/redis_connector.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/redis_infra.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/simple_table.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/staging_area.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/storage/__init__.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/storage/azure_storage.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/storage/gcp_storage.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/storage/local_storage.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/storage/minio_storage.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/storage/s3_storage.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/storage/storage_factory.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/storage/storage_interface.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/storage/tests/test_storage.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/super_pipe.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/super_table.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/tests/__init__.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/tests/test_align_to_schema_fix.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/tests/test_create_if_missing.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/tests/test_data_reader_preflight.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/tests/test_data_writer.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/tests/test_data_writer_compact.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/tests/test_data_writer_comprehensive.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/tests/test_data_writer_tombstones.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/tests/test_dedup_on_read_write.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/tests/test_errors.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/tests/test_meta_reader.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/tests/test_monitoring_sink_guard.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/tests/test_newer_than.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/tests/test_process_delete_only.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/tests/test_processing.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/tests/test_processing_compact_resources.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/tests/test_query_sql.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/tests/test_simple_table.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/tests/test_small_file_compaction.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/tests/test_super_table.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/tests/test_supertable_all.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/utils/__init__.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/utils/helper.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/utils/profiler.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/utils/sql_parser.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/utils/tests/test_sql_parser_columns.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable/utils/timer.py +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable.egg-info/dependency_links.txt +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable.egg-info/entry_points.txt +0 -0
- {supertable-2.2.5 → supertable-2.2.7}/supertable.egg-info/requires.txt +0 -0
- {supertable-2.2.5 → 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
|
# =========================================================
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
# route: supertable.engine.engine_config
|
|
2
|
+
"""
|
|
3
|
+
Engine runtime configuration — single source of truth for resolution.
|
|
4
|
+
|
|
5
|
+
Engine config is stored once per organization at **system scope**
|
|
6
|
+
(``supertable:{org}:system:engine:duckdb``). It is global to the org, not
|
|
7
|
+
per-supertable, and is resolved **live on every query** (no caching) so that
|
|
8
|
+
changes made in the UI take effect on the next query without a restart or a
|
|
9
|
+
connection reset.
|
|
10
|
+
|
|
11
|
+
Two kinds of settings live in that document:
|
|
12
|
+
|
|
13
|
+
* **Shared auto-pick thresholds** (one value, used by the router):
|
|
14
|
+
``engine_lite_max_bytes``, ``engine_spark_min_bytes``,
|
|
15
|
+
``engine_freshness_sec``. These describe the *boundaries* between engines
|
|
16
|
+
so they cannot be per-engine.
|
|
17
|
+
* **Per-engine DuckDB runtime pragmas** (separate values for Lite and Pro):
|
|
18
|
+
``duckdb_memory_limit``, ``duckdb_io_multiplier``, ``duckdb_threads``,
|
|
19
|
+
``duckdb_http_timeout``, ``duckdb_external_cache_size``. Each DuckDB engine
|
|
20
|
+
carries its own copy under a ``"lite"`` / ``"pro"`` section, so tuning Lite
|
|
21
|
+
never changes Pro and vice-versa.
|
|
22
|
+
|
|
23
|
+
Stored document shape::
|
|
24
|
+
|
|
25
|
+
{
|
|
26
|
+
"engine_lite_max_bytes": "104857600",
|
|
27
|
+
"engine_spark_min_bytes": "10737418240",
|
|
28
|
+
"engine_freshness_sec": "300",
|
|
29
|
+
"lite": {"duckdb_memory_limit": "1GB", ...},
|
|
30
|
+
"pro": {"duckdb_memory_limit": "8GB", ...},
|
|
31
|
+
"modified_ms": 1750000000000
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
Resolution precedence, per field::
|
|
35
|
+
|
|
36
|
+
Redis (org system config) → environment variable → built-in default
|
|
37
|
+
|
|
38
|
+
The environment variable is a single global fallback shared by both engines;
|
|
39
|
+
per-engine differentiation therefore only happens once a value is stored in
|
|
40
|
+
Redis (via the UI).
|
|
41
|
+
|
|
42
|
+
Entry points (all share that precedence so engine and UI never disagree):
|
|
43
|
+
|
|
44
|
+
* :func:`resolve_engine_configs` → ``{"lite": cfg, "pro": cfg}`` in a
|
|
45
|
+
single Redis read; used by the
|
|
46
|
+
executor.
|
|
47
|
+
* :func:`resolve_engine_config` → typed :class:`EngineRuntimeConfig`
|
|
48
|
+
for one engine (convenience).
|
|
49
|
+
* :func:`resolve_engine_config_provenance` → ``{shared, lite, pro,
|
|
50
|
+
modified_ms}`` per-field
|
|
51
|
+
``{value, source, env_var,
|
|
52
|
+
default}`` for the API / UI.
|
|
53
|
+
|
|
54
|
+
Built-in defaults mirror the :mod:`supertable.config.settings` dataclass
|
|
55
|
+
defaults, so behaviour is identical whether a value flows through ``settings``
|
|
56
|
+
(no Redis override) or through this resolver.
|
|
57
|
+
"""
|
|
58
|
+
from __future__ import annotations
|
|
59
|
+
|
|
60
|
+
import os
|
|
61
|
+
import re
|
|
62
|
+
from dataclasses import dataclass
|
|
63
|
+
from typing import Any, Dict, Optional, Tuple
|
|
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
|
+
|
|
108
|
+
# Shared auto-pick thresholds: key → (env var, built-in default as string).
|
|
109
|
+
_SHARED_SPEC: Dict[str, Tuple[str, str]] = {
|
|
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(10 * 1024 * 1024 * 1024)),
|
|
112
|
+
"engine_freshness_sec": ("SUPERTABLE_ENGINE_FRESHNESS_SEC", "300"),
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
# Per-engine DuckDB runtime pragmas: key → (env var, built-in default as string).
|
|
116
|
+
# Defaults mirror the settings dataclass defaults in supertable/config/settings.py.
|
|
117
|
+
_DUCKDB_SPEC: Dict[str, Tuple[str, str]] = {
|
|
118
|
+
"duckdb_memory_limit": ("SUPERTABLE_DUCKDB_MEMORY_LIMIT", "1GB"),
|
|
119
|
+
"duckdb_io_multiplier": ("SUPERTABLE_DUCKDB_IO_MULTIPLIER", "3"),
|
|
120
|
+
"duckdb_threads": ("SUPERTABLE_DUCKDB_THREADS", ""),
|
|
121
|
+
"duckdb_http_timeout": ("SUPERTABLE_DUCKDB_HTTP_TIMEOUT", ""),
|
|
122
|
+
"duckdb_external_cache_size": ("SUPERTABLE_DUCKDB_EXTERNAL_CACHE_SIZE", "5GB"),
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
# The two DuckDB engines that each carry their own runtime section.
|
|
126
|
+
DUCKDB_ENGINES: Tuple[str, ...] = ("lite", "pro")
|
|
127
|
+
|
|
128
|
+
# Public whitelists (for callers / catalog / tests).
|
|
129
|
+
SHARED_CONFIG_FIELDS: Tuple[str, ...] = tuple(_SHARED_SPEC.keys())
|
|
130
|
+
DUCKDB_CONFIG_FIELDS: Tuple[str, ...] = tuple(_DUCKDB_SPEC.keys())
|
|
131
|
+
ENGINE_CONFIG_FIELDS: Tuple[str, ...] = SHARED_CONFIG_FIELDS + DUCKDB_CONFIG_FIELDS
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@dataclass(frozen=True)
|
|
135
|
+
class EngineRuntimeConfig:
|
|
136
|
+
"""Effective, typed config for one DuckDB engine resolution.
|
|
137
|
+
|
|
138
|
+
Carries the shared auto-pick thresholds (identical across engines) plus the
|
|
139
|
+
DuckDB session pragmas for the engine it was resolved for.
|
|
140
|
+
"""
|
|
141
|
+
|
|
142
|
+
# auto-pick thresholds (shared)
|
|
143
|
+
engine_lite_max_bytes: int
|
|
144
|
+
engine_spark_min_bytes: int
|
|
145
|
+
engine_freshness_sec: int
|
|
146
|
+
|
|
147
|
+
# duckdb session pragmas (per-engine, re-applied live per query)
|
|
148
|
+
duckdb_memory_limit: str # e.g. "1GB"
|
|
149
|
+
duckdb_io_multiplier: float
|
|
150
|
+
duckdb_threads: Optional[int] # None → auto-derive from memory limit
|
|
151
|
+
duckdb_http_timeout: Optional[int] # seconds; None → leave DuckDB default
|
|
152
|
+
duckdb_external_cache_size: str # "" → external file cache disabled
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _redis_cfg(org: str, catalog: Optional[Any]) -> Dict[str, Any]:
|
|
156
|
+
"""Read the stored engine config for ``org`` (best effort).
|
|
157
|
+
|
|
158
|
+
Returns an empty dict when no catalog is supplied or Redis is unreachable,
|
|
159
|
+
so the resolver degrades cleanly to env vars + defaults.
|
|
160
|
+
"""
|
|
161
|
+
if not (org and catalog is not None):
|
|
162
|
+
return {}
|
|
163
|
+
try:
|
|
164
|
+
return catalog.get_engine_config(org) or {}
|
|
165
|
+
except Exception:
|
|
166
|
+
return {}
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _effective(stored: Dict[str, Any], key: str, spec: Dict[str, Tuple[str, str]]) -> Tuple[str, str]:
|
|
170
|
+
"""Return ``(value, source)`` for one field. source ∈ redis|env|default.
|
|
171
|
+
|
|
172
|
+
``stored`` is the dict the value is looked up in directly: the top-level
|
|
173
|
+
document for shared fields, or the ``lite`` / ``pro`` sub-section for
|
|
174
|
+
per-engine fields.
|
|
175
|
+
"""
|
|
176
|
+
env_var, default = spec[key]
|
|
177
|
+
redis_val = stored.get(key) if isinstance(stored, dict) else None
|
|
178
|
+
if redis_val is not None and str(redis_val).strip() != "":
|
|
179
|
+
return str(redis_val).strip(), "redis"
|
|
180
|
+
env_val = (os.getenv(env_var) or "").strip()
|
|
181
|
+
if env_val:
|
|
182
|
+
return env_val, "env"
|
|
183
|
+
return default, "default"
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _to_int(s: Any, fallback: int) -> int:
|
|
187
|
+
try:
|
|
188
|
+
return int(str(s).strip())
|
|
189
|
+
except (TypeError, ValueError):
|
|
190
|
+
return fallback
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _to_float(s: Any, fallback: float) -> float:
|
|
194
|
+
try:
|
|
195
|
+
return float(str(s).strip())
|
|
196
|
+
except (TypeError, ValueError):
|
|
197
|
+
return fallback
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _build_runtime(redis_cfg: Dict[str, Any], engine: str) -> EngineRuntimeConfig:
|
|
201
|
+
"""Build the typed config for ``engine`` ("lite"/"pro") from a stored doc."""
|
|
202
|
+
section = redis_cfg.get(engine) if isinstance(redis_cfg.get(engine), dict) else {}
|
|
203
|
+
|
|
204
|
+
sv = {k: _effective(redis_cfg, k, _SHARED_SPEC)[0] for k in _SHARED_SPEC}
|
|
205
|
+
dv = {k: _effective(section, k, _DUCKDB_SPEC)[0] for k in _DUCKDB_SPEC}
|
|
206
|
+
|
|
207
|
+
threads = _to_int(dv["duckdb_threads"], 0) if dv["duckdb_threads"] else 0
|
|
208
|
+
http = _to_int(dv["duckdb_http_timeout"], 0) if dv["duckdb_http_timeout"] else 0
|
|
209
|
+
|
|
210
|
+
return EngineRuntimeConfig(
|
|
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"], 10 * 1024 * 1024 * 1024),
|
|
213
|
+
engine_freshness_sec=_to_int(sv["engine_freshness_sec"], 300),
|
|
214
|
+
duckdb_memory_limit=normalize_memory_size(dv["duckdb_memory_limit"], default="1GB"),
|
|
215
|
+
duckdb_io_multiplier=_to_float(dv["duckdb_io_multiplier"], 3.0),
|
|
216
|
+
duckdb_threads=(threads if threads > 0 else None),
|
|
217
|
+
duckdb_http_timeout=(http if http > 0 else None),
|
|
218
|
+
duckdb_external_cache_size=normalize_memory_size(dv["duckdb_external_cache_size"], default=""),
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def resolve_engine_configs(org: str, catalog: Optional[Any] = None) -> Dict[str, EngineRuntimeConfig]:
|
|
223
|
+
"""Resolve both DuckDB engines in a single Redis read.
|
|
224
|
+
|
|
225
|
+
Returns ``{"lite": EngineRuntimeConfig, "pro": EngineRuntimeConfig}``. The
|
|
226
|
+
shared thresholds are identical in both; only the DuckDB pragmas differ.
|
|
227
|
+
"""
|
|
228
|
+
cfg = _redis_cfg(org, catalog)
|
|
229
|
+
return {e: _build_runtime(cfg, e) for e in DUCKDB_ENGINES}
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def resolve_engine_config(org: str, catalog: Optional[Any] = None, engine: str = "lite") -> EngineRuntimeConfig:
|
|
233
|
+
"""Resolve effective config for a single ``engine`` (Redis → env → default).
|
|
234
|
+
|
|
235
|
+
``catalog`` is an optional ``RedisCatalog``. When it is None or Redis is
|
|
236
|
+
unreachable the resolver falls back to environment variables and built-in
|
|
237
|
+
defaults, which keeps the engine usable without Redis (e.g. in unit tests).
|
|
238
|
+
"""
|
|
239
|
+
engine = engine if engine in DUCKDB_ENGINES else "lite"
|
|
240
|
+
return _build_runtime(_redis_cfg(org, catalog), engine)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def resolve_engine_config_provenance(org: str, catalog: Optional[Any] = None) -> Dict[str, Any]:
|
|
244
|
+
"""Per-field provenance for the UI.
|
|
245
|
+
|
|
246
|
+
Shape::
|
|
247
|
+
|
|
248
|
+
{
|
|
249
|
+
"shared": {field: {value, source, env_var, default}, ...},
|
|
250
|
+
"lite": {field: {value, source, env_var, default}, ...},
|
|
251
|
+
"pro": {field: {value, source, env_var, default}, ...},
|
|
252
|
+
"modified_ms": <int|None>,
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
``source`` is one of ``redis`` / ``env`` / ``default``.
|
|
256
|
+
"""
|
|
257
|
+
cfg = _redis_cfg(org, catalog)
|
|
258
|
+
|
|
259
|
+
def _block(stored: Dict[str, Any], spec: Dict[str, Tuple[str, str]]) -> Dict[str, Any]:
|
|
260
|
+
out: Dict[str, Any] = {}
|
|
261
|
+
for key, (env_var, default) in spec.items():
|
|
262
|
+
value, source = _effective(stored, key, spec)
|
|
263
|
+
out[key] = {"value": value, "source": source, "env_var": env_var, "default": default}
|
|
264
|
+
return out
|
|
265
|
+
|
|
266
|
+
result: Dict[str, Any] = {"shared": _block(cfg, _SHARED_SPEC)}
|
|
267
|
+
for engine in DUCKDB_ENGINES:
|
|
268
|
+
section = cfg.get(engine) if isinstance(cfg.get(engine), dict) else {}
|
|
269
|
+
result[engine] = _block(section, _DUCKDB_SPEC)
|
|
270
|
+
result["modified_ms"] = cfg.get("modified_ms")
|
|
271
|
+
return result
|
|
@@ -16,7 +16,7 @@ from supertable.utils.sql_parser import SQLParser
|
|
|
16
16
|
from supertable.engine.engine_enum import Engine
|
|
17
17
|
from supertable.engine.duckdb_lite import DuckDBLite
|
|
18
18
|
from supertable.engine.duckdb_pro import DuckDBPro
|
|
19
|
-
from supertable.engine.engine_config import
|
|
19
|
+
from supertable.engine.engine_config import resolve_engine_configs, EngineRuntimeConfig
|
|
20
20
|
from supertable.data_classes import Reflection
|
|
21
21
|
from supertable.config.defaults import logger
|
|
22
22
|
|
|
@@ -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 ---
|
|
@@ -143,10 +189,14 @@ class Executor:
|
|
|
143
189
|
log_prefix: str,
|
|
144
190
|
) -> Tuple[pd.DataFrame, str]:
|
|
145
191
|
# Resolve engine config live (Redis → env → default) for this query so
|
|
146
|
-
# UI changes take effect immediately without restart or cache.
|
|
147
|
-
|
|
192
|
+
# UI changes take effect immediately without restart or cache. Lite and
|
|
193
|
+
# Pro carry independent DuckDB pragmas; the shared auto-pick thresholds
|
|
194
|
+
# are identical in both, so either may drive the routing decision.
|
|
195
|
+
cfgs = resolve_engine_configs(self.organization, self._get_catalog())
|
|
196
|
+
lite_cfg = cfgs["lite"]
|
|
197
|
+
pro_cfg = cfgs["pro"]
|
|
148
198
|
|
|
149
|
-
chosen = engine if engine != Engine.AUTO else self._auto_pick(reflection,
|
|
199
|
+
chosen = engine if engine != Engine.AUTO else self._auto_pick(reflection, lite_cfg)
|
|
150
200
|
|
|
151
201
|
def timer_capture(evt: str):
|
|
152
202
|
timer.capture_and_reset_timing(evt)
|
|
@@ -158,7 +208,7 @@ class Executor:
|
|
|
158
208
|
query_manager=query_manager,
|
|
159
209
|
timer_capture=timer_capture,
|
|
160
210
|
log_prefix=log_prefix,
|
|
161
|
-
engine_config=
|
|
211
|
+
engine_config=lite_cfg,
|
|
162
212
|
)
|
|
163
213
|
used = "duckdb_lite"
|
|
164
214
|
|
|
@@ -170,7 +220,7 @@ class Executor:
|
|
|
170
220
|
query_manager=query_manager,
|
|
171
221
|
timer_capture=timer_capture,
|
|
172
222
|
log_prefix=log_prefix,
|
|
173
|
-
engine_config=
|
|
223
|
+
engine_config=pro_cfg,
|
|
174
224
|
)
|
|
175
225
|
used = "duckdb_pro"
|
|
176
226
|
|
|
@@ -25,7 +25,7 @@ from supertable.engine.engine_common import (
|
|
|
25
25
|
escape_parquet_path,
|
|
26
26
|
)
|
|
27
27
|
|
|
28
|
-
from urllib.parse import urlparse
|
|
28
|
+
from urllib.parse import urlparse, unquote
|
|
29
29
|
|
|
30
30
|
# Suppress verbose INFO logging from PyHive and Thrift libraries.
|
|
31
31
|
# PyHive logs every SQL statement (CREATE VIEW, DROP VIEW, SET, etc.)
|
|
@@ -77,8 +77,14 @@ def _to_s3a_path(file_path: str) -> str:
|
|
|
77
77
|
|
|
78
78
|
if file_path.startswith("http://") or file_path.startswith("https://"):
|
|
79
79
|
parsed = urlparse(file_path)
|
|
80
|
-
# path is /bucket/key — strip the leading slash, split into bucket + key
|
|
81
|
-
|
|
80
|
+
# path is /bucket/key — strip the leading slash, split into bucket + key.
|
|
81
|
+
# urlparse leaves percent-escapes intact, and boto3 presigning encodes
|
|
82
|
+
# the key (Hive partition dirs like ``year=2006`` become ``year%3D2006``).
|
|
83
|
+
# Spark's S3A client expects the *raw* object key and re-encodes itself,
|
|
84
|
+
# so we must decode here — otherwise Ceph RGW, which matches keys
|
|
85
|
+
# literally, 404s on the ``%3D`` form. The presign query string is
|
|
86
|
+
# dropped on purpose: Spark authenticates with its own fs.s3a.* creds.
|
|
87
|
+
path = unquote(parsed.path).lstrip("/")
|
|
82
88
|
if "/" in path:
|
|
83
89
|
return f"s3a://{path}"
|
|
84
90
|
# Degenerate case: only bucket, no key — return as-is
|