perf(ui): prefetch nearby nests in the Database Open dialog

Browsing the saved-nest list was slow because arrowing to each row
downloaded that nest's archive from scratch. NestDetailsSession now
keeps a small LRU cache and exposes Prefetch(id); SavedNestsForm warms
the rows around the highlight once its own details are showing, so
arrowing through the page mostly resolves from cache instead of
re-downloading each nest.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Sonnet 5 committed 2026-10-07 13:13:25 -04:00
1 parent 91aef95b1b
commit 47140cc59e
3 files changed
+193 -3

No files matched your search

+81 -3
View File
@@ -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 <see cref="Prefetch"/>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).
/// </summary>
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<Guid, CancellationToken, Task<NestDetails>> _load;
private readonly Dictionary<Guid, NestDetails> _cache = new();
private readonly List<Guid> _cacheOrder = new();
private readonly Dictionary<Guid, CancellationTokenSource> _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
}
}
/// <summary>Forgets the current details and discards any load in flight (no nest highlighted).</summary>
/// <summary>
/// Starts loading one nest's details in the background so a later <see cref="LoadAsync"/>
/// 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.
/// </summary>
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);
}
/// <summary>Forgets the current details and discards any load or prefetch in flight.</summary>
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()
@@ -147,6 +147,74 @@ public class NestDetailsSessionTests
await Assert.ThrowsAsync<ObjectDisposedException>(() => session.LoadAsync(Guid.NewGuid()));
}
[Fact]
public async Task Prefetch_ThenLoad_ResolvesFromTheCacheWithoutASecondRequest()
{
var requested = new List<Guid>();
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<Guid>();
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);
}
/// <summary>Each load waits for its own result, which the test completes explicitly.</summary>
private sealed class ControlledLoads
{
+44
View File
@@ -22,6 +22,11 @@ namespace OpenNest.Forms;
/// </summary>
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
}
}
/// <summary>
/// Warms the cache for the rows around the highlighted one (<see cref="PrefetchWindow"/>
/// on each side) so arrowing to a nearby nest resolves from <see cref="NestDetailsSession"/>'s
/// cache instead of a fresh download. Only the current page's rows are candidates.
/// </summary>
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);
}
/// <summary>Downloads one nest's archive and reads its plates and drawings off the UI thread.</summary>
private async Task<NestDetails> LoadDetailsAsync(Guid id, CancellationToken cancellationToken)
{