[Bug] Recovered Durable ActionState for keys owned by other subtasks is never pruned - #1024
[Bug] Recovered Durable ActionState for keys owned by other subtasks is never pruned#1024da-daken wants to merge 6 commits into
Conversation
When the Kafka or Fluss durable ActionState backend is enabled, every restored subtask receives all recovery markers via UnionListState and rebuilding the in-memory cache replays the full recovery tail. Because notifyCheckpointComplete only prunes keys present in the current subtask's keyed state, keys owned by other subtasks are never pruned and stay resident for the whole operator attempt (an orphan-state memory leak). This keeps the bulk replay but skips records not owned by the current subtask while rebuilding, so foreign keys never enter the cache. - ActionStateStore: add default setOwnershipFilter(Predicate<String>) (null means no filter, safe for in-memory/test backends). - Kafka/Fluss ActionStateStore: hold the predicate and skip foreign records in rebuildState, reusing the existing OperatorStateManager.isKeyOwnedByCurrentSubtask (Flink key-group semantics, not the Kafka partition hash). - DurableExecutionManager.handleRecovery: accept the ownership filter and apply it before rebuildState. - ActionExecutionOperator.initializeState: compute maxParallelism and KeyGroupRange, then pass the ownership predicate into handleRecovery. - Add Kafka unit tests and a Fluss IT reproducing the report at parallelism 2 (A -> subtask0, B -> subtask1). The durable ActionState storage backend is implemented only on the Java side; Python actions go through the same Java ActionExecutionOperator, so this fix covers Python actions as well. Co-Authored-By: WorkBuddy <workbuddy@tencent.com>
GreatEugenius
left a comment
There was a problem hiding this comment.
Hi @da-daken, thank you for the PR. I left one comment, which I believe is important.
| return true; | ||
| } | ||
| try { | ||
| return ownershipFilter.test(parseKey(stateKey).get(0)); |
There was a problem hiding this comment.
The ownership check hashes the String parsed from the durable state key, but Flink assigned keyed-state ownership using the original typed key. KeyGroupRangeAssignment hashes different object types differently; for example, with max parallelism 128, Long(1) maps to key-group 86 while String("1") maps to 54. The true owner can therefore discard its recovered ActionState while another subtask retains it, allowing replay to execute an already completed action again. Please persist the key-group computed from the original typed key in the WAL record and compare that value with currentSubtaskKeyGroupRange, instead of reconstructing ownership from the string form.
There was a problem hiding this comment.
Good catch! Since the string key recovered during rebuild carries no type
information, we can't reconstruct the correct key-group from it (e.g. both
Long(1) and String("1") toString to "1", but hash to different key-groups).
The fix is to store the key-group directly at write time, when the typed key is
still available, so recovery can read it back without relying on the string form.
There are two options for where to store the key-group:
| Dimension | A (embed in key) | C (store in ActionState value) |
|---|---|---|
| Correctness | Correct. Key-group computed once from typed key at write time. | Correct. |
| Format change | Key format (4 → 5 segments) | Value format (new field) |
| Upgrade behavior | Old 4-segment keys are deterministically dropped + warn log. At most one re-execution within the retention window. | Old values without keyGroup field must be dropped, or fall back to the buggy string-hash — which preserves the original bug. |
| Upgrade cost | One-time: old keys evicted after retention window. | Same one-time cost. |
| Self-describing | Yes. The key alone tells you which key-group it belongs to. | No. Must read the value to determine ownership. |
| Identity vs. payload | Key-group is identity information (like business key, seqNum), belongs in the key. | Key-group is stored as payload, mixing identity metadata with business data. |
| Perf (Fluss rebuild) | Filter by key before deserializing value. | Must deserialize all values to read keyGroup. |
Both A and C have the same upgrade cost — old records lack the key-group in
either location and must be dropped. The difference is semantic: key-group is
derived from the typed key at write time, just like the business key and
sequence number. It belongs in the key, not in the value payload.
I prefer A because it keeps identity information in the key where it
belongs, avoids mixing identity metadata with business payload, and in Fluss
avoids deserializing values for non-owned keys during rebuild.
There was a problem hiding this comment.
Thanks for the detailed analysis. Persisting the key-group at write time makes sense.
I am concerned about dropping all legacy 4-segment records during upgrade. A missing key-group does not have to mean either dropping the record or falling back to the incorrect string hash. We could treat it as UNKNOWN ownership:
Records with a key-group: filter normally using the current subtask’s KeyGroupRange.
Legacy records without a key-group: temporarily retain them in every subtask.
This preserves the old memory amplification only for the legacy recovery tail during the first upgraded attempt, while avoiding the loss of durable state. Once a new checkpoint marker advances past those records, they will no longer be included in the next recovery tail.
Dropping legacy records may re-execute every affected action or durable call in the recovery tail, potentially repeating external side effects. For durable execution, I think the bounded one-time memory overhead is preferable to weakening recovery correctness.
There was a problem hiding this comment.
I think it can be kept. To avoid re‑running, the subsequent GET path will also need to be compatible with the 4‑segment key. I'm not sure if adding a fallback logic in the code is acceptable.
…grade Records written before the key-group upgrade use the 4-segment key format without a key-group prefix, so they cannot be attributed to a key-group. Instead of dropping them during recovery, treat them as UNKNOWN ownership and retain them in every subtask, and add a legacy-key lookup fallback in KafkaActionStateStore.get and FlussActionStateStore.get so the durable action is found and not re-executed after an upgrade.
Linked issue: #1010
Purpose of change
I've confirmed the root cause:
rebuildStatereplays all recovery markers (UnionListState broadcasts every subtask's marker) into each subtask'sactionStatescache, whilenotifyCheckpointCompleteonly prunes keys present in the current subtask's own keyed state — so keys owned by other subtasks are never pruned and stay resident for the whole operator attempt.Adopted fix: keep the bulk replay, but skip records not owned by the current subtask during rebuild, so foreign keys never enter the cache.
Changes
ActionStateStore: add default methodsetOwnershipFilter(Predicate<String>)(nullmeans no filter, safe for in-memory/test backends).KafkaActionStateStore/FlussActionStateStore: hold the predicate; inrebuildState's replay loop, skip records whose business key is not owned by the current subtask, reusing the existingOperatorStateManager.isKeyOwnedByCurrentSubtask(Flink key-group semantics).DurableExecutionManager.handleRecovery: compute the current subtask'sKeyGroupRangeand set it as the filter beforerebuildState.ActionExecutionOperator.initializeState: computemaxParallelism+KeyGroupRange(already available here) and pass them intohandleRecovery.Tests
ActionStateUtilTest: backend-agnostic coverage ofisKeyRetained(owned key retained, foreign key dropped,nullfilter keeps all, unparseable key retained). Both stores route through this function, so this is the shared filtering contract.KafkaActionStateStoreTest(MockConsumer): seed records for keys A/B, set the filter to "owner of A only", assert the cache keeps only A; plus no-filter-keeps-all and unparseable-key-retained cases.FlussActionStateStoreIT.testRebuildStateFiltersForeignKeys(real Fluss cluster viaFlussClusterExtension): write A and B, capture a recovery marker, then rebuild into a fresh store with the filter "owner of A only" and assert A is recovered while B is filtered out. This is the only test that drives Fluss's real log-scan → deserialize → filter → cache replay path.API
no API
Documentation
doc-neededdoc-not-neededdoc-includedWas this patch authored or co-authored using generative AI tooling?
Generated-by: Cursor 2.4.22 (Claude Opus 4.8), WorkBuddy 5.3.11 (hy3)