diff --git a/Orleans.slnx b/Orleans.slnx
index e4047b5f194..877afac409d 100644
--- a/Orleans.slnx
+++ b/Orleans.slnx
@@ -42,6 +42,7 @@
+
@@ -152,6 +153,7 @@
+
diff --git a/docs/site/src/content/docs/resources/nuget-packages.md b/docs/site/src/content/docs/resources/nuget-packages.md
index 6858d4ef0c9..157908666a6 100644
--- a/docs/site/src/content/docs/resources/nuget-packages.md
+++ b/docs/site/src/content/docs/resources/nuget-packages.md
@@ -7,7 +7,7 @@ ms.topic: reference
# Orleans NuGet packages
-All official packages use the `Microsoft.Orleans` prefix and are published on [NuGet.org](https://www.nuget.org/profiles/Orleans). Keep Orleans package versions aligned within an application.
+Official packages use the `Microsoft.Orleans` prefix. Find released packages on [NuGet.org](https://www.nuget.org/profiles/Orleans). Keep Orleans package versions aligned within an application.
## Start with a metapackage
@@ -76,6 +76,7 @@ Memory persistence distributes records across cluster storage grains but isn't d
| [Microsoft.Orleans.Reminders.Redis](https://www.nuget.org/packages/Microsoft.Orleans.Reminders.Redis) | Redis reminders. |
| [Microsoft.Orleans.DurableJobs](https://www.nuget.org/packages/Microsoft.Orleans.DurableJobs) | Distributed scheduling for durable one-time jobs. |
| [Microsoft.Orleans.DurableJobs.AzureStorage](https://www.nuget.org/packages/Microsoft.Orleans.DurableJobs.AzureStorage) | Azure Blob Storage for durable jobs. |
+| `Microsoft.Orleans.Messaging` | Experimental grain-scoped inbox and outbox messaging contracts, awaiting their first NuGet publication. |
Use reminders for recurring durable callbacks and durable jobs for scheduled one-time work. Grain timers are activation-scoped and use the core runtime rather than a provider package.
diff --git a/docs/site/src/data/unpublished-api-packages.json b/docs/site/src/data/unpublished-api-packages.json
index 64d185c77e2..49cf020943e 100644
--- a/docs/site/src/data/unpublished-api-packages.json
+++ b/docs/site/src/data/unpublished-api-packages.json
@@ -1,4 +1,6 @@
{
"description": "Generated API assemblies which are not currently published as standalone NuGet packages.",
- "packages": {}
+ "packages": {
+ "Microsoft.Orleans.Messaging": "The new package is awaiting its first NuGet publication."
+ }
}
diff --git a/src/Orleans.Core/Orleans.Core.csproj b/src/Orleans.Core/Orleans.Core.csproj
index 303f4a85626..9d993320826 100644
--- a/src/Orleans.Core/Orleans.Core.csproj
+++ b/src/Orleans.Core/Orleans.Core.csproj
@@ -76,6 +76,7 @@
+
diff --git a/src/Orleans.Journaling/DurableDictionary.cs b/src/Orleans.Journaling/DurableDictionary.cs
index 8aeed395024..83f3ddd8ddb 100644
--- a/src/Orleans.Journaling/DurableDictionary.cs
+++ b/src/Orleans.Journaling/DurableDictionary.cs
@@ -16,7 +16,7 @@ public interface IDurableDictionary : IDictionary wh
[DebuggerTypeProxy(typeof(IDurableDictionaryDebugView<,>))]
[DebuggerDisplay("Count = {Count}")]
-internal class DurableDictionary : IDurableDictionary, IStateMachine, IDurableDictionaryCommandHandler where TKey : notnull
+internal class DurableDictionary : IDurableDictionary, IStateMachine, IReportsStagedChanges, IDurableDictionaryCommandHandler where TKey : notnull
{
private readonly IDurableDictionaryCommandCodec _codec;
private readonly Dictionary _items = [];
diff --git a/src/Orleans.Journaling/DurableList.cs b/src/Orleans.Journaling/DurableList.cs
index 57ede9747db..6b46bca9897 100644
--- a/src/Orleans.Journaling/DurableList.cs
+++ b/src/Orleans.Journaling/DurableList.cs
@@ -27,7 +27,7 @@ public interface IDurableList : IList
[DebuggerTypeProxy(typeof(IDurableCollectionDebugView<>))]
[DebuggerDisplay("Count = {Count}")]
-internal sealed class DurableList : IDurableList, IStateMachine, IDurableListCommandHandler
+internal sealed class DurableList : IDurableList, IStateMachine, IReportsStagedChanges, IDurableListCommandHandler
{
private readonly IDurableListCommandCodec _codec;
private readonly List _items = [];
diff --git a/src/Orleans.Journaling/DurableQueue.cs b/src/Orleans.Journaling/DurableQueue.cs
index f24a2543e3e..49ddf0cb0ca 100644
--- a/src/Orleans.Journaling/DurableQueue.cs
+++ b/src/Orleans.Journaling/DurableQueue.cs
@@ -65,7 +65,7 @@ public interface IDurableQueue : IEnumerable, IReadOnlyCollection
[DebuggerTypeProxy(typeof(DurableQueueDebugView<>))]
[DebuggerDisplay("Count = {Count}")]
-internal sealed class DurableQueue : IDurableQueue, IStateMachine, IDurableQueueCommandHandler
+internal sealed class DurableQueue : IDurableQueue, IStateMachine, IReportsStagedChanges, IDurableQueueCommandHandler
{
private readonly IDurableQueueCommandCodec _codec;
private readonly Queue _items = new();
diff --git a/src/Orleans.Journaling/DurableSet.cs b/src/Orleans.Journaling/DurableSet.cs
index 8efd87b4502..a883e4c397f 100644
--- a/src/Orleans.Journaling/DurableSet.cs
+++ b/src/Orleans.Journaling/DurableSet.cs
@@ -40,7 +40,7 @@ public interface IDurableSet : ISet, IReadOnlyCollection, IReadOnlySet<
[DebuggerTypeProxy(typeof(IDurableCollectionDebugView<>))]
[DebuggerDisplay("Count = {Count}")]
-internal sealed class DurableSet : IDurableSet, IStateMachine, IDurableSetCommandHandler
+internal sealed class DurableSet : IDurableSet, IStateMachine, IReportsStagedChanges, IDurableSetCommandHandler
{
private readonly IDurableSetCommandCodec _codec;
private readonly HashSet _items = [];
diff --git a/src/Orleans.Journaling/DurableTaskCompletionSource.cs b/src/Orleans.Journaling/DurableTaskCompletionSource.cs
index 760d65ca000..af37e01bdcf 100644
--- a/src/Orleans.Journaling/DurableTaskCompletionSource.cs
+++ b/src/Orleans.Journaling/DurableTaskCompletionSource.cs
@@ -42,7 +42,7 @@ public interface IDurableTaskCompletionSource
}
[DebuggerDisplay("Status = {Status}")]
-internal sealed class DurableTaskCompletionSource : IDurableTaskCompletionSource, IStateMachine, IDurableTaskCompletionSourceCommandHandler
+internal sealed class DurableTaskCompletionSource : IDurableTaskCompletionSource, IStateMachine, IReportsStagedChanges, IDurableTaskCompletionSourceCommandHandler
{
private readonly IDurableTaskCompletionSourceCommandCodec _codec;
private readonly DeepCopier _copier;
@@ -52,6 +52,7 @@ internal sealed class DurableTaskCompletionSource : IDurableTaskCompletionSou
private DurableTaskCompletionSourceStatus _status;
private T? _value;
private Exception? _exception;
+ private JournalStreamWriter _writer;
public DurableTaskCompletionSource(
[ServiceKey] string key,
@@ -90,6 +91,7 @@ public bool TrySetResult(T value)
}
_status = DurableTaskCompletionSourceStatus.Completed;
+ _writer.MarkModified();
_value = _copier.Copy(value);
return true;
}
@@ -102,6 +104,7 @@ public bool TrySetException(Exception exception)
}
_status = DurableTaskCompletionSourceStatus.Faulted;
+ _writer.MarkModified();
_exception = _exceptionCopier.Copy(exception);
return true;
}
@@ -114,6 +117,7 @@ public bool TrySetCanceled()
}
_status = DurableTaskCompletionSourceStatus.Canceled;
+ _writer.MarkModified();
return true;
}
@@ -154,6 +158,7 @@ private void OnValuePersisted()
void IStateMachine.Reset(JournalStreamWriter writer)
{
+ _writer = writer;
_status = DurableTaskCompletionSourceStatus.Pending;
_value = default;
_exception = null;
@@ -198,12 +203,14 @@ private void WriteState(JournalStreamWriter writer)
void IDurableTaskCompletionSourceCommandHandler.ApplyCompleted(T value)
{
_status = DurableTaskCompletionSourceStatus.Completed;
+ _writer.MarkModified();
_value = value;
}
void IDurableTaskCompletionSourceCommandHandler.ApplyFaulted(Exception exception)
{
_status = DurableTaskCompletionSourceStatus.Faulted;
+ _writer.MarkModified();
_exception = exception;
}
diff --git a/src/Orleans.Journaling/DurableValue.cs b/src/Orleans.Journaling/DurableValue.cs
index cc6a39614ad..caaeeaba4ab 100644
--- a/src/Orleans.Journaling/DurableValue.cs
+++ b/src/Orleans.Journaling/DurableValue.cs
@@ -16,11 +16,12 @@ public interface IDurableValue
}
[DebuggerDisplay("{Value}")]
-internal sealed class DurableValue : IDurableValue, IStateMachine, IDurableValueCommandHandler
+internal sealed class DurableValue : IDurableValue, IStateMachine, IReportsStagedChanges, IDurableValueCommandHandler
{
private readonly IDurableValueCommandCodec _codec;
private T? _value;
private bool _isDirty;
+ private JournalStreamWriter _writer;
public DurableValue(
[ServiceKey] string key,
@@ -54,7 +55,11 @@ public T? Value
private void OnValuePersisted() => OnPersisted?.Invoke();
- public void OnModified() => _isDirty = true;
+ public void OnModified()
+ {
+ _isDirty = true;
+ _writer.MarkModified();
+ }
void IStateMachine.ReplayEntry(JournalEntry entry, JournalReplayContext context) =>
context.GetRequiredCommandCodec(entry.FormatKey, _codec).Apply(entry.Reader, this);
@@ -64,6 +69,7 @@ void IStateMachine.ReplayEntry(JournalEntry entry, JournalReplayContext context)
void IStateMachine.Reset(JournalStreamWriter writer)
{
+ _writer = writer;
_value = default;
_isDirty = false;
}
diff --git a/src/Orleans.Journaling/IJournaledStateManager.cs b/src/Orleans.Journaling/IJournaledStateManager.cs
index 13d369ecacf..c56ae37188d 100644
--- a/src/Orleans.Journaling/IJournaledStateManager.cs
+++ b/src/Orleans.Journaling/IJournaledStateManager.cs
@@ -52,6 +52,9 @@ public interface IJournaledStateManager : IAsyncDisposable
/// Stage mutations only after establishing that they are safe to commit. Pending changes are shared
/// by all callers using this manager. Storage acknowledgement establishes durability.
/// A failed journal operation fences the manager; recovery requires a new manager and state machine instances.
+ /// Before capture, the manager prepares each registered state through .
+ /// A preparation failure reports and retains pending changes
+ /// for an explicit retry.
/// Cancellation stops the caller's wait; an already queued write continues to its storage outcome.
///
/// The token used to cancel the caller's wait.
@@ -81,4 +84,5 @@ public interface IJournaledStateManager : IAsyncDisposable
/// concurrent writers and should not be used for correctness decisions.
///
long PendingWriteByteCount => -1;
+
}
diff --git a/src/Orleans.Journaling/IReportsStagedChanges.cs b/src/Orleans.Journaling/IReportsStagedChanges.cs
new file mode 100644
index 00000000000..e183c3a3b26
--- /dev/null
+++ b/src/Orleans.Journaling/IReportsStagedChanges.cs
@@ -0,0 +1,12 @@
+namespace Orleans.Journaling;
+
+///
+/// Marks a state whose every change advances : the state commits a
+/// journal entry when the change is made, or calls .
+///
+///
+/// A state which journals changes it can't observe, such as an object mutated in place and serialized whole when a write
+/// captures it, doesn't implement this interface. While one is registered, the manager can't tell whether a state staged
+/// a change, so is negative.
+///
+internal interface IReportsStagedChanges;
diff --git a/src/Orleans.Journaling/IStateMachine.cs b/src/Orleans.Journaling/IStateMachine.cs
index bf3fd61204e..5f1cde9828e 100644
--- a/src/Orleans.Journaling/IStateMachine.cs
+++ b/src/Orleans.Journaling/IStateMachine.cs
@@ -15,6 +15,7 @@ namespace Orleans.Journaling;
///
/// -
/// When the application requests a write, the journaled state manager calls
+/// to establish the state's persistence prerequisites, then
/// (and occasionally ) to materialize
/// the pending changes, then flushes the journal to durable storage.
///
@@ -68,6 +69,25 @@ public interface IStateMachine
///
void OnRecoveryCompleted() { }
+ ///
+ /// Establishes the persistence prerequisites for this state's pending changes.
+ ///
+ /// The token which cancels preparation when the journal owner stops.
+ /// A completion representing readiness to capture this state.
+ ///
+ /// The manager prepares every registered state before capturing each append or snapshot, whether or not the state
+ /// has pending changes, so a state can establish prerequisites for changes which other states hold, or fail a write
+ /// whose pending changes must not commit. Preparation is idempotent. After asynchronous
+ /// preparation completes, the manager repeats preparation for all states to include changes
+ /// staged on interleaved turns. Prepared prerequisites complete synchronously on subsequent calls
+ /// and remain valid through capture. A complete synchronous pass flows directly into capture.
+ /// Preparation completes without awaiting another operation on its own journal manager,
+ /// since that operation is serialized behind the current write.
+ /// A failure reports and retains pending changes
+ /// for an explicit write retry.
+ ///
+ ValueTask PrepareWriteAsync(CancellationToken cancellationToken = default) => ValueTask.CompletedTask;
+
///
/// Writes pending state changes to the journal.
///
diff --git a/src/Orleans.Journaling/JournalBufferWriter.cs b/src/Orleans.Journaling/JournalBufferWriter.cs
index 60a745e0d98..316442e34e4 100644
--- a/src/Orleans.Journaling/JournalBufferWriter.cs
+++ b/src/Orleans.Journaling/JournalBufferWriter.cs
@@ -24,6 +24,7 @@ public abstract class JournalBufferWriter : IDisposable, IBufferWriter
#endif
private readonly ArcBufferWriter _buffer = new();
private int _committedLength;
+ private long _stagedChangeCount;
private bool _hasActiveEntry;
private bool _disposed;
@@ -269,6 +270,28 @@ internal void AppendPreservedEntry(JournalStreamId streamId, IPreservedJournalEn
}
}
+ ///
+ /// Gets or sets a count which advances whenever a state stages a change and never decreases between writes.
+ ///
+ /// The manager restores it after a write captures pending changes, which stages nothing new.
+ internal long StagedChangeCount
+ {
+ get
+ {
+ lock (_lock) return _stagedChangeCount;
+ }
+ set
+ {
+ lock (_lock) _stagedChangeCount = value;
+ }
+ }
+
+ /// Records a change which a state stages in memory and journals when a write captures it.
+ internal void MarkModified()
+ {
+ lock (_lock) _stagedChangeCount++;
+ }
+
internal void CommitActiveEntry(JournalStreamId streamId)
{
lock (_lock)
@@ -279,6 +302,7 @@ internal void CommitActiveEntry(JournalStreamId streamId)
{
FinishEntry(streamId);
_committedLength = _buffer.Length;
+ _stagedChangeCount++;
}
catch
{
diff --git a/src/Orleans.Journaling/JournalStreamWriter.cs b/src/Orleans.Journaling/JournalStreamWriter.cs
index f5895bd7b7d..2ffdf8fca02 100644
--- a/src/Orleans.Journaling/JournalStreamWriter.cs
+++ b/src/Orleans.Journaling/JournalStreamWriter.cs
@@ -26,6 +26,9 @@ internal JournalStreamWriter(JournalStreamId id, JournalBufferWriter writer)
internal bool IsInitialized => _writer is not null;
+ /// Records a change which the state journals when a write captures it.
+ internal void MarkModified() => _writer?.MarkModified();
+
///
/// Appends a format-owned entry for retired or unknown state preservation.
///
diff --git a/src/Orleans.Journaling/JournaledStateManager.cs b/src/Orleans.Journaling/JournaledStateManager.cs
index 3f69bc77186..7cd4b99dd14 100644
--- a/src/Orleans.Journaling/JournaledStateManager.cs
+++ b/src/Orleans.Journaling/JournaledStateManager.cs
@@ -18,6 +18,9 @@ internal partial class JournaledStateManager : IJournaledStateManager, IJournalS
#endif
private readonly Dictionary _states = new(StringComparer.Ordinal);
private readonly Dictionary _statesMap = [];
+
+ // Whether a registered state journals changes which StagedChangeCount can't observe.
+ private bool _hasUnreportedChanges;
private readonly JournaledStateManagerShared _shared;
private readonly IJournalStorage _storage;
private readonly IGrainContext? _grainContext;
@@ -147,6 +150,7 @@ public void RegisterStateMachine(string name, IStateMachine stateMachine)
}
_states.Add(name, stateMachine);
+ if (stateMachine is not IReportsStagedChanges) _hasUnreportedChanges = true;
_workQueue.Enqueue(new RegisterStateWorkItem(name));
}
@@ -282,6 +286,31 @@ private async Task WorkLoop()
"The journaled state operation was queued before deletion reset its state.");
}
+ if (workItem is AppendJournalWorkItem or WriteSnapshotWorkItem)
+ {
+ try
+ {
+ for (var prepared = false; !prepared;)
+ {
+ await PrepareStates(ref prepared, _shutdownCancellation.Token).ConfigureAwait(true);
+ }
+
+ _shutdownCancellation.Token.ThrowIfCancellationRequested();
+ }
+ catch (OperationCanceledException) when (_shutdownCancellation.IsCancellationRequested)
+ {
+ workItem.TrySetCanceled(_shutdownCancellation.Token);
+ return;
+ }
+ catch (Exception exception)
+ {
+ LogStatePreparationFailed(_shared.Logger, exception);
+ storageActivity?.SetStatus(ActivityStatusCode.Error, "Journal state preparation failed.");
+ workItem.SetException(new JournaledStatePreCommitException(exception));
+ continue;
+ }
+ }
+
// Note that the implementation of each command is inlined to avoid allocating unnecessary async states.
// We are ok sacrificing some code organization for performance in the inner loop.
switch (workItem)
@@ -317,6 +346,8 @@ private async Task WorkLoop()
lock (_lock)
{
+ // Capture journals changes which states already staged, so it doesn't count as staging.
+ var stagedChangeCount = _journalWriter.StagedChangeCount;
if (isSnapshot)
{
using var snapshotWriter = _shared.JournalFormat.CreateWriter();
@@ -402,6 +433,8 @@ private async Task WorkLoop()
hasBufferToConsume = true;
}
}
+
+ _journalWriter.StagedChangeCount = stagedChangeCount;
}
_shared.Instruments.OnGather(
@@ -599,6 +632,28 @@ private async Task WorkLoop()
}
}
+ // Completes when every state is synchronously ready; otherwise returns the first incomplete preparation.
+ private ValueTask PrepareStates(ref bool prepared, CancellationToken cancellationToken)
+ {
+ prepared = true;
+ lock (_lock)
+ {
+ foreach (var state in _states.Values)
+ {
+ var preparation = state.PrepareWriteAsync(cancellationToken);
+ if (!preparation.IsCompletedSuccessfully)
+ {
+ prepared = false;
+ return preparation;
+ }
+
+ preparation.GetAwaiter().GetResult();
+ }
+ }
+
+ return ValueTask.CompletedTask;
+ }
+
private void Fence(Exception exception)
{
lock (_lock)
@@ -1057,6 +1112,18 @@ private void BindState(string name, uint id)
public long PendingWriteByteCount => _journalWriter.CommittedLength;
+ ///
+ /// Gets a count which advances whenever a registered state stages a change and never decreases, or a negative value
+ /// when a registered state doesn't report its changes.
+ ///
+ ///
+ /// Every state which implements advances it when it is modified. A write captures
+ /// changes without advancing it. Comparing two readings taken on the activation's scheduler shows whether any state
+ /// staged a change between them. Orleans.Messaging uses it to tell whether a failed handler attempt staged changes,
+ /// and treats a negative value as staged.
+ ///
+ internal long StagedChangeCount => _hasUnreportedChanges ? -1 : _journalWriter.StagedChangeCount;
+
void ILifecycleParticipant.Participate(IGrainLifecycle observer)
=> observer.Subscribe(GrainLifecycleStage.SetupState, this);
@@ -1327,7 +1394,7 @@ private sealed class RetiredStateTracker(
/// This keeps buffering entries and dumps them back into the journal upon compaction.
///
[DebuggerDisplay("RetiredStateMachine Id = {StreamId.Value}")]
- private sealed class RetiredStateMachine(JournalStreamId streamId) : IStateMachine
+ private sealed class RetiredStateMachine(JournalStreamId streamId) : IStateMachine, IReportsStagedChanges
{
private readonly List _preservedEntries = [];
@@ -1355,6 +1422,11 @@ void IStateMachine.WritePendingEntries(JournalStreamWriter writer) { }
Message = "Error processing work items.")]
private static partial void LogErrorProcessingWorkItems(ILogger logger, Exception exception);
+ [LoggerMessage(
+ Level = LogLevel.Error,
+ Message = "Journal state preparation failed; pending changes remain staged.")]
+ private static partial void LogStatePreparationFailed(ILogger logger, Exception exception);
+
[LoggerMessage(
Level = LogLevel.Information,
Message = "State \"{Name}\" was not found. I have substituted a placeholder for graceful time-based retirement.")]
diff --git a/src/Orleans.Journaling/JournaledStatePreCommitException.cs b/src/Orleans.Journaling/JournaledStatePreCommitException.cs
new file mode 100644
index 00000000000..55a0cbbbae5
--- /dev/null
+++ b/src/Orleans.Journaling/JournaledStatePreCommitException.cs
@@ -0,0 +1,21 @@
+namespace Orleans.Journaling;
+
+///
+/// Reports a state preparation failure which prevented a journal write.
+///
+///
+/// Pending changes remain staged and safe to commit. The caller can restore the prerequisite
+/// and explicitly retry persistence, or retire its owner and recover from durable state.
+///
+[GenerateSerializer]
+public sealed class JournaledStatePreCommitException : Exception
+{
+ ///
+ /// Initializes a state preparation failure.
+ ///
+ /// The original preparation failure.
+ public JournaledStatePreCommitException(Exception innerException)
+ : base("The journal write was prevented by a state preparation failure.", innerException)
+ {
+ }
+}
diff --git a/src/Orleans.Journaling/Orleans.Journaling.csproj b/src/Orleans.Journaling/Orleans.Journaling.csproj
index 7af1350de12..30a0adad54b 100644
--- a/src/Orleans.Journaling/Orleans.Journaling.csproj
+++ b/src/Orleans.Journaling/Orleans.Journaling.csproj
@@ -27,6 +27,8 @@
+
+
diff --git a/src/Orleans.Journaling/README.md b/src/Orleans.Journaling/README.md
index 09c18463568..395a57a95e8 100644
--- a/src/Orleans.Journaling/README.md
+++ b/src/Orleans.Journaling/README.md
@@ -207,15 +207,29 @@ provider and requested state name.
- `Reset`: reset in-memory state and bind a `JournalStreamWriter`.
- `ReplayEntry`: reconstruct state from a recorded operation.
+- `PrepareWriteAsync`: establish asynchronous persistence prerequisites for pending state.
- `WritePendingEntries`: emit pending operations into the supplied writer.
- `WriteSnapshot`: emit a reconstructible snapshot into the supplied writer.
- `OnRecoveryCompleted`: finish reconstruction before application use.
- `OnWriteCompleted`: publish effects that depend on storage acknowledgement.
+Command-backed states can use `IDurableValueCommandCodec` to encode a
+complete command before applying it locally. A snapshot is a command in the same
+application-defined schema which reconstructs the whole state. Annotate command data
+for Orleans serialization and supply System.Text.Json metadata when using the JSON codec.
+
Recovery resets state machines and replays durable entries. A failed initialization can be retried on the
same manager and registered states. `JournalReplayContext.ResolveStateMachine` routes entries to the
state machine for their stream.
+Write preparation runs before append or snapshot capture.
+Each state prepares its own prerequisites idempotently. An asynchronous completion
+leaves prepared prerequisites synchronously ready; the manager then rechecks all states
+to include mutations staged on interleaved turns. A complete synchronous pass flows
+directly into capture. Preparation failures retain pending changes for an explicit
+write retry and report `JournaledStatePreCommitException`. The owner's shutdown token
+cancels preparation, and disposal drains its actual completion.
+
`IJournaledStateManager` is independent of the grain-facing `IDurableStateManager` and extends
`IAsyncDisposable`. Its owner API provides `RegisterStateMachine`, `TryGetStateMachine`,
`InitializeAsync`, `WriteStateAsync`, whole-journal `DeleteStateAsync`, and `PendingWriteByteCount`.
diff --git a/src/Orleans.Messaging/Configuration/DurableMessagingOptions.cs b/src/Orleans.Messaging/Configuration/DurableMessagingOptions.cs
new file mode 100644
index 00000000000..fb1e04c7bef
--- /dev/null
+++ b/src/Orleans.Messaging/Configuration/DurableMessagingOptions.cs
@@ -0,0 +1,258 @@
+using System;
+
+namespace Orleans.Messaging.Configuration;
+
+///
+/// Configuration options for durable messaging inboxes, outboxes, and the messaging pump.
+///
+///
+///
+/// These options control inbox capacity, deduplication tracking, inbox and outbox retry and
+/// dead-letter behavior, and pump batch sizes.
+///
+///
+/// Transport is at-least-once. Retained processed records and a durable admission floor deduplicate logical sends.
+/// Configuration values affect delivery availability, memory usage, throughput, and recovery characteristics.
+///
+///
+public class DurableMessagingOptions
+{
+ internal const int MaximumBackoffExponent = 6;
+ internal const int MaximumBackoffMultiplier = 1 << MaximumBackoffExponent;
+
+ // The runtime multiplies this base delay by at most MaximumBackoffMultiplier.
+ // Keep the expanded delay within the timer implementation's uint-millisecond limit.
+ private static readonly TimeSpan MaxSupportedRetryDelay =
+ TimeSpan.FromTicks(
+ TimeSpan.FromMilliseconds(uint.MaxValue - 1).Ticks
+ / MaximumBackoffMultiplier);
+
+ ///
+ /// Gets or sets the maximum number of pending inbox messages.
+ /// When this limit is reached, new deliveries return DeliveryResult.Backpressured().
+ ///
+ ///
+ ///
+ /// A lower value (e.g., 100) provides stronger backpressure but may reduce throughput.
+ /// A higher value (e.g., 10,000) allows more buffering but increases memory usage and recovery time.
+ ///
+ ///
+ /// The inbox capacity is checked before accepting new messages. Messages are persisted to durable
+ /// storage, so capacity limits affect both in-memory state and storage I/O during recovery.
+ ///
+ ///
+ /// A message with an time counts toward this limit from its acceptance until it
+ /// completes, so size the limit for the messages a grain holds scheduled as well as those it is processing.
+ ///
+ ///
+ /// Messages a grain sends to itself are accepted regardless of this limit, because sending is synchronous. They count
+ /// toward it until they complete, so they can cause deliveries from other grains to be backpressured.
+ ///
+ ///
+ ///
+ /// The maximum inbox capacity. Must be greater than zero. Defaults to 1000.
+ ///
+ public int MaxCapacity { get; set; } = 1000;
+
+ ///
+ /// Gets or sets how long after a message's send time the receiving inbox retains its processed record and admits
+ /// deliveries of it. A tracked processed message is answered with DeliveryResult.Duplicate(), or, when an
+ /// inbox sink rejected it on delivery, with the same DeliveryResult.DeadLettered reason and code.
+ ///
+ ///
+ ///
+ /// A longer window (e.g., 30 days) retains more processed records and admits older sends, increasing
+ /// memory usage and storage I/O. A shorter window (e.g., 1 hour) expires older sends sooner.
+ ///
+ ///
+ /// The inbox advances a journaled admission floor to its current clock minus this window; the floor only advances
+ /// and survives recovery and migration. Pending messages stay until they are processed. A processed record becomes
+ /// eligible for removal once its is at or below the durable floor. A message the
+ /// inbox doesn't track whose send time is at or below the floor receives ,
+ /// including after a backward clock jump. The inbox never accepts a delivered send time later than its clock plus
+ /// this window: it rejects such a delivery without staging anything when the message is untracked and above the
+ /// admission floor, or the time would extend its record. A grain's messages to itself use its clock, or the first time above the floor after a backward
+ /// clock jump. Every processed record therefore becomes eligible for removal within twice this window of the
+ /// receiver's clock, or just after the floor when a self-send follows a backward jump.
+ ///
+ ///
+ /// Clock jumps and skew change when messages expire and how long records are retained; deduplication follows the
+ /// journaled floor and each send's fixed time. Size this window to exceed by the
+ /// delivery delays and clock variation the application tolerates, and configure it uniformly across the cluster.
+ ///
+ ///
+ ///
+ /// The deduplication window. Must be greater than . Defaults to 7 days.
+ ///
+ public TimeSpan DeduplicationWindow { get; set; } = TimeSpan.FromDays(7);
+
+ ///
+ /// Gets or sets the delay before the first retry of a failed outbox delivery or inbox processing attempt.
+ ///
+ ///
+ ///
+ /// The outbox doubles this interval after each failed delivery attempt, including when the target inbox returns
+ /// DeliveryResult.Backpressured(), up to 64 times this interval. The inbox applies the same backoff after a
+ /// handler fails.
+ ///
+ ///
+ /// A shorter delay (e.g., 100ms) enables faster recovery when the target processes messages quickly,
+ /// but may increase CPU usage during sustained backpressure. A longer delay (e.g., 5 seconds)
+ /// reduces retry overhead but increases latency for message delivery.
+ ///
+ ///
+ /// For high-throughput scenarios where quick recovery from backpressure is important,
+ /// consider values between 100-500ms. For less time-sensitive workloads, 1-5 seconds is appropriate.
+ ///
+ ///
+ ///
+ /// The initial retry interval. Must be greater than zero. Defaults to 1 second.
+ ///
+ public TimeSpan RetryInitialInterval { get; set; } = TimeSpan.FromSeconds(1);
+
+ ///
+ /// Gets or sets how long the inbox retries a message whose handler attempts throw before the message is
+ /// dead-lettered.
+ ///
+ ///
+ /// The timeout starts with the first failed attempt, and the last attempt runs when it elapses. A handler exception
+ /// leaves the message's outcome unknown, so retries span transient faults, such as outages and deployments which
+ /// fix a bug; a handler calls IInboxHandlerContext.Fail to dead-letter a message at once. A retrying message
+ /// keeps its place in . The timeout and retry backoff follow the Durable Jobs clock, which
+ /// schedules the wake-up for each retry.
+ ///
+ ///
+ /// Must be greater than or equal to , which dead-letters a message at its first failure.
+ /// Defaults to 6 hours.
+ ///
+ public TimeSpan InboxRetryTimeout { get; set; } = TimeSpan.FromHours(6);
+
+ ///
+ /// Gets or sets how long after a message's send time the outbox attempts delivery before it dead-letters the
+ /// message, including while the receiver is backpressured or reports no inbox handler.
+ ///
+ ///
+ ///
+ /// The outbox journals the message's when it stages the send; retries,
+ /// backpressure, and recovery keep it. The timeout follows the Durable Jobs clock; a jump can end retries early or
+ /// prolong them. A timeout or leaves the receiver's earlier delivery outcome
+ /// unknown. A source's stages application responses atomically with
+ /// retirement; see .
+ ///
+ ///
+ /// The outbox logs each DeliveryResult.HandlerNotFound() answer as an error.
+ ///
+ ///
+ ///
+ /// Must be greater than zero and less than , so the retry budget ends within the
+ /// receiver's retention window. Defaults to 6 hours.
+ ///
+ public TimeSpan OutboxRetryTimeout { get; set; } = TimeSpan.FromHours(6);
+
+ ///
+ /// Gets or sets how long inbox and outbox dead letters are retained.
+ ///
+ public TimeSpan DeadLetterRetentionPeriod { get; set; } = TimeSpan.FromDays(30);
+
+ ///
+ /// Gets or sets the maximum number of dead letters retained per inbox and per outbox.
+ ///
+ public int MaxRetainedDeadLetters { get; set; } = 1000;
+
+ ///
+ /// Gets or sets the maximum number of inbox messages processed by one messaging pump pass.
+ ///
+ public int InboxBatchSize { get; set; } = 32;
+
+ ///
+ /// Gets or sets the maximum number of outbox delivery attempts in flight for one grain.
+ ///
+ ///
+ /// The pump starts another attempt as soon as one completes, so a slow receiver holds one slot without delaying
+ /// messages to other receivers.
+ ///
+ public int OutboxBatchSize { get; set; } = 32;
+
+ ///
+ /// Validates the configuration values and throws if any are invalid.
+ ///
+ ///
+ /// Thrown if is less than or equal to zero,
+ /// or if is less than or equal to ,
+ /// or if a retry, retention, or batch option is outside its supported range.
+ ///
+
+ ///
+ /// This method is typically called by the dependency injection container during service registration
+ /// to ensure configuration values are valid before the system starts.
+ ///
+ public void Validate()
+ {
+
+ if (MaxCapacity <= 0)
+ {
+ throw new ArgumentOutOfRangeException(nameof(MaxCapacity), MaxCapacity, "MaxCapacity must be greater than zero.");
+ }
+
+ if (DeduplicationWindow <= TimeSpan.Zero)
+ {
+ throw new ArgumentOutOfRangeException(nameof(DeduplicationWindow), DeduplicationWindow, "DeduplicationWindow must be greater than TimeSpan.Zero.");
+ }
+
+ if (RetryInitialInterval <= TimeSpan.Zero)
+ {
+ throw new ArgumentOutOfRangeException(nameof(RetryInitialInterval), RetryInitialInterval, "RetryInitialInterval must be greater than TimeSpan.Zero.");
+ }
+ if (RetryInitialInterval > MaxSupportedRetryDelay)
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(RetryInitialInterval),
+ RetryInitialInterval,
+ $"RetryInitialInterval must be less than or equal to {MaxSupportedRetryDelay}.");
+ }
+
+ if (InboxRetryTimeout < TimeSpan.Zero)
+ {
+ throw new ArgumentOutOfRangeException(nameof(InboxRetryTimeout), InboxRetryTimeout, "InboxRetryTimeout must be greater than or equal to TimeSpan.Zero.");
+ }
+
+ if (OutboxRetryTimeout <= TimeSpan.Zero)
+ {
+ throw new ArgumentOutOfRangeException(nameof(OutboxRetryTimeout), OutboxRetryTimeout, "OutboxRetryTimeout must be greater than TimeSpan.Zero.");
+ }
+
+ if (OutboxRetryTimeout >= DeduplicationWindow)
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(OutboxRetryTimeout),
+ OutboxRetryTimeout,
+ "OutboxRetryTimeout must be less than DeduplicationWindow.");
+ }
+
+ if (DeadLetterRetentionPeriod <= TimeSpan.Zero)
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(DeadLetterRetentionPeriod),
+ DeadLetterRetentionPeriod,
+ "DeadLetterRetentionPeriod must be greater than TimeSpan.Zero.");
+ }
+
+ if (MaxRetainedDeadLetters <= 0)
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(MaxRetainedDeadLetters),
+ MaxRetainedDeadLetters,
+ "MaxRetainedDeadLetters must be greater than zero.");
+ }
+
+ if (InboxBatchSize <= 0)
+ {
+ throw new ArgumentOutOfRangeException(nameof(InboxBatchSize), InboxBatchSize, "InboxBatchSize must be greater than zero.");
+ }
+
+ if (OutboxBatchSize <= 0)
+ {
+ throw new ArgumentOutOfRangeException(nameof(OutboxBatchSize), OutboxBatchSize, "OutboxBatchSize must be greater than zero.");
+ }
+ }
+}
diff --git a/src/Orleans.Messaging/DeadLetterRetention.cs b/src/Orleans.Messaging/DeadLetterRetention.cs
new file mode 100644
index 00000000000..e2388a608e9
--- /dev/null
+++ b/src/Orleans.Messaging/DeadLetterRetention.cs
@@ -0,0 +1,41 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace Orleans.Messaging;
+
+internal static class DeadLetterRetention
+{
+ public static bool Compact(
+ IDictionary entries,
+ DateTimeOffset now,
+ TimeSpan retentionPeriod,
+ int maxRetainedEntries,
+ Func getTimestamp,
+ int reservedCapacity = 0)
+ where TKey : notnull
+ {
+ var removed = false;
+ foreach (var entry in entries
+ .Where(entry => MessagingTime.IsExpired(now, getTimestamp(entry.Value), retentionPeriod))
+ .ToList())
+ {
+ entries.Remove(entry.Key);
+ removed = true;
+ }
+
+ var removeCount = entries.Count + reservedCapacity - maxRetainedEntries;
+ if (removeCount <= 0)
+ {
+ return removed;
+ }
+
+ foreach (var entry in entries.OrderBy(entry => getTimestamp(entry.Value)).Take(removeCount).ToList())
+ {
+ entries.Remove(entry.Key);
+ removed = true;
+ }
+
+ return removed;
+ }
+}
diff --git a/src/Orleans.Messaging/DeliveryResult.cs b/src/Orleans.Messaging/DeliveryResult.cs
new file mode 100644
index 00000000000..3b250362c4b
--- /dev/null
+++ b/src/Orleans.Messaging/DeliveryResult.cs
@@ -0,0 +1,77 @@
+using Orleans.Serialization;
+
+namespace Orleans.Messaging;
+
+///
+/// Result of attempting to deliver a message to an inbox.
+///
+[GenerateSerializer, Alias("Orleans.Messaging.DeliveryResult")]
+public readonly struct DeliveryResult
+{
+ ///
+ /// The status of the delivery attempt.
+ ///
+ [Id(0)]
+ public DeliveryStatus Status { get; init; }
+
+ ///
+ /// Optional diagnostic message (e.g., reason for rejection).
+ ///
+ [Id(1)]
+ public string? Message { get; init; }
+
+ ///
+ /// Gets the code which identifies why the receiver dead-lettered the message: an application-defined code from an
+ /// inbox message sink, such as "orders/duplicate-order";
+ /// when the receiver reported none.
+ ///
+ ///
+ /// Messaging carries the code to the inbox dead letter and to the sender's
+ /// without interpreting it.
+ ///
+ [Id(2)]
+ public string? Code { get; init; }
+
+ ///
+ /// Creates a result indicating the message was accepted and persisted to inbox.
+ ///
+ public static DeliveryResult Accepted() => new() { Status = DeliveryStatus.Accepted };
+
+ ///
+ /// Creates a result indicating the message was a duplicate.
+ ///
+ public static DeliveryResult Duplicate() => new() { Status = DeliveryStatus.Duplicate };
+
+ ///
+ /// Creates a result indicating the inbox is at capacity.
+ ///
+ public static DeliveryResult Backpressured() => new() { Status = DeliveryStatus.Backpressured };
+
+ ///
+ /// Creates a result indicating the receiving inbox has no registered handler.
+ ///
+ public static DeliveryResult HandlerNotFound() => new()
+ {
+ Status = DeliveryStatus.HandlerNotFound,
+ Message = "No inbox handler is registered."
+ };
+
+ ///
+ /// Creates a result indicating the message was dead-lettered.
+ ///
+ /// The diagnostic reason.
+ /// The application-defined code which identifies the rejection, if any.
+ public static DeliveryResult DeadLettered(string reason, string? code = null) => new()
+ {
+ Status = DeliveryStatus.DeadLettered,
+ Message = reason,
+ Code = code
+ };
+
+ /// Creates a terminal result for an expired send whose earlier delivery outcome is unknown.
+ public static DeliveryResult Expired() => new()
+ {
+ Status = DeliveryStatus.Expired,
+ Message = "The message was sent before the receiver's deduplication window; its earlier delivery outcome is unknown."
+ };
+}
diff --git a/src/Orleans.Messaging/DeliveryStatus.cs b/src/Orleans.Messaging/DeliveryStatus.cs
new file mode 100644
index 00000000000..7beb2fb6885
--- /dev/null
+++ b/src/Orleans.Messaging/DeliveryStatus.cs
@@ -0,0 +1,50 @@
+namespace Orleans.Messaging;
+
+///
+/// Status codes for delivery attempts.
+///
+/// Zero is not a valid status, so a default reports no outcome.
+public enum DeliveryStatus
+{
+ ///
+ /// Message was accepted and persisted to inbox.
+ ///
+ Accepted = 1,
+
+ ///
+ /// The inbox already holds the message or retains its processed record.
+ ///
+ ///
+ /// A duplicate of a message which an inbox message sink rejected receives that rejection instead: the original
+ /// result, with its reason and .
+ ///
+ Duplicate = 2,
+
+ ///
+ /// Inbox is at capacity; sender should retry later.
+ ///
+ Backpressured = 3,
+
+ ///
+ /// The receiving inbox has no registered handler.
+ ///
+ HandlerNotFound = 4,
+
+ ///
+ /// The receiver rejected the message permanently and moved it to its dead-letter store.
+ ///
+ ///
+ /// A redelivery while its processed record is retained receives the same result.
+ ///
+ DeadLettered = 5,
+
+ ///
+ /// The message was sent at or before the receiver's durable admission floor, and the receiver retains no outcome
+ /// for it.
+ ///
+ ///
+ /// This attempt stages no message effects. An earlier attempt can have been accepted, so the sender treats the
+ /// delivery outcome as unknown and retires the message as .
+ ///
+ Expired = 6
+}
diff --git a/src/Orleans.Messaging/Diagnostics/MessagingEvents.cs b/src/Orleans.Messaging/Diagnostics/MessagingEvents.cs
new file mode 100644
index 00000000000..12a88ee0df8
--- /dev/null
+++ b/src/Orleans.Messaging/Diagnostics/MessagingEvents.cs
@@ -0,0 +1,196 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Diagnostics.CodeAnalysis;
+using System.Runtime.CompilerServices;
+using Orleans.Runtime;
+
+namespace Orleans.Messaging.Diagnostics;
+
+///
+/// Provides the diagnostic listener and event payload types for durable messaging events.
+///
+///
+///
+/// These types are public but may change between minor versions. They are intended for advanced scenarios such as
+/// simulation testing and diagnostics.
+///
+///
+/// Each event is written synchronously on the activation which raises it, at the transition which it describes, so an
+/// observer sees the grain's state at that transition. An exception thrown by an observer propagates to the code which
+/// raised the event; documents how a delivery treats one.
+///
+///
+public static class MessagingEvents
+{
+ /// The name of the diagnostic listener for durable messaging events.
+ public const string ListenerName = "Orleans.Messaging";
+
+ private static readonly DiagnosticListener Listener = new(ListenerName);
+
+ /// Gets an observable sequence of all durable messaging events.
+ public static IObservable AllEvents { get; } = new Observable();
+
+ /// The base class used for durable messaging diagnostic events.
+ /// The activation whose inbox or outbox raised the event.
+ public abstract class MessagingEvent(IGrainContext grainContext)
+ {
+ /// The activation whose inbox or outbox raised the event.
+ public readonly IGrainContext GrainContext = grainContext;
+ }
+
+ ///
+ /// Event payload for a new delivery whose send time is above the inbox's admission floor, before the inbox
+ /// stages message effects.
+ ///
+ /// The receiving activation.
+ /// The delivered message.
+ ///
+ /// An exception thrown by an observer fails the delivery attempt like an inbox message sink exception: the inbox
+ /// stages nothing, and the sender's outbox retries the message, which tests use to inject delivery faults.
+ ///
+ public sealed class InboxMessageReceiving(IGrainContext grainContext, InboxMessage message) : MessagingEvent(grainContext)
+ {
+ /// The delivered message.
+ public readonly InboxMessage Message = message;
+ }
+
+ ///
+ /// Event payload for a failed delivery attempt whose retry the outbox staged.
+ ///
+ /// The sending activation.
+ /// The message's ID.
+ /// The receiving grain.
+ /// The number of failed delivery attempts so far.
+ /// The time, on the Durable Jobs clock, from which the outbox attempts the delivery again.
+ /// The attempt's failure.
+ public sealed class OutboxRetryScheduled(
+ IGrainContext grainContext,
+ MessageId messageId,
+ GrainId receiverId,
+ int attemptCount,
+ DateTimeOffset nextAttemptAt,
+ string error) : MessagingEvent(grainContext)
+ {
+ /// The message's ID.
+ public readonly MessageId MessageId = messageId;
+
+ /// The receiving grain.
+ public readonly GrainId ReceiverId = receiverId;
+
+ /// The number of failed delivery attempts so far.
+ public readonly int AttemptCount = attemptCount;
+
+ /// The time, on the Durable Jobs clock, from which the outbox attempts the delivery again.
+ public readonly DateTimeOffset NextAttemptAt = nextAttemptAt;
+
+ /// The attempt's failure.
+ public readonly string Error = error;
+ }
+
+ ///
+ /// Event payload for an outbox message whose retirement the outbox staged: the message was delivered, its receiver
+ /// rejected it, or the outbox dead-lettered it.
+ ///
+ /// The sending activation.
+ /// The message's ID.
+ /// How the message retired.
+ ///
+ /// The retirement commits with the sending grain's next journal write. A reference message's source is notified
+ /// first, so its changes for the retirement are staged when observers run.
+ ///
+ public sealed class OutboxMessageRetired(
+ IGrainContext grainContext,
+ MessageId messageId,
+ OutboxMessageRetirement retirement) : MessagingEvent(grainContext)
+ {
+ /// The message's ID.
+ public readonly MessageId MessageId = messageId;
+
+ /// How the message retired.
+ public readonly OutboxMessageRetirement Retirement = retirement;
+ }
+
+ internal static void EmitInboxMessageReceiving(IGrainContext grainContext, InboxMessage message)
+ {
+ if (!Listener.IsEnabled(nameof(InboxMessageReceiving)))
+ {
+ return;
+ }
+
+ Emit(grainContext, message);
+
+ [MethodImpl(MethodImplOptions.NoInlining)]
+ static void Emit(IGrainContext grainContext, InboxMessage message) =>
+ Write(nameof(InboxMessageReceiving), new InboxMessageReceiving(grainContext, message));
+ }
+
+ internal static void EmitOutboxRetryScheduled(
+ IGrainContext grainContext,
+ MessageId messageId,
+ GrainId receiverId,
+ int attemptCount,
+ DateTimeOffset nextAttemptAt,
+ string error)
+ {
+ if (!Listener.IsEnabled(nameof(OutboxRetryScheduled)))
+ {
+ return;
+ }
+
+ Emit(grainContext, messageId, receiverId, attemptCount, nextAttemptAt, error);
+
+ [MethodImpl(MethodImplOptions.NoInlining)]
+ static void Emit(
+ IGrainContext grainContext,
+ MessageId messageId,
+ GrainId receiverId,
+ int attemptCount,
+ DateTimeOffset nextAttemptAt,
+ string error) =>
+ Write(
+ nameof(OutboxRetryScheduled),
+ new OutboxRetryScheduled(grainContext, messageId, receiverId, attemptCount, nextAttemptAt, error));
+ }
+
+ internal static void EmitOutboxMessageRetired(IGrainContext grainContext, MessageId messageId, OutboxMessageRetirement retirement)
+ {
+ if (!Listener.IsEnabled(nameof(OutboxMessageRetired)))
+ {
+ return;
+ }
+
+ Emit(grainContext, messageId, retirement);
+
+ [MethodImpl(MethodImplOptions.NoInlining)]
+ static void Emit(IGrainContext grainContext, MessageId messageId, OutboxMessageRetirement retirement) =>
+ Write(nameof(OutboxMessageRetired), new OutboxMessageRetired(grainContext, messageId, retirement));
+ }
+
+ [UnconditionalSuppressMessage(
+ "Trimming",
+ "IL2026",
+ Justification = "Event payloads are sealed types whose public fields and properties the generic annotation preserves, and subscribers consume the typed payload rather than reflecting over the members of the values it carries.")]
+ private static void Write<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicFields | DynamicallyAccessedMemberTypes.PublicProperties)] TEvent>(string name, TEvent payload)
+ where TEvent : MessagingEvent => Listener.Write(name, payload);
+
+ private sealed class Observable : IObservable
+ {
+ public IDisposable Subscribe(IObserver observer) => Listener.Subscribe(new Observer(observer));
+
+ private sealed class Observer(IObserver observer) : IObserver>
+ {
+ public void OnCompleted() => observer.OnCompleted();
+
+ public void OnError(Exception error) => observer.OnError(error);
+
+ public void OnNext(KeyValuePair value)
+ {
+ if (value.Value is MessagingEvent evt)
+ {
+ observer.OnNext(evt);
+ }
+ }
+ }
+ }
+}
diff --git a/src/Orleans.Messaging/Envelope.cs b/src/Orleans.Messaging/Envelope.cs
new file mode 100644
index 00000000000..2da2671d3d1
--- /dev/null
+++ b/src/Orleans.Messaging/Envelope.cs
@@ -0,0 +1,51 @@
+using System;
+using System.Collections.Generic;
+using System.Text.Json.Serialization;
+
+namespace Orleans.Messaging;
+
+/// Contains a command identity, payload, subject, optional application headers, and an optional delivery time.
+///
+/// Message identity and payload are required; an empty payload is valid. Raw protocols can omit
+/// subject and headers. Prepare the payload and headers before publishing a message and keep them
+/// unchanged afterward. Struct assignment shares these objects; ordinary Orleans deep copying
+/// and deserialization produce independent payload arrays, header dictionaries, and header value arrays.
+///
+[GenerateSerializer, Alias("Orleans.Messaging.Envelope")]
+public readonly struct Envelope
+{
+ /// Gets the exact application-defined command identity within the receiving inbox.
+ [Id(0)]
+ public required MessageId MessageId { get; init; }
+
+ /// Gets the required application payload bytes, including an empty payload.
+ [Id(1)]
+ public required byte[] Payload { get; init; }
+
+ /// Gets the optional, nonempty ordinal application protocol subject.
+ [Id(2)]
+ public string? Subject { get; init; }
+
+ /// Gets optional application-defined named byte-array headers.
+ /// Use an ordinal dictionary for case-sensitive header names. Keys are nonempty, well-formed UTF-16 strings, and arrays are non-null.
+ [Id(3)]
+ public Dictionary? Headers { get; init; }
+
+ /// Gets the optional earliest time at which the receiving inbox processes the message.
+ ///
+ ///
+ /// The outbox delivers the message immediately. The receiving inbox accepts, deduplicates, and persists it
+ /// as usual, then holds it until this time is reached on the Durable Jobs clock, and one durable wake-up
+ /// resumes the grain when the message becomes due. A message a grain sends to itself is accepted by its own
+ /// inbox in the sending journal write. processes the message as soon as
+ /// its acceptance is durable.
+ ///
+ ///
+ /// The time is part of the command: reusing a pending message ID with a different value is a conflicting command.
+ /// The message counts toward the inbox MaxCapacity from its acceptance until it completes.
+ ///
+ ///
+ [Id(4)]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public DateTimeOffset? DeliverAt { get; init; }
+}
diff --git a/src/Orleans.Messaging/EnvelopeEquivalence.cs b/src/Orleans.Messaging/EnvelopeEquivalence.cs
new file mode 100644
index 00000000000..bdbf42079f8
--- /dev/null
+++ b/src/Orleans.Messaging/EnvelopeEquivalence.cs
@@ -0,0 +1,18 @@
+using System;
+
+namespace Orleans.Messaging;
+
+internal static class EnvelopeEquivalence
+{
+ public static bool AreEquivalent(OutboxMessage left, OutboxMessage right) =>
+ left.ReceiverId == right.ReceiverId && AreSameCommand(left.Envelope, right.Envelope);
+
+ public static bool AreSameCommand(InboxMessage left, InboxMessage right) => AreSameCommand(left.Envelope, right.Envelope);
+
+ public static bool AreSameCommand(Envelope left, Envelope right) =>
+ left.MessageId == right.MessageId
+ && left.Payload.Length == right.Payload.Length
+ && string.Equals(left.Subject, right.Subject, StringComparison.Ordinal)
+ && left.DeliverAt == right.DeliverAt
+ && left.Payload.AsSpan().SequenceEqual(right.Payload);
+}
diff --git a/src/Orleans.Messaging/EnvelopeValidation.cs b/src/Orleans.Messaging/EnvelopeValidation.cs
new file mode 100644
index 00000000000..f69a2375636
--- /dev/null
+++ b/src/Orleans.Messaging/EnvelopeValidation.cs
@@ -0,0 +1,53 @@
+using System;
+using System.Text;
+
+namespace Orleans.Messaging;
+
+internal static class EnvelopeValidation
+{
+ internal const int MaxMessageIdBytes = 1024;
+ internal const int MaxSubjectBytes = 256;
+ private static readonly UTF8Encoding Utf8 = new(false, true);
+
+ // External admission and recovery validate generated values before publishing shared mutations.
+ public static void Validate(Envelope envelope)
+ {
+ ValidateMessageId(envelope.MessageId);
+ if (envelope.Payload is null) throw new ArgumentException("The payload must not be null.", nameof(envelope));
+ if (envelope.Subject is not null) ValidateSubject(envelope.Subject);
+ if (envelope.Headers is { } headers)
+ {
+ foreach (var header in headers)
+ {
+ if (string.IsNullOrEmpty(header.Key)) throw new ArgumentException("Header keys must not be empty.", nameof(envelope));
+ if (!HierarchicalKey.IsWellFormedUtf16(header.Key))
+ throw new ArgumentException("Header keys must not contain unpaired surrogate characters.", nameof(envelope));
+ if (header.Value is null) throw new ArgumentException("Header values must not be null.", nameof(envelope));
+ }
+ }
+ }
+
+ public static void Validate(InboxMessage message) => Validate(message.Envelope);
+
+ public static void Validate(OutboxMessage message)
+ {
+ if (message.ReceiverId.IsDefault) throw new ArgumentException("The outgoing receiver must not be default.", nameof(message));
+ Validate(message.Envelope);
+ }
+
+ internal static void ValidateMessageId(MessageId messageId)
+ {
+ if (messageId.IsDefault) throw new ArgumentException("The message ID must not be unset.", nameof(messageId));
+ if (Utf8.GetByteCount(messageId.ToString()) > MaxMessageIdBytes)
+ throw new ArgumentException($"The message ID exceeds {MaxMessageIdBytes} UTF-8 bytes.", nameof(messageId));
+ }
+
+ public static void ValidateSubject(string subject)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(subject);
+ if (!HierarchicalKey.IsWellFormedUtf16(subject))
+ throw new ArgumentException("The subject must not contain unpaired surrogate characters.", nameof(subject));
+ if (Utf8.GetByteCount(subject) > MaxSubjectBytes)
+ throw new ArgumentException($"The subject exceeds {MaxSubjectBytes} UTF-8 bytes.", nameof(subject));
+ }
+}
diff --git a/src/Orleans.Messaging/HierarchicalKey.cs b/src/Orleans.Messaging/HierarchicalKey.cs
new file mode 100644
index 00000000000..fe8f9801341
--- /dev/null
+++ b/src/Orleans.Messaging/HierarchicalKey.cs
@@ -0,0 +1,350 @@
+using System;
+using System.Diagnostics.CodeAnalysis;
+using System.Runtime.CompilerServices;
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Orleans.Messaging;
+
+///
+/// An immutable, ordinal application identity formed from nonempty hierarchical segments.
+///
+///
+/// Create constructs literal segments and escapes slash and backslash characters exactly once.
+/// Parse reads the canonical escaped path. The key wraps its canonical string; equality and hashing use that string. Applications preserve the identity across retries.
+/// Every key is well-formed UTF-16: surrogate characters appear only as high-low pairs, so distinct keys keep distinct serialized forms.
+///
+[Immutable, Alias("Orleans.Messaging.HierarchicalKey")]
+[JsonConverter(typeof(HierarchicalKeyJsonConverter))]
+public readonly struct HierarchicalKey : ISpanFormattable, IEquatable, IParsable, ISpanParsable
+{
+ /// The escape character used within canonical segments.
+ public const char EscapeCharacter = '\\';
+
+ /// The separator between canonical segments.
+ public const char SegmentSeparator = '/';
+
+ private const char HighSurrogateStart = '\uD800';
+ private const char LowSurrogateEnd = '\uDFFF';
+
+ private readonly string? _value;
+
+ private HierarchicalKey(string canonical) => _value = canonical;
+
+ /// Serializes hierarchical identities as canonical escaped JSON strings.
+ public sealed class HierarchicalKeyJsonConverter : JsonConverter
+ {
+ /// Creates a hierarchical identity JSON converter.
+ public HierarchicalKeyJsonConverter() { }
+
+ ///
+ public override HierarchicalKey Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ if (reader.TokenType == JsonTokenType.Null) return default;
+ if (reader.TokenType is JsonTokenType.String or JsonTokenType.PropertyName
+ && TryParse(reader.GetString(), null, out var result)) return result;
+ throw new JsonException("A hierarchical identity must be a canonical escaped path.");
+ }
+
+ ///
+ public override void Write(Utf8JsonWriter writer, HierarchicalKey value, JsonSerializerOptions options)
+ {
+ if (value.IsDefault) writer.WriteNullValue();
+ else writer.WriteStringValue(value.ToString());
+ }
+
+ ///
+ public override HierarchicalKey ReadAsPropertyName(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) =>
+ Read(ref reader, typeToConvert, options);
+
+ ///
+ public override void WriteAsPropertyName(Utf8JsonWriter writer, HierarchicalKey value, JsonSerializerOptions options)
+ {
+ if (value.IsDefault) throw new JsonException("A hierarchical dictionary key must be set.");
+ writer.WritePropertyName(value.ToString());
+ }
+ }
+
+ /// Gets whether this value is unset.
+ public bool IsDefault => _value is null;
+
+ /// Gets the canonical path length in UTF-16 characters.
+ public int Length => _value?.Length ?? 0;
+
+ /// Creates one literal segment, escaping slash and backslash characters.
+ /// The nonempty literal segment.
+ /// The segment identity.
+ /// is null.
+ /// is empty or contains an unpaired surrogate.
+ public static HierarchicalKey Create(string value)
+ {
+ ValidateSegment(value);
+ return new(Escape(value));
+ }
+
+ /// Creates a hierarchy from literal segments in root-first order.
+ /// The nonempty literal segments.
+ /// A flat canonical identity which owns its immutable backing string.
+ /// A segment is null.
+ /// The input or a segment is empty, or a segment contains an unpaired surrogate.
+ public static HierarchicalKey Create(params ReadOnlySpan values)
+ {
+ if (values.IsEmpty)
+ {
+ throw new ArgumentException("Values must not be empty.", nameof(values));
+ }
+ var builder = new StringBuilder();
+ foreach (var value in values)
+ {
+ ValidateSegment(value);
+ if (builder.Length > 0) builder.Append(SegmentSeparator);
+ AppendEscaped(builder, value);
+ }
+ return new(builder.ToString());
+ }
+
+ /// Appends one literal child segment.
+ /// The nonempty literal child segment.
+ /// The child identity.
+ /// This key is unset.
+ /// is null.
+ /// is empty or contains an unpaired surrogate.
+ public HierarchicalKey CreateChildKey(string value)
+ {
+ EnsureSet();
+ ValidateSegment(value);
+ return new(string.Concat(_value, "/", Escape(value)));
+ }
+
+ /// Composes this hierarchy with an already constructed suffix hierarchy.
+ /// The constructed suffix.
+ /// A flat concatenated identity preserving both paths' segment boundaries.
+ /// This key is unset.
+ /// is unset.
+ public HierarchicalKey Append(HierarchicalKey suffix)
+ {
+ EnsureSet();
+ if (suffix.IsDefault) throw new ArgumentException("The suffix must not be unset.", nameof(suffix));
+ return new(string.Concat(_value, "/", suffix._value));
+ }
+
+ /// Gets the immediate parent, or null for a root or unset key.
+ /// The parent identity when this key has more than one segment.
+ public HierarchicalKey? GetParent()
+ {
+ if (_value is null) return null;
+ var lastSeparator = LastSeparator(_value);
+ return lastSeparator < 0 ? null : new HierarchicalKey(_value[..lastSeparator]);
+ }
+
+ /// Tests whether this key is the other's immediate child.
+ /// The potential parent.
+ /// Whether there is exactly one additional segment.
+ public bool IsChildOf(HierarchicalKey other) => other.IsParentOf(this);
+
+ /// Tests whether this key is the other's immediate parent.
+ /// The potential child.
+ /// Whether the other key extends this key by exactly one segment.
+ public bool IsParentOf(HierarchicalKey other) => IsAncestorOf(other) && LastSeparator(other._value!) == Length;
+
+ /// Tests whether this key is equal to or an ancestor of the other key.
+ /// The identity to inspect.
+ /// Whether the full prefix consists of equal ordinal segments. Unset keys return false.
+ public bool IsAncestorOf(HierarchicalKey other) => !IsDefault && !other.IsDefault
+ && (Equals(other) || other.Length > Length && other._value![Length] == SegmentSeparator
+ && other._value.StartsWith(_value!, StringComparison.Ordinal));
+
+ ///
+ public static HierarchicalKey Parse(string s, IFormatProvider? provider = null)
+ {
+ ArgumentNullException.ThrowIfNull(s);
+ return TryParse(s, provider, out var result) ? result : throw new FormatException("The value is not a valid canonical hierarchical key.");
+ }
+
+ ///
+ public static HierarchicalKey Parse(ReadOnlySpan s, IFormatProvider? provider = null) =>
+ TryParse(s, provider, out var result) ? result : throw new FormatException("The value is not a valid canonical hierarchical key.");
+
+ ///
+ public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? provider, out HierarchicalKey result)
+ {
+ if (s is not null && IsCanonical(s))
+ {
+ result = new(s);
+ return true;
+ }
+ result = default;
+ return false;
+ }
+
+ ///
+ public static bool TryParse(ReadOnlySpan s, IFormatProvider? provider, out HierarchicalKey result)
+ {
+ if (IsCanonical(s))
+ {
+ result = new(new string(s));
+ return true;
+ }
+ result = default;
+ return false;
+ }
+
+ ///
+ public bool Equals(HierarchicalKey other) => string.Equals(_value, other._value, StringComparison.Ordinal);
+
+
+ ///
+ public override bool Equals(object? obj) => obj is HierarchicalKey other && Equals(other);
+
+ ///
+ public override int GetHashCode() => _value is null ? 0 : StringComparer.Ordinal.GetHashCode(_value);
+
+ /// Compares complete ordinal key values.
+ /// The first identity.
+ /// The second identity.
+ /// Whether the identities are equal.
+ public static bool operator ==(HierarchicalKey left, HierarchicalKey right) => left.Equals(right);
+
+ /// Compares complete ordinal key values for inequality.
+ /// The first identity.
+ /// The second identity.
+ /// Whether the identities differ.
+ public static bool operator !=(HierarchicalKey left, HierarchicalKey right) => !left.Equals(right);
+
+ ///
+ public override string ToString() => _value ?? string.Empty;
+
+ ///
+ public string ToString(string? format, IFormatProvider? formatProvider) => ToString();
+
+ ///
+ public bool TryFormat(Span destination, out int charsWritten, ReadOnlySpan format, IFormatProvider? provider)
+ {
+ if (ToString().AsSpan().TryCopyTo(destination))
+ {
+ charsWritten = Length;
+ return true;
+ }
+ charsWritten = 0;
+ return false;
+ }
+
+ /// Enumerates escaped canonical segment spans in root-first order.
+ /// An allocation-free segment enumerator.
+ public SegmentEnumerator GetEnumerator() => new(ToString().AsSpan());
+
+ private void EnsureSet()
+ {
+ if (IsDefault) throw new InvalidOperationException("The key must not be unset.");
+ }
+
+ private static void ValidateSegment(string value, [CallerArgumentExpression(nameof(value))] string? paramName = null)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(value, paramName);
+ if (!IsWellFormedUtf16(value)) throw new ArgumentException("The value must not contain unpaired surrogate characters.", paramName);
+ }
+
+ // Orleans string serialization replaces unpaired surrogates, so accepting them would let distinct keys collide on the wire.
+ internal static bool IsWellFormedUtf16(ReadOnlySpan value)
+ {
+ var i = value.IndexOfAnyInRange(HighSurrogateStart, LowSurrogateEnd);
+ if (i < 0) return true;
+ for (; i < value.Length; i++)
+ {
+ if (char.IsHighSurrogate(value[i]))
+ {
+ if (++i == value.Length || !char.IsLowSurrogate(value[i])) return false;
+ }
+ else if (char.IsLowSurrogate(value[i]))
+ {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ private static string Escape(string value)
+ {
+ if (value.AsSpan().IndexOfAny(EscapeCharacter, SegmentSeparator) < 0) return value;
+ var builder = new StringBuilder(value.Length);
+ AppendEscaped(builder, value);
+ return builder.ToString();
+ }
+
+ private static void AppendEscaped(StringBuilder builder, string value)
+ {
+ foreach (var character in value)
+ {
+ if (character is EscapeCharacter or SegmentSeparator) builder.Append(EscapeCharacter);
+ builder.Append(character);
+ }
+ }
+
+ private static int LastSeparator(ReadOnlySpan path)
+ {
+ var lastSeparator = -1;
+ for (var i = 0; i < path.Length; i++)
+ {
+ if (path[i] == EscapeCharacter) i++;
+ else if (path[i] == SegmentSeparator) lastSeparator = i;
+ }
+ return lastSeparator;
+ }
+
+ private static bool IsCanonical(ReadOnlySpan path)
+ {
+ var segmentLength = 0;
+ for (var i = 0; i < path.Length; i++)
+ {
+ if (path[i] == EscapeCharacter)
+ {
+ if (++i == path.Length || path[i] is not (EscapeCharacter or SegmentSeparator)) return false;
+ }
+ else if (path[i] == SegmentSeparator)
+ {
+ if (segmentLength == 0) return false;
+ segmentLength = 0;
+ continue;
+ }
+ else if (char.IsSurrogate(path[i]))
+ {
+ if (!char.IsHighSurrogate(path[i]) || ++i == path.Length || !char.IsLowSurrogate(path[i])) return false;
+ }
+ segmentLength++;
+ }
+ return segmentLength != 0;
+ }
+
+ /// Enumerates borrowed spans of the immutable canonical key.
+ public ref struct SegmentEnumerator
+ {
+ private readonly ReadOnlySpan _path;
+ private int _next;
+
+ internal SegmentEnumerator(ReadOnlySpan path) => _path = path;
+
+ /// Gets the current escaped canonical segment.
+ public ReadOnlySpan Current { get; private set; }
+
+ /// Advances to the next segment.
+ /// Whether a segment is available.
+ public bool MoveNext()
+ {
+ if (_next >= _path.Length)
+ {
+ Current = default;
+ return false;
+ }
+ var start = _next;
+ for (; _next < _path.Length; _next++)
+ {
+ if (_path[_next] == EscapeCharacter) _next++;
+ else if (_path[_next] == SegmentSeparator) break;
+ }
+ Current = _path[start.._next];
+ _next++;
+ return true;
+ }
+ }
+}
diff --git a/src/Orleans.Messaging/HierarchicalKeyCodec.cs b/src/Orleans.Messaging/HierarchicalKeyCodec.cs
new file mode 100644
index 00000000000..98a43ecea81
--- /dev/null
+++ b/src/Orleans.Messaging/HierarchicalKeyCodec.cs
@@ -0,0 +1,48 @@
+using System;
+using System.Buffers;
+using Orleans.Serialization;
+using Orleans.Serialization.Buffers;
+using Orleans.Serialization.Cloning;
+using Orleans.Serialization.Codecs;
+using Orleans.Serialization.WireProtocol;
+
+namespace Orleans.Messaging;
+
+[RegisterSerializer]
+internal sealed class HierarchicalKeyCodec(IFieldCodec strings) : IFieldCodec
+{
+ public void WriteField(ref Writer writer, uint fieldIdDelta,
+ Type? expectedType, HierarchicalKey value) where TBufferWriter : IBufferWriter
+ {
+ ReferenceCodec.MarkValueField(writer.Session);
+ writer.WriteFieldHeader(fieldIdDelta, expectedType, typeof(HierarchicalKey), WireType.TagDelimited);
+ strings.WriteField(ref writer, 0, typeof(string), value.IsDefault ? null! : value.ToString());
+ writer.WriteEndObject();
+ }
+
+ public HierarchicalKey ReadValue(ref Reader reader, Field field)
+ {
+ field.EnsureWireTypeTagDelimited();
+ ReferenceCodec.MarkValueField(reader.Session);
+ HierarchicalKey result = default;
+ uint fieldId = 0;
+ while (true)
+ {
+ var header = reader.ReadFieldHeader();
+ if (header.IsEndBaseOrEndObject) return result;
+ fieldId += header.FieldIdDelta;
+ if (fieldId == 0)
+ {
+ var canonical = strings.ReadValue(ref reader, header);
+ result = canonical is null ? default : HierarchicalKey.Parse(canonical);
+ }
+ else reader.ConsumeUnknownField(header);
+ }
+ }
+}
+
+[RegisterCopier]
+internal sealed class HierarchicalKeyCopier : IDeepCopier
+{
+ public HierarchicalKey DeepCopy(HierarchicalKey input, CopyContext context) => input;
+}
diff --git a/src/Orleans.Messaging/Hosting/MessageTypeExtensions.cs b/src/Orleans.Messaging/Hosting/MessageTypeExtensions.cs
new file mode 100644
index 00000000000..64344d5740e
--- /dev/null
+++ b/src/Orleans.Messaging/Hosting/MessageTypeExtensions.cs
@@ -0,0 +1,38 @@
+using System;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+using Orleans.Messaging;
+using Orleans.Serialization;
+
+namespace Orleans.Hosting;
+
+/// Extensions for registering typed durable message subjects.
+public static class MessageTypeExtensions
+{
+ /// Registers a singleton subject binding, keyed by its ordinal subject.
+ /// The subject's payload contract.
+ /// The service collection.
+ /// The exact application protocol subject.
+ /// The service collection.
+ /// A subject binding is already registered.
+ public static IServiceCollection AddMessageType(this IServiceCollection services, string subject)
+ {
+ ArgumentNullException.ThrowIfNull(services);
+ EnvelopeValidation.ValidateSubject(subject);
+ foreach (var descriptor in services)
+ {
+ if (descriptor.IsKeyedService
+ && descriptor.ServiceType.IsGenericType
+ && descriptor.ServiceType.GetGenericTypeDefinition() == typeof(MessageType<>)
+ && Equals(descriptor.ServiceKey, subject))
+ {
+ throw new InvalidOperationException($"A payload contract is already registered for durable message subject '{subject}'.");
+ }
+ }
+
+ services.TryAddSingleton>(static provider => provider.GetRequiredService().GetSerializer());
+ services.AddKeyedSingleton>(subject,
+ (provider, _) => new MessageType(subject, provider.GetRequiredService>()));
+ return services;
+ }
+}
diff --git a/src/Orleans.Messaging/Hosting/MessagingExtensions.cs b/src/Orleans.Messaging/Hosting/MessagingExtensions.cs
new file mode 100644
index 00000000000..f5002f7fb46
--- /dev/null
+++ b/src/Orleans.Messaging/Hosting/MessagingExtensions.cs
@@ -0,0 +1,101 @@
+using System;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+using Microsoft.Extensions.Options;
+using Orleans.Configuration;
+using Orleans.Messaging;
+using Orleans.Messaging.Configuration;
+using Orleans.DurableJobs;
+using Orleans.Journaling;
+using Orleans.Journaling.Json;
+
+namespace Orleans.Hosting;
+
+///
+/// Extensions for configuring messaging.
+///
+public static class MessagingExtensions
+{
+ ///
+ /// Adds inbox and outbox messaging support to the silo.
+ ///
+ ///
+ /// Grains implementing or deriving from
+ /// bind their scoped messaging services during activation setup, before journal recovery.
+ ///
+ /// The silo builder.
+ /// The optional action used to configure inbox and outbox behavior.
+ /// The silo builder.
+ public static ISiloBuilder AddMessaging(this ISiloBuilder builder, Action? configureOptions = null)
+ {
+ builder.AddDurableJobs();
+ return builder.ConfigureServices(services => services.AddMessaging(configureOptions));
+ }
+
+ ///
+ /// Adds inbox and outbox messaging services.
+ ///
+ ///
+ /// Grains implementing or deriving from
+ /// bind their scoped messaging services during activation setup, before journal recovery.
+ ///
+ /// The service collection.
+ /// The optional action used to configure inbox and outbox behavior.
+ /// The service collection.
+ public static IServiceCollection AddMessaging(this IServiceCollection services, Action? configureOptions = null)
+ {
+ services.AddDurableJobs();
+ services.TryAddSingleton(TimeProvider.System);
+
+ // The pump job is each grain's only durable wake-up for committed messaging work, so it retries indefinitely
+ // while other jobs keep the configured policy.
+ services.AddOptions().PostConfigure(static (options, services) =>
+ {
+ var shouldRetry = options.ShouldRetry;
+ var clock = services.GetRequiredKeyedService(DurableJobTimeProviderNames.DurableJobs);
+ var messaging = services.GetRequiredService>();
+ options.ShouldRetry = (context, exception) => string.Equals(context.Job.Name, MessagingPump.JobName, StringComparison.Ordinal)
+ ? MessagingPump.GetRetryTime(context, clock.GetUtcNow(), messaging.Value.RetryInitialInterval)
+ : shouldRetry(context, exception);
+ });
+ services.Configure(options =>
+ {
+ options.AddTypeInfoResolver(InboxJsonContext.Default);
+ options.AddTypeInfoResolver(OutboxJsonContext.Default);
+ });
+
+ var optionsBuilder = services.AddOptions();
+ if (configureOptions is not null)
+ {
+ optionsBuilder.Configure(configureOptions);
+ }
+
+ optionsBuilder.Validate(
+ options =>
+ {
+ try
+ {
+ options.Validate();
+ return true;
+ }
+ catch (ArgumentException)
+ {
+ return false;
+ }
+ },
+ "DurableMessagingOptions validation failed.");
+
+ services.ConfigureNamedOptionForLogging(Options.DefaultName);
+ services.TryAddSingleton();
+ services.TryAddSingleton();
+ services.TryAddScoped();
+ services.TryAddScoped();
+ services.Replace(ServiceDescriptor.Scoped(static sp => sp.GetRequiredService()));
+ services.TryAddScoped();
+ services.Replace(ServiceDescriptor.Scoped(static sp => sp.GetRequiredService()));
+ services.TryAddScoped();
+ services.TryAddEnumerable(
+ ServiceDescriptor.Singleton());
+ return services;
+ }
+}
diff --git a/src/Orleans.Messaging/IInbox.cs b/src/Orleans.Messaging/IInbox.cs
new file mode 100644
index 00000000000..db6cb3a8655
--- /dev/null
+++ b/src/Orleans.Messaging/IInbox.cs
@@ -0,0 +1,41 @@
+namespace Orleans.Messaging;
+
+/// Registers handlers for the messages delivered to a grain's inbox.
+///
+///
+/// Each message is dispatched to the first registered handler which can handle it. Handlers can be registered at any
+/// time. registers typed subject handlers.
+///
+///
+/// A handler decodes the body, performs asynchronous I/O, validates local results, and checks cancellation before
+/// the first shared business or journaled mutation. From that mutation until it returns, it executes synchronously:
+/// it applies safe-to-commit business changes, stages outgoing messages through , and calls
+/// or . The runtime persists the
+/// outcome with those changes. Failures before a terminal operation follow the inbox retry and dead-letter policy.
+///
+///
+public interface IInbox
+{
+ /// Registers a handler.
+ /// The handler.
+ /// This inbox.
+ IInbox Register(IInboxHandler handler);
+
+ /// Registers a sink which accepts messages directly into application-owned journaled state.
+ /// The sink.
+ /// This inbox.
+ ///
+ /// Messages are offered to sinks before inbox acceptance and handler dispatch; see .
+ /// Register sinks during construction or activation setup, before the grain receives messages.
+ ///
+ IInbox Register(IInboxMessageSink sink);
+
+ /// Registers a source of durable work which the inbox's wake-up keeps reactivating the grain for.
+ /// The work source.
+ /// This inbox.
+ ///
+ /// See . Register work sources during construction or activation setup, before the
+ /// grain activates.
+ ///
+ IInbox Register(IInboxWorkSource source);
+}
diff --git a/src/Orleans.Messaging/IInboxHandler.cs b/src/Orleans.Messaging/IInboxHandler.cs
new file mode 100644
index 00000000000..9d5d707a9ac
--- /dev/null
+++ b/src/Orleans.Messaging/IInboxHandler.cs
@@ -0,0 +1,24 @@
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Orleans.Messaging;
+
+/// Handles inbox messages which it accepts.
+///
+/// The inbox dispatches each message to the first registered handler whose returns
+/// . Selection receives only the message, so a handler stages a terminal outcome only
+/// from , after the inbox selects it.
+///
+public interface IInboxHandler
+{
+ /// Determines whether this handler handles the message.
+ /// The message to route.
+ /// when this handler handles the message.
+ bool CanHandle(InboxMessage message);
+
+ /// Handles the message.
+ /// The message and its terminal operations.
+ /// The attempt token.
+ /// A task which completes when handling returns.
+ ValueTask HandleAsync(IInboxHandlerContext context, CancellationToken cancellationToken);
+}
diff --git a/src/Orleans.Messaging/IInboxHandlerContext.cs b/src/Orleans.Messaging/IInboxHandlerContext.cs
new file mode 100644
index 00000000000..d7217376bd6
--- /dev/null
+++ b/src/Orleans.Messaging/IInboxHandlerContext.cs
@@ -0,0 +1,54 @@
+namespace Orleans.Messaging;
+
+///
+/// Exposes the received message and its attempt-scoped terminal outcome.
+///
+public interface IInboxHandlerContext
+{
+ ///
+ /// Gets the received message and its immutable application headers and payload.
+ ///
+ ///
+ /// The context keeps the message available through actual handler completion, including
+ /// after Complete or Fail removes the pending message. Keep its payload and headers unchanged while handling the command.
+ ///
+ InboxMessage Message { get; }
+
+ ///
+ /// Synchronously stages inbox completion and transport deduplication for the active attempt.
+ ///
+ ///
+ /// Perform fallible and asynchronous work first, then apply business mutations and stage outgoing messages before
+ /// calling Complete in the same synchronous final block. Return from the handler without further awaits. An attempt
+ /// which stages changes and then ends without an outcome fails the activation, which discards those changes, and
+ /// the message runs again after recovery from the journal.
+ /// The runtime owns the subsequent journal write and acknowledgement.
+ /// Repeated completion within the same active attempt coalesces. Completion retains its
+ /// logical outcome when cancellation arrives after the final block starts.
+ ///
+ ///
+ /// The context is retired or belongs to another activation or handler attempt.
+ ///
+ void Complete();
+
+ ///
+ /// Synchronously stages a permanent processing failure, retaining the message as a dead letter
+ /// and recording its identity for transport deduplication.
+ ///
+ /// The nonblank diagnostic reason for the permanent failure.
+ ///
+ /// Stage any safe-to-commit business changes and outgoing messages before this operation,
+ /// then return synchronously. The runtime persists the dead letter and completion together
+ /// and owns their journal acknowledgement. The dead letter records the current attempt count.
+ /// Repeated calls with the same reason coalesce; Complete preserves an already staged dead letter.
+ /// Cancellation after staging preserves the terminal outcome.
+ /// Use an application reply and Complete for normal business rejection outcomes.
+ ///
+ /// The reason is blank.
+ /// The reason is null.
+ ///
+ /// The context is retired or belongs to another activation or attempt, or a different
+ /// terminal outcome has already been staged.
+ ///
+ void Fail(string reason);
+}
diff --git a/src/Orleans.Messaging/IInboxMessageSink.cs b/src/Orleans.Messaging/IInboxMessageSink.cs
new file mode 100644
index 00000000000..a902651b153
--- /dev/null
+++ b/src/Orleans.Messaging/IInboxMessageSink.cs
@@ -0,0 +1,73 @@
+namespace Orleans.Messaging;
+
+///
+/// Accepts delivered messages directly into application-owned journaled state, so their payload is journaled once.
+///
+///
+///
+/// After validation and deduplication, the inbox offers each message to the registered sinks in registration order,
+/// including messages the grain sends to itself and messages with . The first sink
+/// which takes the message decides its . A sink which reports
+/// has synchronously staged the message's effects in its own journaled state. The
+/// inbox then journals only the message's processed record, without its payload, in the same journal write, and reports
+/// the result once that write is durable. Run effects which depend on the write, such as starting work, from
+/// .
+///
+///
+/// A sink-owned message never occupies inbox capacity and has no inbox retry lifecycle: the sink owns its processing,
+/// including honoring the message's . Messages which every sink declines are accepted by
+/// the inbox and dispatched to instances once their has
+/// passed; decline a scheduled message to have the inbox hold it until then.
+///
+///
+/// Sinks must be idempotent. The inbox deduplicates the message ID while it retains the processed record, for
+/// DeduplicationWindow after the message's . Delayed retries after that receive
+/// before reaching a sink. A message whose journal write fails is redelivered to
+/// the next activation, which recovers the sink's state from the journal.
+///
+///
+public interface IInboxMessageSink
+{
+ /// Takes a message into the sink's journaled state, or declines it.
+ /// The validated, deduplicated message.
+ ///
+ /// When this method returns , the delivery result which the inbox reports to the sender.
+ ///
+ ///
+ /// when the sink takes the message and reports ;
+ /// when it declines the message without staging anything.
+ ///
+ ///
+ ///
+ /// Runs synchronously on the activation scheduler. Validate the message before staging any change. The sink reports
+ /// one of these statuses:
+ ///
+ ///
+ /// -
+ /// : the sink staged the message's effects, and the inbox records the message
+ /// as processed in the same write.
+ ///
+ /// -
+ /// : the sink already holds the message and stages nothing. The inbox reports
+ /// it once any write in progress, which can hold the original, is durable.
+ ///
+ /// -
+ /// : the sink is at capacity and stages nothing, so the sender retries
+ /// later. A message the grain sends to itself can't wait for a retry, so this status fails that send.
+ ///
+ /// -
+ /// : the sink rejects the message and stages nothing. The inbox
+ /// dead-letters it with its payload and the result's reason and , records it as
+ /// processed, and returns the reason and code to the sender. The processed record keeps the reason and code, so a
+ /// duplicate of the message, such as a retry whose first answer was lost, receives the same rejection.
+ ///
+ ///
+ ///
+ /// A sink exception, or any other status, means the outcome is unknown, so the inbox stages nothing and fails the
+ /// delivery: the sender's outbox retries it with backoff until OutboxRetryTimeout, and a send to the grain
+ /// itself throws to its caller. A sink which throws must leave no staged change. Return
+ /// to reject a message permanently.
+ ///
+ ///
+ bool TryAccept(InboxMessage message, out DeliveryResult result);
+}
diff --git a/src/Orleans.Messaging/IInboxWorkSource.cs b/src/Orleans.Messaging/IInboxWorkSource.cs
new file mode 100644
index 00000000000..7a3a367a9b8
--- /dev/null
+++ b/src/Orleans.Messaging/IInboxWorkSource.cs
@@ -0,0 +1,34 @@
+using System;
+
+namespace Orleans.Messaging;
+
+///
+/// Reports durable work which a component holds outside the inbox, such as messages which an
+/// accepted, so the inbox's durable wake-up reactivates the grain while that work remains.
+///
+///
+///
+/// The inbox keeps one durable wake-up job per grain while it, the outbox, or any registered work source holds work.
+/// Before each journal write, the inbox consults the work sources and ensures the wake-up, so a write which makes work
+/// pending also guarantees that the grain is reactivated after a deactivation or crash. Hold the work in journaled
+/// state of the grain's journal, and resume it when the grain activates: a wake-up reactivates the grain, and the work
+/// source's owner continues its work from the recovered state.
+///
+///
+/// Each wake-up job run consults the work sources again. It reschedules after the shortest delay any source reports,
+/// and the wake-up ends once no source, the inbox, nor the outbox holds work. Wake-ups may be early, late, or duplicated,
+/// including while the grain is active and running the work.
+///
+///
+public interface IInboxWorkSource
+{
+ /// Gets the delay before the grain must be woken for this source's pending work.
+ ///
+ /// when the source holds no pending work; otherwise, the delay after which a wake-up should
+ /// reactivate the grain if it isn't active. A delay of or less requests a prompt
+ /// wake-up. For work which runs as soon as the grain is active, return the longest delay acceptable before a
+ /// deactivated grain resumes it.
+ ///
+ /// Runs synchronously on the activation scheduler, before each journal write and on each wake-up.
+ TimeSpan? GetWakeupDelay();
+}
diff --git a/src/Orleans.Messaging/IMessagingDiagnostics.cs b/src/Orleans.Messaging/IMessagingDiagnostics.cs
new file mode 100644
index 00000000000..a7f462161ed
--- /dev/null
+++ b/src/Orleans.Messaging/IMessagingDiagnostics.cs
@@ -0,0 +1,116 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Orleans.Runtime;
+
+namespace Orleans.Messaging;
+
+///
+/// Provides operational access to a grain's messaging state.
+///
+public interface IMessagingDiagnostics
+{
+ ///
+ /// Gets messages which failed during inbox processing.
+ ///
+ IReadOnlyList> InboxDeadLetters { get; }
+
+ ///
+ /// Gets messages which could not be delivered from the outbox.
+ ///
+ ///
+ /// A message whose failed to provide it has no materialized message: its record
+ /// carries only the , with an empty payload and a default
+ /// , and its reason records the source's failure.
+ ///
+ IReadOnlyList> OutboxDeadLetters { get; }
+
+ ///
+ /// Stages removal of an inbox dead letter.
+ ///
+ /// The receiver-local command identity.
+ /// when the dead letter existed and was removed.
+ ///
+ /// The removal becomes durable with the grain's next journal write.
+ ///
+ bool RemoveInboxDeadLetter(MessageId messageId);
+
+ ///
+ /// Stages removal of an outbox dead letter.
+ ///
+ /// The message identifier.
+ /// when the dead letter existed and was removed.
+ ///
+ /// The removal becomes durable with the grain's next journal write.
+ ///
+ bool RemoveOutboxDeadLetter(MessageId messageId);
+}
+
+///
+/// Describes a dead-lettered durable message.
+///
+/// The incoming or outgoing message type.
+[GenerateSerializer, Alias("Orleans.Messaging.DeadLetter`1")]
+public sealed class DeadLetter
+{
+ ///
+ /// Gets the message.
+ ///
+ ///
+ /// Treat the message's encoded headers as immutable. Application diagnostic DTOs use
+ /// ordinary RPC copying when returning message buffers.
+ ///
+ [Id(0)]
+ public required TMessage Message { get; init; }
+
+ ///
+ /// Gets when the message was dead-lettered.
+ ///
+ [Id(1)]
+ public DateTimeOffset DeadLetteredAt { get; init; }
+
+ ///
+ /// Gets the terminal failure reason.
+ ///
+ [Id(2)]
+ public required string Reason { get; init; }
+
+ ///
+ /// Gets the number of attempts made.
+ ///
+ [Id(3)]
+ public int AttemptCount { get; init; }
+
+ ///
+ /// Gets the application-defined code which an inbox message sink reported when it dead-lettered the message, or
+ /// .
+ ///
+ [Id(4)]
+ public string? Code { get; init; }
+}
+
+internal sealed class MessagingDiagnostics(Inbox inbox, Outbox outbox) : IMessagingDiagnostics
+{
+ public IReadOnlyList> InboxDeadLetters =>
+ inbox.DeadLetterState.Values.Select(static entry => new DeadLetter
+ {
+ Message = entry.Message,
+ DeadLetteredAt = entry.DeadLetteredAt,
+ Reason = entry.Reason,
+ AttemptCount = entry.AttemptCount,
+ Code = entry.Code
+ }).ToList();
+
+ public IReadOnlyList> OutboxDeadLetters =>
+ outbox.DeadLetterState.Values.Select(static entry => new DeadLetter
+ {
+ Message = entry.Message,
+ DeadLetteredAt = entry.DeadLetteredAt,
+ Reason = entry.Reason,
+ AttemptCount = entry.AttemptCount
+ }).ToList();
+
+ public bool RemoveInboxDeadLetter(MessageId messageId) => inbox.RemoveDeadLetter(messageId);
+
+ public bool RemoveOutboxDeadLetter(MessageId messageId) => outbox.RemoveDeadLetter(messageId);
+}
diff --git a/src/Orleans.Messaging/IMessagingExtension.cs b/src/Orleans.Messaging/IMessagingExtension.cs
new file mode 100644
index 00000000000..fff97ffb613
--- /dev/null
+++ b/src/Orleans.Messaging/IMessagingExtension.cs
@@ -0,0 +1,48 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using Orleans;
+using Orleans.Concurrency;
+using Orleans.Runtime;
+using Orleans.Serialization;
+
+namespace Orleans.Messaging;
+
+///
+/// The grain extension through which other grains exchange durable messages with this grain.
+///
+[Alias("Orleans.Messaging.IMessagingExtension")]
+public interface IMessagingExtension : IGrainExtension
+{
+ ///
+ /// Delivers a message to this grain's inbox.
+ ///
+ /// The received command with a required identity and payload; its receiver is this grain.
+ /// Cancels the caller's wait for delivery.
+ ///
+ /// Direct admission shares the payload array, header dictionary, and header value arrays under the immutable-publication contract.
+ /// RPC copying and deserialization isolate payload arrays, header dictionaries, and header value arrays using ordinary Orleans serialization.
+ /// A successful result means the inbox durably accepted the message, or already processed it.
+ /// Callers retrying a delivery reuse the message's .
+ ///
+ /// Deliveries interleave with the grain's other requests. A delivery runs no handler: it validates, deduplicates,
+ /// and stages the message's acceptance synchronously, then waits for that journal write, and the messaging pump runs
+ /// handlers later. An inbox message sink runs synchronously within the delivery, so it can run while a request is
+ /// suspended at an await, as handlers can. Interleaving lets concurrent deliveries share journal writes, and lets a
+ /// message reach a grain whose running request waits for it.
+ ///
+ ///
+ /// Result indicating delivery/processing status.
+ ///
+ /// has an unset message ID, exceeds identity admission limits, or has invalid payload or headers.
+ /// Optional standard metadata is validated where its contract is consumed.
+ ///
+ ///
+ /// The message's is later than the receiver's clock plus DeduplicationWindow,
+ /// and the message is untracked and above the admission floor, or that send time would extend its record. The inbox
+ /// stages nothing for it.
+ ///
+ [Alias("DeliverAsync")]
+ [AlwaysInterleave]
+ ValueTask DeliverAsync(InboxMessage message, CancellationToken cancellationToken = default);
+}
diff --git a/src/Orleans.Messaging/IMessagingGrain.cs b/src/Orleans.Messaging/IMessagingGrain.cs
new file mode 100644
index 00000000000..718d6952c0f
--- /dev/null
+++ b/src/Orleans.Messaging/IMessagingGrain.cs
@@ -0,0 +1,14 @@
+namespace Orleans.Messaging;
+
+///
+/// Identifies grain implementations which initialize inbox and outbox services during activation setup.
+///
+///
+/// Implement this local capability on a grain class, an application base class, or an application grain interface.
+/// With messaging services registered, selected activations validate their execution model and bind
+/// messaging state before journal recovery. Grains deriving from
+/// receive the same setup automatically.
+///
+public interface IMessagingGrain
+{
+}
diff --git a/src/Orleans.Messaging/IOutbox.cs b/src/Orleans.Messaging/IOutbox.cs
new file mode 100644
index 00000000000..1478b2c837b
--- /dev/null
+++ b/src/Orleans.Messaging/IOutbox.cs
@@ -0,0 +1,102 @@
+using System;
+
+namespace Orleans.Messaging;
+
+///
+/// Stages opaque durable messages alongside a grain's journaled business state.
+///
+///
+/// The outbox state machine's asynchronous write preparation establishes a durable self-wakeup before capture.
+/// Its write-completion callback makes captured messages eligible for dispatch.
+/// Each wakeup checks the grain's messages and their retry deadlines. Messages remain pending until the destination
+/// acknowledges durable acceptance, recognizes a duplicate, or reports a terminal delivery outcome.
+/// The outbox journals each send's ; every attempt, backpressure, and recovery reuse
+/// it, and retries end OutboxRetryTimeout after it. Applications use and
+/// to stage crash-recoverable responses to terminal outcomes in the
+/// same journal write as retirement.
+/// Applications namespace command identities and define ordering in their protocols.
+///
+public interface IOutbox
+{
+ ///
+ /// Synchronously stages an outgoing command for the grain's next journal write.
+ ///
+ /// The fully built outgoing message and required receiver.
+ ///
+ /// Direct sends share the payload array, header dictionary, and header value arrays. Keep these objects unchanged after publication.
+ /// Equivalent repeated identities retain the original message and headers; payload, optional subject,
+ /// optional , and destination must match. Custom headers do not change pending intent equality. Conflicts fail explicitly.
+ /// Inbox handlers stage outgoing
+ /// messages in their synchronous final block before calling
+ /// or .
+ /// Ordinary callers persist staged messages using their journaled state manager.
+ /// An explicit write retry retains pending business changes and messages after a scheduling failure.
+ /// A message addressed to the sending grain bypasses the outbox: the grain's own inbox stages its acceptance
+ /// for the same journal write, applying the inbox identity rules, so a completed ID is a duplicate and a different
+ /// pending command with the ID conflicts. The inbox admits such a message regardless of its capacity and of handler
+ /// registration, and the send fails when an reports
+ /// for it.
+ ///
+ /// The envelope has an unset identity, invalid subject or destination, or exceeds identity/subject limits.
+ ///
+ /// The identity conflicts, an inbox sink backpressured a message addressed to the sending grain, or the outbox is
+ /// unavailable.
+ ///
+ void Send(OutboxMessage message);
+
+ ///
+ /// Synchronously stages, for the grain's next journal write, a message which a registered
+ /// provides.
+ ///
+ /// The message ID, which a registered source provides.
+ ///
+ /// The outbox journals the message ID and delivery state, while the source holds the payload and receiver, and
+ /// delivers the message like one staged through . Stage the message in the source's
+ /// state before this call: the outbox resolves the source now, reads and validates its message, and fails when none
+ /// provides it; an exception from the source propagates and nothing is staged. After the send, a source which fails
+ /// to provide a valid message dead-letters the entry instead; see . Sending a
+ /// pending ID again coalesces with the pending message. A message which a source addresses to
+ /// the sending grain is rejected; send it through , which stages it in the grain's
+ /// own inbox.
+ /// A retry timeout or reports
+ /// to the source; the receiver's earlier outcome is unknown,
+ /// so the application reconciles it before starting a new send.
+ ///
+ ///
+ /// The ID is unset or too long, or the source's message is invalid or addressed to the sending grain.
+ ///
+ ///
+ /// The ID is pending as an ordinary message, no registered source provides it, the source returned a different ID,
+ /// or the outbox is unavailable.
+ ///
+ void Send(MessageId messageId);
+
+ ///
+ /// Synchronously stages, for the grain's next journal write, the removal of a pending message which a registered
+ /// provides.
+ ///
+ /// The ID of a message sent through .
+ ///
+ /// when the outbox staged the removal; when no such message is pending.
+ ///
+ ///
+ /// Stage the source's release of the payload in the same journal write. The outbox makes no further delivery attempt
+ /// and doesn't call . An attempt already in flight can still deliver the
+ /// message and still reads the payload array, header dictionary, and header value arrays which
+ /// returned, so release them by dropping references rather than
+ /// reusing them.
+ ///
+ ///
+ /// The ID is pending as an ordinary message, or the outbox is unavailable.
+ ///
+ bool Withdraw(MessageId messageId);
+
+ /// Registers a source which provides messages sent through .
+ /// The source.
+ /// This outbox.
+ ///
+ /// Register sources during construction or activation setup, before journal recovery; see
+ /// .
+ ///
+ IOutbox Register(IOutboxMessageSource source);
+}
diff --git a/src/Orleans.Messaging/IOutboxMessageSource.cs b/src/Orleans.Messaging/IOutboxMessageSource.cs
new file mode 100644
index 00000000000..98fbd10ec66
--- /dev/null
+++ b/src/Orleans.Messaging/IOutboxMessageSource.cs
@@ -0,0 +1,150 @@
+namespace Orleans.Messaging;
+
+///
+/// Provides outgoing messages whose payload and receiver the application stores in its own journaled state.
+///
+///
+///
+/// stages a reference entry which carries the message ID and delivery
+/// state, but no payload or receiver, so the payload is journaled once, in the application's state. The outbox keeps
+/// every delivery semantic of an ordinary message: delivery after the sending write is durable, retries with backoff,
+/// dead-lettering after OutboxRetryTimeout, wake-ups, and metrics.
+///
+///
+/// The outbox calls when the message is sent and before every delivery attempt. The first
+/// registered source which returns the message owns it for the activation's lifetime; for an entry recovered from the
+/// journal, the first source which returns the message or throws owns it. When the entry retires, the outbox calls the
+/// owner's once for that entry in the current activation, synchronously, while it stages the
+/// entry's removal. The source's journaled changes and the removal commit in the same journal write. If the activation
+/// fails before that write commits, recovery restores the pending entry and the source's prior journaled state, and
+/// retiring the recovered entry invokes again.
+///
+///
+/// A source withdraws a pending message through , for example after the application times
+/// it out or an administrator removes it, and releases its payload in the same journal write. The outbox removes the
+/// entry without calling and makes no further delivery attempt. An attempt already in flight can
+/// still deliver the message and reads the buffers which returned, so keep them unchanged:
+/// release them by dropping references rather than reusing them.
+///
+///
+/// A source which can't provide a pending message after its send, for example because its stored payload no longer
+/// decodes, throws from ; an invalid message is handled the same way. The outbox then
+/// dead-letters that entry alone, without delivering it, and calls with
+/// ; the grain's messaging and its other messages are
+/// unaffected. A recovered entry which no registered source provides is dead-lettered the same way, without an owner to
+/// notify. Because no valid message exists, the dead letter's message carries only its ,
+/// with an empty payload and a default , and its reason records the failure.
+///
+///
+/// All calls run on the activation scheduler. Register sources during activation setup, before journal recovery: a
+/// recovered reference entry fails the activation when no source is registered. While a source provides a message, it
+/// returns the same message, envelope and receiver alike, for every call.
+///
+///
+public interface IOutboxMessageSource
+{
+ /// Materializes a message for its send or a delivery attempt.
+ /// The ID of a reference entry.
+ ///
+ /// When this method returns , a valid message whose envelope's
+ /// is and whose receiver is another grain.
+ ///
+ ///
+ /// when this source provides the message; when the message isn't
+ /// this source's.
+ ///
+ ///
+ /// The outbox validates the message. During , an exception or an invalid
+ /// message fails the send, and nothing is staged. Afterwards, the entry is durable: an exception, an invalid message,
+ /// including one with a different or addressed to the sending grain, or
+ /// from the source which owns the entry dead-letters the entry with its failure as the
+ /// reason, counting the failed materialization as a delivery attempt, and the outbox then calls
+ /// with .
+ ///
+ bool TryGetMessage(MessageId messageId, out OutboxMessage message);
+
+ /// Notifies the source that the outbox is staging a message's retirement, so the source can release its payload.
+ /// The ID of the retired reference entry.
+ /// How the message retired, and the dead letter's reason for a dead-lettered message.
+ ///
+ ///
+ /// Stage the release and other durable effects synchronously in journaled state; they commit atomically with the
+ /// entry's removal. This callback runs once for the entry in the current activation. Recovery can invoke it again
+ /// when the retirement write has not committed, starting from the source's recovered journaled state.
+ /// Make any effects outside that journal write idempotent across repeated calls.
+ /// Stage a durable application outcome or follow-up work here for
+ /// and
+ /// , so recovery preserves the application's response even
+ /// after diagnostic dead letters are compacted. A dead-lettered send can have been accepted by an earlier attempt;
+ /// reconcile that uncertain outcome using application-level idempotency.
+ ///
+ ///
+ /// Before this call, the outbox copies a dead-lettered message's payload and header values into its dead letter,
+ /// unless the source failed to provide the message, so the source can release or reuse its buffers here. Don't depend
+ /// on decoding the payload here: this call also retires a message whose payload the source can no longer provide. An
+ /// exception fails the activation's messaging, which deactivates the grain.
+ ///
+ ///
+ void OnRetired(MessageId messageId, OutboxMessageRetirement retirement);
+}
+
+/// Describes how an outbox message retired.
+/// The outbox creates retirements; sources read them in .
+public readonly struct OutboxMessageRetirement
+{
+ /// Gets how the message retired.
+ public OutboxMessageRetirementStatus Status { get; init; }
+
+ ///
+ /// Gets the dead letter's reason for a message which the receiver rejected or the outbox dead-lettered, such as the
+ /// rejection which the receiver reported; for a delivered message.
+ ///
+ public string? Reason { get; init; }
+
+ ///
+ /// Gets the application-defined code which the receiver's inbox message sink reported with a rejection, as
+ /// ; when the receiver reported none or the message didn't
+ /// retire as .
+ ///
+ public string? Code { get; init; }
+
+ /// Creates a retirement of a message which the receiver durably accepted or reported as a duplicate.
+ public static OutboxMessageRetirement Delivered() => new() { Status = OutboxMessageRetirementStatus.Delivered };
+
+ /// Creates a retirement of a message which the outbox dead-lettered for .
+ /// The dead letter's reason.
+ public static OutboxMessageRetirement DeadLettered(string reason) => new()
+ {
+ Status = OutboxMessageRetirementStatus.DeadLettered,
+ Reason = reason
+ };
+
+ /// Creates a retirement of a message which the receiver dead-lettered for .
+ /// The reason which the receiver reported.
+ /// The application-defined code which the receiver reported, if any.
+ public static OutboxMessageRetirement Rejected(string reason, string? code = null) => new()
+ {
+ Status = OutboxMessageRetirementStatus.Rejected,
+ Reason = reason,
+ Code = code
+ };
+}
+
+/// Describes how an outbox message retired.
+public enum OutboxMessageRetirementStatus
+{
+ /// The receiver durably accepted the message, or reported it as a duplicate.
+ Delivered = 1,
+
+ ///
+ /// The outbox dead-lettered the message: its delivery attempts didn't succeed before its retry limits elapsed, or its
+ /// source failed to provide it, or the receiver answered . The receiver's
+ /// earlier delivery outcome is unknown: an earlier attempt can have been durably accepted.
+ ///
+ DeadLettered = 2,
+
+ ///
+ /// The receiver durably rejected the message, with the reason and code which it reported.
+ ///
+ Rejected = 3
+}
diff --git a/src/Orleans.Messaging/Inbox.State.cs b/src/Orleans.Messaging/Inbox.State.cs
new file mode 100644
index 00000000000..363f9e4d67d
--- /dev/null
+++ b/src/Orleans.Messaging/Inbox.State.cs
@@ -0,0 +1,344 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text.Json.Serialization;
+using System.Threading;
+using System.Threading.Tasks;
+using Orleans.Journaling;
+
+namespace Orleans.Messaging;
+
+internal sealed partial class Inbox
+{
+ private readonly Dictionary _messages = [];
+ private readonly Dictionary _processed = [];
+ private readonly Dictionary _deadLetters = [];
+ private JournalStreamWriter _writer;
+
+ // Forgetting an identity requires a durable floor which excludes its latest observed send time.
+ private DateTimeOffset _admissionFloor = DateTimeOffset.MinValue;
+ private DateTimeOffset _capturedAdmissionFloor = DateTimeOffset.MinValue;
+ private DateTimeOffset _durableAdmissionFloor = DateTimeOffset.MinValue;
+
+ // Acceptances and completions are durable once a completed write captured their sequence number.
+ private long _lastSequence;
+ private long _capturedSequence;
+ private long _durableSequence;
+ private long _lastCompletionSequence;
+ private bool _capturedSnapshot;
+
+ // Whether the last inbox command in the journal records that an activation stopped without an interrupted attempt.
+ private bool _stoppedCleanly;
+
+ // Whether this activation finished its recovery bookkeeping, so its stop can be recorded as clean.
+ private bool _recovered;
+
+ // The earliest due time, on the Durable Jobs clock, of accepted or retrying messages staged since the last capture.
+ private DateTimeOffset? _stagedWakeup;
+
+ private bool IsDurable(InboxEntry entry) => entry.Sequence <= _durableSequence;
+
+ // Whether every processed record which deduplication relies on is durable.
+ private bool AreCompletionsDurable => _lastCompletionSequence <= _durableSequence;
+
+ // The handler attempt in progress, whose staged changes the inbox discards if it fails.
+ private HandlerExecution? _execution;
+
+ // Every journal write prepares every state, so a write which makes a message or a work source's work pending ensures
+ // its wake-up before it can become durable. After messaging fails, in-memory state may hold changes which must not
+ // commit, such as a failed handler's, so every later write fails and recovery starts from the journal.
+ ValueTask IStateMachine.PrepareWriteAsync(CancellationToken cancellationToken)
+ {
+ if (_pump.IsFailed)
+ {
+ return ValueTask.FromException(new InvalidOperationException(
+ "Messaging failed for this activation, so its uncommitted changes are discarded by recovery from the journal."));
+ }
+
+ var dueTime = _stagedWakeup;
+ if (GetWorkSourceDelay() is { } delay)
+ {
+ var workDueTime = MessagingTime.AddClamped(Shared.JobTimeProvider.GetUtcNow(), delay);
+ if (dueTime is null || workDueTime < dueTime) dueTime = workDueTime;
+ }
+
+ return dueTime is { } due ? _pump.EnsureWakeupAsync(due, cancellationToken) : ValueTask.CompletedTask;
+ }
+
+ private void OnWriteCaptured(bool snapshot = false)
+ {
+ _capturedSequence = _lastSequence;
+ _capturedAdmissionFloor = _admissionFloor;
+ _capturedSnapshot = snapshot;
+ _stagedWakeup = null;
+ }
+
+ void IStateMachine.OnWriteCompleted()
+ {
+ _durableSequence = _capturedSequence;
+ _durableAdmissionFloor = _capturedAdmissionFloor;
+ // Floor-only appends stay constant-time; snapshots reclaim history after their admission floor is durable.
+ if (_capturedSnapshot) CompactProcessed();
+ _pump.Signal();
+ }
+
+ private DateTimeOffset GetAdmissionFloor(DateTimeOffset now)
+ {
+ var nowTicks = now.UtcTicks;
+ var candidate = new DateTimeOffset(Math.Max(0, nowTicks - Options.DeduplicationWindow.Ticks), TimeSpan.Zero);
+ return candidate > _admissionFloor ? candidate : _admissionFloor;
+ }
+
+ private static bool IsAtOrBelowFloor(InboxProcessedEntry entry, DateTimeOffset floor) =>
+ entry.SentAt <= floor;
+
+ private void CompactProcessed()
+ {
+ foreach (var (key, entry) in _processed)
+ {
+ if (entry.Sequence <= _durableSequence && IsAtOrBelowFloor(entry, _durableAdmissionFloor)) _processed.Remove(key);
+ }
+ }
+
+ private void Emit(InboxCommand command)
+ {
+ Shared.InboxCodec.WriteSet(command, _writer);
+ Apply(command);
+ var dueTime = command switch
+ {
+ { Kind: InboxCommandKind.Accept, Message: { } message } => message.Envelope.DeliverAt ?? DateTimeOffset.MinValue,
+ { Kind: InboxCommandKind.Retry, State: { } state } => state.NextAttemptAt,
+ { Kind: InboxCommandKind.ExtendSentAt, MessageId: var key } when _messages.TryGetValue(key, out var pending) => GetDueTime(pending),
+ _ => (DateTimeOffset?)null
+ };
+ if (dueTime is { } due && (_stagedWakeup is null || due < _stagedWakeup)) _stagedWakeup = due;
+ }
+
+ void IStateMachine.Reset(JournalStreamWriter writer)
+ {
+ ReportDepth(-_messages.Count);
+ _writer = writer;
+ _messages.Clear();
+ _processed.Clear();
+ _deadLetters.Clear();
+ _admissionFloor = _capturedAdmissionFloor = _durableAdmissionFloor = DateTimeOffset.MinValue;
+ _stagedWakeup = null;
+ _capturedSnapshot = false;
+ _stoppedCleanly = false;
+ _lastSequence = _capturedSequence = _durableSequence = _lastCompletionSequence = 0;
+ }
+
+ void IStateMachine.ReplayEntry(JournalEntry entry, JournalReplayContext context) =>
+ context.GetRequiredCommandCodec(entry.FormatKey, Shared.InboxCodec).Apply(entry.Reader, this);
+
+ void IStateMachine.OnRecoveryCompleted()
+ {
+ foreach (var (key, entry) in _messages)
+ {
+ EnvelopeValidation.Validate(entry.Message);
+ ValidateRecoveredSentAt(entry.Message.SentAt);
+ if (key != entry.Message.Envelope.MessageId)
+ {
+ throw new InvalidOperationException("The recovered inbox key does not match its command identity.");
+ }
+ }
+
+ foreach (var (key, letter) in _deadLetters)
+ {
+ EnvelopeValidation.Validate(letter.Message);
+ ValidateRecoveredSentAt(letter.Message.SentAt);
+ if (key != letter.Message.Envelope.MessageId)
+ {
+ throw new InvalidOperationException("The recovered inbox dead-letter key does not match its command identity.");
+ }
+ }
+
+ foreach (var entry in _processed.Values) ValidateRecoveredSentAt(entry.SentAt);
+
+ _capturedSequence = _durableSequence = _lastSequence;
+ _capturedAdmissionFloor = _durableAdmissionFloor = _admissionFloor;
+ CompactProcessed();
+ }
+
+ void IStateMachine.WritePendingEntries(JournalStreamWriter writer) => OnWriteCaptured();
+
+ void IStateMachine.WriteSnapshot(JournalStreamWriter writer)
+ {
+ // The floor and compacted history commit atomically. Keep the in-memory records until acknowledgement, so
+ // an interleaving delivery still deduplicates while the snapshot's admission floor is not yet durable.
+ var floor = GetAdmissionFloor(Shared.JobTimeProvider.GetUtcNow());
+ Shared.InboxCodec.WriteSet(new()
+ {
+ Kind = InboxCommandKind.Snapshot,
+ Snapshot = new()
+ {
+ Messages = _messages.Values.ToArray(),
+ Processed = _processed.Values.Where(entry => !IsAtOrBelowFloor(entry, floor)).ToArray(),
+ DeadLetters = _deadLetters.Values.ToArray(),
+ AdmissionFloor = floor,
+ StoppedCleanly = _stoppedCleanly
+ }
+ }, writer);
+ _admissionFloor = floor;
+ OnWriteCaptured(snapshot: true);
+ }
+
+ void IDurableValueCommandHandler.ApplySet(InboxCommand command) => Apply(command);
+
+ private void Apply(InboxCommand command)
+ {
+ if (command.Kind != InboxCommandKind.Snapshot) _stoppedCleanly = command.Kind == InboxCommandKind.CleanStop;
+ switch (command.Kind)
+ {
+ case InboxCommandKind.Accept:
+ var message = command.Message ?? throw new InvalidOperationException("An accept command requires its message.");
+ _messages.Add(message.Envelope.MessageId, new InboxEntry { Message = message, State = new(), Sequence = ++_lastSequence });
+ _processed.Remove(message.Envelope.MessageId);
+ ReportDepth(1);
+ break;
+ case InboxCommandKind.Retry:
+ _messages[command.MessageId].State = command.State ?? throw new InvalidOperationException("A retry command requires its state.");
+ break;
+ case InboxCommandKind.Complete:
+ if (_messages.Remove(command.MessageId)) ReportDepth(-1);
+
+ _lastCompletionSequence = ++_lastSequence;
+ _processed[command.MessageId] = new()
+ {
+ MessageId = command.MessageId,
+ SentAt = command.SentAt,
+ Rejection = command.Rejection,
+ Sequence = _lastSequence
+ };
+ if (command.DeadLetter is { } letter) _deadLetters[command.MessageId] = letter;
+ break;
+ case InboxCommandKind.AdvanceAdmissionFloor:
+ AdvanceAdmissionFloor(command.AdmissionFloor);
+ break;
+ case InboxCommandKind.ExtendSentAt:
+ // Pending entries are present in every snapshot; processed extensions use completion upserts.
+ if (!_messages.TryGetValue(command.MessageId, out var pending))
+ {
+ throw new InvalidOperationException("An ExtendSentAt command requires its pending entry.");
+ }
+ if (command.SentAt > pending.Message.SentAt)
+ {
+ pending.Message = pending.Message with { SentAt = command.SentAt };
+ pending.Sequence = ++_lastSequence;
+ }
+ break;
+ case InboxCommandKind.RemoveDeadLetters:
+ foreach (var key in command.RemovedKeys!) _deadLetters.Remove(key);
+ break;
+ case InboxCommandKind.CleanStop:
+ case InboxCommandKind.Resume:
+ break;
+ case InboxCommandKind.Snapshot:
+ var snapshot = command.Snapshot ?? throw new InvalidOperationException("A snapshot command requires its state.");
+ AdvanceAdmissionFloor(snapshot.AdmissionFloor);
+ _stoppedCleanly = snapshot.StoppedCleanly;
+ var previousCount = _messages.Count;
+ _messages.Clear();
+ _processed.Clear();
+ _deadLetters.Clear();
+ foreach (var restored in snapshot.Messages) _messages.Add(restored.Message.Envelope.MessageId, restored);
+
+ ReportDepth(_messages.Count - previousCount);
+
+ foreach (var processed in snapshot.Processed)
+ {
+ _processed.Add(processed.MessageId, processed);
+ }
+
+ foreach (var restored in snapshot.DeadLetters) _deadLetters.Add(restored.Message.Envelope.MessageId, restored);
+ break;
+ default:
+ throw new NotSupportedException($"Inbox command '{command.Kind}' is not supported.");
+ }
+ }
+
+ private void AdvanceAdmissionFloor(DateTimeOffset floor)
+ {
+ if (floor < _admissionFloor) throw new InvalidOperationException("The inbox admission floor cannot move backward.");
+ _admissionFloor = floor;
+ }
+
+ private static void ValidateRecoveredSentAt(DateTimeOffset sentAt)
+ {
+ // Admission requires a time above a floor which is at least MinValue, so default cannot be admitted state.
+ if (sentAt == DateTimeOffset.MinValue) throw new InvalidOperationException("A recovered inbox entry requires its admitted send time.");
+ }
+}
+
+internal enum InboxCommandKind
+{
+ Accept = 1,
+ Retry = 2,
+ Complete = 3,
+ RemoveDeadLetters = 4,
+ Snapshot = 6,
+ AdvanceAdmissionFloor = 7,
+ ExtendSentAt = 8,
+
+ // An activation stopped with no handler attempt interrupted, so recovery charges no attempt.
+ CleanStop = 9,
+
+ // A recovered activation consumed a clean stop, so a later unclean stop is charged.
+ Resume = 10
+}
+
+[GenerateSerializer, Alias("Orleans.Messaging.InboxCommand")]
+internal sealed class InboxCommand
+{
+ [Id(0)] public InboxCommandKind Kind { get; init; }
+ [Id(1)] public InboxMessage? Message { get; init; }
+ [Id(2)] public MessageId MessageId { get; init; }
+ [Id(3)] public InboxMessageState? State { get; init; }
+ [Id(5)] public InboxDeadLetter? DeadLetter { get; init; }
+ [Id(6)] public MessageId[]? RemovedKeys { get; init; }
+ [Id(7)] public InboxSnapshot? Snapshot { get; init; }
+ [Id(8)] public InboxRejection? Rejection { get; init; }
+ [Id(9)] public DateTimeOffset SentAt { get; init; }
+ [Id(10)] public DateTimeOffset AdmissionFloor { get; init; }
+}
+
+[GenerateSerializer, Alias("Orleans.Messaging.InboxSnapshot")]
+internal sealed class InboxSnapshot
+{
+ [Id(0)] public InboxEntry[] Messages { get; init; } = [];
+ [Id(1)] public InboxProcessedEntry[] Processed { get; init; } = [];
+ [Id(2)] public InboxDeadLetter[] DeadLetters { get; init; } = [];
+ [Id(3)] public DateTimeOffset AdmissionFloor { get; init; } = DateTimeOffset.MinValue;
+ [Id(4)] public bool StoppedCleanly { get; init; }
+}
+
+[GenerateSerializer, Alias("Orleans.Messaging.InboxProcessedEntry")]
+internal sealed class InboxProcessedEntry
+{
+ [Id(0)] public MessageId MessageId { get; init; }
+ [Id(2)] public InboxRejection? Rejection { get; init; }
+ [Id(3)] public required DateTimeOffset SentAt { get; init; }
+ [field: NonSerialized]
+ [JsonIgnore] public long Sequence { get; init; }
+}
+
+/// The answer which an inbox sink gave when it rejected a message on delivery.
+[GenerateSerializer, Alias("Orleans.Messaging.InboxRejection")]
+internal sealed class InboxRejection
+{
+ [Id(0)] public required string Reason { get; init; }
+ [Id(1)] public string? Code { get; init; }
+}
+
+[GenerateSerializer, Alias("Orleans.Messaging.InboxEntry")]
+internal sealed class InboxEntry
+{
+ [Id(0)] public required InboxMessage Message { get; set; }
+ [Id(1)] public required InboxMessageState State { get; set; }
+ [field: NonSerialized]
+ [JsonIgnore] public long Sequence { get; set; }
+}
+
+[JsonSerializable(typeof(InboxCommand))]
+[JsonSerializable(typeof(InboxSnapshot))]
+internal sealed partial class InboxJsonContext : JsonSerializerContext;
diff --git a/src/Orleans.Messaging/Inbox.cs b/src/Orleans.Messaging/Inbox.cs
new file mode 100644
index 00000000000..31ec2206584
--- /dev/null
+++ b/src/Orleans.Messaging/Inbox.cs
@@ -0,0 +1,849 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Diagnostics.CodeAnalysis;
+using System.Linq;
+using System.Runtime.ExceptionServices;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Logging;
+using Orleans.Journaling;
+using Orleans.Messaging.Configuration;
+using Orleans.Messaging.Diagnostics;
+using Orleans.Runtime;
+
+namespace Orleans.Messaging;
+
+///
+/// Accepts, deduplicates, and processes durable incoming messages for one grain.
+///
+///
+/// All state transitions run on the activation scheduler, so each check-then-emit sequence is atomic.
+/// Accepted messages are processed only after their acceptance is durable and their ,
+/// if any, has passed.
+///
+internal sealed partial class Inbox : IInbox, IMessagingExtension, IStateMachine, IReportsStagedChanges, IDurableValueCommandHandler
+{
+ private readonly MessagingPump _pump;
+ private readonly InboxDispatcher _handlers = new([]);
+ private readonly List _sinks = [];
+ private readonly List _workSources = [];
+
+ public Inbox(MessagingPump pump)
+ {
+ _pump = pump;
+ pump.StateManager.RegisterStateMachine(MessagingStateNames.Inbox, this);
+ pump.Register(this);
+ }
+
+ private MessagingShared Shared => _pump.Shared;
+ private DurableMessagingOptions Options => _pump.Shared.Options;
+ private ILogger Logger => _pump.Shared.InboxLogger;
+ private TimeProvider TimeProvider => _pump.Shared.TimeProvider;
+ private IGrainContext GrainContext => _pump.GrainContext;
+
+ internal int Count => _messages.Count;
+
+ internal IReadOnlyDictionary MessageState => _messages;
+
+ internal IReadOnlyDictionary DeadLetterState => _deadLetters;
+
+ internal InboxDispatcher Handlers => _handlers;
+
+ public IInbox Register(IInboxHandler handler)
+ {
+ _handlers.Add(handler);
+
+ // Pending messages wait for a handler without holding a wake-up, so a late registration resumes them.
+ if (_messages.Count > 0) _pump.RequestWakeup();
+ return this;
+ }
+
+ public IInbox Register(IInboxMessageSink sink)
+ {
+ ArgumentNullException.ThrowIfNull(sink);
+ _sinks.Add(sink);
+ return this;
+ }
+
+ public IInbox Register(IInboxWorkSource source)
+ {
+ ArgumentNullException.ThrowIfNull(source);
+ _workSources.Add(source);
+ return this;
+ }
+
+ public async ValueTask DeliverAsync(InboxMessage message, CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ _pump.ShutdownToken.ThrowIfCancellationRequested();
+ EnvelopeValidation.Validate(message);
+
+ var key = message.Envelope.MessageId;
+ var receiverTime = Shared.JobTimeProvider.GetUtcNow();
+ if (message.SentAt > MessagingTime.AddClamped(receiverTime, Options.DeduplicationWindow))
+ {
+ // Recorded outcomes and expiration decisions survive clock rollback. Bound only a time we would store.
+ var wouldStoreTime = _processed.TryGetValue(key, out var processed)
+ ? message.SentAt > processed.SentAt
+ : _messages.TryGetValue(key, out var pending)
+ ? message.SentAt > pending.Message.SentAt
+ : message.SentAt > GetAdmissionFloor(receiverTime);
+ if (wouldStoreTime)
+ {
+ LogSentAtTooFarAhead(Logger, key, GrainContext.GrainId, message.SentAt, receiverTime);
+ throw new ArgumentOutOfRangeException(nameof(message), message.SentAt,
+ "SentAt must not be later than the receiver's clock plus DeduplicationWindow.");
+ }
+ }
+
+ // Delivery is a grain call, so the activation is servicing requests.
+ _pump.EnsureStarted();
+ try
+ {
+ var stagedBeforeDuplicate = _pump.StagedChangeCount;
+ if (TryStageDuplicate(message, out var pending))
+ {
+ var rejection = pending is null ? _processed[key].Rejection : null;
+ ExcludeFromHandlerAttempt(stagedBeforeDuplicate);
+ // The sender may retire its copy once this returns, so the acceptance or the processed record must be
+ // durable. A completion staged by a handler or a sink can still be in flight.
+ if (pending is null ? !AreCompletionsDurable : !IsDurable(pending)) await PersistForCallerAsync(cancellationToken).ConfigureAwait(true);
+ if (pending is not null) _pump.Signal();
+
+ Shared.Instruments.OnInboxMessageReceived(Shared.GrainType, "duplicate");
+
+ // A sender whose answer was lost delivers again, so it receives the rejection which a sink first answered.
+ return rejection is not null
+ ? DeliveryResult.DeadLettered(rejection.Reason, rejection.Code)
+ : DeliveryResult.Duplicate();
+ }
+
+ // Retained identities precede expiration: a lost acknowledgement may belong to pending or completed work.
+ // Only a durable, non-decreasing floor permits forgetting that evidence, regardless of later clock motion.
+ var floor = GetAdmissionFloor(receiverTime);
+ if (message.SentAt <= floor)
+ {
+ var stagedBeforeExpiration = _pump.StagedChangeCount;
+ if (message.SentAt > _admissionFloor)
+ {
+ Emit(new() { Kind = InboxCommandKind.AdvanceAdmissionFloor, AdmissionFloor = floor });
+ ExcludeFromHandlerAttempt(stagedBeforeExpiration);
+ }
+ if (_admissionFloor > _durableAdmissionFloor) await PersistForCallerAsync(cancellationToken).ConfigureAwait(true);
+ Shared.Instruments.OnInboxMessageReceived(Shared.GrainType, "expired");
+ LogMessageExpired(Logger, key, GrainContext.GrainId, message.SentAt, _admissionFloor);
+ return DeliveryResult.Expired();
+ }
+
+ MessagingEvents.EmitInboxMessageReceiving(GrainContext, message);
+
+ // Sink-owned messages never occupy inbox capacity and need no handler. A sink's duplicate can still be staged,
+ // so every outcome except backpressure is reported once the write is durable.
+ var stagedBefore = _pump.StagedChangeCount;
+ if (StageSinkAcceptance(message) is { } sinkResult)
+ {
+ ExcludeFromHandlerAttempt(stagedBefore);
+ if (sinkResult.Status != DeliveryStatus.Backpressured) await PersistForCallerAsync(cancellationToken).ConfigureAwait(true);
+ return sinkResult;
+ }
+
+ // Every pending message, scheduled or not, holds capacity until it completes.
+ if (_messages.Count >= Options.MaxCapacity)
+ {
+ Shared.Instruments.OnInboxMessageReceived(Shared.GrainType, "backpressured");
+ return DeliveryResult.Backpressured();
+ }
+
+ if (_handlers.IsEmpty)
+ {
+ Shared.Instruments.OnInboxMessageReceived(Shared.GrainType, "handler_not_found");
+ LogHandlerNotFound(Logger, key, GrainContext.GrainId);
+ return DeliveryResult.HandlerNotFound();
+ }
+
+ Emit(new() { Kind = InboxCommandKind.Accept, Message = message });
+ ExcludeFromHandlerAttempt(stagedBefore);
+ await PersistForCallerAsync(cancellationToken).ConfigureAwait(true);
+ Shared.Instruments.OnInboxMessageReceived(Shared.GrainType, "accepted");
+ LogMessageAccepted(Logger, key, GrainContext.GrainId);
+ return DeliveryResult.Accepted();
+ }
+ catch (Exception exception) when (exception is not OperationCanceledException || !cancellationToken.IsCancellationRequested)
+ {
+ LogDeliveryOperationFailed(Logger, exception, key, GrainContext.GrainId);
+ throw;
+ }
+ }
+
+ ///
+ /// Attributes changes which a delivery staged since to the delivery, which interleaves
+ /// with a handler attempt waiting at an await, so the attempt's failure doesn't treat them as its own.
+ ///
+ private void ExcludeFromHandlerAttempt(long stagedBefore)
+ {
+ if (_execution is { } execution) execution.StagedChanges += _pump.StagedChangeCount - stagedBefore;
+ }
+
+ // Whether a state staged a change during the attempt which isn't a delivery's; a manager which doesn't track staged
+ // changes may have.
+ private bool StagedDuring(HandlerExecution execution) =>
+ _pump.StagedChangeCount is var count && (count < 0 || count != execution.StagedChanges);
+
+ ///
+ /// Stages acceptance of a message which this grain sends to itself, for the caller's next journal write.
+ ///
+ ///
+ ///
+ /// Applies the identity rules of synchronously and without writing: a completed or
+ /// equivalent pending message is a duplicate, and a different pending command with the same ID conflicts.
+ /// The acceptance becomes durable atomically with the caller's other staged state, after which the inbox processes
+ /// the message as usual. When the pump is not running yet, the durable wake-up which the write ensures for new
+ /// acceptances starts it once the activation services calls, so handlers never run during activation.
+ ///
+ ///
+ /// A synchronous send cannot report backpressure or a missing handler, so the message is admitted regardless of
+ /// and of handler registration, and a sink which backpressures
+ /// it fails the send. The message still occupies capacity for deliveries from other grains until it completes, and
+ /// processing waits for a handler to be registered.
+ ///
+ ///
+ ///
+ /// The inbox holds a different pending command with the same ID, or an inbox sink backpressured the message or
+ /// reported an unsupported status.
+ ///
+ internal void StageLocal(InboxMessage message)
+ {
+ // Reuse the outbox's clock observation: a fresh read could observe another rollback.
+ var now = message.SentAt;
+ // A local send commits with the inbox, so it can use the first admitted time after a clock rollback.
+ if (message.SentAt <= _admissionFloor)
+ {
+ if (_admissionFloor == DateTimeOffset.MaxValue) throw new InvalidOperationException("The inbox admission floor leaves no representable send time for a new local message.");
+ message = message with { SentAt = _admissionFloor.AddTicks(1) };
+ }
+ Debug.Assert(message.SentAt <= MessagingTime.AddClamped(now, Options.DeduplicationWindow)
+ || message.SentAt <= _admissionFloor.AddTicks(1));
+ if (TryStageDuplicate(message, out _))
+ {
+ Shared.Instruments.OnInboxMessageReceived(Shared.GrainType, "duplicate");
+ return;
+ }
+
+ if (StageSinkAcceptance(message) is { } sinkResult)
+ {
+ if (sinkResult.Status == DeliveryStatus.Backpressured)
+ {
+ throw new InvalidOperationException(
+ $"An inbox message sink backpressured message '{message.Envelope.MessageId}', which the grain sent to itself.");
+ }
+
+ return;
+ }
+
+ try
+ {
+ Emit(new() { Kind = InboxCommandKind.Accept, Message = message });
+ }
+ catch (Exception exception)
+ {
+ LogStagingFailed(Logger, exception);
+ _pump.Fail(exception, "Inbox staging failed.");
+ throw;
+ }
+
+ Shared.Instruments.OnInboxMessageReceived(Shared.GrainType, "accepted");
+ LogMessageAccepted(Logger, message.Envelope.MessageId, GrainContext.GrainId);
+ }
+
+ ///
+ /// Offers a message to the registered sinks and stages the outcome which the first sink to take the message reports.
+ ///
+ /// The delivery result, or when every sink declines the message.
+ ///
+ /// An accepted message journals only its processed record, in the write which commits the sink's staged changes.
+ /// A duplicate or backpressured message stages nothing. A sink which dead-letters the message stages nothing itself,
+ /// so the message is dead-lettered with its payload and recorded as processed. A sink exception, or an unsupported
+ /// status, leaves the outcome unknown: it propagates to the caller with nothing staged, so the sender retries.
+ ///
+ /// A sink reported an unsupported delivery status.
+ private DeliveryResult? StageSinkAcceptance(InboxMessage message)
+ {
+ var key = message.Envelope.MessageId;
+ foreach (var sink in _sinks)
+ {
+ if (!sink.TryAccept(message, out var result)) continue;
+ if (result.Status is DeliveryStatus.Duplicate or DeliveryStatus.Backpressured)
+ {
+ Shared.Instruments.OnInboxMessageReceived(Shared.GrainType, result.Status == DeliveryStatus.Duplicate ? "duplicate" : "backpressured");
+ return result;
+ }
+
+ if (result.Status is not (DeliveryStatus.Accepted or DeliveryStatus.DeadLettered))
+ {
+ throw new InvalidOperationException($"The inbox message sink reported the unsupported delivery status '{result.Status}'.");
+ }
+
+ var accepted = result.Status == DeliveryStatus.Accepted;
+ var reason = result.Message ?? "The inbox message sink dead-lettered the message.";
+ try
+ {
+ if (accepted)
+ {
+ StageCompletion(message, deadLetter: null);
+ }
+ else
+ {
+ LogSinkDeadLettered(Logger, key, GrainContext.GrainId, reason, result.Code);
+ StageDeadLetter(message, reason, attemptCount: 1, new() { Reason = reason, Code = result.Code });
+ }
+ }
+ catch (Exception exception)
+ {
+ // The sink's staged changes cannot commit without the processed record.
+ LogStagingFailed(Logger, exception);
+ _pump.Fail(exception, "Inbox staging failed.");
+ throw;
+ }
+
+ Shared.Instruments.OnInboxMessageReceived(Shared.GrainType, accepted ? "accepted" : "dead_lettered");
+ Shared.Instruments.OnInboxMessageProcessed(Shared.GrainType, accepted ? "success" : "dead_lettered");
+ if (!accepted) return DeliveryResult.DeadLettered(reason, result.Code);
+ LogMessageAccepted(Logger, key, GrainContext.GrainId);
+ return DeliveryResult.Accepted();
+ }
+
+ return null;
+ }
+
+ ///
+ /// Recognizes a retained or pending identity and stages any extension of its observed send time.
+ ///
+ /// The received message.
+ /// The pending entry, or when the message was completed or is new.
+ private bool TryStageDuplicate(InboxMessage message, out InboxEntry? pending)
+ {
+ var key = message.Envelope.MessageId;
+ if (_processed.TryGetValue(key, out var processed))
+ {
+ ExtendSentAt(message, processed.SentAt);
+ pending = null;
+ return true;
+ }
+
+ if (_messages.TryGetValue(key, out pending))
+ {
+ if (!EnvelopeEquivalence.AreSameCommand(pending.Message, message))
+ {
+ throw new InvalidOperationException(
+ $"The inbox already contains a different command with message ID '{key}'.");
+ }
+
+ ExtendSentAt(message, pending.Message.SentAt);
+ return true;
+ }
+
+ return false;
+ }
+
+ private void ExtendSentAt(InboxMessage message, DateTimeOffset recordedSentAt)
+ {
+ if (message.SentAt <= recordedSentAt) return;
+ var key = message.Envelope.MessageId;
+ try
+ {
+ if (_processed.TryGetValue(key, out var processed))
+ {
+ // An in-flight snapshot can omit the old record, so replay needs the complete decision, not a delta.
+ StageCompletion(message, deadLetter: null, rejection: processed.Rejection);
+ }
+ else
+ {
+ Debug.Assert(_messages.ContainsKey(key));
+ Emit(new() { Kind = InboxCommandKind.ExtendSentAt, MessageId = key, SentAt = message.SentAt });
+ }
+ }
+ catch (Exception exception)
+ {
+ LogStagingFailed(Logger, exception);
+ _pump.Fail(exception, "Inbox retention update failed.");
+ throw;
+ }
+ }
+
+ ///
+ /// Processes one batch of durable, due messages and returns whether any message was processed.
+ ///
+ internal async ValueTask ProcessReadyAsync(CancellationToken cancellationToken)
+ {
+ if (_handlers.IsEmpty || _messages.Count == 0)
+ {
+ return false;
+ }
+
+ var jobNow = Shared.JobTimeProvider.GetUtcNow();
+ var batch = new List(Math.Min(Options.InboxBatchSize, _messages.Count));
+ foreach (var entry in _messages.Values)
+ {
+ if (IsDurable(entry) && GetDelay(entry, jobNow) == TimeSpan.Zero)
+ {
+ batch.Add(entry);
+ if (batch.Count == Options.InboxBatchSize) break;
+ }
+ }
+
+ if (batch.Count == 0)
+ {
+ return false;
+ }
+
+ foreach (var entry in batch)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ await ProcessAsync(entry, cancellationToken).ConfigureAwait(true);
+ }
+
+ await PersistAsync().ConfigureAwait(true);
+ return true;
+ }
+
+ private async ValueTask ProcessAsync(InboxEntry entry, CancellationToken cancellationToken)
+ {
+ if (!IsCurrent(entry))
+ {
+ return;
+ }
+
+ var start = Stopwatch.GetTimestamp();
+ var execution = new HandlerExecution(this, entry) { Active = true, StagedChanges = _pump.StagedChangeCount };
+ _execution = execution;
+ Exception? error = null;
+ try
+ {
+ await _handlers.HandleAsync(new InboxHandlerContext(entry.Message, execution), cancellationToken).ConfigureAwait(true);
+ execution.ThrowIfRejected();
+ if (!execution.Completed)
+ {
+ throw new InvalidOperationException("Inbox handlers must call Complete or Fail before returning successfully.");
+ }
+ }
+ catch (Exception) when (execution.Rejection is { } rejection)
+ {
+ _pump.Fail(rejection.SourceException, "Inbox operation failed.");
+ rejection.Throw();
+ }
+ catch (Exception exception) when (execution.Completed)
+ {
+ LogHandlerException(Logger, exception, entry.Message.Envelope.MessageId, GrainContext.GrainId);
+ }
+ catch (Exception exception) when (StagedDuring(execution))
+ {
+ // The attempt ended without an outcome after it staged changes. Pending changes share one journal buffer, so a
+ // retry record would commit them; failing the activation discards them, and the message runs again after
+ // recovery from the journal.
+ LogHandlerException(Logger, exception, entry.Message.Envelope.MessageId, GrainContext.GrainId);
+ var failure = new InvalidOperationException(
+ $"The inbox handler for message '{entry.Message.Envelope.MessageId}' failed after staging changes, so the activation discards them.",
+ exception);
+ _pump.Fail(failure, "An inbox handler failed after staging changes.");
+ if (exception is OperationCanceledException && cancellationToken.IsCancellationRequested) throw;
+ throw failure;
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ throw;
+ }
+ catch (Exception exception)
+ {
+ error = exception;
+ LogHandlerException(Logger, exception, entry.Message.Envelope.MessageId, GrainContext.GrainId);
+ }
+ finally
+ {
+ execution.Active = false;
+ _execution = null;
+ }
+
+ string status;
+ if (error is not null)
+ {
+ if (!IsCurrent(entry))
+ {
+ return;
+ }
+
+ status = StageFailedAttempt(entry, error.Message, error.ToString(), Shared.JobTimeProvider.GetUtcNow());
+ }
+ else
+ {
+ status = execution.DeadLetterReason is null ? "success" : "dead_lettered";
+ }
+
+ Shared.Instruments.OnInboxMessageProcessed(Shared.GrainType, status);
+ Shared.Instruments.OnInboxProcessingDuration(Stopwatch.GetElapsedTime(start), Shared.GrainType);
+ }
+
+ ///
+ /// Stages a failed attempt's outcome: a retry with backoff, or a dead letter once InboxRetryTimeout has elapsed
+ /// since the message's first failure. Returns the processing status for metrics.
+ ///
+ ///
+ /// An unknown outcome retries until the timeout; the last attempt runs when it elapses, and Fail(reason)
+ /// dead-letters a message at once. Retry deadlines follow the Durable Jobs clock, which schedules the next wake-up.
+ /// An interrupted attempt starts the retry window but dead-letters only a message whose window an earlier failure
+ /// started, so one unclean stop never dead-letters a message by itself.
+ ///
+ private string StageFailedAttempt(InboxEntry entry, string reason, string detail, DateTimeOffset now, bool interrupted = false)
+ {
+ var attempts = checked(entry.State.AttemptCount + 1);
+ var expirationTime = entry.State.ExpirationTime ?? MessagingTime.AddClamped(now, Options.InboxRetryTimeout);
+ if (expirationTime <= now && (!interrupted || entry.State.ExpirationTime is not null))
+ {
+ StageDeadLetter(entry.Message, reason, attempts);
+ return "dead_lettered";
+ }
+
+ var retryAt = MessagingTime.AddClamped(now, MessagingTime.GetBackoff(Options.RetryInitialInterval, attempts));
+ Emit(new()
+ {
+ Kind = InboxCommandKind.Retry,
+ MessageId = entry.Message.Envelope.MessageId,
+ State = new()
+ {
+ AttemptCount = attempts,
+ LastError = detail,
+ NextAttemptAt = retryAt < expirationTime ? retryAt : expirationTime,
+ ExpirationTime = expirationTime
+ }
+ });
+ return "retry";
+ }
+
+ private bool IsCurrent(InboxEntry entry) =>
+ _messages.TryGetValue(entry.Message.Envelope.MessageId, out var current) && ReferenceEquals(current, entry);
+
+ private void StageCompletion(InboxMessage message, InboxDeadLetter? deadLetter, InboxRejection? rejection = null) =>
+ Emit(new()
+ {
+ Kind = InboxCommandKind.Complete,
+ MessageId = message.Envelope.MessageId,
+ SentAt = message.SentAt,
+ DeadLetter = deadLetter,
+ Rejection = rejection
+ });
+
+ private void StageDeadLetter(InboxMessage message, string reason, int attemptCount, InboxRejection? rejection = null)
+ {
+ var now = TimeProvider.GetUtcNow();
+ var key = message.Envelope.MessageId;
+ CompactDeadLetters(now, reservedCapacity: _deadLetters.ContainsKey(key) ? 0 : 1);
+ StageCompletion(message, new InboxDeadLetter
+ {
+ Message = message,
+ DeadLetteredAt = now,
+ Reason = reason,
+ AttemptCount = attemptCount,
+ Code = rejection?.Code
+ }, rejection);
+ }
+
+ private bool CompactDeadLetters(DateTimeOffset now, int reservedCapacity = 0)
+ {
+ if (_deadLetters.Count == 0) return false;
+ var retained = new Dictionary(_deadLetters);
+ if (!DeadLetterRetention.Compact(retained, now, Options.DeadLetterRetentionPeriod, Options.MaxRetainedDeadLetters,
+ static entry => entry.DeadLetteredAt, reservedCapacity))
+ {
+ return false;
+ }
+
+ Emit(new()
+ {
+ Kind = InboxCommandKind.RemoveDeadLetters,
+ RemovedKeys = _deadLetters.Keys.Where(key => !retained.ContainsKey(key)).ToArray()
+ });
+ return true;
+ }
+
+ internal bool RemoveDeadLetter(MessageId messageId)
+ {
+ _pump.ShutdownToken.ThrowIfCancellationRequested();
+ if (!_deadLetters.ContainsKey(messageId)) return false;
+ Emit(new() { Kind = InboxCommandKind.RemoveDeadLetters, RemovedKeys = [messageId] });
+ return true;
+ }
+
+ ///
+ /// Returns the delay until the next message or work source is due, or when neither the inbox
+ /// nor any work source holds work which this activation can perform. Messages wait without a wake-up until a handler
+ /// is registered.
+ ///
+ /// The current Durable Jobs time, which the caller uses to schedule the wake-up.
+ internal TimeSpan? GetNextWakeupDelay(DateTimeOffset jobNow)
+ {
+ var next = GetWorkSourceDelay();
+ if (_messages.Count == 0 || _handlers.IsEmpty || next <= TimeSpan.Zero) return next;
+ foreach (var entry in _messages.Values)
+ {
+ // Capture follows synchronous preparation; its wake-up covers the write until acknowledgement.
+ if (entry.Sequence > _capturedSequence) continue;
+ var delay = GetDelay(entry, jobNow);
+ if (delay == TimeSpan.Zero) return delay;
+ if (next is null || delay < next) next = delay;
+ }
+
+ return next;
+ }
+
+ // Work sources may report any non-positive delay to request a prompt wake-up; it is normalized to zero so that due
+ // times derived from it stay representable.
+ private TimeSpan? GetWorkSourceDelay()
+ {
+ TimeSpan? next = null;
+ foreach (var source in _workSources)
+ {
+ if (source.GetWakeupDelay() is not { } delay) continue;
+ if (delay <= TimeSpan.Zero) return TimeSpan.Zero;
+ if (next is null || delay < next) next = delay;
+ }
+
+ return next;
+ }
+
+ // DeliverAt and retry deadlines are in the Durable Jobs time domain, which schedules wake-ups.
+ private static TimeSpan GetDelay(InboxEntry entry, DateTimeOffset jobNow)
+ {
+ var dueTime = GetDueTime(entry);
+ return dueTime > jobNow ? dueTime - jobNow : TimeSpan.Zero;
+ }
+
+ private static DateTimeOffset GetDueTime(InboxEntry entry)
+ {
+ var dueTime = entry.Message.Envelope.DeliverAt ?? DateTimeOffset.MinValue;
+ return entry.State.NextAttemptAt is { } retryAt && retryAt > dueTime ? retryAt : dueTime;
+ }
+
+ internal async Task StartAsync(CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ if (GrainContext.GrainInstance is not DurableGrain and not IMessagingGrain)
+ {
+ throw new InvalidOperationException("Inbox activation requires IMessagingGrain or DurableGrain.");
+ }
+
+ var compacted = CompactDeadLetters(TimeProvider.GetUtcNow());
+ if (ChargeInterruptedAttempts() | compacted)
+ {
+ await PersistAsync().ConfigureAwait(true);
+ }
+
+ _recovered = true;
+ }
+
+ ///
+ /// Charges one failed attempt to each due message unless the previous activation journaled a clean stop, and returns
+ /// whether it staged anything.
+ ///
+ ///
+ /// An activation which fails after a handler staged changes, or whose silo crashes, stops without recording the
+ /// attempt's outcome. Charging it at recovery keeps backoff and InboxRetryTimeout bounding every failure path
+ /// without a journal write on the happy path. A crash also charges due messages which hadn't been dispatched, so
+ /// attempts can overcount by one per unclean stop; a charge alone never dead-letters a message whose retry window
+ /// hadn't started. The charge commits before the pump runs any handler.
+ ///
+ private bool ChargeInterruptedAttempts()
+ {
+ if (_messages.Count == 0) return false;
+ if (_stoppedCleanly)
+ {
+ // Consume the marker, so a later unclean stop of this activation is charged.
+ Emit(new() { Kind = InboxCommandKind.Resume });
+ return true;
+ }
+
+ var now = Shared.JobTimeProvider.GetUtcNow();
+ var charged = 0;
+ foreach (var entry in _messages.Values.ToArray())
+ {
+ // A message which isn't due couldn't have been in an attempt.
+ if (GetDelay(entry, now) != TimeSpan.Zero) continue;
+ var status = StageFailedAttempt(entry, InterruptedAttemptReason, InterruptedAttemptReason, now, interrupted: true);
+ Shared.Instruments.OnInboxMessageProcessed(Shared.GrainType, status);
+ charged++;
+ }
+
+ if (charged > 0) LogInterruptedAttemptsCharged(Logger, GrainContext.GrainId, charged);
+ return charged > 0;
+ }
+
+ private const string InterruptedAttemptReason = "The activation stopped without recording the outcome of an attempt.";
+
+ ///
+ /// Journals that this activation stopped with no handler attempt interrupted, so recovery charges none.
+ ///
+ ///
+ /// The pump calls this after its loop ends. A messaging failure, such as a handler which failed after staging
+ /// changes, records nothing, so recovery charges the attempt. A failed write leaves the stop unclean, which charges
+ /// at most one extra attempt.
+ ///
+ internal async Task RecordCleanStopAsync()
+ {
+ if (!_recovered || _pump.IsFailed || _stoppedCleanly || _messages.Count == 0) return;
+ try
+ {
+ Emit(new() { Kind = InboxCommandKind.CleanStop });
+ await _pump.StateManager.WriteStateAsync(CancellationToken.None).ConfigureAwait(true);
+ }
+ catch (Exception exception)
+ {
+ LogCleanStopFailed(Logger, exception, GrainContext.GrainId);
+ }
+ }
+
+ /// Releases the reported depth once the activation's messaging state is discarded.
+ internal void ReleaseDepth() => ReportDepth(-_messages.Count);
+
+ private void ReportDepth(int delta) => Shared.Instruments.OnInboxDepthChanged(Shared.GrainType, delta);
+
+ /// Persists staged changes while ends only the caller's wait.
+ private ValueTask PersistForCallerAsync(CancellationToken cancellationToken)
+ {
+ var write = PersistAsync();
+ if (write.IsCompleted || !cancellationToken.CanBeCanceled)
+ {
+ return write;
+ }
+
+ // The write continues to its outcome after the caller stops waiting; PersistAsync reports failures through the pump.
+ var task = write.AsTask();
+ task.Ignore();
+ return new(task.WaitAsync(cancellationToken));
+ }
+
+ private async ValueTask PersistAsync()
+ {
+ try
+ {
+ await _pump.StateManager.WriteStateAsync(CancellationToken.None).ConfigureAwait(true);
+ }
+ catch (Exception exception)
+ {
+ _pump.Fail(exception, "Inbox persistence failed.");
+ throw;
+ }
+ }
+
+ ///
+ /// Tracks one handler attempt and stages its terminal outcome.
+ ///
+ internal sealed class HandlerExecution(Inbox owner, InboxEntry entry)
+ {
+ public bool Active { get; set; }
+
+ ///
+ /// Gets or sets the journal's staged change count when the attempt started, advanced past changes which
+ /// interleaved deliveries staged.
+ ///
+ public long StagedChanges { get; set; }
+
+ public bool Completed { get; private set; }
+
+ public string? DeadLetterReason { get; private set; }
+
+ public ExceptionDispatchInfo? Rejection { get; private set; }
+
+ public void Complete()
+ {
+ ValidateAttempt();
+ if (Completed)
+ {
+ return;
+ }
+
+ try
+ {
+ owner.StageCompletion(entry.Message, deadLetter: null);
+ Completed = true;
+ }
+ catch (Exception exception)
+ {
+ Reject(exception);
+ }
+ }
+
+ public void Fail(string reason)
+ {
+ ValidateAttempt();
+ ArgumentException.ThrowIfNullOrWhiteSpace(reason);
+ if (Completed)
+ {
+ if (string.Equals(DeadLetterReason, reason, StringComparison.Ordinal))
+ {
+ return;
+ }
+
+ Reject(new InvalidOperationException("The inbox handler has already staged a different terminal outcome."));
+ }
+
+ try
+ {
+ owner.StageDeadLetter(entry.Message, reason, checked(entry.State.AttemptCount + 1));
+ DeadLetterReason = reason;
+ Completed = true;
+ }
+ catch (Exception exception)
+ {
+ Reject(exception);
+ }
+ }
+
+ public void ThrowIfRejected() => Rejection?.Throw();
+
+ [DoesNotReturn]
+ public void Reject(Exception exception)
+ {
+ Rejection ??= ExceptionDispatchInfo.Capture(exception);
+ Rejection.Throw();
+ }
+
+ private void ValidateAttempt()
+ {
+ ThrowIfRejected();
+ if (!Active)
+ {
+ Reject(new InvalidOperationException("The inbox handler context belongs to an inactive or different attempt."));
+ }
+ }
+ }
+
+ [LoggerMessage(Level = LogLevel.Error, EventName = "DeliveryOperationFailed",
+ Message = "Inbox delivery of message {MessageId} to {GrainId} failed")]
+ private static partial void LogDeliveryOperationFailed(ILogger logger, Exception exception, MessageId messageId, GrainId grainId);
+
+ [LoggerMessage(Level = LogLevel.Error, Message = "Inbox command staging failed.")]
+ private static partial void LogStagingFailed(ILogger logger, Exception exception);
+
+ [LoggerMessage(Level = LogLevel.Warning, Message = "Inbox for {ReceiverId} charged an interrupted attempt to {Count} due messages: its previous activation stopped without recording their outcome.")]
+ private static partial void LogInterruptedAttemptsCharged(ILogger logger, GrainId receiverId, int count);
+
+ [LoggerMessage(Level = LogLevel.Warning, Message = "Inbox for {ReceiverId} couldn't record a clean stop, so its next activation charges one attempt to each due message.")]
+ private static partial void LogCleanStopFailed(ILogger logger, Exception exception, GrainId receiverId);
+
+ [LoggerMessage(Level = LogLevel.Warning, Message = "Inbox for {ReceiverId} expired message {MessageId}: its send time {SentAt} is at or below the durable admission floor {AdmissionFloor}. Its earlier delivery outcome is unknown.")]
+ private static partial void LogMessageExpired(ILogger logger, MessageId messageId, GrainId receiverId, DateTimeOffset sentAt, DateTimeOffset admissionFloor);
+
+ [LoggerMessage(Level = LogLevel.Warning, Message = "Inbox for {ReceiverId} rejected message {MessageId}: its send time {SentAt} is later than receiver time {ReceiverTime} plus DeduplicationWindow.")]
+ private static partial void LogSentAtTooFarAhead(ILogger logger, MessageId messageId, GrainId receiverId, DateTimeOffset sentAt, DateTimeOffset receiverTime);
+
+ [LoggerMessage(Level = LogLevel.Warning, Message = "No inbox handler registered for message {MessageId} to {ReceiverId}")]
+ private static partial void LogHandlerNotFound(ILogger logger, MessageId messageId, GrainId receiverId);
+
+ [LoggerMessage(Level = LogLevel.Information, Message = "Accepted message {MessageId} to {ReceiverId}")]
+ private static partial void LogMessageAccepted(ILogger logger, MessageId messageId, GrainId receiverId);
+
+ [LoggerMessage(Level = LogLevel.Error, Message = "Handler threw exception for message {MessageId} to {ReceiverId}")]
+ private static partial void LogHandlerException(ILogger logger, Exception exception, MessageId messageId, GrainId receiverId);
+
+ [LoggerMessage(Level = LogLevel.Warning, Message = "Inbox message sink dead-lettered message {MessageId} to {ReceiverId} with code {Code}: {Reason}")]
+ private static partial void LogSinkDeadLettered(ILogger logger, MessageId messageId, GrainId receiverId, string reason, string? code);
+}
diff --git a/src/Orleans.Messaging/InboxDispatcher.cs b/src/Orleans.Messaging/InboxDispatcher.cs
new file mode 100644
index 00000000000..7464fb10baa
--- /dev/null
+++ b/src/Orleans.Messaging/InboxDispatcher.cs
@@ -0,0 +1,37 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Orleans.Messaging;
+
+/// Dispatches each inbox message to the first registered handler which can handle it.
+internal readonly struct InboxDispatcher
+{
+ private readonly List _handlers;
+
+ internal InboxDispatcher(List handlers) => _handlers = handlers;
+
+ internal bool IsEmpty => _handlers.Count == 0;
+
+ internal void Add(IInboxHandler handler)
+ {
+ ArgumentNullException.ThrowIfNull(handler);
+ _handlers.Add(handler);
+ }
+
+ internal ValueTask HandleAsync(IInboxHandlerContext context, CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ foreach (var handler in _handlers)
+ {
+ if (handler.CanHandle(context.Message))
+ {
+ return handler.HandleAsync(context, cancellationToken);
+ }
+ }
+
+ throw new InvalidOperationException(
+ $"No handler is registered for message subject '{context.Message.Envelope.Subject ?? ""}'.");
+ }
+}
diff --git a/src/Orleans.Messaging/InboxExtensions.cs b/src/Orleans.Messaging/InboxExtensions.cs
new file mode 100644
index 00000000000..a06ed484b88
--- /dev/null
+++ b/src/Orleans.Messaging/InboxExtensions.cs
@@ -0,0 +1,128 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Orleans.Messaging;
+
+/// Registers typed subject handlers with an .
+/// Stateful overloads store their argument directly and support static delegates.
+public static class InboxExtensions
+{
+ /// Registers a synchronous subject handler.
+ /// The body contract.
+ /// The inbox.
+ /// The subject and serializer binding.
+ /// The synchronous body and context delegate.
+ /// The inbox.
+ public static IInbox Register(this IInbox inbox, MessageType messageType, Action handler)
+ {
+ ArgumentNullException.ThrowIfNull(inbox);
+ ArgumentNullException.ThrowIfNull(messageType);
+ ArgumentNullException.ThrowIfNull(handler);
+ return inbox.Register(new SynchronousHandler(messageType, handler));
+ }
+
+ /// Registers a synchronous subject handler with an explicit argument.
+ /// The body contract.
+ /// The handler argument type.
+ /// The inbox.
+ /// The subject and serializer binding.
+ /// The argument stored with the handler.
+ /// The synchronous body, argument, and context delegate.
+ /// The inbox.
+ /// Use a static delegate to pass state without a captured closure.
+ public static IInbox Register(
+ this IInbox inbox, MessageType messageType, TArg argument, Action handler)
+ {
+ ArgumentNullException.ThrowIfNull(inbox);
+ ArgumentNullException.ThrowIfNull(messageType);
+ ArgumentNullException.ThrowIfNull(handler);
+ return inbox.Register(new SynchronousHandler(messageType, argument, handler));
+ }
+
+ /// Registers a subject handler supporting asynchronous preparation.
+ /// The body contract.
+ /// The inbox.
+ /// The subject and serializer binding.
+ /// The body, context, and attempt-token delegate.
+ /// The inbox.
+ public static IInbox Register(
+ this IInbox inbox, MessageType messageType, Func handler)
+ {
+ ArgumentNullException.ThrowIfNull(inbox);
+ ArgumentNullException.ThrowIfNull(messageType);
+ ArgumentNullException.ThrowIfNull(handler);
+ return inbox.Register(new AsynchronousHandler(messageType, handler));
+ }
+
+ /// Registers an asynchronous subject handler with an explicit argument.
+ /// The body contract.
+ /// The handler argument type.
+ /// The inbox.
+ /// The subject and serializer binding.
+ /// The argument stored with the handler.
+ /// The body, argument, context, and attempt-token delegate.
+ /// The inbox.
+ /// Use a static delegate to pass state without a captured closure.
+ public static IInbox Register(
+ this IInbox inbox, MessageType messageType, TArg argument,
+ Func handler)
+ {
+ ArgumentNullException.ThrowIfNull(inbox);
+ ArgumentNullException.ThrowIfNull(messageType);
+ ArgumentNullException.ThrowIfNull(handler);
+ return inbox.Register(new AsynchronousHandler(messageType, argument, handler));
+ }
+
+ private abstract class Handler(MessageType messageType) : IInboxHandler
+ {
+ public bool CanHandle(InboxMessage message) =>
+ string.Equals(message.Envelope.Subject, messageType.Subject, StringComparison.Ordinal);
+
+ public ValueTask HandleAsync(IInboxHandlerContext context, CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ // The delegate owns the decoded body, including when cancellation arrives during decoding.
+ var body = messageType.Decode(context.Message.Envelope);
+ return Invoke(body, context, cancellationToken);
+ }
+
+ protected abstract ValueTask Invoke(T body, IInboxHandlerContext context, CancellationToken cancellationToken);
+ }
+
+ private sealed class SynchronousHandler(
+ MessageType messageType, Action handler) : Handler(messageType)
+ {
+ protected override ValueTask Invoke(T body, IInboxHandlerContext context, CancellationToken cancellationToken)
+ {
+ handler(body, context);
+ return ValueTask.CompletedTask;
+ }
+ }
+
+ private sealed class SynchronousHandler(
+ MessageType messageType, TArg argument, Action handler) : Handler(messageType)
+ {
+ protected override ValueTask Invoke(T body, IInboxHandlerContext context, CancellationToken cancellationToken)
+ {
+ handler(body, argument, context);
+ return ValueTask.CompletedTask;
+ }
+ }
+
+ private sealed class AsynchronousHandler(
+ MessageType messageType,
+ Func handler) : Handler(messageType)
+ {
+ protected override ValueTask Invoke(T body, IInboxHandlerContext context, CancellationToken cancellationToken) =>
+ handler(body, context, cancellationToken);
+ }
+
+ private sealed class AsynchronousHandler(
+ MessageType messageType, TArg argument,
+ Func handler) : Handler(messageType)
+ {
+ protected override ValueTask Invoke(T body, IInboxHandlerContext context, CancellationToken cancellationToken) =>
+ handler(body, argument, context, cancellationToken);
+ }
+}
diff --git a/src/Orleans.Messaging/InboxHandlerContext.cs b/src/Orleans.Messaging/InboxHandlerContext.cs
new file mode 100644
index 00000000000..ed8b74b2857
--- /dev/null
+++ b/src/Orleans.Messaging/InboxHandlerContext.cs
@@ -0,0 +1,10 @@
+namespace Orleans.Messaging;
+
+internal sealed class InboxHandlerContext(InboxMessage message, Inbox.HandlerExecution execution) : IInboxHandlerContext
+{
+ public InboxMessage Message { get; } = message;
+
+ public void Complete() => execution.Complete();
+
+ public void Fail(string reason) => execution.Fail(reason);
+}
diff --git a/src/Orleans.Messaging/InboxMessage.cs b/src/Orleans.Messaging/InboxMessage.cs
new file mode 100644
index 00000000000..099c6f4e916
--- /dev/null
+++ b/src/Orleans.Messaging/InboxMessage.cs
@@ -0,0 +1,50 @@
+using System;
+
+namespace Orleans.Messaging;
+
+/// A received command whose destination is the grain owning the inbox.
+[GenerateSerializer, Alias("Orleans.Messaging.InboxMessage")]
+public readonly struct InboxMessage
+{
+ /// Gets the command identity, payload, and application metadata.
+ [Id(0)]
+ public required Envelope Envelope { get; init; }
+
+ ///
+ /// Gets the time, on the sending grain's Durable Jobs clock, at which this logical send was staged.
+ ///
+ ///
+ ///
+ /// The outbox journals this value when it stages the send, and every delivery attempt and recovery reuses it.
+ /// Direct callers of likewise reuse it when retrying a command.
+ ///
+ ///
+ /// The receiving inbox keeps each pending message until it is processed, including a message whose
+ /// is later. It retains each processed record for DeduplicationWindow after
+ /// the latest send time it has observed for that message ID. A delivery of a tracked message receives its recorded
+ /// outcome; when its send time is later than the recorded one, the inbox durably extends the record to that time
+ /// before answering, and an earlier or equal send time leaves the record unchanged. Before removing records, the inbox
+ /// journals an admission floor, which only advances. It answers a message it doesn't track with
+ /// , staging nothing for it, when the message's send time is at or below that
+ /// floor.
+ ///
+ ///
+ /// The floor survives recovery and migration, so a delayed retry of a removed message stays expired even after a
+ /// clock moves backward. Clock jumps and skew change when messages expire and how long records are retained.
+ /// Once a record is removed, sending its command again with a later send time is admitted as a new delivery, so
+ /// applications reconcile an uncertain outcome before doing so.
+ ///
+ ///
+ /// The inbox never accepts a delivered send time later than its own clock plus DeduplicationWindow. It rejects
+ /// such a delivery with an , staging nothing, when the message is untracked
+ /// and above the admission floor or the send time would extend its record, so the sender retries it within its retry
+ /// budget. A tracked message whose send time is equal to or earlier than its record's still receives its recorded
+ /// outcome, and an untracked message at or below the floor still receives .
+ /// A message a grain sends to itself uses its clock, or the first time above the admission floor after a backward
+ /// clock jump. Every processed record therefore becomes eligible for removal within twice the window of the
+ /// receiver's clock, or just after the floor when a self-send follows a backward jump.
+ ///
+ ///
+ [Id(1)]
+ public required DateTimeOffset SentAt { get; init; }
+}
diff --git a/src/Orleans.Messaging/MessageId.cs b/src/Orleans.Messaging/MessageId.cs
new file mode 100644
index 00000000000..f6082990280
--- /dev/null
+++ b/src/Orleans.Messaging/MessageId.cs
@@ -0,0 +1,122 @@
+using System;
+using System.Diagnostics.CodeAnalysis;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Orleans.Messaging;
+
+///
+/// Identifies one message by an application-supplied .
+///
+///
+/// The receiver deduplicates messages by this identity, so applications reuse it when retrying a send.
+/// Equality, hashing, parsing, and formatting use the key's canonical escaped path.
+///
+[Immutable, Alias("Orleans.Messaging.MessageId")]
+[JsonConverter(typeof(MessageIdJsonConverter))]
+public readonly struct MessageId : ISpanFormattable, IEquatable, IParsable, ISpanParsable
+{
+ /// Creates a message identity from a hierarchical key.
+ /// The application identity of the message.
+ public MessageId(HierarchicalKey key) => Key = key;
+
+ /// Gets the hierarchical key which identifies the message.
+ public HierarchicalKey Key { get; }
+
+ /// Gets whether this value is unset.
+ public bool IsDefault => Key.IsDefault;
+
+ /// Creates a message identity from literal segments in root-first order.
+ /// The nonempty literal segments.
+ /// The message identity.
+ /// A segment is null.
+ /// The input or a segment is empty, or a segment contains an unpaired surrogate.
+ public static MessageId Create(params ReadOnlySpan segments) => new(HierarchicalKey.Create(segments));
+
+ ///
+ public static MessageId Parse(string s, IFormatProvider? provider = null) => new(HierarchicalKey.Parse(s, provider));
+
+ ///
+ public static MessageId Parse(ReadOnlySpan s, IFormatProvider? provider = null) => new(HierarchicalKey.Parse(s, provider));
+
+ ///
+ public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? provider, out MessageId result)
+ {
+ var parsed = HierarchicalKey.TryParse(s, provider, out var key);
+ result = new(key);
+ return parsed;
+ }
+
+ ///
+ public static bool TryParse(ReadOnlySpan s, IFormatProvider? provider, out MessageId result)
+ {
+ var parsed = HierarchicalKey.TryParse(s, provider, out var key);
+ result = new(key);
+ return parsed;
+ }
+
+ ///
+ public bool Equals(MessageId other) => Key.Equals(other.Key);
+
+ ///
+ public override bool Equals(object? obj) => obj is MessageId other && Equals(other);
+
+ ///
+ public override int GetHashCode() => Key.GetHashCode();
+
+ /// Compares message identities.
+ /// The first identity.
+ /// The second identity.
+ /// Whether the identities are equal.
+ public static bool operator ==(MessageId left, MessageId right) => left.Equals(right);
+
+ /// Compares message identities for inequality.
+ /// The first identity.
+ /// The second identity.
+ /// Whether the identities differ.
+ public static bool operator !=(MessageId left, MessageId right) => !left.Equals(right);
+
+ ///
+ public override string ToString() => Key.ToString();
+
+ ///
+ public string ToString(string? format, IFormatProvider? formatProvider) => Key.ToString();
+
+ ///
+ public bool TryFormat(Span destination, out int charsWritten, ReadOnlySpan format, IFormatProvider? provider) =>
+ Key.TryFormat(destination, out charsWritten, format, provider);
+
+ /// Serializes message identities as canonical escaped JSON strings.
+ public sealed class MessageIdJsonConverter : JsonConverter
+ {
+ /// Creates a message identity JSON converter.
+ public MessageIdJsonConverter() { }
+
+ ///
+ public override MessageId Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ if (reader.TokenType == JsonTokenType.Null) return default;
+ if (reader.TokenType is JsonTokenType.String or JsonTokenType.PropertyName
+ && TryParse(reader.GetString(), null, out var result)) return result;
+ throw new JsonException("A message identity must be a canonical escaped path.");
+ }
+
+ ///
+ public override void Write(Utf8JsonWriter writer, MessageId value, JsonSerializerOptions options)
+ {
+ if (value.IsDefault) writer.WriteNullValue();
+ else writer.WriteStringValue(value.ToString());
+ }
+
+ ///
+ public override MessageId ReadAsPropertyName(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) =>
+ Read(ref reader, typeToConvert, options);
+
+ ///
+ public override void WriteAsPropertyName(Utf8JsonWriter writer, MessageId value, JsonSerializerOptions options)
+ {
+ if (value.IsDefault) throw new JsonException("A message identity dictionary key must be set.");
+ writer.WritePropertyName(value.ToString());
+ }
+ }
+}
diff --git a/src/Orleans.Messaging/MessageIdCodec.cs b/src/Orleans.Messaging/MessageIdCodec.cs
new file mode 100644
index 00000000000..1a04a7b6704
--- /dev/null
+++ b/src/Orleans.Messaging/MessageIdCodec.cs
@@ -0,0 +1,48 @@
+using System;
+using System.Buffers;
+using Orleans.Serialization;
+using Orleans.Serialization.Buffers;
+using Orleans.Serialization.Cloning;
+using Orleans.Serialization.Codecs;
+using Orleans.Serialization.WireProtocol;
+
+namespace Orleans.Messaging;
+
+[RegisterSerializer]
+internal sealed class MessageIdCodec(IFieldCodec strings) : IFieldCodec
+{
+ public void WriteField(ref Writer writer, uint fieldIdDelta,
+ Type? expectedType, MessageId value) where TBufferWriter : IBufferWriter
+ {
+ ReferenceCodec.MarkValueField(writer.Session);
+ writer.WriteFieldHeader(fieldIdDelta, expectedType, typeof(MessageId), WireType.TagDelimited);
+ strings.WriteField(ref writer, 0, typeof(string), value.IsDefault ? null! : value.ToString());
+ writer.WriteEndObject();
+ }
+
+ public MessageId ReadValue(ref Reader reader, Field field)
+ {
+ field.EnsureWireTypeTagDelimited();
+ ReferenceCodec.MarkValueField(reader.Session);
+ MessageId result = default;
+ uint fieldId = 0;
+ while (true)
+ {
+ var header = reader.ReadFieldHeader();
+ if (header.IsEndBaseOrEndObject) return result;
+ fieldId += header.FieldIdDelta;
+ if (fieldId == 0)
+ {
+ var canonical = strings.ReadValue(ref reader, header);
+ result = canonical is null ? default : MessageId.Parse(canonical);
+ }
+ else reader.ConsumeUnknownField(header);
+ }
+ }
+}
+
+[RegisterCopier]
+internal sealed class MessageIdCopier : IDeepCopier
+{
+ public MessageId DeepCopy(MessageId input, CopyContext context) => input;
+}
diff --git a/src/Orleans.Messaging/MessageState.cs b/src/Orleans.Messaging/MessageState.cs
new file mode 100644
index 00000000000..b7c30b89d60
--- /dev/null
+++ b/src/Orleans.Messaging/MessageState.cs
@@ -0,0 +1,79 @@
+using System;
+
+namespace Orleans.Messaging;
+
+[GenerateSerializer, Alias("Orleans.Messaging.InboxMessageState")]
+internal sealed class InboxMessageState
+{
+ [Id(0)]
+ public int AttemptCount { get; set; }
+
+ /// Gets or sets the time, on the Durable Jobs clock, from which the inbox attempts the message again.
+ [Id(1)]
+ public DateTimeOffset? NextAttemptAt { get; set; }
+
+ [Id(2)]
+ public string? LastError { get; set; }
+
+ ///
+ /// Gets or sets when a failing message's retries end on the Durable Jobs clock, InboxRetryTimeout after its
+ /// first failure.
+ ///
+ [Id(3)]
+ public DateTimeOffset? ExpirationTime { get; set; }
+}
+
+[GenerateSerializer, Alias("Orleans.Messaging.OutboxMessageState")]
+internal sealed class OutboxMessageState
+{
+ [Id(0)]
+ public int AttemptCount { get; set; }
+
+ [Id(1)]
+ public DateTimeOffset? NextAttemptAt { get; set; }
+
+ [Id(2)]
+ public string? LastError { get; set; }
+
+ ///
+ /// Gets or sets the logical send's staging time on the Durable Jobs clock. Every attempt preserves this value;
+ /// retries end OutboxRetryTimeout after it. A recovered entry requires this journaled value.
+ ///
+ [Id(3)]
+ public DateTimeOffset? SentAt { get; set; }
+}
+
+[GenerateSerializer, Alias("Orleans.Messaging.InboxDeadLetter")]
+internal sealed class InboxDeadLetter
+{
+ [Id(0)]
+ public required InboxMessage Message { get; init; }
+
+ [Id(1)]
+ public required DateTimeOffset DeadLetteredAt { get; init; }
+
+ [Id(2)]
+ public required string Reason { get; init; }
+
+ [Id(3)]
+ public int AttemptCount { get; init; }
+
+ [Id(4)]
+ public string? Code { get; init; }
+}
+
+[GenerateSerializer, Alias("Orleans.Messaging.OutboxDeadLetter")]
+internal sealed class OutboxDeadLetter
+{
+ [Id(0)]
+ public required OutboxMessage Message { get; init; }
+
+ [Id(1)]
+ public required DateTimeOffset DeadLetteredAt { get; init; }
+
+ [Id(2)]
+ public required string Reason { get; init; }
+
+ [Id(3)]
+ public int AttemptCount { get; init; }
+}
diff --git a/src/Orleans.Messaging/MessageType.cs b/src/Orleans.Messaging/MessageType.cs
new file mode 100644
index 00000000000..c8fb9914671
--- /dev/null
+++ b/src/Orleans.Messaging/MessageType.cs
@@ -0,0 +1,100 @@
+using System;
+using Orleans.Runtime;
+using Orleans.Serialization;
+
+namespace Orleans.Messaging;
+
+///
+/// Binds an exact message subject to an Orleans payload serializer.
+///
+/// The subject's payload contract.
+///
+/// Bindings are immutable and can be shared across activations. Decode during handler preparation;
+/// decoded resource-bearing values retain their ordinary application-managed ownership.
+///
+public sealed class MessageType
+{
+ private readonly Serializer _serializer;
+
+ /// Creates a subject and payload-type binding.
+ /// The ordinal protocol subject.
+ /// The payload serializer.
+ public MessageType(string subject, Serializer serializer)
+ {
+ EnvelopeValidation.ValidateSubject(subject);
+ ArgumentNullException.ThrowIfNull(serializer);
+ Subject = subject;
+ _serializer = serializer;
+ }
+
+ /// Creates a subject and payload-type binding using the payload serializer from .
+ /// The ordinal protocol subject.
+ /// The serializer which provides the payload serializer.
+ ///
+ /// Use this overload to define a protocol's bindings together in one application class, constructed from the
+ /// service.
+ ///
+ public MessageType(string subject, Serializer serializer)
+ : this(subject, (serializer ?? throw new ArgumentNullException(nameof(serializer))).GetSerializer())
+ {
+ }
+
+ /// Gets the exact protocol subject.
+ public string Subject { get; }
+
+ /// Decodes an envelope using this subject's payload contract.
+ /// The envelope containing the serialized payload.
+ /// The nonnull decoded payload.
+ /// The subject is absent or different, or the payload is a serialized null value.
+ public T Decode(Envelope envelope)
+ {
+ if (envelope.Subject is null)
+ {
+ throw new ArgumentException(
+ $"Message subject '{Subject}' is required.", nameof(envelope));
+ }
+ if (!string.Equals(Subject, envelope.Subject, StringComparison.Ordinal))
+ {
+ throw new ArgumentException(
+ $"Expected message subject '{Subject}', but received '{envelope.Subject}'.",
+ nameof(envelope));
+ }
+
+ return _serializer.Deserialize(envelope.Payload)
+ ?? throw new ArgumentException($"Message subject '{Subject}' requires a nonnull payload.", nameof(envelope));
+ }
+
+ /// Encodes a typed body into an outgoing message.
+ /// The stable application command identity.
+ /// The destination inbox.
+ /// The nonnull body to serialize.
+ ///
+ /// The optional earliest time, on the Durable Jobs clock, at which the receiving inbox processes the message.
+ /// See .
+ ///
+ /// An outgoing message with the encoded payload.
+ ///
+ /// Serialization completes before publication. Keep the published payload bytes unchanged.
+ ///
+ public OutboxMessage Create(MessageId messageId, GrainId receiverId, T body, DateTimeOffset? deliverAt = null)
+ {
+ if (body is null) throw new ArgumentNullException(nameof(body));
+ if (receiverId.IsDefault)
+ {
+ throw new ArgumentException("The receiving grain ID must be set.", nameof(receiverId));
+ }
+ EnvelopeValidation.ValidateMessageId(messageId);
+ var payload = _serializer.SerializeToArray(body);
+ return new OutboxMessage
+ {
+ Envelope = new Envelope
+ {
+ MessageId = messageId,
+ Subject = Subject,
+ Payload = payload,
+ DeliverAt = deliverAt
+ },
+ ReceiverId = receiverId
+ };
+ }
+}
diff --git a/src/Orleans.Messaging/MessagingActivationValidator.cs b/src/Orleans.Messaging/MessagingActivationValidator.cs
new file mode 100644
index 00000000000..5a8279b4d9a
--- /dev/null
+++ b/src/Orleans.Messaging/MessagingActivationValidator.cs
@@ -0,0 +1,20 @@
+using System;
+using Orleans.Concurrency;
+using Orleans.Runtime;
+
+namespace Orleans.Messaging;
+
+internal static class MessagingActivationValidator
+{
+ // The public attribute exposes the runtime's internal, sealed strategy type.
+ private static readonly Type _statelessWorkerPlacementType = new StatelessWorkerAttribute().PlacementStrategy.GetType();
+
+ public static void Validate(Type grainClass, PlacementStrategy placementStrategy)
+ {
+ if (placementStrategy.GetType() == _statelessWorkerPlacementType)
+ {
+ throw new InvalidOperationException(
+ $"Messaging requires one activation per grain identity, but grain type '{grainClass}' is a stateless worker.");
+ }
+ }
+}
diff --git a/src/Orleans.Messaging/MessagingGrainTypeConfigurator.cs b/src/Orleans.Messaging/MessagingGrainTypeConfigurator.cs
new file mode 100644
index 00000000000..b67b53ff0f9
--- /dev/null
+++ b/src/Orleans.Messaging/MessagingGrainTypeConfigurator.cs
@@ -0,0 +1,28 @@
+using System;
+using Microsoft.Extensions.DependencyInjection;
+using Orleans.Journaling;
+using Orleans.Metadata;
+using Orleans.Runtime;
+
+namespace Orleans.Messaging;
+
+internal sealed class MessagingGrainTypeConfigurator(GrainClassMap grainClasses, IServiceProvider services) : IConfigureGrainTypeComponents
+{
+ public void Configure(GrainType grainType, GrainProperties properties, GrainTypeSharedContext shared)
+ {
+ if (grainClasses.TryGetGrainClass(grainType, out var grainClass)
+ && (typeof(DurableGrain).IsAssignableFrom(grainClass) || typeof(IMessagingGrain).IsAssignableFrom(grainClass)))
+ {
+ var placementStrategy = shared.PlacementStrategy;
+ shared.SetComponent(new MessagingShared(services, grainType));
+ shared.AddActivationSetup(context =>
+ {
+ MessagingActivationValidator.Validate(grainClass, placementStrategy);
+ var activationServices = context.ActivationServices;
+ var inbox = activationServices.GetRequiredService();
+ _ = activationServices.GetRequiredService();
+ context.GetComponent()!.GetOrSetExtension(() => inbox);
+ });
+ }
+ }
+}
diff --git a/src/Orleans.Messaging/MessagingInstruments.cs b/src/Orleans.Messaging/MessagingInstruments.cs
new file mode 100644
index 00000000000..e6aba94026a
--- /dev/null
+++ b/src/Orleans.Messaging/MessagingInstruments.cs
@@ -0,0 +1,84 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics.Metrics;
+using Orleans.Runtime;
+
+namespace Orleans.Messaging;
+
+internal sealed class MessagingInstruments(OrleansInstruments instruments)
+{
+ private const string MillisecondsUnit = "ms";
+ private const string GrainTypeTagName = "grain_type";
+ private const string StatusTagName = "status";
+
+ private readonly Counter _inboxMessagesReceived = instruments.Meter.CreateCounter("orleans-messaging-inbox-messages-received");
+ private readonly Counter _inboxMessagesProcessed = instruments.Meter.CreateCounter("orleans-messaging-inbox-messages-processed");
+ private readonly Counter _outboxMessagesSent = instruments.Meter.CreateCounter("orleans-messaging-outbox-messages-sent");
+ private readonly Counter _outboxMessagesDelivered = instruments.Meter.CreateCounter("orleans-messaging-outbox-messages-delivered");
+ private readonly Histogram _inboxProcessingDuration = instruments.Meter.CreateHistogram("orleans-messaging-inbox-processing-duration", MillisecondsUnit);
+ private readonly Histogram _outboxDeliveryDuration = instruments.Meter.CreateHistogram("orleans-messaging-outbox-delivery-duration", MillisecondsUnit);
+ private readonly UpDownCounter _inboxDepth = instruments.Meter.CreateUpDownCounter("orleans-messaging-inbox-depth");
+ private readonly UpDownCounter _outboxDepth = instruments.Meter.CreateUpDownCounter("orleans-messaging-outbox-depth");
+
+ internal static MessagingInstruments CreateForDirectConstruction() => new(new OrleansInstruments(new DirectMeterFactory()));
+
+ internal void OnInboxDepthChanged(string grainType, int delta) => AddDepth(_inboxDepth, grainType, delta);
+
+ internal void OnOutboxDepthChanged(string grainType, int delta) => AddDepth(_outboxDepth, grainType, delta);
+
+ private static void AddDepth(UpDownCounter counter, string grainType, int delta)
+ {
+ if (delta != 0 && counter.Enabled)
+ {
+ counter.Add(delta, new KeyValuePair(GrainTypeTagName, grainType));
+ }
+ }
+
+ internal void OnInboxMessageReceived(string grainType, string status) =>
+ Add(_inboxMessagesReceived, grainType, status);
+
+ internal void OnInboxMessageProcessed(string grainType, string status) =>
+ Add(_inboxMessagesProcessed, grainType, status);
+
+ internal void OnInboxProcessingDuration(TimeSpan duration, string grainType) =>
+ Record(_inboxProcessingDuration, duration, grainType);
+
+ internal void OnOutboxMessageSent(string grainType)
+ {
+ if (_outboxMessagesSent.Enabled)
+ {
+ _outboxMessagesSent.Add(1, new KeyValuePair(GrainTypeTagName, grainType));
+ }
+ }
+
+ internal void OnOutboxMessageDelivered(string grainType, string status) =>
+ Add(_outboxMessagesDelivered, grainType, status);
+
+ internal void OnOutboxDeliveryDuration(TimeSpan duration, string grainType) =>
+ Record(_outboxDeliveryDuration, duration, grainType);
+
+ private static void Add(Counter counter, string grainType, string status)
+ {
+ if (counter.Enabled)
+ {
+ counter.Add(1, new(GrainTypeTagName, grainType), new(StatusTagName, status));
+ }
+ }
+
+ private static void Record(Histogram histogram, TimeSpan duration, string grainType)
+ {
+ if (histogram.Enabled)
+ {
+ histogram.Record(Math.Max(0, duration.TotalMilliseconds), new KeyValuePair(GrainTypeTagName, grainType));
+ }
+ }
+
+ private sealed class DirectMeterFactory : IMeterFactory
+ {
+ public Meter Create(MeterOptions options) => new(options);
+
+ public void Dispose()
+ {
+ }
+ }
+}
diff --git a/src/Orleans.Messaging/MessagingPump.cs b/src/Orleans.Messaging/MessagingPump.cs
new file mode 100644
index 00000000000..402be588775
--- /dev/null
+++ b/src/Orleans.Messaging/MessagingPump.cs
@@ -0,0 +1,388 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Logging;
+using Orleans.DurableJobs;
+using Orleans.Journaling;
+using Orleans.Runtime;
+
+namespace Orleans.Messaging;
+
+///
+/// Drives inbox processing and outbox delivery for one activation.
+///
+///
+/// A single loop runs on the activation scheduler and services both queues whenever it is signaled.
+/// One durable job keeps waking the grain while either queue or a registered holds work
+/// which the activation can perform; inbox messages wait for a handler without a wake-up.
+/// Work staged with an earlier due time than the current wake-up schedules an earlier one, and the job which a newer,
+/// earlier wake-up supersedes completes when it runs.
+/// Wake-ups may be early, late, or duplicated: every pass derives the work to perform from current state.
+/// The wake-up is crash insurance for work which the live loop performs as soon as it is signaled, so work which is
+/// already due needs a wake-up within , and a write schedules a job only when no tracked or
+/// scheduling job is due by then. A running job re-arms itself once per run, so a busy grain performs no job operation
+/// per write. The job retries indefinitely when an activation fails (see ), and every
+/// activation re-arms it from recovered state, so committed work always keeps a wake-up.
+/// Wake-up bookkeeping runs on the activation scheduler: journal writes prepare on it and serialize with job runs.
+/// The loop keeps the activation alive while a pass runs over queued work and while outbox deliveries are in flight.
+/// Work which only becomes due later relies on the durable job, so the activation can be collected while it waits.
+///
+internal sealed partial class MessagingPump : IDurableJobFeatureHandler, ILifecycleObserver
+{
+ internal const string JobName = "orleans.messaging.pump";
+ internal static readonly TimeSpan RecheckDelay = TimeSpan.FromSeconds(1);
+ private readonly SingleWaiterAutoResetEvent _signal = new() { RunContinuationsAsynchronously = true };
+ private readonly CancellationTokenSource _shutdown = new();
+ private Inbox? _inbox;
+ private Outbox? _outbox;
+ private Task? _loop;
+ private TaskCompletionSource? _nextPass;
+ private Task? _scheduling;
+ private DateTimeOffset _schedulingDueTime;
+ private bool _keepingAlive;
+
+ // The job which wakes the grain next, if any, and its due time. A job run which completes clears it.
+ private string? _wakeupJobId;
+ private DateTimeOffset _wakeupDueTime;
+
+ // Advances whenever a job run completes. A schedule requested in an earlier epoch may belong to a job which has
+ // already run and completed, so it no longer guarantees a wake-up.
+ private int _wakeupEpoch;
+ private int _schedulingEpoch;
+
+ public MessagingPump(IGrainContext grainContext, IJournaledStateManager stateManager, IDurableJobHandlerRegistry jobHandlers)
+ {
+ GrainContext = grainContext;
+ StateManager = stateManager;
+ Shared = grainContext.GetComponent()
+ ?? throw new InvalidOperationException($"Messaging is not configured for grain type '{grainContext.GrainId.Type}'.");
+ jobHandlers.Register(this);
+ grainContext.ObservableLifecycle.Subscribe(nameof(MessagingPump), GrainLifecycleStage.Activate, this);
+
+ // Wake-ups for due work fire up to RecheckDelay later, so the loop starts once activation completes and performs
+ // recovered and newly staged work without waiting for one.
+ grainContext.ObservableLifecycle.Subscribe(nameof(MessagingPump), GrainLifecycleStage.Last, _ =>
+ {
+ EnsureStarted();
+ return Task.CompletedTask;
+ }, static _ => Task.CompletedTask);
+
+ // Recovery reports depth during SetupState, so releasing it there covers activations which fail before Activate.
+ grainContext.ObservableLifecycle.Subscribe(nameof(MessagingPump), GrainLifecycleStage.SetupState, static _ => Task.CompletedTask, _ =>
+ {
+ _inbox?.ReleaseDepth();
+ _outbox?.ReleaseDepth();
+ return Task.CompletedTask;
+ });
+ }
+
+ public IGrainContext GrainContext { get; }
+
+ public IJournaledStateManager StateManager { get; }
+
+ ///
+ /// Gets the state manager's count of staged changes, or a negative value when a replacement manager doesn't track them.
+ ///
+ public long StagedChangeCount => StateManager is JournaledStateManager manager ? manager.StagedChangeCount : -1;
+
+ public MessagingShared Shared { get; }
+
+ /// Gets the token which is canceled when messaging stops for this activation.
+ public CancellationToken ShutdownToken => _shutdown.Token;
+
+ internal void Register(Inbox inbox) => _inbox = inbox;
+
+ internal void Register(Outbox outbox) => _outbox = outbox;
+
+ internal Inbox Inbox => _inbox ?? throw new InvalidOperationException("The messaging inbox has not been registered.");
+
+ private Outbox Outbox => _outbox ?? throw new InvalidOperationException("The messaging outbox has not been registered.");
+
+ public bool CanHandle(string jobName) => string.Equals(jobName, JobName, StringComparison.Ordinal);
+
+ /// Requests a processing pass.
+ public void Signal()
+ {
+ if (!_shutdown.IsCancellationRequested) _signal.Signal();
+ }
+
+ /// Requests a processing pass and ensures a durable wake-up for work which has become processable.
+ public void RequestWakeup()
+ {
+ if (_shutdown.IsCancellationRequested) return;
+ _signal.Signal();
+ RepairWakeupAsync().Ignore();
+ }
+
+ /// Starts the loop once activation completes, when the activation services grain calls.
+ public void EnsureStarted()
+ {
+ if (_loop is null && !_shutdown.IsCancellationRequested)
+ {
+ _loop = RunAsync();
+ }
+ }
+
+ ///
+ /// Ensures a durable job will wake this grain no later than , or within
+ /// for work which is due sooner, on the Durable Jobs clock.
+ ///
+ public async ValueTask EnsureWakeupAsync(DateTimeOffset dueTime, CancellationToken cancellationToken)
+ {
+ // The live loop performs work as soon as it is due, so any tracked or scheduling wake-up within RecheckDelay
+ // covers it should the activation fail.
+ var earliest = MessagingTime.AddClamped(Shared.JobTimeProvider.GetUtcNow(), RecheckDelay);
+ if (dueTime < earliest) dueTime = earliest;
+ if (_wakeupJobId is not null && _wakeupDueTime <= dueTime) return;
+ if (_scheduling is null || _schedulingEpoch != _wakeupEpoch || _schedulingDueTime > dueTime)
+ {
+ _schedulingEpoch = _wakeupEpoch;
+ _schedulingDueTime = dueTime;
+ _scheduling = Shared.JobManager.ScheduleJobAsync(new ScheduleJobRequest
+ {
+ Target = GrainContext.GrainId,
+ JobName = JobName,
+ DueTime = _schedulingDueTime
+ }, _shutdown.Token);
+ }
+
+ var scheduling = _scheduling;
+ var epoch = _schedulingEpoch;
+ try
+ {
+ var job = await scheduling.WaitAsync(cancellationToken).ConfigureAwait(true);
+ if (epoch == _wakeupEpoch && (_wakeupJobId is null || job.DueTime < _wakeupDueTime))
+ {
+ _wakeupJobId = job.Id;
+ _wakeupDueTime = job.DueTime;
+ }
+ }
+ finally
+ {
+ if (scheduling.IsCompleted && ReferenceEquals(_scheduling, scheduling)) _scheduling = null;
+ }
+ }
+
+ public async ValueTask ExecuteJobAsync(IJobRunContext context, CancellationToken cancellationToken)
+ {
+ // A running job keeps rescheduling itself until both queues and every work source are empty.
+ var jobId = context.Job.Id;
+ if (_wakeupJobId is null)
+ {
+ _wakeupJobId = jobId;
+ _wakeupDueTime = context.Job.DueTime;
+ }
+ EnsureStarted();
+ var pass = _nextPass ??= new(TaskCreationOptions.RunContinuationsAsynchronously);
+ _signal.Signal();
+ try
+ {
+ await pass.Task.WaitAsync(cancellationToken).ConfigureAwait(true);
+ // A failed pass can leave only staged removals; its job must survive the activation's failure.
+ _shutdown.Token.ThrowIfCancellationRequested();
+ }
+ catch
+ {
+ // Durable Jobs retries the failed run (see GetRetryTime), which wakes a recovered activation.
+ CompleteWakeup(jobId);
+ throw;
+ }
+
+ var now = Shared.JobTimeProvider.GetUtcNow();
+ if (GetNextWakeupDelay(now) is not { } delay)
+ {
+ CompleteWakeup(jobId);
+ return DurableJobRunResult.Completed;
+ }
+
+ var next = MessagingTime.AddClamped(now, delay > TimeSpan.Zero ? delay : RecheckDelay);
+ if (_wakeupJobId is not null && _wakeupJobId != jobId && _wakeupDueTime <= next)
+ {
+ // Another job wakes the grain first, and its run reschedules for any remaining work.
+ CompleteWakeup(jobId);
+ return DurableJobRunResult.Completed;
+ }
+
+ _wakeupJobId = jobId;
+ _wakeupDueTime = next;
+ return DurableJobRunResult.RescheduleAt(next);
+ }
+
+ private void CompleteWakeup(string jobId)
+ {
+ if (_wakeupJobId == jobId) _wakeupJobId = null;
+ _wakeupEpoch++;
+ }
+
+ // The delay until the inbox, the outbox, or a work source next holds work, or null when none does.
+ private TimeSpan? GetNextWakeupDelay(DateTimeOffset now) =>
+ (Inbox.GetNextWakeupDelay(now), Outbox.GetNextWakeupDelay(now)) switch
+ {
+ ({ } inbox, { } outbox) => inbox < outbox ? inbox : outbox,
+ (var inbox, var outbox) => inbox ?? outbox
+ };
+
+ private async Task RepairWakeupAsync()
+ {
+ try
+ {
+ await EnsureWakeupAsync(Shared.JobTimeProvider.GetUtcNow(), CancellationToken.None).ConfigureAwait(true);
+ }
+ catch (Exception exception) when (!_shutdown.IsCancellationRequested)
+ {
+ // Recovery re-establishes the wake-up for remaining work.
+ Fail(exception, "Scheduling the messaging wake-up failed.");
+ }
+ }
+
+ private async Task RunAsync()
+ {
+ // Callers start the loop from grain calls and job executions; queue the first pass to the activation
+ // scheduler so it never runs on, and nests within, the caller's stack.
+ await Task.Yield();
+ var cancellationToken = _shutdown.Token;
+ while (!cancellationToken.IsCancellationRequested)
+ {
+ SetKeepAlive(Inbox.Count > 0 || Outbox.Count > 0);
+ var pass = _nextPass;
+ _nextPass = null;
+ var progress = false;
+ try
+ {
+ progress = await Inbox.ProcessReadyAsync(cancellationToken).ConfigureAwait(true);
+ progress |= await Outbox.AdvanceAsync(cancellationToken).ConfigureAwait(true);
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ }
+ catch (Exception exception)
+ {
+ // Expected failures are handled within the pass, so an escaping exception leaves state inconsistent with the journal.
+ progress = false;
+ LogPassFailed(Shared.PumpLogger, exception, GrainContext.GrainId);
+ Fail(exception, "Messaging processing pass failed.");
+ }
+
+ pass?.TrySetResult();
+ if (!progress)
+ {
+ SetKeepAlive(Outbox.IsDelivering);
+ await _signal.WaitAsync().ConfigureAwait(true);
+ }
+ }
+
+ SetKeepAlive(false);
+ _nextPass?.TrySetCanceled(cancellationToken);
+ }
+
+ private void SetKeepAlive(bool keepAlive)
+ {
+ if (keepAlive == _keepingAlive || GrainContext is not IActivationKeepAlive activation) return;
+ _keepingAlive = keepAlive;
+ if (keepAlive)
+ {
+ activation.AcquireKeepAlive();
+ }
+ else
+ {
+ activation.ReleaseKeepAlive();
+ }
+ }
+
+ public async Task OnStart(CancellationToken cancellationToken)
+ {
+ await Outbox.StartAsync(cancellationToken).ConfigureAwait(true);
+ await Inbox.StartAsync(cancellationToken).ConfigureAwait(true);
+
+ // Recovered work keeps a wake-up at its real due time, even if the job which woke this activation completes.
+ var now = Shared.JobTimeProvider.GetUtcNow();
+ if (GetNextWakeupDelay(now) is { } delay)
+ {
+ await EnsureWakeupAsync(MessagingTime.AddClamped(now, delay), cancellationToken).ConfigureAwait(true);
+ }
+ }
+
+ ///
+ /// Returns when Durable Jobs retries a pump job whose run failed, which it does indefinitely with backoff on the
+ /// Durable Jobs clock.
+ ///
+ ///
+ /// The job is the grain's only durable wake-up for committed work, so giving up after a bounded number of attempts
+ /// would strand that work after a long outage until an unrelated call reactivated the grain.
+ ///
+ internal static DateTimeOffset GetRetryTime(IJobRunContext context, DateTimeOffset now, TimeSpan initialInterval) =>
+ MessagingTime.AddClamped(now, MessagingTime.GetBackoff(initialInterval, context.DequeueCount));
+
+ public async Task OnStop(CancellationToken cancellationToken)
+ {
+ var scheduling = _scheduling;
+ Stop();
+
+ // Grain calls finish before deactivation stops the lifecycle, so the loop and outbox deliveries own every remaining write.
+ if (_loop is { } loop)
+ {
+ await loop.ConfigureAwait(ConfigureAwaitOptions.ContinueOnCapturedContext | ConfigureAwaitOptions.SuppressThrowing);
+ }
+
+ if (scheduling is not null)
+ {
+ await ((Task)scheduling).ConfigureAwait(ConfigureAwaitOptions.ContinueOnCapturedContext | ConfigureAwaitOptions.SuppressThrowing);
+ }
+
+ await Outbox.StopAsync().ConfigureAwait(true);
+
+ // The loop and the outbox's deliveries have ended, so no attempt is in progress unless messaging failed.
+ await Inbox.RecordCleanStopAsync().ConfigureAwait(true);
+ }
+
+ /// Stops processing for this activation.
+ internal void Stop()
+ {
+ if (_shutdown.IsCancellationRequested) return;
+ try
+ {
+ _shutdown.Cancel();
+ }
+ catch (AggregateException exception)
+ {
+ LogCancellationFailed(Shared.PumpLogger, exception);
+ }
+ finally
+ {
+ _signal.Signal();
+ }
+ }
+
+ /// Gets whether messaging failed, so the activation's uncommitted changes must not commit.
+ internal bool IsFailed { get; private set; }
+
+ /// Stops messaging and deactivates the grain after an unrecoverable failure.
+ ///
+ /// In-memory state may no longer match what the activation may commit, so every later journal write fails and the
+ /// next activation recovers from the journal.
+ ///
+ internal void Fail(Exception exception, string description)
+ {
+ IsFailed = true;
+ if (_shutdown.IsCancellationRequested) return;
+ Stop();
+ try
+ {
+ GrainContext.Deactivate(new DeactivationReason(DeactivationReasonCode.ApplicationError, exception, description));
+ }
+ catch (Exception deactivationException)
+ {
+ LogDeactivationFailed(Shared.PumpLogger, deactivationException);
+ }
+ }
+
+
+ [LoggerMessage(Level = LogLevel.Error, Message = "Messaging processing pass failed for grain {GrainId}")]
+ private static partial void LogPassFailed(ILogger logger, Exception exception, GrainId grainId);
+
+ [LoggerMessage(Level = LogLevel.Error, Message = "Requesting deactivation after a messaging failure failed.")]
+ private static partial void LogDeactivationFailed(ILogger logger, Exception exception);
+
+ [LoggerMessage(Level = LogLevel.Error, Message = "A messaging cancellation callback failed during shutdown.")]
+ private static partial void LogCancellationFailed(ILogger logger, Exception exception);
+}
diff --git a/src/Orleans.Messaging/MessagingShared.cs b/src/Orleans.Messaging/MessagingShared.cs
new file mode 100644
index 00000000000..23fb8626ff4
--- /dev/null
+++ b/src/Orleans.Messaging/MessagingShared.cs
@@ -0,0 +1,64 @@
+using System;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using Orleans.DurableJobs;
+using Orleans.Journaling;
+using Orleans.Messaging.Configuration;
+using Orleans.Runtime;
+
+namespace Orleans.Messaging;
+
+///
+/// Services and settings shared by every messaging activation of one grain type.
+///
+///
+/// stores one instance as a grain-type component, so activations
+/// resolve it as an component instead of copying it per activation.
+///
+internal sealed class MessagingShared
+{
+ public MessagingShared(IServiceProvider services, GrainType grainType)
+ {
+ GrainType = grainType.ToString();
+ Options = services.GetRequiredService>().Value;
+ TimeProvider = services.GetRequiredService();
+ JobTimeProvider = services.GetRequiredKeyedService(DurableJobTimeProviderNames.DurableJobs);
+ JobManager = services.GetRequiredService();
+ Transport = services.GetRequiredService();
+ Instruments = services.GetRequiredService();
+ var loggers = services.GetRequiredService();
+ InboxLogger = loggers.CreateLogger();
+ OutboxLogger = loggers.CreateLogger();
+ PumpLogger = loggers.CreateLogger();
+ var format = services.GetRequiredService>().Value.JournalFormatKey;
+ InboxCodec = services.GetRequiredKeyedService>(format);
+ OutboxCodec = services.GetRequiredKeyedService>(format);
+ }
+
+ /// Gets the grain type metric tag.
+ public string GrainType { get; }
+
+ public DurableMessagingOptions Options { get; }
+
+ public TimeProvider TimeProvider { get; }
+
+ /// Gets the durable jobs clock, which defines the time domain of wake-up due times.
+ public TimeProvider JobTimeProvider { get; }
+
+ public ILocalDurableJobManager JobManager { get; }
+
+ public IOutboxTransport Transport { get; }
+
+ public MessagingInstruments Instruments { get; }
+
+ public ILogger InboxLogger { get; }
+
+ public ILogger OutboxLogger { get; }
+
+ public ILogger PumpLogger { get; }
+
+ public IDurableValueCommandCodec InboxCodec { get; }
+
+ public IDurableValueCommandCodec OutboxCodec { get; }
+}
diff --git a/src/Orleans.Messaging/MessagingStateNames.cs b/src/Orleans.Messaging/MessagingStateNames.cs
new file mode 100644
index 00000000000..d9cd0bb37c9
--- /dev/null
+++ b/src/Orleans.Messaging/MessagingStateNames.cs
@@ -0,0 +1,9 @@
+namespace Orleans.Messaging;
+
+internal static class MessagingStateNames
+{
+ private const string Prefix = "orleans.messaging.";
+
+ public const string Inbox = Prefix + "inbox";
+ public const string Outbox = Prefix + "outbox";
+}
diff --git a/src/Orleans.Messaging/MessagingTime.cs b/src/Orleans.Messaging/MessagingTime.cs
new file mode 100644
index 00000000000..2ae576d1936
--- /dev/null
+++ b/src/Orleans.Messaging/MessagingTime.cs
@@ -0,0 +1,28 @@
+using System;
+
+namespace Orleans.Messaging;
+
+internal static class MessagingTime
+{
+ public static bool IsExpired(DateTimeOffset now, DateTimeOffset timestamp, TimeSpan retention)
+ {
+ var nowTicks = now.UtcTicks;
+ var timestampTicks = timestamp.UtcTicks;
+ return nowTicks >= timestampTicks
+ && nowTicks - timestampTicks >= retention.Ticks;
+ }
+
+ /// Returns the delay before retry , doubling from the initial interval to a cap.
+ /// The delay before the first retry.
+ /// The one-based number of the failed attempt.
+ public static TimeSpan GetBackoff(TimeSpan initialInterval, int attempt) =>
+ TimeSpan.FromTicks(initialInterval.Ticks
+ * (1L << Math.Clamp(attempt - 1, 0, Configuration.DurableMessagingOptions.MaximumBackoffExponent)));
+
+ public static DateTimeOffset AddClamped(DateTimeOffset timestamp, TimeSpan duration)
+ {
+ var utcTicks = timestamp.UtcDateTime.Ticks;
+ var remainingTicks = DateTimeOffset.MaxValue.Ticks - utcTicks;
+ return new DateTimeOffset(utcTicks + Math.Min(duration.Ticks, remainingTicks), TimeSpan.Zero);
+ }
+}
diff --git a/src/Orleans.Messaging/Orleans.Messaging.csproj b/src/Orleans.Messaging/Orleans.Messaging.csproj
new file mode 100644
index 00000000000..54cd69d5f14
--- /dev/null
+++ b/src/Orleans.Messaging/Orleans.Messaging.csproj
@@ -0,0 +1,40 @@
+
+
+