Skip to content

[fix][fn] Support deadLetterTopic and maxMessageRetries for Python functions - #26400

Open
david-streamlio wants to merge 8 commits into
apache:masterfrom
david-streamlio:fix-python-fn-dlq
Open

[fix][fn] Support deadLetterTopic and maxMessageRetries for Python functions#26400
david-streamlio wants to merge 8 commits into
apache:masterfrom
david-streamlio:fix-python-fn-dlq

Conversation

@david-streamlio

@david-streamlio david-streamlio commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Fixes #26397

Motivation

FunctionConfig accepts maxMessageRetries and deadLetterTopic, both are carried into the instance as FunctionDetails.retryDetails (Function.proto L58-61, L91), and the Java runtime applies them. The Python runtime ignored them entirelygrep -i "dead_letter\|retryDetails" python_instance.py returned nothing on master.

The failure mode is silent, which is the damaging part. This is accepted without warning:

pulsar-admin functions create --py fn.py --classname fn.F \
  --dead-letter-topic persistent://public/default/my-dlq \
  --max-message-retries 3 ...

functions get reports the configuration back faithfully, and at runtime nothing is ever routed to the DLQ.

Teaching the runtime to honour retryDetails is necessary but not sufficient. Two gates in FunctionConfigUtils line up exactly and, between them, keep a Python function from ever reaching a retryDetails message on the cluster path:

  • doPythonChecks refuses any maxMessageRetries >= 0 outright ("Message retries not yet supported in python").
  • convert only populates retryDetails when maxMessageRetries != null && >= 0 (L304) — the very condition doPythonChecks rejects. So --dead-letter-topic on its own never produces a retryDetails message at all, and the runtime has nothing to honour.

validateNonJavaFunction has a single caller, the worker REST API (FunctionsImpl), so this refusal applies to cluster submission only. LocalRunner never calls it, which is why the runtime path is reachable under localrun today and nowhere else. Fixing only the runtime would ship a fix that no user could invoke.

Modifications

Runtime (python_instance.py). Add get_dead_letter_policy() to PythonInstance and pass its result to all three subscribe() call sites in run() — the topicsToSerDeClassName loop and both branches of the inputSpecs loop.

The rules follow the Java runtime:

  • Guard on HasField("retryDetails"), matching JavaInstanceRunnable's hasRetryDetails() check. HasField is already the idiom in this file (used for receiverQueueSize).
  • Set the dead letter topic only when non-empty, matching PulsarSource, so the client derives its <topic>-<subscription>-DLQ default rather than receiving an empty name.

Validation (FunctionConfigUtils.doPythonChecks). Replace the blanket refusal with a narrow one on zero.

Zero asks for no redelivery at all before the dead letter topic, and the Python client cannot express it: ConsumerDeadLetterPolicy requires a maxRedeliverCount of at least 1 (pulsar/__init__.py L761-762). Accepting it would create a function whose dead letter topic never receives anything — precisely the silently ineffective configuration this change exists to remove — so it is rejected at creation, where the mistake is still cheap to fix, rather than warned about in an instance log nobody reads. A negative value leaves retries unset, as on the Java path.

CLI metadata (CmdFunctions). The @Option descriptions carry a runtime marker that the docs sync parses into the Support column of the published pulsar-admin CLI reference, and that pulsar-admin functions create --help prints verbatim. --max-message-retries and --dead-letter-topic were both marked #Java; they are now #Java, Python.

doGolangChecks keeps its guard: the Go runtime does not honour retryDetails yet, and a test now pins that so this cannot be widened to Go by accident.

The pre-existing cross-cutting checks in doCommonChecks still apply to Python and cover the rest of the space: a dead letter topic with retries unset is refused ("Dead Letter Topic specified, however max retries is set to infinity"), as is maxMessageRetries combined with EFFECTIVELY_ONCE.

