dotnet-cqrs/Svrnty.CQRS.Events/Decorators/ExactlyOnceDeliveryDecorator.cs

221 lines
8.2 KiB
C#

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;
/// <summary>
/// Decorator that provides exactly-once delivery semantics for event processing.
/// </summary>
/// <remarks>
/// <para>
/// <strong>How It Works:</strong>
/// 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
/// </para>
/// <para>
/// <strong>Guarantees:</strong>
/// - No duplicate processing (even across multiple instances)
/// - Automatic retry on lock contention
/// - Safe for concurrent consumers
/// </para>
/// <para>
/// <strong>Performance:</strong>
/// Adds overhead due to database lookups and locking.
/// Only use for critical operations (financial transactions, inventory, etc.)
/// </para>
/// </remarks>
public class ExactlyOnceDeliveryDecorator
{
private readonly IIdempotencyStore _idempotencyStore;
private readonly ILogger<ExactlyOnceDeliveryDecorator> _logger;
// Configuration
private readonly TimeSpan _lockDuration;
private readonly int _maxRetries;
private readonly TimeSpan _retryDelay;
public ExactlyOnceDeliveryDecorator(
IIdempotencyStore idempotencyStore,
ILogger<ExactlyOnceDeliveryDecorator> logger,
IOptions<ExactlyOnceDeliveryOptions>? 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;
}
/// <summary>
/// Processes an event with exactly-once delivery semantics.
/// </summary>
/// <typeparam name="TResult">The result type of the processing function.</typeparam>
/// <param name="consumerId">The consumer identifier.</param>
/// <param name="event">The event to process.</param>
/// <param name="processFunc">The function that processes the event.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The result of processing, or default if the event was already processed.</returns>
public async Task<TResult?> ProcessWithExactlyOnceAsync<TResult>(
string consumerId,
ICorrelatedEvent @event,
Func<ICorrelatedEvent, CancellationToken, Task<TResult>> 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;
}
/// <summary>
/// Processes an event with exactly-once delivery semantics (no return value).
/// </summary>
/// <param name="consumerId">The consumer identifier.</param>
/// <param name="event">The event to process.</param>
/// <param name="processFunc">The function that processes the event.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public async Task ProcessWithExactlyOnceAsync(
string consumerId,
ICorrelatedEvent @event,
Func<ICorrelatedEvent, CancellationToken, Task> processFunc,
CancellationToken cancellationToken = default)
{
await ProcessWithExactlyOnceAsync<object?>(
consumerId,
@event,
async (evt, ct) =>
{
await processFunc(evt, ct);
return null;
},
cancellationToken);
}
/// <summary>
/// Checks if an event was already processed by a consumer.
/// </summary>
public Task<bool> 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}";
}
}