58 lines
2.5 KiB
C#
58 lines
2.5 KiB
C#
using System.Collections.Concurrent;
|
|
using System.Threading.Channels;
|
|
|
|
namespace w4c_workflows.Services;
|
|
|
|
/// <summary>
|
|
/// Server-push hub for tenant-scoped realtime events (SSE), mirroring w4c-webapi's hub. A connected
|
|
/// client gets its own bounded channel; <see cref="Publish"/> writes a tiny notification into every
|
|
/// channel for the tenant. The client decides what to re-fetch, keeping broadcasts cheap. The channel
|
|
/// is bounded and drops the oldest notification when a client cannot keep up, so one stalled SSE
|
|
/// consumer cannot grow server memory without limit (the next notification makes it re-fetch anyway).
|
|
///
|
|
/// Used to make the frontend an optional view: when a workflow file is written server-side (e.g. by
|
|
/// the AI transform), every subscribed client in the tenant is notified and reloads the file.
|
|
/// </summary>
|
|
public sealed class RealtimeEventHub
|
|
{
|
|
/// <summary>Per-client notification backlog ceiling.</summary>
|
|
private const int ClientChannelCapacity = 256;
|
|
|
|
private readonly ConcurrentDictionary<string, ConcurrentDictionary<Guid, Channel<RealtimeEvent>>> _clients = new();
|
|
|
|
public (Guid Id, ChannelReader<RealtimeEvent> Reader) AddClient(string tenantId)
|
|
{
|
|
var channel = Channel.CreateBounded<RealtimeEvent>(new BoundedChannelOptions(ClientChannelCapacity)
|
|
{
|
|
SingleReader = true,
|
|
SingleWriter = false,
|
|
FullMode = BoundedChannelFullMode.DropOldest,
|
|
});
|
|
var id = Guid.NewGuid();
|
|
var clients = _clients.GetOrAdd(tenantId, _ => new ConcurrentDictionary<Guid, Channel<RealtimeEvent>>());
|
|
clients[id] = channel;
|
|
return (id, channel.Reader);
|
|
}
|
|
|
|
public void RemoveClient(string tenantId, Guid id)
|
|
{
|
|
if (!_clients.TryGetValue(tenantId, out var clients)) return;
|
|
clients.TryRemove(id, out _);
|
|
if (clients.IsEmpty) _clients.TryRemove(tenantId, out _);
|
|
}
|
|
|
|
public void Publish(string tenantId, string kind, string id, DateTime at)
|
|
{
|
|
if (!_clients.TryGetValue(tenantId, out var clients)) return;
|
|
var evt = new RealtimeEvent(kind, id, at);
|
|
foreach (var channel in clients.Values)
|
|
{
|
|
channel.Writer.TryWrite(evt);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>A tenant-scoped change notification: <c>Kind</c> is a component name (e.g. "workflow"),
|
|
/// <c>Id</c> is that component's server id/path, <c>At</c> is the server-side write time.</summary>
|
|
public record RealtimeEvent(string Kind, string Id, DateTime At);
|