refactor(runtime): ParsedDeploymentArtifact — one JsonDocument per apply

Every public entry point on DeploymentArtifact took ReadOnlySpan<byte> and did
JsonDocument.Parse(blob.ToArray()) — a full copy plus a full document build per
call — so one apply paid ~8-11 full parses of the same bytes (the scoped
ParseComposition alone parsed three times: scope, composition, BuildClusterSets).

DeploymentArtifact.Open(byte[]) now parses once into ParsedDeploymentArtifact,
which owns the JsonDocument and exposes an instance method mirroring each static
entry point. The moved bodies operate on Root; BuildClusterSets takes JsonElement
instead of re-parsing the blob; ResolveClusterScope is memoized per node id, since
every consumer of one apply asks about the same node.

Every static is now a thin wrapper (Open + delegate + dispose) keeping its
ReadOnlySpan<byte> signature and its lenient empty/malformed contract — the
wrapper pays the one copy and owns the JsonException catch, so output shapes are
unchanged. Hot callers migrate to Open() separately.

ParsedDeploymentArtifactTests pins same-bytes-same-output through both doors for
all six entry points over a rich multi-cluster artifact (raw chain + UNS refs +
scripts + keys + ACLs), with falsifiability controls proving the fixture actually
scopes, plus memoization and after-dispose-use coverage.
This commit is contained in:
Joseph Doherty
2026-08-15 13:23:05 -04:00
parent 9f68f27218
commit 59202017cd
2 changed files with 673 additions and 135 deletions
@@ -0,0 +1,379 @@
using System.Text;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
/// <summary>
/// Parity coverage for <see cref="DeploymentArtifact.Open"/> / <see cref="ParsedDeploymentArtifact"/> —
/// the "parse the artifact JSON once per apply" seam. Every public static entry point on
/// <see cref="DeploymentArtifact"/> is now a thin wrapper over the instance method of the same name,
/// so the ONLY thing that must be proved is that the two produce identical output for the same bytes.
/// </summary>
/// <remarks>
/// <para>
/// These are not "does the parser work" tests — that is what the eight existing
/// <c>DeploymentArtifact*Tests</c> suites are for, and they now exercise the instance path through
/// the wrappers. These pin the refactor itself: same bytes ⇒ same output through either door.
/// </para>
/// <para>
/// Record equality is NOT usable for the composition/projection results: their members are
/// <c>IReadOnlyList</c>/<c>IReadOnlyDictionary</c>, which records compare by REFERENCE, so two
/// separately-produced-but-identical results are never <c>==</c>. Every assertion here therefore
/// compares element-wise (the element types are records with scalar members, which do compare by
/// value).
/// </para>
/// </remarks>
public sealed class ParsedDeploymentArtifactTests
{
private const string NodeC1 = "c1-1:4053";
private const string NodeC2 = "c2-1:4053";
private const string NodeUnlisted = "stranger:4053";
/// <summary>
/// A deliberately rich MULTI-CLUSTER artifact: two clusters + their node rows, the full raw chain
/// (folder → driver → device → tag group → tags, including a historized tag, an array tag and a
/// native-alarm tag), the UNS chain (area → line → equipment) with a tag reference carrying a
/// display-name override, scripts joined by both a VirtualTag and a ScriptedAlarm (with a
/// <c>{{equip}}</c> reference so the equipment-reference map is genuinely exercised), plus ApiKeys
/// and NodeAcls so the data-plane projection has something to scope.
/// </summary>
private const string Artifact = """
{
"Clusters": [{"ClusterId":"C1"},{"ClusterId":"C2"}],
"Nodes": [
{"NodeId":"c1-1:4053","ClusterId":"C1"},
{"NodeId":"c2-1:4053","ClusterId":"C2"}
],
"RawFolders": [
{"RawFolderId":"RF-plant","ClusterId":"C1","ParentRawFolderId":null,"Name":"Plant"},
{"RawFolderId":"RF-line1","ClusterId":"C1","ParentRawFolderId":"RF-plant","Name":"Line1"},
{"RawFolderId":"RF-c2","ClusterId":"C2","ParentRawFolderId":null,"Name":"Site2"}
],
"DriverInstances": [
{"DriverInstanceRowId":"11111111-1111-1111-1111-111111111111","DriverInstanceId":"DI-modbus","ClusterId":"C1","RawFolderId":"RF-line1","Name":"ModbusDrv","DriverType":"Modbus","Enabled":true,"DriverConfig":"{\"unitId\":1}","ResilienceConfig":"{\"bulkheadMaxConcurrent\":16}"},
{"DriverInstanceRowId":"22222222-2222-2222-2222-222222222222","DriverInstanceId":"DI-s7","ClusterId":"C2","RawFolderId":"RF-c2","Name":"S7Drv","DriverType":"S7","Enabled":true,"DriverConfig":"{}"}
],
"Devices": [
{"DeviceId":"DV-1","DriverInstanceId":"DI-modbus","Name":"dev1","DeviceConfig":"{\"endpoint\":\"10.0.0.1:502\"}"},
{"DeviceId":"DV-2","DriverInstanceId":"DI-s7","Name":"dev2","DeviceConfig":"{}"}
],
"TagGroups": [{"TagGroupId":"TG-fast","DeviceId":"DV-1","ParentTagGroupId":null,"Name":"Fast"}],
"Tags": [
{"TagId":"TAG-speed","DeviceId":"DV-1","TagGroupId":"TG-fast","Name":"Speed","DataType":"Float","AccessLevel":1,"WriteIdempotent":true,"TagConfig":"{\"isHistorized\":true,\"historianTagname\":\"WW.Speed\"}"},
{"TagId":"TAG-run","DeviceId":"DV-1","TagGroupId":null,"Name":"Run","DataType":"Boolean","AccessLevel":0,"TagConfig":"{}"},
{"TagId":"TAG-profile","DeviceId":"DV-1","TagGroupId":"TG-fast","Name":"Profile","DataType":"Float","AccessLevel":0,"TagConfig":"{\"isArray\":true,\"arrayLength\":16}"},
{"TagId":"TAG-temphi","DeviceId":"DV-1","TagGroupId":null,"Name":"TempHi","DataType":"Boolean","AccessLevel":1,"TagConfig":"{\"alarm\":{\"alarmType\":\"OffNormalAlarm\",\"severity\":700}}"},
{"TagId":"TAG-c2","DeviceId":"DV-2","TagGroupId":null,"Name":"Pressure","DataType":"Float","AccessLevel":0,"TagConfig":"{}"}
],
"UnsAreas": [
{"UnsAreaId":"UA-w","ClusterId":"C1","Name":"West"},
{"UnsAreaId":"UA-e","ClusterId":"C2","Name":"East"}
],
"UnsLines": [
{"UnsLineId":"UL-1","UnsAreaId":"UA-w","Name":"L1"},
{"UnsLineId":"UL-2","UnsAreaId":"UA-e","Name":"L2"}
],
"Equipment": [
{"EquipmentId":"EQ-mixer","UnsLineId":"UL-1","Name":"Mixer","MachineCode":"MX1"},
{"EquipmentId":"EQ-press","UnsLineId":"UL-2","Name":"Press","MachineCode":"PR1"}
],
"UnsTagReferences": [
{"UnsTagReferenceId":"REF-1","EquipmentId":"EQ-mixer","TagId":"TAG-speed","DisplayNameOverride":"MixerSpeed"},
{"UnsTagReferenceId":"REF-2","EquipmentId":"EQ-mixer","TagId":"TAG-run","DisplayNameOverride":null}
],
"Scripts": [
{"ScriptId":"SCR-vt","SourceCode":"return ctx.GetTag(\"{{equip}}/MixerSpeed\") * 2;"},
{"ScriptId":"SCR-alarm","SourceCode":"return ctx.GetTag(\"{{equip}}/Run\") == false;"}
],
"VirtualTags": [
{"VirtualTagId":"VT-1","EquipmentId":"EQ-mixer","Name":"DoubleSpeed","DataType":"Float","ScriptId":"SCR-vt","Historize":true}
],
"ScriptedAlarms": [
{"ScriptedAlarmId":"SA-1","EquipmentId":"EQ-mixer","Name":"Stopped","AlarmType":"OffNormalAlarm","Severity":500,"MessageTemplate":"Mixer stopped","PredicateScriptId":"SCR-alarm","HistorizeToAveva":true,"Retain":true,"Enabled":true}
],
"ApiKeys": [
{"ApiKeyId":"k1","Name":"key-one","SecretHash":"hash-one","Principal":"p","Enabled":true,"CreatedUtc":"2026-01-01T00:00:00","ExpiresUtc":"2030-06-15T12:00:00"},
{"ApiKeyId":"k2","Name":"key-two","SecretHash":"hash-two","Principal":"anonymous","Enabled":false,"CreatedUtc":"2026-01-01T00:00:00"}
],
"NodeAcls": [
{"NodeAclId":"acl-dev","ClusterId":"C1","Principal":"p","ScopeKind":9,"ScopeId":"DV-1","PermissionFlags":2},
{"NodeAclId":"acl-eq","ClusterId":"C1","Principal":"p","ScopeKind":4,"ScopeId":"EQ-mixer","PermissionFlags":16},
{"NodeAclId":"acl-c2","ClusterId":"C2","Principal":"p","ScopeKind":4,"ScopeId":"EQ-press","PermissionFlags":16}
]
}
""";
private static byte[] Blob => Encoding.UTF8.GetBytes(Artifact);
[Theory]
[InlineData(NodeC1)]
[InlineData(NodeC2)]
[InlineData(NodeUnlisted)]
public void ResolveClusterScope_is_identical_through_the_parsed_artifact(string nodeId)
{
var blob = Blob;
using var parsed = DeploymentArtifact.Open(blob);
parsed.ResolveClusterScope(nodeId).ShouldBe(DeploymentArtifact.ResolveClusterScope(blob, nodeId));
}
[Fact]
public void The_scope_fixture_actually_distinguishes_its_nodes()
{
// Falsifiability control for the parity theory above: if every node resolved to the same scope,
// a memoization cache keyed on nothing at all would still pass it.
var blob = Blob;
DeploymentArtifact.ResolveClusterScope(blob, NodeC1)
.ShouldBe(new ClusterScope(ClusterFilterMode.ScopeTo, "C1"));
DeploymentArtifact.ResolveClusterScope(blob, NodeC2)
.ShouldBe(new ClusterScope(ClusterFilterMode.ScopeTo, "C2"));
DeploymentArtifact.ResolveClusterScope(blob, NodeUnlisted)
.ShouldBe(new ClusterScope(ClusterFilterMode.Suppress, null));
}
[Fact]
public void ParseDriverInstances_unscoped_is_identical_through_the_parsed_artifact()
{
var blob = Blob;
using var parsed = DeploymentArtifact.Open(blob);
var expected = DeploymentArtifact.ParseDriverInstances(blob);
expected.Count.ShouldBe(2); // sanity: an empty-vs-empty comparison proves nothing
parsed.ParseDriverInstances().ShouldBe(expected);
}
[Theory]
[InlineData(NodeC1)]
[InlineData(NodeC2)]
[InlineData(NodeUnlisted)]
public void ParseDriverInstances_scoped_is_identical_through_the_parsed_artifact(string nodeId)
{
var blob = Blob;
using var parsed = DeploymentArtifact.Open(blob);
parsed.ParseDriverInstances(nodeId).ShouldBe(DeploymentArtifact.ParseDriverInstances(blob, nodeId));
}
[Fact]
public void The_scoped_driver_fixture_actually_filters()
{
var blob = Blob;
DeploymentArtifact.ParseDriverInstances(blob, NodeC1)
.Select(s => s.DriverInstanceId).ShouldBe(new[] { "DI-modbus" });
DeploymentArtifact.ParseDriverInstances(blob, NodeC2)
.Select(s => s.DriverInstanceId).ShouldBe(new[] { "DI-s7" });
DeploymentArtifact.ParseDriverInstances(blob, NodeUnlisted).ShouldBeEmpty();
}
[Theory]
[InlineData(NodeC1)]
[InlineData(NodeC2)]
[InlineData(NodeUnlisted)]
public void ParseDataPlaneAuthority_is_identical_through_the_parsed_artifact(string nodeId)
{
var blob = Blob;
using var parsed = DeploymentArtifact.Open(blob);
AssertSameAuthority(parsed.ParseDataPlaneAuthority(nodeId),
DeploymentArtifact.ParseDataPlaneAuthority(blob, nodeId));
}
[Fact]
public void The_authority_fixture_carries_keys_grants_and_resolved_scope_paths()
{
// Sanity on the parity theory above: it compares three collections, and three empties compare equal.
var projection = DeploymentArtifact.ParseDataPlaneAuthority(Blob, NodeC1);
projection.ClusterId.ShouldBe("C1");
projection.Keys.Select(k => k.ApiKeyId).ShouldBe(new[] { "k1", "k2" });
projection.Grants.Select(g => g.NodeAclId).ShouldBe(new[] { "acl-dev", "acl-eq" });
projection.ScopePaths.ShouldNotBeEmpty();
}
[Fact]
public void ParseComposition_unscoped_is_identical_through_the_parsed_artifact()
{
var blob = Blob;
using var parsed = DeploymentArtifact.Open(blob);
var expected = DeploymentArtifact.ParseComposition(blob);
expected.RawTags.ShouldNotBeEmpty();
expected.UnsReferenceVariables.ShouldNotBeEmpty();
expected.EquipmentVirtualTags.ShouldNotBeEmpty();
expected.EquipmentScriptedAlarms.ShouldNotBeEmpty();
AssertSameComposition(parsed.ParseComposition(), expected);
}
[Theory]
[InlineData(NodeC1)]
[InlineData(NodeC2)]
[InlineData(NodeUnlisted)]
public void ParseComposition_scoped_is_identical_through_the_parsed_artifact(string nodeId)
{
var blob = Blob;
using var parsed = DeploymentArtifact.Open(blob);
AssertSameComposition(parsed.ParseComposition(nodeId), DeploymentArtifact.ParseComposition(blob, nodeId));
}
[Fact]
public void The_scoped_composition_fixture_actually_filters()
{
// BuildClusterSets is the piece this refactor de-duplicated (it used to re-parse the blob), so the
// scoped composition has to keep filtering rather than degrade to "everything" or "nothing".
var blob = Blob;
var c1 = DeploymentArtifact.ParseComposition(blob, NodeC1);
c1.UnsAreas.Select(a => a.UnsAreaId).ShouldBe(new[] { "UA-w" });
c1.EquipmentNodes.Select(e => e.EquipmentId).ShouldBe(new[] { "EQ-mixer" });
c1.RawTags.Select(t => t.DriverInstanceId).Distinct().ShouldBe(new[] { "DI-modbus" });
c1.UnsReferenceVariables.Select(v => v.EquipmentId).Distinct().ShouldBe(new[] { "EQ-mixer" });
var c2 = DeploymentArtifact.ParseComposition(blob, NodeC2);
c2.UnsAreas.Select(a => a.UnsAreaId).ShouldBe(new[] { "UA-e" });
c2.EquipmentNodes.Select(e => e.EquipmentId).ShouldBe(new[] { "EQ-press" });
DeploymentArtifact.ParseComposition(blob, NodeUnlisted).EquipmentNodes.ShouldBeEmpty();
}
[Fact]
public void The_scoped_composition_inconsistency_callback_fires_identically_through_both_doors()
{
var blob = Blob;
using var parsed = DeploymentArtifact.Open(blob);
var viaStatic = new List<string>();
DeploymentArtifact.ParseComposition(blob, NodeC1, viaStatic.Add);
var viaInstance = new List<string>();
parsed.ParseComposition(NodeC1, viaInstance.Add);
viaInstance.ShouldBe(viaStatic);
}
[Fact]
public void A_second_call_on_the_same_parsed_artifact_returns_equal_results()
{
// Memoization sanity: ResolveClusterScope is cached per node id, and the cache must not leak the
// first caller's answer to a different node — nor stale the same caller's answer.
using var parsed = DeploymentArtifact.Open(Blob);
var firstC1 = parsed.ResolveClusterScope(NodeC1);
var firstC2 = parsed.ResolveClusterScope(NodeC2);
parsed.ResolveClusterScope(NodeC1).ShouldBe(firstC1);
parsed.ResolveClusterScope(NodeC2).ShouldBe(firstC2);
firstC1.ShouldNotBe(firstC2);
parsed.ParseDriverInstances().ShouldBe(parsed.ParseDriverInstances());
parsed.ParseDriverInstances(NodeC1).ShouldBe(parsed.ParseDriverInstances(NodeC1));
AssertSameAuthority(parsed.ParseDataPlaneAuthority(NodeC1), parsed.ParseDataPlaneAuthority(NodeC1));
AssertSameComposition(parsed.ParseComposition(), parsed.ParseComposition());
AssertSameComposition(parsed.ParseComposition(NodeC1), parsed.ParseComposition(NodeC1));
}
[Fact]
public void Results_survive_disposing_the_parsed_artifact()
{
// Everything returned is fully materialised — no JsonElement is retained, so a caller that disposes
// the artifact the moment the apply's parsing is done cannot be handed a use-after-dispose.
AddressSpaceComposition composition;
DataPlaneAuthorityProjection authority;
IReadOnlyList<DriverInstanceSpec> specs;
using (var parsed = DeploymentArtifact.Open(Blob))
{
composition = parsed.ParseComposition(NodeC1);
authority = parsed.ParseDataPlaneAuthority(NodeC1);
specs = parsed.ParseDriverInstances(NodeC1);
}
// Touch every collection AFTER disposal — a retained JsonElement would throw ObjectDisposedException.
composition.RawTags.Select(t => t.NodeId).ShouldNotBeEmpty();
composition.UnsReferenceVariables.Select(v => v.NodeId).ShouldNotBeEmpty();
composition.EquipmentVirtualTags.Select(v => v.Expression).ShouldNotBeEmpty();
composition.EquipmentScriptedAlarms.Select(a => a.PredicateSource).ShouldNotBeEmpty();
authority.Keys.Select(k => k.SecretHash).ShouldNotBeEmpty();
authority.ScopePaths.SelectMany(kv => kv.Value).Select(p => p.Segments.Count).ShouldNotBeEmpty();
specs.Select(s => s.DriverConfig).ShouldNotBeEmpty();
}
[Fact]
public void Disposing_twice_is_harmless()
{
var parsed = DeploymentArtifact.Open(Blob);
parsed.Dispose();
Should.NotThrow(() => parsed.Dispose());
}
[Fact]
public void A_malformed_blob_still_degrades_leniently_through_every_static_wrapper()
{
// The statics own the lenient contract (empty list / empty composition / None scope) — Open() itself
// throws, because a caller that opens an artifact wants to know the bytes were unusable.
var bad = Encoding.UTF8.GetBytes("{ this is not json");
DeploymentArtifact.ParseDriverInstances(bad).ShouldBeEmpty();
DeploymentArtifact.ParseDriverInstances(bad, NodeC1).ShouldBeEmpty();
DeploymentArtifact.ResolveClusterScope(bad, NodeC1).Mode.ShouldBe(ClusterFilterMode.None);
DeploymentArtifact.ParseDataPlaneAuthority(bad, NodeC1).Keys.ShouldBeEmpty();
DeploymentArtifact.ParseComposition(bad).EquipmentNodes.ShouldBeEmpty();
DeploymentArtifact.ParseComposition(bad, NodeC1).EquipmentNodes.ShouldBeEmpty();
}
[Fact]
public void An_empty_blob_still_degrades_leniently_through_every_static_wrapper()
{
var empty = Array.Empty<byte>();
DeploymentArtifact.ParseDriverInstances(empty).ShouldBeEmpty();
DeploymentArtifact.ParseDriverInstances(empty, NodeC1).ShouldBeEmpty();
DeploymentArtifact.ResolveClusterScope(empty, NodeC1).Mode.ShouldBe(ClusterFilterMode.None);
DeploymentArtifact.ParseDataPlaneAuthority(empty, NodeC1).ShouldBe(DataPlaneAuthorityProjection.Empty);
DeploymentArtifact.ParseComposition(empty).EquipmentNodes.ShouldBeEmpty();
DeploymentArtifact.ParseComposition(empty, NodeC1).EquipmentNodes.ShouldBeEmpty();
}
private static void AssertSameComposition(AddressSpaceComposition actual, AddressSpaceComposition expected)
{
actual.UnsAreas.ShouldBe(expected.UnsAreas);
actual.UnsLines.ShouldBe(expected.UnsLines);
actual.EquipmentNodes.ShouldBe(expected.EquipmentNodes);
actual.DriverInstancePlans.ShouldBe(expected.DriverInstancePlans);
actual.ScriptedAlarmPlans.ShouldBe(expected.ScriptedAlarmPlans);
actual.EquipmentTags.ShouldBe(expected.EquipmentTags);
actual.EquipmentVirtualTags.ShouldBe(expected.EquipmentVirtualTags);
actual.EquipmentScriptedAlarms.ShouldBe(expected.EquipmentScriptedAlarms);
actual.RawContainers.ShouldBe(expected.RawContainers);
actual.RawTags.ShouldBe(expected.RawTags);
actual.UnsReferenceVariables.ShouldBe(expected.UnsReferenceVariables);
}
private static void AssertSameAuthority(DataPlaneAuthorityProjection actual, DataPlaneAuthorityProjection expected)
{
actual.ClusterId.ShouldBe(expected.ClusterId);
actual.Keys.Select(Describe).ShouldBe(expected.Keys.Select(Describe));
actual.Grants.Select(Describe).ShouldBe(expected.Grants.Select(Describe));
actual.ScopePaths.Keys.OrderBy(k => k.ToString()).ShouldBe(expected.ScopePaths.Keys.OrderBy(k => k.ToString()));
foreach (var (scope, paths) in expected.ScopePaths)
{
actual.ScopePaths.ShouldContainKey(scope);
actual.ScopePaths[scope].Select(p => string.Join('/', p.Segments))
.ShouldBe(paths.Select(p => string.Join('/', p.Segments)));
}
}
// ApiKey / NodeAcl are EF entities (reference equality), so compare their fields as a flat string.
private static string Describe(ZB.MOM.WW.OtOpcUa.Configuration.Entities.ApiKey k) =>
$"{k.ApiKeyId}|{k.Name}|{k.SecretHash}|{k.Principal}|{k.Enabled}|{k.CreatedUtc:O}|{k.ExpiresUtc:O}|{k.Notes}";
private static string Describe(ZB.MOM.WW.OtOpcUa.Configuration.Entities.NodeAcl a) =>
$"{a.NodeAclId}|{a.ClusterId}|{a.Principal}|{a.ScopeKind}|{a.ScopeId}|{a.PermissionFlags}|{a.Notes}";
}