@@ -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