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

wuchong pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/fluss.git


The following commit(s) were added to refs/heads/main by this push:
     new 56dc6dedd [server] Fix TabletServer fails to restart after unclean 
shutdown due to EOFException in log recovery (#2942)
56dc6dedd is described below

commit 56dc6dedd55f99dc72268a560330b5962a8e6fee
Author: Liebing <[email protected]>
AuthorDate: Mon May 11 10:44:14 2026 +0800

    [server] Fix TabletServer fails to restart after unclean shutdown due to 
EOFException in log recovery (#2942)
---
 .../org/apache/fluss/server/log/LogLoader.java     |  87 +++++----
 .../org/apache/fluss/server/log/LogSegment.java    |  43 +++--
 .../org/apache/fluss/server/log/LogLoaderTest.java | 197 +++++++++++----------
 3 files changed, 179 insertions(+), 148 deletions(-)

diff --git 
a/fluss-server/src/main/java/org/apache/fluss/server/log/LogLoader.java 
b/fluss-server/src/main/java/org/apache/fluss/server/log/LogLoader.java
index 572fbd304..b5d5b9f45 100644
--- a/fluss-server/src/main/java/org/apache/fluss/server/log/LogLoader.java
+++ b/fluss-server/src/main/java/org/apache/fluss/server/log/LogLoader.java
@@ -23,7 +23,6 @@ import org.apache.fluss.exception.InvalidOffsetException;
 import org.apache.fluss.exception.LogSegmentOffsetOverflowException;
 import org.apache.fluss.exception.LogStorageException;
 import org.apache.fluss.metadata.LogFormat;
-import org.apache.fluss.server.exception.CorruptIndexException;
 import org.apache.fluss.utils.FlussPaths;
 import org.apache.fluss.utils.types.Tuple2;
 
@@ -137,6 +136,7 @@ final class LogLoader {
      */
     private Tuple2<Long, Long> recoverLog() throws IOException {
         if (!isCleanShutdown) {
+            long recoverLogStart = System.currentTimeMillis();
             List<LogSegment> unflushed =
                     logSegments.values(recoveryPointCheckpoint, 
Long.MAX_VALUE);
             int numUnflushed = unflushed.size();
@@ -153,50 +153,57 @@ final class LogLoader {
                         numUnflushed,
                         logSegments.getTableBucket());
 
+                int truncatedBytes = -1;
                 try {
-                    segment.sanityCheck();
-                } catch (NoSuchFileException | CorruptIndexException e) {
+                    truncatedBytes = recoverSegment(segment);
+                } catch (InvalidOffsetException e) {
+                    long startOffset = segment.getBaseOffset();
                     LOG.warn(
-                            "Found invalid index file corresponding log file 
{} for bucket {}, "
-                                    + "recovering segment and rebuilding index 
files...",
-                            
segment.getFileLogRecords().file().getAbsoluteFile(),
+                            "Found invalid offset during recovery for bucket 
{}. Deleting the corrupt segment "
+                                    + "and creating an empty one with starting 
offset {}",
                             logSegments.getTableBucket(),
-                            e);
-
-                    int truncatedBytes = -1;
-                    try {
-                        truncatedBytes = recoverSegment(segment);
-                    } catch (InvalidOffsetException invalidOffsetException) {
-                        long startOffset = segment.getBaseOffset();
-                        LOG.warn(
-                                "Found invalid offset during recovery for 
bucket {}. Deleting the corrupt segment "
-                                        + "and creating an empty one with 
starting offset {}",
-                                logSegments.getTableBucket(),
-                                startOffset);
-                        truncatedBytes = segment.truncateTo(startOffset);
-                    }
+                            startOffset);
+                    truncatedBytes = segment.truncateTo(startOffset);
+                }
 
-                    if (truncatedBytes > 0) {
-                        // we had an invalid message, delete all remaining log
-                        LOG.warn(
-                                "Corruption found in segment {} for bucket {}, 
truncating to offset {}",
-                                segment.getBaseOffset(),
-                                logSegments.getTableBucket(),
-                                segment.readNextOffset());
-                        removeAndDeleteSegments(unflushedIter);
-                        truncated = true;
-                    }
+                if (truncatedBytes > 0) {
+                    // we had an invalid message, delete all remaining log
+                    LOG.warn(
+                            "Corruption found in segment {} for bucket {}, 
truncating to offset {}",
+                            segment.getBaseOffset(),
+                            logSegments.getTableBucket(),
+                            segment.readNextOffset());
+                    removeAndDeleteSegments(unflushedIter);
+                    truncated = true;
+                } else {
+                    numFlushed += 1;
                 }
-                numFlushed += 1;
             }
+            long recoverLogEnd = System.currentTimeMillis();
+            LOG.info(
+                    "Log recovery completed for bucket {} in {} ms",
+                    logSegments.getTableBucket(),
+                    recoverLogEnd - recoverLogStart);
         }
 
-        // TODO truncate log to recover maybe unflush segments.
         if (logSegments.isEmpty()) {
+            // TODO: use logStartOffset if issue 
https://github.com/apache/fluss/issues/744 ready
             logSegments.add(LogSegment.open(logTabletDir, 0L, conf, 
logFormat));
         }
+
         long logEndOffset = logSegments.lastSegment().get().readNextOffset();
-        return Tuple2.of(recoveryPointCheckpoint, logEndOffset);
+
+        // Update the recovery point if there was a clean shutdown and did not 
perform any changes
+        // to the segment. Otherwise, we just ensure that the recovery point 
is not ahead of the log
+        // end offset. To ensure correctness and to make it easier to reason 
about, it's best to
+        // only advance the recovery point when the log is flushed. If we 
advanced the recovery
+        // point here, we could skip recovery for unflushed segments if the 
server crashed after we
+        // checkpointed the recovery point and before we flush the segment.
+        if (isCleanShutdown) {
+            return Tuple2.of(logEndOffset, logEndOffset);
+        } else {
+            return Tuple2.of(Math.min(recoveryPointCheckpoint, logEndOffset), 
logEndOffset);
+        }
     }
 
     /**
@@ -294,6 +301,20 @@ final class LogLoader {
                         long baseOffset = FlussPaths.offsetFromFile(file);
                         LogSegment segment =
                                 LogSegment.open(logTabletDir, baseOffset, 
conf, true, 0, logFormat);
+
+                        try {
+                            segment.sanityCheck();
+                        } catch (NoSuchFileException e) {
+                            if (isCleanShutdown
+                                    || segment.getBaseOffset() < 
recoveryPointCheckpoint) {
+                                LOG.error(
+                                        "Could not find offset index file 
corresponding to log file {} "
+                                                + "for bucket {}, recovering 
segment and rebuilding index files...",
+                                        
segment.getFileLogRecords().file().getAbsoluteFile(),
+                                        logSegments.getTableBucket());
+                            }
+                            recoverSegment(segment);
+                        }
                         logSegments.add(segment);
                     }
                 }
diff --git 
a/fluss-server/src/main/java/org/apache/fluss/server/log/LogSegment.java 
b/fluss-server/src/main/java/org/apache/fluss/server/log/LogSegment.java
index a88084277..a0d7c6a12 100644
--- a/fluss-server/src/main/java/org/apache/fluss/server/log/LogSegment.java
+++ b/fluss-server/src/main/java/org/apache/fluss/server/log/LogSegment.java
@@ -20,6 +20,7 @@ package org.apache.fluss.server.log;
 import org.apache.fluss.annotation.Internal;
 import org.apache.fluss.config.ConfigOptions;
 import org.apache.fluss.config.Configuration;
+import org.apache.fluss.exception.CorruptMessageException;
 import org.apache.fluss.exception.CorruptRecordException;
 import org.apache.fluss.exception.InvalidColumnProjectionException;
 import org.apache.fluss.exception.InvalidRecordException;
@@ -188,7 +189,6 @@ public final class LogSegment {
                             + lazyOffsetIndex.file().getAbsolutePath()
                             + " does not exist.");
         }
-        lazyOffsetIndex.get().sanityCheck();
 
         if (!lazyTimeIndex.file().exists()) {
             throw new NoSuchFileException(
@@ -196,7 +196,10 @@ public final class LogSegment {
                             + lazyTimeIndex.file().getAbsolutePath()
                             + " does not exist.");
         }
-        lazyTimeIndex.get().sanityCheck();
+
+        // Sanity checks for time index and offset index are skipped because
+        // we will recover the segments above the recovery point in 
recoverLog()
+        // in any case so sanity checking them here is redundant.
     }
 
     /**
@@ -324,14 +327,17 @@ public final class LogSegment {
 
                 // The max timestamp is exposed at the batch level, so no need 
to iterate the
                 // records
-                if (batch.commitTimestamp() > maxTimestampSoFar()) {
+                if (batch.commitTimestamp() > 
maxTimestampAndStartOffsetSoFar.timestamp) {
                     maxTimestampAndStartOffsetSoFar =
                             new TimestampOffset(batch.commitTimestamp(), 
batch.baseLogOffset());
                 }
 
                 if (validBytes - lastIndexEntry > indexIntervalBytes) {
                     offsetIndex().append(batch.lastLogOffset(), validBytes);
-                    timeIndex().maybeAppend(maxTimestampSoFar(), 
startOffsetOfMaxTimestampSoFar());
+                    timeIndex()
+                            .maybeAppend(
+                                    maxTimestampAndStartOffsetSoFar.timestamp,
+                                    maxTimestampAndStartOffsetSoFar.offset);
                     lastIndexEntry = validBytes;
                 }
 
@@ -339,26 +345,27 @@ public final class LogSegment {
 
                 validBytes += batch.sizeInBytes();
             }
-        } catch (CorruptRecordException | InvalidRecordException e) {
+        } catch (CorruptRecordException
+                | InvalidRecordException
+                | CorruptMessageException
+                | IllegalArgumentException
+                | IndexOutOfBoundsException e) {
+            // Data corruption detected during recovery: CRC mismatch, invalid 
record format,
+            // or truncated batch from an interrupted write. Truncate from 
this point onward.
             LOG.warn(
-                    "Found invalid messages in log segment "
-                            + fileLogRecords.file().getAbsolutePath()
-                            + " at byte offset "
-                            + validBytes
-                            + ": "
-                            + e.getMessage()
-                            + ". "
-                            + e.getCause());
+                    "Found invalid messages in log segment {} at byte offset 
{}: {}. {}",
+                    fileLogRecords.file().getAbsolutePath(),
+                    validBytes,
+                    e.getMessage(),
+                    e.getCause());
         }
 
         int truncated = fileLogRecords.sizeInBytes() - validBytes;
         if (truncated > 0) {
             LOG.debug(
-                    "Truncated "
-                            + truncated
-                            + " invalid bytes at the end of segment "
-                            + fileLogRecords.file().getAbsolutePath()
-                            + " during recovery");
+                    "Truncated {} invalid bytes at the end of segment {} 
during recovery",
+                    truncated,
+                    fileLogRecords.file().getAbsolutePath());
         }
         fileLogRecords.truncateTo(validBytes);
         offsetIndex().trimToValidSize();
diff --git 
a/fluss-server/src/test/java/org/apache/fluss/server/log/LogLoaderTest.java 
b/fluss-server/src/test/java/org/apache/fluss/server/log/LogLoaderTest.java
index 582b2934d..075af47c4 100644
--- a/fluss-server/src/test/java/org/apache/fluss/server/log/LogLoaderTest.java
+++ b/fluss-server/src/test/java/org/apache/fluss/server/log/LogLoaderTest.java
@@ -45,6 +45,7 @@ import java.nio.file.Files;
 import java.nio.file.Path;
 import java.nio.file.StandardOpenOption;
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.HashSet;
@@ -268,9 +269,6 @@ final class LogLoaderTest extends LogTestBase {
         corruptSegment.getFileLogRecords().append(memoryLogRecords);
         logTablet.close();
 
-        // delete the index file to trigger the recovery
-        corruptSegment.offsetIndex().deleteIfExists();
-
         logTablet = createLogTablet(false);
         // the corrupt segment should be truncated to base offset
         
assertThat(logTablet.localLogEndOffset()).isEqualTo(corruptSegment.getBaseOffset());
@@ -308,9 +306,6 @@ final class LogLoaderTest extends LogTestBase {
                                 .sorted())
                 .containsExactly(2L, 4L);
 
-        // Delete all offset index files to trigger segment recover
-        deleteAllOffsetIndexFile(log);
-
         log = createLogTablet(false);
         assertThat(
                         WriterStateManager.listSnapshotFiles(logDir).stream()
@@ -408,7 +403,7 @@ final class LogLoaderTest extends LogTestBase {
         // after this step, the segments should be
         //              [0-0], [1-1], [2-2], [3-3], [4-4], [5-5], [6-]
         // writer id    1      2      1      2      1      2
-        // se1 id       0      0      1      1      2      2
+        // seq id       0      0      1      1      2      2
         // snapshot            1      2      3      4      5      6
         for (int i = 0; i <= 5; i++) {
             if (i % 2 == 0) {
@@ -473,22 +468,19 @@ final class LogLoaderTest extends LogTestBase {
         long recoveryPoint = segmentOffsets.get(segmentOffsets.size() - 4);
         
assertThat(recoveryPoint).isLessThan(offsetForSegmentAfterRecoveryPoint);
 
-        // 1. Test unclean shut without any recovery
+        // 1. Test unclean shutdown
         // Retain snapshots for the last 2 segments (delete snapshots before 
that)
         long snapshotRetentionOffset = 
segmentOffsets.get(segmentOffsets.size() - 2);
         
log.writerStateManager().deleteSnapshotsBefore(snapshotRetentionOffset);
         log.close();
 
-        // Reopen the log with recovery point. Although we use unclean 
shutdown here,
-        // all the index files are correctly close, so Fluss will not trigger 
recover for any
-        // segment.
+        // Reopen the log with recovery point. Since we use unclean shutdown 
here,
+        // all the log segments after recovery point will be recovered.
         log = createLogTablet(false, recoveryPoint);
 
-        // Expected snapshot offsets: last 2 segment base offsets + log end 
offset
-        List<Long> lastTowSegmentOffsets =
-                segmentOffsets.subList(
-                        Math.max(0, segmentOffsets.size() - 2), 
segmentOffsets.size());
-        expectedSnapshotOffsets = new HashSet<>(lastTowSegmentOffsets);
+        // Expected snapshot offsets: segments base offset after recovery point
+        expectedSnapshotOffsets =
+                new HashSet<>(segmentOffsets.subList((int) recoveryPoint, 
segmentOffsets.size()));
         expectedSnapshotOffsets.add(log.localLogEndOffset());
 
         // Verify that expected snapshot offsets exist
@@ -509,71 +501,6 @@ final class LogLoaderTest extends LogTestBase {
         expectedWritersLastBatchSequence.put(wid2, seq2 - 1);
         
assertThat(actualWritersLastBatchSequence).isEqualTo(expectedWritersLastBatchSequence);
         log.close();
-
-        // 2. Test unclean shut down without recover segment will rebuild 
writer state
-        // Delete all snapshot files
-        deleteAllWriterSnapshot(logDir);
-
-        // Reopen the log with recovery point. Although Fluss will not trigger 
recover for any
-        // segment, but the writer state should be rebuilt.
-        log = createLogTablet(false, recoveryPoint);
-
-        actualSnapshotOffsets =
-                WriterStateManager.listSnapshotFiles(logDir).stream()
-                        .map(snapshotFile -> snapshotFile.offset)
-                        .collect(Collectors.toSet());
-        // Will rebuild writer state for all segments, but only take snapshot 
for the last 2
-        // segments and the last offset
-        expectedSnapshotOffsets = new HashSet<>();
-        expectedSnapshotOffsets.add(segmentOffsets.get(segmentOffsets.size() - 
2));
-        expectedSnapshotOffsets.add(segmentOffsets.get(segmentOffsets.size() - 
1));
-        expectedSnapshotOffsets.add(log.localLogEndOffset());
-        assertThat(actualSnapshotOffsets).isEqualTo(expectedSnapshotOffsets);
-
-        // Verify that expected writers last batch sequence
-        actualWritersLastBatchSequence =
-                log.writerStateManager().activeWriters().entrySet().stream()
-                        .collect(
-                                Collectors.toMap(
-                                        Map.Entry::getKey, e -> 
e.getValue().lastBatchSequence()));
-        expectedWritersLastBatchSequence = new HashMap<>();
-        expectedWritersLastBatchSequence.put(wid1, seq1 - 1);
-        expectedWritersLastBatchSequence.put(wid2, seq2 - 1);
-        
assertThat(actualWritersLastBatchSequence).isEqualTo(expectedWritersLastBatchSequence);
-        log.close();
-
-        // 3. Test unclean shut down with recover segment will rebuild writer 
state for each segment
-        // after recovery point.
-        // Delete all snapshot files and index files (to trigger segment 
recover)
-        deleteAllWriterSnapshot(logDir);
-        deleteAllOffsetIndexFile(log);
-
-        // Reopen the log with recovery point. All segments will be recovered 
since we delete all
-        // the index files
-        log = createLogTablet(false, recoveryPoint);
-
-        // Writer snapshot files should be rebuilt for each segment after 
recovery point
-        actualSnapshotOffsets =
-                WriterStateManager.listSnapshotFiles(logDir).stream()
-                        .map(snapshotFile -> snapshotFile.offset)
-                        .collect(Collectors.toSet());
-        expectedSnapshotOffsets =
-                log.logSegments(recoveryPoint, 
log.localLogEndOffset()).stream()
-                        .map(LogSegment::getBaseOffset)
-                        .collect(Collectors.toSet());
-        expectedSnapshotOffsets.add(log.localLogEndOffset());
-        assertThat(actualSnapshotOffsets).isEqualTo(expectedSnapshotOffsets);
-
-        actualWritersLastBatchSequence =
-                log.writerStateManager().activeWriters().entrySet().stream()
-                        .collect(
-                                Collectors.toMap(
-                                        Map.Entry::getKey, e -> 
e.getValue().lastBatchSequence()));
-        expectedWritersLastBatchSequence = new HashMap<>();
-        expectedWritersLastBatchSequence.put(wid1, seq1 - 1);
-        expectedWritersLastBatchSequence.put(wid2, seq2 - 1);
-        
assertThat(actualWritersLastBatchSequence).isEqualTo(expectedWritersLastBatchSequence);
-        log.close();
     }
 
     @Test
@@ -627,6 +554,91 @@ final class LogLoaderTest extends LogTestBase {
                 .containsExactly(1L, 2L, 4L);
     }
 
+    /**
+     * Tests that log recovery is correctly triggered after an unclean 
shutdown (TabletServer
+     * crash).
+     *
+     * <p>The test simulates an unclean shutdown by:
+     *
+     * <ol>
+     *   <li>Writing records to a log to create multiple segments
+     *   <li>Recording the logEndOffset before corruption
+     *   <li>Corrupting the index files (appending garbage bytes, simulating 
incomplete flush)
+     *   <li>Appending garbage bytes to the active .log file tail (simulating 
partial batch write)
+     *   <li>Reloading the log with {@code isCleanShutdown=false}
+     *   <li>Verifying that recovery truncates the corrupt tail, rebuilds 
indexes, and restores the
+     *       correct logEndOffset
+     * </ol>
+     */
+    @Test
+    void testLogRecoveryIsCalledUponBrokerCrash() throws Exception {
+        // Step 1: Create log and write enough records to produce multiple 
segments
+        int numRecords = 200;
+        LogTablet logTablet = createLogTablet(true);
+        appendRecords(logTablet, numRecords);
+
+        // Step 2: Record the expected logEndOffset after clean writes
+        long expectedLogEndOffset = logTablet.localLogEndOffset();
+        assertThat(expectedLogEndOffset).isEqualTo(numRecords);
+
+        // Step 3: Collect index files and the active segment's .log file 
before "crash"
+        List<File> indexFiles = collectIndexFiles(logTablet.logSegments());
+        File activeLogFile = 
logTablet.activeLogSegment().getFileLogRecords().file();
+
+        // Step 4: Close the log (simulating the on-disk state at the moment 
of crash)
+        logTablet.close();
+
+        // Step 5: Corrupt the index files (simulate incomplete index flush 
during crash)
+        for (File indexFile : indexFiles) {
+            try (FileChannel fileChannel =
+                    FileChannel.open(indexFile.toPath(), 
StandardOpenOption.APPEND)) {
+                // Append 12 bytes of garbage – enough to make the index size 
non-multiple of
+                // entrySize and trigger CorruptIndexException during 
sanityCheck
+                for (int i = 0; i < 12; i++) {
+                    fileChannel.write(ByteBuffer.wrap(new byte[] {(byte) i}));
+                }
+            }
+        }
+
+        // Step 6: Append garbage bytes to the active segment's .log file tail
+        // (simulate a partial/incomplete batch that was being written at 
crash time)
+        try (FileChannel logFileChannel =
+                FileChannel.open(activeLogFile.toPath(), 
StandardOpenOption.APPEND)) {
+            byte[] garbage = new byte[40];
+            // Corrupt the last log file in the active segment by appending 
zero bytes,
+            // simulating a typical interrupted write during an unclean 
shutdown where
+            // the filesystem zero-fills pre-allocated but unwritten blocks.
+            Arrays.fill(garbage, (byte) 0);
+            logFileChannel.write(ByteBuffer.wrap(garbage));
+        }
+
+        // Step 7: Reload with isCleanShutdown=false – this must trigger log 
recovery
+        logTablet = createLogTablet(false);
+
+        // Step 8: Verify that recovery succeeded and the logEndOffset is 
restored correctly.
+        // Recovery should truncate the garbage bytes appended to the .log 
file and rebuild
+        // indexes, so the final offset must equal the number of valid records 
written.
+        
assertThat(logTablet.localLogEndOffset()).isEqualTo(expectedLogEndOffset);
+
+        // Step 9: Verify all index files pass sanity check after recovery
+        for (LogSegment segment : logTablet.logSegments()) {
+            segment.offsetIndex().sanityCheck();
+            segment.timeIndex().sanityCheck();
+        }
+
+        // Step 10: Verify that all previously written records are still 
readable after recovery
+        for (int i = 0; i < numRecords; i++) {
+            assertThat(logTablet.lookupOffsetForTimestamp(clock.milliseconds() 
+ i * 10L))
+                    .isEqualTo(i);
+        }
+
+        // Step 11: Verify that the log is still writable after recovery
+        appendRecords(logTablet, 1, (int) expectedLogEndOffset);
+        
assertThat(logTablet.localLogEndOffset()).isEqualTo(expectedLogEndOffset + 1);
+
+        logTablet.close();
+    }
+
     private LogTablet createLogTablet(boolean isCleanShutdown) throws 