Two runtime cases still cannot mirror Java exactly, and both warn rather than failing the instance or silently doing nothing:

  1. maxMessageRetries == 0. Now rejected at creation on the cluster path, but still reachable under localrun, which skips validateNonJavaFunction. Attaching no policy is the only available behaviour there; the warning names the dead letter topic that will not receive messages. Raising would take down a function the Java runtime would have started.
  2. Non-Shared subscriptions. A dead letter policy only takes effect on Shared and KeyShared. retainOrdering and EFFECTIVELY_ONCE both select Failover, where the policy would be silently ineffective — the same class of bug as this issue — so that combination warns as well.

I did not change the Java-side >= 0 behaviour or the client's >= 1 constraint; reconciling them is a larger discussion than this fix.

Verifying this change

  • Make sure that the change passes the CI checks.

This change added tests and can be verified as follows:

  • 8 unit tests in test_python_instance.py (TestDeadLetterPolicy), covering: no retryDetails → no policy; a policy built from retryDetails; an empty dead letter topic deferring to the client default; maxMessageRetries of 0 and of -1 attaching no policy rather than raising; KeyShared receiving a policy; Failover and Exclusive not.
  • 4 unit tests in FunctionConfigUtilsTest.java: Python accepts retries with and without an explicit dead letter topic, rejects zero with the new message, and Go still refuses retries.
  • Full Python file: 12/12 pass via pulsar-functions/instance/src/scripts/run_python_instance_tests.sh. FunctionConfigUtilsTest: 41/41 pass.
  • Confirmed the Python tests are not vacuous: making get_dead_letter_policy() return None unconditionally fails 3 of them.

Does this pull request potentially affect one of the following parts:

  • Dependencies (add or upgrade a dependency)
  • The public API
  • The schema
  • The default values of configurations
  • The threading model
  • The binary protocol
  • The REST endpoints

A function that does not set retryDetails is unaffected: get_dead_letter_policy() returns None and dead_letter_policy=None is what the client already defaults to.

The validation change is relaxing only: configurations that were accepted before are still accepted. The one newly-rejected input, maxMessageRetries == 0 on a Python function, was previously rejected too, by the broader guard this replaces — no configuration that used to be creatable stops being creatable.

Documentation

  • doc-required

docs/functions-cli.md in apache/pulsar-site documents maxMessageRetries and deadLetterTopic with no runtime qualification, so the table needs a note that Java and Python honour them (Python requiring at least 1) while Go does not. That table is hand-maintained, so it needs its own PR there; the #Java, Python marker above covers the generated CLI reference from this side.

For the record, the note added when #6084 was closed in 2020 -- "This parameter is not supported in Python Functions" -- is no longer present in the current docs. It survives only in versioned_docs/version-2.3.0 through version-2.10.x, where it was accurate for those releases and should stay; it was dropped from the live docs in the 2.11 functions-cli.md rewrite. So there is no incorrect statement to retract, only a missing caveat to add.

I have not opened the apache/pulsar-site PR yet -- flagging it so it is not lost, and happy to open it once the behaviour here is settled in review.

@david-streamlio

Copy link
Copy Markdown
Contributor Author

A gap in this PR's reasoning that I found while auditing windowing support, and that a reviewer should weigh before merging.

This PR makes the Python runtime honour retryDetails, but the broker still refuses the configuration that would reach it:

// FunctionConfigUtils.doPythonChecks()
if (functionConfig.getMaxMessageRetries() != null && functionConfig.getMaxMessageRetries() >= 0) {
    throw new IllegalArgumentException("Message retries not yet supported in python");
}

So on master, pulsar-admin functions create --py ... --max-message-retries 3 fails at creation rather than being silently ignored at runtime. That makes the framing in #26397 and in this description partly wrong: I described the failure as silent, and for deadLetterTopic alone it is — that field has no such check, so it is accepted and dropped. But the pairing an operator would actually configure, --max-message-retries with --dead-letter-topic, is refused up front.

That changes what this PR is worth on its own:

  • As it stands, it makes the runtime ready but the path is still closed. A user cannot exercise it without also relaxing doPythonChecks.
  • Relaxing that check is a deliberate, separate decision — it is the broker declaring Python DLQ supported — and it should probably not be smuggled in through a runtime PR.

