Consistency & Delivery Guarantees
Applies to: DDD · CQRS · Event Sourcing
This page states, per port and per adapter, what Protean promises for ordering, delivery, consistency, and the isolation it assumes. It is the normative contract: conformance and property tests cite a row here as their expected behavior, and the correctness fixes in the framework are described in terms of the guarantee they restore.
A guarantee is only what is listed here. Where an adapter happens to be stronger
than its port requires (for example, one backend returning rows in a stable order
without an explicit order_by), that extra behavior is noted but is not part
of the contract and may change.
How to read this page
Not every dimension applies to every port; each table shows only the relevant ones, and may fold two into one column (for example OCC & consistency). The dimensions:
- Ordering — the order in which writes become visible or messages are delivered. Per-stream order is within a single aggregate stream (or queue); global order is across streams.
- Delivery — how many times a write or message takes effect.
- At-least-once: delivered one or more times; a consumer may see duplicates (on crash/retry) and must tolerate them.
- At-most-once (best-effort): delivered zero or one time; on failure it may be lost and is not redelivered.
- Exactly-once-effect: duplicates may still be delivered, but an idempotency mechanism ensures the effect applies once. Protean never promises exactly-once delivery — only exactly-once effect, and only where opted in.
- Consistency — when a write is visible to a later read. Read-your-writes: a reader that made a write sees it on a subsequent read. Eventual: the write becomes visible after some delay.
- Isolation / concurrency model — the database isolation level, or the concurrency-control mechanism, under which the guarantee holds.
A few terms used below: the outbox is the table a Unit of Work writes
committed messages into for reliable post-commit dispatch; the recovery stream
is where a subscription records a failed position for later retry; the
idempotency store is the (Redis-backed) store that dedups commands by
idempotency_key.
Optimistic concurrency (OCC) throughout means: an aggregate carries a
_version; an update asserts the expected version atomically with the write and
raises ExpectedVersionError if it has moved. See
ADR-0013.
Aggregate persistence (repository / DAO)
A repository persists an aggregate through a Unit of Work. The aggregate root is
the concurrency boundary
(ADR-0013): OCC is
enforced on the standard write path — repository.add, which routes through the
DAO's save() — on the root's _version.
Gaps in OCC coverage
Two write paths are not version-guarded today. These are limitations to be aware of, not a deliberate part of the contract:
- The lower-level DAO
update()method — an escape hatch beneathrepository.add— issues its write without the version check, so a concurrent update made through it can silently lose one. Userepository.addfor version-safe writes. (This inconsistency withsave()is a candidate for a follow-up fix.) - Child-entity changes within a collection are not independently
version-guarded. By ADR-0013 the aggregate root is the concurrency
boundary, so a concurrent collection add/remove or child-field change is
detected only if it also advances the root's
_version.
| Adapter | Ordering (no order_by) |
Write atomicity / visibility | OCC & consistency | Isolation / concurrency model |
|---|---|---|---|---|
| Memory | Insertion order (not a promise) | Copy-on-write session; visible at commit; rollback() is a no-op |
OCC via a version check under a process-local lock — holds only single-threaded (see below) | Single process; per-provider lock (no MVCC) |
| SQLAlchemy (PostgreSQL / MSSQL) | No guaranteed order | Session-scoped writes deferred to the flush at commit (AUTOCOMMIT engine, autoflush=False) |
OCC via version_id_col → UPDATE … WHERE _version = :expected; a zero-row match raises ExpectedVersionError |
READ COMMITTED or stronger (see below) |
| SQLAlchemy (SQLite) | No guaranteed order | Session autoflushes; write visible in-session | Same version_id_col OCC |
Writers serialized; a contended write raises SQLITE_BUSY (no READ COMMITTED level) |
| Elasticsearch | No guaranteed order | No multi-document transaction; each write forced refresh=True |
OCC via if_seq_no / if_primary_term; a 409 conflict raises ExpectedVersionError |
None (no transactions) |
Consistency (read-your-writes) — with an important caveat. Within a Unit of Work, every repository on a given provider shares one session.
- Memory / SQLite — all reads see the UoW's own uncommitted writes.
- PostgreSQL / MSSQL — the session runs
autoflush=False, so a read inside the UoW reflects committed state and does not see the UoW's own pending writes. This applies tofilter/count/existsand toget(which is itself implemented as a criteria query, not an identity-map lookup). The pending write becomes visible only once it has been flushed — which happens for an auto-increment primary key, or an aggregate whose child sync forces a flush, but not for a plain UUID-keyed childless aggregate. (In-UoW uniqueness validation reads committed state for the same reason; the database constraint still fires at commit.) - Elasticsearch — has no session isolation: every write lands immediately
(
refresh=True) and is not rolled back, so read-your-writes (bothgetand criteria) is trivially satisfied, but a rolled-back UoW's writes persist and are visible to others.
Across UoW boundaries, relational adapters see only committed state.
Memory OCC is single-threaded only. The Memory adapter takes a per-provider lock around the version check, but each session works on a deep-copied snapshot and commits by replacing the whole store, so two overlapping sessions can each pass the check against their own snapshot and the second commit clobbers the first — a lost update with no conflict raised. Memory is a single-writer test/dev store; do not rely on its OCC under concurrent sessions (use a real database to exercise concurrency — this is why the no-lost-update property test runs on PostgreSQL).
Ordering. A query without an explicit order_by has no guaranteed order.
Some adapters are incidentally stable (SQLAlchemy appends ORDER BY <pk> ASC,
Memory preserves insertion order); do not rely on it — pass order_by.
Isolation (the OCC floor). On PostgreSQL / MSSQL the lost-update fix
(ADR-0013) relies on
the write being a single conditional UPDATE whose WHERE _version = :expected
predicate is re-evaluated under the row lock against committed state. That is
atomic at READ COMMITTED, the default on both; Protean runs the engine in
AUTOCOMMIT and requires nothing stronger. Running below READ COMMITTED is
unsupported. SQLite has no READ COMMITTED level — it serializes writers and a
contended write raises SQLITE_BUSY rather than losing one.
Claim / concurrent-consume. For queue-style claiming (e.g. the outbox), the
_claim contract (ADR-0013) guarantees no double-claim. PostgreSQL uses a
SELECT … FOR UPDATE SKIP LOCKED fast path; every other backend uses a portable
guarded read-then-update (same no-double-claim guarantee, but blocking, not
skipping). Elasticsearch is not recommended as a concurrently-consumed claim store
(a lost race surfaces as a version conflict).
Event store
The event store is the source of truth for event-sourced aggregates. append
writes one message at a time, forwarding the aggregate's expected_version for
an atomic OCC check. Reads follow the read-position contract
(ADR-0024): a specific
stream pages by its per-stream position; a category or $all read pages by
global_position, inclusive, ascending.
| Adapter | Per-stream order | Global / $all order |
Append & OCC | Consistency |
|---|---|---|---|---|
| Memory | Gapless per-stream position, ascending |
global_position ascending; no gaps (single process, no rollback) |
Version check + append atomic under a class lock (conflict raises) for single-threaded use — like the repository Memory adapter, the append commits outside the lock, so overlapping sessions can lose an append with no conflict raised | Read-your-writes (in process); single-writer test/dev store |
| MessageDB | Gapless per-stream position, ascending |
global_position ascending, strictly increasing. Globally it may contain gaps — a rolled-back append permanently consumes a value. Within a single category it is gap-free: MessageDB serializes same-category writes with a per-category advisory lock held to commit. |
write_message(… expected_version) stored proc; conflict raises |
Read-your-writes (committed before append returns) |
Append is per message. A Unit of Work that raises N events performs N individual OCC-guarded appends; they are not a single atomic batch. The event-store append is the durable anchor of the commit sequence, and the relational/outbox commit follows it non-atomically.
Interim — not yet a stable contract
The cross-store atomicity story (ADR-0015) is marked Proposed: the window between the event-store append and the relational/outbox commit is closed by a reconciliation sweep on startup (internal outbox rows are rebuilt from the event store; external-broker rows are not). Treat this as interim; it may change.
Subscriptions & delivery
A domain event/command is dispatched either synchronously (inline at commit) or asynchronously (a background subscription reads it from the event store). The two differ on every dimension:
| Synchronous | Asynchronous | |
|---|---|---|
| When it runs | Inline, after the commit | Background engine, after the event is durable |
| Delivery | At-most-once (no retry) | At-least-once |
| Ordering | Breadth-first chain order (ADR-0016) | Per-category contiguous global_position; $all in global_position order |
| Consistency | Read-your-writes for the projection | Eventual |
| Retry / recovery | None | Retried up to max_retries on a recovery pass |
| Terminal state | Propagates to the caller | Exhausted — position dropped, no DLQ |
The defaults quoted below (position_update_interval 10, max_retries 3,
recovery_interval_seconds 30, gap_timeout_seconds 5) are the base profile;
server profiles may override them. The nuances follow.
Synchronous handlers run inline at commit time, breadth-first
(ADR-0016), and are not
re-delivered here. They execute after the aggregate write has committed,
at-most-once, with no retry: a synchronous handler failure does not roll back
the already-committed write and propagates to the caller (as TransactionError,
or ExpectedVersionError for a version conflict).
Asynchronous delivery is at-least-once by default. The read cursor advances
after a message is handled, including on failure (a failure is recorded to a
recovery stream and retried by a periodic pass, rather than blocking the
subscription on a poison message). The cursor is checkpointed durably but in
batches (every position_update_interval messages, 10 by default), so a crash
re-delivers up to that many messages. Handlers must tolerate duplicates.
Failure handling and terminal state. A failed message is retried up to
max_retries (default 3) on a recovery_interval_seconds cadence (default 30).
On exhaustion the position is marked Exhausted and dropped from tracking (a
handler.failed event is emitted) — there is no dead-letter queue for
event-store subscriptions; a DLQ applies only to broker/stream subscriptions.
Recovery retry is best-effort across a crash
The cursor advances before the failure is recorded to the recovery stream, and the durable cursor flush is not coupled to that record. A crash between the durable cursor advance and the recovery-stream write — or a failure of the recovery write itself — can drop the failed message rather than retrying it. The batched-checkpoint at-least-once property (a crash re-delivers the last unflushed batch) is unaffected; it is the recovery of an already-failed message that is not crash-atomic.
Exactly-once-effect is opt-in, in two places:
- A projector marked
@domain.projector(idempotent=True)on a relational provider records(message_id, handler)in the same transaction as the read-model write, so a redelivered message is a no-op (ADR-0017). On the in-memory provider it degrades to dedup by last-processed message id — it protects against serial redelivery only, not concurrent delivery. Markers are pruned after a retention window (default 7 days); a redelivery after the window re-applies. - A command carrying an
idempotency_keyis deduped against the idempotency store, only when that store is active (Redis-backed); otherwise the key is not enforced. Dedup is time-bounded: success entries expire after 24 h, error entries after 60 s (so a failed command becomes retryable sooner).
Read-model consistency. Under synchronous processing, projectors run inline at
commit, so a read model is updated before domain.process() returns
(read-your-writes for the projection). Under asynchronous processing the read
model updates via the event-store subscription path (event store →
subscription → projector) and is eventually consistent. (The outbox is not
involved in internal projector delivery; it publishes to external brokers.)
Ordering. A per-category subscription delivers in global_position-ascending,
contiguous order, which preserves each stream's order (a category is gap-free per
the per-category advisory lock above). A $all subscription delivers in
global_position-ascending order — this is assignment order, not cross-category
commit or causal order; only per-category order is preserved.
No silent skip for $all. Because a lower global_position can commit after
a higher one across categories, a naive "advance past the highest seen" cursor
could permanently skip a late-committing position. A $all subscription instead
processes only the contiguous run from its cursor, holds at the first gap, and
advances the cursor past a hole only after the batch is processed
(ADR-0025). A gap that does not fill
within gap_timeout_seconds (default 5) is assumed rolled back and abandoned —
logged, not silent. The guarantee is therefore: no committed $all event is
silently skipped; a genuinely slow commit (> gap_timeout_seconds) is logged and
dropped. Single-category subscriptions are gap-free by construction and run none
of this machinery.
Single writer. Event-store subscriptions have no cluster-wide ownership;
Protean refuses to start more than one worker when an event-store subscription is
registered — unless the operator sets acknowledge_event_store_risk (an
explicit opt-out). The guard is best-effort: if the domain cannot be classified at
startup it defers to the workers rather than refusing. Horizontal scaling of a
handler is done with a stream subscription (Redis consumer groups), below.
Outbox (transactional delivery to brokers)
When a Unit of Work commits, published messages are written to the outbox in the same transaction; an outbox processor then claims and publishes them to the broker. This decouples the domain commit from broker availability.
| Dimension | Guarantee |
|---|---|
| Delivery | At-least-once to the broker: a crash after broker.publish but before the row is marked published re-delivers. |
| Terminal state | After max_retries (default 3) with exponential backoff, a message is marked abandoned (OutboxStatus.ABANDONED) — permanently not delivered, retained for observability, cleaned up after a retention period. |
| Dedup | Write-side idempotency on (message_id, target_broker); a published event is written once per configured external broker. |
| Ordering | Claimed by priority, not per-stream/commit order — a higher-priority later message can overtake a lower-priority earlier one, and same-priority order is database-dependent. Do not assume end-to-end FIFO through the outbox. |
| Crash recovery | A startup sweep rebuilds missing internal outbox rows from the event store (ADR-0015); external-broker rows are not reconciled. |
Broker (external messaging)
Brokers carry messages to external subscribers. Capability tiers gate what a broker
promises; a lower-tier broker returns empty / warns rather than silently faking a
capability it lacks. Within a Unit of Work, publish is deferred until after the
DB commit.
| Adapter | Tier | Ordering | Delivery | Durability |
|---|---|---|---|---|
| Inline | reliable_messaging |
Per-stream FIFO in practice — not advertised, not a contract | At-least-once — ack/nack with backoff requeue on nack; stale in-flight messages time out to the DLQ; DLQ on exhausted retries | In-process only (non-durable) |
| Redis PubSub | simple_queuing |
Per-stream FIFO in practice — not advertised, not a contract | At-most-once (best-effort): no ack/nack; the read advances the consumer position across the whole batch before any message is processed, so a crash loses every not-yet-processed message in that batch (up to messages_per_tick). Concurrent consumers in one group can also duplicate-and-skip (non-atomic read/increment). |
Redis-backed state; lost messages are not recoverable |
| Redis Streams | ordered_messaging |
Per-stream FIFO — guaranteed (MESSAGE_ORDERING, native stream IDs) |
At-least-once — XREADGROUP pending list, XACK, redelivery across restarts |
Durable in Redis Streams |
Ordering is a contract only where the adapter advertises MESSAGE_ORDERING
(Redis Streams). The Inline and PubSub brokers preserve order in practice but do
not advertise it, so cross-adapter code must not depend on it.
End-to-end order for published domain events is set by the outbox (by
priority — see Outbox → Ordering),
not by commit order; a broker's per-stream FIFO only preserves the order the
outbox published in.
DLQ differs by adapter. The Inline broker populates a DLQ automatically (on
exhausted retries and on in-flight timeout). The Redis Streams broker provides DLQ
inspection and replay over externally-populated :dlq streams but does not
itself move poison messages to a DLQ (a nacked message stays pending).
Cache
The cache (projections keyed by id, with pattern queries) is a best-effort, non-durable store with per-key TTL (default 300s). It promises no ordering, no transactions, and no atomic multi-key operations. The Memory cache is in-process and lost on exit; the Redis cache is durable only to the extent the Redis deployment is configured to persist.
Guarantees restored by correctness fixes
| Fix | Guarantee restored |
|---|---|
| #1087 | No lost update. The aggregate OCC check is atomic with the write (UPDATE … WHERE _version = :expected), so two concurrent updates can no longer both succeed and silently drop one. Holds at READ COMMITTED on PostgreSQL / MSSQL. |
| #1249 (ADR-0024) | Correct read position. Category and $all reads page by global_position, inclusive and ordered, uniformly across adapters — the substrate the no-skip guarantee is expressed on. |
| #1088 (ADR-0025) | No silent skip for $all. A late-committing lower global_position is no longer stepped over; the cursor holds at the gap until it fills or times out. |
Out of scope
- Cross-bounded-context delivery of
publishedevents. Whether apublished=Trueevent carries a delivery guarantee to external subscribers is not yet settled — the routing mechanism is being refined (ADR-0002, Proposed). - Horizontal scaling of a single event-store subscription. Event-store subscriptions are single-writer by construction (see Single writer); scaling a handler across workers uses a stream subscription and Redis consumer groups.
- Nested Units of Work. Opening a UoW inside a UoW reuses the in-progress one rather than nesting with savepoints; there is no independent inner-commit guarantee. Treat a UoW as flat.
- Email adapters. The shipped email adapters are fire-and-forget with no delivery guarantee, retry, or outbox integration.