443 lines
17 KiB
C#
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
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|