Skip to content

KAFKA-20578: Initial producer incremental allocation for uncompressed data - #22654

Open
lianetm wants to merge 56 commits into
apache:trunkfrom
lianetm:lm-producer-dyn-1
Open

KAFKA-20578: Initial producer incremental allocation for uncompressed data#22654
lianetm wants to merge 56 commits into
apache:trunkfrom
lianetm:lm-producer-dyn-1

Conversation

@lianetm

@lianetm lianetm commented Jun 23, 2026

Copy link
Copy Markdown
Member

Initial partial implementation for KIP-1332 (producer incremental
allocation strategy).

This PR includes:

  • new producer config for allocation strategy (incremental/full)
  • initial implementation of the incremental strategy: supports
    uncompressed data only (no growth support), extra-copy on send (linked
    chunks are flattened into a new buffer)
  • unit and integration tests (running existing Producer integration
    tests with the new strategy + new ones)

Support for compressed data and network layer improvements will come in
follow-up PRs.

Reviewers: Jun Rao junrao@gmail.com, Ken Huang s7133700@gmail.com,
Chia-Ping Tsai chia7712@gmail.com

@lianetm
lianetm requested a review from junrao June 23, 2026 18:34
@github-actions github-actions Bot added core Kafka Broker producer build Gradle build or GitHub Actions clients labels Jun 23, 2026

@junrao junrao left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@lianetm : Thanks for the PR. Made a pass of the non-testing files. Left a few comments.

Importance.MEDIUM,
CommonClientConfigs.CLIENT_DNS_LOOKUP_DOC)
.define(BUFFER_MEMORY_CONFIG, Type.LONG, 32 * 1024 * 1024L, atLeast(0L), Importance.HIGH, BUFFER_MEMORY_DOC)
.define(BUFFER_MEMORY_ALLOCATION_STRATEGY_CONFIG,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we mark this as internal to prevent it from leaking into 4.4 before the feature is fully implemented?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, done, and created https://issues.apache.org/jira/browse/KAFKA-20763 to publish it once the feature is complete

// pool memory by completing batches). On exhaustion, close the batch (making it
// drainable) and fall through to the blocking first-record path next iteration.
try {
extensionChunks = chunkedFree.allocateChunks(extensionBytes, 0L);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need to do a special non-blocking call? In the existing logic, if an allocation request is blocked, all existing ProducerBatches become immediately drainable.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not strictly needed, agreed, it was just to fail fast and avoid what seemed like a "waisted" wait here, exactly because of the fact you mention, that blocking would make all open batches drainable, including this one we want to extend (and this is the trick I was trying to avoid, updated the comment that was misleading).

So with a blocking call, even if we get the pool memory we need in time, the batch would probably be gone/drained, and we would need to start a new batch anyways (so opting for non-blocking just to go straight to the new-batch phase if pool is exhausted when extending).

That being said, there is a case where blocking here would seem better, that's if the pool is exhausted at this point but it frees-up before the batch we're extending is drained. But that one didn't seem common in practice given that the sender drains batches before polling (so by the time memory frees up from a request that compelted, the batch we're waiting to extend would be drained already). Makes sense?

last.closeForRecordAppends();
}
}
continue;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It may take a bit of time for the closed batches to be drained. If we continue here, it seems that the client will just busy-loop until some batches are drained and some free space becomes available in buffer pool?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

even without the drain the closeForAppends should be enough to make that the next iteration find that the stream is closed => isFull => !hasRoom so starting a new batch (blocking, no busy loop). Makes sense? Added a test testExhaustedExtensionFallsBackToBlockingNewBatchPath to show the behaviour.

