fix(historian): #689 size reverse-walk segments from the page, not the tie budget
ReverseRawReader aimed every segment at half of MaxTieClusterOverfetch (~32k rows by default) and carried that width page to page, so a paged 'latest N' read on a dense Gateway tag fetched ~32,770 rows per page (2-5 s live) to return N. The walk now lives in a generic ReverseWalk core: segments are aimed at ~N+8 rows (page budget 2N+16), a discovery segment (first without a hint / after an empty one) may read 1,024, and the tie budget is only the ceiling a single-timestamp cluster escalates to. A truncated read never re-enters the stretch below its newest row until the gap above it is bisected. Tests: per-page row counts on a dense tag (node manager + walk), randomized exactness fuzz (walk and node-manager paging), dense-tag-below-a-long-gap read bound.
This commit is contained in:
+102
-3
@@ -650,6 +650,95 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #689: a paged end-only read on a dense tag sizes each segment from the PAGE (N), not from
|
||||
/// <c>MaxTieClusterOverfetch</c>. Live (AVEVA, 1 Hz, N = 3): from about page 5 every page read 32,770 rows and took
|
||||
/// 2–5 s to return 2 rows, because the walk re-aimed its width at half the 65,536-row tie budget and carried it.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Raw_reverse_paging_on_a_dense_forward_only_tag_reads_about_the_page_size_per_page()
|
||||
{
|
||||
// 100,000 samples 100 ms apart (~2.8 h): far more than the default tie budget would ever let a page read.
|
||||
var series = Enumerable.Range(0, 100_000)
|
||||
.Select(i => { var t = Epoch.AddMilliseconds(100L * i); return new DataValueSnapshot((double)i, StatusCodes.Good, t, t); })
|
||||
.ToArray();
|
||||
var source = new ForwardOnlyHistorianDataSource(series);
|
||||
var (host, nm, nodeId) = await BootForwardOnlyAsync(source, "FwdDense689");
|
||||
nm.MaxTieClusterOverfetch.ShouldBe(65536, "the default budget — the one the live gate ran with");
|
||||
var end = Epoch.AddMilliseconds(100L * 99_999);
|
||||
|
||||
byte[]? cp = null;
|
||||
var expected = 99_999;
|
||||
for (var page = 1; page <= 20; page++)
|
||||
{
|
||||
source.ResetCounts();
|
||||
var (values, error, next) = ReadRaw(nm, nodeId, DateTime.MinValue, end, max: 3, inboundCp: cp);
|
||||
error.StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
values.ShouldNotBeEmpty();
|
||||
values[^1].ShouldBe((double)(expected - 2), $"page {page} continues exactly");
|
||||
expected = (int)values[^1];
|
||||
next.ShouldNotBeNull();
|
||||
cp = next;
|
||||
|
||||
// The discriminating counts: rows read and the largest cap asked for, per page. Page 1 has no density yet and
|
||||
// may read one discovery segment (a minute here, 600 rows); every page after it is sized from N.
|
||||
var rowsBound = page == 1 ? ReverseWalk.DiscoveryRows + 2 : 64;
|
||||
source.RowsReturned.ShouldBeLessThanOrEqualTo(rowsBound, $"page {page} read {source.RowsReturned} rows to return {values.Count}");
|
||||
source.MaxCapRequested.ShouldBeLessThanOrEqualTo((uint)rowsBound, $"page {page} asked for {source.MaxCapRequested} rows");
|
||||
source.ReadCount.ShouldBeLessThanOrEqualTo(3, $"page {page}");
|
||||
}
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #689/#691: randomized end-to-end paging of end-only reads through the node manager on a forward-only backend —
|
||||
/// bursts, gaps, sub-millisecond stamps and tie clusters (some larger than the page) — the union of the pages is
|
||||
/// every sample exactly once, newest first, whatever N and the tie budget.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Raw_reverse_paging_on_a_forward_only_backend_is_exact_on_random_data()
|
||||
{
|
||||
var (host, nm, nodeId) = await BootForwardOnlyAsync(new ForwardOnlyHistorianDataSource([]), "FwdFuzz");
|
||||
for (var seed = 0; seed < 150; seed++)
|
||||
{
|
||||
var rng = new Random(seed);
|
||||
var series = new List<DataValueSnapshot>();
|
||||
var t = Epoch.Ticks;
|
||||
var v = 0;
|
||||
var count = rng.Next(1, 300);
|
||||
while (series.Count < count)
|
||||
{
|
||||
t += rng.Next(5) switch
|
||||
{
|
||||
0 => rng.Next(1, 10),
|
||||
1 => rng.Next(1, 5) * TimeSpan.TicksPerMillisecond,
|
||||
2 => rng.Next(1, 90) * TimeSpan.TicksPerMinute,
|
||||
_ => rng.Next(1, 2000) * TimeSpan.TicksPerMillisecond,
|
||||
};
|
||||
var ties = rng.Next(6) == 0 ? rng.Next(2, 7) : 1;
|
||||
for (var k = 0; k < ties; k++)
|
||||
{
|
||||
var ts = new DateTime(t, DateTimeKind.Utc);
|
||||
series.Add(new DataValueSnapshot((double)v++, StatusCodes.Good, ts, ts));
|
||||
}
|
||||
}
|
||||
|
||||
// No fabricated start row: the Gateway source drops it (#687) — the node manager's tie-cluster point read relies on
|
||||
// that. The walk itself is fuzzed WITH the row in ReverseWalkTests.
|
||||
nm.HistorianDataSource = new ForwardOnlyHistorianDataSource(series, emitBoundaryRow: false);
|
||||
nm.MaxTieClusterOverfetch = rng.Next(2) == 0 ? 8 : 65536;
|
||||
var max = (uint)rng.Next(1, 12);
|
||||
var end = new DateTime(t + rng.Next(0, 3) * TimeSpan.TicksPerSecond, DateTimeKind.Utc);
|
||||
|
||||
var collected = await DrainAsync(nm, nodeId, DateTime.MinValue, end, max, maxPages: 2000);
|
||||
|
||||
collected.ShouldBe(Enumerable.Range(0, series.Count).Reverse().Select(i => (double)i), $"seed {seed}, N = {max}");
|
||||
}
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>#686 review: a reverse read from <c>StartTime = DateTime.MaxValue</c> walks back from now + 1 day, not from
|
||||
/// year 9999.</summary>
|
||||
[Fact]
|
||||
@@ -978,9 +1067,15 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable
|
||||
/// <summary>Reads whose row count equalled the cap (a full, possibly truncated, read).</summary>
|
||||
public int FullReads { get; private set; }
|
||||
|
||||
/// <summary>Rows returned across every read since the last <see cref="ResetCounts"/> (#689).</summary>
|
||||
public long RowsReturned { get; private set; }
|
||||
|
||||
/// <summary>The largest <c>maxValuesPerNode</c> asked for since the last <see cref="ResetCounts"/> (#689).</summary>
|
||||
public uint MaxCapRequested { get; private set; }
|
||||
|
||||
public void ResetCounts()
|
||||
{
|
||||
lock (_gate) FullReads = 0;
|
||||
lock (_gate) { FullReads = 0; RowsReturned = 0; MaxCapRequested = 0; }
|
||||
Interlocked.Exchange(ref _readCount, 0);
|
||||
}
|
||||
|
||||
@@ -1016,8 +1111,12 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable
|
||||
: new DataValueSnapshot(prior.Value, prior.StatusCode, stamp, stamp));
|
||||
rows.AddRange(series.Where(s => Ts(s) >= startUtc && Ts(s) <= endUtc));
|
||||
var page = maxValuesPerNode == 0 ? rows : rows.Take((int)maxValuesPerNode).ToList();
|
||||
if (maxValuesPerNode > 0 && page.Count == maxValuesPerNode)
|
||||
lock (_gate) FullReads++;
|
||||
lock (_gate)
|
||||
{
|
||||
if (maxValuesPerNode > 0 && page.Count == maxValuesPerNode) FullReads++;
|
||||
RowsReturned += page.Count;
|
||||
if (maxValuesPerNode > MaxCapRequested) MaxCapRequested = maxValuesPerNode;
|
||||
}
|
||||
int seen;
|
||||
while (page.Count > (seen = Volatile.Read(ref _maxRowsReturned))
|
||||
&& Interlocked.CompareExchange(ref _maxRowsReturned, page.Count, seen) != seen) { }
|
||||
|
||||
Reference in New Issue
Block a user