fix(postgres): fail loudly instead of silently dropping racing appends - #582
Conversation
Concurrent appends to the same stream could both pass the expected-version check because check_stream read the stream row without a lock, and the loser's events were then silently discarded by ON CONFLICT DO NOTHING in append_events, which still reported success with the winner's version. check_stream now locks the stream row with FOR UPDATE, serialising appends to the same stream, and handles the concurrent stream-creation race with ON CONFLICT DO NOTHING plus a locked re-read. append_events no longer swallows insert conflicts: a stream-position conflict raises WrongExpectedVersion, which the client maps to OptimisticConcurrencyException. Adds concurrency tests to the shared store test base so every store enforces the invariant that a successful append is durable and conflicting concurrent appends fail loudly. Fixes #553 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
PR Summary by QodoFix Postgres concurrent appends to fail loudly (no silent event loss)
AI Description
Diagram
High-Level Assessment
Files changed (3)
|
Code Review by Qodo🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0)
Great, no issues found!Qodo reviewed your code and found no material issues that require reviewTip of the day💡 Did you know, you can tweak Display preferences with a live preview to see your comment before it ships |
Test Results 44 files + 21 44 suites +21 12m 27s ⏱️ - 1m 15s Results for commit 7e87b89. ± Comparison against base commit a178a37. This pull request removes 26 and adds 17 tests. Note that renamed tests count towards both. |
Fixes #553
Problem
CommandServicecould report success while the events were missing from the stream under concurrent writes to the same aggregate with the Postgres store:check_streamreadstreams.versionwithout a row lock, so concurrent writers both passed the expected-version check with the same current version.append_eventsinserted messages withon conflict do nothing, so the losing writer's rows were silently dropped when they hit the(stream_id, stream_position)unique constraint.Reproduced with 20 parallel
ExpectedStreamVersion.Anyappends: all 20 reported success, 10 events were lost.Fix
3_CheckStream.sql: the stream row is selectedFOR UPDATE— the lock is held to the end of the append transaction, serialising concurrent appends to the same stream. The stream-creation race is handled withinsert … on conflict (stream_name) do nothingfollowed by a locked re-read, and the expected-version check runs after the lock is acquired. ConcurrentAnyappends now all succeed (queued behind the lock); stale expected versions fail withWrongExpectedVersion.2_AppendEvents.sql: removedon conflict do nothing. As defence in depth, a residual stream-position conflict raisesWrongExpectedVersion(mapped byPostgresStore.IsConflicttoAppendToStreamException/OptimisticConcurrencyException); any other unique violation is re-raised as-is. This mirrors the SQL Server implementation, which already handled the race this way.Tests
Two new tests in the shared
StoreAppendTestsbase, so all stores enforce the invariant:ShouldNotLoseConcurrentAppends— every append that reports success must be durable (fails pre-fix on Postgres: 10 of 20 events lost).ShouldRejectConcurrentAppendsWithSameVersion— of N concurrent appends with the same expected version, exactly one succeeds and the rest throwAppendToStreamException.Verified: full Postgres suite 49/49, Sqlite and KurrentDB append tests green. SQL Server tests are excluded on macOS and will run in CI.
Deployment note
The functions are
create or replace, so existing databases pick up the fix only when the schema scripts re-run — users withInitializeDatabase = falseneed to apply them manually. Worth a line in the release notes.🤖 Generated with Claude Code