Skip to content

KAFKA-20864: Fix to avoid early batch close if extension fails - #23008

Open
lianetm wants to merge 16 commits into
apache:trunkfrom
lianetm:lm-fix-early-close-race
Open

KAFKA-20864: Fix to avoid early batch close if extension fails#23008
lianetm wants to merge 16 commits into
apache:trunkfrom
lianetm:lm-fix-early-close-race

Conversation

@lianetm

@lianetm lianetm commented Jul 31, 2026

Copy link
Copy Markdown
Member

This PR includes the following fixes to avoid closing the wrong batch
when a mid-batch extension acquire fails on an exhausted pool:

  • close the batch only if it's the same we were trying to extend. If
    it's not, do not close it and continue the loop, so the next iteration
    checks against whatever is open (will take the new-batch path if
    nothing is open, or retry with the new open batch, sizing the extension
    needed again)
  • bound the retries the above introduces (retry of non-blocking op). Fix
    by always allowing a first attempt (it's non-blocking), but bound
    retries against the remaining max.block.ms. Edge case to ensure send
    does not exceed max.block.ms if the extension keeps failing with the
    batch being replaced. Bound all retries, successful and failed ones.
  • track an absolute deadline, so that the non-blocking retries time also
    count against max.block.ms (like the blocking ops do)

Reviewers: Jun Rao junrao@gmail.com

@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. Left a few comments.

// Set once the extension acquire has failed on an exhausted pool. That acquire is non-blocking, so we
// always allow a first attempt (even with max.block.ms 0), and only check retries of it against the
// deadline (see throwIfExtensionBudgetSpent), to avoid retrying it continuously with no bound.
boolean extensionAcquireFailed = false;

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.

In theory, we can be in an unbounded while loop even when the extension allocation never fails. The appender will keep doing successful extension and keep finding the batch has been replaced by some other appenders. Should we gate that case too? For example, we can simply check the deadline at the beginning of the loop for each iteration except for the first one.

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, good catch. Changed to check the deadline on all extension retries (failed or successful).

Added it after the tryAppend though (not at the top of the loop), just to allow an append that can fit into the open batch without extension. Also added a test for this testSucceedingExtensionRetriesStopOnceMaxBlockTimeIsUsedUp

* on re-entry only, after {@code tryAppend} has re-confirmed the record needs chunks the exhausted
* pool would not hand over: the extension acquire never blocks, so we enforce the max.block.ms here.
*/
private void throwIfExtensionBudgetSpent(long deadlineMs, long maxTimeToBlock, String topic, int partition) {

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.

throwIfExtensionBudgetExceeded?

* {@code batchToExtend} for appends so the record retries on the new-batch path (blocks for memory)
* <p>
* The acquire runs off the deque lock, so the open batch may no longer be the one the gap was
* sized against by the time this would close it: it can have been drained and replaced by a batch

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 can have been => it could have been

ChunkedRecordAccumulator accum = accumRef.get();
Deque<ProducerBatch> dq = accum.getDeque(tp1);
ProducerBatch drained;
synchronized (dq) {

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 reuse simulateConcurrentDrainAndReplace here?

accum.append(topic, partition1, 0L, key, new byte[100], Record.EMPTY_HEADERS, null,
maxBlockTimeMs, time.milliseconds(), cluster);

// Needs an extension, which fails with the budget already spent.

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.

with the budget => with the time budget

assertEquals(1.0, (double) exhausted.metricValue(),
"giving up on the extension path must count the dropped record exactly once");

// Giving up must leave the batch it declined to close untouched.

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.

leave the batch it declined to close untouched => leave the open batch untouched

* it has room to spare for the retried one.
*/
private BufferPool poolFailingFirstExtensionAfterBatchReplaced(int chunkSize,
AtomicReference<ChunkedRecordAccumulator> accumRef,

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.

identation

}

/**
* The extension acquire fails with the batch it was sized against already replaced, so nothing is

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 extension acquire fails with the batch it was sized against already replaced, => The extension acquire fails with the batch it was sized against, which is already replaced,

@lianetm

lianetm commented Aug 4, 2026

Copy link
Copy Markdown
Member Author

Thanks for the review @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 couple of more comments.

// or succeeded without getting the record appended (not enough capacity by then, or no longer the batch it was
// sized against). Neither blocks, so a retry never times out on its own and is checked against the deadline
// based on this flag. A first attempt always runs, even with max.block.ms 0.
boolean extensionRetried = false;

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.

In theory, the existing append could loop forever if the partition keep changing. Should we have a more general approach to handle that too?

@lianetm lianetm Aug 5, 2026

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.

uhm interesting point about the case of partition changing, agree with the gap, but that's an issue in the "full" strategy as well, right? (send without partition + default partitioning + no key + concurrent send with same default partitioning that makes the partition change. Then append just continues retrying, unbounded)

Here in RecordAccumulator (and again a few lines below):

// After taking the lock, validate that the partition hasn't changed and retry.
if (partitionChanged(topic, topicInfo, partitionInfo, dq, nowMs, cluster))
continue;

We probably need to consider enforcing a deadline for that partition-change case too (just as we did on the extension path here). A separate PR/jira better? given that the issue exists in both strategies (and the fix would go in the default path RecordAccumulator and the new Chunk one too I expect)

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 partition changing case applies to both strategies. I am just wondering if we can have a single mechanism to handle all cases of potential infinite loop, instead of having special mechanisms for each individual case.

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, added a single mechanism to track the deadline across retries (all retries).

The fix applies to ChunkedAccum only, but I defined the core funcs in the base RecordAccum so we can reuse to bound retries on the full strategy too (even though there it should be the partition change only, will file jira to review that separately as it would touch the default path).

@lianetm

lianetm commented Aug 5, 2026

Copy link
Copy Markdown
Member Author

Thanks @junrao ! Comments addressed, main one open above

@lianetm

lianetm commented Aug 11, 2026

Copy link
Copy Markdown
Member Author

Thanks @junrao , comment addressed : #23008 (comment)

@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, could we rerun the perf test to make sure there is no perf degradation?

* <li>the first pass is always allowed — the deadline is not even read, so an append that completes in one
* pass never depends on the clock;</li>
* <li>retries are allowed while there is time left before {@code deadlineMs};</li>
* <li>after the deadline, one more retry is allowed, since it may need no memory at all and

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.

Intuitively, this feels weird. Why do we want to allow an extra retry after the deadline has passed? If max.block.ms > 0, this is not needed since it's covered by deadline. If max.block.ms=0, does one more retry guarantee success when the blocking part is never hit?

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.

If max.block.ms=0, does one more retry guarantee success when the blocking part is never hit?

no, it doesn;t (this extra allowed was mainly thinking of the case where it could guarantee success. But agree that it's weird, changed it to the simper approach : first pass always allowed. Following passes (retries) allowed while there is time.

// Whether the non-blocking extension was denied memory on the pass that just ended (only
// memory exhaustion case a pass can survive because it's non-blocking, all others throw).
// Cleared once the next pass has read it, so it can only ever describe the pass immediately before.
boolean nonBlockingAllocationDeniedMemory = false;

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.

nonBlockingAllocationDeniedMemory => nonBlockingMemoryAllocationDenied ?

TimeoutException timeout = assertThrows(TimeoutException.class,
() -> accum.throwIfNoMoreRetriesAllowed(expired, spent, false, topic));
assertEquals(TimeoutException.class, timeout.getClass(), timeout.getMessage());
assertTrue(timeout.getMessage().contains("kept restarting"), timeout.getMessage());

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 use retry consistently instead of having both restart and retry?

@lianetm

lianetm commented Aug 13, 2026

Copy link
Copy Markdown
Member Author

Thanks @junrao ! Comments addressed.

Ran the ProducerAppendPathBenchmark we used on the initial PR (#22997), trunk vs this branch with the incremental (full/default not touched on this PR): no regressions identified with that

Screenshot 2026-08-13 at 12 50 51 PM

--update
If the single mechanism I introduced here to bound retries on the incremental makes sense, I will file a jira to review the full path separately for the same (retries on partition change unbounded)

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants