Skip to content

feat(messaging): add durable messaging contracts - #11282

Open
ReubenBond wants to merge 44 commits into
dotnet:mainfrom
ReubenBond:rb-probable-train
Open

ReubenBond wants to merge 44 commits into
dotnet:mainfrom
ReubenBond:rb-probable-train

Conversation

@ReubenBond

@ReubenBond ReubenBond commented Sep 16, 2026 •

Copy link
Copy Markdown
Member

Durable messaging stack: #11282 → #11284 → #11285 → #10693. Each PR targets main and includes the PRs before it.

Problem

Grains coordinating through ordinary calls need retries, deduplication, and recovery which preserve their business state and outgoing messages together. This PR establishes the public contracts for journaled inboxes and outboxes; later layers implement the state machines and activation integration.

Contracts

  • Envelope carries an application-defined MessageId, opaque payload, optional ordinal subject, byte-array headers, and DeliverAt. InboxMessage adds the logical send's required SentAt time; OutboxMessage pairs an envelope with its receiver.
  • HierarchicalKey stores one canonical escaped path with ordinal equality and hashing, navigation, parsing/formatting, JSON conversion, and an Orleans codec. MessageId wraps that identity for deduplication.
  • IInbox registers read-only first-match handler selection and application-owned inbox sinks. Typed helpers bind subjects to Orleans serializers and synchronous or asynchronous handlers.
  • IOutbox stages ordinary or source-provided sends in the grain's next journal write. Typed helpers encode individual commands, batches, and deterministic replies.
  • IOutboxMessageSource.OnRetired lets applications stage durable terminal outcomes and follow-up work atomically with retirement. Recovery can repeat the callback before that write commits; its journaled effects and outbox removal recover together.
  • IMessagingExtension.DeliverAsync returns Accepted, Duplicate, Backpressured, HandlerNotFound, DeadLettered, or Expired. Expired answers a send older than the receiver's deduplication window whose earlier outcome is unknown; DeadLettered is a durable receiver rejection.

Deduplication window

The outbox journals each send's SentAt and reuses it for every attempt, backpressure retry, and recovery. OutboxRetryTimeout bounds attempts after that time and must be shorter than DeduplicationWindow.

The inbox retains each processed record for DeduplicationWindow after its SentAt, then removes it. Before removing records, it journals an admission floor — its clock minus the window — which only advances. A message the inbox no longer tracks whose SentAt is at or below the floor receives Expired before any sink or handler runs. Recovery and migration preserve the floor, so clock jumps change only when messages expire and how long records are retained.

An expired or locally dead-lettered send can have been accepted before an acknowledgement was lost. Sources receive OutboxMessageRetirementStatus.DeadLettered and can journal reconciliation work; diagnostic dead-letter retention is separate from that application-owned work.

Layer boundary and rationale

This package depends on Orleans.Core.Abstractions and Orleans.Serialization. Journaling, Durable Jobs, the pump, activation integration, and runtime recovery tests arrive in the implementation layers. The generated API surface is committed with the contracts. The new Messaging package is awaiting first NuGet publication; its package inventory entry is labelled accordingly.

Journaled staging is the authority for business changes, message acceptance, retirement, and application follow-up work. Payloads are ordinary GC-owned arrays under immutable publication; copying and deserialization provide the usual mutable-array isolation.

After first NuGet publication

  • Remove the Microsoft.Orleans.Messaging entry from docs/site/src/data/unpublished-api-packages.json and link the package inventory entry to its published NuGet page.

Copilot AI lite review requested due to automatic review settings September 16, 2026 21:43

Copilot AI 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.

Copilot review overview

🟡 Changes recommended

Five unresolved findings remain, including two critical HierarchicalKey issues.

Get a fresh assessment by requesting another Copilot review.

Review effort: Lite
Findings: 2 High severity · 1 Medium severity

Open (3)
What changed in this PR

Adds durable messaging contracts, routing helpers, and Journaling lifecycle support for future inbox/outbox runtime layers.

