diff --git a/archreview/plans/R2-08-ldap-historyread-async-plan.md.tasks.json b/archreview/plans/R2-08-ldap-historyread-async-plan.md.tasks.json index 530ed9e9..e0df2097 100644 --- a/archreview/plans/R2-08-ldap-historyread-async-plan.md.tasks.json +++ b/archreview/plans/R2-08-ldap-historyread-async-plan.md.tasks.json @@ -8,7 +8,7 @@ { "id": "R2-08-T5", "subject": "Directory-outage circuit: fail-fast deny after N consecutive system-side failures, half-open after cooldown", "status": "completed", "blockedBy": ["R2-08-T3", "R2-08-T4"] }, { "id": "R2-08-T6", "subject": "Outage-sim integration test (real library, unroutable host, via internal HandleImpersonation) + SDK m_eventLock contract doc + docs/security.md", "status": "completed", "blockedBy": ["R2-08-T4"] }, { "id": "R2-08-T7", "subject": "S3 sequential-serving repro test (RED): MaxObservedConcurrency >= 2 on an 8-node Raw batch — fails on current code", "status": "completed", "blockedBy": [] }, - { "id": "R2-08-T8", "subject": "ServerHistorianOptions: HistoryReadBatchParallelism/MaxConcurrentHistoryReadBatches/HistoryReadDeadline + Validate warnings + node-manager properties + hosted-service wiring", "status": "pending", "blockedBy": [] }, + { "id": "R2-08-T8", "subject": "ServerHistorianOptions: HistoryReadBatchParallelism/MaxConcurrentHistoryReadBatches/HistoryReadDeadline + Validate warnings + node-manager properties + hosted-service wiring", "status": "completed", "blockedBy": [] }, { "id": "R2-08-T9", "subject": "Async-ify ServeNode + Processed/AtTime arms (ct threading, OCE=>BadTimeout, behavior-neutral)", "status": "pending", "blockedBy": ["R2-08-T7", "R2-08-T8"] }, { "id": "R2-08-T10", "subject": "Async-ify ServeRawPaged (page read + tie-cluster over-fetch) + extract ServeEventsAsync", "status": "pending", "blockedBy": ["R2-08-T9"] }, { "id": "R2-08-T11", "subject": "RunBounded fan-out in all four HistoryRead arms, single bridge per arm (turns T7 green) + upper-bound + isolation tests", "status": "pending", "blockedBy": ["R2-08-T9", "R2-08-T10"] }, diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/OtOpcUaServerHostedService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/OtOpcUaServerHostedService.cs index c9b16310..fe582630 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/OtOpcUaServerHostedService.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/OtOpcUaServerHostedService.cs @@ -222,6 +222,13 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl // section is absent; NodeManager is non-null here (guarded above). _server.NodeManager.MaxTieClusterOverfetch = _serverHistorianOptions.MaxTieClusterOverfetch; + // Propagate the S3 HistoryRead concurrency bounds (arch-review 03/S3): bounded per-node fan-out + // within a batch, the process-wide concurrent-batch limiter, and the per-request deadline. Defaults + // (4 / 16 / 60 s) survive when the ServerHistorian section is absent. + _server.NodeManager.HistoryReadBatchParallelism = _serverHistorianOptions.HistoryReadBatchParallelism; + _server.NodeManager.MaxConcurrentHistoryReadBatches = _serverHistorianOptions.MaxConcurrentHistoryReadBatches; + _server.NodeManager.HistoryReadDeadline = _serverHistorianOptions.HistoryReadDeadline; + // ServiceLevel publisher needs IServerInternal — only available after Start. if (_server.CurrentInstance is { } serverInternal) { diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs index 23c08e95..ef90e691 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs @@ -197,6 +197,30 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// public int MaxTieClusterOverfetch { get; set; } = 65536; + /// + /// Maximum historized nodes served concurrently within one HistoryRead batch (arch-review 03/S3). + /// Mirrors ServerHistorianOptions.HistoryReadBatchParallelism; the Host sets it at + /// StartAsync. ≤ 1 falls back to sequential serving. The default (4) survives when the + /// ServerHistorian section is absent. + /// + public int HistoryReadBatchParallelism { get; set; } = 4; + + /// + /// 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. + /// + public int MaxConcurrentHistoryReadBatches { get; set; } = 16; + + /// + /// Per-request wall-clock deadline for a HistoryRead batch (arch-review 03/S3). A node still in + /// flight at the deadline surfaces BadTimeout. Non-positive = unbounded. Mirrors + /// ServerHistorianOptions.HistoryReadDeadline; the Host sets it at StartAsync. The + /// default (60 s) survives when the section is absent. + /// + public TimeSpan HistoryReadDeadline { get; set; } = TimeSpan.FromSeconds(60); + private volatile IHistoryContinuationStore _historyContinuationStore = new SessionHistoryContinuationStore(); /// diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ServerHistorianOptions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ServerHistorianOptions.cs index 98f4eb56..d714ccb9 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ServerHistorianOptions.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ServerHistorianOptions.cs @@ -68,6 +68,32 @@ public sealed class ServerHistorianOptions /// public int MaxTieClusterOverfetch { get; init; } = 65536; + /// + /// Maximum number of historized nodes served CONCURRENTLY within one HistoryRead batch (arch-review + /// 03/S3). The SDK's HistoryRead* override surface is synchronous, so each arm block-bridges + /// once at its boundary; this bound turns the per-node fan-out inside that boundary from sequential + /// (worst case N × CallTimeout) into ⌈N / P⌉ × CallTimeout. It also caps the gateway-load + /// multiplication a single client can command. Default 4. + /// + public int HistoryReadBatchParallelism { get; init; } = 4; + + /// + /// Process-wide limit on the number of HistoryRead batches served CONCURRENTLY (arch-review 03/S3). + /// A flood of history clients degrades to BadTooManyOperations rather than exhausting the SDK + /// request-thread pool. Together with this caps total + /// in-flight gateway reads at MaxConcurrentHistoryReadBatches × HistoryReadBatchParallelism + /// (default 16 × 4 = 64). Default 16. + /// + public int MaxConcurrentHistoryReadBatches { get; init; } = 16; + + /// + /// Per-request wall-clock deadline for a HistoryRead batch (arch-review 03/S3). A single request can + /// no longer hold an SDK request thread longer than this regardless of node count; a node still + /// in flight at the deadline surfaces BadTimeout. Non-positive = unbounded (a + /// warning). Default 60 seconds. + /// + public TimeSpan HistoryReadDeadline { get; init; } = TimeSpan.FromSeconds(60); + /// Returns operator-facing misconfiguration warnings for an Enabled historian /// (empty when disabled or correctly configured). Pure — the registration logs each entry. /// @@ -100,6 +126,14 @@ public sealed class ServerHistorianOptions // so a zero/negative value is harmless (and noise-free) when the historian is disabled. if (MaxTieClusterOverfetch <= 0) warnings.Add($"ServerHistorian:MaxTieClusterOverfetch is {MaxTieClusterOverfetch} — must be > 0; HistoryRead-Raw cannot page within an oversized tie cluster and will surface BadHistoryOperationUnsupported for those reads."); + // S3 HistoryRead concurrency knobs — a non-positive value disables the corresponding bound, so warn + // (these gate the per-node fan-out / batch limiter that only run when a real data source is wired). + if (HistoryReadBatchParallelism <= 0) + warnings.Add($"ServerHistorian:HistoryReadBatchParallelism is {HistoryReadBatchParallelism} — must be > 0; HistoryRead falls back to serving each node sequentially."); + if (MaxConcurrentHistoryReadBatches <= 0) + warnings.Add($"ServerHistorian:MaxConcurrentHistoryReadBatches is {MaxConcurrentHistoryReadBatches} — must be > 0; the process-wide HistoryRead batch limiter is disabled."); + if (HistoryReadDeadline <= TimeSpan.Zero) + warnings.Add($"ServerHistorian:HistoryReadDeadline is {HistoryReadDeadline} — must be > 0; HistoryRead requests run unbounded (no per-request deadline)."); return warnings; } } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/ServerHistorianOptionsTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/ServerHistorianOptionsTests.cs index 88acedae..458070cd 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/ServerHistorianOptionsTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/ServerHistorianOptionsTests.cs @@ -50,4 +50,55 @@ public sealed class ServerHistorianOptionsTests .Validate(); Assert.Contains(w, m => m.Contains("MaxTieClusterOverfetch")); } + + // ── S3 HistoryRead concurrency knobs (arch-review 03/S3) ──────────────────────────────────── + + [Fact] + public void HistoryRead_knob_defaults() + { + var o = new ServerHistorianOptions(); + Assert.Equal(4, o.HistoryReadBatchParallelism); + Assert.Equal(16, o.MaxConcurrentHistoryReadBatches); + Assert.Equal(TimeSpan.FromSeconds(60), o.HistoryReadDeadline); + } + + [Fact] + public void Enabled_with_nonpositive_HistoryReadBatchParallelism_warns() + { + var w = new ServerHistorianOptions + { Enabled = true, Endpoint = "https://h:5222", ApiKey = "histgw_x_y", HistoryReadBatchParallelism = 0 } + .Validate(); + Assert.Contains(w, m => m.Contains("HistoryReadBatchParallelism")); + } + + [Fact] + public void Enabled_with_nonpositive_MaxConcurrentHistoryReadBatches_warns() + { + var w = new ServerHistorianOptions + { Enabled = true, Endpoint = "https://h:5222", ApiKey = "histgw_x_y", MaxConcurrentHistoryReadBatches = 0 } + .Validate(); + Assert.Contains(w, m => m.Contains("MaxConcurrentHistoryReadBatches")); + } + + [Fact] + public void Enabled_with_nonpositive_HistoryReadDeadline_warns() + { + var w = new ServerHistorianOptions + { Enabled = true, Endpoint = "https://h:5222", ApiKey = "histgw_x_y", HistoryReadDeadline = TimeSpan.Zero } + .Validate(); + Assert.Contains(w, m => m.Contains("HistoryReadDeadline")); + } + + [Fact] + public void Disabled_with_nonpositive_HistoryRead_knobs_is_silent() + { + var w = new ServerHistorianOptions + { + Enabled = false, + HistoryReadBatchParallelism = 0, + MaxConcurrentHistoryReadBatches = 0, + HistoryReadDeadline = TimeSpan.Zero, + }.Validate(); + Assert.Empty(w); + } }