diff --git a/OpenNest.Data/NestDetailsSession.cs b/OpenNest.Data/NestDetailsSession.cs index 27aed5f..f87f8ae 100644 --- a/OpenNest.Data/NestDetailsSession.cs +++ b/OpenNest.Data/NestDetailsSession.cs @@ -4,11 +4,20 @@ namespace OpenNest.Data; /// Loads the details of the nest highlighted in the Database-mode nest browser. Moving /// the highlight starts a new load and cancels the previous one; a superseded load's /// result or failure is discarded, so details never belong to a nest that is no longer -/// highlighted. Call from one thread (the UI thread). +/// highlighted. Also holds a small LRU cache of recently shown or ed +/// nests, so browsing back onto one, or onto one a caller warmed ahead of time, resolves +/// without a server round trip. Call from one thread (the UI thread). /// public sealed class NestDetailsSession : IDisposable { + // Bounds memory from cached plate/drawing geometry; generous enough for a caller's + // prefetch window plus some scrollback before the oldest entries are evicted. + private const int CacheCapacity = 25; + private readonly Func> _load; + private readonly Dictionary _cache = new(); + private readonly List _cacheOrder = new(); + private readonly Dictionary _prefetches = new(); private CancellationTokenSource? _pending; private int _generation; private bool _disposed; @@ -49,7 +58,18 @@ public sealed class NestDetailsSession : IDisposable try { - var details = await _load(id, cancellation.Token); + NestDetails details; + if (_cache.TryGetValue(id, out var cached)) + { + Touch(id); + details = cached; + } + else + { + details = await _load(id, cancellation.Token); + Remember(id, details); + } + if (generation != _generation) return false; @@ -70,7 +90,63 @@ public sealed class NestDetailsSession : IDisposable } } - /// Forgets the current details and discards any load in flight (no nest highlighted). + /// + /// Starts loading one nest's details in the background so a later + /// for it resolves from the cache instead of the server. Skipped when the nest is already + /// cached, already prefetching, or is the loaded/loading nest. Failures are swallowed here; + /// an on-demand load surfaces the error if the nest is actually selected later. + /// + public void Prefetch(Guid id) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (id == NestId || _cache.ContainsKey(id) || _prefetches.ContainsKey(id)) + return; + + var cancellation = new CancellationTokenSource(); + _prefetches[id] = cancellation; + _ = PrefetchCoreAsync(id, cancellation); + } + + private async Task PrefetchCoreAsync(Guid id, CancellationTokenSource cancellation) + { + try + { + var details = await _load(id, cancellation.Token); + if (!cancellation.IsCancellationRequested) + Remember(id, details); + } + catch + { + // Silent: this prefetch only mattered if the nest is later selected, and + // LoadAsync's own on-demand fetch will surface the error then. + } + finally + { + if (ReferenceEquals(_prefetches.GetValueOrDefault(id), cancellation)) + _prefetches.Remove(id); + cancellation.Dispose(); + } + } + + private void Remember(Guid id, NestDetails details) + { + _cache[id] = details; + Touch(id); + while (_cacheOrder.Count > CacheCapacity) + { + var oldest = _cacheOrder[0]; + _cacheOrder.RemoveAt(0); + _cache.Remove(oldest); + } + } + + private void Touch(Guid id) + { + _cacheOrder.Remove(id); + _cacheOrder.Add(id); + } + + /// Forgets the current details and discards any load or prefetch in flight. public void Clear() { if (_disposed) @@ -83,6 +159,8 @@ public sealed class NestDetailsSession : IDisposable Details = null; IsLoading = false; pending?.Cancel(); + foreach (var prefetch in _prefetches.Values) + prefetch.Cancel(); } private CancellationTokenSource Supersede() diff --git a/OpenNest.Tests/Data/NestDetailsSessionTests.cs b/OpenNest.Tests/Data/NestDetailsSessionTests.cs index 4c6c51c..935edb7 100644 --- a/OpenNest.Tests/Data/NestDetailsSessionTests.cs +++ b/OpenNest.Tests/Data/NestDetailsSessionTests.cs @@ -147,6 +147,74 @@ public class NestDetailsSessionTests await Assert.ThrowsAsync(() => session.LoadAsync(Guid.NewGuid())); } + [Fact] + public async Task Prefetch_ThenLoad_ResolvesFromTheCacheWithoutASecondRequest() + { + var requested = new List(); + var details = new NestDetails(); + using var session = new NestDetailsSession((id, _) => + { + requested.Add(id); + return Task.FromResult(details); + }); + var nestId = Guid.NewGuid(); + + session.Prefetch(nestId); + Assert.Equal(new[] { nestId }, requested); + + Assert.True(await session.LoadAsync(nestId)); + Assert.Same(details, session.Details); + Assert.Equal(new[] { nestId }, requested); + } + + [Fact] + public async Task Prefetch_SkipsWhenAlreadyTheCurrentNest_OrAlreadyCached() + { + var requested = new List(); + using var session = new NestDetailsSession((id, _) => + { + requested.Add(id); + return Task.FromResult(new NestDetails()); + }); + var nestId = Guid.NewGuid(); + + await session.LoadAsync(nestId); + Assert.Equal(new[] { nestId }, requested); + + // Already the current nest. + session.Prefetch(nestId); + Assert.Equal(new[] { nestId }, requested); + + // No longer current, but its earlier load is still cached. + session.Clear(); + session.Prefetch(nestId); + Assert.Equal(new[] { nestId }, requested); + } + + [Fact] + public async Task Clear_CancelsAnInFlightPrefetch_SoItsResultIsNeverCached() + { + var loads = new ControlledLoads(); + using var session = new NestDetailsSession(loads.Load); + var nestId = Guid.NewGuid(); + + session.Prefetch(nestId); + Assert.False(loads.Tokens[0].IsCancellationRequested); + + session.Clear(); + Assert.True(loads.Tokens[0].IsCancellationRequested); + loads.Results[0].SetResult(new NestDetails()); + + // Whether or not the cancelled prefetch's continuation has run yet, it must not have + // populated the cache, so loading the same nest issues a fresh request. + var fresh = new NestDetails(); + var load = session.LoadAsync(nestId); + Assert.Equal(2, loads.Results.Count); + loads.Results[1].SetResult(fresh); + Assert.True(await load); + Assert.Same(fresh, session.Details); + } + /// Each load waits for its own result, which the test completes explicitly. private sealed class ControlledLoads { diff --git a/OpenNest/Forms/SavedNestsForm.cs b/OpenNest/Forms/SavedNestsForm.cs index 9b08684..a58d7da 100644 --- a/OpenNest/Forms/SavedNestsForm.cs +++ b/OpenNest/Forms/SavedNestsForm.cs @@ -22,6 +22,11 @@ namespace OpenNest.Forms; /// public sealed class SavedNestsForm : Form { + // Rows this far before/after the highlight are warmed in the background once the + // highlighted nest's own details are showing, so arrowing through the page mostly + // hits the NestDetailsSession cache instead of downloading each nest on demand. + private const int PrefetchWindow = 5; + private const string IdColumn = "Id"; private const string PlateSizeColumn = "PlateSize"; private const string AreaColumn = "Area"; @@ -571,6 +576,7 @@ public sealed class SavedNestsForm : Form if (details.Details != null) { PopulateDetails(details.Details); + PrefetchNearby(id.Value); return; } @@ -590,7 +596,10 @@ public sealed class SavedNestsForm : Form try { if (await details.LoadAsync(id) && !IsDisposed) + { PopulateDetails(details.Details); + PrefetchNearby(id); + } } catch (Exception ex) { @@ -599,6 +608,41 @@ public sealed class SavedNestsForm : Form } } + /// + /// Warms the cache for the rows around the highlighted one ( + /// on each side) so arrowing to a nearby nest resolves from 's + /// cache instead of a fresh download. Only the current page's rows are candidates. + /// + private void PrefetchNearby(Guid id) + { + var index = -1; + for (var i = 0; i < nestGrid.Rows.Count; i++) + { + if (nestGrid.Rows[i].Cells[IdColumn].Value is Guid rowId && rowId == id) + { + index = i; + break; + } + } + + if (index < 0) + return; + + for (var offset = 1; offset <= PrefetchWindow; offset++) + { + PrefetchRow(index - offset); + PrefetchRow(index + offset); + } + } + + private void PrefetchRow(int index) + { + if (index < 0 || index >= nestGrid.Rows.Count) + return; + if (nestGrid.Rows[index].Cells[IdColumn].Value is Guid id) + details.Prefetch(id); + } + /// Downloads one nest's archive and reads its plates and drawings off the UI thread. private async Task LoadDetailsAsync(Guid id, CancellationToken cancellationToken) {