1+ using System . Collections . Concurrent ;
12using Microsoft . EntityFrameworkCore ;
23using Microsoft . EntityFrameworkCore . Storage ;
34using Microsoft . Extensions . DependencyInjection ;
@@ -13,6 +14,8 @@ internal sealed class PostgresSagaStore(DbContext context, PostgresSagaStoreQuer
1314 , IDisposable
1415{
1516 private readonly object _lock = new ( ) ;
17+ private readonly ConcurrentDictionary < SagaStateKey , Guid > _versions = new ( ) ;
18+ private Guid ? _transactionId = context . Database . CurrentTransaction ? . TransactionId ;
1619 private PooledArrayWriter ? _arrayWriter ;
1720
1821 /// <summary>
@@ -28,7 +31,7 @@ public static PostgresSagaStore Create(Type contextType, string optionsName, ISe
2831 var dbContext = ( DbContext ) services . GetRequiredService ( contextType ) ;
2932 var optionsMonitor = services . GetRequiredService < IOptionsMonitor < PostgresSagaStoreOptions > > ( ) ;
3033 var options = optionsMonitor . Get ( optionsName ) ;
31- var timeProvider = services . GetRequiredService < TimeProvider > ( ) ;
34+ var timeProvider = services . GetService < TimeProvider > ( ) ?? TimeProvider . System ;
3235
3336 return new PostgresSagaStore ( dbContext , options . Queries , timeProvider ) ;
3437 }
@@ -55,8 +58,9 @@ public async Task<ISagaTransaction> StartTransactionAsync(CancellationToken canc
5558 }
5659
5760 /// <summary>
58- /// Persists the saga state using raw SQL against Postgres, inserting a new record or updating
59- /// the existing one with optimistic concurrency control via the version column.
61+ /// Persists the saga state using raw SQL against Postgres. States loaded by this store are
62+ /// updated with optimistic concurrency control using the version captured at load time. States
63+ /// that were not loaded by this store are inserted and conflict if a row already exists.
6064 /// </summary>
6165 /// <typeparam name="T">The saga state type derived from <see cref="SagaStateBase"/>.</typeparam>
6266 /// <param name="saga">The saga definition providing name and serialization metadata.</param>
@@ -67,6 +71,8 @@ public async Task<ISagaTransaction> StartTransactionAsync(CancellationToken canc
6771 /// </exception>
6872 public async Task SaveAsync < T > ( Saga saga , T state , CancellationToken cancellationToken ) where T : SagaStateBase
6973 {
74+ EnsureCurrentTransactionGeneration ( ) ;
75+
7076 var connection = ( NpgsqlConnection ) context . Database . GetDbConnection ( ) ;
7177 var transaction = context . Database . CurrentTransaction ? . GetDbTransaction ( ) as NpgsqlTransaction ;
7278
@@ -75,22 +81,13 @@ public async Task SaveAsync<T>(Saga saga, T state, CancellationToken cancellatio
7581 await connection . OpenAsync ( cancellationToken ) ;
7682 }
7783
78- // Check if record exists
79- var existingVersion = await GetExistingVersionAsync (
80- connection ,
81- transaction ,
82- saga . Name ,
83- state . Id ,
84- cancellationToken ) ;
85-
86- // Serialize state to JSON
8784 var jsonData = SerializeState ( saga , state ) ;
8885 var newVersion = NewVersion ( ) ;
8986 var now = timeProvider . GetUtcNow ( ) ;
87+ var key = new SagaStateKey ( saga . Name , state . Id ) ;
9088
91- if ( existingVersion is null )
89+ if ( ! _versions . TryGetValue ( key , out var version ) )
9290 {
93- // Insert new record
9491 await using var cmd = connection . CreateCommand ( ) ;
9592 cmd . CommandText = queries . InsertState ;
9693 cmd . Transaction = transaction ;
@@ -102,11 +99,21 @@ public async Task SaveAsync<T>(Saga saga, T state, CancellationToken cancellatio
10299 cmd . Parameters . AddWithValue ( "@version" , newVersion ) ;
103100 await cmd . PrepareAsync ( cancellationToken ) ;
104101
105- await cmd . ExecuteNonQueryAsync ( cancellationToken ) ;
102+ try
103+ {
104+ await cmd . ExecuteNonQueryAsync ( cancellationToken ) ;
105+ }
106+ catch ( PostgresException ex ) when ( ex . SqlState == PostgresErrorCodes . UniqueViolation )
107+ {
108+ throw new DbUpdateConcurrencyException (
109+ "The saga state was concurrently created or already exists." ,
110+ ex ) ;
111+ }
112+
113+ _versions [ key ] = newVersion ;
106114 }
107115 else
108116 {
109- // Update existing record with optimistic concurrency
110117 await using var cmd = connection . CreateCommand ( ) ;
111118 cmd . CommandText = queries . UpdateState ;
112119 cmd . Transaction = transaction ;
@@ -115,14 +122,16 @@ public async Task SaveAsync<T>(Saga saga, T state, CancellationToken cancellatio
115122 cmd . Parameters . AddWithValue ( "@newVersion" , newVersion ) ;
116123 cmd . Parameters . AddWithValue ( "@id" , state . Id ) ;
117124 cmd . Parameters . AddWithValue ( "@sagaName" , saga . Name ) ;
118- cmd . Parameters . AddWithValue ( "@oldVersion" , existingVersion . Value ) ;
125+ cmd . Parameters . AddWithValue ( "@oldVersion" , version ) ;
119126 await cmd . PrepareAsync ( cancellationToken ) ;
120127
121128 var rowsAffected = await cmd . ExecuteNonQueryAsync ( cancellationToken ) ;
122129 if ( rowsAffected == 0 )
123130 {
124131 throw new DbUpdateConcurrencyException ( "The saga state was modified by another process." ) ;
125132 }
133+
134+ _versions [ key ] = newVersion ;
126135 }
127136 }
128137
@@ -134,6 +143,8 @@ public async Task SaveAsync<T>(Saga saga, T state, CancellationToken cancellatio
134143 /// <param name="cancellationToken">A token to observe for cancellation.</param>
135144 public async Task DeleteAsync ( Saga saga , Guid id , CancellationToken cancellationToken )
136145 {
146+ EnsureCurrentTransactionGeneration ( ) ;
147+
137148 var connection = ( NpgsqlConnection ) context . Database . GetDbConnection ( ) ;
138149
139150 if ( connection . State != System . Data . ConnectionState . Open )
@@ -148,6 +159,7 @@ public async Task DeleteAsync(Saga saga, Guid id, CancellationToken cancellation
148159 await cmd . PrepareAsync ( cancellationToken ) ;
149160
150161 await cmd . ExecuteNonQueryAsync ( cancellationToken ) ;
162+ _versions . TryRemove ( new SagaStateKey ( saga . Name , id ) , out _ ) ;
151163 }
152164
153165 /// <summary>
@@ -160,6 +172,8 @@ public async Task DeleteAsync(Saga saga, Guid id, CancellationToken cancellation
160172 /// <returns>The deserialized saga state, or <c>default</c> if no state is found for the given identifier.</returns>
161173 public async Task < T ? > LoadAsync < T > ( Saga saga , Guid id , CancellationToken cancellationToken )
162174 {
175+ EnsureCurrentTransactionGeneration ( ) ;
176+
163177 var connection = ( NpgsqlConnection ) context . Database . GetDbConnection ( ) ;
164178 var transaction = context . Database . CurrentTransaction ? . GetDbTransaction ( ) as NpgsqlTransaction ;
165179
@@ -182,25 +196,26 @@ public async Task DeleteAsync(Saga saga, Guid id, CancellationToken cancellation
182196 }
183197
184198 var stateJson = reader . GetFieldValue < ReadOnlyMemory < byte > > ( 0 ) ;
185- return saga . StateSerializer . Deserialize < T > ( stateJson ) ;
199+ var version = reader . GetFieldValue < Guid > ( 1 ) ;
200+ var state = saga . StateSerializer . Deserialize < T > ( stateJson ) ;
201+
202+ if ( state is SagaStateBase )
203+ {
204+ _versions [ new SagaStateKey ( saga . Name , id ) ] = version ;
205+ }
206+
207+ return state ;
186208 }
187209
188- private async Task < Guid ? > GetExistingVersionAsync (
189- NpgsqlConnection connection ,
190- NpgsqlTransaction ? transaction ,
191- string sagaName ,
192- Guid id ,
193- CancellationToken cancellationToken )
210+ private void EnsureCurrentTransactionGeneration ( )
194211 {
195- await using var cmd = connection . CreateCommand ( ) ;
196- cmd . CommandText = queries . SelectVersion ;
197- cmd . Transaction = transaction ;
198- cmd . Parameters . AddWithValue ( "@id" , id ) ;
199- cmd . Parameters . AddWithValue ( "@sagaName" , sagaName ) ;
200- await cmd . PrepareAsync ( cancellationToken ) ;
212+ var transactionId = context . Database . CurrentTransaction ? . TransactionId ;
201213
202- var result = await cmd . ExecuteScalarAsync ( cancellationToken ) ;
203- return result is Guid version ? version : null ;
214+ if ( _transactionId != transactionId )
215+ {
216+ _versions . Clear ( ) ;
217+ _transactionId = transactionId ;
218+ }
204219 }
205220
206221 private byte [ ] SerializeState ( Saga saga , SagaStateBase state )
@@ -232,4 +247,6 @@ public void Dispose()
232247 {
233248 _arrayWriter ? . Dispose ( ) ;
234249 }
250+
251+ private readonly record struct SagaStateKey ( string SagaName , Guid Id ) ;
235252}
0 commit comments