This is an automated email from the ASF dual-hosted git repository.
JingsongLi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/paimon-cpp.git
The following commit(s) were added to refs/heads/main by this push:
new ddc8d63 feat(format): introduce blob file format (#98)
ddc8d63 is described below
commit ddc8d631cfeb8835c5c3ddc59301b5a3f1d3dbfc
Author: Zhang Jiawei <[email protected]>
AuthorDate: Mon Jun 22 17:32:59 2026 +0800
feat(format): introduce blob file format (#98)
---
src/paimon/format/blob/blob_file_batch_reader.cpp | 334 ++++++++++++++++
src/paimon/format/blob/blob_file_batch_reader.h | 182 +++++++++
.../format/blob/blob_file_batch_reader_test.cpp | 370 +++++++++++++++++
src/paimon/format/blob/blob_file_format.h | 76 ++++
.../format/blob/blob_file_format_factory.cpp | 37 ++
src/paimon/format/blob/blob_file_format_factory.h | 43 ++
.../format/blob/blob_file_format_factory_test.cpp | 34 ++
src/paimon/format/blob/blob_format_writer.cpp | 212 ++++++++++
src/paimon/format/blob/blob_format_writer.h | 99 +++++
src/paimon/format/blob/blob_format_writer_test.cpp | 437 +++++++++++++++++++++
src/paimon/format/blob/blob_reader_builder.h | 61 +++
src/paimon/format/blob/blob_stats_extractor.cpp | 64 +++
src/paimon/format/blob/blob_stats_extractor.h | 61 +++
.../format/blob/blob_stats_extractor_test.cpp | 111 ++++++
src/paimon/format/blob/blob_writer_builder.h | 85 ++++
.../format/blob/blob_writer_builder_test.cpp | 57 +++
16 files changed, 2263 insertions(+)
diff --git a/src/paimon/format/blob/blob_file_batch_reader.cpp
b/src/paimon/format/blob/blob_file_batch_reader.cpp
new file mode 100644
index 0000000..52df797
--- /dev/null
+++ b/src/paimon/format/blob/blob_file_batch_reader.cpp
@@ -0,0 +1,334 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#include "paimon/format/blob/blob_file_batch_reader.h"
+
+#include <algorithm>
+#include <future>
+#include <numeric>
+
+#include "arrow/api.h"
+#include "arrow/array/builder_dict.h"
+#include "arrow/array/builder_nested.h"
+#include "arrow/c/bridge.h"
+#include "arrow/util/bit_util.h"
+#include "fmt/format.h"
+#include "paimon/common/data/blob_utils.h"
+#include "paimon/common/executor/future.h"
+#include "paimon/common/io/offset_input_stream.h"
+#include "paimon/common/metrics/metrics_impl.h"
+#include "paimon/common/utils/arrow/mem_utils.h"
+#include "paimon/common/utils/arrow/status_utils.h"
+#include "paimon/common/utils/delta_varint_compressor.h"
+#include "paimon/common/utils/stream_utils.h"
+#include "paimon/data/blob.h"
+
+namespace paimon::blob {
+
+Result<std::unique_ptr<BlobFileBatchReader>> BlobFileBatchReader::Create(
+ const std::shared_ptr<InputStream>& input_stream, int32_t batch_size, bool
blob_as_descriptor,
+ const std::shared_ptr<MemoryPool>& pool) {
+ if (input_stream == nullptr) {
+ return Status::Invalid("blob file batch reader create failed: input
stream is nullptr");
+ }
+ if (batch_size <= 0) {
+ return Status::Invalid(fmt::format(
+ "blob file batch reader create failed: read batch size '{}' should
be larger than zero",
+ batch_size));
+ }
+
+ PAIMON_ASSIGN_OR_RAISE(uint64_t file_size, input_stream->Length());
+ PAIMON_RETURN_NOT_OK(
+ input_stream->Seek(file_size - BlobDefs::kBlobFileHeaderLength,
FS_SEEK_SET));
+ int8_t header[BlobDefs::kBlobFileHeaderLength];
+ PAIMON_ASSIGN_OR_RAISE(
+ int32_t actual_size,
+ input_stream->Read(reinterpret_cast<char*>(header),
BlobDefs::kBlobFileHeaderLength));
+ if (actual_size != BlobDefs::kBlobFileHeaderLength) {
+ return Status::Invalid(
+ fmt::format("actual read size {} not match with expect header
length {}", actual_size,
+ BlobDefs::kBlobFileHeaderLength));
+ }
+ int8_t version = header[4];
+ if (version != BlobDefs::kFileVersion) {
+ return Status::Invalid(fmt::format(
+ "create blob format reader failed. unsupported blob file version:
{}", version));
+ }
+ int32_t index_length = GetIndexLength(header, 0);
+ PAIMON_RETURN_NOT_OK(input_stream->Seek(
+ file_size - BlobDefs::kBlobFileHeaderLength - index_length,
FS_SEEK_SET));
+ std::vector<char> index_bytes(index_length, '\0');
+ PAIMON_ASSIGN_OR_RAISE(actual_size, input_stream->Read(index_bytes.data(),
index_length));
+ if (actual_size != index_length) {
+ return Status::Invalid(
+ fmt::format("actual read size {} not match with expect index
length {}", actual_size,
+ index_length));
+ }
+ PAIMON_ASSIGN_OR_RAISE(const std::vector<int64_t> blob_lengths,
+ DeltaVarintCompressor::Decompress(index_bytes));
+
+ std::vector<int64_t> blob_offsets;
+ blob_offsets.reserve(blob_lengths.size());
+ int64_t offset = 0;
+ for (const auto& blob_length : blob_lengths) {
+ blob_offsets.push_back(offset);
+ // Null blobs (bin_length == -1) don't occupy file space
+ if (blob_length >= 0) {
+ offset += blob_length;
+ }
+ }
+ PAIMON_ASSIGN_OR_RAISE(std::string file_path, input_stream->GetUri());
+ auto reader = std::unique_ptr<BlobFileBatchReader>(new BlobFileBatchReader(
+ input_stream, file_path, blob_lengths, blob_offsets, batch_size,
blob_as_descriptor, pool));
+ return reader;
+}
+
+BlobFileBatchReader::BlobFileBatchReader(const std::shared_ptr<InputStream>&
input_stream,
+ const std::string& file_path,
+ const std::vector<int64_t>&
blob_lengths,
+ const std::vector<int64_t>&
blob_offsets,
+ int32_t batch_size, bool
blob_as_descriptor,
+ const std::shared_ptr<MemoryPool>&
pool)
+ : input_stream_(input_stream),
+ file_path_(file_path),
+ all_blob_lengths_(blob_lengths),
+ all_blob_offsets_(blob_offsets),
+ target_blob_lengths_(blob_lengths),
+ target_blob_offsets_(blob_offsets),
+ batch_size_(batch_size),
+ blob_as_descriptor_(blob_as_descriptor),
+ pool_(pool),
+ arrow_pool_(GetArrowPool(pool_)),
+ metrics_(std::make_shared<MetricsImpl>()) {
+ target_blob_row_indexes_.resize(target_blob_lengths_.size());
+ std::iota(target_blob_row_indexes_.begin(),
target_blob_row_indexes_.end(), 0);
+}
+
+Status BlobFileBatchReader::SetReadSchema(::ArrowSchema* read_schema,
+ const std::shared_ptr<Predicate>&
predicate,
+ const
std::optional<RoaringBitmap32>& selection_bitmap) {
+ if (!read_schema) {
+ return Status::Invalid("SetReadSchema failed: read schema cannot be
nullptr");
+ }
+ PAIMON_ASSIGN_OR_RAISE_FROM_ARROW(std::shared_ptr<arrow::Schema>
arrow_schema,
+ arrow::ImportSchema(read_schema));
+ if (arrow_schema->num_fields() != 1) {
+ return Status::Invalid(
+ fmt::format("read schema field number {} is not 1",
arrow_schema->num_fields()));
+ }
+ if (!BlobUtils::IsBlobField(arrow_schema->field(0))) {
+ return Status::Invalid(
+ fmt::format("field {} is not BLOB",
arrow_schema->field(0)->ToString()));
+ }
+ if (selection_bitmap != std::nullopt) {
+ int32_t cardinality = selection_bitmap->Cardinality();
+ std::vector<int64_t> new_lengths(cardinality);
+ std::vector<int64_t> new_offsets(cardinality);
+ std::vector<uint64_t> new_row_indexes(cardinality);
+
+ PAIMON_ASSIGN_OR_RAISE(uint64_t total_rows, GetNumberOfRows());
+ RoaringBitmap32::Iterator iterator(*selection_bitmap);
+ for (int32_t i = 0; i < cardinality; i++) {
+ int32_t row_index = *iterator;
+ if (static_cast<size_t>(row_index) >= total_rows) {
+ return Status::Invalid(fmt::format(
+ "row index {} is out of bound of total row number {}",
row_index, total_rows));
+ }
+ ++iterator;
+ new_lengths[i] = all_blob_lengths_[row_index];
+ new_offsets[i] = all_blob_offsets_[row_index];
+ new_row_indexes[i] = row_index;
+ }
+ target_blob_lengths_ = new_lengths;
+ target_blob_offsets_ = new_offsets;
+ target_blob_row_indexes_ = new_row_indexes;
+ }
+ target_type_ = arrow::struct_(arrow_schema->fields());
+ current_pos_ = 0;
+ previous_batch_first_row_number_ = std::numeric_limits<uint64_t>::max();
+
+ return Status::OK();
+}
+
+Result<std::shared_ptr<arrow::Buffer>> BlobFileBatchReader::NextBlobOffsets(
+ int32_t rows_to_read) const {
+ arrow::TypedBufferBuilder<int64_t> buffer_builder(arrow_pool_.get());
+ PAIMON_RETURN_NOT_OK_FROM_ARROW(buffer_builder.Reserve(rows_to_read + 1));
+ PAIMON_RETURN_NOT_OK_FROM_ARROW(buffer_builder.Append(0));
+ int64_t data_length = 0;
+ for (int32_t k = 0; k < rows_to_read; ++k) {
+ const size_t i = current_pos_ + k;
+ // Null blobs contribute zero bytes to content
+ if (!IsTargetNull(i)) {
+ data_length += GetTargetContentLength(i);
+ }
+ PAIMON_RETURN_NOT_OK_FROM_ARROW(buffer_builder.Append(data_length));
+ }
+ PAIMON_ASSIGN_OR_RAISE_FROM_ARROW(std::shared_ptr<arrow::Buffer>
offset_buffer,
+ buffer_builder.Finish());
+ return offset_buffer;
+}
+
+Result<std::shared_ptr<arrow::Buffer>> BlobFileBatchReader::NextBlobContents(
+ int32_t rows_to_read) const {
+ int64_t total_length = 0;
+ for (int32_t k = 0; k < rows_to_read; ++k) {
+ const size_t i = current_pos_ + k;
+ if (!IsTargetNull(i)) {
+ total_length += GetTargetContentLength(i);
+ }
+ }
+ PAIMON_ASSIGN_OR_RAISE_FROM_ARROW(std::shared_ptr<arrow::Buffer>
data_buffer,
+ arrow::AllocateBuffer(total_length,
arrow_pool_.get()));
+ uint8_t* buffer = data_buffer->mutable_data();
+ for (int32_t k = 0; k < rows_to_read; ++k) {
+ const size_t i = current_pos_ + k;
+ if (IsTargetNull(i)) {
+ continue;
+ }
+ int64_t offset = GetTargetContentOffset(i);
+ int64_t length = GetTargetContentLength(i);
+ PAIMON_RETURN_NOT_OK(ReadBlobContentAt(offset, length, buffer));
+ buffer += length;
+ }
+ return data_buffer;
+}
+
+Result<std::shared_ptr<arrow::Buffer>> BlobFileBatchReader::BuildNullBitmap(
+ int32_t rows_to_read) const {
+ bool has_null = false;
+ for (int32_t k = 0; k < rows_to_read; ++k) {
+ if (IsTargetNull(current_pos_ + k)) {
+ has_null = true;
+ break;
+ }
+ }
+ if (!has_null) {
+ return std::shared_ptr<arrow::Buffer>();
+ }
+ PAIMON_ASSIGN_OR_RAISE_FROM_ARROW(std::shared_ptr<arrow::Buffer>
null_bitmap,
+ arrow::AllocateBitmap(rows_to_read,
arrow_pool_.get()));
+ // Initialize all bits to 1 (valid), then clear bits for null rows
+ memset(null_bitmap->mutable_data(), 0xFF, null_bitmap->size());
+ for (int32_t k = 0; k < rows_to_read; ++k) {
+ if (IsTargetNull(current_pos_ + k)) {
+ arrow::bit_util::ClearBit(null_bitmap->mutable_data(), k);
+ }
+ }
+ return null_bitmap;
+}
+
+Result<std::shared_ptr<arrow::Array>> BlobFileBatchReader::BuildContentArray(
+ int32_t rows_to_read) const {
+ PAIMON_ASSIGN_OR_RAISE(std::shared_ptr<arrow::Buffer> value_offsets,
+ NextBlobOffsets(rows_to_read));
+ PAIMON_ASSIGN_OR_RAISE(std::shared_ptr<arrow::Buffer> data,
NextBlobContents(rows_to_read));
+ PAIMON_ASSIGN_OR_RAISE(std::shared_ptr<arrow::Buffer> child_null_bitmap,
+ BuildNullBitmap(rows_to_read));
+
+ auto large_binary_array =
std::make_shared<arrow::LargeBinaryArray>(rows_to_read, value_offsets,
+ data,
child_null_bitmap);
+ std::vector<std::shared_ptr<arrow::ArrayData>> child_data;
+ child_data.emplace_back(large_binary_array->data());
+ std::shared_ptr<arrow::ArrayData> struct_array_data =
+ arrow::ArrayData::Make(target_type_, large_binary_array->length(),
{nullptr}, child_data);
+ return std::make_shared<arrow::StructArray>(struct_array_data);
+}
+
+Result<std::shared_ptr<arrow::Array>> BlobFileBatchReader::BuildTargetArray(
+ int32_t rows_to_read) const {
+ std::shared_ptr<arrow::Array> blob_array;
+ if (!blob_as_descriptor_) {
+ return BuildContentArray(rows_to_read);
+ }
+ // For descriptor mode, build using StructBuilder to handle nulls properly
+ PAIMON_ASSIGN_OR_RAISE_FROM_ARROW(std::unique_ptr<arrow::ArrayBuilder>
array_builder,
+ arrow::MakeBuilder(target_type_,
arrow_pool_.get()));
+ auto builder = dynamic_cast<arrow::StructBuilder*>(array_builder.get());
+ if (builder == nullptr) {
+ return Status::Invalid("cast to struct builder failed");
+ }
+ auto field_builder =
dynamic_cast<arrow::LargeBinaryBuilder*>(builder->field_builder(0));
+ if (field_builder == nullptr) {
+ return Status::Invalid("cast to large binary builder failed");
+ }
+ for (int32_t k = 0; k < rows_to_read; ++k) {
+ const size_t i = current_pos_ + k;
+ PAIMON_RETURN_NOT_OK_FROM_ARROW(builder->Append());
+ if (IsTargetNull(i)) {
+ PAIMON_RETURN_NOT_OK_FROM_ARROW(field_builder->AppendNull());
+ } else {
+ int64_t offset = GetTargetContentOffset(i);
+ int64_t length = GetTargetContentLength(i);
+ PAIMON_ASSIGN_OR_RAISE(std::unique_ptr<Blob> blob,
+ Blob::FromPath(file_path_, offset, length));
+ auto descriptor = blob->ToDescriptor(pool_);
+ PAIMON_RETURN_NOT_OK_FROM_ARROW(
+ field_builder->Append(descriptor->data(), descriptor->size()));
+ }
+ }
+ std::shared_ptr<arrow::Array> array;
+ PAIMON_RETURN_NOT_OK_FROM_ARROW(builder->Finish(&array));
+ return array;
+}
+
+Result<BatchReader::ReadBatch> BlobFileBatchReader::NextBatch() {
+ if (closed_) {
+ return Status::Invalid("blob file batch reader is closed");
+ }
+ if (target_type_ == nullptr) {
+ return Status::Invalid("target type is nullptr, call SetReadSchema
first");
+ }
+ if (current_pos_ >= target_blob_lengths_.size()) {
+ PAIMON_ASSIGN_OR_RAISE(previous_batch_first_row_number_,
GetNumberOfRows());
+ return BatchReader::MakeEofBatch();
+ }
+ int32_t left_rows = target_blob_lengths_.size() - current_pos_;
+ int32_t rows_to_read = std::min(left_rows, batch_size_);
+ PAIMON_ASSIGN_OR_RAISE(std::shared_ptr<arrow::Array> blob_array,
+ BuildTargetArray(rows_to_read));
+ std::unique_ptr<ArrowArray> c_array = std::make_unique<ArrowArray>();
+ std::unique_ptr<ArrowSchema> c_schema = std::make_unique<ArrowSchema>();
+ PAIMON_RETURN_NOT_OK_FROM_ARROW(arrow::ExportArray(*blob_array,
c_array.get(), c_schema.get()));
+ previous_batch_first_row_number_ = target_blob_row_indexes_[current_pos_];
+ current_pos_ += rows_to_read;
+ return make_pair(std::move(c_array), std::move(c_schema));
+}
+
+Status BlobFileBatchReader::ReadBlobContentAt(const int64_t offset, const
int64_t length,
+ uint8_t* content) const {
+ PAIMON_ASSIGN_OR_RAISE(std::unique_ptr<OffsetInputStream>
offset_input_stream,
+ OffsetInputStream::Create(input_stream_, length,
offset));
+ return StreamUtils::ReadAsyncFully(std::move(offset_input_stream),
+ reinterpret_cast<char*>(content));
+}
+
+int32_t BlobFileBatchReader::GetIndexLength(const int8_t* bytes, int32_t
offset) {
+ return static_cast<int32_t>(
+ (static_cast<uint32_t>(static_cast<uint8_t>(bytes[offset + 3])) << 24)
|
+ (static_cast<uint32_t>(static_cast<uint8_t>(bytes[offset + 2])) << 16)
|
+ (static_cast<uint32_t>(static_cast<uint8_t>(bytes[offset + 1])) << 8) |
+ static_cast<uint32_t>(static_cast<uint8_t>(bytes[offset])));
+}
+
+// Note: blob file has no self-describing schema, use read schema instead.
+Result<std::unique_ptr<::ArrowSchema>> BlobFileBatchReader::GetFileSchema()
const {
+ return Status::NotImplemented("blob file has no self-describing file
schema");
+}
+
+} // namespace paimon::blob
diff --git a/src/paimon/format/blob/blob_file_batch_reader.h
b/src/paimon/format/blob/blob_file_batch_reader.h
new file mode 100644
index 0000000..159d055
--- /dev/null
+++ b/src/paimon/format/blob/blob_file_batch_reader.h
@@ -0,0 +1,182 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#pragma once
+
+#include <limits>
+#include <memory>
+#include <string>
+#include <utility>
+#include <vector>
+
+#include "arrow/memory_pool.h"
+#include "arrow/type.h"
+#include "paimon/common/data/blob_defs.h"
+#include "paimon/fs/file_system.h"
+#include "paimon/memory/bytes.h"
+#include "paimon/predicate/predicate.h"
+#include "paimon/reader/batch_reader.h"
+#include "paimon/reader/file_batch_reader.h"
+#include "paimon/result.h"
+#include "paimon/utils/roaring_bitmap32.h"
+
+namespace paimon::blob {
+
+/// Binary Blob File Layout Specification
+///
+/// This file format is designed for the efficient storage of a sequence of
'bins' (data
+/// blocks/records) with associated metadata. The structure consists of one or
more data bins
+/// (bin_0, bin_1, ...), followed by an Index section and a Footer.
+///
+/// Endianness:
+/// - All multi-byte fields (magic number, bin length, crc32, index len,
index) use little-endian
+/// byte order.
+///
+/// ====================================================================
+/// 1. Data Bins Section
+/// ====================================================================
+/// The file consists of one or more contiguous 'bins' (bin_0, bin_1, bin_2,
...).
+/// The structure of each bin is as follows:
+///
+/// | Field Name | Length (bytes) | Description
|
+///
|-------------------|----------------|---------------------------------------------------------|
+/// | magic number | 4 | A fixed number identifying the
start of the block. |
+/// | blob content | bin len - 16 | The actual data payload.
|
+/// | bin length | 8 | The total length of the entire bin
(including metadata).|
+/// | bin CRC32 | 4 | The 32-bit Cyclic Redundancy
Checksum for the bin. |
+///
+/// Note:
+/// - Current magic number is 1481511375.
+///
+/// ====================================================================
+/// 2. Index Section
+/// ====================================================================
+/// The Index is located after all data bins and is used for quick lookup and
management.
+///
+/// Purpose: Records the lengths (record lens) of all data bins.
+///
+/// Encoding:
+/// - Uses Delta Encoding to store differences between successive length
values.
+/// - Uses Varints (Variable-length Integers) to store long values efficiently.
+///
+/// ====================================================================
+/// 3. File Footer
+/// ====================================================================
+/// Metadata located at the very end of the file, describing the index and
file version.
+///
+/// | Field Name | Length (bytes) | Description
|
+///
|---------------|----------------|---------------------------------------------------|
+/// | Index Len | 4 | The byte length of the preceding Index
section. |
+/// | version | 1 | The file format version number.
|
+///
+/// Note:
+/// - Current version is 1.
+class BlobFileBatchReader : public FileBatchReader {
+ public:
+ static Result<std::unique_ptr<BlobFileBatchReader>> Create(
+ const std::shared_ptr<InputStream>& input_stream, int32_t batch_size,
+ bool blob_as_descriptor, const std::shared_ptr<MemoryPool>& pool);
+
+ Result<std::unique_ptr<::ArrowSchema>> GetFileSchema() const override;
+
+ Status SetReadSchema(::ArrowSchema* read_schema, const
std::shared_ptr<Predicate>& predicate,
+ const std::optional<RoaringBitmap32>&
selection_bitmap) override;
+
+ Result<ReadBatch> NextBatch() override;
+
+ Result<uint64_t> GetPreviousBatchFirstRowNumber() const override {
+ if (all_blob_lengths_.size() != target_blob_lengths_.size()) {
+ return Status::Invalid(
+ "Cannot call GetPreviousBatchFirstRowNumber in
BlobFileBatchReader because, after "
+ "bitmap pushdown, rows in the array returned by NextBatch are
no longer "
+ "contiguous.");
+ }
+ return previous_batch_first_row_number_;
+ }
+
+ Result<uint64_t> GetNumberOfRows() const override {
+ return all_blob_lengths_.size();
+ }
+
+ std::shared_ptr<Metrics> GetReaderMetrics() const override {
+ return metrics_;
+ }
+
+ void Close() override {
+ closed_ = true;
+ }
+
+ bool SupportPreciseBitmapSelection() const override {
+ return true;
+ }
+
+ private:
+ static constexpr uint64_t kDefaultReadChunkSize = 1024 * 1024;
+
+ static int32_t GetIndexLength(const int8_t* bytes, int32_t offset);
+
+ BlobFileBatchReader(const std::shared_ptr<InputStream>& input_stream,
+ const std::string& file_path, const
std::vector<int64_t>& blob_lengths,
+ const std::vector<int64_t>& blob_offsets, int32_t
batch_size,
+ bool blob_as_descriptor, const
std::shared_ptr<MemoryPool>& pool);
+
+ Status ReadBlobContentAt(const int64_t offset, const int64_t length,
uint8_t* content) const;
+
+ Result<std::shared_ptr<arrow::Buffer>> NextBlobOffsets(int32_t
rows_to_read) const;
+ Result<std::shared_ptr<arrow::Buffer>> NextBlobContents(int32_t
rows_to_read) const;
+ /// Builds a null bitmap buffer for the given rows. Returns nullptr if no
nulls.
+ Result<std::shared_ptr<arrow::Buffer>> BuildNullBitmap(int32_t
rows_to_read) const;
+ Result<std::shared_ptr<arrow::Array>> BuildContentArray(int32_t
rows_to_read) const;
+ Result<std::shared_ptr<arrow::Array>> BuildTargetArray(int32_t
rows_to_read) const;
+
+ /// Returns true if the blob at the given index is null (bin_length ==
kNullBinLength).
+ bool IsTargetNull(size_t index) const {
+ return target_blob_lengths_[index] == BlobDefs::kNullBinLength;
+ }
+
+ int64_t GetTargetContentOffset(size_t index) const {
+ return target_blob_offsets_[index] + BlobDefs::kContentStartOffset;
+ }
+
+ int64_t GetTargetContentLength(size_t index) const {
+ return target_blob_lengths_[index] - BlobDefs::kTotalMetaLength;
+ }
+
+ std::shared_ptr<InputStream> input_stream_;
+ const std::string file_path_;
+ const std::vector<int64_t> all_blob_lengths_;
+ const std::vector<int64_t> all_blob_offsets_;
+
+ std::vector<int64_t> target_blob_lengths_;
+ std::vector<int64_t> target_blob_offsets_;
+ std::vector<uint64_t> target_blob_row_indexes_;
+
+ const int32_t batch_size_;
+ const bool blob_as_descriptor_;
+ std::shared_ptr<MemoryPool> pool_;
+ std::shared_ptr<arrow::MemoryPool> arrow_pool_;
+
+ std::shared_ptr<arrow::DataType> target_type_;
+ std::shared_ptr<Metrics> metrics_;
+
+ size_t current_pos_ = 0;
+ uint64_t previous_batch_first_row_number_ =
std::numeric_limits<uint64_t>::max();
+ bool closed_ = false;
+};
+
+} // namespace paimon::blob
diff --git a/src/paimon/format/blob/blob_file_batch_reader_test.cpp
b/src/paimon/format/blob/blob_file_batch_reader_test.cpp
new file mode 100644
index 0000000..3fc9051
--- /dev/null
+++ b/src/paimon/format/blob/blob_file_batch_reader_test.cpp
@@ -0,0 +1,370 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#include "paimon/format/blob/blob_file_batch_reader.h"
+
+#include "arrow/api.h"
+#include "arrow/c/helpers.h"
+#include "gtest/gtest.h"
+#include "paimon/common/data/blob_utils.h"
+#include "paimon/data/blob.h"
+#include "paimon/format/blob/blob_format_writer.h"
+#include "paimon/fs/local/local_file_system.h"
+#include "paimon/memory/memory_pool.h"
+#include "paimon/testing/utils/read_result_collector.h"
+#include "paimon/testing/utils/testharness.h"
+
+namespace paimon::blob::test {
+
+class BlobFileBatchReaderTest : public testing::Test, public
::testing::WithParamInterface<bool> {
+ public:
+ void SetUp() override {
+ pool_ = GetDefaultPool();
+ }
+
+ void CheckResult(const std::string& table_path, const std::string&
paimon_blob_file,
+ const std::vector<std::string>& original_blob_files, bool
blob_as_descriptor,
+ const std::optional<RoaringBitmap32>& selection_bitmap =
std::nullopt) {
+ auto schema = arrow::schema({BlobUtils::ToArrowField(blob_field_name_,
false)});
+ ::ArrowSchema c_schema;
+ ASSERT_TRUE(arrow::ExportSchema(*schema, &c_schema).ok());
+ std::shared_ptr<FileSystem> fs = std::make_shared<LocalFileSystem>();
+ ASSERT_OK_AND_ASSIGN(std::shared_ptr<InputStream> input_stream,
+ fs->Open(table_path + "/bucket-0/" +
paimon_blob_file));
+ ASSERT_OK_AND_ASSIGN(auto reader,
+ BlobFileBatchReader::Create(input_stream,
/*batch_size=*/1024,
+ blob_as_descriptor,
pool_));
+ ASSERT_OK(reader->SetReadSchema(&c_schema, nullptr, selection_bitmap));
+ ASSERT_OK_AND_ASSIGN(auto chunked_array,
+
paimon::test::ReadResultCollector::CollectResult(reader.get()));
+ if (chunked_array == nullptr) {
+ ASSERT_EQ(0, original_blob_files.size());
+ return;
+ }
+
+ std::shared_ptr<arrow::Array> combined_array =
+ arrow::Concatenate(chunked_array->chunks()).ValueOrDie();
+ if (original_blob_files.size() == 0) {
+ ASSERT_EQ(0, combined_array->length());
+ return;
+ }
+ auto struct_array =
std::dynamic_pointer_cast<arrow::StructArray>(combined_array);
+ ASSERT_TRUE(struct_array);
+ auto blob_array =
+
std::dynamic_pointer_cast<arrow::LargeBinaryArray>(struct_array->field(0));
+ ASSERT_EQ(blob_array->length(), original_blob_files.size());
+ for (size_t i = 0; i < original_blob_files.size(); i++) {
+ ASSERT_OK_AND_ASSIGN(auto origin_input_stream,
+ fs->Open(table_path + "/" +
original_blob_files[i]));
+ ASSERT_OK_AND_ASSIGN(auto origin_length,
origin_input_stream->Length());
+ auto origin_bytes = Bytes::AllocateBytes(origin_length,
pool_.get());
+ ASSERT_OK_AND_ASSIGN(auto actual_read_length,
+
origin_input_stream->Read(origin_bytes->data(), origin_length));
+ ASSERT_EQ(actual_read_length, origin_length);
+ if (blob_as_descriptor) {
+ auto blob_descriptor = blob_array->GetString(i);
+ ASSERT_OK_AND_ASSIGN(auto blob,
Blob::FromDescriptor(blob_descriptor.data(),
+
blob_descriptor.size()));
+ ASSERT_OK_AND_ASSIGN(auto input_stream,
blob->NewInputStream(fs));
+ ASSERT_OK_AND_ASSIGN(auto pos, input_stream->GetPos());
+ ASSERT_EQ(pos, 0);
+ ASSERT_OK_AND_ASSIGN(auto length, input_stream->Length());
+ auto bytes = Bytes::AllocateBytes(length, pool_.get());
+ ASSERT_OK_AND_ASSIGN(auto actual_read_length,
+ input_stream->Read(bytes->data(),
length));
+ ASSERT_EQ(actual_read_length, length);
+ ASSERT_EQ(length, origin_length);
+ ASSERT_EQ(*bytes, *origin_bytes);
+ } else {
+ auto blob_data = blob_array->GetString(i);
+ ASSERT_EQ(blob_data.size(), origin_length);
+ std::string origin_data(origin_bytes->data(), origin_length);
+ ASSERT_EQ(blob_data, origin_data);
+ }
+ }
+ }
+
+ private:
+ std::string blob_field_name_;
+ std::shared_ptr<MemoryPool> pool_;
+};
+
+TEST_P(BlobFileBatchReaderTest, TestSimple) {
+ std::string test_data_path = paimon::test::GetDataDir() +
"/db_with_blob.db/table_with_blob/";
+ auto dir = paimon::test::UniqueTestDirectory::Create();
+ std::string table_path = dir->Str();
+ bool blob_as_descriptor = GetParam();
+ ASSERT_TRUE(paimon::test::TestUtil::CopyDirectory(test_data_path,
table_path));
+ CheckResult(table_path, "data-d7816e8e-6c6d-4e28-9137-837cdf706350-1.blob",
+ {"blob_0_811d5dab.bin", "blob_1_b81cf9f4.bin",
"blob_2_470e1dfe.bin"},
+ blob_as_descriptor);
+ CheckResult(table_path, "data-d7816e8e-6c6d-4e28-9137-837cdf706350-2.blob",
+ {"blob_3_07b08c4d.bin", "blob_4_67007c96.bin"},
blob_as_descriptor);
+ CheckResult(table_path, "data-d7816e8e-6c6d-4e28-9137-837cdf706350-3.blob",
+ {"blob_5_f7099dea.bin", "blob_6_6b6706ef.bin",
"blob_7_6bcae65e.bin",
+ "blob_8_5fba0737.bin"},
+ blob_as_descriptor);
+ CheckResult(table_path, "data-d7816e8e-6c6d-4e28-9137-837cdf706350-4.blob",
+ {"blob_9_f54d253c.bin"}, blob_as_descriptor);
+}
+
+TEST_P(BlobFileBatchReaderTest, TestPushdownBitmap) {
+ std::string test_data_path = paimon::test::GetDataDir() +
"/db_with_blob.db/table_with_blob/";
+ auto dir = paimon::test::UniqueTestDirectory::Create();
+ std::string table_path = dir->Str();
+ bool blob_as_descriptor = GetParam();
+ ASSERT_TRUE(paimon::test::TestUtil::CopyDirectory(test_data_path,
table_path));
+ RoaringBitmap32 roaring_0;
+ roaring_0.Add(0);
+ CheckResult(table_path, "data-d7816e8e-6c6d-4e28-9137-837cdf706350-1.blob",
+ {"blob_0_811d5dab.bin"}, blob_as_descriptor, roaring_0);
+ RoaringBitmap32 roaring_1;
+ roaring_1.Add(1);
+
+ CheckResult(table_path, "data-d7816e8e-6c6d-4e28-9137-837cdf706350-2.blob",
+ {"blob_4_67007c96.bin"}, blob_as_descriptor, roaring_1);
+ RoaringBitmap32 roaring_2;
+ roaring_2.Add(0);
+ roaring_2.Add(1);
+ roaring_2.Add(3);
+ CheckResult(table_path, "data-d7816e8e-6c6d-4e28-9137-837cdf706350-3.blob",
+ {"blob_5_f7099dea.bin", "blob_6_6b6706ef.bin",
"blob_8_5fba0737.bin"},
+ blob_as_descriptor, roaring_2);
+ RoaringBitmap32 roaring_3;
+ CheckResult(table_path,
"data-d7816e8e-6c6d-4e28-9137-837cdf706350-4.blob", {},
+ blob_as_descriptor, roaring_3);
+}
+
+TEST_F(BlobFileBatchReaderTest, TestRowNumbers) {
+ auto schema = arrow::schema({BlobUtils::ToArrowField("my_blob_field",
false)});
+ ::ArrowSchema c_schema;
+ ASSERT_TRUE(arrow::ExportSchema(*schema, &c_schema).ok());
+
+ std::string test_data_path = paimon::test::GetDataDir() +
"/db_with_blob.db/table_with_blob/";
+ auto dir = paimon::test::UniqueTestDirectory::Create();
+ std::string table_path = dir->Str();
+ ASSERT_TRUE(paimon::test::TestUtil::CopyDirectory(test_data_path,
table_path));
+
+ std::shared_ptr<FileSystem> fs = std::make_shared<LocalFileSystem>();
+ ASSERT_OK_AND_ASSIGN(
+ std::shared_ptr<InputStream> input_stream,
+ fs->Open(table_path +
"/bucket-0/data-d7816e8e-6c6d-4e28-9137-837cdf706350-1.blob"));
+ ASSERT_OK_AND_ASSIGN(auto reader, BlobFileBatchReader::Create(
+ input_stream,
+ /*batch_size=*/1,
/*blob_as_descriptor=*/true, pool_));
+
+ ASSERT_OK(reader->SetReadSchema(&c_schema, nullptr, std::nullopt));
+ ASSERT_OK_AND_ASSIGN(auto number_of_rows, reader->GetNumberOfRows());
+ ASSERT_EQ(3, number_of_rows);
+ ASSERT_EQ(std::numeric_limits<uint64_t>::max(),
+ reader->GetPreviousBatchFirstRowNumber().value());
+ ASSERT_OK_AND_ASSIGN(auto batch1, reader->NextBatch());
+ ArrowArrayRelease(batch1.first.get());
+ ArrowSchemaRelease(batch1.second.get());
+ ASSERT_EQ(0, reader->GetPreviousBatchFirstRowNumber().value());
+ ASSERT_OK_AND_ASSIGN(auto batch2, reader->NextBatch());
+ ASSERT_EQ(1, reader->GetPreviousBatchFirstRowNumber().value());
+ ArrowArrayRelease(batch2.first.get());
+ ArrowSchemaRelease(batch2.second.get());
+ ASSERT_OK_AND_ASSIGN(auto batch3, reader->NextBatch());
+ ASSERT_EQ(2, reader->GetPreviousBatchFirstRowNumber().value());
+ ArrowArrayRelease(batch3.first.get());
+ ArrowSchemaRelease(batch3.second.get());
+ ASSERT_OK_AND_ASSIGN(auto batch4, reader->NextBatch());
+ ASSERT_EQ(3, reader->GetPreviousBatchFirstRowNumber().value());
+ ASSERT_TRUE(BatchReader::IsEofBatch(batch4));
+}
+
+TEST_F(BlobFileBatchReaderTest, InvalidScenario) {
+ auto dir = paimon::test::UniqueTestDirectory::Create();
+ ASSERT_TRUE(dir);
+ auto file_system = std::make_shared<LocalFileSystem>();
+ std::string test_data_path = paimon::test::GetDataDir() +
"/db_with_blob.db/table_with_blob/";
+ std::string table_path = dir->Str();
+ ASSERT_TRUE(paimon::test::TestUtil::CopyDirectory(test_data_path,
table_path));
+
+ std::shared_ptr<FileSystem> fs = std::make_shared<LocalFileSystem>();
+ ASSERT_OK_AND_ASSIGN(
+ std::shared_ptr<InputStream> input_stream,
+ fs->Open(table_path +
"/bucket-0/data-d7816e8e-6c6d-4e28-9137-837cdf706350-1.blob"));
+ {
+ ASSERT_NOK_WITH_MSG(
+ BlobFileBatchReader::Create(input_stream,
+ /*batch_size=*/0,
/*blob_as_descriptor=*/true, pool_),
+ "blob file batch reader create failed: read batch size '0' should
be larger than zero");
+ }
+ {
+ ASSERT_NOK_WITH_MSG(
+ BlobFileBatchReader::Create(/*input_stream=*/nullptr,
+ /*batch_size=*/1,
/*blob_as_descriptor=*/true, pool_),
+ "blob file batch reader create failed: input stream is nullptr");
+ }
+ {
+ ASSERT_OK_AND_ASSIGN(
+ auto reader,
+ BlobFileBatchReader::Create(/*input_stream=*/input_stream,
+ /*batch_size=*/1,
/*blob_as_descriptor=*/true, pool_));
+ ASSERT_NOK_WITH_MSG(reader->GetFileSchema(),
+ "blob file has no self-describing file schema");
+ ASSERT_TRUE(reader->GetReaderMetrics());
+ ASSERT_NOK_WITH_MSG(reader->NextBatch(),
+ "target type is nullptr, call SetReadSchema
first");
+ reader->Close();
+ ASSERT_NOK_WITH_MSG(reader->NextBatch(), "blob file batch reader is
closed");
+ }
+}
+
+TEST_P(BlobFileBatchReaderTest, EmptyFile) {
+ auto dir = paimon::test::UniqueTestDirectory::Create();
+ ASSERT_TRUE(dir);
+ auto file_system = std::make_shared<LocalFileSystem>();
+ ASSERT_OK_AND_ASSIGN(std::shared_ptr<OutputStream> output_stream,
+ file_system->Create(dir->Str() + "/file.blob",
/*overwrite=*/true));
+ std::shared_ptr<arrow::Field> blob_field =
BlobUtils::ToArrowField("blob_col");
+ auto struct_type = arrow::struct_({blob_field});
+ bool blob_as_descriptor = GetParam();
+ ASSERT_OK_AND_ASSIGN(std::shared_ptr<BlobFormatWriter> writer,
+ BlobFormatWriter::Create(blob_as_descriptor,
output_stream, struct_type,
+ file_system, pool_));
+
+ ASSERT_OK(writer->Flush());
+ ASSERT_OK(writer->Finish());
+ ASSERT_OK(output_stream->Flush());
+ auto schema = arrow::schema({blob_field});
+ ::ArrowSchema c_schema;
+ ASSERT_TRUE(arrow::ExportSchema(*schema, &c_schema).ok());
+
+ ASSERT_OK_AND_ASSIGN(std::shared_ptr<InputStream> input_stream,
+ file_system->Open(dir->Str() + "/file.blob"));
+ ASSERT_OK_AND_ASSIGN(auto reader, BlobFileBatchReader::Create(
+ input_stream,
+ /*batch_size=*/1,
/*blob_as_descriptor=*/true, pool_));
+
+ ASSERT_OK(reader->SetReadSchema(&c_schema, nullptr, std::nullopt));
+ ASSERT_OK_AND_ASSIGN(auto number_of_rows, reader->GetNumberOfRows());
+ ASSERT_EQ(0, number_of_rows);
+ ASSERT_EQ(std::numeric_limits<uint64_t>::max(),
+ reader->GetPreviousBatchFirstRowNumber().value());
+ ASSERT_OK_AND_ASSIGN(auto batch, reader->NextBatch());
+ ASSERT_TRUE(BatchReader::IsEofBatch(batch));
+}
+
+TEST_F(BlobFileBatchReaderTest, SetReadSchemaWithInvalidInputs) {
+ {
+ std::string test_data_path =
+ paimon::test::GetDataDir() + "/db_with_blob.db/table_with_blob/";
+ auto dir = paimon::test::UniqueTestDirectory::Create();
+ std::string table_path = dir->Str();
+ ASSERT_TRUE(paimon::test::TestUtil::CopyDirectory(test_data_path,
table_path));
+
+ std::shared_ptr<FileSystem> fs = std::make_shared<LocalFileSystem>();
+ ASSERT_OK_AND_ASSIGN(
+ std::shared_ptr<InputStream> input_stream,
+ fs->Open(table_path +
"/bucket-0/data-d7816e8e-6c6d-4e28-9137-837cdf706350-1.blob"));
+ ASSERT_OK_AND_ASSIGN(
+ auto reader,
+ BlobFileBatchReader::Create(input_stream,
+ /*batch_size=*/1,
/*blob_as_descriptor=*/true, pool_));
+ ASSERT_NOK_WITH_MSG(reader->SetReadSchema(/*read_schema=*/nullptr,
/*predicate=*/nullptr,
+
/*selection_bitmap=*/std::nullopt),
+ "SetReadSchema failed: read schema cannot be
nullptr");
+ }
+ {
+ auto schema = arrow::schema({BlobUtils::ToArrowField("my_blob_field",
false),
+
BlobUtils::ToArrowField("my_blob_field_2", false)});
+
+ ::ArrowSchema c_schema;
+ ASSERT_TRUE(arrow::ExportSchema(*schema, &c_schema).ok());
+
+ std::string test_data_path =
+ paimon::test::GetDataDir() + "/db_with_blob.db/table_with_blob/";
+ auto dir = paimon::test::UniqueTestDirectory::Create();
+ std::string table_path = dir->Str();
+ ASSERT_TRUE(paimon::test::TestUtil::CopyDirectory(test_data_path,
table_path));
+
+ std::shared_ptr<FileSystem> fs = std::make_shared<LocalFileSystem>();
+ ASSERT_OK_AND_ASSIGN(
+ std::shared_ptr<InputStream> input_stream,
+ fs->Open(table_path +
"/bucket-0/data-d7816e8e-6c6d-4e28-9137-837cdf706350-1.blob"));
+ ASSERT_OK_AND_ASSIGN(
+ auto reader,
+ BlobFileBatchReader::Create(input_stream,
+ /*batch_size=*/1,
/*blob_as_descriptor=*/true, pool_));
+ ASSERT_NOK_WITH_MSG(reader->SetReadSchema(&c_schema,
/*predicate=*/nullptr,
+
/*selection_bitmap=*/std::nullopt),
+ "read schema field number 2 is not 1");
+ }
+ {
+ auto blob_field = arrow::field("my_blob_field", arrow::large_binary());
+
+ auto schema = arrow::schema({blob_field});
+ ::ArrowSchema c_schema;
+ ASSERT_TRUE(arrow::ExportSchema(*schema, &c_schema).ok());
+
+ std::string test_data_path =
+ paimon::test::GetDataDir() + "/db_with_blob.db/table_with_blob/";
+ auto dir = paimon::test::UniqueTestDirectory::Create();
+ std::string table_path = dir->Str();
+ ASSERT_TRUE(paimon::test::TestUtil::CopyDirectory(test_data_path,
table_path));
+
+ std::shared_ptr<FileSystem> fs = std::make_shared<LocalFileSystem>();
+ ASSERT_OK_AND_ASSIGN(
+ std::shared_ptr<InputStream> input_stream,
+ fs->Open(table_path +
"/bucket-0/data-d7816e8e-6c6d-4e28-9137-837cdf706350-1.blob"));
+ ASSERT_OK_AND_ASSIGN(
+ auto reader,
+ BlobFileBatchReader::Create(input_stream,
+ /*batch_size=*/1,
/*blob_as_descriptor=*/true, pool_));
+ ASSERT_NOK_WITH_MSG(reader->SetReadSchema(&c_schema,
/*predicate=*/nullptr,
+
/*selection_bitmap=*/std::nullopt),
+ "field my_blob_field: large_binary is not BLOB");
+ }
+ {
+ auto schema = arrow::schema({BlobUtils::ToArrowField("my_blob_field",
false)});
+ ::ArrowSchema c_schema;
+ ASSERT_TRUE(arrow::ExportSchema(*schema, &c_schema).ok());
+
+ std::string test_data_path =
+ paimon::test::GetDataDir() + "/db_with_blob.db/table_with_blob/";
+ auto dir = paimon::test::UniqueTestDirectory::Create();
+ std::string table_path = dir->Str();
+ ASSERT_TRUE(paimon::test::TestUtil::CopyDirectory(test_data_path,
table_path));
+
+ std::shared_ptr<FileSystem> fs = std::make_shared<LocalFileSystem>();
+ ASSERT_OK_AND_ASSIGN(
+ std::shared_ptr<InputStream> input_stream,
+ fs->Open(table_path +
"/bucket-0/data-d7816e8e-6c6d-4e28-9137-837cdf706350-1.blob"));
+ ASSERT_OK_AND_ASSIGN(
+ auto reader,
+ BlobFileBatchReader::Create(input_stream,
+ /*batch_size=*/1,
/*blob_as_descriptor=*/true, pool_));
+ RoaringBitmap32 roaring;
+ roaring.Add(0);
+ roaring.Add(1);
+ roaring.Add(2);
+ roaring.Add(3);
+ roaring.Add(4);
+ ASSERT_NOK_WITH_MSG(
+ reader->SetReadSchema(&c_schema, /*predicate=*/nullptr,
/*selection_bitmap=*/roaring),
+ "Invalid: row index 3 is out of bound of total row number 3");
+ }
+}
+
+INSTANTIATE_TEST_SUITE_P(BlobAsDescriptor, BlobFileBatchReaderTest,
::testing::Values(true, false));
+
+} // namespace paimon::blob::test
diff --git a/src/paimon/format/blob/blob_file_format.h
b/src/paimon/format/blob/blob_file_format.h
new file mode 100644
index 0000000..486cd04
--- /dev/null
+++ b/src/paimon/format/blob/blob_file_format.h
@@ -0,0 +1,76 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#pragma once
+
+#include <map>
+#include <memory>
+#include <string>
+
+#include "arrow/c/bridge.h"
+#include "paimon/common/utils/arrow/status_utils.h"
+#include "paimon/format/blob/blob_reader_builder.h"
+#include "paimon/format/blob/blob_stats_extractor.h"
+#include "paimon/format/blob/blob_writer_builder.h"
+#include "paimon/format/file_format.h"
+
+struct ArrowSchema;
+
+namespace paimon {
+
+class WriterBuilder;
+class ReaderBuilder;
+class FormatStatsExtractor;
+
+namespace blob {
+
+class BlobFileFormat : public FileFormat {
+ public:
+ explicit BlobFileFormat(const std::map<std::string, std::string>& options)
+ : identifier_("blob"), options_(options) {}
+
+ const std::string& Identifier() const override {
+ return identifier_;
+ }
+
+ Result<std::unique_ptr<ReaderBuilder>> CreateReaderBuilder(int32_t
batch_size) const override {
+ return std::make_unique<BlobReaderBuilder>(batch_size, options_);
+ }
+
+ Result<std::unique_ptr<WriterBuilder>> CreateWriterBuilder(::ArrowSchema*
schema,
+ int32_t
batch_size) const override {
+ PAIMON_ASSIGN_OR_RAISE_FROM_ARROW(std::shared_ptr<arrow::Schema>
typed_schema,
+ arrow::ImportSchema(schema));
+ auto data_type = arrow::struct_(typed_schema->fields());
+ return std::make_unique<BlobWriterBuilder>(data_type, options_);
+ }
+
+ Result<std::unique_ptr<FormatStatsExtractor>> CreateStatsExtractor(
+ ::ArrowSchema* schema) const override {
+ PAIMON_ASSIGN_OR_RAISE_FROM_ARROW(std::shared_ptr<arrow::Schema>
typed_schema,
+ arrow::ImportSchema(schema));
+ return std::make_unique<BlobStatsExtractor>(typed_schema);
+ }
+
+ private:
+ std::string identifier_;
+ std::map<std::string, std::string> options_;
+};
+
+} // namespace blob
+} // namespace paimon
diff --git a/src/paimon/format/blob/blob_file_format_factory.cpp
b/src/paimon/format/blob/blob_file_format_factory.cpp
new file mode 100644
index 0000000..8d0f858
--- /dev/null
+++ b/src/paimon/format/blob/blob_file_format_factory.cpp
@@ -0,0 +1,37 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#include "paimon/format/blob/blob_file_format_factory.h"
+
+#include <utility>
+
+#include "paimon/factories/factory.h"
+#include "paimon/format/blob/blob_file_format.h"
+
+namespace paimon::blob {
+
+const char BlobFileFormatFactory::IDENTIFIER[] = "blob";
+
+Result<std::unique_ptr<FileFormat>> BlobFileFormatFactory::Create(
+ const std::map<std::string, std::string>& options) const {
+ return std::make_unique<BlobFileFormat>(options);
+}
+
+REGISTER_PAIMON_FACTORY(BlobFileFormatFactory);
+
+} // namespace paimon::blob
diff --git a/src/paimon/format/blob/blob_file_format_factory.h
b/src/paimon/format/blob/blob_file_format_factory.h
new file mode 100644
index 0000000..681be5c
--- /dev/null
+++ b/src/paimon/format/blob/blob_file_format_factory.h
@@ -0,0 +1,43 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#pragma once
+
+#include <map>
+#include <memory>
+#include <string>
+
+#include "paimon/format/file_format.h"
+#include "paimon/format/file_format_factory.h"
+#include "paimon/result.h"
+
+namespace paimon::blob {
+
+class BlobFileFormatFactory : public FileFormatFactory {
+ public:
+ static const char IDENTIFIER[];
+
+ const char* Identifier() const override {
+ return IDENTIFIER;
+ }
+
+ Result<std::unique_ptr<FileFormat>> Create(
+ const std::map<std::string, std::string>& options) const override;
+};
+
+} // namespace paimon::blob
diff --git a/src/paimon/format/blob/blob_file_format_factory_test.cpp
b/src/paimon/format/blob/blob_file_format_factory_test.cpp
new file mode 100644
index 0000000..0589898
--- /dev/null
+++ b/src/paimon/format/blob/blob_file_format_factory_test.cpp
@@ -0,0 +1,34 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#include "paimon/format/blob/blob_file_format_factory.h"
+
+#include "gtest/gtest.h"
+#include "paimon/status.h"
+#include "paimon/testing/utils/testharness.h"
+
+namespace paimon::blob::test {
+
+TEST(BlobFileFormatFactoryTest, TestIdentifier) {
+ BlobFileFormatFactory factory;
+ ASSERT_EQ(std::string(factory.Identifier()), "blob");
+ ASSERT_OK_AND_ASSIGN(auto file_format, factory.Create({}));
+ ASSERT_EQ(file_format->Identifier(), "blob");
+}
+
+} // namespace paimon::blob::test
diff --git a/src/paimon/format/blob/blob_format_writer.cpp
b/src/paimon/format/blob/blob_format_writer.cpp
new file mode 100644
index 0000000..d9e8f4d
--- /dev/null
+++ b/src/paimon/format/blob/blob_format_writer.cpp
@@ -0,0 +1,212 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#include "paimon/format/blob/blob_format_writer.h"
+
+#include <algorithm>
+
+#include "arrow/api.h"
+#include "arrow/c/bridge.h"
+#include "paimon/common/data/blob_defs.h"
+#include "paimon/common/data/blob_utils.h"
+#include "paimon/common/memory/memory_segment_utils.h"
+#include "paimon/common/metrics/metrics_impl.h"
+#include "paimon/common/utils/arrow/status_utils.h"
+#include "paimon/common/utils/delta_varint_compressor.h"
+#include "paimon/data/blob.h"
+#include "paimon/io/byte_array_input_stream.h"
+
+namespace paimon::blob {
+
+BlobFormatWriter::BlobFormatWriter(bool blob_as_descriptor,
+ const std::shared_ptr<OutputStream>& out,
+ const std::shared_ptr<arrow::DataType>&
data_type,
+ const std::shared_ptr<FileSystem>& fs,
+ const std::shared_ptr<MemoryPool>& pool)
+ : blob_as_descriptor_(blob_as_descriptor),
+ out_(out),
+ data_type_(data_type),
+ fs_(fs),
+ pool_(pool) {
+ metrics_ = std::make_shared<MetricsImpl>();
+ tmp_buffer_ = Bytes::AllocateBytes(kTmpBufferSize, pool_.get());
+}
+
+Result<std::unique_ptr<BlobFormatWriter>> BlobFormatWriter::Create(
+ bool blob_as_descriptor, const std::shared_ptr<OutputStream>& out,
+ const std::shared_ptr<arrow::DataType>& data_type, const
std::shared_ptr<FileSystem>& fs,
+ const std::shared_ptr<MemoryPool>& pool) {
+ if (out == nullptr) {
+ return Status::Invalid("blob format writer create failed. out is
nullptr");
+ }
+ if (data_type == nullptr) {
+ return Status::Invalid("blob format writer create failed. data_type is
nullptr");
+ }
+ if (pool == nullptr) {
+ return Status::Invalid("blob format writer create failed. pool is
nullptr");
+ }
+ if (data_type->num_fields() != 1) {
+ return Status::Invalid(
+ fmt::format("blob data type field number {} is not 1",
data_type->num_fields()));
+ }
+ if (!BlobUtils::IsBlobField(data_type->field(0))) {
+ return Status::Invalid(
+ fmt::format("field {} is not BLOB",
data_type->field(0)->ToString()));
+ }
+ return std::unique_ptr<BlobFormatWriter>(
+ new BlobFormatWriter(blob_as_descriptor, out, data_type, fs, pool));
+}
+
+Status BlobFormatWriter::AddBatch(ArrowArray* batch) {
+ if (batch == nullptr) {
+ return Status::Invalid("blob format writer add batch failed. batch is
nullptr");
+ }
+ if (batch->length != 1) {
+ return Status::Invalid("BlobFormatWriter only supports batch with a
row count of 1");
+ }
+ PAIMON_ASSIGN_OR_RAISE_FROM_ARROW(std::shared_ptr<arrow::Array>
arrow_array,
+ arrow::ImportArray(batch, data_type_));
+
+ assert(arrow_array->num_fields() == 1);
+ auto struct_array =
arrow::internal::checked_pointer_cast<arrow::StructArray>(arrow_array);
+ auto child_array = struct_array->field(0);
+
+ // Struct-level null is not supported (caller should not pass null struct
rows)
+ if (struct_array->IsNull(0)) {
+ return Status::Invalid("BlobFormatWriter does not support struct-level
null.");
+ }
+ // Child-level null: record kNullBinLength, skip data writing (aligned
with Java)
+ if (child_array->IsNull(0)) {
+ bin_lengths_.push_back(BlobDefs::kNullBinLength);
+ return Status::OK();
+ }
+
+ if (child_array->type_id() != arrow::Type::type::LARGE_BINARY) {
+ return Status::Invalid("BlobFormatWriter only support large binary
type.");
+ }
+
+ const auto& blob_array =
+ arrow::internal::checked_cast<const
arrow::LargeBinaryArray&>(*child_array);
+ assert(blob_array.length() == 1);
+ PAIMON_RETURN_NOT_OK(WriteBlob(blob_array.GetView(0)));
+
+ PAIMON_RETURN_NOT_OK(Flush());
+ return Status::OK();
+}
+
+Status BlobFormatWriter::Flush() {
+ return out_->Flush();
+}
+
+Status BlobFormatWriter::Finish() {
+ // index
+ const auto& index_bytes = DeltaVarintCompressor::Compress(bin_lengths_);
+ PAIMON_RETURN_NOT_OK(WriteBytes(index_bytes.data(), index_bytes.size()));
+ // header
+ PAIMON_UNIQUE_PTR<Bytes> index_length_bytes =
+
IntegerToLittleEndian<int32_t>(static_cast<int32_t>(index_bytes.size()), pool_);
+ PAIMON_RETURN_NOT_OK(WriteBytes(index_length_bytes->data(),
index_length_bytes->size()));
+ PAIMON_RETURN_NOT_OK(WriteBytes(reinterpret_cast<const
char*>(&BlobDefs::kFileVersion),
+ sizeof(BlobDefs::kFileVersion)));
+
+ PAIMON_RETURN_NOT_OK(Flush());
+
+ tmp_buffer_.reset();
+ return Status::OK();
+}
+
+Status BlobFormatWriter::WriteBlob(std::string_view blob_data) {
+ crc32_ = 0;
+ PAIMON_ASSIGN_OR_RAISE(int64_t previous_pos, out_->GetPos());
+
+ // write magic number
+ static PAIMON_UNIQUE_PTR<Bytes> kMagicNumberBytes =
+ IntegerToLittleEndian<int32_t>(BlobDefs::kMagicNumber, pool_);
+ PAIMON_RETURN_NOT_OK(WriteWithCrc32(kMagicNumberBytes->data(),
kMagicNumberBytes->size()));
+
+ // write blob content
+ std::unique_ptr<InputStream> in;
+ if (blob_as_descriptor_) {
+ PAIMON_ASSIGN_OR_RAISE(std::unique_ptr<Blob> blob,
+ Blob::FromDescriptor(blob_data.data(),
blob_data.size()));
+ PAIMON_ASSIGN_OR_RAISE(in, blob->NewInputStream(fs_));
+ } else {
+ in = std::make_unique<ByteArrayInputStream>(blob_data.data(),
blob_data.size());
+ }
+ PAIMON_ASSIGN_OR_RAISE(uint64_t file_length, in->Length());
+ uint64_t total_read_length = 0;
+ auto read_len = static_cast<uint32_t>(std::min<uint64_t>(file_length,
tmp_buffer_->size()));
+ while (read_len > 0) {
+ PAIMON_ASSIGN_OR_RAISE(int32_t actual_read_len,
in->Read(tmp_buffer_->data(), read_len));
+ if (static_cast<uint32_t>(actual_read_len) != read_len) {
+ return Status::Invalid("actual read length {}, not match with
expect length {}",
+ actual_read_len, read_len);
+ }
+ PAIMON_RETURN_NOT_OK(WriteWithCrc32(tmp_buffer_->data(),
actual_read_len));
+ total_read_length += actual_read_len;
+ read_len = static_cast<uint32_t>(
+ std::min<uint64_t>(file_length - total_read_length,
tmp_buffer_->size()));
+ }
+
+ // write bin length
+ PAIMON_ASSIGN_OR_RAISE(int64_t current_pos, out_->GetPos());
+ /// magic number(4) + blob content(bin length - 16) + bin length(8) +
crc32(4)
+ /// ↑ ↑
+ /// previous_pos current_pos
+ int64_t bin_length = current_pos - previous_pos + 8 + 4;
+ bin_lengths_.push_back(bin_length);
+ PAIMON_UNIQUE_PTR<Bytes> bin_length_bytes =
IntegerToLittleEndian<int64_t>(bin_length, pool_);
+ PAIMON_RETURN_NOT_OK(WriteWithCrc32(bin_length_bytes->data(),
bin_length_bytes->size()));
+
+ // write crc32
+ PAIMON_UNIQUE_PTR<Bytes> crc32_bytes =
IntegerToLittleEndian<int32_t>(crc32_, pool_);
+ PAIMON_RETURN_NOT_OK(WriteBytes(crc32_bytes->data(), crc32_bytes->size()));
+
+ return Status::OK();
+}
+
+Status BlobFormatWriter::WriteBytes(const char* data, int32_t length) {
+ PAIMON_ASSIGN_OR_RAISE(int32_t actual, out_->Write(data, length));
+ if (actual != length) {
+ return Status::Invalid("not suppose actual length {} not match with
expect {}", actual,
+ length);
+ }
+ return Status::OK();
+}
+
+Status BlobFormatWriter::WriteWithCrc32(const char* data, int32_t length) {
+ crc32_ = arrow::internal::crc32(crc32_, data, length);
+ return WriteBytes(data, length);
+}
+
+Result<bool> BlobFormatWriter::ReachTargetSize(bool suggested_check, int64_t
target_size) const {
+ PAIMON_ASSIGN_OR_RAISE(int64_t current_pos, out_->GetPos());
+ return current_pos >= target_size;
+}
+
+template <typename T>
+PAIMON_UNIQUE_PTR<Bytes> BlobFormatWriter::IntegerToLittleEndian(
+ T value, const std::shared_ptr<MemoryPool>& pool) {
+ static_assert(std::is_integral_v<T>, "IntegerToLittleEndian() only
supports integral types.");
+ MemorySegmentOutputStream
out(MemorySegmentOutputStream::DEFAULT_SEGMENT_SIZE, pool);
+ out.SetOrder(ByteOrder::PAIMON_LITTLE_ENDIAN);
+ out.WriteValue<T>(value);
+ return MemorySegmentUtils::CopyToBytes(out.Segments(), 0,
out.CurrentSize(), pool.get());
+}
+
+} // namespace paimon::blob
diff --git a/src/paimon/format/blob/blob_format_writer.h
b/src/paimon/format/blob/blob_format_writer.h
new file mode 100644
index 0000000..542fe26
--- /dev/null
+++ b/src/paimon/format/blob/blob_format_writer.h
@@ -0,0 +1,99 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#pragma once
+
+#include <cstdint>
+#include <memory>
+#include <string_view>
+#include <vector>
+
+#include "arrow/api.h"
+#include "arrow/util/crc32.h"
+#include "paimon/format/format_writer.h"
+#include "paimon/memory/bytes.h"
+#include "paimon/memory/memory_pool.h"
+#include "paimon/result.h"
+#include "paimon/status.h"
+
+namespace arrow {
+class DataType;
+} // namespace arrow
+struct ArrowArray;
+
+namespace paimon {
+class Blob;
+class FileSystem;
+class Metrics;
+class OutputStream;
+} // namespace paimon
+
+namespace paimon::blob {
+
+// Blob format:
+//
https://cwiki.apache.org/confluence/display/PAIMON/PIP-35%3A+Introduce+Blob+to+store+multimodal+data
+class BlobFormatWriter : public FormatWriter {
+ public:
+ static Result<std::unique_ptr<BlobFormatWriter>> Create(
+ bool blob_as_descriptor, const std::shared_ptr<OutputStream>& out,
+ const std::shared_ptr<arrow::DataType>& data_type, const
std::shared_ptr<FileSystem>& fs,
+ const std::shared_ptr<MemoryPool>& pool);
+
+ Status AddBatch(ArrowArray* batch) override;
+
+ Status Flush() override;
+
+ Status Finish() override;
+
+ Result<bool> ReachTargetSize(bool suggested_check, int64_t target_size)
const override;
+
+ std::shared_ptr<Metrics> GetWriterMetrics() const override {
+ return metrics_;
+ }
+
+ private:
+ BlobFormatWriter(bool blob_as_descriptor, const
std::shared_ptr<OutputStream>& out,
+ const std::shared_ptr<arrow::DataType>& data_type,
+ const std::shared_ptr<FileSystem>& fs,
+ const std::shared_ptr<MemoryPool>& pool);
+
+ Status WriteBlob(std::string_view blob_data);
+
+ Status WriteBytes(const char* data, int32_t length);
+ Status WriteWithCrc32(const char* data, int32_t length);
+
+ template <typename T>
+ static PAIMON_UNIQUE_PTR<Bytes> IntegerToLittleEndian(T value,
+ const
std::shared_ptr<MemoryPool>& pool);
+
+ public:
+ static constexpr uint32_t kTmpBufferSize = 1024 * 1024;
+
+ private:
+ bool blob_as_descriptor_;
+ uint32_t crc32_ = 0;
+ std::vector<int64_t> bin_lengths_;
+ std::shared_ptr<OutputStream> out_;
+ PAIMON_UNIQUE_PTR<Bytes> tmp_buffer_;
+ std::shared_ptr<arrow::DataType> data_type_;
+ std::shared_ptr<FileSystem> fs_;
+ std::shared_ptr<MemoryPool> pool_;
+ std::shared_ptr<Metrics> metrics_;
+};
+
+} // namespace paimon::blob
diff --git a/src/paimon/format/blob/blob_format_writer_test.cpp
b/src/paimon/format/blob/blob_format_writer_test.cpp
new file mode 100644
index 0000000..9902225
--- /dev/null
+++ b/src/paimon/format/blob/blob_format_writer_test.cpp
@@ -0,0 +1,437 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#include "paimon/format/blob/blob_format_writer.h"
+
+#include <string>
+#include <utility>
+
+#include "arrow/c/bridge.h"
+#include "gtest/gtest.h"
+#include "paimon/common/utils/arrow/status_utils.h"
+#include "paimon/common/utils/stream_utils.h"
+#include "paimon/data/blob.h"
+#include "paimon/format/blob/blob_file_batch_reader.h"
+#include "paimon/fs/local/local_file_system.h"
+#include "paimon/testing/utils/read_result_collector.h"
+#include "paimon/testing/utils/test_helper.h"
+#include "paimon/testing/utils/testharness.h"
+
+namespace paimon::blob::test {
+class BlobFormatWriterTest : public ::testing::Test, public
::testing::WithParamInterface<bool> {
+ public:
+ void SetUp() override {
+ blob_as_descriptor_ = GetParam();
+ pool_ = GetDefaultPool();
+ dir_ = paimon::test::UniqueTestDirectory::Create();
+ ASSERT_TRUE(dir_);
+ file_system_ = std::make_shared<LocalFileSystem>();
+ ASSERT_OK_AND_ASSIGN(output_stream_,
+ file_system_->Create(dir_->Str() + "/file.blob",
/*overwrite=*/true));
+
+ struct_type_ = arrow::struct_({BlobUtils::ToArrowField("blob_col",
true)});
+ }
+ void TearDown() override {
+ ASSERT_OK(output_stream_->Flush());
+ ASSERT_OK(output_stream_->Close());
+ }
+
+ Result<std::shared_ptr<arrow::Array>> PrepareBlobArray(
+ const std::shared_ptr<Blob>& blob) const {
+ arrow::StructBuilder struct_builder(struct_type_,
arrow::default_memory_pool(),
+
{std::make_shared<arrow::LargeBinaryBuilder>()});
+ auto blob_builder =
+
static_cast<arrow::LargeBinaryBuilder*>(struct_builder.field_builder(0));
+ PAIMON_RETURN_NOT_OK_FROM_ARROW(struct_builder.Append());
+ if (blob_as_descriptor_) {
+ PAIMON_RETURN_NOT_OK_FROM_ARROW(blob_builder->Append(
+ blob->ToDescriptor(pool_)->data(),
blob->ToDescriptor(pool_)->size()));
+ } else {
+ PAIMON_ASSIGN_OR_RAISE(auto blob_data, blob->ToData(file_system_,
pool_));
+ PAIMON_RETURN_NOT_OK_FROM_ARROW(
+ blob_builder->Append(blob_data->data(), blob_data->size()));
+ }
+ std::shared_ptr<arrow::Array> array;
+ PAIMON_RETURN_NOT_OK_FROM_ARROW(struct_builder.Finish(&array));
+ return array;
+ }
+
+ Status AddBatchOnce(const std::shared_ptr<BlobFormatWriter>& format_writer,
+ const std::shared_ptr<arrow::Array>& blob_array) const
{
+ auto c_array = std::make_unique<ArrowArray>();
+ PAIMON_RETURN_NOT_OK_FROM_ARROW(arrow::ExportArray(*blob_array,
c_array.get()));
+ return format_writer->AddBatch(c_array.get());
+ }
+
+ private:
+ bool blob_as_descriptor_;
+ std::shared_ptr<MemoryPool> pool_;
+ std::unique_ptr<paimon::test::UniqueTestDirectory> dir_;
+ std::shared_ptr<OutputStream> output_stream_;
+ std::shared_ptr<FileSystem> file_system_;
+ std::shared_ptr<arrow::DataType> struct_type_;
+};
+
+INSTANTIATE_TEST_SUITE_P(BlobAsDescriptor, BlobFormatWriterTest,
::testing::Values(false, true));
+
+TEST_P(BlobFormatWriterTest, TestSimple) {
+ // write
+ ASSERT_OK_AND_ASSIGN(std::shared_ptr<BlobFormatWriter> writer,
+ BlobFormatWriter::Create(blob_as_descriptor_,
output_stream_, struct_type_,
+ file_system_, pool_));
+
+ std::vector<std::shared_ptr<Blob>> expected_blobs;
+ std::string file1 = paimon::test::GetDataDir() +
"/avro/data/avro_with_null";
+ ASSERT_OK_AND_ASSIGN(std::shared_ptr<Blob> blob1, Blob::FromPath(file1));
+ expected_blobs.emplace_back(blob1);
+ ASSERT_OK_AND_ASSIGN(auto array1, PrepareBlobArray(blob1));
+ ASSERT_OK(AddBatchOnce(writer, array1));
+ ASSERT_OK(writer->Flush());
+
+ std::string file2 = paimon::test::GetDataDir() + "/xxhash.data";
+ ASSERT_OK_AND_ASSIGN(std::shared_ptr<Blob> blob2,
+ Blob::FromPath(file2, /*offset=*/0, /*length=*/91));
+ expected_blobs.emplace_back(blob2);
+ ASSERT_OK_AND_ASSIGN(auto array2, PrepareBlobArray(blob2));
+ ASSERT_OK(AddBatchOnce(writer, array2));
+ ASSERT_OK_AND_ASSIGN(std::shared_ptr<Blob> blob3,
+ Blob::FromPath(file2, /*offset=*/92, /*length=*/85));
+ expected_blobs.emplace_back(blob3);
+ ASSERT_OK_AND_ASSIGN(auto array3, PrepareBlobArray(blob3));
+ ASSERT_OK(AddBatchOnce(writer, array3));
+
+ ASSERT_OK(writer->Flush());
+ ASSERT_OK(writer->Finish());
+
+ // read
+ ASSERT_OK_AND_ASSIGN(std::shared_ptr<InputStream> input_stream,
+ file_system_->Open(dir_->Str() + "/file.blob"));
+ ASSERT_TRUE(input_stream);
+ ASSERT_OK_AND_ASSIGN(
+ std::unique_ptr<BlobFileBatchReader> reader,
+ BlobFileBatchReader::Create(input_stream, /*batch_size=*/1024,
blob_as_descriptor_, pool_));
+ auto schema = arrow::schema(struct_type_->fields());
+ ::ArrowSchema c_schema;
+ ASSERT_TRUE(arrow::ExportSchema(*schema, &c_schema).ok());
+ ASSERT_OK(
+ reader->SetReadSchema(&c_schema, /*predicate=*/nullptr,
/*selection_bitmap=*/std::nullopt));
+ ASSERT_OK_AND_ASSIGN(auto chunked_array,
+
paimon::test::ReadResultCollector::CollectResult(reader.get()));
+
+ // check result
+ if (blob_as_descriptor_) {
+ auto concat_array =
arrow::Concatenate(chunked_array->chunks()).ValueOrDie();
+ auto struct_array =
arrow::internal::checked_pointer_cast<arrow::StructArray>(concat_array);
+ ASSERT_TRUE(struct_array);
+ ASSERT_OK_AND_ASSIGN(std::vector<std::shared_ptr<Blob>> result_blobs,
+ paimon::test::TestHelper::ToBlobs(struct_array));
+ ASSERT_OK_AND_ASSIGN(bool equal,
paimon::test::TestHelper::CheckBlobsEqual(
+ result_blobs, expected_blobs,
file_system_));
+ ASSERT_TRUE(equal);
+ } else {
+ auto expected_chunk_array =
+ arrow::ChunkedArray::Make({array1, array2, array3}).ValueOrDie();
+ ASSERT_TRUE(expected_chunk_array->Equals(chunked_array))
+ << expected_chunk_array->ToString() << chunked_array->ToString();
+ }
+}
+
+TEST_P(BlobFormatWriterTest, TestCreateWithInvalidParameters) {
+ // Test with nullptr output stream
+ ASSERT_NOK_WITH_MSG(
+ BlobFormatWriter::Create(blob_as_descriptor_, nullptr, struct_type_,
file_system_, pool_),
+ "blob format writer create failed. out is nullptr");
+
+ // Test with nullptr data type
+ ASSERT_NOK_WITH_MSG(
+ BlobFormatWriter::Create(blob_as_descriptor_, output_stream_, nullptr,
file_system_, pool_),
+ "blob format writer create failed. data_type is nullptr");
+
+ // Test with nullptr memory pool
+ ASSERT_NOK_WITH_MSG(BlobFormatWriter::Create(blob_as_descriptor_,
output_stream_, struct_type_,
+ file_system_, nullptr),
+ "blob format writer create failed. pool is nullptr");
+
+ // Test with invalid field count (more than 1 field)
+ auto multi_field_type = arrow::struct_(
+ {arrow::field("blob_col1", arrow::binary()), arrow::field("blob_col2",
arrow::binary())});
+ ASSERT_NOK_WITH_MSG(BlobFormatWriter::Create(blob_as_descriptor_,
output_stream_,
+ multi_field_type,
file_system_, pool_),
+ "blob data type field number 2 is not 1");
+
+ // Test with non-blob field (missing blob metadata)
+ auto non_blob_field = arrow::field("regular_col", arrow::binary());
+ auto non_blob_type = arrow::struct_({non_blob_field});
+ ASSERT_NOK_WITH_MSG(BlobFormatWriter::Create(blob_as_descriptor_,
output_stream_, non_blob_type,
+ file_system_, pool_),
+ "field regular_col: binary is not BLOB");
+}
+
+TEST_P(BlobFormatWriterTest, TestInvalidCase) {
+ ASSERT_OK_AND_ASSIGN(std::shared_ptr<BlobFormatWriter> writer,
+ BlobFormatWriter::Create(blob_as_descriptor_,
output_stream_, struct_type_,
+ file_system_, pool_));
+
+ // Test nullptr batch
+ ASSERT_NOK_WITH_MSG(writer->AddBatch(nullptr),
+ "blob format writer add batch failed. batch is
nullptr");
+
+ // Test invalid blob
+ ASSERT_OK_AND_ASSIGN(auto blob, Blob::FromPath("test_path", 0, 10));
+ if (blob_as_descriptor_) {
+ ASSERT_OK_AND_ASSIGN(auto array, PrepareBlobArray(std::move(blob)));
+ ASSERT_NOK_WITH_MSG(AddBatchOnce(writer, array), "File 'test_path' not
exists");
+ } else {
+ ASSERT_NOK_WITH_MSG(PrepareBlobArray(std::move(blob)), "File
'test_path' not exists");
+ }
+}
+
+TEST_P(BlobFormatWriterTest, TestAddBatchWithInvalidBatchLength) {
+ ASSERT_OK_AND_ASSIGN(std::shared_ptr<BlobFormatWriter> writer,
+ BlobFormatWriter::Create(blob_as_descriptor_,
output_stream_, struct_type_,
+ file_system_, pool_));
+
+ // Test batch with wrong length (not 1)
+ arrow::StructBuilder struct_builder(struct_type_,
arrow::default_memory_pool(),
+
{std::make_shared<arrow::BinaryBuilder>()});
+ auto blob_builder =
static_cast<arrow::BinaryBuilder*>(struct_builder.field_builder(0));
+
+ // Add two rows instead of one
+ ASSERT_OK_AND_ASSIGN(auto blob, Blob::FromPath(paimon::test::GetDataDir()
+ "/xxhash.data"));
+ ASSERT_TRUE(struct_builder.Append().ok());
+ auto blob_descriptor = blob->ToDescriptor(pool_);
+ ASSERT_TRUE(blob_builder->Append(blob_descriptor->data(),
blob_descriptor->size()).ok());
+ ASSERT_TRUE(struct_builder.Append().ok());
+ ASSERT_TRUE(blob_builder->Append(blob_descriptor->data(),
blob_descriptor->size()).ok());
+
+ std::shared_ptr<arrow::Array> array;
+ ASSERT_TRUE(struct_builder.Finish(&array).ok());
+ auto c_array = std::make_unique<ArrowArray>();
+ ASSERT_TRUE(arrow::ExportArray(*array, c_array.get()).ok());
+
+ ASSERT_NOK_WITH_MSG(writer->AddBatch(c_array.get()),
+ "BlobFormatWriter only supports batch with a row count
of 1");
+ ArrowArrayRelease(c_array.get());
+}
+
+TEST_P(BlobFormatWriterTest, TestReachTargetSize) {
+ ASSERT_OK_AND_ASSIGN(std::shared_ptr<BlobFormatWriter> writer,
+ BlobFormatWriter::Create(blob_as_descriptor_,
output_stream_, struct_type_,
+ file_system_, pool_));
+
+ // Initially should not reach target size
+ ASSERT_OK_AND_ASSIGN(bool reached, writer->ReachTargetSize(true, 1000));
+ ASSERT_FALSE(reached);
+
+ // Add some data
+ std::string file = paimon::test::GetDataDir() + "/xxhash.data";
+ ASSERT_OK_AND_ASSIGN(std::shared_ptr<Blob> blob, Blob::FromPath(file));
+ ASSERT_OK_AND_ASSIGN(auto array, PrepareBlobArray(blob));
+ ASSERT_OK(AddBatchOnce(writer, array));
+ ASSERT_OK(writer->Flush());
+
+ // Check if we reach a small target size
+ ASSERT_OK_AND_ASSIGN(reached, writer->ReachTargetSize(true, 10));
+ ASSERT_TRUE(reached);
+
+ // Check if we don't reach a large target size
+ ASSERT_OK_AND_ASSIGN(reached, writer->ReachTargetSize(true, 100000));
+ ASSERT_FALSE(reached);
+}
+
+TEST_P(BlobFormatWriterTest, TestGetWriterMetrics) {
+ ASSERT_OK_AND_ASSIGN(std::shared_ptr<BlobFormatWriter> writer,
+ BlobFormatWriter::Create(blob_as_descriptor_,
output_stream_, struct_type_,
+ file_system_, pool_));
+
+ auto metrics = writer->GetWriterMetrics();
+ ASSERT_TRUE(metrics);
+}
+
+TEST_P(BlobFormatWriterTest, TestEmptyWriter) {
+ // Test creating a writer and finishing without adding any data
+ ASSERT_OK_AND_ASSIGN(std::shared_ptr<BlobFormatWriter> writer,
+ BlobFormatWriter::Create(blob_as_descriptor_,
output_stream_, struct_type_,
+ file_system_, pool_));
+
+ ASSERT_OK(writer->Flush());
+ ASSERT_OK(writer->Finish());
+
+ // Verify the file is the same with java
+ ASSERT_OK_AND_ASSIGN(std::shared_ptr<InputStream> input_stream,
+ file_system_->Open(dir_->Str() + "/file.blob"));
+ ASSERT_TRUE(input_stream);
+ ASSERT_OK_AND_ASSIGN(uint64_t file_length, input_stream->Length());
+ ASSERT_EQ(file_length, 5); // Should have footer even if no data
+ std::vector<char> buffer(file_length);
+ ASSERT_OK_AND_ASSIGN(auto read_length, input_stream->Read(buffer.data(),
buffer.size()));
+ ASSERT_EQ(read_length, 5);
+ std::vector<char> expected = {0x00, 0x00, 0x00, 0x00, 0x01};
+ ASSERT_EQ(buffer, expected);
+}
+
+TEST_P(BlobFormatWriterTest, TestLargeBlob) {
+ ASSERT_OK_AND_ASSIGN(std::shared_ptr<BlobFormatWriter> writer,
+ BlobFormatWriter::Create(blob_as_descriptor_,
output_stream_, struct_type_,
+ file_system_, pool_));
+
+ // Create a temporary large file for testing
+ std::string large_file_path = dir_->Str() + "/large_test_file.bin";
+ ASSERT_OK_AND_ASSIGN(auto large_file_stream,
+ file_system_->Create(large_file_path,
/*overwrite=*/true));
+
+ // Write data larger than TMP_BUFFER_SIZE (1MB)
+ const size_t large_size = BlobFormatWriter::kTmpBufferSize * 2 + 1000; //
~2MB
+ std::vector<char> large_data(large_size, 'A');
+ ASSERT_OK_AND_ASSIGN(int32_t written,
large_file_stream->Write(large_data.data(), large_size));
+ ASSERT_EQ(written, large_size);
+ ASSERT_OK(large_file_stream->Flush());
+ ASSERT_OK(large_file_stream->Close());
+
+ // Create blob from large file and write it
+ ASSERT_OK_AND_ASSIGN(std::shared_ptr<Blob> large_blob,
Blob::FromPath(large_file_path));
+ ASSERT_OK_AND_ASSIGN(auto array, PrepareBlobArray(large_blob));
+ ASSERT_OK(AddBatchOnce(writer, array));
+ ASSERT_OK(writer->Flush());
+ ASSERT_OK(writer->Finish());
+
+ // Verify we can read it back
+ ASSERT_OK_AND_ASSIGN(std::shared_ptr<InputStream> input_stream,
+ file_system_->Open(dir_->Str() + "/file.blob"));
+ ASSERT_OK_AND_ASSIGN(
+ std::unique_ptr<BlobFileBatchReader> reader,
+ BlobFileBatchReader::Create(input_stream, /*batch_size=*/1024,
blob_as_descriptor_, pool_));
+ auto schema = arrow::schema(struct_type_->fields());
+ ::ArrowSchema c_schema;
+ ASSERT_TRUE(arrow::ExportSchema(*schema, &c_schema).ok());
+ ASSERT_OK(
+ reader->SetReadSchema(&c_schema, /*predicate=*/nullptr,
/*selection_bitmap=*/std::nullopt));
+ ASSERT_OK_AND_ASSIGN(auto chunked_array,
+
paimon::test::ReadResultCollector::CollectResult(reader.get()));
+
+ // check result
+ if (blob_as_descriptor_) {
+ auto concat_array =
arrow::Concatenate(chunked_array->chunks()).ValueOrDie();
+ auto struct_array =
arrow::internal::checked_pointer_cast<arrow::StructArray>(concat_array);
+ ASSERT_TRUE(struct_array);
+ ASSERT_OK_AND_ASSIGN(std::vector<std::shared_ptr<Blob>> result_blobs,
+ paimon::test::TestHelper::ToBlobs(struct_array));
+ ASSERT_OK_AND_ASSIGN(bool equal,
paimon::test::TestHelper::CheckBlobsEqual(
+ result_blobs, {large_blob},
file_system_));
+ ASSERT_TRUE(equal);
+ } else {
+ auto expected_chunk_array =
arrow::ChunkedArray::Make({array}).ValueOrDie();
+ ASSERT_TRUE(expected_chunk_array->Equals(chunked_array));
+ }
+}
+
+TEST_P(BlobFormatWriterTest, TestAddBatchWithNullValues) {
+ ASSERT_OK_AND_ASSIGN(std::shared_ptr<BlobFormatWriter> writer,
+ BlobFormatWriter::Create(blob_as_descriptor_,
output_stream_, struct_type_,
+ file_system_, pool_));
+
+ // Write one row with child-level null blob
+ arrow::StructBuilder struct_builder(struct_type_,
arrow::default_memory_pool(),
+
{std::make_shared<arrow::LargeBinaryBuilder>()});
+ auto blob_builder =
static_cast<arrow::LargeBinaryBuilder*>(struct_builder.field_builder(0));
+ ASSERT_TRUE(struct_builder.Append().ok());
+ ASSERT_TRUE(blob_builder->AppendNull().ok());
+ std::shared_ptr<arrow::Array> null_child_array;
+ ASSERT_TRUE(struct_builder.Finish(&null_child_array).ok());
+ auto c_array = std::make_unique<ArrowArray>();
+ ASSERT_TRUE(arrow::ExportArray(*null_child_array, c_array.get()).ok());
+ ASSERT_OK(writer->AddBatch(c_array.get()));
+
+ ASSERT_OK(writer->Flush());
+ ASSERT_OK(writer->Finish());
+
+ // Read back and verify
+ ASSERT_OK_AND_ASSIGN(std::shared_ptr<InputStream> input_stream,
+ file_system_->Open(dir_->Str() + "/file.blob"));
+ ASSERT_TRUE(input_stream);
+ ASSERT_OK_AND_ASSIGN(
+ std::unique_ptr<BlobFileBatchReader> reader,
+ BlobFileBatchReader::Create(input_stream, /*batch_size=*/1024,
blob_as_descriptor_, pool_));
+ auto schema = arrow::schema(struct_type_->fields());
+ ::ArrowSchema c_schema;
+ ASSERT_TRUE(arrow::ExportSchema(*schema, &c_schema).ok());
+ ASSERT_OK(
+ reader->SetReadSchema(&c_schema, /*predicate=*/nullptr,
/*selection_bitmap=*/std::nullopt));
+ ASSERT_OK_AND_ASSIGN(auto chunked_array,
+
paimon::test::ReadResultCollector::CollectResult(reader.get()));
+
+ auto concat_array =
arrow::Concatenate(chunked_array->chunks()).ValueOrDie();
+ auto result_struct =
arrow::internal::checked_pointer_cast<arrow::StructArray>(concat_array);
+ ASSERT_TRUE(result_struct);
+ ASSERT_EQ(result_struct->length(), 1);
+ ASSERT_TRUE(result_struct->field(0)->IsNull(0));
+
+ // Struct-level null should still be rejected
+ arrow::StructBuilder struct_builder2(struct_type_,
arrow::default_memory_pool(),
+
{std::make_shared<arrow::LargeBinaryBuilder>()});
+ ASSERT_TRUE(struct_builder2.AppendNull().ok());
+ std::shared_ptr<arrow::Array> null_struct_array;
+ ASSERT_TRUE(struct_builder2.Finish(&null_struct_array).ok());
+ auto null_c_array = std::make_unique<ArrowArray>();
+ ASSERT_TRUE(arrow::ExportArray(*null_struct_array,
null_c_array.get()).ok());
+ ASSERT_OK_AND_ASSIGN(std::shared_ptr<BlobFormatWriter> writer2,
+ BlobFormatWriter::Create(blob_as_descriptor_,
output_stream_, struct_type_,
+ file_system_, pool_));
+ ASSERT_NOK_WITH_MSG(writer2->AddBatch(null_c_array.get()),
+ "BlobFormatWriter does not support struct-level
null.");
+ ArrowArrayRelease(null_c_array.get());
+}
+
+TEST_P(BlobFormatWriterTest, TestAddBatchWithZeroLengthBlob) {
+ ASSERT_OK_AND_ASSIGN(std::shared_ptr<BlobFormatWriter> writer,
+ BlobFormatWriter::Create(blob_as_descriptor_,
output_stream_, struct_type_,
+ file_system_, pool_));
+
+ // Create a zero-length file
+ std::string zero_file_path = dir_->Str() + "/zero_length_file.bin";
+ ASSERT_OK_AND_ASSIGN(auto zero_file_stream,
+ file_system_->Create(zero_file_path,
/*overwrite=*/true));
+ ASSERT_OK(zero_file_stream->Flush());
+ ASSERT_OK(zero_file_stream->Close());
+
+ // Create blob from zero-length file
+ ASSERT_OK_AND_ASSIGN(std::shared_ptr<Blob> zero_blob,
Blob::FromPath(zero_file_path));
+
+ // This should work - zero-length blobs should be supported
+ ASSERT_OK_AND_ASSIGN(auto array, PrepareBlobArray(zero_blob));
+ ASSERT_OK(AddBatchOnce(writer, array));
+ ASSERT_OK(writer->Flush());
+ ASSERT_OK(writer->Finish());
+
+ // Verify the file is the same with java
+ ASSERT_OK_AND_ASSIGN(std::shared_ptr<InputStream> input_stream,
+ file_system_->Open(dir_->Str() + "/file.blob"));
+ ASSERT_TRUE(input_stream);
+ ASSERT_OK_AND_ASSIGN(uint64_t file_length, input_stream->Length());
+ ASSERT_EQ(file_length, 22);
+ std::vector<uint8_t> buffer(file_length);
+ ASSERT_OK_AND_ASSIGN(auto read_length,
+
input_stream->Read(reinterpret_cast<char*>(buffer.data()), buffer.size()));
+ ASSERT_EQ(read_length, 22);
+ std::vector<uint8_t> expected = {{0xcf, 0x11, 0x4e, 0x58, 0x10, 0x00,
0x00, 0x00,
+ 0x00, 0x00, 0x00, 0x00, 0x53, 0x7f,
0xdf, 0x03,
+ 0x20, 0x01, 0x00, 0x00, 0x00, 0x01}};
+ ASSERT_EQ(buffer, expected);
+}
+
+} // namespace paimon::blob::test
diff --git a/src/paimon/format/blob/blob_reader_builder.h
b/src/paimon/format/blob/blob_reader_builder.h
new file mode 100644
index 0000000..0bffbf3
--- /dev/null
+++ b/src/paimon/format/blob/blob_reader_builder.h
@@ -0,0 +1,61 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#pragma once
+
+#include <map>
+#include <memory>
+#include <string>
+
+#include "paimon/common/utils/options_utils.h"
+#include "paimon/format/blob/blob_file_batch_reader.h"
+#include "paimon/format/reader_builder.h"
+#include "paimon/fs/file_system.h"
+#include "paimon/memory/memory_pool.h"
+
+namespace paimon::blob {
+
+class BlobReaderBuilder : public ReaderBuilder {
+ public:
+ BlobReaderBuilder(int32_t batch_size, const std::map<std::string,
std::string>& options)
+ : batch_size_(batch_size), pool_(GetDefaultPool()), options_(options)
{}
+
+ ReaderBuilder* WithMemoryPool(const std::shared_ptr<MemoryPool>& pool)
override {
+ pool_ = pool;
+ return this;
+ }
+
+ Result<std::unique_ptr<FileBatchReader>> Build(
+ const std::shared_ptr<InputStream>& input_stream) const override {
+ PAIMON_ASSIGN_OR_RAISE(
+ bool blob_as_descriptor,
+ OptionsUtils::GetValueFromMap<bool>(options_,
Options::BLOB_AS_DESCRIPTOR, false));
+ return BlobFileBatchReader::Create(input_stream, batch_size_,
blob_as_descriptor, pool_);
+ }
+
+ Result<std::unique_ptr<FileBatchReader>> Build(const std::string& path)
const override {
+ return Status::Invalid("do not support build reader with path in blob
format");
+ }
+
+ private:
+ int32_t batch_size_;
+ std::shared_ptr<MemoryPool> pool_;
+ std::map<std::string, std::string> options_;
+};
+
+} // namespace paimon::blob
diff --git a/src/paimon/format/blob/blob_stats_extractor.cpp
b/src/paimon/format/blob/blob_stats_extractor.cpp
new file mode 100644
index 0000000..12a7abd
--- /dev/null
+++ b/src/paimon/format/blob/blob_stats_extractor.cpp
@@ -0,0 +1,64 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#include "paimon/format/blob/blob_stats_extractor.h"
+
+#include <cassert>
+#include <optional>
+
+#include "arrow/api.h"
+#include "fmt/format.h"
+#include "paimon/common/data/blob_utils.h"
+#include "paimon/format/blob/blob_file_batch_reader.h"
+#include "paimon/format/column_stats.h"
+#include "paimon/fs/file_system.h"
+#include "paimon/status.h"
+
+namespace paimon {
+class MemoryPool;
+} // namespace paimon
+
+namespace paimon::blob {
+
+Result<std::pair<ColumnStatsVector, FormatStatsExtractor::FileInfo>>
+BlobStatsExtractor::ExtractWithFileInfo(const std::shared_ptr<FileSystem>&
file_system,
+ const std::string& path,
+ const std::shared_ptr<MemoryPool>&
pool) {
+ PAIMON_ASSIGN_OR_RAISE(std::shared_ptr<InputStream> input_stream,
file_system->Open(path));
+ assert(input_stream);
+ if (write_schema_->num_fields() != 1) {
+ return Status::Invalid(
+ fmt::format("schema field number {} is not 1",
write_schema_->num_fields()));
+ }
+ if (!BlobUtils::IsBlobField(write_schema_->field(0))) {
+ return Status::Invalid(
+ fmt::format("field {} is not BLOB",
write_schema_->field(0)->ToString()));
+ }
+
+ PAIMON_ASSIGN_OR_RAISE(
+ std::unique_ptr<BlobFileBatchReader> blob_reader,
+ BlobFileBatchReader::Create(input_stream,
+ /*batch_size=*/1024,
/*blob_as_descriptor=*/true, pool));
+ ColumnStatsVector result_stats;
+ result_stats.push_back(
+ ColumnStats::CreateStringColumnStats(std::nullopt, std::nullopt,
/*null_count=*/0));
+ PAIMON_ASSIGN_OR_RAISE(uint64_t num_rows, blob_reader->GetNumberOfRows());
+ return std::make_pair(result_stats, FileInfo(num_rows));
+}
+
+} // namespace paimon::blob
diff --git a/src/paimon/format/blob/blob_stats_extractor.h
b/src/paimon/format/blob/blob_stats_extractor.h
new file mode 100644
index 0000000..eed11d8
--- /dev/null
+++ b/src/paimon/format/blob/blob_stats_extractor.h
@@ -0,0 +1,61 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#pragma once
+
+#include <memory>
+#include <string>
+#include <utility>
+
+#include "arrow/api.h"
+#include "paimon/format/format_stats_extractor.h"
+#include "paimon/result.h"
+#include "paimon/type_fwd.h"
+
+namespace arrow {
+class DataType;
+class Schema;
+} // namespace arrow
+namespace paimon {
+class FileSystem;
+class MemoryPool;
+} // namespace paimon
+
+namespace paimon::blob {
+
+class BlobStatsExtractor : public FormatStatsExtractor {
+ public:
+ explicit BlobStatsExtractor(const std::shared_ptr<arrow::Schema>&
write_schema)
+ : write_schema_(write_schema) {}
+
+ Result<ColumnStatsVector> Extract(const std::shared_ptr<FileSystem>&
file_system,
+ const std::string& path,
+ const std::shared_ptr<MemoryPool>& pool)
override {
+ PAIMON_ASSIGN_OR_RAISE(auto result, ExtractWithFileInfo(file_system,
path, pool));
+ return result.first;
+ }
+
+ Result<std::pair<ColumnStatsVector, FileInfo>> ExtractWithFileInfo(
+ const std::shared_ptr<FileSystem>& file_system, const std::string&
path,
+ const std::shared_ptr<MemoryPool>& pool) override;
+
+ private:
+ std::shared_ptr<arrow::Schema> write_schema_;
+};
+
+} // namespace paimon::blob
diff --git a/src/paimon/format/blob/blob_stats_extractor_test.cpp
b/src/paimon/format/blob/blob_stats_extractor_test.cpp
new file mode 100644
index 0000000..e6c8d69
--- /dev/null
+++ b/src/paimon/format/blob/blob_stats_extractor_test.cpp
@@ -0,0 +1,111 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#include "paimon/format/blob/blob_stats_extractor.h"
+
+#include <cstdint>
+#include <vector>
+
+#include "arrow/api.h"
+#include "gtest/gtest.h"
+#include "paimon/common/data/blob_utils.h"
+#include "paimon/defs.h"
+#include "paimon/format/column_stats.h"
+#include "paimon/fs/local/local_file_system.h"
+#include "paimon/memory/memory_pool.h"
+#include "paimon/status.h"
+#include "paimon/testing/utils/testharness.h"
+
+namespace paimon::blob::test {
+
+class BlobStatsExtractorTest : public testing::Test {
+ public:
+ void SetUp() override {
+ pool_ = GetDefaultPool();
+ // Create a blob schema with one blob field
+ blob_field_ = BlobUtils::ToArrowField("blob_field", false);
+ blob_schema_ = arrow::schema({blob_field_});
+ fs_ = std::make_shared<LocalFileSystem>();
+ }
+
+ private:
+ std::shared_ptr<MemoryPool> pool_;
+ std::shared_ptr<arrow::Field> blob_field_;
+ std::shared_ptr<arrow::Schema> blob_schema_;
+ std::shared_ptr<FileSystem> fs_;
+};
+
+TEST_F(BlobStatsExtractorTest, TestDifferentBlobFiles) {
+ BlobStatsExtractor extractor(blob_schema_);
+
+ std::vector<std::pair<std::string, int64_t>> test_files = {
+ {"data-d7816e8e-6c6d-4e28-9137-837cdf706350-1.blob", 3},
+ {"data-d7816e8e-6c6d-4e28-9137-837cdf706350-2.blob", 2},
+ {"data-d7816e8e-6c6d-4e28-9137-837cdf706350-3.blob", 4},
+ {"data-d7816e8e-6c6d-4e28-9137-837cdf706350-4.blob", 1}};
+
+ for (const auto& [filename, expected_rows] : test_files) {
+ std::string blob_file_path =
+ paimon::test::GetDataDir() +
"/db_with_blob.db/table_with_blob/bucket-0/" + filename;
+
+ ASSERT_OK_AND_ASSIGN(auto stats_with_info,
+ extractor.ExtractWithFileInfo(fs_,
blob_file_path, pool_));
+
+ // Check stats structure
+ ASSERT_EQ(1u, stats_with_info.first.size());
+ ASSERT_TRUE(stats_with_info.first[0]);
+ ASSERT_EQ(FieldType::STRING, stats_with_info.first[0]->GetFieldType());
+ ASSERT_EQ("min null, max null, null count 0",
stats_with_info.first[0]->ToString());
+
+ // Check row count matches expected
+ ASSERT_EQ(expected_rows, stats_with_info.second.GetRowCount())
+ << "Row count mismatch for file: " << filename;
+ }
+}
+
+TEST_F(BlobStatsExtractorTest, TestInvalidCase) {
+ std::string blob_file_path = paimon::test::GetDataDir() +
+ "/db_with_blob.db/table_with_blob/bucket-0/"
+
"data-d7816e8e-6c6d-4e28-9137-837cdf706350-1.blob";
+
+ // Should fail because schema has more than 1 field
+ {
+ auto int_field = arrow::field("int_field", arrow::int32());
+ auto multi_field_schema = arrow::schema({blob_field_, int_field});
+ BlobStatsExtractor extractor(multi_field_schema);
+ ASSERT_NOK_WITH_MSG(extractor.ExtractWithFileInfo(fs_, blob_file_path,
pool_),
+ "schema field number 2 is not 1");
+ }
+ // Should fail because field is not a blob field
+ {
+ auto string_field = arrow::field("string_field", arrow::utf8());
+ auto non_blob_schema = arrow::schema({string_field});
+ BlobStatsExtractor extractor(non_blob_schema);
+ ASSERT_NOK_WITH_MSG(extractor.ExtractWithFileInfo(fs_, blob_file_path,
pool_),
+ "field string_field: string is not BLOB");
+ }
+ // Should fail because file doesn't exist
+ {
+ BlobStatsExtractor extractor(blob_schema_);
+ std::string non_existent_path = "/path/that/does/not/exist.blob";
+ ASSERT_NOK_WITH_MSG(extractor.ExtractWithFileInfo(fs_,
non_existent_path, pool_),
+ "File '/path/that/does/not/exist.blob' not
exists");
+ }
+}
+
+} // namespace paimon::blob::test
diff --git a/src/paimon/format/blob/blob_writer_builder.h
b/src/paimon/format/blob/blob_writer_builder.h
new file mode 100644
index 0000000..1fc1a58
--- /dev/null
+++ b/src/paimon/format/blob/blob_writer_builder.h
@@ -0,0 +1,85 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#pragma once
+
+#include <cassert>
+#include <map>
+#include <memory>
+#include <optional>
+#include <string>
+#include <utility>
+
+#include "arrow/api.h"
+#include "paimon/common/utils/options_utils.h"
+#include "paimon/defs.h"
+#include "paimon/format/blob/blob_format_writer.h"
+#include "paimon/format/format_writer.h"
+#include "paimon/format/writer_builder.h"
+#include "paimon/memory/memory_pool.h"
+#include "paimon/result.h"
+#include "paimon/status.h"
+
+namespace arrow {
+class DataType;
+} // namespace arrow
+namespace paimon {
+class FileSystem;
+class OutputStream;
+} // namespace paimon
+
+namespace paimon::blob {
+
+class BlobWriterBuilder : public SpecificFSWriterBuilder {
+ public:
+ BlobWriterBuilder(const std::shared_ptr<arrow::DataType>& data_type,
+ const std::map<std::string, std::string>& options)
+ : pool_(GetDefaultPool()), data_type_(data_type), options_(options) {
+ assert(data_type_);
+ }
+
+ WriterBuilder* WithMemoryPool(const std::shared_ptr<MemoryPool>& pool)
override {
+ pool_ = pool;
+ return this;
+ }
+
+ SpecificFSWriterBuilder* WithFileSystem(const std::shared_ptr<FileSystem>&
fs) override {
+ fs_ = fs;
+ return this;
+ }
+
+ Result<std::unique_ptr<FormatWriter>> Build(const
std::shared_ptr<OutputStream>& out,
+ const std::string&
compression) override {
+ assert(out);
+ if (fs_ == nullptr) {
+ return Status::Invalid("File system is nullptr. Please call
WithFileSystem() first.");
+ }
+ PAIMON_ASSIGN_OR_RAISE(
+ bool blob_as_descriptor,
+ OptionsUtils::GetValueFromMap<bool>(options_,
Options::BLOB_AS_DESCRIPTOR, false));
+ return BlobFormatWriter::Create(blob_as_descriptor, out, data_type_,
fs_, pool_);
+ }
+
+ private:
+ std::shared_ptr<MemoryPool> pool_;
+ std::shared_ptr<arrow::DataType> data_type_;
+ std::map<std::string, std::string> options_;
+ std::shared_ptr<FileSystem> fs_;
+};
+
+} // namespace paimon::blob
diff --git a/src/paimon/format/blob/blob_writer_builder_test.cpp
b/src/paimon/format/blob/blob_writer_builder_test.cpp
new file mode 100644
index 0000000..2350eba
--- /dev/null
+++ b/src/paimon/format/blob/blob_writer_builder_test.cpp
@@ -0,0 +1,57 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#include "paimon/format/blob/blob_writer_builder.h"
+
+#include "arrow/api.h"
+#include "gtest/gtest.h"
+#include "paimon/common/data/blob_utils.h"
+#include "paimon/fs/file_system.h"
+#include "paimon/fs/local/local_file_system.h"
+#include "paimon/testing/utils/testharness.h"
+
+namespace paimon::blob::test {
+class BlobWriterBuilderTest : public ::testing::Test {
+ public:
+ void SetUp() override {
+ dir_ = paimon::test::UniqueTestDirectory::Create();
+ ASSERT_TRUE(dir_);
+ file_system_ = std::make_shared<LocalFileSystem>();
+ ASSERT_OK_AND_ASSIGN(output_stream_,
+ file_system_->Create(dir_->Str() + "/file.blob",
/*overwrite=*/true));
+ struct_type_ = arrow::struct_({BlobUtils::ToArrowField("blob_col",
false)});
+ }
+ void TearDown() override {}
+
+ private:
+ std::unique_ptr<paimon::test::UniqueTestDirectory> dir_;
+ std::shared_ptr<OutputStream> output_stream_;
+ std::shared_ptr<FileSystem> file_system_;
+ std::shared_ptr<arrow::DataType> struct_type_;
+};
+
+TEST_F(BlobWriterBuilderTest, TestSimple) {
+ BlobWriterBuilder builder(struct_type_, {});
+ ASSERT_NOK_WITH_MSG(builder.Build(output_stream_, "none"),
+ "File system is nullptr. Please call WithFileSystem()
first.");
+
+ builder.WithFileSystem(file_system_);
+ ASSERT_OK(builder.Build(output_stream_, "none"));
+}
+
+} // namespace paimon::blob::test