Options, and I do not have a strong view on which is right:

  1. Merge this as-is and follow up with a small PR removing the doPythonChecks guard, so the runtime support demonstrably exists before the config surface opens.
  2. Extend this PR to remove the guard too, so the feature is usable when it lands.
  3. Hold it until there is a decision on whether Python DLQ is a supported feature at all.

I would lean toward 1, since it keeps the runtime change reviewable on its own merits and makes the enabling change an explicit, visible decision rather than a side effect. Happy to open the follow-up either way.

The same relationship exists for the Go runtime: doGolangChecks carries an identical maxMessageRetries guard, so #26406 will need the same consideration when it is picked up. negativeAckRedeliveryDelayMs (#26413, #26415) is unaffected — no equivalent check exists for it.

…runtime

FunctionConfig accepts maxMessageRetries and deadLetterTopic, both are
carried into the instance as FunctionDetails.retryDetails, and the Java
runtime applies them. The Python runtime ignored them entirely: nothing in
python_instance.py referenced retryDetails, so a function created with
--dead-letter-topic was accepted, reported back faithfully by functions
get, and then routed nothing to the DLQ at runtime.

Build a ConsumerDeadLetterPolicy from retryDetails in a new
get_dead_letter_policy() and pass it to all three subscribe() call sites.

The rules mirror the Java runtime, which guards on hasRetryDetails() in
JavaInstanceRunnable and applies the policy in PulsarSource, setting the
dead letter topic only when it is non-empty so the client can derive its
"<topic>-<subscription>-DLQ" default.

Two cases cannot mirror Java exactly, and both warn rather than failing
the instance or silently doing nothing:

- Java accepts maxMessageRetries >= 0, but the Python client's
  ConsumerDeadLetterPolicy rejects a redelivery count below 1, so zero
  cannot be expressed. Attaching no policy is the only option; a warning
  names the dead letter topic that will not receive messages.
- A dead letter policy only takes effect on Shared and KeyShared
  subscriptions. retainOrdering and EFFECTIVELY_ONCE both select Failover,
  where the policy would be silently ineffective, so that combination
  warns too. Silently ineffective configuration is the bug this fixes, and
  it should not be reintroduced by the fix.

Fixes apache#26397
…nctions

### Motivation

`doPythonChecks` refuses any `maxMessageRetries >= 0`, so
`pulsar-admin functions create --py ... --max-message-retries 3` fails at
creation with "Message retries not yet supported in python".

That guard is now the only thing standing between the Python runtime and a
working dead letter queue. The runtime honours `FunctionDetails.retryDetails`,
but nothing can reach it through the cluster path, because the two gates line
up exactly:

- `FunctionConfigUtils.convert` only populates `retryDetails` when
  `maxMessageRetries != null && >= 0` -- the condition `doPythonChecks`
  rejects. So `--dead-letter-topic` on its own never produces a
  `retryDetails` message at all, and the runtime sees nothing to honour.
- `--max-message-retries` with any value the runtime could use is refused
  before it gets that far.

`validateNonJavaFunction` has one caller, the worker REST API
(`FunctionsImpl`), so the refusal applies to cluster submission only;
`LocalRunner` never calls it, which is why the runtime path is reachable under
`localrun` today and nowhere else.

### Modifications

Replace the blanket refusal in `doPythonChecks` with a narrow one on zero.

Zero asks for no redelivery at all before the dead letter topic, and the
Python client cannot express it: `ConsumerDeadLetterPolicy` requires a
`maxRedeliverCount` of at least 1. Accepting it would create a function whose
dead letter topic never receives anything -- the silently ineffective
configuration this support exists to remove -- so it is rejected at creation,
where the mistake is still cheap to fix, rather than warned about in an
instance log nobody reads. A negative value leaves retries unset, as on the
Java path.

`doGolangChecks` keeps its guard: the Go runtime does not honour
`retryDetails` yet, and a test now pins that so this change cannot be widened
to Go by accident.

