From d1268a5fab48b678fe36a0f4dad8c6560402ad9b Mon Sep 17 00:00:00 2001 From: Alex Hope-O'Connor Date: Tue, 3 Feb 2026 19:21:08 +1000 Subject: [PATCH] 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 --- .../Client/Http/Abstractions/ILeasesClient.cs | 3 ++- src/DnsmasqWebUI/Client/Http/LeasesClient.cs | 7 ++++-- src/DnsmasqWebUI/Components/Pages/Dhcp.razor | 8 +++--- .../Controllers/LeasesController.cs | 8 ++++-- .../Services/Abstractions/ILeasesCache.cs | 3 +++ src/DnsmasqWebUI/Services/LeasesCache.cs | 25 +++++++++++++++++-- 6 files changed, 43 insertions(+), 11 deletions(-) diff --git a/src/DnsmasqWebUI/Client/Http/Abstractions/ILeasesClient.cs b/src/DnsmasqWebUI/Client/Http/Abstractions/ILeasesClient.cs index 81b373e..6482daf 100644 --- a/src/DnsmasqWebUI/Client/Http/Abstractions/ILeasesClient.cs +++ b/src/DnsmasqWebUI/Client/Http/Abstractions/ILeasesClient.cs @@ -5,5 +5,6 @@ namespace DnsmasqWebUI.Client.Http.Abstractions; /// Typed client for GET api/leases. public interface ILeasesClient { - Task GetLeasesAsync(CancellationToken ct = default); + /// When true, invalidates the server cache so the next read is from disk (e.g. after manual Refresh). + Task GetLeasesAsync(bool forceRefresh = false, CancellationToken ct = default); } diff --git a/src/DnsmasqWebUI/Client/Http/LeasesClient.cs b/src/DnsmasqWebUI/Client/Http/LeasesClient.cs index 1ea45f9..1a7df25 100644 --- a/src/DnsmasqWebUI/Client/Http/LeasesClient.cs +++ b/src/DnsmasqWebUI/Client/Http/LeasesClient.cs @@ -10,7 +10,10 @@ public sealed class LeasesClient : ILeasesClient public LeasesClient(HttpClient http) => _http = http; - public async Task GetLeasesAsync(CancellationToken ct = default) => - await _http.GetFromJsonAsync("api/leases", ct) + public async Task GetLeasesAsync(bool forceRefresh = false, CancellationToken ct = default) + { + var url = forceRefresh ? "api/leases?refresh=true" : "api/leases"; + return await _http.GetFromJsonAsync(url, ct) ?? throw new InvalidOperationException("Unexpected null from api/leases."); + } } diff --git a/src/DnsmasqWebUI/Components/Pages/Dhcp.razor b/src/DnsmasqWebUI/Components/Pages/Dhcp.razor index 5bcb683..ffa20ca 100644 --- a/src/DnsmasqWebUI/Components/Pages/Dhcp.razor +++ b/src/DnsmasqWebUI/Components/Pages/Dhcp.razor @@ -252,7 +252,7 @@ else {

No leases yet. Leases appear here when DHCP clients obtain an address; use Refresh to update.

- +
} else @@ -317,7 +317,7 @@ else
- +
} @@ -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 diff --git a/src/DnsmasqWebUI/Controllers/LeasesController.cs b/src/DnsmasqWebUI/Controllers/LeasesController.cs index d86b03c..27e2f07 100644 --- a/src/DnsmasqWebUI/Controllers/LeasesController.cs +++ b/src/DnsmasqWebUI/Controllers/LeasesController.cs @@ -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> Get(CancellationToken ct) + public async Task> 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.")); diff --git a/src/DnsmasqWebUI/Services/Abstractions/ILeasesCache.cs b/src/DnsmasqWebUI/Services/Abstractions/ILeasesCache.cs index d39dabd..2c54f3e 100644 --- a/src/DnsmasqWebUI/Services/Abstractions/ILeasesCache.cs +++ b/src/DnsmasqWebUI/Services/Abstractions/ILeasesCache.cs @@ -7,5 +7,8 @@ namespace DnsmasqWebUI.Services.Abstractions; /// public interface ILeasesCache : IApplicationSingleton { + /// Forces the next to re-read the file. Use for manual Refresh; no need to recreate the file watcher. + void Invalidate(); + Task<(bool Available, IReadOnlyList? Entries)> GetOrRefreshAsync(CancellationToken ct = default); } diff --git a/src/DnsmasqWebUI/Services/LeasesCache.cs b/src/DnsmasqWebUI/Services/LeasesCache.cs index 85fa603..2545149 100644 --- a/src/DnsmasqWebUI/Services/LeasesCache.cs +++ b/src/DnsmasqWebUI/Services/LeasesCache.cs @@ -7,14 +7,19 @@ namespace DnsmasqWebUI.Services; /// /// Singleton cache for the leases file with a so we re-read only when the file changes. +/// Falls back to treating cache as dirty after so missed watcher events still get fresh data. /// public sealed class LeasesCache : ILeasesCache, IDisposable { + /// After this many seconds, cached data is treated as stale and re-read on next request (fallback if FileSystemWatcher missed an event). + private const int StaleCacheSeconds = 120; + private readonly string? _path; private readonly ILogger _logger; private FileSystemWatcher? _watcher; private readonly object _lock = new(); private (bool Available, IReadOnlyList? Entries)? _cache; + private DateTime? _lastReadUtc; private bool _dirty = true; public LeasesCache(IDnsmasqConfigSetService configSetService, ILogger logger) @@ -64,6 +69,14 @@ public sealed class LeasesCache : ILeasesCache, IDisposable } } + public void Invalidate() + { + lock (_lock) + { + _dirty = true; + } + } + public (bool Available, IReadOnlyList? 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()); + _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; }