diff --git a/.gitmodules b/.gitmodules index 343e61166d959..a5bde06c85cba 100644 --- a/.gitmodules +++ b/.gitmodules @@ -353,7 +353,7 @@ url = https://github.com/ClickHouse/wasmtime [submodule "contrib/delta-kernel-rs"] path = contrib/delta-kernel-rs - url = https://github.com/ClickHouse/delta-kernel-rs.git + url = https://github.com/Altinity/delta-kernel-rs.git [submodule "contrib/SHA3IUF"] path = contrib/SHA3IUF url = https://github.com/brainhub/SHA3IUF.git diff --git a/contrib/delta-kernel-rs b/contrib/delta-kernel-rs index 857565caf9ac6..625c2fa0911c5 160000 --- a/contrib/delta-kernel-rs +++ b/contrib/delta-kernel-rs @@ -1 +1 @@ -Subproject commit 857565caf9ac61511c0e005ee3a32a30877a1a15 +Subproject commit 625c2fa0911c550ca5cada52952a7fb3eb687ea4 diff --git a/src/Common/ProfileEvents.cpp b/src/Common/ProfileEvents.cpp index 217946305cd56..4776c9e3f42f7 100644 --- a/src/Common/ProfileEvents.cpp +++ b/src/Common/ProfileEvents.cpp @@ -450,6 +450,7 @@ M(DeltaLakePartitionPrunedFiles, "Number of skipped files during DeltaLake partition pruning", ValueType::Number) \ M(DeltaLakeSnapshotInitializations, "Number of times a DeltaLake table snapshot was initialized (loaded from object storage)", ValueType::Number) \ M(DeltaLakeScannedFiles, "Number of files scanned during DeltaLake scan callbacks", ValueType::Number) \ + M(DeltaLakeClusteringPredicateFallbacks, "Number of times the clustering-column predicate fallback was used when full predicate translation to delta-kernel failed", ValueType::Number) \ \ M(SlowRead, "Number of reads from a file that were slow. This indicate system overload. Thresholds are controlled by read_backoff_* settings.", ValueType::Number) \ M(ReadBackoff, "Number of times the number of query processing threads was lowered due to slow reads.", ValueType::Number) \ diff --git a/src/Core/Settings.cpp b/src/Core/Settings.cpp index adfdad727e793..85a7ea31a5a0c 100644 --- a/src/Core/Settings.cpp +++ b/src/Core/Settings.cpp @@ -7466,6 +7466,12 @@ Enables throwing an exception if there was an error when analyzing scan predicat )", 0) \ DECLARE(Bool, delta_lake_enable_engine_predicate, true, R"( Enables delta-kernel internal data pruning. +)", 0) \ + DECLARE(Bool, delta_lake_clustering_predicate_fallback, true, R"( +When full predicate translation to delta-kernel fails, fall back to pushing only the +conjuncts that reference liquid clustering columns (which always have per-file min/max stats). +This improves file-level data skipping for queries that combine clustering-column filters +with expressions the kernel cannot translate. )", 0) \ DECLARE(NonZeroUInt64, delta_lake_insert_max_rows_in_data_file, 1000000, R"( Defines a rows limit for a single inserted data file in delta lake. diff --git a/src/Core/SettingsChangesHistory.cpp b/src/Core/SettingsChangesHistory.cpp index 8ab7255ca6040..1e336d727aba8 100644 --- a/src/Core/SettingsChangesHistory.cpp +++ b/src/Core/SettingsChangesHistory.cpp @@ -39,6 +39,11 @@ const VersionToSettingsChangesMap & getSettingsChangesHistory() /// controls new feature and it's 'true' by default, use 'false' as previous_value). /// It's used to implement `compatibility` setting (see https://github.com/ClickHouse/ClickHouse/issues/35972) /// Note: please check if the key already exists to prevent duplicate entries. + addSettingsChanges(settings_changes_history, "26.6.8.20002.altinityantalya", + { + {"delta_lake_clustering_predicate_fallback", false, true, "New setting to enable clustering-aware predicate fallback for Delta Lake tables with Liquid Clustering."}, + }); + addSettingsChanges(settings_changes_history, "26.6.8.20003.altinityantalya", { {"iceberg_target_data_file_size_bytes", 536870912, 536870912, "Target file size for Iceberg bin-packing compaction (default 512 MiB)."}, diff --git a/src/Storages/ObjectStorage/DataLakes/DeltaLake/EnginePredicate.cpp b/src/Storages/ObjectStorage/DataLakes/DeltaLake/EnginePredicate.cpp index 0655f8d10cb55..146878c761656 100644 --- a/src/Storages/ObjectStorage/DataLakes/DeltaLake/EnginePredicate.cpp +++ b/src/Storages/ObjectStorage/DataLakes/DeltaLake/EnginePredicate.cpp @@ -69,6 +69,23 @@ namespace const auto * nullable = assert_cast(node->result_type.get()); return nullable->getNestedType(); } + + /// Whether a node is a constant with value `true`. + /// Such nodes are produced e.g. by ActionsDAG::restrictFilterDAGToInputs, + /// which replaces non-computable branches of `and` with constant true, + /// or by constant folding of the filter. + bool isConstantTrueNode(const DB::ActionsDAG::Node * node) + { + if (!isConstNode(node) || !node->column) + return false; + + /// Conjuncts of `and` in a filter DAG are always (Nullable) UInt8. + if (getTypeIndex(node) != DB::TypeIndex::UInt8) + return false; + + const auto & column = *node->column; + return DB::isColumnConst(column) && column.getBool(0); + } } std::shared_ptr getEnginePredicate( @@ -84,9 +101,11 @@ struct EngineIteratorData EngineIteratorData( ffi::KernelExpressionVisitorState * state_, const DB::ActionsDAG::NodeRawConstPtrs & nodes_, - EnginePredicate & predicate_) + EnginePredicate & predicate_, + bool and_context_ = false) : state(state_) , predicate(predicate_) + , and_context(and_context_) , nodes(nodes_) , it(nodes.begin()) { @@ -99,6 +118,12 @@ struct EngineIteratorData bool hasException() const { return predicate.exception != nullptr; } + void markUnsupportedNode() { predicate.has_unsupported_nodes = true; } + + /// Whether this iterator feeds `visit_predicate_and`, i.e. its nodes are + /// conjuncts of an AND predicate. Only then is it safe to drop constant true. + bool isAndContext() const { return and_context; } + const LoggerPtr & log() const { return predicate.log; } const DB::ActionsDAG::Node * next() @@ -110,6 +135,7 @@ struct EngineIteratorData ffi::KernelExpressionVisitorState * state; EnginePredicate & predicate; + const bool and_context; private: const DB::ActionsDAG::NodeRawConstPtrs & nodes; @@ -135,30 +161,48 @@ class EngineIterator : public ffi::EngineIterator auto * iterator_data = static_cast(data_); try { - if (iterator_data->hasException()) + while (true) { - LOG_TEST(iterator_data->log(), "Exception during processing"); - return nullptr; - } + if (iterator_data->hasException()) + { + LOG_TEST(iterator_data->log(), "Exception during processing"); + return nullptr; + } - const auto * node = iterator_data->next(); - if (!node) - { - LOG_TEST(iterator_data->log(), "Iterator finished"); - return nullptr; - } + const auto * node = iterator_data->next(); + if (!node) + { + LOG_TEST(iterator_data->log(), "Iterator finished"); + return nullptr; + } - LOG_TEST( - iterator_data->log(), - "Node name: {}, node type: {}, column type: {}", - node->result_name, - node->type, - node->column ? toString(node->column->getDataType()) : "None"); + LOG_TEST( + iterator_data->log(), + "Node name: {}, node type: {}, column type: {}", + node->result_name, + node->type, + node->column ? toString(node->column->getDataType()) : "None"); - auto result = getNextImpl(*iterator_data, node); - if (result && result != VISITOR_FAILED_OR_UNSUPPORTED) - { - return reinterpret_cast(result); + /// Constant `true` does not change an AND predicate, so it can be + /// dropped instead of failing the translation (see isConstantTrueNode). + if (iterator_data->isAndContext() && isConstantTrueNode(node)) + continue; + + auto result = getNextImpl(*iterator_data, node); + if (result && result != VISITOR_FAILED_OR_UNSUPPORTED) + { + return reinterpret_cast(result); + } + + if (!iterator_data->hasException()) + { + LOG_TEST(iterator_data->log(), "Unsupported node: {}", node->result_name); + iterator_data->markUnsupportedNode(); + } + + /// An unsupported node must not truncate the rest of the conjuncts: + /// the kernel treats nullptr as the end of the iterator, so + /// returning here would silently drop all the following nodes. } } catch (...) @@ -175,11 +219,15 @@ class EngineIterator : public ffi::EngineIterator uintptr_t EnginePredicate::visitPredicate(void * data, ffi::KernelExpressionVisitorState * state) { auto * predicate = static_cast(data); - EngineIteratorData iterator_data(state, predicate->filter.getOutputs(), *predicate); + EngineIteratorData iterator_data(state, predicate->filter.getOutputs(), *predicate, /* and_context_ */ true); EngineIterator engine_iterator(iterator_data); auto result = ffi::visit_predicate_and(state, &engine_iterator); - LOG_TEST(iterator_data.log(), "visitPredicate finished (exception: {})", predicate->hasException()); + LOG_TEST( + iterator_data.log(), + "visitPredicate finished (exception: {}, unsupported nodes: {})", + predicate->hasException(), + predicate->hasUnsupportedNodes()); return result; } @@ -289,7 +337,8 @@ uintptr_t EngineIterator::getNextImpl(EngineIteratorData & iterator_data, const EngineIteratorData current_iterator_data( iterator_data.state, node->children, - iterator_data.predicate); + iterator_data.predicate, + /* and_context_ */ true); EngineIterator current_engine_iterator(current_iterator_data); diff --git a/src/Storages/ObjectStorage/DataLakes/DeltaLake/EnginePredicate.h b/src/Storages/ObjectStorage/DataLakes/DeltaLake/EnginePredicate.h index 4ff17685a2f45..5e3e0c7def5f2 100644 --- a/src/Storages/ObjectStorage/DataLakes/DeltaLake/EnginePredicate.h +++ b/src/Storages/ObjectStorage/DataLakes/DeltaLake/EnginePredicate.h @@ -46,6 +46,10 @@ class EnginePredicate : public ffi::EnginePredicate exception = exception_; } + /// Whether some node of the filter could not be translated to a delta-kernel expression. + /// Such nodes are dropped from the predicate passed to delta-kernel. + bool hasUnsupportedNodes() const { return has_unsupported_nodes; } + const DB::ActionsDAG & getFilterDAG() const { return filter; } DB::ContextPtr getContext() const { return context; } @@ -58,6 +62,7 @@ class EnginePredicate : public ffi::EnginePredicate /// Exceptions cannot be rethrown as it will cause /// panic from rust and server terminate. std::exception_ptr & exception; + bool has_unsupported_nodes = false; /// Context for accessing settings DB::ContextPtr context; diff --git a/src/Storages/ObjectStorage/DataLakes/DeltaLake/TableSnapshot.cpp b/src/Storages/ObjectStorage/DataLakes/DeltaLake/TableSnapshot.cpp index e8ba1daa51a63..dd18e4c2727f1 100644 --- a/src/Storages/ObjectStorage/DataLakes/DeltaLake/TableSnapshot.cpp +++ b/src/Storages/ObjectStorage/DataLakes/DeltaLake/TableSnapshot.cpp @@ -26,6 +26,11 @@ #include #include #include +#include + +#include +#include +#include #include #include @@ -41,6 +46,7 @@ namespace DB::ErrorCodes { extern const int BAD_ARGUMENTS; extern const int DELTA_KERNEL_ERROR; + extern const int INCORRECT_DATA; } namespace DB::Setting @@ -49,6 +55,7 @@ namespace DB::Setting extern const SettingsInt64 delta_lake_snapshot_version; extern const SettingsBool delta_lake_throw_on_engine_predicate_error; extern const SettingsBool delta_lake_enable_engine_predicate; + extern const SettingsBool delta_lake_clustering_predicate_fallback; } namespace ProfileEvents @@ -56,6 +63,7 @@ namespace ProfileEvents extern const Event DeltaLakePartitionPrunedFiles; extern const Event DeltaLakeSnapshotInitializations; extern const Event DeltaLakeScannedFiles; + extern const Event DeltaLakeClusteringPredicateFallbacks; } namespace DB::FailPoints @@ -96,6 +104,7 @@ class TableSnapshot::Iterator final : public DB::IObjectIterator const TableSchema & table_schema_, const DB::NameToNameMap & physical_names_map_, const DB::Names & partition_columns_, + const DB::Names & clustering_columns_, DB::ObjectStoragePtr object_storage_, const DB::ActionsDAG * filter_, DB::IDataLakeMetadata::FileProgressCallback callback_, @@ -103,6 +112,7 @@ class TableSnapshot::Iterator final : public DB::IObjectIterator bool enable_expression_visitor_logging_, bool throw_on_engine_predicate_error_, bool enable_engine_predicate_, + bool enable_clustering_predicate_fallback_, UpdateStatsFunc update_stats_func_, LoggerPtr log_) : kernel_snapshot_state(kernel_snapshot_state_) @@ -111,6 +121,7 @@ class TableSnapshot::Iterator final : public DB::IObjectIterator , read_schema(read_schema_) , expression_schema(table_schema_) , partition_columns(partition_columns_) + , clustering_columns(clustering_columns_) , object_storage(object_storage_) , callback(callback_) , list_batch_size(list_batch_size_) @@ -118,6 +129,7 @@ class TableSnapshot::Iterator final : public DB::IObjectIterator , enable_expression_visitor_logging(enable_expression_visitor_logging_) , throw_on_engine_predicate_error(throw_on_engine_predicate_error_) , enable_engine_predicate(enable_engine_predicate_) + , enable_clustering_predicate_fallback(enable_clustering_predicate_fallback_) , update_stats_func(update_stats_func_) { if (filter_) @@ -189,6 +201,80 @@ class TableSnapshot::Iterator final : public DB::IObjectIterator predicate.get(), /* schema */nullptr), "scan"); + + const bool full_translation_failed = engine_predicate_exception || predicate->hasUnsupportedNodes(); + if (full_translation_failed && enable_clustering_predicate_fallback && !clustering_columns.empty()) + { + /// Full predicate translation failed. Try a fallback: restrict the filter + /// to only reference clustering columns (which always have per-file min/max stats). + /// `restrictFilterDAGToInputs` replaces non-computable AND branches with `true`, + /// giving us a weaker but valid predicate for file-level skipping. + DB::NameSet clustering_inputs; + for (const auto & col : clustering_columns) + clustering_inputs.insert(col); + + const auto * filter_output = filter->getOutputs().at(0); + auto restricted_dag = filter->restrictFilterDAGToInputs(filter_output, clustering_inputs); + + /// Check if the restricted filter is non-trivial (has at least one function node). + /// If restrictFilterDAGToInputs replaced everything with constant `true`, + /// the DAG will have no function nodes. + bool has_function = false; + for (const auto & node : restricted_dag.getNodes()) + { + if (node.type == DB::ActionsDAG::ActionType::FUNCTION) + { + has_function = true; + break; + } + } + + if (has_function) + { + std::exception_ptr fallback_exception; + auto fallback_predicate = getEnginePredicate(restricted_dag, fallback_exception, nullptr); + + /// The predicate is translated inside `ffi::scan`, so its outcome is known only afterwards. + KernelScan fallback_scan; + fallback_scan = KernelUtils::unwrapResult( + ffi::scan( + kernel_snapshot_state->snapshot.get(), + kernel_snapshot_state->engine.get(), + fallback_predicate.get(), + /* schema */nullptr), + "scan"); + + if (!fallback_exception && !fallback_predicate->hasUnsupportedNodes()) + { + ProfileEvents::increment(ProfileEvents::DeltaLakeClusteringPredicateFallbacks); + + if (engine_predicate_exception) + { + /// The original scan's predicate may be in an unreliable state + /// after a translation exception; use the restricted predicate. + LOG_INFO( + log, + "Full predicate translation failed; falling back to clustering-column-only " + "predicate for file-level data skipping (clustering columns: {})", + fmt::join(clustering_columns, ", ")); + + scan = std::move(fallback_scan); + } + else + { + /// The original scan already contains every translatable conjunct, + /// including all clustering-column conjuncts of the restricted + /// predicate, so keeping it is never weaker than the fallback scan. + LOG_INFO( + log, + "Full predicate translation has unsupported nodes; keeping the original " + "scan, which already includes the clustering-column conjuncts " + "(clustering columns: {})", + fmt::join(clustering_columns, ", ")); + } + } + } + } } else { @@ -617,6 +703,7 @@ class TableSnapshot::Iterator final : public DB::IObjectIterator DB::NamesAndTypesList read_schema; DB::NamesAndTypesList expression_schema; DB::Names partition_columns; + DB::Names clustering_columns; const DB::ObjectStoragePtr object_storage; const DB::IDataLakeMetadata::FileProgressCallback callback; const size_t list_batch_size; @@ -624,6 +711,7 @@ class TableSnapshot::Iterator final : public DB::IObjectIterator const bool enable_expression_visitor_logging; const bool throw_on_engine_predicate_error; const bool enable_engine_predicate; + const bool enable_clustering_predicate_fallback; const UpdateStatsFunc update_stats_func; std::exception_ptr scan_exception; @@ -919,6 +1007,12 @@ DB::ObjectIterator TableSnapshot::iterate( const auto & settings = context->getSettingsRef(); std::lock_guard lock(mutex); initOrUpdateSchemaIfChanged(); + /// Clustering columns are only used by the clustering-predicate fallback in `Iterator::initScanState`. + const bool need_clustering_columns = filter_dag + && settings[DB::Setting::delta_lake_enable_engine_predicate] + && settings[DB::Setting::delta_lake_clustering_predicate_fallback]; + if (need_clustering_columns) + initOrUpdateClusteringColumns(); auto state = getKernelSnapshotState(); auto update_stats_func = [self = shared_from_this(), version = state->snapshot_version, this] (SnapshotStats && stats) @@ -942,6 +1036,7 @@ DB::ObjectIterator TableSnapshot::iterate( schema->table_schema, schema->physical_names_map, schema->partition_columns, + need_clustering_columns ? *clustering_columns : DB::Names{}, object_storage, filter_dag, callback, @@ -949,6 +1044,7 @@ DB::ObjectIterator TableSnapshot::iterate( settings[DB::Setting::delta_lake_enable_expression_visitor_logging], settings[DB::Setting::delta_lake_throw_on_engine_predicate_error], settings[DB::Setting::delta_lake_enable_engine_predicate], + settings[DB::Setting::delta_lake_clustering_predicate_fallback], std::move(update_stats_func), log); } @@ -1011,6 +1107,113 @@ const DB::NameToNameMap & TableSnapshot::getPhysicalNamesMap() const return schema->physical_names_map; } +void TableSnapshot::initOrUpdateClusteringColumns() const +{ + if (clustering_columns.has_value()) + return; + + auto read_domain_metadata = [](const KernelSnapshotState & state) + { + return KernelUtils::unwrapResult( + ffi::get_domain_metadata_internal( + state.snapshot.get(), + KernelUtils::toDeltaString("delta.clustering"), + state.engine.get(), + KernelUtils::allocateString), + "get_domain_metadata_internal(delta.clustering)"); + }; + + auto state = getKernelSnapshotState(); + const auto pre_fingerprint = kernel_state_credentials_fingerprint; + decltype(read_domain_metadata(*state)) result = nullptr; + try + { + result = read_domain_metadata(*state); + } + catch (const DB::Exception & e) + { + if (!tryRefreshAfterStaleTokenError(e, pre_fingerprint, "clustering metadata read")) + throw; + /// Invalidate the cached engine so `initOrUpdateSnapshot` rebuilds with the + /// freshened credentials, then re-read the domain metadata once. + kernel_snapshot_state.reset(); + initOrUpdateSnapshot(); + result = read_domain_metadata(*kernel_snapshot_state); + } + + if (!result) + { + LOG_TRACE(log, "No delta.clustering domain metadata found for table {} — table is not liquid-clustered", helper->getTableLocation()); + clustering_columns.emplace(); + return; + } + + std::unique_ptr json_str(static_cast(result)); + + DB::Names physical_columns; + try + { + Poco::JSON::Parser parser; + auto json = parser.parse(*json_str); + auto obj = json.extract(); + + auto arr = obj->getArray("clusteringColumns"); + if (!arr) + throw DB::Exception(DB::ErrorCodes::INCORRECT_DATA, "delta.clustering: missing clusteringColumns array"); + + for (size_t i = 0; i < arr->size(); ++i) + { + auto path_arr = arr->getArray(static_cast(i)); + if (!path_arr || path_arr->size() == 0) + continue; + + std::string col_name; + for (size_t j = 0; j < path_arr->size(); ++j) + { + if (!col_name.empty()) + col_name += '.'; + col_name += path_arr->getElement(static_cast(j)); + } + physical_columns.push_back(std::move(col_name)); + } + } + catch (const Poco::Exception & e) + { + throw DB::Exception( + DB::ErrorCodes::INCORRECT_DATA, + "Failed to parse delta.clustering domain metadata of table {}: {}. " + "Set delta_lake_clustering_predicate_fallback = 0 to read the table without clustering-based file skipping", + helper->getTableLocation(), e.displayText()); + } + + /// Convert physical column names to logical names using the inverse of physical_names_map. + /// The filter DAG uses logical names, so the clustering column names must match. + DB::Names logical_columns; + if (schema.has_value() && !schema->physical_names_map.empty()) + { + /// Build reverse map: physical → logical + DB::NameToNameMap reverse_map; + for (const auto & [logical, physical] : schema->physical_names_map) + reverse_map[physical] = logical; + + for (const auto & phys : physical_columns) + { + auto it = reverse_map.find(phys); + if (it != reverse_map.end()) + logical_columns.push_back(it->second); + else + logical_columns.push_back(phys); + } + } + else + { + logical_columns = std::move(physical_columns); + } + + LOG_TRACE(log, "Clustering columns for table {}: {}", helper->getTableLocation(), fmt::join(logical_columns, ", ")); + clustering_columns.emplace(std::move(logical_columns)); +} + } #endif diff --git a/src/Storages/ObjectStorage/DataLakes/DeltaLake/TableSnapshot.h b/src/Storages/ObjectStorage/DataLakes/DeltaLake/TableSnapshot.h index 0a6f193113ac7..29b9fc0907555 100644 --- a/src/Storages/ObjectStorage/DataLakes/DeltaLake/TableSnapshot.h +++ b/src/Storages/ObjectStorage/DataLakes/DeltaLake/TableSnapshot.h @@ -106,6 +106,10 @@ class TableSnapshot : public std::enable_shared_from_this }; mutable std::optional schema; + /// Clustering columns from the `delta.clustering` domain metadata. + /// std::nullopt means not yet fetched; empty vector means not clustered. + mutable std::optional clustering_columns; + struct SnapshotStats { /// Total number of bytes in table @@ -121,6 +125,7 @@ class TableSnapshot : public std::enable_shared_from_this void initOrUpdateSnapshot() const TSA_REQUIRES(mutex); void initOrUpdateSchemaIfChanged() const TSA_REQUIRES(mutex); + void initOrUpdateClusteringColumns() const TSA_REQUIRES(mutex); SnapshotStats getSnapshotStats() const TSA_REQUIRES(mutex); SnapshotStats getSnapshotStatsImpl() const TSA_REQUIRES(mutex); diff --git a/tests/integration/test_storage_delta/test.py b/tests/integration/test_storage_delta/test.py index 1a84f02680ada..3df923a3d394b 100644 --- a/tests/integration/test_storage_delta/test.py +++ b/tests/integration/test_storage_delta/test.py @@ -74,6 +74,9 @@ def get_spark(log_dir=None): "spark.sql.catalog.spark_catalog.warehouse", "/var/lib/clickhouse/user_files/test_storage_delta", ) + .config( + "spark.databricks.delta.clusteredTable.enableClusteringTablePreview", "true" + ) .config("spark.driver.memory", "2g") .config("spark.executor.memory", "2g") .master("local") @@ -162,7 +165,7 @@ def started_cluster(): ], user_configs=["configs/users.d/users.xml"], with_installed_binary=True, - image="clickhouse/clickhouse-server", + image="altinity/clickhouse-server", tag=CLICKHOUSE_CI_MIN_TESTED_VERSION, with_minio=True, with_azurite=True, @@ -5632,3 +5635,189 @@ def test_delta_kernel_retry_on_stale_token_via_catalog_callback(started_cluster) f"Expected the catalog-callback retry log line to fire for query {retry_query_id}, " f"found {retry_hits} hits — the stale-token retry path was not exercised." ) + + +def test_liquid_clustering(started_cluster): + """Test reading a Delta Lake table that uses liquid clustering. + + Creates a table with CLUSTER BY via Spark SQL, writes data, runs OPTIMIZE, + then reads it from ClickHouse via the delta-kernel-rs path. Liquid + clustering tables require the `v2Checkpoint` and `domainMetadata` reader + features, which are only supported by the delta-kernel-rs path. + """ + instance = started_cluster.instances["node1"] + spark = started_cluster.spark_session + TABLE_NAME = randomize_table_name("test_liquid_clustering") + + # Create a liquid-clustered Delta table via Spark SQL. + # delta-spark 3.3.0 supports CLUSTER BY. + delta_path = f"/{TABLE_NAME}" + spark.sql(f""" + CREATE TABLE delta.`{delta_path}` (a INT, b STRING) + USING DELTA CLUSTER BY (a, b) + """) + spark.sql(f""" + INSERT INTO delta.`{delta_path}` + SELECT id as a, CAST(id + 1 AS STRING) as b FROM range(100) + """) + spark.sql(f"OPTIMIZE delta.`{delta_path}`") + + files = default_upload_directory( + started_cluster, + "s3", + delta_path, + "", + ) + assert len(files) > 0 + + create_delta_table(instance, "s3", TABLE_NAME, started_cluster) + + # Verify row count and data. + assert int(instance.query(f"SELECT count() FROM {TABLE_NAME}")) == 100 + assert instance.query(f"SELECT * FROM {TABLE_NAME} ORDER BY a") == instance.query( + "SELECT number as a, toString(number + 1) as b FROM numbers(100)" + ) + + # Verify predicate pushdown on the clustering column. + result = instance.query(f"SELECT a FROM {TABLE_NAME} WHERE a = 42") + assert result.strip() == "42" + + # Verify aggregation works. + assert int(instance.query(f"SELECT sum(a) FROM {TABLE_NAME}")) == sum(range(100)) + + +def test_liquid_clustering_data_skipping(started_cluster): + """Verify that filtering on a clustering column skips files at the kernel level. + + Creates a liquid-clustered Delta table with multiple batches so that OPTIMIZE + produces several output files, then checks that a point-lookup on the + clustering column reads fewer files than a full scan. + """ + instance = started_cluster.instances["node1"] + spark = started_cluster.spark_session + TABLE_NAME = randomize_table_name("test_liquid_clustering_ds") + + delta_path = f"/{TABLE_NAME}" + spark.sql(f""" + CREATE TABLE delta.`{delta_path}` (a INT, b STRING) + USING DELTA CLUSTER BY (a, b) + """) + + # Insert multiple batches to produce several files after OPTIMIZE. + for i in range(5): + spark.sql(f""" + INSERT INTO delta.`{delta_path}` + SELECT id + {i * 1000} as a, CAST(id + {i * 1000} AS STRING) as b + FROM range(1000) + """) + # OPTIMIZE targets ~1GB cubes/files by default, which would pack this small + # dataset into a single file and leave nothing to skip. Shrink the cube and + # file size targets so several files are produced. + spark.conf.set("spark.databricks.delta.optimize.maxFileSize", "10240") + spark.conf.set( + "spark.databricks.delta.optimize.clustering.mergeStrategy.minCubeSize.targetCubeSize", + "10240", + ) + spark.conf.set( + "spark.databricks.delta.optimize.clustering.mergeStrategy.minCubeSize.threshold", + "1024", + ) + spark.sql(f"OPTIMIZE delta.`{delta_path}`") + + files = default_upload_directory(started_cluster, "s3", delta_path, "") + assert len(files) > 0 + + create_delta_table(instance, "s3", TABLE_NAME, started_cluster) + + total_rows = int(instance.query(f"SELECT count() FROM {TABLE_NAME}")) + assert total_rows == 5000 + + # Full scan — note the total scanned files. + full_scan_id = f"{TABLE_NAME}_full_scan" + instance.query( + f"SELECT count() FROM {TABLE_NAME} SETTINGS optimize_trivial_count_query = 0", + query_id=full_scan_id, + ) + instance.query("SYSTEM FLUSH LOGS") + full_scan_files = int( + instance.query( + f"SELECT ProfileEvents['DeltaLakeScannedFiles'] " + f"FROM system.query_log WHERE query_id = '{full_scan_id}' AND type = 'QueryFinish'" + ).strip() + ) + + # Point query on the clustering column — should scan fewer files. + point_query_id = f"{TABLE_NAME}_point_query" + result = instance.query( + f"SELECT a FROM {TABLE_NAME} WHERE a = 42", query_id=point_query_id + ) + assert result.strip() == "42" + instance.query("SYSTEM FLUSH LOGS") + point_scan_files = int( + instance.query( + f"SELECT ProfileEvents['DeltaLakeScannedFiles'] " + f"FROM system.query_log WHERE query_id = '{point_query_id}' AND type = 'QueryFinish'" + ).strip() + ) + + assert point_scan_files < full_scan_files, ( + f"Expected clustering to enable file skipping: point query scanned {point_scan_files} files " + f"but full scan scanned {full_scan_files} files" + ) + + +def test_liquid_clustering_predicate_fallback(started_cluster): + """Verify that the clustering-column predicate fallback fires when the full + predicate cannot be translated, and that file skipping still works. + + Uses a filter combining an untranslatable expression with a simple clustering + column filter. The fallback should push the clustering filter to the kernel + while ClickHouse evaluates the full expression on the remaining rows. + """ + instance = started_cluster.instances["node1"] + spark = started_cluster.spark_session + TABLE_NAME = randomize_table_name("test_liquid_clustering_fb") + + delta_path = f"/{TABLE_NAME}" + spark.sql(f""" + CREATE TABLE delta.`{delta_path}` (a INT, b STRING, c STRING) + USING DELTA CLUSTER BY (a, b) + """) + + for i in range(5): + spark.sql(f""" + INSERT INTO delta.`{delta_path}` + SELECT id + {i * 1000} as a, + CAST(id + {i * 1000} AS STRING) as b, + CAST(id + {i * 1000} + 1 AS STRING) as c + FROM range(1000) + """) + spark.sql(f"OPTIMIZE delta.`{delta_path}`") + + files = default_upload_directory(started_cluster, "s3", delta_path, "") + assert len(files) > 0 + + create_delta_table(instance, "s3", TABLE_NAME, started_cluster) + + # Query with a clustering filter (a = 42) AND an untranslatable expression + # referencing non-clustering column `c` (cityHash64 cannot be translated by + # the delta-kernel engine predicate). `restrictFilterDAGToInputs` will + # replace the non-clustering conjunct with constant true, leaving only + # `a = 42` for the fallback predicate to push for file-level skipping. + fallback_query_id = f"{TABLE_NAME}_fallback" + result = instance.query( + f"SELECT a FROM {TABLE_NAME} WHERE a = 42 AND cityHash64(c) != 0", + query_id=fallback_query_id, + ) + assert result.strip() == "42" + + instance.query("SYSTEM FLUSH LOGS") + fallback_count = int( + instance.query( + f"SELECT ProfileEvents['DeltaLakeClusteringPredicateFallbacks'] " + f"FROM system.query_log WHERE query_id = '{fallback_query_id}' AND type = 'QueryFinish'" + ).strip() + ) + assert fallback_count >= 1, ( + f"Expected the clustering predicate fallback to fire, but got {fallback_count} hits" + )