test(historian): multi-node HistoryRead isolation + single-lookup ServeNode + comment fix
Fix A: add Raw_multi_node_per_node_error_isolation test — two historized variables (eqA/good→A.PV, eqB/bad→B.PV) in one Raw batch; per-tagname fake throws for B.PV, returns a sample for A.PV; asserts errors[0]=Good+sample, errors[1]=Bad, HistoryData[1]=null (no cross-slot leak), no exception escapes. Fix B: collapse double ConcurrentDictionary lookup in ServeNode — TryGetHistorizedTagname now captures `out var tagname` on the guard; the resolved tagname is threaded into the read callback as a second parameter (Func<IHistorianDataSource, string, Task<HistorianRead>>), removing the redundant ResolveTagname helper (deleted) and the tiny race window between the check and the second lookup. All three call-sites (Raw/Processed/AtTime) updated. Fix C: rewrite the IsReadModified comment at NodeManagerHistoryReadTests.cs:102 — the SDK's ReadRawModifiedDetails.Initialize() sets m_isReadModified=true (generated ctor body in Opc.Ua.DataTypes.cs), so the default IS true; the test must explicitly clear it to false for a plain raw read. Previous comment said the same thing but imprecisely; now cites the SDK mechanism (Initialize() call in the public ctor).
This commit is contained in:
@@ -1050,8 +1050,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
ServeNode(handle, results, errors, source => source.ReadRawAsync(
|
ServeNode(handle, results, errors, (source, tagname) => source.ReadRawAsync(
|
||||||
ResolveTagname(handle),
|
tagname,
|
||||||
details.StartTime,
|
details.StartTime,
|
||||||
details.EndTime,
|
details.EndTime,
|
||||||
details.NumValuesPerNode,
|
details.NumValuesPerNode,
|
||||||
@@ -1088,8 +1088,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
ServeNode(handle, results, errors, source => source.ReadProcessedAsync(
|
ServeNode(handle, results, errors, (source, tagname) => source.ReadProcessedAsync(
|
||||||
ResolveTagname(handle),
|
tagname,
|
||||||
details.StartTime,
|
details.StartTime,
|
||||||
details.EndTime,
|
details.EndTime,
|
||||||
// OPC UA ProcessingInterval is a Duration in milliseconds.
|
// OPC UA ProcessingInterval is a Duration in milliseconds.
|
||||||
@@ -1117,8 +1117,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
|||||||
var timestamps = details.ReqTimes?.ToList() ?? new List<DateTime>();
|
var timestamps = details.ReqTimes?.ToList() ?? new List<DateTime>();
|
||||||
foreach (var handle in nodesToProcess)
|
foreach (var handle in nodesToProcess)
|
||||||
{
|
{
|
||||||
ServeNode(handle, results, errors, source => source.ReadAtTimeAsync(
|
ServeNode(handle, results, errors, (source, tagname) => source.ReadAtTimeAsync(
|
||||||
ResolveTagname(handle),
|
tagname,
|
||||||
timestamps,
|
timestamps,
|
||||||
CancellationToken.None));
|
CancellationToken.None));
|
||||||
}
|
}
|
||||||
@@ -1126,25 +1126,28 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Block-bridge to the historian source for one node handle and project the result onto the
|
/// Block-bridge to the historian source for one node handle and project the result onto the
|
||||||
/// service-level results/errors slots. Resolves the node's registered historian tagname first
|
/// service-level results/errors slots. Resolves the node's registered historian tagname first —
|
||||||
/// (a node we don't recognise as historized — which shouldn't reach us, since the base only
|
/// a single <see cref="TryGetHistorizedTagname"/> lookup; the resolved tagname is passed directly
|
||||||
/// hands us nodes with the HistoryRead access bit — maps to <c>BadHistoryOperationUnsupported</c>).
|
/// to <paramref name="read"/>, removing any risk of a second concurrent lookup on the same key.
|
||||||
/// The <paramref name="read"/> callback is invoked only AFTER the tagname is confirmed present;
|
/// A node we don't recognise as historized maps to <c>BadHistoryOperationUnsupported</c>
|
||||||
/// it is wrapped in try/catch so a backend throw / timeout becomes a Bad status for THIS node
|
/// (shouldn't normally reach us, since the base only hands us nodes with the HistoryRead access
|
||||||
/// without throwing out of the batch.
|
/// bit, but we guard explicitly). The <paramref name="read"/> callback receives the resolved
|
||||||
|
/// tagname and is wrapped in try/catch so a backend throw / timeout becomes a Bad status for
|
||||||
|
/// THIS node without throwing out of the batch.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="handle">The pre-filtered node handle to serve; <c>handle.Index</c> indexes results/errors.</param>
|
/// <param name="handle">The pre-filtered node handle to serve; <c>handle.Index</c> indexes results/errors.</param>
|
||||||
/// <param name="results">The service-level results list to fill at <c>handle.Index</c>.</param>
|
/// <param name="results">The service-level results list to fill at <c>handle.Index</c>.</param>
|
||||||
/// <param name="errors">The service-level errors list to fill at <c>handle.Index</c>.</param>
|
/// <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; only called once the tagname is known.</param>
|
/// <param name="read">Invokes the resolved data-source read with the resolved tagname; only called
|
||||||
|
/// once the tagname is confirmed present.</param>
|
||||||
private void ServeNode(
|
private void ServeNode(
|
||||||
NodeHandle handle,
|
NodeHandle handle,
|
||||||
IList<SdkHistoryReadResult> results,
|
IList<SdkHistoryReadResult> results,
|
||||||
IList<ServiceResult> errors,
|
IList<ServiceResult> errors,
|
||||||
Func<IHistorianDataSource, Task<HistorianRead>> read)
|
Func<IHistorianDataSource, string, Task<HistorianRead>> read)
|
||||||
{
|
{
|
||||||
var idString = handle.NodeId.Identifier?.ToString();
|
var idString = handle.NodeId.Identifier?.ToString();
|
||||||
if (idString is null || !TryGetHistorizedTagname(idString, out _))
|
if (idString is null || !TryGetHistorizedTagname(idString, out var tagname))
|
||||||
{
|
{
|
||||||
// Not a historized node we own a tagname for — unsupported. (The base pre-seeds this same
|
// Not a historized node we own a tagname for — unsupported. (The base pre-seeds this same
|
||||||
// status, but set it explicitly so the contract is local + obvious.)
|
// status, but set it explicitly so the contract is local + obvious.)
|
||||||
@@ -1156,7 +1159,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
|||||||
{
|
{
|
||||||
// HistoryRead is NOT invoked under the node-manager Lock (unlike OnWriteValue), so blocking
|
// HistoryRead is NOT invoked under the node-manager Lock (unlike OnWriteValue), so blocking
|
||||||
// on the async source here is safe and won't freeze the address space.
|
// on the async source here is safe and won't freeze the address space.
|
||||||
var sourceResult = read(HistorianDataSource).GetAwaiter().GetResult();
|
var sourceResult = read(HistorianDataSource, tagname!).GetAwaiter().GetResult();
|
||||||
var historyData = ToHistoryData(sourceResult);
|
var historyData = ToHistoryData(sourceResult);
|
||||||
|
|
||||||
results[handle.Index] = new SdkHistoryReadResult
|
results[handle.Index] = new SdkHistoryReadResult
|
||||||
@@ -1183,15 +1186,6 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Resolve a handle's registered historian tagname. The caller (<see cref="ServeNode"/>)
|
|
||||||
/// has already confirmed the node is historized before invoking the read callback, so this is a
|
|
||||||
/// guaranteed hit; the null-coalesce is a defensive fallback to the bare NodeId string.</summary>
|
|
||||||
private string ResolveTagname(NodeHandle handle)
|
|
||||||
{
|
|
||||||
var idString = handle.NodeId.Identifier?.ToString() ?? string.Empty;
|
|
||||||
return TryGetHistorizedTagname(idString, out var tagname) ? tagname! : idString;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Map an OPC UA Part 13 standard-aggregate function NodeId to our
|
/// Map an OPC UA Part 13 standard-aggregate function NodeId to our
|
||||||
/// <see cref="HistoryAggregateType"/>. Returns <c>null</c> for any aggregate we don't serve so
|
/// <see cref="HistoryAggregateType"/>. Returns <c>null</c> for any aggregate we don't serve so
|
||||||
|
|||||||
@@ -99,7 +99,9 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
|
|||||||
StartTime = DateTime.UtcNow.AddHours(-1),
|
StartTime = DateTime.UtcNow.AddHours(-1),
|
||||||
EndTime = DateTime.UtcNow,
|
EndTime = DateTime.UtcNow,
|
||||||
NumValuesPerNode = 10,
|
NumValuesPerNode = 10,
|
||||||
// ReadRawModifiedDetails defaults IsReadModified=true; a raw (non-modified) read clears it.
|
// ReadRawModifiedDetails.Initialize() sets m_isReadModified=true (SDK-generated ctor body),
|
||||||
|
// so the default from new ReadRawModifiedDetails() is TRUE. A plain raw read must explicitly
|
||||||
|
// clear it to false to avoid hitting the IsReadModified=true early-return in the override.
|
||||||
IsReadModified = false,
|
IsReadModified = false,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -294,6 +296,67 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
|
|||||||
await host.DisposeAsync();
|
await host.DisposeAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Two nodes in a single Raw HistoryRead batch — one backend succeeds, one throws. Proves that
|
||||||
|
/// per-node error isolation is enforced across handle indices: the good node's slot is
|
||||||
|
/// Good+samples, the bad node's slot is Bad, and neither status leaks into the other.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Raw_multi_node_per_node_error_isolation()
|
||||||
|
{
|
||||||
|
var (host, server) = await BootAsync();
|
||||||
|
var nm = server.NodeManager!;
|
||||||
|
|
||||||
|
// Materialise two distinct historized variables under separate equipment folders.
|
||||||
|
nm.EnsureVariable("eqA/good", parentFolderNodeId: null, displayName: "Good", dataType: "Float",
|
||||||
|
writable: false, historianTagname: "A.PV");
|
||||||
|
nm.EnsureVariable("eqB/bad", parentFolderNodeId: null, displayName: "Bad", dataType: "Float",
|
||||||
|
writable: false, historianTagname: "B.PV");
|
||||||
|
|
||||||
|
var goodNodeId = nm.TryGetVariable("eqA/good")!.NodeId;
|
||||||
|
var badNodeId = nm.TryGetVariable("eqB/bad")!.NodeId;
|
||||||
|
|
||||||
|
var src = DateTime.UtcNow.AddSeconds(-5);
|
||||||
|
var srv = DateTime.UtcNow;
|
||||||
|
|
||||||
|
// Per-tagname fake: A.PV returns one sample; B.PV throws.
|
||||||
|
var perTagFake = new PerTagnameHistorianDataSource(tagname =>
|
||||||
|
tagname == "A.PV"
|
||||||
|
? Task.FromResult(new HistorianRead(new[] { new DataValueSnapshot(1.0f, StatusCodes.Good, src, srv) }, null))
|
||||||
|
: throw new InvalidOperationException("backend boom for B.PV"));
|
||||||
|
nm.HistorianDataSource = perTagFake;
|
||||||
|
|
||||||
|
var details = new ReadRawModifiedDetails
|
||||||
|
{
|
||||||
|
StartTime = DateTime.UtcNow.AddHours(-1),
|
||||||
|
EndTime = DateTime.UtcNow,
|
||||||
|
NumValuesPerNode = 10,
|
||||||
|
IsReadModified = false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Single batch listing BOTH nodes in order [goodNodeId, badNodeId].
|
||||||
|
// handle.Index maps by position: good=0, bad=1.
|
||||||
|
var (results, errors) = InvokeHistoryRead(server, nm, details, goodNodeId, badNodeId);
|
||||||
|
|
||||||
|
// Good node (index 0): should have a Good status + one sample.
|
||||||
|
errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||||
|
results[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||||
|
var data = (HistoryData)ExtensionObject.ToEncodeable(results[0].HistoryData);
|
||||||
|
data.DataValues.Count.ShouldBe(1);
|
||||||
|
data.DataValues[0].Value.ShouldBe(1.0f);
|
||||||
|
|
||||||
|
// Bad node (index 1): errors slot must be Bad; results slot must NOT carry any HistoryData
|
||||||
|
// (no data was projected into it). The base may pre-seed results[i] to a default-constructed
|
||||||
|
// SdkHistoryReadResult whose StatusCode is 0 (Good) — that's fine for the result field, but the
|
||||||
|
// distinguishing signal is the error slot AND the absence of HistoryData from the good node.
|
||||||
|
StatusCode.IsBad(errors[1].StatusCode).ShouldBeTrue();
|
||||||
|
errors[1].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
|
||||||
|
// HistoryData must be null/empty — the good node's sample MUST NOT have leaked into slot 1.
|
||||||
|
(results[1].HistoryData == null || ExtensionObject.IsNull(results[1].HistoryData)).ShouldBeTrue();
|
||||||
|
|
||||||
|
await host.DisposeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>A backend that throws ⇒ that node's error is Bad (not GoodNoData) and no exception
|
/// <summary>A backend that throws ⇒ that node's error is Bad (not GoodNoData) and no exception
|
||||||
/// escapes the HistoryRead call.</summary>
|
/// escapes the HistoryRead call.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -333,16 +396,19 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
|
|||||||
/// handle-building only needs the NodeId + namespace, not a session.</summary>
|
/// handle-building only needs the NodeId + namespace, not a session.</summary>
|
||||||
private static (IList<SdkHistoryReadResult> Results, IList<ServiceResult> Errors) InvokeHistoryRead(
|
private static (IList<SdkHistoryReadResult> Results, IList<ServiceResult> Errors) InvokeHistoryRead(
|
||||||
OtOpcUaSdkServer server, OtOpcUaNodeManager nm, HistoryReadDetails details, NodeId nodeId)
|
OtOpcUaSdkServer server, OtOpcUaNodeManager nm, HistoryReadDetails details, NodeId nodeId)
|
||||||
|
=> InvokeHistoryRead(server, nm, details, new[] { nodeId });
|
||||||
|
|
||||||
|
/// <summary>Multi-node overload — issues one HistoryRead batch for all supplied node ids in order.
|
||||||
|
/// Results and errors are returned in the same order as <paramref name="nodeIds"/>.</summary>
|
||||||
|
private static (IList<SdkHistoryReadResult> Results, IList<ServiceResult> Errors) InvokeHistoryRead(
|
||||||
|
OtOpcUaSdkServer server, OtOpcUaNodeManager nm, HistoryReadDetails details, params NodeId[] nodeIds)
|
||||||
{
|
{
|
||||||
var context = new OperationContext(
|
var context = new OperationContext(
|
||||||
new RequestHeader(), secureChannelContext: null, RequestType.HistoryRead, identity: null);
|
new RequestHeader(), secureChannelContext: null, RequestType.HistoryRead, identity: null);
|
||||||
|
|
||||||
var nodesToRead = new List<HistoryReadValueId>
|
var nodesToRead = nodeIds.Select(id => new HistoryReadValueId { NodeId = id }).ToList();
|
||||||
{
|
var results = Enumerable.Repeat<SdkHistoryReadResult>(null!, nodeIds.Length).ToList();
|
||||||
new() { NodeId = nodeId },
|
var errors = Enumerable.Repeat<ServiceResult>(null!, nodeIds.Length).ToList();
|
||||||
};
|
|
||||||
var results = new List<SdkHistoryReadResult> { null! };
|
|
||||||
var errors = new List<ServiceResult> { null! };
|
|
||||||
|
|
||||||
nm.HistoryRead(
|
nm.HistoryRead(
|
||||||
context,
|
context,
|
||||||
@@ -425,6 +491,36 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>A per-tagname historian source that delegates each raw read to a caller-supplied
|
||||||
|
/// <see cref="Func{T, TResult}"/>. The func may throw to simulate a backend failure for a
|
||||||
|
/// specific tagname while returning results for others — enabling per-node isolation tests.
|
||||||
|
/// Processed / AtTime / Events are not wired (they delegate to the null source).</summary>
|
||||||
|
private sealed class PerTagnameHistorianDataSource(Func<string, Task<HistorianRead>> rawHandler)
|
||||||
|
: IHistorianDataSource
|
||||||
|
{
|
||||||
|
public Task<HistorianRead> ReadRawAsync(
|
||||||
|
string fullReference, DateTime startUtc, DateTime endUtc, uint maxValuesPerNode,
|
||||||
|
CancellationToken cancellationToken) => rawHandler(fullReference);
|
||||||
|
|
||||||
|
public Task<HistorianRead> ReadProcessedAsync(
|
||||||
|
string fullReference, DateTime startUtc, DateTime endUtc, TimeSpan interval,
|
||||||
|
HistoryAggregateType aggregate, CancellationToken cancellationToken) =>
|
||||||
|
NullHistorianDataSource.Instance.ReadProcessedAsync(fullReference, startUtc, endUtc, interval, aggregate, cancellationToken);
|
||||||
|
|
||||||
|
public Task<HistorianRead> ReadAtTimeAsync(
|
||||||
|
string fullReference, IReadOnlyList<DateTime> timestampsUtc, CancellationToken cancellationToken) =>
|
||||||
|
NullHistorianDataSource.Instance.ReadAtTimeAsync(fullReference, timestampsUtc, cancellationToken);
|
||||||
|
|
||||||
|
public Task<HistoricalEventsResult> ReadEventsAsync(
|
||||||
|
string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
NullHistorianDataSource.Instance.ReadEventsAsync(sourceName, startUtc, endUtc, maxEvents, cancellationToken);
|
||||||
|
|
||||||
|
public HistorianHealthSnapshot GetHealthSnapshot() => NullHistorianDataSource.Instance.GetHealthSnapshot();
|
||||||
|
|
||||||
|
public void Dispose() { }
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<(OpcUaApplicationHost Host, OtOpcUaSdkServer Server)> BootAsync()
|
private async Task<(OpcUaApplicationHost Host, OtOpcUaSdkServer Server)> BootAsync()
|
||||||
{
|
{
|
||||||
var host = new OpcUaApplicationHost(
|
var host = new OpcUaApplicationHost(
|
||||||
|
|||||||
Reference in New Issue
Block a user