Skip to main content
Version: 0.11.0

Idempotency & Retry

Orisun gives you two distinct idempotency problems to solve, and a mechanism for each:

  • Write side: a command may need to be retried (network blip, contention, or a lost response). Retrying must not double-apply the business decision.
  • Read side: delivery is at least once, so a projector can see the same event more than once. Reprocessing must not double-apply a side effect.

Write side: CCC can make a retry self-conflicting​

Orisun does not provide an event-id uniqueness constraint or an idempotency-key table. A CCC observation makes a retry self-conflicting only when the committed command advanced at least one of its observed queries:

  1. Read the command context (GetLatestByCriteria or a complete GetEvents query), then retain its exact query and latest matching position as one observation.
  2. Pass every observation to SaveEventsV2.consistency.
  3. Make sure at least one event produced by the command matches an observed query.
  4. If the save committed and the exact request is retried, that observation is now stale, so Orisun returns ALREADY_EXISTS rather than writing a duplicate.

If none of the written events match any observed query, the observations remain current and the same request can commit again. In that shape, CCC still protects the business decision against concurrent context changes, but it is not a retry deduplicator. Add an explicit command-id criterion to the context or perform an application-level idempotency check.

So ALREADY_EXISTS after a retry means "something moved this context." That may be the first attempt, or it may be a competing command; always re-read and inspect the resulting state before deciding what happened.

note

The store does not deduplicate by event_id. There is no unique constraint on event_id (the primary key is the per-boundary global_id). A stable event_id is for detection and consumer dedup. CCC prevents a repeated write only under the matching-query condition described above.

Use a command-stable event_id​

Assign the event_id when the command is first accepted, then reuse it on every retry of that command. A UUIDv7 works well when it is generated once and carried with the command; what breaks idempotency is generating a fresh value on every attempt. A retried command should carry the same event_id as the original so you and your projectors can recognize it.

Retry loop​

On ALREADY_EXISTS, re-read the context and re-decide because the invariant may no longer hold. Loop until the save commits or the decision is no longer valid:

// Stable for this command. Do not call uuid.NewString() on each attempt.
eventID := "018f2d5e-00a1-7000-8000-0000000000a1"
accountOpenedID := "018f2d5e-2001-7000-8000-000000000001"
accountCriteria := []*eventstore.Criterion{
{Tags: []*eventstore.Tag{
{Key: "eventType", Value: "AccountOpened"},
{Key: "accountOpenedId", Value: accountOpenedID},
}},
{Tags: []*eventstore.Tag{{Key: "scopes.accountOpenedId", Value: accountOpenedID}}},
}

for {
latest, err := client.GetLatestByCriteria(ctx, &eventstore.GetLatestByCriteriaRequest{
Boundary: "accounts",
Criteria: accountCriteria,
})
if err != nil {
return err
}

balance := readBalance(latest) // application reads carried state
if balance < amount {
return ErrInsufficientFunds // no longer valid; stop
}

_, err = client.SaveEventsV2(ctx, &eventstore.SaveEventsV2Request{
Boundary: "accounts",
Consistency: []*eventstore.ConsistencyObservation{{
Query: &eventstore.Query{Criteria: accountCriteria},
Position: latest.ContextPosition,
}},
Events: []*eventstore.EventToSave{{
EventId: eventID,
EventType: "MoneyDebited",
Data: `{"moneyDebitedId":"` + eventID + `","amount":40,"balanceAfter":` + bal(balance-amount) + `,"scopes.accountOpenedId":"` + accountOpenedID + `"}`,
}},
})
if err == nil {
return nil
}

var conflict *orisun.OptimisticConcurrencyException
if !errors.As(err, &conflict) {
return err // a real failure, not a concurrency signal
}
// Context changed between read and write, so loop re-reads and re-decides.
}

Ambiguous failures: "maybe it committed"​

A timeout after the server received the save but before you got the response is ambiguous because the command may have committed. Treat it like a conflict: re-read the context. If the carried state already reflects your stable command or event id, your first attempt committed; do not apply it again. If it does not, re-run business validation before retrying. A blind retry is safe only when the original write necessarily advanced one of the reused observations.

Read side: deduplicate by event_id in the projector​

Because delivery is at least once, a projector must treat apply(event) as idempotent. Two common approaches:

  • Idempotent writes: make the side effect a keyed upsert keyed by event_id, so applying the same event twice converges. Simplest.
  • Processed-event table: record each processed event_id; skip events already seen. Needed when the side effect is not naturally idempotent (e.g. appending to an external ledger).

Persist the projector checkpoint after the side effect is durable, so a restart resumes from the last fully-applied event rather than re-emitting it. See Delivery Guarantees.

Summary​

ConcernMechanism
Make a committed retry conflictWrite into at least one reused CCC observation query
Recognize a retried commandCommand-stable event_id
Don't double-apply on redeliveryConsumer dedup by event_id
Recover from a lost responseRe-read the context before retrying