diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/OtOpcUaServerHostedService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/OtOpcUaServerHostedService.cs
index 32af5abd..9ad9c414 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/OtOpcUaServerHostedService.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/OtOpcUaServerHostedService.cs
@@ -317,6 +317,9 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
// section is absent; NodeManager is non-null here (guarded above).
_server.NodeManager.MaxTieClusterOverfetch = _serverHistorianOptions.MaxTieClusterOverfetch;
+ // Gitea #691: the ReturnBounds kill switch (default on).
+ _server.NodeManager.HonorReturnBounds = HonorReturnBounds;
+
// Propagate the S3 HistoryRead concurrency bounds (arch-review 03/S3): bounded per-node fan-out
// within a batch, the process-wide concurrent-batch limiter, and the per-request deadline. Defaults
// (4 / 16 / 60 s) survive when the ServerHistorian section is absent.
@@ -347,6 +350,12 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
_logger.LogInformation("OtOpcUaServerHostedService: SDK started, address-space + ServiceLevel sinks bound");
}
+ ///
+ /// Gitea #691 — the ServerHistorian:HonorReturnBounds kill switch StartAsync applies to the node manager
+ /// (default true when the section or key is absent). Exposed so the wiring is testable without booting the SDK.
+ ///
+ internal bool HonorReturnBounds => _serverHistorianOptions.HonorReturnBounds;
+
///
/// Gitea #674 — what StartAsync wires onto the node manager for HistoryUpdate: the Primary gate (read from
/// the on every call; null when none is registered, i.e. allow), the
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/HistoryBounds.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/HistoryBounds.cs
index 120def61..be10d6d9 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/HistoryBounds.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/HistoryBounds.cs
@@ -65,6 +65,12 @@ internal static class HistoryBounds
public static HistoryEndBound For(DateTime startUtc, DateTime endUtc) =>
new(endUtc, Reverse: startUtc > endUtc, Inclusive: startUtc != endUtc, Open: IsOpen(endUtc));
+ /// Whether a returned value is a placeholder rather than a stored sample.
+ /// The value.
+ /// true for a Bad_BoundNotFound placeholder.
+ public static bool IsPlaceholder(DataValueSnapshot value) =>
+ value.StatusCode == StatusCodes.BadBoundNotFound && value.Value is null;
+
/// The pending end bound to store behind a continuation point, remembering the page's last returned time.
/// The pending end bound (null = bounds not requested).
/// The page being returned.
@@ -90,7 +96,7 @@ internal static class HistoryBounds
{
if (IsOpen(startUtc)) return NotFound(startUtc);
var found = reverse
- ? await EarliestAtOrAfterAsync(source, tagname, startUtc, ct).ConfigureAwait(false)
+ ? await EarliestAtOrAfterAsync(source, tagname, startUtc, nowUtc, ct).ConfigureAwait(false)
: await LatestAtOrBeforeAsync(source, tagname, startUtc, maxTieCluster, nowUtc, ct).ConfigureAwait(false);
if (found is null) return NotFound(startUtc);
return SameInstant(Ticks(found), startUtc.Ticks, Resolution(source)) ? null : found;
@@ -120,13 +126,13 @@ internal static class HistoryBounds
if (bound.Reverse)
found = await LatestAtOrBeforeAsync(source, tagname, bound.AtUtc, maxTieCluster, nowUtc, ct).ConfigureAwait(false);
else if (bound.Inclusive)
- found = await EarliestAtOrAfterAsync(source, tagname, bound.AtUtc, ct).ConfigureAwait(false);
+ found = await EarliestAtOrAfterAsync(source, tagname, bound.AtUtc, nowUtc, ct).ConfigureAwait(false);
else
{
var resolution = Resolution(source);
found = bound.AtUtc.Ticks > DateTime.MaxValue.Ticks - resolution
? null
- : await EarliestAtOrAfterAsync(source, tagname, bound.AtUtc.AddTicks(resolution), ct).ConfigureAwait(false);
+ : await EarliestAtOrAfterAsync(source, tagname, bound.AtUtc.AddTicks(resolution), nowUtc, ct).ConfigureAwait(false);
}
return found ?? NotFound(bound.AtUtc);
@@ -172,11 +178,26 @@ internal static class HistoryBounds
}
/// The earliest stored sample at or after , or null.
+ ///
+ /// #691 review: on a backend that reads forward only (the Gateway) the read's end is clamped to now +
+ /// — the same horizon the walk-back uses — and a time beyond it is "not found"
+ /// without a read. The HistorianGateway only checks start <= end, so a DateTime.MaxValue end would
+ /// reach AVEVA as a year-9999 query end (unverified: a rejection, or a fabricated row). The clamp is NOT applied to a
+ /// backend that reads newest-first natively (SqlServer / QuestDB): they bind MaxValue safely (pinned by the
+ /// Extreme_bounds conformance test) and can legitimately hold samples further ahead (HistoryUpdate's
+ /// HistoryUpdateMaxFutureSkew is configurable, and driver-stamped live values carry device time) — clamping
+ /// there would turn a real end bound into Bad_BoundNotFound.
+ ///
internal static async Task EarliestAtOrAfterAsync(
- IHistorianDataSource source, string tagname, DateTime atUtc, CancellationToken ct)
+ IHistorianDataSource source, string tagname, DateTime atUtc, DateTime nowUtc, CancellationToken ct)
{
- if (atUtc.Ticks >= DateTime.MaxValue.Ticks) return null;
- var rows = (await source.ReadRawAsync(tagname, atUtc, new DateTime(DateTime.MaxValue.Ticks, DateTimeKind.Utc), 1, ct)
+ var endTicks = DateTime.MaxValue.Ticks;
+ if (!source.SupportsReverseRaw)
+ endTicks = nowUtc.Ticks > DateTime.MaxValue.Ticks - ReverseRawReader.LookAhead.Ticks
+ ? DateTime.MaxValue.Ticks
+ : nowUtc.Ticks + ReverseRawReader.LookAhead.Ticks;
+ if (atUtc.Ticks >= endTicks) return null;
+ var rows = (await source.ReadRawAsync(tagname, atUtc, new DateTime(endTicks, DateTimeKind.Utc), 1, ct)
.ConfigureAwait(false)).Samples;
var limit = Align(atUtc.Ticks, Resolution(source));
foreach (var row in rows)
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs
index 0913e033..eb0c81d8 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs
@@ -544,6 +544,13 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
///
public int MaxTieClusterOverfetch { get; set; } = 65536;
+ ///
+ /// Gitea #691: whether HistoryRead-Raw honours ReturnBounds (Part 11 bounding values, )
+ /// — ServerHistorian:HonorReturnBounds, default true. false ignores the flag, as before #691,
+ /// including the modified-read Bad_InvalidArgument.
+ ///
+ public bool HonorReturnBounds { get; set; } = true;
+
///
/// Maximum historized nodes served concurrently within one HistoryRead batch (arch-review 03/S3).
/// Mirrors ServerHistorianOptions.HistoryReadBatchParallelism; the Host sets it at
@@ -5056,10 +5063,10 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
var work = new List>(nodesToProcess.Count);
foreach (var handle in nodesToProcess)
{
- if (details.IsReadModified && details.ReturnBounds)
+ if (details.IsReadModified && details.ReturnBounds && HonorReturnBounds)
{
// Part 11 §6.5.3.3: returnBounds "shall be set to FALSE" for a modified read, "otherwise the Server returns
- // a Bad_InvalidArgument" (#691).
+ // a Bad_InvalidArgument" (#691). Off with the ServerHistorian:HonorReturnBounds kill switch.
errors[handle.Index] = StatusCodes.BadInvalidArgument;
results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadInvalidArgument };
continue;
@@ -5079,7 +5086,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
work.Add(() => ServeRawPagedAsync(
handle, session, nodesToRead, results, errors,
details.StartTime, details.EndTime, details.NumValuesPerNode,
- ct, details.ReturnBounds));
+ ct, details.ReturnBounds && HonorReturnBounds));
}
RunBounded(work, HistoryReadBatchParallelism);
@@ -6306,6 +6313,21 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
PendingEndBound: HistoryBounds.Carry(endBound!, page)! with { DataExhausted = true }));
}
+ // #691 review: whether a bounded read whose start bound was NOT found has any data or a found end bound — only
+ // asked when a one-value page would otherwise be the start-bound placeholder alone.
+ async Task BoundedReadHasAnythingAsync()
+ {
+ var src = HistorianDataSource;
+ IReadOnlyList first = startUtc > endUtc && !src.SupportsReverseRaw
+ ? (await ReverseRawReader.ReadAsync(src, tagname, startUtc, endUtc, 1, MaxTieClusterOverfetch, nowUtc, 0, ct)
+ .ConfigureAwait(false)).Samples
+ : (await src.ReadRawAsync(tagname, startUtc, endUtc, 1, ct).ConfigureAwait(false)).Samples;
+ if (first.Any(x => HistoryBounds.InDataWindow(x, endBound!, resolution))) return true;
+ var end = await HistoryBounds.EndBoundAsync(
+ src, tagname, endBound!, startUtc, MaxTieClusterOverfetch, nowUtc, ct).ConfigureAwait(false);
+ return !HistoryBounds.IsPlaceholder(end);
+ }
+
// #691: a resumed bounded read whose data is drained (the end bound did not fit on the previous page, or the
// resume point already sits on the end time) owes only the end bound.
if (endBound is not null && inboundCp is { Length: > 0 }
@@ -6332,7 +6354,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
startBound = await HistoryBounds.StartBoundAsync(
HistorianDataSource, tagname, startUtc, endBound.Reverse, MaxTieClusterOverfetch, nowUtc, ct)
.ConfigureAwait(false);
- if (startBound is not null && numValuesPerNode == 1)
+ if (startBound is not null && numValuesPerNode == 1
+ && (!HistoryBounds.IsPlaceholder(startBound) || await BoundedReadHasAnythingAsync().ConfigureAwait(false)))
{
// The page is the start bound alone; the data starts at StartTime on the next page.
var boundOnlyCp = _historyContinuationStore.Save(session, new HistoryContinuationState(
@@ -6349,6 +6372,19 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
return;
}
+ if (startBound is not null && numValuesPerNode == 1)
+ {
+ // Nothing at all — no data and no bound found (review of #691): the pre-bounds GoodNoData answer.
+ results[handle.Index] = new SdkHistoryReadResult
+ {
+ StatusCode = StatusCodes.GoodNoData,
+ HistoryData = new ExtensionObject(ToHistoryDataFromSamples([])),
+ ContinuationPoint = null,
+ };
+ errors[handle.Index] = ServiceResult.Good;
+ return;
+ }
+
if (startBound is not null && numValuesPerNode > 0) readCap = numValuesPerNode - 1;
}
@@ -6510,6 +6546,15 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
historyData = ToHistoryDataFromSamples(samples);
}
+ // #691 review: a whole bounded read with no data and no bound found (one page, every value a placeholder) is the
+ // pre-bounds answer — GoodNoData, no values (§6.5.3.2: bounds make it a success only "if one or both exist").
+ if (endBound is not null && inboundCp is not { Length: > 0 } && outboundCp is null
+ && samples.All(HistoryBounds.IsPlaceholder))
+ {
+ samples = [];
+ historyData = ToHistoryDataFromSamples(samples);
+ }
+
results[handle.Index] = new SdkHistoryReadResult
{
// No samples ⇒ GoodNoData (the node is historized, the window just held no data). With the
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ServerHistorianOptions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ServerHistorianOptions.cs
index 7c04110d..37c04091 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ServerHistorianOptions.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ServerHistorianOptions.cs
@@ -89,6 +89,14 @@ public sealed class ServerHistorianOptions
///
public int MaxTieClusterOverfetch { get; init; } = 65536;
+ ///
+ /// Gitea #691: whether HistoryRead-Raw honours ReadRawModifiedDetails.ReturnBounds (OPC UA Part 11 bounding
+ /// values). Default true. false is the kill switch back to the pre-#691 answer: the flag is ignored
+ /// (stored samples only) and a modified read carrying it is not refused Bad_InvalidArgument. Note the .NET
+ /// SDK's new ReadRawModifiedDetails() defaults ReturnBounds (and IsReadModified) to true.
+ ///
+ public bool HonorReturnBounds { get; init; } = true;
+
///
/// Maximum number of historized nodes served CONCURRENTLY within one HistoryRead batch (arch-review
/// 03/S3). The SDK's HistoryRead* override surface is synchronous, so each arm block-bridges
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayBoundingValueTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayBoundingValueTests.cs
index 7bea7f50..0b855923 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayBoundingValueTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayBoundingValueTests.cs
@@ -40,14 +40,28 @@ public sealed class GatewayBoundingValueTests
Quality = isNull ? 1u : 0u,
};
- /// ReadRaw over [start, end] ascending, capped, led by AVEVA's boundary row at the query start.
- private static FakeHistorianGatewayClient Aveva()
+ /// The latest query end any ReadRaw was sent (a far-future end is the #691 review's risk).
+ private sealed class EndProbe
+ {
+ public DateTime MaxEnd = DateTime.MinValue;
+ }
+
+ ///
+ /// ReadRaw over [start, end] ascending, capped, led by AVEVA's boundary row at the query start. A query end more
+ /// than two days past is REFUSED (InvalidArgument), modelling the unverified AVEVA behaviour
+ /// for a year-9999 end (the HistorianGateway only checks start <= end, so it reaches AVEVA unclamped).
+ ///
+ private static FakeHistorianGatewayClient Aveva(EndProbe? probe = null)
{
var sorted = Gate681.Select(x => (T: At(x.Sec), V: x.Value, x.Null)).ToList();
return new FakeHistorianGatewayClient
{
RawSource = (start, end, max) =>
{
+ if (probe is not null && end > probe.MaxEnd) probe.MaxEnd = end;
+ if (end > Now.AddDays(2))
+ throw new Grpc.Core.RpcException(new Grpc.Core.Status(Grpc.Core.StatusCode.InvalidArgument,
+ "end_time is outside the historian's time range"));
var rows = new List();
if (sorted.All(d => d.T != start))
{
@@ -124,6 +138,30 @@ public sealed class GatewayBoundingValueTests
missing.SourceTimestampUtc.ShouldBe(At(101));
}
+ ///
+ /// #691 review: the at-or-after read never sends an end beyond now + (the
+ /// historian stores nothing further ahead), and an end bound wanted beyond that horizon is not found without a read.
+ ///
+ [Fact]
+ public async Task The_at_or_after_read_never_asks_past_now_plus_one_day()
+ {
+ var probe = new EndProbe();
+ var fake = Aveva(probe);
+ var source = Source(fake);
+
+ var found = await HistoryBounds.EndBoundAsync(source, "T", HistoryBounds.For(At(0), At(41)), At(40), 65536, Now, Ct);
+ found.SourceTimestampUtc.ShouldBe(At(50));
+ probe.MaxEnd.ShouldBeLessThanOrEqualTo(Now + ReverseRawReader.LookAhead.Add(TimeSpan.FromMilliseconds(1)));
+
+ var calls = fake.ReadRawCallCount;
+ var beyond = Now.AddDays(3);
+ var missing = await HistoryBounds.EndBoundAsync(source, "T", HistoryBounds.For(At(0), beyond), At(100), 65536, Now, Ct);
+ missing.StatusCode.ShouldBe(Opc.Ua.StatusCodes.BadBoundNotFound);
+ missing.SourceTimestampUtc.ShouldBe(beyond);
+ fake.ReadRawCallCount.ShouldBe(calls, "a bound beyond the horizon is not found without a read");
+ source.GetHealthSnapshot().TotalFailures.ShouldBe(0);
+ }
+
[Fact]
public async Task A_reverse_read_end_bound_walks_back_to_the_stored_sample_at_or_before_the_end_time()
{
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/OpcUa/OtOpcUaServerHostedServiceHistoryUpdateTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/OpcUa/OtOpcUaServerHostedServiceHistoryUpdateTests.cs
index 9b3e9dc5..37461249 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/OpcUa/OtOpcUaServerHostedServiceHistoryUpdateTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/OpcUa/OtOpcUaServerHostedServiceHistoryUpdateTests.cs
@@ -72,6 +72,18 @@ public sealed class OtOpcUaServerHostedServiceHistoryUpdateTests
Service(view: null).BuildHistoryUpdatePosture().MaxFutureSkew.ShouldBe(TimeSpan.FromDays(1));
}
+ /// #691: the ReturnBounds kill switch is bound from ServerHistorian:HonorReturnBounds, default on.
+ [Fact]
+ public void The_return_bounds_kill_switch_comes_from_options_and_defaults_on()
+ {
+ Service(view: null).HonorReturnBounds.ShouldBeTrue();
+ var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary
+ {
+ ["ServerHistorian:HonorReturnBounds"] = "false",
+ }).Build();
+ Service(view: null, config: config).HonorReturnBounds.ShouldBeFalse();
+ }
+
private static OtOpcUaServerHostedService Service(
IHistoryUpdateRoleView? view, IDriverPrimaryRoleSource? primary = null, IConfiguration? config = null,
TimeProvider? clock = null) => new(
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadBoundsTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadBoundsTests.cs
index ebee310b..8b618b9e 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadBoundsTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadBoundsTests.cs
@@ -292,6 +292,71 @@ public sealed class NodeManagerHistoryReadBoundsTests(BoundsServerFixture fx) :
Render(values, T("5:00"), T("5:05")).ShouldBe("5:00, 5:02, 5:03, 5:05");
}
+ ///
+ /// #691 review: no data AND no bound found ⇒ GoodNoData with no values — the answer a tag with no history (and a
+ /// disabled historian) gave before bounds (§6.5.3.2: bounds make the result Good only "if one or both exist"). Only
+ /// when the WHOLE read is empty: a single bound found keeps the placeholders (Table 1: "4:48 4:48 1 ⇒ FIRST").
+ ///
+ [Theory]
+ [InlineData(Shape.Reverse)]
+ [InlineData(Shape.ForwardOnly)]
+ [InlineData(Shape.Microsecond)]
+ public async Task No_data_and_no_bound_found_is_GoodNoData_with_no_values(Shape shape)
+ {
+ var windows = new (string S, string E)[] { ("5:00", "5:05"), ("5:05", "5:00"), ("5:00", "5:00"), ("5:00", ""), ("", "5:05") };
+ foreach (var (start, end) in windows)
+ {
+ var s = start.Length == 0 ? Unspecified : T(start);
+ var e = end.Length == 0 ? Unspecified : T(end);
+ foreach (var n in new uint[] { 0, 1, 2, 3 })
+ {
+ if (n == 0 && (s == Unspecified || e == Unspecified)) continue; // a one-sided read needs a cap
+ var (values, error, cp) = await ReadResultAsync(Source(shape, []), s, e, n);
+ values.ShouldBeEmpty($"{start}->{end} by {n}");
+ cp.ShouldBeNull();
+ error.StatusCode.Code.ShouldBe(StatusCodes.Good);
+ LastNodeStatus.Code.ShouldBe(StatusCodes.GoodNoData, $"{start}->{end} by {n}");
+ }
+ }
+
+ // Positive control: one bound found keeps the missing one's placeholder.
+ var single = await DrainAsync(Source(shape, TableData()), T("4:48"), T("4:48"), 1, bounds: true);
+ Render(single, T("4:48"), T("4:48")).ShouldBe("FIRST, 5:00");
+ }
+
+ ///
+ /// #691 review: ServerHistorian:HonorReturnBounds = false restores the pre-#691 answer — ReturnBounds is ignored
+ /// (Table 1's "No" rows for every "Yes" row) and a modified read with ReturnBounds is not refused for it.
+ ///
+ [Fact]
+ public async Task The_kill_switch_ignores_return_bounds()
+ {
+ await fx.Gate.WaitAsync(TestContext.Current.CancellationToken);
+ try
+ {
+ fx.Nm.HonorReturnBounds = false;
+ fx.Nm.HistorianDataSource = Source(Shape.Reverse, TableData());
+ var (values, _, _) = Read(T("5:01"), T("5:07"), 0, bounds: true, cp: null, modified: false);
+ Render(values, T("5:01"), T("5:07")).ShouldBe("5:02, 5:03, 5:05, 5:06");
+
+ var (_, modErr, _) = Read(T("5:00"), T("5:05"), 0, bounds: true, cp: null, modified: true);
+ modErr.StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported, "no trail on this fake — not InvalidArgument");
+ }
+ finally
+ {
+ fx.Nm.HonorReturnBounds = true;
+ fx.Gate.Release();
+ }
+ }
+
+ [Fact]
+ public async Task Bounds_are_honoured_by_default()
+ {
+ await fx.Gate.WaitAsync(TestContext.Current.CancellationToken);
+ try { fx.Nm.HonorReturnBounds.ShouldBeTrue(); }
+ finally { fx.Gate.Release(); }
+ }
+
/// Part 11 §6.5.3.3: a modified read with ReturnBounds is Bad_InvalidArgument.
[Fact]
public async Task A_modified_read_with_return_bounds_is_an_invalid_argument()
@@ -357,6 +422,13 @@ public sealed class NodeManagerHistoryReadBoundsTests(BoundsServerFixture fx) :
return all;
}
+ /// The node-level StatusCode of the last (GoodNoData vs Good).
+ private StatusCode LastNodeStatus;
+
+ private async Task<(IReadOnlyList Values, ServiceResult Error, byte[]? Cp)> ReadResultAsync(
+ IHistorianDataSource source, DateTime start, DateTime end, uint max) =>
+ await ReadAsync(source, start, end, max, bounds: true, cp: null);
+
private async Task<(IReadOnlyList Values, ServiceResult Error, byte[]? Cp)> ReadAsync(
IHistorianDataSource source, DateTime start, DateTime end, uint max, bool bounds, byte[]? cp)
{
@@ -381,6 +453,7 @@ public sealed class NodeManagerHistoryReadBoundsTests(BoundsServerFixture fx) :
var results = new List { null! };
var errors = new List { null! };
fx.Nm.HistoryRead(context, details, TimestampsToReturn.Both, releaseContinuationPoints: false, nodesToRead, results, errors);
+ LastNodeStatus = results[0]?.StatusCode ?? StatusCodes.Bad;
var values = new List();
if (results[0]?.HistoryData is { } ho && !ExtensionObject.IsNull(ho))
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/ServerHistorianOptionsTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/ServerHistorianOptionsTests.cs
index 767ec21f..40bea597 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/ServerHistorianOptionsTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/ServerHistorianOptionsTests.cs
@@ -104,6 +104,21 @@ public sealed class ServerHistorianOptionsTests
/// #674: the HistoryUpdate future-skew bound defaults to one day and binds from configuration as a TimeSpan;
/// a non-positive value is a deliberate "disabled", not a misconfiguration, so it does not warn.
///
+ /// #691: the ReturnBounds kill switch defaults on and binds off; neither value warns.
+ [Fact]
+ public void HonorReturnBounds_defaults_to_true_and_binds_false_silently()
+ {
+ Assert.True(new ServerHistorianOptions().HonorReturnBounds);
+ var bound = new ConfigurationBuilder()
+ .AddInMemoryCollection(new Dictionary { ["ServerHistorian:HonorReturnBounds"] = "false" })
+ .Build()
+ .GetSection(ServerHistorianOptions.SectionName)
+ .Get();
+ Assert.False(bound!.HonorReturnBounds);
+ Assert.Empty(new ServerHistorianOptions
+ { Enabled = true, Endpoint = "https://h:5222", ApiKey = "histgw_x_y", HonorReturnBounds = false }.Validate());
+ }
+
[Fact]
public void HistoryUpdateMaxFutureSkew_defaults_to_one_day_binds_and_zero_is_silent()
{