Files
Oleksandr Bezdieniezhnykh 23ab05766d [AZ-370] Refactor C17: status / point-type enums + AC RT2 update
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>
2026-05-11 03:55:22 +03:00

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;
}
}