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
189 changes: 151 additions & 38 deletions Areas/Admin/Controllers/SettingsController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,17 @@ namespace Wayfarer.Areas.Admin.Controllers
[Area("Admin")]
public class SettingsController : BaseController
{
/// <summary>
/// SSE channel name for broadcasting tile cache purge progress to admin clients.
/// </summary>
public const string TileCachePurgeChannel = "admin-tile-cache-purge";

private readonly IApplicationSettingsService _settingsService;
private readonly TileCacheService _tileCacheService;
private readonly IProxiedImageCacheService _imageCacheService;
private readonly IWebHostEnvironment _env;
private readonly IServiceScopeFactory _scopeFactory;
private readonly SseService _sseService;

public SettingsController(
ILogger<BaseController> logger,
Expand All @@ -26,14 +32,16 @@ public SettingsController(
TileCacheService tileCacheService,
IProxiedImageCacheService imageCacheService,
IWebHostEnvironment env,
IServiceScopeFactory scopeFactory)
IServiceScopeFactory scopeFactory,
SseService sseService)
: base(logger, dbContext)
{
_settingsService = settingsService ?? throw new ArgumentNullException(nameof(settingsService));
_tileCacheService = tileCacheService ?? throw new ArgumentNullException(nameof(tileCacheService));
_imageCacheService = imageCacheService ?? throw new ArgumentNullException(nameof(imageCacheService));
_env = env ?? throw new ArgumentNullException(nameof(env));
_scopeFactory = scopeFactory ?? throw new ArgumentNullException(nameof(scopeFactory));
_sseService = sseService ?? throw new ArgumentNullException(nameof(sseService));
}

[HttpGet]
Expand Down Expand Up @@ -273,50 +281,134 @@ void Track<T>(string name, T oldVal, T newVal)
}
}

/// <summary>
/// Queues a full tile cache purge as a background operation.
/// Returns 202 Accepted immediately; progress is reported via SSE on
/// <see cref="TileCachePurgeChannel"/>.
/// Returns 409 Conflict if a purge is already running.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> DeleteAllMapTileCache()
public IActionResult DeleteAllMapTileCache()
{
try
{
await _tileCacheService.PurgeAllCacheAsync();

var cacheStatus = await GetCacheStatus();
if (TileCacheService.IsPurgeInProgress)
return Conflict(new { success = false, message = "A cache purge is already in progress." });

return Ok(new
{
success = true,
message = "The map tile cache has been deleted successfully.",
cacheStatus
});
}
catch (Exception e)
{
return Ok(new { success = false, message = e.Message });
}
QueuePurgeOperation("all");
return Accepted(new { success = true, message = "Full cache purge started." });
}

/// <summary>
/// Queues an LRU tile cache purge (zoom >= 9) as a background operation.
/// Returns 202 Accepted immediately; progress is reported via SSE on
/// <see cref="TileCachePurgeChannel"/>.
/// Returns 409 Conflict if a purge is already running.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> DeleteLruCache()
public IActionResult DeleteLruCache()
{
try
{
await _tileCacheService.PurgeLRUCacheAsync();
if (TileCacheService.IsPurgeInProgress)
return Conflict(new { success = false, message = "A cache purge is already in progress." });

var cacheStatus = await GetCacheStatus();
QueuePurgeOperation("lru");
return Accepted(new { success = true, message = "LRU cache purge started." });
}

return Ok(new
{
success = true,
message = "The map tile cache for zoom levels equal or greater of 9, has been deleted successfully.",
cacheStatus
});
}
catch (Exception e)
/// <summary>
/// SSE endpoint for receiving real-time tile cache purge progress events.
/// Admin clients connect here after initiating a purge or on page load
/// when a purge is already in progress.
/// </summary>
[HttpGet]
public async Task<IActionResult> TileCachePurgeSse(CancellationToken cancellationToken)
{
await _sseService.SubscribeAsync(
TileCachePurgeChannel,
Response,
cancellationToken,
enableHeartbeat: true,
heartbeatInterval: TimeSpan.FromSeconds(30));
return new EmptyResult();
}

