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/grains/journaling/configuration.md b/docs/site/src/content/docs/grains/journaling/configuration.md
index 70c53059e5c..f40ea04f877 100644
--- a/docs/site/src/content/docs/grains/journaling/configuration.md
+++ b/docs/site/src/content/docs/grains/journaling/configuration.md
@@ -150,7 +150,7 @@ Serializer naming policies affect application payload values. Journal command na
## Migrate a journal format
-Providers expose the persisted format key as and . Recovery selects the stored reader independently of the configured write format. When they differ, the next write creates a full snapshot using the configured format and updates the metadata. supplies the JSON Lines format key.
+Providers expose the persisted format key as and . Recovery selects the stored reader independently of the configured write format. When stored format metadata is absent, recovery uses the configured format. New empty journals use the configured write format. When the stored and configured keys differ, the next write creates a full snapshot using the configured format and updates the metadata. supplies the JSON Lines format key.
Use this deployment sequence:
diff --git a/docs/site/src/content/docs/grains/journaling/runtime-behavior.md b/docs/site/src/content/docs/grains/journaling/runtime-behavior.md
index e513f837fc9..6583f5ea8aa 100644
--- a/docs/site/src/content/docs/grains/journaling/runtime-behavior.md
+++ b/docs/site/src/content/docs/grains/journaling/runtime-behavior.md
@@ -25,6 +25,8 @@ During , the manager:
1. Resets and replays each registered durable state.
1. Completes activation setup after replay finishes.
+Recovery uses the configured format when stored format metadata is absent. New empty journals use the configured write format.
+
and requests observe recovered durable state after setup succeeds, whether the grain derives directly from , from an application-owned base, or from . A storage read, format, codec, or malformed-data failure fails activation and preserves the stored journal for diagnosis and recovery.
Provider registration makes Journaling services available. Per-grain journal I/O begins only for activations which resolve the manager, directly or through durable-state dependencies. Grains which use other persistence models keep their existing activation behavior.
@@ -68,6 +70,46 @@ Orleans executes that synchronous block on a single activation thread. Another g
the operation awaits, so keep shared state safe to commit at each await. Any caller's write can include
staged mutations from other calls.
+## Journal operation hooks
+
+Activation-scoped features coordinate prerequisites and completion through
+. The owner exposes a stable, lazily
+allocated list of registrations. Inspect and
+deduplicate feature registrations on the owner's logical execution context while persistence
+is quiescent. Registration survives recovery and deletion; the standard manager rejects hook-list
+mutation while persistence is queued or running.
+Every journal owner implementation provides this list and runs its registered callbacks at the
+operation boundaries below. Delegating owners forward the list to the inner owner.
+
+Each actual append, snapshot, or deletion runs ordinary before callbacks in list order, outside
+the manager lock. At most one supplies the
+final prerequisite. Its before callback runs last, and the work loop awaits it directly before
+synchronous capture or storage deletion. Prerequisites cover changes staged during asynchronous
+preparation, including the final hook's own I/O wait. Preserve operation-local bookkeeping for
+the captured batch separately from changes staged later.
+
+Storage acknowledgement and registered-state acknowledgement or reset precede after callbacks.
+All after callbacks run in list order, including for successful zero-byte writes. Coalesced callers
+share callbacks for the actual operation. Features implement the before and after callbacks on
+their own hook, retaining feature identity and operation-local bookkeeping there.
+
+A failed prerequisite reports with pending
+state retained for an explicit persistence retry after the prerequisite is restored. A failed after
+callback reports with persistence completed.
+Remaining after callbacks run, multiple failures are aggregated, and the manager stays usable.
+The feature's durable recovery protocol resumes interrupted post-persistence work. Storage and
+state-processing failures retain the manager's fencing and fresh-recovery behavior.
+
+Hook callbacks receive the owner's shutdown token. Cancelling a caller's wait leaves the owned
+operation running through its actual outcome. Disposal drains owned hooks and storage before releasing
+journal resources, including when cancellation callbacks or cleanup fail. Concurrent disposal callers
+share this completion. Hook implementations complete without calling initialization, persistence,
+or disposal on their own owner. Awaiting an operation serialized behind the current callback creates
+a circular dependency. Shutdown closes work admission and cancels queued operations while the current
+operation drains to its actual storage and hook outcome. For deletion, the feature owner stops
+admission and drains feature operations before
+queuing the whole-journal reset.
+
## Consistency and competing writers
Orleans grain placement normally supplies a single active writer for a grain identity. Journal storage providers also use optimistic concurrency to protect the journal when a stale or competing writer reaches storage.
diff --git a/docs/site/src/content/docs/grains/timers.md b/docs/site/src/content/docs/grains/timers.md
index b5e40e3814b..45ec72ca2d2 100644
--- a/docs/site/src/content/docs/grains/timers.md
+++ b/docs/site/src/content/docs/grains/timers.md
@@ -32,9 +32,13 @@ Register timers with . controls delayed tick timing and resolution; Orleans queues the provider's tick notifications on the activation.
### Interleaving
@@ -50,9 +54,11 @@ With set to `true`, e
## Change or stop a timer
-Call to replace the due time and period. The new due time schedules the next callback, and the new period applies after that callback completes. A change made inside a running callback takes effect after the callback completes.
+Call to replace the due time and period. When a callback is already queued or running, it keeps its turn and the change takes effect after it completes. Repeated changes use the latest due time and period for the following schedule.
+
+A physical tick already dispatched by the provider can arrive after a change to another delayed schedule. Orleans admits that tick according to the activation's scheduling rules. After its callback completes, the configured period determines the next tick, or a change made during the callback supplies the next due time.
-Dispose to cancel its callback token and stop future callbacks. Orleans also cancels the token and disposes the timer when the activation begins deactivating.
+Dispose to invalidate queued ticks, cancel the token of an admitted callback, and stop further scheduling. Queued messages drain through activation scheduling. Orleans also cancels the token and disposes the timer when the activation begins deactivating.
## Handle callback failures
diff --git a/docs/site/src/content/docs/snippets/compiled/Grains/JournalingSnippets.cs b/docs/site/src/content/docs/snippets/compiled/Grains/JournalingSnippets.cs
index 7396ef52697..30369581c97 100644
--- a/docs/site/src/content/docs/snippets/compiled/Grains/JournalingSnippets.cs
+++ b/docs/site/src/content/docs/snippets/compiled/Grains/JournalingSnippets.cs
@@ -9,6 +9,20 @@
namespace Documentation.Grains.Journaling;
+//
+internal static class JournalHookRegistration
+{
+ internal static void Register(IJournaledStateManager owner, IJournaledStateHook featureHook)
+ {
+ var hooks = owner.Hooks;
+ if (!hooks.Contains(featureHook))
+ {
+ hooks.Add(featureHook);
+ }
+ }
+}
+//
+
//
public interface IShoppingCartGrain : IGrainWithStringKey
{
diff --git a/src/Orleans.Core/Messaging/MessageFactory.cs b/src/Orleans.Core/Messaging/MessageFactory.cs
index d56e027b7b9..25f2fdfc282 100644
--- a/src/Orleans.Core/Messaging/MessageFactory.cs
+++ b/src/Orleans.Core/Messaging/MessageFactory.cs
@@ -27,7 +27,10 @@ public MessageFactory(DeepCopier deepCopier, ILogger logger, Mes
_seed = unchecked((ulong)Random.Shared.NextInt64());
}
- public Message CreateMessage(object? body, InvokeMethodOptions options)
+ public Message CreateMessage(object? body, InvokeMethodOptions options) =>
+ CreateMessage(body, options, RequestContextExtensions.Export(_deepCopier));
+
+ public Message CreateMessage(object? body, InvokeMethodOptions options, Dictionary? requestContextData)
{
var message = new Message
{
@@ -37,7 +40,7 @@ public Message CreateMessage(object? body, InvokeMethodOptions options)
IsUnordered = (options & InvokeMethodOptions.Unordered) != 0,
IsAlwaysInterleave = (options & InvokeMethodOptions.AlwaysInterleave) != 0,
BodyObject = body,
- RequestContextData = RequestContextExtensions.Export(_deepCopier),
+ RequestContextData = requestContextData,
};
return message;
diff --git a/src/Orleans.Journaling/CompatibilitySuppressions.xml b/src/Orleans.Journaling/CompatibilitySuppressions.xml
index eb687e7cf91..ed87a41fc9b 100644
--- a/src/Orleans.Journaling/CompatibilitySuppressions.xml
+++ b/src/Orleans.Journaling/CompatibilitySuppressions.xml
@@ -267,6 +267,13 @@
lib/net10.0/Orleans.Journaling.dll
true
+
+ CP0006
+ P:Orleans.Journaling.IJournaledStateManager.Hooks
+ lib/net10.0/Orleans.Journaling.dll
+ lib/net10.0/Orleans.Journaling.dll
+ true
+
CP0006
P:Orleans.Journaling.IJournalMetadata.FormatKey
@@ -302,6 +309,13 @@
lib/net8.0/Orleans.Journaling.dll
true
+
+ CP0006
+ P:Orleans.Journaling.IJournaledStateManager.Hooks
+ lib/net8.0/Orleans.Journaling.dll
+ lib/net8.0/Orleans.Journaling.dll
+ true
+
CP0006
P:Orleans.Journaling.IJournalMetadata.FormatKey
diff --git a/src/Orleans.Journaling/IJournaledStateCaptureHook.cs b/src/Orleans.Journaling/IJournaledStateCaptureHook.cs
new file mode 100644
index 00000000000..cd6d0b42693
--- /dev/null
+++ b/src/Orleans.Journaling/IJournaledStateCaptureHook.cs
@@ -0,0 +1,15 @@
+namespace Orleans.Journaling;
+
+///
+/// Establishes the final prerequisite immediately before journal capture or deletion.
+///
+///
+/// An owner admits at most one capture hook in .
+/// Its before callback runs after all ordinary before callbacks. The work loop awaits it directly,
+/// then captures state or starts deletion without a further asynchronous phase.
+/// Its prerequisites cover changes staged while its own I/O awaited. After callbacks retain
+/// normal list order. Features inspect and deduplicate this registration using the same hook list.
+///
+public interface IJournaledStateCaptureHook : IJournaledStateHook
+{
+}
diff --git a/src/Orleans.Journaling/IJournaledStateHook.cs b/src/Orleans.Journaling/IJournaledStateHook.cs
new file mode 100644
index 00000000000..3639b6c658a
--- /dev/null
+++ b/src/Orleans.Journaling/IJournaledStateHook.cs
@@ -0,0 +1,58 @@
+namespace Orleans.Journaling;
+
+///
+/// Identifies a journal persistence operation.
+///
+public enum JournaledStateOperation
+{
+ /// Appends pending journal entries.
+ Write,
+
+ /// Replaces storage with a snapshot.
+ Snapshot,
+
+ /// Deletes storage and resets registered states.
+ Delete
+}
+
+///
+/// Participates in the prerequisites and completion of actual journal operations.
+///
+///
+/// Ordinary before hooks run in list order on the owner's logical execution context, outside its lock.
+/// An optional runs last immediately before capture or deletion.
+/// Prerequisites must cover changes staged during asynchronous preparation. After hooks run after
+/// storage acknowledgement and state acknowledgement or reset, including successful writes which
+/// produce no storage bytes. Hooks retain operation-local data across these boundaries and keep later
+/// pending changes separate. Hook implementations must complete without calling initialization,
+/// persistence, or disposal on their own journal owner. Those operations are serialized behind the
+/// current operation, so awaiting them from a hook would create a circular dependency.
+///
+public interface IJournaledStateHook
+{
+ ///
+ /// Establishes prerequisites before capture or storage deletion.
+ ///
+ /// The operation about to execute.
+ /// The token for the owned operation's lifetime.
+ /// A completion representing the prerequisite work.
+ ///
+ /// Failure reports and retains pending changes
+ /// for an explicit retry. All staged changes remain safe to commit. Full deletion requires the
+ /// owner to stop admission and drain feature operations before queuing deletion.
+ ///
+ ValueTask BeforeOperationAsync(JournaledStateOperation operation, CancellationToken cancellationToken) => default;
+
+ ///
+ /// Completes post-persistence work after the operation succeeds.
+ ///
+ /// The completed operation.
+ /// The token for the owned operation's lifetime.
+ /// A completion representing post-persistence work.
+ ///
+ /// Every after hook is invoked even when an earlier after hook fails. Failures are surfaced as
+ /// and leave the successfully persisted owner usable.
+ /// Durable feature state supplies recovery for interrupted post-persistence work.
+ ///
+ ValueTask AfterOperationAsync(JournaledStateOperation operation, CancellationToken cancellationToken) => default;
+}
diff --git a/src/Orleans.Journaling/IJournaledStateManager.cs b/src/Orleans.Journaling/IJournaledStateManager.cs
index b6363a1b4cc..ffd59075f22 100644
--- a/src/Orleans.Journaling/IJournaledStateManager.cs
+++ b/src/Orleans.Journaling/IJournaledStateManager.cs
@@ -8,10 +8,23 @@ namespace Orleans.Journaling;
///
/// The owner registers state machines and initializes the journal before using recovered state.
/// State machine instances and their dependencies retain the lifetime assigned by their caller.
-/// Disposing this manager stops journal processing and releases its journal resources.
+/// Disposal drains owned storage and hook operations before releasing resources.
///
public interface IJournaledStateManager : IAsyncDisposable
{
+ ///
+ /// Gets the mutable, lazily allocated list of journal operation hooks.
+ ///
+ ///
+ /// Inspect, add, remove, and deduplicate hooks on the owner's logical execution context while
+ /// persistence is quiescent. Mutation while persistence is queued or running is rejected. Ordinary before hooks
+ /// and all after hooks execute in list order. The optional single
+ /// supplies the final prerequisite.
+ /// Registration is independent of state-machine registration and persists through recovery and deletion.
+ /// Every implementation provides this list and invokes its hooks at the documented operation boundaries.
+ ///
+ IList Hooks { get; }
+
///
ValueTask IAsyncDisposable.DisposeAsync() => default;
@@ -51,8 +64,11 @@ 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.
+ /// A storage or state-processing failure fences the manager; recovery requires a new manager and state machine instances.
/// Cancellation stops the caller's wait; an already queued write continues to its storage outcome.
+ /// Before-hook failure reports and retains pending
+ /// changes for an explicit retry. After-hook failure reports
+ /// after successful persistence.
///
/// The token used to cancel the caller's wait.
/// A task representing the write acknowledgement.
@@ -64,7 +80,10 @@ public interface IJournaledStateManager : IAsyncDisposable
///
/// The caller keeps other operations quiescent through completion: deletion resets every registered state machine.
/// Cancellation ends the caller's wait; an already queued deletion continues to its storage and reset outcome.
- /// A failed deletion permanently fences the manager and requests deactivation of its owning grain.
+ /// A storage or state-reset failure permanently fences the manager and requests deactivation of its owning grain.
+ /// Before-hook failure reports with state retained.
+ /// After-hook failure reports after storage deletion
+ /// and state reset succeed.
///
/// The cancellation token.
/// A which represents the operation.
diff --git a/src/Orleans.Journaling/JournaledStateManager.cs b/src/Orleans.Journaling/JournaledStateManager.cs
index cbf6be752bc..6f8d8479d36 100644
--- a/src/Orleans.Journaling/JournaledStateManager.cs
+++ b/src/Orleans.Journaling/JournaledStateManager.cs
@@ -1,6 +1,8 @@
using System.Buffers;
+using System.Collections.ObjectModel;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
+using System.Runtime.ExceptionServices;
using Microsoft.Extensions.Logging;
using Orleans.Diagnostics;
using Orleans.Serialization.Buffers;
@@ -33,6 +35,20 @@ internal partial class JournaledStateManager : IJournaledStateManager, IJournalS
private Exception? _failure;
private bool _migrationSnapshotRequired;
private int _disposed;
+ private Task? _disposeTask;
+ private HookCollection? _hooks;
+ private bool _hookOperationRunning;
+
+ public IList Hooks
+ {
+ get
+ {
+ lock (_lock)
+ {
+ return _hooks ??= new(this);
+ }
+ }
+ }
public JournaledStateManager(JournaledStateManagerShared shared, IJournalStorageProvider storageProvider, IGrainContext grainContext)
: this(shared, CreateStorage(storageProvider, CreateJournalId(grainContext)), grainContext)
@@ -245,6 +261,11 @@ private async Task WorkLoop()
WorkItem workItem;
lock (_lock)
{
+ if (_shutdownCancellation.IsCancellationRequested)
+ {
+ return;
+ }
+
if (!_workQueue.TryDequeue(out var dequeuedWorkItem))
{
// Wait for the queue to be signaled again.
@@ -252,6 +273,7 @@ private async Task WorkLoop()
}
workItem = dequeuedWorkItem;
+ _hookOperationRunning = workItem is AppendJournalWorkItem or WriteSnapshotWorkItem or DeleteStateWorkItem;
}
var processingTimestamp = _shared.TimeProvider.GetTimestamp();
@@ -273,6 +295,9 @@ private async Task WorkLoop()
storageActivity.SetTag(ActivityTagKeys.JournalStorageOperation, queueOperation);
}
+ JournaledStateOperation? hookOperation = null;
+ var beforeHookRunning = false;
+ var afterHookRunning = false;
try
{
if (workItem is AppendJournalWorkItem or WriteSnapshotWorkItem
@@ -282,6 +307,29 @@ private async Task WorkLoop()
"The journaled state operation was queued before deletion reset its state.");
}
+ hookOperation = workItem switch
+ {
+ AppendJournalWorkItem or WriteSnapshotWorkItem =>
+ workItem is WriteSnapshotWorkItem || _migrationSnapshotRequired || _storage.IsCompactionRequested
+ ? JournaledStateOperation.Snapshot : JournaledStateOperation.Write,
+ DeleteStateWorkItem => JournaledStateOperation.Delete,
+ _ => null
+ };
+ if (hookOperation is { } operation && _hooks is { Count: > 0 })
+ {
+ beforeHookRunning = true;
+ var captureHook = await InvokeBeforeHooksAsync(operation, _shutdownCancellation.Token).ConfigureAwait(true);
+
+ if (captureHook is not null)
+ {
+ // Await the final prerequisite in this frame so capture follows its completion directly.
+ await captureHook.BeforeOperationAsync(operation, _shutdownCancellation.Token).ConfigureAwait(true);
+ }
+
+ _shutdownCancellation.Token.ThrowIfCancellationRequested();
+ beforeHookRunning = false;
+ }
+
// 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)
@@ -291,9 +339,7 @@ private async Task WorkLoop()
{
// TODO: decide whether it's best to snapshot or append. Eg, by summing the size of the most recent snapshots and the current journal length.
// If the current journal length is greater than the snapshot size, then take a snapshot instead of appending more journal entries.
- var isSnapshot = workItem is WriteSnapshotWorkItem
- || _migrationSnapshotRequired
- || _storage.IsCompactionRequested;
+ var isSnapshot = hookOperation == JournaledStateOperation.Snapshot;
var operationLabel = isSnapshot
? JournalingInstruments.OperationSnapshot
: JournalingInstruments.OperationAppend;
@@ -540,13 +586,47 @@ private async Task WorkLoop()
}
}
+ if (hookOperation is { } completedOperation && _hooks is { Count: > 0 })
+ {
+ afterHookRunning = true;
+ await InvokeAfterHooksAsync(completedOperation, _shutdownCancellation.Token).ConfigureAwait(true);
+ afterHookRunning = false;
+ }
+
if (recordQueueDuration && queueOperation is not null)
{
_shared.Instruments.OnStorageOperationQueued(queueOperation, queueDuration, succeeded: true);
}
storageActivity?.SetStatus(ActivityStatusCode.Ok);
- workItem.SetResult();
+ lock (_lock)
+ {
+ _hookOperationRunning = false;
+ workItem.SetResult();
+ }
+ }
+ catch (Exception exception) when (beforeHookRunning || afterHookRunning)
+ {
+ if (beforeHookRunning && exception is OperationCanceledException && _shutdownCancellation.IsCancellationRequested)
+ {
+ workItem.TrySetCanceled(_shutdownCancellation.Token);
+ continue;
+ }
+
+ LogOperationHookFailed(_shared.Logger, exception, hookOperation!.Value, afterHookRunning);
+ storageActivity?.SetStatus(ActivityStatusCode.Error, "Journal operation hook failed.");
+ if (recordQueueDuration && queueOperation is not null)
+ {
+ _shared.Instruments.OnStorageOperationQueued(queueOperation, queueDuration, succeeded: afterHookRunning);
+ }
+
+ lock (_lock)
+ {
+ _hookOperationRunning = false;
+ workItem.SetException(afterHookRunning
+ ? new JournaledStatePostCommitException(hookOperation.Value, exception)
+ : new JournaledStatePreCommitException(hookOperation.Value, exception));
+ }
}
catch (Exception exception)
{
@@ -580,6 +660,11 @@ private async Task WorkLoop()
}
finally
{
+ lock (_lock)
+ {
+ _hookOperationRunning = false;
+ }
+
storageActivity?.Dispose();
}
}
@@ -596,6 +681,127 @@ private async Task WorkLoop()
}
}
+ private async ValueTask InvokeBeforeHooksAsync(JournaledStateOperation operation, CancellationToken cancellationToken)
+ {
+ IJournaledStateCaptureHook? captureHook = null;
+ for (var i = 0; i < _hooks!.Count; i++)
+ {
+ var hook = _hooks[i];
+ if (hook is IJournaledStateCaptureHook capture)
+ {
+ captureHook = capture;
+ continue;
+ }
+
+ cancellationToken.ThrowIfCancellationRequested();
+ await hook.BeforeOperationAsync(operation, cancellationToken).ConfigureAwait(true);
+ }
+
+ cancellationToken.ThrowIfCancellationRequested();
+ return captureHook;
+ }
+
+ private async ValueTask InvokeAfterHooksAsync(JournaledStateOperation operation, CancellationToken cancellationToken)
+ {
+ List? failures = null;
+ for (var i = 0; i < _hooks!.Count; i++)
+ {
+ try
+ {
+ await _hooks[i].AfterOperationAsync(operation, cancellationToken).ConfigureAwait(true);
+ }
+ catch (Exception exception)
+ {
+ (failures ??= []).Add(exception);
+ }
+ }
+
+ if (failures is { Count: 1 })
+ {
+ ExceptionDispatchInfo.Capture(failures[0]).Throw();
+ }
+
+ if (failures is not null)
+ {
+ throw new AggregateException(failures);
+ }
+ }
+
+ private sealed class HookCollection(JournaledStateManager owner) : Collection
+ {
+ protected override void InsertItem(int index, IJournaledStateHook item)
+ {
+ lock (owner._lock)
+ {
+ EnsureMutationAllowed();
+ ValidateHook(item, replacingIndex: -1);
+ base.InsertItem(index, item);
+ }
+ }
+
+ protected override void SetItem(int index, IJournaledStateHook item)
+ {
+ lock (owner._lock)
+ {
+ EnsureMutationAllowed();
+ ValidateHook(item, index);
+ base.SetItem(index, item);
+ }
+ }
+
+ protected override void RemoveItem(int index)
+ {
+ lock (owner._lock)
+ {
+ EnsureMutationAllowed();
+ base.RemoveItem(index);
+ }
+ }
+
+ protected override void ClearItems()
+ {
+ lock (owner._lock)
+ {
+ EnsureMutationAllowed();
+ base.ClearItems();
+ }
+ }
+
+ private void EnsureMutationAllowed()
+ {
+ ObjectDisposedException.ThrowIf(owner._disposed != 0, owner);
+ owner._shutdownCancellation.Token.ThrowIfCancellationRequested();
+ owner.ThrowIfFenced();
+ if (owner._hookOperationRunning)
+ {
+ throw new InvalidOperationException("Journal operation hooks can be changed only while persistence is quiescent.");
+ }
+
+ foreach (var workItem in owner._workQueue)
+ {
+ if (workItem is AppendJournalWorkItem or WriteSnapshotWorkItem or DeleteStateWorkItem)
+ {
+ throw new InvalidOperationException("Journal operation hooks can be changed only while persistence is quiescent.");
+ }
+ }
+ }
+
+ private void ValidateHook(IJournaledStateHook item, int replacingIndex)
+ {
+ ArgumentNullException.ThrowIfNull(item);
+ if (item is IJournaledStateCaptureHook)
+ {
+ for (var i = 0; i < Count; i++)
+ {
+ if (i != replacingIndex && this[i] is IJournaledStateCaptureHook)
+ {
+ throw new InvalidOperationException("A journal owner supports one final capture prerequisite hook. Inspect and deduplicate the hook list before registration.");
+ }
+ }
+ }
+ }
+ }
+
private void Fence(Exception exception)
{
lock (_lock)
@@ -1062,9 +1268,22 @@ void ILifecycleParticipant.Participate(IGrainLifecycle observer
private async Task StopAsync(CancellationToken cancellationToken)
{
+ AggregateException? cancellationFailure = null;
lock (_lock)
{
- _shutdownCancellation.Cancel();
+ try
+ {
+ _shutdownCancellation.Cancel();
+ }
+ catch (AggregateException exception)
+ {
+ cancellationFailure = exception;
+ }
+ }
+
+ if (cancellationFailure is not null)
+ {
+ LogShutdownCancellationFailed(_shared.Logger, cancellationFailure);
}
_workSignal.Signal();
@@ -1079,6 +1298,11 @@ private async Task StopAsync(CancellationToken cancellationToken)
{
CancelQueuedWorkItems(_shutdownCancellation.Token);
}
+
+ if (cancellationFailure is not null)
+ {
+ ExceptionDispatchInfo.Capture(cancellationFailure).Throw();
+ }
}
private void CancelQueuedWorkItems(CancellationToken cancellationToken)
@@ -1097,13 +1321,17 @@ void IDisposable.Dispose()
DisposeAsync().AsTask().GetAwaiter().GetResult();
}
- public async ValueTask DisposeAsync()
+ public ValueTask DisposeAsync()
{
- if (Interlocked.Exchange(ref _disposed, 1) != 0)
+ lock (_lock)
{
- return;
+ return new(_disposeTask ??= DisposeCoreAsync());
}
+ }
+ private async Task DisposeCoreAsync()
+ {
+ _disposed = 1;
try
{
await StopAsync(CancellationToken.None).ConfigureAwait(false);
@@ -1352,6 +1580,16 @@ 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 {Operation} hook failed. Persistence completed: {Committed}.")]
+ private static partial void LogOperationHookFailed(ILogger logger, Exception exception, JournaledStateOperation operation, bool committed);
+
+ [LoggerMessage(
+ Level = LogLevel.Error,
+ Message = "Journal shutdown cancellation callback failed; owned operations are drained before resources are released.")]
+ private static partial void LogShutdownCancellationFailed(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/JournaledStatePostCommitException.cs b/src/Orleans.Journaling/JournaledStatePostCommitException.cs
new file mode 100644
index 00000000000..8274c8726d6
--- /dev/null
+++ b/src/Orleans.Journaling/JournaledStatePostCommitException.cs
@@ -0,0 +1,22 @@
+namespace Orleans.Journaling;
+
+///
+/// Reports a hook failure after the journal operation and its state acknowledgement or reset succeeded.
+///
+///
+/// The journal owner remains usable. The caller handles the failed post-persistence work using
+/// the feature's durable recovery protocol, preserving the completed business operation.
+///
+[GenerateSerializer]
+public sealed class JournaledStatePostCommitException : Exception
+{
+ ///
+ /// Initializes a post-persistence hook failure.
+ ///
+ /// The successfully completed journal operation.
+ /// The failure or aggregate of failures from after hooks.
+ public JournaledStatePostCommitException(JournaledStateOperation operation, Exception innerException)
+ : base($"Journal operation '{operation}' completed, but a post-persistence hook failed.", innerException)
+ {
+ }
+}
diff --git a/src/Orleans.Journaling/JournaledStatePreCommitException.cs b/src/Orleans.Journaling/JournaledStatePreCommitException.cs
new file mode 100644
index 00000000000..fe8d2eb79d4
--- /dev/null
+++ b/src/Orleans.Journaling/JournaledStatePreCommitException.cs
@@ -0,0 +1,22 @@
+namespace Orleans.Journaling;
+
+///
+/// Reports a prerequisite hook failure which prevented the journal storage operation.
+///
+///
+/// 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 prerequisite hook failure.
+ ///
+ /// The journal operation prevented by the failed prerequisite.
+ /// The original prerequisite failure.
+ public JournaledStatePreCommitException(JournaledStateOperation operation, Exception innerException)
+ : base($"Journal operation '{operation}' was prevented by a prerequisite hook failure.", innerException)
+ {
+ }
+}
diff --git a/src/Orleans.Journaling/README.md b/src/Orleans.Journaling/README.md
index 2f5046a30be..ba7257516a2 100644
--- a/src/Orleans.Journaling/README.md
+++ b/src/Orleans.Journaling/README.md
@@ -92,7 +92,7 @@ Recovery reads the selected provider's physical namespace using the existing
journal identity. Changing the selection for a grain type with existing journals
requires a deliberate data migration or cutover strategy, including rollback.
-JSON Lines is the default `JournaledStateManagerOptions.JournalFormatKey`. Storage providers expose the stored journal format key through `IJournalMetadata.FormatKey` and `JournalMetadata.FormatKey`. During recovery, Orleans uses that stored key to select the matching journal format and durable operation codecs. If a non-empty journal has no stored format metadata, Orleans treats it as legacy OrleansBinary data for compatibility.
+JSON Lines is the default `JournaledStateManagerOptions.JournalFormatKey`. Storage providers expose the stored journal format key through `IJournalMetadata.FormatKey` and `JournalMetadata.FormatKey`. During recovery, Orleans uses that stored key to select the matching journal format and durable operation codecs. When stored format metadata is absent, recovery uses the configured format. New empty journals use the configured write format.
If you already have data written with the OrleansBinary format, you can keep using it while you plan a migration:
@@ -264,6 +264,44 @@ the operation's token before use, or deliberately supply them through a registra
lifecycle ownership. Creation through the explicit-journal factory keeps failure handling independent
of the ambient grain context, including when the caller subsequently enrolls the manager in a lifecycle.
+## Journal operation hooks
+
+`IJournaledStateManager.Hooks` is a lazily allocated, stable list of `IJournaledStateHook`
+registrations. Features inspect and deduplicate their registrations on the owner's logical
+execution context while persistence is quiescent. Registration persists through recovery and
+whole-journal deletion. The standard manager rejects mutation of the list while persistence is
+queued or running and admits at most one `IJournaledStateCaptureHook`.
+Every journal owner implementation provides the hook list and invokes registered callbacks at
+the operation boundaries described below. Delegating owners forward the list to their inner owner.
+
+For each actual write, snapshot, or deletion, ordinary before hooks run in list order outside
+the manager lock. The capture hook runs last: the work loop awaits it directly, then synchronously
+captures the registered states or starts deletion. Prerequisites cover all changes staged during
+asynchronous preparation, including changes arriving while the capture hook awaits its own I/O.
+Keep operation-local bookkeeping for the captured batch separate from later pending changes.
+
+After hooks run in list order after storage acknowledgement and state acknowledgement or reset.
+They also run for a successful zero-byte write. Coalesced callers share the hooks for their actual
+operation. Features implement the before and after callbacks on their own hook, retaining their
+identity and operation-local bookkeeping there.
+
+| Outcome | Owner and caller behavior |
+| --- | --- |
+| Before hook fails | `JournaledStatePreCommitException` retains pending state for an explicit retry after restoring the prerequisite. |
+| Storage or state processing fails | The original failure fences the manager; create a fresh owner and recover the durable outcome. |
+| After hook fails | `JournaledStatePostCommitException` reports successful persistence. Every remaining after hook runs, failures are aggregated, and the manager stays usable. The feature's durable recovery protocol resumes interrupted post-persistence work. |
+
+Hooks receive the owner's shutdown token. Caller cancellation ends the caller's wait while
+the owned prerequisite, capture, storage, and completion phases continue. Disposal cancels the
+owner token and drains owned work before releasing journal resources, including when cancellation
+callbacks or after-hook cleanup fail. Concurrent disposal callers share that drain and its outcome.
+Shutdown closes work admission and cancels queued operations while an already running operation
+drains to its actual storage and hook outcome.
+Hook implementations complete without calling initialization, persistence, or disposal on their
+own owner: awaiting an operation serialized behind the current callback creates a circular dependency.
+Before whole-journal deletion, the feature owner stops admission and drains its own operations;
+deletion completion follows storage deletion and registered-state reset.
+
## State identity and retirement
Preserve state names across activations and deployments. A stream absent from the setup declarations
@@ -312,7 +350,7 @@ Each record contains the state id as element 0 and the durable operation payload
Inside the operation payload array, element 0 is the command name, followed by command-specific operands such as keys, values, item arrays, or versions. Storage write batches append one or more complete JSON Lines records without adding a separate extent envelope or final container-close step.
-Existing data is read using its stored format metadata, or as legacy OrleansBinary data when metadata is absent, and migrated to the configured write format by the next snapshot write.
+Existing data is read using its stored format key, or the configured format when metadata is absent, and migrated to the configured write format by the next snapshot write.
## Catalog enumeration
diff --git a/src/Orleans.Messaging/Configuration/InboxOptions.cs b/src/Orleans.Messaging/Configuration/InboxOptions.cs
new file mode 100644
index 00000000000..15e43c902fd
--- /dev/null
+++ b/src/Orleans.Messaging/Configuration/InboxOptions.cs
@@ -0,0 +1,238 @@
+using System;
+
+namespace Orleans.Messaging.Configuration;
+
+///
+/// Configuration options for the inbox messaging system.
+///
+///
+///
+/// These options control the behavior of the inbox, including capacity limits,
+/// deduplication tracking, retry behavior, and pump batch sizes.
+///
+///
+/// Transport is at-least-once. Deduplication provides effectively-once handler effects only
+/// while the processed-message record is retained. Configuration values affect memory usage,
+/// throughput, and recovery characteristics.
+///
+///
+public class InboxOptions
+{
+ 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 messages in the inbox.
+ /// When this limit is reached, new message deliveries will 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.
+ ///
+ ///
+ ///
+ /// The maximum inbox capacity. Must be greater than zero. Defaults to 1000.
+ ///
+ public int MaxCapacity { get; set; } = 1000;
+
+ ///
+ /// Gets or sets the time window for tracking processed messages to prevent duplicates.
+ /// Messages that were processed within this window will be rejected with DeliveryResult.Duplicate().
+ ///
+ ///
+ ///
+ /// A longer window (e.g., 30 days) provides stronger deduplication guarantees but increases
+ /// memory usage and storage I/O. A shorter window (e.g., 1 hour) reduces overhead but may
+ /// allow duplicate processing if retries are delayed.
+ ///
+ ///
+ /// Processed message tracking uses the exact receiver-local MessageId with timestamps.
+ /// Expired entries are removed atomically when a replay is accepted and are also eligible for
+ /// compaction during inbox pump maintenance.
+ ///
+ ///
+ /// Consider your retry policies when setting this value. For example, if senders retry for
+ /// up to 24 hours, set the window to at least 48 hours to ensure deduplication coverage.
+ ///
+ ///
+ ///
+ /// The deduplication window. Must be greater than zero. Defaults to 7 days.
+ ///
+ public TimeSpan DeduplicationWindow { get; set; } = TimeSpan.FromDays(7);
+
+ ///
+ /// Gets or sets the base delay between retry attempts when delivery encounters backpressure.
+ ///
+ ///
+ ///
+ /// When the target inbox is at capacity and returns DeliveryResult.Backpressured(),
+ /// the outbox delivery pump applies exponential backoff from this duration before retrying.
+ ///
+ ///
+ /// 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 base backpressure retry delay. Must be greater than zero. Defaults to 1 second.
+ ///
+ public TimeSpan BackpressureRetryDelay { get; set; } = TimeSpan.FromSeconds(1);
+
+ ///
+ /// Gets or sets the maximum number of attempts before an inbox message is dead-lettered.
+ ///
+ public int MaxProcessingAttempts { get; set; } = 5;
+
+ ///
+ /// Gets or sets the maximum number of attempts before an outbox message is dead-lettered.
+ ///
+ public int MaxDeliveryAttempts { get; set; } = 100;
+
+ ///
+ /// Gets or sets the maximum age of an outbox message.
+ ///
+ public TimeSpan MaxOutboxRetryAge { get; set; } = TimeSpan.FromDays(1);
+
+ ///
+ /// 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 durable job attempt.
+ ///
+ public int InboxBatchSize { get; set; } = 32;
+
+ ///
+ /// Gets or sets the maximum number of outbox messages processed by one durable job attempt.
+ ///
+ public int OutboxBatchSize { get; set; } = 32;
+
+ ///
+ /// Gets or sets how long an empty outbox retains its acknowledged physical recovery job.
+ ///
+ ///
+ /// The default is 100 milliseconds. Zero selects immediate retirement. Ready local work
+ /// wakes its pump immediately, while delivery, acceptance, and acknowledgement keep their
+ /// normal durability boundaries. Once idle, the job is rescheduled at the idle deadline;
+ /// provider polling and activation latency also contribute to recovery timing.
+ /// A durable wakeup is scheduled before outbound work can be acknowledged.
+ ///
+ /// A non-negative interval within the supported timer range.
+ public TimeSpan OutboxIdleRetirementGracePeriod { get; set; } = TimeSpan.FromMilliseconds(100);
+
+ ///
+ /// 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 (OutboxIdleRetirementGracePeriod < TimeSpan.Zero
+ || OutboxIdleRetirementGracePeriod > TimeSpan.FromMilliseconds(uint.MaxValue - 1))
+ {
+ throw new ArgumentOutOfRangeException(nameof(OutboxIdleRetirementGracePeriod), OutboxIdleRetirementGracePeriod,
+ "OutboxIdleRetirementGracePeriod must be non-negative and within the supported timer range.");
+ }
+
+ 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 (BackpressureRetryDelay <= TimeSpan.Zero)
+ {
+ throw new ArgumentOutOfRangeException(nameof(BackpressureRetryDelay), BackpressureRetryDelay, "BackpressureRetryDelay must be greater than TimeSpan.Zero.");
+ }
+ if (BackpressureRetryDelay > MaxSupportedRetryDelay)
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(BackpressureRetryDelay),
+ BackpressureRetryDelay,
+ $"BackpressureRetryDelay must be less than or equal to {MaxSupportedRetryDelay}.");
+ }
+
+ if (MaxProcessingAttempts <= 0)
+ {
+ throw new ArgumentOutOfRangeException(nameof(MaxProcessingAttempts), MaxProcessingAttempts, "MaxProcessingAttempts must be greater than zero.");
+ }
+
+ if (MaxDeliveryAttempts <= 0)
+ {
+ throw new ArgumentOutOfRangeException(nameof(MaxDeliveryAttempts), MaxDeliveryAttempts, "MaxDeliveryAttempts must be greater than zero.");
+ }
+
+ if (MaxOutboxRetryAge <= TimeSpan.Zero)
+ {
+ throw new ArgumentOutOfRangeException(nameof(MaxOutboxRetryAge), MaxOutboxRetryAge, "MaxOutboxRetryAge must be greater than TimeSpan.Zero.");
+ }
+
+ if (MaxOutboxRetryAge >= DeduplicationWindow)
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(MaxOutboxRetryAge),
+ MaxOutboxRetryAge,
+ "MaxOutboxRetryAge 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..6124b87548c
--- /dev/null
+++ b/src/Orleans.Messaging/DeliveryResult.cs
@@ -0,0 +1,55 @@
+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; }
+
+ ///
+ /// 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.
+ ///
+ public static DeliveryResult DeadLettered(string reason) => new()
+ {
+ Status = DeliveryStatus.DeadLettered,
+ Message = reason
+ };
+}
diff --git a/src/Orleans.Messaging/DeliveryStatus.cs b/src/Orleans.Messaging/DeliveryStatus.cs
new file mode 100644
index 00000000000..d4d24333e2d
--- /dev/null
+++ b/src/Orleans.Messaging/DeliveryStatus.cs
@@ -0,0 +1,32 @@
+namespace Orleans.Messaging;
+
+///
+/// Status codes for delivery attempts.
+///
+public enum DeliveryStatus
+{
+ ///
+ /// Message was accepted and persisted to inbox.
+ ///
+ Accepted = 0,
+
+ ///
+ /// Message was a duplicate (already processed or in inbox).
+ ///
+ Duplicate = 1,
+
+ ///
+ /// Inbox is at capacity; sender should retry later.
+ ///
+ Backpressured = 2,
+
+ ///
+ /// The receiving inbox has no registered handler.
+ ///
+ HandlerNotFound = 3,
+
+ ///
+ /// The message was moved to the receiver's dead-letter store.
+ ///
+ DeadLettered = 4
+}
diff --git a/src/Orleans.Messaging/Envelope.cs b/src/Orleans.Messaging/Envelope.cs
new file mode 100644
index 00000000000..5627271c703
--- /dev/null
+++ b/src/Orleans.Messaging/Envelope.cs
@@ -0,0 +1,96 @@
+using System;
+using System.Collections.Generic;
+using Orleans.Serialization;
+
+namespace Orleans.Messaging;
+
+/// Contains a command identity and independently encoded headers in one managed buffer.
+///
+/// Build an envelope before publication using . Payload is required
+/// and can be empty. Subject, sender, and application headers are optional. Raw views share the final
+/// buffer and remain available across asynchronous preparation. Keep the published bytes unchanged;
+/// ordinary Orleans deep copying and deserialization produce independent buffers.
+///
+[GenerateSerializer, Alias("Orleans.Messaging.Envelope")]
+public readonly struct Envelope
+{
+ [Id(1)]
+ private readonly byte[]? _headers;
+
+ internal Envelope(HierarchicalKey messageId, byte[] headers)
+ {
+ MessageId = messageId;
+ _headers = headers;
+ }
+
+ /// Gets the exact application-defined identity within the receiving inbox.
+ [Id(0)]
+ public HierarchicalKey MessageId { get; }
+
+ /// Gets the required payload as a slice of the encoded header buffer.
+ public ReadOnlyMemory Payload => PackedEnvelopeHeaders.GetPayload(Data);
+
+ /// Gets the complete packed header directory and values.
+ /// The bytes follow the current envelope format and remain unchanged after publication.
+ public ReadOnlyMemory EncodedHeaders => Data;
+
+ /// Enumerates ordinal header names, decoding custom names on demand.
+ public IEnumerable Keys => PackedEnvelopeHeaders.GetKeys(Data);
+
+ private byte[] Data => _headers ?? throw new FormatException("The envelope header buffer is missing.");
+
+ /// Creates an envelope by validating and copying an encoded header buffer.
+ /// The required command identity.
+ /// The complete packed directory and value bytes.
+ /// An envelope with its own exact-length managed buffer.
+ /// The identity exceeds admission limits or is unset.
+ /// The framing, keys, or required payload are invalid.
+ public static Envelope FromEncodedHeaders(HierarchicalKey messageId, ReadOnlySpan encodedHeaders)
+ {
+ EnvelopeValidation.ValidateMessageId(messageId);
+ PackedEnvelopeHeaders.Validate(encodedHeaders);
+ return new(messageId, encodedHeaders.ToArray());
+ }
+
+ /// Looks up raw value bytes without decoding other headers.
+ /// The exact ordinal header name.
+ /// A slice of the common buffer when present.
+ /// Whether the header is present, including a present empty value.
+ public bool TryGetBytes(string key, out ReadOnlyMemory value) => PackedEnvelopeHeaders.TryGetBytes(Data, key, out value);
+
+ /// Decodes one independently serialized header using an externally bound serializer.
+ /// The exact header name. Subjects use raw UTF-8 retrieval.
+ /// The Orleans serializer for this header's value format.
+ /// The decoded value, which can be null for a present header.
+ /// Whether the header is present. Decoding failures propagate to the caller.
+ public bool TryGetValue(string key, Serializer serializer, out T? value)
+ {
+ ArgumentNullException.ThrowIfNull(serializer);
+ if (key == MessageHeaders.Subject)
+ {
+ throw new ArgumentException("Subjects use raw UTF-8 bytes.", nameof(key));
+ }
+ if (TryGetBytes(key, out var bytes))
+ {
+ value = serializer.Deserialize(bytes.Span);
+ return true;
+ }
+ value = default;
+ return false;
+ }
+
+ /// Decodes the optional subject for diagnostics or application inspection.
+ /// The exact subject when present.
+ /// Whether a subject is present.
+ /// Dispatch can compare the raw subject slice against preencoded UTF-8 bytes instead.
+ public bool TryGetSubject(out string? subject)
+ {
+ if (TryGetBytes(MessageHeaders.Subject, out var bytes))
+ {
+ subject = PackedEnvelopeHeaders.Utf8.GetString(bytes.Span);
+ return true;
+ }
+ subject = null;
+ return false;
+ }
+}
diff --git a/src/Orleans.Messaging/EnvelopeBuilder.cs b/src/Orleans.Messaging/EnvelopeBuilder.cs
new file mode 100644
index 00000000000..ca09bcb9663
--- /dev/null
+++ b/src/Orleans.Messaging/EnvelopeBuilder.cs
@@ -0,0 +1,155 @@
+using System;
+using System.Buffers;
+using System.Collections.Generic;
+using Orleans.Serialization;
+using Orleans.Serialization.Buffers;
+
+namespace Orleans.Messaging;
+
+/// Encodes headers into temporary pooled storage and publishes one exact-length managed buffer.
+///
+/// This builder is single-caller and disposable. Values have independent serialization sessions.
+/// A failed value writer leaves its entry unpublished; later successful additions and builds retain
+/// only committed entries. Every build creates independent bytes and preserves earlier publications.
+///
+public sealed class EnvelopeBuilder : IDisposable, IBufferWriter
+{
+ private readonly HierarchicalKey _messageId;
+ private readonly Dictionary _entries = new(StringComparer.Ordinal);
+ private PooledBuffer _values = new();
+ private bool _disposed;
+
+ /// Creates a builder for a required application command identity.
+ /// The identity, limited to 1,024 UTF-8 bytes and 32 segments.
+ public EnvelopeBuilder(HierarchicalKey messageId)
+ {
+ EnvelopeValidation.ValidateMessageId(messageId);
+ _messageId = messageId;
+ }
+
+ /// Copies a raw header value into the common temporary buffer.
+ /// The unique ordinal name. Subject bytes must be nonempty canonical UTF-8.
+ /// The bytes to copy, including an empty payload.
+ /// The key, subject, or duplicate insertion is invalid.
+ public void AddBytes(string key, ReadOnlySpan value)
+ {
+ if (!TryAddBytes(key, value))
+ {
+ throw new ArgumentException("The header already exists.", nameof(key));
+ }
+ }
+
+ /// Adds raw bytes when the header is absent, preserving the first value on a duplicate.
+ /// The unique ordinal header name.
+ /// The bytes to copy.
+ /// Whether the value was added.
+ public bool TryAddBytes(string key, ReadOnlySpan value)
+ {
+ ThrowIfDisposed();
+ PackedEnvelopeHeaders.ValidateKey(key);
+ if (_entries.ContainsKey(key)) return false;
+ if (key == MessageHeaders.Subject) PackedEnvelopeHeaders.ValidateSubject(value);
+ var start = _values.Length;
+ _values.Write(value);
+ _entries.Add(key, (start, value.Length));
+ return true;
+ }
+
+ /// Serializes one value directly into the common buffer using an independent session.
+ /// The header value type.
+ /// The unique header name. Subjects use .
+ /// The value, including a typed null.
+ /// The externally bound serializer.
+ /// The key is a duplicate or uses typed subject encoding.
+ public void AddValue(string key, T? value, Serializer serializer)
+ {
+ if (!TryAddValue(key, value, serializer))
+ {
+ throw new ArgumentException("The header already exists.", nameof(key));
+ }
+ }
+
+ /// Serializes a value only when its header is absent.
+ /// The unique ordinal header name.
+ /// The value to encode.
+ /// The externally bound serializer.
+ /// Whether the value was added. Writer failures propagate without publishing an entry.
+ public bool TryAddValue(string key, T? value, Serializer serializer)
+ {
+ ThrowIfDisposed();
+ ArgumentNullException.ThrowIfNull(serializer);
+ PackedEnvelopeHeaders.ValidateKey(key);
+ if (key == MessageHeaders.Subject) throw new ArgumentException("Subjects use raw UTF-8 bytes.", nameof(key));
+ if (_entries.ContainsKey(key)) return false;
+ var start = _values.Length;
+ serializer.Serialize(value!, this);
+ _entries.Add(key, (start, _values.Length - start));
+ return true;
+ }
+
+ /// Publishes committed entries in one buffer, with the required payload first.
+ /// A complete envelope owning independent managed bytes.
+ /// The required payload was not added.
+ public Envelope Build()
+ {
+ ThrowIfDisposed();
+ if (!_entries.TryGetValue(MessageHeaders.Payload, out var payload))
+ {
+ throw new InvalidOperationException("The payload header is required, including for an empty payload.");
+ }
+ var directoryLength = PackedEnvelopeHeaders.VarIntLength(payload.Length);
+ var valueLength = payload.Length;
+ foreach (var entry in _entries)
+ {
+ if (entry.Key == MessageHeaders.Payload) continue;
+ var token = PackedEnvelopeHeaders.GetKeyToken(entry.Key);
+ directoryLength = checked(directoryLength + PackedEnvelopeHeaders.VarIntLength(token)
+ + (token >= 4 ? token - 3 : 0) + PackedEnvelopeHeaders.VarIntLength(entry.Value.Length));
+ valueLength = checked(valueLength + entry.Value.Length);
+ }
+ var prefixLength = 1 + PackedEnvelopeHeaders.VarIntLength(_entries.Count) + PackedEnvelopeHeaders.VarIntLength(directoryLength);
+ var result = new byte[checked(prefixLength + directoryLength + valueLength)];
+ var position = 0;
+ result[position++] = PackedEnvelopeHeaders.Version;
+ PackedEnvelopeHeaders.WriteVarInt(result, ref position, _entries.Count);
+ PackedEnvelopeHeaders.WriteVarInt(result, ref position, directoryLength);
+ PackedEnvelopeHeaders.WriteVarInt(result, ref position, payload.Length);
+ var valuePosition = prefixLength + directoryLength;
+ CopyValue(payload, result, ref valuePosition);
+ foreach (var entry in _entries)
+ {
+ if (entry.Key == MessageHeaders.Payload) continue;
+ var token = PackedEnvelopeHeaders.GetKeyToken(entry.Key);
+ PackedEnvelopeHeaders.WriteVarInt(result, ref position, token);
+ if (token >= 4)
+ {
+ position += PackedEnvelopeHeaders.Utf8.GetBytes(entry.Key, result.AsSpan(position, token - 3));
+ }
+ PackedEnvelopeHeaders.WriteVarInt(result, ref position, entry.Value.Length);
+ CopyValue(entry.Value, result, ref valuePosition);
+ }
+ System.Diagnostics.Debug.Assert(position == prefixLength + directoryLength && valuePosition == result.Length);
+ return new(_messageId, result);
+ }
+
+ private void CopyValue((int Offset, int Length) entry, byte[] result, ref int position)
+ {
+ _values.AsReadOnlySequence().Slice(entry.Offset, entry.Length).CopyTo(result.AsSpan(position, entry.Length));
+ position += entry.Length;
+ }
+
+ /// Releases temporary storage. Previously built envelopes retain their managed bytes.
+ public void Dispose()
+ {
+ if (_disposed) return;
+ _disposed = true;
+ _values.Dispose();
+ _entries.Clear();
+ }
+
+ private void ThrowIfDisposed() => ObjectDisposedException.ThrowIf(_disposed, this);
+
+ void IBufferWriter.Advance(int count) { ThrowIfDisposed(); _values.Advance(count); }
+ Memory IBufferWriter.GetMemory(int sizeHint) { ThrowIfDisposed(); return _values.GetMemory(sizeHint); }
+ Span IBufferWriter.GetSpan(int sizeHint) { ThrowIfDisposed(); return _values.GetSpan(sizeHint); }
+}
diff --git a/src/Orleans.Messaging/EnvelopeEquivalence.cs b/src/Orleans.Messaging/EnvelopeEquivalence.cs
new file mode 100644
index 00000000000..dc40394cbee
--- /dev/null
+++ b/src/Orleans.Messaging/EnvelopeEquivalence.cs
@@ -0,0 +1,23 @@
+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)
+ {
+ if (left.MessageId != right.MessageId) return false;
+ var leftPayload = left.Payload;
+ var rightPayload = right.Payload;
+ if (leftPayload.Length != rightPayload.Length) return false;
+ var hasLeft = left.TryGetBytes(MessageHeaders.Subject, out var leftSubject);
+ var hasRight = right.TryGetBytes(MessageHeaders.Subject, out var rightSubject);
+ return hasLeft == hasRight && (!hasLeft || leftSubject.Span.SequenceEqual(rightSubject.Span))
+ && leftPayload.Span.SequenceEqual(rightPayload.Span);
+ }
+}
diff --git a/src/Orleans.Messaging/EnvelopeValidation.cs b/src/Orleans.Messaging/EnvelopeValidation.cs
new file mode 100644
index 00000000000..a20b87b329e
--- /dev/null
+++ b/src/Orleans.Messaging/EnvelopeValidation.cs
@@ -0,0 +1,43 @@
+using System;
+using System.Text;
+
+namespace Orleans.Messaging;
+
+internal static class EnvelopeValidation
+{
+ internal const int MaxMessageIdBytes = 1024;
+ internal const int MaxMessageIdSegments = 32;
+ 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);
+ PackedEnvelopeHeaders.Validate(envelope.EncodedHeaders.Span);
+ }
+
+ 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(HierarchicalKey messageId)
+ {
+ if (messageId.IsDefault) throw new ArgumentException("The message ID must not be unset.", nameof(messageId));
+ if (messageId.SegmentCount > MaxMessageIdSegments)
+ throw new ArgumentException($"The message ID exceeds {MaxMessageIdSegments} segments.", 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 (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..0debc8647be
--- /dev/null
+++ b/src/Orleans.Messaging/HierarchicalKey.cs
@@ -0,0 +1,289 @@
+using System;
+using System.Diagnostics.CodeAnalysis;
+using System.Text;
+
+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. Assignment shares immutable backing data; equality and
+/// hashing use the full canonical identity. Applications preserve the identity across retries.
+///
+[Immutable, Alias("Orleans.Messaging.HierarchicalKey")]
+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 readonly KeyData? _data;
+
+ private HierarchicalKey(string canonical, int segmentCount) => _data = new(canonical, segmentCount);
+
+ /// Gets whether this value is unset.
+ public bool IsDefault => _data is null;
+
+ /// Gets the canonical path length in UTF-16 characters.
+ public int Length => _data?.Canonical.Length ?? 0;
+
+ /// Gets the number of segments, or zero for an unset key.
+ public int SegmentCount => _data?.SegmentCount ?? 0;
+
+ /// Creates one literal segment, escaping slash and backslash characters.
+ /// The nonempty literal segment.
+ /// The segment identity.
+ /// is null.
+ /// is empty.
+ public static HierarchicalKey Create(string value)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(value);
+ return new(Escape(value), 1);
+ }
+
+ /// 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.
+ 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)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(value);
+ if (builder.Length > 0) builder.Append(SegmentSeparator);
+ AppendEscaped(builder, value);
+ }
+ return new(builder.ToString(), values.Length);
+ }
+
+ /// Appends one literal child segment.
+ /// The nonempty literal child segment.
+ /// The child identity.
+ /// This key is unset.
+ /// is null.
+ /// is empty.
+ public HierarchicalKey CreateChildKey(string value)
+ {
+ EnsureSet();
+ ArgumentException.ThrowIfNullOrEmpty(value);
+ return new(string.Concat(_data!.Canonical, "/", Escape(value)), checked(SegmentCount + 1));
+ }
+
+ /// 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(_data!.Canonical, "/", suffix._data!.Canonical), checked(SegmentCount + suffix.SegmentCount));
+ }
+
+ /// 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 (SegmentCount < 2) return null;
+ var lastSeparator = 0;
+ var path = _data!.Canonical.AsSpan();
+ for (var i = 0; i < path.Length; i++)
+ {
+ if (path[i] == EscapeCharacter) i++;
+ else if (path[i] == SegmentSeparator) lastSeparator = i;
+ }
+ return new HierarchicalKey(_data.Canonical[..lastSeparator], SegmentCount - 1);
+ }
+
+ /// 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) => !IsDefault && other.SegmentCount == SegmentCount + 1 && IsAncestorOf(other);
+
+ /// 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._data!.Canonical[Length] == SegmentSeparator
+ && other._data.Canonical.StartsWith(_data!.Canonical, 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 && TryCountSegments(s, out var count))
+ {
+ result = new(s, count);
+ return true;
+ }
+ result = default;
+ return false;
+ }
+
+ ///
+ public static bool TryParse(ReadOnlySpan s, IFormatProvider? provider, out HierarchicalKey result)
+ {
+ if (TryCountSegments(s, out var count))
+ {
+ result = new(new string(s), count);
+ return true;
+ }
+ result = default;
+ return false;
+ }
+
+ ///
+ public bool Equals(HierarchicalKey other) => ReferenceEquals(_data, other._data)
+ || string.Equals(_data?.Canonical, other._data?.Canonical, StringComparison.Ordinal);
+
+ ///
+ public override bool Equals(object? obj) => obj is HierarchicalKey other && Equals(other);
+
+ ///
+ public override int GetHashCode() => _data?.Hash ?? 0;
+
+ /// 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() => _data?.Canonical ?? 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 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 bool TryCountSegments(ReadOnlySpan path, out int count)
+ {
+ count = 0;
+ 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;
+ count++;
+ segmentLength = 0;
+ continue;
+ }
+ segmentLength++;
+ }
+ if (segmentLength == 0) return false;
+ count++;
+ return true;
+ }
+
+ private sealed class KeyData(string canonical, int segmentCount)
+ {
+ public string Canonical { get; } = canonical;
+ public int Hash { get; } = canonical.GetHashCode(StringComparison.Ordinal);
+ public int SegmentCount { get; } = segmentCount;
+ }
+
+ /// 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/IInbox.cs b/src/Orleans.Messaging/IInbox.cs
new file mode 100644
index 00000000000..64dd0ad1127
--- /dev/null
+++ b/src/Orleans.Messaging/IInbox.cs
@@ -0,0 +1,43 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+
+namespace Orleans.Messaging;
+
+///
+/// Registers a grain's opaque-message handler and exposes pending inbox state.
+///
+public interface IInbox
+{
+ ///
+ /// Gets the number of unprocessed messages.
+ ///
+ int Count { get; }
+
+ ///
+ /// Gets the capacity at which delivery returns .
+ ///
+ int Capacity { get; }
+
+ ///
+ /// Gets pending messages in unspecified order.
+ ///
+ /// Messages share their finalized envelope buffers, which remain unchanged after publication.
+ IEnumerable Messages { get; }
+
+ ///
+ /// Looks up a pending command by its application identity within this inbox.
+ ///
+ /// The exact command identity, independent of its immediate sender and subject.
+ /// The matching stored message when found.
+ /// Whether the message is pending.
+ bool TryGetMessage(HierarchicalKey messageId, [MaybeNullWhen(false)] out InboxMessage message);
+
+ ///
+ /// Registers the handler for this inbox.
+ ///
+ /// The handler responsible for application decoding and dispatch.
+ /// is null.
+ /// A handler has already been registered.
+ void RegisterHandler(IInboxHandler handler);
+}
diff --git a/src/Orleans.Messaging/IInboxExtension.cs b/src/Orleans.Messaging/IInboxExtension.cs
new file mode 100644
index 00000000000..4893ab90e81
--- /dev/null
+++ b/src/Orleans.Messaging/IInboxExtension.cs
@@ -0,0 +1,36 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using Orleans;
+using Orleans.Runtime;
+using Orleans.Serialization;
+
+namespace Orleans.Messaging;
+
+///
+/// Non-generic grain extension for inbox message delivery.
+///
+[Alias("IInboxExtension")]
+public interface IInboxExtension : 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 finalized envelope buffer under the immutable-publication contract.
+ /// RPC copying and deserialization isolate envelope buffers using ordinary Orleans serialization.
+ /// Once delivery owns inbox admission, it retains its gate and ownership reservation until
+ /// its operation completes. Caller cancellation leaves that operation running to its durable outcome.
+ /// The grain owner keeps delivery quiescent during journal deletion and resumes delivery
+ /// after the deletion task completes successfully.
+ ///
+ /// Result indicating delivery/processing status.
+ ///
+ /// has an unset message ID, exceeds identity admission limits, or has invalid header framing.
+ /// Optional standard metadata is validated where its contract is consumed.
+ ///
+ [Alias("DeliverAsync")]
+ ValueTask DeliverAsync(InboxMessage message, CancellationToken cancellationToken = default);
+}
diff --git a/src/Orleans.Messaging/IInboxHandler.cs b/src/Orleans.Messaging/IInboxHandler.cs
new file mode 100644
index 00000000000..f06bf473ccb
--- /dev/null
+++ b/src/Orleans.Messaging/IInboxHandler.cs
@@ -0,0 +1,41 @@
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Orleans.Messaging;
+
+///
+/// Processes opaque messages accepted by a grain's inbox.
+///
+///
+/// Register one handler per inbox. Applications perform payload decoding and dispatch in their handler
+/// and stage outbound messages through an injected .
+///
+public interface IInboxHandler
+{
+ ///
+ /// Handles a message and stages its logical completion.
+ ///
+ /// The received envelope and attempt-scoped completion operation.
+ /// The token to check during preparation and before shared mutation.
+ /// The handler's method outcome, awaited by the inbox runtime.
+ ///
+ ///
+ /// Decode the payload, perform asynchronous I/O, validate local results, build outgoing envelopes,
+ /// and check cancellation before the first shared business or journaled mutation.
+ ///
+ ///
+ /// From the first shared mutation through completion of this method, execute synchronously.
+ /// Apply complete, safe-to-commit business changes, stage outgoing messages through
+ /// , call , and return.
+ /// This final block includes the method's return after Complete and relies on the trusted
+ /// handler contract. Every successful outcome calls Complete, including outcomes with no business effects.
+ ///
+ ///
+ /// Complete stages inbox completion and transport deduplication alongside the business changes
+ /// and outgoing intents. The runtime owns the subsequent journal write, persistence acknowledgement,
+ /// and retirement. Failures during local preparation follow the inbox retry and dead-letter policy.
+ /// An error after Complete preserves the completed logical outcome and is reported by the runtime.
+ ///
+ ///
+ 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..752047b06e5
--- /dev/null
+++ b/src/Orleans.Messaging/IInboxHandlerContext.cs
@@ -0,0 +1,31 @@
+namespace Orleans.Messaging;
+
+///
+/// Exposes the received message and its attempt-scoped logical completion.
+///
+public interface IInboxHandlerContext
+{
+ ///
+ /// Gets the received message and its finalized application bytes.
+ ///
+ ///
+ /// The context keeps the message available through actual handler completion, including
+ /// after Complete removes the pending message. Keep its envelope buffer unchanged while handling the command.
+ ///
+ InboxMessage Message { get; }
+
+ ///
+ /// Synchronously stages inbox completion and transport deduplication for the active attempt.
+ ///
+ ///
+ /// Apply safe-to-commit business mutations and stage outgoing messages before calling Complete
+ /// in the same synchronous final block. Return from the handler without further awaits.
+ /// 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();
+}
diff --git a/src/Orleans.Messaging/IMessagingDiagnostics.cs b/src/Orleans.Messaging/IMessagingDiagnostics.cs
new file mode 100644
index 00000000000..fb73bd880ea
--- /dev/null
+++ b/src/Orleans.Messaging/IMessagingDiagnostics.cs
@@ -0,0 +1,104 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Orleans.Journaling;
+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.
+ ///
+ 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(HierarchicalKey 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(HierarchicalKey messageId);
+}
+
+///
+/// Describes a dead-lettered durable message.
+///
+/// The incoming command or outgoing routed intent type.
+public sealed class DeadLetter
+{
+ ///
+ /// Gets the message.
+ ///
+ ///
+ /// Treat the message payload as immutable. Ordinary RPC serialization copies the payload
+ /// when returning diagnostic results.
+ ///
+ public required TMessage Message { get; init; }
+
+ ///
+ /// Gets when the message was dead-lettered.
+ ///
+ public DateTimeOffset DeadLetteredAt { get; init; }
+
+ ///
+ /// Gets the terminal failure reason.
+ ///
+ public required string Reason { get; init; }
+
+ ///
+ /// Gets the number of attempts made.
+ ///
+ public int AttemptCount { get; init; }
+}
+
+internal sealed class MessagingDiagnostics(
+ [Microsoft.Extensions.DependencyInjection.FromKeyedServices(MessagingStateNames.InboxDeadLetters)]
+ IDurableDictionary inbox,
+ [Microsoft.Extensions.DependencyInjection.FromKeyedServices(MessagingStateNames.OutboxDeadLetters)]
+ IDurableDictionary outbox) : IMessagingDiagnostics
+{
+ public IReadOnlyList> InboxDeadLetters =>
+ inbox.Values.Select(static entry => new DeadLetter
+ {
+ Message = entry.Message,
+ DeadLetteredAt = entry.DeadLetteredAt,
+ Reason = entry.Reason,
+ AttemptCount = entry.AttemptCount
+ }).ToList();
+
+ public IReadOnlyList> OutboxDeadLetters =>
+ outbox.Values.Select(static entry => new DeadLetter
+ {
+ Message = entry.Message,
+ DeadLetteredAt = entry.DeadLetteredAt,
+ Reason = entry.Reason,
+ AttemptCount = entry.AttemptCount
+ }).ToList();
+
+ public bool RemoveInboxDeadLetter(HierarchicalKey messageId) =>
+ inbox.Remove(messageId);
+
+ public bool RemoveOutboxDeadLetter(HierarchicalKey messageId) => outbox.Remove(messageId);
+}
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..69e135efc09
--- /dev/null
+++ b/src/Orleans.Messaging/IOutbox.cs
@@ -0,0 +1,62 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using Orleans.Runtime;
+
+namespace Orleans.Messaging;
+
+///
+/// Stages opaque durable messages alongside a grain's journaled business state.
+///
+///
+/// The journal capture hook establishes a durable self-wakeup before capturing pending state.
+/// Dispatch begins after persistence acknowledgement. Messages remain pending until the destination
+/// acknowledges durable acceptance, recognizes a duplicate, or reports a terminal delivery outcome.
+/// Applications namespace command identities and define ordering in their protocols.
+///
+public interface IOutbox
+{
+ ///
+ /// Gets the grain identity which owns this outbox and sends its messages.
+ ///
+ GrainId SenderId { get; }
+
+ ///
+ /// Gets the number of pending outbound messages.
+ ///
+ int Count { get; }
+
+ ///
+ /// Gets pending messages in unspecified order.
+ ///
+ /// Messages share their finalized envelope buffers, which remain unchanged after publication.
+ IEnumerable Messages { get; }
+
+ ///
+ /// Synchronously stages an outgoing command for the grain's next journal write.
+ ///
+ /// The fully built outgoing message and required receiver.
+ ///
+ /// Direct sends share the finalized envelope buffer. Keep its bytes unchanged after publication.
+ /// A supplied sender must identify this outbox's grain. Equivalent repeated identities retain
+ /// the original message and headers; payload, optional subject, and destination must match.
+ /// Custom headers and sender provenance do not change pending intent equality. Conflicts fail explicitly.
+ /// Inbox handlers stage outgoing
+ /// messages in their synchronous final block before calling .
+ /// Ordinary callers persist staged messages using their journaled state manager.
+ /// An explicit write retry retains pending business changes and messages after a scheduling failure.
+ ///
+ /// The envelope has an unset identity, invalid subject or destination, or exceeds identity/subject limits.
+ ///
+ /// The sender differs from the owning grain, the identity conflicts, or the outbox is unavailable.
+ ///
+ void Send(OutboxMessage message);
+
+ ///
+ /// Looks up a pending outbound message.
+ ///
+ /// The message identifier.
+ /// The matching stored message when found.
+ /// Whether the message is pending.
+ bool TryGetMessage(HierarchicalKey messageId, [MaybeNullWhen(false)] out OutboxMessage message);
+}
diff --git a/src/Orleans.Messaging/Inbox.cs b/src/Orleans.Messaging/Inbox.cs
new file mode 100644
index 00000000000..edbfa198acd
--- /dev/null
+++ b/src/Orleans.Messaging/Inbox.cs
@@ -0,0 +1,90 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using Orleans.Journaling;
+using Orleans.Runtime;
+
+namespace Orleans.Messaging;
+
+///
+/// Provides access to journaled pending messages and their capacity limit.
+/// Registers the single handler for this inbox.
+///
+internal sealed class Inbox : IInbox
+{
+ private readonly IDurableDictionary _inbox;
+ private IInboxHandler? _handler;
+ private readonly int _capacity;
+
+ ///
+ /// Creates an inbox over journaled message storage.
+ ///
+ /// Durable dictionary for storing unprocessed messages.
+ /// Maximum inbox capacity (default: 1000).
+ public Inbox(
+ IDurableDictionary inbox,
+ int capacity = 1000)
+ {
+ ArgumentNullException.ThrowIfNull(inbox);
+ ArgumentOutOfRangeException.ThrowIfNegativeOrZero(capacity);
+
+ _inbox = inbox;
+ _capacity = capacity;
+ }
+
+ internal Inbox(
+ IDurableDictionary inbox,
+ IEnumerable handlers,
+ int capacity)
+ : this(inbox, capacity)
+ {
+ foreach (var handler in handlers)
+ {
+ RegisterHandler(handler);
+ }
+ }
+
+ ///
+ /// Number of unprocessed messages.
+ ///
+ public int Count => _inbox.Count;
+
+ ///
+ /// Gets the maximum inbox capacity.
+ ///
+ public int Capacity => _capacity;
+
+ ///
+ /// Gets all pending messages (no ordering guarantee).
+ ///
+ public IEnumerable Messages => _inbox.Values;
+
+ ///
+ /// Tries to get a specific message by its key.
+ ///
+ /// The receiver-local command identity.
+ /// The envelope if found.
+ /// True if the message exists in the inbox; otherwise, false.
+ public bool TryGetMessage(HierarchicalKey messageId, [MaybeNullWhen(false)] out InboxMessage envelope) =>
+ _inbox.TryGetValue(messageId, out envelope);
+
+ ///
+ /// Registers the single handler for this inbox.
+ ///
+ public void RegisterHandler(IInboxHandler handler)
+ {
+ ArgumentNullException.ThrowIfNull(handler);
+ if (_handler is not null)
+ {
+ throw new InvalidOperationException("A handler is already registered for this inbox.");
+ }
+
+ _handler = handler;
+ }
+
+ internal bool TryGetHandler([MaybeNullWhen(false)] out IInboxHandler handler)
+ {
+ handler = _handler;
+ return handler is not null;
+ }
+}
diff --git a/src/Orleans.Messaging/InboxExtension.cs b/src/Orleans.Messaging/InboxExtension.cs
new file mode 100644
index 00000000000..1a04ddfc353
--- /dev/null
+++ b/src/Orleans.Messaging/InboxExtension.cs
@@ -0,0 +1,1409 @@
+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.DurableJobs;
+using Orleans.Messaging.Configuration;
+using Orleans.Journaling;
+using Orleans.Runtime;
+using Orleans.Serialization.TypeSystem;
+using Orleans.Timers;
+
+namespace Orleans.Messaging;
+
+///
+/// Implementation of inbox extension for grain message delivery.
+/// Handles message persistence, deduplication, and processing.
+///
+internal sealed partial class InboxExtension :
+ IInboxExtension,
+ IDurableJobFeatureHandler,
+ ILifecycleObserver,
+ IDisposable
+{
+ internal const string JobName = "orleans.messaging.inbox-drain";
+
+ // Nested handler requests and independent interleaved calls have different logical execution contexts.
+ private static readonly AsyncLocal _handlerExecution = new();
+
+ public bool CanHandle(string jobName) => string.Equals(jobName, JobName, StringComparison.Ordinal);
+
+ private readonly IGrainContext _grainContext;
+ private readonly string _grainType;
+ private readonly ITimerRegistry _timerRegistry;
+ private readonly IJournaledStateManager _stateManager;
+ private readonly ILogger _logger;
+ private readonly MessagingInstruments _instruments;
+ private readonly Inbox _inbox;
+ private readonly IDictionary _inboxDict;
+ private readonly IDictionary _processed;
+ private readonly IDictionary _messageStates;
+ private readonly IDictionary _deadLetters;
+ private readonly IDurableValue _jobId;
+ private readonly IDurableValue _job;
+ private readonly IDurableValue _completedJobId;
+ private readonly IDurableValue _jobSequence;
+ private readonly ILocalDurableJobManager _jobManager;
+ private readonly TimeProvider _timeProvider;
+ private readonly TimeProvider _jobTimeProvider;
+ private readonly HashSet _provisionalAcceptances = [];
+ private readonly MessagingPumpResults _pumpResults;
+ private readonly MessagingPumpCoordinator _pumpCoordinator = new();
+ private readonly int _maxCapacity;
+ private readonly TimeSpan _deduplicationWindow;
+ private readonly TimeSpan _processedCompactionInterval;
+ private readonly int _maxProcessingAttempts;
+ private readonly int _batchSize;
+ private readonly TimeSpan _retryDelay;
+ private readonly TimeSpan _deadLetterRetentionPeriod;
+ private readonly int _maxRetainedDeadLetters;
+ private readonly SemaphoreSlim _gate = new(1, 1);
+ private readonly CancellationTokenSource _shutdownCts = new();
+ private readonly CancellationToken _shutdownToken;
+ private Task _activeDelivery = Task.CompletedTask;
+ private PumpTimerState? _pumpTimer;
+ private LocalDrainTimerState? _localDrainTimer;
+ private bool _localDrainRequested;
+ private int _disposed;
+ private int _metricsActive;
+ private int _reportedDepth;
+ private string? _durableOwnershipId;
+ private DurableJob? _durableJob;
+ private string _ownershipEpoch = Guid.NewGuid().ToString("N");
+ private long _stateGeneration;
+ private bool _recoveryCompleted;
+ private string? _ownershipStateError;
+ private long _reservedSequence;
+ private ExceptionDispatchInfo? _failure;
+ private readonly HashSet _pendingOwnershipIds = new(StringComparer.Ordinal);
+ private readonly List _pendingWrites = [];
+ private string? _durableCompletedJobId;
+ private DateTimeOffset? _nextProcessedExpiry;
+ private DateTimeOffset? _lastProcessedCompaction;
+
+ ///
+ /// Creates a new inbox extension instance.
+ ///
+ /// The grain context for this extension.
+ /// State manager for atomic persistence.
+ /// Logger for diagnostics.
+ /// Journaling metrics.
+ /// The grain's inbox (shared with grain DI).
+ /// Durable dictionary for inbox messages.
+ /// Durable dictionary for processed message tracking.
+ /// Messaging options.
+ public InboxExtension(
+ IGrainContext grainContext,
+ ITimerRegistry timerRegistry,
+ IJournaledStateManager stateManager,
+ ILogger logger,
+ MessagingInstruments instruments,
+ Inbox inbox,
+ IDictionary inboxDict,
+ IDictionary processed,
+ IDictionary messageStates,
+ IDictionary deadLetters,
+ IDurableValue jobId,
+ IDurableValue job,
+ IDurableValue completedJobId,
+ IDurableValue jobSequence,
+ ILocalDurableJobManager jobManager,
+ IDurableJobHandlerRegistry jobHandlers,
+ MessagingPumpResults pumpResults,
+ TimeProvider timeProvider,
+ TimeProvider jobTimeProvider,
+ InboxOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(grainContext);
+ ArgumentNullException.ThrowIfNull(timerRegistry);
+ ArgumentNullException.ThrowIfNull(stateManager);
+ ArgumentNullException.ThrowIfNull(logger);
+ ArgumentNullException.ThrowIfNull(instruments);
+ ArgumentNullException.ThrowIfNull(inbox);
+ ArgumentNullException.ThrowIfNull(inboxDict);
+ ArgumentNullException.ThrowIfNull(processed);
+ ArgumentNullException.ThrowIfNull(messageStates);
+ ArgumentNullException.ThrowIfNull(deadLetters);
+ ArgumentNullException.ThrowIfNull(jobId);
+ ArgumentNullException.ThrowIfNull(job);
+ ArgumentNullException.ThrowIfNull(completedJobId);
+ ArgumentNullException.ThrowIfNull(jobSequence);
+ ArgumentNullException.ThrowIfNull(jobManager);
+ ArgumentNullException.ThrowIfNull(jobHandlers);
+ ArgumentNullException.ThrowIfNull(pumpResults);
+ ArgumentNullException.ThrowIfNull(timeProvider);
+ ArgumentNullException.ThrowIfNull(jobTimeProvider);
+ ArgumentNullException.ThrowIfNull(options);
+ _shutdownToken = _shutdownCts.Token;
+ _grainContext = grainContext;
+ _grainType = grainContext.GrainId.Type.ToString();
+ _timerRegistry = timerRegistry;
+ _stateManager = stateManager;
+ _logger = logger;
+ _instruments = instruments;
+ _inbox = inbox;
+ _inboxDict = inboxDict;
+ _processed = processed;
+ _messageStates = messageStates;
+ _deadLetters = deadLetters;
+ _jobId = jobId;
+ _job = job;
+ _completedJobId = completedJobId;
+ _jobSequence = jobSequence;
+ _jobManager = jobManager;
+ _pumpResults = pumpResults;
+ _timeProvider = timeProvider;
+ _jobTimeProvider = jobTimeProvider;
+ _maxCapacity = options.MaxCapacity;
+ _deduplicationWindow = options.DeduplicationWindow;
+ _processedCompactionInterval = TimeSpan.FromTicks(Math.Max(1, _deduplicationWindow.Ticks / 4));
+ _maxProcessingAttempts = options.MaxProcessingAttempts;
+ _batchSize = options.InboxBatchSize;
+ _retryDelay = options.BackpressureRetryDelay;
+ _deadLetterRetentionPeriod = options.DeadLetterRetentionPeriod;
+ _maxRetainedDeadLetters = options.MaxRetainedDeadLetters;
+ jobHandlers.Register(this);
+ grainContext.ObservableLifecycle.Subscribe(
+ RuntimeTypeNameFormatter.Format(GetType()),
+ GrainLifecycleStage.Activate,
+ this);
+ }
+
+ private static void ThrowIfHandlerOperationRejected(HandlerExecution execution) => execution.RejectionFailure?.Throw();
+
+ public int Count => _inboxDict.Count;
+ public int Capacity => _maxCapacity;
+
+ public async ValueTask DeliverAsync(InboxMessage envelope, CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ ValidateReady();
+ EnvelopeValidation.Validate(envelope);
+
+ EnsureMetricsActive();
+ // Direct publication shares immutable payload bytes; the admitted task keeps the
+ // envelope reachable independently of cancellation of its caller's wait.
+ await _gate.WaitAsync(cancellationToken).ConfigureAwait(true);
+ var delivery = DeliverUnderGateAsync(envelope);
+ _activeDelivery = delivery;
+ delivery.Ignore();
+ return await delivery.WaitAsync(cancellationToken).ConfigureAwait(true);
+ }
+
+ private async Task DeliverUnderGateAsync(InboxMessage envelope)
+ {
+ try
+ {
+ ValidateReady();
+ var key = envelope.Envelope.MessageId;
+ if (_processed.TryGetValue(key, out var processedAt)
+ && !MessagingTime.IsExpired(_timeProvider.GetUtcNow(), processedAt, _deduplicationWindow))
+ {
+ _instruments.OnInboxMessageReceived(_grainType, "duplicate");
+ return DeliveryResult.Duplicate();
+ }
+
+ if (_inboxDict.TryGetValue(key, out var pending))
+ {
+ if (!EnvelopeEquivalence.AreSameCommand(pending, envelope))
+ {
+ throw new InvalidOperationException(
+ $"The inbox already contains a different command with message ID '{key}'.");
+ }
+ await EnsureJobScheduledUnderGateAsync(CancellationToken.None).ConfigureAwait(true);
+ ScheduleLocalDrain();
+ _instruments.OnInboxMessageReceived(_grainType, "duplicate");
+ return DeliveryResult.Duplicate();
+ }
+
+ if (_inboxDict.Count >= _maxCapacity)
+ {
+ _instruments.OnInboxMessageReceived(_grainType, "backpressured");
+ return DeliveryResult.Backpressured();
+ }
+
+ if (!_inbox.TryGetHandler(out _))
+ {
+ _instruments.OnInboxMessageReceived(_grainType, "handler_not_found");
+ LogHandlerNotFound(_logger, envelope.Envelope.MessageId, _grainContext.GrainId);
+ return DeliveryResult.HandlerNotFound();
+ }
+
+ var generation = _stateGeneration;
+ OwnershipProposal? proposal = null;
+ try
+ {
+ if (GetCommittedInboxCount() == 0 || !HasCommittedOwnership())
+ {
+ proposal = await PrepareOwnershipAsync(CancellationToken.None).ConfigureAwait(true);
+ }
+
+ ValidateReady();
+ var operation = new AcceptanceWrite(generation, envelope, proposal);
+ await SubmitAsync(operation).ConfigureAwait(true);
+ ValidateReady();
+ ScheduleLocalDrain();
+ _instruments.OnInboxMessageReceived(_grainType, "accepted");
+ LogMessageAccepted(_logger, envelope.Envelope.MessageId, _grainContext.GrainId);
+ return DeliveryResult.Accepted();
+ }
+ finally
+ {
+ if (proposal is not null)
+ {
+ _pendingOwnershipIds.Remove(proposal.Id);
+ }
+ }
+ }
+ catch (Exception exception)
+ {
+ LogDeliveryOperationFailed(_logger, exception, envelope.Envelope.MessageId, _grainContext.GrainId);
+ throw;
+ }
+ finally
+ {
+ _gate.Release();
+ }
+ }
+
+ private async Task PrepareOwnershipAsync(CancellationToken cancellationToken)
+ {
+ ValidateReady();
+ var generation = _stateGeneration;
+ var previousId = _jobId.Value;
+ var previousJob = _job.Value;
+ var sequence = checked(++_reservedSequence);
+ var id = MessagingJobOwnership.CreateId(_ownershipEpoch, sequence);
+ _pendingOwnershipIds.Add(id);
+ try
+ {
+ using var cancellation = MessagingCancellation.Combine(cancellationToken, _shutdownCts.Token, out var combinedToken);
+ var job = await _jobManager.ScheduleJobAsync(new ScheduleJobRequest
+ {
+ Target = _grainContext.GrainId,
+ JobName = JobName,
+ DueTime = _jobTimeProvider.GetUtcNow(),
+ Metadata = MessagingJobOwnership.CreateMetadata(id)
+ }, combinedToken).ConfigureAwait(true);
+ ValidateReady();
+ return new(id, sequence, job, generation, previousId, previousJob);
+ }
+ catch
+ {
+ _pendingOwnershipIds.Remove(id);
+ _failure?.Throw();
+ throw;
+ }
+ }
+
+ private async ValueTask EnsureJobScheduledUnderGateAsync(CancellationToken cancellationToken)
+ {
+ ValidateReady();
+ if (GetCommittedInboxCount() == 0 || HasCommittedOwnership())
+ {
+ return;
+ }
+
+ var proposal = await PrepareOwnershipAsync(cancellationToken).ConfigureAwait(true);
+ try
+ {
+ await SubmitAsync(new OwnershipWrite(_stateGeneration, proposal)).ConfigureAwait(true);
+ }
+ finally
+ {
+ _pendingOwnershipIds.Remove(proposal.Id);
+ }
+ }
+
+ private async ValueTask SubmitAsync(InboxWrite operation)
+ {
+ ValidateReady();
+ _pendingWrites.Add(operation);
+ try
+ {
+ if (operation is HandlerWrite handler)
+ {
+ await InvokeHandlerAsync(handler).ConfigureAwait(true);
+ if (handler.Skipped)
+ {
+ return;
+ }
+ }
+
+ if (operation is not HandlerWrite { Completed: true } && !StageWrite(operation))
+ {
+ return;
+ }
+ try
+ {
+ await _stateManager.WriteStateAsync(CancellationToken.None).ConfigureAwait(true);
+ }
+ catch (JournaledStatePostCommitException)
+ {
+ AcknowledgeWrite(operation);
+ throw;
+ }
+ catch (Exception exception)
+ {
+ LatchFailure(exception);
+ throw;
+ }
+ AcknowledgeWrite(operation);
+ }
+ catch (OperationCanceledException) when (_failure is null
+ && operation is HandlerWrite { Completed: false } handler
+ && handler.Cancellation.IsCancellationRequested
+ && handler.Execution?.RejectionFailure is null)
+ {
+ throw;
+ }
+ catch (Exception exception) when (exception is not JournaledStatePostCommitException
+ && (exception is not OperationCanceledException || _failure is not null || !_shutdownToken.IsCancellationRequested
+ || operation is HandlerWrite { Completed: true }
+ || operation is HandlerWrite { Execution.RejectionFailure: not null }))
+ {
+ LatchFailure(exception);
+ try
+ {
+ _grainContext.Deactivate(new DeactivationReason(DeactivationReasonCode.ApplicationError, _failure!.SourceException, "Inbox operation failed."));
+ }
+ catch (Exception deactivationException)
+ {
+ LogDeactivationRequestFailure(_logger, deactivationException);
+ }
+ _failure!.Throw();
+ throw;
+ }
+ finally
+ {
+ _pendingWrites.Remove(operation);
+ operation.SignalFinished();
+ }
+
+ if (operation is HandlerWrite { PostCompletionFailure: { } failure })
+ {
+ failure.Throw();
+ }
+ }
+
+ private void ValidateReady()
+ {
+ _failure?.Throw();
+ ThrowIfOwnershipStateInvalid();
+ _shutdownToken.ThrowIfCancellationRequested();
+ if (!_recoveryCompleted)
+ {
+ throw new InvalidOperationException("Inbox initialization must complete before processing.");
+ }
+ }
+
+ private void ThrowIfOwnershipStateInvalid()
+ {
+ var error = _ownershipStateError ?? MessagingJobOwnership.GetPairError(_jobId.Value, _job.Value);
+ if (error is not null)
+ {
+ throw new InvalidOperationException(error);
+ }
+ }
+
+ private void ValidateGeneration(long generation)
+ {
+ ValidateReady();
+ if (generation != _stateGeneration)
+ {
+ throw new InvalidOperationException("The admitted inbox operation belongs to an obsolete activation or deletion generation.");
+ }
+ }
+
+ private bool HasCommittedOwnership() =>
+ MessagingJobOwnership.HasOwner(_jobId.Value, _job.Value)
+ && string.Equals(_durableOwnershipId, _jobId.Value, StringComparison.Ordinal)
+ && MessagingJobOwnership.IsSamePhysicalJob(_durableJob, _job.Value);
+
+ private bool IsCurrentOwner(PumpOwner owner) => owner.Generation == _stateGeneration
+ && string.Equals(owner.Id, _jobId.Value, StringComparison.Ordinal)
+ && MessagingJobOwnership.IsSamePhysicalJob(owner.Job, _job.Value)
+ && HasCommittedOwnership();
+
+ private void ValidateOwner(PumpOwner owner)
+ {
+ ValidateGeneration(owner.Generation);
+ if (!IsCurrentOwner(owner))
+ {
+ throw new InvalidOperationException("The admitted inbox operation no longer owns the acknowledged physical job.");
+ }
+ }
+
+ // Every provisional key is an inbox key; acknowledgement removes only its provisional marker.
+ private int GetCommittedInboxCount() => _inboxDict.Count - _provisionalAcceptances.Count;
+
+ private async ValueTask InvokeHandlerAsync(HandlerWrite operation)
+ {
+ ValidateOwner(operation.Owner);
+ if (!_inboxDict.ContainsKey(operation.Key))
+ {
+ throw new InvalidOperationException("The admitted inbox message is no longer pending.");
+ }
+
+ if (operation.Cancellation.IsCancellationRequested)
+ {
+ operation.Skipped = true;
+ return;
+ }
+
+ // Both pump entry points already combine activation shutdown with their attempt scope.
+ // Borrow that scope through handler completion and the owned journal write.
+ var combinedToken = operation.Cancellation;
+ var previous = _handlerExecution.Value;
+ var execution = operation.Execution = new HandlerExecution(this, operation);
+ _handlerExecution.Value = execution;
+ try
+ {
+ var found = _inbox.TryGetHandler(out var handler);
+ if (!found)
+ {
+ operation.Error = new InvalidOperationException("No inbox handler is registered.");
+ operation.DeadLetter = true;
+ return;
+ }
+
+ execution.Active = true;
+ await handler!.HandleAsync(new InboxHandlerContext(
+ operation.Envelope, execution.Complete),
+ combinedToken).ConfigureAwait(true);
+ ThrowIfHandlerOperationRejected(execution);
+ if (!operation.Completed)
+ {
+ execution.RejectOperation(new InvalidOperationException(
+ "Inbox handlers must call Complete before returning successfully."));
+ }
+ }
+ catch (Exception exception) when (operation.Completed)
+ {
+ operation.PostCompletionFailure = execution.RejectionFailure ?? ExceptionDispatchInfo.Capture(exception);
+ LogHandlerException(_logger, operation.PostCompletionFailure.SourceException,
+ operation.Envelope.Envelope.MessageId, _grainContext.GrainId);
+ }
+ catch (Exception) when (execution.RejectionFailure is not null)
+ {
+ execution.RejectionFailure.Throw();
+ throw;
+ }
+ catch (Exception exception) when (!combinedToken.IsCancellationRequested && _failure is null)
+ {
+ // Before Complete, handler failures retain the message for retry or dead-lettering.
+ operation.Error = exception;
+ LogHandlerException(_logger, exception, operation.Envelope.Envelope.MessageId, _grainContext.GrainId);
+ }
+ finally
+ {
+ execution.Active = false;
+ _handlerExecution.Value = previous;
+ }
+
+ if (operation.Completed)
+ {
+ return;
+ }
+ ValidateOwner(operation.Owner);
+
+ if (operation.Error is not null)
+ {
+ var attempts = _messageStates.TryGetValue(operation.Key, out var state) ? state.AttemptCount : 0;
+ var count = checked(attempts + 1);
+ operation.DeadLetter = count >= _maxProcessingAttempts;
+ operation.Retry = new InboxMessageState
+ {
+ AttemptCount = count,
+ LastError = operation.Error.ToString(),
+ NextAttemptAt = MessagingTime.AddClamped(_timeProvider.GetUtcNow(),
+ TimeSpan.FromTicks(_retryDelay.Ticks * (1L << Math.Min(count - 1, InboxOptions.MaximumBackoffExponent))))
+ };
+ }
+ }
+
+ private bool StageWrite(InboxWrite operation)
+ {
+ ValidateGeneration(operation.Generation);
+ var processedBefore = operation is CompactWrite ? _processed.Count : 0;
+ var deadLettersBefore = operation is CompactWrite ? _deadLetters.Count : 0;
+ switch (operation)
+ {
+ case AcceptanceWrite acceptance:
+ if (_inboxDict.ContainsKey(acceptance.Key) || _inboxDict.Count >= _maxCapacity)
+ {
+ throw new InvalidOperationException("The admitted inbox acceptance no longer matches available capacity or message identity.");
+ }
+
+ if (acceptance.Owner is { } acceptanceOwner)
+ {
+ ApplyOwnership(acceptanceOwner);
+ }
+ else if (!HasCommittedOwnership())
+ {
+ throw new InvalidOperationException("The admitted inbox acceptance requires acknowledged job ownership.");
+ }
+
+ _processed.Remove(acceptance.Key);
+ _inboxDict.Add(acceptance.Key, acceptance.Envelope);
+ _messageStates.Add(acceptance.Key, new InboxMessageState());
+ _provisionalAcceptances.Add(acceptance.Key);
+ UpdateInboxDepth(1);
+ break;
+ case OwnershipWrite ownership:
+ if (GetCommittedInboxCount() == 0)
+ {
+ throw new InvalidOperationException("The admitted ownership repair has no pending inbox work.");
+ }
+ ApplyOwnership(ownership.Owner);
+ break;
+ case HandlerWrite handler:
+ ValidateOwner(handler.Owner);
+ handler.Cancellation.ThrowIfCancellationRequested();
+ if (handler.Skipped)
+ {
+ break;
+ }
+
+ if (!_inboxDict.ContainsKey(handler.Key))
+ {
+ throw new InvalidOperationException("The invoked inbox handler lost its pending message before capture.");
+ }
+
+ if (handler.Error is not null && !handler.DeadLetter)
+ {
+ _messageStates[handler.Key] = handler.Retry!;
+ }
+ else
+ {
+ var now = _timeProvider.GetUtcNow();
+ if (handler.DeadLetter)
+ {
+ DeadLetterRetention.Compact(_deadLetters, now, _deadLetterRetentionPeriod,
+ _maxRetainedDeadLetters, static entry => entry.DeadLetteredAt,
+ reservedCapacity: _deadLetters.ContainsKey(handler.Key) ? 0 : 1);
+ _deadLetters[handler.Key] = new InboxDeadLetter
+ {
+ Message = handler.Envelope,
+ DeadLetteredAt = now,
+ Reason = handler.Error!.Message,
+ AttemptCount = handler.Retry?.AttemptCount ?? 0
+ };
+ }
+ StageHandlerCompletion(handler);
+ }
+ break;
+ case ClearOwnerWrite clear:
+ ValidateOwner(clear.Owner);
+ if (_inboxDict.Count != 0)
+ {
+ throw new InvalidOperationException("The admitted inbox owner cannot be cleared while work is pending.");
+ }
+ _completedJobId.Value = clear.Owner.Id;
+ _jobId.Value = null;
+ _job.Value = null;
+ break;
+ case CompactWrite:
+ DeadLetterRetention.Compact(_deadLetters, _timeProvider.GetUtcNow(), _deadLetterRetentionPeriod,
+ _maxRetainedDeadLetters, static entry => entry.DeadLetteredAt);
+ break;
+ }
+
+ var maintenanceTime = _timeProvider.GetUtcNow();
+ if (IsProcessedMaintenanceDue(maintenanceTime))
+ {
+ CompactProcessedMessages(maintenanceTime);
+ }
+
+ return operation is not CompactWrite || _processed.Count != processedBefore || _deadLetters.Count != deadLettersBefore;
+ }
+
+ private void StageHandlerCompletion(HandlerWrite operation)
+ {
+ var now = _timeProvider.GetUtcNow();
+ RemoveMessage(operation.Key);
+ _messageStates.Remove(operation.Key);
+ _processed[operation.Key] = now;
+ TrackProcessedExpiry(now);
+ operation.Completed = true;
+ }
+
+ private void ApplyOwnership(OwnershipProposal proposal)
+ {
+ ValidateGeneration(proposal.Generation);
+ if (!string.Equals(_jobId.Value, proposal.PreviousId, StringComparison.Ordinal)
+ || !(proposal.PreviousJob is null && _job.Value is null
+ || MessagingJobOwnership.IsSamePhysicalJob(proposal.PreviousJob, _job.Value)))
+ {
+ throw new InvalidOperationException("The admitted inbox ownership proposal no longer matches the preceding owner.");
+ }
+ _jobId.Value = proposal.Id;
+ _job.Value = proposal.Job;
+ _jobSequence.Value = proposal.Sequence;
+ }
+
+ private void AcknowledgeWrite(InboxWrite operation)
+ {
+ // Ownership-changing operations retain the inbox gate through their own write acknowledgement.
+ switch (operation)
+ {
+ case AcceptanceWrite acceptance:
+ if (acceptance.Owner is { } owner)
+ {
+ _durableOwnershipId = owner.Id;
+ _durableJob = owner.Job;
+ }
+ _provisionalAcceptances.Remove(acceptance.Key);
+ break;
+ case OwnershipWrite ownership:
+ _durableOwnershipId = ownership.Owner.Id;
+ _durableJob = ownership.Owner.Job;
+ break;
+ case ClearOwnerWrite clear:
+ _durableCompletedJobId = clear.Owner.Id;
+ _durableOwnershipId = null;
+ _durableJob = null;
+ break;
+ }
+ }
+
+ private void LatchFailure(Exception exception)
+ {
+ _failure ??= ExceptionDispatchInfo.Capture(exception);
+ _pumpCoordinator.Reset();
+ _pumpResults.Clear(JobName);
+ CancelProcessing();
+ }
+
+ private void CancelProcessing()
+ {
+ _localDrainRequested = false;
+ try
+ {
+ _shutdownCts.Cancel();
+ }
+ catch (AggregateException cancellationException)
+ {
+ LogCancellationCallbackFailure(_logger, cancellationException);
+ }
+ finally
+ {
+ // Activation cancellation owns callback failures; disposing timers first would
+ // transfer those errors to the runtime timer logger instead of preserving them here.
+ _pumpTimer?.Dispose();
+ _localDrainTimer?.Dispose();
+ }
+ }
+
+ [LoggerMessage(Level = LogLevel.Error, Message = "An inbox cancellation callback failed while stopping processing.")]
+ private static partial void LogCancellationCallbackFailure(ILogger logger, Exception exception);
+
+ [LoggerMessage(Level = LogLevel.Error, Message = "Requesting deactivation after an inbox persistence failure failed.")]
+ private static partial void LogDeactivationRequestFailure(ILogger logger, Exception exception);
+
+ private void InitializeRecoveredState()
+ {
+ _stateGeneration++;
+ _reservedSequence = _jobSequence.Value;
+ _durableOwnershipId = _jobId.Value;
+ _durableJob = _job.Value;
+ _durableCompletedJobId = _completedJobId.Value;
+ _ownershipStateError = MessagingJobOwnership.GetPairError(_jobId.Value, _job.Value);
+ _lastProcessedCompaction = null;
+ RebuildProcessedExpiry();
+ _recoveryCompleted = true;
+ ReconcileInboxDepth();
+ }
+
+ internal async Task ResumeProcessingAsync(CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ await _gate.WaitAsync(cancellationToken).ConfigureAwait(true);
+ try
+ {
+ ValidateReady();
+ EnsureMetricsActive();
+ await EnsureJobScheduledUnderGateAsync(cancellationToken).ConfigureAwait(true);
+ ScheduleLocalDrain();
+ }
+ finally
+ {
+ _gate.Release();
+ }
+ }
+
+ public async Task OnStart(CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ if (_grainContext.GrainInstance is not DurableGrain and not IMessagingGrain)
+ {
+ throw new InvalidOperationException("Inbox activation requires IMessagingGrain or DurableGrain.");
+ }
+ foreach (var (key, message) in _inboxDict)
+ {
+ EnvelopeValidation.Validate(message);
+ if (key != message.Envelope.MessageId)
+ {
+ throw new InvalidOperationException("The recovered inbox key does not match its command identity.");
+ }
+ }
+ foreach (var entry in _deadLetters.Values)
+ {
+ EnvelopeValidation.Validate(entry.Message);
+ }
+ InitializeRecoveredState();
+ await ResumeProcessingAsync(cancellationToken).ConfigureAwait(true);
+ if (_deadLetters.Values.Any(entry => MessagingTime.IsExpired(_timeProvider.GetUtcNow(), entry.DeadLetteredAt, _deadLetterRetentionPeriod))
+ || _deadLetters.Count > _maxRetainedDeadLetters
+ || HasExpiredProcessedMessages(_timeProvider.GetUtcNow()))
+ {
+ await SubmitAsync(new CompactWrite(_stateGeneration)).ConfigureAwait(true);
+ }
+ }
+
+ public async Task OnStop(CancellationToken cancellationToken)
+ {
+ var operations = _pendingWrites.Select(static operation => operation.WaitForFinished()).Append(_activeDelivery).ToArray();
+ StopProcessing();
+ // Owned work outlives caller cancellation and drains through handler completion and actual persistence.
+ await Task.WhenAll(operations).ConfigureAwait(
+ ConfigureAwaitOptions.ContinueOnCapturedContext | ConfigureAwaitOptions.SuppressThrowing);
+ }
+
+ internal void StopProcessing()
+ {
+ try
+ {
+ CancelProcessing();
+ }
+ finally
+ {
+ _pumpCoordinator.Reset();
+ _pumpResults.Clear(JobName);
+ if (Interlocked.Exchange(ref _metricsActive, 0) != 0)
+ {
+ _instruments.OnInboxDepthChanged(-Interlocked.Exchange(ref _reportedDepth, 0));
+ }
+ }
+ }
+
+ public void Dispose()
+ {
+ if (Interlocked.Exchange(ref _disposed, 1) == 0)
+ {
+ try
+ {
+ StopProcessing();
+ }
+ finally
+ {
+ // Scope teardown has drained actual operations and releases durable values.
+ // Retired admission metadata must not continue reporting provisional work.
+ _provisionalAcceptances.Clear();
+ _shutdownCts.Dispose();
+ }
+ }
+ }
+
+ private sealed class HandlerExecution(InboxExtension owner, HandlerWrite operation)
+ {
+ public InboxExtension Owner { get; } = owner;
+ public bool Active { get; set; }
+ public ExceptionDispatchInfo? RejectionFailure { get; private set; }
+
+ public void Complete()
+ {
+ ValidateAttempt();
+ if (operation.Completed)
+ {
+ return;
+ }
+ try
+ {
+ // Business state and directly injected outbox sends have already been staged.
+ // Stage inbox removal and dedupe synchronously, before the owned write can await.
+ Owner.StageHandlerCompletion(operation);
+ }
+ catch (Exception exception)
+ {
+ RejectOperation(exception);
+ throw;
+ }
+ }
+
+ private void ValidateAttempt()
+ {
+ if (!Active || !ReferenceEquals(_handlerExecution.Value, this))
+ {
+ RejectOperation(new InvalidOperationException(
+ "The inbox handler context belongs to an inactive or different attempt."));
+ }
+ ThrowIfHandlerOperationRejected(this);
+ }
+
+ [DoesNotReturn]
+ public void RejectOperation(Exception exception)
+ {
+ RejectionFailure ??= ExceptionDispatchInfo.Capture(exception);
+ if (_handlerExecution.Value is { } current && ReferenceEquals(current.Owner, Owner))
+ {
+ current.RejectionFailure ??= RejectionFailure;
+ }
+ RejectionFailure.Throw();
+ }
+ }
+
+ private readonly record struct PumpOwner(string Id, DurableJob Job, long Generation);
+ private sealed record OwnershipProposal(string Id, long Sequence, DurableJob Job, long Generation, string? PreviousId, DurableJob? PreviousJob);
+
+ private abstract class InboxWrite(long generation)
+ {
+ public long Generation { get; } = generation;
+ private TaskCompletionSource? _finished;
+ private bool _retired;
+
+ public TaskCompletionSource Finished
+ {
+ get
+ {
+ var finished = _finished ??= new(TaskCreationOptions.RunContinuationsAsynchronously);
+ if (_retired) finished.TrySetResult();
+ return finished;
+ }
+ }
+ public Task WaitForFinished() => _retired ? Task.CompletedTask : Finished.Task;
+
+ public void SignalFinished()
+ {
+ _retired = true;
+ _finished?.TrySetResult();
+ }
+ }
+
+ private sealed class AcceptanceWrite(long generation, InboxMessage envelope, OwnershipProposal? owner) : InboxWrite(generation)
+ {
+ public InboxMessage Envelope { get; } = envelope;
+ public HierarchicalKey Key => Envelope.Envelope.MessageId;
+ public OwnershipProposal? Owner { get; } = owner;
+ }
+
+ private sealed class OwnershipWrite(long generation, OwnershipProposal owner) : InboxWrite(generation)
+ {
+ public OwnershipProposal Owner { get; } = owner;
+ }
+
+ private sealed class HandlerWrite(PumpOwner owner, InboxMessage envelope, CancellationToken cancellation) : InboxWrite(owner.Generation)
+ {
+ public PumpOwner Owner { get; } = owner;
+ public InboxMessage Envelope { get; } = envelope;
+ public HierarchicalKey Key => Envelope.Envelope.MessageId;
+ public CancellationToken Cancellation { get; } = cancellation;
+ public HandlerExecution? Execution { get; set; }
+ public bool Completed { get; set; }
+ public ExceptionDispatchInfo? PostCompletionFailure { get; set; }
+ public bool Skipped { get; set; }
+ public bool DeadLetter { get; set; }
+ public Exception? Error { get; set; }
+ public InboxMessageState? Retry { get; set; }
+ }
+
+ private sealed class ClearOwnerWrite(PumpOwner owner) : InboxWrite(owner.Generation)
+ {
+ public PumpOwner Owner { get; } = owner;
+ }
+
+ private sealed class CompactWrite(long generation) : InboxWrite(generation);
+
+ public async ValueTask ExecuteJobAsync(IJobRunContext context, CancellationToken cancellationToken)
+ {
+ _failure?.Throw();
+ _shutdownToken.ThrowIfCancellationRequested();
+ cancellationToken.ThrowIfCancellationRequested();
+ if (!_recoveryCompleted)
+ {
+ return DurableJobRunResult.InProgress(TimeSpan.FromMilliseconds(10));
+ }
+ ThrowIfOwnershipStateInvalid();
+ if (!MessagingJobOwnership.TryGetOwnershipId(context.Job, out var ownershipId))
+ {
+ return DurableJobRunResult.Completed;
+ }
+
+ if (_pendingOwnershipIds.Count != 0 || _pendingWrites.Any(static operation => operation is ClearOwnerWrite))
+ {
+ return DurableJobRunResult.InProgress(TimeSpan.FromMilliseconds(10));
+ }
+
+ var key = new MessagingPumpExecutionKey(
+ JobName,
+ context.Job.Id,
+ context.RunId,
+ Volatile.Read(ref _stateGeneration));
+ if (!string.Equals(_jobId.Value, ownershipId, StringComparison.Ordinal))
+ {
+ var disposition = MessagingJobOwnership.ResolveMismatch(
+ _recoveryCompleted,
+ HasCommittedOwnership(),
+ MessagingJobOwnership.IsCompleted(_durableCompletedJobId, ownershipId),
+ _inboxDict.Count > 0);
+ if (disposition == OwnershipMismatchDisposition.ReclaimOrphan)
+ {
+ LogOrphanedJobReclaimed(_logger, ownershipId, _grainContext.GrainId);
+ _instruments.OnOrphanedJobReclaimed(_grainContext.GrainId.Type.ToString(), JobName);
+ return CompleteObsoleteExecution(key);
+ }
+
+ if (disposition == OwnershipMismatchDisposition.CompleteStale)
+ {
+ return CompleteObsoleteExecution(key);
+ }
+
+ return DurableJobRunResult.InProgress(TimeSpan.FromMilliseconds(10));
+ }
+
+ if (!HasCommittedOwnership())
+ {
+ return DurableJobRunResult.InProgress(TimeSpan.FromMilliseconds(10));
+ }
+
+ if (!MessagingJobOwnership.IsSamePhysicalJob(_job.Value, context.Job))
+ {
+ return DurableJobRunResult.Completed;
+ }
+
+ if (_pumpResults.TryTake(key, out var result, out var exception))
+ {
+ if (exception is not null)
+ {
+ throw exception;
+ }
+
+ return result!;
+ }
+
+ if (!_pumpCoordinator.TryAcquire(ownershipId, cancellationToken, out var lease))
+ {
+ return DurableJobRunResult.InProgress(TimeSpan.FromMilliseconds(10));
+ }
+
+ if (!_pumpResults.TryStart(key, cancellationToken, out var execution))
+ {
+ _pumpCoordinator.Release(lease);
+ return DurableJobRunResult.InProgress(TimeSpan.FromMilliseconds(10));
+ }
+
+ try
+ {
+ (_pumpTimer ??= new(this)).Queue(new(execution, lease, new(ownershipId, context.Job, key.StateGeneration), cancellationToken));
+ }
+ catch (Exception registrationException)
+ {
+ _pumpCoordinator.Release(lease);
+ _pumpResults.Fail(execution, registrationException);
+ throw;
+ }
+
+ return DurableJobRunResult.InProgress(TimeSpan.FromMilliseconds(10));
+ }
+
+ private DurableJobRunResult CompleteObsoleteExecution(MessagingPumpExecutionKey key)
+ {
+ // Committed ownership establishes retirement; this run's retained result will no longer be polled.
+ _pumpResults.TryTake(key, out _, out _);
+ return DurableJobRunResult.Completed;
+ }
+
+ private async Task RunPumpTimerAsync(
+ MessagingPumpExecution execution,
+ MessagingPumpLease lease,
+ PumpOwner pumpOwner,
+ CancellationToken jobCancellation,
+ CancellationToken timerCancellation)
+ {
+ if (!_pumpCoordinator.IsCurrent(lease) || !IsCurrentOwner(pumpOwner))
+ {
+ _pumpResults.Discard(execution);
+ return;
+ }
+
+ if (!_pumpResults.TryBegin(execution))
+ {
+ return;
+ }
+
+ DurableJobRunResult? result = null;
+ Exception? failure = null;
+ try
+ {
+ using var linkedCancellation = MessagingCancellation.Combine(
+ jobCancellation, timerCancellation, _shutdownCts.Token, out var combinedToken);
+ result = await ExecuteJobCoreAsync(
+ pumpOwner,
+ clearOwnershipWhenEmpty: true,
+ combinedToken);
+ }
+ catch (Exception exception)
+ {
+ failure = exception;
+ }
+ finally
+ {
+ if (failure is null)
+ {
+ _pumpResults.Complete(execution, result!);
+ }
+ else
+ {
+ _pumpResults.Fail(execution, failure);
+ }
+ }
+ }
+
+ private async ValueTask ExecuteJobCoreAsync(PumpOwner owner, bool clearOwnershipWhenEmpty, CancellationToken cancellationToken)
+ {
+ ValidateReady();
+ if (!IsCurrentOwner(owner))
+ {
+ return DurableJobRunResult.Completed;
+ }
+
+ var now = _timeProvider.GetUtcNow();
+ var pending = new List(Math.Min(_inboxDict.Count, _batchSize));
+ foreach (var pair in _inboxDict)
+ {
+ if (!_provisionalAcceptances.Contains(pair.Key)
+ && (!_messageStates.TryGetValue(pair.Key, out var state) || state.NextAttemptAt is null || state.NextAttemptAt <= now))
+ {
+ pending.Add(pair.Value);
+ if (pending.Count == _batchSize)
+ {
+ break;
+ }
+ }
+ }
+ foreach (var envelope in pending)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ ValidateOwner(owner);
+ var start = Stopwatch.GetTimestamp();
+ var operation = new HandlerWrite(owner, envelope, cancellationToken);
+ await SubmitAsync(operation).ConfigureAwait(true);
+ if (operation.Skipped)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ }
+ var status = operation.Error is null ? "success" : operation.DeadLetter ? "dead_lettered" : "retry";
+ _instruments.OnInboxMessageProcessed(_grainType, status);
+ _instruments.OnInboxProcessingDuration(Stopwatch.GetElapsedTime(start), _grainType);
+ }
+
+ await _gate.WaitAsync(cancellationToken).ConfigureAwait(true);
+ try
+ {
+ ValidateReady();
+ if (!IsCurrentOwner(owner))
+ {
+ return DurableJobRunResult.Completed;
+ }
+ if (_inboxDict.Count == 0)
+ {
+ if (clearOwnershipWhenEmpty)
+ {
+ await SubmitAsync(new ClearOwnerWrite(owner)).ConfigureAwait(true);
+ }
+ return DurableJobRunResult.Completed;
+ }
+ }
+ finally
+ {
+ _gate.Release();
+ }
+
+ if (HasExpiredProcessedMessages(_timeProvider.GetUtcNow()))
+ {
+ await SubmitAsync(new CompactWrite(_stateGeneration)).ConfigureAwait(true);
+ }
+
+ var nextAttempt = GetNextAttemptAt();
+ if (GetNextProcessedMaintenance() is { } maintenance && maintenance < nextAttempt)
+ {
+ nextAttempt = maintenance;
+ }
+ var delay = nextAttempt - _timeProvider.GetUtcNow();
+ return DurableJobRunResult.RescheduleAt(MessagingTime.AddClamped(
+ _jobTimeProvider.GetUtcNow(), delay > TimeSpan.Zero ? delay : TimeSpan.Zero));
+ }
+
+ private DateTimeOffset GetNextAttemptAt()
+ {
+ var now = _timeProvider.GetUtcNow();
+ var next = DateTimeOffset.MaxValue;
+ foreach (var key in _inboxDict.Keys)
+ {
+ if (!_messageStates.TryGetValue(key, out var state) || state.NextAttemptAt is not { } at || at <= now)
+ {
+ return now;
+ }
+ if (at < next)
+ {
+ next = at;
+ }
+ }
+ return next;
+ }
+
+ private DateTimeOffset? GetNextProcessedMaintenance()
+ {
+ if (_nextProcessedExpiry is not { } next)
+ {
+ return null;
+ }
+ if (_lastProcessedCompaction is { } last)
+ {
+ if (_processedCompactionInterval.Ticks > DateTimeOffset.MaxValue.UtcTicks - last.UtcTicks)
+ {
+ return null;
+ }
+ var earliestScan = MessagingTime.AddClamped(last, _processedCompactionInterval);
+ if (earliestScan > next)
+ {
+ next = earliestScan;
+ }
+ }
+ return next;
+ }
+
+ private bool IsProcessedMaintenanceDue(DateTimeOffset now) =>
+ GetNextProcessedMaintenance() is { } next && now >= next;
+
+ private bool HasExpiredProcessedMessages(DateTimeOffset now)
+ {
+ if (!IsProcessedMaintenanceDue(now))
+ {
+ return false;
+ }
+ if (_processed.Any(pair => MessagingTime.IsExpired(now, pair.Value, _deduplicationWindow)))
+ {
+ return true;
+ }
+
+ _lastProcessedCompaction = now;
+ RebuildProcessedExpiry();
+ return false;
+ }
+
+ private void TrackProcessedExpiry(DateTimeOffset processedAt)
+ {
+ if (_deduplicationWindow.Ticks > DateTimeOffset.MaxValue.UtcTicks - processedAt.UtcTicks)
+ {
+ return;
+ }
+ var expiry = MessagingTime.AddClamped(processedAt, _deduplicationWindow);
+ if (_nextProcessedExpiry is null || expiry < _nextProcessedExpiry)
+ {
+ _nextProcessedExpiry = expiry;
+ }
+ }
+
+ private void RebuildProcessedExpiry()
+ {
+ _nextProcessedExpiry = null;
+ foreach (var entry in _processed)
+ {
+ TrackProcessedExpiry(entry.Value);
+ }
+ }
+
+ private void CompactProcessedMessages(DateTimeOffset now)
+ {
+ // Amortize dictionary scans over retention time; ordinary completion and owner-clear writes only check the deadline.
+ List? expired = null;
+ _nextProcessedExpiry = null;
+ foreach (var entry in _processed)
+ {
+ if (MessagingTime.IsExpired(now, entry.Value, _deduplicationWindow))
+ {
+ (expired ??= []).Add(entry.Key);
+ }
+ else
+ {
+ TrackProcessedExpiry(entry.Value);
+ }
+ }
+ if (expired is not null)
+ {
+ foreach (var key in expired)
+ {
+ _processed.Remove(key);
+ }
+ }
+ _lastProcessedCompaction = now;
+ }
+
+ private void EnsureMetricsActive()
+ {
+ if (Interlocked.Exchange(ref _metricsActive, 1) == 0)
+ {
+ Volatile.Write(ref _reportedDepth, _inboxDict.Count);
+ _instruments.OnInboxDepthChanged(_inboxDict.Count);
+ }
+ }
+
+ private void UpdateInboxDepth(int delta)
+ {
+ if (Volatile.Read(ref _metricsActive) != 0)
+ {
+ Interlocked.Add(ref _reportedDepth, delta);
+ _instruments.OnInboxDepthChanged(delta);
+ }
+ }
+
+ private void ReconcileInboxDepth()
+ {
+ if (Volatile.Read(ref _metricsActive) == 0)
+ {
+ return;
+ }
+
+ var count = _inboxDict.Count;
+ var delta = count - Interlocked.Exchange(ref _reportedDepth, count);
+ if (delta != 0)
+ {
+ _instruments.OnInboxDepthChanged(delta);
+ }
+ }
+
+ private bool RemoveMessage(HierarchicalKey key)
+ {
+ if (!_inboxDict.Remove(key))
+ {
+ return false;
+ }
+
+ UpdateInboxDepth(-1);
+
+ return true;
+ }
+
+ // Structured logging using LoggerMessage source generator
+
+ [LoggerMessage(Level = LogLevel.Error, EventName = "DeliveryOperationFailed",
+ Message = "Inbox delivery of message {MessageId} to {GrainId} failed")]
+ private static partial void LogDeliveryOperationFailed(ILogger logger, Exception exception, HierarchicalKey messageId, GrainId grainId);
+
+ [LoggerMessage(
+ Level = LogLevel.Warning,
+ Message = "No inbox handler registered for message {MessageId} to {ReceiverId}")]
+ private static partial void LogHandlerNotFound(ILogger logger, HierarchicalKey messageId, GrainId receiverId);
+
+ [LoggerMessage(
+ Level = LogLevel.Information,
+ Message = "Accepted message {MessageId} to {ReceiverId}")]
+ private static partial void LogMessageAccepted(ILogger logger, HierarchicalKey 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, HierarchicalKey messageId, GrainId receiverId);
+
+ [LoggerMessage(
+ Level = LogLevel.Information,
+ Message = "Reclaimed orphaned inbox job ownership {OwnershipId} for grain {GrainId}")]
+ private static partial void LogOrphanedJobReclaimed(ILogger logger, string ownershipId, GrainId grainId);
+
+ [LoggerMessage(
+ Level = LogLevel.Error,
+ Message = "Error scheduling inbox recovery for grain {GrainId}")]
+ private static partial void LogRecoverySchedulingError(ILogger logger, Exception exception, GrainId grainId);
+
+ private readonly record struct PumpTurn(MessagingPumpExecution Execution,
+ MessagingPumpLease Lease, PumpOwner Owner, CancellationToken JobCancellation);
+ private readonly record struct LocalDrainTurn(MessagingPumpLease Lease, PumpOwner Owner);
+
+ private sealed class PumpTimerState(InboxExtension owner) : MessagingTurn
+ {
+ protected override IGrainTimer RegisterTimer(long registrationGeneration) => owner._timerRegistry.RegisterGrainTimer(
+ owner._grainContext, (state, token) => state.RunAsync(registrationGeneration, token), this,
+ new GrainTimerCreationOptions(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan) { Interleave = false, KeepAlive = true });
+
+ protected override async Task ExecuteAsync(PumpTurn turn, CancellationToken timerCancellation)
+ {
+ try
+ {
+ await owner.RunPumpTimerAsync(turn.Execution, turn.Lease, turn.Owner, turn.JobCancellation, timerCancellation);
+ }
+ finally
+ {
+ owner._pumpCoordinator.Release(turn.Lease);
+ if (owner._localDrainRequested) owner.ScheduleLocalDrain();
+ }
+ }
+
+ protected override void Discard(PumpTurn turn)
+ {
+ owner._pumpResults.Discard(turn.Execution);
+ owner._pumpCoordinator.Release(turn.Lease);
+ }
+ }
+
+ private void ScheduleLocalDrain()
+ {
+ if (_shutdownToken.IsCancellationRequested || _failure is not null
+ || _jobId.Value is not { Length: > 0 } jobId
+ || GetCommittedInboxCount() == 0
+ || !HasCommittedOwnership())
+ {
+ return;
+ }
+
+ _localDrainRequested = true;
+ if (_pumpCoordinator.IsActive || !_pumpCoordinator.TryAcquire(jobId, _shutdownToken, out var lease))
+ {
+ return;
+ }
+
+ _localDrainRequested = false;
+ try
+ {
+ (_localDrainTimer ??= new(this)).Queue(new(lease, new PumpOwner(jobId, _job.Value!, _stateGeneration)));
+ }
+ catch
+ {
+ _pumpCoordinator.Release(lease);
+ throw;
+ }
+ }
+
+ private sealed class LocalDrainTimerState(InboxExtension owner) : MessagingTurn
+ {
+ protected override IGrainTimer RegisterTimer(long registrationGeneration) => owner._timerRegistry.RegisterGrainTimer(
+ owner._grainContext, (state, token) => state.RunAsync(registrationGeneration, token), this,
+ new GrainTimerCreationOptions(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan) { Interleave = false, KeepAlive = true });
+
+ protected override async Task ExecuteAsync(LocalDrainTurn turn, CancellationToken cancellationToken)
+ {
+ try
+ {
+ if (owner._pumpCoordinator.IsCurrent(turn.Lease))
+ {
+ using var cancellation = MessagingCancellation.Combine(
+ cancellationToken, owner._shutdownToken, out var combinedToken);
+ _ = await owner.ExecuteJobCoreAsync(turn.Owner, clearOwnershipWhenEmpty: false, combinedToken);
+ }
+ }
+ finally
+ {
+ owner._pumpCoordinator.Release(turn.Lease);
+ if (owner._localDrainRequested) owner.ScheduleLocalDrain();
+ }
+ }
+
+ protected override void Discard(LocalDrainTurn turn) => owner._pumpCoordinator.Release(turn.Lease);
+ }
+
+}
diff --git a/src/Orleans.Messaging/InboxHandlerContext.cs b/src/Orleans.Messaging/InboxHandlerContext.cs
new file mode 100644
index 00000000000..a9278c2d532
--- /dev/null
+++ b/src/Orleans.Messaging/InboxHandlerContext.cs
@@ -0,0 +1,12 @@
+using System;
+
+namespace Orleans.Messaging;
+
+internal sealed class InboxHandlerContext(InboxMessage message, Action complete) : IInboxHandlerContext
+{
+ private readonly Action _complete = complete ?? throw new ArgumentNullException(nameof(complete));
+
+ public InboxMessage Message { get; } = message;
+
+ public void Complete() => _complete();
+}
diff --git a/src/Orleans.Messaging/InboxMessage.cs b/src/Orleans.Messaging/InboxMessage.cs
new file mode 100644
index 00000000000..0a9e5aeb3ee
--- /dev/null
+++ b/src/Orleans.Messaging/InboxMessage.cs
@@ -0,0 +1,10 @@
+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 and common header buffer.
+ [Id(0)]
+ public required Envelope Envelope { get; init; }
+}
diff --git a/src/Orleans.Messaging/MessageHeaders.cs b/src/Orleans.Messaging/MessageHeaders.cs
new file mode 100644
index 00000000000..c883edf216f
--- /dev/null
+++ b/src/Orleans.Messaging/MessageHeaders.cs
@@ -0,0 +1,14 @@
+namespace Orleans.Messaging;
+
+/// Names the standard envelope headers.
+public static class MessageHeaders
+{
+ /// The required application payload, including an empty payload.
+ public const string Payload = "payload";
+
+ /// The optional, nonempty protocol subject encoded as canonical UTF-8 bytes.
+ public const string Subject = "subject";
+
+ /// The optional immediate sender encoded using an externally supplied Orleans serializer.
+ public const string Sender = "sender";
+}
diff --git a/src/Orleans.Messaging/MessageState.cs b/src/Orleans.Messaging/MessageState.cs
new file mode 100644
index 00000000000..6cb3e4ed292
--- /dev/null
+++ b/src/Orleans.Messaging/MessageState.cs
@@ -0,0 +1,65 @@
+using System;
+
+namespace Orleans.Messaging;
+
+[GenerateSerializer, Alias("Orleans.Messaging.InboxMessageState")]
+internal sealed class InboxMessageState
+{
+ [Id(0)]
+ public int AttemptCount { get; set; }
+
+ [Id(1)]
+ public DateTimeOffset? NextAttemptAt { get; set; }
+
+ [Id(2)]
+ public string? LastError { 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; }
+
+ [Id(3)]
+ public DateTimeOffset? EnqueuedAt { 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; }
+}
+
+[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/MessagingActivationValidator.cs b/src/Orleans.Messaging/MessagingActivationValidator.cs
new file mode 100644
index 00000000000..cfdfaf65056
--- /dev/null
+++ b/src/Orleans.Messaging/MessagingActivationValidator.cs
@@ -0,0 +1,49 @@
+using System;
+using System.Linq;
+using Orleans;
+using Orleans.Concurrency;
+using Orleans.Metadata;
+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(IGrainContext grainContext, GrainProperties properties, PlacementStrategy placementStrategy)
+ {
+ var grain = grainContext.GrainInstance
+ ?? throw new InvalidOperationException("Messaging activation requires an initialized grain instance.");
+ var grainType = grain.GetType();
+ if (placementStrategy.GetType() == _statelessWorkerPlacementType)
+ {
+ throw new InvalidOperationException(
+ $"Messaging requires one activation per grain identity, but grain type '{grainType}' is a stateless worker.");
+ }
+
+ if (properties.Properties.TryGetValue(WellKnownGrainTypeProperties.Reentrant, out var reentrant) && bool.Parse(reentrant)
+ || properties.Properties.ContainsKey(WellKnownGrainTypeProperties.MayInterleavePredicate))
+ {
+ throw new InvalidOperationException(
+ $"Messaging requires non-reentrant grain execution, but grain type '{grainType}' enables interleaving.");
+ }
+
+ var grainInterfaces = grainType
+ .GetInterfaces()
+ .Where(static type => typeof(IGrain).IsAssignableFrom(type))
+ .ToArray();
+ var interleavableMethod = grainInterfaces
+ .SelectMany(static type => type.GetInterfaces().Append(type))
+ .Distinct()
+ .SelectMany(static type => type.GetMethods())
+ .FirstOrDefault(static method => method.IsDefined(typeof(AlwaysInterleaveAttribute), inherit: true));
+ if (interleavableMethod is not null)
+ {
+ throw new InvalidOperationException(
+ $"Messaging grain type '{grainType}' implements interleavable method "
+ + $"'{interleavableMethod.DeclaringType}.{interleavableMethod.Name}'.");
+ }
+ }
+}
diff --git a/src/Orleans.Messaging/MessagingCancellation.cs b/src/Orleans.Messaging/MessagingCancellation.cs
new file mode 100644
index 00000000000..c868da367f7
--- /dev/null
+++ b/src/Orleans.Messaging/MessagingCancellation.cs
@@ -0,0 +1,44 @@
+using System.Threading;
+
+namespace Orleans.Messaging;
+
+internal static class MessagingCancellation
+{
+ // A borrowed token needs no resource; distinct scopes still require a link owned by the caller.
+ public static CancellationTokenSource? Combine(CancellationToken first, CancellationToken second, out CancellationToken token)
+ {
+ if (!first.CanBeCanceled || first == second)
+ {
+ token = second;
+ return null;
+ }
+ if (!second.CanBeCanceled)
+ {
+ token = first;
+ return null;
+ }
+ var source = CancellationTokenSource.CreateLinkedTokenSource(first, second);
+ token = source.Token;
+ return source;
+ }
+
+ public static CancellationTokenSource? Combine(
+ CancellationToken first, CancellationToken second, CancellationToken third, out CancellationToken token)
+ {
+ if (!first.CanBeCanceled || first == second || first == third)
+ {
+ return Combine(second, third, out token);
+ }
+ if (!second.CanBeCanceled || second == third)
+ {
+ return Combine(first, third, out token);
+ }
+ if (!third.CanBeCanceled)
+ {
+ return Combine(first, second, out token);
+ }
+ var source = CancellationTokenSource.CreateLinkedTokenSource(first, second, third);
+ token = source.Token;
+ return source;
+ }
+}
diff --git a/src/Orleans.Messaging/MessagingGrainTypeConfigurator.cs b/src/Orleans.Messaging/MessagingGrainTypeConfigurator.cs
new file mode 100644
index 00000000000..bd0187a2a4f
--- /dev/null
+++ b/src/Orleans.Messaging/MessagingGrainTypeConfigurator.cs
@@ -0,0 +1,32 @@
+using System;
+using Microsoft.Extensions.DependencyInjection;
+using Orleans.Journaling;
+using Orleans.Metadata;
+using Orleans.Runtime;
+
+namespace Orleans.Messaging;
+
+internal sealed class MessagingGrainTypeConfigurator(GrainClassMap grainClasses) : IConfigureGrainTypeComponents
+{
+ public void Configure(GrainType grainType, GrainProperties properties, GrainTypeSharedContext shared)
+ {
+ if (!grainClasses.TryGetGrainClass(grainType, out var grainClass))
+ {
+ throw new InvalidOperationException($"No grain implementation is registered for '{grainType}'.");
+ }
+
+ if (typeof(DurableGrain).IsAssignableFrom(grainClass) || typeof(IMessagingGrain).IsAssignableFrom(grainClass))
+ {
+ var placementStrategy = shared.PlacementStrategy;
+ shared.AddActivationSetup(context =>
+ {
+ MessagingActivationValidator.Validate(context, properties, placementStrategy);
+ var services = context.ActivationServices;
+ _ = services.GetRequiredService();
+ _ = services.GetRequiredService();
+ _ = services.GetRequiredService();
+ _ = services.GetRequiredService();
+ });
+ }
+ }
+}
diff --git a/src/Orleans.Messaging/MessagingInstruments.cs b/src/Orleans.Messaging/MessagingInstruments.cs
new file mode 100644
index 00000000000..01af41137fb
--- /dev/null
+++ b/src/Orleans.Messaging/MessagingInstruments.cs
@@ -0,0 +1,114 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics.Metrics;
+using System.Threading;
+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 Counter _orphanedJobsReclaimed = instruments.Meter.CreateCounter("orleans-messaging-orphaned-jobs-reclaimed");
+ 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 DepthTracker _inboxDepth = new(instruments.Meter, "orleans-messaging-inbox-depth");
+ private readonly DepthTracker _outboxDepth = new(instruments.Meter, "orleans-messaging-outbox-depth");
+
+ internal static MessagingInstruments CreateForDirectConstruction() => new(new OrleansInstruments(new DirectMeterFactory()));
+
+ internal void OnInboxDepthChanged(int delta) => _inboxDepth.Adjust(delta);
+
+ internal void OnOutboxDepthChanged(int delta) => _outboxDepth.Adjust(delta);
+
+ 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, CreateTags(grainType));
+ }
+ }
+
+ internal void OnOutboxMessageDelivered(string grainType, string status) =>
+ Add(_outboxMessagesDelivered, grainType, status);
+
+ internal void OnOutboxDeliveryDuration(TimeSpan duration, string grainType) =>
+ Record(_outboxDeliveryDuration, duration, grainType);
+
+ internal void OnOrphanedJobReclaimed(string grainType, string jobName)
+ {
+ if (_orphanedJobsReclaimed.Enabled)
+ {
+ _orphanedJobsReclaimed.Add(
+ 1,
+ [
+ new(GrainTypeTagName, grainType),
+ new("job_name", jobName)
+ ]);
+ }
+ }
+
+ 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), CreateTags(grainType));
+ }
+ }
+
+ private static KeyValuePair[] CreateTags(string grainType) =>
+ [
+ new(GrainTypeTagName, grainType)
+ ];
+
+ private sealed class DirectMeterFactory : IMeterFactory
+ {
+ public Meter Create(MeterOptions options) => new(options);
+
+ public void Dispose()
+ {
+ }
+ }
+
+ private sealed class DepthTracker
+ {
+ private readonly ObservableGauge _gauge;
+ private long _value;
+
+ public DepthTracker(Meter meter, string name)
+ {
+ _gauge = meter.CreateObservableGauge(name, () => Volatile.Read(ref _value));
+ }
+
+ public void Adjust(int delta) => Interlocked.Add(ref _value, delta);
+ }
+}
diff --git a/src/Orleans.Messaging/MessagingJobOwnership.cs b/src/Orleans.Messaging/MessagingJobOwnership.cs
new file mode 100644
index 00000000000..a73ae264e66
--- /dev/null
+++ b/src/Orleans.Messaging/MessagingJobOwnership.cs
@@ -0,0 +1,127 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using Orleans.DurableJobs;
+using Orleans.Runtime;
+
+namespace Orleans.Messaging;
+
+internal static class MessagingJobOwnership
+{
+ private const string MetadataKey = "orleans.messaging.ownership-id";
+
+ public static IReadOnlyDictionary CreateMetadata(string ownershipId) =>
+ new Dictionary(1, StringComparer.Ordinal)
+ {
+ [MetadataKey] = ownershipId
+ };
+
+ public static bool TryGetOwnershipId(DurableJob job, out string ownershipId)
+ {
+ if (job.Metadata is not null
+ && job.Metadata.TryGetValue(MetadataKey, out var value)
+ && !string.IsNullOrWhiteSpace(value))
+ {
+ ownershipId = value;
+ return true;
+ }
+
+ ownershipId = string.Empty;
+ return false;
+ }
+
+
+ public static bool HasOwner(string? ownershipId, DurableJob? job) =>
+ !string.IsNullOrWhiteSpace(ownershipId) && job is not null;
+
+ public static string? GetPairError(string? ownershipId, DurableJob? job)
+ {
+ var hasOwnershipId = !string.IsNullOrWhiteSpace(ownershipId);
+ if (hasOwnershipId != (job is not null))
+ {
+ return "The messaging ownership generation and job handle must either both be present or both be absent.";
+ }
+
+ if (hasOwnershipId
+ && (!TryGetOwnershipId(job!, out var jobOwnershipId)
+ || !string.Equals(jobOwnershipId, ownershipId, StringComparison.Ordinal)))
+ {
+ return "The messaging job handle metadata does not match its ownership generation.";
+ }
+
+ return null;
+ }
+
+ public static bool IsSamePhysicalJob(DurableJob? expected, DurableJob? actual) =>
+ expected is not null
+ && actual is not null
+ && string.Equals(expected.Id, actual.Id, StringComparison.Ordinal)
+ && string.Equals(expected.ShardId, actual.ShardId, StringComparison.Ordinal);
+
+ public static string CreateId(string epoch, long sequence)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(epoch);
+ return $"{epoch}:{sequence.ToString(CultureInfo.InvariantCulture)}";
+ }
+
+ public static bool IsCompleted(string? completedOwnershipId, string ownershipId)
+ {
+ if (string.Equals(completedOwnershipId, ownershipId, StringComparison.Ordinal))
+ {
+ return true;
+ }
+
+ return TryParse(completedOwnershipId, out var completedEpoch, out var completed)
+ && TryParse(ownershipId, out var currentEpoch, out var current)
+ && string.Equals(completedEpoch, currentEpoch, StringComparison.Ordinal)
+ && current <= completed;
+ }
+
+ private static bool TryParse(string? value, out string epoch, out long sequence)
+ {
+ sequence = 0;
+ var separator = value?.LastIndexOf(':') ?? -1;
+ if (separator <= 0
+ || !long.TryParse(
+ value.AsSpan(separator + 1),
+ NumberStyles.None,
+ CultureInfo.InvariantCulture,
+ out sequence))
+ {
+ epoch = string.Empty;
+ return false;
+ }
+
+ epoch = value![..separator];
+ return true;
+ }
+
+ public static OwnershipMismatchDisposition ResolveMismatch(
+ bool recoveryCompleted,
+ bool hasCurrentOwner,
+ bool ownershipCompleted,
+ bool hasWork)
+ {
+ if (!recoveryCompleted)
+ {
+ return OwnershipMismatchDisposition.WaitForRecovery;
+ }
+
+ if (hasCurrentOwner || ownershipCompleted)
+ {
+ return OwnershipMismatchDisposition.CompleteStale;
+ }
+
+ return hasWork
+ ? OwnershipMismatchDisposition.WaitForReplacement
+ : OwnershipMismatchDisposition.ReclaimOrphan;
+ }
+}
+
+internal enum OwnershipMismatchDisposition
+{
+ WaitForRecovery,
+ WaitForReplacement,
+ CompleteStale,
+ ReclaimOrphan
+}
diff --git a/src/Orleans.Messaging/MessagingPumpCoordinator.cs b/src/Orleans.Messaging/MessagingPumpCoordinator.cs
new file mode 100644
index 00000000000..c4d57c76f83
--- /dev/null
+++ b/src/Orleans.Messaging/MessagingPumpCoordinator.cs
@@ -0,0 +1,82 @@
+using System;
+using System.Threading;
+
+namespace Orleans.Messaging;
+
+///
+/// Coalesces physical durable-job callbacks which represent the same logical pump ownership.
+///
+internal sealed class MessagingPumpCoordinator
+{
+ private readonly object _lock = new();
+ private string? _activeOwnershipId;
+ private CancellationToken _activeCancellationToken;
+ private long _activeGeneration;
+
+ public bool IsActive
+ {
+ get
+ {
+ lock (_lock)
+ {
+ return _activeOwnershipId is not null;
+ }
+ }
+ }
+
+ public bool TryAcquire(
+ string ownershipId,
+ CancellationToken cancellationToken,
+ out MessagingPumpLease lease)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(ownershipId);
+
+ lock (_lock)
+ {
+ if (string.Equals(_activeOwnershipId, ownershipId, StringComparison.Ordinal)
+ && !_activeCancellationToken.IsCancellationRequested)
+ {
+ lease = default;
+ return false;
+ }
+
+ lease = new MessagingPumpLease(ownershipId, ++_activeGeneration);
+ _activeOwnershipId = ownershipId;
+ _activeCancellationToken = cancellationToken;
+ return true;
+ }
+ }
+
+ public bool IsCurrent(MessagingPumpLease lease)
+ {
+ lock (_lock)
+ {
+ return _activeGeneration == lease.Generation
+ && string.Equals(_activeOwnershipId, lease.OwnershipId, StringComparison.Ordinal);
+ }
+ }
+
+ public void Release(MessagingPumpLease lease)
+ {
+ lock (_lock)
+ {
+ if (_activeGeneration == lease.Generation
+ && string.Equals(_activeOwnershipId, lease.OwnershipId, StringComparison.Ordinal))
+ {
+ _activeOwnershipId = null;
+ _activeCancellationToken = default;
+ }
+ }
+ }
+
+ public void Reset()
+ {
+ lock (_lock)
+ {
+ _activeOwnershipId = null;
+ _activeCancellationToken = default;
+ }
+ }
+}
+
+internal readonly record struct MessagingPumpLease(string OwnershipId, long Generation);
diff --git a/src/Orleans.Messaging/MessagingPumpResults.cs b/src/Orleans.Messaging/MessagingPumpResults.cs
new file mode 100644
index 00000000000..16a3bdbe3d0
--- /dev/null
+++ b/src/Orleans.Messaging/MessagingPumpResults.cs
@@ -0,0 +1,428 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using Orleans.DurableJobs;
+using Orleans.Runtime;
+
+namespace Orleans.Messaging;
+
+internal readonly record struct MessagingPumpExecutionKey(string JobName, string JobId, string RunId, long StateGeneration);
+
+internal readonly record struct MessagingPumpExecution(MessagingPumpExecutionKey Key, long Generation);
+
+internal sealed class MessagingPumpResults
+{
+ private const int DefaultMaxRetainedEntries = 65_536;
+ private static readonly TimeSpan DefaultRetentionPeriod = TimeSpan.FromMinutes(10);
+
+ private readonly object _lock = new();
+ private readonly Dictionary _entries = [];
+ private readonly TimeProvider _timeProvider;
+ private readonly TimeSpan _completedRetentionPeriod;
+ private readonly TimeSpan _abandonedRetentionPeriod;
+ private readonly TimeSpan _cleanupInterval;
+ private readonly int _maxRetainedEntries;
+ private DateTimeOffset _nextCleanup;
+ private long _generation;
+
+ internal MessagingPumpResults()
+ : this(TimeProvider.System, DefaultRetentionPeriod, DefaultRetentionPeriod, DefaultMaxRetainedEntries)
+ {
+ }
+
+ internal MessagingPumpResults(
+ TimeProvider timeProvider,
+ TimeSpan completedRetentionPeriod,
+ TimeSpan abandonedRetentionPeriod,
+ int maxRetainedEntries)
+ {
+ ArgumentNullException.ThrowIfNull(timeProvider);
+ ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(completedRetentionPeriod, TimeSpan.Zero);
+ ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(abandonedRetentionPeriod, TimeSpan.Zero);
+ ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxRetainedEntries);
+
+ _timeProvider = timeProvider;
+ _completedRetentionPeriod = completedRetentionPeriod;
+ _abandonedRetentionPeriod = abandonedRetentionPeriod;
+ _maxRetainedEntries = maxRetainedEntries;
+ _cleanupInterval = TimeSpan.FromTicks(Math.Max(
+ TimeSpan.FromSeconds(1).Ticks,
+ Math.Min(TimeSpan.FromMinutes(1).Ticks, Math.Min(completedRetentionPeriod.Ticks, abandonedRetentionPeriod.Ticks) / 4)));
+ _nextCleanup = MessagingTime.AddClamped(timeProvider.GetUtcNow(), _cleanupInterval);
+ }
+
+ public bool TryStart(
+ MessagingPumpExecutionKey key,
+ CancellationToken cancellationToken,
+ out MessagingPumpExecution execution)
+ {
+ List? removed;
+ lock (_lock)
+ {
+ var now = _timeProvider.GetUtcNow();
+ removed = Prune(now, force: _entries.Count >= _maxRetainedEntries);
+ if (_entries.ContainsKey(key))
+ {
+ execution = default;
+ }
+ else
+ {
+ if (_entries.Count >= _maxRetainedEntries)
+ {
+ MessagingPumpExecutionKey? candidateKey = null;
+ Entry? candidateEntry = null;
+ DateTimeOffset candidateTime = DateTimeOffset.MaxValue;
+
+ foreach (var (entryKey, entryValue) in _entries)
+ {
+ if (entryValue.State != EntryState.Running)
+ {
+ var timestamp = entryValue.State == EntryState.Completed ? entryValue.CompletedAt : entryValue.CreatedAt;
+ if (candidateEntry is null || timestamp < candidateTime)
+ {
+ candidateKey = entryKey;
+ candidateEntry = entryValue;
+ candidateTime = timestamp;
+ }
+ }
+ }
+
+ if (candidateKey is not null && _entries.Remove(candidateKey.Value))
+ {
+ (removed ??= []).Add(candidateEntry!);
+ }
+ }
+
+ if (_entries.Count >= _maxRetainedEntries)
+ {
+ execution = default;
+ }
+ else
+ {
+ execution = new(key, ++_generation);
+ _entries.Add(key, new Entry(execution.Generation, now));
+ }
+ }
+ }
+
+ DisposeRegistrations(removed);
+ if (execution == default)
+ {
+ return false;
+ }
+
+ if (!cancellationToken.CanBeCanceled)
+ {
+ return true;
+ }
+
+ var registration = cancellationToken.UnsafeRegister(
+ static state =>
+ {
+ var cancellation = (CancellationState)state!;
+ cancellation.Owner.CancelWaiting(cancellation.Execution, cancellation.Token);
+ },
+ new CancellationState(this, execution, cancellationToken));
+
+ var disposeRegistration = false;
+ lock (_lock)
+ {
+ if (_entries.TryGetValue(key, out var current)
+ && current.Generation == execution.Generation
+ && current.State == EntryState.Waiting)
+ {
+ current.CancellationRegistration = registration;
+ }
+ else
+ {
+ disposeRegistration = true;
+ }
+ }
+
+ if (disposeRegistration)
+ {
+ registration.Dispose();
+ }
+
+ return true;
+ }
+
+ public bool TryBegin(MessagingPumpExecution execution)
+ {
+ CancellationTokenRegistration registration = default;
+ lock (_lock)
+ {
+ if (!_entries.TryGetValue(execution.Key, out var entry)
+ || entry.Generation != execution.Generation
+ || entry.State != EntryState.Waiting)
+ {
+ return false;
+ }
+
+ entry.State = EntryState.Running;
+ registration = entry.CancellationRegistration;
+ entry.CancellationRegistration = default;
+ }
+
+ registration.Dispose();
+ return true;
+ }
+
+ public void Complete(MessagingPumpExecution execution, DurableJobRunResult result)
+ {
+ ArgumentNullException.ThrowIfNull(result);
+ Finish(execution, result, exception: null);
+ }
+
+ public void Fail(MessagingPumpExecution execution, Exception exception)
+ {
+ ArgumentNullException.ThrowIfNull(exception);
+ Finish(execution, result: null, exception);
+ }
+
+ public void Discard(MessagingPumpExecution execution)
+ {
+ Entry? removed = null;
+ lock (_lock)
+ {
+ if (_entries.TryGetValue(execution.Key, out var entry)
+ && entry.Generation == execution.Generation
+ && entry.State != EntryState.Running)
+ {
+ _entries.Remove(execution.Key);
+ removed = entry;
+ }
+ }
+
+ if (removed is not null)
+ {
+ DisposeRegistration(removed);
+ }
+ }
+
+ public void Clear(string jobName)
+ {
+ List? removed = null;
+ lock (_lock)
+ {
+ foreach (var pair in _entries.ToArray())
+ {
+ if (string.Equals(pair.Key.JobName, jobName, StringComparison.Ordinal))
+ {
+ _entries.Remove(pair.Key);
+ (removed ??= []).Add(pair.Value);
+ }
+ }
+ }
+
+ DisposeRegistrations(removed);
+ }
+
+ public bool TryTake(
+ MessagingPumpExecutionKey key,
+ out DurableJobRunResult? result,
+ out Exception? exception)
+ {
+ Entry? removedEntry = null;
+ List? pruned;
+ lock (_lock)
+ {
+ pruned = Prune(_timeProvider.GetUtcNow(), force: false);
+ if (!_entries.TryGetValue(key, out var entry) || entry.State != EntryState.Completed)
+ {
+ result = null;
+ exception = null;
+ }
+ else
+ {
+ _entries.Remove(key);
+ removedEntry = entry;
+ result = entry.Result;
+ exception = entry.Exception;
+ }
+ }
+
+ DisposeRegistrations(pruned);
+ if (removedEntry is null)
+ {
+ return false;
+ }
+
+ removedEntry.CancellationRegistration.Dispose();
+ return true;
+ }
+
+ private void Finish(
+ MessagingPumpExecution execution,
+ DurableJobRunResult? result,
+ Exception? exception)
+ {
+ CancellationTokenRegistration registration = default;
+ List? removed;
+ lock (_lock)
+ {
+ var now = _timeProvider.GetUtcNow();
+ if (_entries.TryGetValue(execution.Key, out var entry)
+ && entry.Generation == execution.Generation
+ && entry.State != EntryState.Completed)
+ {
+ entry.Result = result;
+ entry.Exception = exception;
+ entry.State = EntryState.Completed;
+ entry.CompletedAt = now;
+ registration = entry.CancellationRegistration;
+ entry.CancellationRegistration = default;
+ }
+
+ removed = Prune(now, force: _entries.Count > _maxRetainedEntries);
+ }
+
+ registration.Dispose();
+ DisposeRegistrations(removed);
+ }
+
+ private void CancelWaiting(MessagingPumpExecution execution, CancellationToken cancellationToken)
+ {
+ CancellationTokenRegistration registration = default;
+ lock (_lock)
+ {
+ var now = _timeProvider.GetUtcNow();
+ if (_entries.TryGetValue(execution.Key, out var entry)
+ && entry.Generation == execution.Generation
+ && entry.State == EntryState.Waiting)
+ {
+ entry.Exception = new OperationCanceledException(cancellationToken);
+ entry.State = EntryState.Completed;
+ entry.CompletedAt = now;
+ registration = entry.CancellationRegistration;
+ entry.CancellationRegistration = default;
+ }
+ }
+
+ registration.Dispose();
+ }
+
+ private List? Prune(DateTimeOffset now, bool force)
+ {
+ if (!force && now < _nextCleanup)
+ {
+ return null;
+ }
+
+ _nextCleanup = MessagingTime.AddClamped(now, _cleanupInterval);
+ List? removed = null;
+ foreach (var pair in _entries.ToArray())
+ {
+ var entry = pair.Value;
+ var expired = entry.State switch
+ {
+ EntryState.Completed => now - entry.CompletedAt >= _completedRetentionPeriod,
+ EntryState.Waiting => now - entry.CreatedAt >= _abandonedRetentionPeriod,
+ _ => false
+ };
+ if (expired && _entries.Remove(pair.Key))
+ {
+ (removed ??= []).Add(entry);
+ }
+ }
+
+ if (_entries.Count <= _maxRetainedEntries)
+ {
+ return removed;
+ }
+
+ foreach (var pair in _entries
+ .Where(static pair => pair.Value.State != EntryState.Running)
+ .OrderBy(static pair => pair.Value.State == EntryState.Completed ? pair.Value.CompletedAt : pair.Value.CreatedAt)
+ .ToArray())
+ {
+ if (_entries.Count <= _maxRetainedEntries)
+ {
+ break;
+ }
+
+ if (_entries.Remove(pair.Key))
+ {
+ (removed ??= []).Add(pair.Value);
+ }
+ }
+
+ return removed;
+ }
+
+ private static void DisposeRegistrations(List? entries)
+ {
+ if (entries is null)
+ {
+ return;
+ }
+
+ foreach (var entry in entries)
+ {
+ DisposeRegistration(entry);
+ }
+ }
+
+ private static void DisposeRegistration(Entry entry)
+ {
+ var registration = entry.CancellationRegistration;
+ entry.CancellationRegistration = default;
+ registration.Dispose();
+ }
+
+ private sealed class Entry(long generation, DateTimeOffset createdAt)
+ {
+ public long Generation { get; } = generation;
+ public DateTimeOffset CreatedAt { get; } = createdAt;
+ public DateTimeOffset CompletedAt { get; set; }
+ public EntryState State { get; set; }
+ public DurableJobRunResult? Result { get; set; }
+ public Exception? Exception { get; set; }
+ public CancellationTokenRegistration CancellationRegistration { get; set; }
+ }
+
+ private sealed record CancellationState(
+ MessagingPumpResults Owner,
+ MessagingPumpExecution Execution,
+ CancellationToken Token);
+
+ private enum EntryState
+ {
+ Waiting,
+ Running,
+ Completed
+ }
+}
+
+internal sealed class OneShotTimerHandle
+{
+ private readonly object _lock = new();
+ private IGrainTimer? _timer;
+ private bool _completed;
+
+ public void Attach(IGrainTimer timer)
+ {
+ lock (_lock)
+ {
+ if (_completed)
+ {
+ timer.Dispose();
+ }
+ else
+ {
+ _timer = timer;
+ }
+ }
+ }
+
+ public void Complete()
+ {
+ lock (_lock)
+ {
+ _completed = true;
+ _timer?.Dispose();
+ _timer = null;
+ }
+ }
+}
diff --git a/src/Orleans.Messaging/MessagingStateNames.cs b/src/Orleans.Messaging/MessagingStateNames.cs
new file mode 100644
index 00000000000..b4a99609f08
--- /dev/null
+++ b/src/Orleans.Messaging/MessagingStateNames.cs
@@ -0,0 +1,22 @@
+namespace Orleans.Messaging;
+
+internal static class MessagingStateNames
+{
+ private const string Prefix = "__orleans.messaging.";
+
+ public const string Inbox = Prefix + "inbox";
+ public const string InboxProcessed = Prefix + "inbox-processed";
+ public const string InboxMessageState = Prefix + "inbox-message-state";
+ public const string InboxDeadLetters = Prefix + "inbox-dead-letters";
+ public const string InboxJobId = Prefix + "inbox-job-id";
+ public const string InboxJobHandle = Prefix + "inbox-job-handle";
+ public const string InboxCompletedJobId = Prefix + "inbox-completed-job-id";
+ public const string InboxJobSequence = Prefix + "inbox-job-sequence";
+ public const string Outbox = Prefix + "outbox";
+ public const string OutboxMessageState = Prefix + "outbox-message-state";
+ public const string OutboxDeadLetters = Prefix + "outbox-dead-letters";
+ public const string OutboxJobId = Prefix + "outbox-job-id";
+ public const string OutboxJobHandle = Prefix + "outbox-job-handle";
+ public const string OutboxCompletedJobId = Prefix + "outbox-completed-job-id";
+ public const string OutboxJobSequence = Prefix + "outbox-job-sequence";
+}
diff --git a/src/Orleans.Messaging/MessagingTime.cs b/src/Orleans.Messaging/MessagingTime.cs
new file mode 100644
index 00000000000..c185c232926
--- /dev/null
+++ b/src/Orleans.Messaging/MessagingTime.cs
@@ -0,0 +1,21 @@
+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;
+ }
+
+ 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/MessagingTurn.cs b/src/Orleans.Messaging/MessagingTurn.cs
new file mode 100644
index 00000000000..5030e326dea
--- /dev/null
+++ b/src/Orleans.Messaging/MessagingTurn.cs
@@ -0,0 +1,75 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using Orleans.Runtime;
+
+namespace Orleans.Messaging;
+
+// Accessed on the owning activation. Runtime admission stays non-interleaving and neutral.
+// Only the timer/state is reused: every logical arm carries an immutable owner-bound payload.
+internal abstract class MessagingTurn : IDisposable where T : struct
+{
+ private IGrainTimer? _timer;
+ private T? _pending;
+ private bool _disposed;
+ private long _registrationGeneration;
+ private CancellationToken _timerCancellation;
+
+ protected abstract IGrainTimer RegisterTimer(long registrationGeneration);
+ protected abstract Task ExecuteAsync(T payload, CancellationToken cancellationToken);
+ protected abstract void Discard(T payload);
+
+ public void Queue(T payload)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ if (_pending is { } superseded)
+ {
+ _pending = null;
+ Discard(superseded);
+ }
+ _pending = payload;
+ try
+ {
+ // Register disarmed so no callback can observe a partially installed timer handle.
+ if (_timerCancellation.IsCancellationRequested)
+ {
+ // A disposed physical handle is never reanimated. The active callback keeps its
+ // original payload/token; only future arms receive a new registration generation.
+ _timer?.Dispose();
+ _timer = null;
+ _timerCancellation = default;
+ }
+ _timer ??= RegisterTimer(++_registrationGeneration);
+ _timer.Change(TimeSpan.Zero, Timeout.InfiniteTimeSpan);
+ }
+ catch
+ {
+ _pending = null; // Queue's caller still owns this failed admission and its cleanup.
+ throw;
+ }
+ }
+
+ public Task RunAsync(long registrationGeneration, CancellationToken cancellationToken)
+ {
+ if (_disposed || registrationGeneration != _registrationGeneration || _pending is not { } payload)
+ {
+ return Task.CompletedTask;
+ }
+ _pending = null;
+ _timerCancellation = cancellationToken;
+ // The active payload is a value snapshot, never the mutable slot used by a subsequent arm.
+ return ExecuteAsync(payload, cancellationToken);
+ }
+
+ public void Dispose()
+ {
+ if (_disposed) return;
+ _disposed = true;
+ if (_pending is { } queued)
+ {
+ _pending = null;
+ Discard(queued);
+ }
+ _timer?.Dispose();
+ }
+}
diff --git a/src/Orleans.Messaging/Orleans.Messaging.csproj b/src/Orleans.Messaging/Orleans.Messaging.csproj
new file mode 100644
index 00000000000..2069ef3a10b
--- /dev/null
+++ b/src/Orleans.Messaging/Orleans.Messaging.csproj
@@ -0,0 +1,29 @@
+
+
+ Microsoft.Orleans.Messaging
+ Microsoft Orleans Messaging
+ Opaque messaging contracts and journaled inbox processing for Microsoft Orleans.
+ $(PackageTags) Messaging Inbox Outbox
+ false
+
+ true
+ true
+ $(DefaultTargetFrameworks)
+ disable
+ enable
+ $(NoWarn);ORLEANSEXP005
+ $(VersionSuffix).alpha.1
+ alpha.1
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Orleans.Messaging/OrleansContracts.txt b/src/Orleans.Messaging/OrleansContracts.txt
new file mode 100644
index 00000000000..d082ad687ac
--- /dev/null
+++ b/src/Orleans.Messaging/OrleansContracts.txt
@@ -0,0 +1,17 @@
+# This file is generated by the Orleans contract analyzer.
+# To regenerate this project from the repository root:
+# dotnet format PATH_TO_PROJECT.csproj analyzers --severity info --diagnostics ORLEANS0016 ORLEANS0017 ORLEANS0018 ORLEANS0019 ORLEANS0020 ORLEANS0022 ORLEANS0023 ORLEANS0024
+# Run the command once per contract project; do not pass a .sln or .slnx path.
+# Verify with: dotnet build PATH_TO_PROJECT.csproj
+# The regeneration command edits this manifest only; it does not change source attributes.
+# OrleansContracts format: 2
+# Method lines use: wire-identity: CLR-signature.
+# The identity is the identifier Orleans uses at runtime, whether generated or declared in source.
+# Review every diff: identity or signature changes can break wire compatibility during rolling upgrades.
+# Details: https://aka.ms/orleans/OrleansContracts.txt
+
+*RETIRED* interface [GrainInterfaceType("Orleans.DurableMessaging.IDurableInboxExtension")] Orleans.DurableMessaging.IDurableInboxExtension [Version(0)]
+ DeliverAsync: DeliverAsync(Orleans.DurableMessaging.InboxMessage, System.Threading.CancellationToken) -> ValueTask
+
+interface [GrainInterfaceType("Orleans.Messaging.IInboxExtension")] Orleans.Messaging.IInboxExtension [Version(0)]
+ DeliverAsync: DeliverAsync(Orleans.Messaging.InboxMessage, System.Threading.CancellationToken) -> ValueTask
diff --git a/src/Orleans.Messaging/OutboxMessage.cs b/src/Orleans.Messaging/OutboxMessage.cs
new file mode 100644
index 00000000000..32210bb8fde
--- /dev/null
+++ b/src/Orleans.Messaging/OutboxMessage.cs
@@ -0,0 +1,20 @@
+using Orleans.Runtime;
+
+namespace Orleans.Messaging;
+
+/// An outgoing command with destination routing separate from its common envelope.
+[GenerateSerializer, Alias("Orleans.Messaging.OutboxMessage")]
+public readonly struct OutboxMessage
+{
+ /// Gets the command identity and common header buffer.
+ [Id(0)]
+ public required Envelope Envelope { get; init; }
+
+ /// Gets the required destination grain identity.
+ [Id(1)]
+ public required GrainId ReceiverId { get; init; }
+
+ /// Creates the incoming view by sharing the common envelope and its buffer.
+ /// A received message whose destination is implied by the receiving grain.
+ public InboxMessage ToInboxMessage() => new() { Envelope = Envelope };
+}
diff --git a/src/Orleans.Messaging/PackedEnvelopeHeaders.cs b/src/Orleans.Messaging/PackedEnvelopeHeaders.cs
new file mode 100644
index 00000000000..6f4121dd03f
--- /dev/null
+++ b/src/Orleans.Messaging/PackedEnvelopeHeaders.cs
@@ -0,0 +1,211 @@
+using System;
+using System.Buffers;
+using System.Collections.Generic;
+using System.Text;
+
+namespace Orleans.Messaging;
+
+internal static class PackedEnvelopeHeaders
+{
+ // Version, count and directory length precede payload length, keyed lengths, then contiguous values.
+ internal const byte Version = 1;
+ internal static readonly UTF8Encoding Utf8 = new(false, true);
+
+ internal static void ValidateKey(string key)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(key);
+ _ = Utf8.GetByteCount(key);
+ }
+
+ internal static int GetKeyToken(string key) => key switch
+ {
+ MessageHeaders.Payload => 0,
+ MessageHeaders.Subject => 1,
+ MessageHeaders.Sender => 2,
+ _ => checked(3 + Utf8.GetByteCount(key))
+ };
+
+ internal static void ValidateSubject(ReadOnlySpan subject)
+ {
+ if (subject.IsEmpty || subject.Length > EnvelopeValidation.MaxSubjectBytes)
+ {
+ throw new ArgumentException($"Subjects require 1 to {EnvelopeValidation.MaxSubjectBytes} UTF-8 bytes.", nameof(subject));
+ }
+ _ = Utf8.GetCharCount(subject);
+ }
+
+ internal static int VarIntLength(int value)
+ {
+ var length = 1;
+ while ((value >>= 7) > 0) length++;
+ return length;
+ }
+
+ internal static void WriteVarInt(Span bytes, ref int position, int value)
+ {
+ do
+ {
+ var next = (byte)(value & 127);
+ value >>= 7;
+ bytes[position++] = (byte)(next | (value > 0 ? 128 : 0));
+ } while (value > 0);
+ }
+
+ private static int ReadVarInt(ReadOnlySpan bytes, ref int position)
+ {
+ uint value = 0;
+ for (var shift = 0; shift <= 28; shift += 7)
+ {
+ if (position >= bytes.Length) throw Invalid("Truncated integer.");
+ var next = bytes[position++];
+ if (shift == 28 && next > 7) throw Invalid("Integer exceeds the buffer address space.");
+ value |= (uint)(next & 127) << shift;
+ if (next < 128)
+ {
+ if (shift > 0 && next == 0) throw Invalid("Noncanonical integer.");
+ return (int)value;
+ }
+ }
+ throw Invalid("Invalid integer.");
+ }
+
+ private static (int Count, int DirectoryEnd) ReadPrefix(ReadOnlySpan bytes, ref int position)
+ {
+ if (bytes.IsEmpty || bytes[position++] != Version) throw Invalid("Unknown or missing envelope format.");
+ var count = ReadVarInt(bytes, ref position);
+ var directoryLength = ReadVarInt(bytes, ref position);
+ if (count == 0 || directoryLength == 0 || directoryLength > bytes.Length - position
+ || count - 1 > (directoryLength - 1) / 2)
+ {
+ throw Invalid("Invalid header count or directory length.");
+ }
+ return (count, position + directoryLength);
+ }
+
+ internal static void Validate(ReadOnlySpan bytes)
+ {
+ var position = 0;
+ var (count, directoryEnd) = ReadPrefix(bytes, ref position);
+ var directory = bytes[..directoryEnd];
+ var valuePosition = directoryEnd;
+ var payloadLength = ReadVarInt(directory, ref position);
+ AdvanceValue(bytes, ref valuePosition, payloadLength);
+ HashSet? customKeys = null;
+ var standardKeys = 0;
+ for (var i = 1; i < count; i++)
+ {
+ var token = ReadVarInt(directory, ref position);
+ if (token is 1 or 2)
+ {
+ var mask = 1 << token;
+ if ((standardKeys & mask) != 0) throw Invalid("Duplicate standard header.");
+ standardKeys |= mask;
+ }
+ else
+ {
+ var nameLength = token - 3;
+ if (nameLength <= 0 || nameLength > directoryEnd - position) throw Invalid("Invalid header key length.");
+ string key;
+ try { key = Utf8.GetString(directory.Slice(position, nameLength)); }
+ catch (DecoderFallbackException error) { throw Invalid("Invalid UTF-8 key.", error); }
+ position += nameLength;
+ if (key is MessageHeaders.Payload or MessageHeaders.Subject or MessageHeaders.Sender)
+ throw Invalid("Standard keys require their reserved encoding.");
+ if (!(customKeys ??= new(StringComparer.Ordinal)).Add(key)) throw Invalid("Duplicate custom header.");
+ }
+ var length = ReadVarInt(directory, ref position);
+ var start = valuePosition;
+ AdvanceValue(bytes, ref valuePosition, length);
+ if (token == 1)
+ {
+ try { ValidateSubject(bytes.Slice(start, length)); }
+ catch (ArgumentException error) { throw Invalid("Invalid subject header.", error); }
+ }
+ }
+ if (position != directoryEnd || valuePosition != bytes.Length) throw Invalid("Trailing or missing directory/value bytes.");
+ }
+
+ private static void AdvanceValue(ReadOnlySpan bytes, ref int position, int length)
+ {
+ if (length > bytes.Length - position) throw Invalid("Header value exceeds the buffer.");
+ position += length;
+ }
+
+ internal static ReadOnlyMemory GetPayload(byte[] bytes)
+ {
+ var position = 0;
+ var (_, directoryEnd) = ReadPrefix(bytes, ref position);
+ var length = ReadVarInt(bytes.AsSpan(0, directoryEnd), ref position);
+ if (length > bytes.Length - directoryEnd) throw Invalid("Payload exceeds the buffer.");
+ return bytes.AsMemory(directoryEnd, length);
+ }
+
+ internal static bool TryGetBytes(byte[] bytes, string key, out ReadOnlyMemory value)
+ {
+ ValidateKey(key);
+ if (key == MessageHeaders.Payload)
+ {
+ value = GetPayload(bytes);
+ return true;
+ }
+ var wanted = GetKeyToken(key);
+ byte[]? rented = null;
+ var keyLength = wanted >= 4 ? wanted - 3 : 0;
+ Span encodedKey = keyLength <= 256 ? stackalloc byte[keyLength] : (rented = ArrayPool.Shared.Rent(keyLength)).AsSpan(0, keyLength);
+ try
+ {
+ if (keyLength > 0) Utf8.GetBytes(key, encodedKey);
+ var position = 0;
+ var (count, directoryEnd) = ReadPrefix(bytes, ref position);
+ var directory = bytes.AsSpan(0, directoryEnd);
+ var valuePosition = directoryEnd + ReadVarInt(directory, ref position);
+ for (var i = 1; i < count; i++)
+ {
+ var token = ReadVarInt(directory, ref position);
+ var match = token == wanted;
+ if (token >= 4)
+ {
+ var nameLength = token - 3;
+ match &= directory.Slice(position, nameLength).SequenceEqual(encodedKey);
+ position += nameLength;
+ }
+ var length = ReadVarInt(directory, ref position);
+ if (match)
+ {
+ value = bytes.AsMemory(valuePosition, length);
+ return true;
+ }
+ valuePosition += length;
+ }
+ }
+ finally
+ {
+ if (rented is not null) ArrayPool.Shared.Return(rented);
+ }
+ value = default;
+ return false;
+ }
+
+ internal static IEnumerable GetKeys(byte[] bytes)
+ {
+ var position = 0;
+ var (count, directoryEnd) = ReadPrefix(bytes, ref position);
+ _ = ReadVarInt(bytes.AsSpan(0, directoryEnd), ref position);
+ yield return MessageHeaders.Payload;
+ for (var i = 1; i < count; i++)
+ {
+ var token = ReadVarInt(bytes.AsSpan(0, directoryEnd), ref position);
+ var key = token switch
+ {
+ 1 => MessageHeaders.Subject,
+ 2 => MessageHeaders.Sender,
+ _ => Utf8.GetString(bytes, position, token - 3)
+ };
+ if (token >= 4) position += token - 3;
+ _ = ReadVarInt(bytes.AsSpan(0, directoryEnd), ref position);
+ yield return key;
+ }
+ }
+
+ private static FormatException Invalid(string message, Exception? inner = null) => new(message, inner);
+}
diff --git a/src/Orleans.Messaging/README.md b/src/Orleans.Messaging/README.md
new file mode 100644
index 00000000000..cd50fee59ee
--- /dev/null
+++ b/src/Orleans.Messaging/README.md
@@ -0,0 +1,59 @@
+# Microsoft Orleans Messaging
+
+This intermediate project supplies command identities, extensible envelopes, distinct incoming and outgoing
+messages, handler contracts, and journaled inbox processing. It remains non-packable while the outgoing
+runtime and hosting layers assemble
+`Microsoft.Orleans.Messaging`.
+
+`EnvelopeBuilder` encodes raw bytes and independently Orleans-serialized values into one final,
+exact-length managed array. `Envelope` retains its `HierarchicalKey MessageId` and that array.
+Its required `payload` header can be empty. `subject`, `sender`, and custom ordinal headers are optional.
+Use `AddBytes` for a subject's nonempty UTF-8 bytes and `AddValue` with an externally bound serializer for
+sender or custom values. `TryAddBytes` and `TryAddValue` preserve the first value of an existing header.
+Dispose the builder after building; published envelopes keep their managed bytes.
+
+Payload and raw header retrieval expose `ReadOnlyMemory` slices of the common array. Typed retrieval
+uses a caller-supplied `Serializer` and decodes only the requested header, including a present typed null.
+Enumerating keys decodes custom names on demand. Unknown values remain opaque across copying and serialization.
+A failed value serialization propagates its error and leaves no published entry; later builds include only
+successfully added values. Every build owns independent bytes. Direct staging and local delivery share the
+finalized array, whose contents remain stable after publication. Ordinary Orleans copying and deserialization
+produce independent arrays.
+
+`OutboxMessage` adds a required destination `ReceiverId`. `ToInboxMessage()` shares its envelope with an
+`InboxMessage`, whose receiver is the grain owning the inbox. Pending incoming equality compares exact message
+ID, payload, and optional subject; outgoing equality also compares destination. Equivalent repeated submissions
+retain the original message and all its headers, including sender provenance and custom metadata. Completed
+records deduplicate by receiver-local message ID for their configured lifetime.
+
+Admission validates the exact identity's 1,024 UTF-8 byte and 32 segment limits, required payload, packed counts,
+canonical integers, strict UTF-8 keys, unique names, and directory/value bounds before shared mutation. Present
+subjects have 1 to 256 UTF-8 bytes. Raw handlers accept identity and payload alone; consuming typed protocols
+supply their own subject and sender requirements.
+
+`HierarchicalKey` is a readonly ordinal value with an immutable canonical path and cached hash. `Create` and
+`CreateChildKey` accept literal segments; `Parse` reads escaped canonical paths; `Append` composes hierarchies.
+The default key is unset. Serialization stores the canonical path and reconstructs navigation and hash state.
+
+`IInboxHandler.HandleAsync` receives an `InboxMessage` through its context and explicitly calls `Complete()`.
+Decode local results, do asynchronous preparation, build outgoing messages, and check cancellation before the
+first shared mutation. From that mutation through method return, execute synchronously: apply safe-to-commit
+changes, send through `IOutbox`, complete, and return. The runtime owns persistence and acknowledgement.
+
+The receiver acknowledges admission after its wakeup is scheduled and the journal persists the incoming
+message with the logical generation and exact returned physical job handle. Pending-message conflicts fail
+before scheduling or mutation. Caller cancellation ends the wait while an admitted operation retains its gate
+through the actual persistence outcome. Handler preparation failures follow bounded retry and dead-letter
+policy; errors after Complete preserve the logical outcome through acknowledgement.
+
+Recovery validates pending and dead-letter header buffers before marking the inbox ready or scheduling work.
+Diagnostics expose `DeadLetter` and `DeadLetter`, preserving header
+content while keeping outgoing destinations separate. Stop closes admission and drains actual operations
+before deletion or scope disposal. Storage failures retain their first cause; fresh activations restore only
+the actual journal outcome.
+
+The packed format directly replaces the earlier unreleased five-field envelope. Use fresh journal state when
+adopting this representation. The format is a version byte, compact header count and directory length,
+payload length, keyed value lengths, and contiguous value bytes. Standard keys have reserved identifiers;
+custom names are UTF-8. Values are independent and header offsets are derived from lengths. Generated envelopes
+received from external calls or recovery are validated by the owning admission/recovery boundary.
diff --git a/src/Orleans.Runtime/Timers/GrainTimer.cs b/src/Orleans.Runtime/Timers/GrainTimer.cs
index 525ce76e30a..dc2a1248d2f 100644
--- a/src/Orleans.Runtime/Timers/GrainTimer.cs
+++ b/src/Orleans.Runtime/Timers/GrainTimer.cs
@@ -1,3 +1,4 @@
+using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Reflection;
using Microsoft.Extensions.Logging;
@@ -21,11 +22,18 @@ internal abstract partial class GrainTimer : IGrainTimer
private readonly bool _interleave;
private readonly bool _keepAlive;
private readonly TimerTickInvoker _invoker;
- private bool _changed;
- private bool _firing;
+ private TimerState _state;
+ // The current arm while idle; the next delay once a tick is queued. Change replaces it.
private TimeSpan _dueTime;
private TimeSpan _period;
+ private enum TimerState : byte
+ {
+ Idle,
+ Busy,
+ Disposed
+ }
+
public GrainTimer(TimerRegistry shared, IGrainContext grainContext, bool interleave, bool keepAlive)
{
ArgumentNullException.ThrowIfNull(shared);
@@ -38,17 +46,29 @@ public GrainTimer(TimerRegistry shared, IGrainContext grainContext, bool interle
_dueTime = Timeout.InfiniteTimeSpan;
_period = Timeout.InfiniteTimeSpan;
_invoker = new(this);
-
- // Avoid capturing async locals.
using (new ExecutionContextSuppressor())
{
- _timer = shared.TimeProvider.CreateTimer(TimerCallback, this, Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan);
+ _timer = _shared.TimeProvider.CreateTimer(TimerCallback, this, Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan);
}
-
}
protected IGrainContext GrainContext => _grainContext;
+ // Called with _cts locked, after Change or callback completion.
+ private bool ScheduleNextTick()
+ {
+ Debug.Assert(_state == TimerState.Idle);
+ _timer.Change(_dueTime == TimeSpan.Zero ? Timeout.InfiniteTimeSpan : _dueTime, Timeout.InfiniteTimeSpan);
+ if (_dueTime == TimeSpan.Zero)
+ {
+ _state = TimerState.Busy;
+ _dueTime = _period;
+ return true;
+ }
+
+ return false;
+ }
+
private ILogger Logger => _shared.TimerLogger;
[DoesNotReturn]
@@ -65,13 +85,30 @@ private static void ThrowInvalidSchedulingContext()
protected void ScheduleTickOnActivation()
{
- try
+ lock (_cts)
{
- // Indicate that the timer is firing so that the effect of the next change call is deferred until after the tick completes.
- _firing = true;
+ // Provider callbacks may race Change. A reserved tick stays busy until completion.
+ if (_state != TimerState.Idle || _dueTime == Timeout.InfiniteTimeSpan)
+ {
+ return;
+ }
- // Note: this does not execute on the activation's execution context.
- var msg = _shared.MessageFactory.CreateMessage(body: _invoker, options: InvokeMethodOptions.OneWay);
+ _state = TimerState.Busy;
+ _dueTime = _period;
+ }
+
+ QueueTickOnActivation();
+ }
+
+ // The admission is reserved under _cts, but delivered outside it: activation shutdown disposes
+ // timers under its own lock. Taking that lock while holding _cts would invert the lock order.
+ // Changes during delivery affect the following tick; disposal prevents callback admission.
+ private void QueueTickOnActivation()
+ {
+ try
+ {
+ // Timer requests start a new call chain, including ticks queued immediately during registration.
+ var msg = _shared.MessageFactory.CreateMessage(body: _invoker, options: InvokeMethodOptions.OneWay, requestContextData: null);
msg.SetInfiniteTimeToLive();
msg.SendingGrain = _grainContext.GrainId;
msg.TargetGrain = _grainContext.GrainId;
@@ -88,14 +125,23 @@ protected void ScheduleTickOnActivation()
}
catch (Exception exception)
{
+ lock (_cts)
+ {
+ if (_state != TimerState.Disposed)
+ {
+ _state = TimerState.Idle;
+ // Release failed admission without immediately retrying the failed delivery.
+ _timer.Change(_dueTime == TimeSpan.Zero ? Timeout.InfiniteTimeSpan : _dueTime, Timeout.InfiniteTimeSpan);
+ }
+ }
+
try
{
LogErrorScheduleTickOnActivation(Logger, exception, this);
}
catch
{
- // Ignore.
- // Allowing an exception to escape here would crash the process.
+ // Allowing an exception to escape a physical timer callback would crash the process.
}
}
}
@@ -104,11 +150,20 @@ protected void ScheduleTickOnActivation()
private ValueTask InvokeGrainTimerCallbackAsync()
{
+ lock (_cts)
+ {
+ if (_state == TimerState.Disposed)
+ {
+ return new(Response.Completed);
+ }
+
+ Debug.Assert(_state == TimerState.Busy);
+ }
+
try
{
LogTraceBeforeCallback(Logger, this);
- _changed = false;
GrainTimerEvents.EmitTickStart(GrainContext, this);
var task = InvokeCallbackAsync(_cts.Token);
@@ -138,32 +193,22 @@ private ValueTask InvokeGrainTimerCallbackAsync()
private void OnTickCompleted()
{
- // Schedule the next tick.
- try
+ bool queueTick;
+ lock (_cts)
{
- if (_cts.IsCancellationRequested)
+ if (_state == TimerState.Disposed)
{
- // The instance has been disposed. No further ticks should be fired.
return;
}
- if (!_changed)
- {
- // If the timer was not modified during the tick, schedule the next tick based on the period.
- _timer.Change(_period, Timeout.InfiniteTimeSpan);
- }
- else
- {
- // If the timer was modified during the tick, schedule the next tick based on the new due time.
- _timer.Change(_dueTime, Timeout.InfiniteTimeSpan);
- }
- }
- catch (ObjectDisposedException)
- {
+ Debug.Assert(_state == TimerState.Busy);
+ _state = TimerState.Idle;
+ queueTick = ScheduleNextTick();
}
- finally
+
+ if (queueTick)
{
- _firing = false;
+ QueueTickOnActivation();
}
}
@@ -201,24 +246,28 @@ public void Change(TimeSpan dueTime, TimeSpan period)
{
ValidateArguments(dueTime, period);
- _changed = true;
- _dueTime = dueTime;
- _period = period;
-
- // If the timer is currently firing, the change will be deferred until after the tick completes.
- // Otherwise, perform the change now.
- if (!_firing)
+ var queueTick = false;
+ lock (_cts)
{
- try
+ if (_state == TimerState.Disposed)
{
- // This method resets the timer, so the next tick will be scheduled at the new due time and subsequent
- // ticks will be scheduled after the specified period.
- _timer.Change(dueTime, Timeout.InfiniteTimeSpan);
+ return;
}
- catch (ObjectDisposedException)
+
+ _dueTime = dueTime;
+ _period = period;
+
+ // A queued or running callback keeps its turn; changes schedule work after it completes.
+ if (_state == TimerState.Idle)
{
+ queueTick = ScheduleNextTick();
}
}
+
+ if (queueTick)
+ {
+ QueueTickOnActivation();
+ }
}
private static void ValidateArguments(TimeSpan dueTime, TimeSpan period)
@@ -238,8 +287,21 @@ private static void ValidateArguments(TimeSpan dueTime, TimeSpan period)
public void Dispose()
{
+ lock (_cts)
+ {
+ if (_state == TimerState.Disposed)
+ {
+ return;
+ }
+
+ // Publish disposal before cancellation, whose registrations can reenter Change/Dispose.
+ _state = TimerState.Disposed;
+ _timer.Dispose();
+ }
+
try
{
+ // Do not run cancellation registrations under the timer's state lock.
_cts.Cancel();
}
catch (Exception exception)
@@ -247,8 +309,6 @@ public void Dispose()
LogErrorCancellingCallback(Logger, exception);
}
- _timer.Dispose();
-
GrainTimerEvents.EmitDisposed(GrainContext, this);
var timerRegistry = _grainContext.GetComponent();
diff --git a/src/api/Orleans.Journaling/Orleans.Journaling.cs b/src/api/Orleans.Journaling/Orleans.Journaling.cs
index 396a1692cad..76ff42769dd 100644
--- a/src/api/Orleans.Journaling/Orleans.Journaling.cs
+++ b/src/api/Orleans.Journaling/Orleans.Journaling.cs
@@ -219,8 +219,20 @@ public partial interface IDurableValue
T? Value { get; set; }
}
+ public partial interface IJournaledStateCaptureHook : IJournaledStateHook
+ {
+ }
+
+ public partial interface IJournaledStateHook
+ {
+ System.Threading.Tasks.ValueTask AfterOperationAsync(JournaledStateOperation operation, System.Threading.CancellationToken cancellationToken);
+ System.Threading.Tasks.ValueTask BeforeOperationAsync(JournaledStateOperation operation, System.Threading.CancellationToken cancellationToken);
+ }
+
public partial interface IJournaledStateManager : System.IAsyncDisposable
{
+ System.Collections.Generic.IList Hooks { get; }
+
long PendingWriteByteCount { get; }
System.Threading.Tasks.ValueTask DeleteStateAsync(System.Threading.CancellationToken cancellationToken = default);
@@ -424,6 +436,25 @@ public sealed partial class JournaledStateManagerOptions
public System.TimeSpan RetirementGracePeriod { get { throw null; } set { } }
}
+ public enum JournaledStateOperation
+ {
+ Write = 0,
+ Snapshot = 1,
+ Delete = 2
+ }
+
+ [GenerateSerializer]
+ public sealed partial class JournaledStatePostCommitException : System.Exception
+ {
+ public JournaledStatePostCommitException(JournaledStateOperation operation, System.Exception innerException) { }
+ }
+
+ [GenerateSerializer]
+ public sealed partial class JournaledStatePreCommitException : System.Exception
+ {
+ public JournaledStatePreCommitException(JournaledStateOperation operation, System.Exception innerException) { }
+ }
+
public readonly ref partial struct JournalEntry
{
private readonly object _dummy;
@@ -800,10 +831,62 @@ public void WriteField(ref global::Orleans.Serialization.Buffers.
where TBufferWriter : System.Buffers.IBufferWriter { }
}
+ [System.CodeDom.Compiler.GeneratedCode("OrleansCodeGen", "10.0.0.0")]
+ [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)]
+ [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
+ public sealed partial class Codec_JournaledStatePostCommitException : global::Orleans.Serialization.Codecs.IFieldCodec, global::Orleans.Serialization.Codecs.IFieldCodec
+ {
+ public Codec_JournaledStatePostCommitException(global::Orleans.Serialization.Serializers.IBaseCodec _baseTypeSerializer, global::Orleans.Serialization.Activators.IActivator _activator) { }
+
+ public void Deserialize(ref global::Orleans.Serialization.Buffers.Reader reader, global::Orleans.Journaling.JournaledStatePostCommitException instance) { }
+
+ public global::Orleans.Journaling.JournaledStatePostCommitException ReadValue(ref global::Orleans.Serialization.Buffers.Reader reader, global::Orleans.Serialization.WireProtocol.Field field) { throw null; }
+
+ public void Serialize(ref global::Orleans.Serialization.Buffers.Writer writer, global::Orleans.Journaling.JournaledStatePostCommitException instance)
+ where TBufferWriter : System.Buffers.IBufferWriter { }
+
+ public void WriteField(ref global::Orleans.Serialization.Buffers.Writer writer, uint fieldIdDelta, System.Type expectedType, global::Orleans.Journaling.JournaledStatePostCommitException value)
+ where TBufferWriter : System.Buffers.IBufferWriter { }
+ }
+
+ [System.CodeDom.Compiler.GeneratedCode("OrleansCodeGen", "10.0.0.0")]
+ [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)]
+ [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
+ public sealed partial class Codec_JournaledStatePreCommitException : global::Orleans.Serialization.Codecs.IFieldCodec, global::Orleans.Serialization.Codecs.IFieldCodec
+ {
+ public Codec_JournaledStatePreCommitException(global::Orleans.Serialization.Serializers.IBaseCodec _baseTypeSerializer, global::Orleans.Serialization.Activators.IActivator _activator) { }
+
+ public void Deserialize(ref global::Orleans.Serialization.Buffers.Reader reader, global::Orleans.Journaling.JournaledStatePreCommitException instance) { }
+
+ public global::Orleans.Journaling.JournaledStatePreCommitException ReadValue(ref global::Orleans.Serialization.Buffers.Reader reader, global::Orleans.Serialization.WireProtocol.Field field) { throw null; }
+
+ public void Serialize(ref global::Orleans.Serialization.Buffers.Writer writer, global::Orleans.Journaling.JournaledStatePreCommitException instance)
+ where TBufferWriter : System.Buffers.IBufferWriter { }
+
+ public void WriteField(ref global::Orleans.Serialization.Buffers.Writer writer, uint fieldIdDelta, System.Type expectedType, global::Orleans.Journaling.JournaledStatePreCommitException value)
+ where TBufferWriter : System.Buffers.IBufferWriter { }
+ }
+
[System.CodeDom.Compiler.GeneratedCode("OrleansCodeGen", "10.0.0.0")]
[System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)]
[System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
public sealed partial class Copier_DurableTaskCompletionSourceState : global::Orleans.Serialization.Cloning.ShallowCopier>
{
}
+
+ [System.CodeDom.Compiler.GeneratedCode("OrleansCodeGen", "10.0.0.0")]
+ [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)]
+ [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
+ public sealed partial class Copier_JournaledStatePostCommitException : global::Orleans.Serialization.GeneratedCodeHelpers.OrleansGeneratedCodeHelper.ExceptionCopier
+ {
+ public Copier_JournaledStatePostCommitException(global::Orleans.Serialization.Serializers.ICodecProvider codecProvider) : base(default(Serialization.Serializers.ICodecProvider)!) { }
+ }
+
+ [System.CodeDom.Compiler.GeneratedCode("OrleansCodeGen", "10.0.0.0")]
+ [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)]
+ [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
+ public sealed partial class Copier_JournaledStatePreCommitException : global::Orleans.Serialization.GeneratedCodeHelpers.OrleansGeneratedCodeHelper.ExceptionCopier
+ {
+ public Copier_JournaledStatePreCommitException(global::Orleans.Serialization.Serializers.ICodecProvider codecProvider) : base(default(Serialization.Serializers.ICodecProvider)!) { }
+ }
}
\ No newline at end of file
diff --git a/src/api/Orleans.Messaging/Orleans.Messaging.cs b/src/api/Orleans.Messaging/Orleans.Messaging.cs
new file mode 100644
index 00000000000..5e5e5794dea
--- /dev/null
+++ b/src/api/Orleans.Messaging/Orleans.Messaging.cs
@@ -0,0 +1,451 @@
+//------------------------------------------------------------------------------
+//
+// This code was generated by a tool.
+//
+// Changes to this file may cause incorrect behavior and will be lost if
+// the code is regenerated.
+//
+//------------------------------------------------------------------------------
+namespace Orleans.Messaging
+{
+ public sealed partial class DeadLetter
+ {
+ public int AttemptCount { get { throw null; } init { } }
+
+ public System.DateTimeOffset DeadLetteredAt { get { throw null; } init { } }
+
+ public required TMessage Message { get { throw null; } init { } }
+
+ public required string Reason { get { throw null; } init { } }
+ }
+
+ [GenerateSerializer]
+ [Alias("Orleans.Messaging.DeliveryResult")]
+ public readonly partial struct DeliveryResult
+ {
+ private readonly object _dummy;
+ private readonly int _dummyPrimitive;
+ [Id(1)]
+ public string? Message { get { throw null; } init { } }
+
+ [Id(0)]
+ public DeliveryStatus Status { get { throw null; } init { } }
+
+ public static DeliveryResult Accepted() { throw null; }
+
+ public static DeliveryResult Backpressured() { throw null; }
+
+ public static DeliveryResult DeadLettered(string reason) { throw null; }
+
+ public static DeliveryResult Duplicate() { throw null; }
+
+ public static DeliveryResult HandlerNotFound() { throw null; }
+ }
+
+ public enum DeliveryStatus
+ {
+ Accepted = 0,
+ Duplicate = 1,
+ Backpressured = 2,
+ HandlerNotFound = 3,
+ DeadLettered = 4
+ }
+
+ [GenerateSerializer]
+ [Alias("Orleans.Messaging.Envelope")]
+ public readonly partial struct Envelope
+ {
+ private readonly object _dummy;
+ private readonly int _dummyPrimitive;
+ public System.ReadOnlyMemory EncodedHeaders { get { throw null; } }
+
+ public System.Collections.Generic.IEnumerable Keys { get { throw null; } }
+
+ [Id(0)]
+ public HierarchicalKey MessageId { get { throw null; } }
+
+ public System.ReadOnlyMemory Payload { get { throw null; } }
+
+ public static Envelope FromEncodedHeaders(HierarchicalKey messageId, System.ReadOnlySpan encodedHeaders) { throw null; }
+
+ public readonly bool TryGetBytes(string key, out System.ReadOnlyMemory value) { throw null; }
+
+ public readonly bool TryGetSubject(out string? subject) { throw null; }
+
+ public readonly bool TryGetValue(string key, Serialization.Serializer serializer, out T? value) { throw null; }
+ }
+
+ public sealed partial class EnvelopeBuilder : System.IDisposable, System.Buffers.IBufferWriter
+ {
+ public EnvelopeBuilder(HierarchicalKey messageId) { }
+
+ public void AddBytes(string key, System.ReadOnlySpan value) { }
+
+ public void AddValue(string key, T? value, Serialization.Serializer serializer) { }
+
+ public Envelope Build() { throw null; }
+
+ public void Dispose() { }
+
+ void System.Buffers.IBufferWriter.Advance(int count) { }
+
+ System.Memory System.Buffers.IBufferWriter.GetMemory(int sizeHint) { throw null; }
+
+ System.Span System.Buffers.IBufferWriter.GetSpan(int sizeHint) { throw null; }
+
+ public bool TryAddBytes(string key, System.ReadOnlySpan value) { throw null; }
+
+ public bool TryAddValue(string key, T? value, Serialization.Serializer serializer) { throw null; }
+ }
+
+ [Immutable]
+ [Alias("Orleans.Messaging.HierarchicalKey")]
+ public readonly partial struct HierarchicalKey : System.ISpanFormattable, System.IFormattable, System.IEquatable, System.IParsable, System.ISpanParsable
+ {
+ private readonly object _dummy;
+ private readonly int _dummyPrimitive;
+ public const char EscapeCharacter = '\\';
+ public const char SegmentSeparator = '/';
+ public bool IsDefault { get { throw null; } }
+
+ public int Length { get { throw null; } }
+
+ public int SegmentCount { get { throw null; } }
+
+ public readonly HierarchicalKey Append(HierarchicalKey suffix) { throw null; }
+
+ public static HierarchicalKey Create(scoped params System.ReadOnlySpan values) { throw null; }
+
+ public static HierarchicalKey Create(string value) { throw null; }
+
+ public readonly HierarchicalKey CreateChildKey(string value) { throw null; }
+
+ public readonly bool Equals(HierarchicalKey other) { throw null; }
+
+ public override readonly bool Equals(object? obj) { throw null; }
+
+ public readonly SegmentEnumerator GetEnumerator() { throw null; }
+
+ public override readonly int GetHashCode() { throw null; }
+
+ public readonly HierarchicalKey? GetParent() { throw null; }
+
+ public readonly bool IsAncestorOf(HierarchicalKey other) { throw null; }
+
+ public readonly bool IsChildOf(HierarchicalKey other) { throw null; }
+
+ public readonly bool IsParentOf(HierarchicalKey other) { throw null; }
+
+ public static bool operator ==(HierarchicalKey left, HierarchicalKey right) { throw null; }
+
+ public static bool operator !=(HierarchicalKey left, HierarchicalKey right) { throw null; }
+
+ static HierarchicalKey System.ISpanParsable.Parse(System.ReadOnlySpan s, System.IFormatProvider? provider) { throw null; }
+
+ static HierarchicalKey System.IParsable.Parse(string s, System.IFormatProvider? provider) { throw null; }
+
+ public override readonly string ToString() { throw null; }
+
+ public readonly string ToString(string? format, System.IFormatProvider? formatProvider) { throw null; }
+
+ public readonly bool TryFormat(System.Span destination, out int charsWritten, System.ReadOnlySpan format, System.IFormatProvider? provider) { throw null; }
+
+ static bool System.ISpanParsable.TryParse(System.ReadOnlySpan s, System.IFormatProvider? provider, out HierarchicalKey result) { throw null; }
+
+ static bool System.IParsable.TryParse(string? s, System.IFormatProvider? provider, out HierarchicalKey result) { throw null; }
+
+ public ref partial struct SegmentEnumerator
+ {
+ private object _dummy;
+ private int _dummyPrimitive;
+ public System.ReadOnlySpan Current { get { throw null; } }
+
+ public bool MoveNext() { throw null; }
+ }
+ }
+
+ public partial interface IInbox
+ {
+ int Capacity { get; }
+
+ int Count { get; }
+
+ System.Collections.Generic.IEnumerable Messages { get; }
+
+ void RegisterHandler(IInboxHandler handler);
+ bool TryGetMessage(HierarchicalKey messageId, out InboxMessage message);
+ }
+
+ [Alias("IInboxExtension")]
+ public partial interface IInboxExtension : Runtime.IGrainExtension, Runtime.IAddressable
+ {
+ [Alias("DeliverAsync")]
+ System.Threading.Tasks.ValueTask DeliverAsync(InboxMessage message, System.Threading.CancellationToken cancellationToken = default);
+ }
+
+ public partial interface IInboxHandler
+ {
+ System.Threading.Tasks.ValueTask HandleAsync(IInboxHandlerContext context, System.Threading.CancellationToken cancellationToken);
+ }
+
+ public partial interface IInboxHandlerContext
+ {
+ InboxMessage Message { get; }
+
+ void Complete();
+ }
+
+ public partial interface IMessagingDiagnostics
+ {
+ System.Collections.Generic.IReadOnlyList> InboxDeadLetters { get; }
+
+ System.Collections.Generic.IReadOnlyList> OutboxDeadLetters { get; }
+
+ bool RemoveInboxDeadLetter(HierarchicalKey messageId);
+ bool RemoveOutboxDeadLetter(HierarchicalKey messageId);
+ }
+
+ public partial interface IMessagingGrain
+ {
+ }
+
+ [GenerateSerializer]
+ [Alias("Orleans.Messaging.InboxMessage")]
+ public readonly partial struct InboxMessage
+ {
+ [Id(0)]
+ public required Envelope Envelope { get { throw null; } init { } }
+ }
+
+ public partial interface IOutbox
+ {
+ int Count { get; }
+
+ System.Collections.Generic.IEnumerable Messages { get; }
+
+ Runtime.GrainId SenderId { get; }
+
+ void Send(OutboxMessage message);
+ bool TryGetMessage(HierarchicalKey messageId, out OutboxMessage message);
+ }
+
+ public static partial class MessageHeaders
+ {
+ public const string Payload = "payload";
+ public const string Sender = "sender";
+ public const string Subject = "subject";
+ }
+
+ [GenerateSerializer]
+ [Alias("Orleans.Messaging.OutboxMessage")]
+ public readonly partial struct OutboxMessage
+ {
+ [Id(0)]
+ public required Envelope Envelope { get { throw null; } init { } }
+
+ [Id(1)]
+ public required Runtime.GrainId ReceiverId { get { throw null; } init { } }
+
+ public readonly InboxMessage ToInboxMessage() { throw null; }
+ }
+}
+
+namespace Orleans.Messaging.Configuration
+{
+ public partial class InboxOptions
+ {
+ public System.TimeSpan BackpressureRetryDelay { get { throw null; } set { } }
+
+ public System.TimeSpan DeadLetterRetentionPeriod { get { throw null; } set { } }
+
+ public System.TimeSpan DeduplicationWindow { get { throw null; } set { } }
+
+ public int InboxBatchSize { get { throw null; } set { } }
+
+ public int MaxCapacity { get { throw null; } set { } }
+
+ public int MaxDeliveryAttempts { get { throw null; } set { } }
+
+ public System.TimeSpan MaxOutboxRetryAge { get { throw null; } set { } }
+
+ public int MaxProcessingAttempts { get { throw null; } set { } }
+
+ public int MaxRetainedDeadLetters { get { throw null; } set { } }
+
+ public int OutboxBatchSize { get { throw null; } set { } }
+
+ public System.TimeSpan OutboxIdleRetirementGracePeriod { get { throw null; } set { } }
+
+ public void Validate() { }
+ }
+}
+
+namespace OrleansCodeGen.Orleans.Messaging
+{
+ [System.CodeDom.Compiler.GeneratedCode("OrleansCodeGen", "10.0.0.0")]
+ [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)]
+ [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
+ public sealed partial class Codec_DeliveryResult : global::Orleans.Serialization.Codecs.IFieldCodec, global::Orleans.Serialization.Codecs.IFieldCodec, global::Orleans.Serialization.Serializers.IValueSerializer, global::Orleans.Serialization.Serializers.IValueSerializer
+ {
+ public Codec_DeliveryResult(global::Orleans.Serialization.Serializers.ICodecProvider codecProvider) { }
+
+ public void Deserialize(ref global::Orleans.Serialization.Buffers.Reader reader, scoped ref global::Orleans.Messaging.DeliveryResult instance) { }
+
+ public global::Orleans.Messaging.DeliveryResult ReadValue