Repository navigation
Conversation
Wait for the Kafka producer acknowledgement before flushing and caching an action state. Preserve the interrupt flag when the acknowledgement wait is interrupted, and cover asynchronous broker failures and interruption semantics. Generated-by: TraeCode (GPT-5)
sangkyoonnam
left a comment
There was a problem hiding this comment.
I'd flush before waiting on the send future. As written, a put with a small record in an otherwise idle batch waits out the 5 ms default linger.ms before the record is sent. Awaiting the record's future is the right fix otherwise: it matches FlussActionStateStore.put, which calls writer.append(row).get() before caching, and both new tests fail when I revert only KafkaActionStateStore.java (2 failures in KafkaActionStateStoreTest, 51/51 with the change). Separately, a non-blocking suggestion on the two new Markdown files. Happy to rerun the timing on the revised branch.
| producer.send(kafkaRecord).get(); | ||
| producer.flush(); |
There was a problem hiding this comment.
For small records in otherwise idle batches, waiting on get() before flush() adds roughly the default linger delay. Puts are sequential, so the next put can't fill the batch while this one waits. kafka-clients 4.0 changed the linger.ms default from 0 to 5 ms, and createProducerProp() doesn't set it. The existing flush() is what bypassed linger. After get() returns, it no longer helps this record. FlussActionStateStore sets its writer batch timeout to zero for the same reason (L156-L158).
I timed the three orderings with a plain KafkaProducer (kafka-clients 4.0.0, acks=all, retries=3, string payloads) against a local apache/kafka-native:3.8.0 container. 200 sequential sends per ordering, second of two rounds, ms per send iteration:
main send; flush 0.50
PR send.get; flush 6.88
alt send; flush; get 0.84
A fresh action writes initial and completion state, and durable calls can add more synchronous writes, so the delay repeats per action. Flushing first bypasses linger, and get() still checks the record's result before the cache insert:
Future<RecordMetadata> ack = producer.send(kafkaRecord);
producer.flush();
ack.get();
actionStates.put(stateKey, state);Setting linger.ms to 0 in createProducerProp() would also work and keeps your order. With the snippet, the broker-failure test still passes. The interrupt test fails only on assertThat(flushCalled).isFalse() at L277; flipping it to isTrue() keeps the ordering pinned. An interrupt while blocked in flush() produces Kafka's InterruptException wrapped in the generic RuntimeException, with the flag restored. That matches main today. Surfacing InterruptedException from that path would need a catch that translates Kafka's exception. I haven't tested a real interrupt. The flow and interaction table in the PR description would need the same update.
There was a problem hiding this comment.
Non-blocking. These two files repeat what the PR description already says, and nothing in the repo links to them. I'd remove them. If the contract needs a permanent home, the Javadoc on KafkaActionStateStore.put keeps it next to the code: for an initialized store, a normal return means the record was acknowledged and cached.
|
Thanks for the fix! The new tests correctly catch the previously ignored send failure. I agree with @sangkyoonnam that we should flush before waiting on the send future, so each synchronous state write avoids Kafka 4.0’s default linger delay: Future<RecordMetadata> ack = producer.send(kafkaRecord);
producer.flush();
ack.get();
actionStates.put(stateKey, state);Could we also move the relevant contract into |
Reorder the write so the record is sent, the producer is flushed, and only then is the send future awaited. Waiting on the future before flushing would block each synchronous write for the producer's linger.ms (5 ms by default since kafka-clients 4.0); flushing first bypasses linger while get() still surfaces record-level asynchronous broker failures before the attempt-local cache is updated. Move the write contract onto KafkaActionStateStore.put(...) and drop the ad-hoc runtime/API.md and runtime/Method.md. Reword the failure contract to state that the update is not added to the cache instead of promising the cache stays unchanged, and flip the interrupt test's flush assertion to pin the new ordering. Generated-by: Codex CLI 0.159.0 (GPT-5)
|
Thanks @sangkyoonnam and @wenjin272 — agreed on all three points, and the timing data is convincing. Addressed in b66fd26. Ordering. Reordered to Interrupts. Flipped the interrupt test's Docs. Removed The PR description's flow and interaction table are updated to match the new order. |
Wrap the new put(...) Javadoc so google-java-format (AOSP) accepts it, fixing the Code Style Check failure. Generated-by: Codex CLI 0.159.0 (GPT-5)
sangkyoonnam
left a comment
There was a problem hiding this comment.
The send, flush, then ack.get() order and the interrupt handling look right to me, and KafkaActionStateStoreTest passes at 7385011 (51 tests). One wording nit on the put() Javadoc, inline.
| * <p>The state is added to the attempt-local cache only after both the flush and the | ||
| * acknowledgement succeed. If the send, the acknowledgement, or the flush fails, the state is | ||
| * not cached and a {@link RuntimeException} wrapping the cause is thrown. If the | ||
| * acknowledgement wait is interrupted, {@link InterruptedException} is rethrown with the | ||
| * thread's interrupt flag restored. |
There was a problem hiding this comment.
"The state is not cached" can still read as a claim about what the cache holds, which is the concern wenjin272 raised. DurableExecutionManager.maybePersistTaskResult takes the cached ActionState, marks it completed, and passes that same object to put(). If that put() fails, the cache still holds the state, already marked completed. Describing only what put() does avoids that:
| * <p>The state is added to the attempt-local cache only after both the flush and the | |
| * acknowledgement succeed. If the send, the acknowledgement, or the flush fails, the state is | |
| * not cached and a {@link RuntimeException} wrapping the cause is thrown. If the | |
| * acknowledgement wait is interrupted, {@link InterruptedException} is rethrown with the | |
| * thread's interrupt flag restored. | |
| * <p>{@code put()} adds the state to the attempt-local cache only after both the flush and the | |
| * acknowledgement succeed. If the send, the flush, or the acknowledgement fails, {@code put()} | |
| * does not add the state and throws a {@link RuntimeException} wrapping the cause. If the | |
| * acknowledgement wait is interrupted, {@link InterruptedException} is rethrown with the | |
| * thread's interrupt flag restored. |
I applied it locally and tools/lint.sh -c passes on JDK 11.
Linked issue: #1173
Purpose of change
A caller no longer receives a successful return from
KafkaActionStateStore.put(...)when Kafkalater rejects the state record. The asynchronous broker failure now fails the task before the
update is added to the attempt-local cache, preventing a checkpoint from treating an action-state
update as durable when its Kafka record was never acknowledged.
Runtime flow:
put(...)encodes the composite state key and sends theProducerRecord.synchronous write is not delayed by the producer's
linger.ms.Future<RecordMetadata>returned by that specificsend()call. The wait isstill needed because
flush()can return normally while the per-record future retains anasynchronous failure.
return successfully.
Key decisions
linger.ms(5 ms by default since kafka-clients 4.0). The wait is still performed afterwardsbecause Kafka's
MockProducerdemonstrates thatflush()can return normally while theper-record future retains an asynchronous failure.
acknowledgement and flush completed.
InterruptedExceptionwith the thread's interrupt flag restored, rather than wrappingit as a generic Kafka persistence failure. An interrupt raised while blocked in
flush()stillsurfaces as Kafka's
InterruptExceptionwrapped in the genericRuntimeExceptionwith the flagrestored, matching the previous behavior.
wait observable; it does not change the configured producer behavior.
Interaction decisions
send()throws synchronouslyRuntimeException; the state is not added to the cacheflush()failsRuntimeException; the state is not added to the cacheRuntimeExceptionwith the broker failure as root cause; the state is not added to the cacheInterruptedException; interrupt flag restored; the state is not added to the cacheput(...)returns normallyBehavioral contracts
put(...)means the specific record was acknowledged, the producer was flushed,and the attempt-local cache contains the state for this update.
the attempt-local cache.
Thread.currentThread().isInterrupted()andthrows
InterruptedException.makes no claim about the rest of the cache, which callers can mutate through previously cached
ActionStatereferences.Failure behavior
Kafka send and flush exceptions are surfaced as
RuntimeException("Failed to send action state to Kafka", cause). Interruption is not wrapped:it is re-thrown with the interrupt status preserved. A null producer retains the pre-existing
behavior of logging and returning.
Tests
put(...)and does not cache the statetestPutPropagatesAsyncBrokerFailureBeforeCachingStatetestPutPreservesInterruptWhileWaitingForBrokerAcknowledgementtestPutActionStateand the existingKafkaActionStateStoreTestsuiteLocal verification completed on the branch:
KafkaActionStateStoreTest(51/51), repositorylint/Spotless, and Apache RAT all pass.
Not verified: live-broker failover/recovery and a never-completing broker acknowledgement. The
regression uses
MockProducerto make the record-level failure deterministic; no acknowledgementtimeout is introduced by this change.
Implementation invariants and supporting evidence
actionStates.put(...)is reached only afterproducer.flush()andfuture.get()completesuccessfully.
InterruptedExceptioncatch restores the flag before re-throwing the same exception.ActionStateStore.put(...)signature already declaresthrows Exception, so nopublic interface change is required.
API
No public API signature, event schema, checkpoint format, or configuration change is introduced.
The caller-visible behavior change is limited to propagating failures that were previously hidden
by ignoring the send future.
Documentation
doc-neededdoc-not-neededdoc-includedWas this patch authored or co-authored using generative AI tooling?
Generated-by: TraeCode (GPT-5)
Generated-by: Codex CLI 0.159.0 (GPT-5)