140 lines
4.4 KiB
C#
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);
|
|
}
|