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 59beaa6b [Bug] Recovered Durable ActionState for keys owned by other 
subtasks is never pruned (#1024)
59beaa6b is described below

commit 59beaa6b5f51c15dffe4064e5e2189198e511b74
Author: daken <[email protected]>
AuthorDate: Wed Aug 26 18:46:42 2026 +0800

    [Bug] Recovered Durable ActionState for keys owned by other subtasks is 
never pruned (#1024)
    
    Co-authored-by: daken <[email protected]>
    Co-authored-by: WorkBuddy <[email protected]>
---
 .../actionstate/ActionStateKeyPartitioner.java     |  12 +-
 .../runtime/actionstate/ActionStateStore.java      |  26 +++
 .../runtime/actionstate/ActionStateUtil.java       | 166 ++++++++++++++-
 .../runtime/actionstate/FlussActionStateStore.java |  67 +++---
 .../runtime/actionstate/KafkaActionStateStore.java |  89 ++++----
 .../runtime/operator/ActionExecutionOperator.java  |  24 ++-
 .../runtime/operator/DurableExecutionManager.java  |  40 ++--
 .../actionstate/ActionStateKeyPartitionerTest.java |  41 ++--
 .../runtime/actionstate/ActionStateUtilTest.java   | 172 ++++++++++++---
 ...a => FlussActionStateStoreIntegrationTest.java} |  52 ++++-
 ... FlussActionStateStoreSaslIntegrationTest.java} |   7 +-
 .../actionstate/FlussActionStateStoreTest.java     |  94 +++++++--
 .../actionstate/InMemoryActionStateStore.java      |  12 +-
 .../actionstate/KafkaActionStateStoreTest.java     | 233 ++++++++++++++++++---
 .../operator/ActionExecutionOperatorTest.java      | 173 ++++++++++++++-
 .../operator/DurableExecutionManagerTest.java      |   4 +-
 16 files changed, 1005 insertions(+), 207 deletions(-)

diff --git 
a/runtime/src/main/java/org/apache/flink/agents/runtime/actionstate/ActionStateKeyPartitioner.java
 
