mirror of
https://github.com/azaion/satellite-provider.git
synced 2026-06-21 15:01:14 +00:00
2393bff1f2
Both POST /api/satellite/request and POST /api/satellite/route accept a caller-supplied id (Guid). Before this change, a retried POST with the same id would either crash with a unique-key violation (regions) or quietly create a divergent row (routes), neither of which matched the documented intent of caller-supplied GUIDs. RegionService.RequestRegionAsync and RouteService.CreateRouteAsync now check for an existing row by id at the top of the method. If one is found, the existing resource is returned with HTTP 200 and the side effects (insert + enqueue + point regeneration + geofence-region queueing) are all skipped. The Information-level log line on the idempotent path makes retries observable. OpenAPI Description metadata documents the contract on both endpoints so client integrators see it in Swagger. Coverage: - 2 new unit tests (one per service) assert that on duplicate id no insert / enqueue / point-generation / region-queueing call is made. - 2 new integration tests (IdempotentPostTests.cs) exercise the contract end-to-end via HTTP, asserting both calls return 200 and CreatedAt matches within 1ms (PostgreSQL truncates TIMESTAMP to microseconds while .NET DateTime keeps 100ns ticks; a real re-insertion would shift CreatedAt by milliseconds at minimum). Note: the check-first pattern leaves a TOCTOU window for concurrent retries. The repository unique key still surfaces the race as a PostgresException which AZ-353 maps to a clean error. Acceptable for realistic sequential-retry patterns; recorded in batch report as a non-blocking observation. Co-authored-by: Cursor <cursoragent@cursor.com>
392 lines
14 KiB
C#
392 lines
14 KiB
C#
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Options;
|
|
using SatelliteProvider.Common.Configs;
|
|
using SatelliteProvider.Common.DTO;
|
|
using SatelliteProvider.Common.Exceptions;
|
|
using SatelliteProvider.Common.Interfaces;
|
|
using SatelliteProvider.Common.Utils;
|
|
using SatelliteProvider.DataAccess.Models;
|
|
using SatelliteProvider.DataAccess.Repositories;
|
|
using SixLabors.ImageSharp;
|
|
using SixLabors.ImageSharp.PixelFormats;
|
|
using SixLabors.ImageSharp.Processing;
|
|
|
|
namespace SatelliteProvider.Services.RegionProcessing;
|
|
|
|
public class RegionService : IRegionService
|
|
{
|
|
private readonly IRegionRepository _regionRepository;
|
|
private readonly IRegionRequestQueue _queue;
|
|
private readonly ITileService _tileService;
|
|
private readonly StorageConfig _storageConfig;
|
|
private readonly ILogger<RegionService> _logger;
|
|
|
|
public RegionService(
|
|
IRegionRepository regionRepository,
|
|
IRegionRequestQueue queue,
|
|
ITileService tileService,
|
|
IOptions<StorageConfig> storageConfig,
|
|
ILogger<RegionService> logger)
|
|
{
|
|
_regionRepository = regionRepository;
|
|
_queue = queue;
|
|
_tileService = tileService;
|
|
_storageConfig = storageConfig.Value;
|
|
_logger = logger;
|
|
}
|
|
|
|
public async Task<RegionStatus> RequestRegionAsync(Guid id, double latitude, double longitude, double sizeMeters, int zoomLevel, bool stitchTiles = false)
|
|
{
|
|
// AZ-362: idempotent POST contract. A retried POST with the same caller-supplied
|
|
// Id returns the existing region instead of bubbling a unique-key violation.
|
|
var existing = await _regionRepository.GetByIdAsync(id);
|
|
if (existing != null)
|
|
{
|
|
_logger.LogInformation(
|
|
"Idempotent region POST: id {RegionId} already exists with status {Status}; returning existing resource without re-enqueueing",
|
|
id, existing.Status);
|
|
return MapToStatus(existing);
|
|
}
|
|
|
|
var now = DateTime.UtcNow;
|
|
var region = new RegionEntity
|
|
{
|
|
Id = id,
|
|
Latitude = latitude,
|
|
Longitude = longitude,
|
|
SizeMeters = sizeMeters,
|
|
ZoomLevel = zoomLevel,
|
|
StitchTiles = stitchTiles,
|
|
Status = "queued",
|
|
TilesDownloaded = 0,
|
|
TilesReused = 0,
|
|
CreatedAt = now,
|
|
UpdatedAt = now
|
|
};
|
|
|
|
await _regionRepository.InsertAsync(region);
|
|
|
|
var request = new RegionRequest
|
|
{
|
|
Id = id,
|
|
Latitude = latitude,
|
|
Longitude = longitude,
|
|
SizeMeters = sizeMeters,
|
|
ZoomLevel = zoomLevel,
|
|
StitchTiles = stitchTiles
|
|
};
|
|
|
|
await _queue.EnqueueAsync(request);
|
|
|
|
return MapToStatus(region);
|
|
}
|
|
|
|
public async Task<RegionStatus?> GetRegionStatusAsync(Guid id)
|
|
{
|
|
var region = await _regionRepository.GetByIdAsync(id);
|
|
return region != null ? MapToStatus(region) : null;
|
|
}
|
|
|
|
public async Task ProcessRegionAsync(Guid id, CancellationToken cancellationToken = default)
|
|
{
|
|
var startTime = DateTime.UtcNow;
|
|
|
|
var region = await _regionRepository.GetByIdAsync(id);
|
|
if (region == null)
|
|
{
|
|
_logger.LogWarning("Region {RegionId} not found in database", id);
|
|
return;
|
|
}
|
|
|
|
region.Status = "processing";
|
|
region.UpdatedAt = DateTime.UtcNow;
|
|
await _regionRepository.UpdateAsync(region);
|
|
|
|
using var timeoutCts = new CancellationTokenSource(TimeSpan.FromMinutes(5));
|
|
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token);
|
|
|
|
string? errorMessage = null;
|
|
List<TileMetadata>? tiles = null;
|
|
int tilesDownloaded = 0;
|
|
int tilesReused = 0;
|
|
|
|
try
|
|
{
|
|
var processingStartTime = DateTime.UtcNow;
|
|
|
|
var tilesBeforeDownload = await _tileService.GetTilesByRegionAsync(
|
|
region.Latitude,
|
|
region.Longitude,
|
|
region.SizeMeters,
|
|
region.ZoomLevel);
|
|
var existingTileIds = new HashSet<Guid>(tilesBeforeDownload.Select(t => t.Id));
|
|
|
|
tiles = await _tileService.DownloadAndStoreTilesAsync(
|
|
region.Latitude,
|
|
region.Longitude,
|
|
region.SizeMeters,
|
|
region.ZoomLevel,
|
|
linkedCts.Token);
|
|
|
|
tilesDownloaded = tiles.Count(t => !existingTileIds.Contains(t.Id));
|
|
tilesReused = tiles.Count(t => existingTileIds.Contains(t.Id));
|
|
|
|
var readyDir = _storageConfig.ReadyDirectory;
|
|
Directory.CreateDirectory(readyDir);
|
|
|
|
var csvPath = Path.Combine(readyDir, $"region_{id}_ready.csv");
|
|
var summaryPath = Path.Combine(readyDir, $"region_{id}_summary.txt");
|
|
string? stitchedImagePath = null;
|
|
|
|
await GenerateCsvFileAsync(csvPath, tiles, linkedCts.Token);
|
|
|
|
if (region.StitchTiles)
|
|
{
|
|
stitchedImagePath = Path.Combine(readyDir, $"region_{id}_stitched.jpg");
|
|
await StitchTilesAsync(tiles, region.Latitude, region.Longitude, region.ZoomLevel, stitchedImagePath, linkedCts.Token);
|
|
}
|
|
|
|
await GenerateSummaryFileAsync(summaryPath, id, region, tiles, tilesDownloaded, tilesReused, stitchedImagePath, processingStartTime, linkedCts.Token, errorMessage);
|
|
|
|
region.Status = "completed";
|
|
region.CsvFilePath = csvPath;
|
|
region.SummaryFilePath = summaryPath;
|
|
region.TilesDownloaded = tilesDownloaded;
|
|
region.TilesReused = tilesReused;
|
|
region.UpdatedAt = DateTime.UtcNow;
|
|
await _regionRepository.UpdateAsync(region);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
var classification = RegionFailureClassifier.Classify(ex, timeoutCts, cancellationToken);
|
|
errorMessage = classification.ErrorMessage;
|
|
_logger.LogError(
|
|
ex,
|
|
"Region {RegionId} processing failed (category={Category}): {ErrorMessage}",
|
|
id,
|
|
classification.Category,
|
|
classification.ErrorMessage);
|
|
await HandleProcessingFailureAsync(id, region, startTime, tiles, tilesDownloaded, tilesReused, errorMessage);
|
|
}
|
|
}
|
|
|
|
private async Task HandleProcessingFailureAsync(
|
|
Guid id,
|
|
RegionEntity region,
|
|
DateTime startTime,
|
|
List<TileMetadata>? tiles,
|
|
int tilesDownloaded,
|
|
int tilesReused,
|
|
string errorMessage)
|
|
{
|
|
region.Status = "failed";
|
|
region.UpdatedAt = DateTime.UtcNow;
|
|
|
|
try
|
|
{
|
|
var readyDir = _storageConfig.ReadyDirectory;
|
|
Directory.CreateDirectory(readyDir);
|
|
|
|
var summaryPath = Path.Combine(readyDir, $"region_{id}_summary.txt");
|
|
region.SummaryFilePath = summaryPath;
|
|
|
|
await GenerateSummaryFileAsync(
|
|
summaryPath,
|
|
id,
|
|
region,
|
|
tiles ?? new List<TileMetadata>(),
|
|
tilesDownloaded,
|
|
tilesReused,
|
|
null,
|
|
startTime,
|
|
CancellationToken.None,
|
|
errorMessage);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Failed to generate error summary for region {RegionId}", id);
|
|
}
|
|
|
|
await _regionRepository.UpdateAsync(region);
|
|
}
|
|
|
|
private async Task<string> StitchTilesAsync(
|
|
List<TileMetadata> tiles,
|
|
double centerLatitude,
|
|
double centerLongitude,
|
|
int zoomLevel,
|
|
string outputPath,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (tiles.Count == 0)
|
|
{
|
|
throw new InvalidOperationException("No tiles to stitch");
|
|
}
|
|
|
|
var tileSizePixels = tiles.First().TileSizePixels;
|
|
|
|
var tileCoords = tiles.Select(t =>
|
|
{
|
|
var (x, y) = GeoUtils.WorldToTilePos(new GeoPoint(t.Latitude, t.Longitude), zoomLevel);
|
|
return (x, y, t.FilePath);
|
|
}).ToList();
|
|
|
|
var minX = tileCoords.Min(t => t.x);
|
|
var maxX = tileCoords.Max(t => t.x);
|
|
var minY = tileCoords.Min(t => t.y);
|
|
var maxY = tileCoords.Max(t => t.y);
|
|
|
|
var gridWidth = maxX - minX + 1;
|
|
var gridHeight = maxY - minY + 1;
|
|
var imageWidth = gridWidth * tileSizePixels;
|
|
var imageHeight = gridHeight * tileSizePixels;
|
|
|
|
using var stitchedImage = new Image<Rgb24>(imageWidth, imageHeight);
|
|
|
|
foreach (var (x, y, filePath) in tileCoords)
|
|
{
|
|
if (!File.Exists(filePath))
|
|
{
|
|
_logger.LogWarning("Tile file not found: {FilePath}", filePath);
|
|
continue;
|
|
}
|
|
|
|
using var tileImage = await Image.LoadAsync<Rgb24>(filePath, cancellationToken);
|
|
|
|
var destX = (x - minX) * tileSizePixels;
|
|
var destY = (y - minY) * tileSizePixels;
|
|
|
|
stitchedImage.Mutate(ctx => ctx.DrawImage(tileImage, new Point(destX, destY), 1f));
|
|
}
|
|
|
|
var (centerTileX, centerTileY) = GeoUtils.WorldToTilePos(new GeoPoint(centerLatitude, centerLongitude), zoomLevel);
|
|
|
|
var n = Math.Pow(2.0, zoomLevel);
|
|
var centerTilePixelX = ((centerLongitude + 180.0) / 360.0 * n - centerTileX) * tileSizePixels;
|
|
var centerTilePixelY = ((1.0 - Math.Log(Math.Tan(centerLatitude * Math.PI / 180.0) + 1.0 / Math.Cos(centerLatitude * Math.PI / 180.0)) / Math.PI) / 2.0 * n - centerTileY) * tileSizePixels;
|
|
|
|
var crossX = (int)Math.Round((centerTileX - minX) * tileSizePixels + centerTilePixelX);
|
|
var crossY = (int)Math.Round((centerTileY - minY) * tileSizePixels + centerTilePixelY);
|
|
|
|
var red = new Rgb24(255, 0, 0);
|
|
stitchedImage.Mutate(ctx =>
|
|
{
|
|
for (int i = -5; i < 5; i++)
|
|
{
|
|
var hx = crossX + i;
|
|
var vy = crossY + i;
|
|
|
|
if (hx >= 0 && hx < imageWidth && crossY >= 0 && crossY < imageHeight)
|
|
{
|
|
stitchedImage[hx, crossY] = red;
|
|
}
|
|
|
|
if (crossX >= 0 && crossX < imageWidth && vy >= 0 && vy < imageHeight)
|
|
{
|
|
stitchedImage[crossX, vy] = red;
|
|
}
|
|
}
|
|
});
|
|
|
|
await stitchedImage.SaveAsJpegAsync(outputPath, cancellationToken);
|
|
|
|
return outputPath;
|
|
}
|
|
|
|
private async Task GenerateCsvFileAsync(string filePath, List<TileMetadata> tiles, CancellationToken cancellationToken)
|
|
{
|
|
var orderedTiles = tiles.OrderByDescending(t => t.Latitude).ThenBy(t => t.Longitude).ToList();
|
|
|
|
using var writer = new StreamWriter(filePath);
|
|
await writer.WriteLineAsync("latitude,longitude,file_path");
|
|
|
|
foreach (var tile in orderedTiles)
|
|
{
|
|
await writer.WriteLineAsync($"{tile.Latitude:F6},{tile.Longitude:F6},{tile.FilePath}");
|
|
}
|
|
}
|
|
|
|
private async Task GenerateSummaryFileAsync(
|
|
string filePath,
|
|
Guid regionId,
|
|
RegionEntity region,
|
|
List<TileMetadata> tiles,
|
|
int tilesDownloaded,
|
|
int tilesReused,
|
|
string? stitchedImagePath,
|
|
DateTime startTime,
|
|
CancellationToken cancellationToken,
|
|
string? errorMessage = null)
|
|
{
|
|
var endTime = DateTime.UtcNow;
|
|
var processingTime = (endTime - startTime).TotalSeconds;
|
|
|
|
var summary = new System.Text.StringBuilder();
|
|
summary.AppendLine("Region Processing Summary");
|
|
summary.AppendLine("========================");
|
|
summary.AppendLine($"Region ID: {regionId}");
|
|
summary.AppendLine($"Center: {region.Latitude:F6}, {region.Longitude:F6}");
|
|
summary.AppendLine($"Size: {region.SizeMeters:F0} meters");
|
|
summary.AppendLine($"Zoom Level: {region.ZoomLevel}");
|
|
summary.AppendLine($"Status: {region.Status}");
|
|
summary.AppendLine();
|
|
|
|
if (!string.IsNullOrEmpty(errorMessage))
|
|
{
|
|
summary.AppendLine("ERROR:");
|
|
summary.AppendLine(errorMessage);
|
|
summary.AppendLine();
|
|
}
|
|
|
|
summary.AppendLine("Processing Statistics:");
|
|
summary.AppendLine($"- Tiles Downloaded: {tilesDownloaded}");
|
|
summary.AppendLine($"- Tiles Reused from Cache: {tilesReused}");
|
|
summary.AppendLine($"- Total Tiles: {tiles.Count}");
|
|
summary.AppendLine($"- Processing Time: {processingTime:F2} seconds");
|
|
summary.AppendLine($"- Started: {startTime:yyyy-MM-dd HH:mm:ss} UTC");
|
|
|
|
if (region.Status == "completed")
|
|
{
|
|
summary.AppendLine($"- Completed: {endTime:yyyy-MM-dd HH:mm:ss} UTC");
|
|
}
|
|
else
|
|
{
|
|
summary.AppendLine($"- Failed: {endTime:yyyy-MM-dd HH:mm:ss} UTC");
|
|
}
|
|
|
|
summary.AppendLine();
|
|
summary.AppendLine("Files Created:");
|
|
|
|
if (tiles.Count > 0)
|
|
{
|
|
summary.AppendLine($"- CSV: {Path.GetFileName(filePath).Replace("_summary.txt", "_ready.csv")}");
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(stitchedImagePath))
|
|
{
|
|
summary.AppendLine($"- Stitched Image: {Path.GetFileName(stitchedImagePath)}");
|
|
summary.AppendLine($"- Stitched Image Path: {stitchedImagePath}");
|
|
}
|
|
|
|
summary.AppendLine($"- Summary: {Path.GetFileName(filePath)}");
|
|
|
|
await File.WriteAllTextAsync(filePath, summary.ToString(), cancellationToken);
|
|
}
|
|
|
|
private static RegionStatus MapToStatus(RegionEntity region)
|
|
{
|
|
return new RegionStatus
|
|
{
|
|
Id = region.Id,
|
|
Status = region.Status,
|
|
CsvFilePath = region.CsvFilePath,
|
|
SummaryFilePath = region.SummaryFilePath,
|
|
TilesDownloaded = region.TilesDownloaded,
|
|
TilesReused = region.TilesReused,
|
|
CreatedAt = region.CreatedAt,
|
|
UpdatedAt = region.UpdatedAt
|
|
};
|
|
}
|
|
}
|
|
|