521 lines
20 KiB
C#
521 lines
20 KiB
C#
using System;
|
|
using Svrnty.CQRS.Events.Abstractions.Schema;
|
|
using Svrnty.CQRS.Events.Abstractions.Storage;
|
|
using Svrnty.CQRS.Events.Schema;
|
|
using Svrnty.CQRS.Events.Abstractions.Subscriptions;
|
|
using Svrnty.CQRS.Events.Abstractions.EventStore;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Runtime.CompilerServices;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Microsoft.Extensions.Logging;
|
|
using Svrnty.CQRS.Events.Abstractions;
|
|
|
|
namespace Svrnty.CQRS.Events.Subscriptions;
|
|
|
|
/// <summary>
|
|
/// Default implementation of <see cref="IEventSubscriptionClient"/>.
|
|
/// Provides event streaming from subscriptions to consumers.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <strong>Phase 1 Implementation:</strong>
|
|
/// Supports Broadcast and Exclusive modes with in-memory storage.
|
|
/// ConsumerGroup and ReadReceipt modes will be fully implemented in later phases.
|
|
/// </para>
|
|
/// <para>
|
|
/// <strong>Phase 5 Implementation:</strong>
|
|
/// Supports automatic event upcasting when schema evolution is enabled.
|
|
/// </para>
|
|
/// </remarks>
|
|
public class EventSubscriptionClient : IEventSubscriptionClient
|
|
{
|
|
private readonly IEventStreamStore _streamStore;
|
|
private readonly IConsumerRegistry _consumerRegistry;
|
|
private readonly IReadReceiptStore _readReceiptStore;
|
|
private readonly ISchemaRegistry? _schemaRegistry;
|
|
private readonly ILogger<EventSubscriptionClient>? _logger;
|
|
private readonly Dictionary<string, Subscription> _subscriptions; // In-memory for Phase 1
|
|
|
|
/// <summary>
|
|
/// Initializes a new instance of the <see cref="EventSubscriptionClient"/> class.
|
|
/// </summary>
|
|
public EventSubscriptionClient(
|
|
IEventStreamStore streamStore,
|
|
IConsumerRegistry consumerRegistry,
|
|
IReadReceiptStore readReceiptStore,
|
|
IEnumerable<Subscription> subscriptions,
|
|
ISchemaRegistry? schemaRegistry = null,
|
|
ILogger<EventSubscriptionClient>? logger = null)
|
|
{
|
|
_streamStore = streamStore ?? throw new ArgumentNullException(nameof(streamStore));
|
|
_consumerRegistry = consumerRegistry ?? throw new ArgumentNullException(nameof(consumerRegistry));
|
|
_readReceiptStore = readReceiptStore ?? throw new ArgumentNullException(nameof(readReceiptStore));
|
|
_schemaRegistry = schemaRegistry;
|
|
_logger = logger;
|
|
_subscriptions = new Dictionary<string, Subscription>();
|
|
|
|
// Register all subscriptions provided via DI
|
|
if (subscriptions != null)
|
|
{
|
|
foreach (var subscription in subscriptions)
|
|
{
|
|
RegisterSubscription(subscription);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Register a subscription (for Phase 1, stored in-memory).
|
|
/// </summary>
|
|
public void RegisterSubscription(Subscription subscription)
|
|
{
|
|
if (subscription == null)
|
|
throw new ArgumentNullException(nameof(subscription));
|
|
|
|
subscription.Validate();
|
|
_subscriptions[subscription.SubscriptionId] = subscription;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async IAsyncEnumerable<ICorrelatedEvent> SubscribeAsync(
|
|
string subscriptionId,
|
|
string consumerId,
|
|
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
|
{
|
|
await foreach (var @event in SubscribeAsync(subscriptionId, consumerId, null!, cancellationToken))
|
|
{
|
|
yield return @event;
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async IAsyncEnumerable<ICorrelatedEvent> SubscribeAsync(
|
|
string subscriptionId,
|
|
string consumerId,
|
|
Dictionary<string, string> metadata,
|
|
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(subscriptionId))
|
|
throw new ArgumentException("Subscription ID cannot be null or whitespace.", nameof(subscriptionId));
|
|
if (string.IsNullOrWhiteSpace(consumerId))
|
|
throw new ArgumentException("Consumer ID cannot be null or whitespace.", nameof(consumerId));
|
|
|
|
// Get subscription configuration
|
|
if (!_subscriptions.TryGetValue(subscriptionId, out var subscription))
|
|
{
|
|
throw new InvalidOperationException($"Subscription '{subscriptionId}' not found. Register it first using RegisterSubscription().");
|
|
}
|
|
|
|
if (!subscription.IsActive)
|
|
{
|
|
throw new InvalidOperationException($"Subscription '{subscriptionId}' is not active.");
|
|
}
|
|
|
|
// Register consumer
|
|
await _consumerRegistry.RegisterConsumerAsync(subscriptionId, consumerId, metadata, cancellationToken);
|
|
|
|
try
|
|
{
|
|
// Stream events based on subscription mode
|
|
switch (subscription.Mode)
|
|
{
|
|
case SubscriptionMode.Broadcast:
|
|
await foreach (var @event in StreamBroadcastAsync(subscription, consumerId, cancellationToken))
|
|
{
|
|
yield return @event;
|
|
}
|
|
break;
|
|
|
|
case SubscriptionMode.Exclusive:
|
|
await foreach (var @event in StreamExclusiveAsync(subscription, consumerId, cancellationToken))
|
|
{
|
|
yield return @event;
|
|
}
|
|
break;
|
|
|
|
case SubscriptionMode.ConsumerGroup:
|
|
// Phase 1: Same as Exclusive for now
|
|
// Phase 3+ will implement proper consumer group partitioning
|
|
await foreach (var @event in StreamExclusiveAsync(subscription, consumerId, cancellationToken))
|
|
{
|
|
yield return @event;
|
|
}
|
|
break;
|
|
|
|
case SubscriptionMode.ReadReceipt:
|
|
throw new NotImplementedException(
|
|
"ReadReceipt mode is not implemented in Phase 1. " +
|
|
"It will be fully implemented in Phase 3 with explicit MarkAsRead support.");
|
|
|
|
default:
|
|
throw new NotSupportedException($"Subscription mode '{subscription.Mode}' is not supported.");
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
// Unregister consumer when enumeration ends
|
|
await _consumerRegistry.UnregisterConsumerAsync(subscriptionId, consumerId, cancellationToken);
|
|
}
|
|
}
|
|
|
|
// ========================================================================
|
|
// Phase 5: Schema Evolution & Upcasting
|
|
// ========================================================================
|
|
|
|
/// <summary>
|
|
/// Applies automatic upcasting to an event if enabled for the subscription.
|
|
/// </summary>
|
|
/// <param name="event">The event to potentially upcast.</param>
|
|
/// <param name="subscription">The subscription configuration.</param>
|
|
/// <param name="cancellationToken">Cancellation token.</param>
|
|
/// <returns>The upcast event, or the original event if upcasting is not needed/enabled.</returns>
|
|
private async Task<ICorrelatedEvent> ApplyUpcastingAsync(
|
|
ICorrelatedEvent @event,
|
|
Subscription subscription,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
// Skip if upcasting not enabled
|
|
if (!subscription.EnableUpcasting)
|
|
return @event;
|
|
|
|
// Skip if schema registry not available
|
|
if (_schemaRegistry == null)
|
|
{
|
|
_logger?.LogWarning(
|
|
"Upcasting enabled for subscription {SubscriptionId} but ISchemaRegistry is not registered. " +
|
|
"Event will be delivered without upcasting.",
|
|
subscription.SubscriptionId);
|
|
return @event;
|
|
}
|
|
|
|
try
|
|
{
|
|
// Check if upcasting is needed
|
|
var needsUpcasting = await _schemaRegistry.NeedsUpcastingAsync(
|
|
@event,
|
|
subscription.TargetEventVersion,
|
|
cancellationToken);
|
|
|
|
if (!needsUpcasting)
|
|
return @event;
|
|
|
|
// Perform upcasting
|
|
var upcastEvent = await _schemaRegistry.UpcastAsync(
|
|
@event,
|
|
subscription.TargetEventVersion,
|
|
cancellationToken);
|
|
|
|
_logger?.LogDebug(
|
|
"Upcast event {EventType} from v{FromVersion} to v{ToVersion} for subscription {SubscriptionId}",
|
|
@event.GetType().Name,
|
|
@event.GetType().Name,
|
|
upcastEvent.GetType().Name,
|
|
subscription.SubscriptionId);
|
|
|
|
return upcastEvent;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger?.LogError(
|
|
ex,
|
|
"Failed to upcast event {EventId} for subscription {SubscriptionId}. Delivering original event.",
|
|
@event.EventId,
|
|
subscription.SubscriptionId);
|
|
|
|
// On upcasting failure, deliver original event rather than losing it
|
|
return @event;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Stream events in Broadcast mode (all consumers get all events).
|
|
/// </summary>
|
|
private async IAsyncEnumerable<ICorrelatedEvent> StreamBroadcastAsync(
|
|
Subscription subscription,
|
|
string consumerId,
|
|
[EnumeratorCancellation] CancellationToken cancellationToken)
|
|
{
|
|
// In Broadcast mode, each consumer gets their own copy of events
|
|
// We use a polling approach with a small delay between polls
|
|
var pollInterval = TimeSpan.FromMilliseconds(100);
|
|
|
|
while (!cancellationToken.IsCancellationRequested)
|
|
{
|
|
// Dequeue event from stream
|
|
var @event = await _streamStore.DequeueAsync(
|
|
subscription.StreamName,
|
|
consumerId,
|
|
subscription.VisibilityTimeout,
|
|
cancellationToken);
|
|
|
|
if (@event != null)
|
|
{
|
|
// Apply event type filter if configured
|
|
if (ShouldIncludeEvent(@event, subscription))
|
|
{
|
|
// Phase 5: Apply automatic upcasting if enabled
|
|
var deliveryEvent = await ApplyUpcastingAsync(@event, subscription, cancellationToken);
|
|
|
|
yield return deliveryEvent;
|
|
|
|
// Auto-acknowledge (event consumed successfully)
|
|
await _streamStore.AcknowledgeAsync(
|
|
subscription.StreamName,
|
|
@event.EventId,
|
|
consumerId,
|
|
cancellationToken);
|
|
}
|
|
else
|
|
{
|
|
// Event filtered out, acknowledge it anyway
|
|
await _streamStore.AcknowledgeAsync(
|
|
subscription.StreamName,
|
|
@event.EventId,
|
|
consumerId,
|
|
cancellationToken);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
// No events available, wait before polling again
|
|
await Task.Delay(pollInterval, cancellationToken);
|
|
}
|
|
|
|
// Send heartbeat
|
|
await _consumerRegistry.HeartbeatAsync(subscription.SubscriptionId, consumerId, cancellationToken);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Stream events in Exclusive mode (only one consumer gets each event).
|
|
/// </summary>
|
|
private async IAsyncEnumerable<ICorrelatedEvent> StreamExclusiveAsync(
|
|
Subscription subscription,
|
|
string consumerId,
|
|
[EnumeratorCancellation] CancellationToken cancellationToken)
|
|
{
|
|
// In Exclusive mode, consumers compete for events
|
|
// First consumer to dequeue gets the event
|
|
var pollInterval = TimeSpan.FromMilliseconds(100);
|
|
|
|
while (!cancellationToken.IsCancellationRequested)
|
|
{
|
|
// Try to dequeue an event
|
|
var @event = await _streamStore.DequeueAsync(
|
|
subscription.StreamName,
|
|
consumerId,
|
|
subscription.VisibilityTimeout,
|
|
cancellationToken);
|
|
|
|
if (@event != null)
|
|
{
|
|
// Apply event type filter if configured
|
|
if (ShouldIncludeEvent(@event, subscription))
|
|
{
|
|
// Phase 5: Apply automatic upcasting if enabled
|
|
var deliveryEvent = await ApplyUpcastingAsync(@event, subscription, cancellationToken);
|
|
|
|
yield return deliveryEvent;
|
|
|
|
// Auto-acknowledge (event consumed successfully)
|
|
await _streamStore.AcknowledgeAsync(
|
|
subscription.StreamName,
|
|
@event.EventId,
|
|
consumerId,
|
|
cancellationToken);
|
|
}
|
|
else
|
|
{
|
|
// Event filtered out, acknowledge it anyway
|
|
await _streamStore.AcknowledgeAsync(
|
|
subscription.StreamName,
|
|
@event.EventId,
|
|
consumerId,
|
|
cancellationToken);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
// No events available, wait before polling again
|
|
await Task.Delay(pollInterval, cancellationToken);
|
|
}
|
|
|
|
// Send heartbeat
|
|
await _consumerRegistry.HeartbeatAsync(subscription.SubscriptionId, consumerId, cancellationToken);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Check if an event should be included based on subscription filters.
|
|
/// </summary>
|
|
private static bool ShouldIncludeEvent(ICorrelatedEvent @event, Subscription subscription)
|
|
{
|
|
// No filter means include all events
|
|
if (subscription.EventTypeFilter == null || subscription.EventTypeFilter.Count == 0)
|
|
return true;
|
|
|
|
// Check if event type is in the filter
|
|
var eventTypeName = @event.GetType().Name;
|
|
return subscription.EventTypeFilter.Contains(eventTypeName);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task<bool> AcknowledgeAsync(
|
|
string subscriptionId,
|
|
string eventId,
|
|
string consumerId,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(subscriptionId))
|
|
throw new ArgumentException("Subscription ID cannot be null or whitespace.", nameof(subscriptionId));
|
|
if (string.IsNullOrWhiteSpace(eventId))
|
|
throw new ArgumentException("Event ID cannot be null or whitespace.", nameof(eventId));
|
|
if (string.IsNullOrWhiteSpace(consumerId))
|
|
throw new ArgumentException("Consumer ID cannot be null or whitespace.", nameof(consumerId));
|
|
|
|
if (!_subscriptions.TryGetValue(subscriptionId, out var subscription))
|
|
{
|
|
throw new InvalidOperationException($"Subscription '{subscriptionId}' not found.");
|
|
}
|
|
|
|
return _streamStore.AcknowledgeAsync(subscription.StreamName, eventId, consumerId, cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task<bool> NackAsync(
|
|
string subscriptionId,
|
|
string eventId,
|
|
string consumerId,
|
|
bool requeue = true,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(subscriptionId))
|
|
throw new ArgumentException("Subscription ID cannot be null or whitespace.", nameof(subscriptionId));
|
|
if (string.IsNullOrWhiteSpace(eventId))
|
|
throw new ArgumentException("Event ID cannot be null or whitespace.", nameof(eventId));
|
|
if (string.IsNullOrWhiteSpace(consumerId))
|
|
throw new ArgumentException("Consumer ID cannot be null or whitespace.", nameof(consumerId));
|
|
|
|
if (!_subscriptions.TryGetValue(subscriptionId, out var subscription))
|
|
{
|
|
throw new InvalidOperationException($"Subscription '{subscriptionId}' not found.");
|
|
}
|
|
|
|
return _streamStore.NackAsync(subscription.StreamName, eventId, consumerId, requeue, cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task<ISubscription?> GetSubscriptionAsync(
|
|
string subscriptionId,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(subscriptionId))
|
|
throw new ArgumentException("Subscription ID cannot be null or whitespace.", nameof(subscriptionId));
|
|
|
|
_subscriptions.TryGetValue(subscriptionId, out var subscription);
|
|
return Task.FromResult<ISubscription?>(subscription);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task<List<ConsumerInfo>> GetActiveConsumersAsync(
|
|
string subscriptionId,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(subscriptionId))
|
|
throw new ArgumentException("Subscription ID cannot be null or whitespace.", nameof(subscriptionId));
|
|
|
|
return _consumerRegistry.GetConsumerInfoAsync(subscriptionId, cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task<bool> UnsubscribeAsync(
|
|
string subscriptionId,
|
|
string consumerId,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(subscriptionId))
|
|
throw new ArgumentException("Subscription ID cannot be null or whitespace.", nameof(subscriptionId));
|
|
if (string.IsNullOrWhiteSpace(consumerId))
|
|
throw new ArgumentException("Consumer ID cannot be null or whitespace.", nameof(consumerId));
|
|
|
|
return _consumerRegistry.UnregisterConsumerAsync(subscriptionId, consumerId, cancellationToken);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Get all registered subscriptions (for debugging/monitoring).
|
|
/// </summary>
|
|
public IReadOnlyList<Subscription> GetAllSubscriptions()
|
|
{
|
|
return _subscriptions.Values.ToList();
|
|
}
|
|
|
|
// ========================================================================
|
|
// Phase 3: Read Receipt API Implementation
|
|
// ========================================================================
|
|
|
|
/// <inheritdoc />
|
|
public Task RecordReadReceiptAsync(
|
|
string streamName,
|
|
string consumerId,
|
|
string eventId,
|
|
long offset,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(streamName))
|
|
throw new ArgumentException("Stream name cannot be null or whitespace.", nameof(streamName));
|
|
if (string.IsNullOrWhiteSpace(consumerId))
|
|
throw new ArgumentException("Consumer ID cannot be null or whitespace.", nameof(consumerId));
|
|
if (string.IsNullOrWhiteSpace(eventId))
|
|
throw new ArgumentException("Event ID cannot be null or whitespace.", nameof(eventId));
|
|
|
|
return _readReceiptStore.AcknowledgeEventAsync(
|
|
consumerId,
|
|
streamName,
|
|
eventId,
|
|
offset,
|
|
DateTimeOffset.UtcNow,
|
|
cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task<long?> GetLastReadOffsetAsync(
|
|
string streamName,
|
|
string consumerId,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(streamName))
|
|
throw new ArgumentException("Stream name cannot be null or whitespace.", nameof(streamName));
|
|
if (string.IsNullOrWhiteSpace(consumerId))
|
|
throw new ArgumentException("Consumer ID cannot be null or whitespace.", nameof(consumerId));
|
|
|
|
return _readReceiptStore.GetLastAcknowledgedOffsetAsync(consumerId, streamName, cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task<ConsumerProgress?> GetConsumerProgressAsync(
|
|
string streamName,
|
|
string consumerId,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(streamName))
|
|
throw new ArgumentException("Stream name cannot be null or whitespace.", nameof(streamName));
|
|
if (string.IsNullOrWhiteSpace(consumerId))
|
|
throw new ArgumentException("Consumer ID cannot be null or whitespace.", nameof(consumerId));
|
|
|
|
return _readReceiptStore.GetConsumerProgressAsync(consumerId, streamName, cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task<IReadOnlyList<string>> GetStreamConsumersAsync(
|
|
string streamName,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(streamName))
|
|
throw new ArgumentException("Stream name cannot be null or whitespace.", nameof(streamName));
|
|
|
|
return _readReceiptStore.GetConsumersForStreamAsync(streamName, cancellationToken);
|
|
}
|
|
}
|