Investigate: StreamVersion vs EventId for optimistic concurrency #249

Closed
opened 2025-12-28 20:00:46 -08:00 by jwilger · 1 comment
jwilger commented 2025-12-28 20:00:46 -08:00 (Migrated from github.com)

Overview

Investigate whether using EventId (UUID) for optimistic concurrency instead of StreamVersion (sequential integer) would provide benefits.

Current Approach

  • StreamVersion: sequential integer per stream
  • Pro: Gap-free, simple mental model
  • Con: Requires careful version management

Alternative Approach

  • EventId: UUID for each event
  • Pro: Globally unique, no coordination needed
  • Con: Less intuitive for "expected version" semantics

Questions to Answer

  • What are the tradeoffs in terms of API ergonomics?
  • Does EventId-based concurrency simplify or complicate the implementation?
  • Are there performance implications?
  • How do other event sourcing libraries handle this?

Acceptance Criteria

  • Research completed
  • Tradeoffs documented
  • Recommendation made (keep current approach or change)

Migrated from beads issue: eventcore-aet

## Overview Investigate whether using EventId (UUID) for optimistic concurrency instead of StreamVersion (sequential integer) would provide benefits. ## Current Approach - StreamVersion: sequential integer per stream - Pro: Gap-free, simple mental model - Con: Requires careful version management ## Alternative Approach - EventId: UUID for each event - Pro: Globally unique, no coordination needed - Con: Less intuitive for "expected version" semantics ## Questions to Answer - What are the tradeoffs in terms of API ergonomics? - Does EventId-based concurrency simplify or complicate the implementation? - Are there performance implications? - How do other event sourcing libraries handle this? ## Acceptance Criteria - [ ] Research completed - [ ] Tradeoffs documented - [ ] Recommendation made (keep current approach or change) --- *Migrated from beads issue: eventcore-aet*
jwilger-ai-bot commented 2026-04-12 10:57:15 -07:00 (Migrated from github.com)

Research: StreamVersion vs EventId for Optimistic Concurrency

Current Approach: StreamVersion (Sequential Integer per Stream)

EventCore uses per-stream monotonic version numbers (0 = empty, incrementing by 1) for optimistic concurrency control, as defined in ADR-007.

How it works:

  1. Read phase: Capture current version for each declared stream
  2. Compute phase: Apply events, run business logic (no locks held)
  3. Write phase: Atomic append with expected versions — if any stream's version changed, ConcurrencyError triggers automatic retry

All three backends (Postgres, SQLite, in-memory) implement atomic version verification within their transaction/lock boundaries.

Alternative: EventId (UUID) for Concurrency

This would replace "expected version N" with "last event I saw was UUID X" — the store would reject writes if a stream's latest event ID doesn't match.

Tradeoff Analysis

Dimension StreamVersion (current) EventId-based
Mental model Simple: "I read version 5, expect version 5" Less intuitive: "I last saw event abc-123"
Expected-version semantics Natural: "append after version N" maps to integer comparison Awkward: "append after event X" requires lookup to verify X is still the tail
Multi-stream atomicity Clean: verify N integers atomically Requires N UUID lookups + comparisons atomically
Gap-free ordering Guaranteed: version N implies events 1..N exist No ordering guarantee from UUIDs (even UUID v7 only guarantees rough temporal ordering, not per-stream sequence)
Backend implementation Simple integer comparison in SQL/memory Requires index lookup per stream to find latest event ID
Performance Integer comparison is O(1), negligible cost UUID comparison + lookup adds overhead per stream per write
Coordination None needed — version is derived from stream length None needed — UUIDs are globally unique (wash)
Deterministic replay Version numbers give deterministic ordering within a stream UUIDs don't encode position — would need a secondary ordering mechanism
Projection checkpoints StreamVersion works as a natural checkpoint marker EventId already used for cross-stream checkpoint positions (StreamPosition)
Industry practice Standard: EventStoreDB, Marten, Axon all use sequential version numbers No major event sourcing library uses EventId for optimistic concurrency

Key Findings

  1. EventId already exists in the codebase — events are identified by UUID v7 for global ordering. But this serves a different purpose (cross-stream pagination for projections via StreamPosition), not per-stream conflict detection.

  2. The two mechanisms are complementary, not competing: StreamVersion handles per-stream optimistic concurrency (write-side). EventId handles cross-stream position tracking (read-side/projections). Replacing one with the other would conflate two distinct concerns.

  3. UUID-based concurrency would complicate every backend: Currently, Postgres uses a trigger comparing integers, SQLite queries MAX(stream_version), and in-memory compares a stored integer. Switching to EventId would require each backend to look up the latest event UUID per stream — strictly more work for no correctness gain.

  4. No industry precedent: EventStoreDB (the reference implementation for event sourcing) uses sequential stream versions. Marten (PostgreSQL event store) uses sequential versions. Axon Framework uses sequence numbers. No mainstream event sourcing library uses event IDs for optimistic concurrency.

  5. Sequential versions enable gap-free invariants: "Version N implies events 1..N exist" is a powerful guarantee that simplifies reasoning about stream state. UUIDs cannot provide this.

