221 lines
8.2 KiB
C#
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}";
|
|
}
|
|
}
|