[ 
https://issues.apache.org/jira/browse/KAFKA-21068?page=com.atlassian.jira.plugin.system.issuetabpanels:all-tabpanel
 ]

Lucas Bradstreet updated KAFKA-21068:
-------------------------------------
    Description: 
[note: some edited/reduced LLM output below, human verified]
h4. Summary

The java producer can delay a record that already exceeds batch.size because 
its buffer still has a few unused bytes.
 
*Example:*

With a 16 KiB batch size and 60-second linger, a single uncompressed 1 MiB 
record waits 60 seconds. Producing a 100-byte second record to the same 
partition after 20 seconds causes the first record to sent immediately.
h4. Explanation

A producer record substantially larger than `batch.size` can remain in an 
unready batch until `linger.ms` expires, even when the leader is known and 
there is no backoff, transaction fence, or buffer pressure.

Another second record that fails to append to the same batch (due to lack of 
room) can make the first batch ready earlier and sendable. When a record 
exceeds batch.size, Java allocates a larger buffer to hold it. The problem is 
that Java then uses the larger buffer’s size as the new batching target. Even a 
few unused bytes can make Java keep waiting, although the record has already 
exceeded the configured batch size.

The buffer includes a little extra space to be safe. Even a few unused bytes 
can therefore make Java keep waiting, although the batch is already much larger 
than the target.

{*}{{*}}Compression is not required to trigger this issue.{{*}}{*} The simplest 
reproduction below uses `Compression.NONE`. Compression estimates can change 
whether the batch is considered full; the control results show both outcomes.

In a deterministic uncompressed reproduction with `batch.size=16384` and a 1 
MiB value:

```text
Configured batch target:       16,384 bytes
Allocated capacity:         1,048,664 bytes
Encoded/estimated size:      1,048,650 bytes
Remaining allocation space:        14 bytes
Batch isFull():                    false
Room for another 1 MiB record:     false
```

With `linger.ms=1000`, this batch is unready at 0 and 999 ms, and becomes ready 
at 1000 ms without another record. A second 1 MiB append to the same partition 
makes it ready at time zero. With `linger.ms=0`, it is ready immediately.
h4. Unit test of behavior (failure expected)

Apply the following test-only diff to Kafka's existing producer 
`RecordAccumulatorTest`. The record is explicitly assigned to one partition to 
isolate the batching decision from sticky partition selection.

The test appends one {*}{{*}}uncompressed 1 MiB value{{*}}{*} with 
`batch.size=16384`, `linger.ms=1000`, and an 8 MiB buffer pool. It checks that 
the batch already exceeds the target, then asserts that its leader is ready 
immediately. There is no second append or clock advance.

```diff
diff --git 
a/clients/src/test/java/org/apache/kafka/clients/producer/internals/RecordAccumulatorTest.java
 
b/clients/src/test/java/org/apache/kafka/clients/producer/internals/RecordAccumulatorTest.java
index 7890a5474b..075f3f9cf1 100644
— 
a/clients/src/test/java/org/apache/kafka/clients/producer/internals/RecordAccumulatorTest.java
+++ 
b/clients/src/test/java/org/apache/kafka/clients/producer/internals/RecordAccumulatorTest.java
@@ -276,6 +276,28 @@ public class RecordAccumulatorTest

{          testAppendLarge(Compression.NONE);      }

+    @Test
+    public void testOversizedRecordIsReadyWithoutWaitingForLinger() throws 
Exception {
+        int batchSize = 16 * 1024;
+        int lingerMs = 1000;
+        RecordAccumulator accum = createTestRecordAccumulator(
+            batchSize, 8 * 1024 * 1024, Compression.NONE, lingerMs);
+        try

{ +            long now = time.milliseconds(); +            accum.append(topic, 
partition1, 0L, null, new byte[1024 * 1024], +                
Record.EMPTY_HEADERS, null, maxBlockTimeMs, now, cluster); + +            
assertEquals(1, accum.getDeque(tp1).size()); +            
assertTrue(accum.getDeque(tp1).peekFirst().estimatedSizeInBytes() > batchSize); 
+            // No clock advance, second append, flush, or buffer pressure is 
needed. +            assertEquals(Collections.singleton(node1), 
accum.ready(metadataCache, now).readyNodes, +                "A batch already 
larger than batch.size should not wait for linger"); +        }

finally

{ +            accum.abortIncompleteBatches(); +            accum.close(); +    
    }

+    }
+
     private void testAppendLarge(Compression compression) throws Exception {
         int batchSize = 512;
         byte[] value = new byte[2 * batchSize];
```