Recommendation

Keep the current StreamVersion approach. It is:

  • The industry standard for event sourcing optimistic concurrency
  • Simpler to implement, reason about, and debug
  • More performant (integer comparison vs UUID lookup)
  • Already well-integrated across all three backends with thorough ADR documentation (ADR-007)
  • Complementary to the existing EventId/UUID v7 usage for cross-stream positioning

There is no benefit to switching. The perceived "coordination" advantage of UUIDs is irrelevant here — StreamVersion requires no coordination either, since it's derived from the stream's own event count within an atomic transaction.

## Research: StreamVersion vs EventId for Optimistic Concurrency ### Current Approach: StreamVersion (Sequential Integer per Stream) EventCore uses per-stream monotonic version numbers (0 = empty, incrementing by 1) for optimistic concurrency control, as defined in ADR-007. **How it works:** 1. **Read phase**: Capture current version for each declared stream 2. **Compute phase**: Apply events, run business logic (no locks held) 3. **Write phase**: Atomic append with expected versions — if any stream's version changed, `ConcurrencyError` triggers automatic retry All three backends (Postgres, SQLite, in-memory) implement atomic version verification within their transaction/lock boundaries. ### Alternative: EventId (UUID) for Concurrency This would replace "expected version N" with "last event I saw was UUID X" — the store would reject writes if a stream's latest event ID doesn't match. ### Tradeoff Analysis | Dimension | StreamVersion (current) | EventId-based | |-----------|------------------------|---------------| | **Mental model** | Simple: "I read version 5, expect version 5" | Less intuitive: "I last saw event abc-123" | | **Expected-version semantics** | Natural: "append after version N" maps to integer comparison | Awkward: "append after event X" requires lookup to verify X is still the tail | | **Multi-stream atomicity** | Clean: verify N integers atomically | Requires N UUID lookups + comparisons atomically | | **Gap-free ordering** | Guaranteed: version N implies events 1..N exist | No ordering guarantee from UUIDs (even UUID v7 only guarantees rough temporal ordering, not per-stream sequence) | | **Backend implementation** | Simple integer comparison in SQL/memory | Requires index lookup per stream to find latest event ID | | **Performance** | Integer comparison is O(1), negligible cost | UUID comparison + lookup adds overhead per stream per write | | **Coordination** | None needed — version is derived from stream length | None needed — UUIDs are globally unique (wash) | | **Deterministic replay** | Version numbers give deterministic ordering within a stream | UUIDs don't encode position — would need a secondary ordering mechanism | | **Projection checkpoints** | StreamVersion works as a natural checkpoint marker | EventId already used for cross-stream checkpoint positions (StreamPosition) | | **Industry practice** | Standard: EventStoreDB, Marten, Axon all use sequential version numbers | No major event sourcing library uses EventId for optimistic concurrency | ### Key Findings 1. **EventId already exists in the codebase** — events are identified by UUID v7 for global ordering. But this serves a different purpose (cross-stream pagination for projections via `StreamPosition`), not per-stream conflict detection. 2. **The two mechanisms are complementary, not competing**: StreamVersion handles per-stream optimistic concurrency (write-side). EventId handles cross-stream position tracking (read-side/projections). Replacing one with the other would conflate two distinct concerns. 3. **UUID-based concurrency would complicate every backend**: Currently, Postgres uses a trigger comparing integers, SQLite queries `MAX(stream_version)`, and in-memory compares a stored integer. Switching to EventId would require each backend to look up the latest event UUID per stream — strictly more work for no correctness gain. 4. **No industry precedent**: EventStoreDB (the reference implementation for event sourcing) uses sequential stream versions. Marten (PostgreSQL event store) uses sequential versions. Axon Framework uses sequence numbers. No mainstream event sourcing library uses event IDs for optimistic concurrency. 5. **Sequential versions enable gap-free invariants**: "Version N implies events 1..N exist" is a powerful guarantee that simplifies reasoning about stream state. UUIDs cannot provide this. ### Recommendation **Keep the current StreamVersion approach.** It is: - The industry standard for event sourcing optimistic concurrency - Simpler to implement, reason about, and debug - More performant (integer comparison vs UUID lookup) - Already well-integrated across all three backends with thorough ADR documentation (ADR-007) - Complementary to the existing EventId/UUID v7 usage for cross-stream positioning There is no benefit to switching. The perceived "coordination" advantage of UUIDs is irrelevant here — StreamVersion requires no coordination either, since it's derived from the stream's own event count within an atomic transaction.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
jwilger/eventcore#249
No description provided.