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:
Joseph Doherty
2026-06-14 19:44:56 -04:00
parent 13fba8f8fb
commit 059f18bdad
2 changed files with 122 additions and 32 deletions
@@ -99,7 +99,9 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
StartTime = DateTime.UtcNow.AddHours(-1),
EndTime = DateTime.UtcNow,
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,
};
@@ -294,6 +296,67 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
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
/// escapes the HistoryRead call.</summary>
[Fact]
@@ -333,16 +396,19 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
/// handle-building only needs the NodeId + namespace, not a session.</summary>
private static (IList<SdkHistoryReadResult> Results, IList<ServiceResult> Errors) InvokeHistoryRead(
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(
new RequestHeader(), secureChannelContext: null, RequestType.HistoryRead, identity: null);
var nodesToRead = new List<HistoryReadValueId>
{
new() { NodeId = nodeId },
};
var results = new List<SdkHistoryReadResult> { null! };
var errors = new List<ServiceResult> { null! };
var nodesToRead = nodeIds.Select(id => new HistoryReadValueId { NodeId = id }).ToList();
var results = Enumerable.Repeat<SdkHistoryReadResult>(null!, nodeIds.Length).ToList();
var errors = Enumerable.Repeat<ServiceResult>(null!, nodeIds.Length).ToList();
nm.HistoryRead(
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()
{
var host = new OpcUaApplicationHost(