Four tests: Python accepts retries with and without an explicit dead letter
topic, rejects zero with the new message, and Go still refuses retries.
Every other method in python_instance.py is preceded by a blank line;
get_dead_letter_policy, added earlier in this branch, ran on directly from
the end of get_record_class.
@david-streamlio david-streamlio changed the title [fix][fn] Honour deadLetterTopic and maxMessageRetries in the Python function runtime [fix][fn] Support deadLetterTopic and maxMessageRetries for Python functions Aug 25, 2026
…n-capable

The @option descriptions in CmdFunctions carry a runtime marker that the
docs sync parses into the "Support" column of the published pulsar-admin
CLI reference, and that `functions create --help` prints verbatim. Both
flags were marked #Java, which this branch makes untrue.
@david-streamlio

Copy link
Copy Markdown
Contributor Author

Cross-linking for reviewers: the documentation for this change is apache/pulsar-site#1214 ("Note runtime support for maxMessageRetries and deadLetterTopic"), which is open and awaiting review.

The doc-required label here is correct and will be satisfied once that merges — leaving it in place deliberately, unlike #26392 and #26393 where the corresponding doc has already landed.

@david-streamlio
david-streamlio requested a review from nodece August 26, 2026 15:38

@freeznet freeznet left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The positive retry mapping and default DLQ topic handling look sound, but the two guards below introduce behavior and ownership differences from the Java runtime. Please align these paths before merging.

# The Java runtime accepts maxMessageRetries >= 0, but the Python client rejects a
# maxRedeliverCount below 1, so zero cannot be expressed here. Warn rather than fail the
# instance, and rather than dropping it silently - silent drops are the bug this fixes.
if max_message_retries < 1:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Java does not start successfully with this value: PulsarSource forwards 0 to ConsumerBuilder.deadLetterPolicy(), and ConsumerBuilderImpl rejects maxRedeliverCount <= 0. Since LocalRunner bypasses validateNonJavaFunction, returning None here makes Python localrun start with retries silently disabled while Java fails fast. Please let ConsumerDeadLetterPolicy reject zero (or move the validation to a path shared by cluster and localrun) instead of swallowing it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed, and fixed in 47df72c. I verified the Java path rather than taking it on trust, and it is exactly as you describe:

  • PulsarSource.java:97 guards on getMaxMessageRetries() != null && >= 0, so 0 is forwarded rather than skipped;
  • ConsumerBuilderImpl.java:530 then does checkArgument(deadLetterPolicy.getMaxRedeliverCount() > 0, "MaxRedeliverCount must be > 0.");
  • validateNonJavaFunction has exactly one non-test caller, FunctionsImpl.java:829, so LocalRunner does bypass it.

I took the first of your two options: the value is now passed straight through and ConsumerDeadLetterPolicy rejects it. Confirmed against the pinned client that it raises ValueError: max_redeliver_count must be greater than 0 for both 0 and negatives, so localrun now fails the instance the same way Java does instead of starting with retries quietly disabled.

The cluster path keeps its earlier, friendlier diagnostic — validateNonJavaFunction still rejects maxMessageRetries == 0 for Python before submission — so this only changes what happens when that check is bypassed.

return None

# A dead letter policy only takes effect on Shared and KeyShared subscriptions.
if consumer_type not in (pulsar._pulsar.ConsumerType.Shared, pulsar._pulsar.ConsumerType.KeyShared):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Could we remove this runtime-level subscription-type gate and the consumer_type parameter? The Java runtime always forwards a configured DeadLetterPolicy; Shared/KeyShared support is a client concern. Encoding the current native-client limitation here makes Python diverge from Java and adds a second support matrix that can become stale. Passing the policy through is simpler; any validation or warning should live at the common config or client boundary.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed, removed in 47df72c along with the consumer_type parameter; the caller is now get_dead_letter_policy().

Your reasoning is the part I found persuasive: the Java runtime always forwards a configured DeadLetterPolicy, so gating here created a second support matrix in the runtime that would drift from the client as native-client support changes. Passing the policy through is both simpler and one less thing to keep in sync.

