Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -371,7 +371,7 @@ abstract class FunctionDetailsCommand extends BaseCommand {
@Option(names = "--timeout-ms", description = "The message timeout in milliseconds #Java, Python")
protected Long timeoutMs;
@Option(names = "--max-message-retries",
description = "How many times should we try to process a message before giving up #Java")
description = "How many times should we try to process a message before giving up #Java, Python")
protected Integer maxMessageRetries;
@Option(names = "--custom-runtime-options", description = "A string that encodes options to "
+ "customize the runtime, see docs for configured runtime for details #Java")
Expand All @@ -380,7 +380,7 @@ abstract class FunctionDetailsCommand extends BaseCommand {
+ "how the secret is fetched by the underlying secrets provider #Java, Python")
protected String secretsString;
@Option(names = "--dead-letter-topic",
description = "The topic where messages that are not processed successfully are sent to #Java")
description = "The topic where messages that are not processed successfully are sent to #Java, Python")
protected String deadLetterTopic;
@Option(names = "--runtime-flags", description = "Any flags that you want to pass to a runtime"
+ " (for process & Kubernetes runtime only).")
Expand Down
30 changes: 29 additions & 1 deletion pulsar-functions/instance/src/main/python/python_instance.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,8 @@ def run(self):
if self.instance_config.function_details.source.subscriptionPosition == Function_pb2.SubscriptionPosition.Value("EARLIEST"):
position = pulsar._pulsar.InitialPosition.Earliest

dead_letter_policy = self.get_dead_letter_policy()

subscription_name = self.instance_config.function_details.source.subscriptionName

if not (subscription_name and subscription_name.strip()):
Expand Down Expand Up @@ -184,6 +186,7 @@ def run(self):
unacked_messages_timeout_ms=int(self.timeout_ms) if self.timeout_ms else None,
initial_position=position,
properties=properties,
dead_letter_policy=dead_letter_policy,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[QUALITY] dead_letter_policy raises the Python runtime's minimum pulsar-client-python to 3.3.0 — worth stating in the requirements and docs

Both subscribe paths now always pass the keyword — here and again at line 215 — even when get_dead_letter_policy() returned None.

Client.subscribe() only grew a dead_letter_policy parameter in pulsar-client-python 3.3.0; it is absent from the v3.2.0 signature. Passing None is fine on 3.3.0+ (3.13.0 declares dead_letter_policy: Union[None, ConsumerDeadLetterPolicy] = None and guards with if dead_letter_policy:), so the practical effect is simply that the Python runtime now needs a 3.3.0-or-newer client.

Everything apache/pulsar ships already satisfies that: gradle/libs.versions.toml:22 pins 3.13.0, docker/pulsar/Dockerfile:187 and docker/pulsar/Dockerfile.wolfi:111 install it into the images, and run_python_instance_tests.sh:34 pins the same version for CI. The floor is only visible on a self-managed worker, where the process runtime launches the host's interpreter — RuntimeUtils.java:420 adds a bare "python3" — and the distribution ships the instance sources rather than a pinned wheel, so the installed client is the operator's.

So rather than defending the call against old clients, I would state the requirement. The Python runtime's dependency documentation should instruct users to run a recent pulsar-client-python (>= 3.3.0): a line in pulsar-functions/instance/src/main/python/README.md, the matching note in the pulsar-site page you already opened (apache/pulsar-site#1214), and — for zip-packaged functions — the per-function requirements.txt that python_instance_main.py:209-216 pip-installs, which is where an operator would pin it.

If you would rather keep older clients working anyway, the idiom is one method away: get_negative_ack_args() returns a dict to splat exactly so that an unconfigured argument is never passed at all, and a get_dead_letter_args() returning {} or {"dead_letter_policy": policy} would keep unconfigured functions on the old call shape. Given the images and CI already pin 3.13.0, documenting the requirement looks like the better trade.

**nack_args
)

