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);
+ }
}