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:

  1. Call ^update — reset all mutable attributes and replay ALL events from the store
  2. Execute the command body — validate against fresh state
  3. 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 SystemsEvent Sourcing
User edits a form with merged stateCommand parameters are the intent
Automatic retry overwrites changesAggregate is rebuilt from events
User needs to review and re-decideCommand is deterministic: same input + same state = same result
State is mutated in placeNo "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