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