Skip to content

Orleans (.NET) ​

A virtual-actor framework whose persistence story is a state-and-log spectrum rather than a replay engine: a grain is a single-threaded activation of an always-existing logical entity, and its state is either a snapshot written through an e-tag, an event log applied by a pure transition function (JournaledGrain), a per-activation command journal with snapshots (Orleans.Journaling), or a prepared-then-committed transactional version chain. Nothing in Orleans replays code; everything replays data.

FieldValue
LanguageC# (.NET 8/9/10 per the docs' version pivots)
LicenseMIT (LICENSE)
Repositorydotnet/orleans
Documentationlearn.microsoft.com/dotnet/orleans · event sourcing: overview, JournaledGrain basics, log-consistency providers, immediate vs. delayed confirmation, replicated instances, notifications, configuration, diagnostics
Categoryevent-sourced actors
Persistence modelsnapshot (StateStorage, IPersistentState) · replay-of-events (LogStorage, Orleans.Journaling) · explicit state machine (transactions: prepared versions + commit sequence); the log-consistency provider is chosen per grain class
Journal storeWhatever IGrainStorage provider the grain names: the confirmed view plus metadata (GrainStateWithMetaData) or the whole List<TEntry> as one object (LogStateWithMetaData), both behind the provider's e-tag; Orleans.Journaling appends JSON Lines records to an IJournalStorage (Azure Blob/Table, S3, Redis, in-memory VolatileJournalStorage) and periodically replaces it with a snapshot
Latest releasev10.3.1, published August 28, 2026 (GitHub releases); the checkout's Directory.Build.props carries VersionPrefix 10.0.0
Local clone$REPOS/orleans at cff49293e9132dc889428c376fffee4a4cc95653

Last reviewed: September 12, 2026.


Overview ​

What it solves ​

Orleans is not a durable-execution engine. It is the origin of the virtual actor abstraction, and its durability features answer a different question from Temporal's: not "how do I resume a long program where it died?" but "how does an entity's state survive the server that held it, given that the entity itself can never die?" The 2014 technical report (Bernstein et al., MSR-TR-2014-41) defines the abstraction in four facets, the first of which is the one durable execution borrows:

"1. Perpetual existence: actors are purely logical entities that always exist, virtually. An actor cannot be explicitly created or destroyed and its virtual existence is unaffected by the failure of a server that executes it. Since actors always exist, they are always addressable. 2. Automatic instantiation: Orleans' runtime automatically creates in-memory instances of an actor called activations. … If the server where an actor currently is instantiated fails, the runtime will automatically re-instantiate it on a new server on its next invocation."

The same report fixes the execution model that every persistence feature below relies on:

"Actor activations are single threaded and do work in chunks, called turns. An activation executes one turn at a time. A turn can be a method invocation or a closure executed on resolution of a promise. While Orleans may execute turns of different activations in parallel, each activation always executes one turn at a time."

The earlier SOCC 2011 paper (Bykov et al.) already framed grains as "isolated units of state and computation that communicate through asynchronous messages" and promised "lightweight transactions that support a consistent view of state and provide a foundation for automatic error handling and failure recovery"; those transactions were cut before release, and the 2014 report says plainly "Orleans does not yet support cross-actor transactions." The transactions that exist today (Orleans.Transactions) are a later, separate design.

So where Temporal makes the program recoverable by replaying its decisions, Orleans makes the entity recoverable by reloading its state on the next activation, and offers four ways to represent that state on disk. The interesting one for this catalog is the event-sourced way, because it is the closest thing in a mainstream framework to a journal with a projection.

Design philosophy ​

Three commitments shape the event-sourcing layer (src/Orleans.EventSourcing/):

  1. The grain sees two states, confirmed and tentative. State is the fold of the confirmed prefix of the log; TentativeState additionally applies the unconfirmed suffix. Version is defined as the length of the confirmed prefix (JournaledGrain.cs): "Gets the version of the current confirmed state. Equals the total number of confirmed events."
  2. The transition function must be pure, by contract not by enforcement. From the docs (JournaledGrain basics): "Assume transition methods have no side effects other than modifying the state object and should be deterministic (otherwise, the effects are unpredictable). If the transition code throws an exception, Orleans catches it and includes it in a warning in the Orleans log, issued by the log-consistency provider."
  3. What gets persisted is a provider decision, not an API decision. The same JournaledGrain code runs over a provider that stores only the latest state, one that stores only the event list, or a user-supplied storage interface. The base class's own doc-comment gives the game away (PrimaryBasedLogViewAdaptor.cs): "Note that the log itself is transient, i.e. not actually saved to storage - only the latest view and some metadata (the log position, and write flags) is stored in the primary."

