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 931fde96ffd KAFKA-20772 (1/n): Add DLQ system tests for 
single-partition share group scenarios (#22786)
931fde96ffd is described below

commit 931fde96ffd449161661c96fbf89c705aa349603
Author: Sanskar Jhajharia <[email protected]>
AuthorDate: Thu Jul 16 19:00:24 2026 +0530

    KAFKA-20772 (1/n): Add DLQ system tests for single-partition share group 
scenarios (#22786)
    
    This adds the first system-test coverage for share group dead-letter
    queues (KIP-1191, part of KAFKA-19469), plus the client-side tooling
    changes needed to drive it — there was previously no way for any
    ducktape test client to explicitly REJECT or RELEASE a share-consumed
    record; VerifiableShareConsumer only ever implicitly accepted.
    
    `VerifiableShareConsumer.java`
    - New `--ack-pattern` CLI flag: a comma-separated cycle of
    `accept|release|reject|renew`, applied per record via `(offset %
    pattern.length)` — keyed off the immutable record offset so the
    assignment is stable across redeliveries.
    - When set, the consumer is switched to
    `share.acknowledgement.mode=explicit` and requires
    `--acknowledgement-mode sync|async` (rejects auto at arg-parse time,
    since explicit per-record acks still need a commit to ship).
    - `offsets_acknowledged` events now optionally include an
    `ackTypeCounts` breakdown `({"ACCEPT": n, "RELEASE": n, ...})`, omitted
    entirely when `--ack-pattern` isn't used — no change to the existing
    JSON schema for any current caller.
    
    `verifiable_share_consumer.py` (ducktape service)
    - Plumbs `ack_pattern` through to the CLI, and adds
    `total_accepted()/total_released()/total_rejected()/total_renewed()`
    accessors backed by the new `ackTypeCounts` data.
    
    `kafka.py`
    - `set_share_group_dlq_config(group, topic_name, copy_record_enable)` —
    sets the GroupConfig DLQ configs via kafka-configs.sh.
    - `set_share_group_delivery_count_limit(group, limit)` — sets
    `share.delivery.count.limit` (the per-group override).
    - New share_version constructor param to bootstrap a cluster with
    `--feature share.version=<n>`. Since DLQ (share.version=2) is above
    `ShareVersion.LATEST_PRODUCTION`, this also flips on
    `unstable.feature.versions.enable` in the broker properties.
    
    New: `tests/kafkatest/tests/client/share_consumer_dlq_test.py`
    Three tests on a real 3-broker isolated-KRaft cluster, verifying actual
    DLQ topic content (not just consumer-side counters):
    - `test_single_partition_dlq_reject` — every record REJECTed, DLQ'd
    exactly once, values null (copy-record disabled, the default).
    - `test_single_partition_dlq_release` — every record RELEASEd
    repeatedly; once delivery count exceeds a lowered
    share.delivery.count.limit, it lands in the DLQ.
    - `test_single_partition_dlq_mixed` — records cycled
    reject/release/accept by offset % 3, copy-record enabled; asserts the
    DLQ'd record set and values exactly match the subset of produced values
    that should have been rejected/released (cross-checked against
    VerifiableProducer.acked_values, not just "value is non-null").
    
    All three verified passing end-to-end against a live ducker cluster
    (tests/docker/run_tests.sh).
    
    ```
    > TC_PATHS="tests/kafkatest/tests/client/share_consumer_dlq_test.py"
    bash tests/docker/run_tests.sh
    
    
================================================================================
    SESSION REPORT (ALL TESTS)
    ducktape version: 0.14.0
    session_id:       2026-07-08--005
    run time:         5 minutes 27.339 seconds
    tests run:        6
    passed:           6
    flaky:            0
    failed:           0
    ignored:          0
    
================================================================================
    test_id:
    
kafkatest.tests.client.share_consumer_dlq_test.ShareConsumerDLQTest.test_single_partition_dlq_mixed.metadata_quorum=COMBINED_KRAFT
    status:     PASS
    run time:   45.786 seconds
    
--------------------------------------------------------------------------------
    test_id:
    
kafkatest.tests.client.share_consumer_dlq_test.ShareConsumerDLQTest.test_single_partition_dlq_mixed.metadata_quorum=ISOLATED_KRAFT
    status:     PASS
    run time:   1 minute 5.269 seconds
    
--------------------------------------------------------------------------------
    test_id:
    
kafkatest.tests.client.share_consumer_dlq_test.ShareConsumerDLQTest.test_single_partition_dlq_reject.metadata_quorum=COMBINED_KRAFT
    status:     PASS
    run time:   43.421 seconds
    
--------------------------------------------------------------------------------
    test_id:
    
kafkatest.tests.client.share_consumer_dlq_test.ShareConsumerDLQTest.test_single_partition_dlq_reject.metadata_quorum=ISOLATED_KRAFT
    status:     PASS
    run time:   1 minute 2.872 seconds
    
--------------------------------------------------------------------------------
    test_id:
    
kafkatest.tests.client.share_consumer_dlq_test.ShareConsumerDLQTest.test_single_partition_dlq_release.metadata_quorum=COMBINED_KRAFT
    status:     PASS
    run time:   43.429 seconds
    
--------------------------------------------------------------------------------
    test_id:
    
kafkatest.tests.client.share_consumer_dlq_test.ShareConsumerDLQTest.test_single_partition_dlq_release.metadata_quorum=ISOLATED_KRAFT
    status:     PASS
    run time:   1 minute 5.794 seconds
    
--------------------------------------------------------------------------------
    ```
    
    Reviewers: Sushant Mahajan <[email protected]>, Chia-Ping Tsai
     <[email protected]>, Andrew Schofield <[email protected]>
---
 tests/kafkatest/services/kafka/kafka.py            |  65 ++++++-
 .../services/verifiable_share_consumer.py          |  41 ++++-
 .../tests/client/share_consumer_dlq_test.py        | 202 +++++++++++++++++++++
 .../kafka/tools/VerifiableShareConsumer.java       | 120 +++++++++++-
 4 files changed, 416 insertions(+), 12 deletions(-)

diff --git a/tests/kafkatest/services/kafka/kafka.py 
b/tests/kafkatest/services/kafka/kafka.py
index 8d98b96e283..82193813e96 100644
--- a/tests/kafkatest/services/kafka/kafka.py
+++ b/tests/kafkatest/services/kafka/kafka.py
@@ -207,7 +207,8 @@ class KafkaService(KafkaPathResolverMixin, JmxMixin, 
Service):
                  dynamicRaftQuorum=False,
                  use_transactions_v2=False,
                  use_streams_groups=False,
-                 enable_assignment_batching=None
+                 enable_assignment_batching=None,
+                 share_version=None
                  ):
         """
         :param context: test context
@@ -273,6 +274,7 @@ class KafkaService(KafkaPathResolverMixin, JmxMixin, 
Service):
         :param use_transactions_v2: When true, uses transaction.version=2 
which utilizes the new transaction protocol introduced in KIP-890
         :param use_streams_groups: When true, enables the use of streams 
groups introduced in KIP-1071
         :param enable_assignment_batching: When true, enables assignment 
batching introduced in KIP-1263. If not specified, defaults to True.
+        :param share_version: When set, bootstraps the cluster with --feature 
share.version=<value> (KIP-932/KIP-1191).
         """
 
         self.zk = zk
@@ -288,6 +290,7 @@ class KafkaService(KafkaPathResolverMixin, JmxMixin, 
Service):
 
         self.use_transactions_v2 = use_transactions_v2
         self.use_streams_groups = use_streams_groups
+        self.share_version = share_version
 
         # Set consumer_group_migration_policy based on context and arguments.
         if consumer_group_migration_policy is None:
@@ -361,7 +364,8 @@ class KafkaService(KafkaPathResolverMixin, JmxMixin, 
Service):
                     server_prop_overrides=server_prop_overrides, 
dynamicRaftQuorum=self.dynamicRaftQuorum,
                     use_transactions_v2=self.use_transactions_v2,
                     use_streams_groups=self.use_streams_groups,
-                    enable_assignment_batching=self.enable_assignment_batching
+                    enable_assignment_batching=self.enable_assignment_batching,
+                    share_version=self.share_version
                 )
                 self.controller_quorum = self.isolated_controller_quorum
 
