Skip to content

[runtime][java] Reuse saved Kafka action results after recovery - #1161

Merged
wenjin272 merged 8 commits into
apache:mainfrom
1fanwang:1fannnw/fix-kafka-recovery-pending-results
Oct 12, 2026
Merged

wenjin272 merged 8 commits into
apache:mainfrom
1fanwang:1fannnw/fix-kafka-recovery-pending-results

Conversation

@1fanwang

@1fanwang 1fanwang commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

Linked issue: #1158

Purpose of change

After restoring a job, an action can repeat an external call even though Kafka already holds its result. Recovery now keeps that saved result reachable so the restored action can reuse it.

The checkpoint records which inputs have finished. Recovery uses that existing completion state rather than tracking it twice, so unfinished actions can reuse saved results even when no new input arrives.

Closes #1158.

Behavioral Semantics

Interaction decisions

Finished inputs are not repeated.
An unfinished action reuses its saved result when one exists.
Otherwise, it makes the call.

Behavioral contracts

  • Queued actions resume with their saved results after restore.
  • Finished inputs remain finished after a restart.

Failure behavior

Write errors and cancellation still reach the caller. Recovery still rejects invalid recovery data.

Tests

Contract Check
Restored actions reuse saved results Queued recovery
Recovery recognises previously finished inputs Restore without new input
Failed writes are not reported as saved results Write completion and failure
Cancellation reaches the caller Interrupted wait

Testing Done

The broker-backed regression ran on Java 17 with Kafka 3.8.0, kafka-clients 4.0.0, and the real operator checkpoint/restore harness. The first attempt checkpoints a queued action, executes its durable call, and fails after persistence. The second attempt restores that queue and checkpoints it before dispatch. The third restores the second checkpoint, emits 70, and leaves the supplier count at one.

The before run uses the actual PR merge-base, 67c66a8. The after run uses d664cd6.

In a scratch checkout, add the recovery regression and its test-constructor access to the base. The factory change only makes the existing test constructor public; the base's Kafka recovery implementation remains unchanged:

git switch --detach 67c66a829281793c627b74a1a6f72387ec13c4f2
git checkout d664cd6146a9ad183de693519d276f9fcf6a4cf3 -- \
  runtime/src/test/java/org/apache/flink/agents/runtime/actionstate/KafkaActionStateStoreRecoveryTest.java \
  runtime/src/main/java/org/apache/flink/agents/runtime/operator/ActionExecutionOperatorFactory.java

In queuedActionReusesResultFromPreviousAttemptWithKafka, add the diagnostic from the first source block after the waitInFlightEventsFinished call in the final restore. Run:

mvn -B --no-transfer-progress -pl runtime -am \
  '-Dtest=KafkaActionStateStoreRecoveryTest#queuedActionReusesResultFromPreviousAttemptWithKafka' \
  -Dsurefire.failIfNoSpecifiedTests=false test

Before: the output is correct, but the supplier runs twice. Selected raw output:

restored_output=[Record @ (undef) : 70], supplier_calls=2
expected: 1
 but was: 2
[INFO] BUILD FAILURE

For the after run, remove only the test setup changes and switch to the fixed commit:

git restore --source=HEAD --staged --worktree -- \
  runtime/src/test/java/org/apache/flink/agents/runtime/actionstate/KafkaActionStateStoreRecoveryTest.java \
  runtime/src/main/java/org/apache/flink/agents/runtime/operator/ActionExecutionOperatorFactory.java
git switch --detach d664cd6146a9ad183de693519d276f9fcf6a4cf3

Add the same diagnostic statement and repeat the command:

mvn -B --no-transfer-progress -pl runtime -am \
  '-Dtest=KafkaActionStateStoreRecoveryTest#queuedActionReusesResultFromPreviousAttemptWithKafka' \
  -Dsurefire.failIfNoSpecifiedTests=false test

After: recovery emits the saved result without a second supplier call:

restored_output=[Record @ (undef) : 70], supplier_calls=1
[INFO] BUILD SUCCESS

The broker-backed run uses one subtask and the serial fallback; it does not exercise the packaged continuation executor.

Replay may still re-scan some completed records while an earlier action is pending.

