GlobalPartitionedMessageTopology.Except<T>() (#3867 follow-up) - #3873
Merged
Conversation
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
This was referenced Aug 10, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 likeMessagesImplementing<T>()would otherwise match it. Exclusions are checked first and win outright, so the result does not depend on declaration order.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:
With
Exceptin place the same measurement is 429 appends from ~429 published — exactly 1.0x.Note
MessageRouter.DeduplicateRoutesdoes not catch this: it only removes explicit routes to sticky-handler local queues, never a transport route coexisting with aGlobalPartitionedRoute.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, andExceptremoves it. Without both halves,MatchesByMessageTypeName— the pre-deserialization path Kafka uses — would disagree withMatches()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.