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.