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] [Fact]
public async Task Host_ShouldResolveContractInstanceStoresFromDi() public async Task Host_ShouldResolveContractInstanceStoresFromDi()
{ {

View File

@ -107,6 +107,29 @@ public sealed class InMemoryContractOutcomeStoreTests
Assert.Empty(store.GetOutcomesForInstance(InstanceId)); 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() private static (InMemoryContractInstanceStore InstanceStore, InMemoryContractOutcomeStore OutcomeStore) CreateStores()
{ {
var options = Options.Create(new GamePositionOptions { DevPlayerId = PlayerId }); 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) || if (!byInstanceId.TryGetValue(instanceId, out var row) ||
!string.Equals(row.PlayerId, player, StringComparison.Ordinal)) !string.Equals(row.PlayerId, player, StringComparison.Ordinal))
{ {
return false; return true;
} }
_ = byInstanceId.TryRemove(instanceId, out _); _ = byInstanceId.TryRemove(instanceId, out _);

View File

@ -25,17 +25,47 @@ public sealed class InMemoryContractOutcomeStore(IContractInstanceStore instance
return false; return false;
} }
lock (idLocks.GetOrAdd(normalized.Id, _ => new object())) var appended = false;
LockRowAndIdempotencyKey(normalized.Id, normalized.IdempotencyKey, () =>
{ {
if (rowsById.ContainsKey(normalized.Id) || if (rowsById.ContainsKey(normalized.Id) ||
rowIdByIdempotencyKey.ContainsKey(normalized.IdempotencyKey)) rowIdByIdempotencyKey.ContainsKey(normalized.IdempotencyKey))
{ {
return false; return;
} }
rowsById[normalized.Id] = normalized; rowsById[normalized.Id] = normalized;
rowIdByIdempotencyKey[normalized.IdempotencyKey] = normalized.Id; 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)) if (rowsById.TryRemove(id, out var removed))
{ {
rowIdByIdempotencyKey.TryRemove(removed.IdempotencyKey, out _); rowIdByIdempotencyKey.TryRemove(removed.IdempotencyKey, out _);
idLocks.TryRemove(IdempotencyLockKey(removed.IdempotencyKey), out _);
} }
idLocks.TryRemove(id, out _); idLocks.TryRemove(id, out _);