-
Notifications
You must be signed in to change notification settings - Fork 157
[Bug] Recovered Durable ActionState for keys owned by other subtasks is never pruned #1024
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
4dd7a11
ac8ae94
14b7f38
65680fa
3a68db6
a268646
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -51,6 +51,7 @@ | |
| 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 int maxParallelism; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
|
|
||
| @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,6 +128,7 @@ public class FlussActionStateStore implements ActionStateStore { | |
| this.connection = connection; | ||
| this.table = table; | ||
| this.writer = writer; | ||
| this.maxParallelism = maxParallelism; | ||
| } | ||
|
|
||
| public FlussActionStateStore(AgentConfiguration agentConfiguration) { | ||
|
|
@@ -193,7 +203,7 @@ public FlussActionStateStore(AgentConfiguration agentConfiguration) { | |
| @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,50 +225,42 @@ public void put(Object key, long seqNum, Action action, Event event, ActionState | |
|
|
||
| @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); | ||
| if (state == null) { | ||
| // Fall back to the pre-key-group 4-segment key so durable state written before the | ||
| // key-group upgrade is still found instead of being re-executed. | ||
| state = actionStates.get(ActionStateUtil.legacyKeyOf(stateKey)); | ||
| } | ||
| LOG.debug("Lookup action state: key={}, found={}", stateKey, state != null); | ||
| 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,13 +443,26 @@ private long replayRecords(Iterable<ScanRecord> records, long endOffset) { | |
| } | ||
| 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); | ||
| } | ||
| return lastSeenOffset; | ||
| } | ||
|
|
||
| @Override | ||
| public void setOwnershipFilter(IntPredicate ownershipFilter) { | ||
| this.ownershipFilter = ownershipFilter; | ||
| } | ||
|
|
||
| @Override | ||
| public void setMaxParallelism(int maxParallelism) { | ||
| this.maxParallelism = maxParallelism; | ||
| } | ||
|
|
||
| private Map<Integer, Long> getBucketEndOffsets() { | ||
| return getBucketOffsets(new OffsetSpec.LatestSpec()); | ||
| } | ||
|
|
@@ -486,7 +501,7 @@ public Object getRecoveryMarker() { | |
| @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 | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could we avoid using the number of underscore-separated segments to parse the state key?
businessKeyis inserted without escaping, so a key such astenant_userproduces more than five segments and is treated as UNKNOWN, causing every subtask to retain it and leaving the orphan-state leak unfixed. Since the project is still beta and previous ActionState data does not need to be preserved, I suggest defining a single new, unambiguous key format and removing the legacy compatibility logic entirely. For example, the business key could be length-prefixed, or the fixed fields could be parsed from both ends.