Skip to main content
net10.0net8.0

Redis-native outbox storage + transactional unit-of-work for ZeroAlloc.Saga.Outbox + ZeroAlloc.Saga.Redis. Closes the cross-backend atomic-dispatch story: saga state save and outbox row commit together (or roll back together) inside a single Redis MULTI/EXEC. Native AOT compatible.

Published
Oct 15, 2026
3d ago
Total Downloads
765
765

Install

dotnet add package ZeroAlloc.Saga.Outbox.Redis --version 4.1.3
ProductVersions
.NET
net10.0net8.0

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 the ZeroAlloc.Saga.Outbox bridge commits every step command's dispatch row atomically with the saga state save on the EF Core store, and on the Redis store with WithRedisOutbox(), and on the ORM store with WithOrmOutbox() β€” together they eliminate Saga 1.1's "OCC retry can dispatch twice" caveat for both cross-process races and same-process retries. Durable persistence via ZeroAlloc.Saga.EfCore (single shared SagaInstance table, 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(). See docs/persistence-efcore.md and docs/outbox.md.

NuGet Build License: MIT AOT GitHub Sponsors

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-size byte[]. For the test saga's 21-byte state that is 48 B per call, down from 168 B, asserted by SagaStateAllocationTests (#273). Every durable store calls it once per saga step.
  • ISagaCommandDispatcher indirection: step handlers no longer depend on IMediator directly. Default impl (generator-emitted) forwards to IMediator.Send; Saga.Outbox's WithOutbox() swaps it in for transactional dispatch.
  • SagaCommandRegistry generator-emitted in consumer assemblies β€” central deserialise+dispatch lookup keyed by typeof(T).FullName, resolves ISerializer<T> from DI.
  • ZASAGA016 / ZASAGA017 new diagnostics, and an auto-[ZeroAllocSerializable] partial extension on step command types. The extension and ZASAGA016 were later removed, because ZeroAlloc.Serialisation's generator never saw the generated attribute (#207). Apply [ZeroAllocSerializable] or register an ISerializer<T> for each outbox step command.

Earlier releases: durable persistence

ZeroAlloc.Saga 1.2.0

  • ISagaPersistableState + SagaStateWriter / SagaStateReader ref structs. The writer writes into your IBufferWriter<byte> and allocates 0 B; the reader allocates 0 B apart from the string and byte[] values it returns. The generated Snapshot() returns a new byte[] 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 common record struct Foo(TPrim Bar) shape, byte[], and Nullable<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 without MakeGenericType.
  • EfCore-aware handler emit β€” when the generator detects WithEfCoreStore in the same compilation, the emitted notification handlers wrap the load β†’ step β†’ save loop in a configurable retry catching DbUpdateConcurrencyException.
  • 2 new diagnostics:
    • ZASAGA014 (Error) β€” saga state field has an unsupported type.
    • ZASAGA015 (Info, suppressible) β€” saga commands should be idempotent under durable backends; fires when WithEfCoreStore / WithRedisStore is detected in the same compilation.
  • Implicit AddMediator() β€” AddSaga() no longer requires a separate services.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

Samples

  • samples/OrderFulfillment/ β€” full demo with happy path, compensation, orphan-event handling, and operator-initiated compensation. dotnet run --project samples/OrderFulfillment/ (InMemory) or dotnet run --project samples/OrderFulfillment/ -- --efcore (EfCore + SQLite).
  • samples/AotSmoke/ β€” minimal saga end-to-end published with PublishAot=true. Run by the aot-smoke CI 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 with PublishAot=true and run in CI.
  • samples/AotSmokeOrm/ β€” ZeroAlloc.Saga.Orm and ZeroAlloc.Saga.Outbox.Orm published with PublishAot=true and run in CI against a SQLite file.
  • samples/AotSmokeRedis/ β€” ZeroAlloc.Saga.Redis and ZeroAlloc.Saga.Outbox.Redis published with PublishAot=true in CI; running it needs a Redis server, so CI publishes only.
  • samples/ResilienceSample/ β€” ZeroAlloc.Saga.Resilience retrying a flaky receiver, published with PublishAot=true and 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 β€” for INotification, IRequest, IMediator.Send
  • Microsoft.Extensions.DependencyInjection β€” for AddSaga(), 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.Orm or ZeroAlloc.Saga.Redis for durability.
  • ZeroAlloc.Saga.EfCore Native AOT publish β€” the runtime library itself is AOT-clean, but a fully PublishAot=true binary is blocked upstream by EF Core's experimental NativeAOT support (precompiled queries don't yet cover the store's tracked Set<>().AsTracking()... shape). Use the EfCore backend on JITted hosts, or the ORM or Redis store, whose smoke samples publish with PublishAot=true in CI. Tracked in #16. See docs/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

No structural changes between v4.1.2 and v4.1.3.

Adoption Assessment

Assess this package against its supported frameworks, license, maintenance signals and dependency graph before making it a project standard.

Compatibility metadata lists 2 target frameworks; confirm the exact target used by your application.The published metadata declares 26 direct dependency entries across framework groups. Inspect the resolved dependency graph for your target framework.

Related Guides & Workflows

Learn how to evaluate, integrate, or audit this package in your .NET project.