Merge lane B: OPC UA address space remediation (#732)
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
@@ -93,37 +93,33 @@ public sealed class AddressSpaceApplier
|
||||
}
|
||||
|
||||
var ts = DateTime.UtcNow;
|
||||
var removedCount = 0;
|
||||
// A removed equipment is a FOLDER: it gets no terminal condition write (review 01 L4 — the write the
|
||||
// applier used to make against the equipment id never found a condition and minted a placeholder
|
||||
// variable that shadowed the folder's SDK lookup entry). Its subtree teardown is in ApplyPureRemove.
|
||||
var removedCount = plan.RemovedEquipment.Count;
|
||||
// Swallowed removal-pass condition-write failures are tallied here (not lost to a per-node Warning)
|
||||
// so a degraded removal is operator-visible via AddressSpaceApplyOutcome.FailedNodes (archreview 01/S-1).
|
||||
var failedNodes = 0;
|
||||
foreach (var eq in plan.RemovedEquipment)
|
||||
{
|
||||
if (!SafeWriteAlarmCondition(eq.EquipmentId, RemovedConditionState, ts, AddressSpaceRealm.Uns)) failedNodes++;
|
||||
removedCount++;
|
||||
}
|
||||
foreach (var alarm in plan.RemovedAlarms)
|
||||
{
|
||||
if (!SafeWriteAlarmCondition(alarm.ScriptedAlarmId, RemovedConditionState, ts, AddressSpaceRealm.Uns)) failedNodes++;
|
||||
removedCount++;
|
||||
}
|
||||
// Removed equipment tags / VirtualTags are plain variable nodes (no Part 9 condition to write
|
||||
// before tear-down), but they ARE real removals — count them so AddressSpaceApplyOutcome.RemovedNodes
|
||||
// is accurate on a removed-tag-only deploy, which now reaches the rebuild path below. v3 Batch 4:
|
||||
// removed raw containers/tags + UNS reference variables are likewise real removals.
|
||||
removedCount += plan.RemovedEquipmentTags.Count + plan.RemovedEquipmentVirtualTags.Count +
|
||||
// Removed VirtualTags are plain variable nodes (no Part 9 condition to write before tear-down), but
|
||||
// they ARE real removals — count them so AddressSpaceApplyOutcome.RemovedNodes is accurate on a
|
||||
// removed-vtag-only deploy. v3 Batch 4: removed raw containers/tags + UNS reference variables are
|
||||
// likewise real removals.
|
||||
removedCount += plan.RemovedEquipmentVirtualTags.Count +
|
||||
plan.RemovedRawContainers.Count + plan.RemovedRawTags.Count + plan.RemovedUnsReferenceVariables.Count;
|
||||
|
||||
var changedCount =
|
||||
plan.ChangedEquipment.Count + plan.ChangedDrivers.Count + plan.ChangedAlarms.Count +
|
||||
plan.ChangedEquipmentTags.Count +
|
||||
plan.ChangedEquipmentVirtualTags.Count +
|
||||
plan.ChangedRawContainers.Count + plan.ChangedRawTags.Count + plan.ChangedUnsReferenceVariables.Count +
|
||||
// A UNS Area/Line rename is an in-place change to an existing folder node.
|
||||
plan.RenamedFolders.Count;
|
||||
var addedCount =
|
||||
plan.AddedEquipment.Count + plan.AddedDrivers.Count + plan.AddedAlarms.Count +
|
||||
plan.AddedEquipmentTags.Count +
|
||||
plan.AddedEquipmentVirtualTags.Count +
|
||||
plan.AddedRawContainers.Count + plan.AddedRawTags.Count + plan.AddedUnsReferenceVariables.Count;
|
||||
|
||||
@@ -145,7 +141,7 @@ public sealed class AddressSpaceApplier
|
||||
// order even when an id is reused across the remove + add sets (the recreated node is a fresh
|
||||
// NodeState, correct — it is a different tag). Any remove false/throw ⇒ full-rebuild fallback.
|
||||
// • Rebuild ⇒ the default-closed safety valve for any node-affecting change (ChangedEquipment /
|
||||
// ChangedAlarms / non-surgical ChangedEquipmentTags / node-relevant ChangedEquipmentVirtualTags)
|
||||
// ChangedAlarms / node-relevant ChangedEquipmentVirtualTags / non-surgical raw-tag deltas)
|
||||
// — the classifier's rule 2, which also catches any future plan field that makes a changed
|
||||
// record unequal, so the worst outcome of a misclassification is today's full rebuild.
|
||||
// ChangedDrivers is node-inert (routes through DriverHostActor's spawn plan) — the classifier leaves
|
||||
@@ -156,7 +152,6 @@ public sealed class AddressSpaceApplier
|
||||
// afterward by the publish actor's Materialise passes + announce).
|
||||
var hasRemovePass = kind is AddressSpaceChangeKind.PureRemove or AddressSpaceChangeKind.AddRemoveMix;
|
||||
|
||||
var surgicalTagDeltas = plan.ChangedEquipmentTags.Where(AddressSpaceChangeClassifier.TagDeltaIsSurgicalEligible).ToList();
|
||||
// UNS Area / Line renames are surgically applicable (in-place DisplayName swap)
|
||||
// when no structural rebuild fires. When a rebuild DOES fire (any add/remove/structural change),
|
||||
// MaterialiseHierarchy re-creates every folder with the new display names, so the renames are
|
||||
@@ -176,7 +171,7 @@ public sealed class AddressSpaceApplier
|
||||
rebuildFailed = !SafeRebuild();
|
||||
rebuilt = true;
|
||||
}
|
||||
else if (surgicalTagDeltas.Count > 0 || renamedFolders.Count > 0 || hasRemovePass ||
|
||||
else if (renamedFolders.Count > 0 || hasRemovePass ||
|
||||
surgicalRawTagDeltas.Count > 0 || unsRefDeltas.Count > 0)
|
||||
{
|
||||
if (_sink is ISurgicalAddressSpaceSink surgical)
|
||||
@@ -194,25 +189,6 @@ public sealed class AddressSpaceApplier
|
||||
}
|
||||
if (!ok) { allApplied = false; break; }
|
||||
}
|
||||
foreach (var d in allApplied ? surgicalTagDeltas : Enumerable.Empty<AddressSpacePlan.EquipmentTagDelta>())
|
||||
{
|
||||
// Compute the node id + writable + historian + shape EXACTLY as MaterialiseEquipmentTags
|
||||
// would so the in-place update matches what a rebuild would have produced. Array tags are
|
||||
// forced read-only (same as EnsureVariable: the driver write path doesn't handle arrays).
|
||||
var nodeId = UnsEquipmentVar(d.Current.EquipmentId, d.Current.FolderPath, d.Current.Name);
|
||||
var writable = d.Current.Writable && !d.Current.IsArray;
|
||||
var historian = d.Current.IsHistorized
|
||||
? (string.IsNullOrWhiteSpace(d.Current.HistorianTagname) ? d.Current.FullName : d.Current.HistorianTagname)
|
||||
: null;
|
||||
bool ok;
|
||||
try { ok = surgical.UpdateTagAttributes(nodeId, writable, historian, d.Current.DataType, d.Current.IsArray, d.Current.ArrayLength, AddressSpaceRealm.Uns); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "AddressSpaceApplier: surgical UpdateTagAttributes threw for {Node}", nodeId);
|
||||
ok = false;
|
||||
}
|
||||
if (!ok) { allApplied = false; break; }
|
||||
}
|
||||
// v3 RAW tags — the same in-place attribute update, in the RAW realm. Adding or dropping ONE
|
||||
// UnsTagReference flips its backing raw tag to Changed purely through ReferencingEquipmentPaths,
|
||||
// and before this path existed that tore down every node on the server for an edit that creates
|
||||
@@ -243,6 +219,9 @@ public sealed class AddressSpaceApplier
|
||||
ok = false;
|
||||
}
|
||||
if (!ok) { allApplied = false; break; }
|
||||
RecordReshape(AddressSpaceRealm.Raw, d.Current.NodeId,
|
||||
d.Previous.DataType, d.Previous.IsArray, d.Previous.ArrayLength,
|
||||
d.Current.DataType, d.Current.IsArray, d.Current.ArrayLength);
|
||||
}
|
||||
// v3 UNS reference variables — TWO shapes, split on whether the delta moves the node's
|
||||
// IDENTITY (2026-08-15 review I2).
|
||||
@@ -294,6 +273,9 @@ public sealed class AddressSpaceApplier
|
||||
updated = false;
|
||||
}
|
||||
if (!updated) { allApplied = false; break; }
|
||||
RecordReshape(AddressSpaceRealm.Uns, d.Current.NodeId,
|
||||
d.Previous.DataType, d.Previous.BackingIsArray, d.Previous.BackingArrayLength,
|
||||
d.Current.DataType, d.Current.BackingIsArray, d.Current.BackingArrayLength);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -320,8 +302,8 @@ public sealed class AddressSpaceApplier
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"AddressSpaceApplier: applied plan (kind={Kind}, added={Added}, removed={Removed}, changed={Changed}, surgicalTags={Surgical}, surgicalRawTags={SurgicalRaw}, unsRefDeltas={UnsRefs}, renamedFolders={Renamed}, rebuild={Rebuild})",
|
||||
kind, addedCount, removedCount, changedCount, rebuilt ? 0 : surgicalTagDeltas.Count,
|
||||
"AddressSpaceApplier: applied plan (kind={Kind}, added={Added}, removed={Removed}, changed={Changed}, surgicalRawTags={SurgicalRaw}, unsRefDeltas={UnsRefs}, renamedFolders={Renamed}, rebuild={Rebuild})",
|
||||
kind, addedCount, removedCount, changedCount,
|
||||
rebuilt ? 0 : surgicalRawTagDeltas.Count, rebuilt ? 0 : unsRefDeltas.Count,
|
||||
rebuilt ? 0 : renamedFolders.Count, rebuilt);
|
||||
|
||||
@@ -342,10 +324,9 @@ public sealed class AddressSpaceApplier
|
||||
/// Apply a PureRemove plan by tearing down ONLY the affected nodes IN PLACE (no full
|
||||
/// rebuild). Removed equipment "own" their child tags/vtags/alarms, so any removed child whose
|
||||
/// <c>EquipmentId</c> is itself in <see cref="AddressSpacePlan.RemovedEquipment"/> is SUBSUMED by the
|
||||
/// subtree removal and skipped individually. For surviving-equipment removals: a removed value
|
||||
/// subtree removal and skipped individually. For surviving-equipment removals: a removed VirtualTag
|
||||
/// variable gets a terminal Bad <c>WriteValue</c> then <see cref="ISurgicalAddressSpaceSink.RemoveVariableNode"/>;
|
||||
/// a removed alarm-bearing tag (previously it got no terminal condition write) and a
|
||||
/// removed scripted alarm get a terminal <see cref="RemovedConditionState"/> write then
|
||||
/// a removed scripted alarm gets a terminal <see cref="RemovedConditionState"/> write then
|
||||
/// <see cref="ISurgicalAddressSpaceSink.RemoveAlarmConditionNode"/>; each removed equipment gets one
|
||||
/// <see cref="ISurgicalAddressSpaceSink.RemoveEquipmentSubtree"/>. The terminal write is what an
|
||||
/// in-flight MonitoredItem observes (a final Bad); after the node is gone, re-subscription gets
|
||||
@@ -363,25 +344,6 @@ public sealed class AddressSpaceApplier
|
||||
plan.RemovedEquipment.Select(e => e.EquipmentId), StringComparer.Ordinal);
|
||||
bool NotSubsumed(string equipmentId) => !removedEquipmentIds.Contains(equipmentId);
|
||||
|
||||
// Removed equipment tags — value variables OR alarm-bearing conditions — for SURVIVING equipment.
|
||||
foreach (var tag in plan.RemovedEquipmentTags.Where(t => NotSubsumed(t.EquipmentId)))
|
||||
{
|
||||
var nodeId = UnsEquipmentVar(tag.EquipmentId, tag.FolderPath, tag.Name);
|
||||
if (tag.Alarm is not null)
|
||||
{
|
||||
// Alarm-bearing tag → a Part 9 condition node. Terminal RemovedConditionState (today-gap
|
||||
// closed), then remove the condition in place.
|
||||
if (!SafeWriteAlarmCondition(nodeId, RemovedConditionState, ts, AddressSpaceRealm.Uns)) failedNodes++;
|
||||
if (!SafeRemoveAlarmCondition(surgical, nodeId, AddressSpaceRealm.Uns)) return false;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Plain value variable → terminal Bad so an in-flight MonitoredItem sees the removal.
|
||||
SafeWriteValue(nodeId, null, OpcUaStatus.Bad, ts, AddressSpaceRealm.Uns);
|
||||
if (!SafeRemoveVariable(surgical, nodeId, AddressSpaceRealm.Uns)) return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Removed VirtualTags (always plain value variables) for surviving equipment.
|
||||
foreach (var v in plan.RemovedEquipmentVirtualTags.Where(t => NotSubsumed(t.EquipmentId)))
|
||||
{
|
||||
@@ -550,7 +512,6 @@ public sealed class AddressSpaceApplier
|
||||
ArgumentNullException.ThrowIfNull(plan);
|
||||
|
||||
var ids = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var t in plan.AddedEquipmentTags) ids.Add(MaterialiseParent(t.EquipmentId, t.FolderPath));
|
||||
foreach (var v in plan.AddedEquipmentVirtualTags) ids.Add(MaterialiseParent(v.EquipmentId, v.FolderPath));
|
||||
foreach (var a in plan.AddedAlarms) ids.Add(a.EquipmentId);
|
||||
// v3 Batch 4 / review I5 — an added UNS reference variable is materialised directly under its
|
||||
@@ -1074,96 +1035,9 @@ public sealed class AddressSpaceApplier
|
||||
return failed;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Materialise Equipment-namespace tags from a composition snapshot.
|
||||
/// For each <see cref="EquipmentTagPlan"/>,
|
||||
/// ensure its optional <c>FolderPath</c> sub-folder under the existing equipment folder, then
|
||||
/// ensure a Variable (NodeId = <c>FullName</c>, the driver-side ref) inside it. Variables
|
||||
/// start BadWaitingForInitialData; the driver fills live values in a later milestone.
|
||||
/// Idempotent. This is a sink-based pass, NOT a reuse of <c>EquipmentNodeWalker</c>: no
|
||||
/// sink-backed <c>IAddressSpaceBuilder</c> adapter exists, and the walker re-creates the
|
||||
/// whole Area/Line/Equipment tree with browse-path NodeIds, incompatible with this path's
|
||||
/// logical-Id NodeIds and the already-materialised equipment folders. This pass adds ONLY
|
||||
/// variables (and any per-tag sub-folder); <see cref="MaterialiseHierarchy"/> owns the
|
||||
/// equipment folders and this pass never re-creates them. The sink's <c>EnsureVariable</c>
|
||||
/// takes a plain <c>string dataType</c> (not a DriverAttributeInfo).
|
||||
/// <para><b>Subset overload (2026-08-15 surgical-deploys).</b> <paramref name="only"/> narrows WHAT is
|
||||
/// materialised to a plan's added tags; <paramref name="composition"/> stays the FULL composition for
|
||||
/// context. A <c>null</c> subset means "the whole composition" (the rebuild path).</para>
|
||||
/// </summary>
|
||||
/// <param name="composition">The FULL composition — the context even when a subset is passed.</param>
|
||||
/// <param name="only">Materialise only these tags; <c>null</c> ⇒ the whole composition's.</param>
|
||||
/// <returns>The count of swallowed sink failures in this pass (0 on a clean apply) — the publish actor
|
||||
/// sums it into the degraded-apply surface (archreview 01/S-1).</returns>
|
||||
public int MaterialiseEquipmentTags(
|
||||
AddressSpaceComposition composition,
|
||||
IReadOnlyList<EquipmentTagPlan>? only = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(composition);
|
||||
var equipmentTags = only ?? composition.EquipmentTags;
|
||||
if (equipmentTags.Count == 0) return 0;
|
||||
|
||||
var failed = 0;
|
||||
// Sub-folders first — a tag's FolderPath becomes one folder UNDER its equipment folder
|
||||
// (deduped per distinct equipment+path). Tags with no FolderPath hang directly under the
|
||||
// equipment folder, which MaterialiseHierarchy already created (never re-create
|
||||
// the equipment folder here).
|
||||
var foldersCreated = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var tag in equipmentTags)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tag.FolderPath)) continue;
|
||||
var folderNodeId = UnsSubFolder(tag.EquipmentId, tag.FolderPath);
|
||||
if (!foldersCreated.Add(folderNodeId)) continue;
|
||||
if (!SafeEnsureFolder(folderNodeId, parentNodeId: tag.EquipmentId, displayName: tag.FolderPath, realm: AddressSpaceRealm.Uns)) failed++;
|
||||
}
|
||||
|
||||
// Variables: NodeId is FOLDER-SCOPED ("<parent>/<Name>"), NOT the raw FullName — a driver
|
||||
// ref (e.g. a Modbus register) is not unique across identical machines, so FullName-as-NodeId
|
||||
// would collide in the sink (EnsureVariable keys on NodeId) and drop all but one machine's
|
||||
// signal. The driver-side FullName lives on EquipmentTagPlan for the later values milestone to
|
||||
// route by. Parent is the FolderPath sub-folder when set, else the equipment folder directly.
|
||||
// Per-variable idempotency relies on the sink's own EnsureVariable.
|
||||
foreach (var tag in equipmentTags)
|
||||
{
|
||||
var parent = string.IsNullOrWhiteSpace(tag.FolderPath)
|
||||
? tag.EquipmentId
|
||||
: UnsSubFolder(tag.EquipmentId, tag.FolderPath);
|
||||
var nodeId = UnsEquipmentVar(tag.EquipmentId, tag.FolderPath, tag.Name);
|
||||
if (tag.Alarm is not null)
|
||||
{
|
||||
// Native alarm tag → a real Part 9 condition node (reuses the scripted-alarm path),
|
||||
// NOT a value variable. Parent is the sub-folder when set, else the equipment folder.
|
||||
if (!SafeMaterialiseAlarmCondition(nodeId, parent, tag.Name, tag.Alarm.AlarmType, tag.Alarm.Severity, isNative: true, realm: AddressSpaceRealm.Uns)) failed++;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Phase C: a historized tag materialises Historizing + HistoryRead. Resolve the effective
|
||||
// historian tagname HERE (default-vs-override): a null/blank override falls back to the
|
||||
// driver-side FullName; null means the tag is not historized at all.
|
||||
string? historianTagname = tag.IsHistorized
|
||||
? (string.IsNullOrWhiteSpace(tag.HistorianTagname) ? tag.FullName : tag.HistorianTagname)
|
||||
: null;
|
||||
// Retired equipment-tag path (structurally empty in v3): array tags stay forced read-only
|
||||
// here. The live array-write capability is carried on RawTagPlan /
|
||||
// UnsReferenceVariable only — see RawNodeWritable.
|
||||
var writable = tag.Writable && !tag.IsArray;
|
||||
if (!SafeEnsureVariable(nodeId, parent, tag.Name, tag.DataType, writable, AddressSpaceRealm.Uns, historianTagname, tag.IsArray, tag.ArrayLength)) failed++;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"AddressSpaceApplier: equipment tags materialised (tags={Tags}, equipment={Equipment}, subset={Subset}, failed={Failed})",
|
||||
equipmentTags.Count,
|
||||
equipmentTags.Select(t => t.EquipmentId).Distinct(StringComparer.Ordinal).Count(),
|
||||
only is not null,
|
||||
failed);
|
||||
return failed;
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Materialise Equipment-namespace VirtualTags from a composition snapshot — the VirtualTag
|
||||
/// analogue of <see cref="MaterialiseEquipmentTags"/>. For each <see cref="EquipmentVirtualTagPlan"/>,
|
||||
/// computed-signal pass. For each <see cref="EquipmentVirtualTagPlan"/>,
|
||||
/// ensure its optional <c>FolderPath</c> sub-folder under the existing equipment folder (in
|
||||
/// practice <c>FolderPath</c> is empty for VirtualTags, so this is usually a no-op), then ensure
|
||||
/// a Variable inside it. Like the tag pass, the variable's NodeId is FOLDER-SCOPED
|
||||
@@ -1285,10 +1159,53 @@ public sealed class AddressSpaceApplier
|
||||
/// <returns><c>true</c> when the variable was ensured; <c>false</c> when the sink threw.</returns>
|
||||
private bool SafeEnsureVariable(string nodeId, string? parentNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false)
|
||||
{
|
||||
try { _sink.EnsureVariable(nodeId, parentNodeId, displayName, dataType, writable, realm, historianTagname, isArray, arrayLength, refuseArrayOnScalar); return true; }
|
||||
try
|
||||
{
|
||||
_sink.EnsureVariable(nodeId, parentNodeId, displayName, dataType, writable, realm, historianTagname, isArray, arrayLength, refuseArrayOnScalar);
|
||||
_recording?.Ensured.Add((realm, nodeId));
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex) { _logger.LogWarning(ex, "AddressSpaceApplier: EnsureVariable threw for {Node}", nodeId); return false; }
|
||||
}
|
||||
|
||||
/// <summary>Records an in-place UpdateTagAttributes that changed the node's SHAPE (DataType / array-ness /
|
||||
/// array length): the node manager drops such a node's value to BadWaitingForInitialData.</summary>
|
||||
private void RecordReshape(
|
||||
AddressSpaceRealm realm, string nodeId,
|
||||
string previousDataType, bool previousIsArray, uint? previousArrayLength,
|
||||
string dataType, bool isArray, uint? arrayLength)
|
||||
{
|
||||
if (_recording is null) return;
|
||||
if (string.Equals(previousDataType, dataType, StringComparison.Ordinal)
|
||||
&& previousIsArray == isArray
|
||||
&& (!isArray || previousArrayLength == arrayLength))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_recording.Reshaped[(realm, nodeId)] = new VariableShape(dataType, isArray, arrayLength);
|
||||
}
|
||||
|
||||
/// <summary>The recording in progress, or null. See <see cref="BeginRecording"/>.</summary>
|
||||
private MaterialisationRecording? _recording;
|
||||
|
||||
/// <summary>
|
||||
/// Starts recording what the following apply and materialise passes do to VARIABLES, until
|
||||
/// <see cref="EndRecording"/>: every variable ensured, and every variable an in-place attribute update
|
||||
/// re-shaped (which resets its value). The publish actor scopes the replay of writes it deferred while a plan
|
||||
/// was in flight to these — a variable the apply kept took the live write already, and rewriting it would
|
||||
/// revert a client write made in the window. Single-caller (the publish actor's thread), like the apply.
|
||||
/// </summary>
|
||||
/// <returns>The live recording.</returns>
|
||||
public MaterialisationRecording BeginRecording()
|
||||
{
|
||||
_recording = new MaterialisationRecording();
|
||||
return _recording;
|
||||
}
|
||||
|
||||
/// <summary>Stops the recording <see cref="BeginRecording"/> started (the returned recording is kept).</summary>
|
||||
public void EndRecording() => _recording = null;
|
||||
|
||||
// The tag/vtag surgical-eligibility predicates moved to AddressSpaceChangeClassifier so the
|
||||
// whole delta classification is one pure, table-testable unit; the applier references them there (and the
|
||||
// classifier's Classify names the routing). The `with`-expression whitelist technique is preserved there
|
||||
@@ -1401,3 +1318,91 @@ public sealed record AddressSpaceApplyOutcome(
|
||||
bool RebuildCalled,
|
||||
bool RebuildFailed = false,
|
||||
int FailedNodes = 0);
|
||||
|
||||
/// <summary>What one apply did to variables (see <see cref="AddressSpaceApplier.BeginRecording"/>).</summary>
|
||||
public sealed class MaterialisationRecording
|
||||
{
|
||||
/// <summary>Every variable an <c>EnsureVariable</c> was issued for, as (realm, NodeId).</summary>
|
||||
public HashSet<(AddressSpaceRealm, string)> Ensured { get; } = new();
|
||||
|
||||
/// <summary>Every variable an in-place attribute update RE-SHAPED (its value reset), with its new shape.</summary>
|
||||
public Dictionary<(AddressSpaceRealm, string), VariableShape> Reshaped { get; } = new();
|
||||
}
|
||||
|
||||
/// <summary>A variable's value shape: its Tag.DataType name, and its array-ness / 1-D length.</summary>
|
||||
/// <param name="DataType">The Tag.DataType name ("Int32", "Float", …; unknown ⇒ BaseDataType, any value fits).</param>
|
||||
/// <param name="IsArray">Whether the node is a one-dimensional array.</param>
|
||||
/// <param name="ArrayLength">The fixed array length, when known (null / 0 ⇒ any length).</param>
|
||||
public sealed record VariableShape(string DataType, bool IsArray, uint? ArrayLength)
|
||||
{
|
||||
/// <summary>
|
||||
/// Fits <paramref name="value"/> to this shape for a write onto a re-shaped node: unchanged when it already
|
||||
/// fits, converted (invariant culture) when a lossless-enough conversion exists, otherwise refused — an
|
||||
/// old-typed value must never be served under the new DataType (the reset exists to prevent exactly that).
|
||||
/// A null value always fits (a status annotation carries none).
|
||||
/// </summary>
|
||||
/// <param name="value">The value to fit.</param>
|
||||
/// <param name="fitted">The fitted value, when this returns true.</param>
|
||||
/// <returns>True when the value fits (as is, or converted).</returns>
|
||||
public bool TryFit(object? value, out object? fitted)
|
||||
{
|
||||
fitted = null;
|
||||
if (value is null) return true;
|
||||
var element = ElementType(DataType);
|
||||
if (!IsArray)
|
||||
{
|
||||
if (value is Array) return false;
|
||||
return TryConvert(value, element, out fitted);
|
||||
}
|
||||
|
||||
if (value is not Array array || value is string) return false;
|
||||
if (ArrayLength is > 0 && array.Length != ArrayLength) return false;
|
||||
if (element is null) { fitted = value; return true; }
|
||||
var result = Array.CreateInstance(element, array.Length);
|
||||
for (var i = 0; i < array.Length; i++)
|
||||
{
|
||||
if (!TryConvert(array.GetValue(i), element, out var item) || item is null) return false;
|
||||
result.SetValue(item, i);
|
||||
}
|
||||
|
||||
fitted = result;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryConvert(object? value, Type? target, out object? converted)
|
||||
{
|
||||
converted = value;
|
||||
if (value is null || target is null || target.IsInstanceOfType(value)) return true;
|
||||
if (value is not IConvertible) return false;
|
||||
try
|
||||
{
|
||||
converted = Convert.ChangeType(value, target, System.Globalization.CultureInfo.InvariantCulture);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex) when (ex is InvalidCastException or FormatException or OverflowException)
|
||||
{
|
||||
converted = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The CLR type the node manager serves for a Tag.DataType name (its ResolveBuiltInDataType set);
|
||||
/// null ⇒ BaseDataType, where any value fits.</summary>
|
||||
private static Type? ElementType(string dataType) => dataType switch
|
||||
{
|
||||
"Boolean" => typeof(bool),
|
||||
"SByte" => typeof(sbyte),
|
||||
"Byte" => typeof(byte),
|
||||
"Int16" => typeof(short),
|
||||
"UInt16" => typeof(ushort),
|
||||
"Int32" => typeof(int),
|
||||
"UInt32" => typeof(uint),
|
||||
"Int64" => typeof(long),
|
||||
"UInt64" => typeof(ulong),
|
||||
"Float" => typeof(float),
|
||||
"Double" => typeof(double),
|
||||
"String" => typeof(string),
|
||||
"DateTime" => typeof(DateTime),
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ public enum AddressSpaceChangeKind
|
||||
/// <summary><see cref="AddressSpacePlan.IsEmpty"/> — nothing to do.</summary>
|
||||
Empty,
|
||||
|
||||
/// <summary>Only surgical-eligible changed equipment/raw tags, surgical UNS-reference deltas,
|
||||
/// <summary>Only surgical-eligible changed raw tags, surgical UNS-reference deltas,
|
||||
/// node-irrelevant vtag changes, folder renames, and/or node-inert driver deltas — the existing F10b
|
||||
/// in-place path. No adds, no removes.</summary>
|
||||
AttributeOnly,
|
||||
@@ -27,7 +27,7 @@ public enum AddressSpaceChangeKind
|
||||
AddRemoveMix,
|
||||
|
||||
/// <summary>Anything with a node-affecting change (ChangedEquipment, ChangedAlarms, ChangedRawContainers,
|
||||
/// non-surgical ChangedEquipmentTags, alarm-bearing/identity-changing ChangedRawTags, node-relevant
|
||||
/// alarm-bearing/identity-changing ChangedRawTags, node-relevant
|
||||
/// ChangedEquipmentVirtualTags) — the unclassifiable-safe default. Full rebuild.</summary>
|
||||
Rebuild,
|
||||
}
|
||||
@@ -53,8 +53,7 @@ public static class AddressSpaceChangeClassifier
|
||||
if (plan.IsEmpty) return AddressSpaceChangeKind.Empty;
|
||||
|
||||
// 2 — any node-affecting CHANGE forces a full rebuild (default-closed). ChangedEquipment /
|
||||
// ChangedAlarms / ChangedRawContainers are always node-affecting; a changed equipment tag is
|
||||
// node-affecting UNLESS it is surgical-eligible, a changed raw tag UNLESS it is
|
||||
// ChangedAlarms / ChangedRawContainers are always node-affecting; a changed raw tag UNLESS it is
|
||||
// RawTagDeltaIsSurgicalEligible, and a changed vtag UNLESS it is node-irrelevant. Evaluated BEFORE the
|
||||
// add/remove split so a non-surgical change alongside pure adds still rebuilds.
|
||||
//
|
||||
@@ -94,7 +93,6 @@ public static class AddressSpaceChangeClassifier
|
||||
plan.ChangedAlarms.Count > 0 ||
|
||||
plan.ChangedRawContainers.Count > 0 ||
|
||||
plan.ChangedRawTags.Any(d => !RawTagDeltaIsSurgicalEligible(d)) ||
|
||||
plan.ChangedEquipmentTags.Any(d => !TagDeltaIsSurgicalEligible(d)) ||
|
||||
plan.ChangedEquipmentVirtualTags.Any(d => !VtagDeltaIsNodeIrrelevant(d)))
|
||||
{
|
||||
return AddressSpaceChangeKind.Rebuild;
|
||||
@@ -105,12 +103,12 @@ public static class AddressSpaceChangeClassifier
|
||||
// add/remove split below and leave a driver-only plan classified AttributeOnly.
|
||||
var hasAdds =
|
||||
plan.AddedEquipment.Count + plan.AddedAlarms.Count +
|
||||
plan.AddedEquipmentTags.Count + plan.AddedEquipmentVirtualTags.Count +
|
||||
plan.AddedEquipmentVirtualTags.Count +
|
||||
plan.AddedRawContainers.Count + plan.AddedRawTags.Count +
|
||||
plan.AddedUnsReferenceVariables.Count > 0;
|
||||
var hasRemoves =
|
||||
plan.RemovedEquipment.Count + plan.RemovedAlarms.Count +
|
||||
plan.RemovedEquipmentTags.Count + plan.RemovedEquipmentVirtualTags.Count +
|
||||
plan.RemovedEquipmentVirtualTags.Count +
|
||||
plan.RemovedRawContainers.Count + plan.RemovedRawTags.Count +
|
||||
plan.RemovedUnsReferenceVariables.Count > 0;
|
||||
|
||||
@@ -141,36 +139,6 @@ public static class AddressSpaceChangeClassifier
|
||||
Historize = d.Current.Historize,
|
||||
}).Equals(d.Current);
|
||||
|
||||
// F10b: a CHANGED equipment tag whose ONLY differences are Writable / IsHistorized /
|
||||
// HistorianTagname / DataType / IsArray / ArrayLength (a plain value variable — no alarm condition node)
|
||||
// can be updated IN PLACE on the existing node via ISurgicalAddressSpaceSink.UpdateTagAttributes,
|
||||
// avoiding a full rebuild (preserving subscriptions). The presentation-shape fields (DataType / IsArray /
|
||||
// ArrayLength) join the whitelist now that the surgical sink swaps DataType + ValueRank + ArrayDimensions
|
||||
// in place (and raises a GeneralModelChangeEvent). FullName / DriverInstanceId / Name / identity / alarm
|
||||
// differences still fall through to a rebuild — FullName/DriverInstanceId re-route the node to a different
|
||||
// driver point, Name re-derives the NodeId, and an alarm flip turns the node into a Part 9 condition. The
|
||||
// override-unequal default also covers any future field.
|
||||
// REACH (live-verified): the shape path only fires for drivers whose TagConfig carries a stable
|
||||
// top-level "FullName" (Galaxy = tag_name.AttributeName; OpcUaClient = the node id) — there a DataType/array
|
||||
// edit leaves FullName untouched ⇒ surgical. For structured-TagConfig protocol drivers (Modbus/S7/AbCip/…)
|
||||
// TagConfigIntent.Parse falls back to the RAW TagConfig blob as FullName, so a DataType/
|
||||
// array edit also mutates that blob ⇒ FullName differs ⇒ this returns false ⇒ full rebuild. That is the
|
||||
// correct safe default (a protocol driver's subscription needs re-spawning for a new shape anyway).
|
||||
/// <summary>Whether a changed equipment tag can be updated in place on its existing node instead of rebuilding.</summary>
|
||||
/// <param name="d">The equipment-tag delta to classify.</param>
|
||||
/// <returns><see langword="true"/> when the tag has no alarm and differs only in the in-place-updatable attributes; otherwise <see langword="false"/>.</returns>
|
||||
internal static bool TagDeltaIsSurgicalEligible(AddressSpacePlan.EquipmentTagDelta d) =>
|
||||
d.Previous.Alarm is null && d.Current.Alarm is null &&
|
||||
(d.Previous with
|
||||
{
|
||||
Writable = d.Current.Writable,
|
||||
IsHistorized = d.Current.IsHistorized,
|
||||
HistorianTagname = d.Current.HistorianTagname,
|
||||
DataType = d.Current.DataType,
|
||||
IsArray = d.Current.IsArray,
|
||||
ArrayLength = d.Current.ArrayLength,
|
||||
}).Equals(d.Current);
|
||||
|
||||
// v3 surgical raw-tag eligibility: a NON-alarm raw tag whose only differences are the in-place-updatable
|
||||
// attributes (Writable / IsHistorized / HistorianTagname / DataType / IsArray / ArrayLength / ArrayWritable)
|
||||
// and/or its ReferencingEquipmentPaths. ArrayWritable only feeds the node's writability — the
|
||||
|
||||
@@ -23,28 +23,15 @@ public sealed record AddressSpacePlan(
|
||||
IReadOnlyList<ScriptedAlarmPlan> RemovedAlarms,
|
||||
IReadOnlyList<AddressSpacePlan.AlarmDelta> ChangedAlarms)
|
||||
{
|
||||
/// <summary>
|
||||
/// Equipment-namespace tag diff sets, keyed by <see cref="EquipmentTagPlan.TagId"/>. Added as
|
||||
/// init-only members (defaulting empty) rather than positional parameters so existing
|
||||
/// <c>AddressSpacePlan</c> construction sites compile unchanged — consistent with how
|
||||
/// <see cref="AddressSpaceComposition.EquipmentTags"/> was added. Without these, an
|
||||
/// incremental deploy that changes ONLY equipment tags produced an empty plan and
|
||||
/// <c>OpcUaPublishActor.HandleRebuild</c> short-circuited before materialising them.
|
||||
/// </summary>
|
||||
public IReadOnlyList<EquipmentTagPlan> AddedEquipmentTags { get; init; } = Array.Empty<EquipmentTagPlan>();
|
||||
/// <inheritdoc cref="AddedEquipmentTags"/>
|
||||
public IReadOnlyList<EquipmentTagPlan> RemovedEquipmentTags { get; init; } = Array.Empty<EquipmentTagPlan>();
|
||||
/// <inheritdoc cref="AddedEquipmentTags"/>
|
||||
public IReadOnlyList<EquipmentTagDelta> ChangedEquipmentTags { get; init; } = Array.Empty<EquipmentTagDelta>();
|
||||
|
||||
/// <summary>
|
||||
/// Equipment-namespace VirtualTag diff sets, keyed by <see cref="EquipmentVirtualTagPlan.VirtualTagId"/>.
|
||||
/// The value-side analogue of <see cref="AddedEquipmentTags"/>: a VirtualTag carries an
|
||||
/// A VirtualTag carries an
|
||||
/// <c>Expression</c> evaluated over <c>DependencyRefs</c>, so a deploy that changes ONLY
|
||||
/// VirtualTags (e.g. a new computed signal or an edited formula) must still produce a
|
||||
/// non-empty plan and drive a rebuild — without these the diff was blind to VirtualTags and
|
||||
/// such a deploy silently no-op'd. Added as init-only members (defaulting empty) for the same
|
||||
/// compile-compatibility reason as <see cref="AddedEquipmentTags"/>.
|
||||
/// such a deploy silently no-op'd. Added as init-only members (defaulting empty) so existing
|
||||
/// construction sites compile unchanged. (The retired v3 equipment-tag diff sets — review 01 U2 — are gone:
|
||||
/// device values live in the Raw subtree and reach equipment as <see cref="UnsReferenceVariable"/>s.)
|
||||
/// </summary>
|
||||
public IReadOnlyList<EquipmentVirtualTagPlan> AddedEquipmentVirtualTags { get; init; } = Array.Empty<EquipmentVirtualTagPlan>();
|
||||
/// <inheritdoc cref="AddedEquipmentVirtualTags"/>
|
||||
@@ -65,7 +52,7 @@ public sealed record AddressSpacePlan(
|
||||
/// uses), preserving every client's subscriptions; a sink lacking the surgical capability or a
|
||||
/// missing folder falls back to a full rebuild. Added as an init-only member (defaulting empty) so
|
||||
/// every existing <c>AddressSpacePlan</c> construction site compiles unchanged — consistent with
|
||||
/// the EquipmentTag / EquipmentVirtualTag diff sets above.
|
||||
/// the EquipmentVirtualTag diff sets above.
|
||||
/// </summary>
|
||||
public IReadOnlyList<FolderRename> RenamedFolders { get; init; } = Array.Empty<FolderRename>();
|
||||
|
||||
@@ -74,7 +61,7 @@ public sealed record AddressSpacePlan(
|
||||
/// keyed by <see cref="RawContainerNode.NodeId"/> (the RawPath). A rename of any container changes
|
||||
/// its RawPath (and every descendant's), so it manifests as remove(old)+add(new) — an OPC UA NodeId
|
||||
/// is immutable identity. Init-only, defaulting empty, so existing construction sites compile
|
||||
/// unchanged (consistent with the EquipmentTag diff sets above).
|
||||
/// unchanged.
|
||||
/// </summary>
|
||||
public IReadOnlyList<RawContainerNode> AddedRawContainers { get; init; } = Array.Empty<RawContainerNode>();
|
||||
/// <inheritdoc cref="AddedRawContainers"/>
|
||||
@@ -114,7 +101,6 @@ public sealed record AddressSpacePlan(
|
||||
AddedEquipment.Count == 0 && RemovedEquipment.Count == 0 && ChangedEquipment.Count == 0 &&
|
||||
AddedDrivers.Count == 0 && RemovedDrivers.Count == 0 && ChangedDrivers.Count == 0 &&
|
||||
AddedAlarms.Count == 0 && RemovedAlarms.Count == 0 && ChangedAlarms.Count == 0 &&
|
||||
AddedEquipmentTags.Count == 0 && RemovedEquipmentTags.Count == 0 && ChangedEquipmentTags.Count == 0 &&
|
||||
AddedEquipmentVirtualTags.Count == 0 && RemovedEquipmentVirtualTags.Count == 0 && ChangedEquipmentVirtualTags.Count == 0 &&
|
||||
AddedRawContainers.Count == 0 && RemovedRawContainers.Count == 0 && ChangedRawContainers.Count == 0 &&
|
||||
AddedRawTags.Count == 0 && RemovedRawTags.Count == 0 && ChangedRawTags.Count == 0 &&
|
||||
@@ -124,7 +110,6 @@ public sealed record AddressSpacePlan(
|
||||
public sealed record EquipmentDelta(EquipmentNode Previous, EquipmentNode Current);
|
||||
public sealed record DriverDelta(DriverInstancePlan Previous, DriverInstancePlan Current);
|
||||
public sealed record AlarmDelta(ScriptedAlarmPlan Previous, ScriptedAlarmPlan Current);
|
||||
public sealed record EquipmentTagDelta(EquipmentTagPlan Previous, EquipmentTagPlan Current);
|
||||
public sealed record EquipmentVirtualTagDelta(EquipmentVirtualTagPlan Previous, EquipmentVirtualTagPlan Current);
|
||||
|
||||
/// <summary>A Raw container node present in both snapshots (same RawPath) with ≥1 differing field.</summary>
|
||||
@@ -177,12 +162,7 @@ public static class AddressSpacePlanner
|
||||
a => a.ScriptedAlarmId,
|
||||
(a, b) => new AddressSpacePlan.AlarmDelta(a, b));
|
||||
|
||||
var (addedEqTags, removedEqTags, changedEqTags) = DiffById(
|
||||
previous.EquipmentTags, next.EquipmentTags,
|
||||
t => t.TagId,
|
||||
(a, b) => new AddressSpacePlan.EquipmentTagDelta(a, b));
|
||||
|
||||
// VirtualTags diff by VirtualTagId, mirroring the EquipmentTags pass. EquipmentVirtualTagPlan
|
||||
// VirtualTags diff by VirtualTagId. EquipmentVirtualTagPlan
|
||||
// overrides record equality to compare ALL fields by value — scalars (Expression/DataType/
|
||||
// Name/FolderPath) plus DependencyRefs element-wise (SequenceEqual). So a no-op redeploy (fresh
|
||||
// list instances, identical contents) correctly diffs to empty; only a real content change is
|
||||
@@ -232,9 +212,6 @@ public static class AddressSpacePlanner
|
||||
addedDrv, removedDrv, changedDrv,
|
||||
addedAlarm, removedAlarm, changedAlarm)
|
||||
{
|
||||
AddedEquipmentTags = addedEqTags,
|
||||
RemovedEquipmentTags = removedEqTags,
|
||||
ChangedEquipmentTags = changedEqTags,
|
||||
AddedEquipmentVirtualTags = addedVTags,
|
||||
RemovedEquipmentVirtualTags = removedVTags,
|
||||
ChangedEquipmentVirtualTags = changedVTags,
|
||||
|
||||
@@ -105,6 +105,55 @@ public sealed class OpcUaApplicationHostOptions
|
||||
/// is always element 0.
|
||||
/// </summary>
|
||||
public IList<string> PeerApplicationUris { get; set; } = new List<string>();
|
||||
|
||||
/// <summary>
|
||||
/// SDK request-processing and transport quotas (<c>OpcUa:Quotas:*</c>, review 01 P6). The defaults are
|
||||
/// exactly the values that used to be hard-coded (5 / 100 / 200 request threads and queue) and the SDK's
|
||||
/// own transport defaults, so an absent section changes nothing.
|
||||
/// </summary>
|
||||
public OpcUaQuotaOptions Quotas { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The <c>OpcUa:Quotas</c> section: the SDK's request-thread / queue ceilings and its transport quotas.
|
||||
/// Request quotas carry the former hard-coded defaults; a transport quota left <c>null</c> keeps the SDK
|
||||
/// default (<see cref="TransportQuotas"/>'s constructor values).
|
||||
/// </summary>
|
||||
public sealed class OpcUaQuotaOptions
|
||||
{
|
||||
/// <summary>Minimum SDK request-processing threads (default 5).</summary>
|
||||
public int MinRequestThreadCount { get; set; } = 5;
|
||||
|
||||
/// <summary>Maximum SDK request-processing threads (default 100).</summary>
|
||||
public int MaxRequestThreadCount { get; set; } = 100;
|
||||
|
||||
/// <summary>Requests the SDK queues before refusing with <c>BadTooManyOperations</c> (default 200) — the
|
||||
/// burst ceiling review 01 P6 flagged.</summary>
|
||||
public int MaxQueuedRequestCount { get; set; } = 200;
|
||||
|
||||
/// <summary>Per-operation timeout in milliseconds; <c>null</c> keeps the SDK default.</summary>
|
||||
public int? OperationTimeoutMs { get; set; }
|
||||
|
||||
/// <summary>Largest string the transport decodes; <c>null</c> keeps the SDK default.</summary>
|
||||
public int? MaxStringLength { get; set; }
|
||||
|
||||
/// <summary>Largest byte string the transport decodes; <c>null</c> keeps the SDK default.</summary>
|
||||
public int? MaxByteStringLength { get; set; }
|
||||
|
||||
/// <summary>Largest array the transport decodes; <c>null</c> keeps the SDK default.</summary>
|
||||
public int? MaxArrayLength { get; set; }
|
||||
|
||||
/// <summary>Largest message the transport accepts, in bytes; <c>null</c> keeps the SDK default.</summary>
|
||||
public int? MaxMessageSize { get; set; }
|
||||
|
||||
/// <summary>Transport buffer size in bytes; <c>null</c> keeps the SDK default.</summary>
|
||||
public int? MaxBufferSize { get; set; }
|
||||
|
||||
/// <summary>Secure-channel lifetime in milliseconds; <c>null</c> keeps the SDK default.</summary>
|
||||
public int? ChannelLifetimeMs { get; set; }
|
||||
|
||||
/// <summary>Security-token lifetime in milliseconds; <c>null</c> keeps the SDK default.</summary>
|
||||
public int? SecurityTokenLifetimeMs { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -435,10 +484,8 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
||||
var serverConfig = new ServerConfiguration
|
||||
{
|
||||
BaseAddresses = { $"opc.tcp://{_options.PublicHostname}:{_options.OpcUaPort}/OtOpcUa" },
|
||||
MinRequestThreadCount = 5,
|
||||
MaxRequestThreadCount = 100,
|
||||
MaxQueuedRequestCount = 200,
|
||||
};
|
||||
ApplyRequestQuotas(serverConfig, _options.Quotas);
|
||||
|
||||
foreach (var policy in BuildSecurityPolicies(_options.EnabledSecurityProfiles))
|
||||
{
|
||||
@@ -469,7 +516,7 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
||||
RejectedCertificateStore = new CertificateTrustList { StoreType = "Directory", StorePath = Path.Combine(_options.PkiStoreRoot, "rejected") },
|
||||
AutoAcceptUntrustedCertificates = _options.AutoAcceptUntrustedClientCertificates,
|
||||
},
|
||||
TransportQuotas = new TransportQuotas(),
|
||||
TransportQuotas = BuildTransportQuotas(_options.Quotas),
|
||||
ClientConfiguration = new ClientConfiguration(),
|
||||
TraceConfiguration = new TraceConfiguration(),
|
||||
};
|
||||
@@ -479,6 +526,40 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
||||
return config;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies the request-processing quotas (<c>OpcUa:Quotas:*</c>, review 01 P6 — they were hard-coded at
|
||||
/// 5 / 100 / 200) to the SDK server configuration.
|
||||
/// </summary>
|
||||
/// <param name="serverConfig">The configuration to set.</param>
|
||||
/// <param name="quotas">The configured quotas.</param>
|
||||
internal static void ApplyRequestQuotas(ServerConfiguration serverConfig, OpcUaQuotaOptions quotas)
|
||||
{
|
||||
serverConfig.MinRequestThreadCount = quotas.MinRequestThreadCount;
|
||||
serverConfig.MaxRequestThreadCount = quotas.MaxRequestThreadCount;
|
||||
serverConfig.MaxQueuedRequestCount = quotas.MaxQueuedRequestCount;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the SDK transport quotas from <c>OpcUa:Quotas:*</c>: every member starts at the SDK default and
|
||||
/// only a configured (non-null) value replaces it, so an absent section is byte-identical to the former
|
||||
/// <c>new TransportQuotas()</c>.
|
||||
/// </summary>
|
||||
/// <param name="quotas">The configured quotas.</param>
|
||||
/// <returns>The transport quotas to install.</returns>
|
||||
internal static TransportQuotas BuildTransportQuotas(OpcUaQuotaOptions quotas)
|
||||
{
|
||||
var t = new TransportQuotas();
|
||||
if (quotas.OperationTimeoutMs is { } operationTimeout) t.OperationTimeout = operationTimeout;
|
||||
if (quotas.MaxStringLength is { } maxString) t.MaxStringLength = maxString;
|
||||
if (quotas.MaxByteStringLength is { } maxByteString) t.MaxByteStringLength = maxByteString;
|
||||
if (quotas.MaxArrayLength is { } maxArray) t.MaxArrayLength = maxArray;
|
||||
if (quotas.MaxMessageSize is { } maxMessage) t.MaxMessageSize = maxMessage;
|
||||
if (quotas.MaxBufferSize is { } maxBuffer) t.MaxBufferSize = maxBuffer;
|
||||
if (quotas.ChannelLifetimeMs is { } channelLifetime) t.ChannelLifetime = channelLifetime;
|
||||
if (quotas.SecurityTokenLifetimeMs is { } tokenLifetime) t.SecurityTokenLifetime = tokenLifetime;
|
||||
return t;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps each configured <see cref="OpcUaSecurityProfile"/> to a SDK
|
||||
/// <see cref="ServerSecurityPolicy"/>. Duplicate profiles are silently de-duped because
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -86,19 +86,17 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
|
||||
/// <param name="Realm">The namespace realm the condition lives in (<see cref="AddressSpaceRealm.Raw"/> for native).</param>
|
||||
public sealed record AlarmQualityUpdate(string AlarmNodeId, uint StatusCode, DateTime TimestampUtc, AddressSpaceRealm Realm);
|
||||
/// <summary>
|
||||
/// Triggers an address-space rebuild. <paramref name="DeploymentId"/> is the deployment
|
||||
/// just applied by the host; the rebuild loads THAT artifact so materialisation matches the
|
||||
/// applied config + the SubscribeBulk pass. It is null only for legacy/dev callers, which
|
||||
/// fall back to the latest sealed deployment (lags a not-yet-sealed apply by one revision).
|
||||
/// Triggers an address-space rebuild of the artifact carried in <paramref name="Artifact"/>.
|
||||
/// <paramref name="DeploymentId"/> is the deployment just applied by the host (echoed in the reply and
|
||||
/// the logs). Review 02 P5: the ConfigDb fallback for a message carrying no bytes is gone — it had no
|
||||
/// production producer and would have blocked the pinned OPC UA thread on central SQL — so a message
|
||||
/// without bytes is "no answer" and holds the served address space.
|
||||
/// </summary>
|
||||
/// <param name="Correlation">Correlation id for tracing this rebuild.</param>
|
||||
/// <param name="DeploymentId">The applied deployment whose artifact to materialise, or null for the latest-sealed fallback.</param>
|
||||
/// <param name="DeploymentId">The applied deployment whose artifact this is, or null for a legacy caller.</param>
|
||||
/// <param name="Artifact">
|
||||
/// The artifact bytes already in hand, used INSTEAD of loading them from the ConfigDb. This is
|
||||
/// what makes boot-from-cache actually serve its address space: on a central-SQL outage the
|
||||
/// host has the cached blob but <see cref="DeploymentId"/> alone would drive a ConfigDb read
|
||||
/// that cannot succeed, leaving the rebuild a no-op and clients browsing an empty server. Null
|
||||
/// on the normal path, where loading from the ConfigDb by id is correct.
|
||||
/// The artifact bytes. Every producer (the deploy path, boot-from-cache, FetchAndCache) has them in
|
||||
/// hand; null or empty is treated as no answer.
|
||||
/// </param>
|
||||
/// <param name="ReportOutcome">
|
||||
/// when <see langword="true"/>, the actor replies to the sender with a
|
||||
@@ -128,11 +126,110 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
|
||||
|
||||
public sealed record ServiceLevelChanged(byte ServiceLevel);
|
||||
|
||||
/// <summary>
|
||||
/// Review 02 P5 — a rebuild whose artifact parse, cluster-scoped composition and plan were computed
|
||||
/// OFF the pinned OPC UA thread (a <c>Task.Run</c> piped back to <c>Self</c>), ready for the sink passes,
|
||||
/// which are the only part that must run on this actor's thread. Carries the <see cref="Request"/> and
|
||||
/// the original <see cref="Requester"/> so the <see cref="RebuildCompleted"/> reply reaches whoever asked.
|
||||
/// </summary>
|
||||
/// <para><see cref="Incarnation"/> stamps the actor instance that started the plan: a <c>PipeTo(Self)</c>
|
||||
/// outlives a restart (the ActorRef survives it), and a plan computed against a previous instance's
|
||||
/// <see cref="_lastApplied"/> must not be applied by the next one. <see cref="AuthorityError"/> carries a
|
||||
/// data-plane-authority parse failure, which never fails the address-space apply (review fix I2).</para>
|
||||
/// </summary>
|
||||
private sealed record PlanReady(
|
||||
object Incarnation, RebuildAddressSpace Request, IActorRef Requester, byte[] Artifact,
|
||||
DataPlaneAuthorityProjection? Authority, Exception? AuthorityError,
|
||||
AddressSpaceComposition Composition, AddressSpacePlan Plan, IReadOnlyList<string> Inconsistencies);
|
||||
|
||||
/// <summary>The off-thread parse / plan step threw (unreadable bytes, a composer fault).</summary>
|
||||
private sealed record PlanFailed(object Incarnation, RebuildAddressSpace Request, IActorRef Requester, Exception Error);
|
||||
|
||||
/// <summary>Test seams for the off-thread plan step. Production passes none.</summary>
|
||||
internal sealed class TestHooks
|
||||
{
|
||||
/// <summary>Runs on the thread pool at the start of every plan step (a test blocks it to hold a plan in flight).</summary>
|
||||
internal Action? BeforePlan { get; init; }
|
||||
|
||||
/// <summary>Replaces <see cref="ParsedDeploymentArtifact.ParseDataPlaneAuthority"/> (a test makes it throw).</summary>
|
||||
internal Func<ParsedDeploymentArtifact, string, DataPlaneAuthorityProjection>? ParseAuthority { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>The default bound on <see cref="_deferred"/>: distinct (realm, id) keys held while a plan is in flight.</summary>
|
||||
internal const int DefaultDeferredWriteCapacity = 250_000;
|
||||
|
||||
/// <summary>Identity of THIS actor instance (see <see cref="PlanReady.Incarnation"/>).</summary>
|
||||
private readonly object _incarnation = new();
|
||||
private readonly TestHooks? _hooks;
|
||||
|
||||
/// <summary>
|
||||
/// Review fix C1 — the latest value / status / alarm write per (realm, id) received while a rebuild's plan is
|
||||
/// in flight (or queued), replayed through the sink after each apply. Writes are non-creating (review 01 L1),
|
||||
/// so a driver's first publish for a node the in-flight plan ADDS used to be skipped — and a change-driven
|
||||
/// driver (or a poll group whose last-value cache already holds it) never sends it again, leaving the node
|
||||
/// <c>BadWaitingForInitialData</c> indefinitely. On a full rebuild the same holds for every node: the
|
||||
/// re-subscribe's first values land on old nodes the apply then discards.
|
||||
/// <para>Writes are still applied IMMEDIATELY (existing nodes keep updating during the plan); the buffer
|
||||
/// only remembers each key's latest so the replay can land it on a node the apply created. Replaying onto a
|
||||
/// node that already holds it is inert: the same value/status/timestamp is filtered by every monitored
|
||||
/// item's data-change filter, and an alarm write is delta-gated against the node's current state. That is
|
||||
/// also why alarms coalesce to the latest state per id rather than replaying every transition: replaying an
|
||||
/// older transition onto a live condition would move it backwards and fire spurious events.</para>
|
||||
/// </summary>
|
||||
private readonly DeferredWrites _deferred;
|
||||
|
||||
/// <summary>
|
||||
/// Review fix round 2 (N2 / M1) — WHICH deferred writes the next replay may land: only the ones whose live write
|
||||
/// could not have, i.e. onto nodes the apply just CREATED. A node the apply kept took the live write already,
|
||||
/// and replaying it is not inert: a client changed it in the window (an SDK-applied Acknowledge / Confirm /
|
||||
/// Shelve on a condition, an optimistic Write on a variable) and the replay would roll that back and fire
|
||||
/// spurious Part 9 events. Set by <see cref="ApplyPlan"/>, consumed by <see cref="FinishRebuild"/>.
|
||||
/// <list type="bullet">
|
||||
/// <item>Variables: after a FULL rebuild every one (each was recreated, its live write landed on the
|
||||
/// discarded node); otherwise exactly those the apply asked the sink to ensure (the applier records them).</item>
|
||||
/// <item>Variables an in-place attribute update RE-SHAPED (review N4): the node manager reset their value to
|
||||
/// BadWaitingForInitialData, so the window's value is replayed — but only once fitted to the new shape
|
||||
/// (<see cref="VariableShape.TryFit"/>); a value that does not fit is dropped and the node left waiting.
|
||||
/// The raw node and its UNS mirror are re-shaped by the same edit, so both get the same treatment.</item>
|
||||
/// <item>Conditions: those in the applied composition but not the previous one; after a full rebuild ALSO
|
||||
/// every kept SCRIPTED condition (review N3) — the node manager carries only NATIVE conditions' state over
|
||||
/// a rebuild, so a scripted one is recreated fresh and needs the window's state. A kept native condition
|
||||
/// is restored from the carry-over (which already holds the live write and any SDK ack), so it is not
|
||||
/// replayed. Accepted cost on the scripted path: an SDK ack on the discarded instance in the window is
|
||||
/// lost with it; the engine's own ack emission (a scripted ack routes to the engine) restores it.</item>
|
||||
/// <item>Known gaps (degraded path only, review m1 / m2): a degraded non-rebuild apply does not advance
|
||||
/// <c>_lastApplied</c>, so the next apply counts its conditions as added again and may replay onto them;
|
||||
/// and the empty-plan repair of a degraded composition scopes no condition (the composition is
|
||||
/// unchanged), so a condition the repair newly places gets no window state — writes to it between the
|
||||
/// two applies were already dropped before this replay existed.</item>
|
||||
/// </list>
|
||||
/// (Precise "did the live write land" would need the sink to report it; the production sink chain runs through
|
||||
/// Commons' DeferredAddressSpaceSink, outside this lane — follow-up F7.)
|
||||
/// </summary>
|
||||
private ReplayScope _replayScope = ReplayScope.Nothing;
|
||||
|
||||
private sealed record ReplayScope(
|
||||
bool AllVariables,
|
||||
IReadOnlySet<(AddressSpaceRealm, string)> Variables,
|
||||
IReadOnlyDictionary<(AddressSpaceRealm, string), VariableShape> Reshaped,
|
||||
IReadOnlySet<(AddressSpaceRealm, string)> Conditions)
|
||||
{
|
||||
private static readonly IReadOnlySet<(AddressSpaceRealm, string)> None = new HashSet<(AddressSpaceRealm, string)>();
|
||||
public static readonly ReplayScope Nothing =
|
||||
new(false, None, new Dictionary<(AddressSpaceRealm, string), VariableShape>(), None);
|
||||
}
|
||||
|
||||
/// <summary>True while a rebuild's parse / plan step is running off-thread. Rebuilds are applied strictly in
|
||||
/// arrival order — the plan is a diff against <see cref="_lastApplied"/>, which only the apply advances — so a
|
||||
/// rebuild arriving meanwhile waits in <see cref="_pendingRebuilds"/>; value, status and alarm writes do not — they
|
||||
/// are applied at once AND remembered in <see cref="_deferred"/> for replay after the apply.</summary>
|
||||
private bool _planInFlight;
|
||||
private readonly Queue<(RebuildAddressSpace Request, IActorRef Requester)> _pendingRebuilds = new();
|
||||
|
||||
private readonly IOpcUaAddressSpaceSink _sink;
|
||||
private readonly IServiceLevelPublisher _serviceLevel;
|
||||
private readonly bool _subscribeRedundancyTopic;
|
||||
private readonly NodeId? _localNode;
|
||||
private readonly IDbContextFactory<OtOpcUaConfigDbContext>? _dbFactory;
|
||||
private readonly AddressSpaceApplier? _applier;
|
||||
/// <summary>Receives the API keys + ACL grants carried by each applied artifact, so
|
||||
/// OPC UA session activation can authenticate. Null when nothing consumes it (tests, and any node
|
||||
@@ -184,9 +281,9 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
|
||||
|
||||
/// <summary>Production Props — pins the OPC UA dispatcher + subscribes to the
|
||||
/// <c>redundancy-state</c> DPS topic so cluster transitions drive the local ServiceLevel
|
||||
/// publish path. When <paramref name="dbFactory"/> + <paramref name="applier"/> are supplied,
|
||||
/// <see cref="RebuildAddressSpace"/> reads the latest deployment artifact + drives the
|
||||
/// applier through the sink. When <paramref name="dbHealthProbe"/> is supplied the local
|
||||
/// publish path. When <paramref name="applier"/> is supplied, <see cref="RebuildAddressSpace"/>
|
||||
/// diffs + applies the artifact bytes it carries through the sink (<paramref name="dbFactory"/> is
|
||||
/// accepted for wiring compatibility only — review 02 P5 removed the ConfigDb fallback). When <paramref name="dbHealthProbe"/> is supplied the local
|
||||
/// ServiceLevel is computed via <see cref="ServiceLevelCalculator"/> from real DB-health +
|
||||
/// staleness + role-leader inputs; otherwise the legacy role-only switch is used.</summary>
|
||||
/// <param name="sink">The OPC UA address space sink.</param>
|
||||
@@ -293,6 +390,28 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
|
||||
dataPlaneAuthority,
|
||||
utcNow));
|
||||
|
||||
/// <summary>Test-only Props exposing the deferred-write bound and the plan-step seams.</summary>
|
||||
internal static Props PropsForTests(
|
||||
IOpcUaAddressSpaceSink sink,
|
||||
AddressSpaceApplier? applier,
|
||||
TestHooks? hooks,
|
||||
int deferredWriteCapacity = DefaultDeferredWriteCapacity,
|
||||
NodeId? localNode = null,
|
||||
IDataPlaneAuthoritySink? dataPlaneAuthority = null) =>
|
||||
// CreateBy, not Create(expression): Akka's expression path activates through a PUBLIC constructor, and the
|
||||
// seam-carrying constructor is internal so the test seams stay off the public API (review M3).
|
||||
Akka.Actor.Props.CreateBy(new TestProducer(() => new OpcUaPublishActor(
|
||||
sink, NullServiceLevelPublisher.Instance, false, localNode, null, applier, null, null, null, null,
|
||||
false, dataPlaneAuthority, null, deferredWriteCapacity, hooks)));
|
||||
|
||||
/// <summary>Produces the actor through its internal (seam-carrying) constructor, for <see cref="PropsForTests(IOpcUaAddressSpaceSink, AddressSpaceApplier?, TestHooks?, int, NodeId?, IDataPlaneAuthoritySink?)"/>.</summary>
|
||||
private sealed class TestProducer(Func<OpcUaPublishActor> create) : IIndirectActorProducer
|
||||
{
|
||||
public Type ActorType => typeof(OpcUaPublishActor);
|
||||
public ActorBase Produce() => create();
|
||||
public void Release(ActorBase actor) { }
|
||||
}
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="OpcUaPublishActor"/> class.</summary>
|
||||
/// <param name="sink">The OPC UA address space sink.</param>
|
||||
/// <param name="serviceLevel">The service level publisher.</param>
|
||||
@@ -329,13 +448,41 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
|
||||
bool dbLess = false,
|
||||
IDataPlaneAuthoritySink? dataPlaneAuthority = null,
|
||||
Func<DateTime>? utcNow = null)
|
||||
: this(sink, serviceLevel, subscribeRedundancyTopic, localNode, dbFactory, applier, dbHealthProbe, staleWindow,
|
||||
probeFreshnessWindow, healthTickInterval, dbLess, dataPlaneAuthority, utcNow, DefaultDeferredWriteCapacity, hooks: null)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>The full constructor: also the deferred-write bound and the test seams (internal — tests only).</summary>
|
||||
/// <param name="deferredWriteCapacity">The bound on distinct nodes whose writes are held for replay while a rebuild plan is in flight.</param>
|
||||
/// <param name="hooks">Test seams for the off-thread plan step; null in production.</param>
|
||||
internal OpcUaPublishActor(
|
||||
IOpcUaAddressSpaceSink sink,
|
||||
IServiceLevelPublisher serviceLevel,
|
||||
bool subscribeRedundancyTopic,
|
||||
NodeId? localNode,
|
||||
IDbContextFactory<OtOpcUaConfigDbContext>? dbFactory,
|
||||
AddressSpaceApplier? applier,
|
||||
IActorRef? dbHealthProbe,
|
||||
TimeSpan? staleWindow,
|
||||
TimeSpan? probeFreshnessWindow,
|
||||
TimeSpan? healthTickInterval,
|
||||
bool dbLess,
|
||||
IDataPlaneAuthoritySink? dataPlaneAuthority,
|
||||
Func<DateTime>? utcNow,
|
||||
int deferredWriteCapacity,
|
||||
TestHooks? hooks)
|
||||
{
|
||||
_deferred = new DeferredWrites(Math.Max(1, deferredWriteCapacity));
|
||||
_hooks = hooks;
|
||||
_sink = sink;
|
||||
_dataPlaneAuthority = dataPlaneAuthority;
|
||||
_serviceLevel = serviceLevel;
|
||||
_subscribeRedundancyTopic = subscribeRedundancyTopic;
|
||||
_localNode = localNode;
|
||||
_dbFactory = dbFactory;
|
||||
// dbFactory is accepted for wiring compatibility only (review 02 P5): the bytes-less ConfigDb read the
|
||||
// rebuild used to fall back to is gone — every producer carries the artifact bytes in the message.
|
||||
_ = dbFactory;
|
||||
_applier = applier;
|
||||
_dbHealthProbe = dbHealthProbe;
|
||||
_dbLess = dbLess;
|
||||
@@ -350,6 +497,8 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
|
||||
Receive<AlarmStateUpdate>(HandleAlarmUpdate);
|
||||
Receive<AlarmQualityUpdate>(HandleAlarmQualityUpdate);
|
||||
Receive<RebuildAddressSpace>(HandleRebuild);
|
||||
Receive<PlanReady>(HandlePlanReady);
|
||||
Receive<PlanFailed>(HandlePlanFailed);
|
||||
Receive<ServiceLevelChanged>(HandleServiceLevelChanged);
|
||||
Receive<RedundancyStateChanged>(HandleRedundancyStateChanged);
|
||||
Receive<DbHealthProbeActor.DbHealthStatus>(HandleDbHealthStatus);
|
||||
@@ -358,6 +507,24 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
|
||||
Receive<SubscribeAck>(_ => { /* PubSub ack */ });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Review M5 — a restart discards <see cref="_pendingRebuilds"/>; answer each queued requester (degraded) rather
|
||||
/// than leave it to the deploy timeout. The in-flight plan's requester is answered when its plan pipes back to
|
||||
/// the new instance (<see cref="IsFromAnotherIncarnation"/>).
|
||||
/// </summary>
|
||||
/// <inheritdoc />
|
||||
protected override void PreRestart(Exception reason, object message)
|
||||
{
|
||||
while (_pendingRebuilds.TryDequeue(out var queued))
|
||||
{
|
||||
if (!queued.Request.ReportOutcome) continue;
|
||||
queued.Requester.Tell(new RebuildCompleted(queued.Request.Correlation, queued.Request.DeploymentId, true,
|
||||
"the address-space publisher restarted before this rebuild was applied; it was not applied"));
|
||||
}
|
||||
|
||||
base.PreRestart(reason, message);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void PreStart()
|
||||
{
|
||||
@@ -380,6 +547,7 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
|
||||
{
|
||||
try
|
||||
{
|
||||
if (PlanPipelineBusy) DeferValue(new ValueWrite(msg.NodeId, msg.Value, msg.StatusCode, msg.TimestampUtc, msg.Realm));
|
||||
_sink.WriteValue(msg.NodeId, msg.Value, msg.StatusCode, msg.TimestampUtc, msg.Realm);
|
||||
Interlocked.Increment(ref _writes);
|
||||
OtOpcUaTelemetry.OpcUaSinkWrite.Add(1, new KeyValuePair<string, object?>("kind", "value"));
|
||||
@@ -401,6 +569,8 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
|
||||
/// a whole batch because one item was bad.</para></summary>
|
||||
private void HandleAttributeBatchUpdate(AttributeValueBatchUpdate msg)
|
||||
{
|
||||
if (PlanPipelineBusy)
|
||||
foreach (var item in msg.Items) DeferValue(item);
|
||||
try
|
||||
{
|
||||
_sink.WriteValues(msg.Items);
|
||||
@@ -431,6 +601,8 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
|
||||
/// value write on the same counter.</summary>
|
||||
private void HandleAttributeStatusBatchUpdate(AttributeStatusBatchUpdate msg)
|
||||
{
|
||||
if (PlanPipelineBusy)
|
||||
foreach (var item in msg.Items) DeferStatus(item);
|
||||
try
|
||||
{
|
||||
_sink.WriteStatuses(msg.Items);
|
||||
@@ -454,6 +626,7 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
|
||||
|
||||
private void HandleAlarmUpdate(AlarmStateUpdate msg)
|
||||
{
|
||||
if (PlanPipelineBusy) DeferAlarm(msg);
|
||||
try
|
||||
{
|
||||
_sink.WriteAlarmCondition(msg.AlarmNodeId, msg.State, msg.TimestampUtc, msg.Realm);
|
||||
@@ -468,6 +641,7 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
|
||||
|
||||
private void HandleAlarmQualityUpdate(AlarmQualityUpdate msg)
|
||||
{
|
||||
if (PlanPipelineBusy) DeferAlarmQuality(msg);
|
||||
try
|
||||
{
|
||||
_sink.WriteAlarmQuality(msg.AlarmNodeId, msg.StatusCode, msg.TimestampUtc, msg.Realm);
|
||||
@@ -480,114 +654,467 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleRebuild(RebuildAddressSpace msg)
|
||||
private void HandleRebuild(RebuildAddressSpace msg) => StartRebuild(msg, Sender);
|
||||
|
||||
/// <summary>
|
||||
/// The entry half of a rebuild, on the actor thread. The raw-sink fallback (no applier) and the
|
||||
/// no-artifact case complete synchronously; otherwise the artifact parse, the cluster-scoped composition
|
||||
/// and the plan — the O(artifact) work that used to serialise every live value batch and (because the
|
||||
/// node manager's lock is the SDK's) every client Publish behind it — run on the thread pool and come
|
||||
/// back as a <see cref="PlanReady"/>. Only the sink materialisation (<see cref="ApplyPlan"/>) runs here.
|
||||
/// </summary>
|
||||
private void StartRebuild(RebuildAddressSpace msg, IActorRef requester)
|
||||
{
|
||||
// Captured up front: the pipeline below is synchronous on this actor's thread today, but the reply
|
||||
// must reach whoever asked even if a future edit introduces an await into it.
|
||||
var requester = Sender;
|
||||
(bool Degraded, string? Reason) outcome;
|
||||
try
|
||||
// Strict arrival order for EVERY rebuild, including the ones that complete synchronously (no applier, no
|
||||
// bytes): a bytes-less request arriving mid-plan used to answer before the plan ahead of it, so replies went
|
||||
// out of order. HandleRebuild is the only entry that can find the pipeline busy; the drain calls this only
|
||||
// when it is idle.
|
||||
if (_planInFlight)
|
||||
{
|
||||
outcome = HandleRebuildCore(msg);
|
||||
}
|
||||
finally
|
||||
{
|
||||
// close the apply on EVERY exit, early returns and a throwing pass included: the node
|
||||
// manager reports here the native conditions a rebuild carried over (after the notifier wiring the
|
||||
// passes did) and discards any captured state no materialise claimed. Skipping it on a throw would
|
||||
// leave that state for a later surgical re-add of the same id to resurrect. No-op when nothing is
|
||||
// pending; never throws out of the handler.
|
||||
try { _sink.CompleteAddressSpaceApply(); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Error(ex, "OpcUaPublish: sink.CompleteAddressSpaceApply threw (correlation={Correlation})", msg.Correlation);
|
||||
}
|
||||
_pendingRebuilds.Enqueue((msg, requester));
|
||||
return;
|
||||
}
|
||||
|
||||
// the outcome goes out only after the apply (including its completion step) is done.
|
||||
if (msg.ReportOutcome)
|
||||
requester.Tell(new RebuildCompleted(msg.Correlation, msg.DeploymentId, outcome.Degraded, outcome.Reason));
|
||||
}
|
||||
|
||||
/// <summary>The rebuild pipeline. Returns whether the apply was degraded, and why.</summary>
|
||||
private (bool Degraded, string? Reason) HandleRebuildCore(RebuildAddressSpace msg)
|
||||
{
|
||||
using var span = OtOpcUaTelemetry.StartAddressSpaceRebuildSpan();
|
||||
span?.SetTag("otopcua.correlation_id", msg.Correlation.ToString());
|
||||
|
||||
// Two modes. With an applier wired, do a REAL diff-and-apply pass — driven either by the
|
||||
// artifact bytes already in hand (a per-cluster-mesh driver-only / FetchAndCache node, whose
|
||||
// ConfigDb is admin-only now so _dbFactory is null, hands its fetched/cached bytes in
|
||||
// msg.Artifact) or, when no bytes are carried, by loading the artifact from the ConfigDb (the
|
||||
// Direct path, which requires _dbFactory). Only a node with NO applier at all (the F10b/dev
|
||||
// seam) falls back to the raw-sink rebuild — and that fallback WIPES the served address space
|
||||
// without re-materialising, so it must NEVER be reached by a real driver node that has an
|
||||
// applier but simply has no ConfigDb (that node diff-and-applies from its in-hand bytes below).
|
||||
// Only a node with NO applier at all (the F10b/dev seam) falls back to the raw-sink rebuild — and that
|
||||
// fallback WIPES the served address space without re-materialising, so it must NEVER be reached by a
|
||||
// real driver node that has an applier (that node diff-and-applies from its in-hand bytes below).
|
||||
if (_applier is null)
|
||||
{
|
||||
// Every rebuild is traced, whichever path completes it: the applier path opens its span in ApplyPlan
|
||||
// (the actor-thread half), the two synchronous completions below open their own.
|
||||
using var span = OtOpcUaTelemetry.StartAddressSpaceRebuildSpan();
|
||||
span?.SetTag("otopcua.correlation_id", msg.Correlation.ToString());
|
||||
(bool Degraded, string? Reason) outcome;
|
||||
try
|
||||
{
|
||||
_sink.RebuildAddressSpace();
|
||||
OtOpcUaTelemetry.OpcUaSinkWrite.Add(1, new KeyValuePair<string, object?>("kind", "rebuild"));
|
||||
outcome = (false, null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Error(ex, "OpcUaPublish: sink.RebuildAddressSpace threw (correlation={Correlation})",
|
||||
msg.Correlation);
|
||||
return (true, $"address-space rebuild threw: {ex.Message}");
|
||||
_log.Error(ex, "OpcUaPublish: sink.RebuildAddressSpace threw (correlation={Correlation})", msg.Correlation);
|
||||
outcome = (true, $"address-space rebuild threw: {ex.Message}");
|
||||
}
|
||||
return (false, null);
|
||||
|
||||
FinishRebuild(msg, requester, outcome);
|
||||
return;
|
||||
}
|
||||
|
||||
// An artifact we could not obtain is NOT an empty configuration. Parsing zero bytes yields an empty
|
||||
// composition, which the planner diffs against the live one as a PureRemove and the applier then
|
||||
// faithfully tears down. Nothing legitimate produces a zero-length blob (an operator who really deletes
|
||||
// everything still deploys a JSON document with empty arrays), so the only honest reading of "no bytes"
|
||||
// is "no answer" — hold the last-known-good address space and let the next deploy (or the next boot)
|
||||
// supply a real one. Leaving _lastApplied intact means the retry diffs against what is materialised.
|
||||
if (msg.Artifact is null or { Length: 0 })
|
||||
{
|
||||
using var span = OtOpcUaTelemetry.StartAddressSpaceRebuildSpan();
|
||||
span?.SetTag("otopcua.correlation_id", msg.Correlation.ToString());
|
||||
if (_hasAppliedComposition)
|
||||
_log.Warning(
|
||||
"OpcUaPublish: no usable artifact for deployment {Id} (correlation={Correlation}) — KEEPING the currently served address space rather than tearing it down",
|
||||
msg.DeploymentId, msg.Correlation);
|
||||
else
|
||||
_log.Debug(
|
||||
"OpcUaPublish: no artifact to materialise yet (deployment={Id}, correlation={Correlation})",
|
||||
msg.DeploymentId, msg.Correlation);
|
||||
FinishRebuild(msg, requester, (true, "no usable artifact reached the address-space apply; the previously served address space was kept"));
|
||||
return;
|
||||
}
|
||||
|
||||
_planInFlight = true;
|
||||
var artifact = msg.Artifact;
|
||||
var baseComposition = _lastApplied;
|
||||
var nodeId = _localNode?.Value;
|
||||
var wantAuthority = _dataPlaneAuthority is not null;
|
||||
var incarnation = _incarnation;
|
||||
var hooks = _hooks;
|
||||
Task.Run(() =>
|
||||
{
|
||||
hooks?.BeforePlan?.Invoke();
|
||||
// Open() throws on bytes that are not valid JSON, where the static wrappers swallowed to an EMPTY
|
||||
// result — and an empty composition is not a benign no-op here (see the no-bytes guard above). The
|
||||
// throw lands in PlanFailed: logged at Error, _lastApplied untouched, last-known-good still served.
|
||||
using var parsed = DeploymentArtifact.Open(artifact);
|
||||
// The authority parse is isolated from the address-space plan (review fix I2): before the plan moved
|
||||
// off-thread a throw here was contained by FeedDataPlaneAuthority's own catch; it must not now
|
||||
// become PlanFailed and degrade an apply whose topology is perfectly readable.
|
||||
DataPlaneAuthorityProjection? authority = null;
|
||||
Exception? authorityError = null;
|
||||
if (wantAuthority)
|
||||
{
|
||||
try
|
||||
{
|
||||
authority = hooks?.ParseAuthority is { } parse
|
||||
? parse(parsed, nodeId ?? string.Empty)
|
||||
: parsed.ParseDataPlaneAuthority(nodeId ?? string.Empty);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
authorityError = ex;
|
||||
}
|
||||
}
|
||||
|
||||
var inconsistencies = new List<string>();
|
||||
var composition = nodeId is not null
|
||||
? parsed.ParseComposition(nodeId, inconsistencies.Add)
|
||||
: parsed.ParseComposition();
|
||||
var plan = AddressSpacePlanner.Compute(baseComposition, composition);
|
||||
return new PlanReady(incarnation, msg, requester, artifact, authority, authorityError, composition, plan, inconsistencies);
|
||||
})
|
||||
.PipeTo(Self, failure: ex => new PlanFailed(incarnation, msg, requester, ex));
|
||||
}
|
||||
|
||||
private void HandlePlanReady(PlanReady ready)
|
||||
{
|
||||
if (IsFromAnotherIncarnation(ready.Incarnation, ready.Request, ready.Requester)) return;
|
||||
|
||||
if (ready.AuthorityError is { } authorityError)
|
||||
_log.Error(authorityError,
|
||||
"OpcUaPublish: data-plane authority parse threw (correlation={Correlation}) — the address space is applied; the previously installed keys and grants stay in force",
|
||||
ready.Request.Correlation);
|
||||
|
||||
(bool Degraded, string? Reason) outcome;
|
||||
try
|
||||
{
|
||||
outcome = ApplyPlan(ready);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_planInFlight = false;
|
||||
}
|
||||
|
||||
FinishRebuild(ready.Request, ready.Requester, outcome);
|
||||
AfterPlanSettled();
|
||||
}
|
||||
|
||||
private void HandlePlanFailed(PlanFailed failed)
|
||||
{
|
||||
if (IsFromAnotherIncarnation(failed.Incarnation, failed.Request, failed.Requester)) return;
|
||||
|
||||
_planInFlight = false;
|
||||
_log.Error(failed.Error, "OpcUaPublish: rebuild pipeline threw (correlation={Correlation})", failed.Request.Correlation);
|
||||
FinishRebuild(failed.Request, failed.Requester, (true, $"address-space apply threw: {failed.Error.Message}"));
|
||||
AfterPlanSettled();
|
||||
}
|
||||
|
||||
/// <summary>A plan piped back to a RESTARTED instance (the ActorRef survives a restart) was computed against the
|
||||
/// previous instance's <see cref="_lastApplied"/>: never applied. Its requester is still owed an answer.</summary>
|
||||
private bool IsFromAnotherIncarnation(object incarnation, RebuildAddressSpace request, IActorRef requester)
|
||||
{
|
||||
if (ReferenceEquals(incarnation, _incarnation)) return false;
|
||||
_log.Warning(
|
||||
"OpcUaPublish: ignoring a rebuild plan started by a previous incarnation of this actor (correlation={Correlation})",
|
||||
request.Correlation);
|
||||
if (request.ReportOutcome)
|
||||
requester.Tell(new RebuildCompleted(request.Correlation, request.DeploymentId, true,
|
||||
"the address-space publisher restarted while this rebuild was being planned; it was not applied"));
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>After an apply (or a failed plan) has been answered — its deferred writes already replayed by
|
||||
/// <see cref="FinishRebuild"/> — start what queued behind it, and drop the buffer once nothing is in flight or
|
||||
/// queued (a queued plan may add nodes the buffer names, so it is replayed again after that apply).</summary>
|
||||
private void AfterPlanSettled()
|
||||
{
|
||||
StartNextPendingRebuild();
|
||||
if (!PlanPipelineBusy) _deferred.Clear();
|
||||
}
|
||||
|
||||
/// <summary>Starts queued rebuilds in arrival order until one goes off-thread (or the queue empties) — the
|
||||
/// synchronous completions (no bytes) answer in their turn.</summary>
|
||||
private void StartNextPendingRebuild()
|
||||
{
|
||||
while (!_planInFlight && _pendingRebuilds.TryDequeue(out var next))
|
||||
StartRebuild(next.Request, next.Requester);
|
||||
}
|
||||
|
||||
/// <summary>True while a plan is computing or a rebuild is queued behind one.</summary>
|
||||
private bool PlanPipelineBusy => _planInFlight || _pendingRebuilds.Count > 0;
|
||||
|
||||
private void DeferValue(in ValueWrite write)
|
||||
{
|
||||
if (!_deferred.Value(write)) WarnDeferredOverflow();
|
||||
}
|
||||
|
||||
private void DeferStatus(in StatusWrite write)
|
||||
{
|
||||
if (!_deferred.Status(write)) WarnDeferredOverflow();
|
||||
}
|
||||
|
||||
private void DeferAlarm(AlarmStateUpdate msg)
|
||||
{
|
||||
if (!_deferred.Alarm(msg)) WarnDeferredOverflow();
|
||||
}
|
||||
|
||||
private void DeferAlarmQuality(AlarmQualityUpdate msg)
|
||||
{
|
||||
if (!_deferred.AlarmQuality(msg)) WarnDeferredOverflow();
|
||||
}
|
||||
|
||||
private void WarnDeferredOverflow()
|
||||
{
|
||||
if (_deferred.Dropped == 1)
|
||||
_log.Warning(
|
||||
"OpcUaPublish: more than {Capacity} distinct nodes written while a rebuild plan is in flight — later new nodes are not held for replay (their first value lands only if the driver republishes it)",
|
||||
_deferred.Capacity);
|
||||
}
|
||||
|
||||
/// <summary>Re-applies the deferred writes through the sink: values, then status-only annotations, then alarm
|
||||
/// states and alarm qualities. Each pass isolated like the live handlers; not re-metered (counted on arrival).</summary>
|
||||
private void ReplayDeferredWrites(ReplayScope scope)
|
||||
{
|
||||
if (_deferred.IsEmpty) return;
|
||||
var (allValues, allStatuses) = _deferred.VariableWrites(
|
||||
key => scope.AllVariables || scope.Variables.Contains(key) || scope.Reshaped.ContainsKey(key));
|
||||
var values = new List<ValueWrite>(allValues.Count);
|
||||
var statuses = new List<StatusWrite>(allStatuses.Count);
|
||||
var unfit = 0;
|
||||
foreach (var v in allValues)
|
||||
{
|
||||
if (!scope.Reshaped.TryGetValue((v.Realm, v.NodeId), out var shape)) { values.Add(v); continue; }
|
||||
if (shape.TryFit(v.Value, out var fitted)) { values.Add(v with { Value = fitted }); continue; }
|
||||
unfit++;
|
||||
_log.Debug(
|
||||
"OpcUaPublish: a value deferred for re-shaped {Realm} node {Node} does not fit its new shape ({DataType}, array={IsArray}) — not replayed; the node waits for the driver's next value",
|
||||
v.Realm, v.NodeId, shape.DataType, shape.IsArray);
|
||||
}
|
||||
|
||||
foreach (var st in allStatuses)
|
||||
{
|
||||
// A status-only annotation onto a re-shaped (value-less) node would serve a null value under it; the
|
||||
// node's honest state is BadWaitingForInitialData until a value of the new shape arrives.
|
||||
if (scope.Reshaped.ContainsKey((st.Realm, st.NodeId))) { unfit++; continue; }
|
||||
statuses.Add(st);
|
||||
}
|
||||
if (values.Count > 0)
|
||||
{
|
||||
try { _sink.WriteValues(values); }
|
||||
catch (Exception ex) { _log.Warning(ex, "OpcUaPublish: replaying {Count} deferred value writes threw", values.Count); }
|
||||
}
|
||||
|
||||
if (statuses.Count > 0)
|
||||
{
|
||||
try { _sink.WriteStatuses(statuses); }
|
||||
catch (Exception ex) { _log.Warning(ex, "OpcUaPublish: replaying {Count} deferred status writes threw", statuses.Count); }
|
||||
}
|
||||
|
||||
var alarms = 0;
|
||||
foreach (var alarm in _deferred.Alarms)
|
||||
{
|
||||
if (!scope.Conditions.Contains((alarm.Realm, alarm.AlarmNodeId))) continue;
|
||||
alarms++;
|
||||
try { _sink.WriteAlarmCondition(alarm.AlarmNodeId, alarm.State, alarm.TimestampUtc, alarm.Realm); }
|
||||
catch (Exception ex) { _log.Warning(ex, "OpcUaPublish: replaying a deferred alarm write threw for {Node}", alarm.AlarmNodeId); }
|
||||
}
|
||||
|
||||
var qualities = 0;
|
||||
foreach (var quality in _deferred.AlarmQualities)
|
||||
{
|
||||
if (!scope.Conditions.Contains((quality.Realm, quality.AlarmNodeId))) continue;
|
||||
qualities++;
|
||||
try { _sink.WriteAlarmQuality(quality.AlarmNodeId, quality.StatusCode, quality.TimestampUtc, quality.Realm); }
|
||||
catch (Exception ex) { _log.Warning(ex, "OpcUaPublish: replaying a deferred alarm-quality write threw for {Node}", quality.AlarmNodeId); }
|
||||
}
|
||||
|
||||
_log.Debug(
|
||||
"OpcUaPublish: replayed {Values} value, {Statuses} status, {Alarms} alarm-state and {Qualities} alarm-quality writes (of {Held} held; {Unfit} not fitting a re-shaped node) deferred during a rebuild plan",
|
||||
values.Count, statuses.Count, alarms, qualities, _deferred.Count, unfit);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The latest write per (realm, id) received while a plan is in flight — see <see cref="_deferred"/>. A value
|
||||
/// followed by a status-only write for the same node composes to the value under the new status (exactly what
|
||||
/// the two live writes leave on the node). Bounded by <see cref="Capacity"/> distinct keys across all kinds;
|
||||
/// beyond it a NEW key is not held (an existing key still updates), counted in <see cref="Dropped"/>.
|
||||
/// </summary>
|
||||
private sealed class DeferredWrites(int capacity)
|
||||
{
|
||||
private struct VariableEntry
|
||||
{
|
||||
public bool HasValue;
|
||||
public object? Value;
|
||||
public uint Status;
|
||||
public DateTime TimestampUtc;
|
||||
}
|
||||
|
||||
private readonly Dictionary<(AddressSpaceRealm, string), VariableEntry> _variables = new();
|
||||
private readonly Dictionary<(AddressSpaceRealm, string), AlarmStateUpdate> _alarms = new();
|
||||
private readonly Dictionary<(AddressSpaceRealm, string), AlarmQualityUpdate> _qualities = new();
|
||||
|
||||
public int Capacity { get; } = capacity;
|
||||
public long Dropped { get; private set; }
|
||||
public int Count => _variables.Count + _alarms.Count + _qualities.Count;
|
||||
public bool IsEmpty => Count == 0;
|
||||
public IEnumerable<AlarmStateUpdate> Alarms => _alarms.Values;
|
||||
public IEnumerable<AlarmQualityUpdate> AlarmQualities => _qualities.Values;
|
||||
|
||||
public bool Value(in ValueWrite write)
|
||||
{
|
||||
if (string.IsNullOrEmpty(write.NodeId)) return true;
|
||||
var key = (write.Realm, write.NodeId);
|
||||
if (!_variables.ContainsKey(key) && !HasRoom()) return false;
|
||||
_variables[key] = new VariableEntry
|
||||
{
|
||||
HasValue = true, Value = write.Value, Status = write.StatusCode, TimestampUtc = write.SourceTimestampUtc,
|
||||
};
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool Status(in StatusWrite write)
|
||||
{
|
||||
if (string.IsNullOrEmpty(write.NodeId)) return true;
|
||||
var key = (write.Realm, write.NodeId);
|
||||
if (_variables.TryGetValue(key, out var entry))
|
||||
{
|
||||
entry.Status = write.StatusCode;
|
||||
entry.TimestampUtc = write.SourceTimestampUtc;
|
||||
_variables[key] = entry;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!HasRoom()) return false;
|
||||
_variables[key] = new VariableEntry { Status = write.StatusCode, TimestampUtc = write.SourceTimestampUtc };
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>A full snapshot also sets the condition's Quality (from its StatusCode), so it SUPERSEDES an
|
||||
/// earlier quality-only write for the same id (review N1): replayed state-then-quality, the stale quality
|
||||
/// would overwrite the snapshot's. A quality arriving AFTER the state is kept and replays after it.</summary>
|
||||
public bool Alarm(AlarmStateUpdate msg)
|
||||
{
|
||||
var key = (msg.Realm, msg.AlarmNodeId);
|
||||
if (!Put(_alarms, key, msg)) return false;
|
||||
_qualities.Remove(key);
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool AlarmQuality(AlarmQualityUpdate msg) => Put(_qualities, (msg.Realm, msg.AlarmNodeId), msg);
|
||||
|
||||
public (List<ValueWrite> Values, List<StatusWrite> Statuses) VariableWrites(Func<(AddressSpaceRealm, string), bool> include)
|
||||
{
|
||||
var values = new List<ValueWrite>();
|
||||
var statuses = new List<StatusWrite>();
|
||||
foreach (var ((realm, id), e) in _variables)
|
||||
{
|
||||
if (!include((realm, id))) continue;
|
||||
if (e.HasValue) values.Add(new ValueWrite(id, e.Value, e.Status, e.TimestampUtc, realm));
|
||||
else statuses.Add(new StatusWrite(id, e.Status, e.TimestampUtc, realm));
|
||||
}
|
||||
|
||||
return (values, statuses);
|
||||
}
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
_variables.Clear();
|
||||
_alarms.Clear();
|
||||
_qualities.Clear();
|
||||
Dropped = 0;
|
||||
}
|
||||
|
||||
private bool Put<T>(Dictionary<(AddressSpaceRealm, string), T> map, (AddressSpaceRealm, string) key, T value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(key.Item2)) return true;
|
||||
if (!map.ContainsKey(key) && !HasRoom()) return false;
|
||||
map[key] = value;
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool HasRoom()
|
||||
{
|
||||
if (Count < Capacity) return true;
|
||||
Dropped++;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Closes an apply on EVERY exit — early returns and a throwing pass included: the node manager reports
|
||||
/// here the native conditions a rebuild carried over (after the notifier wiring the passes did) and
|
||||
/// discards any captured state no materialise claimed. Skipping it on a throw would leave that state for a
|
||||
/// later surgical re-add of the same id to resurrect. No-op when nothing is pending; never throws. The
|
||||
/// outcome goes out only after the apply (including its completion step) is done.
|
||||
/// </summary>
|
||||
private void FinishRebuild(RebuildAddressSpace msg, IActorRef requester, (bool Degraded, string? Reason) outcome)
|
||||
{
|
||||
try { _sink.CompleteAddressSpaceApply(); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Error(ex, "OpcUaPublish: sink.CompleteAddressSpaceApply threw (correlation={Correlation})", msg.Correlation);
|
||||
}
|
||||
|
||||
// Land what arrived while the plan was in flight on the nodes the apply just created — before the answer,
|
||||
// so "applied" means the deferred first values are on the address space too (review fix C1).
|
||||
ReplayDeferredWrites(_replayScope);
|
||||
_replayScope = ReplayScope.Nothing;
|
||||
|
||||
if (msg.ReportOutcome)
|
||||
requester.Tell(new RebuildCompleted(msg.Correlation, msg.DeploymentId, outcome.Degraded, outcome.Reason));
|
||||
}
|
||||
|
||||
/// <summary>The actor-thread half of a rebuild: install the artifact's authority, apply the plan through the
|
||||
/// sink passes, announce. Returns whether the apply was degraded, and why.</summary>
|
||||
private (bool Degraded, string? Reason) ApplyPlan(PlanReady ready)
|
||||
{
|
||||
var previous = _lastApplied;
|
||||
var recording = _applier!.BeginRecording();
|
||||
try
|
||||
{
|
||||
var result = ApplyPlanCore(ready, out var rebuilt);
|
||||
// What this apply created or reset (see _replayScope). A throwing pass still scopes to what it did.
|
||||
var conditions = AddedConditions(previous, ready.Composition);
|
||||
if (rebuilt)
|
||||
{
|
||||
// Review N3: a full rebuild recreates every SCRIPTED condition fresh — the node manager carries only
|
||||
// NATIVE conditions' state over (OtOpcUaNodeManager.RebuildAddressSpace). The engine's re-assert
|
||||
// (or any transition) handled in the window landed on the discarded instance, so every kept scripted
|
||||
// condition must be replayed onto its new one. Native kept conditions stay "added only".
|
||||
foreach (var a in ready.Composition.EquipmentScriptedAlarms)
|
||||
if (a.Enabled) conditions.Add((AddressSpaceRealm.Uns, a.ScriptedAlarmId));
|
||||
}
|
||||
|
||||
_replayScope = new ReplayScope(rebuilt, recording.Ensured, recording.Reshaped, conditions);
|
||||
return result;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_applier.EndRecording();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The condition ids in <paramref name="next"/> that <paramref name="previous"/> did not materialise:
|
||||
/// enabled scripted alarms (Uns, by ScriptedAlarmId) and native alarms (Raw, at their RawPath).</summary>
|
||||
private static HashSet<(AddressSpaceRealm, string)> AddedConditions(
|
||||
AddressSpaceComposition previous, AddressSpaceComposition next)
|
||||
{
|
||||
var added = ConditionIds(next);
|
||||
added.ExceptWith(ConditionIds(previous));
|
||||
return added;
|
||||
|
||||
static HashSet<(AddressSpaceRealm, string)> ConditionIds(AddressSpaceComposition c)
|
||||
{
|
||||
var ids = new HashSet<(AddressSpaceRealm, string)>();
|
||||
foreach (var a in c.EquipmentScriptedAlarms)
|
||||
if (a.Enabled) ids.Add((AddressSpaceRealm.Uns, a.ScriptedAlarmId));
|
||||
foreach (var t in c.RawTags)
|
||||
if (t.Alarm is not null) ids.Add((AddressSpaceRealm.Raw, t.NodeId));
|
||||
return ids;
|
||||
}
|
||||
}
|
||||
|
||||
private (bool Degraded, string? Reason) ApplyPlanCore(PlanReady ready, out bool rebuilt)
|
||||
{
|
||||
rebuilt = false;
|
||||
var msg = ready.Request;
|
||||
using var span = OtOpcUaTelemetry.StartAddressSpaceRebuildSpan();
|
||||
span?.SetTag("otopcua.correlation_id", msg.Correlation.ToString());
|
||||
|
||||
try
|
||||
{
|
||||
// An in-hand artifact (boot-from-cache / FetchAndCache) wins: the host already has the
|
||||
// cached/fetched bytes, and the ConfigDb read the DeploymentId path would do is exactly what
|
||||
// is unreachable during the outage this exists to survive — and is simply absent on a
|
||||
// driver-only node with no ConfigDb. Otherwise, when a ConfigDb is present (Direct mode),
|
||||
// prefer the artifact of the deployment the host just applied — at apply time it is not yet
|
||||
// Sealed, so LoadLatestArtifact would return the PREVIOUS revision and materialise a stale
|
||||
// composition (variables that don't match the SubscribeBulk refs); fall back to latest-sealed
|
||||
// only for legacy callers that carry neither. With NO bytes AND no ConfigDb there is nothing
|
||||
// to load, so yield Array.Empty<byte>() — the guard below reads that as "no answer" and
|
||||
// abandons the rebuild, holding last-known-good rather than wiping the address space.
|
||||
var artifact = msg.Artifact
|
||||
?? (_dbFactory is not null
|
||||
? (msg.DeploymentId is { } depId ? LoadArtifact(depId) : LoadLatestArtifact())
|
||||
: Array.Empty<byte>());
|
||||
|
||||
// An artifact we could not obtain is NOT an empty configuration. Parsing zero
|
||||
// bytes yields an empty composition, which the planner diffs against the live one as a
|
||||
// PureRemove and the applier then faithfully tears down: a transient ConfigDb blip empties the
|
||||
// served address space. Nothing legitimate produces a zero-length blob (an operator who really
|
||||
// deletes everything still deploys a JSON document with empty arrays), so the only honest
|
||||
// reading of "no bytes" is "no answer" — hold the last-known-good address space and let the
|
||||
// next deploy (or the next boot) supply a real one. Returning here also leaves _lastApplied
|
||||
// intact, so the retry diffs against what is actually materialised.
|
||||
if (artifact is null or { Length: 0 })
|
||||
{
|
||||
if (_hasAppliedComposition)
|
||||
_log.Warning(
|
||||
"OpcUaPublish: no usable artifact for deployment {Id} (correlation={Correlation}) — KEEPING the currently served address space rather than tearing it down",
|
||||
msg.DeploymentId, msg.Correlation);
|
||||
else
|
||||
_log.Debug(
|
||||
"OpcUaPublish: no artifact to materialise yet (deployment={Id}, correlation={Correlation})",
|
||||
msg.DeploymentId, msg.Correlation);
|
||||
return (true, "no usable artifact reached the address-space apply; the previously served address space was kept");
|
||||
}
|
||||
|
||||
// Parse the artifact ONCE for this rebuild. Every consumer below (data-plane authority, then the
|
||||
// composition) reads this one JsonDocument instead of rebuilding it per question — the rebuild
|
||||
// used to pay a full parse-and-throw-away per static call. Disposed when the handler returns.
|
||||
//
|
||||
// Open() throws on bytes that are not valid JSON, where the static wrappers swallowed to an
|
||||
// EMPTY result — and an empty composition is not a benign no-op here: the planner diffs it as a
|
||||
// PureRemove and the applier tears the served address space down. So the throw is the honest
|
||||
// outcome, and it lands in this method's catch below: logged at Error, _lastApplied untouched,
|
||||
// last-known-good still served. Same "no answer ≠ empty configuration" rule as the guard
|
||||
// immediately above, applied to bytes that arrived unreadable rather than absent.
|
||||
using var parsed = DeploymentArtifact.Open(artifact);
|
||||
foreach (var inconsistency in ready.Inconsistencies)
|
||||
_log.Warning("OpcUaPublish {Node}: cross-cluster binding — {Message}", _localNode, inconsistency);
|
||||
|
||||
// install this artifact's API keys + ACL grants BEFORE the address-space work
|
||||
// below, and deliberately ahead of the `plan.IsEmpty` early-return: an artifact that revokes a
|
||||
@@ -595,13 +1122,10 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
|
||||
// feeding this after that return would leave a revoked key authenticating until some unrelated
|
||||
// structural change happened to redeploy. Authorization and topology change on their own
|
||||
// schedules.
|
||||
FeedDataPlaneAuthority(parsed, artifact);
|
||||
if (ready.Authority is not null) FeedDataPlaneAuthority(ready.Authority, ready.Artifact);
|
||||
|
||||
var composition = _localNode is { } ln
|
||||
? parsed.ParseComposition(ln.Value,
|
||||
inconsistency => _log.Warning("OpcUaPublish {Node}: cross-cluster binding — {Message}", ln, inconsistency))
|
||||
: parsed.ParseComposition();
|
||||
var plan = AddressSpacePlanner.Compute(_lastApplied, composition);
|
||||
var composition = ready.Composition;
|
||||
var plan = ready.Plan;
|
||||
|
||||
if (plan.IsEmpty)
|
||||
{
|
||||
@@ -610,7 +1134,7 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
|
||||
// The composition is unchanged but the last apply of it was a degraded
|
||||
// rebuild: re-ensure every node (each Ensure* no-ops on a node that stands) so the
|
||||
// re-deploy actually repairs what that rebuild could not place.
|
||||
var repairFailed = _applier.MaterialiseHierarchy(composition)
|
||||
var repairFailed = _applier!.MaterialiseHierarchy(composition)
|
||||
+ MaterialiseNodes(plan, composition, fullComposition: true);
|
||||
if (repairFailed > 0)
|
||||
{
|
||||
@@ -632,7 +1156,8 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
|
||||
return (false, null);
|
||||
}
|
||||
|
||||
var outcome = _applier.Apply(plan);
|
||||
var outcome = _applier!.Apply(plan);
|
||||
rebuilt = outcome.RebuildCalled;
|
||||
|
||||
// Sum swallowed per-node materialise failures across every pass together with Apply's own
|
||||
// removal-pass tally (archreview 01/S-1). A degraded apply used to vanish into per-node
|
||||
@@ -725,8 +1250,8 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Run the five node-materialising applier passes — Raw subtree, UNS references, scripted alarms,
|
||||
/// equipment tags, equipment VirtualTags — either over the FULL composition (after a rebuild) or over
|
||||
/// Run the four node-materialising applier passes — Raw subtree, UNS references, scripted alarms,
|
||||
/// equipment VirtualTags — either over the FULL composition (after a rebuild) or over
|
||||
/// just this plan's delta (surgical deploys). <c>MaterialiseHierarchy</c> is NOT one of them:
|
||||
/// the caller always runs it in full, because parents must exist before any add.
|
||||
/// </summary>
|
||||
@@ -809,21 +1334,7 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
|
||||
if (addedAlarms.Count > 0) failedNodes += applier.MaterialiseScriptedAlarms(composition, addedAlarms);
|
||||
}
|
||||
|
||||
// Equipment-namespace tags get their own pass: ensures each signal's Variable (and any
|
||||
// FolderPath sub-folder) exists under its already-materialised equipment folder so
|
||||
// clients can browse them. Live values are pushed by DriverHostActor.ForwardBatchToMux after
|
||||
// each subscription cycle; variables show BadWaitingForInitialData only until the first
|
||||
// publish interval fires.
|
||||
if (fullComposition)
|
||||
{
|
||||
failedNodes += applier.MaterialiseEquipmentTags(composition);
|
||||
}
|
||||
else if (plan.AddedEquipmentTags.Count > 0)
|
||||
{
|
||||
failedNodes += applier.MaterialiseEquipmentTags(composition, plan.AddedEquipmentTags);
|
||||
}
|
||||
|
||||
// Equipment-namespace VirtualTags get their own pass right after the equipment tags:
|
||||
// Equipment-namespace VirtualTags get their own pass right after the scripted alarms:
|
||||
// ensures each computed signal's Variable (and any FolderPath sub-folder) exists under its
|
||||
// equipment folder with a folder-scoped NodeId. VirtualTagHostActor.OnResult pushes live
|
||||
// values once the first dependency update arrives; until then variables show BadWaitingForInitialData.
|
||||
@@ -881,17 +1392,15 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
|
||||
/// the safe direction for availability but means a revocation carried by an artifact this node
|
||||
/// could not decode has not taken effect. It is logged at Error for exactly that reason.
|
||||
/// </remarks>
|
||||
/// <param name="parsed">The rebuild's single parse of the artifact, read for the keys + grants.</param>
|
||||
/// <param name="projection">The keys + grants decoded (off-thread) from the rebuild's single parse of the artifact.</param>
|
||||
/// <param name="artifactBytes">The RAW artifact bytes — hashed for the revision id, which must be
|
||||
/// SHA-256 of the exact blob (see below); never re-parsed here.</param>
|
||||
private void FeedDataPlaneAuthority(ParsedDeploymentArtifact parsed, byte[] artifactBytes)
|
||||
private void FeedDataPlaneAuthority(DataPlaneAuthorityProjection projection, byte[] artifactBytes)
|
||||
{
|
||||
if (_dataPlaneAuthority is null) return;
|
||||
|
||||
try
|
||||
{
|
||||
var nodeId = _localNode?.Value ?? string.Empty;
|
||||
var projection = parsed.ParseDataPlaneAuthority(nodeId);
|
||||
// The revision hash is SHA-256 of the artifact bytes — the same derivation ConfigComposer uses,
|
||||
// which is what lets a node recognise a re-applied revision without the hash riding the wire.
|
||||
// Hashed off the RAW bytes for exactly that reason: a re-serialisation of the parsed document
|
||||
@@ -936,46 +1445,6 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Read a specific deployment's artifact blob from ConfigDb (the one just applied,
|
||||
/// which may not be Sealed yet). Empty array on any failure — <see cref="HandleRebuild"/> treats that as
|
||||
/// "no answer" and abandons the rebuild, leaving the served address space untouched.</summary>
|
||||
private byte[] LoadArtifact(DeploymentId deploymentId)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var db = _dbFactory!.CreateDbContext();
|
||||
return db.Deployments.AsNoTracking()
|
||||
.Where(d => d.DeploymentId == deploymentId.Value)
|
||||
.Select(d => d.ArtifactBlob)
|
||||
.FirstOrDefault() ?? Array.Empty<byte>();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Warning(ex, "OpcUaPublish: failed to load artifact for deployment {Id}; the rebuild is abandoned", deploymentId);
|
||||
return Array.Empty<byte>();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Read the most recent <c>Sealed</c> deployment's artifact blob from ConfigDb.
|
||||
/// Empty array on any failure — see <see cref="LoadArtifact"/> for how that is handled.</summary>
|
||||
private byte[] LoadLatestArtifact()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var db = _dbFactory!.CreateDbContext();
|
||||
return db.Deployments.AsNoTracking()
|
||||
.Where(d => d.Status == Configuration.Enums.DeploymentStatus.Sealed)
|
||||
.OrderByDescending(d => d.SealedAtUtc)
|
||||
.Select(d => d.ArtifactBlob)
|
||||
.FirstOrDefault() ?? Array.Empty<byte>();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Warning(ex, "OpcUaPublish: failed to load latest deployment artifact; the rebuild is abandoned");
|
||||
return Array.Empty<byte>();
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleServiceLevelChanged(ServiceLevelChanged msg)
|
||||
{
|
||||
// Always publish the FIRST computed level, even if it equals the byte-default 0. Otherwise a
|
||||
|
||||
+21
-8
@@ -215,11 +215,13 @@ public sealed class HistoryUpdateOverTheWireTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Control (no retro-update): materialising the node BEFORE the updater is installed leaves it without
|
||||
/// the HistoryWrite bit, so the SDK base answers BadHistoryOperationUnsupported — even for an editor.
|
||||
/// Review 01 U3 (B7): an updater installed AFTER the node materialised re-composes the HistoryWrite bit on
|
||||
/// the existing node (no rebuild), so an editor's insert reaches the updater over the wire; clearing the
|
||||
/// updater again retracts the bit and the SDK base answers BadHistoryOperationUnsupported. (Before B7 the
|
||||
/// bit was fixed at materialisation and a late install stayed unsupported.)
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task An_updater_installed_after_the_node_materialised_is_unsupported()
|
||||
public async Task An_updater_installed_after_the_node_materialised_grants_history_write_and_a_reset_takes_it_back()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var store = new SharedStore();
|
||||
@@ -228,16 +230,27 @@ public sealed class HistoryUpdateOverTheWireTests
|
||||
{
|
||||
rig.Enforce(true, [Key()], ClusterGrant(KeyPrincipal, "acl-editor", Editor));
|
||||
var id = rig.EnsureRawVariable("Speed", "Double", writable: false, Tagname);
|
||||
rig.Nm.HistorianDataUpdater = store;
|
||||
|
||||
using var session = await rig.ConnectAsync(anonymous: false, ct);
|
||||
var node = RawNode(session, id);
|
||||
(await ReadByteAsync(session, node, Attributes.AccessLevel, ct) & AccessLevels.HistoryWrite)
|
||||
.ShouldBe(0, "the bit is fixed at materialisation");
|
||||
.ShouldBe(0, "no updater at materialisation (non-vacuity for the refresh)");
|
||||
|
||||
var result = await UpdateAsync(session, node, PerformUpdateType.Insert, ct, (T1, 1.0));
|
||||
result.StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
|
||||
store.UpdateCalls.ShouldBe(0);
|
||||
rig.Nm.HistorianDataUpdater = store;
|
||||
|
||||
(await ReadByteAsync(session, node, Attributes.AccessLevel, ct) & AccessLevels.HistoryWrite)
|
||||
.ShouldBe(AccessLevels.HistoryWrite, "a late updater lights the bit on the existing node");
|
||||
var ok = await UpdateAsync(session, node, PerformUpdateType.Insert, ct, (T1, 1.0));
|
||||
ok.OperationResults.Select(c => c.Code).ShouldBe([StatusCodes.GoodEntryInserted]);
|
||||
store.UpdateCalls.ShouldBe(1);
|
||||
|
||||
rig.Nm.HistorianDataUpdater = null;
|
||||
|
||||
(await ReadByteAsync(session, node, Attributes.AccessLevel, ct) & AccessLevels.HistoryWrite)
|
||||
.ShouldBe(0, "a reset updater takes the bit back");
|
||||
var refused = await UpdateAsync(session, node, PerformUpdateType.Insert, ct, (T2, 2.0));
|
||||
refused.StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
|
||||
store.UpdateCalls.ShouldBe(1, "the reset updater is never reached");
|
||||
}
|
||||
finally { await rig.DisposeAsync(); }
|
||||
}
|
||||
|
||||
+287
@@ -0,0 +1,287 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Client;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Review 01 L2, over the wire against the real SDK: a rebuild (or a surgical remove + re-add) that
|
||||
/// recreates a NodeId must not leave the SDK's per-NodeId <c>MonitoredNode2</c> — and its component-cache
|
||||
/// entry — pointing at the DEAD <c>NodeState</c>. Before the fix, ANY new item on that NodeId, from any
|
||||
/// session, bound to the dead node while an older item on it still existed: created <c>Good</c>, fed its
|
||||
/// stale initial value, then never again.
|
||||
/// <para>
|
||||
/// Each test keeps the OLD item alive while creating the NEW one — that is exactly the trap — and
|
||||
/// proves the positive path (the new item receives a value written AFTER its creation) before asserting
|
||||
/// the old item's <c>BadNodeIdUnknown</c>. Deleting the old item afterwards must succeed too.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class StaleMonitoredNodeWireTests
|
||||
{
|
||||
private const string ServerUri = "urn:OtOpcUa.StaleMonitoredNode";
|
||||
private const string Device = "Plant/Modbus/dev1";
|
||||
private const string Tag = "Plant/Modbus/dev1/Speed";
|
||||
|
||||
/// <summary>Rebuild + re-create the same NodeId: the old item is told, a new item on the same NodeId receives values.</summary>
|
||||
[Fact]
|
||||
public async Task A_new_item_after_a_rebuild_binds_to_the_recreated_node()
|
||||
{
|
||||
await RunAsync("rebuild", nm =>
|
||||
{
|
||||
nm.RebuildAddressSpace();
|
||||
nm.CompleteAddressSpaceApply();
|
||||
Materialise(nm);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Surgical remove + re-add of the same NodeId: same contract as the rebuild.</summary>
|
||||
[Fact]
|
||||
public async Task A_new_item_after_a_surgical_remove_and_readd_binds_to_the_recreated_node()
|
||||
{
|
||||
await RunAsync("surgical", nm =>
|
||||
{
|
||||
nm.RemoveVariableNode(Tag, AddressSpaceRealm.Raw).ShouldBeTrue();
|
||||
nm.EnsureVariable(Tag, Device, "Speed", "Int32", writable: false, realm: AddressSpaceRealm.Raw);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The same trap from a SECOND session: a UaExpert operator connecting after a deploy while ScadaBridge's
|
||||
/// original item is still alive.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_second_session_binds_to_the_recreated_node_while_the_first_sessions_item_lives()
|
||||
{
|
||||
var pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-stale-2s-{Guid.NewGuid():N}");
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
try
|
||||
{
|
||||
var (server, host, port) = await BootServerAsync(pkiRoot, ServerUri + ".TwoSessions", ct);
|
||||
await using var _ = host;
|
||||
var nm = server.NodeManager!;
|
||||
Materialise(nm);
|
||||
nm.WriteValue(Tag, 1, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
|
||||
|
||||
using var sessionA = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct);
|
||||
var nodeId = new NodeId(Tag, (ushort)sessionA.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri));
|
||||
var (subA, oldItem, oldValues, gate) = await SubscribeAsync(sessionA, nodeId, ct);
|
||||
await WaitUntilAsync(() => { lock (gate) return oldValues.Count >= 1; }, TimeSpan.FromSeconds(5));
|
||||
|
||||
nm.RebuildAddressSpace();
|
||||
nm.CompleteAddressSpaceApply();
|
||||
Materialise(nm);
|
||||
nm.WriteValue(Tag, 2, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
|
||||
|
||||
// Session B arrives AFTER the rebuild, while A's item is still in A's subscription.
|
||||
using var sessionB = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct);
|
||||
var (subB, newItem, newValues, gateB) = await SubscribeAsync(sessionB, nodeId, ct);
|
||||
await WaitUntilAsync(() => { lock (gateB) return newValues.Count >= 1; }, TimeSpan.FromSeconds(5));
|
||||
|
||||
nm.WriteValue(Tag, 3, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
|
||||
await WaitUntilAsync(() => { lock (gateB) return newValues.Any(v => Equals(v.Value, 3)); }, TimeSpan.FromSeconds(5),
|
||||
"the second session's item must receive a value written after its creation");
|
||||
|
||||
lock (gate) oldValues.ShouldContain(v => v.StatusCode == StatusCodes.BadNodeIdUnknown, "the first session's item was told its node went away");
|
||||
lock (gateB) newValues.ShouldNotContain(v => v.StatusCode == StatusCodes.BadNodeIdUnknown);
|
||||
|
||||
subA.RemoveItem(oldItem);
|
||||
await subA.ApplyChangesAsync(ct);
|
||||
subB.RemoveItem(newItem);
|
||||
await subB.ApplyChangesAsync(ct);
|
||||
}
|
||||
finally
|
||||
{
|
||||
SafeDelete(pkiRoot);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task RunAsync(string name, Action<OtOpcUaNodeManager> recreate)
|
||||
{
|
||||
var pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-stale-{name}-{Guid.NewGuid():N}");
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
try
|
||||
{
|
||||
var (server, host, port) = await BootServerAsync(pkiRoot, $"{ServerUri}.{name}", ct);
|
||||
await using var _ = host;
|
||||
var nm = server.NodeManager!;
|
||||
Materialise(nm);
|
||||
nm.WriteValue(Tag, 41, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
|
||||
|
||||
using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct);
|
||||
var nodeId = new NodeId(Tag, (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri));
|
||||
var (subscription, oldItem, oldValues, gate) = await SubscribeAsync(session, nodeId, ct);
|
||||
await WaitUntilAsync(() => { lock (gate) return oldValues.Any(v => Equals(v.Value, 41)); }, TimeSpan.FromSeconds(5));
|
||||
nm.IsNodeMonitored(nodeId).ShouldBeTrue("non-vacuity: the SDK really holds a fan-out for the node");
|
||||
|
||||
// The node goes away and comes back under the same NodeId; a fresh value lands on the NEW node.
|
||||
recreate(nm);
|
||||
nm.WriteValue(Tag, 42, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
|
||||
|
||||
// A NEW item on the same NodeId, in the same subscription, with the old one still present.
|
||||
var newValues = new List<DataValue>();
|
||||
var newItem = new MonitoredItem(subscription.DefaultItem)
|
||||
{
|
||||
StartNodeId = nodeId, AttributeId = Attributes.Value, SamplingInterval = 50, QueueSize = 10,
|
||||
};
|
||||
newItem.Notification += (item, _) => { foreach (var v in item.DequeueValues()) lock (gate) newValues.Add(v); };
|
||||
subscription.AddItem(newItem);
|
||||
await subscription.ApplyChangesAsync(ct);
|
||||
newItem.Status.Error.ShouldBeNull();
|
||||
|
||||
// Positive control: the initial value is the NEW node's, and a value written afterwards arrives.
|
||||
await WaitUntilAsync(() => { lock (gate) return newValues.Any(v => Equals(v.Value, 42)); }, TimeSpan.FromSeconds(5),
|
||||
"the new item's initial read must come from the re-created node");
|
||||
nm.WriteValue(Tag, 43, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
|
||||
await WaitUntilAsync(() => { lock (gate) return newValues.Any(v => Equals(v.Value, 43)); }, TimeSpan.FromSeconds(5),
|
||||
"the new item must receive a value written after its creation");
|
||||
lock (gate) newValues.ShouldNotContain(v => v.StatusCode == StatusCodes.BadNodeIdUnknown);
|
||||
|
||||
// Review fix I3: the cache entry the new item holds is ITS reference alone — the old item's was released
|
||||
// by the removal.
|
||||
CacheRefCount(nm, nodeId).ShouldBe(1, "one live item, one component-cache reference");
|
||||
|
||||
// The old item was told, and the dead node's fan-out is gone.
|
||||
await WaitUntilAsync(() => { lock (gate) return oldValues.Any(v => v.StatusCode == StatusCodes.BadNodeIdUnknown); }, TimeSpan.FromSeconds(5),
|
||||
"the item on the removed node must be told BadNodeIdUnknown");
|
||||
nm.IsNodeMonitored(nodeId).ShouldBeTrue("the NEW item's fan-out exists for the re-created node");
|
||||
|
||||
// The old item's delete completes — its node is gone, not its right to be deleted.
|
||||
subscription.RemoveItem(oldItem);
|
||||
await subscription.ApplyChangesAsync(ct);
|
||||
(oldItem.Status.Error is null || ServiceResult.IsGood(oldItem.Status.Error)).ShouldBeTrue(
|
||||
$"deleting the orphaned item must succeed, got {oldItem.Status.Error}");
|
||||
|
||||
// The SDK releases a deleted item's cache reference on a Good delete; the old item's was released by the
|
||||
// removal already, so a second release would evict the NEW item's live entry (review fix I3).
|
||||
CacheRefCount(nm, nodeId).ShouldBe(1, "deleting the orphaned item must not take the new item's reference");
|
||||
|
||||
// And the new item is unaffected by that delete.
|
||||
nm.WriteValue(Tag, 44, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
|
||||
await WaitUntilAsync(() => { lock (gate) return newValues.Any(v => Equals(v.Value, 44)); }, TimeSpan.FromSeconds(5));
|
||||
|
||||
// Positive control: deleting the live item gives its reference back.
|
||||
subscription.RemoveItem(newItem);
|
||||
await subscription.ApplyChangesAsync(ct);
|
||||
CacheRefCount(nm, nodeId).ShouldBe(0);
|
||||
}
|
||||
finally
|
||||
{
|
||||
SafeDelete(pkiRoot);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The SDK's component-cache reference count for <paramref name="nodeId"/> (private SDK state, read by
|
||||
/// reflection: <c>CustomNodeManager2.m_componentCache</c> → <c>CacheEntry.RefCount</c>); 0 when no entry.</summary>
|
||||
private static int CacheRefCount(OtOpcUaNodeManager nm, NodeId nodeId)
|
||||
{
|
||||
const System.Reflection.BindingFlags Any =
|
||||
System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic;
|
||||
var field = typeof(Opc.Ua.Server.CustomNodeManager2).GetField("m_componentCache", Any)
|
||||
.ShouldNotBeNull("the SDK's component cache field (SDK internals moved?)");
|
||||
lock (nm.Lock)
|
||||
{
|
||||
if (field.GetValue(nm) is not System.Collections.IDictionary cache || !cache.Contains(nodeId)) return 0;
|
||||
var entry = cache[nodeId]!;
|
||||
var member = (object?)entry.GetType().GetField("RefCount", Any) ?? entry.GetType().GetProperty("RefCount", Any);
|
||||
return member switch
|
||||
{
|
||||
System.Reflection.FieldInfo f => (int)f.GetValue(entry)!,
|
||||
System.Reflection.PropertyInfo p => (int)p.GetValue(entry)!,
|
||||
_ => throw new InvalidOperationException("CacheEntry.RefCount not found"),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private static void Materialise(OtOpcUaNodeManager nm)
|
||||
{
|
||||
nm.EnsureFolder(Device, null, "dev1", AddressSpaceRealm.Raw);
|
||||
nm.EnsureVariable(Tag, Device, "Speed", "Int32", writable: false, realm: AddressSpaceRealm.Raw);
|
||||
}
|
||||
|
||||
private static async Task<(Subscription Subscription, MonitoredItem Item, List<DataValue> Values, object Gate)> SubscribeAsync(
|
||||
ISession session, NodeId nodeId, CancellationToken ct)
|
||||
{
|
||||
var values = new List<DataValue>();
|
||||
var gate = new object();
|
||||
var subscription = new Subscription(session.DefaultSubscription) { PublishingInterval = 100 };
|
||||
session.AddSubscription(subscription);
|
||||
await subscription.CreateAsync(ct);
|
||||
var item = new MonitoredItem(subscription.DefaultItem)
|
||||
{
|
||||
StartNodeId = nodeId, AttributeId = Attributes.Value, SamplingInterval = 50, QueueSize = 10,
|
||||
};
|
||||
item.Notification += (i, _) => { foreach (var v in i.DequeueValues()) lock (gate) values.Add(v); };
|
||||
subscription.AddItem(item);
|
||||
await subscription.ApplyChangesAsync(ct);
|
||||
return (subscription, item, values, gate);
|
||||
}
|
||||
|
||||
private static async Task<(OtOpcUaSdkServer Server, OpcUaApplicationHost Host, int Port)> BootServerAsync(
|
||||
string pkiRoot, string appUri, CancellationToken ct)
|
||||
{
|
||||
var boundPort = 0;
|
||||
var started = await OpcUaHostStart.StartAsync(
|
||||
port =>
|
||||
{
|
||||
boundPort = port;
|
||||
return new OpcUaApplicationHost(
|
||||
new OpcUaApplicationHostOptions
|
||||
{
|
||||
ApplicationName = appUri,
|
||||
ApplicationUri = appUri,
|
||||
OpcUaPort = port,
|
||||
PublicHostname = "127.0.0.1",
|
||||
PkiStoreRoot = pkiRoot,
|
||||
EnabledSecurityProfiles = new List<OpcUaSecurityProfile> { OpcUaSecurityProfile.None },
|
||||
AutoAcceptUntrustedClientCertificates = true,
|
||||
},
|
||||
NullLogger<OpcUaApplicationHost>.Instance);
|
||||
},
|
||||
() => new OtOpcUaSdkServer(), ct);
|
||||
return (started.Server, started.Host, boundPort);
|
||||
}
|
||||
|
||||
private static async Task<ISession> OpenSessionAsync(string endpointUrl, CancellationToken ct)
|
||||
{
|
||||
var appConfig = new ApplicationConfiguration
|
||||
{
|
||||
ApplicationName = "OtOpcUa.StaleMonitoredNodeClient",
|
||||
ApplicationUri = $"urn:OtOpcUa.StaleMonitoredNodeClient.{Guid.NewGuid():N}",
|
||||
ApplicationType = ApplicationType.Client,
|
||||
SecurityConfiguration = TestClientSecurity.Build(TestClientSecurity.AllocatePkiRoot()),
|
||||
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60_000 },
|
||||
};
|
||||
await appConfig.ValidateAsync(ApplicationType.Client, ct);
|
||||
appConfig.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true;
|
||||
|
||||
var endpoint = await CoreClientUtils.SelectEndpointAsync(
|
||||
appConfig, endpointUrl, false, DefaultTelemetry.Create(_ => { }), ct);
|
||||
var configuredEndpoint = new ConfiguredEndpoint(null, endpoint, EndpointConfiguration.Create(appConfig));
|
||||
|
||||
return await new DefaultSessionFactory(DefaultTelemetry.Create(_ => { })).CreateAsync(
|
||||
appConfig, configuredEndpoint, updateBeforeConnect: false, checkDomain: false,
|
||||
sessionName: "StaleMonitoredNodeWireTests", sessionTimeout: 60_000,
|
||||
identity: new UserIdentity(new AnonymousIdentityToken()), preferredLocales: null, ct: ct);
|
||||
}
|
||||
|
||||
private static async Task WaitUntilAsync(Func<bool> condition, TimeSpan timeout, string? because = null)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + timeout;
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
if (condition()) return;
|
||||
await Task.Delay(50);
|
||||
}
|
||||
condition().ShouldBeTrue(because ?? "condition not met within timeout");
|
||||
}
|
||||
|
||||
private static void SafeDelete(string dir)
|
||||
{
|
||||
if (!Directory.Exists(dir)) return;
|
||||
try { Directory.Delete(dir, recursive: true); }
|
||||
catch { /* best-effort */ }
|
||||
}
|
||||
}
|
||||
+46
-41
@@ -26,7 +26,7 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
|
||||
/// announce (T4b) land.
|
||||
/// <para><b>Run note:</b> this is a heavy in-process server+client integration test — run it in the
|
||||
/// serial integration pass (it is NOT part of the lightweight unit sweep). The <see cref="OtOpcUaNodeManager"/>
|
||||
/// namespace URI is <see cref="OtOpcUaNodeManager.DefaultNamespaceUri"/>; the client resolves the
|
||||
/// namespace URI is <see cref="V3NodeIds.RawNamespaceUri"/>; the client resolves the
|
||||
/// folder-scoped variable NodeIds against it.</para>
|
||||
/// </summary>
|
||||
public sealed class SubscriptionSurvivalTests
|
||||
@@ -68,15 +68,15 @@ public sealed class SubscriptionSurvivalTests
|
||||
|
||||
// --- Materialise tag A (equipment eq-1, tag "A") and seed it a Good value. ---
|
||||
var withA = CompositionWith(
|
||||
new EquipmentTagPlan("tag-a", "eq-1", "drv", FolderPath: "", Name: "A", DataType: "Int32", FullName: "1", Writable: false, Alarm: null));
|
||||
RawTag("tag-a", "A"));
|
||||
applier.MaterialiseHierarchy(withA);
|
||||
applier.MaterialiseEquipmentTags(withA);
|
||||
var nodeIdAString = V3NodeIds.Uns("eq-1", "A");
|
||||
sink.WriteValue(nodeIdAString, 41, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||
applier.MaterialiseRawSubtree(withA);
|
||||
var nodeIdAString = "Plant/dev1/A";
|
||||
sink.WriteValue(nodeIdAString, 41, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
|
||||
|
||||
// --- Open a client session + subscription + monitored item on A. ---
|
||||
using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct);
|
||||
var ns = (ushort)session.NamespaceUris.GetIndex(OtOpcUaNodeManager.DefaultNamespaceUri);
|
||||
var ns = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri);
|
||||
ns.ShouldBeGreaterThan((ushort)0);
|
||||
var nodeIdA = new NodeId(nodeIdAString, ns);
|
||||
|
||||
@@ -106,22 +106,22 @@ public sealed class SubscriptionSurvivalTests
|
||||
|
||||
// --- Drive a PURE-ADD apply adding tag B (previous={A}, next={A,B}). ---
|
||||
var withAB = CompositionWith(
|
||||
new EquipmentTagPlan("tag-a", "eq-1", "drv", FolderPath: "", Name: "A", DataType: "Int32", FullName: "1", Writable: false, Alarm: null),
|
||||
new EquipmentTagPlan("tag-b", "eq-1", "drv", FolderPath: "", Name: "B", DataType: "Int32", FullName: "2", Writable: false, Alarm: null));
|
||||
RawTag("tag-a", "A"),
|
||||
RawTag("tag-b", "B"));
|
||||
var plan = AddressSpacePlanner.Compute(withA, withAB);
|
||||
plan.AddedEquipmentTags.Count.ShouldBe(1);
|
||||
plan.AddedRawTags.Count.ShouldBe(1);
|
||||
|
||||
var outcome = applier.Apply(plan);
|
||||
outcome.RebuildCalled.ShouldBeFalse(); // PureAdd — no teardown
|
||||
|
||||
// Materialise passes (as the publish actor runs them) then announce — over the FULL composition.
|
||||
applier.MaterialiseHierarchy(withAB);
|
||||
applier.MaterialiseEquipmentTags(withAB);
|
||||
applier.MaterialiseRawSubtree(withAB);
|
||||
applier.AnnounceAddedNodes(plan);
|
||||
|
||||
// --- A's monitored item must still be alive: a new write to A is delivered. ---
|
||||
lock (gate) received.Clear();
|
||||
sink.WriteValue(nodeIdAString, 42, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||
sink.WriteValue(nodeIdAString, 42, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
|
||||
|
||||
await WaitUntilAsync(() => { lock (gate) return received.Any(v => Equals(v.Value, 42)); }, TimeSpan.FromSeconds(5));
|
||||
lock (gate)
|
||||
@@ -131,8 +131,8 @@ public sealed class SubscriptionSurvivalTests
|
||||
}
|
||||
|
||||
// --- B is browsable + readable (the add landed). ---
|
||||
var nodeIdB = new NodeId(V3NodeIds.Uns("eq-1", "B"), ns);
|
||||
sink.WriteValue(nodeIdB.Identifier.ToString()!, 7, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||
var nodeIdB = new NodeId("Plant/dev1/B", ns);
|
||||
sink.WriteValue(nodeIdB.Identifier.ToString()!, 7, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
|
||||
var bValue = await session.ReadValueAsync(nodeIdB, ct);
|
||||
bValue.StatusCode.ShouldNotBe(StatusCodes.BadNodeIdUnknown);
|
||||
bValue.Value.ShouldBe(7);
|
||||
@@ -180,17 +180,17 @@ public sealed class SubscriptionSurvivalTests
|
||||
|
||||
// Materialise A + B and seed both Good.
|
||||
var withAB = CompositionWith(
|
||||
new EquipmentTagPlan("tag-a", "eq-1", "drv", FolderPath: "", Name: "A", DataType: "Int32", FullName: "1", Writable: false, Alarm: null),
|
||||
new EquipmentTagPlan("tag-b", "eq-1", "drv", FolderPath: "", Name: "B", DataType: "Int32", FullName: "2", Writable: false, Alarm: null));
|
||||
RawTag("tag-a", "A"),
|
||||
RawTag("tag-b", "B"));
|
||||
applier.MaterialiseHierarchy(withAB);
|
||||
applier.MaterialiseEquipmentTags(withAB);
|
||||
var nodeIdAString = V3NodeIds.Uns("eq-1", "A");
|
||||
var nodeIdBString = V3NodeIds.Uns("eq-1", "B");
|
||||
sink.WriteValue(nodeIdAString, 10, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||
sink.WriteValue(nodeIdBString, 20, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||
applier.MaterialiseRawSubtree(withAB);
|
||||
var nodeIdAString = "Plant/dev1/A";
|
||||
var nodeIdBString = "Plant/dev1/B";
|
||||
sink.WriteValue(nodeIdAString, 10, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
|
||||
sink.WriteValue(nodeIdBString, 20, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
|
||||
|
||||
using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct);
|
||||
var ns = (ushort)session.NamespaceUris.GetIndex(OtOpcUaNodeManager.DefaultNamespaceUri);
|
||||
var ns = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri);
|
||||
var nodeIdA = new NodeId(nodeIdAString, ns);
|
||||
var nodeIdB = new NodeId(nodeIdBString, ns);
|
||||
|
||||
@@ -211,9 +211,9 @@ public sealed class SubscriptionSurvivalTests
|
||||
|
||||
// Drive a PURE-REMOVE apply removing B (previous={A,B}, next={A}).
|
||||
var withA = CompositionWith(
|
||||
new EquipmentTagPlan("tag-a", "eq-1", "drv", FolderPath: "", Name: "A", DataType: "Int32", FullName: "1", Writable: false, Alarm: null));
|
||||
RawTag("tag-a", "A"));
|
||||
var plan = AddressSpacePlanner.Compute(withAB, withA);
|
||||
plan.RemovedEquipmentTags.Count.ShouldBe(1);
|
||||
plan.RemovedRawTags.Count.ShouldBe(1);
|
||||
|
||||
lock (gate) { receivedA.Clear(); receivedB.Clear(); }
|
||||
var outcome = applier.Apply(plan);
|
||||
@@ -223,7 +223,7 @@ public sealed class SubscriptionSurvivalTests
|
||||
await WaitUntilAsync(() => { lock (gate) return receivedB.Any(v => StatusCode.IsBad(v.StatusCode)); }, TimeSpan.FromSeconds(5));
|
||||
|
||||
// A stays alive: a fresh write to A is delivered.
|
||||
sink.WriteValue(nodeIdAString, 11, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||
sink.WriteValue(nodeIdAString, 11, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
|
||||
await WaitUntilAsync(() => { lock (gate) return receivedA.Any(v => Equals(v.Value, 11)); }, TimeSpan.FromSeconds(5));
|
||||
|
||||
// B is gone: a re-read surfaces BadNodeIdUnknown. The 1.5.378 node-cache read path
|
||||
@@ -273,15 +273,15 @@ public sealed class SubscriptionSurvivalTests
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
|
||||
var withAB = CompositionWith(
|
||||
new EquipmentTagPlan("tag-a", "eq-1", "drv", FolderPath: "", Name: "A", DataType: "Int32", FullName: "1", Writable: false, Alarm: null),
|
||||
new EquipmentTagPlan("tag-b", "eq-1", "drv", FolderPath: "", Name: "B", DataType: "Int32", FullName: "2", Writable: false, Alarm: null));
|
||||
RawTag("tag-a", "A"),
|
||||
RawTag("tag-b", "B"));
|
||||
applier.MaterialiseHierarchy(withAB);
|
||||
applier.MaterialiseEquipmentTags(withAB);
|
||||
var nodeIdAString = V3NodeIds.Uns("eq-1", "A");
|
||||
sink.WriteValue(nodeIdAString, 10, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||
applier.MaterialiseRawSubtree(withAB);
|
||||
var nodeIdAString = "Plant/dev1/A";
|
||||
sink.WriteValue(nodeIdAString, 10, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
|
||||
|
||||
using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct);
|
||||
var ns = (ushort)session.NamespaceUris.GetIndex(OtOpcUaNodeManager.DefaultNamespaceUri);
|
||||
var ns = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri);
|
||||
var nodeIdA = new NodeId(nodeIdAString, ns);
|
||||
|
||||
var receivedA = new List<DataValue>();
|
||||
@@ -297,32 +297,32 @@ public sealed class SubscriptionSurvivalTests
|
||||
|
||||
// MIXED apply: remove B, add C (previous={A,B}, next={A,C}).
|
||||
var withAC = CompositionWith(
|
||||
new EquipmentTagPlan("tag-a", "eq-1", "drv", FolderPath: "", Name: "A", DataType: "Int32", FullName: "1", Writable: false, Alarm: null),
|
||||
new EquipmentTagPlan("tag-c", "eq-1", "drv", FolderPath: "", Name: "C", DataType: "Int32", FullName: "3", Writable: false, Alarm: null));
|
||||
RawTag("tag-a", "A"),
|
||||
RawTag("tag-c", "C"));
|
||||
var plan = AddressSpacePlanner.Compute(withAB, withAC);
|
||||
plan.RemovedEquipmentTags.Count.ShouldBe(1);
|
||||
plan.AddedEquipmentTags.Count.ShouldBe(1);
|
||||
plan.RemovedRawTags.Count.ShouldBe(1);
|
||||
plan.AddedRawTags.Count.ShouldBe(1);
|
||||
|
||||
lock (gate) receivedA.Clear();
|
||||
var outcome = applier.Apply(plan);
|
||||
outcome.RebuildCalled.ShouldBeFalse(); // AddRemoveMix — removes in place, no rebuild
|
||||
// Publish-actor sequence: removes ran inside Apply; now materialise the adds + announce.
|
||||
applier.MaterialiseEquipmentTags(withAC);
|
||||
applier.MaterialiseRawSubtree(withAC);
|
||||
applier.AnnounceAddedNodes(plan);
|
||||
|
||||
// A stays alive.
|
||||
sink.WriteValue(nodeIdAString, 11, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||
sink.WriteValue(nodeIdAString, 11, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
|
||||
await WaitUntilAsync(() => { lock (gate) return receivedA.Any(v => Equals(v.Value, 11)); }, TimeSpan.FromSeconds(5));
|
||||
|
||||
// B is gone. (1.5.378 node-cache read throws for an unknown node — see above.)
|
||||
var nodeIdB = new NodeId(V3NodeIds.Uns("eq-1", "B"), ns);
|
||||
var nodeIdB = new NodeId("Plant/dev1/B", ns);
|
||||
var bReadEx = await Should.ThrowAsync<ServiceResultException>(
|
||||
async () => await session.ReadValueAsync(nodeIdB, ct));
|
||||
bReadEx.StatusCode.ShouldBe(StatusCodes.BadNodeIdUnknown);
|
||||
|
||||
// C is browsable + readable.
|
||||
var nodeIdC = new NodeId(V3NodeIds.Uns("eq-1", "C"), ns);
|
||||
sink.WriteValue(nodeIdC.Identifier.ToString()!, 9, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||
var nodeIdC = new NodeId("Plant/dev1/C", ns);
|
||||
sink.WriteValue(nodeIdC.Identifier.ToString()!, 9, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
|
||||
var cRead = await session.ReadValueAsync(nodeIdC, ct);
|
||||
cRead.StatusCode.ShouldNotBe((StatusCode)StatusCodes.BadNodeIdUnknown);
|
||||
cRead.Value.ShouldBe(9);
|
||||
@@ -333,7 +333,11 @@ public sealed class SubscriptionSurvivalTests
|
||||
}
|
||||
}
|
||||
|
||||
private static AddressSpaceComposition CompositionWith(params EquipmentTagPlan[] tags) =>
|
||||
private static RawTagPlan RawTag(string tagId, string name) =>
|
||||
new(tagId, $"Plant/dev1/{name}", ParentNodeId: "Plant/dev1", "drv", Name: name, DataType: "Int32", Writable: false,
|
||||
Alarm: null, ReferencingEquipmentPaths: Array.Empty<string>());
|
||||
|
||||
private static AddressSpaceComposition CompositionWith(params RawTagPlan[] tags) =>
|
||||
new(
|
||||
UnsAreas: Array.Empty<UnsAreaProjection>(),
|
||||
UnsLines: Array.Empty<UnsLineProjection>(),
|
||||
@@ -341,7 +345,8 @@ public sealed class SubscriptionSurvivalTests
|
||||
DriverInstancePlans: Array.Empty<DriverInstancePlan>(),
|
||||
ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>())
|
||||
{
|
||||
EquipmentTags = tags,
|
||||
RawContainers = new[] { new RawContainerNode("Plant/dev1", null, "dev1", RawNodeKind.Device) },
|
||||
RawTags = tags,
|
||||
};
|
||||
|
||||
private static async Task<ISession> OpenSessionAsync(string endpointUrl, CancellationToken ct)
|
||||
|
||||
+41
-15
@@ -45,45 +45,56 @@ public sealed class AddressSpaceApplierFailureSurfaceTests
|
||||
|
||||
// ---------------- T2: FailedNodes (removal-pass tally) ----------------
|
||||
|
||||
/// <summary>A removal-pass alarm-condition write that throws is counted into FailedNodes (swallowed today).</summary>
|
||||
/// <summary>A removal-pass alarm-condition write that throws is counted into FailedNodes (swallowed today).
|
||||
/// The terminal condition write is made for a removed SCRIPTED ALARM — a removed equipment gets none (review
|
||||
/// 01 L4: an equipment id is a folder, never a condition, so writing one only minted a phantom).</summary>
|
||||
[Fact]
|
||||
public void Apply_WhenRemovalConditionWriteThrows_CountsFailedNodes()
|
||||
{
|
||||
var sink = new ConfigurableThrowingSink { ThrowOnAlarmWrite = true };
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
|
||||
var outcome = applier.Apply(AlarmRemovalPlan("alm-1"));
|
||||
|
||||
outcome.RemovedNodes.ShouldBe(1);
|
||||
outcome.FailedNodes.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>Review 01 L4 — removing an equipment writes NO condition state against the equipment id.</summary>
|
||||
[Fact]
|
||||
public void Apply_RemovedEquipment_WritesNoConditionAgainstTheEquipmentId()
|
||||
{
|
||||
var sink = new ConfigurableThrowingSink { ThrowOnAlarmWrite = true };
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
|
||||
var outcome = applier.Apply(EquipmentRemovalPlan("eq-1"));
|
||||
|
||||
outcome.RemovedNodes.ShouldBe(1);
|
||||
outcome.FailedNodes.ShouldBe(1);
|
||||
outcome.FailedNodes.ShouldBe(0, "no condition write happens for an equipment folder, so a throwing alarm write cannot be reached");
|
||||
}
|
||||
|
||||
// ---------------- T3: Materialise* passes return failed-node counts ----------------
|
||||
|
||||
/// <summary>Every EnsureVariable throwing in MaterialiseEquipmentTags is tallied into the pass's int return.</summary>
|
||||
/// <summary>Every EnsureVariable throwing in MaterialiseRawSubtree is tallied into the pass's int return.</summary>
|
||||
[Fact]
|
||||
public void MaterialiseEquipmentTags_WhenEnsureVariableThrows_ReturnsFailedCount()
|
||||
public void MaterialiseRawSubtree_WhenEnsureVariableThrows_ReturnsFailedCount()
|
||||
{
|
||||
var sink = new ConfigurableThrowingSink { ThrowOnEnsureVariable = true };
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
|
||||
var composition = CompositionWithTags(
|
||||
new EquipmentTagPlan("t1", "eq-1", "drv", FolderPath: "", Name: "A", DataType: "Float", FullName: "40001", Writable: false, Alarm: null),
|
||||
new EquipmentTagPlan("t2", "eq-1", "drv", FolderPath: "", Name: "B", DataType: "Float", FullName: "40002", Writable: false, Alarm: null),
|
||||
new EquipmentTagPlan("t3", "eq-1", "drv", FolderPath: "", Name: "C", DataType: "Float", FullName: "40003", Writable: false, Alarm: null));
|
||||
var composition = CompositionWithRawTags(RawTag("t1", "A"), RawTag("t2", "B"), RawTag("t3", "C"));
|
||||
|
||||
applier.MaterialiseEquipmentTags(composition).ShouldBe(3);
|
||||
applier.MaterialiseRawSubtree(composition).ShouldBe(3);
|
||||
}
|
||||
|
||||
/// <summary>All five Materialise* passes return 0 failures on a clean sink — the happy-path regression.</summary>
|
||||
/// <summary>All four Materialise* passes return 0 failures on a clean sink — the happy-path regression.</summary>
|
||||
[Fact]
|
||||
public void Materialise_HappyPath_AllPassesReturnZero()
|
||||
{
|
||||
var sink = new ConfigurableThrowingSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
|
||||
var tags = CompositionWithTags(
|
||||
new EquipmentTagPlan("t1", "eq-1", "drv", FolderPath: "", Name: "A", DataType: "Float", FullName: "40001", Writable: false, Alarm: null));
|
||||
var tags = CompositionWithRawTags(RawTag("t1", "A"));
|
||||
var vtags = CompositionWithVirtualTags(
|
||||
new EquipmentVirtualTagPlan("v1", "eq-1", FolderPath: "", Name: "VA", DataType: "Float", Expression: "1", DependencyRefs: Array.Empty<string>(), Historize: false));
|
||||
var alarms = CompositionWithScriptedAlarms(
|
||||
@@ -91,17 +102,21 @@ public sealed class AddressSpaceApplierFailureSurfaceTests
|
||||
Array.Empty<string>(), HistorizeToAveva: false, Retain: true, Enabled: true));
|
||||
|
||||
applier.MaterialiseHierarchy(tags).ShouldBe(0);
|
||||
applier.MaterialiseEquipmentTags(tags).ShouldBe(0);
|
||||
applier.MaterialiseRawSubtree(tags).ShouldBe(0);
|
||||
applier.MaterialiseEquipmentVirtualTags(vtags).ShouldBe(0);
|
||||
applier.MaterialiseScriptedAlarms(alarms).ShouldBe(0);
|
||||
}
|
||||
|
||||
// ---------------- fixtures ----------------
|
||||
|
||||
private static AddressSpaceComposition CompositionWithTags(params EquipmentTagPlan[] tags) =>
|
||||
private static RawTagPlan RawTag(string id, string name) =>
|
||||
new(id, $"Plant/dev1/{name}", ParentNodeId: null, "drv", Name: name, DataType: "Float", Writable: false, Alarm: null,
|
||||
ReferencingEquipmentPaths: Array.Empty<string>());
|
||||
|
||||
private static AddressSpaceComposition CompositionWithRawTags(params RawTagPlan[] tags) =>
|
||||
new(Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
|
||||
{
|
||||
EquipmentTags = tags,
|
||||
RawTags = tags,
|
||||
};
|
||||
|
||||
private static AddressSpaceComposition CompositionWithVirtualTags(params EquipmentVirtualTagPlan[] vtags) =>
|
||||
@@ -135,6 +150,17 @@ public sealed class AddressSpaceApplierFailureSurfaceTests
|
||||
RemovedAlarms: Array.Empty<ScriptedAlarmPlan>(),
|
||||
ChangedAlarms: Array.Empty<AddressSpacePlan.AlarmDelta>());
|
||||
|
||||
private static AddressSpacePlan AlarmRemovalPlan(params string[] ids) => new(
|
||||
AddedEquipment: Array.Empty<EquipmentNode>(),
|
||||
RemovedEquipment: Array.Empty<EquipmentNode>(),
|
||||
ChangedEquipment: Array.Empty<AddressSpacePlan.EquipmentDelta>(),
|
||||
AddedDrivers: Array.Empty<DriverInstancePlan>(),
|
||||
RemovedDrivers: Array.Empty<DriverInstancePlan>(),
|
||||
ChangedDrivers: Array.Empty<AddressSpacePlan.DriverDelta>(),
|
||||
AddedAlarms: Array.Empty<ScriptedAlarmPlan>(),
|
||||
RemovedAlarms: ids.Select(id => new ScriptedAlarmPlan(id, "eq-1", "pred", "msg")).ToArray(),
|
||||
ChangedAlarms: Array.Empty<AddressSpacePlan.AlarmDelta>());
|
||||
|
||||
private static AddressSpacePlan EquipmentRemovalPlan(params string[] ids) => new(
|
||||
AddedEquipment: Array.Empty<EquipmentNode>(),
|
||||
RemovedEquipment: ids.Select(id => new EquipmentNode(id, id, "line-1")).ToArray(),
|
||||
|
||||
+8
-7
@@ -110,13 +110,13 @@ public sealed class AddressSpaceApplierHierarchyTests : IDisposable
|
||||
sdkServer.NodeManager!.FolderCount.ShouldBe(5);
|
||||
}
|
||||
|
||||
/// <summary>Verifies MaterialiseEquipmentTags is idempotent against a real SDK node manager:
|
||||
/// <summary>Verifies MaterialiseRawSubtree is idempotent against a real SDK node manager:
|
||||
/// applying the same composition twice yields a single Variable node (no duplicates). This is the
|
||||
/// restart-safety guarantee — HandleRebuild runs on both the apply path and the
|
||||
/// DriverHostActor.RestoreApplied bootstrap path (same RebuildAddressSpace message), so a node
|
||||
/// restart re-runs this pass and must not double-materialise.</summary>
|
||||
[Fact]
|
||||
public async Task MaterialiseEquipmentTags_against_real_SDK_node_manager_is_idempotent()
|
||||
public async Task MaterialiseRawSubtree_against_real_SDK_node_manager_is_idempotent()
|
||||
{
|
||||
var applicationUri = $"urn:OtOpcUa.EquipmentTags:{Guid.NewGuid():N}";
|
||||
var started = await OpcUaHostStart.StartAsync(
|
||||
@@ -145,16 +145,17 @@ public sealed class AddressSpaceApplierHierarchyTests : IDisposable
|
||||
DriverInstancePlans: Array.Empty<DriverInstancePlan>(),
|
||||
ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>())
|
||||
{
|
||||
EquipmentTags = new[]
|
||||
RawTags = new[]
|
||||
{
|
||||
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null),
|
||||
new RawTagPlan("tag-1", "Plant/dev1/Speed", ParentNodeId: null, "drv", Name: "Speed", DataType: "Float",
|
||||
Writable: false, Alarm: null, ReferencingEquipmentPaths: Array.Empty<string>()),
|
||||
},
|
||||
};
|
||||
|
||||
// Equipment folder first (the variable's parent), then the tag pass — applied twice.
|
||||
// The raw pass — applied twice.
|
||||
applier.MaterialiseHierarchy(composition);
|
||||
applier.MaterialiseEquipmentTags(composition);
|
||||
applier.MaterialiseEquipmentTags(composition);
|
||||
applier.MaterialiseRawSubtree(composition);
|
||||
applier.MaterialiseRawSubtree(composition);
|
||||
|
||||
sdkServer.NodeManager!.VariableCount.ShouldBe(1); // single variable despite the double-apply
|
||||
}
|
||||
|
||||
@@ -12,30 +12,6 @@ public sealed class AddressSpaceApplierTests
|
||||
|
||||
|
||||
|
||||
/// <summary>R2-07 T2 — added equipment tags in an otherwise-empty plan are a PureAdd: the applier
|
||||
/// SKIPS the rebuild (the idempotent Materialise passes create the new variables; existing subscriptions
|
||||
/// survive). (Supersedes the pre-R2-07 "added tags ⇒ rebuild" pin.)</summary>
|
||||
[Fact]
|
||||
public void Added_equipment_tags_are_pure_add_and_skip_rebuild()
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
|
||||
var plan = EmptyPlan with
|
||||
{
|
||||
AddedEquipmentTags = new[]
|
||||
{
|
||||
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null),
|
||||
},
|
||||
};
|
||||
|
||||
var outcome = applier.Apply(plan);
|
||||
|
||||
outcome.RebuildCalled.ShouldBeFalse();
|
||||
outcome.AddedNodes.ShouldBe(1);
|
||||
sink.RebuildCalls.ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>R2-07 T2 — added Equipment VirtualTags in an otherwise-empty plan are a PureAdd: the applier
|
||||
/// SKIPS the rebuild (parity with the equipment-tag path). (Supersedes the pre-R2-07 "added vtags ⇒
|
||||
/// rebuild" pin.)</summary>
|
||||
@@ -61,27 +37,22 @@ public sealed class AddressSpaceApplierTests
|
||||
sink.RebuildCalls.ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>R2-07 T2 — a PureAdd plan carrying a coincident surgical tag delta (only Writable flips on
|
||||
/// an existing tag) SKIPS the rebuild AND applies the in-place tag update via the surgical sink. The
|
||||
/// classifier routes adds+surgical-change to PureAdd (rule 2 clears because the change is
|
||||
/// surgical-eligible); the applier still runs the F10b surgical pass.</summary>
|
||||
/// <summary>R2-07 T11 (raw-tag vehicle, review 01 U2 retired the equipment-tag one) — a remove-one-raw-tag
|
||||
/// plan is a PureRemove: the applier writes a terminal Bad to the removed variable then removes it in place
|
||||
/// via RemoveVariableNode in the RAW realm — NO rebuild. An alarm-bearing raw tag gets the terminal
|
||||
/// RemovedConditionState and a condition-node removal instead.</summary>
|
||||
[Fact]
|
||||
public void Pure_add_with_coincident_surgical_tag_delta_skips_rebuild_and_updates_in_place()
|
||||
public void Removed_raw_tags_write_terminal_state_and_remove_in_place_without_rebuild()
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
|
||||
var plan = EmptyPlan with
|
||||
{
|
||||
AddedEquipmentTags = new[]
|
||||
RemovedRawTags = new[]
|
||||
{
|
||||
new EquipmentTagPlan("tag-new", "eq-1", "drv", FolderPath: "", Name: "New", DataType: "Float", FullName: "40009", Writable: false, Alarm: null),
|
||||
},
|
||||
ChangedEquipmentTags = new[]
|
||||
{
|
||||
new AddressSpacePlan.EquipmentTagDelta(
|
||||
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null),
|
||||
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: true, Alarm: null)),
|
||||
RawTag("tag-1", "Plant/dev1/Speed"),
|
||||
RawTag("tag-alm", "Plant/dev1/OverTemp", alarm: new EquipmentTagAlarmInfo("OffNormalAlarm", 700)),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -89,125 +60,16 @@ public sealed class AddressSpaceApplierTests
|
||||
|
||||
outcome.RebuildCalled.ShouldBeFalse();
|
||||
sink.RebuildCalls.ShouldBe(0);
|
||||
var call = sink.SurgicalCalls.ShouldHaveSingleItem();
|
||||
call.NodeId.ShouldBe(V3NodeIds.Uns("eq-1", "Speed"));
|
||||
call.Writable.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>R2-07 T2 — during a PureAdd apply, if a coincident surgical tag update reports the node
|
||||
/// missing (returns false), the applier falls back to a full rebuild (the F10b safety valve is
|
||||
/// preserved through the classifier routing).</summary>
|
||||
[Fact]
|
||||
public void Pure_add_with_surgical_returns_false_falls_back_to_rebuild()
|
||||
{
|
||||
var sink = new RecordingSink { SurgicalReturns = false };
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
|
||||
var plan = EmptyPlan with
|
||||
{
|
||||
AddedEquipmentTags = new[]
|
||||
{
|
||||
new EquipmentTagPlan("tag-new", "eq-1", "drv", FolderPath: "", Name: "New", DataType: "Float", FullName: "40009", Writable: false, Alarm: null),
|
||||
},
|
||||
ChangedEquipmentTags = new[]
|
||||
{
|
||||
new AddressSpacePlan.EquipmentTagDelta(
|
||||
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null),
|
||||
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: true, Alarm: null)),
|
||||
},
|
||||
};
|
||||
|
||||
var outcome = applier.Apply(plan);
|
||||
|
||||
outcome.RebuildCalled.ShouldBeTrue(); // fell back to a full rebuild
|
||||
sink.RebuildCalls.ShouldBe(1);
|
||||
sink.SurgicalCalls.ShouldHaveSingleItem(); // the surgical update was attempted first
|
||||
}
|
||||
|
||||
/// <summary>R2-07 T11 — a remove-one-tag plan is a PureRemove: the applier writes a terminal Bad to the
|
||||
/// removed variable then removes it in place via RemoveVariableNode — NO rebuild. (Supersedes the T2
|
||||
/// phase pin.)</summary>
|
||||
[Fact]
|
||||
public void Removed_equipment_tag_writes_terminal_bad_and_removes_variable_without_rebuild()
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
|
||||
var plan = EmptyPlan with
|
||||
{
|
||||
RemovedEquipmentTags = new[]
|
||||
{
|
||||
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null),
|
||||
},
|
||||
};
|
||||
|
||||
var outcome = applier.Apply(plan);
|
||||
|
||||
outcome.RebuildCalled.ShouldBeFalse();
|
||||
sink.RebuildCalls.ShouldBe(0);
|
||||
var nodeId = V3NodeIds.Uns("eq-1", "Speed");
|
||||
// Terminal Bad written to the removed variable BEFORE the removal.
|
||||
sink.ValueWrites.ShouldContain((nodeId, OpcUaStatus.Bad, AddressSpaceRealm.Uns));
|
||||
sink.RemoveCalls.ShouldHaveSingleItem().ShouldBe(("var", nodeId, AddressSpaceRealm.Uns));
|
||||
}
|
||||
|
||||
/// <summary>R2-07 T11 — a removed alarm-bearing tag writes the terminal RemovedConditionState (closing
|
||||
/// the pre-R2-07 today-gap where a removed alarm tag got no condition write) then removes the CONDITION
|
||||
/// node in place (not a value variable).</summary>
|
||||
[Fact]
|
||||
public void Removed_alarm_bearing_tag_writes_removed_condition_state_and_removes_condition()
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
|
||||
var plan = EmptyPlan with
|
||||
{
|
||||
RemovedEquipmentTags = new[]
|
||||
{
|
||||
new EquipmentTagPlan("tag-alm", "eq-1", "drv", FolderPath: "", Name: "OverTemp", DataType: "Boolean", FullName: "00001", Writable: false,
|
||||
Alarm: new EquipmentTagAlarmInfo("OffNormalAlarm", 700)),
|
||||
},
|
||||
};
|
||||
|
||||
var outcome = applier.Apply(plan);
|
||||
|
||||
outcome.RebuildCalled.ShouldBeFalse();
|
||||
var nodeId = V3NodeIds.Uns("eq-1", "OverTemp");
|
||||
// Terminal RemovedConditionState (inactive/acked/confirmed) written to the condition node.
|
||||
sink.ValueWrites.ShouldContain(("Plant/dev1/Speed", OpcUaStatus.Bad, AddressSpaceRealm.Raw));
|
||||
var write = sink.AlarmWrites.ShouldHaveSingleItem();
|
||||
write.NodeId.ShouldBe(nodeId);
|
||||
write.NodeId.ShouldBe("Plant/dev1/OverTemp");
|
||||
(!write.State.Active && write.State.Acknowledged && write.State.Confirmed).ShouldBeTrue();
|
||||
// Removed as a condition node, not a value variable.
|
||||
sink.RemoveCalls.ShouldHaveSingleItem().ShouldBe(("alarm", nodeId, AddressSpaceRealm.Uns));
|
||||
sink.ValueWrites.ShouldNotContain(w => w.NodeId == nodeId);
|
||||
}
|
||||
|
||||
/// <summary>R2-07 T11 — a removed child (tag/vtag/alarm) whose equipment is ALSO removed is SUBSUMED by
|
||||
/// the equipment-subtree removal: only RemoveEquipmentSubtree fires, no individual child remove.</summary>
|
||||
[Fact]
|
||||
public void Removed_child_under_removed_equipment_is_subsumed_by_subtree_removal()
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
|
||||
var plan = EmptyPlan with
|
||||
sink.RemoveCalls.ShouldBe(new[]
|
||||
{
|
||||
RemovedEquipment = new[] { new EquipmentNode("eq-1", "One", "line-1") },
|
||||
RemovedEquipmentTags = new[]
|
||||
{
|
||||
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null),
|
||||
},
|
||||
RemovedEquipmentVirtualTags = new[]
|
||||
{
|
||||
new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Eff", DataType: "Float", Expression: "a", DependencyRefs: new[] { "a" }),
|
||||
},
|
||||
};
|
||||
|
||||
var outcome = applier.Apply(plan);
|
||||
|
||||
outcome.RebuildCalled.ShouldBeFalse();
|
||||
// ONLY the subtree removal fires — no individual var removal for the subsumed child tag/vtag.
|
||||
sink.RemoveCalls.ShouldHaveSingleItem().ShouldBe(("equipment", "eq-1", AddressSpaceRealm.Uns));
|
||||
("var", "Plant/dev1/Speed", AddressSpaceRealm.Raw),
|
||||
("alarm", "Plant/dev1/OverTemp", AddressSpaceRealm.Raw),
|
||||
});
|
||||
outcome.RemovedNodes.ShouldBe(2);
|
||||
}
|
||||
|
||||
/// <summary>R2-07 T11 — if a surgical remove reports the node unknown (RemoveReturns=false), the applier
|
||||
@@ -220,11 +82,7 @@ public sealed class AddressSpaceApplierTests
|
||||
|
||||
var plan = EmptyPlan with
|
||||
{
|
||||
RemovedEquipmentTags = new[]
|
||||
{
|
||||
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "A", DataType: "Float", FullName: "1", Writable: false, Alarm: null),
|
||||
new EquipmentTagPlan("tag-2", "eq-1", "drv", FolderPath: "", Name: "B", DataType: "Float", FullName: "2", Writable: false, Alarm: null),
|
||||
},
|
||||
RemovedRawTags = new[] { RawTag("tag-1", "Plant/dev1/A"), RawTag("tag-2", "Plant/dev1/B") },
|
||||
};
|
||||
|
||||
var outcome = applier.Apply(plan);
|
||||
@@ -243,13 +101,7 @@ public sealed class AddressSpaceApplierTests
|
||||
var sink = new PlainRecordingSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
|
||||
var plan = EmptyPlan with
|
||||
{
|
||||
RemovedEquipmentTags = new[]
|
||||
{
|
||||
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "A", DataType: "Float", FullName: "1", Writable: false, Alarm: null),
|
||||
},
|
||||
};
|
||||
var plan = EmptyPlan with { RemovedRawTags = new[] { RawTag("tag-1", "Plant/dev1/A") } };
|
||||
|
||||
var outcome = applier.Apply(plan);
|
||||
|
||||
@@ -259,7 +111,7 @@ public sealed class AddressSpaceApplierTests
|
||||
|
||||
/// <summary>R2-07 T13 — an add+remove plan is an AddRemoveMix: the applier removes the old node IN PLACE
|
||||
/// (RemoveVariableNode) and does NOT rebuild; the add is materialised afterward by the publish actor's
|
||||
/// passes. (Supersedes the T2 phase pin.)</summary>
|
||||
/// passes.</summary>
|
||||
[Fact]
|
||||
public void Add_and_remove_is_mixed_removes_in_place_without_rebuild()
|
||||
{
|
||||
@@ -268,14 +120,8 @@ public sealed class AddressSpaceApplierTests
|
||||
|
||||
var plan = EmptyPlan with
|
||||
{
|
||||
AddedEquipmentTags = new[]
|
||||
{
|
||||
new EquipmentTagPlan("tag-new", "eq-1", "drv", FolderPath: "", Name: "New", DataType: "Float", FullName: "40009", Writable: false, Alarm: null),
|
||||
},
|
||||
RemovedEquipmentTags = new[]
|
||||
{
|
||||
new EquipmentTagPlan("tag-old", "eq-1", "drv", FolderPath: "", Name: "Old", DataType: "Float", FullName: "40001", Writable: false, Alarm: null),
|
||||
},
|
||||
AddedRawTags = new[] { RawTag("tag-new", "Plant/dev1/New") },
|
||||
RemovedRawTags = new[] { RawTag("tag-old", "Plant/dev1/Old") },
|
||||
};
|
||||
|
||||
var outcome = applier.Apply(plan);
|
||||
@@ -283,40 +129,11 @@ public sealed class AddressSpaceApplierTests
|
||||
outcome.RebuildCalled.ShouldBeFalse();
|
||||
sink.RebuildCalls.ShouldBe(0);
|
||||
// The removed node is torn down in place; the add is left to the publish actor's materialise passes.
|
||||
sink.RemoveCalls.ShouldHaveSingleItem().ShouldBe(("var", V3NodeIds.Uns("eq-1", "Old"), AddressSpaceRealm.Uns));
|
||||
sink.RemoveCalls.ShouldHaveSingleItem().ShouldBe(("var", "Plant/dev1/Old", AddressSpaceRealm.Raw));
|
||||
outcome.AddedNodes.ShouldBe(1);
|
||||
outcome.RemovedNodes.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>R2-07 T13 — an id REUSED across the remove + add sets in one deploy (a tag removed at the
|
||||
/// same folder-scoped NodeId another tag is added at): the applier removes the old node in place (the
|
||||
/// recreate is the publish actor's job, a fresh NodeState). No rebuild.</summary>
|
||||
[Fact]
|
||||
public void Add_and_remove_reusing_same_node_id_removes_the_old_node_without_rebuild()
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
|
||||
var nodeId = V3NodeIds.Uns("eq-1", "Slot");
|
||||
var plan = EmptyPlan with
|
||||
{
|
||||
// Different TagId, but both resolve to the SAME folder-scoped NodeId (eq-1/Slot).
|
||||
RemovedEquipmentTags = new[]
|
||||
{
|
||||
new EquipmentTagPlan("tag-old", "eq-1", "drv", FolderPath: "", Name: "Slot", DataType: "Float", FullName: "40001", Writable: false, Alarm: null),
|
||||
},
|
||||
AddedEquipmentTags = new[]
|
||||
{
|
||||
new EquipmentTagPlan("tag-new", "eq-1", "drv", FolderPath: "", Name: "Slot", DataType: "Int32", FullName: "40002", Writable: false, Alarm: null),
|
||||
},
|
||||
};
|
||||
|
||||
var outcome = applier.Apply(plan);
|
||||
|
||||
outcome.RebuildCalled.ShouldBeFalse();
|
||||
sink.RemoveCalls.ShouldHaveSingleItem().ShouldBe(("var", nodeId, AddressSpaceRealm.Uns));
|
||||
}
|
||||
|
||||
/// <summary>R2-07 T13 — a remove failure inside an AddRemoveMix still trips the rebuild ratchet.</summary>
|
||||
[Fact]
|
||||
public void Add_and_remove_with_remove_returns_false_falls_back_to_rebuild()
|
||||
@@ -326,14 +143,8 @@ public sealed class AddressSpaceApplierTests
|
||||
|
||||
var plan = EmptyPlan with
|
||||
{
|
||||
AddedEquipmentTags = new[]
|
||||
{
|
||||
new EquipmentTagPlan("tag-new", "eq-1", "drv", FolderPath: "", Name: "New", DataType: "Float", FullName: "40009", Writable: false, Alarm: null),
|
||||
},
|
||||
RemovedEquipmentTags = new[]
|
||||
{
|
||||
new EquipmentTagPlan("tag-old", "eq-1", "drv", FolderPath: "", Name: "Old", DataType: "Float", FullName: "40001", Writable: false, Alarm: null),
|
||||
},
|
||||
AddedRawTags = new[] { RawTag("tag-new", "Plant/dev1/New") },
|
||||
RemovedRawTags = new[] { RawTag("tag-old", "Plant/dev1/Old") },
|
||||
};
|
||||
|
||||
var outcome = applier.Apply(plan);
|
||||
@@ -342,38 +153,28 @@ public sealed class AddressSpaceApplierTests
|
||||
sink.RebuildCalls.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>H1a — a deploy that ONLY changes an existing equipment tag in a NON-surgical way (here the
|
||||
/// driver-side <c>FullName</c> re-routes to a different point) must rebuild the address space. The planner
|
||||
/// diffs the tag into <c>ChangedEquipmentTags</c> with no Added/Removed of anything else; the applier must
|
||||
/// still drive exactly one rebuild so the running server drops the stale node and re-materialises it.
|
||||
/// (Surgically-applicable tag changes — Writable/Historizing/DataType/array-shape — take the in-place path
|
||||
/// instead; those are covered by the F10b + FB-7 surgical tests.)</summary>
|
||||
/// <summary>R2-07 T11 — a removed child (tag/vtag/alarm) whose equipment is ALSO removed is SUBSUMED by
|
||||
/// the equipment-subtree removal: only RemoveEquipmentSubtree fires, no individual child remove.</summary>
|
||||
[Fact]
|
||||
public void Changed_equipment_tags_only_trigger_rebuild()
|
||||
public void Removed_child_under_removed_equipment_is_subsumed_by_subtree_removal()
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
|
||||
var previous = CompositionWithTags(
|
||||
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null));
|
||||
// Same tag id, but the driver-side FullName flipped — a non-surgical change, so the applier rebuilds.
|
||||
var next = CompositionWithTags(
|
||||
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40002", Writable: false, Alarm: null));
|
||||
|
||||
var plan = AddressSpacePlanner.Compute(previous, next);
|
||||
|
||||
// Guard the arrange: ONLY ChangedEquipmentTags is populated.
|
||||
plan.ChangedEquipmentTags.Count.ShouldBe(1);
|
||||
plan.AddedEquipmentTags.ShouldBeEmpty();
|
||||
plan.RemovedEquipmentTags.ShouldBeEmpty();
|
||||
plan.AddedEquipment.ShouldBeEmpty();
|
||||
plan.RemovedEquipment.ShouldBeEmpty();
|
||||
var plan = EmptyPlan with
|
||||
{
|
||||
RemovedEquipment = new[] { new EquipmentNode("eq-1", "One", "line-1") },
|
||||
RemovedEquipmentVirtualTags = new[]
|
||||
{
|
||||
new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Eff", DataType: "Float", Expression: "a", DependencyRefs: new[] { "a" }),
|
||||
},
|
||||
};
|
||||
|
||||
var outcome = applier.Apply(plan);
|
||||
|
||||
outcome.RebuildCalled.ShouldBeTrue();
|
||||
outcome.ChangedNodes.ShouldBe(1);
|
||||
sink.RebuildCalls.ShouldBe(1);
|
||||
outcome.RebuildCalled.ShouldBeFalse();
|
||||
// ONLY the subtree removal fires — no individual var removal for the subsumed child vtag.
|
||||
sink.RemoveCalls.ShouldHaveSingleItem().ShouldBe(("equipment", "eq-1", AddressSpaceRealm.Uns));
|
||||
}
|
||||
|
||||
/// <summary>F10b (backlog #11) — a deploy that ONLY edits an existing VirtualTag's <c>Expression</c>
|
||||
@@ -553,26 +354,18 @@ public sealed class AddressSpaceApplierTests
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
|
||||
var previous = new AddressSpaceComposition(
|
||||
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
|
||||
new[] { new EquipmentNode("eq-1", "One", "line-1") }, Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
|
||||
{
|
||||
EquipmentTags = new[]
|
||||
{
|
||||
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null),
|
||||
},
|
||||
EquipmentVirtualTags = new[]
|
||||
{
|
||||
new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float64",
|
||||
Expression: "ctx.GetTag(\"a\") * 60", DependencyRefs: new[] { "a" }),
|
||||
},
|
||||
};
|
||||
// Expression-only vtag edit (node-irrelevant) AND a non-surgical tag change (FullName re-route).
|
||||
// Expression-only vtag edit (node-irrelevant) AND a node-affecting equipment change (rename).
|
||||
var next = new AddressSpaceComposition(
|
||||
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
|
||||
new[] { new EquipmentNode("eq-1", "One renamed", "line-1") }, Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
|
||||
{
|
||||
EquipmentTags = new[]
|
||||
{
|
||||
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40002", Writable: false, Alarm: null),
|
||||
},
|
||||
EquipmentVirtualTags = new[]
|
||||
{
|
||||
new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float64",
|
||||
@@ -582,9 +375,9 @@ public sealed class AddressSpaceApplierTests
|
||||
|
||||
var plan = AddressSpacePlanner.Compute(previous, next);
|
||||
|
||||
// Both a node-irrelevant vtag change AND a node-affecting tag change are present.
|
||||
// Both a node-irrelevant vtag change AND a node-affecting equipment change are present.
|
||||
plan.ChangedEquipmentVirtualTags.Count.ShouldBe(1);
|
||||
plan.ChangedEquipmentTags.Count.ShouldBe(1);
|
||||
plan.ChangedEquipment.Count.ShouldBe(1);
|
||||
|
||||
var outcome = applier.Apply(plan);
|
||||
|
||||
@@ -670,7 +463,6 @@ public sealed class AddressSpaceApplierTests
|
||||
plan.ChangedEquipmentVirtualTags.Count.ShouldBe(2);
|
||||
plan.AddedEquipmentVirtualTags.ShouldBeEmpty();
|
||||
plan.RemovedEquipmentVirtualTags.ShouldBeEmpty();
|
||||
plan.ChangedEquipmentTags.ShouldBeEmpty();
|
||||
plan.ChangedEquipment.ShouldBeEmpty();
|
||||
|
||||
var outcome = applier.Apply(plan);
|
||||
@@ -732,7 +524,6 @@ public sealed class AddressSpaceApplierTests
|
||||
plan.RemovedDrivers.ShouldBeEmpty();
|
||||
plan.ChangedEquipment.ShouldBeEmpty();
|
||||
plan.ChangedAlarms.ShouldBeEmpty();
|
||||
plan.ChangedEquipmentTags.ShouldBeEmpty();
|
||||
plan.ChangedEquipmentVirtualTags.ShouldBeEmpty();
|
||||
|
||||
var outcome = applier.Apply(plan);
|
||||
@@ -748,7 +539,7 @@ public sealed class AddressSpaceApplierTests
|
||||
/// to <c>removedCount</c> — <c>AddressSpaceApplyOutcome.RemovedNodes</c> reported 0, a misleading audit
|
||||
/// entry. This pins both the rebuild and the accurate count.</summary>
|
||||
[Fact]
|
||||
public void Removed_equipment_tags_and_virtual_tags_only_rebuild_and_are_counted()
|
||||
public void Removed_virtual_tags_only_remove_in_place_and_are_counted()
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
@@ -756,10 +547,6 @@ public sealed class AddressSpaceApplierTests
|
||||
var previous = new AddressSpaceComposition(
|
||||
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
|
||||
{
|
||||
EquipmentTags = new[]
|
||||
{
|
||||
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null),
|
||||
},
|
||||
EquipmentVirtualTags = new[]
|
||||
{
|
||||
new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float64",
|
||||
@@ -771,440 +558,23 @@ public sealed class AddressSpaceApplierTests
|
||||
|
||||
var plan = AddressSpacePlanner.Compute(previous, next);
|
||||
|
||||
// Guard the arrange: ONLY the two Removed sets are populated.
|
||||
plan.RemovedEquipmentTags.Count.ShouldBe(1);
|
||||
// Guard the arrange: ONLY the removed-vtag set is populated.
|
||||
plan.RemovedEquipmentVirtualTags.Count.ShouldBe(1);
|
||||
plan.AddedEquipmentTags.ShouldBeEmpty();
|
||||
plan.ChangedEquipmentTags.ShouldBeEmpty();
|
||||
plan.RemovedEquipment.ShouldBeEmpty();
|
||||
plan.RemovedAlarms.ShouldBeEmpty();
|
||||
|
||||
var outcome = applier.Apply(plan);
|
||||
|
||||
// R2-07 T11 — a removed-tag + removed-vtag plan is a PureRemove: both variables are torn down in
|
||||
// place (RemoveVariableNode ×2), NO full rebuild; both removals are still counted.
|
||||
// R2-07 T11 — a removed-vtag plan is a PureRemove: the variable is torn down in place
|
||||
// (RemoveVariableNode), NO full rebuild; the removal is still counted.
|
||||
outcome.RebuildCalled.ShouldBeFalse();
|
||||
sink.RebuildCalls.ShouldBe(0);
|
||||
outcome.RemovedNodes.ShouldBe(2); // both removals counted
|
||||
sink.RemoveCalls.Count.ShouldBe(2);
|
||||
sink.RemoveCalls.ShouldContain(("var", V3NodeIds.Uns("eq-1", "Speed"), AddressSpaceRealm.Uns));
|
||||
sink.RemoveCalls.ShouldContain(("var", V3NodeIds.Uns("eq-1", "Efficiency"), AddressSpaceRealm.Uns));
|
||||
outcome.RemovedNodes.ShouldBe(1);
|
||||
sink.RemoveCalls.ShouldHaveSingleItem().ShouldBe(("var", V3NodeIds.Uns("eq-1", "Efficiency"), AddressSpaceRealm.Uns));
|
||||
}
|
||||
|
||||
// ----- F10b: surgical in-place tag-attribute writes (Writable / IsHistorized / HistorianTagname) -----
|
||||
|
||||
/// <summary>F10b — a deploy that ONLY flips an existing equipment tag's <c>Writable</c> bit (a plain,
|
||||
/// non-array, non-alarm value variable with stable identity) must SKIP the rebuild and apply the change
|
||||
/// IN PLACE via <c>ISurgicalAddressSpaceSink.UpdateTagAttributes</c>, preserving every client's
|
||||
/// subscriptions. Exactly one surgical call lands with the NEW Writable value; the edit still counts as
|
||||
/// a change (ChangedNodes == 1).</summary>
|
||||
[Fact]
|
||||
public void Changed_tag_writable_only_skips_rebuild_and_updates_in_place()
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
|
||||
var previous = CompositionWithTags(
|
||||
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null));
|
||||
// Same TagId/identity; only Writable flips false → true.
|
||||
var next = CompositionWithTags(
|
||||
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: true, Alarm: null));
|
||||
|
||||
var plan = AddressSpacePlanner.Compute(previous, next);
|
||||
plan.ChangedEquipmentTags.Count.ShouldBe(1);
|
||||
plan.AddedEquipmentTags.ShouldBeEmpty();
|
||||
plan.RemovedEquipmentTags.ShouldBeEmpty();
|
||||
|
||||
var outcome = applier.Apply(plan);
|
||||
|
||||
outcome.RebuildCalled.ShouldBeFalse();
|
||||
sink.RebuildCalls.ShouldBe(0); // NO RebuildAddressSpace — subscriptions preserved
|
||||
var call = sink.SurgicalCalls.ShouldHaveSingleItem();
|
||||
call.NodeId.ShouldBe(V3NodeIds.Uns("eq-1", "Speed"));
|
||||
call.Writable.ShouldBeTrue(); // the NEW Writable value
|
||||
call.Historian.ShouldBeNull(); // not historized
|
||||
outcome.ChangedNodes.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>F10b — flipping <c>IsHistorized</c> false → true (no override) updates in place with the
|
||||
/// historian tagname defaulting to <c>FullName</c>; flipping true → false updates in place with a null
|
||||
/// historian tagname. Both skip the rebuild.</summary>
|
||||
[Fact]
|
||||
public void Changed_tag_is_historized_toggle_skips_rebuild_and_resolves_historian()
|
||||
{
|
||||
// false → true (no override) ⇒ historian defaults to FullName.
|
||||
var sinkOn = new RecordingSink();
|
||||
var applierOn = new AddressSpaceApplier(sinkOn, NullLogger<AddressSpaceApplier>.Instance);
|
||||
var planOn = AddressSpacePlanner.Compute(
|
||||
CompositionWithTags(new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float",
|
||||
FullName: "T.A", Writable: false, Alarm: null, IsHistorized: false, HistorianTagname: null)),
|
||||
CompositionWithTags(new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float",
|
||||
FullName: "T.A", Writable: false, Alarm: null, IsHistorized: true, HistorianTagname: null)));
|
||||
planOn.ChangedEquipmentTags.Count.ShouldBe(1);
|
||||
|
||||
var outcomeOn = applierOn.Apply(planOn);
|
||||
|
||||
outcomeOn.RebuildCalled.ShouldBeFalse();
|
||||
sinkOn.RebuildCalls.ShouldBe(0);
|
||||
sinkOn.SurgicalCalls.ShouldHaveSingleItem().Historian.ShouldBe("T.A"); // default ⇒ FullName
|
||||
|
||||
// true → false ⇒ historian null.
|
||||
var sinkOff = new RecordingSink();
|
||||
var applierOff = new AddressSpaceApplier(sinkOff, NullLogger<AddressSpaceApplier>.Instance);
|
||||
var planOff = AddressSpacePlanner.Compute(
|
||||
CompositionWithTags(new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float",
|
||||
FullName: "T.A", Writable: false, Alarm: null, IsHistorized: true, HistorianTagname: null)),
|
||||
CompositionWithTags(new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float",
|
||||
FullName: "T.A", Writable: false, Alarm: null, IsHistorized: false, HistorianTagname: null)));
|
||||
planOff.ChangedEquipmentTags.Count.ShouldBe(1);
|
||||
|
||||
var outcomeOff = applierOff.Apply(planOff);
|
||||
|
||||
outcomeOff.RebuildCalled.ShouldBeFalse();
|
||||
sinkOff.RebuildCalls.ShouldBe(0);
|
||||
sinkOff.SurgicalCalls.ShouldHaveSingleItem().Historian.ShouldBeNull(); // not historized ⇒ null
|
||||
}
|
||||
|
||||
/// <summary>F10b — changing ONLY the <c>HistorianTagname</c> override on an already-historized tag
|
||||
/// skips the rebuild and updates in place, passing the NEW override verbatim.</summary>
|
||||
[Fact]
|
||||
public void Changed_tag_historian_tagname_only_skips_rebuild()
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
|
||||
var previous = CompositionWithTags(
|
||||
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float",
|
||||
FullName: "T.A", Writable: false, Alarm: null, IsHistorized: true, HistorianTagname: "WW.Old"));
|
||||
var next = CompositionWithTags(
|
||||
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float",
|
||||
FullName: "T.A", Writable: false, Alarm: null, IsHistorized: true, HistorianTagname: "WW.New"));
|
||||
|
||||
var plan = AddressSpacePlanner.Compute(previous, next);
|
||||
plan.ChangedEquipmentTags.Count.ShouldBe(1);
|
||||
|
||||
var outcome = applier.Apply(plan);
|
||||
|
||||
outcome.RebuildCalled.ShouldBeFalse();
|
||||
sink.RebuildCalls.ShouldBe(0);
|
||||
sink.SurgicalCalls.ShouldHaveSingleItem().Historian.ShouldBe("WW.New"); // override verbatim
|
||||
}
|
||||
|
||||
/// <summary>FB-7 — a tag delta whose <c>DataType</c> changed is now surgical-eligible: the sink swaps the
|
||||
/// node's DataType in place (and raises a GeneralModelChangeEvent), so the applier SKIPS the rebuild and
|
||||
/// makes exactly one surgical call carrying the NEW DataType. Here Writable also flips, which the same
|
||||
/// in-place update applies. Subscriptions are preserved.</summary>
|
||||
[Fact]
|
||||
public void Changed_tag_data_type_change_skips_rebuild_and_updates_in_place()
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
|
||||
var previous = CompositionWithTags(
|
||||
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null));
|
||||
// DataType flips Float → Int32 AND Writable flips false → true — both are now surgically applied.
|
||||
var next = CompositionWithTags(
|
||||
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Int32", FullName: "40001", Writable: true, Alarm: null));
|
||||
|
||||
var plan = AddressSpacePlanner.Compute(previous, next);
|
||||
plan.ChangedEquipmentTags.Count.ShouldBe(1);
|
||||
|
||||
var outcome = applier.Apply(plan);
|
||||
|
||||
outcome.RebuildCalled.ShouldBeFalse();
|
||||
sink.RebuildCalls.ShouldBe(0); // NO rebuild — subscriptions preserved
|
||||
var call = sink.SurgicalCalls.ShouldHaveSingleItem();
|
||||
call.NodeId.ShouldBe(V3NodeIds.Uns("eq-1", "Speed"));
|
||||
call.DataType.ShouldBe("Int32"); // the NEW DataType
|
||||
call.Writable.ShouldBeTrue(); // the NEW Writable, applied in the same call
|
||||
call.IsArray.ShouldBeFalse();
|
||||
outcome.ChangedNodes.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>FB-7 — a tag delta whose <c>IsArray</c> flag flips scalar → array is now surgical-eligible:
|
||||
/// the sink swaps ValueRank + ArrayDimensions in place, so the applier skips the rebuild and the surgical
|
||||
/// call carries the new array shape. An array tag is forced read-only (matching EnsureVariable), so the
|
||||
/// surgical Writable is false even though the tag stays non-writable here.</summary>
|
||||
[Fact]
|
||||
public void Changed_tag_is_array_change_skips_rebuild_and_updates_in_place()
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
|
||||
var previous = CompositionWithTags(
|
||||
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Buffer", DataType: "Int16",
|
||||
FullName: "40001", Writable: false, Alarm: null, IsArray: false, ArrayLength: null));
|
||||
var next = CompositionWithTags(
|
||||
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Buffer", DataType: "Int16",
|
||||
FullName: "40001", Writable: false, Alarm: null, IsArray: true, ArrayLength: 16u));
|
||||
|
||||
var plan = AddressSpacePlanner.Compute(previous, next);
|
||||
plan.ChangedEquipmentTags.Count.ShouldBe(1);
|
||||
|
||||
var outcome = applier.Apply(plan);
|
||||
|
||||
outcome.RebuildCalled.ShouldBeFalse();
|
||||
sink.RebuildCalls.ShouldBe(0);
|
||||
var call = sink.SurgicalCalls.ShouldHaveSingleItem();
|
||||
call.IsArray.ShouldBeTrue(); // the NEW array shape
|
||||
call.ArrayLength.ShouldBe(16u);
|
||||
call.DataType.ShouldBe("Int16"); // element type unchanged
|
||||
call.Writable.ShouldBeFalse(); // array tag forced read-only
|
||||
}
|
||||
|
||||
/// <summary>F10b safe-default — a tag delta whose driver-side <c>FullName</c> changed is NOT
|
||||
/// surgical-eligible (it re-routes the node to a different driver point), so the applier rebuilds.</summary>
|
||||
[Fact]
|
||||
public void Changed_tag_full_name_change_rebuilds()
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
|
||||
var previous = CompositionWithTags(
|
||||
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null));
|
||||
var next = CompositionWithTags(
|
||||
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40002", Writable: false, Alarm: null));
|
||||
|
||||
var plan = AddressSpacePlanner.Compute(previous, next);
|
||||
plan.ChangedEquipmentTags.Count.ShouldBe(1);
|
||||
|
||||
var outcome = applier.Apply(plan);
|
||||
|
||||
outcome.RebuildCalled.ShouldBeTrue();
|
||||
sink.RebuildCalls.ShouldBe(1);
|
||||
sink.SurgicalCalls.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>F10b safe-default — a tag delta whose <c>Name</c> changed is NOT surgical-eligible (the
|
||||
/// folder-scoped NodeId + BrowseName derive from Name), so the applier rebuilds.</summary>
|
||||
[Fact]
|
||||
public void Changed_tag_name_change_rebuilds()
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
|
||||
var previous = CompositionWithTags(
|
||||
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null));
|
||||
var next = CompositionWithTags(
|
||||
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "SpeedRpm", DataType: "Float", FullName: "40001", Writable: false, Alarm: null));
|
||||
|
||||
var plan = AddressSpacePlanner.Compute(previous, next);
|
||||
plan.ChangedEquipmentTags.Count.ShouldBe(1);
|
||||
|
||||
var outcome = applier.Apply(plan);
|
||||
|
||||
outcome.RebuildCalled.ShouldBeTrue();
|
||||
sink.RebuildCalls.ShouldBe(1);
|
||||
sink.SurgicalCalls.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>F10b safe-default — a tag delta where an alarm appears (Alarm null → non-null) is NOT
|
||||
/// surgical-eligible: the tag flips from a plain value variable to a Part 9 condition node, which only
|
||||
/// a rebuild can materialise. No surgical call is made.</summary>
|
||||
[Fact]
|
||||
public void Changed_tag_alarm_presence_change_rebuilds()
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
|
||||
var previous = CompositionWithTags(
|
||||
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "OverTemp", DataType: "Boolean", FullName: "00001", Writable: false, Alarm: null));
|
||||
var next = CompositionWithTags(
|
||||
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "OverTemp", DataType: "Boolean", FullName: "00001", Writable: false,
|
||||
Alarm: new EquipmentTagAlarmInfo("OffNormalAlarm", 700)));
|
||||
|
||||
var plan = AddressSpacePlanner.Compute(previous, next);
|
||||
plan.ChangedEquipmentTags.Count.ShouldBe(1);
|
||||
|
||||
var outcome = applier.Apply(plan);
|
||||
|
||||
outcome.RebuildCalled.ShouldBeTrue();
|
||||
sink.RebuildCalls.ShouldBe(1);
|
||||
sink.SurgicalCalls.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>F10b guard — a tag that ALREADY carries an alarm (<c>Alarm is not null</c> on BOTH previous
|
||||
/// and current, identical alarm info) is NOT surgical-eligible even when only <c>Writable</c> changes.
|
||||
/// The explicit <c>Alarm is null</c> guard in <c>TagDeltaIsSurgicalEligible</c> prevents a false
|
||||
/// positive: without it the <c>with { Writable = … }</c> override (which does NOT touch <c>Alarm</c>)
|
||||
/// would leave both sides with equal <c>Alarm</c> values and the delta would WRONGLY look surgical.
|
||||
/// An alarm-bearing tag is a Part 9 condition node, not a plain value variable, so any change to it
|
||||
/// must go through a full rebuild.</summary>
|
||||
[Fact]
|
||||
public void Changed_alarm_bearing_tag_writable_only_still_rebuilds()
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
|
||||
// Both previous and current carry an identical, non-null alarm — the tag is an alarm-bearing node.
|
||||
var alarm = new EquipmentTagAlarmInfo("OffNormalAlarm", 700);
|
||||
var previous = CompositionWithTags(
|
||||
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "OverTemp", DataType: "Boolean",
|
||||
FullName: "00001", Writable: false, Alarm: alarm));
|
||||
// Only Writable flips; alarm is unchanged (and still non-null on both sides).
|
||||
var next = CompositionWithTags(
|
||||
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "OverTemp", DataType: "Boolean",
|
||||
FullName: "00001", Writable: true, Alarm: alarm));
|
||||
|
||||
var plan = AddressSpacePlanner.Compute(previous, next);
|
||||
|
||||
// Guard the arrange: the planner sees a changed tag (Writable differs) and nothing else.
|
||||
plan.ChangedEquipmentTags.Count.ShouldBe(1);
|
||||
plan.AddedEquipmentTags.ShouldBeEmpty();
|
||||
plan.RemovedEquipmentTags.ShouldBeEmpty();
|
||||
|
||||
var outcome = applier.Apply(plan);
|
||||
|
||||
// The alarm-bearing tag is NOT surgical-eligible — it must rebuild and make NO surgical call.
|
||||
outcome.RebuildCalled.ShouldBeTrue();
|
||||
sink.RebuildCalls.ShouldBe(1);
|
||||
sink.SurgicalCalls.ShouldBeEmpty(); // NO surgical write on an alarm-bearing tag
|
||||
}
|
||||
|
||||
/// <summary>F10b multi-delta — when a plan contains TWO distinct surgical-eligible tag deltas (plain
|
||||
/// value variables, no alarm, both changing only <c>Writable</c>), the applier must apply BOTH in
|
||||
/// place (two <c>UpdateTagAttributes</c> calls) without triggering any rebuild. Proves the surgical
|
||||
/// path iterates all eligible deltas rather than stopping after the first.</summary>
|
||||
[Fact]
|
||||
public void Two_surgical_eligible_tag_deltas_both_apply_in_place_no_rebuild()
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
|
||||
// Two distinct plain (no-alarm) tags, both will flip Writable — both are surgical-eligible.
|
||||
var previous = new AddressSpaceComposition(
|
||||
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
|
||||
{
|
||||
EquipmentTags = new[]
|
||||
{
|
||||
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null),
|
||||
new EquipmentTagPlan("tag-2", "eq-1", "drv", FolderPath: "", Name: "Pressure", DataType: "Float", FullName: "40002", Writable: false, Alarm: null),
|
||||
},
|
||||
};
|
||||
// Only Writable flips on both tags; everything else is identical.
|
||||
var next = new AddressSpaceComposition(
|
||||
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
|
||||
{
|
||||
EquipmentTags = new[]
|
||||
{
|
||||
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: true, Alarm: null),
|
||||
new EquipmentTagPlan("tag-2", "eq-1", "drv", FolderPath: "", Name: "Pressure", DataType: "Float", FullName: "40002", Writable: true, Alarm: null),
|
||||
},
|
||||
};
|
||||
|
||||
var plan = AddressSpacePlanner.Compute(previous, next);
|
||||
|
||||
// Guard the arrange: exactly two changed tags, nothing else.
|
||||
plan.ChangedEquipmentTags.Count.ShouldBe(2);
|
||||
plan.AddedEquipmentTags.ShouldBeEmpty();
|
||||
plan.RemovedEquipmentTags.ShouldBeEmpty();
|
||||
plan.AddedEquipment.ShouldBeEmpty();
|
||||
plan.RemovedEquipment.ShouldBeEmpty();
|
||||
|
||||
var outcome = applier.Apply(plan);
|
||||
|
||||
// No rebuild — both deltas are surgical-eligible.
|
||||
outcome.RebuildCalled.ShouldBeFalse();
|
||||
sink.RebuildCalls.ShouldBe(0);
|
||||
|
||||
// Exactly two surgical calls — one per eligible delta.
|
||||
sink.SurgicalCalls.Count.ShouldBe(2);
|
||||
|
||||
// Both expected node ids must appear (order is not guaranteed).
|
||||
var nodeId1 = V3NodeIds.Uns("eq-1", "Speed");
|
||||
var nodeId2 = V3NodeIds.Uns("eq-1", "Pressure");
|
||||
sink.SurgicalCalls.ShouldContain(c => c.NodeId == nodeId1 && c.Writable);
|
||||
sink.SurgicalCalls.ShouldContain(c => c.NodeId == nodeId2 && c.Writable);
|
||||
|
||||
outcome.ChangedNodes.ShouldBe(2);
|
||||
}
|
||||
|
||||
/// <summary>R2-07 T2 — a surgical-eligible tag delta MIXED with an added equipment is now a PureAdd:
|
||||
/// the added equipment is created by the Materialise passes and the surgical tag change is applied IN
|
||||
/// PLACE, so NO rebuild fires and exactly one surgical call lands. (Supersedes the pre-R2-07
|
||||
/// "surgical-mixed-with-add ⇒ rebuild" pin — an add no longer forces a rebuild.)</summary>
|
||||
[Fact]
|
||||
public void Surgical_eligible_tag_delta_mixed_with_added_equipment_is_pure_add_and_skips_rebuild()
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
|
||||
var previous = new AddressSpaceComposition(
|
||||
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
|
||||
{
|
||||
EquipmentTags = new[]
|
||||
{
|
||||
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null),
|
||||
},
|
||||
};
|
||||
// Surgical-eligible Writable flip on the tag AND a brand-new equipment node.
|
||||
var next = new AddressSpaceComposition(
|
||||
new[] { new EquipmentNode("eq-new", "New", "line-1") }, Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
|
||||
{
|
||||
EquipmentTags = new[]
|
||||
{
|
||||
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: true, Alarm: null),
|
||||
},
|
||||
};
|
||||
|
||||
var plan = AddressSpacePlanner.Compute(previous, next);
|
||||
plan.ChangedEquipmentTags.Count.ShouldBe(1);
|
||||
plan.AddedEquipment.Count.ShouldBe(1);
|
||||
|
||||
var outcome = applier.Apply(plan);
|
||||
|
||||
outcome.RebuildCalled.ShouldBeFalse();
|
||||
sink.RebuildCalls.ShouldBe(0);
|
||||
sink.SurgicalCalls.ShouldHaveSingleItem().Writable.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>F10b fallback — a sink that does NOT implement <see cref="ISurgicalAddressSpaceSink"/> cannot
|
||||
/// apply the in-place update, so even a surgical-eligible (Writable-only) tag delta drives a full
|
||||
/// rebuild (safe default).</summary>
|
||||
[Fact]
|
||||
public void Surgical_eligible_delta_on_non_surgical_sink_rebuilds()
|
||||
{
|
||||
var sink = new PlainRecordingSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
|
||||
var previous = CompositionWithTags(
|
||||
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null));
|
||||
var next = CompositionWithTags(
|
||||
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: true, Alarm: null));
|
||||
|
||||
var plan = AddressSpacePlanner.Compute(previous, next);
|
||||
plan.ChangedEquipmentTags.Count.ShouldBe(1);
|
||||
|
||||
var outcome = applier.Apply(plan);
|
||||
|
||||
outcome.RebuildCalled.ShouldBeTrue();
|
||||
sink.RebuildCalls.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>F10b fallback — when the surgical sink reports the node MISSING
|
||||
/// (<c>UpdateTagAttributes</c> returns false), the applier falls back to a full rebuild. The surgical
|
||||
/// call is still attempted (recorded once) before the fallback fires.</summary>
|
||||
[Fact]
|
||||
public void Surgical_sink_returning_false_node_missing_falls_back_to_rebuild()
|
||||
{
|
||||
var sink = new RecordingSink { SurgicalReturns = false };
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
|
||||
var previous = CompositionWithTags(
|
||||
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null));
|
||||
var next = CompositionWithTags(
|
||||
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: true, Alarm: null));
|
||||
|
||||
var plan = AddressSpacePlanner.Compute(previous, next);
|
||||
plan.ChangedEquipmentTags.Count.ShouldBe(1);
|
||||
|
||||
var outcome = applier.Apply(plan);
|
||||
|
||||
outcome.RebuildCalled.ShouldBeTrue();
|
||||
sink.RebuildCalls.ShouldBe(1); // fell back to a full rebuild
|
||||
sink.SurgicalCalls.ShouldHaveSingleItem(); // the surgical update was attempted first
|
||||
}
|
||||
|
||||
// ----- OpcUaServer-001: in-place UNS Area / Line folder rename (no rebuild) -----
|
||||
|
||||
/// <summary>OpcUaServer-001 — a deploy whose ONLY change is a UNS Area rename must SKIP the rebuild and
|
||||
@@ -1337,42 +707,6 @@ public sealed class AddressSpaceApplierTests
|
||||
|
||||
// ----- R2-07 T3: pure-add NodeAdded announcements -----
|
||||
|
||||
/// <summary>Two tags added under the SAME equipment (no FolderPath) dedup to ONE announcement for the
|
||||
/// equipment id.</summary>
|
||||
[Fact]
|
||||
public void ComputeAddAnnouncements_two_tags_under_same_equipment_dedup_to_one()
|
||||
{
|
||||
var applier = new AddressSpaceApplier(new RecordingSink(), NullLogger<AddressSpaceApplier>.Instance);
|
||||
|
||||
var plan = EmptyPlan with
|
||||
{
|
||||
AddedEquipmentTags = new[]
|
||||
{
|
||||
new EquipmentTagPlan("t1", "eq-1", "drv", FolderPath: "", Name: "A", DataType: "Float", FullName: "1", Writable: false, Alarm: null),
|
||||
new EquipmentTagPlan("t2", "eq-1", "drv", FolderPath: "", Name: "B", DataType: "Float", FullName: "2", Writable: false, Alarm: null),
|
||||
},
|
||||
};
|
||||
|
||||
applier.ComputeAddAnnouncements(plan).ShouldBe(new[] { "eq-1" });
|
||||
}
|
||||
|
||||
/// <summary>A tag with a FolderPath announces its SUB-FOLDER (the materialise parent), not the equipment.</summary>
|
||||
[Fact]
|
||||
public void ComputeAddAnnouncements_tag_with_folder_path_announces_subfolder()
|
||||
{
|
||||
var applier = new AddressSpaceApplier(new RecordingSink(), NullLogger<AddressSpaceApplier>.Instance);
|
||||
|
||||
var plan = EmptyPlan with
|
||||
{
|
||||
AddedEquipmentTags = new[]
|
||||
{
|
||||
new EquipmentTagPlan("t1", "eq-1", "drv", FolderPath: "Diag", Name: "A", DataType: "Float", FullName: "1", Writable: false, Alarm: null),
|
||||
},
|
||||
};
|
||||
|
||||
applier.ComputeAddAnnouncements(plan).ShouldBe(new[] { V3NodeIds.Uns("eq-1", "Diag") });
|
||||
}
|
||||
|
||||
/// <summary>An added scripted alarm announces its equipment folder (where its condition node parents).</summary>
|
||||
[Fact]
|
||||
public void ComputeAddAnnouncements_added_alarm_announces_equipment()
|
||||
@@ -1445,11 +779,11 @@ public sealed class AddressSpaceApplierTests
|
||||
var plan = EmptyPlan with
|
||||
{
|
||||
AddedEquipment = new[] { new EquipmentNode("eq-2", "Two", "line-7") },
|
||||
AddedEquipmentTags = new[]
|
||||
AddedEquipmentVirtualTags = new[]
|
||||
{
|
||||
new EquipmentTagPlan("t1", "eq-1", "drv", FolderPath: "", Name: "A", DataType: "Float", FullName: "1", Writable: false, Alarm: null),
|
||||
new EquipmentTagPlan("t2", "eq-1", "drv", FolderPath: "", Name: "B", DataType: "Float", FullName: "2", Writable: false, Alarm: null),
|
||||
new EquipmentTagPlan("t3", "eq-1", "drv", FolderPath: "Diag", Name: "C", DataType: "Float", FullName: "3", Writable: false, Alarm: null),
|
||||
new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "A", DataType: "Float", Expression: "1", DependencyRefs: Array.Empty<string>()),
|
||||
new EquipmentVirtualTagPlan("vt-2", "eq-1", FolderPath: "", Name: "B", DataType: "Float", Expression: "2", DependencyRefs: Array.Empty<string>()),
|
||||
new EquipmentVirtualTagPlan("vt-3", "eq-1", FolderPath: "Diag", Name: "C", DataType: "Float", Expression: "3", DependencyRefs: Array.Empty<string>()),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1855,13 +1189,6 @@ public sealed class AddressSpaceApplierTests
|
||||
Array.Empty<UnsAreaProjection>(), lines, Array.Empty<EquipmentNode>(),
|
||||
Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>());
|
||||
|
||||
private static AddressSpaceComposition CompositionWithTags(params EquipmentTagPlan[] tags) =>
|
||||
new(
|
||||
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
|
||||
{
|
||||
EquipmentTags = tags,
|
||||
};
|
||||
|
||||
private static AddressSpaceComposition CompositionWithVirtualTags(params EquipmentVirtualTagPlan[] vtags) =>
|
||||
new(
|
||||
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
|
||||
@@ -1878,6 +1205,10 @@ public sealed class AddressSpaceApplierTests
|
||||
new(
|
||||
Array.Empty<EquipmentNode>(), drivers, Array.Empty<ScriptedAlarmPlan>());
|
||||
|
||||
private static RawTagPlan RawTag(string tagId, string rawPath, EquipmentTagAlarmInfo? alarm = null) =>
|
||||
new(tagId, rawPath, ParentNodeId: null, "drv", Name: rawPath[(rawPath.LastIndexOf('/') + 1)..], DataType: "Float",
|
||||
Writable: false, Alarm: alarm, ReferencingEquipmentPaths: Array.Empty<string>());
|
||||
|
||||
private static AddressSpacePlan EmptyPlan => new(
|
||||
Array.Empty<EquipmentNode>(), Array.Empty<EquipmentNode>(), Array.Empty<AddressSpacePlan.EquipmentDelta>(),
|
||||
Array.Empty<DriverInstancePlan>(), Array.Empty<DriverInstancePlan>(), Array.Empty<AddressSpacePlan.DriverDelta>(),
|
||||
|
||||
+9
-66
@@ -35,7 +35,7 @@ public sealed class AddressSpaceChangeClassifierTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Added_equipment_tag_only_is_PureAdd()
|
||||
public void Added_raw_tag_only_is_PureAdd()
|
||||
{
|
||||
var plan = Plan(addedTags: new[] { Tag("tag-1", "eq-1") });
|
||||
AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.PureAdd);
|
||||
@@ -65,7 +65,7 @@ public sealed class AddressSpaceChangeClassifierTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Removed_equipment_tag_only_is_PureRemove()
|
||||
public void Removed_raw_tag_only_is_PureRemove()
|
||||
{
|
||||
var plan = Plan(removedTags: new[] { Tag("tag-1", "eq-1") });
|
||||
AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.PureRemove);
|
||||
@@ -116,19 +116,6 @@ public sealed class AddressSpaceChangeClassifierTests
|
||||
AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.Rebuild);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Non_surgical_changed_tag_is_Rebuild()
|
||||
{
|
||||
// FullName re-routes the node to a different driver point ⇒ NOT surgical-eligible ⇒ Rebuild.
|
||||
var plan = Plan(changedTags: new[]
|
||||
{
|
||||
new AddressSpacePlan.EquipmentTagDelta(
|
||||
Tag("tag-1", "eq-1", fullName: "40001"),
|
||||
Tag("tag-1", "eq-1", fullName: "40002")),
|
||||
});
|
||||
AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.Rebuild);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Node_relevant_changed_vtag_is_Rebuild()
|
||||
{
|
||||
@@ -144,19 +131,6 @@ public sealed class AddressSpaceChangeClassifierTests
|
||||
|
||||
// ----- surgical-only / rename-only / driver-only ⇒ AttributeOnly -----
|
||||
|
||||
[Fact]
|
||||
public void Surgical_eligible_changed_tag_only_is_AttributeOnly()
|
||||
{
|
||||
// Only Writable flips ⇒ surgical-eligible ⇒ AttributeOnly (no adds, no removes).
|
||||
var plan = Plan(changedTags: new[]
|
||||
{
|
||||
new AddressSpacePlan.EquipmentTagDelta(
|
||||
Tag("tag-1", "eq-1", writable: false),
|
||||
Tag("tag-1", "eq-1", writable: true)),
|
||||
});
|
||||
AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.AttributeOnly);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Node_irrelevant_changed_vtag_only_is_AttributeOnly()
|
||||
{
|
||||
@@ -195,36 +169,6 @@ public sealed class AddressSpaceChangeClassifierTests
|
||||
|
||||
// ----- mixed-with-surgical: adds still win over coincident surgical changes -----
|
||||
|
||||
[Fact]
|
||||
public void Adds_with_coincident_surgical_change_is_PureAdd()
|
||||
{
|
||||
var plan = Plan(
|
||||
addedTags: new[] { Tag("tag-new", "eq-1") },
|
||||
changedTags: new[]
|
||||
{
|
||||
new AddressSpacePlan.EquipmentTagDelta(
|
||||
Tag("tag-1", "eq-1", writable: false),
|
||||
Tag("tag-1", "eq-1", writable: true)),
|
||||
});
|
||||
AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.PureAdd);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Adds_with_coincident_non_surgical_change_is_Rebuild()
|
||||
{
|
||||
// Rule 2 (node-affecting change) is evaluated BEFORE the add/remove split, so a non-surgical
|
||||
// change forces Rebuild even alongside pure adds.
|
||||
var plan = Plan(
|
||||
addedTags: new[] { Tag("tag-new", "eq-1") },
|
||||
changedTags: new[]
|
||||
{
|
||||
new AddressSpacePlan.EquipmentTagDelta(
|
||||
Tag("tag-1", "eq-1", fullName: "40001"),
|
||||
Tag("tag-1", "eq-1", fullName: "40002")),
|
||||
});
|
||||
AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.Rebuild);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Adds_with_coincident_folder_rename_is_PureAdd()
|
||||
{
|
||||
@@ -240,8 +184,9 @@ public sealed class AddressSpaceChangeClassifierTests
|
||||
|
||||
private static ScriptedAlarmPlan Alarm(string id) => new(id, "eq-1", "scr-1", "msg");
|
||||
|
||||
private static EquipmentTagPlan Tag(string id, string equipmentId, string fullName = "40001", bool writable = false) =>
|
||||
new(id, equipmentId, "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: fullName, Writable: writable, Alarm: null);
|
||||
private static RawTagPlan Tag(string id, string device) =>
|
||||
new(id, $"{device}/{id}", ParentNodeId: device, "drv", Name: id, DataType: "Float", Writable: false, Alarm: null,
|
||||
ReferencingEquipmentPaths: Array.Empty<string>());
|
||||
|
||||
private static EquipmentVirtualTagPlan Vtag(string id, string equipmentId, string name = "Efficiency", string expression = "ctx.GetTag(\"a\")") =>
|
||||
new(id, equipmentId, FolderPath: "", Name: name, DataType: "Float64", Expression: expression, DependencyRefs: new[] { "a" });
|
||||
@@ -256,9 +201,8 @@ public sealed class AddressSpaceChangeClassifierTests
|
||||
IReadOnlyList<ScriptedAlarmPlan>? addedAlarms = null,
|
||||
IReadOnlyList<ScriptedAlarmPlan>? removedAlarms = null,
|
||||
IReadOnlyList<AddressSpacePlan.AlarmDelta>? changedAlarms = null,
|
||||
IReadOnlyList<EquipmentTagPlan>? addedTags = null,
|
||||
IReadOnlyList<EquipmentTagPlan>? removedTags = null,
|
||||
IReadOnlyList<AddressSpacePlan.EquipmentTagDelta>? changedTags = null,
|
||||
IReadOnlyList<RawTagPlan>? addedTags = null,
|
||||
IReadOnlyList<RawTagPlan>? removedTags = null,
|
||||
IReadOnlyList<EquipmentVirtualTagPlan>? addedVtags = null,
|
||||
IReadOnlyList<EquipmentVirtualTagPlan>? removedVtags = null,
|
||||
IReadOnlyList<AddressSpacePlan.EquipmentVirtualTagDelta>? changedVtags = null,
|
||||
@@ -274,9 +218,8 @@ public sealed class AddressSpaceChangeClassifierTests
|
||||
removedAlarms ?? Array.Empty<ScriptedAlarmPlan>(),
|
||||
changedAlarms ?? Array.Empty<AddressSpacePlan.AlarmDelta>())
|
||||
{
|
||||
AddedEquipmentTags = addedTags ?? Array.Empty<EquipmentTagPlan>(),
|
||||
RemovedEquipmentTags = removedTags ?? Array.Empty<EquipmentTagPlan>(),
|
||||
ChangedEquipmentTags = changedTags ?? Array.Empty<AddressSpacePlan.EquipmentTagDelta>(),
|
||||
AddedRawTags = addedTags ?? Array.Empty<RawTagPlan>(),
|
||||
RemovedRawTags = removedTags ?? Array.Empty<RawTagPlan>(),
|
||||
AddedEquipmentVirtualTags = addedVtags ?? Array.Empty<EquipmentVirtualTagPlan>(),
|
||||
RemovedEquipmentVirtualTags = removedVtags ?? Array.Empty<EquipmentVirtualTagPlan>(),
|
||||
ChangedEquipmentVirtualTags = changedVtags ?? Array.Empty<AddressSpacePlan.EquipmentVirtualTagDelta>(),
|
||||
|
||||
@@ -30,31 +30,6 @@ public sealed class AddressSpacePlannerTests
|
||||
plan.IsEmpty.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>Verifies an equipment-tag-only delta (no equipment/driver/alarm/galaxy change)
|
||||
/// yields a NON-empty plan, so OpcUaPublishActor.HandleRebuild does not short-circuit at the
|
||||
/// IsEmpty gate before materialising the new equipment variables.</summary>
|
||||
[Fact]
|
||||
public void Equipment_tag_only_change_yields_non_empty_plan_with_added_tag()
|
||||
{
|
||||
var prev = new AddressSpaceComposition(
|
||||
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>());
|
||||
var next = new AddressSpaceComposition(
|
||||
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
|
||||
{
|
||||
EquipmentTags = new[]
|
||||
{
|
||||
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null),
|
||||
},
|
||||
};
|
||||
|
||||
var plan = AddressSpacePlanner.Compute(prev, next);
|
||||
|
||||
plan.IsEmpty.ShouldBeFalse();
|
||||
plan.AddedEquipmentTags.Single().TagId.ShouldBe("tag-1");
|
||||
plan.RemovedEquipmentTags.ShouldBeEmpty();
|
||||
plan.ChangedEquipmentTags.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>Verifies a VirtualTag-only delta (no equipment/driver/alarm/galaxy/tag change)
|
||||
/// yields a NON-empty plan with the new VirtualTag in AddedEquipmentVirtualTags, so a deploy that
|
||||
/// only adds VirtualTags is no longer a silent no-op at the IsEmpty gate.</summary>
|
||||
@@ -174,42 +149,6 @@ public sealed class AddressSpacePlannerTests
|
||||
plan.RemovedEquipmentVirtualTags.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>An equipment Tag with the same id but a toggled <c>IsArray</c> flag (and otherwise
|
||||
/// identical fields) must route to ChangedEquipmentTags. This pins that <c>IsArray</c> is part of
|
||||
/// <see cref="EquipmentTagPlan.Equals"/> (record value-equality) so an array-flag-only deploy is not
|
||||
/// a silent no-op at the diff/IsEmpty gate — same posture as the Historize flag.</summary>
|
||||
[Fact]
|
||||
public void Same_id_with_toggled_isarray_routes_to_ChangedEquipmentTags()
|
||||
{
|
||||
var prev = new AddressSpaceComposition(
|
||||
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
|
||||
{
|
||||
EquipmentTags = new[]
|
||||
{
|
||||
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Buffer", DataType: "Int16",
|
||||
FullName: "40001", Writable: false, Alarm: null, IsArray: false, ArrayLength: null),
|
||||
},
|
||||
};
|
||||
var next = new AddressSpaceComposition(
|
||||
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
|
||||
{
|
||||
EquipmentTags = new[]
|
||||
{
|
||||
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Buffer", DataType: "Int16",
|
||||
FullName: "40001", Writable: false, Alarm: null, IsArray: true, ArrayLength: 16),
|
||||
},
|
||||
};
|
||||
|
||||
var plan = AddressSpacePlanner.Compute(prev, next);
|
||||
|
||||
plan.IsEmpty.ShouldBeFalse();
|
||||
plan.ChangedEquipmentTags.Single().Previous.IsArray.ShouldBeFalse();
|
||||
plan.ChangedEquipmentTags.Single().Current.IsArray.ShouldBeTrue();
|
||||
plan.ChangedEquipmentTags.Single().Current.ArrayLength.ShouldBe((uint)16);
|
||||
plan.AddedEquipmentTags.ShouldBeEmpty();
|
||||
plan.RemovedEquipmentTags.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>Regression guard for structural equality on <see cref="EquipmentVirtualTagPlan.DependencyRefs"/>:
|
||||
/// two snapshots containing the SAME VirtualTag built from SEPARATE list instances must diff to an empty plan
|
||||
/// (IReadOnlyList equality is BY REFERENCE without the custom Equals override, so every VirtualTag with
|
||||
|
||||
@@ -223,8 +223,10 @@ public sealed class HistoryUpdateTests : IDisposable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An updater removed AFTER materialisation (AccessLevel is not retro-updated) still yields unsupported
|
||||
/// rather than a null dereference — the override re-checks the updater per request.
|
||||
/// An updater removed AFTER materialisation retracts the node's HistoryWrite bit (review 01 U3 / B7), so
|
||||
/// the SDK base refuses the insert with BadHistoryOperationUnsupported before our override runs and the
|
||||
/// old updater is never reached. (Only <c>errors</c> is asserted: on the SDK-base refusal path the
|
||||
/// per-node result is left for the master node manager to fill from it.)
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task An_updater_cleared_after_materialisation_is_unsupported()
|
||||
@@ -233,15 +235,53 @@ public sealed class HistoryUpdateTests : IDisposable
|
||||
try
|
||||
{
|
||||
var nm = server.NodeManager!;
|
||||
nm.HistorianDataUpdater = new FakeUpdater();
|
||||
var updater = new FakeUpdater();
|
||||
nm.HistorianDataUpdater = updater;
|
||||
WireAcl(nm, enabled: false, NodePermissions.HistoryUpdate);
|
||||
var nodeId = Historized(nm, AddressSpaceRealm.Raw, RawPath);
|
||||
(nm.TryGetVariable(RawPath, AddressSpaceRealm.Raw)!.AccessLevel & AccessLevels.HistoryWrite)
|
||||
.ShouldNotBe((byte)0, "granted while the updater was wired (non-vacuity)");
|
||||
|
||||
nm.HistorianDataUpdater = null;
|
||||
|
||||
(nm.TryGetVariable(RawPath, AddressSpaceRealm.Raw)!.AccessLevel & AccessLevels.HistoryWrite)
|
||||
.ShouldBe((byte)0, "clearing the updater retracts the bit");
|
||||
var (_, errors) = Insert(nm, KeyIdentity(OpcUaDataPlaneRoles.HistoryUpdate), nodeId, Value(T1, 1d));
|
||||
|
||||
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
|
||||
updater.UpdateCalls.ShouldBeEmpty();
|
||||
}
|
||||
finally { await host.DisposeAsync(); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Review fix I4 — the override's OWN per-request updater check, which the bit retraction above now hides.
|
||||
/// It is the B7 race window: a request the SDK base admitted on the HistoryWrite bit just before the updater
|
||||
/// was cleared. Reconstructed by restoring the bit after the clear, so the request reaches the override with
|
||||
/// no updater; it must answer unsupported on both slots (the override sets results itself) and never reach the
|
||||
/// old updater.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task The_override_rechecks_the_updater_per_request_when_the_bit_is_still_set()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
try
|
||||
{
|
||||
var nm = server.NodeManager!;
|
||||
var updater = new FakeUpdater();
|
||||
nm.HistorianDataUpdater = updater;
|
||||
WireAcl(nm, enabled: false, NodePermissions.HistoryUpdate);
|
||||
var nodeId = Historized(nm, AddressSpaceRealm.Raw, RawPath);
|
||||
nm.HistorianDataUpdater = null;
|
||||
var variable = nm.TryGetVariable(RawPath, AddressSpaceRealm.Raw)!;
|
||||
variable.AccessLevel |= AccessLevels.HistoryWrite;
|
||||
variable.UserAccessLevel |= AccessLevels.HistoryWrite;
|
||||
|
||||
var (results, errors) = Insert(nm, KeyIdentity(OpcUaDataPlaneRoles.HistoryUpdate), nodeId, Value(T1, 1d));
|
||||
|
||||
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
|
||||
results[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
|
||||
results[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported, "set by the override, not left for the master node manager");
|
||||
updater.UpdateCalls.ShouldBeEmpty();
|
||||
}
|
||||
finally { await host.DisposeAsync(); }
|
||||
}
|
||||
|
||||
@@ -12,8 +12,9 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
|
||||
/// per-value take was the steady-state throughput ceiling on the publish path; the chunk cap keeps the
|
||||
/// first poll cycle's force-raise-everything batch from stalling every SDK service thread).
|
||||
/// <para>These tests pin the <b>observable</b> contract rather than the locking: every item lands with
|
||||
/// its own Value / StatusCode / Timestamp, an item for an undeclared NodeId is lazily created exactly
|
||||
/// as <see cref="OtOpcUaNodeManager.WriteValue"/> does, and an empty batch does nothing at all.
|
||||
/// its own Value / StatusCode / Timestamp, the realm travels with every item (the same <c>s=</c> id in the
|
||||
/// two namespaces is two nodes), and an empty batch does nothing at all. An item for an undeclared NodeId
|
||||
/// is skipped, never created (review 01 L1 — <see cref="NodeManagerPhantomNodeTests"/>).
|
||||
/// A non-Good status is included so the #519 verbatim-sub-code passthrough is proven on the batched
|
||||
/// path too — a batch that re-projected status through a 3-state enum would collapse it to a bare
|
||||
/// <c>Bad</c>.</para>
|
||||
@@ -90,16 +91,16 @@ public sealed class NodeManagerBatchWriteTests : IDisposable
|
||||
/// assertion is therefore two nodes with DISTINCT namespace indices, not merely "both reads returned
|
||||
/// something" (which a realm-blind map satisfies, since the shared key resolves for either lookup).</summary>
|
||||
[Fact]
|
||||
public async Task WriteValues_creates_missing_variables()
|
||||
public async Task WriteValues_is_realm_qualified()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
try
|
||||
{
|
||||
var nm = server.NodeManager!;
|
||||
|
||||
const string SharedId = "Shared/lazy/id";
|
||||
nm.TryGetVariable(SharedId, AddressSpaceRealm.Uns).ShouldBeNull();
|
||||
nm.TryGetVariable(SharedId, AddressSpaceRealm.Raw).ShouldBeNull();
|
||||
const string SharedId = "Shared/both/id";
|
||||
nm.EnsureVariable(SharedId, null, "shared", "Int32", writable: false, realm: AddressSpaceRealm.Uns);
|
||||
nm.EnsureVariable(SharedId, null, "shared", "Int32", writable: false, realm: AddressSpaceRealm.Raw);
|
||||
|
||||
var ts = new DateTime(2026, 8, 15, 11, 0, 0, DateTimeKind.Utc);
|
||||
nm.WriteValues(new List<ValueWrite>
|
||||
@@ -143,6 +144,8 @@ public sealed class NodeManagerBatchWriteTests : IDisposable
|
||||
{
|
||||
var nm = server.NodeManager!;
|
||||
var ts = new DateTime(2026, 8, 15, 12, 0, 0, DateTimeKind.Utc);
|
||||
nm.EnsureVariable("eq-iso/first", null, "first", "Int32", writable: false, realm: AddressSpaceRealm.Uns);
|
||||
nm.EnsureVariable("eq-iso/third", null, "third", "Int32", writable: false, realm: AddressSpaceRealm.Uns);
|
||||
|
||||
var ex = Should.Throw<ValueWriteBatchException>(() => nm.WriteValues(new List<ValueWrite>
|
||||
{
|
||||
@@ -187,6 +190,7 @@ public sealed class NodeManagerBatchWriteTests : IDisposable
|
||||
var items = new List<ValueWrite>(Count);
|
||||
for (var i = 0; i < Count; i++)
|
||||
{
|
||||
nm.EnsureVariable($"eq-chunk/{i}", null, $"{i}", "Int32", writable: false, realm: AddressSpaceRealm.Uns);
|
||||
items.Add(new ValueWrite($"eq-chunk/{i}", i, OpcUaStatus.Good, ts, AddressSpaceRealm.Uns));
|
||||
}
|
||||
|
||||
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Opc.Ua;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using HistorianRead = ZB.MOM.WW.OtOpcUa.Core.Abstractions.HistoryReadResult;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Review 01 U3 — the historian capability bits were fixed at promotion / materialise time, so a
|
||||
/// <see cref="OtOpcUaNodeManager.HistorianDataSource"/> or <see cref="OtOpcUaNodeManager.HistorianDataUpdater"/>
|
||||
/// assigned after the first apply left existing folders without event history (and existing nodes
|
||||
/// without HistoryUpdate) until a full rebuild, and a source reset to Null never took the bit back.
|
||||
/// Both setters now re-evaluate the bits on every existing node.
|
||||
/// </summary>
|
||||
public sealed class NodeManagerCapabilityRefreshTests : IDisposable
|
||||
{
|
||||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||
|
||||
private readonly string _pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-caprefresh-{Guid.NewGuid():N}");
|
||||
|
||||
[Fact]
|
||||
public async Task A_late_historian_source_grants_event_history_to_already_promoted_folders_and_a_reset_takes_it_back()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
try
|
||||
{
|
||||
var nm = server.NodeManager!;
|
||||
nm.EnsureFolder("eq-1", null, "Equipment 1", AddressSpaceRealm.Uns);
|
||||
nm.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", 700, AddressSpaceRealm.Uns);
|
||||
var folder = nm.TryGetFolder("eq-1")!;
|
||||
|
||||
// Promoted while the source is Null: live events only (non-vacuity for the refresh).
|
||||
(folder.EventNotifier & EventNotifiers.SubscribeToEvents).ShouldNotBe((byte)0);
|
||||
(folder.EventNotifier & EventNotifiers.HistoryRead).ShouldBe((byte)0);
|
||||
nm.IsEventHistorySource("eq-1").ShouldBeFalse();
|
||||
|
||||
nm.HistorianDataSource = new StubSource();
|
||||
|
||||
(folder.EventNotifier & EventNotifiers.HistoryRead).ShouldNotBe((byte)0, "a late source must light the bit on the existing folder");
|
||||
nm.IsEventHistorySource("eq-1").ShouldBeTrue();
|
||||
|
||||
nm.HistorianDataSource = null!; // the setter maps null to the Null source
|
||||
|
||||
(folder.EventNotifier & EventNotifiers.HistoryRead).ShouldBe((byte)0, "a reset source must take the bit back");
|
||||
(folder.EventNotifier & EventNotifiers.SubscribeToEvents).ShouldNotBe((byte)0, "live-event subscription is untouched");
|
||||
nm.IsEventHistorySource("eq-1").ShouldBeFalse();
|
||||
}
|
||||
finally { await host.DisposeAsync(); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_late_updater_grants_history_write_to_historized_nodes_only_and_a_reset_takes_it_back()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
try
|
||||
{
|
||||
var nm = server.NodeManager!;
|
||||
nm.EnsureVariable("Plant/dev1/hist", null, "hist", "Float", writable: true, realm: AddressSpaceRealm.Raw, historianTagname: "Hist.A");
|
||||
nm.EnsureVariable("Plant/dev1/plain", null, "plain", "Float", writable: false, realm: AddressSpaceRealm.Raw);
|
||||
var hist = nm.TryGetVariable("Plant/dev1/hist", AddressSpaceRealm.Raw)!;
|
||||
var plain = nm.TryGetVariable("Plant/dev1/plain", AddressSpaceRealm.Raw)!;
|
||||
(hist.AccessLevel & AccessLevels.HistoryWrite).ShouldBe((byte)0, "no updater at materialise time");
|
||||
|
||||
nm.HistorianDataUpdater = new StubUpdater();
|
||||
|
||||
(hist.AccessLevel & AccessLevels.HistoryWrite).ShouldNotBe((byte)0);
|
||||
(hist.UserAccessLevel & AccessLevels.HistoryWrite).ShouldNotBe((byte)0);
|
||||
(hist.AccessLevel & AccessLevels.CurrentWrite).ShouldNotBe((byte)0, "the authored Writable survives the refresh");
|
||||
(hist.AccessLevel & AccessLevels.HistoryRead).ShouldNotBe((byte)0);
|
||||
(plain.AccessLevel & AccessLevels.HistoryWrite).ShouldBe((byte)0, "a non-historized node never gets HistoryWrite");
|
||||
|
||||
nm.HistorianDataUpdater = null;
|
||||
|
||||
(hist.AccessLevel & AccessLevels.HistoryWrite).ShouldBe((byte)0);
|
||||
(hist.AccessLevel & AccessLevels.CurrentWrite).ShouldNotBe((byte)0);
|
||||
}
|
||||
finally { await host.DisposeAsync(); }
|
||||
}
|
||||
|
||||
private async Task<(OpcUaApplicationHost Host, OtOpcUaSdkServer Server)> BootAsync() =>
|
||||
await OpcUaHostStart.StartAsync(
|
||||
port => new OpcUaApplicationHost(
|
||||
new OpcUaApplicationHostOptions
|
||||
{
|
||||
ApplicationName = "OtOpcUa.CapRefreshTest",
|
||||
ApplicationUri = $"urn:OtOpcUa.CapRefreshTest:{Guid.NewGuid():N}",
|
||||
OpcUaPort = port,
|
||||
PublicHostname = "localhost",
|
||||
PkiStoreRoot = _pkiRoot,
|
||||
},
|
||||
NullLogger<OpcUaApplicationHost>.Instance),
|
||||
() => new OtOpcUaSdkServer(), Ct);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_pkiRoot)) Directory.Delete(_pkiRoot, recursive: true);
|
||||
}
|
||||
|
||||
private sealed class StubSource : IHistorianDataSource
|
||||
{
|
||||
public Task<HistorianRead> ReadRawAsync(string r, DateTime s, DateTime e, uint m, CancellationToken ct) =>
|
||||
NullHistorianDataSource.Instance.ReadRawAsync(r, s, e, m, ct);
|
||||
public Task<HistorianRead> ReadProcessedAsync(string r, DateTime s, DateTime e, TimeSpan i, HistoryAggregateType a, CancellationToken ct) =>
|
||||
NullHistorianDataSource.Instance.ReadProcessedAsync(r, s, e, i, a, ct);
|
||||
public Task<HistorianRead> ReadAtTimeAsync(string r, IReadOnlyList<DateTime> t, CancellationToken ct) =>
|
||||
NullHistorianDataSource.Instance.ReadAtTimeAsync(r, t, ct);
|
||||
public Task<HistoricalEventsResult> ReadEventsAsync(string? n, DateTime s, DateTime e, int m, CancellationToken ct) =>
|
||||
NullHistorianDataSource.Instance.ReadEventsAsync(n, s, e, m, ct);
|
||||
public HistorianHealthSnapshot GetHealthSnapshot() => NullHistorianDataSource.Instance.GetHealthSnapshot();
|
||||
public void Dispose() { }
|
||||
}
|
||||
|
||||
private sealed class StubUpdater : IHistorianDataUpdater
|
||||
{
|
||||
public Task<IReadOnlyList<uint>> UpdateDataAsync(string tag, HistoryUpdateMode mode, IReadOnlyList<HistorizationValue> values, HistoryUpdateContext context, CancellationToken ct) =>
|
||||
throw new NotSupportedException();
|
||||
public Task<uint> DeleteRawAsync(string tag, DateTime startUtc, DateTime endUtc, HistoryUpdateContext context, CancellationToken ct) =>
|
||||
throw new NotSupportedException();
|
||||
public Task<IReadOnlyList<uint>> DeleteAtTimeAsync(string tag, IReadOnlyList<DateTime> timesUtc, HistoryUpdateContext context, CancellationToken ct) =>
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
+81
-1
@@ -292,6 +292,83 @@ public sealed class NodeManagerHistoryReadBoundsTests(BoundsServerFixture fx) :
|
||||
Render(values, T("5:00"), T("5:05")).ShouldBe("5:00, 5:02, 5:03, 5:05");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #736 (H1 fuzz) — a backend row ceiling sizes the page BELOW NumValuesPerNode (RawPageLimit: ceiling 4 ⇒ 3 data
|
||||
/// rows per page). When the data exactly fills that page, the end bound must go to the NEXT page through the
|
||||
/// continuation point, not be appended to a page that is already at its effective cap: the fit used to be
|
||||
/// judged against NumValuesPerNode (10 here), so the page came back with cap + 1 values.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_full_ceiling_sized_page_carries_the_end_bound_on_the_next_page()
|
||||
{
|
||||
var source = new SeriesSource(TableData(), reverse: true, inclusiveForwardEnd: false, resolution: 1, ceiling: 4);
|
||||
var (first, error, cp) = await ReadAsync(source, T("5:01"), T("5:06"), max: 10, bounds: true, cp: null);
|
||||
error.StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
|
||||
// Start bound 5:00 + the three data rows the ceiling allows = the page's effective cap, exactly.
|
||||
Render(first, T("5:01"), T("5:06")).ShouldBe("5:00, 5:02, 5:03, 5:05");
|
||||
cp.ShouldNotBeNull("the page is full, so the end bound is owed on the next page");
|
||||
|
||||
var (second, error2, cp2) = await ReadAsync(source, T("5:01"), T("5:06"), max: 10, bounds: true, cp);
|
||||
error2.StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
Render(second, T("5:01"), T("5:06")).ShouldBe("5:06");
|
||||
cp2.ShouldBeNull();
|
||||
|
||||
// Control: without a ceiling the same read fits on ONE page of NumValuesPerNode — the fit keys on the
|
||||
// effective cap, not on a smaller constant.
|
||||
var (whole, _, cpWhole) = await ReadAsync(Source(Shape.Reverse, TableData()), T("5:01"), T("5:06"), max: 10, bounds: true, cp: null);
|
||||
Render(whole, T("5:01"), T("5:06")).ShouldBe("5:00, 5:02, 5:03, 5:05, 5:06");
|
||||
cpWhole.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lane B review fix round 1 (minor): an UNCAPPED read (N = 0) against a backend with a row ceiling L pages at
|
||||
/// L, and MaxReturnDataValues advertises L — so no page, the tie-cluster slices and their end bound included,
|
||||
/// may carry more than L values; and the pages still concatenate to the uncapped single-shot answer. Swept
|
||||
/// over ceilings and tie-cluster sizes up to what one over-fetch read can hold (L − 1).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task An_uncapped_read_never_returns_a_page_larger_than_the_backend_ceiling()
|
||||
{
|
||||
foreach (var ceiling in new[] { 2, 3, 4, 6 })
|
||||
{
|
||||
for (var ties = 1; ties < ceiling; ties++)
|
||||
{
|
||||
var series = new List<DataValueSnapshot> { Snap("5:00", 0) };
|
||||
for (var i = 0; i < ties; i++) series.Add(Snap("5:02", 10 + i));
|
||||
series.Add(Snap("5:03", 3));
|
||||
for (var i = 0; i < ties; i++) series.Add(Snap("5:05", 50 + i));
|
||||
series.Add(Snap("5:06", 6));
|
||||
|
||||
foreach (var (start, end) in new[] { ("5:01", "5:04"), ("5:00", "5:06"), ("5:01", "5:07"), ("5:06", "5:01") })
|
||||
{
|
||||
var expected = Render(
|
||||
(await ReadAsync(new SeriesSource(series, reverse: true, inclusiveForwardEnd: false, resolution: 1), T(start), T(end), 0, true, null)).Values,
|
||||
T(start), T(end));
|
||||
|
||||
var capped = new SeriesSource(series, reverse: true, inclusiveForwardEnd: false, resolution: 1, ceiling: ceiling);
|
||||
var all = new List<DataValue>();
|
||||
byte[]? cp = null;
|
||||
var pages = 0;
|
||||
do
|
||||
{
|
||||
var (values, error, next) = await ReadAsync(capped, T(start), T(end), 0, true, cp);
|
||||
error.StatusCode.Code.ShouldBe(StatusCodes.Good, $"L={ceiling} ties={ties} {start}->{end}");
|
||||
values.Count.ShouldBeLessThanOrEqualTo(ceiling, $"L={ceiling} ties={ties} {start}->{end} page {pages}");
|
||||
all.AddRange(values);
|
||||
cp = next;
|
||||
(++pages).ShouldBeLessThan(60, "paging must terminate");
|
||||
}
|
||||
while (cp is not null);
|
||||
|
||||
Render(all, T(start), T(end)).ShouldBe(expected, $"L={ceiling} ties={ties} {start}->{end}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static DataValueSnapshot Snap(string at, double v) => new(v, StatusCodes.Good, T(at), T(at));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #691 review: no data AND no bound found ⇒ <c>GoodNoData</c> with no values — the answer a tag with no history (and a
|
||||
/// disabled historian) gave before bounds (§6.5.3.2: bounds make the result Good only "if one or both exist"). Only
|
||||
@@ -468,7 +545,7 @@ public sealed class NodeManagerHistoryReadBoundsTests(BoundsServerFixture fx) :
|
||||
/// a point read is the instant. <paramref name="resolution"/> > 1 truncates every window bound to it first (QuestDB
|
||||
/// µs); the series must already be stored at that resolution.
|
||||
/// </summary>
|
||||
private sealed class SeriesSource(IReadOnlyList<DataValueSnapshot> series, bool reverse, bool inclusiveForwardEnd, long resolution)
|
||||
private sealed class SeriesSource(IReadOnlyList<DataValueSnapshot> series, bool reverse, bool inclusiveForwardEnd, long resolution, int ceiling = 0)
|
||||
: IHistorianDataSource
|
||||
{
|
||||
public int ReverseWindowsOnForwardOnly;
|
||||
@@ -477,6 +554,9 @@ public sealed class NodeManagerHistoryReadBoundsTests(BoundsServerFixture fx) :
|
||||
|
||||
public long TimestampResolutionTicks => resolution;
|
||||
|
||||
/// <summary>A strict row ceiling (0 = none), so a page is sized below NumValuesPerNode (RawPageLimit).</summary>
|
||||
public int MaxRawValuesPerRead => ceiling;
|
||||
|
||||
private DateTime Trunc(DateTime t) => new(t.Ticks - t.Ticks % resolution, DateTimeKind.Utc);
|
||||
|
||||
public Task<HistorianRead> ReadRawAsync(
|
||||
|
||||
+53
-12
@@ -211,25 +211,21 @@ public sealed class NodeManagerHistoryReadEventsTests : IDisposable
|
||||
}
|
||||
|
||||
/// <summary>A folder promoted to an event notifier while NO historian was wired (Null source at
|
||||
/// materialise time) is NOT registered as an event-history source ⇒ a HistoryReadEvents over it yields
|
||||
/// BadHistoryOperationUnsupported, and the (later-wired) source is never invoked.</summary>
|
||||
/// materialise time) is not an event-history source ⇒ a HistoryReadEvents over it yields
|
||||
/// BadHistoryOperationUnsupported. Wiring a real source AFTER promotion re-evaluates the folder
|
||||
/// (review 01 U3 / B7 — before B7 it stayed unsupported until a rebuild), so the same read then reaches
|
||||
/// the source.</summary>
|
||||
[Fact]
|
||||
public async Task Events_folder_promoted_without_source_yields_BadHistoryOperationUnsupported()
|
||||
public async Task Events_folder_promoted_without_source_is_unsupported_until_a_source_is_wired()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
var nm = server.NodeManager!;
|
||||
|
||||
// Materialise the alarm while the source is still the Null default — the folder is promoted to
|
||||
// SubscribeToEvents but DOES NOT get the HistoryRead bit / source registration.
|
||||
const string equipmentId = "eq-nosrc";
|
||||
nm.EnsureFolder(equipmentId, parentNodeId: null, displayName: "Equipment", realm: AddressSpaceRealm.Uns);
|
||||
nm.MaterialiseAlarmCondition("alarm-4", equipmentId, "Cond", "OffNormalAlarm", severity: 200, realm: AddressSpaceRealm.Uns);
|
||||
var notifierNodeId = nm.TryGetFolder(equipmentId)!.NodeId;
|
||||
|
||||
// Wire a real source AFTER promotion — it must NOT retroactively make the folder a source.
|
||||
var fake = new RecordingHistorianDataSource();
|
||||
nm.HistorianDataSource = fake;
|
||||
|
||||
var details = new ReadEventDetails
|
||||
{
|
||||
StartTime = DateTime.UtcNow.AddHours(-1),
|
||||
@@ -238,10 +234,55 @@ public sealed class NodeManagerHistoryReadEventsTests : IDisposable
|
||||
Filter = SelectFilter("EventId"),
|
||||
};
|
||||
|
||||
var (_, unsupported) = InvokeHistoryRead(server, nm, details, notifierNodeId);
|
||||
unsupported[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
|
||||
|
||||
var fake = new RecordingHistorianDataSource();
|
||||
nm.HistorianDataSource = fake;
|
||||
|
||||
var (_, errors) = InvokeHistoryRead(server, nm, details, notifierNodeId);
|
||||
|
||||
errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
fake.EventsCalled.ShouldBeTrue();
|
||||
fake.LastSourceName.ShouldBe(equipmentId);
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Review fix I4 — the HistoryReadEvents override's OWN source guard. Every other "unsupported" case here is
|
||||
/// answered by the SDK base, which admits only notifiers carrying the <c>EventNotifier.HistoryRead</c> bit —
|
||||
/// and since B7 the bit and the source registration move together. So the guard is reached only by a request
|
||||
/// the base admitted on the bit just before a source reset took the registration back; reconstructed by
|
||||
/// restoring the bit after the reset. The override must answer unsupported on both slots and reach no source.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task The_event_override_refuses_a_notifier_with_the_bit_but_no_registered_source()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
var nm = server.NodeManager!;
|
||||
var fake = new RecordingHistorianDataSource();
|
||||
nm.HistorianDataSource = fake;
|
||||
nm.EnsureFolder("eq-guard", parentNodeId: null, displayName: "Equipment", realm: AddressSpaceRealm.Uns);
|
||||
nm.MaterialiseAlarmCondition("alarm-g", "eq-guard", "Cond", "OffNormalAlarm", severity: 200, realm: AddressSpaceRealm.Uns);
|
||||
nm.IsEventHistorySource("eq-guard").ShouldBeTrue("non-vacuity: registered while the source was wired");
|
||||
|
||||
nm.HistorianDataSource = null!;
|
||||
var folder = nm.TryGetFolder("eq-guard")!;
|
||||
nm.IsEventHistorySource("eq-guard").ShouldBeFalse();
|
||||
folder.EventNotifier |= EventNotifiers.HistoryRead;
|
||||
|
||||
var details = new ReadEventDetails
|
||||
{
|
||||
StartTime = DateTime.UtcNow.AddHours(-1),
|
||||
EndTime = DateTime.UtcNow,
|
||||
NumValuesPerNode = 10,
|
||||
Filter = SelectFilter("EventId"),
|
||||
};
|
||||
var (results, errors) = InvokeHistoryRead(server, nm, details, folder.NodeId);
|
||||
|
||||
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
|
||||
fake.LastSourceName.ShouldBeNull(); // source never reached
|
||||
results[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported, "set by the override's guard");
|
||||
fake.EventsCalled.ShouldBeFalse();
|
||||
|
||||
await host.DisposeAsync();
|
||||
@@ -257,8 +298,8 @@ public sealed class NodeManagerHistoryReadEventsTests : IDisposable
|
||||
/// SDK base therefore rejects it and does NOT pass it to our <c>HistoryReadEvents</c> override;
|
||||
/// the Bad result comes from the base's pre-seeding, not from our source-guard. This test pins
|
||||
/// that base-level rejection of variable nodes for event reads.
|
||||
/// The override's own source-guard (miss in <c>_eventNotifierSources</c>) is exercised by the
|
||||
/// <c>Events_folder_promoted_without_source_yields_BadHistoryOperationUnsupported</c> test instead.
|
||||
/// The override's own source-guard (a miss in <c>_eventNotifierSources</c>) is exercised by
|
||||
/// <c>The_event_override_refuses_a_notifier_with_the_bit_but_no_registered_source</c>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
|
||||
+196
@@ -0,0 +1,196 @@
|
||||
using System.Diagnostics;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Server;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Review 01 P1 / P2 / P3 — the lock-time budget of the three teardown paths that run under the
|
||||
/// manager-wide SDK <c>Lock</c> on the pinned publish thread, where every Read / Browse / Publish waits.
|
||||
/// <list type="bullet">
|
||||
/// <item>P1: a full rebuild of a 10k-tag device folder was O(k²) per folder because
|
||||
/// <c>NodeState.RemoveChild</c> is a linear scan + <c>RemoveAt</c> per leaf (~50M compares).</item>
|
||||
/// <item>P2: removing M equipment from an N-node space scanned every map per equipment (O(M·N)).</item>
|
||||
/// <item>P3: every surgical condition removal re-walked every condition for the Server-object
|
||||
/// monitoring bookkeeping (O(C) per removal) while any <c>i=2253</c> item existed.</item>
|
||||
/// </list>
|
||||
/// The wall-clock budgets are generous for CI hardware; the before/after numbers are in the commit
|
||||
/// message. P3 is pinned by the bookkeeping's own visit counter, which does not depend on the machine.
|
||||
/// Tagged <c>Category=Benchmark</c>, but no filter excludes that category (CI drops only E2E + LiveIntegration),
|
||||
/// so it runs by default — on purpose:
|
||||
/// measured after the fix at 5–6 ms (P1) and 58–64 ms (P2) on a loaded dev machine against the 1 s budget —
|
||||
/// ≥ 15× headroom, so it does not flake on slow CI. The flip side: the pre-fix code (75–150 ms / 470–680 ms) also
|
||||
/// fits 1 s at these sizes, so the wall-clock legs guard against gross (order-of-magnitude) regressions only; the
|
||||
/// machine-independent P3 visit counter is the strict pin.
|
||||
/// </summary>
|
||||
[Trait("Category", "Benchmark")]
|
||||
public sealed class NodeManagerLockTimeBenchmarkTests(ITestOutputHelper output) : IDisposable
|
||||
{
|
||||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||
private static readonly TimeSpan Budget = TimeSpan.FromSeconds(1);
|
||||
|
||||
private readonly string _pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-bench-{Guid.NewGuid():N}");
|
||||
|
||||
/// <summary>P1 — a rebuild that discards one device folder holding 10k tags completes within the budget.</summary>
|
||||
[Fact]
|
||||
public async Task Rebuild_of_a_10k_tag_folder_stays_within_budget()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
try
|
||||
{
|
||||
var nm = server.NodeManager!;
|
||||
const string device = "Plant/Modbus/dev1";
|
||||
nm.EnsureFolder(device, null, "dev1", AddressSpaceRealm.Raw);
|
||||
for (var i = 0; i < 10_000; i++)
|
||||
nm.EnsureVariable($"{device}/T{i}", device, $"T{i}", "Int32", writable: false, realm: AddressSpaceRealm.Raw);
|
||||
nm.VariableCount.ShouldBe(10_000);
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
nm.RebuildAddressSpace();
|
||||
sw.Stop();
|
||||
nm.CompleteAddressSpaceApply();
|
||||
|
||||
nm.VariableCount.ShouldBe(0);
|
||||
nm.FolderCount.ShouldBe(0);
|
||||
// Positive control: the space is really empty for the SDK too, and can be re-populated.
|
||||
nm.GetManagerHandle(new NodeId($"{device}/T0", nm.NamespaceIndexForRealm(AddressSpaceRealm.Raw))).ShouldBeNull();
|
||||
nm.EnsureFolder(device, null, "dev1", AddressSpaceRealm.Raw);
|
||||
nm.EnsureVariable($"{device}/T0", device, "T0", "Int32", writable: false, realm: AddressSpaceRealm.Raw);
|
||||
nm.TryGetVariable($"{device}/T0", AddressSpaceRealm.Raw)!.Parent.ShouldBeSameAs(nm.TryGetFolder(device, AddressSpaceRealm.Raw));
|
||||
|
||||
output.WriteLine($"BENCH rebuild 10k-tag folder: {sw.ElapsedMilliseconds} ms");
|
||||
sw.Elapsed.ShouldBeLessThan(Budget, $"rebuild of a 10k-tag folder took {sw.ElapsedMilliseconds} ms");
|
||||
}
|
||||
finally { await host.DisposeAsync(); }
|
||||
}
|
||||
|
||||
/// <summary>P2 — removing 200 equipment (250 signals each, 50k nodes) completes within the budget.</summary>
|
||||
[Fact]
|
||||
public async Task Removing_200_equipment_from_a_50k_node_space_stays_within_budget()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
try
|
||||
{
|
||||
var nm = server.NodeManager!;
|
||||
const int equipmentCount = 200, perEquipment = 250;
|
||||
for (var e = 0; e < equipmentCount; e++)
|
||||
{
|
||||
var equip = $"area/line/eq{e}";
|
||||
nm.EnsureFolder(equip, null, $"eq{e}", AddressSpaceRealm.Uns);
|
||||
for (var i = 0; i < perEquipment; i++)
|
||||
nm.EnsureVariable($"{equip}/S{i}", equip, $"S{i}", "Float", writable: false, realm: AddressSpaceRealm.Uns);
|
||||
}
|
||||
// A survivor the removals must not touch.
|
||||
nm.EnsureFolder("area/line/keep", null, "keep", AddressSpaceRealm.Uns);
|
||||
nm.EnsureVariable("area/line/keep/S0", "area/line/keep", "S0", "Float", writable: false, realm: AddressSpaceRealm.Uns);
|
||||
nm.VariableCount.ShouldBe(equipmentCount * perEquipment + 1);
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
for (var e = 0; e < equipmentCount; e++)
|
||||
nm.RemoveEquipmentSubtree($"area/line/eq{e}", AddressSpaceRealm.Uns).ShouldBeTrue();
|
||||
sw.Stop();
|
||||
|
||||
nm.VariableCount.ShouldBe(1);
|
||||
nm.FolderCount.ShouldBe(1);
|
||||
nm.TryGetVariable("area/line/keep/S0").ShouldNotBeNull();
|
||||
nm.GetManagerHandle(new NodeId("area/line/eq0/S0", nm.NamespaceIndexForRealm(AddressSpaceRealm.Uns))).ShouldBeNull();
|
||||
|
||||
output.WriteLine($"BENCH remove 200 equipment / 50k nodes: {sw.ElapsedMilliseconds} ms");
|
||||
sw.Elapsed.ShouldBeLessThan(Budget, $"removing 200 equipment took {sw.ElapsedMilliseconds} ms");
|
||||
}
|
||||
finally { await host.DisposeAsync(); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// P3 — with a Server-object item live, a surgical condition removal lowers only that condition's
|
||||
/// monitoring unit instead of re-walking every condition: the bookkeeping visits at most one
|
||||
/// condition per removal. (A full reconcile visits every condition twice — desired + held.)
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_surgical_condition_removal_visits_only_its_own_condition()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
try
|
||||
{
|
||||
var nm = server.NodeManager!;
|
||||
nm.EnsureFolder("eq-1", null, "Equipment 1", AddressSpaceRealm.Uns);
|
||||
const int conditions = 50;
|
||||
for (var i = 0; i < conditions; i++)
|
||||
nm.MaterialiseAlarmCondition($"alm-{i}", "eq-1", $"Alarm {i}", "OffNormalAlarm", 500, AddressSpaceRealm.Uns);
|
||||
|
||||
// A Server-object (all-events) subscriber, so the per-condition monitoring unit is in play.
|
||||
nm.SubscribeToAllEvents(
|
||||
new OperationContext(new RequestHeader(), secureChannelContext: null, RequestType.CreateMonitoredItems, identity: null),
|
||||
1, new StubEventItem(), unsubscribe: false);
|
||||
nm.ConditionEventsMonitored("alm-0", AddressSpaceRealm.Uns).ShouldBeTrue("non-vacuity: the units really were raised");
|
||||
|
||||
var before = nm.AllEventsMonitoringConditionVisits;
|
||||
for (var i = 0; i < 10; i++)
|
||||
nm.RemoveAlarmConditionNode($"alm-{i}", AddressSpaceRealm.Uns).ShouldBeTrue();
|
||||
var visits = nm.AllEventsMonitoringConditionVisits - before;
|
||||
output.WriteLine($"BENCH all-events visits for 10 condition removals over {conditions} conditions: {visits}");
|
||||
|
||||
visits.ShouldBeLessThanOrEqualTo(10, $"ten removals visited {visits} conditions; a full reconcile per removal would visit ~{10 * 2 * conditions}");
|
||||
// The survivors keep their unit.
|
||||
nm.ConditionEventsMonitored("alm-49", AddressSpaceRealm.Uns).ShouldBeTrue();
|
||||
}
|
||||
finally { await host.DisposeAsync(); }
|
||||
}
|
||||
|
||||
private async Task<(OpcUaApplicationHost Host, OtOpcUaSdkServer Server)> BootAsync() =>
|
||||
await OpcUaHostStart.StartAsync(
|
||||
port => new OpcUaApplicationHost(
|
||||
new OpcUaApplicationHostOptions
|
||||
{
|
||||
ApplicationName = "OtOpcUa.BenchTest",
|
||||
ApplicationUri = $"urn:OtOpcUa.BenchTest:{Guid.NewGuid():N}",
|
||||
OpcUaPort = port,
|
||||
PublicHostname = "localhost",
|
||||
PkiStoreRoot = _pkiRoot,
|
||||
},
|
||||
NullLogger<OpcUaApplicationHost>.Instance),
|
||||
() => new OtOpcUaSdkServer(), Ct);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_pkiRoot)) Directory.Delete(_pkiRoot, recursive: true);
|
||||
}
|
||||
|
||||
/// <summary>The minimum of a Server-object event item the all-events bookkeeping touches: identity only.</summary>
|
||||
private sealed class StubEventItem : IEventMonitoredItem
|
||||
{
|
||||
public bool MonitoringAllEvents => true;
|
||||
public NodeId NodeId => ObjectIds.Server;
|
||||
public IUserIdentity EffectiveIdentity => null!;
|
||||
public object ManagerHandle => null!;
|
||||
public void QueueEvent(IFilterTarget instance) { }
|
||||
public void QueueEvent(IFilterTarget instance, bool bypassFilter) { }
|
||||
public void Dispose() { }
|
||||
|
||||
public INodeManager NodeManager => throw new NotSupportedException();
|
||||
public ISession Session => throw new NotSupportedException();
|
||||
public uint Id => 1;
|
||||
public uint SubscriptionId => 1;
|
||||
public bool IsDurable => false;
|
||||
public uint ClientHandle => 1;
|
||||
public ISubscription SubscriptionCallback { get => throw new NotSupportedException(); set => throw new NotSupportedException(); }
|
||||
public int MonitoredItemType => MonitoredItemTypeMask.Events;
|
||||
public bool IsReadyToPublish => false;
|
||||
public bool IsReadyToTrigger { get => false; set { } }
|
||||
public bool IsResendData => false;
|
||||
public MonitoringMode MonitoringMode => MonitoringMode.Reporting;
|
||||
public double SamplingInterval => 0;
|
||||
public EventFilter EventFilter => null!;
|
||||
public void SetupResendDataTrigger() { }
|
||||
public ServiceResult GetCreateResult(out MonitoredItemCreateResult result) => throw new NotSupportedException();
|
||||
public ServiceResult GetModifyResult(out MonitoredItemModifyResult result) => throw new NotSupportedException();
|
||||
public IStoredMonitoredItem ToStorableMonitoredItem() => throw new NotSupportedException();
|
||||
public MonitoringMode SetMonitoringMode(MonitoringMode monitoringMode) => throw new NotSupportedException();
|
||||
public bool Publish(OperationContext context, Queue<EventFieldList> notifications, uint maxNotificationsPerPublish) => throw new NotSupportedException();
|
||||
public ServiceResult ModifyAttributes(DiagnosticsMasks diagnosticsMasks, TimestampsToReturn timestampsToReturn, uint clientHandle, MonitoringFilter originalFilter, MonitoringFilter filterToUse, Opc.Ua.Range range, double samplingInterval, uint queueSize, bool discardOldest) => throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Server;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using HistorianRead = ZB.MOM.WW.OtOpcUa.Core.Abstractions.HistoryReadResult;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// #736 — assigning <see cref="OtOpcUaNodeManager.HistorianDataSource"/> re-syncs
|
||||
/// <c>HistoryServerCapabilities/MaxReturnDataValues</c> (i=11273) to the source's
|
||||
/// <see cref="IHistorianDataSource.MaxRawValuesPerRead"/>, read back the way a client would. The Null
|
||||
/// source (the historian-disabled default) advertises Part 11's "no limit".
|
||||
/// </summary>
|
||||
public sealed class NodeManagerMaxReturnDataValuesSyncTests(BoundsServerFixture fx) : IClassFixture<BoundsServerFixture>
|
||||
{
|
||||
[Fact]
|
||||
public async Task Setting_the_historian_source_advertises_its_raw_cap()
|
||||
{
|
||||
await fx.Gate.WaitAsync(TestContext.Current.CancellationToken);
|
||||
try
|
||||
{
|
||||
fx.Nm.HistorianDataSource = new CappedSource(7_000);
|
||||
(await ReadAdvertisedAsync()).ShouldBe(7_000u);
|
||||
|
||||
fx.Nm.HistorianDataSource = new CappedSource(250);
|
||||
(await ReadAdvertisedAsync()).ShouldBe(250u);
|
||||
|
||||
fx.Nm.HistorianDataSource = null!; // the Null default: no cap
|
||||
(await ReadAdvertisedAsync()).ShouldBe(0u);
|
||||
}
|
||||
finally
|
||||
{
|
||||
fx.Nm.HistorianDataSource = null!;
|
||||
fx.Gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<uint> ReadAdvertisedAsync()
|
||||
{
|
||||
var context = new OperationContext(new RequestHeader(), secureChannelContext: null, RequestType.Read, identity: null);
|
||||
var nodesToRead = new ReadValueIdCollection
|
||||
{
|
||||
new ReadValueId { NodeId = VariableIds.HistoryServerCapabilities_MaxReturnDataValues, AttributeId = Attributes.Value },
|
||||
};
|
||||
var (values, _) = await fx.Nm.Server.NodeManager.ReadAsync(context, 0, TimestampsToReturn.Neither, nodesToRead, TestContext.Current.CancellationToken);
|
||||
values.Count.ShouldBe(1);
|
||||
StatusCode.IsGood(values[0].StatusCode).ShouldBeTrue(values[0].StatusCode.ToString());
|
||||
return (uint)values[0].Value;
|
||||
}
|
||||
|
||||
private sealed class CappedSource(int cap) : IHistorianDataSource
|
||||
{
|
||||
public int MaxRawValuesPerRead => cap;
|
||||
public Task<HistorianRead> ReadRawAsync(string r, DateTime s, DateTime e, uint m, CancellationToken ct) =>
|
||||
NullHistorianDataSource.Instance.ReadRawAsync(r, s, e, m, ct);
|
||||
public Task<HistorianRead> ReadProcessedAsync(string r, DateTime s, DateTime e, TimeSpan i, HistoryAggregateType a, CancellationToken ct) =>
|
||||
NullHistorianDataSource.Instance.ReadProcessedAsync(r, s, e, i, a, ct);
|
||||
public Task<HistorianRead> ReadAtTimeAsync(string r, IReadOnlyList<DateTime> t, CancellationToken ct) =>
|
||||
NullHistorianDataSource.Instance.ReadAtTimeAsync(r, t, ct);
|
||||
public Task<HistoricalEventsResult> ReadEventsAsync(string? n, DateTime s, DateTime e, int m, CancellationToken ct) =>
|
||||
NullHistorianDataSource.Instance.ReadEventsAsync(n, s, e, m, ct);
|
||||
public HistorianHealthSnapshot GetHealthSnapshot() => NullHistorianDataSource.Instance.GetHealthSnapshot();
|
||||
public void Dispose() { }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Server;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Review 01 L1 / L4 / H2 — the node manager never materialises a node from a VALUE or ALARM write, and
|
||||
/// never lets one <c>(realm, id)</c> be held by two different node kinds.
|
||||
/// <para>
|
||||
/// The lazy create-on-first-write path predates <see cref="OtOpcUaNodeManager.EnsureVariable"/> and had
|
||||
/// no production producer left — but it had a production <i>consumer</i> by accident: a driver publish
|
||||
/// (or the UNS fan-out) racing a surgical remove re-created the removed id as a flat, read-only,
|
||||
/// <c>BaseDataType</c> variable under the shared root, and because <c>EnsureVariable</c> early-returns
|
||||
/// on an existing id, a later re-add of the same tag silently kept the phantom's shape.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class NodeManagerPhantomNodeTests : IDisposable
|
||||
{
|
||||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||
|
||||
private readonly string _pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-phantom-{Guid.NewGuid():N}");
|
||||
|
||||
/// <summary>A value write to an id nothing declared creates nothing.</summary>
|
||||
[Fact]
|
||||
public async Task WriteValue_for_an_undeclared_id_creates_no_node()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
try
|
||||
{
|
||||
var nm = server.NodeManager!;
|
||||
var before = nm.VariableCount;
|
||||
|
||||
nm.WriteValue("Plant/Modbus/dev1/Ghost", 1, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
|
||||
|
||||
nm.VariableCount.ShouldBe(before);
|
||||
nm.TryGetVariable("Plant/Modbus/dev1/Ghost", AddressSpaceRealm.Raw).ShouldBeNull();
|
||||
nm.GetManagerHandle(new NodeId("Plant/Modbus/dev1/Ghost", nm.NamespaceIndexForRealm(AddressSpaceRealm.Raw)))
|
||||
.ShouldBeNull("the SDK must not resolve a node that was never declared");
|
||||
}
|
||||
finally { await host.DisposeAsync(); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A batch naming a removed id skips it (no node, no exception) while the declared ids in the same batch
|
||||
/// land — the positive control that proves the skip is a skip and not a dropped batch.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task WriteValues_skips_undeclared_ids_and_still_applies_the_declared_ones()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
try
|
||||
{
|
||||
var nm = server.NodeManager!;
|
||||
nm.EnsureVariable("Plant/Modbus/dev1/Speed", null, "Speed", "Int32", writable: false, realm: AddressSpaceRealm.Raw);
|
||||
var ts = DateTime.UtcNow;
|
||||
|
||||
nm.WriteValues(
|
||||
[
|
||||
new ValueWrite("Plant/Modbus/dev1/Ghost", 1, OpcUaStatus.Good, ts, AddressSpaceRealm.Raw),
|
||||
new ValueWrite("Plant/Modbus/dev1/Speed", 42, OpcUaStatus.Good, ts, AddressSpaceRealm.Raw),
|
||||
]);
|
||||
|
||||
nm.TryGetVariable("Plant/Modbus/dev1/Ghost", AddressSpaceRealm.Raw).ShouldBeNull();
|
||||
nm.TryGetVariable("Plant/Modbus/dev1/Speed", AddressSpaceRealm.Raw)!.Value.ShouldBe(42);
|
||||
}
|
||||
finally { await host.DisposeAsync(); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The L1 scenario end to end: a publish racing a surgical remove leaves NO node, and the re-add that
|
||||
/// follows gets the AUTHORED DataType, parent and writability rather than inheriting a phantom's.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_publish_racing_a_surgical_remove_leaves_no_node_and_the_readd_gets_the_authored_shape()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
try
|
||||
{
|
||||
var nm = server.NodeManager!;
|
||||
const string id = "Plant/Modbus/dev1/Speed";
|
||||
nm.EnsureFolder("Plant/Modbus/dev1", null, "dev1", AddressSpaceRealm.Raw);
|
||||
nm.EnsureVariable(id, "Plant/Modbus/dev1", "Speed", "Float", writable: false, realm: AddressSpaceRealm.Raw);
|
||||
nm.RemoveVariableNode(id, AddressSpaceRealm.Raw).ShouldBeTrue();
|
||||
|
||||
// The race: the driver's fan-out publishes the id the surgical remove just dropped.
|
||||
nm.WriteValue(id, 1.5f, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
|
||||
nm.TryGetVariable(id, AddressSpaceRealm.Raw).ShouldBeNull("the racing publish must not resurrect the node");
|
||||
|
||||
// The re-add (a later deploy re-authors the tag with a different shape).
|
||||
nm.EnsureVariable(id, "Plant/Modbus/dev1", "Speed", "Int32", writable: true, realm: AddressSpaceRealm.Raw);
|
||||
|
||||
var v = nm.TryGetVariable(id, AddressSpaceRealm.Raw);
|
||||
v.ShouldNotBeNull();
|
||||
v!.DataType.ShouldBe(DataTypeIds.Int32);
|
||||
v.Parent.ShouldBeSameAs(nm.TryGetFolder("Plant/Modbus/dev1", AddressSpaceRealm.Raw));
|
||||
((int)v.AccessLevel & AccessLevels.CurrentWrite).ShouldNotBe(0, "the re-added node is writable as authored");
|
||||
v.OnWriteValue.ShouldNotBeNull("a writable node carries the inbound-write handler");
|
||||
|
||||
// Positive control: the re-added node is live — a publish lands on it.
|
||||
nm.WriteValue(id, 7, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
|
||||
v.Value.ShouldBe(7);
|
||||
}
|
||||
finally { await host.DisposeAsync(); }
|
||||
}
|
||||
|
||||
/// <summary>A condition write for an id that was never materialised is a logged no-op (L4): no placeholder variable.</summary>
|
||||
[Fact]
|
||||
public async Task WriteAlarmCondition_for_an_unknown_id_creates_nothing()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
try
|
||||
{
|
||||
var nm = server.NodeManager!;
|
||||
nm.EnsureFolder("eq-1", null, "Equipment 1", AddressSpaceRealm.Uns);
|
||||
var before = nm.VariableCount;
|
||||
|
||||
var snapshot = new AlarmConditionSnapshot(
|
||||
Active: false, Acknowledged: true, Confirmed: true, Enabled: true,
|
||||
Shelving: AlarmShelvingKind.Unshelved, Severity: 0, Message: string.Empty);
|
||||
// The applier used to write this against the EQUIPMENT id on every removal — a folder, never a condition.
|
||||
nm.WriteAlarmCondition("eq-1", snapshot, DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||
|
||||
nm.VariableCount.ShouldBe(before);
|
||||
nm.TryGetVariable("eq-1").ShouldBeNull();
|
||||
// The folder's SDK lookup entry survives (the phantom used to REPLACE it — AddPredefinedNode is AddOrUpdate).
|
||||
var handle = (NodeHandle?)nm.GetManagerHandle(new NodeId("eq-1", nm.NamespaceIndexForRealm(AddressSpaceRealm.Uns)));
|
||||
handle.ShouldNotBeNull();
|
||||
handle!.Node.ShouldBeOfType<FolderState>();
|
||||
}
|
||||
finally { await host.DisposeAsync(); }
|
||||
}
|
||||
|
||||
/// <summary>H2 — a variable may not take an id a folder already holds in the same realm.</summary>
|
||||
[Fact]
|
||||
public async Task EnsureVariable_refuses_an_id_held_by_a_folder()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
try
|
||||
{
|
||||
var nm = server.NodeManager!;
|
||||
nm.EnsureFolder("eq-1", null, "Equipment 1", AddressSpaceRealm.Uns);
|
||||
|
||||
nm.EnsureVariable("eq-1", null, "eq-1", "Int32", writable: false, realm: AddressSpaceRealm.Uns);
|
||||
|
||||
nm.TryGetVariable("eq-1").ShouldBeNull();
|
||||
nm.TryGetFolder("eq-1").ShouldNotBeNull();
|
||||
var handle = (NodeHandle?)nm.GetManagerHandle(new NodeId("eq-1", nm.NamespaceIndexForRealm(AddressSpaceRealm.Uns)));
|
||||
handle!.Node.ShouldBeSameAs(nm.TryGetFolder("eq-1"));
|
||||
}
|
||||
finally { await host.DisposeAsync(); }
|
||||
}
|
||||
|
||||
/// <summary>H2 — a folder may not take an id a variable already holds in the same realm.</summary>
|
||||
[Fact]
|
||||
public async Task EnsureFolder_refuses_an_id_held_by_a_variable()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
try
|
||||
{
|
||||
var nm = server.NodeManager!;
|
||||
nm.EnsureVariable("Plant/X", null, "X", "Int32", writable: false, realm: AddressSpaceRealm.Raw);
|
||||
|
||||
nm.EnsureFolder("Plant/X", null, "X", AddressSpaceRealm.Raw);
|
||||
|
||||
nm.TryGetFolder("Plant/X", AddressSpaceRealm.Raw).ShouldBeNull();
|
||||
var handle = (NodeHandle?)nm.GetManagerHandle(new NodeId("Plant/X", nm.NamespaceIndexForRealm(AddressSpaceRealm.Raw)));
|
||||
handle!.Node.ShouldBeSameAs(nm.TryGetVariable("Plant/X", AddressSpaceRealm.Raw));
|
||||
}
|
||||
finally { await host.DisposeAsync(); }
|
||||
}
|
||||
|
||||
/// <summary>H2 — a condition may not take an id a variable already holds in the same realm.</summary>
|
||||
[Fact]
|
||||
public async Task MaterialiseAlarmCondition_refuses_an_id_held_by_a_variable()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
try
|
||||
{
|
||||
var nm = server.NodeManager!;
|
||||
nm.EnsureFolder("Plant/Modbus/dev1", null, "dev1", AddressSpaceRealm.Raw);
|
||||
nm.EnsureVariable("Plant/Modbus/dev1/temp", "Plant/Modbus/dev1", "temp", "Float", writable: false, realm: AddressSpaceRealm.Raw);
|
||||
|
||||
nm.MaterialiseAlarmCondition("Plant/Modbus/dev1/temp", "Plant/Modbus/dev1", "temp", "OffNormalAlarm", 500, AddressSpaceRealm.Raw, isNative: true);
|
||||
|
||||
nm.TryGetAlarmCondition("Plant/Modbus/dev1/temp", AddressSpaceRealm.Raw).ShouldBeNull();
|
||||
nm.TryGetVariable("Plant/Modbus/dev1/temp", AddressSpaceRealm.Raw).ShouldNotBeNull();
|
||||
}
|
||||
finally { await host.DisposeAsync(); }
|
||||
}
|
||||
|
||||
/// <summary>The same bare id in the OTHER realm is a different node and is still allowed (the guard is realm-qualified).</summary>
|
||||
[Fact]
|
||||
public async Task The_guard_is_realm_qualified()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
try
|
||||
{
|
||||
var nm = server.NodeManager!;
|
||||
nm.EnsureFolder("shared", null, "shared", AddressSpaceRealm.Uns);
|
||||
|
||||
nm.EnsureVariable("shared", null, "shared", "Int32", writable: false, realm: AddressSpaceRealm.Raw);
|
||||
|
||||
nm.TryGetVariable("shared", AddressSpaceRealm.Raw).ShouldNotBeNull();
|
||||
nm.TryGetFolder("shared", AddressSpaceRealm.Uns).ShouldNotBeNull();
|
||||
}
|
||||
finally { await host.DisposeAsync(); }
|
||||
}
|
||||
|
||||
private async Task<(OpcUaApplicationHost Host, OtOpcUaSdkServer Server)> BootAsync() =>
|
||||
await OpcUaHostStart.StartAsync(
|
||||
port => new OpcUaApplicationHost(
|
||||
new OpcUaApplicationHostOptions
|
||||
{
|
||||
ApplicationName = "OtOpcUa.PhantomTest",
|
||||
ApplicationUri = $"urn:OtOpcUa.PhantomTest:{Guid.NewGuid():N}",
|
||||
OpcUaPort = port,
|
||||
PublicHostname = "localhost",
|
||||
PkiStoreRoot = _pkiRoot,
|
||||
},
|
||||
NullLogger<OpcUaApplicationHost>.Instance),
|
||||
() => new OtOpcUaSdkServer(), Ct);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_pkiRoot)) Directory.Delete(_pkiRoot, recursive: true);
|
||||
}
|
||||
}
|
||||
+230
@@ -0,0 +1,230 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Server;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Review 01 L2, the half the wire tests (<c>StaleMonitoredNodeWireTests</c>) cannot reach: an item the
|
||||
/// ACL revalidation pass has STOOD DOWN is on no SDK fan-out list, so the tombstone a removal runs must find
|
||||
/// it through the revalidation map. Otherwise it keeps a component-cache reference to the dead node — and
|
||||
/// the cache hands that node to the next item created on the NodeId.
|
||||
/// </summary>
|
||||
public sealed class NodeManagerStaleMonitoredNodeTests : IDisposable
|
||||
{
|
||||
private const string Cluster = "SITE-A";
|
||||
private const string Principal = "svc";
|
||||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||
|
||||
private readonly string _pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-stale-{Guid.NewGuid():N}");
|
||||
|
||||
/// <summary>A surgical remove tells a stood-down item and drops it from the revalidation map.</summary>
|
||||
[Fact]
|
||||
public async Task A_surgical_remove_tells_and_forgets_a_stood_down_item()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
try
|
||||
{
|
||||
var nm = server.NodeManager!;
|
||||
var (item, nodeId) = await StandDownAsync(nm, "Plant/Modbus/dev1/Speed");
|
||||
|
||||
nm.RemoveVariableNode("Plant/Modbus/dev1/Speed", AddressSpaceRealm.Raw).ShouldBeTrue();
|
||||
|
||||
item.Statuses.ShouldContain((StatusCode)StatusCodes.BadNodeIdUnknown);
|
||||
nm.LiveItemCount.ShouldBe(0, "the item's node is gone; the map must not keep re-authorizing it");
|
||||
nm.IsNodeMonitored(nodeId).ShouldBeFalse();
|
||||
}
|
||||
finally { await host.DisposeAsync(); }
|
||||
}
|
||||
|
||||
/// <summary>A rebuild sweeps every stood-down item.</summary>
|
||||
[Fact]
|
||||
public async Task A_rebuild_tells_and_forgets_every_stood_down_item()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
try
|
||||
{
|
||||
var nm = server.NodeManager!;
|
||||
var (item, _) = await StandDownAsync(nm, "Plant/Modbus/dev1/Speed");
|
||||
|
||||
nm.RebuildAddressSpace();
|
||||
nm.CompleteAddressSpaceApply();
|
||||
|
||||
item.Statuses.ShouldContain((StatusCode)StatusCodes.BadNodeIdUnknown);
|
||||
nm.LiveItemCount.ShouldBe(0);
|
||||
}
|
||||
finally { await host.DisposeAsync(); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Positive control for the sweep: a stood-down item on a SURVIVING node is left alone by a surgical
|
||||
/// remove of a sibling, and the next permitted pass restores it.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_stood_down_item_on_a_surviving_node_is_untouched_and_still_restorable()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
try
|
||||
{
|
||||
var nm = server.NodeManager!;
|
||||
nm.EnsureVariable("Plant/Modbus/dev1/Other", null, "Other", "Float", writable: false, realm: AddressSpaceRealm.Raw);
|
||||
var (item, _) = await StandDownAsync(nm, "Plant/Modbus/dev1/Speed");
|
||||
|
||||
nm.RemoveVariableNode("Plant/Modbus/dev1/Other", AddressSpaceRealm.Raw).ShouldBeTrue();
|
||||
|
||||
item.Statuses.ShouldNotContain((StatusCode)StatusCodes.BadNodeIdUnknown);
|
||||
nm.LiveItemCount.ShouldBe(1);
|
||||
|
||||
// Grant restored ⇒ the pass re-attaches it (a second stand-down proves the restore happened).
|
||||
nm.Authorizer = Authorizer(permits: true);
|
||||
nm.RevalidateMonitoredItems();
|
||||
nm.Authorizer = Authorizer(permits: false);
|
||||
nm.RevalidateMonitoredItems();
|
||||
item.Statuses.Count(s => s == StatusCodes.BadUserAccessDenied).ShouldBe(2);
|
||||
}
|
||||
finally { await host.DisposeAsync(); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Review fix round 1 (minor): the same-id kind swap (scripted ⇄ native) in <c>MaterialiseAlarmCondition</c>
|
||||
/// drops the old condition instance — one more removal path, so it tombstones like the others. Believed
|
||||
/// unreachable from a composition (native ids are RawPaths in Raw, scripted ids live in Uns), so it is driven
|
||||
/// directly.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_condition_kind_swap_tells_and_forgets_items_on_the_dropped_instance()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
try
|
||||
{
|
||||
var nm = server.NodeManager!;
|
||||
nm.EnsureFolder("eq-1", null, "Equipment", AddressSpaceRealm.Uns);
|
||||
nm.MaterialiseAlarmCondition("al-1", "eq-1", "High", "OffNormalAlarm", 500, AddressSpaceRealm.Uns, isNative: false);
|
||||
var nodeId = new NodeId("al-1", nm.TryGetFolder("eq-1")!.NodeId.NamespaceIndex);
|
||||
var handle = (NodeHandle)nm.GetManagerHandle(nodeId);
|
||||
handle.ShouldNotBeNull("non-vacuity: the condition is materialised");
|
||||
var item = new StubItem(handle, Identity());
|
||||
nm.TrackLiveMonitoredItem(handle, item);
|
||||
nm.Authorizer = Authorizer(permits: false);
|
||||
nm.RevalidateMonitoredItems();
|
||||
item.Statuses.ShouldContain((StatusCode)StatusCodes.BadUserAccessDenied, "non-vacuity: stood down");
|
||||
|
||||
nm.MaterialiseAlarmCondition("al-1", "eq-1", "High", "OffNormalAlarm", 500, AddressSpaceRealm.Uns, isNative: true);
|
||||
|
||||
item.Statuses.ShouldContain((StatusCode)StatusCodes.BadNodeIdUnknown, "the dropped instance's item is told");
|
||||
nm.LiveItemCount.ShouldBe(0);
|
||||
((NodeHandle)nm.GetManagerHandle(nodeId)).ShouldNotBeNull("positive control: the swapped-in condition exists");
|
||||
}
|
||||
finally { await host.DisposeAsync(); }
|
||||
}
|
||||
|
||||
/// <summary>Registers a stub item on <paramref name="rawPath"/> and stands it down through a denying pass.</summary>
|
||||
private static async Task<(StubItem Item, NodeId NodeId)> StandDownAsync(OtOpcUaNodeManager nm, string rawPath)
|
||||
{
|
||||
nm.EnsureVariable(rawPath, null, rawPath, "Float", writable: false, realm: AddressSpaceRealm.Raw);
|
||||
var nodeId = nm.TryGetVariable(rawPath, AddressSpaceRealm.Raw)!.NodeId;
|
||||
var handle = (NodeHandle)nm.GetManagerHandle(nodeId);
|
||||
var item = new StubItem(handle, Identity());
|
||||
nm.TrackLiveMonitoredItem(handle, item);
|
||||
|
||||
nm.Authorizer = Authorizer(permits: false);
|
||||
nm.RevalidateMonitoredItems();
|
||||
item.Statuses.ShouldContain((StatusCode)StatusCodes.BadUserAccessDenied, "non-vacuity: the item really was stood down");
|
||||
await Task.CompletedTask;
|
||||
return (item, nodeId);
|
||||
}
|
||||
|
||||
private static DataPlaneAuthorizer Authorizer(bool permits)
|
||||
{
|
||||
var state = new DataPlaneAuthorityState();
|
||||
var grants = permits
|
||||
? new[]
|
||||
{
|
||||
new NodeAcl
|
||||
{
|
||||
NodeAclId = "acl-cluster", ClusterId = Cluster, Principal = Principal,
|
||||
ScopeKind = NodeAclScopeKind.Cluster, PermissionFlags = NodePermissions.Subscribe,
|
||||
},
|
||||
}
|
||||
: [];
|
||||
state.Apply(Cluster, "rev-1", [], grants, new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>());
|
||||
return new DataPlaneAuthorizer(state, new TriePermissionEvaluator(state.Tries), enabled: true, NullLogger.Instance);
|
||||
}
|
||||
|
||||
private static RoleCarryingUserIdentity Identity() =>
|
||||
new(new UserNameIdentityToken { UserName = "apikey" }, [], Principal, apiKeyId: null);
|
||||
|
||||
private async Task<(OpcUaApplicationHost Host, OtOpcUaSdkServer Server)> BootAsync() =>
|
||||
await OpcUaHostStart.StartAsync(
|
||||
port => new OpcUaApplicationHost(
|
||||
new OpcUaApplicationHostOptions
|
||||
{
|
||||
ApplicationName = "OtOpcUa.StaleTest",
|
||||
ApplicationUri = $"urn:OtOpcUa.StaleTest:{Guid.NewGuid():N}",
|
||||
OpcUaPort = port,
|
||||
PublicHostname = "localhost",
|
||||
PkiStoreRoot = _pkiRoot,
|
||||
},
|
||||
NullLogger<OpcUaApplicationHost>.Instance),
|
||||
() => new OtOpcUaSdkServer(), Ct);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_pkiRoot)) Directory.Delete(_pkiRoot, recursive: true);
|
||||
}
|
||||
|
||||
/// <summary>The minimum of a sampled item the removal path touches: its handle, identity and value queue.</summary>
|
||||
private sealed class StubItem(NodeHandle handle, IUserIdentity identity) : ISampledDataChangeMonitoredItem
|
||||
{
|
||||
public List<StatusCode> Statuses { get; } = [];
|
||||
public IUserIdentity EffectiveIdentity => identity;
|
||||
public object ManagerHandle => handle;
|
||||
public NodeId NodeId => handle.NodeId;
|
||||
public void QueueValue(DataValue value, ServiceResult error, bool ignoreFilters) => Statuses.Add(error?.StatusCode ?? value.StatusCode);
|
||||
public void QueueValue(DataValue value, ServiceResult error) => QueueValue(value, error, false);
|
||||
public void Dispose() { }
|
||||
|
||||
public INodeManager NodeManager => throw new NotSupportedException();
|
||||
public ISession Session => throw new NotSupportedException();
|
||||
public uint Id => throw new NotSupportedException();
|
||||
public uint SubscriptionId => throw new NotSupportedException();
|
||||
public bool IsDurable => throw new NotSupportedException();
|
||||
public uint ClientHandle => throw new NotSupportedException();
|
||||
public ISubscription SubscriptionCallback { get => throw new NotSupportedException(); set => throw new NotSupportedException(); }
|
||||
public int MonitoredItemType => throw new NotSupportedException();
|
||||
public bool IsReadyToPublish => throw new NotSupportedException();
|
||||
public bool IsReadyToTrigger { get => throw new NotSupportedException(); set => throw new NotSupportedException(); }
|
||||
public bool IsResendData => throw new NotSupportedException();
|
||||
public MonitoringMode MonitoringMode => throw new NotSupportedException();
|
||||
public double SamplingInterval => throw new NotSupportedException();
|
||||
public DataChangeFilter DataChangeFilter => throw new NotSupportedException();
|
||||
public uint AttributeId => throw new NotSupportedException();
|
||||
public NumericRange IndexRange => throw new NotSupportedException();
|
||||
public QualifiedName DataEncoding => throw new NotSupportedException();
|
||||
public bool AlwaysReportUpdates { get => throw new NotSupportedException(); set => throw new NotSupportedException(); }
|
||||
public DiagnosticsMasks DiagnosticsMasks => throw new NotSupportedException();
|
||||
public uint QueueSize => throw new NotSupportedException();
|
||||
public double MinimumSamplingInterval => throw new NotSupportedException();
|
||||
public int TimeToNextSample => throw new NotSupportedException();
|
||||
public void SetupResendDataTrigger() => throw new NotSupportedException();
|
||||
public ServiceResult GetCreateResult(out MonitoredItemCreateResult result) => throw new NotSupportedException();
|
||||
public ServiceResult GetModifyResult(out MonitoredItemModifyResult result) => throw new NotSupportedException();
|
||||
public IStoredMonitoredItem ToStorableMonitoredItem() => throw new NotSupportedException();
|
||||
public MonitoringMode SetMonitoringMode(MonitoringMode monitoringMode) => throw new NotSupportedException();
|
||||
public bool Publish(OperationContext context, Queue<MonitoredItemNotification> notifications, Queue<DiagnosticInfo> diagnostics, uint maxNotificationsPerPublish, ILogger logger) => throw new NotSupportedException();
|
||||
public void SetSemanticsChanged() => throw new NotSupportedException();
|
||||
public void SetStructureChanged() => throw new NotSupportedException();
|
||||
public bool SamplingIntervalExpired() => throw new NotSupportedException();
|
||||
public ReadValueId GetReadValueId() => throw new NotSupportedException();
|
||||
public ServiceResult ModifyAttributes(DiagnosticsMasks diagnosticsMasks, TimestampsToReturn timestampsToReturn, uint clientHandle, MonitoringFilter originalFilter, MonitoringFilter filterToUse, Opc.Ua.Range range, double samplingInterval, uint queueSize, bool discardOldest) => throw new NotSupportedException();
|
||||
public void SetSamplingInterval(double samplingInterval) => throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Opc.Ua;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Review 01 L3 / H5 / H4 — the device-write dispatch behind <see cref="OtOpcUaNodeManager.OnEquipmentTagWrite"/>
|
||||
/// and the manager's clock: a gateway that throws synchronously is refused inline (the handler runs under
|
||||
/// the SDK <c>Lock</c>, and the throw used to escape it), a faulted dispatch task is observed and still
|
||||
/// reverts the optimistic value, and the timestamps the manager stamps come from the injected
|
||||
/// <see cref="OtOpcUaNodeManager.Clock"/>.
|
||||
/// </summary>
|
||||
public sealed class NodeManagerWriteDispatchTests : IDisposable
|
||||
{
|
||||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||
|
||||
private readonly string _pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-dispatch-{Guid.NewGuid():N}");
|
||||
|
||||
/// <summary>H5 — a synchronous throw out of the gateway is a Bad result, never an escaped exception.</summary>
|
||||
[Fact]
|
||||
public async Task A_gateway_that_throws_synchronously_is_refused_inline()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
try
|
||||
{
|
||||
var nm = server.NodeManager!;
|
||||
nm.EnsureVariable("eq-1/sp", null, "Sp", "Int32", writable: true, realm: AddressSpaceRealm.Uns);
|
||||
nm.WriteValue("eq-1/sp", 7, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||
|
||||
var result = nm.DispatchDeviceWrite(
|
||||
new ThrowingGateway(), "ns=2;s=eq-1/sp", "eq-1/sp", AddressSpaceRealm.Uns,
|
||||
optimisticValue: 42, priorValue: 7, priorStatus: StatusCodes.Good, clientUserId: "op");
|
||||
|
||||
ServiceResult.IsBad(result).ShouldBeTrue();
|
||||
result.StatusCode.ShouldBe((StatusCode)StatusCodes.BadDeviceFailure);
|
||||
result.ToString().ShouldContain("boom");
|
||||
nm.TryGetVariable("eq-1/sp")!.Value.ShouldBe(7, "nothing was applied, so nothing needs reverting");
|
||||
}
|
||||
finally { await host.DisposeAsync(); }
|
||||
}
|
||||
|
||||
/// <summary>L3 — a faulted dispatch task is observed and the optimistic value is reverted with its reason.</summary>
|
||||
[Fact]
|
||||
public async Task A_faulted_dispatch_is_observed_and_reverts_the_optimistic_value()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
try
|
||||
{
|
||||
var nm = server.NodeManager!;
|
||||
nm.EnsureVariable("eq-1/sp", null, "Sp", "Int32", writable: true, realm: AddressSpaceRealm.Uns);
|
||||
nm.WriteValue("eq-1/sp", 7, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||
var node = nm.TryGetVariable("eq-1/sp")!;
|
||||
|
||||
var result = nm.DispatchDeviceWrite(
|
||||
new FaultingGateway(), "ns=2;s=eq-1/sp", "eq-1/sp", AddressSpaceRealm.Uns,
|
||||
optimisticValue: 42, priorValue: 7, priorStatus: StatusCodes.Good, clientUserId: "op");
|
||||
ServiceResult.IsGood(result).ShouldBeTrue("the dispatch itself started; the SDK applies the value optimistically");
|
||||
node.Value = 42; // what the SDK does on Good
|
||||
|
||||
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5);
|
||||
while (DateTime.UtcNow < deadline && !Equals(node.Value, 7)) await Task.Delay(20, Ct);
|
||||
node.Value.ShouldBe(7, "the faulted dispatch must revert the optimistic value");
|
||||
node.StatusCode.ShouldBe((StatusCode)StatusCodes.Good);
|
||||
}
|
||||
finally { await host.DisposeAsync(); }
|
||||
}
|
||||
|
||||
/// <summary>Positive control for the two above: a gateway that succeeds leaves the optimistic value standing.</summary>
|
||||
[Fact]
|
||||
public async Task A_successful_dispatch_leaves_the_optimistic_value()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
try
|
||||
{
|
||||
var nm = server.NodeManager!;
|
||||
nm.EnsureVariable("eq-1/sp", null, "Sp", "Int32", writable: true, realm: AddressSpaceRealm.Uns);
|
||||
nm.WriteValue("eq-1/sp", 7, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||
var node = nm.TryGetVariable("eq-1/sp")!;
|
||||
|
||||
nm.DispatchDeviceWrite(
|
||||
new SucceedingGateway(), "ns=2;s=eq-1/sp", "eq-1/sp", AddressSpaceRealm.Uns,
|
||||
optimisticValue: 42, priorValue: 7, priorStatus: StatusCodes.Good, clientUserId: "op");
|
||||
node.Value = 42;
|
||||
|
||||
await Task.Delay(200, Ct);
|
||||
node.Value.ShouldBe(42);
|
||||
}
|
||||
finally { await host.DisposeAsync(); }
|
||||
}
|
||||
|
||||
/// <summary>H4 — the revert stamps the manager's clock, not the wall clock.</summary>
|
||||
[Fact]
|
||||
public async Task The_revert_timestamps_come_from_the_injected_clock()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
try
|
||||
{
|
||||
var nm = server.NodeManager!;
|
||||
var fixedNow = new DateTime(2030, 1, 2, 3, 4, 5, DateTimeKind.Utc);
|
||||
nm.Clock = new FixedClock(fixedNow);
|
||||
nm.EnsureVariable("eq-1/sp", null, "Sp", "Int32", writable: true, realm: AddressSpaceRealm.Uns);
|
||||
nm.WriteValue("eq-1/sp", 7, OpcUaStatus.Good, new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), AddressSpaceRealm.Uns);
|
||||
var node = nm.TryGetVariable("eq-1/sp")!;
|
||||
node.Value = 42;
|
||||
|
||||
nm.RevertOptimisticWriteIfNeeded(
|
||||
"eq-1/sp", new NodeWriteOutcome(false, "rejected"), optimisticValue: 42, priorValue: 7,
|
||||
priorStatus: StatusCodes.Good, clientUserId: "op", realm: AddressSpaceRealm.Uns);
|
||||
|
||||
node.Timestamp.ShouldBe(fixedNow);
|
||||
OtOpcUaNodeManager.RemainingShelveMs(fixedNow.AddSeconds(30), fixedNow).ShouldBe(30_000);
|
||||
}
|
||||
finally { await host.DisposeAsync(); }
|
||||
}
|
||||
|
||||
private async Task<(OpcUaApplicationHost Host, OtOpcUaSdkServer Server)> BootAsync() =>
|
||||
await OpcUaHostStart.StartAsync(
|
||||
port => new OpcUaApplicationHost(
|
||||
new OpcUaApplicationHostOptions
|
||||
{
|
||||
ApplicationName = "OtOpcUa.DispatchTest",
|
||||
ApplicationUri = $"urn:OtOpcUa.DispatchTest:{Guid.NewGuid():N}",
|
||||
OpcUaPort = port,
|
||||
PublicHostname = "localhost",
|
||||
PkiStoreRoot = _pkiRoot,
|
||||
},
|
||||
NullLogger<OpcUaApplicationHost>.Instance),
|
||||
() => new OtOpcUaSdkServer(), Ct);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_pkiRoot)) Directory.Delete(_pkiRoot, recursive: true);
|
||||
}
|
||||
|
||||
private sealed class ThrowingGateway : IOpcUaNodeWriteGateway
|
||||
{
|
||||
public Task<NodeWriteOutcome> WriteAsync(string nodeId, object? value, AddressSpaceRealm realm, CancellationToken ct) =>
|
||||
throw new InvalidOperationException("boom");
|
||||
}
|
||||
|
||||
private sealed class FaultingGateway : IOpcUaNodeWriteGateway
|
||||
{
|
||||
public Task<NodeWriteOutcome> WriteAsync(string nodeId, object? value, AddressSpaceRealm realm, CancellationToken ct) =>
|
||||
Task.FromException<NodeWriteOutcome>(new TimeoutException("gateway ask timed out"));
|
||||
}
|
||||
|
||||
private sealed class SucceedingGateway : IOpcUaNodeWriteGateway
|
||||
{
|
||||
public Task<NodeWriteOutcome> WriteAsync(string nodeId, object? value, AddressSpaceRealm realm, CancellationToken ct) =>
|
||||
Task.FromResult(new NodeWriteOutcome(true, null));
|
||||
}
|
||||
|
||||
private sealed class FixedClock(DateTime utcNow) : TimeProvider
|
||||
{
|
||||
public override DateTimeOffset GetUtcNow() => new(utcNow, TimeSpan.Zero);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
using Opc.Ua;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
|
||||
|
||||
/// <summary>Review 01 P6 — the SDK request and transport quotas are configurable (<c>OpcUa:Quotas:*</c>) and
|
||||
/// default to exactly what used to be hard-coded.</summary>
|
||||
public sealed class OpcUaApplicationHostQuotaTests
|
||||
{
|
||||
[Fact]
|
||||
public void Default_request_quotas_are_the_former_hard_coded_values()
|
||||
{
|
||||
var config = new ServerConfiguration();
|
||||
OpcUaApplicationHost.ApplyRequestQuotas(config, new OpcUaQuotaOptions());
|
||||
|
||||
config.MinRequestThreadCount.ShouldBe(5);
|
||||
config.MaxRequestThreadCount.ShouldBe(100);
|
||||
config.MaxQueuedRequestCount.ShouldBe(200);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Configured_request_quotas_are_applied()
|
||||
{
|
||||
var config = new ServerConfiguration();
|
||||
OpcUaApplicationHost.ApplyRequestQuotas(config, new OpcUaQuotaOptions
|
||||
{
|
||||
MinRequestThreadCount = 8, MaxRequestThreadCount = 250, MaxQueuedRequestCount = 1000,
|
||||
});
|
||||
|
||||
config.MinRequestThreadCount.ShouldBe(8);
|
||||
config.MaxRequestThreadCount.ShouldBe(250);
|
||||
config.MaxQueuedRequestCount.ShouldBe(1000);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unset_transport_quotas_keep_the_sdk_defaults()
|
||||
{
|
||||
var sdkDefault = new TransportQuotas();
|
||||
var built = OpcUaApplicationHost.BuildTransportQuotas(new OpcUaQuotaOptions());
|
||||
|
||||
built.OperationTimeout.ShouldBe(sdkDefault.OperationTimeout);
|
||||
built.MaxStringLength.ShouldBe(sdkDefault.MaxStringLength);
|
||||
built.MaxByteStringLength.ShouldBe(sdkDefault.MaxByteStringLength);
|
||||
built.MaxArrayLength.ShouldBe(sdkDefault.MaxArrayLength);
|
||||
built.MaxMessageSize.ShouldBe(sdkDefault.MaxMessageSize);
|
||||
built.MaxBufferSize.ShouldBe(sdkDefault.MaxBufferSize);
|
||||
built.ChannelLifetime.ShouldBe(sdkDefault.ChannelLifetime);
|
||||
built.SecurityTokenLifetime.ShouldBe(sdkDefault.SecurityTokenLifetime);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Configured_transport_quotas_replace_only_what_is_set()
|
||||
{
|
||||
var sdkDefault = new TransportQuotas();
|
||||
var built = OpcUaApplicationHost.BuildTransportQuotas(new OpcUaQuotaOptions
|
||||
{
|
||||
MaxMessageSize = 8 * 1024 * 1024, OperationTimeoutMs = 45_000, MaxArrayLength = 1_000_000,
|
||||
});
|
||||
|
||||
built.MaxMessageSize.ShouldBe(8 * 1024 * 1024);
|
||||
built.OperationTimeout.ShouldBe(45_000);
|
||||
built.MaxArrayLength.ShouldBe(1_000_000);
|
||||
built.MaxStringLength.ShouldBe(sdkDefault.MaxStringLength);
|
||||
built.ChannelLifetime.ShouldBe(sdkDefault.ChannelLifetime);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void History_read_batch_cap_default_scales_with_the_machine()
|
||||
{
|
||||
OtOpcUaNodeManager.DefaultMaxConcurrentHistoryReadBatches
|
||||
.ShouldBe(Math.Clamp(Environment.ProcessorCount * 2, 4, 16));
|
||||
}
|
||||
}
|
||||
+7
-2
@@ -19,8 +19,8 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
|
||||
///
|
||||
/// <para><b>Never creates.</b> A status carries nothing to create a node from, and the caller's
|
||||
/// per-driver index can legitimately name a node a surgical remove has just dropped. A missing id
|
||||
/// is skipped, not materialised — the opposite of <see cref="OtOpcUaNodeManager.WriteValues"/>,
|
||||
/// which lazily creates.</para>
|
||||
/// is skipped, not materialised — the same contract <see cref="OtOpcUaNodeManager.WriteValues"/>
|
||||
/// carries since review 01 L1 (it used to lazily create).</para>
|
||||
///
|
||||
/// <para>Boots a real <see cref="OtOpcUaSdkServer"/>, like
|
||||
/// <see cref="NodeManagerBatchWriteTests"/>, so the assertions read the SDK node state a client
|
||||
@@ -81,6 +81,8 @@ public sealed class OtOpcUaNodeManagerWriteStatusesTests : IDisposable
|
||||
var ts = new DateTime(2026, 8, 20, 10, 0, 0, DateTimeKind.Utc);
|
||||
const string sharedId = "Shared/both/realms";
|
||||
|
||||
nm.EnsureVariable(sharedId, null, "shared", "Int32", writable: false, realm: AddressSpaceRealm.Raw);
|
||||
nm.EnsureVariable(sharedId, null, "shared", "Int32", writable: false, realm: AddressSpaceRealm.Uns);
|
||||
nm.WriteValue(sharedId, 1, OpcUaStatus.Good, ts, AddressSpaceRealm.Raw);
|
||||
nm.WriteValue(sharedId, 2, OpcUaStatus.Good, ts, AddressSpaceRealm.Uns);
|
||||
|
||||
@@ -110,6 +112,7 @@ public sealed class OtOpcUaNodeManagerWriteStatusesTests : IDisposable
|
||||
{
|
||||
var nm = server.NodeManager!;
|
||||
var ts = new DateTime(2026, 8, 20, 10, 0, 0, DateTimeKind.Utc);
|
||||
nm.EnsureVariable("Plant/known", null, "known", "Int32", writable: false, realm: AddressSpaceRealm.Raw);
|
||||
nm.WriteValue("Plant/known", 7, OpcUaStatus.Good, ts, AddressSpaceRealm.Raw);
|
||||
var before = nm.VariableCount;
|
||||
|
||||
@@ -143,6 +146,8 @@ public sealed class OtOpcUaNodeManagerWriteStatusesTests : IDisposable
|
||||
{
|
||||
var nm = server.NodeManager!;
|
||||
var ts = new DateTime(2026, 8, 20, 10, 0, 0, DateTimeKind.Utc);
|
||||
nm.EnsureVariable("Plant/first", null, "first", "Int32", writable: false, realm: AddressSpaceRealm.Raw);
|
||||
nm.EnsureVariable("Plant/third", null, "third", "Int32", writable: false, realm: AddressSpaceRealm.Raw);
|
||||
nm.WriteValue("Plant/first", 1, OpcUaStatus.Good, ts, AddressSpaceRealm.Raw);
|
||||
nm.WriteValue("Plant/third", 3, OpcUaStatus.Good, ts, AddressSpaceRealm.Raw);
|
||||
|
||||
|
||||
@@ -20,18 +20,25 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
|
||||
Path.GetTempPath(),
|
||||
$"otopcua-sink-{Guid.NewGuid():N}");
|
||||
|
||||
/// <summary>Verifies that WriteValue creates and updates variables in the OPC UA node manager.</summary>
|
||||
/// <summary>Verifies that WriteValue updates declared variables in the OPC UA node manager (and never
|
||||
/// creates one — review 01 L1, pinned by <see cref="NodeManagerPhantomNodeTests"/>).</summary>
|
||||
[Fact]
|
||||
public async Task WriteValue_creates_and_updates_variable_in_node_manager()
|
||||
public async Task WriteValue_updates_declared_variables_in_node_manager()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
var sink = new SdkAddressSpaceSink(server.NodeManager!);
|
||||
var nm = server.NodeManager!;
|
||||
var sink = new SdkAddressSpaceSink(nm);
|
||||
sink.EnsureVariable("eq-1/temp", null, "temp", "Double", writable: false, realm: AddressSpaceRealm.Uns);
|
||||
sink.EnsureVariable("eq-1/pressure", null, "pressure", "Int32", writable: false, realm: AddressSpaceRealm.Uns);
|
||||
|
||||
sink.WriteValue("eq-1/temp", 22.5, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||
sink.WriteValue("eq-1/temp", 23.1, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||
sink.WriteValue("eq-1/pressure", 100, OpcUaStatus.Uncertain, DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||
sink.WriteValue("eq-1/undeclared", 1, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||
|
||||
server.NodeManager!.VariableCount.ShouldBe(2);
|
||||
nm.VariableCount.ShouldBe(2);
|
||||
nm.TryGetVariable("eq-1/temp")!.Value.ShouldBe(23.1);
|
||||
nm.TryGetVariable("eq-1/pressure")!.StatusCode.ShouldBe((StatusCode)StatusCodes.Uncertain);
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
@@ -50,6 +57,8 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
|
||||
// (a concrete-typed call would simply stop compiling, which is not the regression being pinned).
|
||||
IOpcUaAddressSpaceSink sink = new SdkAddressSpaceSink(nm);
|
||||
var ts = new DateTime(2026, 8, 15, 9, 0, 0, DateTimeKind.Utc);
|
||||
sink.EnsureVariable("Plant/dev1/Speed", null, "Speed", "Int32", writable: false, realm: AddressSpaceRealm.Raw);
|
||||
sink.EnsureVariable("eq-1/Speed", null, "Speed", "Int32", writable: false, realm: AddressSpaceRealm.Uns);
|
||||
|
||||
sink.WriteValues(new List<ValueWrite>
|
||||
{
|
||||
@@ -91,6 +100,8 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
|
||||
var nm = server.NodeManager!;
|
||||
IOpcUaAddressSpaceSink sink = new SdkAddressSpaceSink(nm);
|
||||
var ts = new DateTime(2026, 8, 15, 9, 30, 0, DateTimeKind.Utc);
|
||||
sink.EnsureVariable("eq-fwd/first", null, "first", "Int32", writable: false, realm: AddressSpaceRealm.Uns);
|
||||
sink.EnsureVariable("eq-fwd/third", null, "third", "Int32", writable: false, realm: AddressSpaceRealm.Uns);
|
||||
|
||||
var ex = Should.Throw<ValueWriteBatchException>(() => sink.WriteValues(new List<ValueWrite>
|
||||
{
|
||||
@@ -113,18 +124,22 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verifies that WriteAlarmCondition (un-materialised fallback) creates a dedicated node
|
||||
/// distinct from value writes.</summary>
|
||||
/// <summary>A WriteAlarmCondition for an id no condition was materialised under is a no-op (review 01 L4):
|
||||
/// it creates no placeholder node, and the value write beside it is unaffected.</summary>
|
||||
[Fact]
|
||||
public async Task WriteAlarmCondition_creates_dedicated_node_distinct_from_value_writes()
|
||||
public async Task WriteAlarmCondition_for_an_unmaterialised_id_creates_no_node()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
var sink = new SdkAddressSpaceSink(server.NodeManager!);
|
||||
var nm = server.NodeManager!;
|
||||
var sink = new SdkAddressSpaceSink(nm);
|
||||
sink.EnsureVariable("eq-1/temp", null, "temp", "Double", writable: false, realm: AddressSpaceRealm.Uns);
|
||||
|
||||
sink.WriteAlarmCondition("alarm-7", Snapshot(active: true, acknowledged: false), DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||
sink.WriteValue("eq-1/temp", 22.5, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||
|
||||
server.NodeManager!.VariableCount.ShouldBe(2);
|
||||
nm.VariableCount.ShouldBe(1);
|
||||
nm.TryGetVariable("alarm-7").ShouldBeNull();
|
||||
nm.TryGetAlarmCondition("alarm-7").ShouldBeNull();
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
@@ -242,17 +257,21 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
|
||||
var (host, server) = await BootAsync();
|
||||
var sink = new SdkAddressSpaceSink(server.NodeManager!);
|
||||
|
||||
sink.EnsureVariable("a", null, "a", "Int32", writable: false, realm: AddressSpaceRealm.Uns);
|
||||
sink.EnsureVariable("b", null, "b", "Int32", writable: false, realm: AddressSpaceRealm.Uns);
|
||||
sink.EnsureVariable("c", null, "c", "Int32", writable: false, realm: AddressSpaceRealm.Uns);
|
||||
sink.WriteValue("a", 1, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||
sink.WriteValue("b", 2, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||
sink.WriteAlarmCondition("alarm-c", Snapshot(active: true), DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||
server.NodeManager!.VariableCount.ShouldBe(3);
|
||||
|
||||
sink.RebuildAddressSpace();
|
||||
server.NodeManager.VariableCount.ShouldBe(0);
|
||||
|
||||
// After rebuild, subsequent writes start fresh.
|
||||
// After rebuild, subsequent materialisation starts fresh.
|
||||
sink.EnsureVariable("a", null, "a", "Int32", writable: false, realm: AddressSpaceRealm.Uns);
|
||||
sink.WriteValue("a", 99, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||
server.NodeManager.VariableCount.ShouldBe(1);
|
||||
server.NodeManager.TryGetVariable("a")!.Value.ShouldBe(99);
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Lane B review N4 — <see cref="VariableShape.TryFit"/> decides whether a value deferred during a plan window may
|
||||
/// be replayed onto a node an in-place edit re-shaped: never an old-typed value the new DataType contradicts.
|
||||
/// </summary>
|
||||
public sealed class VariableShapeTests
|
||||
{
|
||||
[Fact]
|
||||
public void A_value_of_the_new_type_fits_unchanged()
|
||||
{
|
||||
new VariableShape("Int32", false, null).TryFit(7, out var fitted).ShouldBeTrue();
|
||||
fitted.ShouldBe(7);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_convertible_old_typed_value_is_converted_to_the_new_type()
|
||||
{
|
||||
new VariableShape("Int32", false, null).TryFit(5.0f, out var fitted).ShouldBeTrue();
|
||||
fitted.ShouldBeOfType<int>().ShouldBe(5);
|
||||
new VariableShape("Double", false, null).TryFit((short)3, out var d).ShouldBeTrue();
|
||||
d.ShouldBeOfType<double>().ShouldBe(3d);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Int32", "fast")] // not a number
|
||||
[InlineData("Byte", 300)] // overflow
|
||||
[InlineData("DateTime", 12)] // no conversion
|
||||
public void A_value_the_new_type_cannot_hold_does_not_fit(string dataType, object value)
|
||||
{
|
||||
new VariableShape(dataType, false, null).TryFit(value, out var fitted).ShouldBeFalse();
|
||||
fitted.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Scalar_and_array_never_cross()
|
||||
{
|
||||
new VariableShape("Int32", false, null).TryFit(new[] { 1, 2 }, out _).ShouldBeFalse();
|
||||
new VariableShape("Int32", true, 2).TryFit(1, out _).ShouldBeFalse();
|
||||
new VariableShape("String", true, null).TryFit("ab", out _).ShouldBeFalse("a string is not an array of strings");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_array_is_converted_element_wise_and_its_fixed_length_enforced()
|
||||
{
|
||||
new VariableShape("Int16", true, 3).TryFit(new[] { 1, 2, 3 }, out var fitted).ShouldBeTrue();
|
||||
fitted.ShouldBeOfType<short[]>().ShouldBe(new short[] { 1, 2, 3 });
|
||||
new VariableShape("Int16", true, 4).TryFit(new[] { 1, 2, 3 }, out _).ShouldBeFalse("wrong length");
|
||||
new VariableShape("Int16", true, null).TryFit(new[] { 1, 2, 3 }, out _).ShouldBeTrue("no fixed length");
|
||||
new VariableShape("Byte", true, null).TryFit(new[] { 1, 999 }, out _).ShouldBeFalse("an element overflows");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Null_and_unknown_types_always_fit()
|
||||
{
|
||||
new VariableShape("Int32", false, null).TryFit(null, out var n).ShouldBeTrue();
|
||||
n.ShouldBeNull();
|
||||
new VariableShape("Structure", false, null).TryFit("anything", out var any).ShouldBeTrue();
|
||||
any.ShouldBe("anything");
|
||||
}
|
||||
}
|
||||
+10
-4
@@ -50,13 +50,13 @@ public sealed class OpcUaPublishActorApplyFailureTests : RuntimeActorTestBase
|
||||
|
||||
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink, dbFactory: db, applier: applier));
|
||||
// First deploy: PureAdd — no rebuild, so the throwing sink is not hit yet.
|
||||
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep1)));
|
||||
actor.Tell(Rebuild(dep1));
|
||||
Thread.Sleep(200);
|
||||
recorder.Total.ShouldBe(0);
|
||||
|
||||
// Second deploy: eq-1 RENAMED ⇒ ChangedEquipment ⇒ Rebuild ⇒ the sink's RebuildAddressSpace throws.
|
||||
var dep2 = SeedEquipmentDeployment(db, ("eq-1", "eq-1-renamed"));
|
||||
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep2)));
|
||||
actor.Tell(Rebuild(dep2));
|
||||
|
||||
// A PRESENCE budget, so it is an upper bound before giving up rather than a wait: the helper polls
|
||||
// and returns the instant the meter fires, so a generous value costs nothing on the happy path and
|
||||
@@ -78,10 +78,10 @@ public sealed class OpcUaPublishActorApplyFailureTests : RuntimeActorTestBase
|
||||
var db = NewInMemoryDbFactory();
|
||||
var sink = new NoopSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
SeedEquipmentDeployment(db, ("eq-1", "eq-1"));
|
||||
var dep = SeedEquipmentDeployment(db, ("eq-1", "eq-1"));
|
||||
|
||||
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink, dbFactory: db, applier: applier));
|
||||
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId()));
|
||||
actor.Tell(Rebuild(dep));
|
||||
|
||||
// Give the actor time to process, then confirm the failed meter never fired.
|
||||
Thread.Sleep(400);
|
||||
@@ -100,6 +100,11 @@ public sealed class OpcUaPublishActorApplyFailureTests : RuntimeActorTestBase
|
||||
if (last is not null) throw last;
|
||||
}
|
||||
|
||||
private static readonly System.Collections.Concurrent.ConcurrentDictionary<Guid, byte[]> Artifacts = new();
|
||||
|
||||
private static OpcUaPublishActor.RebuildAddressSpace Rebuild(Guid deploymentId) =>
|
||||
new(CorrelationId.NewId(), new DeploymentId(deploymentId), Artifacts[deploymentId]);
|
||||
|
||||
private static Guid SeedEquipmentDeployment(IDbContextFactory<OtOpcUaConfigDbContext> dbFactory, params (string Id, string Name)[] equipment)
|
||||
{
|
||||
var artifact = JsonSerializer.SerializeToUtf8Bytes(new
|
||||
@@ -116,6 +121,7 @@ public sealed class OpcUaPublishActorApplyFailureTests : RuntimeActorTestBase
|
||||
});
|
||||
|
||||
var id = Guid.NewGuid();
|
||||
Artifacts[id] = artifact;
|
||||
using var ctx = dbFactory.CreateDbContext();
|
||||
ctx.Deployments.Add(new Deployment
|
||||
{
|
||||
|
||||
+844
@@ -0,0 +1,844 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.Json;
|
||||
using Akka.Actor;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.OpcUa;
|
||||
|
||||
/// <summary>
|
||||
/// Lane B review fix round 1. C1: the plan runs off the pinned thread (R02:P5) while writes are non-creating
|
||||
/// (R01:L1), so a value, status or alarm write for a node the IN-FLIGHT plan adds used to be skipped — and a
|
||||
/// change-driven driver never republishes it. The actor now remembers the latest write per node while a plan
|
||||
/// is in flight and replays it after the apply. Every test holds the plan open with a gate, so "during the
|
||||
/// plan" is deterministic, and runs against a sink that — like the real node manager — drops writes for nodes
|
||||
/// that do not exist.
|
||||
/// Also here: I2 (an authority parse throw never fails the apply), the bytes-less request queued behind an
|
||||
/// in-flight plan, and a plan piped back to a restarted actor.
|
||||
/// </summary>
|
||||
public sealed class OpcUaPublishActorDeferredWriteTests : RuntimeActorTestBase
|
||||
{
|
||||
private const string NewTag = "Plant/Modbus/dev1/T1";
|
||||
private static readonly TimeSpan PlanBudget = TimeSpan.FromSeconds(30);
|
||||
|
||||
[Fact]
|
||||
public void A_value_for_a_node_the_in_flight_plan_adds_lands_on_that_node_after_the_apply()
|
||||
{
|
||||
var sink = new ExistenceSink();
|
||||
using var gate = new ManualResetEventSlim(false);
|
||||
var actor = Spawn(sink, gate);
|
||||
|
||||
actor.Tell(Rebuild(RawTagArtifact(3)));
|
||||
var at = DateTime.UtcNow;
|
||||
actor.Tell(new OpcUaPublishActor.AttributeValueBatchUpdate(
|
||||
[new ValueWrite(NewTag, 7, OpcUaStatus.Good, at, AddressSpaceRealm.Raw)]));
|
||||
AwaitAssert(() => sink.SkippedWrites.ShouldContain(NewTag), PlanBudget); // non-vacuity: it really missed
|
||||
|
||||
gate.Set();
|
||||
ExpectMsg<OpcUaPublishActor.RebuildCompleted>(PlanBudget).Degraded.ShouldBeFalse();
|
||||
|
||||
sink.Exists(NewTag).ShouldBeTrue("the plan materialised the node");
|
||||
sink.ValueOf(NewTag).ShouldBe((7, OpcUaStatus.Good, at), "the value sent while the plan was in flight must end up on the node");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_status_after_a_value_composes_onto_the_new_node_in_order()
|
||||
{
|
||||
var sink = new ExistenceSink();
|
||||
using var gate = new ManualResetEventSlim(false);
|
||||
var actor = Spawn(sink, gate);
|
||||
|
||||
actor.Tell(Rebuild(RawTagArtifact(3)));
|
||||
var t1 = DateTime.UtcNow;
|
||||
var t2 = t1.AddSeconds(1);
|
||||
actor.Tell(new OpcUaPublishActor.AttributeValueBatchUpdate([new ValueWrite(NewTag, 7, OpcUaStatus.Good, t1, AddressSpaceRealm.Raw)]));
|
||||
actor.Tell(new OpcUaPublishActor.AttributeStatusBatchUpdate([new StatusWrite(NewTag, OpcUaStatus.BadNoCommunication, t2, AddressSpaceRealm.Raw)]));
|
||||
gate.Set();
|
||||
ExpectMsg<OpcUaPublishActor.RebuildCompleted>(PlanBudget);
|
||||
|
||||
sink.ValueOf(NewTag).ShouldBe((7, OpcUaStatus.BadNoCommunication, t2), "last known value under the later status");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Existing_nodes_keep_updating_while_the_plan_computes()
|
||||
{
|
||||
var sink = new ExistenceSink();
|
||||
using var gate = new ManualResetEventSlim(false);
|
||||
var actor = Spawn(sink, gate);
|
||||
actor.Tell(Rebuild(RawTagArtifact(1)));
|
||||
gate.Set();
|
||||
ExpectMsg<OpcUaPublishActor.RebuildCompleted>(PlanBudget);
|
||||
var existing = "Plant/Modbus/dev1/T0";
|
||||
sink.Exists(existing).ShouldBeTrue();
|
||||
|
||||
gate.Reset();
|
||||
actor.Tell(Rebuild(RawTagArtifact(3)));
|
||||
var at = DateTime.UtcNow;
|
||||
actor.Tell(new OpcUaPublishActor.AttributeValueBatchUpdate([new ValueWrite(existing, 5, OpcUaStatus.Good, at, AddressSpaceRealm.Raw)]));
|
||||
|
||||
// Applied immediately, while the plan is still held open.
|
||||
AwaitAssert(() => sink.ValueOf(existing).ShouldBe((5, OpcUaStatus.Good, at)), PlanBudget);
|
||||
sink.Exists(NewTag).ShouldBeFalse("the plan is still in flight");
|
||||
|
||||
gate.Set();
|
||||
ExpectMsg<OpcUaPublishActor.RebuildCompleted>(PlanBudget);
|
||||
sink.ValueOf(existing).ShouldBe((5, OpcUaStatus.Good, at));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_alarm_state_sent_during_the_plan_lands_on_the_condition_it_materialises()
|
||||
{
|
||||
var sink = new ExistenceSink();
|
||||
using var gate = new ManualResetEventSlim(false);
|
||||
var actor = Spawn(sink, gate);
|
||||
|
||||
actor.Tell(Rebuild(AlarmArtifact()));
|
||||
var state = new AlarmConditionSnapshot(Active: true, Acknowledged: false, Confirmed: true, Enabled: true, AlarmShelvingKind.Unshelved, 500, "high");
|
||||
actor.Tell(new OpcUaPublishActor.AlarmStateUpdate("al-1", state, DateTime.UtcNow, AddressSpaceRealm.Uns));
|
||||
actor.Tell(new OpcUaPublishActor.AlarmQualityUpdate("al-1", OpcUaStatus.BadNoCommunication, DateTime.UtcNow, AddressSpaceRealm.Uns));
|
||||
AwaitAssert(() => sink.SkippedAlarms.ShouldContain("al-1"), PlanBudget);
|
||||
|
||||
gate.Set();
|
||||
ExpectMsg<OpcUaPublishActor.RebuildCompleted>(PlanBudget).Degraded.ShouldBeFalse();
|
||||
|
||||
sink.ConditionExists("al-1").ShouldBeTrue("the plan materialised the condition");
|
||||
sink.AlarmStateOf("al-1").ShouldBeSameAs(state);
|
||||
sink.AlarmQualityOf("al-1").ShouldBe(OpcUaStatus.BadNoCommunication);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_deferred_buffer_is_bounded_under_a_flood_and_keeps_the_earliest_keys()
|
||||
{
|
||||
var sink = new ExistenceSink();
|
||||
using var gate = new ManualResetEventSlim(false);
|
||||
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(
|
||||
sink, new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance),
|
||||
new OpcUaPublishActor.TestHooks { BeforePlan = () => gate.Wait() }, deferredWriteCapacity: 2));
|
||||
|
||||
actor.Tell(Rebuild(RawTagArtifact(5)));
|
||||
var at = DateTime.UtcNow;
|
||||
actor.Tell(new OpcUaPublishActor.AttributeValueBatchUpdate(Enumerable.Range(0, 5)
|
||||
.Select(i => new ValueWrite($"Plant/Modbus/dev1/T{i}", i, OpcUaStatus.Good, at, AddressSpaceRealm.Raw)).ToList()));
|
||||
// An existing key still updates when the buffer is full.
|
||||
actor.Tell(new OpcUaPublishActor.AttributeValueBatchUpdate([new ValueWrite("Plant/Modbus/dev1/T0", 100, OpcUaStatus.Good, at, AddressSpaceRealm.Raw)]));
|
||||
AwaitAssert(() => sink.SkippedWrites.Count.ShouldBe(6), PlanBudget);
|
||||
|
||||
gate.Set();
|
||||
ExpectMsg<OpcUaPublishActor.RebuildCompleted>(PlanBudget);
|
||||
|
||||
sink.ValueOf("Plant/Modbus/dev1/T0")!.Value.Value.ShouldBe(100);
|
||||
sink.ValueOf("Plant/Modbus/dev1/T1")!.Value.Value.ShouldBe(1);
|
||||
for (var i = 2; i < 5; i++)
|
||||
sink.ValueOf($"Plant/Modbus/dev1/T{i}").ShouldBeNull("beyond the bound a new key is not held");
|
||||
sink.ReplayedItems.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Writes_after_the_pipeline_drains_are_not_replayed_again()
|
||||
{
|
||||
var sink = new ExistenceSink();
|
||||
using var gate = new ManualResetEventSlim(false);
|
||||
var actor = Spawn(sink, gate);
|
||||
actor.Tell(Rebuild(RawTagArtifact(3)));
|
||||
actor.Tell(new OpcUaPublishActor.AttributeValueBatchUpdate([new ValueWrite(NewTag, 7, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw)]));
|
||||
gate.Set();
|
||||
ExpectMsg<OpcUaPublishActor.RebuildCompleted>(PlanBudget);
|
||||
var replayed = sink.ReplayedItems;
|
||||
replayed.ShouldBe(1);
|
||||
|
||||
// A later plan replays only what arrived during IT.
|
||||
actor.Tell(Rebuild(RawTagArtifact(3)));
|
||||
ExpectMsg<OpcUaPublishActor.RebuildCompleted>(PlanBudget);
|
||||
sink.ReplayedItems.ShouldBe(replayed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_throwing_authority_parse_does_not_fail_the_apply()
|
||||
{
|
||||
var sink = new ExistenceSink();
|
||||
var authority = new RecordingAuthoritySink();
|
||||
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(
|
||||
sink, new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance),
|
||||
new OpcUaPublishActor.TestHooks { ParseAuthority = (_, _) => throw new InvalidOperationException("bad grants") },
|
||||
dataPlaneAuthority: authority));
|
||||
|
||||
actor.Tell(Rebuild(RawTagArtifact(2)));
|
||||
|
||||
var completed = ExpectMsg<OpcUaPublishActor.RebuildCompleted>(PlanBudget);
|
||||
completed.Degraded.ShouldBeFalse(completed.DegradedReason);
|
||||
sink.Exists("Plant/Modbus/dev1/T1").ShouldBeTrue("the address space is applied");
|
||||
authority.Applies.ShouldBe(0, "nothing is installed from an authority that could not be read");
|
||||
|
||||
// Positive control: the same actor installs an authority that CAN be read.
|
||||
var healthy = Sys.ActorOf(OpcUaPublishActor.PropsForTests(
|
||||
new ExistenceSink(), new AddressSpaceApplier(new ExistenceSink(), NullLogger<AddressSpaceApplier>.Instance),
|
||||
hooks: null, dataPlaneAuthority: authority));
|
||||
healthy.Tell(Rebuild(RawTagArtifact(2)));
|
||||
ExpectMsg<OpcUaPublishActor.RebuildCompleted>(PlanBudget).Degraded.ShouldBeFalse();
|
||||
authority.Applies.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_bytes_less_rebuild_arriving_mid_plan_answers_after_the_plan_ahead_of_it()
|
||||
{
|
||||
var sink = new ExistenceSink();
|
||||
using var gate = new ManualResetEventSlim(false);
|
||||
var actor = Spawn(sink, gate);
|
||||
|
||||
var first = Rebuild(RawTagArtifact(2));
|
||||
var second = new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), DeploymentId.NewId(), Artifact: null, ReportOutcome: true);
|
||||
actor.Tell(first);
|
||||
actor.Tell(second);
|
||||
ExpectNoMsg(TimeSpan.FromMilliseconds(300)); // the bytes-less one must not overtake the held plan
|
||||
|
||||
gate.Set();
|
||||
var a = ExpectMsg<OpcUaPublishActor.RebuildCompleted>(PlanBudget);
|
||||
var b = ExpectMsg<OpcUaPublishActor.RebuildCompleted>(PlanBudget);
|
||||
a.Correlation.ShouldBe(first.Correlation);
|
||||
a.Degraded.ShouldBeFalse();
|
||||
b.Correlation.ShouldBe(second.Correlation);
|
||||
b.Degraded.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_plan_piped_back_to_a_restarted_actor_is_not_applied()
|
||||
{
|
||||
var sink = new ExistenceSink();
|
||||
using var gate = new ManualResetEventSlim(false);
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
var childProps = OpcUaPublishActor.PropsForTests(sink, applier, new OpcUaPublishActor.TestHooks { BeforePlan = () => gate.Wait() });
|
||||
var parent = Sys.ActorOf(Props.Create(() => new RestartingParent(childProps)));
|
||||
parent.Tell(RestartingParent.GetChild.Instance);
|
||||
var child = ExpectMsg<IActorRef>();
|
||||
|
||||
var stale = Rebuild(RawTagArtifact(3));
|
||||
child.Tell(stale);
|
||||
child.Tell(Kill.Instance); // the parent's strategy RESTARTS on any failure, ActorKilledException included
|
||||
ExpectNoMsg(TimeSpan.FromMilliseconds(300));
|
||||
|
||||
gate.Set();
|
||||
var answer = ExpectMsg<OpcUaPublishActor.RebuildCompleted>(PlanBudget);
|
||||
answer.Correlation.ShouldBe(stale.Correlation);
|
||||
answer.Degraded.ShouldBeTrue("the requester is still answered");
|
||||
sink.Exists(NewTag).ShouldBeFalse("a plan from the previous incarnation must not be applied");
|
||||
|
||||
// Positive control: the restarted instance applies a fresh rebuild.
|
||||
child.Tell(Rebuild(RawTagArtifact(3)));
|
||||
ExpectMsg<OpcUaPublishActor.RebuildCompleted>(PlanBudget).Degraded.ShouldBeFalse();
|
||||
sink.Exists(NewTag).ShouldBeTrue();
|
||||
}
|
||||
|
||||
// ───────────────────── fix round 2: N1 / N2 / M1 / M4 / M5 ─────────────────────
|
||||
|
||||
/// <summary>N1 — a full state snapshot carries quality, so it supersedes an EARLIER quality-only write for the
|
||||
/// same condition (the engine sends a transition with the recovered worst quality and no QualityChanged).</summary>
|
||||
[Fact]
|
||||
public void A_state_after_a_quality_only_write_supersedes_it_on_replay()
|
||||
{
|
||||
var sink = new ExistenceSink();
|
||||
using var gate = new ManualResetEventSlim(false);
|
||||
var actor = Spawn(sink, gate);
|
||||
|
||||
actor.Tell(Rebuild(AlarmArtifact()));
|
||||
actor.Tell(new OpcUaPublishActor.AlarmQualityUpdate("al-1", OpcUaStatus.BadNoCommunication, DateTime.UtcNow, AddressSpaceRealm.Uns));
|
||||
actor.Tell(new OpcUaPublishActor.AlarmStateUpdate("al-1", ActiveUnacked(OpcUaStatus.Good), DateTime.UtcNow, AddressSpaceRealm.Uns));
|
||||
AwaitAssert(() => sink.SkippedAlarms.Count.ShouldBe(2), PlanBudget);
|
||||
gate.Set();
|
||||
ExpectMsg<OpcUaPublishActor.RebuildCompleted>(PlanBudget);
|
||||
|
||||
sink.AlarmQualityOf("al-1").ShouldBe(OpcUaStatus.Good, "the later snapshot's quality is what live would leave");
|
||||
sink.AlarmStateOf("al-1")!.Active.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>N1 positive control — the reverse order: a quality-only write AFTER the state still lands last.</summary>
|
||||
[Fact]
|
||||
public void A_quality_only_write_after_a_state_still_lands_after_it_on_replay()
|
||||
{
|
||||
var sink = new ExistenceSink();
|
||||
using var gate = new ManualResetEventSlim(false);
|
||||
var actor = Spawn(sink, gate);
|
||||
|
||||
actor.Tell(Rebuild(AlarmArtifact()));
|
||||
actor.Tell(new OpcUaPublishActor.AlarmStateUpdate("al-1", ActiveUnacked(OpcUaStatus.Good), DateTime.UtcNow, AddressSpaceRealm.Uns));
|
||||
actor.Tell(new OpcUaPublishActor.AlarmQualityUpdate("al-1", OpcUaStatus.BadNoCommunication, DateTime.UtcNow, AddressSpaceRealm.Uns));
|
||||
AwaitAssert(() => sink.SkippedAlarms.Count.ShouldBe(2), PlanBudget);
|
||||
gate.Set();
|
||||
ExpectMsg<OpcUaPublishActor.RebuildCompleted>(PlanBudget);
|
||||
|
||||
sink.AlarmQualityOf("al-1").ShouldBe(OpcUaStatus.BadNoCommunication);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// N2 — a condition the plan KEEPS took the live write already; replaying it would roll back an operator's
|
||||
/// Acknowledge applied in the window (the SDK mutates the node directly) and fire spurious events. The surgical
|
||||
/// apply shape; the rebuild shapes are the two tests below (scripted: replayed; native: carried over).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void An_out_of_band_ack_on_a_kept_condition_is_not_reverted_by_the_replay()
|
||||
{
|
||||
var sink = new ExistenceSink();
|
||||
using var gate = new ManualResetEventSlim(true);
|
||||
var actor = Spawn(sink, gate);
|
||||
actor.Tell(Rebuild(AlarmArtifact()));
|
||||
ExpectMsg<OpcUaPublishActor.RebuildCompleted>(PlanBudget).Degraded.ShouldBeFalse();
|
||||
sink.ConditionExists("al-1").ShouldBeTrue();
|
||||
|
||||
gate.Reset();
|
||||
// A surgical pure-add (new raw tags) that keeps al-1.
|
||||
actor.Tell(Rebuild(AlarmArtifact(rawTags: 2)));
|
||||
actor.Tell(new OpcUaPublishActor.AlarmStateUpdate("al-1", ActiveUnacked(OpcUaStatus.Good), DateTime.UtcNow, AddressSpaceRealm.Uns));
|
||||
AwaitAssert(() => sink.AlarmWritesTo("al-1").ShouldBe(1), PlanBudget); // the live write landed
|
||||
sink.AcknowledgeOutOfBand("al-1"); // an operator acks in the window
|
||||
|
||||
gate.Set();
|
||||
ExpectMsg<OpcUaPublishActor.RebuildCompleted>(PlanBudget).Degraded.ShouldBeFalse();
|
||||
|
||||
sink.SawRebuild.ShouldBeFalse("non-vacuity: a surgical apply");
|
||||
sink.AlarmStateOf("al-1")!.Acknowledged.ShouldBeTrue("the operator's ack must survive the apply");
|
||||
sink.AlarmWritesTo("al-1").ShouldBe(1, "no replayed write (and so no extra Part 9 event) on a kept condition");
|
||||
}
|
||||
|
||||
/// <summary>M1 — a variable the surgical plan keeps took the live write; replaying it would revert a client
|
||||
/// Write applied in the window. Positive control in the same apply: the node the plan ADDS gets its value.</summary>
|
||||
[Fact]
|
||||
public void A_surgical_apply_replays_values_only_onto_the_variables_it_added()
|
||||
{
|
||||
var sink = new ExistenceSink();
|
||||
using var gate = new ManualResetEventSlim(true);
|
||||
var actor = Spawn(sink, gate);
|
||||
actor.Tell(Rebuild(RawTagArtifact(1)));
|
||||
ExpectMsg<OpcUaPublishActor.RebuildCompleted>(PlanBudget);
|
||||
const string kept = "Plant/Modbus/dev1/T0";
|
||||
|
||||
gate.Reset();
|
||||
actor.Tell(Rebuild(RawTagArtifact(3)));
|
||||
var at = DateTime.UtcNow;
|
||||
actor.Tell(new OpcUaPublishActor.AttributeValueBatchUpdate(
|
||||
[
|
||||
new ValueWrite(kept, 5, OpcUaStatus.Good, at, AddressSpaceRealm.Raw),
|
||||
new ValueWrite(NewTag, 7, OpcUaStatus.Good, at, AddressSpaceRealm.Raw),
|
||||
]));
|
||||
AwaitAssert(() => sink.ValueOf(kept)!.Value.Value.ShouldBe(5), PlanBudget);
|
||||
sink.SetValueOutOfBand(kept, 9); // a client Write in the window
|
||||
|
||||
gate.Set();
|
||||
ExpectMsg<OpcUaPublishActor.RebuildCompleted>(PlanBudget);
|
||||
|
||||
sink.SawRebuild.ShouldBeFalse("non-vacuity: a surgical apply");
|
||||
sink.ValueOf(kept)!.Value.Value.ShouldBe(9, "the kept node's client write is not reverted");
|
||||
sink.ValueOf(NewTag)!.Value.Value.ShouldBe(7, "positive control: the added node gets its deferred value");
|
||||
}
|
||||
|
||||
/// <summary>M1 — a FULL rebuild recreates every variable, so every deferred value replays (the live write landed
|
||||
/// on a node the rebuild discarded).</summary>
|
||||
[Fact]
|
||||
public void A_rebuild_replays_every_deferred_value()
|
||||
{
|
||||
var sink = new ExistenceSink();
|
||||
using var gate = new ManualResetEventSlim(true);
|
||||
var actor = Spawn(sink, gate);
|
||||
actor.Tell(Rebuild(AlarmArtifact(rawTags: 1)));
|
||||
ExpectMsg<OpcUaPublishActor.RebuildCompleted>(PlanBudget);
|
||||
const string kept = "Plant/Modbus/dev1/T0";
|
||||
sink.Exists(kept).ShouldBeTrue();
|
||||
|
||||
gate.Reset();
|
||||
// An equipment rename is a ChangedEquipment delta ⇒ full rebuild; the raw tag keeps its path.
|
||||
actor.Tell(Rebuild(AlarmArtifact(equipmentName: "Eq-renamed", rawTags: 1)));
|
||||
var at = DateTime.UtcNow;
|
||||
actor.Tell(new OpcUaPublishActor.AttributeValueBatchUpdate([new ValueWrite(kept, 5, OpcUaStatus.Good, at, AddressSpaceRealm.Raw)]));
|
||||
AwaitAssert(() => sink.ValueOf(kept)!.Value.Value.ShouldBe(5), PlanBudget);
|
||||
|
||||
gate.Set();
|
||||
ExpectMsg<OpcUaPublishActor.RebuildCompleted>(PlanBudget);
|
||||
|
||||
sink.SawRebuild.ShouldBeTrue("non-vacuity: a full rebuild ran");
|
||||
sink.ValueOf(kept)!.Value.Value.ShouldBe(5, "the recreated node gets the value its predecessor took");
|
||||
}
|
||||
|
||||
/// <summary>M4 — a FAILED plan applies nothing, so nothing is replayed, and the buffer is dropped once the
|
||||
/// pipeline is idle: a later plan that adds the node does not receive a write from the failed window.</summary>
|
||||
[Fact]
|
||||
public void A_failed_plan_replays_nothing_and_drops_the_buffer()
|
||||
{
|
||||
var sink = new ExistenceSink();
|
||||
using var gate = new ManualResetEventSlim(true);
|
||||
var actor = Spawn(sink, gate);
|
||||
actor.Tell(Rebuild(RawTagArtifact(2)));
|
||||
ExpectMsg<OpcUaPublishActor.RebuildCompleted>(PlanBudget);
|
||||
|
||||
gate.Reset();
|
||||
actor.Tell(Rebuild("not json"u8.ToArray()));
|
||||
actor.Tell(new OpcUaPublishActor.AttributeValueBatchUpdate(
|
||||
[new ValueWrite("Plant/Modbus/dev1/T2", 42, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw)]));
|
||||
AwaitAssert(() => sink.SkippedWrites.ShouldContain("Plant/Modbus/dev1/T2"), PlanBudget);
|
||||
// Snapshot AFTER the live write (the sink's counter cannot tell a live write after an apply from a replay).
|
||||
var replayedBefore = sink.ReplayedItems;
|
||||
gate.Set();
|
||||
ExpectMsg<OpcUaPublishActor.RebuildCompleted>(PlanBudget).Degraded.ShouldBeTrue();
|
||||
sink.ReplayedItems.ShouldBe(replayedBefore, "nothing was applied, so nothing is replayed");
|
||||
|
||||
actor.Tell(Rebuild(RawTagArtifact(3)));
|
||||
ExpectMsg<OpcUaPublishActor.RebuildCompleted>(PlanBudget).Degraded.ShouldBeFalse();
|
||||
sink.Exists("Plant/Modbus/dev1/T2").ShouldBeTrue("positive control: the later plan adds it");
|
||||
sink.ValueOf("Plant/Modbus/dev1/T2").ShouldBeNull("the failed window's buffer was dropped");
|
||||
}
|
||||
|
||||
/// <summary>M5 — a restart answers every QUEUED requester (degraded) instead of leaving it to the deploy timeout;
|
||||
/// the in-flight one is answered by the incarnation check.</summary>
|
||||
[Fact]
|
||||
public void A_restart_answers_queued_rebuild_requesters()
|
||||
{
|
||||
var sink = new ExistenceSink();
|
||||
using var gate = new ManualResetEventSlim(false);
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
var childProps = OpcUaPublishActor.PropsForTests(sink, applier, new OpcUaPublishActor.TestHooks { BeforePlan = () => gate.Wait() });
|
||||
var parent = Sys.ActorOf(Props.Create(() => new RestartingParent(childProps)));
|
||||
parent.Tell(RestartingParent.GetChild.Instance);
|
||||
var child = ExpectMsg<IActorRef>();
|
||||
|
||||
var inFlight = Rebuild(RawTagArtifact(2));
|
||||
var queued = Rebuild(RawTagArtifact(3));
|
||||
child.Tell(inFlight);
|
||||
child.Tell(queued);
|
||||
child.Tell(Kill.Instance);
|
||||
|
||||
var first = ExpectMsg<OpcUaPublishActor.RebuildCompleted>(PlanBudget);
|
||||
first.Correlation.ShouldBe(queued.Correlation, "the queued requester is answered at the restart");
|
||||
first.Degraded.ShouldBeTrue();
|
||||
|
||||
gate.Set();
|
||||
var second = ExpectMsg<OpcUaPublishActor.RebuildCompleted>(PlanBudget);
|
||||
second.Correlation.ShouldBe(inFlight.Correlation);
|
||||
second.Degraded.ShouldBeTrue();
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// N3 (i) — a full rebuild recreates every KEPT SCRIPTED condition fresh (only native conditions are carried
|
||||
/// over), so the engine's re-assert handled in the window — landing on the discarded instance — must be
|
||||
/// replayed onto the new one, or the active alarm reads Normal until its next transition (#487).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void A_rebuild_replays_the_window_state_onto_every_kept_scripted_condition()
|
||||
{
|
||||
var sink = new ExistenceSink();
|
||||
using var gate = new ManualResetEventSlim(true);
|
||||
var actor = Spawn(sink, gate);
|
||||
actor.Tell(Rebuild(AlarmArtifact()));
|
||||
ExpectMsg<OpcUaPublishActor.RebuildCompleted>(PlanBudget).Degraded.ShouldBeFalse();
|
||||
|
||||
gate.Reset();
|
||||
actor.Tell(Rebuild(AlarmArtifact(equipmentName: "Eq-renamed"))); // ChangedEquipment ⇒ full rebuild, al-1 kept
|
||||
actor.Tell(new OpcUaPublishActor.AlarmStateUpdate("al-1", ActiveUnacked(OpcUaStatus.Good), DateTime.UtcNow, AddressSpaceRealm.Uns));
|
||||
AwaitAssert(() => sink.AlarmWritesTo("al-1").ShouldBe(1), PlanBudget); // landed live on the old instance
|
||||
|
||||
gate.Set();
|
||||
ExpectMsg<OpcUaPublishActor.RebuildCompleted>(PlanBudget).Degraded.ShouldBeFalse();
|
||||
|
||||
sink.SawRebuild.ShouldBeTrue("non-vacuity: a full rebuild ran");
|
||||
sink.AlarmStateOf("al-1").ShouldNotBeNull("the re-assert must reach the recreated condition")
|
||||
.Active.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// N3 (ii) — the native counterpart: a kept NATIVE condition is restored from the node manager's carry-over
|
||||
/// (which already holds the live write and the operator's ack), so it is not replayed — the ack survives and no
|
||||
/// extra write lands.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void A_rebuild_does_not_replay_onto_a_kept_native_condition()
|
||||
{
|
||||
var sink = new ExistenceSink();
|
||||
using var gate = new ManualResetEventSlim(true);
|
||||
var actor = Spawn(sink, gate);
|
||||
actor.Tell(Rebuild(AlarmArtifact(nativeAlarm: true)));
|
||||
ExpectMsg<OpcUaPublishActor.RebuildCompleted>(PlanBudget).Degraded.ShouldBeFalse();
|
||||
sink.ConditionExists(NativeAlarm).ShouldBeTrue();
|
||||
|
||||
gate.Reset();
|
||||
actor.Tell(Rebuild(AlarmArtifact(equipmentName: "Eq-renamed", nativeAlarm: true)));
|
||||
actor.Tell(new OpcUaPublishActor.AlarmStateUpdate(NativeAlarm, ActiveUnacked(OpcUaStatus.Good), DateTime.UtcNow, AddressSpaceRealm.Raw));
|
||||
AwaitAssert(() => sink.AlarmWritesTo(NativeAlarm).ShouldBe(1), PlanBudget);
|
||||
sink.AcknowledgeOutOfBand(NativeAlarm);
|
||||
|
||||
gate.Set();
|
||||
ExpectMsg<OpcUaPublishActor.RebuildCompleted>(PlanBudget).Degraded.ShouldBeFalse();
|
||||
|
||||
sink.SawRebuild.ShouldBeTrue("non-vacuity: a full rebuild ran");
|
||||
sink.AlarmStateOf(NativeAlarm)!.Acknowledged.ShouldBeTrue("the carried-over ack survives");
|
||||
sink.AlarmWritesTo(NativeAlarm).ShouldBe(1, "no replayed write on a carried-over native condition");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// N4 — a surgical in-place DataType edit resets the raw node AND its UNS mirror (the node manager drops an
|
||||
/// old-typed value), and neither is ensured by the apply. The window's value is replayed onto both, fitted to
|
||||
/// the new type, so the two NodeIds of one value agree.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void A_surgical_shape_edit_replays_the_window_value_fitted_onto_the_raw_node_and_its_mirror()
|
||||
{
|
||||
var sink = new ExistenceSink();
|
||||
using var gate = new ManualResetEventSlim(true);
|
||||
var actor = Spawn(sink, gate);
|
||||
actor.Tell(Rebuild(ShapeArtifact("Float")));
|
||||
ExpectMsg<OpcUaPublishActor.RebuildCompleted>(PlanBudget).Degraded.ShouldBeFalse();
|
||||
sink.Exists(RawSpeed).ShouldBeTrue();
|
||||
sink.Exists(UnsSpeed).ShouldBeTrue("non-vacuity: the mirror exists");
|
||||
|
||||
gate.Reset();
|
||||
actor.Tell(Rebuild(ShapeArtifact("Int32")));
|
||||
var at = DateTime.UtcNow;
|
||||
actor.Tell(new OpcUaPublishActor.AttributeValueBatchUpdate(
|
||||
[
|
||||
new ValueWrite(RawSpeed, 5.0f, OpcUaStatus.Good, at, AddressSpaceRealm.Raw),
|
||||
new ValueWrite(UnsSpeed, 5.0f, OpcUaStatus.Good, at, AddressSpaceRealm.Uns),
|
||||
]));
|
||||
AwaitAssert(() => sink.ValueOf(RawSpeed)!.Value.Value.ShouldBe(5.0f), PlanBudget);
|
||||
|
||||
gate.Set();
|
||||
ExpectMsg<OpcUaPublishActor.RebuildCompleted>(PlanBudget).Degraded.ShouldBeFalse();
|
||||
|
||||
sink.SawRebuild.ShouldBeFalse("non-vacuity: a surgical in-place edit");
|
||||
sink.ReshapedNodes.ShouldContain(RawSpeed, "non-vacuity: the raw node was reset");
|
||||
sink.ReshapedNodes.ShouldContain(UnsSpeed, "non-vacuity: the mirror was reset");
|
||||
var raw = sink.ValueOf(RawSpeed).ShouldNotBeNull();
|
||||
raw.Value.ShouldBeOfType<int>().ShouldBe(5);
|
||||
sink.ValueOf(UnsSpeed).ShouldBe(raw, "the raw node and its UNS mirror agree");
|
||||
}
|
||||
|
||||
/// <summary>N4 — a window value that cannot be fitted to the new shape is never written: both NodeIds stay
|
||||
/// value-less (BadWaitingForInitialData) until the driver's next value.</summary>
|
||||
[Fact]
|
||||
public void A_surgical_shape_edit_never_replays_a_value_that_does_not_fit_the_new_shape()
|
||||
{
|
||||
var sink = new ExistenceSink();
|
||||
using var gate = new ManualResetEventSlim(true);
|
||||
var actor = Spawn(sink, gate);
|
||||
actor.Tell(Rebuild(ShapeArtifact("String")));
|
||||
ExpectMsg<OpcUaPublishActor.RebuildCompleted>(PlanBudget);
|
||||
|
||||
gate.Reset();
|
||||
actor.Tell(Rebuild(ShapeArtifact("Int32")));
|
||||
var at = DateTime.UtcNow;
|
||||
actor.Tell(new OpcUaPublishActor.AttributeValueBatchUpdate(
|
||||
[
|
||||
new ValueWrite(RawSpeed, "fast", OpcUaStatus.Good, at, AddressSpaceRealm.Raw),
|
||||
new ValueWrite(UnsSpeed, "fast", OpcUaStatus.Good, at, AddressSpaceRealm.Uns),
|
||||
]));
|
||||
AwaitAssert(() => sink.ValueOf(RawSpeed)!.Value.Value.ShouldBe("fast"), PlanBudget);
|
||||
|
||||
gate.Set();
|
||||
ExpectMsg<OpcUaPublishActor.RebuildCompleted>(PlanBudget);
|
||||
|
||||
sink.ReshapedNodes.ShouldContain(RawSpeed, "non-vacuity: the raw node was reset");
|
||||
sink.ValueOf(RawSpeed).ShouldBeNull("\"fast\" is not an Int32 — the node keeps waiting");
|
||||
sink.ValueOf(UnsSpeed).ShouldBeNull();
|
||||
}
|
||||
|
||||
private const string NativeAlarm = "Plant/Modbus/dev1/Hi";
|
||||
private const string RawSpeed = "Plant/Modbus/dev1/Speed";
|
||||
private const string UnsSpeed = "Area/Line/Eq/Speed";
|
||||
|
||||
/// <summary>One raw tag (Speed) of <paramref name="dataType"/> and one UNS reference to it under Area/Line/Eq.</summary>
|
||||
private static byte[] ShapeArtifact(string dataType) =>
|
||||
JsonSerializer.SerializeToUtf8Bytes(new
|
||||
{
|
||||
UnsAreas = new[] { new { UnsAreaId = "area-1", ClusterId = "c1", Name = "Area" } },
|
||||
UnsLines = new[] { new { UnsLineId = "line-1", UnsAreaId = "area-1", Name = "Line" } },
|
||||
Equipment = new[] { new { EquipmentId = "eq-1", DriverInstanceId = (string?)null, UnsLineId = "line-1", Name = "Eq", MachineCode = "EQ-1" } },
|
||||
RawFolders = new[] { new { RawFolderId = "rf-1", ParentRawFolderId = (string?)null, Name = "Plant", ClusterId = "c1" } },
|
||||
DriverInstances = new[] { new { DriverInstanceId = "drv-1", DriverType = "Modbus", DriverConfig = "{}", Name = "Modbus", RawFolderId = "rf-1", ClusterId = "c1" } },
|
||||
Devices = new[] { new { DeviceId = "dev-1", DriverInstanceId = "drv-1", Name = "dev1", DeviceConfig = "{}" } },
|
||||
TagGroups = Array.Empty<object>(),
|
||||
Tags = new[] { new { TagId = "t-speed", DeviceId = "dev-1", TagGroupId = (string?)null, Name = "Speed", DataType = dataType, AccessLevel = 0, TagConfig = "{}" } },
|
||||
UnsTagReferences = new[] { new { UnsTagReferenceId = "ref-1", EquipmentId = "eq-1", TagId = "t-speed", DisplayNameOverride = (string?)null } },
|
||||
ScriptedAlarms = Array.Empty<object>(),
|
||||
});
|
||||
|
||||
private static AlarmConditionSnapshot ActiveUnacked(uint quality) =>
|
||||
new(Active: true, Acknowledged: false, Confirmed: true, Enabled: true, AlarmShelvingKind.Unshelved, 500, "high", quality);
|
||||
|
||||
// ───────────────────────────── helpers ─────────────────────────────
|
||||
|
||||
private IActorRef Spawn(ExistenceSink sink, ManualResetEventSlim gate) =>
|
||||
Sys.ActorOf(OpcUaPublishActor.PropsForTests(
|
||||
sink, new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance),
|
||||
new OpcUaPublishActor.TestHooks { BeforePlan = () => gate.Wait() }));
|
||||
|
||||
private static OpcUaPublishActor.RebuildAddressSpace Rebuild(byte[] artifact) =>
|
||||
new(CorrelationId.NewId(), DeploymentId.NewId(), artifact, ReportOutcome: true);
|
||||
|
||||
private static byte[] RawTagArtifact(int tagCount) =>
|
||||
JsonSerializer.SerializeToUtf8Bytes(new
|
||||
{
|
||||
UnsAreas = Array.Empty<object>(),
|
||||
UnsLines = Array.Empty<object>(),
|
||||
Equipment = Array.Empty<object>(),
|
||||
RawFolders = new[] { new { RawFolderId = "rf-1", ParentRawFolderId = (string?)null, Name = "Plant", ClusterId = "c1" } },
|
||||
DriverInstances = new[] { new { DriverInstanceId = "drv-1", DriverType = "Modbus", DriverConfig = "{}", Name = "Modbus", RawFolderId = "rf-1", ClusterId = "c1" } },
|
||||
Devices = new[] { new { DeviceId = "dev-1", DriverInstanceId = "drv-1", Name = "dev1", DeviceConfig = "{}" } },
|
||||
TagGroups = Array.Empty<object>(),
|
||||
Tags = Enumerable.Range(0, tagCount).Select(i => new
|
||||
{
|
||||
TagId = $"t-{i}", DeviceId = "dev-1", TagGroupId = (string?)null, Name = $"T{i}",
|
||||
DataType = "Float", AccessLevel = 0, TagConfig = "{}",
|
||||
}).ToArray(),
|
||||
UnsTagReferences = Array.Empty<object>(),
|
||||
ScriptedAlarms = Array.Empty<object>(),
|
||||
});
|
||||
|
||||
private static byte[] AlarmArtifact(string equipmentName = "Eq", int rawTags = 0, bool nativeAlarm = false) =>
|
||||
JsonSerializer.SerializeToUtf8Bytes(new
|
||||
{
|
||||
UnsAreas = new[] { new { UnsAreaId = "area-1", ClusterId = "c1", Name = "Area" } },
|
||||
UnsLines = new[] { new { UnsLineId = "line-1", UnsAreaId = "area-1", Name = "Line" } },
|
||||
Equipment = new[]
|
||||
{
|
||||
new { EquipmentId = "eq-1", DriverInstanceId = (string?)null, UnsLineId = "line-1", Name = equipmentName, MachineCode = "EQ-1" },
|
||||
},
|
||||
RawFolders = rawTags == 0 && !nativeAlarm
|
||||
? Array.Empty<object>()
|
||||
: new object[] { new { RawFolderId = "rf-1", ParentRawFolderId = (string?)null, Name = "Plant", ClusterId = "c1" } },
|
||||
DriverInstances = rawTags == 0 && !nativeAlarm
|
||||
? Array.Empty<object>()
|
||||
: new object[] { new { DriverInstanceId = "drv-1", DriverType = "Modbus", DriverConfig = "{}", Name = "Modbus", RawFolderId = "rf-1", ClusterId = "c1" } },
|
||||
Devices = rawTags == 0 && !nativeAlarm
|
||||
? Array.Empty<object>()
|
||||
: new object[] { new { DeviceId = "dev-1", DriverInstanceId = "drv-1", Name = "dev1", DeviceConfig = "{}" } },
|
||||
TagGroups = Array.Empty<object>(),
|
||||
Tags = Enumerable.Range(0, rawTags).Select(i => (object)new
|
||||
{
|
||||
TagId = $"t-{i}", DeviceId = "dev-1", TagGroupId = (string?)null, Name = $"T{i}",
|
||||
DataType = "Float", AccessLevel = 0, TagConfig = "{}",
|
||||
}).Concat(nativeAlarm
|
||||
? new object[]
|
||||
{
|
||||
new
|
||||
{
|
||||
TagId = "t-hi", DeviceId = "dev-1", TagGroupId = (string?)null, Name = "Hi", DataType = "Boolean",
|
||||
AccessLevel = 0, TagConfig = "{\"alarm\":{\"alarmType\":\"OffNormalAlarm\",\"severity\":700}}",
|
||||
},
|
||||
}
|
||||
: Array.Empty<object>()).ToArray(),
|
||||
ScriptedAlarms = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
ScriptedAlarmId = "al-1", EquipmentId = "eq-1", Name = "High", AlarmType = "AlarmCondition",
|
||||
Severity = 500, MessageTemplate = "high", PredicateScriptId = "s-1", HistorizeToAveva = false,
|
||||
Retain = true, Enabled = true,
|
||||
},
|
||||
},
|
||||
Scripts = new[] { new { ScriptId = "s-1", SourceCode = "return false;" } },
|
||||
});
|
||||
|
||||
/// <summary>Restarts its one child on any failure, and lets the test reach the child.</summary>
|
||||
private sealed class RestartingParent : ReceiveActor
|
||||
{
|
||||
public sealed class GetChild { public static readonly GetChild Instance = new(); }
|
||||
|
||||
public RestartingParent(Props childProps)
|
||||
{
|
||||
var child = Context.ActorOf(childProps, "publish");
|
||||
Receive<GetChild>(_ => Sender.Tell(child));
|
||||
}
|
||||
|
||||
protected override SupervisorStrategy SupervisorStrategy() =>
|
||||
new OneForOneStrategy(_ => Directive.Restart);
|
||||
}
|
||||
|
||||
/// <summary>An authority sink that counts installs.</summary>
|
||||
private sealed class RecordingAuthoritySink : IDataPlaneAuthoritySink
|
||||
{
|
||||
private int _applies;
|
||||
public int Applies => Volatile.Read(ref _applies);
|
||||
|
||||
public DataPlaneAuthoritySnapshot Apply(
|
||||
string? clusterId, string revision, IReadOnlyList<ZB.MOM.WW.OtOpcUa.Configuration.Entities.ApiKey> keys,
|
||||
IReadOnlyList<ZB.MOM.WW.OtOpcUa.Configuration.Entities.NodeAcl> grants,
|
||||
IReadOnlyDictionary<AclScopeRef, IReadOnlyList<NodeAclPath>> scopePaths)
|
||||
{
|
||||
Interlocked.Increment(ref _applies);
|
||||
return new DataPlaneAuthorityState().Apply(clusterId, revision, keys, grants, scopePaths);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Models the node manager's non-creating writes: a write to a node that was never ensured (or was removed)
|
||||
/// is skipped and recorded; a full sink rebuild forgets every node. <see cref="ReplayedItems"/> counts the
|
||||
/// items written after an apply closed and before the next apply starts — the actor's replay, since the
|
||||
/// tests send nothing between releasing a plan and its answer.
|
||||
/// </summary>
|
||||
private sealed class ExistenceSink : IOpcUaAddressSpaceSink, ISurgicalAddressSpaceSink
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
private readonly HashSet<string> _variables = new();
|
||||
private readonly HashSet<string> _conditions = new();
|
||||
private readonly Dictionary<string, (object? Value, uint Status, DateTime At)> _values = new();
|
||||
private readonly Dictionary<string, AlarmConditionSnapshot> _alarmStates = new();
|
||||
private readonly Dictionary<string, uint> _alarmQualities = new();
|
||||
|
||||
public ConcurrentQueue<string> SkippedWrites { get; } = new();
|
||||
private readonly Dictionary<string, int> _alarmWrites = new();
|
||||
private volatile bool _sawRebuild;
|
||||
|
||||
/// <summary>Whether any apply called <see cref="RebuildAddressSpace"/> (the full-rebuild shape).</summary>
|
||||
public bool SawRebuild => _sawRebuild;
|
||||
public int AlarmWritesTo(string id) { lock (_gate) return _alarmWrites.GetValueOrDefault(id); }
|
||||
public void SetValueOutOfBand(string id, object? value)
|
||||
{
|
||||
lock (_gate) { var c = _values.GetValueOrDefault(id); _values[id] = (value, c.Status, c.At); }
|
||||
}
|
||||
|
||||
/// <summary>What an SDK-applied Part 9 Acknowledge does: mutates the condition directly, no sink write.</summary>
|
||||
public void AcknowledgeOutOfBand(string id)
|
||||
{
|
||||
lock (_gate) _alarmStates[id] = _alarmStates[id] with { Acknowledged = true };
|
||||
}
|
||||
public ConcurrentQueue<string> SkippedAlarms { get; } = new();
|
||||
public int ReplayedItems;
|
||||
|
||||
public bool Exists(string id) { lock (_gate) return _variables.Contains(id); }
|
||||
public bool ConditionExists(string id) { lock (_gate) return _conditions.Contains(id); }
|
||||
public (object? Value, uint Status, DateTime At)? ValueOf(string id) { lock (_gate) return _values.TryGetValue(id, out var v) ? v : null; }
|
||||
public AlarmConditionSnapshot? AlarmStateOf(string id) { lock (_gate) return _alarmStates.GetValueOrDefault(id); }
|
||||
public uint? AlarmQualityOf(string id) { lock (_gate) return _alarmQualities.TryGetValue(id, out var q) ? q : null; }
|
||||
|
||||
public void WriteValue(string nodeId, object? value, uint statusCode, DateTime ts, AddressSpaceRealm realm) =>
|
||||
WriteValues([new ValueWrite(nodeId, value, statusCode, ts, realm)]);
|
||||
|
||||
public void WriteValues(IReadOnlyList<ValueWrite> values)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_applyClosed) ReplayedItems += values.Count;
|
||||
foreach (var v in values)
|
||||
{
|
||||
if (!_variables.Contains(v.NodeId)) { SkippedWrites.Enqueue(v.NodeId); continue; }
|
||||
_values[v.NodeId] = (v.Value, v.StatusCode, v.SourceTimestampUtc);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void WriteStatuses(IReadOnlyList<StatusWrite> statuses)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_applyClosed) ReplayedItems += statuses.Count;
|
||||
foreach (var s in statuses)
|
||||
{
|
||||
if (!_variables.Contains(s.NodeId)) { SkippedWrites.Enqueue(s.NodeId); continue; }
|
||||
var current = _values.GetValueOrDefault(s.NodeId);
|
||||
_values[s.NodeId] = (current.Value, s.StatusCode, s.SourceTimestampUtc);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime ts, AddressSpaceRealm realm)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_conditions.Contains(alarmNodeId)) { SkippedAlarms.Enqueue(alarmNodeId); return; }
|
||||
_alarmWrites[alarmNodeId] = _alarmWrites.GetValueOrDefault(alarmNodeId) + 1;
|
||||
// The node manager projects the snapshot's StatusCode onto the condition's Quality.
|
||||
_alarmStates[alarmNodeId] = state;
|
||||
_alarmQualities[alarmNodeId] = state.StatusCode;
|
||||
}
|
||||
}
|
||||
|
||||
public void WriteAlarmQuality(string alarmNodeId, uint statusCode, DateTime ts, AddressSpaceRealm realm)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_conditions.Contains(alarmNodeId)) { SkippedAlarms.Enqueue(alarmNodeId); return; }
|
||||
_alarmQualities[alarmNodeId] = statusCode;
|
||||
}
|
||||
}
|
||||
|
||||
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_applyClosed = false;
|
||||
_conditions.Add(alarmNodeId);
|
||||
if (isNative) _nativeConditions.Add(alarmNodeId); else _nativeConditions.Remove(alarmNodeId);
|
||||
}
|
||||
}
|
||||
|
||||
private readonly HashSet<string> _nativeConditions = new();
|
||||
private readonly Dictionary<string, (string DataType, bool IsArray, uint? ArrayLength)> _shapes = new();
|
||||
public ConcurrentQueue<string> ReshapedNodes { get; } = new();
|
||||
|
||||
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm)
|
||||
{
|
||||
lock (_gate) _applyClosed = false;
|
||||
}
|
||||
|
||||
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_applyClosed = false;
|
||||
if (_variables.Add(variableNodeId)) _shapes[variableNodeId] = (dataType, isArray, arrayLength);
|
||||
}
|
||||
}
|
||||
|
||||
public void RebuildAddressSpace()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
// OtOpcUaNodeManager.RebuildAddressSpace: variables are recreated empty; only NATIVE conditions'
|
||||
// state is captured and carried over to the re-materialised instance — a scripted condition comes
|
||||
// back fresh (its engine re-asserts it).
|
||||
_applyClosed = false;
|
||||
_sawRebuild = true;
|
||||
_variables.Clear();
|
||||
_shapes.Clear();
|
||||
_conditions.Clear();
|
||||
_values.Clear();
|
||||
foreach (var id in _alarmStates.Keys.Where(k => !_nativeConditions.Contains(k)).ToList()) _alarmStates.Remove(id);
|
||||
foreach (var id in _alarmQualities.Keys.Where(k => !_nativeConditions.Contains(k)).ToList()) _alarmQualities.Remove(id);
|
||||
}
|
||||
}
|
||||
|
||||
private bool _applyClosed;
|
||||
|
||||
public void CompleteAddressSpaceApply()
|
||||
{
|
||||
lock (_gate) _applyClosed = true;
|
||||
}
|
||||
|
||||
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm) { }
|
||||
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
|
||||
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { }
|
||||
/// <summary>OtOpcUaNodeManager.UpdateTagAttributes: a SHAPE change (DataType / array-ness / length) drops the
|
||||
/// node's value (BadWaitingForInitialData); any other attribute edit leaves it.</summary>
|
||||
public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength, AddressSpaceRealm realm)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_variables.Contains(variableNodeId)) return false;
|
||||
if (_shapes.GetValueOrDefault(variableNodeId) != (dataType, isArray, arrayLength))
|
||||
{
|
||||
_shapes[variableNodeId] = (dataType, isArray, arrayLength);
|
||||
_values.Remove(variableNodeId);
|
||||
ReshapedNodes.Enqueue(variableNodeId);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
public bool UpdateFolderDisplayName(string folderNodeId, string displayName, AddressSpaceRealm realm) => true;
|
||||
public bool RemoveVariableNode(string variableNodeId, AddressSpaceRealm realm) { lock (_gate) return _variables.Remove(variableNodeId); }
|
||||
public bool RemoveAlarmConditionNode(string alarmNodeId, AddressSpaceRealm realm) { lock (_gate) return _conditions.Remove(alarmNodeId); }
|
||||
public bool RemoveEquipmentSubtree(string equipmentNodeId, AddressSpaceRealm realm) => true;
|
||||
}
|
||||
}
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.Json;
|
||||
using Akka.Actor;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.OpcUa;
|
||||
|
||||
/// <summary>
|
||||
/// Review 02 P5 — a rebuild's artifact parse + plan no longer run on the pinned OPC UA thread. The
|
||||
/// observable: value batches sent right after a large rebuild land on the sink BEFORE that rebuild's first
|
||||
/// materialise call, because the actor is free while the thread pool parses and plans; two rebuilds sent
|
||||
/// back to back still apply in arrival order; a plan that throws still answers the requester degraded.
|
||||
/// </summary>
|
||||
public sealed class OpcUaPublishActorOffThreadPlanTests : RuntimeActorTestBase
|
||||
{
|
||||
[Fact]
|
||||
public void Value_batches_keep_flowing_while_a_large_plan_computes()
|
||||
{
|
||||
var sink = new OrderedSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink, applier: applier));
|
||||
// A value node the batch names; the sink is a recorder, so it need not exist.
|
||||
var batch = new OpcUaPublishActor.AttributeValueBatchUpdate(
|
||||
[new ValueWrite("Plant/Modbus/dev1/live", 1, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw)]);
|
||||
|
||||
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), DeploymentId.NewId(), RawTagArtifact(50_000), ReportOutcome: true));
|
||||
actor.Tell(batch);
|
||||
|
||||
var completed = ExpectMsg<OpcUaPublishActor.RebuildCompleted>(TimeSpan.FromSeconds(120));
|
||||
completed.Degraded.ShouldBeFalse(completed.DegradedReason);
|
||||
|
||||
var calls = sink.Calls.ToList();
|
||||
var batchIndex = calls.IndexOf("WV:Plant/Modbus/dev1/live");
|
||||
var firstMaterialise = calls.FindIndex(c => c.StartsWith("EV:", StringComparison.Ordinal) || c.StartsWith("EF:", StringComparison.Ordinal));
|
||||
batchIndex.ShouldBeGreaterThanOrEqualTo(0, "the batch must reach the sink");
|
||||
firstMaterialise.ShouldBeGreaterThanOrEqualTo(0, "the plan must still be applied (positive control)");
|
||||
batchIndex.ShouldBeLessThan(firstMaterialise, "the batch sent after the rebuild must land before the rebuild's materialisation — the parse/plan step no longer holds the actor thread");
|
||||
calls.Count(c => c.StartsWith("EV:", StringComparison.Ordinal)).ShouldBe(50_000);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Back_to_back_rebuilds_apply_in_arrival_order()
|
||||
{
|
||||
var sink = new OrderedSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink, applier: applier));
|
||||
|
||||
// The first (large) plan is still computing when the second (tiny) arrives; the second must wait for
|
||||
// it — its plan is a diff against what the first applies.
|
||||
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), DeploymentId.NewId(), RawTagArtifact(20_000), ReportOutcome: true));
|
||||
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), DeploymentId.NewId(), RawTagArtifact(2), ReportOutcome: true));
|
||||
|
||||
var first = ExpectMsg<OpcUaPublishActor.RebuildCompleted>(TimeSpan.FromSeconds(120));
|
||||
var second = ExpectMsg<OpcUaPublishActor.RebuildCompleted>(TimeSpan.FromSeconds(120));
|
||||
first.Degraded.ShouldBeFalse(first.DegradedReason);
|
||||
second.Degraded.ShouldBeFalse(second.DegradedReason);
|
||||
|
||||
// The second composition is a strict subset (2 of the same tags), so it diffs as a PureRemove of the
|
||||
// other 19,998 — which only works if it was planned AFTER the first apply, against its composition.
|
||||
sink.Calls.Count(c => c.StartsWith("RV:", StringComparison.Ordinal)).ShouldBe(19_998);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unreadable_bytes_answer_degraded_and_keep_the_served_space()
|
||||
{
|
||||
var sink = new OrderedSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink, applier: applier));
|
||||
|
||||
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), DeploymentId.NewId(), "not json"u8.ToArray(), ReportOutcome: true));
|
||||
|
||||
var completed = ExpectMsg<OpcUaPublishActor.RebuildCompleted>(PresenceBudget);
|
||||
completed.Degraded.ShouldBeTrue();
|
||||
completed.DegradedReason.ShouldNotBeNull().ShouldContain("threw");
|
||||
sink.Calls.ShouldBeEmpty("nothing may be torn down for bytes that could not be read");
|
||||
sink.Completed.ShouldBe(1, "the apply is still closed");
|
||||
}
|
||||
|
||||
private static byte[] RawTagArtifact(int tagCount) =>
|
||||
JsonSerializer.SerializeToUtf8Bytes(new
|
||||
{
|
||||
UnsAreas = Array.Empty<object>(),
|
||||
UnsLines = Array.Empty<object>(),
|
||||
Equipment = Array.Empty<object>(),
|
||||
RawFolders = new[] { new { RawFolderId = "rf-1", ParentRawFolderId = (string?)null, Name = "Plant", ClusterId = "c1" } },
|
||||
DriverInstances = new[] { new { DriverInstanceId = "drv-1", DriverType = "Modbus", DriverConfig = "{}", Name = "Modbus", RawFolderId = "rf-1", ClusterId = "c1" } },
|
||||
Devices = new[] { new { DeviceId = "dev-1", DriverInstanceId = "drv-1", Name = "dev1", DeviceConfig = "{}" } },
|
||||
TagGroups = Array.Empty<object>(),
|
||||
Tags = Enumerable.Range(0, tagCount).Select(i => new
|
||||
{
|
||||
TagId = $"t-{i}", DeviceId = "dev-1", TagGroupId = (string?)null, Name = $"T{i}",
|
||||
DataType = "Float", AccessLevel = 0, TagConfig = "{}",
|
||||
}).ToArray(),
|
||||
UnsTagReferences = Array.Empty<object>(),
|
||||
ScriptedAlarms = Array.Empty<object>(),
|
||||
});
|
||||
|
||||
private sealed class OrderedSink : IOpcUaAddressSpaceSink, ISurgicalAddressSpaceSink
|
||||
{
|
||||
public ConcurrentQueue<string> Calls { get; } = new();
|
||||
public int Completed;
|
||||
|
||||
public void WriteValue(string nodeId, object? value, uint statusCode, DateTime ts, AddressSpaceRealm realm) => Calls.Enqueue($"WV:{nodeId}");
|
||||
public void WriteValues(IReadOnlyList<ValueWrite> values) { foreach (var v in values) Calls.Enqueue($"WV:{v.NodeId}"); }
|
||||
public void WriteAlarmQuality(string alarmNodeId, uint statusCode, DateTime ts, AddressSpaceRealm realm) { }
|
||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime ts, AddressSpaceRealm realm) => Calls.Enqueue($"WA:{alarmNodeId}");
|
||||
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) => Calls.Enqueue($"MA:{alarmNodeId}");
|
||||
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm) => Calls.Enqueue($"EF:{folderNodeId}");
|
||||
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false) => Calls.Enqueue($"EV:{variableNodeId}");
|
||||
public void RebuildAddressSpace() => Calls.Enqueue("RB");
|
||||
public void CompleteAddressSpaceApply() => Interlocked.Increment(ref Completed);
|
||||
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm) => Calls.Enqueue($"NA:{affectedNodeId}");
|
||||
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
|
||||
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { }
|
||||
public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength, AddressSpaceRealm realm) { Calls.Enqueue($"UT:{variableNodeId}"); return true; }
|
||||
public bool UpdateFolderDisplayName(string folderNodeId, string displayName, AddressSpaceRealm realm) => true;
|
||||
public bool RemoveVariableNode(string variableNodeId, AddressSpaceRealm realm) { Calls.Enqueue($"RV:{variableNodeId}"); return true; }
|
||||
public bool RemoveAlarmConditionNode(string alarmNodeId, AddressSpaceRealm realm) { Calls.Enqueue($"RA:{alarmNodeId}"); return true; }
|
||||
public bool RemoveEquipmentSubtree(string equipmentNodeId, AddressSpaceRealm realm) { Calls.Enqueue($"RE:{equipmentNodeId}"); return true; }
|
||||
}
|
||||
}
|
||||
+54
-91
@@ -41,14 +41,14 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
|
||||
var sink = new RecordingSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
|
||||
SeedDeployment(db, equipmentIds: new[] { "eq-1", "eq-2" }, driverIds: new[] { "drv-1" });
|
||||
var dep = SeedDeployment(db, equipmentIds: new[] { "eq-1", "eq-2" }, driverIds: new[] { "drv-1" });
|
||||
|
||||
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(
|
||||
sink: sink,
|
||||
dbFactory: db,
|
||||
applier: applier));
|
||||
|
||||
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId()));
|
||||
actor.Tell(Rebuild(dep));
|
||||
|
||||
AwaitAssert(() =>
|
||||
{
|
||||
@@ -72,10 +72,10 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
|
||||
var db = NewInMemoryDbFactory();
|
||||
var sink = new RecordingSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
SeedDeployment(db, equipmentIds: new[] { "eq-1", "eq-2" }, driverIds: new[] { "drv-1" });
|
||||
var dep = SeedDeployment(db, equipmentIds: new[] { "eq-1", "eq-2" }, driverIds: new[] { "drv-1" });
|
||||
|
||||
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink, dbFactory: db, applier: applier));
|
||||
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId()));
|
||||
actor.Tell(Rebuild(dep));
|
||||
|
||||
AwaitAssert(() => sink.CompletedAfterCallCount.Count.ShouldBe(1), duration: PresenceBudget);
|
||||
sink.CompletedAfterCallCount.Single().ShouldBe(sink.Calls.Count, "nothing may follow the completion");
|
||||
@@ -126,17 +126,17 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
|
||||
var db = NewInMemoryDbFactory();
|
||||
var sink = new RecordingSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
SeedDeployment(db, equipmentIds: new[] { "eq-1" }, driverIds: Array.Empty<string>());
|
||||
var dep = SeedDeployment(db, equipmentIds: new[] { "eq-1" }, driverIds: Array.Empty<string>());
|
||||
|
||||
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(
|
||||
sink: sink, dbFactory: db, applier: applier));
|
||||
|
||||
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId()));
|
||||
actor.Tell(Rebuild(dep));
|
||||
AwaitAssert(() => sink.Calls.ShouldContain("EF:eq-1"), duration: PresenceBudget);
|
||||
sink.RebuildCalls.ShouldBe(0); // PureAdd — no full rebuild
|
||||
var callsAfterFirst = sink.Calls.Count;
|
||||
|
||||
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId()));
|
||||
actor.Tell(Rebuild(dep));
|
||||
Thread.Sleep(200);
|
||||
// Same composition ⇒ plan IsEmpty ⇒ applier + materialise passes not run again.
|
||||
sink.Calls.Count.ShouldBe(callsAfterFirst);
|
||||
@@ -232,7 +232,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
|
||||
/// and drives the applier. The guard split must leave the Direct path byte-identical.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Rebuild_direct_mode_with_dbFactory_and_deploymentId_still_loads_and_applies()
|
||||
public void Rebuild_with_deploymentId_and_in_hand_bytes_applies()
|
||||
{
|
||||
var db = NewInMemoryDbFactory();
|
||||
var sink = new RecordingSink();
|
||||
@@ -242,7 +242,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
|
||||
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(
|
||||
sink: sink, dbFactory: db, applier: applier));
|
||||
|
||||
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep)));
|
||||
actor.Tell(Rebuild(dep));
|
||||
|
||||
AwaitAssert(() => sink.Calls.ShouldContain("EF:eq-1"), duration: PresenceBudget);
|
||||
sink.RebuildCalls.ShouldBe(0);
|
||||
@@ -267,7 +267,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
|
||||
var dbA = NewInMemoryDbFactory();
|
||||
var sinkA = new RecordingSink();
|
||||
var applierA = new AddressSpaceApplier(sinkA, NullLogger<AddressSpaceApplier>.Instance);
|
||||
SeedMultiClusterDeployment(dbA);
|
||||
var depA = SeedMultiClusterDeployment(dbA);
|
||||
|
||||
var siteActor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(
|
||||
sink: sinkA,
|
||||
@@ -275,7 +275,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
|
||||
applier: applierA,
|
||||
localNode: NodeId.Parse("site-a-1:4053")));
|
||||
|
||||
siteActor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId()));
|
||||
siteActor.Tell(Rebuild(depA));
|
||||
|
||||
// PureAdd ⇒ no full rebuild; the materialise passes still run the cluster slice.
|
||||
AwaitAssert(() => sinkA.Calls.ShouldContain("EV:SA/Modbus/dev-sa/S1"), duration: PresenceBudget);
|
||||
@@ -287,7 +287,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
|
||||
var dbM = NewInMemoryDbFactory();
|
||||
var sinkM = new RecordingSink();
|
||||
var applierM = new AddressSpaceApplier(sinkM, NullLogger<AddressSpaceApplier>.Instance);
|
||||
SeedMultiClusterDeployment(dbM);
|
||||
var depM = SeedMultiClusterDeployment(dbM);
|
||||
|
||||
var mainActor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(
|
||||
sink: sinkM,
|
||||
@@ -295,7 +295,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
|
||||
applier: applierM,
|
||||
localNode: NodeId.Parse("central-1:4053")));
|
||||
|
||||
mainActor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId()));
|
||||
mainActor.Tell(Rebuild(depM));
|
||||
|
||||
AwaitAssert(() => sinkM.Calls.ShouldContain("EV:Main/Modbus/dev-main/M1"), duration: PresenceBudget);
|
||||
sinkM.RebuildCalls.ShouldBe(0);
|
||||
@@ -308,7 +308,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
|
||||
/// tag chain plus its UNS area/line/equipment. Used by
|
||||
/// <see cref="Rebuild_materialises_only_the_nodes_cluster"/>.
|
||||
/// </summary>
|
||||
private static void SeedMultiClusterDeployment(IDbContextFactory<OtOpcUaConfigDbContext> dbFactory)
|
||||
private static Guid SeedMultiClusterDeployment(IDbContextFactory<OtOpcUaConfigDbContext> dbFactory)
|
||||
{
|
||||
var artifact = JsonSerializer.SerializeToUtf8Bytes(new
|
||||
{
|
||||
@@ -358,17 +358,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
|
||||
ScriptedAlarms = Array.Empty<object>(),
|
||||
});
|
||||
|
||||
using var ctx = dbFactory.CreateDbContext();
|
||||
ctx.Deployments.Add(new Deployment
|
||||
{
|
||||
DeploymentId = Guid.NewGuid(),
|
||||
RevisionHash = new string('b', 64),
|
||||
Status = DeploymentStatus.Sealed,
|
||||
CreatedBy = "test",
|
||||
SealedAtUtc = DateTime.UtcNow,
|
||||
ArtifactBlob = artifact,
|
||||
});
|
||||
ctx.SaveChanges();
|
||||
return SealArtifact(dbFactory, artifact);
|
||||
}
|
||||
|
||||
/// <summary>R2-07 T4b — a PureAdd deploy (here a single new equipment) does NOT rebuild, and the
|
||||
@@ -381,10 +371,10 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
|
||||
var db = NewInMemoryDbFactory();
|
||||
var sink = new RecordingSink();
|
||||
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
|
||||
SeedDeployment(db, equipmentIds: new[] { "eq-1" }, driverIds: Array.Empty<string>());
|
||||
var dep = SeedDeployment(db, equipmentIds: new[] { "eq-1" }, driverIds: Array.Empty<string>());
|
||||
|
||||
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink, dbFactory: db, applier: applier));
|
||||
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId()));
|
||||
actor.Tell(Rebuild(dep));
|
||||
|
||||
AwaitAssert(() => sink.Calls.ShouldContain("NA:line-1"), duration: PresenceBudget);
|
||||
sink.RebuildCalls.ShouldBe(0);
|
||||
@@ -410,7 +400,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
|
||||
var dep1 = SeedNamedEquipmentDeployment(db, ("eq-1", "Pump-1"));
|
||||
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink, dbFactory: db, applier: applier));
|
||||
|
||||
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep1)));
|
||||
actor.Tell(Rebuild(dep1));
|
||||
AwaitAssert(() => sink.Calls.Count(c => c.StartsWith("NA:")).ShouldBe(1), duration: PresenceBudget);
|
||||
sink.RebuildCalls.ShouldBe(0);
|
||||
var naAfterFirst = sink.Calls.Count(c => c.StartsWith("NA:"));
|
||||
@@ -418,7 +408,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
|
||||
// Second deploy: eq-1 RENAMED (ChangedEquipment ⇒ Rebuild) AND a new eq-2 (an add). The rebuild
|
||||
// fires, so the announce hook is skipped despite eq-2 being added.
|
||||
var dep2 = SeedNamedEquipmentDeployment(db, ("eq-1", "Pump-1-RENAMED"), ("eq-2", "Pump-2"));
|
||||
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep2)));
|
||||
actor.Tell(Rebuild(dep2));
|
||||
|
||||
AwaitAssert(() => sink.RebuildCalls.ShouldBe(1), duration: PresenceBudget);
|
||||
// No new NodeAdded announcement was raised for the rebuild-kind deploy.
|
||||
@@ -442,7 +432,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
|
||||
var dep1 = SeedNamedEquipmentDeployment(db, ("eq-1", "Pump-1"), ("eq-2", "Pump-2"));
|
||||
|
||||
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink, dbFactory: db, applier: applier));
|
||||
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep1)));
|
||||
actor.Tell(Rebuild(dep1));
|
||||
AwaitAssert(() => sink.Calls.ShouldContain("EF:eq-2"), duration: PresenceBudget);
|
||||
var callsAfterFirst = sink.Calls.Count;
|
||||
|
||||
@@ -458,7 +448,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
|
||||
// …and the actor still remembers WHAT is applied: once the database is back, re-applying the same
|
||||
// composition diffs to an empty plan. A clobbered _lastApplied would re-materialise everything.
|
||||
db.Fail = false;
|
||||
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep1)));
|
||||
actor.Tell(Rebuild(dep1));
|
||||
Thread.Sleep(200);
|
||||
sink.Calls.Count.ShouldBe(callsAfterFirst);
|
||||
}
|
||||
@@ -477,12 +467,12 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
|
||||
var dep1 = SeedNamedEquipmentDeployment(db, ("eq-1", "Pump-1"), ("eq-2", "Pump-2"));
|
||||
|
||||
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink, dbFactory: db, applier: applier));
|
||||
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep1)));
|
||||
actor.Tell(Rebuild(dep1));
|
||||
AwaitAssert(() => sink.Calls.ShouldContain("EF:eq-2"), duration: PresenceBudget);
|
||||
|
||||
// eq-2 really is gone from the next artifact — that IS a configuration change, so it must apply.
|
||||
var dep2 = SeedNamedEquipmentDeployment(db, ("eq-1", "Pump-1"));
|
||||
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep2)));
|
||||
actor.Tell(Rebuild(dep2));
|
||||
|
||||
AwaitAssert(() => sink.Calls.ShouldContain("RE:eq-2"), duration: PresenceBudget);
|
||||
}
|
||||
@@ -534,24 +524,10 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
|
||||
IDbContextFactory<OtOpcUaConfigDbContext> dbFactory,
|
||||
params (string Id, string Name)[] equipment)
|
||||
{
|
||||
var artifact = BuildNamedEquipmentArtifact(equipment);
|
||||
|
||||
var id = Guid.NewGuid();
|
||||
using var ctx = dbFactory.CreateDbContext();
|
||||
ctx.Deployments.Add(new Deployment
|
||||
{
|
||||
DeploymentId = id,
|
||||
RevisionHash = new string('d', 64),
|
||||
Status = DeploymentStatus.Sealed,
|
||||
CreatedBy = "test",
|
||||
SealedAtUtc = DateTime.UtcNow,
|
||||
ArtifactBlob = artifact,
|
||||
});
|
||||
ctx.SaveChanges();
|
||||
return id;
|
||||
return SealArtifact(dbFactory, BuildNamedEquipmentArtifact(equipment));
|
||||
}
|
||||
|
||||
private static void SeedDeployment(
|
||||
private static Guid SeedDeployment(
|
||||
IDbContextFactory<OtOpcUaConfigDbContext> dbFactory,
|
||||
string[] equipmentIds,
|
||||
string[] driverIds)
|
||||
@@ -575,17 +551,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
|
||||
ScriptedAlarms = Array.Empty<object>(),
|
||||
});
|
||||
|
||||
using var ctx = dbFactory.CreateDbContext();
|
||||
ctx.Deployments.Add(new Deployment
|
||||
{
|
||||
DeploymentId = Guid.NewGuid(),
|
||||
RevisionHash = new string('a', 64),
|
||||
Status = DeploymentStatus.Sealed,
|
||||
CreatedBy = "test",
|
||||
SealedAtUtc = DateTime.UtcNow,
|
||||
ArtifactBlob = artifact,
|
||||
});
|
||||
ctx.SaveChanges();
|
||||
return SealArtifact(dbFactory, artifact);
|
||||
}
|
||||
|
||||
/// <summary>OpcUaServer-001 — a deploy whose ONLY change is a UNS Area rename (the area Name differs;
|
||||
@@ -606,14 +572,14 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
|
||||
|
||||
// First deploy: the area folder is materialised with the OLD name. R2-07 — this is now a PureAdd
|
||||
// (area + line + equipment all added), so NO full rebuild; the folder is materialised directly.
|
||||
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep1)));
|
||||
actor.Tell(Rebuild(dep1));
|
||||
AwaitAssert(() => sink.Calls.ShouldContain("EF:area-1"), duration: PresenceBudget);
|
||||
sink.RebuildCalls.ShouldBe(0);
|
||||
|
||||
// Second deploy: ONLY the area Name changed — a rename. The actor must reach the apply path and
|
||||
// drive a surgical in-place folder rename (NOT a rebuild, NOT a no-op).
|
||||
var dep2 = SeedAreaDeployment(db, areaName: "Plant South");
|
||||
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep2)));
|
||||
actor.Tell(Rebuild(dep2));
|
||||
|
||||
AwaitAssert(() =>
|
||||
{
|
||||
@@ -639,19 +605,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
|
||||
ScriptedAlarms = Array.Empty<object>(),
|
||||
});
|
||||
|
||||
var id = Guid.NewGuid();
|
||||
using var ctx = dbFactory.CreateDbContext();
|
||||
ctx.Deployments.Add(new Deployment
|
||||
{
|
||||
DeploymentId = id,
|
||||
RevisionHash = new string('c', 64),
|
||||
Status = DeploymentStatus.Sealed,
|
||||
CreatedBy = "test",
|
||||
SealedAtUtc = DateTime.UtcNow,
|
||||
ArtifactBlob = artifact,
|
||||
});
|
||||
ctx.SaveChanges();
|
||||
return id;
|
||||
return SealArtifact(dbFactory, artifact);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------
|
||||
@@ -677,13 +631,13 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
|
||||
var dep1 = SeedRawTagDeployment(db, new[] { "A", "B", "C" });
|
||||
|
||||
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink, dbFactory: db, applier: applier));
|
||||
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep1)));
|
||||
actor.Tell(Rebuild(dep1));
|
||||
AwaitAssert(() => sink.Calls.ShouldContain("EV:Plant/Modbus/dev1/C"), duration: PresenceBudget);
|
||||
var afterFirst = sink.Calls.Count;
|
||||
|
||||
// One new tag on an otherwise identical configuration.
|
||||
var dep2 = SeedRawTagDeployment(db, new[] { "A", "B", "C", "D" });
|
||||
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep2)));
|
||||
actor.Tell(Rebuild(dep2));
|
||||
AwaitAssert(() => sink.Calls.ShouldContain("EV:Plant/Modbus/dev1/D"), duration: PresenceBudget);
|
||||
Thread.Sleep(200); // let any (unwanted) extra pass finish before counting
|
||||
|
||||
@@ -707,13 +661,13 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
|
||||
var dep1 = SeedUnsRawDeployment(db, equipmentName: "station1", referencedTag: "a");
|
||||
|
||||
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink, dbFactory: db, applier: applier));
|
||||
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep1)));
|
||||
actor.Tell(Rebuild(dep1));
|
||||
AwaitAssert(() => sink.Calls.ShouldContain("EV:Plant/Modbus/dev1/b"), duration: PresenceBudget);
|
||||
var afterFirst = sink.Calls.Count;
|
||||
|
||||
// Renaming the equipment is a ChangedEquipment delta ⇒ the classifier's default-closed Rebuild.
|
||||
var dep2 = SeedUnsRawDeployment(db, equipmentName: "station1-renamed", referencedTag: "a");
|
||||
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep2)));
|
||||
actor.Tell(Rebuild(dep2));
|
||||
AwaitAssert(() => sink.RebuildCalls.ShouldBe(1), duration: PresenceBudget);
|
||||
AwaitAssert(
|
||||
() => sink.Calls.Skip(afterFirst).ShouldContain("EV:Plant/Modbus/dev1/b"),
|
||||
@@ -746,13 +700,13 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
|
||||
var dep1 = SeedUnsRawDeployment(db, equipmentName: "station1", referencedTag: "a");
|
||||
|
||||
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink, dbFactory: db, applier: applier));
|
||||
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep1)));
|
||||
actor.Tell(Rebuild(dep1));
|
||||
AwaitAssert(() => sink.Calls.ShouldContain("EV:filling/line1/station1/a"), duration: PresenceBudget);
|
||||
var afterFirst = sink.Calls.Count;
|
||||
|
||||
// Re-point the SAME reference row at raw tag "b".
|
||||
var dep2 = SeedUnsRawDeployment(db, equipmentName: "station1", referencedTag: "b");
|
||||
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep2)));
|
||||
actor.Tell(Rebuild(dep2));
|
||||
AwaitAssert(() => sink.Calls.ShouldContain("EV:filling/line1/station1/b"), duration: PresenceBudget);
|
||||
|
||||
var second = sink.Calls.Skip(afterFirst).ToList();
|
||||
@@ -784,13 +738,13 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
|
||||
var dep1 = SeedUnsRawDeployment(db, equipmentName: "station1", referencedTag: "a");
|
||||
|
||||
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink, dbFactory: db, applier: applier));
|
||||
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep1)));
|
||||
actor.Tell(Rebuild(dep1));
|
||||
AwaitAssert(() => sink.Calls.ShouldContain("EV:filling/line1/station1/a"), duration: PresenceBudget);
|
||||
var afterFirst = sink.Calls.Count;
|
||||
|
||||
var dep2 = SeedUnsRawDeployment(db, equipmentName: "station1", referencedTag: "a",
|
||||
historianTagnameForA: "WW.A");
|
||||
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep2)));
|
||||
actor.Tell(Rebuild(dep2));
|
||||
|
||||
// The mirror now carries the backing tag's historian tagname — the repair itself, unchanged by I2.
|
||||
AwaitAssert(
|
||||
@@ -818,12 +772,12 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
|
||||
var dep1 = SeedRawTagDeployment(db, new[] { "A", "B" });
|
||||
|
||||
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink, dbFactory: db, applier: applier));
|
||||
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep1)));
|
||||
actor.Tell(Rebuild(dep1));
|
||||
AwaitAssert(() => sink.Calls.ShouldContain("EV:Plant/Modbus/dev1/B"), duration: PresenceBudget);
|
||||
var afterFirst = sink.Calls.Count;
|
||||
|
||||
var dep2 = SeedRawTagDeployment(db, new[] { "A", "B" }, historizedTag: "A");
|
||||
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep2)));
|
||||
actor.Tell(Rebuild(dep2));
|
||||
AwaitAssert(() => sink.Calls.ShouldContain("UT:Plant/Modbus/dev1/A"), duration: PresenceBudget);
|
||||
Thread.Sleep(200); // let any (unwanted) extra pass finish before counting
|
||||
|
||||
@@ -855,7 +809,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
|
||||
var dep1 = SeedRawTagDeployment(db, new[] { "A", "B" });
|
||||
|
||||
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink, dbFactory: db, applier: applier));
|
||||
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep1)));
|
||||
actor.Tell(Rebuild(dep1));
|
||||
AwaitAssert(() => sink.Calls.ShouldContain("EV:Plant/Modbus/dev1/B"), duration: PresenceBudget);
|
||||
Thread.Sleep(200);
|
||||
sink.RebuildCalls.ShouldBe(0); // genuinely a non-rebuild (PureAdd) apply
|
||||
@@ -867,7 +821,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
|
||||
// EMPTY plan and A would be gone from the served address space forever.
|
||||
sink.FailEnsureVariableFor = null;
|
||||
var dep2 = SeedRawTagDeployment(db, new[] { "A", "B" });
|
||||
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep2)));
|
||||
actor.Tell(Rebuild(dep2));
|
||||
|
||||
AwaitAssert(
|
||||
() => sink.Calls.Skip(afterFirst).ShouldContain("EV:Plant/Modbus/dev1/A"),
|
||||
@@ -894,14 +848,14 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
|
||||
var dep1 = SeedUnsRawDeployment(db, equipmentName: "station1", referencedTag: "a");
|
||||
|
||||
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink, dbFactory: db, applier: applier));
|
||||
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep1)));
|
||||
actor.Tell(Rebuild(dep1));
|
||||
AwaitAssert(() => sink.Calls.ShouldContain("EV:Plant/Modbus/dev1/b"), duration: PresenceBudget);
|
||||
|
||||
// Renaming the equipment is ChangedEquipment ⇒ the classifier's default-closed Rebuild; the sink
|
||||
// faults on one variable of the full re-materialise that follows.
|
||||
sink.FailEnsureVariableFor = "Plant/Modbus/dev1/b";
|
||||
var dep2 = SeedUnsRawDeployment(db, equipmentName: "station1-renamed", referencedTag: "a");
|
||||
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep2)));
|
||||
actor.Tell(Rebuild(dep2));
|
||||
AwaitAssert(() => sink.RebuildCalls.ShouldBe(1), duration: PresenceBudget);
|
||||
Thread.Sleep(200);
|
||||
var afterSecond = sink.Calls.Count;
|
||||
@@ -911,7 +865,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
|
||||
// rebuild again) — but the degraded rebuild is remembered, so the failed variable is re-ensured.
|
||||
sink.FailEnsureVariableFor = null;
|
||||
var dep3 = SeedUnsRawDeployment(db, equipmentName: "station1-renamed", referencedTag: "a");
|
||||
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep3)));
|
||||
actor.Tell(Rebuild(dep3));
|
||||
AwaitAssert(
|
||||
() => sink.Calls.Skip(afterSecond).ShouldContain("EV:Plant/Modbus/dev1/b"),
|
||||
duration: PresenceBudget);
|
||||
@@ -921,7 +875,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
|
||||
Thread.Sleep(200);
|
||||
var afterRepair = sink.Calls.Count;
|
||||
var dep4 = SeedUnsRawDeployment(db, equipmentName: "station1-renamed", referencedTag: "a");
|
||||
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep4)));
|
||||
actor.Tell(Rebuild(dep4));
|
||||
Thread.Sleep(300);
|
||||
sink.Calls.Count.ShouldBe(afterRepair);
|
||||
sink.RebuildCalls.ShouldBe(1);
|
||||
@@ -1017,10 +971,19 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
|
||||
return SealArtifact(dbFactory, artifact);
|
||||
}
|
||||
|
||||
/// <summary>The artifact bytes behind each sealed deployment id, so a test can hand the actor the bytes its
|
||||
/// production producers always carry (review 02 P5 removed the actor's ConfigDb fallback).</summary>
|
||||
private static readonly System.Collections.Concurrent.ConcurrentDictionary<Guid, byte[]> Artifacts = new();
|
||||
|
||||
/// <summary>The rebuild message a producer sends for deployment <paramref name="deploymentId"/>: its id AND its bytes.</summary>
|
||||
private static OpcUaPublishActor.RebuildAddressSpace Rebuild(Guid deploymentId) =>
|
||||
new(CorrelationId.NewId(), new DeploymentId(deploymentId), Artifacts[deploymentId]);
|
||||
|
||||
/// <summary>Seal <paramref name="artifact"/> as a new deployment and return its id.</summary>
|
||||
private static Guid SealArtifact(IDbContextFactory<OtOpcUaConfigDbContext> dbFactory, byte[] artifact)
|
||||
{
|
||||
var id = Guid.NewGuid();
|
||||
Artifacts[id] = artifact;
|
||||
using var ctx = dbFactory.CreateDbContext();
|
||||
ctx.Deployments.Add(new Deployment
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user