fix(opcuaclient): HistoryRead honours the per-node StatusCode (BadAggregateNotSupported + other Bad) instead of returning empty data (#649)

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-08 19:16:55 -04:00
parent 8801631739
commit 22e79112b6
5 changed files with 109 additions and 14 deletions
@@ -185,6 +185,26 @@ public sealed class HistoryAggregateNotSupportedException : NotSupportedExceptio
=> Aggregate = aggregate;
}
/// <summary>
/// Thrown by a history backend that is a passthrough to another OPC UA server when that server answered
/// a node with a Bad per-node <c>HistoryReadResult.StatusCode</c> other than
/// <c>BadAggregateNotSupported</c> (which has its own <see cref="HistoryAggregateNotSupportedException"/>).
/// The Server HistoryRead path surfaces <see cref="StatusCode"/> verbatim as that node's result status
/// instead of the generic <c>BadHistoryOperationUnsupported</c>, so a refused read is never mistaken for
/// an empty Good one.
/// </summary>
public sealed class HistoryReadStatusException : Exception
{
/// <summary>The raw OPC UA status code the upstream server returned for the node.</summary>
public uint StatusCode { get; }
/// <summary>Creates the exception for the upstream <paramref name="statusCode"/>.</summary>
/// <param name="statusCode">The Bad OPC UA status code to surface for the node.</param>
public HistoryReadStatusException(uint statusCode)
: base($"The upstream server refused the history read with status 0x{statusCode:X8}.")
=> StatusCode = statusCode;
}
/// <summary>
/// One row returned by <see cref="IHistoryProvider.ReadEventsAsync"/> — a historical
/// alarm/event record, not the OPC UA live-event stream. Fields match the minimum set the
@@ -2315,7 +2315,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
ProcessingInterval = interval.TotalMilliseconds,
AggregateType = [aggregateId],
};
return await ExecuteHistoryReadAsync(fullReference, new ExtensionObject(details), cancellationToken)
return await ExecuteHistoryReadAsync(fullReference, new ExtensionObject(details), cancellationToken, aggregate)
.ConfigureAwait(false);
}
@@ -2335,11 +2335,16 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
/// <summary>
/// Shared HistoryRead wire path — used by Raw/Processed/AtTime. Handles NodeId parse,
/// Session.HistoryReadAsync call, Bad-StatusCode passthrough (no translation per §8
/// cascading-quality rule), and HistoryData unwrap into <see cref="DataValueSnapshot"/>.
/// Session.HistoryReadAsync call, per-sample StatusCode passthrough (no translation per §8
/// cascading-quality rule), and HistoryData unwrap into <see cref="DataValueSnapshot"/>. A Bad
/// per-NODE <c>HistoryReadResult.StatusCode</c> is never read as empty data: <c>BadAggregateNotSupported</c>
/// throws <see cref="HistoryAggregateNotSupportedException"/>, any other Bad throws
/// <see cref="HistoryReadStatusException"/> (Gitea #649). Good / GoodNoData / GoodMoreData / Uncertain are
/// unchanged.
/// </summary>
private async Task<Core.Abstractions.HistoryReadResult> ExecuteHistoryReadAsync(
string fullReference, ExtensionObject historyReadDetails, CancellationToken ct)
string fullReference, ExtensionObject historyReadDetails, CancellationToken ct,
HistoryAggregateType? aggregate = null)
{
// Make sure a session exists before queuing on the gate, but do NOT bind the wire call
// (or the namespace-relative NodeId parse) to this reference — a reconnect can swap
@@ -2375,6 +2380,15 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
if (resp.Results.Count == 0) return new Core.Abstractions.HistoryReadResult([], null);
var r = resp.Results[0];
// The per-node status decides whether the payload means anything. BadNoData is the one Bad code
// that legitimately means "nothing in the window", so it stays an empty result.
if (StatusCode.IsBad(r.StatusCode) && r.StatusCode.Code != StatusCodes.BadNoData)
{
if (r.StatusCode.Code == StatusCodes.BadAggregateNotSupported && aggregate is { } agg)
throw new HistoryAggregateNotSupportedException(agg);
throw new HistoryReadStatusException(r.StatusCode.Code);
}
// Unwrap HistoryData from the ExtensionObject-encoded payload the SDK returns.
// Samples stay in chronological order per OPC UA Part 11; cascading-quality
// rule: preserve each DataValue's upstream StatusCode + timestamps verbatim.
@@ -5419,6 +5419,14 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
errors[handle.Index] = StatusCodes.BadAggregateNotSupported;
results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadAggregateNotSupported };
}
catch (HistoryReadStatusException ex)
{
// A passthrough backend (OpcUaClient) relayed the upstream server's Bad per-node status - surface
// it verbatim for THIS node rather than collapsing it to BadHistoryOperationUnsupported.
_logger.LogDebug(ex, "OtOpcUaNodeManager: backend refused history read for node {NodeId}", handle.NodeId);
errors[handle.Index] = new StatusCode(ex.StatusCode);
results[handle.Index] = new SdkHistoryReadResult { StatusCode = new StatusCode(ex.StatusCode) };
}
catch (Exception ex)
{
// One node's backend failure (throw / timeout) must not throw out of the batch — surface a
@@ -5620,6 +5628,13 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
errors[handle.Index] = StatusCodes.BadTimeout;
results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadTimeout };
}
catch (HistoryReadStatusException ex)
{
// Upstream Bad per-node status relayed by a passthrough backend - surface it verbatim (see ServeNodeAsync).
_logger.LogDebug(ex, "OtOpcUaNodeManager: backend refused HistoryReadRaw for node {NodeId}", handle.NodeId);
errors[handle.Index] = new StatusCode(ex.StatusCode);
results[handle.Index] = new SdkHistoryReadResult { StatusCode = new StatusCode(ex.StatusCode) };
}
catch (Exception ex)
{
// One node's backend failure must not throw out of the batch — Bad for THIS node only.