Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions AsyncImageLoader.Avalonia.Tests/MemoryImageCacheTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,84 @@ public async Task SlidingExpirationIsRenewedByAccess() {
loads.Should().Be(1);
}

[Fact]
public async Task MaxItemsEvictsLeastRecentlyUsedUnleasedImage() {
using var cache = new MemoryImageCache(new MemoryImageCacheOptions { MaxItems = 2 });
var loads = 0;
var first = await cache.GetOrCreateAsync("first", _ => CreateImageAsync(() => ++loads));
var firstImage = first!.Image;
first!.Dispose();
var second = await cache.GetOrCreateAsync("second", _ => CreateImageAsync(() => ++loads));
var secondImage = second!.Image;
second.Should().NotBeNull();
second.Dispose();
var firstAgainBeforeEviction = await cache.GetOrCreateAsync("first", _ => CreateImageAsync(() => ++loads));
firstAgainBeforeEviction!.Image.Should().BeSameAs(firstImage);
firstAgainBeforeEviction.Dispose();
using var third = await cache.GetOrCreateAsync("third", _ => CreateImageAsync(() => ++loads));
third.Should().NotBeNull();
using var secondAgain = await cache.GetOrCreateAsync("second", _ => CreateImageAsync(() => ++loads));

secondAgain!.Image.Should().NotBeSameAs(secondImage);
loads.Should().Be(4);
}

[Fact]
public async Task MaxItemsDoesNotEvictImageWithActiveLease() {
using var cache = new MemoryImageCache(new MemoryImageCacheOptions { MaxItems = 1 });
var loads = 0;
using var first = await cache.GetOrCreateAsync("first", _ => CreateImageAsync());
var firstImage = first!.Image;
using var second = await cache.GetOrCreateAsync("second", _ => CreateImageAsync(() => ++loads));

second!.Image.Should().NotBeNull();
first.Image.Should().BeSameAs(firstImage);
first.Dispose();

using var third = await cache.GetOrCreateAsync("third", _ => CreateImageAsync(() => ++loads));
third!.Image.Should().NotBeSameAs(firstImage);
loads.Should().Be(2);
}

[Fact]
public async Task MaxItemsIgnoresDetachedEntriesLeftInEvictionQueue() {
using var cache = new MemoryImageCache(new MemoryImageCacheOptions { MaxItems = 1 });
var loads = 0;
var first = await cache.GetOrCreateAsync("first", _ => CreateImageAsync(() => ++loads));
first!.Dispose();
cache.Clear();

var second = await cache.GetOrCreateAsync("second", _ => CreateImageAsync(() => ++loads));
second!.Dispose();
using var third = await cache.GetOrCreateAsync("third", _ => CreateImageAsync(() => ++loads));
third.Should().NotBeNull();
using var secondAgain = await cache.GetOrCreateAsync("second", _ => CreateImageAsync(() => ++loads));

secondAgain.Should().NotBeNull();
loads.Should().Be(4);
}

[Fact]
public async Task MaxItemsKeepsCountCorrectAfterExpiredEntryIsRemoved() {
var timeProvider = new FakeTimeProvider();
using var cache = new MemoryImageCache(new MemoryImageCacheOptions {
MaxItems = 1,
AbsoluteExpiration = TimeSpan.FromMilliseconds(100)
}, timeProvider);
var loads = 0;
var first = await cache.GetOrCreateAsync("first", _ => CreateImageAsync(() => ++loads));
first!.Dispose();
timeProvider.Advance(TimeSpan.FromMilliseconds(100));

using var second = await cache.GetOrCreateAsync("second", _ => CreateImageAsync(() => ++loads));
second.Should().NotBeNull();
second.Dispose();
using var third = await cache.GetOrCreateAsync("third", _ => CreateImageAsync(() => ++loads));

third.Should().NotBeNull();
loads.Should().Be(3);
}