The newer Orleans.Journaling package (in this checkout, without a docs page yet) reverses the last point: there the journal is the primary, entries are commands against typed durable collections, and snapshots are a compaction of it.


How it works ​

The user-facing shape ​

A journaled grain names its state type and its event base type, raises events, and optionally waits for them to be confirmed. The package README's bank account (src/Orleans.EventSourcing/README.md), trimmed:

csharp
public class BankAccountGrain : JournaledGrain<BankAccountState, object>, IBankAccountGrain
{
    public async Task Deposit(decimal amount)
    {
        RaiseEvent(new DepositEvent { Amount = amount });   // initiates a write, does not wait
        await ConfirmEvents();                              // waits for the storage ack
    }

    public Task<IReadOnlyList<object>> GetHistory()
        => RetrieveConfirmedEvents(0, Version);             // LogStorage only

    protected override void ApplyEvent(object @event) { /* fold one event into State */ }
}

The full surface of JournaledGrain<TGrainState, TEventBase> (JournaledGrain.cs):

MemberMeaning
RaiseEvent(e) / RaiseEvents(es)LogViewAdaptor.Submit / SubmitRange: append to the pending queue; a range is written atomically
RaiseConditionalEvent(e) → Task<bool>TryAppend: succeeds "only if there are no conflicts, that is, no other events were raised in the meantime"; returns false on a lost race
State / VersionConfirmedView / ConfirmedVersion
TentativeState / UnconfirmedEventsTentativeView / UnconfirmedSuffix
ConfirmEvents()ConfirmSubmittedEntries: wait until the worker has serviced the pending queue
RefreshNow()Synchronize: confirm everything and reload from storage (linearizable read across instances)
RetrieveConfirmedEvents(from, to)RetrieveLogSegment; throws NotSupportedException on providers that do not keep events
TransitionState(state, event)the fold; the default is dynamic s = state; dynamic e = @event; s.Apply(e); (overload resolution on the runtime event type)
OnStateChanged() / OnTentativeStateChanged()callbacks when the confirmed / tentative view changes
OnConnectionIssue(issue)storage or cross-cluster trouble, reported every time it recurs, resolved once
ClearLogAsync()reset to the initial state; discards unconfirmed events

OnActivateAsync defaults to LogViewAdaptor.Synchronize(): "upon activation, the journaled grain waits until it has loaded the latest view from storage." The lifecycle hooks are wired in LogConsistentGrain.cs, which subscribes the adaptor one stage before and one stage after the user's Activate stage and resolves the provider from the [LogConsistencyProvider(ProviderName = …)] attribute, falling back to StateStorage.DefaultAdaptorFactory when none is named.

The adaptor: one worker, one primary, a pending queue ​

All three built-in providers derive from PrimaryBasedLogViewAdaptor<TLogView, TLogEntry, TSubmissionEntry> (PrimaryBasedLogViewAdaptor.cs). Its state is a pending list of SubmissionEntry records, a cached confirmed view, and a lazily computed tentative view:

csharp
public class SubmissionEntry<TLogEntry>
{
    public TLogEntry Entry;
    public DateTime SubmissionTime;
    public TaskCompletionSource<bool>? ResultPromise;   // conditional updates only
    public int ConditionalPosition;                      // the log position this update must land at; -1 = unconditional
}

Submit appends to pending, folds the entry into the tentative view if one is materialized, fires OnViewChanged(tentative: true), and pokes a background worker. TryAppend does the same but records ConditionalPosition = ConfirmedVersion + pending.Count and hands back a promise. The worker's cycle (Work) is: process notifications from other instances, read the primary if an initial read or refresh is due, then UpdatePrimary, which loops: drop stale conditional updates (RemoveStaleConditionalUpdates: any conditional entry whose ConditionalPosition != version + pos, and every conditional entry after it, is failed with false and removed), write the batch, retry on a zero-length result, and finally resolve promises and pending.RemoveRange(0, writeResult).

