Skip to content

Commit 0ba99b9

Browse files
committed
[LI-HOTFIX] Fix flush behavior
TICKET = KAFKA-8021 LI_DESCRIPTION = kafkaProducer#flush() api is expected to evaluate all record futures previously obtained via kafkaProducer#send(). The record futures can either be successful or may throw exception depending on the produce response by the broker but nonetheless, they should all be evaluated. This behavior is broken when a produce batch is split into smaller batches(because of RecordTooLargeException) and rescheduled to be sent to the broker. That is, it can so happen that after successfully executing #flush() api, there may exist few record futures which are not evaluated. This change checks if any of the produce batch failed with RecordTooLargeException and in such a case, retries flush operation of all incomplete batches. Note that, Although this change does not break the #flush() api contract, it has a behavior change in that more batches may get flushed than before. Rejected alternative: The other alternative was to chain the produce batch future. This approach fails to evaluate record futures as soon as their corresponding produce batch futures are evaluated. It also introduces a erroneous scenario where if one of the split batches are evaluated and the other split batch failed with an exception, we end up failing all record futures including the ones which belonged to the successful split batch. EXIT_CRITERIA = MANUAL ["When the hotfix is pushed to apache/kafka"]
1 parent eae1c4c commit 0ba99b9

2 files changed

Lines changed: 80 additions & 9 deletions

File tree

clients/src/main/java/org/apache/kafka/clients/producer/internals/RecordAccumulator.java

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
import org.apache.kafka.common.Node;
3838
import org.apache.kafka.common.PartitionInfo;
3939
import org.apache.kafka.common.TopicPartition;
40+
import org.apache.kafka.common.errors.RecordBatchTooLargeException;
4041
import org.apache.kafka.common.errors.TimeoutException;
4142
import org.apache.kafka.common.errors.UnsupportedVersionException;
4243
import org.apache.kafka.common.header.Header;
@@ -667,6 +668,13 @@ boolean flushInProgress() {
667668
return flushesInProgress.get() > 0;
668669
}
669670

