-
Notifications
You must be signed in to change notification settings - Fork 167
[bug][runtime] Derive durable-state action UUID from the plan-unique action name #1094
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
purushah
wants to merge
1
commit into
apache:main
Choose a base branch
from
purushah:fix-action-state-key-stability
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
123 changes: 123 additions & 0 deletions
123
...java/org/apache/flink/agents/runtime/actionstate/ActionStateUtilCrossClassLoaderTest.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| } | ||
| } | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
94 changes: 94 additions & 0 deletions
94
...e/src/test/java/org/apache/flink/agents/runtime/actionstate/CrossJvmKeyStabilityTest.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
| } | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good catch, thanks.
PythonFunctionhashesmoduleandqualName, 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 todeployment.md.There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.