feat(opcuaserver): process-wide HistoryRead batch limiter — flood degrades to BadTooManyOperations, not pool exhaustion (03/S3)
This commit is contained in:
@@ -221,6 +221,10 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
/// </summary>
|
||||
public TimeSpan HistoryReadDeadline { get; set; } = TimeSpan.FromSeconds(60);
|
||||
|
||||
/// <summary>Lazily-created process-wide HistoryRead-batch limiter (see <see cref="MaxConcurrentHistoryReadBatches"/>).</summary>
|
||||
private volatile SemaphoreSlim? _historyReadBatchLimiter;
|
||||
private readonly object _historyReadBatchLimiterLock = new();
|
||||
|
||||
private volatile IHistoryContinuationStore _historyContinuationStore = new SessionHistoryContinuationStore();
|
||||
|
||||
/// <summary>
|
||||
@@ -1816,25 +1820,28 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
IDictionary<NodeId, NodeState> cache)
|
||||
{
|
||||
var session = context.OperationContext?.Session;
|
||||
using var deadline = CreateDeadline();
|
||||
var ct = deadline?.Token ?? CancellationToken.None;
|
||||
var work = new List<Func<Task>>(nodesToProcess.Count);
|
||||
foreach (var handle in nodesToProcess)
|
||||
WithHistoryReadBatchLimiter(nodesToProcess, results, errors, () =>
|
||||
{
|
||||
if (details.IsReadModified)
|
||||
using var deadline = CreateDeadline();
|
||||
var ct = deadline?.Token ?? CancellationToken.None;
|
||||
var work = new List<Func<Task>>(nodesToProcess.Count);
|
||||
foreach (var handle in nodesToProcess)
|
||||
{
|
||||
// We never serve modified-value history; mark this node unsupported and move on.
|
||||
errors[handle.Index] = StatusCodes.BadHistoryOperationUnsupported;
|
||||
continue;
|
||||
if (details.IsReadModified)
|
||||
{
|
||||
// We never serve modified-value history; mark this node unsupported and move on.
|
||||
errors[handle.Index] = StatusCodes.BadHistoryOperationUnsupported;
|
||||
continue;
|
||||
}
|
||||
|
||||
work.Add(() => ServeRawPagedAsync(
|
||||
handle, session, nodesToRead, results, errors,
|
||||
details.StartTime, details.EndTime, details.NumValuesPerNode,
|
||||
ct));
|
||||
}
|
||||
|
||||
work.Add(() => ServeRawPagedAsync(
|
||||
handle, session, nodesToRead, results, errors,
|
||||
details.StartTime, details.EndTime, details.NumValuesPerNode,
|
||||
ct));
|
||||
}
|
||||
|
||||
RunBounded(work, HistoryReadBatchParallelism);
|
||||
RunBounded(work, HistoryReadBatchParallelism);
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -1850,37 +1857,40 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
{
|
||||
// OPC UA ProcessingInterval is a Duration in milliseconds — convert once per batch.
|
||||
var interval = TimeSpan.FromMilliseconds(details.ProcessingInterval);
|
||||
using var deadline = CreateDeadline();
|
||||
var ct = deadline?.Token ?? CancellationToken.None;
|
||||
var work = new List<Func<Task>>(nodesToProcess.Count);
|
||||
foreach (var handle in nodesToProcess)
|
||||
WithHistoryReadBatchLimiter(nodesToProcess, results, errors, () =>
|
||||
{
|
||||
// AggregateType is a per-node parallel collection (same length as nodesToRead, enforced by
|
||||
// the base dispatcher). handle.Index is the node's position in that collection.
|
||||
var aggregateNodeId = details.AggregateType[handle.Index];
|
||||
var aggregate = MapAggregate(aggregateNodeId);
|
||||
if (aggregate is null)
|
||||
using var deadline = CreateDeadline();
|
||||
var ct = deadline?.Token ?? CancellationToken.None;
|
||||
var work = new List<Func<Task>>(nodesToProcess.Count);
|
||||
foreach (var handle in nodesToProcess)
|
||||
{
|
||||
errors[handle.Index] = StatusCodes.BadAggregateNotSupported;
|
||||
continue;
|
||||
// AggregateType is a per-node parallel collection (same length as nodesToRead, enforced by
|
||||
// the base dispatcher). handle.Index is the node's position in that collection.
|
||||
var aggregateNodeId = details.AggregateType[handle.Index];
|
||||
var aggregate = MapAggregate(aggregateNodeId);
|
||||
if (aggregate is null)
|
||||
{
|
||||
errors[handle.Index] = StatusCodes.BadAggregateNotSupported;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Processed is SINGLE-SHOT (no continuation point). Unlike Raw, ReadProcessedDetails carries
|
||||
// NO client count cap (NumValuesPerNode) — the bucket count is deterministic (window / interval)
|
||||
// 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).
|
||||
var agg = aggregate.Value;
|
||||
work.Add(() => ServeNodeAsync(handle, results, errors, (source, tagname, token) => source.ReadProcessedAsync(
|
||||
tagname,
|
||||
details.StartTime,
|
||||
details.EndTime,
|
||||
interval,
|
||||
agg,
|
||||
token), ct));
|
||||
}
|
||||
|
||||
// Processed is SINGLE-SHOT (no continuation point). Unlike Raw, ReadProcessedDetails carries
|
||||
// NO client count cap (NumValuesPerNode) — the bucket count is deterministic (window / interval)
|
||||
// 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).
|
||||
var agg = aggregate.Value;
|
||||
work.Add(() => ServeNodeAsync(handle, results, errors, (source, tagname, token) => source.ReadProcessedAsync(
|
||||
tagname,
|
||||
details.StartTime,
|
||||
details.EndTime,
|
||||
interval,
|
||||
agg,
|
||||
token), ct));
|
||||
}
|
||||
|
||||
RunBounded(work, HistoryReadBatchParallelism);
|
||||
RunBounded(work, HistoryReadBatchParallelism);
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -1896,18 +1906,21 @@ 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>();
|
||||
using var deadline = CreateDeadline();
|
||||
var ct = deadline?.Token ?? CancellationToken.None;
|
||||
var work = new List<Func<Task>>(nodesToProcess.Count);
|
||||
foreach (var handle in nodesToProcess)
|
||||
WithHistoryReadBatchLimiter(nodesToProcess, results, errors, () =>
|
||||
{
|
||||
work.Add(() => ServeNodeAsync(handle, results, errors, (source, tagname, token) => source.ReadAtTimeAsync(
|
||||
tagname,
|
||||
timestamps,
|
||||
token), ct));
|
||||
}
|
||||
using var deadline = CreateDeadline();
|
||||
var ct = deadline?.Token ?? CancellationToken.None;
|
||||
var work = new List<Func<Task>>(nodesToProcess.Count);
|
||||
foreach (var handle in nodesToProcess)
|
||||
{
|
||||
work.Add(() => ServeNodeAsync(handle, results, errors, (source, tagname, token) => source.ReadAtTimeAsync(
|
||||
tagname,
|
||||
timestamps,
|
||||
token), ct));
|
||||
}
|
||||
|
||||
RunBounded(work, HistoryReadBatchParallelism);
|
||||
RunBounded(work, HistoryReadBatchParallelism);
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -1924,27 +1937,30 @@ 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();
|
||||
|
||||
using var deadline = CreateDeadline();
|
||||
var ct = deadline?.Token ?? CancellationToken.None;
|
||||
var work = new List<Func<Task>>(nodesToProcess.Count);
|
||||
foreach (var handle in nodesToProcess)
|
||||
WithHistoryReadBatchLimiter(nodesToProcess, results, errors, () =>
|
||||
{
|
||||
var idString = handle.NodeId.Identifier?.ToString();
|
||||
if (idString is null || !_eventNotifierSources.TryGetValue(idString, out var sourceName))
|
||||
using var deadline = CreateDeadline();
|
||||
var ct = deadline?.Token ?? CancellationToken.None;
|
||||
var work = new List<Func<Task>>(nodesToProcess.Count);
|
||||
foreach (var handle in nodesToProcess)
|
||||
{
|
||||
// Not a registered event-history source (plain folder / Null-source promotion) ⇒ unsupported.
|
||||
// Set both errors and results explicitly on every bad path — don't rely on the SDK base
|
||||
// pre-seeding results[i], so every path is self-contained and the contract is obvious.
|
||||
errors[handle.Index] = StatusCodes.BadHistoryOperationUnsupported;
|
||||
results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadHistoryOperationUnsupported };
|
||||
continue;
|
||||
var idString = handle.NodeId.Identifier?.ToString();
|
||||
if (idString is null || !_eventNotifierSources.TryGetValue(idString, out var sourceName))
|
||||
{
|
||||
// Not a registered event-history source (plain folder / Null-source promotion) ⇒ unsupported.
|
||||
// Set both errors and results explicitly on every bad path — don't rely on the SDK base
|
||||
// pre-seeding results[i], so every path is self-contained and the contract is obvious.
|
||||
errors[handle.Index] = StatusCodes.BadHistoryOperationUnsupported;
|
||||
results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadHistoryOperationUnsupported };
|
||||
continue;
|
||||
}
|
||||
|
||||
var sn = sourceName;
|
||||
work.Add(() => ServeEventsAsync(handle, sn, details, selectClauses, results, errors, ct));
|
||||
}
|
||||
|
||||
var sn = sourceName;
|
||||
work.Add(() => ServeEventsAsync(handle, sn, details, selectClauses, results, errors, ct));
|
||||
}
|
||||
|
||||
RunBounded(work, HistoryReadBatchParallelism);
|
||||
RunBounded(work, HistoryReadBatchParallelism);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -2146,6 +2162,68 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
Task.WhenAll(tasks).GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Run one HistoryRead arm under the process-wide concurrent-batch limiter (arch-review 03/S3). Each
|
||||
/// arm waits up to <c>min(HistoryReadDeadline, 5 s)</c> for a slot; on expiry every handle in the
|
||||
/// batch gets <c>BadTooManyOperations</c> (fast-fail under flood, brief waits under mild contention)
|
||||
/// and <paramref name="serve"/> is skipped. On acquisition the arm body runs and the slot is
|
||||
/// released in a <c>finally</c>. Combined with <see cref="HistoryReadBatchParallelism"/> this caps
|
||||
/// total in-flight gateway reads at
|
||||
/// <c>MaxConcurrentHistoryReadBatches × HistoryReadBatchParallelism</c>.
|
||||
/// </summary>
|
||||
/// <param name="nodesToProcess">The batch's handles — every slot is failed on limiter saturation.</param>
|
||||
/// <param name="results">The service-level results list.</param>
|
||||
/// <param name="errors">The service-level errors list.</param>
|
||||
/// <param name="serve">The arm body to run once a slot is acquired.</param>
|
||||
private void WithHistoryReadBatchLimiter(
|
||||
List<NodeHandle> nodesToProcess,
|
||||
IList<SdkHistoryReadResult> results,
|
||||
IList<ServiceResult> errors,
|
||||
Action serve)
|
||||
{
|
||||
var gate = GetHistoryReadBatchLimiter();
|
||||
|
||||
// Bounded admission wait: cap the block at 5 s, and never exceed the per-request deadline.
|
||||
var waitMs = HistoryReadDeadline > TimeSpan.Zero
|
||||
? Math.Min(HistoryReadDeadline.TotalMilliseconds, 5000.0)
|
||||
: 5000.0;
|
||||
|
||||
if (!gate.Wait(TimeSpan.FromMilliseconds(waitMs)))
|
||||
{
|
||||
foreach (var handle in nodesToProcess)
|
||||
{
|
||||
errors[handle.Index] = StatusCodes.BadTooManyOperations;
|
||||
results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadTooManyOperations };
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
serve();
|
||||
}
|
||||
finally
|
||||
{
|
||||
gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lazily create (double-checked) the process-wide HistoryRead-batch limiter, sized from
|
||||
/// <see cref="MaxConcurrentHistoryReadBatches"/> at first use so the Host's post-boot property set is
|
||||
/// respected (the same benign wiring race as <see cref="MaxTieClusterOverfetch"/>).
|
||||
/// </summary>
|
||||
/// <returns>The shared batch limiter semaphore.</returns>
|
||||
private SemaphoreSlim GetHistoryReadBatchLimiter()
|
||||
{
|
||||
var gate = _historyReadBatchLimiter;
|
||||
if (gate is not null) return gate;
|
||||
lock (_historyReadBatchLimiterLock)
|
||||
{
|
||||
return _historyReadBatchLimiter ??= new SemaphoreSlim(Math.Max(1, MaxConcurrentHistoryReadBatches));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create the per-request deadline source for a HistoryRead arm (arch-review 03/S3): a
|
||||
/// <see cref="CancellationTokenSource"/> that fires after <see cref="HistoryReadDeadline"/>, so a
|
||||
|
||||
Reference in New Issue
Block a user