@@ -907,6 +911,12 @@ class KafkaService(KafkaPathResolverMixin, JmxMixin, 
Service):
             else:
                 if get_version(node).supports_feature_command():
                     cmd += " --feature transaction.version=0"
+            if self.share_version is not None:
+                # share.version=2 (KIP-1191 DLQ) declares a bootstrap 
metadata.version of 4.4-IV0,
+                # but that isn't yet a valid --release-version for 
kafka-storage.sh format in this
+                # build (max supported is 4.3-IV0), and 
Feature.validateVersion() does not enforce
+                # the dependency, so the feature can be bootstrapped on its 
own.
+                cmd += " --feature share.version=%s" % self.share_version
             self.logger.info("Running log directory format command...\n%s" % 
cmd)
             node.account.ssh(cmd)
 
@@ -1748,6 +1758,57 @@ class KafkaService(KafkaPathResolverMixin, JmxMixin, 
Service):
                 command_config)
         return "Completed" in self.run_cli_tool(node, cmd)
 
+    def set_share_group_dlq_config(self, group, topic_name=None, 
copy_record_enable=None, node=None, command_config=None):
+        """ Set the DLQ configs (errors.deadletterqueue.topic.name / 
errors.deadletterqueue.copy.record.enable)
+        for the given share group (KIP-1191).
+        """
+        if topic_name is None and copy_record_enable is None:
+            return
+        if node is None:
+            node = self.nodes[0]
+        config_script = self.path.script("kafka-configs.sh", node)
+
+        if command_config is None:
+            command_config = ""
+        else:
+            command_config = "--command-config " + command_config
+
+        configs = []
+        if topic_name is not None:
+            configs.append("errors.deadletterqueue.topic.name=%s" % topic_name)
+        if copy_record_enable is not None:
+            configs.append("errors.deadletterqueue.copy.record.enable=%s" % 
str(copy_record_enable).lower())
+
+        cmd = fix_opts_for_new_jvm(node)
+        cmd += "%s --bootstrap-server %s --group %s --alter --add-config 
\"%s\" %s" % \
+               (config_script,
+                self.bootstrap_servers(self.security_protocol),
+                group,
+                ",".join(configs),
+                command_config)
+        return "Completed" in self.run_cli_tool(node, cmd)
+
+    def set_share_group_delivery_count_limit(self, group, limit, node=None, 
command_config=None):
+        """ Set the share.delivery.count.limit config (GroupConfig, per-group 
override) for the given share group.
+        """
+        if node is None:
+            node = self.nodes[0]
+        config_script = self.path.script("kafka-configs.sh", node)
+
+        if command_config is None:
+            command_config = ""
+        else:
+            command_config = "--command-config " + command_config
+
+        cmd = fix_opts_for_new_jvm(node)
+        cmd += "%s --bootstrap-server %s --group %s --alter --add-config 
\"share.delivery.count.limit=%s\" %s" % \
+               (config_script,
+                self.bootstrap_servers(self.security_protocol),
+                group,
+                limit,
+                command_config)
+        return "Completed" in self.run_cli_tool(node, cmd)
+
     def list_consumer_groups(self, node=None, command_config=None, state=None, 
type=None):
         """ Get list of consumer groups.
         """
