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 805eb7b260b KAFKA-20723: Bound decompression memory in
ShareGroupDLQRecordFetcher (#22905)
805eb7b260b is described below
commit 805eb7b260be7d4670ad39180c99aae3d991b4ab
Author: Sushant Mahajan <[email protected]>
AuthorDate: Thu Jul 23 21:16:03 2026 +0530
KAFKA-20723: Bound decompression memory in ShareGroupDLQRecordFetcher
(#22905)
### What
A compressed source record could decompress to far more data than its
on-wire size suggests, since the previous implementation fully
materialized a compressed batch via `DefaultRecordBatch.iterator()` and
allocated per-record buffers based on an unchecked, attacker-controlled
length.
### How
Compressed v2 batches are now decompressed into a size-capped
buffer before any record is parsed, so even a single oversized record
can't force a large allocation; the cap is derived from the DLQ topic's
own `max.message.bytes` rather than an arbitrary constant. Uncompressed
batches are left uncapped (no expansion risk), and fetch()/resume() now
catch Throwable so an OOM can't escape uncontained.
### Testing
Added new unit tests in `ShareGroupDLQRecordFetcherTest` and integ tests
in `ShareConsumerDLQTest`.
Co-Authored-By: Claude Sonnet 5 <[email protected]> Reviewers:
Andrew Schofield <[email protected]>
---------
Co-authored-by: Claude Sonnet 5 <[email protected]>
---
.../clients/consumer/ShareConsumerDLQTest.java | 108 ++++++++++
.../share/dlq/ShareGroupDLQRecordFetcher.java | 217 +++++++++++++++++++--
.../share/dlq/ShareGroupDLQStateManager.java | 10 +-
.../share/dlq/ShareGroupDLQRecordFetcherTest.java | 171 +++++++++++++++-
4 files changed, 489 insertions(+), 17 deletions(-)
diff --git
a/clients/clients-integration-tests/src/test/java/org/apache/kafka/clients/consumer/ShareConsumerDLQTest.java
b/clients/clients-integration-tests/src/test/java/org/apache/kafka/clients/consumer/ShareConsumerDLQTest.java
index 2fe897fdd71..2d5f415a9e5 100644
---
a/clients/clients-integration-tests/src/test/java/org/apache/kafka/clients/consumer/ShareConsumerDLQTest.java
+++
b/clients/clients-integration-tests/src/test/java/org/apache/kafka/clients/consumer/ShareConsumerDLQTest.java
@@ -24,6 +24,7 @@ import org.apache.kafka.clients.admin.ConfigEntry;
import org.apache.kafka.clients.admin.NewTopic;
import org.apache.kafka.clients.admin.OffsetSpec;
import org.apache.kafka.clients.producer.Producer;
+import org.apache.kafka.clients.producer.ProducerConfig;
import org.apache.kafka.clients.producer.ProducerRecord;
import org.apache.kafka.common.TopicPartition;
import org.apache.kafka.common.config.ConfigResource;
@@ -42,6 +43,7 @@ import com.yammer.metrics.core.Meter;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.ArrayList;
+import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
@@ -599,6 +601,112 @@ public class ShareConsumerDLQTest extends
ShareConsumerTestBase {
+ "chunking), count went from " + produceCountBeforeRaise + "
to " + dlqMeterCount(METRIC_DLQ_PRODUCE_TOTAL, groupId));
}
+ /**
+ * Guards against a decompression-bomb-shaped source record: a highly
compressible record that is tiny
+ * on the wire but expands to a much larger size once decompressed. The
DLQ record fetcher bounds the
+ * decompression budget it will spend copying a record by the DLQ topic's
own {@code max.message.bytes}
+ * (there is no point retaining more decompressed data than the DLQ topic
could ever accept anyway).
+ *
+ * <p>Phase 1: the DLQ topic is configured with a deliberately low {@code
max.message.bytes}. Two records
+ * with a highly compressible ~200KB payload (tiny once gzip-compressed)
are rejected with record copy
+ * enabled. The decompression budget (derived from the low limit) is
exhausted well before the payload can
+ * be fully decompressed, so the copy is skipped - but the DLQ write
itself still succeeds, headers-only
+ * (same degraded outcome as record-copy-disabled): the DLQ metrics still
fire and the records still land
+ * on the DLQ topic, just without a key/value.
+ *
+ * <p>Phase 2: the DLQ topic's {@code max.message.bytes} is raised
comfortably above the decompressed
+ * payload size, and a fresh batch of the same records is rejected. This
time the decompression budget is
+ * sufficient, so the copy succeeds and the new DLQ records carry the
original key/value.
+ */
+ @ClusterTest
+ public void
testDlqCopyRecordSkippedWhenDecompressedSizeExceedsDlqMaxMessageBytes() throws
Exception {
+ String groupId = "dlq-decompress-cap-group";
+ String sourceTopic = "dlq-decompress-cap-source";
+ String dlqTopic = "dlq.decompress-cap";
+ int recordCount = 2;
+ int payloadSize = 200_000;
+ int lowDlqMaxMessageBytes = 2_000;
+ // Comfortably above the two records' combined decompressed payload (2
* payloadSize = 400,000 bytes)
+ // plus batch/record framing overhead.
+ int highDlqMaxMessageBytes = 500_000;
+
+ try (Admin admin = createAdminClient()) {
+ admin.createTopics(Set.of(
+ new NewTopic(sourceTopic, 1, (short) 1),
+ new NewTopic(dlqTopic, 1, (short) 1)
+ .configs(Map.of(
+
TopicConfig.ERRORS_DEADLETTERQUEUE_GROUP_ENABLE_CONFIG, "true",
+ TopicConfig.MAX_MESSAGE_BYTES_CONFIG,
Integer.toString(lowDlqMaxMessageBytes)))
+ )).all().get();
+ }
+
+ alterShareAutoOffsetReset(groupId, "earliest");
+ alterShareGroupConfig(groupId,
GroupConfig.ERRORS_DEADLETTERQUEUE_TOPIC_NAME_CONFIG, dlqTopic);
+ alterShareGroupConfig(groupId,
GroupConfig.ERRORS_DEADLETTERQUEUE_COPY_RECORD_ENABLE_CONFIG, "true");
+
+ // Highly compressible payload: tiny on the wire (comfortably under
any max.message.bytes involved),
+ // but decompresses to `payloadSize` bytes - large enough to blow past
lowDlqMaxMessageBytes once the
+ // DLQ record fetcher tries to decompress it for copying.
+ byte[] payload = new byte[payloadSize];
+ Arrays.fill(payload, (byte) 'a');
+ try (Producer<byte[], byte[]> producer =
createProducer(Map.of(ProducerConfig.COMPRESSION_TYPE_CONFIG, "gzip"))) {
+ for (int i = 0; i < recordCount; i++) {
+ producer.send(new ProducerRecord<>(sourceTopic, 0,
"key".getBytes(StandardCharsets.UTF_8), payload));
+ }
+ producer.flush();
+ }
+ rejectRecords(groupId, sourceTopic, recordCount);
+
+ // Phase 1: DLQ write succeeds (headers-only) despite the low limit -
copy is skipped, not the write.
+ waitForCondition(() -> dlqMeterCount(METRIC_DLQ_RECORD_COUNT, groupId)
== recordCount,
+ DEFAULT_MAX_WAIT_MS, 200L,
+ () -> "Expected " + recordCount + " DLQ records, was " +
dlqMeterCount(METRIC_DLQ_RECORD_COUNT, groupId));
+ List<ConsumerRecord<byte[], byte[]>> dlqRecords =
readDlqPartition(dlqTopic, 0, recordCount);
+ assertEquals(recordCount, dlqRecords.size(), "Unexpected number of
records on the DLQ topic");
+ for (ConsumerRecord<byte[], byte[]> record : dlqRecords) {
+ assertNull(record.key(), "DLQ record key should be absent - record
copy must have been skipped");
+ assertNull(record.value(), "DLQ record value should be absent -
record copy must have been skipped");
+ assertEquals(groupId, headerValue(record,
HEADER_DLQ_ERRORS_GROUP));
+ assertEquals(sourceTopic, headerValue(record,
HEADER_DLQ_ERRORS_TOPIC));
+ }
+
+ // Phase 2: point the group at a second, freshly-created DLQ topic
whose max.message.bytes is set
+ // comfortably above the decompressed payload size up front - simpler
than altering the first
+ // topic's config and waiting for it to propagate.
+ String dlqTopic2 = "dlq.decompress-cap-2";
+ try (Admin admin = createAdminClient()) {
+ admin.createTopics(Set.of(
+ new NewTopic(dlqTopic2, 1, (short) 1)
+ .configs(Map.of(
+
TopicConfig.ERRORS_DEADLETTERQUEUE_GROUP_ENABLE_CONFIG, "true",
+ TopicConfig.MAX_MESSAGE_BYTES_CONFIG,
Integer.toString(highDlqMaxMessageBytes)))
+ )).all().get();
+ }
+ alterShareGroupConfig(groupId,
GroupConfig.ERRORS_DEADLETTERQUEUE_TOPIC_NAME_CONFIG, dlqTopic2);
+
+ try (Producer<byte[], byte[]> producer =
createProducer(Map.of(ProducerConfig.COMPRESSION_TYPE_CONFIG, "gzip"))) {
+ for (int i = 0; i < recordCount; i++) {
+ producer.send(new ProducerRecord<>(sourceTopic, 0,
"key".getBytes(StandardCharsets.UTF_8), payload));
+ }
+ producer.flush();
+ }
+ rejectRecords(groupId, sourceTopic, recordCount);
+
+ // The budget is now sufficient, so the second batch's DLQ records (on
the new topic) must carry the
+ // copied payload. The per-group metric accumulates across both DLQ
topics used by this group.
+ waitForCondition(() -> dlqMeterCount(METRIC_DLQ_RECORD_COUNT, groupId)
== recordCount * 2,
+ DEFAULT_MAX_WAIT_MS, 200L,
+ () -> "Expected " + (recordCount * 2) + " DLQ records, was " +
dlqMeterCount(METRIC_DLQ_RECORD_COUNT, groupId));
+ List<ConsumerRecord<byte[], byte[]>> secondBatchDlqRecords =
readDlqPartition(dlqTopic2, 0, recordCount);
+ assertEquals(recordCount, secondBatchDlqRecords.size(),
+ "Unexpected number of records on the second DLQ topic");
+ for (ConsumerRecord<byte[], byte[]> record : secondBatchDlqRecords) {
+ assertArrayEquals(payload, record.value(), "DLQ record value
should be the copied payload");
+ assertEquals(groupId, headerValue(record,
HEADER_DLQ_ERRORS_GROUP));
+ assertEquals(sourceTopic, headerValue(record,
HEADER_DLQ_ERRORS_TOPIC));
+ }
+ }
+
/**
* Verifies the DLQ is NOT written when it is gated off, across the gating
conditions:
* (a) no DLQ topic configured for the group;
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 4d832423b02..588077ade02 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
@@ -17,13 +17,21 @@
package org.apache.kafka.server.share.dlq;
+import org.apache.kafka.common.KafkaException;
import org.apache.kafka.common.TopicIdPartition;
import org.apache.kafka.common.protocol.Errors;
+import org.apache.kafka.common.record.TimestampType;
+import org.apache.kafka.common.record.internal.DefaultRecord;
+import org.apache.kafka.common.record.internal.DefaultRecordBatch;
+import org.apache.kafka.common.record.internal.MemoryRecords;
import org.apache.kafka.common.record.internal.Record;
import org.apache.kafka.common.record.internal.RecordBatch;
import org.apache.kafka.common.record.internal.Records;
import org.apache.kafka.common.requests.FetchRequest;
import org.apache.kafka.common.utils.Time;
+import org.apache.kafka.common.utils.internals.BufferSupplier;
+import org.apache.kafka.common.utils.internals.ByteBufferOutputStream;
+import org.apache.kafka.common.utils.internals.CloseableIterator;
import org.apache.kafka.server.share.LogReader;
import org.apache.kafka.server.storage.log.FetchIsolation;
import org.apache.kafka.server.storage.log.FetchParams;
@@ -32,6 +40,9 @@ import org.apache.kafka.storage.internals.log.LogReadResult;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.ByteBuffer;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;
@@ -51,11 +62,27 @@ import java.util.concurrent.CompletableFuture;
* 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>{@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.
+ *
* <p>Instances are single-use: create one fetcher per {@link #fetch()} call.
*/
public class ShareGroupDLQRecordFetcher {
private static final Logger log =
LoggerFactory.getLogger(ShareGroupDLQRecordFetcher.class);
+ // Size of the transfer buffer used to pull decompressed bytes out of the
codec's stream a chunk at a
+ // time (see decompressBounded). Fixed and deliberately independent of
maxDecompressedBytes: it only
+ // affects how many read() calls happen, not whether the budget check is
correct, so tying it to the
+ // (policy-configured, potentially large) budget would allocate a scratch
buffer sized by configuration
+ // rather than by the data actually being copied. Matches the precedent in
ClientTelemetryUtils.decompress.
+ private static final int DECOMPRESS_CHUNK_BYTES = 8 * 1024;
+
private final LogReader logReader;
private final Time time;
private final ShareGroupDLQRecordParameter param;
@@ -65,6 +92,9 @@ public class ShareGroupDLQRecordFetcher {
private final int recordCount;
private final long startTime;
private final Map<Long, Record> recordMap;
+ private final long maxDecompressedBytes;
+ private final BufferSupplier bufferSupplier = BufferSupplier.create();
+ private long decompressedBytes = 0;
// 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.
@@ -73,7 +103,8 @@ public class ShareGroupDLQRecordFetcher {
private final CompletableFuture<Map<Long, Record>> result = new
CompletableFuture<>();
private final FetchParams fetchParams;
- public ShareGroupDLQRecordFetcher(LogReader logReader, Time time,
ShareGroupDLQRecordParameter param, int maxFetchBytes) {
+ public ShareGroupDLQRecordFetcher(LogReader logReader, Time time,
ShareGroupDLQRecordParameter param,
+ int maxFetchBytes, int
maxDecompressedBytes) {
this.logReader = logReader;
this.time = time;
this.param = param;
@@ -83,6 +114,7 @@ public class ShareGroupDLQRecordFetcher {
this.startTime = time.hiResClockMs();
this.recordMap = new HashMap<>(recordCount);
this.maxBytesMap.put(tp, maxFetchBytes);
+ this.maxDecompressedBytes = maxDecompressedBytes;
this.fetchParams = new FetchParams(
FetchRequest.CONSUMER_REPLICA_ID, // -1, reading as a
consumer
-1, // replicaEpoch
@@ -102,10 +134,14 @@ public class ShareGroupDLQRecordFetcher {
public CompletableFuture<Map<Long, Record>> fetch() {
try {
runFrom(param.firstOffset());
- } catch (Exception e) {
- // Never let an unexpected error escape; skip record copy entirely.
- log.warn("Unexpected error fetching records for {}. Skipping
record copy.", param, e);
- result.complete(Map.of());
+ } 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.
+ log.warn("Unexpected error fetching records for {}. Returning
records fetched so far.", param, e);
+ complete();
}
return result;
}
@@ -139,7 +175,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
+ complete(); // no progress - stop
return;
}
nextOffset = advanced;
@@ -168,11 +204,14 @@ public class ShareGroupDLQRecordFetcher {
}
long advanced = collect(readFrom, logReadResult);
if (advanced <= readFrom) {
- complete(); // no progress, stop
+ complete(); // no progress - stop
} else {
runFrom(advanced); // resume the loop
}
- } catch (Exception e) {
+ } 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();
}
@@ -197,19 +236,168 @@ 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.
+ * (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.
*/
private long collectRecords(Records records, long readFrom) {
long nextOffset = readFrom;
for (RecordBatch batch : records.batches()) {
- for (Record record : batch) {
- // A fetch can return a batch whose base offset is below the
requested offset, so skip
- // any record at or before the read position to avoid
re-processing and dragging
- // nextOffset backwards.
+ nextOffset = collectFromBatch(batch, nextOffset);
+ }
+ return nextOffset;
+ }
+
+ private long collectFromBatch(RecordBatch batch, long readFrom) {
+ if (!batch.isCompressed()) {
+ // No decompression risk to bound: the on-wire size already bounds
what's in memory, so no
+ // cap applies (unlike the compressed cases below, capping this
would reject legitimately
+ // large uncompressed records for no safety benefit).
+ return collectUncompressed(batch, readFrom);
+ }
+ if (batch.magic() >= RecordBatch.MAGIC_VALUE_V2) {
+ return collectFromCompressedBatch(asDefaultRecordBatch(batch),
readFrom);
+ }
+ // Legacy magic v0/v1 compressed batch, effectively unseen in
practice: no bounded-buffer path
+ // available for that format, so fall back to the cumulative cap.
+ return collectWithCumulativeCap(batch, readFrom);
+ }
+
+ private long collectUncompressed(RecordBatch batch, long readFrom) {
+ long nextOffset = readFrom;
+ for (Record record : batch) {
+ // A fetch can return a batch whose base offset is below the
requested offset, so skip
+ // any record at or before the read position to avoid
re-processing and dragging
+ // nextOffset backwards.
+ if (record.offset() < readFrom) continue;
+ if (record.offset() > endOffset) return nextOffset;
+ recordMap.put(record.offset(), record);
+ nextOffset = Math.max(nextOffset, record.offset() + 1); // never
moves backwards
+ }
+ return nextOffset;
+ }
+
+ /**
+ * Materializes the batch's own on-wire bytes into a plain in-memory
{@link DefaultRecordBatch}. This is
+ * needed because a real local log read ({@code FileRecords#batches()})
returns a lazy file-channel-backed
+ * wrapper ({@code DefaultRecordBatch.DefaultFileChannelRecordBatch})
whose underlying
+ * {@code DefaultRecordBatch} is not reachable from outside {@code
org.apache.kafka.common.record.internal}
+ * (package-private constructor, protected loader) - only
already-in-memory batches (e.g. {@link
+ * MemoryRecords}) are directly a {@code DefaultRecordBatch}. The copy is
bounded by {@link
+ * RecordBatch#sizeInBytes()} - the compressed, on-wire size, itself
already bounded by this fetch's
+ * {@code maxFetchBytes} cap - so this is safe regardless of how large the
batch decompresses to.
+ */
+ private DefaultRecordBatch asDefaultRecordBatch(RecordBatch batch) {
+ if (batch instanceof DefaultRecordBatch defaultBatch) {
+ return defaultBatch;
+ }
+ ByteBuffer buffer = ByteBuffer.allocate(batch.sizeInBytes());
+ batch.writeTo(buffer);
+ buffer.flip();
+ return (DefaultRecordBatch)
MemoryRecords.readableRecords(buffer).batches().iterator().next();
+ }
+
+ /**
+ * Decompresses the batch into a size-bounded buffer first (see {@link
#decompressBounded}), then parses
+ * records out of that already-bounded buffer via the slice-based {@link
DefaultRecord#readFrom(ByteBuffer,
+ * long, long, int, Long)} - the same one the uncompressed path uses - so
a record's declared length can
+ * never cause an allocation larger than the buffer we already control.
+ */
+ 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);
+ }
+
+ long baseOffset = batch.baseOffset();
+ long baseTimestamp = batch.baseTimestamp();
+ int baseSequence = batch.baseSequence();
+ Long logAppendTime = batch.timestampType() ==
TimestampType.LOG_APPEND_TIME ? batch.maxTimestamp() : null;
+
+ long nextOffset = readFrom;
+ while (decompressed.hasRemaining()) {
+ Record record = DefaultRecord.readFrom(decompressed, baseOffset,
baseTimestamp, baseSequence, logAppendTime);
+ // A fetch can return a batch whose base offset is below the
requested offset, so skip
+ // any record at or before the read position to avoid
re-processing and dragging
+ // nextOffset backwards.
+ if (record.offset() < readFrom) continue;
+ if (record.offset() > endOffset) return nextOffset;
+ recordMap.put(record.offset(), record);
+ nextOffset = Math.max(nextOffset, record.offset() + 1); // never
moves backwards
+ }
+ 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.
+ *
+ * @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.
+ */
+ 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)) {
+ byte[] chunk = new byte[chunkBytes];
+ int nRead;
+ 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);
+ return null;
+ }
+ out.write(chunk, 0, nRead);
+ }
+ decompressedBytes += total;
+ out.buffer().flip();
+ return out.buffer();
+ } catch (IOException e) {
+ throw new KafkaException("Failed to decompress batch for " +
param, e);
+ }
+ }
+
+ /**
+ * 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.
+ */
+ private long collectWithCumulativeCap(RecordBatch batch, long readFrom) {
+ long nextOffset = readFrom;
+ try (CloseableIterator<Record> iterator =
batch.streamingIterator(bufferSupplier)) {
+ while (iterator.hasNext()) {
+ Record record = iterator.next();
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);
+ }
+
recordMap.put(record.offset(), record);
- nextOffset = Math.max(nextOffset, record.offset() + 1); //
never moves backwards
+ nextOffset = Math.max(nextOffset, record.offset() + 1);
}
}
return nextOffset;
@@ -220,6 +408,7 @@ public class ShareGroupDLQRecordFetcher {
* that could not be read are absent from the map; the caller produces a
headers-only DLQ record for them.
*/
private void complete() {
+ bufferSupplier.close();
log.trace("Log fetch took {} ms for {} records starting at {} for {}",
time.hiResClockMs() - startTime,
recordCount, param.firstOffset(), param);
if (recordCount != recordMap.size()) {
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 52753feb9d0..51e674a514f 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
@@ -794,7 +794,15 @@ public class ShareGroupDLQStateManager {
if
(!cacheHelper.isShareGroupDlqCopyRecordEnabled(param.groupId())) {
return CompletableFuture.completedFuture(Map.of());
}
- return new ShareGroupDLQRecordFetcher(logReader, time, param,
DLQ_MAX_FETCH_BYTES).fetch();
+ // 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
+ // (unlike the user's own topic config) this value isn't
controlled by whoever produced the
+ // record being copied. Falls back to DLQ_MAX_FETCH_BYTES in the
(defensive-only) case the DLQ
+ // topic isn't resolvable here - copy-record being enabled implies
one is configured in practice.
+ 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();
}
}
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 6779e9617cc..5dac442912f 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
@@ -21,11 +21,16 @@ import org.apache.kafka.common.TopicIdPartition;
import org.apache.kafka.common.Uuid;
import org.apache.kafka.common.compress.Compression;
import org.apache.kafka.common.protocol.Errors;
+import org.apache.kafka.common.record.internal.DefaultRecordBatch;
import org.apache.kafka.common.record.internal.MemoryRecords;
import org.apache.kafka.common.record.internal.Record;
+import org.apache.kafka.common.record.internal.RecordBatch;
import org.apache.kafka.common.record.internal.Records;
import org.apache.kafka.common.record.internal.SimpleRecord;
import org.apache.kafka.common.utils.Utils;
+import org.apache.kafka.common.utils.internals.BufferSupplier;
+import org.apache.kafka.common.utils.internals.ByteBufferOutputStream;
+import org.apache.kafka.common.utils.internals.ByteUtils;
import org.apache.kafka.server.share.LogReader;
import org.apache.kafka.server.util.MockTime;
import org.apache.kafka.storage.internals.log.FetchDataInfo;
@@ -33,6 +38,9 @@ import org.apache.kafka.storage.internals.log.LogReadResult;
import org.junit.jupiter.api.Test;
+import java.io.ByteArrayOutputStream;
+import java.io.InputStream;
+import java.io.OutputStream;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.util.LinkedHashMap;
@@ -63,6 +71,7 @@ class ShareGroupDLQRecordFetcherTest {
private static final TopicIdPartition TOPIC_ID_PARTITION =
new TopicIdPartition(Uuid.randomUuid(), 0, "source-topic");
private static final int MAX_FETCH_BYTES = 1024 * 1024;
+ private static final int MAX_DECOMPRESSED_BYTES = 1024 * 1024;
private final LogReader logReader = mock(LogReader.class);
@@ -72,7 +81,11 @@ class ShareGroupDLQRecordFetcherTest {
}
private ShareGroupDLQRecordFetcher fetcher(ShareGroupDLQRecordParameter
param) {
- return new ShareGroupDLQRecordFetcher(logReader, MOCK_TIME, param,
MAX_FETCH_BYTES);
+ return fetcher(param, MAX_DECOMPRESSED_BYTES);
+ }
+
+ private ShareGroupDLQRecordFetcher fetcher(ShareGroupDLQRecordParameter
param, int maxDecompressedBytes) {
+ return new ShareGroupDLQRecordFetcher(logReader, MOCK_TIME, param,
MAX_FETCH_BYTES, maxDecompressedBytes);
}
private Map<Long, Record> fetch(ShareGroupDLQRecordParameter param) throws
Exception {
@@ -88,8 +101,12 @@ class ShareGroupDLQRecordFetcherTest {
// A successful read carrying the given records (offsets assigned from 0
by MemoryRecords#withRecords).
private static LogReadResult success(SimpleRecord... records) {
+ return success(Compression.NONE, records);
+ }
+
+ private static LogReadResult success(Compression compression,
SimpleRecord... records) {
return logReadResult(
- new FetchDataInfo(null,
MemoryRecords.withRecords(Compression.NONE, records)), Errors.NONE);
+ new FetchDataInfo(null, MemoryRecords.withRecords(compression,
records)), Errors.NONE);
}
// A failed read - partial-data tolerant, so it still carries a (here
empty) FetchDataInfo.
@@ -288,4 +305,154 @@ class ShareGroupDLQRecordFetcherTest {
assertTrue(result.isEmpty());
}
+
+ @Test
+ public void testCompressedBatchFetchedCorrectly() throws Exception {
+ // A normal, well within-budget compressed batch is unaffected by the
bounded decompression path.
+ whenReadAsync(done(success(Compression.gzip().build(),
+ record("k0", "v0"), record("k1", "v1"), record("k2", "v2"))));
+
+ Map<Long, Record> result = fetch(param(0L, 2L));
+
+ assertEquals(3, result.size());
+ assertRecord(result, 0L, "k0", "v0");
+ assertRecord(result, 1L, "k1", "v1");
+ assertRecord(result, 2L, "k2", "v2");
+ }
+
+ @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.
+ 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);
+
+ assertTrue(result.isEmpty(), "Expected the over-budget batch to be
skipped, yielding no records");
+ }
+
+ @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.
+ String bigValue = "x".repeat(10_000);
+ MemoryRecords compressedBatch = MemoryRecords.withRecords(0L,
Compression.gzip().build(),
+ record("k0", bigValue), record("k1", bigValue), record("k2",
bigValue));
+ MemoryRecords uncompressedBatch = MemoryRecords.withRecords(3L,
Compression.NONE, record("k3", "v3"));
+
+ 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);
+
+ assertNull(result.get(0L));
+ assertNull(result.get(1L));
+ assertNull(result.get(2L));
+ assertRecord(result, 3L, "k3", "v3");
+ }
+
+ @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
+ // whole fetch - a later, uncompressed batch remains unaffected.
+ String bigValue = "x".repeat(10_000);
+ MemoryRecords legacyCompressedBatch =
MemoryRecords.withRecords(RecordBatch.MAGIC_VALUE_V1, 0L,
+ Compression.gzip().build(), record("k0", bigValue), record("k1",
bigValue), record("k2", bigValue));
+ MemoryRecords uncompressedBatch = MemoryRecords.withRecords(3L,
Compression.NONE, record("k3", "v3"));
+
+ 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);
+
+ assertNull(result.get(0L));
+ assertNull(result.get(1L));
+ assertNull(result.get(2L));
+ assertRecord(result, 3L, "k3", "v3");
+ }
+
+ // Concatenates the given batches' on-wire bytes into a single Records,
simulating one read returning
+ // multiple consecutive batches.
+ private static Records concatBatches(MemoryRecords... batches) {
+ int size = 0;
+ for (MemoryRecords batch : batches) {
+ size += batch.buffer().remaining();
+ }
+ ByteBuffer combined = ByteBuffer.allocate(size);
+ for (MemoryRecords batch : batches) {
+ combined.put(batch.buffer().duplicate());
+ }
+ combined.flip();
+ return MemoryRecords.readableRecords(combined);
+ }
+
+ @Test
+ public void
testSingleRecordWithFabricatedLengthRejectedWithoutLargeAllocation() throws
Exception {
+ // Distinct from
testCompressedBatchExceedingDecompressedBudgetStopsEarly (which uses a real,
+ // legitimately-compressible payload to blow a small budget): here the
batch is tiny even once
+ // decompressed - well within budget - but the single record's own
declared length lies about how
+ // much data follows it, simulating a maliciously corrupted record
rather than a genuinely large
+ // one. Verifies the record is rejected via the bounded buffer's own
remaining-capacity check
+ // (DefaultRecord.readFrom throwing InvalidRecordException) rather
than the fetcher ever attempting
+ // an allocation sized by the fabricated (here, ~1GB) declared length.
+ whenReadAsync(done(logReadResult(new FetchDataInfo(null,
corruptedLengthBatch()), Errors.NONE)));
+
+ Map<Long, Record> result = fetch(param(0L, 0L));
+
+ assertTrue(result.isEmpty(), "Corrupted record must be rejected, not
force a large allocation");
+ }
+
+ // Builds a single-record, gzip-compressed batch whose record claims a
declared body size (~1GB) far
+ // larger than what actually follows it - fabricating a corrupted length
rather than a genuinely large
+ // payload, to exercise DefaultRecord.readFrom's bounds check in isolation
from the budget check.
+ private static Records corruptedLengthBatch() throws Exception {
+ // A normal single-record gzip batch, to source real header +
compressed record bytes from.
+ MemoryRecords source =
MemoryRecords.withRecords(Compression.gzip().build(), record("k", "v"));
+ DefaultRecordBatch batch = (DefaultRecordBatch)
source.batches().iterator().next();
+
+ // Decompress the record region to plain bytes.
+ ByteArrayOutputStream plainOut = new ByteArrayOutputStream();
+ try (InputStream in =
batch.recordInputStream(BufferSupplier.NO_CACHING)) {
+ in.transferTo(plainOut);
+ }
+ byte[] plain = plainOut.toByteArray();
+
+ // Replace the record's declared body-size varint (its first byte, for
a record this small) with
+ // one that claims a huge size, far more than actually remains.
+ ByteBuffer corruptedPlain = ByteBuffer.allocate(plain.length + 8);
+ ByteUtils.writeVarint(1_000_000_000, corruptedPlain);
+ corruptedPlain.put(plain, 1, plain.length - 1);
+ corruptedPlain.flip();
+ byte[] corrupted = new byte[corruptedPlain.remaining()];
+ corruptedPlain.get(corrupted);
+
+ // Re-compress the corrupted plain bytes with the same codec.
+ ByteBufferOutputStream compressedOut = new ByteBufferOutputStream(256);
+ try (OutputStream out =
Compression.gzip().build().wrapForOutput(compressedOut,
RecordBatch.CURRENT_MAGIC_VALUE)) {
+ out.write(corrupted);
+ }
+ compressedOut.buffer().flip();
+
+ // Splice: the original header (unchanged) + newly-compressed
corrupted record region, with the
+ // batch's LENGTH field (Int32 at offset 8: size of everything after
the length field itself)
+ // patched to match the new total size.
+ ByteBuffer originalFull = ByteBuffer.allocate(batch.sizeInBytes());
+ batch.writeTo(originalFull);
+ originalFull.flip();
+ originalFull.limit(DefaultRecordBatch.RECORD_BATCH_OVERHEAD);
+
+ ByteBuffer full =
ByteBuffer.allocate(DefaultRecordBatch.RECORD_BATCH_OVERHEAD +
compressedOut.buffer().remaining());
+ full.put(originalFull);
+ full.put(compressedOut.buffer());
+ full.flip();
+ full.putInt(8, full.limit() - 12);
+
+ return MemoryRecords.readableRecords(full);
+ }
}