feat: complete gRPC streaming channel — site host, docker config, docs, integration tests

Switch site host to WebApplicationBuilder with Kestrel HTTP/2 gRPC server,
add GrpcPort/keepalive config, wire SiteStreamManager as ISiteStreamSubscriber,
expose gRPC ports in docker-compose, add site seed script, update all 10
requirement docs + CLAUDE.md + README.md for the new dual-transport architecture.
This commit is contained in:
Joseph Doherty
2026-03-21 12:38:33 -04:00
parent 3fe3c4161b
commit 416a03b782
34 changed files with 728 additions and 156 deletions

View File

@@ -33,6 +33,18 @@ public class CommunicationOptions
/// </summary>
public List<string> CentralContactPoints { get; set; } = new();
/// <summary>gRPC keepalive ping interval for streaming connections.</summary>
public TimeSpan GrpcKeepAlivePingDelay { get; set; } = TimeSpan.FromSeconds(15);
/// <summary>gRPC keepalive ping timeout — stream is considered dead if no response within this period.</summary>
public TimeSpan GrpcKeepAlivePingTimeout { get; set; } = TimeSpan.FromSeconds(10);
/// <summary>Maximum lifetime for a single gRPC stream before the server forces re-establishment.</summary>
public TimeSpan GrpcMaxStreamLifetime { get; set; } = TimeSpan.FromHours(4);
/// <summary>Maximum number of concurrent gRPC streaming subscriptions per site node.</summary>
public int GrpcMaxConcurrentStreams { get; set; } = 100;
/// <summary>Akka.Remote transport heartbeat interval.</summary>
public TimeSpan TransportHeartbeatInterval { get; set; } = TimeSpan.FromSeconds(5);

View File

@@ -15,7 +15,7 @@ namespace ScadaLink.Communication.Grpc;
public class SiteStreamGrpcServer : SiteStreamService.SiteStreamServiceBase
{
private readonly ISiteStreamSubscriber _streamSubscriber;
private readonly ActorSystem _actorSystem;
private ActorSystem? _actorSystem;
private readonly ILogger<SiteStreamGrpcServer> _logger;
private readonly ConcurrentDictionary<string, StreamEntry> _activeStreams = new();
private readonly int _maxConcurrentStreams;
@@ -24,21 +24,25 @@ public class SiteStreamGrpcServer : SiteStreamService.SiteStreamServiceBase
public SiteStreamGrpcServer(
ISiteStreamSubscriber streamSubscriber,
ActorSystem actorSystem,
ILogger<SiteStreamGrpcServer> logger,
int maxConcurrentStreams = 100)
{
_streamSubscriber = streamSubscriber;
_actorSystem = actorSystem;
_logger = logger;
_maxConcurrentStreams = maxConcurrentStreams;
}
/// <summary>
/// Marks the server as ready to accept subscriptions.
/// Called after the site runtime is fully initialized.
/// Marks the server as ready to accept subscriptions and injects the ActorSystem.
/// Called after the site runtime actor system is fully initialized.
/// The ActorSystem is set here rather than via the constructor so that
/// the gRPC server can be created by DI before the actor system exists.
/// </summary>
public void SetReady() => _ready = true;
public void SetReady(ActorSystem actorSystem)
{
_actorSystem = actorSystem;
_ready = true;
}
/// <summary>
/// Number of currently active streaming subscriptions. Exposed for diagnostics.
@@ -72,7 +76,7 @@ public class SiteStreamGrpcServer : SiteStreamService.SiteStreamServiceBase
new BoundedChannelOptions(1000) { FullMode = BoundedChannelFullMode.DropOldest });
var actorSeq = Interlocked.Increment(ref _actorCounter);
var relayActor = _actorSystem.ActorOf(
var relayActor = _actorSystem!.ActorOf(
Props.Create(typeof(Actors.StreamRelayActor), request.CorrelationId, channel.Writer),
$"stream-relay-{request.CorrelationId}-{actorSeq}");
@@ -96,7 +100,7 @@ public class SiteStreamGrpcServer : SiteStreamService.SiteStreamServiceBase
finally
{
_streamSubscriber.RemoveSubscriber(relayActor);
_actorSystem.Stop(relayActor);
_actorSystem!.Stop(relayActor);
channel.Writer.TryComplete();
// Only remove our own entry -- a replacement stream may have already taken the slot