Changes:

  • Adds envelopes, hierarchical keys, delivery outcomes, options, and inbox/outbox interfaces.
  • Adds routing handlers and Journaling observer/participant support.
  • Adds tests, API surfaces, and project/solution wiring.
File Reviewed change
test/​Orleans.Journaling.Tests/​StateManagerTests.cs Tests observer boundaries and recovery fencing.
test/​Orleans.Journaling.Tests/​JournaledGrainParticipantTests.cs Tests participant lifecycle integration.
test/​Orleans.DurableMessaging.Tests/​Orleans.DurableMessaging.Tests.csproj Configures messaging contract tests.
test/​Orleans.DurableMessaging.Tests/​Contracts/​HierarchicalKeyTests.cs Tests hierarchical key behavior.
test/​Orleans.DurableMessaging.Tests/​Contracts/​HandlerRoutingContractTests.cs Tests routing and typed handlers.
test/​Orleans.DurableMessaging.Tests/​Contracts/​DurableEnvelopeContractTests.cs Tests envelope serialization and validation.
test/​Orleans.DurableMessaging.Tests/​Contracts/​DeliveryAndOptionsContractTests.cs Tests delivery results and options.
src/​Orleans.Journaling/​JournaledStateManager.cs Implements observers and recovery generations.
src/​Orleans.Journaling/​IJournaledStateObserver.cs Defines observer callbacks.
src/​Orleans.Journaling/​IJournaledStateManager.cs Adds observer registration. Finding: moderate (1 vote) — null observers are not validated before the compatibility default throws NotSupportedException.
src/​Orleans.Journaling/​IJournaledGrainParticipant.cs Defines grain participants.
src/​Orleans.Journaling/​DurableGrain.cs Initializes participants during activation.
src/​Orleans.DurableMessaging/​RoutePrefixHandler.cs Implements prefix routing.
src/​Orleans.DurableMessaging/​RouteKeyHandler.cs Implements exact routing.
src/​Orleans.DurableMessaging/​README.md Documents messaging contracts.
src/​Orleans.DurableMessaging/​Orleans.DurableMessaging.csproj Defines the intermediate project.
src/​Orleans.DurableMessaging/​InboxHandlerSelectionContext.cs Provides read-only selection context.
src/​Orleans.DurableMessaging/​InboxHandlerContext.cs Implements handler messaging context.
src/​Orleans.DurableMessaging/​IInboxHandlerContext.cs Defines handler context APIs.
src/​Orleans.DurableMessaging/​IInboxHandler.cs Defines typed and untyped handlers.
src/​Orleans.DurableMessaging/​IDurableOutbox.cs Defines outbox contracts.
src/​Orleans.DurableMessaging/​IDurableInboxExtension.cs Defines the grain delivery extension.
src/​Orleans.DurableMessaging/​IDurableInbox.cs Defines inbox contracts.
src/​Orleans.DurableMessaging/​HierarchicalKey.cs Implements hierarchical correlation keys. Findings: moderate (3 votes) — null overload inputs can produce NullReferenceException; critical (1 vote) — child-key enumeration reverses chain order; critical (1 vote) — HashCode.AddBytes is unavailable for the net8.0 target.
src/​Orleans.DurableMessaging/​DurableEnvelopeData.cs Stores deferred serialized data.
src/​Orleans.DurableMessaging/​DurableEnvelopeBuilder.cs Builds serialized envelopes. Finding: moderate (1 vote) — default SenderId can create an invalid deduplication identity.
src/​Orleans.DurableMessaging/​DurableEnvelope.cs Defines the envelope model.
src/​Orleans.DurableMessaging/​DeliveryStatus.cs Defines delivery statuses.
src/​Orleans.DurableMessaging/​DeliveryResult.cs Defines delivery outcomes.
src/​Orleans.DurableMessaging/​CorrelationHandler.cs Implements correlation routing.
src/​Orleans.DurableMessaging/​Configuration/​DurableInboxOptions.cs Defines validated messaging options.
src/​api/​Orleans.Journaling/​Orleans.Journaling.cs Updates the Journaling API surface.
src/​api/​Orleans.DurableMessaging/​Orleans.DurableMessaging.cs Adds the messaging API surface.
Orleans.slnx Registers source and test projects.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@github-actions

github-actions Bot commented Sep 16, 2026 •

Copy link
Copy Markdown
Contributor

Code coverage

Metric Pull request Current main Variance
Lines 83.76% (119,904 / 143,148) 83.71% (119,367 / 142,602) +0.0559 pp
Branches 73.54% (35,856 / 48,754) 73.39% (35,600 / 48,508) +0.1548 pp

Report-only conclusion: improved.

The current-main baseline is commit 3f42edf1ff and uses the same reviewed coverage matrix.

Coverage combines every CI test matrix job, including providers, CodeGen, .NET 8/10, Linux, Windows, and macOS, using canonical physical source and branch identities.

The comparison remains report-only while normal line and branch variance is calibrated.

Coverage details

Copilot AI 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.

Copilot review overview

🟡 Changes recommended

Unresolved recovery-generation and envelope-builder findings remain.

Get a fresh assessment by requesting another Copilot review.

Review effort: Lite
Findings: 3 High severity · 1 Medium severity

Open (4)
Previously missed (1)

In code that hasn't changed since last review

Low severity Fix article in hierarchical-key validation comment

src/​Orleans.DurableMessaging/​HierarchicalKey.cs:291

The comment uses “an segment separator”, which is grammatically incorrect and makes the validation explanation less clear; change it to “a segment separator”.

Copilot AI review requested due to automatic review settings September 16, 2026 22:43

Copilot AI 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.

Copilot review overview

🟡 Changes recommended

The moderate CorrelationKey nullability issue remains unresolved; the additional comment typo is a minor nit.

Get a fresh assessment by requesting another Copilot review.

Review effort: Lite
Findings: 1 Low severity

Open (1)
Resolved since last review (4)

Copilot AI review requested due to automatic review settings September 16, 2026 23:40

Copilot AI 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.

Copilot review overview

🔵 Needs a closer look

Two moderate validation issues remain in DurableEnvelopeBuilder.

Review effort: Lite
Findings: None

Resolved since last review (2)

Copilot AI review requested due to automatic review settings September 17, 2026 00:42

Copilot AI 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.

Copilot review overview

🟡 Changes recommended

Unresolved contract validation, API compatibility, manifest, and Journaling behavior findings remain.

Get a fresh assessment by requesting another Copilot review.

Review effort: Lite
Findings: 1 High severity · 1 Medium severity

Open (2)

Copilot AI 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.

Copilot review overview

🔵 Needs a closer look

A moderate nullable-correlation API issue remains unresolved across a broad contract and journaling change.

Review effort: Lite
Findings: 1 High severity

Open (1)
Resolved since last review (1)

Copilot AI review requested due to automatic review settings September 17, 2026 03:14

Copilot AI 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.

Copilot review overview

🟡 Changes recommended

Unresolved critical shutdown fault-reporting and moderate contract issues remain.

Get a fresh assessment by requesting another Copilot review.

Review effort: Lite
Findings: 1 High severity

Open (1)
Resolved since last review (1)

Copilot AI review requested due to automatic review settings September 17, 2026 07:12
…s report delivery results

IInboxMessageSink.TryAccept reports a DeliveryResult: Accepted, Duplicate, Backpressured, or DeadLettered. A sink that throws or reports another status dead-letters the message. Sinks are also offered messages with DeliverAt and honor it for the messages they take. A Backpressured result for a self-sent message fails the send.
MessageType<T>.Decode accepts an Envelope. Callers pass InboxMessage.Envelope or OutboxMessage.Envelope.

BREAKING CHANGE: MessageType<T>.Decode(InboxMessage) and Decode(OutboxMessage) are removed.
The grain extension becomes IMessagingExtension with alias IMessagingExtension, so later messaging operations can join it without another extension interface.
…nce pipeline

