feat(opcuaserver): bounded per-node parallel HistoryRead within a batch (03/S3)

This commit is contained in:
Joseph Doherty
2026-07-13 12:09:53 -04:00
parent cef7d9b281
commit 8c365da754
3 changed files with 175 additions and 14 deletions
@@ -1816,6 +1816,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
IDictionary<NodeId, NodeState> cache)
{
var session = context.OperationContext?.Session;
var work = new List<Func<Task>>(nodesToProcess.Count);
foreach (var handle in nodesToProcess)
{
if (details.IsReadModified)
@@ -1825,12 +1826,13 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
continue;
}
// Interim: still bridged sequentially per node (bounded fan-out lands in a later slice).
ServeRawPagedAsync(
work.Add(() => ServeRawPagedAsync(
handle, session, nodesToRead, results, errors,
details.StartTime, details.EndTime, details.NumValuesPerNode,
CancellationToken.None).GetAwaiter().GetResult();
CancellationToken.None));
}
RunBounded(work, HistoryReadBatchParallelism);
}
/// <inheritdoc />
@@ -1846,6 +1848,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
{
// OPC UA ProcessingInterval is a Duration in milliseconds — convert once per batch.
var interval = TimeSpan.FromMilliseconds(details.ProcessingInterval);
var work = new List<Func<Task>>(nodesToProcess.Count);
foreach (var handle in nodesToProcess)
{
// AggregateType is a per-node parallel collection (same length as nodesToRead, enforced by
@@ -1863,15 +1866,17 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// and the single-shot backend returns every bucket in one read, so there is no "full page ⇒
// maybe more" signal to page on. Returning the complete aggregate result with a null CP is
// spec-conformant (OPC UA Part 11 lets a server return all available data in one response).
// Interim: still bridged sequentially per node (bounded fan-out lands in a later slice).
ServeNodeAsync(handle, results, errors, (source, tagname, token) => source.ReadProcessedAsync(
var agg = aggregate.Value;
work.Add(() => ServeNodeAsync(handle, results, errors, (source, tagname, token) => source.ReadProcessedAsync(
tagname,
details.StartTime,
details.EndTime,
interval,
aggregate.Value,
token), CancellationToken.None).GetAwaiter().GetResult();
agg,
token), CancellationToken.None));
}
RunBounded(work, HistoryReadBatchParallelism);
}
/// <inheritdoc />
@@ -1887,14 +1892,16 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
{
// Snapshot the requested timestamps once — the same list is read for every node.
var timestamps = details.ReqTimes?.ToList() ?? new List<DateTime>();
var work = new List<Func<Task>>(nodesToProcess.Count);
foreach (var handle in nodesToProcess)
{
// Interim: still bridged sequentially per node (bounded fan-out lands in a later slice).
ServeNodeAsync(handle, results, errors, (source, tagname, token) => source.ReadAtTimeAsync(
work.Add(() => ServeNodeAsync(handle, results, errors, (source, tagname, token) => source.ReadAtTimeAsync(
tagname,
timestamps,
token), CancellationToken.None).GetAwaiter().GetResult();
token), CancellationToken.None));
}
RunBounded(work, HistoryReadBatchParallelism);
}
/// <inheritdoc />
@@ -1911,6 +1918,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// Snapshot the select clauses once — the same filter projects every node's events.
var selectClauses = details.Filter?.SelectClauses ?? new SimpleAttributeOperandCollection();
var work = new List<Func<Task>>(nodesToProcess.Count);
foreach (var handle in nodesToProcess)
{
var idString = handle.NodeId.Identifier?.ToString();
@@ -1924,10 +1932,11 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
continue;
}
// Interim: still bridged sequentially per node (bounded fan-out lands in a later slice).
ServeEventsAsync(handle, sourceName, details, selectClauses, results, errors, CancellationToken.None)
.GetAwaiter().GetResult();
var sn = sourceName;
work.Add(() => ServeEventsAsync(handle, sn, details, selectClauses, results, errors, CancellationToken.None));
}
RunBounded(work, HistoryReadBatchParallelism);
}
/// <summary>
@@ -2099,6 +2108,52 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// <param name="errors">The service-level errors list to fill at <c>handle.Index</c>.</param>
/// <param name="read">Invokes the resolved data-source read with the resolved tagname; only called
/// once the tagname is confirmed present.</param>
/// <summary>
/// Run a batch of per-node HistoryRead work items with BOUNDED parallelism (arch-review 03/S3),
/// block-bridged ONCE at the arm boundary. <b>Contract:</b> every work item must be fully
/// self-contained — it fills its own results/errors slot and NEVER throws (all per-node handlers
/// catch every exception, including cancellation, and surface a Bad status for that node only). That
/// invariant is what makes the single <c>GetAwaiter().GetResult()</c> safe: <see cref="Task.WhenAll(Task[])"/>
/// can never fault, so it cannot throw out of the synchronous SDK override. A degree ≤ 1 (or a
/// single item) runs sequentially — no semaphore, no extra tasks.
/// </summary>
/// <param name="work">The per-node work items (index-disjoint writes into the shared results/errors).</param>
/// <param name="degree">Max concurrent work items (<see cref="HistoryReadBatchParallelism"/>).</param>
private static void RunBounded(IReadOnlyList<Func<Task>> work, int degree)
{
if (work.Count == 0) return;
if (degree <= 1 || work.Count == 1)
{
foreach (var item in work)
item().GetAwaiter().GetResult();
return;
}
using var gate = new SemaphoreSlim(degree);
var tasks = new Task[work.Count];
for (var i = 0; i < work.Count; i++)
tasks[i] = RunGatedAsync(gate, work[i]);
// Safe: every body is self-contained + never throws, so WhenAll never faults (see contract above).
Task.WhenAll(tasks).GetAwaiter().GetResult();
}
/// <summary>Awaits one work item under a semaphore permit, releasing it when the item completes.</summary>
/// <param name="gate">The concurrency-limiting semaphore.</param>
/// <param name="work">The per-node work item.</param>
private static async Task RunGatedAsync(SemaphoreSlim gate, Func<Task> work)
{
await gate.WaitAsync().ConfigureAwait(false);
try
{
await work().ConfigureAwait(false);
}
finally
{
gate.Release();
}
}
private async Task ServeNodeAsync(
NodeHandle handle,
IList<SdkHistoryReadResult> results,