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 f7d95930e8 [core] Fix SnapshotManager watermark binary searches with 
null watermarks (#9037)
f7d95930e8 is described below

commit f7d95930e86d5e9578883f962e77aca8b6b2932d
Author: kid <[email protected]>
AuthorDate: Fri Aug 7 13:33:14 2026 +0800

    [core] Fix SnapshotManager watermark binary searches with null watermarks 
(#9037)
---
 .../org/apache/paimon/utils/SnapshotManager.java   | 100 ++++----
 .../source/snapshot/WatermarkTimeTravelTest.java   | 279 +++++++++++++++++++++
 .../apache/paimon/utils/SnapshotManagerTest.java   | 199 ++++++++++++++-
 3 files changed, 519 insertions(+), 59 deletions(-)

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 984a98902e..23694934e8 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
@@ -411,9 +411,7 @@ public class SnapshotManager implements Serializable {
 
     public @Nullable Snapshot earlierOrEqualWatermark(long watermark) {
         Long latest = latestSnapshotId();
-        // If latest == Long.MIN_VALUE don't need next binary search for 
watermark
-        // which can reduce IO cost with snapshot
-        if (latest == null || snapshot(latest).watermark() == Long.MIN_VALUE) {
+        if (latest == null) {
             return null;
         }
 
@@ -423,45 +421,35 @@ public class SnapshotManager implements Serializable {
         }
         long earliest = earliestSnapShot.id();
 
-        Long earliestWatermark = null;
-        // find the first snapshot with watermark
-        if ((earliestWatermark = earliestSnapShot.watermark()) == null) {
-            while (earliest < latest) {
-                earliest++;
-                earliestWatermark = snapshot(earliest).watermark();
-                if (earliestWatermark != null) {
-                    break;
-                }
-            }
+        // find the first snapshot with a real watermark
+        Long earliestWatermark = earliestSnapShot.watermark();
+        while (isMissingWatermark(earliestWatermark) && earliest < latest) {
+            earliest++;
+            earliestWatermark = snapshot(earliest).watermark();
         }
-        if (earliestWatermark == null) {
+        if (isMissingWatermark(earliestWatermark) || earliestWatermark > 
watermark) {
             return null;
         }
-
-        if (earliestWatermark >= watermark) {
-            return snapshot(earliest);
-        }
         Snapshot finalSnapshot = null;
 
         while (earliest <= latest) {
             long mid = earliest + (latest - earliest) / 2; // Avoid overflow
-            Snapshot snapshot = snapshot(mid);
+            // A snapshot without a watermark takes the ordering position of 
the
+            // nearest earlier snapshot (within the search window) that 
carries one
+            long pos = mid;
+            Snapshot snapshot = snapshot(pos);
             Long commitWatermark = snapshot.watermark();
-            if (commitWatermark == null) {
-                // find the first snapshot with watermark
-                while (mid >= earliest) {
-                    mid--;
-                    commitWatermark = snapshot(mid).watermark();
-                    if (commitWatermark != null) {
-                        break;
-                    }
-                }
+            while (isMissingWatermark(commitWatermark) && pos > earliest) {
+                pos--;
+                snapshot = snapshot(pos);
+                commitWatermark = snapshot.watermark();
             }
-            if (commitWatermark == null) {
+            if (isMissingWatermark(commitWatermark)) {
+                // No snapshot with watermark in [earliest, mid]: skip the 
range
                 earliest = mid + 1;
             } else {
                 if (commitWatermark > watermark) {
-                    latest = mid - 1; // Search in the left half
+                    latest = pos - 1; // Search in the left half
                 } else if (commitWatermark < watermark) {
                     earliest = mid + 1; // Search in the right half
                     finalSnapshot = snapshot;
@@ -476,9 +464,7 @@ public class SnapshotManager implements Serializable {
 
     public @Nullable Snapshot laterOrEqualWatermark(long watermark) {
         Long latest = latestSnapshotId();
-        // If latest == Long.MIN_VALUE don't need next binary search for 
watermark
-        // which can reduce IO cost with snapshot
-        if (latest == null || snapshot(latest).watermark() == Long.MIN_VALUE) {
+        if (latest == null) {
             return null;
         }
 
@@ -488,18 +474,13 @@ public class SnapshotManager implements Serializable {
         }
         long earliest = earliestSnapShot.id();
 
-        Long earliestWatermark = null;
-        // find the first snapshot with watermark
-        if ((earliestWatermark = earliestSnapShot.watermark()) == null) {
-            while (earliest < latest) {
-                earliest++;
-                earliestWatermark = snapshot(earliest).watermark();
-                if (earliestWatermark != null) {
-                    break;
-                }
-            }
+        // find the first snapshot with a real watermark
+        Long earliestWatermark = earliestSnapShot.watermark();
+        while (isMissingWatermark(earliestWatermark) && earliest < latest) {
+            earliest++;
+            earliestWatermark = snapshot(earliest).watermark();
         }
-        if (earliestWatermark == null) {
+        if (isMissingWatermark(earliestWatermark)) {
             return null;
         }
 
@@ -510,23 +491,22 @@ public class SnapshotManager implements Serializable {
 
         while (earliest <= latest) {
             long mid = earliest + (latest - earliest) / 2; // Avoid overflow
-            Snapshot snapshot = snapshot(mid);
+            // A snapshot without a watermark takes the ordering position of 
the
+            // nearest earlier snapshot (within the search window) that 
carries one
+            long pos = mid;
+            Snapshot snapshot = snapshot(pos);
             Long commitWatermark = snapshot.watermark();
-            if (commitWatermark == null) {
-                // find the first snapshot with watermark
-                while (mid >= earliest) {
-                    mid--;
-                    commitWatermark = snapshot(mid).watermark();
-                    if (commitWatermark != null) {
-                        break;
-                    }
-                }
+            while (isMissingWatermark(commitWatermark) && pos > earliest) {
+                pos--;
+                snapshot = snapshot(pos);
+                commitWatermark = snapshot.watermark();
             }
-            if (commitWatermark == null) {
+            if (isMissingWatermark(commitWatermark)) {
+                // No snapshot with watermark in [earliest, mid]: skip the 
range
                 earliest = mid + 1;
             } else {
                 if (commitWatermark > watermark) {
-                    latest = mid - 1; // Search in the left half
+                    latest = pos - 1; // Search in the left half
                     finalSnapshot = snapshot;
                 } else if (commitWatermark < watermark) {
                     earliest = mid + 1; // Search in the right half
@@ -539,6 +519,14 @@ public class SnapshotManager implements Serializable {
         return finalSnapshot;
     }
 
+    /**
+     * Both {@code null} and {@link Long#MIN_VALUE} mean that the snapshot 
carries no watermark (the
+     * latter is written as a sentinel by engines without watermark semantics).
+     */
+    private static boolean isMissingWatermark(@Nullable Long watermark) {
+        return watermark == null || watermark == Long.MIN_VALUE;
+    }
+
     public long snapshotCount() throws IOException {
         return snapshotIdStream().count();
     }
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/table/source/snapshot/WatermarkTimeTravelTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/table/source/snapshot/WatermarkTimeTravelTest.java
new file mode 100644
index 0000000000..569e2d5bd4
--- /dev/null
+++ 
b/paimon-core/src/test/java/org/apache/paimon/table/source/snapshot/WatermarkTimeTravelTest.java
@@ -0,0 +1,279 @@
+/*
+ * 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.source.snapshot;
+
+import org.apache.paimon.CoreOptions;
+import org.apache.paimon.Snapshot;
+import org.apache.paimon.manifest.ManifestCommittable;
+import org.apache.paimon.options.Options;
+import org.apache.paimon.table.sink.CommitMessage;
+import org.apache.paimon.table.sink.StreamTableCommit;
+import org.apache.paimon.table.sink.StreamTableWrite;
+import org.apache.paimon.table.sink.TableCommitImpl;
+import org.apache.paimon.table.source.Split;
+import org.apache.paimon.utils.SnapshotManager;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.Timeout;
+
+import javax.annotation.Nullable;
+
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.apache.paimon.CoreOptions.SCAN_WATERMARK;
+import static 
org.apache.paimon.testutils.assertj.PaimonAssertions.anyCauseMatches;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * End-to-end tests for watermark time travel: snapshots are produced through 
the real commit path
+ * ({@link TableCommitImpl} with {@link ManifestCommittable} watermarks, the 
same entry the Flink
+ * committer uses) and reads go through the real batch scan link ({@code 
scan.watermark} &rarr;
+ * {@link StaticFromWatermarkStartingScanner} &rarr; {@code 
SnapshotManager.laterOrEqualWatermark}).
+ */
+public class WatermarkTimeTravelTest extends ScannerTestBase {
+
+    @Test
+    public void testScanWatermarkWithDenseWatermarks() throws Exception {
+        createAppendOnlyTableWithoutCompaction();
+        StreamTableWrite write = table.newWrite(commitUser);
+        StreamTableCommit commit = table.newCommit(commitUser);
+
+        // snapshots 1, 2, 3 carry watermarks 100, 200, 300
+        for (int i = 0; i < 3; i++) {
+            commitRow(write, commit, i, 100L * (i + 1), i);
+        }
+
+        assertThat(scanFromWatermark(50)).hasSameElementsAs(Arrays.asList("+I 
1|0|0"));
+        assertThat(scanFromWatermark(150))
+                .hasSameElementsAs(Arrays.asList("+I 1|0|0", "+I 1|1|100"));
+        assertThat(scanFromWatermark(200))
+                .hasSameElementsAs(Arrays.asList("+I 1|0|0", "+I 1|1|100"));
+        assertThat(scanFromWatermark(250))
+                .hasSameElementsAs(Arrays.asList("+I 1|0|0", "+I 1|1|100", "+I 
1|2|200"));
+        assertNoSnapshotForWatermark(301);
+
+        write.close();
+        commit.close();
+    }
+
+    @Test
+    public void testScanWatermarkOnTableWithoutWatermarks() throws Exception {
+        createAppendOnlyTableWithoutCompaction();
+        StreamTableWrite write = table.newWrite(commitUser);
+        StreamTableCommit commit = table.newCommit(commitUser);
+
+        // snapshots 1, 2, 3 carry no watermark field, like any pure 
batch-written table
+        for (int i = 0; i < 3; i++) {
+            commitRow(write, commit, i, null, i);
+        }
+
+        // the defective guard unboxed the null watermark and threw a raw 
NullPointerException;
+        // the fix makes it a clean, actionable error
+        assertNoSnapshotForWatermark(100);
+
+        write.close();
+        commit.close();
+    }
+
+    @Test
+    @Timeout(60) // the defective search loops forever on this layout; the fix 
must terminate
+    public void testScanWatermarkWithInterleavedNullWatermarks() throws 
Exception {
+        createAppendOnlyTableWithoutCompaction();
+        StreamTableWrite write = table.newWrite(commitUser);
+        StreamTableCommit commit = table.newCommit(commitUser);
+
+        // snapshots 1..10, then snapshots 1, 5, 10 are assigned watermarks 
100, 200, 300
+        for (int i = 0; i < 10; i++) {
+            commitRow(write, commit, i, null, i);
+        }
+        patchWatermark(1, 100L);
+        patchWatermark(5, 200L);
+        patchWatermark(10, 300L);
+
+        assertThat(scanFromWatermark(50)).hasSameElementsAs(Arrays.asList("+I 
1|0|0"));
+        assertThat(scanFromWatermark(150))
+                .hasSameElementsAs(
+                        Arrays.asList(
+                                "+I 1|0|0",
+                                "+I 1|1|100",
+                                "+I 1|2|200",
+                                "+I 1|3|300",
+                                "+I 1|4|400"));
+        assertThat(scanFromWatermark(250))
+                .hasSameElementsAs(
+                        Arrays.asList(
+                                "+I 1|0|0",
+                                "+I 1|1|100",
+                                "+I 1|2|200",
+                                "+I 1|3|300",
+                                "+I 1|4|400",
+                                "+I 1|5|500",
+                                "+I 1|6|600",
+                                "+I 1|7|700",
+                                "+I 1|8|800",
+                                "+I 1|9|900"));
+        assertNoSnapshotForWatermark(301);
+
+        write.close();
+        commit.close();
+    }
+
+    @Test
+    public void testScanWatermarkExactMatchWithInterleavedNullWatermarks() 
throws Exception {
+        createAppendOnlyTableWithoutCompaction();
+        StreamTableWrite write = table.newWrite(commitUser);
+        StreamTableCommit commit = table.newCommit(commitUser);
+
+        // snapshots 1..5, then snapshots 1, 2, 5 are assigned watermarks 100, 
150, 300
+        for (int i = 0; i < 5; i++) {
+            commitRow(write, commit, i, null, i);
+        }
+        patchWatermark(1, 100L);
+        patchWatermark(2, 150L);
+        patchWatermark(5, 300L);
+
+        // the exact match is snapshot 2; the defective search returned the 
null-watermark
+        // snapshot 3 instead, silently including commit 2's row
+        assertThat(scanFromWatermark(150))
+                .hasSameElementsAs(Arrays.asList("+I 1|0|0", "+I 1|1|100"));
+
+        write.close();
+        commit.close();
+    }
+
+    @Test
+    public void testRollbackToWatermarkBelowMinimum() throws Exception {
+        createAppendOnlyTableWithoutCompaction();
+        StreamTableWrite write = table.newWrite(commitUser);
+        StreamTableCommit commit = table.newCommit(commitUser);
+
+        // snapshots 1, 2, 3 carry watermarks 100, 200, 300
+        for (int i = 0; i < 3; i++) {
+            commitRow(write, commit, i, 100L * (i + 1), i);
+        }
+
+        // the rollback_to_watermark procedures do earlierOrEqualWatermark + 
checkNotNull +
+        // rollbackTo. The defective inverted early-return handed them 
snapshot 1 (watermark
+        // 100 > 50), so the procedure rolled back to it and deleted snapshots 
2 and 3;
+        // the fix returns null so the procedure rejects the request and the 
table stays intact
+        SnapshotManager snapshotManager = table.snapshotManager();
+        assertThat(snapshotManager.earlierOrEqualWatermark(50)).isNull();
+        assertThat(snapshotManager.latestSnapshotId()).isEqualTo(3);
+
+        // a request within range still rolls back correctly
+        Snapshot target = snapshotManager.earlierOrEqualWatermark(150);
+        assertThat(target.id()).isEqualTo(1);
+        table.rollbackTo(target.id());
+        assertThat(snapshotManager.latestSnapshotId()).isEqualTo(1);
+        assertThat(getResult(table.newRead(), table.newScan().plan().splits()))
+                .hasSameElementsAs(Arrays.asList("+I 1|0|0"));
+
+        write.close();
+        commit.close();
+    }
+
+    // ------------------------------------------------------------------
+    // helpers
+    // ------------------------------------------------------------------
+
+    /**
+     * Creates an append-only table that never compacts, so the tests control 
the exact snapshot
+     * layout: every commit produces exactly one snapshot, with sequential ids.
+     */
+    private void createAppendOnlyTableWithoutCompaction() throws Exception {
+        Options conf = new Options();
+        conf.set(CoreOptions.WRITE_ONLY, true);
+        createAppendOnlyTable(conf);
+    }
+
+    /** Commits one row {@code (1, value, 100 * value)} as one snapshot with 
the given watermark. */
+    private void commitRow(
+            StreamTableWrite write,
+            StreamTableCommit commit,
+            long identifier,
+            @Nullable Long watermark,
+            int value)
+            throws Exception {
+        write.write(rowData(1, value, 100L * value));
+        List<CommitMessage> messages = write.prepareCommit(true, identifier);
+        if (watermark == null) {
+            commit.commit(identifier, messages);
+        } else {
+            ManifestCommittable committable = new 
ManifestCommittable(identifier, watermark);
+            messages.forEach(committable::addFileCommittable);
+            ((TableCommitImpl) commit).commit(committable);
+        }
+    }
+
+    private List<String> scanFromWatermark(long watermark) throws Exception {
+        Map<String, String> dynamicOptions = new HashMap<>();
+        dynamicOptions.put(SCAN_WATERMARK.key(), String.valueOf(watermark));
+        List<Split> splits = 
table.copy(dynamicOptions).newScan().plan().splits();
+        return getResult(table.newRead(), splits);
+    }
+
+    private void assertNoSnapshotForWatermark(long watermark) {
+        assertThatThrownBy(() -> scanFromWatermark(watermark))
+                .satisfies(
+                        anyCauseMatches(
+                                RuntimeException.class,
+                                "There is currently no snapshot later than or 
equal to watermark"));
+    }
+
+    /**
+     * Rewrites a snapshot file with the given watermark field set. Java 
commits carry the previous
+     * watermark forward ({@code FileStoreCommitImpl}), so a pure-Java history 
can never hold
+     * interleaved null watermarks; this produces on disk exactly what a 
mixed-engine history looks
+     * like, e.g. Flink streaming commits (watermark-bearing) interleaved with 
paimon-rust /
+     * pypaimon appends (no watermark field).
+     */
+    private void patchWatermark(long snapshotId, long watermark) throws 
Exception {
+        SnapshotManager snapshotManager = table.snapshotManager();
+        Snapshot s = snapshotManager.snapshot(snapshotId);
+        Snapshot patched =
+                new Snapshot(
+                        s.id(),
+                        s.schemaId(),
+                        s.baseManifestList(),
+                        s.baseManifestListSize(),
+                        s.deltaManifestList(),
+                        s.deltaManifestListSize(),
+                        s.changelogManifestList(),
+                        s.changelogManifestListSize(),
+                        s.indexManifest(),
+                        s.commitUser(),
+                        s.commitIdentifier(),
+                        s.commitKind(),
+                        s.timeMillis(),
+                        s.totalRecordCount(),
+                        s.deltaRecordCount(),
+                        s.changelogRecordCount(),
+                        watermark,
+                        s.statistics(),
+                        s.properties(),
+                        s.nextRowId(),
+                        s.operation());
+        fileIO.delete(snapshotManager.snapshotPath(snapshotId), false);
+        fileIO.tryToWriteAtomic(snapshotManager.snapshotPath(snapshotId), 
patched.toJson());
+    }
+}
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 e44af2cd90..6c09b9a327 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
@@ -27,6 +27,7 @@ import org.apache.paimon.table.source.snapshot.TimeTravelUtil;
 
 import org.assertj.core.api.Assertions;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.Timeout;
 import org.junit.jupiter.api.io.TempDir;
 import org.junit.jupiter.params.ParameterizedTest;
 import org.junit.jupiter.params.provider.ValueSource;
@@ -37,8 +38,10 @@ import javax.annotation.Nullable;
 import java.io.File;
 import java.io.IOException;
 import java.util.ArrayList;
+import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
+import java.util.Map;
 import java.util.Set;
 import java.util.concurrent.ThreadLocalRandom;
 import java.util.concurrent.atomic.AtomicReference;
@@ -241,8 +244,13 @@ public class SnapshotManagerTest {
             localFileIO.tryToWriteAtomic(snapshotManager.snapshotPath(i), 
snapshot.toJson());
         }
 
-        assertThat(snapshotManager.earlierOrEqualWatermark(millis + 999).id())
-                .isEqualTo(isRaceCondition ? 1 : 0);
+        if (isRaceCondition) {
+            // The earliest snapshot has expired, so no remaining snapshot 
carries a
+            // watermark earlier than or equal to the requested one
+            assertThat(snapshotManager.earlierOrEqualWatermark(millis + 
999)).isNull();
+        } else {
+            assertThat(snapshotManager.earlierOrEqualWatermark(millis + 
999).id()).isEqualTo(0);
+        }
     }
 
     @ParameterizedTest
@@ -419,7 +427,7 @@ public class SnapshotManagerTest {
                 null);
     }
 
-    private Snapshot createSnapshotWithMillis(long id, long millis, long 
watermark) {
+    private Snapshot createSnapshotWithMillis(long id, long millis, @Nullable 
Long watermark) {
         return new Snapshot(
                 id,
                 0L,
@@ -444,6 +452,14 @@ public class SnapshotManagerTest {
                 null);
     }
 
+    private static Map<Long, Long> watermarkMap(long... idsAndWatermarks) {
+        Map<Long, Long> watermarks = new HashMap<>();
+        for (int i = 0; i < idsAndWatermarks.length; i += 2) {
+            watermarks.put(idsAndWatermarks[i], idsAndWatermarks[i + 1]);
+        }
+        return watermarks;
+    }
+
     private Changelog createChangelogWithMillis(long id, long millis) {
         return new Changelog(
                 new Snapshot(
@@ -662,6 +678,183 @@ public class SnapshotManagerTest {
         assertDoesNotThrow(() -> snapshotManager.commitChangelog(changelog, 
id));
     }
 
+    @Test
+    @Timeout(60) // the search must terminate; defective code loops forever 
here
+    public void testLaterOrEqualWatermarkWithInterleavedNullWatermarks() 
throws IOException {
+        FileIO localFileIO = LocalFileIO.create();
+        SnapshotManager snapshotManager =
+                newSnapshotManager(localFileIO, new Path(tempDir.toString()));
+        // create 10 snapshots, only snapshots 0, 4 and 9 carry a watermark
+        Map<Long, Long> watermarks = watermarkMap(0, 100, 4, 200, 9, 300);
+        for (long i = 0; i < 10; i++) {
+            Snapshot snapshot = createSnapshotWithMillis(i, 1000 + i * 1000, 
watermarks.get(i));
+            localFileIO.tryToWriteAtomic(snapshotManager.snapshotPath(i), 
snapshot.toJson());
+        }
+
+        // the null-watermark fallback must terminate and return snapshot 4
+        
assertThat(snapshotManager.laterOrEqualWatermark(150).id()).isEqualTo(4);
+        
assertThat(snapshotManager.laterOrEqualWatermark(250).id()).isEqualTo(9);
+        // larger than the largest watermark returns null
+        assertThat(snapshotManager.laterOrEqualWatermark(301)).isNull();
+        // smaller than the smallest watermark returns the first snapshot with 
a watermark
+        
assertThat(snapshotManager.laterOrEqualWatermark(50).id()).isEqualTo(0);
+    }
+
+    @Test
+    public void testLaterOrEqualWatermarkExactMatchWithNullWatermarks() throws 
IOException {
+        FileIO localFileIO = LocalFileIO.create();
+        SnapshotManager snapshotManager =
+                newSnapshotManager(localFileIO, new Path(tempDir.toString()));
+        // create 5 snapshots, snapshots 2 and 3 do not carry a watermark
+        Map<Long, Long> watermarks = watermarkMap(0, 100, 1, 150, 4, 300);
+        for (long i = 0; i < 5; i++) {
+            Snapshot snapshot = createSnapshotWithMillis(i, 1000 + i * 1000, 
watermarks.get(i));
+            localFileIO.tryToWriteAtomic(snapshotManager.snapshotPath(i), 
snapshot.toJson());
+        }
+
+        // the exact match is snapshot 1, not one of the null-watermark 
snapshots
+        
assertThat(snapshotManager.laterOrEqualWatermark(150).id()).isEqualTo(1);
+    }
+
+    @Test
+    public void testLaterOrEqualWatermarkWithAllNullWatermarks() throws 
IOException {
+        FileIO localFileIO = LocalFileIO.create();
+        SnapshotManager snapshotManager =
+                newSnapshotManager(localFileIO, new Path(tempDir.toString()));
+        // create 5 snapshots without watermark
+        for (long i = 0; i < 5; i++) {
+            Snapshot snapshot = createSnapshotWithMillis(i, 1000 + i * 1000);
+            localFileIO.tryToWriteAtomic(snapshotManager.snapshotPath(i), 
snapshot.toJson());
+        }
+
+        assertThat(snapshotManager.laterOrEqualWatermark(100)).isNull();
+    }
+
+    @Test
+    public void testLaterOrEqualWatermarkWithNullWatermarkTail() throws 
IOException {
+        FileIO localFileIO = LocalFileIO.create();
+        SnapshotManager snapshotManager =
+                newSnapshotManager(localFileIO, new Path(tempDir.toString()));
+        // create 4 snapshots, snapshots 2 and 3 do not carry a watermark
+        Map<Long, Long> watermarks = watermarkMap(0, 100, 1, 200);
+        for (long i = 0; i < 4; i++) {
+            Snapshot snapshot = createSnapshotWithMillis(i, 1000 + i * 1000, 
watermarks.get(i));
+            localFileIO.tryToWriteAtomic(snapshotManager.snapshotPath(i), 
snapshot.toJson());
+        }
+
+        
assertThat(snapshotManager.laterOrEqualWatermark(150).id()).isEqualTo(1);
+        // the null-watermark tail does not extend the watermark range
+        assertThat(snapshotManager.laterOrEqualWatermark(250)).isNull();
+    }
+
+    @Test
+    public void testEarlierOrEqualWatermarkBelowMinimum() throws IOException {
+        FileIO localFileIO = LocalFileIO.create();
+        SnapshotManager snapshotManager =
+                newSnapshotManager(localFileIO, new Path(tempDir.toString()));
+        // create 3 snapshots with dense watermarks
+        for (long i = 0; i < 3; i++) {
+            Snapshot snapshot = createSnapshotWithMillis(i, 1000 + i * 1000, 
100 + i * 100);
+            localFileIO.tryToWriteAtomic(snapshotManager.snapshotPath(i), 
snapshot.toJson());
+        }
+
+        // smaller than the smallest watermark returns null
+        assertThat(snapshotManager.earlierOrEqualWatermark(50)).isNull();
+    }
+
+    @Test
+    @Timeout(60) // the search must terminate; defective code loops forever 
here
+    public void testEarlierOrEqualWatermarkWithInterleavedNullWatermarks() 
throws IOException {
+        FileIO localFileIO = LocalFileIO.create();
+        SnapshotManager snapshotManager =
+                newSnapshotManager(localFileIO, new Path(tempDir.toString()));
+        // create 10 snapshots, only snapshots 0, 4 and 9 carry a watermark
+        Map<Long, Long> watermarks = watermarkMap(0, 100, 4, 200, 9, 300);
+        for (long i = 0; i < 10; i++) {
+            Snapshot snapshot = createSnapshotWithMillis(i, 1000 + i * 1000, 
watermarks.get(i));
+            localFileIO.tryToWriteAtomic(snapshotManager.snapshotPath(i), 
snapshot.toJson());
+        }
+
+        
assertThat(snapshotManager.earlierOrEqualWatermark(150).id()).isEqualTo(0);
+        
assertThat(snapshotManager.earlierOrEqualWatermark(250).id()).isEqualTo(4);
+        
assertThat(snapshotManager.earlierOrEqualWatermark(350).id()).isEqualTo(9);
+    }
+
+    @Test
+    public void testEarlierOrEqualWatermarkExactMatchWithNullWatermarks() 
throws IOException {
+        FileIO localFileIO = LocalFileIO.create();
+        SnapshotManager snapshotManager =
+                newSnapshotManager(localFileIO, new Path(tempDir.toString()));
+        // create 5 snapshots, snapshots 2 and 3 do not carry a watermark
+        Map<Long, Long> watermarks = watermarkMap(0, 100, 1, 150, 4, 300);
+        for (long i = 0; i < 5; i++) {
+            Snapshot snapshot = createSnapshotWithMillis(i, 1000 + i * 1000, 
watermarks.get(i));
+            localFileIO.tryToWriteAtomic(snapshotManager.snapshotPath(i), 
snapshot.toJson());
+        }
+
+        // the exact match is snapshot 1, not one of the null-watermark 
snapshots
+        
assertThat(snapshotManager.earlierOrEqualWatermark(150).id()).isEqualTo(1);
+        // null-watermark snapshots fall back to the nearest earlier watermark
+        
assertThat(snapshotManager.earlierOrEqualWatermark(200).id()).isEqualTo(1);
+    }
+
+    @Test
+    public void testEarlierOrEqualWatermarkWithAllNullWatermarks() throws 
IOException {
+        FileIO localFileIO = LocalFileIO.create();
+        SnapshotManager snapshotManager =
+                newSnapshotManager(localFileIO, new Path(tempDir.toString()));
+        // create 5 snapshots without watermark
+        for (long i = 0; i < 5; i++) {
+            Snapshot snapshot = createSnapshotWithMillis(i, 1000 + i * 1000);
+            localFileIO.tryToWriteAtomic(snapshotManager.snapshotPath(i), 
snapshot.toJson());
+        }
+
+        assertThat(snapshotManager.earlierOrEqualWatermark(100)).isNull();
+    }
+
+    @Test
+    public void testWatermarkSearchWithMinValueSentinelAndNullWatermarks() 
throws IOException {
+        FileIO localFileIO = LocalFileIO.create();
+        SnapshotManager snapshotManager =
+                newSnapshotManager(localFileIO, new Path(tempDir.toString()));
+        // a mixed-engine history: snapshot 0 carries the Long.MIN_VALUE 
sentinel written by
+        // engines without watermark semantics, snapshot 1 carries no 
watermark at all
+        Map<Long, Long> watermarks = watermarkMap(0, Long.MIN_VALUE);
+        for (long i = 0; i < 2; i++) {
+            Snapshot snapshot = createSnapshotWithMillis(i, 1000 + i * 1000, 
watermarks.get(i));
+            localFileIO.tryToWriteAtomic(snapshotManager.snapshotPath(i), 
snapshot.toJson());
+        }
+
+        // the Long.MIN_VALUE sentinel means "no watermark" and must never 
match a query;
+        // in particular it must not be returned for a rollback to watermark 0
+        assertThat(snapshotManager.earlierOrEqualWatermark(0)).isNull();
+        assertThat(snapshotManager.laterOrEqualWatermark(0)).isNull();
+    }
+
+    @Test
+    public void 
testWatermarkSearchWithMinValueSentinelMixedWithRealWatermarks()
+            throws IOException {
+        FileIO localFileIO = LocalFileIO.create();
+        SnapshotManager snapshotManager =
+                newSnapshotManager(localFileIO, new Path(tempDir.toString()));
+        // snapshots 0 and 3 carry the Long.MIN_VALUE sentinel, snapshots 1 
and 2 carry real
+        // watermarks
+        Map<Long, Long> watermarks =
+                watermarkMap(0, Long.MIN_VALUE, 1, 100, 2, 200, 3, 
Long.MIN_VALUE);
+        for (long i = 0; i < 4; i++) {
+            Snapshot snapshot = createSnapshotWithMillis(i, 1000 + i * 1000, 
watermarks.get(i));
+            localFileIO.tryToWriteAtomic(snapshotManager.snapshotPath(i), 
snapshot.toJson());
+        }
+
+        // the sentinel on the latest snapshot must not short-circuit the 
search
+        
assertThat(snapshotManager.earlierOrEqualWatermark(100).id()).isEqualTo(1);
+        
assertThat(snapshotManager.earlierOrEqualWatermark(250).id()).isEqualTo(2);
+        
assertThat(snapshotManager.laterOrEqualWatermark(150).id()).isEqualTo(2);
+        // the leading sentinel must not shadow the real watermarks after it
+        
assertThat(snapshotManager.laterOrEqualWatermark(50).id()).isEqualTo(1);
+        assertThat(snapshotManager.earlierOrEqualWatermark(50)).isNull();
+    }
+
     /**
      * Test {@link SnapshotManager} to mock situations when there is a race 
condition, that the
      * earliest snapshot is deleted by another thread in the middle of the 
current thread's

Reply via email to