fix(historian): #691.4 review — search the gap above a truncated read by gallop then bisect, never below 1 ms

Bisecting the gap above a truncated read halved below the 1 ms request
granularity (requests are ms-rounded, so it re-sent identical requests)
and bisected the WHOLE gap even when the burst ended right above the
row it saw: a 1,026-row burst ending 100 days ago took 46 reads. After
an empty top-anchored segment the walk now gallops up from the known
row ((known + d, top], d x4, following each truncated read), then
bisects the bracket, with halving floored at the granularity (the Min
kept; a crowded gallop falls back to top-anchored halving). Burst: 18
reads, 0 repeated requests; dense tag stopped 2 days ago: 26 (gallop
alone crawled: 94). Walk fuzzed at 4,000 seeds locally (400 in-tree).
Docs: measured counts; CLAUDE.md gateway prerequisites list
RuntimeDb:EventReadMaxRows.
This commit is contained in:
Joseph Doherty
2026-10-12 17:37:32 -04:00
parent ccda27d220
commit d28e3e64f1
4 changed files with 119 additions and 18 deletions
@@ -120,7 +120,7 @@ public sealed class ReverseWalkTests
/// <summary>
/// #689: a dense tag that stopped long ago. The walk's ×4 growth across the empty stretch overshoots into far more
/// rows than one read takes; the truncated read only shows the OLDEST of them, so the walk must not keep re-entering
/// that stretch — it bisects the gap above the newest row it saw.
/// that stretch — it searches the gap above the newest row it saw (gallop up, then bisect). Measured: 26 reads.
/// </summary>
[Fact]
public async Task A_dense_tag_below_a_long_empty_stretch_is_found_in_a_bounded_number_of_reads()
@@ -133,8 +133,8 @@ public sealed class ReverseWalkTests
var page = await ReverseRawReader.ReadAsync(source, "T", T0, DateTime.MinValue, 3, 65536, T0, default(ReverseWalkHint), Ct);
page.Samples.Select(s => (double)s.Value!).ShouldBe([107_999d, 107_998d, 107_997d]);
source.Reads.ShouldBeLessThanOrEqualTo(25);
source.RowsReturned.ShouldBeLessThanOrEqualTo(25 * (ReverseWalk.DiscoveryRows + 2));
source.Reads.ShouldBeLessThanOrEqualTo(30);
source.RowsReturned.ShouldBeLessThanOrEqualTo(30 * (ReverseWalk.DiscoveryRows + 2));
}
/// <summary>
@@ -185,6 +185,27 @@ public sealed class ReverseWalkTests
}
}
/// <summary>
/// #691.4 review: a burst just over the discovery read, sub-millisecond spaced, ending 100 days ago. The search for
/// the top of the burst above the truncated read never re-sends an identical request (halving below the 1 ms request
/// granularity did), and gallops up from the truncated read's newest row instead of bisecting the whole 100-day gap
/// down to the millisecond (was 46 reads).
/// </summary>
[Fact]
public async Task Bisecting_above_a_truncated_read_never_resends_an_identical_request()
{
var end = T0.AddDays(-100);
var count = ReverseWalk.DiscoveryRows + 2;
var series = Enumerable.Range(0, count).Select(i => At(end.Ticks - (count - 1 - i) * 1_000, i)).ToList(); // 0.1 ms apart
var source = new AvevaShapedSource(series);
var page = await ReverseRawReader.ReadAsync(source, "T", T0, DateTime.MinValue, 3, 65536, T0, default(ReverseWalkHint), Ct);
page.Samples.Select(s => (double)s.Value!).ShouldBe([count - 1d, count - 2d, count - 3d]);
source.RepeatedRequests.ShouldBe(0, string.Join("\n", source.Log));
source.Reads.ShouldBeLessThanOrEqualTo(22, string.Join("\n", source.Log));
}
private static long Ts(DataValueSnapshot s) => s.SourceTimestampUtc!.Value.Ticks;
/// <summary>Random ascending series: bursts, gaps, sub-millisecond stamps and tie clusters.</summary>
@@ -221,7 +242,15 @@ public sealed class ReverseWalkTests
public int Reads { get; private set; }
public int BadRequests { get; private set; }
public void Reset() { MaxCap = 0; RowsReturned = 0; Reads = 0; }
/// <summary>Requests that repeated an earlier request exactly (start, end, cap) — read-only, so pure waste.</summary>
public int RepeatedRequests { get; private set; }
private readonly HashSet<(DateTime, DateTime, uint)> _seen = [];
/// <summary>Every request, for failure messages.</summary>
public List<string> Log { get; } = [];
public void Reset() { MaxCap = 0; RowsReturned = 0; Reads = 0; RepeatedRequests = 0; _seen.Clear(); Log.Clear(); }
public bool SupportsReverseRaw => false;
@@ -229,6 +258,9 @@ public sealed class ReverseWalkTests
CancellationToken cancellationToken)
{
Reads++;
if (!_seen.Add((startUtc, endUtc, maxValuesPerNode))) RepeatedRequests++;
Log.Add($"[{startUtc:O} .. {endUtc:O}] cap {maxValuesPerNode}");
if (startUtc > endUtc || maxValuesPerNode == 0 || startUtc < DateTime.UnixEpoch) BadRequests++;
MaxCap = Math.Max(MaxCap, maxValuesPerNode);
var stamp = roundUp && startUtc.Ticks % TimeSpan.TicksPerMillisecond != 0