Exception {
         return createLogTablet(isCleanShutdown, 0);
     }
@@ -649,8 +661,13 @@ final class LogLoaderTest extends LogTestBase {
     }
 
     private void appendRecords(LogTablet logTablet, int numRecords) throws 
Exception {
-        int baseOffset = 0;
-        int batchSequence = 0;
+        appendRecords(logTablet, numRecords, 0);
+    }
+
+    private void appendRecords(LogTablet logTablet, int numRecords, int 
startOffset)
+            throws Exception {
+        int baseOffset = startOffset;
+        int batchSequence = startOffset;
         for (int i = 0; i < numRecords; i++) {
             List<Object[]> objects = Collections.singletonList(new Object[] 
{1, "a"});
             List<ChangeType> changeTypes =
@@ -684,18 +701,4 @@ final class LogLoaderTest extends LogTestBase {
         }
         return indexFiles;
     }
-
-    private void deleteAllOffsetIndexFile(LogTablet logTablet) throws 
IOException {
-        List<LogSegment> logSegments = logTablet.logSegments();
-        for (LogSegment segment : logSegments) {
-            segment.offsetIndex().deleteIfExists();
-        }
-    }
-
-    private void deleteAllWriterSnapshot(File logDir) throws Exception {
-        List<SnapshotFile> snapshotFiles = 
WriterStateManager.listSnapshotFiles(logDir);
-        for (SnapshotFile snapshotFile : snapshotFiles) {
-            snapshotFile.deleteIfExists();
-        }
-    }
 }

Reply via email to