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 ae1783df066 KAFKA-20686: Add base integ tests for share group DLQ. 
[1/N] (#22647)
ae1783df066 is described below

commit ae1783df0661a6b921a76b64757ece18967e6e49
Author: Sushant Mahajan <[email protected]>
AuthorDate: Wed Jun 24 14:48:07 2026 +0530

    KAFKA-20686: Add base integ tests for share group DLQ. [1/N] (#22647)
    
    Add share group DLQ integ tests to test for basic good paths related to
    `REJECT` and `RELEASE` acknowledgements. DLQ topic auto create is
    verified in a separate test. More complex scenarios will follow.
    
    Reviewers: Andrew Schofield <[email protected]>
---
 .../clients/consumer/ShareConsumerDLQTest.java     | 308 +++++++++++++++++++++
 1 file changed, 308 insertions(+)

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
new file mode 100644
index 00000000000..e67d66b0d73
--- /dev/null
+++ 
b/clients/clients-integration-tests/src/test/java/org/apache/kafka/clients/consumer/ShareConsumerDLQTest.java
@@ -0,0 +1,308 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.kafka.clients.consumer;
+
+import org.apache.kafka.clients.admin.Admin;
+import org.apache.kafka.clients.admin.Config;
+import org.apache.kafka.clients.admin.ConfigEntry;
+import org.apache.kafka.clients.admin.NewTopic;
+import org.apache.kafka.common.TopicPartition;
+import org.apache.kafka.common.config.ConfigResource;
+import org.apache.kafka.common.config.TopicConfig;
+import org.apache.kafka.common.header.Header;
+import org.apache.kafka.common.test.ClusterInstance;
+import org.apache.kafka.common.test.api.ClusterConfigProperty;
+import org.apache.kafka.common.test.api.ClusterTest;
+import org.apache.kafka.common.test.api.ClusterTestDefaults;
+import org.apache.kafka.common.test.api.Type;
+import org.apache.kafka.coordinator.group.GroupConfig;
+import org.apache.kafka.server.metrics.KafkaYammerMetrics;
+
+import com.yammer.metrics.core.Meter;
+
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+
+import static org.apache.kafka.test.TestUtils.DEFAULT_MAX_WAIT_MS;
+import static org.apache.kafka.test.TestUtils.waitForCondition;
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+@ClusterTestDefaults(
+    types = {Type.KRAFT},
+    serverProperties = {
+        @ClusterConfigProperty(key = "auto.create.topics.enable", value = 
"false"),
+        @ClusterConfigProperty(key = 
"group.share.max.partition.max.record.locks", value = "10000"),
+        @ClusterConfigProperty(key = "group.share.partition.max.record.locks", 
value = "10000"),
+        @ClusterConfigProperty(key = "group.share.record.lock.duration.ms", 
value = "15000"),
+        @ClusterConfigProperty(key = "offsets.topic.replication.factor", value 
= "1"),
+        @ClusterConfigProperty(key = "share.coordinator.state.topic.min.isr", 
value = "1"),
+        @ClusterConfigProperty(key = 
"share.coordinator.state.topic.num.partitions", value = "3"),
+        @ClusterConfigProperty(key = 
"share.coordinator.state.topic.replication.factor", value = "1")
+    }
+)
+public class ShareConsumerDLQTest extends ShareConsumerTestBase {
+
+    // DLQ context headers written onto each DLQ record. These mirror the 
(package-private) constants in
+    // org.apache.kafka.server.share.dlq.ShareGroupDLQStateManager and form 
the wire contract for DLQ records.
+    private static final String HEADER_DLQ_ERRORS_TOPIC = "__dlq.errors.topic";
+    private static final String HEADER_DLQ_ERRORS_PARTITION = 
"__dlq.errors.partition";
+    private static final String HEADER_DLQ_ERRORS_OFFSET = 
"__dlq.errors.offset";
+    private static final String HEADER_DLQ_ERRORS_GROUP = "__dlq.errors.group";
+
+    // Yammer metric names registered by 
org.apache.kafka.server.share.metrics.ShareGroupMetrics.
+    private static final String METRIC_DLQ_RECORD_COUNT = 
"DeadLetterQueueRecordCount";
+    private static final String METRIC_DLQ_PRODUCE_TOTAL = 
"DeadLetterQueueTotalProduceRequestsPerSec";
+
+    public ShareConsumerDLQTest(ClusterInstance cluster) {
+        super(cluster);
+    }
+
+    /**
+     * Produces 5 records, rejects every one of them with a share consumer in 
EXPLICIT acknowledgement mode,
+     * and verifies they are written to the configured DLQ topic. Record copy 
is disabled, so the DLQ records
+     * carry only the context headers (no key/value). Finally asserts the DLQ 
metrics for records written and
+     * produce requests enqueued.
+     */
+    @ClusterTest
+    public void testRejectedRecordsWrittenToDlqWithCopyRecordDisabled() throws 
Exception {
+        String groupId = "dlq-group";
+        // The broker's default share-group DLQ topic prefix is "dlq.", so the 
topic name must start with it.
+        String dlqTopic = "dlq.topic";
+        int recordCount = 5;
+
+        alterShareAutoOffsetReset(groupId, "earliest");
+        // Create the DLQ topic with DLQ enabled, and point the share group at 
it. Record copy is left
+        // disabled (the default), so produced DLQ records contain headers 
only.
+        createDlqTopic(dlqTopic);
+        alterShareGroupConfig(groupId, 
GroupConfig.ERRORS_DEADLETTERQUEUE_TOPIC_NAME_CONFIG, dlqTopic);
+
+        // Produce the source records onto "topic" (partition 0), created by 
the base setup.
+        produceMessages(recordCount);
+
+        // Reject every record using an EXPLICIT-mode share consumer.
+        rejectAllRecords(groupId, recordCount);
+
+        // Verify by reading from the DLQ topic, then assert the DLQ metrics.
+        verifyDlqTopicRecords(dlqTopic, groupId, 
expectedSourceOffsets(recordCount));
+        verifyDlqMetrics(groupId, recordCount);
+    }
+
+    /**
+     * As {@link #testRejectedRecordsWrittenToDlqWithCopyRecordDisabled()}, 
but the DLQ topic is not created up
+     * front: with DLQ auto topic creation enabled on the broker, the broker 
should create the configured DLQ
+     * topic on the first write. Verifies the topic was created (with DLQ 
enabled), received the records, and
+     * that the DLQ metrics fired.
+     */
+    @ClusterTest(
+        serverProperties = {
+            @ClusterConfigProperty(key = 
"errors.deadletterqueue.auto.create.topics.enable", value = "true")
+        }
+    )
+    public void testRejectedRecordsWrittenToAutoCreatedDlq() throws Exception {
+        String groupId = "dlq-autocreate-group";
+        // The broker's default share-group DLQ topic prefix is "dlq.", so the 
topic name must start with it.
+        String dlqTopic = "dlq.autocreate";
+        int recordCount = 5;
+
+        alterShareAutoOffsetReset(groupId, "earliest");
+        // Point the share group at a DLQ topic that does NOT exist yet; the 
broker should auto-create it.
+        alterShareGroupConfig(groupId, 
GroupConfig.ERRORS_DEADLETTERQUEUE_TOPIC_NAME_CONFIG, dlqTopic);
+
+        produceMessages(recordCount);
+        rejectAllRecords(groupId, recordCount);
+
+        // Verify the DLQ topic was auto-created (with DLQ enabled), received 
the records, and metrics fired.
+        verifyDlqTopicCreated(dlqTopic);
+        verifyDlqTopicRecords(dlqTopic, groupId, 
expectedSourceOffsets(recordCount));
+        verifyDlqMetrics(groupId, recordCount);
+    }
+
+    /**
+     * Produces 5 records and repeatedly releases them with a share consumer. 
Each release makes the records
+     * available again and, on re-acquisition, increments their delivery 
count; once the delivery count limit
+     * is exceeded the broker archives them and writes them to the DLQ (cause: 
delivery count exceeded). The
+     * DLQ topic is created manually up front. Verifies the records reached 
the DLQ and the DLQ metrics fired.
+     */
+    @ClusterTest
+    public void testReleasedRecordsExceedingDeliveryCountWrittenToDlq() throws 
Exception {
+        String groupId = "dlq-release-group";
+        // The broker's default share-group DLQ topic prefix is "dlq.", so the 
topic name must start with it.
+        String dlqTopic = "dlq.release";
+        int recordCount = 5;
+
+        alterShareAutoOffsetReset(groupId, "earliest");
+        // Keep the delivery count limit low so a couple of releases exhaust 
it and trigger the DLQ.
+        alterShareDeliveryCountLimit(groupId, "2");
+        // Create the DLQ topic with DLQ enabled, and point the share group at 
it.
+        createDlqTopic(dlqTopic);
+        alterShareGroupConfig(groupId, 
GroupConfig.ERRORS_DEADLETTERQUEUE_TOPIC_NAME_CONFIG, dlqTopic);
+
+        produceMessages(recordCount);
+
+        // Repeatedly release the records until their delivery count is 
exceeded and they are written to the DLQ.
+        releaseRecordsUntilDlq(groupId, recordCount);
+
+        verifyDlqTopicRecords(dlqTopic, groupId, 
expectedSourceOffsets(recordCount));
+        verifyDlqMetrics(groupId, recordCount);
+    }
+
+    // Consumes from the source topic in EXPLICIT acknowledgement mode and 
rejects every record.
+    private void rejectAllRecords(String groupId, int recordCount) {
+        try (ShareConsumer<byte[], byte[]> shareConsumer = createShareConsumer(
+            groupId, Map.of(ConsumerConfig.SHARE_ACKNOWLEDGEMENT_MODE_CONFIG, 
EXPLICIT))) {
+            shareConsumer.subscribe(Set.of(tp.topic()));
+            int rejected = 0;
+            long deadlineMs = System.currentTimeMillis() + DEFAULT_MAX_WAIT_MS;
+            while (rejected < recordCount && System.currentTimeMillis() < 
deadlineMs) {
+                ConsumerRecords<byte[], byte[]> records = 
shareConsumer.poll(Duration.ofMillis(2000));
+                for (ConsumerRecord<byte[], byte[]> record : records) {
+                    shareConsumer.acknowledge(record, AcknowledgeType.REJECT);
+                    rejected++;
+                }
+                if (records.count() > 0) {
+                    shareConsumer.commitSync(Duration.ofMillis(10000));
+                }
+            }
+            assertEquals(recordCount, rejected, "Expected to reject all 
produced records");
+        }
+    }
+
+    // Repeatedly polls and releases every record until the delivery count 
limit is exceeded for all of them
+    // and they have been written to the DLQ (tracked via the DLQ record-count 
metric).
+    private void releaseRecordsUntilDlq(String groupId, int recordCount) {
+        try (ShareConsumer<byte[], byte[]> shareConsumer = createShareConsumer(
+            groupId, Map.of(ConsumerConfig.SHARE_ACKNOWLEDGEMENT_MODE_CONFIG, 
EXPLICIT))) {
+            shareConsumer.subscribe(Set.of(tp.topic()));
+            long deadlineMs = System.currentTimeMillis() + DEFAULT_MAX_WAIT_MS;
+            while (dlqMeterCount(METRIC_DLQ_RECORD_COUNT, groupId) < 
recordCount
+                && System.currentTimeMillis() < deadlineMs) {
+                ConsumerRecords<byte[], byte[]> records = 
shareConsumer.poll(Duration.ofMillis(2000));
+                for (ConsumerRecord<byte[], byte[]> record : records) {
+                    shareConsumer.acknowledge(record, AcknowledgeType.RELEASE);
+                }
+                if (records.count() > 0) {
+                    shareConsumer.commitSync(Duration.ofMillis(10000));
+                }
+            }
+        }
+    }
+
+    private static Set<Long> expectedSourceOffsets(int recordCount) {
+        Set<Long> offsets = new HashSet<>();
+        for (long offset = 0; offset < recordCount; offset++) {
+            offsets.add(offset);
+        }
+        return offsets;
+    }
+
+    // Asserts that every record was written to the DLQ and that at least one 
DLQ produce request was enqueued.
+    private void verifyDlqMetrics(String groupId, int expectedRecordCount) 
throws InterruptedException {
+        waitForCondition(() -> dlqMeterCount(METRIC_DLQ_RECORD_COUNT, groupId) 
== expectedRecordCount,
+            DEFAULT_MAX_WAIT_MS, 200L,
+            () -> "DeadLetterQueueRecordCount did not reach " + 
expectedRecordCount
+                + ", was " + dlqMeterCount(METRIC_DLQ_RECORD_COUNT, groupId));
+        assertEquals(expectedRecordCount, 
dlqMeterCount(METRIC_DLQ_RECORD_COUNT, groupId));
+        assertTrue(dlqMeterCount(METRIC_DLQ_PRODUCE_TOTAL, groupId) >= 1,
+            "Expected at least one DLQ produce request to have been enqueued");
+    }
+
+    // Verifies the DLQ topic was auto-created by the broker and has the 
DLQ-enable topic config set.
+    private void verifyDlqTopicCreated(String dlqTopic) throws Exception {
+        try (Admin admin = createAdminClient()) {
+            waitForCondition(() -> 
admin.listTopics().names().get().contains(dlqTopic),
+                DEFAULT_MAX_WAIT_MS, 500L, () -> "DLQ topic " + dlqTopic + " 
was not auto-created");
+
+            ConfigResource resource = new 
ConfigResource(ConfigResource.Type.TOPIC, dlqTopic);
+            Config config = 
admin.describeConfigs(List.of(resource)).all().get().get(resource);
+            ConfigEntry entry = 
config.get(TopicConfig.ERRORS_DEADLETTERQUEUE_GROUP_ENABLE_CONFIG);
+            assertNotNull(entry, "Auto-created DLQ topic is missing the 
DLQ-enable config");
+            assertEquals("true", entry.value(), "Auto-created DLQ topic should 
have DLQ enabled");
+        }
+    }
+
+    /**
+     * Reads the DLQ topic and asserts it received exactly one record per 
expected source offset. Record copy
+     * is disabled, so each DLQ record carries only the context headers (no 
key/value).
+     *
+     * @param dlqTopic              the DLQ topic to read from (single 
partition)
+     * @param groupId               the share group the rejected records 
belonged to
+     * @param expectedSourceOffsets the source offsets expected to have been 
written to the DLQ
+     */
+    private void verifyDlqTopicRecords(String dlqTopic, String groupId, 
Set<Long> expectedSourceOffsets) throws InterruptedException {
+        TopicPartition dlqTp = new TopicPartition(dlqTopic, 0);
+        List<ConsumerRecord<byte[], byte[]>> dlqRecords = new ArrayList<>();
+        try (Consumer<byte[], byte[]> consumer = cluster.consumer()) {
+            consumer.assign(List.of(dlqTp));
+            consumer.seekToBeginning(List.of(dlqTp));
+            waitForCondition(() -> {
+                
dlqRecords.addAll(consumer.poll(Duration.ofMillis(1000)).records(dlqTp));
+                return dlqRecords.size() >= expectedSourceOffsets.size();
+            }, DEFAULT_MAX_WAIT_MS, 500L, () -> "DLQ topic did not receive " + 
expectedSourceOffsets.size() + " records, got " + dlqRecords.size());
+        }
+
+        assertEquals(expectedSourceOffsets.size(), dlqRecords.size(), 
"Unexpected number of records on the DLQ topic");
+        Set<Long> actualSourceOffsets = new HashSet<>();
+        for (ConsumerRecord<byte[], byte[]> record : dlqRecords) {
+            // Record copy is disabled, so only headers are written - the 
value (and key) are null.
+            assertNull(record.value(), "DLQ record value should be null when 
record copy is disabled");
+            assertEquals(groupId, headerValue(record, 
HEADER_DLQ_ERRORS_GROUP));
+            assertEquals(tp.topic(), headerValue(record, 
HEADER_DLQ_ERRORS_TOPIC));
+            assertEquals(Integer.toString(tp.partition()), headerValue(record, 
HEADER_DLQ_ERRORS_PARTITION));
+            
actualSourceOffsets.add(Long.parseLong(Objects.requireNonNull(headerValue(record,
 HEADER_DLQ_ERRORS_OFFSET))));
+        }
+        assertEquals(expectedSourceOffsets, actualSourceOffsets, "DLQ records 
should cover every expected source offset");
+    }
+
+    private void createDlqTopic(String topicName) {
+        assertDoesNotThrow(() -> {
+            try (Admin admin = createAdminClient()) {
+                NewTopic newTopic = new NewTopic(topicName, 1, (short) 1)
+                    
.configs(Map.of(TopicConfig.ERRORS_DEADLETTERQUEUE_GROUP_ENABLE_CONFIG, 
"true"));
+                admin.createTopics(Set.of(newTopic)).all().get();
+            }
+        }, "Failed to create DLQ topic");
+    }
+
+    private static String headerValue(ConsumerRecord<byte[], byte[]> record, 
String key) {
+        Header header = record.headers().lastHeader(key);
+        return header == null ? null : new String(header.value(), 
StandardCharsets.UTF_8);
+    }
+
+    // Returns the count of the (per-group) ShareGroupMetrics meter with the 
given name, or 0 if it has not
+    // been registered yet (the meters are created lazily on the first DLQ 
write for a group).
+    private static long dlqMeterCount(String metricName, String groupId) {
+        return 
KafkaYammerMetrics.defaultRegistry().allMetrics().entrySet().stream()
+            .filter(entry -> {
+                String mBeanName = entry.getKey().toString();
+                return mBeanName.contains("name=" + metricName) && 
mBeanName.contains("group=" + groupId);
+            })
+            .map(Map.Entry::getValue)
+            .mapToLong(metric -> ((Meter) metric).count())
+            .findFirst()
+            .orElse(-1L);
+    }
+}

Reply via email to