feat(historian): page within oversized tie clusters (#400) instead of loud-failing

This commit is contained in:
Joseph Doherty
2026-06-17 20:11:09 -04:00
parent 3699fc16a8
commit 2e6c6d3ab6
6 changed files with 368 additions and 31 deletions
@@ -146,6 +146,112 @@ public sealed class HistoryPagingTests
trimmed[0].Value.ShouldBe(11.0);
}
// --- SliceTieCluster ------------------------------------------------------------------------
[Fact]
public void SliceTieCluster_mid_cluster_emits_cp_at_same_timestamp()
{
// 10 ties at T; already emitted 2; cap 3. Slice [2,5) → next page resumes AT T, skip 5.
var t = new DateTime(2026, 1, 1, 0, 0, 5, DateTimeKind.Utc);
var end = t.AddHours(1);
HistoryPaging.SliceTieCluster(
clusterCount: 10, skip: 2, cap: 3, boundaryT: t, endUtc: end,
out var sliceStart, out var sliceCount, out var nextStartUtc, out var nextSkip);
sliceStart.ShouldBe(2);
sliceCount.ShouldBe(3);
nextStartUtc.ShouldBe(t); // still draining the cluster ⇒ resume AT T
nextSkip.ShouldBe(5); // 2 already emitted + 3 just emitted
}
[Fact]
public void SliceTieCluster_exact_drain_advances_one_tick_when_window_remains()
{
// 6 ties at T; skip 3; cap 3. Slice [3,6) drains the cluster exactly ⇒ advance to T+1tick.
var t = new DateTime(2026, 1, 1, 0, 0, 5, DateTimeKind.Utc);
var end = t.AddHours(1);
HistoryPaging.SliceTieCluster(
clusterCount: 6, skip: 3, cap: 3, boundaryT: t, endUtc: end,
out var sliceStart, out var sliceCount, out var nextStartUtc, out var nextSkip);
sliceStart.ShouldBe(3);
sliceCount.ShouldBe(3);
nextStartUtc.ShouldBe(t.AddTicks(1)); // cluster drained, window remains ⇒ next tick, fresh skip
nextSkip.ShouldBe(0);
}
[Fact]
public void SliceTieCluster_short_final_slice_still_emits_cp_when_window_remains()
{
// 5 ties at T; skip 3; cap 10. Slice [3,5) is SHORT (2 < cap) but it fully drains the cluster
// and the window still extends past T ⇒ we MUST emit a CP to read the rest of the window even
// though this page is short of the cap.
var t = new DateTime(2026, 1, 1, 0, 0, 5, DateTimeKind.Utc);
var end = t.AddHours(1);
HistoryPaging.SliceTieCluster(
clusterCount: 5, skip: 3, cap: 10, boundaryT: t, endUtc: end,
out var sliceStart, out var sliceCount, out var nextStartUtc, out var nextSkip);
sliceStart.ShouldBe(3);
sliceCount.ShouldBe(2); // short slice
nextStartUtc.ShouldBe(t.AddTicks(1)); // but CP emitted so the rest of the window is read
nextSkip.ShouldBe(0);
}
[Fact]
public void SliceTieCluster_drained_at_window_end_terminates()
{
// The cluster ends exactly AT the window end (endUtc == T). Draining it cannot advance past the
// window ⇒ terminal (no CP).
var t = new DateTime(2026, 1, 1, 0, 0, 5, DateTimeKind.Utc);
HistoryPaging.SliceTieCluster(
clusterCount: 4, skip: 0, cap: 10, boundaryT: t, endUtc: t,
out var sliceStart, out var sliceCount, out var nextStartUtc, out var nextSkip);
sliceStart.ShouldBe(0);
sliceCount.ShouldBe(4);
nextStartUtc.ShouldBeNull(); // T+1tick > endUtc ⇒ window exhausted ⇒ terminal
nextSkip.ShouldBe(0);
}
[Fact]
public void SliceTieCluster_self_heals_when_skip_exceeds_cluster()
{
// Defensive: a stale skip points past the (re-read, possibly shrunk) cluster. The slice is empty
// (count 0) and, since emitted == clusterCount, the cursor advances/terminates rather than looping.
var t = new DateTime(2026, 1, 1, 0, 0, 5, DateTimeKind.Utc);
var end = t.AddHours(1);
HistoryPaging.SliceTieCluster(
clusterCount: 4, skip: 4, cap: 10, boundaryT: t, endUtc: end,
out var sliceStart, out var sliceCount, out var nextStartUtc, out var nextSkip);
sliceStart.ShouldBe(4);
sliceCount.ShouldBe(0); // nothing left in the cluster to emit
nextStartUtc.ShouldBe(t.AddTicks(1)); // emitted (4) == clusterCount (4) ⇒ advance, don't loop
nextSkip.ShouldBe(0);
}
[Fact]
public void SliceTieCluster_self_heals_to_terminal_at_window_end()
{
// Same stale-skip self-heal, but the window ends at T ⇒ advancing terminates instead of looping.
var t = new DateTime(2026, 1, 1, 0, 0, 5, DateTimeKind.Utc);
HistoryPaging.SliceTieCluster(
clusterCount: 4, skip: 9, cap: 10, boundaryT: t, endUtc: t,
out var sliceStart, out var sliceCount, out var nextStartUtc, out var nextSkip);
sliceStart.ShouldBe(4); // clamped to clusterCount
sliceCount.ShouldBe(0);
nextStartUtc.ShouldBeNull();
nextSkip.ShouldBe(0);
}
// --- InMemoryHistoryContinuationStore (mirrors the production session store contract) --------
[Fact]