Skip to content

Commit 991db0b

Browse files
SteNicholasclaude
andcommitted
test(blob): add end-to-end test for write-null-on-fetch-failure with bad offset
Add TestWriteNullOnFetchFailure to blob_table_inte_test.cpp: with blob-write-null-on-fetch-failure=true, a descriptor whose offset points beyond the end of the target file (a fetch failure that is not a missing file) is written as a NULL blob element, the row itself is kept in both the data file and the .blob file, and the read path merges them back with the blob column NULL for that row. Introduce a CorruptDescriptorOffset test helper that rewrites the serialized BlobDescriptor of a single row to reference an out-of-range offset, reusing the TransformBlobFields framework. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 0bc4270 commit 991db0b

1 file changed

Lines changed: 99 additions & 0 deletions

File tree

test/inte/blob_table_inte_test.cpp

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -385,6 +385,36 @@ class BlobTableInteTest : public testing::Test, public ::testing::WithParamInter
385385
return fs.Delete(descriptor->Uri(), /*recursive=*/false);
386386
}
387387

388+
/// Rewrite the serialized BlobDescriptor at `row` of blob field `field_name` to reference
389+
/// an offset beyond the end of the target file, so that a subsequent table write observes
390+
/// a fetch failure that is not a missing file. `row` counts non-null rows.
391+
Result<std::shared_ptr<arrow::StructArray>> CorruptDescriptorOffset(
392+
const std::shared_ptr<arrow::StructArray>& desc_array, const std::string& field_name,
393+
int64_t row) const {
394+
int64_t current_row = 0;
395+
return TransformBlobFields(
396+
desc_array, {field_name},
397+
[&](const std::string_view& descriptor_bytes,
398+
arrow::LargeBinaryBuilder* builder) -> Status {
399+
if (current_row++ != row) {
400+
PAIMON_RETURN_NOT_OK_FROM_ARROW(
401+
builder->Append(descriptor_bytes.data(), descriptor_bytes.size()));
402+
return Status::OK();
403+
}
404+
PAIMON_ASSIGN_OR_RAISE(
405+
std::unique_ptr<BlobDescriptor> descriptor,
406+
BlobDescriptor::Deserialize(descriptor_bytes.data(), descriptor_bytes.size()));
407+
PAIMON_ASSIGN_OR_RAISE(
408+
std::unique_ptr<BlobDescriptor> corrupted,
409+
BlobDescriptor::Create(descriptor->Version(), descriptor->Uri(),
410+
/*offset=*/1 << 20, descriptor->Length()));
411+
auto corrupted_bytes = corrupted->Serialize(pool_);
412+
PAIMON_RETURN_NOT_OK_FROM_ARROW(
413+
builder->Append(corrupted_bytes->data(), corrupted_bytes->size()));
414+
return Status::OK();
415+
});
416+
}
417+
388418
struct BlobDescriptorPathRewrite {
389419
std::string table_path;
390420
std::vector<std::string> table_relative_blob_dirs;
@@ -667,6 +697,75 @@ TEST_P(BlobTableInteTest, TestMissingFileFailsWriteWhenWriteNullDisabled) {
667697
"not exists");
668698
}
669699