DurableMessagingOptions.DeliveryResiliencePipeline supplies the Polly pipeline which runs each outbox delivery attempt. Exceptions which leave the pipeline retry durably until MaxOutboxRetryAge without consuming MaxDeliveryAttempts.
… backpressure indefinitely

Remove the default delivery resilience pipeline and its shared circuit breaker; DeliveryResiliencePipeline defaults to ResiliencePipeline.Empty. Failed attempts reschedule durably with exponential backoff while other messages continue. Backpressured retries until the receiver takes the message. HandlerNotFound logs an error and dead-letters after MaxHandlerNotFoundRetryAge (6 hours). Unanswered attempts dead-letter after MaxOutboxRetryAge, which restarts on each receiver answer. MaxDeliveryAttempts is removed.
…erNotFoundRetryAge

OutboxMessageState records a single RetryingSince, which starts with the first failed attempt and restarts on each Backpressured answer. MaxOutboxRetryAge (now 6 hours) bounds both unanswered attempts and HandlerNotFound answers, and dead letters keep the last failure as their reason.
Rename the retry options and state after Temporal's retry policy:

- DurableMessagingOptions.BackpressureRetryDelay becomes RetryInitialInterval.
- DurableMessagingOptions.MaxOutboxRetryAge becomes OutboxRetryTimeout.
- OutboxMessageState.RetryingSince becomes ExpirationTime and stores the
  deadline: the first failed attempt plus OutboxRetryTimeout. A Backpressured
  answer clears it, and the next failed attempt sets a new deadline.

BREAKING CHANGE: BackpressureRetryDelay and MaxOutboxRetryAge are renamed to
RetryInitialInterval and OutboxRetryTimeout.
Orleans string serialization replaces unpaired surrogates with U+FFFD, so distinct hierarchical keys, message IDs, or header names could collapse on the wire. HierarchicalKey construction and parsing now accept only well-formed UTF-16, MessageId inherits the guarantee, and envelope validation rejects header names with unpaired surrogates.
IInboxHandler.CanHandle now receives the InboxMessage instead of the mutable IInboxHandlerContext. A handler can only stage a terminal outcome from HandleAsync, after the inbox selects it.

BREAKING CHANGE: IInboxHandler.CanHandle(IInboxHandlerContext) is replaced by CanHandle(InboxMessage). Orleans.Messaging is alpha and has no package validation baseline.
…ADME

The Envelope field list states that a scheduled message is accepted immediately, processed once the Durable Jobs clock reaches DeliverAt, and does not count toward MaxCapacity.
Matches dotnet#11513: the net10.0 build of Orleans.Messaging runs the trim, NativeAOT, and single-file analyzers. The package builds without IL warnings.
…tracts

Later layers reshaped several L1 contracts, so reviewers of this layer saw a
surface which never ships. Land the final shapes here so the inbox, outbox and
integration layers only implement them:

- A sink exception or unsupported status leaves the outcome unknown, so the
  sender retries; only DeliveryResult.DeadLettered rejects permanently. A
  transient sink fault must not become a permanent rejection.
- DeliveryResult.Code carries an application-defined rejection code, and a
  duplicate of a rejected message receives the original rejection.
- OutboxMessageRetirement is a struct with a status, reason and code, and
  OutboxMessageRetirementStatus distinguishes a receiver's Rejected from the
  outbox's DeadLettered.
- IOutbox.Withdraw makes withdrawal explicit. TryGetMessage returning false
  means "not mine", so a recovered entry which no source provides is
  dead-lettered loudly instead of being deleted as if withdrawn.
- Scheduled messages count toward MaxCapacity from acceptance, so any sender
  can no longer grow a receiver's state without backpressure.
- OutboxRetryTimeout starts when a message is sent, so every attempt starts
  within the timeout of any possible acceptance and redeliveries stay within the
  receiver's DeduplicationWindow, including after a failed removal write.
- Remove DeliveryResiliencePipeline and the Polly dependency. The Durable Jobs
  clock is the single retry and time authority; an in-attempt pipeline ran on a
  second clock, outside OutboxRetryTimeout, and shared breakers across grains.