The three subscription-type test cases are replaced by a single one asserting the policy is not gated on subscription type, so the intent is pinned rather than just untested.

Motivation:
Review of apache#26400 found that the two guards in get_dead_letter_policy made the
Python runtime behave differently from Java and put a support matrix in the
runtime that belongs to the client.

Modifications:
- Pass maxMessageRetries through instead of returning None below 1. Java does
  not start with that value either: PulsarSource builds a policy for any
  maxMessageRetries >= 0 and ConsumerBuilderImpl.deadLetterPolicy then rejects
  "MaxRedeliverCount must be > 0". Because LocalRunner bypasses
  validateNonJavaFunction, the previous guard let localrun start with retries
  silently disabled while Java failed fast. ConsumerDeadLetterPolicy raises
  ValueError for 0 and for negatives, so both now fail the instance.
- Drop the Shared/KeyShared gate and the consumer_type parameter. The Java
  runtime always forwards a configured DeadLetterPolicy; which subscription
  types can act on one is a client concern, and encoding the current native
  client limitation here would drift from the client over time.

Verification:
- Reworked TestDeadLetterPolicy: the zero and negative cases now assert the
  fail-fast, the three subscription-type cases are replaced by one asserting
  the policy is not gated on subscription type
- run_python_instance_tests.sh equivalent passes: 10 tests, all green

The cluster path keeps its earlier, friendlier diagnostic -
validateNonJavaFunction still rejects maxMessageRetries == 0 for Python before
submission, so this only changes what happens when that check is bypassed.
@david-streamlio

Copy link
Copy Markdown
Contributor Author

@freeznet both addressed in 47df72c — replies inline.

  • The zero guard is gone; the value is passed through and ConsumerDeadLetterPolicy rejects it, so localrun fails the same way Java does.
  • The subscription-type gate and the consumer_type parameter are gone; the policy is forwarded unconditionally, as in the Java runtime.

I checked PulsarSource, ConsumerBuilderImpl, and the validateNonJavaFunction call sites directly rather than taking the description on trust, and each matched. Tests reworked accordingly: 10 pass under the same invocation run_python_instance_tests.sh uses.

Both changes made the PR smaller, which is a good sign — thanks for pushing on the consistency argument.

david-streamlio and others added 2 commits August 26, 2026 16:44
# Conflicts:
#	pulsar-functions/instance/src/main/python/python_instance.py
#	pulsar-functions/instance/src/test/python/test_python_instance.py
Merging master brought in apache#26421, which had independently added a test
named testGoFunctionStillRejectsMessageRetries. Both copies landed in
different regions of the file, so the merge was clean and the collision
only surfaced as a compile error:

  method testGoFunctionStillRejectsMessageRetries() is already defined

Keep upstream's copy, which sits with the other Go tests and builds its
config from minimalGoFunctionConfig() rather than routing through a
Python one, and carry the explanatory comment onto it.

Also cover three retry edges that had no test:

- a negative maxMessageRetries means "unset" on the Python path, as it
  does on the Java path, and convert() emits no retryDetails for it
- a dead letter topic with maxMessageRetries unset is rejected by
  doCommonChecks; nothing asserted that message
- doGolangChecks refuses every count >= 0, not only a positive one,
  unlike Python which refuses only 0

Verified locally under JDK 25: 48 tests, 0 failures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017DmEAdjzyB9hJthimd3TZf
@david-streamlio

Copy link
Copy Markdown
Contributor Author

@freeznet both points are addressed in 47df72c:

  • Zero retries — the value now passes straight through, so ConsumerDeadLetterPolicy rejects it (ValueError: max_redeliver_count must be greater than 0) for both 0 and negatives. Localrun now fails the instance the same way Java does instead of starting with retries quietly disabled. The cluster path keeps its earlier, friendlier diagnostic, since validateNonJavaFunction still rejects maxMessageRetries == 0 for Python before submission.
  • Subscription-type gate — removed, along with the consumer_type parameter; the caller is now get_dead_letter_policy(). The three subscription-type test cases are replaced by a single one asserting the policy is not gated on subscription type, so the intent is pinned rather than just untested.

