Skip to content

GH-4059: say so when a publish rule overwrites a listener's EndpointMode - #4582

Merged
jeremydmiller merged 1 commit into
mainfrom
gh-4059-sendinline-overwrites-listener-mode
Sep 23, 2026
Merged

jeremydmiller merged 1 commit into
mainfrom
gh-4059-sendinline-overwrites-listener-mode

Conversation

@jeremydmiller

Copy link
Copy Markdown
Member

Closes #4059.

What was wrong

EndpointMode is one property governing both directions, so on any transport where publishing and listening resolve to the same Endpoint object — a RabbitMQ queue, a Redis stream, a Pulsar topic — this silently discards the listener's mode:

opts.ListenToRabbitQueue("orders")
    .ProcessInParallelWithNativeAcks()
    .PartitionProcessingByGroupId(PartitionSlots.Five);

opts.PublishMessage<Order>()
    .ToRabbitQueue("orders")
    .SendInline();                          // resets Mode to Inline, no warning

Two people hit it independently within an hour while building the Redis Streams and Pulsar transports.

The collision is now reported

Endpoint records ListenerRequestedMode and SubscriberRequestedMode alongside the outcome, so ListenerConfigurationValidator can see both requests and say which mode each side asked for, which one actually won, and that the answer depends on the order Wolverine applies the two configuration blocks.

That recording is the whole reason the check can work. Both sides are delayed configuration, so a guard in the Mode setter catches one ordering and misses the reverse — the same reasoning as GH-3712's parallelism clamp, and the placement the issue asked for. A test runs both orderings for exactly that reason.

Applies to UseDurableOutbox() and BufferedInMemory() too; SendInline() is the loudest instance, not the only one.

Warning rather than refusal — a judgement call worth reviewing

The issue offered "warn or throw". I went with warn, and the reasoning is the part to check:

"Send inline, receive durably" is a coherent thing to want. With a single Mode property Wolverine cannot express it, so throwing would refuse a configuration that is meaningful rather than mistaken, leaving no remedy but to give one of the two calls up. Option 2 in the issue — a distinct SendingMode — removes the collision at the root and needs its own migration story; that is where the refusal belongs.

The Fatal that already existed now blames the right call

This is the half with teeth, and it closes the gap the issue names: GH-3712's validator "sees the endpoint after the overwrite and cannot tell 'the user asked for Inline' from 'the user's listener mode was clobbered by a publish rule'."

Before, that case refused the host with:

use ProcessInParallelWithNativeAcks() instead of ProcessInline()

— advice pointing at a ProcessInline() call the reader never wrote, and in the report's own configuration, telling them to make a change their listener had already made.

It now names SendInline() and the publishing side when that is what imposed Inline, and a test asserts the message does not say "instead of ProcessInline()" in that case. When the listener genuinely asked for Inline, the original advice is unchanged — also asserted.

Tests

Nine, covering:

  • both orderings of the two configuration blocks
  • UseDurableOutbox() over an Inline listener, so the check is not SendInline()-specific
  • the three cases that must stay quiet: sides that agree, a side that named no mode at all (a bare PublishAllMessages().To(...) — the common case, and why this is not merely "Mode was assigned twice"), and a listen-only endpoint
  • both halves of the partitioning message

The report's literal ProcessInParallelWithNativeAcks() combination is built directly against a native-ack-capable stub endpoint, because supportsNativeAck is opt-in per transport and the plain stub throws on that mode — the durable inbox stands in for the fluent-API version of the same case.

Verification

CoreTests 3047 green. Pinned full-solution build clean: dotnet build wolverine.slnx -c Release -f net9.0.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VDUrBeB4tTnKj4AExCS1nj