The one piece of machinery that makes retried appends safe is the write vector (GrainStateWithMetaData.cs, identical in LogStateWithMetaData.cs):

"Metadata that is used to avoid duplicate appends. Logically, this is a (string->bit) map, the keys being replica ids … Bits are toggled when writing, so that the retry logic can avoid appending an entry twice when retrying a failed append."

WriteAsync flips the bit for Services.MyClusterId, writes, and on any exception re-reads the primary in a loop ("be stubborn until we can read what is there") and compares the stored bit to the one it wrote: "check if last apparently failed write was in fact successful" (StateStorage/LogViewAdaptor.cs). A write whose acknowledgement was lost is therefore recognised as committed rather than re-applied.

The three log-consistency providers ​

ProviderWhat the primary holdsOn activationRetrieveConfirmedEventsDocs' verdict
StateStorageGrainStateWithMetaData<TView>: State, GlobalVersion, WriteVector, behind the storage e-tagread the snapshotthrows"stores grain state snapshots"; "the events aren't persisted" (providers)
LogStorageLogStateWithMetaData<TEntry>: List<TEntry> Log, WriteVector; GlobalVersion is Log.Countread the whole list, fold every entryfrom the in-memory list"not suitable for production use unless the event sequences are guaranteed to remain fairly short … to illustrate the semantics"
CustomStoragewhatever the grain's ICustomStorageInterface<TState, TDelta> does with (version, state) and deltasReadStateFromStorage() → (version, state)throws"doesn't make specific assumptions about whether the stored data consists of state snapshots or events"

The custom contract is small and is the clearest statement of the protocol's expectations (ICustomStorageInterface.cs):

csharp
public interface ICustomStorageInterface<TState, TDelta>
{
    Task<KeyValuePair<int, TState>> ReadStateFromStorage();
    // "returns true, if the version in storage matches the expected version. Otherwise, does nothing and returns false.
    //  If successful, the version of storage must be increased by the number of deltas."
    Task<bool> ApplyUpdatesToStorage(IReadOnlyList<TDelta> updates, int expectedVersion);
    Task ClearStoredState() => throw new NotSupportedException();
}

The docs add the duplication caveat that the write vector solves for the built-in providers and hands to the user here (providers): "If ApplyUpdatesToStorage fails with an exception, the consistency provider retries. This means some events could be duplicated if such an exception is thrown but the event was persisted. You are responsible for ensuring this is safe."

Delayed confirmation and replicated instances ​

By default a grain confirms before returning, which the docs call immediate confirmation: no [Reentrant], always await ConfirmEvents(). The cost is availability: "If the connection to a remote cluster or storage is temporarily interrupted, the grain becomes unavailable" (confirmation). Delayed confirmation lets methods return with events still in flight, at which point TentativeState is "a 'best guess' at what will likely become the next confirmed state … there's no guarantee it actually will become the confirmed state. This is because the grain might fail, or the events might race against other events and lose, causing them to be canceled (if conditional) or appear later in the sequence than anticipated (if unconditional)."

Multiple activations of one grain (multi-cluster deployments, or a transient duplicate during a partition) all submit to the same primary, and the primary's e-tag serialises them: "if two instances see the same version number, they see the same state" (replicated instances). After a successful write the writer sends an UpdateNotificationMessage { Version, Origin, Updates, ETag } to the other instances; the receiver keeps them in a SortedList keyed by starting version, applies only the one whose key equals its current version, discards older ones, and leaves a gap to be filled by a refresh (LogStorage/LogViewAdaptor.cs). The test grains show both conflict styles: AccountGrain.cs guards withdrawals with RaiseConditionalEvent ("so we can guarantee that we never overdraw even if racing with other clusters"), while SeatReservationGrain.cs raises unconditionally and lets the fold decide ("this is a 'first writer wins' conflict resolution"): a reservation for a taken seat is applied as a no-op and the caller re-reads State to learn whether it won.

Orleans.Journaling: durable collections over an appendable journal ​

The newer package (src/Orleans.Journaling/) is a different design under the same word. A DurableGrain (DurableGrain.cs) owns an IJournaledStateManager; named states (IDurableList<T>, IDurableDictionary<K,V>, IDurableSet<T>, IDurableQueue<T>, IDurableValue<T>, IPersistentState<T> via DurableState<T>, DurableTaskCompletionSource<T>) register with it. A mutation applies in memory and writes a command to the state's stream at once; WriteStateAsync() flushes. The contract's own summary (IJournaledState.cs):

"In other words, in-memory mutations are journaled within the same grain turn and become durable when the journal flushes. Implementations are free to apply mutations eagerly (before the durable write completes); recovery rebuilds in-memory state from the journal, so a turn-failure-and-recovery cycle observably rewinds any unflushed changes."

The storage format is JSON Lines, one record per entry: [8,["set","alpha",1]] is stream 8, command set, operands "alpha", 1 (README.md). IJournalStorage (IJournalStorage.cs) has exactly the operations a WAL needs: ReadAsync (stream everything to a consumer), AppendAsync, ReplaceAsync (the snapshot), DeleteAsync, an IsCompactionRequested flag, and format-key metadata. The manager's work loop decides per write whether to append or snapshot (JournaledStateManager.cs): a snapshot is taken when the user asks, when the storage requests compaction, or when the recovered data was written in a different format (_migrationSnapshotRequired). A TODO in that loop admits the policy is unfinished: "decide whether it's best to snapshot or append. Eg, by summing the size of the most recent snapshots and the current journal length."

Recovery (RecoverAsync) resets every state, streams the journal through the format's Replay, and dispatches each entry to the state registered under its stream id; an entry for a stream no grain code registers any more lands in a RetiredState that is dropped after a grace period (RetirementGracePeriod, default 7 days). Each entry carries its own format key, so JournalReplayContext.GetRequiredCommandCodec can decode an old-format entry with the old codec while new writes use the configured one (JournalReplayContext.cs).

The contrast case: transactions ​

Orleans.Transactions does not journal and does not replay. A grain declares ITransactionalState<TState> (ITransactionalState.cs) and touches state only through PerformRead/PerformUpdate lambdas; methods carry [Transaction(TransactionOption.Create | Join | CreateOrJoin | …)] (TransactionAttribute.cs). Storage keeps a committed state plus a chain of prepared versions (ITransactionalStateStorage.cs): Store(expectedETag, metadata, statesToPrepare, commitUpTo, abortAfter), where each PendingTransactionState is "A snapshot of the state after this transaction executed" with a dense local SequenceId. Recovery reads the authoritative record and asks the transaction manager about the fate of each prepared version. The docs' guarantee is ACID across grains with no central coordinator, and their failure contract is the durable-execution one turned inside out: an OrleansTransactionAbortedException means retry, but "Any other exception thrown indicates the transaction terminated with an unknown state. Since transactions are distributed operations, a transaction in an unknown state could have succeeded, failed, or still be in progress" (transactions).


Analysis ​

The eight questions were written for replay-of-code systems. For an event-sourced actor, "step" becomes "event", "journal" becomes "log or snapshot", and "the world" becomes "the storage primary". Each subsection says which translation it is using.

1. Step identity and replay matching ​

There are no steps to match, because nothing re-executes grain code. An event's identity is its position in the confirmed sequence: Version is the count of confirmed events, and the fold is order-dependent by definition. Two things are keyed off that position. A conditional event carries ConditionalPosition (the version it expects to land at) and is dropped if the confirmed version has moved (PrimaryBasedLogViewAdaptor.cs, RemoveStaleConditionalUpdates). A retried write is keyed by the per-replica bit in the WriteVector, which is how the adaptor tells "my lost-ack write landed" from "somebody else wrote" (GrainStateWithMetaData.cs). Events carry no names, no attempt counters and no argument hashes; deduplication is by batch, not by event. In Orleans.Journaling an entry is identified by its stream id and its order within the journal (README.md); there is no per-entry id at all.

2. Journal versus world ​

