Skip to content

GlobalPartitionedMessageTopology.Except<T>() (#3867 follow-up) - #3873

Merged
jeremydmiller merged 1 commit into
mainfrom
gh-3867-topology-except
Aug 7, 2026
Merged

GlobalPartitionedMessageTopology.Except<T>() (#3867 follow-up)#3873
jeremydmiller merged 1 commit into
mainfrom
gh-3867-topology-except

Conversation

@jeremydmiller

Copy link
Copy Markdown
Member

Follow-up to #3867 (parts 1 and 2 merged in #3868). Small addition, and it closes a real loop we hit immediately after adopting global partitioning.

What

GlobalPartitionedMessageTopology.Except<T>() / Except(Type) — carve a message type, or a whole family when given an interface or base class, out of a topology even when a broader rule like MessagesImplementing<T>() would otherwise match it. Exclusions are checked first and win outright, so the result does not depend on declaration order.

opts.MessagePartitioning.GlobalPartitioned(topology =>
{
    topology.UseShardedRabbitQueues("critterwatch", 5);
    topology.MessagesImplementing<ICritterWatchMessage>();

    // This family is what we forward to the browser; without the exemption our own
    // re-publish re-enters the topology and comes back to the handler that sent it.
    topology.Except<ICritterStackWebSocketMessage>();
});

Why

A message type can legitimately belong to the topology on the way in while the receiving application also re-publishes it on its way somewhere else. Because each side configures its own topology, excluding it on the receiving side keeps inbound partitioning intact while stopping that application's own re-publish from re-entering the topology and arriving back at the handler that published it.

The loop is worth describing because it is invisible in configuration and shows up only as amplified load. In CritterWatch a handler ends with bus.PublishAsync(message) to forward the message to the UI over SignalR. The message implements an interface that extends the marker the topology matches, so that re-publish drew two routes — the intended SignalR one, and the topology one, which came straight back to the same handler.

Measured, same driver and rate, over two minutes:

event type appends published amplification
the re-publishing type 2,607 ~425 6.1x
sibling type 60 ~425 1.0x
sibling type 61 ~425 1.0x

With Except in place the same measurement is 429 appends from ~429 published — exactly 1.0x.

Note MessageRouter.DeduplicateRoutes does not catch this: it only removes explicit routes to sticky-handler local queues, never a transport route coexisting with a GlobalPartitionedRoute.

Two things worth a reviewer's eye

Excluding a type does not stop the application listening for it on the topology's slots — the companion-queue bridge is wired per endpoint, not per message type. Only the publishing rules are affected. That asymmetry is the whole point, and it is documented on the method.

Message(Type) now skips seeding the name cache for an already-excluded type, and Except removes it. Without both halves, MatchesByMessageTypeName — the pre-deserialization path Kafka uses — would disagree with Matches() under one of the two declaration orders. There is a test for each ordering.

Tests

7 new in CoreTests.Acceptance.GlobalPartitionedExclusionTests: broad-rule carve-out, both declaration orders, interface-family exclusion, the name-cache agreement under both orders, no effect on unrelated types, null guard.

Partitioning + batching suites on net9.0: 207 passed / 0 failed. Full CoreTests on the previous base: 2,322 passed / 0 failed.

Carve a message type — or a whole family, when given an interface or base
class — out of a global partitioned topology, even when a broader rule like
MessagesImplementing<T>() would otherwise match it. Exclusions are checked
first and win outright, so the result does not depend on declaration order.

The case this exists for: a message type that legitimately belongs to the
topology on the way IN, but that the receiving application also re-publishes on
its way somewhere else. Because each side configures its own topology,
excluding it on the RECEIVING side keeps inbound partitioning intact while
stopping that application's own re-publish from re-entering the topology and
arriving back at the handler that published it.

That loop is worth describing, because it is invisible in configuration and
shows up only as amplified load. Found in CritterWatch: a handler ends with
bus.PublishAsync(message) to forward the message to a UI over SignalR. The
message implements an interface that extends the marker the topology matches,
so the re-publish drew TWO routes — the intended SignalR one and the topology
one, which came straight back to the same handler. Measured 6.1x write
amplification against sibling message types on an identical driver. Note
MessageRouter.DeduplicateRoutes does not catch this: it only removes explicit
routes to sticky-handler local queues.

Excluding a type does not stop the application LISTENING for it on the
topology's slots — the companion-queue bridge is wired per endpoint, not per
message type — so inbound partitioning is unaffected. That asymmetry is the
whole point.

Message(Type) skips seeding the name cache for an already-excluded type, and
Except() removes it, so MatchesByMessageTypeName (the pre-deserialization path)
agrees with Matches() under either declaration order.

7 tests in CoreTests.Acceptance.GlobalPartitionedExclusionTests. Full CoreTests
on net9.0: 2,322 passed / 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016v2Aijyo8MX2AdPUZL5VtG
@jeremydmiller
jeremydmiller merged commit 5d8537b into main Aug 7, 2026
36 checks passed
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.

1 participant