-
Notifications
You must be signed in to change notification settings - Fork 15.4k
KAFKA-12226: Prevent source task offset failure when producer is overwhelmed #10112
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
Changes from 3 commits
cc59865
549fdfb
03c5a83
f13033f
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 |
|---|---|---|
|
|
@@ -98,7 +98,8 @@ class WorkerSourceTask extends WorkerTask { | |
| private IdentityHashMap<ProducerRecord<byte[], byte[]>, ProducerRecord<byte[], byte[]>> outstandingMessages; | ||
| // A second buffer is used while an offset flush is running | ||
| private IdentityHashMap<ProducerRecord<byte[], byte[]>, ProducerRecord<byte[], byte[]>> outstandingMessagesBacklog; | ||
| private boolean flushing; | ||
| private boolean recordFlushPending; | ||
| private boolean offsetFlushPending; | ||
| private CountDownLatch stopRequestedLatch; | ||
|
|
||
| private Map<String, String> taskConfig; | ||
|
|
@@ -144,7 +145,7 @@ public WorkerSourceTask(ConnectorTaskId id, | |
| this.lastSendFailed = false; | ||
| this.outstandingMessages = new IdentityHashMap<>(); | ||
| this.outstandingMessagesBacklog = new IdentityHashMap<>(); | ||
| this.flushing = false; | ||
| this.recordFlushPending = false; | ||
| this.stopRequestedLatch = new CountDownLatch(1); | ||
| this.sourceTaskMetricsGroup = new SourceTaskMetricsGroup(id, connectMetrics); | ||
| this.producerSendException = new AtomicReference<>(); | ||
|
|
@@ -335,7 +336,7 @@ private boolean sendRecords() { | |
| // messages and update the offsets. | ||
| synchronized (this) { | ||
| if (!lastSendFailed) { | ||
| if (!flushing) { | ||
| if (!recordFlushPending) { | ||
| outstandingMessages.put(producerRecord, producerRecord); | ||
| } else { | ||
| outstandingMessagesBacklog.put(producerRecord, producerRecord); | ||
|
|
@@ -453,12 +454,12 @@ private void commitTaskRecord(SourceRecord record, RecordMetadata metadata) { | |
| private synchronized void recordSent(final ProducerRecord<byte[], byte[]> record) { | ||
| ProducerRecord<byte[], byte[]> removed = outstandingMessages.remove(record); | ||
| // While flushing, we may also see callbacks for items in the backlog | ||
| if (removed == null && flushing) | ||
| if (removed == null && recordFlushPending) | ||
| removed = outstandingMessagesBacklog.remove(record); | ||
| // But if neither one had it, something is very wrong | ||
| if (removed == null) { | ||
| log.error("{} CRITICAL Saw callback for record that was not present in the outstanding message set: {}", this, record); | ||
| } else if (flushing && outstandingMessages.isEmpty()) { | ||
| } else if (recordFlushPending && outstandingMessages.isEmpty()) { | ||
| // flush thread may be waiting on the outstanding messages to clear | ||
| this.notifyAll(); | ||
| } | ||
|
|
@@ -475,11 +476,15 @@ public boolean commitOffsets() { | |
| synchronized (this) { | ||
| // First we need to make sure we snapshot everything in exactly the current state. This | ||
| // means both the current set of messages we're still waiting to finish, stored in this | ||
| // class, which setting flushing = true will handle by storing any new values into a new | ||
| // class, which setting recordFlushPending = true will handle by storing any new values into a new | ||
| // buffer; and the current set of user-specified offsets, stored in the | ||
| // OffsetStorageWriter, for which we can use beginFlush() to initiate the snapshot. | ||
| flushing = true; | ||
| boolean flushStarted = offsetWriter.beginFlush(); | ||
| // No need to begin a new offset flush if we timed out waiting for records to be flushed to | ||
| // Kafka in a prior attempt. | ||
| if (!recordFlushPending) { | ||
|
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. If I understand it correctly, the main difference in this patch is that we no longer fail the flush if the messages cannot be drained quickly enough from
Overall, I can't shake the feeling that this logic is more complicated than necessary. Why do we need the concept of flushing at all? It would be more intuitive to just commit whatever the latest offsets are. Note that we do not use
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 share your feelings about the complexity here. I think ultimately it arises from two constraints:
I don't think either of these points make it impossible to add even more-fine-grained offset commit behavior and/or remove offset commit timeouts, but the work involved would be a fair amount heavier than this relatively-minor patch. If you'd prefer to see something along those lines, could we consider merging this patch for the moment and perform a more serious overhaul of the source task offset commit logic as a follow-up, possibly with a small design discussion on a Jira ticket to make sure there's alignment on the new behavior?
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.
Ok, that rings a bell. I think I see how the logic works now and I don't see an obvious way to make it simpler. Doing something finer-grained as you said might be the way to go. Anyway, I agree this is something to save for a follow-up improvement.
Hmm.. This is suspicious. Why do we need to block the executor while we wait for the flush? Would it be simpler to let the worker source task finish the flush and the offset commit in its own event thread? We end up blocking the event thread anyway because of the need to do it under the lock.
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 think we actually keep polling the task for records during the offset commit, which is the entire reason we have the Concretely, we can see that the offset thread relinquishes the lock on the I'm not sure we need to perform offset commits on a separate thread, but it is in line with what we do for sink tasks, where we leverage the If we want to consider making offset commit synchronous (which is likely going to happen anyways when transactional writes for exactly-once source are introduced), that also might be worth a follow-up. The biggest problem I can think of with that approach would be that a single offline topic-partition would block up the entire task thread when it comes time for offset commit. If we keep the timeout for offset commit, then that'd limit the fallout and allow us to resume polling new records from the task and dispatching them to the producer after the commit attempt timed out. However, there'd still be a non-negligible throughput hit (especially for workers configured with higher offset timeouts).
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. It's mostly the flushing that concerns me, not really the offset commit. I don't think we need to make it synchronous, just that it seems silly to block that shared scheduler to complete it. My thought instead was to let the scheduler trigger the flush, but then let the task be responsible for waiting for its completion. While waiting, of course, it can continue writing to
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 been ruminating over this for a few days and I think it should be possible to make task offset commits independent of each other by changing the source task offset commit scheduler to use a multi-threaded executor instead of a global single-threaded executor for all tasks. This isn't quite the same thing as what you're proposing since tasks would still not be responsible for waiting for flush completion (the offset scheduler's threads would be), but it's a smaller change and as far as I can tell, the potential downsides only really amount to a few extra threads being created. The usage of Beyond that, the only concern that comes to mind is potential races caused by concurrent access of the offset backing store and its underlying resources. In distributed mode, the In standalone mode, the Granted, none of this addresses your original concern, which is whether an offset commit timeout is necessary at all. In response to that, I think we may also want to revisit the offset commit logic and possibly do away with a timeout altogether. In sink tasks, for example, offset commit timeouts are almost a cosmetic feature at this point and are really only useful for metrics tracking. However, at the moment it's actually been pretty useful to us to monitor source task offset commit success/failure JMX metrics as a means of tracking overall task health. We might be able to make up the difference by relying on metrics for the number of active records, but it's probably not safe to make that assumption for all users, especially for what is intended to be a bug fix. So, if possible, I'd like to leave a lot of the offset commit logic intact as it is for the moment and try to keep the changes here minimal. To summarize: I'd like to proceed by keeping the currently-proposed changes, and changing the source task offset committer to use a multi-threaded executor instead of a single-threaded executor. I can file a follow-up ticket to track improvements in offset commit logic (definitely for source tasks, and possibly for sinks) and we can look into that if it becomes a problem in the future. What do you think? |
||
| recordFlushPending = true; | ||
| offsetFlushPending = offsetWriter.beginFlush(); | ||
| } | ||
| // Still wait for any producer records to flush, even if there aren't any offsets to write | ||
| // to persistent storage | ||
|
|
||
|
|
@@ -490,7 +495,6 @@ public boolean commitOffsets() { | |
| long timeoutMs = timeout - time.milliseconds(); | ||
| if (timeoutMs <= 0) { | ||
| log.error("{} Failed to flush, timed out while waiting for producer to flush outstanding {} messages", this, outstandingMessages.size()); | ||
| finishFailedFlush(); | ||
| recordCommitFailure(time.milliseconds() - started, null); | ||
| return false; | ||
| } | ||
|
|
@@ -506,7 +510,7 @@ public boolean commitOffsets() { | |
| } | ||
| } | ||
|
|
||
| if (!flushStarted) { | ||
| if (!offsetFlushPending) { | ||
| // There was nothing in the offsets to process, but we still waited for the data in the | ||
| // buffer to flush. This is useful since this can feed into metrics to monitor, e.g. | ||
| // flush time, which can be used for monitoring even if the connector doesn't record any | ||
|
|
@@ -583,15 +587,17 @@ private synchronized void finishFailedFlush() { | |
| offsetWriter.cancelFlush(); | ||
| outstandingMessages.putAll(outstandingMessagesBacklog); | ||
| outstandingMessagesBacklog.clear(); | ||
| flushing = false; | ||
| recordFlushPending = false; | ||
| offsetFlushPending = false; | ||
| } | ||
|
|
||
| private synchronized void finishSuccessfulFlush() { | ||
| // If we were successful, we can just swap instead of replacing items back into the original map | ||
| IdentityHashMap<ProducerRecord<byte[], byte[]>, ProducerRecord<byte[], byte[]>> temp = outstandingMessages; | ||
| outstandingMessages = outstandingMessagesBacklog; | ||
| outstandingMessagesBacklog = temp; | ||
| flushing = false; | ||
| recordFlushPending = false; | ||
| offsetFlushPending = false; | ||
| } | ||
|
|
||
| @Override | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: while we're at it, this could be
final