neon-sprawl/server/NeonSprawl.Server/Game/Contracts/InMemoryContractOutcomeStor...

169 lines
5.2 KiB
C#

using System.Collections.Concurrent;
using NeonSprawl.Server.Game.Rewards;
namespace NeonSprawl.Server.Game.Contracts;
/// <summary>Thread-safe in-memory append-only contract outcome audit log (NEO-146).</summary>
public sealed class InMemoryContractOutcomeStore(IContractInstanceStore instanceStore) : IContractOutcomeStore
{
private readonly ConcurrentDictionary<string, ContractOutcomeRow> rowsById = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, string> rowIdByIdempotencyKey = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, object> idLocks = new(StringComparer.Ordinal);
/// <inheritdoc />
public bool TryAppend(ContractOutcomeRow row)
{
if (!TryNormalizeRow(row, out var normalized))
{
return false;
}
if (!instanceStore.TryGet(normalized.PlayerId, normalized.ContractInstanceId, out _))
{
return false;
}
var appended = false;
LockRowAndIdempotencyKey(normalized.Id, normalized.IdempotencyKey, () =>
{
if (rowsById.ContainsKey(normalized.Id) ||
rowIdByIdempotencyKey.ContainsKey(normalized.IdempotencyKey))
{
return;
}
rowsById[normalized.Id] = normalized;
rowIdByIdempotencyKey[normalized.IdempotencyKey] = normalized.Id;
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();
}
}
}
/// <inheritdoc />
public bool TryGetByIdempotencyKey(string idempotencyKey, out ContractOutcomeRow row)
{
row = null!;
var key = idempotencyKey?.Trim();
if (string.IsNullOrEmpty(key))
{
return false;
}
if (!rowIdByIdempotencyKey.TryGetValue(key, out var rowId) ||
!rowsById.TryGetValue(rowId, out var stored))
{
return false;
}
row = stored;
return true;
}
/// <inheritdoc />
public IReadOnlyList<ContractOutcomeRow> GetOutcomesForInstance(string contractInstanceId, int? limit = null)
{
var instanceId = ContractInstanceIds.NormalizeContractInstanceId(contractInstanceId);
if (instanceId.Length == 0)
{
return [];
}
IEnumerable<ContractOutcomeRow> query = rowsById.Values
.Where(r => string.Equals(r.ContractInstanceId, instanceId, StringComparison.Ordinal))
.OrderBy(static r => r.RecordedAt)
.ThenBy(static r => r.Id, StringComparer.Ordinal);
if (limit is > 0)
{
query = query.Take(limit.Value);
}
return [..query];
}
/// <inheritdoc />
public bool TryClearForInstance(string contractInstanceId)
{
var instanceId = ContractInstanceIds.NormalizeContractInstanceId(contractInstanceId);
if (instanceId.Length == 0)
{
return false;
}
string[] idsToRemove = [.. rowsById.Values
.Where(row => string.Equals(row.ContractInstanceId, instanceId, StringComparison.Ordinal))
.Select(static row => row.Id)];
foreach (var id in idsToRemove)
{
if (rowsById.TryRemove(id, out var removed))
{
rowIdByIdempotencyKey.TryRemove(removed.IdempotencyKey, out _);
idLocks.TryRemove(IdempotencyLockKey(removed.IdempotencyKey), out _);
}
idLocks.TryRemove(id, out _);
}
return true;
}
private static bool TryNormalizeRow(ContractOutcomeRow row, out ContractOutcomeRow normalized)
{
normalized = null!;
var id = row.Id.Trim();
var playerId = ContractInstanceIds.NormalizePlayerId(row.PlayerId);
var instanceId = ContractInstanceIds.NormalizeContractInstanceId(row.ContractInstanceId);
var idempotencyKey = row.IdempotencyKey.Trim();
if (id.Length == 0 ||
playerId.Length == 0 ||
instanceId.Length == 0 ||
idempotencyKey.Length == 0)
{
return false;
}
normalized = row with
{
Id = id,
PlayerId = playerId,
ContractInstanceId = instanceId,
IdempotencyKey = idempotencyKey,
GrantedItems = [..row.GrantedItems],
GrantedSkillXp = [..row.GrantedSkillXp],
GrantedReputation = [..row.GrantedReputation],
};
return true;
}
}