b/runtime/src/main/java/org/apache/flink/agents/runtime/actionstate/ActionStateKeyPartitioner.java
index 7fc8b175..d31509f9 100644
--- 
a/runtime/src/main/java/org/apache/flink/agents/runtime/actionstate/ActionStateKeyPartitioner.java
+++ 
b/runtime/src/main/java/org/apache/flink/agents/runtime/actionstate/ActionStateKeyPartitioner.java
@@ -40,16 +40,16 @@ public class ActionStateKeyPartitioner implements 
Partitioner {
         if (!(key instanceof String)) {
             throw new IllegalArgumentException("Key must be a String");
         }
-        String[] keyParts = ((String) key).split("_");
-        if (keyParts.length < 4) {
+
+        String businessKey = ActionStateUtil.businessKeyOf((String) key);
+        if (businessKey == null) {
             throw new IllegalArgumentException("Key format is invalid");
         }
-
-        if ("".equalsIgnoreCase(keyParts[0])) {
-            throw new IllegalArgumentException("First part of the key cannot 
be empty");
+        if (businessKey.isEmpty()) {
+            throw new IllegalArgumentException("Business key part of the key 
cannot be empty");
         }
 
-        return MathUtils.murmurHash(keyParts[0].hashCode()) % numPartitions;
+        return MathUtils.murmurHash(businessKey.hashCode()) % numPartitions;
     }
 
     @Override
diff --git 
a/runtime/src/main/java/org/apache/flink/agents/runtime/actionstate/ActionStateStore.java
 
b/runtime/src/main/java/org/apache/flink/agents/runtime/actionstate/ActionStateStore.java
index e29557c0..496f6f3c 100644
--- 
a/runtime/src/main/java/org/apache/flink/agents/runtime/actionstate/ActionStateStore.java
+++ 
b/runtime/src/main/java/org/apache/flink/agents/runtime/actionstate/ActionStateStore.java
@@ -22,6 +22,7 @@ import org.apache.flink.agents.plan.actions.Action;
 
 import java.io.IOException;
 import java.util.List;
+import java.util.function.IntPredicate;
 
 /** Interface for storing and retrieving the state of actions performed by 
agents. */
 public interface ActionStateStore extends AutoCloseable {
@@ -82,6 +83,31 @@ public interface ActionStateStore extends AutoCloseable {
      */
     void pruneState(Object key, long seqNum);
 
+    /**
+     * Installs a predicate that decides which key-groups are retained in this 
store's in-memory
+     * cache during {@link #rebuildState(List)}.
+     *
+     * <p>Used after recovery so that {@code rebuildState} can skip 
action-state records owned by
+     * other subtasks. UnionListState broadcasts every subtask's recovery 
marker to all subtasks, so
+     * a naive replay loads the full key set into every subtask's cache; those 
foreign keys are then
+     * never pruned and stay resident for the whole attempt (the orphan-state 
leak). Passing a
+     * predicate that accepts only the current subtask's key-groups prevents 
foreign keys from ever
+     * entering the cache.
+     *
+     * <p>The key-group is extracted directly from the action-state record 
key, where it was
+     * persisted from the original typed key via {@code 
KeyGroupRangeAssignment.assignToKeyGroup}.
+     * This avoids the type-dependent hashing mismatch that would occur if 
ownership were
+     * reconstructed from the string form of the business key.
+     *
+     * <p>{@code null} means "retain all keys" — the default, which is safe 
for the in-memory and
+     * test backends where replay loads nothing extra. Implementations that do 
not rebuild from a
+     * shared backend can ignore this.
+     *
+     * @param ownershipFilter predicate over the key-group (the first segment 
of the composite state
+     *     key); {@code null} retains everything.
+     */
+    default void setOwnershipFilter(IntPredicate ownershipFilter) {}
+
     /**
      * Get a marker object representing the current recovery point in the 
state store.
      *
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 24d849ba..0d3e221b 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
@@ -22,18 +22,26 @@ import com.fasterxml.jackson.databind.SerializationFeature;
 import com.fasterxml.jackson.databind.json.JsonMapper;
 import org.apache.flink.agents.api.Event;
 import org.apache.flink.agents.plan.actions.Action;
+import org.apache.flink.runtime.state.KeyGroupRangeAssignment;
 import org.apache.flink.util.Preconditions;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
 
 import java.io.IOException;
 import java.nio.charset.StandardCharsets;
 import java.util.List;
 import java.util.UUID;
+import java.util.function.IntPredicate;
+import java.util.function.LongPredicate;
 
 /** Utility class for action state related operations. */
 public class ActionStateUtil {
 
+    private static final Logger LOG = 
LoggerFactory.getLogger(ActionStateUtil.class);
+
     private static final JsonMapper MAPPER =
             JsonMapper.builder()
                     .configure(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, 
true)
@@ -41,25 +49,171 @@ public class ActionStateUtil {
                     .build();
     private static final String KEY_SEPARATOR = "_";
 
+    // Composite key layout: keyGroup_seqNum_eventUUID_actionUUID_businessKey.
+    //
+    // Every fixed field before the business key (key-group, seq-num, and the 
two UUIDs) is
+    // guaranteed to be free of KEY_SEPARATOR, and the business key — the only 
caller-supplied,
+    // variable-length field — is placed LAST. Parsing therefore splits with a 
fixed limit so the
+    // final segment keeps the business key intact even when it contains the 
separator, e.g.
+    // "tenant_user". No escaping is required and the segment count is always 
exact.
+    private static final int KEY_GROUP_SEGMENT = 0;
+    private static final int SEQ_NUM_SEGMENT = 1;
+    private static final int EVENT_UUID_SEGMENT = 2;
+    private static final int ACTION_UUID_SEGMENT = 3;
+    private static final int BUSINESS_KEY_SEGMENT = 4;
+    static final int KEY_SEGMENT_COUNT = 5;
+
     public static String generateKey(
-            @Nonnull Object key, long seqNum, @Nonnull Action action, @Nonnull 
Event event)
+            @Nonnull Object key,
+            long seqNum,
+            @Nonnull Action action,
+            @Nonnull Event event,
+            int maxParallelism)
             throws IOException {
         Preconditions.checkNotNull(key, "key cannot be null.");
         Preconditions.checkNotNull(action, "action cannot be null.");
         Preconditions.checkNotNull(event, "event cannot be null.");
+        Preconditions.checkArgument(
+                maxParallelism > 0,
+                "maxParallelism must be positive but was %s; the store's 
maxParallelism must be"
+                        + " set to the operator's max parallelism before 
writing action state.",
+                maxParallelism);
+        int keyGroup = KeyGroupRangeAssignment.assignToKeyGroup(key, 
maxParallelism);
         return String.join(
                 KEY_SEPARATOR,
-                key.toString(),
+                String.valueOf(keyGroup),
                 String.valueOf(seqNum),
                 generateUUIDForEvent(event),
-                generateUUIDForAction(action));
+                generateUUIDForAction(action),
+                key.toString());
     }
 
+    /**
+     * Parses a composite state key into its semantic fields, in the order 
{@code [keyGroup, seqNum,
+     * eventUUID, actionUUID, businessKey]}. Throws when {@code key} is not in 
the current format.
+     */
     public static List<String> parseKey(String key) {
         Preconditions.checkNotNull(key, "key cannot be null.");
-        String[] parts = key.split(KEY_SEPARATOR);
-        Preconditions.checkArgument(parts.length == 4, "Invalid key format.");
-        return List.of(parts);
+        String[] parts = splitValidatedKey(key);
+        Preconditions.checkArgument(parts != null, "Invalid key format.");
+        return List.of(
+                parts[KEY_GROUP_SEGMENT],
+                parts[SEQ_NUM_SEGMENT],
+                parts[EVENT_UUID_SEGMENT],
+                parts[ACTION_UUID_SEGMENT],
+                parts[BUSINESS_KEY_SEGMENT]);
+    }
+
+    /**
+     * Extracts the key-group from a composite state key. The key-group was 
computed from the
+     * original typed key via {@link 
KeyGroupRangeAssignment#assignToKeyGroup}. Throws when {@code
+     * key} is not in the current format.
+     */
+    public static int parseKeyGroup(String key) {
+        Preconditions.checkNotNull(key, "key cannot be null.");
+        String[] parts = splitValidatedKey(key);
+        Preconditions.checkArgument(parts != null, "Invalid key format.");
+        return Integer.parseInt(parts[KEY_GROUP_SEGMENT]);
+    }
+
+    /**
+     * Returns {@code true} when {@code stateKey} is in the current format and 
its business-key
+     * segment equals {@code businessKey}. The business key occupies its own 
trailing segment, so
+     * the comparison is exact and cannot collide with another record's 
numeric segments.
+     */
+    public static boolean matchesBusinessKey(String stateKey, Object 
businessKey) {
+        String[] parts = splitValidatedKey(stateKey);
+        return parts != null && 
parts[BUSINESS_KEY_SEGMENT].equals(businessKey.toString());
+    }
+
+    /** Like {@link #matchesBusinessKey} with an additional exact 
sequence-number segment match. */
+    public static boolean matchesBusinessKeyAndSeqNum(
+            String stateKey, Object businessKey, long seqNum) {
+        String[] parts = splitValidatedKey(stateKey);
+        return parts != null
+                && parts[BUSINESS_KEY_SEGMENT].equals(businessKey.toString())
+                && parts[SEQ_NUM_SEGMENT].equals(String.valueOf(seqNum));
+    }
+
+    /**
+     * Like {@link #matchesBusinessKey} with an additional predicate over the 
parsed sequence-number
+     * segment. Returns {@code false} for keys that cannot be attributed (not 
the current format or
+     * an unparsable sequence number): never prune what cannot be attributed.
+     */
+    public static boolean matchesBusinessKeyWithSeqNum(
+            String stateKey, Object businessKey, LongPredicate seqNumFilter) {
+        String[] parts = splitValidatedKey(stateKey);
+        if (parts == null || 
!parts[BUSINESS_KEY_SEGMENT].equals(businessKey.toString())) {
+            return false;
+        }
+        try {
+            return seqNumFilter.test(Long.parseLong(parts[SEQ_NUM_SEGMENT]));
+        } catch (NumberFormatException e) {
+            LOG.warn("Failed to parse sequence number from state key: {}", 
stateKey);
+            return false;
+        }
+    }
+
+    /**
+     * Returns {@code true} if the composite {@code stateKey}'s key-group is 
accepted by the given
+     * ownership filter. A {@code null} filter retains every key (the default 
for in-memory and test
+     * backends).
+     *
+     * <p>A key that does not have the expected segment count — or whose 
key-group segment cannot be
+     * parsed — is dropped rather than retained: it cannot be attributed to a 
key-group, so keeping
+     * it in every subtask would leak orphan state. This is safe because the 
project does not
+     * preserve pre-format durable state.
+     */
+    public static boolean isKeyRetained(@Nullable IntPredicate 
ownershipFilter, String stateKey) {
+        if (ownershipFilter == null) {
+            return true;
+        }
+        String[] parts = splitValidatedKey(stateKey);
+        if (parts == null) {
+            LOG.warn(
+                    "Dropping state key with unrecognized format during 
ownership filtering: {}",
+                    stateKey);
+            return false;
+        }
+        try {
+            return 
ownershipFilter.test(Integer.parseInt(parts[KEY_GROUP_SEGMENT]));
+        } catch (NumberFormatException e) {
+            LOG.warn(
+                    "Dropping state key with unparsable key-group during 
ownership filtering: {}",
+                    stateKey,
+                    e);
+            return false;
+        }
+    }
+
+    /**
+     * Returns the business-key segment of {@code stateKey}, or {@code null} 
when {@code stateKey}
+     * is not in the current format. The returned value preserves separators 
inside the business
+     * key.
+     */
+    @Nullable
+    public static String businessKeyOf(String stateKey) {
+        Preconditions.checkNotNull(stateKey, "stateKey cannot be null.");
+        String[] parts = splitValidatedKey(stateKey);
+        return parts == null ? null : parts[BUSINESS_KEY_SEGMENT];
+    }
+
+    /**
+     * Splits and validates a composite state key. Returns its {@link 
#KEY_SEGMENT_COUNT} segments
+     * when {@code key} has the expected segment count, or {@code null} 
otherwise. The split is
+     * bounded so the trailing business-key segment is returned intact even 
when it contains {@link
+     * #KEY_SEPARATOR}.
+     */
+    @Nullable
+    private static String[] splitValidatedKey(String key) {
+        if (key == null) {
+            return null;
+        }
+        String[] parts = key.split(KEY_SEPARATOR, KEY_SEGMENT_COUNT);
+        if (parts.length != KEY_SEGMENT_COUNT) {
+            return null;
+        }
+        return parts;
     }
 
     private static String generateUUIDForEvent(Event event) throws IOException 
{
diff --git 
a/runtime/src/main/java/org/apache/flink/agents/runtime/actionstate/FlussActionStateStore.java
 
b/runtime/src/main/java/org/apache/flink/agents/runtime/actionstate/FlussActionStateStore.java
index 0a20fe2b..9ee3b7a3 100644
--- 
a/runtime/src/main/java/org/apache/flink/agents/runtime/actionstate/FlussActionStateStore.java
+++ 
b/runtime/src/main/java/org/apache/flink/agents/runtime/actionstate/FlussActionStateStore.java
@@ -51,6 +51,7 @@ import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.function.IntPredicate;
 import java.util.function.LongPredicate;
 
 import static 
org.apache.flink.agents.api.configuration.AgentConfigOptions.FLUSS_ACTION_STATE_DATABASE;
@@ -105,12 +106,20 @@ public class FlussActionStateStore implements 
ActionStateStore {
     /** In-memory cache for O(1) state lookups; rebuilt from Fluss log on 
recovery. */
     private final Map<String, ActionState> actionStates;
 
+    // When set, only records whose key-group is accepted by this predicate 
are kept in the
+    // in-memory cache during rebuildState; null means retain all keys 
(default).
+    private IntPredicate ownershipFilter;
+
+    // The operator's maximum parallelism, used to compute key-groups 
consistently with Flink.
+    private final int maxParallelism;
+
     @VisibleForTesting
     FlussActionStateStore(
             Map<String, ActionState> actionStates,
             Connection connection,
             Table table,
-            AppendWriter writer) {
+            AppendWriter writer,
+            int maxParallelism) {
         this.agentConfiguration = null;
         this.databaseName = null;
         this.tableName = null;
@@ -119,9 +128,16 @@ public class FlussActionStateStore implements 
ActionStateStore {
         this.connection = connection;
         this.table = table;
         this.writer = writer;
+        this.maxParallelism = maxParallelism;
     }
 
-    public FlussActionStateStore(AgentConfiguration agentConfiguration) {
+    public FlussActionStateStore(AgentConfiguration agentConfiguration, int 
maxParallelism) {
+        Preconditions.checkArgument(
+                maxParallelism > 0,
+                "maxParallelism must be positive but was %s; it must be set to 
the operator's max"
+                        + " parallelism so key-groups match Flink's key-group 
assignment.",
+                maxParallelism);
+        this.maxParallelism = maxParallelism;
         this.agentConfiguration = agentConfiguration;
         this.databaseName = 
agentConfiguration.get(FLUSS_ACTION_STATE_DATABASE);
         this.tableName =
@@ -193,7 +209,7 @@ public class FlussActionStateStore implements 
ActionStateStore {
     @Override
     public void put(Object key, long seqNum, Action action, Event event, 
ActionState state)
             throws Exception {
-        String stateKey = generateKey(key, seqNum, action, event);
+        String stateKey = generateKey(key, seqNum, action, event, 
maxParallelism);
         byte[] payload = ActionStateSerde.serialize(state);
 
         GenericRow row =
@@ -215,13 +231,12 @@ public class FlussActionStateStore implements 
ActionStateStore {
 
     @Override
     public ActionState get(Object key, long seqNum, Action action, Event 
event) throws Exception {
-        String stateKey = generateKey(key, seqNum, action, event);
-        String keyPrefix = key.toString() + "_";
+        String stateKey = generateKey(key, seqNum, action, event, 
maxParallelism);
 
-        boolean hasDivergence = checkDivergence(key.toString(), seqNum);
+        boolean hasDivergence = checkDivergence(key, seqNum);
 
         if (!actionStates.containsKey(stateKey) || hasDivergence) {
-            removeStateEntries(keyPrefix, stateSeqNum -> stateSeqNum > seqNum);
+            removeStateEntries(key, stateSeqNum -> stateSeqNum > seqNum);
         }
 
         ActionState state = actionStates.get(stateKey);
@@ -229,36 +244,24 @@ public class FlussActionStateStore implements 
ActionStateStore {
         return state;
     }
 
-    private boolean checkDivergence(String key, long seqNum) {
+    private boolean checkDivergence(Object key, long seqNum) {
         return actionStates.keySet().stream()
-                        .filter(k -> k.startsWith(key + "_" + seqNum + "_"))
+                        .filter(k -> 
ActionStateUtil.matchesBusinessKeyAndSeqNum(k, key, seqNum))
                         .count()
                 > 1;
     }
 
     /**
-     * Removes cached state entries whose key starts with {@code keyPrefix} 
and whose parsed
+     * Removes cached state entries whose business-key segment equals {@code 
key} and whose parsed
      * sequence number satisfies {@code seqNumFilter}.
      */
-    private void removeStateEntries(String keyPrefix, LongPredicate 
seqNumFilter) {
+    private void removeStateEntries(Object key, LongPredicate seqNumFilter) {
         actionStates
-                .entrySet()
+                .keySet()
                 .removeIf(
-                        entry -> {
-                            if (!entry.getKey().startsWith(keyPrefix)) {
-                                return false;
-                            }
-                            try {
-                                List<String> parts = 
ActionStateUtil.parseKey(entry.getKey());
-                                if (parts.size() >= 2) {
-                                    long stateSeqNum = 
Long.parseLong(parts.get(1));
-                                    return seqNumFilter.test(stateSeqNum);
-                                }
-                            } catch (Exception e) {
-                                LOG.warn("Failed to parse state key: {}", 
entry.getKey(), e);
-                            }
-                            return false;
-                        });
+                        cachedKey ->
+                                ActionStateUtil.matchesBusinessKeyWithSeqNum(
+                                        cachedKey, key, seqNumFilter));
     }
 
     /**
@@ -441,6 +444,9 @@ public class FlussActionStateStore implements 
ActionStateStore {
             }
             InternalRow row = record.getRow();
             String stateKey = row.getString(COL_STATE_KEY).toString();
+            if (!ActionStateUtil.isKeyRetained(ownershipFilter, stateKey)) {
+                continue;
+            }
             byte[] payload = row.getBytes(COL_STATE_PAYLOAD);
             ActionState state = ActionStateSerde.deserialize(payload);
             actionStates.put(stateKey, state);
@@ -448,6 +454,11 @@ public class FlussActionStateStore implements 
ActionStateStore {
         return lastSeenOffset;
     }
 
+    @Override
+    public void setOwnershipFilter(IntPredicate ownershipFilter) {
+        this.ownershipFilter = ownershipFilter;
+    }
+
     private Map<Integer, Long> getBucketEndOffsets() {
         return getBucketOffsets(new OffsetSpec.LatestSpec());
     }
@@ -486,7 +497,7 @@ public class FlussActionStateStore implements 
ActionStateStore {
     @Override
     public void pruneState(Object key, long seqNum) {
         LOG.debug("Pruning in-memory state for key: {} up to seqNum: {}", key, 
seqNum);
-        removeStateEntries(key.toString() + "_", stateSeqNum -> stateSeqNum <= 
seqNum);
+        removeStateEntries(key, stateSeqNum -> stateSeqNum <= seqNum);
     }
 
     @Override
diff --git 
a/runtime/src/main/java/org/apache/flink/agents/runtime/actionstate/KafkaActionStateStore.java
 
b/runtime/src/main/java/org/apache/flink/agents/runtime/actionstate/KafkaActionStateStore.java
index 99519acb..b17db187 100644
--- 
a/runtime/src/main/java/org/apache/flink/agents/runtime/actionstate/KafkaActionStateStore.java
+++ 
b/runtime/src/main/java/org/apache/flink/agents/runtime/actionstate/KafkaActionStateStore.java
@@ -17,12 +17,12 @@
  */
 package org.apache.flink.agents.runtime.actionstate;
 
-import org.apache.beam.sdk.util.Preconditions;
 import org.apache.flink.agents.api.Event;
 import org.apache.flink.agents.plan.AgentConfiguration;
 import org.apache.flink.agents.plan.actions.Action;
 import org.apache.flink.annotation.VisibleForTesting;
 import org.apache.flink.util.ExceptionUtils;
+import org.apache.flink.util.Preconditions;
 import org.apache.kafka.clients.admin.AdminClient;
 import org.apache.kafka.clients.admin.ListTopicsResult;
 import org.apache.kafka.clients.admin.NewTopic;
@@ -50,6 +50,7 @@ import java.util.Map;
 import java.util.Properties;
 import java.util.UUID;
 import java.util.concurrent.TimeUnit;
+import java.util.function.IntPredicate;
 
 import static 
org.apache.flink.agents.api.configuration.AgentConfigOptions.KAFKA_ACTION_STATE_TOPIC;
 import static 
org.apache.flink.agents.api.configuration.AgentConfigOptions.KAFKA_ACTION_STATE_TOPIC_NUM_PARTITIONS;
@@ -91,28 +92,43 @@ public class KafkaActionStateStore implements 
ActionStateStore {
     // Kafka topic that stores action states
     private final String topic;
 
+    // When set, only records whose key-group is accepted by this predicate 
are kept in the
+    // in-memory cache during rebuildState; null means retain all keys 
(default).
+    private IntPredicate ownershipFilter;
+
+    // The operator's maximum parallelism, used to compute key-groups 
consistently with Flink.
+    private final int maxParallelism;
+
     @VisibleForTesting
     KafkaActionStateStore(
             Map<String, ActionState> actionStates,
             AgentConfiguration agentConfiguration,
             Producer<String, ActionState> producer,
             Consumer<String, ActionState> consumer,
-            String topic) {
+            String topic,
+            int maxParallelism) {
         this.actionStates = actionStates;
         this.producer = producer;
         this.consumer = consumer;
         this.topic = topic;
         this.latestKeySeqNum = new HashMap<>();
         this.agentConfiguration = agentConfiguration;
+        this.maxParallelism = maxParallelism;
     }
 
     /** Constructs a new KafkaActionStateStore with custom Kafka 
configuration. */
-    public KafkaActionStateStore(AgentConfiguration agentConfiguration) {
+    public KafkaActionStateStore(AgentConfiguration agentConfiguration, int 
maxParallelism) {
+        Preconditions.checkArgument(
+                maxParallelism > 0,
+                "maxParallelism must be positive but was %s; it must be set to 
the operator's max"
+                        + " parallelism so key-groups match Flink's key-group 
assignment.",
+                maxParallelism);
+        this.maxParallelism = maxParallelism;
         this.actionStates = new HashMap<>();
         this.latestKeySeqNum = new HashMap<>();
         this.agentConfiguration = agentConfiguration;
         this.topic =
-                Preconditions.checkArgumentNotNull(
+                Preconditions.checkNotNull(
                         agentConfiguration.get(KAFKA_ACTION_STATE_TOPIC),
                         "Kafka action state topic must be configured");
         // create the topic if not exists
@@ -132,7 +148,7 @@ public class KafkaActionStateStore implements 
ActionStateStore {
             return;
         }
 
-        String stateKey = generateKey(key, seqNum, action, event);
+        String stateKey = generateKey(key, seqNum, action, event, 
maxParallelism);
         try {
             ProducerRecord<String, ActionState> kafkaRecord =
                     new ProducerRecord<>(topic, stateKey, state);
@@ -150,7 +166,7 @@ public class KafkaActionStateStore implements 
ActionStateStore {
 
     @Override
     public ActionState get(Object key, long seqNum, Action action, Event 
event) throws Exception {
-        String stateKey = generateKey(key, seqNum, action, event);
+        String stateKey = generateKey(key, seqNum, action, event, 
maxParallelism);
 
         LOG.debug(
                 "Looking up action state: key={}, seqNum={}, stateKey={}, 
cachedStates={}",
@@ -159,29 +175,16 @@ public class KafkaActionStateStore implements 
ActionStateStore {
                 stateKey,
                 actionStates.keySet());
 
-        boolean hasDivergence = checkDivergence(key.toString(), seqNum);
+        boolean hasDivergence = checkDivergence(key, seqNum);
 
         if (!actionStates.containsKey(stateKey) || hasDivergence) {
+            // Clean up this key's states with sequence number greater than 
the requested seqNum.
             actionStates
-                    .entrySet()
+                    .keySet()
                     .removeIf(
-                            entry -> {
-                                // Extract key and sequence number from the 
state key
-                                try {
-                                    List<String> parts = 
ActionStateUtil.parseKey(entry.getKey());
-                                    if (parts.size() >= 2) {
-                                        long stateSeqNum = 
Long.parseLong(parts.get(1));
-                                        // clean up any states with sequence 
number greater than
-                                        // the requested seqNum
-                                        return stateSeqNum > seqNum;
-                                    }
-                                } catch (NumberFormatException e) {
-                                    LOG.warn(
-                                            "Failed to parse sequence number 
from state key: {}",
-                                            stateKey);
-                                }
-                                return false;
-                            });
+                            cachedKey ->
+                                    
ActionStateUtil.matchesBusinessKeyWithSeqNum(
+                                            cachedKey, key, stateSeqNum -> 
stateSeqNum > seqNum));
         }
 
         ActionState result = actionStates.get(stateKey);
@@ -194,9 +197,9 @@ public class KafkaActionStateStore implements 
ActionStateStore {
         return result;
     }
 
-    private boolean checkDivergence(String key, long seqNum) {
+    private boolean checkDivergence(Object key, long seqNum) {
         return actionStates.keySet().stream()
-                        .filter(k -> k.startsWith(key + "_" + seqNum + "_"))
+                        .filter(k -> 
ActionStateUtil.matchesBusinessKeyAndSeqNum(k, key, seqNum))
                         .count()
                 > 1;
     }
@@ -255,6 +258,9 @@ public class KafkaActionStateStore implements 
ActionStateStore {
 
                 for (ConsumerRecord<String, ActionState> record : records) {
                     try {
+                        if (!ActionStateUtil.isKeyRetained(ownershipFilter, 
record.key())) {
+                            continue;
+                        }
                         actionStates.put(record.key(), record.value());
                     } catch (Exception e) {
                         LOG.warn(
@@ -273,6 +279,11 @@ public class KafkaActionStateStore implements 
ActionStateStore {
         }
     }
 
+    @Override
+    public void setOwnershipFilter(IntPredicate ownershipFilter) {
+        this.ownershipFilter = ownershipFilter;
+    }
+
     @Override
     public void pruneState(Object key, long seqNum) {
         LOG.debug("Pruning state for key: {} up to sequence number: {}", key, 
seqNum);
@@ -280,27 +291,11 @@ public class KafkaActionStateStore implements 
ActionStateStore {
         // Remove states from in-memory cache for this key up to the specified 
sequence
         // number
         actionStates
-                .entrySet()
+                .keySet()
                 .removeIf(
-                        entry -> {
-                            String stateKey = entry.getKey();
-                            // Extract key and sequence number from the state 
key
-                            // State key format: "key_seqNum_action_event"
-                            if (stateKey.startsWith(key.toString() + "_")) {
-                                try {
-                                    List<String> parts = 
ActionStateUtil.parseKey(stateKey);
-                                    if (parts.size() >= 2) {
-                                        long stateSeqNum = 
Long.parseLong(parts.get(1));
-                                        return stateSeqNum <= seqNum;
-                                    }
-                                } catch (NumberFormatException e) {
-                                    LOG.warn(
-                                            "Failed to parse sequence number 
from state key: {}",
-                                            stateKey);
-                                }
-                            }
-                            return false;
-                        });
+                        cachedKey ->
+                                ActionStateUtil.matchesBusinessKeyWithSeqNum(
+                                        cachedKey, key, stateSeqNum -> 
stateSeqNum <= seqNum));
 
         LOG.debug("Pruned state for key: {} up to sequence number: {}", key, 
seqNum);
     }
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 eac7c025..18931ee9 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
@@ -73,6 +73,7 @@ import java.util.List;
 import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
+import java.util.function.IntPredicate;
 
 import static 
org.apache.flink.agents.api.configuration.AgentConfigOptions.JOB_IDENTIFIER;
 import static org.apache.flink.util.Preconditions.checkState;
@@ -193,7 +194,8 @@ public class ActionExecutionOperator<IN, OUT> extends 
AbstractStreamOperator<OUT
 
         eventRouter.open(builtInMetrics);
 
-        durableExecManager.maybeInitActionStateStore(agentPlan.getConfig());
+        int maxParallelism = 
getRuntimeContext().getTaskInfo().getMaxNumberOfParallelSubtasks();
+        durableExecManager.maybeInitActionStateStore(agentPlan.getConfig(), 
maxParallelism);
         durableExecManager.initRecoveryMarkerState(getOperatorStateBackend());
         durableExecManager.initializeKeyedStates(getRuntimeContext());
 
@@ -613,11 +615,27 @@ public class ActionExecutionOperator<IN, OUT> extends 
AbstractStreamOperator<OUT
     public void initializeState(StateInitializationContext context) throws 
Exception {
         super.initializeState(context);
 
-        durableExecManager.maybeInitActionStateStore(agentPlan.getConfig());
-        durableExecManager.handleRecovery(getOperatorStateBackend());
+        int maxParallelism = 
getRuntimeContext().getTaskInfo().getMaxNumberOfParallelSubtasks();
+        durableExecManager.maybeInitActionStateStore(agentPlan.getConfig(), 
maxParallelism);
 
         stateManager = new OperatorStateManager();
 
+        // Drop action-state records owned by other subtasks during rebuild. 
UnionListState
+        // broadcasts every subtask's recovery marker, so a naive replay would 
load all keys into
+        // every subtask's cache, where the foreign ones are never pruned 
(orphan-state leak).
+        //
+        // The ownership filter operates on the key-group embedded in the 
action-state record key.
+        // The key-group was computed from the original typed key via
+        // KeyGroupRangeAssignment.assignToKeyGroup, which matches how Flink 
assigns keyed-state
+        // ownership. This avoids the type-dependent hashing mismatch that 
would occur if ownership
+        // were reconstructed from the string form of the business key (e.g., 
Long(1) hashes to
+        // key-group 86 while String("1") hashes to 54).
+        KeyGroupRange currentSubtaskKeyGroupRange =
+                stateManager.getCurrentSubtaskKeyGroupRange(maxParallelism, 
getRuntimeContext());
+        IntPredicate ownershipFilter = currentSubtaskKeyGroupRange::contains;
+
+        durableExecManager.handleRecovery(getOperatorStateBackend(), 
ownershipFilter);
+
         // Resolve the agent's stable job identifier:
         //  - If the user set it via AgentConfigOptions.JOB_IDENTIFIER, use 
that.
         //  - Otherwise fall back to the current Flink JobID, cached in 
operator
diff --git 
a/runtime/src/main/java/org/apache/flink/agents/runtime/operator/DurableExecutionManager.java
 
b/runtime/src/main/java/org/apache/flink/agents/runtime/operator/DurableExecutionManager.java
index 3b24b3ab..f9b1df84 100644
--- 
a/runtime/src/main/java/org/apache/flink/agents/runtime/operator/DurableExecutionManager.java
+++ 
b/runtime/src/main/java/org/apache/flink/agents/runtime/operator/DurableExecutionManager.java
@@ -47,6 +47,7 @@ import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.function.IntPredicate;
 
 import static 
org.apache.flink.agents.api.configuration.AgentConfigOptions.ACTION_STATE_STORE_BACKEND;
 import static 
org.apache.flink.agents.runtime.actionstate.ActionStateStore.BackendType.FLUSS;
@@ -63,14 +64,14 @@ import static 
org.apache.flink.agents.runtime.actionstate.ActionStateStore.Backe
  * durable execution is enabled.
  *
  * <p>Lifecycle: instantiated in the operator constructor. {@link
- * #maybeInitActionStateStore(AgentConfiguration)} runs from BOTH the 
operator's {@code
+ * #maybeInitActionStateStore(AgentConfiguration, int)} runs from BOTH the 
operator's {@code
  * initializeState()} and {@code open()} — recovery requires the store to be 
configured before
- * {@link #handleRecovery(OperatorStateBackend)} reads from it, and the {@code 
open()} call ensures
- * the store is also available on the normal (non-recovery) path. The method 
creates a default
- * Kafka-backed store when one was not pre-injected, and is idempotent on the 
second call. {@link
- * #handleRecovery(OperatorStateBackend)} runs from the operator's {@code 
initializeState()} during
- * recovery. {@link #initRecoveryMarkerState(OperatorStateBackend)} runs from 
the operator's {@code
- * open()}. {@link #close()} closes the underlying store.
+ * {@link #handleRecovery(OperatorStateBackend, IntPredicate)} reads from it, 
and the {@code open()}
+ * call ensures the store is also available on the normal (non-recovery) path. 
The method creates a
+ * default Kafka-backed store when one was not pre-injected, and is idempotent 
on the second call.
+ * {@link #handleRecovery(OperatorStateBackend, IntPredicate)} runs from the 
operator's {@code
+ * initializeState()} during recovery. {@link 
#initRecoveryMarkerState(OperatorStateBackend)} runs
+ * from the operator's {@code open()}. {@link #close()} closes the underlying 
store.
  *
  * <p>Design constraint: package-private; no manager-to-manager held 
references. Cross-cutting data
  * flows via method parameters. In particular, {@link
@@ -95,8 +96,8 @@ class DurableExecutionManager implements 
ActionStatePersister, AutoCloseable {
 
     /**
      * @param actionStateStore an optional pre-injected store, primarily for 
tests. When {@code
-     *     null}, {@link #maybeInitActionStateStore(AgentConfiguration)} may 
create a default store
-     *     based on configuration; otherwise durable execution is disabled.
+     *     null}, {@link #maybeInitActionStateStore(AgentConfiguration, int)} 
may create a default
+     *     store based on configuration; otherwise durable execution is 
disabled.
      */
     DurableExecutionManager(@Nullable ActionStateStore actionStateStore) {
         this.actionStateStore = actionStateStore;
@@ -114,15 +115,15 @@ class DurableExecutionManager implements 
ActionStatePersister, AutoCloseable {
      *
      * @param config the agent configuration carrying the backend selection.
      */
-    void maybeInitActionStateStore(AgentConfiguration config) {
+    void maybeInitActionStateStore(AgentConfiguration config, int 
maxParallelism) {
         if (actionStateStore == null) {
             String backend = config.get(ACTION_STATE_STORE_BACKEND);
             if (KAFKA.getType().equalsIgnoreCase(backend)) {
                 LOG.info("Using Kafka as backend of action state store.");
-                actionStateStore = new KafkaActionStateStore(config);
+                actionStateStore = new KafkaActionStateStore(config, 
maxParallelism);
             } else if (FLUSS.getType().equalsIgnoreCase(backend)) {
                 LOG.info("Using Fluss as backend of action state store.");
-                actionStateStore = new FlussActionStateStore(config);
+                actionStateStore = new FlussActionStateStore(config, 
maxParallelism);
             }
         }
     }
@@ -185,10 +186,20 @@ class DurableExecutionManager implements 
ActionStatePersister, AutoCloseable {
      * descriptor is re-created here using the same descriptor name — Flink 
returns the same
      * underlying state. No-op when durable execution is disabled.
      *
+     * <p>UnionListState broadcasts every subtask's recovery marker to all 
subtasks, so a naive
+     * replay would load the full key set into every subtask's cache, where 
the foreign keys are
+     * never pruned and stay resident for the whole attempt (the orphan-state 
leak). {@code
+     * ownershipFilter} restricts the rebuilt cache to key-groups owned by the 
current subtask; it
+     * is installed on the store just before {@link #rebuildState(List)}.
+     *
      * @param operatorStateBackend the operator state backend used to obtain 
the recovery-marker
      *     union-list state.
+     * @param ownershipFilter predicate accepting only the key-groups owned by 
the current subtask;
+     *     {@code null} retains all keys (e.g. for the in-memory/test 
backends).
      */
-    void handleRecovery(OperatorStateBackend operatorStateBackend) throws 
Exception {
+    void handleRecovery(
+            OperatorStateBackend operatorStateBackend, @Nullable IntPredicate 
ownershipFilter)
+            throws Exception {
         if (actionStateStore != null) {
             List<Object> markers = new ArrayList<>();
             ListState<Object> markerState =
@@ -200,6 +211,7 @@ class DurableExecutionManager implements 
ActionStatePersister, AutoCloseable {
                 recoveryMarkers.forEach(markers::add);
             }
             LOG.info("Rebuilding action state from {} recovery markers", 
markers.size());
+            actionStateStore.setOwnershipFilter(ownershipFilter);
             actionStateStore.rebuildState(markers);
         }
     }
@@ -209,7 +221,7 @@ class DurableExecutionManager implements 
ActionStatePersister, AutoCloseable {
             throws Exception {
         return actionStateStore == null
                 ? null
-                : actionStateStore.get(key.toString(), sequenceNum, action, 
event);
+                : actionStateStore.get(key, sequenceNum, action, event);
     }
 
     void maybeInitActionState(Object key, long sequenceNum, Action action, 
Event event)
diff --git 
a/runtime/src/test/java/org/apache/flink/agents/runtime/actionstate/ActionStateKeyPartitionerTest.java
 
b/runtime/src/test/java/org/apache/flink/agents/runtime/actionstate/ActionStateKeyPartitionerTest.java
index 3f45bcf7..8c93eb42 100644
--- 
a/runtime/src/test/java/org/apache/flink/agents/runtime/actionstate/ActionStateKeyPartitionerTest.java
+++ 
b/runtime/src/test/java/org/apache/flink/agents/runtime/actionstate/ActionStateKeyPartitionerTest.java
@@ -63,9 +63,9 @@ public class ActionStateKeyPartitionerTest {
 
     @Test
     void testValidKeyPartitioning() {
-        String key1 = "1_1_action1_event1";
-        String key2 = "456_1_action2_event2";
-        String key3 = "789_1_action3_event3";
+        String key1 = "0_1_event1_action1_bk1";
+        String key2 = "5_1_event2_action2_bk2";
+        String key3 = "9_1_event3_action3_bk3";
 
         int partition1 =
                 partitioner.partition(TEST_TOPIC, key1, key1.getBytes(), null, 
null, cluster);
@@ -81,11 +81,11 @@ public class ActionStateKeyPartitionerTest {
     }
 
     @Test
-    void testSameKeyFirstPartConsistentPartitioning() {
-        // Keys with the same first part should go to the same partition
-        String key1 = "123_1_action1_event1";
-        String key2 = "123_2_action2_event2";
-        String key3 = "123_3_action3_event3";
+    void testSameBusinessKeyConsistentPartitioning() {
+        // Keys sharing the same business key (trailing segment) go to the 
same partition
+        String key1 = "5_1_event1_action1_123";
+        String key2 = "5_2_event2_action2_123";
+        String key3 = "5_3_event3_action3_123";
 
         int partition1 =
                 partitioner.partition(TEST_TOPIC, key1, key1.getBytes(), null, 
null, cluster);
@@ -94,7 +94,7 @@ public class ActionStateKeyPartitionerTest {
         int partition3 =
                 partitioner.partition(TEST_TOPIC, key3, key3.getBytes(), null, 
null, cluster);
 
-        // All should go to the same partition since first part is the same
+        // All should go to the same partition since the business key is the 
same
         assertEquals(partition1, partition2);
         assertEquals(partition1, partition3);
     }
@@ -123,7 +123,7 @@ public class ActionStateKeyPartitionerTest {
 
     @Test
     void testInvalidKeyFormatThrowsException() {
-        // Test keys with less than 3 parts
+        // Keys that lack the expected segment count are rejected.
         String invalidKey1 = "onlyonepart";
         String invalidKey2 = "only_twoparts";
 
@@ -155,21 +155,22 @@ public class ActionStateKeyPartitionerTest {
     }
 
     @Test
-    void testEmptyFirstKeyPartThrowException() {
-        String invalidKey = "_1_action_event";
+    void testEmptyBusinessKeyPartThrowException() {
+        String invalidKey = "5_1_event_action_";
         IllegalArgumentException exception =
                 assertThrows(
                         IllegalArgumentException.class,
                         () ->
                                 partitioner.partition(
                                         TEST_TOPIC, invalidKey, null, null, 
null, cluster));
-        assertEquals("First part of the key cannot be empty", 
exception.getMessage());
+        assertEquals("Business key part of the key cannot be empty", 
exception.getMessage());
     }
 
     @Test
-    void testKeyWithMoreThanThreePartsIsValid() {
-        // Keys with more than 3 parts should still work (only first part 
matters for partitioning)
-        String key = "123_action1_event1_extra_parts_here";
+    void testBusinessKeyContainingSeparatorIsValid() {
+        // The business key occupies the trailing segment, so it may contain 
the separator
+        // (e.g. "tenant_user") without breaking partitioning.
+        String key = "0_1_event_action_tenant_user";
 
         int partition = partitioner.partition(TEST_TOPIC, key, key.getBytes(), 
null, null, cluster);
 
@@ -181,9 +182,9 @@ public class ActionStateKeyPartitionerTest {
         // Test that different first key parts go to potentially different 
partitions
         Map<Integer, Integer> partitionCounts = new HashMap<>();
 
-        // Generate keys with different first parts
+        // Generate keys with different business keys (trailing segment)
         for (int i = 0; i < 100; i++) {
-            String key = "" + i + "_1_action_event";
+            String key = "0_1_event_action_" + i;
             int partition =
                     partitioner.partition(TEST_TOPIC, key, key.getBytes(), 
null, null, cluster);
 
@@ -219,7 +220,7 @@ public class ActionStateKeyPartitionerTest {
                         java.util.Collections.emptySet(),
                         java.util.Collections.emptySet());
 
-        String key = "123_1_action1_event1";
+        String key = "5_1_event1_action1_123";
         int partition =
                 partitioner.partition(
                         TEST_TOPIC, key, key.getBytes(), null, null, 
singlePartitionCluster);
@@ -230,7 +231,7 @@ public class ActionStateKeyPartitionerTest {
     @Test
     void testHashConsistency() {
         // Same key should always produce the same partition
-        String key = "123_1_action1_event1";
+        String key = "5_1_event1_action1_123";
 
         int partition1 =
                 partitioner.partition(TEST_TOPIC, key, key.getBytes(), null, 
null, cluster);
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 2a90c1f1..eb2ba717 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
@@ -24,6 +24,7 @@ import org.junit.jupiter.api.Test;
 import java.util.List;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertNotEquals;
 import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -31,6 +32,8 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
 /** Test class for {@link ActionStateUtil}. */
 public class ActionStateUtilTest {
 
+    private static final int MAX_PARALLELISM = 128;
+
     @Test
     public void testGenerateKeyConsistency() throws Exception {
         // Create test data
@@ -40,8 +43,8 @@ public class ActionStateUtilTest {
         InputEvent inputEvent2 = new InputEvent("same-input");
 
         // Generate keys multiple times
-        String key1 = ActionStateUtil.generateKey(key, 1, action, inputEvent);
-        String key2 = ActionStateUtil.generateKey(key, 1, action, inputEvent2);
+        String key1 = ActionStateUtil.generateKey(key, 1, action, inputEvent, 
MAX_PARALLELISM);
+        String key2 = ActionStateUtil.generateKey(key, 1, action, inputEvent2, 
MAX_PARALLELISM);
 
         // Keys should be the same for the same input
         assertEquals(key1, key2);
@@ -56,8 +59,8 @@ public class ActionStateUtilTest {
         InputEvent inputEvent2 = new InputEvent("input2");
 
         // Generate keys
-        String key1 = ActionStateUtil.generateKey(key, 1, action, inputEvent1);
-        String key2 = ActionStateUtil.generateKey(key, 1, action, inputEvent2);
+        String key1 = ActionStateUtil.generateKey(key, 1, action, inputEvent1, 
MAX_PARALLELISM);
+        String key2 = ActionStateUtil.generateKey(key, 1, action, inputEvent2, 
MAX_PARALLELISM);
 
         // Keys should be different for different inputs
         assertNotEquals(key1, key2);
@@ -71,7 +74,7 @@ public class ActionStateUtilTest {
         assertThrows(
                 NullPointerException.class,
                 () -> {
-                    ActionStateUtil.generateKey(null, 1, action, inputEvent);
+                    ActionStateUtil.generateKey(null, 1, action, inputEvent, 
MAX_PARALLELISM);
                 });
     }
 
@@ -83,7 +86,7 @@ public class ActionStateUtilTest {
         assertThrows(
                 NullPointerException.class,
                 () -> {
-                    ActionStateUtil.generateKey(key, 1, null, inputEvent);
+                    ActionStateUtil.generateKey(key, 1, null, inputEvent, 
MAX_PARALLELISM);
                 });
     }
 
@@ -95,10 +98,24 @@ public class ActionStateUtilTest {
         assertThrows(
                 NullPointerException.class,
                 () -> {
-                    ActionStateUtil.generateKey(key, 1, action, null);
+                    ActionStateUtil.generateKey(key, 1, action, null, 
MAX_PARALLELISM);
                 });
     }
 
+    @Test
+    public void testGenerateKeyRejectsNonPositiveMaxParallelism() throws 
Exception {
+        Object key = "test-key";
+        Action action = new NoOpAction("test-action");
+        InputEvent inputEvent = new InputEvent("test-input");
+
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> ActionStateUtil.generateKey(key, 1, action, inputEvent, 
0));
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> ActionStateUtil.generateKey(key, 1, action, inputEvent, 
-1));
+    }
+
     @Test
     public void testParseKeyValidKey() throws Exception {
         // Create test data and generate a key
@@ -107,18 +124,20 @@ public class ActionStateUtilTest {
         InputEvent inputEvent = new InputEvent("test-input");
         long seqNum = 123;
 
-        String generatedKey = ActionStateUtil.generateKey(key, seqNum, action, 
inputEvent);
+        String generatedKey =
+                ActionStateUtil.generateKey(key, seqNum, action, inputEvent, 
MAX_PARALLELISM);
 
         // Parse the generated key
         List<String> parsedParts = ActionStateUtil.parseKey(generatedKey);
 
-        // Verify the parsed components
-        assertEquals(4, parsedParts.size());
-        assertEquals(key.toString(), parsedParts.get(0));
+        // Verify the parsed components: [keyGroup, seqNum, eventUUID, 
actionUUID, businessKey]
+        assertEquals(5, parsedParts.size());
+        assertTrue(Integer.parseInt(parsedParts.get(0)) >= 0); // keyGroup
         assertEquals(String.valueOf(seqNum), parsedParts.get(1));
-        // The third and fourth parts are UUIDs - just verify they're non-empty
+        // The event and action UUID segments are non-empty.
         assertTrue(parsedParts.get(2).length() > 0);
         assertTrue(parsedParts.get(3).length() > 0);
+        assertEquals(key.toString(), parsedParts.get(4));
     }
 
     @Test
@@ -129,10 +148,12 @@ public class ActionStateUtilTest {
         InputEvent inputEvent = new InputEvent("round-trip-input");
         long seqNum = 456;
 
-        String generatedKey = ActionStateUtil.generateKey(originalKey, seqNum, 
action, inputEvent);
+        String generatedKey =
+                ActionStateUtil.generateKey(
+                        originalKey, seqNum, action, inputEvent, 
MAX_PARALLELISM);
         List<String> parsedParts = ActionStateUtil.parseKey(generatedKey);
 
-        assertEquals(originalKey.toString(), parsedParts.get(0));
+        assertEquals(originalKey.toString(), parsedParts.get(4));
         assertEquals(String.valueOf(seqNum), parsedParts.get(1));
     }
 
@@ -147,21 +168,21 @@ public class ActionStateUtilTest {
 
     @Test
     public void testParseKeyWithInvalidFormat() {
-        // Test with too few parts
+        // Too few segments.
         assertThrows(
                 IllegalArgumentException.class,
                 () -> {
                     ActionStateUtil.parseKey("only_three_parts");
                 });
 
-        // Test with too many parts
+        // Still one segment short of the required count.
         assertThrows(
                 IllegalArgumentException.class,
                 () -> {
-                    ActionStateUtil.parseKey("one_two_three_four_five_six");
+                    ActionStateUtil.parseKey("one_two_three_four");
                 });
 
-        // Test with empty string
+        // Empty string.
         assertThrows(
                 IllegalArgumentException.class,
                 () -> {
@@ -177,10 +198,11 @@ public class ActionStateUtilTest {
         InputEvent inputEvent = new InputEvent("input-with-special@chars");
         long seqNum = 789;
 
-        String generatedKey = ActionStateUtil.generateKey(key, seqNum, action, 
inputEvent);
+        String generatedKey =
+                ActionStateUtil.generateKey(key, seqNum, action, inputEvent, 
MAX_PARALLELISM);
         List<String> parsedParts = ActionStateUtil.parseKey(generatedKey);
 
-        assertEquals(key.toString(), parsedParts.get(0));
+        assertEquals(key.toString(), parsedParts.get(4));
         assertEquals(String.valueOf(seqNum), parsedParts.get(1));
     }
 
@@ -190,18 +212,118 @@ public class ActionStateUtilTest {
         Action action = new NoOpAction("consistency-action");
         InputEvent inputEvent = new InputEvent("consistency-input");
 
-        String key1 = ActionStateUtil.generateKey("key1", 100, action, 
inputEvent);
-        String key2 = ActionStateUtil.generateKey("key2", 200, action, 
inputEvent);
+        String key1 = ActionStateUtil.generateKey("key1", 100, action, 
inputEvent, MAX_PARALLELISM);
+        String key2 = ActionStateUtil.generateKey("key2", 200, action, 
inputEvent, MAX_PARALLELISM);
 
         List<String> parsed1 = ActionStateUtil.parseKey(key1);
         List<String> parsed2 = ActionStateUtil.parseKey(key2);
 
-        // Keys should be different
-        assertNotEquals(parsed1.get(0), parsed2.get(0));
-        assertNotEquals(parsed1.get(1), parsed2.get(1));
+        // Business keys and sequence numbers differ.
+        assertNotEquals(parsed1.get(4), parsed2.get(4)); // businessKey
+        assertNotEquals(parsed1.get(1), parsed2.get(1)); // seqNum
 
         // But event and action UUIDs should be the same (same event and 
action)
         assertEquals(parsed1.get(2), parsed2.get(2)); // Event UUID
         assertEquals(parsed1.get(3), parsed2.get(3)); // Action UUID
     }
+
+    @Test
+    public void testIsKeyRetainedFiltersForeignKeys() throws Exception {
+        Action action = new NoOpAction("owner-action");
+        InputEvent event = new InputEvent("owner-input");
+        String ownedKey = ActionStateUtil.generateKey("A", 1, action, event, 
MAX_PARALLELISM);
+        String foreignKey = ActionStateUtil.generateKey("B", 1, action, event, 
MAX_PARALLELISM);
+
+        int ownedKeyGroup = ActionStateUtil.parseKeyGroup(ownedKey);
+        assertTrue(ActionStateUtil.isKeyRetained(kg -> kg == ownedKeyGroup, 
ownedKey));
+        assertFalse(ActionStateUtil.isKeyRetained(kg -> kg == ownedKeyGroup, 
foreignKey));
+    }
+
+    @Test
+    public void testIsKeyRetainedKeepsAllKeysWhenNoFilter() throws Exception {
+        Action action = new NoOpAction("no-filter-action");
+        InputEvent event = new InputEvent("no-filter-input");
+        String keyA = ActionStateUtil.generateKey("A", 1, action, event, 
MAX_PARALLELISM);
+        String keyB = ActionStateUtil.generateKey("B", 1, action, event, 
MAX_PARALLELISM);
+
+        assertTrue(ActionStateUtil.isKeyRetained(null, keyA));
+        assertTrue(ActionStateUtil.isKeyRetained(null, keyB));
+    }
+
+    @Test
+    public void testIsKeyRetainedDropsUnrecognizedFormatKeys() {
+        // Keys that do not have the current segment count cannot be 
attributed to a key-group, so
+        // they are dropped during ownership filtering rather than retained in 
every subtask. This
+        // closes the orphan-state leak; the project does not preserve 
pre-format durable state.
+        assertFalse(ActionStateUtil.isKeyRetained(kg -> true, 
"test-key_1_event-uuid_action-uuid"));
+        assertFalse(ActionStateUtil.isKeyRetained(kg -> true, 
"malformed-key"));
+    }
+
+    @Test
+    public void testIsKeyRetainedDropsKeyWithUnparsableKeyGroup() {
+        // A well-formed (5-segment) key whose key-group segment is not 
numeric cannot be
+        // attributed to a key-group and is dropped.
+        assertFalse(
+                ActionStateUtil.isKeyRetained(
+                        kg -> true, 
"not-a-number_1_event-uuid_action-uuid_bkey"));
+    }
+
+    @Test
+    public void testBusinessKeyContainingSeparatorIsHandled() throws Exception 
{
+        // A business key containing the separator (e.g. "tenant_user") must 
still round-trip and
+        // be attributable, because it occupies the trailing segment of the 
composite key. This is
+        // the exact case that broke the previous segment-count parsing.
+        Object businessKey = "tenant_user";
+        Action action = new NoOpAction("underscore-action");
+        InputEvent event = new InputEvent("underscore-input");
+        String stateKey =
+                ActionStateUtil.generateKey(businessKey, 3, action, event, 
MAX_PARALLELISM);
+
+        assertEquals("tenant_user", ActionStateUtil.businessKeyOf(stateKey));
+        assertEquals("tenant_user", ActionStateUtil.parseKey(stateKey).get(4));
+        assertTrue(ActionStateUtil.matchesBusinessKey(stateKey, businessKey));
+        assertTrue(ActionStateUtil.matchesBusinessKeyAndSeqNum(stateKey, 
businessKey, 3));
+
+        int ownedKeyGroup = ActionStateUtil.parseKeyGroup(stateKey);
+        assertTrue(ActionStateUtil.isKeyRetained(kg -> kg == ownedKeyGroup, 
stateKey));
+        assertFalse(ActionStateUtil.isKeyRetained(kg -> kg != ownedKeyGroup, 
stateKey));
+    }
+
+    @Test
+    public void testMatchesBusinessKeyIsSegmentExact() throws Exception {
+        Action action = new NoOpAction("match-action");
+        InputEvent event = new InputEvent("match-input");
+        // Numeric business key 1 at seqNum 5: a substring match on "_5_" 
would wrongly
+        // attribute this record to business key 5 via its seqNum segment.
+        String keyOneAtSeqFive = ActionStateUtil.generateKey(1L, 5, action, 
event, MAX_PARALLELISM);
+
+        assertTrue(ActionStateUtil.matchesBusinessKey(keyOneAtSeqFive, 1L));
+        assertFalse(ActionStateUtil.matchesBusinessKey(keyOneAtSeqFive, 5L));
+        
assertFalse(ActionStateUtil.matchesBusinessKey("legacy_1_event-uuid_action-uuid",
 1L));
+    }
+
+    @Test
+    public void testMatchesBusinessKeyAndSeqNum() throws Exception {
+        Action action = new NoOpAction("match-action");
+        InputEvent event = new InputEvent("match-input");
+        String stateKey = ActionStateUtil.generateKey("A", 7, action, event, 
MAX_PARALLELISM);
+
+        assertTrue(ActionStateUtil.matchesBusinessKeyAndSeqNum(stateKey, "A", 
7));
+        assertFalse(ActionStateUtil.matchesBusinessKeyAndSeqNum(stateKey, "A", 
8));
+        assertFalse(ActionStateUtil.matchesBusinessKeyAndSeqNum(stateKey, "B", 
7));
+    }
+
+    @Test
+    public void testMatchesBusinessKeyWithSeqNumFilter() throws Exception {
+        Action action = new NoOpAction("match-action");
+        InputEvent event = new InputEvent("match-input");
+        String keyOneAtSeqFive = ActionStateUtil.generateKey(1L, 5, action, 
event, MAX_PARALLELISM);
+
+        assertTrue(
+                ActionStateUtil.matchesBusinessKeyWithSeqNum(keyOneAtSeqFive, 
1L, seq -> seq <= 5));
+        assertFalse(
+                ActionStateUtil.matchesBusinessKeyWithSeqNum(keyOneAtSeqFive, 
1L, seq -> seq > 5));
+        // Wrong business key never matches, regardless of the seqNum filter.
+        
assertFalse(ActionStateUtil.matchesBusinessKeyWithSeqNum(keyOneAtSeqFive, 5L, 
seq -> true));
+    }
 }
diff --git 
a/runtime/src/test/java/org/apache/flink/agents/runtime/actionstate/FlussActionStateStoreIT.java
 
b/runtime/src/test/java/org/apache/flink/agents/runtime/actionstate/FlussActionStateStoreIntegrationTest.java
similarity index 86%
rename from 
runtime/src/test/java/org/apache/flink/agents/runtime/actionstate/FlussActionStateStoreIT.java
rename to 
runtime/src/test/java/org/apache/flink/agents/runtime/actionstate/FlussActionStateStoreIntegrationTest.java
index 0d4ddd06..7c5d14e8 100644
--- 
a/runtime/src/test/java/org/apache/flink/agents/runtime/actionstate/FlussActionStateStoreIT.java
+++ 
b/runtime/src/test/java/org/apache/flink/agents/runtime/actionstate/FlussActionStateStoreIntegrationTest.java
@@ -41,11 +41,12 @@ import static 
org.apache.flink.agents.api.configuration.AgentConfigOptions.FLUSS
 import static org.assertj.core.api.Assertions.assertThat;
 
 /** Integration tests for {@link FlussActionStateStore} against an embedded 
Fluss cluster. */
-public class FlussActionStateStoreIT {
+public class FlussActionStateStoreIntegrationTest {
 
     private static final String TEST_DATABASE = "test_flink_agents";
     private static final String TEST_TABLE = "action_state_it";
     private static final String TEST_KEY = "test-key";
+    private static final int MAX_PARALLELISM = 128;
 
     @RegisterExtension
     static final FlussClusterExtension FLUSS_CLUSTER =
@@ -58,7 +59,7 @@ public class FlussActionStateStoreIT {
     @BeforeEach
     void setUp() throws Exception {
         AgentConfiguration config = createAgentConfiguration();
-        store = new FlussActionStateStore(config);
+        store = new FlussActionStateStore(config, MAX_PARALLELISM);
 
         // Wait for table to be ready in the cluster
         waitForTableReady();
@@ -188,7 +189,7 @@ public class FlussActionStateStoreIT {
 
         // Simulate recovery: new store instance
         FlussActionStateStore recoveredStore =
-                new FlussActionStateStore(createAgentConfiguration());
+                new FlussActionStateStore(createAgentConfiguration(), 
MAX_PARALLELISM);
         try {
             // Rebuild using the marker; should replay from marker offset to 
current end
             recoveredStore.rebuildState(List.of(marker));
@@ -206,6 +207,44 @@ public class FlussActionStateStoreIT {
         }
     }
 
+    /**
+     * Reproduces the orphan-state leak fix: after recovery, a subtask must 
keep only the keys it
+     * owns and drop keys owned by other subtasks. Here "A" is owned and "B" 
is foreign, so the
+     * rebuilt cache must contain "A" but not "B".
+     */
+    @Test
+    @SuppressWarnings("unchecked")
+    void testRebuildStateFiltersForeignKeys() throws Exception {
+        // Capture the recovery marker before any writes so the replay window 
covers the writes
+        // below (simulates a checkpoint taken before the actions were 
recorded).
+        Object marker = store.getRecoveryMarker();
+
+        store.put("A", 1L, testAction, testEvent, new ActionState(testEvent));
+        store.put("B", 1L, testAction, testEvent, new ActionState(testEvent));
+        store.close();
+
+        // Simulate recovery into a new store instance that owns only key "A".
+        FlussActionStateStore recoveredStore =
+                new FlussActionStateStore(createAgentConfiguration(), 
MAX_PARALLELISM);
+        try {
+            // Own key's key-group computed from the WAL key; the filter 
accepts only this
+            // key-group.
+            int ownedKeyGroup =
+                    ActionStateUtil.parseKeyGroup(
+                            ActionStateUtil.generateKey("A", 1L, testAction, 
testEvent, 128));
+            recoveredStore.setOwnershipFilter(kg -> kg == ownedKeyGroup);
+            recoveredStore.rebuildState(List.of(marker));
+
+            // Owned key is recovered; foreign key is filtered out and never 
enters the cache.
+            assertThat(recoveredStore.get("A", 1L, testAction, 
testEvent)).isNotNull();
+            assertThat(recoveredStore.get("B", 1L, testAction, 
testEvent)).isNull();
+        } finally {
+            recoveredStore.close();
+            // Prevent double-close in tearDown
+            store = null;
+        }
+    }
+
     @Test
     void testPruneWorksAfterRecovery() throws Exception {
         // Capture recovery marker BEFORE writing data.
@@ -218,7 +257,7 @@ public class FlussActionStateStoreIT {
 
         // Simulate recovery: new store instance
         FlussActionStateStore recoveredStore =
-                new FlussActionStateStore(createAgentConfiguration());
+                new FlussActionStateStore(createAgentConfiguration(), 
MAX_PARALLELISM);
         try {
             // Rebuild state from the log using recovery markers
             recoveredStore.rebuildState(List.of(marker));
@@ -247,7 +286,7 @@ public class FlussActionStateStoreIT {
         String multiDb = "test_flink_agents_multi";
         String multiTable = "action_state_multi";
         AgentConfiguration multiConfig = createAgentConfiguration(multiDb, 
multiTable, 4);
-        FlussActionStateStore multiStore = new 
FlussActionStateStore(multiConfig);
+        FlussActionStateStore multiStore = new 
FlussActionStateStore(multiConfig, MAX_PARALLELISM);
         try {
             waitForTableReady(multiDb, multiTable);
 
@@ -278,7 +317,8 @@ public class FlussActionStateStoreIT {
             multiStore.close();
 
             // Recover into a new store instance
-            FlussActionStateStore recoveredStore = new 
FlussActionStateStore(multiConfig);
+            FlussActionStateStore recoveredStore =
+                    new FlussActionStateStore(multiConfig, MAX_PARALLELISM);
             try {
                 recoveredStore.rebuildState(List.of(marker));
 
diff --git 
a/runtime/src/test/java/org/apache/flink/agents/runtime/actionstate/FlussActionStateStoreSaslIT.java
 
b/runtime/src/test/java/org/apache/flink/agents/runtime/actionstate/FlussActionStateStoreSaslIntegrationTest.java
similarity index 97%
rename from 
runtime/src/test/java/org/apache/flink/agents/runtime/actionstate/FlussActionStateStoreSaslIT.java
rename to 
runtime/src/test/java/org/apache/flink/agents/runtime/actionstate/FlussActionStateStoreSaslIntegrationTest.java
index 547a83fb..0fb682d2 100644
--- 
a/runtime/src/test/java/org/apache/flink/agents/runtime/actionstate/FlussActionStateStoreSaslIT.java
+++ 
b/runtime/src/test/java/org/apache/flink/agents/runtime/actionstate/FlussActionStateStoreSaslIntegrationTest.java
@@ -42,11 +42,12 @@ import static org.assertj.core.api.Assertions.assertThat;
  * Integration tests for {@link FlussActionStateStore} with SASL/PLAIN 
authentication against an
  * embedded Fluss cluster.
  */
-public class FlussActionStateStoreSaslIT {
+public class FlussActionStateStoreSaslIntegrationTest {
 
     private static final String TEST_DATABASE = "test_flink_agents_sasl";
     private static final String TEST_TABLE = "action_state_sasl_it";
     private static final String TEST_KEY = "test-key";
+    private static final int MAX_PARALLELISM = 128;
     private static final String SASL_USERNAME = "testuser";
     private static final String SASL_PASSWORD = "testpass";
 
@@ -64,7 +65,7 @@ public class FlussActionStateStoreSaslIT {
     @BeforeEach
     void setUp() throws Exception {
         AgentConfiguration config = createSaslAgentConfiguration();
-        store = new FlussActionStateStore(config);
+        store = new FlussActionStateStore(config, MAX_PARALLELISM);
     }
 
     @AfterEach
@@ -102,7 +103,7 @@ public class FlussActionStateStoreSaslIT {
 
         // Recover into a new store instance with SASL
         FlussActionStateStore recoveredStore =
-                new FlussActionStateStore(createSaslAgentConfiguration());
+                new FlussActionStateStore(createSaslAgentConfiguration(), 
MAX_PARALLELISM);
         try {
             recoveredStore.rebuildState(java.util.List.of(marker));
 
diff --git 
a/runtime/src/test/java/org/apache/flink/agents/runtime/actionstate/FlussActionStateStoreTest.java
 
b/runtime/src/test/java/org/apache/flink/agents/runtime/actionstate/FlussActionStateStoreTest.java
index f6ba5fcc..9e54871b 100644
--- 
a/runtime/src/test/java/org/apache/flink/agents/runtime/actionstate/FlussActionStateStoreTest.java
+++ 
b/runtime/src/test/java/org/apache/flink/agents/runtime/actionstate/FlussActionStateStoreTest.java
@@ -47,6 +47,7 @@ import static org.mockito.Mockito.when;
 public class FlussActionStateStoreTest {
 
     private static final String TEST_KEY = "test-key";
+    private static final int MAX_PARALLELISM = 128;
 
     private AppendWriter mockWriter;
     private FlussActionStateStore store;
@@ -64,7 +65,11 @@ public class FlussActionStateStoreTest {
         actionStates = new HashMap<>();
         store =
                 new FlussActionStateStore(
-                        actionStates, mock(Connection.class), 
mock(Table.class), mockWriter);
+                        actionStates,
+                        mock(Connection.class),
+                        mock(Table.class),
+                        mockWriter,
+                        MAX_PARALLELISM);
 
         testAction = new NoOpAction("test-action");
         testEvent = new InputEvent("test data");
@@ -77,7 +82,8 @@ public class FlussActionStateStoreTest {
 
         verify(mockWriter).append(any(InternalRow.class));
 
-        String stateKey = ActionStateUtil.generateKey(TEST_KEY, 1L, 
testAction, testEvent);
+        String stateKey =
+                ActionStateUtil.generateKey(TEST_KEY, 1L, testAction, 
testEvent, MAX_PARALLELISM);
         assertThat(actionStates).containsKey(stateKey);
         assertThat(actionStates.get(stateKey)).isEqualTo(testActionState);
     }
@@ -89,29 +95,38 @@ public class FlussActionStateStoreTest {
 
         FlussActionStateStore failStore =
                 new FlussActionStateStore(
-                        actionStates, mock(Connection.class), 
mock(Table.class), mockWriter);
+                        actionStates,
+                        mock(Connection.class),
+                        mock(Table.class),
+                        mockWriter,
+                        MAX_PARALLELISM);
 
         assertThatThrownBy(
                         () -> failStore.put(TEST_KEY, 1L, testAction, 
testEvent, testActionState))
                 .isInstanceOf(Exception.class);
 
         // Cache should NOT be updated on write failure
-        String stateKey = ActionStateUtil.generateKey(TEST_KEY, 1L, 
testAction, testEvent);
+        String stateKey =
+                ActionStateUtil.generateKey(TEST_KEY, 1L, testAction, 
testEvent, MAX_PARALLELISM);
         assertThat(actionStates).doesNotContainKey(stateKey);
     }
 
     @Test
     void testGetTriggersDivergenceCleanup() throws Exception {
         actionStates.put(
-                ActionStateUtil.generateKey(TEST_KEY, 1L, testAction, 
testEvent), testActionState);
+                ActionStateUtil.generateKey(TEST_KEY, 1L, testAction, 
testEvent, MAX_PARALLELISM),
+                testActionState);
         actionStates.put(
-                ActionStateUtil.generateKey(TEST_KEY, 2L, testAction, 
testEvent), testActionState);
+                ActionStateUtil.generateKey(TEST_KEY, 2L, testAction, 
testEvent, MAX_PARALLELISM),
+                testActionState);
         // diverge: same key+seqNum, different action
         actionStates.put(
-                ActionStateUtil.generateKey(TEST_KEY, 2L, new 
NoOpAction("test-2"), testEvent),
+                ActionStateUtil.generateKey(
+                        TEST_KEY, 2L, new NoOpAction("test-2"), testEvent, 
MAX_PARALLELISM),
                 testActionState);
         actionStates.put(
-                ActionStateUtil.generateKey(TEST_KEY, 3L, testAction, 
testEvent), testActionState);
+                ActionStateUtil.generateKey(TEST_KEY, 3L, testAction, 
testEvent, MAX_PARALLELISM),
+                testActionState);
 
         store.get(TEST_KEY, 2L, new NoOpAction("test-1"), testEvent);
 
@@ -121,12 +136,52 @@ public class FlussActionStateStoreTest {
         assertThat(store.get(TEST_KEY, 3L, testAction, testEvent)).isNull();
     }
 
+    /**
+     * Regression test for cross-key pruning: a numeric business key must not 
match another record's
+     * sequence-number segment. Here business key 1 at seqNum 5 collides, on 
substring matching,
+     * with pruning business key 5 — segment-exact matching must keep it.
+     */
+    @Test
+    void testPruneStateDoesNotCrossNumericKeyAndSeqNum() throws Exception {
+        String keyOneAtSeqFive =
+                ActionStateUtil.generateKey(1L, 5L, testAction, testEvent, 
MAX_PARALLELISM);
+        String keyFiveAtSeqThree =
+                ActionStateUtil.generateKey(5L, 3L, testAction, testEvent, 
MAX_PARALLELISM);
+        actionStates.put(keyOneAtSeqFive, testActionState);
+        actionStates.put(keyFiveAtSeqThree, testActionState);
+
+        store.pruneState(5L, 10L);
+
+        // Key 5's record (seqNum 3 <= 10) is pruned; key 1's record must 
survive even though its
+        // seqNum segment ("_5_") textually contains the pruned business key.
+        assertThat(actionStates).containsKey(keyOneAtSeqFive);
+        assertThat(actionStates).doesNotContainKey(keyFiveAtSeqThree);
+    }
+
+    /**
+     * The divergence cleanup inside {@code get()} must be scoped to the 
requested business key: a
+     * cache miss for one key must not evict another key's newer states.
+     */
+    @Test
+    void testGetCleanupIsScopedToRequestedKey() throws Exception {
+        String otherKeyNewerState =
+                ActionStateUtil.generateKey(
+                        "other-key", 9L, testAction, testEvent, 
MAX_PARALLELISM);
+        actionStates.put(otherKeyNewerState, testActionState);
+
+        // Cache miss for TEST_KEY at seqNum 1 triggers cleanup of states with 
seqNum > 1.
+        assertThat(store.get(TEST_KEY, 1L, testAction, testEvent)).isNull();
+
+        assertThat(actionStates).containsKey(otherKeyNewerState);
+    }
+
     // ==================== rebuildState tests ====================
 
     @Test
     void testRebuildStateSkipsOnEmptyMarkers() throws Exception {
         actionStates.put(
-                ActionStateUtil.generateKey(TEST_KEY, 1L, testAction, 
testEvent), testActionState);
+                ActionStateUtil.generateKey(TEST_KEY, 1L, testAction, 
testEvent, MAX_PARALLELISM),
+                testActionState);
 
         store.rebuildState(Collections.emptyList());
 
@@ -137,7 +192,8 @@ public class FlussActionStateStoreTest {
     @Test
     void testRebuildStateSkipsOnNonMapMarker() throws Exception {
         actionStates.put(
-                ActionStateUtil.generateKey(TEST_KEY, 1L, testAction, 
testEvent), testActionState);
+                ActionStateUtil.generateKey(TEST_KEY, 1L, testAction, 
testEvent, MAX_PARALLELISM),
+                testActionState);
 
         // A non-Map marker is ignored, resulting in empty bucketStartOffsets.
         // Note: rebuildState clears the cache before checking offsets,
@@ -150,7 +206,8 @@ public class FlussActionStateStoreTest {
     @Test
     void testRebuildStateSkipsOnEmptyBucketOffsets() throws Exception {
         actionStates.put(
-                ActionStateUtil.generateKey(TEST_KEY, 1L, testAction, 
testEvent), testActionState);
+                ActionStateUtil.generateKey(TEST_KEY, 1L, testAction, 
testEvent, MAX_PARALLELISM),
+                testActionState);
 
         // Empty map marker → no valid bucket offsets.
         // Same as above: cache is cleared before the early-return check.
@@ -166,7 +223,8 @@ public class FlussActionStateStoreTest {
         Connection mockConnection = mock(Connection.class);
 
         FlussActionStateStore closeableStore =
-                new FlussActionStateStore(actionStates, mockConnection, 
mockTable, mockWriter);
+                new FlussActionStateStore(
+                        actionStates, mockConnection, mockTable, mockWriter, 
MAX_PARALLELISM);
 
         closeableStore.close();
 
@@ -186,7 +244,8 @@ public class FlussActionStateStoreTest {
         doThrow(tableFailure).when(failingTable).close();
 
         FlussActionStateStore closeableStore =
-                new FlussActionStateStore(actionStates, mockConnection, 
failingTable, mockWriter);
+                new FlussActionStateStore(
+                        actionStates, mockConnection, failingTable, 
mockWriter, MAX_PARALLELISM);
 
         
assertThat(catchThrowable(closeableStore::close)).isSameAs(tableFailure);
 
@@ -208,7 +267,7 @@ public class FlussActionStateStoreTest {
 
         FlussActionStateStore closeableStore =
                 new FlussActionStateStore(
-                        actionStates, failingConnection, failingTable, 
mockWriter);
+                        actionStates, failingConnection, failingTable, 
mockWriter, MAX_PARALLELISM);
 
         Throwable thrown = catchThrowable(closeableStore::close);
 
@@ -228,7 +287,8 @@ public class FlussActionStateStoreTest {
         doThrow(connectionFailure).when(failingConnection).close();
 
         FlussActionStateStore closeableStore =
-                new FlussActionStateStore(actionStates, failingConnection, 
mockTable, mockWriter);
+                new FlussActionStateStore(
+                        actionStates, failingConnection, mockTable, 
mockWriter, MAX_PARALLELISM);
 
         Throwable thrown = catchThrowable(closeableStore::close);
 
@@ -252,7 +312,7 @@ public class FlussActionStateStoreTest {
 
         FlussActionStateStore closeableStore =
                 new FlussActionStateStore(
-                        actionStates, failingConnection, failingTable, 
mockWriter);
+                        actionStates, failingConnection, failingTable, 
mockWriter, MAX_PARALLELISM);
 
         Throwable thrown = catchThrowable(closeableStore::close);
 
@@ -278,7 +338,7 @@ public class FlussActionStateStoreTest {
 
         FlussActionStateStore closeableStore =
                 new FlussActionStateStore(
-                        actionStates, failingConnection, failingTable, 
mockWriter);
+                        actionStates, failingConnection, failingTable, 
mockWriter, MAX_PARALLELISM);
 
         Throwable thrown = catchThrowable(closeableStore::close);
 
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 b69791c1..a6ad7ac8 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
@@ -34,12 +34,20 @@ import static 
org.apache.flink.agents.runtime.actionstate.ActionStateUtil.genera
  */
 public class InMemoryActionStateStore implements ActionStateStore {
 
+    private static final int DEFAULT_MAX_PARALLELISM = 128;
+
     private final Map<String, Map<String, ActionState>> keyedActionStates;
     private final boolean doCleanup;
+    private final int maxParallelism;
 
     public InMemoryActionStateStore(boolean doCleanup) {
+        this(doCleanup, DEFAULT_MAX_PARALLELISM);
+    }
+
+    public InMemoryActionStateStore(boolean doCleanup, int maxParallelism) {
         this.keyedActionStates = new HashMap<>();
         this.doCleanup = doCleanup;
+        this.maxParallelism = maxParallelism;
     }
 
     @Override
@@ -47,7 +55,7 @@ public class InMemoryActionStateStore implements 
ActionStateStore {
             throws IOException {
         Map<String, ActionState> actionStates =
                 keyedActionStates.getOrDefault(key.toString(), new 
HashMap<>());
-        actionStates.put(generateKey(key.toString(), seqNum, action, event), 
state);
+        actionStates.put(generateKey(key, seqNum, action, event, 
maxParallelism), state);
         keyedActionStates.put(key.toString(), actionStates);
     }
 
@@ -55,7 +63,7 @@ public class InMemoryActionStateStore implements 
ActionStateStore {
     public ActionState get(Object key, long seqNum, Action action, Event 
event) throws IOException {
         return keyedActionStates
                 .getOrDefault(key.toString(), new HashMap<>())
-                .get(generateKey(key.toString(), seqNum, action, event));
+                .get(generateKey(key, seqNum, action, event, maxParallelism));
     }
 
     @Override
diff --git 
a/runtime/src/test/java/org/apache/flink/agents/runtime/actionstate/KafkaActionStateStoreTest.java
 
b/runtime/src/test/java/org/apache/flink/agents/runtime/actionstate/KafkaActionStateStoreTest.java
index 1d8ae231..beb9f7a8 100644
--- 
a/runtime/src/test/java/org/apache/flink/agents/runtime/actionstate/KafkaActionStateStoreTest.java
+++ 
b/runtime/src/test/java/org/apache/flink/agents/runtime/actionstate/KafkaActionStateStoreTest.java
@@ -50,6 +50,7 @@ public class KafkaActionStateStoreTest {
 
     private static final String TEST_TOPIC = "test-action-state";
     private static final String TEST_KEY = "test-key";
+    private static final int MAX_PARALLELISM = 128;
 
     private MockProducer<String, ActionState> mockProducer;
     private MockConsumer<String, ActionState> mockConsumer;
@@ -77,7 +78,8 @@ public class KafkaActionStateStoreTest {
                         new AgentConfiguration(),
                         mockProducer,
                         mockConsumer,
-                        TEST_TOPIC);
+                        TEST_TOPIC,
+                        MAX_PARALLELISM);
 
         // Create test objects
         testAction = new NoOpAction("test-action");
@@ -95,7 +97,8 @@ public class KafkaActionStateStoreTest {
         assertEquals(1, history.size());
         var record = history.get(0);
         assertEquals(TEST_TOPIC, record.topic());
-        assertThat(record.key()).startsWith(TEST_KEY + "_1");
+        assertThat(ActionStateUtil.matchesBusinessKeyAndSeqNum(record.key(), 
TEST_KEY, 1L))
+                .isTrue();
         assertNotNull(record.value());
         assertThat(record.value()).isEqualTo(testActionState);
     }
@@ -103,13 +106,17 @@ public class KafkaActionStateStoreTest {
     @Test
     void testGetNonExistentActionState() throws Exception {
         actionStates.put(
-                ActionStateUtil.generateKey(TEST_KEY, 1L, testAction, 
testEvent), testActionState);
+                ActionStateUtil.generateKey(TEST_KEY, 1L, testAction, 
testEvent, MAX_PARALLELISM),
+                testActionState);
         actionStates.put(
-                ActionStateUtil.generateKey(TEST_KEY, 2L, testAction, 
testEvent), testActionState);
+                ActionStateUtil.generateKey(TEST_KEY, 2L, testAction, 
testEvent, MAX_PARALLELISM),
+                testActionState);
         actionStates.put(
-                ActionStateUtil.generateKey(TEST_KEY, 3L, testAction, 
testEvent), testActionState);
+                ActionStateUtil.generateKey(TEST_KEY, 3L, testAction, 
testEvent, MAX_PARALLELISM),
+                testActionState);
         actionStates.put(
-                ActionStateUtil.generateKey(TEST_KEY, 4L, testAction, 
testEvent), testActionState);
+                ActionStateUtil.generateKey(TEST_KEY, 4L, testAction, 
testEvent, MAX_PARALLELISM),
+                testActionState);
 
         actionStateStore.get(TEST_KEY, 2L, new NoOpAction("test-1"), 
testEvent);
 
@@ -122,17 +129,22 @@ public class KafkaActionStateStoreTest {
     @Test
     void testGetActionStateWithDiverge() throws Exception {
         actionStates.put(
-                ActionStateUtil.generateKey(TEST_KEY, 1L, testAction, 
testEvent), testActionState);
+                ActionStateUtil.generateKey(TEST_KEY, 1L, testAction, 
testEvent, MAX_PARALLELISM),
+                testActionState);
         actionStates.put(
-                ActionStateUtil.generateKey(TEST_KEY, 2L, testAction, 
testEvent), testActionState);
+                ActionStateUtil.generateKey(TEST_KEY, 2L, testAction, 
testEvent, MAX_PARALLELISM),
+                testActionState);
         // diverge here
         actionStates.put(
-                ActionStateUtil.generateKey(TEST_KEY, 2L, new 
NoOpAction("test-2"), testEvent),
+                ActionStateUtil.generateKey(
+                        TEST_KEY, 2L, new NoOpAction("test-2"), testEvent, 
MAX_PARALLELISM),
                 testActionState);
         actionStates.put(
-                ActionStateUtil.generateKey(TEST_KEY, 3L, testAction, 
testEvent), testActionState);
+                ActionStateUtil.generateKey(TEST_KEY, 3L, testAction, 
testEvent, MAX_PARALLELISM),
+                testActionState);
         actionStates.put(
-                ActionStateUtil.generateKey(TEST_KEY, 4L, testAction, 
testEvent), testActionState);
+                ActionStateUtil.generateKey(TEST_KEY, 4L, testAction, 
testEvent, MAX_PARALLELISM),
+                testActionState);
 
         actionStateStore.get(TEST_KEY, 2L, testAction, testEvent);
 
@@ -182,11 +194,14 @@ public class KafkaActionStateStoreTest {
     void testPruneState() throws Exception {
         // Arrange
         actionStates.put(
-                ActionStateUtil.generateKey(TEST_KEY, 1L, testAction, 
testEvent), testActionState);
+                ActionStateUtil.generateKey(TEST_KEY, 1L, testAction, 
testEvent, MAX_PARALLELISM),
+                testActionState);
         actionStates.put(
-                ActionStateUtil.generateKey(TEST_KEY, 2L, testAction, 
testEvent), testActionState);
+                ActionStateUtil.generateKey(TEST_KEY, 2L, testAction, 
testEvent, MAX_PARALLELISM),
+                testActionState);
         actionStates.put(
-                ActionStateUtil.generateKey(TEST_KEY, 3L, testAction, 
testEvent), testActionState);
+                ActionStateUtil.generateKey(TEST_KEY, 3L, testAction, 
testEvent, MAX_PARALLELISM),
+                testActionState);
 
         // Verify all states exist
         assertNotNull(actionStateStore.get(TEST_KEY, 1L, testAction, 
testEvent));
@@ -198,9 +213,13 @@ public class KafkaActionStateStoreTest {
 
         // Assert - states 1 and 2 should be pruned, state 3 should remain
         assertNull(
-                actionStates.get(ActionStateUtil.generateKey(TEST_KEY, 1L, 
testAction, testEvent)));
+                actionStates.get(
+                        ActionStateUtil.generateKey(
+                                TEST_KEY, 1L, testAction, testEvent, 
MAX_PARALLELISM)));
         assertNull(
-                actionStates.get(ActionStateUtil.generateKey(TEST_KEY, 2L, 
testAction, testEvent)));
+                actionStates.get(
+                        ActionStateUtil.generateKey(
+                                TEST_KEY, 2L, testAction, testEvent, 
MAX_PARALLELISM)));
         assertNotNull(actionStateStore.get(TEST_KEY, 3L, testAction, 
testEvent));
     }
 
@@ -220,7 +239,8 @@ public class KafkaActionStateStoreTest {
         assertEquals(2, history.size());
         var record = history.get(0);
         assertEquals(TEST_TOPIC, record.topic());
-        assertThat(record.key()).startsWith(TEST_KEY + "_1");
+        assertThat(ActionStateUtil.matchesBusinessKeyAndSeqNum(record.key(), 
TEST_KEY, 1L))
+                .isTrue();
         assertNotNull(record.value());
         assertThat(record.value()).isEqualTo(testActionState);
     }
@@ -249,18 +269,176 @@ public class KafkaActionStateStoreTest {
         // Assert - only the state up to the recovery marker should be restored
         assertThat(
                         actionStates.get(
-                                ActionStateUtil.generateKey(TEST_KEY, 1L, 
testAction, testEvent)))
+                                ActionStateUtil.generateKey(
+                                        TEST_KEY, 1L, testAction, testEvent, 
MAX_PARALLELISM)))
                 .isEqualTo(testActionState);
         assertThat(
                         actionStates.get(
-                                ActionStateUtil.generateKey(TEST_KEY, 2L, 
testAction, testEvent)))
+                                ActionStateUtil.generateKey(
+                                        TEST_KEY, 2L, testAction, testEvent, 
MAX_PARALLELISM)))
                 .isEqualTo(secondState);
         assertThat(
                         actionStates.get(
-                                ActionStateUtil.generateKey(TEST_KEY, 3L, 
testAction, testEvent)))
+                                ActionStateUtil.generateKey(
+                                        TEST_KEY, 3L, testAction, testEvent, 
MAX_PARALLELISM)))
                 .isEqualTo(thirdState);
     }
 
+    /**
+     * After recovery, only the keys accepted by the ownership filter should 
enter the in-memory
+     * cache. Here key "A" is owned and "B" is foreign, so "B" must be skipped 
while "A" is kept.
+     */
+    @Test
+    void testRebuildStateFiltersForeignKeys() throws Exception {
+        String keyA = "A";
+        String keyB = "B";
+        String stateKeyA =
+                ActionStateUtil.generateKey(keyA, 1L, testAction, testEvent, 
MAX_PARALLELISM);
+        String stateKeyB =
+                ActionStateUtil.generateKey(keyB, 1L, testAction, testEvent, 
MAX_PARALLELISM);
+
+        long offset = 0L;
+        mockConsumer.addRecord(
+                new ConsumerRecord<>(TEST_TOPIC, 0, offset++, stateKeyA, 
testActionState));
+        mockConsumer.addRecord(
+                new ConsumerRecord<>(TEST_TOPIC, 0, offset++, stateKeyB, 
testActionState));
+
+        List<Object> recoveryMarkers = List.of(Map.of(0, 0L, 1, 0L));
+
+        int ownedKeyGroup = ActionStateUtil.parseKeyGroup(stateKeyA);
+        actionStateStore.setOwnershipFilter(kg -> kg == ownedKeyGroup);
+        actionStateStore.rebuildState(recoveryMarkers);
+
+        assertThat(actionStates).containsKey(stateKeyA);
+        assertThat(actionStates).doesNotContainKey(stateKeyB);
+        assertThat(actionStateStore.get(keyA, 1L, testAction, testEvent))
+                .isEqualTo(testActionState);
+        assertThat(actionStateStore.get(keyB, 1L, testAction, 
testEvent)).isNull();
+    }
+
+    /**
+     * When no ownership filter is set, rebuildState retains every key — the 
original behavior is
+     * preserved (important for the in-memory and test backends).
+     */
+    @Test
+    void testRebuildStateKeepsAllKeysWhenNoFilter() throws Exception {
+        String stateKeyA =
+                ActionStateUtil.generateKey("A", 1L, testAction, testEvent, 
MAX_PARALLELISM);
+        String stateKeyB =
+                ActionStateUtil.generateKey("B", 1L, testAction, testEvent, 
MAX_PARALLELISM);
+
+        long offset = 0L;
+        mockConsumer.addRecord(
+                new ConsumerRecord<>(TEST_TOPIC, 0, offset++, stateKeyA, 
testActionState));
+        mockConsumer.addRecord(
+                new ConsumerRecord<>(TEST_TOPIC, 0, offset++, stateKeyB, 
testActionState));
+
+        List<Object> recoveryMarkers = List.of(Map.of(0, 0L, 1, 0L));
+
+        actionStateStore.rebuildState(recoveryMarkers);
+
+        assertThat(actionStates).containsKey(stateKeyA);
+        assertThat(actionStates).containsKey(stateKeyB);
+    }
+
+    /**
+     * Regression test for cross-key pruning: a numeric business key must not 
match another record's
+     * sequence-number segment. Here business key 1 at seqNum 5 collides, on 
substring matching,
+     * with pruning business key 5 — segment-exact matching must keep it.
+     */
+    @Test
+    void testPruneStateDoesNotCrossNumericKeyAndSeqNum() throws Exception {
+        String keyOneAtSeqFive =
+                ActionStateUtil.generateKey(1L, 5L, testAction, testEvent, 
MAX_PARALLELISM);
+        String keyFiveAtSeqThree =
+                ActionStateUtil.generateKey(5L, 3L, testAction, testEvent, 
MAX_PARALLELISM);
+        actionStates.put(keyOneAtSeqFive, testActionState);
+        actionStates.put(keyFiveAtSeqThree, testActionState);
+
+        actionStateStore.pruneState(5L, 10L);
+
+        // Key 5's record (seqNum 3 <= 10) is pruned; key 1's record must 
survive even though its
+        // seqNum segment ("_5_") textually contains the pruned business key.
+        assertThat(actionStates).containsKey(keyOneAtSeqFive);
+        assertThat(actionStates).doesNotContainKey(keyFiveAtSeqThree);
+    }
+
+    /**
+     * The divergence cleanup inside {@code get()} must also be scoped to the 
requested business
+     * key: a cache miss for one key must not evict another key's newer states.
+     */
+    @Test
+    void testGetCleanupIsScopedToRequestedKey() throws Exception {
+        String otherKeyNewerState =
+                ActionStateUtil.generateKey(
+                        "other-key", 9L, testAction, testEvent, 
MAX_PARALLELISM);
+        actionStates.put(otherKeyNewerState, testActionState);
+
+        // Cache miss for TEST_KEY at seqNum 1 triggers cleanup of states with 
seqNum > 1.
+        assertNull(actionStateStore.get(TEST_KEY, 1L, testAction, testEvent));
+
+        assertThat(actionStates).containsKey(otherKeyNewerState);
+    }
+
+    /**
+     * Records whose composite state key is not in the current format — 
including records written
+     * before the format change and otherwise malformed keys — cannot be 
attributed to a key-group
+     * and are dropped during rebuild rather than retained in every subtask. 
This closes the
+     * orphan-state leak; the project does not preserve pre-format durable 
state.
+     */
+    @Test
+    void testRebuildStateDropsUnrecognizedFormatKeys() throws Exception {
+        String legacyKey = TEST_KEY + "_1_event-uuid_action-uuid";
+        String malformedKey = "malformed-key";
+        String stateKeyA =
+                ActionStateUtil.generateKey("A", 1L, testAction, testEvent, 
MAX_PARALLELISM);
+
+        long offset = 0L;
+        mockConsumer.addRecord(
+                new ConsumerRecord<>(TEST_TOPIC, 0, offset++, legacyKey, 
testActionState));
+        mockConsumer.addRecord(
+                new ConsumerRecord<>(TEST_TOPIC, 0, offset++, malformedKey, 
testActionState));
+        mockConsumer.addRecord(
+                new ConsumerRecord<>(TEST_TOPIC, 0, offset++, stateKeyA, 
testActionState));
+
+        List<Object> recoveryMarkers = List.of(Map.of(0, 0L, 1, 0L));
+
+        int ownedKeyGroup = ActionStateUtil.parseKeyGroup(stateKeyA);
+        actionStateStore.setOwnershipFilter(kg -> kg == ownedKeyGroup);
+        actionStateStore.rebuildState(recoveryMarkers);
+
+        assertThat(actionStates).containsKey(stateKeyA);
+        assertThat(actionStates).doesNotContainKey(legacyKey);
+        assertThat(actionStates).doesNotContainKey(malformedKey);
+    }
+
+    /**
+     * A well-formed (5-segment) key whose key-group segment is not numeric 
cannot be attributed to
+     * a key-group and is dropped during rebuild.
+     */
+    @Test
+    void testRebuildStateDropsKeyWithUnparsableKeyGroup() throws Exception {
+        String unparseableGroupKey = 
"not-a-number_1_event-uuid_action-uuid_bkey";
+        String stateKeyA =
+                ActionStateUtil.generateKey("A", 1L, testAction, testEvent, 
MAX_PARALLELISM);
+
+        long offset = 0L;
+        mockConsumer.addRecord(
+                new ConsumerRecord<>(
+                        TEST_TOPIC, 0, offset++, unparseableGroupKey, 
testActionState));
+        mockConsumer.addRecord(
+                new ConsumerRecord<>(TEST_TOPIC, 0, offset++, stateKeyA, 
testActionState));
+
+        List<Object> recoveryMarkers = List.of(Map.of(0, 0L, 1, 0L));
+
+        int ownedKeyGroup = ActionStateUtil.parseKeyGroup(stateKeyA);
+        actionStateStore.setOwnershipFilter(kg -> kg == ownedKeyGroup);
+        actionStateStore.rebuildState(recoveryMarkers);
+
+        assertThat(actionStates).containsKey(stateKeyA);
+        assertThat(actionStates).doesNotContainKey(unparseableGroupKey);
+    }
+
     /** Contract: the consumer is closed even when closing the producer 
throws. */
     @Test
     @SuppressWarnings("unchecked")
@@ -275,7 +453,8 @@ public class KafkaActionStateStoreTest {
                         new AgentConfiguration(),
                         failingProducer,
                         consumer,
-                        TEST_TOPIC);
+                        TEST_TOPIC,
+                        MAX_PARALLELISM);
 
         assertThrows(RuntimeException.class, store::close);
 
@@ -302,7 +481,8 @@ public class KafkaActionStateStoreTest {
                         new AgentConfiguration(),
                         failingProducer,
                         failingConsumer,
-                        TEST_TOPIC);
+                        TEST_TOPIC,
+                        MAX_PARALLELISM);
 
         RuntimeException thrown = assertThrows(RuntimeException.class, 
store::close);
 
@@ -328,7 +508,8 @@ public class KafkaActionStateStoreTest {
                         new AgentConfiguration(),
                         producer,
                         failingConsumer,
-                        TEST_TOPIC);
+                        TEST_TOPIC,
+                        MAX_PARALLELISM);
 
         RuntimeException thrown = assertThrows(RuntimeException.class, 
store::close);
 
@@ -355,7 +536,8 @@ public class KafkaActionStateStoreTest {
                         new AgentConfiguration(),
                         failingProducer,
                         consumer,
-                        TEST_TOPIC);
+                        TEST_TOPIC,
+                        MAX_PARALLELISM);
 
         assertThat(catchThrowable(store::close)).isSameAs(producerFailure);
 
@@ -384,7 +566,8 @@ public class KafkaActionStateStoreTest {
                         new AgentConfiguration(),
                         failingProducer,
                         failingConsumer,
-                        TEST_TOPIC);
+                        TEST_TOPIC,
+                        MAX_PARALLELISM);
 
         Throwable thrown = catchThrowable(store::close);
 
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 ee5fc4ea..9fcab04d 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
@@ -50,6 +50,7 @@ import 
org.apache.flink.agents.plan.resourceprovider.ResourceProvider;
 import org.apache.flink.agents.plan.tools.FunctionTool;
 import org.apache.flink.agents.runtime.actionstate.ActionState;
 import org.apache.flink.agents.runtime.actionstate.ActionStateSerde;
+import org.apache.flink.agents.runtime.actionstate.ActionStateUtil;
 import org.apache.flink.agents.runtime.actionstate.CallResult;
 import org.apache.flink.agents.runtime.actionstate.InMemoryActionStateStore;
 import org.apache.flink.agents.runtime.eventlog.FileEventLogger;
@@ -83,6 +84,7 @@ import java.util.concurrent.Callable;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
 import java.util.concurrent.Future;
+import java.util.function.IntPredicate;
 import java.util.stream.Collectors;
 
 import static org.assertj.core.api.Assertions.assertThat;
@@ -2150,6 +2152,153 @@ public class ActionExecutionOperatorTest {
         }
     }
 
+    /**
+     * Regression test: durable-store lookups must use the original typed key, 
never its string
+     * form. The key-group segment embedded in every action-state record key 
is derived from the
+     * typed key's hash, so a stringified lookup computes a different 
key-group at maxParallelism
+     * greater than 1 and every recovery read misses, silently re-executing 
completed durable calls.
+     * Harness maxParallelism of 1 masks this (all keys collapse to key-group 
0), hence the
+     * realistic maxParallelism here.
+     */
+    @Test
+    void testDurableRecoveryHitsCacheWithTypedKeyAtRealisticMaxParallelism() 
throws Exception {
+        final int maxParallelism = 128;
+        final long key = 1L;
+        // Fixture guard: the regression only manifests when the typed key and 
its string form
+        // hash to different key-groups.
+        assertThat(KeyGroupRangeAssignment.assignToKeyGroup(key, 
maxParallelism))
+                .isNotEqualTo(
+                        KeyGroupRangeAssignment.assignToKeyGroup(
+                                String.valueOf(key), maxParallelism));
+
+        AgentPlan agentPlan = TestAgent.getDurableSyncAgentPlan();
+        InMemoryActionStateStore actionStateStore = new 
InMemoryActionStateStore(false);
+        TestAgent.DURABLE_CALL_COUNTER.set(0);
+
+        for (int run = 0; run < 2; run++) {
+            try (KeyedOneInputStreamOperatorTestHarness<Long, Long, Object> 
testHarness =
+                    new KeyedOneInputStreamOperatorTestHarness<>(
+                            new ActionExecutionOperatorFactory<>(agentPlan, 
true, actionStateStore),
+                            (KeySelector<Long, Long>) value -> value,
+                            TypeInformation.of(Long.class),
+                            maxParallelism,
+                            1,
+                            0)) {
+                testHarness.open();
+                ActionExecutionOperator<Long, Object> operator =
+                        (ActionExecutionOperator<Long, Object>) 
testHarness.getOperator();
+
+                testHarness.processElement(new StreamRecord<>(key));
+                operator.waitInFlightEventsFinished();
+
+                List<StreamRecord<Object>> recordOutput =
+                        (List<StreamRecord<Object>>) 
testHarness.getRecordOutput();
+                assertThat(recordOutput).hasSize(1);
+                assertThat(recordOutput.get(0).getValue()).isEqualTo(key * 3);
+            }
+        }
+
+        assertThat(TestAgent.DURABLE_CALL_COUNTER.get())
+                .as("Second run must recover from the durable store instead of 
re-executing")
+                .isEqualTo(1);
+    }
+
+    /**
+     * Regression test for the recovery ownership check: the key-group 
embedded in a persisted
+     * action-state record key is derived from the original typed key, and 
after rescaling it must
+     * be accepted by exactly the subtask that Flink assigns that key to. 
Under the old scheme —
+     * ownership recomputed by hashing the string form of the business key — 
the true owner (subtask
+     * of Long(1)'s key-group) would have dropped its own record while a 
foreign subtask retained
+     * it, re-executing completed actions and leaking orphan state.
+     */
+    @Test
+    void testOwnershipFilterAcceptsTypedKeyGroupOnlyOnOwnerSubtask() throws 
Exception {
+        final int maxParallelism = 128;
+        final int parallelism = 2;
+        final long key = 1L;
+        AgentPlan agentPlan = TestAgent.getDurableSyncAgentPlan();
+
+        // Phase 1: run with the typed key so the store holds records whose 
embedded key-group was
+        // computed from Long(1), not from "1".
+        InMemoryActionStateStore writerStore = new 
InMemoryActionStateStore(false);
+        TestAgent.DURABLE_CALL_COUNTER.set(0);
+        try (KeyedOneInputStreamOperatorTestHarness<Long, Long, Object> 
writerHarness =
+                new KeyedOneInputStreamOperatorTestHarness<>(
+                        new ActionExecutionOperatorFactory<>(agentPlan, true, 
writerStore),
+                        (KeySelector<Long, Long>) value -> value,
+                        TypeInformation.of(Long.class),
+                        maxParallelism,
+                        1,
+                        0)) {
+            writerHarness.open();
+            writerHarness.processElement(new StreamRecord<>(key));
+            ((ActionExecutionOperator<Long, Object>) 
writerHarness.getOperator())
+                    .waitInFlightEventsFinished();
+        }
+
+        List<String> persistedKeys =
+                writerStore.getKeyedActionStates().values().stream()
+                        .flatMap(states -> states.keySet().stream())
+                        .collect(Collectors.toList());
+        assertThat(persistedKeys).isNotEmpty();
+        int embeddedKeyGroup = 
ActionStateUtil.parseKeyGroup(persistedKeys.get(0));
+        assertThat(embeddedKeyGroup)
+                .isEqualTo(KeyGroupRangeAssignment.assignToKeyGroup(key, 
maxParallelism));
+
+        int ownerSubtask =
+                KeyGroupRangeAssignment.computeOperatorIndexForKeyGroup(
+                        maxParallelism, parallelism, embeddedKeyGroup);
+        int stringDerivedKeyGroup =
+                KeyGroupRangeAssignment.assignToKeyGroup(String.valueOf(key), 
maxParallelism);
+        // Fixture guard: the string-derived key-group must land on the other 
subtask, mirroring
+        // the original ownership bug.
+        assertThat(
+                        
KeyGroupRangeAssignment.computeOperatorIndexForKeyGroup(
+                                maxParallelism, parallelism, 
stringDerivedKeyGroup))
+                .isNotEqualTo(ownerSubtask);
+
+        // Phase 2: restart at parallelism 2 and capture the ownership filter 
each subtask
+        // installs on its store during recovery.
+        FilterCapturingActionStateStore ownerStore = new 
FilterCapturingActionStateStore();
+        FilterCapturingActionStateStore nonOwnerStore = new 
FilterCapturingActionStateStore();
+        try (KeyedOneInputStreamOperatorTestHarness<Long, Long, Object> 
ownerHarness =
+                        new KeyedOneInputStreamOperatorTestHarness<>(
+                                new 
ActionExecutionOperatorFactory<>(agentPlan, true, ownerStore),
+                                (KeySelector<Long, Long>) value -> value,
+                                TypeInformation.of(Long.class),
+                                maxParallelism,
+                                parallelism,
+                                ownerSubtask);
+                KeyedOneInputStreamOperatorTestHarness<Long, Long, Object> 
nonOwnerHarness =
+                        new KeyedOneInputStreamOperatorTestHarness<>(
+                                new ActionExecutionOperatorFactory<>(
+                                        agentPlan, true, nonOwnerStore),
+                                (KeySelector<Long, Long>) value -> value,
+                                TypeInformation.of(Long.class),
+                                maxParallelism,
+                                parallelism,
+                                1 - ownerSubtask)) {
+            ownerHarness.open();
+            nonOwnerHarness.open();
+
+            assertThat(ownerStore.capturedOwnershipFilter).isNotNull();
+            assertThat(nonOwnerStore.capturedOwnershipFilter).isNotNull();
+
+            
assertThat(ownerStore.capturedOwnershipFilter.test(embeddedKeyGroup))
+                    .as("The subtask owning the typed key's key-group must 
retain the record")
+                    .isTrue();
+            
assertThat(nonOwnerStore.capturedOwnershipFilter.test(embeddedKeyGroup))
+                    .as("Every other subtask must drop the record")
+                    .isFalse();
+            
assertThat(ownerStore.capturedOwnershipFilter.test(stringDerivedKeyGroup))
+                    .as(
+                            "String-derived key-group must not be owned by the 
typed key's owner;"
+                                    + " otherwise the original string-hash 
ownership bug would be"
+                                    + " undetectable")
+                    .isFalse();
+        }
+    }
+
     /** Tests that durableExecute properly handles exceptions thrown by the 
supplier. */
     @Test
     void testDurableExecuteExceptionHandling() throws Exception {
@@ -2409,7 +2558,7 @@ public class ActionExecutionOperatorTest {
     @Test
     void testDurableExecuteReconcilableRecoverySuccess() throws Exception {
         AgentPlan agentPlan = TestAgent.getDurableReconcilableAgentPlan();
-        InMemoryActionStateStore actionStateStore = new 
InMemoryActionStateStore(false);
+        InMemoryActionStateStore actionStateStore = new 
InMemoryActionStateStore(false, 1);
         long key = 1L;
         long input = 1L;
         TestAgent.RECONCILABLE_RECOVERY_BEHAVIOR = 
TestAgent.ReconcileBehavior.SUCCESS;
@@ -2454,7 +2603,7 @@ public class ActionExecutionOperatorTest {
     @Test
     void testDurableExecuteReconcilableRecoveryException() throws Exception {
         AgentPlan agentPlan = TestAgent.getDurableReconcilableAgentPlan();
-        InMemoryActionStateStore actionStateStore = new 
InMemoryActionStateStore(false);
+        InMemoryActionStateStore actionStateStore = new 
InMemoryActionStateStore(false, 1);
         long key = 2L;
         long input = 2L;
         TestAgent.RECONCILABLE_RECOVERY_BEHAVIOR = 
TestAgent.ReconcileBehavior.EXCEPTION;
@@ -2542,7 +2691,7 @@ public class ActionExecutionOperatorTest {
     @Test
     void testDurableExecuteRecoveryMixedCompletionOnlyAndReconcilableCalls() 
throws Exception {
         AgentPlan agentPlan = TestAgent.getDurableMixedRecoveryAgentPlan();
-        InMemoryActionStateStore actionStateStore = new 
InMemoryActionStateStore(false);
+        InMemoryActionStateStore actionStateStore = new 
InMemoryActionStateStore(false, 1);
         long key = 1L;
         long input = 1L;
         TestAgent.MIXED_RECONCILE_BEHAVIOR = 
TestAgent.ReconcileBehavior.SUCCESS;
@@ -3465,6 +3614,24 @@ public class ActionExecutionOperatorTest {
         return actionStateStore.get(key, 0L, action, 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
+     * would retain.
+     */
+    private static class FilterCapturingActionStateStore extends 
InMemoryActionStateStore {
+        private volatile IntPredicate capturedOwnershipFilter;
+
+        private FilterCapturingActionStateStore() {
+            super(false);
+        }
+
+        @Override
+        public void setOwnershipFilter(IntPredicate ownershipFilter) {
+            this.capturedOwnershipFilter = ownershipFilter;
+        }
+    }
+
     private static class RecordingActionStateStore extends 
InMemoryActionStateStore {
         private final List<Long> prunedSeqNums = new java.util.ArrayList<>();
 
diff --git 
a/runtime/src/test/java/org/apache/flink/agents/runtime/operator/DurableExecutionManagerTest.java
 
b/runtime/src/test/java/org/apache/flink/agents/runtime/operator/DurableExecutionManagerTest.java
index 88775255..2b2f4f5b 100644
--- 
a/runtime/src/test/java/org/apache/flink/agents/runtime/operator/DurableExecutionManagerTest.java
+++ 
b/runtime/src/test/java/org/apache/flink/agents/runtime/operator/DurableExecutionManagerTest.java
@@ -54,7 +54,7 @@ class DurableExecutionManagerTest {
     void noStoreModeMakesAllMaybeOperationsNoOp() throws Exception {
         DurableExecutionManager dem = new DurableExecutionManager(null);
         // No ACTION_STATE_STORE_BACKEND set → no default store should be 
created.
-        dem.maybeInitActionStateStore(new AgentConfiguration());
+        dem.maybeInitActionStateStore(new AgentConfiguration(), 128);
 
         assertThat(dem.hasDurableStore()).isFalse();
         assertThat(dem.getActionStateStore()).isNull();
@@ -232,7 +232,7 @@ class DurableExecutionManagerTest {
         
when(opBackend.getUnionListState(any(ListStateDescriptor.class))).thenReturn(markerState);
         when(markerState.get()).thenReturn(List.of("test-marker"));
 
-        dem.handleRecovery(opBackend);
+        dem.handleRecovery(opBackend, null);
 
         // InMemoryActionStateStore.rebuildState is a no-op (lines 62–64), so 
state mutation is
         // not observable here — the test verifies the call contract only.

Reply via email to