Storage is the world, and storage always wins. RefreshNow and activation both reload from the primary; a write is accepted only through the provider's e-tag (GrainStateWithMetaDataAndETag.ETag, ApplyUpdatesToStorage(…, expectedVersion)), and a mismatch is detected as a rejected write, never as a divergence to reconcile. On conflict the adaptor re-reads, re-folds its pending unconditional events on top of the newer state (they are resubmitted, so they may "appear later in the sequence than anticipated", confirmation), and fails its conditional ones. There is no notion of an observation that the journal recorded and the world later contradicted, because the journal never records observations: it records decisions (events), and the fold is the only reader. The one place "the world disagrees with what I thought I did" is the lost-ack write, and the write vector answers it (StateStorage/LogViewAdaptor.cs). In Orleans.Journaling the same role is played by InconsistentStateException from AppendAsync/ReplaceAsync (IJournalStorage.cs), after which the manager re-runs recovery from the journal.

3. Determinism enforcement ​

By discipline. The docs say transition methods "should be deterministic (otherwise, the effects are unpredictable)" (JournaledGrain basics) and the runtime does nothing to check it; the default TransitionState is a dynamic dispatch to Apply overloads (JournaledGrain.cs), so even the choice of transition is resolved at run time. Worse for auditability, an exception in the fold is caught, logged (Services.CaughtUserCodeException("UpdateView", …)), and the version advances anyway (LogStorage/LogViewAdaptor.cs, UpdateConfirmedView); a non-deterministic or throwing fold silently produces a view that no longer equals the fold of the log. What Orleans does enforce is the scheduling half: the turn-based model guarantees the fold and the grain method never run in parallel (request scheduling: "Grain activations have a single-threaded execution model"), and Orleans.Journaling states that "Implementations are owned by a single JournaledStateManager and are accessed from one logical thread at a time" (IJournaledState.cs). Event construction is unconstrained: the docs' own example stamps Timestamp = DateTime.UtcNow into the event, which is fine precisely because the timestamp is data in the log, not a value re-derived on replay.

4. Compensation and failure handling ​

