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 f7cebf619 [runtime] Add cross-release state migration test for 
ActionExecutionOperator (#1341)
f7cebf619 is described below

commit f7cebf619f428fd4f2c58d2ca8f094f098379914
Author: Sangkyoon Nam <[email protected]>
AuthorDate: Mon Nov 2 18:44:33 2026 +0900

    [runtime] Add cross-release state migration test for 
ActionExecutionOperator (#1341)
    
    Generated-by: Claude Code 2.1.312 (Claude Opus 5.5)
---
 runtime/pom.xml                                    |  45 ++++
 .../runtime/migration/FlinkAgentsVersion.java      |  69 ++++++
 .../migration/MigrationTestsSnapshotGenerator.java | 102 +++++++++
 .../runtime/migration/SnapshotsGenerator.java      |  34 +++
 .../ActionExecutionOperatorMigrationTest.java      | 239 +++++++++++++++++++++
 ...ator-migration-at-rest-flink-agents0.4-snapshot | Bin 0 -> 5496 bytes
 6 files changed, 489 insertions(+)

diff --git a/runtime/pom.xml b/runtime/pom.xml
index 96ba564d0..0e1a2cb2e 100644
--- a/runtime/pom.xml
+++ b/runtime/pom.xml
@@ -246,6 +246,51 @@ under the License.
     </build>
 
     <profiles>
+        <!--
+          Writes the reference snapshots of migration tests for one release. 
Run it on the
+          release branch of that version:
+          mvn clean package -Pgenerate-migration-test-data 
-Dgenerate.version=0.4 -DskipTests -pl runtime -am
+        -->
+        <profile>
+            <id>generate-migration-test-data</id>
+            <properties>
+                
<generate.classes>org.apache.flink.agents.runtime.operator.ActionExecutionOperatorMigrationTest</generate.classes>
+            </properties>
+            <build>
+                <plugins>
+                    <plugin>
+                        <groupId>org.apache.maven.plugins</groupId>
+                        <artifactId>maven-antrun-plugin</artifactId>
+                        <executions>
+                            <execution>
+                                <id>generate-migration-test-data</id>
+                                <phase>package</phase>
+                                <goals>
+                                    <goal>run</goal>
+                                </goals>
+                                <configuration>
+                                    <target>
+                                        <java 
classname="org.apache.flink.agents.runtime.migration.MigrationTestsSnapshotGenerator"
+                                              fork="true" failonerror="true" 
dir="${project.basedir}">
+                                            <classpath 
refid="maven.test.classpath"/>
+                                            <!-- Same flags as the surefire 
argLine in the root pom. -->
+                                            <jvmarg 
value="--add-opens=java.base/java.util=ALL-UNNAMED"/>
+                                            <jvmarg 
value="--add-opens=java.base/java.nio=ALL-UNNAMED"/>
+                                            <jvmarg 
value="--add-exports=java.base/jdk.internal.vm=ALL-UNNAMED"/>
+                                            <arg value="--version"/>
+                                            <arg value="${generate.version}"/>
+                                            <arg value="--classes"/>
+                                            <arg value="${generate.classes}"/>
+                                        </java>
+                                    </target>
+                                </configuration>
+                            </execution>
+                        </executions>
+                    </plugin>
+                </plugins>
+            </build>
+        </profile>
+
         <!-- Profile for building JDK 21 specific classes when running on JDK 
21+ -->
         <profile>
             <id>java-21</id>
diff --git 
a/runtime/src/test/java/org/apache/flink/agents/runtime/migration/FlinkAgentsVersion.java
 
b/runtime/src/test/java/org/apache/flink/agents/runtime/migration/FlinkAgentsVersion.java
new file mode 100644
index 000000000..cc22e549f
--- /dev/null
+++ 
b/runtime/src/test/java/org/apache/flink/agents/runtime/migration/FlinkAgentsVersion.java
@@ -0,0 +1,69 @@
+/*
+ * 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.migration;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Flink Agents releases that have reference snapshots for migration tests.
+ *
+ * <p>Flink's {@code FlinkVersion} can't name Flink Agents releases, so 
migration tests are
+ * parameterized over this enum instead. After a release, add its constant 
here and generate its
+ * snapshots on the release branch with the {@code 
generate-migration-test-data} profile.
+ */
+public enum FlinkAgentsVersion {
+    v0_4("0.4");
+
+    private static final Pattern VERSION_PATTERN = 
Pattern.compile("v?(\\d+)[._](\\d+)");
+
+    private final String versionStr;
+
+    FlinkAgentsVersion(String versionStr) {
+        this.versionStr = versionStr;
+    }
+
+    @Override
+    public String toString() {
+        return versionStr;
+    }
+
+    /** Returns every version with reference snapshots, oldest first. */
+    public static List<FlinkAgentsVersion> publishedVersions() {
+        return Arrays.asList(values());
+    }
+
+    /** Parses names like {@code 0.4}, {@code 0_4} or {@code v0_4}. */
+    public static FlinkAgentsVersion parse(String name) {
+        Matcher matcher = VERSION_PATTERN.matcher(name);
+        if (matcher.matches()) {
+            String versionStr = matcher.group(1) + "." + matcher.group(2);
+            for (FlinkAgentsVersion version : values()) {
+                if (version.versionStr.equals(versionStr)) {
+                    return version;
+                }
+            }
+        }
+        throw new IllegalArgumentException(
+                "Unknown Flink Agents version "
+                        + name
+                        + ". Add it to FlinkAgentsVersion before generating 
its snapshots.");
+    }
+}
diff --git 
a/runtime/src/test/java/org/apache/flink/agents/runtime/migration/MigrationTestsSnapshotGenerator.java
 
b/runtime/src/test/java/org/apache/flink/agents/runtime/migration/MigrationTestsSnapshotGenerator.java
new file mode 100644
index 000000000..2e419bea4
--- /dev/null
+++ 
b/runtime/src/test/java/org/apache/flink/agents/runtime/migration/MigrationTestsSnapshotGenerator.java
@@ -0,0 +1,102 @@
+/*
+ * 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.migration;
+
+import java.lang.reflect.Constructor;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Writes the reference snapshots of migration tests for one Flink Agents 
version.
+ *
+ * <p>Run it through the {@code generate-migration-test-data} profile from the 
repository root, on
+ * the release branch of the version being generated. The profile runs the 
generator in the {@code
+ * runtime} module directory, so the snapshots land in {@code 
runtime/src/test/resources}:
+ *
+ * <pre>
+ * mvn clean package -Pgenerate-migration-test-data -Dgenerate.version=0.4 
-DskipTests -pl runtime -am
+ * </pre>
+ *
+ * <p>Arguments: {@code --version <version> --classes <class>[,<class>...]}.
+ */
+public class MigrationTestsSnapshotGenerator {
+
+    private static final String USAGE =
+            "Usage: MigrationTestsSnapshotGenerator --version <version> 
--classes <class>[,<class>...]";
+
+    public static void main(String[] args) throws Throwable {
+        String versionName = null;
+        String classes = null;
+        if (args.length % 2 != 0) {
+            throw new IllegalArgumentException(USAGE);
+        }
+        for (int i = 0; i < args.length; i += 2) {
+            switch (args[i]) {
+                case "--version":
+                    versionName = args[i + 1];
+                    break;
+                case "--classes":
+                    classes = args[i + 1];
+                    break;
+                default:
+                    throw new IllegalArgumentException("Unknown argument " + 
args[i]);
+            }
+        }
+        List<String> classNames = new ArrayList<>();
+        if (classes != null) {
+            for (String className : classes.split(",")) {
+                if (!className.trim().isEmpty()) {
+                    classNames.add(className.trim());
+                }
+            }
+        }
+        if (versionName == null || classNames.isEmpty()) {
+            throw new IllegalArgumentException(USAGE);
+        }
+
+        FlinkAgentsVersion version = FlinkAgentsVersion.parse(versionName);
+        for (String className : classNames) {
+            generate(Class.forName(className), version);
+        }
+    }
+
+    private static void generate(Class<?> testClass, FlinkAgentsVersion 
version) throws Throwable {
+        Constructor<?> constructor = testClass.getDeclaredConstructor();
+        constructor.setAccessible(true);
+        Object test = constructor.newInstance();
+        int generated = 0;
+        for (Method method : testClass.getDeclaredMethods()) {
+            if (!method.isAnnotationPresent(SnapshotsGenerator.class)) {
+                continue;
+            }
+            method.setAccessible(true);
+            try {
+                method.invoke(test, version);
+            } catch (InvocationTargetException e) {
+                throw e.getCause();
+            }
+            generated++;
+        }
+        if (generated == 0) {
+            throw new IllegalArgumentException(
+                    testClass.getName() + " has no @SnapshotsGenerator 
method");
+        }
+    }
+}
diff --git 
a/runtime/src/test/java/org/apache/flink/agents/runtime/migration/SnapshotsGenerator.java
 
b/runtime/src/test/java/org/apache/flink/agents/runtime/migration/SnapshotsGenerator.java
new file mode 100644
index 000000000..07432216a
--- /dev/null
+++ 
b/runtime/src/test/java/org/apache/flink/agents/runtime/migration/SnapshotsGenerator.java
@@ -0,0 +1,34 @@
+/*
+ * 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.migration;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * Marks a method that writes the reference snapshots of a migration test.
+ *
+ * <p>The method must take a single {@link FlinkAgentsVersion} argument, the 
version the snapshots
+ * are written for. {@link MigrationTestsSnapshotGenerator} calls it on a new 
instance of the test
+ * class.
+ */
+@Target(ElementType.METHOD)
+@Retention(RetentionPolicy.RUNTIME)
+public @interface SnapshotsGenerator {}
diff --git 
a/runtime/src/test/java/org/apache/flink/agents/runtime/operator/ActionExecutionOperatorMigrationTest.java
 
b/runtime/src/test/java/org/apache/flink/agents/runtime/operator/ActionExecutionOperatorMigrationTest.java
new file mode 100644
index 000000000..ea9077483
--- /dev/null
+++ 
b/runtime/src/test/java/org/apache/flink/agents/runtime/operator/ActionExecutionOperatorMigrationTest.java
@@ -0,0 +1,239 @@
+/*
+ * 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.operator;
+
+import org.apache.flink.agents.api.Event;
+import org.apache.flink.agents.api.InputEvent;
+import org.apache.flink.agents.api.OutputEvent;
+import org.apache.flink.agents.api.context.MemoryObject;
+import org.apache.flink.agents.api.context.RunnerContext;
+import org.apache.flink.agents.plan.AgentPlan;
+import org.apache.flink.agents.plan.JavaFunction;
+import org.apache.flink.agents.plan.actions.Action;
+import org.apache.flink.agents.runtime.migration.FlinkAgentsVersion;
+import org.apache.flink.agents.runtime.migration.SnapshotsGenerator;
+import org.apache.flink.api.common.JobID;
+import org.apache.flink.api.common.state.ListState;
+import org.apache.flink.api.common.state.ListStateDescriptor;
+import org.apache.flink.api.common.state.State;
+import org.apache.flink.api.common.state.StateDescriptor;
+import org.apache.flink.api.common.state.ValueState;
+import org.apache.flink.api.common.state.ValueStateDescriptor;
+import org.apache.flink.api.common.typeinfo.TypeInformation;
+import org.apache.flink.api.java.functions.KeySelector;
+import org.apache.flink.runtime.state.KeyedStateBackend;
+import org.apache.flink.runtime.state.VoidNamespace;
+import org.apache.flink.runtime.state.VoidNamespaceSerializer;
+import org.apache.flink.streaming.api.operators.AbstractStreamOperator;
+import org.apache.flink.streaming.runtime.streamrecord.StreamRecord;
+import org.apache.flink.streaming.util.KeyedOneInputStreamOperatorTestHarness;
+import org.apache.flink.streaming.util.OperatorSnapshotUtil;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.MethodSource;
+
+import java.lang.reflect.Field;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Restores {@link ActionExecutionOperator} snapshots written by earlier Flink 
Agents releases.
+ *
+ * <p>The snapshots live in {@code src/test/resources} and are written by the 
{@link
+ * SnapshotsGenerator} methods on the release branch of each version. The test 
agent's actions and
+ * the generated input must stay the same across releases, so a restore 
failure points at a state or
+ * serializer change rather than a change in this test.
+ */
+class ActionExecutionOperatorMigrationTest {
+
+    static Collection<FlinkAgentsVersion> versions() {
+        return FlinkAgentsVersion.publishedVersions();
+    }
+
+    /**
+     * Snapshot taken after every input finished, so no action or input is 
pending. Inputs are keyed
+     * by parity: key 1 has seen 1 and 3, key 0 has seen 2.
+     */
+    @SnapshotsGenerator
+    void writeAtRestSnapshot(FlinkAgentsVersion version) throws Exception {
+        try (KeyedOneInputStreamOperatorTestHarness<Long, Long, Object> 
harness = harness()) {
+            harness.open();
+            processAndWait(harness, 1L);
+            processAndWait(harness, 3L);
+            processAndWait(harness, 2L);
+            assertThat(outputValues(harness)).containsExactly(1L, 4L, 2L);
+
+            OperatorSnapshotUtil.writeStateHandle(
+                    harness.snapshot(0L, 0L), "src/test/resources/" + 
atRestSnapshot(version));
+        }
+    }
+
+    @ParameterizedTest(name = "Migration snapshot: {0}")
+    @MethodSource("versions")
+    void restoresStateAtRest(FlinkAgentsVersion version) throws Exception {
+        try (KeyedOneInputStreamOperatorTestHarness<Long, Long, Object> 
harness = harness()) {
+            harness.initializeState(
+                    
OperatorSnapshotUtil.getResourceFilename(atRestSnapshot(version)));
+            harness.open();
+
+            AbstractStreamOperator<?> operator = (AbstractStreamOperator<?>) 
harness.getOperator();
+            assertThat(sequenceNumber(operator, 1L)).isEqualTo(1L);
+            assertThat(sequenceNumber(operator, 0L)).isEqualTo(0L);
+            for (long key : new long[] {0L, 1L}) {
+                assertThat(pending(operator, key, 
OperatorStateManager.ACTION_TASK_STATE_NAME))
+                        .isEmpty();
+                assertThat(
+                                pending(
+                                        operator,
+                                        key,
+                                        
OperatorStateManager.PENDING_INPUT_EVENT_STATE_NAME))
+                        .isEmpty();
+            }
+            assertThat(operatorState(operator, "currentProcessingKeys", 
Object.class)).isEmpty();
+
+            // The operator keeps the job identifier from the snapshot instead 
of taking this
+            // harness's job ID. The Java runner context doesn't expose it, so 
read the field.
+            List<String> storedJobIdentifier =
+                    operatorState(operator, "identifier_state", String.class);
+            assertThat(storedJobIdentifier).hasSize(1);
+            
assertThat(jobIdentifier(operator)).isEqualTo(storedJobIdentifier.get(0));
+            assertThat(JobID.fromHexString(storedJobIdentifier.get(0)))
+                    .isNotEqualTo(harness.getEnvironment().getJobID());
+
+            // The short-term memory keeps each key's running total, so 
processing continues from
+            // it.
+            processAndWait(harness, 1L);
+            processAndWait(harness, 2L);
+            processAndWait(harness, 5L);
+            assertThat(outputValues(harness)).containsExactly(5L, 4L, 10L);
+            assertThat(sequenceNumber(operator, 1L)).isEqualTo(3L);
+            assertThat(sequenceNumber(operator, 0L)).isEqualTo(1L);
+        }
+    }
+
+    private static String atRestSnapshot(FlinkAgentsVersion version) {
+        return "action-execution-operator-migration-at-rest-flink-agents" + 
version + "-snapshot";
+    }
+
+    private static KeyedOneInputStreamOperatorTestHarness<Long, Long, Object> 
harness()
+            throws Exception {
+        return new KeyedOneInputStreamOperatorTestHarness<>(
+                new 
ActionExecutionOperatorFactory(MigrationTestAgent.agentPlan(), true),
+                (KeySelector<Long, Long>) value -> value % 2,
+                TypeInformation.of(Long.class));
+    }
+
+    private static void processAndWait(
+            KeyedOneInputStreamOperatorTestHarness<Long, Long, Object> 
harness, long input)
+            throws Exception {
+        harness.processElement(new StreamRecord<>(input));
+        ((ActionExecutionOperator<Long, Object>) harness.getOperator())
+                .waitInFlightEventsFinished();
+    }
+
+    private static List<Object> outputValues(
+            KeyedOneInputStreamOperatorTestHarness<Long, Long, Object> 
harness) {
+        List<Object> values = new ArrayList<>();
+        for (Object record : harness.extractOutputStreamRecords()) {
+            values.add(((StreamRecord<?>) record).getValue());
+        }
+        harness.getOutput().clear();
+        return values;
+    }
+
+    private static Long sequenceNumber(AbstractStreamOperator<?> operator, 
long key)
+            throws Exception {
+        ValueState<Long> state =
+                keyedState(
+                        operator,
+                        key,
+                        new ValueStateDescriptor<>(
+                                
OperatorStateManager.MESSAGE_SEQUENCE_NUMBER_STATE_NAME,
+                                Long.class));
+        return state.value();
+    }
+
+    private static List<Object> pending(
+            AbstractStreamOperator<?> operator, long key, String stateName) 
throws Exception {
+        ListState<Object> state =
+                keyedState(
+                        operator,
+                        key,
+                        new ListStateDescriptor<>(stateName, 
TypeInformation.of(Object.class)));
+        List<Object> values = new ArrayList<>();
+        Iterable<Object> stored = state.get();
+        if (stored != null) {
+            stored.forEach(values::add);
+        }
+        return values;
+    }
+
+    private static <S extends State> S keyedState(
+            AbstractStreamOperator<?> operator, long key, StateDescriptor<S, 
?> descriptor)
+            throws Exception {
+        KeyedStateBackend<Object> backend = operator.getKeyedStateBackend();
+        backend.setCurrentKey(key);
+        return backend.getPartitionedState(
+                VoidNamespace.INSTANCE, VoidNamespaceSerializer.INSTANCE, 
descriptor);
+    }
+
+    private static String jobIdentifier(AbstractStreamOperator<?> operator) 
throws Exception {
+        Field field = 
ActionExecutionOperator.class.getDeclaredField("jobIdentifier");
+        field.setAccessible(true);
+        return (String) field.get(operator);
+    }
+
+    private static <T> List<T> operatorState(
+            AbstractStreamOperator<?> operator, String stateName, Class<T> 
type) throws Exception {
+        List<T> values = new ArrayList<>();
+        operator.getOperatorStateBackend()
+                .getUnionListState(new ListStateDescriptor<>(stateName, type))
+                .get()
+                .forEach(values::add);
+        return values;
+    }
+
+    /** Agent with one action that keeps a running total per key in short-term 
memory. */
+    public static class MigrationTestAgent {
+
+        private static final String TOTAL = "total";
+
+        static AgentPlan agentPlan() throws Exception {
+            Action accumulate =
+                    new Action(
+                            "accumulate",
+                            new JavaFunction(
+                                    MigrationTestAgent.class,
+                                    "accumulate",
+                                    new Class<?>[] {Event.class, 
RunnerContext.class}),
+                            List.of(InputEvent.EVENT_TYPE));
+            return new AgentPlan(Map.of(accumulate.getName(), accumulate));
+        }
+
+        public static void accumulate(Event event, RunnerContext context) 
throws Exception {
+            long input = (Long) InputEvent.fromEvent(event).getInput();
+            MemoryObject memory = context.getShortTermMemory();
+            long total = memory.isExist(TOTAL) ? (Long) 
memory.get(TOTAL).getValue() : 0L;
+            memory.set(TOTAL, total + input);
+            context.sendEvent(new OutputEvent(total + input));
+        }
+    }
+}
diff --git 
a/runtime/src/test/resources/action-execution-operator-migration-at-rest-flink-agents0.4-snapshot
 
b/runtime/src/test/resources/action-execution-operator-migration-at-rest-flink-agents0.4-snapshot
new file mode 100644
index 000000000..dc67cdfe9
Binary files /dev/null and 
b/runtime/src/test/resources/action-execution-operator-migration-at-rest-flink-agents0.4-snapshot
 differ

Reply via email to