This is an automated email from the ASF dual-hosted git repository.

AndrewJSchofield pushed a commit to branch trunk
in repository https://gitbox.apache.org/repos/asf/kafka.git


The following commit(s) were added to refs/heads/trunk by this push:
     new ae9cf0f2692 KAFKA-20723: Resolve DLQ copy records per produce round. 
(#22919)
ae9cf0f2692 is described below

commit ae9cf0f26925a4ee3f2aebedd71845f7d77a1d62
Author: Sushant Mahajan <[email protected]>
AuthorDate: Mon Jul 27 13:18:36 2026 +0530

    KAFKA-20723: Resolve DLQ copy records per produce round. (#22919)
    
    * ShareGroupDLQStateManager previously resolved a DLQ record parameter's
    entire archived offset range once, eagerly, before the handler was even
    enqueued to send - capping total copyable content at roughly one
    message's worth (regardless of how many produce requests the range
    needed) and delaying the first produce request until the whole range,
    including any remote-tiered reads, had been read.
    * Move to resolving one produce round at a time instead: resolveRound()
    now fetches only the window starting at nextOffsetToSend, with a fresh
    decompression budget per round, and handleProduceResponse() resolves the
    next round before rejoining the coalescing node map. This lets a large
    range recover real content across every round it needs, and lets the
    first produce request go out as soon as its own round resolves.
    * Also harden two related failure paths surfaced while working through
    this: an uncaught exception from one handler's onComplete() no longer
    prevents other handlers sharing the same coalesced produce response from
    being notified (each is now wrapped via completeHandlerSafely(), with
    its own future explicitly failed if it throws), and resolveRound() no
    longer lets a synchronous exception from maybeFetchRecordData() escape
    into a RequestCompletionHandler callback, where depending on the
    response-delivery path it wasn't guaranteed to be caught before reaching
    the broker's fatal-error handling.
    * Separately, in ShareGroupDLQRecordFetcher, tighten the decompression
    budget accounting the round-based design depends on: the fetch now stops
    once enough content has been collected.
    
    Co-Authored-By: Claude Sonnet 5 <[email protected]>
    
    Reviewers: Andrew Schofield <[email protected]>, Apoorv Mittal
     <[email protected]>
    
    ---------
    
    Co-authored-by: Claude Sonnet 5 <[email protected]>
---
 .../share/dlq/ShareGroupDLQRecordFetcher.java      | 240 +++++++++----
 .../share/dlq/ShareGroupDLQStateManager.java       | 157 +++++++--
 .../share/dlq/ShareGroupDLQRecordFetcherTest.java  | 244 ++++++++++++-
 .../share/dlq/ShareGroupDLQStateManagerTest.java   | 384 ++++++++++++++++++++-
 4 files changed, 911 insertions(+), 114 deletions(-)

diff --git 
a/server/src/main/java/org/apache/kafka/server/share/dlq/ShareGroupDLQRecordFetcher.java
 
b/server/src/main/java/org/apache/kafka/server/share/dlq/ShareGroupDLQRecordFetcher.java
index 588077ade02..21acd854d10 100644
--- 
a/server/src/main/java/org/apache/kafka/server/share/dlq/ShareGroupDLQRecordFetcher.java
+++ 
b/server/src/main/java/org/apache/kafka/server/share/dlq/ShareGroupDLQRecordFetcher.java
@@ -58,18 +58,29 @@ import java.util.concurrent.CompletableFuture;
  * pending (a remote read in flight) the loop returns and is resumed from the 
callback - so the calling
  * thread is never blocked on remote storage IO and the synchronous path never 
recurses.
  *
- * <p>Best-effort: the returned future always completes normally with whatever 
records could be read.
- * Offsets that cannot be read - locally or remotely - are simply absent from 
the map, leaving the caller
- * to produce a DLQ record with headers only for them.
+ * <p>Best-effort: the returned future always completes normally with a {@link 
FetchResult} holding whatever
+ * records could be read, plus the offset through which a definitive outcome 
was reached this fetch. Offsets
+ * within that boundary that have no entry in {@link FetchResult#records()} 
were either permanently excluded
+ * because they (or their containing batch) can never fit within {@code 
maxDecompressedBytes}, or belong to
+ * a range given up on entirely after a genuine failure (a read error, or an 
unexpected exception) - either
+ * way, the caller should produce a DLQ record with headers only for them. 
Offsets beyond the boundary were
+ * never examined because this fetch ran out of budget before reaching them, 
and are eligible for a fresh
+ * fetch with its own full budget.
  *
  * <p>{@code maxFetchBytes} only bounds the compressed, on-the-wire size of 
each read; a compressed batch
  * can still decompress into something far larger in memory. To keep a 
pathologically compressible batch
- * from ballooning broker heap usage, decompression is bounded by {@code 
maxDecompressedBytes} (shared across
- * the whole fetch): compressed batches are decompressed into a size-capped 
flat buffer before any record is
- * parsed (see {@link #decompressBounded}), so even a single record with a 
fabricated huge length can't force
- * a large allocation. A batch that would exceed the remaining budget is 
simply discarded - its offsets are
- * skipped, same as any other partial-failure case - and the fetch continues 
with whatever batches follow,
- * rather than one oversized batch poisoning the rest of the range.
+ * from ballooning broker heap usage, each batch is decompressed into a 
size-capped flat buffer before any
+ * record is parsed (see {@link #decompressBounded}), bounded by {@code 
maxDecompressedBytes} - so even a
+ * single record with a fabricated huge length can't force a large allocation. 
A batch whose decompressed
+ * size exceeds {@code maxDecompressedBytes} on its own can never fit in any 
fetch, so it is permanently
+ * excluded rather than retried.
+ *
+ * <p>{@code maxDecompressedBytes} also bounds the total amount of content 
this fetch collects: once the
+ * records actually collected (compressed or not) reach that many bytes, or a 
record is found that doesn't
+ * fit alongside what's already been collected, the fetch stops rather than 
continuing to walk the rest of
+ * the requested range - reading further would just be discarded work, since 
callers only ever need one
+ * message's worth of records at a time (see {@code 
ShareGroupDLQStateManager}, which resolves one produce
+ * round at a time and passes its {@code dlqTopicMaxMessageBytes} as this 
budget).
  *
  * <p>Instances are single-use: create one fetcher per {@link #fetch()} call.
  */
@@ -94,13 +105,27 @@ public class ShareGroupDLQRecordFetcher {
     private final Map<Long, Record> recordMap;
     private final long maxDecompressedBytes;
     private final BufferSupplier bufferSupplier = BufferSupplier.create();
-    private long decompressedBytes = 0;
+    // Cumulative sizeInBytes() of every record actually collected into 
recordMap so far, regardless of
+    // whether it came from a compressed or uncompressed batch. Compared 
against maxDecompressedBytes to
+    // decide whether the next record still fits alongside what's already been 
collected this fetch.
+    private long collectedBytes = 0;
+    // The largest offset such that every offset from param.firstOffset() 
through it has a definitive,
+    // in-scope outcome this fetch: collected into recordMap, or permanently 
excluded because it (or its
+    // containing batch) can never fit within maxDecompressedBytes. Never 
advances past a record or batch
+    // that was merely deferred for not fitting alongside collectedBytes - 
those, and everything after them,
+    // are left untouched for a fresh fetch (with its own full budget) to 
attempt. Starts one before the
+    // requested range so a fetch that resolves nothing still reports that 
correctly.
+    private long lastResolvedOffset;
+    // Set once a record is found that doesn't fit alongside collectedBytes 
but isn't itself permanently
+    // excluded - signals every loop in this class to stop immediately rather 
than trying further batches or
+    // reads, since that record and everything after it must be left untouched 
for a fresh fetch to attempt.
+    private boolean deferredRemainder = false;
     // We are fetching data for one TopicIdPartition only. Hence, there is no 
need to keep recreating
     // the maxBytes map, and we can re-use a single copy. In similar vein, we 
needn't clear the offsets
     // map either and just update the value corresponding to the 
TopicIdPartition key across iterations.
     private final LinkedHashMap<TopicIdPartition, Long> offsets = new 
LinkedHashMap<>();
     private final LinkedHashMap<TopicIdPartition, Integer> maxBytesMap = new 
LinkedHashMap<>();
-    private final CompletableFuture<Map<Long, Record>> result = new 
CompletableFuture<>();
+    private final CompletableFuture<FetchResult> result = new 
CompletableFuture<>();
     private final FetchParams fetchParams;
 
     public ShareGroupDLQRecordFetcher(LogReader logReader, Time time, 
ShareGroupDLQRecordParameter param,
@@ -115,6 +140,7 @@ public class ShareGroupDLQRecordFetcher {
         this.recordMap = new HashMap<>(recordCount);
         this.maxBytesMap.put(tp, maxFetchBytes);
         this.maxDecompressedBytes = maxDecompressedBytes;
+        this.lastResolvedOffset = param.firstOffset() - 1;
         this.fetchParams = new FetchParams(
             FetchRequest.CONSUMER_REPLICA_ID,           // -1, reading as a 
consumer
             -1,                                         // replicaEpoch
@@ -129,19 +155,20 @@ public class ShareGroupDLQRecordFetcher {
     /**
      * Fetches the source records for the configured offset range.
      *
-     * @return A future that always completes normally with the records that 
could be read, keyed by offset.
+     * @return A future that always completes normally with the records that 
could be read, plus the
+     *         boundary through which a definitive outcome was reached - see 
{@link FetchResult}.
      */
-    public CompletableFuture<Map<Long, Record>> fetch() {
+    public CompletableFuture<FetchResult> fetch() {
         try {
             runFrom(param.firstOffset());
         } catch (Throwable e) {
             // Never let an unexpected error - including an OutOfMemoryError 
from a maliciously
             // compressible record, or an 
InvalidRecordException/KafkaException from a rejected
-            // malformed one - escape. Uses complete() (not 
result.complete(Map.of())) so that any
-            // records already collected from earlier batches in this call are 
still returned, rather
-            // than discarding a partially-successful copy because of one bad 
batch.
+            // malformed one - escape. Uses completeGivingUpOnRemainder() (not 
result.complete(...))
+            // so that any records already collected from earlier batches in 
this call are still
+            // returned, rather than discarding a partially-successful copy 
because of one bad batch.
             log.warn("Unexpected error fetching records for {}. Returning 
records fetched so far.", param, e);
-            complete();
+            completeGivingUpOnRemainder();
         }
         return result;
     }
@@ -151,10 +178,14 @@ public class ShareGroupDLQRecordFetcher {
      * complete (local data, or remote data already resolved) the loop 
continues in place; when it is still
      * pending (remote read in flight) the loop returns and is resumed from 
the callback - so the synchronous
      * path never recurses and the async path resumes on a fresh stack (the 
remote storage reader thread).
+     *
+     * <p>Also stops once {@link #shouldStop()} is true - there's no need to 
keep reading once enough usable
+     * content has been collected for the caller's purposes, or a record has 
been found that doesn't fit
+     * alongside it, even if {@link #endOffset} hasn't been reached yet.
      */
     private void runFrom(long startFrom) {
         long nextOffset = startFrom;
-        while (nextOffset <= endOffset) {
+        while (nextOffset <= endOffset && !shouldStop()) {
             offsets.put(tp, nextOffset);
 
             CompletableFuture<LinkedHashMap<TopicIdPartition, LogReadResult>> 
future =
@@ -175,7 +206,7 @@ public class ShareGroupDLQRecordFetcher {
             // completes normally; any unexpected exceptional completion is 
caught by fetch().
             long advanced = collect(nextOffset, 
logReadResult(future.getNow(null)));
             if (advanced <= nextOffset) {
-                complete();     // no progress - stop
+                completeAfterNoProgress();     // no progress - stop
                 return;
             }
             nextOffset = advanced;
@@ -183,6 +214,32 @@ public class ShareGroupDLQRecordFetcher {
         complete();
     }
 
+    /**
+     * True once enough content has been collected for the caller's purposes, 
or a record has been found
+     * that doesn't fit alongside {@link #collectedBytes} - either way, every 
loop in this class should stop
+     * immediately rather than attempting further batches or reads.
+     */
+    private boolean shouldStop() {
+        return deferredRemainder || collectedBytes >= maxDecompressedBytes;
+    }
+
+    /**
+     * Completes after a read makes no progress. This happens for two very 
different reasons that need
+     * different handling: {@link #deferredRemainder} being set means a record 
was found that doesn't fit
+     * alongside {@link #collectedBytes} - a budget-related stop worth 
retrying fresh, so the boundary is
+     * preserved as-is via {@link #complete()}. Otherwise, nothing advanced 
because the read itself found
+     * nothing new (an error, an empty result, or a batch containing only 
already-seen offsets) - a genuine
+     * dead end that a retry with a fresh budget wouldn't fix, so {@link 
#completeGivingUpOnRemainder()}
+     * gives up on the rest of the range instead of leaving it to be retried 
forever.
+     */
+    private void completeAfterNoProgress() {
+        if (deferredRemainder) {
+            complete();
+        } else {
+            completeGivingUpOnRemainder();
+        }
+    }
+
     /**
      * Extracts the read result for the partition being fetched, or {@code 
null} when none was produced
      * (e.g. the read returned no entry for the partition).
@@ -199,21 +256,21 @@ public class ShareGroupDLQRecordFetcher {
         try {
             if (exception != null) {
                 log.warn("Unable to read records at offset {} for {}. Skipping 
it.", readFrom, param, exception);
-                complete();
+                completeGivingUpOnRemainder();
                 return;
             }
             long advanced = collect(readFrom, logReadResult);
             if (advanced <= readFrom) {
-                complete();         // no progress - stop
+                completeAfterNoProgress();  // no progress - stop
             } else {
-                runFrom(advanced);  // resume the loop
+                runFrom(advanced);          // resume the loop
             }
         } catch (Throwable e) {
             // Never let an unexpected error - including an OutOfMemoryError 
from a maliciously
             // compressible record, or an 
InvalidRecordException/KafkaException from a rejected
             // malformed one - escape; return whatever was collected so far.
             log.warn("Unexpected error processing records for {}. Returning 
records fetched so far.", param, e);
-            complete();
+            completeGivingUpOnRemainder();
         }
     }
 
@@ -236,13 +293,17 @@ public class ShareGroupDLQRecordFetcher {
 
     /**
      * Adds the records within the requested range to the map and returns the 
offset to read from next
-     * (never moves backwards). Records below readFrom or above endOffset are 
ignored. A batch that would
-     * exceed the decompression budget ({@link #maxDecompressedBytes}) is 
skipped - its offsets are left
-     * unread - but the loop continues on to whatever batches follow.
+     * (never moves backwards). Records below readFrom or above endOffset are 
ignored.
+     *
+     * <p>Also stops before starting a new batch once {@link #shouldStop()} is 
true - a single read can
+     * return many batches (up to {@code maxFetchBytes} worth), so this check 
can't wait for {@link
+     * #runFrom}'s between-reads check alone, or a read containing several 
batches could keep being
+     * processed past the point where the fetch should have stopped.
      */
     private long collectRecords(Records records, long readFrom) {
         long nextOffset = readFrom;
         for (RecordBatch batch : records.batches()) {
+            if (shouldStop()) return nextOffset;
             nextOffset = collectFromBatch(batch, nextOffset);
         }
         return nextOffset;
@@ -271,8 +332,24 @@ public class ShareGroupDLQRecordFetcher {
             // nextOffset backwards.
             if (record.offset() < readFrom) continue;
             if (record.offset() > endOffset) return nextOffset;
+            long recordSize = record.sizeInBytes();
+            if (recordSize > maxDecompressedBytes) {
+                // This one record can never fit within maxDecompressedBytes, 
in this fetch or any other -
+                // exclude it permanently rather than leaving it for a retry 
that could never succeed.
+                nextOffset = Math.max(nextOffset, record.offset() + 1);
+                lastResolvedOffset = Math.max(lastResolvedOffset, 
record.offset());
+                continue;
+            }
+            if (collectedBytes + recordSize > maxDecompressedBytes) {
+                // Fits on its own, but not alongside what's already been 
collected this fetch - stop here
+                // and leave this record, and everything after it, untouched 
for a fresh fetch to attempt.
+                deferredRemainder = true;
+                return nextOffset;
+            }
             recordMap.put(record.offset(), record);
+            collectedBytes += recordSize;
             nextOffset = Math.max(nextOffset, record.offset() + 1); // never 
moves backwards
+            lastResolvedOffset = Math.max(lastResolvedOffset, record.offset());
         }
         return nextOffset;
     }
@@ -306,10 +383,11 @@ public class ShareGroupDLQRecordFetcher {
     private long collectFromCompressedBatch(DefaultRecordBatch batch, long 
readFrom) {
         ByteBuffer decompressed = decompressBounded(batch);
         if (decompressed == null) {
-            // This batch alone would exceed the remaining budget: skip past 
it (its offsets are left
-            // unread) rather than aborting the whole fetch, so later batches 
- which may well fit - are
-            // still read.
-            return Math.max(readFrom, batch.lastOffset() + 1);
+            // The batch's decompressed size exceeds maxDecompressedBytes on 
its own, so it can never fit -
+            // in this fetch or any other. Exclude it permanently and move on 
to whatever batches follow.
+            long lastOffsetInRange = Math.min(batch.lastOffset(), endOffset);
+            lastResolvedOffset = Math.max(lastResolvedOffset, 
lastOffsetInRange);
+            return Math.max(readFrom, lastOffsetInRange + 1);
         }
 
         long baseOffset = batch.baseOffset();
@@ -325,26 +403,38 @@ public class ShareGroupDLQRecordFetcher {
             // nextOffset backwards.
             if (record.offset() < readFrom) continue;
             if (record.offset() > endOffset) return nextOffset;
+            long recordSize = record.sizeInBytes();
+            if (collectedBytes + recordSize > maxDecompressedBytes) {
+                // The whole batch already fit within maxDecompressedBytes on 
its own (decompression would
+                // have failed above otherwise), so this record only fails to 
fit alongside what's already
+                // been collected - stop here and leave it, and everything 
after it, untouched for a fresh
+                // fetch to attempt.
+                deferredRemainder = true;
+                return nextOffset;
+            }
             recordMap.put(record.offset(), record);
+            collectedBytes += recordSize;
             nextOffset = Math.max(nextOffset, record.offset() + 1); // never 
moves backwards
+            lastResolvedOffset = Math.max(lastResolvedOffset, record.offset());
         }
         return nextOffset;
     }
 
     /**
      * Decompresses the batch's record region into a flat buffer, reading in 
{@link #DECOMPRESS_CHUNK_BYTES}
-     * chunks and checking the cumulative total against the remaining 
decompression budget before each chunk
-     * is kept - before any record-level parsing happens. A single record with 
a fabricated huge length can't
-     * cause a large allocation this way: parsing only begins once the buffer 
is already fully bounded, and the
-     * buffer-based reader rejects a record whose declared length doesn't fit 
what's left of it.
+     * chunks and checking the cumulative total against {@link 
#maxDecompressedBytes} before each chunk is
+     * kept - before any record-level parsing happens. A single record with a 
fabricated huge length can't
+     * cause a large allocation this way: parsing only begins once the buffer 
is already fully bounded, and
+     * the buffer-based reader rejects a record whose declared length doesn't 
fit what's left of it.
+     *
+     * <p>Bounded by the full {@code maxDecompressedBytes} regardless of 
{@link #collectedBytes}: whether a
+     * batch fits is a property of the batch alone, not of how much of the 
budget earlier batches in this
+     * fetch happened to use, so every batch gets the same, full-sized chance 
to decompress.
      *
-     * @return the bounded, flipped buffer ready for reading, or {@code null} 
if the remaining decompression
-     *         budget ({@link #maxDecompressedBytes}, shared across the whole 
fetch) is already exhausted.
+     * @return the bounded, flipped buffer ready for reading, or {@code null} 
if the batch's decompressed
+     *         size exceeds {@link #maxDecompressedBytes} on its own.
      */
     private ByteBuffer decompressBounded(DefaultRecordBatch batch) {
-        long budget = maxDecompressedBytes - decompressedBytes;
-        if (budget <= 0) return null;
-
         int chunkBytes = Math.min(batch.sizeInBytes(), DECOMPRESS_CHUNK_BYTES);
         try (InputStream in = batch.recordInputStream(bufferSupplier);
              ByteBufferOutputStream out = new 
ByteBufferOutputStream(chunkBytes)) {
@@ -353,14 +443,13 @@ public class ShareGroupDLQRecordFetcher {
             long total = 0;
             while ((nRead = in.read(chunk, 0, chunk.length)) != -1) {
                 total += nRead;
-                if (total > budget) {
-                    log.warn("Decompressed batch data for {} exceeded the {} 
byte budget. " +
-                        "Stopping record copy early to bound memory use.", 
param, maxDecompressedBytes);
+                if (total > maxDecompressedBytes) {
+                    log.warn("Decompressed batch data for {} exceeded the {} 
byte budget on its own. " +
+                        "The batch can never fit and will be permanently 
skipped.", param, maxDecompressedBytes);
                     return null;
                 }
                 out.write(chunk, 0, nRead);
             }
-            decompressedBytes += total;
             out.buffer().flip();
             return out.buffer();
         } catch (IOException e) {
@@ -371,14 +460,9 @@ public class ShareGroupDLQRecordFetcher {
     /**
      * Fallback used only for legacy magic v0/v1 compressed batches (no 
bounded-buffer path available for
      * that format - see {@link #asDefaultRecordBatch}): iterates records one 
at a time via {@link
-     * RecordBatch#streamingIterator}, tracking a cumulative 
decompressed-bytes total across the whole fetch
-     * and stopping once {@link #maxDecompressedBytes} is exceeded. Unlike 
{@link
-     * #collectFromCompressedBatch}, this does not prevent a single oversized 
record's initial allocation -
-     * it only bounds growth across multiple records/batches. Because that 
allocation already happened (the
-     * byte count reflects real, already-materialized memory rather than data 
that was rejected before use),
-     * the overshoot is not undone: {@link #decompressedBytes} stays over 
budget, so later batches on this
-     * path keep getting skipped too. The rest of the current batch is skipped 
- not the whole fetch - so
-     * later batches (e.g. uncompressed ones, which carry no such risk) are 
still read.
+     * RecordBatch#streamingIterator}. Unlike {@link 
#collectFromCompressedBatch}, this does not prevent a
+     * single oversized record's initial allocation before its size is known - 
it only decides, once a
+     * record has already been decompressed, whether to keep it or discard it.
      */
     private long collectWithCumulativeCap(RecordBatch batch, long readFrom) {
         long nextOffset = readFrom;
@@ -388,24 +472,34 @@ public class ShareGroupDLQRecordFetcher {
                 if (record.offset() < readFrom) continue;
                 if (record.offset() > endOffset) return nextOffset;
 
-                decompressedBytes += record.sizeInBytes();
-                if (decompressedBytes > maxDecompressedBytes) {
-                    log.warn("Decompressed record data for {} exceeded {} 
bytes at offset {}. " +
-                        "Skipping the rest of this batch to bound memory use.",
-                        param, maxDecompressedBytes, record.offset());
-                    return Math.max(nextOffset, batch.lastOffset() + 1);
+                long recordSize = record.sizeInBytes();
+                if (recordSize > maxDecompressedBytes) {
+                    // This one record can never fit within 
maxDecompressedBytes, in this fetch or any
+                    // other - exclude it permanently rather than leaving it 
for a retry that could never
+                    // succeed.
+                    nextOffset = Math.max(nextOffset, record.offset() + 1);
+                    lastResolvedOffset = Math.max(lastResolvedOffset, 
record.offset());
+                    continue;
+                }
+                if (collectedBytes + recordSize > maxDecompressedBytes) {
+                    // Fits on its own, but not alongside what's already been 
collected this fetch - stop
+                    // here and leave it, and everything after it, untouched 
for a fresh fetch to attempt.
+                    deferredRemainder = true;
+                    return nextOffset;
                 }
 
                 recordMap.put(record.offset(), record);
+                collectedBytes += recordSize;
                 nextOffset = Math.max(nextOffset, record.offset() + 1);
+                lastResolvedOffset = Math.max(lastResolvedOffset, 
record.offset());
             }
         }
         return nextOffset;
     }
 
     /**
-     * Completes the result future with an immutable snapshot of the records 
collected so far. Offsets
-     * that could not be read are absent from the map; the caller produces a 
headers-only DLQ record for them.
+     * Completes the result future with an immutable snapshot of the records 
collected so far, plus the
+     * boundary through which a definitive outcome was reached this fetch.
      */
     private void complete() {
         bufferSupplier.close();
@@ -414,6 +508,32 @@ public class ShareGroupDLQRecordFetcher {
         if (recordCount != recordMap.size()) {
             log.info("Total offsets requested: {}, Records found: {}", 
recordCount, recordMap.size());
         }
-        result.complete(Map.copyOf(recordMap));
+        result.complete(new FetchResult(Map.copyOf(recordMap), 
lastResolvedOffset));
+    }
+
+    /**
+     * Like {@link #complete()}, but first treats the entire remaining range - 
from wherever this fetch got
+     * to, through {@link #endOffset} - as settled. Used when the reason for 
stopping is a genuine failure
+     * (a read error, an unexpected exception, or a read that made no 
progress) rather than running out of
+     * budget: unlike a budget-related stop, retrying a failure like this with 
a fresh budget wouldn't help,
+     * so there's nothing to gain by leaving the remainder for a later fetch 
to retry - the caller should
+     * treat it as permanently unavailable and produce headers only for it, 
the same as any offset excluded
+     * for not fitting the decompression budget.
+     */
+    private void completeGivingUpOnRemainder() {
+        lastResolvedOffset = Math.max(lastResolvedOffset, endOffset);
+        complete();
+    }
+
+    /**
+     * @param records the source records that could be read, keyed by offset
+     * @param lastResolvedOffset the largest offset such that every offset 
from the requested range's start
+     *                           through it has a definitive, in-scope outcome 
- collected into
+     *                           {@code records}, or permanently excluded 
because it (or its containing
+     *                           batch) can never fit within the configured 
decompression budget. Offsets
+     *                           beyond it were never examined and should be 
retried, with a fresh budget,
+     *                           by a later fetch.
+     */
+    public record FetchResult(Map<Long, Record> records, long 
lastResolvedOffset) {
     }
 }
diff --git 
a/server/src/main/java/org/apache/kafka/server/share/dlq/ShareGroupDLQStateManager.java
 
b/server/src/main/java/org/apache/kafka/server/share/dlq/ShareGroupDLQStateManager.java
index 51e674a514f..0661adca3e8 100644
--- 
a/server/src/main/java/org/apache/kafka/server/share/dlq/ShareGroupDLQStateManager.java
+++ 
b/server/src/main/java/org/apache/kafka/server/share/dlq/ShareGroupDLQStateManager.java
@@ -198,14 +198,14 @@ public class ShareGroupDLQStateManager {
             return future;
         }
 
-        // Resolve the source records once, here - on the calling thread for 
local offsets and, for
+        // Resolve round 1's source records here - on the calling thread for 
local offsets and, for
         // tiered offsets, asynchronously on the remote-storage reader pool - 
and enqueue only once
-        // resolution finishes. This keeps both the local and remote reads off 
the single sender
-        // thread, and the memoized result is reused on every (re)send so 
retries never re-fetch.
-        // Records are only read when copy is enabled for the group and the 
DLQ is correctly
+        // resolution finishes. Later rounds (if the range needs more than one 
produce request) are
+        // resolved the same way from handleProduceResponse(), each with its 
own fresh decompression
+        // budget. Records are only read when copy is enabled for the group 
and the DLQ is correctly
         // configured (validated above); otherwise we enqueue immediately.
         if (cacheHelper.isShareGroupDlqCopyRecordEnabled(param.groupId())) {
-            requestHandler.resolveRecords().whenComplete((ignored, 
ignoredError) -> enqueue(requestHandler));
+            requestHandler.resolveRound().whenComplete((ignored, ignoredError) 
-> enqueue(requestHandler));
         } else {
             enqueue(requestHandler);
         }
@@ -246,6 +246,26 @@ public class ShareGroupDLQStateManager {
         sender.wakeup();
     }
 
+    /**
+     * Invokes {@code handler.onComplete(response)}, isolating this one 
handler's failure from
+     * whatever else is being processed in the same batch (see the 
coalesced-response callback in
+     * {@link SendThread#generateRequests}, which invokes this once per 
handler in a shared
+     * produce response). An uncaught exception here must not prevent the rest 
of that batch's
+     * handlers from being notified - which would otherwise leave their {@link 
#dlq} futures
+     * hanging forever - so it's caught, logged, and the offending handler's 
own future is
+     * explicitly failed too, in case the exception happened before {@code 
onComplete} reached its
+     * own completion call.
+     */
+    // Visibility for tests
+    static void completeHandlerSafely(ProduceRequestHandler handler, 
ClientResponse response) {
+        try {
+            handler.onComplete(response);
+        } catch (Exception e) {
+            log.error("Uncaught error handling produce response for handler 
{}.", handler, e);
+            handler.requestErrorResponse(e);
+        }
+    }
+
     // Visibility for tests
     class ProduceRequestHandler implements RequestCompletionHandler {
         private final CompletableFuture<Void> result;
@@ -259,12 +279,22 @@ public class ShareGroupDLQStateManager {
         private volatile Node dlqPartitionLeaderNode;
         private volatile int dlqDestinationPartition;
         private volatile ShareGroupDLQMetadataCacheHelper.TopicPartitionData 
dlqTopicPartitionData;
-        // The original source records, resolved once before this handler is 
enqueued (see resolveRecords()).
-        // Volatile because resolution runs off the sender thread - on the 
calling thread for local offsets
-        // and, for tiered offsets, on the remote-storage reader pool - while 
this value is read on the
-        // sender thread when the produce request is built. Memoized: set once 
and reused for every (re)send,
-        // so retries never re-fetch.
+        // The original source records for the CURRENT round only, resolved 
before this round is added
+        // to the node map (see resolveRound()): once for round 1 (before this 
handler is first
+        // enqueued), then again for each subsequent round after a successful 
produce response advances
+        // nextOffsetToSend. Volatile because resolution runs off the sender 
thread - on the calling
+        // thread for local offsets and, for tiered offsets, on the 
remote-storage reader pool - while
+        // this value is read on the sender thread when the produce request is 
built. Memoized per round:
+        // set once per round and reused for every retry of that round, so 
retries never re-fetch.
         private volatile Map<Long, Record> resolvedRecordData = Map.of();
+        // The largest offset such that everything from this round's start 
through it has a definitive
+        // outcome (real content, or a permanently-excluded gap) per the fetch 
behind resolvedRecordData -
+        // see ShareGroupDLQRecordFetcher.FetchResult. Caps 
topicProduceData()'s walk so it never packs in
+        // offsets the fetch never got to attempt this round as headers-only; 
those stay untouched for a
+        // fresh round (with a fresh decompression budget) to retry. Defaults 
to param.lastOffset() so a
+        // handler that never calls resolveRound() (copy-record disabled) 
still leaves the full range fair
+        // game for topicProduceData()'s own size-based packing.
+        private volatile long lastResolvedOffsetThisRound;
         // The next offset that has not yet been included in a produce 
request. Starts at
         // param.firstOffset() and advances past whatever topicProduceData() 
managed to fit within
         // dlqTopicMaxMessageBytes() on each successful send, so a range that 
doesn't fit in a single
@@ -295,6 +325,7 @@ public class ShareGroupDLQStateManager {
             this.result = result;
             this.nextOffsetToSend = param.firstOffset();
             this.lastOffsetIncludedThisRound = param.firstOffset() - 1;
+            this.lastResolvedOffsetThisRound = param.lastOffset();
             this.createTopicsBackoff = new ExponentialBackoffManager(
                 maxRPCRetryAttempts,
                 backoffMs,
@@ -411,7 +442,16 @@ public class ShareGroupDLQStateManager {
             List<SimpleRecord> simpleRecords = new ArrayList<>();
             int batchSize = DefaultRecordBatch.RECORD_BATCH_OVERHEAD;
             Long baseTimestamp = null;
-            for (long offset = nextOffsetToSend; offset <= param.lastOffset(); 
offset++) {
+            // Capped at lastResolvedOffsetThisRound, not just 
param.lastOffset(): offsets beyond it were
+            // never attempted by this round's fetch and must stay untouched 
for a fresh round to retry,
+            // rather than being packed in here as headers-only just because 
they have no map entry yet.
+            // Floored at nextOffsetToSend itself (mirroring the single-record 
floor below for the
+            // size-exceeds-limit case): a fetch that resolved nothing at all 
for this round - e.g. a read
+            // that failed outright - would otherwise leave this loop with 
zero iterations, producing an
+            // empty record batch, which the broker rejects outright. Sending 
nextOffsetToSend alone,
+            // headers-only, guarantees forward progress even when nothing 
could be resolved.
+            long roundEnd = Math.max(nextOffsetToSend, 
Math.min(param.lastOffset(), lastResolvedOffsetThisRound));
+            for (long offset = nextOffsetToSend; offset <= roundEnd; offset++) 
{
                 // Must be wall-clock (epoch) time: log retention decides 
whether to delete this
                 // record's segment by comparing its timestamp against the 
current wall-clock time.
                 long timestamp = time.milliseconds();
@@ -520,7 +560,11 @@ public class ShareGroupDLQStateManager {
                 } catch (ConfigException e) {
                     return false;
                 }
-                // Source records were already resolved before enqueue; just 
add to the node map.
+                // This path handles both round 1 (dlq() already resolved its 
records before enqueue)
+                // and a retry of whichever round is currently in flight 
(nextOffsetToSend is untouched
+                // by a failed produce, so that round's records - resolved 
when it was first entered -
+                // are still valid); either way, this round's data is already 
resolved, so just add to
+                // the node map without fetching again.
                 addRequestToNodeMap(dlqPartitionLeaderNode, this);
             }
             return isDlqTopicPresent;
@@ -615,7 +659,9 @@ public class ShareGroupDLQStateManager {
                             try {
                                 populateDLQTopicData();
                                 createTopicsBackoff.resetAttempts();
-                                // Source records were already resolved before 
enqueue; just add to the node map.
+                                // This path is only ever reached for round 1 
(a brand-new handler still
+                                // waiting on topic creation), whose records 
dlq() already resolved before
+                                // enqueue; just add to the node map.
                                 
addRequestToNodeMap(this.dlqPartitionLeaderNode, this);
                             } catch (ConfigException e) {
                                 LOG.error("Error enqueueing after DLQ create 
topic response {}.", this, e);
@@ -715,8 +761,11 @@ public class ShareGroupDLQStateManager {
                             if (lastOffsetIncludedThisRound < 
param.lastOffset()) {
                                 // Only part of the offset range fit within 
dlqTopicMaxMessageBytes - continue
                                 // sending the remainder as a follow-up 
produce request instead of completing.
+                                // Resolve the next round's source records (a 
fresh decompression budget, scoped
+                                // to what's left to send) before rejoining 
the node map, mirroring the
+                                // "resolved before eligible for coalescing" 
invariant dlq() establishes for round 1.
                                 nextOffsetToSend = lastOffsetIncludedThisRound 
+ 1;
-                                addRequestToNodeMap(dlqPartitionLeaderNode(), 
this);
+                                resolveRound().whenComplete((ignored, err) -> 
addRequestToNodeMap(dlqPartitionLeaderNode(), this));
                             } else {
                                 this.result.complete(null);
                             }
@@ -765,34 +814,61 @@ public class ShareGroupDLQStateManager {
         }
 
         /**
-         * Resolves the original source records for this handler once, before 
it is enqueued - reading
-         * from the local log on the calling thread and, for any offsets 
tiered to remote storage,
-         * asynchronously on the remote-storage reader pool. The result is 
memoized in
-         * {@link #resolvedRecordData} and reused for every (re)send, so the 
single sender thread never
-         * reads the log (neither local nor remote) and retries do not 
re-fetch.
+         * Resolves the source records for the CURRENT round only - the window 
starting at
+         * {@link #nextOffsetToSend} - reading from the local log on the 
calling thread and, for any
+         * offsets tiered to remote storage, asynchronously on the 
remote-storage reader pool. The result
+         * is memoized in {@link #resolvedRecordData} and reused for every 
retry of this same round (this
+         * method is only ever called once per round - see {@link #dlq} for 
round 1 and
+         * {@link #handleProduceResponse} for subsequent rounds - so a round's 
data is never re-fetched by
+         * a retry of that round).
          *
-         * <p>A failed fetch is non-fatal: {@link #resolvedRecordData} stays 
empty and the DLQ record is
-         * produced with headers only (no key/value), mirroring how 
individually unavailable offsets are skipped.
+         * <p>A failed fetch is non-fatal: {@link #resolvedRecordData} stays 
empty, {@link
+         * #lastResolvedOffsetThisRound} is set to {@code param.lastOffset()} 
so {@code topicProduceData()}
+         * treats the whole remaining range as settled, and the DLQ record is 
produced with headers only
+         * (no key/value) for it - mirroring how individually unavailable 
offsets are skipped. Unlike a
+         * fetch that partially succeeds (see {@link 
ShareGroupDLQRecordFetcher.FetchResult}), a total
+         * failure here isn't a decompression-budget gap a fresh round could 
resolve, so there's no reason
+         * to hold any of the range back for a retry. This applies equally to 
an unexpected error thrown
+         * synchronously by {@link #maybeFetchRecordData} itself (e.g. a 
cache-helper lookup) - not just
+         * one carried by the returned future's exceptional completion - since 
this method is called
+         * directly from {@link #handleProduceResponse} (on the sender thread, 
inside a
+         * {@code RequestCompletionHandler} callback) for round 2 onward: 
letting an exception escape
+         * from here would propagate out of {@code onComplete()}, which - 
depending on which internal
+         * path delivers the response - is not guaranteed to be caught before 
reaching the broker's
+         * fatal-error handling.
          *
-         * @return A future that always completes normally, once resolution 
has finished.
+         * @return A future that always completes normally, once this round's 
resolution has finished.
          */
-        CompletableFuture<Void> resolveRecords() {
+        CompletableFuture<Void> resolveRound() {
+            long roundStart = nextOffsetToSend;
             CompletableFuture<Void> resolved = new CompletableFuture<>();
-            maybeFetchRecordData().whenComplete((records, exception) -> {
-                if (exception != null || records == null) {
-                    LOG.warn("Unable to fetch original record data for handler 
{}. DLQ records will be produced with headers only.", this, exception);
-                    this.resolvedRecordData = Map.of();
-                } else {
-                    this.resolvedRecordData = records;
-                }
+            try {
+                maybeFetchRecordData(roundStart).whenComplete((fetchResult, 
exception) -> {
+                    if (exception != null || fetchResult == null) {
+                        LOG.warn("Unable to fetch original record data for 
handler {} for the round starting at offset {}. " +
+                            "DLQ records will be produced with headers only 
for this round.", this, roundStart, exception);
+                        this.resolvedRecordData = Map.of();
+                        this.lastResolvedOffsetThisRound = param.lastOffset();
+                    } else {
+                        this.resolvedRecordData = fetchResult.records();
+                        this.lastResolvedOffsetThisRound = 
fetchResult.lastResolvedOffset();
+                    }
+                    resolved.complete(null);
+                });
+            } catch (Throwable t) {
+                LOG.warn("Unexpected error resolving round starting at offset 
{} for {}. " +
+                    "DLQ records will be produced with headers only for this 
round.", roundStart, this, t);
+                this.resolvedRecordData = Map.of();
+                this.lastResolvedOffsetThisRound = param.lastOffset();
                 resolved.complete(null);
-            });
+            }
             return resolved;
         }
 
-        private CompletableFuture<Map<Long, Record>> maybeFetchRecordData() {
+        private CompletableFuture<ShareGroupDLQRecordFetcher.FetchResult> 
maybeFetchRecordData(long fromOffset) {
             if 
(!cacheHelper.isShareGroupDlqCopyRecordEnabled(param.groupId())) {
-                return CompletableFuture.completedFuture(Map.of());
+                return CompletableFuture.completedFuture(
+                    new ShareGroupDLQRecordFetcher.FetchResult(Map.of(), 
param.lastOffset()));
             }
             // Bounds decompression memory against a pathologically 
compressible source record: there's
             // no point retaining more decompressed data than the DLQ topic 
could ever accept anyway, and
@@ -802,7 +878,18 @@ public class ShareGroupDLQStateManager {
             int maxDecompressedBytes = 
cacheHelper.shareGroupDlqTopic(param.groupId())
                 .map(cacheHelper::dlqTopicMaxMessageBytes)
                 .orElse(DLQ_MAX_FETCH_BYTES);
-            return new ShareGroupDLQRecordFetcher(logReader, time, param, 
DLQ_MAX_FETCH_BYTES, maxDecompressedBytes).fetch();
+            // param itself is never mutated - headers()/topicProduceData() 
rely on its original,
+            // unwindowed firstOffset/lastOffset for the handler's whole 
lifetime. Build a throwaway
+            // windowed copy only to scope this round's fetch (and its 
decompression budget) to what's
+            // left to send.
+            ShareGroupDLQRecordParameter window;
+            if (fromOffset == param.firstOffset()) {
+                window = param;
+            } else {
+                window = new ShareGroupDLQRecordParameter(param.groupId(), 
param.topicIdPartition(), fromOffset,
+                    param.lastOffset(), param.deliveryCount(), param.cause());
+            }
+            return new ShareGroupDLQRecordFetcher(logReader, time, window, 
DLQ_MAX_FETCH_BYTES, maxDecompressedBytes).fetch();
         }
     }
 
@@ -899,7 +986,7 @@ public class ShareGroupDLQStateManager {
                                 // now the combined request has completed
                                 // we need to create responses for individual
                                 // requests which composed the combined request
-                                results.liveHandlers().forEach(handler -> 
handler.onComplete(response));
+                                results.liveHandlers().forEach(handler -> 
completeHandlerSafely(handler, response));
                                 wakeup();
                             }));
                         sending.add(destNode);
diff --git 
a/server/src/test/java/org/apache/kafka/server/share/dlq/ShareGroupDLQRecordFetcherTest.java
 
b/server/src/test/java/org/apache/kafka/server/share/dlq/ShareGroupDLQRecordFetcherTest.java
index 5dac442912f..8867d271484 100644
--- 
a/server/src/test/java/org/apache/kafka/server/share/dlq/ShareGroupDLQRecordFetcherTest.java
+++ 
b/server/src/test/java/org/apache/kafka/server/share/dlq/ShareGroupDLQRecordFetcherTest.java
@@ -37,6 +37,9 @@ import org.apache.kafka.storage.internals.log.FetchDataInfo;
 import org.apache.kafka.storage.internals.log.LogReadResult;
 
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
 
 import java.io.ByteArrayOutputStream;
 import java.io.InputStream;
@@ -44,11 +47,14 @@ import java.io.OutputStream;
 import java.nio.ByteBuffer;
 import java.nio.charset.StandardCharsets;
 import java.util.LinkedHashMap;
+import java.util.List;
 import java.util.Map;
 import java.util.Optional;
 import java.util.OptionalLong;
+import java.util.Set;
 import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.TimeUnit;
+import java.util.stream.Stream;
 
 import static org.junit.jupiter.api.Assertions.assertArrayEquals;
 import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -89,7 +95,14 @@ class ShareGroupDLQRecordFetcherTest {
     }
 
     private Map<Long, Record> fetch(ShareGroupDLQRecordParameter param) throws 
Exception {
-        return fetcher(param).fetch().get(10, TimeUnit.SECONDS);
+        return fetcher(param).fetch().get(10, TimeUnit.SECONDS).records();
+    }
+
+    // The exact on-wire size of a single uncompressed record, computed via 
the real API rather than
+    // hardcoded, so budget-boundary tests can align a budget precisely with a 
record's own size.
+    private static int recordSizeInBytes(SimpleRecord simpleRecord) {
+        MemoryRecords batch = MemoryRecords.withRecords(Compression.NONE, 
simpleRecord);
+        return 
batch.batches().iterator().next().iterator().next().sizeInBytes();
     }
 
     // ---- helpers ----
@@ -255,12 +268,12 @@ class ShareGroupDLQRecordFetcherTest {
         CompletableFuture<LinkedHashMap<TopicIdPartition, LogReadResult>> 
pending = new CompletableFuture<>();
         when(logReader.readAsync(any(), anySet(), any(), any(), 
anyBoolean())).thenReturn(pending);
 
-        CompletableFuture<Map<Long, Record>> resultFuture = fetcher(param(0L, 
2L)).fetch();
+        CompletableFuture<ShareGroupDLQRecordFetcher.FetchResult> resultFuture 
= fetcher(param(0L, 2L)).fetch();
         assertFalse(resultFuture.isDone(), "Fetch should be waiting on the 
pending read");
 
         pending.complete(resultMap(success(record("k0", "v0"), record("k1", 
"v1"), record("k2", "v2"))));
 
-        Map<Long, Record> result = resultFuture.get(10, TimeUnit.SECONDS);
+        Map<Long, Record> result = resultFuture.get(10, 
TimeUnit.SECONDS).records();
         assertEquals(3, result.size());
         assertRecord(result, 0L, "k0", "v0");
         assertRecord(result, 1L, "k1", "v1");
@@ -272,13 +285,13 @@ class ShareGroupDLQRecordFetcherTest {
         CompletableFuture<LinkedHashMap<TopicIdPartition, LogReadResult>> 
pending = new CompletableFuture<>();
         when(logReader.readAsync(any(), anySet(), any(), any(), 
anyBoolean())).thenReturn(pending);
 
-        CompletableFuture<Map<Long, Record>> resultFuture = fetcher(param(0L, 
2L)).fetch();
+        CompletableFuture<ShareGroupDLQRecordFetcher.FetchResult> resultFuture 
= fetcher(param(0L, 2L)).fetch();
         assertFalse(resultFuture.isDone());
 
         // Completing with no records makes no progress, so the resumed loop 
stops and completes empty.
         pending.complete(resultMap(success()));
 
-        assertTrue(resultFuture.get(10, TimeUnit.SECONDS).isEmpty());
+        assertTrue(resultFuture.get(10, TimeUnit.SECONDS).records().isEmpty());
     }
 
     @Test
@@ -286,14 +299,14 @@ class ShareGroupDLQRecordFetcherTest {
         CompletableFuture<LinkedHashMap<TopicIdPartition, LogReadResult>> 
pending = new CompletableFuture<>();
         when(logReader.readAsync(any(), anySet(), any(), any(), 
anyBoolean())).thenReturn(pending);
 
-        CompletableFuture<Map<Long, Record>> resultFuture = fetcher(param(0L, 
2L)).fetch();
+        CompletableFuture<ShareGroupDLQRecordFetcher.FetchResult> resultFuture 
= fetcher(param(0L, 2L)).fetch();
 
         // An unexpected error while processing the resumed records must not 
escape the callback.
         Records throwing = mock(Records.class);
         when(throwing.batches()).thenThrow(new RuntimeException("boom"));
         pending.complete(resultMap(logReadResult(new FetchDataInfo(null, 
throwing), Errors.NONE)));
 
-        assertTrue(resultFuture.get(10, TimeUnit.SECONDS).isEmpty());
+        assertTrue(resultFuture.get(10, TimeUnit.SECONDS).records().isEmpty());
     }
 
     @Test
@@ -320,26 +333,226 @@ class ShareGroupDLQRecordFetcherTest {
         assertRecord(result, 2L, "k2", "v2");
     }
 
+    @ParameterizedTest(name = "{0}")
+    @MethodSource("compressedBatchCases")
+    public void testCompressedBatchPossibilitiesReturnCorrectMapAndOffset(
+            String description, List<SimpleRecord> batchRecords, int 
maxDecompressedBytes,
+            Set<Long> expectedPresentOffsets, long expectedLastResolvedOffset) 
throws Exception {
+        MemoryRecords batch = MemoryRecords.withRecords(0L, 
Compression.gzip().build(),
+            batchRecords.toArray(new SimpleRecord[0]));
+        whenReadAsync(done(logReadResult(new FetchDataInfo(null, batch), 
Errors.NONE)));
+
+        long lastOffset = batchRecords.size() - 1;
+        ShareGroupDLQRecordFetcher.FetchResult result =
+            fetcher(param(0L, lastOffset), 
maxDecompressedBytes).fetch().get(10, TimeUnit.SECONDS);
+
+        assertEquals(expectedPresentOffsets.size(), result.records().size(), 
description);
+        for (long offset = 0; offset <= lastOffset; offset++) {
+            Record record = result.records().get(offset);
+            if (expectedPresentOffsets.contains(offset)) {
+                assertTrue(record != null, description + ": expected offset " 
+ offset + " to be present");
+                SimpleRecord expected = batchRecords.get((int) offset);
+                assertArrayEquals(toArray(expected.key()), 
toArray(record.key()), description);
+                assertArrayEquals(toArray(expected.value()), 
toArray(record.value()), description);
+            } else {
+                assertNull(record, description + ": expected offset " + offset 
+ " to be absent");
+            }
+        }
+        assertEquals(expectedLastResolvedOffset, result.lastResolvedOffset(), 
description);
+    }
+
+    // Covers every shape of compressed-batch decompression outcome: a single 
record that fits, a single
+    // record that can never fit on its own, several small records that 
individually fit but collectively
+    // exceed the budget, and a mix of one oversized record alongside several 
individually-fitting ones -
+    // in the last two cases, decompressBounded() rejects the whole batch (see 
its class-level doc), so
+    // even the individually-fitting records within it are excluded, not just 
the oversized one.
+    private static Stream<Arguments> compressedBatchCases() {
+        SimpleRecord small0 = record("k0", "v".repeat(20));
+        SimpleRecord small1 = record("k1", "v".repeat(20));
+        SimpleRecord small2 = record("k2", "v".repeat(20));
+        SimpleRecord small3 = record("k3", "v".repeat(20));
+        SimpleRecord small4 = record("k4", "v".repeat(20));
+        SimpleRecord big0 = record("k0", "v".repeat(2000));
+        int smallSize = recordSizeInBytes(small0);
+
+        return Stream.of(
+            Arguments.of("single small record fits within budget",
+                List.of(small0), smallSize, Set.of(0L), 0L),
+            Arguments.of("single large record can never fit and is permanently 
excluded",
+                List.of(big0), smallSize, Set.of(), 0L),
+            Arguments.of("multiple small records all fit within budget",
+                List.of(small0, small1, small2), smallSize * 3, Set.of(0L, 1L, 
2L), 2L),
+            Arguments.of("many small records whose combined size exceeds 
budget - whole batch excluded",
+                List.of(small0, small1, small2, small3, small4), smallSize * 
3, Set.of(), 4L),
+            Arguments.of("combination of one large and several small records - 
whole batch excluded",
+                List.of(big0, small1, small2, small3, small4), smallSize * 3, 
Set.of(), 4L)
+        );
+    }
+
+    @Test
+    public void 
testSingleReadWithUncompressedMagicV2AndLegacyCompressedBatchesAllCollected() 
throws Exception {
+        // One read returning three consecutive batches, each using a 
different collection path in
+        // collectFromBatch()'s dispatch: uncompressed (collectUncompressed), 
magic v2 compressed
+        // (collectFromCompressedBatch, the bounded-buffer path), and legacy 
magic v0/v1 compressed
+        // (collectWithCumulativeCap, the streaming fallback). All comfortably 
fit within budget, so this
+        // is purely about correct dispatch and offset/content tracking across 
a heterogeneous sequence,
+        // not budget edge cases (already covered by the batch-specific tests).
+        MemoryRecords uncompressedBatch = MemoryRecords.withRecords(0L, 
Compression.NONE, record("k0", "v0"));
+        MemoryRecords magicV2CompressedBatch = MemoryRecords.withRecords(1L, 
Compression.gzip().build(), record("k1", "v1"));
+        MemoryRecords legacyCompressedBatch = 
MemoryRecords.withRecords(RecordBatch.MAGIC_VALUE_V1, 2L,
+            Compression.gzip().build(), record("k2", "v2"));
+
+        whenReadAsync(done(logReadResult(new FetchDataInfo(null,
+            concatBatches(uncompressedBatch, magicV2CompressedBatch, 
legacyCompressedBatch)), Errors.NONE)));
+
+        Map<Long, Record> result = fetcher(param(0L, 2L), 
10_000).fetch().get(10, TimeUnit.SECONDS).records();
+
+        assertEquals(3, result.size());
+        assertRecord(result, 0L, "k0", "v0");
+        assertRecord(result, 1L, "k1", "v1");
+        assertRecord(result, 2L, "k2", "v2");
+    }
+
+    @Test
+    public void 
testFetchStopsOnceEnoughContentCollectedWithoutScanningWholeRange() throws 
Exception {
+        // A large range (0..100), but a budget sized to exactly the one 
record's own size: once that
+        // record is collected the target has been reached, and the fetch 
stops issuing further reads
+        // rather than scanning all the way to endOffset looking for more.
+        SimpleRecord simpleRecord = record("k0", "v".repeat(50));
+        int recordSize = recordSizeInBytes(simpleRecord);
+        whenReadAsync(done(success(simpleRecord)));
+
+        Map<Long, Record> result = fetcher(param(0L, 100L), 
recordSize).fetch().get(10, TimeUnit.SECONDS).records();
+
+        assertEquals(1, result.size());
+        assertRecord(result, 0L, "k0", "v".repeat(50));
+        verify(logReader, times(1)).readAsync(any(), anySet(), any(), any(), 
anyBoolean());
+    }
+
+    @Test
+    public void testMultipleBatchesInSingleReadStopAtBudgetBetweenBatches() 
throws Exception {
+        // Two uncompressed batches concatenated into ONE read response - the 
first batch alone already
+        // reaches the budget. collectRecords() must stop BEFORE starting the 
second batch (not just wait
+        // for runFrom()'s between-reads check), or a single read containing 
many batches could keep being
+        // processed past the point where the fetch should have stopped.
+        String value = "v".repeat(50);
+        MemoryRecords batch0 = MemoryRecords.withRecords(0L, Compression.NONE, 
record("k0", value));
+        MemoryRecords batch1 = MemoryRecords.withRecords(1L, Compression.NONE, 
record("k1", value));
+        int recordSize = recordSizeInBytes(record("k0", value));
+
+        whenReadAsync(done(logReadResult(new FetchDataInfo(null, 
concatBatches(batch0, batch1)), Errors.NONE)));
+
+        Map<Long, Record> result = fetcher(param(0L, 1L), 
recordSize).fetch().get(10, TimeUnit.SECONDS).records();
+
+        assertEquals(1, result.size());
+        assertRecord(result, 0L, "k0", value);
+        assertNull(result.get(1L), "Second batch must not be collected once 
the first alone reached budget");
+    }
+
+    @Test
+    public void testUncompressedBatchStopsMidBatchOnceBudgetReached() throws 
Exception {
+        // A single uncompressed batch with several records, budget sized to 
exactly the first record's
+        // own size - collectUncompressed() must stop mid-batch, not just 
between batches/reads, since a
+        // single uncompressed batch carries no decompression risk and so has 
no other size cap of its own.
+        // The record that would push collectedBytes over budget is excluded 
(not included), and the fetch
+        // stops there rather than examining records after it in the same 
batch.
+        String value = "v".repeat(50);
+        int recordSize = recordSizeInBytes(record("k0", value));
+        whenReadAsync(done(success(record("k0", value), record("k1", value), 
record("k2", value))));
+
+        Map<Long, Record> result = fetcher(param(0L, 2L), 
recordSize).fetch().get(10, TimeUnit.SECONDS).records();
+
+        assertEquals(1, result.size());
+        assertRecord(result, 0L, "k0", value);
+        assertNull(result.get(1L), "Budget was reached after the first record; 
one that doesn't fit alongside it must not be collected");
+        assertNull(result.get(2L), "The fetch stops at the first record that 
doesn't fit; later records in the same batch must not be examined");
+    }
+
+    @Test
+    public void testCompressedBatchAfterUncompressedRespectsCombinedBudget() 
throws Exception {
+        // The first batch is uncompressed and alone consumes most of the 
budget. The second batch is
+        // compressed and, on its own, decompresses to well within the full 
budget - decompression succeeds
+        // - but it still must not be collected, because it doesn't fit 
alongside collectedBytes from the
+        // uncompressed batch already retained this fetch. Proves the 
per-record fit check gates on
+        // collectedBytes accumulated across every batch in the fetch, not 
just what the current batch
+        // itself decompresses to.
+        String uncompressedValue = "v".repeat(150);
+        String compressedValue = "x".repeat(80);
+        MemoryRecords uncompressedBatch = MemoryRecords.withRecords(0L, 
Compression.NONE, record("k0", uncompressedValue));
+        MemoryRecords compressedBatch = MemoryRecords.withRecords(1L, 
Compression.gzip().build(), record("k1", compressedValue));
+
+        whenReadAsync(done(logReadResult(
+            new FetchDataInfo(null, concatBatches(uncompressedBatch, 
compressedBatch)), Errors.NONE)));
+
+        Map<Long, Record> result = fetcher(param(0L, 1L), 200).fetch().get(10, 
TimeUnit.SECONDS).records();
+
+        assertRecord(result, 0L, "k0", uncompressedValue);
+        assertNull(result.get(1L),
+            "Compressed batch must not be collected - it doesn't fit alongside 
what the uncompressed batch already retained");
+    }
+
+    @Test
+    public void 
testOversizedUncompressedRecordExcludedButLaterRecordStillCollected() throws 
Exception {
+        // The first record's own size exceeds the entire budget on its own - 
it can never fit, in this
+        // fetch or any other - so it's permanently excluded rather than 
blocking the smaller record after
+        // it, which fits comfortably since the excluded record never consumed 
any of collectedBytes.
+        SimpleRecord small = record("k1", "v1");
+        int budget = recordSizeInBytes(small);
+        whenReadAsync(done(success(record("k0", "v".repeat(500)), small)));
+
+        Map<Long, Record> result = fetcher(param(0L, 1L), 
budget).fetch().get(10, TimeUnit.SECONDS).records();
+
+        assertNull(result.get(0L), "A record whose own size exceeds the budget 
must be permanently excluded");
+        assertRecord(result, 1L, "k1", "v1");
+    }
+
+    @Test
+    public void testHardStopPreventsFurtherReads() throws Exception {
+        // Once a record is found that doesn't fit alongside what's already 
been collected, the fetch stops
+        // immediately - it must not attempt further reads for the rest of the 
range, even though offsets
+        // remain unexamined. Everything from that point on is left untouched 
for a fresh fetch to retry.
+        String value = "v".repeat(50);
+        int recordSize = recordSizeInBytes(record("k0", value));
+        whenReadAsync(
+            done(logReadResult(new FetchDataInfo(null,
+                MemoryRecords.withRecords(0L, Compression.NONE, record("k0", 
value))), Errors.NONE)),
+            done(logReadResult(new FetchDataInfo(null,
+                MemoryRecords.withRecords(1L, Compression.NONE, record("k1", 
value))), Errors.NONE)),
+            done(logReadResult(new FetchDataInfo(null,
+                MemoryRecords.withRecords(2L, Compression.NONE, record("k2", 
value))), Errors.NONE)));
+
+        ShareGroupDLQRecordFetcher.FetchResult result =
+            fetcher(param(0L, 10L), recordSize + 1).fetch().get(10, 
TimeUnit.SECONDS);
+
+        assertEquals(1, result.records().size());
+        assertRecord(result.records(), 0L, "k0", value);
+        assertEquals(0L, result.lastResolvedOffset(), "Only the first record 
was resolved; the rest must be left for a fresh fetch");
+        verify(logReader, times(2)).readAsync(any(), anySet(), any(), any(), 
anyBoolean());
+    }
+
     @Test
     public void testCompressedBatchExceedingDecompressedBudgetSkipsBatch() 
throws Exception {
         // Highly compressible values so the batch is small on the wire but 
decompresses well past a
         // tiny budget - simulates a decompression-bomb-shaped batch. The 
whole batch is decompressed
-        // into a bounded buffer before any record is parsed, so exceeding the 
budget yields none of its
-        // records rather than a partial subset.
+        // into a bounded buffer before any record is parsed, so a batch that 
can never fit within the
+        // budget is permanently excluded, yielding none of its records rather 
than a partial subset.
         String bigValue = "x".repeat(10_000);
         whenReadAsync(done(success(Compression.gzip().build(),
             record("k0", bigValue), record("k1", bigValue), record("k2", 
bigValue))));
 
-        Map<Long, Record> result = fetcher(param(0L, 2L), 100).fetch().get(10, 
TimeUnit.SECONDS);
+        ShareGroupDLQRecordFetcher.FetchResult result =
+            fetcher(param(0L, 2L), 100).fetch().get(10, TimeUnit.SECONDS);
 
-        assertTrue(result.isEmpty(), "Expected the over-budget batch to be 
skipped, yielding no records");
+        assertTrue(result.records().isEmpty(), "Expected the over-budget batch 
to be skipped, yielding no records");
+        assertEquals(2L, result.lastResolvedOffset(), "The whole excluded 
batch must count as resolved so it is never retried");
     }
 
     @Test
     public void 
testCompressedBatchExceedingDecompressedBudgetSkippedButLaterBatchStillCollected()
 throws Exception {
         // First batch is highly compressible so it alone decompresses well 
past a tiny budget; the second
         // batch is uncompressed and carries no decompression risk. Only the 
first batch's offsets should be
-        // skipped - the fetch must not abort the whole range, so the second 
batch is still collected.
+        // permanently excluded - the fetch must not abort the whole range, so 
the second batch is still
+        // collected.
         String bigValue = "x".repeat(10_000);
         MemoryRecords compressedBatch = MemoryRecords.withRecords(0L, 
Compression.gzip().build(),
             record("k0", bigValue), record("k1", bigValue), record("k2", 
bigValue));
@@ -348,7 +561,7 @@ class ShareGroupDLQRecordFetcherTest {
         whenReadAsync(done(logReadResult(
             new FetchDataInfo(null, concatBatches(compressedBatch, 
uncompressedBatch)), Errors.NONE)));
 
-        Map<Long, Record> result = fetcher(param(0L, 3L), 100).fetch().get(10, 
TimeUnit.SECONDS);
+        Map<Long, Record> result = fetcher(param(0L, 3L), 100).fetch().get(10, 
TimeUnit.SECONDS).records();
 
         assertNull(result.get(0L));
         assertNull(result.get(1L));
@@ -359,7 +572,8 @@ class ShareGroupDLQRecordFetcherTest {
     @Test
     public void 
testLegacyCompressedBatchExceedingCumulativeCapSkipsBatchButLaterBatchStillCollected()
 throws Exception {
         // Legacy magic v0/v1 batches use the cumulative-cap fallback (no 
bounded-buffer path for that
-        // format). Exceeding the cap should still only skip the rest of the 
offending batch, not abort the
+        // format). Each record's own size is checked individually as it's 
decompressed: a record that can
+        // never fit on its own is permanently excluded, but the scan 
continues rather than aborting the
         // whole fetch - a later, uncompressed batch remains unaffected.
         String bigValue = "x".repeat(10_000);
         MemoryRecords legacyCompressedBatch = 
MemoryRecords.withRecords(RecordBatch.MAGIC_VALUE_V1, 0L,
@@ -369,7 +583,7 @@ class ShareGroupDLQRecordFetcherTest {
         whenReadAsync(done(logReadResult(
             new FetchDataInfo(null, concatBatches(legacyCompressedBatch, 
uncompressedBatch)), Errors.NONE)));
 
-        Map<Long, Record> result = fetcher(param(0L, 3L), 100).fetch().get(10, 
TimeUnit.SECONDS);
+        Map<Long, Record> result = fetcher(param(0L, 3L), 100).fetch().get(10, 
TimeUnit.SECONDS).records();
 
         assertNull(result.get(0L));
         assertNull(result.get(1L));
diff --git 
a/server/src/test/java/org/apache/kafka/server/share/dlq/ShareGroupDLQStateManagerTest.java
 
b/server/src/test/java/org/apache/kafka/server/share/dlq/ShareGroupDLQStateManagerTest.java
index 307003022b4..5e88c3f9dbc 100644
--- 
a/server/src/test/java/org/apache/kafka/server/share/dlq/ShareGroupDLQStateManagerTest.java
+++ 
b/server/src/test/java/org/apache/kafka/server/share/dlq/ShareGroupDLQStateManagerTest.java
@@ -54,8 +54,10 @@ import org.apache.kafka.test.TestUtils;
 
 import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentMatcher;
 import org.mockito.Mockito;
 
+import java.nio.ByteBuffer;
 import java.nio.charset.StandardCharsets;
 import java.util.ArrayList;
 import java.util.Arrays;
@@ -65,6 +67,7 @@ import java.util.Iterator;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Objects;
 import java.util.Optional;
 import java.util.OptionalLong;
 import java.util.Set;
@@ -97,9 +100,12 @@ import static org.mockito.ArgumentMatchers.anyBoolean;
 import static org.mockito.ArgumentMatchers.anyInt;
 import static org.mockito.ArgumentMatchers.anySet;
 import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.argThat;
 import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doThrow;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.spy;
 import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.verifyNoInteractions;
@@ -1716,22 +1722,141 @@ class ShareGroupDLQStateManagerTest {
         verify(mockMetrics, times(3)).recordDLQProduce(GROUP_ID);
     }
 
+    @Test
+    public void 
testCompleteHandlerSafelyIsolatesOneHandlersUncaughtExceptionFromOthers() {
+        // Simulates the coalesced-response callback in 
SendThread.generateRequests(), which invokes
+        // completeHandlerSafely() once per handler sharing one produce 
response. If one handler's
+        // onComplete() throws, that must not prevent other handlers in the 
same batch from being
+        // notified (which would otherwise leave their dlq() futures hanging 
forever), and the
+        // offending handler's own future must still be explicitly failed 
rather than left hanging.
+        stateManager = builder().build();
+        ShareGroupDLQStateManager.ProduceRequestHandler throwing =
+            spy(newHandlerForCoalesceTest(stateManager, GROUP_ID, 0));
+        throwing.populateDLQTopicData();
+        doThrow(new RuntimeException("boom")).when(throwing).onComplete(any());
+
+        ShareGroupDLQStateManager.ProduceRequestHandler normal =
+            spy(newHandlerForCoalesceTest(stateManager, GROUP_ID, 1));
+        normal.populateDLQTopicData();
+
+        // A null response is a valid, already-handled input to onComplete() 
(see its own null
+        // check) and lets this test avoid needing a real ClientResponse.
+        List.of(throwing, normal).forEach(handler ->
+            ShareGroupDLQStateManager.completeHandlerSafely(handler, null));
+
+        verify(throwing).onComplete(null);
+        verify(throwing).requestErrorResponse(any(RuntimeException.class));
+        verify(normal).onComplete(null);
+        verify(normal, never()).requestErrorResponse(any());
+    }
+
+    @Test
+    public void 
testResolveRoundSurvivesSynchronousExceptionFromMaybeFetchRecordData() throws 
Exception {
+        // maybeFetchRecordData() begins with a synchronous cache-helper call
+        // (isShareGroupDlqCopyRecordEnabled) before ever returning a future. 
If that call throws -
+        // e.g. a cache-helper bug - resolveRound() must not let the exception 
escape synchronously:
+        // for round 2 onward this method is called directly from 
handleProduceResponse() on the
+        // sender thread, inside a RequestCompletionHandler callback, where an 
uncaught exception can
+        // (depending on the internal response-delivery path) propagate all 
the way to the broker's
+        // fatal-error handling rather than just failing this one handler.
+        ShareGroupDLQMetadataCacheHelper cacheHelper = 
cacheHelper(DEFAULT_LEADER);
+        
when(cacheHelper.isShareGroupDlqCopyRecordEnabled(GROUP_ID)).thenThrow(new 
RuntimeException("cache helper boom"));
+        stateManager = builder().withCacheHelper(cacheHelper).build();
+
+        // A multi-offset range - not newHandlerForCoalesceTest()'s usual 
single-offset (0,0) convention.
+        // That distinction matters here: with only one offset in range, "the 
single-record floor forces
+        // exactly one offset through" and "the whole remaining range is 
deliberately given up on" look
+        // identical, which would mask a regression back to the narrower, 
floor-only behavior. A
+        // multi-offset range only comes out right if 
lastResolvedOffsetThisRound is actually set to
+        // param.lastOffset() on a total failure, not just nextOffsetToSend's 
floor value.
+        ShareGroupDLQRecordParameter param = new ShareGroupDLQRecordParameter(
+            GROUP_ID, new TopicIdPartition(SOURCE_TOPIC_ID, 0, "source-topic"),
+            0L, 2L, Optional.of((short) 1), Optional.of(new 
RuntimeException("simulated cause")));
+        ShareGroupDLQStateManager.ProduceRequestHandler handler = 
stateManager.new ProduceRequestHandler(
+            param, new CompletableFuture<>(), 
ShareGroupDLQStateManager.REQUEST_BACKOFF_MS,
+            ShareGroupDLQStateManager.REQUEST_BACKOFF_MAX_MS, 3);
+        handler.populateDLQTopicData();
+
+        CompletableFuture<Void> resolved = handler.resolveRound();
+
+        assertNull(resolved.get(5, TimeUnit.SECONDS),
+            "resolveRound() must complete normally even when 
maybeFetchRecordData() throws synchronously");
+
+        // Falls back to headers-only for the WHOLE remaining range in this 
one round, same as any other
+        // total-failure case - not just a single, floored offset.
+        ProduceRequestData.TopicProduceData topicData = 
handler.topicProduceData();
+        List<Record> records = new ArrayList<>();
+        ((MemoryRecords) 
topicData.partitionData().get(0).records()).records().forEach(records::add);
+        assertEquals(3, records.size(),
+            "The whole 3-offset range must be given up on in this one round, 
not just a single floored offset");
+        for (Record record : records) {
+            assertFalse(record.hasKey());
+            assertFalse(record.hasValue());
+        }
+    }
+
     // --- DLQ record with copy record enabled ---
 
     private static FetchDataInfo recordsInfo(SimpleRecord... records) {
         return new FetchDataInfo(null, 
MemoryRecords.withRecords(Compression.NONE, records));
     }
 
+    // The exact on-wire size of a single uncompressed record, computed via 
the real API rather than
+    // hardcoded, so a test can size dlqTopicMaxMessageBytes precisely against 
a record's own raw size.
+    private static int recordSizeInBytes(SimpleRecord simpleRecord) {
+        MemoryRecords batch = MemoryRecords.withRecords(Compression.NONE, 
simpleRecord);
+        return 
batch.batches().iterator().next().iterator().next().sizeInBytes();
+    }
+
     // A read result carrying the given data and error. Other read metadata is 
irrelevant to the fetcher.
     private static LogReadResult logReadResult(FetchDataInfo info, Errors 
error) {
         return new LogReadResult(info, Optional.empty(), 0L, 0L, 0L, 0L, -1L, 
OptionalLong.empty(), error);
     }
 
-    private static CompletableFuture<LinkedHashMap<TopicIdPartition, 
LogReadResult>> asyncReadMap(
+    private static LinkedHashMap<TopicIdPartition, LogReadResult> resultMap(
             TopicIdPartition topicIdPartition, LogReadResult result) {
         LinkedHashMap<TopicIdPartition, LogReadResult> map = new 
LinkedHashMap<>();
         map.put(topicIdPartition, result);
-        return CompletableFuture.completedFuture(map);
+        return map;
+    }
+
+    private static CompletableFuture<LinkedHashMap<TopicIdPartition, 
LogReadResult>> asyncReadMap(
+            TopicIdPartition topicIdPartition, LogReadResult result) {
+        return CompletableFuture.completedFuture(resultMap(topicIdPartition, 
result));
+    }
+
+    // Concatenates the given batches' on-wire bytes into a single 
MemoryRecords, simulating one
+    // read returning multiple consecutive batches.
+    private static MemoryRecords concatBatches(List<MemoryRecords> batches) {
+        int size = batches.stream().mapToInt(b -> 
b.buffer().remaining()).sum();
+        ByteBuffer combined = ByteBuffer.allocate(size);
+        batches.forEach(b -> combined.put(b.buffer().duplicate()));
+        combined.flip();
+        return MemoryRecords.readableRecords(combined);
+    }
+
+    // Stubs logReader.readAsync to behave like a real log keyed by starting 
offset: a read
+    // requesting offset N returns only the batches at index >= N 
(batchesByOffset.get(i) is the
+    // batch whose base offset is i), never earlier ones - unlike a stub that 
always returns the
+    // same fixed blob regardless of the requested position, which would waste 
each round's fresh
+    // decompression budget re-decompressing already-consumed batches.
+    private static void whenReadAsyncFromLog(LogReader logReader, 
TopicIdPartition topicIdPartition,
+                                              List<MemoryRecords> 
batchesByOffset) {
+        when(logReader.readAsync(any(), anySet(), any(), any(), 
anyBoolean())).thenAnswer(invocation -> {
+            Map<TopicIdPartition, Long> offsets = invocation.getArgument(2);
+            int fromIndex = (int) Math.min(offsets.get(topicIdPartition), 
batchesByOffset.size());
+            MemoryRecords records = fromIndex >= batchesByOffset.size()
+                ? MemoryRecords.EMPTY
+                : concatBatches(batchesByOffset.subList(fromIndex, 
batchesByOffset.size()));
+            return asyncReadMap(topicIdPartition, logReadResult(new 
FetchDataInfo(null, records), Errors.NONE));
+        });
+    }
+
+    // Matches the offsets map argument of readAsync() when it requests 
starting from the given
+    // offset for the given partition - lets tests assert readAsync was (or 
wasn't) called for a
+    // specific round's window, rather than relying on call order/count alone.
+    private static ArgumentMatcher<Map<TopicIdPartition, Long>> 
offsetsRequesting(TopicIdPartition topicIdPartition, long offset) {
+        return offsets -> offsets != null && 
Objects.equals(offsets.get(topicIdPartition), offset);
     }
 
     // Stubs logReader.readAsync to return, in order, the given per-call 
results for the partition,
@@ -1829,8 +1954,11 @@ class ShareGroupDLQStateManagerTest {
             stateManager.start();
             assertNull(stateManager.dlq(param, 1L, 5L, maxAttempts).get(5, 
TimeUnit.SECONDS));
 
-            // Records are resolved once, before enqueue, and the memoized 
result is reused on every
-            // (re)send. So despite three produce attempts the source log is 
read exactly once.
+            // param() fits entirely in one produce round here (no chunking), 
so all three attempts
+            // are retries of round 1: records are resolved once for that 
round, before enqueue, and
+            // the memoized result is reused on every retry. So despite three 
produce attempts the
+            // source log is read exactly once - a genuinely new round (not 
exercised by this test)
+            // would trigger its own, separate readAsync call.
             verify(logReader, times(1)).readAsync(any(), anySet(), any(), 
any(), anyBoolean());
             verify(mockMetrics, times(maxAttempts)).recordDLQProduce(GROUP_ID);
             verify(mockMetrics).recordDLQRecordWrite(GROUP_ID, 3);
@@ -1840,6 +1968,254 @@ class ShareGroupDLQStateManagerTest {
         }
     }
 
+    @Test
+    public void testDlqRecordCopyRoundBudgetResetAcrossMultipleProduceRounds() 
throws Exception {
+        // Each source record's value is highly compressible (gzip) but 
decompresses to 400 bytes -
+        // comfortably within one round's decompression budget 
(maxDecompressedBytes, derived from
+        // dlqTopicMaxMessageBytes), but not within two rounds' worth 
combined. The resulting DLQ
+        // record (400-byte value plus DLQ headers) also exceeds 
maxMessageBytes on its own, which -
+        // via topicProduceData()'s single-record floor - forces exactly one 
offset per produce
+        // round, giving 3 rounds for param()'s 3-offset range.
+        int maxMessageBytes = 600;
+        ShareGroupDLQRecordParameter param = param();
+        TopicIdPartition tp = param.topicIdPartition();
+
+        List<MemoryRecords> batchesByOffset = new ArrayList<>();
+        List<byte[]> keys = new ArrayList<>();
+        List<byte[]> values = new ArrayList<>();
+        for (long offset = 0; offset <= 2; offset++) {
+            byte[] key = ("k" + offset).getBytes(StandardCharsets.UTF_8);
+            byte[] value = ("x".repeat(400) + 
offset).getBytes(StandardCharsets.UTF_8);
+            keys.add(key);
+            values.add(value);
+            batchesByOffset.add(MemoryRecords.withRecords(offset, 
Compression.gzip().build(),
+                new SimpleRecord(MOCK_TIME.milliseconds(), key, value)));
+        }
+
+        LogReader logReader = mock(LogReader.class);
+        whenReadAsyncFromLog(logReader, tp, batchesByOffset);
+
+        ShareGroupDLQMetadataCacheHelper cacheHelper = 
cacheHelper(DEFAULT_LEADER);
+        
when(cacheHelper.isShareGroupDlqCopyRecordEnabled(any())).thenReturn(true);
+        
when(cacheHelper.dlqTopicMaxMessageBytes(anyString())).thenReturn(maxMessageBytes);
+
+        MockClient client = new MockClient(MOCK_TIME);
+        List<ProduceRequest> capturedProduces = new ArrayList<>();
+        MockClient.RequestMatcher captureProduce = body -> {
+            if (body instanceof ProduceRequest pr) {
+                capturedProduces.add(pr);
+                return true;
+            }
+            return false;
+        };
+        client.prepareResponseFrom(captureProduce, 
successfulProduceResponse(0), DEFAULT_LEADER);
+        client.prepareResponseFrom(captureProduce, 
successfulProduceResponse(0), DEFAULT_LEADER);
+        client.prepareResponseFrom(captureProduce, 
successfulProduceResponse(0), DEFAULT_LEADER);
+
+        stateManager = 
builder().withClient(client).withLogReader(logReader).withCacheHelper(cacheHelper).build();
+        stateManager.start();
+        assertNull(stateManager.dlq(param).get(10, TimeUnit.SECONDS));
+
+        assertEquals(3, capturedProduces.size(), "Expected one produce round 
per offset");
+        Map<String, String> sharedHeaders = Map.of(
+            HEADER_DLQ_ERRORS_TOPIC, "source-topic",
+            HEADER_DLQ_ERRORS_PARTITION, "0",
+            HEADER_DLQ_ERRORS_GROUP, GROUP_ID,
+            HEADER_DLQ_ERRORS_DELIVERY_COUNT, "1",
+            HEADER_DLQ_ERRORS_MESSAGE, "simulated cause"
+        );
+        for (int round = 0; round < 3; round++) {
+            assertDlqProduceRecordHeaders(capturedProduces.get(round), Map.of(
+                0, new ExpectedDlqPartition(round, round, sharedHeaders, 
List.of(keys.get(round)), List.of(values.get(round)))
+            ));
+        }
+        verify(logReader, times(3)).readAsync(any(), anySet(), any(), any(), 
anyBoolean());
+    }
+
+    @Test
+    public void 
testFetcherBudgetSplitsRoundEvenWhenSizeFloorWouldHaveRoomForMore() throws 
Exception {
+        // Isolates topicProduceData()'s lastResolvedOffsetThisRound cap from 
the pre-existing
+        // single-record size floor (see 
testDlqChunksOverMaxMessageBytesAcrossMultipleProduceRequests):
+        // dlqTopicMaxMessageBytes is generous enough that, size-wise alone, 
round 1 could easily combine
+        // offset 0's real content with a cheap headers-only filler for offset 
1 - a DLQ record is mostly
+        // fixed header overhead, so an unresolved offset costs very little to 
pack in on top of real
+        // content that already fits. Without the cap, topicProduceData() 
would happily sweep offset 1 in
+        // as headers-only and complete the whole range in one round, 
permanently losing its real content
+        // (nextOffsetToSend would advance past it, and a fresh fetch would 
never be attempted for it).
+        // With the cap, round 1 is limited to exactly what the fetcher 
actually resolved - offset 0 alone,
+        // since its 2000-byte value combined with offset 1's would exceed the 
fetcher's own budget even
+        // though each fits comfortably on its own - and offset 1 is correctly 
deferred to a fresh round.
+        int maxMessageBytes = 3200;
+        ShareGroupDLQRecordParameter param = new ShareGroupDLQRecordParameter(
+            GROUP_ID, new TopicIdPartition(SOURCE_TOPIC_ID, 0, "source-topic"),
+            0L, 1L, Optional.of((short) 1), Optional.of(new 
RuntimeException("simulated cause")));
+        TopicIdPartition tp = param.topicIdPartition();
+
+        byte[] key0 = "k0".getBytes(StandardCharsets.UTF_8);
+        byte[] value0 = "x".repeat(2000).getBytes(StandardCharsets.UTF_8);
+        byte[] key1 = "k1".getBytes(StandardCharsets.UTF_8);
+        byte[] value1 = "y".repeat(2000).getBytes(StandardCharsets.UTF_8);
+        List<MemoryRecords> batchesByOffset = List.of(
+            MemoryRecords.withRecords(0L, Compression.gzip().build(),
+                new SimpleRecord(MOCK_TIME.milliseconds(), key0, value0)),
+            MemoryRecords.withRecords(1L, Compression.gzip().build(),
+                new SimpleRecord(MOCK_TIME.milliseconds(), key1, value1))
+        );
+        LogReader logReader = mock(LogReader.class);
+        whenReadAsyncFromLog(logReader, tp, batchesByOffset);
+
+        ShareGroupDLQMetadataCacheHelper cacheHelper = 
cacheHelper(DEFAULT_LEADER);
+        
when(cacheHelper.isShareGroupDlqCopyRecordEnabled(any())).thenReturn(true);
+        
when(cacheHelper.dlqTopicMaxMessageBytes(anyString())).thenReturn(maxMessageBytes);
+
+        MockClient client = new MockClient(MOCK_TIME);
+        List<ProduceRequest> capturedProduces = new ArrayList<>();
+        MockClient.RequestMatcher captureProduce = body -> {
+            if (body instanceof ProduceRequest pr) {
+                capturedProduces.add(pr);
+                return true;
+            }
+            return false;
+        };
+        client.prepareResponseFrom(captureProduce, 
successfulProduceResponse(0), DEFAULT_LEADER);
+        client.prepareResponseFrom(captureProduce, 
successfulProduceResponse(0), DEFAULT_LEADER);
+
+        stateManager = 
builder().withClient(client).withLogReader(logReader).withCacheHelper(cacheHelper).build();
+        stateManager.start();
+        assertNull(stateManager.dlq(param).get(10, TimeUnit.SECONDS));
+
+        assertEquals(2, capturedProduces.size(),
+            "Round 1 must stop at offset 0 (the fetcher's own budget), not 
sweep offset 1 in as " +
+            "headers-only just because topicProduceData()'s size floor would 
have had room for it");
+        Map<String, String> sharedHeaders = Map.of(
+            HEADER_DLQ_ERRORS_TOPIC, "source-topic",
+            HEADER_DLQ_ERRORS_PARTITION, "0",
+            HEADER_DLQ_ERRORS_GROUP, GROUP_ID,
+            HEADER_DLQ_ERRORS_DELIVERY_COUNT, "1",
+            HEADER_DLQ_ERRORS_MESSAGE, "simulated cause"
+        );
+        assertDlqProduceRecordHeaders(capturedProduces.get(0), Map.of(
+            0, new ExpectedDlqPartition(0L, 0L, sharedHeaders, List.of(key0), 
List.of(value0))
+        ));
+        assertDlqProduceRecordHeaders(capturedProduces.get(1), Map.of(
+            0, new ExpectedDlqPartition(1L, 1L, sharedHeaders, List.of(key1), 
List.of(value1))
+        ));
+        verify(logReader, times(2)).readAsync(any(), anySet(), any(), any(), 
anyBoolean());
+    }
+
+    @Test
+    public void 
testDlqRecordCopyFirstProduceRoundGoesOutBeforeLaterRoundResolves() throws 
Exception {
+        // Forces exactly one offset per produce round via 
topicProduceData()'s single-record floor
+        // (see 
testDlqChunksOverMaxMessageBytesAcrossMultipleProduceRequests): the DLQ headers 
alone
+        // (topic, partition, offset, group, delivery count, cause message) 
make the wrapped record far
+        // bigger than dlqTopicMaxMessageBytes below, regardless of the source 
record's own tiny size, so
+        // the floor kicks in on the very first offset every round. Round 1's 
read is already resolved;
+        // round 2's read is held pending to simulate an in-flight (e.g. 
remote-storage) fetch - proving
+        // round 1's produce goes out without waiting for round 2 to resolve.
+        ShareGroupDLQRecordParameter param = new ShareGroupDLQRecordParameter(
+            GROUP_ID, new TopicIdPartition(SOURCE_TOPIC_ID, 0, "source-topic"),
+            0L, 1L, Optional.of((short) 1), Optional.of(new 
RuntimeException("simulated cause")));
+        TopicIdPartition tp = param.topicIdPartition();
+
+        // Round 1's own fetch window covers the WHOLE param range (offsets 0 
and 1, since round 1
+        // always starts at param.firstOffset()) - but dlqTopicMaxMessageBytes 
below is sized to exactly
+        // offset 0's own record, so the fetch collects it, immediately 
reaches its target, and stops on
+        // its own without needing to look for offset 1 at all. The genuinely 
separate, held-pending read
+        // only comes later, from round 2's own resolveRound() call (triggered 
by round 1's successful
+        // produce response).
+        SimpleRecord record0 = new SimpleRecord(MOCK_TIME.milliseconds(),
+            "k0".getBytes(StandardCharsets.UTF_8), 
"v0".getBytes(StandardCharsets.UTF_8));
+        LogReader logReader = mock(LogReader.class);
+        CompletableFuture<LinkedHashMap<TopicIdPartition, LogReadResult>> 
round1Read = asyncReadMap(tp,
+            logReadResult(recordsInfo(record0), Errors.NONE));
+        CompletableFuture<LinkedHashMap<TopicIdPartition, LogReadResult>> 
round2Read = new CompletableFuture<>();
+        when(logReader.readAsync(any(), anySet(), any(), any(), anyBoolean()))
+            .thenReturn(round1Read)
+            .thenReturn(round2Read);
+
+        ShareGroupDLQMetadataCacheHelper cacheHelper = 
cacheHelper(DEFAULT_LEADER);
+        
when(cacheHelper.isShareGroupDlqCopyRecordEnabled(any())).thenReturn(true);
+        
when(cacheHelper.dlqTopicMaxMessageBytes(anyString())).thenReturn(recordSizeInBytes(record0));
+
+        MockClient client = new MockClient(MOCK_TIME);
+        List<ProduceRequest> capturedProduces = new ArrayList<>();
+        MockClient.RequestMatcher captureProduce = body -> {
+            if (body instanceof ProduceRequest pr) {
+                capturedProduces.add(pr);
+                return true;
+            }
+            return false;
+        };
+        client.prepareResponseFrom(captureProduce, 
successfulProduceResponse(0), DEFAULT_LEADER);
+        client.prepareResponseFrom(captureProduce, 
successfulProduceResponse(0), DEFAULT_LEADER);
+
+        stateManager = 
builder().withClient(client).withLogReader(logReader).withCacheHelper(cacheHelper).build();
+        stateManager.start();
+        CompletableFuture<Void> dlqFuture = stateManager.dlq(param);
+
+        TestUtils.waitForCondition(() -> capturedProduces.size() == 1, 
TestUtils.DEFAULT_MAX_WAIT_MS,
+            "Expected round 1's produce request to go out while round 2's 
fetch is still pending");
+        assertFalse(dlqFuture.isDone(), "Overall future must not complete 
until round 2 resolves and is sent");
+
+        round2Read.complete(resultMap(tp, logReadResult(recordsInfo(new 
SimpleRecord(MOCK_TIME.milliseconds(),
+            "k1".getBytes(StandardCharsets.UTF_8), 
"v1".getBytes(StandardCharsets.UTF_8))), Errors.NONE)));
+
+        assertNull(dlqFuture.get(10, TimeUnit.SECONDS));
+        assertEquals(2, capturedProduces.size(), "Expected round 2's produce 
request once its fetch resolved");
+    }
+
+    @Test
+    public void 
testDlqRecordCopyRetryOfSameRoundDoesNotRefetchButNewRoundDoes() throws 
Exception {
+        int maxAttempts = 3;
+        Timer realTimer = new SystemTimerReaper("shareGroupDLQTestTimer", new 
SystemTimer("shareGroupDLQTestTimer"));
+        try {
+            // 2-offset range, 1-byte max message bytes forces exactly one 
offset per round (same
+            // trick as 
testDlqChunksOverMaxMessageBytesAcrossMultipleProduceRequests), giving
+            // round 1 = offset 0, round 2 = offset 1.
+            ShareGroupDLQRecordParameter param = new 
ShareGroupDLQRecordParameter(
+                GROUP_ID, new TopicIdPartition(SOURCE_TOPIC_ID, 0, 
"source-topic"),
+                0L, 1L, Optional.of((short) 1), Optional.of(new 
RuntimeException("simulated cause")));
+            TopicIdPartition tp = param.topicIdPartition();
+
+            List<MemoryRecords> batchesByOffset = List.of(
+                MemoryRecords.withRecords(0L, Compression.NONE,
+                    new SimpleRecord(MOCK_TIME.milliseconds(), 
"k0".getBytes(StandardCharsets.UTF_8), "v0".getBytes(StandardCharsets.UTF_8))),
+                MemoryRecords.withRecords(1L, Compression.NONE,
+                    new SimpleRecord(MOCK_TIME.milliseconds(), 
"k1".getBytes(StandardCharsets.UTF_8), "v1".getBytes(StandardCharsets.UTF_8)))
+            );
+            LogReader logReader = mock(LogReader.class);
+            whenReadAsyncFromLog(logReader, tp, batchesByOffset);
+
+            ShareGroupDLQMetadataCacheHelper cacheHelper = 
cacheHelper(DEFAULT_LEADER);
+            
when(cacheHelper.isShareGroupDlqCopyRecordEnabled(any())).thenReturn(true);
+            
when(cacheHelper.dlqTopicMaxMessageBytes(anyString())).thenReturn(1);
+
+            MockClient client = new MockClient(MOCK_TIME);
+            // Round 1 attempt 1: disconnect (retriable). Round 1 attempt 2: 
success. Round 2: success.
+            client.prepareResponseFrom(body -> body instanceof ProduceRequest, 
null, DEFAULT_LEADER, true);
+            client.prepareResponseFrom(body -> body instanceof ProduceRequest, 
successfulProduceResponse(0), DEFAULT_LEADER);
+            client.prepareResponseFrom(body -> body instanceof ProduceRequest, 
successfulProduceResponse(0), DEFAULT_LEADER);
+
+            stateManager = builder()
+                .withClient(client)
+                .withLogReader(logReader)
+                .withCacheHelper(cacheHelper)
+                .withTimer(realTimer)
+                .build();
+            stateManager.start();
+            assertNull(stateManager.dlq(param, 1L, 5L, maxAttempts).get(10, 
TimeUnit.SECONDS));
+
+            // Despite round 1 needing 2 produce attempts (1 retry), its 
window (offset 0) is only
+            // fetched once - the retry reuses the already-resolved data (see 
dlqTopicExists()).
+            // Round 2's window (offset 1) is a genuinely new round and gets 
its own, separate fetch.
+            verify(logReader, times(1)).readAsync(any(), anySet(), 
argThat(offsetsRequesting(tp, 0L)), any(), anyBoolean());
+            verify(logReader, times(1)).readAsync(any(), anySet(), 
argThat(offsetsRequesting(tp, 1L)), any(), anyBoolean());
+            verify(logReader, times(2)).readAsync(any(), anySet(), any(), 
any(), anyBoolean());
+        } finally {
+            Utils.closeQuietly(realTimer, "shareGroupDLQTestTimer");
+        }
+    }
+
     @Test
     public void testDLQRecordCopyEnabledButInvalidConfigSkipsFetch() throws 
Exception {
         LogReader logReader = mock(LogReader.class);


Reply via email to