Reference
Unique keys and OCC
Reference for go-sequent optimistic concurrency control and aggregate unique keys: ExpectedVersion, ErrOptimisticConcurrency, tenant-scoped UniqueKeys scopes and ErrUniqueKeyViolation.
Unique keys and OCC
Two checks happen on every append, both inside the event store: optimistic concurrency control (OCC) on each stream, and uniqueness of business keys across aggregates.
For how these fit with the UnitOfWork and idempotency, see Write-path consistency.
Optimistic concurrency control
Every aggregate stream has a version: the sequence number of its last committed event. When the repository commits, each StreamAppend carries ExpectedVersion, the version the aggregate had when it was loaded.
The store accepts the append only if the stream tip equals ExpectedVersion. The first new event must then have sequence ExpectedVersion + 1. Otherwise it returns ErrOptimisticConcurrency.
err := app.Service.Execute(ctx, cmd)
if errors.Is(err, eventstore.ErrOptimisticConcurrency) {
// Another writer appended to this stream first.
// Execute again; the aggregate will be loaded at the new version.
}
How Postgres checks it
The store_events logic follows Sequent’s procedure of the same name:
- Lock the aggregate row (
SELECT ... FOR NO KEY UPDATE). - Read the last sequence number.
- Require the first new event to be last plus one, or one for an empty stream.
- Insert the events. A primary key collision means a conflict.
- Update unique keys.
Integrity failures map to ErrOptimisticConcurrency or ErrUniqueKeyViolation.
What to do on conflict
Do not patch the old state. Run the whole command again so that handlers load fresh aggregates and re-check the rules. A withdrawal that was valid at version 5 may be invalid at version 6.
- Retry with backoff and a bounded attempt count.
- Keep transactions short. One
Executebatch is one transaction. - Expect bursts of conflicts after a failover and on hot aggregates.
- Watch
sequent.optimistic_conflict.total(OpenTelemetry).
If a single aggregate takes sustained conflicts, the model may be too coarse. Splitting a hot aggregate, for example one per merchant and currency instead of one per merchant, reduces contention.
Unique keys
OCC protects one stream. Unique keys protect a business rule across streams, such as “one account per owner”.
An aggregate returns a map from scope to value:
func (a *Account) UniqueKeys() map[string]any {
if !a.Open {
return nil
}
scope := "bank.Account.owner"
if pk := a.PartitionKey(); pk != "" {
scope = "tenant:" + pk + ":bank.Account.owner"
}
return map[string]any{scope: a.Owner}
}
On append the store records (scope, key) in the aggregate_unique_keys table, which has a unique constraint. If another aggregate already holds the same pair, the append fails with ErrUniqueKeyViolation:
if errors.Is(err, eventstore.ErrUniqueKeyViolation) {
// e.g. this owner already has an account in this tenant
}
The Base type provides SetUniqueKeys(map[string]any), so an applier can refresh the keys whenever state that feeds them changes. After loading from a snapshot, call it again, as the bank example does with refreshUniqueKeys.
Scope design
The table is global within the shared schema. Put the tenant in the scope so one tenant cannot block another:
scope := tenancy.UniqueScope(tenantID, "email")
// "tenant:{uuid}:email"
Use a descriptive suffix per rule, and include the aggregate type when the same word means different things. Keys are checked on append.
OCC, unique keys and idempotency
| Question | Mechanism |
|---|---|
| Did this stream change since I read it? | OCC (ExpectedVersion) |
| Is this business value already taken by another aggregate? | Unique keys |
| Did I already handle this request? | Idempotency |
Related
Docs track the go-sequent library. When APIs drift, the source wins.