671+
/**
672+
* This method should be used only for testing.
673+
*/
674+
IncompleteBatches incompleteBatches() {
675+
return incomplete;
676+
}
677+
670678
/* Visible for testing */
671679
Map<TopicPartition, Deque<ProducerBatch>> batches() {
672680
return Collections.unmodifiableMap(batches);
@@ -691,17 +699,29 @@ private boolean appendsInProgress() {
691699
*/
692700
public void awaitFlushCompletion(long timeoutMs) throws InterruptedException {
693701
try {
702+
boolean retry;
694703
Long expireMs = System.currentTimeMillis() + timeoutMs;
695-
for (ProducerBatch batch : this.incomplete.copyAll()) {
696-
Long currentMs = System.currentTimeMillis();
697-
if (currentMs > expireMs) {
698-
throw new TimeoutException("Failed to flush accumulated records within" + timeoutMs + "milliseconds.");
699-
}
700-
boolean completed = batch.produceFuture.await(Math.max(expireMs - currentMs, 0), TimeUnit.MILLISECONDS);
701-
if (!completed) {
702-
throw new TimeoutException("Failed to flush accumulated records within" + timeoutMs + "milliseconds.");
704+
do {
705+
retry = false;
706+
for (ProducerBatch batch : this.incomplete.copyAll()) {
707+
Long currentMs = System.currentTimeMillis();
708+
if (currentMs > expireMs) {
709+
throw new TimeoutException("Failed to flush accumulated records within" + timeoutMs + "milliseconds.");
710+
}
711+
boolean completed = batch.produceFuture.await(Math.max(expireMs - currentMs, 0), TimeUnit.MILLISECONDS);
712+
if (!completed) {
713+
throw new TimeoutException("Failed to flush accumulated records within" + timeoutMs + "milliseconds.");
714+
}
715+
// If the produceFuture failed with RecordBatchTooLargeException, it means that the
716+
// batch was split into smaller batches and re-enqueued into the RecordAccumulator by Sender thread.
717+
// This if condition will make sure to retry and send all the split batches.
718+
// Note that, More records get sent to the broker than necessary because the retry mechanism
719+
// will also include all the newly added records via kafkaProducer.send() api.
720+
if (batch.produceFuture.error() instanceof RecordBatchTooLargeException) {
721+
retry = true;
722+
}
703723
}
704-
}
724+
} while (retry);
705725
} finally {
706726
this.flushesInProgress.decrementAndGet();
707727
}

clients/src/test/java/org/apache/kafka/clients/producer/internals/RecordAccumulatorTest.java

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616
*/
1717
package org.apache.kafka.clients.producer.internals;
1818

19+
import java.util.concurrent.CountDownLatch;
20+
import java.util.concurrent.atomic.AtomicBoolean;
1921
import org.apache.kafka.clients.ApiVersions;
2022
import org.apache.kafka.clients.NodeApiVersions;
2123
import org.apache.kafka.clients.producer.Callback;
@@ -44,6 +46,7 @@
4446
import org.apache.kafka.common.utils.Time;
4547
import org.apache.kafka.test.TestUtils;
4648
import org.junit.After;
49+
import org.junit.Assert;
4750
import org.junit.Test;
4851

4952
import java.nio.ByteBuffer;
@@ -417,6 +420,54 @@ public void run() {
417420
t.start();
418421
}
419422

423+
@Test
424+
public void testSplitAwaitFlushComplete() throws Exception {
425+
RecordAccumulator accum = createTestRecordAccumulator(1024, 10 * 1024, CompressionType.GZIP, 10);
426+
427+
// Create a big batch
428+
byte[] value = new byte[256];
429+
// Create a batch such that it fails with RecordBatchTooLargeException
430+
accum.append(new TopicPartition(topic, 0), 0L, null, value, null, null, maxBlockTimeMs);
431+
accum.append(new TopicPartition(topic, 0), 0L, null, value, null, null, maxBlockTimeMs);
432+
433+
CountDownLatch flushInProgress = new CountDownLatch(1);
434+
Iterator<ProducerBatch> incompleteBatches = accum.incompleteBatches().copyAll().iterator();
435+
436+
// Assert that there is only one batch
437+
Assert.assertTrue(incompleteBatches.hasNext());
438+
ProducerBatch producerBatch = incompleteBatches.next();
439+
Assert.assertFalse(incompleteBatches.hasNext());
440+
441+
AtomicBoolean timedOut = new AtomicBoolean(false);
442+
Thread thread = new Thread(() -> {
443+
Assert.assertTrue(accum.hasIncomplete());
444+
accum.beginFlush();
445+
Assert.assertTrue(accum.flushInProgress());
446+
try {
447+
flushInProgress.countDown();
448+
accum.awaitFlushCompletion(2000);
449+
} catch (TimeoutException timeoutException) {
450+
// Catch it and set the timedout variable
451+
// This is the only valid path for this thread.
452+
timedOut.set(true);
453+
} catch (InterruptedException e) {
454+
}
455+
});
456+
thread.start();
457+
flushInProgress.await();
458+
// Wait for 100ms to make sure that the flush is actually in progress
459+
Thread.sleep(100);
460+
461+
// Split the big batch and re-enqueue
462+
accum.splitAndReenqueue(producerBatch);
463+
accum.deallocate(producerBatch);
464+
465+
thread.join();
466+
// The thread would have failed with timeout exception because the child batches
467+
// are not evaluated and it would have waited for 2seconds before the timeout.
468+
Assert.assertTrue("The thread should have timed out", timedOut.get());
469+
}
470+
420471
@Test
421472
public void testAwaitFlushComplete() throws Exception {
422473
RecordAccumulator accum = createTestRecordAccumulator(

0 commit comments

Comments
 (0)