From 00c15f1dea02c873810f6501b61c649dc6a60052 Mon Sep 17 00:00:00 2001 From: Oleg <18466855+EvaTheSalmon@users.noreply.github.com> Date: Fri, 2 Oct 2026 11:46:21 +0300 Subject: [PATCH] feat(chart): cancel the read a gesture left behind A drag queries the history of every window it passes. When the gesture ended, a read for a window the operator had already left ran on to its end, up to the command timeout, and held the next read behind it. A window whose apply threw could not be asked for again. The history token now runs from IDataProvider.QueryHistoryAsync to the Npgsql reads. A paced request during a drag never cancels, so the strip fills while it moves; the trailing request at the end of a gesture cancels the read for any other window, and a cancelled read reports nothing. The cancel goes through CancelAsync, so Npgsql's blocking cancel request never runs on the UI thread or under the debouncer's or Rx's locks. The chart is the one owner of the window drawn: a gesture that ends inside it reads nothing and still cancels the read it left, and a read for an older pen set opens nothing. A window whose apply threw is read again at the next move. Two gaps on the same read path are closed too: a window starting before the archive's first sample no longer rereads every throttle period, and a pen added with older rows reads them once the archive extent widens. Claude-Session: https://claude.ai/code/session_01PfZJa7neMMKPzycFR6iuhD --- SemiPlot/SemiPlot.Core/Data/IDataProvider.cs | 3 +- .../PostgresDataProvider.cs | 44 +- .../PostgresHistoryReadTests.cs | 72 +++ .../UI/Bridge/FakeDataProvider.cs | 18 +- .../UI/Bridge/TrendCoordinatorTests.cs | 12 +- .../ChartHistoryRequestCancellationTests.cs | 430 ++++++++++++++++++ .../ChartHistoryRequestDebouncerTests.cs | 345 +++++++------- .../UI/Chart/HistoryDebouncerTestBuilder.cs | 87 ++++ .../UI/Chart/HistoryPrefetchTests.cs | 39 +- .../UI/Chart/TrendChartCatalogueTests.cs | 74 +++ .../UI/Chart/TrendChartViewModelTests.cs | 345 +++++++++++++- .../UI/MainWindow/PenCatalogueApplierTests.cs | 70 +++ .../SemiPlot.UI/Bridge/TrendCoordinator.cs | 5 +- .../Chart/ChartHistoryRequestDebouncer.cs | 198 +++++--- SemiPlot/SemiPlot.UI/Chart/HistoryPrefetch.cs | 9 +- .../SemiPlot.UI/Chart/TrendChartViewModel.cs | 46 +- .../MainWindow/PenCatalogueApplier.cs | 3 +- docs/architecture/charting.md | 42 +- docs/architecture/data-integration.md | 113 ++++- docs/architecture/overview.md | 20 +- docs/architecture/trend-interaction.md | 7 +- docs/plans/20260929-window-per-process.md | 188 +++++--- 22 files changed, 1770 insertions(+), 400 deletions(-) create mode 100644 SemiPlot/SemiPlot.Tests.Unit/UI/Chart/ChartHistoryRequestCancellationTests.cs create mode 100644 SemiPlot/SemiPlot.Tests.Unit/UI/Chart/HistoryDebouncerTestBuilder.cs diff --git a/SemiPlot/SemiPlot.Core/Data/IDataProvider.cs b/SemiPlot/SemiPlot.Core/Data/IDataProvider.cs index 5baf886..8062dd1 100644 --- a/SemiPlot/SemiPlot.Core/Data/IDataProvider.cs +++ b/SemiPlot/SemiPlot.Core/Data/IDataProvider.cs @@ -21,7 +21,8 @@ Task>> QueryHistoryAsync( DateTime fromUtc, DateTime toUtc, AggregationLayer layer, - int targetColumnCount); + int targetColumnCount, + CancellationToken cancellationToken = default); Task> QueryArchiveExtentAsync(); } diff --git a/SemiPlot/SemiPlot.DataSource.Postgres/PostgresDataProvider.cs b/SemiPlot/SemiPlot.DataSource.Postgres/PostgresDataProvider.cs index a518308..1884e2d 100644 --- a/SemiPlot/SemiPlot.DataSource.Postgres/PostgresDataProvider.cs +++ b/SemiPlot/SemiPlot.DataSource.Postgres/PostgresDataProvider.cs @@ -121,7 +121,8 @@ public async Task>> QueryHistoryAsync( DateTime fromUtc, DateTime toUtc, AggregationLayer layer, - int targetColumnCount) + int targetColumnCount, + CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(penIds); @@ -142,22 +143,30 @@ public async Task>> QueryHistoryAsync( try { - await using var connection = await _dataSource.OpenConnectionAsync().ConfigureAwait(false); + await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false); var fromLocal = _timeConverter.ToArchiveLocal(fromUtc); var toLocal = _timeConverter.ToArchiveLocal(toUtc); if (layer == AggregationLayer.Raw) { - var buckets = await ReadBucketedWindowAsync(connection, ids, fromLocal, toLocal, targetColumnCount) + var buckets = await ReadBucketedWindowAsync( + connection, + ids, + fromLocal, + toLocal, + targetColumnCount, + cancellationToken) .ConfigureAwait(false); return Result.Ok(BucketedRowFold.Fold(buckets, _timeConverter)); } - var rows = await ReadWindowAsync(connection, ids, fromLocal, toLocal, layer).ConfigureAwait(false); + var rows = await ReadWindowAsync(connection, ids, fromLocal, toLocal, layer, cancellationToken) + .ConfigureAwait(false); - rows = await FillFreshTailAsync(connection, ids, fromLocal, toLocal, layer, rows).ConfigureAwait(false); + rows = await FillFreshTailAsync(connection, ids, fromLocal, toLocal, layer, rows, cancellationToken) + .ConfigureAwait(false); return Result.Ok(HistoryRowFold.Fold(rows, _timeConverter, targetColumnCount)); } @@ -337,17 +346,18 @@ internal static TimeSpan BucketFor(DateTime fromLocal, DateTime toLocal, int tar int[] penIds, DateTime fromLocal, DateTime toLocal, - AggregationLayer layer) + AggregationLayer layer, + CancellationToken cancellationToken) { await using var command = new NpgsqlCommand(ArchiveStatements.SparseHistoryWindow, connection); BindLocalWindow(command, penIds, fromLocal, toLocal, layer); - await using var reader = await command.ExecuteReaderAsync().ConfigureAwait(false); + await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); var rows = new List(); - while (await reader.ReadAsync().ConfigureAwait(false)) + while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) { rows.Add(ReadHistoryRow(reader)); } @@ -360,17 +370,18 @@ internal static TimeSpan BucketFor(DateTime fromLocal, DateTime toLocal, int tar int[] penIds, DateTime fromLocal, DateTime toLocal, - int targetColumnCount) + int targetColumnCount, + CancellationToken cancellationToken) { await using var command = new NpgsqlCommand(ArchiveStatements.BucketedRawWindow, connection); BindLocalBucketedWindow(command, penIds, fromLocal, toLocal, targetColumnCount); - await using var reader = await command.ExecuteReaderAsync().ConfigureAwait(false); + await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); var rows = new List(); - while (await reader.ReadAsync().ConfigureAwait(false)) + while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) { rows.Add(ReadBucketedRow(reader)); } @@ -384,7 +395,8 @@ internal static TimeSpan BucketFor(DateTime fromLocal, DateTime toLocal, int tar DateTime fromLocal, DateTime toLocal, AggregationLayer layer, - IReadOnlyList coarseRows) + IReadOnlyList coarseRows, + CancellationToken cancellationToken) { var seams = FreshTail.Seams(coarseRows, penIds, fromLocal); @@ -393,7 +405,13 @@ internal static TimeSpan BucketFor(DateTime fromLocal, DateTime toLocal, int tar return coarseRows; } - var tailRows = await ReadWindowAsync(connection, penIds, tailStart, toLocal, AggregationLayer.Raw) + var tailRows = await ReadWindowAsync( + connection, + penIds, + tailStart, + toLocal, + AggregationLayer.Raw, + cancellationToken) .ConfigureAwait(false); return FreshTail.Merge(coarseRows, tailRows, seams, tailStart); diff --git a/SemiPlot/SemiPlot.Tests.Integration/PostgresHistoryReadTests.cs b/SemiPlot/SemiPlot.Tests.Integration/PostgresHistoryReadTests.cs index 1ac1339..efb310b 100644 --- a/SemiPlot/SemiPlot.Tests.Integration/PostgresHistoryReadTests.cs +++ b/SemiPlot/SemiPlot.Tests.Integration/PostgresHistoryReadTests.cs @@ -129,6 +129,14 @@ public sealed class PostgresHistoryReadTests( // The archive's own code for the layer the coarse rows below belong to. private const short MinuteLayer = (short)AggregationLayer.Minute; + private const string LockTrendsCommand = "LOCK TABLE public.trends IN ACCESS EXCLUSIVE MODE;"; + + private const string LockWaitOnTrendsQuery = + "SELECT count(*) FROM pg_locks WHERE NOT granted AND relation = 'public.trends'::regclass;"; + + // Far above the round trip a cancel request takes, so only a read the token never reached runs into it. + private static readonly TimeSpan _cancelledReadDeadline = TimeSpan.FromSeconds(20); + [Fact] public async Task AWindowInsideTheFirstRunReadsTheSeedersOwnRows() { @@ -206,6 +214,50 @@ public async Task AWindowBeforeTheArchiveStartsIsASuccessfulEmptyList() result.Value.Should().BeEmpty(); } + // The lock holds the statement on the server, so the cancel lands mid-statement and the server answers + // 57014. Raw reaches the bucketed window, Minute the sparse one. + [Theory] + [InlineData(AggregationLayer.Raw)] + [InlineData(AggregationLayer.Minute)] + public async Task AReadCancelledMidStatementThrowsInsteadOfFailing(AggregationLayer layer) + { + var testCancellation = TestContext.Current.CancellationToken; + await using var database = await postgresContainerFixture.CloneProvisionedAsync(testCancellation); + await using var locker = new NpgsqlConnection(database.AdminConnectionString); + await locker.OpenAsync(testCancellation); + await using var transaction = await locker.BeginTransactionAsync(testCancellation); + await using var lockTrends = new NpgsqlCommand(LockTrendsCommand, locker, transaction); + await lockTrends.ExecuteNonQueryAsync(testCancellation); + + var window = QuietWindow(); + using var cancellation = new CancellationTokenSource(); + await using var services = ArchiveProviderFactory.Build(database.PlotConnectionString); + + var read = services.GetRequiredService().QueryHistoryAsync( + _seededPenIds.Value, + _timeConverter.ToUtc(window.From), + _timeConverter.ToUtc(window.To), + layer, + ColumnTargetFor(window), + cancellation.Token); + + try + { + (await AwaitALockWaitOnTrendsAsync(locker, transaction)).Should().BeTrue( + "the read has to be waiting on the server when the token is cancelled"); + + await cancellation.CancelAsync(); + + var settled = () => read.WaitAsync(_cancelledReadDeadline, testCancellation); + + await settled.Should().ThrowAsync(); + } + finally + { + await transaction.RollbackAsync(testCancellation); + } + } + // A break writes no rows at all, and the fold turns the q = 32 row bounding it into one NaN anchor a // tick later. Counting the anchors rather than finding one is the whole instrument. [Fact] @@ -568,6 +620,26 @@ public void TheExpectedSeedLookBackIsTheStatementsOwn() SeedLookBackFor(wideWindow).Should().Be(wideWindow.To - wideWindow.From); } + private static async Task AwaitALockWaitOnTrendsAsync( + NpgsqlConnection locker, + NpgsqlTransaction transaction) + { + await using var command = new NpgsqlCommand(LockWaitOnTrendsQuery, locker, transaction); + var deadline = DateTime.UtcNow + _cancelledReadDeadline; + + while (DateTime.UtcNow < deadline) + { + if (await command.ExecuteScalarAsync(TestContext.Current.CancellationToken) is > 0L) + { + return true; + } + + await Task.Delay(TimeSpan.FromMilliseconds(20), TestContext.Current.CancellationToken); + } + + return false; + } + // The archive's first two minutes: inside the first archiving run, before any break. private static LocalWindow QuietWindow() { diff --git a/SemiPlot/SemiPlot.Tests.Unit/UI/Bridge/FakeDataProvider.cs b/SemiPlot/SemiPlot.Tests.Unit/UI/Bridge/FakeDataProvider.cs index 19f7f0b..085f4f6 100644 --- a/SemiPlot/SemiPlot.Tests.Unit/UI/Bridge/FakeDataProvider.cs +++ b/SemiPlot/SemiPlot.Tests.Unit/UI/Bridge/FakeDataProvider.cs @@ -67,8 +67,9 @@ internal sealed class FakeDataProvider( // (e.g. an initial Raw load vs. a superseding coarser-layer gesture re-query). public Dictionary LayerCenterOverrides { get; } = []; - // Plain, for the reason stated at PensGate: TrendChartViewModelTests asserts inline after SetResult. - public TaskCompletionSource>> HistoryGate { get; } = new(); + // Plain, for the reason stated at PensGate: TrendChartViewModelTests asserts inline after SetResult. A held + // read waits on the gate in place when it starts, so replacing the gate holds the next read apart from it. + public TaskCompletionSource>> HistoryGate { get; set; } = new(); // Pen identifiers the history read answers with no envelope at all, the shape a real provider returns // for a pen holding no row in the window. A requested pen missing from the result is not an error. @@ -86,6 +87,8 @@ internal sealed class FakeDataProvider( public int? LastQueriedTargetColumnCount { get; private set; } + public CancellationToken LastQueriedCancellationToken { get; private set; } + public int PensQueryCount { get; private set; } public int ExtentQueryCount { get; private set; } @@ -112,7 +115,7 @@ internal sealed class FakeDataProvider( // TrendCoordinator.BuildRealtimeBatch looks like from the pipeline's side. public bool PoisonRealtimeWindow { get; set; } - // A provider that ends the live edge with OnError, the one terminal message TrendCoordinator.Start + // A provider that ends the live edge with OnError, the one terminal message TrendCoordinator.RealtimeBatches // has to survive. The real provider catches everything inside its poll, so only this fake reaches it. public Exception? RealtimeStreamFailure { get; set; } @@ -205,14 +208,17 @@ public Task>> QueryHistoryAsync( DateTime fromUtc, DateTime toUtc, AggregationLayer layer, - int targetColumnCount) + int targetColumnCount, + CancellationToken cancellationToken = default) { - HistoryQueryCount++; + // The count moves last: a test that waits for it on another thread then reads the query it counts. LastQueriedPenIds = penIds; LastQueriedLayer = layer; LastQueriedFromUtc = fromUtc; LastQueriedToUtc = toUtc; LastQueriedTargetColumnCount = targetColumnCount; + LastQueriedCancellationToken = cancellationToken; + HistoryQueryCount++; if (FailHistory) { @@ -221,7 +227,7 @@ public Task>> QueryHistoryAsync( if (GatedLayer == layer) { - return HistoryGate.Task; + return HistoryGate.Task.WaitAsync(cancellationToken); } var center = LayerCenterOverrides.TryGetValue(layer, out var overridden) diff --git a/SemiPlot/SemiPlot.Tests.Unit/UI/Bridge/TrendCoordinatorTests.cs b/SemiPlot/SemiPlot.Tests.Unit/UI/Bridge/TrendCoordinatorTests.cs index 5f05f11..42f655f 100644 --- a/SemiPlot/SemiPlot.Tests.Unit/UI/Bridge/TrendCoordinatorTests.cs +++ b/SemiPlot/SemiPlot.Tests.Unit/UI/Bridge/TrendCoordinatorTests.cs @@ -72,16 +72,24 @@ public void RealtimeBatch_KeepsEachPenOnItsOwnTimestamps() } [Fact] - public async Task QueryHistoryAsync_PassesThroughToProviderWithTargetColumnCount() + public async Task QueryHistoryAsync_PassesThroughToProviderWithTargetColumnCountAndToken() { var (coordinator, _, provider) = CreateCoordinator(); + using var cancellation = new CancellationTokenSource(); - var result = await coordinator.QueryHistoryAsync([1, 2], _from, _to, AggregationLayer.Minute, 256); + var result = await coordinator.QueryHistoryAsync( + [1, 2], + _from, + _to, + AggregationLayer.Minute, + 256, + cancellation.Token); result.IsSuccess.Should().BeTrue(); result.Value.Select(pen => pen.PenId).Should().Equal(1, 2); provider.LastQueriedLayer.Should().Be(AggregationLayer.Minute); provider.LastQueriedTargetColumnCount.Should().Be(256); + provider.LastQueriedCancellationToken.Should().Be(cancellation.Token); } [Fact] diff --git a/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/ChartHistoryRequestCancellationTests.cs b/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/ChartHistoryRequestCancellationTests.cs new file mode 100644 index 0000000..107aafa --- /dev/null +++ b/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/ChartHistoryRequestCancellationTests.cs @@ -0,0 +1,430 @@ +using System.Collections.Concurrent; + +using AwesomeAssertions; + +using FluentResults; + +using Microsoft.Reactive.Testing; + +using SemiPlot.Core.Trends; +using SemiPlot.UI.Chart; + +using Xunit; + +using static SemiPlot.Tests.Unit.UI.Chart.HistoryDebouncerTestBuilder; + +namespace SemiPlot.Tests.Unit.UI.Chart; + +[Trait("Component", "UI")] +[Trait("Area", "Chart")] +[Trait("Category", "Unit")] +public sealed class ChartHistoryRequestCancellationTests +{ + // Longer than the cap interval, so a sample tick always finds the read it started still running. + private static readonly TimeSpan _slowQuery = TimeSpan.FromMilliseconds(600); + + // Well under the cancel callback's own block, so a call that waited for it cannot pass. + private static readonly TimeSpan _promptReturn = TimeSpan.FromSeconds(5.0); + + private const int LastDragNotch = 25; + + // A notch every 20 ms puts this one at 380 ms, the last before the first sample tick. + private const int LastNotchBeforeTheFirstTick = 19; + + [Fact] + public async Task APacedRequestNeverCancelsTheQueryInFlight() + { + var scheduler = new TestScheduler(); + var queries = new HeldQueries(scheduler); + var applied = new ConcurrentQueue(); + using var debouncer = CreateDebouncer(scheduler, queries.QueryAsync, (request, _) => applied.Enqueue(request)); + + for (var notch = 1; notch <= 100; notch++) + { + scheduler.AdvanceBy(TimeSpan.FromMilliseconds(20).Ticks); + debouncer.Request(RequestAtNotch(notch)); + + if (queries.Running is { } running && scheduler.Clock - running.StartedAt >= _slowQuery.Ticks) + { + await queries.LandAndAwaitTheNextStart(running); + } + } + + applied.Should().HaveCount(2); + queries.Started.Should().HaveCount(3) + .And.OnlyContain(query => !query.Cancellation.IsCancellationRequested); + } + + [Fact] + public async Task AGestureEndCancelsTheLeftBehindQuery() + { + var scheduler = new TestScheduler(); + var queries = new HeldQueries(scheduler); + var applied = new ConcurrentQueue(); + using var debouncer = CreateDebouncer(scheduler, queries.QueryAsync, (request, _) => applied.Enqueue(request)); + + DragUntilTheFirstReadIsLeftBehind(debouncer, scheduler); + var leftBehind = queries.Started.Should().ContainSingle().Which; + + scheduler.AdvanceBy(DebounceWindow.Ticks + 1); + + leftBehind.Cancellation.IsCancellationRequested.Should().BeTrue(); + await AwaitCondition(() => queries.Started.Count == 2); + + var final = queries.Started[1]; + final.Request.FromUtc.Should().Be(From.AddSeconds(LastDragNotch)); + final.Cancellation.IsCancellationRequested.Should().BeFalse(); + + final.Gate.SetResult(Ok(final.Request)); + await AwaitCondition(() => !applied.IsEmpty); + + applied.Select(request => request.FromUtc).Should().Equal(From.AddSeconds(LastDragNotch)); + } + + [Fact] + public async Task AGestureEndingOnTheWindowBeingReadLeavesTheReadRunning() + { + var scheduler = new TestScheduler(); + var queries = new HeldQueries(scheduler); + var applied = new ConcurrentQueue(); + using var debouncer = CreateDebouncer(scheduler, queries.QueryAsync, (request, _) => applied.Enqueue(request)); + + for (var notch = 1; notch <= LastNotchBeforeTheFirstTick; notch++) + { + scheduler.AdvanceBy(TimeSpan.FromMilliseconds(20).Ticks); + debouncer.Request(RequestAtNotch(notch)); + } + + scheduler.AdvanceBy(TimeSpan.FromMilliseconds(20).Ticks); + var running = queries.Started.Should().ContainSingle().Which; + running.Request.FromUtc.Should().Be(From.AddSeconds(LastNotchBeforeTheFirstTick)); + + scheduler.AdvanceBy(DebounceWindow.Ticks + 1); + + running.Cancellation.IsCancellationRequested.Should().BeFalse(); + queries.Started.Should().ContainSingle(); + + running.Gate.SetResult(Ok(running.Request)); + await AwaitCondition(() => !applied.IsEmpty); + + applied.Select(request => request.FromUtc).Should().Equal(From.AddSeconds(LastNotchBeforeTheFirstTick)); + } + + [Fact] + public async Task AGestureEndingOnTheDrawnWindowCancelsTheReadItLeftAndReadsNothing() + { + var scheduler = new TestScheduler(); + var queries = new HeldQueries(scheduler); + var applied = new ConcurrentQueue(); + using var debouncer = CreateDebouncer(scheduler, queries.QueryAsync, (request, _) => applied.Enqueue(request)); + + await ApplyNotchZero(debouncer, scheduler, queries, applied); + + debouncer.Request(RequestAtNotch(1)); + scheduler.AdvanceBy(DebounceWindow.Ticks + 1); + var farRead = queries.Started[1]; + + debouncer.RequestNothing(); + scheduler.AdvanceBy(DebounceWindow.Ticks + 1); + + farRead.Cancellation.IsCancellationRequested.Should().BeTrue(); + + debouncer.Request(RequestAtNotch(2)); + scheduler.AdvanceBy(DebounceWindow.Ticks + 1); + await AwaitCondition(() => queries.Started.Count == 3); + + queries.Started.Select(query => query.Request.FromUtc).Should() + .Equal(From, From.AddSeconds(1), From.AddSeconds(2)); + } + + // The reads here ignore their tokens and the test ends each one, so the order the completions land in is + // the test's own. + [Fact] + public async Task AGestureEndingOnTheDrawnWindowAfterACancelledReadReadsNothing() + { + var scheduler = new TestScheduler(); + var queries = new HeldQueries(scheduler, endsOnCancellation: false); + var applied = new ConcurrentQueue(); + using var debouncer = CreateDebouncer(scheduler, queries.QueryAsync, (request, _) => applied.Enqueue(request)); + + await ApplyNotchZero(debouncer, scheduler, queries, applied); + + debouncer.Request(RequestAtNotch(1)); + scheduler.AdvanceBy(DebounceWindow.Ticks + 1); + debouncer.Request(RequestAtNotch(2)); + scheduler.AdvanceBy(DebounceWindow.Ticks + 1); + + var cancelled = queries.Started[1]; + cancelled.Cancellation.IsCancellationRequested.Should().BeTrue(); + cancelled.Gate.SetCanceled(cancelled.Cancellation); + await AwaitCondition(() => queries.Started.Count == 3); + + debouncer.RequestNothing(); + scheduler.AdvanceBy(DebounceWindow.Ticks + 1); + + var running = queries.Started[2]; + running.Gate.SetResult(Ok(running.Request)); + await AwaitCondition(() => applied.Count == 2); + + debouncer.Request(RequestAtNotch(3)); + scheduler.AdvanceBy(DebounceWindow.Ticks + 1); + await AwaitCondition(() => queries.Started.Count == 4); + + queries.Started.Select(query => query.Request.FromUtc).Should() + .Equal(From, From.AddSeconds(1), From.AddSeconds(2), From.AddSeconds(3)); + } + + [Fact] + public async Task AGestureEndingOnTheDrawnWindowAfterAFailedReadCancelsTheReadItLeft() + { + var scheduler = new TestScheduler(); + var queries = new HeldQueries(scheduler); + var applied = new ConcurrentQueue(); + var reportedFailures = new ConcurrentQueue>(); + using var debouncer = CreateDebouncer( + scheduler, + queries.QueryAsync, + (request, _) => applied.Enqueue(request), + reportedFailures.Enqueue); + + await ApplyNotchZero(debouncer, scheduler, queries, applied); + + debouncer.Request(RequestAtNotch(1)); + scheduler.AdvanceBy(DebounceWindow.Ticks + 1); + queries.Started[1].Gate.SetResult(Unreachable()); + await AwaitCondition(() => !reportedFailures.IsEmpty); + + debouncer.Request(RequestAtNotch(2)); + scheduler.AdvanceBy(DebounceWindow.Ticks + 1); + var farRead = queries.Started[2]; + + debouncer.RequestNothing(); + scheduler.AdvanceBy(DebounceWindow.Ticks + 1); + + farRead.Cancellation.IsCancellationRequested.Should().BeTrue(); + queries.Started.Should().HaveCount(3); + } + + [Fact] + public async Task AGestureEndNeverWaitsForTheCancelToReachTheServer() + { + var scheduler = new TestScheduler(); + var queries = new UnansweredCancels(); + using var debouncer = CreateDebouncer(scheduler, queries.QueryAsync); + + try + { + DragUntilTheFirstReadIsLeftBehind(debouncer, scheduler); + var leftBehind = queries.Started.Should().ContainSingle().Which; + + var returned = await ReturnsPromptly(() => scheduler.AdvanceBy(DebounceWindow.Ticks + 1)); + + returned.Should().BeTrue(); + leftBehind.IsCancellationRequested.Should().BeTrue(); + } + finally + { + queries.Answer(); + } + } + + [Fact] + public async Task DisposingNeverWaitsForTheCancelToReachTheServer() + { + var scheduler = new TestScheduler(); + var queries = new UnansweredCancels(); + var debouncer = CreateDebouncer(scheduler, queries.QueryAsync); + + try + { + debouncer.Request(RequestForLayer(AggregationLayer.Raw)); + scheduler.AdvanceBy(DebounceWindow.Ticks + 1); + var running = queries.Started.Should().ContainSingle().Which; + + var returned = await ReturnsPromptly(debouncer.Dispose); + + returned.Should().BeTrue(); + running.IsCancellationRequested.Should().BeTrue(); + } + finally + { + queries.Answer(); + } + } + + [Fact] + public async Task ACancelledQueryReportsNothing() + { + var scheduler = new TestScheduler(); + var queries = new HeldQueries(scheduler); + var reportedFailures = new ConcurrentQueue>(); + var applied = new ConcurrentQueue(); + using var debouncer = CreateDebouncer( + scheduler, + queries.QueryAsync, + (request, _) => applied.Enqueue(request), + reportedFailures.Enqueue); + + DragUntilTheFirstReadIsLeftBehind(debouncer, scheduler); + scheduler.AdvanceBy(DebounceWindow.Ticks + 1); + await AwaitCondition(() => queries.Started.Count == 2); + + queries.Started[0].Gate.Task.IsCanceled.Should().BeTrue(); + + queries.Started[1].Gate.SetResult(Ok(queries.Started[1].Request)); + await AwaitCondition(() => !applied.IsEmpty); + + reportedFailures.Should().BeEmpty(); + applied.Should().ContainSingle(); + } + + [Fact] + public void ACancellationTheDebouncerNeverAskedForIsReported() + { + var scheduler = new TestScheduler(); + var reportedFailures = new List>(); + using var debouncer = CreateDebouncer( + scheduler, + (_, _) => Task.FromCanceled>>(new CancellationToken(true)), + reportQueryFailure: reportedFailures.Add); + + debouncer.Request(RequestForLayer(AggregationLayer.Raw)); + scheduler.AdvanceBy(DebounceWindow.Ticks + 1); + + reportedFailures.Should().ContainSingle(); + reportedFailures[0].OfType().Should() + .ContainSingle().Which.Exception.Should().BeAssignableTo(); + } + + [Fact] + public void DisposingCancelsTheRunningQuery() + { + var scheduler = new TestScheduler(); + var queries = new HeldQueries(scheduler); + var debouncer = CreateDebouncer(scheduler, queries.QueryAsync); + + debouncer.Request(RequestForLayer(AggregationLayer.Raw)); + scheduler.AdvanceBy(DebounceWindow.Ticks + 1); + var running = queries.Started.Should().ContainSingle().Which; + + debouncer.Dispose(); + + running.Cancellation.IsCancellationRequested.Should().BeTrue(); + } + + // A notch every 20 ms: the 400 ms sample tick starts the one read, and the drag stops 100 ms later on a + // window that read does not cover. + private static void DragUntilTheFirstReadIsLeftBehind( + ChartHistoryRequestDebouncer debouncer, + TestScheduler scheduler) + { + for (var notch = 1; notch <= LastDragNotch; notch++) + { + scheduler.AdvanceBy(TimeSpan.FromMilliseconds(20).Ticks); + debouncer.Request(RequestAtNotch(notch)); + } + } + + private static async Task ApplyNotchZero( + ChartHistoryRequestDebouncer debouncer, + TestScheduler scheduler, + HeldQueries queries, + ConcurrentQueue applied) + { + debouncer.Request(RequestAtNotch(0)); + scheduler.AdvanceBy(DebounceWindow.Ticks + 1); + + var first = queries.Started.Should().ContainSingle().Which; + first.Gate.SetResult(Ok(first.Request)); + + await AwaitCondition(() => applied.Count == 1); + } + + private static async Task ReturnsPromptly(Action action) + { + var call = Task.Run(action, TestContext.Current.CancellationToken); + var promptly = Task.Delay(_promptReturn, TestContext.Current.CancellationToken); + + return await Task.WhenAny(call, promptly) == call; + } + + private sealed record HeldQuery( + HistoryRequest Request, + long StartedAt, + TaskCompletionSource>> Gate, + CancellationToken Cancellation); + + // A read that lands when the test sets its gate and, unless told otherwise, ends cancelled the moment its + // token is, as the provider's does. StartedAt is the virtual clock, read while the test thread waits for + // the start. + private sealed class HeldQueries(TestScheduler scheduler, bool endsOnCancellation = true) + { + private readonly Lock _gate = new(); + private readonly List _started = []; + + public IReadOnlyList Started + { + get + { + lock (_gate) + { + return [.. _started]; + } + } + } + + public HeldQuery? Running => Started.LastOrDefault(query => !query.Gate.Task.IsCompleted); + + public Task>> QueryAsync( + HistoryRequest request, + CancellationToken cancellation) + { + var query = new HeldQuery(request, scheduler.Clock, new(), cancellation); + if (endsOnCancellation) + { + cancellation.Register(() => query.Gate.TrySetCanceled(cancellation)); + } + + lock (_gate) + { + _started.Add(query); + } + + return query.Gate.Task; + } + + public async Task LandAndAwaitTheNextStart(HeldQuery query) + { + var startedBefore = Started.Count; + + query.Gate.SetResult(Ok(query.Request)); + + await AwaitCondition(() => Started.Count > startedBefore); + } + } + + // A read whose cancel callback blocks until the test answers, as Npgsql's does while it opens a connection + // to a server that never replies. The read itself never lands. + private sealed class UnansweredCancels + { + private readonly ManualResetEventSlim _serverAnswers = new(); + private readonly ConcurrentQueue _started = new(); + + public IReadOnlyCollection Started => _started; + + public Task>> QueryAsync( + HistoryRequest _, + CancellationToken cancellation) + { + cancellation.Register(() => _serverAnswers.Wait(TestDeadline)); + _started.Enqueue(cancellation); + + return new TaskCompletionSource>>().Task; + } + + public void Answer() + { + _serverAnswers.Set(); + } + } +} diff --git a/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/ChartHistoryRequestDebouncerTests.cs b/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/ChartHistoryRequestDebouncerTests.cs index 6b56a8d..0a87ae7 100644 --- a/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/ChartHistoryRequestDebouncerTests.cs +++ b/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/ChartHistoryRequestDebouncerTests.cs @@ -1,4 +1,3 @@ -using System.Reactive.Concurrency; using System.Reactive.Linq; using System.Reactive.Threading.Tasks; @@ -9,10 +8,11 @@ using Microsoft.Reactive.Testing; using SemiPlot.Core.Trends; -using SemiPlot.UI.Chart; using Xunit; +using static SemiPlot.Tests.Unit.UI.Chart.HistoryDebouncerTestBuilder; + namespace SemiPlot.Tests.Unit.UI.Chart; [Trait("Component", "UI")] @@ -20,33 +20,20 @@ namespace SemiPlot.Tests.Unit.UI.Chart; [Trait("Category", "Unit")] public sealed class ChartHistoryRequestDebouncerTests { - private static readonly TimeSpan _debounceWindow = TimeSpan.FromMilliseconds(150); - private static readonly TimeSpan _capInterval = TimeSpan.FromMilliseconds(400); - private static readonly TimeSpan _testDeadline = TimeSpan.FromSeconds(10.0); - private static readonly DateTime _from = new(2026, 6, 15, 8, 0, 0, DateTimeKind.Utc); - private static readonly DateTime _to = new(2026, 6, 15, 9, 0, 0, DateTimeKind.Utc); - [Fact] public void RapidRequests_CollapseToOneTrailingQuery() { // The gesture runs 100 ms, shorter than the cap interval, so no sample tick falls inside it. var scheduler = new TestScheduler(); var queryCount = 0; - using var debouncer = new ChartHistoryRequestDebouncer( - request => - { - queryCount++; + using var debouncer = CreateDebouncer(scheduler, (request, _) => + { + queryCount++; - return Observable - .Return(Ok(request)) - .ToTask(); - }, - (_, _) => { }, - _ => { }, - _debounceWindow, - _capInterval, - scheduler, - ImmediateScheduler.Instance); + return Observable + .Return(Ok(request)) + .ToTask(); + }); for (var notch = 0; notch < 5; notch++) { @@ -56,7 +43,7 @@ public void RapidRequests_CollapseToOneTrailingQuery() queryCount.Should().Be(0); - scheduler.AdvanceBy(_debounceWindow.Ticks + 1); + scheduler.AdvanceBy(DebounceWindow.Ticks + 1); queryCount.Should().Be(1); } @@ -72,8 +59,9 @@ public async Task ASlowQueryLandsAndOnlyTheNewestPendingWindowRunsAfterIt() var queriedLayers = new List(); var appliedLayers = new List(); - using var debouncer = new ChartHistoryRequestDebouncer( - request => + using var debouncer = CreateDebouncer( + scheduler, + (request, _) => { queriedLayers.Add(request.Layer); @@ -88,26 +76,21 @@ public async Task ASlowQueryLandsAndOnlyTheNewestPendingWindowRunsAfterIt() { lastApplied.TrySetResult(); } - }, - _ => { }, - _debounceWindow, - _capInterval, - scheduler, - ImmediateScheduler.Instance); + }); debouncer.Request(RequestForLayer(AggregationLayer.Minute)); - scheduler.AdvanceBy(_debounceWindow.Ticks + 1); + scheduler.AdvanceBy(DebounceWindow.Ticks + 1); // Two more windows arrive while that read is held; only the newer of them is worth reading. debouncer.Request(RequestForLayer(AggregationLayer.Hour)); - scheduler.AdvanceBy(_debounceWindow.Ticks + 1); + scheduler.AdvanceBy(DebounceWindow.Ticks + 1); debouncer.Request(RequestForLayer(AggregationLayer.Day)); - scheduler.AdvanceBy(_debounceWindow.Ticks + 1); + scheduler.AdvanceBy(DebounceWindow.Ticks + 1); queriedLayers.Should().Equal(AggregationLayer.Minute); firstQueryGate.SetResult(Ok(RequestForLayer(AggregationLayer.Minute))); - await lastApplied.Task.WaitAsync(_testDeadline, TestContext.Current.CancellationToken); + await lastApplied.Task.WaitAsync(TestDeadline, TestContext.Current.CancellationToken); queriedLayers.Should().Equal(AggregationLayer.Minute, AggregationLayer.Day); appliedLayers.Should().Equal(AggregationLayer.Minute, AggregationLayer.Day); @@ -119,20 +102,17 @@ public void ThrowingQuery_IsReportedAndDropped_WithoutKillingTheStream() var scheduler = new TestScheduler(); var reportedFailures = new List>(); var appliedLayers = new List(); - using var debouncer = new ChartHistoryRequestDebouncer( - _ => throw new InvalidOperationException("query failed"), - (request, _) => appliedLayers.Add(request.Layer), - reportedFailures.Add, - _debounceWindow, - _capInterval, + using var debouncer = CreateDebouncer( scheduler, - ImmediateScheduler.Instance); + (_, _) => throw new InvalidOperationException("query failed"), + (request, _) => appliedLayers.Add(request.Layer), + reportedFailures.Add); debouncer.Request(RequestForLayer(AggregationLayer.Raw)); - scheduler.AdvanceBy(_debounceWindow.Ticks + 1); + scheduler.AdvanceBy(DebounceWindow.Ticks + 1); debouncer.Request(RequestForLayer(AggregationLayer.Hour)); - scheduler.AdvanceBy(_debounceWindow.Ticks + 1); + scheduler.AdvanceBy(DebounceWindow.Ticks + 1); reportedFailures.Should().HaveCount(2); appliedLayers.Should().BeEmpty(); @@ -150,18 +130,14 @@ public void AFailedResult_IsReportedToTheConsumer() var scheduler = new TestScheduler(); var reportedFailures = new List>(); var appliedLayers = new List(); - using var debouncer = new ChartHistoryRequestDebouncer( - _ => Task.FromResult( - Result.Fail>("The archive is unreachable.")), - (request, _) => appliedLayers.Add(request.Layer), - reportedFailures.Add, - _debounceWindow, - _capInterval, + using var debouncer = CreateDebouncer( scheduler, - ImmediateScheduler.Instance); + (_, _) => Task.FromResult(Unreachable()), + (request, _) => appliedLayers.Add(request.Layer), + reportedFailures.Add); debouncer.Request(RequestForLayer(AggregationLayer.Raw)); - scheduler.AdvanceBy(_debounceWindow.Ticks + 1); + scheduler.AdvanceBy(DebounceWindow.Ticks + 1); reportedFailures.Should().ContainSingle(); reportedFailures[0].Should().ContainSingle() @@ -175,17 +151,13 @@ public void AppliedResult_CarriesTheIdentifiersItsRequestAskedFor() // Without them the consumer cannot tell a pen the provider omitted from one it was never asked for. var scheduler = new TestScheduler(); IReadOnlyList? appliedPenIds = null; - using var debouncer = new ChartHistoryRequestDebouncer( - request => Task.FromResult(Ok(request)), - (request, _) => appliedPenIds = request.PenIds, - _ => { }, - _debounceWindow, - _capInterval, + using var debouncer = CreateDebouncer( scheduler, - ImmediateScheduler.Instance); + (request, _) => Task.FromResult(Ok(request)), + (request, _) => appliedPenIds = request.PenIds); - debouncer.Request(RequestOver([4, 7], _from, _to, AggregationLayer.Raw)); - scheduler.AdvanceBy(_debounceWindow.Ticks + 1); + debouncer.Request(RequestOver([4, 7], From, To, AggregationLayer.Raw)); + scheduler.AdvanceBy(DebounceWindow.Ticks + 1); appliedPenIds.Should().Equal(4, 7); } @@ -201,21 +173,14 @@ public void AContinuousGestureFetchesAtTheCapInterval() // them; a read that outlived a tick would move its query behind the one in flight, never add one. var scheduler = new TestScheduler(); var queryCount = 0; - using var debouncer = new ChartHistoryRequestDebouncer( - request => - { - queryCount++; + using var debouncer = CreateDebouncer(scheduler, (request, _) => + { + queryCount++; - return Observable - .Return(Ok(request)) - .ToTask(); - }, - (_, _) => { }, - _ => { }, - _debounceWindow, - _capInterval, - scheduler, - ImmediateScheduler.Instance); + return Observable + .Return(Ok(request)) + .ToTask(); + }); for (var notch = 1; notch <= 100; notch++) { @@ -225,7 +190,7 @@ public void AContinuousGestureFetchesAtTheCapInterval() queryCount.Should().Be(5); - scheduler.AdvanceBy(_debounceWindow.Ticks + 1); + scheduler.AdvanceBy(DebounceWindow.Ticks + 1); queryCount.Should().Be(6); } @@ -233,28 +198,66 @@ public void AContinuousGestureFetchesAtTheCapInterval() [Fact] public void TheSameWindowRequestedTwiceQueriesOnce() { - // The trailing throttle emits the window and its result is applied; the sample tick that follows - // carries the same window, which the applied envelopes already cover, so the archive is read once. + // The trailing throttle admits the second push at 250 ms and its read is applied; the sample tick at + // 400 ms emits that same push, which asked for the rows the read brought, so the archive is read once. var scheduler = new TestScheduler(); var queryCount = 0; - using var debouncer = new ChartHistoryRequestDebouncer( - request => - { - queryCount++; + using var debouncer = CreateDebouncer(scheduler, (request, _) => + { + queryCount++; - return Task.FromResult(Ok(request)); - }, - (_, _) => { }, - _ => { }, - _debounceWindow, - _capInterval, + return Task.FromResult(Ok(request)); + }); + + debouncer.Request(RequestForLayer(AggregationLayer.Raw)); + scheduler.AdvanceBy(TimeSpan.FromMilliseconds(100).Ticks); + debouncer.Request(RequestForLayer(AggregationLayer.Raw)); + scheduler.AdvanceBy(CapInterval.Ticks + 1); + + queryCount.Should().Be(1); + } + + [Fact] + public void AWindowAskedForAgainAfterItsReadWasDeliveredIsReadAgain() + { + var scheduler = new TestScheduler(); + var queryCount = 0; + using var debouncer = CreateDebouncer(scheduler, (request, _) => + { + queryCount++; + + return Task.FromResult(Ok(request)); + }); + + debouncer.Request(RequestForLayer(AggregationLayer.Raw)); + scheduler.AdvanceBy(DebounceWindow.Ticks + 1); + debouncer.Request(RequestForLayer(AggregationLayer.Raw)); + scheduler.AdvanceBy(CapInterval.Ticks + 1); + + queryCount.Should().Be(2); + } + + [Fact] + public async Task ARequestPushedWhileItsWindowIsReadReadsNothingOnceTheReadLands() + { + var scheduler = new TestScheduler(); + var queryGate = new TaskCompletionSource>>(); + var applied = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var queryCount = 0; + using var debouncer = CreateDebouncer( scheduler, - ImmediateScheduler.Instance); + (request, _) => Interlocked.Increment(ref queryCount) == 1 + ? queryGate.Task + : Task.FromResult(Ok(request)), + (_, _) => applied.TrySetResult()); debouncer.Request(RequestForLayer(AggregationLayer.Raw)); - scheduler.AdvanceBy(TimeSpan.FromMilliseconds(200).Ticks); + scheduler.AdvanceBy(DebounceWindow.Ticks + 1); debouncer.Request(RequestForLayer(AggregationLayer.Raw)); - scheduler.AdvanceBy(_capInterval.Ticks + 1); + + queryGate.SetResult(Ok(RequestForLayer(AggregationLayer.Raw))); + await applied.Task.WaitAsync(TestDeadline, TestContext.Current.CancellationToken); + scheduler.AdvanceBy(CapInterval.Ticks + 1); queryCount.Should().Be(1); } @@ -266,29 +269,20 @@ public void TheSameWindowRequestedAgainAfterAFailedQueryIsRead() { var scheduler = new TestScheduler(); var queryCount = 0; - using var debouncer = new ChartHistoryRequestDebouncer( - request => - { - queryCount++; + using var debouncer = CreateDebouncer(scheduler, (request, _) => + { + queryCount++; - return Task.FromResult(queryCount == 1 - ? Result.Fail>("The archive is unreachable.") - : Ok(request)); - }, - (_, _) => { }, - _ => { }, - _debounceWindow, - _capInterval, - scheduler, - ImmediateScheduler.Instance); + return Task.FromResult(queryCount == 1 ? Unreachable() : Ok(request)); + }); debouncer.Request(RequestForLayer(AggregationLayer.Raw)); - scheduler.AdvanceBy(_debounceWindow.Ticks + 1); + scheduler.AdvanceBy(DebounceWindow.Ticks + 1); queryCount.Should().Be(1); debouncer.Request(RequestForLayer(AggregationLayer.Raw)); - scheduler.AdvanceBy(_debounceWindow.Ticks + 1); + scheduler.AdvanceBy(DebounceWindow.Ticks + 1); queryCount.Should().Be(2); } @@ -304,72 +298,55 @@ public async Task AnIdenticalWindowAskedForWhileTheQueryIsInFlightIsDroppedWhenI var applied = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var queryCount = 0; - using var debouncer = new ChartHistoryRequestDebouncer( - request => + using var debouncer = CreateDebouncer( + scheduler, + (request, _) => { queryCount++; return queryCount == 1 ? queryGate.Task : Task.FromResult(Ok(request)); }, - (_, _) => applied.TrySetResult(), - _ => { }, - _debounceWindow, - _capInterval, - scheduler, - ImmediateScheduler.Instance); + (_, _) => applied.TrySetResult()); debouncer.Request(RequestForLayer(AggregationLayer.Raw)); - scheduler.AdvanceBy(_debounceWindow.Ticks + 1); + scheduler.AdvanceBy(DebounceWindow.Ticks + 1); debouncer.Request(RequestForLayer(AggregationLayer.Raw)); - scheduler.AdvanceBy(_debounceWindow.Ticks + 1); + scheduler.AdvanceBy(DebounceWindow.Ticks + 1); queryCount.Should().Be(1); queryGate.SetResult(Ok(RequestForLayer(AggregationLayer.Raw))); - await applied.Task.WaitAsync(_testDeadline, TestContext.Current.CancellationToken); + await applied.Task.WaitAsync(TestDeadline, TestContext.Current.CancellationToken); queryCount.Should().Be(1); } - // The failing case the applied-window record exists for: a window asked for again while its first read is - // still in flight must not be silenced by that read, because the read can come back with nothing. [Fact] public async Task AnIdenticalWindowAskedForWhileAFailingQueryIsInFlightIsStillRead() { - var firstQueryGate = new TaskCompletionSource(); - var firstQueryStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var scheduler = new TestScheduler(); + var firstQueryGate = new TaskCompletionSource>>(); var applied = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var queryCount = 0; - var shortWindow = TimeSpan.FromMilliseconds(20); - using var debouncer = new ChartHistoryRequestDebouncer( - async request => - { - if (Interlocked.Increment(ref queryCount) > 1) - { - return Ok(request); - } - - firstQueryStarted.TrySetResult(); - await firstQueryGate.Task; - - return Result.Fail>("The archive is unreachable."); - }, - (_, _) => applied.TrySetResult(), - _ => { }, - shortWindow, - _capInterval, - DefaultScheduler.Instance, - ImmediateScheduler.Instance); + using var debouncer = CreateDebouncer( + scheduler, + (request, _) => Interlocked.Increment(ref queryCount) == 1 + ? firstQueryGate.Task + : Task.FromResult(Ok(request)), + (_, _) => applied.TrySetResult()); debouncer.Request(RequestForLayer(AggregationLayer.Raw)); - await firstQueryStarted.Task.WaitAsync(_testDeadline, TestContext.Current.CancellationToken); - + scheduler.AdvanceBy(DebounceWindow.Ticks + 1); debouncer.Request(RequestForLayer(AggregationLayer.Raw)); - firstQueryGate.SetResult(); + scheduler.AdvanceBy(DebounceWindow.Ticks + 1); + + queryCount.Should().Be(1); - await applied.Task.WaitAsync(_testDeadline, TestContext.Current.CancellationToken); - queryCount.Should().BeGreaterThanOrEqualTo(2); + firstQueryGate.SetResult(Unreachable()); + await applied.Task.WaitAsync(TestDeadline, TestContext.Current.CancellationToken); + + queryCount.Should().Be(2); } // Without the guard the throw tears down the one subscription that issues every history query, and the @@ -381,8 +358,9 @@ public void AThrowingApplyIsReportedAndTheNextWindowIsStillApplied() var reportedFailures = new List>(); var appliedWindows = new List(); var applyCount = 0; - using var debouncer = new ChartHistoryRequestDebouncer( - request => Task.FromResult(Ok(request)), + using var debouncer = CreateDebouncer( + scheduler, + (request, _) => Task.FromResult(Ok(request)), (request, _) => { applyCount++; @@ -394,50 +372,57 @@ public void AThrowingApplyIsReportedAndTheNextWindowIsStillApplied() appliedWindows.Add(request.FromUtc); }, - reportedFailures.Add, - _debounceWindow, - _capInterval, - scheduler, - ImmediateScheduler.Instance); + reportedFailures.Add); debouncer.Request(RequestAtNotch(1)); - scheduler.AdvanceBy(_debounceWindow.Ticks + 1); + scheduler.AdvanceBy(DebounceWindow.Ticks + 1); reportedFailures.Should().ContainSingle(); reportedFailures[0].Should().ContainSingle() .Which.Message.Should().Be("The axis model rejected the envelope."); debouncer.Request(RequestAtNotch(2)); - scheduler.AdvanceBy(_debounceWindow.Ticks + 1); + scheduler.AdvanceBy(DebounceWindow.Ticks + 1); - appliedWindows.Should().Equal(_from.AddSeconds(2)); + appliedWindows.Should().Equal(From.AddSeconds(2)); } - private static Result> Ok(HistoryRequest request) + [Fact] + public void AFailedApplyCanBeRequestedAgain() { - return Result.Ok>( - [new PenHistoryEnvelope(request.PenIds[0], [request.FromUtc], [0.0], [0.0], [0.0])]); - } + var scheduler = new TestScheduler(); + var queryCount = 0; + var applyCount = 0; + var appliedWindows = new List(); + using var debouncer = CreateDebouncer( + scheduler, + (request, _) => + { + queryCount++; - private static HistoryRequest RequestForLayer(AggregationLayer layer) - { - return RequestOver([1], _from, _to, layer); - } + return Task.FromResult(Ok(request)); + }, + (request, _) => + { + applyCount++; - private static HistoryRequest RequestAtNotch(int notch) - { - return RequestOver([1], _from.AddSeconds(notch), _to.AddSeconds(notch), AggregationLayer.Raw); - } + if (applyCount == 1) + { + throw new InvalidOperationException("The axis model rejected the envelope."); + } - private static HistoryRequest RequestOver( - IReadOnlyList penIds, - DateTime fromUtc, - DateTime toUtc, - AggregationLayer layer) - { - return new HistoryRequest( - penIds, - new FetchRange(fromUtc, toUtc, layer, HistoryColumnTarget.MaxColumns, toUtc - fromUtc), - HistoryColumnTarget.MaxColumns); + appliedWindows.Add(request.FromUtc); + }); + + debouncer.Request(RequestAtNotch(1)); + scheduler.AdvanceBy(DebounceWindow.Ticks + 1); + + appliedWindows.Should().BeEmpty(); + + debouncer.Request(RequestAtNotch(1)); + scheduler.AdvanceBy(DebounceWindow.Ticks + 1); + + queryCount.Should().Be(2); + appliedWindows.Should().Equal(From.AddSeconds(1)); } } diff --git a/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/HistoryDebouncerTestBuilder.cs b/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/HistoryDebouncerTestBuilder.cs new file mode 100644 index 0000000..8fcb281 --- /dev/null +++ b/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/HistoryDebouncerTestBuilder.cs @@ -0,0 +1,87 @@ +using System.Reactive.Concurrency; + +using AwesomeAssertions; + +using FluentResults; + +using Microsoft.Reactive.Testing; + +using SemiPlot.Core.Trends; +using SemiPlot.UI.Chart; + +using Xunit; + +namespace SemiPlot.Tests.Unit.UI.Chart; + +internal static class HistoryDebouncerTestBuilder +{ + public static readonly TimeSpan DebounceWindow = TimeSpan.FromMilliseconds(150); + public static readonly TimeSpan CapInterval = TimeSpan.FromMilliseconds(400); + public static readonly TimeSpan TestDeadline = TimeSpan.FromSeconds(10.0); + public static readonly DateTime From = new(2026, 6, 15, 8, 0, 0, DateTimeKind.Utc); + public static readonly DateTime To = new(2026, 6, 15, 9, 0, 0, DateTimeKind.Utc); + + // The emission head runs on the TestScheduler and every delivery on the thread the query completed on. + public static ChartHistoryRequestDebouncer CreateDebouncer( + TestScheduler scheduler, + Func>>> queryAsync, + Action>? applyHistory = null, + Action>? reportQueryFailure = null) + { + return new ChartHistoryRequestDebouncer( + queryAsync, + applyHistory ?? ((_, _) => { }), + reportQueryFailure ?? (_ => { }), + DebounceWindow, + CapInterval, + scheduler, + ImmediateScheduler.Instance); + } + + // A released query's continuation, and the query the pipeline starts behind it, run on the thread Rx + // resumes the task on. + public static async Task AwaitCondition(Func condition) + { + var deadline = DateTime.UtcNow + TestDeadline; + + while (!condition() && DateTime.UtcNow < deadline) + { + await Task.Delay(TimeSpan.FromMilliseconds(10), TestContext.Current.CancellationToken); + } + + condition().Should().BeTrue(); + } + + public static Result> Ok(HistoryRequest request) + { + return Result.Ok>( + [new PenHistoryEnvelope(request.PenIds[0], [request.FromUtc], [0.0], [0.0], [0.0])]); + } + + public static Result> Unreachable() + { + return Result.Fail>("The archive is unreachable."); + } + + public static HistoryRequest RequestForLayer(AggregationLayer layer) + { + return RequestOver([1], From, To, layer); + } + + public static HistoryRequest RequestAtNotch(int notch) + { + return RequestOver([1], From.AddSeconds(notch), To.AddSeconds(notch), AggregationLayer.Raw); + } + + public static HistoryRequest RequestOver( + IReadOnlyList penIds, + DateTime fromUtc, + DateTime toUtc, + AggregationLayer layer) + { + return new HistoryRequest( + penIds, + new FetchRange(fromUtc, toUtc, layer, HistoryColumnTarget.MaxColumns, toUtc - fromUtc), + HistoryColumnTarget.MaxColumns); + } +} diff --git a/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/HistoryPrefetchTests.cs b/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/HistoryPrefetchTests.cs index 02f2d16..d28816c 100644 --- a/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/HistoryPrefetchTests.cs +++ b/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/HistoryPrefetchTests.cs @@ -73,7 +73,7 @@ public void Covers_HoldsUntilTheWindowLeavesTheInnerBand(int panMinutes, bool is var pannedFrom = _windowFrom.AddMinutes(panMinutes); var covers = HistoryPrefetch.Covers( - fetched, pannedFrom, pannedFrom.AddHours(1.0), AggregationLayer.Raw, ReportedColumns); + fetched, pannedFrom, pannedFrom.AddHours(1.0), AggregationLayer.Raw, ReportedColumns, _archiveStart); covers.Should().Be(isCovered); } @@ -88,7 +88,8 @@ public void Covers_IsFalseAfterAZoom() _windowFrom.AddMinutes(15.0), _windowFrom.AddMinutes(45.0), AggregationLayer.Raw, - ReportedColumns); + ReportedColumns, + _archiveStart); covers.Should().BeFalse(); } @@ -99,7 +100,12 @@ public void Covers_IsFalseOnALayerChange() var fetched = OneHourFetch(); var covers = HistoryPrefetch.Covers( - fetched, _windowFrom, _windowFrom.AddHours(1.0), AggregationLayer.Minute, ReportedColumns); + fetched, + _windowFrom, + _windowFrom.AddHours(1.0), + AggregationLayer.Minute, + ReportedColumns, + _archiveStart); covers.Should().BeFalse(); } @@ -110,7 +116,12 @@ public void Covers_IsFalseOnAColumnTargetChange() var fetched = OneHourFetch(); var covers = HistoryPrefetch.Covers( - fetched, _windowFrom, _windowFrom.AddHours(1.0), AggregationLayer.Raw, ReportedColumns / 2); + fetched, + _windowFrom, + _windowFrom.AddHours(1.0), + AggregationLayer.Raw, + ReportedColumns / 2, + _archiveStart); covers.Should().BeFalse(); } @@ -132,11 +143,29 @@ public void Covers_AtTheArchiveStart_GuardsOnlyTheMarginItActuallyHas(int panMin var pannedFrom = windowFrom.AddMinutes(panMinutes); var covers = HistoryPrefetch.Covers( - fetched, pannedFrom, pannedFrom.AddHours(1.0), AggregationLayer.Raw, ReportedColumns); + fetched, pannedFrom, pannedFrom.AddHours(1.0), AggregationLayer.Raw, ReportedColumns, _archiveStart); covers.Should().Be(isCovered); } + [Theory] + [InlineData(1)] + [InlineData(30)] + [InlineData(59)] + public void Covers_AWindowStartingBeforeTheFirstSample_HoldsTheRangeFetchedForIt(int minutesBeforeTheFirstSample) + { + var windowFrom = _archiveStart.AddMinutes(-minutesBeforeTheFirstSample); + var windowTo = windowFrom.AddHours(1.0); + var fetched = HistoryPrefetch.Expand( + windowFrom, windowTo, AggregationLayer.Raw, ReportedColumns, _archiveStart); + + var covers = HistoryPrefetch.Covers( + fetched, windowFrom, windowTo, AggregationLayer.Raw, ReportedColumns, _archiveStart); + + fetched.FromUtc.Should().Be(_archiveStart); + covers.Should().BeTrue(); + } + private static FetchRange OneHourFetch() { return HistoryPrefetch.Expand( diff --git a/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/TrendChartCatalogueTests.cs b/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/TrendChartCatalogueTests.cs index 6c0be85..0121e5e 100644 --- a/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/TrendChartCatalogueTests.cs +++ b/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/TrendChartCatalogueTests.cs @@ -4,6 +4,7 @@ using FluentResults; +using SemiPlot.Core.Data; using SemiPlot.Core.Trends; using SemiPlot.UI.Chart; @@ -394,4 +395,77 @@ public void ADeltaAdding500Pens_ComputesTheAxisModelOnce() viewModel.Pens.Should().HaveCount(500); viewModel.ScalesRevision.Should().Be(revisionBefore + 1); } + + // The archive is younger than the window, so the window opens before the first sample and every read stops + // at it. + [AvaloniaFact] + public async Task AWidenAfterTheAddedPensReadLanded_ReadsTheWindowFromTheEarlierFirstSample() + { + var (viewModel, scheduler, _, provider) = CreateViewModel(); + viewModel.AddPen(new Pen(1, "Pen 1", ["Group A"], "#ff0000")); + await LoadInitialHistory(viewModel, scheduler, _to.AddMinutes(-10.0), _to); + viewModel.AddPen(new Pen(2, "Pen 2", ["Group A"], "#00ff00")); + scheduler.AdvanceBy(TimeSpan.FromSeconds(5.0).Ticks); + provider.HistoryQueryCount.Should().Be(2); + + viewModel.WidenToArchiveExtent(new ArchiveExtent(_to.AddHours(-3.0), _to)); + scheduler.AdvanceBy(TimeSpan.FromSeconds(5.0).Ticks); + + provider.HistoryQueryCount.Should().Be(3); + provider.LastQueriedPenIds.Should().Equal(1, 2); + provider.LastQueriedFromUtc.Should().Be(_from - TimeSpan.FromHours(1.0)); + + scheduler.AdvanceBy(TimeSpan.FromMinutes(10.0).Ticks); + + provider.HistoryQueryCount.Should().Be(3, "an idle chart reads nothing more"); + } + + [AvaloniaFact] + public async Task AWidenBeforeTheAddedPensReadIsIssued_ReadsTheWindowOnceFromTheEarlierFirstSample() + { + var (viewModel, scheduler, _, provider) = CreateViewModel(); + viewModel.AddPen(new Pen(1, "Pen 1", ["Group A"], "#ff0000")); + await LoadInitialHistory(viewModel, scheduler, _to.AddMinutes(-10.0), _to); + viewModel.AddPen(new Pen(2, "Pen 2", ["Group A"], "#00ff00")); + + viewModel.WidenToArchiveExtent(new ArchiveExtent(_to.AddHours(-3.0), _to)); + scheduler.AdvanceBy(TimeSpan.FromSeconds(5.0).Ticks); + + provider.HistoryQueryCount.Should().Be(2); + provider.LastQueriedFromUtc.Should().Be(_from - TimeSpan.FromHours(1.0)); + + scheduler.AdvanceBy(TimeSpan.FromMinutes(10.0).Ticks); + + provider.HistoryQueryCount.Should().Be(2, "an idle chart reads nothing more"); + } + + [AvaloniaFact] + public async Task AWidenThatKeepsTheFirstSample_ReadsNothing() + { + var (viewModel, scheduler, _, provider) = CreateViewModel(); + viewModel.AddPen(new Pen(1, "Pen 1", ["Group A"], "#ff0000")); + await LoadInitialHistory(viewModel, scheduler, _to.AddMinutes(-10.0), _to); + + viewModel.WidenToArchiveExtent(new ArchiveExtent(_to.AddMinutes(-5.0), _to)); + scheduler.AdvanceBy(TimeSpan.FromSeconds(5.0).Ticks); + + provider.HistoryQueryCount.Should().Be(1); + } + + // The applier widens an empty chart ahead of the catalogue that gives it pens. + [AvaloniaFact] + public async Task AWidenOnAChartWithNoPens_MovesThePanFloorAndReadsNothing() + { + var (viewModel, scheduler, _, provider) = CreateViewModel(); + viewModel.AddPen(new Pen(1, "Pen 1", ["Group A"], "#ff0000")); + await LoadInitialHistory(viewModel, scheduler, _to.AddMinutes(-10.0), _to); + viewModel.ApplyCatalogue([]); + var earlier = _to.AddHours(-3.0); + + viewModel.WidenToArchiveExtent(new ArchiveExtent(earlier, _to)); + scheduler.AdvanceBy(TimeSpan.FromSeconds(5.0).Ticks); + + viewModel.Navigation.FirstSample.Should().Be(earlier); + provider.HistoryQueryCount.Should().Be(1); + } } diff --git a/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/TrendChartViewModelTests.cs b/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/TrendChartViewModelTests.cs index 75423fd..f20f6f8 100644 --- a/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/TrendChartViewModelTests.cs +++ b/SemiPlot/SemiPlot.Tests.Unit/UI/Chart/TrendChartViewModelTests.cs @@ -1,3 +1,5 @@ +using System.Collections; +using System.Reactive.Concurrency; using System.Reactive.Linq; using System.Reactive.Threading.Tasks; @@ -574,6 +576,42 @@ public void RequestInitialHistory_FiresExactlyOneHistoryQuery_NoDoubleLoad() provider.HistoryQueryCount.Should().Be(1); } + // The archive is younger than the default window, so the window opens before the first sample. + [AvaloniaFact] + public void AnArchiveYoungerThanTheWindowIsReadOnce() + { + var (viewModel, scheduler, _, provider) = CreateViewModel(); + viewModel.AddPen(new Pen(1, "Pen 1", ["Group A"], "#ff0000")); + var firstSample = _to.AddMinutes(-10.0); + + viewModel.Navigation.TrackDataExtents(firstSample, _to); + viewModel.RequestInitialHistory(); + scheduler.AdvanceBy(TimeSpan.FromSeconds(5.0).Ticks); + + viewModel.Navigation.From.Should().BeBefore(firstSample); + provider.HistoryQueryCount.Should().Be(1); + provider.LastQueriedFromUtc.Should().Be(firstSample); + } + + [AvaloniaFact] + public async Task AZoomOutPastTheArchiveFollowedByNowReadsTheWindowOnce() + { + var (viewModel, scheduler, _, provider) = CreateViewModel(); + viewModel.AddPen(new Pen(1, "Pen 1", ["Group A"], "#ff0000")); + var firstSample = _from.AddDays(-30.0); + await LoadInitialHistory(viewModel, scheduler, firstSample, _to); + + viewModel.Navigation.ZoomAt(1000.0, viewModel.Navigation.To); + viewModel.Navigation.JumpToNow(); + scheduler.AdvanceBy(TimeSpan.FromSeconds(5.0).Ticks); + + var width = viewModel.Navigation.To - viewModel.Navigation.From; + viewModel.Navigation.From.Should().BeBefore(firstSample); + provider.HistoryQueryCount.Should().Be(2); + provider.LastQueriedFromUtc.Should().Be(firstSample); + provider.LastQueriedToUtc.Should().Be(viewModel.Navigation.To + width); + } + [AvaloniaFact] public void RequestInitialHistory_WithNoPens_DoesNotQuery() { @@ -940,9 +978,7 @@ public async Task APanInsideTheBandAfterAFailedQueryAsksAgain() provider.HistoryQueryCount.Should().Be(2); } - // The drag that leaves the fetched band and comes back into it before the far query lands. The gate reads - // the band as fetched and asks nothing, so the far result arriving is the only thing that can notice the - // envelopes it replaced no longer draw the window in view. + // The drag that leaves the fetched band and comes back into it, still moving when the far query lands. [AvaloniaFact] public async Task AResultForAWindowTheDragLeftReQueriesTheWindowInView() { @@ -960,7 +996,6 @@ public async Task AResultForAWindowTheDragLeftReQueriesTheWindowInView() provider.GatedLayer = null; viewModel.Navigation.PanBy(4 * width); - scheduler.AdvanceBy(HistoryDebounceWindow.Ticks + 1); viewModel.Navigation.From.Should().Be(windowFrom); provider.HistoryQueryCount.Should().Be(2); @@ -990,7 +1025,6 @@ public async Task AResultForAWindowTheDragLeftLeavesTheAxisUntouched() provider.GatedLayer = null; viewModel.Navigation.PanBy(4 * width); - scheduler.AdvanceBy(HistoryDebounceWindow.Ticks + 1); var revisionBefore = viewModel.ScalesRevision; await ReleaseAndAwaitResults(viewModel, 1, () => provider.HistoryGate.SetResult(StaleEnvelopes())); @@ -998,6 +1032,208 @@ public async Task AResultForAWindowTheDragLeftLeavesTheAxisUntouched() viewModel.ScalesRevision.Should().Be(revisionBefore); } + [AvaloniaFact] + public async Task AGestureEndingBackInsideTheFetchedBandCancelsTheReadItLeft() + { + var (viewModel, scheduler, _, provider) = CreateViewModel(); + viewModel.AddPen(new Pen(1, "Pen 1", ["Group A"], "#ff0000")); + await LoadInitialHistory(viewModel, scheduler, _from.AddDays(-30.0), _to); + var width = viewModel.Navigation.To - viewModel.Navigation.From; + + provider.GatedLayer = viewModel.Navigation.ActiveLayer; + viewModel.Navigation.PanBy(-4 * width); + scheduler.AdvanceBy(HistoryDebounceWindow.Ticks + 1); + var farRead = provider.LastQueriedCancellationToken; + + viewModel.Navigation.PanBy(4 * width); + scheduler.AdvanceBy(HistoryDebounceWindow.Ticks + 1); + + farRead.IsCancellationRequested.Should().BeTrue(); + provider.HistoryQueryCount.Should().Be(2); + } + + // The apply throws after the band is loaded and the gate open, so the band is drawn though the apply was + // reported as failed. + [AvaloniaFact] + public async Task AGestureEndingInsideABandWhoseApplyThrewAfterTheLoadCancelsTheReadItLeft() + { + var (viewModel, scheduler, provider, panel) = CreateViewModelWithPanel(); + viewModel.AddPen(new Pen(1, "Pen 1", ["Group A"], "#ff0000")); + var subscriberThrows = true; + using var subscriber = viewModel.HistoryApplied.Subscribe(_ => + { + if (subscriberThrows) + { + subscriberThrows = false; + + throw new InvalidOperationException("A subscriber rejected the history."); + } + }); + await LoadInitialHistory(viewModel, scheduler, _from.AddDays(-30.0), _to); + await AwaitPanelEntries(panel, 1); + var width = viewModel.Navigation.To - viewModel.Navigation.From; + + provider.GatedLayer = viewModel.Navigation.ActiveLayer; + viewModel.Navigation.PanBy(-4 * width); + scheduler.AdvanceBy(HistoryDebounceWindow.Ticks + 1); + var farRead = provider.LastQueriedCancellationToken; + + viewModel.Navigation.PanBy(4 * width); + scheduler.AdvanceBy(HistoryDebounceWindow.Ticks + 1); + + farRead.IsCancellationRequested.Should().BeTrue(); + provider.HistoryQueryCount.Should().Be(2); + } + + // The read for pen 1 alone is admitted at 150 ms; pen 2 arrives at 300 ms and the 400 ms sample tick parks + // its read behind the first, so that read lands with pen 2 already shown. Its range covers the window in + // view but holds no row of pen 2, and the pan that follows ends inside it while pen 2's read still runs. + [AvaloniaFact] + public async Task AReadForAnOlderPenSetLeavesTheNewPensReadRunning() + { + var (viewModel, scheduler, _, provider) = CreateViewModel(); + viewModel.AddPen(new Pen(1, "Pen 1", ["Group A"], "#ff0000")); + viewModel.Navigation.TrackDataExtents(_from.AddDays(-30.0), _to); + provider.GatedLayer = viewModel.Navigation.ActiveLayer; + viewModel.RequestInitialHistory(); + scheduler.AdvanceBy(TimeSpan.FromMilliseconds(300).Ticks); + + var penOneRead = provider.HistoryGate; + provider.HistoryGate = new(); + viewModel.AddPen(new Pen(2, "Pen 2", ["Group A"], "#00ff00")); + scheduler.AdvanceBy(TimeSpan.FromMilliseconds(100).Ticks); + + await ReleaseAndAwaitResults(viewModel, 1, () => penOneRead.SetResult(StaleEnvelopes())); + await AwaitQueryCount(provider, 2); + + viewModel.Navigation.PanBy(TimeSpan.FromMinutes(-30.0)); + scheduler.AdvanceBy(HistoryDebounceWindow.Ticks + 1); + await AwaitQueryCount(provider, 3); + + provider.LastQueriedPenIds.Should().BeEquivalentTo([1, 2]); + provider.LastQueriedCancellationToken.IsCancellationRequested.Should().BeFalse(); + + await ReleaseAndAwaitResults(viewModel, 1, () => provider.HistoryGate.SetResult( + Result.Ok>( + [ + new PenHistoryEnvelope(1, [_from, _to], [7.0, 7.0], [7.0, 7.0], [7.0, 7.0]), + new PenHistoryEnvelope(2, [_from, _to], [7.0, 7.0], [7.0, 7.0], [7.0, 7.0]) + ]))); + + viewModel.FindPen(2)!.CurrentValue.Should().Be(7.0); + } + + // Pen 2 comes back into the slot it left, so the read the return asks for names the pens and the window + // the read before the removal applied, while the read the removal asked for is still held. + [AvaloniaFact] + public async Task APenThatComesBackWhileTheRemovalsReadIsHeldIsReadAgain() + { + var (viewModel, scheduler, _, provider) = CreateViewModel(); + var catalogue = provider.Pens; + viewModel.ApplyCatalogue(catalogue); + await LoadInitialHistory(viewModel, scheduler, _from.AddDays(-30.0), _to); + + provider.GatedLayer = viewModel.Navigation.ActiveLayer; + viewModel.ApplyCatalogue([catalogue[0]]); + scheduler.AdvanceBy(HistoryDebounceWindow.Ticks + 1); + provider.HistoryQueryCount.Should().Be(2); + + viewModel.ApplyCatalogue(catalogue); + scheduler.AdvanceBy(HistoryDebounceWindow.Ticks + 1); + await AwaitQueryCount(provider, 3); + + provider.LastQueriedPenIds.Should().Equal(1, 2); + + await ReleaseAndAwaitResults(viewModel, 1, () => provider.HistoryGate.SetResult( + Result.Ok>( + [ + new PenHistoryEnvelope(1, [_from, _to], [7.0, 7.0], [7.0, 7.0], [7.0, 7.0]), + new PenHistoryEnvelope(2, [_from, _to], [7.0, 7.0], [7.0, 7.0], [7.0, 7.0]) + ]))); + + viewModel.FindPen(2)!.CurrentValue.Should().Be(7.0); + } + + // The same return after the removal's read failed: the failure keeps the window drawn before it, which + // names the pens the return asks for. + [AvaloniaFact] + public async Task APenThatComesBackAfterTheRemovalsReadFailedIsReadAgain() + { + var (viewModel, scheduler, _, provider) = CreateViewModel(); + var catalogue = provider.Pens; + viewModel.ApplyCatalogue(catalogue); + await LoadInitialHistory(viewModel, scheduler, _from.AddDays(-30.0), _to); + + provider.FailHistory = true; + viewModel.ApplyCatalogue([catalogue[0]]); + scheduler.AdvanceBy(HistoryDebounceWindow.Ticks + 1); + provider.HistoryQueryCount.Should().Be(2); + + provider.FailHistory = false; + await ReleaseAndAwaitResults(viewModel, 1, () => + { + viewModel.ApplyCatalogue(catalogue); + scheduler.AdvanceBy(HistoryDebounceWindow.Ticks + 1); + }); + + provider.HistoryQueryCount.Should().Be(3); + provider.LastQueriedPenIds.Should().Equal(1, 2); + viewModel.FindPen(2)!.CurrentValue.Should().Be(FakeDataProvider.DefaultCenter); + } + + [AvaloniaFact] + public async Task AWindowWhoseApplyThrewIsReadAgainOnTheNextMove() + { + var (viewModel, scheduler, provider, panel) = CreateViewModelWithPanel(); + var state = viewModel.AddPen(new Pen(1, "Pen 1", ["Group A"], "#ff0000")); + viewModel.Navigation.TrackDataExtents(_from.AddDays(-30.0), _to); + provider.GatedLayer = viewModel.Navigation.ActiveLayer; + viewModel.RequestInitialHistory(); + scheduler.AdvanceBy(HistoryDebounceWindow.Ticks + 1); + + provider.GatedLayer = null; + provider.HistoryGate.SetResult(Result.Ok>([UnloadableEnvelope(1)])); + await AwaitPanelEntries(panel, 1); + + viewModel.Navigation.PanBy(TimeSpan.FromMinutes(-1.0)); + await ReleaseAndAwaitResults(viewModel, 1, () => scheduler.AdvanceBy(HistoryDebounceWindow.Ticks + 1)); + + provider.HistoryQueryCount.Should().Be(2); + state.CurrentValue.Should().Be(FakeDataProvider.DefaultCenter); + } + + // The far result loads pen 1 and throws on pen 2, so pen 1 holds rows of a band nothing is fetched for. + [AvaloniaFact] + public async Task AnApplyThatThrowsPartWayLeavesNoBandFetched() + { + var (viewModel, scheduler, provider, panel) = CreateViewModelWithPanel(); + viewModel.AddPen(new Pen(1, "Pen 1", ["Group A"], "#ff0000")); + viewModel.AddPen(new Pen(2, "Pen 2", ["Group A"], "#00ff00")); + await LoadInitialHistory(viewModel, scheduler, _from.AddDays(-30.0), _to); + var width = viewModel.Navigation.To - viewModel.Navigation.From; + var windowFrom = viewModel.Navigation.From; + + provider.GatedLayer = viewModel.Navigation.ActiveLayer; + viewModel.Navigation.PanBy(-4 * width); + scheduler.AdvanceBy(HistoryDebounceWindow.Ticks + 1); + + provider.GatedLayer = null; + provider.HistoryGate.SetResult(Result.Ok>( + [.. StaleEnvelopes().Value, UnloadableEnvelope(2)])); + await AwaitPanelEntries(panel, 1); + viewModel.FindPen(1)!.CurrentValue.Should().Be(99.0); + + await ReleaseAndAwaitResults(viewModel, 1, () => + { + viewModel.Navigation.PanBy(4 * width); + scheduler.AdvanceBy(HistoryDebounceWindow.Ticks + 1); + }); + + provider.HistoryQueryCount.Should().Be(3); + provider.LastQueriedFromUtc.Should().Be(windowFrom - width); + viewModel.FindPen(1)!.CurrentValue.Should().Be(FakeDataProvider.DefaultCenter); + } + // Skipping the axis here freezes it with no path back, since the gate that would ask again opens on a // result. APanInsideTheBandAfterAFailedQueryAsksAgain pins the ask. [AvaloniaFact] @@ -1116,11 +1352,8 @@ public void ReportedWidthChangingTheLayer_ReQueriesAtTheNewLayerAndColumnCount() } [AvaloniaFact] - public async Task WidthReportedWhileTheInitialQueryIsInFlight_AppliesTheLaterWindowLast() + public async Task WidthReportedWhileTheInitialQueryIsInFlight_CancelsItAndAppliesTheReportedResolution() { - // Startup race: the render seam reports a width while the initial query is in flight. One query runs - // at a time, so the reported window waits behind the held one and reaches the archive only when it - // lands; it is then the last window applied, at the reported resolution. var (viewModel, scheduler, _, provider) = CreateViewModel(); var state = viewModel.AddPen(new Pen(1, "Pen 1", ["Group A"], "#ff0000")); provider.GatedLayer = AggregationLayer.Raw; @@ -1128,15 +1361,13 @@ public async Task WidthReportedWhileTheInitialQueryIsInFlight_AppliesTheLaterWin viewModel.RequestInitialHistory(); scheduler.AdvanceBy(HistoryDebounceWindow.Ticks + 1); provider.HistoryQueryCount.Should().Be(1); + var initialRead = provider.LastQueriedCancellationToken; viewModel.ReportDataAreaWidth(256.0); provider.GatedLayer = null; - scheduler.AdvanceBy(HistoryDebounceWindow.Ticks + 1); - - provider.HistoryQueryCount.Should().Be(1); - - await ReleaseAndAwaitResults(viewModel, 2, () => provider.HistoryGate.SetResult(StaleEnvelopes())); + await ReleaseAndAwaitResults(viewModel, 1, () => scheduler.AdvanceBy(HistoryDebounceWindow.Ticks + 1)); + initialRead.IsCancellationRequested.Should().BeTrue(); provider.HistoryQueryCount.Should().Be(2); provider.LastQueriedTargetColumnCount.Should().Be(HistoryPrefetch.MarginColumnFactor * 256); state.Line.Columns.Should().HaveCount(2); @@ -1290,30 +1521,42 @@ public async Task RequestInitialHistory_LoadsEnvelopes_ThenALaterGestureResultSu } [AvaloniaFact] - public async Task StaleInitialHistory_DoesNotOverwriteANewerDebouncedGestureWindow() + public async Task StaleInitialHistory_IsCancelledByAZoomEndingOnAnotherWindow() { - // The initial query is held in flight while a zoom asks for a coarser window. One query runs at a - // time, so the held read lands first and the gesture's window runs behind it: the value the stale - // result carried is overwritten, never the other way round. var (viewModel, scheduler, _, provider) = CreateViewModel(); viewModel.AddPen(new Pen(1, "Pen 1", ["Group A"], "#ff0000")); provider.GatedLayer = AggregationLayer.Raw; viewModel.RequestInitialHistory(); scheduler.AdvanceBy(HistoryDebounceWindow.Ticks + 1); + var initialRead = provider.LastQueriedCancellationToken; viewModel.Navigation.ZoomAt(48.0, viewModel.Navigation.To); viewModel.Navigation.ActiveLayer.Should().NotBe(AggregationLayer.Raw); - scheduler.AdvanceBy(HistoryDebounceWindow.Ticks + 1); - - provider.HistoryQueryCount.Should().Be(1); - - await ReleaseAndAwaitResults(viewModel, 2, () => provider.HistoryGate.SetResult(StaleEnvelopes())); + await ReleaseAndAwaitResults(viewModel, 1, () => scheduler.AdvanceBy(HistoryDebounceWindow.Ticks + 1)); + initialRead.IsCancellationRequested.Should().BeTrue(); provider.HistoryQueryCount.Should().Be(2); viewModel.FindPen(1)!.CurrentValue.Should().Be(2.0); } + [AvaloniaFact] + public void DisposingTheChart_CancelsTheHistoryReadInFlight() + { + var (viewModel, scheduler, _, provider) = CreateViewModel(); + viewModel.AddPen(new Pen(1, "Pen 1", ["Group A"], "#ff0000")); + provider.GatedLayer = AggregationLayer.Raw; + + viewModel.RequestInitialHistory(); + scheduler.AdvanceBy(HistoryDebounceWindow.Ticks + 1); + var read = provider.LastQueriedCancellationToken; + read.IsCancellationRequested.Should().BeFalse(); + + viewModel.Dispose(); + + read.IsCancellationRequested.Should().BeTrue(); + } + [AvaloniaFact] public void RequestInitialHistory_ResultArrivingAfterDispose_DoesNotApplyOrThrow() { @@ -1560,6 +1803,41 @@ private static Result> StaleEnvelopes() ]); } + // Loading it throws inside the apply, as a defect there would; the axis model reads it afterwards unharmed. + private static PenHistoryEnvelope UnloadableEnvelope(int penId) + { + return new PenHistoryEnvelope(penId, [_from, _to], new ThrowsOnFirstRead(99.0, 2), [99.0, 99.0], [99.0, 99.0]); + } + + private static (TrendChartViewModel ViewModel, TestScheduler Scheduler, FakeDataProvider Provider, + MessagePanelViewModel Panel) CreateViewModelWithPanel() + { + var scheduler = new TestScheduler(); + var provider = new FakeDataProvider(scheduler, TimeSpan.FromHours(1)); + var panel = new MessagePanelViewModel(); + var viewModel = new TrendChartViewModel( + CreateCoordinator(scheduler, provider), + scheduler, + ImmediateScheduler.Instance, + panel, + NullLogger.Instance); + + return (viewModel, scheduler, provider, panel); + } + + // The apply runs on the thread Rx resumes the released query on, so its report lands after the release. + private static async Task AwaitPanelEntries(MessagePanelViewModel panel, int count) + { + var deadline = DateTime.UtcNow + _testDeadline; + + while (panel.Entries.Count < count && DateTime.UtcNow < deadline) + { + await Task.Delay(TimeSpan.FromMilliseconds(10), TestContext.Current.CancellationToken); + } + + panel.Entries.Should().HaveCount(count); + } + // A query the pipeline issues behind a released one starts once Rx has unwound the released query on the // thread it resumed the task on, so a count read straight after the release can run ahead of it. private static async Task AwaitQueryCount(FakeDataProvider provider, int count) @@ -1587,4 +1865,25 @@ private static async Task ReleaseAndAwaitResults( await applied.WaitAsync(_testDeadline, TestContext.Current.CancellationToken); } + + private sealed class ThrowsOnFirstRead(double value, int count) : IReadOnlyList + { + private int _reads; + + public int Count => count; + + public double this[int index] => Interlocked.Increment(ref _reads) == 1 + ? throw new InvalidOperationException("The envelope could not be loaded.") + : value; + + public IEnumerator GetEnumerator() + { + return Enumerable.Repeat(value, count).GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + } } diff --git a/SemiPlot/SemiPlot.Tests.Unit/UI/MainWindow/PenCatalogueApplierTests.cs b/SemiPlot/SemiPlot.Tests.Unit/UI/MainWindow/PenCatalogueApplierTests.cs index f9723c9..8b9e374 100644 --- a/SemiPlot/SemiPlot.Tests.Unit/UI/MainWindow/PenCatalogueApplierTests.cs +++ b/SemiPlot/SemiPlot.Tests.Unit/UI/MainWindow/PenCatalogueApplierTests.cs @@ -61,6 +61,65 @@ public void ADeltaAddingAPenWithOlderRows_LetsTheChartPanBackToThem() chart.Navigation.From.Should().Be(olderFirstSample); } + [AvaloniaFact] + public void ADeltaAddingAPenWithOlderRows_WhoseReadLandsBeforeTheExtent_ReadsTheOlderRowsInView() + { + using var stand = NewWindowStand(); + var provider = stand.Provider; + var chart = stand.ViewModel.ChartViewModel; + OpenTheWindowBeforeTheFirstSample(stand); + var olderFirstSample = chart.Navigation.FirstSample - TimeSpan.FromDays(3.0); + provider.GateExtent = true; + provider.Pens = [.. provider.Pens, new Pen(3, "Pen 3", ["Group B"], "#0000ff")]; + + AdvanceOneRead(stand); + Advance(stand, TimeSpan.FromSeconds(1)); + + provider.LastQueriedPenIds.Should().Equal(1, 2, 3); + var readsBeforeTheExtent = provider.HistoryQueryCount; + + provider.ExtentGate.SetResult(Result.Ok(new ArchiveExtent(olderFirstSample, provider.ArchiveLastUtc))); + Dispatcher.UIThread.RunJobs(); + Advance(stand, TimeSpan.FromSeconds(1)); + Advance(stand, TimeSpan.FromSeconds(1)); + + chart.Navigation.FirstSample.Should().Be(olderFirstSample); + provider.HistoryQueryCount.Should().Be(readsBeforeTheExtent + 1); + provider.LastQueriedFromUtc.Should().Be(olderFirstSample); + + Advance(stand, TimeSpan.FromMinutes(10.0)); + + provider.HistoryQueryCount.Should().Be(readsBeforeTheExtent + 1, "an idle chart reads nothing more"); + } + + [AvaloniaFact] + public void ADeltaGivingAChartThatLostItsPensAPenWithOlderRows_ReadsAndPansBackToThem() + { + using var stand = NewWindowStand(); + var provider = stand.Provider; + var chart = stand.ViewModel.ChartViewModel; + OpenTheWindowBeforeTheFirstSample(stand); + var olderFirstSample = chart.Navigation.FirstSample - TimeSpan.FromDays(3.0); + provider.Pens = []; + AdvanceOneRead(stand); + chart.HasNoPens.Should().BeTrue(); + + provider.ArchiveFirstUtc = olderFirstSample; + provider.Pens = [new Pen(3, "Pen 3", ["Group B"], "#0000ff")]; + AdvanceOneRead(stand); + Advance(stand, TimeSpan.FromSeconds(1)); + Advance(stand, TimeSpan.FromSeconds(1)); + + chart.Navigation.FirstSample.Should().Be(olderFirstSample); + provider.LastQueriedPenIds.Should().Equal(3); + provider.LastQueriedFromUtc.Should().Be(olderFirstSample); + var reads = provider.HistoryQueryCount; + + Advance(stand, TimeSpan.FromMinutes(10.0)); + + provider.HistoryQueryCount.Should().Be(reads, "an idle chart reads nothing more"); + } + [AvaloniaFact] public void ADeltaGivingAnEmptyChartItsFirstPens_SeedsTheNavigationBeforeTheHistoryQuery() { @@ -247,6 +306,17 @@ private static IEnumerable LegendRowNames(MainWindowViewModel viewModel) return viewModel.LegendViewModel.Groups.SelectMany(group => group.Rows).Select(row => row.Name); } + // A window wider than the archive, at the live edge, starts before the first sample. + private static void OpenTheWindowBeforeTheFirstSample(WindowStand stand) + { + var navigation = stand.ViewModel.ChartViewModel.Navigation; + navigation.ZoomAt(400.0, navigation.To); + navigation.JumpToNow(); + Advance(stand, TimeSpan.FromSeconds(1)); + + navigation.From.Should().BeBefore(navigation.FirstSample); + } + private static void AdvanceOneRead(WindowStand stand) { Advance(stand, PenCatalogueSync.ReadInterval); diff --git a/SemiPlot/SemiPlot.UI/Bridge/TrendCoordinator.cs b/SemiPlot/SemiPlot.UI/Bridge/TrendCoordinator.cs index 07f3cc4..6d81c91 100644 --- a/SemiPlot/SemiPlot.UI/Bridge/TrendCoordinator.cs +++ b/SemiPlot/SemiPlot.UI/Bridge/TrendCoordinator.cs @@ -94,9 +94,10 @@ public Task>> QueryHistoryAsync( DateTime fromUtc, DateTime toUtc, AggregationLayer layer, - int targetColumnCount) + int targetColumnCount, + CancellationToken cancellationToken = default) { - return _dataProvider.QueryHistoryAsync(penIds, fromUtc, toUtc, layer, targetColumnCount); + return _dataProvider.QueryHistoryAsync(penIds, fromUtc, toUtc, layer, targetColumnCount, cancellationToken); } public Task> QueryArchiveExtentAsync() diff --git a/SemiPlot/SemiPlot.UI/Chart/ChartHistoryRequestDebouncer.cs b/SemiPlot/SemiPlot.UI/Chart/ChartHistoryRequestDebouncer.cs index a0230c7..29a3e6b 100644 --- a/SemiPlot/SemiPlot.UI/Chart/ChartHistoryRequestDebouncer.cs +++ b/SemiPlot/SemiPlot.UI/Chart/ChartHistoryRequestDebouncer.cs @@ -12,27 +12,32 @@ namespace SemiPlot.UI.Chart; // docs/architecture/data-integration.md#what-one-history-query-covers public sealed class ChartHistoryRequestDebouncer : IDisposable { - private readonly Subject _requests = new(); + private readonly Subject _requests = new(); // Nothing subscribes to it but the query pipeline, and only Admit and the slot release push into it, // which is what keeps a single query in flight. - private readonly Subject _admitted = new(); + private readonly Subject _admitted = new(); private readonly Action> _applyHistory; private readonly Action> _reportQueryFailure; + private readonly IScheduler _uiScheduler; private readonly IDisposable _subscription; - // Written from the emission head on the data scheduler and from the thread a query completes on, so every - // access to these three takes the one gate. + // Written from the emission head on the data scheduler, from the thread a query completes on, and on the + // UI thread by Dispose, so every access to these three takes the one gate. private readonly Lock _gate = new(); - private HistoryRequest? _lastApplied; - private HistoryRequest? _pending; - private bool _isQueryRunning; + private SucceededRead? _lastSucceeded; + private StampedRequest? _pending; + private RunningQuery? _running; + + // Incremented by Deliver on the UI scheduler and read by Request on the caller's thread, which are one + // thread only when the UI scheduler is the dispatcher. + private int _deliveredReads; // Written on the UI thread that disposes and read on the data and query-completion threads. private volatile bool _isDisposed; public ChartHistoryRequestDebouncer( - Func>>> queryAsync, + Func>>> queryAsync, Action> applyHistory, Action> reportQueryFailure, TimeSpan debounceWindow, @@ -42,27 +47,30 @@ public ChartHistoryRequestDebouncer( { _applyHistory = applyHistory; _reportQueryFailure = reportQueryFailure; + _uiScheduler = uiScheduler; - var trailing = _requests.Throttle(debounceWindow, dataScheduler); - var paced = _requests.Sample(capInterval, dataScheduler); + var trailing = _requests + .Throttle(debounceWindow, dataScheduler) + .Select(pushed => (Pushed: pushed, EndsGesture: true)); + var paced = _requests + .Sample(capInterval, dataScheduler) + .Select(pushed => (Pushed: pushed, EndsGesture: false)); var emissions = trailing .Merge(paced) - .Where(request => !ReadsWhatTheLastQueryApplied(request)) - .Subscribe(Admit); + .Subscribe(admission => Admit(admission.Pushed, admission.EndsGesture)); var queries = _admitted - .Select(request => Observable - .FromAsync(() => queryAsync(request)) + .Select(query => Observable + .FromAsync(() => queryAsync(query.Request, query.Cancellation.Token)) + .Select(result => new QueryCompletion(query.Request, result)) // A thrown query joins the failure channel the provider already answers on, so the delivery // below has one failure path instead of two and the stream survives either. - .Catch((Exception queryFailure) => Observable.Return( - Result.Fail>(new ExceptionalError(queryFailure)))) - .Select(result => (request, result))) + .Catch((Exception queryFailure) => Observable.Return(CompletionOf(query, queryFailure)))) .Concat() - .Do(pair => CompleteQuery(pair.request, pair.result)) + .Do(CompleteQuery) .ObserveOn(uiScheduler) - .Subscribe(pair => Deliver(pair.request, pair.result), ReportPipelineFailure); + .Subscribe(Deliver, ReportPipelineFailure); _subscription = new CompositeDisposable(emissions, queries); } @@ -78,53 +86,80 @@ public void Dispose() _subscription.Dispose(); _requests.Dispose(); _admitted.Dispose(); + + lock (_gate) + { + if (_running is { } running) + { + CancelInBackground(running); + } + } } public void Request(HistoryRequest request) { - ObjectDisposedException.ThrowIf(_isDisposed, this); + _requests.OnNext(new StampedRequest(request, Volatile.Read(ref _deliveredReads))); + } - _requests.OnNext(request); + /// + /// Says the window in view is drawn: nothing is read for it, and as a gesture's end it cancels the read in flight. + /// + public void RequestNothing() + { + _requests.OnNext(null); } - // Only the newest window waits behind the running query: an older one the gesture has already left would - // read rows nothing draws. - private void Admit(HistoryRequest request) + private void Admit(StampedRequest? pushed, bool endsGesture) { + RunningQuery query; + lock (_gate) { - if (_isQueryRunning) + var toRead = pushed is not null && !IsAnsweredByLastSucceeded(pushed) ? pushed : null; + + if (_running is { } running) { - _pending = request; + _pending = toRead; + + if (endsGesture && !Matches(running.Request, pushed?.Request)) + { + CancelInBackground(running); + } return; } - _isQueryRunning = true; + if (toRead is null) + { + return; + } + + query = TakeQuerySlot(toRead.Request); } - Start(request); + Start(query); } - private void CompleteQuery(HistoryRequest request, Result> result) + private void CompleteQuery(QueryCompletion completion) { - HistoryRequest? next; + RunningQuery? next = null; lock (_gate) { - _lastApplied = result.IsSuccess ? request : null; + _running = null; - next = _pending; + if (completion.Result is { IsSuccess: true }) + { + _lastSucceeded = new SucceededRead(completion.Request, (_lastSucceeded?.Ordinal ?? 0) + 1); + } + + var waiting = _pending; _pending = null; - // The window waiting behind a query can be the window that query just applied: a sample tick - // fires on the request the gesture last pushed, whether or not a read for it is already running. - if (Matches(_lastApplied, next)) + if (waiting is not null && !IsAnsweredByLastSucceeded(waiting)) { - next = null; + next = TakeQuerySlot(waiting.Request); } - - _isQueryRunning = next is not null; } if (next is not null) @@ -133,20 +168,49 @@ private void CompleteQuery(HistoryRequest request, Result _uiScheduler.Schedule(() => ReportCancelFailure(cancelled.Exception!)), + CancellationToken.None, + TaskContinuationOptions.OnlyOnFaulted, + TaskScheduler.Default); } // The request travels with its result so the consumer can tell "asked for and not returned" from "not // asked for": a pen added while the query was in flight is neither. - private void Deliver(HistoryRequest request, Result> result) + private void Deliver(QueryCompletion completion) { + if (completion.Result is not { } result) + { + return; + } + if (result.IsFailed) { _reportQueryFailure(result.Errors); @@ -154,11 +218,15 @@ private void Deliver(HistoryRequest request, Result>(new ExceptionalError(queryFailure))); } - private static bool Matches(HistoryRequest? applied, HistoryRequest? request) + private static bool Matches(HistoryRequest known, HistoryRequest? request) { - return applied is not null - && request is not null - && applied.FromUtc == request.FromUtc - && applied.ToUtc == request.ToUtc - && applied.Layer == request.Layer - && applied.TargetColumnCount == request.TargetColumnCount - && applied.PenIds.SequenceEqual(request.PenIds); + return request is not null + && known.FromUtc == request.FromUtc + && known.ToUtc == request.ToUtc + && known.Layer == request.Layer + && known.TargetColumnCount == request.TargetColumnCount + && known.PenIds.SequenceEqual(request.PenIds); } + + private sealed record StampedRequest(HistoryRequest Request, int ReadsDeliveredAtPush); + + /// Ordinal counts successful reads from one, in the order they are delivered. + private sealed record SucceededRead(HistoryRequest Request, int Ordinal); + + private sealed record RunningQuery(HistoryRequest Request, CancellationTokenSource Cancellation); + + /// A null result is a query the debouncer cancelled. + private readonly record struct QueryCompletion( + HistoryRequest Request, + Result>? Result); } diff --git a/SemiPlot/SemiPlot.UI/Chart/HistoryPrefetch.cs b/SemiPlot/SemiPlot.UI/Chart/HistoryPrefetch.cs index 3e48754..511ef34 100644 --- a/SemiPlot/SemiPlot.UI/Chart/HistoryPrefetch.cs +++ b/SemiPlot/SemiPlot.UI/Chart/HistoryPrefetch.cs @@ -48,7 +48,8 @@ public static bool Covers( DateTime fromUtc, DateTime toUtc, AggregationLayer layer, - int columnTarget) + int columnTarget, + DateTime firstSampleUtc) { if (fetched.Layer != layer || fetched.ColumnTarget != ScaleColumnTarget(columnTarget)) { @@ -65,10 +66,12 @@ public static bool Covers( // Expand clamps the left edge only, so what lies left of the visible window is the whole range less // that window and the unclamped right margin. var leftMargin = fetched.ToUtc - fetched.FromUtc - fetched.WindowWidth - rightMargin; + var reachesTheFirstSample = fetched.FromUtc <= firstSampleUtc; // The inner band ends half of each side's own margin in from that fetched edge, so the next query is - // issued while the rest of the margin still covers the gesture that triggered it. - return fromUtc >= fetched.FromUtc + (leftMargin / 2) + // issued while the rest of the margin still covers the gesture that triggered it. No row lies before + // the first sample, so a range reaching it has no left edge to approach. + return (reachesTheFirstSample || fromUtc >= fetched.FromUtc + (leftMargin / 2)) && toUtc <= fetched.ToUtc - (rightMargin / 2); } } diff --git a/SemiPlot/SemiPlot.UI/Chart/TrendChartViewModel.cs b/SemiPlot/SemiPlot.UI/Chart/TrendChartViewModel.cs index 3cfb34b..301dbd1 100644 --- a/SemiPlot/SemiPlot.UI/Chart/TrendChartViewModel.cs +++ b/SemiPlot/SemiPlot.UI/Chart/TrendChartViewModel.cs @@ -12,6 +12,7 @@ using ScottPlot; +using SemiPlot.Core.Data; using SemiPlot.Core.Trends; using SemiPlot.UI.Bridge; using SemiPlot.UI.Messages; @@ -257,6 +258,21 @@ public void ApplyCatalogue(IReadOnlyList catalogue) RequestRedraw(); } + /// + /// Moves the pan floor back to an earlier first sample, and reads the window in view again when the range + /// in hand stopped at the floor that moved (docs/architecture/charting.md#applying-a-catalogue-read). + /// + public void WidenToArchiveExtent(ArchiveExtent extent) + { + var firstSampleBefore = Navigation.FirstSample; + Navigation.WidenToArchiveExtent(extent); + + if (_isHistoryStarted && Navigation.FirstSample != firstSampleBefore) + { + RequestWindowInForce(); + } + } + public bool SetPenVisibility(int penId, bool isVisible) { ObjectDisposedException.ThrowIf(_isDisposed, this); @@ -388,7 +404,7 @@ public bool SetAxisLimits(int penId, double min, double max) private void MoveTheLiveEdgeOntoThePensShown() { - if (_coordinator.PenIds.ToHashSet().SetEquals(_penSet.ById.Keys)) + if (ShowsExactly(_coordinator.PenIds)) { return; } @@ -407,6 +423,11 @@ private void RequeryAllPens() RequestWindowInForce(); } + private bool ShowsExactly(IEnumerable penIds) + { + return penIds.ToHashSet().SetEquals(_penSet.ById.Keys); + } + private void RequestWindowInForce() { if (Pens.Count > 0 && !IsWindowFetched(Navigation.From, Navigation.To, Navigation.ActiveLayer)) @@ -460,7 +481,11 @@ private void OnNavigationWindowChanged(object? sender, NavigationWindow window) _windowStart = window.From; _windowEnd = window.To; - if (window.RequiresHistoryRequery && !IsWindowFetched(window.From, window.To, window.Layer)) + if (window.RequiresHistoryRequery && IsWindowFetched(window.From, window.To, window.Layer)) + { + _historyDebouncer.RequestNothing(); + } + else if (window.RequiresHistoryRequery) { RequestHistory(window.From, window.To, window.Layer); @@ -480,7 +505,8 @@ private void OnNavigationWindowChanged(object? sender, NavigationWindow window) private bool IsWindowFetched(DateTime fromUtc, DateTime toUtc, AggregationLayer layer) { return _lastFetch is { } fetched - && HistoryPrefetch.Covers(fetched, fromUtc, toUtc, layer, Navigation.TargetColumnCount); + && HistoryPrefetch.Covers( + fetched, fromUtc, toUtc, layer, Navigation.TargetColumnCount, Navigation.FirstSample); } private bool MayApplyAxisModel(DateTime fromUtc, DateTime toUtc) @@ -499,21 +525,23 @@ private void RequestHistory(DateTime fromUtc, DateTime toUtc, AggregationLayer l HistoryPrefetch.ScaleColumnTarget(_reportedColumnTarget))); } - private Task>> QueryHistoryAsync(HistoryRequest request) + private Task>> QueryHistoryAsync( + HistoryRequest request, + CancellationToken cancellationToken) { return _coordinator.QueryHistoryAsync( request.PenIds, request.FromUtc, request.ToUtc, request.Layer, - request.TargetColumnCount); + request.TargetColumnCount, + cancellationToken); } + // docs/architecture/data-integration.md#what-one-history-query-covers private void ApplyHistory(HistoryRequest request, IReadOnlyList envelopes) { - // The gate opens on the range that came back, never on the one last asked for: only a result that - // landed says what the envelopes in hand cover. - _lastFetch = request.Range; + _lastFetch = null; foreach (var envelope in envelopes) { @@ -535,6 +563,8 @@ private void ApplyHistory(HistoryRequest request, IReadOnlyList> QueryArchiveExtentAsync(); } @@ -78,6 +79,9 @@ Contract points every implementation keeps: (`DA-1`). - An inverted window, a target column count below one, or a pen identifier outside the archive's 32-bit range is a failed `Result` with a plain message. +- A history read whose `cancellationToken` is cancelled ends in `OperationCanceledException`, never in + a failed `Result`. The chart treats only that exception, while its own token is cancelled, as its + own cancellation. ## The pen catalogue editor @@ -235,28 +239,99 @@ asks for three times the column target, so the fetched range stays at one column edge is clamped to the archive's first sample; the right edge is not, because a range reaching past the live edge is a successful empty read. A new window is drawn from the last fetch while it stays inside the inner band, half a window width in from each fetched edge, so a pan issues no query until -the margin is half spent. A zoom, a layer change and a column-target change always re-fetch, -because each changes the resolution the range was read at. +the margin is half spent. A range that reaches the first sample has no left edge to approach, because +no row lies before it: `HistoryPrefetch.Covers` takes `Navigation.FirstSample` and holds the left side +of such a range whatever margin the clamp left. A window that starts before the first sample is +therefore read once and drawn from that read; an archive younger than the window opens on one, and a +zoom out past the archive followed by a jump to now leaves one. A first sample that moves back, after +a catalogue read adds a pen with older rows, reopens the question: +`TrendChartViewModel.WidenToArchiveExtent` asks `Covers` again with the new first sample, and a range +clamped at the old one no longer holds its left side, so the window in view is read once more from the +new first sample. A zoom, a layer change and a column-target change always re-fetch, because each +changes the resolution the range was read at. Two column counts travel on one request, and the gate reads only one of them. `HistoryRequest.Range` carries the quantized count `ChartNavigationController.TargetColumnCount` holds, which a deadband keeps still while the Y tick labels widen and narrow the data rect by a pixel mid-gesture; `HistoryRequest.TargetColumnCount` carries the unquantized pixel width the provider decimates to. The range travels on the request so the result alone says which band the envelopes in hand describe: -`TrendChartViewModel` opens the gate on the range that came back, never on the one last asked for. +`TrendChartViewModel` opens the gate on the range that came back, never on the one last asked for, and +only when that read carried every pen the chart shows. A read issued before the catalogue added a pen +holds no row of it, so its result leaves the gate shut and the window in view is asked for again with +the pens shown. A gesture that never goes quiet still fetches. `ChartHistoryRequestDebouncer` merges the trailing -`Throttle` of 150 ms with a `Sample` of 400 ms over the same requests and drops the request whose -window the last applied result already covers, so a continuous drag issues one query per 400 ms and -one more after it stops. A read that is still in flight or that came back failed covers nothing, so -the same window asked for again reaches the archive. - -One query runs at a time, and the newest request that arrived while it ran runs when it lands. A read -slower than the cap interval therefore still completes during the gesture, the server sees one -history query per chart, no window is read for nothing, and the last window a gesture asked for is -the last one applied. The window waiting behind a query is dropped when that query turns out to have -applied it. The provider takes no `CancellationToken`, and this shape needs none: the query in flight -is never abandoned. +`Throttle` of 150 ms with a `Sample` of 400 ms over the same requests, so a continuous drag issues one +query per 400 ms and one more after it stops. + +The chart alone knows what is drawn. It asks for a window only while its fetched range does not cover +the window in view, and otherwise pushes `RequestNothing`. The debouncer keeps no drawn window of its +own. It stamps each request with the number of successful reads delivered so far, and drops a request +the last successful read answers: the same pens, window and column target, pushed before that read was +delivered. That is how the two heads emitting one push, and a drag that stops on the window being +read, issue one query. A request pushed after that read was delivered is the chart asking again, and it +reaches the archive. + +A read that is still in flight, that came back failed, or whose apply threw covers nothing, so the +chart asks for the same window again. `TrendChartViewModel.ApplyHistory` clears its fetched range +before it loads the envelopes and sets it only after, so an apply that throws part way leaves the gate +shut and the window in view is asked for again at the next move. A catalogue read that adds or removes +a pen drops envelopes outside that apply, so it clears the fetched range and asks for the window in +view with every pen shown. A pen removed and named again takes back its old place in the pen order, so +that request can name the same pens and window as the read applied before the removal; it is pushed +after that read was delivered, so it is read. + +One query runs at a time, and the newest request that arrived while it ran runs when it lands. No +request waits when the newest push needs no read, and the window waiting behind a query is dropped +when that query's read answers it. The two heads treat the query in flight differently: + +- A paced request never cancels it. A read slower than the cap interval therefore still completes + during the gesture and lands, so the strip fills while the drag moves. +- A trailing request is the gesture's end. When it finds a query running for another window, it + cancels that query, so the final window starts as soon as the cancelled one completes instead of + waiting up to the 300 s command timeout behind a read for a window the gesture has left. The + cancelled query completes once the server acknowledges the cancel, or when Npgsql breaks the socket + after `CancellationTimeout`. + +A gesture can end back inside the band the envelopes in hand cover, after a read for another band +started. A pan inside the fetched band then pushes `RequestNothing`: it reads nothing, drops the +request waiting behind the running query, and as the gesture's end it cancels the read for the band +the drag left. The gate is open only on a read of the pens shown, so that push never cancels a read +issued for a pen the catalogue added since. A read that failed changes nothing on screen and leaves the +gate as it was, so a gesture whose read failed still ends on the window drawn before it; an apply that +throws after the envelopes load leaves the gate open on them, with the same effect. A read that lands +while the drag is still moving is applied, and the window in view is re-requested from the range that +arrived. + +"Trailing" means 150 ms without a request, not the release of a mouse button. Input that arrives in +steps more than 150 ms apart, such as wheel notches, key presses or a window resize, ends a gesture +at every step, so each step cancels the read the previous one started, and no step's read lands until +the input pauses for one whole read. A read on a local archive completes well inside that gap; the +trade-off is that a slow read shows the final window sooner and the intermediate ones not at all. + +`Dispose` cancels the running query as well. A cancelled query reports nothing and starts the request +waiting behind it; the last window a gesture asked for is the last one applied. Each query runs under its own `CancellationTokenSource`, which the debouncer +creates under its gate and cancels with `CancelAsync`: the flag flips at once, and the callbacks run +on the thread pool. Npgsql's callback opens a second connection to send the cancel request, bounded +by the 15 s connect timeout, and blocks until the server closes it; run under the gate and Rx's own +locks, it would freeze the UI thread's next request and the window's close for that long. The source +is never disposed: it holds no timer and no wait handle, and disposing it before the queued callbacks +run drops them, so the provider would never hear of the cancel. Only a cancellation the debouncer +asked for is silent: an `OperationCanceledException` while its token is still live is a failed read +like any other. + +The token is the trailing `cancellationToken` of `IDataProvider.QueryHistoryAsync`, threaded through +`TrendCoordinator.QueryHistoryAsync`. `PostgresDataProvider` passes it to the connection open, to +every `ExecuteReaderAsync` and to every row read of the window, the bucketed Raw window and the fresh +tail, and the mapper rethrows the `OperationCanceledException` it ends in, so a cancelled read never +becomes a failed `Result`. `PostgresHistoryReadTests.AReadCancelledMidStatementThrowsInsteadOfFailing` +cancels a Raw and a Minute read held on a lock of `trends`. A cancelled command costs one extra +connection for the cancel request, and its own connection for at most two seconds more: Npgsql first +sends PostgreSQL the cancel request, and if no answer arrives within `CancellationTimeout` (2000 ms by +default, which the connection string keeps) it breaks the socket. A broken connection leaves the pool, +and the pool opens a new one. The backend behind a broken socket can keep running the abandoned +statement until it notices the dead connection, so for that stretch the server runs two history +queries for one chart. A failed read reaches the chart. `ChartHistoryRequestDebouncer` delivers the `Result`'s errors on the UI scheduler, a thrown query joining the same channel as an `ExceptionalError`; @@ -598,9 +673,11 @@ panel through `ResultReporting.ReportFailure`; a failed refresh reports and leav were. The window's code-behind handlers are `async void` with the whole body in `try/catch`, and the `catch` reaches the panel through `PenEditorViewModel.ReportFailure`, which takes the guarded form. -`57014` maps unconditionally to `QueryTimedOut`: no member of `IDataProvider` or `IPenCatalogueEditor` -takes a `CancellationToken`, so no statement is cancelled by a caller, and a caller's own cancellation -raises `OperationCanceledException`, which the mapper rethrows. +`57014` maps unconditionally to `QueryTimedOut`. The one statement a caller cancels is a history read +whose token the chart cancelled, and Npgsql raises `OperationCanceledException` for a cancellation it +requested, which the mapper rethrows; a `57014` that reaches the mapper is therefore the server's own +timeout or an administrator's cancel. No other member of `IDataProvider` or `IPenCatalogueEditor` +takes a `CancellationToken`. ## Configuration diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index 8bd5eb1..099bdcb 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -618,17 +618,21 @@ required: `FromAsync` without one completes an apply that awaited on the thread queued behind it would start there. Each apply takes four steps: 1. When the chart has no pens and the delta adds some, it reads the extent through - `MinimapViewModel.LoadExtentAsync` and seeds `Navigation.SeedFromArchiveExtent` with a successful - one before anything else. The navigation latches on the first data it sees, and a history envelope - would otherwise latch the first sample of a one-hour window, so the operator could not reach the - archive's first day. + `MinimapViewModel.LoadExtentAsync` and, before anything else, hands a successful one to + `Navigation.SeedFromArchiveExtent` and then to `TrendChartViewModel.WidenToArchiveExtent`. The + navigation latches on the first data it sees, and a history envelope would otherwise latch the first + sample of a one-hour window, so the operator could not reach the archive's first day. A chart that + had pens and lost them all latched long ago, so the seed changes nothing there, and the widen is what + moves its pan floor back to the new pen's older rows. 2. `TrendChartViewModel.ApplyCatalogue(delta.Current)` (`charting.md#applying-a-catalogue-read`). 3. `TrendLegendViewModel.Rebuild()`. 4. When the delta added a pen to a chart that already had some, `LoadExtentAsync` again, because the - extent read at start does not know the new pen. A successful read whose first sample is earlier - than the navigation's goes to `Navigation.WidenToArchiveExtent`, which moves the pan floor back and - leaves the window: the navigation latches its first sample once, so the minimap would otherwise draw - rows the chart cannot pan to. + extent read at start does not know the new pen. A successful read goes to + `TrendChartViewModel.WidenToArchiveExtent`. When its first sample is earlier than the navigation's, + the pan floor moves back and the window stays: the navigation latches its first sample once, so the + minimap would otherwise draw rows the chart cannot pan to. The chart then asks for the window in view + again unless the range in hand covers it with the new floor. The read step 2 issued for the new pen + can land before the extent read, clamped at the old floor, and nothing else checks it again. The body is one `try/catch`, and a throw goes to the message panel through `TrendChartViewModel.ReportFailure`, so the next delta still applies. A throw ahead of the end of step 3 diff --git a/docs/architecture/trend-interaction.md b/docs/architecture/trend-interaction.md index effcda9..cbb6635 100644 --- a/docs/architecture/trend-interaction.md +++ b/docs/architecture/trend-interaction.md @@ -320,9 +320,10 @@ and fast navigation across long archives. - The strip reads the extent at start (`App.StartExtentLoad`), before a catalogue delta gives an empty chart its first pens, and after a delta adds a pen to a chart that already has some (`overview.md#what-a-read-changes`). `LoadExtentAsync` returns the read once the strip has applied - it, so the empty-chart caller seeds the navigation from that same read, and the caller after a delta - that adds a pen widens the navigation's first sample to an earlier one in that read - (`ChartNavigationController.WidenToArchiveExtent`), so the chart pans back as far as the strip draws. + it. Both callers widen the navigation's first sample to an earlier one in that read + (`TrendChartViewModel.WidenToArchiveExtent`), so the chart pans back as far as the strip draws and + reads the rows a moved floor brings into the window in view; the empty-chart caller seeds the + navigation from the same read first. - `Minimap/MinimapView` is a Canvas-based strip (not a second `AvaPlot`): a highlight border sized from `WindowStartFraction` / `WindowWidthFraction`. Press/drag converts pointer-X to a fraction → `NavigateToFraction`, which recenters the window via the **same** `ChartNavigationController` the diff --git a/docs/plans/20260929-window-per-process.md b/docs/plans/20260929-window-per-process.md index 9749a46..f0dd55d 100644 --- a/docs/plans/20260929-window-per-process.md +++ b/docs/plans/20260929-window-per-process.md @@ -382,9 +382,15 @@ completes and lands, which `data-integration.md:248-259` requires. A trailing ad end. When it finds a query running for a different request, it stores itself as `_pending` and cancels the running query. `Dispose` cancels the running query. -Each query runs under its own `CancellationTokenSource`, created, cancelled and disposed only under -`_gate`. A query that ends in `OperationCanceledException` completes as `Cancelled`: `CompleteQuery` -leaves `_lastApplied` as it was and starts the pending request, and `Deliver` reports nothing. +Each query runs under its own `CancellationTokenSource`, created under `_gate` and cancelled there with +`CancelAsync`, never disposed (⚠️ as built after review: Npgsql's cancel callback opens a connection +and blocks, so it runs on the pool, and a dispose ahead of the queued callbacks drops them). A query +that ends in `OperationCanceledException` while its own token is cancelled completes with a null +`Result` (see the ⚠️ note under Task 9): `CompleteQuery` starts the pending request, and `Deliver` +reports nothing. ⚠️ as built after review: when a gesture ends back inside the fetched band, +`TrendChartViewModel` pushes `RequestNothing`, which reads nothing, clears `_pending` and, as a trailing +admission, cancels the running query. `ApplyHistory` opens `_lastFetch` only when the request's pens +set-equal the pens shown, so that push never cancels a read issued for a pen the catalogue added since. The token is the trailing `CancellationToken cancellationToken = default` of `IDataProvider.QueryHistoryAsync`, `TrendCoordinator.QueryHistoryAsync` and the chart's adapter. @@ -392,14 +398,24 @@ The token is the trailing `CancellationToken cancellationToken = default` of `ReadWindowAsync`, `ReadBucketedWindowAsync` and `FillFreshTailAsync`. The mapper's rethrow (`ArchiveExceptionMapper.cs:26-29`) keeps a cancelled read out of the failure `Result`. -A cancelled command costs at most one connection for two seconds. Npgsql first sends PostgreSQL a cancel +A cancelled command costs one extra connection for the cancel request, bounded by the 15 s connect +timeout, and its own connection for at most two seconds more. Npgsql first sends PostgreSQL a cancel request. If no answer arrives within `CancellationTimeout`, it breaks the socket itself: 2000 ms by default (`Npgsql.xml` of package 10.0.3, lines 3684-3688), and the connection string leaves it at the default (`PostgresConnectionSettings.cs:34-44`). A broken connection leaves the pool, and the pool opens a new one. -**Applied mark.** `CompleteQuery` keeps writing `_lastApplied`. `Deliver`'s catch clears it under `_gate` -when it still equals the request whose apply threw, so the same window passes the head filter again. -`TrendChartViewModel.ApplyHistory` sets `_lastFetch` after the envelopes are loaded. +**Applied mark.** ⚠️ as built after review: `TrendChartViewModel._lastFetch` is the only record of the +window drawn. `ApplyHistory` clears it before it loads the envelopes and sets it after, and +`RequeryAllPens` clears it on a pen-set change. The debouncer keeps `_lastSucceeded`, the last successful +read with its ordinal, which only `CompleteQuery` writes, and stamps each push with the count of +successful reads `Deliver` has delivered, counted ahead of the apply. A push stamped before the last +successful read was delivered and matching it is dropped at admission and in `CompleteQuery`; any later +push is read. So the same window is read again after an apply that threw, and after a pen removed and +named again takes back its slot and asks for the request applied before the removal. ⚠️ as built after +review: `ApplyHistory` re-pushes the window in view whenever `Covers` says the read just applied does +not draw it, and that push is read, so `Covers` has to hold every window the range `Expand` fetched for +it draws. It takes the first sample and holds the left side of a range that reaches it, so a window +that starts before the first sample is read once instead of once per cap interval. **Column cap.** `EnvelopeLine` trims when the count passes `MaxColumns`, removing `MaxColumns / 10` columns in one `RemoveRange`. The list lives between 90 000 and 100 000 columns, and the shift runs once @@ -883,34 +899,96 @@ The rule after this task: - Modify: `SemiPlot/SemiPlot.UI/Chart/ChartHistoryRequestDebouncer.cs` - Modify: `SemiPlot/SemiPlot.Tests.Unit/UI/Bridge/FakeDataProvider.cs` - Modify: `SemiPlot/SemiPlot.Tests.Unit/UI/Chart/ChartHistoryRequestDebouncerTests.cs` +- Modify: `SemiPlot/SemiPlot.Tests.Unit/UI/Chart/TrendChartViewModelTests.cs` +- Modify: `SemiPlot/SemiPlot.Tests.Unit/UI/Bridge/TrendCoordinatorTests.cs` - Modify: `SemiPlot/SemiPlot.Tests.Integration/PostgresHistoryReadTests.cs` - Modify: `docs/architecture/data-integration.md` +- Modify: `docs/architecture/charting.md` -- [ ] thread the token as Technical Details states, from the interface to `ReadAsync` -- [ ] implement the debouncer's cancellation rule and the `Cancelled` completion -- [ ] `FakeDataProvider` records the last token and honours it -- [ ] write `APacedRequestNeverCancelsTheQueryInFlight`: a continuous drag over a 600 ms query delivers +- [x] thread the token as Technical Details states, from the interface to `ReadAsync` +- [x] implement the debouncer's cancellation rule and the `Cancelled` completion +- [x] `FakeDataProvider` records the last token and honours it +- [x] write `APacedRequestNeverCancelsTheQueryInFlight`: a continuous drag over a 600 ms query delivers results during the drag -- [ ] write `AGestureEndCancelsTheLeftBehindQuery`, `ACancelledQueryReportsNothing` and a test that +- [x] write `AGestureEndCancelsTheLeftBehindQuery`, `ACancelledQueryReportsNothing` and a test that disposal cancels the running read -- [ ] write an integration fact in `PostgresHistoryReadTests`: a history read with a cancelled token throws +- [x] write an integration fact in `PostgresHistoryReadTests`: a history read with a cancelled token throws `OperationCanceledException` and returns no failed `Result` -- [ ] `data-integration.md:248-259`, `:598`: the query in flight completes during a gesture and is +- [x] `data-integration.md:248-259`, `:598`: the query in flight completes during a gesture and is cancelled when the gesture ends on another window -- [ ] run both test projects - must pass before task 10 +- [x] ⚠️ as built: only an `OperationCanceledException` while the debouncer's own token is cancelled counts + as `Cancelled`; any other one is a failed read (`ACancellationTheDebouncerNeverAskedForIsReported`). + Two `TrendChartViewModelTests` cases pinned the held initial read landing ahead of a newer window; a + zoom or a width report now cancels it, so they assert the cancellation instead, and + `DisposingTheChart_CancelsTheHistoryReadInFlight` covers the chain from the chart to the provider. + `charting.md`'s debouncer bullet names the cancellation +- [x] ⚠️ review: the debouncer cancels with `CancelAsync` and disposes no token source + (`AGestureEndNeverWaitsForTheCancelToReachTheServer`, `DisposingNeverWaitsForTheCancelToReachTheServer`); + a gesture that ends on the drawn window cancels the read it left + (`AGestureEndingOnTheDrawnWindowCancelsTheReadItLeftAndReadsNothing`, + `AGestureEndingBackInsideTheFetchedBandCancelsTheReadItLeft`); the integration fact became + `AReadCancelledMidStatementThrowsInsteadOfFailing`, a Raw and a Minute read cancelled while a lock on + `trends` holds them on the server; stepwise input more than 150 ms apart cancels at every step, which + `data-integration.md` states as the trade-off +- [x] ⚠️ review: the gate opens only on a read of the pens shown, so the applied request a gesture pushes + leaves a new pen's read running (`AReadForAnOlderPenSetLeavesTheNewPensReadRunning`); a cancel that + faults after `Dispose` reports nothing +- [x] run both test projects - must pass before task 10 ### Task 10: Let a window whose apply threw be requested again **Files:** - Modify: `SemiPlot/SemiPlot.UI/Chart/ChartHistoryRequestDebouncer.cs` - Modify: `SemiPlot/SemiPlot.UI/Chart/TrendChartViewModel.cs` +- Modify: `SemiPlot/SemiPlot.UI/Chart/HistoryPrefetch.cs` - Modify: `SemiPlot/SemiPlot.Tests.Unit/UI/Chart/ChartHistoryRequestDebouncerTests.cs` +- Modify: `SemiPlot/SemiPlot.Tests.Unit/UI/Chart/HistoryPrefetchTests.cs` +- Create: `SemiPlot/SemiPlot.Tests.Unit/UI/Chart/ChartHistoryRequestCancellationTests.cs` +- Create: `SemiPlot/SemiPlot.Tests.Unit/UI/Chart/HistoryDebouncerTestBuilder.cs` +- Modify: `SemiPlot/SemiPlot.Tests.Unit/UI/Chart/TrendChartViewModelTests.cs` +- Modify: `SemiPlot/SemiPlot.UI/MainWindow/PenCatalogueApplier.cs` +- Modify: `SemiPlot/SemiPlot.Tests.Unit/UI/Chart/TrendChartCatalogueTests.cs` +- Modify: `SemiPlot/SemiPlot.Tests.Unit/UI/MainWindow/PenCatalogueApplierTests.cs` +- Modify: `docs/architecture/data-integration.md` +- Modify: `docs/architecture/charting.md` +- Modify: `docs/architecture/overview.md` +- Modify: `docs/architecture/trend-interaction.md` -- [ ] `Deliver`'s catch clears `_lastApplied` under `_gate` when it still equals the failed request; +- [x] `Deliver`'s catch clears `_lastApplied` under `_gate` when it still equals the failed request; `ApplyHistory` sets `_lastFetch` after the load -- [ ] write `AFailedApplyCanBeRequestedAgain`: `applyHistory` throws once, and the same request issued +- [x] write `AFailedApplyCanBeRequestedAgain`: `applyHistory` throws once, and the same request issued again is applied -- [ ] run the unit tests - must pass before task 11 +- [x] ⚠️ as built: `data-integration.md`'s "covers nothing" sentence names a read whose apply threw +- [x] ⚠️ review: `ApplyHistory` clears `_lastFetch` before the load, so a throw part way leaves no band + fetched (`AWindowWhoseApplyThrewIsReadAgainOnTheNextMove`, `AnApplyThatThrowsPartWayLeavesNoBandFetched`) +- [x] ⚠️ review: a failed read changes nothing drawn, so a gesture whose read failed still cancels the + read it left (`AGestureEndingOnTheDrawnWindowAfterAFailedReadCancelsTheReadItLeft`) +- [x] ⚠️ review: a pen removed and named again while the removal's read is held or after it failed is + read again (`APenThatComesBackWhileTheRemovalsReadIsHeldIsReadAgain`, + `APenThatComesBackAfterTheRemovalsReadFailedIsReadAgain`) +- [x] ⚠️ review: the view model is the one owner of the window drawn. `RequestAppliedWindow`, + `ForgetAppliedWindow` and `ForgetApplied` are gone: a gesture ending in the fetched band pushes + `RequestNothing`, and the debouncer drops only a push stamped before the read it matches was + delivered (`ARequestPushedWhileItsWindowIsReadReadsNothingOnceTheReadLands`, + `AWindowAskedForAgainAfterItsReadWasDeliveredIsReadAgain`), so an apply that throws after the load + no longer stops the gesture's end from cancelling the far read + (`AGestureEndingInsideABandWhoseApplyThrewAfterTheLoadCancelsTheReadItLeft`). The cancellation facts + moved to `ChartHistoryRequestCancellationTests`, both classes built through `HistoryDebouncerTestBuilder` +- [x] ⚠️ review: `HistoryPrefetch.Covers` takes the first sample and holds the left side of a range that + reaches it, so a window starting before the first sample is read once + (`Covers_AWindowStartingBeforeTheFirstSample_HoldsTheRangeFetchedForIt`, + `AnArchiveYoungerThanTheWindowIsReadOnce`, `AZoomOutPastTheArchiveFollowedByNowReadsTheWindowOnce`) +- [x] ⚠️ final review: two gaps present on master since the catalogue applier landed, fixed here because + they sit on the history read path. `TrendChartViewModel.WidenToArchiveExtent` moves the pan floor + and, when it moved, asks for the window in force through `Covers`, so the added pen's read that + landed before the extent read is read again from the new floor + (`ADeltaAddingAPenWithOlderRows_WhoseReadLandsBeforeTheExtent_ReadsTheOlderRowsInView`, + `AWidenAfterTheAddedPensReadLanded_ReadsTheWindowFromTheEarlierFirstSample`). The empty-chart branch + of `PenCatalogueApplier` widens after the seed, which a latched navigation ignores, so a chart that + lost every pen moves its floor back to the new pen's rows + (`ADeltaGivingAChartThatLostItsPensAPenWithOlderRows_ReadsAndPansBackToThem`). Each test ends idle + for ten minutes with no further read +- [x] run the unit tests - must pass before task 11 ### Group F: the render path and the legend (PR 6) @@ -1037,51 +1115,43 @@ The rule after this task: - branch: initial-scale - branch: another-instance - branch: live-theme +- branch: history-pipeline ## Verify it yourself -Group D (Task 8) only; Groups A-C shipped as #97, #98, #99 and #102, Groups E-G are later branches. +Group E (Tasks 9 and 10) only; Groups A-D shipped as #97, #98, #99, #102 and #103, Groups F-G are later +branches. 1. Build and tests, from the repository root: - `dotnet build SemiPlot.slnx` - 0 warnings, 0 errors. - - `dotnet test SemiPlot/SemiPlot.Tests.Unit/SemiPlot.Tests.Unit.csproj` - 1547 passed. - - `dotnet test SemiPlot/SemiPlot.Tests.Integration/SemiPlot.Tests.Integration.csproj` - 136 passed + - `dotnet test SemiPlot/SemiPlot.Tests.Unit/SemiPlot.Tests.Unit.csproj` - 1580 passed. + - `dotnet test SemiPlot/SemiPlot.Tests.Integration/SemiPlot.Tests.Integration.csproj` - 138 passed (needs Docker; do not run it at the same time as the unit suite). -2. The live theme, by test (`dotnet test SemiPlot/SemiPlot.Tests.Unit/SemiPlot.Tests.Unit.csproj --filter - "FullyQualifiedName~"`); none of them exists on `master`: - - `AppSectionWatcherTests.AThemeChangeOnDiskAppliesTheTheme`, `ALanguageChangeOnDiskAppliesNothing` and - `ABufferOverflowReloadsOnce` - the pipeline passes on a changed theme only, and a buffer overflow - reloads; - - `AppSectionWatcherTests.AStoppedWatcherIsReportedOnceAndItsWatchOpensAgain` and - `ARefusedOpenIsReportedOnceAndOpenedAgain` - an outage is reported once and the watch opens again; - - `AppSectionWatcherTests.AnAppFolderDeletedAndRecreatedIsWatchedAgain` - on Windows a deleted and - recreated `app/` applies its theme within one reopen interval; - - `AppSectionWatcherTests.AThemeSavedBeforeTheWatchStartsIsAppliedAfterOneQuietPeriod` - a save that - landed while the start ran its probe still applies; - - `AppSectionWatcherTests.AReplacedFileInARealFolderYieldsTheNewTheme` - a `File.Replace` into a real - folder reaches the pipeline; - - `AppConfigurationTests.AThemeSavedOnDiskReachesTheRunningApplication` and - `AThemeSavedOnDiskReachesAStartedApplication` - both starts apply a theme saved on disk; - - `AppConfigurationTests.ABrokenAppFileOnDiskReachesTheShownWindowsPanel` - a broken file reaches the - panel and the theme stays; - - `AppConfigurationTests.AnAppFolderThatCannotBeListed_StillStartsAndReachesThePanel` and - `AnAppFolderThatCannotBeListed_ReachesTheFailureWindowsPanel` - a folder the watcher cannot open is - reported on either start, and the start goes on; - - `SettingsViewModelTests.AThemeOnlySaveSetsNoRestartNotice` - a theme-only save shows no notice; - - `SettingsViewTests.TheRestartNotice_AfterASaveThatAlsoChangedTheTheme_SaysTheThemeIsApplied` - the - notice names the theme as applied; - - `ConfigurationSectionTests.AFileAnotherHandleHoldsOpenForWritingStillReads` and - `AFileTheSectionReadHoldsOpenCanStillBeReplaced` - a read and a replace never block each other; - - `ConfigurationSectionWriterTests.AFileAnotherSavesWriteProbeHoldsOpenIsStillCopied` and - `SettingsSaveTests.ASaveReplacesATargetAnotherSavesWriteProbeHoldsOpen` - two saves' handles never - block each other. -3. On the demo stand (`dotnet run --project SemiPlot/SemiPlot.AppHost`): - - File -> New window opens a second window. In one window, Edit -> Settings, switch only the theme and - save: both windows switch theme within 2 s and no restart notice appears. On `master` the notice - appears and the other window keeps its theme; - - with the dialog of the other window open, switch the theme back from the first window: the open - dialog's theme box follows the window; - - switch the language and save: the notice and "Restart now" appear, and the other window does not - change; - - a window opened through New window or Restart is not a stand resource: stopping the AppHost leaves - it running, so close it by hand. +2. The history pipeline, by test (`dotnet test --filter "FullyQualifiedName~"`); none of + them exists on `master`: + - `ChartHistoryRequestCancellationTests`: `APacedRequestNeverCancelsTheQueryInFlight`, + `AGestureEndCancelsTheLeftBehindQuery`, `ACancelledQueryReportsNothing`, + `AGestureEndNeverWaitsForTheCancelToReachTheServer`, `DisposingNeverWaitsForTheCancelToReachTheServer` + - a drag never cancels, its end cancels the read it left, a cancel reports nothing, and Npgsql's cancel + request never runs on the UI thread or under a lock; + - `ChartHistoryRequestDebouncerTests.AFailedApplyCanBeRequestedAgain` and + `TrendChartViewModelTests.AWindowWhoseApplyThrewIsReadAgainOnTheNextMove`, + `AnApplyThatThrowsPartWayLeavesNoBandFetched` - a window whose apply threw is read again; + - `TrendChartViewModelTests.AGestureEndingBackInsideTheFetchedBandCancelsTheReadItLeft`, + `AReadForAnOlderPenSetLeavesTheNewPensReadRunning`, `APenThatComesBackWhileTheRemovalsReadIsHeldIsReadAgain` + - the drawn window has one owner, per pen set; + - `TrendChartViewModelTests.AnArchiveYoungerThanTheWindowIsReadOnce`, + `AZoomOutPastTheArchiveFollowedByNowReadsTheWindowOnce` - an idle chart whose window starts before the + first sample reads once (on the branch before `70c98ef` it read every 200 ms); + - `PenCatalogueApplierTests.ADeltaAddingAPenWithOlderRows_WhoseReadLandsBeforeTheExtent_ReadsTheOlderRowsInView`, + `ADeltaGivingAChartThatLostItsPensAPenWithOlderRows_ReadsAndPansBackToThem` - two gaps present on + `master` (failing there), fixed here; + - integration: `PostgresHistoryReadTests.AReadCancelledMidStatementThrowsInsteadOfFailing` (Raw and + Minute) - a read cancelled while a lock holds it ends in `OperationCanceledException`, not a fault. +3. On the demo stand (`dotnet run --project SemiPlot/SemiPlot.AppHost`), acceptance step 7: + - drag the chart across a Raw window for about 3 s and release: the strip fills during the drag, and the + final window appears within one read of the release; no archive fault appears in the message panel; + - drag out of the drawn band and back into it, then release: the window stays drawn and does not flicker + to another band; + - leave the window idle for a minute: the log shows no repeated history reads; + - close the window while a long drag is in flight: it closes at once.