diff --git a/tests/kafkatest/services/verifiable_share_consumer.py 
b/tests/kafkatest/services/verifiable_share_consumer.py
index 65f86966237..f061beeb1fe 100644
--- a/tests/kafkatest/services/verifiable_share_consumer.py
+++ b/tests/kafkatest/services/verifiable_share_consumer.py
@@ -36,6 +36,10 @@ class ShareConsumerEventHandler(object):
         self.total_consumed = 0
         self.total_acknowledged_successfully = 0
         self.total_acknowledged_failed = 0
+        self.total_accepted = 0
+        self.total_released = 0
+        self.total_rejected = 0
+        self.total_renewed = 0
         self.consumed_per_partition = {}
         self.acknowledged_per_partition = {}
         self.acknowledged_per_partition_failed = {}
@@ -56,6 +60,12 @@ class ShareConsumerEventHandler(object):
             for share_partition_data in event["partitions"]:
                 topic_partition = 
TopicPartition(share_partition_data["topic"], share_partition_data["partition"])
                 self.acknowledged_per_partition[topic_partition] = 
self.acknowledged_per_partition.get(topic_partition, 0) + 
share_partition_data["count"]
+            ack_type_counts = event.get("ackTypeCounts")
+            if ack_type_counts:
+                self.total_accepted += ack_type_counts.get("ACCEPT", 0)
+                self.total_released += ack_type_counts.get("RELEASE", 0)
+                self.total_rejected += ack_type_counts.get("REJECT", 0)
+                self.total_renewed += ack_type_counts.get("RENEW", 0)
             logger.debug("Offsets acknowledged for %s" % 
(node.account.hostname))
         else:
             self.total_acknowledged_failed += event["count"]
@@ -107,11 +117,16 @@ class VerifiableShareConsumer(KafkaPathResolverMixin, 
VerifiableClientMixin, Bac
                 "collect_default": True}
             }
 
-    def __init__(self, context, num_nodes, kafka, topic, group_id, 
max_messages=-1, 
-                 acknowledgement_mode="auto", version=DEV_BRANCH, 
stop_timeout_sec=60, 
-                 log_level="INFO", jaas_override_variables=None, 
on_record_consumed=None):
+    def __init__(self, context, num_nodes, kafka, topic, group_id, 
max_messages=-1,
+                 acknowledgement_mode="auto", version=DEV_BRANCH, 
stop_timeout_sec=60,
+                 log_level="INFO", jaas_override_variables=None, 
on_record_consumed=None,
+                 ack_pattern=None):
         """
         :param jaas_override_variables: A dict of variables to be used in the 
jaas.conf template file
+        :param ack_pattern: A list of acknowledge types (e.g. ["reject", 
"release", "accept"]) cycled
+            through per record via (offset % len(ack_pattern)). Requires 
acknowledgement_mode to be
+            "sync" or "async". When None/empty (the default), acknowledgement 
stays implicit (unchanged
+            behavior).
         """
         super(VerifiableShareConsumer, self).__init__(context, num_nodes)
         self.log_level = log_level
