This is an automated email from the ASF dual-hosted git repository.

JingsongLi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git


The following commit(s) were added to refs/heads/master by this push:
     new 9ea8120590 [core][flink] Add blob-write-null-on-fetch-failure for BLOB 
descriptor writes. (#8412)
9ea8120590 is described below

commit 9ea8120590944a7a31af0a046ad7ec0db0ae5021
Author: wangwj <[email protected]>
AuthorDate: Sat Jul 4 22:29:58 2026 +0800

    [core][flink] Add blob-write-null-on-fetch-failure for BLOB descriptor 
writes. (#8412)
    
    Add `blob-write-null-on-fetch-failure` for Flink BLOB descriptor writes,
    mirroring the scope of `blob-write-null-on-missing-file`.
---
 docs/generated/core_configuration.html             |   6 +
 .../main/java/org/apache/paimon/CoreOptions.java   |  15 +
 .../org/apache/paimon/rest/HttpClientUtils.java    |  73 +++-
 .../apache/paimon/rest/HttpClientUtilsTest.java    |  93 +++++
 .../append/DedicatedFormatRollingFileWriter.java   |   4 +-
 .../paimon/append/MultipleBlobFileWriter.java      |   6 +-
 .../apache/paimon/operation/BlobFileContext.java   |  24 +-
 .../org/apache/paimon/flink/FlinkRowWrapper.java   |  64 +++-
 .../apache/paimon/flink/sink/FlinkSinkBuilder.java |  15 +-
 .../org/apache/paimon/flink/BlobTableITCase.java   | 143 ++++++++
 .../apache/paimon/flink/FlinkRowWrapperTest.java   |  79 ++++-
 .../apache/paimon/format/blob/BlobFileFormat.java  |  13 +-
 .../paimon/format/blob/BlobFormatWriter.java       | 120 ++++++-
 .../paimon/format/blob/BlobFormatWriterTest.java   | 377 +++++++++++++++++++++
 14 files changed, 1002 insertions(+), 30 deletions(-)

diff --git a/docs/generated/core_configuration.html 
b/docs/generated/core_configuration.html
index e31bd111b1..2bb0099a49 100644
--- a/docs/generated/core_configuration.html
+++ b/docs/generated/core_configuration.html
@@ -92,6 +92,12 @@ under the License.
             <td>Boolean</td>
             <td>Whether to resolve blob-view-field values from upstream tables 
at read time. Set to false to preserve BlobViewStruct references when 
forwarding blob view values to another blob-view table.</td>
         </tr>
+        <tr>
+            <td><h5>blob-write-null-on-fetch-failure</h5></td>
+            <td style="word-wrap: break-word;">false</td>
+            <td>Boolean</td>
+            <td>Whether to write NULL for a descriptor BLOB value when the 
referenced resource cannot be fetched during Flink writes (e.g. invalid URI or 
HTTP errors other than 404). HTTP 404 is handled by 
'blob-write-null-on-missing-file'. When false, the write fails when the 
descriptor is read.</td>
+        </tr>
         <tr>
             <td><h5>blob-write-null-on-missing-file</h5></td>
             <td style="word-wrap: break-word;">false</td>
diff --git a/paimon-api/src/main/java/org/apache/paimon/CoreOptions.java 
b/paimon-api/src/main/java/org/apache/paimon/CoreOptions.java
index 23721c1f44..0302d10995 100644
--- a/paimon-api/src/main/java/org/apache/paimon/CoreOptions.java
+++ b/paimon-api/src/main/java/org/apache/paimon/CoreOptions.java
@@ -2556,6 +2556,17 @@ public class CoreOptions implements Serializable {
                                     + "writes. When false, the write fails 
when the descriptor is "
                                     + "read.");
 
+    public static final ConfigOption<Boolean> BLOB_WRITE_NULL_ON_FETCH_FAILURE 
=
+            key("blob-write-null-on-fetch-failure")
+                    .booleanType()
+                    .defaultValue(false)
+                    .withDescription(
+                            "Whether to write NULL for a descriptor BLOB value 
when the "
+                                    + "referenced resource cannot be fetched 
during Flink writes "
+                                    + "(e.g. invalid URI or HTTP errors other 
than 404). "
+                                    + "HTTP 404 is handled by 
'blob-write-null-on-missing-file'. "
+                                    + "When false, the write fails when the 
descriptor is read.");
+
     public static final ConfigOption<Boolean> COMMIT_DISCARD_DUPLICATE_FILES =
             key("commit.discard-duplicate-files")
                     .booleanType()
@@ -4133,6 +4144,10 @@ public class CoreOptions implements Serializable {
         return options.get(BLOB_WRITE_NULL_ON_MISSING_FILE);
     }
 
+    public boolean blobWriteNullOnFetchFailure() {
+        return options.get(BLOB_WRITE_NULL_ON_FETCH_FAILURE);
+    }
+
     public boolean postponeBatchWriteFixedBucket() {
         return options.get(POSTPONE_BATCH_WRITE_FIXED_BUCKET);
     }
diff --git 
a/paimon-api/src/main/java/org/apache/paimon/rest/HttpClientUtils.java 
b/paimon-api/src/main/java/org/apache/paimon/rest/HttpClientUtils.java
index f5a4a1ec0d..f54ef7044d 100644
--- a/paimon-api/src/main/java/org/apache/paimon/rest/HttpClientUtils.java
+++ b/paimon-api/src/main/java/org/apache/paimon/rest/HttpClientUtils.java
@@ -88,8 +88,13 @@ public class HttpClientUtils {
     public static InputStream getAsInputStream(String uri) throws IOException {
         HttpGet httpGet = new HttpGet(uri);
         CloseableHttpResponse response = DEFAULT_HTTP_CLIENT.execute(httpGet);
-        if (response.getCode() != 200) {
-            throw new RuntimeException("HTTP error code: " + 
response.getCode());
+        int statusCode = response.getCode();
+        if (statusCode != HttpStatus.SC_OK) {
+            try {
+                throw httpError(statusCode);
+            } finally {
+                response.close();
+            }
         }
         return response.getEntity().getContent();
     }
@@ -98,7 +103,7 @@ public class HttpClientUtils {
      * Checks whether an HTTP resource exists. HEAD is attempted first; when 
HEAD does not return
      * 200, a lightweight GET with {@code Range: bytes=0-0} is used to verify 
readability. This
      * avoids treating signed or GET-only URLs as missing when HEAD is 
rejected or returns a
-     * different status than GET. HTTP 416 from the range probe indicates a 
zero-length resource.
+     * different status than GET.
      */
     public static boolean exists(String uri) throws IOException {
         int headStatusCode = headStatusCode(uri);
@@ -118,6 +123,64 @@ public class HttpClientUtils {
                 "Unexpected HTTP status code: " + rangeStatusCode + " for uri: 
" + uri);
     }
 
+    public static boolean isNotFoundError(Throwable throwable) {
+        Integer statusCode = getHttpStatusCode(throwable);
+        return statusCode != null && statusCode == HttpStatus.SC_NOT_FOUND;
+    }
+
+    public static boolean isInvalidUriException(Throwable throwable) {
+        Throwable current = throwable;
+        while (current != null) {
+            if (current instanceof java.net.URISyntaxException) {
+                return true;
+            }
+            if (current instanceof IllegalArgumentException
+                    && current.getMessage() != null
+                    && current.getMessage().contains("Illegal character")) {
+                return true;
+            }
+            current = current.getCause();
+        }
+        return false;
+    }
+
+    public static Integer getHttpStatusCode(Throwable throwable) {
+        Throwable current = throwable;
+        while (current != null) {
+            if (current.getMessage() != null) {
+                Integer statusCode = parseHttpStatusCode(current.getMessage());
+                if (statusCode != null) {
+                    return statusCode;
+                }
+            }
+            current = current.getCause();
+        }
+        return null;
+    }
+
+    private static Integer parseHttpStatusCode(String message) {
+        if (message.startsWith("HTTP error code: ")) {
+            return parseStatusCodeSuffix(message.substring("HTTP error code: 
".length()));
+        }
+        if (message.startsWith("Unexpected HTTP status code: ")) {
+            int end = message.indexOf(' ', "Unexpected HTTP status code: 
".length());
+            String statusText =
+                    end < 0
+                            ? message.substring("Unexpected HTTP status code: 
".length())
+                            : message.substring("Unexpected HTTP status code: 
".length(), end);
+            return parseStatusCodeSuffix(statusText);
+        }
+        return null;
+    }
+
+    private static Integer parseStatusCodeSuffix(String statusText) {
+        try {
+            return Integer.parseInt(statusText.trim());
+        } catch (NumberFormatException e) {
+            return null;
+        }
+    }
+
     private static int headStatusCode(String uri) throws IOException {
         HttpHead httpHead = new HttpHead(uri);
         try (CloseableHttpResponse response = 
DEFAULT_HTTP_CLIENT.execute(httpHead)) {
@@ -132,4 +195,8 @@ public class HttpClientUtils {
             return response.getCode();
         }
     }
+
+    private static RuntimeException httpError(int statusCode) {
+        return new RuntimeException("HTTP error code: " + statusCode);
+    }
 }
diff --git 
a/paimon-api/src/test/java/org/apache/paimon/rest/HttpClientUtilsTest.java 
b/paimon-api/src/test/java/org/apache/paimon/rest/HttpClientUtilsTest.java
index 064ebe232f..d92cb2b501 100644
--- a/paimon-api/src/test/java/org/apache/paimon/rest/HttpClientUtilsTest.java
+++ b/paimon-api/src/test/java/org/apache/paimon/rest/HttpClientUtilsTest.java
@@ -26,10 +26,12 @@ import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 
 import java.io.IOException;
+import java.io.InputStream;
 import java.io.OutputStream;
 import java.net.InetSocketAddress;
 
 import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
 
 /** Tests for {@link HttpClientUtils}. */
 public class HttpClientUtilsTest {
@@ -163,6 +165,97 @@ public class HttpClientUtilsTest {
         assertThat(HttpClientUtils.exists(url("/head-404-get-404"))).isFalse();
     }
 
+    @Test
+    public void testGetAsInputStreamThrowsForNotFound() {
+        registerHandler(
+                "/get-missing",
+                exchange -> {
+                    respond(exchange, 404, new byte[0]);
+                });
+
+        assertThatThrownBy(() -> 
HttpClientUtils.getAsInputStream(url("/get-missing")))
+                .isInstanceOf(RuntimeException.class)
+                .hasMessage("HTTP error code: 404");
+    }
+
+    @Test
+    public void testGetAsInputStreamDoesNotLeakConnectionsOnRepeatedNotFound() 
throws Exception {
+        registerHandler(
+                "/missing",
+                exchange -> {
+                    respond(exchange, 404, new byte[0]);
+                });
+        registerHandler(
+                "/ok",
+                exchange -> {
+                    respond(exchange, 200, "x".getBytes());
+                });
+
+        for (int i = 0; i < 120; i++) {
+            assertThatThrownBy(() -> 
HttpClientUtils.getAsInputStream(url("/missing")))
+                    .isInstanceOf(RuntimeException.class)
+                    .hasMessage("HTTP error code: 404");
+        }
+
+        try (InputStream in = HttpClientUtils.getAsInputStream(url("/ok"))) {
+            assertThat(in.read()).isEqualTo('x');
+        }
+    }
+
+    @Test
+    public void testIsNotFoundError() {
+        RuntimeException exception =
+                new RuntimeException("wrapper", new RuntimeException("HTTP 
error code: 404"));
+        assertThat(HttpClientUtils.isNotFoundError(exception)).isTrue();
+        assertThat(HttpClientUtils.isNotFoundError(new RuntimeException("HTTP 
error code: 500")))
+                .isFalse();
+    }
+
+    @Test
+    public void testGetHttpStatusCodeFromUnexpectedStatusIOException() {
+        IOException exception =
+                new IOException("Unexpected HTTP status code: 400 for uri: 
http://127.0.0.1/test";);
+        
assertThat(HttpClientUtils.getHttpStatusCode(exception)).isEqualTo(400);
+    }
+
+    @Test
+    public void testIsInvalidUriException() {
+        assertThat(
+                        HttpClientUtils.isInvalidUriException(
+                                new IllegalArgumentException("Illegal 
character in path")))
+                .isTrue();
+        assertThat(
+                        HttpClientUtils.isInvalidUriException(
+                                new RuntimeException("HTTP error code: 404")))
+                .isFalse();
+    }
+
+    @Test
+    public void testExistsThrowsForBadRequest() {
+        registerHandler(
+                "/bad-request",
+                exchange -> {
+                    respond(exchange, 400, new byte[0]);
+                });
+
+        assertThatThrownBy(() -> HttpClientUtils.exists(url("/bad-request")))
+                .isInstanceOf(IOException.class)
+                .hasMessageContaining("Unexpected HTTP status code: 400");
+    }
+
+    @Test
+    public void testExistsThrowsForRateLimitOnHead() {
+        registerHandler(
+                "/rate-limit",
+                exchange -> {
+                    respond(exchange, 420, new byte[0]);
+                });
+
+        assertThatThrownBy(() -> HttpClientUtils.exists(url("/rate-limit")))
+                .isInstanceOf(IOException.class)
+                .hasMessageContaining("Unexpected HTTP status code: 420");
+    }
+
     private void registerHandler(String path, HttpHandler handler) {
         server.createContext(path, handler);
     }
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/append/DedicatedFormatRollingFileWriter.java
 
b/paimon-core/src/main/java/org/apache/paimon/append/DedicatedFormatRollingFileWriter.java
index 870cb2dd35..e9ef1e15ee 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/append/DedicatedFormatRollingFileWriter.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/append/DedicatedFormatRollingFileWriter.java
@@ -198,7 +198,9 @@ public class DedicatedFormatRollingFileWriter
                                     statsDenseStore,
                                     blobTargetFileSize,
                                     context.blobConsumer(),
-                                    context.blobInlineFields());
+                                    context.blobInlineFields(),
+                                    context.writeNullOnMissingFile(),
+                                    context.writeNullOnFetchFailure());
         } else {
             this.blobWriterFactory = null;
         }
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/append/MultipleBlobFileWriter.java
 
b/paimon-core/src/main/java/org/apache/paimon/append/MultipleBlobFileWriter.java
index 045af6ff75..b8d2d1e6c5 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/append/MultipleBlobFileWriter.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/append/MultipleBlobFileWriter.java
@@ -63,12 +63,16 @@ public class MultipleBlobFileWriter implements Closeable {
             boolean statsDenseStore,
             long targetFileSize,
             @Nullable BlobConsumer blobConsumer,
-            Set<String> blobInlineFields) {
+            Set<String> blobInlineFields,
+            boolean writeNullOnMissingFile,
+            boolean writeNullOnFetchFailure) {
         RowType blobRowType = new RowType(fieldsInBlobFile(writeSchema, 
blobInlineFields));
         this.blobWriters = new ArrayList<>();
         for (String blobFieldName : blobRowType.getFieldNames()) {
             BlobFileFormat blobFileFormat = new BlobFileFormat();
             blobFileFormat.setWriteConsumer(blobConsumer);
+            blobFileFormat.setWriteNullOnMissingFile(writeNullOnMissingFile);
+            blobFileFormat.setWriteNullOnFetchFailure(writeNullOnFetchFailure);
             blobWriters.add(
                     new BlobProjectedFileWriter(
                             () ->
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/operation/BlobFileContext.java 
b/paimon-core/src/main/java/org/apache/paimon/operation/BlobFileContext.java
index 8e98ef3e8a..dd475da637 100644
--- a/paimon-core/src/main/java/org/apache/paimon/operation/BlobFileContext.java
+++ b/paimon-core/src/main/java/org/apache/paimon/operation/BlobFileContext.java
@@ -35,12 +35,20 @@ public class BlobFileContext {
 
     private final Set<String> blobDescriptorFields;
     private final Set<String> blobInlineFields;
+    private final boolean writeNullOnMissingFile;
+    private final boolean writeNullOnFetchFailure;
 
     private @Nullable BlobConsumer blobConsumer;
 
-    private BlobFileContext(Set<String> blobDescriptorFields, Set<String> 
blobInlineFields) {
+    private BlobFileContext(
+            Set<String> blobDescriptorFields,
+            Set<String> blobInlineFields,
+            boolean writeNullOnMissingFile,
+            boolean writeNullOnFetchFailure) {
         this.blobDescriptorFields = blobDescriptorFields;
         this.blobInlineFields = blobInlineFields;
+        this.writeNullOnMissingFile = writeNullOnMissingFile;
+        this.writeNullOnFetchFailure = writeNullOnFetchFailure;
     }
 
     @Nullable
@@ -61,7 +69,11 @@ public class BlobFileContext {
         if (!requireBlobFile) {
             return null;
         }
-        return new BlobFileContext(descriptorFields, inlineFields);
+        return new BlobFileContext(
+                descriptorFields,
+                inlineFields,
+                options.blobWriteNullOnMissingFile(),
+                options.blobWriteNullOnFetchFailure());
     }
 
     public BlobFileContext withBlobConsumer(BlobConsumer blobConsumer) {
@@ -88,4 +100,12 @@ public class BlobFileContext {
     public BlobConsumer blobConsumer() {
         return blobConsumer;
     }
+
+    public boolean writeNullOnMissingFile() {
+        return writeNullOnMissingFile;
+    }
+
+    public boolean writeNullOnFetchFailure() {
+        return writeNullOnFetchFailure;
+    }
 }
diff --git 
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/FlinkRowWrapper.java
 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/FlinkRowWrapper.java
index cd9d56ee65..3838d4f4ee 100644
--- 
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/FlinkRowWrapper.java
+++ 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/FlinkRowWrapper.java
@@ -30,6 +30,7 @@ import org.apache.paimon.data.InternalVector;
 import org.apache.paimon.data.Timestamp;
 import org.apache.paimon.data.variant.GenericVariant;
 import org.apache.paimon.data.variant.Variant;
+import org.apache.paimon.rest.HttpClientUtils;
 import org.apache.paimon.types.DataTypeRoot;
 import org.apache.paimon.types.RowKind;
 import org.apache.paimon.utils.UriReaderFactory;
@@ -57,6 +58,7 @@ public class FlinkRowWrapper implements InternalRow {
     private final org.apache.flink.table.data.RowData row;
     private final UriReaderFactory uriReaderFactory;
     private final boolean checkBlobDescriptorExists;
+    private final boolean writeNullOnFetchFailure;
     private final Set<Integer> blobFields;
 
     public FlinkRowWrapper(org.apache.flink.table.data.RowData row) {
@@ -71,17 +73,40 @@ public class FlinkRowWrapper implements InternalRow {
             org.apache.flink.table.data.RowData row,
             CatalogContext catalogContext,
             boolean checkBlobDescriptorExists) {
-        this(row, catalogContext, checkBlobDescriptorExists, 
Collections.emptySet());
+        this(row, catalogContext, checkBlobDescriptorExists, false, 
Collections.emptySet());
     }
 
     public FlinkRowWrapper(
             org.apache.flink.table.data.RowData row,
             CatalogContext catalogContext,
             boolean checkBlobDescriptorExists,
+            boolean writeNullOnFetchFailure) {
+        this(
+                row,
+                catalogContext,
+                checkBlobDescriptorExists,
+                writeNullOnFetchFailure,
+                Collections.emptySet());
+    }
+
+    public FlinkRowWrapper(
+            org.apache.flink.table.data.RowData row,
+            CatalogContext catalogContext,
+            boolean checkBlobDescriptorExists,
+            Set<Integer> blobFields) {
+        this(row, catalogContext, checkBlobDescriptorExists, false, 
blobFields);
+    }
+
+    public FlinkRowWrapper(
+            org.apache.flink.table.data.RowData row,
+            CatalogContext catalogContext,
+            boolean checkBlobDescriptorExists,
+            boolean writeNullOnFetchFailure,
             Set<Integer> blobFields) {
         this.row = row;
         this.uriReaderFactory = new UriReaderFactory(catalogContext);
         this.checkBlobDescriptorExists = checkBlobDescriptorExists;
+        this.writeNullOnFetchFailure = writeNullOnFetchFailure;
         this.blobFields = blobFields;
     }
 
@@ -204,13 +229,13 @@ public class FlinkRowWrapper implements InternalRow {
         try {
             boolean exists = uriReaderFactory.exists(descriptor.uri());
             if (!exists) {
-                LOG.warn(
-                        "Blob descriptor file {} does not exist, returning 
NULL for BLOB field at position {}.",
-                        descriptor.uri(),
-                        pos);
+                logMissingDescriptor(pos, descriptor);
             }
             return exists;
         } catch (IOException e) {
+            if (deferExistsCheckFailure(e)) {
+                return true;
+            }
             LOG.warn(
                     "Failed to check blob descriptor file {} for BLOB field at 
position {}.",
                     descriptor.uri(),
@@ -218,6 +243,9 @@ public class FlinkRowWrapper implements InternalRow {
                     e);
             throw new RuntimeException(e);
         } catch (RuntimeException e) {
+            if (deferExistsCheckFailure(e)) {
+                return true;
+            }
             LOG.warn(
                     "Failed to check blob descriptor file {} for BLOB field at 
position {}.",
                     descriptor.uri(),
@@ -227,6 +255,32 @@ public class FlinkRowWrapper implements InternalRow {
         }
     }
 
+    private void logMissingDescriptor(int pos, BlobDescriptor descriptor) {
+        if (isHttpUri(descriptor.uri())) {
+            LOG.warn(
+                    "Blob descriptor file {} returned HTTP 404, returning NULL 
for BLOB field at position {}.",
+                    descriptor.uri(),
+                    pos);
+        } else {
+            LOG.warn(
+                    "Blob descriptor file {} does not exist, returning NULL 
for BLOB field at position {}.",
+                    descriptor.uri(),
+                    pos);
+        }
+    }
+
+    private static boolean isHttpUri(String uri) {
+        return uri.startsWith("http://";) || uri.startsWith("https://";);
+    }
+
+    /**
+     * When fetch-failure null write is enabled, non-404 errors during exists 
pre-check are deferred
+     * to the writer fetch path, so they can be logged and written as NULL.
+     */
+    private boolean deferExistsCheckFailure(Throwable failure) {
+        return writeNullOnFetchFailure && 
!HttpClientUtils.isNotFoundError(failure);
+    }
+
     @Override
     public InternalArray getArray(int pos) {
         return new FlinkArrayWrapper(row.getArray(pos));
diff --git 
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/sink/FlinkSinkBuilder.java
 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/sink/FlinkSinkBuilder.java
index a3d0dd3b90..c381864747 100644
--- 
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/sink/FlinkSinkBuilder.java
+++ 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/sink/FlinkSinkBuilder.java
@@ -223,7 +223,8 @@ public class FlinkSinkBuilder {
                         this.input,
                         table.rowType(),
                         contextForDescriptor,
-                        table.coreOptions().blobWriteNullOnMissingFile());
+                        table.coreOptions().blobWriteNullOnMissingFile(),
+                        table.coreOptions().blobWriteNullOnFetchFailure());
         if (table.coreOptions().localMergeEnabled() && 
table.schema().primaryKeys().size() > 0) {
             SingleOutputStreamOperator<InternalRow> newInput =
                     input.forward()
@@ -256,7 +257,7 @@ public class FlinkSinkBuilder {
             DataStream<RowData> input,
             org.apache.paimon.types.RowType rowType,
             CatalogContext catalogContext) {
-        return mapToInternalRow(input, rowType, catalogContext, false);
+        return mapToInternalRow(input, rowType, catalogContext, false, false);
     }
 
     public static DataStream<InternalRow> mapToInternalRow(
@@ -264,6 +265,15 @@ public class FlinkSinkBuilder {
             org.apache.paimon.types.RowType rowType,
             CatalogContext catalogContext,
             boolean checkBlobDescriptorExists) {
+        return mapToInternalRow(input, rowType, catalogContext, 
checkBlobDescriptorExists, false);
+    }
+
+    public static DataStream<InternalRow> mapToInternalRow(
+            DataStream<RowData> input,
+            org.apache.paimon.types.RowType rowType,
+            CatalogContext catalogContext,
+            boolean checkBlobDescriptorExists,
+            boolean writeNullOnFetchFailure) {
         Set<Integer> blobFields =
                 checkBlobDescriptorExists
                         ? FlinkRowWrapper.blobFieldIndexes(rowType)
@@ -276,6 +286,7 @@ public class FlinkSinkBuilder {
                                                         r,
                                                         catalogContext,
                                                         
checkBlobDescriptorExists,
+                                                        
writeNullOnFetchFailure,
                                                         blobFields))
                         .returns(
                                 
org.apache.paimon.flink.utils.InternalTypeInfo.fromRowType(
diff --git 
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/BlobTableITCase.java
 
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/BlobTableITCase.java
index 8cb4e1fbd4..d09ccedb16 100644
--- 
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/BlobTableITCase.java
+++ 
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/BlobTableITCase.java
@@ -537,6 +537,149 @@ public class BlobTableITCase extends CatalogITCaseBase {
         }
     }
 
+    @Test
+    public void testWriteFetchFailureDescriptorWritesNull() throws Exception {
+        tEnv.executeSql(
+                "CREATE TABLE fetch_failure_blob_table (id INT, data STRING, 
picture BYTES)"
+                        + " WITH ('row-tracking.enabled'='true',"
+                        + " 'data-evolution.enabled'='true',"
+                        + " 'blob-field'='picture',"
+                        + " 'blob-as-descriptor'='true',"
+                        + " 'blob-write-null-on-fetch-failure'='true')");
+
+        batchSql(
+                "INSERT INTO fetch_failure_blob_table VALUES"
+                        + " (1, 'invalid-uri', 
sys.path_to_descriptor('https://img.alicdn.com/imgextra/##1304008055350781673'))");
+
+        List<Row> result = batchSql("SELECT * FROM fetch_failure_blob_table");
+        assertThat(result).hasSize(1);
+        assertThat(result.get(0).getField(0)).isEqualTo(1);
+        assertThat(result.get(0).getField(1)).isEqualTo("invalid-uri");
+        assertThat(result.get(0).getField(2)).isNull();
+    }
+
+    @Test
+    public void testWriteHttpBadRequestWritesNullWithFetchFailure() throws 
Exception {
+        TestHttpWebServer httpServer = new 
TestHttpWebServer("/bad_request_blob");
+        httpServer.start();
+        try {
+            String httpUrl = httpServer.getBaseUrl();
+            httpServer.enqueueResponse("", 400);
+            httpServer.enqueueResponse("", 400);
+
+            tEnv.executeSql(
+                    "CREATE TABLE bad_request_blob_table (id INT, data STRING, 
picture BYTES)"
+                            + " WITH ('row-tracking.enabled'='true',"
+                            + " 'data-evolution.enabled'='true',"
+                            + " 'blob-field'='picture',"
+                            + " 'blob-as-descriptor'='true',"
+                            + " 'blob-write-null-on-fetch-failure'='true')");
+
+            batchSql(
+                    "INSERT INTO bad_request_blob_table VALUES"
+                            + " (1, 'bad-request', sys.path_to_descriptor('"
+                            + httpUrl
+                            + "'))");
+
+            List<Row> result = batchSql("SELECT * FROM 
bad_request_blob_table");
+            assertThat(result).hasSize(1);
+            assertThat(result.get(0).getField(0)).isEqualTo(1);
+            assertThat(result.get(0).getField(1)).isEqualTo("bad-request");
+            assertThat(result.get(0).getField(2)).isNull();
+        } finally {
+            httpServer.stop();
+        }
+    }
+
+    @Test
+    public void testWriteHttpRateLimitWritesNullWithFetchFailure() throws 
Exception {
+        TestHttpWebServer httpServer = new 
TestHttpWebServer("/rate_limit_blob");
+        httpServer.start();
+        try {
+            String httpUrl = httpServer.getBaseUrl();
+            httpServer.enqueueResponse("", 420);
+            httpServer.enqueueResponse("", 420);
+
+            tEnv.executeSql(
+                    "CREATE TABLE rate_limit_blob_table (id INT, data STRING, 
picture BYTES)"
+                            + " WITH ('row-tracking.enabled'='true',"
+                            + " 'data-evolution.enabled'='true',"
+                            + " 'blob-field'='picture',"
+                            + " 'blob-as-descriptor'='true',"
+                            + " 'blob-write-null-on-fetch-failure'='true')");
+
+            batchSql(
+                    "INSERT INTO rate_limit_blob_table VALUES"
+                            + " (1, 'rate-limit', sys.path_to_descriptor('"
+                            + httpUrl
+                            + "'))");
+
+            List<Row> result = batchSql("SELECT * FROM rate_limit_blob_table");
+            assertThat(result).hasSize(1);
+            assertThat(result.get(0).getField(0)).isEqualTo(1);
+            assertThat(result.get(0).getField(1)).isEqualTo("rate-limit");
+            assertThat(result.get(0).getField(2)).isNull();
+        } finally {
+            httpServer.stop();
+        }
+    }
+
+    @Test
+    public void testWriteInvalidUriWritesNullWithMissingFileAndFetchFailure() 
throws Exception {
+        tEnv.executeSql(
+                "CREATE TABLE combined_null_blob_table (id INT, data STRING, 
picture BYTES)"
+                        + " WITH ('row-tracking.enabled'='true',"
+                        + " 'data-evolution.enabled'='true',"
+                        + " 'blob-field'='picture',"
+                        + " 'blob-as-descriptor'='true',"
+                        + " 'blob-write-null-on-missing-file'='true',"
+                        + " 'blob-write-null-on-fetch-failure'='true')");
+
+        batchSql(
+                "INSERT INTO combined_null_blob_table VALUES"
+                        + " (1, 'invalid-uri', 
sys.path_to_descriptor('https://img.alicdn.com/imgextra/##1304008055350781673'))");
+
+        List<Row> result = batchSql("SELECT * FROM combined_null_blob_table");
+        assertThat(result).hasSize(1);
+        assertThat(result.get(0).getField(0)).isEqualTo(1);
+        assertThat(result.get(0).getField(1)).isEqualTo("invalid-uri");
+        assertThat(result.get(0).getField(2)).isNull();
+    }
+
+    @Test
+    public void 
testWriteHttpBadRequestWritesNullWithMissingFileAndFetchFailure() throws 
Exception {
+        TestHttpWebServer httpServer = new 
TestHttpWebServer("/combined_bad_request_blob");
+        httpServer.start();
+        try {
+            String httpUrl = httpServer.getBaseUrl();
+            httpServer.enqueueResponse("", 400);
+            httpServer.enqueueResponse("", 400);
+
+            tEnv.executeSql(
+                    "CREATE TABLE combined_bad_request_blob_table (id INT, 
data STRING, picture BYTES)"
+                            + " WITH ('row-tracking.enabled'='true',"
+                            + " 'data-evolution.enabled'='true',"
+                            + " 'blob-field'='picture',"
+                            + " 'blob-as-descriptor'='true',"
+                            + " 'blob-write-null-on-missing-file'='true',"
+                            + " 'blob-write-null-on-fetch-failure'='true')");
+
+            batchSql(
+                    "INSERT INTO combined_bad_request_blob_table VALUES"
+                            + " (1, 'bad-request', sys.path_to_descriptor('"
+                            + httpUrl
+                            + "'))");
+
+            List<Row> result = batchSql("SELECT * FROM 
combined_bad_request_blob_table");
+            assertThat(result).hasSize(1);
+            assertThat(result.get(0).getField(0)).isEqualTo(1);
+            assertThat(result.get(0).getField(1)).isEqualTo("bad-request");
+            assertThat(result.get(0).getField(2)).isNull();
+        } finally {
+            httpServer.stop();
+        }
+    }
+
     @Test
     public void testBlobTypeSchemaEquals() throws Exception {
         // Step 1: Create a Paimon table with blob field via Flink SQL
diff --git 
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/FlinkRowWrapperTest.java
 
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/FlinkRowWrapperTest.java
index c53adecec1..517917e4c0 100644
--- 
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/FlinkRowWrapperTest.java
+++ 
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/FlinkRowWrapperTest.java
@@ -39,6 +39,7 @@ import java.util.Collections;
 import java.util.Set;
 
 import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
 
 /** Tests for {@link FlinkRowWrapper}. */
 public class FlinkRowWrapperTest {
@@ -147,6 +148,60 @@ public class FlinkRowWrapperTest {
         assertThat(blob).isNotNull();
     }
 
+    @Test
+    public void testInvalidUriDefersExistsCheckWhenFetchFailureEnabled() {
+        GenericRowData row =
+                
descriptorRow("https://img.alicdn.com/imgextra/##1304008055350781673";, 1);
+
+        FlinkRowWrapper wrapper = wrapper(row, true, true);
+
+        assertThat(wrapper.isNullAt(0)).isFalse();
+    }
+
+    @Test
+    public void testInvalidUriThrowsWhenFetchFailureDisabled() {
+        GenericRowData row =
+                
descriptorRow("https://img.alicdn.com/imgextra/##1304008055350781673";, 1);
+
+        FlinkRowWrapper wrapper = wrapper(row, true, false);
+
+        assertThatThrownBy(() -> wrapper.isNullAt(0))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("Illegal character");
+    }
+
+    @Test
+    public void testHttpBadRequestDefersExistsCheckWhenFetchFailureEnabled() 
throws Exception {
+        httpServer.createContext(
+                "/bad.jpg",
+                exchange -> {
+                    sendResponse(exchange, 400, new byte[0]);
+                });
+        GenericRowData row = descriptorRow("http://127.0.0.1:"; + httpPort + 
"/bad.jpg", 1);
+
+        FlinkRowWrapper wrapper = wrapper(row, true, true);
+
+        assertThat(wrapper.isNullAt(0)).isFalse();
+    }
+
+    @Test
+    public void testHttpBadRequestThrowsWhenFetchFailureDisabled() throws 
Exception {
+        httpServer.createContext(
+                "/bad.jpg",
+                exchange -> {
+                    sendResponse(exchange, 400, new byte[0]);
+                });
+        GenericRowData row = descriptorRow("http://127.0.0.1:"; + httpPort + 
"/bad.jpg", 1);
+
+        FlinkRowWrapper wrapper = wrapper(row, true, false);
+
+        assertThatThrownBy(() -> wrapper.isNullAt(0))
+                .isInstanceOf(RuntimeException.class)
+                .hasRootCauseInstanceOf(IOException.class)
+                .rootCause()
+                .hasMessageContaining("Unexpected HTTP status code: 400");
+    }
+
     private GenericRowData descriptorRow(java.nio.file.Path path, long length) 
{
         return descriptorRow(path.toUri().toString(), length);
     }
@@ -170,12 +225,32 @@ public class FlinkRowWrapperTest {
     }
 
     private FlinkRowWrapper wrapper(GenericRowData row, boolean 
checkBlobDescriptorExists) {
-        return wrapper(row, checkBlobDescriptorExists, 
Collections.singleton(0));
+        return wrapper(row, checkBlobDescriptorExists, false);
+    }
+
+    private FlinkRowWrapper wrapper(
+            GenericRowData row,
+            boolean checkBlobDescriptorExists,
+            boolean writeNullOnFetchFailure) {
+        return wrapper(
+                row, checkBlobDescriptorExists, writeNullOnFetchFailure, 
Collections.singleton(0));
     }
 
     private FlinkRowWrapper wrapper(
             GenericRowData row, boolean checkBlobDescriptorExists, 
Set<Integer> blobFields) {
+        return wrapper(row, checkBlobDescriptorExists, false, blobFields);
+    }
+
+    private FlinkRowWrapper wrapper(
+            GenericRowData row,
+            boolean checkBlobDescriptorExists,
+            boolean writeNullOnFetchFailure,
+            Set<Integer> blobFields) {
         return new FlinkRowWrapper(
-                row, CatalogContext.create(new Options()), 
checkBlobDescriptorExists, blobFields);
+                row,
+                CatalogContext.create(new Options()),
+                checkBlobDescriptorExists,
+                writeNullOnFetchFailure,
+                blobFields);
     }
 }
diff --git 
a/paimon-format/src/main/java/org/apache/paimon/format/blob/BlobFileFormat.java 
b/paimon-format/src/main/java/org/apache/paimon/format/blob/BlobFileFormat.java
index c65f76eeaa..66798c4bf3 100644
--- 
a/paimon-format/src/main/java/org/apache/paimon/format/blob/BlobFileFormat.java
+++ 
b/paimon-format/src/main/java/org/apache/paimon/format/blob/BlobFileFormat.java
@@ -50,6 +50,8 @@ import static 
org.apache.paimon.utils.Preconditions.checkArgument;
 public class BlobFileFormat extends FileFormat {
 
     private final boolean blobAsDescriptor;
+    private boolean writeNullOnMissingFile;
+    private boolean writeNullOnFetchFailure;
 
     @Nullable public BlobConsumer writeConsumer;
 
@@ -66,6 +68,14 @@ public class BlobFileFormat extends FileFormat {
         return fileName.endsWith("." + BlobFileFormatFactory.IDENTIFIER);
     }
 
+    public void setWriteNullOnMissingFile(boolean writeNullOnMissingFile) {
+        this.writeNullOnMissingFile = writeNullOnMissingFile;
+    }
+
+    public void setWriteNullOnFetchFailure(boolean writeNullOnFetchFailure) {
+        this.writeNullOnFetchFailure = writeNullOnFetchFailure;
+    }
+
     public void setWriteConsumer(@Nullable BlobConsumer writeConsumer) {
         this.writeConsumer = writeConsumer;
     }
@@ -108,7 +118,8 @@ public class BlobFileFormat extends FileFormat {
 
         @Override
         public FormatWriter create(PositionOutputStream out, String 
compression) {
-            return new BlobFormatWriter(out, writeConsumer, type);
+            return new BlobFormatWriter(
+                    out, writeConsumer, type, writeNullOnMissingFile, 
writeNullOnFetchFailure);
         }
     }
 
diff --git 
a/paimon-format/src/main/java/org/apache/paimon/format/blob/BlobFormatWriter.java
 
b/paimon-format/src/main/java/org/apache/paimon/format/blob/BlobFormatWriter.java
index f97158c171..5b3e50537d 100644
--- 
a/paimon-format/src/main/java/org/apache/paimon/format/blob/BlobFormatWriter.java
+++ 
b/paimon-format/src/main/java/org/apache/paimon/format/blob/BlobFormatWriter.java
@@ -22,16 +22,21 @@ import org.apache.paimon.data.Blob;
 import org.apache.paimon.data.BlobConsumer;
 import org.apache.paimon.data.BlobDescriptor;
 import org.apache.paimon.data.BlobPlaceholder;
+import org.apache.paimon.data.BlobRef;
 import org.apache.paimon.data.InternalRow;
 import org.apache.paimon.format.FileAwareFormatWriter;
 import org.apache.paimon.format.FormatWriter;
 import org.apache.paimon.fs.Path;
 import org.apache.paimon.fs.PositionOutputStream;
 import org.apache.paimon.fs.SeekableInputStream;
+import org.apache.paimon.rest.HttpClientUtils;
 import org.apache.paimon.types.RowType;
 import org.apache.paimon.utils.DeltaVarintCompressor;
 import org.apache.paimon.utils.LongArrayList;
 
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
 import javax.annotation.Nullable;
 
 import java.io.IOException;
@@ -44,6 +49,8 @@ import static 
org.apache.paimon.utils.StreamUtils.longToLittleEndian;
 /** {@link FormatWriter} for blob file. */
 public class BlobFormatWriter implements FileAwareFormatWriter {
 
+    private static final Logger LOG = 
LoggerFactory.getLogger(BlobFormatWriter.class);
+
     public static final byte VERSION = 1;
     public static final int MAGIC_NUMBER = 1481511375;
     public static final byte[] MAGIC_NUMBER_BYTES = 
intToLittleEndian(MAGIC_NUMBER);
@@ -53,6 +60,8 @@ public class BlobFormatWriter implements 
FileAwareFormatWriter {
     private final PositionOutputStream out;
     @Nullable private final BlobConsumer writeConsumer;
     private final String blobFieldName;
+    private final boolean writeNullOnMissingFile;
+    private final boolean writeNullOnFetchFailure;
     private final CRC32 crc32;
     private final byte[] tmpBuffer;
     private final LongArrayList lengths;
@@ -61,8 +70,27 @@ public class BlobFormatWriter implements 
FileAwareFormatWriter {
 
     public BlobFormatWriter(
             PositionOutputStream out, @Nullable BlobConsumer writeConsumer, 
RowType type) {
+        this(out, writeConsumer, type, false, false);
+    }
+
+    public BlobFormatWriter(
+            PositionOutputStream out,
+            @Nullable BlobConsumer writeConsumer,
+            RowType type,
+            boolean writeNullOnMissingFile) {
+        this(out, writeConsumer, type, writeNullOnMissingFile, false);
+    }
+
+    public BlobFormatWriter(
+            PositionOutputStream out,
+            @Nullable BlobConsumer writeConsumer,
+            RowType type,
+            boolean writeNullOnMissingFile,
+            boolean writeNullOnFetchFailure) {
         this.out = out;
         this.writeConsumer = writeConsumer;
+        this.writeNullOnMissingFile = writeNullOnMissingFile;
+        this.writeNullOnFetchFailure = writeNullOnFetchFailure;
         checkArgument(type.getFieldCount() == 1, "BlobFormatWriter only 
support one field.");
         this.blobFieldName = type.getFieldNames().get(0);
         this.crc32 = new CRC32();
@@ -84,34 +112,56 @@ public class BlobFormatWriter implements 
FileAwareFormatWriter {
     public void addElement(InternalRow element) throws IOException {
         checkArgument(element.getFieldCount() == 1, "BlobFormatWriter only 
support one field.");
         if (element.isNullAt(0)) {
-            lengths.add(NULL_LENGTH);
-            if (writeConsumer != null) {
-                writeConsumer.accept(blobFieldName, null);
-            }
+            writeNullElement();
             return;
         }
-        Blob blob = element.getBlob(0);
+        Blob blob;
+        try {
+            blob = element.getBlob(0);
+        } catch (RuntimeException e) {
+            if (tryWriteNullOnFetchFailure(e, null)) {
+                return;
+            }
+            throw e;
+        }
         if (blob == BlobPlaceholder.INSTANCE) {
             lengths.add(PLACE_HOLDER_LENGTH);
             return;
         }
 
-        long previousPos = out.getPos();
-        crc32.reset();
+        SeekableInputStream in;
+        try {
+            in = blob.newInputStream();
+        } catch (IOException | RuntimeException e) {
+            if (writeNullOnMissingFile && HttpClientUtils.isNotFoundError(e)) {
+                LOG.warn(
+                        "Failed to open blob from {} (HTTP 404), writing NULL 
for BLOB field {}.",
+                        blobUri(blob),
+                        blobFieldName,
+                        e);
+                writeNullElement();
+                return;
+            }
+            if (tryWriteNullOnFetchFailure(e, blob)) {
+                return;
+            }
+            throw e;
+        }
 
+        crc32.reset();
         write(MAGIC_NUMBER_BYTES);
-
         long blobPos = out.getPos();
-        try (SeekableInputStream in = blob.newInputStream()) {
-            int bytesRead = in.read(tmpBuffer);
+        long blobLength = 0;
+        try (SeekableInputStream stream = in) {
+            int bytesRead = stream.read(tmpBuffer);
             while (bytesRead >= 0) {
                 write(tmpBuffer, bytesRead);
-                bytesRead = in.read(tmpBuffer);
+                blobLength += bytesRead;
+                bytesRead = stream.read(tmpBuffer);
             }
         }
 
-        long blobLength = out.getPos() - blobPos;
-        long binLength = out.getPos() - previousPos + 12;
+        long binLength = blobLength + MAGIC_NUMBER_BYTES.length + 12;
         lengths.add(binLength);
         byte[] lenBytes = longToLittleEndian(binLength);
         write(lenBytes);
@@ -127,6 +177,50 @@ public class BlobFormatWriter implements 
FileAwareFormatWriter {
         }
     }
 
+    private boolean tryWriteNullOnFetchFailure(Throwable e, @Nullable Blob 
blob)
+            throws IOException {
+        if (!writeNullOnFetchFailure || HttpClientUtils.isNotFoundError(e)) {
+            return false;
+        }
+        Integer statusCode = HttpClientUtils.getHttpStatusCode(e);
+        if (statusCode != null) {
+            LOG.warn(
+                    "Failed to open blob from {} (HTTP {}), writing NULL for 
BLOB field {}.",
+                    blobUri(blob),
+                    statusCode,
+                    blobFieldName,
+                    e);
+        } else if (HttpClientUtils.isInvalidUriException(e)) {
+            LOG.warn(
+                    "Invalid blob URI {} while opening blob, writing NULL for 
BLOB field {}.",
+                    blobUri(blob),
+                    blobFieldName,
+                    e);
+        } else {
+            LOG.warn(
+                    "Failed to open blob from {} due to fetch failure, writing 
NULL for BLOB field {}.",
+                    blobUri(blob),
+                    blobFieldName,
+                    e);
+        }
+        writeNullElement();
+        return true;
+    }
+
+    private void writeNullElement() throws IOException {
+        lengths.add(NULL_LENGTH);
+        if (writeConsumer != null) {
+            writeConsumer.accept(blobFieldName, null);
+        }
+    }
+
+    private static String blobUri(@Nullable Blob blob) {
+        if (blob instanceof BlobRef) {
+            return ((BlobRef) blob).toDescriptor().uri();
+        }
+        return "unknown";
+    }
+
     private void write(byte[] bytes) throws IOException {
         write(bytes, bytes.length);
     }
diff --git 
a/paimon-format/src/test/java/org/apache/paimon/format/blob/BlobFormatWriterTest.java
 
b/paimon-format/src/test/java/org/apache/paimon/format/blob/BlobFormatWriterTest.java
new file mode 100644
index 0000000000..90ded82a19
--- /dev/null
+++ 
b/paimon-format/src/test/java/org/apache/paimon/format/blob/BlobFormatWriterTest.java
@@ -0,0 +1,377 @@
+/*
+ * 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.
+ */
+
+package org.apache.paimon.format.blob;
+
+import org.apache.paimon.catalog.CatalogContext;
+import org.apache.paimon.data.Blob;
+import org.apache.paimon.data.BlobDescriptor;
+import org.apache.paimon.data.BlobRef;
+import org.apache.paimon.data.GenericRow;
+import org.apache.paimon.data.InternalRow;
+import org.apache.paimon.fs.Path;
+import org.apache.paimon.fs.SeekableInputStream;
+import org.apache.paimon.fs.local.LocalFileIO;
+import org.apache.paimon.options.Options;
+import org.apache.paimon.reader.FileRecordIterator;
+import org.apache.paimon.types.DataTypes;
+import org.apache.paimon.types.RowKind;
+import org.apache.paimon.types.RowType;
+import org.apache.paimon.utils.UriReader;
+import org.apache.paimon.utils.UriReaderFactory;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.nio.file.Files;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/** Tests for {@link BlobFormatWriter}. */
+public class BlobFormatWriterTest {
+
+    @Test
+    public void testTwoConsecutiveBlobsPreserveReadback(@TempDir 
java.nio.file.Path tempDir)
+            throws Exception {
+        RowType rowType = RowType.of(DataTypes.BLOB());
+        java.nio.file.Path outputFile = tempDir.resolve("blob.out");
+        byte[] firstPayload = "first-blob".getBytes();
+        byte[] secondPayload = "second-blob-payload".getBytes();
+
+        BlobFormatWriter writer =
+                new BlobFormatWriter(
+                        new 
LocalFileIO.LocalPositionOutputStream(outputFile.toFile()),
+                        null,
+                        rowType);
+        writer.addElement(GenericRow.of(Blob.fromData(firstPayload)));
+        writer.addElement(GenericRow.of(Blob.fromData(secondPayload)));
+        writer.close();
+
+        LocalFileIO fileIO = new LocalFileIO();
+        Path filePath = new Path(outputFile.toUri());
+        long fileSize = Files.size(outputFile);
+        try (SeekableInputStream in = fileIO.newInputStream(filePath)) {
+            BlobFileMeta fileMeta = new BlobFileMeta(in, fileSize, null);
+            assertThat(fileMeta.recordNumber()).isEqualTo(2);
+
+            BlobFormatReader reader = new BlobFormatReader(fileIO, filePath, 
fileMeta, in, 1, 0);
+            FileRecordIterator<InternalRow> iterator = reader.readBatch();
+            assertBlobPayload(iterator.next().getBlob(0), firstPayload);
+            assertBlobPayload(iterator.next().getBlob(0), secondPayload);
+        }
+    }
+
+    @Test
+    public void testWriteNullOnFetchFailureFallbackForHttpBadRequest(
+            @TempDir java.nio.file.Path tempDir) throws Exception {
+        RowType rowType = RowType.of(DataTypes.BLOB());
+        java.nio.file.Path outputFile = tempDir.resolve("blob.out");
+        BlobFormatWriter writer =
+                new BlobFormatWriter(
+                        new 
LocalFileIO.LocalPositionOutputStream(outputFile.toFile()),
+                        null,
+                        rowType,
+                        false,
+                        true);
+
+        writer.addElement(
+                GenericRow.of(
+                        new BlobRef(
+                                failingHttpReader(400),
+                                new 
BlobDescriptor("https://example.com/bad-request.jpg";, 0, -1))));
+
+        writer.close();
+
+        assertThat(outputFile.toFile()).exists();
+    }
+
+    @Test
+    public void testHttpRateLimitWritesNullWhenFetchFailureEnabled(
+            @TempDir java.nio.file.Path tempDir) throws Exception {
+        RowType rowType = RowType.of(DataTypes.BLOB());
+        java.nio.file.Path outputFile = tempDir.resolve("blob.out");
+        BlobFormatWriter writer =
+                new BlobFormatWriter(
+                        new 
LocalFileIO.LocalPositionOutputStream(outputFile.toFile()),
+                        null,
+                        rowType,
+                        false,
+                        true);
+
+        writer.addElement(
+                GenericRow.of(
+                        new BlobRef(
+                                failingHttpReader(420),
+                                new 
BlobDescriptor("https://example.com/rate-limit.jpg";, 0, -1))));
+
+        writer.close();
+
+        assertThat(outputFile.toFile()).exists();
+    }
+
+    @Test
+    public void testHttpRateLimitFailsWhenFetchFailureDisabled(@TempDir 
java.nio.file.Path tempDir)
+            throws Exception {
+        RowType rowType = RowType.of(DataTypes.BLOB());
+        BlobFormatWriter writer =
+                new BlobFormatWriter(
+                        new LocalFileIO.LocalPositionOutputStream(
+                                tempDir.resolve("blob.out").toFile()),
+                        null,
+                        rowType,
+                        false,
+                        false);
+
+        assertThatThrownBy(
+                        () ->
+                                writer.addElement(
+                                        GenericRow.of(
+                                                new BlobRef(
+                                                        failingHttpReader(420),
+                                                        new BlobDescriptor(
+                                                                
"https://example.com/rate-limit.jpg";,
+                                                                0,
+                                                                -1)))))
+                .isInstanceOf(RuntimeException.class)
+                .hasMessage("HTTP error code: 420");
+    }
+
+    @Test
+    public void testHttpNotFoundPropagatesWhenFetchFailureDisabled(
+            @TempDir java.nio.file.Path tempDir) throws Exception {
+        RowType rowType = RowType.of(DataTypes.BLOB());
+        BlobFormatWriter writer =
+                new BlobFormatWriter(
+                        new LocalFileIO.LocalPositionOutputStream(
+                                tempDir.resolve("blob.out").toFile()),
+                        null,
+                        rowType,
+                        false,
+                        false);
+
+        assertThatThrownBy(
+                        () ->
+                                writer.addElement(
+                                        GenericRow.of(
+                                                new BlobRef(
+                                                        failingHttpReader(404),
+                                                        new BlobDescriptor(
+                                                                
"https://example.com/missing.jpg";,
+                                                                0,
+                                                                -1)))))
+                .isInstanceOf(RuntimeException.class)
+                .hasMessage("HTTP error code: 404");
+    }
+
+    @Test
+    public void testWriteNullOnFetchFailureForInvalidUriDescriptor(
+            @TempDir java.nio.file.Path tempDir) throws Exception {
+        RowType rowType = RowType.of(DataTypes.BLOB());
+        java.nio.file.Path outputFile = tempDir.resolve("blob.out");
+        UriReaderFactory uriReaderFactory =
+                new UriReaderFactory(CatalogContext.create(new Options()));
+        byte[] descriptorBytes =
+                new 
BlobDescriptor("https://img.alicdn.com/imgextra/##1304008055350781673";, 0, -1)
+                        .serialize();
+
+        BlobFormatWriter writer =
+                new BlobFormatWriter(
+                        new 
LocalFileIO.LocalPositionOutputStream(outputFile.toFile()),
+                        null,
+                        rowType,
+                        false,
+                        true);
+
+        writer.addElement(new DescriptorBytesRow(descriptorBytes, 
uriReaderFactory));
+        writer.close();
+
+        LocalFileIO fileIO = new LocalFileIO();
+        Path filePath = new Path(outputFile.toUri());
+        long fileSize = Files.size(outputFile);
+        try (SeekableInputStream in = fileIO.newInputStream(filePath)) {
+            BlobFileMeta fileMeta = new BlobFileMeta(in, fileSize, null);
+            assertThat(fileMeta.recordNumber()).isEqualTo(1);
+            assertThat(fileMeta.isNull(0)).isTrue();
+        }
+    }
+
+    @Test
+    public void testHttpNotFoundWritesNullWhenMissingFileEnabled(
+            @TempDir java.nio.file.Path tempDir) throws Exception {
+        RowType rowType = RowType.of(DataTypes.BLOB());
+        java.nio.file.Path outputFile = tempDir.resolve("blob.out");
+        BlobFormatWriter writer =
+                new BlobFormatWriter(
+                        new 
LocalFileIO.LocalPositionOutputStream(outputFile.toFile()),
+                        null,
+                        rowType,
+                        true,
+                        false);
+
+        writer.addElement(
+                GenericRow.of(
+                        new BlobRef(
+                                failingHttpReader(404),
+                                new 
BlobDescriptor("https://example.com/missing.jpg";, 0, -1))));
+        writer.close();
+
+        LocalFileIO fileIO = new LocalFileIO();
+        Path filePath = new Path(outputFile.toUri());
+        long fileSize = Files.size(outputFile);
+        try (SeekableInputStream in = fileIO.newInputStream(filePath)) {
+            BlobFileMeta fileMeta = new BlobFileMeta(in, fileSize, null);
+            assertThat(fileMeta.recordNumber()).isEqualTo(1);
+            assertThat(fileMeta.isNull(0)).isTrue();
+        }
+    }
+
+    private static void assertBlobPayload(Blob blob, byte[] expected) throws 
Exception {
+        try (SeekableInputStream blobIn = blob.newInputStream()) {
+            byte[] actual = new byte[expected.length];
+            org.apache.paimon.utils.IOUtils.readFully(blobIn, actual);
+            assertThat(actual).isEqualTo(expected);
+        }
+    }
+
+    private static UriReader failingHttpReader(int statusCode) {
+        return new UriReader() {
+            @Override
+            public SeekableInputStream newInputStream(String uri) {
+                throw new RuntimeException("HTTP error code: " + statusCode);
+            }
+        };
+    }
+
+    private static final class DescriptorBytesRow implements InternalRow {
+        private final byte[] descriptorBytes;
+        private final UriReaderFactory uriReaderFactory;
+
+        private DescriptorBytesRow(byte[] descriptorBytes, UriReaderFactory 
uriReaderFactory) {
+            this.descriptorBytes = descriptorBytes;
+            this.uriReaderFactory = uriReaderFactory;
+        }
+
+        @Override
+        public int getFieldCount() {
+            return 1;
+        }
+
+        @Override
+        public RowKind getRowKind() {
+            return RowKind.INSERT;
+        }
+
+        @Override
+        public void setRowKind(RowKind kind) {}
+
+        @Override
+        public boolean isNullAt(int pos) {
+            return false;
+        }
+
+        @Override
+        public Blob getBlob(int pos) {
+            return Blob.fromBytes(descriptorBytes, uriReaderFactory, null);
+        }
+
+        private UnsupportedOperationException unsupported() {
+            return new UnsupportedOperationException();
+        }
+
+        @Override
+        public boolean getBoolean(int pos) {
+            throw unsupported();
+        }
+
+        @Override
+        public byte getByte(int pos) {
+            throw unsupported();
+        }
+
+        @Override
+        public short getShort(int pos) {
+            throw unsupported();
+        }
+
+        @Override
+        public int getInt(int pos) {
+            throw unsupported();
+        }
+
+        @Override
+        public long getLong(int pos) {
+            throw unsupported();
+        }
+
+        @Override
+        public float getFloat(int pos) {
+            throw unsupported();
+        }
+
+        @Override
+        public double getDouble(int pos) {
+            throw unsupported();
+        }
+
+        @Override
+        public org.apache.paimon.data.BinaryString getString(int pos) {
+            throw unsupported();
+        }
+
+        @Override
+        public org.apache.paimon.data.Decimal getDecimal(int pos, int 
precision, int scale) {
+            throw unsupported();
+        }
+
+        @Override
+        public org.apache.paimon.data.Timestamp getTimestamp(int pos, int 
precision) {
+            throw unsupported();
+        }
+
+        @Override
+        public byte[] getBinary(int pos) {
+            throw unsupported();
+        }
+
+        @Override
+        public org.apache.paimon.data.variant.Variant getVariant(int pos) {
+            throw unsupported();
+        }
+
+        @Override
+        public org.apache.paimon.data.InternalArray getArray(int pos) {
+            throw unsupported();
+        }
+
+        @Override
+        public org.apache.paimon.data.InternalVector getVector(int pos) {
+            throw unsupported();
+        }
+
+        @Override
+        public org.apache.paimon.data.InternalMap getMap(int pos) {
+            throw unsupported();
+        }
+
+        @Override
+        public InternalRow getRow(int pos, int numFields) {
+            throw unsupported();
+        }
+    }
+}

Reply via email to