diff --git a/docs/requirements/Component-DataConnectionLayer.md b/docs/requirements/Component-DataConnectionLayer.md index e0c166f8..b6b7ded9 100644 --- a/docs/requirements/Component-DataConnectionLayer.md +++ b/docs/requirements/Component-DataConnectionLayer.md @@ -399,6 +399,7 @@ Subscription demand survives connection transitions through coalescing and owner The DCL manages connection lifecycle automatically: 1. **Connection drop detection**: When a connection to a data source is lost, the DCL immediately pushes a value update with quality `bad` for **every tag subscribed on that connection**. Instance Actors see the staleness immediately and publish it to the site stream / debug view; **script and alarm actors are deliberately NOT re-notified on a quality-only change** (no value changed — re-evaluating triggers would cause spurious firings). Scripts observe quality on their next attribute read; a computed alarm holds its last-known state until fresh values arrive. + **Per-tag Bad with no value is handled the same way (ScadaBridge#112).** A tag update whose quality is `bad` and whose value is null — OPC UA servers commonly omit the value on a Bad status, and OtOpcUa always does — is a quality-only transition at the Instance Actor: the last-known value is kept, quality goes `Bad`, the stream/debug view is told, scripts/alarms/`WaitForAttribute` waiters are not re-evaluated, and the next Good update recovers it. A Bad update that carries a value still stores that value. This covers OtOpcUa's `BadResourceUnavailable` (`0x80040000`, its out-of-process script runner's "worker restarted / runner unavailable" on virtual and Calculation tags, lmxopcua#780): the OPC UA adapter treats it as a plain per-node Bad — never a connection fault (it is not in the DataConnectionActor's session/secure-channel reconnect set), no subscription teardown — logs one Information line per tag on entry and one on recovery, and names it in read errors (`0x80040000 BadResourceUnavailable (source node temporarily unable to compute the value)`; every other Bad read error now carries its symbolic name too). 2. **Auto-reconnect with fixed interval**: The DCL retries the connection at a configurable fixed interval (e.g., every 5 seconds). The retry interval is a **shared setting for all data connections** (`ReconnectInterval` in the Shared Settings table). This is consistent with the fixed-interval retry philosophy used throughout the system. Individual gRPC/OPC UA operations (reads, writes) fail immediately to the caller on error; there is no operation-level retry within the adapter. 3. **Connection state transitions**: The DCL tracks each connection's state as `connected`, `disconnected`, or `reconnecting`. All transitions are logged to Site Event Logging (event type `connection`): the initial connect (`Connection established on … endpoint`, Info — added by review 2026-10-21, it previously logged nothing), connection lost / reconfigured (`Warning` / `Info`), restored on reconnect (`Info`) and failover (`Warning`). 4. **Transparent re-subscribe**: On successful reconnection, the DCL automatically re-establishes all previously active subscriptions for that connection. Instance Actors require no action — they simply see quality return to `good` as fresh values arrive from restored subscriptions. diff --git a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/OpcUaDataConnection.cs b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/OpcUaDataConnection.cs index 90b8935f..4252a900 100644 --- a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/OpcUaDataConnection.cs +++ b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/OpcUaDataConnection.cs @@ -1,3 +1,4 @@ +using System.Collections.Concurrent; using Microsoft.Extensions.Logging; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol; using ZB.MOM.WW.ScadaBridge.Commons.Serialization; @@ -41,6 +42,14 @@ public class OpcUaDataConnection private StaleTagMonitor? _staleMonitor; private string? _heartbeatSubscriptionId; + /// + /// Tags whose latest notification is BadResourceUnavailable (ScadaBridge#112), so the + /// distinct "source temporarily unable to compute" log line fires once per entry and once per + /// recovery rather than per notification. Written from the SDK publish threads, hence + /// concurrent (DCL-019). Bounded by the subscribed tag set; cleared on (re)connect. + /// + private readonly ConcurrentDictionary _resourceUnavailableTags = new(); + /// /// Initializes a new instance of . /// @@ -99,6 +108,7 @@ public class OpcUaDataConnection MaxMonitoredItemsPerSubscription: config.MaxMonitoredItemsPerSubscription); _status = ConnectionHealth.Connecting; + _resourceUnavailableTags.Clear(); // Reconnect reuses this adapter instance (DataConnectionActor re-calls // ConnectAsync on auto-reconnect): detach + dispose the previous client so @@ -191,6 +201,7 @@ public class OpcUaDataConnection (nodeId, value, timestamp, statusCode) => { var quality = MapStatusCode(statusCode); + TrackResourceUnavailable(tagPath, statusCode); callback(tagPath, new TagValue(value, quality, new DateTimeOffset(timestamp, TimeSpan.Zero))); }, cancellationToken); @@ -209,6 +220,7 @@ public class OpcUaDataConnection (nodeId, value, timestamp, statusCode) => { var quality = MapStatusCode(statusCode); + TrackResourceUnavailable(nodeId, statusCode); callback(nodeId, new TagValue(value, quality, new DateTimeOffset(timestamp, TimeSpan.Zero))); }, cancellationToken); @@ -267,7 +279,7 @@ public class OpcUaDataConnection var quality = MapStatusCode(statusCode); if (quality == QualityCode.Bad) - return new ReadResult(false, null, $"OPC UA read returned bad status: 0x{statusCode:X8}"); + return new ReadResult(false, null, DescribeBadRead(statusCode)); return new ReadResult(true, new TagValue(value, quality, new DateTimeOffset(timestamp, TimeSpan.Zero)), null); } @@ -306,7 +318,7 @@ public class OpcUaDataConnection var quality = MapStatusCode(outcome.StatusCode); results[outcome.NodeId] = quality == QualityCode.Bad - ? new ReadResult(false, null, $"OPC UA read returned bad status: 0x{outcome.StatusCode:X8}") + ? new ReadResult(false, null, DescribeBadRead(outcome.StatusCode)) : new ReadResult(true, new TagValue(outcome.Value, quality, new DateTimeOffset(outcome.SourceTimestamp, TimeSpan.Zero)), null); @@ -461,6 +473,60 @@ public class OpcUaDataConnection Disconnected?.Invoke(); } + /// + /// OPC UA BadResourceUnavailable — the code part (high 16 bits) of + /// . OtOpcUa's out-of-process script + /// runner publishes it on script-computed nodes when the script worker died under the + /// evaluation or no worker is ready (ScadaBridge#112, lmxopcua#780). It is a per-node, + /// transient "cannot compute this value right now": a plain Bad quality on the tag — never a + /// connection fault, so it must not reach RaiseDisconnected or the connection-level + /// status set the DataConnectionActor reconnects on. + /// + internal const uint BadResourceUnavailableCode = Opc.Ua.StatusCodes.BadResourceUnavailable; + + /// True when 's code part is BadResourceUnavailable + /// (the low 16 bits are info bits and do not change the meaning). + internal static bool IsResourceUnavailable(uint statusCode) => + (statusCode & 0xFFFF0000u) == BadResourceUnavailableCode; + + /// Read-failure text naming the symbolic status, e.g. + /// 0x80040000 BadResourceUnavailable, so a script/diagnostic reader can tell a + /// transient source-side condition from a configuration or security error. + internal static string DescribeBadRead(uint statusCode) + { + var name = Opc.Ua.StatusCodes.LookupSymbolicId(statusCode & 0xFFFF0000u); + var text = string.IsNullOrEmpty(name) + ? $"OPC UA read returned bad status: 0x{statusCode:X8}" + : $"OPC UA read returned bad status: 0x{statusCode:X8} {name}"; + return IsResourceUnavailable(statusCode) + ? text + " (source node temporarily unable to compute the value)" + : text; + } + + /// + /// Logs the entry into / recovery from BadResourceUnavailable once per transition per + /// tag. Hot path: a tag that is not and was not unavailable costs one status compare and an + /// empty-dictionary check. + /// + private void TrackResourceUnavailable(string tagPath, uint statusCode) + { + if (IsResourceUnavailable(statusCode)) + { + if (_resourceUnavailableTags.TryAdd(tagPath, 0)) + _logger.LogInformation( + "OPC UA source {Endpoint} reports BadResourceUnavailable (0x{Status:X8}) for {TagPath} — " + + "the source node is temporarily unable to compute this value; tag quality is Bad, " + + "last-known value kept, connection unaffected", + _endpointUrl, statusCode, tagPath); + } + else if (!_resourceUnavailableTags.IsEmpty && _resourceUnavailableTags.TryRemove(tagPath, out _)) + { + _logger.LogInformation( + "OPC UA source {Endpoint} recovered {TagPath} from BadResourceUnavailable (now 0x{Status:X8})", + _endpointUrl, tagPath, statusCode); + } + } + /// /// Maps OPC UA StatusCode to QualityCode. /// StatusCode 0 = Good, high bit set = Bad, otherwise Uncertain. diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs index a4244972..21de043f 100644 --- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs @@ -1223,6 +1223,23 @@ public class InstanceActor : ReceiveActor traced = true; } + // Bad quality WITHOUT a value (ScadaBridge#112) is a quality-only transition: the + // source is saying "I cannot give you a value right now", not "the value is now + // null". OPC UA servers commonly omit the value on a Bad status — OtOpcUa always + // does, e.g. BadResourceUnavailable (0x80040000) from its out-of-process script + // runner — and the DCL's own tag-resolution failure pushes Bad+null too. Treat it + // exactly like a connection drop (HandleConnectionQualityChanged, DCL design + // "Connection drop detection"): keep the last-known value, flip quality to Bad, + // publish to the stream, and do NOT re-notify scripts/alarms or evaluate waiters — + // nulling the stored value would fire value-change scripts with a null and make a + // computed alarm re-evaluate on a value that never existed. A Bad update that DOES + // carry a value still takes the normal path below. + if (update.Quality == QualityCode.Bad && update.Value is null) + { + ApplyQualityOnlyBad(attrName, update.TagPath, update.Timestamp, traceSource, traced); + continue; + } + // A List-typed attribute coerces the incoming OPC UA array // (a CLR array/IEnumerable from the SDK) into a typed List. On an // element-type mismatch we set the attribute's quality to Bad, log a @@ -1271,6 +1288,22 @@ public class InstanceActor : ReceiveActor } } + /// + /// Marks a data-sourced attribute Bad while keeping its last-known value (ScadaBridge#112): + /// quality + timestamp move, the stored value does not, and only the site stream / debug + /// view is told — mirroring . + /// + private void ApplyQualityOnlyBad( + string attrName, string tagPath, DateTimeOffset timestamp, Guid? traceSource, bool traced) + { + _attributeQualities[attrName] = "Bad"; + _attributeTimestamps[attrName] = timestamp; + _attributes.TryGetValue(attrName, out var currentValue); + _streamManager?.PublishAttributeValueChanged(new AttributeValueChanged( + _instanceUniqueName, tagPath, attrName, currentValue, "Bad", timestamp, + SourceExecutionId: traceSource, Trace: traced, SourceExecutionInferred: traced)); + } + /// Parses a string, defaulting to /// on an unrecognized value (never throws). private static DataType ParseDataType(string dataType) => diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/OpcUaResourceUnavailableTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/OpcUaResourceUnavailableTests.cs new file mode 100644 index 00000000..e1b009f7 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/OpcUaResourceUnavailableTests.cs @@ -0,0 +1,134 @@ +using System.Collections.Concurrent; +using System.Reflection; +using Microsoft.Extensions.Logging; +using NSubstitute; +using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; +using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Actors; +using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters; + +namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Adapters; + +/// +/// ScadaBridge#112: OtOpcUa's out-of-process script runner publishes +/// BadResourceUnavailable (0x80040000) — with a null value — on script-computed nodes +/// when its worker died or is not ready. The OPC UA adapter must treat it as a plain, transient +/// Bad quality on the tag: no disconnect, no subscription teardown, distinct log lines on entry +/// and recovery, and a read error that names the status. +/// +public class OpcUaResourceUnavailableTests +{ + private const uint BadResourceUnavailable = 0x80040000u; + private const string Tag = "nsu=https://zb.com/otopcua/uns;s=Line1.Pump.Calc"; + + private sealed class CapturingLogger : ILogger + { + public ConcurrentQueue<(LogLevel Level, string Message)> Entries { get; } = new(); + public IDisposable? BeginScope(TState state) where TState : notnull => null; + public bool IsEnabled(LogLevel logLevel) => true; + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, + Func formatter) => Entries.Enqueue((logLevel, formatter(state, exception))); + } + + /// + /// Good / BadResourceUnavailable(null) / Good on a monitored item maps to Good / Bad / Good + /// tag quality, never raises Disconnected, never removes the subscription, and logs the + /// unavailable entry once (not per notification) plus one recovery line. + /// + [Fact] + public async Task Subscription_GoodResourceUnavailableGood_IsTransientBadQuality_NotAConnectionFault() + { + var client = Substitute.For(); + var factory = Substitute.For(); + factory.Create().Returns(client); + client.IsConnected.Returns(true); + + Action? publish = null; + client.CreateSubscriptionAsync(Tag, Arg.Do>(cb => publish = cb), + Arg.Any()) + .Returns("sub-1"); + + var logger = new CapturingLogger(); + var adapter = new OpcUaDataConnection(factory, logger); + var disconnected = 0; + adapter.Disconnected += () => Interlocked.Increment(ref disconnected); + + await adapter.ConnectAsync(new Dictionary()); + var received = new List(); + await adapter.SubscribeAsync(Tag, (_, v) => received.Add(v)); + Assert.NotNull(publish); + + publish!(Tag, 42.0, DateTime.UtcNow, 0u); + publish(Tag, null, DateTime.UtcNow, BadResourceUnavailable); + publish(Tag, null, DateTime.UtcNow, BadResourceUnavailable); // repeated: no second log line + publish(Tag, 43.0, DateTime.UtcNow, 0u); + + Assert.Equal( + new[] { QualityCode.Good, QualityCode.Bad, QualityCode.Bad, QualityCode.Good }, + received.Select(v => v.Quality).ToArray()); + Assert.Null(received[1].Value); + Assert.Equal(43.0, received[3].Value); + + Assert.Equal(0, disconnected); + Assert.Equal(ConnectionHealth.Connected, adapter.Status); + await client.DidNotReceiveWithAnyArgs().RemoveSubscriptionAsync(default!, default); + await client.DidNotReceiveWithAnyArgs().DisconnectAsync(default); + + var unavailable = logger.Entries.Where(e => e.Message.Contains("reports BadResourceUnavailable")).ToList(); + var recovered = logger.Entries.Where(e => e.Message.Contains("recovered") && e.Message.Contains(Tag)).ToList(); + Assert.Single(unavailable); + Assert.Contains(Tag, unavailable[0].Message); + Assert.Single(recovered); + } + + /// A read that returns BadResourceUnavailable fails the read without a disconnect and + /// names the symbolic status so it reads as a transient source-side condition. + [Fact] + public async Task Read_ResourceUnavailable_FailsReadWithNamedStatus_NoDisconnect() + { + var client = Substitute.For(); + var factory = Substitute.For(); + factory.Create().Returns(client); + client.IsConnected.Returns(true); + client.ReadValueAsync(Tag, Arg.Any()) + .Returns(((object?)null, DateTime.UtcNow, BadResourceUnavailable)); + + var adapter = new OpcUaDataConnection(factory, new CapturingLogger()); + var disconnected = 0; + adapter.Disconnected += () => disconnected++; + await adapter.ConnectAsync(new Dictionary()); + + var result = await adapter.ReadAsync(Tag); + + Assert.False(result.Success); + Assert.Contains("0x80040000", result.ErrorMessage); + Assert.Contains("BadResourceUnavailable", result.ErrorMessage); + Assert.Contains("temporarily unable", result.ErrorMessage); + Assert.Equal(0, disconnected); + Assert.Equal(ConnectionHealth.Connected, adapter.Status); + } + + /// Other Bad reads still get the symbolic name but not the transient wording. + [Fact] + public void DescribeBadRead_OtherCodes_NamedWithoutTransientWording() + { + var text = OpcUaDataConnection.DescribeBadRead(0x80340000u); + Assert.Contains("BadNodeIdUnknown", text); + Assert.DoesNotContain("temporarily", text); + } + + /// + /// BadResourceUnavailable is a per-node status: the DataConnectionActor must never classify it + /// as a session/secure-channel failure, which would drive a reconnect and a subscription rebuild. + /// + [Fact] + public void ResourceUnavailable_IsNotConnectionLevelStatus() + { + var method = typeof(DataConnectionActor).GetMethod("IsConnectionLevelStatus", + BindingFlags.NonPublic | BindingFlags.Static); + Assert.NotNull(method); + Assert.False((bool)method!.Invoke(null, new object[] { BadResourceUnavailable })!); + // Positive control: a genuine session code IS connection-level. + Assert.True((bool)method.Invoke(null, new object[] { 0x80250000u /* BadSessionIdInvalid */ })!); + } +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorTests.cs index c2a960bc..1351c0a2 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorTests.cs @@ -768,6 +768,92 @@ public class InstanceActorTests : TestKit, IDisposable Assert.Equal(1450, response.Value); } + /// + /// ScadaBridge#112: a Bad-quality update that carries NO value (OtOpcUa sends a null Value + /// with every Bad status, e.g. BadResourceUnavailable from its out-of-process script runner) + /// is a quality-only transition — the last-known value survives, quality goes Bad, waiters + /// and children are not re-evaluated against a null that never existed, and the next Good + /// update recovers the attribute. Pins the Good / Bad(null) / Good sequence. + /// + [Fact] + public void InstanceActor_BadQualityWithNullValue_KeepsLastKnownValue_AndRecoversOnGood() + { + const string tag = "nsu=https://zb.com/otopcua/uns;s=Line1.Pump.Calc"; + var config = new FlattenedConfiguration + { + InstanceUniqueName = "Pump-BadNull", + Attributes = + [ + new ResolvedAttribute + { + CanonicalName = "Calc", Value = "0", DataType = "Int32", + DataSourceReference = tag, BoundDataConnectionName = "OtOpcUa" + } + ] + }; + + var dcl = CreateTestProbe(); + var actor = CreateInstanceActorWithDcl("Pump-BadNull", config, dcl); + + actor.Tell(new TagValueUpdate("OtOpcUa", tag, 1450, QualityCode.Good, DateTimeOffset.UtcNow)); + + // A waiter that would fire if the stored value were ever nulled. + actor.Tell(new WaitForAttributeRequest( + "wfa-null", "Pump-BadNull", "Calc", + null, v => v is null, TimeSpan.FromSeconds(30), DateTimeOffset.UtcNow)); + ExpectNoMsg(TimeSpan.FromMilliseconds(200)); + + // Source goes BadResourceUnavailable: Bad quality, null value. + actor.Tell(new TagValueUpdate("OtOpcUa", tag, null, QualityCode.Bad, DateTimeOffset.UtcNow)); + ExpectNoMsg(TimeSpan.FromMilliseconds(300)); + + actor.Tell(new GetAttributeRequest("corr-bad", "Pump-BadNull", "Calc", DateTimeOffset.UtcNow)); + var bad = ExpectMsg(TimeSpan.FromSeconds(5)); + Assert.True(bad.Found); + Assert.Equal("Bad", bad.Quality); + Assert.Equal(1450, bad.Value); + + // Next good evaluation recovers. + actor.Tell(new TagValueUpdate("OtOpcUa", tag, 1500, QualityCode.Good, DateTimeOffset.UtcNow)); + actor.Tell(new GetAttributeRequest("corr-good", "Pump-BadNull", "Calc", DateTimeOffset.UtcNow)); + var good = ExpectMsg(TimeSpan.FromSeconds(5)); + Assert.Equal("Good", good.Quality); + Assert.Equal(1500, good.Value); + } + + /// + /// Guard for ScadaBridge#112: a Bad update that DOES carry a value keeps the historical + /// behaviour — the carried value is stored with Bad quality. + /// + [Fact] + public void InstanceActor_BadQualityWithValue_StoresCarriedValue() + { + const string tag = "ns=3;s=Pump.BadWithValue"; + var config = new FlattenedConfiguration + { + InstanceUniqueName = "Pump-BadValue", + Attributes = + [ + new ResolvedAttribute + { + CanonicalName = "Speed", Value = "0", DataType = "Int32", + DataSourceReference = tag, BoundDataConnectionName = "PLC" + } + ] + }; + + var dcl = CreateTestProbe(); + var actor = CreateInstanceActorWithDcl("Pump-BadValue", config, dcl); + + actor.Tell(new TagValueUpdate("PLC", tag, 1450, QualityCode.Good, DateTimeOffset.UtcNow)); + actor.Tell(new TagValueUpdate("PLC", tag, 1300, QualityCode.Bad, DateTimeOffset.UtcNow)); + + actor.Tell(new GetAttributeRequest("corr-bv", "Pump-BadValue", "Speed", DateTimeOffset.UtcNow)); + var response = ExpectMsg(TimeSpan.FromSeconds(5)); + Assert.Equal("Bad", response.Quality); + Assert.Equal(1300, response.Value); + } + /// /// MV (C1 fix): a WRITE to a data-sourced DataType.List attribute must /// send the DCL a TYPED collection (so OPC UA writes an array node), NOT the