mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-11 12:04:13 -04:00
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:
1 parent
91aef95b1b
commit
47140cc59e
3 files changed
+193
-3
No files matched your search
@@ -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
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user