/// <summary>
/// Returns whether a tile cache purge is currently in progress.
/// Used by the admin UI on page load to detect and reconnect to an ongoing purge.
/// </summary>
[HttpGet]
public IActionResult TileCachePurgeStatus()
{
return Ok(new { inProgress = TileCacheService.IsPurgeInProgress });
}

/// <summary>
/// Fires a cache purge in the background with SSE progress reporting.
/// The purge methods broadcast "started" after acquiring the guard, and this
/// method broadcasts "completed" or "failed" based on the outcome.
/// Uses the captured <see cref="_sseService"/> singleton directly instead of
/// re-resolving from a new DI scope.
/// </summary>
private void QueuePurgeOperation(string purgeType)
{
// Capture the singleton reference for the background task — avoids
// re-resolving the same singleton from a new DI scope.
var sseService = _sseService;

_ = Task.Run(async () =>
{
return Ok(new { success = false, message = e.Message });
}
try
{
using var scope = _scopeFactory.CreateScope();
var tileCacheService = scope.ServiceProvider.GetRequiredService<TileCacheService>();

// "started" is broadcast inside the purge methods after the
// CompareExchange guard succeeds — no dangling "started" on TOCTOU race.
if (purgeType == "lru")
await tileCacheService.PurgeLRUCacheAsync(sseService, TileCachePurgeChannel);
else
await tileCacheService.PurgeAllCacheAsync(sseService, TileCachePurgeChannel);

// Broadcast final cache status so the UI can update counters.
var cacheStatus = await BuildCacheStatusAsync(tileCacheService);
await sseService.BroadcastAsync(TileCachePurgeChannel,
System.Text.Json.JsonSerializer.Serialize(new
{
eventType = "completed",
purgeType,
message = purgeType == "lru"
? "LRU cache purge completed successfully."
: "Full cache purge completed successfully.",
cacheStatus
}));
}
catch (InvalidOperationException)
{
// Another purge won the CompareExchange race between the controller's
// IsPurgeInProgress check and the service's atomic guard. Safe to ignore —
// the winning request is already broadcasting progress. No "started" event
// was sent for the losing request (it's broadcast after the guard).
_logger.LogInformation("Background {PurgeType} purge skipped: concurrent purge is running.", purgeType);
}
catch (Exception ex)
{
_logger.LogError(ex, "Background {PurgeType} cache purge failed.", purgeType);
try
{
await sseService.BroadcastAsync(TileCachePurgeChannel,
System.Text.Json.JsonSerializer.Serialize(new
{
eventType = "failed",
purgeType,
errorMessage = ex.Message
}));
}
catch (Exception broadcastEx)
{
_logger.LogDebug(broadcastEx, "Failed to broadcast purge failure event.");
}
}
});
}

private class CacheStatus
Expand All @@ -329,14 +421,18 @@ private class CacheStatus
public double TotalLruGB { get; set; }
}

private async Task<CacheStatus> GetCacheStatus()
/// <summary>
/// Builds cache status from a <see cref="TileCacheService"/> instance.
/// Used by both the request-scoped path and the background purge task.
/// </summary>
private static async Task<CacheStatus> BuildCacheStatusAsync(TileCacheService tileCacheService)
{
var cacheStatus = new CacheStatus();
double total = await _tileCacheService.GetCacheFileSizeInMbAsync();
double lru = await _tileCacheService.GetLruCachedInMbFilesAsync();
double total = await tileCacheService.GetCacheFileSizeInMbAsync();
double lru = await tileCacheService.GetLruCachedInMbFilesAsync();

cacheStatus.TotalCacheFiles = await _tileCacheService.GetTotalCachedFilesAsync();
cacheStatus.LruTotalFiles = await _tileCacheService.GetLruTotalFilesInDbAsync();
cacheStatus.TotalCacheFiles = await tileCacheService.GetTotalCachedFilesAsync();
cacheStatus.LruTotalFiles = await tileCacheService.GetLruTotalFilesInDbAsync();
cacheStatus.TotalCacheSize = Math.Round(total, 2);
cacheStatus.TotalCacheSizeGB = Math.Round(total / 1024, 3);
cacheStatus.TotalLru = Math.Round(lru, 2);
Expand All @@ -345,6 +441,11 @@ private async Task<CacheStatus> GetCacheStatus()
return cacheStatus;
}

