mirror of
https://github.com/azaion/satellite-provider.git
synced 2026-06-21 15:41:13 +00:00
23ab05766d
Replaces bare strings with two enums in Common/Enums/:
RegionStatus { Queued, Processing, Completed, Failed }
RoutePointType { Start, End, Action, Intermediate }
Adds a Dapper EnumStringTypeHandler<T> (DataAccess/TypeHandlers/)
that round-trips enums to/from lowercase strings, registered once
at startup via DapperEnumTypeHandlers.RegisterAll(). DataAccess now
references Common (project ref) so entities can carry the enum types.
Sites converted: RegionService (5), RouteProcessingService (3),
RoutePointGraphBuilder (4), entity Status/PointType columns. Log
message and summary file format preserved via .ToLowerInvariant().
API JSON contract preserved by adding JsonStringEnumConverter with
JsonNamingPolicy.CamelCase to the http JSON options — single-word
enum members serialize to the same lowercase strings as before.
DTO renamed: Common.DTO.RegionStatus -> RegionStatusResponse to
free the RegionStatus name for the new enum (forced by the task's
explicit enum name); the renamed DTO has no public-API impact at
the JSON wire level. Stale doc references updated.
AC RT2 in _docs/00_problem/acceptance_criteria.md now lists all 4
point types (start/end/action/intermediate).
Tests: 171 / 171 unit + 5 / 5 smoke green (was 141 + 5; +30 new tests
covering type handler round-trip, set/parse, unknown-value rejection,
idempotent registration, and the AC RT2 doc check).
Co-authored-by: Cursor <cursoragent@cursor.com>
360 lines
14 KiB
C#
360 lines
14 KiB
C#
using Microsoft.Extensions.Hosting;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Options;
|
|
using SatelliteProvider.Common.Configs;
|
|
using SatelliteProvider.Common.DTO;
|
|
using SatelliteProvider.Common.Enums;
|
|
using SatelliteProvider.Common.Interfaces;
|
|
using SatelliteProvider.Common.Utils;
|
|
using SatelliteProvider.DataAccess.Models;
|
|
using SatelliteProvider.DataAccess.Repositories;
|
|
|
|
namespace SatelliteProvider.Services.RouteManagement;
|
|
|
|
// AZ-364 / C11: thin orchestrator after the god-class decomposition.
|
|
// Polls the route repository, classifies region statuses, queues region work
|
|
// via IRegionService (AZ-360 / C08 — direct dependency, no IServiceProvider),
|
|
// and dispatches output generation to the per-concern collaborators
|
|
// (RouteCsvWriter, RouteSummaryWriter, RouteImageRenderer, TilesZipBuilder,
|
|
// RegionFileCleaner, RouteRegionMatcher).
|
|
public class RouteProcessingService : BackgroundService
|
|
{
|
|
private readonly IRouteRepository _routeRepository;
|
|
private readonly IRegionRepository _regionRepository;
|
|
private readonly IRegionService _regionService;
|
|
private readonly RouteCsvWriter _routeCsvWriter;
|
|
private readonly RouteSummaryWriter _routeSummaryWriter;
|
|
private readonly RouteImageRenderer _routeImageRenderer;
|
|
private readonly TilesZipBuilder _tilesZipBuilder;
|
|
private readonly RegionFileCleaner _regionFileCleaner;
|
|
private readonly RouteRegionMatcher _routeRegionMatcher;
|
|
private readonly ILogger<RouteProcessingService> _logger;
|
|
private readonly TimeSpan _checkInterval;
|
|
|
|
public RouteProcessingService(
|
|
IRouteRepository routeRepository,
|
|
IRegionRepository regionRepository,
|
|
IRegionService regionService,
|
|
RouteCsvWriter routeCsvWriter,
|
|
RouteSummaryWriter routeSummaryWriter,
|
|
RouteImageRenderer routeImageRenderer,
|
|
TilesZipBuilder tilesZipBuilder,
|
|
RegionFileCleaner regionFileCleaner,
|
|
IOptions<ProcessingConfig> processingConfig,
|
|
ILogger<RouteProcessingService> logger)
|
|
{
|
|
_routeRepository = routeRepository;
|
|
_regionRepository = regionRepository;
|
|
_regionService = regionService;
|
|
_routeCsvWriter = routeCsvWriter;
|
|
_routeSummaryWriter = routeSummaryWriter;
|
|
_routeImageRenderer = routeImageRenderer;
|
|
_tilesZipBuilder = tilesZipBuilder;
|
|
_regionFileCleaner = regionFileCleaner;
|
|
_routeRegionMatcher = new RouteRegionMatcher();
|
|
_checkInterval = TimeSpan.FromSeconds(processingConfig.Value.RouteProcessingPollIntervalSeconds);
|
|
_logger = logger;
|
|
}
|
|
|
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
|
{
|
|
_logger.LogInformation("Route Processing Service started");
|
|
|
|
while (!stoppingToken.IsCancellationRequested)
|
|
{
|
|
try
|
|
{
|
|
await ProcessPendingRoutesAsync(stoppingToken);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error in route processing service");
|
|
}
|
|
|
|
await Task.Delay(_checkInterval, stoppingToken);
|
|
}
|
|
|
|
_logger.LogInformation("Route Processing Service stopped");
|
|
}
|
|
|
|
private async Task ProcessPendingRoutesAsync(CancellationToken cancellationToken)
|
|
{
|
|
var pendingRoutes = await _routeRepository.GetRoutesWithPendingMapsAsync();
|
|
|
|
foreach (var route in pendingRoutes)
|
|
{
|
|
if (cancellationToken.IsCancellationRequested)
|
|
break;
|
|
|
|
try
|
|
{
|
|
await ProcessRouteSequentiallyAsync(route.Id, cancellationToken);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error processing route {RouteId}", route.Id);
|
|
}
|
|
}
|
|
}
|
|
|
|
private async Task ProcessRouteSequentiallyAsync(Guid routeId, CancellationToken cancellationToken)
|
|
{
|
|
var route = await _routeRepository.GetByIdAsync(routeId);
|
|
if (route == null)
|
|
{
|
|
_logger.LogWarning("Route {RouteId}: Route not found, skipping processing", routeId);
|
|
return;
|
|
}
|
|
|
|
if (!route.RequestMaps || route.MapsReady)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var routePointsList = (await _routeRepository.GetRoutePointsAsync(routeId)).ToList();
|
|
var regionIdsList = (await _routeRepository.GetRegionIdsByRouteAsync(routeId)).ToList();
|
|
var geofenceRegionIdsList = (await _routeRepository.GetGeofenceRegionIdsByRouteAsync(routeId)).ToList();
|
|
|
|
var allRegionIds = regionIdsList.Union(geofenceRegionIdsList).ToList();
|
|
|
|
if (regionIdsList.Count == 0 && routePointsList.Count > 0)
|
|
{
|
|
foreach (var point in routePointsList)
|
|
{
|
|
var regionId = Guid.NewGuid();
|
|
|
|
await _regionService.RequestRegionAsync(
|
|
regionId,
|
|
point.Latitude,
|
|
point.Longitude,
|
|
route.RegionSizeMeters,
|
|
route.ZoomLevel,
|
|
stitchTiles: false);
|
|
|
|
await _routeRepository.LinkRouteToRegionAsync(routeId, regionId);
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
var regions = new List<RegionEntity>();
|
|
foreach (var regionId in allRegionIds)
|
|
{
|
|
var region = await _regionRepository.GetByIdAsync(regionId);
|
|
if (region != null)
|
|
{
|
|
regions.Add(region);
|
|
}
|
|
}
|
|
|
|
var completedRegions = regions.Where(r => r.Status == RegionStatus.Completed).ToList();
|
|
var failedRegions = regions.Where(r => r.Status == RegionStatus.Failed).ToList();
|
|
var processingRegions = regions.Where(r => r.Status == RegionStatus.Queued || r.Status == RegionStatus.Processing).ToList();
|
|
|
|
var completedRoutePointRegions = completedRegions.Where(r => !geofenceRegionIdsList.Contains(r.Id)).ToList();
|
|
var completedGeofenceRegions = completedRegions.Where(r => geofenceRegionIdsList.Contains(r.Id)).ToList();
|
|
|
|
var hasRoutePointRegions = regionIdsList.Count > 0;
|
|
var hasEnoughRoutePointRegions = !hasRoutePointRegions || completedRoutePointRegions.Count >= routePointsList.Count;
|
|
var hasAllGeofenceRegions = geofenceRegionIdsList.Count == 0 || completedGeofenceRegions.Count >= geofenceRegionIdsList.Count;
|
|
var hasEnoughCompleted = hasEnoughRoutePointRegions && hasAllGeofenceRegions;
|
|
|
|
var activeRegions = completedRegions.Count + processingRegions.Count;
|
|
var shouldRetryFailed = failedRegions.Count > 0 && !hasEnoughCompleted && activeRegions < allRegionIds.Count;
|
|
|
|
if (hasEnoughCompleted)
|
|
{
|
|
var orderedRouteRegions = _routeRegionMatcher.Match(routePointsList, completedRoutePointRegions);
|
|
var regionsForOutput = orderedRouteRegions.Concat(completedGeofenceRegions)
|
|
.GroupBy(r => r.Id)
|
|
.Select(g => g.First())
|
|
.ToList();
|
|
|
|
await GenerateRouteMapsAsync(routeId, route, regionsForOutput, completedGeofenceRegions, routePointsList, cancellationToken);
|
|
return;
|
|
}
|
|
|
|
if (shouldRetryFailed)
|
|
{
|
|
foreach (var failedRegion in failedRegions)
|
|
{
|
|
var newRegionId = Guid.NewGuid();
|
|
|
|
await _regionService.RequestRegionAsync(
|
|
newRegionId,
|
|
failedRegion.Latitude,
|
|
failedRegion.Longitude,
|
|
failedRegion.SizeMeters,
|
|
failedRegion.ZoomLevel,
|
|
stitchTiles: false);
|
|
|
|
await _routeRepository.LinkRouteToRegionAsync(routeId, newRegionId);
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
var anyProcessing = processingRegions.Count > 0;
|
|
if (anyProcessing)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_logger.LogWarning(
|
|
"Route {RouteId}: No processing regions, but not enough completed. This is an unexpected state - hasEnoughCompleted={HasEnough}, shouldRetryFailed={ShouldRetry}, anyProcessing={AnyProcessing}",
|
|
routeId, hasEnoughCompleted, shouldRetryFailed, anyProcessing);
|
|
}
|
|
|
|
private async Task GenerateRouteMapsAsync(
|
|
Guid routeId,
|
|
RouteEntity route,
|
|
IReadOnlyList<RegionEntity> regionsForOutput,
|
|
IReadOnlyList<RegionEntity> geofenceRegions,
|
|
IReadOnlyList<RoutePointEntity> routePoints,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
try
|
|
{
|
|
var allTiles = new Dictionary<string, TileInfo>();
|
|
int totalTilesFromRegions = 0;
|
|
int duplicateTiles = 0;
|
|
|
|
foreach (var region in regionsForOutput)
|
|
{
|
|
if (string.IsNullOrEmpty(region.CsvFilePath) || !File.Exists(region.CsvFilePath))
|
|
{
|
|
_logger.LogWarning("Route {RouteId}: Region {RegionId} CSV not found", routeId, region.Id);
|
|
continue;
|
|
}
|
|
|
|
var csvLines = await File.ReadAllLinesAsync(region.CsvFilePath, cancellationToken);
|
|
|
|
foreach (var line in csvLines.Skip(1))
|
|
{
|
|
var parts = line.Split(',');
|
|
if (parts.Length < 3) continue;
|
|
|
|
if (!double.TryParse(parts[0], out var lat))
|
|
{
|
|
continue;
|
|
}
|
|
if (!double.TryParse(parts[1], out var lon))
|
|
{
|
|
continue;
|
|
}
|
|
var filePath = parts[2];
|
|
|
|
totalTilesFromRegions++;
|
|
var key = $"{lat:F6}_{lon:F6}";
|
|
|
|
if (!allTiles.ContainsKey(key))
|
|
{
|
|
allTiles[key] = new TileInfo(lat, lon, filePath);
|
|
}
|
|
else
|
|
{
|
|
duplicateTiles++;
|
|
}
|
|
}
|
|
}
|
|
|
|
var tileList = allTiles.Values.ToList();
|
|
|
|
var csvPath = await _routeCsvWriter.WriteAsync(routeId, tileList, cancellationToken);
|
|
|
|
string? stitchedImagePath = null;
|
|
if (route.RequestMaps)
|
|
{
|
|
var geofencePolygonBounds = await ComputeGeofencePolygonBoundsAsync(routeId, route.ZoomLevel, cancellationToken);
|
|
stitchedImagePath = await _routeImageRenderer.RenderAsync(
|
|
routeId,
|
|
tileList,
|
|
route.ZoomLevel,
|
|
geofencePolygonBounds,
|
|
routePoints,
|
|
cancellationToken);
|
|
}
|
|
|
|
string? tilesZipPath = null;
|
|
if (route.CreateTilesZip)
|
|
{
|
|
tilesZipPath = await _tilesZipBuilder.BuildAsync(routeId, tileList, cancellationToken);
|
|
}
|
|
|
|
var summaryPath = await _routeSummaryWriter.WriteAsync(
|
|
route,
|
|
tileList.Count,
|
|
totalTilesFromRegions,
|
|
duplicateTiles,
|
|
tilesZipPath,
|
|
cancellationToken);
|
|
|
|
route.MapsReady = true;
|
|
route.CsvFilePath = csvPath;
|
|
route.SummaryFilePath = summaryPath;
|
|
route.StitchedImagePath = stitchedImagePath;
|
|
route.TilesZipPath = tilesZipPath;
|
|
route.UpdatedAt = DateTime.UtcNow;
|
|
|
|
await _routeRepository.UpdateRouteAsync(route);
|
|
|
|
await _regionFileCleaner.CleanupAsync(regionsForOutput, cancellationToken);
|
|
|
|
_logger.LogInformation("Route {RouteId} maps processing completed successfully", routeId);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error generating maps for route {RouteId}", routeId);
|
|
throw;
|
|
}
|
|
}
|
|
|
|
private async Task<List<(int MinX, int MinY, int MaxX, int MaxY)>> ComputeGeofencePolygonBoundsAsync(
|
|
Guid routeId,
|
|
int zoomLevel,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var bounds = new List<(int MinX, int MinY, int MaxX, int MaxY)>();
|
|
var geofencesByPolygon = await _routeRepository.GetGeofenceRegionsByPolygonAsync(routeId);
|
|
|
|
foreach (var (_, polygonRegionIds) in geofencesByPolygon.OrderBy(kvp => kvp.Key))
|
|
{
|
|
int? minX = null, minY = null, maxX = null, maxY = null;
|
|
|
|
foreach (var geofenceId in polygonRegionIds)
|
|
{
|
|
var region = await _regionRepository.GetByIdAsync(geofenceId);
|
|
if (region == null || string.IsNullOrEmpty(region.CsvFilePath) || !File.Exists(region.CsvFilePath))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var csvLines = await File.ReadAllLinesAsync(region.CsvFilePath, cancellationToken);
|
|
|
|
foreach (var line in csvLines.Skip(1))
|
|
{
|
|
var parts = line.Split(',');
|
|
if (parts.Length < 3) continue;
|
|
if (!double.TryParse(parts[0], out var lat) || !double.TryParse(parts[1], out var lon))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var tile = GeoUtils.WorldToTilePos(new GeoPoint(lat, lon), zoomLevel);
|
|
minX = minX == null ? tile.x : Math.Min(minX.Value, tile.x);
|
|
minY = minY == null ? tile.y : Math.Min(minY.Value, tile.y);
|
|
maxX = maxX == null ? tile.x : Math.Max(maxX.Value, tile.x);
|
|
maxY = maxY == null ? tile.y : Math.Max(maxY.Value, tile.y);
|
|
}
|
|
}
|
|
|
|
if (minX.HasValue && minY.HasValue && maxX.HasValue && maxY.HasValue)
|
|
{
|
|
bounds.Add((minX.Value, minY.Value, maxX.Value, maxY.Value));
|
|
}
|
|
}
|
|
|
|
return bounds;
|
|
}
|
|
}
|