diff --git a/docs/docs/maintenance/metrics.md b/docs/docs/maintenance/metrics.md
index ef764c13e148..1dffec46123b 100644
--- a/docs/docs/maintenance/metrics.md
+++ b/docs/docs/maintenance/metrics.md
@@ -37,7 +37,7 @@ Paimon has supported built-in metrics to measure operations of **commits**, **sc
## Metrics List
-Below is lists of Paimon built-in metrics. They are summarized into types of scan metrics, commit metrics, write metrics, write buffer metrics and compaction metrics.
+Below is lists of Paimon built-in metrics. They are summarized into types of scan metrics, commit metrics, write metrics, write buffer metrics, blob fetch metrics and compaction metrics.
### Scan Metrics
@@ -226,6 +226,85 @@ Below is lists of Paimon built-in metrics. They are summarized into types of sca
+### Blob Fetch Metrics
+
+
+
+
+ | Metrics Name |
+ Type |
+ Description |
+
+
+
+
+ | blobFetchTotal |
+ Counter |
+ Total number of blob fetch outcomes handled during write, including successful fetches, failures, and missing resources written as NULL. |
+
+
+ | blobFetchSuccess |
+ Counter |
+ Total number of blob fetches that completed successfully. |
+
+
+ | blobFetchSuccessBytes |
+ Counter |
+ Total bytes written by successful blob fetches. |
+
+
+ | blobFetchNullWritten |
+ Counter |
+ Total number of NULL values written because a blob resource was missing or failed to fetch. |
+
+
+ | blobFetchMissingFileNullWritten |
+ Counter |
+ Total number of NULL values written because blob-write-null-on-missing-file handled a missing blob resource. |
+
+
+ | blobFetchFailureNullWritten |
+ Counter |
+ Total number of NULL values written because blob-write-null-on-fetch-failure handled a non-404 blob fetch failure. |
+
+
+ | blobFetchFailure |
+ Counter |
+ Total number of blob fetch failures that were not written as NULL. |
+
+
+ | blobFetchHttpNotFound |
+ Counter |
+ Total number of HTTP 404 blob fetch failures. |
+
+
+ | blobFetchHttpClientError |
+ Counter |
+ Total number of non-404 HTTP 4xx blob fetch failures. |
+
+
+ | blobFetchHttpServerError |
+ Counter |
+ Total number of HTTP 5xx blob fetch failures. |
+
+
+ | blobFetchHttpOtherError |
+ Counter |
+ Total number of blob fetch failures with HTTP status codes outside 4xx and 5xx. |
+
+
+ | blobFetchInvalidUri |
+ Counter |
+ Total number of blob fetch failures caused by invalid URIs. |
+
+
+ | blobFetchOtherError |
+ Counter |
+ Total number of blob fetch failures that are not classified as HTTP status or invalid URI failures. |
+
+
+
+
### Compaction Metrics
@@ -375,6 +454,11 @@ From Flink Web-UI, go to the committer operator's metrics, it's shown as:
| <host>.taskmanager.<tm_id>.<job_name>.<writer_operator_name>.<subtask_index> |
paimon.table.<table_name>.writeBuffer |
+
+ | Blob Fetch Metrics |
+ <host>.taskmanager.<tm_id>.<job_name>.<writer_operator_name>.<subtask_index> |
+ paimon.table.<table_name>.blobFetch |
+
| Compaction Metrics |
<host>.taskmanager.<tm_id>.<job_name>.<writer_operator_name>.<subtask_index> |
diff --git a/paimon-common/src/main/java/org/apache/paimon/data/BlobFetchMetricReporter.java b/paimon-common/src/main/java/org/apache/paimon/data/BlobFetchMetricReporter.java
new file mode 100644
index 000000000000..0670c7098c71
--- /dev/null
+++ b/paimon-common/src/main/java/org/apache/paimon/data/BlobFetchMetricReporter.java
@@ -0,0 +1,46 @@
+/*
+ * 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.data;
+
+/** Reporter for blob fetch metrics. */
+public interface BlobFetchMetricReporter {
+
+ BlobFetchMetricReporter NOOP =
+ new BlobFetchMetricReporter() {
+ @Override
+ public void recordSuccess(long bytes) {}
+
+ @Override
+ public void recordMissingFileNullWritten(boolean httpNotFound) {}
+
+ @Override
+ public void recordFetchFailureNullWritten(Throwable throwable) {}
+
+ @Override
+ public void recordFetchFailure(Throwable throwable) {}
+ };
+
+ void recordSuccess(long bytes);
+
+ void recordMissingFileNullWritten(boolean httpNotFound);
+
+ void recordFetchFailureNullWritten(Throwable throwable);
+
+ void recordFetchFailure(Throwable throwable);
+}
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 e9ef1e15eedb..86df6b10b265 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
@@ -200,7 +200,8 @@ public DedicatedFormatRollingFileWriter(
context.blobConsumer(),
context.blobInlineFields(),
context.writeNullOnMissingFile(),
- context.writeNullOnFetchFailure());
+ context.writeNullOnFetchFailure(),
+ context.blobFetchMetricReporter());
} 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 b8d2d1e6c549..d952018ee1f0 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
@@ -19,6 +19,7 @@
package org.apache.paimon.append;
import org.apache.paimon.data.BlobConsumer;
+import org.apache.paimon.data.BlobFetchMetricReporter;
import org.apache.paimon.data.InternalRow;
import org.apache.paimon.fileindex.FileIndexOptions;
import org.apache.paimon.format.blob.BlobFileFormat;
@@ -65,7 +66,8 @@ public MultipleBlobFileWriter(
@Nullable BlobConsumer blobConsumer,
Set blobInlineFields,
boolean writeNullOnMissingFile,
- boolean writeNullOnFetchFailure) {
+ boolean writeNullOnFetchFailure,
+ BlobFetchMetricReporter blobFetchMetricReporter) {
RowType blobRowType = new RowType(fieldsInBlobFile(writeSchema, blobInlineFields));
this.blobWriters = new ArrayList<>();
for (String blobFieldName : blobRowType.getFieldNames()) {
@@ -73,6 +75,7 @@ public MultipleBlobFileWriter(
blobFileFormat.setWriteConsumer(blobConsumer);
blobFileFormat.setWriteNullOnMissingFile(writeNullOnMissingFile);
blobFileFormat.setWriteNullOnFetchFailure(writeNullOnFetchFailure);
+ blobFileFormat.setBlobFetchMetricReporter(blobFetchMetricReporter);
blobWriters.add(
new BlobProjectedFileWriter(
() ->
diff --git a/paimon-core/src/main/java/org/apache/paimon/operation/BaseAppendFileStoreWrite.java b/paimon-core/src/main/java/org/apache/paimon/operation/BaseAppendFileStoreWrite.java
index 574143192e19..24bdadc416b6 100644
--- a/paimon-core/src/main/java/org/apache/paimon/operation/BaseAppendFileStoreWrite.java
+++ b/paimon-core/src/main/java/org/apache/paimon/operation/BaseAppendFileStoreWrite.java
@@ -35,6 +35,8 @@
import org.apache.paimon.io.DataFileMeta;
import org.apache.paimon.io.RowDataRollingFileWriter;
import org.apache.paimon.manifest.FileSource;
+import org.apache.paimon.metrics.MetricRegistry;
+import org.apache.paimon.operation.metrics.BlobFetchMetrics;
import org.apache.paimon.reader.RecordReaderIterator;
import org.apache.paimon.statistics.SimpleColStatsCollector;
import org.apache.paimon.types.RowType;
@@ -80,6 +82,7 @@ public abstract class BaseAppendFileStoreWrite extends MemoryFileStoreWrite writeCols;
private boolean forceBufferSpill = false;
@@ -117,6 +120,16 @@ public BaseAppendFileStoreWrite withBlobConsumer(BlobConsumer blobConsumer) {
return this;
}
+ @Override
+ public BaseAppendFileStoreWrite withMetricRegistry(MetricRegistry metricRegistry) {
+ super.withMetricRegistry(metricRegistry);
+ if (blobContext != null) {
+ blobFetchMetrics = new BlobFetchMetrics(metricRegistry, tableName);
+ blobContext = blobContext.withBlobFetchMetricReporter(blobFetchMetrics);
+ }
+ return this;
+ }
+
@Override
protected RecordWriter createWriter(
BinaryRow partition,
@@ -179,6 +192,14 @@ private SimpleColStatsCollector.Factory[] statsCollectors() {
return createStatsFactories(options.statsMode(), options, writeType.getFieldNames());
}
+ @Override
+ public void close() throws Exception {
+ super.close();
+ if (blobFetchMetrics != null) {
+ blobFetchMetrics.close();
+ }
+ }
+
protected abstract CompactManager getCompactManager(
BinaryRow partition,
int bucket,
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 dd475da637bd..dc43824b6c0d 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
@@ -20,6 +20,7 @@
import org.apache.paimon.CoreOptions;
import org.apache.paimon.data.BlobConsumer;
+import org.apache.paimon.data.BlobFetchMetricReporter;
import org.apache.paimon.types.DataField;
import org.apache.paimon.types.DataTypeRoot;
import org.apache.paimon.types.RowType;
@@ -39,6 +40,7 @@ public class BlobFileContext {
private final boolean writeNullOnFetchFailure;
private @Nullable BlobConsumer blobConsumer;
+ private BlobFetchMetricReporter blobFetchMetricReporter = BlobFetchMetricReporter.NOOP;
private BlobFileContext(
Set blobDescriptorFields,
@@ -81,6 +83,12 @@ public BlobFileContext withBlobConsumer(BlobConsumer blobConsumer) {
return this;
}
+ public BlobFileContext withBlobFetchMetricReporter(
+ BlobFetchMetricReporter blobFetchMetricReporter) {
+ this.blobFetchMetricReporter = blobFetchMetricReporter;
+ return this;
+ }
+
public BlobFileContext withWriteType(RowType writeType) {
if (writeType.getFieldTypes().stream().noneMatch(t -> t.is(BLOB))) {
return null;
@@ -108,4 +116,8 @@ public boolean writeNullOnMissingFile() {
public boolean writeNullOnFetchFailure() {
return writeNullOnFetchFailure;
}
+
+ public BlobFetchMetricReporter blobFetchMetricReporter() {
+ return blobFetchMetricReporter;
+ }
}
diff --git a/paimon-core/src/main/java/org/apache/paimon/operation/metrics/BlobFetchMetrics.java b/paimon-core/src/main/java/org/apache/paimon/operation/metrics/BlobFetchMetrics.java
new file mode 100644
index 000000000000..3f708a13884f
--- /dev/null
+++ b/paimon-core/src/main/java/org/apache/paimon/operation/metrics/BlobFetchMetrics.java
@@ -0,0 +1,146 @@
+/*
+ * 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.operation.metrics;
+
+import org.apache.paimon.annotation.VisibleForTesting;
+import org.apache.paimon.data.BlobFetchMetricReporter;
+import org.apache.paimon.metrics.Counter;
+import org.apache.paimon.metrics.MetricGroup;
+import org.apache.paimon.metrics.MetricRegistry;
+import org.apache.paimon.rest.HttpClientUtils;
+
+/** Metrics to measure blob fetches during write. */
+public class BlobFetchMetrics implements BlobFetchMetricReporter {
+
+ public static final String GROUP_NAME = "blobFetch";
+
+ public static final String BLOB_FETCH_TOTAL = "blobFetchTotal";
+ public static final String BLOB_FETCH_SUCCESS = "blobFetchSuccess";
+ public static final String BLOB_FETCH_SUCCESS_BYTES = "blobFetchSuccessBytes";
+ public static final String BLOB_FETCH_NULL_WRITTEN = "blobFetchNullWritten";
+ public static final String BLOB_FETCH_MISSING_FILE_NULL_WRITTEN =
+ "blobFetchMissingFileNullWritten";
+ public static final String BLOB_FETCH_FAILURE_NULL_WRITTEN = "blobFetchFailureNullWritten";
+ public static final String BLOB_FETCH_FAILURE = "blobFetchFailure";
+ public static final String BLOB_FETCH_HTTP_NOT_FOUND = "blobFetchHttpNotFound";
+ public static final String BLOB_FETCH_HTTP_CLIENT_ERROR = "blobFetchHttpClientError";
+ public static final String BLOB_FETCH_HTTP_SERVER_ERROR = "blobFetchHttpServerError";
+ public static final String BLOB_FETCH_HTTP_OTHER_ERROR = "blobFetchHttpOtherError";
+ public static final String BLOB_FETCH_INVALID_URI = "blobFetchInvalidUri";
+ public static final String BLOB_FETCH_OTHER_ERROR = "blobFetchOtherError";
+
+ private final MetricGroup metricGroup;
+ private final Counter totalCounter;
+ private final Counter successCounter;
+ private final Counter successBytesCounter;
+ private final Counter nullWrittenCounter;
+ private final Counter missingFileNullWrittenCounter;
+ private final Counter fetchFailureNullWrittenCounter;
+ private final Counter failureCounter;
+ private final Counter httpNotFoundCounter;
+ private final Counter httpClientErrorCounter;
+ private final Counter httpServerErrorCounter;
+ private final Counter httpOtherErrorCounter;
+ private final Counter invalidUriCounter;
+ private final Counter otherErrorCounter;
+
+ public BlobFetchMetrics(MetricRegistry registry, String tableName) {
+ this.metricGroup = registry.createTableMetricGroup(GROUP_NAME, tableName);
+ this.totalCounter = metricGroup.counter(BLOB_FETCH_TOTAL);
+ this.successCounter = metricGroup.counter(BLOB_FETCH_SUCCESS);
+ this.successBytesCounter = metricGroup.counter(BLOB_FETCH_SUCCESS_BYTES);
+ this.nullWrittenCounter = metricGroup.counter(BLOB_FETCH_NULL_WRITTEN);
+ this.missingFileNullWrittenCounter =
+ metricGroup.counter(BLOB_FETCH_MISSING_FILE_NULL_WRITTEN);
+ this.fetchFailureNullWrittenCounter = metricGroup.counter(BLOB_FETCH_FAILURE_NULL_WRITTEN);
+ this.failureCounter = metricGroup.counter(BLOB_FETCH_FAILURE);
+ this.httpNotFoundCounter = metricGroup.counter(BLOB_FETCH_HTTP_NOT_FOUND);
+ this.httpClientErrorCounter = metricGroup.counter(BLOB_FETCH_HTTP_CLIENT_ERROR);
+ this.httpServerErrorCounter = metricGroup.counter(BLOB_FETCH_HTTP_SERVER_ERROR);
+ this.httpOtherErrorCounter = metricGroup.counter(BLOB_FETCH_HTTP_OTHER_ERROR);
+ this.invalidUriCounter = metricGroup.counter(BLOB_FETCH_INVALID_URI);
+ this.otherErrorCounter = metricGroup.counter(BLOB_FETCH_OTHER_ERROR);
+ }
+
+ @VisibleForTesting
+ public MetricGroup getMetricGroup() {
+ return metricGroup;
+ }
+
+ @Override
+ public void recordSuccess(long bytes) {
+ totalCounter.inc();
+ successCounter.inc();
+ if (bytes > 0) {
+ successBytesCounter.inc(bytes);
+ }
+ }
+
+ @Override
+ public void recordMissingFileNullWritten(boolean httpNotFound) {
+ totalCounter.inc();
+ nullWrittenCounter.inc();
+ missingFileNullWrittenCounter.inc();
+ if (httpNotFound) {
+ httpNotFoundCounter.inc();
+ }
+ }
+
+ @Override
+ public void recordFetchFailureNullWritten(Throwable throwable) {
+ totalCounter.inc();
+ nullWrittenCounter.inc();
+ fetchFailureNullWrittenCounter.inc();
+ recordFailureReason(throwable);
+ }
+
+ @Override
+ public void recordFetchFailure(Throwable throwable) {
+ totalCounter.inc();
+ failureCounter.inc();
+ recordFailureReason(throwable);
+ }
+
+ public void close() {
+ metricGroup.close();
+ }
+
+ private void recordFailureReason(Throwable throwable) {
+ Integer statusCode = HttpClientUtils.getHttpStatusCode(throwable);
+ if (statusCode != null) {
+ if (statusCode == 404) {
+ httpNotFoundCounter.inc();
+ } else if (statusCode >= 400 && statusCode < 500) {
+ httpClientErrorCounter.inc();
+ } else if (statusCode >= 500 && statusCode < 600) {
+ httpServerErrorCounter.inc();
+ } else {
+ httpOtherErrorCounter.inc();
+ }
+ return;
+ }
+
+ if (HttpClientUtils.isInvalidUriException(throwable)) {
+ invalidUriCounter.inc();
+ return;
+ }
+
+ otherErrorCounter.inc();
+ }
+}
diff --git a/paimon-core/src/test/java/org/apache/paimon/operation/metrics/BlobFetchMetricsTest.java b/paimon-core/src/test/java/org/apache/paimon/operation/metrics/BlobFetchMetricsTest.java
new file mode 100644
index 000000000000..7fb53005fc49
--- /dev/null
+++ b/paimon-core/src/test/java/org/apache/paimon/operation/metrics/BlobFetchMetricsTest.java
@@ -0,0 +1,97 @@
+/*
+ * 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.operation.metrics;
+
+import org.apache.paimon.metrics.Counter;
+import org.apache.paimon.metrics.Metric;
+import org.apache.paimon.metrics.MetricGroup;
+import org.apache.paimon.metrics.TestMetricRegistry;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/** Tests for {@link BlobFetchMetrics}. */
+public class BlobFetchMetricsTest {
+
+ private static final String TABLE_NAME = "myTable";
+
+ @Test
+ public void testMetricRegistration() {
+ BlobFetchMetrics metrics = new BlobFetchMetrics(new TestMetricRegistry(), TABLE_NAME);
+ MetricGroup metricGroup = metrics.getMetricGroup();
+
+ assertThat(metricGroup.getGroupName()).isEqualTo(BlobFetchMetrics.GROUP_NAME);
+ assertThat(metricGroup.getAllVariables()).containsEntry("table", TABLE_NAME);
+ assertThat(metricGroup.getMetrics().keySet())
+ .containsExactlyInAnyOrder(
+ BlobFetchMetrics.BLOB_FETCH_TOTAL,
+ BlobFetchMetrics.BLOB_FETCH_SUCCESS,
+ BlobFetchMetrics.BLOB_FETCH_SUCCESS_BYTES,
+ BlobFetchMetrics.BLOB_FETCH_NULL_WRITTEN,
+ BlobFetchMetrics.BLOB_FETCH_MISSING_FILE_NULL_WRITTEN,
+ BlobFetchMetrics.BLOB_FETCH_FAILURE_NULL_WRITTEN,
+ BlobFetchMetrics.BLOB_FETCH_FAILURE,
+ BlobFetchMetrics.BLOB_FETCH_HTTP_NOT_FOUND,
+ BlobFetchMetrics.BLOB_FETCH_HTTP_CLIENT_ERROR,
+ BlobFetchMetrics.BLOB_FETCH_HTTP_SERVER_ERROR,
+ BlobFetchMetrics.BLOB_FETCH_HTTP_OTHER_ERROR,
+ BlobFetchMetrics.BLOB_FETCH_INVALID_URI,
+ BlobFetchMetrics.BLOB_FETCH_OTHER_ERROR);
+ }
+
+ @Test
+ public void testMetricsAreUpdated() {
+ BlobFetchMetrics metrics = new BlobFetchMetrics(new TestMetricRegistry(), TABLE_NAME);
+
+ metrics.recordSuccess(10);
+ metrics.recordMissingFileNullWritten(true);
+ metrics.recordMissingFileNullWritten(false);
+ metrics.recordFetchFailureNullWritten(new RuntimeException("HTTP error code: 500"));
+ metrics.recordFetchFailure(new RuntimeException("HTTP error code: 429"));
+ metrics.recordFetchFailure(
+ new IllegalArgumentException("Illegal character in path at index 1"));
+ metrics.recordFetchFailure(new RuntimeException("boom"));
+
+ assertCounter(metrics, BlobFetchMetrics.BLOB_FETCH_TOTAL, 7);
+ assertCounter(metrics, BlobFetchMetrics.BLOB_FETCH_SUCCESS, 1);
+ assertCounter(metrics, BlobFetchMetrics.BLOB_FETCH_SUCCESS_BYTES, 10);
+ assertCounter(metrics, BlobFetchMetrics.BLOB_FETCH_NULL_WRITTEN, 3);
+ assertCounter(metrics, BlobFetchMetrics.BLOB_FETCH_MISSING_FILE_NULL_WRITTEN, 2);
+ assertCounter(metrics, BlobFetchMetrics.BLOB_FETCH_FAILURE_NULL_WRITTEN, 1);
+ assertCounter(metrics, BlobFetchMetrics.BLOB_FETCH_FAILURE, 3);
+ assertCounter(metrics, BlobFetchMetrics.BLOB_FETCH_HTTP_NOT_FOUND, 1);
+ assertCounter(metrics, BlobFetchMetrics.BLOB_FETCH_HTTP_CLIENT_ERROR, 1);
+ assertCounter(metrics, BlobFetchMetrics.BLOB_FETCH_HTTP_SERVER_ERROR, 1);
+ assertCounter(metrics, BlobFetchMetrics.BLOB_FETCH_HTTP_OTHER_ERROR, 0);
+ assertCounter(metrics, BlobFetchMetrics.BLOB_FETCH_INVALID_URI, 1);
+ assertCounter(metrics, BlobFetchMetrics.BLOB_FETCH_OTHER_ERROR, 1);
+ }
+
+ private static void assertCounter(BlobFetchMetrics metrics, String name, long expected) {
+ assertThat(counter(metrics, name)).isEqualTo(expected);
+ }
+
+ private static long counter(BlobFetchMetrics metrics, String name) {
+ Map registeredMetrics = metrics.getMetricGroup().getMetrics();
+ return ((Counter) registeredMetrics.get(name)).getCount();
+ }
+}
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 66798c4bf389..afa8cdf232cf 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
@@ -19,6 +19,7 @@
package org.apache.paimon.format.blob;
import org.apache.paimon.data.BlobConsumer;
+import org.apache.paimon.data.BlobFetchMetricReporter;
import org.apache.paimon.data.InternalRow;
import org.apache.paimon.format.EmptyStatsExtractor;
import org.apache.paimon.format.FileFormat;
@@ -52,6 +53,7 @@ public class BlobFileFormat extends FileFormat {
private final boolean blobAsDescriptor;
private boolean writeNullOnMissingFile;
private boolean writeNullOnFetchFailure;
+ private BlobFetchMetricReporter blobFetchMetricReporter = BlobFetchMetricReporter.NOOP;
@Nullable public BlobConsumer writeConsumer;
@@ -80,6 +82,10 @@ public void setWriteConsumer(@Nullable BlobConsumer writeConsumer) {
this.writeConsumer = writeConsumer;
}
+ public void setBlobFetchMetricReporter(BlobFetchMetricReporter blobFetchMetricReporter) {
+ this.blobFetchMetricReporter = blobFetchMetricReporter;
+ }
+
@Override
public FormatReaderFactory createReaderFactory(
RowType dataSchemaRowType,
@@ -119,7 +125,12 @@ private BlobFormatWriterFactory(RowType type) {
@Override
public FormatWriter create(PositionOutputStream out, String compression) {
return new BlobFormatWriter(
- out, writeConsumer, type, writeNullOnMissingFile, writeNullOnFetchFailure);
+ out,
+ writeConsumer,
+ type,
+ writeNullOnMissingFile,
+ writeNullOnFetchFailure,
+ blobFetchMetricReporter);
}
}
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 5b3e50537d64..23a0303ca734 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
@@ -21,6 +21,7 @@
import org.apache.paimon.data.Blob;
import org.apache.paimon.data.BlobConsumer;
import org.apache.paimon.data.BlobDescriptor;
+import org.apache.paimon.data.BlobFetchMetricReporter;
import org.apache.paimon.data.BlobPlaceholder;
import org.apache.paimon.data.BlobRef;
import org.apache.paimon.data.InternalRow;
@@ -59,6 +60,7 @@ public class BlobFormatWriter implements FileAwareFormatWriter {
private final PositionOutputStream out;
@Nullable private final BlobConsumer writeConsumer;
+ private final BlobFetchMetricReporter blobFetchMetricReporter;
private final String blobFieldName;
private final boolean writeNullOnMissingFile;
private final boolean writeNullOnFetchFailure;
@@ -87,8 +89,25 @@ public BlobFormatWriter(
RowType type,
boolean writeNullOnMissingFile,
boolean writeNullOnFetchFailure) {
+ this(
+ out,
+ writeConsumer,
+ type,
+ writeNullOnMissingFile,
+ writeNullOnFetchFailure,
+ BlobFetchMetricReporter.NOOP);
+ }
+
+ public BlobFormatWriter(
+ PositionOutputStream out,
+ @Nullable BlobConsumer writeConsumer,
+ RowType type,
+ boolean writeNullOnMissingFile,
+ boolean writeNullOnFetchFailure,
+ BlobFetchMetricReporter blobFetchMetricReporter) {
this.out = out;
this.writeConsumer = writeConsumer;
+ this.blobFetchMetricReporter = blobFetchMetricReporter;
this.writeNullOnMissingFile = writeNullOnMissingFile;
this.writeNullOnFetchFailure = writeNullOnFetchFailure;
checkArgument(type.getFieldCount() == 1, "BlobFormatWriter only support one field.");
@@ -112,6 +131,7 @@ public boolean deleteFileUponAbort() {
public void addElement(InternalRow element) throws IOException {
checkArgument(element.getFieldCount() == 1, "BlobFormatWriter only support one field.");
if (element.isNullAt(0)) {
+ recordPreCheckedMissingFileNull(element);
writeNullElement();
return;
}
@@ -122,6 +142,7 @@ public void addElement(InternalRow element) throws IOException {
if (tryWriteNullOnFetchFailure(e, null)) {
return;
}
+ blobFetchMetricReporter.recordFetchFailure(e);
throw e;
}
if (blob == BlobPlaceholder.INSTANCE) {
@@ -139,12 +160,14 @@ public void addElement(InternalRow element) throws IOException {
blobUri(blob),
blobFieldName,
e);
+ blobFetchMetricReporter.recordMissingFileNullWritten(true);
writeNullElement();
return;
}
if (tryWriteNullOnFetchFailure(e, blob)) {
return;
}
+ blobFetchMetricReporter.recordFetchFailure(e);
throw e;
}
@@ -159,6 +182,9 @@ public void addElement(InternalRow element) throws IOException {
blobLength += bytesRead;
bytesRead = stream.read(tmpBuffer);
}
+ } catch (IOException | RuntimeException e) {
+ blobFetchMetricReporter.recordFetchFailure(e);
+ throw e;
}
long binLength = blobLength + MAGIC_NUMBER_BYTES.length + 12;
@@ -175,6 +201,7 @@ public void addElement(InternalRow element) throws IOException {
out.flush();
}
}
+ blobFetchMetricReporter.recordSuccess(blobLength);
}
private boolean tryWriteNullOnFetchFailure(Throwable e, @Nullable Blob blob)
@@ -203,10 +230,34 @@ private boolean tryWriteNullOnFetchFailure(Throwable e, @Nullable Blob blob)
blobFieldName,
e);
}
+ blobFetchMetricReporter.recordFetchFailureNullWritten(e);
writeNullElement();
return true;
}
+ private void recordPreCheckedMissingFileNull(InternalRow element) {
+ if (!writeNullOnMissingFile) {
+ return;
+ }
+ BlobDescriptor descriptor = tryGetBlobDescriptor(element);
+ if (descriptor != null) {
+ blobFetchMetricReporter.recordMissingFileNullWritten(isHttpUri(descriptor.uri()));
+ }
+ }
+
+ @Nullable
+ private BlobDescriptor tryGetBlobDescriptor(InternalRow element) {
+ try {
+ byte[] bytes = element.getBinary(0);
+ if (bytes != null && BlobDescriptor.isBlobDescriptor(bytes)) {
+ return BlobDescriptor.deserialize(bytes);
+ }
+ } catch (RuntimeException ignored) {
+ // Null rows may come from normal user input, not from a pre-checked blob descriptor.
+ }
+ return null;
+ }
+
private void writeNullElement() throws IOException {
lengths.add(NULL_LENGTH);
if (writeConsumer != null) {
@@ -221,6 +272,11 @@ private static String blobUri(@Nullable Blob blob) {
return "unknown";
}
+ private static boolean isHttpUri(String uri) {
+ return uri.regionMatches(true, 0, "http://", 0, "http://".length())
+ || uri.regionMatches(true, 0, "https://", 0, "https://".length());
+ }
+
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
index 90ded82a1952..e0a130c419c7 100644
--- 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
@@ -21,6 +21,7 @@
import org.apache.paimon.catalog.CatalogContext;
import org.apache.paimon.data.Blob;
import org.apache.paimon.data.BlobDescriptor;
+import org.apache.paimon.data.BlobFetchMetricReporter;
import org.apache.paimon.data.BlobRef;
import org.apache.paimon.data.GenericRow;
import org.apache.paimon.data.InternalRow;
@@ -241,6 +242,92 @@ public void testHttpNotFoundWritesNullWhenMissingFileEnabled(
}
}
+ @Test
+ public void testBlobFetchMetricReporterForSuccessAndNullWritten(
+ @TempDir java.nio.file.Path tempDir) throws Exception {
+ RowType rowType = RowType.of(DataTypes.BLOB());
+ TestingBlobFetchMetricReporter metricReporter = new TestingBlobFetchMetricReporter();
+ BlobFormatWriter writer =
+ new BlobFormatWriter(
+ new LocalFileIO.LocalPositionOutputStream(
+ tempDir.resolve("blob.out").toFile()),
+ null,
+ rowType,
+ false,
+ true,
+ metricReporter);
+
+ writer.addElement(GenericRow.of(Blob.fromData("image".getBytes())));
+ writer.addElement(
+ GenericRow.of(
+ new BlobRef(
+ failingHttpReader(500),
+ new BlobDescriptor("https://example.com/error.jpg", 0, -1))));
+ writer.close();
+
+ assertThat(metricReporter.success).isEqualTo(1);
+ assertThat(metricReporter.successBytes).isEqualTo(5);
+ assertThat(metricReporter.fetchFailureNullWritten).isEqualTo(1);
+ assertThat(metricReporter.failure).isEqualTo(0);
+ }
+
+ @Test
+ public void testBlobFetchMetricReporterForUnhandledFailure(@TempDir java.nio.file.Path tempDir)
+ throws Exception {
+ RowType rowType = RowType.of(DataTypes.BLOB());
+ TestingBlobFetchMetricReporter metricReporter = new TestingBlobFetchMetricReporter();
+ BlobFormatWriter writer =
+ new BlobFormatWriter(
+ new LocalFileIO.LocalPositionOutputStream(
+ tempDir.resolve("blob.out").toFile()),
+ null,
+ rowType,
+ false,
+ false,
+ metricReporter);
+
+ assertThatThrownBy(
+ () ->
+ writer.addElement(
+ GenericRow.of(
+ new BlobRef(
+ failingHttpReader(500),
+ new BlobDescriptor(
+ "https://example.com/error.jpg",
+ 0,
+ -1)))))
+ .isInstanceOf(RuntimeException.class)
+ .hasMessage("HTTP error code: 500");
+ assertThat(metricReporter.failure).isEqualTo(1);
+ assertThat(metricReporter.fetchFailureNullWritten).isEqualTo(0);
+ }
+
+ @Test
+ public void testBlobFetchMetricReporterForPreCheckedMissingFile(
+ @TempDir java.nio.file.Path tempDir) throws Exception {
+ RowType rowType = RowType.of(DataTypes.BLOB());
+ UriReaderFactory uriReaderFactory =
+ new UriReaderFactory(CatalogContext.create(new Options()));
+ byte[] descriptorBytes =
+ new BlobDescriptor("https://example.com/missing.jpg", 0, -1).serialize();
+ TestingBlobFetchMetricReporter metricReporter = new TestingBlobFetchMetricReporter();
+ BlobFormatWriter writer =
+ new BlobFormatWriter(
+ new LocalFileIO.LocalPositionOutputStream(
+ tempDir.resolve("blob.out").toFile()),
+ null,
+ rowType,
+ true,
+ false,
+ metricReporter);
+
+ writer.addElement(new DescriptorBytesRow(descriptorBytes, uriReaderFactory, true));
+ writer.close();
+
+ assertThat(metricReporter.missingFileNullWritten).isEqualTo(1);
+ assertThat(metricReporter.httpNotFound).isEqualTo(1);
+ }
+
private static void assertBlobPayload(Blob blob, byte[] expected) throws Exception {
try (SeekableInputStream blobIn = blob.newInputStream()) {
byte[] actual = new byte[expected.length];
@@ -261,10 +348,17 @@ public SeekableInputStream newInputStream(String uri) {
private static final class DescriptorBytesRow implements InternalRow {
private final byte[] descriptorBytes;
private final UriReaderFactory uriReaderFactory;
+ private final boolean nullAt;
private DescriptorBytesRow(byte[] descriptorBytes, UriReaderFactory uriReaderFactory) {
+ this(descriptorBytes, uriReaderFactory, false);
+ }
+
+ private DescriptorBytesRow(
+ byte[] descriptorBytes, UriReaderFactory uriReaderFactory, boolean nullAt) {
this.descriptorBytes = descriptorBytes;
this.uriReaderFactory = uriReaderFactory;
+ this.nullAt = nullAt;
}
@Override
@@ -282,7 +376,7 @@ public void setRowKind(RowKind kind) {}
@Override
public boolean isNullAt(int pos) {
- return false;
+ return nullAt;
}
@Override
@@ -346,7 +440,7 @@ public org.apache.paimon.data.Timestamp getTimestamp(int pos, int precision) {
@Override
public byte[] getBinary(int pos) {
- throw unsupported();
+ return descriptorBytes;
}
@Override
@@ -374,4 +468,38 @@ public InternalRow getRow(int pos, int numFields) {
throw unsupported();
}
}
+
+ private static final class TestingBlobFetchMetricReporter implements BlobFetchMetricReporter {
+
+ private int success;
+ private long successBytes;
+ private int missingFileNullWritten;
+ private int httpNotFound;
+ private int fetchFailureNullWritten;
+ private int failure;
+
+ @Override
+ public void recordSuccess(long bytes) {
+ success++;
+ successBytes += bytes;
+ }
+
+ @Override
+ public void recordMissingFileNullWritten(boolean httpNotFound) {
+ missingFileNullWritten++;
+ if (httpNotFound) {
+ this.httpNotFound++;
+ }
+ }
+
+ @Override
+ public void recordFetchFailureNullWritten(Throwable throwable) {
+ fetchFailureNullWritten++;
+ }
+
+ @Override
+ public void recordFetchFailure(Throwable throwable) {
+ failure++;
+ }
+ }
}