Skip to content

Commit 4040eec

Browse files
[fix][fn] Honour negativeAckRedeliveryDelayMs in the Python function runtime (#26413)
1 parent 8a6f25b commit 4040eec

2 files changed

Lines changed: 61 additions & 1 deletion

File tree

pulsar-functions/instance/src/main/python/python_instance.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,8 @@ def run(self):
150150
elif self.instance_config.function_details.retainKeyOrdering:
151151
mode = pulsar._pulsar.ConsumerType.KeyShared
152152

153+
nack_args = self.get_negative_ack_args()
154+
153155
position = pulsar._pulsar.InitialPosition.Latest
154156
if self.instance_config.function_details.source.subscriptionPosition == Function_pb2.SubscriptionPosition.Value("EARLIEST"):
155157
position = pulsar._pulsar.InitialPosition.Earliest
@@ -181,7 +183,8 @@ def run(self):
181183
message_listener=partial(self.message_listener, self.input_serdes[topic], DEFAULT_SCHEMA),
182184
unacked_messages_timeout_ms=int(self.timeout_ms) if self.timeout_ms else None,
183185
initial_position=position,
184-
properties=properties
186+
properties=properties,
187+
**nack_args
185188
)
186189

187190
for topic, consumer_conf in self.instance_config.function_details.source.inputSpecs.items():
@@ -207,6 +210,7 @@ def run(self):
207210
"properties": properties,
208211
"crypto_key_reader": crypto_key_reader
209212
}
213+
consumer_args.update(nack_args)
210214
if consumer_conf.HasField("receiverQueueSize"):
211215
consumer_args["receiver_queue_size"] = consumer_conf.receiverQueueSize.value
212216

@@ -578,6 +582,26 @@ def get_record_class(self, class_name):
578582
except:
579583
pass
580584
return record_kclass
585+
def get_negative_ack_args(self):
586+
"""Build the negative-ack redelivery delay argument for Client.subscribe().
587+
588+
Returns a dict to splat into the subscribe() call: either empty, or carrying
589+
negative_ack_redelivery_delay_ms.
590+
591+
SourceSpec.negativeAckRedeliveryDelayMs is a proto3 scalar with no presence, so an unset field
592+
reads as 0. Only a positive value is forwarded, leaving the client default (60s) in place
593+
otherwise - the same guard the Java runtime applies in JavaInstanceRunnable.
594+
595+
The argument is omitted rather than passed as None because subscribe() validates it with
596+
_check_type(int, ...) rather than _check_type_or_none, so None would fail for every function
597+
that does not configure it.
598+
"""
599+
delay_ms = self.instance_config.function_details.source.negativeAckRedeliveryDelayMs
600+
if delay_ms <= 0:
601+
return {}
602+
603+
return {"negative_ack_redelivery_delay_ms": delay_ms}
604+
581605
def get_crypto_reader(self, crypto_spec):
582606
crypto_key_reader = None
583607
if crypto_spec is not None:

pulsar-functions/instance/src/test/python/test_python_instance.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -360,3 +360,39 @@ def test_batch_builder_reaches_the_producer(self):
360360
function_details.sink.producerSpec.batchBuilder = "KEY_BASED"
361361
kwargs = self._create_producer_kwargs(function_details)
362362
self.assertEqual(kwargs["batching_type"], pulsar.BatchingType.KeyBased)
363+
364+
class TestNegativeAckRedeliveryDelay(unittest.TestCase):
365+
"""Covers SourceSpec.negativeAckRedeliveryDelayMs reaching the consumer.
366+
367+
The runtime negatively acknowledges on failure but never configured the delay, so the client
368+
default of 60s always applied. The Java runtime guards on > 0 in JavaInstanceRunnable.
369+
"""
370+
371+
def _instance(self, delay_ms=None):
372+
function_details = Function_pb2.FunctionDetails()
373+
function_details.sink.topic = "test_sink_topic"
374+
if delay_ms is not None:
375+
function_details.source.negativeAckRedeliveryDelayMs = delay_ms
376+
377+
return PythonInstance('test_instance', 'test_func', '1.0', function_details, 100, 30,
378+
'user_code', Mock(), Mock(), 'test_cluster', 'test_url', None)
379+
380+
def test_positive_delay_is_forwarded(self):
381+
args = self._instance(delay_ms=5000).get_negative_ack_args()
382+
self.assertEqual({"negative_ack_redelivery_delay_ms": 5000}, args)
383+
384+
def test_unset_delay_is_omitted(self):
385+
# proto3 scalar with no presence: unset reads as 0. The argument must be omitted rather than
386+
# sent - subscribe() validates it with _check_type(int), so None would fail for every function
387+
# that does not set it, and 0 would mean immediate redelivery instead of the 60s default.
388+
self.assertEqual({}, self._instance().get_negative_ack_args())
389+
390+
def test_explicit_zero_is_omitted(self):
391+
self.assertEqual({}, self._instance(delay_ms=0).get_negative_ack_args())
392+
393+
def test_result_is_splattable_into_subscribe_kwargs(self):
394+
# The value is consumed via **nack_args and consumer_args.update(...), so it must be a dict
395+
# with exactly the keyword subscribe() expects.
396+
args = self._instance(delay_ms=250).get_negative_ack_args()
397+
self.assertIsInstance(args, dict)
398+
self.assertEqual(["negative_ack_redelivery_delay_ms"], list(args.keys()))

0 commit comments

Comments
 (0)