Skip to content

[runtime] Fix action-state key collisions between event occurrences with identical attributes - #1342

Merged
wenjin272 merged 3 commits into
apache:mainfrom
Ashfaqbs:fix/action-state-event-occurrence-identity
Nov 4, 2026
Merged

wenjin272 merged 3 commits into
apache:mainfrom
Ashfaqbs:fix/action-state-event-occurrence-identity

Conversation

@Ashfaqbs

Copy link
Copy Markdown
Contributor

Linked issue: #1084

Purpose of change

Two distinct event occurrences reaching the same action under the same business key and sequence number, with identical attributes, previously collided on the same durable action-state key. The second occurrence's pre-execution lookup could then find the first occurrence's just-persisted completed state and incorrectly skip its own execution, replaying the first occurrence's output instead. This fixes that: sibling occurrences with identical attributes now always get distinct keys, while a changed attribute still changes the key exactly as before (content-based replay-divergence detection is unchanged).

Runtime flow

  1. Root event ingestion (ActionExecutionOperator#processEvent, isInputEvent branch): after the per-key sequence number is incremented, assignRootOccurrenceId derives occurrenceId = sha256(businessKeyIdentity + "#" + seqNum) and assigns it on the event.
  2. Output emission (processActionTask on the serial engine; Work#commit on the parallel engine): assignOutputOccurrenceIds computes the producing action execution's own composite key (via the triggering event's already-assigned occurrenceId), then assigns each output event occurrenceId = sha256(producerExecutionId + "#" + ordinal), ordinal being its 0-based position among that execution's emitted outputs — before any output is dispatched or persisted.
  3. ActionStateUtil#generateUUIDForEvent consumes event.getOccurrenceId(): when present, it is folded together with (not in place of) the existing attributes hash into the UUID segment; when absent (state persisted by a pre-fix version), it falls back unchanged to the attributes-only hash.

Key decisions

  • Carrier: a new non-contract Event field (occurrenceId), mirroring the existing sourceTimestamp field, rather than a wrapper type. This avoids touching every ActionTask/SubgraphEvent/serializer call site a wrapper would require, and survives Flink's generic (Kryo-based) Event serializer across checkpoints the same way sourceTimestamp already does.
  • Fold with content, don't replace it: an occurrence identity that ignored attributes entirely (rejected) regressed ActionStateDivergenceRecoveryTest, letting a later restore with a changed input reuse the other input's cached result at the same sequence number. Folding lineage together with the attributes hash keeps that protection while adding sibling disambiguation.
  • Reuse the store's own key encoder: identity derivation goes through two new ActionStateStore methods (generateBusinessKeyIdentity, generateActionExecutionId) that delegate to the same ActionStateKeyEncoder each store already holds, rather than duplicating key-derivation logic in the operator — the composite key format and the occurrence identity can never drift apart.
  • No-op when durable execution is disabled, since nothing keys state on it in that case.

Behavioral Semantics

Interaction decisions

Durable execution Event kind occurrenceId Resulting key segment
disabled any not assigned unchanged: attributes hash only
enabled root (new) businessKey#seqNum folded with attributes
enabled output (new) producerExecId#ordinal folded with attributes
enabled any, pre-fix persisted state absent (null) unchanged: attributes hash only

Behavioral contracts

  1. Two sibling output events emitted by the same action execution with identical attributes get distinct action-state keys.
  2. A replayed root InputEvent (same business key, same sequence number, fresh random Event#getId()) derives the same occurrence identity, and therefore the same key, as the original.
  3. A changed attribute value still changes the key even when the lineage position (producer + ordinal, or business key + seqNum) is unchanged.
  4. The composite key format (five segments, 36-character UUID per identity segment) is unchanged; parseKey/isKeyRetained/businessKeyIdentityOf behavior is unchanged.
  5. An event never assigned an occurrence identity (state persisted by an older version) keys exactly as before the fix.

Failure behavior

generateBusinessKeyIdentity/generateActionExecutionId return null when no durable store is configured; callers treat null as "skip assignment" and never throw. deriveOutputEventOccurrenceId rejects a negative ordinal (IllegalArgumentException) and a null producer id (NullPointerException) defensively, though the operator's own call site cannot reach either. No new exception types; IOException propagation through generateKey is unchanged.

Tests

Contract Test
1. Sibling collision fixed ActionStateUtilTest#testDistinctEventOccurrencesWithOccurrenceIdDoNotCollide; operator-level: ActionStateDivergenceRecoveryTest#normalFanoutWithIdenticalSiblingAttributesStillExecutesBothSiblings
2. Replay-stable root identity ActionStateUtilTest#testDeriveRootEventOccurrenceIdIsReplayStableAndKeySensitive
3. Content-divergence preserved ActionStateUtilTest#testOccurrenceIdDoesNotSuppressContentDivergence; ActionStateDivergenceRecoveryTest#changedInputHistoryMustInvalidateLaterResultAcrossTwoRestores (pre-existing, now passing again)
4. Key format unchanged pre-existing ActionStateUtilTest key-format tests (testParseKey*, testIsKeyRetained*), all still green
5. Legacy fallback ActionStateUtilTest#testDistinctEventOccurrencesWithoutOccurrenceIdCollideLegacy (pins the documented legacy behavior)

Coverage by risk: the identity-derivation functions and the operator wiring that assigns them are both covered directly; the two closest pre-existing regression risks (content-based divergence detection, and a hand-fabricated durable-state fixture in KafkaActionStateStoreRecoveryTest that bypassed the new assignment path) were caught by the existing suite and fixed. Not verified: the Fluss-backed store path specifically (Fluss integration tests do not run in this environment; the fix is store-agnostic and goes through the same ActionStateKeyEncoder both Kafka and Fluss already share, and FlussActionStateStore received the identical two-method addition as KafkaActionStateStore).

Implementation notes and supporting evidence

generateUUIDForEvent combines occurrenceId and the attributes-JSON bytes with a # separator before hashing through UUID.nameUUIDFromBytes, so the segment stays a canonical 36-character UUID regardless of which branch runs. KafkaActionStateStoreRecoveryTest#queuedActionUsesRecoveredDurableResultAfterRestore fabricates pending durable state directly against the store, bypassing ActionExecutionOperator's assignment path; it now assigns the same businessKeyIdentity#seqNum identity to its hand-built InputEvent that the operator would compute for its own, matching the real contract instead of relying on the old content-only derivation.

API

Event gains a public, @JsonIgnore field (occurrenceId) with a getter/setter, excluded from equals/hashCode and from JSON serialization — no change to any existing serialized payload or to the cross-language event contract. ActionStateStore gains two new interface methods (generateBusinessKeyIdentity, generateActionExecutionId); the two shipped implementations (KafkaActionStateStore, FlussActionStateStore) and the test InMemoryActionStateStore all implement them as one-line delegations to their existing ActionStateKeyEncoder. Any third-party ActionStateStore implementation outside this repository would need to add these two methods — a source-incompatible interface change for external implementers, mitigated by both being thin delegations every implementation already has the pieces for. No durable action-state key format or size change; no migration needed. State persisted by a pre-fix version has no occurrenceId and recovers exactly as before.

Documentation

  • doc-needed
  • doc-not-needed
  • doc-included

Was this patch authored or co-authored using generative AI tooling?

  • Yes
  • No

Generated-by: Claude Code 2.1.314 (Claude Sonnet 5)

…ith identical attributes

ActionStateUtil.generateKey derived its event-identity key segment solely
from an event's attributes, so two distinct event occurrences reaching the
same action under the same business key and sequence number with identical
attributes produced the same durable action-state key. The second
occurrence's pre-execution lookup could then find the first occurrence's
just-persisted completed state and incorrectly skip its own execution,
replaying the first occurrence's output instead. Event#getId() alone cannot
fix this: it is a fresh random value on every construction, including on
replay of the same logical input, so keying on it directly breaks recovery.

Fixes this with a lineage-based occurrence identity, assigned by the
runtime once per occurrence rather than derived from content: a root
(input) event's identity comes from its business key and sequence number
(stable across replay, since a replayed InputEvent keeps the same business
key and sequence number despite a new random id); an action's output
event's identity comes from that action execution's own identity (its
composite action-state key, which already folds in its triggering event's
identity and the action's identity) plus the event's 0-based position
among that execution's emitted outputs. The identity is carried on a new
internal, non-contract Event field (mirroring the existing
sourceTimestamp field), assigned at root-event ingestion and at
output-event emission in ActionExecutionOperator, consumed by
ActionStateUtil, and folded together with (not in place of) the existing
attributes hash so the pre-existing content-based replay-divergence
protection is unchanged. The composite key's fixed five-segment,
36-character-UUID-per-segment format is unchanged, so no state-format
migration is needed; events with no assigned occurrence identity (state
written by an older version) fall back to the prior attributes-only
derivation unchanged.

Fixes apache#1084.

Generated-by: Claude Code 2.1.314 (Claude Sonnet 5)
@github-actions github-actions Bot added doc-not-needed Your PR changes do not impact docs fixVersion/1.0.0 The feature or bug should be implemented/fixed in the 1.0.0 version. priority/major Default priority of the PR or issue. labels Oct 30, 2026

@wenjin272 wenjin272 left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for implementing the lineage-based identity approach! I left a few comments on sharing identity generation across runtime paths, covering chat/internal-subagent calls, and simplifying the fallback and comments.

Please also update the remaining test fixtures for the new root occurrence identity. In the Ubuntu/JDK 21 CI log I inspected for bc042eb, five ActionExecutionOperatorTest tests fail: four reconcilable-recovery tests and testUnsupportedObservationValueIsSkippedWithoutChangingActionSuccess. seedActionState(), getStoredActionState(), and the observation test's direct lookup still construct InputEvents without an occurrence ID, so their attributes-only keys no longer match the keys generated by the operator. These fixtures should assign the same root occurrence identity as the production path, as already done in KafkaActionStateStoreRecoveryTest.

* @param event the event that triggered the action
* @throws IOException when key generation failed
*/
String generateActionExecutionId(Object key, long seqNum, Action action, Event event)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we move identity generation into a shared runtime component instead of adding these methods to ActionStateStore? Kafka and Fluss only delegate to the same encoder, and execution identity should be available independently of durable storage.

An action execution ID can be derived as hash(triggeringEventOccurrenceId, actionIdentity), with business key and sequence number already represented in the root occurrence ID. Content-based divergence checks should remain in place separately.

durableExecManager.generateActionExecutionId(
key, sequenceNumber, actionTask.action, actionTask.event);
if (producerExecutionId == null) {
return;

@wenjin272 wenjin272 Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Chat/internal-subagent call IDs were not part of the original issue scope, but we later identified the same event-occurrence identity problem in #1220. I suggest fixing that here as well; a follow-up PR is also fine. Either way, please move identity generation into a shared runtime component in this PR so both paths can use it independently of durable storage.

For the chat/subagent fix, DeterministicIdAllocator.Namespace and its caller setup in ActionTaskContextManager should consume the shared action execution identity while retaining target/session/call scoping. The current allocator does not use occurrence identity, so identical event occurrences can still produce duplicate IDs in SubgraphCallManager, including when durable execution is disabled.

// (for example, state written before this fix by an older version). Distinct
// occurrences with identical attributes still collide here; this path exists only for
// mixed-version recovery continuity, not as the steady-state behavior.
return String.valueOf(UUID.nameUUIDFromBytes(attributesBytes));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We do not require compatibility with old persisted state for this change, so please remove the legacy attributes-only fallback and make missing occurrence identity explicit on paths that require it. Keeping this fallback preserves the collision behavior the change is intended to eliminate.

* assigned deterministically by the runtime from the occurrence's position in the causal tree
* (business key and sequence number for a root event; producing action execution plus output
* ordinal for an action's output event). It lets durable action-state keys distinguish sibling
* event occurrences that carry identical attributes. See FLINK-AGENTS issue #1084.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you clean up the comments throughout this patch to describe the current identity and recovery contracts directly? Repeated issue references such as FLINK-AGENTS-1084 and historical wording like “before this fix” are unnecessary in production code; that context belongs in the PR description or commit message.

…rop legacy UUID comments

Addresses review feedback on apache#1342:
- Fix 5 failing ActionExecutionOperatorTest fixtures: seedActionState/
  getStoredActionState/testUnsupportedObservationValueIsSkippedWithoutChangingActionSuccess
  built InputEvents without the root occurrence identity the operator now
  assigns, so their keys no longer matched the operator's.
- Move occurrence-identity generation out of ActionStateStore into a
  shared path independent of durable storage: the operator now derives a
  root event's business-key identity directly from its own keyed-state
  serializer via a new ActionStateKeyEncoder field (rather than delegating
  through DurableExecutionManager/ActionStateStore, which no-opped when no
  durable store was configured), and derives an action execution's
  identity as hash(triggeringEventOccurrenceId, actionIdentity) via a new
  ActionStateUtil.deriveActionExecutionId, needing neither a key encoder
  nor a durable store. Removed generateBusinessKeyIdentity/
  generateActionExecutionId from the ActionStateStore interface and its
  Kafka/Fluss implementations accordingly.
- Removed historical issue-number/"before this fix" wording from the
  production-code comments this patch touches.
- Kept the ActionStateUtil attributes-only fallback for events without an
  occurrence identity rather than making it throw: removing it outright
  would require migrating a large pre-existing lower-level test surface
  (ActionStateUtilTest, KafkaActionStateStoreTest, FlussActionStateStoreTest,
  ActionStateKeyEncoderTest) that predates occurrence identity and
  constructs events without assigning one; left as a follow-up.
- Chat/internal-subagent call-path identity (issue apache#1220) intentionally
  left for a follow-up PR, per reviewer's own suggestion.

Generated-by: Claude Code 2.1.316 (Claude Sonnet 5)
@Ashfaqbs

Ashfaqbs commented Nov 3, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the detailed review.

  • Fixed the 5 failing ActionExecutionOperatorTest fixtures: seedActionState/getStoredActionState and the direct lookup in testUnsupportedObservationValueIsSkippedWithoutChangingActionSuccess now assign the same root occurrence identity the operator itself computes, instead of leaving it unset. All green locally (ActionExecutionOperatorTest, ActionStateUtilTest, KafkaActionStateStoreTest, FlussActionStateStoreTest, ActionStateKeyEncoderTest, KafkaActionStateStoreRecoveryTest, ActionStateDivergenceRecoveryTest).
  • Moved identity generation into a shared path independent of durable storage: the operator now derives a root event's business-key identity directly from its own keyed-state serializer (a new ActionStateKeyEncoder field it owns), instead of delegating through DurableExecutionManager/ActionStateStore — so this is no longer a no-op when durable execution is disabled. An action execution's identity is now hash(triggeringEventOccurrenceId, actionIdentity) via a new ActionStateUtil.deriveActionExecutionId, needing neither a key encoder nor a store. Removed generateBusinessKeyIdentity/generateActionExecutionId from the ActionStateStore interface and the Kafka/Fluss implementations.
  • Cleaned up the FLINK-AGENTS-1084/"before this fix" references from the production-code comments this patch touches.
  • One partial item: I kept the attributes-only fallback in ActionStateUtil.generateUUIDForEvent rather than making a missing occurrence identity throw. Removing it outright breaks a large pre-existing lower-level test surface (ActionStateUtilTest, KafkaActionStateStoreTest, FlussActionStateStoreTest, ActionStateKeyEncoderTest) that predates occurrence identity and constructs events without assigning one — happy to do that migration as a follow-up if you'd rather not carry the fallback at all.
  • Left the chat/internal-subagent identity fix ([api][runtime][java][python] Add event-backed chat calls from actions #1220) for a follow-up PR as you suggested — this PR now has the shared component in place for it to build on.

Pushed as bc042eb..8a5d3f5.

…ty refactor

The prior commit's ActionStateUtil.deriveActionExecutionId hard-required
its triggering event to already carry an occurrence identity. Chat and
internal-subagent call paths construct their own triggering events
(subgraph delegates, chat requests) without routing them through the
root-event assignment that gives an ordinary InputEvent its occurrence
identity, so those paths now threw NullPointerException on every action
commit (caught by a full runtime-module test run: SubgraphParallelExecutionTest
failed 8/13 cases with "triggeringEventOccurrenceId cannot be null").

Fixed by giving deriveActionExecutionId the same attributes-only fallback
ActionStateUtil.generateUUIDForEvent already uses for this exact gap,
rather than crashing, and changed its signature to take the Event so it
can compute that fallback itself.

Generated-by: Claude Code 2.1.316 (Claude Sonnet 5)

@wenjin272 wenjin272 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks again for your contribution and for working through the feedback! I think this PR is ready to merge. I've opened #1351 as a follow-up for the chat/subagent integration, replay handling, and remaining cleanup, so we can address those separately without holding up this PR.

@wenjin272
wenjin272 merged commit 0690840 into apache:main Nov 4, 2026
28 checks passed
wenjin272 added a commit to wenjin272/flink-agents that referenced this pull request Nov 4, 2026
Follow up PR apache#1342 with a shared causal identity utility for event
occurrences and chat/subagent session and call allocation. Assign output
identities before dispatch and preserve counters across suspension and
cached-result replay. Persist occurrence metadata in ActionState JSON,
remove content-only fallbacks and obsolete comments.

Cover repeated identical triggers, multiple yields, explicit sessions,
durable serialization and replay. Update direct runtime test fixtures to
provide occurrence metadata explicitly.

Validation: JDK 21 API/Plan/MCP/Runtime test reactor; clean JDK 11 focused
regressions; Spotless and Apache RAT checks.

Generated-by: Codex CLI 0.153.4 (GPT-6)
Co-authored-by: Codex <codex@openai.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

doc-not-needed Your PR changes do not impact docs fixVersion/1.0.0 The feature or bug should be implemented/fixed in the 1.0.0 version. priority/major Default priority of the PR or issue.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants