Repository navigation
Persistence
Quark offers six implemented persistence patterns, from ephemeral in-memory state to full event sourcing.
Which one do I use? Answer three questions:
-
Must it survive deactivation / restart? No →
IActivationMemory<T>(plain data),IManagedActivationMemory<T>(needs async init or cleanup — buffers, clients, handles), orIEagerActivationMemory<T>(must be ready beforeOnActivateAsync). Yes → continue. -
Do you need the history or just the latest value? History →
JournaledGrain<TState,TEvent>. Latest value → continue. -
Hot state or Orleans compatibility? Read on most calls →
IPersistentActivationMemory<T>(shell-cached, explicitWriteStateAsync). Migrating Orleans code or need multiple named slots →[PersistentState] IPersistentState<T>(⚠ re-reads storage every call — see the comparison below).
Multi-grain atomic updates are a separate concern — see Transactions. What each pattern guarantees on failure and deactivation is specified in Lifecycle and Failure Semantics.
| Pattern | Interface | Init timing |
.Value access |
Storage |
|---|---|---|---|---|
| In-memory | IActivationMemory<T> |
new() sync |
Sync | None |
| Managed in-memory | IManagedActivationMemory<T> |
Lazy — first GetAsync()
|
await GetAsync() |
None |
| Eager in-memory | IEagerActivationMemory<T> |
Eager — before OnActivateAsync
|
Sync | None |
| Persistent activation | IPersistentActivationMemory<T> |
ReadStateAsync() |
Sync | IGrainStorage |
| Named state injection | [PersistentState] IPersistentState<T> |
ReadStateAsync() |
.State |
Named IGrainStorage
|
| Event sourcing | JournaledGrain<TState,TEvent> |
Replay on activation | State |
ILogStorage |
Every persistence pattern is anchored to the grain activation lifecycle. State init runs during
activation; cleanup runs during deactivation, in this exact order (verified against the runtime —
GrainActivation.RunActivationAsync / RunDeactivationAsync):
stateDiagram-v2
[*] --> Activating : first call / placement
state Activating {
[*] --> ConstructBehavior
ConstructBehavior --> EagerInit : Load() factories registered in ctor
EagerInit --> OnActivate : RunEagerInitAsync, eager .Value ready
OnActivate --> [*] : OnActivateAsync, ReadStateAsync / replay log
}
Activating --> Active
state Active {
[*] --> Idle
Idle --> Serving : grain call
Serving --> Idle : per-call scope + behavior disposed
}
Active --> Deactivating : idle timeout / Deactivate() / shutdown
state Deactivating {
[*] --> DisposeTimers
DisposeTimers --> OnDeactivate : OnDeactivateAsync
OnDeactivate --> DestroyHolders : managed + eager Destroy()
DestroyHolders --> [*]
}
Deactivating --> [*] : shell removed, in-memory state lost
What survives, and where the source of truth lives. Activation memory (plain, managed, eager) and the journaled projection live only in the volatile shell; durable backends are the only thing that crosses a deactivation or a silo restart:
flowchart TB
subgraph Shell["GrainActivation shell — volatile, one per identity, lost on deactivation"]
AM["IActivationMemory / Managed / Eager<br/>(never persisted)"]
PAM["IPersistentActivationMemory cache"]
JG["JournaledGrain in-memory projection"]
end
subgraph Durable["Durable backends — survive deactivation + restart"]
GS[(IGrainStorage)]
LS[(ILogStorage)]
end
PSlot["IPersistentState slot<br/>per-call handle, no shell cache"]
PAM -- "LoadAsync on activate" --> GS
PAM -- "SaveAsync write-through" --> GS
PSlot -- "Read/WriteStateAsync each call" --> GS
JG -- "ConfirmEventsAsync appends" --> LS
JG -- "replay on activate" --> LS
| Pattern | Survives across calls? | Survives deactivation? | Source of truth |
|---|---|---|---|
IActivationMemory<T> |
✅ shell-cached | ❌ | shell |
IManagedActivationMemory<T> |
✅ shell-cached | ❌ | shell |
IEagerActivationMemory<T> |
✅ shell-cached | ❌ (rebuilt each activation) | shell (rebuilt from DI) |
IPersistentActivationMemory<T> |
✅ shell-cached | ✅ via SaveAsync
|
IGrainStorage |
[PersistentState] IPersistentState<T> |
✅ | IGrainStorage |
|
JournaledGrain<TState,TEvent> |
✅ shell projection | ✅ via event log | ILogStorage |
The per-pattern lifecycle is shown inline in each section below.
For state that must survive across method calls on the same activation but does not need to outlive it:
flowchart LR
N["new() at first access"] --> C["read/write .Value across calls"] --> D["discarded on deactivation (no storage)"]
public sealed class CounterState { public int Count { get; set; } }
public sealed class CounterBehavior : IGrainBehavior, ICounterGrain
{
private readonly IActivationMemory<CounterState> _memory;
public CounterBehavior(IActivationMemory<CounterState> memory)
=> _memory = memory;
public Task IncrementAsync() { _memory.Value.Count++; return Task.CompletedTask; }
public Task<int> GetAsync() => Task.FromResult(_memory.Value.Count);
}Register in silo startup:
silo.Services.AddScoped<IActivationMemory<CounterState>>(sp =>
new ActivationMemoryAccessor<CounterState>(
sp.GetRequiredService<IActivationShellAccessor>()
.Shell.GetOrCreateHolder<CounterState>()));The StateHolder<TState> lives on the GrainActivation shell and is shared across all per-call scopes for the same activation.
For resources that require async initialization and optional async cleanup on deactivation, but must not be persisted. Common uses: in-memory ring buffers, pooled channels, cached projections, or anything built with an async factory.
flowchart LR
R["Init() factory registered in ctor"] --> L["lazy: factory runs on first GetAsync()"] --> C["value cached for the activation"] --> D["Destroy() after OnDeactivateAsync"]
Key differences from IActivationMemory<T>:
-
Thas nonew()constraint — works with any class. - Init is async and lazy: the factory runs only on the first
GetAsync()call, not at activation time. - Access is always
await GetAsync()— one async hop per call site.
Key differences from IEagerActivationMemory<T> (see section 6):
- Lazy vs. eager: factory runs on first access, not during grain activation.
-
Async vs. sync access:
await GetAsync()instead of.Value. - The factory receives no
IServiceProvider; close over injected services in the behavior constructor instead.
public sealed class MetricsBehavior : IGrainBehavior, IMetricsGrain
{
private readonly IManagedActivationMemory<RingBuffer> _buffer;
public MetricsBehavior(IManagedActivationMemory<RingBuffer> buffer, IOptions<MetricsOptions> opts)
{
_buffer = buffer
.Init(() => ValueTask.FromResult(new RingBuffer(capacity: opts.Value.BufferSize)))
.Destroy(b => b.FlushAsync());
}
public async Task RecordAsync(double value)
{
RingBuffer buf = await _buffer.GetAsync();
buf.Write(value);
}
}API:
| Member | Description |
|---|---|
Init(Func<ValueTask<T>>) |
Configures the async factory. Called once in the behavior constructor. Invoked on the first GetAsync() call; result cached for the activation lifetime. |
Destroy(Func<T, ValueTask>) |
Optional cleanup delegate. Called after OnDeactivateAsync completes, so the grain can still read the resource during teardown. Omit if no cleanup is needed. |
GetAsync(CancellationToken) |
Returns the value; initializes it on first call. |
IsInitialized |
true once the factory has succeeded. Destroy is a no-op if the resource was never initialized. |
Thread safety:
GetAsync()is safe only from within the grain mailbox. Do not call from timer callbacks or external threads without routing throughGrainActivation.PostAsync.
Register in silo startup:
// Manual:
silo.Services.AddManagedActivationMemory<RingBuffer>();
// Via BehaviorRegistrationGenerator (emitted automatically when the behavior
// has an IManagedActivationMemory<T> constructor parameter):
silo.Services.AddMyAssemblyBehaviors();Drop-in for IActivationMemory<T> with automatic load-on-first-access and explicit WriteAsync():
flowchart LR
A["OnActivateAsync: ReadStateAsync() loads from storage"] --> C["read .Value with no round-trip"] --> W["WriteStateAsync() write-through after mutation"] --> D["deactivation drops cache; storage keeps data"] --> A
public sealed class AccountBehavior : IGrainBehavior, IAccountGrain, IActivationLifecycle
{
private readonly IPersistentActivationMemory<AccountState> _state;
public AccountBehavior(IPersistentActivationMemory<AccountState> state)
=> _state = state;
public async Task OnActivateAsync(CancellationToken ct)
=> await _state.ReadStateAsync(ct);
public async Task DepositAsync(decimal amount)
{
_state.Value.Balance += amount;
await _state.WriteStateAsync();
}
public Task<decimal> GetBalanceAsync()
=> Task.FromResult(_state.Value.Balance);
}Register in silo startup (replace InMemory with Redis for durable storage):
silo.Services.AddInMemoryGrainStorage();
silo.Services.AddScoped<IPersistentActivationMemory<AccountState>>(sp =>
new PersistentActivationMemoryAccessor<AccountState>(
sp.GetRequiredService<IActivationShellAccessor>().Shell.GetOrCreateHolder<AccountState>(),
sp.GetRequiredService<IGrainStorage>(),
sp.GetRequiredService<IActivationShellAccessor>().Shell.GrainId,
"accounts")); // storage provider nameGrains that want Orleans-style named state injection use IPersistentState<T> with the [PersistentState] attribute:
⚠️ Lifetime gotcha. UnlikeIPersistentActivationMemory<T>, a named slot is not cached in the shell. The handle is resolved per call (scoped), so its in-memoryState/RecordExistsreset between calls. Reading state inOnActivateAsynconly populates the first call's instance. Treat it as a thin handle over a storage record:ReadStateAsync()when you need the value,WriteStateAsync()after a mutation.
flowchart LR
H["fresh handle injected per call (scoped)"] --> RW["ReadStateAsync() / WriteStateAsync() hit storage directly"] --> X["no shell cache: re-read each call"]
public sealed class ProfileBehavior : IGrainBehavior, IProfileGrain
{
private readonly IPersistentState<ProfileData> _profile;
public ProfileBehavior(
[PersistentState("profile", "profileStore")] IPersistentState<ProfileData> profile)
{
_profile = profile;
}
public async Task UpdateNameAsync(string name)
{
_profile.State.Name = name;
await _profile.WriteStateAsync();
}
}Register the named storage provider:
services.AddInMemoryGrainStorage("profileStore");
// or
services.AddRedisGrainStorage("profileStore", opt => opt.ConnectionString = "localhost:6379");The [PersistentState("name","provider")] attribute is resolved at construction time — no reflection at call time.
For grains whose history of decisions matters. Inherit from JournaledGrain<TState,TEvent>:
flowchart LR
A["OnActivateAsync: replay log, rebuild State"] --> R["RaiseEvent() applies to State in memory"] --> C["ConfirmEventsAsync() appends to ILogStorage"] --> D["deactivation drops projection; events stay durable"] --> A
public sealed class BankAccountState { public decimal Balance { get; set; } }
public abstract record AccountEvent;
public record Deposited(decimal Amount) : AccountEvent;
public record Withdrawn(decimal Amount) : AccountEvent;
public sealed class BankAccountBehavior
: JournaledGrain<BankAccountState, AccountEvent>, IBankAccountGrain
{
public BankAccountBehavior(
IActivationMemory<JournaledGrainState<BankAccountState, AccountEvent>> memory,
ICallContext ctx,
ILogStorage logStorage)
: base(memory, ctx, logStorage) { }
protected override void TransitionState(BankAccountState state, AccountEvent @event)
{
switch (@event)
{
case Deposited(var amount): state.Balance += amount; break;
case Withdrawn(var amount): state.Balance -= amount; break;
}
}
public async Task DepositAsync(decimal amount)
{
RaiseEvent(new Deposited(amount));
await ConfirmEventsAsync();
}
public Task<decimal> GetBalanceAsync() => Task.FromResult(State.Balance);
}Key members:
-
RaiseEvent(event)— applies the event toStateimmediately; stages it for persistence -
ConfirmEventsAsync()— persists all staged events toILogStorage -
RetrieveConfirmedEvents(from, to)— reads events back from storage -
Version— number of confirmed events -
OnActivateAsync— automatically replays the event log on first activation
Register an ILogStorage provider (in-memory is provided):
services.AddInMemoryLogStorage();TransitionState runs twice in a grain's lifetime for the same event: once during original
execution (via RaiseEvent), and again during log replay on every reactivation (via
OnActivateAsync). If it calls a nondeterministic API — Guid.NewGuid(), DateTime.Now/
UtcNow/Today, DateTimeOffset.Now/UtcNow, or new Random() — the value computed on replay
will differ from the value computed originally, so the replayed state silently diverges from the
state that was actually persisted. There is no error; the grain just quietly ends up with the
wrong data.
// Wrong — replay produces a different value.Id on every reactivation.
protected override void TransitionState(BankAccountState state, AccountEvent @event)
{
if (@event is Deposited(var amount))
state.LastTransactionId = Guid.NewGuid(); // nondeterministic
}
// Right — compute the value once, before raising the event, and carry it in the payload.
public async Task DepositAsync(decimal amount)
{
RaiseEvent(new Deposited(amount, TransactionId: Guid.NewGuid()));
await ConfirmEventsAsync();
}
protected override void TransitionState(BankAccountState state, AccountEvent @event)
{
if (@event is Deposited(var amount, var transactionId))
state.LastTransactionId = transactionId; // pure — same input, same output, every time
}Quark.Analyzers flags direct nondeterministic calls inside a TransitionState override with
QRK0041 (category Quark.Determinism, enabled by default). It only catches calls made directly
in the override body, not ones hidden behind a helper method it calls.
Fills the gap between IActivationMemory<T> (sync default-construct, no DI) and IManagedActivationMemory<T> (lazy, async access). Use when you need to load a large or externally-sourced resource at grain activation time and then access it synchronously on every call.
flowchart LR
Reg["Load() factory registered in ctor"] --> E["eager: RunEagerInitAsync before OnActivateAsync (DI available)"] --> S["read .Value synchronously on every call"] --> D["Destroy() after OnDeactivateAsync"]
Motivating use case: a time-series grain that loads a large RingBuffer<DataPoint> from a database when it activates. The buffer is too large to persist on every write, but must be fully populated before any method runs and must be readable without await.
Key differences from IManagedActivationMemory<T>:
-
Eager vs. lazy: factory runs before
OnActivateAsyncfires, not on first access. -
Sync access:
.Valueis available withoutawaitafter activation. -
Full DI access: the factory receives the scoped
IServiceProvider, so repositories, options, andICallContextare all available.
public interface IEagerActivationMemory<T> where T : class
{
// Configure in the behavior constructor. The IServiceProvider is the
// activation-scoped provider — resolve repositories or ICallContext from it.
IEagerActivationMemory<T> Load(Func<IServiceProvider, CancellationToken, ValueTask<T>> factory);
// Optional cleanup called after OnDeactivateAsync, same as IManagedActivationMemory<T>.
IEagerActivationMemory<T> Destroy(Func<T, ValueTask> cleanup);
// Synchronous access — safe after grain activation completes.
T Value { get; }
bool IsInitialized { get; }
}public sealed class TimeSeriesBehavior : IGrainBehavior, ITimeSeriesGrain
{
private readonly IEagerActivationMemory<RingBuffer<DataPoint>> _buffer;
public TimeSeriesBehavior(IEagerActivationMemory<RingBuffer<DataPoint>> buffer)
{
_buffer = buffer
.Load(async (sp, ct) =>
{
var repo = sp.GetRequiredService<ITimeSeriesRepository>();
var key = sp.GetRequiredService<ICallContext>().GrainId.Key;
return await repo.LoadRingBufferAsync(key, capacity: 4096, ct);
})
.Destroy(b => b.FlushAsync());
}
// .Value is always safe — init ran before the first call arrived
public Task<DataPoint[]> QueryAsync(DateTimeOffset from, DateTimeOffset to)
=> Task.FromResult(_buffer.Value.Query(from, to));
}When LocalGrainCallInvoker creates a new GrainActivation it:
- Constructs the behavior (behavior constructor fires →
Load(...)registers the factory on the shell'sEagerActivationMemoryHolder<T>). - Calls
RunEagerInitAsync(scopedServiceProvider, ct)— discovers allIEagerActivationMemoryHolderinstances in the memory bag and calls each factory. - Calls
OnActivateAsync(if the behavior implementsIActivationLifecycle).
After step 2 .Value is available for the rest of the activation lifetime, including inside OnActivateAsync. Accessing .Value before initialization (or when Load was never called) throws InvalidOperationException.
// Manual:
silo.Services.AddEagerActivationMemory<RingBuffer<DataPoint>>();
// Via BehaviorRegistrationGenerator (auto-detected from constructor parameters):
silo.Services.AddMyAssemblyBehaviors();services.AddInMemoryGrainStorage(); // default provider
services.AddInMemoryGrainStorage("myProvider"); // named providerservices.AddRedisGrainStorage(options =>
{
options.ConnectionString = "localhost:6379";
});
services.AddRedisGrainStorage("myProvider", options =>
{
options.ConnectionString = "redis.internal:6379";
options.KeyPrefix = "quark:";
});State is serialized to JSON via System.Text.Json and stored as a Redis string under key {prefix}{grainType}/{grainId}/{stateName}.
Grains that haven't received a call within the configured idle period are deactivated by GrainIdleCollector. Configure via SiloRuntimeOptions:
silo.Services.Configure<SiloRuntimeOptions>(opts =>
{
opts.CollectionAge = TimeSpan.FromMinutes(5);
opts.CollectionInterval = TimeSpan.FromMinutes(1);
});To prevent deactivation of a specific grain, call DelayDeactivation(TimeSpan) from inside the behavior:
_ctx.DelayDeactivation(TimeSpan.FromHours(1));