@@ -48,10 +48,14 @@ def _assert_no_codec_warnings(case: unittest.TestCase, fn) -> None:
4848
4949
5050class FakeMessage :
51- def __init__ (self , value , headers = None , err = None ) -> None :
51+ def __init__ (self , value , headers = None , err = None , key = None ) -> None :
5252 self ._value = value
5353 self ._headers = headers or []
5454 self ._err = err
55+ self ._key = key
56+
57+ def key (self ):
58+ return self ._key
5559
5660 def value (self ):
5761 return self ._value
@@ -72,7 +76,15 @@ def __init__(self) -> None:
7276 self ._reports = []
7377
7478 def produce (self , topic , value = None , headers = None , timestamp = None , on_delivery = None , ** kw ) -> None :
75- self .produced .append ({"topic" : topic , "value" : value , "headers" : headers , "timestamp" : timestamp })
79+ self .produced .append (
80+ {
81+ "topic" : topic ,
82+ "value" : value ,
83+ "headers" : headers ,
84+ "timestamp" : timestamp ,
85+ "key" : kw .get ("key" ),
86+ }
87+ )
7688 if on_delivery is not None :
7789 self ._reports .append (on_delivery )
7890
@@ -104,8 +116,8 @@ def close(self) -> None:
104116class OffsetMessage (FakeMessage ):
105117 """A record that knows its place in a single-partition log."""
106118
107- def __init__ (self , value , headers , offset , topic = "orders" , partition = 0 ) -> None :
108- super ().__init__ (value , headers = headers )
119+ def __init__ (self , value , headers , offset , topic = "orders" , partition = 0 , key = None ) -> None :
120+ super ().__init__ (value , headers = headers , key = key )
109121 self ._offset , self ._topic , self ._partition = offset , topic , partition
110122
111123 def topic (self ):
@@ -638,7 +650,7 @@ def __init__(self, outcomes=(), stray=0) -> None:
638650 self ._queued = []
639651
640652 def produce (self , topic , value = None , headers = None , timestamp = None , on_delivery = None , ** kw ) -> None :
641- super ().produce (topic , value = value , headers = headers , timestamp = timestamp )
653+ super ().produce (topic , value = value , headers = headers , timestamp = timestamp , ** kw )
642654 outcome = self .outcomes .pop (0 ) if self .outcomes else "ok"
643655 if on_delivery is not None :
644656 self .confirmed .append (topic )
@@ -864,6 +876,104 @@ def test_non_utf8_attempts_header_does_not_raise(self):
864876 self .assertEqual (EnvelopeCodec .decode (message .body )["attempts" ], 2 ) # the body's own count
865877
866878
879+ class KafkaKeyPreservationTest (unittest .TestCase ):
880+ """The consumed record's key is carried, byte-for-byte, onto every record the runtime itself
881+ writes for it (retry republish, envelope DLQ, gate DLQ, raw non-UTF-8 DLQ). Null stays null,
882+ empty stays empty, and no key is ever made up (§6)."""
883+
884+ KEYS = (b"order-1042" , b"" , None )
885+
886+ def _run (self , record_value , key , headers = (), ** app_kw ):
887+ consumer = OffsetConsumer ([OffsetMessage (record_value , list (headers ), offset = 0 , key = key )])
888+ producer = ReportingProducer ()
889+ app = BabelQueue (transport = transport (producer , consumer ), ** app_kw )
890+ app ._rewind_pause = 0.0 # noqa: SLF001
891+
892+ @app .handler (URN )
893+ def _on (data , meta ):
894+ raise RuntimeError ("handler failure" )
895+
896+ app .consume (queue = "orders" , max_messages = 1 )
897+ self .assertEqual (consumer .commits , [0 ])
898+ self .assertEqual (len (producer .produced ), 1 )
899+ return producer .produced [0 ]
900+
901+ def test_pop_surfaces_the_record_key (self ):
902+ for key in self .KEYS :
903+ with self .subTest (key = key ):
904+ got = transport (consumer = FakeConsumer ([FakeMessage (body ().encode (), key = key )])).pop ("orders" )
905+ self .assertEqual (got .key , key )
906+
907+ def test_pop_tolerates_a_record_without_a_key_accessor (self ):
908+ class NoKey :
909+ def value (self ):
910+ return body ().encode ()
911+
912+ def headers (self ):
913+ return []
914+
915+ def error (self ):
916+ return None
917+
918+ self .assertIsNone (transport (consumer = FakeConsumer ([NoKey ()])).pop ("orders" ).key )
919+
920+ def test_retry_republish_keeps_the_key (self ):
921+ for key in self .KEYS :
922+ with self .subTest (key = key ):
923+ record = self ._run (body ().encode (), key , max_attempts = 3 )
924+ self .assertEqual (record ["topic" ], "orders" )
925+ self .assertEqual (record ["key" ], key )
926+
927+ def test_envelope_dead_letter_keeps_the_key (self ):
928+ for key in self .KEYS :
929+ with self .subTest (key = key ):
930+ record = self ._run (body ().encode (), key , max_attempts = 1 , dead_letter = True )
931+ self .assertEqual (record ["topic" ], "orders.dlq" )
932+ self .assertEqual (record ["key" ], key )
933+
934+ def test_gated_dead_letter_keeps_the_key (self ):
935+ for key in self .KEYS :
936+ with self .subTest (key = key ):
937+ record = self ._run (
938+ body ().encode (), key , headers = [("bq-schema-version" , b"2" )], dead_letter = True
939+ )
940+ self .assertEqual (record ["topic" ], "orders.dlq" )
941+ self .assertEqual (record ["key" ], key )
942+
943+ def test_non_utf8_raw_dead_letter_keeps_the_key (self ):
944+ bad = b"\xff \xfe not utf-8 \x80 "
945+ for key in self .KEYS :
946+ with self .subTest (key = key ):
947+ with self .assertLogs ("babelqueue.kafka" , level = "WARNING" ):
948+ record = self ._run (bad , key , dead_letter = True )
949+ self .assertEqual (record ["topic" ], "orders.dlq" )
950+ self .assertEqual (record ["value" ], bad )
951+ self .assertEqual (record ["key" ], key )
952+
953+ def test_a_null_key_is_not_passed_to_produce (self ):
954+ class StrictProducer (FakeProducer ):
955+ def produce (self , topic , value = None , headers = None , timestamp = None , on_delivery = None , ** kw ):
956+ assert "key" not in kw , "a null key must not be synthesised"
957+ super ().produce (topic , value = value , headers = headers , timestamp = timestamp , on_delivery = on_delivery )
958+
959+ transport (producer = StrictProducer ()).publish_settle ("orders" , body (), key = None )
960+
961+ def test_plain_publish_does_not_set_a_key (self ):
962+ producer = FakeProducer ()
963+ transport (producer = producer ).publish ("orders" , body ())
964+ self .assertIsNone (producer .produced [0 ]["key" ])
965+
966+ def test_redrive_restore_keeps_the_key (self ):
967+ from babelqueue import redrive
968+
969+ consumer = FakeConsumer ([FakeMessage (b"not json at all" , key = b"k-7" )])
970+ producer = FakeProducer ()
971+ result = redrive .redrive (transport (producer , consumer ), "orders.dlq" , timeout = 0 )
972+ self .assertEqual (result .skipped , 1 )
973+ self .assertEqual (producer .produced [0 ]["topic" ], "orders.dlq" )
974+ self .assertEqual (producer .produced [0 ]["key" ], b"k-7" )
975+
976+
867977class KafkaSchemeTest (unittest .TestCase ):
868978 def test_make_transport_routes_kafka_scheme_lazily (self ):
869979 # Construction is lazy (no client built), so it succeeds with no confluent-kafka.
0 commit comments