Expand All @@ -208,7 +211,8 @@ def run(self):
"unacked_messages_timeout_ms": int(self.timeout_ms) if self.timeout_ms else None,
"initial_position": position,
"properties": properties,
"crypto_key_reader": crypto_key_reader
"crypto_key_reader": crypto_key_reader,
"dead_letter_policy": dead_letter_policy
}
consumer_args.update(nack_args)
if consumer_conf.HasField("receiverQueueSize"):
Expand Down Expand Up @@ -602,6 +606,30 @@ def get_negative_ack_args(self):

return {"negative_ack_redelivery_delay_ms": delay_ms}

def get_dead_letter_policy(self):
"""Build the consumer dead letter policy from FunctionDetails.retryDetails.

Mirrors the Java runtime (JavaInstanceRunnable + PulsarSource): the policy is only considered
when retryDetails is present, and an empty deadLetterTopic is left to the client, which defaults
it to "<topic>-<subscription>-DLQ".

The configured value is passed through unchanged. Java forwards it the same way - PulsarSource
builds the policy for any maxMessageRetries >= 0 and ConsumerBuilderImpl.deadLetterPolicy then
rejects a redelivery count below 1 - so an unusable value fails the instance here too rather
than starting with retries quietly disabled. Whether a subscription type can act on the policy
is left to the client, as it is for Java.

Returns None when no policy should be attached.
"""
if not self.instance_config.function_details.HasField("retryDetails"):
return None

retry_details = self.instance_config.function_details.retryDetails

return pulsar.ConsumerDeadLetterPolicy(
max_redeliver_count=retry_details.maxMessageRetries,
dead_letter_topic=retry_details.deadLetterTopic or None)

def get_crypto_reader(self, crypto_spec):
crypto_key_reader = None
if crypto_spec is not None:
Expand Down
73 changes: 73 additions & 0 deletions pulsar-functions/instance/src/test/python/test_python_instance.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@

from contextimpl import ContextImpl
from python_instance import PythonInstance, InstanceConfig
import pulsar

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[QUALITY] duplicate import pulsar

The module already imports pulsar at line 41. The new TestDeadLetterPolicy tests reach the policy through instance.get_dead_letter_policy() and never reference pulsar. themselves, so this line can go.

from pulsar import Message

import Function_pb2
Expand Down Expand Up @@ -396,3 +397,75 @@ def test_result_is_splattable_into_subscribe_kwargs(self):
args = self._instance(delay_ms=250).get_negative_ack_args()
self.assertIsInstance(args, dict)
self.assertEqual(["negative_ack_redelivery_delay_ms"], list(args.keys()))

class TestDeadLetterPolicy(unittest.TestCase):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[QUALITY] no test exercises either modified subscribe() call

Every test in this class calls get_dead_letter_policy() directly. Nothing asserts that the policy actually reaches subscribe(), on either the topicsToSerDeClassName path (python_instance.py:186-192) or the inputSpecs/regex path (python_instance.py:211-218).

That is the coverage gap behind the other three test findings: the policy could be dropped from one of the two call sites, gated again on subscription type, or passed under a keyword the installed client does not accept, and this suite would stay green.

The file already has the pattern for it — the producer-config tests around lines 300-365 assert on kwargs captured from a mocked client. Applying the same shape to a mocked pulsar_client.subscribe would cover the two call sites, and would give the subscription-type intent somewhere durable to live now that get_dead_letter_policy() no longer sees a consumer type.

"""Covers FunctionDetails.retryDetails -> ConsumerDeadLetterPolicy.

The Java runtime applies these in JavaInstanceRunnable (guarded on hasRetryDetails) and
PulsarSource (maxMessageRetries >= 0, deadLetterTopic only when non-empty). The Python runtime
previously ignored retryDetails entirely.

The configured redelivery count is passed straight through, matching Java: PulsarSource builds a
policy for any value >= 0 and ConsumerBuilderImpl.deadLetterPolicy then rejects anything below 1.
Whether a subscription type can act on the policy is a client concern, so the runtime does not
gate on it.
"""

