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 4c6ab399e2 [core] Apply scan.ignore-corrupt-files to failures while
iterating rows (#10716)
4c6ab399e2 is described below
commit 4c6ab399e20a628ea3b0a7974bb6daac33354704
Author: COLDMANYU <[email protected]>
AuthorDate: Mon Oct 26 12:18:56 2026 +0800
[core] Apply scan.ignore-corrupt-files to failures while iterating rows
(#10716)
---
.../org/apache/paimon/io/DataFileRecordReader.java | 108 ++++++-
.../paimon/table/format/FormatReadBuilder.java | 29 +-
.../format/FormatTableIgnoreCorruptFilesTest.java | 333 +++++++++++++++++++++
3 files changed, 463 insertions(+), 7 deletions(-)
diff --git
a/paimon-core/src/main/java/org/apache/paimon/io/DataFileRecordReader.java
b/paimon-core/src/main/java/org/apache/paimon/io/DataFileRecordReader.java
index 4569926dd4..ee5ba1c348 100644
--- a/paimon-core/src/main/java/org/apache/paimon/io/DataFileRecordReader.java
+++ b/paimon-core/src/main/java/org/apache/paimon/io/DataFileRecordReader.java
@@ -62,6 +62,9 @@ public class DataFileRecordReader implements
FileRecordReader<InternalRow> {
private final Map<String, Integer> systemFields;
@Nullable private final RoaringBitmap32 selection;
+ // Set once a corrupt file has been skipped, so later readBatch() calls
stop reading it.
+ private boolean corrupted;
+
public DataFileRecordReader(
RowType tableRowType,
FormatReaderFactory readerFactory,
@@ -128,7 +131,8 @@ public class DataFileRecordReader implements
FileRecordReader<InternalRow> {
throws IOException {
try {
return readerFactory.createReader(context);
- } catch (Exception e) {
+ } catch (Exception | InternalError e) {
+ rethrowIfNotIgnorable(e, ignoreCorruptFiles);
Path actualPath =
CompactedChangelogPathResolver.resolveCompactedChangelogPath(
context.filePath());
@@ -164,15 +168,27 @@ public class DataFileRecordReader implements
FileRecordReader<InternalRow> {
LOG.warn("Reader is not initialized, maybe file: {} is corrupt.",
filePath);
return null;
}
+ if (corrupted) {
+ return null;
+ }
+ FileRecordIterator<InternalRow> iterator;
try {
- return readBatchInternal();
- } catch (Exception e) {
+ iterator = readBatchInternal();
+ } catch (Exception | InternalError e) {
+ rethrowIfNotIgnorable(e, ignoreCorruptFiles);
if (ignoreCorruptException(e, ignoreCorruptFiles)) {
LOG.warn("Failed to read batch from file: {}, ignore
exception", filePath, e);
+ corrupted = true;
return null;
}
throw new IOException("Failed to read batch from file: " +
filePath, e);
}
+ // Columnar iterators are fully materialized by readBatch(), so
failures have already been
+ // handled above. Leave them unwrapped: callers detect vectorized
batches by their type.
+ if (ignoreCorruptFiles && iterator != null && !(iterator instanceof
ColumnarRowIterator)) {
+ iterator = new IgnoreCorruptFileRecordIterator(iterator);
+ }
+ return iterator;
}
private FileRecordIterator<InternalRow> readBatchInternal() throws
IOException {
@@ -256,4 +272,90 @@ public class DataFileRecordReader implements
FileRecordReader<InternalRow> {
|| e instanceof RuntimeException
|| e instanceof InternalError);
}
+
+ /**
+ * {@link InternalError} (such as {@link java.util.zip.ZipError}) is only
caught so that it can
+ * be ignored as a corrupt file. Without {@code scan.ignore-corrupt-files}
it propagates as
+ * before.
+ */
+ private static void rethrowIfNotIgnorable(Throwable e, boolean
ignoreCorruptFiles) {
+ if (e instanceof InternalError && !ignoreCorruptFiles) {
+ throw (InternalError) e;
+ }
+ }
+
+ /**
+ * Row-based formats (CSV, JSON, TEXT, Avro, ...) decode lazily in {@link
#next()}, so a file
+ * that is truncated or corrupt in the middle only fails after {@link
#readBatch()} returned.
+ * This iterator extends {@code scan.ignore-corrupt-files} to that point:
the rows already
+ * returned are kept and the rest of the file is skipped.
+ */
+ private class IgnoreCorruptFileRecordIterator implements
FileRecordIterator<InternalRow> {
+
+ private final FileRecordIterator<InternalRow> iterator;
+
+ private
IgnoreCorruptFileRecordIterator(FileRecordIterator<InternalRow> iterator) {
+ this.iterator = iterator;
+ }
+
+ @Nullable
+ @Override
+ public InternalRow next() throws IOException {
+ if (corrupted) {
+ return null;
+ }
+ try {
+ return iterator.next();
+ } catch (Exception | InternalError e) {
+ return handleCorruptException(e, null);
+ }
+ }
+
+ @Override
+ public boolean skip() throws IOException {
+ if (corrupted) {
+ return false;
+ }
+ try {
+ return iterator.skip();
+ } catch (UnsupportedOperationException e) {
+ throw e;
+ } catch (Exception | InternalError e) {
+ return handleCorruptException(e, false);
+ }
+ }
+
+ private <T> T handleCorruptException(Throwable e, T endOfFile) throws
IOException {
+ if (ignoreCorruptException(e, ignoreCorruptFiles)) {
+ LOG.warn(
+ "Failed to read records from file: {}, ignore the rest
of the file",
+ filePath,
+ e);
+ corrupted = true;
+ return endOfFile;
+ }
+ if (e instanceof IOException) {
+ throw (IOException) e;
+ }
+ if (e instanceof Error) {
+ throw (Error) e;
+ }
+ throw new IOException(e);
+ }
+
+ @Override
+ public long returnedPosition() {
+ return iterator.returnedPosition();
+ }
+
+ @Override
+ public Path filePath() {
+ return iterator.filePath();
+ }
+
+ @Override
+ public void releaseBatch() {
+ iterator.releaseBatch();
+ }
+ }
}
diff --git
a/paimon-core/src/main/java/org/apache/paimon/table/format/FormatReadBuilder.java
b/paimon-core/src/main/java/org/apache/paimon/table/format/FormatReadBuilder.java
index 9e7cb1d349..31c112686d 100644
---
a/paimon-core/src/main/java/org/apache/paimon/table/format/FormatReadBuilder.java
+++
b/paimon-core/src/main/java/org/apache/paimon/table/format/FormatReadBuilder.java
@@ -26,6 +26,7 @@ import org.apache.paimon.format.FileFormatDiscover;
import org.apache.paimon.format.FormatReaderContext;
import org.apache.paimon.format.FormatReaderFactory;
import org.apache.paimon.fs.FileIO;
+import org.apache.paimon.fs.Path;
import org.apache.paimon.io.DataFileRecordReader;
import org.apache.paimon.mergetree.compact.ConcatRecordReader;
import org.apache.paimon.options.CatalogOptions;
@@ -35,6 +36,7 @@ import org.apache.paimon.partition.PartitionUtils;
import org.apache.paimon.predicate.Predicate;
import org.apache.paimon.predicate.PredicateBuilder;
import org.apache.paimon.predicate.TopN;
+import org.apache.paimon.reader.EmptyRecordReader;
import org.apache.paimon.reader.FileRecordReader;
import org.apache.paimon.reader.ReadBatchSizer;
import org.apache.paimon.reader.ReaderSupplier;
@@ -51,6 +53,9 @@ import org.apache.paimon.utils.Pair;
import org.apache.paimon.utils.Range;
import org.apache.paimon.utils.RowRangeIndex;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
import javax.annotation.Nullable;
import java.io.IOException;
@@ -73,6 +78,8 @@ public class FormatReadBuilder implements ReadBuilder {
private static final long serialVersionUID = 1L;
+ private static final Logger LOG =
LoggerFactory.getLogger(FormatReadBuilder.class);
+
private final FormatTable table;
private RowType readType;
private final CoreOptions options;
@@ -265,13 +272,15 @@ public class FormatReadBuilder implements ReadBuilder {
FormatReaderContext formatReaderContext =
new FormatReaderContext(
fileIO, file.filePath(), file.fileSize(), null,
readBatchSizer);
+ Long length = file.length();
+ if (length == null) {
+ checkArgument(file.offset() == 0, "Offset must be 0.");
+ }
try {
FileRecordReader<InternalRow> reader;
- Long length = file.length();
if (length != null) {
reader = readerFactory.createReader(formatReaderContext,
file.offset(), length);
} else {
- checkArgument(file.offset() == 0, "Offset must be 0.");
reader = readerFactory.createReader(formatReaderContext);
}
return new DataFileRecordReader(
@@ -288,8 +297,20 @@ public class FormatReadBuilder implements ReadBuilder {
Collections.emptyMap(),
null,
formatReaderContext.filePath());
- } catch (Exception e) {
- FileUtils.checkExists(formatReaderContext.fileIO(),
formatReaderContext.filePath());
+ } catch (Exception | InternalError e) {
+ // InternalError (such as ZipError) is only caught to be ignored
as a corrupt file.
+ if (e instanceof InternalError &&
!options.scanIgnoreCorruptFile()) {
+ throw (InternalError) e;
+ }
+ Path filePath = formatReaderContext.filePath();
+ // A missing file is not corrupt: keep reporting it as not found.
+ FileUtils.checkExists(formatReaderContext.fileIO(), filePath);
+ // Text readers fill their first buffer while being constructed,
so a small or empty
+ // compressed file can already fail here rather than in
DataFileRecordReader.
+ if (options.scanIgnoreCorruptFile()) {
+ LOG.warn("Failed to create reader for file: {}, ignore
exception", filePath, e);
+ return new EmptyRecordReader<>();
+ }
// A split spans many files that a Format Table's writers may have
written differently.
// Naming the one that failed is the only way to tell them apart
from the outside.
throw new IOException(
diff --git
a/paimon-core/src/test/java/org/apache/paimon/table/format/FormatTableIgnoreCorruptFilesTest.java
b/paimon-core/src/test/java/org/apache/paimon/table/format/FormatTableIgnoreCorruptFilesTest.java
new file mode 100644
index 0000000000..f94ee240aa
--- /dev/null
+++
b/paimon-core/src/test/java/org/apache/paimon/table/format/FormatTableIgnoreCorruptFilesTest.java
@@ -0,0 +1,333 @@
+/*
+ * 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.table.format;
+
+import org.apache.paimon.CoreOptions;
+import org.apache.paimon.catalog.Identifier;
+import org.apache.paimon.data.InternalRow;
+import org.apache.paimon.fs.FileIO;
+import org.apache.paimon.fs.SeekableInputStream;
+import org.apache.paimon.fs.SeekableInputStreamWrapper;
+import org.apache.paimon.fs.local.LocalFileIO;
+import org.apache.paimon.reader.RecordReader;
+import org.apache.paimon.table.FormatTable;
+import org.apache.paimon.table.source.TableScan;
+import org.apache.paimon.types.DataTypes;
+import org.apache.paimon.types.RowType;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import java.io.EOFException;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Random;
+import java.util.zip.GZIPOutputStream;
+import java.util.zip.ZipError;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Tests {@link CoreOptions#SCAN_IGNORE_CORRUPT_FILE} for Format Table files
that only fail in the
+ * middle of reading, such as truncated compressed text files, or already
while the reader is
+ * created.
+ */
+class FormatTableIgnoreCorruptFilesTest {
+
+ private static final int TRUNCATED_FILE_ROWS = 20_000;
+ private static final int INTACT_FILE_FIRST_ID = 1_000_000;
+ private static final int INTACT_FILE_ROWS = 3;
+ private static final String ZIP_ERROR_AT_OPEN = "zip-error-at-open";
+ private static final String ZIP_ERROR_WHILE_READING =
"zip-error-while-reading";
+ private static final int ZIP_ERROR_AFTER_BYTES = 64 * 1024;
+
+ @TempDir Path tempDir;
+
+ @ParameterizedTest(name = "format = {0}")
+ @ValueSource(strings = {"csv", "json"})
+ void testTruncatedCompressedFile(String format) throws Exception {
+ writeGzip(tempDir.resolve("truncated." + format + ".gz"), format, 0,
TRUNCATED_FILE_ROWS);
+ truncateToHalf(tempDir.resolve("truncated." + format + ".gz"));
+ writeGzip(
+ tempDir.resolve("intact." + format + ".gz"),
+ format,
+ INTACT_FILE_FIRST_ID,
+ INTACT_FILE_ROWS);
+
+ FormatTable table = createTable(format);
+
+ // The truncated file opens fine and only fails once decompression
reaches the cut.
+ assertThatThrownBy(() ->
readIds(table)).isInstanceOf(EOFException.class);
+
+ List<Integer> ids =
+ readIds(
+ table.copy(
+ Collections.singletonMap(
+
CoreOptions.SCAN_IGNORE_CORRUPT_FILE.key(), "true")));
+
+ List<Integer> truncatedIds = new ArrayList<>();
+ List<Integer> intactIds = new ArrayList<>();
+ for (int id : ids) {
+ (id >= INTACT_FILE_FIRST_ID ? intactIds : truncatedIds).add(id);
+ }
+ // The other file of the split is still read completely.
+ assertThat(intactIds)
+ .containsExactly(
+ INTACT_FILE_FIRST_ID, INTACT_FILE_FIRST_ID + 1,
INTACT_FILE_FIRST_ID + 2);
+ // The rows before the cut are kept, the rest of the corrupt file is
skipped.
+
assertThat(truncatedIds).isNotEmpty().hasSizeLessThan(TRUNCATED_FILE_ROWS);
+ for (int i = 0; i < truncatedIds.size(); i++) {
+ assertThat(truncatedIds.get(i)).isEqualTo(i);
+ }
+ }
+
+ @ParameterizedTest(name = "format = {0}")
+ @ValueSource(strings = {"csv", "json"})
+ void testCompressedFileCorruptAtOpen(String format) throws Exception {
+ // Text readers fill their first buffer while being constructed, so
these files already
+ // fail while the reader is created: an empty gzip file and one cut
inside its header.
+ Files.write(tempDir.resolve("empty." + format + ".gz"), new byte[0]);
+ writeGzip(tempDir.resolve("header." + format + ".gz"), format, 0,
1_000);
+ byte[] bytes = Files.readAllBytes(tempDir.resolve("header." + format +
".gz"));
+ byte[] header = new byte[5];
+ System.arraycopy(bytes, 0, header, 0, header.length);
+ Files.write(tempDir.resolve("header." + format + ".gz"), header);
+ writeGzip(
+ tempDir.resolve("intact." + format + ".gz"),
+ format,
+ INTACT_FILE_FIRST_ID,
+ INTACT_FILE_ROWS);
+
+ FormatTable table = createTable(format);
+
+ assertThatThrownBy(() -> readIds(table))
+ .isInstanceOf(IOException.class)
+ .hasMessageContaining("Failed to read file");
+
+ List<Integer> ids =
+ readIds(
+ table.copy(
+ Collections.singletonMap(
+
CoreOptions.SCAN_IGNORE_CORRUPT_FILE.key(), "true")));
+ assertThat(ids)
+ .containsExactly(
+ INTACT_FILE_FIRST_ID, INTACT_FILE_FIRST_ID + 1,
INTACT_FILE_FIRST_ID + 2);
+ }
+
+ @ParameterizedTest(name = "format = {0}")
+ @ValueSource(strings = {"csv", "json"})
+ void testZipErrorWhileReading(String format) throws Exception {
+ // ZipError is an InternalError, not an Exception, but the option
treats it as corruption.
+ writeGzip(
+ tempDir.resolve(ZIP_ERROR_WHILE_READING + "." + format +
".gz"), format, 0, 20_000);
+ writeGzip(
+ tempDir.resolve("intact." + format + ".gz"),
+ format,
+ INTACT_FILE_FIRST_ID,
+ INTACT_FILE_ROWS);
+
+ FormatTable table = createTable(format, new ZipErrorFileIO());
+
+ assertThatThrownBy(() -> readIds(table)).isInstanceOf(ZipError.class);
+
+ List<Integer> ids =
+ readIds(
+ table.copy(
+ Collections.singletonMap(
+
CoreOptions.SCAN_IGNORE_CORRUPT_FILE.key(), "true")));
+
+ List<Integer> brokenIds = new ArrayList<>();
+ List<Integer> intactIds = new ArrayList<>();
+ for (int id : ids) {
+ (id >= INTACT_FILE_FIRST_ID ? intactIds : brokenIds).add(id);
+ }
+ assertThat(intactIds)
+ .containsExactly(
+ INTACT_FILE_FIRST_ID, INTACT_FILE_FIRST_ID + 1,
INTACT_FILE_FIRST_ID + 2);
+ assertThat(brokenIds).isNotEmpty().hasSizeLessThan(20_000);
+ for (int i = 0; i < brokenIds.size(); i++) {
+ assertThat(brokenIds.get(i)).isEqualTo(i);
+ }
+ }
+
+ @ParameterizedTest(name = "format = {0}")
+ @ValueSource(strings = {"csv", "json"})
+ void testZipErrorAtOpen(String format) throws Exception {
+ writeGzip(tempDir.resolve(ZIP_ERROR_AT_OPEN + "." + format + ".gz"),
format, 0, 1_000);
+ writeGzip(
+ tempDir.resolve("intact." + format + ".gz"),
+ format,
+ INTACT_FILE_FIRST_ID,
+ INTACT_FILE_ROWS);
+
+ FormatTable table = createTable(format, new ZipErrorFileIO());
+
+ assertThatThrownBy(() -> readIds(table)).isInstanceOf(ZipError.class);
+
+ List<Integer> ids =
+ readIds(
+ table.copy(
+ Collections.singletonMap(
+
CoreOptions.SCAN_IGNORE_CORRUPT_FILE.key(), "true")));
+ assertThat(ids)
+ .containsExactly(
+ INTACT_FILE_FIRST_ID, INTACT_FILE_FIRST_ID + 1,
INTACT_FILE_FIRST_ID + 2);
+ }
+
+ @Test
+ void testLostFileIsNotTreatedAsCorrupt() throws Exception {
+ writeGzip(tempDir.resolve("lost.csv.gz"), "csv", 0, INTACT_FILE_ROWS);
+ FormatTable table =
+ createTable("csv")
+ .copy(
+ Collections.singletonMap(
+
CoreOptions.SCAN_IGNORE_CORRUPT_FILE.key(), "true"));
+ TableScan.Plan plan = table.newReadBuilder().newScan().plan();
+ // Plans list files lazily: materialize the splits before the file
disappears.
+ assertThat(plan.splits()).isNotEmpty();
+ Files.delete(tempDir.resolve("lost.csv.gz"));
+
+ assertThatThrownBy(
+ () -> {
+ try (RecordReader<InternalRow> reader =
+
table.newReadBuilder().newRead().createReader(plan)) {
+ reader.forEachRemaining(row -> {});
+ }
+ })
+ .isInstanceOf(FileNotFoundException.class);
+ }
+
+ private FormatTable createTable(String format) {
+ return createTable(format, LocalFileIO.create());
+ }
+
+ private FormatTable createTable(String format, FileIO fileIO) {
+ Map<String, String> options = new HashMap<>();
+ options.put(CoreOptions.FILE_FORMAT.key(), format);
+ return FormatTable.builder()
+ .fileIO(fileIO)
+ .identifier(Identifier.create("test_db", "corrupt_" + format))
+ .rowType(
+ RowType.builder()
+ .field("id", DataTypes.INT())
+ .field("name", DataTypes.STRING())
+ .build())
+ .partitionKeys(Collections.emptyList())
+ .location(tempDir.toString())
+ .format(FormatTable.Format.valueOf(format.toUpperCase()))
+ .options(options)
+ .build();
+ }
+
+ private static List<Integer> readIds(FormatTable table) throws IOException
{
+ TableScan.Plan plan = table.newReadBuilder().newScan().plan();
+ List<Integer> ids = new ArrayList<>();
+ try (RecordReader<InternalRow> reader =
+ table.newReadBuilder().newRead().createReader(plan)) {
+ reader.forEachRemaining(row -> ids.add(row.getInt(0)));
+ }
+ return ids;
+ }
+
+ private static void writeGzip(Path file, String format, int firstId, int
rows)
+ throws IOException {
+ // Random names keep the compression ratio low, so half of the file
still holds many rows.
+ Random random = new Random(firstId);
+ try (OutputStream out = new
GZIPOutputStream(Files.newOutputStream(file))) {
+ for (int id = firstId; id < firstId + rows; id++) {
+ String name = Long.toHexString(random.nextLong());
+ String line =
+ "csv".equals(format)
+ ? id + "," + name
+ : "{\"id\":" + id + ",\"name\":\"" + name +
"\"}";
+ out.write((line + "\n").getBytes(StandardCharsets.UTF_8));
+ }
+ }
+ }
+
+ private static void truncateToHalf(Path file) throws IOException {
+ byte[] bytes = Files.readAllBytes(file);
+ byte[] half = new byte[bytes.length / 2];
+ System.arraycopy(bytes, 0, half, 0, half.length);
+ Files.write(file, half);
+ }
+
+ /**
+ * Throws {@link ZipError} for files named after the failure to simulate:
either as soon as the
+ * file is opened, or once the first {@link #ZIP_ERROR_AFTER_BYTES} bytes
have been read.
+ */
+ private static class ZipErrorFileIO extends LocalFileIO {
+
+ private static final long serialVersionUID = 1L;
+
+ @Override
+ public SeekableInputStream newInputStream(org.apache.paimon.fs.Path
path)
+ throws IOException {
+ String name = path.getName();
+ if (name.startsWith(ZIP_ERROR_AT_OPEN)) {
+ throw new ZipError("simulated zip error at open: " + name);
+ }
+ SeekableInputStream in = super.newInputStream(path);
+ if (!name.startsWith(ZIP_ERROR_WHILE_READING)) {
+ return in;
+ }
+ return new SeekableInputStreamWrapper(in) {
+ private long bytesRead;
+
+ @Override
+ public int read() throws IOException {
+ checkLimit();
+ int b = super.read();
+ if (b >= 0) {
+ bytesRead++;
+ }
+ return b;
+ }
+
+ @Override
+ public int read(byte[] b, int off, int len) throws IOException
{
+ checkLimit();
+ int n = super.read(b, off, len);
+ if (n > 0) {
+ bytesRead += n;
+ }
+ return n;
+ }
+
+ private void checkLimit() {
+ if (bytesRead >= ZIP_ERROR_AFTER_BYTES) {
+ throw new ZipError("simulated zip error while reading:
" + name);
+ }
+ }
+ };
+ }
+ }
+}