The same diff is attached as `RecordAccumulatorTest.patch`. From the Kafka 
repository root, run:

```sh
git apply RecordAccumulatorTest.patch
./gradlew :clients:test \
  --tests 
org.apache.kafka.clients.producer.internals.RecordAccumulatorTest.testOversizedRecordIsReadyWithoutWaitingForLinger
```

This behavior reproduced against two real Kafka brokers with an uncompressed 1 
MiB record, a 16 KiB batch size, and 60-second linger. Sending only that record 
resulted in an acknowledgement after 60.011 seconds. In a separate run, the 
record remained unsent for 20 seconds; appending a 100-byte second record then 
triggered delivery of the original record about 8 ms later, without a flush or 
close. With linger set to zero, the same 1 MiB record was acknowledged in 124 
ms.
 # 
 ## 
 ### Observed results

|Codec / estimate state|Linger|Ready after first record at 0 ms?|Ready after 
next 1 MiB same-partition append at 0 ms?|Ready at linger deadline without 
another append?|
|—|---:|—|—|—|
|None|1000 ms|{*}{{*}}No{{*}}{*}|Yes|Yes; not ready at 999 ms|
|None|0 ms|Yes|Not needed|Yes|
|Gzip, initial estimate|1000 ms|Yes|Already ready|Already ready|
|Zstd, initial estimate|1000 ms|Yes|Already ready|Already ready|
|Gzip, explicit estimate 0.1|1000 ms|{*}{{*}}No{{*}}{*}|Yes|Yes; not ready at 
999 ms|
|Zstd, explicit estimate 0.1|1000 ms|{*}{{*}}No{{*}}{*}|Yes|Yes; not ready at 
999 ms|

The 0.1 compression estimate is injected with the existing 
`CompressionRatioEstimator.setEstimation` test helper. It represents a 
controlled already-learned state; no particular warmup workload is claimed to 
have learned that ratio. Initial gzip/zstd estimates produce 1,101,079 
estimated bytes, which exceed the allocated capacity and therefore do not 
reproduce the delayed-readiness case.
h4. Historical evidence
 - {*}{{*}}0.10.2.0:{{*}}{*} the accumulator explicitly passed configured 
