Fix: Improve cache stability and configuration consistency

Active Folder Exclusion Fixes:
- Exclude active cache folders in GetCachedFrameAsync, GetCachedFramesAsync, and GetAllCacheFoldersAsync
- Prevents TaskCanceledException when reading metadata from folders being written to
- Matches pattern used in GetCachedRadarAsync for consistency

Cache Configuration Fixes:
- Standardize default CacheExpirationMinutes to 12.5 (matches appsettings.json)
- Add cache configuration to ScrapingService (removes hardcoded values)
- Fix check interval calculation bug for intervals that don't divide 60 evenly
- Fix NextUpdateTime calculation to account for background check intervals
- Add configuration validation to all service constructors

All fixes verified - service running stably with 0 exceptions in recent logs
This commit is contained in:
2025-12-16 02:33:51 +10:00
parent ee5bffde88
commit d60706f625
5 changed files with 100 additions and 16 deletions
+29 -6
View File
@@ -30,8 +30,17 @@ public class BomRadarService : IBomRadarService, IDisposable
_scrapingService = scrapingService;
_debugService = debugService;
_configuration = configuration;
_cacheExpirationMinutes = configuration.GetValue<double>("CacheExpirationMinutes", 15.5);
_cacheExpirationMinutes = configuration.GetValue<double>("CacheExpirationMinutes", 12.5);
_cacheManagementCheckIntervalMinutes = configuration.GetValue<int>("CacheManagement:CheckIntervalMinutes", 5);
if (_cacheExpirationMinutes <= 0)
{
throw new ArgumentException("CacheExpirationMinutes must be greater than 0", nameof(configuration));
}
if (_cacheManagementCheckIntervalMinutes <= 0 || _cacheManagementCheckIntervalMinutes > 60)
{
throw new ArgumentException("CacheManagement:CheckIntervalMinutes must be between 1 and 60", nameof(configuration));
}
}
public async Task<RadarResponse?> GetCachedRadarAsync(string suburb, string state, CancellationToken cancellationToken = default)
@@ -275,11 +284,25 @@ public class BomRadarService : IBomRadarService, IDisposable
// Remove from active tracking once complete
_cacheService.ClearActiveCacheFolder(locationKey);
// Update result with cache state
result.IsUpdating = false;
result.CacheIsValid = true;
result.CacheExpiresAt = result.ObservationTime.AddMinutes(_cacheExpirationMinutes);
result.NextUpdateTime = result.CacheExpiresAt;
// Update result with cache state - rebuild response to get correct NextUpdateTime calculation
var cacheExpiresAt = result.ObservationTime.AddMinutes(_cacheExpirationMinutes);
var metadata = new LastUpdatedInfo
{
ObservationTime = result.ObservationTime,
ForecastTime = result.ForecastTime,
WeatherStation = result.WeatherStation,
Distance = result.Distance
};
result = ResponseBuilder.CreateRadarResponse(
cacheFolderPath: newCacheFolderPath,
frames: result.Frames,
metadata: metadata,
suburb: suburb,
state: state,
cacheIsValid: true,
cacheExpiresAt: cacheExpiresAt,
isUpdating: false,
cacheManagementCheckIntervalMinutes: _cacheManagementCheckIntervalMinutes);
return result;
}
+6
View File
@@ -25,6 +25,12 @@ public class CacheManagementService : BackgroundService
_browserService = browserService;
_configuration = configuration;
var checkIntervalMinutes = configuration.GetValue<int>("CacheManagement:CheckIntervalMinutes", 5);
if (checkIntervalMinutes <= 0 || checkIntervalMinutes > 60)
{
throw new ArgumentException("CacheManagement:CheckIntervalMinutes must be between 1 and 60", nameof(configuration));
}
_checkInterval = TimeSpan.FromMinutes(checkIntervalMinutes);
}
+22 -5
View File
@@ -19,7 +19,12 @@ public class CacheService : ICacheService
_logger = logger;
_configuration = configuration;
_cacheDirectory = FilePathHelper.GetCacheDirectory(configuration);
_cacheExpirationMinutes = configuration.GetValue<double>("CacheExpirationMinutes", 15.5);
_cacheExpirationMinutes = configuration.GetValue<double>("CacheExpirationMinutes", 12.5);
if (_cacheExpirationMinutes <= 0)
{
throw new ArgumentException("CacheExpirationMinutes must be greater than 0", nameof(configuration));
}
// Ensure cache directory exists
Directory.CreateDirectory(_cacheDirectory);
@@ -90,7 +95,10 @@ public class CacheService : ICacheService
string state,
CancellationToken cancellationToken = default)
{
var (cacheFolderPath, _) = await GetCachedScreenshotWithMetadataAsync(suburb, state, CachedDataType.Radar, null, cancellationToken);
// Exclude active cache folder (currently being written to) to avoid reading incomplete data
var locationKey = LocationHelper.GetLocationKey(suburb, state);
var excludeFolder = GetActiveCacheFolder(locationKey);
var (cacheFolderPath, _) = await GetCachedScreenshotWithMetadataAsync(suburb, state, CachedDataType.Radar, excludeFolder, cancellationToken);
if (string.IsNullOrEmpty(cacheFolderPath) || !Directory.Exists(cacheFolderPath))
{
@@ -322,6 +330,10 @@ public class CacheService : ICacheService
string state,
CancellationToken cancellationToken = default)
{
// Exclude active cache folder (currently being written to) to avoid reading incomplete data
var locationKey = LocationHelper.GetLocationKey(suburb, state);
var excludeFolder = GetActiveCacheFolder(locationKey);
var pattern = FilePathHelper.GetCacheFolderPattern(suburb, state);
var folders = Directory.GetDirectories(_cacheDirectory, pattern)
.Select(f =>
@@ -342,6 +354,13 @@ public class CacheService : ICacheService
if (!Directory.Exists(folder.Folder))
continue;
// Skip the folder if it's being excluded (currently being written to)
if (!string.IsNullOrEmpty(excludeFolder) && Path.GetFullPath(folder.Folder).Equals(Path.GetFullPath(excludeFolder), StringComparison.OrdinalIgnoreCase))
{
_logger.LogDebug("Skipping excluded cache folder (being written to): {Folder}", folder.Folder);
continue;
}
var metadata = await LoadMetadataAsync(folder.Folder, cancellationToken);
if (metadata == null)
continue;
@@ -621,9 +640,7 @@ public class CacheService : ICacheService
{
// Cache is invalid/missing - next update would be after background service check
status.Message = status.CacheExists ? "Cache is stale" : "No cache exists";
var now = DateTime.UtcNow;
var minutesUntilNextCheck = cacheManagementCheckIntervalMinutes - (now.Minute % cacheManagementCheckIntervalMinutes);
status.NextUpdateTime = now.AddMinutes(minutesUntilNextCheck);
status.NextUpdateTime = ResponseBuilder.CalculateNextServiceCheck(cacheManagementCheckIntervalMinutes);
}
return status;
+16 -3
View File
@@ -15,6 +15,8 @@ public class ScrapingService : IScrapingService
private readonly int _dynamicContentWaitMs;
private readonly int _tileRenderWaitMs;
private readonly ScreenshotCropConfig _cropConfig;
private readonly double _cacheExpirationMinutes;
private readonly int _cacheManagementCheckIntervalMinutes;
// Selector constants
private static readonly string[] SearchButtonSelectors = new[]
@@ -67,6 +69,18 @@ public class ScrapingService : IScrapingService
_logger.LogInformation("Screenshot crop config: X={X}, Y={Y}, RightOffset={RightOffset}, Height={Height}",
_cropConfig.X, _cropConfig.Y, _cropConfig.RightOffset, _cropConfig.Height);
_cacheExpirationMinutes = configuration.GetValue<double>("CacheExpirationMinutes", 12.5);
_cacheManagementCheckIntervalMinutes = configuration.GetValue<int>("CacheManagement:CheckIntervalMinutes", 5);
if (_cacheExpirationMinutes <= 0)
{
throw new ArgumentException("CacheExpirationMinutes must be greater than 0", nameof(configuration));
}
if (_cacheManagementCheckIntervalMinutes <= 0 || _cacheManagementCheckIntervalMinutes > 60)
{
throw new ArgumentException("CacheManagement:CheckIntervalMinutes must be between 1 and 60", nameof(configuration));
}
}
/// <summary>
@@ -544,9 +558,8 @@ public class ScrapingService : IScrapingService
await _cacheService.SaveFramesMetadataAsync(cacheFolderPath, CachedDataType.Radar, frames, cancellationToken);
// Step 23: Return response with all frames
// Note: ScrapingService doesn't have access to cache management check interval
// Default to 5 minutes (standard check interval)
return ResponseBuilder.CreateRadarResponse(cacheFolderPath, frames, lastUpdatedInfo, suburb, state, cacheIsValid: true, cacheExpiresAt: null, isUpdating: false, cacheManagementCheckIntervalMinutes: 5);
var cacheExpiresAt = lastUpdatedInfo.ObservationTime.AddMinutes(_cacheExpirationMinutes);
return ResponseBuilder.CreateRadarResponse(cacheFolderPath, frames, lastUpdatedInfo, suburb, state, cacheIsValid: true, cacheExpiresAt: cacheExpiresAt, isUpdating: false, cacheManagementCheckIntervalMinutes: _cacheManagementCheckIntervalMinutes);
}
catch (Exception ex)
{