@@ -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+
670769TEST_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