`batchSize` as the builder's write limit, separately from buffer allocation 
capacity. [Release 
source]([https://github.com/apache/kafka/blob/0.10.2.0/clients/src/main/java/org/apache/kafka/clients/producer/internals/RecordAccumulator.java])
 - {*}{{*}}24 March 2017:{{*}}{*} [KAFKA-4816 commit 
`5bd06f1d542`]([https://github.com/apache/kafka/commit/5bd06f1d542e6b588a1d402d059bc24690017d32])
 introduced the v2 format and conservative sizing, and changed the builder 
overload used by the accumulator to one that defaults its write limit to buffer 
capacity. The old final argument `this.batchSize` now represented a base offset 
rather than a write limit.
 - {*}{{*}}3 April 2017:{{*}}{*} [commit 
`f54b61909d5`]([https://github.com/apache/kafka/commit/f54b61909d525547d65123c02bbd36d92ccee5da])
 corrected that accidental base offset to `0L`; it did not restore the separate 
configured write limit.
 - {*}{{*}}0.11.0.0, released 28 June 2017:{{*}}{*} the released source 
contains the complete oversized-allocation/fullness/linger mechanism. [Release 
archive]([https://kafka.apache.org/community/downloads/#0.11.0.0])
 - Sticky partitioning arrived later through 
[KIP-480]([https://cwiki.apache.org/confluence/spaces/KAFKA/pages/120722025/KIP-480%2BSticky%2BPartitioner]),
 in Kafka 2.4.0. This behavior therefore predates sticky partitioning.

  was:
[note: some edited/reduced LLM output below, human verified]
h4. Summary
The java producer can delay a record that already exceeds batch.size because 
its buffer still has a few unused bytes.
 
Example:With a 16 KiB batch size and 60-second linger, a single uncompressed 1 
MiB record waits 60 seconds. Sending a 100-byte second record to the same 
partition after 20 seconds causes the first record to sent immediately.
h4. Explanation

A producer record substantially larger than `batch.size` can remain in an 
unready batch until `linger.ms` expires, even when the leader is known and 
there is no backoff, transaction fence, or buffer pressure.

Another second record that fails to append to the same batch (due to lack of 
room) can make the first batch ready earlier and sendable. When a record 
exceeds batch.size, Java allocates a larger buffer to hold it. The problem is 
that Java then uses the larger buffer’s size as the new batching target. Even a 
few unused bytes can make Java keep waiting, although the record has already 
exceeded the configured batch size.

The buffer includes a little extra space to be safe. Even a few unused bytes 
can therefore make Java keep waiting, although the batch is already much larger 
than the target.

{*}{{*}}Compression is not required to trigger this issue.{{*}}{*} The simplest 
reproduction below uses `Compression.NONE`. Compression estimates can change 
whether the batch is considered full; the control results show both outcomes.

In a deterministic uncompressed reproduction with `batch.size=16384` and a 1 
MiB value:

```text
Configured batch target:       16,384 bytes
Allocated capacity:         1,048,664 bytes
Encoded/estimated size:      1,048,650 bytes
Remaining allocation space:        14 bytes
Batch isFull():                    false
Room for another 1 MiB record:     false
```

With `linger.ms=1000`, this batch is unready at 0 and 999 ms, and becomes ready 
at 1000 ms without another record. A second 1 MiB append to the same partition 
makes it ready at time zero. With `linger.ms=0`, it is ready immediately.
h4. Unit test of behavior (failure expected)

Apply the following test-only diff to Kafka's existing producer 
`RecordAccumulatorTest`. The record is explicitly assigned to one partition to 
isolate the batching decision from sticky partition selection.

The test appends one {*}{{*}}uncompressed 1 MiB value{{*}}{*} with 
`batch.size=16384`, `linger.ms=1000`, and an 8 MiB buffer pool. It checks that 
the batch already exceeds the target, then asserts that its leader is ready 
immediately. There is no second append or clock advance.

```diff
diff --git 
a/clients/src/test/java/org/apache/kafka/clients/producer/internals/RecordAccumulatorTest.java
 
b/clients/src/test/java/org/apache/kafka/clients/producer/internals/RecordAccumulatorTest.java
index 7890a5474b..075f3f9cf1 100644
— 
a/clients/src/test/java/org/apache/kafka/clients/producer/internals/RecordAccumulatorTest.java
+++ 
b/clients/src/test/java/org/apache/kafka/clients/producer/internals/RecordAccumulatorTest.java
@@ -276,6 +276,28 @@ public class RecordAccumulatorTest

{          testAppendLarge(Compression.NONE);      }

+    @Test
+    public void testOversizedRecordIsReadyWithoutWaitingForLinger() throws 
Exception {
+        int batchSize = 16 * 1024;
+        int lingerMs = 1000;
+        RecordAccumulator accum = createTestRecordAccumulator(
+            batchSize, 8 * 1024 * 1024, Compression.NONE, lingerMs);
+        try

{ +            long now = time.milliseconds(); +            accum.append(topic, 
partition1, 0L, null, new byte[1024 * 1024], +                
Record.EMPTY_HEADERS, null, maxBlockTimeMs, now, cluster); + +            
assertEquals(1, accum.getDeque(tp1).size()); +            
assertTrue(accum.getDeque(tp1).peekFirst().estimatedSizeInBytes() > batchSize); 
+            // No clock advance, second append, flush, or buffer pressure is 
needed. +            assertEquals(Collections.singleton(node1), 
accum.ready(metadataCache, now).readyNodes, +                "A batch already 
larger than batch.size should not wait for linger"); +        }

finally

{ +            accum.abortIncompleteBatches(); +            accum.close(); +    
    }

+    }
+
     private void testAppendLarge(Compression compression) throws Exception {
         int batchSize = 512;
         byte[] value = new byte[2 * batchSize];
```

The same diff is attached as `RecordAccumulatorTest.patch`. From the Kafka 
repository root, run:

```sh
git apply RecordAccumulatorTest.patch
./gradlew :clients:test \
  --tests 
org.apache.kafka.clients.producer.internals.RecordAccumulatorTest.testOversizedRecordIsReadyWithoutWaitingForLinger
```

This behavior reproduced against two real Kafka brokers with an uncompressed 1 
MiB record, a 16 KiB batch size, and 60-second linger. Sending only that record 
resulted in an acknowledgement after 60.011 seconds. In a separate run, the 
record remained unsent for 20 seconds; appending a 100-byte second record then 
triggered delivery of the original record about 8 ms later, without a flush or 
close. With linger set to zero, the same 1 MiB record was acknowledged in 124 
ms.
 # 
 ## 
 ### Observed results

|Codec / estimate state|Linger|Ready after first record at 0 ms?|Ready after 
next 1 MiB same-partition append at 0 ms?|Ready at linger deadline without 
another append?|
|—|---:|—|—|—|
|None|1000 ms|{*}{{*}}No{{*}}{*}|Yes|Yes; not ready at 999 ms|
|None|0 ms|Yes|Not needed|Yes|
|Gzip, initial estimate|1000 ms|Yes|Already ready|Already ready|
|Zstd, initial estimate|1000 ms|Yes|Already ready|Already ready|
|Gzip, explicit estimate 0.1|1000 ms|{*}{{*}}No{{*}}{*}|Yes|Yes; not ready at 
999 ms|
|Zstd, explicit estimate 0.1|1000 ms|{*}{{*}}No{{*}}{*}|Yes|Yes; not ready at 
999 ms|

The 0.1 compression estimate is injected with the existing 
`CompressionRatioEstimator.setEstimation` test helper. It represents a 
controlled already-learned state; no particular warmup workload is claimed to 
have learned that ratio. Initial gzip/zstd estimates produce 1,101,079 
estimated bytes, which exceed the allocated capacity and therefore do not 
reproduce the delayed-readiness case.
h4. Historical evidence
 - {*}{{*}}0.10.2.0:{{*}}{*} the accumulator explicitly passed configured 
`batchSize` as the builder's write limit, separately from buffer allocation 
capacity. [Release 
source]([https://github.com/apache/kafka/blob/0.10.2.0/clients/src/main/java/org/apache/kafka/clients/producer/internals/RecordAccumulator.java])
 - {*}{{*}}24 March 2017:{{*}}{*} [KAFKA-4816 commit 
`5bd06f1d542`]([https://github.com/apache/kafka/commit/5bd06f1d542e6b588a1d402d059bc24690017d32])
 introduced the v2 format and conservative sizing, and changed the builder 
overload used by the accumulator to one that defaults its write limit to buffer 
capacity. The old final argument `this.batchSize` now represented a base offset 
rather than a write limit.
 - {*}{{*}}3 April 2017:{{*}}{*} [commit 
`f54b61909d5`]([https://github.com/apache/kafka/commit/f54b61909d525547d65123c02bbd36d92ccee5da])
 corrected that accidental base offset to `0L`; it did not restore the separate 
configured write limit.
 - {*}{{*}}0.11.0.0, released 28 June 2017:{{*}}{*} the released source 
contains the complete oversized-allocation/fullness/linger mechanism. [Release 
archive]([https://kafka.apache.org/community/downloads/#0.11.0.0])
 - Sticky partitioning arrived later through 
[KIP-480]([https://cwiki.apache.org/confluence/spaces/KAFKA/pages/120722025/KIP-480%2BSticky%2BPartitioner]),
 in Kafka 2.4.0. This behavior therefore predates sticky partitioning.


> Produced records can wait for linger even after exceeding batch.size
> --------------------------------------------------------------------
>
>                 Key: KAFKA-21068
>                 URL: https://issues.apache.org/jira/browse/KAFKA-21068
>             Project: Kafka
>          Issue Type: Improvement
>            Reporter: Lucas Bradstreet
>            Priority: Major
>
> [note: some edited/reduced LLM output below, human verified]
> h4. Summary
> The java producer can delay a record that already exceeds batch.size because 
> its buffer still has a few unused bytes.
>  
> *Example:*
> With a 16 KiB batch size and 60-second linger, a single uncompressed 1 MiB 
> record waits 60 seconds. Producing a 100-byte second record to the same 
> partition after 20 seconds causes the first record to sent immediately.
> h4. Explanation
> A producer record substantially larger than `batch.size` can remain in an 
> unready batch until `linger.ms` expires, even when the leader is known and 
> there is no backoff, transaction fence, or buffer pressure.
> Another second record that fails to append to the same batch (due to lack of 
> room) can make the first batch ready earlier and sendable. When a record 
> exceeds batch.size, Java allocates a larger buffer to hold it. The problem is 
> that Java then uses the larger buffer’s size as the new batching target. Even 
> a few unused bytes can make Java keep waiting, although the record has 
> already exceeded the configured batch size.
> The buffer includes a little extra space to be safe. Even a few unused bytes 
> can therefore make Java keep waiting, although the batch is already much 
> larger than the target.
> {*}{{*}}Compression is not required to trigger this issue.{{*}}{*} The 
> simplest reproduction below uses `Compression.NONE`. Compression estimates 
> can change whether the batch is considered full; the control results show 
> both outcomes.
> In a deterministic uncompressed reproduction with `batch.size=16384` and a 1 
> MiB value:
> ```text
> Configured batch target:       16,384 bytes
> Allocated capacity:         1,048,664 bytes
> Encoded/estimated size:      1,048,650 bytes
> Remaining allocation space:        14 bytes
> Batch isFull():                    false
> Room for another 1 MiB record:     false
> ```
> With `linger.ms=1000`, this batch is unready at 0 and 999 ms, and becomes 
> ready at 1000 ms without another record. A second 1 MiB append to the same 
> partition makes it ready at time zero. With `linger.ms=0`, it is ready 
> immediately.
> h4. Unit test of behavior (failure expected)
> Apply the following test-only diff to Kafka's existing producer 
> `RecordAccumulatorTest`. The record is explicitly assigned to one partition 
> to isolate the batching decision from sticky partition selection.
> The test appends one {*}{{*}}uncompressed 1 MiB value{{*}}{*} with 
> `batch.size=16384`, `linger.ms=1000`, and an 8 MiB buffer pool. It checks 
> that the batch already exceeds the target, then asserts that its leader is 
> ready immediately. There is no second append or clock advance.
> ```diff
> diff --git 
> a/clients/src/test/java/org/apache/kafka/clients/producer/internals/RecordAccumulatorTest.java
>  
> b/clients/src/test/java/org/apache/kafka/clients/producer/internals/RecordAccumulatorTest.java
> index 7890a5474b..075f3f9cf1 100644
> — 
> a/clients/src/test/java/org/apache/kafka/clients/producer/internals/RecordAccumulatorTest.java
> +++ 
> b/clients/src/test/java/org/apache/kafka/clients/producer/internals/RecordAccumulatorTest.java
> @@ -276,6 +276,28 @@ public class RecordAccumulatorTest
> {          testAppendLarge(Compression.NONE);      }
> +    @Test
> +    public void testOversizedRecordIsReadyWithoutWaitingForLinger() throws 
> Exception {
> +        int batchSize = 16 * 1024;
> +        int lingerMs = 1000;
> +        RecordAccumulator accum = createTestRecordAccumulator(
> +            batchSize, 8 * 1024 * 1024, Compression.NONE, lingerMs);
> +        try
> { +            long now = time.milliseconds(); +            
> accum.append(topic, partition1, 0L, null, new byte[1024 * 1024], +            
>     Record.EMPTY_HEADERS, null, maxBlockTimeMs, now, cluster); + +            
> assertEquals(1, accum.getDeque(tp1).size()); +            
> assertTrue(accum.getDeque(tp1).peekFirst().estimatedSizeInBytes() > 
> batchSize); +            // No clock advance, second append, flush, or buffer 
> pressure is needed. +            assertEquals(Collections.singleton(node1), 
> accum.ready(metadataCache, now).readyNodes, +                "A batch already 
> larger than batch.size should not wait for linger"); +        }
> finally
> { +            accum.abortIncompleteBatches(); +            accum.close(); +  
>       }
> +    }
> +
>      private void testAppendLarge(Compression compression) throws Exception {
>          int batchSize = 512;
>          byte[] value = new byte[2 * batchSize];
> ```
> The same diff is attached as `RecordAccumulatorTest.patch`. From the Kafka 
> repository root, run:
> ```sh
> git apply RecordAccumulatorTest.patch
> ./gradlew :clients:test \
>   --tests 
> org.apache.kafka.clients.producer.internals.RecordAccumulatorTest.testOversizedRecordIsReadyWithoutWaitingForLinger
> ```
> This behavior reproduced against two real Kafka brokers with an uncompressed 
> 1 MiB record, a 16 KiB batch size, and 60-second linger. Sending only that 
> record resulted in an acknowledgement after 60.011 seconds. In a separate 
> run, the record remained unsent for 20 seconds; appending a 100-byte second 
> record then triggered delivery of the original record about 8 ms later, 
> without a flush or close. With linger set to zero, the same 1 MiB record was 
> acknowledged in 124 ms.
>  # 
>  ## 
>  ### Observed results
> |Codec / estimate state|Linger|Ready after first record at 0 ms?|Ready after 
> next 1 MiB same-partition append at 0 ms?|Ready at linger deadline without 
> another append?|
> |—|---:|—|—|—|
> |None|1000 ms|{*}{{*}}No{{*}}{*}|Yes|Yes; not ready at 999 ms|
> |None|0 ms|Yes|Not needed|Yes|
> |Gzip, initial estimate|1000 ms|Yes|Already ready|Already ready|
> |Zstd, initial estimate|1000 ms|Yes|Already ready|Already ready|
> |Gzip, explicit estimate 0.1|1000 ms|{*}{{*}}No{{*}}{*}|Yes|Yes; not ready at 
> 999 ms|
> |Zstd, explicit estimate 0.1|1000 ms|{*}{{*}}No{{*}}{*}|Yes|Yes; not ready at 
> 999 ms|
> The 0.1 compression estimate is injected with the existing 
> `CompressionRatioEstimator.setEstimation` test helper. It represents a 
> controlled already-learned state; no particular warmup workload is claimed to 
> have learned that ratio. Initial gzip/zstd estimates produce 1,101,079 
> estimated bytes, which exceed the allocated capacity and therefore do not 
> reproduce the delayed-readiness case.
> h4. Historical evidence
>  - {*}{{*}}0.10.2.0:{{*}}{*} the accumulator explicitly passed configured 
> `batchSize` as the builder's write limit, separately from buffer allocation 
> capacity. [Release 
> source]([https://github.com/apache/kafka/blob/0.10.2.0/clients/src/main/java/org/apache/kafka/clients/producer/internals/RecordAccumulator.java])
>  - {*}{{*}}24 March 2017:{{*}}{*} [KAFKA-4816 commit 
> `5bd06f1d542`]([https://github.com/apache/kafka/commit/5bd06f1d542e6b588a1d402d059bc24690017d32])
>  introduced the v2 format and conservative sizing, and changed the builder 
> overload used by the accumulator to one that defaults its write limit to 
> buffer capacity. The old final argument `this.batchSize` now represented a 
> base offset rather than a write limit.
>  - {*}{{*}}3 April 2017:{{*}}{*} [commit 
> `f54b61909d5`]([https://github.com/apache/kafka/commit/f54b61909d525547d65123c02bbd36d92ccee5da])
>  corrected that accidental base offset to `0L`; it did not restore the 
> separate configured write limit.
>  - {*}{{*}}0.11.0.0, released 28 June 2017:{{*}}{*} the released source 
> contains the complete oversized-allocation/fullness/linger mechanism. 
> [Release archive]([https://kafka.apache.org/community/downloads/#0.11.0.0])
>  - Sticky partitioning arrived later through 
> [KIP-480]([https://cwiki.apache.org/confluence/spaces/KAFKA/pages/120722025/KIP-480%2BSticky%2BPartitioner]),
>  in Kafka 2.4.0. This behavior therefore predates sticky partitioning.



--
This message was sent by Atlassian Jira
(v8.20.10#820010)

Reply via email to