Skip to main content

Single-writer guarantee

Persistent actors follow the single-writer principle: exactly one writer appends to a given event stream at any time. This prevents split-brain data corruption when multiple processes share the same event store.

The design

Every EventSourcedBehavior (and DurableStateBehavior) mints a fresh ULID as its writerId when the behavior is built. The engine stamps this ULID on every EventEnvelope and SnapshotEnvelope it persists. Note that ActorSystem::writerId() also exists, but it is not propagated to persistence — the behavior's own writer ID is what gets stamped. When an actor recovers with a ReplayFilter enabled, the filter checks whether the event stream was written by exactly one writer. If it detects events from more than one writer, it applies the configured conflict mode.

Override the writer ID on EventSourcedBehavior when migrating data or writing deterministic tests:

src/Aggregates/OrderActor.php
<?php

declare(strict_types=1);

use Monadial\Nexus\Persistence\EventSourced\EventSourcedBehavior;
use Symfony\Component\Uid\Ulid;

$behavior = EventSourcedBehavior::create($persistenceId, $emptyState, $commandHandler, $eventHandler)
->withEventStore($eventStore)
->withWriterId(Ulid::fromString('01J4XGVT00000000000000000A'))
->toBehavior();

ReplayFilter

ReplayFilter applies conflict detection during event replay. Set it via ->withReplayFilter(...):

src/Aggregates/OrderActor.php
<?php

declare(strict_types=1);

use Monadial\Nexus\Persistence\EventSourced\EventSourcedBehavior;
use Monadial\Nexus\Persistence\Recovery\ReplayFilter;

$behavior = EventSourcedBehavior::create($persistenceId, $emptyState, $commandHandler, $eventHandler)
->withEventStore($eventStore)
->withReplayFilter(ReplayFilter::fail())
->toBehavior();

The four modes:

ModeFactoryBehaviour on conflict
FailReplayFilter::fail()Throw WriterConflictException immediately — safest for production
WarnReplayFilter::warn()Log a PSR-3 warning and continue replaying all events
RepairByDiscardOldReplayFilter::repairByDiscardOld()Discard events from all writers except the latest; continue recovery with only the most recent writer's events
OffReplayFilter::off()Skip conflict detection entirely — the default when withReplayFilter() is not called
RepairByDiscardOld skips events during replay

RepairByDiscardOld filters the in-memory replay list only: events from earlier writers are excluded from recovery but remain in the store — nothing is deleted. The recovered state simply no longer reflects them. Use it only in migration scenarios where you have already confirmed the earlier writer is gone.

WriterConflictException

WriterConflictException is thrown during recovery when ReplayFilter::fail() detects interleaved writers. It carries:

  • persistenceId — the stream where the conflict was found.
  • expectedWriter — the ULID of the first writer seen in the stream.
  • actualWriter — the ULID of the conflicting writer.
  • sequenceNr — the sequence number at which the conflict was detected.

The actor fails to start. The parent's supervision strategy handles the failure — typically Directive::Stop to prevent inconsistent state from being served.

Practical rules

Keep exactly one ActorSystem writing to each persistence ID at any time. In a worker pool (multiple threads), each actor name maps deterministically to a single worker via the consistent hash ring, so each persistent entity has one writer. In a multi-machine deployment, route entity commands to a single designated node by entity ID.

See also