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; /// /// gRPC service implementation for Phase 8 persistent subscriptions via bidirectional streaming. /// /// /// This service provides the same functionality as SignalR hubs but using gRPC protocol, /// making it suitable for non-browser clients (mobile apps, services, etc.). /// public sealed class EventServiceImpl : EventService.EventServiceBase { private readonly ISubscriptionManager _subscriptionManager; private readonly IPersistentSubscriptionDeliveryService _deliveryService; private readonly IPersistentSubscriptionStore _subscriptionStore; private readonly ILogger _logger; // Track active gRPC streams by subscriber ID private static readonly ConcurrentDictionary> _activeStreams = new(); public EventServiceImpl( ISubscriptionManager subscriptionManager, IPersistentSubscriptionDeliveryService deliveryService, IPersistentSubscriptionStore subscriptionStore, ILogger 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 requestStream, IServerStreamWriter 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 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 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 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 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 }; /// /// Get the number of active gRPC streams. /// public static int GetActiveStreamCount() => _activeStreams.Count; /// /// Notify all active gRPC subscribers about a new event (push-based delivery). /// This is called by the PersistentSubscriptionDeliveryDecorator when events are emitted. /// 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 } } } } }