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

dybyte pushed a commit to branch dev
in repository https://gitbox.apache.org/repos/asf/seatunnel.git


The following commit(s) were added to refs/heads/dev by this push:
     new d4446df7c9 [Fix][Zeta] Fix IMap file data comparison contract (#11465)
d4446df7c9 is described below

commit d4446df7c979eb109c96258fa87e747e34bd0afb
Author: lifeng <[email protected]>
AuthorDate: Thu Jul 23 00:20:22 2026 +0800

    [Fix][Zeta] Fix IMap file data comparison contract (#11465)
---
 .../engine/imap/storage/file/bean/IMapData.java    |  14 ++-
 .../imap/storage/file/bean/IMapDataComparator.java | 104 ++++++++++++++++++
 .../imap/storage/file/bean/IMapFileData.java       |  14 ++-
 .../file/bean/IMapFileDataCompareToTest.java       | 119 +++++++++++++++++++++
 .../file/common/WALReaderAndWriterTest.java        |  37 +++++++
 5 files changed, 286 insertions(+), 2 deletions(-)

diff --git 
a/seatunnel-engine/seatunnel-engine-storage/imap-storage-plugins/imap-storage-file/src/main/java/org/apache/seatunnel/engine/imap/storage/file/bean/IMapData.java
 
b/seatunnel-engine/seatunnel-engine-storage/imap-storage-plugins/imap-storage-file/src/main/java/org/apache/seatunnel/engine/imap/storage/file/bean/IMapData.java
index fa2a8731c2..2c812d23b6 100644
--- 
a/seatunnel-engine/seatunnel-engine-storage/imap-storage-plugins/imap-storage-file/src/main/java/org/apache/seatunnel/engine/imap/storage/file/bean/IMapData.java
+++ 
b/seatunnel-engine/seatunnel-engine-storage/imap-storage-plugins/imap-storage-file/src/main/java/org/apache/seatunnel/engine/imap/storage/file/bean/IMapData.java
@@ -41,6 +41,18 @@ public class IMapData implements Serializable, 
Comparable<IMapData> {
 
     @Override
     public int compareTo(IMapData o) {
-        return o.timestamp - this.timestamp > 0 ? 1 : -1;
+        return IMapDataComparator.compare(
+                this.deleted,
+                this.key,
+                this.keyClassName,
+                this.value,
+                this.valueClassName,
+                this.timestamp,
+                o.deleted,
+                o.key,
+                o.keyClassName,
+                o.value,
+                o.valueClassName,
+                o.timestamp);
     }
 }
diff --git 
a/seatunnel-engine/seatunnel-engine-storage/imap-storage-plugins/imap-storage-file/src/main/java/org/apache/seatunnel/engine/imap/storage/file/bean/IMapDataComparator.java
 
b/seatunnel-engine/seatunnel-engine-storage/imap-storage-plugins/imap-storage-file/src/main/java/org/apache/seatunnel/engine/imap/storage/file/bean/IMapDataComparator.java
new file mode 100644
index 0000000000..58b2f938cf
--- /dev/null
+++ 
b/seatunnel-engine/seatunnel-engine-storage/imap-storage-plugins/imap-storage-file/src/main/java/org/apache/seatunnel/engine/imap/storage/file/bean/IMapDataComparator.java
@@ -0,0 +1,104 @@
+/*
+ * 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.seatunnel.engine.imap.storage.file.bean;
+
+final class IMapDataComparator {
+
+    private IMapDataComparator() {}
+
+    static int compare(
+            boolean leftDeleted,
+            byte[] leftKey,
+            String leftKeyClassName,
+            byte[] leftValue,
+            String leftValueClassName,
+            long leftTimestamp,
+            boolean rightDeleted,
+            byte[] rightKey,
+            String rightKeyClassName,
+            byte[] rightValue,
+            String rightValueClassName,
+            long rightTimestamp) {
+        int keyClassCompare = compareNullableString(leftKeyClassName, 
rightKeyClassName);
+        if (keyClassCompare != 0) {
+            return keyClassCompare;
+        }
+
+        int keyCompare = compareBytes(leftKey, rightKey);
+        if (keyCompare != 0) {
+            return keyCompare;
+        }
+
+        int timestampCompare = Long.compare(rightTimestamp, leftTimestamp);
+        if (timestampCompare != 0) {
+            return timestampCompare;
+        }
+
+        // WAL timestamps have millisecond precision and Hadoop FileSystem 
does not provide a
+        // portable, atomic global sequence across HDFS, S3, and OSS writers. 
These remaining
+        // comparisons therefore provide a deterministic total order for 
timestamp collisions;
+        // they do not claim general last-write-wins ordering across 
independently active writers.
+        int deleteCompare = Boolean.compare(rightDeleted, leftDeleted);
+        if (deleteCompare != 0) {
+            return deleteCompare;
+        }
+
+        int valueClassCompare = compareNullableString(leftValueClassName, 
rightValueClassName);
+        if (valueClassCompare != 0) {
+            return valueClassCompare;
+        }
+
+        return compareBytes(leftValue, rightValue);
+    }
+
+    private static int compareNullableString(String left, String right) {
+        if (left == right) {
+            return 0;
+        }
+        if (left == null) {
+            return -1;
+        }
+        if (right == null) {
+            return 1;
+        }
+        return left.compareTo(right);
+    }
+
+    private static int compareBytes(byte[] left, byte[] right) {
+        if (left == right) {
+            return 0;
+        }
+        if (left == null) {
+            return -1;
+        }
+        if (right == null) {
+            return 1;
+        }
+        int length = Math.min(left.length, right.length);
+        for (int i = 0; i < length; i++) {
+            int compare = Byte.compare(left[i], right[i]);
+            if (compare != 0) {
+                return compare;
+            }
+        }
+        return Integer.compare(left.length, right.length);
+    }
+}
diff --git 
a/seatunnel-engine/seatunnel-engine-storage/imap-storage-plugins/imap-storage-file/src/main/java/org/apache/seatunnel/engine/imap/storage/file/bean/IMapFileData.java
 
b/seatunnel-engine/seatunnel-engine-storage/imap-storage-plugins/imap-storage-file/src/main/java/org/apache/seatunnel/engine/imap/storage/file/bean/IMapFileData.java
index 5b9d8863f5..e8920f86fb 100644
--- 
a/seatunnel-engine/seatunnel-engine-storage/imap-storage-plugins/imap-storage-file/src/main/java/org/apache/seatunnel/engine/imap/storage/file/bean/IMapFileData.java
+++ 
b/seatunnel-engine/seatunnel-engine-storage/imap-storage-plugins/imap-storage-file/src/main/java/org/apache/seatunnel/engine/imap/storage/file/bean/IMapFileData.java
@@ -46,6 +46,18 @@ public class IMapFileData implements Serializable, 
Comparable<IMapFileData> {
 
     @Override
     public int compareTo(IMapFileData o) {
-        return o.timestamp - this.timestamp > 0 ? 1 : -1;
+        return IMapDataComparator.compare(
+                this.deleted,
+                this.key,
+                this.keyClassName,
+                this.value,
+                this.valueClassName,
+                this.timestamp,
+                o.deleted,
+                o.key,
+                o.keyClassName,
+                o.value,
+                o.valueClassName,
+                o.timestamp);
     }
 }
diff --git 
a/seatunnel-engine/seatunnel-engine-storage/imap-storage-plugins/imap-storage-file/src/test/java/org/apache/seatunnel/engine/imap/storage/file/bean/IMapFileDataCompareToTest.java
 
b/seatunnel-engine/seatunnel-engine-storage/imap-storage-plugins/imap-storage-file/src/test/java/org/apache/seatunnel/engine/imap/storage/file/bean/IMapFileDataCompareToTest.java
new file mode 100644
index 0000000000..65eca4682c
--- /dev/null
+++ 
b/seatunnel-engine/seatunnel-engine-storage/imap-storage-plugins/imap-storage-file/src/test/java/org/apache/seatunnel/engine/imap/storage/file/bean/IMapFileDataCompareToTest.java
@@ -0,0 +1,119 @@
+/*
+ * 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.seatunnel.engine.imap.storage.file.bean;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Random;
+
+public class IMapFileDataCompareToTest {
+
+    @Test
+    public void testCompareToOrdersNewerTimestampFirst() {
+        IMapFileData older = data("key", "value", false, 1000L);
+        IMapFileData newer = data("key", "value", false, 1001L);
+
+        Assertions.assertTrue(newer.compareTo(older) < 0);
+        Assertions.assertTrue(older.compareTo(newer) > 0);
+    }
+
+    @Test
+    public void testCompareToOrdersDeleteBeforePutWithSameTimestamp() {
+        IMapFileData put = data("key", "value", false, 1000L);
+        IMapFileData delete = data("key", null, true, 1000L);
+
+        Assertions.assertTrue(delete.compareTo(put) < 0);
+        Assertions.assertTrue(put.compareTo(delete) > 0);
+    }
+
+    @Test
+    public void testCompareToGroupsRecordsByKeyBeforeTimestamp() {
+        IMapFileData olderA = data("key-a", "older", false, 1000L);
+        IMapFileData newerA = data("key-a", "newer", false, 2000L);
+        IMapFileData keyB = data("key-b", "value", false, 3000L);
+
+        List<IMapFileData> records = new ArrayList<>();
+        records.add(keyB);
+        records.add(olderA);
+        records.add(newerA);
+        Collections.sort(records);
+
+        Assertions.assertEquals(newerA, records.get(0));
+        Assertions.assertEquals(olderA, records.get(1));
+        Assertions.assertEquals(keyB, records.get(2));
+    }
+
+    @Test
+    public void testCompareToIsAntisymmetricWithEqualTimestamps() {
+        IMapFileData left = data("key-a", "value-a", false, 1000L);
+        IMapFileData right = data("key-b", "value-b", true, 1000L);
+
+        int leftToRight = Integer.signum(left.compareTo(right));
+        int rightToLeft = Integer.signum(right.compareTo(left));
+
+        Assertions.assertEquals(-rightToLeft, leftToRight);
+    }
+
+    @Test
+    public void testCompareToReturnsZeroForSameData() {
+        IMapFileData left = data("key", "value", false, 1000L);
+        IMapFileData right = data("key", "value", false, 1000L);
+
+        Assertions.assertEquals(0, left.compareTo(right));
+        Assertions.assertEquals(0, right.compareTo(left));
+    }
+
+    @Test
+    public void testCollectionsSortHandlesManyEqualTimestamps() {
+        List<IMapFileData> data = new ArrayList<>();
+        Random random = new Random(42);
+        for (int i = 0; i < 4000; i++) {
+            data.add(
+                    data(
+                            "key-" + i,
+                            "value-" + i,
+                            random.nextBoolean(),
+                            1000L + random.nextInt(50)));
+        }
+
+        Assertions.assertDoesNotThrow(() -> Collections.sort(data));
+    }
+
+    private static IMapFileData data(String key, String value, boolean 
deleted, long timestamp) {
+        return IMapFileData.builder()
+                .deleted(deleted)
+                .key(bytes(key))
+                .keyClassName(String.class.getName())
+                .value(bytes(value))
+                .valueClassName(value == null ? null : String.class.getName())
+                .timestamp(timestamp)
+                .build();
+    }
+
+    private static byte[] bytes(String value) {
+        return value == null ? null : value.getBytes(StandardCharsets.UTF_8);
+    }
+}
diff --git 
a/seatunnel-engine/seatunnel-engine-storage/imap-storage-plugins/imap-storage-file/src/test/java/org/apache/seatunnel/engine/imap/storage/file/common/WALReaderAndWriterTest.java
 
b/seatunnel-engine/seatunnel-engine-storage/imap-storage-plugins/imap-storage-file/src/test/java/org/apache/seatunnel/engine/imap/storage/file/common/WALReaderAndWriterTest.java
index 388be84836..917d02cf79 100644
--- 
a/seatunnel-engine/seatunnel-engine-storage/imap-storage-plugins/imap-storage-file/src/test/java/org/apache/seatunnel/engine/imap/storage/file/common/WALReaderAndWriterTest.java
+++ 
b/seatunnel-engine/seatunnel-engine-storage/imap-storage-plugins/imap-storage-file/src/test/java/org/apache/seatunnel/engine/imap/storage/file/common/WALReaderAndWriterTest.java
@@ -48,6 +48,8 @@ public class WALReaderAndWriterTest {
 
     private static FileSystem FS;
     private static final Path PARENT_PATH = new Path("/tmp/9/");
+    private static final Path SAME_TIMESTAMP_TOMBSTONE_PATH =
+            new Path("/tmp/imap-wal-same-timestamp-tombstone/");
     private static final Serializer SERIALIZER = new ProtoStuffSerializer();
 
     @BeforeAll
@@ -114,9 +116,44 @@ public class WALReaderAndWriterTest {
         Assertions.assertNull(result.get("key519"));
     }
 
+    @Test
+    public void testReplayKeepsTombstoneForSameKeyAndTimestamp() throws 
Exception {
+        String key = "deleted-key";
+        long timestamp = 1000L;
+
+        try (WALWriter writer =
+                new WALWriter(
+                        FS, FileConfiguration.HDFS, 
SAME_TIMESTAMP_TOMBSTONE_PATH, SERIALIZER)) {
+            writer.write(
+                    IMapFileData.builder()
+                            .key(SERIALIZER.serialize(key))
+                            .keyClassName(String.class.getName())
+                            .value(SERIALIZER.serialize("value"))
+                            .valueClassName(String.class.getName())
+                            .deleted(false)
+                            .timestamp(timestamp)
+                            .build());
+            writer.write(
+                    IMapFileData.builder()
+                            .key(SERIALIZER.serialize(key))
+                            .keyClassName(String.class.getName())
+                            .deleted(true)
+                            .timestamp(timestamp)
+                            .build());
+        }
+
+        WALReader reader = new WALReader(FS, FileConfiguration.HDFS, 
SERIALIZER);
+        Map<Object, Object> data =
+                reader.loadAllData(SAME_TIMESTAMP_TOMBSTONE_PATH, new 
HashSet<>());
+
+        Assertions.assertFalse(data.containsKey(key));
+        
Assertions.assertFalse(reader.loadAllKeys(SAME_TIMESTAMP_TOMBSTONE_PATH).contains(key));
+    }
+
     @AfterAll
     public static void close() throws IOException {
         FS.delete(PARENT_PATH, true);
+        FS.delete(SAME_TIMESTAMP_TOMBSTONE_PATH, true);
         FS.close();
     }
 }

Reply via email to