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:
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -216,7 +216,8 @@ akka {{
|
||||
var storage = _serviceProvider.GetRequiredService<SiteStorageService>();
|
||||
var compilationService = _serviceProvider.GetRequiredService<ScriptCompilationService>();
|
||||
var sharedScriptLibrary = _serviceProvider.GetRequiredService<SharedScriptLibrary>();
|
||||
var streamManager = _serviceProvider.GetService<SiteStreamManager>();
|
||||
var streamManager = _serviceProvider.GetRequiredService<SiteStreamManager>();
|
||||
streamManager.Initialize(_actorSystem!);
|
||||
var siteRuntimeOptionsValue = _serviceProvider.GetService<IOptions<SiteRuntimeOptions>>()?.Value
|
||||
?? new SiteRuntimeOptions();
|
||||
var dmLogger = _serviceProvider.GetRequiredService<ILoggerFactory>()
|
||||
@@ -325,5 +326,9 @@ akka {{
|
||||
"Created ClusterClient to central with {Count} contact point(s) for site {SiteId}",
|
||||
contacts.Count, _nodeOptions.SiteId);
|
||||
}
|
||||
|
||||
// Gate gRPC subscriptions until the actor system and SiteStreamManager are initialized
|
||||
var grpcServer = _serviceProvider.GetService<ScadaLink.Communication.Grpc.SiteStreamGrpcServer>();
|
||||
grpcServer?.SetReady(_actorSystem!);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,22 +156,40 @@ try
|
||||
}
|
||||
else if (nodeRole.Equals("Site", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var builder = Microsoft.Extensions.Hosting.Host.CreateDefaultBuilder(args);
|
||||
builder.ConfigureAppConfiguration(config => config.AddConfiguration(configuration));
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
builder.Configuration.AddConfiguration(configuration);
|
||||
|
||||
// WP-14: Serilog
|
||||
builder.UseSerilog();
|
||||
builder.Host.UseSerilog();
|
||||
|
||||
// WP-17: Windows Service support (no-op when not running as a Windows Service)
|
||||
builder.UseWindowsService();
|
||||
builder.Host.UseWindowsService();
|
||||
|
||||
builder.ConfigureServices((context, services) =>
|
||||
// Read GrpcPort from config (NodeOptions already has default 8083)
|
||||
var grpcPort = configuration.GetValue<int>("ScadaLink:Node:GrpcPort", 8083);
|
||||
|
||||
// Configure Kestrel for HTTP/2 only on the gRPC port
|
||||
builder.WebHost.ConfigureKestrel(options =>
|
||||
{
|
||||
SiteServiceRegistration.Configure(services, context.Configuration);
|
||||
options.ListenAnyIP(grpcPort, listenOptions =>
|
||||
{
|
||||
listenOptions.Protocols = Microsoft.AspNetCore.Server.Kestrel.Core.HttpProtocols.Http2;
|
||||
});
|
||||
});
|
||||
|
||||
var host = builder.Build();
|
||||
await host.RunAsync();
|
||||
// gRPC server registration
|
||||
builder.Services.AddGrpc();
|
||||
builder.Services.AddSingleton<ScadaLink.Communication.Grpc.SiteStreamGrpcServer>();
|
||||
|
||||
// Existing site service registrations
|
||||
SiteServiceRegistration.Configure(builder.Services, builder.Configuration);
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Map gRPC service — resolves the singleton SiteStreamGrpcServer from DI
|
||||
app.MapGrpcService<ScadaLink.Communication.Grpc.SiteStreamGrpcServer>();
|
||||
|
||||
await app.RunAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Grpc.AspNetCore" Version="2.71.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="10.0.5" />
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../ScadaLink.Commons/ScadaLink.Commons.csproj" />
|
||||
<ProjectReference Include="../ScadaLink.Communication/ScadaLink.Communication.csproj" />
|
||||
<ProjectReference Include="../ScadaLink.HealthMonitoring/ScadaLink.HealthMonitoring.csproj" />
|
||||
<ProjectReference Include="../ScadaLink.StoreAndForward/ScadaLink.StoreAndForward.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ScadaLink.Communication.Grpc;
|
||||
using ScadaLink.Commons.Interfaces.Repositories;
|
||||
using ScadaLink.SiteRuntime.Persistence;
|
||||
using ScadaLink.SiteRuntime.Repositories;
|
||||
using ScadaLink.SiteRuntime.Scripts;
|
||||
using ScadaLink.SiteRuntime.Streaming;
|
||||
|
||||
namespace ScadaLink.SiteRuntime;
|
||||
|
||||
@@ -40,6 +43,17 @@ public static class ServiceCollectionExtensions
|
||||
// WP-17: Shared script library
|
||||
services.AddSingleton<SharedScriptLibrary>();
|
||||
|
||||
// WP-23: Site stream manager — registered as singleton and exposed as ISiteStreamSubscriber
|
||||
// so the gRPC server can subscribe relay actors to instance events.
|
||||
// ActorSystem is injected later via Initialize() after AkkaHostedService starts.
|
||||
services.AddSingleton(sp =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptions<SiteRuntimeOptions>>().Value;
|
||||
var logger = sp.GetRequiredService<ILogger<SiteStreamManager>>();
|
||||
return new SiteStreamManager(options, logger);
|
||||
});
|
||||
services.AddSingleton<ISiteStreamSubscriber>(sp => sp.GetRequiredService<SiteStreamManager>());
|
||||
|
||||
// Site-local repository implementations backed by SQLite
|
||||
services.AddScoped<IExternalSystemRepository, SiteExternalSystemRepository>();
|
||||
services.AddScoped<INotificationRepository, SiteNotificationRepository>();
|
||||
|
||||
@@ -3,6 +3,7 @@ using Akka.Actor;
|
||||
using Akka.Streams;
|
||||
using Akka.Streams.Dsl;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ScadaLink.Communication.Grpc;
|
||||
using ScadaLink.Commons.Messages.Streaming;
|
||||
|
||||
namespace ScadaLink.SiteRuntime.Streaming;
|
||||
@@ -13,10 +14,12 @@ namespace ScadaLink.SiteRuntime.Streaming;
|
||||
/// Subscribers get per-subscriber bounded buffers with drop-oldest overflow.
|
||||
///
|
||||
/// Filterable by instance name for debug view (WP-25).
|
||||
/// Implements ISiteStreamSubscriber so the gRPC server can subscribe actors
|
||||
/// to instance events without referencing SiteRuntime directly.
|
||||
/// </summary>
|
||||
public class SiteStreamManager
|
||||
public class SiteStreamManager : ISiteStreamSubscriber
|
||||
{
|
||||
private readonly ActorSystem _system;
|
||||
private ActorSystem? _system;
|
||||
private readonly int _bufferSize;
|
||||
private readonly ILogger<SiteStreamManager> _logger;
|
||||
private readonly object _lock = new();
|
||||
@@ -25,20 +28,21 @@ public class SiteStreamManager
|
||||
private readonly Dictionary<string, SubscriptionInfo> _subscriptions = new();
|
||||
|
||||
public SiteStreamManager(
|
||||
ActorSystem system,
|
||||
SiteRuntimeOptions options,
|
||||
ILogger<SiteStreamManager> logger)
|
||||
{
|
||||
_system = system;
|
||||
_bufferSize = options.StreamBufferSize;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the stream source. Must be called after ActorSystem is ready.
|
||||
/// The ActorSystem is passed here rather than via the constructor so that
|
||||
/// SiteStreamManager can be created by DI before the actor system exists.
|
||||
/// </summary>
|
||||
public void Initialize()
|
||||
public void Initialize(ActorSystem system)
|
||||
{
|
||||
_system = system;
|
||||
var materializer = _system.Materializer();
|
||||
|
||||
var source = Source.ActorRef<ISiteStreamEvent>(
|
||||
|
||||
Reference in New Issue
Block a user