fix(historian): #694 page raw reads at a strict gateway's learned ceiling; opt-in EventReadHorizon
Item 1: IHistorianDataSource.MaxRawValuesPerRead (Gateway: learned ceiling - 16 = L; 0 elsewhere, guard-tested). RawPageLimit plans every raw page on top of #692's lookahead: with L > 0 the read (page P + boundary ties k + lookahead) is <= L, P = min(N, L - k - 1) (uncapped: L - k - 1); no room => straight to the tie-cluster over-fetch (never an uncapped or underflowing request). One re-plan + re-read when the read taught a new limit or was refused for crowding pre-window rows (HistoryReadLimitExceededException.PreWindowRows): the page shrinks by them. Over-fetch bound min(MaxTieClusterOverfetch, L - 1); slice size never 0. Review fixes: F1 a window starting at/before the epoch counts AVEVA's synthetic row as pre-window (a ceiling-truncated read was reported complete); F2 the page path disables ReverseRawReader's own re-run (cold end-only ceiling 20/N=3000 cost 3,023 reads, now <= 40); F3 crowded look-back re-read around, not refused; F4 the #687 crowding re-read room at least doubles (up to 8 re-reads), so a cluster of hundreds just before a resume point no longer fails on a default gateway; a crowding refusal that leaves no room is BadTooManyOperations, never a tick-by-tick cursor. Item 2: ServerHistorian:EventReadHorizon (default 00:00:00 = exact, unchanged). When set, every fresh HistoryReadEvents window's earlier bound is raised to now - horizon (both directions, all backends). Lossy by design; documented. Item 3 (native TimeAverage/Total re-check) stays blocked on historiangw#27.
This commit is contained in:
+12
@@ -84,6 +84,18 @@ public sealed class OtOpcUaServerHostedServiceHistoryUpdateTests
|
||||
Service(view: null, config: config).HonorReturnBounds.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>#694: the events horizon is bound from <c>ServerHistorian:EventReadHorizon</c>, default none.</summary>
|
||||
[Fact]
|
||||
public void The_event_read_horizon_comes_from_options_and_defaults_off()
|
||||
{
|
||||
Service(view: null).EventReadHorizon.ShouldBe(TimeSpan.Zero);
|
||||
var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["ServerHistorian:EventReadHorizon"] = "365.00:00:00",
|
||||
}).Build();
|
||||
Service(view: null, config: config).EventReadHorizon.ShouldBe(TimeSpan.FromDays(365));
|
||||
}
|
||||
|
||||
private static OtOpcUaServerHostedService Service(
|
||||
IHistoryUpdateRoleView? view, IDriverPrimaryRoleSource? primary = null, IConfiguration? config = null,
|
||||
TimeProvider? clock = null) => new(
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
|
||||
|
||||
/// <summary>Gitea #694: <see cref="EventHorizon.TryClamp"/> — the pure window arithmetic of <c>ServerHistorian:EventReadHorizon</c>.</summary>
|
||||
public sealed class EventHorizonTests
|
||||
{
|
||||
private static readonly DateTime Now = new(2026, 10, 13, 12, 0, 0, DateTimeKind.Utc);
|
||||
private static readonly TimeSpan H = TimeSpan.FromDays(30);
|
||||
private static readonly DateTime Floor = Now - H;
|
||||
|
||||
[Fact]
|
||||
public void No_horizon_changes_nothing()
|
||||
{
|
||||
var (s, e) = (DateTime.MinValue, Now);
|
||||
EventHorizon.TryClamp(ref s, ref e, reverse: false, TimeSpan.Zero, Now).ShouldBeTrue();
|
||||
(s, e).ShouldBe((DateTime.MinValue, Now));
|
||||
EventHorizon.TryClamp(ref s, ref e, reverse: false, TimeSpan.FromDays(-1), Now).ShouldBeTrue();
|
||||
(s, e).ShouldBe((DateTime.MinValue, Now));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_forward_window_starts_at_the_floor()
|
||||
{
|
||||
var (s, e) = (DateTime.UnixEpoch, Now);
|
||||
EventHorizon.TryClamp(ref s, ref e, reverse: false, H, Now).ShouldBeTrue();
|
||||
(s, e).ShouldBe((Floor, Now));
|
||||
|
||||
var (s2, e2) = (Floor.AddDays(1), Now);
|
||||
EventHorizon.TryClamp(ref s2, ref e2, reverse: false, H, Now).ShouldBeTrue();
|
||||
s2.ShouldBe(Floor.AddDays(1)); // already inside: unchanged
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_reverse_window_keeps_the_floor_instant()
|
||||
{
|
||||
var (s, e) = (Now, new DateTime(0, DateTimeKind.Utc));
|
||||
EventHorizon.TryClamp(ref s, ref e, reverse: true, H, Now).ShouldBeTrue();
|
||||
(s, e).ShouldBe((Now, Floor.AddTicks(-1))); // (end, start] — the floor itself is read
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_window_wholly_before_the_floor_is_empty()
|
||||
{
|
||||
var (s, e) = (Floor.AddDays(-2), Floor);
|
||||
EventHorizon.TryClamp(ref s, ref e, reverse: false, H, Now).ShouldBeFalse(); // [start, end) ends at the floor
|
||||
|
||||
var (rs, re) = (Floor.AddTicks(-1), Floor.AddDays(-2));
|
||||
EventHorizon.TryClamp(ref rs, ref re, reverse: true, H, Now).ShouldBeFalse();
|
||||
|
||||
var (ok, oe) = (Floor, Floor.AddDays(-2));
|
||||
EventHorizon.TryClamp(ref ok, ref oe, reverse: true, H, Now).ShouldBeTrue(); // (…, floor] holds the floor
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_horizon_longer_than_the_calendar_is_no_horizon()
|
||||
{
|
||||
var (s, e) = (DateTime.MinValue, Now);
|
||||
EventHorizon.TryClamp(ref s, ref e, reverse: false, TimeSpan.MaxValue, Now).ShouldBeTrue();
|
||||
s.ShouldBe(DateTime.MinValue);
|
||||
}
|
||||
}
|
||||
+231
@@ -0,0 +1,231 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Server;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using HistorianRead = ZB.MOM.WW.OtOpcUa.Core.Abstractions.HistoryReadResult;
|
||||
using SdkHistoryReadResult = Opc.Ua.HistoryReadResult;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #694 item 2 (live gate F4): a walked-back events read on the Gateway proves "nothing older" with one forward read
|
||||
/// down to the Unix epoch (#691) — ~3.8 s live on <c>Runtime.dbo.Events</c>, and an unfiltered deep read hit the gateway's
|
||||
/// 30 s SQL timeout. The opt-in <c>ServerHistorian:EventReadHorizon</c> (<see cref="OtOpcUaNodeManager.EventReadHorizon"/>)
|
||||
/// bounds every events read at <c>now − horizon</c>, in both directions; off (the default) nothing changes. The backend is
|
||||
/// shaped like the Gateway's SQL event read: forward only, <c>[start, end]</c> inclusive, refuses <c>start > end</c>
|
||||
/// (counted), records every requested window.
|
||||
/// </summary>
|
||||
public sealed class NodeManagerHistoryReadEventsHorizonTests : IDisposable
|
||||
{
|
||||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||
private static readonly DateTime Now = new(2026, 10, 13, 12, 0, 0, DateTimeKind.Utc);
|
||||
private static readonly TimeSpan Horizon = TimeSpan.FromDays(30);
|
||||
private static readonly DateTime Floor = Now - Horizon;
|
||||
private readonly string _pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-eventhorizon-{Guid.NewGuid():N}");
|
||||
|
||||
/// <summary>Five recent events (the last five minutes) — a source with little history, as in the live gate.</summary>
|
||||
private static List<HistoricalEvent> Recent() =>
|
||||
Enumerable.Range(0, 5).Select(i => Ev($"r{i}", Now.AddMinutes(-5 + i))).ToList();
|
||||
|
||||
[Fact]
|
||||
public async Task Without_a_horizon_a_walked_reverse_read_proves_the_rest_empty_down_to_the_epoch()
|
||||
{
|
||||
// The positive control: today's exact behaviour, and the cost the horizon removes.
|
||||
var (host, nm, node, gw) = await SetupAsync(Recent());
|
||||
try
|
||||
{
|
||||
var r = ReadEvents(nm, node, max: 10, start: DateTime.MinValue, end: Now);
|
||||
r.Error.StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
r.Ids.ShouldBe(["r4", "r3", "r2", "r1", "r0"]);
|
||||
gw.Requests.Min(q => q.Start).ShouldBe(DateTime.UnixEpoch);
|
||||
gw.BadRequests.ShouldBe(0);
|
||||
}
|
||||
finally { await host.DisposeAsync(); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task With_a_horizon_the_walk_never_reads_before_it_and_answers_the_same()
|
||||
{
|
||||
var (host, nm, node, gw) = await SetupAsync(Recent());
|
||||
try
|
||||
{
|
||||
nm.EventReadHorizon = Horizon;
|
||||
var r = ReadEvents(nm, node, max: 10, start: DateTime.MinValue, end: Now);
|
||||
r.Error.StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
r.Ids.ShouldBe(["r4", "r3", "r2", "r1", "r0"]);
|
||||
r.Cp.ShouldBeNull();
|
||||
// Requests are rounded outward to the millisecond, so the earliest one may start up to 1 ms before the floor.
|
||||
gw.Requests.Min(q => q.Start).ShouldBeGreaterThanOrEqualTo(Floor.AddMilliseconds(-1));
|
||||
gw.BadRequests.ShouldBe(0);
|
||||
}
|
||||
finally { await host.DisposeAsync(); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Events_older_than_the_horizon_are_not_returned_in_either_direction()
|
||||
{
|
||||
var events = new List<HistoricalEvent> { Ev("old", Floor.AddDays(-10)), Ev("edge", Floor), Ev("new", Now.AddDays(-1)) };
|
||||
var (host, nm, node, gw) = await SetupAsync(events);
|
||||
try
|
||||
{
|
||||
nm.EventReadHorizon = Horizon;
|
||||
|
||||
var reverse = ReadEvents(nm, node, max: 10, start: Now, end: DateTime.UnixEpoch);
|
||||
reverse.Ids.ShouldBe(["new", "edge"]); // the floor instant itself is inside
|
||||
|
||||
var forward = ReadEvents(nm, node, max: 10, start: DateTime.UnixEpoch, end: Now);
|
||||
forward.Ids.ShouldBe(["edge", "new"]);
|
||||
|
||||
gw.Requests.ShouldAllBe(q => q.Start >= Floor.AddMilliseconds(-1));
|
||||
|
||||
nm.EventReadHorizon = TimeSpan.Zero; // off again: exact to the window
|
||||
ReadEvents(nm, node, max: 10, start: DateTime.UnixEpoch, end: Now).Ids.ShouldBe(["old", "edge", "new"]);
|
||||
}
|
||||
finally { await host.DisposeAsync(); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_window_wholly_older_than_the_horizon_is_GoodNoData_and_never_sent()
|
||||
{
|
||||
var (host, nm, node, gw) = await SetupAsync([Ev("old", Floor.AddDays(-10))]);
|
||||
try
|
||||
{
|
||||
nm.EventReadHorizon = Horizon;
|
||||
var forward = ReadEvents(nm, node, max: 10, start: Floor.AddDays(-20), end: Floor.AddDays(-1));
|
||||
forward.Error.StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
forward.Status.Code.ShouldBe(StatusCodes.GoodNoData);
|
||||
forward.Ids.ShouldBeEmpty();
|
||||
|
||||
var reverse = ReadEvents(nm, node, max: 10, start: Floor.AddDays(-1), end: Floor.AddDays(-20));
|
||||
reverse.Status.Code.ShouldBe(StatusCodes.GoodNoData);
|
||||
|
||||
gw.Requests.ShouldBeEmpty();
|
||||
}
|
||||
finally { await host.DisposeAsync(); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_page_through_under_a_horizon_keeps_its_floor_across_continuation_points()
|
||||
{
|
||||
var events = Enumerable.Range(0, 25).Select(i => Ev($"e{i:00}", Now.AddMinutes(-i))).ToList();
|
||||
events.Add(Ev("old", Floor.AddDays(-1)));
|
||||
var (host, nm, node, gw) = await SetupAsync(events);
|
||||
try
|
||||
{
|
||||
nm.EventReadHorizon = Horizon;
|
||||
var all = new List<string>();
|
||||
byte[]? cp = null;
|
||||
var pages = 0;
|
||||
do
|
||||
{
|
||||
var r = ReadEvents(nm, node, max: 10, start: DateTime.MinValue, end: Now, inboundCp: cp);
|
||||
r.Error.StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
all.AddRange(r.Ids);
|
||||
cp = r.Cp;
|
||||
(++pages).ShouldBeLessThan(10);
|
||||
}
|
||||
while (cp is not null);
|
||||
|
||||
all.ShouldBe(Enumerable.Range(0, 25).Select(i => $"e{i:00}"));
|
||||
gw.Requests.ShouldAllBe(q => q.Start >= Floor.AddMilliseconds(-1));
|
||||
}
|
||||
finally { await host.DisposeAsync(); }
|
||||
}
|
||||
|
||||
// ---- helpers ---------------------------------------------------------------------------------------------------------
|
||||
|
||||
private static HistoricalEvent Ev(string id, DateTime at) => new(id, "src", at, at, "m", 500);
|
||||
|
||||
private async Task<(OpcUaApplicationHost Host, OtOpcUaNodeManager Nm, NodeId Node, GatewayShapedEventSource Gw)> SetupAsync(
|
||||
List<HistoricalEvent> events)
|
||||
{
|
||||
var started = await OpcUaHostStart.StartAsync(
|
||||
port => new OpcUaApplicationHost(
|
||||
new OpcUaApplicationHostOptions
|
||||
{
|
||||
ApplicationName = "OtOpcUa.EventHorizonTest",
|
||||
ApplicationUri = $"urn:OtOpcUa.EventHorizonTest:{Guid.NewGuid():N}",
|
||||
OpcUaPort = port,
|
||||
PublicHostname = "localhost",
|
||||
PkiStoreRoot = _pkiRoot,
|
||||
},
|
||||
NullLogger<OpcUaApplicationHost>.Instance),
|
||||
() => new OtOpcUaSdkServer(), Ct);
|
||||
var nm = started.Server.NodeManager!;
|
||||
nm.HistoryContinuationStore = new InMemoryHistoryContinuationStore();
|
||||
nm.Clock = new FixedClock(Now);
|
||||
var gw = new GatewayShapedEventSource(events);
|
||||
nm.HistorianDataSource = gw;
|
||||
nm.EnsureFolder("eq-evt", parentNodeId: null, displayName: "Equipment", realm: AddressSpaceRealm.Uns);
|
||||
nm.MaterialiseAlarmCondition("alarm-1", "eq-evt", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns);
|
||||
return (started.Host, nm, nm.TryGetFolder("eq-evt")!.NodeId, gw);
|
||||
}
|
||||
|
||||
private static (List<string> Ids, ServiceResult Error, StatusCode Status, byte[]? Cp) ReadEvents(
|
||||
OtOpcUaNodeManager nm, NodeId node, uint max, DateTime start, DateTime end, byte[]? inboundCp = null)
|
||||
{
|
||||
var context = new OperationContext(new RequestHeader(), secureChannelContext: null, RequestType.HistoryRead, identity: null);
|
||||
var filter = new EventFilter();
|
||||
filter.SelectClauses.Add(new SimpleAttributeOperand(ObjectTypeIds.BaseEventType, new QualifiedName("EventId"))
|
||||
{
|
||||
AttributeId = Attributes.Value,
|
||||
});
|
||||
var details = new ReadEventDetails { StartTime = start, EndTime = end, NumValuesPerNode = max, Filter = filter };
|
||||
var nodesToRead = new List<HistoryReadValueId> { new() { NodeId = node, ContinuationPoint = inboundCp } };
|
||||
var results = new List<SdkHistoryReadResult> { null! };
|
||||
var errors = new List<ServiceResult> { null! };
|
||||
nm.HistoryRead(context, details, TimestampsToReturn.Both, false, nodesToRead, results, errors);
|
||||
|
||||
var ids = new List<string>();
|
||||
if (results[0]?.HistoryData is { } ho && !ExtensionObject.IsNull(ho))
|
||||
foreach (var e in ((HistoryEvent)ExtensionObject.ToEncodeable(ho)).Events)
|
||||
ids.Add(System.Text.Encoding.UTF8.GetString((byte[])e.EventFields[0].Value));
|
||||
return (ids, errors[0], results[0]?.StatusCode ?? StatusCodes.Good, results[0]?.ContinuationPoint);
|
||||
}
|
||||
|
||||
/// <summary>The Gateway's SQL event read: forward only, <c>[start, end]</c> inclusive, time-ordered, capped + flag.</summary>
|
||||
private sealed class GatewayShapedEventSource(List<HistoricalEvent> events) : IHistorianDataSource
|
||||
{
|
||||
public List<(DateTime Start, DateTime End, int Max)> Requests { get; } = [];
|
||||
public int BadRequests { get; private set; }
|
||||
public bool SupportsReverseEvents => false;
|
||||
|
||||
public Task<HistoricalEventsResult> ReadEventsAsync(string? n, DateTime s, DateTime e, int m, CancellationToken ct)
|
||||
{
|
||||
Requests.Add((s, e, m));
|
||||
if (s > e) BadRequests++; // the gateway's ValidateRange
|
||||
var window = events.Where(x => x.EventTimeUtc >= s && x.EventTimeUtc <= e).OrderBy(x => x.EventTimeUtc).ToList();
|
||||
var cap = m <= 0 ? int.MaxValue : m;
|
||||
return Task.FromResult(new HistoricalEventsResult(window.Take(cap).ToList(), window.Count > cap ? new byte[] { 1 } : null));
|
||||
}
|
||||
|
||||
public Task<HistoricalEventsResult> ReadEventsAsync(string? n, DateTime s, DateTime e, int m, bool reverse, CancellationToken ct) =>
|
||||
reverse ? throw new NotSupportedException("forward only") : ReadEventsAsync(n, s, e, m, ct);
|
||||
|
||||
public Task<HistorianRead> ReadRawAsync(string r, DateTime s, DateTime e, uint m, CancellationToken ct) =>
|
||||
NullHistorianDataSource.Instance.ReadRawAsync(r, s, e, m, ct);
|
||||
public Task<HistorianRead> ReadProcessedAsync(string r, DateTime s, DateTime e, TimeSpan i, HistoryAggregateType a, CancellationToken ct) =>
|
||||
NullHistorianDataSource.Instance.ReadProcessedAsync(r, s, e, i, a, ct);
|
||||
public Task<HistorianRead> ReadAtTimeAsync(string r, IReadOnlyList<DateTime> t, CancellationToken ct) =>
|
||||
NullHistorianDataSource.Instance.ReadAtTimeAsync(r, t, ct);
|
||||
public HistorianHealthSnapshot GetHealthSnapshot() => NullHistorianDataSource.Instance.GetHealthSnapshot();
|
||||
public void Dispose() { }
|
||||
}
|
||||
|
||||
private sealed class FixedClock(DateTime now) : TimeProvider
|
||||
{
|
||||
public override DateTimeOffset GetUtcNow() => new(now);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_pkiRoot))
|
||||
{
|
||||
try { Directory.Delete(_pkiRoot, recursive: true); }
|
||||
catch { /* best-effort */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #694: <see cref="RawPageLimit"/> — the page plan (P = min(want, L − k − 1), request = P + k + 1 ≤ L) and the one
|
||||
/// re-plan after a refusal. The end-to-end behaviour over the real Gateway data source is pinned in
|
||||
/// <c>GatewayCeilingPagingTests</c>.
|
||||
/// </summary>
|
||||
public sealed class RawPageLimitTests
|
||||
{
|
||||
[Theory]
|
||||
// want, k, L ⇒ page, request, noRoom
|
||||
[InlineData(5000u, 0, 0, 5000u, 5001u, false)] // no limit: #692 unchanged (page + ties + lookahead)
|
||||
[InlineData(5000u, 3, 0, 5000u, 5004u, false)]
|
||||
[InlineData(0u, 3, 0, 0u, 0u, false)] // no limit, uncapped: one uncapped read
|
||||
[InlineData(5000u, 0, 984, 983u, 984u, false)] // over the limit: the request fills the limit exactly
|
||||
[InlineData(5000u, 10, 984, 973u, 984u, false)] // boundary ties take room from the page
|
||||
[InlineData(10u, 10, 984, 10u, 21u, false)] // under the limit: unchanged
|
||||
[InlineData(0u, 0, 984, 983u, 984u, false)] // uncapped: a limit-sized page
|
||||
[InlineData(0u, 982, 984, 1u, 984u, false)]
|
||||
[InlineData(5000u, 983, 984, 0u, 0u, true)] // the ties alone fill a read: never a 0 (uncapped) request
|
||||
[InlineData(5000u, 5000, 984, 0u, 0u, true)] // never an underflow
|
||||
[InlineData(5000u, 0, 1, 0u, 0u, true)]
|
||||
public void Plan_fits_page_ties_and_lookahead_into_the_limit(uint want, int k, int limit, uint page, uint request, bool noRoom)
|
||||
{
|
||||
var plan = RawPageLimit.Plan(want, k, limit);
|
||||
plan.PageCap.ShouldBe(page);
|
||||
plan.RequestCap.ShouldBe(request);
|
||||
plan.NoRoom.ShouldBe(noRoom);
|
||||
if (limit > 0 && !noRoom) plan.RequestCap.ShouldBeLessThanOrEqualTo((uint)limit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_read_that_teaches_the_limit_is_re_planned_once_at_it()
|
||||
{
|
||||
var source = new LimitSource();
|
||||
var asked = new List<uint>();
|
||||
var (page, plan) = await RawPageLimit.ReadAsync(source, 5000u, 0, p =>
|
||||
{
|
||||
asked.Add(p.RequestCap);
|
||||
if (p.RequestCap > 984) { source.Limit = 984; throw new HistoryReadLimitExceededException(1000, "ceiling"); }
|
||||
return Task.FromResult("ok");
|
||||
});
|
||||
page.ShouldBe("ok");
|
||||
plan.PageCap.ShouldBe(983u);
|
||||
asked.ShouldBe([5001u, 984u]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_crowded_refusal_shrinks_the_page_by_the_pre_window_rows()
|
||||
{
|
||||
var source = new LimitSource { Limit = 984 };
|
||||
var asked = new List<uint>();
|
||||
var (_, plan) = await RawPageLimit.ReadAsync(source, 0u, 2, p =>
|
||||
{
|
||||
asked.Add(p.RequestCap);
|
||||
if (asked.Count == 1) throw new HistoryReadLimitExceededException(1000, "crowded") { PreWindowRows = 21 };
|
||||
return Task.FromResult("ok");
|
||||
});
|
||||
asked.ShouldBe([984u, 963u]);
|
||||
plan.PageCap.ShouldBe(960u);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_refusal_at_an_unchanged_limit_without_crowding_is_the_answer()
|
||||
{
|
||||
var source = new LimitSource { Limit = 984 };
|
||||
var calls = 0;
|
||||
await Should.ThrowAsync<HistoryReadLimitExceededException>(() => RawPageLimit.ReadAsync<string>(source, 5000u, 0, _ =>
|
||||
{
|
||||
calls++;
|
||||
throw new HistoryReadLimitExceededException(1000, "tie cluster");
|
||||
}));
|
||||
calls.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_second_refusal_is_not_retried_again()
|
||||
{
|
||||
var source = new LimitSource();
|
||||
var calls = 0;
|
||||
await Should.ThrowAsync<HistoryReadLimitExceededException>(() => RawPageLimit.ReadAsync<string>(source, 0u, 0, _ =>
|
||||
{
|
||||
calls++;
|
||||
source.Limit = 984 - calls; // even if the limit keeps moving
|
||||
throw new HistoryReadLimitExceededException(1000, "refused") { PreWindowRows = 5 };
|
||||
}));
|
||||
calls.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task No_room_is_returned_without_a_read()
|
||||
{
|
||||
var source = new LimitSource { Limit = 10 };
|
||||
var calls = 0;
|
||||
var (page, plan) = await RawPageLimit.ReadAsync(source, 5u, 9, _ => { calls++; return Task.FromResult("x"); });
|
||||
plan.NoRoom.ShouldBeTrue();
|
||||
page.ShouldBeNull();
|
||||
calls.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Other_failures_are_not_retried()
|
||||
{
|
||||
var source = new LimitSource();
|
||||
var calls = 0;
|
||||
await Should.ThrowAsync<InvalidOperationException>(() => RawPageLimit.ReadAsync<string>(source, 5000u, 0, _ =>
|
||||
{
|
||||
calls++;
|
||||
source.Limit = 984;
|
||||
throw new InvalidOperationException("backend fault");
|
||||
}));
|
||||
calls.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>Only <see cref="IHistorianDataSource.MaxRawValuesPerRead"/> matters here.</summary>
|
||||
private sealed class LimitSource : IHistorianDataSource
|
||||
{
|
||||
public int Limit;
|
||||
|
||||
public int MaxRawValuesPerRead => Limit;
|
||||
|
||||
public Task<HistoryReadResult> ReadRawAsync(string fullReference, DateTime startUtc, DateTime endUtc, uint maxValuesPerNode,
|
||||
CancellationToken cancellationToken) => throw new NotSupportedException();
|
||||
|
||||
public Task<HistoryReadResult> ReadProcessedAsync(string fullReference, DateTime startUtc, DateTime endUtc, TimeSpan interval,
|
||||
HistoryAggregateType aggregate, CancellationToken cancellationToken) => throw new NotSupportedException();
|
||||
|
||||
public Task<HistoryReadResult> ReadAtTimeAsync(string fullReference, IReadOnlyList<DateTime> timestampsUtc,
|
||||
CancellationToken cancellationToken) => throw new NotSupportedException();
|
||||
|
||||
public Task<HistoricalEventsResult> ReadEventsAsync(string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents,
|
||||
CancellationToken cancellationToken) => throw new NotSupportedException();
|
||||
|
||||
public HistorianHealthSnapshot GetHealthSnapshot() => NullHistorianDataSource.Instance.GetHealthSnapshot();
|
||||
|
||||
public void Dispose() { }
|
||||
}
|
||||
}
|
||||
@@ -119,6 +119,24 @@ public sealed class ServerHistorianOptionsTests
|
||||
{ Enabled = true, Endpoint = "https://h:5222", ApiKey = "histgw_x_y", HonorReturnBounds = false }.Validate());
|
||||
}
|
||||
|
||||
/// <summary>#694: the events horizon defaults off, binds as a TimeSpan, and only a negative value warns.</summary>
|
||||
[Fact]
|
||||
public void EventReadHorizon_defaults_off_binds_and_only_a_negative_value_warns()
|
||||
{
|
||||
Assert.Equal(TimeSpan.Zero, new ServerHistorianOptions().EventReadHorizon);
|
||||
var bound = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?> { ["ServerHistorian:EventReadHorizon"] = "3650.00:00:00" })
|
||||
.Build()
|
||||
.GetSection(ServerHistorianOptions.SectionName)
|
||||
.Get<ServerHistorianOptions>();
|
||||
Assert.Equal(TimeSpan.FromDays(3650), bound!.EventReadHorizon);
|
||||
Assert.Empty(new ServerHistorianOptions
|
||||
{ Enabled = true, Endpoint = "https://h:5222", ApiKey = "histgw_x_y", EventReadHorizon = TimeSpan.FromDays(30) }.Validate());
|
||||
var w = new ServerHistorianOptions
|
||||
{ Enabled = true, Endpoint = "https://h:5222", ApiKey = "histgw_x_y", EventReadHorizon = TimeSpan.FromDays(-1) }.Validate();
|
||||
Assert.Contains(w, m => m.Contains("EventReadHorizon"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HistoryUpdateMaxFutureSkew_defaults_to_one_day_binds_and_zero_is_silent()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user