def _instance(self, max_message_retries=None, dead_letter_topic=None):
function_details = Function_pb2.FunctionDetails()
function_details.sink.topic = "test_sink_topic"
if max_message_retries is not None:
function_details.retryDetails.maxMessageRetries = max_message_retries
if dead_letter_topic is not None:
function_details.retryDetails.deadLetterTopic = dead_letter_topic

return PythonInstance('test_instance', 'test_func', '1.0', function_details, 100, 30,
'user_code', Mock(), Mock(), 'test_cluster', 'test_url', None)

def test_no_retry_details_means_no_policy(self):
instance = self._instance()
self.assertIsNone(instance.get_dead_letter_policy())

def test_policy_built_from_retry_details(self):
instance = self._instance(max_message_retries=3,
dead_letter_topic="persistent://public/default/my-dlq")
policy = instance.get_dead_letter_policy()

self.assertIsNotNone(policy)
self.assertEqual(3, policy.max_redeliver_count)
self.assertEqual("persistent://public/default/my-dlq", policy.dead_letter_topic)

def test_empty_dead_letter_topic_defers_to_client_default(self):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[QUALITY] test_empty_dead_letter_topic_defers_to_client_default does not pin the or None, and the rationale in its comment is not what the client does

Two separate things here.

The test does not cover what it exists for. It asserts only assertIsNotNone(policy) and max_redeliver_count == 2. I changed dead_letter_topic=retry_details.deadLetterTopic or None to dead_letter_topic=retry_details.deadLetterTopic and all 33 tests in the module stayed green. Asserting policy.dead_letter_topic directly would at least pin the observable value.

The stated reason for the or None is not accurate. The comment (and the matching paragraph in the get_dead_letter_policy() docstring) says passing "" through "would override that with an invalid name". It would not: DeadLetterPolicyBuilder::deadLetterTopic("") is indistinguishable from never calling it — the impl field is a plain std::string defaulting to empty (DeadLetterPolicyBuilder.cc) — and ConsumerImpl.cc derives topic + "-" + subscriptionName + "-DLQ" whenever getDeadLetterTopic().empty(). So the or None is harmless and arguably clearer, but it is defensive rather than load-bearing.

Since the C++ policy exposes no presence flag, "unset" and "" are not distinguishable through the Python API at all, which is worth saying plainly instead of asserting a failure mode that cannot occur. Either drop the claim from the comment and docstring, or keep the or None and describe it as normalisation.

# The Java runtime only sets the topic when non-empty, leaving the client to derive
# "<topic>-<subscription>-DLQ". Passing "" through would override that with an invalid name.
instance = self._instance(max_message_retries=2)
policy = instance.get_dead_letter_policy()

self.assertIsNotNone(policy)
self.assertEqual(2, policy.max_redeliver_count)

def test_zero_retries_fails_fast(self):
# Java does not start with this value either: PulsarSource forwards 0 and
# ConsumerBuilderImpl.deadLetterPolicy rejects "MaxRedeliverCount must be > 0". Returning None
# here instead would let localrun - which bypasses validateNonJavaFunction - start with retries
# silently disabled while Java fails.
instance = self._instance(max_message_retries=0,
dead_letter_topic="persistent://public/default/my-dlq")
with self.assertRaises(ValueError):
instance.get_dead_letter_policy()

def test_negative_retries_fails_fast(self):
instance = self._instance(max_message_retries=-1)
with self.assertRaises(ValueError):
instance.get_dead_letter_policy()

def test_policy_is_not_gated_on_subscription_type(self):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[QUALITY] test_policy_is_not_gated_on_subscription_type passes with the subscription-type gate fully restored

Removing the gate is right, and I agree with the reasoning in the thread. What I could not reproduce is the claim that this test leaves the intent "pinned rather than just untested".

I copied python_instance.py and put the gate from 5cc6207b061 back into get_dead_letter_policy(), deriving the consumer mode from function_details the way run() does at lines 143-152:

if mode not in (pulsar._pulsar.ConsumerType.Shared, pulsar._pulsar.ConsumerType.KeyShared):
  return None

