Compensation Is a Replay in Reverse
When a Chronicle saga rejects, the saga log is replayed backwards and every handled message gets its CompensateAsync called. Tracing both rejection paths through the source reveals a surprise - only one of them actually compensates - plus a ForEach that never awaits.
Compensation is the half of the saga pattern you pay for. The happy path is just handlers; the unhappy path is where the pattern earns its keep — refund what was charged, release what was reserved, in reverse order, reliably. This part follows Chronicle's rejection machinery through the source of version 3.2.1, and I'll say up front: this is the article in the series where reading the code changed how I use the library. Two of the things I found contradict what I'd assumed from the README.
The undo list is the saga log
First, where the undo list comes from. Every message a saga handles is appended to the saga log — id, saga type, timestamp, and the message object itself — by the processor's finally block (part 5). When a saga lands in Rejected, the post-processor turns that log into a compensation plan:
private async Task CompensateAsync(ISaga saga, Type sagaType, ISagaContext context)
{
var sagaLogs = await _log.ReadAsync(saga.Id, sagaType);
sagaLogs.OrderByDescending(l => l.CreatedAt)
.Select(l => l.Message)
.ToList()
.ForEach(async message =>
{
await ((Task)saga.InvokeGeneric(nameof(ISagaAction<object>.CompensateAsync),
message, context)).ConfigureAwait(false);
});
}
Read it slowly, because every line teaches something.
OrderByDescending(l => l.CreatedAt) — last handled, first compensated. LIFO, exactly what the saga papers prescribe: unwind in reverse. But CreatedAt is a unix millisecond timestamp. Two messages handled in the same millisecond — entirely possible when a saga completes two quick steps back-to-back — tie, and their compensation order is whatever the sort felt like. If two of your compensations are order-sensitive at millisecond granularity, the ordering guarantee you think you have is statistical.
Select(l => l.Message) + InvokeGeneric — the original message objects are replayed into CompensateAsync, with the right overload picked by reflection on the message's runtime type. This is why the interface forces you to write a CompensateAsync per message type: the replay literally re-dispatches history to those methods. It also quietly couples compensation to persistence quality — whatever your saga log provider re-materialises is what gets dispatched, a thread we'll pull hard in part 8.
The message that killed the saga is in the log too. The processor persists the log entry in finally, before the post-processor compensates. So the failing message's own CompensateAsync runs as the first step of the replay. Your compensation for a step must therefore tolerate that step having half-happened or not happened at all — “release the reservation, if one exists”. Compensations that assume their handler succeeded will misfire on exactly the message that failed.
And the quirk: List.ForEach(async message => ...). An async lambda passed to ForEach compiles to async void. The loop starts each compensation in reverse order — and awaits none of them. CompensateAsync (the outer method) returns as soon as the last one is launched; the pipeline moves on, the per-saga lock is released, and your compensations are still running, unobserved. Three consequences, in ascending severity:
- The reverse ordering only holds up to each compensation's first real
await. Truly async compensations effectively run concurrently. - By the time your
onRejectedhook has finished andProcessAsyncreturns, compensation may not have happened yet. Don't sequence anything afterProcessAsyncthat assumes it has. - An exception inside a compensation is thrown from an
async voidmethod — nothing observes it, and depending on your runtime configuration it can escalate to process death. Wrap everyCompensateAsyncbody in its own try/catch and log. Every one. This is the least optional advice in the series.
To be fair to the library: compensations should be independent and idempotent anyway — that's saga hygiene regardless of framework. But there's a difference between “should be safe to run concurrently” as discipline and as a hard runtime requirement, and Chronicle's ForEach makes it the latter without saying so.
Two ways to reject, one way to compensate
Now the bigger finding. Recall from part 2 that Saga.Reject() sets the state and throws:
public virtual void Reject(Exception innerException = null)
{
State = SagaStates.Rejected;
throw new ChronicleException("Saga rejection called by method", innerException);
}
And from part 5, the processor's catch block:
catch (Exception ex)
{
context.SagaContextError = new SagaContextError(ex);
if (!(saga.State is SagaStates.Rejected))
{
saga.Reject(ex);
}
}
finally
{
await UpdateSagaAsync(message, saga, state);
}
Trace path A — the handler calls Reject() itself. The throw aborts the handler; the catch runs; the state is already Rejected, so the guard skips the re-reject; the catch block completes normally. The finally persists the rejected state. The coordinator proceeds to the post-processor: onRejected fires, compensation replays. Everything the pattern promises.
Trace path B — the handler throws an ordinary exception. The catch runs; state is still Pending; the processor calls saga.Reject(ex) on the saga's behalf — which throws a fresh ChronicleException. We are inside a catch block, so this new exception propagates. The finally still runs (the rejected state and the log entry are persisted — good), but then the exception leaves SagaProcessor.ProcessAsync entirely. Back in the coordinator, the await _processor.ProcessAsync(...) throws, and the next line — the post-processor call — never executes. No onRejected. No compensation. The ChronicleException surfaces out of Task.WhenAll to whoever called ProcessAsync.
The README says an unhandled exception “will cause Reject() to be called and begin the compensation”. Half true, in 3.2.1: rejection is persisted, compensation does not begin. I've verified this both by reading and by running it — path B leaves the saga tombstoned as Rejected (so it will never process again, per the initializer's gate) with its completed steps un-compensated, and hands you an exception. The payment stays charged; the saga stops answering.
I don't think this is intended behaviour — Reject()'s throw is a control-flow device for aborting handlers, and reusing it inside the processor's own catch looks like the one caller the device wasn't designed for. But intended or not, it's the behaviour you operate. So, practically:
Make every rejection a path-A rejection. Don't let ordinary exceptions escape your handlers; convert them:
public async Task HandleAsync(ReserveStock message, ISagaContext context)
{
try
{
await _inventory.ReserveAsync(message.OrderId, message.Items);
Data.StockReserved = true;
}
catch (Exception ex)
{
Reject(ex); // aborts the handler; compensation will actually run
}
}
It's boilerplate, and I resent it slightly, but it converts “tombstone with dangling side-effects plus a surprise exception in the bus consumer” into “clean rejection with compensation and an onRejected hook”. A base-class helper (HandleSafelyAsync(Func<Task>)) removes most of the noise. And whichever path you take, your caller should still expect ChronicleException from ProcessAsync — even path A's cousin, a RejectAsync in a hook, can surface one; treat it as “the saga rejected”, not as infrastructure failure.
Rejection is forever — design accordingly
Both paths end in the same persisted fact: SagaStates.Rejected. And as the initializer showed us in part 5, that fact is a tombstone — every subsequent message for that saga id and type is silently dropped, with no API to resurrect the saga. Rejection in Chronicle is not “the process failed, try again”; it's “the process is dead, the undo has been issued”.
That makes the choice to reject a bigger design decision than it looks. Transient failures — a payment gateway timeout, an inventory service deploy — shouldn't reject; they should stay Pending, recorded in Data (Attempts, LastError), with retry driven by your message infrastructure redelivering. Reserve Reject() for business-final outcomes: card declined, stock discontinued, customer cancelled. My rule of thumb after a couple of years with the library: if you'd retry it, don't reject it; if you'd apologise to the customer for it, reject it.
The compensation checklist
Everything this source-read implies for the compensations you write:
- Idempotent, always — the log can double-deliver (dual-write windows, your own retries).
- Tolerant of un-happened work — the failing message compensates too; “undo if present”.
- Independently safe — the
ForEachmeans order and completion are not guaranteed; never let compensation N depend on compensation N+1 having finished. - Self-observing — try/catch and log inside every
CompensateAsync; nothing else will. - Forward-acting — a compensation is a new business action (refund, release), not a rollback; it can and sometimes should publish its own events.
Next part descends one more level: where all this state and log data actually lives — the two small persistence contracts, the in-memory implementations that hide behind AddChronicle(), and what you must guarantee when you write a provider of your own.