Egil.Orleans.Messaging.Streams.EventHubs
0.1.80
dotnet add package Egil.Orleans.Messaging.Streams.EventHubs --version 0.1.80
NuGet\Install-Package Egil.Orleans.Messaging.Streams.EventHubs -Version 0.1.80
<PackageReference Include="Egil.Orleans.Messaging.Streams.EventHubs" Version="0.1.80" />
<PackageVersion Include="Egil.Orleans.Messaging.Streams.EventHubs" Version="0.1.80" />
<PackageReference Include="Egil.Orleans.Messaging.Streams.EventHubs" />
paket add Egil.Orleans.Messaging.Streams.EventHubs --version 0.1.80
#r "nuget: Egil.Orleans.Messaging.Streams.EventHubs, 0.1.80"
#:package Egil.Orleans.Messaging.Streams.EventHubs@0.1.80
#addin nuget:?package=Egil.Orleans.Messaging.Streams.EventHubs&version=0.1.80
#tool nuget:?package=Egil.Orleans.Messaging.Streams.EventHubs&version=0.1.80
Egil.Orleans.Messaging
Composable messaging infrastructure for Microsoft Orleans grains.
Egil.Orleans.Messaging provides building blocks for grains that need durable state changes and durable message handoff to move together:
IStateManager<T>wrapsIPersistentState<T>so a grain does not keep observing uncommitted state after ambiguous write failures.Outbox<T>stores messages alongside grain state and assigns durable sender sequence tokens.OutboxProcessor<T>dispatches pending outbox items through registered postmen, with retry, reminder forwarding, failure reconciliation, and telemetry.MessageTrackerrecords receiver-side high-water marks for outbox messages and Orleans streams.StreamManagergives grains a fluent subscription facade with resume-token and handler-error support.
Install
dotnet add package Egil.Orleans.Messaging
Provider-specific integrations are shipped as companion packages:
dotnet add package Egil.Orleans.Messaging.Streams.EventHubs
dotnet add package Egil.Orleans.Messaging.State.AzureStorage
Use the capability namespaces for the tools you need:
using Egil.Orleans.Messaging.Outboxes;
using Egil.Orleans.Messaging.State;
using Egil.Orleans.Messaging.Streams;
using Egil.Orleans.Messaging.Tracking;
Registration extension members live with the Orleans, hosting, and DI types they extend:
using Microsoft.Extensions.DependencyInjection;
using Orleans;
using Orleans.Hosting;
State Manager
Register the default state manager factory on the silo:
siloBuilder.AddDefaultStateManager("state");
For Orleans Azure Table or Blob grain storage, install and configure the
Orleans storage provider separately. The Messaging companion works through
IPersistentState<T> and Azure SDK exceptions; it does not select or install
the underlying provider. Install Egil.Orleans.Messaging.State.AzureStorage
and register the Azure-aware factory instead:
siloBuilder.AddAzureStorageStateManager("state");
The Azure-aware manager uses Azure SDK RequestFailedException.Status and
ErrorCode values to decide recovery. Optimistic-concurrency and rejected
request failures such as HTTP 412, 409, 404, authentication/authorization
failures, and payload/validation failures are treated as definite
non-persistence, so writes and clears fail fast without an unnecessary
recovery read. Ambiguous or transient outcomes, including HTTP 503
ServerBusy, HTTP 500 OperationTimedOut, HTTP 429 throttling, no-response
failures, and timeout exceptions, still use read-back recovery.
Then wrap the Orleans persistent state facet during activation:
public sealed class OrderGrain(
[PersistentState("state", "Default")] IPersistentState<OrderState> storage)
: Grain, IOrderGrain
{
private IStateManager<OrderState> state = default!;
private OrderState CurrentState =>
state.State ?? throw new InvalidOperationException("Order state is not initialized.");
public override Task OnActivateAsync(CancellationToken cancellationToken)
{
state = this.RegisterStateManager("state", storage);
return Task.CompletedTask;
}
public async Task RenameAsync(string name)
{
await state.WriteAsync(CurrentState with { Name = name });
}
}
State is nullable because persistent storage can expose no value, including
after ClearAsync. Initialize missing state or guard it before dereferencing,
as CurrentState does above. Later snippets use state.State! only to keep
their focus narrow and assume the surrounding grain has already established
that invariant.
State types must be reference types and implement IEquatable<T>. For
non-trivial state graphs, inherit from VersionedState so the recovery path
compares a library-stamped version rather than relying on structural
collection equality.
Outbox
Store an Outbox<T> on the grain state and commit messages with the business state change:
[GenerateSerializer]
public sealed record OrderState : VersionedState
{
[Id(0)] public string? Name { get; init; }
[Id(1)] public Outbox<IOrderEvent> Outbox { get; init; } =
Outbox<IOrderEvent>.Create(GrainId.Create("order", "example"));
}
public async Task SubmitAsync()
{
var next = state.State! with
{
Outbox = state.State!.Outbox.Add(new OrderSubmitted())
};
await state.WriteAsync(next);
await outboxProcessor.PostInBackgroundAsync();
}
Add(message) uses system UTC. When a grain uses an injected clock, sample it
at the call site and pass the instant with
Add(message, timeProvider.GetUtcNow()). The persisted outbox never retains
the provider, so serialization and state rehydration need no clock
re-registration.
Use OutboxProcessor<T> to dispatch pending items and acknowledge only the items that were posted successfully. Use OutboxMessageEnvelope<T> as the processor item type so the acknowledgement callback can remove exactly the posted items by token:
public sealed class OrderGrain : Grain, IOutboxGrain
{
private OutboxProcessor<OutboxMessageEnvelope<IOrderEvent>> outboxProcessor = default!;
public override Task OnActivateAsync(CancellationToken cancellationToken)
{
outboxProcessor = this.RegisterOutboxProcessor(new OutboxProcessorOptions<OutboxMessageEnvelope<IOrderEvent>>
{
PendingItems = () => [.. state.State!.Outbox],
AcknowledgePostedAsync = async (items, ct) =>
{
await state.WriteAsync(state.State! with
{
Outbox = state.State!.Outbox.RemoveRange(items.Select(item => item.Token))
});
},
ReconcileFailedAsync = (_, _) => ValueTask.CompletedTask,
})
.AddPostman<OutboxMessageEnvelope<IOrderEvent>>(
envelope => PublishSubmittedAsync(envelope.Message));
return Task.CompletedTask;
}
}
AcknowledgePostedAsync receives exactly the items that posted successfully —
not necessarily a contiguous prefix of the pending list. Different message
types dispatch through different postmen concurrently, and an item without a
matching postman fails in place while later items can still succeed. Never
acknowledge by position (for example outbox.Take(items.Length)); that can
remove a failed item and lose it. When the processor item type is a bare
message type instead of the envelope, the callback must map each received item
back to its token — which is only reliable when payloads are unique — so
prefer the envelope form above unless per-message-type postman registration
(shown below) is required.
IOutboxGrain forwards reminder ticks to the single attached processor.
Register exactly one processor per grain activation; a second registration
throws. Add multiple postmen to that processor when item subtypes need
different delivery behavior. The grain remains responsible for its own
message contracts, posting target, and dead-letter policy.
Postman matching is first-match-wins: register specific message types before
base interfaces or catch-all handlers.
Failed dispatches are reported through ReconcileFailedAsync. That callback
is where the owning grain applies retry, dead-letter, max-depth, or trimming
policy, because the grain owns the durable outbox state. The attempt counts
passed to the callback are in-memory per activation (and pruned once an item
is no longer pending), so policies that must survive activation restarts need
to persist their own counters on the items or grain state.
The outbox tools do not require the state manager. When persisting the outbox
with plain IPersistentState<T> writes, the pipeline stays at-least-once on
its own: items only leave durable state when the grain removes them in
AcknowledgePostedAsync after a successful post, so a failed or ambiguous
state write leaves them pending and at worst causes duplicate delivery, never
loss. Be aware that Outbox<T>.Equals is an O(1) fingerprint (sender,
sequence metadata, count, and first/last pending tokens), not deep payload
equality — safe for dirty-checks and write recovery, but not a substitute for
comparing message contents item by item.
If a post run fails before reconciliation completes — for example when the
run exceeds ProcessingTimeout or an acknowledgement callback throws — the
processor arms its retry timer and durable reminder before rethrowing, so
pending items are retried without requiring another explicit post. Successful
posts never pay reminder I/O: PostInBackgroundAsync schedules an in-memory
grain timer only, and the durable reminder is registered lazily when a run
fails or leaves items pending.
Background outbox postage allows unrelated grain calls to continue while
postmen await I/O by default. IPostman<T> services should be state-free with
respect to the owning grain. Inline lambda postmen may read activation-local
state, but should not write it; durable changes belong in
AcknowledgePostedAsync or ReconcileFailedAsync.
Postmen run on Orleans' activation scheduler, not on the .NET thread pool.
Acknowledgement and failure callbacks are non-interleaving by default, so they
do not interleave with normal grain calls unless
InterleaveReconciliationCallbacks is enabled. Reentrant grains can still
interleave according to Orleans' normal scheduling rules.
Pending items in a post run are dispatched concurrently. Successful items are
still acknowledged as one ordered batch after all dispatches complete, and
failed items are reconciled as one batch.
For reusable delivery code, implement and register keyed postman services:
[OutboxPostman("orders")]
public sealed class OrderEventPostman : IPostman<OrderSubmitted>
{
public async ValueTask PostAsync(OrderSubmitted message, CancellationToken ct)
{
await publisher.PublishAsync(message, ct);
}
}
services.AddOutboxPostman<OrderEventPostman>();
Then resolve the postman by name from the grain activation service provider:
outboxProcessor = this.RegisterOutboxProcessor(options)
.AddPostman<OrderSubmitted>("orders");
For common Orleans targets, use the built-in helpers instead of writing the callback by hand:
outboxProcessor = this.RegisterOutboxProcessor(options)
.AddStreamPostman<OrderSubmitted>(
"order-streams",
message => StreamId.Create("submitted-orders", message.OrderId));
outboxProcessor = this.RegisterOutboxProcessor(options)
.AddGrainPostman<OrderSubmitted, IOrderProjectionGrain>(
(message, grainFactory) => grainFactory.GetGrain<IOrderProjectionGrain>(message.OrderId),
(grain, message) => grain.ApplyAsync(message));
Receiver Dedup
MessageTracker accepts a message only when its stream token, stream cursor, or outbox token advances the stored high-water mark:
if (!state.State!.Tracker.ProcessMessage("prices", token, out var tracker))
{
return;
}
await state.WriteAsync(state.State! with { Tracker = tracker });
Use LatestStreamSequenceToken("prices") when all you need is the previous
resume token. Keep using LatestStream("prices") when you need the full
cursor or must distinguish "no stream tracked" from "tracked stream with a
null token".
The tracker can also evict old sender or stream entries when your retention policy allows it.
Streams
Use StreamManager to configure stream subscriptions from OnActivateAsync. Pass a tracker snapshot when you want persisted resume tokens, or omit it when the grain does not track stream positions:
streamManager = this.RegisterStreamManager(state.State!.Tracker)
.ConfigureExplicitSubscription<PriceChanged>(
"StreamProvider",
"prices",
async (message, cursor) =>
{
if (!state.State!.Tracker.ProcessMessage(cursor, out var tracker))
{
return;
}
await state.WriteAsync(state.State! with { Tracker = tracker });
});
await streamManager.EnsureExplicitSubscriptionsAsync(cancellationToken);
The string namespace overload derives a stream id from the complete receiving
GrainId, including its grain type and compound-key extension. Publishers must
use the same helper with the target grain identity:
var customer = grainFactory.GetGrain<ICustomerGrain>(customerId);
var streamId = StreamManager.CreateStreamId("prices", customer.GetGrainId());
var stream = streamProvider.GetStream<PriceChanged>(streamId);
This convention follows the grain type, so renaming that type changes the
derived stream id. Use the StreamId overload for an application-owned id that
must survive grain-type changes, or when a custom grain identity cannot
round-trip through Orleans' textual GrainId representation:
streamManager = this.RegisterStreamManager(state.State!.Tracker)
.ConfigureExplicitSubscription<PriceChanged>(
"StreamProvider",
StreamId.Create("prices", customerId),
HandlePriceChangedAsync);
The previous key-only convention is not compatible with these full-identity
stream ids. Recreate existing durable subscriptions and update publishers
together, or preserve the previous id through the explicit StreamId overload.
Tracked resume tokens are a per-subscription choice. The default is to pass the previous token when a tracker snapshot is supplied. Opt out when a subscription should attach without a resume token:
streamManager = this.RegisterStreamManager(state.State!.Tracker)
.ConfigureExplicitSubscription<PriceChanged>(
"StreamProvider",
"prices",
HandlePriceChangedAsync,
useTrackedResumeToken: false);
this.RegisterStreamManager()
.ConfigureImplicitSubscription<PriceChanged>(
"prices",
async (message, cursor) => await UpdateProjectionAsync(message));
Install Egil.Orleans.Messaging.Streams.EventHubs when using Orleans Event
Hubs streams and the enriched adapter/token support:
using Egil.Orleans.Messaging.Streams.EventHubs;
using Orleans.Hosting;
Registering the enriched adapter also registers Event Hubs sequence-token JSON
converters, so MessageTracker and StreamCursor can persist and restore
EnrichedEventHubSequenceToken without downcasting it to the Orleans base
event token:
siloBuilder.AddEventHubStreams("event-hubs", configurator =>
{
configurator.UseEnrichedDataAdapter();
});
The core package can consume provider-specific token metadata through
IStreamSequenceTokenMetadata without taking a direct Event Hubs dependency.
Custom stream providers that expose custom StreamSequenceToken types should
register a JsonConverter<TToken> with StreamSequenceTokenJsonConverters
during startup.
Scope
This package is messaging infrastructure, not an event-sourcing or CQRS framework. It wraps Orleans state, outbox dispatch, receiver deduplication, and stream subscription management while leaving domain modeling, read models, transport targets, and operational policy to the application.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- Egil.Orleans.Messaging (>= 0.1.80)
- Microsoft.Orleans.Streaming.EventHubs (>= 10.1.0)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 0.1.80 | 56 | 8/10/2026 |
BREAKING CHANGES:
- prevent grain-derived stream ID collisions
Derive namespace-based explicit subscription IDs from the complete Orleans GrainId and expose the same helper to producers. Reject textual identities that do not round-trip instead of silently collapsing them.
Cover compound keys, hex-parseable strings, cross-kind collisions, Orleans binary serialization, and StreamId text parsing. Document producer wiring and migration.
- split provider-specific messaging packages
Move Event Hubs stream enrichment into Egil.Orleans.Messaging.Streams.EventHubs so the core package no longer carries the Event Hubs dependency.
Add Egil.Orleans.Messaging.State.AzureStorage with Azure Table/Blob storage-aware state manager registration and failure classification for optimistic concurrency failures.
Rename WriteFailureKind to StorageFailureKind so write and clear recovery can share the same provider classification model.
Update CI to pack all messaging packages and document the companion package model.
- make stream resume tokens configurable
Make MessageTracker fully optional for StreamManager registration and let each implicit or explicit subscription opt out of passing the tracked resume token to Orleans.
Remove the StreamCursor constructor that accepted StreamId so cursors are built from the stream namespace directly. Add tests and docs for the resume-token policy and include the outbox postman API plan.
- split messaging tools by capability
Move messaging tools into capability namespaces and matching test folders, with Outboxes used to avoid a namespace/type name collision.
Replace StreamManager's AddSubscription/SubscribeAsync surface with explicit implicit/explicit configuration APIs and IImplicitStreamGrain forwarding through an attached grain component.
New Features:
- coordinate outbox postman processing
Refactor outbox processing into dedicated dispatch, postman registry, and reconciliation collaborators.
Coordinate manual and timer-backed drains through a single active drain so callers wait for the current pass before processing the next pending snapshot. Preserve per-postman ordering while allowing different postmen to dispatch concurrently.
Remove the prototype OutboxProcessor2 implementation from the compiled package.
- preserve stream token json types
Add an explicit JsonConverter registry for concrete StreamSequenceToken types and route StreamCursor and MessageTracker token payloads through that registry.
Register Event Hubs token converters from the enriched adapter setup so enriched checkpoints round-trip without downcasting provider metadata.
- post outbox items concurrently
Dispatch pending outbox items concurrently on the Orleans activation scheduler and remove the thread-pool execution mode.
Background posting now allows delivery to interleave by default while durable acknowledgement and failure reconciliation remain non-interleaving unless explicitly opted in.
- classify azure storage failures
Classify deterministic Azure Storage failures as non-persistence outcomes and keep ambiguous transient results on the read-back recovery path.
This lets StateManager skip unnecessary recovery reads for rejected mutations while preserving correctness for throttling, timeouts, and service failures.
- add outbox postman helper APIs
Add built-in stream and grain postman helpers so common Orleans fan-out targets can use the outbox processor without hand-written dispatch callbacks.
Cover helper behavior through Orleans tests and fill validation coverage for keyed postman registration edge cases.
- add keyed outbox postman services
Add IPostman<TMessage>, named postman attributes, IServiceCollection registration helpers, and an OutboxProcessor.AddPostman overload that resolves keyed postman services from the grain activation service provider.
Cover keyed registration, scoped multi-contract resolution, successful keyed dispatch, and keyed dispatch failure reconciliation.
- add stream token tracker overloads
Allow MessageTracker callers to process stream sequence tokens directly with explicit stream namespace or provider-qualified namespace, and expose latest stream sequence token lookup for resume-token use cases.
Clarify stream manager tests by using distinct provider-name and namespace constants for explicit subscriptions.
- make stream tracking optional
Allow grains to register StreamManager without providing a MessageTracker when they do not persist stream high-water marks.
The tracked overloads remain source-compatible and still supply resume tokens when a tracker snapshot is provided.
- implement outbox processor stream flow
Implements the outbox processor registration and draining path, including postman dispatch, success acknowledgement, failure reconciliation, retry timer/reminder scheduling, and stream-backed integration coverage.
Adds outbox and stream receive telemetry plus focused Orleans in-process tests for stream delivery, error paths, resume behavior, JSON converter branches, and tracker semantics.
- implement stream manager subscriptions
StreamManager now separates subscription configuration from activation-time subscription setup via RegisterStreamManager, AddSubscription, and SubscribeAsync. This lets grains await Orleans stream subscription establishment without blocking activation and keeps subscription failures visible.
MessageTracker now records receive-lag histograms for accepted enriched stream cursors and outbox tokens, with shared telemetry plumbing kept in MessagingTelemetry. The Messaging test suite adds an in-process Orleans cluster with memory streams covering ValueTask, Task, default error handling, and resume-token behavior.
- add keyed state manager registration and remove write policy
Add InitializeStateManager(...) that resolves keyed IStateManagerFactory<T> by storage name, with IServiceCollection and ISiloBuilder helper APIs for keyed singleton registrations.
Refactor state management to StateManagerBase<T>/DefaultStateManager<T>, remove WritePolicy and force-write behavior, and drop reflection-based ETag mutation.
Update state manager tests and API design docs to match the new factory wiring and write semantics.
- implement enriched event hub adapter token stamping
Implement EnrichedEventHubAdapter overrides to stamp traceparent on queued messages, create EnrichedEventHubSequenceToken from cached Event Hub messages, and return enriched stream positions that preserve offset/sequence/event index and optional traceparent.
Add focused tests for traceparent stamping behavior and enriched token construction from cached message metadata.
- wire enriched event hub data adapter registration
Implement UseEnrichedDataAdapter extension to validate input and register EnrichedEventHubAdapter through the Event Hub configurator using Orleans Serializer from DI.
Add focused tests for null-argument validation and configurator callback registration.
- implement StreamCursor enriched token accessors
Enable StreamCursor JSON converter attribute and implement TryGetEnqueuedTime, TryGetStreamProviderName, and TryGetTraceParent to expose enrichment metadata when backed by EnrichedEventHubSequenceToken.
Add focused tests for positive and negative cases across enqueued time, stream provider name, and traceparent access.
- implement StateManager extension wrapper
Implement AsStateManager<T> to validate input and wrap IPersistentState<T> with StateManager<T> for grain-facing usage.
Add focused tests for successful wrapping and null-argument validation.
- implement StateManager persistence workflow
Implement StateManager<T> state cache, read/write/clear operations, force-write ETag override, and conflict handling that reconciles equivalent persisted state before rethrowing non-equivalent failures.
Add focused tests for read refresh, successful writes, write-failure reconciliation behavior, rollback on conflicting writes, rollback when post-failure read fails, and VersionedState version stamping.
- implement OutboxMessageEnvelope STJ converter factory
Enable JSON converter factory attribute on OutboxMessageEnvelope<T> and implement OutboxMessageEnvelopeJsonConverterFactory with closed generic converters for read/write round-trip serialization.
Add tests for attribute wiring, CanConvert behavior, round-trip serialization, and missing-token payload validation.
- implement OutboxSequenceToken STJ converter
Enable JSON converter attribute on OutboxSequenceToken and implement OutboxSequenceTokenJsonConverter read/write paths. The converter now round-trips sequence, sender GrainId shape, timestamp, and epoch without requiring caller-side serializer options.
Add focused tests for converter attribute registration, round-trip behavior (including multiple GrainId key shapes), and payload validation for missing sender.
- MessageTracker
- Outbox<T> implemented
- stub types with XML docs
Bug Fixes:
- deliver enriched Event Hub tokens
Wrap Event Hubs batch containers so stream consumers receive broker enqueue time, provider name, and trace context on batch and per-event sequence tokens. Cover the cached-message and Orleans serialization pipeline deterministically.
- preserve clear concurrency conflicts
Keep InconsistentStateException observable when clear recovery finds a missing record, while adopting the provider's recovered state. Document the throwing-path state contract and cover it with a deterministic regression test.
- expose provider state as nullable
Annotate IStateManager<T>.State for storage providers that can expose null after reads or clears, and handle null recovery values without dereferencing them. Preserve Orleans activated defaults while documenting and testing both nullable and non-null missing-record shapes.
- decouple Azure storage provider
Keep only Azure.Core as a runtime dependency of the Azure Storage state-manager companion. Move Table and Blob SDK references to tests and leave Orleans provider selection to consumers, avoiding unwanted provider and version coupling.
- stabilize message tracker JSON schema
Pin every private tracker model property to its canonical wire name so serializer naming policies cannot change persisted state. Require both root collections so mismatched or malformed payloads fail instead of silently clearing deduplication state.
- tolerate additive stream token JSON
Read cursor, discriminator-envelope, built-in token, and Event Hub token properties by name while ignoring unknown fields. Required fields, known value types, and duplicate properties remain strict so rolling upgrades gain forward tolerance without accepting ambiguous payloads.
- preserve concurrent state after write failures
Adopt durable state after a successful recovery read before rethrowing mismatches and concurrency conflicts. This keeps recovered state paired with the refreshed ETag so retries cannot overwrite concurrent writes.
- retry inherited reminder cleanup
Retain a reminder handle loaded from a previous activation before unregistering it, so a transient unregister failure can be retried by the next empty outbox drain.
- reject duplicate outbox processors
Fail immediately when a grain activation attempts to register a second processor, preserving the original reminder component and its durable retry path. Document the one-processor invariant and cover different-type registration through the reminder DIM.
- preserve active outbox reconciliation
Keep reconciliation ownership visible until callbacks complete so an interleaving empty post cannot cancel acknowledged work. Processing timeouts now accept a scoped TimeProvider, and tests use deterministic gates and clocks.
- keep transient clocks out of persisted outboxes
Accept an explicit UTC append timestamp while preserving the system-clock convenience overload. Persisted outboxes no longer retain a TimeProvider, so serialization and rehydration need no clock re-registration.
- prevent outbox drain handoff deadlock
Release the drain gate between background dispatch and non-interleaving reconciliation, and let a foreground drain finish any pending batch before taking a new snapshot. This prevents non-reentrant grain calls from blocking the reconciliation turn while preserving direct PostAsync completion semantics.
- preserve outbox behavior after rehydration
Fall back to TimeProvider.System when Orleans rehydrates an outbox without its non-serialized clock. Document custom-provider re-registration and cover the Orleans deep-copy path so the first mutation no longer fails.
- preserve sender timestamp in tracked outbox tokens
MessageTracker.LatestOutbox reconstructed the last accepted
OutboxSequenceToken using the receiver's Received wall-clock time as
the token Timestamp. Token equality includes Timestamp, so whenever
sender and receiver clocks differ the returned token did not equal the
token that was actually accepted, breaking equality-based consumers and
any logic reading the timestamp as sender time.
Outbox entries now store the sender-stamped timestamp of the last
accepted token alongside the receiver-side Received time (which remains
the basis for eviction). LatestOutbox returns the original timestamp.
JSON payloads written before the new property fall back to Received,
matching the previous behavior.
- prune in-memory outbox attempt counters after reconciliation
The outbox processor tracks per-item dispatch attempt counts in an
in-memory dictionary keyed by item equality. Entries were only removed
when an item posted successfully, so items that left the outbox any
other way - dead-lettered or dropped in ReconcileFailedAsync, or removed
directly by the grain - kept their entries for the lifetime of the
activation, a slow memory leak invisible to the owning grain.
The processor now prunes counters for items no longer pending after
each reconciliation. A consequence is that an equal item enqueued after
its predecessor was dead-lettered starts a fresh attempt sequence
instead of inheriting stale counts.
Also documents the attempt-count semantics on ReconcileFailedAsync:
counts are per-activation and keyed by item equality, so retry and
dead-letter policies that must survive activation restarts need to
persist their own counters.
- detect diverged outbox tails during write recovery
Outbox equality now compares the full first and last pending tokens,
including their timestamps, instead of only their sequence numbers.
Items are only appended at the tail, so two outbox histories that diverged
by adding different messages - for example duplicate grain activations
racing an ambiguous storage write - always differ in their highest pending
token. Comparing the full token lets the state-manager recovery read-back
distinguish such diverged states instead of mistaking them for a lost
response, which could silently drop the local activation's message.
Histories that diverged only by removals can still compare equal; recovery
then at worst re-delivers an already posted item (at-least-once) but never
loses a pending message. Equality remains O(1) and still ignores payloads.
- arm outbox retry when a foreground post run fails
A failed PostAsync run - processing timeout, cancellation, or an exception
from an acknowledgement or reconciliation callback - previously skipped the
retry scheduling that normally happens after reconciliation. On a first-ever
foreground post no timer or reminder existed yet, so announced messages
stayed in the durable outbox until the next post or activation.
The processor now arms its retry timer and reminder before rethrowing the
post-run failure, but only on the failure path, so successful posts still
avoid the reminder registration write.
- accept tokenless stream messages
Do not persist a stream tracking entry when Orleans delivers a null StreamSequenceToken. Without a token there is no high-water mark to compare, so tokenless stream messages are accepted without changing the tracker instead of causing later tokenless messages to look like duplicates.
- harden outbox review behavior
Remove posted outbox tokens by identity so successful items after a failed gap can still be acknowledged. Also makes outbox token/envelope types Orleans-friendly with required init properties and rewrites the STJ converters to delegate through typed converters while preserving the existing JSON shape.
- tighten stream subscription configuration
Fail fast when Orleans invokes an implicit stream subscription without a configured handler, and add explicit StreamId subscription overloads so explicit streams are not limited to the grain-keyed convention.
Clarify outbox ownership, first-match postman dispatch, thread-pool postman safety, and future provider-specific package boundaries in public docs.
- fail fast for missing grain components
Throw clear configuration errors when implicit stream and outbox marker interfaces receive callbacks before their corresponding components have been attached.
This avoids silently swallowing stream subscription or reminder callbacks when a grain implements the marker interface but forgets to call the registration helper during activation.
- resume streams from tracked cursors
Use the activation-time MessageTracker snapshot to provide provider-aware resume tokens when StreamManager attaches implicit handles, resumes explicit handles, or creates missing explicit subscriptions.
Add focused tests for provider-specific cursor lookup and the Orleans stream APIs that receive those tokens.
- tighten messaging manager invariants
Fix stream subscription initialization so handles are collected without concurrent List mutation, repeated SubscribeAsync calls are rejected, and stream handlers receive nullable sequence tokens instead of relying on null-forgiving calls.
Recover StateManager clear failures with a read-back path, clamp negative receive-lag telemetry, validate malformed outbox JSON with JsonException, and document the intentional outbox equality fingerprint invariant.
Rename the deferred outbox processor activation API references to RegisterOutboxProcessor to align with the other activation-time helpers while leaving the implementation deferred.
- restrict StreamCursor JSON tokens to built-in Orleans types
Limit StreamCursorJsonConverter to Orleans built-in stream sequence token types and remove custom token adapter serialization paths. Unsupported token kinds now throw a NotSupportedException with an actionable message that explains the stability rationale and points users to the GitHub issues page for feature requests.
Update StreamCursorJsonConverterTests to assert unsupported custom tokens and unknown token kinds fail with the new guidance message while keeping built-in token round-trip coverage.
Performance:
- register outbox retry reminder lazily
PostInBackgroundAsync and follow-up drain scheduling no longer register the
durable reminder up front; they arm only the in-memory grain timer. The
reminder - which costs a storage write - is now registered only when a post
run fails or completes with items still pending, including when a background
dispatch or reconciliation callback throws. Unregistering also caches the
one-time lookup for a leftover reminder from a previous activation, so an
empty outbox no longer pays a reminder-table read on every successful drain.
Successful posts therefore incur no reminder I/O. The trade-off is that a
silo crash between scheduling a background post and the first failed run
loses the cross-activation retry trigger; pending items then wait for the
next activation's post instead of a reminder tick.