dotnet-cqrs/Svrnty.CQRS.Events/InMemory/InMemoryIdempotencyStore.cs

140 lines
4.4 KiB
C#

using System;
using Svrnty.CQRS.Events.Abstractions.Storage;
using System.Collections.Concurrent;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Svrnty.CQRS.Events.Abstractions;
namespace Svrnty.CQRS.Events.InMemory;
/// <summary>
/// In-memory implementation of <see cref="IIdempotencyStore"/> for development and testing.
/// </summary>
/// <remarks>
/// ⚠️ WARNING: This implementation is NOT suitable for production use in distributed systems.
/// - State is not shared across multiple instances
/// - State is lost on process restart
/// - No distributed locking capabilities
///
/// Use <see cref="Svrnty.CQRS.Events.PostgreSQL.PostgresIdempotencyStore"/> for production.
/// </remarks>
public sealed class InMemoryIdempotencyStore : IIdempotencyStore
{
private readonly ConcurrentDictionary<string, DateTimeOffset> _processedEvents = new();
private readonly ConcurrentDictionary<string, IdempotencyLock> _locks = new();
public Task<bool> WasProcessedAsync(
string consumerId,
string eventId,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(consumerId);
ArgumentNullException.ThrowIfNull(eventId);
var key = GetProcessedKey(consumerId, eventId);
var wasProcessed = _processedEvents.ContainsKey(key);
return Task.FromResult(wasProcessed);
}
public Task MarkProcessedAsync(
string consumerId,
string eventId,
DateTimeOffset processedAt,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(consumerId);
ArgumentNullException.ThrowIfNull(eventId);
var key = GetProcessedKey(consumerId, eventId);
_processedEvents[key] = processedAt;
return Task.CompletedTask;
}
public Task<bool> TryAcquireIdempotencyLockAsync(
string idempotencyKey,
TimeSpan lockDuration,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(idempotencyKey);
if (lockDuration <= TimeSpan.Zero)
throw new ArgumentException("Lock duration must be positive", nameof(lockDuration));
var now = DateTimeOffset.UtcNow;
var expiresAt = now.Add(lockDuration);
// Try to add a new lock
var lockAdded = _locks.TryAdd(idempotencyKey, new IdempotencyLock(now, expiresAt));
if (lockAdded)
return Task.FromResult(true);
// Lock exists - check if it's expired
if (_locks.TryGetValue(idempotencyKey, out var existingLock))
{
if (existingLock.ExpiresAt <= now)
{
// Lock expired - try to replace it
var replaced = _locks.TryUpdate(
idempotencyKey,
new IdempotencyLock(now, expiresAt),
existingLock);
return Task.FromResult(replaced);
}
}
return Task.FromResult(false);
}
public Task ReleaseIdempotencyLockAsync(
string idempotencyKey,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(idempotencyKey);
_locks.TryRemove(idempotencyKey, out _);
return Task.CompletedTask;
}
public Task<int> CleanupAsync(
DateTimeOffset olderThan,
CancellationToken cancellationToken = default)
{
// Clean up old processed event records
var processedKeysToRemove = _processedEvents
.Where(kvp => kvp.Value < olderThan)
.Select(kvp => kvp.Key)
.ToList();
var removedCount = 0;
foreach (var key in processedKeysToRemove)
{
if (_processedEvents.TryRemove(key, out _))
removedCount++;
}
// Clean up expired locks
var now = DateTimeOffset.UtcNow;
var expiredLocks = _locks
.Where(kvp => kvp.Value.ExpiresAt <= now)
.Select(kvp => kvp.Key)
.ToList();
foreach (var key in expiredLocks)
{
_locks.TryRemove(key, out _);
}
return Task.FromResult(removedCount);
}
private static string GetProcessedKey(string consumerId, string eventId)
=> $"{consumerId}:{eventId}";
private sealed record IdempotencyLock(DateTimeOffset AcquiredAt, DateTimeOffset ExpiresAt);
}