Redis-backed durable persistence for ZeroAlloc.Saga. WATCH/MULTI/EXEC-based optimistic concurrency, hash-per-saga storage, byte-encoded state via ISagaPersistableState. Native AOT compatible.
Install
| Product | Versions |
|---|---|
| .NET |
README
ZeroAlloc.Saga
Source-generated long-running process orchestration for the ZeroAlloc ecosystem.
Status: AOT compatible. The generator-emitted saga handler runs each OCC retry attempt in a fresh
IServiceScope, and theZeroAlloc.Saga.Outboxbridge commits every step command's dispatch row atomically with the saga state save on the EF Core store, and on the Redis store withWithRedisOutbox(), and on the ORM store withWithOrmOutbox()β together they eliminate Saga 1.1's "OCC retry can dispatch twice" caveat for both cross-process races and same-process retries. Durable persistence viaZeroAlloc.Saga.EfCore(single sharedSagaInstancetable, row-version OCC, retry-on-conflict) is unchanged. InMemory remains the default backend; switch to EfCore with one fluent call, and opt into the outbox bridge with.WithOutbox(). Seedocs/persistence-efcore.mdanddocs/outbox.md.
Overview
ZeroAlloc.Saga lets you express multi-step business workflows declaratively
as a partial class. The source generator emits state-machine code,
notification handlers, and dispatch wiring. Compensation runs in reverse on
failure. No reflection, no open-generic resolution at runtime β the whole
runtime is Native AOT-compatible and exercised by an aot-smoke CI job
that publishes a sample with PublishAot=true and asserts end-to-end execution.
[Saga]
public partial class OrderFulfillmentSaga
{
public OrderId OrderId { get; private set; }
public decimal Total { get; private set; }
[CorrelationKey] public OrderId Correlation(OrderPlaced e) => e.OrderId;
[CorrelationKey] public OrderId Correlation(StockReserved e) => e.OrderId;
[CorrelationKey] public OrderId Correlation(PaymentCharged e) => e.OrderId;
[CorrelationKey] public OrderId Correlation(PaymentDeclined e) => e.OrderId;
[Step(Order = 1, Compensate = nameof(CancelReservation))]
public ReserveStockCommand ReserveStock(OrderPlaced e)
{
OrderId = e.OrderId; Total = e.Total;
return new ReserveStockCommand(e.OrderId, e.Total);
}
[Step(Order = 2, Compensate = nameof(RefundPayment), CompensateOn = typeof(PaymentDeclined))]
public ChargeCustomerCommand ChargeCustomer(StockReserved e) => new(OrderId, Total);
[Step(Order = 3)]
public ShipOrderCommand ShipOrder(PaymentCharged e) => new(OrderId);
public CancelReservationCommand CancelReservation() => new(OrderId);
public RefundPaymentCommand RefundPayment() => new(OrderId);
}
Wiring (one line per saga):
// AddSaga() calls AddMediator() for you. A separate AddMediator() call is
// not needed, though it remains harmless (idempotent TryAdd*).
services.AddSaga()
.WithOrderFulfillmentSaga(); // generator-emitted extension
That's it. Publish OrderPlaced via IMediator.Publish and the saga drives
itself: each [Step] runs in correlation-key order, returned commands flow
through IMediator.Send, downstream events advance the FSM, and a terminal
Completed (or Compensated) state removes the saga from the store
automatically.
What's new
ZeroAlloc.Saga.Outbox.Orm (new package)
Atomic outbox dispatch on the ZeroAlloc.ORM saga store. A saga step's outbox rows are written in the same database transaction as the saga row, through ZeroAlloc.Outbox.Orm, so a failed save or removal discards both.
services.AddOutbox().WithOrm(OutboxOrmDialect.SqlServer);
services.AddSaga()
.WithOrmStore()
.WithOutbox()
.WithOrmOutbox() // <-- commits the outbox rows in the saga store's transaction
.WithOrderFulfillmentSaga();
Requires ZeroAlloc.Outbox 4.4.3 or later. See
docs/outbox.md.
ZeroAlloc.Saga.Outbox.Redis (new package β closes Phase 3a-2)
Redis-native atomic dispatch. Saga state save and outbox-row write commit
together in a single Redis MULTI/EXEC, so a failed save discards both β
matching the Saga.EfCore + Saga.Outbox.EfCore story for Redis-backed
sagas.
services.AddSingleton<IConnectionMultiplexer>(_ => ConnectionMultiplexer.Connect("..."));
services.AddOutbox(); // ZeroAlloc.Outbox's worker dispatches the saga commands
services.AddSaga()
.WithRedisStore()
.WithOutbox()
.WithRedisOutbox() // <-- enlists outbox writes into the saga store's MULTI/EXEC
.WithOrderFulfillmentSaga();
See docs/outbox-redis.md for the full atomicity
contract, the IRedisSagaTransactionContributor extension point, and how dispatch claims entries
under a lease.
ZeroAlloc.Saga.Redis (new package)
Second durable backend, mirroring Saga.EfCore. One Redis Hash per saga
instance with state (bytes) + version (Guid) fields; OCC via
WATCH/MULTI/EXEC; conflicts surface as RedisSagaConcurrencyException
which the generator-emitted handler's retry loop catches alongside EfCore's
exceptions.
services.AddSingleton<IConnectionMultiplexer>(_ => ConnectionMultiplexer.Connect("..."));
services.AddSaga()
.WithRedisStore(opts => opts.KeyPrefix = "myapp:saga")
.WithOrderFulfillmentSaga();
Mutually exclusive with WithEfCoreStore<TContext>(). Composition with
WithOutbox() alone is at best at-least-once, and fails at startup with an EF Core outbox store;
add ZeroAlloc.Saga.Outbox.Redis and WithRedisOutbox() for full atomic-commit guarantees,
see above. Requires StackExchange.Redis 3.4 or later. See docs/persistence-redis.md.
ISagaUnitOfWork abstraction (Phase 3a-2 stage 1)
The dispatch-side outbox enlistment now goes through ISagaUnitOfWork
instead of IOutboxStore directly, opening the door for backend-specific
unit-of-work implementations (Redis MULTI/EXEC, etc.). No behavior change
for existing EfCore + Outbox consumers β the default
OutboxStoreSagaUnitOfWork is a passthrough.
Builder API: Add{Saga}Saga() β With{Saga}Saga()
The generator-emitted per-saga registration method is renamed from
Add{Saga}Saga() to With{Saga}Saga() so it aligns with the rest of
the builder API (WithEfCoreStore, WithOutbox, WithResilience).
The legacy Add-prefixed shim, which emitted diagnostic ZASAGA018,
was removed in 4.0.
services.AddSaga()
.WithEfCoreStore<AppDbContext>()
.WithOrderFulfillmentSaga();
ZeroAlloc.Saga.Resilience (new package)
Optional bridge that wraps every saga step command's dispatch in a
ZeroAlloc.Resilience policy stack β retry, timeout, circuit-breaker,
rate-limit. One fluent call configures the pipeline:
services.AddSaga()
.WithEfCoreStore<AppDbContext>(opts => opts.MaxRetryAttempts = 3)
.WithOrderFulfillmentSaga()
.WithResilience(r =>
{
r.Retry = new RetryPolicy(maxAttempts: 5, backoffMs: 200, jitter: true, perAttemptTimeoutMs: 5_000);
r.CircuitBreaker = new CircuitBreakerPolicy(maxFailures: 10, resetMs: 30_000, halfOpenProbes: 1);
});
Composition order is outermost-first: circuit-breaker β rate-limit β timeout β retry β inner.DispatchAsync. Caller cancellation propagates
as OperationCanceledException; policy denials surface as
ResilienceException(Policy: ...) so consumers can disambiguate.
Requires ZeroAlloc.Resilience 3.3 or later. See docs/resilience.md.
ZeroAlloc.Saga.Outbox (new package)
Optional bridge that routes every saga step command through
ZeroAlloc.Outbox so dispatch is committed in the same database
transaction as the saga state save. Eliminates the cross-process race
where a state update can succeed without the corresponding command being
delivered.
services.AddOutbox().WithEfCore<AppDbContext>(); // the worker that dispatches saga commands
services.AddSaga()
.WithEfCoreStore<AppDbContext>(opts => opts.MaxRetryAttempts = 3)
.WithOutbox() // <-- one fluent call
.WithOrderFulfillmentSaga();
Requires ZeroAlloc.Outbox 4.4.3+ and ZeroAlloc.Serialisation 2.8.1+. EF Core users also need
the matching 4.x ZeroAlloc.Outbox.EfCore; Saga's floor on ZeroAlloc.Outbox does not raise it.
See docs/outbox.md for the full setup, the
ISerializer<T> each step command needs, ZASAGA017, and dispatch options. Upgrading from 3.x: see
docs/migrating-to-v4.md.
ZeroAlloc.Saga runtime
- The generated
Snapshot()allocates only the array it returns. It writes into a scratch writer each thread keeps and reuses, and returns an exact-sizebyte[]. For the test saga's 21-byte state that is 48 B per call, down from 168 B, asserted bySagaStateAllocationTests(#273). Every durable store calls it once per saga step. ISagaCommandDispatcherindirection: step handlers no longer depend onIMediatordirectly. Default impl (generator-emitted) forwards toIMediator.Send;Saga.Outbox'sWithOutbox()swaps it in for transactional dispatch.SagaCommandRegistrygenerator-emitted in consumer assemblies β central deserialise+dispatch lookup keyed bytypeof(T).FullName, resolvesISerializer<T>from DI.ZASAGA016/ZASAGA017new diagnostics, and an auto-[ZeroAllocSerializable]partial extension on step command types. The extension andZASAGA016were later removed, because ZeroAlloc.Serialisation's generator never saw the generated attribute (#207). Apply[ZeroAllocSerializable]or register anISerializer<T>for each outbox step command.
Earlier releases: durable persistence
ZeroAlloc.Saga 1.2.0
ISagaPersistableState+SagaStateWriter/SagaStateReaderref structs. The writer writes into yourIBufferWriter<byte>and allocates 0 B; the reader allocates 0 B apart from thestringandbyte[]values it returns. The generatedSnapshot()returns a newbyte[]per call. Every[Saga]class implements the interface via a generator-emitted partial; backends use it to round-trip saga state across process boundaries. Supported state shapes: primitives, enums,string,DateTime/DateTimeOffset/TimeSpan/Guid,[TypedId]-attributed types, the commonrecord struct Foo(TPrim Bar)shape,byte[], andNullable<T>wrappers thereof.[NotSagaState]escape-hatch attribute β exclude transient or computed members from generator-emitted Snapshot/Restore.SagaRetryOptions+ISagaStoreRegistrarβ backend-agnostic retry knobs and a typed registrar indirection so durable backends swap themselves in withoutMakeGenericType.- EfCore-aware handler emit β when the generator detects
WithEfCoreStorein the same compilation, the emitted notification handlers wrap the load β step β save loop in a configurable retry catchingDbUpdateConcurrencyException. - 2 new diagnostics:
ZASAGA014(Error) β saga state field has an unsupported type.ZASAGA015(Info, suppressible) β saga commands should be idempotent under durable backends; fires whenWithEfCoreStore/WithRedisStoreis detected in the same compilation.
- Implicit
AddMediator()βAddSaga()no longer requires a separateservices.AddMediator()call. - InMemory backend unchanged β v1.0 users see no behavioural change.
ZeroAlloc.Saga.EfCore 1.0.0 (new package)
First durable backend for ZeroAlloc.Saga. Single shared SagaInstance
table keyed by (SagaType, CorrelationKey); provider-agnostic row-version
optimistic concurrency; automatic retry-on-conflict driven by
EfCoreSagaStoreOptions. See
docs/persistence-efcore.md for the full
guide.
services.AddDbContext<AppDbContext>(opts => opts.UseSqlServer(connStr));
services.AddSaga()
.WithEfCoreStore<AppDbContext>()
.WithOrderFulfillmentSaga();
Plus, in your DbContext:
protected override void OnModelCreating(ModelBuilder mb) => mb.AddSagas();
Documentation
docs/concepts.mdβ saga lifecycle, generator output, AOT storydocs/correlation.mdβ[CorrelationKey]rules, multi-saga subscription, composite keysdocs/compensation.mdβCompensate/CompensateOn, reverse cascade,ISagaManager.CompensateAsyncdocs/persistence-efcore.mdβ durable persistence withZeroAlloc.Saga.EfCore: setup, migrations, OCC, idempotency, AOT storydocs/persistence-redis.mdβ durable persistence withZeroAlloc.Saga.Redis: Hash layout, WATCH/MULTI/EXEC OCCdocs/outbox.mdβ atomic command dispatch withZeroAlloc.Saga.Outbox, store pairings, serializersdocs/outbox-redis.mdβ Redis-native atomic dispatch withZeroAlloc.Saga.Outbox.Redisdocs/resilience.mdβ retry, timeout, circuit-breaker and rate-limit policies withZeroAlloc.Saga.Resiliencedocs/diagnostics.mdβ everyZASAGA0XXdiagnostic with examples and fixesdocs/migrating-to-v4.mdβ upgrading from 3.x
Samples
samples/OrderFulfillment/β full demo with happy path, compensation, orphan-event handling, and operator-initiated compensation.dotnet run --project samples/OrderFulfillment/(InMemory) ordotnet run --project samples/OrderFulfillment/ -- --efcore(EfCore + SQLite).samples/AotSmoke/β minimal saga end-to-end published withPublishAot=true. Run by theaot-smokeCI job on every push.samples/AotSmokeEfCore/β EfCore variant of the smoke. Builds under the same AOT/trim analyzer set; runs end-to-end with SQLite in-memory verifying RowVersion rotation and Completed-state row removal.samples/AotSmokeOutbox/β the outbox bridge published withPublishAot=trueand run in CI.samples/AotSmokeOrm/βZeroAlloc.Saga.OrmandZeroAlloc.Saga.Outbox.Ormpublished withPublishAot=trueand run in CI against a SQLite file.samples/AotSmokeRedis/βZeroAlloc.Saga.RedisandZeroAlloc.Saga.Outbox.Redispublished withPublishAot=truein CI; running it needs a Redis server, so CI publishes only.samples/ResilienceSample/βZeroAlloc.Saga.Resilienceretrying a flaky receiver, published withPublishAot=trueand run in CI.
Install
dotnet add package ZeroAlloc.Saga # runtime + generator (single package)
Optional packages:
| Package | Adds |
|---|---|
ZeroAlloc.Saga.EfCore |
Durable saga store over an EF Core DbContext (WithEfCoreStore<TContext>()) |
ZeroAlloc.Saga.Orm |
Durable saga store over ZeroAlloc.ORM (WithOrmStore()) |
ZeroAlloc.Saga.Redis |
Durable saga store over Redis (WithRedisStore()) |
ZeroAlloc.Saga.Outbox |
Step commands dispatched through ZeroAlloc.Outbox (WithOutbox()) |
ZeroAlloc.Saga.Outbox.Orm |
Outbox rows committed in the ORM saga store's transaction (WithOrmOutbox()) |
ZeroAlloc.Saga.Outbox.Redis |
Outbox rows committed in the Redis saga store's MULTI/EXEC (WithRedisOutbox()) |
ZeroAlloc.Saga.Resilience |
Resilience policies around step dispatch (WithResilience()) |
The base ZeroAlloc.Saga package contains both the runtime and the source
generator (bundled as an analyzer asset). No separate .Generator package to
install.
Hard dependencies pulled in transitively:
ZeroAlloc.Mediatorβ forINotification,IRequest,IMediator.SendMicrosoft.Extensions.DependencyInjectionβ forAddSaga(),WithXxxSaga()Microsoft.Extensions.Logging.Abstractionsβ for the saga-handler loggers
Diagnostics
16 source-generator diagnostics catch authoring mistakes at compile time:
| ID | What | Severity |
|---|---|---|
| ZASAGA001 | [Saga] class not partial |
error (code-fix: Make partial) |
| ZASAGA002 | Saga is static, abstract, generic, or nested | error |
| ZASAGA003 | Saga lacks parameterless ctor | error |
| ZASAGA004 | Step input event missing [CorrelationKey] |
error |
| ZASAGA005 | [CorrelationKey] methods return inconsistent types |
error |
| ZASAGA006 | [CorrelationKey] method has wrong signature |
error |
| ZASAGA007 | [Step(Order = ...)] values have gaps or duplicates |
error (code-fix: Renumber steps) |
| ZASAGA008 | [Step] method has wrong signature |
error |
| ZASAGA009 | [Step.Compensate] target missing or mis-shaped |
error (code-fix: Add compensation method) |
| ZASAGA010 | [Step.CompensateOn] event missing [CorrelationKey] |
error |
| ZASAGA011 | [CorrelationKey] method appears to mutate state |
warning |
| ZASAGA012 | Compensate without CompensateOn β dead code |
warning |
| ZASAGA013 | Two sagas correlate on same event with different key types | warning |
| ZASAGA014 | Saga state member has a type the state serializer does not support | error |
| ZASAGA015 | Durable backend detected: step commands should be idempotent | info |
| ZASAGA017 | Step command type is in a referenced assembly, so the outbox needs its ISerializer<T> |
info |
ZASAGA016 is retired, and ZASAGA018, the Add{Saga}Saga() legacy shim, was removed in 4.0;
use With{Saga}Saga().
Every diagnostic links to docs/diagnostics.md with a
worked example.
Known limitations
- InMemory persistence is not durable. Process crash loses all in-flight
sagas. Switch to
ZeroAlloc.Saga.EfCore,ZeroAlloc.Saga.OrmorZeroAlloc.Saga.Redisfor durability. ZeroAlloc.Saga.EfCoreNative AOT publish β the runtime library itself is AOT-clean, but a fullyPublishAot=truebinary is blocked upstream by EF Core's experimental NativeAOT support (precompiled queries don't yet cover the store's trackedSet<>().AsTracking()...shape). Use the EfCore backend on JITted hosts, or the ORM or Redis store, whose smoke samples publish withPublishAot=truein CI. Tracked in #16. Seedocs/persistence-efcore.md.- No step timeouts. A saga waits indefinitely for its next event; there is no per-step timeout or deadline.
- No built-in telemetry. The saga runtime emits log entries but no OpenTelemetry spans or metrics.
Roadmap
Shipped, all at the current 4.x version:
| Package | Provides |
|---|---|
ZeroAlloc.Saga |
runtime + generator + InMemory store + diagnostics + AOT, ISagaCommandDispatcher indirection, snapshot/rehydrate via ISagaPersistableState |
ZeroAlloc.Saga.EfCore, ZeroAlloc.Saga.Orm, ZeroAlloc.Saga.Redis |
durable persistence with retry-on-OCC-conflict |
ZeroAlloc.Saga.Outbox, .Outbox.Orm, .Outbox.Redis |
atomic command dispatch through the transactional outbox |
ZeroAlloc.Saga.Resilience |
retry / timeout / circuit-breaker / rate-limit around step dispatch |
Not yet started, no package exists:
- per-step timeouts and deadlines, through ZeroAlloc.Scheduling
- OpenTelemetry spans and metrics, and an operations dashboard
- an event-sourced store and a choreography mode
License
MIT
Changes from Previous Version
Adoption Assessment
Assess this package against its supported frameworks, license, maintenance signals and dependency graph before making it a project standard.
Related Guides & Workflows
Learn how to evaluate, integrate, or audit this package in your .NET project.