using System.Reflection; using System.Text.Json; using Akka.Actor; using Akka.TestKit.Xunit2; using Microsoft.Extensions.Logging.Abstractions; using Serilog.Context; using ZB.MOM.WW.ScadaBridge.Commons.Messages.DataConnection; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Instance; using ZB.MOM.WW.ScadaBridge.Commons.Observability; using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening; using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors; using ZB.MOM.WW.ScadaBridge.SiteRuntime.Messages; using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence; using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts; using ZB.MOM.WW.ScadaBridge.TestSupport; namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Logging; /// /// ScadaBridge#56: a log line the Instance Actor (or any other actor serving an execution) writes /// while handling a message sent on behalf of that execution carries the execution's id, although /// LogContext itself never crosses the message hop - the id rides the message and the actor /// re-pushes it per message. /// public class ExecutionLogEnrichmentTests : TestKit, IDisposable { private const string InstanceName = "Enrich1"; private const string NoDclLine = "cannot be routed"; private readonly SiteStorageService _storage; private readonly ScriptCompilationService _compilationService; private readonly SharedScriptLibrary _sharedScriptLibrary; private readonly TestLocalDb _localDb; /// Creates a temporary LocalDb-backed site store per test. public ExecutionLogEnrichmentTests() { _localDb = TestLocalDb.CreateTemp("execution-log-enrichment"); _storage = new SiteStorageService(_localDb.Db, NullLogger.Instance); _storage.InitializeAsync().GetAwaiter().GetResult(); _compilationService = new ScriptCompilationService(NullLogger.Instance); _sharedScriptLibrary = new SharedScriptLibrary( _compilationService, NullLogger.Instance); } /// void IDisposable.Dispose() { Shutdown(); var path = _localDb.Path; _localDb.Dispose(); TestLocalDb.DeleteFiles(path); } private IActorRef StartInstance(Microsoft.Extensions.Logging.ILogger logger, IActorRef? dcl = null) { var config = new FlattenedConfiguration { InstanceUniqueName = InstanceName, Attributes = [ new ResolvedAttribute { CanonicalName = "Speed", Value = "0", DataType = "Int32", DataSourceReference = "ns=2;s=Speed", BoundDataConnectionName = "conn-1" }, new ResolvedAttribute { CanonicalName = "Trigger", Value = "0", DataType = "Int32", DataSourceReference = "ns=2;s=Trigger", BoundDataConnectionName = "conn-1" } ] }; var json = JsonSerializer.Serialize(config); return ActorOf(Props.Create(() => new InstanceActor( InstanceName, json, _storage, _compilationService, _sharedScriptLibrary, null, new SiteRuntimeOptions(), logger, dcl))); } /// /// A data-sourced write with no DCL logs a warning while the Instance Actor serves it; that /// line carries the writing execution's id. /// [Fact] public void InstanceActor_LineLoggedWhileServingAnExecution_CarriesItsExecutionId() { var capture = new LogContextCapture(); var instance = StartInstance(capture); var executionId = Guid.NewGuid(); instance.Tell(new SetStaticAttributeCommand( "c1", InstanceName, "Speed", "5", DateTimeOffset.UtcNow, SourceExecutionId: executionId)); Assert.False(ExpectMsg(TimeSpan.FromSeconds(10)).Success); Assert.Equal(executionId, capture.ExecutionIdOf(NoDclLine)); } /// /// The id comes from the MESSAGE, never from the sender's ambient context: a message that names /// no execution produces an untagged line even when the sending thread had one pushed. (The /// positive control is the test above - same command, same line, id on the message.) /// [Fact] public void InstanceActor_UncorrelatedMessage_DoesNotInheritTheSendersLogContext() { var capture = new LogContextCapture(); var instance = StartInstance(capture); using (LogContext.PushProperty(ExecutionLogContext.ExecutionIdProperty, Guid.NewGuid())) { instance.Tell(new SetStaticAttributeCommand("c1", InstanceName, "Speed", "5", DateTimeOffset.UtcNow)); } ExpectMsg(TimeSpan.FromSeconds(10)); Assert.True(capture.Has(NoDclLine)); Assert.Null(capture.ExecutionIdOf(NoDclLine)); } /// The scope is popped after the message: the next uncorrelated message logs untagged. [Fact] public void InstanceActor_ExecutionScopeEndsWithTheMessage() { var capture = new LogContextCapture(); var instance = StartInstance(capture); instance.Tell(new SetStaticAttributeCommand( "first", InstanceName, "Speed", "5", DateTimeOffset.UtcNow, SourceExecutionId: Guid.NewGuid())); ExpectMsg(TimeSpan.FromSeconds(10)); instance.Tell(new SetStaticAttributeCommand("second", InstanceName, "Trigger", "1", DateTimeOffset.UtcNow)); ExpectMsg(TimeSpan.FromSeconds(10)); var lines = capture.Events.Where(e => e.RenderMessage().Contains(NoDclLine, StringComparison.Ordinal)).ToList(); Assert.Equal(2, lines.Count); Assert.True(lines[0].Properties.ContainsKey(ExecutionLogContext.ExecutionIdProperty)); Assert.False(lines[1].Properties.ContainsKey(ExecutionLogContext.ExecutionIdProperty)); } /// /// A routed SetAttributes arrives as one that names no /// execution; each write inside it does, and its line carries that write's id. /// [Fact] public void InstanceActor_BatchedWrites_LogUnderEachWritesExecution() { var capture = new LogContextCapture(); var instance = StartInstance(capture); var executionId = Guid.NewGuid(); instance.Tell(new InstanceSetAttributesRequest("c1", [ new SetStaticAttributeCommand("w1", InstanceName, "Speed", "5", DateTimeOffset.UtcNow, SourceExecutionId: executionId), ])); ExpectMsg(TimeSpan.FromSeconds(10)); Assert.Equal(executionId, capture.ExecutionIdOf(NoDclLine)); } /// A script run stamps its own id on every request it sends the Instance Actor. /// A task that represents the asynchronous operation. [Fact] public async Task ScriptRuntimeContext_StampsItsExecutionIdOnInstanceRequests() { var probe = CreateTestProbe(); var executionId = Guid.NewGuid(); var ctx = new ScriptRuntimeContext( probe.Ref, _sharedScriptLibrary, currentCallDepth: 0, maxCallDepth: 10, askTimeout: TimeSpan.FromSeconds(5), instanceName: InstanceName, logger: NullLogger.Instance, executionId: executionId); var read = ctx.GetAttribute("Speed"); var get = probe.ExpectMsg(TimeSpan.FromSeconds(5)); Assert.Equal(executionId, get.SourceExecutionId); probe.Reply(new GetAttributeResponse(get.CorrelationId, InstanceName, "Speed", 1, true, "Good", DateTimeOffset.UtcNow)); await read; var call = ctx.WriteBatchAndWait( new Dictionary { ["Speed"] = "7" }, "Trigger", "1", "Ack", "1", TimeSpan.FromSeconds(3)); var batch = probe.ExpectMsg(TimeSpan.FromSeconds(5)); Assert.Equal(executionId, batch.SourceExecutionId); probe.Reply(new WriteAttributeBatchResponse(batch.CorrelationId, true, null)); var wait = probe.ExpectMsg(TimeSpan.FromSeconds(5)); Assert.Equal(executionId, wait.SourceExecutionId); probe.Reply(new WaitForAttributeResponse(wait.CorrelationId, Matched: true, "1", "Good", TimedOut: false)); Assert.True(await call); } /// The Instance Actor hands the writing execution's id on to the DCL, single and batch writes alike. [Fact] public void InstanceActor_ForwardsTheExecutionIdOnDclWrites() { var dcl = CreateTestProbe(); var instance = StartInstance(NullLogger.Instance, dcl.Ref); dcl.ExpectMsg(TimeSpan.FromSeconds(10)); var single = Guid.NewGuid(); var batch = Guid.NewGuid(); instance.Tell(new SetStaticAttributeCommand( "c1", InstanceName, "Speed", "5", DateTimeOffset.UtcNow, SourceExecutionId: single)); Assert.Equal(single, dcl.ExpectMsg(TimeSpan.FromSeconds(10)).SourceExecutionId); instance.Tell(new WriteAttributeBatchRequest( "c2", InstanceName, new Dictionary { ["Speed"] = "7" }, "Trigger", "1", DateTimeOffset.UtcNow, SourceExecutionId: batch)); Assert.Equal(batch, dcl.ExpectMsg(TimeSpan.FromSeconds(10)).SourceExecutionId); } /// /// Every site actor that serves executions re-pushes the id per message. A behavioural test /// covers the Instance Actor (above) and the DCL connection actor (DCL tests); this pins the /// hook on the rest so a refactor that drops it is caught. /// /// The actor type. [Theory] [InlineData(typeof(InstanceActor))] [InlineData(typeof(ScriptActor))] [InlineData(typeof(AlarmActor))] [InlineData(typeof(DeploymentManagerActor))] [InlineData(typeof(ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Actors.DataConnectionManagerActor))] [InlineData(typeof(ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Actors.DataConnectionActor))] public void ExecutionServingActors_OverrideAroundReceive(Type actorType) { var method = actorType.GetMethod( "AroundReceive", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.DeclaredOnly); Assert.NotNull(method); } }