feat(cache): sample metadata access for lru

This commit is contained in:
joshpatra committed 2026-07-26 21:21:29 -04:00
1 parent a535eb1f43
commit 0126357e21
3 files changed
+68

No files matched your search

@@ -43,6 +43,23 @@ public sealed class DatabaseApplicationCacheTests : IAsyncLifetime
Assert.Equal(6, entry.PayloadBytes);
}
[Fact]
public async Task ReadsFlushOneSampledAccessTimestamp()
{
await _cache.SetStringAsync("search:touched", "value", TimeSpan.FromHours(1));
_clock.UtcNow = _clock.UtcNow.AddMinutes(5);
Assert.Equal("value", await _cache.GetStringAsync("search:touched"));
Assert.Equal("value", await _cache.GetStringAsync("search:touched"));
Assert.Equal(1, await _cache.FlushAccessesAsync());
await using var database = await _factory.CreateDbContextAsync();
Assert.Equal(
_clock.UtcNow,
(await database.ApplicationCacheEntries.SingleAsync()).UpdatedAt);
Assert.Equal(0, await _cache.FlushAccessesAsync());
}
[Fact]
public async Task ExpiredEntry_IsAColdMissAndIsRemoved()
{
@@ -39,6 +39,7 @@ public sealed class BoundedHotApplicationCache : IApplicationCache, IDisposable
{
if (_memory.TryGetValue(key, out HotEntry? entry))
{
_database.RecordAccess(key);
Interlocked.Increment(ref _hits);
return entry?.Value;
}
@@ -160,6 +161,7 @@ public sealed class BoundedHotApplicationCache : IApplicationCache, IDisposable
{
_memory.Clear();
Interlocked.Add(ref _evictions, ClearResidents());
await _database.FlushAccessesAsync(cancellationToken: cancellationToken);
var deleted = await _database.CleanupExpiredAsync(
cancellationToken: cancellationToken);
deleted += await _database.CleanupInvalidOwnershipAsync(
@@ -1,5 +1,6 @@
using System.Text;
using System.Text.Json;
using System.Collections.Concurrent;
using allstarr.Core.Operations;
using allstarr.Core.Storage;
using allstarr.Models.Settings;
@@ -25,6 +26,7 @@ public sealed class DatabaseApplicationCache(
private long _misses;
private long _writes;
private long _evictions;
private readonly ConcurrentDictionary<string, byte> _accessed = new(StringComparer.Ordinal);
private readonly CacheSettings _settings = configuredSettings?.Value ?? new CacheSettings();
public bool IsEnabled => true;
@@ -137,6 +139,7 @@ public sealed class DatabaseApplicationCache(
if (entry.ExpiresAt is null || entry.ExpiresAt > clock.UtcNow)
{
RecordAccess(key);
Interlocked.Increment(ref _hits);
return entry.Value;
}
@@ -395,6 +398,49 @@ public sealed class DatabaseApplicationCache(
}
}
public void RecordAccess(string key)
{
if (IsValidKey(key))
{
_accessed[key] = 0;
}
}
public async Task<int> FlushAccessesAsync(
int batchSize = DefaultCleanupBatchSize,
CancellationToken cancellationToken = default)
{
var keys = _accessed.Keys
.Order(StringComparer.Ordinal)
.Take(Math.Clamp(batchSize, 1, DefaultCleanupBatchSize))
.ToArray();
if (keys.Length == 0)
{
return 0;
}
try
{
await using var database = await contextFactory.CreateDbContextAsync(cancellationToken);
var updated = await database.Set<ApplicationCacheEntryRecord>()
.Where(item => keys.Contains(item.Key))
.ExecuteUpdateAsync(
setters => setters.SetProperty(item => item.UpdatedAt, clock.UtcNow),
cancellationToken);
foreach (var key in keys)
{
_accessed.TryRemove(key, out _);
}
return updated;
}
catch (Exception exception)
{
logger.LogWarning(exception, "Database cache access flush failed");
return 0;
}
}
public async Task<DatabaseCacheMaintenancePreview> PreviewMaintenanceAsync(
int batchSize = DefaultCleanupBatchSize,
CancellationToken cancellationToken = default)
@@ -769,6 +815,9 @@ public sealed class DatabaseApplicationCacheCleanupService(
using var timer = new PeriodicTimer(CleanupInterval);
while (await timer.WaitForNextTickAsync(stoppingToken))
{
await cache.FlushAccessesAsync(
DatabaseApplicationCache.DefaultCleanupBatchSize,
stoppingToken);
var deleted = await cache.CleanupExpiredAsync(
DatabaseApplicationCache.DefaultCleanupBatchSize,
stoppingToken);