mirror of
https://github.com/azaion/satellite-provider.git
synced 2026-06-21 09:41:15 +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>
296 lines
12 KiB
C#
296 lines
12 KiB
C#
using Microsoft.Extensions.Logging;
|
|
using SatelliteProvider.Common.DTO;
|
|
using SatelliteProvider.Common.Interfaces;
|
|
using SatelliteProvider.Common.Utils;
|
|
using SatelliteProvider.DataAccess.Models;
|
|
using SatelliteProvider.DataAccess.Repositories;
|
|
|
|
namespace SatelliteProvider.Services.RouteManagement;
|
|
|
|
public class RouteService : IRouteService
|
|
{
|
|
private readonly IRouteRepository _routeRepository;
|
|
private readonly IRegionService _regionService;
|
|
private readonly ILogger<RouteService> _logger;
|
|
private const double MAX_POINT_SPACING_METERS = 200.0;
|
|
|
|
public RouteService(
|
|
IRouteRepository routeRepository,
|
|
IRegionService regionService,
|
|
ILogger<RouteService> logger)
|
|
{
|
|
_routeRepository = routeRepository;
|
|
_regionService = regionService;
|
|
_logger = logger;
|
|
}
|
|
|
|
public async Task<RouteResponse> CreateRouteAsync(CreateRouteRequest request)
|
|
{
|
|
// AZ-362: idempotent POST contract. A retried POST with the same caller-supplied
|
|
// Id returns the existing route instead of re-running point generation and
|
|
// re-queueing geofence regions.
|
|
var existing = await GetRouteAsync(request.Id);
|
|
if (existing != null)
|
|
{
|
|
_logger.LogInformation(
|
|
"Idempotent route POST: id {RouteId} already exists; returning existing resource",
|
|
request.Id);
|
|
return existing;
|
|
}
|
|
|
|
if (request.Points.Count < 2)
|
|
{
|
|
throw new ArgumentException("Route must have at least 2 points");
|
|
}
|
|
|
|
if (request.RegionSizeMeters < 100 || request.RegionSizeMeters > 10000)
|
|
{
|
|
throw new ArgumentException("Region size must be between 100 and 10000 meters");
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(request.Name))
|
|
{
|
|
throw new ArgumentException("Route name is required");
|
|
}
|
|
|
|
|
|
var allPoints = new List<RoutePointDto>();
|
|
var totalDistance = 0.0;
|
|
var sequenceNumber = 0;
|
|
|
|
for (int segmentIndex = 0; segmentIndex < request.Points.Count; segmentIndex++)
|
|
{
|
|
var currentPoint = request.Points[segmentIndex];
|
|
var isStart = segmentIndex == 0;
|
|
var isEnd = segmentIndex == request.Points.Count - 1;
|
|
|
|
var geoPoint = new GeoPoint(currentPoint.Latitude, currentPoint.Longitude);
|
|
|
|
double? distanceFromPrevious = null;
|
|
if (allPoints.Count > 0)
|
|
{
|
|
var lastAddedPoint = allPoints[^1];
|
|
var prevGeoPoint = new GeoPoint(lastAddedPoint.Latitude, lastAddedPoint.Longitude);
|
|
distanceFromPrevious = GeoUtils.CalculateDistance(prevGeoPoint, geoPoint);
|
|
totalDistance += distanceFromPrevious.Value;
|
|
}
|
|
|
|
var pointType = isStart ? "start" : (isEnd ? "end" : "action");
|
|
|
|
var routePointDto = new RoutePointDto
|
|
{
|
|
Latitude = currentPoint.Latitude,
|
|
Longitude = currentPoint.Longitude,
|
|
PointType = pointType,
|
|
SequenceNumber = sequenceNumber++,
|
|
SegmentIndex = segmentIndex,
|
|
DistanceFromPrevious = distanceFromPrevious
|
|
};
|
|
|
|
allPoints.Add(routePointDto);
|
|
|
|
if (!isEnd)
|
|
{
|
|
var nextPoint = request.Points[segmentIndex + 1];
|
|
var startGeo = new GeoPoint(currentPoint.Latitude, currentPoint.Longitude);
|
|
var endGeo = new GeoPoint(nextPoint.Latitude, nextPoint.Longitude);
|
|
|
|
var intermediatePoints = GeoUtils.CalculateIntermediatePoints(startGeo, endGeo, MAX_POINT_SPACING_METERS);
|
|
|
|
foreach (var intermediateGeo in intermediatePoints)
|
|
{
|
|
var lastAddedPoint = allPoints[^1];
|
|
var prevGeo = new GeoPoint(lastAddedPoint.Latitude, lastAddedPoint.Longitude);
|
|
|
|
var distFromPrev = GeoUtils.CalculateDistance(prevGeo, intermediateGeo);
|
|
totalDistance += distFromPrev;
|
|
|
|
allPoints.Add(new RoutePointDto
|
|
{
|
|
Latitude = intermediateGeo.Lat,
|
|
Longitude = intermediateGeo.Lon,
|
|
PointType = "intermediate",
|
|
SequenceNumber = sequenceNumber++,
|
|
SegmentIndex = segmentIndex,
|
|
DistanceFromPrevious = distFromPrev
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
var now = DateTime.UtcNow;
|
|
var routeEntity = new RouteEntity
|
|
{
|
|
Id = request.Id,
|
|
Name = request.Name,
|
|
Description = request.Description,
|
|
RegionSizeMeters = request.RegionSizeMeters,
|
|
ZoomLevel = request.ZoomLevel,
|
|
TotalDistanceMeters = totalDistance,
|
|
TotalPoints = allPoints.Count,
|
|
RequestMaps = request.RequestMaps,
|
|
CreateTilesZip = request.CreateTilesZip,
|
|
MapsReady = false,
|
|
CreatedAt = now,
|
|
UpdatedAt = now
|
|
};
|
|
|
|
await _routeRepository.InsertRouteAsync(routeEntity);
|
|
|
|
var pointEntities = allPoints.Select(p => new RoutePointEntity
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
RouteId = request.Id,
|
|
SequenceNumber = p.SequenceNumber,
|
|
Latitude = p.Latitude,
|
|
Longitude = p.Longitude,
|
|
PointType = p.PointType,
|
|
SegmentIndex = p.SegmentIndex,
|
|
DistanceFromPrevious = p.DistanceFromPrevious,
|
|
CreatedAt = now
|
|
}).ToList();
|
|
|
|
await _routeRepository.InsertRoutePointsAsync(pointEntities);
|
|
|
|
if (request.Geofences?.Polygons != null && request.Geofences.Polygons.Count > 0)
|
|
{
|
|
for (int polygonIndex = 0; polygonIndex < request.Geofences.Polygons.Count; polygonIndex++)
|
|
{
|
|
var polygon = request.Geofences.Polygons[polygonIndex];
|
|
|
|
if (polygon.NorthWest is null || polygon.SouthEast is null)
|
|
{
|
|
throw new ArgumentException("Geofence polygon coordinates are required");
|
|
}
|
|
|
|
if ((Math.Abs(polygon.NorthWest.Lat) < 0.0001 && Math.Abs(polygon.NorthWest.Lon) < 0.0001) ||
|
|
(Math.Abs(polygon.SouthEast.Lat) < 0.0001 && Math.Abs(polygon.SouthEast.Lon) < 0.0001))
|
|
{
|
|
throw new ArgumentException("Geofence polygon coordinates cannot be (0,0)");
|
|
}
|
|
|
|
if (polygon.NorthWest.Lat < -90 || polygon.NorthWest.Lat > 90 ||
|
|
polygon.SouthEast.Lat < -90 || polygon.SouthEast.Lat > 90 ||
|
|
polygon.NorthWest.Lon < -180 || polygon.NorthWest.Lon > 180 ||
|
|
polygon.SouthEast.Lon < -180 || polygon.SouthEast.Lon > 180)
|
|
{
|
|
throw new ArgumentException("Geofence polygon coordinates must be valid (lat: -90 to 90, lon: -180 to 180)");
|
|
}
|
|
|
|
if (polygon.NorthWest.Lat <= polygon.SouthEast.Lat)
|
|
{
|
|
throw new ArgumentException("Geofence northWest latitude must be greater than southEast latitude");
|
|
}
|
|
|
|
var geofenceRegions = CreateGeofenceRegionGrid(polygon.NorthWest, polygon.SouthEast, request.RegionSizeMeters);
|
|
|
|
foreach (var geofencePoint in geofenceRegions)
|
|
{
|
|
var geofenceRegionId = Guid.NewGuid();
|
|
|
|
await _regionService.RequestRegionAsync(
|
|
geofenceRegionId,
|
|
geofencePoint.Lat,
|
|
geofencePoint.Lon,
|
|
request.RegionSizeMeters,
|
|
request.ZoomLevel,
|
|
stitchTiles: false);
|
|
|
|
await _routeRepository.LinkRouteToRegionAsync(request.Id, geofenceRegionId, isGeofence: true, geofencePolygonIndex: polygonIndex);
|
|
}
|
|
}
|
|
}
|
|
|
|
return new RouteResponse
|
|
{
|
|
Id = routeEntity.Id,
|
|
Name = routeEntity.Name,
|
|
Description = routeEntity.Description,
|
|
RegionSizeMeters = routeEntity.RegionSizeMeters,
|
|
ZoomLevel = routeEntity.ZoomLevel,
|
|
TotalDistanceMeters = routeEntity.TotalDistanceMeters,
|
|
TotalPoints = routeEntity.TotalPoints,
|
|
Points = allPoints,
|
|
RequestMaps = routeEntity.RequestMaps,
|
|
MapsReady = routeEntity.MapsReady,
|
|
CsvFilePath = routeEntity.CsvFilePath,
|
|
SummaryFilePath = routeEntity.SummaryFilePath,
|
|
StitchedImagePath = routeEntity.StitchedImagePath,
|
|
TilesZipPath = routeEntity.TilesZipPath,
|
|
CreatedAt = routeEntity.CreatedAt,
|
|
UpdatedAt = routeEntity.UpdatedAt
|
|
};
|
|
}
|
|
|
|
public async Task<RouteResponse?> GetRouteAsync(Guid id)
|
|
{
|
|
var route = await _routeRepository.GetByIdAsync(id);
|
|
if (route == null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var points = await _routeRepository.GetRoutePointsAsync(id);
|
|
|
|
return new RouteResponse
|
|
{
|
|
Id = route.Id,
|
|
Name = route.Name,
|
|
Description = route.Description,
|
|
RegionSizeMeters = route.RegionSizeMeters,
|
|
ZoomLevel = route.ZoomLevel,
|
|
TotalDistanceMeters = route.TotalDistanceMeters,
|
|
TotalPoints = route.TotalPoints,
|
|
Points = points.Select(p => new RoutePointDto
|
|
{
|
|
Latitude = p.Latitude,
|
|
Longitude = p.Longitude,
|
|
PointType = p.PointType,
|
|
SequenceNumber = p.SequenceNumber,
|
|
SegmentIndex = p.SegmentIndex,
|
|
DistanceFromPrevious = p.DistanceFromPrevious
|
|
}).ToList(),
|
|
RequestMaps = route.RequestMaps,
|
|
MapsReady = route.MapsReady,
|
|
CsvFilePath = route.CsvFilePath,
|
|
SummaryFilePath = route.SummaryFilePath,
|
|
StitchedImagePath = route.StitchedImagePath,
|
|
TilesZipPath = route.TilesZipPath,
|
|
CreatedAt = route.CreatedAt,
|
|
UpdatedAt = route.UpdatedAt
|
|
};
|
|
}
|
|
|
|
private List<GeoPoint> CreateGeofenceRegionGrid(GeoPoint northWest, GeoPoint southEast, double regionSizeMeters)
|
|
{
|
|
var regions = new List<GeoPoint>();
|
|
|
|
var northPoint = new GeoPoint(northWest.Lat, (northWest.Lon + southEast.Lon) / 2);
|
|
var southPoint = new GeoPoint(southEast.Lat, (northWest.Lon + southEast.Lon) / 2);
|
|
var heightMeters = GeoUtils.CalculateDistance(northPoint, southPoint);
|
|
|
|
var westPoint = new GeoPoint((northWest.Lat + southEast.Lat) / 2, northWest.Lon);
|
|
var eastPoint = new GeoPoint((northWest.Lat + southEast.Lat) / 2, southEast.Lon);
|
|
var widthMeters = GeoUtils.CalculateDistance(westPoint, eastPoint);
|
|
|
|
var numLatSteps = Math.Max(1, (int)Math.Ceiling(heightMeters / regionSizeMeters));
|
|
var numLonSteps = Math.Max(1, (int)Math.Ceiling(widthMeters / regionSizeMeters));
|
|
|
|
var latStep = (northWest.Lat - southEast.Lat) / numLatSteps;
|
|
var lonStep = (southEast.Lon - northWest.Lon) / numLonSteps;
|
|
|
|
for (int latIdx = 0; latIdx < numLatSteps; latIdx++)
|
|
{
|
|
for (int lonIdx = 0; lonIdx < numLonSteps; lonIdx++)
|
|
{
|
|
var lat = northWest.Lat - (latIdx + 0.5) * latStep;
|
|
var lon = northWest.Lon + (lonIdx + 0.5) * lonStep;
|
|
regions.Add(new GeoPoint(lat, lon));
|
|
}
|
|
}
|
|
|
|
return regions;
|
|
}
|
|
}
|
|
|