perf(comms+audit): close phase-2 residuals — direct ingest path, monotonic timeouts, synthetic probe, not-reporting set, cursor-exact audit pull
This commit is contained in:
+194
@@ -0,0 +1,194 @@
|
||||
using Akka.Actor;
|
||||
using Akka.TestKit.Xunit2;
|
||||
using Grpc.Core;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NSubstitute;
|
||||
using ZB.MOM.WW.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
using ZB.MOM.WW.ScadaBridge.Communication.Actors;
|
||||
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Communication.Tests.Grpc;
|
||||
|
||||
/// <summary>
|
||||
/// Audit Log (#23) site→central ingest routing on <see cref="CentralControlGrpcService"/>.
|
||||
/// <para>
|
||||
/// The service Asks the <c>audit-log-ingest</c> singleton proxy DIRECTLY. It used to relay
|
||||
/// through <see cref="CentralCommunicationActor"/>, which re-Asked the same proxy with the same
|
||||
/// 30 s <see cref="SiteStreamGrpcServer.AuditIngestAskTimeout"/> — a second hop whose inner Ask
|
||||
/// expired at the same instant as the outer one, so it could only add latency. These tests pin
|
||||
/// the direct dispatch, the wiring-race reply, and the fact that the relay is gone.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class CentralControlGrpcServiceAuditIngestTests : TestKit
|
||||
{
|
||||
private static ServerCallContext NewContext(CancellationToken ct = default)
|
||||
{
|
||||
var context = Substitute.For<ServerCallContext>();
|
||||
context.CancellationToken.Returns(ct);
|
||||
return context;
|
||||
}
|
||||
|
||||
private static CentralControlGrpcService CreateService() => new(
|
||||
NullLogger<CentralControlGrpcService>.Instance,
|
||||
Options.Create(new CommunicationOptions()));
|
||||
|
||||
[Fact]
|
||||
public async Task IngestAuditEvents_AsksTheIngestProxy_AndNeverTheCommunicationActor()
|
||||
{
|
||||
var ingest = CreateTestProbe();
|
||||
var control = CreateTestProbe();
|
||||
var service = CreateService();
|
||||
service.SetReady(control.Ref);
|
||||
service.SetAuditIngestActor(ingest.Ref);
|
||||
|
||||
var evt = SampleAuditEvent();
|
||||
var batch = new AuditEventBatch();
|
||||
batch.Events.Add(AuditEventDtoMapper.ToDto(evt));
|
||||
|
||||
var call = service.IngestAuditEvents(batch, NewContext());
|
||||
|
||||
var received = ingest.ExpectMsg<IngestAuditEventsCommand>();
|
||||
Assert.Equal(evt.EventId, Assert.Single(received.Events).EventId);
|
||||
ingest.Reply(new IngestAuditEventsReply(new[] { evt.EventId }));
|
||||
|
||||
var ack = await call;
|
||||
Assert.Equal(evt.EventId.ToString(), Assert.Single(ack.AcceptedEventIds));
|
||||
|
||||
// The old relay hop is gone: the control-plane actor sees nothing at all.
|
||||
control.ExpectNoMsg(TimeSpan.FromMilliseconds(200));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IngestCachedTelemetry_AsksTheIngestProxy_AndNeverTheCommunicationActor()
|
||||
{
|
||||
var ingest = CreateTestProbe();
|
||||
var control = CreateTestProbe();
|
||||
var service = CreateService();
|
||||
service.SetReady(control.Ref);
|
||||
service.SetAuditIngestActor(ingest.Ref);
|
||||
|
||||
var evt = SampleAuditEvent();
|
||||
var batch = new CachedTelemetryBatch();
|
||||
batch.Packets.Add(new CachedTelemetryPacket
|
||||
{
|
||||
AuditEvent = AuditEventDtoMapper.ToDto(evt),
|
||||
Operational = SiteCallDtoMapper.ToDto(SampleSiteCall()),
|
||||
});
|
||||
|
||||
var call = service.IngestCachedTelemetry(batch, NewContext());
|
||||
|
||||
var received = ingest.ExpectMsg<IngestCachedTelemetryCommand>();
|
||||
Assert.Single(received.Entries);
|
||||
ingest.Reply(new IngestCachedTelemetryReply(new[] { evt.EventId }));
|
||||
|
||||
var ack = await call;
|
||||
Assert.Equal(evt.EventId.ToString(), Assert.Single(ack.AcceptedEventIds));
|
||||
control.ExpectNoMsg(TimeSpan.FromMilliseconds(200));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IngestAuditEvents_BeforeTheIngestProxyIsWired_ReturnsAnEmptyAck()
|
||||
{
|
||||
// The singleton starts moments after SetReady, so this window is real. An empty ack
|
||||
// (NOT a fault, NOT Unavailable) leaves the site's rows Pending for the next drain —
|
||||
// byte-for-byte what the removed relay replied when its proxy was still null.
|
||||
var service = CreateService();
|
||||
service.SetReady(CreateTestProbe().Ref);
|
||||
|
||||
var batch = new AuditEventBatch();
|
||||
batch.Events.Add(AuditEventDtoMapper.ToDto(SampleAuditEvent()));
|
||||
|
||||
var ack = await service.IngestAuditEvents(batch, NewContext());
|
||||
|
||||
Assert.Empty(ack.AcceptedEventIds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IngestCachedTelemetry_BeforeTheIngestProxyIsWired_ReturnsAnEmptyAck()
|
||||
{
|
||||
var service = CreateService();
|
||||
service.SetReady(CreateTestProbe().Ref);
|
||||
|
||||
var batch = new CachedTelemetryBatch();
|
||||
batch.Packets.Add(new CachedTelemetryPacket
|
||||
{
|
||||
AuditEvent = AuditEventDtoMapper.ToDto(SampleAuditEvent()),
|
||||
Operational = SiteCallDtoMapper.ToDto(SampleSiteCall()),
|
||||
});
|
||||
|
||||
var ack = await service.IngestCachedTelemetry(batch, NewContext());
|
||||
|
||||
Assert.Empty(ack.AcceptedEventIds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetAuditIngestActor_IsIndependentOfSetReady()
|
||||
{
|
||||
var service = CreateService();
|
||||
Assert.False(service.IsAuditIngestBound);
|
||||
|
||||
service.SetAuditIngestActor(CreateTestProbe().Ref);
|
||||
|
||||
Assert.True(service.IsAuditIngestBound);
|
||||
Assert.False(service.IsReady);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CentralCommunicationActor_NoLongerRelaysIngestCommands()
|
||||
{
|
||||
// Regression pin for the removed hop: the actor has no ingest receive at all, so an
|
||||
// ingest command reaching it is an unhandled message with no reply — not a silent
|
||||
// second path that could drift from the direct one.
|
||||
var actor = CreateCentralCommunicationActor();
|
||||
|
||||
actor.Tell(new IngestAuditEventsCommand(new[] { SampleAuditEvent() }), TestActor);
|
||||
actor.Tell(
|
||||
new IngestCachedTelemetryCommand(
|
||||
new[] { new CachedTelemetryEntry(SampleAuditEvent(), SampleSiteCall()) }),
|
||||
TestActor);
|
||||
|
||||
ExpectNoMsg(TimeSpan.FromMilliseconds(300));
|
||||
}
|
||||
|
||||
private IActorRef CreateCentralCommunicationActor()
|
||||
{
|
||||
var siteRepo = Substitute.For<ISiteRepository>();
|
||||
siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(new List<Commons.Entities.Sites.Site>());
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddScoped(_ => siteRepo);
|
||||
var sp = services.BuildServiceProvider();
|
||||
|
||||
var transport = Substitute.For<ISiteCommandTransport>();
|
||||
return Sys.ActorOf(Props.Create(() => new CentralCommunicationActor(sp, transport)));
|
||||
}
|
||||
|
||||
private static AuditEvent SampleAuditEvent() =>
|
||||
ScadaBridgeAuditEventFactory.Create(
|
||||
channel: AuditChannel.ApiOutbound,
|
||||
kind: AuditKind.ApiCall,
|
||||
status: AuditStatus.Delivered,
|
||||
sourceSiteId: "site-a");
|
||||
|
||||
private static SiteCall SampleSiteCall() => new()
|
||||
{
|
||||
TrackedOperationId = TrackedOperationId.New(),
|
||||
Channel = "OutboundApi",
|
||||
Target = "ExternalSystemA",
|
||||
SourceSite = "site-a",
|
||||
Status = "Delivered",
|
||||
RetryCount = 0,
|
||||
CreatedAtUtc = DateTime.UtcNow,
|
||||
UpdatedAtUtc = DateTime.UtcNow,
|
||||
IngestedAtUtc = DateTime.UtcNow,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user