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);
}
}