Skip to content

Commit 7e368e8

Browse files
authored
KAFKA-20772 (2/n): Add multi-partition and multi-topic DLQ system tests (#22851)
### Summary Extends the share-group DLQ system test suite (`ShareConsumerDLQTest`, added in the prior PR covering single-partition scenarios) to cover two more topologies from the KAFKA-20772 test plan: - Multi-partition (single topic, 3 partitions): `test_multi_partition_dlq_reject` (parametrized over `num_dlq_partitions=[3, 1]` to cover both 1:1 and modulus-collapsing DLQ routing), `test_multi_partition_dlq_release`, `test_multi_partition_dlq_mixed`. - Multi-topic (two source topics sharing one share group and one DLQ topic): `test_multi_topic_dlq_reject`, `test_multi_topic_dlq_release`. A single `VerifiableShareConsumer` member subscribes to both topics at once via a comma-separated `--topic` list (new capability added here). DLQ records are asserted to carry the correct `__dlq.errors.topic` header and route to `source_partition % num_dlq_partitions` per topic. ### Changes - Multi-topic membership: two independent VerifiableShareConsumer members join the same share group, each subscribed to just one source topic, instead of one member subscribed to both. - Routing/attribution verification: instead of reading per-record __dlq.errors.* headers, routing correctness is verified from the observable per-DLQ-partition record counts (via the existing kafka.get_offset_shell helper), computed from producer.acked_values_by_partition and the source_partition % num_dlq_partitions rule. Reviewers: Sushant Mahajan <smahajan@confluent.io>, Andrew Schofield <aschofield@confluent.io>
1 parent 3408aa3 commit 7e368e8

1 file changed

Lines changed: 285 additions & 6 deletions

File tree

tests/kafkatest/tests/client/share_consumer_dlq_test.py

Lines changed: 285 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -31,21 +31,28 @@ class ShareConsumerDLQTest(VerifiableShareConsumerTest):
3131
"""
3232

3333
TOPIC = {"name": "dlq-source-topic", "partitions": 1, "replication_factor": 1}
34+
TOPIC_MULTI = {"name": "dlq-source-multi", "partitions": 3, "replication_factor": 3}
35+
TOPIC_MULTI_A = {"name": "dlq-source-multi-a", "partitions": 3, "replication_factor": 3}
36+
TOPIC_MULTI_B = {"name": "dlq-source-multi-b", "partitions": 2, "replication_factor": 3}
3437

3538
num_consumers = 1
3639
num_producers = 1
3740
num_brokers = 3
3841

3942
share_group_id = "dlq-test-group"
40-
total_messages = 300
43+
total_messages = 50
4144

4245
default_timeout_sec = 180
4346

4447
def __init__(self, test_context):
48+
topics = {}
49+
for topic in (self.TOPIC, self.TOPIC_MULTI, self.TOPIC_MULTI_A, self.TOPIC_MULTI_B):
50+
topics[topic["name"]] = {"partitions": topic["partitions"],
51+
"replication-factor": topic["replication_factor"]}
52+
4553
super(ShareConsumerDLQTest, self).__init__(test_context, num_consumers=self.num_consumers,
4654
num_producers=self.num_producers, num_zk=0, num_brokers=self.num_brokers,
47-
topics={self.TOPIC["name"]: {"partitions": self.TOPIC["partitions"],
48-
"replication-factor": self.TOPIC["replication_factor"]}})
55+
topics=topics)
4956

5057
# DLQ (KIP-1191) is gated behind share.version=2, which is not yet the production
5158
# default (LATEST_PRODUCTION=SV_1) -- bootstrap the cluster with it explicitly. KafkaTest's
@@ -59,14 +66,75 @@ def __init__(self, test_context):
5966
self.kafka = KafkaService(test_context, self.num_brokers, self.zk, topics=self.topics,
6067
controller_num_nodes_override=self.num_zk, share_version="2")
6168

62-
def create_dlq_topic(self, name):
69+
def create_dlq_topic(self, name, partitions=1, replication_factor=1):
6370
self.kafka.create_topic({
6471
"topic": name,
65-
"partitions": 1,
66-
"replication-factor": 1,
72+
"partitions": partitions,
73+
"replication-factor": replication_factor,
6774
"configs": {"errors.deadletterqueue.group.enable": "true"}
6875
})
6976

77+
def dlq_partition_counts(self, dlq_topic):
78+
"""Return {partition: record_count} for dlq_topic, from each partition's latest offset
79+
(these DLQ topics are freshly created for each test, so offset == record count).
80+
"""
81+
counts = {}
82+
for line in self.kafka.get_offset_shell(topic=dlq_topic, time="latest").strip().split("\n"):
83+
if not line:
84+
continue
85+
_, partition, offset = line.rsplit(":", 2)
86+
counts[int(partition)] = int(offset)
87+
return counts
88+
89+
def expected_dlq_partition_counts(self, dlq_counts_by_source_partition, num_dlq_partitions):
90+
"""Compute the expected number of DLQ records per DLQ partition from the number of records
91+
actually DLQ'd on each source partition and the source_partition % num_dlq_partitions
92+
routing rule.
93+
"""
94+
counts = {}
95+
for source_partition, dlq_count in dlq_counts_by_source_partition.items():
96+
dlq_partition = source_partition % num_dlq_partitions
97+
counts[dlq_partition] = counts.get(dlq_partition, 0) + dlq_count
98+
return counts
99+
100+
def assert_dlq_partition_counts(self, dlq_topic, expected_counts_by_dlq_partition):
101+
"""Assert the number of records that actually landed in each DLQ partition matches
102+
expected_counts_by_dlq_partition, validating source_partition % num_dlq_partitions routing
103+
purely from observable per-partition record counts (no per-record header access needed).
104+
"""
105+
actual_counts = self.dlq_partition_counts(dlq_topic)
106+
for partition in set(expected_counts_by_dlq_partition) | set(actual_counts):
107+
expected = expected_counts_by_dlq_partition.get(partition, 0)
108+
actual = actual_counts.get(partition, 0)
109+
assert actual == expected, \
110+
"DLQ topic %s partition %d has %d records, expected %d" % \
111+
(dlq_topic, partition, actual, expected)
112+
113+
def merge_partition_counts(self, *count_dicts):
114+
"""Sum multiple {partition: count} dicts (e.g. one per source topic sharing a DLQ topic)
115+
into a single combined {partition: count} dict."""
116+
merged = {}
117+
for counts in count_dicts:
118+
for partition, count in counts.items():
119+
merged[partition] = merged.get(partition, 0) + count
120+
return merged
121+
122+
def expected_mixed_dlq_outcome(self, producer):
123+
"""For a producer whose consumer uses the reject/release/accept-cycling ack pattern, compute
124+
the expected DLQ'd value set and accepted count from the producer's actual per-partition ack
125+
order: (offset % 3) is evaluated per-partition, not globally, since each partition has its
126+
own independent offset sequence starting at 0.
127+
"""
128+
dlq_values = set()
129+
accepted_count = 0
130+
for values in producer.acked_values_by_partition.values():
131+
for offset, value in enumerate(values):
132+
if offset % 3 != 2:
133+
dlq_values.add(value)
134+
else:
135+
accepted_count += 1
136+
return dlq_values, accepted_count
137+
70138
def setup_dlq_group_config(self, dlq_topic, copy_record_enable=None):
71139
wait_until(lambda: self.kafka.set_share_group_offset_reset_strategy(group=self.share_group_id, strategy="earliest"),
72140
timeout_sec=20, backoff_sec=2, err_msg="share.auto.offset.reset not set to earliest")
@@ -200,3 +268,214 @@ def test_single_partition_dlq_mixed(self, metadata_quorum=quorum.isolated_kraft)
200268
"DLQ record values did not match the original produced values (copy-record enabled)"
201269

202270
consumer.stop_all()
271+
272+
@cluster(num_nodes=10)
273+
@matrix(metadata_quorum=[quorum.isolated_kraft, quorum.combined_kraft], num_dlq_partitions=[3, 1])
274+
def test_multi_partition_dlq_reject(self, metadata_quorum=quorum.isolated_kraft, num_dlq_partitions=3):
275+
"""Every record on a 3-partition source topic is REJECTed. Verifies both DLQ topic content
276+
and per-record routing (source_partition % num_dlq_partitions) -- num_dlq_partitions=3 covers
277+
1:1 routing, num_dlq_partitions=1 covers the modulus-collapsing case."""
278+
dlq_topic = "dlq.reject-multi-partition-%d" % num_dlq_partitions
279+
self.create_dlq_topic(dlq_topic, partitions=num_dlq_partitions, replication_factor=3)
280+
self.setup_dlq_group_config(dlq_topic)
281+
282+
producer = self.setup_producer(self.TOPIC_MULTI["name"], max_messages=self.total_messages)
283+
consumer = self.setup_share_group(self.TOPIC_MULTI["name"], group_id=self.share_group_id,
284+
acknowledgement_mode="sync", ack_pattern=["reject"])
285+
286+
producer.start()
287+
self.await_produced_messages(producer, min_messages=self.total_messages, timeout_sec=self.default_timeout_sec)
288+
289+
consumer.start()
290+
self.await_all_members(consumer, timeout_sec=self.default_timeout_sec)
291+
292+
wait_until(lambda: consumer.total_rejected() >= self.total_messages, timeout_sec=self.default_timeout_sec,
293+
err_msg="Timed out waiting for all records to be rejected")
294+
295+
producer.stop()
296+
consumer.stop_all()
297+
298+
dlq_records = self.read_dlq_topic(dlq_topic, self.total_messages)
299+
assert len(dlq_records) == self.total_messages
300+
301+
dlq_counts_by_source_partition = {tp.partition: len(values) for tp, values in producer.acked_values_by_partition.items()}
302+
expected_counts = self.expected_dlq_partition_counts(dlq_counts_by_source_partition, num_dlq_partitions)
303+
self.assert_dlq_partition_counts(dlq_topic, expected_counts)
304+
305+
@cluster(num_nodes=10)
306+
@matrix(metadata_quorum=[quorum.isolated_kraft, quorum.combined_kraft])
307+
def test_multi_partition_dlq_release(self, metadata_quorum=quorum.isolated_kraft):
308+
"""Same as test_single_partition_dlq_release but on a 3-partition source topic."""
309+
dlq_topic = "dlq.release-multi-partition"
310+
num_dlq_partitions = 3
311+
delivery_count_limit = 2
312+
self.create_dlq_topic(dlq_topic, partitions=num_dlq_partitions, replication_factor=3)
313+
self.setup_dlq_group_config(dlq_topic)
314+
wait_until(lambda: self.kafka.set_share_group_delivery_count_limit(group=self.share_group_id, limit=delivery_count_limit),
315+
timeout_sec=20, backoff_sec=2, err_msg="share.delivery.count.limit not set")
316+
317+
producer = self.setup_producer(self.TOPIC_MULTI["name"], max_messages=self.total_messages)
318+
consumer = self.setup_share_group(self.TOPIC_MULTI["name"], group_id=self.share_group_id,
319+
acknowledgement_mode="sync", ack_pattern=["release"])
320+
321+
producer.start()
322+
self.await_produced_messages(producer, min_messages=self.total_messages, timeout_sec=self.default_timeout_sec)
323+
324+
consumer.start()
325+
self.await_all_members(consumer, timeout_sec=self.default_timeout_sec)
326+
327+
producer.stop()
328+
329+
dlq_records = self.read_dlq_topic(dlq_topic, self.total_messages, timeout_sec=self.default_timeout_sec * 2)
330+
assert len(dlq_records) == self.total_messages
331+
332+
dlq_counts_by_source_partition = {tp.partition: len(values) for tp, values in producer.acked_values_by_partition.items()}
333+
expected_counts = self.expected_dlq_partition_counts(dlq_counts_by_source_partition, num_dlq_partitions)
334+
self.assert_dlq_partition_counts(dlq_topic, expected_counts)
335+
336+
consumer.stop_all()
337+
338+
@cluster(num_nodes=10)
339+
@matrix(metadata_quorum=[quorum.isolated_kraft, quorum.combined_kraft])
340+
def test_multi_partition_dlq_mixed(self, metadata_quorum=quorum.isolated_kraft):
341+
"""Same as test_single_partition_dlq_mixed but on a 3-partition source topic. The expected
342+
DLQ set/count is computed per-partition from the producer's actual per-partition ack order
343+
(offset % 3 is evaluated per-partition, not globally, since each partition has its own
344+
independent offset sequence starting at 0)."""
345+
dlq_topic = "dlq.mixed-multi-partition"
346+
num_dlq_partitions = 3
347+
delivery_count_limit = 2
348+
self.create_dlq_topic(dlq_topic, partitions=num_dlq_partitions, replication_factor=3)
349+
self.setup_dlq_group_config(dlq_topic, copy_record_enable=True)
350+
wait_until(lambda: self.kafka.set_share_group_delivery_count_limit(group=self.share_group_id, limit=delivery_count_limit),
351+
timeout_sec=20, backoff_sec=2, err_msg="share.delivery.count.limit not set")
352+
353+
producer = self.setup_producer(self.TOPIC_MULTI["name"], max_messages=self.total_messages)
354+
consumer = self.setup_share_group(self.TOPIC_MULTI["name"], group_id=self.share_group_id,
355+
acknowledgement_mode="sync", ack_pattern=["reject", "release", "accept"])
356+
357+
producer.start()
358+
self.await_produced_messages(producer, min_messages=self.total_messages, timeout_sec=self.default_timeout_sec)
359+
360+
consumer.start()
361+
self.await_all_members(consumer, timeout_sec=self.default_timeout_sec)
362+
363+
expected_dlq_values, expected_accepted_count = self.expected_mixed_dlq_outcome(producer)
364+
365+
wait_until(lambda: consumer.total_accepted() >= expected_accepted_count, timeout_sec=self.default_timeout_sec,
366+
err_msg="Timed out waiting for all accept-pattern records to be accepted")
367+
368+
producer.stop()
369+
370+
dlq_records = self.read_dlq_topic(dlq_topic, len(expected_dlq_values), timeout_sec=self.default_timeout_sec * 2)
371+
assert len(dlq_records) == len(expected_dlq_values)
372+
373+
actual_dlq_values = {int(record["value"]) for record in dlq_records}
374+
assert actual_dlq_values == expected_dlq_values, \
375+
"DLQ record values did not match the original produced values (copy-record enabled)"
376+
377+
dlq_counts_by_source_partition = {
378+
tp.partition: sum(1 for offset in range(len(values)) if offset % 3 != 2)
379+
for tp, values in producer.acked_values_by_partition.items()
380+
}
381+
expected_counts = self.expected_dlq_partition_counts(dlq_counts_by_source_partition, num_dlq_partitions)
382+
self.assert_dlq_partition_counts(dlq_topic, expected_counts)
383+
384+
consumer.stop_all()
385+
386+
@cluster(num_nodes=11)
387+
@matrix(metadata_quorum=[quorum.isolated_kraft, quorum.combined_kraft])
388+
def test_multi_topic_dlq_reject(self, metadata_quorum=quorum.isolated_kraft):
389+
"""Two source topics share one share group and one DLQ topic; every record on both is
390+
REJECTed. Two separate VerifiableShareConsumer members join the group, each subscribed to
391+
just one of the two topics. Verifies DLQ content is correctly attributed to its source
392+
topic via the __dlq.errors.topic header."""
393+
dlq_topic = "dlq.reject-multi-topic"
394+
num_dlq_partitions = 3
395+
self.create_dlq_topic(dlq_topic, partitions=num_dlq_partitions, replication_factor=3)
396+
self.setup_dlq_group_config(dlq_topic)
397+
398+
producer_a = self.setup_producer(self.TOPIC_MULTI_A["name"], max_messages=self.total_messages)
399+
producer_b = self.setup_producer(self.TOPIC_MULTI_B["name"], max_messages=self.total_messages)
400+
consumer_a = self.setup_share_group(self.TOPIC_MULTI_A["name"], group_id=self.share_group_id,
401+
acknowledgement_mode="sync", ack_pattern=["reject"])
402+
consumer_b = self.setup_share_group(self.TOPIC_MULTI_B["name"], group_id=self.share_group_id,
403+
acknowledgement_mode="sync", ack_pattern=["reject"])
404+
405+
producer_a.start()
406+
producer_b.start()
407+
self.await_produced_messages(producer_a, min_messages=self.total_messages, timeout_sec=self.default_timeout_sec)
408+
self.await_produced_messages(producer_b, min_messages=self.total_messages, timeout_sec=self.default_timeout_sec)
409+
410+
consumer_a.start()
411+
consumer_b.start()
412+
self.await_all_members(consumer_a, timeout_sec=self.default_timeout_sec)
413+
self.await_all_members(consumer_b, timeout_sec=self.default_timeout_sec)
414+
415+
wait_until(lambda: consumer_a.total_rejected() >= self.total_messages,
416+
timeout_sec=self.default_timeout_sec, err_msg="Timed out waiting for all records on topic A to be rejected")
417+
wait_until(lambda: consumer_b.total_rejected() >= self.total_messages,
418+
timeout_sec=self.default_timeout_sec, err_msg="Timed out waiting for all records on topic B to be rejected")
419+
420+
producer_a.stop()
421+
producer_b.stop()
422+
consumer_a.stop_all()
423+
consumer_b.stop_all()
424+
425+
dlq_records = self.read_dlq_topic(dlq_topic, 2 * self.total_messages)
426+
assert len(dlq_records) == 2 * self.total_messages
427+
428+
counts_a = {tp.partition: len(values) for tp, values in producer_a.acked_values_by_partition.items()}
429+
counts_b = {tp.partition: len(values) for tp, values in producer_b.acked_values_by_partition.items()}
430+
expected_counts = self.merge_partition_counts(
431+
self.expected_dlq_partition_counts(counts_a, num_dlq_partitions),
432+
self.expected_dlq_partition_counts(counts_b, num_dlq_partitions))
433+
self.assert_dlq_partition_counts(dlq_topic, expected_counts)
434+
435+
@cluster(num_nodes=11)
436+
@matrix(metadata_quorum=[quorum.isolated_kraft, quorum.combined_kraft])
437+
def test_multi_topic_dlq_release(self, metadata_quorum=quorum.isolated_kraft):
438+
"""Same as test_multi_topic_dlq_reject but with every record RELEASEd repeatedly on both
439+
topics until it exceeds the (lowered) delivery count limit."""
440+
dlq_topic = "dlq.release-multi-topic"
441+
num_dlq_partitions = 3
442+
delivery_count_limit = 2
443+
self.create_dlq_topic(dlq_topic, partitions=num_dlq_partitions, replication_factor=3)
444+
self.setup_dlq_group_config(dlq_topic)
445+
wait_until(lambda: self.kafka.set_share_group_delivery_count_limit(group=self.share_group_id, limit=delivery_count_limit),
446+
timeout_sec=20, backoff_sec=2, err_msg="share.delivery.count.limit not set")
447+
448+
producer_a = self.setup_producer(self.TOPIC_MULTI_A["name"], max_messages=self.total_messages)
449+
producer_b = self.setup_producer(self.TOPIC_MULTI_B["name"], max_messages=self.total_messages)
450+
consumer_a = self.setup_share_group(self.TOPIC_MULTI_A["name"], group_id=self.share_group_id,
451+
acknowledgement_mode="sync", ack_pattern=["release"])
452+
consumer_b = self.setup_share_group(self.TOPIC_MULTI_B["name"], group_id=self.share_group_id,
453+
acknowledgement_mode="sync", ack_pattern=["release"])
454+
455+
producer_a.start()
456+
producer_b.start()
457+
self.await_produced_messages(producer_a, min_messages=self.total_messages, timeout_sec=self.default_timeout_sec)
458+
self.await_produced_messages(producer_b, min_messages=self.total_messages, timeout_sec=self.default_timeout_sec)
459+
460+
consumer_a.start()
461+
consumer_b.start()
462+
self.await_all_members(consumer_a, timeout_sec=self.default_timeout_sec)
463+
self.await_all_members(consumer_b, timeout_sec=self.default_timeout_sec)
464+
465+
producer_a.stop()
466+
producer_b.stop()
467+
468+
# Both consumers must stay running while we wait: it's what drives the redelivery cycles
469+
# that eventually push each record over the delivery count limit and into the DLQ.
470+
dlq_records = self.read_dlq_topic(dlq_topic, 2 * self.total_messages, timeout_sec=self.default_timeout_sec * 2)
471+
assert len(dlq_records) == 2 * self.total_messages
472+
473+
counts_a = {tp.partition: len(values) for tp, values in producer_a.acked_values_by_partition.items()}
474+
counts_b = {tp.partition: len(values) for tp, values in producer_b.acked_values_by_partition.items()}
475+
expected_counts = self.merge_partition_counts(
476+
self.expected_dlq_partition_counts(counts_a, num_dlq_partitions),
477+
self.expected_dlq_partition_counts(counts_b, num_dlq_partitions))
478+
self.assert_dlq_partition_counts(dlq_topic, expected_counts)
479+
480+
consumer_a.stop_all()
481+
consumer_b.stop_all()

0 commit comments

Comments
 (0)