Skip to main content

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: the CCC check is your idempotency guard

The primary idempotency mechanism is the Command Context Consistency expected_position, not the event_id:

  1. Read the command context (GetLatestByCriteria or GetEvents) and note the position.
  2. Save with that position as expected_position and a subsetQuery for the context.
  3. If the save committed and you retry with the same expected_position, Orisun returns ALREADY_EXISTS — the context already advanced — rather than writing a duplicate.

So ALREADY_EXISTS after a retry means "something already moved this context" — very often your own first attempt.

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; the CCC check is what prevents duplicate writes.

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 — the invariant may no longer hold. Loop until the save commits or the decision is no longer valid:

// Stable for this command — NOT uuid.NewString() on each attempt.
eventID := "018f2d5e-00a1-7000-8000-0000000000a1"

for {
latest, err := client.GetLatestByCriteria(ctx, &eventstore.GetLatestByCriteriaRequest{
Boundary: "accounts",
Criteria: []*eventstore.Criterion{{
Tags: []*eventstore.Tag{{Key: "account_id", Value: "acct-1"}},
}},
})
if err != nil {
return err
}

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

_, err = client.SaveEvents(ctx, &eventstore.SaveEventsRequest{
Boundary: "accounts",
Query: &eventstore.SaveQuery{
ExpectedPosition: latest.ContextPosition,
SubsetQuery: &eventstore.Query{
Criteria: []*eventstore.Criterion{{
Tags: []*eventstore.Tag{{Key: "account_id", Value: "acct-1"}},
}},
},
},
Events: []*eventstore.EventToSave{{
EventId: eventID,
EventType: "MoneyDebited",
Data: `{"account_id":"acct-1","amount":40,"balance":` + bal(balance-amount) + `}`,
}},
})
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 — 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 — the command may have committed. Treat it like a conflict: re-read the context. If the carried state already reflects your decision, your first attempt committed; do not apply it again. A command-stable event_id makes this check recognizable downstream.

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
Don't write a duplicate on retryCCC expected_positionALREADY_EXISTS
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