fix(historian): #687 review — one bounded re-read instead of failing, raw budget unclamped, exclusive forward end

- M1/M2: when the 1 ms pre-window margin (boundary row + stored samples, e.g. a
  >16-sample tie cluster at start-1ms / lastTs-1ms) uses up the cap + 16 budget,
  re-read ONCE asking for cap + m + 16 (m = pre-window rows seen); fail
  BadTooManyOperations only if the re-read is still short because of them. The
  raw budget is no longer clamped at the gateway default ceiling (AVEVA always
  emits the boundary row, so page sizes 999,985-1,000,000 always came back
  short); the #688 clamp stays on the per-sample aggregate path only.
- Merge trap: NumValuesPerNode 0 => int.MaxValue, never through the clamp.
- L3: the forward end is exclusive ([start, end), the IHistorianDataSource
  contract); a point read (start == end, the tie-cluster over-fetch) inclusive.
- RequestStartFor never goes before the Unix epoch (#686's walk-back floor).
- L4 (docs): an Uncertain native row for an all-NULL interval is unverified live.
This commit is contained in:
Joseph Doherty
2026-10-12 15:19:34 -04:00
parent 6796ba4126
commit 8114f13a28
5 changed files with 202 additions and 63 deletions
@@ -125,14 +125,6 @@ public sealed class GatewayRawBoundaryRowTests
fake.RawCalls.Single().ShouldBe((At(30) - RawAggregateReader.BoundaryMargin, At(55), 5 + RawAggregateReader.PreWindowAllowance));
}
[Fact]
public async Task An_unlimited_read_stays_unlimited()
{
var fake = Gate();
await Source(fake).ReadRawAsync("T", At(30), At(55), 0, Ct);
fake.RawCalls.Single().MaxValues.ShouldBe(0);
}
[Fact]
public async Task A_full_page_holds_exactly_the_cap_and_the_stream_is_drained_not_abandoned()
{
@@ -148,13 +140,19 @@ public sealed class GatewayRawBoundaryRowTests
[InlineData(3u)]
public async Task Server_style_paging_returns_every_stored_sample_once_and_nothing_else(uint cap)
{
// Ties at +60 (three samples) and a stored NULL at +40, over the AVEVA-shaped wire.
var data = Gate681.Select(x => (At(x.Sec), x.Value, x.Null))
.Concat([(At(60), 2d, false), (At(60), 3d, false)]).ToList();
var fake = Aveva(data);
var ds = Source(fake);
// Ties at +60 (three samples), a stored NULL at +40, a 20-sample tie cluster 1 ms before the +60 resume point
// (crowds that resume read's margin: more than the 16-row allowance) and one 1 ms before the window start (outside
// the window; crowds the first read's margin), over the AVEVA-shaped wire.
var start = At(-10);
var end = At(200);
var inside = Gate681.Select(x => (At(x.Sec), x.Value, x.Null))
.Concat([(At(60), 2d, false), (At(60), 3d, false)])
.Concat(Enumerable.Range(0, 20).Select(i => (At(60) - TimeSpan.FromMilliseconds(1), 100d + i, false)))
.ToList();
var outside = Enumerable.Range(0, 20).Select(i => (start - TimeSpan.FromMilliseconds(1), 200d + i, false));
var data = inside;
var fake = Aveva(inside.Concat(outside).ToList());
var ds = Source(fake);
var got = new List<DataValueSnapshot>();
var skip = 0;
var cursor = start;
@@ -195,15 +193,103 @@ public sealed class GatewayRawBoundaryRowTests
}
[Fact]
public async Task A_margin_that_swallows_the_row_budget_fails_the_node_rather_than_answering_a_short_page()
public async Task A_margin_that_swallows_the_row_budget_is_re_read_once_with_room_for_it()
{
// More stored rows inside the 1 ms margin than the allowance: the page could not be read, and a short page would
// end the client's paging early — refuse it (a request limit, not a backend fault).
var margin = Enumerable.Range(0, 40).Select(i => (At(30).AddTicks(-1 - i), (double)i, false));
// 30 stored rows inside the 1 ms margin (plus AVEVA's boundary row) eat the whole first budget (5 + 16): the page is
// re-read ONCE asking for cap + m + 16 (m = the pre-window rows the first read saw), which fits, and the full page
// is answered — a short page would end the client's paging early.
var margin = Enumerable.Range(0, 30).Select(i => (At(30).AddTicks(-1 - i), (double)i, false));
var window = Enumerable.Range(0, 10).Select(i => (At(31 + i), 1000d + i, false));
var fake = Aveva(margin.Concat(window).ToList());
var ds = Source(fake);
var r = await ds.ReadRawAsync("T", At(30), At(50), 5, Ct);
r.Samples.Select(s => (double)s.Value!).ShouldBe([1000d, 1001d, 1002d, 1003d, 1004d]);
fake.RawCalls.Select(c => c.MaxValues).ShouldBe([5 + 16, 5 + 21 + 16]);
fake.RawCalls.ShouldAllBe(c => c.Start == At(30) - RawAggregateReader.BoundaryMargin);
fake.RawStreamsCompleted.ShouldBe(2);
ds.GetHealthSnapshot().TotalFailures.ShouldBe(0);
}
[Fact]
public async Task A_margin_still_crowding_the_re_read_fails_the_node_as_a_request_limit()
{
// 200 rows in the margin: the single bounded re-read (5 + 21 + 16) is still short of the cap because of them.
var margin = Enumerable.Range(0, 200).Select(i => (At(30).AddTicks(-1 - i), (double)i, false));
var fake = Aveva(margin.Append((At(35), 1d, false)).ToList());
var ds = Source(fake);
await Should.ThrowAsync<HistoryReadLimitExceededException>(() => ds.ReadRawAsync("T", At(30), At(40), 5, Ct));
fake.ReadRawCallCount.ShouldBe(2); // one bounded re-read, never more
fake.RawStreamsCompleted.ShouldBe(2);
ds.GetHealthSnapshot().TotalFailures.ShouldBe(0);
fake.RawStreamsCompleted.ShouldBe(1);
}
[Fact]
public async Task A_short_read_is_complete_and_is_not_re_read()
{
var fake = Gate();
await Source(fake).ReadRawAsync("T", At(30), At(55), 5, Ct);
fake.ReadRawCallCount.ShouldBe(1);
}
[Fact]
public async Task A_read_that_reached_past_the_window_end_is_complete_and_is_not_re_read()
{
// Budget 3 + 16 = 19 = boundary + 16 margin rows + the one window row + the row stored at the (exclusive) end:
// the stream is used up and the page is short, but it reached the end — every window row was seen.
var margin = Enumerable.Range(0, 16).Select(i => (At(30).AddTicks(-1 - i), (double)i, false));
var fake = Aveva(margin.Append((At(35), 1d, false)).Append((At(40), 2d, false)).ToList());
var ds = Source(fake);
var r = await ds.ReadRawAsync("T", At(30), At(40), 3, Ct);
r.Samples.Select(s => (double)s.Value!).ShouldBe([1d]);
fake.RawYieldedCount.ShouldBe(19);
fake.ReadRawCallCount.ShouldBe(1);
}
[Theory] // M1: the raw budget is NOT clamped at the gateway's default ceiling — AVEVA always emits a boundary row, so a
// page size within 16 of the ceiling would always come back short (a strict gateway refuses instead ⇒
// BadTooManyOperations, a default gateway serves it).
[InlineData(999_984u, 1_000_000)]
[InlineData(999_990u, 1_000_006)]
[InlineData(1_000_000u, 1_000_016)]
public async Task The_raw_row_budget_is_not_clamped_at_the_gateways_default_ceiling(uint cap, int expected)
{
var fake = Gate();
await Source(fake).ReadRawAsync("T", At(30), At(55), cap, Ct);
fake.RawCalls.Single().MaxValues.ShouldBe(expected);
}
// ---- L3: the forward end bound is exclusive; a point read is inclusive ----------------------------------------
[Fact]
public async Task A_stored_sample_exactly_at_the_window_end_is_not_returned()
{
var r = await Source(Gate()).ReadRawAsync("T", At(0), At(20), 0, Ct);
r.Samples.Select(s => s.SourceTimestampUtc).ShouldBe(new DateTime?[] { At(0), At(5) });
}
[Fact]
public async Task A_point_read_returns_the_samples_stored_at_that_instant()
{
var data = new List<(DateTime, double, bool)> { (At(10), 1, false), (At(20), 2, false), (At(20), 3, false), (At(30), 4, false) };
var r = await Source(Aveva(data)).ReadRawAsync("T", At(20), At(20), 1000, Ct);
r.Samples.Select(s => (double)s.Value!).ShouldBe([2d, 3d]);
}
// ---- the Unix-epoch floor ----------------------------------------------------------------------------------------
[Theory] // the gateway's time encoding starts at the Unix epoch: the 1 ms margin never reaches before it
[InlineData(0L)]
[InlineData(5_000L)]
public void The_request_start_never_precedes_the_Unix_epoch(long ticksAfterEpoch)
{
var start = DateTime.UnixEpoch.AddTicks(ticksAfterEpoch);
RawAggregateReader.RequestStartFor(start).ShouldBe(DateTime.UnixEpoch);
RawAggregateReader.RequestStartFor(At(0)).ShouldBe(At(0) - RawAggregateReader.BoundaryMargin);
}
}