diff --git a/docs/content/docs/operations/deployment.md b/docs/content/docs/operations/deployment.md index 384e2ab0e..9e8bb18b3 100644 --- a/docs/content/docs/operations/deployment.md +++ b/docs/content/docs/operations/deployment.md @@ -104,6 +104,10 @@ The same persisted action state is also used by fine-grained durable execution. See [Action State Store Configuration]({{< ref "docs/operations/configuration#action-state-store" >}}) for configuration options. +{{< hint warning >}} +**Upgrading**: Action state keys are derived from the action name since Flink Agents 0.4. Action state recorded by earlier versions is not consulted after the upgrade. Upgrade from a stop-with-savepoint taken after the job is idle with no pending actions, and do not restore the upgraded job from an older checkpoint; otherwise actions completed since that checkpoint are executed once more. +{{< /hint >}} + {{< hint info >}} **Note**: Exactly-once action consistency is guaranteed only if, after recovering from the same checkpoint, inputs for each key arrive in the same order as before recovery. If this ordering requirement is not met, the system falls back to exactly-once output consistency. {{< /hint >}} diff --git a/runtime/src/main/java/org/apache/flink/agents/runtime/actionstate/ActionStateUtil.java b/runtime/src/main/java/org/apache/flink/agents/runtime/actionstate/ActionStateUtil.java index 0d3e221ba..83a2e613a 100644 --- a/runtime/src/main/java/org/apache/flink/agents/runtime/actionstate/ActionStateUtil.java +++ b/runtime/src/main/java/org/apache/flink/agents/runtime/actionstate/ActionStateUtil.java @@ -222,8 +222,11 @@ private static String generateUUIDForEvent(Event event) throws IOException { } private static String generateUUIDForAction(Action action) throws IOException { + // Action.hashCode() folds in JavaFunction's Class[] parameterTypes, and Class.hashCode() + // is the per-JVM identity hash — so the hash-derived UUID changes on every process + // restart and recovery lookups can never hit. Derive from the plan-unique action name, + // which is stable across restarts. return String.valueOf( - UUID.nameUUIDFromBytes( - String.valueOf(action.hashCode()).getBytes(StandardCharsets.UTF_8))); + UUID.nameUUIDFromBytes(action.getName().getBytes(StandardCharsets.UTF_8))); } } 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 000000000..dee3dd91a --- /dev/null +++ b/runtime/src/test/java/org/apache/flink/agents/runtime/actionstate/ActionStateUtilCrossClassLoaderTest.java @@ -0,0 +1,123 @@ +/* + * 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.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. + * + *
{@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(
+ ActionStateUtil.generateKey("test-key", 7, first, event, MAX_PARALLELISM),
+ ActionStateUtil.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 eb2ba717d..67ddd730b 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
@@ -21,7 +21,9 @@
import org.apache.flink.agents.plan.actions.Action;
import org.junit.jupiter.api.Test;
+import java.nio.charset.StandardCharsets;
import java.util.List;
+import java.util.UUID;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
@@ -116,6 +118,26 @@ public void testGenerateKeyRejectsNonPositiveMaxParallelism() throws Exception {
() -> ActionStateUtil.generateKey(key, 1, action, inputEvent, -1));
}
+ /**
+ * The action-UUID key segment must be derived from the plan-unique action NAME, never from
+ * {@code Action.hashCode()}: the hash folds in {@code Class.hashCode()} (a per-JVM identity
+ * hash), so a hash-derived segment silently changes across process restarts and recovery
+ * lookups can never hit. This pins the derivation so any future change to the key format is a
+ * conscious, reviewed break of cross-restart state compatibility.
+ */
+ @Test
+ public void testActionUUIDSegmentDerivesFromActionName() throws Exception {
+ Action action = new NoOpAction("test-action");
+ String generatedKey =
+ ActionStateUtil.generateKey(
+ "test-key", 1, action, new InputEvent("test-input"), MAX_PARALLELISM);
+
+ String actionUUIDSegment = ActionStateUtil.parseKey(generatedKey).get(3);
+ assertEquals(
+ UUID.nameUUIDFromBytes("test-action".getBytes(StandardCharsets.UTF_8)).toString(),
+ actionUUIDSegment);
+ }
+
@Test
public void testParseKeyValidKey() throws Exception {
// Create test data and generate a key
diff --git a/runtime/src/test/java/org/apache/flink/agents/runtime/actionstate/CrossJvmKeyStabilityTest.java b/runtime/src/test/java/org/apache/flink/agents/runtime/actionstate/CrossJvmKeyStabilityTest.java
new file mode 100644
index 000000000..5f4464a38
--- /dev/null
+++ b/runtime/src/test/java/org/apache/flink/agents/runtime/actionstate/CrossJvmKeyStabilityTest.java
@@ -0,0 +1,94 @@
+/*
+ * 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.InputEvent;
+import org.apache.flink.agents.plan.actions.Action;
+import org.junit.jupiter.api.Test;
+
+import java.io.BufferedReader;
+import java.io.InputStreamReader;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+/**
+ * Cross-JVM regression test for the original failure (review): durable-state keys must be identical
+ * when the same action is keyed from two DIFFERENT JVM processes, which is what recovery after a
+ * process restart looks like. The in-JVM tests in {@link ActionStateUtilTest} pin the derivation;
+ * they cannot catch a hash that folds in {@code Class.hashCode()}, because identity hashes are
+ * stable within one JVM — the pre-fix bug only manifests across processes.
+ */
+class CrossJvmKeyStabilityTest {
+
+ /** Entry point run in the child JVMs: prints the state key for a fixed action and event. */
+ public static final class PrintKey {
+ public static void main(String[] args) throws Exception {
+ Action action = new NoOpAction("cross-jvm-action");
+ InputEvent event = new InputEvent("cross-jvm-input");
+ // Fixed key/seqNum/maxParallelism so the only possible variation is the action UUID.
+ System.out.println(
+ "STATE_KEY="
+ + ActionStateUtil.generateKey("cross-jvm-key", 5, action, event, 8));
+ }
+ }
+
+ @Test
+ void sameActionYieldsSameKeyAcrossSeparateJvms() throws Exception {
+ String first = keyFromFreshJvm();
+ String second = keyFromFreshJvm();
+ assertEquals(
+ first,
+ second,
+ "Durable-state keys must be stable across JVM restarts, or recovery can never"
+ + " replay saved action results.");
+ }
+
+ private static String keyFromFreshJvm() throws Exception {
+ String javaBin =
+ Path.of(System.getProperty("java.home"), "bin", "java").toAbsolutePath().toString();
+ List