@@ -120,6 +135,7 @@ class VerifiableShareConsumer(KafkaPathResolverMixin, 
VerifiableClientMixin, Bac
         self.group_id = group_id
         self.max_messages = max_messages
         self.acknowledgement_mode = acknowledgement_mode
+        self.ack_pattern = ack_pattern
         self.prop_file = ""
         self.stop_timeout_sec = stop_timeout_sec
         self.on_record_consumed = on_record_consumed
@@ -221,6 +237,9 @@ class VerifiableShareConsumer(KafkaPathResolverMixin, 
VerifiableClientMixin, Bac
 
         cmd += " --acknowledgement-mode %s" % self.acknowledgement_mode
 
+        if self.ack_pattern:
+            cmd += " --ack-pattern %s" % ",".join(self.ack_pattern)
+
         cmd += " --bootstrap-server %s" % 
self.kafka.bootstrap_servers(self.security_config.security_protocol)
 
         cmd += " --group-id %s --topic %s" % (self.group_id, self.topic)
@@ -291,6 +310,22 @@ class VerifiableShareConsumer(KafkaPathResolverMixin, 
VerifiableClientMixin, Bac
         with self.lock:
             return self.total_records_acknowledged_failed
 
+    def total_accepted(self):
+        with self.lock:
+            return sum(handler.total_accepted for handler in 
self.event_handlers.values())
+
+    def total_released(self):
+        with self.lock:
+            return sum(handler.total_released for handler in 
self.event_handlers.values())
+
+    def total_rejected(self):
+        with self.lock:
+            return sum(handler.total_rejected for handler in 
self.event_handlers.values())
+
+    def total_renewed(self):
+        with self.lock:
+            return sum(handler.total_renewed for handler in 
self.event_handlers.values())
+
     def total_consumed_for_a_share_consumer(self, node):
         with self.lock:
             return self.event_handlers[node].total_consumed
diff --git a/tests/kafkatest/tests/client/share_consumer_dlq_test.py 
b/tests/kafkatest/tests/client/share_consumer_dlq_test.py
new file mode 100644
index 00000000000..76cfe3a45b8
--- /dev/null
+++ b/tests/kafkatest/tests/client/share_consumer_dlq_test.py
@@ -0,0 +1,202 @@
+# 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.
+
+from ducktape.mark import matrix
+from ducktape.mark.resource import cluster
+from ducktape.utils.util import wait_until
+
+from kafkatest.services.kafka import KafkaService, quorum
+from kafkatest.services.verifiable_consumer import VerifiableConsumer
+from kafkatest.tests.verifiable_share_consumer_test import 
VerifiableShareConsumerTest
+
+
+class ShareConsumerDLQTest(VerifiableShareConsumerTest):
+    """System tests for share group dead-letter queues (KIP-1191).
+
+    These tests exercise the real distributed cluster/consumer topology that 
the JUnit
+    integration test suite (ShareConsumerDLQTest, clients-integration-tests) 
cannot: multiple
+    brokers, real consumer processes, and end-to-end DLQ topic content 
verification.
+    """
+
+    TOPIC = {"name": "dlq-source-topic", "partitions": 1, 
"replication_factor": 1}
+
+    num_consumers = 1
+    num_producers = 1
+    num_brokers = 3
+
+    share_group_id = "dlq-test-group"
+    total_messages = 300
+
+    default_timeout_sec = 180
+
+    def __init__(self, test_context):
+        super(ShareConsumerDLQTest, self).__init__(test_context, 
num_consumers=self.num_consumers,
+            num_producers=self.num_producers, num_zk=0, 
num_brokers=self.num_brokers,
+            topics={self.TOPIC["name"]: {"partitions": 
self.TOPIC["partitions"],
+                                          "replication-factor": 
self.TOPIC["replication_factor"]}})
+
+        # DLQ (KIP-1191) is gated behind share.version=2, which is not yet the 
production
+        # default (LATEST_PRODUCTION=SV_1) -- bootstrap the cluster with it 
explicitly. KafkaTest's
+        # constructor already built a throwaway self.kafka above; nothing has 
started yet, but
+        # ducktape allocates cluster nodes at Service.__init__ time (not at 
start()), so the
+        # throwaway service's nodes (and its isolated controller quorum's, if 
any) must be freed
+        # before building the replacement, or the cluster runs out of nodes.
+        self.kafka.free()
+        if self.kafka.isolated_controller_quorum:
+            self.kafka.isolated_controller_quorum.free()
+        self.kafka = KafkaService(test_context, self.num_brokers, self.zk, 
topics=self.topics,
+                                   controller_num_nodes_override=self.num_zk, 
share_version="2")
+
+    def create_dlq_topic(self, name):
+        self.kafka.create_topic({
+            "topic": name,
+            "partitions": 1,
+            "replication-factor": 1,
+            "configs": {"errors.deadletterqueue.group.enable": "true"}
+        })
+
+    def setup_dlq_group_config(self, dlq_topic, copy_record_enable=None):
+        wait_until(lambda: 
self.kafka.set_share_group_offset_reset_strategy(group=self.share_group_id, 
strategy="earliest"),
+                   timeout_sec=20, backoff_sec=2, 
err_msg="share.auto.offset.reset not set to earliest")
+        wait_until(lambda: 
self.kafka.set_share_group_dlq_config(group=self.share_group_id, 
topic_name=dlq_topic,
+                                                                  
copy_record_enable=copy_record_enable),
+                   timeout_sec=20, backoff_sec=2, err_msg="DLQ config not 
applied to share group")
+
+    def read_dlq_topic(self, dlq_topic, min_messages, timeout_sec=None):
+        """Consume dlq_topic with a plain (non-share) VerifiableConsumer and 
return the collected record_data events."""
+        timeout_sec = timeout_sec or self.default_timeout_sec
+        dlq_records = []
+
+        def on_dlq_record(event, node):
+            dlq_records.append(event)
+
+        dlq_consumer = VerifiableConsumer(self.test_context, 1, self.kafka, 
dlq_topic,
+                                           group_id="%s-dlq-verifier" % 
self.share_group_id,
+                                           on_record_consumed=on_dlq_record)
+        dlq_consumer.start()
+        wait_until(lambda: dlq_consumer.total_consumed() >= min_messages, 
timeout_sec=timeout_sec,
+                   err_msg="Timed out waiting to consume %d records from DLQ 
topic %s" % (min_messages, dlq_topic))
+        dlq_consumer.stop_all()
+        return dlq_records
+
+    @cluster(num_nodes=10)
+    @matrix(metadata_quorum=[quorum.isolated_kraft, quorum.combined_kraft])
+    def test_single_partition_dlq_reject(self, 
metadata_quorum=quorum.isolated_kraft):
+        """Every record is REJECTed and must be written to the DLQ topic 
exactly once, headers-only
+        (copy-record left at its default of false)."""
+        dlq_topic = "dlq.reject-single"
+        self.create_dlq_topic(dlq_topic)
+        self.setup_dlq_group_config(dlq_topic)
+
+        producer = self.setup_producer(self.TOPIC["name"], 
max_messages=self.total_messages)
+        consumer = self.setup_share_group(self.TOPIC["name"], 
group_id=self.share_group_id,
+                                           acknowledgement_mode="sync", 
ack_pattern=["reject"])
+
+        producer.start()
+        self.await_produced_messages(producer, 
min_messages=self.total_messages, timeout_sec=self.default_timeout_sec)
+
+        consumer.start()
+        self.await_all_members(consumer, timeout_sec=self.default_timeout_sec)
+
+        wait_until(lambda: consumer.total_rejected() >= self.total_messages, 
timeout_sec=self.default_timeout_sec,
+                   err_msg="Timed out waiting for all records to be rejected")
+
+        producer.stop()
+        consumer.stop_all()
+
+        dlq_records = self.read_dlq_topic(dlq_topic, self.total_messages)
+        assert len(dlq_records) == self.total_messages
+        assert all(record["value"] is None for record in dlq_records), \
+            "Expected DLQ records to carry no value when copy-record is 
disabled"
+
+    @cluster(num_nodes=10)
+    @matrix(metadata_quorum=[quorum.isolated_kraft, quorum.combined_kraft])
+    def test_single_partition_dlq_release(self, 
metadata_quorum=quorum.isolated_kraft):
+        """Every record is RELEASEd repeatedly; once its delivery count 
exceeds the (lowered) limit
+        it must be written to the DLQ topic."""
+        dlq_topic = "dlq.release-single"
+        delivery_count_limit = 2
+        self.create_dlq_topic(dlq_topic)
+        self.setup_dlq_group_config(dlq_topic)
+        wait_until(lambda: 
self.kafka.set_share_group_delivery_count_limit(group=self.share_group_id, 
limit=delivery_count_limit),
+                   timeout_sec=20, backoff_sec=2, 
err_msg="group.share.delivery.count.limit not set")
+
+        producer = self.setup_producer(self.TOPIC["name"], 
max_messages=self.total_messages)
+        consumer = self.setup_share_group(self.TOPIC["name"], 
group_id=self.share_group_id,
+                                           acknowledgement_mode="sync", 
ack_pattern=["release"])
+
+        producer.start()
+        self.await_produced_messages(producer, 
min_messages=self.total_messages, timeout_sec=self.default_timeout_sec)
+
+        consumer.start()
+        self.await_all_members(consumer, timeout_sec=self.default_timeout_sec)
+
+        producer.stop()
+
+        # Assert via DLQ topic content, not consumer-side release counts, 
since RELEASE keeps
+        # redelivering (and re-releasing) the same offsets until the delivery 
count is exceeded.
+        # The consumer must stay running while we wait: it's what drives the 
redelivery cycles
+        # that eventually push each record over the delivery count limit and 
into the DLQ.
+        dlq_records = self.read_dlq_topic(dlq_topic, self.total_messages, 
timeout_sec=self.default_timeout_sec * 2)
+        assert len(dlq_records) == self.total_messages
+
+        consumer.stop_all()
+
+    @cluster(num_nodes=10)
+    @matrix(metadata_quorum=[quorum.isolated_kraft, quorum.combined_kraft])
+    def test_single_partition_dlq_mixed(self, 
metadata_quorum=quorum.isolated_kraft):
+        """Records are cycled reject/release/accept by (offset % 3); 
reject+release offsets must
+        eventually land in the DLQ topic with the original value copied 
(copy-record enabled),
+        while accept offsets never reach the DLQ."""
+        dlq_topic = "dlq.mixed-single"
+        delivery_count_limit = 2
+        self.create_dlq_topic(dlq_topic)
+        self.setup_dlq_group_config(dlq_topic, copy_record_enable=True)
+        wait_until(lambda: 
self.kafka.set_share_group_delivery_count_limit(group=self.share_group_id, 
limit=delivery_count_limit),
+                   timeout_sec=20, backoff_sec=2, 
err_msg="group.share.delivery.count.limit not set")
+
+        producer = self.setup_producer(self.TOPIC["name"], 
max_messages=self.total_messages)
+        consumer = self.setup_share_group(self.TOPIC["name"], 
group_id=self.share_group_id,
+                                           acknowledgement_mode="sync", 
ack_pattern=["reject", "release", "accept"])
+
+        producer.start()
+        self.await_produced_messages(producer, 
min_messages=self.total_messages, timeout_sec=self.default_timeout_sec)
+
+        consumer.start()
+        self.await_all_members(consumer, timeout_sec=self.default_timeout_sec)
+
+        expected_dlq_count = sum(1 for offset in range(self.total_messages) if 
offset % 3 != 2)
+        expected_accepted_count = self.total_messages - expected_dlq_count
+
+        wait_until(lambda: consumer.total_accepted() >= 
expected_accepted_count, timeout_sec=self.default_timeout_sec,
+                   err_msg="Timed out waiting for all accept-pattern records 
to be accepted")
+
+        producer.stop()
+
+        # The consumer must stay running while we wait: released offsets only 
reach the DLQ after
+        # repeated redelivery, which the accepted-count check above does not 
guarantee has finished.
+        dlq_records = self.read_dlq_topic(dlq_topic, expected_dlq_count, 
timeout_sec=self.default_timeout_sec * 2)
+        assert len(dlq_records) == expected_dlq_count
+
+        # With copy-record enabled, verify DLQ records carry the actual 
original values, not just
+        # some non-null value: since offsets map 1:1 to VerifiableProducer's 
sequential int values on
+        # this single-partition topic, cross-check against the real values the 
producer sent rather
+        # than trusting the DLQ content blindly.
+        expected_dlq_values = {value for value in producer.acked_values if 
value % 3 != 2}
+        actual_dlq_values = {int(record["value"]) for record in dlq_records}
+        assert actual_dlq_values == expected_dlq_values, \
+            "DLQ record values did not match the original produced values 
(copy-record enabled)"
+
+        consumer.stop_all()
diff --git 
a/tools/src/main/java/org/apache/kafka/tools/VerifiableShareConsumer.java 
b/tools/src/main/java/org/apache/kafka/tools/VerifiableShareConsumer.java
index 5b897cfb643..da176c3a689 100644
--- a/tools/src/main/java/org/apache/kafka/tools/VerifiableShareConsumer.java
+++ b/tools/src/main/java/org/apache/kafka/tools/VerifiableShareConsumer.java
@@ -21,6 +21,7 @@ import org.apache.kafka.clients.admin.Admin;
 import org.apache.kafka.clients.admin.AlterConfigOp;
 import org.apache.kafka.clients.admin.AlterConfigsOptions;
 import org.apache.kafka.clients.admin.ConfigEntry;
+import org.apache.kafka.clients.consumer.AcknowledgeType;
 import org.apache.kafka.clients.consumer.AcknowledgementCommitCallback;
 import org.apache.kafka.clients.consumer.ConsumerConfig;
 import org.apache.kafka.clients.consumer.ConsumerRecord;
@@ -88,7 +89,8 @@ import static 
net.sourceforge.argparse4j.impl.Arguments.storeTrue;
  *     See {@link VerifiableShareConsumer.OffsetResetStrategySet}.</li>
  * <li>records_consumed: contains a summary of records consumed in a single 
call to {@link KafkaShareConsumer#poll(Duration)}.
  *     See {@link VerifiableShareConsumer.RecordsConsumed}.</li>
- * <li>offsets_acknowledged: contains the result of acknowledging offsets.
+ * <li>offsets_acknowledged: contains the result of acknowledging offsets. 
When {@code --ack-pattern} is
+ *     used, also includes a per-{@link AcknowledgeType} count breakdown.
  *     See {@link VerifiableShareConsumer.OffsetsAcknowledged}.</li>
  * <li>record_data: contains the key, value, and offset of an individual 
consumed record (only included if verbose
  *  *     output is enabled). See {@link 
VerifiableShareConsumer.RecordData}.</li>
@@ -114,6 +116,12 @@ public class VerifiableShareConsumer implements Closeable, 
AcknowledgementCommit
     private Integer totalAcknowledged = 0;
     private final String groupId;
     private final CountDownLatch shutdownLatch = new CountDownLatch(1);
+    private final List<AcknowledgeType> ackPattern;
+    private final Map<TopicPartition, Map<Long, AcknowledgeType>> 
pendingAckTypes = new HashMap<>();
+    // Tracks, per offset, which ackPattern index to apply next. Only advances 
past a RENEW (RENEW
+    // re-delivers the same record), so non-renewed offsets keep resolving to 
their original
+    // (offset % ackPattern.size()) entry, e.g. a RELEASE keeps being 
re-applied on every redelivery.
+    private final Map<TopicPartition, Map<Long, Integer>> ackPatternIndex = 
new HashMap<>();
 
     public static class PartitionData {
         private final String topic;
@@ -261,19 +269,22 @@ public class VerifiableShareConsumer implements 
Closeable, AcknowledgementCommit
         }
     }
 
-    @JsonPropertyOrder({ "timestamp", "name", "count", "partitions", 
"success", "error" })
+    @JsonPropertyOrder({ "timestamp", "name", "count", "partitions", 
"success", "error", "ackTypeCounts" })
     protected static class OffsetsAcknowledged extends ShareConsumerEvent {
 
         private final long count;
         private final List<AcknowledgedData> partitions;
         private final String error;
         private final boolean success;
+        private final Map<String, Long> ackTypeCounts;
 
-        public OffsetsAcknowledged(long count, List<AcknowledgedData> 
partitions, String error, boolean success) {
+        public OffsetsAcknowledged(long count, List<AcknowledgedData> 
partitions, String error, boolean success,
+                                    Map<String, Long> ackTypeCounts) {
             this.count = count;
             this.partitions = partitions;
             this.error = error;
             this.success = success;
+            this.ackTypeCounts = ackTypeCounts;
         }
 
         @Override
@@ -302,6 +313,12 @@ public class VerifiableShareConsumer implements Closeable, 
AcknowledgementCommit
             return success;
         }
 
+        @JsonProperty
+        @JsonInclude(JsonInclude.Include.NON_NULL)
+        public Map<String, Long> ackTypeCounts() {
+            return ackTypeCounts;
+        }
+
     }
 
     @JsonPropertyOrder({ "timestamp", "name", "key", "value", "topic", 
"partition", "offset" })
@@ -353,7 +370,8 @@ public class VerifiableShareConsumer implements Closeable, 
AcknowledgementCommit
                                    AcknowledgementMode acknowledgementMode,
                                    String offsetResetStrategy,
                                    String groupId,
-                                   Boolean verbose) {
+                                   Boolean verbose,
+                                   List<AcknowledgeType> ackPattern) {
         this.out = out;
         this.consumer = consumer;
         this.adminClient = adminClient;
@@ -363,6 +381,7 @@ public class VerifiableShareConsumer implements Closeable, 
AcknowledgementCommit
         this.verbose = verbose;
         this.maxMessages = maxMessages;
         this.groupId = groupId;
+        this.ackPattern = ackPattern;
         addKafkaSerializerModule();
     }
 
@@ -380,7 +399,8 @@ public class VerifiableShareConsumer implements Closeable, 
AcknowledgementCommit
         mapper.registerModule(kafka);
     }
 
-    private void onRecordsReceived(ConsumerRecords<String, String> records) {
+    // package-private for testing
+    void onRecordsReceived(ConsumerRecords<String, String> records) {
         List<RecordSetSummary> summaries = new ArrayList<>();
         for (TopicPartition tp : records.partitions()) {
             List<ConsumerRecord<String, String>> partitionRecords = 
records.records(tp);
@@ -392,6 +412,21 @@ public class VerifiableShareConsumer implements Closeable, 
AcknowledgementCommit
 
             for (ConsumerRecord<String, String> record : partitionRecords) {
                 partitionOffsets.add(record.offset());
+
+                if (!ackPattern.isEmpty()) {
+                    Map<Long, Integer> partitionIndex = 
ackPatternIndex.computeIfAbsent(tp, k -> new HashMap<>());
+                    int index = partitionIndex.computeIfAbsent(record.offset(),
+                        offset -> (int) (offset % ackPattern.size()));
+                    AcknowledgeType type = ackPattern.get(index);
+                    consumer.acknowledge(record, type);
+                    pendingAckTypes.computeIfAbsent(tp, k -> new 
HashMap<>()).put(record.offset(), type);
+                    if (type == AcknowledgeType.RENEW) {
+                        // RENEW re-delivers the same record; advance to the 
next pattern entry so a
+                        // subsequent delivery resolves to a different 
(eventually terminal) type
+                        // instead of renewing the same offset indefinitely.
+                        partitionIndex.put(record.offset(), (index + 1) % 
ackPattern.size());
+                    }
+                }
             }
 
             summaries.add(new RecordSetSummary(tp.topic(), tp.partition(), 
partitionOffsets));
@@ -410,10 +445,38 @@ public class VerifiableShareConsumer implements 
Closeable, AcknowledgementCommit
     public void onComplete(Map<TopicIdPartition, Set<Long>> offsetsMap, 
Exception exception) {
         List<AcknowledgedData> acknowledgedOffsets = new ArrayList<>();
         int totalAcknowledged = 0;
+        Map<String, Long> ackTypeCounts = (ackPattern.isEmpty() || exception 
!= null) ? null : new HashMap<>();
         for (Map.Entry<TopicIdPartition, Set<Long>> offsetEntry : 
offsetsMap.entrySet()) {
             TopicIdPartition tp = offsetEntry.getKey();
             acknowledgedOffsets.add(new AcknowledgedData(tp.topic(), 
tp.partition(), offsetEntry.getValue()));
             totalAcknowledged += offsetEntry.getValue().size();
+
+            if (ackTypeCounts != null) {
+                TopicPartition topicPartition = new TopicPartition(tp.topic(), 
tp.partition());
+                Map<Long, AcknowledgeType> offsetTypes = 
pendingAckTypes.get(topicPartition);
+                if (offsetTypes != null) {
+                    for (long offset : offsetEntry.getValue()) {
+                        AcknowledgeType type = offsetTypes.remove(offset);
+                        if (type != null) {
+                            ackTypeCounts.merge(type.name(), 1L, Long::sum);
+                            if (type != AcknowledgeType.RENEW) {
+                                // Terminal (or 
about-to-be-redelivered-unchanged, e.g. RELEASE) outcome:
+                                // no future delivery of this offset needs an 
advanced pattern index.
+                                Map<Long, Integer> partitionIndex = 
ackPatternIndex.get(topicPartition);
+                                if (partitionIndex != null) {
+                                    partitionIndex.remove(offset);
+                                    if (partitionIndex.isEmpty()) {
+                                        ackPatternIndex.remove(topicPartition);
+                                    }
+                                }
+                            }
+                        }
+                    }
+                    if (offsetTypes.isEmpty()) {
+                        pendingAckTypes.remove(topicPartition);
+                    }
+                }
+            }
         }
 
         boolean success = true;
@@ -422,7 +485,7 @@ public class VerifiableShareConsumer implements Closeable, 
AcknowledgementCommit
             success = false;
             error = exception.getMessage();
         }
-        printJson(new OffsetsAcknowledged(totalAcknowledged, 
acknowledgedOffsets, error, success));
+        printJson(new OffsetsAcknowledged(totalAcknowledged, 
acknowledgedOffsets, error, success, ackTypeCounts));
         if (success) {
             this.totalAcknowledged += totalAcknowledged;
         }
@@ -573,6 +636,20 @@ public class VerifiableShareConsumer implements Closeable, 
AcknowledgementCommit
             .metavar("ACKNOWLEDGEMENT-MODE")
             .help("Acknowledgement mode for the share consumers (must be 
either 'auto', 'sync' or 'async')");
 
+        parser.addArgument("--ack-pattern")
+            .action(store())
+            .required(false)
+            .setDefault("")
+            .type(String.class)
+            .dest("ackPattern")
+            .metavar("ACK-PATTERN")
+            .help("Comma-separated cycle of accept|release|reject|renew 
applied to each consumed record " +
+                "via AcknowledgeType, selected by (record offset % pattern 
length). RENEW re-delivers the " +
+                "same record, so its next delivery advances to the following 
pattern entry instead of " +
+                "renewing again (a pattern of only 'renew' is rejected, since 
it could never resolve). " +
+                "When set, the share consumer uses explicit acknowledgement 
and requires " +
+                "--acknowledgement-mode to be 'sync' or 'async'.");
+
         parser.addArgument("--offset-reset-strategy")
             .action(store())
             .required(false)
@@ -593,6 +670,17 @@ public class VerifiableShareConsumer implements Closeable, 
AcknowledgementCommit
         return parser;
     }
 
+    // package-private for testing
+    static List<AcknowledgeType> parseAckPattern(String ackPatternArg) {
+        List<AcknowledgeType> ackPattern = new ArrayList<>();
+        if (ackPatternArg != null && !ackPatternArg.isEmpty()) {
+            for (String type : ackPatternArg.split(",")) {
+                
ackPattern.add(AcknowledgeType.valueOf(type.trim().toUpperCase(Locale.ROOT)));
+            }
+        }
+        return ackPattern;
+    }
+
     public static VerifiableShareConsumer createFromArgs(ArgumentParser 
parser, String[] args) throws ArgumentParserException {
         Namespace res = parser.parseArgs(args);
 
@@ -602,6 +690,19 @@ public class VerifiableShareConsumer implements Closeable, 
AcknowledgementCommit
         String configFile = res.getString("commandConfig");
         String brokerHostAndPort = res.getString("bootstrapServer");
 
+        List<AcknowledgeType> ackPattern = 
parseAckPattern(res.getString("ackPattern"));
+        if (!ackPattern.isEmpty()) {
+            if (acknowledgementMode == AcknowledgementMode.AUTO) {
+                throw new ArgumentParserException(
+                    "--ack-pattern requires --acknowledgement-mode to be 
'sync' or 'async', not 'auto'", parser);
+            }
+            if (ackPattern.stream().allMatch(type -> type == 
AcknowledgeType.RENEW)) {
+                throw new ArgumentParserException(
+                    "--ack-pattern must contain at least one non-RENEW type; a 
pattern of only 'renew' " +
+                        "would re-deliver every record indefinitely without 
ever resolving it", parser);
+            }
+        }
+
         Properties consumerProps = new Properties();
         if (configFile != null) {
             try {
@@ -617,6 +718,10 @@ public class VerifiableShareConsumer implements Closeable, 
AcknowledgementCommit
 
         consumerProps.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, 
brokerHostAndPort);
 
+        if (!ackPattern.isEmpty()) {
+            
consumerProps.put(ConsumerConfig.SHARE_ACKNOWLEDGEMENT_MODE_CONFIG, "explicit");
+        }
+
         String topic = res.getString("topic");
         int maxMessages = res.getInt("maxMessages");
         boolean verbose = res.getBoolean("verbose");
@@ -645,7 +750,8 @@ public class VerifiableShareConsumer implements Closeable, 
AcknowledgementCommit
             acknowledgementMode,
             offsetResetStrategy,
             groupId,
-            verbose);
+            verbose,
+            ackPattern);
     }
 
     public static void main(String[] args) {


Reply via email to