Skip to content

Record transport transaction state explicitly instead of inferring it - #1756

Merged
dvdstelt merged 7 commits into
masterfrom
refactor/explicit-dispatch-state
Aug 21, 2026
Merged

Record transport transaction state explicitly instead of inferring it#1756
dvdstelt merged 7 commits into
masterfrom
refactor/explicit-dispatch-state

Conversation

@dvdstelt

@dvdstelt dvdstelt commented Jul 11, 2026

Copy link
Copy Markdown
Member

Why

I always had issues reading the many if-statements on what state/context we're in. So I created this and hope it's more readable from now on.

What

MessageDispatcher.DispatchDefault reverse-engineered the transaction mode by probing which entries happened to be present in the TransportTransaction bag, through an order-dependent if/else chain of extension methods (OutsideOfHandler, IsNoTransaction, IsReceiveOnly, IsSendsAtomicWithReceive, IsTransactionScope). Every TransportTransaction is created by code that already knows the mode, so this PR stamps a TransportTransactionState into the transaction at creation time and turns the dispatcher into a single exhaustive switch.

  • New internal TransportTransactionState enum; all TransportTransactions factory methods record it.
  • DispatchDefault switches on the state, with a comment per case explaining how sends relate to the receive transaction. The two identical "open a dedicated connection" blocks (outside handler and receive-only) collapse into one case.
  • Inference from bag contents remains only as a fallback (InferState) for instances not created by the transport: the empty one core creates for dispatches outside the message processing pipeline, and hand-rolled ones used by external integrations. It mirrors the old chain's semantics, including its ordering.
  • The "SqlTransport.ReceiveOnlyTransactionMode" key was defined in two classes; the writer used one copy and the readers the other, working only because the strings happened to match. It now lives once in TransportTransactionKeys and is still written to the bag for downstream components (e.g. SQL persistence).
  • SendOptionsExtensions hand-assembled user-provided transactions with its own copy of the key strings while PublishOptionsExtensions used the TransportTransactions.UserProvided(...) factory; both now use the factory, and the duplicate key was removed from SettingsKeys.

Behavior notes

No public API changes (API approval tests unchanged). One deliberate alignment: a user-provided connection-only transaction reaching default (non-immediate) dispatch previously got wrapped in a new transaction, while the same bag in isolated dispatch did not. That path is unreachable in practice because UseCustomSqlConnection/UseCustomSqlTransaction force immediate dispatch; both paths now behave identically (dispatch on the user's connection). Everything else is a faithful translation of the old chain.

@github-actions

Copy link
Copy Markdown

This pull request has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.

@github-actions github-actions Bot added the stale label Aug 11, 2026
@dvdstelt

Copy link
Copy Markdown
Member Author

ping

@danielmarbach

Copy link
Copy Markdown
Contributor

I pushed what I believe are simplifications and one commit that contains a proposal that would switch to record types that in the future we could then use with C sharp discriminated union support in C sharp 15 we could create a closed hierarchy. It has one downside, it creates one class allocation more compared to the state approach. But given the complexity of this connection handling and that it makes it more expressive, it might be worthwhile the trade-off. But that's why I called the commit discussion for the proposal, and if we decide, we can just drop it. Do you have any thoughts, @dvdstelt ?

@github-actions github-actions Bot removed the stale label Aug 14, 2026
@dvdstelt

Copy link
Copy Markdown
Member Author

I like the records, mostly for the non-null connection/transaction per case rather than the future C# 15 exhaustiveness. But storing the state next to SqlConnection/SqlTransaction reintroduced the two-sources-of-truth problem your previous commit removed.

So I pushed two commits on top:

  • State is now derived on read instead of stored, so the entries stay the single source of truth. MessageDispatcher needed no changes.
  • OutsideHandler, ReceiveOnly and AmbientTransaction became empty singletons since the dispatcher opens its own connection for those anyway. Only 3 of the 6 states allocate now, so the allocation downside mostly disappears.
  • Added unit tests for the state derivation, which we only covered through integration tests before.

If you're OK with this shape, let's merge. Although there's still no review.

dvdstelt and others added 7 commits August 21, 2026 23:13
The dispatcher reverse-engineered the transaction mode by probing which
entries happened to be present in the TransportTransaction, through an
order-dependent if/else chain. Every TransportTransaction is created by
code that already knows the mode, so stamp it explicitly and switch on
it when dispatching. Inference remains only as a fallback for instances
created outside the transport (core's empty one, hand-rolled ones).

Also consolidates the ReceiveOnlyTransactionMode key, which was defined
twice and only worked because both strings happened to match.
SendOptionsExtensions hand-assembled the TransportTransaction with its
own copy of the key strings while PublishOptionsExtensions used the
TransportTransactions factory. Use the factory in both places and drop
the now-unused duplicate key from SettingsKeys.
Keeps the record hierarchy from the proposal but makes it a projection over the TransportTransaction entries rather than a second copy of them.

Storing the state record under its own key meant the connection and native transaction were held twice: once inside the record and once under the well-known SqlConnection/SqlTransaction keys that SQL persistence and hand-rolled integrations write. The record won on the read path, so any later mutation of those entries would leave the dispatcher silently using a stale connection. Deriving on every read restores the single source of truth without giving up the pattern matching.

OutsideHandler, ReceiveOnly and AmbientTransaction now carry no data, because the dispatcher opens its own connection for all three and relies on Transaction.Current for the ambient case. That drops the null-forgiving operators the ReceiveOnly branch needed and leaves only NoTransaction, SendsAtomicWithReceive and UserProvided allocating, so send-only and TransactionScope dispatches allocate nothing.

The factories go back to writing only the storage entries, which also restores the original behaviour of throwing at dispatch rather than eagerly inside UseCustomSqlTransaction.

MessageDispatcher is unchanged.
The mapping from stored entries to dispatch state had no unit tests at all. Only the integration tests exercised it, so catching a regression required a database.

Covers the six states produced by the factories, the hand-rolled shapes that external integrations and the core create, the invalid combinations that throw, and that the state follows the entries when they change after creation.
@dvdstelt
dvdstelt force-pushed the refactor/explicit-dispatch-state branch from a0f9fd6 to 6ee8670 Compare August 21, 2026 21:14
@dvdstelt
dvdstelt enabled auto-merge August 21, 2026 21:14
@dvdstelt
dvdstelt merged commit 2e8b2c4 into master Aug 21, 2026
4 checks passed
@dvdstelt
dvdstelt deleted the refactor/explicit-dispatch-state branch August 21, 2026 21:35
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.

2 participants