All 33 tests in test_python_instance still pass. As a control, making get_dead_letter_policy() always return None fails 5 of them, so the suite is running and does have teeth — the gate is just invisible to it.

The reason is the fixture: _instance() sets only sink.topic and retryDetails, so source.subscriptionType, retainOrdering, retainKeyOrdering and processingGuarantees all keep their proto defaults. That resolves to SHARED = 0, which is precisely the case the removed gate allowed. The test's assertions are also identical to test_policy_built_from_retry_details, so it adds no signal beyond it.

What it does pin is the signature change: the old get_dead_letter_policy(consumer_type) would raise TypeError here. That is worth having, but it is a narrower guarantee than the name and comment claim.

To pin the behaviour, build the policy for an instance that actually selects a non-Shared subscription — retainOrdering=True, or processingGuarantees=EFFECTIVELY_ONCE, or source.subscriptionType=FAILOVER — and assert the policy is still produced. Since get_dead_letter_policy() no longer sees a consumer type at all, the durable place for that assertion is the subscribe() call in run() rather than the helper.

# Subscription-type support is a client concern; the Java runtime always forwards a configured
# policy. Gating here would add a second support matrix that can drift from the client.
instance = self._instance(max_message_retries=3,
dead_letter_topic="persistent://public/default/my-dlq")
policy = instance.get_dead_letter_policy()

self.assertIsNotNone(policy)
self.assertEqual(3, policy.max_redeliver_count)

Original file line number Diff line number Diff line change
Expand Up @@ -757,8 +757,15 @@ private static void doPythonChecks(FunctionConfig functionConfig) {
throw new IllegalArgumentException("There is currently no support windowing in python");
}

