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 const string PlayerId = "dev-local-1";
|
||||||
private static readonly DateTimeOffset CompletedAt = new(2026, 6, 3, 14, 30, 0, TimeSpan.Zero);
|
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]
|
[RequirePostgresFact]
|
||||||
public async Task ActivateCounterComplete_ShouldPersistAcrossNewFactory()
|
public async Task ActivateCounterComplete_ShouldPersistAcrossNewFactory()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,29 @@ public sealed class PostgresPlayerQuestStateStore(Npgsql.NpgsqlDataSource dataSo
|
||||||
return false;
|
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(
|
using (var sel = new Npgsql.NpgsqlCommand(
|
||||||
"""
|
"""
|
||||||
SELECT status, current_step_index, objective_counters, completed_at
|
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("pid", player);
|
||||||
sel.Parameters.AddWithValue("qid", quest);
|
sel.Parameters.AddWithValue("qid", quest);
|
||||||
using var reader = sel.ExecuteReader();
|
using var reader = sel.ExecuteReader();
|
||||||
if (reader.Read())
|
if (!reader.Read())
|
||||||
{
|
{
|
||||||
snapshot = ReadSnapshot(reader, player, quest);
|
|
||||||
tx.Rollback();
|
tx.Rollback();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
snapshot = ReadSnapshot(reader, player, quest);
|
||||||
}
|
}
|
||||||
|
|
||||||
using var insert = new Npgsql.NpgsqlCommand(
|
tx.Rollback();
|
||||||
"""
|
return false;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue