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; /// /// In-memory implementation of for development and testing. /// /// /// ⚠️ 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 for production. /// public sealed class InMemoryIdempotencyStore : IIdempotencyStore { private readonly ConcurrentDictionary _processedEvents = new(); private readonly ConcurrentDictionary _locks = new(); public Task 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 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 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); }