Since then I also pushed 1a5e054acf, which removes a duplicate testGoFunctionStillRejectsMessageRetries that arrived when master brought in #26421 — both copies landed in different regions of the file, so the merge was clean and it only surfaced as a compile error. That commit also adds coverage for three retry edges that had none: a negative maxMessageRetries meaning "unset" on the Python path, a dead letter topic with retries unset being rejected by doCommonChecks, and doGolangChecks refusing every count >= 0 rather than only a positive one.

CI is green on 1a5e054acf — 46/46 checks passing.

Could you take another look when you have a moment?

@lhotari lhotari left a comment

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.

The direction here is right and the two points @freeznet raised are genuinely fixed in 47df72c — I checked both rather than taking the replies on trust. Passing maxMessageRetries through so ConsumerDeadLetterPolicy rejects it matches PulsarSource.java:98-104 (>= 0 builds the policy, ConsumerBuilderImpl then rejects <= 0), and ConsumerDeadLetterPolicy.__init__ does raise ValueError: max_redeliver_count must be greater than 0 for both 0 and negatives. Removing the subscription-type gate is also the right call for the reason given: the Java runtime forwards a configured policy unconditionally.

What I do not think holds up is the claim, made in reply to the second thread, that the replacement test leaves the intent "pinned rather than just untested". I re-applied the exact gate that 5cc6207b061 introduced to a copy of get_dead_letter_policy() (deriving the consumer mode from function_details the way run() does) and all 33 tests in test_python_instance still passed. A positive control (get_dead_letter_policy() always returning None) fails 5 tests, so the harness works — the gate simply is not covered. The same is true of the or None on deadLetterTopic: dropping it leaves the suite green.