/// <summary>
/// Retrieves cache status using the request-scoped tile cache service.
/// </summary>
private Task<CacheStatus> GetCacheStatus() => BuildCacheStatusAsync(_tileCacheService);

/// <summary>
/// Normalizes and validates tile provider settings, applying presets when selected.
/// </summary>
Expand Down Expand Up @@ -423,9 +524,16 @@ private void SetTileProviderViewData()

/// <summary>
/// Purges the tile cache in the background to avoid blocking the settings update.
/// Skips if another purge is already in progress to prevent conflicts.
/// </summary>
private void QueueTileCachePurge()
{
if (TileCacheService.IsPurgeInProgress)
{
_logger.LogWarning("Skipping tile-provider-change purge: another purge is already in progress.");
return;
}

_ = Task.Run(async () =>
{
try
Expand All @@ -434,6 +542,11 @@ private void QueueTileCachePurge()
var tileCacheService = scope.ServiceProvider.GetRequiredService<TileCacheService>();
await tileCacheService.PurgeAllCacheAsync();
}
catch (InvalidOperationException)
{
// Another purge started between our check and execution — safe to ignore.
_logger.LogInformation("Tile-provider-change purge skipped: concurrent purge is running.");
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to purge tile cache in background.");
Expand Down
13 changes: 13 additions & 0 deletions Areas/Admin/Views/Settings/Index.cshtml
Original file line number Diff line number Diff line change
Expand Up @@ -734,6 +734,19 @@
</div>
</div>

@* ── Purge progress bar (hidden until a purge starts) ── *@
<div class="col-12">
<div id="cachePurgeProgress" class="mt-3" style="display:none;">
<div class="progress">
<div id="cachePurgeBar"
class="progress-bar progress-bar-striped progress-bar-animated"
role="progressbar" aria-valuenow="0" aria-valuemin="0"
aria-valuemax="100" style="width: 0%">0%</div>
</div>
<small id="cachePurgeText" class="text-muted">Starting purge...</small>
</div>
</div>

</div>

<hr/>
Expand Down
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,22 @@
# CHANGELOG

## [1.2.27] - 2026-03-27

### Fixed
- **HIGH:** LRU/full cache purge timed out on large caches (~500MB), showing error page despite successful deletion. Purge now runs in background with immediate HTTP 202 response (#207)
- Cache lock contention during purge blocked concurrent tile writes. Reduced file-delete chunk size from 100 to 10 with `Task.Yield()` between chunks to prevent writer starvation (#207)

### Added
- SSE-based real-time progress reporting for cache purge operations — admin UI shows animated progress bar with file count and percentage (#207)
- Atomic purge-in-progress guard (`Interlocked.CompareExchange`) prevents concurrent purge operations; second request returns 409 Conflict (#207)
- `TileCachePurgeSse` endpoint for SSE subscription and `TileCachePurgeStatus` endpoint for on-load reconnect (#207)
- On page load, admin settings UI checks purge status and reconnects SSE if a purge is mid-flight (#207)
- Tile-provider-change purge now respects the concurrency guard — skips gracefully if manual purge is running (#207)

### Changed
- `DeleteAllMapTileCache` and `DeleteLruCache` endpoints return HTTP 202 Accepted (was 200 with awaited result) (#207)
- `PurgeAllCacheAsync` and `PurgeLRUCacheAsync` accept optional `SseService`/channel params for progress broadcasting (#207)

## [1.2.26] - 2026-03-27

### Changed
Expand Down
Loading
Loading