-
Notifications
You must be signed in to change notification settings - Fork 15.4k
KAFKA-13469: Block for in-flight record delivery before end-of-life source task offset commit #11524
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
KAFKA-13469: Block for in-flight record delivery before end-of-life source task offset commit #11524
Changes from 1 commit
c7454b6
96ce342
e1a711a
4197592
1f17249
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 | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -26,6 +26,10 @@ | |||||||||||||||||||||
| import java.util.HashMap; | ||||||||||||||||||||||
| import java.util.LinkedList; | ||||||||||||||||||||||
| import java.util.Map; | ||||||||||||||||||||||
| import java.util.concurrent.CountDownLatch; | ||||||||||||||||||||||
| import java.util.concurrent.TimeUnit; | ||||||||||||||||||||||
| import java.util.concurrent.atomic.AtomicBoolean; | ||||||||||||||||||||||
| import java.util.concurrent.atomic.AtomicInteger; | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| /** | ||||||||||||||||||||||
| * Used to track source records that have been (or are about to be) dispatched to a producer and their accompanying | ||||||||||||||||||||||
|
|
@@ -42,9 +46,12 @@ class SubmittedRecords { | |||||||||||||||||||||
|
|
||||||||||||||||||||||
| // Visible for testing | ||||||||||||||||||||||
| final Map<Map<String, Object>, Deque<SubmittedRecord>> records; | ||||||||||||||||||||||
| private AtomicInteger numUnackedMessages; | ||||||||||||||||||||||
| private CountDownLatch messageDrainLatch; | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| public SubmittedRecords() { | ||||||||||||||||||||||
| this.records = new HashMap<>(); | ||||||||||||||||||||||
| this.numUnackedMessages = new AtomicInteger(0); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| /** | ||||||||||||||||||||||
|
|
@@ -68,6 +75,9 @@ SubmittedRecord submit(Map<String, Object> partition, Map<String, Object> offset | |||||||||||||||||||||
| SubmittedRecord result = new SubmittedRecord(partition, offset); | ||||||||||||||||||||||
| records.computeIfAbsent(result.partition(), p -> new LinkedList<>()) | ||||||||||||||||||||||
| .add(result); | ||||||||||||||||||||||
| synchronized (this) { | ||||||||||||||||||||||
| numUnackedMessages.incrementAndGet(); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
C0urante marked this conversation as resolved.
|
||||||||||||||||||||||
| return result; | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
|
|
@@ -89,7 +99,9 @@ public boolean removeLastOccurrence(SubmittedRecord record) { | |||||||||||||||||||||
| if (deque.isEmpty()) { | ||||||||||||||||||||||
| records.remove(record.partition()); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| if (!result) { | ||||||||||||||||||||||
| if (result) { | ||||||||||||||||||||||
| messageAcked(); | ||||||||||||||||||||||
| } else { | ||||||||||||||||||||||
| log.warn("Attempted to remove record from submitted queue for partition {}, but the record has not been submitted or has already been removed", record.partition()); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| return result; | ||||||||||||||||||||||
|
|
@@ -132,6 +144,27 @@ public CommittableOffsets committableOffsets() { | |||||||||||||||||||||
| return new CommittableOffsets(offsets, totalCommittableMessages, totalUncommittableMessages, records.size(), largestDequeSize, largestDequePartition); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| /** | ||||||||||||||||||||||
| * Wait for all currently in-flight messages to be acknowledged, up to the requested timeout. | ||||||||||||||||||||||
|
C0urante marked this conversation as resolved.
|
||||||||||||||||||||||
| * @param timeout the maximum time to wait | ||||||||||||||||||||||
| * @param timeUnit the time unit of the timeout argument | ||||||||||||||||||||||
| * @return whether all in-flight messages were acknowledged before the timeout elapsed | ||||||||||||||||||||||
| */ | ||||||||||||||||||||||
| public boolean awaitAllMessages(long timeout, TimeUnit timeUnit) { | ||||||||||||||||||||||
|
rhauch marked this conversation as resolved.
|
||||||||||||||||||||||
| // Create a new message drain latch as a local variable to avoid SpotBugs warnings about inconsistent synchronization | ||||||||||||||||||||||
| // on an instance variable when invoking CountDownLatch::await outside a synchronized block | ||||||||||||||||||||||
| CountDownLatch messageDrainLatch; | ||||||||||||||||||||||
| synchronized (this) { | ||||||||||||||||||||||
| messageDrainLatch = new CountDownLatch(numUnackedMessages.get()); | ||||||||||||||||||||||
| this.messageDrainLatch = messageDrainLatch; | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
Comment on lines
+154
to
+158
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. Could this code be simplified and the synchronized block removed altogether by changing the and then changing these lines above to:
Suggested change
This synchronized block ensures that the latch is initialized sized with the number of unacked messages at the time this method is called but does not prevent new messages from being added. Using concurrent types for the two fields solves the first issue, while the second is prevented by having the And calling out that last assumption would be good. Might be as simple as adding the following to the You'd also have to change the and remove the There are a few reasons why this might be better:
Contributor
Author
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. Thanks Randall. I agree that the synchronization here is, even if necessary, inelegant, and I hope that we can improve things. But I'm worried that the proposal here may be prone to a race condition. Imagine we restructure the code with your suggestions and the result is this: class SubmittedRecords {
private final AtomicReference messageDrainLatch = new AtomicReference<>();
private boolean awaitAllMessages(long timeout, TimeUnit timeUnit) {
// (2)
CountDownLatch messageDrainLatch = this.messageDrainLatch.updateAndGet(existing -> new CountDownLatch(numUnackedMessages.get()));
try {
return messageDrainLatch.await(timeout, timeUnit);
} catch (InterruptedException e) {
return false;
}
}
private void messageAcked() {
// (1)
numUnackedMessages.decrementAndGet();
// (3)
CountDownLatch messageDrainLatch = this.messageDrainLatch.get();
if (messageDrainLatch != null) {
messageDrainLatch.countDown();
}
}
}Isn't it still possible that the lines marked FWIW, I originally used a
Contributor
Author
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. I've just pushed an update that simplifies things a little bit but still utilizes synchronized blocks. I realized that, since we're synchronizing around every read of the |
||||||||||||||||||||||
| try { | ||||||||||||||||||||||
| return messageDrainLatch.await(timeout, timeUnit); | ||||||||||||||||||||||
| } catch (InterruptedException e) { | ||||||||||||||||||||||
| return false; | ||||||||||||||||||||||
|
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. Should this clear the interrupted flag before returning, since we're handling the interruption here?
Contributor
Author
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. I believe this is already accomplished. According to the Javadocs for
|
||||||||||||||||||||||
| } | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| // Note that this will return null if either there are no committable offsets for the given deque, or the latest | ||||||||||||||||||||||
| // committable offset is itself null. The caller is responsible for distinguishing between the two cases. | ||||||||||||||||||||||
| private Map<String, Object> committableOffset(Deque<SubmittedRecord> queuedRecords) { | ||||||||||||||||||||||
|
|
@@ -146,27 +179,36 @@ private boolean canCommitHead(Deque<SubmittedRecord> queuedRecords) { | |||||||||||||||||||||
| return queuedRecords.peek() != null && queuedRecords.peek().acked(); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| static class SubmittedRecord { | ||||||||||||||||||||||
| private synchronized void messageAcked() { | ||||||||||||||||||||||
| numUnackedMessages.decrementAndGet(); | ||||||||||||||||||||||
| if (messageDrainLatch != null) { | ||||||||||||||||||||||
| messageDrainLatch.countDown(); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| class SubmittedRecord { | ||||||||||||||||||||||
| private final Map<String, Object> partition; | ||||||||||||||||||||||
| private final Map<String, Object> offset; | ||||||||||||||||||||||
| private volatile boolean acked; | ||||||||||||||||||||||
| private final AtomicBoolean acked; | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| public SubmittedRecord(Map<String, Object> partition, Map<String, Object> offset) { | ||||||||||||||||||||||
| this.partition = partition; | ||||||||||||||||||||||
| this.offset = offset; | ||||||||||||||||||||||
| this.acked = false; | ||||||||||||||||||||||
| this.acked = new AtomicBoolean(false); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| /** | ||||||||||||||||||||||
| * Acknowledge this record; signals that its offset may be safely committed. | ||||||||||||||||||||||
| * This is safe to be called from a different thread than what called {@link SubmittedRecords#submit(SourceRecord)}. | ||||||||||||||||||||||
| */ | ||||||||||||||||||||||
| public void ack() { | ||||||||||||||||||||||
| this.acked = true; | ||||||||||||||||||||||
| if (this.acked.compareAndSet(false, true)) { | ||||||||||||||||||||||
| messageAcked(); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
Comment on lines
+206
to
+208
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. Nice simplification. |
||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| private boolean acked() { | ||||||||||||||||||||||
| return acked; | ||||||||||||||||||||||
| return acked.get(); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| private Map<String, Object> partition() { | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -65,7 +65,6 @@ | |
| import java.util.concurrent.TimeoutException; | ||
| import java.util.concurrent.atomic.AtomicReference; | ||
|
|
||
| import static org.apache.kafka.connect.runtime.SubmittedRecords.SubmittedRecord; | ||
| import static org.apache.kafka.connect.runtime.SubmittedRecords.CommittableOffsets; | ||
| import static org.apache.kafka.connect.runtime.WorkerConfig.TOPIC_TRACKING_ENABLE_CONFIG; | ||
|
|
||
|
|
@@ -260,6 +259,10 @@ public void execute() { | |
| } catch (InterruptedException e) { | ||
| // Ignore and allow to exit. | ||
| } finally { | ||
| submittedRecords.awaitAllMessages( | ||
| workerConfig.getLong(WorkerConfig.OFFSET_COMMIT_TIMEOUT_MS_CONFIG), | ||
|
Contributor
Author
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. Waiting for up to I initially considered an approach where we would require the entire combination of (awaiting in-flight messages, computing committable offsets, committing offsets to backing store) to complete in We could also consider blocking for at most half of
But I don't know if that's really necessary since in this case, these offsets have no chance of being committed in a future attempt (at least, not by the same task instance), and the offset commit is taking place on the task's work thread instead of the source task offset commit thread, so there's no worry about squatting on that thread and blocking other tasks from being able to commit offsets while it's in use.
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. Yeah, I think I agree. The previous behavior called |
||
| TimeUnit.MILLISECONDS | ||
| ); | ||
| // It should still be safe to commit offsets since any exception would have | ||
| // simply resulted in not getting more records but all the existing records should be ok to flush | ||
| // and commit offsets. Worst case, task.flush() will also throw an exception causing the offset commit | ||
|
|
@@ -356,7 +359,7 @@ private boolean sendRecords() { | |
| } | ||
|
|
||
| log.trace("{} Appending record to the topic {} with key {}, value {}", this, record.topic(), record.key(), record.value()); | ||
| SubmittedRecord submittedRecord = submittedRecords.submit(record); | ||
| SubmittedRecords.SubmittedRecord submittedRecord = submittedRecords.submit(record); | ||
| try { | ||
| maybeCreateTopic(record.topic()); | ||
| final String topic = producerRecord.topic(); | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.