700+
TEST_P(BlobTableInteTest, TestWriteNullOnFetchFailure) {
701+
// blob-write-null-on-fetch-failure=true: a descriptor whose data cannot be fetched for a
702+
// reason other than a missing file (here: offset beyond the end of the file) becomes a
703+
// NULL blob element, while the row itself is still written and merged with the data file
704+
// on read.
705+
arrow::FieldVector fields = {arrow::field("f0", arrow::utf8()),
706+
arrow::field("f1", arrow::int32()),
707+
BlobUtils::ToArrowField("blob", true)};
708+
709+
std::map<std::string, std::string> options = {
710+
{Options::MANIFEST_FORMAT, "orc"},
711+
{Options::FILE_FORMAT, GetParam()},
712+
{Options::TARGET_FILE_SIZE, "700"},
713+
{Options::BUCKET, "-1"},
714+
{Options::ROW_TRACKING_ENABLED, "true"},
715+
{Options::DATA_EVOLUTION_ENABLED, "true"},
716+
{Options::BLOB_AS_DESCRIPTOR, "true"},
717+
{Options::BLOB_WRITE_NULL_ON_FETCH_FAILURE, "true"},
718+
{Options::FILE_SYSTEM, "local"}};
719+
CreateTable(fields, /*partition_keys=*/{}, options);
720+
std::string table_path = PathUtil::JoinPath(dir_->Str(), "foo.db/bar");
721+
722+
std::string raw_json = R"([
723+
["str_0", 0, "blob_data_0"],
724+
["str_1", 1, "blob_data_1"],
725+
["str_2", 2, "blob_data_2"]
726+
])";
727+
auto raw_array = std::dynamic_pointer_cast<arrow::StructArray>(
728+
arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_(fields), raw_json).ValueOrDie());
729+
ASSERT_OK_AND_ASSIGN(auto desc_array, ConvertRawBlobToDescriptor(raw_array, {"blob"}));
730+
731+
// Point row 1's descriptor at an offset beyond the end of its file so the write observes
732+
// a fetch failure that is not a missing file.
733+
ASSERT_OK_AND_ASSIGN(desc_array, CorruptDescriptorOffset(desc_array, "blob", /*row=*/1));
734+
735+
auto schema = arrow::schema(fields);
736+
ASSERT_OK_AND_ASSIGN(auto commit_msgs,
737+
WriteArray(table_path, {}, schema->field_names(), {desc_array}));
738+
ASSERT_OK(Commit(table_path, commit_msgs));
739+
740+
// Both the main data file and the .blob file keep the full row count; the read path
741+
// merges them back into rows where row 1's blob is NULL.
742+
ASSERT_OK_AND_ASSIGN(auto plan, ScanTable(table_path));
743+
VerifyDataFileMetas(plan, /*expected_file_count=*/2, /*expected_row_counts=*/{3, 3},
744+
/*expected_min_seqs=*/{1, 1}, /*expected_max_seqs=*/{1, 1},
745+
/*expected_first_row_ids=*/{0, 0},
746+
/*expected_write_cols=*/
747+
{std::vector<std::string>{"f0", "f1"}, std::vector<std::string>{"blob"}});
748+
749+
ASSERT_OK_AND_ASSIGN(auto result, ReadTable(table_path, schema->field_names(), plan));
750+
ASSERT_TRUE(result.chunked_array);
751+
auto read_concat = arrow::Concatenate(result.chunked_array->chunks()).ValueOrDie();
752+
auto read_struct = std::dynamic_pointer_cast<arrow::StructArray>(read_concat);
753+
ASSERT_OK_AND_ASSIGN(auto resolved, ConvertDescriptorToRawBlob(read_struct, {"blob"}));
754+
755+
std::string expected_json = R"([
756+
["str_0", 0, "blob_data_0"],
757+
["str_1", 1, null],
758+
["str_2", 2, "blob_data_2"]
759+
])";
760+
auto expected_array = std::dynamic_pointer_cast<arrow::StructArray>(
761+
arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_(fields), expected_json)
762+
.ValueOrDie());
763+
ASSERT_OK_AND_ASSIGN(auto expected_with_rk, PrependRowKindColumn(expected_array));
764+
ASSERT_TRUE(resolved->Equals(expected_with_rk))
765+
<< "result:" << resolved->ToString() << std::endl
766+
<< "expected:" << expected_with_rk->ToString();
767+
}
768+
670769
TEST_P(BlobTableInteTest, TestWriteNullOnFetchFailureKeepsMissingFileFailing) {
671770
// blob-write-null-on-fetch-failure only converts non-NotExist open failures; a missing
672771
// descriptor file still fails the write when only this option is enabled.

0 commit comments

Comments
 (0)