None, in the saga sense. The event-sourcing layer offers three failure behaviours and no rollback primitive: a conditional event fails fast and returns false to the caller, who decides (AccountGrain.cs); an unconditional event is retried against storage forever (while (true) // be stubborn), surfacing only through OnConnectionIssue callbacks (diagnostics) and the grain's unavailability; and the fold can encode application-level conflict resolution, as the seat grain's first-writer-wins no-op does (SeatReservationGrain.cs). There is no way to un-raise a confirmed event other than raising a compensating one, and the framework does not know which event compensates which. Orleans.Journaling adds RevertPendingChangesAsync (JournaledStateManager.cs), which discards unflushed mutations by re-running recovery, a rollback of memory to the last durable point, not of the world. The transactions package is the compensation story Orleans actually ships: prepared versions that are committed or aborted as a unit (ITransactionalStateStorage.cs), with the explicit warning that a non-abort exception leaves the outcome unknown (transactions).

5. Versioning against old histories ​

The docs answer this per provider, and the answer is the classic event-sourcing trade (JournaledGrain basics): "Some providers, like the LogStorage log-consistency provider, replay the event sequence every time the grain loads. Therefore, as long as the event objects can still be properly deserialized from storage, you can radically modify the GrainState class and the transition methods. However, for other providers, such as the StateStorage log-consistency provider, only the GrainState object is persisted. In this case, you must ensure it can be deserialized correctly when read from storage." Compatibility is delegated to the serializer (version-tolerant JSON by default, grain persistence). There is no schema version on the log, no upcaster hook, and no equivalent of Temporal's patched gate; a changed fold simply produces a different view of the same events, which is a feature here (the fold is a projection) and a hazard for a durable program (a changed decision is not a projection). Orleans.Journaling is more deliberate: every entry carries a format key, an old-format journal is decoded with its own codec and rewritten as a snapshot in the new format on the next write, and streams that code no longer registers are quarantined as RetiredState for a grace period before being dropped (JournalReplayContext.cs, JournaledStateManager.cs).

6. Concurrency under replay ​

Replay is a fold over a totally ordered list, so there is nothing to interleave: the primary assigns the order, and every instance folds the same sequence (replicated instances). Concurrency exists around the log: within one activation, delayed confirmation plus [Reentrant] lets several grain methods be in flight while events are unconfirmed, and the docs' guarantee is exactly the tail-resumptive one this catalog's host relies on: "even though several methods might be in progress, only one can be actively executing—all others are stuck at an await. … The properties State, TentativeState, Version, and UnconfirmedEvents can change during the execution of a method. However, such changes can only happen while stuck at an await" (confirmation). Across activations, races are resolved by the e-tag and reported as false (conditional) or reordering (unconditional). The test suite is candid about the cost: the tentative-vs-confirmed test has to run inside the grain because "otherwise the interleaving of the individual steps is nondeterministic" (PersonGrainTests.cs).

7. Replay or snapshot ​

Orleans refuses to choose, and the refusal is the finding. StateStorage is snapshot-only: every write serialises the whole view ("this provider isn't suitable for objects with very large grain states") and the history is gone. LogStorage is replay-only: every read and write moves the whole List<TEntry> as one storage object, which is why the docs call it a teaching provider. CustomStorage is "you decide", and the docs' stated plan is to grow the provider list "to more easily allow you to plug in standard event storage systems" (overview). Orleans.Journaling finally does both in one store: append entries, snapshot on request or on compaction pressure, with the append-vs-snapshot heuristic still a TODO (JournaledStateManager.cs). What replay buys here is cheap: the fold is pure over data, activation cost is linear in log length, and there is no code to keep deterministic beyond Apply. What it rules out is reading a step's result before the log is written; there is no "started" record, so a crash between raising and confirming loses the event, which the docs accept as the meaning of "unconfirmed".

8. Testing ​

Integration-first, with in-memory storage and a real cluster in-process. TestCluster and the newer InProcessTestCluster (testing, TestCluster.cs) start silos in the test process; the event-sourcing fixture registers all three providers plus AddMemoryGrainStorageAsDefault and a FaultInjectionMemoryStorage with a 15 ms latency (EventSourcingClusterFixture.cs, FaultInjectionStorageProvider.cs). Orleans.Journaling tests run over VolatileJournalStorageProvider (VolatileJournalStorage.cs, DurableStateAndTcsRecoveryTests.cs) and check Grain_State_Should_Persist_Between_Activations (DurableGrainTests.cs). The adaptor itself has unit tests that drive the protocol directly: a conditional range that observes the activation read advancing the version completes false and removes the whole range; a notification arriving during a blocked write is applied in the next worker cycle; a throwing view-changed callback is reported, not propagated (PrimaryBasedLogViewAdaptorTests.cs). There is no crash-at-every-index harness and no deterministic scheduler; the PersonGrainTests comment above is the admission.

9. Journal integrity and the single writer ​

Orleans has the strongest runtime single-writer guarantee in the survey and, because it cannot rely on that guarantee alone, also ships one of its most interesting integrity tricks.

Single-writer is the activation, and it is a platform property. A grain has at most one live activation cluster-wide, and the turn-based model means that activation processes one request at a time. So "two writers for one entity" is not an ordinary case; it is a failure of placement, which happens during a silo failure or a network partition.

The e-tag is the conditional guard. Grain state is written through IGrainStorage with an e-tag carried in GrainStateWithMetaData; a write whose tag does not match the stored one is rejected, which is what turns a stale activation's write into an error rather than a lost update.

Lost acknowledgements get their own mechanism, and it is a bit vector. The metadata carries a WriteVector whose "Bits are toggled when writing, so that the retry logic can avoid appending an entry twice when retrying a failed append" (GrainStateWithMetaData.cs). The problem it solves is precisely the started-without-completed ambiguity: an append whose acknowledgement was lost cannot be distinguished from one that never landed, so the writer flips a bit, re-reads, and compares. That is a cheaper answer than a full intent record, and it works only because the write is an idempotent replacement of a single object rather than an append to a log.

The log-storage provider writes the whole event list as one object, which makes every append atomic by construction and is also why the documentation warns it is not suitable for large streams (§7). Atomicity and scalability are traded directly against each other here.

The newer journaling package appends instead, writing JSON Lines to an IJournalStorage with snapshot replacement, and it carries a per-entry format key so a change of encoding forces a fresh snapshot (§5).

No writer identity is recorded, and nothing checks on read which activation produced an entry. The e-tag prevents the bad write; it leaves no trace that one was attempted.

Torn writes are the storage provider's problem, as with every pluggable-provider design.

10. Operator recovery and intervention ​

Orleans is a framework rather than a platform, and its recovery surface is correspondingly thin — with one exception that is unusual enough to be worth naming.

Nothing forks, rewinds, or resets a grain's history. Recovery means activation: read the state or replay the log, and continue. There is no operation that resumes from a chosen version with a new identity.

Retired state is quarantined rather than deleted. The journaling package tracks states "that are not registered via user-code anymore, until time-based purging has elapsed", with the note that "Resurrecting of retired states is supported" and a default grace period of seven days (JournaledStateManager.cs, JournaledStateManagerOptions.cs). That is a genuine quarantine — the one mechanism in this survey that keeps a record the code no longer claims, on the assumption that the omission may have been a mistake.

Conditional events give the caller the decision. A conditional RaiseEvent returns false when the version moved under it, so the grain's own code — not an operator — decides whether to retry, merge, or give up. Unconditional writes retry until they succeed.

Cancellation and pausing do not apply, for the same reason as in Pekko: a grain is not a run with a lifecycle. Deactivation is invisible to correctness.

Inspection is programmatic. RetrieveConfirmedEvents reads back a range for a grain that opts into it, and the log-consistency providers differ in whether the events are even retained to be read. There is no tool, no query surface, and no cross-grain listing.

A fold that throws is swallowed. The runtime catches an exception from a transition method, logs it, and advances the version regardless — so a record the code can no longer apply produces a silently divergent state rather than a stopped grain. That is the most consequential absence on this dimension, because it means the failure is invisible exactly when an operator would most want to be told.

11. Suspension and external input ​

A grain does not suspend, and the reason is the same as Pekko's: it is an addressable entity that exists whether or not it is activated, so waiting is not a state it enters but the absence of a message. Activation on demand is the whole mechanism — "actors are purely logical entities that always exist, virtually" — and replay happens when a message arrives, not when a wait ends.

Delayed confirmation is the nearest thing to a wait, and it is about batching rather than about external input: a grain may accept several events before confirming them to storage, trading durability latency for throughput, with TentativeState exposing the unconfirmed view meanwhile.

Reentrancy is the concurrency knob, not a suspension model. A [Reentrant] grain may interleave requests at await points, which is what allows a grain to make a call without blocking every other caller — but it is in-memory interleaving, and nothing about it is persisted.

External input is a grain call or a stream event, addressed by grain id. Delivery semantics come from the messaging layer rather than from persistence, so duplicate and lost messages are the caller's problem in both directions.

There is no durable timer in the persistence model. Reminders exist in the Orleans runtime and are durable, but they are a separate facility from event sourcing, so a journaled program that wants to wake later composes two mechanisms rather than using one.

Human-in-the-loop is not modelled. An approval is a call to a grain, and the fact that one is outstanding is state the author writes like any other — the same conclusion the actor model reaches everywhere in this survey.


Strengths ​

  • Single-writer activation is a runtime guarantee, not a convention. The turn model means the fold and the method body cannot race, which is the property every journaling scheme needs and most SDKs have to police with sandboxes.
  • Confirmed versus tentative is first-class in the API. State/Version versus TentativeState/UnconfirmedEvents, plus OnStateChanged/OnTentativeStateChanged, give the caller a precise vocabulary for "durable" versus "in flight".
  • Lost-acknowledgement writes are solved, cheaply. The per-replica flip bit in the write vector turns "did my write land?" into a re-read and a bit comparison, with no idempotency key per event.
  • Conflict handling is a choice the fold can make. Conditional events (fail fast) and unconditional events with a resolving fold (first writer wins) coexist on one grain.
  • The provider seam decouples the programming model from the persistence model. The same grain runs snapshot-only, log-only or custom, and CustomStorage is a 20-line interface.
  • Orleans.Journaling gets the storage format right. JSON Lines, append plus replace, per-entry format keys, retired-stream quarantine, and a storage contract small enough to implement over a file.

Weaknesses ​

  • Purity of the fold is unenforced and its failures are swallowed. A throwing Apply is logged and the version still advances; the view and the log silently diverge.
  • LogStorage is not a production event store. The whole list is one blob per read and write; the docs say so, and the promised pluggable event stores have not shipped in Orleans.EventSourcing.
  • No schema versioning for events. Compatibility is whatever the serializer tolerates; nothing records which code produced an event.
  • No "started" record. Nothing is durable before the write completes, so there is no way to distinguish "never attempted" from "attempted, ack lost" for anything except the storage write itself.
  • No compensation model. Retry-forever for unconditional writes and false for conditional ones is the whole failure vocabulary of the event-sourcing layer.
  • Testing is integration-heavy and timing-sensitive. No deterministic scheduler; the tests that need a fixed interleaving move the test body into the grain.

Key design decisions and trade-offs ​

DecisionRationaleTrade-off
Virtual actors: always exist, activated on demand, one turn at a timeRemoves creation, lookup, supervision and locking from application codeState must be reloadable on every activation; long in-memory computations are cut into turns by the runtime, not the programmer
Two views, confirmed and tentative, with Version = confirmed lengthLets a grain answer from memory while writes are in flight, and reason about what is durableTentative state is a guess; callers must choose which view to read
The log is transient unless the provider keeps itSame API over cheap snapshot storage and over a real event logRetrieveConfirmedEvents and "radically modify the state class" work on some providers only
Conflicts resolved by the primary's e-tag, surfaced as falseNo coordinator, works across clusters, one round tripUnconditional events can be reordered behind a lost race; the caller learns by re-reading
Write vector bit per replicaDetects a committed-but-unacknowledged write without per-event idempotency keysOnly distinguishes "my last batch" from "not mine"; two lost acks in a row from one replica are ambiguous
Fold purity by documentation; exceptions caught and loggedNever let user code wedge the protocol workerDivergence between log and view is possible and quiet
Orleans.Journaling: append entries, snapshot on compaction or migrationBounded recovery time with an append-only hot pathThe append-vs-snapshot policy is a TODO; snapshots are whole-journal ReplaceAsync
Transactions as prepared versions, not a journalACID across grains without a central coordinatorUnknown outcome on non-abort failure; no history

Implications for a durable-execution library ​

  • A bit vector is a cheap answer to the lost acknowledgement (§9). Flipping a bit before writing, then re-reading and comparing, lets a writer tell "my append landed and the ack was lost" from "it never landed" — without an intent record. It works only because the write replaces one object rather than appending, which is precisely the trade a library makes when it chooses a log.
  • An e-tag is the minimum conditional guard, and it is orthogonal to whatever runtime guarantee says there should only be one writer. Orleans has the strongest such guarantee in the survey and still needs the e-tag.
  • Quarantine a record the code no longer claims, rather than deleting it (§10). A retirement tracker with a grace period and explicit support for resurrection is the only mechanism of its kind here, and it encodes a useful assumption: a state that disappeared from the code may be a mistake rather than an intention.
  • Swallowing a fold exception and advancing anyway is the wrong default (§10). It converts a record the code cannot apply into silent divergence. Making the fold total, and treating a throw during replay as a hard stop, is the opposite and better.
  • Refusing to choose between snapshot and log has a real cost. Three log-consistency providers means three sets of semantics, and the documentation has to warn that one of them is unsuitable for production (§7). A library is better served by one mechanism plus a bounded replay than by a menu.
  • A format key per record with a forced re-snapshot on change is a compact versioning mechanism (§5), and it is the piece the older provider lacks.
  • Conditional writes that return a boolean, rather than throwing, put the merge decision where the domain knowledge is (§10) — in the program, not in the storage layer or an operator's hands.
  • Virtual, always-existing entities make suspension a non-concept (§11), which is elegant and means every waiting concern — timers, approvals, outstanding requests — becomes state the author persists. A library that wants durable waits must supply them; it will not get them from this model.

Sources ​