EndpointMode is one property governing both directions, so on any transport where publishing and
listening resolve to the same Endpoint object -- a RabbitMQ queue, a Redis stream, a Pulsar topic --
a sending-side SendInline() silently overwrote the listening side's mode, and vice versa. Two people
hit this independently within an hour while building the Redis Streams and Pulsar transports (#4046,
#4047).

Endpoint now records ListenerRequestedMode and SubscriberRequestedMode alongside the outcome, so
ListenerConfigurationValidator can see both requests and report the collision: which mode each side
asked for, which one actually won, and that the answer depends on the order Wolverine applies the two
configuration blocks.

That recording is why the check lives in the validator rather than the Mode setter. Both sides are
delayed configuration, so a setter guard catches one ordering and misses the reverse -- the same
reasoning as GH-3712's parallelism clamp. A test runs both orderings for exactly that reason.

## Warning rather than refusal

The issue offered warn or throw. "Send inline, receive durably" is a coherent thing to want, and with
a single Mode property Wolverine cannot express it -- so throwing would refuse a configuration that is
meaningful rather than mistaken, with no remedy but to give one of the two calls up. Separating the two
into a distinct SendingMode is the real fix and needs its own migration story.

Applies to UseDurableOutbox() and BufferedInMemory() as well; SendInline() is the loudest instance,
not the only one.

## The Fatal that already existed now blames the right call

The sharper half. When SendInline() reset a listener's mode to Inline and PartitionProcessingByGroupId()
was configured, GH-3712's validator refused the host with "use ProcessInParallelWithNativeAcks() instead
of ProcessInline()" -- advice pointing at a ProcessInline() call the reader never wrote, and, in the
report's own case, telling them to make a change their listener had already made.

The message now distinguishes the two causes: when the Inline came from a sending-side call it names
SendInline() and the publishing side, and a test asserts it does NOT say "instead of ProcessInline()".
When the listener genuinely asked for Inline, the original advice is unchanged.

Nine tests, covering both orderings, both non-SendInline sending-side setters, the three cases that must
stay quiet (sides that agree, a side that named no mode at all, a listen-only endpoint), and both halves
of the partitioning message. The report's literal ProcessInParallelWithNativeAcks() combination is built
directly against a native-ack-capable stub, since supportsNativeAck is opt-in per transport and the plain
stub throws on the mode.

CoreTests 3047 green. Pinned Release build of wolverine.slnx clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VDUrBeB4tTnKj4AExCS1nj
@jeremydmiller
jeremydmiller merged commit 9b60947 into main Sep 23, 2026
44 checks passed
jeremydmiller added a commit that referenced this pull request Sep 24, 2026
… test suites (#4597)

Extracted from @tmorejon's #4506, which found these while building a validator for GH-4059 and is the
original work here. The core fix landed separately as #4582 with a WARNING rather than a rejection --
"send inline, receive durably" is a coherent thing to want that a single Endpoint.Mode cannot express --
but these 27 files are real misconfigurations either way, and #4582's warning now reports every one of
them.

Most are a `.SendInline()` on a publish rule for an endpoint whose listener separately asked for
UseDurableInbox() or ProcessInParallelWithNativeAcks(); whichever configuration block Wolverine applied
last silently decided the mode for both directions.

The Redis dead letter tests are the interesting ones, and the reason this is worth landing rather than
leaving as noise: they were RELYING on the bug. Their listener needs Inline, because only an Inline
listener takes RedisStreamListener.MoveToErrorsAsync -- a buffered one dead-letters through
TryBuildDeadLetterSender instead -- and it was silently getting that from the SendInline() on the
publishing side sharing Endpoint.Mode. They now say ProcessInline() outright.

Verified: Wolverine.Redis.Tests DeadLetterQueueTests 6 green, which is where these fixes overlap the
GH-4559 rewrite of disabled_dead_letter_queue_should_not_create_dead_letter_stream (#4579).


Claude-Session: https://claude.ai/code/session_01VDUrBeB4tTnKj4AExCS1nj

Co-authored-by: Jorge L. Torres M <tmorejon.jl@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

SendInline() on a shared endpoint silently overwrites the listener's EndpointMode

1 participant