Refactor configuration system and remove OpenAPI

- Remove OpenAPI/Swagger support and all related code
- Standardize configuration: remove hardcoded defaults, all defaults from appsettings.json
- Configuration values throw exceptions if missing (except Debug:Enabled defaults to false)
- Refactor CacheManagementService: extract ProcessLocationsAsync helper, remove duplicate code
- Combine UpdateStaggerSeconds and LocationStaggerSeconds into single LocationStaggerSeconds config
- Move config reads to constructors for better performance
- Update README and docker-compose.yml to reflect configuration changes
This commit is contained in:
2025-12-23 21:03:21 +10:00
parent 12dd0eedad
commit b30d76ddec
21 changed files with 265 additions and 147 deletions
-1
View File
@@ -7,7 +7,6 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.11" />
<PackageReference Include="Microsoft.Playwright" Version="1.57.0" /> <PackageReference Include="Microsoft.Playwright" Version="1.57.0" />
</ItemGroup> </ItemGroup>
+34 -5
View File
@@ -60,8 +60,19 @@ public class RadarController : ControllerBase
{ {
// Get cache status to include in 404 response // Get cache status to include in 404 response
// Use cache service directly since this is cache management, not radar-specific // Use cache service directly since this is cache management, not radar-specific
var cacheExpirationMinutes = (int)_configuration.GetValue<double>("CacheExpirationMinutes", 15.5); var cacheExpirationMinutesConfig = _configuration.GetValue<double?>("CacheExpirationMinutes");
var cacheManagementCheckIntervalMinutes = _configuration.GetValue<int>("CacheManagement:CheckIntervalMinutes", 5); if (!cacheExpirationMinutesConfig.HasValue)
{
throw new InvalidOperationException("CacheExpirationMinutes configuration is required. Set it in appsettings.json or via CACHEEXPIRATIONMINUTES environment variable.");
}
var cacheExpirationMinutes = (int)cacheExpirationMinutesConfig.Value;
var cacheManagementCheckIntervalMinutesConfig = _configuration.GetValue<int?>("CacheManagement:CheckIntervalMinutes");
if (!cacheManagementCheckIntervalMinutesConfig.HasValue)
{
throw new InvalidOperationException("CacheManagement:CheckIntervalMinutes configuration is required. Set it in appsettings.json or via CACHEMANAGEMENT__CHECKINTERVALMINUTES environment variable.");
}
var cacheManagementCheckIntervalMinutes = cacheManagementCheckIntervalMinutesConfig.Value;
var cacheStatus = await _cacheService.GetCacheStatusAsync( var cacheStatus = await _cacheService.GetCacheStatusAsync(
suburb, suburb,
state, state,
@@ -288,7 +299,14 @@ public class RadarController : ControllerBase
// Validate time range size to prevent excessive data loading // Validate time range size to prevent excessive data loading
// Default: base limit on cache retention, but allow override via config // Default: base limit on cache retention, but allow override via config
var cacheRetentionHours = _configuration.GetValue<int>("CacheRetentionHours", 24); var cacheRetentionHoursConfig = _configuration.GetValue<int?>("CacheRetentionHours");
if (!cacheRetentionHoursConfig.HasValue)
{
throw new InvalidOperationException("CacheRetentionHours configuration is required. Set it in appsettings.json or via CACHERETENTIONHOURS environment variable.");
}
var cacheRetentionHours = cacheRetentionHoursConfig.Value;
// TimeSeries:MaxTimeRangeHours is optional (nullable)
var configuredMaxHours = _configuration.GetValue<int?>("TimeSeries:MaxTimeRangeHours"); var configuredMaxHours = _configuration.GetValue<int?>("TimeSeries:MaxTimeRangeHours");
// Use configured value if set, otherwise use cache retention (with minimum of 24 hours) // Use configured value if set, otherwise use cache retention (with minimum of 24 hours)
@@ -331,8 +349,19 @@ public class RadarController : ControllerBase
}); });
// Get cache status to include in 404 response // Get cache status to include in 404 response
var cacheExpirationMinutes = (int)_configuration.GetValue<double>("CacheExpirationMinutes", 15.5); var cacheExpirationMinutesConfig = _configuration.GetValue<double?>("CacheExpirationMinutes");
var cacheManagementCheckIntervalMinutes = _configuration.GetValue<int>("CacheManagement:CheckIntervalMinutes", 5); if (!cacheExpirationMinutesConfig.HasValue)
{
throw new InvalidOperationException("CacheExpirationMinutes configuration is required. Set it in appsettings.json or via CACHEEXPIRATIONMINUTES environment variable.");
}
var cacheExpirationMinutes = (int)cacheExpirationMinutesConfig.Value;
var cacheManagementCheckIntervalMinutesConfig = _configuration.GetValue<int?>("CacheManagement:CheckIntervalMinutes");
if (!cacheManagementCheckIntervalMinutesConfig.HasValue)
{
throw new InvalidOperationException("CacheManagement:CheckIntervalMinutes configuration is required. Set it in appsettings.json or via CACHEMANAGEMENT__CHECKINTERVALMINUTES environment variable.");
}
var cacheManagementCheckIntervalMinutes = cacheManagementCheckIntervalMinutesConfig.Value;
var cacheStatus = await _cacheService.GetCacheStatusAsync( var cacheStatus = await _cacheService.GetCacheStatusAsync(
suburb, suburb,
state, state,
+15 -40
View File
@@ -14,39 +14,25 @@ var builder = WebApplication.CreateBuilder(args);
// AddControllersWithViews includes AddControllers, so we use it for both MVC and API controllers // AddControllersWithViews includes AddControllers, so we use it for both MVC and API controllers
builder.Services.AddControllersWithViews(); builder.Services.AddControllersWithViews();
builder.Services.AddEndpointsApiExplorer(); builder.Services.AddEndpointsApiExplorer();
builder.Services.AddOpenApi();
builder.Services.AddHealthChecks(); builder.Services.AddHealthChecks();
// Configure CORS - MUST be added before other services // Configure CORS - MUST be added before other services
var corsOrigins = builder.Configuration.GetValue<string>("Cors:AllowedOrigins", "*"); // Configuration values come from appsettings.json (defaults) and can be overridden via environment variables
var corsMethods = builder.Configuration.GetValue<string>("Cors:AllowedMethods", "GET,POST,OPTIONS"); // Environment variables use double underscore for nested keys (e.g., CORS__ALLOWEDORIGINS)
var corsHeaders = builder.Configuration.GetValue<string>("Cors:AllowedHeaders", "*"); var corsOrigins = builder.Configuration.GetValue<string>("Cors:AllowedOrigins")
var corsAllowCredentials = builder.Configuration.GetValue<bool>("Cors:AllowCredentials", false); ?? throw new InvalidOperationException("Cors:AllowedOrigins configuration is required. Set it in appsettings.json or via CORS__ALLOWEDORIGINS environment variable.");
var corsMethods = builder.Configuration.GetValue<string>("Cors:AllowedMethods")
?? throw new InvalidOperationException("Cors:AllowedMethods configuration is required. Set it in appsettings.json or via CORS__ALLOWEDMETHODS environment variable.");
var corsHeaders = builder.Configuration.GetValue<string>("Cors:AllowedHeaders")
?? throw new InvalidOperationException("Cors:AllowedHeaders configuration is required. Set it in appsettings.json or via CORS__ALLOWEDHEADERS environment variable.");
// Support environment variable override (comma-separated for multiple origins) // For bool, check if the key exists in configuration (GetValue<bool> returns false if not found, which is ambiguous)
var corsOriginsEnv = Environment.GetEnvironmentVariable("CORS__ALLOWEDORIGINS"); var corsAllowCredentialsKey = builder.Configuration["Cors:AllowCredentials"];
if (!string.IsNullOrEmpty(corsOriginsEnv)) if (corsAllowCredentialsKey == null)
{ {
corsOrigins = corsOriginsEnv; throw new InvalidOperationException("Cors:AllowCredentials configuration is required. Set it in appsettings.json or via CORS__ALLOWCREDENTIALS environment variable.");
}
var corsMethodsEnv = Environment.GetEnvironmentVariable("CORS__ALLOWEDMETHODS");
if (!string.IsNullOrEmpty(corsMethodsEnv))
{
corsMethods = corsMethodsEnv;
}
var corsHeadersEnv = Environment.GetEnvironmentVariable("CORS__ALLOWEDHEADERS");
if (!string.IsNullOrEmpty(corsHeadersEnv))
{
corsHeaders = corsHeadersEnv;
}
var corsAllowCredentialsEnv = Environment.GetEnvironmentVariable("CORS__ALLOWCREDENTIALS");
if (!string.IsNullOrEmpty(corsAllowCredentialsEnv) && bool.TryParse(corsAllowCredentialsEnv, out var parsedCredentials))
{
corsAllowCredentials = parsedCredentials;
} }
var corsAllowCredentials = builder.Configuration.GetValue<bool>("Cors:AllowCredentials");
builder.Services.AddCors(options => builder.Services.AddCors(options =>
{ {
@@ -126,23 +112,12 @@ builder.Services.AddSingleton<IBomRadarService, BomLocalService.Services.BomRada
builder.Services.AddHostedService<CacheCleanupService>(); builder.Services.AddHostedService<CacheCleanupService>();
builder.Services.AddHostedService<CacheManagementService>(); builder.Services.AddHostedService<CacheManagementService>();
// Add configuration - appsettings.json provides default values
// All values can be overridden via environment variables
builder.Configuration.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true);
var app = builder.Build(); var app = builder.Build();
// Configure the HTTP request pipeline // Configure the HTTP request pipeline
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
// HTTPS redirection is optional and disabled by default for Docker flexibility // HTTPS redirection is optional and disabled by default for Docker flexibility
// Users can enable it by setting ENABLE_HTTPS_REDIRECTION=true environment variable // Users can enable it by setting EnableHttpsRedirection=true in appsettings.json or ENABLEHTTPSREDIRECTION=true environment variable
// or EnableHttpsRedirection=true in appsettings.json var enableHttpsRedirection = builder.Configuration.GetValue<bool>("EnableHttpsRedirection", false);
var enableHttpsRedirection = builder.Configuration.GetValue<bool>("EnableHttpsRedirection", false) ||
Environment.GetEnvironmentVariable("ENABLE_HTTPS_REDIRECTION")?.Equals("true", StringComparison.OrdinalIgnoreCase) == true;
if (enableHttpsRedirection) if (enableHttpsRedirection)
{ {
app.UseHttpsRedirection(); app.UseHttpsRedirection();
+2 -10
View File
@@ -218,7 +218,7 @@ All configuration can be done via environment variables, which override the defa
|----------|-------------|---------| |----------|-------------|---------|
| `ASPNETCORE_ENVIRONMENT` | Runtime environment (Development/Production) | `Production` | | `ASPNETCORE_ENVIRONMENT` | Runtime environment (Development/Production) | `Production` |
| `ASPNETCORE_URLS` | URLs the service listens on | `http://+:8080` | | `ASPNETCORE_URLS` | URLs the service listens on | `http://+:8080` |
| `ENABLE_HTTPS_REDIRECTION` | Enable HTTPS redirection | `false` | | `ENABLEHTTPSREDIRECTION` | Enable HTTPS redirection | `false` |
#### Application Configuration #### Application Configuration
@@ -235,8 +235,7 @@ All configuration can be done via environment variables, which override the defa
|----------|-------------|---------|---------| |----------|-------------|---------|---------|
| `CACHEMANAGEMENT__CHECKINTERVALMINUTES` | Interval between cache validity checks | `5` | `10` | | `CACHEMANAGEMENT__CHECKINTERVALMINUTES` | Interval between cache validity checks | `5` | `10` |
| `CACHEMANAGEMENT__INITIALDELAYSECONDS` | Delay before first cache check on startup | `10` | `30` | | `CACHEMANAGEMENT__INITIALDELAYSECONDS` | Delay before first cache check on startup | `10` | `30` |
| `CACHEMANAGEMENT__UPDATESTAGGERSECONDS` | Delay between triggering cache updates | `2` | `5` | | `CACHEMANAGEMENT__LOCATIONSTAGGERSECONDS` | Delay between processing different locations (used for both initial and periodic updates) | `1` | `2` |
| `CACHEMANAGEMENT__LOCATIONSTAGGERSECONDS` | Delay between processing different locations | `1` | `2` |
#### Cache Cleanup #### Cache Cleanup
@@ -880,13 +879,6 @@ DELETE /api/cache/{suburb}/{state}
} }
``` ```
### OpenAPI Documentation
When running in Development mode, OpenAPI documentation is available at:
```
http://localhost:8082/openapi/v1.json
```
## Cache Update Estimation ## Cache Update Estimation
The service uses a **metrics-based estimation system** to provide accurate estimates of cache update completion times. This ensures clients receive meaningful `nextUpdateTime` values that adapt to the actual hardware performance. The service uses a **metrics-based estimation system** to provide accurate estimates of cache update completion times. This ensures clients receive meaningful `nextUpdateTime` values that adapt to the actual hardware performance.
+21 -3
View File
@@ -32,9 +32,27 @@ public class BomRadarService : IBomRadarService, IDisposable
_scrapingService = scrapingService; _scrapingService = scrapingService;
_debugService = debugService; _debugService = debugService;
_configuration = configuration; _configuration = configuration;
_cacheExpirationMinutes = configuration.GetValue<double>("CacheExpirationMinutes", 12.5);
_cacheManagementCheckIntervalMinutes = configuration.GetValue<int>("CacheManagement:CheckIntervalMinutes", 5); var cacheExpirationMinutesConfig = configuration.GetValue<double?>("CacheExpirationMinutes");
_timeSeriesWarningFolderCount = configuration.GetValue<int>("TimeSeries:WarningFolderCount", 200); if (!cacheExpirationMinutesConfig.HasValue)
{
throw new InvalidOperationException("CacheExpirationMinutes configuration is required. Set it in appsettings.json or via CACHEEXPIRATIONMINUTES environment variable.");
}
_cacheExpirationMinutes = cacheExpirationMinutesConfig.Value;
var cacheManagementCheckIntervalMinutesConfig = configuration.GetValue<int?>("CacheManagement:CheckIntervalMinutes");
if (!cacheManagementCheckIntervalMinutesConfig.HasValue)
{
throw new InvalidOperationException("CacheManagement:CheckIntervalMinutes configuration is required. Set it in appsettings.json or via CACHEMANAGEMENT__CHECKINTERVALMINUTES environment variable.");
}
_cacheManagementCheckIntervalMinutes = cacheManagementCheckIntervalMinutesConfig.Value;
var timeSeriesWarningFolderCountConfig = configuration.GetValue<int?>("TimeSeries:WarningFolderCount");
if (!timeSeriesWarningFolderCountConfig.HasValue)
{
throw new InvalidOperationException("TimeSeries:WarningFolderCount configuration is required. Set it in appsettings.json or via TIMESERIES__WARNINGFOLDERCOUNT environment variable.");
}
_timeSeriesWarningFolderCount = timeSeriesWarningFolderCountConfig.Value;
// Calculate estimated cache update duration // Calculate estimated cache update duration
_estimatedUpdateDurationSeconds = CacheHelper.GetEstimatedUpdateDurationSeconds(configuration, CachedDataType.Radar); _estimatedUpdateDurationSeconds = CacheHelper.GetEstimatedUpdateDurationSeconds(configuration, CachedDataType.Radar);
+9 -1
View File
@@ -15,7 +15,15 @@ public class BrowserService : IBrowserService
public BrowserService(ILogger<BrowserService> logger, IConfiguration configuration, IDebugService debugService) public BrowserService(ILogger<BrowserService> logger, IConfiguration configuration, IDebugService debugService)
{ {
_logger = logger; _logger = logger;
_timezone = configuration.GetValue<string>("Timezone") ?? "Australia/Brisbane"; // Get timezone from configuration (default from appsettings.json, can be overridden via TIMEZONE environment variable)
var timezone = configuration.GetValue<string>("Timezone");
if (string.IsNullOrEmpty(timezone))
{
throw new InvalidOperationException("Timezone configuration is required. Set it in appsettings.json or via TIMEZONE environment variable.");
}
_timezone = timezone;
_debugService = debugService; _debugService = debugService;
} }
+14 -2
View File
@@ -14,8 +14,20 @@ public class CacheCleanupService : BackgroundService
{ {
_logger = logger; _logger = logger;
_cacheDirectory = FilePathHelper.GetCacheDirectory(configuration); _cacheDirectory = FilePathHelper.GetCacheDirectory(configuration);
_retentionHours = configuration.GetValue<int>("CacheRetentionHours", 24);
var cleanupIntervalHours = configuration.GetValue<int>("CacheCleanup:IntervalHours", 1); var retentionHoursConfig = configuration.GetValue<int?>("CacheRetentionHours");
if (!retentionHoursConfig.HasValue)
{
throw new InvalidOperationException("CacheRetentionHours configuration is required. Set it in appsettings.json or via CACHERETENTIONHOURS environment variable.");
}
_retentionHours = retentionHoursConfig.Value;
var cleanupIntervalHoursConfig = configuration.GetValue<int?>("CacheCleanup:IntervalHours");
if (!cleanupIntervalHoursConfig.HasValue)
{
throw new InvalidOperationException("CacheCleanup:IntervalHours configuration is required. Set it in appsettings.json or via CACHECLEANUP__INTERVALHOURS environment variable.");
}
var cleanupIntervalHours = cleanupIntervalHoursConfig.Value;
_cleanupInterval = TimeSpan.FromHours(cleanupIntervalHours); _cleanupInterval = TimeSpan.FromHours(cleanupIntervalHours);
} }
+59 -48
View File
@@ -11,6 +11,8 @@ public class CacheManagementService : BackgroundService
private readonly IBrowserService _browserService; private readonly IBrowserService _browserService;
private readonly IConfiguration _configuration; private readonly IConfiguration _configuration;
private readonly TimeSpan _checkInterval; private readonly TimeSpan _checkInterval;
private readonly TimeSpan _locationStaggerInterval;
private readonly TimeSpan _initialDelayInterval;
private readonly HashSet<string> _activeUpdates = new(); private readonly HashSet<string> _activeUpdates = new();
private readonly object _lock = new(); private readonly object _lock = new();
@@ -24,14 +26,34 @@ public class CacheManagementService : BackgroundService
_bomRadarService = bomRadarService; _bomRadarService = bomRadarService;
_browserService = browserService; _browserService = browserService;
_configuration = configuration; _configuration = configuration;
var checkIntervalMinutes = configuration.GetValue<int>("CacheManagement:CheckIntervalMinutes", 5);
var checkIntervalMinutesConfig = configuration.GetValue<int?>("CacheManagement:CheckIntervalMinutes");
if (!checkIntervalMinutesConfig.HasValue)
{
throw new InvalidOperationException("CacheManagement:CheckIntervalMinutes configuration is required. Set it in appsettings.json or via CACHEMANAGEMENT__CHECKINTERVALMINUTES environment variable.");
}
var checkIntervalMinutes = checkIntervalMinutesConfig.Value;
if (checkIntervalMinutes <= 0 || checkIntervalMinutes > 60) if (checkIntervalMinutes <= 0 || checkIntervalMinutes > 60)
{ {
throw new ArgumentException("CacheManagement:CheckIntervalMinutes must be between 1 and 60", nameof(configuration)); throw new ArgumentException("CacheManagement:CheckIntervalMinutes must be between 1 and 60", nameof(configuration));
} }
_checkInterval = TimeSpan.FromMinutes(checkIntervalMinutes); _checkInterval = TimeSpan.FromMinutes(checkIntervalMinutes);
// LocationStaggerSeconds is used for delays between processing locations (both initial and periodic)
var locationStaggerSecondsConfig = configuration.GetValue<int?>("CacheManagement:LocationStaggerSeconds");
if (!locationStaggerSecondsConfig.HasValue)
{
throw new InvalidOperationException("CacheManagement:LocationStaggerSeconds configuration is required. Set it in appsettings.json or via CACHEMANAGEMENT__LOCATIONSTAGGERSECONDS environment variable.");
}
_locationStaggerInterval = TimeSpan.FromSeconds(locationStaggerSecondsConfig.Value);
var initialDelaySecondsConfig = configuration.GetValue<int?>("CacheManagement:InitialDelaySeconds");
if (!initialDelaySecondsConfig.HasValue)
{
throw new InvalidOperationException("CacheManagement:InitialDelaySeconds configuration is required. Set it in appsettings.json or via CACHEMANAGEMENT__INITIALDELAYSECONDS environment variable.");
}
_initialDelayInterval = TimeSpan.FromSeconds(initialDelaySecondsConfig.Value);
} }
protected override async Task ExecuteAsync(CancellationToken stoppingToken) protected override async Task ExecuteAsync(CancellationToken stoppingToken)
@@ -39,8 +61,7 @@ public class CacheManagementService : BackgroundService
_logger.LogInformation("Cache management service started. Check interval: {Interval}", _checkInterval); _logger.LogInformation("Cache management service started. Check interval: {Interval}", _checkInterval);
// Wait a bit for the service to fully initialize // Wait a bit for the service to fully initialize
var initialDelaySeconds = _configuration.GetValue<int>("CacheManagement:InitialDelaySeconds", 10); await Task.Delay(_initialDelayInterval, stoppingToken);
await Task.Delay(TimeSpan.FromSeconds(initialDelaySeconds), stoppingToken);
// Pre-warm the browser before starting cache updates // Pre-warm the browser before starting cache updates
_logger.LogInformation("Pre-warming browser before cache updates"); _logger.LogInformation("Pre-warming browser before cache updates");
@@ -58,28 +79,7 @@ public class CacheManagementService : BackgroundService
// Initial cache update on startup // Initial cache update on startup
_logger.LogInformation("Performing initial cache update for {Count} locations", locationsToManage.Count); _logger.LogInformation("Performing initial cache update for {Count} locations", locationsToManage.Count);
int initialUpdatesTriggered = 0; var (initialUpdatesTriggered, initialCachesValid) = await ProcessLocationsAsync(locationsToManage, stoppingToken);
int initialCachesValid = 0;
foreach (var (suburb, state) in locationsToManage)
{
if (stoppingToken.IsCancellationRequested)
break;
var (triggered, isValid) = await UpdateCacheIfNeededAsync(suburb, state, stoppingToken);
if (triggered)
{
initialUpdatesTriggered++;
}
else if (isValid)
{
initialCachesValid++;
}
// Stagger updates to avoid overwhelming the system
var updateStaggerSeconds = _configuration.GetValue<int>("CacheManagement:UpdateStaggerSeconds", 2);
await Task.Delay(TimeSpan.FromSeconds(updateStaggerSeconds), stoppingToken);
}
_logger.LogInformation("Initial cache update completed: {UpdatesTriggered} updates triggered, {CachesValid} caches valid", _logger.LogInformation("Initial cache update completed: {UpdatesTriggered} updates triggered, {CachesValid} caches valid",
initialUpdatesTriggered, initialCachesValid); initialUpdatesTriggered, initialCachesValid);
@@ -95,28 +95,7 @@ public class CacheManagementService : BackgroundService
locationsToManage = GetLocationsToManage(); locationsToManage = GetLocationsToManage();
_logger.LogInformation("Starting periodic cache check for {Count} locations", locationsToManage.Count); _logger.LogInformation("Starting periodic cache check for {Count} locations", locationsToManage.Count);
int updatesTriggered = 0; var (updatesTriggered, cachesValid) = await ProcessLocationsAsync(locationsToManage, stoppingToken);
int cachesValid = 0;
foreach (var (suburb, state) in locationsToManage)
{
if (stoppingToken.IsCancellationRequested)
break;
var (triggered, isValid) = await UpdateCacheIfNeededAsync(suburb, state, stoppingToken);
if (triggered)
{
updatesTriggered++;
}
else if (isValid)
{
cachesValid++;
}
// Small delay between locations
var locationStaggerSeconds = _configuration.GetValue<int>("CacheManagement:LocationStaggerSeconds", 1);
await Task.Delay(TimeSpan.FromSeconds(locationStaggerSeconds), stoppingToken);
}
_logger.LogInformation("Periodic cache check completed: {UpdatesTriggered} updates triggered, {CachesValid} caches valid", _logger.LogInformation("Periodic cache check completed: {UpdatesTriggered} updates triggered, {CachesValid} caches valid",
updatesTriggered, cachesValid); updatesTriggered, cachesValid);
@@ -128,6 +107,38 @@ public class CacheManagementService : BackgroundService
} }
} }
/// <summary>
/// Processes a list of locations, checking and updating cache as needed
/// </summary>
private async Task<(int updatesTriggered, int cachesValid)> ProcessLocationsAsync(
List<(string suburb, string state)> locations,
CancellationToken cancellationToken)
{
int updatesTriggered = 0;
int cachesValid = 0;
foreach (var (suburb, state) in locations)
{
if (cancellationToken.IsCancellationRequested)
break;
var (triggered, isValid) = await UpdateCacheIfNeededAsync(suburb, state, cancellationToken);
if (triggered)
{
updatesTriggered++;
}
else if (isValid)
{
cachesValid++;
}
// Stagger processing to avoid overwhelming the system
await Task.Delay(_locationStaggerInterval, cancellationToken);
}
return (updatesTriggered, cachesValid);
}
private async Task<(bool updateTriggered, bool isValid)> UpdateCacheIfNeededAsync(string suburb, string state, CancellationToken cancellationToken) private async Task<(bool updateTriggered, bool isValid)> UpdateCacheIfNeededAsync(string suburb, string state, CancellationToken cancellationToken)
{ {
var locationKey = LocationHelper.GetLocationKey(suburb, state); var locationKey = LocationHelper.GetLocationKey(suburb, state);
+7 -1
View File
@@ -27,7 +27,13 @@ public class CacheService : ICacheService
_logger = logger; _logger = logger;
_configuration = configuration; _configuration = configuration;
_cacheDirectory = FilePathHelper.GetCacheDirectory(configuration); _cacheDirectory = FilePathHelper.GetCacheDirectory(configuration);
_cacheExpirationMinutes = configuration.GetValue<double>("CacheExpirationMinutes", 12.5);
var cacheExpirationMinutesConfig = configuration.GetValue<double?>("CacheExpirationMinutes");
if (!cacheExpirationMinutesConfig.HasValue)
{
throw new InvalidOperationException("CacheExpirationMinutes configuration is required. Set it in appsettings.json or via CACHEEXPIRATIONMINUTES environment variable.");
}
_cacheExpirationMinutes = cacheExpirationMinutesConfig.Value;
if (_cacheExpirationMinutes <= 0) if (_cacheExpirationMinutes <= 0)
{ {
+10 -13
View File
@@ -14,20 +14,17 @@ namespace BomLocalService.Services;
public DebugService(ILogger<DebugService> logger, IConfiguration configuration) public DebugService(ILogger<DebugService> logger, IConfiguration configuration)
{ {
_logger = logger; _logger = logger;
_waitMs = configuration.GetValue<int>("Debug:WaitMs", 2000);
var waitMsConfig = configuration.GetValue<int?>("Debug:WaitMs");
if (!waitMsConfig.HasValue)
{
throw new InvalidOperationException("Debug:WaitMs configuration is required. Set it in appsettings.json or via DEBUG__WAITMS environment variable.");
}
_waitMs = waitMsConfig.Value;
// Check all possible ways the config might be set // Get debug enabled from configuration (default from appsettings.json, can be overridden via DEBUG__ENABLED environment variable)
var debugEnabledEnv = Environment.GetEnvironmentVariable("DEBUG__ENABLED"); // Note: bool defaults to false if not found, which is acceptable for Debug:Enabled
var debugEnabledConfig = configuration.GetValue<bool>("Debug:Enabled", false); _enabled = configuration.GetValue<bool>("Debug:Enabled", false);
var debugEnabledFromConfig = configuration["Debug:Enabled"];
_logger.LogInformation("Debug configuration check - Env var DEBUG__ENABLED: {EnvVar}, Config Debug:Enabled: {ConfigValue}, Config string: {ConfigString}",
debugEnabledEnv ?? "null", debugEnabledConfig, debugEnabledFromConfig ?? "null");
// Try environment variable first, then config
_enabled = !string.IsNullOrEmpty(debugEnabledEnv)
? bool.TryParse(debugEnabledEnv, out var envBool) && envBool
: debugEnabledConfig;
var cacheDirectory = FilePathHelper.GetCacheDirectory(configuration); var cacheDirectory = FilePathHelper.GetCacheDirectory(configuration);
_debugDirectory = Path.Combine(cacheDirectory, "debug"); _debugDirectory = Path.Combine(cacheDirectory, "debug");
@@ -25,15 +25,38 @@ public class CaptureFramesStep : BaseScrapingStep
: base(logger, selectorService, debugService, configuration) : base(logger, selectorService, debugService, configuration)
{ {
_cacheService = cacheService; _cacheService = cacheService;
_tileRenderWaitMs = configuration.GetValue<int>("Screenshot:TileRenderWaitMs", 5000);
var tileRenderWaitMsConfig = configuration.GetValue<int?>("Screenshot:TileRenderWaitMs");
if (!tileRenderWaitMsConfig.HasValue)
{
throw new InvalidOperationException("Screenshot:TileRenderWaitMs configuration is required. Set it in appsettings.json or via SCREENSHOT__TILERENDERWAITMS environment variable.");
}
_tileRenderWaitMs = tileRenderWaitMsConfig.Value;
var cropSection = configuration.GetSection("Screenshot:Crop"); var cropSection = configuration.GetSection("Screenshot:Crop");
var cropXConfig = cropSection.GetValue<int?>("X");
if (!cropXConfig.HasValue)
{
throw new InvalidOperationException("Screenshot:Crop:X configuration is required. Set it in appsettings.json or via SCREENSHOT__CROP__X environment variable.");
}
var cropYConfig = cropSection.GetValue<int?>("Y");
if (!cropYConfig.HasValue)
{
throw new InvalidOperationException("Screenshot:Crop:Y configuration is required. Set it in appsettings.json or via SCREENSHOT__CROP__Y environment variable.");
}
var cropRightOffsetConfig = cropSection.GetValue<int?>("RightOffset");
if (!cropRightOffsetConfig.HasValue)
{
throw new InvalidOperationException("Screenshot:Crop:RightOffset configuration is required. Set it in appsettings.json or via SCREENSHOT__CROP__RIGHTOFFSET environment variable.");
}
_cropConfig = new ScreenshotCropConfig _cropConfig = new ScreenshotCropConfig
{ {
X = cropSection.GetValue<int>("X", 0), X = cropXConfig.Value,
Y = cropSection.GetValue<int>("Y", 0), Y = cropYConfig.Value,
RightOffset = cropSection.GetValue<int>("RightOffset", 0), RightOffset = cropRightOffsetConfig.Value,
Height = cropSection.GetValue<int?>("Height") Height = cropSection.GetValue<int?>("Height") // Height is optional (nullable)
}; };
} }
@@ -86,7 +86,12 @@ public class ResetToFirstFrameStep : BaseScrapingStep
await SaveDebugAsync(context, 10, "scrubber_at_position_0", cancellationToken); await SaveDebugAsync(context, 10, "scrubber_at_position_0", cancellationToken);
// Wait for frame 0 tiles to fully render // Wait for frame 0 tiles to fully render
var tileRenderWaitMs = Configuration.GetValue<int>("Screenshot:TileRenderWaitMs", 5000); var tileRenderWaitMsConfig = Configuration.GetValue<int?>("Screenshot:TileRenderWaitMs");
if (!tileRenderWaitMsConfig.HasValue)
{
throw new InvalidOperationException("Screenshot:TileRenderWaitMs configuration is required. Set it in appsettings.json or via SCREENSHOT__TILERENDERWAITMS environment variable.");
}
var tileRenderWaitMs = tileRenderWaitMsConfig.Value;
await context.Page.WaitForTimeoutAsync(tileRenderWaitMs); await context.Page.WaitForTimeoutAsync(tileRenderWaitMs);
context.CurrentState = PageState.Frame0Selected; context.CurrentState = PageState.Frame0Selected;
@@ -18,7 +18,12 @@ public class WaitForMapReadyStep : BaseScrapingStep
IConfiguration configuration) IConfiguration configuration)
: base(logger, selectorService, debugService, configuration) : base(logger, selectorService, debugService, configuration)
{ {
_tileRenderWaitMs = configuration.GetValue<int>("Screenshot:TileRenderWaitMs", 5000); var tileRenderWaitMsConfig = configuration.GetValue<int?>("Screenshot:TileRenderWaitMs");
if (!tileRenderWaitMsConfig.HasValue)
{
throw new InvalidOperationException("Screenshot:TileRenderWaitMs configuration is required. Set it in appsettings.json or via SCREENSHOT__TILERENDERWAITMS environment variable.");
}
_tileRenderWaitMs = tileRenderWaitMsConfig.Value;
} }
public override bool CanExecute(ScrapingContext context) public override bool CanExecute(ScrapingContext context)
@@ -18,7 +18,8 @@ public class NavigateHomepageStep : BaseScrapingStep
IConfiguration configuration) IConfiguration configuration)
: base(logger, selectorService, debugService, configuration) : base(logger, selectorService, debugService, configuration)
{ {
_baseUrl = configuration.GetValue<string>("Scraping:BaseUrl") ?? "https://www.bom.gov.au/"; _baseUrl = configuration.GetValue<string>("Scraping:BaseUrl")
?? throw new InvalidOperationException("Scraping:BaseUrl configuration is required. Set it in appsettings.json or via SCRAPING__BASEURL environment variable.");
} }
public override bool CanExecute(ScrapingContext context) public override bool CanExecute(ScrapingContext context)
@@ -174,7 +174,12 @@ public class SelectSearchResultStep : BaseScrapingStep
// Wait for forecast page to load // Wait for forecast page to load
await context.Page.WaitForLoadStateAsync(LoadState.DOMContentLoaded, new PageWaitForLoadStateOptions { Timeout = 15000 }); await context.Page.WaitForLoadStateAsync(LoadState.DOMContentLoaded, new PageWaitForLoadStateOptions { Timeout = 15000 });
var dynamicContentWaitMs = Configuration.GetValue<int>("Screenshot:DynamicContentWaitMs", 2000); var dynamicContentWaitMsConfig = Configuration.GetValue<int?>("Screenshot:DynamicContentWaitMs");
if (!dynamicContentWaitMsConfig.HasValue)
{
throw new InvalidOperationException("Screenshot:DynamicContentWaitMs configuration is required. Set it in appsettings.json or via SCREENSHOT__DYNAMICCONTENTWAITMS environment variable.");
}
var dynamicContentWaitMs = dynamicContentWaitMsConfig.Value;
await context.Page.WaitForTimeoutAsync(dynamicContentWaitMs); await context.Page.WaitForTimeoutAsync(dynamicContentWaitMs);
await SaveDebugAsync(context, 5, "forecast_page_loaded", cancellationToken); await SaveDebugAsync(context, 5, "forecast_page_loaded", cancellationToken);
@@ -44,8 +44,20 @@ public class RadarScrapingWorkflow : IWorkflow<RadarResponse>
_stepRegistry = stepRegistry; _stepRegistry = stepRegistry;
_configuration = configuration; _configuration = configuration;
_cacheService = cacheService; _cacheService = cacheService;
_cacheExpirationMinutes = configuration.GetValue<double>("CacheExpirationMinutes", 12.5);
_cacheManagementCheckIntervalMinutes = configuration.GetValue<int>("CacheManagement:CheckIntervalMinutes", 5); var cacheExpirationMinutesConfig = configuration.GetValue<double?>("CacheExpirationMinutes");
if (!cacheExpirationMinutesConfig.HasValue)
{
throw new InvalidOperationException("CacheExpirationMinutes configuration is required. Set it in appsettings.json or via CACHEEXPIRATIONMINUTES environment variable.");
}
_cacheExpirationMinutes = cacheExpirationMinutesConfig.Value;
var cacheManagementCheckIntervalMinutesConfig = configuration.GetValue<int?>("CacheManagement:CheckIntervalMinutes");
if (!cacheManagementCheckIntervalMinutesConfig.HasValue)
{
throw new InvalidOperationException("CacheManagement:CheckIntervalMinutes configuration is required. Set it in appsettings.json or via CACHEMANAGEMENT__CHECKINTERVALMINUTES environment variable.");
}
_cacheManagementCheckIntervalMinutes = cacheManagementCheckIntervalMinutesConfig.Value;
} }
public async Task<RadarResponse> ExecuteAsync(ScrapingContext context, CancellationToken cancellationToken) public async Task<RadarResponse> ExecuteAsync(ScrapingContext context, CancellationToken cancellationToken)
+9 -3
View File
@@ -14,7 +14,13 @@ public class TimeParsingService : ITimeParsingService
public TimeParsingService(ILogger<TimeParsingService> logger, IConfiguration configuration) public TimeParsingService(ILogger<TimeParsingService> logger, IConfiguration configuration)
{ {
_logger = logger; _logger = logger;
var timezone = configuration.GetValue<string>("Timezone") ?? "Australia/Brisbane"; // Get timezone from configuration (default from appsettings.json, can be overridden via TIMEZONE environment variable)
var timezone = configuration.GetValue<string>("Timezone");
if (string.IsNullOrEmpty(timezone))
{
throw new InvalidOperationException("Timezone configuration is required. Set it in appsettings.json or via TIMEZONE environment variable.");
}
try try
{ {
@@ -23,8 +29,8 @@ public class TimeParsingService : ITimeParsingService
} }
catch (TimeZoneNotFoundException) catch (TimeZoneNotFoundException)
{ {
_logger.LogWarning("Timezone {Timezone} not found, defaulting to Australia/Brisbane", timezone); _logger.LogError("Timezone {Timezone} not found. Please use a valid IANA timezone identifier.", timezone);
_timeZoneInfo = TimeZoneInfo.FindSystemTimeZoneById("Australia/Brisbane"); throw new InvalidOperationException($"Invalid timezone '{timezone}'. Please use a valid IANA timezone identifier (e.g., Australia/Sydney, Australia/Brisbane).");
} }
} }
+20 -4
View File
@@ -14,8 +14,12 @@ public static class CacheHelper
public static int GetFrameCountForDataType(IConfiguration configuration, CachedDataType dataType) public static int GetFrameCountForDataType(IConfiguration configuration, CachedDataType dataType)
{ {
var dataTypeName = dataType.ToString(); var dataTypeName = dataType.ToString();
var frameCount = configuration.GetValue<int>($"CachedDataTypes:{dataTypeName}:FrameCount", 7); var frameCountConfig = configuration.GetValue<int?>($"CachedDataTypes:{dataTypeName}:FrameCount");
return frameCount; if (!frameCountConfig.HasValue)
{
throw new InvalidOperationException($"CachedDataTypes:{dataTypeName}:FrameCount configuration is required. Set it in appsettings.json or via CACHEDDATATYPES__{dataTypeName.ToUpperInvariant()}__FRAMECOUNT environment variable.");
}
return frameCountConfig.Value;
} }
/// <summary> /// <summary>
@@ -71,8 +75,20 @@ public static class CacheHelper
{ {
// Calculate based on actual wait times and frame count // Calculate based on actual wait times and frame count
var frameCount = GetFrameCountForDataType(configuration, dataType); var frameCount = GetFrameCountForDataType(configuration, dataType);
var tileRenderWaitMs = configuration.GetValue<int>("Screenshot:TileRenderWaitMs", 5000);
var dynamicContentWaitMs = configuration.GetValue<int>("Screenshot:DynamicContentWaitMs", 2000); var tileRenderWaitMsConfig = configuration.GetValue<int?>("Screenshot:TileRenderWaitMs");
if (!tileRenderWaitMsConfig.HasValue)
{
throw new InvalidOperationException("Screenshot:TileRenderWaitMs configuration is required. Set it in appsettings.json or via SCREENSHOT__TILERENDERWAITMS environment variable.");
}
var tileRenderWaitMs = tileRenderWaitMsConfig.Value;
var dynamicContentWaitMsConfig = configuration.GetValue<int?>("Screenshot:DynamicContentWaitMs");
if (!dynamicContentWaitMsConfig.HasValue)
{
throw new InvalidOperationException("Screenshot:DynamicContentWaitMs configuration is required. Set it in appsettings.json or via SCREENSHOT__DYNAMICCONTENTWAITMS environment variable.");
}
var dynamicContentWaitMs = dynamicContentWaitMsConfig.Value;
// Rough calculation: // Rough calculation:
// - Initial page load and navigation: ~10-15 seconds // - Initial page load and navigation: ~10-15 seconds
+3 -3
View File
@@ -5,12 +5,12 @@ namespace BomLocalService.Utilities;
public static class FilePathHelper public static class FilePathHelper
{ {
/// <summary> /// <summary>
/// Gets the cache directory path from configuration or returns default /// Gets the cache directory path from configuration (default from appsettings.json, can be overridden via CACHEDIRECTORY environment variable)
/// </summary> /// </summary>
public static string GetCacheDirectory(IConfiguration configuration) public static string GetCacheDirectory(IConfiguration configuration)
{ {
return configuration.GetValue<string>("CacheDirectory") return configuration.GetValue<string>("CacheDirectory")
?? Path.Combine(AppContext.BaseDirectory, "cache"); ?? throw new InvalidOperationException("CacheDirectory configuration is required. Set it in appsettings.json or via CACHEDIRECTORY environment variable.");
} }
/// <summary> /// <summary>
-1
View File
@@ -18,7 +18,6 @@
"CacheManagement": { "CacheManagement": {
"CheckIntervalMinutes": 5, "CheckIntervalMinutes": 5,
"InitialDelaySeconds": 10, "InitialDelaySeconds": 10,
"UpdateStaggerSeconds": 2,
"LocationStaggerSeconds": 1 "LocationStaggerSeconds": 1
}, },
"CacheCleanup": { "CacheCleanup": {
-1
View File
@@ -32,7 +32,6 @@ services:
- CACHEEXPIRATIONMINUTES=${CACHEEXPIRATIONMINUTES:-12.5} - CACHEEXPIRATIONMINUTES=${CACHEEXPIRATIONMINUTES:-12.5}
- CACHEMANAGEMENT__CHECKINTERVALMINUTES=${CACHEMANAGEMENT__CHECKINTERVALMINUTES:-5} - CACHEMANAGEMENT__CHECKINTERVALMINUTES=${CACHEMANAGEMENT__CHECKINTERVALMINUTES:-5}
- CACHEMANAGEMENT__INITIALDELAYSECONDS=${CACHEMANAGEMENT__INITIALDELAYSECONDS:-10} - CACHEMANAGEMENT__INITIALDELAYSECONDS=${CACHEMANAGEMENT__INITIALDELAYSECONDS:-10}
- CACHEMANAGEMENT__UPDATESTAGGERSECONDS=${CACHEMANAGEMENT__UPDATESTAGGERSECONDS:-2}
- CACHEMANAGEMENT__LOCATIONSTAGGERSECONDS=${CACHEMANAGEMENT__LOCATIONSTAGGERSECONDS:-1} - CACHEMANAGEMENT__LOCATIONSTAGGERSECONDS=${CACHEMANAGEMENT__LOCATIONSTAGGERSECONDS:-1}
- CACHECLEANUP__INTERVALHOURS=${CACHECLEANUP__INTERVALHOURS:-1} - CACHECLEANUP__INTERVALHOURS=${CACHECLEANUP__INTERVALHOURS:-1}
- SCREENSHOT__DYNAMICCONTENTWAITMS=${SCREENSHOT__DYNAMICCONTENTWAITMS:-2000} - SCREENSHOT__DYNAMICCONTENTWAITMS=${SCREENSHOT__DYNAMICCONTENTWAITMS:-2000}