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();
}
}