using System.Collections.Concurrent; using Grpc.Core; using Microsoft.Extensions.Logging; namespace Svrnty.CQRS.Notifications.Grpc; /// /// Manages gRPC stream subscriptions for notifications. /// Thread-safe singleton that tracks subscriptions and routes notifications to subscribers. /// public class NotificationSubscriptionManager { private readonly ConcurrentDictionary<(string TypeName, string Key), ConcurrentBag> _subscriptions = new(); private readonly ILogger _logger; public NotificationSubscriptionManager(ILogger logger) { _logger = logger; } /// /// Subscribe to notifications of a specific domain type with a mapper to convert to proto format. /// /// The domain notification type. /// The proto message type. /// The subscription key value (e.g., inventory ID). /// The gRPC server stream writer. /// Function to map domain notification to proto message. /// A disposable that removes the subscription when disposed. public IDisposable Subscribe( object subscriptionKey, IServerStreamWriter stream, Func mapper) where TDomain : class { var key = (typeof(TDomain).FullName!, subscriptionKey.ToString()!); var subscriber = new Subscriber(stream, mapper); var bag = _subscriptions.GetOrAdd(key, _ => new ConcurrentBag()); bag.Add(subscriber); _logger.LogInformation( "Client subscribed to {NotificationType} with key {SubscriptionKey}. Total subscribers: {Count}", typeof(TDomain).Name, subscriptionKey, bag.Count); return new SubscriptionHandle(() => Remove(key, subscriber)); } /// /// Notify all subscribers of a specific notification type and subscription key. /// internal async Task NotifyAsync(TDomain notification, object subscriptionKey, CancellationToken ct) where TDomain : class { var key = (typeof(TDomain).FullName!, subscriptionKey.ToString()!); if (!_subscriptions.TryGetValue(key, out var subscribers)) { _logger.LogDebug( "No subscribers for {NotificationType} with key {SubscriptionKey}", typeof(TDomain).Name, subscriptionKey); return; } var deadSubscribers = new List(); foreach (var sub in subscribers) { if (sub is INotifiable notifiable) { try { await notifiable.NotifyAsync(notification, ct); } catch (Exception ex) { _logger.LogWarning(ex, "Failed to notify subscriber for {NotificationType}, marking for removal", typeof(TDomain).Name); deadSubscribers.Add(sub); } } } // Clean up dead subscribers foreach (var dead in deadSubscribers) { Remove(key, dead); } _logger.LogDebug( "Notified {Count} subscribers for {NotificationType} with key {SubscriptionKey}", subscribers.Count - deadSubscribers.Count, typeof(TDomain).Name, subscriptionKey); } private void Remove((string TypeName, string Key) key, object subscriber) { if (_subscriptions.TryGetValue(key, out var bag)) { // ConcurrentBag doesn't support removal, so we rebuild var remaining = bag.Where(s => !ReferenceEquals(s, subscriber)).ToList(); if (remaining.Count == 0) { _subscriptions.TryRemove(key, out _); } else { var newBag = new ConcurrentBag(remaining); _subscriptions.TryUpdate(key, newBag, bag); } _logger.LogInformation( "Client unsubscribed from {NotificationType} with key {SubscriptionKey}", key.TypeName.Split('.').Last(), key.Key); } } } /// /// Internal interface for type-erased notification delivery. /// internal interface INotifiable { Task NotifyAsync(TDomain notification, CancellationToken ct); } /// /// Wraps a gRPC stream writer with a domain→proto mapper. /// internal sealed class Subscriber : INotifiable { private readonly IServerStreamWriter _stream; private readonly Func _mapper; public Subscriber(IServerStreamWriter stream, Func mapper) { _stream = stream; _mapper = mapper; } public async Task NotifyAsync(TDomain notification, CancellationToken ct) { var proto = _mapper(notification); await _stream.WriteAsync(proto, ct); } } /// /// Handle that removes a subscription when disposed. /// internal sealed class SubscriptionHandle : IDisposable { private readonly Action _onDispose; private bool _disposed; public SubscriptionHandle(Action onDispose) { _onDispose = onDispose; } public void Dispose() { if (_disposed) return; _disposed = true; _onDispose(); } }