KAFKA-20864: Fix to avoid early batch close if extension fails - #23008
KAFKA-20864: Fix to avoid early batch close if extension fails#23008lianetm wants to merge 16 commits into
Conversation
| // 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; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
it can have been => it could have been
| ChunkedRecordAccumulator accum = accumRef.get(); | ||
| Deque<ProducerBatch> dq = accum.getDeque(tp1); | ||
| ProducerBatch drained; | ||
| synchronized (dq) { |
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
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, |
| } | ||
|
|
||
| /** | ||
| * The extension acquire fails with the batch it was sized against already replaced, so nothing is |
There was a problem hiding this comment.
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,
|
Thanks for the review @junrao ! All comments addressed |
| // 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; |
There was a problem hiding this comment.
In theory, the existing append could loop forever if the partition keep changing. Should we have a more general approach to handle that too?
There was a problem hiding this comment.
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):
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)
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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).
|
Thanks @junrao , comment addressed : #23008 (comment) |
| * <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 |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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()); |
There was a problem hiding this comment.
Should we use retry consistently instead of having both restart and retry?
|
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
--update |

This PR includes the following fixes to avoid closing the wrong batch
when a mid-batch extension acquire fails on an exhausted pool:
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)
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.
count against max.block.ms (like the blocking ops do)
Reviewers: Jun Rao junrao@gmail.com