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

nzw921rx 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 fb8e7a7eea [Fix][Zeta] Restore legacy job status WAL entries (#11781)
fb8e7a7eea is described below

commit fb8e7a7eeaa8ece1a4fc169619a8cc7b2c3f69f2
Author: Jast <[email protected]>
AuthorDate: Wed Oct 21 22:13:21 2026 +0800

    [Fix][Zeta] Restore legacy job status WAL entries (#11781)
    
    Co-authored-by: zhangshenghang <[email protected]>
    Co-authored-by: zhangshenghang <[email protected]>
    Co-authored-by: zhangshenghang <[email protected]>
    Co-authored-by: jast <[email protected]>
    Co-authored-by: David Zollo <[email protected]>
    Co-authored-by: Claude Sonnet 5.5 <[email protected]>
    Co-authored-by: multica-agent <[email protected]>
    Co-authored-by: David Zollo <[email protected]>
---
 .../introduction/concepts/incompatible-changes.md  |   5 +
 .../introduction/concepts/incompatible-changes.md  |   5 +
 .../imap-storage-plugins/imap-storage-file/pom.xml |   9 ++
 .../engine/imap/storage/file/common/WALReader.java |  60 +++++++-
 .../file/common/WALReaderAndWriterTest.java        | 163 +++++++++++++++++++++
 5 files changed, 238 insertions(+), 4 deletions(-)

diff --git a/docs/en/introduction/concepts/incompatible-changes.md 
b/docs/en/introduction/concepts/incompatible-changes.md
index eff49771be..15453b9ae6 100644
--- a/docs/en/introduction/concepts/incompatible-changes.md
+++ b/docs/en/introduction/concepts/incompatible-changes.md
@@ -573,6 +573,11 @@ or state format migration is required.
 
 ### Engine Behavior Changes
 
+- **File-based IMap storage: legacy job model class names in WAL files are 
remapped on startup**
+  - **Affected component**: 
`seatunnel-engine/seatunnel-engine-storage/imap-storage-plugins/imap-storage-file`
+  - **Description**: SeaTunnel 3.0 relocated the job status model classes 
(`JobStatus`, `JobResult`, `JobStatusData`) from 
`org.apache.seatunnel.engine.core.job.*` to 
`org.apache.seatunnel.engine.common.job.*` (#9689). WAL files written by 
releases before that relocation still record the old fully-qualified class 
names, so replaying them on a current distribution fails with 
`ClassNotFoundException`. The file-based IMap storage now transparently 
rewrites these three legacy names to their  [...]
+  - **Impact**: Clusters using file-based IMap persistence can upgrade and 
restart across the #9689 relocation without manual WAL surgery. Writing is 
unaffected: new WAL entries always record the current class names. Note that 
the rewrite is one-directional: WAL files written by a current distribution 
contain class names that older releases cannot resolve, so downgrading a 
cluster after it has written new WAL entries requires deleting or manually 
rewriting the persisted WAL data. (#11781)
+
 - **Behavior change: the REST log-content endpoints return at most 64 MB by 
default**
   - **Affected component**: `seatunnel-engine-server`, REST v2 endpoints `GET 
/logs/:file` and
     `GET /log/:file` and their REST v1 equivalents `GET 
/hazelcast/rest/maps/logs/:file` and
diff --git a/docs/zh/introduction/concepts/incompatible-changes.md 
b/docs/zh/introduction/concepts/incompatible-changes.md
index bb0974bc39..ff9e054595 100644
--- a/docs/zh/introduction/concepts/incompatible-changes.md
+++ b/docs/zh/introduction/concepts/incompatible-changes.md
@@ -475,6 +475,11 @@ Zeta SQL ARRAY 函数现在将 TINYINT 元素声明为 `ARRAY<TINYINT>`,与实
 
 ### 引擎行为变更
 
+- **文件型 IMap 存储:启动时自动重映射 WAL 文件中的旧版作业模型类名**
+  - 
**受影响组件**:`seatunnel-engine/seatunnel-engine-storage/imap-storage-plugins/imap-storage-file`
+  - **描述**:SeaTunnel 3.0 将作业状态模型类(`JobStatus`、`JobResult`、`JobStatusData`)从 
`org.apache.seatunnel.engine.core.job.*` 迁移到了 
`org.apache.seatunnel.engine.common.job.*`(#9689)。迁移之前版本写入的 WAL 
文件仍记录旧的完整类名,在当前发行版上回放会抛出 `ClassNotFoundException`。文件型 IMap 存储现在在读取 WAL 
条目时会自动将这三个旧类名重写到当前位置,无需任何配置变更。
+  - **影响**:使用文件型 IMap 持久化的集群可以跨 #9689 迁移直接升级重启,无需手工修改 WAL 数据。写入不受影响:新的 WAL 
条目始终记录当前类名。注意该重写是单向的:当前发行版写出的 WAL 文件中的类名无法被旧版本解析,因此在写出新 WAL 
条目后回滚降级集群时,需要删除或手工改写已持久化的 WAL 数据。(#11781)
+
 - **行为变更:REST 日志内容接口默认最多返回 64 MB**
   - **受影响组件**:`seatunnel-engine-server`,REST v2 接口 `GET /logs/:file`、`GET 
/log/:file`,
     以及对应的 REST v1 接口 `GET /hazelcast/rest/maps/logs/:file`、`GET 
/hazelcast/rest/maps/log/:file`。
diff --git 
a/seatunnel-engine/seatunnel-engine-storage/imap-storage-plugins/imap-storage-file/pom.xml
 
b/seatunnel-engine/seatunnel-engine-storage/imap-storage-plugins/imap-storage-file/pom.xml
index 96069b75c0..cb7bf09e26 100644
--- 
a/seatunnel-engine/seatunnel-engine-storage/imap-storage-plugins/imap-storage-file/pom.xml
+++ 
b/seatunnel-engine/seatunnel-engine-storage/imap-storage-plugins/imap-storage-file/pom.xml
@@ -43,6 +43,15 @@
             <artifactId>serializer-protobuf</artifactId>
             <version>${project.version}</version>
         </dependency>
+        <!-- Test-scoped only: production code intentionally references the 
pre-relocation
+             job model class names as strings; the tests bind those strings to 
the real
+             engine-common classes so a future rename fails the build. -->
+        <dependency>
+            <groupId>org.apache.seatunnel</groupId>
+            <artifactId>seatunnel-engine-common</artifactId>
+            <version>${project.version}</version>
+            <scope>test</scope>
+        </dependency>
         <!-- hadoop jar -->
         <dependency>
             <groupId>org.apache.seatunnel</groupId>
diff --git 
a/seatunnel-engine/seatunnel-engine-storage/imap-storage-plugins/imap-storage-file/src/main/java/org/apache/seatunnel/engine/imap/storage/file/common/WALReader.java
 
b/seatunnel-engine/seatunnel-engine-storage/imap-storage-plugins/imap-storage-file/src/main/java/org/apache/seatunnel/engine/imap/storage/file/common/WALReader.java
index f026e2f940..f69d0cfb38 100644
--- 
a/seatunnel-engine/seatunnel-engine-storage/imap-storage-plugins/imap-storage-file/src/main/java/org/apache/seatunnel/engine/imap/storage/file/common/WALReader.java
+++ 
b/seatunnel-engine/seatunnel-engine-storage/imap-storage-plugins/imap-storage-file/src/main/java/org/apache/seatunnel/engine/imap/storage/file/common/WALReader.java
@@ -33,6 +33,7 @@ import org.apache.hadoop.fs.FileSystem;
 import org.apache.hadoop.fs.Path;
 
 import java.io.IOException;
+import java.util.Collections;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.Iterator;
@@ -42,6 +43,38 @@ import java.util.Set;
 import static 
org.apache.seatunnel.engine.imap.storage.file.common.LatestMutationAccumulator.SerializedKey;
 
 public class WALReader {
+    /**
+     * Maps the fully-qualified class names recorded in WAL entries written by 
SeaTunnel versions
+     * older than #9689 (which moved the job status model from {@code 
seatunnel-engine-core} to
+     * {@code seatunnel-engine-common}) to their current class names.
+     *
+     * <p>Without this mapping, restarting a cluster that persists job state 
through the file-based
+     * IMap storage fails with {@code ClassNotFoundException} while replaying 
existing WAL entries,
+     * see #9928. Removing an entry from this table breaks upgrades from any 
release that still
+     * wrote the old class name, so keep it in place and extend it whenever a 
persisted job model
+     * class is moved again.
+     *
+     * <p>Resolution is single-level: each entry rewrites one legacy name to 
its current location.
+     * If a target class is relocated again later, add an entry for every 
intermediate name so WAL
+     * files written between relocations still resolve; do not rely on the map 
being followed
+     * transitively.
+     */
+    private static final Map<String, String> LEGACY_CLASS_NAME_MAPPINGS;
+
+    static {
+        Map<String, String> legacyClassNameMappings = new HashMap<>();
+        legacyClassNameMappings.put(
+                "org.apache.seatunnel.engine.core.job.JobResult",
+                "org.apache.seatunnel.engine.common.job.JobResult");
+        legacyClassNameMappings.put(
+                "org.apache.seatunnel.engine.core.job.JobStatus",
+                "org.apache.seatunnel.engine.common.job.JobStatus");
+        legacyClassNameMappings.put(
+                "org.apache.seatunnel.engine.core.job.JobStatusData",
+                "org.apache.seatunnel.engine.common.job.JobStatusData");
+        LEGACY_CLASS_NAME_MAPPINGS = 
Collections.unmodifiableMap(legacyClassNameMappings);
+    }
+
     private final Serializer serializer;
     private final IFileReader<IMapFileData> fileReader;
 
@@ -108,19 +141,38 @@ public class WALReader {
 
     private Object deserializeData(byte[] data, String className) {
         try {
-            Class<?> clazz = ClassUtils.getClass(className);
+            Class<?> clazz = ClassUtils.getClass(resolveClassName(className));
             try {
                 return serializer.deserialize(data, clazz);
             } catch (IOException e) {
                 // log.error("deserialize data error, data is {}, className is 
{}", data, className,
                 // e);
+                // IMapStorageException formats the message with 
String.format, so use %s
+                // placeholders instead of {} to keep the data and class name 
visible.
                 throw new IMapStorageException(
-                        e, "deserialize data error: data is s%, className is 
s%", data, className);
+                        e, "deserialize data error: data is %s, className is 
%s", data, className);
             }
         } catch (ClassNotFoundException e) {
-            //  log.error("deserialize data error, class name is {}", 
className, e);
+            //  log.error("deserialize data error, class name is {}, resolved 
class name is {}",
+            // className, resolveClassName(className), e);
+            // IMapStorageException formats the message with String.format, so 
use %s
+            // placeholders instead of {} to keep both class names visible.
             throw new IMapStorageException(
-                    e, "deserialize data error, class name is {}", className);
+                    e,
+                    "deserialize data error, class name is %s, resolved class 
name is %s",
+                    className,
+                    resolveClassName(className));
         }
     }
+
+    /**
+     * Resolves class names written by SeaTunnel versions before the job model 
moved to
+     * engine-common.
+     *
+     * @param className class name recorded in the WAL
+     * @return class name available in the current distribution
+     */
+    private static String resolveClassName(String className) {
+        return LEGACY_CLASS_NAME_MAPPINGS.getOrDefault(className, className);
+    }
 }
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 6b1fb4b7ff..2eeb243669 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
@@ -20,6 +20,10 @@
 
 package org.apache.seatunnel.engine.imap.storage.file.common;
 
+import org.apache.seatunnel.engine.common.job.JobResult;
+import org.apache.seatunnel.engine.common.job.JobStatus;
+import org.apache.seatunnel.engine.common.job.JobStatusData;
+import 
org.apache.seatunnel.engine.imap.storage.api.exception.IMapStorageException;
 import org.apache.seatunnel.engine.imap.storage.file.bean.IMapFileData;
 import org.apache.seatunnel.engine.imap.storage.file.config.FileConfiguration;
 import org.apache.seatunnel.engine.serializer.api.Serializer;
@@ -55,6 +59,15 @@ public class WALReaderAndWriterTest {
     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/");
+    // A real pre-#9689 WAL file is not required to exercise the legacy path: 
the
+    // relocated types (JobStatus, JobResult, JobStatusData) kept identical 
field
+    // layout and enum ordinals across the #9689 move, and the static 
serialVersionUID
+    // added later is skipped by ProtoStuff's RuntimeSchema, so the only wire 
difference
+    // is the valueClassName string, which these fixtures set to the old names 
explicitly.
+    // Replaying a captured pre-relocation WAL frame remains a welcome 
follow-up.
+    private static final Path LEGACY_JOB_STATUS_PATH = new 
Path("/tmp/imap-wal-legacy-job-status/");
+    private static final Path UNKNOWN_CLASS_NAME_PATH =
+            new Path("/tmp/imap-wal-unknown-class-name/");
     private static final Serializer SERIALIZER = new ProtoStuffSerializer();
 
     @BeforeAll
@@ -240,10 +253,160 @@ public class WALReaderAndWriterTest {
         
Assertions.assertFalse(reader.loadAllKeys(SAME_TIMESTAMP_TOMBSTONE_PATH).contains(key));
     }
 
+    /**
+     * Binds every legacy mapping target to the live engine-common class at 
compile time: if a
+     * target class is renamed again, this test fails to compile or fails the 
assertion instead of
+     * leaving a stale shim behind.
+     */
+    @Test
+    public void testLegacyMappingsTargetCurrentClassNames() throws Exception {
+        java.lang.reflect.Field field =
+                WALReader.class.getDeclaredField("LEGACY_CLASS_NAME_MAPPINGS");
+        field.setAccessible(true);
+        java.util.Map<String, String> mappings = (java.util.Map<String, 
String>) field.get(null);
+
+        Assertions.assertEquals(
+                JobStatus.class.getName(),
+                
mappings.get("org.apache.seatunnel.engine.core.job.JobStatus"));
+        Assertions.assertEquals(
+                JobResult.class.getName(),
+                
mappings.get("org.apache.seatunnel.engine.core.job.JobResult"));
+        Assertions.assertEquals(
+                JobStatusData.class.getName(),
+                
mappings.get("org.apache.seatunnel.engine.core.job.JobStatusData"));
+    }
+
+    @Test
+    public void testReaderLoadsJobStatusWrittenWithLegacyClassName() throws 
Exception {
+        String key = "job-status";
+        try (WALWriter writer =
+                new WALWriter(FS, FileConfiguration.HDFS, 
LEGACY_JOB_STATUS_PATH, SERIALIZER)) {
+            writer.write(
+                    IMapFileData.builder()
+                            .key(SERIALIZER.serialize(key))
+                            .keyClassName(String.class.getName())
+                            .value(SERIALIZER.serialize(JobStatus.RUNNING))
+                            
.valueClassName("org.apache.seatunnel.engine.core.job.JobStatus")
+                            .deleted(false)
+                            .timestamp(System.nanoTime())
+                            .build());
+        }
+
+        WALReader reader = new WALReader(FS, FileConfiguration.HDFS, 
SERIALIZER);
+        Map<Object, Object> data = reader.loadAllData(LEGACY_JOB_STATUS_PATH, 
new HashSet<>());
+
+        Assertions.assertEquals(JobStatus.class, data.get(key).getClass());
+        // ProtoStuff returns a non-canonical enum instance, so compare by 
value, not identity.
+        Assertions.assertEquals(JobStatus.RUNNING.name(), ((JobStatus) 
data.get(key)).name());
+    }
+
+    @Test
+    public void testReaderLoadsAllLegacyJobModelClassNames() throws Exception {
+        String jobStatusKey = "legacy-job-status";
+        String jobResultKey = "legacy-job-result";
+        String jobStatusDataKey = "legacy-job-status-data";
+        JobStatusData jobStatusData =
+                new JobStatusData(1L, "legacy-job", JobStatus.FINISHED, 1L, 
2L, 3L);
+        JobResult jobResult = new JobResult(JobStatus.CANCELED, "legacy 
error");
+        try (WALWriter writer =
+                new WALWriter(FS, FileConfiguration.HDFS, 
LEGACY_JOB_STATUS_PATH, SERIALIZER)) {
+            writer.write(
+                    IMapFileData.builder()
+                            .key(SERIALIZER.serialize(jobStatusKey))
+                            .keyClassName(String.class.getName())
+                            .value(SERIALIZER.serialize(JobStatus.RUNNING))
+                            
.valueClassName("org.apache.seatunnel.engine.core.job.JobStatus")
+                            .deleted(false)
+                            .timestamp(System.nanoTime())
+                            .build());
+            writer.write(
+                    IMapFileData.builder()
+                            .key(SERIALIZER.serialize(jobResultKey))
+                            .keyClassName(String.class.getName())
+                            .value(SERIALIZER.serialize(jobResult))
+                            
.valueClassName("org.apache.seatunnel.engine.core.job.JobResult")
+                            .deleted(false)
+                            .timestamp(System.nanoTime())
+                            .build());
+            writer.write(
+                    IMapFileData.builder()
+                            .key(SERIALIZER.serialize(jobStatusDataKey))
+                            .keyClassName(String.class.getName())
+                            .value(SERIALIZER.serialize(jobStatusData))
+                            
.valueClassName("org.apache.seatunnel.engine.core.job.JobStatusData")
+                            .deleted(false)
+                            .timestamp(System.nanoTime())
+                            .build());
+        }
+
+        WALReader reader = new WALReader(FS, FileConfiguration.HDFS, 
SERIALIZER);
+        Map<Object, Object> data = reader.loadAllData(LEGACY_JOB_STATUS_PATH, 
new HashSet<>());
+
+        Assertions.assertEquals(
+                JobStatus.RUNNING.name(), ((JobStatus) 
data.get(jobStatusKey)).name());
+
+        JobResult replayedResult = (JobResult) data.get(jobResultKey);
+        Assertions.assertEquals(JobResult.class, replayedResult.getClass());
+        Assertions.assertEquals(JobStatus.CANCELED.name(), 
replayedResult.getStatus().name());
+        Assertions.assertEquals("legacy error", replayedResult.getError());
+
+        JobStatusData replayedStatusData = (JobStatusData) 
data.get(jobStatusDataKey);
+        Assertions.assertEquals(JobStatusData.class, 
replayedStatusData.getClass());
+        Assertions.assertEquals(jobStatusData.getJobId(), 
replayedStatusData.getJobId());
+        Assertions.assertEquals(jobStatusData.getJobName(), 
replayedStatusData.getJobName());
+        Assertions.assertEquals(
+                jobStatusData.getJobStatus().name(), 
replayedStatusData.getJobStatus().name());
+        Assertions.assertEquals(jobStatusData.getSubmitTime(), 
replayedStatusData.getSubmitTime());
+        Assertions.assertEquals(jobStatusData.getStartTime(), 
replayedStatusData.getStartTime());
+        Assertions.assertEquals(jobStatusData.getFinishTime(), 
replayedStatusData.getFinishTime());
+    }
+
+    /**
+     * Feeds a record whose class name is neither a current class nor a legacy 
mapping key: the
+     * failure must surface the recorded and the resolved class name so a 
stale or wrong mapping is
+     * diagnosable from the restart error alone. A name absent from the legacy 
table resolves to
+     * itself, so both rendered names equal the recorded one.
+     */
+    @Test
+    public void testUnknownClassNameSurfacesInExceptionMessage() throws 
Exception {
+        String unknownClassName = 
"org.apache.seatunnel.engine.core.job.NeverExistedJobStatus";
+        try (WALWriter writer =
+                new WALWriter(FS, FileConfiguration.HDFS, 
UNKNOWN_CLASS_NAME_PATH, SERIALIZER)) {
+            writer.write(
+                    IMapFileData.builder()
+                            .key(SERIALIZER.serialize("unknown-class"))
+                            .keyClassName(String.class.getName())
+                            .value(SERIALIZER.serialize("value"))
+                            .valueClassName(unknownClassName)
+                            .deleted(false)
+                            .timestamp(System.nanoTime())
+                            .build());
+        }
+
+        WALReader reader = new WALReader(FS, FileConfiguration.HDFS, 
SERIALIZER);
+        IMapStorageException exception =
+                Assertions.assertThrows(
+                        IMapStorageException.class,
+                        () -> reader.loadAllData(UNKNOWN_CLASS_NAME_PATH, new 
HashSet<>()));
+
+        Assertions.assertTrue(
+                exception
+                        .getMessage()
+                        .contains(
+                                "class name is "
+                                        + unknownClassName
+                                        + ", resolved class name is "
+                                        + unknownClassName),
+                "exception message should render the recorded and resolved 
class names, got: "
+                        + exception.getMessage());
+    }
+
     @AfterAll
     public static void close() throws IOException {
         FS.delete(PARENT_PATH, true);
         FS.delete(SAME_TIMESTAMP_TOMBSTONE_PATH, true);
+        FS.delete(LEGACY_JOB_STATUS_PATH, true);
+        FS.delete(UNKNOWN_CLASS_NAME_PATH, true);
         FS.close();
     }
 }

Reply via email to