fix(historian): respect gateway ceilings and history value contracts
v2-ci / build (pull_request) Successful in 5m10s
v2-ci / unit-tests (pull_request) Failing after 28m34s

This commit is contained in:
Joseph Doherty
2026-10-13 03:51:04 -04:00
parent 66a2b0c3e9
commit 97668d26d3
15 changed files with 474 additions and 29 deletions
@@ -5129,6 +5129,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
RunBounded(work, HistoryReadBatchParallelism);
});
NormalizeHistoryValues(nodesToProcess, results);
}
/// <inheritdoc />
@@ -5225,6 +5226,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
RunBounded(work, HistoryReadBatchParallelism);
});
NormalizeHistoryValues(nodesToProcess, results);
}
/// <inheritdoc />
@@ -5516,8 +5518,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// One node's backend failure must not throw out of the batch — surface Bad for THIS node
// only. Logged via the injected ILogger.
_logger.LogError(ex, "OtOpcUaNodeManager: HistoryReadEvents failed for node {NodeId}", handle.NodeId);
errors[handle.Index] = StatusCodes.BadHistoryOperationUnsupported;
results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadHistoryOperationUnsupported };
errors[handle.Index] = HistoryReadFailureStatus(ex);
results[handle.Index] = new SdkHistoryReadResult { StatusCode = HistoryReadFailureStatus(ex) };
}
}
@@ -6233,7 +6235,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// Bad status for THIS node only. Logged through the injected ILogger
// rather than swallowed silently.
_logger.LogError(ex, "OtOpcUaNodeManager: HistoryRead failed for node {NodeId}", handle.NodeId);
errors[handle.Index] = StatusCodes.BadHistoryOperationUnsupported;
errors[handle.Index] = HistoryReadFailureStatus(ex);
results[handle.Index] = new SdkHistoryReadResult { StatusCode = HistoryReadFailureStatus(ex) };
}
}
@@ -6722,7 +6725,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
{
// One node's backend failure must not throw out of the batch — Bad for THIS node only.
_logger.LogError(ex, "OtOpcUaNodeManager: HistoryReadRaw (paged) failed for node {NodeId}", handle.NodeId);
errors[handle.Index] = StatusCodes.BadHistoryOperationUnsupported;
errors[handle.Index] = HistoryReadFailureStatus(ex);
results[handle.Index] = new SdkHistoryReadResult { StatusCode = HistoryReadFailureStatus(ex) };
}
}
@@ -6829,8 +6833,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
catch (Exception ex)
{
_logger.LogError(ex, "OtOpcUaNodeManager: HistoryRead (modified) failed for node {NodeId}", handle.NodeId);
errors[handle.Index] = StatusCodes.BadHistoryOperationUnsupported;
results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadHistoryOperationUnsupported };
errors[handle.Index] = HistoryReadFailureStatus(ex);
results[handle.Index] = new SdkHistoryReadResult { StatusCode = HistoryReadFailureStatus(ex) };
}
}
@@ -6879,6 +6883,49 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
}
}
/// <summary>Transport failures are retryable; unsupported operations retain their distinct status (#697).</summary>
private static uint HistoryReadFailureStatus(Exception error)
{
for (Exception? ex = error; ex is not null; ex = ex.InnerException)
if (ex is System.IO.IOException or System.Net.Sockets.SocketException or System.Net.Http.HttpRequestException or TimeoutException)
return StatusCodes.BadNoCommunication;
return StatusCodes.BadHistoryOperationUnsupported;
}
/// <summary>Normalize stored and AtTime history to each scalar node's declared DataType (#698).</summary>
private void NormalizeHistoryValues(List<NodeHandle> handles, IList<SdkHistoryReadResult> results)
{
foreach (var handle in handles)
{
if (AtTimeValueType(handle.NodeId) is not { } type || results[handle.Index]?.HistoryData is not { } body) continue;
var values = ExtensionObject.ToEncodeable(body) switch
{
HistoryData data => data.DataValues,
_ => null,
};
if (values is null) continue;
foreach (var value in values)
{
if (value.Value is null) continue;
try
{
// A gateway number cannot reconstruct a DateTime or a String which was never stored.
if ((type == TypeCode.DateTime && value.Value is not DateTime)
|| (type == TypeCode.String && value.Value is not string)) throw new InvalidCastException();
var converted = Convert.ChangeType(value.Value, type, System.Globalization.CultureInfo.InvariantCulture);
if (converted is float f && float.IsInfinity(f) && value.Value is double d && double.IsFinite(d))
throw new OverflowException();
value.WrappedValue = new Variant(converted);
}
catch (Exception ex) when (ex is InvalidCastException or OverflowException or FormatException)
{
value.WrappedValue = Variant.Null;
value.StatusCode = StatusCodes.BadTypeMismatch;
}
}
}
}
/// <summary>Project a plain sample list into an SDK <see cref="HistoryData"/> (the paged Raw path
/// works on a trimmed <see cref="IReadOnlyList{T}"/> rather than a whole <see cref="HistorianRead"/>).</summary>
/// <param name="samples">The samples to project (already trimmed of boundary duplicates).</param>
@@ -100,16 +100,22 @@ internal static class ReverseRawReader
{
var asked = limit > 0 && cap > limit ? limit : cap; // cap 0 (the whole window) is never cut
IReadOnlyList<DataValueSnapshot> samples;
try
for (var attempt = 0; ; attempt++)
{
samples = (await source.ReadRawAsync(tagname, start, end, (uint)asked, token).ConfigureAwait(false)).Samples;
}
catch (HistoryReadLimitExceededException ex) when (asked > 0 && ex.PreWindowRows > 0 && asked - ex.PreWindowRows >= 1)
{
// #694: rows just before the segment crowded the backend's budget — read it smaller; a full answer
// then counts as truncated (the walk narrows), never as complete.
asked -= ex.PreWindowRows;
samples = (await source.ReadRawAsync(tagname, start, end, (uint)asked, token).ConfigureAwait(false)).Samples;
try
{
samples = (await source.ReadRawAsync(tagname, start, end, (uint)asked, token).ConfigureAwait(false)).Samples;
break;
}
catch (HistoryReadLimitExceededException ex) when (asked > 0 && ex.PreWindowRows > 0 && attempt < 2)
{
// #695: the discovery request may be smaller than the pre-window cluster. Size against
// the ceiling, not that request; a second pass can discover more crowding rows.
var ceilingRoom = source.MaxRawValuesPerRead is var learned and > 0 ? learned : asked;
var smaller = Math.Min(asked - 1, ceilingRoom - ex.PreWindowRows - 1);
if (smaller < 1) throw;
asked = smaller;
}
}
return new ForwardSegmentRead<DataValueSnapshot>(samples, asked > 0 && samples.Count >= asked);
},