Leases cache: manual refresh, staleness fallback, no duplicate reads

- ILeasesCache.Invalidate() so manual Refresh forces re-read; no watcher recreate
- GET api/leases?refresh=true; LeasesClient.GetLeasesAsync(forceRefresh); Dhcp Refresh button uses forceRefresh: true
- Staleness fallback: cache older than 2 min treated as dirty (fallback if FileSystemWatcher missed event)
- LeasesCache: _lastReadUtc, StaleCacheSeconds = 120
This commit is contained in:
2026-02-03 19:21:08 +10:00
parent a8ddcdec4e
commit d1268a5fab
6 changed files with 43 additions and 11 deletions
@@ -5,5 +5,6 @@ namespace DnsmasqWebUI.Client.Http.Abstractions;
/// <summary>Typed client for GET api/leases.</summary>
public interface ILeasesClient
{
Task<LeasesResult> GetLeasesAsync(CancellationToken ct = default);
/// <param name="forceRefresh">When true, invalidates the server cache so the next read is from disk (e.g. after manual Refresh).</param>
Task<LeasesResult> GetLeasesAsync(bool forceRefresh = false, CancellationToken ct = default);
}
+5 -2
View File
@@ -10,7 +10,10 @@ public sealed class LeasesClient : ILeasesClient
public LeasesClient(HttpClient http) => _http = http;
public async Task<LeasesResult> GetLeasesAsync(CancellationToken ct = default) =>
await _http.GetFromJsonAsync<LeasesResult>("api/leases", ct)
public async Task<LeasesResult> GetLeasesAsync(bool forceRefresh = false, CancellationToken ct = default)
{
var url = forceRefresh ? "api/leases?refresh=true" : "api/leases";
return await _http.GetFromJsonAsync<LeasesResult>(url, ct)
?? throw new InvalidOperationException("Unexpected null from api/leases.");
}
}
+4 -4
View File
@@ -252,7 +252,7 @@ else
{
<p class="text-muted">No leases yet. Leases appear here when DHCP clients obtain an address; use Refresh to update.</p>
<div class="d-flex flex-wrap align-items-center gap-2 mt-2">
<button class="btn btn-primary" @onclick="() => LoadLeases(showLoading: true)" disabled="@_leasesLoading">Refresh</button>
<button class="btn btn-primary" @onclick="() => LoadLeases(showLoading: true, forceRefresh: true)" disabled="@_leasesLoading">Refresh</button>
</div>
}
else
@@ -317,7 +317,7 @@ else
</div>
</div>
<div class="d-flex flex-wrap align-items-center gap-2 mt-2">
<button class="btn btn-primary" @onclick="() => LoadLeases(showLoading: true)" disabled="@_leasesLoading">Refresh</button>
<button class="btn btn-primary" @onclick="() => LoadLeases(showLoading: true, forceRefresh: true)" disabled="@_leasesLoading">Refresh</button>
</div>
}
<SettingsModal IsVisible="_settingsModalOpen" Title="@_settingsModalTitle" SettingsContext="_settingsModalContext" OnClose="CloseSettingsModal" />
@@ -461,13 +461,13 @@ else
await InvokeAsync(StateHasChanged);
}
private async Task LoadLeases(bool showLoading = true)
private async Task LoadLeases(bool showLoading = true, bool forceRefresh = false)
{
if (_status == null || !_status.LeasesPathConfigured) return;
if (showLoading) _leasesLoading = true;
try
{
var resp = await LeasesClient.GetLeasesAsync();
var resp = await LeasesClient.GetLeasesAsync(forceRefresh: forceRefresh);
_leases = resp.Entries;
}
finally
@@ -9,17 +9,21 @@ namespace DnsmasqWebUI.Controllers;
public class LeasesController : ControllerBase
{
private readonly ILeasesFileService _leasesService;
private readonly ILeasesCache _cache;
public LeasesController(ILeasesFileService leasesService)
public LeasesController(ILeasesFileService leasesService, ILeasesCache cache)
{
_leasesService = leasesService;
_cache = cache;
}
[HttpGet]
public async Task<ActionResult<LeasesResult>> Get(CancellationToken ct)
public async Task<ActionResult<LeasesResult>> Get([FromQuery] bool refresh, CancellationToken ct)
{
try
{
if (refresh)
_cache.Invalidate();
var (available, entries) = await _leasesService.TryReadAsync(ct);
if (!available)
return Ok(new LeasesResult(false, null, "Leases not configured."));
@@ -7,5 +7,8 @@ namespace DnsmasqWebUI.Services.Abstractions;
/// </summary>
public interface ILeasesCache : IApplicationSingleton
{
/// <summary>Forces the next <see cref="GetOrRefreshAsync"/> to re-read the file. Use for manual Refresh; no need to recreate the file watcher.</summary>
void Invalidate();
Task<(bool Available, IReadOnlyList<LeaseEntry>? Entries)> GetOrRefreshAsync(CancellationToken ct = default);
}
+23 -2
View File
@@ -7,14 +7,19 @@ namespace DnsmasqWebUI.Services;
/// <summary>
/// Singleton cache for the leases file with a <see cref="FileSystemWatcher"/> so we re-read only when the file changes.
/// Falls back to treating cache as dirty after <see cref="StaleCacheSeconds"/> so missed watcher events still get fresh data.
/// </summary>
public sealed class LeasesCache : ILeasesCache, IDisposable
{
/// <summary>After this many seconds, cached data is treated as stale and re-read on next request (fallback if FileSystemWatcher missed an event).</summary>
private const int StaleCacheSeconds = 120;
private readonly string? _path;
private readonly ILogger<LeasesCache> _logger;
private FileSystemWatcher? _watcher;
private readonly object _lock = new();
private (bool Available, IReadOnlyList<LeaseEntry>? Entries)? _cache;
private DateTime? _lastReadUtc;
private bool _dirty = true;
public LeasesCache(IDnsmasqConfigSetService configSetService, ILogger<LeasesCache> logger)
@@ -64,6 +69,14 @@ public sealed class LeasesCache : ILeasesCache, IDisposable
}
}
public void Invalidate()
{
lock (_lock)
{
_dirty = true;
}
}
public (bool Available, IReadOnlyList<LeaseEntry>? Entries) GetOrRefresh(CancellationToken ct = default)
{
if (string.IsNullOrEmpty(_path))
@@ -71,13 +84,19 @@ public sealed class LeasesCache : ILeasesCache, IDisposable
lock (_lock)
{
if (!_dirty && _cache.HasValue)
return _cache.Value;
if (!_dirty && _cache.HasValue && _lastReadUtc.HasValue)
{
var ageSeconds = (DateTime.UtcNow - _lastReadUtc.Value).TotalSeconds;
if (ageSeconds < StaleCacheSeconds)
return _cache.Value;
_dirty = true;
}
if (!File.Exists(_path))
{
_logger.LogDebug("Leases file not found: {Path}", _path);
_cache = (true, Array.Empty<LeaseEntry>());
_lastReadUtc = DateTime.UtcNow;
_dirty = false;
return _cache.Value;
}
@@ -92,6 +111,7 @@ public sealed class LeasesCache : ILeasesCache, IDisposable
entries.Add(entry);
}
_cache = (true, entries);
_lastReadUtc = DateTime.UtcNow;
_dirty = false;
return _cache.Value;
}
@@ -99,6 +119,7 @@ public sealed class LeasesCache : ILeasesCache, IDisposable
{
_logger.LogWarning(ex, "Failed to read leases file: {Path}", _path);
_cache = (true, null);
_lastReadUtc = DateTime.UtcNow;
_dirty = false;
return _cache.Value;
}