dotnet-cqrs/Svrnty.CQRS.Events.Grpc/EventServiceImpl.cs

443 lines
17 KiB
C#

using System;
using Svrnty.CQRS.Events.Abstractions.EventStore;
using System.Collections.Concurrent;
using System.Linq;
using System.Threading.Tasks;
using Google.Protobuf.WellKnownTypes;
using Grpc.Core;
using Microsoft.Extensions.Logging;
using Svrnty.CQRS.Events.Abstractions;
using Svrnty.CQRS.Events.Abstractions.Subscriptions;
namespace Svrnty.CQRS.Events.Grpc;
/// <summary>
/// gRPC service implementation for Phase 8 persistent subscriptions via bidirectional streaming.
/// </summary>
/// <remarks>
/// This service provides the same functionality as SignalR hubs but using gRPC protocol,
/// making it suitable for non-browser clients (mobile apps, services, etc.).
/// </remarks>
public sealed class EventServiceImpl : EventService.EventServiceBase
{
private readonly ISubscriptionManager _subscriptionManager;
private readonly IPersistentSubscriptionDeliveryService _deliveryService;
private readonly IPersistentSubscriptionStore _subscriptionStore;
private readonly ILogger<EventServiceImpl> _logger;
// Track active gRPC streams by subscriber ID
private static readonly ConcurrentDictionary<string, IServerStreamWriter<EventMessage>> _activeStreams = new();
public EventServiceImpl(
ISubscriptionManager subscriptionManager,
IPersistentSubscriptionDeliveryService deliveryService,
IPersistentSubscriptionStore subscriptionStore,
ILogger<EventServiceImpl> logger)
{
_subscriptionManager = subscriptionManager ?? throw new ArgumentNullException(nameof(subscriptionManager));
_deliveryService = deliveryService ?? throw new ArgumentNullException(nameof(deliveryService));
_subscriptionStore = subscriptionStore ?? throw new ArgumentNullException(nameof(subscriptionStore));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public override async Task Subscribe(
IAsyncStreamReader<SubscriptionRequest> requestStream,
IServerStreamWriter<EventMessage> responseStream,
ServerCallContext context)
{
string? subscriberId = null;
try
{
// Extract subscriber ID from metadata (would typically come from JWT auth)
subscriberId = context.RequestHeaders.GetValue("subscriber-id") ?? Guid.NewGuid().ToString();
_logger.LogInformation("gRPC client {SubscriberId} connected to event stream", subscriberId);
// Register this stream for push-based event delivery
_activeStreams.TryAdd(subscriberId, responseStream);
// Process incoming requests from client
await foreach (var request in requestStream.ReadAllAsync(context.CancellationToken))
{
try
{
await HandleRequestAsync(request, subscriberId, responseStream, context);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error handling request from {SubscriberId}", subscriberId);
await responseStream.WriteAsync(new EventMessage
{
Error = new ErrorMessage
{
Code = "internal_error",
Message = ex.Message
}
});
}
}
_logger.LogInformation("gRPC client {SubscriberId} disconnected from event stream", subscriberId);
}
catch (Exception ex)
{
_logger.LogError(ex, "Stream error for {SubscriberId}", subscriberId);
}
finally
{
// Remove stream on disconnect
if (subscriberId != null)
{
_activeStreams.TryRemove(subscriberId, out _);
}
}
}
private async Task HandleRequestAsync(
SubscriptionRequest request,
string subscriberId,
IServerStreamWriter<EventMessage> responseStream,
ServerCallContext context)
{
switch (request.RequestTypeCase)
{
case SubscriptionRequest.RequestTypeOneofCase.Subscribe:
await HandleSubscribeAsync(request.Subscribe, subscriberId, responseStream, context);
break;
case SubscriptionRequest.RequestTypeOneofCase.Unsubscribe:
await HandleUnsubscribeAsync(request.Unsubscribe, subscriberId, context);
break;
case SubscriptionRequest.RequestTypeOneofCase.CatchUp:
await HandleCatchUpAsync(request.CatchUp, subscriberId, responseStream, context);
break;
case SubscriptionRequest.RequestTypeOneofCase.Acknowledge:
await HandleAcknowledgeAsync(request.Acknowledge, subscriberId, context);
break;
case SubscriptionRequest.RequestTypeOneofCase.Nack:
await HandleNackAsync(request.Nack, subscriberId, context);
break;
default:
_logger.LogWarning("Unknown request type from {SubscriberId}", subscriberId);
break;
}
}
private async Task HandleSubscribeAsync(
SubscribeCommand command,
string subscriberId,
IServerStreamWriter<EventMessage> responseStream,
ServerCallContext context)
{
_logger.LogInformation(
"Creating persistent subscription {SubscriptionId} for {SubscriberId} on correlation {CorrelationId}",
command.SubscriptionId,
subscriberId,
command.CorrelationId);
try
{
// Create persistent subscription using Phase 8 subscription manager
var subscription = await _subscriptionManager.CreateSubscriptionAsync(
subscriberId: subscriberId,
correlationId: command.CorrelationId,
eventTypes: command.EventTypes.ToHashSet(),
terminalEventTypes: command.TerminalEventTypes.ToHashSet(),
deliveryMode: MapDeliveryMode(command.DeliveryMode),
expiresAt: command.TimeoutSeconds > 0
? DateTimeOffset.UtcNow.AddSeconds(command.TimeoutSeconds)
: null,
cancellationToken: context.CancellationToken);
_logger.LogInformation(
"Persistent subscription {SubscriptionId} created successfully",
subscription.Id);
// Immediately trigger catch-up for any missed events
await DeliverPendingEventsAsync(subscription.Id, responseStream, context.CancellationToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create subscription {SubscriptionId}", command.SubscriptionId);
await responseStream.WriteAsync(new EventMessage
{
Error = new ErrorMessage
{
Code = "subscription_failed",
Message = ex.Message,
SubscriptionId = command.SubscriptionId
}
});
}
}
private async Task HandleUnsubscribeAsync(
UnsubscribeCommand command,
string subscriberId,
ServerCallContext context)
{
_logger.LogInformation(
"Cancelling persistent subscription {SubscriptionId} for {SubscriberId}",
command.SubscriptionId,
subscriberId);
await _subscriptionManager.CancelSubscriptionAsync(command.SubscriptionId, context.CancellationToken);
}
private async Task HandleCatchUpAsync(
CatchUpCommand command,
string subscriberId,
IServerStreamWriter<EventMessage> responseStream,
ServerCallContext context)
{
_logger.LogInformation(
"Processing catch-up for {SubscriberId} with {Count} subscriptions",
subscriberId,
command.SubscriptionIds.Count);
foreach (var subscriptionId in command.SubscriptionIds)
{
try
{
var deliveredCount = await _deliveryService.CatchUpSubscriptionAsync(
subscriptionId,
context.CancellationToken);
_logger.LogInformation(
"Delivered {Count} missed events to subscription {SubscriptionId}",
deliveredCount,
subscriptionId);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to catch up subscription {SubscriptionId}", subscriptionId);
await responseStream.WriteAsync(new EventMessage
{
Error = new ErrorMessage
{
Code = "catchup_failed",
Message = ex.Message,
SubscriptionId = subscriptionId
}
});
}
}
}
private Task HandleAcknowledgeAsync(
AcknowledgeCommand command,
string subscriberId,
ServerCallContext context)
{
_logger.LogDebug(
"Acknowledgment received from {SubscriberId} for event {EventId} in subscription {SubscriptionId}",
subscriberId,
command.EventId,
command.SubscriptionId);
// Phase 8: Acknowledgment is implicit via MarkDelivered in the delivery service
// Future: Could add explicit ack tracking for read receipts
return Task.CompletedTask;
}
private Task HandleNackAsync(
NackCommand command,
string subscriberId,
ServerCallContext context)
{
_logger.LogWarning(
"Negative acknowledgment received from {SubscriberId} for event {EventId} in subscription {SubscriptionId} (requeue: {Requeue})",
subscriberId,
command.EventId,
command.SubscriptionId,
command.Requeue);
// Phase 8: NACK not yet implemented (would require replay capability)
// Future: Implement requeue or dead-letter logic
return Task.CompletedTask;
}
private async Task DeliverPendingEventsAsync(
string subscriptionId,
IServerStreamWriter<EventMessage> responseStream,
System.Threading.CancellationToken cancellationToken)
{
try
{
// Get pending events for this subscription
var pendingEvents = await _deliveryService.GetPendingEventsAsync(
subscriptionId,
limit: 100,
cancellationToken: cancellationToken);
if (pendingEvents.Count == 0)
{
_logger.LogDebug("No pending events for subscription {SubscriptionId}", subscriptionId);
return;
}
_logger.LogInformation(
"Delivering {Count} pending events to subscription {SubscriptionId}",
pendingEvents.Count,
subscriptionId);
var subscription = await _subscriptionStore.GetByIdAsync(subscriptionId, cancellationToken);
if (subscription == null)
{
_logger.LogWarning("Subscription {SubscriptionId} not found during delivery", subscriptionId);
return;
}
foreach (var @event in pendingEvents)
{
var eventTypeName = @event.GetType().Name;
var isTerminal = subscription.IsTerminalEvent(eventTypeName);
await responseStream.WriteAsync(new EventMessage
{
Event = new EventDelivery
{
SubscriptionId = subscriptionId,
CorrelationId = @event.CorrelationId,
EventType = eventTypeName,
EventId = @event.EventId,
Sequence = 0, // TODO: Get sequence from event store
OccurredAt = Timestamp.FromDateTimeOffset(@event.OccurredAt),
// Note: EventData would be set here when source generator adds event types
Placeholder = new PlaceholderEvent { Data = $"Event: {eventTypeName}" }
}
}, cancellationToken);
// If terminal event, send completion message
if (isTerminal)
{
await responseStream.WriteAsync(new EventMessage
{
Completed = new SubscriptionCompleted
{
SubscriptionId = subscriptionId,
Reason = "terminal_event",
TerminalEventType = eventTypeName
}
}, cancellationToken);
_logger.LogInformation(
"Terminal event {EventType} delivered, subscription {SubscriptionId} completed",
eventTypeName,
subscriptionId);
break;
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error delivering pending events to subscription {SubscriptionId}", subscriptionId);
throw;
}
}
private static Abstractions.Subscriptions.DeliveryMode MapDeliveryMode(Grpc.DeliveryMode mode) => mode switch
{
Grpc.DeliveryMode.Immediate => Abstractions.Subscriptions.DeliveryMode.Immediate,
Grpc.DeliveryMode.Batched => Abstractions.Subscriptions.DeliveryMode.Batched,
Grpc.DeliveryMode.OnReconnect => Abstractions.Subscriptions.DeliveryMode.OnReconnect,
_ => Abstractions.Subscriptions.DeliveryMode.Immediate
};
/// <summary>
/// Get the number of active gRPC streams.
/// </summary>
public static int GetActiveStreamCount() => _activeStreams.Count;
/// <summary>
/// Notify all active gRPC subscribers about a new event (push-based delivery).
/// This is called by the PersistentSubscriptionDeliveryDecorator when events are emitted.
/// </summary>
public static async Task NotifySubscribersAsync(
string correlationId,
ICorrelatedEvent @event,
long sequence,
IPersistentSubscriptionStore subscriptionStore)
{
// Find all active persistent subscriptions for this correlation
var subscriptions = await subscriptionStore.GetByCorrelationIdAsync(correlationId);
foreach (var subscription in subscriptions)
{
if (!subscription.CanReceiveEvents)
continue;
var eventTypeName = @event.GetType().Name;
// Check if subscription is interested in this event type
if (!subscription.ShouldDeliverEventType(eventTypeName))
continue;
// Skip if delivery mode is OnReconnect (only deliver on catch-up)
if (subscription.DeliveryMode == Abstractions.Subscriptions.DeliveryMode.OnReconnect)
continue;
// Get the active gRPC stream for this subscriber
if (_activeStreams.TryGetValue(subscription.SubscriberId, out var stream))
{
try
{
var isTerminal = subscription.IsTerminalEvent(eventTypeName);
await stream.WriteAsync(new EventMessage
{
Event = new EventDelivery
{
SubscriptionId = subscription.Id,
CorrelationId = correlationId,
EventType = eventTypeName,
EventId = @event.EventId,
Sequence = sequence,
OccurredAt = Timestamp.FromDateTimeOffset(@event.OccurredAt),
Placeholder = new PlaceholderEvent { Data = $"Event: {eventTypeName}" }
}
});
// Mark as delivered
subscription.MarkDelivered(sequence);
// If terminal event, complete subscription and notify client
if (isTerminal)
{
subscription.Complete();
await stream.WriteAsync(new EventMessage
{
Completed = new SubscriptionCompleted
{
SubscriptionId = subscription.Id,
Reason = "terminal_event",
TerminalEventType = eventTypeName
}
});
}
// Update subscription in store
await subscriptionStore.UpdateAsync(subscription);
}
catch (Exception)
{
// Stream might be closed, will be cleaned up on disconnect
// Don't throw - other subscriptions should still receive events
}
}
}
}
}