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:
@@ -24,7 +24,7 @@ public class SiteStreamGrpcServerTests : TestKit
|
||||
|
||||
private SiteStreamGrpcServer CreateServer(int maxStreams = 100)
|
||||
{
|
||||
return new SiteStreamGrpcServer(_subscriber, Sys, _logger, maxStreams);
|
||||
return new SiteStreamGrpcServer(_subscriber, _logger, maxStreams);
|
||||
}
|
||||
|
||||
private static InstanceStreamRequest MakeRequest(string correlationId = "corr-1", string instance = "Site1.Pump01")
|
||||
@@ -55,7 +55,7 @@ public class SiteStreamGrpcServerTests : TestKit
|
||||
public async Task RejectsWhenMaxStreamsReached()
|
||||
{
|
||||
var server = CreateServer(maxStreams: 1);
|
||||
server.SetReady();
|
||||
server.SetReady(Sys);
|
||||
|
||||
// Start one stream that blocks
|
||||
var cts1 = new CancellationTokenSource();
|
||||
@@ -86,7 +86,7 @@ public class SiteStreamGrpcServerTests : TestKit
|
||||
public async Task CancelsDuplicateCorrelationId()
|
||||
{
|
||||
var server = CreateServer();
|
||||
server.SetReady();
|
||||
server.SetReady(Sys);
|
||||
|
||||
var cts1 = new CancellationTokenSource();
|
||||
var context1 = CreateMockContext(cts1.Token);
|
||||
@@ -121,7 +121,7 @@ public class SiteStreamGrpcServerTests : TestKit
|
||||
public async Task CleansUpOnCancellation()
|
||||
{
|
||||
var server = CreateServer();
|
||||
server.SetReady();
|
||||
server.SetReady(Sys);
|
||||
|
||||
var cts = new CancellationTokenSource();
|
||||
var context = CreateMockContext(cts.Token);
|
||||
@@ -142,7 +142,7 @@ public class SiteStreamGrpcServerTests : TestKit
|
||||
public async Task SubscribesAndRemovesFromStreamManager()
|
||||
{
|
||||
var server = CreateServer();
|
||||
server.SetReady();
|
||||
server.SetReady(Sys);
|
||||
|
||||
var cts = new CancellationTokenSource();
|
||||
var context = CreateMockContext(cts.Token);
|
||||
@@ -167,7 +167,7 @@ public class SiteStreamGrpcServerTests : TestKit
|
||||
public async Task WritesEventsToResponseStream()
|
||||
{
|
||||
var server = CreateServer();
|
||||
server.SetReady();
|
||||
server.SetReady(Sys);
|
||||
|
||||
// Capture the relay actor so we can send it events
|
||||
IActorRef? capturedActor = null;
|
||||
@@ -212,7 +212,7 @@ public class SiteStreamGrpcServerTests : TestKit
|
||||
{
|
||||
var server = CreateServer();
|
||||
// Initially not ready -- just verify the property works
|
||||
server.SetReady();
|
||||
server.SetReady(Sys);
|
||||
// No assertion needed -- the other tests verify that SetReady enables streaming
|
||||
Assert.Equal(0, server.ActiveStreamCount);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -21,10 +22,12 @@ using ScadaLink.ManagementService;
|
||||
using ScadaLink.NotificationService;
|
||||
using ScadaLink.Security;
|
||||
using ScadaLink.SiteEventLogging;
|
||||
using ScadaLink.Communication.Grpc;
|
||||
using ScadaLink.SiteRuntime;
|
||||
using ScadaLink.SiteRuntime.Persistence;
|
||||
using ScadaLink.SiteRuntime.Repositories;
|
||||
using ScadaLink.SiteRuntime.Scripts;
|
||||
using ScadaLink.SiteRuntime.Streaming;
|
||||
using ScadaLink.StoreAndForward;
|
||||
using ScadaLink.TemplateEngine;
|
||||
using ScadaLink.TemplateEngine.Flattening;
|
||||
@@ -274,45 +277,45 @@ public class CentralCompositionRootTests : IDisposable
|
||||
/// <summary>
|
||||
/// Verifies every expected DI service resolves from the Site composition root.
|
||||
/// Uses the extracted SiteServiceRegistration.Configure() so the test always
|
||||
/// matches the real Program.cs registration.
|
||||
/// matches the real Program.cs registration (WebApplicationBuilder + gRPC).
|
||||
/// </summary>
|
||||
public class SiteCompositionRootTests : IDisposable
|
||||
{
|
||||
private readonly IHost _host;
|
||||
private readonly WebApplication _host;
|
||||
private readonly string _tempDbPath;
|
||||
|
||||
public SiteCompositionRootTests()
|
||||
{
|
||||
_tempDbPath = Path.Combine(Path.GetTempPath(), $"scadalink_test_{Guid.NewGuid()}.db");
|
||||
|
||||
var builder = Microsoft.Extensions.Hosting.Host.CreateDefaultBuilder();
|
||||
builder.ConfigureAppConfiguration(config =>
|
||||
var builder = WebApplication.CreateBuilder();
|
||||
builder.Configuration.Sources.Clear();
|
||||
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
config.Sources.Clear();
|
||||
config.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["ScadaLink:Node:Role"] = "Site",
|
||||
["ScadaLink:Node:NodeHostname"] = "test-site",
|
||||
["ScadaLink:Node:SiteId"] = "TestSite",
|
||||
["ScadaLink:Node:RemotingPort"] = "0",
|
||||
["ScadaLink:Database:SiteDbPath"] = _tempDbPath,
|
||||
});
|
||||
["ScadaLink:Node:Role"] = "Site",
|
||||
["ScadaLink:Node:NodeHostname"] = "test-site",
|
||||
["ScadaLink:Node:SiteId"] = "TestSite",
|
||||
["ScadaLink:Node:RemotingPort"] = "0",
|
||||
["ScadaLink:Node:GrpcPort"] = "0",
|
||||
["ScadaLink:Database:SiteDbPath"] = _tempDbPath,
|
||||
});
|
||||
builder.ConfigureServices((context, services) =>
|
||||
{
|
||||
SiteServiceRegistration.Configure(services, context.Configuration);
|
||||
|
||||
// Keep AkkaHostedService in DI (other services depend on it)
|
||||
// but prevent it from starting by removing only its IHostedService registration.
|
||||
AkkaHostedServiceRemover.RemoveAkkaHostedServiceOnly(services);
|
||||
});
|
||||
// gRPC server registration (mirrors Program.cs site section)
|
||||
builder.Services.AddGrpc();
|
||||
builder.Services.AddSingleton<ScadaLink.Communication.Grpc.SiteStreamGrpcServer>();
|
||||
|
||||
SiteServiceRegistration.Configure(builder.Services, builder.Configuration);
|
||||
|
||||
// Keep AkkaHostedService in DI (other services depend on it)
|
||||
// but prevent it from starting by removing only its IHostedService registration.
|
||||
AkkaHostedServiceRemover.RemoveAkkaHostedServiceOnly(builder.Services);
|
||||
|
||||
_host = builder.Build();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_host.Dispose();
|
||||
(_host as IDisposable)?.Dispose();
|
||||
try { File.Delete(_tempDbPath); } catch { /* best effort */ }
|
||||
}
|
||||
|
||||
@@ -333,6 +336,9 @@ public class SiteCompositionRootTests : IDisposable
|
||||
new object[] { typeof(SiteStorageService) },
|
||||
new object[] { typeof(ScriptCompilationService) },
|
||||
new object[] { typeof(SharedScriptLibrary) },
|
||||
new object[] { typeof(SiteStreamManager) },
|
||||
new object[] { typeof(ISiteStreamSubscriber) },
|
||||
new object[] { typeof(SiteStreamGrpcServer) },
|
||||
new object[] { typeof(IDataConnectionFactory) },
|
||||
new object[] { typeof(StoreAndForwardStorage) },
|
||||
new object[] { typeof(StoreAndForwardService) },
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using System.Reflection;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
@@ -65,70 +67,74 @@ public class HostStartupTests : IDisposable
|
||||
[Fact]
|
||||
public void SiteRole_StartsWithoutError()
|
||||
{
|
||||
var builder = Microsoft.Extensions.Hosting.Host.CreateDefaultBuilder();
|
||||
builder.ConfigureAppConfiguration(config =>
|
||||
var builder = WebApplication.CreateBuilder();
|
||||
builder.Configuration.Sources.Clear();
|
||||
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
config.Sources.Clear();
|
||||
config.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["ScadaLink:Node:Role"] = "Site",
|
||||
["ScadaLink:Node:NodeHostname"] = "test-site",
|
||||
["ScadaLink:Node:SiteId"] = "TestSite",
|
||||
["ScadaLink:Node:RemotingPort"] = "0",
|
||||
});
|
||||
});
|
||||
builder.ConfigureServices((context, services) =>
|
||||
{
|
||||
SiteServiceRegistration.Configure(services, context.Configuration);
|
||||
["ScadaLink:Node:Role"] = "Site",
|
||||
["ScadaLink:Node:NodeHostname"] = "test-site",
|
||||
["ScadaLink:Node:SiteId"] = "TestSite",
|
||||
["ScadaLink:Node:RemotingPort"] = "0",
|
||||
["ScadaLink:Node:GrpcPort"] = "0",
|
||||
});
|
||||
|
||||
var host = builder.Build();
|
||||
_disposables.Add(host);
|
||||
builder.Services.AddGrpc();
|
||||
builder.Services.AddSingleton<ScadaLink.Communication.Grpc.SiteStreamGrpcServer>();
|
||||
SiteServiceRegistration.Configure(builder.Services, builder.Configuration);
|
||||
|
||||
// Remove AkkaHostedService from running
|
||||
AkkaHostedServiceRemover.RemoveAkkaHostedServiceOnly(builder.Services);
|
||||
|
||||
var app = builder.Build();
|
||||
_disposables.Add(app);
|
||||
|
||||
// Build succeeds = DI container is valid and all services resolve
|
||||
Assert.NotNull(host);
|
||||
Assert.NotNull(host.Services);
|
||||
Assert.NotNull(app);
|
||||
Assert.NotNull(app.Services);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SiteRole_DoesNotConfigureKestrel()
|
||||
public void SiteRole_ConfiguresKestrelForGrpc()
|
||||
{
|
||||
var builder = Microsoft.Extensions.Hosting.Host.CreateDefaultBuilder();
|
||||
builder.ConfigureAppConfiguration(config =>
|
||||
var builder = WebApplication.CreateBuilder();
|
||||
builder.Configuration.Sources.Clear();
|
||||
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
config.Sources.Clear();
|
||||
config.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
["ScadaLink:Node:Role"] = "Site",
|
||||
["ScadaLink:Node:NodeHostname"] = "test-site",
|
||||
["ScadaLink:Node:SiteId"] = "TestSite",
|
||||
["ScadaLink:Node:RemotingPort"] = "0",
|
||||
["ScadaLink:Node:GrpcPort"] = "0",
|
||||
});
|
||||
|
||||
builder.WebHost.ConfigureKestrel(options =>
|
||||
{
|
||||
options.ListenAnyIP(0, listenOptions =>
|
||||
{
|
||||
["ScadaLink:Node:Role"] = "Site",
|
||||
["ScadaLink:Node:NodeHostname"] = "test-site",
|
||||
["ScadaLink:Node:SiteId"] = "TestSite",
|
||||
["ScadaLink:Node:RemotingPort"] = "0",
|
||||
listenOptions.Protocols = Microsoft.AspNetCore.Server.Kestrel.Core.HttpProtocols.Http2;
|
||||
});
|
||||
});
|
||||
builder.ConfigureServices((context, services) =>
|
||||
{
|
||||
SiteServiceRegistration.Configure(services, context.Configuration);
|
||||
});
|
||||
|
||||
var host = builder.Build();
|
||||
builder.Services.AddGrpc();
|
||||
builder.Services.AddSingleton<ScadaLink.Communication.Grpc.SiteStreamGrpcServer>();
|
||||
SiteServiceRegistration.Configure(builder.Services, builder.Configuration);
|
||||
|
||||
// Verify no Kestrel server or web host is registered.
|
||||
// Host.CreateDefaultBuilder does not add Kestrel, so there should be no IServer.
|
||||
// Remove AkkaHostedService from running
|
||||
AkkaHostedServiceRemover.RemoveAkkaHostedServiceOnly(builder.Services);
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Verify Kestrel IS configured (site now hosts gRPC via WebApplicationBuilder)
|
||||
var serverType = Type.GetType(
|
||||
"Microsoft.AspNetCore.Hosting.Server.IServer, Microsoft.AspNetCore.Hosting.Server.Abstractions");
|
||||
|
||||
if (serverType != null)
|
||||
{
|
||||
var server = host.Services.GetService(serverType);
|
||||
Assert.Null(server);
|
||||
var server = app.Services.GetService(serverType);
|
||||
Assert.NotNull(server);
|
||||
}
|
||||
|
||||
// Additionally verify no HTTP URLs are configured
|
||||
var config = host.Services.GetRequiredService<IConfiguration>();
|
||||
var urls = config["urls"] ?? config["ASPNETCORE_URLS"];
|
||||
Assert.Null(urls);
|
||||
|
||||
host.Dispose();
|
||||
(app as IDisposable)?.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
using System.Threading.Channels;
|
||||
using Akka.Actor;
|
||||
using Akka.TestKit.Xunit2;
|
||||
using Google.Protobuf.WellKnownTypes;
|
||||
using Grpc.Core;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NSubstitute;
|
||||
using ScadaLink.Commons.Messages.Streaming;
|
||||
using ScadaLink.Communication.Actors;
|
||||
using ScadaLink.Communication.Grpc;
|
||||
|
||||
namespace ScadaLink.IntegrationTests.Grpc;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for the gRPC streaming pipeline.
|
||||
/// Tests the full in-process flow: subscribe request -> StreamRelayActor creation ->
|
||||
/// domain events via Akka Tell -> Channel relay -> gRPC response stream writes.
|
||||
///
|
||||
/// These tests exercise the real SiteStreamGrpcServer, StreamRelayActor, and Channel
|
||||
/// wiring together with a real Akka actor system, using only mocked gRPC transport
|
||||
/// (IServerStreamWriter + ServerCallContext).
|
||||
///
|
||||
/// Full end-to-end gRPC-over-HTTP/2 tests are performed manually against the Docker
|
||||
/// cluster (docker/deploy.sh + docker/seed-sites.sh + CLI debug-stream).
|
||||
/// </summary>
|
||||
public class GrpcStreamIntegrationTests : TestKit
|
||||
{
|
||||
/// <summary>
|
||||
/// End-to-end pipeline test: subscribe -> relay actor receives domain events ->
|
||||
/// events flow through Channel to gRPC response stream.
|
||||
/// Validates attribute value changes arrive with correct protobuf mapping.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Pipeline_AttributeValueChanged_FlowsToResponseStream()
|
||||
{
|
||||
// Arrange: capture the relay actor created by the gRPC server
|
||||
IActorRef? relayActor = null;
|
||||
var subscriber = Substitute.For<ISiteStreamSubscriber>();
|
||||
subscriber.Subscribe(Arg.Any<string>(), Arg.Any<IActorRef>())
|
||||
.Returns(ci =>
|
||||
{
|
||||
relayActor = ci.Arg<IActorRef>();
|
||||
return "sub-integ-1";
|
||||
});
|
||||
|
||||
var server = new SiteStreamGrpcServer(
|
||||
subscriber,
|
||||
NullLogger<SiteStreamGrpcServer>.Instance);
|
||||
server.SetReady(Sys);
|
||||
|
||||
var writtenEvents = new List<SiteStreamEvent>();
|
||||
var writer = Substitute.For<IServerStreamWriter<SiteStreamEvent>>();
|
||||
writer.WriteAsync(Arg.Any<SiteStreamEvent>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.CompletedTask)
|
||||
.AndDoes(ci => writtenEvents.Add(ci.Arg<SiteStreamEvent>()));
|
||||
|
||||
var cts = new CancellationTokenSource();
|
||||
var context = CreateMockContext(cts.Token);
|
||||
|
||||
var request = new InstanceStreamRequest
|
||||
{
|
||||
CorrelationId = "integ-attr-1",
|
||||
InstanceUniqueName = "SiteA.Pump01"
|
||||
};
|
||||
|
||||
// Act: start the subscription stream
|
||||
var streamTask = Task.Run(() => server.SubscribeInstance(request, writer, context));
|
||||
|
||||
await WaitForConditionAsync(() => relayActor != null);
|
||||
|
||||
// Send domain events to the relay actor (simulating what SiteStreamManager does)
|
||||
var ts1 = new DateTimeOffset(2026, 3, 21, 14, 0, 0, TimeSpan.Zero);
|
||||
var ts2 = new DateTimeOffset(2026, 3, 21, 14, 0, 1, TimeSpan.Zero);
|
||||
|
||||
relayActor!.Tell(new AttributeValueChanged(
|
||||
"SiteA.Pump01", "Modules.Flow", "CurrentGPM", 125.3, "Good", ts1));
|
||||
relayActor.Tell(new AttributeValueChanged(
|
||||
"SiteA.Pump01", "Modules.Pressure", "CurrentPSI", 48.7, "Uncertain", ts2));
|
||||
|
||||
await WaitForConditionAsync(() => writtenEvents.Count >= 2);
|
||||
|
||||
// Cleanup
|
||||
cts.Cancel();
|
||||
await streamTask;
|
||||
|
||||
// Assert: both events arrived in order with correct protobuf mapping
|
||||
Assert.Equal(2, writtenEvents.Count);
|
||||
|
||||
var evt1 = writtenEvents[0];
|
||||
Assert.Equal("integ-attr-1", evt1.CorrelationId);
|
||||
Assert.Equal(SiteStreamEvent.EventOneofCase.AttributeChanged, evt1.EventCase);
|
||||
Assert.Equal("SiteA.Pump01", evt1.AttributeChanged.InstanceUniqueName);
|
||||
Assert.Equal("Modules.Flow", evt1.AttributeChanged.AttributePath);
|
||||
Assert.Equal("CurrentGPM", evt1.AttributeChanged.AttributeName);
|
||||
Assert.Equal("125.3", evt1.AttributeChanged.Value);
|
||||
Assert.Equal(Quality.Good, evt1.AttributeChanged.Quality);
|
||||
Assert.Equal(Timestamp.FromDateTimeOffset(ts1), evt1.AttributeChanged.Timestamp);
|
||||
|
||||
var evt2 = writtenEvents[1];
|
||||
Assert.Equal("integ-attr-1", evt2.CorrelationId);
|
||||
Assert.Equal("Modules.Pressure", evt2.AttributeChanged.AttributePath);
|
||||
Assert.Equal(Quality.Uncertain, evt2.AttributeChanged.Quality);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end pipeline test for alarm state changes.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Pipeline_AlarmStateChanged_FlowsToResponseStream()
|
||||
{
|
||||
IActorRef? relayActor = null;
|
||||
var subscriber = Substitute.For<ISiteStreamSubscriber>();
|
||||
subscriber.Subscribe(Arg.Any<string>(), Arg.Any<IActorRef>())
|
||||
.Returns(ci =>
|
||||
{
|
||||
relayActor = ci.Arg<IActorRef>();
|
||||
return "sub-integ-2";
|
||||
});
|
||||
|
||||
var server = new SiteStreamGrpcServer(
|
||||
subscriber,
|
||||
NullLogger<SiteStreamGrpcServer>.Instance);
|
||||
server.SetReady(Sys);
|
||||
|
||||
var writtenEvents = new List<SiteStreamEvent>();
|
||||
var writer = Substitute.For<IServerStreamWriter<SiteStreamEvent>>();
|
||||
writer.WriteAsync(Arg.Any<SiteStreamEvent>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.CompletedTask)
|
||||
.AndDoes(ci => writtenEvents.Add(ci.Arg<SiteStreamEvent>()));
|
||||
|
||||
var cts = new CancellationTokenSource();
|
||||
var context = CreateMockContext(cts.Token);
|
||||
|
||||
var request = new InstanceStreamRequest
|
||||
{
|
||||
CorrelationId = "integ-alarm-1",
|
||||
InstanceUniqueName = "SiteA.Pump01"
|
||||
};
|
||||
|
||||
var streamTask = Task.Run(() => server.SubscribeInstance(request, writer, context));
|
||||
await WaitForConditionAsync(() => relayActor != null);
|
||||
|
||||
var ts = new DateTimeOffset(2026, 3, 21, 14, 5, 0, TimeSpan.Zero);
|
||||
relayActor!.Tell(new AlarmStateChanged(
|
||||
"SiteA.Pump01", "HighPressure",
|
||||
Commons.Types.Enums.AlarmState.Active, 3, ts));
|
||||
|
||||
await WaitForConditionAsync(() => writtenEvents.Count >= 1);
|
||||
|
||||
cts.Cancel();
|
||||
await streamTask;
|
||||
|
||||
Assert.Single(writtenEvents);
|
||||
var evt = writtenEvents[0];
|
||||
Assert.Equal("integ-alarm-1", evt.CorrelationId);
|
||||
Assert.Equal(SiteStreamEvent.EventOneofCase.AlarmChanged, evt.EventCase);
|
||||
Assert.Equal("SiteA.Pump01", evt.AlarmChanged.InstanceUniqueName);
|
||||
Assert.Equal("HighPressure", evt.AlarmChanged.AlarmName);
|
||||
Assert.Equal(AlarmStateEnum.AlarmStateActive, evt.AlarmChanged.State);
|
||||
Assert.Equal(3, evt.AlarmChanged.Priority);
|
||||
Assert.Equal(Timestamp.FromDateTimeOffset(ts), evt.AlarmChanged.Timestamp);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that mixed event types (attribute + alarm) flow through the same stream.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Pipeline_MixedEvents_FlowInOrder()
|
||||
{
|
||||
IActorRef? relayActor = null;
|
||||
var subscriber = Substitute.For<ISiteStreamSubscriber>();
|
||||
subscriber.Subscribe(Arg.Any<string>(), Arg.Any<IActorRef>())
|
||||
.Returns(ci =>
|
||||
{
|
||||
relayActor = ci.Arg<IActorRef>();
|
||||
return "sub-integ-3";
|
||||
});
|
||||
|
||||
var server = new SiteStreamGrpcServer(
|
||||
subscriber,
|
||||
NullLogger<SiteStreamGrpcServer>.Instance);
|
||||
server.SetReady(Sys);
|
||||
|
||||
var writtenEvents = new List<SiteStreamEvent>();
|
||||
var writer = Substitute.For<IServerStreamWriter<SiteStreamEvent>>();
|
||||
writer.WriteAsync(Arg.Any<SiteStreamEvent>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.CompletedTask)
|
||||
.AndDoes(ci => writtenEvents.Add(ci.Arg<SiteStreamEvent>()));
|
||||
|
||||
var cts = new CancellationTokenSource();
|
||||
var context = CreateMockContext(cts.Token);
|
||||
|
||||
var request = new InstanceStreamRequest
|
||||
{
|
||||
CorrelationId = "integ-mixed-1",
|
||||
InstanceUniqueName = "SiteB.Motor01"
|
||||
};
|
||||
|
||||
var streamTask = Task.Run(() => server.SubscribeInstance(request, writer, context));
|
||||
await WaitForConditionAsync(() => relayActor != null);
|
||||
|
||||
var ts = DateTimeOffset.UtcNow;
|
||||
|
||||
// Send interleaved attribute and alarm events
|
||||
relayActor!.Tell(new AttributeValueChanged(
|
||||
"SiteB.Motor01", "Speed", "RPM", 1750.0, "Good", ts));
|
||||
relayActor.Tell(new AlarmStateChanged(
|
||||
"SiteB.Motor01", "OverSpeed",
|
||||
Commons.Types.Enums.AlarmState.Active, 4, ts));
|
||||
relayActor.Tell(new AttributeValueChanged(
|
||||
"SiteB.Motor01", "Temperature", "BearingTemp", 85.2, "Good", ts));
|
||||
|
||||
await WaitForConditionAsync(() => writtenEvents.Count >= 3);
|
||||
|
||||
cts.Cancel();
|
||||
await streamTask;
|
||||
|
||||
Assert.Equal(3, writtenEvents.Count);
|
||||
Assert.Equal(SiteStreamEvent.EventOneofCase.AttributeChanged, writtenEvents[0].EventCase);
|
||||
Assert.Equal(SiteStreamEvent.EventOneofCase.AlarmChanged, writtenEvents[1].EventCase);
|
||||
Assert.Equal(SiteStreamEvent.EventOneofCase.AttributeChanged, writtenEvents[2].EventCase);
|
||||
Assert.All(writtenEvents, e => Assert.Equal("integ-mixed-1", e.CorrelationId));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that when a stream is cancelled, the subscriber is cleaned up
|
||||
/// and the active stream count returns to zero.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Pipeline_Cancellation_CleansUpRelayActorAndSubscription()
|
||||
{
|
||||
var subscriber = Substitute.For<ISiteStreamSubscriber>();
|
||||
subscriber.Subscribe(Arg.Any<string>(), Arg.Any<IActorRef>())
|
||||
.Returns("sub-integ-4");
|
||||
|
||||
var server = new SiteStreamGrpcServer(
|
||||
subscriber,
|
||||
NullLogger<SiteStreamGrpcServer>.Instance);
|
||||
server.SetReady(Sys);
|
||||
|
||||
var writer = Substitute.For<IServerStreamWriter<SiteStreamEvent>>();
|
||||
var cts = new CancellationTokenSource();
|
||||
var context = CreateMockContext(cts.Token);
|
||||
|
||||
var request = new InstanceStreamRequest
|
||||
{
|
||||
CorrelationId = "integ-cleanup-1",
|
||||
InstanceUniqueName = "SiteC.Valve01"
|
||||
};
|
||||
|
||||
var streamTask = Task.Run(() => server.SubscribeInstance(request, writer, context));
|
||||
await WaitForConditionAsync(() => server.ActiveStreamCount == 1);
|
||||
|
||||
// Cancel the stream
|
||||
cts.Cancel();
|
||||
await streamTask;
|
||||
|
||||
// Verify cleanup
|
||||
Assert.Equal(0, server.ActiveStreamCount);
|
||||
subscriber.Received(1).Subscribe("SiteC.Valve01", Arg.Any<IActorRef>());
|
||||
subscriber.Received(1).RemoveSubscriber(Arg.Any<IActorRef>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that a duplicate correlation ID cancels the first stream and
|
||||
/// the second stream continues to receive events.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Pipeline_DuplicateCorrelationId_ReplacesStream()
|
||||
{
|
||||
IActorRef? relayActor2 = null;
|
||||
var callCount = 0;
|
||||
var subscriber = Substitute.For<ISiteStreamSubscriber>();
|
||||
subscriber.Subscribe(Arg.Any<string>(), Arg.Any<IActorRef>())
|
||||
.Returns(ci =>
|
||||
{
|
||||
callCount++;
|
||||
if (callCount == 2)
|
||||
relayActor2 = ci.Arg<IActorRef>();
|
||||
return $"sub-dup-{callCount}";
|
||||
});
|
||||
|
||||
var server = new SiteStreamGrpcServer(
|
||||
subscriber,
|
||||
NullLogger<SiteStreamGrpcServer>.Instance);
|
||||
server.SetReady(Sys);
|
||||
|
||||
// First stream
|
||||
var writer1 = Substitute.For<IServerStreamWriter<SiteStreamEvent>>();
|
||||
var cts1 = new CancellationTokenSource();
|
||||
var context1 = CreateMockContext(cts1.Token);
|
||||
|
||||
var stream1Task = Task.Run(() => server.SubscribeInstance(
|
||||
new InstanceStreamRequest { CorrelationId = "integ-dup", InstanceUniqueName = "SiteA.Pump01" },
|
||||
writer1, context1));
|
||||
|
||||
await WaitForConditionAsync(() => server.ActiveStreamCount == 1);
|
||||
|
||||
// Second stream with same correlation ID -- should cancel first
|
||||
var writtenEvents2 = new List<SiteStreamEvent>();
|
||||
var writer2 = Substitute.For<IServerStreamWriter<SiteStreamEvent>>();
|
||||
writer2.WriteAsync(Arg.Any<SiteStreamEvent>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.CompletedTask)
|
||||
.AndDoes(ci => writtenEvents2.Add(ci.Arg<SiteStreamEvent>()));
|
||||
|
||||
var cts2 = new CancellationTokenSource();
|
||||
var context2 = CreateMockContext(cts2.Token);
|
||||
|
||||
var stream2Task = Task.Run(() => server.SubscribeInstance(
|
||||
new InstanceStreamRequest { CorrelationId = "integ-dup", InstanceUniqueName = "SiteA.Pump01" },
|
||||
writer2, context2));
|
||||
|
||||
// First stream should complete
|
||||
await stream1Task;
|
||||
await WaitForConditionAsync(() => relayActor2 != null);
|
||||
|
||||
// Send event to second relay
|
||||
var ts = DateTimeOffset.UtcNow;
|
||||
relayActor2!.Tell(new AttributeValueChanged(
|
||||
"SiteA.Pump01", "Flow", "GPM", 100.0, "Good", ts));
|
||||
|
||||
await WaitForConditionAsync(() => writtenEvents2.Count >= 1);
|
||||
|
||||
cts2.Cancel();
|
||||
await stream2Task;
|
||||
|
||||
Assert.Single(writtenEvents2);
|
||||
Assert.Equal("integ-dup", writtenEvents2[0].CorrelationId);
|
||||
}
|
||||
|
||||
private static ServerCallContext CreateMockContext(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var context = Substitute.For<ServerCallContext>();
|
||||
context.CancellationToken.Returns(cancellationToken);
|
||||
return context;
|
||||
}
|
||||
|
||||
private static async Task WaitForConditionAsync(Func<bool> condition, int timeoutMs = 5000)
|
||||
{
|
||||
var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs);
|
||||
while (!condition() && DateTime.UtcNow < deadline)
|
||||
{
|
||||
await Task.Delay(25);
|
||||
}
|
||||
|
||||
Assert.True(condition(), $"Condition not met within {timeoutMs}ms");
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Akka.TestKit.Xunit2" Version="1.5.62" />
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.5" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.5" />
|
||||
|
||||
@@ -19,8 +19,8 @@ public class SiteStreamManagerTests : TestKit, IDisposable
|
||||
{
|
||||
var options = new SiteRuntimeOptions { StreamBufferSize = 100 };
|
||||
_streamManager = new SiteStreamManager(
|
||||
Sys, options, NullLogger<SiteStreamManager>.Instance);
|
||||
_streamManager.Initialize();
|
||||
options, NullLogger<SiteStreamManager>.Instance);
|
||||
_streamManager.Initialize(Sys);
|
||||
}
|
||||
|
||||
void IDisposable.Dispose()
|
||||
|
||||
Reference in New Issue
Block a user