Skip to content

Commit 24c04a1

Browse files
authored
Fixed postgres saga store concurrency issue (#10085)
1 parent 88ef3ec commit 24c04a1

4 files changed

Lines changed: 313 additions & 73 deletions

File tree

src/Mocha/src/Mocha.EntityFrameworkCore.Postgres/Sagas/PostgresSagaStore.cs

Lines changed: 49 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using System.Collections.Concurrent;
12
using Microsoft.EntityFrameworkCore;
23
using Microsoft.EntityFrameworkCore.Storage;
34
using 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
}

src/Mocha/src/Mocha.EntityFrameworkCore.Postgres/Sagas/PostgresSagaStoreQueries.cs

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,10 @@ namespace Mocha.Sagas.EfCore;
99
internal sealed class PostgresSagaStoreQueries
1010
{
1111
/// <summary>
12-
/// Gets or sets the SQL query to select the serialized saga state by id and saga name.
12+
/// Gets or sets the SQL query to select the serialized saga state and concurrency version by id and saga name.
1313
/// </summary>
1414
public string SelectState { get; set; } = null!;
1515

16-
/// <summary>
17-
/// Gets or sets the SQL query to select the concurrency version by id and saga name.
18-
/// </summary>
19-
public string SelectVersion { get; set; } = null!;
20-
2116
/// <summary>
2217
/// Gets or sets the SQL statement to insert a new saga state record.
2318
/// </summary>
@@ -43,13 +38,7 @@ public static PostgresSagaStoreQueries From(SagaStateTableInfo t)
4338
return new PostgresSagaStoreQueries
4439
{
4540
SelectState = $"""
46-
SELECT "{t.State}"
47-
FROM {t.QualifiedTableName}
48-
WHERE "{t.Id}" = @id AND "{t.SagaName}" = @sagaName;
49-
""",
50-
51-
SelectVersion = $"""
52-
SELECT "{t.Version}"
41+
SELECT "{t.State}", "{t.Version}"
5342
FROM {t.QualifiedTableName}
5443
WHERE "{t.Id}" = @id AND "{t.SagaName}" = @sagaName;
5544
""",

0 commit comments

Comments
 (0)