NEO-116: Make Postgres TryActivate safe under concurrent insert.

Use INSERT ON CONFLICT DO NOTHING so duplicate activations return false
with the existing snapshot instead of raising a primary-key violation.
pull/155/head
VinPropane 2026-06-03 23:25:13 -04:00
parent de611ac294
commit fabbdae4c2
2 changed files with 56 additions and 21 deletions

View File

@ -16,6 +16,34 @@ public sealed class PlayerQuestProgressPersistenceIntegrationTests(PostgresInteg
private const string PlayerId = "dev-local-1";
private static readonly DateTimeOffset CompletedAt = new(2026, 6, 3, 14, 30, 0, TimeSpan.Zero);
[RequirePostgresFact]
public async Task TryActivate_ShouldReturnOneTrueAndRestFalse_WhenCalledConcurrently()
{
// Arrange
await ResetQuestProgressTableAsync();
const int concurrentCalls = 8;
var questId = PrototypeE7M1QuestCatalogRules.GatherIntroQuestId;
// Act
var results = await Task.WhenAll(
Enumerable.Range(0, concurrentCalls).Select(_ => Task.Run(() =>
{
using var scope = Factory.Services.CreateScope();
var store = scope.ServiceProvider.GetRequiredService<IPlayerQuestStateStore>();
return store.TryActivate(PlayerId, questId, out QuestStepState _);
})));
// Assert
Assert.Equal(1, results.Count(static r => r));
Assert.Equal(concurrentCalls - 1, results.Count(static r => !r));
Assert.True(
Factory.Services.CreateScope().ServiceProvider
.GetRequiredService<IPlayerQuestStateStore>()
.TryGetProgress(PlayerId, questId, out var snapshot));
Assert.Equal(QuestProgressStatus.Active, snapshot.Status);
Assert.Equal(0, snapshot.CurrentStepIndex);
}
[RequirePostgresFact]
public async Task ActivateCounterComplete_ShouldPersistAcrossNewFactory()
{

View File

@ -68,6 +68,29 @@ public sealed class PostgresPlayerQuestStateStore(Npgsql.NpgsqlDataSource dataSo
return false;
}
using var insert = new Npgsql.NpgsqlCommand(
"""
INSERT INTO player_quest_progress (player_id, quest_id, status, current_step_index, objective_counters, updated_at)
VALUES (@pid, @qid, 'active', 0, '{}'::jsonb, now())
ON CONFLICT (player_id, quest_id) DO NOTHING;
""",
conn,
tx);
insert.Parameters.AddWithValue("pid", player);
insert.Parameters.AddWithValue("qid", quest);
if (insert.ExecuteNonQuery() > 0)
{
tx.Commit();
snapshot = new QuestStepState(
player,
quest,
QuestProgressStatus.Active,
0,
new Dictionary<string, int>(StringComparer.Ordinal),
null);
return true;
}
using (var sel = new Npgsql.NpgsqlCommand(
"""
SELECT status, current_step_index, objective_counters, completed_at
@ -82,33 +105,17 @@ public sealed class PostgresPlayerQuestStateStore(Npgsql.NpgsqlDataSource dataSo
sel.Parameters.AddWithValue("pid", player);
sel.Parameters.AddWithValue("qid", quest);
using var reader = sel.ExecuteReader();
if (reader.Read())
if (!reader.Read())
{
snapshot = ReadSnapshot(reader, player, quest);
tx.Rollback();
return false;
}
snapshot = ReadSnapshot(reader, player, quest);
}
using var insert = new Npgsql.NpgsqlCommand(
"""
INSERT INTO player_quest_progress (player_id, quest_id, status, current_step_index, objective_counters, updated_at)
VALUES (@pid, @qid, 'active', 0, '{}'::jsonb, now());
""",
conn,
tx);
insert.Parameters.AddWithValue("pid", player);
insert.Parameters.AddWithValue("qid", quest);
insert.ExecuteNonQuery();
tx.Commit();
snapshot = new QuestStepState(
player,
quest,
QuestProgressStatus.Active,
0,
new Dictionary<string, int>(StringComparer.Ordinal),
null);
return true;
tx.Rollback();
return false;
}
/// <inheritdoc />