-
Notifications
You must be signed in to change notification settings - Fork 3.8k
[fix][fn] Support deadLetterTopic and maxMessageRetries for Python functions #26400
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
5cc6207
d2b5c83
e6367f4
697cf6c
47df72c
bb024d5
1a5e054
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -32,6 +32,7 @@ | |
|
|
||
| from contextimpl import ContextImpl | ||
| from python_instance import PythonInstance, InstanceConfig | ||
| import pulsar | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [QUALITY] duplicate The module already imports |
||
| from pulsar import Message | ||
|
|
||
| import Function_pb2 | ||
|
|
@@ -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): | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [QUALITY] no test exercises either modified Every test in this class calls 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 |
||
| """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): | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [QUALITY] Two separate things here. The test does not cover what it exists for. It asserts only The stated reason for the Since the C++ policy exposes no presence flag, "unset" and |
||
| # 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): | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [QUALITY] 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 if mode not in (pulsar._pulsar.ConsumerType.Shared, pulsar._pulsar.ConsumerType.KeyShared):
return NoneAll 33 tests in The reason is the fixture: What it does pin is the signature change: the old To pin the behaviour, build the policy for an instance that actually selects a non-Shared subscription — |
||
| # 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 |
|---|---|---|
|
|
@@ -535,6 +535,64 @@ public void testMergeRuntimeFlags() { | |
| } | ||
|
|
||
| @SuppressWarnings("deprecation") | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [QUALITY] the new tests were inserted between On master this annotation sits directly above The build compiles with Moving the new tests below |
||
| @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"); | ||
|
|
@@ -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); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[QUALITY]
dead_letter_policyraises the Python runtime's minimum pulsar-client-python to 3.3.0 — worth stating in the requirements and docsBoth subscribe paths now always pass the keyword — here and again at line 215 — even when
get_dead_letter_policy()returnedNone.Client.subscribe()only grew adead_letter_policyparameter in pulsar-client-python 3.3.0; it is absent from the v3.2.0 signature. PassingNoneis fine on 3.3.0+ (3.13.0declaresdead_letter_policy: Union[None, ConsumerDeadLetterPolicy] = Noneand guards withif 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:22pins 3.13.0,docker/pulsar/Dockerfile:187anddocker/pulsar/Dockerfile.wolfi:111install it into the images, andrun_python_instance_tests.sh:34pins 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:420adds 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-functionrequirements.txtthatpython_instance_main.py:209-216pip-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 aget_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.