blob: b0edbe3bef8c7ee722dcaa3d94d1ccac44e8fe72 [file]
/*
* 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 <vector>
#include "arrow/c/bridge.h"
#include "gtest/gtest.h"
#include "paimon/common/data/blob_descriptor.h"
#include "paimon/common/utils/arrow/mem_utils.h"
#include "paimon/common/utils/arrow/status_utils.h"
#include "paimon/common/utils/checked_cast.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 {
/// A file system whose Open() always fails with the configured status while Exists() keeps the
/// real local check, standing in for a plugin that reports a missing file as something other than
/// Status::NotExist. Open() calls are counted so a test can assert a missing file is never opened.
class OpenFailFileSystem : public LocalFileSystem {
public:
explicit OpenFailFileSystem(Status open_status) : open_status_(std::move(open_status)) {}
Result<std::unique_ptr<InputStream>> Open(const std::string& path) const override {
++open_call_count_;
return open_status_;
}
int64_t OpenCallCount() const {
return open_call_count_;
}
private:
Status open_status_;
mutable int64_t open_call_count_ = 0;
};
/// A file system whose Exists() always fails with the configured status, counting the calls. By
/// default Open() is delegated to a separate LocalFileSystem so that it still succeeds
/// (LocalFileSystem::Open() calls Exists() on itself, so without the delegation a failed check
/// would also fail the open); a non-OK `open_status` makes Open() fail with it instead.
class ExistsFailFileSystem : public LocalFileSystem {
public:
explicit ExistsFailFileSystem(Status exists_status, Status open_status = Status::OK())
: exists_status_(std::move(exists_status)), open_status_(std::move(open_status)) {}
Result<bool> Exists(const std::string& path) const override {
++exists_call_count_;
return exists_status_;
}
Result<std::unique_ptr<InputStream>> Open(const std::string& path) const override {
if (!open_status_.ok()) {
return open_status_;
}
return real_fs_.Open(path);
}
int64_t ExistsCallCount() const {
return exists_call_count_;
}
private:
Status exists_status_;
Status open_status_;
LocalFileSystem real_fs_;
mutable int64_t exists_call_count_ = 0;
};
/// A file system that reports a file as present on the first Exists() and absent afterwards,
/// standing in for a file deleted between the check and the open. Open() fails with a plain
/// IOError, so a test can tell a re-checked classification apart from one taken from the open.
class VanishingFileSystem : public LocalFileSystem {
public:
Result<bool> Exists(const std::string& path) const override {
return ++exists_call_count_ == 1;
}
Result<std::unique_ptr<InputStream>> Open(const std::string& path) const override {
return Status::IOError("mock io error");
}
int64_t ExistsCallCount() const {
return exists_call_count_;
}
private:
mutable int64_t exists_call_count_ = 0;
};
class BlobFormatWriterTestBase : public ::testing::Test {
public:
void SetUp() override {
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());
}
/// Create a writer on output_stream_ with both write-null options disabled.
Result<std::unique_ptr<BlobFormatWriter>> CreateDefaultWriter() const {
return BlobFormatWriter::Create(output_stream_, struct_type_,
/*write_null_on_missing_file=*/false,
/*write_null_on_fetch_failure=*/false,
/*write_placeholder=*/false, file_system_, pool_);
}
/// Create a writer in placeholder mode, as used by data-evolution partial updates.
Result<std::unique_ptr<BlobFormatWriter>> CreatePlaceholderWriter() const {
return BlobFormatWriter::Create(output_stream_, struct_type_,
/*write_null_on_missing_file=*/false,
/*write_null_on_fetch_failure=*/false,
/*write_placeholder=*/true, file_system_, pool_);
}
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());
}
Result<std::shared_ptr<arrow::Array>> PrepareDescriptorArray(
const std::shared_ptr<Blob>& blob) const {
return paimon::test::TestHelper::MakeBlobDescriptorArray(struct_type_, blob, pool_);
}
/// Build a single-row blob array holding `bytes` verbatim, bypassing the Blob helpers.
Result<std::shared_ptr<arrow::Array>> MakeBlobArrayFromBytes(const std::string& bytes) const {
arrow::StructBuilder struct_builder(struct_type_, arrow::default_memory_pool(),
{std::make_shared<arrow::LargeBinaryBuilder>()});
auto blob_builder =
checked_cast<arrow::LargeBinaryBuilder*>(struct_builder.field_builder(0));
PAIMON_RETURN_NOT_OK_FROM_ARROW(struct_builder.Append());
PAIMON_RETURN_NOT_OK_FROM_ARROW(blob_builder->Append(bytes.data(), bytes.size()));
std::shared_ptr<arrow::Array> array;
PAIMON_RETURN_NOT_OK_FROM_ARROW(struct_builder.Finish(&array));
return array;
}
Result<std::shared_ptr<arrow::StructArray>> ReadBackAsData() const {
PAIMON_ASSIGN_OR_RAISE(std::shared_ptr<InputStream> input_stream,
file_system_->Open(dir_->Str() + "/file.blob"));
PAIMON_ASSIGN_OR_RAISE(std::unique_ptr<BlobFileBatchReader> reader,
BlobFileBatchReader::Create(input_stream, /*batch_size=*/1024,
/*blob_as_descriptor=*/false,
/*emit_placeholder_sentinel=*/false,
pool_, GetArrowPool(pool_)));
auto schema = arrow::schema(struct_type_->fields());
::ArrowSchema c_schema;
PAIMON_RETURN_NOT_OK_FROM_ARROW(arrow::ExportSchema(*schema, &c_schema));
PAIMON_RETURN_NOT_OK(reader->SetReadSchema(&c_schema, /*predicate=*/nullptr,
/*selection_bitmap=*/std::nullopt));
PAIMON_ASSIGN_OR_RAISE(std::shared_ptr<arrow::ChunkedArray> chunked_array,
paimon::test::ReadResultCollector::CollectResult(std::move(reader)));
PAIMON_ASSIGN_OR_RAISE_FROM_ARROW(std::shared_ptr<arrow::Array> concat_array,
arrow::Concatenate(chunked_array->chunks()));
return checked_pointer_cast<arrow::StructArray>(concat_array);
}
protected:
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_;
};
class BlobFormatWriterTest : public BlobFormatWriterTestBase,
public ::testing::WithParamInterface<bool> {
public:
void SetUp() override {
blob_as_descriptor_ = GetParam();
BlobFormatWriterTestBase::SetUp();
}
Result<std::shared_ptr<arrow::Array>> PrepareBlobArray(
const std::shared_ptr<Blob>& blob) const {
if (blob_as_descriptor_) {
return PrepareDescriptorArray(blob);
}
arrow::StructBuilder struct_builder(struct_type_, arrow::default_memory_pool(),
{std::make_shared<arrow::LargeBinaryBuilder>()});
auto blob_builder =
checked_cast<arrow::LargeBinaryBuilder*>(struct_builder.field_builder(0));
PAIMON_RETURN_NOT_OK_FROM_ARROW(struct_builder.Append());
PAIMON_ASSIGN_OR_RAISE(PAIMON_UNIQUE_PTR<Bytes> 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;
}
private:
bool blob_as_descriptor_;
};
/// The write-null tests always feed descriptor bytes, so they do not depend on the
/// blob_as_descriptor_ parameter and run once on the non-parameterized fixture.
using BlobFormatWriterWriteNullTest = BlobFormatWriterTestBase;
INSTANTIATE_TEST_SUITE_P(BlobAsDescriptor, BlobFormatWriterTest, ::testing::Values(false, true));
TEST_P(BlobFormatWriterTest, TestSimple) {
// write
ASSERT_OK_AND_ASSIGN(std::shared_ptr<BlobFormatWriter> writer, CreateDefaultWriter());
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_,
/*emit_placeholder_sentinel=*/false, pool_, GetArrowPool(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(std::move(reader)));
// check result
if (blob_as_descriptor_) {
auto concat_array = arrow::Concatenate(chunked_array->chunks()).ValueOrDie();
auto struct_array = 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(nullptr, struct_type_, /*write_null_on_missing_file=*/false,
/*write_null_on_fetch_failure=*/false,
/*write_placeholder=*/false, file_system_, pool_),
"blob format writer create failed. out is nullptr");
// Test with nullptr data type
ASSERT_NOK_WITH_MSG(
BlobFormatWriter::Create(output_stream_, nullptr, /*write_null_on_missing_file=*/false,
/*write_null_on_fetch_failure=*/false,
/*write_placeholder=*/false, file_system_, pool_),
"blob format writer create failed. data_type is nullptr");
// Test with nullptr memory pool
ASSERT_NOK_WITH_MSG(
BlobFormatWriter::Create(output_stream_, struct_type_, /*write_null_on_missing_file=*/false,
/*write_null_on_fetch_failure=*/false,
/*write_placeholder=*/false, file_system_, nullptr),
"blob format writer create failed. pool is nullptr");
// Test with nullptr file system
ASSERT_NOK_WITH_MSG(
BlobFormatWriter::Create(output_stream_, struct_type_, /*write_null_on_missing_file=*/false,
/*write_null_on_fetch_failure=*/false,
/*write_placeholder=*/false, nullptr, pool_),
"blob format writer create failed. fs 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(output_stream_, multi_field_type,
/*write_null_on_missing_file=*/false,
/*write_null_on_fetch_failure=*/false,
/*write_placeholder=*/false, 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(output_stream_, non_blob_type,
/*write_null_on_missing_file=*/false,
/*write_null_on_fetch_failure=*/false,
/*write_placeholder=*/false, file_system_, pool_),
"field regular_col: binary is not BLOB");
}
TEST_P(BlobFormatWriterTest, TestInvalidCase) {
ASSERT_OK_AND_ASSIGN(std::shared_ptr<BlobFormatWriter> writer, CreateDefaultWriter());
// 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, CreateDefaultWriter());
// 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 = checked_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, CreateDefaultWriter());
// 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, CreateDefaultWriter());
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, CreateDefaultWriter());
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(int64_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, CreateDefaultWriter());
// 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(int64_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_,
/*emit_placeholder_sentinel=*/false, pool_, GetArrowPool(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(std::move(reader)));
// check result
if (blob_as_descriptor_) {
auto concat_array = arrow::Concatenate(chunked_array->chunks()).ValueOrDie();
auto struct_array = 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, CreateDefaultWriter());
// 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 = checked_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_,
/*emit_placeholder_sentinel=*/false, pool_, GetArrowPool(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(std::move(reader)));
auto concat_array = arrow::Concatenate(chunked_array->chunks()).ValueOrDie();
auto result_struct = 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, CreateDefaultWriter());
ASSERT_NOK_WITH_MSG(writer2->AddBatch(null_c_array.get()),
"BlobFormatWriter does not support struct-level null.");
ArrowArrayRelease(null_c_array.get());
}
TEST_F(BlobFormatWriterWriteNullTest, TestWriteNullOnMissingFile) {
ASSERT_OK_AND_ASSIGN(
std::shared_ptr<BlobFormatWriter> writer,
BlobFormatWriter::Create(output_stream_, struct_type_, /*write_null_on_missing_file=*/true,
/*write_null_on_fetch_failure=*/false,
/*write_placeholder=*/false, file_system_, pool_));
ASSERT_OK_AND_ASSIGN(std::shared_ptr<Blob> missing_blob,
Blob::FromPath(dir_->Str() + "/not_exist_file", /*offset=*/0,
/*length=*/10));
ASSERT_OK_AND_ASSIGN(auto missing_array, PrepareDescriptorArray(missing_blob));
ASSERT_OK(AddBatchOnce(writer, missing_array));
// A fetch failure is not converted to NULL by write_null_on_missing_file alone; the
// rejected row leaves the writer usable.
std::string file = paimon::test::GetDataDir() + "/xxhash.data";
ASSERT_OK_AND_ASSIGN(std::shared_ptr<Blob> bad_offset_blob,
Blob::FromPath(file, /*offset=*/1 << 20, /*length=*/10));
ASSERT_OK_AND_ASSIGN(auto bad_offset_array, PrepareDescriptorArray(bad_offset_blob));
ASSERT_NOK_WITH_MSG(AddBatchOnce(writer, bad_offset_array), "exceed total length");
ASSERT_OK_AND_ASSIGN(std::shared_ptr<Blob> blob, Blob::FromPath(file));
ASSERT_OK_AND_ASSIGN(auto array, PrepareDescriptorArray(blob));
ASSERT_OK(AddBatchOnce(writer, array));
ASSERT_OK(writer->Flush());
ASSERT_OK(writer->Finish());
ASSERT_OK_AND_ASSIGN(std::shared_ptr<arrow::StructArray> result_struct, ReadBackAsData());
ASSERT_EQ(result_struct->length(), 2);
ASSERT_TRUE(result_struct->field(0)->IsNull(0));
ASSERT_FALSE(result_struct->field(0)->IsNull(1));
auto binary_array = checked_pointer_cast<arrow::LargeBinaryArray>(result_struct->field(0));
ASSERT_OK_AND_ASSIGN(auto expected_data, blob->ToData(file_system_, pool_));
ASSERT_EQ(binary_array->GetView(1),
std::string_view(expected_data->data(), expected_data->size()));
}
TEST_F(BlobFormatWriterWriteNullTest, TestWriteNullOnFetchFailure) {
ASSERT_OK_AND_ASSIGN(
std::shared_ptr<BlobFormatWriter> writer,
BlobFormatWriter::Create(output_stream_, struct_type_, /*write_null_on_missing_file=*/false,
/*write_null_on_fetch_failure=*/true,
/*write_placeholder=*/false, file_system_, pool_));
std::string file = paimon::test::GetDataDir() + "/xxhash.data";
ASSERT_OK_AND_ASSIGN(std::shared_ptr<Blob> bad_offset_blob,
Blob::FromPath(file, /*offset=*/1 << 20, /*length=*/10));
ASSERT_OK_AND_ASSIGN(auto bad_offset_array, PrepareDescriptorArray(bad_offset_blob));
ASSERT_OK(AddBatchOnce(writer, bad_offset_array));
// Without write_null_on_missing_file no existence check runs, so a missing file is not told
// apart from any other failed open and is converted by this option.
ASSERT_OK_AND_ASSIGN(std::shared_ptr<Blob> missing_blob,
Blob::FromPath(dir_->Str() + "/not_exist_file", /*offset=*/0,
/*length=*/10));
ASSERT_OK_AND_ASSIGN(auto missing_array, PrepareDescriptorArray(missing_blob));
ASSERT_OK(AddBatchOnce(writer, missing_array));
ASSERT_OK(writer->Flush());
ASSERT_OK(writer->Finish());
// Both rows count as fetch failures.
ASSERT_OK_AND_ASSIGN(
uint64_t missing_nulls,
writer->GetWriterMetrics()->GetCounter(BlobMetrics::WRITE_NULL_ON_MISSING_FILE_COUNT));
ASSERT_EQ(missing_nulls, 0);
ASSERT_OK_AND_ASSIGN(
uint64_t fetch_failure_nulls,
writer->GetWriterMetrics()->GetCounter(BlobMetrics::WRITE_NULL_ON_FETCH_FAILURE_COUNT));
ASSERT_EQ(fetch_failure_nulls, 2);
ASSERT_OK_AND_ASSIGN(std::shared_ptr<arrow::StructArray> result_struct, ReadBackAsData());
ASSERT_EQ(result_struct->length(), 2);
ASSERT_TRUE(result_struct->field(0)->IsNull(0));
ASSERT_TRUE(result_struct->field(0)->IsNull(1));
}
TEST_F(BlobFormatWriterWriteNullTest, TestWriteNullOnBothOptionsEnabled) {
ASSERT_OK_AND_ASSIGN(
std::shared_ptr<BlobFormatWriter> writer,
BlobFormatWriter::Create(output_stream_, struct_type_, /*write_null_on_missing_file=*/true,
/*write_null_on_fetch_failure=*/true,
/*write_placeholder=*/false, file_system_, pool_));
// Row 0: missing file -> NULL.
ASSERT_OK_AND_ASSIGN(std::shared_ptr<Blob> missing_blob,
Blob::FromPath(dir_->Str() + "/not_exist_file", /*offset=*/0,
/*length=*/10));
ASSERT_OK_AND_ASSIGN(auto missing_array, PrepareDescriptorArray(missing_blob));
ASSERT_OK(AddBatchOnce(writer, missing_array));
// Row 1: fetch failure (offset beyond EOF) -> NULL.
std::string file = paimon::test::GetDataDir() + "/xxhash.data";
ASSERT_OK_AND_ASSIGN(std::shared_ptr<Blob> bad_offset_blob,
Blob::FromPath(file, /*offset=*/1 << 20, /*length=*/10));
ASSERT_OK_AND_ASSIGN(auto bad_offset_array, PrepareDescriptorArray(bad_offset_blob));
ASSERT_OK(AddBatchOnce(writer, bad_offset_array));
// Row 2: valid blob.
ASSERT_OK_AND_ASSIGN(std::shared_ptr<Blob> blob, Blob::FromPath(file));
ASSERT_OK_AND_ASSIGN(auto array, PrepareDescriptorArray(blob));
ASSERT_OK(AddBatchOnce(writer, array));
ASSERT_OK(writer->Flush());
ASSERT_OK(writer->Finish());
// The two NULL rows had different causes, counted separately.
ASSERT_OK_AND_ASSIGN(
uint64_t missing_nulls,
writer->GetWriterMetrics()->GetCounter(BlobMetrics::WRITE_NULL_ON_MISSING_FILE_COUNT));
ASSERT_EQ(missing_nulls, 1);
ASSERT_OK_AND_ASSIGN(
uint64_t fetch_failure_nulls,
writer->GetWriterMetrics()->GetCounter(BlobMetrics::WRITE_NULL_ON_FETCH_FAILURE_COUNT));
ASSERT_EQ(fetch_failure_nulls, 1);
ASSERT_OK_AND_ASSIGN(std::shared_ptr<arrow::StructArray> result_struct, ReadBackAsData());
ASSERT_EQ(result_struct->length(), 3);
ASSERT_TRUE(result_struct->field(0)->IsNull(0));
ASSERT_TRUE(result_struct->field(0)->IsNull(1));
ASSERT_FALSE(result_struct->field(0)->IsNull(2));
auto binary_array = checked_pointer_cast<arrow::LargeBinaryArray>(result_struct->field(0));
ASSERT_OK_AND_ASSIGN(auto expected_data, blob->ToData(file_system_, pool_));
ASSERT_EQ(binary_array->GetView(2),
std::string_view(expected_data->data(), expected_data->size()));
}
TEST_F(BlobFormatWriterWriteNullTest, TestWriteNullClassifiesByExistence) {
// Each case needs its own option pair and therefore its own writer, so none of them finishes
// the shared output stream: this test only asserts how a failure is classified. That the
// resulting NULL element is written correctly is covered by the TestWriteNullOn* tests.
auto io_error_fs = std::make_shared<OpenFailFileSystem>(Status::IOError("mock io error"));
ASSERT_OK_AND_ASSIGN(std::shared_ptr<Blob> missing_blob,
Blob::FromPath(dir_->Str() + "/not_exist_file", /*offset=*/0,
/*length=*/10));
ASSERT_OK_AND_ASSIGN(auto missing_array, PrepareDescriptorArray(missing_blob));
ASSERT_OK_AND_ASSIGN(std::shared_ptr<Blob> existing_blob,
Blob::FromPath(paimon::test::GetDataDir() + "/xxhash.data"));
ASSERT_OK_AND_ASSIGN(auto existing_array, PrepareDescriptorArray(existing_blob));
// Missing file: classified without opening it, so what Open would return is irrelevant.
{
ASSERT_OK_AND_ASSIGN(std::shared_ptr<BlobFormatWriter> writer,
BlobFormatWriter::Create(
output_stream_, struct_type_, /*write_null_on_missing_file=*/true,
/*write_null_on_fetch_failure=*/false,
/*write_placeholder=*/false, io_error_fs, pool_));
ASSERT_OK(AddBatchOnce(writer, missing_array));
ASSERT_EQ(io_error_fs->OpenCallCount(), 0);
}
// Existing file that cannot be opened: a fetch failure, which this writer does not convert.
{
ASSERT_OK_AND_ASSIGN(std::shared_ptr<BlobFormatWriter> writer,
BlobFormatWriter::Create(
output_stream_, struct_type_, /*write_null_on_missing_file=*/true,
/*write_null_on_fetch_failure=*/false,
/*write_placeholder=*/false, io_error_fs, pool_));
ASSERT_NOK_WITH_MSG(AddBatchOnce(writer, existing_array), "mock io error");
}
// The same fetch failure, now converted to NULL.
{
ASSERT_OK_AND_ASSIGN(std::shared_ptr<BlobFormatWriter> writer,
BlobFormatWriter::Create(
output_stream_, struct_type_, /*write_null_on_missing_file=*/false,
/*write_null_on_fetch_failure=*/true,
/*write_placeholder=*/false, io_error_fs, pool_));
ASSERT_OK(AddBatchOnce(writer, existing_array));
}
// Missing file with only fetch-failure enabled: no existence check runs, so the file is
// opened and the failure is converted like any other fetch failure. The mock's count
// accumulates across cases, so compare against it.
{
const int64_t open_calls_before = io_error_fs->OpenCallCount();
ASSERT_OK_AND_ASSIGN(std::shared_ptr<BlobFormatWriter> writer,
BlobFormatWriter::Create(
output_stream_, struct_type_, /*write_null_on_missing_file=*/false,
/*write_null_on_fetch_failure=*/true,
/*write_placeholder=*/false, io_error_fs, pool_));
ASSERT_OK(AddBatchOnce(writer, missing_array));
ASSERT_EQ(io_error_fs->OpenCallCount(), open_calls_before + 1);
ASSERT_OK_AND_ASSIGN(
uint64_t fetch_failure_nulls,
writer->GetWriterMetrics()->GetCounter(BlobMetrics::WRITE_NULL_ON_FETCH_FAILURE_COUNT));
ASSERT_EQ(fetch_failure_nulls, 1);
}
// A file deleted between the check and the open is still a missing file: the failed open
// triggers one more check rather than being classified by its status. Without it the deletion
// would defeat write_null_on_missing_file, which does not convert a fetch failure. Each case
// needs its own file system, since the mock reports the file as present only on the first call.
{
auto vanishing_fs = std::make_shared<VanishingFileSystem>();
ASSERT_OK_AND_ASSIGN(std::shared_ptr<BlobFormatWriter> writer,
BlobFormatWriter::Create(
output_stream_, struct_type_, /*write_null_on_missing_file=*/true,
/*write_null_on_fetch_failure=*/false,
/*write_placeholder=*/false, vanishing_fs, pool_));
ASSERT_OK(AddBatchOnce(writer, existing_array));
// One check before the open and one after it.
ASSERT_EQ(vanishing_fs->ExistsCallCount(), 2);
ASSERT_OK_AND_ASSIGN(
uint64_t missing_nulls,
writer->GetWriterMetrics()->GetCounter(BlobMetrics::WRITE_NULL_ON_MISSING_FILE_COUNT));
ASSERT_EQ(missing_nulls, 1);
ASSERT_OK_AND_ASSIGN(
uint64_t fetch_failure_nulls,
writer->GetWriterMetrics()->GetCounter(BlobMetrics::WRITE_NULL_ON_FETCH_FAILURE_COUNT));
ASSERT_EQ(fetch_failure_nulls, 0);
}
// The same deletion with both options enabled: classified as missing rather than swallowed
// by fetch-failure.
{
auto vanishing_fs = std::make_shared<VanishingFileSystem>();
ASSERT_OK_AND_ASSIGN(std::shared_ptr<BlobFormatWriter> writer,
BlobFormatWriter::Create(
output_stream_, struct_type_, /*write_null_on_missing_file=*/true,
/*write_null_on_fetch_failure=*/true,
/*write_placeholder=*/false, vanishing_fs, pool_));
ASSERT_OK(AddBatchOnce(writer, existing_array));
ASSERT_EQ(vanishing_fs->ExistsCallCount(), 2);
ASSERT_OK_AND_ASSIGN(
uint64_t missing_nulls,
writer->GetWriterMetrics()->GetCounter(BlobMetrics::WRITE_NULL_ON_MISSING_FILE_COUNT));
ASSERT_EQ(missing_nulls, 1);
ASSERT_OK_AND_ASSIGN(
uint64_t fetch_failure_nulls,
writer->GetWriterMetrics()->GetCounter(BlobMetrics::WRITE_NULL_ON_FETCH_FAILURE_COUNT));
ASSERT_EQ(fetch_failure_nulls, 0);
}
// The same deletion with only fetch-failure enabled: existence is never consulted, and the
// failed open is converted like any other fetch failure.
{
auto vanishing_fs = std::make_shared<VanishingFileSystem>();
ASSERT_OK_AND_ASSIGN(std::shared_ptr<BlobFormatWriter> writer,
BlobFormatWriter::Create(
output_stream_, struct_type_, /*write_null_on_missing_file=*/false,
/*write_null_on_fetch_failure=*/true,
/*write_placeholder=*/false, vanishing_fs, pool_));
ASSERT_OK(AddBatchOnce(writer, existing_array));
ASSERT_EQ(vanishing_fs->ExistsCallCount(), 0);
}
// Neither option: existence is never consulted, and the open failure propagates as it is.
{
auto vanishing_fs = std::make_shared<VanishingFileSystem>();
ASSERT_OK_AND_ASSIGN(std::shared_ptr<BlobFormatWriter> writer,
BlobFormatWriter::Create(
output_stream_, struct_type_, /*write_null_on_missing_file=*/false,
/*write_null_on_fetch_failure=*/false,
/*write_placeholder=*/false, vanishing_fs, pool_));
ASSERT_NOK_WITH_MSG(AddBatchOnce(writer, existing_array), "mock io error");
ASSERT_EQ(vanishing_fs->ExistsCallCount(), 0);
}
}
TEST_F(BlobFormatWriterWriteNullTest, TestWriteNullOnInvalidDescriptor) {
// Descriptor detection only inspects version and magic, so a descriptor truncated after those
// passes detection and then fails to deserialize: a fetch failure, not a missing file.
ASSERT_OK_AND_ASSIGN(std::shared_ptr<Blob> blob,
Blob::FromPath(paimon::test::GetDataDir() + "/xxhash.data"));
PAIMON_UNIQUE_PTR<Bytes> descriptor = blob->ToDescriptor(pool_);
ASSERT_GT(descriptor->size(), 8);
std::string truncated(descriptor->data(), descriptor->size() - 8);
ASSERT_OK_AND_ASSIGN(bool is_descriptor,
BlobDescriptor::IsBlobDescriptor(truncated.data(), truncated.size()));
ASSERT_TRUE(is_descriptor);
ASSERT_OK_AND_ASSIGN(auto array, MakeBlobArrayFromBytes(truncated));
{
ASSERT_OK_AND_ASSIGN(std::shared_ptr<BlobFormatWriter> writer,
BlobFormatWriter::Create(
output_stream_, struct_type_, /*write_null_on_missing_file=*/true,
/*write_null_on_fetch_failure=*/false,
/*write_placeholder=*/false, file_system_, pool_));
ASSERT_NOK_WITH_MSG(AddBatchOnce(writer, array), "invalid blob descriptor");
}
{
ASSERT_OK_AND_ASSIGN(std::shared_ptr<BlobFormatWriter> writer,
BlobFormatWriter::Create(
output_stream_, struct_type_, /*write_null_on_missing_file=*/false,
/*write_null_on_fetch_failure=*/true,
/*write_placeholder=*/false, file_system_, pool_));
ASSERT_OK(AddBatchOnce(writer, array));
ASSERT_OK(writer->Flush());
ASSERT_OK(writer->Finish());
}
// Only the second case reaches the stream; the first fails before writing any byte.
ASSERT_OK_AND_ASSIGN(std::shared_ptr<arrow::StructArray> result_struct, ReadBackAsData());
ASSERT_EQ(result_struct->length(), 1);
ASSERT_TRUE(result_struct->field(0)->IsNull(0));
}
TEST_F(BlobFormatWriterWriteNullTest, TestWriteNullOnExistsCheckFailure) {
// An existence check that cannot answer leaves it unknown whether the file is there. With no
// fetch-failure handling to defer to, the write fails; otherwise the failed check is deferred
// to the open, whose own outcome decides.
ASSERT_OK_AND_ASSIGN(std::shared_ptr<Blob> blob,
Blob::FromPath(paimon::test::GetDataDir() + "/xxhash.data"));
ASSERT_OK_AND_ASSIGN(auto array, PrepareDescriptorArray(blob));
// Deferred check failure whose open succeeds: the blob is written as data, not as NULL.
// This case finishes the shared output stream, so it runs first and is read back below.
{
auto exists_fail_fs =
std::make_shared<ExistsFailFileSystem>(Status::IOError("mock exists error"));
ASSERT_OK_AND_ASSIGN(std::shared_ptr<BlobFormatWriter> writer,
BlobFormatWriter::Create(
output_stream_, struct_type_, /*write_null_on_missing_file=*/true,
/*write_null_on_fetch_failure=*/true,
/*write_placeholder=*/false, exists_fail_fs, pool_));
ASSERT_OK(AddBatchOnce(writer, array));
ASSERT_EQ(exists_fail_fs->ExistsCallCount(), 1);
ASSERT_OK(writer->Flush());
ASSERT_OK(writer->Finish());
ASSERT_OK_AND_ASSIGN(
uint64_t missing_nulls,
writer->GetWriterMetrics()->GetCounter(BlobMetrics::WRITE_NULL_ON_MISSING_FILE_COUNT));
ASSERT_EQ(missing_nulls, 0);
ASSERT_OK_AND_ASSIGN(
uint64_t fetch_failure_nulls,
writer->GetWriterMetrics()->GetCounter(BlobMetrics::WRITE_NULL_ON_FETCH_FAILURE_COUNT));
ASSERT_EQ(fetch_failure_nulls, 0);
}
ASSERT_OK_AND_ASSIGN(std::shared_ptr<arrow::StructArray> result_struct, ReadBackAsData());
ASSERT_EQ(result_struct->length(), 1);
ASSERT_FALSE(result_struct->field(0)->IsNull(0));
auto binary_array = checked_pointer_cast<arrow::LargeBinaryArray>(result_struct->field(0));
ASSERT_OK_AND_ASSIGN(auto expected_data, blob->ToData(file_system_, pool_));
ASSERT_EQ(binary_array->GetView(0),
std::string_view(expected_data->data(), expected_data->size()));
// With no fetch-failure handling to defer to, the check failure fails the write.
{
auto exists_fail_fs =
std::make_shared<ExistsFailFileSystem>(Status::IOError("mock exists error"));
ASSERT_OK_AND_ASSIGN(std::shared_ptr<BlobFormatWriter> writer,
BlobFormatWriter::Create(
output_stream_, struct_type_, /*write_null_on_missing_file=*/true,
/*write_null_on_fetch_failure=*/false,
/*write_placeholder=*/false, exists_fail_fs, pool_));
// The reported failure names the check and keeps the underlying status message.
Status check_status = AddBatchOnce(writer, array);
ASSERT_NOK_WITH_MSG(check_status, "failed to check existence of blob file");
ASSERT_NOK_WITH_MSG(check_status, "mock exists error");
}
// Deferred check failure whose open then fails: a fetch failure. The re-check after the
// failed open cannot answer either, so it falls through to the open failure.
{
auto exists_fail_fs = std::make_shared<ExistsFailFileSystem>(
Status::IOError("mock exists error"), Status::IOError("mock open error"));
ASSERT_OK_AND_ASSIGN(std::shared_ptr<BlobFormatWriter> writer,
BlobFormatWriter::Create(
output_stream_, struct_type_, /*write_null_on_missing_file=*/true,
/*write_null_on_fetch_failure=*/true,
/*write_placeholder=*/false, exists_fail_fs, pool_));
ASSERT_OK(AddBatchOnce(writer, array));
// One check before the open and one after it failed.
ASSERT_EQ(exists_fail_fs->ExistsCallCount(), 2);
ASSERT_OK_AND_ASSIGN(
uint64_t fetch_failure_nulls,
writer->GetWriterMetrics()->GetCounter(BlobMetrics::WRITE_NULL_ON_FETCH_FAILURE_COUNT));
ASSERT_EQ(fetch_failure_nulls, 1);
}
// With only write_null_on_fetch_failure, no existence check runs at all; the open succeeds
// and the blob is written as data. A separate output stream keeps the data bytes out of the
// already finished shared stream.
{
auto exists_fail_fs =
std::make_shared<ExistsFailFileSystem>(Status::IOError("mock exists error"));
ASSERT_OK_AND_ASSIGN(std::shared_ptr<OutputStream> side_stream,
file_system_->Create(dir_->Str() + "/side.blob", /*overwrite=*/true));
ASSERT_OK_AND_ASSIGN(std::shared_ptr<BlobFormatWriter> writer,
BlobFormatWriter::Create(
side_stream, struct_type_, /*write_null_on_missing_file=*/false,
/*write_null_on_fetch_failure=*/true,
/*write_placeholder=*/false, exists_fail_fs, pool_));
ASSERT_OK(AddBatchOnce(writer, array));
ASSERT_EQ(exists_fail_fs->ExistsCallCount(), 0);
ASSERT_OK_AND_ASSIGN(
uint64_t fetch_failure_nulls,
writer->GetWriterMetrics()->GetCounter(BlobMetrics::WRITE_NULL_ON_FETCH_FAILURE_COUNT));
ASSERT_EQ(fetch_failure_nulls, 0);
ASSERT_OK(side_stream->Flush());
ASSERT_OK(side_stream->Close());
}
}
TEST_P(BlobFormatWriterTest, TestAddBatchWithZeroLengthBlob) {
ASSERT_OK_AND_ASSIGN(std::shared_ptr<BlobFormatWriter> writer, CreateDefaultWriter());
// 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(int64_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);
}
/// Placeholder tests always feed the sentinel bytes of the placeholder write protocol, so
/// they do not depend on the blob_as_descriptor_ parameter and run once on the
/// non-parameterized fixture.
using BlobFormatWriterPlaceholderTest = BlobFormatWriterTestBase;
std::string PlaceholderSentinelBytes() {
return std::string(BlobDefs::PlaceholderSentinelView());
}
TEST_F(BlobFormatWriterPlaceholderTest, TestWritePlaceholderGoldenBytes) {
ASSERT_OK_AND_ASSIGN(std::shared_ptr<BlobFormatWriter> writer, CreatePlaceholderWriter());
// row 0: inline bytes "inline"; row 1: null; row 2: placeholder
ASSERT_OK_AND_ASSIGN(auto inline_array, MakeBlobArrayFromBytes("inline"));
ASSERT_OK(AddBatchOnce(writer, inline_array));
arrow::StructBuilder struct_builder(struct_type_, arrow::default_memory_pool(),
{std::make_shared<arrow::LargeBinaryBuilder>()});
auto blob_builder = checked_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_array;
ASSERT_TRUE(struct_builder.Finish(&null_array).ok());
ASSERT_OK(AddBatchOnce(writer, null_array));
ASSERT_OK_AND_ASSIGN(auto placeholder_array,
MakeBlobArrayFromBytes(PlaceholderSentinelBytes()));
ASSERT_OK(AddBatchOnce(writer, placeholder_array));
ASSERT_OK(writer->Flush());
ASSERT_OK(writer->Finish());
// Verify byte-level alignment with the Java writer (BlobFormatWriterTest
// testRawBlobGoldenBytes): null and placeholder rows occupy no data bytes; the index
// records [22, -1, -2] as zigzag varint deltas [0x2c, 0x2d, 0x01].
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(int64_t file_length, input_stream->Length());
ASSERT_EQ(file_length, 30);
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, 30);
std::vector<uint8_t> expected = {{// record 0: magic + "inline" + bin_length(22) + crc32
0xcf, 0x11, 0x4e, 0x58, 0x69, 0x6e, 0x6c, 0x69, 0x6e, 0x65,
0x16, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x29, 0x60,
0xc8, 0xe9,
// index of [22, -1, -2]
0x2c, 0x2d, 0x01,
// footer: index length + version
0x03, 0x00, 0x00, 0x00, 0x01}};
ASSERT_EQ(buffer, expected);
}
TEST_F(BlobFormatWriterPlaceholderTest, TestReadPlaceholderStrictAndAwareModes) {
ASSERT_OK_AND_ASSIGN(std::shared_ptr<BlobFormatWriter> writer, CreatePlaceholderWriter());
ASSERT_OK_AND_ASSIGN(auto inline_array, MakeBlobArrayFromBytes("inline"));
ASSERT_OK(AddBatchOnce(writer, inline_array));
ASSERT_OK_AND_ASSIGN(auto placeholder_array,
MakeBlobArrayFromBytes(PlaceholderSentinelBytes()));
ASSERT_OK(AddBatchOnce(writer, placeholder_array));
ASSERT_OK(writer->Flush());
ASSERT_OK(writer->Finish());
auto schema = arrow::schema(struct_type_->fields());
// default (strict) mode: reading a placeholder entry fails
{
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=*/false,
/*emit_placeholder_sentinel=*/false, pool_,
GetArrowPool(pool_)));
::ArrowSchema c_schema;
ASSERT_TRUE(arrow::ExportSchema(*schema, &c_schema).ok());
ASSERT_OK(reader->SetReadSchema(&c_schema, /*predicate=*/nullptr,
/*selection_bitmap=*/std::nullopt));
ASSERT_NOK_WITH_MSG(reader->NextBatch(), "placeholder");
}
// placeholder-aware mode: the entry is returned as the sentinel bytes
{
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=*/false,
/*emit_placeholder_sentinel=*/true, pool_,
GetArrowPool(pool_)));
::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(std::move(reader)));
auto concat_array = arrow::Concatenate(chunked_array->chunks()).ValueOrDie();
auto struct_array = checked_pointer_cast<arrow::StructArray>(concat_array);
ASSERT_EQ(struct_array->length(), 2);
auto binary_array = checked_pointer_cast<arrow::LargeBinaryArray>(struct_array->field(0));
ASSERT_EQ(binary_array->GetString(0), "inline");
ASSERT_FALSE(binary_array->IsNull(1));
ASSERT_EQ(binary_array->GetString(1), PlaceholderSentinelBytes());
}
// placeholder-aware descriptor mode also returns the sentinel bytes
{
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=*/true,
/*emit_placeholder_sentinel=*/true, pool_,
GetArrowPool(pool_)));
::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(std::move(reader)));
auto concat_array = arrow::Concatenate(chunked_array->chunks()).ValueOrDie();
auto struct_array = checked_pointer_cast<arrow::StructArray>(concat_array);
auto binary_array = checked_pointer_cast<arrow::LargeBinaryArray>(struct_array->field(0));
ASSERT_EQ(binary_array->GetString(1), PlaceholderSentinelBytes());
}
}
TEST_F(BlobFormatWriterPlaceholderTest, TestReadPlaceholderWithSelectionBitmap) {
ASSERT_OK_AND_ASSIGN(std::shared_ptr<BlobFormatWriter> writer, CreatePlaceholderWriter());
ASSERT_OK_AND_ASSIGN(auto array0, MakeBlobArrayFromBytes("first"));
ASSERT_OK(AddBatchOnce(writer, array0));
ASSERT_OK_AND_ASSIGN(auto array1, MakeBlobArrayFromBytes(PlaceholderSentinelBytes()));
ASSERT_OK(AddBatchOnce(writer, array1));
ASSERT_OK_AND_ASSIGN(auto array2, MakeBlobArrayFromBytes("third"));
ASSERT_OK(AddBatchOnce(writer, array2));
ASSERT_OK(writer->Flush());
ASSERT_OK(writer->Finish());
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=*/false,
/*emit_placeholder_sentinel=*/true, pool_,
GetArrowPool(pool_)));
auto schema = arrow::schema(struct_type_->fields());
::ArrowSchema c_schema;
ASSERT_TRUE(arrow::ExportSchema(*schema, &c_schema).ok());
RoaringBitmap32 selection;
selection.Add(1);
selection.Add(2);
ASSERT_OK(reader->SetReadSchema(&c_schema, /*predicate=*/nullptr, selection));
ASSERT_OK_AND_ASSIGN(auto chunked_array,
paimon::test::ReadResultCollector::CollectResult(std::move(reader)));
auto concat_array = arrow::Concatenate(chunked_array->chunks()).ValueOrDie();
auto struct_array = checked_pointer_cast<arrow::StructArray>(concat_array);
ASSERT_EQ(struct_array->length(), 2);
auto binary_array = checked_pointer_cast<arrow::LargeBinaryArray>(struct_array->field(0));
ASSERT_EQ(binary_array->GetString(0), PlaceholderSentinelBytes());
ASSERT_EQ(binary_array->GetString(1), "third");
}
TEST_F(BlobFormatWriterPlaceholderTest, TestSentinelBytesVerbatimWithoutPlaceholderMode) {
// outside placeholder mode a user blob whose bytes equal the sentinel is a normal value: it
// must be stored as a real entry (not persisted as bin_length -2) and read back unchanged
ASSERT_OK_AND_ASSIGN(std::shared_ptr<BlobFormatWriter> writer, CreateDefaultWriter());
ASSERT_OK_AND_ASSIGN(auto sentinel_array, MakeBlobArrayFromBytes(PlaceholderSentinelBytes()));
ASSERT_OK(AddBatchOnce(writer, sentinel_array));
ASSERT_OK(writer->Flush());
ASSERT_OK(writer->Finish());
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=*/false,
/*emit_placeholder_sentinel=*/false, pool_,
GetArrowPool(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(std::move(reader)));
auto concat_array = arrow::Concatenate(chunked_array->chunks()).ValueOrDie();
auto struct_array = checked_pointer_cast<arrow::StructArray>(concat_array);
ASSERT_EQ(struct_array->length(), 1);
auto binary_array = checked_pointer_cast<arrow::LargeBinaryArray>(struct_array->field(0));
ASSERT_FALSE(binary_array->IsNull(0));
ASSERT_EQ(binary_array->GetString(0), PlaceholderSentinelBytes());
}
TEST_F(BlobFormatWriterPlaceholderTest, TestSentinelPrefixedValueVerbatimInPlaceholderMode) {
// placeholders are identified by exact equality only: even in placeholder mode a real
// value that merely starts with the sentinel bytes is stored verbatim and read back
// unchanged in both strict and placeholder-aware modes
ASSERT_OK_AND_ASSIGN(std::shared_ptr<BlobFormatWriter> writer, CreatePlaceholderWriter());
std::string sentinel = PlaceholderSentinelBytes();
ASSERT_OK_AND_ASSIGN(auto doubled_sentinel_array, MakeBlobArrayFromBytes(sentinel + sentinel));
ASSERT_OK(AddBatchOnce(writer, doubled_sentinel_array));
ASSERT_OK_AND_ASSIGN(auto prefixed_array, MakeBlobArrayFromBytes(sentinel + "suffix"));
ASSERT_OK(AddBatchOnce(writer, prefixed_array));
ASSERT_OK(writer->Flush());
ASSERT_OK(writer->Finish());
auto schema = arrow::schema(struct_type_->fields());
for (bool emit_placeholder_sentinel : {false, true}) {
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=*/false, emit_placeholder_sentinel,
pool_, GetArrowPool(pool_)));
::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(std::move(reader)));
auto concat_array = arrow::Concatenate(chunked_array->chunks()).ValueOrDie();
auto struct_array = checked_pointer_cast<arrow::StructArray>(concat_array);
auto binary_array = checked_pointer_cast<arrow::LargeBinaryArray>(struct_array->field(0));
ASSERT_EQ(struct_array->length(), 2);
ASSERT_EQ(binary_array->GetString(0), sentinel + sentinel);
ASSERT_EQ(binary_array->GetString(1), sentinel + "suffix");
}
}
} // namespace paimon::blob::test