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 069084082 [runtime] Fix action-state key collisions between event
occurrences with identical attributes (#1342)
069084082 is described below
commit 06908408218d80bc2b12cf1f4ee7d36f21c1ae05
Author: Ashfaq <[email protected]>
AuthorDate: Wed Nov 4 08:47:29 2026 +0530
[runtime] Fix action-state key collisions between event occurrences with
identical attributes (#1342)
Generated-by: Claude Code 2.1.316 (Claude Sonnet 5)
---
.../java/org/apache/flink/agents/api/Event.java | 28 ++++++
.../runtime/actionstate/ActionStateUtil.java | 88 ++++++++++++++++-
.../runtime/operator/ActionExecutionOperator.java | 45 +++++++++
.../ActionStateDivergenceRecoveryTest.java | 34 +++++++
.../runtime/actionstate/ActionStateUtilTest.java | 104 +++++++++++++++++++++
.../actionstate/InMemoryActionStateStore.java | 9 ++
.../KafkaActionStateStoreRecoveryTest.java | 15 +++
.../operator/ActionExecutionOperatorTest.java | 34 ++++++-
8 files changed, 352 insertions(+), 5 deletions(-)
diff --git a/api/src/main/java/org/apache/flink/agents/api/Event.java
b/api/src/main/java/org/apache/flink/agents/api/Event.java
index 1613ecffd..9c87f6e90 100644
--- a/api/src/main/java/org/apache/flink/agents/api/Event.java
+++ b/api/src/main/java/org/apache/flink/agents/api/Event.java
@@ -60,6 +60,17 @@ public class Event {
*/
private Long sourceTimestamp;
+ /**
+ * Runtime-internal lineage identity for this specific event occurrence.
Not part of the
+ * cross-language event contract and not {@link #id}: {@link #id} is a
fresh random value on
+ * every construction (including on replay of the same logical input),
while this field is
+ * assigned deterministically by the runtime from the occurrence's
position in the causal tree
+ * (business key and sequence number for a root event; producing action
execution plus output
+ * ordinal for an action's output event). It lets durable action-state
keys distinguish sibling
+ * event occurrences that carry identical attributes.
+ */
+ private String occurrenceId;
+
/** Unified event with user-defined type and attributes. */
public Event(String type, Map<String, Object> attributes) {
this(UUID.randomUUID(), type, attributes, new HashMap<>());
@@ -156,6 +167,22 @@ public class Event {
this.sourceTimestamp = timestamp;
}
+ /** Returns this occurrence's runtime-assigned lineage identity, or {@code
null} if unset. */
+ @JsonIgnore
+ public String getOccurrenceId() {
+ return occurrenceId;
+ }
+
+ /**
+ * Assigns this occurrence's runtime-internal lineage identity. Called by
the runtime once, at
+ * the point the occurrence is created (root event ingestion or
action-output emission); not a
+ * user-facing API.
+ */
+ @JsonIgnore
+ public void setOccurrenceId(String occurrenceId) {
+ this.occurrenceId = occurrenceId;
+ }
+
/**
* Creates a base Event from another Event, copying its identity, data,
attachments, and
* framework metadata. Subclasses override this to reconstruct typed event
objects with proper
@@ -188,6 +215,7 @@ public class Event {
reconstructedEvent.attachments.clear();
reconstructedEvent.attachments.putAll(source.attachments);
reconstructedEvent.sourceTimestamp = source.sourceTimestamp;
+ reconstructedEvent.occurrenceId = source.occurrenceId;
return reconstructed;
}
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 e67553a73..cc8cf5949 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
@@ -342,8 +342,92 @@ public final class ActionStateUtil {
}
private static String generateUUIDForEvent(Event event) throws IOException
{
- return String.valueOf(
-
UUID.nameUUIDFromBytes(MAPPER.writeValueAsBytes(event.getAttributes())));
+ byte[] attributesBytes =
MAPPER.writeValueAsBytes(event.getAttributes());
+ String occurrenceId = event.getOccurrenceId();
+ if (occurrenceId == null) {
+ // Attributes-only fallback. Distinct occurrences with identical
attributes collide
+ // here; kept only because a large pre-existing lower-level test
surface (key-encoding
+ // and store tests predating occurrence identity) builds events
without assigning one.
+ // Removing this fallback outright requires migrating that test
surface to assign an
+ // occurrence identity on every constructed event, tracked as a
separate follow-up.
+ return String.valueOf(UUID.nameUUIDFromBytes(attributesBytes));
+ }
+ // Folds the occurrence identity together with the existing attributes
hash, rather than
+ // replacing it: attributes must stay in the key so the content-based
replay-divergence
+ // protection this already provides (a later restore with a changed
input must not reuse a
+ // cached result from the other input's history) is unchanged, while
the occurrence
+ // identity additionally distinguishes sibling occurrences that carry
identical attributes,
+ // which attributes alone cannot. Folded through nameUUIDFromBytes so
the composite key's
+ // fixed five-segment, 36-char-UUID format is unchanged.
+ byte[] occurrenceBytes = occurrenceId.getBytes(StandardCharsets.UTF_8);
+ byte[] combined = new byte[occurrenceBytes.length + 1 +
attributesBytes.length];
+ System.arraycopy(occurrenceBytes, 0, combined, 0,
occurrenceBytes.length);
+ combined[occurrenceBytes.length] = '#';
+ System.arraycopy(
+ attributesBytes, 0, combined, occurrenceBytes.length + 1,
attributesBytes.length);
+ return String.valueOf(UUID.nameUUIDFromBytes(combined));
+ }
+
+ /**
+ * Derives the replay-stable occurrence identity of a root (input) event
from its business-key
+ * identity and sequence number. Stable across replay of the same logical
input: a replayed
+ * {@code InputEvent} gets a fresh random {@link Event#getId()} but the
same business key and
+ * sequence number, so it derives the same occurrence identity.
+ */
+ public static String deriveRootEventOccurrenceId(String
businessKeyIdentity, long seqNum) {
+ Preconditions.checkNotNull(businessKeyIdentity, "businessKeyIdentity
cannot be null.");
+ return sha256Base64(
+ (businessKeyIdentity + KEY_SEPARATOR +
seqNum).getBytes(StandardCharsets.UTF_8));
+ }
+
+ /**
+ * Derives the occurrence identity of one of an action execution's output
events from that
+ * execution's own action-execution identity (its composite action-state
key, which already
+ * folds in its own triggering event's occurrence identity and the
action's identity) and the
+ * event's 0-based position among that execution's emitted outputs. Two
sibling output events
+ * with identical attributes get distinct identities because their
ordinals differ; the same
+ * event re-derived during a deterministic replay gets the same identity
because the producing
+ * execution's identity and the emission order are themselves
replay-stable.
+ */
+ public static String deriveOutputEventOccurrenceId(
+ String producerActionExecutionId, int outputOrdinal) {
+ Preconditions.checkNotNull(
+ producerActionExecutionId, "producerActionExecutionId cannot
be null.");
+ Preconditions.checkArgument(
+ outputOrdinal >= 0, "outputOrdinal must be nonnegative but was
%s.", outputOrdinal);
+ return sha256Base64(
+ (producerActionExecutionId + KEY_SEPARATOR + outputOrdinal)
+ .getBytes(StandardCharsets.UTF_8));
+ }
+
+ /**
+ * Derives the identity of one action execution from its triggering
event's own occurrence
+ * identity and the action's identity. Independent of the business key,
sequence number, and
+ * durable storage: the business key and sequence number are already
folded into the triggering
+ * event's occurrence identity, so no store or key encoder is needed here.
+ *
+ * <p>Chat and internal-subagent call paths construct their own triggering
events (subgraph
+ * delegates, chat requests) without routing them through the root-event
assignment that gives
+ * an ordinary {@code InputEvent} its occurrence identity, so {@code
triggeringEvent} can arrive
+ * here with none set. Falls back to an attributes-only identity for that
event in that case,
+ * same tradeoff as {@link #generateUUIDForEvent}'s fallback: collides on
distinct occurrences
+ * with identical attributes, kept only because propagating occurrence
identity through those
+ * paths is a larger migration tracked as a separate follow-up.
+ */
+ public static String deriveActionExecutionId(Event triggeringEvent, Action
action)
+ throws IOException {
+ Preconditions.checkNotNull(triggeringEvent, "triggeringEvent cannot be
null.");
+ Preconditions.checkNotNull(action, "action cannot be null.");
+ String occurrenceId = triggeringEvent.getOccurrenceId();
+ String effectiveId =
+ occurrenceId != null
+ ? occurrenceId
+ : String.valueOf(
+ UUID.nameUUIDFromBytes(
+
MAPPER.writeValueAsBytes(triggeringEvent.getAttributes())));
+ return sha256Base64(
+ (effectiveId + KEY_SEPARATOR + generateUUIDForAction(action))
+ .getBytes(StandardCharsets.UTF_8));
}
private static String generateUUIDForAction(Action action) throws
IOException {
diff --git
a/runtime/src/main/java/org/apache/flink/agents/runtime/operator/ActionExecutionOperator.java
b/runtime/src/main/java/org/apache/flink/agents/runtime/operator/ActionExecutionOperator.java
index a5bc2e4ef..23f0b17a9 100644
---
a/runtime/src/main/java/org/apache/flink/agents/runtime/operator/ActionExecutionOperator.java
+++
b/runtime/src/main/java/org/apache/flink/agents/runtime/operator/ActionExecutionOperator.java
@@ -36,7 +36,9 @@ import
org.apache.flink.agents.plan.subagent.InternalSubagentPlans;
import org.apache.flink.agents.plan.utils.CancellationUtils;
import org.apache.flink.agents.runtime.ResourceCache;
import org.apache.flink.agents.runtime.actionstate.ActionState;
+import org.apache.flink.agents.runtime.actionstate.ActionStateKeyEncoder;
import org.apache.flink.agents.runtime.actionstate.ActionStateStore;
+import org.apache.flink.agents.runtime.actionstate.ActionStateUtil;
import org.apache.flink.agents.runtime.async.ContinuationActionExecutor;
import org.apache.flink.agents.runtime.async.ContinuationContext;
import org.apache.flink.agents.runtime.context.RunnerContextImpl;
@@ -293,6 +295,11 @@ public class ActionExecutionOperator<IN, OUT> extends
AbstractStreamOperator<OUT
private final transient DurableExecutionManager durableExecManager;
+ // Derives root-event business-key identities directly from the operator's
own keyed-state
+ // serializer, independent of whether durable execution (and so an
ActionStateStore) is
+ // configured, so every root event gets a stable occurrence identity
regardless.
+ private transient ActionStateKeyEncoder occurrenceIdentityKeyEncoder;
+
private transient OperatorStateManager stateManager;
private transient TypeSerializer<Event> eventSerializer;
@@ -378,6 +385,8 @@ public class ActionExecutionOperator<IN, OUT> extends
AbstractStreamOperator<OUT
eventRouter.open(builtInMetrics);
int maxParallelism =
getRuntimeContext().getTaskInfo().getMaxNumberOfParallelSubtasks();
+ occurrenceIdentityKeyEncoder =
+ new ActionStateKeyEncoder(maxParallelism,
getActionStateKeySerializer());
durableExecManager.maybeInitActionStateStore(
agentPlan.getConfig(), maxParallelism,
getActionStateKeySerializer());
durableExecManager.initRecoveryMarkerState(getOperatorStateBackend());
@@ -616,6 +625,37 @@ public class ActionExecutionOperator<IN, OUT> extends
AbstractStreamOperator<OUT
}
}
+ /**
+ * Assigns a root (input) event's replay-stable occurrence identity,
derived from its business
+ * key and sequence number. Assigned unconditionally, independent of
whether durable execution
+ * is enabled, so chat and internal-subagent call paths can rely on it too.
+ */
+ private void assignRootOccurrenceId(Object key, Event event) throws
Exception {
+ String businessKeyIdentity =
occurrenceIdentityKeyEncoder.generateBusinessKeyIdentity(key);
+ event.setOccurrenceId(
+ ActionStateUtil.deriveRootEventOccurrenceId(
+ businessKeyIdentity,
stateManager.getSequenceNumber()));
+ }
+
+ /**
+ * Assigns each of an action execution's output events its lineage-based
occurrence identity
+ * before dispatch, so that distinct event occurrences with identical
attributes never collide
+ * in the durable action-state key. Derived purely from the triggering
event's own occurrence
+ * identity and the action's identity, so it needs neither the business
key nor a durable store.
+ */
+ private void assignOutputOccurrenceIds(ActionTask actionTask, List<Event>
outputEvents)
+ throws Exception {
+ String producerExecutionId =
+ ActionStateUtil.deriveActionExecutionId(actionTask.event,
actionTask.action);
+ for (int ordinal = 0; ordinal < outputEvents.size(); ordinal++) {
+ outputEvents
+ .get(ordinal)
+ .setOccurrenceId(
+ ActionStateUtil.deriveOutputEventOccurrenceId(
+ producerExecutionId, ordinal));
+ }
+ }
+
/** Processes an Action's output Event with its producer and upstream
references populated. */
private void processActionOutputEvent(
Object key, String contextKey, Event outputEvent, ActionTask
actionTask)
@@ -686,6 +726,7 @@ public class ActionExecutionOperator<IN, OUT> extends
AbstractStreamOperator<OUT
stateManager.addProcessingKey(key);
recordPriorities.put(key, new
RecordPriority(nextRecordIndex++));
stateManager.initOrIncSequenceNumber();
+ assignRootOccurrenceId(key, event);
if (freshRecordRound) {
// Announced once the record's own bookkeeping is in place
and before anything
// it triggers, so a listener never sees this record's
action callbacks outside
@@ -1037,6 +1078,7 @@ public class ActionExecutionOperator<IN, OUT> extends
AbstractStreamOperator<OUT
}
}
+ assignOutputOccurrenceIds(actionTask, outputEvents);
for (Event actionOutputEvent : outputEvents) {
processActionOutputEvent(key, contextKey, actionOutputEvent,
actionTask);
}
@@ -1222,6 +1264,8 @@ public class ActionExecutionOperator<IN, OUT> extends
AbstractStreamOperator<OUT
+ " or set execution.batch-state-backend.enabled to
false.");
int maxParallelism =
getRuntimeContext().getTaskInfo().getMaxNumberOfParallelSubtasks();
+ occurrenceIdentityKeyEncoder =
+ new ActionStateKeyEncoder(maxParallelism,
getActionStateKeySerializer());
durableExecManager.maybeInitActionStateStore(
agentPlan.getConfig(), maxParallelism,
getActionStateKeySerializer());
@@ -2108,6 +2152,7 @@ public class ActionExecutionOperator<IN, OUT> extends
AbstractStreamOperator<OUT
// callback belongs to, which would otherwise get none.
notifyActionReused(actionTask);
}
+ assignOutputOccurrenceIds(actionTask,
result.getOutputEvents());
} catch (Throwable t) {
if (lifecyclePrepared && actionTask != null) {
notifyActionFailed(actionTask, t);
diff --git
a/runtime/src/test/java/org/apache/flink/agents/runtime/actionstate/ActionStateDivergenceRecoveryTest.java
b/runtime/src/test/java/org/apache/flink/agents/runtime/actionstate/ActionStateDivergenceRecoveryTest.java
index 4ee976b71..172231c5b 100644
---
a/runtime/src/test/java/org/apache/flink/agents/runtime/actionstate/ActionStateDivergenceRecoveryTest.java
+++
b/runtime/src/test/java/org/apache/flink/agents/runtime/actionstate/ActionStateDivergenceRecoveryTest.java
@@ -97,6 +97,26 @@ public class ActionStateDivergenceRecoveryTest {
6);
}
+ /**
+ * FLINK-AGENTS-1084 regression guard: two sibling output events with
identical attributes (not
+ * merely two different ones, as above) must each trigger a genuinely
separate action execution
+ * of "child" rather than the second silently reusing the first's durable
state. Without the
+ * occurrence-identity fix this collapses to 4 total calls (1 "input" + 1
"child" per input)
+ * instead of 6, because the second "child" execution's state lookup would
find the first's
+ * just-persisted completed state under the same (then-colliding) key.
+ */
+ @ParameterizedTest
+ @ValueSource(strings = {"kafka", "fluss", "kafka-parallel",
"fluss-parallel"})
+ void
normalFanoutWithIdenticalSiblingAttributesStillExecutesBothSiblings(String
backend)
+ throws Exception {
+ assertNormalRecovery(
+ backend,
+ Map.of(
+ "input", action("input", "emitTwoIdentical",
InputEvent.EVENT_TYPE),
+ "child", action("child", "count", "step")),
+ 6);
+ }
+
private void assertNormalRecovery(String backend, Map<String, Action>
actions, int expected)
throws Exception {
CALLS.set(0);
@@ -301,6 +321,20 @@ public class ActionStateDivergenceRecoveryTest {
ctx.sendEvent(new Event("step", Map.of("value", "second")));
}
+ /**
+ * Emits two sibling output events with IDENTICAL attributes
(FLINK-AGENTS-1084's reproduction
+ * shape). Before the occurrence-identity fix, both siblings' durable
action-state keys
+ * collided: the second child's pre-execution lookup found the first
child's just-persisted
+ * completed state and incorrectly skipped its own real execution,
replaying the first child's
+ * result instead. See {@link #emitTwo} for the distinct-attributes
sibling case this
+ * complements.
+ */
+ public static void emitTwoIdentical(Event event, RunnerContext ctx) {
+ CALLS.incrementAndGet();
+ ctx.sendEvent(new Event("step", Map.of("value", "same")));
+ ctx.sendEvent(new Event("step", Map.of("value", "same")));
+ }
+
public static void setOrRead(Event event, RunnerContext ctx) throws
Exception {
CALLS.incrementAndGet();
String value = (String) event.getAttr("input");
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 bab111d74..92a131662 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
@@ -17,6 +17,7 @@
*/
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.plan.actions.Action;
import org.apache.flink.agents.runtime.subgraph.SubgraphEvent;
@@ -27,6 +28,7 @@ import org.junit.jupiter.params.provider.ValueSource;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
+import java.util.Map;
import java.util.Objects;
import java.util.UUID;
@@ -61,6 +63,108 @@ public class ActionStateUtilTest {
assertEquals(key1, key2);
}
+ /**
+ * Reproduces the collision reported in FLINK-AGENTS-1084: without an
assigned occurrence
+ * identity (the legacy fallback, e.g. state written before this fix), two
distinct event
+ * occurrences with identical attributes reaching the same action under
the same business key
+ * and sequence number produce the same durable action-state key.
+ */
+ @Test
+ public void testDistinctEventOccurrencesWithoutOccurrenceIdCollideLegacy()
throws Exception {
+ Object businessKey = "key";
+ Action action = new NoOpAction("work-action");
+ Event first = new Event("WorkItem", Map.of("value", "same"));
+ Event second = new Event("WorkItem", Map.of("value", "same"));
+
+ assertNotEquals(first.getId(), second.getId());
+
+ String firstKey = generateKey(businessKey, 1L, action, first,
MAX_PARALLELISM);
+ String secondKey = generateKey(businessKey, 1L, action, second,
MAX_PARALLELISM);
+
+ assertEquals(firstKey, secondKey);
+ }
+
+ /**
+ * The fix for FLINK-AGENTS-1084: once the runtime assigns each output
event its lineage-based
+ * occurrence identity (as {@code ActionExecutionOperator} does via {@link
+ * #deriveOutputEventOccurrenceId}), two sibling occurrences with
identical attributes no longer
+ * collide, because their output ordinals differ.
+ */
+ @Test
+ public void testDistinctEventOccurrencesWithOccurrenceIdDoNotCollide()
throws Exception {
+ Object businessKey = "key";
+ Action action = new NoOpAction("work-action");
+ Event first = new Event("WorkItem", Map.of("value", "same"));
+ Event second = new Event("WorkItem", Map.of("value", "same"));
+
+ String producerExecutionId = generateKey(businessKey, 1L, action,
first, MAX_PARALLELISM);
+ first.setOccurrenceId(
+
ActionStateUtil.deriveOutputEventOccurrenceId(producerExecutionId, 0));
+ second.setOccurrenceId(
+
ActionStateUtil.deriveOutputEventOccurrenceId(producerExecutionId, 1));
+
+ String firstKey = generateKey(businessKey, 1L, action, first,
MAX_PARALLELISM);
+ String secondKey = generateKey(businessKey, 1L, action, second,
MAX_PARALLELISM);
+
+ assertNotEquals(firstKey, secondKey);
+ }
+
+ /**
+ * Content-based replay-divergence protection must survive the
occurrence-identity fix: a
+ * changed attribute still changes the key even when both occurrences
share the same lineage
+ * position (same producer execution and ordinal), as it would on a
genuine replay divergence.
+ */
+ @Test
+ public void testOccurrenceIdDoesNotSuppressContentDivergence() throws
Exception {
+ Object businessKey = "key";
+ Action action = new NoOpAction("work-action");
+ Event original = new Event("WorkItem", Map.of("value", "A"));
+ Event diverged = new Event("WorkItem", Map.of("value", "B"));
+ String sameLineage = "producer-exec-id#0";
+ original.setOccurrenceId(sameLineage);
+ diverged.setOccurrenceId(sameLineage);
+
+ String originalKey = generateKey(businessKey, 1L, action, original,
MAX_PARALLELISM);
+ String divergedKey = generateKey(businessKey, 1L, action, diverged,
MAX_PARALLELISM);
+
+ assertNotEquals(originalKey, divergedKey);
+ }
+
+ @Test
+ public void testDeriveRootEventOccurrenceIdIsReplayStableAndKeySensitive()
{
+ String a =
ActionStateUtil.deriveRootEventOccurrenceId("business-key-identity", 1L);
+ String b =
ActionStateUtil.deriveRootEventOccurrenceId("business-key-identity", 1L);
+ String differentSeqNum =
+
ActionStateUtil.deriveRootEventOccurrenceId("business-key-identity", 2L);
+ String differentKey =
ActionStateUtil.deriveRootEventOccurrenceId("other-identity", 1L);
+
+ // Replay of the same logical input (same business key, same sequence
number) is stable,
+ // independent of the fresh random Event#getId() each replay
constructs.
+ assertEquals(a, b);
+ assertNotEquals(a, differentSeqNum);
+ assertNotEquals(a, differentKey);
+ }
+
+ @Test
+ public void
testDeriveOutputEventOccurrenceIdIsOrdinalAndProducerSensitive() {
+ String producer = "producer-execution-id";
+ String ordinal0 =
ActionStateUtil.deriveOutputEventOccurrenceId(producer, 0);
+ String ordinal0Again =
ActionStateUtil.deriveOutputEventOccurrenceId(producer, 0);
+ String ordinal1 =
ActionStateUtil.deriveOutputEventOccurrenceId(producer, 1);
+ String otherProducer =
ActionStateUtil.deriveOutputEventOccurrenceId("other-producer", 0);
+
+ assertEquals(ordinal0, ordinal0Again);
+ assertNotEquals(ordinal0, ordinal1);
+ assertNotEquals(ordinal0, otherProducer);
+ }
+
+ @Test
+ public void testDeriveOutputEventOccurrenceIdRejectsNegativeOrdinal() {
+ assertThrows(
+ IllegalArgumentException.class,
+ () ->
ActionStateUtil.deriveOutputEventOccurrenceId("producer", -1));
+ }
+
@ParameterizedTest
@ValueSource(booleans = {false, true})
void subgraphStateKeyIgnoresDelegateIdButSeparatesInvocations(boolean
bootstrap)
diff --git
a/runtime/src/test/java/org/apache/flink/agents/runtime/actionstate/InMemoryActionStateStore.java
b/runtime/src/test/java/org/apache/flink/agents/runtime/actionstate/InMemoryActionStateStore.java
index 5a10e0d39..73f3e33b6 100644
---
a/runtime/src/test/java/org/apache/flink/agents/runtime/actionstate/InMemoryActionStateStore.java
+++
b/runtime/src/test/java/org/apache/flink/agents/runtime/actionstate/InMemoryActionStateStore.java
@@ -70,6 +70,15 @@ public class InMemoryActionStateStore implements
ActionStateStore {
.get(keyEncoder.generateKey(key, seqNum, action, event));
}
+ /**
+ * Test-only convenience accessor to this store's own business-key
identity, matching what the
+ * runtime now derives independently via its own {@link
ActionStateKeyEncoder} (not part of the
+ * {@link ActionStateStore} contract).
+ */
+ public String generateBusinessKeyIdentity(Object key) {
+ return keyEncoder.generateBusinessKeyIdentity(key);
+ }
+
@Override
public void rebuildState(List<Object> recoveryMarker) {
// No-op for in-memory store as it does not persist state;
diff --git
a/runtime/src/test/java/org/apache/flink/agents/runtime/actionstate/KafkaActionStateStoreRecoveryTest.java
b/runtime/src/test/java/org/apache/flink/agents/runtime/actionstate/KafkaActionStateStoreRecoveryTest.java
index dd85cc3af..215cd468a 100644
---
a/runtime/src/test/java/org/apache/flink/agents/runtime/actionstate/KafkaActionStateStoreRecoveryTest.java
+++
b/runtime/src/test/java/org/apache/flink/agents/runtime/actionstate/KafkaActionStateStoreRecoveryTest.java
@@ -31,6 +31,7 @@ import org.apache.flink.agents.plan.actions.Action;
import org.apache.flink.agents.runtime.operator.ActionExecutionOperator;
import org.apache.flink.agents.runtime.operator.ActionExecutionOperatorFactory;
import org.apache.flink.api.common.typeinfo.TypeInformation;
+import org.apache.flink.api.common.typeutils.base.LongSerializer;
import org.apache.flink.api.java.functions.KeySelector;
import org.apache.flink.runtime.checkpoint.OperatorSubtaskState;
import org.apache.flink.streaming.runtime.streamrecord.StreamRecord;
@@ -165,6 +166,20 @@ public class KafkaActionStateStoreRecoveryTest {
harness.getOutput().clear();
InputEvent inputEvent = new InputEvent(7L);
+ // The real pipeline assigns a root occurrence identity
(businessKeyIdentity#seqNum)
+ // to every InputEvent before it reaches the durable store; this
fixture fabricates
+ // pending state directly, bypassing that assignment, so it must
assign the same
+ // identity itself or the key this computes below would not match
the key the operator
+ // computes for its own InputEvent(7L) on the same (key=1L,
seqNum=1). The operator
+ // derives this identity from the keyed-state backend's own
serializer (LongSerializer
+ // for this Long-keyed harness), not from the store's encoder
(which uses a generic
+ // Object serializer in this test suite, per
ActionStateTestUtils.KEY_SERIALIZER), so
+ // this must match the former, not
createKeyEncoder(MAX_PARALLELISM).
+ inputEvent.setOccurrenceId(
+ ActionStateUtil.deriveRootEventOccurrenceId(
+ ActionStateUtil.generateBusinessKeyIdentity(
+ 1L, LongSerializer.INSTANCE),
+ 1L));
ActionState pendingState = new ActionState(inputEvent);
pendingState.addCallResult(
new CallResult("multiply",
OBJECT_MAPPER.writeValueAsBytes(callExternal(7L))));
diff --git
a/runtime/src/test/java/org/apache/flink/agents/runtime/operator/ActionExecutionOperatorTest.java
b/runtime/src/test/java/org/apache/flink/agents/runtime/operator/ActionExecutionOperatorTest.java
index 54ad6918c..05ea0de9c 100644
---
a/runtime/src/test/java/org/apache/flink/agents/runtime/operator/ActionExecutionOperatorTest.java
+++
b/runtime/src/test/java/org/apache/flink/agents/runtime/operator/ActionExecutionOperatorTest.java
@@ -86,6 +86,7 @@ import
org.apache.flink.agents.runtime.tracelog.TraceLogWriter;
import org.apache.flink.api.common.serialization.SerializerConfigImpl;
import org.apache.flink.api.common.typeinfo.TypeInformation;
import org.apache.flink.api.common.typeutils.base.LongSerializer;
+import org.apache.flink.api.common.typeutils.base.StringSerializer;
import org.apache.flink.api.java.functions.KeySelector;
import org.apache.flink.runtime.checkpoint.OperatorSubtaskState;
import org.apache.flink.runtime.state.KeyGroupRangeAssignment;
@@ -1677,12 +1678,18 @@ public class ActionExecutionOperatorTest {
(List<StreamRecord<Object>>) testHarness.getRecordOutput();
assertThat(output).singleElement().extracting(StreamRecord::getValue).isEqualTo(7L);
+ InputEvent lookupEvent = new InputEvent(7L);
+ lookupEvent.setOccurrenceId(
+ ActionStateUtil.deriveRootEventOccurrenceId(
+ ActionStateUtil.generateBusinessKeyIdentity(
+ "7", StringSerializer.INSTANCE),
+ 0L));
ActionState actionState =
actionStateStore.get(
"7",
0L,
agentPlan.getActions().get("bestEffortMemoryObservationAction"),
- new InputEvent(7L));
+ lookupEvent);
assertThat(actionState).isNotNull();
assertThat(actionState.getOutputEvents())
.filteredOn(ShortTermWriteEvent.class::isInstance)
@@ -5380,7 +5387,7 @@ public class ActionExecutionOperatorTest {
String actionName,
ActionState actionState)
throws Exception {
- InputEvent event = new InputEvent(input);
+ InputEvent event = rootInputEventWithOccurrenceId(key, input);
Action action = agentPlan.getActions().get(actionName);
actionStateStore.put(key, 0L, action, event, actionState);
}
@@ -5392,11 +5399,32 @@ public class ActionExecutionOperatorTest {
AgentPlan agentPlan,
String actionName)
throws Exception {
- InputEvent event = new InputEvent(input);
+ InputEvent event = rootInputEventWithOccurrenceId(key, input);
Action action = agentPlan.getActions().get(actionName);
return actionStateStore.get(key, 0L, action, event);
}
+ /**
+ * Builds an {@link InputEvent} carrying the same root occurrence identity
the operator itself
+ * assigns on the first (seqNum=0) event for this key, so a fixture's
directly-seeded state keys
+ * to the same durable-store key the operator computes when it processes
its own {@code
+ * InputEvent} for this key. Uses {@code LongSerializer} directly (the
real keyed-state
+ * backend's serializer for these {@code Long}-keyed harnesses, per {@link
+ * #testDefaultStoreUsesKeyedStateBackendSerializer}) rather than the
store's own encoder: the
+ * operator now derives this identity independent of the store, and a
store built standalone
+ * with a generic serializer (as these fixtures' {@link
InMemoryActionStateStore} instances are)
+ * would otherwise compute a different, mismatched identity.
+ */
+ private static InputEvent rootInputEventWithOccurrenceId(long key, long
input)
+ throws Exception {
+ InputEvent event = new InputEvent(input);
+ event.setOccurrenceId(
+ ActionStateUtil.deriveRootEventOccurrenceId(
+ ActionStateUtil.generateBusinessKeyIdentity(key,
LongSerializer.INSTANCE),
+ 0L));
+ return event;
+ }
+
/**
* Records the ownership filter that {@code
DurableExecutionManager.handleRecovery} installs on
* the store during operator recovery, so tests can assert which
key-groups a given subtask