One thing worth writing down rather than coding around. dead_letter_policy is now passed to subscribe() on both consumer paths unconditionally, and that keyword only exists from pulsar-client-python 3.3.0, so this raises the Python runtime's effective client floor. The images we ship are already fine: docker/pulsar/Dockerfile:187 and docker/pulsar/Dockerfile.wolfi:111 install pulsar-client[all] at the version gradle/libs.versions.toml:22 pins (3.13.0), and run_python_instance_tests.sh:34 pins the same for CI. What is missing is that we never state the requirement anywhere — the process runtime launches the host's python3 (RuntimeUtils.java:420), so a self-managed worker runs whatever client the operator happens to have installed. A short note telling users to run a recent pulsar-client-python (>= 3.3.0) in pulsar-functions/instance/src/main/python/README.md, and in the pulsar-site page you already have open (apache/pulsar-site#1214), would close it.

The rest is small: a @SuppressWarnings("deprecation") that the insertion point silently moved off createFunctionConfig(), a duplicated import pulsar, and the fact that neither modified subscribe() call is exercised by any test.

On the broader question raised in the first PR comment (merge as-is vs. relax doPythonChecks here vs. hold): the PR as it stands has taken option 2, and I think that is the right choice — shipping the runtime support without opening the config surface would leave it unreachable dead code.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed — documenting the requirement, not defending against old clients. Done in python/README.md.

I checked the floor against the client sources rather than taking it on trust, and it is where you
place it: pulsar/__init__.py at tag v3.2.0 contains neither dead_letter_policy nor
ConsumerDeadLetterPolicy; v3.3.0 introduces both and guards with if dead_letter_policy:, so
passing None is a no-op from 3.3.0 on.

One extra data point for the README wording: I diffed every keyword the runtime passes to
subscribe() and create_producer() against the v3.2.0 signature, and dead_letter_policy is the
only one 3.2.0 lacks. So 3.3.0 is exactly the floor this introduces — nothing else in the runtime was
already above it.

The section states the requirement, notes it is only visible on a self-managed worker (where
RuntimeUtils launches the host python3 and the installed client is the operator's), and gives the
requirements.txt pin for a zip-packaged function. The matching note for apache/pulsar-site#1214
follows.

One thing worth your call, since it is a project-level statement rather than a PR-level one: this
README line is the first declared minimum pulsar-client-python version anywhere in the repo. 3.3.0 is
the technical floor for this change, but if the project wants to state a higher supported minimum,
that number should be yours rather than mine and I will use whatever you prefer.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

You are right, and I reproduced it the same way before changing anything: restoring the gate leaves
the old test green. The reason is that _instance() builds a FunctionDetails with no ordering
fields set, which selects Shared — the one subscription type the gate allowed — so the assertion
never reached the branch it claimed to pin.

Rewritten to exercise the types the gate actually suppressed. run() derives the consumer type from
FunctionDetails: retainOrdering, processingGuarantees == EFFECTIVELY_ONCE and
source.subscriptionType == FAILOVER each select Failover, and retainKeyOrdering selects
KeyShared. The test now runs all four as subTests, so a gate reintroduced here would have to read one
of those fields and would fail.

Mutation-tested: with the 5cc6207b gate restored (derived from FunctionDetails, since the method
no longer takes a consumer_type), 3 of the 4 subTests fail — retainOrdering, EFFECTIVELY_ONCE
and FAILOVER subscriptionType. The retainKeyOrdering case correctly still passes, because the old
gate permitted KeyShared.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Right on both counts, and the reason the test could not pin the or None is that there was nothing
to pin — so I removed the guard rather than writing a better test for it.

Verified against the CI-pinned client (3.13.0, and 3.10.0 behaves the same):
ConsumerDeadLetterPolicy.__init__ forwards the topic only if dead_letter_topic is not None, but
DeadLetterPolicyBuilder.deadLetterTopic("") and never calling it produce an indistinguishable
object — getDeadLetterTopic() returns "" in both cases. So or None could not change what the
consumer receives, and no assertion on the returned policy could have separated the two.

That also makes the old comment's rationale wrong, as you say: passing "" does not "override the
default with an invalid name", it is simply the same as leaving it unset, and the client derives
<topic>-<subscription>-DLQ from there either way.

The guard is gone, the docstring now describes what the client actually does, and the test pins the
half that is the runtime's to get right: retryDetails with no topic still yields a policy carrying
the redelivery count, and the runtime does not invent a DLQ name of its own. Mutation-tested both
ways — returning None for an empty topic fails it, and inventing a name fails it.

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch — the annotation was silently transplanted onto the new test and left
createFunctionConfig(), which calls the deprecated setAutoAck(true), unannotated.

Moved back onto the helper. git diff upstream/master over those two lines is now empty, so the
annotation sits exactly where master has it, and the new tests carry none of their own.


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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Removed. Both copies came from this PR — line 35 in 5cc6207b and line 41 in 7d2a0bdb — so I kept
the alphabetised one at 41 and dropped 35, as you suggest. The import itself has to stay: the
producer tests use pulsar.BatchingType.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed, and this was the one worth the most — it is the gap the other three findings sat on top of.

run() cannot be driven the way the producer tests drive setup_producer(): it imports the user's
function class and starts the execution thread. So I extracted setup_consumers() to give the
consumer path the same seam. The move is verbatim apart from the four dead_letter_args lines; I
diffed it against the previous head to confirm that.

New TestConsumerSubscribeArgs then does exactly what you describe — asserts on the kwargs captured
from a mocked pulsar_client.subscribe — across the topicsToSerDeClassName path, the inputSpecs
path, its regex variant, and the keyword being passed as None when nothing is
configured.

Mutation-tested, since a coverage claim is worth about as much as the mutation that disproves it:

  • policy dropped from the topicsToSerDeClassName call site only → test_policy_reaches_the_topics_to_serde_path fails, alone;
  • policy dropped from the inputSpecs call site only → test_policy_reaches_the_input_specs_path and test_policy_reaches_the_regex_path fail, and nothing else.

So the two call sites are independently covered rather than one standing in for the other. Your point
about the subscription-type intent needing somewhere durable to live is handled in the
get_dead_letter_policy() tests, which now run the four FunctionDetails inputs that select a
consumer type.

Motivation:

Review feedback on apache#26400 raised six points: the runtime's minimum
pulsar-client-python is now 3.3.0 and is undocumented, nothing exercises
either subscribe() call site, two tests do not pin what they claim to,
and two smaller slips.

Modifications:

- Document the client requirement. Client.subscribe() grew its
  dead_letter_policy parameter in 3.3.0, and the runtime passes the
  keyword on every subscription, so 3.3.0 is the floor. Every other
  argument the runtime passes is already present in 3.2.0, so this is
  the only thing setting it. README.md now states the requirement, notes
  that it is only visible on a self-managed worker, and shows where to
  pin it for a zip-packaged function.

- Extract setup_consumers() from run(), giving the consumer path the
  same testable seam setup_producer() already has, and cover both
  subscribe() call sites - the topicsToSerDeClassName path, the
  inputSpecs path and its regex variant - with assertions on the kwargs
  a mocked client receives.

- Drop the "or None" on deadLetterTopic. It could not change behaviour:
  ConsumerDeadLetterPolicy skips the builder call only for None, and
  DeadLetterPolicyBuilder.deadLetterTopic("") leaves getDeadLetterTopic()
  == "", which is what an unset policy also reports. The test that
  claimed to pin it now pins the runtime's actual half of the contract,
  and its comment no longer misdescribes the client.

- Make test_policy_is_not_gated_on_subscription_type exercise the
  subscription types the earlier gate suppressed. It used a default
  instance, which selects Shared - the one case the gate allowed - so it
  passed with the gate fully restored.

- Remove a duplicate "import pulsar" and move
  @SuppressWarnings("deprecation") back onto createFunctionConfig(), the
  method it was suppressing for.
@david-streamlio

Copy link
Copy Markdown
Contributor Author

@lhotari all six points are addressed in 94b4fcd — thanks, the review found real gaps rather than
style nits, and two of them were things I had asserted without checking.

  • Client floor — documented in python/README.md, per your call. Verified against the client
    sources: v3.2.0 has neither dead_letter_policy nor ConsumerDeadLetterPolicy, v3.3.0 adds
    both. I also diffed every keyword the runtime passes against the 3.2.0 signature —
    dead_letter_policy is the only one missing, so 3.3.0 is exactly the floor this introduces.
  • subscribe() coverage — extracted setup_consumers() to get the same seam setup_producer()
    has, and covered both call sites plus the regex variant against a mocked client.
  • or None — removed. It could not change behaviour: deadLetterTopic("") and never calling it
    produce the same object. The old comment's rationale was wrong; the test now pins the runtime's
    actual half of the contract.
  • Subscription-type test — you were right that the gate restores green. It used a default
    instance, which selects Shared, the one type the gate allowed. Now runs the four FunctionDetails
    inputs that select a consumer type.
  • @SuppressWarnings and the duplicate import — both fixed.

I mutation-tested each rewritten test rather than just checking it passes; the specific mutations and
what fails under them are in the threads.

Verification: run_python_instance_tests.sh against a venv with the CI-pinned deps
(pulsar-client[all]==3.13.0, grpcio==1.78.0, protobuf==6.33.6) — 40 tests, all pass;
FunctionConfigUtilsTest — 48 tests, 0 failures; ./gradlew quickCheck clean.

One question for you in the client-floor thread: the README line is the first declared minimum
pulsar-client-python version in the repo, so if the project wants to state a higher supported
minimum than the 3.3.0 technical floor, that number should be yours.

The matching note for apache/pulsar-site#1214 is next — that PR has no reviewers assigned, so if you
have a moment for it there too it would unblock the docs half.

@freeznet when you have a chance — your two points were fixed back in 47df72c and lhotari confirmed
both independently, so this is only waiting on the standing CHANGES_REQUESTED being cleared.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/function doc-required Your PR changes impact docs and you will update later.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Python Functions] Python instance runtime silently ignores deadLetterTopic / maxMessageRetries

3 participants