Refactor cache validation and add startup cleanup

- Move cache folder completeness check to CacheHelper utility
- Replace Width with RightOffset in screenshot crop config
- Add startup cleanup to remove incomplete cache folders
- Validate cache folders before use in BomRadarService
This commit is contained in:
2025-12-07 14:35:38 +10:00
parent 18e0a7a01f
commit 27dd44aaa1
9 changed files with 196 additions and 34 deletions
+4 -3
View File
@@ -19,10 +19,11 @@ public class ScreenshotCropConfig
public int Y { get; set; } = 0; public int Y { get; set; } = 0;
/// <summary> /// <summary>
/// Width of the crop area in pixels. /// Right offset in pixels from the right edge of the map container.
/// If null, uses the remaining container width from X offset to the right edge. /// How much to cut off from the right side.
/// Default: 0 (no cut from right edge).
/// </summary> /// </summary>
public int? Width { get; set; } public int RightOffset { get; set; } = 0;
/// <summary> /// <summary>
/// Height of the crop area in pixels. /// Height of the crop area in pixels.
+9
View File
@@ -47,6 +47,15 @@ if (enableHttpsRedirection)
// No authorization required - service is designed to run behind a reverse proxy if auth is needed // No authorization required - service is designed to run behind a reverse proxy if auth is needed
app.MapControllers(); app.MapControllers();
// Cleanup incomplete cache folders from previous crashes/restarts before starting services
var cacheService = app.Services.GetRequiredService<ICacheService>();
var deletedCount = cacheService.CleanupIncompleteCacheFolders();
if (deletedCount > 0)
{
var logger = app.Services.GetRequiredService<ILogger<Program>>();
logger.LogInformation("Startup recovery: cleaned up {Count} incomplete cache folder(s) from previous session", deletedCount);
}
// Cleanup on shutdown // Cleanup on shutdown
app.Lifetime.ApplicationStopped.Register(() => app.Lifetime.ApplicationStopped.Register(() =>
{ {
+22 -2
View File
@@ -166,7 +166,7 @@ public class BomRadarService : IBomRadarService, IDisposable
// If there's an active update in progress, return existing cache (if available) with IsUpdating=true // If there's an active update in progress, return existing cache (if available) with IsUpdating=true
if (isUpdating) if (isUpdating)
{ {
if (!string.IsNullOrEmpty(cacheFolderPath) && Directory.Exists(cacheFolderPath)) if (!string.IsNullOrEmpty(cacheFolderPath) && Directory.Exists(cacheFolderPath) && CacheHelper.IsCacheFolderComplete(cacheFolderPath))
{ {
_logger.LogInformation("Cache update in progress for {Suburb}, {State}, returning existing cached data", suburb, state); _logger.LogInformation("Cache update in progress for {Suburb}, {State}, returning existing cached data", suburb, state);
var frames = await _cacheService.GetCachedFramesAsync(suburb, state, cancellationToken); var frames = await _cacheService.GetCachedFramesAsync(suburb, state, cancellationToken);
@@ -181,7 +181,7 @@ public class BomRadarService : IBomRadarService, IDisposable
} }
} }
if (!string.IsNullOrEmpty(cacheFolderPath) && Directory.Exists(cacheFolderPath) && cachedMetadata != null) if (!string.IsNullOrEmpty(cacheFolderPath) && Directory.Exists(cacheFolderPath) && cachedMetadata != null && CacheHelper.IsCacheFolderComplete(cacheFolderPath))
{ {
var isValid = _cacheService.IsCacheValid(cachedMetadata); var isValid = _cacheService.IsCacheValid(cachedMetadata);
if (isValid) if (isValid)
@@ -308,6 +308,26 @@ public class BomRadarService : IBomRadarService, IDisposable
} }
finally finally
{ {
// Always remove from active tracking, even on error
_activeCacheFolders.TryRemove(locationKey, out _);
// Clean up incomplete cache folder if error occurred
if (!string.IsNullOrEmpty(newCacheFolderPath) && Directory.Exists(newCacheFolderPath))
{
try
{
if (!CacheHelper.IsCacheFolderComplete(newCacheFolderPath))
{
Directory.Delete(newCacheFolderPath, recursive: true);
_logger.LogWarning("Cleaned up incomplete cache folder due to error: {Folder}", newCacheFolderPath);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to clean up incomplete cache folder: {Folder}", newCacheFolderPath);
}
}
// Clean up empty debug folder if we returned early without scraping // Clean up empty debug folder if we returned early without scraping
if (!string.IsNullOrEmpty(debugFolder) && Directory.Exists(debugFolder)) if (!string.IsNullOrEmpty(debugFolder) && Directory.Exists(debugFolder))
{ {
+56 -21
View File
@@ -61,32 +61,13 @@ public class CacheService : ICacheService
continue; continue;
} }
// Check if folder is complete: must have all 7 frames and metadata.json // Check if folder is complete: must have all 7 frames, metadata.json, and frames.json
var hasAllFrames = true; if (!CacheHelper.IsCacheFolderComplete(folder))
for (int i = 0; i < 7; i++)
{
var framePath = FilePathHelper.GetFrameFilePath(folder, i);
if (!File.Exists(framePath))
{
hasAllFrames = false;
break;
}
}
if (!hasAllFrames)
{ {
_logger.LogDebug("Skipping incomplete cache folder: {Folder}", folder); _logger.LogDebug("Skipping incomplete cache folder: {Folder}", folder);
continue; // Skip incomplete folders (currently being written to) continue; // Skip incomplete folders (currently being written to)
} }
// Check if metadata exists (indicates folder is complete)
var metadataPath = FilePathHelper.GetMetadataFilePath(folder);
if (!File.Exists(metadataPath))
{
_logger.LogDebug("Skipping cache folder without metadata: {Folder}", folder);
continue; // Skip folders without metadata
}
// This folder is complete // This folder is complete
var metadata = await LoadMetadataAsync(folder, cancellationToken); var metadata = await LoadMetadataAsync(folder, cancellationToken);
return (folder, metadata); return (folder, metadata);
@@ -347,5 +328,59 @@ public class CacheService : ICacheService
/// Gets the cache directory path /// Gets the cache directory path
/// </summary> /// </summary>
public string GetCacheDirectory() => _cacheDirectory; public string GetCacheDirectory() => _cacheDirectory;
/// <summary>
/// Cleans up incomplete cache folders from previous crashes or restarts
/// </summary>
public int CleanupIncompleteCacheFolders()
{
if (!Directory.Exists(_cacheDirectory))
{
return 0;
}
var deletedCount = 0;
try
{
// Get all cache folders (they match the pattern LocationKey_Timestamp)
var folders = Directory.GetDirectories(_cacheDirectory)
.Where(f => !Path.GetFileName(f).StartsWith("debug", StringComparison.OrdinalIgnoreCase)) // Exclude debug folder
.ToList();
foreach (var folder in folders)
{
try
{
// Check if folder is incomplete
if (!CacheHelper.IsCacheFolderComplete(folder))
{
_logger.LogInformation("Found incomplete cache folder from previous session, deleting: {Folder}", Path.GetFileName(folder));
Directory.Delete(folder, recursive: true);
deletedCount++;
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to check or delete incomplete cache folder: {Folder}", folder);
}
}
if (deletedCount > 0)
{
_logger.LogInformation("Startup cleanup completed: deleted {Count} incomplete cache folder(s)", deletedCount);
}
else
{
_logger.LogDebug("Startup cleanup: no incomplete cache folders found");
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error during startup cleanup of incomplete cache folders");
}
return deletedCount;
}
} }
+8
View File
@@ -99,5 +99,13 @@ public interface ICacheService
/// </summary> /// </summary>
/// <returns>The full path to the cache directory</returns> /// <returns>The full path to the cache directory</returns>
string GetCacheDirectory(); string GetCacheDirectory();
/// <summary>
/// Cleans up incomplete cache folders (e.g., from a previous crash or restart).
/// Scans all cache folders and deletes any that are incomplete (missing frames, metadata, etc.).
/// This should be called on application startup to recover from interrupted cache updates.
/// </summary>
/// <returns>The number of incomplete folders that were deleted</returns>
int CleanupIncompleteCacheFolders();
} }
+36 -7
View File
@@ -61,12 +61,12 @@ public class ScrapingService : IScrapingService
{ {
X = cropSection.GetValue<int>("X", 0), X = cropSection.GetValue<int>("X", 0),
Y = cropSection.GetValue<int>("Y", 0), Y = cropSection.GetValue<int>("Y", 0),
Width = cropSection.GetValue<int?>("Width"), RightOffset = cropSection.GetValue<int>("RightOffset", 0),
Height = cropSection.GetValue<int?>("Height") Height = cropSection.GetValue<int?>("Height")
}; };
_logger.LogInformation("Screenshot crop config: X={X}, Y={Y}, Width={Width}, Height={Height}", _logger.LogInformation("Screenshot crop config: X={X}, Y={Y}, RightOffset={RightOffset}, Height={Height}",
_cropConfig.X, _cropConfig.Y, _cropConfig.Width, _cropConfig.Height); _cropConfig.X, _cropConfig.Y, _cropConfig.RightOffset, _cropConfig.Height);
} }
/// <summary> /// <summary>
@@ -561,11 +561,11 @@ public class ScrapingService : IScrapingService
var x = containerClip.X + _cropConfig.X; var x = containerClip.X + _cropConfig.X;
var y = containerClip.Y + _cropConfig.Y; var y = containerClip.Y + _cropConfig.Y;
// Calculate width (use configured or remaining width from X to right edge) // Calculate width: container width minus left offset (X) minus right offset
var width = _cropConfig.Width ?? (containerClip.Width - _cropConfig.X); var width = Math.Max(0, containerClip.Width - _cropConfig.X - _cropConfig.RightOffset);
// Calculate height (use configured or remaining height) // Calculate height (use configured or remaining height)
var height = _cropConfig.Height ?? (containerClip.Height - _cropConfig.Y); var height = _cropConfig.Height ?? Math.Max(0, containerClip.Height - _cropConfig.Y);
// Validate bounds // Validate bounds
if (x < containerClip.X || y < containerClip.Y) if (x < containerClip.X || y < containerClip.Y)
@@ -695,7 +695,36 @@ public class ScrapingService : IScrapingService
/// </summary> /// </summary>
private async Task CaptureMapScreenshotAsync(IPage page, ILocator mapContainer, string outputPath, Clip containerClip) private async Task CaptureMapScreenshotAsync(IPage page, ILocator mapContainer, string outputPath, Clip containerClip)
{ {
var cropArea = CalculateCropArea(containerClip); Clip cropArea;
try
{
cropArea = CalculateCropArea(containerClip);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to calculate crop area, using full container bounds. Container: X={X}, Y={Y}, Width={Width}, Height={Height}",
containerClip.X, containerClip.Y, containerClip.Width, containerClip.Height);
// Fallback to full container if crop calculation fails
cropArea = containerClip;
}
// Validate crop area is within page bounds before attempting screenshot
var viewportSize = page.ViewportSize;
if (viewportSize == null || cropArea.X < 0 || cropArea.Y < 0 ||
cropArea.X + cropArea.Width > viewportSize.Width ||
cropArea.Y + cropArea.Height > viewportSize.Height)
{
_logger.LogWarning("Crop area is outside viewport bounds, using full container. Crop: X={X}, Y={Y}, Width={Width}, Height={Height}, Viewport: {ViewportWidth}x{ViewportHeight}",
cropArea.X, cropArea.Y, cropArea.Width, cropArea.Height, viewportSize?.Width ?? 0, viewportSize?.Height ?? 0);
cropArea = containerClip;
}
// Final validation - ensure dimensions are positive
if (cropArea.Width <= 0 || cropArea.Height <= 0)
{
_logger.LogError("Invalid crop dimensions: {Width}x{Height}, using full container", cropArea.Width, cropArea.Height);
cropArea = containerClip;
}
await page.ScreenshotAsync(new PageScreenshotOptions await page.ScreenshotAsync(new PageScreenshotOptions
{ {
+51
View File
@@ -0,0 +1,51 @@
namespace BomLocalService.Utilities;
/// <summary>
/// Utility methods for cache folder validation and operations.
/// </summary>
public static class CacheHelper
{
/// <summary>
/// Checks if a cache folder is complete (has all required files).
/// A complete cache folder must have:
/// - All 7 frame images (frame_0.png through frame_6.png)
/// - metadata.json file
/// - frames.json file
/// </summary>
/// <param name="cacheFolderPath">The path to the cache folder to check</param>
/// <returns>True if the folder is complete, false otherwise</returns>
public static bool IsCacheFolderComplete(string cacheFolderPath)
{
if (string.IsNullOrEmpty(cacheFolderPath) || !Directory.Exists(cacheFolderPath))
{
return false;
}
// Check for all 7 frames
for (int i = 0; i < 7; i++)
{
var framePath = FilePathHelper.GetFrameFilePath(cacheFolderPath, i);
if (!File.Exists(framePath))
{
return false;
}
}
// Check for metadata.json
var metadataPath = FilePathHelper.GetMetadataFilePath(cacheFolderPath);
if (!File.Exists(metadataPath))
{
return false;
}
// Check for frames.json
var framesMetadataPath = FilePathHelper.GetFramesMetadataFilePath(cacheFolderPath);
if (!File.Exists(framesMetadataPath))
{
return false;
}
return true;
}
}
+9
View File
@@ -88,5 +88,14 @@ public static class FilePathHelper
{ {
return Path.Combine(cacheFolderPath, ".writing"); return Path.Combine(cacheFolderPath, ".writing");
} }
/// <summary>
/// Gets the frames metadata file path within a cache folder.
/// Format: "{CacheFolderPath}/frames.json"
/// </summary>
public static string GetFramesMetadataFilePath(string cacheFolderPath)
{
return Path.Combine(cacheFolderPath, "frames.json");
}
} }
+1 -1
View File
@@ -24,7 +24,7 @@
"Crop": { "Crop": {
"X": 250, "X": 250,
"Y": 0, "Y": 0,
"Width": null, "RightOffset": 250,
"Height": null "Height": null
} }
}, },