Optimistic Locking
Optimistic locking is the concurrency control mechanism that ensures commands always run against the most recent state of an aggregation.
The Problem
Imagine two commands running concurrently on the same aggregate:
Thread A: $account.withdraw(100) # reads balance = 500
Thread B: $account.withdraw(200) # reads balance = 500
Both threads read the same balance. Both validate successfully. Both emit events. The final balance is wrong: 500 - 100 - 200 = 200, but it should have been 500 - 100 = 400, then the second command should have seen 400.
The Solution: CAS Version Checking
Each aggregate instance tracks a version number — the index of the last event applied. When a command emits an event, it includes its expected version. The storage plugin uses CAS (Compare-And-Swap) to atomically check and increment the version:
my $old-value = cas($current, $expected-version, $new-version);
unless $old-value == $expected-version {
Sourcing::X::OptimisticLocked.new.throw
}
If the stored version doesn't match the expected version, another command has already modified the aggregate. The current command fails with X::OptimisticLocked.
Automatic Retry
When X::OptimisticLocked is thrown inside a command, the is command trait catches it and automatically retries:
- Call
^update— reset all mutable attributes and replay ALL events from the store - Execute the command body — validate against fresh state
- Emit events — with the new version
This repeats up to 5 times. If all 5 attempts fail, the last X::OptimisticLocked exception is re-thrown.
Why Automatic Retry Works in Event Sourcing
| CRUD Systems | Event Sourcing |
|---|---|
| User edits a form with merged state | Command parameters are the intent |
| Automatic retry overwrites changes | Aggregate is rebuilt from events |
| User needs to review and re-decide | Command is deterministic: same input + same state = same result |
| State is mutated in place | No "merged state" confusion |
The Exception
Sourcing::X::OptimisticLocked carries structured context:
has Mu:U $.type; # The aggregation type
has Hash $.ids; # The projection IDs
has Int $.expected-version; # What the command expected
has Int $.actual-version; # What was actually in the store
The message includes all this information for debugging:
<sourcing optimistic locked: type=BankAccount, ids={:account-id(1)}, expected-version=2, actual-version=3>
Handling Exhaustion
If all 5 retry attempts fail, the exception is re-thrown. Callers can catch it explicitly:
try {
$account.withdraw(100);
CATCH {
when Sourcing::X::OptimisticLocked {
say "Heavy contention — try again later";
}
}
}
Next Steps
- Learn about Events — the immutable facts
- See the API Reference for X::OptimisticLocked