- OutboxBatchSize bounds attempts in flight rather than a batch barrier, so one
  slow receiver holds one slot.
- Remove MaxProcessingAttempts, which had no consumer.
- The typed batch Send encodes every message before staging any, so an
  encoding failure stages nothing.
…rmat

Deduplication relies on MessageId and HierarchicalKey being injective and
round-tripping through their canonical form, and messages cross silos and persist
in journals. Examples alone cannot catch a collision or an unreviewed format
change, so check the identity laws against an independent segment-list model and
pin every wire type's serialized bytes in both directions.
A delivery runs no grain handler: it validates, deduplicates and stages the
message's acceptance synchronously, then waits for that journal write, and the
pump runs handlers later. Serializing deliveries behind other requests capped a
receiver at one acceptance write per call and deadlocked a request which waits for
a message, such as a durable step awaiting its own cancellation delivery. Mark
DeliverAsync [AlwaysInterleave] so concurrent deliveries share journal writes and
reach a grain whose running request waits for them.
…eiver

An inbox recognizes a redelivery only while it keeps the message's deduplication
record, for DeduplicationWindow after processing. The sender's retry horizon is
OutboxRetryTimeout, which restarts on backpressure, so a receiver whose window is
shorter than the sender's horizon could accept a redelivery as a new message.
Only the receiver knows its window, so the receiver enforces the bound.

InboxMessage.RedeliveryDeadline carries the latest time the sender may deliver
the message again. It belongs to each attempt rather than to the immutable
envelope, because the horizon moves on backpressure and the envelope takes part
in send coalescing. A receiver answers DeadLettered with
DeliveryResult.DeduplicationWindowExceededCode for a deadline beyond its window.
Hash HierarchicalKey ordinally, matching its ordinal equality. Name an absent subject in the dispatcher's no-handler error. The package isn't published yet, so the NuGet page lists it without a link, and the link allowlist drops its entry.
…he deduplication window

Replace the optional RedeliveryDeadline with a required SentAt that every attempt of a logical send reuses. Inboxes retain processed records for DeduplicationWindow after SentAt and journal an admission floor before removing them, so delayed retries of removed messages receive Expired. Records are never retained permanently. OutboxRetryTimeout must again be shorter than DeduplicationWindow so every attempt is admitted.
A delivery whose SentAt is later than the receiver's clock plus DeduplicationWindow is rejected before deduplication without staging anything, so the sender retries it and every record becomes eligible for removal within twice the window. Also clarify that the retry budget ends within the receiver's retention window and replace leftover fencing wording.
…bound

Coordinator decision (Balboa, deciding while the maintainer is away): the inbox rejects a SentAt later than its clock plus DeduplicationWindow only when the message is untracked or the time would extend its record. A tracked message with an equal or earlier SentAt still receives its recorded outcome, so a lost reply followed by a backward receiver clock step is answered Duplicate rather than dead-lettered.
ValidateSubject now rejects unpaired surrogates with the same ArgumentException which header keys raise, naming the subject parameter, rather than relying on the strict UTF-8 encoder's EncoderFallbackException.
The grain interface alias is its wire identity. A bare "IMessagingExtension"
can collide with an application interface of the same alias, so it now uses
the namespace-qualified name, as the package's serializable types do.

Messaging is unshipped, so no persisted or deployed identity depends on the
old alias.
…retention

An attempt in flight when a source withdraws its message still reads the
buffers which TryGetMessage returned, so the source releases them by dropping
references rather than reusing them. Copying every source message per attempt
would cost an allocation the contract avoids, as direct sends already require
unchanged buffers after publication.

Retention docs now distinguish pending messages, which stay until processed,
including scheduled ones, from processed records, which expire with the window.
Main's code generator now emits MayHaveGrainCancellationToken on cancellable invokables; regenerate the Messaging surface after rebasing so it matches the generator's output.

Copilot AI 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.

🟢 Approval recommended

No active review issues block approval of the durable messaging contract changes.

No open issues 🎉

Review effort: Balanced

This branch has not been deployed

No deployments
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