Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/content/docs/operations/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 >}}
Original file line number Diff line number Diff line change
Expand Up @@ -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)));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question about compatibility: Python actions previously used a hash of stable strings, so their identifiers should've already been stable across restarts.

If a Python action saved a completed result before this change, I believe an upgraded job would end up looking for it under the new identifier and run the action again.

The name-based identifier makes sense, but I think we should clarify this upgrade case instead of saying no working deployment regresses, and explain how users should handle existing action state.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, thanks. PythonFunction hashes module and qualName, so Python action keys were already stable and this change does alter them. I'll fix the compatibility note: a Python action in flight at the restoring checkpoint re-executes once after upgrading. To avoid that, drain in-flight work before the stop-with-savepoint, or start the upgraded job with a fresh action-state topic/table. No fallback to the old key, per the beta breaking-change policy. I'll add a note to deployment.md.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok! A fresh topic/table would still allow completed actions to repeat if the job restores from an older checkpoint, since their saved results are unavailable.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, a fresh topic/table doesn't help; it just makes the old results unreachable the other way. I'll drop that from the note. The guidance will be: upgrade from a stop-with-savepoint taken after the job has gone idle with no pending actions, and don't restore the upgraded job from an older checkpoint. Restoring from an older checkpoint re-executes, once, the Python actions completed since that checkpoint.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great catch — you're right, and my compatibility note overclaimed. PythonFunction hashes stable strings (module, qualName), so Python-action keys were already stable across restarts and this change does invalidate their existing state. Will correct the note: Java action state was unrecoverable across restarts (the bug — no regression possible); Python action state was recoverable, and the first recovery after upgrading re-executes those actions once and re-persists under the new format. I'll spell that out as the upgrade guidance. If you'd prefer a legacy-key fallback read for Python actions during a deprecation window I'm happy to add one, though given the project's pre-1.0 status a release note may be the better trade.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed — a fresh topic only avoids stale entries; restore from an older checkpoint re-executes either way since the saved results are unreadable under the new format. Will fold that into the note.

}
}
Original file line number Diff line number Diff line change
@@ -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.
*
* <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(
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);
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String> command = new ArrayList<>();
command.add(javaBin);
command.add("-cp");
command.add(System.getProperty("java.class.path"));
command.add(PrintKey.class.getName());
Process process = new ProcessBuilder(command).redirectErrorStream(true).start();
String key = null;
StringBuilder output = new StringBuilder();
try (BufferedReader reader =
new BufferedReader(
new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
output.append(line).append('\n');
// Prefix-marked so child-JVM logging can never be mistaken for the key.
if (line.startsWith("STATE_KEY=")) {
key = line.substring("STATE_KEY=".length()).trim();
}
}
}
int exit = process.waitFor();
assertEquals(0, exit, "child JVM failed:\n" + output);
assertNotNull(key, "child JVM printed no state key:\n" + output);
return key;
}
}
Loading