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

wenjin272 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/flink-agents.git


The following commit(s) were added to refs/heads/main by this push:
     new b2aa864d [runtime] Cover the cross-loader action key failure and 
document action renaming (#1094)
b2aa864d is described below

commit b2aa864dd26ecbf4dc6c844156ec4d7e2c7b7318
Author: puru <[email protected]>
AuthorDate: Mon Sep 14 23:32:18 2026 -0700

    [runtime] Cover the cross-loader action key failure and document action 
renaming (#1094)
    
    Generated-by: Claude Code (Claude Fable 5.1)
    
    Co-authored-by: purshotam shah <[email protected]>
    Co-authored-by: Claude Fable 5.1 <[email protected]>
---
 docs/content/docs/operations/deployment.md         |   4 +-
 .../ActionStateUtilCrossClassLoaderTest.java       | 124 +++++++++++++++++++++
 .../runtime/actionstate/ActionStateUtilTest.java   |  15 ---
 3 files changed, 127 insertions(+), 16 deletions(-)

diff --git a/docs/content/docs/operations/deployment.md 
b/docs/content/docs/operations/deployment.md
index e5c59e86..8ff65b1b 100644
--- a/docs/content/docs/operations/deployment.md
+++ b/docs/content/docs/operations/deployment.md
@@ -103,10 +103,12 @@ The same persisted action state is also used by 
fine-grained durable execution.
 {{< /hint >}}
 
 {{< hint warning >}}
-The action-state key format has changed and existing action-state records are 
unsupported. When upgrading, use a fresh Kafka topic or Fluss table and start 
without an older checkpoint or savepoint. Recovery validates the current 
fields, but cannot reliably distinguish every older record from the current 
format.
+The action-state key format changed in Flink Agents 0.4 and action-state 
records written by earlier versions are unsupported. When upgrading, use a 
fresh Kafka topic or Fluss table and start without an older checkpoint or 
savepoint. Recovery validates the current fields, but cannot reliably 
distinguish every older record from the current format.
 
 Action-state keys contain a digest of the serialized key. Changing key types 
or serializer configuration when recovering existing state is unsupported, even 
if Flink accepts the change for keyed state: the same key can serialize to 
different bytes, causing recovery to miss completed actions and repeat their 
side effects. These changes are not detected by the action-state store. 
Preserve the original key types and serializer configuration for recovery. 
Custom key serializers must produce  [...]
 
+Action-state keys identify an action by its name. Keeping an action's name 
across a code change lets recovery reuse its completed results; renaming an 
action makes its recorded results unreachable, so it is executed again on the 
next recovery.
+
 Dedicate each Kafka topic or Fluss table to one logical Flink Agents operator, 
shared by that operator's subtasks. Action-state keys do not contain a job or 
operator namespace, so sharing a backend between logical operators can allow 
otherwise identical records to collide.
 {{< /hint >}}
 
diff --git 
a/runtime/src/test/java/org/apache/flink/agents/runtime/actionstate/ActionStateUtilCrossClassLoaderTest.java
 
b/runtime/src/test/java/org/apache/flink/agents/runtime/actionstate/ActionStateUtilCrossClassLoaderTest.java
new file mode 100644
index 00000000..71afe60b
--- /dev/null
+++ 
b/runtime/src/test/java/org/apache/flink/agents/runtime/actionstate/ActionStateUtilCrossClassLoaderTest.java
@@ -0,0 +1,124 @@
+/*
+ * 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.flink.agents.runtime.actionstate;
+
+import org.apache.flink.agents.api.Event;
+import org.apache.flink.agents.api.InputEvent;
+import org.apache.flink.agents.api.context.RunnerContext;
+import org.apache.flink.agents.plan.JavaFunction;
+import org.apache.flink.agents.plan.actions.Action;
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.List;
+
+import static 
org.apache.flink.agents.runtime.actionstate.ActionStateTestUtils.generateKey;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotSame;
+import static org.junit.jupiter.api.Assumptions.assumeTrue;
+
+/**
+ * Reproduces the cross-restart failure of the action-UUID key segment inside 
one JVM.
+ *
+ * <p>{@link JavaFunction#hashCode()} folds in {@code 
Arrays.hashCode(Class<?>[] parameterTypes)},
+ * and {@link Class#hashCode()} is an identity hash, so the same action hashes 
differently in every
+ * process. A fresh JVM cannot be started from a unit test, but a fresh class 
loader produces the
+ * same effect: it defines a distinct {@link Class} object for the same bytes, 
with its own identity
+ * hash. The loader below defines only {@link ParamEvent} itself and delegates 
everything else to
+ * the parent, so {@link Action}'s signature check against {@link Event} still 
passes.
+ */
+public class ActionStateUtilCrossClassLoaderTest {
+
+    private static final int MAX_PARALLELISM = 128;
+
+    /** Subclass of {@link Event} that the isolating loader defines a second 
time. */
+    public static class ParamEvent extends Event {
+        public ParamEvent() {
+            super("param");
+        }
+    }
+
+    @Test
+    public void 
testKeyIsStableWhenParameterClassesComeFromDifferentClassLoaders()
+            throws Exception {
+        Class<?> paramFromLoaderA = new 
IsolatingLoader().loadClass(ParamEvent.class.getName());
+        Class<?> paramFromLoaderB = new 
IsolatingLoader().loadClass(ParamEvent.class.getName());
+        assertNotSame(paramFromLoaderA, paramFromLoaderB);
+
+        Action first = actionWithParameterType(paramFromLoaderA);
+        Action second = actionWithParameterType(paramFromLoaderB);
+        // Identity hashes almost always differ; this is the pre-fix failure 
mode. On the rare
+        // collision there is nothing to test, so skip rather than fail.
+        assumeTrue(
+                first.hashCode() != second.hashCode(),
+                "identity hashes of the two Class objects collided");
+
+        InputEvent event = new InputEvent("test-input");
+        assertEquals(
+                generateKey("test-key", 7, first, event, MAX_PARALLELISM),
+                generateKey("test-key", 7, second, event, MAX_PARALLELISM));
+    }
+
+    private static Action actionWithParameterType(Class<?> eventParameterType) 
throws Exception {
+        return new Action(
+                "stable-name",
+                new JavaFunction(
+                        NoOpAction.class.getName(),
+                        "doNothing",
+                        new Class<?>[] {eventParameterType, 
RunnerContext.class}),
+                List.of(InputEvent.EVENT_TYPE));
+    }
+
+    /** Defines {@link ParamEvent} itself and delegates every other class to 
the parent loader. */
+    private static final class IsolatingLoader extends ClassLoader {
+        IsolatingLoader() {
+            super(ActionStateUtilCrossClassLoaderTest.class.getClassLoader());
+        }
+
+        @Override
+        protected Class<?> loadClass(String name, boolean resolve) throws 
ClassNotFoundException {
+            if (!name.equals(ParamEvent.class.getName())) {
+                return super.loadClass(name, resolve);
+            }
+            synchronized (getClassLoadingLock(name)) {
+                Class<?> loaded = findLoadedClass(name);
+                if (loaded == null) {
+                    byte[] bytes = readClassBytes(name);
+                    loaded = defineClass(name, bytes, 0, bytes.length);
+                }
+                if (resolve) {
+                    resolveClass(loaded);
+                }
+                return loaded;
+            }
+        }
+
+        private byte[] readClassBytes(String name) throws 
ClassNotFoundException {
+            String resource = name.replace('.', '/') + ".class";
+            try (InputStream in = getParent().getResourceAsStream(resource)) {
+                if (in == null) {
+                    throw new ClassNotFoundException(name);
+                }
+                return in.readAllBytes();
+            } catch (IOException e) {
+                throw new ClassNotFoundException(name, e);
+            }
+        }
+    }
+}
diff --git 
a/runtime/src/test/java/org/apache/flink/agents/runtime/actionstate/ActionStateUtilTest.java
 
b/runtime/src/test/java/org/apache/flink/agents/runtime/actionstate/ActionStateUtilTest.java
index deff9f67..2430784d 100644
--- 
a/runtime/src/test/java/org/apache/flink/agents/runtime/actionstate/ActionStateUtilTest.java
+++ 
b/runtime/src/test/java/org/apache/flink/agents/runtime/actionstate/ActionStateUtilTest.java
@@ -182,21 +182,6 @@ public class ActionStateUtilTest {
                 actionUUIDSegment);
     }
 
-    /**
-     * Two separately constructed Action instances with the same name — which 
is what "the same
-     * action, after a JVM restart" looks like — must produce identical state 
keys, or recovery can
-     * never replay.
-     */
-    @Test
-    public void testSameActionNameYieldsSameKeyAcrossInstances() throws 
Exception {
-        InputEvent event = new InputEvent("test-input");
-        String first =
-                generateKey("test-key", 7, new NoOpAction("stable-name"), 
event, MAX_PARALLELISM);
-        String second =
-                generateKey("test-key", 7, new NoOpAction("stable-name"), 
event, MAX_PARALLELISM);
-        assertEquals(first, second);
-    }
-
     @Test
     public void testParseKeyValidKey() throws Exception {
         // Create test data and generate a key

Reply via email to