using System; using Svrnty.CQRS.Events.Abstractions.Storage; using Svrnty.CQRS.Events.Delivery; using Svrnty.CQRS.Events.Abstractions.EventStore; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Svrnty.CQRS.Events.Abstractions; namespace Svrnty.CQRS.Events.Decorators; /// /// Decorator that provides exactly-once delivery semantics for event processing. /// /// /// /// How It Works: /// 1. Before processing an event, checks if it was already processed /// 2. Acquires a distributed lock to prevent concurrent processing /// 3. Processes the event using the wrapped handler /// 4. Marks the event as processed in the idempotency store /// 5. Releases the lock /// /// /// Guarantees: /// - No duplicate processing (even across multiple instances) /// - Automatic retry on lock contention /// - Safe for concurrent consumers /// /// /// Performance: /// Adds overhead due to database lookups and locking. /// Only use for critical operations (financial transactions, inventory, etc.) /// /// public class ExactlyOnceDeliveryDecorator { private readonly IIdempotencyStore _idempotencyStore; private readonly ILogger _logger; // Configuration private readonly TimeSpan _lockDuration; private readonly int _maxRetries; private readonly TimeSpan _retryDelay; public ExactlyOnceDeliveryDecorator( IIdempotencyStore idempotencyStore, ILogger logger, IOptions? options = null) { _idempotencyStore = idempotencyStore ?? throw new ArgumentNullException(nameof(idempotencyStore)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); var opts = options?.Value ?? new ExactlyOnceDeliveryOptions(); _lockDuration = opts.LockDuration; _maxRetries = opts.MaxRetries; _retryDelay = opts.RetryDelay; } /// /// Processes an event with exactly-once delivery semantics. /// /// The result type of the processing function. /// The consumer identifier. /// The event to process. /// The function that processes the event. /// Cancellation token. /// The result of processing, or default if the event was already processed. public async Task ProcessWithExactlyOnceAsync( string consumerId, ICorrelatedEvent @event, Func> processFunc, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(consumerId)) throw new ArgumentException("Consumer ID cannot be null or whitespace.", nameof(consumerId)); if (@event == null) throw new ArgumentNullException(nameof(@event)); if (processFunc == null) throw new ArgumentNullException(nameof(processFunc)); var eventId = @event.EventId; // Step 1: Check if already processed (fast path) if (await _idempotencyStore.WasProcessedAsync(consumerId, eventId, cancellationToken)) { _logger.LogDebug( "Event {EventId} was already processed by consumer {ConsumerId}, skipping", eventId, consumerId); return default; } var idempotencyKey = GetIdempotencyKey(consumerId, eventId); // Step 2: Try to acquire idempotency lock with retries var retryCount = 0; while (retryCount < _maxRetries) { var lockAcquired = await _idempotencyStore.TryAcquireIdempotencyLockAsync( idempotencyKey, _lockDuration, cancellationToken); if (lockAcquired) { try { // Step 3: Double-check if processed (another instance might have processed it while we waited) if (await _idempotencyStore.WasProcessedAsync(consumerId, eventId, cancellationToken)) { _logger.LogDebug( "Event {EventId} was processed by another instance while acquiring lock, skipping", eventId); return default; } // Step 4: Process the event _logger.LogDebug( "Processing event {EventId} for consumer {ConsumerId} with exactly-once semantics", eventId, consumerId); var startTime = DateTimeOffset.UtcNow; var result = await processFunc(@event, cancellationToken); // Step 5: Mark as processed await _idempotencyStore.MarkProcessedAsync( consumerId, eventId, DateTimeOffset.UtcNow, cancellationToken); _logger.LogInformation( "Successfully processed event {EventId} for consumer {ConsumerId} (exactly-once)", eventId, consumerId); return result; } catch (Exception ex) { _logger.LogError( ex, "Failed to process event {EventId} for consumer {ConsumerId} with exactly-once semantics", eventId, consumerId); throw; } finally { // Step 6: Release the lock await _idempotencyStore.ReleaseIdempotencyLockAsync(idempotencyKey, cancellationToken); } } // Lock contention - retry after delay retryCount++; if (retryCount < _maxRetries) { _logger.LogDebug( "Failed to acquire lock for event {EventId}, retry {RetryCount}/{MaxRetries}", eventId, retryCount, _maxRetries); await Task.Delay(_retryDelay, cancellationToken); } } // Failed to acquire lock after all retries _logger.LogWarning( "Failed to acquire idempotency lock for event {EventId} after {MaxRetries} retries, another instance is processing it", eventId, _maxRetries); return default; } /// /// Processes an event with exactly-once delivery semantics (no return value). /// /// The consumer identifier. /// The event to process. /// The function that processes the event. /// Cancellation token. public async Task ProcessWithExactlyOnceAsync( string consumerId, ICorrelatedEvent @event, Func processFunc, CancellationToken cancellationToken = default) { await ProcessWithExactlyOnceAsync( consumerId, @event, async (evt, ct) => { await processFunc(evt, ct); return null; }, cancellationToken); } /// /// Checks if an event was already processed by a consumer. /// public Task WasProcessedAsync( string consumerId, string eventId, CancellationToken cancellationToken = default) { return _idempotencyStore.WasProcessedAsync(consumerId, eventId, cancellationToken); } private static string GetIdempotencyKey(string consumerId, string eventId) { return $"{consumerId}:{eventId}"; } }