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
parent
de611ac294
commit
fabbdae4c2
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
Loading…
Reference in New Issue