edd0ca672f
LogContext is an AsyncLocal and does not cross an Akka message hop, so the run's id now rides the message: Commons.Observability.IExecutionCorrelated (explicit interface member, no wire change) on SetStaticAttributeCommand, AttributeValueChanged (not for an inferred writer), ScriptCallRequest and the four RouteTo*Requests, plus a new additive SourceExecutionId on GetAttributeRequest, WaitForAttributeRequest, WriteAttributeBatchRequest, WriteTagRequest and WriteTagBatchRequest. ScriptRuntimeContext stamps its id on the requests it sends, the Instance Actor hands it on to the DCL, and the Instance, Script, Alarm, Deployment Manager and both DCL actors re-push it per message in AroundReceive (ExecutionLogContext.PushFor: one type test when the message names no execution). The inbound executor pushes the request's ExecutionId for the method execution. Tests (TestKit): Instance Actor line carries the message's id, no leak from the sender's ambient context, scope ends with the message, per-write ids inside a batched SetAttributes, ids forwarded on DCL writes, ScriptRuntimeContext stamps; DCL adapter lines on single/batch writes carry the id; inbound executor lines carry the request id; Commons mapping + no serialized member. Each mutation- proved. Still open on #56: a queryable per-execution sink and a Flow Debugger section. The DCL actors' own Akka _log lines bypass Serilog (filed #120). Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
229 lines
10 KiB
C#
229 lines
10 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// 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
|
|
/// <c>LogContext</c> itself never crosses the message hop - the id rides the message and the actor
|
|
/// re-pushes it per message.
|
|
/// </summary>
|
|
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;
|
|
|
|
/// <summary>Creates a temporary LocalDb-backed site store per test.</summary>
|
|
public ExecutionLogEnrichmentTests()
|
|
{
|
|
_localDb = TestLocalDb.CreateTemp("execution-log-enrichment");
|
|
_storage = new SiteStorageService(_localDb.Db, NullLogger<SiteStorageService>.Instance);
|
|
_storage.InitializeAsync().GetAwaiter().GetResult();
|
|
_compilationService = new ScriptCompilationService(NullLogger<ScriptCompilationService>.Instance);
|
|
_sharedScriptLibrary = new SharedScriptLibrary(
|
|
_compilationService, NullLogger<SharedScriptLibrary>.Instance);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
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)));
|
|
}
|
|
|
|
/// <summary>
|
|
/// A data-sourced write with no DCL logs a warning while the Instance Actor serves it; that
|
|
/// line carries the writing execution's id.
|
|
/// </summary>
|
|
[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<SetStaticAttributeResponse>(TimeSpan.FromSeconds(10)).Success);
|
|
|
|
Assert.Equal(executionId, capture.ExecutionIdOf(NoDclLine));
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.)
|
|
/// </summary>
|
|
[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<SetStaticAttributeResponse>(TimeSpan.FromSeconds(10));
|
|
|
|
Assert.True(capture.Has(NoDclLine));
|
|
Assert.Null(capture.ExecutionIdOf(NoDclLine));
|
|
}
|
|
|
|
/// <summary>The scope is popped after the message: the next uncorrelated message logs untagged.</summary>
|
|
[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<SetStaticAttributeResponse>(TimeSpan.FromSeconds(10));
|
|
instance.Tell(new SetStaticAttributeCommand("second", InstanceName, "Trigger", "1", DateTimeOffset.UtcNow));
|
|
ExpectMsg<SetStaticAttributeResponse>(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));
|
|
}
|
|
|
|
/// <summary>
|
|
/// A routed SetAttributes arrives as one <see cref="InstanceSetAttributesRequest"/> that names no
|
|
/// execution; each write inside it does, and its line carries that write's id.
|
|
/// </summary>
|
|
[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<InstanceSetAttributesResponse>(TimeSpan.FromSeconds(10));
|
|
|
|
Assert.Equal(executionId, capture.ExecutionIdOf(NoDclLine));
|
|
}
|
|
|
|
/// <summary>A script run stamps its own id on every request it sends the Instance Actor.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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<GetAttributeRequest>(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<string, string?> { ["Speed"] = "7" }, "Trigger", "1", "Ack", "1", TimeSpan.FromSeconds(3));
|
|
var batch = probe.ExpectMsg<WriteAttributeBatchRequest>(TimeSpan.FromSeconds(5));
|
|
Assert.Equal(executionId, batch.SourceExecutionId);
|
|
probe.Reply(new WriteAttributeBatchResponse(batch.CorrelationId, true, null));
|
|
var wait = probe.ExpectMsg<WaitForAttributeRequest>(TimeSpan.FromSeconds(5));
|
|
Assert.Equal(executionId, wait.SourceExecutionId);
|
|
probe.Reply(new WaitForAttributeResponse(wait.CorrelationId, Matched: true, "1", "Good", TimedOut: false));
|
|
Assert.True(await call);
|
|
}
|
|
|
|
/// <summary>The Instance Actor hands the writing execution's id on to the DCL, single and batch writes alike.</summary>
|
|
[Fact]
|
|
public void InstanceActor_ForwardsTheExecutionIdOnDclWrites()
|
|
{
|
|
var dcl = CreateTestProbe();
|
|
var instance = StartInstance(NullLogger.Instance, dcl.Ref);
|
|
dcl.ExpectMsg<SubscribeTagsRequest>(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<WriteTagRequest>(TimeSpan.FromSeconds(10)).SourceExecutionId);
|
|
|
|
instance.Tell(new WriteAttributeBatchRequest(
|
|
"c2", InstanceName, new Dictionary<string, string?> { ["Speed"] = "7" }, "Trigger", "1",
|
|
DateTimeOffset.UtcNow, SourceExecutionId: batch));
|
|
Assert.Equal(batch, dcl.ExpectMsg<WriteTagBatchRequest>(TimeSpan.FromSeconds(10)).SourceExecutionId);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
/// <param name="actorType">The actor type.</param>
|
|
[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);
|
|
}
|
|
}
|