Reproducer source: KafkaActionStateStoreRecoveryTest.java
                operator.waitInFlightEventsFinished();

                System.out.printf(
                        "restored_output=%s, supplier_calls=%d%n",
                        harness.getOutput(), EXTERNAL_CALLS.get());
Reproducer source: KafkaActionStateStore.java
            }
            latestStateOffsets.forEach(
                    (stateKey, offset) -> {
                        if (needsReplay(stateKey, lastCompletedSequenceNumbersByIdentity)) {
                            recoveryOffsets.merge(offset.partition, offset.offset, Math::min);
                        }
                    });
            if (!recoveryOffsets.keySet().equals(currentTopicMetadata.getPartitions())) {

API

Existing user-facing Java, Python, and YAML APIs and saved Kafka data remain compatible. The runtime store interface can also receive input-completion information; existing stores keep their previous behavior.

Documentation

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

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

  • Yes
  • No

Generated-by: GitHub Copilot CLI (gpt-6-astra)

See the ASF generative tooling guidance.

@github-actions github-actions Bot added doc-not-needed Your PR changes do not impact docs fixVersion/0.4.0 priority/major Default priority of the PR or issue. labels Sep 25, 2026
@1fanwang
1fanwang force-pushed the 1fannnw/fix-kafka-recovery-pending-results branch from b1b1284 to 39c3916 Compare September 25, 2026 07:40
@github-actions github-actions Bot added doc-not-needed Your PR changes do not impact docs and removed doc-not-needed Your PR changes do not impact docs labels Sep 25, 2026
ProducerRecord<String, ActionState> kafkaRecord =
new ProducerRecord<>(topic, stateKey, state);
producer.send(kafkaRecord);
RecordMetadata metadata = producer.send(kafkaRecord).get();

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.

If the thread is interrupted while waiting for Kafka here, get() throws InterruptedException and clears the thread's interrupt flag. The catch below wraps the exception but leaves that flag cleared. The caller still receives an error, but code checking the interrupt flag no longer sees the request to stop.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in f109972f. put() now catches InterruptedException before the generic catch, restores the flag with Thread.currentThread().interrupt(), and rethrows the original exception instead of wrapping it. testPutRestoresInterruptFlagWhenSendIsInterrupted covers it: the send fails like a cancelled FutureTask (clears the flag, throws), and the test asserts the flag is set again after put() returns.


@Override
public void markCheckpointedSequence(Object key, long seqNum) {
latestKeySeqNum.merge(keyEncoder.generateBusinessKeyIdentity(key), seqNum, Math::max);

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.

Each completed input adds its key to latestKeySeqNum. After checkpoint completion, pruning removes the action state and its offset but leaves this entry behind.

A task processing many different keys therefore keeps accumulating entries in heap, even when none of those keys has remaining action state.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in f109972f. pruneState() now drops the key from latestKeySeqNum once the identity has no live state left in the cache, so entries stop accumulating per key for the job lifetime. Losing the boundary is harmless: any later record for that identity is newer than the last completed sequence and must be replayed either way. testPruneDropsCheckpointedBoundaryWhenNoLiveStateRemains covers it.

Comment on lines +237 to +239
void testRebuildStateKeepsPendingResultBeforeRecoveryMarker() throws Exception {
ActionState pendingState = new ActionState(testEvent);
actionStateStore.put(TEST_KEY, 1L, testAction, testEvent, pendingState);

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.

This test stores an empty ActionState and checks that the store reads it back. It never ends up executing a durable call or taking a Flink checkpoint, so it doesn't fully verify the behavior reported in the issue.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Replaced by KafkaActionStateStoreRecoveryTest in b07f9bd5, which now executes a real durable call and takes a real checkpoint in the window the issue describes:

  • The action runs context.durableExecute(...) with a real supplier, then snapshots the operator from inside the action body while the durable result is still pending, then fails without completing.
  • Recovery takes the checkpoint into a fresh KafkaActionStateStore via initializeState, so it exercises the real handleRecovery -> rebuildState path against the checkpoint's marker.
  • The assertions check the pending result survived: the recovered ActionState is incomplete, carries the one successful CallResult, and the supplier ran exactly once.

A constraint: under mvn test the operator always runs the synchronous executor, because the JDK 21 continuation executor lives only in the packaged multi-release JAR and is never on the surefire classpath, so a public-API checkpoint only observes post-invocation state. The checkpoint is therefore taken from inside the action body to land in the pre-completion window. I kept the original unit test because it still exercises the same marker rewind and rebuild path at unit scope, cheaply.

@wenjin272

Copy link
Copy Markdown
Contributor

Could you help review this PR at your convenience @joeyutong?

By the way, I think the Fluss action state has this issue too.

Track the latest durable Kafka record per action-state key and use the
checkpointed sequence boundary to compute recovery markers.

Generated-by: GitHub Copilot CLI (Claude Opus 5.5)
Signed-off-by: 1fanwang <1fannnw@gmail.com>
…e map in Kafka action state store

put() wraps producer send failures in a RuntimeException. When the send is
interrupted, Future.get() clears the thread's interrupt flag before throwing
InterruptedException; swallowing the flag leaves the caller unable to observe
the cancellation. Restore the flag before rethrowing the InterruptedException.

pruneState() removed live state for a business key but kept the latest
checkpointed sequence number forever, so a task over unbounded key cardinality
accumulates one entry per key for the whole job lifetime. Drop the boundary
once the identity has no live state left; a later record for the identity is
newer than the last completed sequence and must be replayed either way.

Signed-off-by: 1fanwang <1fannnw@gmail.com>
…al checkpoint

Replace the criticized marker-only unit test with an end-to-end test that
executes a real durable call and snapshots a real Flink checkpoint while the
action is still in flight, then recovers the checkpoint into a fresh store over
the recorded topic history. Pre-fix the recovery marker skips the pending
record; post-fix it rewinds to it and the pending durable result survives.

Adds unit coverage for the two fixed behaviors: put() restoring the interrupt
flag cleared by Future.get(), and pruneState() dropping the checkpointed
sequence boundary once the identity has no live state left. The store's
test-only key-sequence map getter and the operator factory's test constructor
become visible to the actionstate package for this.

Signed-off-by: 1fanwang <1fannnw@gmail.com>
@1fanwang
1fanwang force-pushed the 1fannnw/fix-kafka-recovery-pending-results branch from d4a032d to 9a50f3f Compare September 28, 2026 20:57
…ynamic end offsets

Signed-off-by: Stefan Wang <1fannnw@gmail.com>
Signed-off-by: 1fanwang <1fannnw@gmail.com>
The recovery regression targets the continuation engine pending-action checkpoint path. Current main defaults Maven tests to a parallel engine that drains in-flight work, so the test must explicitly select the serial fallback that models the packaged continuation path.

Signed-off-by: 1fanwang <1fannnw@gmail.com>

@joeyutong joeyutong left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks for taking on this issue!

private final Map<String, ActionState> actionStates;

// Record the lastest sequence number for each key that should be considered as valid
// Records the latest checkpointed sequence number for each business key identity.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Could we keep the completed-sequence tracking in the runtime and pass it to the store at snapshot time? snapshotLastCompletedSequenceNumbers() already collects this information, so reusing it could keep the store simpler and avoid maintaining a second copy.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in d87ea7b.

}
});
if (CHECKPOINT.get() == null) {
CHECKPOINT.set(HARNESS.get().snapshot(1L, 1L));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Could we take the checkpoint with the pending action still queued, then run it after restore and assert the result and supplier call count? The action is already dequeued here, so the restored queue is empty and the test only verifies store rebuild.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in d87ea7b.

@sangkyoonnam sangkyoonnam 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.

The rewound recovery marker fixes the pre-checkpoint loss in #1158. In a local run all 56 tests passed, and removing only the rewind in getRecoveryMarker() made three fail (KafkaActionStateStoreRecoveryTest:206, KafkaActionStateStoreTest:436 and :467). As joeyutong noted, the recovery test still needs to re-run the queued action after restore and assert the durable-call supplier ran once. Two more things inline: put latency, and a non-blocking replay cost that backs his snapshot-boundary suggestion.

Against the current heads, this merges without conflicts with main (bd527d7f) and #1175 but conflicts with #1177 in put(); whichever lands second needs to keep both the metadata tracking and the flush ordering.

ProducerRecord<String, ActionState> kafkaRecord =
new ProducerRecord<>(topic, stateKey, state);
producer.send(kafkaRecord);
RecordMetadata metadata = producer.send(kafkaRecord).get();

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.

Waiting on send(...).get() before flush() is the ordering I flagged on #1177: with kafka-clients 4.0's 5 ms default linger.ms, a small record in an otherwise idle batch can wait out the linger delay, and puts are sequential within a subtask. In the #1177 plain-producer probe, send.get; flush took 6.88 ms per send-loop iteration against 0.84 ms for send; flush; get; I haven't benchmarked this branch. This PR needs the RecordMetadata, so keep the future, flush, then await it before updating both caches; that keeps the metadata and the failure check without the delay.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in d664cd6.

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, d664cd6 does it: flush before get(), and the caches only update after the write succeeds. testPutDoesNotCacheFailedWrite covers both a failed flush and a failed send result. #1177 still conflicts in put() and the store tests, so whichever lands second should keep this ordering and the offset tracking.

for (Map.Entry<TopicPartition, Long> entry : endOffsets.entrySet()) {
recoveryOffsets.put(entry.getKey().partition(), entry.getValue());
}
latestStateOffsets.forEach(

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.

One long-pending action holds the replay start for its whole partition, and the per-partition min merge makes every subtask replay from there. latestStateOffsets is rebuilt from Kafka after a restore, but latestKeySeqNum starts empty, so for keys with no newly completed input the already-checkpointed records keep the next marker rewound until a completed checkpoint prunes them. In a local probe with one pending key B followed by 1000 checkpointed and pruned inputs on key A:

marker={0=0}
restored cacheSize=1001
first marker after restore (B done, A untouched)={0=1}
marker after first checkpoint-complete prune={0=1001} cache=0

As a non-blocking follow-up to joeyutong's suggestion, collecting the keyed completed-sequence boundaries before building the marker and passing them to the store would drop the duplicate latestKeySeqNum map and keep restored completed inputs from becoming rewind candidates. A pending action would still pin its partition, so later completed records would still be scanned. For comparison, #1175 captures the Fluss end offsets and then rewrites all cached states, which avoids holding the old replay start at the cost of synchronous writes on every snapshot.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in d87ea7b.

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, d87ea7b does what I had in mind: the marker now takes the completed-sequence boundaries from the keyed state captured by the same checkpoint, and latestKeySeqNum is gone.

Kafka recovery boundaries must come from the keyed state captured by the same checkpoint. Keeping a second store-local copy obscures that ownership and loses the boundary across runtime attempts.

Generated-by: GitHub Copilot CLI (gpt-6-astra)
Signed-off-by: 1fanwang <1fannnw@gmail.com>
Update cached state and offsets only after the write succeeds. Cover failed writes, restored completion boundaries, and queued-action recovery against a real Kafka broker.

Generated-by: GitHub Copilot CLI (gpt-6-astra)
Signed-off-by: 1fanwang <1fannnw@gmail.com>
@1fanwang 1fanwang changed the title [runtime][java] Replay pending Kafka action state on recovery [runtime][java] Reuse saved Kafka action results after recovery Oct 11, 2026
@github-actions github-actions Bot added doc-label-missing The Bot applies this label either because none or multiple labels were provided. and removed doc-not-needed Your PR changes do not impact docs labels Oct 11, 2026
@joeyutong

joeyutong commented Oct 12, 2026 •

Copy link
Copy Markdown
Collaborator

Thanks for addressing my feedback! LGTM from my side.

@wenjin272

Copy link
Copy Markdown
Contributor

Thanks @1fanwang for the fix and for addressing the feedback, and @rob-9, @joeyutong, and @sangkyoonnam for the thorough reviews and validation. Really appreciate everyone’s work on this!

@wenjin272
wenjin272 merged commit 84f3ab9 into apache:main Oct 12, 2026
28 checks passed
@wenjin272 wenjin272 added doc-not-needed Your PR changes do not impact docs and removed doc-label-missing The Bot applies this label either because none or multiple labels were provided. labels Oct 12, 2026
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/0.4.0 priority/major Default priority of the PR or issue.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug][runtime] Kafka recovery can skip saved results for pending actions

5 participants