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 9a2fe15f..8f03cd75 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs @@ -581,9 +581,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 @@ -1058,7 +1072,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); @@ -1105,11 +1119,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); } @@ -1476,7 +1491,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). @@ -1538,7 +1553,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 @@ -1635,9 +1650,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, @@ -1953,7 +1968,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 } if (alwaysReport || ReadConditionDelta(alarm) != before) - ReportConditionEvent(alarm, DateTime.UtcNow); + ReportConditionEvent(alarm, UtcNow); return result; } @@ -2223,20 +2238,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; } @@ -2647,13 +2702,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. @@ -2710,7 +2766,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 @@ -5079,7 +5135,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; 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)); + } +}