NEO-146: fix in-memory outcome idempotency race and clear parity

Lock idempotency keys alongside row ids in TryAppend so concurrent
duplicate keys cannot both succeed; return true from TryClearInstance
when the row is absent to match Postgres idempotent DELETE behavior.
pull/187/head
VinPropane 2026-06-24 21:13:42 -04:00
parent 237e39c8ca
commit 93bd45d49a
4 changed files with 70 additions and 4 deletions

View File

@ -265,6 +265,18 @@ public sealed class InMemoryContractInstanceStoreTests
}
}
[Fact]
public void TryClearInstance_ShouldReturnTrue_WhenRowMissing()
{
// Arrange
var store = CreateStore();
// Act
var cleared = store.TryClearInstance(PlayerId, InstanceId);
// Assert
Assert.True(cleared);
Assert.False(store.TryGet(PlayerId, InstanceId, out _));
}
[Fact]
public async Task Host_ShouldResolveContractInstanceStoresFromDi()
{

View File

@ -107,6 +107,29 @@ public sealed class InMemoryContractOutcomeStoreTests
Assert.Empty(store.GetOutcomesForInstance(InstanceId));
}
[Fact]
public async Task TryAppend_ShouldAllowOnlyOneSuccess_WhenConcurrentDuplicateIdempotencyKeysRace()
{
// Arrange
var (instanceStore, store) = CreateStores();
SeedActiveInstance(instanceStore);
var idempotencyKey = ContractOutcomeIds.MakeIdempotencyKey(PlayerId, InstanceId);
var baseRow = CreatePrototypeOutcome();
// Act
var results = await Task.WhenAll(
Enumerable.Range(0, 8).Select(i => Task.Run(() =>
{
var row = baseRow with { Id = $"outcome-row-race-{i:D3}" };
Assert.Equal(idempotencyKey, row.IdempotencyKey);
return store.TryAppend(row);
})));
// Assert
Assert.Equal(1, results.Count(static r => r));
Assert.Equal(7, results.Count(static r => !r));
Assert.Single(store.GetOutcomesForInstance(InstanceId));
}
private static (InMemoryContractInstanceStore InstanceStore, InMemoryContractOutcomeStore OutcomeStore) CreateStores()
{
var options = Options.Create(new GamePositionOptions { DevPlayerId = PlayerId });

View File

@ -263,7 +263,7 @@ public sealed class InMemoryContractInstanceStore(IOptions<GamePositionOptions>
if (!byInstanceId.TryGetValue(instanceId, out var row) ||
!string.Equals(row.PlayerId, player, StringComparison.Ordinal))
{
return false;
return true;
}
_ = byInstanceId.TryRemove(instanceId, out _);

View File

@ -25,17 +25,47 @@ public sealed class InMemoryContractOutcomeStore(IContractInstanceStore instance
return false;
}
lock (idLocks.GetOrAdd(normalized.Id, _ => new object()))
var appended = false;
LockRowAndIdempotencyKey(normalized.Id, normalized.IdempotencyKey, () =>
{
if (rowsById.ContainsKey(normalized.Id) ||
rowIdByIdempotencyKey.ContainsKey(normalized.IdempotencyKey))
{
return false;
return;
}
rowsById[normalized.Id] = normalized;
rowIdByIdempotencyKey[normalized.IdempotencyKey] = normalized.Id;
return true;
appended = true;
});
return appended;
}
private static string IdempotencyLockKey(string idempotencyKey) => $"idem\0{idempotencyKey}";
private void LockRowAndIdempotencyKey(string rowId, string idempotencyKey, Action action)
{
var idemLockKey = IdempotencyLockKey(idempotencyKey);
if (string.CompareOrdinal(rowId, idemLockKey) <= 0)
{
lock (idLocks.GetOrAdd(rowId, _ => new object()))
{
lock (idLocks.GetOrAdd(idemLockKey, _ => new object()))
{
action();
}
}
return;
}
lock (idLocks.GetOrAdd(idemLockKey, _ => new object()))
{
lock (idLocks.GetOrAdd(rowId, _ => new object()))
{
action();
}
}
}
@ -99,6 +129,7 @@ public sealed class InMemoryContractOutcomeStore(IContractInstanceStore instance
if (rowsById.TryRemove(id, out var removed))
{
rowIdByIdempotencyKey.TryRemove(removed.IdempotencyKey, out _);
idLocks.TryRemove(IdempotencyLockKey(removed.IdempotencyKey), out _);
}
idLocks.TryRemove(id, out _);