Skip to content

Delivery identity

A reactor can be handed the same event more than once, and nothing in the event itself says whether this is the first time. Declare a ReactorDelivery parameter on a handler method and Chronicle gives you a stable identity for this delivery of this event to this reactor — the same value every time the event comes back, and a different value for every genuinely different delivery.

using Cratis.Chronicle.Events;
using Cratis.Chronicle.Reactors;
[EventType]
public record IdempotentPaymentDue(string OrderId, decimal Amount);
public interface IIdempotentPaymentGateway
{
Task Charge(string orderId, decimal amount);
}
// Your storage, not Chronicle's. One row per completed delivery, keyed by its identity.
public interface IDeliveryReceipts
{
Task<bool> HasCompleted(DeliveryId delivery);
Task Complete(DeliveryId delivery);
}
public class IdempotentBilling(IIdempotentPaymentGateway payments, IDeliveryReceipts receipts) : IReactor
{
public async Task PaymentDue(IdempotentPaymentDue @event, ReactorDelivery delivery)
{
if (await receipts.HasCompleted(delivery.Id))
{
return;
}
await payments.Charge(@event.OrderId, @event.Amount);
await receipts.Complete(delivery.Id);
}
}

delivery.Id is the key. Record it when the side effect completes, check it before doing the side effect again.

OnceOnly and Replay both act on replay — the deliberate re-delivery you trigger by rewinding an observer, or that a redaction or revision triggers for you. Neither one touches the other reason an event comes back: your handler failed part-way through, the event-source partition paused, and recovering that partition re-delivers the event as an ordinary observation. That is not a replay, so OnceOnly does not suppress it — nor should it, since re-running the handler is the entire point of a retry. But if the handler had already charged the card before it failed, the retry charges it again (see Troubleshooting for the symptom, and Observers for how retries and quarantine are configured).

The delivery identity is the seam for that case. It is the same across the failure and the recovery, so a record you keep under it survives the retry.

Payment APIYour receipt storeReactorChroniclePayment APIYour receipt storeReactorChroniclepartition fails, then recoversPaymentDue (delivery A)has A completed?nochargerecord Ahandler fails after the chargePaymentDue (delivery A — same identity)has A completed?yesskip the charge, carry on
MechanismCovers replayCovers recovery after a failureNeeds storage
[OnceOnly]Yes — the handler is skippedNoNo
[Replay]Yes — a different handler takes overNoNo
ReactorDeliveryYes — a replay repeats the same delivery, so the identity matchesYesYes — yours

They compose rather than compete. Reach for [OnceOnly] or [Replay] first: they are declarative, cost nothing, and cover replay completely. Add the delivery identity when the side effect must also survive a partition failure, and keep the marker — [OnceOnly] then spares you a storage round-trip on every replayed event, and the receipt covers the retries the marker cannot see.

using Cratis.Chronicle.Events;
using Cratis.Chronicle.Reactors;
[EventType]
public record CustomerWelcomed(string CustomerId);
public interface IWelcomeMail
{
Task Send(string customerId);
}
public class WelcomeMailer(IWelcomeMail mail, IDeliveryReceipts receipts) : IReactor
{
[OnceOnly]
public async Task SendWelcomeMail(CustomerWelcomed @event, ReactorDelivery delivery)
{
if (await receipts.HasCompleted(delivery.Id))
{
return;
}
await mail.Send(@event.CustomerId);
await receipts.Complete(delivery.Id);
}
}

The identity is built from six things Chronicle already knows, and nothing else:

ComponentWhat it is
ReactorThe reactor the event is delivered to
EventStoreThe event store the event belongs to
NamespaceThe namespace within that event store
EventSequenceThe event sequence the reactor observes — the event log, or an inbox
PartitionThe event source id the event is observed under
SequenceNumberThe event’s position in the event sequence

Two deliveries agreeing on all six are the same delivery; differing on any one of them makes them different. There is deliberately nothing in there about why the event arrived, so a replay and the live delivery it repeats share one identity. If a handler needs to know why, take an EventContext alongside and read its ObservationState.

ReactorDelivery is a record, so you can compare two of them directly. delivery.Id renders the same components as a single string for use as a storage key.

It is an identity. It is not exactly-once delivery, and it does not make your reactor idempotent — it gives you the one thing you cannot compute yourself, and leaves the rest to you.

  • Chronicle does not know whether your side effect ran. It never sees your payment API or your mail server, so it cannot suppress a repeat on your behalf. Nothing is skipped unless your own code skips it.
  • Chronicle stores no receipt. There is no framework-managed table of completed deliveries, no retention policy to configure, and no state added to your event store. The record is yours to write, index, and expire.
  • The gap between the effect and the record is still a gap. Charge the card, die before writing the receipt, and the retry charges again. Recording the identity narrows at-least-once towards at-most-once exactly as far as your record is atomic with the effect — write both in one database transaction and the gap closes; call a remote API and it does not. Where you cannot close it, use the identity as the idempotency key the remote API itself accepts, and let it deduplicate.
  • Delivery is still at-least-once. That is the contract Chronicle offers and this does not change it. The identity makes at-least-once tractable; it does not replace it.

If the integration you are calling is already idempotent, you do not need any of this — pass delivery.Id as its idempotency key and stop there.