From 891013f001c3674c5e6631f03af7a95347533913 Mon Sep 17 00:00:00 2001 From: Anton Ivashkin Date: Wed, 26 Aug 2026 15:27:00 +0200 Subject: [PATCH 1/4] Read Iceberg v3 deletion vectors stored as Delta .bin files Databricks writes the spec-aligned deletion-vector-v1 envelope in a Delta container, so Iceberg reads failed on Puffin magic. Decode that slice from content_offset when the object is not a Puffin file. Co-authored-by: Cursor --- .../table-engines/integrations/iceberg.md | 8 +- .../sql-reference/table-functions/iceberg.md | 8 +- .../table-functions/icebergCluster.md | 2 +- .../Common/AvroForIcebergDeserializer.cpp | 11 +- .../Iceberg/IcebergDeletionVector.cpp | 57 ++++-- .../DataLakes/Iceberg/IcebergIterator.cpp | 5 +- .../DataLakes/Iceberg/ManifestFile.cpp | 2 +- .../DataLakes/Iceberg/ManifestFile.h | 13 +- .../DataLakes/PuffinDeletionVectorReader.cpp | 113 ++++++++++- .../DataLakes/PuffinDeletionVectorReader.h | 31 ++- .../tests/gtest_delta_bin_deletion_vector.cpp | 186 ++++++++++++++++++ .../gtest_puffin_dv_referenced_data_file.cpp | 44 +++++ 12 files changed, 434 insertions(+), 46 deletions(-) create mode 100644 src/Storages/ObjectStorage/DataLakes/tests/gtest_delta_bin_deletion_vector.cpp diff --git a/docs/en/engines/table-engines/integrations/iceberg.md b/docs/en/engines/table-engines/integrations/iceberg.md index 19b87ea5c1a9..f67379d3a369 100644 --- a/docs/en/engines/table-engines/integrations/iceberg.md +++ b/docs/en/engines/table-engines/integrations/iceberg.md @@ -131,19 +131,19 @@ ClickHouse supports reading Iceberg tables that use the following deletion metho - [Position deletes](https://iceberg.apache.org/spec/#position-delete-files) - [Equality deletes](https://iceberg.apache.org/spec/#equality-delete-files) (supported from version 25.8+) -- [Deletion vectors](https://iceberg.apache.org/spec/#deletion-vectors) stored in Puffin files (Iceberg v3, read-only) +- [Deletion vectors](https://iceberg.apache.org/spec/#deletion-vectors) (Iceberg v3, read-only), stored either in Puffin files or in Delta-style `deletion_vector_*.bin` files using the same `deletion-vector-v1` envelope at the manifest `content_offset` / `content_size_in_bytes` The following limitations apply to deletion vectors: -- Only `deletion-vector-v1` Puffin blobs are supported +- Only `deletion-vector-v1` blobs are supported (Puffin container or Delta `.bin` slice) - Data files must be in Parquet format - Column-scoped deletion vectors (user column ids in puffin `fields`) are not supported. Writers may set `fields` to `[]` or to the Iceberg reserved `_pos` id (`2147483645`) for file-scoped deletion vectors. - Writing deletion vectors is not supported - `DELETE` / `UPDATE` mutations on Iceberg format version 3+ tables are rejected (writers must not add position-delete files) -Parsed deletion vectors can be cached in memory when `use_puffin_files_cache` is enabled and the puffin file has a non-empty `etag`. Empty deletion vectors are cached as well, so repeated reads do not re-fetch the puffin file. Parsed footers for coalesced multi-DV Puffin files are memoized with that cache (same identity: storage, path, `etag`) so slices share one footer parse; the memo shares `puffin_files_cache_size` / max-entry limits and is dropped when the cache is disabled (`puffin_files_cache_size=0`) or cleared. The cache can be cleared with `SYSTEM DROP PUFFIN FILES CACHE`. +Parsed deletion vectors can be cached in memory when `use_puffin_files_cache` is enabled and the deletion-vector object has a non-empty `etag`. Empty deletion vectors are cached as well, so repeated reads do not re-fetch the object. Parsed footers for coalesced multi-DV Puffin files are memoized with that cache (same identity: storage, path, `etag`) so slices share one footer parse; Delta `.bin` files have no Puffin footer and skip that memo. The memo shares `puffin_files_cache_size` / max-entry limits and is dropped when the cache is disabled (`puffin_files_cache_size=0`) or cleared. The cache can be cleared with `SYSTEM DROP PUFFIN FILES CACHE`. -For [`icebergCluster`](/sql-reference/table-functions/icebergCluster.md) (and `object_storage_cluster`), the initiator loads and materializes each data file's deletion vector while distributing tasks, then sends the resulting row bitmap to workers with the task. Workers apply the bitmap; they do not re-read the Puffin blob for that path. On wide v3 tables this can make the initiator a serialization point for deletion-vector I/O and decode. +For [`icebergCluster`](/sql-reference/table-functions/icebergCluster.md) (and `object_storage_cluster`), the initiator loads and materializes each data file's deletion vector while distributing tasks, then sends the resulting row bitmap to workers with the task. Workers apply the bitmap; they do not re-read the Puffin or `.bin` object for that path. On wide v3 tables this can make the initiator a serialization point for deletion-vector I/O and decode. ### Basic usage {#basic-usage} ```sql diff --git a/docs/en/sql-reference/table-functions/iceberg.md b/docs/en/sql-reference/table-functions/iceberg.md index 1abff945e6bb..6dd8b8dd3a79 100644 --- a/docs/en/sql-reference/table-functions/iceberg.md +++ b/docs/en/sql-reference/table-functions/iceberg.md @@ -124,19 +124,19 @@ ClickHouse supports reading Iceberg tables that use the following deletion metho - [Position deletes](https://iceberg.apache.org/spec/#position-delete-files) - [Equality deletes](https://iceberg.apache.org/spec/#equality-delete-files) (supported from version 25.8+) -- [Deletion vectors](https://iceberg.apache.org/spec/#deletion-vectors) stored in Puffin files (Iceberg v3, read-only) +- [Deletion vectors](https://iceberg.apache.org/spec/#deletion-vectors) (Iceberg v3, read-only), stored either in Puffin files or in Delta-style `deletion_vector_*.bin` files using the same `deletion-vector-v1` envelope at the manifest `content_offset` / `content_size_in_bytes` The following limitations apply to deletion vectors: -- Only `deletion-vector-v1` Puffin blobs are supported +- Only `deletion-vector-v1` blobs are supported (Puffin container or Delta `.bin` slice) - Data files must be in Parquet format - Column-scoped deletion vectors (user column ids in puffin `fields`) are not supported. Writers may set `fields` to `[]` or to the Iceberg reserved `_pos` id (`2147483645`) for file-scoped deletion vectors. - Writing deletion vectors is not supported - `DELETE` / `UPDATE` mutations on Iceberg format version 3+ tables are rejected (writers must not add position-delete files) -Parsed deletion vectors can be cached in memory when `use_puffin_files_cache` is enabled and the puffin file has a non-empty `etag`. Empty deletion vectors are cached as well, so repeated reads do not re-fetch the puffin file. Parsed footers for coalesced multi-DV Puffin files are memoized with that cache (same identity: storage, path, `etag`) so slices share one footer parse; the memo shares `puffin_files_cache_size` / max-entry limits and is dropped when the cache is disabled (`puffin_files_cache_size=0`) or cleared. The cache can be cleared with `SYSTEM DROP PUFFIN FILES CACHE`. +Parsed deletion vectors can be cached in memory when `use_puffin_files_cache` is enabled and the deletion-vector object has a non-empty `etag`. Empty deletion vectors are cached as well, so repeated reads do not re-fetch the object. Parsed footers for coalesced multi-DV Puffin files are memoized with that cache (same identity: storage, path, `etag`) so slices share one footer parse; Delta `.bin` files have no Puffin footer and skip that memo. The memo shares `puffin_files_cache_size` / max-entry limits and is dropped when the cache is disabled (`puffin_files_cache_size=0`) or cleared. The cache can be cleared with `SYSTEM DROP PUFFIN FILES CACHE`. -For [`icebergCluster`](/sql-reference/table-functions/icebergCluster.md) (and `object_storage_cluster`), the initiator loads and materializes each data file's deletion vector while distributing tasks, then sends the resulting row bitmap to workers with the task. Workers apply the bitmap; they do not re-read the Puffin blob for that path. On wide v3 tables this can make the initiator a serialization point for deletion-vector I/O and decode. +For [`icebergCluster`](/sql-reference/table-functions/icebergCluster.md) (and `object_storage_cluster`), the initiator loads and materializes each data file's deletion vector while distributing tasks, then sends the resulting row bitmap to workers with the task. Workers apply the bitmap; they do not re-read the Puffin or `.bin` object for that path. On wide v3 tables this can make the initiator a serialization point for deletion-vector I/O and decode. ### Basic usage {#basic-usage} diff --git a/docs/en/sql-reference/table-functions/icebergCluster.md b/docs/en/sql-reference/table-functions/icebergCluster.md index 312e5b30ec0b..72577f140e71 100644 --- a/docs/en/sql-reference/table-functions/icebergCluster.md +++ b/docs/en/sql-reference/table-functions/icebergCluster.md @@ -14,7 +14,7 @@ Allows processing files from Apache [Iceberg](https://iceberg.apache.org/) in pa ## Deletion vectors on cluster reads {#deletion-vectors-cluster} -Iceberg v3 [deletion vectors](https://iceberg.apache.org/spec/#deletion-vectors) are loaded on the **initiator** while it distributes tasks: for each data file the initiator reads the Puffin blob, validates it, materializes deleted row positions, and attaches the bitmap to the task sent to workers. Workers apply that bitmap when reading Parquet; they do not fetch or parse the Puffin file again for that path. See [Processing of tables with deleted rows](/sql-reference/table-functions/iceberg.md#deleted-rows) for format limits and caching. +Iceberg v3 [deletion vectors](https://iceberg.apache.org/spec/#deletion-vectors) are loaded on the **initiator** while it distributes tasks: for each data file the initiator reads the Puffin or Delta `.bin` object, validates the `deletion-vector-v1` envelope, materializes deleted row positions, and attaches the bitmap to the task sent to workers. Workers apply that bitmap when reading Parquet; they do not fetch or parse the deletion-vector file again for that path. See [Processing of tables with deleted rows](/sql-reference/table-functions/iceberg.md#deleted-rows) for format limits and caching. On wide tables with many deletion vectors, initiator-side decode and per-task bitmap serialization can become a bottleneck even when Parquet reads are well parallelized across the cluster. diff --git a/src/Storages/ObjectStorage/DataLakes/Common/AvroForIcebergDeserializer.cpp b/src/Storages/ObjectStorage/DataLakes/Common/AvroForIcebergDeserializer.cpp index 277d04c3a77a..8aca0793296f 100644 --- a/src/Storages/ObjectStorage/DataLakes/Common/AvroForIcebergDeserializer.cpp +++ b/src/Storages/ObjectStorage/DataLakes/Common/AvroForIcebergDeserializer.cpp @@ -294,6 +294,7 @@ ParsedManifestFileEntryPtr AvroForIcebergDeserializer::createParsedManifestFileE case FileContentType::POSITION_DELETE: { /// reference_file_path can be absent in schema for some reason, though it is present in specification: https://iceberg.apache.org/spec/#manifests const bool is_puffin = Poco::toLower(file_format) == "puffin"; + const bool has_dv_offsets = content_offset.has_value() && content_size_in_bytes.has_value(); std::optional lower_reference_data_file_path; std::optional upper_reference_data_file_path; bool bounds_set_by_referenced_data_file = false; @@ -309,9 +310,9 @@ ParsedManifestFileEntryPtr AvroForIcebergDeserializer::createParsedManifestFileE bounds_set_by_referenced_data_file = true; } } - /// Parquet position deletes may fall back to file-path column bounds. Puffin deletion + /// Parquet position deletes may fall back to file-path column bounds. Deletion /// vectors must use the dedicated referenced_data_file field only. - if (!bounds_set_by_referenced_data_file && !is_puffin) + if (!bounds_set_by_referenced_data_file && !is_puffin && !has_dv_offsets) { if (auto it = value_for_bounds.find(IcebergPositionDeleteTransform::data_file_path_column_field_id); it != value_for_bounds.end()) @@ -326,13 +327,17 @@ ParsedManifestFileEntryPtr AvroForIcebergDeserializer::createParsedManifestFileE if (is_puffin) { - if (!content_offset.has_value() || !content_size_in_bytes.has_value()) + if (!has_dv_offsets) { throw Exception( DB::ErrorCodes::ICEBERG_SPECIFICATION_VIOLATION, "Puffin deletion vector entry in manifest file '{}' is missing content_offset or content_size_in_bytes", manifest_file_path); } + } + + if (is_puffin || has_dv_offsets) + { requireDirectReferencedDataFileForPuffinDeletionVector( bounds_set_by_referenced_data_file, lower_reference_data_file_path, manifest_file_path); } diff --git a/src/Storages/ObjectStorage/DataLakes/Iceberg/IcebergDeletionVector.cpp b/src/Storages/ObjectStorage/DataLakes/Iceberg/IcebergDeletionVector.cpp index 91d40f6df66f..7f77e3f40e3c 100644 --- a/src/Storages/ObjectStorage/DataLakes/Iceberg/IcebergDeletionVector.cpp +++ b/src/Storages/ObjectStorage/DataLakes/Iceberg/IcebergDeletionVector.cpp @@ -18,6 +18,7 @@ #include #include +#include namespace DB { @@ -131,7 +132,9 @@ DataLakeObjectMetadata::ExcludedRowsPtr loadDeletionVectorUncached( ContextPtr context, LoggerPtr log, bool disable_filesystem_cache, - FooterBlobsPtr preloaded_footer) + FooterBlobsPtr preloaded_footer, + PuffinFilesCache * footer_cache = nullptr, + const std::optional & footer_key = {}) { RelativePathWithMetadata puffin_object{puffin_path}; auto read_settings = context->getReadSettings(); @@ -148,16 +151,35 @@ DataLakeObjectMetadata::ExcludedRowsPtr loadDeletionVectorUncached( if (!file_size) throw Exception(ErrorCodes::BAD_ARGUMENTS, "Cannot determine Puffin file size for '{}'", puffin_path); - FooterBlobsPtr footer_owner = preloaded_footer; - if (!footer_owner) - footer_owner = std::make_shared>(readPuffinFooterBlobsFromSeekable(*seekable, *file_size)); + const auto container = detectIcebergDeletionVectorContainer( + *seekable, *file_size, content_offset, content_size_in_bytes, puffin_path); - bindDeletionVectorBlob( - *footer_owner, - content_offset, - content_size_in_bytes, - expected_data_file.serialize(), - expected_cardinality); + if (container == IcebergDeletionVectorContainer::Puffin) + { + FooterBlobsPtr footer_owner = preloaded_footer; + if (!footer_owner) + { + if (footer_cache && footer_key.has_value()) + { + footer_owner = footer_cache->getOrSetFooter(*footer_key, [&]() + { + return readFooterBlobs(object_storage, puffin_path, context, log, /*disable_filesystem_cache=*/ true); + }); + } + else + { + footer_owner = std::make_shared>( + readPuffinFooterBlobsFromSeekable(*seekable, *file_size)); + } + } + + bindDeletionVectorBlob( + *footer_owner, + content_offset, + content_size_in_bytes, + expected_data_file.serialize(), + expected_cardinality); + } auto deleted_positions = readDeletionVectorFromPuffin( *read_buffer, content_offset, content_size_in_bytes, expected_cardinality); @@ -173,8 +195,9 @@ DataLakeObjectMetadata::ExcludedRowsPtr loadDeletionVectorUncached( LOG_DEBUG( log, - "Loaded deletion vector from puffin file '{}' for data file '{}': {} deleted rows", + "Loaded deletion vector from file '{}' ({}) for data file '{}': {} deleted rows", puffin_path, + container == IcebergDeletionVectorContainer::Puffin ? "Puffin" : "Delta .bin", expected_data_file.serialize(), deleted_positions.size()); @@ -367,14 +390,10 @@ DataLakeObjectMetadata::ExcludedRowsPtr loadDeletionVector( } /// Footer is keyed by file identity only, so N DV slices in one coalesced Puffin share one parse. - /// Resolve the footer only on a deletion-vector cache miss (nested memo lookup). + /// Resolve the footer only on a Puffin deletion-vector cache miss (nested memo lookup). + /// Delta `.bin` objects have no Puffin footer; detection inside the uncached loader skips memo. return cache->getOrSetDeletionVector(*cache_key, [&]() { - auto footer = cache->getOrSetFooter(*footer_key, [&]() - { - return readFooterBlobs(object_storage, puffin_path, context, log, /*disable_filesystem_cache=*/ true); - }); - return loadDeletionVectorUncached( object_storage, puffin_path, @@ -386,7 +405,9 @@ DataLakeObjectMetadata::ExcludedRowsPtr loadDeletionVector( context, log, true, - footer); + nullptr, + cache.get(), + footer_key); }); } diff --git a/src/Storages/ObjectStorage/DataLakes/Iceberg/IcebergIterator.cpp b/src/Storages/ObjectStorage/DataLakes/Iceberg/IcebergIterator.cpp index b843d87e2868..b8a20b9b46bc 100644 --- a/src/Storages/ObjectStorage/DataLakes/Iceberg/IcebergIterator.cpp +++ b/src/Storages/ObjectStorage/DataLakes/Iceberg/IcebergIterator.cpp @@ -398,7 +398,8 @@ ObjectInfoPtr IcebergIterator::next(size_t) /// For icebergCluster, next() runs on the initiator's task-distribution path: DV /// I/O / CRC / roaring materialization happen here, then excluded_rows is sent on - /// the wire per task. Workers apply the bitmap and do not re-read the Puffin blob. + /// the wire per task. Workers apply the bitmap and do not re-read the deletion-vector + /// object (Puffin or Delta `.bin`). auto excluded_rows = Iceberg::loadDeletionVector( puffin_storage, puffin_key, @@ -417,7 +418,7 @@ ObjectInfoPtr IcebergIterator::next(size_t) has_deletion_vector = true; LOG_DEBUG( logger, - "Attached deletion vector from puffin file `{}` to data file `{}`", + "Attached deletion vector from file `{}` to data file `{}`", parsed_entry->file_path_key, data_file_path); } diff --git a/src/Storages/ObjectStorage/DataLakes/Iceberg/ManifestFile.cpp b/src/Storages/ObjectStorage/DataLakes/Iceberg/ManifestFile.cpp index bffd91213282..201fdcd3a7eb 100644 --- a/src/Storages/ObjectStorage/DataLakes/Iceberg/ManifestFile.cpp +++ b/src/Storages/ObjectStorage/DataLakes/Iceberg/ManifestFile.cpp @@ -92,7 +92,7 @@ void requireDirectReferencedDataFileForPuffinDeletionVector( { throw DB::Exception( DB::ErrorCodes::ICEBERG_SPECIFICATION_VIOLATION, - "Puffin deletion vector entry in manifest file '{}' is missing referenced_data_file", + "Deletion vector entry in manifest file '{}' is missing referenced_data_file", manifest_file_path); } } diff --git a/src/Storages/ObjectStorage/DataLakes/Iceberg/ManifestFile.h b/src/Storages/ObjectStorage/DataLakes/Iceberg/ManifestFile.h index ff5fd50c0884..acf7a39b17e9 100644 --- a/src/Storages/ObjectStorage/DataLakes/Iceberg/ManifestFile.h +++ b/src/Storages/ObjectStorage/DataLakes/Iceberg/ManifestFile.h @@ -30,7 +30,6 @@ struct ColumnInfo #include #include -#include namespace DB::Iceberg { @@ -103,15 +102,14 @@ struct ParsedManifestFileEntry : boost::noncopyable Int64 record_count; Int64 file_size_in_bytes; - /// Iceberg v3 deletion vector metadata (position delete entries with puffin format) + /// Iceberg v3 deletion vector metadata (`content_offset` / `content_size_in_bytes`). + /// Present for Puffin containers and for Delta-style `.bin` files that store the same envelope. std::optional content_offset; std::optional content_size_in_bytes; bool isDeletionVector() const { - return Poco::toLower(file_format) == "puffin" - && content_offset.has_value() - && content_size_in_bytes.has_value(); + return content_offset.has_value() && content_size_in_bytes.has_value(); } ParsedManifestFileEntry( @@ -182,8 +180,9 @@ std::optional getRecordCountInAllFilesExcludingDeleted( std::optional getBytesSizeInAllDataFilesExcludingDeleted( const std::vector & files); -/// Puffin deletion vectors must identify the data file via the dedicated `referenced_data_file` -/// manifest field (non-empty). Position-delete lower/upper bounds must not be used as a fallback. +/// Deletion vectors (Puffin or Delta `.bin`) must identify the data file via the dedicated +/// `referenced_data_file` manifest field (non-empty). Position-delete lower/upper bounds +/// must not be used as a fallback. void requireDirectReferencedDataFileForPuffinDeletionVector( bool set_from_referenced_data_file_field, const std::optional & referenced_path, diff --git a/src/Storages/ObjectStorage/DataLakes/PuffinDeletionVectorReader.cpp b/src/Storages/ObjectStorage/DataLakes/PuffinDeletionVectorReader.cpp index b91a87fc6269..92a11a6457d0 100644 --- a/src/Storages/ObjectStorage/DataLakes/PuffinDeletionVectorReader.cpp +++ b/src/Storages/ObjectStorage/DataLakes/PuffinDeletionVectorReader.cpp @@ -8,6 +8,8 @@ #include #include #include +#include +#include #include #include @@ -107,25 +109,126 @@ void checkDeletionVectorBlobReadLimits(Int64 length, std::optional expec throw Exception(ErrorCodes::BAD_ARGUMENTS, "Deletion vector blob is too small"); } -void validateDeletionVectorEnvelope(const UInt8 * header, Int64 length) +bool isPuffinFileMagic(const UInt8 * header) +{ + return std::memcmp(header, PUFFIN_FILE_MAGIC, sizeof(PUFFIN_FILE_MAGIC)) == 0; +} + +bool isDeletionVectorV1Envelope(const UInt8 * header, Int64 length) { + if (length < 12) + return false; + const UInt32 combined_length = readBigEndianUInt32(header); if (std::memcmp(header + sizeof(UInt32), DELETION_VECTOR_MAGIC, sizeof(DELETION_VECTOR_MAGIC)) != 0) - throw Exception(ErrorCodes::BAD_ARGUMENTS, "Invalid deletion vector magic"); + return false; if (combined_length < sizeof(DELETION_VECTOR_MAGIC)) - throw Exception(ErrorCodes::BAD_ARGUMENTS, "Invalid deletion vector combined length: {}", combined_length); + return false; UInt64 expected_blob_size = 0; if (common::addOverflow(static_cast(combined_length), UInt64{8}, expected_blob_size)) - throw Exception(ErrorCodes::BAD_ARGUMENTS, "Invalid deletion vector combined length: {}", combined_length); + return false; + + return static_cast(length) == expected_blob_size; +} + +void validateDeletionVectorEnvelope(const UInt8 * header, Int64 length) +{ + if (!isDeletionVectorV1Envelope(header, length)) + { + const UInt32 combined_length = readBigEndianUInt32(header); + if (std::memcmp(header + sizeof(UInt32), DELETION_VECTOR_MAGIC, sizeof(DELETION_VECTOR_MAGIC)) != 0) + throw Exception(ErrorCodes::BAD_ARGUMENTS, "Invalid deletion vector magic"); + + if (combined_length < sizeof(DELETION_VECTOR_MAGIC)) + throw Exception(ErrorCodes::BAD_ARGUMENTS, "Invalid deletion vector combined length: {}", combined_length); + + UInt64 expected_blob_size = 0; + if (common::addOverflow(static_cast(combined_length), UInt64{8}, expected_blob_size)) + throw Exception(ErrorCodes::BAD_ARGUMENTS, "Invalid deletion vector combined length: {}", combined_length); - if (static_cast(length) != expected_blob_size) throw Exception( ErrorCodes::BAD_ARGUMENTS, "Deletion vector blob size {} does not match combined length {}", length, combined_length); + } +} + +namespace +{ + +String formatFourBytesHex(const UInt8 * bytes) +{ + return fmt::format( + "{:02X} {:02X} {:02X} {:02X}", + static_cast(bytes[0]), + static_cast(bytes[1]), + static_cast(bytes[2]), + static_cast(bytes[3])); +} + +[[noreturn]] void throwUnknownDeletionVectorContainer( + std::string_view path, const UInt8 * header, Int64 content_offset) +{ + throw Exception( + ErrorCodes::BAD_ARGUMENTS, + "Deletion vector file '{}' is neither a Puffin container (header magic {}, expected PFA1) " + "nor a deletion-vector-v1 / Delta .bin envelope at offset {}", + path, + formatFourBytesHex(header), + content_offset); +} + +} + +IcebergDeletionVectorContainer detectIcebergDeletionVectorContainer( + SeekableReadBuffer & file, + size_t file_size, + Int64 content_offset, + Int64 content_size_in_bytes, + std::string_view path) +{ + checkDeletionVectorBlobReadLimits(content_size_in_bytes, /*expected_cardinality=*/std::nullopt); + validatePuffinBlobBounds(content_offset, content_size_in_bytes, file_size, "Deletion vector"); + + if (file_size < PUFFIN_MAGIC_SIZE) + { + throw Exception( + ErrorCodes::BAD_ARGUMENTS, + "Deletion vector file '{}' is neither a Puffin container (file size {} is smaller than PFA1) " + "nor a deletion-vector-v1 / Delta .bin envelope at offset {}", + path, + file_size, + content_offset); + } + + file.seek(0, SEEK_SET); + UInt8 header[PUFFIN_MAGIC_SIZE]; + file.readStrict(reinterpret_cast(header), sizeof(header)); + + if (isPuffinFileMagic(header)) + return IcebergDeletionVectorContainer::Puffin; + + Int64 envelope_end = 0; + const bool envelope_header_in_bounds + = !common::addOverflow(content_offset, static_cast(8), envelope_end) + && envelope_end >= 0 + && static_cast(envelope_end) <= file_size + && content_offset >= 0; + + if (envelope_header_in_bounds) + { + file.seek(content_offset, SEEK_SET); + UInt8 envelope[8]; + file.readStrict(reinterpret_cast(envelope), sizeof(envelope)); + if (isDeletionVectorV1Envelope(envelope, content_size_in_bytes)) + return IcebergDeletionVectorContainer::SliceOnly; + } + + throwUnknownDeletionVectorContainer(path, header, content_offset); + UNREACHABLE(); } std::vector deserializeDeletionVectorV1Blob(std::string_view blob_bytes, std::optional expected_cardinality) diff --git a/src/Storages/ObjectStorage/DataLakes/PuffinDeletionVectorReader.h b/src/Storages/ObjectStorage/DataLakes/PuffinDeletionVectorReader.h index b78f57041d02..4412015160d1 100644 --- a/src/Storages/ObjectStorage/DataLakes/PuffinDeletionVectorReader.h +++ b/src/Storages/ObjectStorage/DataLakes/PuffinDeletionVectorReader.h @@ -4,10 +4,11 @@ #include #include +#include #include +#include #include #include -#include namespace DB { @@ -44,6 +45,34 @@ void validatePuffinBlobBounds(Int64 offset, Int64 length, size_t file_size, std: /// Iceberg deletion-vector-v1 envelope magic (`0xD1D33964`), shared with SQL `Puffin` decode. inline constexpr UInt8 DELETION_VECTOR_MAGIC[4] = {0xD1, 0xD3, 0x39, 0x64}; +/// Puffin file magic (`PFA1`), shared with SQL `Puffin` decode. +inline constexpr UInt8 PUFFIN_FILE_MAGIC[4] = {0x50, 0x46, 0x41, 0x31}; + +enum class IcebergDeletionVectorContainer : uint8_t +{ + /// Classic Iceberg v3 Puffin (`PFA1` + JSON footer). Footer identity bind is required. + Puffin, + /// Spec-aligned `deletion-vector-v1` envelope at `content_offset` without a Puffin container + /// (Databricks / Delta `deletion_vector_*.bin`). Identity comes from the Iceberg manifest. + SliceOnly, +}; + +/// True when `header` is the 4-byte Puffin file magic `PFA1`. +bool isPuffinFileMagic(const UInt8 * header); + +/// True when `header` (first 8 bytes of a blob) is a `deletion-vector-v1` envelope for `length`. +/// Does not throw; used to distinguish Delta `.bin` slices from unknown files without catch-as-control-flow. +bool isDeletionVectorV1Envelope(const UInt8 * header, Int64 length); + +/// Peek the object start: `PFA1` => Puffin; otherwise require a valid `deletion-vector-v1` envelope +/// at (`content_offset`, `content_size_in_bytes`). Throws `BAD_ARGUMENTS` if neither matches. +IcebergDeletionVectorContainer detectIcebergDeletionVectorContainer( + SeekableReadBuffer & file, + size_t file_size, + Int64 content_offset, + Int64 content_size_in_bytes, + std::string_view path); + /// Fail closed before envelope peek / full allocate. Shared by SQL `Puffin` and Iceberg loaders. /// Order: cardinality ceiling, then length bounds (`length < 0`, absolute blob cap, min envelope). void checkDeletionVectorBlobReadLimits(Int64 length, std::optional expected_cardinality); diff --git a/src/Storages/ObjectStorage/DataLakes/tests/gtest_delta_bin_deletion_vector.cpp b/src/Storages/ObjectStorage/DataLakes/tests/gtest_delta_bin_deletion_vector.cpp new file mode 100644 index 000000000000..62f3db885bb4 --- /dev/null +++ b/src/Storages/ObjectStorage/DataLakes/tests/gtest_delta_bin_deletion_vector.cpp @@ -0,0 +1,186 @@ +#include + +#include +#include +#include + +#include +#include +#include +#include + +using namespace DB; + +namespace DB +{ +namespace ErrorCodes +{ +extern const int BAD_ARGUMENTS; +} +} + +namespace +{ + +/// deletion-vector-v1 blob for positions {2, 5} (cardinality 2). Same bytes as +/// gtest_puffin_deletion_vector_envelope.cpp. +constexpr UInt8 two_position_dv_blob[] = { + 0x00, 0x00, 0x00, 0x24, 0xD1, 0xD3, 0x39, 0x64, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x3A, 0x30, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, + 0x10, 0x00, 0x00, 0x00, 0x02, 0x00, 0x05, 0x00, 0x2C, 0xDB, 0x9F, 0xC1, +}; + +/// deletion-vector-v1 blob for positions {7, 9} (cardinality 2). +constexpr UInt8 second_two_position_dv_blob[] = { + 0x00, 0x00, 0x00, 0x24, 0xD1, 0xD3, 0x39, 0x64, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x3A, 0x30, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, + 0x10, 0x00, 0x00, 0x00, 0x07, 0x00, 0x09, 0x00, 0xB7, 0xB0, 0x20, 0xFF, +}; + +/// Coalesced Puffin with two equal-cardinality DVs (same fixture as footer-bind tests). +constexpr UInt8 two_equal_cardinality_dvs_puffin[] = { + 0x50, 0x46, 0x41, 0x31, 0x00, 0x00, 0x00, 0x24, 0xD1, 0xD3, 0x39, 0x64, 0x01, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3A, 0x30, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x01, 0x00, 0x10, 0x00, 0x00, 0x00, 0x02, 0x00, 0x05, 0x00, 0x2C, 0xDB, 0x9F, 0xC1, + 0x00, 0x00, 0x00, 0x24, 0xD1, 0xD3, 0x39, 0x64, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x3A, 0x30, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, + 0x10, 0x00, 0x00, 0x00, 0x07, 0x00, 0x09, 0x00, 0xB7, 0xB0, 0x20, 0xFF, 0x50, 0x46, 0x41, 0x31, + 0x7B, 0x22, 0x62, 0x6C, 0x6F, 0x62, 0x73, 0x22, 0x3A, 0x20, 0x5B, 0x7B, 0x22, 0x74, 0x79, 0x70, + 0x65, 0x22, 0x3A, 0x20, 0x22, 0x64, 0x65, 0x6C, 0x65, 0x74, 0x69, 0x6F, 0x6E, 0x2D, 0x76, 0x65, + 0x63, 0x74, 0x6F, 0x72, 0x2D, 0x76, 0x31, 0x22, 0x2C, 0x20, 0x22, 0x66, 0x69, 0x65, 0x6C, 0x64, + 0x73, 0x22, 0x3A, 0x20, 0x5B, 0x5D, 0x2C, 0x20, 0x22, 0x73, 0x6E, 0x61, 0x70, 0x73, 0x68, 0x6F, + 0x74, 0x2D, 0x69, 0x64, 0x22, 0x3A, 0x20, 0x2D, 0x31, 0x2C, 0x20, 0x22, 0x73, 0x65, 0x71, 0x75, + 0x65, 0x6E, 0x63, 0x65, 0x2D, 0x6E, 0x75, 0x6D, 0x62, 0x65, 0x72, 0x22, 0x3A, 0x20, 0x2D, 0x31, + 0x2C, 0x20, 0x22, 0x6F, 0x66, 0x66, 0x73, 0x65, 0x74, 0x22, 0x3A, 0x20, 0x34, 0x2C, 0x20, 0x22, + 0x6C, 0x65, 0x6E, 0x67, 0x74, 0x68, 0x22, 0x3A, 0x20, 0x34, 0x34, 0x2C, 0x20, 0x22, 0x70, 0x72, + 0x6F, 0x70, 0x65, 0x72, 0x74, 0x69, 0x65, 0x73, 0x22, 0x3A, 0x20, 0x7B, 0x22, 0x72, 0x65, 0x66, + 0x65, 0x72, 0x65, 0x6E, 0x63, 0x65, 0x64, 0x2D, 0x64, 0x61, 0x74, 0x61, 0x2D, 0x66, 0x69, 0x6C, + 0x65, 0x22, 0x3A, 0x20, 0x22, 0x2F, 0x64, 0x61, 0x74, 0x61, 0x2F, 0x66, 0x69, 0x6C, 0x65, 0x5F, + 0x61, 0x2E, 0x70, 0x61, 0x72, 0x71, 0x75, 0x65, 0x74, 0x22, 0x2C, 0x20, 0x22, 0x63, 0x61, 0x72, + 0x64, 0x69, 0x6E, 0x61, 0x6C, 0x69, 0x74, 0x79, 0x22, 0x3A, 0x20, 0x22, 0x32, 0x22, 0x7D, 0x7D, + 0x2C, 0x20, 0x7B, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3A, 0x20, 0x22, 0x64, 0x65, 0x6C, 0x65, + 0x74, 0x69, 0x6F, 0x6E, 0x2D, 0x76, 0x65, 0x63, 0x74, 0x6F, 0x72, 0x2D, 0x76, 0x31, 0x22, 0x2C, + 0x20, 0x22, 0x66, 0x69, 0x65, 0x6C, 0x64, 0x73, 0x22, 0x3A, 0x20, 0x5B, 0x5D, 0x2C, 0x20, 0x22, + 0x73, 0x6E, 0x61, 0x70, 0x73, 0x68, 0x6F, 0x74, 0x2D, 0x69, 0x64, 0x22, 0x3A, 0x20, 0x2D, 0x31, + 0x2C, 0x20, 0x22, 0x73, 0x65, 0x71, 0x75, 0x65, 0x6E, 0x63, 0x65, 0x2D, 0x6E, 0x75, 0x6D, 0x62, + 0x65, 0x72, 0x22, 0x3A, 0x20, 0x2D, 0x31, 0x2C, 0x20, 0x22, 0x6F, 0x66, 0x66, 0x73, 0x65, 0x74, + 0x22, 0x3A, 0x20, 0x34, 0x38, 0x2C, 0x20, 0x22, 0x6C, 0x65, 0x6E, 0x67, 0x74, 0x68, 0x22, 0x3A, + 0x20, 0x34, 0x34, 0x2C, 0x20, 0x22, 0x70, 0x72, 0x6F, 0x70, 0x65, 0x72, 0x74, 0x69, 0x65, 0x73, + 0x22, 0x3A, 0x20, 0x7B, 0x22, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6E, 0x63, 0x65, 0x64, 0x2D, + 0x64, 0x61, 0x74, 0x61, 0x2D, 0x66, 0x69, 0x6C, 0x65, 0x22, 0x3A, 0x20, 0x22, 0x2F, 0x64, 0x61, + 0x74, 0x61, 0x2F, 0x66, 0x69, 0x6C, 0x65, 0x5F, 0x62, 0x2E, 0x70, 0x61, 0x72, 0x71, 0x75, 0x65, + 0x74, 0x22, 0x2C, 0x20, 0x22, 0x63, 0x61, 0x72, 0x64, 0x69, 0x6E, 0x61, 0x6C, 0x69, 0x74, 0x79, + 0x22, 0x3A, 0x20, 0x22, 0x32, 0x22, 0x7D, 0x7D, 0x5D, 0x7D, 0x9A, 0x01, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x50, 0x46, 0x41, 0x31, +}; + +std::vector makeDeltaBin(std::initializer_list blobs) +{ + std::vector file; + file.push_back(0x01); + for (std::string_view blob : blobs) + file.insert(file.end(), blob.begin(), blob.end()); + return file; +} + +} + +TEST(DeltaBinDeletionVector, DetectsSliceOnlyAndDecodesSingleBlob) +{ + const auto file_bytes = makeDeltaBin({std::string_view( + reinterpret_cast(two_position_dv_blob), sizeof(two_position_dv_blob))}); + ReadBufferFromMemory file(file_bytes.data(), file_bytes.size()); + + EXPECT_EQ( + detectIcebergDeletionVectorContainer( + file, file_bytes.size(), /*content_offset=*/1, static_cast(sizeof(two_position_dv_blob)), "deletion_vector.bin"), + IcebergDeletionVectorContainer::SliceOnly); + + const auto positions = readDeletionVectorFromPuffin( + file, /*offset=*/1, static_cast(sizeof(two_position_dv_blob)), /*expected_cardinality=*/2); + ASSERT_EQ(positions.size(), 2u); + EXPECT_EQ(positions[0], 2u); + EXPECT_EQ(positions[1], 5u); +} + +TEST(DeltaBinDeletionVector, DecodesTwoSlicesInOneFile) +{ + const auto file_bytes = makeDeltaBin( + {std::string_view(reinterpret_cast(two_position_dv_blob), sizeof(two_position_dv_blob)), + std::string_view(reinterpret_cast(second_two_position_dv_blob), sizeof(second_two_position_dv_blob))}); + ReadBufferFromMemory file(file_bytes.data(), file_bytes.size()); + + const Int64 first_offset = 1; + const Int64 second_offset = 1 + static_cast(sizeof(two_position_dv_blob)); + + EXPECT_EQ( + detectIcebergDeletionVectorContainer( + file, file_bytes.size(), second_offset, static_cast(sizeof(second_two_position_dv_blob)), "deletion_vector.bin"), + IcebergDeletionVectorContainer::SliceOnly); + + const auto first = readDeletionVectorFromPuffin( + file, first_offset, static_cast(sizeof(two_position_dv_blob)), /*expected_cardinality=*/2); + ASSERT_EQ(first.size(), 2u); + EXPECT_EQ(first[0], 2u); + EXPECT_EQ(first[1], 5u); + + const auto second = readDeletionVectorFromPuffin( + file, second_offset, static_cast(sizeof(second_two_position_dv_blob)), /*expected_cardinality=*/2); + ASSERT_EQ(second.size(), 2u); + EXPECT_EQ(second[0], 7u); + EXPECT_EQ(second[1], 9u); +} + +TEST(DeltaBinDeletionVector, RejectsGarbageHeaderAndInvalidEnvelope) +{ + /// Parquet-like magic; slice at offset 1 is not a deletion-vector-v1 envelope. + /// File must be large enough that offset+length is in bounds so detection reaches magic checks. + constexpr UInt8 garbage[16] = {0x50, 0x41, 0x52, 0x31, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}; + ReadBufferFromMemory file(garbage, sizeof(garbage)); + + try + { + detectIcebergDeletionVectorContainer( + file, sizeof(garbage), /*content_offset=*/1, /*content_size_in_bytes=*/12, "not-a-dv.bin"); + FAIL() << "Expected exception"; + } + catch (const Exception & e) + { + EXPECT_EQ(e.code(), ErrorCodes::BAD_ARGUMENTS); + EXPECT_NE(e.message().find("neither a Puffin container"), std::string::npos); + EXPECT_NE(e.message().find("50 41 52 31"), std::string::npos); + EXPECT_NE(e.message().find("not-a-dv.bin"), std::string::npos); + EXPECT_NE(e.message().find("offset 1"), std::string::npos); + } +} + +TEST(DeltaBinDeletionVector, PuffinHeaderStillTakesFooterBindPath) +{ + ReadBufferFromMemory file(two_equal_cardinality_dvs_puffin, sizeof(two_equal_cardinality_dvs_puffin)); + EXPECT_EQ( + detectIcebergDeletionVectorContainer( + file, sizeof(two_equal_cardinality_dvs_puffin), /*content_offset=*/4, /*content_size_in_bytes=*/44, "dv.puffin"), + IcebergDeletionVectorContainer::Puffin); + + const auto blobs = readPuffinFooterBlobsFromSeekable(file, sizeof(two_equal_cardinality_dvs_puffin)); + const auto & blob = bindDeletionVectorBlob(blobs, /*content_offset=*/4, /*content_size_in_bytes=*/44, "/data/file_a.parquet", 2); + EXPECT_EQ(blob.type, "deletion-vector-v1"); + + const auto positions = readDeletionVectorFromPuffin(file, 4, 44, /*expected_cardinality=*/2); + ASSERT_EQ(positions.size(), 2u); + EXPECT_EQ(positions[0], 2u); + EXPECT_EQ(positions[1], 5u); +} + +TEST(DeltaBinDeletionVector, AcceptsBareEnvelopeWithoutVersionByte) +{ + ReadBufferFromMemory file(two_position_dv_blob, sizeof(two_position_dv_blob)); + EXPECT_EQ( + detectIcebergDeletionVectorContainer( + file, + sizeof(two_position_dv_blob), + /*content_offset=*/0, + static_cast(sizeof(two_position_dv_blob)), + "bare-envelope.bin"), + IcebergDeletionVectorContainer::SliceOnly); +} diff --git a/src/Storages/ObjectStorage/DataLakes/tests/gtest_puffin_dv_referenced_data_file.cpp b/src/Storages/ObjectStorage/DataLakes/tests/gtest_puffin_dv_referenced_data_file.cpp index dba4b03f5244..568ca89a0797 100644 --- a/src/Storages/ObjectStorage/DataLakes/tests/gtest_puffin_dv_referenced_data_file.cpp +++ b/src/Storages/ObjectStorage/DataLakes/tests/gtest_puffin_dv_referenced_data_file.cpp @@ -8,6 +8,11 @@ #include #include +#include +#include +#include +#include + using namespace DB; using namespace DB::Iceberg; @@ -68,4 +73,43 @@ TEST(PuffinDeletionVectorReferencedDataFile, RejectsEmptyPath) } } +namespace +{ + +std::shared_ptr makePositionDeleteEntry( + String file_format, std::optional content_offset, std::optional content_size_in_bytes) +{ + return std::make_shared( + FileContentType::POSITION_DELETE, + IcebergPathFromMetadata::deserialize("s3://bucket/deletes/dv.bin"), + /*row_number=*/0, + ManifestEntryStatus::ADDED, + /*written_sequence_number=*/std::nullopt, + /*written_snapshot_id=*/std::nullopt, + DB::Row{}, + std::unordered_map{}, + std::unordered_map>{}, + std::move(file_format), + IcebergPathFromMetadata::deserialize("s3://bucket/data/file.parquet"), + IcebergPathFromMetadata::deserialize("s3://bucket/data/file.parquet"), + /*equality_ids=*/std::nullopt, + /*sort_order_id=*/std::nullopt, + /*record_count=*/2, + /*file_size_in_bytes=*/45, + content_offset, + content_size_in_bytes); +} + +} + +TEST(IcebergDeletionVectorClassification, OffsetsClassifyAsDeletionVectorRegardlessOfFileFormat) +{ + EXPECT_TRUE(makePositionDeleteEntry("puffin", 1, 44)->isDeletionVector()); + EXPECT_TRUE(makePositionDeleteEntry("PUFFIN", 4, 44)->isDeletionVector()); + EXPECT_TRUE(makePositionDeleteEntry("parquet", 1, 44)->isDeletionVector()); + EXPECT_FALSE(makePositionDeleteEntry("puffin", std::nullopt, std::nullopt)->isDeletionVector()); + EXPECT_FALSE(makePositionDeleteEntry("parquet", std::nullopt, std::nullopt)->isDeletionVector()); + EXPECT_FALSE(makePositionDeleteEntry("parquet", 1, std::nullopt)->isDeletionVector()); +} + #endif From 60be0ab2d88421d928a6126f54dcf81f6ce9eedb Mon Sep 17 00:00:00 2001 From: Anton Ivashkin Date: Wed, 26 Aug 2026 16:28:00 +0200 Subject: [PATCH 2/4] Add Iceberg integration coverage for Delta `.bin` deletion vectors Spark Iceberg DELETE writes Puffin, so convert those objects to the Databricks UniForm layout before the ClickHouse read. Co-authored-by: Cursor --- .../test_deletion_vectors.py | 281 ++++++++++++++++++ 1 file changed, 281 insertions(+) diff --git a/tests/integration/test_storage_iceberg_with_spark/test_deletion_vectors.py b/tests/integration/test_storage_iceberg_with_spark/test_deletion_vectors.py index b6b42786c07a..1d5fd3e64d63 100644 --- a/tests/integration/test_storage_iceberg_with_spark/test_deletion_vectors.py +++ b/tests/integration/test_storage_iceberg_with_spark/test_deletion_vectors.py @@ -1,3 +1,5 @@ +import json +import os import uuid import pytest @@ -40,6 +42,223 @@ def _strip_file_uri_scheme(path): return path +PUFFIN_MAGIC = b"PFA1" +PUFFIN_FOOTER_TRAILER_SIZE = 12 +ICEBERG_WAREHOUSE = "/var/lib/clickhouse/user_files/iceberg_data" + + +def _avro_long(value): + if value is None: + return None + if isinstance(value, dict): + if "long" in value: + return int(value["long"]) + if not value: + return None + return int(next(iter(value.values()))) + return int(value) + + +def _read_avro_file(path): + import avro.datafile + import avro.io + + with open(path, "rb") as f: + reader = avro.datafile.DataFileReader(f, avro.io.DatumReader()) + schema = reader.datum_reader.writers_schema + meta = dict(reader.meta) + codec = reader.codec + records = list(reader) + reader.close() + return records, schema, meta, codec + + +def _write_avro_file(path, records, schema, meta, codec): + import avro.datafile + import avro.io + + with open(path, "wb") as f: + writer = avro.datafile.DataFileWriter(f, avro.io.DatumWriter(), schema, codec=codec) + for key, value in meta.items(): + if key not in ("avro.schema", "avro.codec"): + writer.set_meta(key, value) + for record in records: + writer.append(record) + writer.flush() + writer.close() + + +def _resolve_under_table(table_dir, path): + path = _strip_file_uri_scheme(path) + if os.path.isfile(path): + return path + joined = os.path.join(table_dir, path.lstrip("/")) + if os.path.isfile(joined): + return joined + raise RuntimeError(f"Cannot resolve Iceberg path '{path}' under '{table_dir}'") + + +def _current_metadata(table_dir): + metadata_dir = os.path.join(table_dir, "metadata") + hint_path = os.path.join(metadata_dir, "version-hint.text") + if os.path.isfile(hint_path): + with open(hint_path, encoding="utf-8") as f: + version = f.read().strip() + if version.startswith("v"): + version = version[1:] + path = os.path.join(metadata_dir, f"v{version}.metadata.json") + if os.path.isfile(path): + with open(path, encoding="utf-8") as f: + return json.load(f) + + candidates = [ + os.path.join(metadata_dir, name) + for name in os.listdir(metadata_dir) + if name.endswith(".metadata.json") + ] + if not candidates: + raise RuntimeError(f"No metadata JSON under {metadata_dir}") + + best = None + best_ts = -1 + for path in candidates: + with open(path, encoding="utf-8") as f: + meta = json.load(f) + ts = int(meta.get("last-updated-ms") or 0) + if ts >= best_ts: + best = meta + best_ts = ts + return best + + +def _puffin_to_delta_bin(data): + """Extract deletion-vector-v1 blobs from a Puffin file into a Delta `.bin` envelope. + + Databricks UniForm / Delta DV objects are `version=1` plus the same + `deletion-vector-v1` bytes Iceberg stores inside Puffin. Manifest + `content_offset` for a single blob becomes 1. + """ + if len(data) < 16 or data[:4] != PUFFIN_MAGIC or data[-4:] != PUFFIN_MAGIC: + raise ValueError("not a Puffin file") + + footer_length = int.from_bytes(data[-12:-8], "little", signed=True) + flags = data[-8:-4] + if flags[0] & 0x01: + raise ValueError("compressed Puffin footers are not supported by this test helper") + if footer_length <= 0: + raise ValueError(f"invalid Puffin footer length {footer_length}") + + payload_start = len(data) - PUFFIN_FOOTER_TRAILER_SIZE - footer_length + if payload_start < 8 or data[payload_start - 4 : payload_start] != PUFFIN_MAGIC: + raise ValueError("invalid Puffin footer magic") + + footer = json.loads(data[payload_start : payload_start + footer_length]) + out = bytearray([0x01]) + offset_map = {} + for blob in footer.get("blobs", []): + if blob.get("type") != "deletion-vector-v1": + continue + old_offset = int(blob["offset"]) + length = int(blob["length"]) + slice_bytes = data[old_offset : old_offset + length] + if len(slice_bytes) != length: + raise ValueError(f"truncated Puffin blob at offset {old_offset}") + offset_map[old_offset] = len(out) + out.extend(slice_bytes) + + if not offset_map: + raise ValueError("Puffin file has no deletion-vector-v1 blobs") + return bytes(out), offset_map + + +def convert_spark_puffin_dvs_to_delta_bin(table_name): + """Rewrite Spark Iceberg v3 Puffin DVs as Databricks-style `.bin` files. + + Spark `DELETE` on format-version 3 writes `PFA1` Puffin. Databricks UniForm + writes `deletion_vector_.bin` (version byte + envelope) and still + stores `content_offset` / `content_size_in_bytes` on the Iceberg delete + manifest. Overwrite each referenced Puffin in place and retarget offsets + so ClickHouse must take the slice-only path (skip the Puffin footer). + """ + table_dir = os.path.join(ICEBERG_WAREHOUSE, "default", table_name) + metadata = _current_metadata(table_dir) + snapshot_id = metadata.get("current-snapshot-id") + snapshot = next((s for s in metadata.get("snapshots", []) if s.get("snapshot-id") == snapshot_id), None) + if not snapshot: + raise RuntimeError(f"Snapshot {snapshot_id} not found for {table_name}") + + manifest_list_path = _resolve_under_table(table_dir, snapshot["manifest-list"]) + manifest_list_records, _, _, _ = _read_avro_file(manifest_list_path) + manifest_paths = [ + _resolve_under_table(table_dir, record["manifest_path"]) + for record in manifest_list_records + if "manifest_path" in record + ] + + dv_paths = set() + for manifest_path in manifest_paths: + records, _, _, _ = _read_avro_file(manifest_path) + for record in records: + data_file = record.get("data_file") or {} + if _avro_long(data_file.get("content_offset")) is None: + continue + if _avro_long(data_file.get("content_size_in_bytes")) is None: + continue + dv_paths.add(_resolve_under_table(table_dir, data_file["file_path"])) + + if not dv_paths: + raise RuntimeError(f"No Iceberg deletion-vector entries found under {table_dir}") + + conversions = {} + for path in sorted(dv_paths): + with open(path, "rb") as f: + original = f.read() + if original[:4] != PUFFIN_MAGIC: + raise RuntimeError(f"Expected Puffin magic in '{path}', got {original[:4]!r}") + new_bytes, offset_map = _puffin_to_delta_bin(original) + if new_bytes[:4] == PUFFIN_MAGIC: + raise RuntimeError(f"Converted DV '{path}' still starts with Puffin magic") + with open(path, "wb") as f: + f.write(new_bytes) + conversions[path] = (offset_map, len(new_bytes)) + + patched_entries = 0 + for manifest_path in manifest_paths: + records, schema, meta, codec = _read_avro_file(manifest_path) + changed = False + for record in records: + data_file = record.get("data_file") + if not data_file: + continue + content_offset = _avro_long(data_file.get("content_offset")) + content_size = _avro_long(data_file.get("content_size_in_bytes")) + if content_offset is None or content_size is None: + continue + local_path = _resolve_under_table(table_dir, data_file["file_path"]) + if local_path not in conversions: + continue + offset_map, new_size = conversions[local_path] + if content_offset not in offset_map: + raise RuntimeError( + f"content_offset {content_offset} missing from converted Puffin '{local_path}'" + ) + new_offset = offset_map[content_offset] + if new_offset + content_size > new_size: + raise RuntimeError( + f"Delta .bin slice [{new_offset}, {new_offset + content_size}) exceeds file size {new_size}" + ) + data_file["content_offset"] = new_offset + data_file["file_size_in_bytes"] = new_size + changed = True + patched_entries += 1 + if changed: + _write_avro_file(manifest_path, records, schema, meta, codec) + + if patched_entries == 0: + raise RuntimeError(f"Failed to patch deletion-vector manifests for {table_name}") + return len(conversions) + + def add_equality_deletes_by_id(spark, table_name, ids): """Commit an Iceberg equality-delete file for the given `id` values. @@ -143,6 +362,68 @@ def test_deletion_vectors(started_cluster_iceberg_with_spark, storage_type, run_ ] +@pytest.mark.parametrize("run_on_cluster", [False, True]) +@pytest.mark.parametrize("storage_type", ["s3", "azure", "local"]) +def test_deletion_vectors_delta_bin(started_cluster_iceberg_with_spark, storage_type, run_on_cluster): + """Iceberg v3 DVs stored as Delta `.bin` files (Databricks UniForm), not Puffin. + + Spark Iceberg DELETE writes `PFA1` Puffin. Convert those objects to the + Databricks layout (`0x01` + deletion-vector-v1 envelope, `content_offset=1`) + so ClickHouse must skip the Puffin footer and decode the slice in place. + """ + if storage_type == "local" and run_on_cluster: + pytest.skip("Local storage with cluster execution is not supported") + + instance = started_cluster_iceberg_with_spark.instances["node1"] + spark = started_cluster_iceberg_with_spark.spark_session + table_name = "test_deletion_vectors_delta_bin_" + storage_type + "_" + get_uuid_str() + deleted_ids = [2, 5, 7, 100] + + spark.sql( + f""" + CREATE TABLE {table_name} (id bigint) USING iceberg + TBLPROPERTIES ( + 'format-version' = '3', + 'write.delete.mode' = 'merge-on-read', + 'write.update.mode' = 'merge-on-read', + 'write.merge.mode' = 'merge-on-read' + ) + """ + ) + spark.sql(f"INSERT INTO {table_name} SELECT id FROM range(0, 200)") + spark.sql( + f"DELETE FROM {table_name} WHERE id IN ({', '.join(str(x) for x in deleted_ids)})" + ) + + converted = convert_spark_puffin_dvs_to_delta_bin(table_name) + assert converted >= 1 + + upload_table(started_cluster_iceberg_with_spark, storage_type, table_name) + + expression = get_creation_expression( + storage_type, + table_name, + started_cluster_iceberg_with_spark, + run_on_cluster=run_on_cluster, + table_function=True, + ) + + expected = [x for x in range(200) if x not in deleted_ids] + settings = {"use_iceberg_metadata_files_cache": 0, "use_puffin_files_cache": 0} + + assert int(instance.query(f"SELECT count() FROM {expression}", settings=settings)) == len(expected) + assert get_array(instance.query(f"SELECT id FROM {expression}", settings=settings)) == expected + assert ( + int( + instance.query( + f"SELECT count() FROM {expression}", + settings={**settings, "optimize_trivial_count_query": 1}, + ) + ) + == len(expected) + ) + + @pytest.mark.parametrize("run_on_cluster", [False, True]) @pytest.mark.parametrize("storage_type", ["s3", "azure", "local"]) def test_deletion_vectors_aggregates(started_cluster_iceberg_with_spark, storage_type, run_on_cluster): From 4654495d9eeeb91418fe729d713d5461daadb7d2 Mon Sep 17 00:00:00 2001 From: Anton Ivashkin Date: Wed, 26 Aug 2026 19:09:32 +0200 Subject: [PATCH 3/4] Add a Databricks UniForm Iceberg fixture for Delta `.bin` deletion vectors Spark Iceberg DELETE writes Puffin; this customer table stores DVs as version-1 `.bin` objects with `content_offset = 1`. Co-authored-by: Cursor --- .../altinity_dv_puffin_repro_v3/README.md | 13 ++ ...84-47b8-ae93-31b30e5d8359.gz.metadata.json | Bin 0 -> 655 bytes ...98-46cf-9c64-8d0cdf419c13.gz.metadata.json | Bin 0 -> 1001 bytes ...46-4a90-bf5a-80cf5e93be12.gz.metadata.json | Bin 0 -> 1167 bytes ...c1a95b-41b1-4c5c-a585-cd0480cc6739-m0.avro | Bin 0 -> 7998 bytes ...531858-556b-45f9-a989-a7ba0b0ae967-m0.avro | Bin 0 -> 8007 bytes ...-7fc1a95b-41b1-4c5c-a585-cd0480cc6739.avro | Bin 0 -> 4816 bytes ...-e5531858-556b-45f9-a989-a7ba0b0ae967.avro | Bin 0 -> 4895 bytes ...1-4bc7-b94d-2e5639a05e68.c000.zstd.parquet | Bin 0 -> 50617 bytes ...est_deletion_vectors_databricks_uniform.py | 180 ++++++++++++++++++ 10 files changed, 193 insertions(+) create mode 100644 tests/integration/test_storage_iceberg_with_spark/data/altinity_dv_puffin_repro_v3/README.md create mode 100644 tests/integration/test_storage_iceberg_with_spark/data/altinity_dv_puffin_repro_v3/_iceberg/metadata/00000-102ad1cf-eb84-47b8-ae93-31b30e5d8359.gz.metadata.json create mode 100644 tests/integration/test_storage_iceberg_with_spark/data/altinity_dv_puffin_repro_v3/_iceberg/metadata/00001-364c19a9-7e98-46cf-9c64-8d0cdf419c13.gz.metadata.json create mode 100644 tests/integration/test_storage_iceberg_with_spark/data/altinity_dv_puffin_repro_v3/_iceberg/metadata/00002-8de40d94-9846-4a90-bf5a-80cf5e93be12.gz.metadata.json create mode 100644 tests/integration/test_storage_iceberg_with_spark/data/altinity_dv_puffin_repro_v3/_iceberg/metadata/7fc1a95b-41b1-4c5c-a585-cd0480cc6739-m0.avro create mode 100644 tests/integration/test_storage_iceberg_with_spark/data/altinity_dv_puffin_repro_v3/_iceberg/metadata/e5531858-556b-45f9-a989-a7ba0b0ae967-m0.avro create mode 100644 tests/integration/test_storage_iceberg_with_spark/data/altinity_dv_puffin_repro_v3/_iceberg/metadata/snap-4017675268503509610-1-7fc1a95b-41b1-4c5c-a585-cd0480cc6739.avro create mode 100644 tests/integration/test_storage_iceberg_with_spark/data/altinity_dv_puffin_repro_v3/_iceberg/metadata/snap-6006002030350834282-1-e5531858-556b-45f9-a989-a7ba0b0ae967.avro create mode 100644 tests/integration/test_storage_iceberg_with_spark/data/altinity_dv_puffin_repro_v3/jk/part-00000-c0585bde-5e01-4bc7-b94d-2e5639a05e68.c000.zstd.parquet create mode 100644 tests/integration/test_storage_iceberg_with_spark/test_deletion_vectors_databricks_uniform.py diff --git a/tests/integration/test_storage_iceberg_with_spark/data/altinity_dv_puffin_repro_v3/README.md b/tests/integration/test_storage_iceberg_with_spark/data/altinity_dv_puffin_repro_v3/README.md new file mode 100644 index 000000000000..356bdfea265d --- /dev/null +++ b/tests/integration/test_storage_iceberg_with_spark/data/altinity_dv_puffin_repro_v3/README.md @@ -0,0 +1,13 @@ +# Databricks UniForm Iceberg v3 + Delta `.bin` deletion vectors + +Customer-provided table (`altinity_dv_puffin_repro_v3.zip`). Databricks UniForm writes Iceberg +metadata under `_iceberg/metadata/` and data / deletion vectors at the table root. + +- Schema: `Id String`, `Name String`, `UpdatedAt timestamptz` +- 10000 rows (`Id` `"1"` .. `"10000"`) +- `DELETE WHERE cast(Id as int) <= 1000` → 1000 positions in `deletion_vector_*.bin` +- Delete-manifest entry: `file_format = PUFFIN`, `content_offset = 1`, `content_size_in_bytes = 251` +- Object header is Delta version `0x01`, not Puffin `PFA1` + +The integration test copies this tree, flattens `_iceberg/metadata` to `metadata/`, and rewrites +Databricks `s3://` URIs onto the ClickHouse test warehouse path. diff --git a/tests/integration/test_storage_iceberg_with_spark/data/altinity_dv_puffin_repro_v3/_iceberg/metadata/00000-102ad1cf-eb84-47b8-ae93-31b30e5d8359.gz.metadata.json b/tests/integration/test_storage_iceberg_with_spark/data/altinity_dv_puffin_repro_v3/_iceberg/metadata/00000-102ad1cf-eb84-47b8-ae93-31b30e5d8359.gz.metadata.json new file mode 100644 index 0000000000000000000000000000000000000000..287f3d2187499ad5804fe562a6e9a2fda7bce085 GIT binary patch literal 655 zcmV;A0&x8wiwFP!00000|IJiQkDD+M{VzVX5(WZEfJi-5J?*8Js;6qTGWL+T3)r=X z&3-8Vy<-R@TWzZz_XNzxo0<1!cwvokok0Eo&TG@NT^=zMbqjnLv|>A!ZOTS8jpSvX zHhf(|#p}AvIb?OS-PTo8Wd(~^Yox#tfcbovB#4R+y~Za#cn}^F=pQ~I(97N3X!mHG z*h4~TqBVP;$W>mbJTG{?%~QV4%aYe=k?~5&vIN*{3$;zg)+n|pRw`3#4nmfEovvzL zXIsUyJgc*s8itKX?zDs&+&*+i9dHl72IwX5e(1=~cInwMSS1h??>yVBin1s+G}F2$ zva?^BcIbK@=pc6F;2iV>ClAnx(Mmwm7XkcuVfbVr@PKZR$Y$Rh*=T6h6c{Y4i1nfi z@i&TM9W|*PkfRZ;hv*7S_j`7VrmSqq`WhbpotH0p->0EJ<3Ey7cchr;EIzZ-?ddjR z3K}uAG`s~l4`hWy9kd^<(`48FW#AEEWb~2}VyddTLN*S$aSGf*?P8g*UpZfcTW2f~ zG?3+m9i2vqJ3v9Bir5M~yu!%nVEvXrIniT1U^#_BVqv7`emZDc_`bMyLT!J|bJ{|*0m-6qB!KJsO zuz#g(QU5L8_Rrcbk*59$lJ;+E`wEcG_=4w$t`kJtWrJJYX5!$r_1l_Z_DJbz!E>K~q^3Q09tX69zg={NYKt~4wL000=(JiGt^ literal 0 HcmV?d00001 diff --git a/tests/integration/test_storage_iceberg_with_spark/data/altinity_dv_puffin_repro_v3/_iceberg/metadata/00001-364c19a9-7e98-46cf-9c64-8d0cdf419c13.gz.metadata.json b/tests/integration/test_storage_iceberg_with_spark/data/altinity_dv_puffin_repro_v3/_iceberg/metadata/00001-364c19a9-7e98-46cf-9c64-8d0cdf419c13.gz.metadata.json new file mode 100644 index 0000000000000000000000000000000000000000..c52c0f805bea3eeb632176f225ff7e7fcea24d44 GIT binary patch literal 1001 zcmVy)5>$2m(GvHd{&5 zl2o3^f8U{G%TDWUdJGW2&dhK)^Ue1S{gSlS^&*gaaNgJp&?S%Ie|>JRVACY$#}9!I;%vO6C^&r-flzCWExC(B9jNyR&@d~HDvx8$AwFsa|@6pC<&dVmN zdCjteU)gTz2NK~xbkyV=446*sp%=52Zj$9cOz_W35}rngI0QGTkQ{x-$ksrom%wPj zA_mdN_#2I}4vRDn(9w#{!{#iR8FtC*W;vCur~Zr|zkM!W&;7kL{U!Y3a4KY}_oGzu3?oc(NFIy?8T4bK^7L{CRzOw%-HnXL=NIt^}xcC{?I z-#FhUADtZmQv+IFk_Tr3q&))iroF+53}WY>AHK!2T=N(6Ztk+Yv}^dFnfJ}lo*uctk8oJwRoBEc(O zW>sC%yrfOVPy+)z2IB0)9D!v}8YSJ%8t8=?;^+HW4w{!v3~HaeVg)OC7D-=NpTCjy zxI%u|t~{^s_yT6EFY5OAfCvO+h=dke<64f8lGb!l)CpV74is(zzaT$jzu4zU;>~mH zrlb{%&y(7VVTaDs*p!`ya7cKz!RCr^e-JaMZqnxj2o{Na;o8N1@!G}pQv(MxG9kd~ zIHek`U#eLm-a2s#qKmS7>pU!z(>tDg83n#OS2v5}ilvOE^y(Ap0Qq{AF;?IMo--&K z(Uy6ps|qp&3}1LOY0dDjW$jGGUJRziF(TJ}_&5vT=M}n*gd`V;NEqR*VxlP}DVSuW zP^BWGtV^OaEo!P%#q%Z=_s(8;v7J8&jIIQ<5B{LH`1T;n1HxnG7x4BCsebURszFqi z9}K?~$vYa0cOUzICnGO7&(5MF^*;1CBepm@nhDKROF-5IDY&eOfF>t7lR1S_*Lm5b XyXVuc|K?Ev_=Dv?U47e<-U80qY2m*>6+1%Bu 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 LhW&poOhfV?MNWOP literal 0 HcmV?d00001 diff --git a/tests/integration/test_storage_iceberg_with_spark/data/altinity_dv_puffin_repro_v3/_iceberg/metadata/e5531858-556b-45f9-a989-a7ba0b0ae967-m0.avro b/tests/integration/test_storage_iceberg_with_spark/data/altinity_dv_puffin_repro_v3/_iceberg/metadata/e5531858-556b-45f9-a989-a7ba0b0ae967-m0.avro new file mode 100644 index 0000000000000000000000000000000000000000..c49e7340201c6e44a92d1b71d9bd76b6261af2ef GIT binary patch literal 8007 zcmcIpX^0#}6gDxC7_U_%hz2d&V^wBlx_h(PU3Nn}vN57-G>NFo+SGK{>~_-AJ?ZYr zvda3y3*!|~F+ZY+x1#x>Q4l;5Aq0hZix-Hf;15we@jww@Rd-ia_e^(YXIBV;^i;j$ zdq=(Rsq zhIWkvA+FWK>y?@DnPqsJ@0}NU^U*5qr%z1Jr?$s_)Uaz;;hsU<(4U6ylE?885E?cD z_$h|UcBvWcTm%#KGdb(xdNApQ2B4K(7*pJa14?r0&DHf^Xh7&4-^t&ucl_deT z#5IZGHfj~(cOXrdq*ZN)7{Q!H_DUilBG{mExoNo|`x=~S2G;c45Mbx`l?YkOGa4<$c~gyIjsb4N@#AwPD4~z;-%z-+7FG6_LT|S#LmX3 zlh<$^`d@;^Ww=hUjyy!Rg+dShP=pvbw0`Vh3s{ujl*;{Co1y^E4^#sHl~(Qqs)4Lo z0@pa>B&f9V9H3(PT!Kt8orVQH0@22euxp`FJX(yMY$Uy9u%ON(fSO7HOqDo)jf!(k zlRL^Gb4}Aa0+1oaghLKST_HcyRJoJ7*qwK<^nL(KALt<4^rj*x$~JwVL$K&mPwN%( z3Nh4pdyFc$Fxhmuc{tt_@r?>#4)a^maOTj{v_6_=D?5Nr>&q|H7M)pJ*%5ZkU&Q<^ z1W8*VS<|T3=NBZ}&Gb&XnI<=iOfj|qN$;$kF@M_uNzQiYB1Vmbm`({znWRL<@%Ltp zYx>i5HaEdNl-xrV$Ey^^MVGr#P=_(j@T%2-gzaZ<@Z>psR&TQD^ypfITa2~;V(1LIf*b^?y zlXUn<{TR=FYygc1z4}$7FE;Wv57Y(LIK-U*D#XV3BmnTTGihLj2ipMI)M8*(uudpjI_A}5c))zc=qQaTM(vs;QzuVk--_}p94b7%c2EK@g_hh5eXG#d1eRDj;h?9 zfY8#<6DG3_5b{%{%psu+~9rX&oe@ur5vD zguIk-#WarF*(+LEmaz%JDMRBoB#Kp>kmS;<5 z^!mT}nXpt$6HQbs&&DRaW<$w@W|5_dX1aox*;m^J%$9`#uYy0z`QXTC)1tBQV`58y zX|exAJM85fZj#`;EQ-KKSJBFS~ZE`U`)4 zaOmR~)|@l8WMt|353XJEXl);U@qvqXEj#-1RY%>wr#5EZy!6erM_%&Lf(y*9lRmrU ztWRI+{$b#Q)z3V9z+J2EXdJ)goAq6bx^?TNg(p9@I6Qmz26ue%6}R3vG_?2X-J|dR zzSbZ=cmMvAcgicTe*MXfyH@|RLP8 literal 0 HcmV?d00001 diff --git a/tests/integration/test_storage_iceberg_with_spark/data/altinity_dv_puffin_repro_v3/_iceberg/metadata/snap-4017675268503509610-1-7fc1a95b-41b1-4c5c-a585-cd0480cc6739.avro b/tests/integration/test_storage_iceberg_with_spark/data/altinity_dv_puffin_repro_v3/_iceberg/metadata/snap-4017675268503509610-1-7fc1a95b-41b1-4c5c-a585-cd0480cc6739.avro new file mode 100644 index 0000000000000000000000000000000000000000..dc9dbead027c07245f7c4bcf2b044958c7ef52fd GIT binary patch literal 4816 zcmbVPU1%It6mFwdMWcCW(^lJZ9j$_F>P(W|O@c*AH_~dg#Z8NCZI`<*jY4{WEOrerPG~`;SZ0l}qzWqX2)iPb;y(?M1axp{&DYDbW~ zRl_chA!VyZ3{sc5k`y2u@^p!qD#@6oLYCM(?+_*F>M7)(LghR$L!hXP9fUQF(3^Wj z^;uHl0#-o>e%iv@WtE{xP;fYag-VDwt$|7iP+lq-$HYY^Z@Hm@gfCV-L962CvFqQV z35q;WheA8W$*5K-s#4o1F9-^0S5R|(qPlq&->HyYF2Zode4&r7l%OKc<2fiqd z*(Fe`9KvSBm#;h>N*Er)KrBx*iH%aN=nM-T$ickO;X6qI=Al-g*ld8QksK}=t~5e( z3Jb-Mkb(i@Z)dVFQCvs^RwZAH8UCI?dXe`Q^4>x@%SITk?ad}7C>mH?5T#=A8i2jG zItNx}4$Z_Qn6?5)Lv^VP&9IB5ON9ak#h_OqSj}@fo8Ctj)qR3gG6mvdx(`_2L@_yP zs&=L5ZB=*=1@ED(VP%^_Dq{QObQ{(?%#J9!4AK(?enpoS&m2c^+*4o+VaL(OLFO%F zk}`en(EYnDl^RVUdW$wH+AjmKt35xWS{hMrgW?<5CB4Bq0Iu&Hl$B&68ShUF#pQvx zJe*MEbzbUyfC&e>Eibtkyl+gWOu6Wq}~}-eUA-`Qnz8BjzB2FX1)U-L2sduC!;t>4| zf+>yQg(5&iJJZ-3F#sjW0Jtk^BWx!wiG#%%SWt8Bk0L-rgrz(1OB^-9P#Zg?c97e2 zP7G8YV4#gWQ#+99Harx^8?3<#yQc^|u~F*X6c5hp##}_l@tLa-4I78xMG6c>#|VMJ zXb7P2N%9LqHITW>2!2A%SLd}@@(M3DnX%t`PGQf0( zIxo&k4jT@WOQy#LxN107vuxP|!IUxO7CudV6&+@s%Po@c$2fXOs{^mp$|^PTXPnWY z2C$wpliHBiG{ot zk4x(XCd^N|iR0qA@-C<`S3GDo&geoqldl&*+->k1s4gw|YlkHuv+B>x^42 zAMbwQ*xZ-N!=KK6Ja@;RqX%}}zCU&I{5QW9FP(X4-_;Xi6E_@vxiI~ry5_`V3zshc zsBQf9-@hl`Sv#Ln5*NQu?=_yf|A)=*{V_Q8*1+83y)T^|er@*cN5;DK`<9ZM)5bsO z+x2G>*K6v^rP%((JC8kocH>sesXeYdHL%3D@)HV&o7_+;_#+@SI$3Bd#(QgiVAUQ literal 0 HcmV?d00001 diff --git a/tests/integration/test_storage_iceberg_with_spark/data/altinity_dv_puffin_repro_v3/_iceberg/metadata/snap-6006002030350834282-1-e5531858-556b-45f9-a989-a7ba0b0ae967.avro b/tests/integration/test_storage_iceberg_with_spark/data/altinity_dv_puffin_repro_v3/_iceberg/metadata/snap-6006002030350834282-1-e5531858-556b-45f9-a989-a7ba0b0ae967.avro new file mode 100644 index 0000000000000000000000000000000000000000..4cd9d9d485fee397ff2395be274269623a3cf028 GIT binary patch literal 4895 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 literal 0 HcmV?d00001 diff --git a/tests/integration/test_storage_iceberg_with_spark/test_deletion_vectors_databricks_uniform.py b/tests/integration/test_storage_iceberg_with_spark/test_deletion_vectors_databricks_uniform.py new file mode 100644 index 000000000000..76baa2d11826 --- /dev/null +++ b/tests/integration/test_storage_iceberg_with_spark/test_deletion_vectors_databricks_uniform.py @@ -0,0 +1,180 @@ +import gzip +import json +import os +import shutil + +import pytest + +from helpers.iceberg_utils import ( + default_upload_directory, + get_creation_expression, + get_uuid_str, +) + + +DATABRICKS_TABLE_PREFIX = ( + "s3://ttd-unity-useast/env=test/__unitystorage/catalogs/" + "c137d337-b630-4388-b072-9dc88ee567d6/tables/c771d2d4-eac8-401b-b26d-232b2b8fae5a" +) + +FIXTURE_DIR = os.path.join( + os.path.dirname(__file__), "data", "altinity_dv_puffin_repro_v3" +) + +DV_BIN_NAME = "deletion_vector_3d169d0e-b938-4854-8cca-d9d38949f1b0.bin" +EXPECTED_LIVE_IDS = list(range(1001, 10001)) + + +def get_int_array(query_result: str): + return [int(x) for x in query_result.strip().split("\n") if x] + + +def upload_table(cluster, storage_type, table_name): + default_upload_directory( + cluster, + storage_type, + f"/iceberg_data/default/{table_name}/", + f"/iceberg_data/default/{table_name}/", + ) + + +def _replace_strings(obj, old, new): + if isinstance(obj, str): + value = obj.replace(old, new) + value = value.replace("/_iceberg/metadata/", "/metadata/") + if value.endswith("/_iceberg"): + return value[: -len("/_iceberg")] + return value + if isinstance(obj, list): + return [_replace_strings(item, old, new) for item in obj] + if isinstance(obj, dict): + return {key: _replace_strings(value, old, new) for key, value in obj.items()} + return obj + + +def _rewrite_avro_paths(path, old, new): + import avro.datafile + import avro.io + + with open(path, "rb") as handle: + reader = avro.datafile.DataFileReader(handle, avro.io.DatumReader()) + schema = reader.datum_reader.writers_schema + meta = dict(reader.meta) + codec = reader.codec + records = [_replace_strings(record, old, new) for record in reader] + reader.close() + + with open(path, "wb") as handle: + writer = avro.datafile.DataFileWriter(handle, avro.io.DatumWriter(), schema, codec=codec) + for key, value in meta.items(): + if key not in ("avro.schema", "avro.codec"): + writer.set_meta(key, value) + for record in records: + writer.append(record) + writer.flush() + writer.close() + + +def prepare_databricks_uniform_table(dest_dir): + """Copy the UniForm fixture and rewrite Databricks URIs onto `dest_dir`. + + Iceberg `location` is `_iceberg` while data files live on the parent table root. + Flatten metadata to `metadata/` and set `location` to the parent so + `IcebergPathResolver` can map both data files and the `.bin` DV. + """ + if os.path.exists(dest_dir): + shutil.rmtree(dest_dir) + os.makedirs(dest_dir) + + shutil.copy2(os.path.join(FIXTURE_DIR, DV_BIN_NAME), os.path.join(dest_dir, DV_BIN_NAME)) + shutil.copytree(os.path.join(FIXTURE_DIR, "jk"), os.path.join(dest_dir, "jk")) + shutil.copytree( + os.path.join(FIXTURE_DIR, "_iceberg", "metadata"), + os.path.join(dest_dir, "metadata"), + ) + + with open(os.path.join(dest_dir, DV_BIN_NAME), "rb") as handle: + header = handle.read(4) + assert header[:1] == b"\x01", header + assert header != b"PFA1", header + + new_prefix = dest_dir.rstrip("/") + metadata_dir = os.path.join(dest_dir, "metadata") + for name in os.listdir(metadata_dir): + path = os.path.join(metadata_dir, name) + if name.endswith(".gz.metadata.json"): + with gzip.open(path, "rt", encoding="utf-8") as handle: + text = handle.read() + text = text.replace(DATABRICKS_TABLE_PREFIX, new_prefix) + text = text.replace("/_iceberg/metadata/", "/metadata/") + metadata = json.loads(text) + location = metadata.get("location", "") + if location.endswith("/_iceberg"): + metadata["location"] = location[: -len("/_iceberg")] + with gzip.open(path, "wt", encoding="utf-8") as handle: + json.dump(metadata, handle, separators=(",", ":")) + elif name.endswith(".avro"): + _rewrite_avro_paths(path, DATABRICKS_TABLE_PREFIX, new_prefix) + + return dest_dir + + +@pytest.mark.parametrize("run_on_cluster", [False, True]) +@pytest.mark.parametrize("storage_type", ["s3", "azure", "local"]) +def test_deletion_vectors_databricks_uniform_bin( + started_cluster_iceberg_with_spark, storage_type, run_on_cluster +): + """Read a Databricks UniForm Iceberg v3 table whose DVs are Delta `.bin` files. + + Spark Iceberg writes Puffin; this fixture is the customer layout: `file_format=PUFFIN`, + `content_offset=1`, object bytes `0x01` + deletion-vector-v1 envelope. + """ + if storage_type == "local" and run_on_cluster: + pytest.skip("Local storage with cluster execution is not supported") + + instance = started_cluster_iceberg_with_spark.instances["node1"] + table_name = "altinity_dv_puffin_repro_v3_" + storage_type + "_" + get_uuid_str() + dest_dir = f"/var/lib/clickhouse/user_files/iceberg_data/default/{table_name}" + prepare_databricks_uniform_table(dest_dir) + upload_table(started_cluster_iceberg_with_spark, storage_type, table_name) + + expression = get_creation_expression( + storage_type, + table_name, + started_cluster_iceberg_with_spark, + run_on_cluster=run_on_cluster, + table_function=True, + format_version=3, + ) + settings = {"use_iceberg_metadata_files_cache": 0, "use_puffin_files_cache": 0} + + assert int(instance.query(f"SELECT count() FROM {expression}", settings=settings)) == 9000 + assert ( + int(instance.query(f"SELECT min(toInt64(Id)) FROM {expression}", settings=settings)) + == 1001 + ) + assert ( + int(instance.query(f"SELECT max(toInt64(Id)) FROM {expression}", settings=settings)) + == 10000 + ) + assert ( + int( + instance.query( + f"SELECT countIf(toInt64(Id) <= 1000) FROM {expression}", + settings=settings, + ) + ) + == 0 + ) + assert get_int_array( + instance.query(f"SELECT toInt64(Id) FROM {expression} ORDER BY toInt64(Id)", settings=settings) + ) == EXPECTED_LIVE_IDS + assert ( + int( + instance.query( + f"SELECT count() FROM {expression}", + settings={**settings, "optimize_trivial_count_query": 1}, + ) + ) + == 9000 + ) From d8e94ed1bb500432c1ac8aa4d6367881aaf39977 Mon Sep 17 00:00:00 2001 From: Anton Ivashkin Date: Wed, 26 Aug 2026 19:10:07 +0200 Subject: [PATCH 4/4] Track the Databricks UniForm deletion-vector `.bin` fixture The root gitignore drops `*.bin`, so the previous commit omitted the object the test actually reads. Co-authored-by: Cursor --- .../data/altinity_dv_puffin_repro_v3/.gitignore | 2 ++ ...ector_3d169d0e-b938-4854-8cca-d9d38949f1b0.bin | Bin 0 -> 252 bytes 2 files changed, 2 insertions(+) create mode 100644 tests/integration/test_storage_iceberg_with_spark/data/altinity_dv_puffin_repro_v3/.gitignore create mode 100644 tests/integration/test_storage_iceberg_with_spark/data/altinity_dv_puffin_repro_v3/deletion_vector_3d169d0e-b938-4854-8cca-d9d38949f1b0.bin diff --git a/tests/integration/test_storage_iceberg_with_spark/data/altinity_dv_puffin_repro_v3/.gitignore b/tests/integration/test_storage_iceberg_with_spark/data/altinity_dv_puffin_repro_v3/.gitignore new file mode 100644 index 000000000000..cea42dcdcd26 --- /dev/null +++ b/tests/integration/test_storage_iceberg_with_spark/data/altinity_dv_puffin_repro_v3/.gitignore @@ -0,0 +1,2 @@ +# Root .gitignore ignores `*.bin`. Keep Databricks UniForm deletion-vector objects. +!*.bin diff --git a/tests/integration/test_storage_iceberg_with_spark/data/altinity_dv_puffin_repro_v3/deletion_vector_3d169d0e-b938-4854-8cca-d9d38949f1b0.bin b/tests/integration/test_storage_iceberg_with_spark/data/altinity_dv_puffin_repro_v3/deletion_vector_3d169d0e-b938-4854-8cca-d9d38949f1b0.bin new file mode 100644 index 0000000000000000000000000000000000000000..5234bf016097122e8f671deed971980f0fdb34b6 GIT binary patch literal 252 zcmXxcKMO%&6oBz_T!Z^6CCX&*3CQ4*$--bUNLehDjYTHOB!ds&14y#i