if (functionConfig.getMaxMessageRetries() != null && functionConfig.getMaxMessageRetries() >= 0) {
throw new IllegalArgumentException("Message retries not yet supported in python");
// The Python runtime honours FunctionDetails.retryDetails, so message retries and a dead letter
// topic are no longer refused outright. Zero is still refused: it asks for no redelivery at all
// before the dead letter topic, and the Python client cannot express that -- ConsumerDeadLetterPolicy
// requires a maxRedeliverCount of at least 1. Accepting it would create a function whose dead letter
// topic never receives anything, which is the silently ineffective configuration this support was
// added to remove. A negative value leaves retries unset, as it does on the Java path.
if (functionConfig.getMaxMessageRetries() != null && functionConfig.getMaxMessageRetries() == 0) {
throw new IllegalArgumentException("maxMessageRetries must be at least 1 in python; the Python "
+ "client cannot express a redelivery count of 0");
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -535,6 +535,64 @@ public void testMergeRuntimeFlags() {
}

@SuppressWarnings("deprecation")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[QUALITY] the new tests were inserted between @SuppressWarnings("deprecation") and the method it was suppressing for

On master this annotation sits directly above private FunctionConfig createFunctionConfig(), which calls the @Deprecated FunctionConfig.setAutoAck(true). The new tests were inserted between the two, so at head the annotation applies to testPythonFunctionAcceptsMessageRetriesAndDeadLetterTopic() (line 539) — which touches no deprecated API — and createFunctionConfig() (now line 596, setAutoAck(true) at line 618) is left unsuppressed.

The build compiles with -Xlint:deprecation (build-logic/conventions/src/main/kotlin/pulsar.java-conventions.gradle.kts:69), so this reintroduces the warning the annotation was added to silence. It is not an error, which is why CI stays green. Corroboration that this is the annotation's real target: every other @SuppressWarnings("deprecation") in the file — lines 80, 93, 139, 658 — sits on a method that calls setAutoAck.

Moving the new tests below createPythonFunctionConfig() (or just re-attaching the annotation) restores it.

@Test
public void testPythonFunctionAcceptsMessageRetriesAndDeadLetterTopic() {
FunctionConfig functionConfig = createPythonFunctionConfig();
functionConfig.setMaxMessageRetries(3);
functionConfig.setDeadLetterTopic("test-dlq");
FunctionConfigUtils.validateNonJavaFunction(functionConfig);
}

@Test
public void testPythonFunctionAcceptsMessageRetriesWithoutADeadLetterTopic() {
// The client defaults the topic to "<topic>-<subscription>-DLQ" when it is left empty.
FunctionConfig functionConfig = createPythonFunctionConfig();
functionConfig.setMaxMessageRetries(1);
FunctionConfigUtils.validateNonJavaFunction(functionConfig);
}

@Test(expectedExceptions = IllegalArgumentException.class,
expectedExceptionsMessageRegExp = "maxMessageRetries must be at least 1 in python.*")
public void testPythonFunctionRejectsZeroMessageRetries() {
// Zero asks for no redelivery before the dead letter topic, which the Python client cannot
// express, so the dead letter topic would never receive anything.
FunctionConfig functionConfig = createPythonFunctionConfig();
functionConfig.setMaxMessageRetries(0);
functionConfig.setDeadLetterTopic("test-dlq");
FunctionConfigUtils.validateNonJavaFunction(functionConfig);
}

@Test
public void testPythonFunctionTreatsNegativeMessageRetriesAsUnset() {
// doPythonChecks refuses exactly 0; a negative count means "unset", as it does on the Java path,
// and convert() emits retryDetails only for a count >= 0. Pinned so tightening that guard to <= 0
// cannot pass silently.
FunctionConfig functionConfig = createPythonFunctionConfig();
functionConfig.setMaxMessageRetries(-1);

FunctionConfigUtils.validateNonJavaFunction(functionConfig);

assertFalse(FunctionConfigUtils.convert(functionConfig).hasRetryDetails());
}

@Test(expectedExceptions = IllegalArgumentException.class,
expectedExceptionsMessageRegExp = "Dead Letter Topic specified, however max retries is set to infinity")
public void testPythonFunctionRejectsDeadLetterTopicWithoutMessageRetries() {
// doCommonChecks refuses a dead letter topic nothing can route to: with maxMessageRetries unset the
// redelivery count is infinite, so the topic would never receive anything. Same shape as the zero
// case above, reached from the other side.
FunctionConfig functionConfig = createPythonFunctionConfig();
functionConfig.setDeadLetterTopic("test-dlq");

FunctionConfigUtils.validateNonJavaFunction(functionConfig);
}

private FunctionConfig createPythonFunctionConfig() {
FunctionConfig functionConfig = createFunctionConfig();
functionConfig.setRuntime(FunctionConfig.Runtime.PYTHON);
return functionConfig;
}

private FunctionConfig createFunctionConfig() {
FunctionConfig functionConfig = new FunctionConfig();
functionConfig.setTenant("test-tenant");
Expand Down Expand Up @@ -825,9 +883,21 @@ public void testGoFunctionRejectsRetainKeyOrderingWithEffectivelyOnce() {
@Test(expectedExceptions = IllegalArgumentException.class,
expectedExceptionsMessageRegExp = "Message retries not yet supported in Go function")
public void testGoFunctionStillRejectsMessageRetries() {
// The Go runtime does not honour retryDetails yet, so its guard stays until it does.
FunctionConfig functionConfig = minimalGoFunctionConfig();
functionConfig.setMaxMessageRetries(3);

FunctionConfigUtils.validateNonJavaFunction(functionConfig);
}

@Test(expectedExceptions = IllegalArgumentException.class,
expectedExceptionsMessageRegExp = "Message retries not yet supported in Go function")
public void testGoFunctionRejectsZeroMessageRetries() {
// doGolangChecks refuses every count >= 0, not only a positive one -- unlike Python, which refuses
// only 0. Pinned so the two guards cannot be quietly aligned.
FunctionConfig functionConfig = minimalGoFunctionConfig();
functionConfig.setMaxMessageRetries(0);

FunctionConfigUtils.validateNonJavaFunction(functionConfig);
}
}
Loading