diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs index bd7e8ba0..961319f5 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs @@ -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()) - { - // 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 /// EquipmentId is itself in 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 WriteValue then ; - /// a removed alarm-bearing tag (previously it got no terminal condition write) and a - /// removed scripted alarm get a terminal write then + /// a removed scripted alarm gets a terminal write then /// ; each removed equipment gets one /// . 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(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; } - /// - /// Materialise Equipment-namespace tags from a composition snapshot. - /// For each , - /// ensure its optional FolderPath sub-folder under the existing equipment folder, then - /// ensure a Variable (NodeId = FullName, 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 EquipmentNodeWalker: no - /// sink-backed IAddressSpaceBuilder 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); owns the - /// equipment folders and this pass never re-creates them. The sink's EnsureVariable - /// takes a plain string dataType (not a DriverAttributeInfo). - /// Subset overload (2026-08-15 surgical-deploys). narrows WHAT is - /// materialised to a plan's added tags; stays the FULL composition for - /// context. A null subset means "the whole composition" (the rebuild path). - /// - /// The FULL composition — the context even when a subset is passed. - /// Materialise only these tags; null ⇒ the whole composition's. - /// 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). - public int MaterialiseEquipmentTags( - AddressSpaceComposition composition, - IReadOnlyList? 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(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 ("/"), 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; - } - - /// /// Materialise Equipment-namespace VirtualTags from a composition snapshot — the VirtualTag - /// analogue of . For each , + /// computed-signal pass. For each , /// ensure its optional FolderPath sub-folder under the existing equipment folder (in /// practice FolderPath 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 /// true when the variable was ensured; false when the sink threw. 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; } } + /// 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. + 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); + } + + /// The recording in progress, or null. See . + private MaterialisationRecording? _recording; + + /// + /// Starts recording what the following apply and materialise passes do to VARIABLES, until + /// : 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. + /// + /// The live recording. + public MaterialisationRecording BeginRecording() + { + _recording = new MaterialisationRecording(); + return _recording; + } + + /// Stops the recording started (the returned recording is kept). + 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); + +/// What one apply did to variables (see ). +public sealed class MaterialisationRecording +{ + /// Every variable an EnsureVariable was issued for, as (realm, NodeId). + public HashSet<(AddressSpaceRealm, string)> Ensured { get; } = new(); + + /// Every variable an in-place attribute update RE-SHAPED (its value reset), with its new shape. + public Dictionary<(AddressSpaceRealm, string), VariableShape> Reshaped { get; } = new(); +} + +/// A variable's value shape: its Tag.DataType name, and its array-ness / 1-D length. +/// The Tag.DataType name ("Int32", "Float", …; unknown ⇒ BaseDataType, any value fits). +/// Whether the node is a one-dimensional array. +/// The fixed array length, when known (null / 0 ⇒ any length). +public sealed record VariableShape(string DataType, bool IsArray, uint? ArrayLength) +{ + /// + /// Fits 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). + /// + /// The value to fit. + /// The fitted value, when this returns true. + /// True when the value fits (as is, or converted). + 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; + } + } + + /// The CLR type the node manager serves for a Tag.DataType name (its ResolveBuiltInDataType set); + /// null ⇒ BaseDataType, where any value fits. + 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, + }; +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceChangeClassifier.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceChangeClassifier.cs index a64f3055..8d3b97d0 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceChangeClassifier.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceChangeClassifier.cs @@ -8,7 +8,7 @@ public enum AddressSpaceChangeKind /// — nothing to do. Empty, - /// Only surgical-eligible changed equipment/raw tags, surgical UNS-reference deltas, + /// 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. AttributeOnly, @@ -27,7 +27,7 @@ public enum AddressSpaceChangeKind AddRemoveMix, /// 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. 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). - /// Whether a changed equipment tag can be updated in place on its existing node instead of rebuilding. - /// The equipment-tag delta to classify. - /// when the tag has no alarm and differs only in the in-place-updatable attributes; otherwise . - 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 diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpacePlan.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpacePlan.cs index 8b6cdd26..466a4a37 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpacePlan.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpacePlan.cs @@ -23,28 +23,15 @@ public sealed record AddressSpacePlan( IReadOnlyList RemovedAlarms, IReadOnlyList ChangedAlarms) { - /// - /// Equipment-namespace tag diff sets, keyed by . Added as - /// init-only members (defaulting empty) rather than positional parameters so existing - /// AddressSpacePlan construction sites compile unchanged — consistent with how - /// was added. Without these, an - /// incremental deploy that changes ONLY equipment tags produced an empty plan and - /// OpcUaPublishActor.HandleRebuild short-circuited before materialising them. - /// - public IReadOnlyList AddedEquipmentTags { get; init; } = Array.Empty(); - /// - public IReadOnlyList RemovedEquipmentTags { get; init; } = Array.Empty(); - /// - public IReadOnlyList ChangedEquipmentTags { get; init; } = Array.Empty(); - /// /// Equipment-namespace VirtualTag diff sets, keyed by . - /// The value-side analogue of : a VirtualTag carries an + /// A VirtualTag carries an /// Expression evaluated over DependencyRefs, 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 . + /// 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 s.) /// public IReadOnlyList AddedEquipmentVirtualTags { get; init; } = Array.Empty(); /// @@ -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 AddressSpacePlan construction site compiles unchanged — consistent with - /// the EquipmentTag / EquipmentVirtualTag diff sets above. + /// the EquipmentVirtualTag diff sets above. /// public IReadOnlyList RenamedFolders { get; init; } = Array.Empty(); @@ -74,7 +61,7 @@ public sealed record AddressSpacePlan( /// keyed by (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. /// public IReadOnlyList AddedRawContainers { get; init; } = Array.Empty(); /// @@ -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); /// A Raw container node present in both snapshots (same RawPath) with ≥1 differing field. @@ -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, diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs index b1e265fd..4b12e0d1 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs @@ -105,6 +105,55 @@ public sealed class OpcUaApplicationHostOptions /// is always element 0. /// public IList PeerApplicationUris { get; set; } = new List(); + + /// + /// SDK request-processing and transport quotas (OpcUa:Quotas:*, 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. + /// + public OpcUaQuotaOptions Quotas { get; set; } = new(); +} + +/// +/// The OpcUa:Quotas section: the SDK's request-thread / queue ceilings and its transport quotas. +/// Request quotas carry the former hard-coded defaults; a transport quota left null keeps the SDK +/// default ('s constructor values). +/// +public sealed class OpcUaQuotaOptions +{ + /// Minimum SDK request-processing threads (default 5). + public int MinRequestThreadCount { get; set; } = 5; + + /// Maximum SDK request-processing threads (default 100). + public int MaxRequestThreadCount { get; set; } = 100; + + /// Requests the SDK queues before refusing with BadTooManyOperations (default 200) — the + /// burst ceiling review 01 P6 flagged. + public int MaxQueuedRequestCount { get; set; } = 200; + + /// Per-operation timeout in milliseconds; null keeps the SDK default. + public int? OperationTimeoutMs { get; set; } + + /// Largest string the transport decodes; null keeps the SDK default. + public int? MaxStringLength { get; set; } + + /// Largest byte string the transport decodes; null keeps the SDK default. + public int? MaxByteStringLength { get; set; } + + /// Largest array the transport decodes; null keeps the SDK default. + public int? MaxArrayLength { get; set; } + + /// Largest message the transport accepts, in bytes; null keeps the SDK default. + public int? MaxMessageSize { get; set; } + + /// Transport buffer size in bytes; null keeps the SDK default. + public int? MaxBufferSize { get; set; } + + /// Secure-channel lifetime in milliseconds; null keeps the SDK default. + public int? ChannelLifetimeMs { get; set; } + + /// Security-token lifetime in milliseconds; null keeps the SDK default. + public int? SecurityTokenLifetimeMs { get; set; } } /// @@ -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; } + /// + /// Applies the request-processing quotas (OpcUa:Quotas:*, review 01 P6 — they were hard-coded at + /// 5 / 100 / 200) to the SDK server configuration. + /// + /// The configuration to set. + /// The configured quotas. + internal static void ApplyRequestQuotas(ServerConfiguration serverConfig, OpcUaQuotaOptions quotas) + { + serverConfig.MinRequestThreadCount = quotas.MinRequestThreadCount; + serverConfig.MaxRequestThreadCount = quotas.MaxRequestThreadCount; + serverConfig.MaxQueuedRequestCount = quotas.MaxQueuedRequestCount; + } + + /// + /// Builds the SDK transport quotas from OpcUa:Quotas:*: 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 + /// new TransportQuotas(). + /// + /// The configured quotas. + /// The transport quotas to install. + 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; + } + /// /// Maps each configured to a SDK /// . Duplicate profiles are silently de-duped because diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs index f8355b90..6b6f5970 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs @@ -18,18 +18,19 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer; /// /// Custom OPC UA that owns the writable address space for -/// the OtOpcUa server. Variable nodes are created lazily on first -/// under the manager's namespace; subsequent writes update the existing node's Value + -/// StatusCode + SourceTimestamp and notify subscribed clients via the standard -/// ClearChangeMasks path. +/// the OtOpcUa server. Variable nodes are declared by (the applier's +/// materialise passes); a updates an existing node's Value + StatusCode + +/// SourceTimestamp and notifies subscribed clients via the standard ClearChangeMasks path. +/// No write ever creates a node (review 01 L1): a publish naming an id a surgical remove has just +/// dropped is skipped, so it can neither resurrect the node as a flat phantom under the shared root nor +/// hand a later re-add of the same id the phantom's shape. /// /// This is the F10b production wiring behind the v2 /// seam — once a is bound, OpcUaPublishActor's writes /// materialise as real OPC UA Variable updates that clients can browse + subscribe to. /// /// Node-id encoding uses the manager's default namespace + the caller-supplied string id -/// as the identifier portion (e.g. "ns=2;s=eq-1/temp"). Beyond lazily-created flat -/// variables, the manager also materialises the full UNS Area/Line/Equipment folder hierarchy +/// as the identifier portion (e.g. "ns=2;s=eq-1/temp"). The manager materialises the full UNS Area/Line/Equipment folder hierarchy /// (), typed equipment-tag variables with the correct built-in /// DataType / array shape / access levels (), historized /// nodes (the HistoryRead access bit + HistoryRead overrides), and real Part 9 @@ -110,6 +111,13 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// against it (a miss ⇒ BadHistoryOperationUnsupported). Cleared on /// . private readonly ConcurrentDictionary _eventNotifierSources = new(); + /// Review 01 P2 — parent key → the keys of the folders / variables / conditions materialised + /// under it (by the parent id the CALLER named, so a node whose parent was unknown at creation and hangs + /// under the shared root is still found under its intended parent). + /// walks this instead of prefix-scanning every map per removed equipment. Guarded by Lock. + private readonly Dictionary> _childIndex = new(); + /// The reverse of : child key → parent key. Guarded by Lock. + private readonly Dictionary _parentIndex = new(); private FolderState? _root; private readonly ILogger _logger; @@ -289,6 +297,10 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 private volatile TimeProvider _clock = TimeProvider.System; + /// The manager's "now" (UTC) — read off so a test clock drives every timestamp this + /// manager stamps (condition / shelve / revert / revalidation times), not only the HistoryUpdate bound. + private DateTime UtcNow => _clock.GetUtcNow().UtcDateTime; + private volatile IOpcUaNodeWriteGateway _nodeWriteGateway = NullOpcUaNodeWriteGateway.Instance; /// @@ -356,9 +368,73 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 { _historianDataSource = value ?? NullHistorianDataSource.Instance; SyncAdvertisedAggregates(); + RefreshEventHistoryCapability(); } } + /// + /// Review 01 U3 — re-evaluates the bit (and the event-history + /// source registration resolves against) on every folder already promoted + /// to an event notifier, so a assigned AFTER the first apply advertises + /// event history without a full rebuild — and a source reset to the Null default takes the bit back + /// instead of leaving folders answering BadHistoryOperationUnsupported. Folders promoted later go + /// through , which reads the same source. + /// + private void RefreshEventHistoryCapability() + { + var wired = _historianDataSource is not NullHistorianDataSource; + lock (Lock) + { + foreach (var (nodeId, folder) in _notifierFolders) + { + var has = (folder.EventNotifier & EventNotifiers.HistoryRead) != 0; + if (has == wired) continue; + var key = MapKey(nodeId); + if (wired) + { + folder.EventNotifier = (byte)(folder.EventNotifier | EventNotifiers.HistoryRead); + _eventNotifierSources[key] = nodeId.Identifier?.ToString() ?? string.Empty; + } + else + { + folder.EventNotifier = (byte)(folder.EventNotifier & ~EventNotifiers.HistoryRead); + _eventNotifierSources.TryRemove(key, out _); + } + + folder.ClearChangeMasks(SystemContext, includeChildren: false); + } + } + } + + /// + /// Review 01 U3 — re-evaluates the bit on every historized + /// variable, so a assigned (or cleared) after nodes exist changes what + /// the SDK base admits to HistoryUpdate without a full rebuild. Writable is read back off the node's + /// own CurrentWrite bit — the authored value was given. + /// + private void RefreshHistoryWriteCapability() + { + var historyWritable = _historianDataUpdater is not null; + lock (Lock) + { + foreach (var (key, variable) in _variables) + { + if (!_historizedTagnames.ContainsKey(key)) continue; + var writable = (variable.AccessLevel & AccessLevels.CurrentWrite) != 0; + var access = ComposeAccessLevel(writable, historized: true, historyWritable); + if (variable.AccessLevel == access && variable.UserAccessLevel == access) continue; + variable.AccessLevel = access; + variable.UserAccessLevel = access; + variable.ClearChangeMasks(SystemContext, includeChildren: false); + } + } + } + + /// Test observability: whether is registered as an event-history + /// source (what accepts). + internal bool IsEventHistorySource(string folderNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => + _eventNotifierSources.ContainsKey(MapKey(realm, folderNodeId)); + /// /// Trim HistoryServerCapabilities/AggregateFunctions (i=11201) to the current source's /// (null ⇒ all, ). @@ -382,6 +458,15 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 advertised.Count, source.GetType().Name, string.Join(", ", advertised.Select(id => AggregateFunctionMap.Map(id)?.ToString() ?? id.ToString()))); } + + // #736: HistoryServerCapabilities/MaxReturnDataValues (i=11273) carries the source's raw-page cap + // (0 = unlimited), re-synced on every source assignment like the aggregate set above. + if (!HistoryPaging.AdvertiseMaxReturnDataValues(Server, source.MaxRawValuesPerRead)) + { + _logger.LogWarning( + "OtOpcUaNodeManager: could not advertise HistoryServerCapabilities/MaxReturnDataValues ({Cap}) for historian {Source}; the cap is still enforced by HistoryRead", + source.MaxRawValuesPerRead, source.GetType().Name); + } } catch (Exception ex) { @@ -396,20 +481,23 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// delete of historized samples). null (the default) means the selected historian backend /// cannot modify history: every HistoryUpdate is reported BadHistoryOperationUnsupported. /// - /// ⚠️ Assign it before any node materialises. Whether an updater is present decides the - /// HistoryWrite AccessLevel bit a historized node is created with - /// (), and the SDK base admits a node to HistoryUpdate only when - /// that bit is set — so the bit is dispatch-load-bearing, not cosmetic. Setting the updater after - /// nodes exist does NOT retro-update their AccessLevel. The Host assigns it in - /// OtOpcUaServerHostedService.StartAsync before installing the address-space sink, which - /// precedes every deploy apply. Volatile for the same start-thread / request-thread handoff as - /// . + /// Whether an updater is present decides the HistoryWrite AccessLevel bit a historized node + /// carries (), and the SDK base admits a node to HistoryUpdate only + /// when that bit is set — so the bit is dispatch-load-bearing, not cosmetic. Assigning the updater + /// after nodes exist re-evaluates the bit on every historized node (review 01 U3 — + /// ), so the Host's ordering (assigned in + /// OtOpcUaServerHostedService.StartAsync before the sink is installed) is no longer load-bearing. + /// Volatile for the same start-thread / request-thread handoff as . /// /// public IHistorianDataUpdater? HistorianDataUpdater { get => _historianDataUpdater; - set => _historianDataUpdater = value; + set + { + _historianDataUpdater = value; + RefreshHistoryWriteCapability(); + } } /// @@ -569,9 +657,23 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// Process-wide cap on concurrently-served HistoryRead batches (arch-review 03/S3). A batch that /// cannot acquire a slot within its (bounded) wait budget fast-fails every handle with /// BadTooManyOperations. Mirrors ServerHistorianOptions.MaxConcurrentHistoryReadBatches; - /// the Host sets it at StartAsync. The default (16) survives when the section is absent. + /// the Host sets it at StartAsync. The default () + /// survives when the section is absent. + /// + /// Review 01 P4: every HistoryRead / HistoryUpdate arm is sync-over-async on an SDK request thread — + /// the SDK line pins this manager to the synchronous CustomNodeManager2.HistoryRead dispatcher + /// (its IHistoryReadAsyncNodeManager is a separate interface, adapted for sync managers, that + /// offers no async virtual to override; adopting it means re-implementing the base's handle validation + /// and continuation-point plumbing). So the admission cap is the lever: with a slow historian a + /// batch holds its request thread for up to , and 16 of them on a + /// small node is a third of MaxRequestThreadCount. The default therefore scales with the + /// machine — clamp(2 × ProcessorCount, 4, 16) — instead of a flat 16. + /// /// - public int MaxConcurrentHistoryReadBatches { get; set; } = 16; + public int MaxConcurrentHistoryReadBatches { get; set; } = DefaultMaxConcurrentHistoryReadBatches; + + /// The CPU-scaled default of : clamp(2 × ProcessorCount, 4, 16). + public static int DefaultMaxConcurrentHistoryReadBatches { get; } = Math.Clamp(Environment.ProcessorCount * 2, 4, 16); /// /// Per-request wall-clock deadline for a HistoryRead batch (arch-review 03/S3). A node still in @@ -705,9 +807,11 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 } /// - /// Apply a value write from . Creates the - /// variable node on first call; subsequent calls update Value + StatusCode + - /// SourceTimestamp and call ClearChangeMasks so subscribed clients see the change. + /// Apply a value write from : update the materialised + /// variable's Value + StatusCode + SourceTimestamp and call ClearChangeMasks so subscribed clients + /// see the change. Never creates a node — an id nothing declared via + /// (a publish racing a surgical remove) is skipped and Debug-logged, exactly as + /// always did (review 01 L1). /// /// The node identifier of the variable. /// The new value to write. @@ -720,10 +824,17 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 ArgumentException.ThrowIfNullOrEmpty(nodeId); EnsureAddressSpaceCreated(); - var ns = NamespaceIndexForRealm(realm); var key = MapKey(realm, nodeId); + bool applied; lock (Lock) - WriteValueLocked(nodeId, ns, key, value, statusCode, sourceTimestampUtc); + applied = WriteValueLocked(key, value, statusCode, sourceTimestampUtc); + + if (!applied) + { + _logger.LogDebug( + "OtOpcUaNodeManager: WriteValue skipped {NodeId} (realm {Realm}) — node not materialised.", + nodeId, realm); + } } /// The most items applies under a SINGLE Lock acquisition before @@ -741,7 +852,9 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// Batched : one Lock acquisition per chunk of /// items instead of one per value — the publish path's per-value /// lock take was the steady-state throughput ceiling. Per-item semantics are identical to - /// (create-on-first-write verbatim status codes, ClearChangeMasks per node). + /// (never creates; verbatim status codes; ClearChangeMasks per node) — an item naming a + /// node that is not materialised is skipped and counted, not failed (the caller's per-driver ref index can + /// legitimately name a node a surgical remove has just dropped). /// Items are isolated. A bad item (empty NodeId, or a throw out of the SDK) does not abandon /// the rest of the batch: it is recorded and the loop continues, and the aggregate surfaces afterwards as /// a . Dropping the tail would be silent and durable — the poll @@ -785,6 +898,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 } var succeeded = 0; + var skipped = 0; for (var start = 0; start < values.Count; start += MaxWritesPerLockAcquisition) { var end = Math.Min(start + MaxWritesPerLockAcquisition, values.Count); @@ -796,9 +910,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 var v = values[i]; try { - WriteValueLocked(resolved[i].NodeId, resolved[i].NamespaceIndex, resolved[i].Key, - v.Value, v.StatusCode, v.SourceTimestampUtc); - succeeded++; + if (WriteValueLocked(resolved[i].Key, v.Value, v.StatusCode, v.SourceTimestampUtc)) succeeded++; + else skipped++; } catch (Exception ex) { @@ -810,6 +923,16 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 } } + if (skipped > 0) + { + // Debug, not Warning: the caller's index racing a surgical remove is expected, and a driver + // whose nodes were all just removed would otherwise log once per ref. + _logger.LogDebug( + "OtOpcUaNodeManager: WriteValues skipped {Skipped} of {Total} item(s) — node not materialised.", + skipped, + values.Count); + } + if (failed > 0) { throw new ValueWriteBatchException( @@ -925,24 +1048,21 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// False when the item failed the pre-pass and must be skipped. private readonly record struct ResolvedWrite(string NodeId, ushort NamespaceIndex, RealmKey Key, bool IsValid); - /// The body of a single value write. Caller MUST hold Lock — this is factored out - /// solely so can apply a whole batch under one acquisition; it is byte-identical - /// to what has always done inside its own lock. - /// The bare s= identifier of the variable. - /// The namespace index resolved from the write's realm. - /// The realm-qualified map key for . + /// The body of a single value write. Caller MUST hold Lock — factored out so + /// can apply a whole batch under one acquisition. + /// Non-creating (review 01 L1). The former create-on-first-write branch minted a flat, read-only, + /// BaseDataType variable under the shared root whenever a publish raced a surgical remove; it stayed + /// until the next full rebuild, and because early-returns on an existing id, a + /// later re-add of the same tag silently kept the phantom's parent, DataType and writability. + /// The realm-qualified map key of the variable. /// The new value to write. /// The full OPC UA status code, sub-code included. /// The timestamp of the value in UTC. - private void WriteValueLocked(string nodeId, ushort ns, RealmKey key, object? value, uint statusCode, DateTime sourceTimestampUtc) + /// when the node existed and was updated; when no + /// such node is materialised (skipped — deliberately not a create). + private bool WriteValueLocked(RealmKey key, object? value, uint statusCode, DateTime sourceTimestampUtc) { - // CreateVariable mutates the SDK address space (_root.AddChild + AddPredefinedNode), - // so it MUST run under Lock — the SDK's subscription/ConditionRefresh threads take it too. - if (!_variables.TryGetValue(key, out var variable)) - { - variable = CreateVariable(nodeId, ns); - _variables[key] = variable; - } + if (!_variables.TryGetValue(key, out var variable)) return false; variable.Value = value; // The caller's status code reaches the SDK verbatim, sub-code included. This used to @@ -952,6 +1072,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 variable.Timestamp = sourceTimestampUtc; // Stays INSIDE the lock, per node: this is how the SDK notifies monitored items of the change. variable.ClearChangeMasks(SystemContext, includeChildren: false); + return true; } /// @@ -959,10 +1080,11 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// been materialised for (via ), /// this projects the whole snapshot /// (Enabled / Active / Acked / Confirmed / Shelving / Severity / Message) onto the live condition - /// node and recomputes Retain (T15 — richer state; still no event firing, that lands in T16). - /// Otherwise it falls back to the legacy two-element [Active, Acknowledged] - /// placeholder so callers whose alarm node hasn't been - /// materialised (and the existing unit tests) keep working. + /// node, recomputes Retain and fires one condition event when the state actually moved. + /// An id with no materialised condition is a logged no-op (review 01 L4) — it used to mint a legacy + /// bool[2] placeholder variable whose AddPredefinedNode (an AddOrUpdate) REPLACED the SDK + /// lookup entry of any folder sharing the id, which the applier's per-removed-equipment write did on + /// every removal. /// /// The node identifier of the alarm (== ScriptedAlarmId for materialised conditions). /// The full condition state to project onto the node. @@ -974,13 +1096,21 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 ArgumentNullException.ThrowIfNull(state); EnsureAddressSpaceCreated(); - var ns = NamespaceIndexForRealm(realm); var key = MapKey(realm, alarmNodeId); // Look up + project under a SINGLE Lock so a concurrent RebuildAddressSpace can't clear // _alarmConditions / detach the condition node between the lookup and the Set* calls. lock (Lock) { - if (_alarmConditions.TryGetValue(key, out var condition)) + if (!_alarmConditions.TryGetValue(key, out var condition)) + { + // Unknown / unmaterialised condition ⇒ safe no-op (a mid-rebuild race, or a caller naming a + // node that is not a condition), mirroring WriteAlarmQuality. + _logger.LogDebug( + "OtOpcUaNodeManager: WriteAlarmCondition skipped {NodeId} (realm {Realm}) — condition not materialised.", + alarmNodeId, realm); + return; + } + { // T20 delta-gate: read the node's CURRENT live condition state FIRST (before projecting // the incoming snapshot onto it), then decide fire-vs-suppress by comparing the incoming @@ -1018,7 +1148,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 SystemContext, shelved: state.Shelving != AlarmShelvingKind.Unshelved, oneShot: state.Shelving == AlarmShelvingKind.OneShot, - shelvingTime: state.Shelving == AlarmShelvingKind.Timed ? RemainingShelveMs(state.UnshelveAtUtc) : 0); + shelvingTime: state.Shelving == AlarmShelvingKind.Timed ? RemainingShelveMs(state.UnshelveAtUtc, UtcNow) : 0); } condition.SetSeverity(SystemContext, ToPart9Severity(state.Severity)); condition.Message.Value = new LocalizedText(state.Message); @@ -1053,22 +1183,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 { ReportConditionEvent(condition, sourceTimestampUtc); } - return; } - - // Fallback: alarm not materialised as a real condition — keep the legacy bool[2] variable so - // un-materialised callers (and the existing unit tests) keep working. CreateVariable mutates - // the SDK address space, so it MUST run under Lock (see WriteValue). - if (!_variables.TryGetValue(key, out var variable)) - { - variable = CreateVariable(alarmNodeId, ns); - _variables[key] = variable; - } - - variable.Value = new[] { state.Active, state.Acknowledged }; - variable.StatusCode = StatusCodes.Good; - variable.Timestamp = sourceTimestampUtc; - variable.ClearChangeMasks(SystemContext, includeChildren: false); } } @@ -1080,11 +1195,12 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// at most int.MaxValue (the SDK timer's range). /// /// The engine's expiry instant, or null. + /// The manager's clock reading (), so a test clock drives it. /// The shelving time in milliseconds. - internal static double RemainingShelveMs(DateTime? unshelveAtUtc) + internal static double RemainingShelveMs(DateTime? unshelveAtUtc, DateTime nowUtc) { if (unshelveAtUtc is not { } at) return 0; - var remaining = (at - DateTime.UtcNow).TotalMilliseconds; + var remaining = (at - nowUtc).TotalMilliseconds; return Math.Clamp(Math.Ceiling(remaining), 1, int.MaxValue); } @@ -1364,6 +1480,9 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // stays ONLY for the kind-swap (native↔scripted) case, which flips the native flag. if (_alarmConditions.ContainsKey(conditionKey) && _nativeAlarmNodeIds.Contains(conditionKey) == isNative) return; + // Review 01 H2 — a variable or folder already holding this id in this realm wins; never shadow it. + if (!_alarmConditions.ContainsKey(conditionKey) && IsHeldByAnotherKind(conditionKey, "alarm condition")) + return; // Idempotent: drop any prior node for this id so a re-materialise (e.g. changed // type/severity on redeploy) reflects cleanly instead of leaking the old node. @@ -1375,12 +1494,14 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // same-key kind-swap ever carries wired notifiers), but this is the one asymmetric teardown // path; keep it symmetric with RemoveAlarmConditionNode / RebuildAddressSpace. UnwireAlarmNotifiers(conditionKey); + // A removal path like any other (review 01 L2): the dropped instance's items are told and its + // fan-out forgotten, or a new item on the swapped-in condition would bind to the dead one. + TombstoneMonitoredSubtree(existing, UtcNow); existing.Parent?.RemoveChild(existing); ForgetPredefinedSubtree(existing); // The materialise tail raises only the new instance's unit (no full reconcile), so give the // dropped instance's back here rather than leave it tracked until the next full reconcile. - if (_allEventsMonitoredConditions.Remove(existing)) - existing.SetAreEventsMonitored(SystemContext, false, includeChildren: true); + LowerAllEventsMonitoring(existing); } // H6a: re-materialising the same id as the OTHER kind (native↔scripted) must reflect the new @@ -1449,7 +1570,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 if (alarm.Quality is not null) { alarm.Quality.Value = isNative ? StatusCodes.BadWaitingForInitialData : StatusCodes.Good; - if (alarm.Quality.SourceTimestamp is not null) alarm.Quality.SourceTimestamp.Value = DateTime.UtcNow; + if (alarm.Quality.SourceTimestamp is not null) alarm.Quality.SourceTimestamp.Value = UtcNow; } // Initial state via the SDK setters (T14: basic state only, NO event firing). @@ -1511,7 +1632,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 var (operation, unshelveAt) = !shelving ? ("Unshelve", (DateTime?)null) : oneShot ? ("OneShotShelve", null) - : ("TimedShelve", DateTime.UtcNow + TimeSpan.FromMilliseconds(shelvingTime)); + : ("TimedShelve", UtcNow + TimeSpan.FromMilliseconds(shelvingTime)); return HandleAlarmCommand(context, condition, operation, comment: null, unshelveAt); }; // The SDK's auto-unshelve timer (armed by SetShelvingState for the real remaining time since the @@ -1558,6 +1679,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 AddPredefinedNode(SystemContext, alarm); _alarmConditions[conditionKey] = alarm; + if (!string.IsNullOrEmpty(equipmentNodeId)) IndexChild(MapKey(realm, equipmentNodeId), conditionKey); // Only the new condition can be missing its unit here, so raise just that one — a full reconcile // walks every condition, which made a deploy of N alarms O(N²) under Lock whenever any Server-object // item was live (always, in production). Removal, rebuild and (un)subscribe keep the full reconcile. @@ -1607,9 +1729,9 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// /// The live native condition, about to be discarded. /// Its carried state. - private static NativeConditionCarryOver CaptureCarryOver(AlarmConditionState condition) => new( + private NativeConditionCarryOver CaptureCarryOver(AlarmConditionState condition) => new( ReadConditionDelta(condition), - condition.Time?.Value ?? DateTime.UtcNow, + condition.Time?.Value ?? UtcNow, condition.ActiveState?.TransitionTime?.Value, condition.Quality?.SourceTimestamp?.Value, condition.Comment?.Value, @@ -1669,11 +1791,12 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 } /// One materialised native-alarm condition together with the equipment folders wired as extra - /// event-notifier roots for it. The list is mutated in place under Lock as - /// notifiers are wired / torn down; is the concrete - /// instance the notifiers were wired against (a rebuild recreates the instance, so the entry is reset when - /// the instance changes). - private sealed record AlarmNotifierWiring(AlarmConditionState Alarm, List Folders); + /// event-notifier roots for it. The set (reference identity — review 01 P5: the + /// membership probe per wired folder was a linear ReferenceEquals walk) is mutated in place under + /// Lock as notifiers are wired / torn down; is the concrete + /// instance the notifiers were wired against (a rebuild recreates the + /// instance, so the entry is reset when the instance changes). + private sealed record AlarmNotifierWiring(AlarmConditionState Alarm, HashSet Folders); // This is a RECONCILE, not additive-only: it wires the supplied set AND unwires any // previously-tracked folder whose id is no longer in notifierFolderNodeIds @@ -1714,7 +1837,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // the entry (the old folder list referenced the discarded instance) when the instance differs. if (!_alarmNotifierWiring.TryGetValue(alarmKey, out var wiring) || !ReferenceEquals(wiring.Alarm, alarm)) { - wiring = new AlarmNotifierWiring(alarm, new List()); + wiring = new AlarmNotifierWiring( + alarm, new HashSet(System.Collections.Generic.ReferenceEqualityComparer.Instance)); _alarmNotifierWiring[alarmKey] = wiring; } @@ -1751,13 +1875,13 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // alarm.AddNotifier(isInverse:true, folder) — the upward bubble to the equipment folder // folder.AddNotifier(isInverse:false, alarm) — the downward AreEventsMonitored link // EnsureFolderIsEventNotifier(folder) — SubscribeToEvents + AddRootNotifier (idempotent) - // AddNotifier dedups by ReferenceEquals(Node), so re-wiring the same pair (idempotent re-apply) - // updates rather than duplicates. + // A pair this wiring already holds is skipped outright (review 01 P5): the SDK's AddNotifier + // dedups by a linear ReferenceEquals walk of the condition's notifier list, so re-wiring K + // folders on an idempotent re-apply cost O(K²) for nothing. + if (!wiring.Folders.Add(folder)) continue; alarm.AddNotifier(SystemContext, null, isInverse: true, folder); folder.AddNotifier(SystemContext, null, isInverse: false, alarm); EnsureFolderIsEventNotifier(folder); - - if (!wiring.Folders.Any(f => ReferenceEquals(f, folder))) wiring.Folders.Add(folder); } } } @@ -1923,7 +2047,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 } if (alwaysReport || ReadConditionDelta(alarm) != before) - ReportConditionEvent(alarm, DateTime.UtcNow); + ReportConditionEvent(alarm, UtcNow); return result; } @@ -2193,20 +2317,60 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // presents ("apikey"), so an audit trail built on it cannot tell two consuming systems apart. var clientUserId = identity is { Principal.Length: > 0 } ? identity.Principal : identity?.DisplayName; - // Fire-and-forget — MUST NOT block under Lock. On a FAILED outcome, compare-and-revert (off-Lock - // continuation). A faulted/cancelled WriteAsync is treated as a failure so the optimistic value never - // sticks when the route never resolved a real outcome. RunContinuationsAsynchronously guarantees the - // revert never runs inline on the SDK write thread (the gateway can return a synchronously-completed - // task — e.g. its boot-window "no DriverHostActor yet" branch), so RevertOptimisticWriteIfNeeded never - // re-enters lock (Lock) while CustomNodeManager2.Write still holds it. - _ = gateway.WriteAsync(nodeKey, optimisticValue, nodeRealm, CancellationToken.None) - .ContinueWith( - t => + return DispatchDeviceWrite(gateway, nodeKey, nodeBareId, nodeRealm, optimisticValue, priorValue, priorStatus, clientUserId); + } + + /// + /// The dispatch half of : start the device write and arm the off-Lock + /// revert. Fire-and-forget — MUST NOT block under Lock. On a FAILED outcome, compare-and-revert + /// (off-Lock continuation). A faulted/cancelled WriteAsync is treated as a failure so the optimistic + /// value never sticks when the route never resolved a real outcome; its exception is OBSERVED and logged + /// here (review 01 L3 — it used to surface only as an UnobservedTaskException at finalization, with + /// the constant reason "write dispatch faulted" in the audit event). A gateway that throws SYNCHRONOUSLY + /// (review 01 H5) is refused inline with BadDeviceFailure before the SDK applies anything, rather + /// than escaping the handler under Lock. RunContinuationsAsynchronously guarantees the revert + /// never runs inline on the SDK write thread (the gateway can return a synchronously-completed task — e.g. + /// its boot-window "no DriverHostActor yet" branch), so never + /// re-enters Lock while CustomNodeManager2.Write still holds it. + /// + /// once the write is in flight (the SDK then applies the value + /// optimistically); a Bad result when the dispatch itself threw. + internal ServiceResult DispatchDeviceWrite( + IOpcUaNodeWriteGateway gateway, string nodeKey, string nodeBareId, AddressSpaceRealm nodeRealm, + object? optimisticValue, object? priorValue, StatusCode priorStatus, string? clientUserId) + { + Task dispatch; + try + { + dispatch = gateway.WriteAsync(nodeKey, optimisticValue, nodeRealm, CancellationToken.None); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "OtOpcUaNodeManager: write dispatch for {NodeId} threw synchronously; the write is refused", nodeKey); + return new ServiceResult(StatusCodes.BadDeviceFailure, $"write dispatch threw: {ex.Message}"); + } + + _ = dispatch.ContinueWith( + t => + { + NodeWriteOutcome outcome; + if (t.IsCompletedSuccessfully) { - var outcome = t.IsCompletedSuccessfully ? t.Result : new NodeWriteOutcome(false, "write dispatch faulted"); - RevertOptimisticWriteIfNeeded(nodeBareId, outcome, optimisticValue, priorValue, priorStatus, clientUserId, nodeRealm); - }, - CancellationToken.None, TaskContinuationOptions.RunContinuationsAsynchronously, TaskScheduler.Default); + outcome = t.Result; + } + else + { + // Reading t.Exception observes it, so the failure never reaches UnobservedTaskException. + var fault = t.Exception?.GetBaseException(); + var reason = fault is not null ? $"write dispatch faulted: {fault.Message}" + : t.IsCanceled ? "write dispatch cancelled" : "write dispatch faulted"; + _logger.LogWarning(fault, "OtOpcUaNodeManager: write dispatch for {NodeId} did not complete — {Reason}", nodeKey, reason); + outcome = new NodeWriteOutcome(false, reason); + } + + RevertOptimisticWriteIfNeeded(nodeBareId, outcome, optimisticValue, priorValue, priorStatus, clientUserId, nodeRealm); + }, + CancellationToken.None, TaskContinuationOptions.RunContinuationsAsynchronously, TaskScheduler.Default); return ServiceResult.Good; } @@ -2617,13 +2781,14 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // Item B: surface a transient Bad-quality blip on the still-applied optimistic value, then restore // the prior value/status. Both publishes are under this single Lock hold (mirrors WriteValue's // node-update shape). See the method remarks for the queue-coalescing caveat. + var now = UtcNow; variable.StatusCode = StatusCodes.BadDeviceFailure; - variable.Timestamp = DateTime.UtcNow; + variable.Timestamp = now; variable.ClearChangeMasks(SystemContext, includeChildren: false); // notify — the Bad blip variable.Value = priorValue; variable.StatusCode = priorStatus; - variable.Timestamp = DateTime.UtcNow; + variable.Timestamp = now; variable.ClearChangeMasks(SystemContext, includeChildren: false); // notify — restore prior // Item C: build the audit event-state while we hold the node reference + Lock, but DON'T report yet. @@ -2680,7 +2845,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 severity: EventSeverity.Medium, message: new LocalizedText($"Inbound write rejected by device: {reason}"), status: false, - actionTimestamp: DateTime.UtcNow); + actionTimestamp: UtcNow); // Write-specific fields (AuditWriteUpdateEventType). AttributeId = Value; IndexRange empty (full write); // OldValue = the real pre-write value; NewValue = the value the client attempted. The AuditWriteUpdate @@ -2805,10 +2970,18 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// The node whose subtree is forgotten. private void ForgetPredefinedSubtree(NodeState node) { - PredefinedNodes?.Remove(node.NodeId); + // Iterative with one reused child buffer — the recursive form allocated a list per node (review 01 P1). + var pending = new Stack(); + pending.Push(node); var children = new List(); - node.GetChildren(SystemContext, children); - foreach (var child in children) ForgetPredefinedSubtree(child); + while (pending.Count > 0) + { + var current = pending.Pop(); + PredefinedNodes?.Remove(current.NodeId); + children.Clear(); + current.GetChildren(SystemContext, children); + foreach (var child in children) pending.Push(child); + } } /// Promote to and @@ -2820,12 +2993,10 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// ), the folder ALSO gets the /// bit OR-ed in (keeping SubscribeToEvents) and registers /// its NodeId identifier as an event-history source so the override - /// accepts it. The HistoryRead-events bit is therefore only advertised when a historian is wired at - /// the moment of promotion: the Host wires the source at StartAsync — BEFORE any deployment - /// materialises alarms — so the normal boot ordering promotes folders with the bit set. A folder - /// promoted while the source is still Null advertises live-event subscription but NOT event history - /// until the next re-promotes it (acceptable, documented). The - /// HistoryRead bit + source registration happen inside the same first-time + /// accepts it. A folder promoted while the source is still Null advertises live-event subscription + /// only; a later assignment re-evaluates every promoted folder + /// (review 01 U3 — ), so the Host's boot ordering is no + /// longer load-bearing. The HistoryRead bit + source registration happen inside the same first-time /// block so the idempotency guard covers them too. /// private void EnsureFolderIsEventNotifier(FolderState folder) @@ -2886,6 +3057,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 lock (Lock) { if (_folders.ContainsKey(key)) return; + if (IsHeldByAnotherKind(key, "folder")) return; var parent = ResolveParentFolder(parentNodeId, realm); var folder = new FolderState(parent) @@ -2900,9 +3072,33 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 parent.AddChild(folder); AddPredefinedNode(SystemContext, folder); _folders[key] = folder; + if (!string.IsNullOrEmpty(parentNodeId)) IndexChild(MapKey(realm, parentNodeId), key); } } + /// Records under in the P2 subtree index. MUST be + /// called under Lock. Idempotent for the same pair; a child re-indexed under another parent moves. + private void IndexChild(RealmKey parent, RealmKey child) + { + if (_parentIndex.TryGetValue(child, out var previous)) + { + if (previous == parent) return; + UnindexChild(child); + } + + if (!_childIndex.TryGetValue(parent, out var children)) _childIndex[parent] = children = new HashSet(); + children.Add(child); + _parentIndex[child] = parent; + } + + /// Drops from the P2 subtree index. MUST be called under Lock. + private void UnindexChild(RealmKey child) + { + if (!_parentIndex.Remove(child, out var parent)) return; + if (_childIndex.TryGetValue(parent, out var children) && children.Remove(child) && children.Count == 0) + _childIndex.Remove(parent); + } + /// /// Update an EXISTING folder node's in place and notify /// subscribers, WITHOUT a rebuild — the in-place counterpart of for a @@ -2977,6 +3173,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 lock (Lock) { if (_variables.ContainsKey(key)) return; + if (IsHeldByAnotherKind(key, "variable")) return; var parent = ResolveParentFolder(parentFolderNodeId, realm); // Phase C: a non-null historian tagname makes the node Historizing and grants the HistoryRead @@ -3013,6 +3210,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 parent.AddChild(variable); AddPredefinedNode(SystemContext, variable); _variables[key] = variable; + if (!string.IsNullOrEmpty(parentFolderNodeId)) IndexChild(MapKey(realm, parentFolderNodeId), key); // Phase C: register the resolved historian tagname so the HistoryRead override can map this // NodeId back to its Aveva/historian source. A historized UNS reference node passes the SAME // tagname as its backing raw node — each NodeId keys its own entry, both mapping to one tagname. @@ -3472,15 +3670,10 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 { lock (Lock) { - foreach (var v in _variables.Values) - { - v.Parent?.RemoveChild(v); - PredefinedNodes?.Remove(v.NodeId); - } - _variables.Clear(); - // Phase C: drop the NodeId→historian-tagname registrations alongside the variables they map. - _historizedTagnames.Clear(); - _arrayOnScalarRefused.Clear(); + // Review 01 L2 — every monitored item on a node this rebuild discards is told and forgotten BEFORE + // the nodes go, so a later create on the same NodeId binds to the re-created node (see + // TombstoneMonitoredNode). The shared root survives the rebuild and keeps its items. + TombstoneAllMonitoredNodes(); // Capture every NATIVE condition's live state before the conditions are discarded, so the // re-materialise that follows in this same apply can put it back (see CaptureCarryOver). Scripted @@ -3492,38 +3685,56 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 if (_nativeAlarmNodeIds.Contains(key)) _rebuildCarryOver[key] = CaptureCarryOver(alarm); } - foreach (var alarm in _alarmConditions.Values) - { - alarm.Parent?.RemoveChild(alarm); - ForgetPredefinedSubtree(alarm); - } // tear down every native-alarm→equipment-folder notifier pair bidirectionally // BEFORE the conditions + folders are cleared below, so no inverse-notifier entry leaks across the - // rebuild. The condition + folder NodeState objects still exist here (the loops above/below only - // detach them from their parents + PredefinedNodes), so RemoveNotifier is valid; then clear the - // tracking map so the re-materialise + re-wire on the next apply starts from a clean slate. + // rebuild. The condition + folder NodeState objects still exist here, so RemoveNotifier is valid; + // then clear the tracking map so the re-materialise + re-wire on the next apply starts clean. foreach (var alarmKey in _alarmNotifierWiring.Keys.ToList()) UnwireAlarmNotifiers(alarmKey); + // Review 01 P1 — detach whole subtrees, not leaves. NodeState.RemoveChild is a linear scan + + // RemoveAt, so detaching each of a folder's k children from it cost ~k²/2 compares under Lock + // (a 10k-tag device folder ≈ 50M). Everything this manager minted hangs under the shared root, + // which survives the rebuild, so detaching the root's own children once discards every subtree + // beneath them; the SDK lookup dictionary is still cleared per id (O(1) each) because + // PredefinedNodes indexes every node, condition children included. + if (_root is { } root) + { + var topLevel = new List(); + root.GetChildren(SystemContext, topLevel); + // Last-first: RemoveChild still scans for the reference, but RemoveAt on the tail shifts nothing. + for (var i = topLevel.Count - 1; i >= 0; i--) root.RemoveChild(topLevel[i]); + } + + foreach (var v in _variables.Values) PredefinedNodes?.Remove(v.NodeId); + _variables.Clear(); + // Phase C: drop the NodeId→historian-tagname registrations alongside the variables they map. + _historizedTagnames.Clear(); + _arrayOnScalarRefused.Clear(); + + foreach (var alarm in _alarmConditions.Values) ForgetPredefinedSubtree(alarm); _alarmConditions.Clear(); // H6a: drop the native-alarm flags in lock-step with the conditions they classify, so a // re-materialise on the next apply (possibly as the other kind) starts from a clean slate. _nativeAlarmNodeIds.Clear(); - foreach (var f in _folders.Values) - { - f.Parent?.RemoveChild(f); - PredefinedNodes?.Remove(f.NodeId); - } + foreach (var f in _folders.Values) PredefinedNodes?.Remove(f.NodeId); _folders.Clear(); + _childIndex.Clear(); + _parentIndex.Clear(); // Detach the Server↔folder HasNotifier ref for every promoted folder before dropping the // guard, otherwise the rebuild leaks an orphaned root-notifier reference on the Server - // object. RemoveRootNotifier just severs that link, so its order relative to the folder - // teardown above doesn't matter — but it must run under this same Lock. - foreach (var folder in _notifierFolders.Values) + // object. Done the way RemoveRootNotifier does it, but over the whole list at once: the SDK's + // per-folder removal scans RootNotifiers each time (O(F²) for F promoted folders). + if (RootNotifiers is { Count: > 0 } rootNotifiers) { - RemoveRootNotifier(folder); + foreach (var notifier in rootNotifiers) + { + notifier.OnReportEvent = null; + notifier.RemoveReference(ReferenceTypeIds.HasNotifier, true, ObjectIds.Server); + } + rootNotifiers.Clear(); } // Drop the notifier-folder guard so re-materialised alarms re-promote their (rebuilt) @@ -3583,6 +3794,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // caller (AddressSpaceApplier) falls back to a full rebuild (resync). Mirrors the per-node // teardown inside RebuildAddressSpace, scoped to this one id. if (!_variables.TryRemove(key, out var variable)) return false; + UnindexChild(key); + TombstoneMonitoredNode(variable.NodeId, UtcNow); // 2026-08-15 review I3: sever the INVERSE half of every cross-tree Organizes edge this node holds // BEFORE detaching it — the same hazard (and the same fix shape) as UnwireAlarmNotifiers on a // condition removal. AddReference wires the edge bidirectionally: forward on the UNS reference @@ -3649,6 +3862,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // lingering notifier folder is harmless (RemoveEquipmentSubtree demotes it when the whole // equipment goes). if (!_alarmConditions.TryRemove(key, out var condition)) return false; + UnindexChild(key); + TombstoneMonitoredSubtree(condition, UtcNow); // unwire this condition's extra equipment-folder notifiers bidirectionally BEFORE // the condition drops — the folders (equipment, UNS realm) SURVIVE this scoped remove, so without // this they would keep a dangling inverse-notifier entry to the removed condition. @@ -3656,7 +3871,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 condition.Parent?.RemoveChild(condition); ForgetPredefinedSubtree(condition); _nativeAlarmNodeIds.Remove(key); - ReconcileAllEventsMonitoring(); + LowerAllEventsMonitoring(condition); e = BuildNodesRemovedModelChange(alarmNodeId, realm); } ReportModelChangeOutsideLock(e, alarmNodeId); @@ -3676,80 +3891,15 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // Unknown equipment id ⇒ map drift ⇒ false (caller rebuilds). if (!_folders.ContainsKey(equipKey)) return false; - // Maps key on the (realm, bare id) pair. Every descendant in the SAME realm shares that id exactly - // or begins with "/" (sub-folders, variables, condition nodes); the explicit realm - // compare is what makes the match realm-scoped — it replaces the old string key's leading "ns=N;s=" - // — so a node in the OTHER namespace that happens to share a bare id is never in scope. Snapshot - // each map's in-scope keys first (ToList) so we don't mutate while enumerating. - var prefix = equipmentNodeId + "/"; - bool InScope(RealmKey key) => key.Realm == realm - && (key.Id == equipmentNodeId || key.Id.StartsWith(prefix, StringComparison.Ordinal)); - - // Value variables (+ their historized-tagname registrations). - foreach (var id in _variables.Keys.Where(InScope).ToList()) - { - if (_variables.TryRemove(id, out var v)) - { - // Sever the inverse half of each cross-tree Organizes edge - // first, exactly as RemoveVariableNode does — a UNS reference variable in scope points at a - // RAW node that survives this UNS-scoped removal and would otherwise keep a dangling edge. - SeverOutgoingReferences(v); - v.Parent?.RemoveChild(v); - PredefinedNodes?.Remove(v.NodeId); - } - _historizedTagnames.TryRemove(id, out _); - _arrayOnScalarRefused.TryRemove(id, out _); - } - - // Part 9 condition nodes (+ their native flags). - foreach (var id in _alarmConditions.Keys.Where(InScope).ToList()) - { - if (_alarmConditions.TryRemove(id, out var c)) - { - c.Parent?.RemoveChild(c); - ForgetPredefinedSubtree(c); - } - _nativeAlarmNodeIds.Remove(id); - } - ReconcileAllEventsMonitoring(); - - // a native alarm condition lives at its raw tag (Raw realm) and SURVIVES this - // UNS-scoped subtree removal, but it may hold an in-scope equipment folder as an extra - // event-notifier root. Unwire those condition↔folder pairs bidirectionally BEFORE the folders drop, - // else the surviving condition keeps a dangling inverse-notifier entry to a removed folder. (The - // condition's own scoped removal — if its raw tag is also removed this apply — runs later via - // RemoveAlarmConditionNode; this only reaches surviving conditions.) - foreach (var wiring in _alarmNotifierWiring.Values) - { - foreach (var folder in wiring.Folders.Where(f => InScope(MapKey(f.NodeId))).ToList()) - { - wiring.Alarm.RemoveNotifier(SystemContext, folder, bidirectional: true); - wiring.Folders.Remove(folder); - } - } - - // Notifier demotion BEFORE dropping the folders: sever the Server↔folder HasNotifier ref for - // every promoted folder in the subtree (an equipment folder, or a sub-folder that hosted an - // alarm), else the removal leaks an orphaned root-notifier reference on the Server object. - // _notifierFolders is keyed by the folder's NodeId; match InScope on its realm-qualified map key. - foreach (var kvp in _notifierFolders.Where(kv => InScope(MapKey(kv.Key))).ToList()) - { - RemoveRootNotifier(kvp.Value); - _notifierFolders.Remove(kvp.Key); - } - // Drop the event-history source registrations for the subtree (keyed by the folder id string). - foreach (var src in _eventNotifierSources.Keys.Where(InScope).ToList()) - _eventNotifierSources.TryRemove(src, out _); - - // Sub-folders + the equipment folder itself (children already detached above). - foreach (var id in _folders.Keys.Where(InScope).ToList()) - { - if (_folders.TryRemove(id, out var f)) - { - f.Parent?.RemoveChild(f); - PredefinedNodes?.Remove(f.NodeId); - } - } + // Review 01 P2 — walk the parent→children index the materialise calls maintain instead of + // prefix-scanning every map (variables, conditions, folders, notifier folders, event sources and + // every alarm's wiring) per removed equipment, which made a PureRemove of M equipment O(M·N) + // under Lock. A node is detached from its SDK parent only when that parent SURVIVES (the + // equipment folder from its line; a node hung under the shared root because its parent was + // unknown at creation) — detaching k children from a folder that is itself being discarded cost + // k²/2 compares for nothing (NodeState.RemoveChild is a linear scan). + var removedParents = new HashSet(System.Collections.Generic.ReferenceEqualityComparer.Instance); + RemoveSubtree(equipKey, removedParents, UtcNow); e = BuildNodesRemovedModelChange(equipmentNodeId, realm); } @@ -3757,6 +3907,84 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 return true; } + /// Removes the node at and, when it is a folder, everything indexed beneath + /// it (children first). MUST be called under Lock. + /// The node to remove. + /// The folders already discarded by this removal — a node under one of them is + /// not detached from it. + /// The timestamp the removal statuses carry. + private void RemoveSubtree(RealmKey key, HashSet removedParents, DateTime now) + { + if (_folders.TryGetValue(key, out var folder)) + { + removedParents.Add(folder); + if (_childIndex.TryGetValue(key, out var children)) + { + foreach (var child in children.ToArray()) RemoveSubtree(child, removedParents, now); + _childIndex.Remove(key); + } + + _folders.TryRemove(key, out _); + UnindexChild(key); + TombstoneMonitoredNode(folder.NodeId, now); + + // A native alarm condition lives at its raw tag (Raw realm) and SURVIVES this UNS-scoped + // subtree removal, but it may hold this folder as an extra event-notifier root. Unwire those + // condition↔folder pairs bidirectionally BEFORE the folder drops, else the surviving condition + // keeps a dangling inverse-notifier entry to a removed folder. The folder's own notifier list + // names exactly the conditions wired into it (the forward half of each pair). + var notifiers = new List(); + folder.GetNotifiers(SystemContext, notifiers); + foreach (var entry in notifiers) + { + if (entry.IsInverse || entry.Node is not AlarmConditionState alarm) continue; + if (!_alarmNotifierWiring.TryGetValue(MapKey(alarm.NodeId), out var wiring) || !ReferenceEquals(wiring.Alarm, alarm)) continue; + alarm.RemoveNotifier(SystemContext, folder, bidirectional: true); + wiring.Folders.Remove(folder); + } + + // Notifier demotion BEFORE dropping the folder: sever the Server↔folder HasNotifier ref for a + // promoted folder (an equipment folder, or a sub-folder that hosted an alarm), else the removal + // leaks an orphaned root-notifier reference on the Server object. + if (_notifierFolders.Remove(folder.NodeId, out var promoted)) RemoveRootNotifier(promoted); + _eventNotifierSources.TryRemove(key, out _); + + if (folder.Parent is { } parent && !removedParents.Contains(parent)) parent.RemoveChild(folder); + PredefinedNodes?.Remove(folder.NodeId); + return; + } + + if (_variables.TryRemove(key, out var v)) + { + UnindexChild(key); + TombstoneMonitoredNode(v.NodeId, now); + // Sever the inverse half of each cross-tree Organizes edge first, exactly as RemoveVariableNode + // does — a UNS reference variable in scope points at a RAW node that survives this UNS-scoped + // removal and would otherwise keep a dangling edge. + SeverOutgoingReferences(v); + if (v.Parent is { } parent && !removedParents.Contains(parent)) parent.RemoveChild(v); + PredefinedNodes?.Remove(v.NodeId); + _historizedTagnames.TryRemove(key, out _); + _arrayOnScalarRefused.TryRemove(key, out _); + return; + } + + if (_alarmConditions.TryRemove(key, out var c)) + { + UnindexChild(key); + TombstoneMonitoredSubtree(c, now); + UnwireAlarmNotifiers(key); + if (c.Parent is { } parent && !removedParents.Contains(parent)) parent.RemoveChild(c); + ForgetPredefinedSubtree(c); + _nativeAlarmNodeIds.Remove(key); + LowerAllEventsMonitoring(c); + return; + } + + // Indexed but already gone (a surgical remove dropped it first) — nothing to do. + UnindexChild(key); + } + private FolderState ResolveParentFolder(string? parentNodeId, AddressSpaceRealm realm) { EnsureAddressSpaceCreated(); @@ -4061,8 +4289,9 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 if (unsubscribe) { - // An item RevalidateMonitoredItems stood down is ALREADY off its notifier's fan-out. - // Handing its delete to the SDK would make it remove an item that is not there — at best a + // An item RevalidateMonitoredItems stood down is ALREADY off its notifier's fan-out, and so is + // one whose notifier a rebuild / surgical remove tombstoned (review 01 L2, TombstoneMonitoredNode). + // Handing either delete to the SDK would make it remove an item that is not there — at best a // spurious BadNodeIdUnknown on the client's delete, at worst a NullReferenceException inside // MonitoredNode2.Remove when the node's event list has gone null. Checked and dropped under the // same Lock the revalidation pass re-attaches under, so the two cannot interleave into @@ -4070,7 +4299,17 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 lock (Lock) { if (_eventItems.TryRemove(monitoredItem, out var stoodDown) && stoodDown.Suppressed) + { + Interlocked.Decrement(ref _suppressedItems); return ServiceResult.Good; + } + + if (IsHandleInNamespace(sourceId) is { } h + && (!MonitoredNodes.TryGetValue(h.NodeId, out var current) + || current.EventMonitoredItems?.Contains(monitoredItem) != true)) + { + return ServiceResult.Good; + } } return base.SubscribeToEvents(context, sourceId, subscriptionId, monitoredItem, unsubscribe); @@ -4156,10 +4395,213 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 protected override void OnMonitoredItemDeleted( ServerSystemContext context, NodeHandle handle, ISampledDataChangeMonitoredItem monitoredItem) { - _liveItems.TryRemove(monitoredItem, out _); + if (_liveItems.TryRemove(monitoredItem, out var tracked) && tracked.Suppressed) Interlocked.Decrement(ref _suppressedItems); base.OnMonitoredItemDeleted(context, handle, monitoredItem); } + // Review 01 L2 — a client deleting an item whose node this manager removed from under it (a rebuild or + // a surgical remove tombstoned the node's MonitoredNode2, see TombstoneMonitoredNode) must succeed. The + // SDK's own delete looks the node up by NodeId: with the entry gone it answers BadMonitoredItemIdInvalid, + // and with a FRESH entry (the same NodeId re-created and re-subscribed) it would try to remove the item + // from a fan-out list it is not on — a NullReferenceException when that list has gone null. An item that + // is not on the current fan-out for its NodeId has nothing left to detach, so its delete is completed here. + /// + protected override ServiceResult DeleteMonitoredItem( + ServerSystemContext context, IMonitoredItem monitoredItem, NodeHandle handle) + { + lock (Lock) + { + if (monitoredItem is ISampledDataChangeMonitoredItem dataItem + && (!MonitoredNodes.TryGetValue(handle.NodeId, out var current) + || current.DataChangeMonitoredItems?.Contains(dataItem) != true)) + { + OnMonitoredItemDeleted(context, handle, dataItem); + + // Review fix I3 — the SDK's DeleteMonitoredItems releases the item's component-cache reference + // whenever this returns Good. A tombstoned item's reference was ALREADY released by the removal, so + // that second release would take one from the RE-CREATED node's entry (a new item on the same NodeId) + // and evict it while still referenced. Take one back first so the SDK's release nets to zero — + // returning a non-Good status instead would fail the client's delete. + if (_cacheReferenceReleased.TryGetValue(dataItem, out _)) + { + _cacheReferenceReleased.Remove(dataItem); + AddNodeToComponentCache(context, handle, handle.Node); + } + + return ServiceResult.Good; + } + } + + return base.DeleteMonitoredItem(context, monitoredItem, handle); + } + + /// How many items the revalidation pass currently holds detached (stood down). Lets the + /// tombstone sweep for stood-down items — which are not on any fan-out list — run only when there are any. + private int _suppressedItems; + + /// + /// Review 01 L2 — forget the of a node this manager is removing, telling + /// every data-change item on it BadNodeIdUnknown first. MUST be called under Lock, BEFORE + /// the node is detached. + /// + /// + /// + /// The SDK keys its fan-out lists by NodeId and never clears them on the node manager's behalf + /// (DeleteMonitoredItem drops an entry only when its item count reaches zero), and its + /// component cache (AddNodeToComponentCache) hands back the CACHED + /// for a NodeId it already holds. So after a rebuild or a surgical remove+re-add, a NEW item on the + /// same NodeId — from any session — was bound to the DEAD node: created Good, never fed. Only + /// once every session had deleted its items did a create bind to the live node. + /// + /// + /// The fix removes the entry and releases each item's component-cache reference (the refcount the + /// SDK took at create), so the next create binds to the re-created node. The items themselves stay in + /// their client's subscription — a node manager cannot remove them — told BadNodeIdUnknown + /// (data items; an event item has no value slot) and dropped from the revalidation maps; their later + /// client delete is completed by / the SubscribeToEvents + /// unsubscribe arm. Queueing under Lock follows the publish path's own order (Lock, then + /// the item lock) — the inversion the revalidation pass avoids is the other way round. + /// + /// + /// An item the revalidation pass has STOOD DOWN is on no fan-out list, so it is swept from the + /// tracking map by NodeId instead — it still holds a component-cache reference that would otherwise + /// keep the dead node cached. + /// + /// + /// The NodeId being removed. + /// The timestamp the terminal BadNodeIdUnknown carries. + private void TombstoneMonitoredNode(NodeId nodeId, DateTime now) + { + if (MonitoredNodes.TryGetValue(nodeId, out var monitored)) + { + MonitoredNodes.Remove(nodeId); + if (monitored.DataChangeMonitoredItems is { Count: > 0 } dataItems) + { + foreach (var item in dataItems.ToArray()) + { + TellNodeRemoved(item, now); + if (item is ISampledDataChangeMonitoredItem sampled) _liveItems.TryRemove(sampled, out _); + ReleaseComponentCacheReference(item); + } + } + + if (monitored.EventMonitoredItems is { Count: > 0 } eventItems) + { + // No component-cache release: an event item never took a reference (the master node manager + // creates it through the EventManager + SubscribeToEvents, not AddNodeToComponentCache), so a + // release here would take one from a DATA item on the same NodeId. + foreach (var item in eventItems.ToArray()) _eventItems.TryRemove(item, out _); + } + } + + if (Volatile.Read(ref _suppressedItems) > 0) SweepStoodDownItems(nodeId, now); + } + + /// Tombstones and every descendant (a condition's instance-scoped + /// children are monitorable in their own right). MUST be called under Lock. + private void TombstoneMonitoredSubtree(NodeState node, DateTime now) + { + if (MonitoredNodes.Count == 0 && Volatile.Read(ref _suppressedItems) == 0) return; + var pending = new Stack(); + pending.Push(node); + var children = new List(); + while (pending.Count > 0) + { + var current = pending.Pop(); + TombstoneMonitoredNode(current.NodeId, now); + children.Clear(); + current.GetChildren(SystemContext, children); + foreach (var child in children) pending.Push(child); + } + } + + /// The rebuild form: every monitored node this manager serves except the shared root, which the + /// rebuild keeps. MUST be called under Lock. + private void TombstoneAllMonitoredNodes() + { + var now = UtcNow; + var rootId = _root?.NodeId; + foreach (var nodeId in MonitoredNodes.Keys.ToList()) + { + if (rootId is not null && nodeId == rootId) continue; + TombstoneMonitoredNode(nodeId, now); + } + + if (Volatile.Read(ref _suppressedItems) > 0) SweepStoodDownItems(nodeId: null, now); + } + + /// Drops the stood-down items of (every stood-down item when null — + /// the rebuild case) from the revalidation maps, telling the data items BadNodeIdUnknown. + private void SweepStoodDownItems(NodeId? nodeId, DateTime now) + { + foreach (var (item, tracked) in _liveItems) + { + if (!tracked.Suppressed || (nodeId is not null && tracked.Handle.NodeId != nodeId)) continue; + if (!_liveItems.TryRemove(item, out _)) continue; + Interlocked.Decrement(ref _suppressedItems); + TellNodeRemoved(item, now); + ReleaseComponentCacheReference(item); + } + + foreach (var (item, tracked) in _eventItems) + { + if (!tracked.Suppressed || (nodeId is not null && tracked.Handle.NodeId != nodeId)) continue; + if (!_eventItems.TryRemove(item, out _)) continue; + Interlocked.Decrement(ref _suppressedItems); + // Event items hold no component-cache reference (see TombstoneMonitoredNode). + } + } + + /// Queues the terminal BadNodeIdUnknown on a data-change item whose node is going away. + private void TellNodeRemoved(IDataChangeMonitoredItem2 item, DateTime now) + { + try + { + item.QueueValue( + new DataValue(StatusCodes.BadNodeIdUnknown) { SourceTimestamp = now, ServerTimestamp = now }, + new ServiceResult(StatusCodes.BadNodeIdUnknown), + ignoreFilters: true); + } + catch (Exception ex) + { + // A disabled item refuses the value; nothing else here is worth failing a removal for. + _logger.LogDebug(ex, "OtOpcUaNodeManager: could not queue the removal status to a monitored item"); + } + } + + /// Gives back the component-cache reference the SDK took for the DATA item at + /// create, so the cache stops handing out the removed node for that NodeId — and remembers that it did, so the + /// item's later client delete does not give it back a second time (see ). + private void ReleaseComponentCacheReference(IMonitoredItem item) + { + if (item.ManagerHandle is not NodeHandle handle) return; + RemoveNodeFromComponentCache(SystemContext, handle); + _cacheReferenceReleased.AddOrUpdate(item, ReleasedMarker); + } + + /// Data items whose component-cache reference a removal already gave back (review fix I3). Weak, so an + /// item the SDK drops without a delete (it always deletes, but a leak here would be silent) is not kept alive. + private readonly System.Runtime.CompilerServices.ConditionalWeakTable _cacheReferenceReleased = new(); + private static readonly object ReleasedMarker = new(); + + /// Test observability: how many NodeIds currently carry a fan-out. + internal int MonitoredNodeCount + { + get { lock (Lock) return MonitoredNodes.Count; } + } + + /// Test observability: whether currently carries a fan-out. + internal bool IsNodeMonitored(NodeId nodeId) + { + lock (Lock) return MonitoredNodes.ContainsKey(nodeId); + } + + /// Test observability: how many data-change items the revalidation map tracks. + internal int LiveItemCount => _liveItems.Count; + + /// Test observability: how many direct event items the revalidation map tracks. + internal int LiveEventItemCount => _eventItems.Count; + /// /// Live data-change monitored items on nodes this manager owns, so a grant revoked by a deployment /// can reach the subscriptions it was authorizing. @@ -4339,6 +4781,20 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 condition.SetAreEventsMonitored(SystemContext, true, includeChildren: true); } + /// + /// The removal-side mirror of (review 01 P3): gives back the one + /// condition's unit, touching no other condition. Call under Lock, for a condition being removed + /// or replaced. The surgical removal sites used to run the full reconcile — O(C) per removed condition + /// while any Server-object item was live, i.e. always in production. + /// + /// The condition leaving _alarmConditions. + private void LowerAllEventsMonitoring(AlarmConditionState condition) + { + AllEventsMonitoringConditionVisits++; + if (_allEventsMonitoredConditions.Remove(condition)) + condition.SetAreEventsMonitored(SystemContext, false, includeChildren: true); + } + /// /// How many conditions the Server-object monitored bookkeeping has visited since start — every /// condition a full walks, plus one per incremental @@ -4775,7 +5231,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 { if (!AclEnforced || (_liveItems.IsEmpty && _eventItems.IsEmpty)) return; - var now = DateTime.UtcNow; + var now = UtcNow; var stoodDown = 0; var restored = 0; @@ -4798,7 +5254,26 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 { if (!tracked.Suppressed) continue; - lock (Lock) tracked.Handle.MonitoredNode?.Add(item); + // Re-resolve the node by NodeId rather than trusting the handle (review 01 L2): the fan-out + // the item came off belongs to the NodeState that existed when it was stood down, and a + // rebuild or surgical remove in between has tombstoned that node — then the item was already + // dropped from this map, so a mismatch here is a race with one; tell the client and let go. + bool reattached; + lock (Lock) + { + reattached = ResolveNodeState(MapKey(nodeId)) is { } liveNode + && ReferenceEquals(liveNode, tracked.Handle.Node); + if (reattached) tracked.Handle.MonitoredNode?.Add(item); + else _liveItems.TryRemove(item, out _); + } + + Interlocked.Decrement(ref _suppressedItems); + if (!reattached) + { + TellNodeRemoved(item, now); + continue; + } + tracked.Suppressed = false; restored++; continue; @@ -4813,8 +5288,13 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 new ServiceResult(StatusCodes.BadUserAccessDenied), ignoreFilters: true); - lock (Lock) tracked.Handle.MonitoredNode?.Remove(item); + lock (Lock) + { + // The fan-out list may have gone null if every other item on the node was deleted meanwhile. + if (tracked.Handle.MonitoredNode is { DataChangeMonitoredItems: not null } fanOut) fanOut.Remove(item); + } tracked.Suppressed = true; + Interlocked.Increment(ref _suppressedItems); stoodDown++; } @@ -4886,8 +5366,21 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 { if (!_eventItems.TryGetValue(item, out var current) || !ReferenceEquals(current, tracked)) continue; + // Re-resolve the notifier by NodeId (review 01 L2): re-attaching to the handle's NodeState + // after a rebuild would bind the item to a dead node. A notifier that is gone, or has been + // re-created, cannot be the one this item was stood down from — drop the item instead. + if (permitted + && (ResolveNodeState(MapKey(tracked.Handle.NodeId)) is not { } live || !ReferenceEquals(live, tracked.Handle.Node))) + { + _eventItems.TryRemove(item, out _); + Interlocked.Decrement(ref _suppressedItems); + continue; // an event item holds no component-cache reference (see TombstoneMonitoredNode) + } + SubscribeToEvents(SystemContext, tracked.Handle.Node, item, unsubscribe: !permitted); tracked.Suppressed = !permitted; + if (permitted) Interlocked.Decrement(ref _suppressedItems); + else Interlocked.Increment(ref _suppressedItems); } if (permitted) restored++; @@ -6318,13 +6811,17 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // The data is drained — the end bound ends this page when it has room (Part 11: bounds count toward // NumValuesPerNode), else it is the whole next page. Returns the continuation point to emit, if any. - async Task AppendEndBoundAsync(List page, DateTime resumedAtUtc) + // `pageAllowance` is the EFFECTIVE cap of this page — NumValuesPerNode, less whatever the backend's row + // ceiling (RawPageLimit) took off it, plus the start bound a first page already carries — not + // NumValuesPerNode itself: against N a ceiling-sized page that was full still took the bound, returning + // cap + 1 values (#736 fuzz). 0 = unbounded. + async Task AppendEndBoundAsync(List page, DateTime resumedAtUtc, uint pageAllowance) { // The previous returned value stamps an open bound's placeholder (Part 11 Table 1 footnotes a/b): this // page's last, else the last of an earlier page (carried in the cursor), else where the read resumed. var previous = (page.Count > 0 ? page[^1].SourceTimestampUtc : null) ?? endBound!.LastReturnedUtc ?? resumedAtUtc; - if (numValuesPerNode == 0 || page.Count < numValuesPerNode) + if (pageAllowance == 0 || page.Count < pageAllowance) { page.Add(await HistoryBounds.EndBoundAsync( HistorianDataSource, tagname, endBound!, previous, MaxTieClusterOverfetch, nowUtc, ct).ConfigureAwait(false)); @@ -6369,7 +6866,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 && (endBound.DataExhausted || HistoryBounds.DataWindowDrained(startUtc, endBound, resolution))) { var onlyBound = new List(1); - var afterCp = await AppendEndBoundAsync(onlyBound, startUtc).ConfigureAwait(false); + var afterCp = await AppendEndBoundAsync(onlyBound, startUtc, numValuesPerNode).ConfigureAwait(false); results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.Good, @@ -6589,7 +7086,11 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 else if (endBound is not null) { // The data is drained — the end bound ends this page, or is the next one when this one is full. - clusterCp = await AppendEndBoundAsync(slice, startUtc).ConfigureAwait(false); + // An uncapped read (readCap 0) on a backend with a row ceiling is still a page of at most that + // ceiling — the MaxReturnDataValues it advertises — so the bound fits against it, not "unbounded". + // (Today the over-fetch bound keeps a cluster slice at most L − 1, so this is defence in depth.) + var clusterAllowance = readCap > 0 ? readCap : pageLimit > 0 ? (uint)pageLimit : 0u; + clusterCp = await AppendEndBoundAsync(slice, startUtc, clusterAllowance).ConfigureAwait(false); } results[handle.Index] = new SdkHistoryReadResult @@ -6642,7 +7143,9 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // The data is drained — the end bound ends this page, or is the next one when this one is full. var page = new List(samples.Count + 1); page.AddRange(samples); - outboundCp = await AppendEndBoundAsync(page, startUtc).ConfigureAwait(false); + // This page's allowance: the (ceiling-adjusted) data cap, plus the start bound it already leads with. + var allowance = readCap == 0 ? 0u : readCap + (startBound is not null ? 1u : 0u); + outboundCp = await AppendEndBoundAsync(page, startUtc, allowance).ConfigureAwait(false); samples = page; historyData = ToHistoryDataFromSamples(samples); } @@ -6961,28 +7464,23 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 } } - private BaseDataVariableState CreateVariable(string nodeId, ushort ns) + /// + /// Review 01 H2 — the cross-map guard every node-minting entry point (, + /// , ) runs before creating a node: + /// true (and a Warning) when is already held by a node of ANOTHER kind. + /// The three maps are separate, so nothing else stops one (realm, id) being both a folder and a + /// variable — and the SDK's AddPredefinedNode is an AddOrUpdate, so the second mint silently + /// replaced the first's lookup entry. MUST be called under Lock. + /// + /// The realm-qualified id about to be minted. + /// The kind the caller is minting (for the log line). + /// when the id is taken by another kind and the caller must not mint. + private bool IsHeldByAnotherKind(RealmKey key, string requestedKind) { - var v = new BaseDataVariableState(_root) - { - NodeId = new NodeId(nodeId, ns), - BrowseName = new QualifiedName(nodeId, ns), - DisplayName = nodeId, - TypeDefinitionId = VariableTypeIds.BaseDataVariableType, - ReferenceTypeId = ReferenceTypeIds.Organizes, - DataType = DataTypeIds.BaseDataType, - // Lazy-created nodes (a WriteValue for a node never declared via EnsureVariable) carry no - // array intent, so they stay scalar with no ArrayDimensions — mirrors EnsureVariable's - // scalar branch. Array nodes are always pre-declared via EnsureVariable(isArray:true). - ValueRank = ValueRanks.Scalar, - ArrayDimensions = null, - AccessLevel = AccessLevels.CurrentRead, - UserAccessLevel = AccessLevels.CurrentRead, - Historizing = false, - }; - _root?.AddChild(v); - AddPredefinedNode(SystemContext, v); - return v; + if (ResolveNodeState(key) is not { } existing) return false; + _logger.LogWarning( + "OtOpcUaNodeManager: refusing to materialise {Kind} {NodeId} (realm {Realm}) — the id is already held by a {ExistingKind} node", + requestedKind, key.Id, key.Realm, existing.GetType().Name); + return true; } - } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs index 68c23d4d..a1667540 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs @@ -86,19 +86,17 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers /// The namespace realm the condition lives in ( for native). public sealed record AlarmQualityUpdate(string AlarmNodeId, uint StatusCode, DateTime TimestampUtc, AddressSpaceRealm Realm); /// - /// Triggers an address-space rebuild. 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 . + /// 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. /// /// Correlation id for tracing this rebuild. - /// The applied deployment whose artifact to materialise, or null for the latest-sealed fallback. + /// The applied deployment whose artifact this is, or null for a legacy caller. /// - /// 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 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. /// /// /// when , the actor replies to the sender with a @@ -128,11 +126,110 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers public sealed record ServiceLevelChanged(byte ServiceLevel); + /// + /// Review 02 P5 — a rebuild whose artifact parse, cluster-scoped composition and plan were computed + /// OFF the pinned OPC UA thread (a Task.Run piped back to Self), ready for the sink passes, + /// which are the only part that must run on this actor's thread. Carries the and + /// the original so the reply reaches whoever asked. + /// + /// stamps the actor instance that started the plan: a PipeTo(Self) + /// outlives a restart (the ActorRef survives it), and a plan computed against a previous instance's + /// must not be applied by the next one. carries a + /// data-plane-authority parse failure, which never fails the address-space apply (review fix I2). + /// + private sealed record PlanReady( + object Incarnation, RebuildAddressSpace Request, IActorRef Requester, byte[] Artifact, + DataPlaneAuthorityProjection? Authority, Exception? AuthorityError, + AddressSpaceComposition Composition, AddressSpacePlan Plan, IReadOnlyList Inconsistencies); + + /// The off-thread parse / plan step threw (unreadable bytes, a composer fault). + private sealed record PlanFailed(object Incarnation, RebuildAddressSpace Request, IActorRef Requester, Exception Error); + + /// Test seams for the off-thread plan step. Production passes none. + internal sealed class TestHooks + { + /// Runs on the thread pool at the start of every plan step (a test blocks it to hold a plan in flight). + internal Action? BeforePlan { get; init; } + + /// Replaces (a test makes it throw). + internal Func? ParseAuthority { get; init; } + } + + /// The default bound on : distinct (realm, id) keys held while a plan is in flight. + internal const int DefaultDeferredWriteCapacity = 250_000; + + /// Identity of THIS actor instance (see ). + private readonly object _incarnation = new(); + private readonly TestHooks? _hooks; + + /// + /// 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 + /// BadWaitingForInitialData 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. + /// 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. + /// + private readonly DeferredWrites _deferred; + + /// + /// 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 , consumed by . + /// + /// 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). + /// 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 + /// (); 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. + /// 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. + /// Known gaps (degraded path only, review m1 / m2): a degraded non-rebuild apply does not advance + /// _lastApplied, 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. + /// + /// (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.) + /// + 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); + } + + /// 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 , which only the apply advances — so a + /// rebuild arriving meanwhile waits in ; value, status and alarm writes do not — they + /// are applied at once AND remembered in for replay after the apply. + 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? _dbFactory; private readonly AddressSpaceApplier? _applier; /// 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 /// Production Props — pins the OPC UA dispatcher + subscribes to the /// redundancy-state DPS topic so cluster transitions drive the local ServiceLevel - /// publish path. When + are supplied, - /// reads the latest deployment artifact + drives the - /// applier through the sink. When is supplied the local + /// publish path. When is supplied, + /// diffs + applies the artifact bytes it carries through the sink ( is + /// accepted for wiring compatibility only — review 02 P5 removed the ConfigDb fallback). When is supplied the local /// ServiceLevel is computed via from real DB-health + /// staleness + role-leader inputs; otherwise the legacy role-only switch is used. /// The OPC UA address space sink. @@ -293,6 +390,28 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers dataPlaneAuthority, utcNow)); + /// Test-only Props exposing the deferred-write bound and the plan-step seams. + 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))); + + /// Produces the actor through its internal (seam-carrying) constructor, for . + private sealed class TestProducer(Func create) : IIndirectActorProducer + { + public Type ActorType => typeof(OpcUaPublishActor); + public ActorBase Produce() => create(); + public void Release(ActorBase actor) { } + } + /// Initializes a new instance of the class. /// The OPC UA address space sink. /// The service level publisher. @@ -329,13 +448,41 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers bool dbLess = false, IDataPlaneAuthoritySink? dataPlaneAuthority = null, Func? utcNow = null) + : this(sink, serviceLevel, subscribeRedundancyTopic, localNode, dbFactory, applier, dbHealthProbe, staleWindow, + probeFreshnessWindow, healthTickInterval, dbLess, dataPlaneAuthority, utcNow, DefaultDeferredWriteCapacity, hooks: null) { + } + + /// The full constructor: also the deferred-write bound and the test seams (internal — tests only). + /// The bound on distinct nodes whose writes are held for replay while a rebuild plan is in flight. + /// Test seams for the off-thread plan step; null in production. + internal OpcUaPublishActor( + IOpcUaAddressSpaceSink sink, + IServiceLevelPublisher serviceLevel, + bool subscribeRedundancyTopic, + NodeId? localNode, + IDbContextFactory? dbFactory, + AddressSpaceApplier? applier, + IActorRef? dbHealthProbe, + TimeSpan? staleWindow, + TimeSpan? probeFreshnessWindow, + TimeSpan? healthTickInterval, + bool dbLess, + IDataPlaneAuthoritySink? dataPlaneAuthority, + Func? 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(HandleAlarmUpdate); Receive(HandleAlarmQualityUpdate); Receive(HandleRebuild); + Receive(HandlePlanReady); + Receive(HandlePlanFailed); Receive(HandleServiceLevelChanged); Receive(HandleRedundancyStateChanged); Receive(HandleDbHealthStatus); @@ -358,6 +507,24 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers Receive(_ => { /* PubSub ack */ }); } + /// + /// Review M5 — a restart discards ; 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 (). + /// + /// + 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); + } + /// 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("kind", "value")); @@ -401,6 +569,8 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers /// a whole batch because one item was bad. 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. 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); + + /// + /// 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 . Only the sink materialisation () runs here. + /// + 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)); - } - - /// The rebuild pipeline. Returns whether the apply was degraded, and why. - 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("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(); + 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(); + } + + /// A plan piped back to a RESTARTED instance (the ActorRef survives a restart) was computed against the + /// previous instance's : never applied. Its requester is still owed an answer. + 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; + } + + /// After an apply (or a failed plan) has been answered — its deferred writes already replayed by + /// — 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). + private void AfterPlanSettled() + { + StartNextPendingRebuild(); + if (!PlanPipelineBusy) _deferred.Clear(); + } + + /// Starts queued rebuilds in arrival order until one goes off-thread (or the queue empties) — the + /// synchronous completions (no bytes) answer in their turn. + private void StartNextPendingRebuild() + { + while (!_planInFlight && _pendingRebuilds.TryDequeue(out var next)) + StartRebuild(next.Request, next.Requester); + } + + /// True while a plan is computing or a rebuild is queued behind one. + 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); + } + + /// 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). + 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(allValues.Count); + var statuses = new List(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); + } + + /// + /// The latest write per (realm, id) received while a plan is in flight — see . 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 distinct keys across all kinds; + /// beyond it a NEW key is not held (an existing key still updates), counted in . + /// + 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 Alarms => _alarms.Values; + public IEnumerable 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; + } + + /// 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. + 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 Values, List Statuses) VariableWrites(Func<(AddressSpaceRealm, string), bool> include) + { + var values = new List(); + var statuses = new List(); + 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(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; + } + } + + /// + /// 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. + /// + 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)); + } + + /// 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. + 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(); + } + } + + /// The condition ids in that did not materialise: + /// enabled scripted alarms (Uns, by ScriptedAlarmId) and native alarms (Raw, at their RawPath). + 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() — 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()); - - // 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 } /// - /// 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). MaterialiseHierarchy is NOT one of them: /// the caller always runs it in full, because parents must exist before any add. /// @@ -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. /// - /// The rebuild's single parse of the artifact, read for the keys + grants. + /// The keys + grants decoded (off-thread) from the rebuild's single parse of the artifact. /// The RAW artifact bytes — hashed for the revision id, which must be /// SHA-256 of the exact blob (see below); never re-parsed here. - 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 } } - /// Read a specific deployment's artifact blob from ConfigDb (the one just applied, - /// which may not be Sealed yet). Empty array on any failure — treats that as - /// "no answer" and abandons the rebuild, leaving the served address space untouched. - 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(); - } - catch (Exception ex) - { - _log.Warning(ex, "OpcUaPublish: failed to load artifact for deployment {Id}; the rebuild is abandoned", deploymentId); - return Array.Empty(); - } - } - - /// Read the most recent Sealed deployment's artifact blob from ConfigDb. - /// Empty array on any failure — see for how that is handled. - 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(); - } - catch (Exception ex) - { - _log.Warning(ex, "OpcUaPublish: failed to load latest deployment artifact; the rebuild is abandoned"); - return Array.Empty(); - } - } - private void HandleServiceLevelChanged(ServiceLevelChanged msg) { // Always publish the FIRST computed level, even if it equals the byte-default 0. Otherwise a diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/HistoryUpdateOverTheWireTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/HistoryUpdateOverTheWireTests.cs index 43fa05e2..e3eb2acc 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/HistoryUpdateOverTheWireTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/HistoryUpdateOverTheWireTests.cs @@ -215,11 +215,13 @@ public sealed class HistoryUpdateOverTheWireTests } /// - /// 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.) /// [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(); } } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/StaleMonitoredNodeWireTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/StaleMonitoredNodeWireTests.cs new file mode 100644 index 00000000..6893fc2e --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/StaleMonitoredNodeWireTests.cs @@ -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; + +/// +/// 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 MonitoredNode2 — and its component-cache +/// entry — pointing at the DEAD NodeState. 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 Good, fed its +/// stale initial value, then never again. +/// +/// 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 BadNodeIdUnknown. Deleting the old item afterwards must succeed too. +/// +/// +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"; + + /// Rebuild + re-create the same NodeId: the old item is told, a new item on the same NodeId receives values. + [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); + }); + } + + /// Surgical remove + re-add of the same NodeId: same contract as the rebuild. + [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); + }); + } + + /// + /// The same trap from a SECOND session: a UaExpert operator connecting after a deploy while ScadaBridge's + /// original item is still alive. + /// + [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 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(); + 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); + } + } + + /// The SDK's component-cache reference count for (private SDK state, read by + /// reflection: CustomNodeManager2.m_componentCache → CacheEntry.RefCount); 0 when no entry. + 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 Values, object Gate)> SubscribeAsync( + ISession session, NodeId nodeId, CancellationToken ct) + { + var values = new List(); + 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.None }, + AutoAcceptUntrustedClientCertificates = true, + }, + NullLogger.Instance); + }, + () => new OtOpcUaSdkServer(), ct); + return (started.Server, started.Host, boundPort); + } + + private static async Task 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 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 */ } + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/SubscriptionSurvivalTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/SubscriptionSurvivalTests.cs index b8701f69..31b597ef 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/SubscriptionSurvivalTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/SubscriptionSurvivalTests.cs @@ -26,7 +26,7 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests; /// announce (T4b) land. /// Run note: 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 -/// namespace URI is ; the client resolves the +/// namespace URI is ; the client resolves the /// folder-scoped variable NodeIds against it. /// 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.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(); @@ -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( 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()); + + private static AddressSpaceComposition CompositionWith(params RawTagPlan[] tags) => new( UnsAreas: Array.Empty(), UnsLines: Array.Empty(), @@ -341,7 +345,8 @@ public sealed class SubscriptionSurvivalTests DriverInstancePlans: Array.Empty(), ScriptedAlarmPlans: Array.Empty()) { - EquipmentTags = tags, + RawContainers = new[] { new RawContainerNode("Plant/dev1", null, "dev1", RawNodeKind.Device) }, + RawTags = tags, }; private static async Task OpenSessionAsync(string endpointUrl, CancellationToken ct) diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierFailureSurfaceTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierFailureSurfaceTests.cs index 08076fb1..1282a731 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierFailureSurfaceTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierFailureSurfaceTests.cs @@ -45,45 +45,56 @@ public sealed class AddressSpaceApplierFailureSurfaceTests // ---------------- T2: FailedNodes (removal-pass tally) ---------------- - /// A removal-pass alarm-condition write that throws is counted into FailedNodes (swallowed today). + /// 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). [Fact] public void Apply_WhenRemovalConditionWriteThrows_CountsFailedNodes() { var sink = new ConfigurableThrowingSink { ThrowOnAlarmWrite = true }; var applier = new AddressSpaceApplier(sink, NullLogger.Instance); + var outcome = applier.Apply(AlarmRemovalPlan("alm-1")); + + outcome.RemovedNodes.ShouldBe(1); + outcome.FailedNodes.ShouldBe(1); + } + + /// Review 01 L4 — removing an equipment writes NO condition state against the equipment id. + [Fact] + public void Apply_RemovedEquipment_WritesNoConditionAgainstTheEquipmentId() + { + var sink = new ConfigurableThrowingSink { ThrowOnAlarmWrite = true }; + var applier = new AddressSpaceApplier(sink, NullLogger.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 ---------------- - /// Every EnsureVariable throwing in MaterialiseEquipmentTags is tallied into the pass's int return. + /// Every EnsureVariable throwing in MaterialiseRawSubtree is tallied into the pass's int return. [Fact] - public void MaterialiseEquipmentTags_WhenEnsureVariableThrows_ReturnsFailedCount() + public void MaterialiseRawSubtree_WhenEnsureVariableThrows_ReturnsFailedCount() { var sink = new ConfigurableThrowingSink { ThrowOnEnsureVariable = true }; var applier = new AddressSpaceApplier(sink, NullLogger.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); } - /// All five Materialise* passes return 0 failures on a clean sink — the happy-path regression. + /// All four Materialise* passes return 0 failures on a clean sink — the happy-path regression. [Fact] public void Materialise_HappyPath_AllPassesReturnZero() { var sink = new ConfigurableThrowingSink(); var applier = new AddressSpaceApplier(sink, NullLogger.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(), Historize: false)); var alarms = CompositionWithScriptedAlarms( @@ -91,17 +102,21 @@ public sealed class AddressSpaceApplierFailureSurfaceTests Array.Empty(), 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()); + + private static AddressSpaceComposition CompositionWithRawTags(params RawTagPlan[] tags) => new(Array.Empty(), Array.Empty(), Array.Empty()) { - EquipmentTags = tags, + RawTags = tags, }; private static AddressSpaceComposition CompositionWithVirtualTags(params EquipmentVirtualTagPlan[] vtags) => @@ -135,6 +150,17 @@ public sealed class AddressSpaceApplierFailureSurfaceTests RemovedAlarms: Array.Empty(), ChangedAlarms: Array.Empty()); + private static AddressSpacePlan AlarmRemovalPlan(params string[] ids) => new( + AddedEquipment: Array.Empty(), + RemovedEquipment: Array.Empty(), + ChangedEquipment: Array.Empty(), + AddedDrivers: Array.Empty(), + RemovedDrivers: Array.Empty(), + ChangedDrivers: Array.Empty(), + AddedAlarms: Array.Empty(), + RemovedAlarms: ids.Select(id => new ScriptedAlarmPlan(id, "eq-1", "pred", "msg")).ToArray(), + ChangedAlarms: Array.Empty()); + private static AddressSpacePlan EquipmentRemovalPlan(params string[] ids) => new( AddedEquipment: Array.Empty(), RemovedEquipment: ids.Select(id => new EquipmentNode(id, id, "line-1")).ToArray(), diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierHierarchyTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierHierarchyTests.cs index 39d26056..4528812e 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierHierarchyTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierHierarchyTests.cs @@ -110,13 +110,13 @@ public sealed class AddressSpaceApplierHierarchyTests : IDisposable sdkServer.NodeManager!.FolderCount.ShouldBe(5); } - /// Verifies MaterialiseEquipmentTags is idempotent against a real SDK node manager: + /// 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. [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(), ScriptedAlarmPlans: Array.Empty()) { - 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()), }, }; - // 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 } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierTests.cs index f3e61370..2a60cf29 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierTests.cs @@ -12,30 +12,6 @@ public sealed class AddressSpaceApplierTests - /// 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.) - [Fact] - public void Added_equipment_tags_are_pure_add_and_skip_rebuild() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.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); - } - /// 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.) @@ -61,27 +37,22 @@ public sealed class AddressSpaceApplierTests sink.RebuildCalls.ShouldBe(0); } - /// 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. + /// 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. [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.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(); - } - - /// 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). - [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.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 - } - - /// 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.) - [Fact] - public void Removed_equipment_tag_writes_terminal_bad_and_removes_variable_without_rebuild() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.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)); - } - - /// 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). - [Fact] - public void Removed_alarm_bearing_tag_writes_removed_condition_state_and_removes_condition() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.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); - } - - /// 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. - [Fact] - public void Removed_child_under_removed_equipment_is_subsumed_by_subtree_removal() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.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); } /// 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.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 /// 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.) + /// passes. [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); } - /// 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. - [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.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)); - } - /// R2-07 T13 — a remove failure inside an AddRemoveMix still trips the rebuild ratchet. [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); } - /// H1a — a deploy that ONLY changes an existing equipment tag in a NON-surgical way (here the - /// driver-side FullName re-routes to a different point) must rebuild the address space. The planner - /// diffs the tag into ChangedEquipmentTags 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.) + /// 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. [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.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)); } /// F10b (backlog #11) — a deploy that ONLY edits an existing VirtualTag's Expression @@ -553,26 +354,18 @@ public sealed class AddressSpaceApplierTests var applier = new AddressSpaceApplier(sink, NullLogger.Instance); var previous = new AddressSpaceComposition( - Array.Empty(), Array.Empty(), Array.Empty()) + new[] { new EquipmentNode("eq-1", "One", "line-1") }, Array.Empty(), Array.Empty()) { - 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(), Array.Empty(), Array.Empty()) + new[] { new EquipmentNode("eq-1", "One renamed", "line-1") }, Array.Empty(), Array.Empty()) { - 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 removedCount — AddressSpaceApplyOutcome.RemovedNodes reported 0, a misleading audit /// entry. This pins both the rebuild and the accurate count. [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.Instance); @@ -756,10 +547,6 @@ public sealed class AddressSpaceApplierTests var previous = new AddressSpaceComposition( Array.Empty(), Array.Empty(), Array.Empty()) { - 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) ----- - /// F10b — a deploy that ONLY flips an existing equipment tag's Writable bit (a plain, - /// non-array, non-alarm value variable with stable identity) must SKIP the rebuild and apply the change - /// IN PLACE via ISurgicalAddressSpaceSink.UpdateTagAttributes, preserving every client's - /// subscriptions. Exactly one surgical call lands with the NEW Writable value; the edit still counts as - /// a change (ChangedNodes == 1). - [Fact] - public void Changed_tag_writable_only_skips_rebuild_and_updates_in_place() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.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); - } - - /// F10b — flipping IsHistorized false → true (no override) updates in place with the - /// historian tagname defaulting to FullName; flipping true → false updates in place with a null - /// historian tagname. Both skip the rebuild. - [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.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.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 - } - - /// F10b — changing ONLY the HistorianTagname override on an already-historized tag - /// skips the rebuild and updates in place, passing the NEW override verbatim. - [Fact] - public void Changed_tag_historian_tagname_only_skips_rebuild() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.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 - } - - /// FB-7 — a tag delta whose DataType 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. - [Fact] - public void Changed_tag_data_type_change_skips_rebuild_and_updates_in_place() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.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); - } - - /// FB-7 — a tag delta whose IsArray 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. - [Fact] - public void Changed_tag_is_array_change_skips_rebuild_and_updates_in_place() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.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 - } - - /// F10b safe-default — a tag delta whose driver-side FullName changed is NOT - /// surgical-eligible (it re-routes the node to a different driver point), so the applier rebuilds. - [Fact] - public void Changed_tag_full_name_change_rebuilds() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.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(); - } - - /// F10b safe-default — a tag delta whose Name changed is NOT surgical-eligible (the - /// folder-scoped NodeId + BrowseName derive from Name), so the applier rebuilds. - [Fact] - public void Changed_tag_name_change_rebuilds() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.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(); - } - - /// 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. - [Fact] - public void Changed_tag_alarm_presence_change_rebuilds() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.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(); - } - - /// F10b guard — a tag that ALREADY carries an alarm (Alarm is not null on BOTH previous - /// and current, identical alarm info) is NOT surgical-eligible even when only Writable changes. - /// The explicit Alarm is null guard in TagDeltaIsSurgicalEligible prevents a false - /// positive: without it the with { Writable = … } override (which does NOT touch Alarm) - /// would leave both sides with equal Alarm 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. - [Fact] - public void Changed_alarm_bearing_tag_writable_only_still_rebuilds() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.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 - } - - /// F10b multi-delta — when a plan contains TWO distinct surgical-eligible tag deltas (plain - /// value variables, no alarm, both changing only Writable), the applier must apply BOTH in - /// place (two UpdateTagAttributes calls) without triggering any rebuild. Proves the surgical - /// path iterates all eligible deltas rather than stopping after the first. - [Fact] - public void Two_surgical_eligible_tag_deltas_both_apply_in_place_no_rebuild() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - // Two distinct plain (no-alarm) tags, both will flip Writable — both are surgical-eligible. - var previous = new AddressSpaceComposition( - Array.Empty(), Array.Empty(), Array.Empty()) - { - 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(), Array.Empty(), Array.Empty()) - { - 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); - } - - /// 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.) - [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.Instance); - - var previous = new AddressSpaceComposition( - Array.Empty(), Array.Empty(), Array.Empty()) - { - 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(), Array.Empty()) - { - 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(); - } - - /// F10b fallback — a sink that does NOT implement cannot - /// apply the in-place update, so even a surgical-eligible (Writable-only) tag delta drives a full - /// rebuild (safe default). - [Fact] - public void Surgical_eligible_delta_on_non_surgical_sink_rebuilds() - { - var sink = new PlainRecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.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); - } - - /// F10b fallback — when the surgical sink reports the node MISSING - /// (UpdateTagAttributes returns false), the applier falls back to a full rebuild. The surgical - /// call is still attempted (recorded once) before the fallback fires. - [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.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) ----- /// 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 ----- - /// Two tags added under the SAME equipment (no FolderPath) dedup to ONE announcement for the - /// equipment id. - [Fact] - public void ComputeAddAnnouncements_two_tags_under_same_equipment_dedup_to_one() - { - var applier = new AddressSpaceApplier(new RecordingSink(), NullLogger.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" }); - } - - /// A tag with a FolderPath announces its SUB-FOLDER (the materialise parent), not the equipment. - [Fact] - public void ComputeAddAnnouncements_tag_with_folder_path_announces_subfolder() - { - var applier = new AddressSpaceApplier(new RecordingSink(), NullLogger.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") }); - } - /// An added scripted alarm announces its equipment folder (where its condition node parents). [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()), + new EquipmentVirtualTagPlan("vt-2", "eq-1", FolderPath: "", Name: "B", DataType: "Float", Expression: "2", DependencyRefs: Array.Empty()), + new EquipmentVirtualTagPlan("vt-3", "eq-1", FolderPath: "Diag", Name: "C", DataType: "Float", Expression: "3", DependencyRefs: Array.Empty()), }, }; @@ -1855,13 +1189,6 @@ public sealed class AddressSpaceApplierTests Array.Empty(), lines, Array.Empty(), Array.Empty(), Array.Empty()); - private static AddressSpaceComposition CompositionWithTags(params EquipmentTagPlan[] tags) => - new( - Array.Empty(), Array.Empty(), Array.Empty()) - { - EquipmentTags = tags, - }; - private static AddressSpaceComposition CompositionWithVirtualTags(params EquipmentVirtualTagPlan[] vtags) => new( Array.Empty(), Array.Empty(), Array.Empty()) @@ -1878,6 +1205,10 @@ public sealed class AddressSpaceApplierTests new( Array.Empty(), drivers, Array.Empty()); + 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()); + private static AddressSpacePlan EmptyPlan => new( Array.Empty(), Array.Empty(), Array.Empty(), Array.Empty(), Array.Empty(), Array.Empty(), diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceChangeClassifierTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceChangeClassifierTests.cs index 24507eb6..ad32ead8 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceChangeClassifierTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceChangeClassifierTests.cs @@ -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()); 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? addedAlarms = null, IReadOnlyList? removedAlarms = null, IReadOnlyList? changedAlarms = null, - IReadOnlyList? addedTags = null, - IReadOnlyList? removedTags = null, - IReadOnlyList? changedTags = null, + IReadOnlyList? addedTags = null, + IReadOnlyList? removedTags = null, IReadOnlyList? addedVtags = null, IReadOnlyList? removedVtags = null, IReadOnlyList? changedVtags = null, @@ -274,9 +218,8 @@ public sealed class AddressSpaceChangeClassifierTests removedAlarms ?? Array.Empty(), changedAlarms ?? Array.Empty()) { - AddedEquipmentTags = addedTags ?? Array.Empty(), - RemovedEquipmentTags = removedTags ?? Array.Empty(), - ChangedEquipmentTags = changedTags ?? Array.Empty(), + AddedRawTags = addedTags ?? Array.Empty(), + RemovedRawTags = removedTags ?? Array.Empty(), AddedEquipmentVirtualTags = addedVtags ?? Array.Empty(), RemovedEquipmentVirtualTags = removedVtags ?? Array.Empty(), ChangedEquipmentVirtualTags = changedVtags ?? Array.Empty(), diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpacePlannerTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpacePlannerTests.cs index 7f01b9b3..227dba03 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpacePlannerTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpacePlannerTests.cs @@ -30,31 +30,6 @@ public sealed class AddressSpacePlannerTests plan.IsEmpty.ShouldBeTrue(); } - /// 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. - [Fact] - public void Equipment_tag_only_change_yields_non_empty_plan_with_added_tag() - { - var prev = new AddressSpaceComposition( - Array.Empty(), Array.Empty(), Array.Empty()); - var next = new AddressSpaceComposition( - Array.Empty(), Array.Empty(), Array.Empty()) - { - 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(); - } - /// 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. @@ -174,42 +149,6 @@ public sealed class AddressSpacePlannerTests plan.RemovedEquipmentVirtualTags.ShouldBeEmpty(); } - /// An equipment Tag with the same id but a toggled IsArray flag (and otherwise - /// identical fields) must route to ChangedEquipmentTags. This pins that IsArray is part of - /// (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. - [Fact] - public void Same_id_with_toggled_isarray_routes_to_ChangedEquipmentTags() - { - var prev = new AddressSpaceComposition( - Array.Empty(), Array.Empty(), Array.Empty()) - { - 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(), Array.Empty(), Array.Empty()) - { - 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(); - } - /// Regression guard for structural equality on : /// 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 diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/History/HistoryUpdateTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/History/HistoryUpdateTests.cs index 12665523..1ddf9a28 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/History/HistoryUpdateTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/History/HistoryUpdateTests.cs @@ -223,8 +223,10 @@ public sealed class HistoryUpdateTests : IDisposable } /// - /// 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 errors is asserted: on the SDK-base refusal path the + /// per-node result is left for the master node manager to fill from it.) /// [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(); } + } + + /// + /// 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. + /// + [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(); } } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerBatchWriteTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerBatchWriteTests.cs index bb0c8725..92233d32 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerBatchWriteTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerBatchWriteTests.cs @@ -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). /// These tests pin the observable 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 does, and an empty batch does nothing at all. +/// its own Value / StatusCode / Timestamp, the realm travels with every item (the same s= 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 — ). /// 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 /// Bad. @@ -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). [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 @@ -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(() => nm.WriteValues(new List { @@ -187,6 +190,7 @@ public sealed class NodeManagerBatchWriteTests : IDisposable var items = new List(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)); } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerCapabilityRefreshTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerCapabilityRefreshTests.cs new file mode 100644 index 00000000..428f74a2 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerCapabilityRefreshTests.cs @@ -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; + +/// +/// Review 01 U3 — the historian capability bits were fixed at promotion / materialise time, so a +/// or +/// 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. +/// +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.Instance), + () => new OtOpcUaSdkServer(), Ct); + + public void Dispose() + { + if (Directory.Exists(_pkiRoot)) Directory.Delete(_pkiRoot, recursive: true); + } + + private sealed class StubSource : IHistorianDataSource + { + public Task ReadRawAsync(string r, DateTime s, DateTime e, uint m, CancellationToken ct) => + NullHistorianDataSource.Instance.ReadRawAsync(r, s, e, m, ct); + public Task ReadProcessedAsync(string r, DateTime s, DateTime e, TimeSpan i, HistoryAggregateType a, CancellationToken ct) => + NullHistorianDataSource.Instance.ReadProcessedAsync(r, s, e, i, a, ct); + public Task ReadAtTimeAsync(string r, IReadOnlyList t, CancellationToken ct) => + NullHistorianDataSource.Instance.ReadAtTimeAsync(r, t, ct); + public Task 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> UpdateDataAsync(string tag, HistoryUpdateMode mode, IReadOnlyList values, HistoryUpdateContext context, CancellationToken ct) => + throw new NotSupportedException(); + public Task DeleteRawAsync(string tag, DateTime startUtc, DateTime endUtc, HistoryUpdateContext context, CancellationToken ct) => + throw new NotSupportedException(); + public Task> DeleteAtTimeAsync(string tag, IReadOnlyList timesUtc, HistoryUpdateContext context, CancellationToken ct) => + throw new NotSupportedException(); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadBoundsTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadBoundsTests.cs index 8b618b9e..4875ac61 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadBoundsTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadBoundsTests.cs @@ -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"); } + /// + /// #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. + /// + [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(); + } + + /// + /// 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). + /// + [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 { 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(); + 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)); + } + /// /// #691 review: no data AND no bound found ⇒ GoodNoData 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. > 1 truncates every window bound to it first (QuestDB /// µs); the series must already be stored at that resolution. /// - private sealed class SeriesSource(IReadOnlyList series, bool reverse, bool inclusiveForwardEnd, long resolution) + private sealed class SeriesSource(IReadOnlyList 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; + /// A strict row ceiling (0 = none), so a page is sized below NumValuesPerNode (RawPageLimit). + public int MaxRawValuesPerRead => ceiling; + private DateTime Trunc(DateTime t) => new(t.Ticks - t.Ticks % resolution, DateTimeKind.Utc); public Task ReadRawAsync( diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadEventsTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadEventsTests.cs index 36679959..3df484bc 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadEventsTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadEventsTests.cs @@ -211,25 +211,21 @@ public sealed class NodeManagerHistoryReadEventsTests : IDisposable } /// 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. + /// 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. [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(); + } + + /// + /// 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 EventNotifier.HistoryRead 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. + /// + [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 HistoryReadEvents 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 _eventNotifierSources) is exercised by the - /// Events_folder_promoted_without_source_yields_BadHistoryOperationUnsupported test instead. + /// The override's own source-guard (a miss in _eventNotifierSources) is exercised by + /// The_event_override_refuses_a_notifier_with_the_bit_but_no_registered_source. /// /// [Fact] diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerLockTimeBenchmarkTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerLockTimeBenchmarkTests.cs new file mode 100644 index 00000000..7940bd09 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerLockTimeBenchmarkTests.cs @@ -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; + +/// +/// Review 01 P1 / P2 / P3 — the lock-time budget of the three teardown paths that run under the +/// manager-wide SDK Lock on the pinned publish thread, where every Read / Browse / Publish waits. +/// +/// P1: a full rebuild of a 10k-tag device folder was O(k²) per folder because +/// NodeState.RemoveChild is a linear scan + RemoveAt per leaf (~50M compares). +/// P2: removing M equipment from an N-node space scanned every map per equipment (O(M·N)). +/// P3: every surgical condition removal re-walked every condition for the Server-object +/// monitoring bookkeeping (O(C) per removal) while any i=2253 item existed. +/// +/// 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 Category=Benchmark, 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. +/// +[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}"); + + /// P1 — a rebuild that discards one device folder holding 10k tags completes within the budget. + [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(); } + } + + /// P2 — removing 200 equipment (250 signals each, 50k nodes) completes within the budget. + [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(); } + } + + /// + /// 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.) + /// + [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.Instance), + () => new OtOpcUaSdkServer(), Ct); + + public void Dispose() + { + if (Directory.Exists(_pkiRoot)) Directory.Delete(_pkiRoot, recursive: true); + } + + /// The minimum of a Server-object event item the all-events bookkeeping touches: identity only. + 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 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(); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerMaxReturnDataValuesSyncTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerMaxReturnDataValuesSyncTests.cs new file mode 100644 index 00000000..ea47eedd --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerMaxReturnDataValuesSyncTests.cs @@ -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; + +/// +/// #736 — assigning re-syncs +/// HistoryServerCapabilities/MaxReturnDataValues (i=11273) to the source's +/// , read back the way a client would. The Null +/// source (the historian-disabled default) advertises Part 11's "no limit". +/// +public sealed class NodeManagerMaxReturnDataValuesSyncTests(BoundsServerFixture fx) : IClassFixture +{ + [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 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 ReadRawAsync(string r, DateTime s, DateTime e, uint m, CancellationToken ct) => + NullHistorianDataSource.Instance.ReadRawAsync(r, s, e, m, ct); + public Task ReadProcessedAsync(string r, DateTime s, DateTime e, TimeSpan i, HistoryAggregateType a, CancellationToken ct) => + NullHistorianDataSource.Instance.ReadProcessedAsync(r, s, e, i, a, ct); + public Task ReadAtTimeAsync(string r, IReadOnlyList t, CancellationToken ct) => + NullHistorianDataSource.Instance.ReadAtTimeAsync(r, t, ct); + public Task 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() { } + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerPhantomNodeTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerPhantomNodeTests.cs new file mode 100644 index 00000000..821f0dab --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerPhantomNodeTests.cs @@ -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; + +/// +/// Review 01 L1 / L4 / H2 — the node manager never materialises a node from a VALUE or ALARM write, and +/// never lets one (realm, id) be held by two different node kinds. +/// +/// The lazy create-on-first-write path predates and had +/// no production producer left — but it had a production consumer by accident: a driver publish +/// (or the UNS fan-out) racing a surgical remove re-created the removed id as a flat, read-only, +/// BaseDataType variable under the shared root, and because EnsureVariable early-returns +/// on an existing id, a later re-add of the same tag silently kept the phantom's shape. +/// +/// +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}"); + + /// A value write to an id nothing declared creates nothing. + [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(); } + } + + /// + /// 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. + /// + [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(); } + } + + /// + /// 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. + /// + [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(); } + } + + /// A condition write for an id that was never materialised is a logged no-op (L4): no placeholder variable. + [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(); + } + finally { await host.DisposeAsync(); } + } + + /// H2 — a variable may not take an id a folder already holds in the same realm. + [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(); } + } + + /// H2 — a folder may not take an id a variable already holds in the same realm. + [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(); } + } + + /// H2 — a condition may not take an id a variable already holds in the same realm. + [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(); } + } + + /// The same bare id in the OTHER realm is a different node and is still allowed (the guard is realm-qualified). + [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.Instance), + () => new OtOpcUaSdkServer(), Ct); + + public void Dispose() + { + if (Directory.Exists(_pkiRoot)) Directory.Delete(_pkiRoot, recursive: true); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerStaleMonitoredNodeTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerStaleMonitoredNodeTests.cs new file mode 100644 index 00000000..5ddd6b71 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerStaleMonitoredNodeTests.cs @@ -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; + +/// +/// Review 01 L2, the half the wire tests (StaleMonitoredNodeWireTests) 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. +/// +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}"); + + /// A surgical remove tells a stood-down item and drops it from the revalidation map. + [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(); } + } + + /// A rebuild sweeps every stood-down item. + [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(); } + } + + /// + /// 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. + /// + [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(); } + } + + /// + /// Review fix round 1 (minor): the same-id kind swap (scripted ⇄ native) in MaterialiseAlarmCondition + /// 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. + /// + [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(); } + } + + /// Registers a stub item on and stands it down through a denying pass. + 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>()); + 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.Instance), + () => new OtOpcUaSdkServer(), Ct); + + public void Dispose() + { + if (Directory.Exists(_pkiRoot)) Directory.Delete(_pkiRoot, recursive: true); + } + + /// The minimum of a sampled item the removal path touches: its handle, identity and value queue. + private sealed class StubItem(NodeHandle handle, IUserIdentity identity) : ISampledDataChangeMonitoredItem + { + public List 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 notifications, Queue 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(); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerWriteDispatchTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerWriteDispatchTests.cs new file mode 100644 index 00000000..404a8550 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerWriteDispatchTests.cs @@ -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; + +/// +/// Review 01 L3 / H5 / H4 — the device-write dispatch behind +/// and the manager's clock: a gateway that throws synchronously is refused inline (the handler runs under +/// the SDK Lock, 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 +/// . +/// +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}"); + + /// H5 — a synchronous throw out of the gateway is a Bad result, never an escaped exception. + [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(); } + } + + /// L3 — a faulted dispatch task is observed and the optimistic value is reverted with its reason. + [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(); } + } + + /// Positive control for the two above: a gateway that succeeds leaves the optimistic value standing. + [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(); } + } + + /// H4 — the revert stamps the manager's clock, not the wall clock. + [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.Instance), + () => new OtOpcUaSdkServer(), Ct); + + public void Dispose() + { + if (Directory.Exists(_pkiRoot)) Directory.Delete(_pkiRoot, recursive: true); + } + + private sealed class ThrowingGateway : IOpcUaNodeWriteGateway + { + public Task WriteAsync(string nodeId, object? value, AddressSpaceRealm realm, CancellationToken ct) => + throw new InvalidOperationException("boom"); + } + + private sealed class FaultingGateway : IOpcUaNodeWriteGateway + { + public Task WriteAsync(string nodeId, object? value, AddressSpaceRealm realm, CancellationToken ct) => + Task.FromException(new TimeoutException("gateway ask timed out")); + } + + private sealed class SucceedingGateway : IOpcUaNodeWriteGateway + { + public Task 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); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/OpcUaApplicationHostQuotaTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/OpcUaApplicationHostQuotaTests.cs new file mode 100644 index 00000000..0809e916 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/OpcUaApplicationHostQuotaTests.cs @@ -0,0 +1,74 @@ +using Opc.Ua; +using Shouldly; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; + +/// Review 01 P6 — the SDK request and transport quotas are configurable (OpcUa:Quotas:*) and +/// default to exactly what used to be hard-coded. +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)); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/OtOpcUaNodeManagerWriteStatusesTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/OtOpcUaNodeManagerWriteStatusesTests.cs index 6de7a0c2..8cf7211e 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/OtOpcUaNodeManagerWriteStatusesTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/OtOpcUaNodeManagerWriteStatusesTests.cs @@ -19,8 +19,8 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; /// /// Never creates. 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 , -/// which lazily creates. +/// is skipped, not materialised — the same contract +/// carries since review 01 L1 (it used to lazily create). /// /// Boots a real , like /// , 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); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/SdkAddressSpaceSinkTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/SdkAddressSpaceSinkTests.cs index 932e1f95..0611c42f 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/SdkAddressSpaceSinkTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/SdkAddressSpaceSinkTests.cs @@ -20,18 +20,25 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable Path.GetTempPath(), $"otopcua-sink-{Guid.NewGuid():N}"); - /// Verifies that WriteValue creates and updates variables in the OPC UA node manager. + /// Verifies that WriteValue updates declared variables in the OPC UA node manager (and never + /// creates one — review 01 L1, pinned by ). [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 { @@ -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(() => sink.WriteValues(new List { @@ -113,18 +124,22 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable } } - /// Verifies that WriteAlarmCondition (un-materialised fallback) creates a dedicated node - /// distinct from value writes. + /// 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. [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(); } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/VariableShapeTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/VariableShapeTests.cs new file mode 100644 index 00000000..52e6daff --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/VariableShapeTests.cs @@ -0,0 +1,64 @@ +using Shouldly; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; + +/// +/// Lane B review N4 — 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. +/// +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().ShouldBe(5); + new VariableShape("Double", false, null).TryFit((short)3, out var d).ShouldBeTrue(); + d.ShouldBeOfType().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().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"); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorApplyFailureTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorApplyFailureTests.cs index 8d1fd67e..6c07ca0f 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorApplyFailureTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorApplyFailureTests.cs @@ -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.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 Artifacts = new(); + + private static OpcUaPublishActor.RebuildAddressSpace Rebuild(Guid deploymentId) => + new(CorrelationId.NewId(), new DeploymentId(deploymentId), Artifacts[deploymentId]); + private static Guid SeedEquipmentDeployment(IDbContextFactory 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 { diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorDeferredWriteTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorDeferredWriteTests.cs new file mode 100644 index 00000000..2ab304a8 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorDeferredWriteTests.cs @@ -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; + +/// +/// 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. +/// +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(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(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(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(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(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.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(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(PlanBudget); + var replayed = sink.ReplayedItems; + replayed.ShouldBe(1); + + // A later plan replays only what arrived during IT. + actor.Tell(Rebuild(RawTagArtifact(3))); + ExpectMsg(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.Instance), + new OpcUaPublishActor.TestHooks { ParseAuthority = (_, _) => throw new InvalidOperationException("bad grants") }, + dataPlaneAuthority: authority)); + + actor.Tell(Rebuild(RawTagArtifact(2))); + + var completed = ExpectMsg(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.Instance), + hooks: null, dataPlaneAuthority: authority)); + healthy.Tell(Rebuild(RawTagArtifact(2))); + ExpectMsg(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(PlanBudget); + var b = ExpectMsg(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.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(); + + 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(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(PlanBudget).Degraded.ShouldBeFalse(); + sink.Exists(NewTag).ShouldBeTrue(); + } + + // ───────────────────── fix round 2: N1 / N2 / M1 / M4 / M5 ───────────────────── + + /// 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). + [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(PlanBudget); + + sink.AlarmQualityOf("al-1").ShouldBe(OpcUaStatus.Good, "the later snapshot's quality is what live would leave"); + sink.AlarmStateOf("al-1")!.Active.ShouldBeTrue(); + } + + /// N1 positive control — the reverse order: a quality-only write AFTER the state still lands last. + [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(PlanBudget); + + sink.AlarmQualityOf("al-1").ShouldBe(OpcUaStatus.BadNoCommunication); + } + + /// + /// 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). + /// + [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(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(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"); + } + + /// 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. + [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(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(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"); + } + + /// M1 — a FULL rebuild recreates every variable, so every deferred value replays (the live write landed + /// on a node the rebuild discarded). + [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(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(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"); + } + + /// 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. + [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(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(PlanBudget).Degraded.ShouldBeTrue(); + sink.ReplayedItems.ShouldBe(replayedBefore, "nothing was applied, so nothing is replayed"); + + actor.Tell(Rebuild(RawTagArtifact(3))); + ExpectMsg(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"); + } + + /// 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. + [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.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(); + + var inFlight = Rebuild(RawTagArtifact(2)); + var queued = Rebuild(RawTagArtifact(3)); + child.Tell(inFlight); + child.Tell(queued); + child.Tell(Kill.Instance); + + var first = ExpectMsg(PlanBudget); + first.Correlation.ShouldBe(queued.Correlation, "the queued requester is answered at the restart"); + first.Degraded.ShouldBeTrue(); + + gate.Set(); + var second = ExpectMsg(PlanBudget); + second.Correlation.ShouldBe(inFlight.Correlation); + second.Degraded.ShouldBeTrue(); + } + + + /// + /// 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). + /// + [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(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(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(); + } + + /// + /// 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. + /// + [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(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(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"); + } + + /// + /// 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. + /// + [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(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(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().ShouldBe(5); + sink.ValueOf(UnsSpeed).ShouldBe(raw, "the raw node and its UNS mirror agree"); + } + + /// 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. + [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(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(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"; + + /// One raw tag (Speed) of and one UNS reference to it under Area/Line/Eq. + 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(), + 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(), + }); + + 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.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(), + UnsLines = Array.Empty(), + Equipment = Array.Empty(), + 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(), + 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(), + ScriptedAlarms = Array.Empty(), + }); + + 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() + : new object[] { new { RawFolderId = "rf-1", ParentRawFolderId = (string?)null, Name = "Plant", ClusterId = "c1" } }, + DriverInstances = rawTags == 0 && !nativeAlarm + ? Array.Empty() + : new object[] { new { DriverInstanceId = "drv-1", DriverType = "Modbus", DriverConfig = "{}", Name = "Modbus", RawFolderId = "rf-1", ClusterId = "c1" } }, + Devices = rawTags == 0 && !nativeAlarm + ? Array.Empty() + : new object[] { new { DeviceId = "dev-1", DriverInstanceId = "drv-1", Name = "dev1", DeviceConfig = "{}" } }, + TagGroups = Array.Empty(), + 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()).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;" } }, + }); + + /// Restarts its one child on any failure, and lets the test reach the child. + 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(_ => Sender.Tell(child)); + } + + protected override SupervisorStrategy SupervisorStrategy() => + new OneForOneStrategy(_ => Directive.Restart); + } + + /// An authority sink that counts installs. + private sealed class RecordingAuthoritySink : IDataPlaneAuthoritySink + { + private int _applies; + public int Applies => Volatile.Read(ref _applies); + + public DataPlaneAuthoritySnapshot Apply( + string? clusterId, string revision, IReadOnlyList keys, + IReadOnlyList grants, + IReadOnlyDictionary> scopePaths) + { + Interlocked.Increment(ref _applies); + return new DataPlaneAuthorityState().Apply(clusterId, revision, keys, grants, scopePaths); + } + } + + /// + /// 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. 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. + /// + private sealed class ExistenceSink : IOpcUaAddressSpaceSink, ISurgicalAddressSpaceSink + { + private readonly object _gate = new(); + private readonly HashSet _variables = new(); + private readonly HashSet _conditions = new(); + private readonly Dictionary _values = new(); + private readonly Dictionary _alarmStates = new(); + private readonly Dictionary _alarmQualities = new(); + + public ConcurrentQueue SkippedWrites { get; } = new(); + private readonly Dictionary _alarmWrites = new(); + private volatile bool _sawRebuild; + + /// Whether any apply called (the full-rebuild shape). + 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); } + } + + /// What an SDK-applied Part 9 Acknowledge does: mutates the condition directly, no sink write. + public void AcknowledgeOutOfBand(string id) + { + lock (_gate) _alarmStates[id] = _alarmStates[id] with { Acknowledged = true }; + } + public ConcurrentQueue 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 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 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 _nativeConditions = new(); + private readonly Dictionary _shapes = new(); + public ConcurrentQueue 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 notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { } + public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { } + /// OtOpcUaNodeManager.UpdateTagAttributes: a SHAPE change (DataType / array-ness / length) drops the + /// node's value (BadWaitingForInitialData); any other attribute edit leaves it. + 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; + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorOffThreadPlanTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorOffThreadPlanTests.cs new file mode 100644 index 00000000..734edbba --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorOffThreadPlanTests.cs @@ -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; + +/// +/// 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. +/// +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.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(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.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(TimeSpan.FromSeconds(120)); + var second = ExpectMsg(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.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(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(), + UnsLines = Array.Empty(), + Equipment = Array.Empty(), + 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(), + 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(), + ScriptedAlarms = Array.Empty(), + }); + + private sealed class OrderedSink : IOpcUaAddressSpaceSink, ISurgicalAddressSpaceSink + { + public ConcurrentQueue 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 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 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; } + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorRebuildTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorRebuildTests.cs index 2e0d239d..fd05748c 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorRebuildTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorRebuildTests.cs @@ -41,14 +41,14 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase var sink = new RecordingSink(); var applier = new AddressSpaceApplier(sink, NullLogger.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.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.Instance); - SeedDeployment(db, equipmentIds: new[] { "eq-1" }, driverIds: Array.Empty()); + var dep = SeedDeployment(db, equipmentIds: new[] { "eq-1" }, driverIds: Array.Empty()); 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. /// [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.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.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 /// . /// - private static void SeedMultiClusterDeployment(IDbContextFactory dbFactory) + private static Guid SeedMultiClusterDeployment(IDbContextFactory dbFactory) { var artifact = JsonSerializer.SerializeToUtf8Bytes(new { @@ -358,17 +358,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase ScriptedAlarms = Array.Empty(), }); - 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); } /// 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.Instance); - SeedDeployment(db, equipmentIds: new[] { "eq-1" }, driverIds: Array.Empty()); + var dep = SeedDeployment(db, equipmentIds: new[] { "eq-1" }, driverIds: Array.Empty()); 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 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 dbFactory, string[] equipmentIds, string[] driverIds) @@ -575,17 +551,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase ScriptedAlarms = Array.Empty(), }); - 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); } /// 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(), }); - 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); } + /// 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). + private static readonly System.Collections.Concurrent.ConcurrentDictionary Artifacts = new(); + + /// The rebuild message a producer sends for deployment : its id AND its bytes. + private static OpcUaPublishActor.RebuildAddressSpace Rebuild(Guid deploymentId) => + new(CorrelationId.NewId(), new DeploymentId(deploymentId), Artifacts[deploymentId]); + /// Seal as a new deployment and return its id. private static Guid SealArtifact(IDbContextFactory dbFactory, byte[] artifact) { var id = Guid.NewGuid(); + Artifacts[id] = artifact; using var ctx = dbFactory.CreateDbContext(); ctx.Deployments.Add(new Deployment {