[Fact]
public async Task AbsoluteExpirationWinsOverSlidingExpiration() {
var timeProvider = new FakeTimeProvider();
Expand Down Expand Up @@ -300,6 +378,9 @@ public void InvalidExpirationIsRejected() {
Assert.Throws<ArgumentOutOfRangeException>(() => new MemoryImageCache(new MemoryImageCacheOptions {
SlidingExpiration = TimeSpan.Zero
}));
Assert.Throws<ArgumentOutOfRangeException>(() => new MemoryImageCache(new MemoryImageCacheOptions {
MaxItems = 0
}));
}

[Fact]
Expand Down
52 changes: 46 additions & 6 deletions AsyncImageLoader.Avalonia/Core/Caching/MemoryImageCache.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,12 @@ namespace AsyncImageLoader.Core.Caching;
public sealed class MemoryImageCache : IImageMemoryCache {
private readonly object _gate = new();
private readonly Dictionary<string, Entry> _entries = new(StringComparer.Ordinal);
private readonly PriorityQueue<(string Key, Entry Entry), long> _evictionQueue = new();
private readonly MemoryImageCacheOptions _options;
private readonly TimeProvider _timeProvider;
private readonly ITimer? _cleanupTimer;
private long _accessSequence;
private uint _loadedItemCount;
private bool _disposed;

/// <summary>
Expand Down Expand Up @@ -60,9 +63,9 @@ internal MemoryImageCache(MemoryImageCacheOptions? options, TimeProvider timePro
if (entry.LeaseCount == 0 && IsExpired(entry))
DetachEntryLocked(key, entry);
else {
Touch(entry);
Touch(key, entry);
entry.LeaseCount++;
return new MemoryImageLease(entry.Image, () => Release(entry));
return new MemoryImageLease(entry.Image, () => Release(key, entry));
}
}

Expand Down Expand Up @@ -92,6 +95,7 @@ internal MemoryImageCache(MemoryImageCacheOptions? options, TimeProvider timePro
catch {
lock (_gate) {
entry.WaiterCount--;
EnqueueForEvictionIfEligible(key, entry);
DisposeDetachedEntryIfUnused(entry);
}

Expand All @@ -111,7 +115,7 @@ internal MemoryImageCache(MemoryImageCacheOptions? options, TimeProvider timePro
}

entry.LeaseCount++;
return new MemoryImageLease(image, () => Release(entry));
return new MemoryImageLease(image, () => Release(key, entry));
}
}

Expand All @@ -129,6 +133,10 @@ private async Task LoadEntryAsync(
if (image is not null) {
entry.StrongSince = _timeProvider.GetUtcNow();
entry.LastAccess = entry.StrongSince;
entry.LastAccessSequence = ++_accessSequence;
_loadedItemCount++;
EnqueueForEvictionIfEligible(key, entry);
EnforceMaxItemsLocked();
}
else {
DetachEntryLocked(key, entry);
Expand Down Expand Up @@ -173,16 +181,41 @@ public void Dispose() {
}
}

private void Release(Entry entry) {
private void Release(string key, Entry entry) {
lock (_gate) {
if (entry.LeaseCount > 0)
entry.LeaseCount--;

if (entry.LeaseCount == 0 && (_disposed || entry.IsDetached || IsExpired(entry)))
DisposeDetachedEntryIfUnused(entry);

if (!_disposed) {
EnqueueForEvictionIfEligible(key, entry);
EnforceMaxItemsLocked();
}
}
}

private void EnforceMaxItemsLocked() {
if (_options.MaxItems is not { } maxItems)
return;

while (_loadedItemCount > maxItems && _evictionQueue.TryDequeue(out var candidate, out var sequence)) {
var entry = candidate.Entry;
if (entry.IsDetached || entry.Image is null ||
entry.LastAccessSequence != sequence || entry.LeaseCount != 0 || entry.WaiterCount != 0)
continue;

DetachEntryLocked(candidate.Key, entry);
}
}

private void EnqueueForEvictionIfEligible(string key, Entry entry) {
if (!entry.IsDetached && entry.Image is not null &&
entry.LeaseCount == 0 && entry.WaiterCount == 0)
_evictionQueue.Enqueue((key, entry), entry.LastAccessSequence);
}

private void CleanupExpiredEntries(object? state) {
lock (_gate) {
if (_disposed)
Expand All @@ -198,10 +231,12 @@ private void CleanupExpiredEntries(object? state) {
}
}

private void Touch(Entry entry) {
private void Touch(string key, Entry entry) {
var now = _timeProvider.GetUtcNow();
if (_options.SlidingExpiration is not null && !IsAbsoluteExpired(entry, now))
entry.LastAccess = now;
entry.LastAccessSequence = ++_accessSequence;
EnqueueForEvictionIfEligible(key, entry);
}

private bool IsExpired(Entry entry) {
Expand All @@ -224,7 +259,11 @@ private void DetachEntryLocked(string key, Entry entry) {
if (_entries.TryGetValue(key, out var current) && ReferenceEquals(current, entry))
_entries.Remove(key);

entry.IsDetached = true;
if (!entry.IsDetached) {
entry.IsDetached = true;
if (entry.Image is not null)
_loadedItemCount--;
}
DisposeDetachedEntryIfUnused(entry);
}

Expand Down Expand Up @@ -253,6 +292,7 @@ private sealed class Entry {
public int LeaseCount;
public DateTimeOffset StrongSince;
public DateTimeOffset LastAccess;
public long LastAccessSequence;
public bool IsDetached;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ public sealed class MemoryImageCacheOptions {
/// </summary>
public TimeSpan? SlidingExpiration { get; init; }

/// <summary>
/// Gets the maximum number of images retained by the cache.
/// </summary>
public uint? MaxItems { get; init; }

/// <summary>
/// Validates cache options.
/// </summary>
Expand All @@ -25,5 +30,8 @@ public void Validate() {

if (SlidingExpiration is { } sliding && sliding <= TimeSpan.Zero)
throw new ArgumentOutOfRangeException(nameof(SlidingExpiration));

if (MaxItems is 0)
throw new ArgumentOutOfRangeException(nameof(MaxItems));
}
}
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ If you need a brush you can use Avalonia's `ImageBrush` with `ImageBrushLoader.S
using AsyncImageLoader.Core;

var loader = ImageLoaderPipelineBuilder.RamCached(new MemoryImageCacheOptions {
MaxItems = 100,
AbsoluteExpiration = TimeSpan.FromMinutes(10),
SlidingExpiration = TimeSpan.FromMinutes(2)
})
Expand Down
Loading