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 feabf2c6d7 [core] Fix latest snapshot race with concurrent expiration
feabf2c6d7 is described below

commit feabf2c6d74ca3e1ae30e0ee8776d05ad33e1b98
Author: JingsongLi <[email protected]>
AuthorDate: Thu Aug 27 00:29:33 2026 +0800

    [core] Fix latest snapshot race with concurrent expiration
---
 .../org/apache/paimon/utils/SnapshotManager.java   | 15 ++++++-
 .../apache/paimon/utils/SnapshotManagerTest.java   | 51 ++++++++++++++++++++++
 2 files changed, 65 insertions(+), 1 deletion(-)

diff --git 
a/paimon-core/src/main/java/org/apache/paimon/utils/SnapshotManager.java 
b/paimon-core/src/main/java/org/apache/paimon/utils/SnapshotManager.java
index 75be8fe921..8cd1599978 100644
--- a/paimon-core/src/main/java/org/apache/paimon/utils/SnapshotManager.java
+++ b/paimon-core/src/main/java/org/apache/paimon/utils/SnapshotManager.java
@@ -185,7 +185,20 @@ public class SnapshotManager implements Serializable {
 
     public @Nullable Snapshot latestSnapshotFromFileSystem() {
         Long snapshotId = latestSnapshotIdFromFileSystem();
-        return snapshotId == null ? null : snapshot(snapshotId);
+        while (snapshotId != null) {
+            try {
+                return tryGetSnapshot(snapshotId);
+            } catch (FileNotFoundException e) {
+                Long newSnapshotId = latestSnapshotIdFromFileSystem();
+                if (snapshotId.equals(newSnapshotId)) {
+                    // Retry once to preserve the existing exception when the 
latest snapshot is
+                    // genuinely missing instead of being concurrently expired.
+                    return snapshot(snapshotId);
+                }
+                snapshotId = newSnapshotId;
+            }
+        }
+        return null;
     }
 
     public @Nullable Long latestSnapshotId() {
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/utils/SnapshotManagerTest.java 
b/paimon-core/src/test/java/org/apache/paimon/utils/SnapshotManagerTest.java
index 5adbf4129f..bf1a005878 100644
--- a/paimon-core/src/test/java/org/apache/paimon/utils/SnapshotManagerTest.java
+++ b/paimon-core/src/test/java/org/apache/paimon/utils/SnapshotManagerTest.java
@@ -111,6 +111,29 @@ public class SnapshotManagerTest {
         
assertThat(snapshotManager.earliestSnapshot().id()).isEqualTo(isRaceCondition ? 
1 : 0);
     }
 
+    @Test
+    public void testLatestSnapshotWithConcurrentExpiration() throws 
IOException {
+        long millis = 1684726826L;
+        FileIO localFileIO = LocalFileIO.create();
+        SnapshotManager snapshotManager =
+                new LatestSnapshotRaceManager(localFileIO, new 
Path(tempDir.toString()), millis);
+        Snapshot snapshot = createSnapshotWithMillis(0, millis);
+        localFileIO.tryToWriteAtomic(snapshotManager.snapshotPath(0), 
snapshot.toJson());
+
+        
assertThat(snapshotManager.latestSnapshotFromFileSystem().id()).isEqualTo(1);
+    }
+
+    @Test
+    public void testLatestSnapshotStillFailsWhenNoNewerSnapshotExists() {
+        SnapshotManager snapshotManager =
+                Mockito.spy(newSnapshotManager(LocalFileIO.create(), new 
Path(tempDir.toString())));
+        
Mockito.doReturn(1L).when(snapshotManager).latestSnapshotIdFromFileSystem();
+
+        assertThatThrownBy(snapshotManager::latestSnapshotFromFileSystem)
+                .hasMessageContaining("Snapshot file")
+                .hasMessageContaining("does not exist");
+    }
+
     @Test
     public void testRepairEarliestSnapshot() throws IOException {
         FileIO fileIO = LocalFileIO.create();
@@ -904,4 +927,32 @@ public class SnapshotManagerTest {
             return snapshotId;
         }
     }
+
+    /** Simulates a commit and expiration between finding and reading the 
latest snapshot. */
+    private static class LatestSnapshotRaceManager extends SnapshotManager {
+        private final long millis;
+        private boolean expireLatestSnapshot = true;
+
+        private LatestSnapshotRaceManager(FileIO fileIO, Path tablePath, long 
millis) {
+            super(fileIO, tablePath, DEFAULT_MAIN_BRANCH, null, null);
+            this.millis = millis;
+        }
+
+        @Override
+        public @Nullable Long latestSnapshotIdFromFileSystem() {
+            Long snapshotId = super.latestSnapshotIdFromFileSystem();
+            if (snapshotId != null && expireLatestSnapshot) {
+                Snapshot nextSnapshot = createSnapshotWithMillis(snapshotId + 
1, millis + 1000);
+                try {
+                    fileIO().tryToWriteAtomic(
+                                    snapshotPath(nextSnapshot.id()), 
nextSnapshot.toJson());
+                    fileIO().delete(snapshotPath(snapshotId), true);
+                } catch (IOException e) {
+                    throw new RuntimeException(e);
+                }
+                expireLatestSnapshot = false;
+            }
+            return snapshotId;
+        }
+    }
 }

Reply via email to