* them, and retries. Otherwise defers to the parent.
*/
@Override
protected RecordAppendResult tryAppend(long timestamp, byte[] key, byte[] value, Header[] headers,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a bit awkward to have a return value of null and RecordAppendResult.needsExtension. Could we introduce a non-null value to indicate the batch is full?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, done. Introduced needsNewBatch (aligned with needsBufferExtension)

// ProducerBatch), which can't take extension chunks. Only attach to a
// writable chunked batch; otherwise refund the chunks and re-evaluate.
if (last instanceof ChunkedProducerBatch && last.isWritable()) {
((ChunkedProducerBatch) last).addBuffers(extensionChunks);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess two concurrent clients could add buffers exceeding the batch size? Those buffers won't be used, but can only be freed after the batch is drained.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct, there could be over-reservation at this point with concurrent appends. As for how the extra buffers would be used/released, I see the following cases:

  • used right away: next loop iteration -> the loser's tryAppend right after this line fails with needsExtension (the winner already took the free capacity that was used to calculate this extension). Then the loser loops and tries again (recomputes the extension needed, so it uses what it had added concurrently + more)
  • left unused, freed when batch drains (case you mentioned) -> if the loser's record can't land in this batch (batch closes and loser's record goes into new batch).

Added 2 tests to cover this: testConcurrentExtensionRaceDoesNotOverflowChunkedStream and testBatchSizeLimitRespectedDespiteOverReservation

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here is a related case. Two concurrent producer each allocated its own extensionChunks. It's possible that the first producer's allocation is enough to fit the records from both producer. It would be useful to document that this logic optimizes for the common case, but can lead to slightly temporary over allocation.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, agreed, added a comment describing it.

long remainingBytes = memoryRequired - (long) pooled.size() * chunkSize;
if (remainingBytes > 0) {
// remainingBytes <= memoryRequired <= totalMemory (validated above), so the int cast is safe.
freeUp((int) remainingBytes);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a no-op since all pooled chunks have been used if we reach here.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

agreed, removed.

pooled.add(free.pollFirst());
long remainingBytes = memoryRequired - (long) pooled.size() * chunkSize;
if (remainingBytes > 0) {
// remainingBytes <= memoryRequired <= totalMemory (validated above), so the int cast is safe.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is remainingBytes guaranteed to be an int? memoryRequired could be larger than int and pooled.size() initially could be 0.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yup, good catch, seemed safe here but it's not because of how memoryRequired is rounded up higher up. This code here does not exist anymore though so no changes (the other place where a similar unsafe op was being done to calculate stillNeeded went away too, removed with the comment/fix below)

}
long stillNeeded = memoryRequired - (long) pooled.size() * chunkSize - accumulated;
if (stillNeeded > 0) {
freeUp((int) stillNeeded);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This may be ok, but it's a bit weird to free up the chunks only to be reallocated again. Here is an alternative that doesn't require a freeup() call.

  // Reuse pooled chunks first. If a reused chunk covers a slot we already reserved as                                                                                                                                                                                                                                    
  // raw bytes in an earlier iteration, hand that raw reservation back to the pool.                                                                                                                                                                                                                                       
  while (pooled.size() < numChunks && !free.isEmpty()) {                                                                                                                                                                                                                                                                  
      pooled.add(free.pollFirst());                                                                                                                                                                                                                                                                                       
      if (accumulated >= chunkSize) {          // accumulated is always chunk-aligned here                                                                                                                                                                                                                                
          accumulated -= chunkSize;                                                                                                                                                                                                                                                                                       
          this.nonPooledAvailableMemory += chunkSize;   // refund → available to other waiters                                                                                                                                                                                                                            
      }                                                                                                                                                                                                                                                                                                                   
  }                                                                                                                                                                                                                                                                                                                       
  // Reserve raw memory for any still-uncovered chunks, in whole chunks.                                                                                                                                                                                                                                                  
  while (pooled.size() + (int)(accumulated / chunkSize) < numChunks                                                                                                                                                                                                                                                       
          && this.nonPooledAvailableMemory >= chunkSize) {                                                                                                                                                                                                                                                                
      this.nonPooledAvailableMemory -= chunkSize;                                                                                                                                                                                                                                                                         
      accumulated += chunkSize;                                                                                                                                                                                                                                                                                           
  }                                                                                                                                                                                                                                                                                                                       

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice, makes sense, done.


// Take pool chunks first, then reserve non-pool bytes for the remainder.
while (pooled.size() < numChunks
&& (long) (pooled.size() + 1) * chunkSize + accumulated <= memoryRequired

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The first condition is redundant, given the second one.

 (pooled+1)*chunkSize + accumulated ≤ memoryRequired                                                                                                                                                                                                                                                               
    ⇒  (pooled+1)*chunkSize ≤ memoryRequired − accumulated ≤ memoryRequired = numChunks*chunkSize                                                                                                                                                                                                                         
    ⇒  pooled+1 ≤ numChunks                                                                                                                                                                                                                                                                                               
    ⇒  pooled < numChunks

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed (the condition changed though, with the comment above, and does not have the redundancy anymore, so no changes directly for this)

// On failure (timeout / close / interrupt), refund the non-pool bytes taken.
// Pool chunks already in `pooled` are returned to `free` separately by the
// outer catch.
this.nonPooledAvailableMemory += accumulated;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we return accumulated and pooled chunks in the same place? For example, we can set a flag like allocationCompleted to replace accumulated = 0. Then we can free both accumulated and pooled chunks if the flag is not set.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Introduced the allocationCompleted flag and now all refunds happen together in a finally, much better indeed.

@lianetm

lianetm commented Jul 15, 2026

Copy link
Copy Markdown
Member Author

Thanks for the comments @junrao ! All addressed (just some changes pending the parallel refactoring, details in the answers above)

* @throws BufferExhaustedException if the request can't be satisfied within {@code maxTimeToBlockMs}
* @throws KafkaException if the pool is closed during the wait
*/
public List<ByteBuffer> allocateChunks(int totalSize, long maxTimeToBlockMs) throws InterruptedException {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Out of curiosity, have you considered moving allocateChunks into BufferPool instead of having specific ChunkedBufferPool? Also, allocate seems to be a variant of allocateChunks which would simplify the codebase.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

interesting, I moved the allocateChunks into BufferPool and it's a good simplification indeed. I also extracted the common logic that applies to allocate and allocateChunks (awaitMemory), but didn't go for a full merge of the 2 because even though similar (the common logic extracted), they have some differences and made the fully-consolidated approach tricky (e.g., the nature of what they reserve, full chunks vs bytes, metrics used differently). Makes sense?

@junrao junrao left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@lianetm : Thanks for the updated PR. A few more comments.

continue;
}
if (appendResult.needsNewBatch())
throw new IllegalStateException("appendNewBatch must not return a needsNewBatch result");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we do this check right after we get appendResult back?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

*/
public enum Outcome { APPENDED, NEEDS_BUFFER_EXTENSION, NEEDS_NEW_BATCH }

public final Outcome outcome;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be private?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, both indeed, done + comment on what the callers invoke.

* @param nowMs The current time, in milliseconds
* @return the append result, which never has {@code needsNewBatch=true}: the method either
* propagates a non-{@code needsNewBatch} result from an open batch created concurrently
* (a success, or — incremental strategy — a {@code needsBufferExtension} signal), or it

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment is not very clear to me. a success should return appended, not needsBufferExtension, right?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, that was the intention on the comment: success (appended) OR needsBufferExtension. Updated it to make it clearer.

*/
public void addBuffers(List<ByteBuffer> newChunks) {
ensureNotDeallocated();
chunks.addAll(newChunks);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we call ensureWritable() here too?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, done

}
chunks.clear();
currentChunk = null;
currentChunkIndex = 0;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

0 is a valid index. Should we set it to -1?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, done


@Override
public void ensureRemaining(int remainingBytesRequired) {
ensureNotDeallocated();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we call this in limit() and initialCapacity() too?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

makes sense (done + tests)

@Override
public ByteBuffer buffer() {
ensureNotDeallocated();
finalized = true;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's probably better to set this when close() is called. We can then rename it to closed.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

agreed, done

// so bufferStream was allocated (this iteration or carried from a prior one).
if (bufferStream == null)
throw new IllegalStateException("needsNewBatch path reached without an allocated buffer stream");
int firstRecordSize = AbstractRecords.estimateSizeInBytesUpperBound(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This code is different from the code when the new batch is allocated. Could we remember the first record size when the new batch is allocated and reuse it here?

                    int recordUncompressed = AbstractRecords.recordSizeUpperBound(
                            RecordBatch.CURRENT_MAGIC_VALUE, compression.type(), key, value, headers);
                    int size = MemoryRecordsBuilder.estimatedBytesWritten(
                            RecordBatch.CURRENT_MAGIC_VALUE, compression.type(),
                            CompressionRatioEstimator.estimation(topic, compression.type()),
                            recordUncompressed);

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, done (will probably need review once compression lands though, just added a TODO for now and we can discuss once there)

@lianetm

lianetm commented Jul 20, 2026

Copy link
Copy Markdown
Member Author

Thanks @junrao and @chia7712 for the reviews! All comments addressed.

@junrao junrao left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@lianetm : Thanks for the updated PR. A few more comments. Also, there is code duplicated between RecordAccumulator and ChunkedRecordAccumulator. Claude suggested the following two alternatives. Is that worth doing?

Alternative 1: Keep ChunkedRecordAccumulator as subclass.
  // base RecordAccumulator (the single loop), pseudo-hooks:                                                                                                                                                                                                                                                                
                                                                                                                                                                                                                                                                                                                            
  // 1) allocate storage for a NEW batch, return the builder supplier (+ a handle to release on cleanup)                                                                                                                                                                                                                    
  protected BatchBuilderHandle allocateNewBatchBuilder(topic, part, record, maxTimeToBlock) { … }                                                                                                                                                                                                                           
  //   Full:    ByteBuffer via free.allocate → () -> MemoryRecords.builder(buffer, …)                                                                                                                                                                                                                                       
  //   Chunked: chunks via allocateChunks → new ChunkedByteBufferOutputStream → () -> chunkedRecordsBuilder(stream, firstRecordSize)                                                                                                                                                                                        
                                                                                                                                                                                                                                                                                                                            
  // 2) handle the NEEDS_BUFFER_EXTENSION outcome (allocate off-lock, re-lock, attach, retry)                                                                                                                                                                                                                               
  protected void extendOpenBatch(dq, extensionBytesNeeded, maxTimeToBlock) { … }                                                                                                                                                                                                                                            
  //   Full:    unreachable — tryAppend never returns NEEDS_BUFFER_EXTENSION (fixed buffer == writeLimit)                                                                                                                                                                                                                   
  //   Chunked: allocateChunks(0L) → addBuffers → retry / close-and-fall-through on exhaustion                                                                                                                                                                                                                              
                                                                                                                                                                                                                                                                                                                            
  The single loop then does:                                                                                                                                                                                                                                                                                                
  while (true) {                                                                                                                                                                                                                                                                                                            
      ... pick partition, get dq ...                                                                                                                                                                                                                                                                                        
      synchronized (dq) {                                                                                                                                                                                                                                                                                                   
          if (partitionChanged(...)) continue;                                                                                                                                                                                                                                                                              
          RecordAppendResult r = tryAppend(...);                                                                                                                                                                                                                                                                            
          switch (r.outcome) {                                                                                                                                                                                                                                                                                              
              case APPENDED:            updatePartitionInfo(...); return r;                                                                                                                                                                                                                                                 
              case NEEDS_BUFFER_EXTENSION: /* release lock */ extendOpenBatch(...); continue;                                                                                                                                                                                                                               
              case NEEDS_NEW_BATCH:     ... allocateNewBatchBuilder(...) → appendNewBatch(...) ...                                                                                                                                                                                                                          
          }                                                                                                                                                                                                                                                                                                                 
      }                                                                                                                                                                                                                                                                                                                     
  }                                                                                                                                                                                                                                                                                                                         

Alternative 2:  Composition over inheritance (cleaner, bigger change): instead of ChunkedRecordAccumulator extends RecordAccumulator, inject a 
BatchAllocationStrategy (Full vs Chunked) into a single RecordAccumulator. One accumulator, one loop, strategy object handles storage + extension.

* @throws BufferExhaustedException if the request can't be satisfied within {@code maxTimeToBlockMs}
* @throws KafkaException if the pool is closed during the wait
*/
public List<ByteBuffer> allocateChunks(int totalSize, long maxTimeToBlockMs) throws InterruptedException {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess the caller is expected to use either allocate() or allocateChunks(). Should we prevent the caller from mixing the two calls?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes. I added an allocation mode to the pool, then we can easily enforce not only that allocate and allocateChunks are not mixed, but also that they are used with the right intention. Thoughts?

* resources like compression buffers. The batch will be fully closed (ie. the record batch headers will be written
* and memory records built) in one of the following cases (whichever comes first): right before send,
* if it is expired, or when the producer is closed.
* If it is full (or absent), we return {@link RecordAppendResult#NEEDS_NEW_BATCH} and a new batch is created.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be useful to document what types of RecordAppendResult this method could return.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sure, done (here and on the chunk override)

* @return the append result, which is never {@code needsNewBatch}. It is either {@code appended}
* — the record was appended, whether to a batch another thread created concurrently or to
* the batch this method creates — or, in the incremental strategy, {@code needsBufferExtension}:
* a concurrent appender created an extendable open batch, so the caller releases its

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a concurrent appender created an extendable open batch => a concurrent appender created an extendable open batch, but the new record doesn't fit in.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

agreed, clarified.

throw e;
}
nowMs = time.milliseconds();
bufferStream = new ChunkedByteBufferOutputStream(initialChunks, chunkedFree.poolableSize(), chunkedFree);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we put newBatchSize and bufferStream in a record since they should always be present together?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sure, makes sense (just that it cannot be in a record, java 11, but consolidated them with a class)

* Unlike the single-buffer batch — which must donate a fresh buffer because the network
* layer may still be reading the pooled one — a chunked batch's inflight bytes live in the
* separate flattened buffer (see {@link ChunkedByteBufferOutputStream#buffer()}), so it is
* safe to return the actual chunks to the pool here.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a chunked batch's inflight bytes live in the separate flattened buffer

This is true for now, but won't be true when we avoid flattening the buffer. Should we add a TODO to remind ourselves?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

* appender racing the same batch.
*/
private BufferPool poolMockingConcurrentChunkAllocation(int chunkSize, long totalMemory,
AtomicReference<ChunkedRecordAccumulator> injectAppendOnce,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

indentation

// The original batch is far below its writeLimit, so isFull() can only be true via
// the closed append stream — i.e., it was closed for appends on the failed extension.
assertTrue(dq.peekFirst().isFull(), "original batch must be closed for appends");
assertNotNull(dq.peekFirst());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we do this one before the previous line?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sure, done (it was a dup I introduced to fix a warning from the IDE). Fixed a similar one too.


// Second record overflows the batch's chunk, so extension attempt fails (pool empty,
// recovered by closing the batch), then the new-batch acquire fails too (maxTimeToBlock
// = 0). The two failed acquires must count as a single dropped record.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new batch acquire should have maxTimeToBlock != 0, right?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, right. Updated. The extension is the only one non-blocking in this case, then the new batch acquire is done with the maxTimeToBlock != 0

* Test that chunks are returned to the pool only at batch completion (deallocate), never at close.
*/
@Test
public void testBatchCloseDoesNotDeallocateChunksPrematurely() throws Exception {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Where does it test the deallocation doesn't happen?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good catch, it was checking the records were still readable, but not directly checking the memory, fixed

assertNotNull(batch);
assertEquals(6, batch.recordCount);

// batch.close() flattens chunks and writes the header. If chunks under-allocated, this

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, if chunks are under allocated, we should throw at append time, not close time, right?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, updated the comment (assertions were already aligned). No throw at close here, under-allocation would be an IllegalState on tryAppend.

@lianetm

lianetm commented Jul 22, 2026

Copy link
Copy Markdown
Member Author

Thanks for the review @junrao , all comments addressed.

About the high level one (this) on the overlap between RecordAccumulator and ChunkedRecordAccumulator, totally agree. I refactored to extract and reuse different common bits for now (keeping both classes still, just common helpers, along the lines of the alternative 1). Compression will touch the core of this, so wonder if better to wait to see how those changes fit here, to see how much common surface we still have and then decide if continue with the helpers approach, taking them a step further, or go with some like the alt 2 approach. Makes sense? if so I would create a jira on me to review/dedup along with the compression changes.

@junrao junrao left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@lianetm : Thanks for the updated PR. Just a few minor comments.

* strategy), {@link #CHUNKED} only {@link #allocateChunks} (the incremental strategy). Fixed at
* construction so the two are never mixed on the same pool.
*/
public enum AllocationMode { SINGLE, CHUNKED }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it better to use FULL and INCREMENTAL?

throw new IllegalStateException("needsNewBatch path reached without an allocated buffer stream");
// Reuse the new-batch size estimate as the write-limit basis.
// TODO: review when compression is supported.
final NewBatchBuffer pending = newBatch;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pending => pendingNewBatch?

*/
private static final class NewBatchBuffer {
final ChunkedByteBufferOutputStream stream;
final int size;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

size => firstAppendSize?


// Now free chunks one at a time, allowing the multi-chunk request to accumulate.
p.deallocate(h1);
Thread.sleep(50);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why does it need to sleep for 50ms, which is long for a unit test? Ditto below.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeap not needed in this case really, and the other one can be better done with a waitForCondition on the available memory, done.

} catch (Throwable th) {
err.set(th);
}
}, "slow-success");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

slow-success => chunked-waiter ?

@lianetm

lianetm commented Jul 24, 2026

Copy link
Copy Markdown
Member Author

Thanks @junrao ! All comments addressed.

@junrao junrao left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@lianetm : Thanks for the updated PR. A few more comments.

// Use the chunked path only when a batch is at least one full chunk
// (batch.size >= CHUNK_SIZE). Below that, a batch can't fill even one chunk, so chunking
// would over-reserve and the producer falls back to the full strategy instead.
boolean useIncremental = incremental && batchSize >= ChunkedRecordAccumulator.CHUNK_SIZE;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we switch automatically to full because of the batch size, it might be useful to log a warning.

// A single call can guarantee at most `chunkSize` of space (the stream advances one chunk
// at a time). Callers needing more attach chunks via addBuffers first. write(byte[]) loops
// across chunks, so contiguous capacity isn't required.
ensureChunkCapacity(Math.min(remainingBytesRequired, chunkSize));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, this still seems to only work if remainingBytesRequired is 1. If it's larger than 1 and the current chunk doesn't have enough remaining bytes, we move to the next chunk. A subsequent write will write on the new chunk, the remaining bytes on the previous chunk are wasted.

/**
* Appends the record after checking there's chunk capacity for it.
* At this point it's expected that the allocated chunks have
* capacity for the record because the accumulator never routes an

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At this point it's expected that the allocated chunks have capacity

Hmm, is this true? This method could return null because the batch is full, right?

/** Total available memory is the sum of nonPooledAvailableMemory and the number of byte buffers in free * poolableSize. */
private long nonPooledAvailableMemory;
/** Lock held for any read or write of {@link #free}, {@link #waiters}, {@link #nonPooledAvailableMemory}, or {@link #closed}. */
protected final ReentrantLock lock;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this still need to be protected? Ditto for other fields. We can also remove the addition of BufferPool to spotbugs-exclude.xml.

* Wake the longest-waiting thread if any memory (pooled or non-pooled) is available.
* Must be called with {@link #lock} held. No-op if no waiters or no memory is free.
*/
protected void signalNextWaiterIfMemoryAvailable() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can be private.

* full strategy (allocate) and the incremental strategy (ChunkedRecordAccumulator),
* so both update the same buffer-exhausted metrics.
*/
protected void recordBufferExhausted() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can have package level visibility.

* waiter. Acquires {@link #lock} internally. Used by callers
* that reserve memory and then need to roll back the reservation (e.g., upon errors).
*/
protected void releaseReservedBytes(long bytes) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can be private.

* buffers (if needed). Must be called with {@link #lock} held.
*/
private void freeUp(int size) {
protected void freeUp(int size) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can be private.

public class ChunkedProducerBatch extends ProducerBatch {

public ChunkedProducerBatch(TopicPartition tp, MemoryRecordsBuilder recordsBuilder, long createdMs) {
super(tp, recordsBuilder, createdMs);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we validate that recordsBuilder.bufferStream() is chunked?

newBatchSize, chunkedFree.poolableSize(), topic, effectivePartition, maxTimeToBlock);
List<ByteBuffer> initialChunks;
try {
initialChunks = chunkedFree.allocateChunks(newBatchSize, maxTimeToBlock);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems that we can block on the bufferpool longer than maxTimeToBlock. Claude suggested the following path that doesn't exist in RecordAccumulator.

Iteration A: needsNewBatch → blocking allocateChunks(_, maxTimeToBlock) succeeds after a wait. A concurrent thread created a batch meanwhile, so appendNewBatch's in-lock tryAppend returns needsBufferExtension → line 257 deallocates the stream, continue.
Iteration B: extension path does the non-blocking allocateChunks(gap, 0L) → BufferExhaustedException → closes the batch (line 168) → continue.
Iteration C: batch now closed → needsNewBatch → blocks again for a fresh full maxTimeToBlock.

throw new IllegalArgumentException("totalSize must be positive: " + totalSize);

int chunkSize = poolableSize();
int numChunks = (int) (((long) totalSize + chunkSize - 1L) / chunkSize);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

int numChunks = (totalSize - 1) / chunkSize + 1; is overflow-safe even without the cast. The existing code is correct, so feel free to ignore this comment.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

build Gradle build or GitHub Actions ci-approved clients core Kafka Broker producer tools

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants