[AZ-503] Tile identity → UUIDv5 + integer UPSERT (foundation)
ci/woodpecker/push/01-test Pipeline was successful
ci/woodpecker/push/02-build-push Pipeline was successful

Foundation half of original AZ-503 (split during /autodev step 10 batch 2
on user choice; deferred work moved to AZ-505 with a Blocks link).

Adds deterministic tile identity (UUIDv5 over (z, x, y, source, flight_id))
shared cross-repo with gps-denied-onboard via the pinned TileNamespace
5b8d0c2e-7f1a-4d3b-9c5e-1f3a8e7d2b6c, switches the tiles UPSERT key from
floats to integers with per-flight separation, plumbs FlightId through
UavTileMetadata + handler, and writes UAV evidence to per-flight
on-disk directories so two flights at the same (z, x, y) coexist.

- Common: pure-C# RFC 9562 Uuidv5 (no third-party dep) + FlightId DTO
  field; 10 Python-reference unit vectors verify byte parity.
- DataAccess: migration 014 adds flight_id (uuid NULL), location_hash
  (uuid NOT NULL, backfilled via session-scoped pg_temp.uuidv5),
  content_sha256 (bytea NULL), legacy_id (uuid NULL = preserves
  pre-AZ-503 random id one cycle); drops idx_tiles_unique_location_source
  (AZ-484) and adds idx_tiles_unique_identity keyed on
  (tile_zoom, tile_x, tile_y, tile_size_meters, source,
   COALESCE(flight_id, '00000000-...'::uuid)) + idx_tiles_location_hash.
- TileRepository: ColumnList + UPSERT updated; id never updated on
  conflict (preserves AC-2 idempotence). UpdateAsync extended.
- Services: TileService and UavTileUploadHandler compute deterministic
  Id + LocationHash + ContentSha256 before insert; UAV file path
  becomes ./tiles/uav/{flight_id or 'none'}/{z}/{x}/{y}.jpg.
- Tests: Uuidv5Tests (10 reference vectors), UavTileFilePathTests
  (per-flight + anonymous paths), UavTileUploadHandlerTests (AC-2,
  AC-3, AC-7, AC-11 unit-level), UavUploadTests (AC-3 + AC-4
  integration: multi-flight DB coexistence with shared location_hash
  + distinct file_path; float-different lat/lon collapse to 1 row),
  MigrationTests (column shape, idx_tiles_unique_identity supersedes
  AZ-484 index, deterministic backfill).
- IntegrationTests project references Common to reuse Uuidv5 in raw
  SQL seeds.
- AZ-488 MultiSourceCoexistence seed fixed to populate location_hash
  (otherwise migration 014's NOT NULL constraint fails).

ACs covered: AC-1, AC-2, AC-3, AC-4, AC-7, AC-8, AC-11.
ACs deferred to AZ-505: AC-5, AC-6, AC-9, AC-10, AC-12.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-12 17:07:35 +03:00
parent f6197499a4
commit c646aa93e2
17 changed files with 1154 additions and 117 deletions
@@ -3,7 +3,9 @@ using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Security.Claims;
using System.Text.Json;
using System.Globalization;
using Npgsql;
using SatelliteProvider.Common.Utils;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Formats.Jpeg;
using SixLabors.ImageSharp.PixelFormats;
@@ -27,6 +29,8 @@ public static class UavUploadTests
await MixedBatch_ReturnsPerItemResults(apiUrl, secret, connectionString);
await MultiSourceCoexistence_AZ484_Cycle2(apiUrl, secret, connectionString);
await SameSourceUpsert_AZ484_Cycle2(apiUrl, secret, connectionString);
await MultiFlightUavRowsCoexist_AZ503_AC3(apiUrl, secret, connectionString);
await FloatRoundingDoesNotBreakIdempotence_AZ503_AC4(apiUrl, secret, connectionString);
await NoToken_Returns401(apiUrl);
await ValidTokenWithoutGpsPermission_Returns403(apiUrl, secret);
await OversizedBatch_Returns400(apiUrl, secret);
@@ -127,19 +131,25 @@ public static class UavUploadTests
Console.WriteLine("AZ-488 AC-3: UAV upload coexists with a pre-seeded google_maps row");
// Arrange — pre-seed a google_maps row at T1 directly via SQL.
// AZ-503: location_hash is NOT NULL after migration 014; compute it
// inline using the same Uuidv5 algorithm production code uses (see
// SatelliteProvider.Services.TileDownloader.TileService.BuildTileEntity).
var coord = NextTestCoordinate();
const int zoom = 18;
const double sizeMeters = 200.0;
var t1 = DateTime.UtcNow.AddHours(-2);
var googleRowId = Guid.NewGuid();
var seedLocationHash = Uuidv5.Create(
Uuidv5.TileNamespace,
string.Create(CultureInfo.InvariantCulture, $"{zoom}/0/0"));
await ExecuteAsync(connectionString, """
INSERT INTO tiles (id, tile_zoom, tile_x, tile_y, latitude, longitude, tile_size_meters,
tile_size_pixels, image_type, file_path, source, captured_at,
created_at, updated_at)
VALUES (@id, @zoom, 0, 0, @lat, @lon, @size, 256, 'jpg', 'tiles/seed.jpg', 'google_maps', @t1, @t1, @t1);
created_at, updated_at, location_hash)
VALUES (@id, @zoom, 0, 0, @lat, @lon, @size, 256, 'jpg', 'tiles/seed.jpg', 'google_maps', @t1, @t1, @t1, @loc);
""",
("id", googleRowId), ("zoom", zoom), ("lat", coord.Latitude), ("lon", coord.Longitude),
("size", sizeMeters), ("t1", t1));
("size", sizeMeters), ("t1", t1), ("loc", seedLocationHash));
var metadata = new
{
@@ -210,6 +220,142 @@ public static class UavUploadTests
Console.WriteLine(" ✓ Same-source UPSERT collapsed to exactly one uav row");
}
private static async Task MultiFlightUavRowsCoexist_AZ503_AC3(string apiUrl, string secret, string connectionString)
{
Console.WriteLine();
Console.WriteLine("AZ-503 AC-3: two UAV uploads at the same (z, x, y) from different flight_ids coexist as distinct DB rows sharing the same location_hash");
// Arrange — two distinct flightIds, identical lat/lon/zoom/size.
var coord = NextTestCoordinate();
const int zoom = 18;
const double sizeMeters = 200.0;
var flightA = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
var flightB = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
using var client = CreateClient(apiUrl);
AttachToken(client, JwtTestHelpers.MintAuthenticated(secret, extraClaims: GpsClaim()));
var metaA = new
{
items = new[]
{
new { latitude = coord.Latitude, longitude = coord.Longitude, tileZoom = zoom, tileSizeMeters = sizeMeters, capturedAt = DateTime.UtcNow.AddMinutes(-10).ToString("o"), flightId = flightA }
}
};
var metaB = new
{
items = new[]
{
new { latitude = coord.Latitude, longitude = coord.Longitude, tileZoom = zoom, tileSizeMeters = sizeMeters, capturedAt = DateTime.UtcNow.ToString("o"), flightId = flightB }
}
};
// Act
var first = await PostBatch(client, metaA, new[] { CreateValidJpeg(seed: 11) });
await EnsureStatus(first, HttpStatusCode.OK, "AC-3 first flight upload");
var second = await PostBatch(client, metaB, new[] { CreateValidJpeg(seed: 22) });
await EnsureStatus(second, HttpStatusCode.OK, "AC-3 second flight upload");
// Assert
var rows = await QueryUavRowsByFlightAsync(connectionString, coord.Latitude, coord.Longitude, zoom, sizeMeters);
if (rows.Count != 2)
{
throw new Exception(
$"AZ-503 AC-3: expected 2 distinct uav rows for the same cell with different flight_ids, got {rows.Count}. Rows: [{string.Join(", ", rows.Select(r => $"flight_id={r.FlightId} id={r.Id}"))}]");
}
if (!rows.Any(r => r.FlightId == flightA) || !rows.Any(r => r.FlightId == flightB))
{
throw new Exception(
$"AZ-503 AC-3: expected rows with flight_id={flightA} AND flight_id={flightB}, got [{string.Join(", ", rows.Select(r => r.FlightId?.ToString() ?? "NULL"))}]");
}
var ids = rows.Select(r => r.Id).Distinct().ToList();
if (ids.Count != 2)
{
throw new Exception($"AZ-503 AC-3: per-flight rows must have distinct ids, got {ids.Count} distinct id(s).");
}
var locationHashes = rows.Select(r => r.LocationHash).Distinct().ToList();
if (locationHashes.Count != 1)
{
throw new Exception(
$"AZ-503 AC-3: per-flight rows must share the same location_hash (same (z, x, y)), got {locationHashes.Count} distinct hashes: [{string.Join(", ", locationHashes)}]");
}
// AC-11 cross-check at the DB level: each row's file_path embeds its flight_id.
var rowA = rows.Single(r => r.FlightId == flightA);
var rowB = rows.Single(r => r.FlightId == flightB);
if (!rowA.FilePath.Contains(flightA.ToString()) || !rowB.FilePath.Contains(flightB.ToString()))
{
throw new Exception(
$"AZ-503 AC-11: per-flight file_path must contain the flight_id segment. " +
$"rowA.file_path='{rowA.FilePath}', rowB.file_path='{rowB.FilePath}'.");
}
if (string.Equals(rowA.FilePath, rowB.FilePath, StringComparison.Ordinal))
{
throw new Exception(
$"AZ-503 AC-11: per-flight file_path must differ between flights, got identical '{rowA.FilePath}'.");
}
Console.WriteLine($" ✓ Two distinct uav rows for flight_id={flightA} and flight_id={flightB} coexist");
Console.WriteLine($" ✓ Both rows share location_hash={locationHashes[0]}");
Console.WriteLine($" ✓ Per-flight file_path differs ({rowA.FilePath} != {rowB.FilePath})");
}
private static async Task FloatRoundingDoesNotBreakIdempotence_AZ503_AC4(string apiUrl, string secret, string connectionString)
{
Console.WriteLine();
Console.WriteLine("AZ-503 AC-4: two UAV uploads for the same (z, x, y) with float-different lat/lon collapse to one row");
// Arrange — same (z, x, y) coords but two slightly-different lat/lon values.
// The new integer-keyed UPSERT must collapse them; the AZ-484 lat/lon-keyed
// UPSERT would have left two duplicate rows.
var coord = NextTestCoordinate();
const int zoom = 18;
const double sizeMeters = 200.0;
var flightId = Guid.Parse("cccccccc-cccc-cccc-cccc-cccccccccccc");
using var client = CreateClient(apiUrl);
AttachToken(client, JwtTestHelpers.MintAuthenticated(secret, extraClaims: GpsClaim()));
// First upload: exact center of the cell as returned by NextTestCoordinate.
var firstMeta = new
{
items = new[]
{
new { latitude = coord.Latitude, longitude = coord.Longitude, tileZoom = zoom, tileSizeMeters = sizeMeters, capturedAt = DateTime.UtcNow.AddMinutes(-20).ToString("o"), flightId }
}
};
// Second upload: a coordinate offset by < 1 m so it lands in the same (tile_x,
// tile_y) bucket but with a different float bit pattern.
var nudgedLat = coord.Latitude + 1e-7;
var nudgedLon = coord.Longitude + 1e-7;
var secondMeta = new
{
items = new[]
{
new { latitude = nudgedLat, longitude = nudgedLon, tileZoom = zoom, tileSizeMeters = sizeMeters, capturedAt = DateTime.UtcNow.ToString("o"), flightId }
}
};
// Act
var first = await PostBatch(client, firstMeta, new[] { CreateValidJpeg(seed: 31) });
await EnsureStatus(first, HttpStatusCode.OK, "AC-4 first upload");
var second = await PostBatch(client, secondMeta, new[] { CreateValidJpeg(seed: 32) });
await EnsureStatus(second, HttpStatusCode.OK, "AC-4 second upload");
// Assert
var rows = await QueryUavRowsByFlightAsync(connectionString, coord.Latitude, coord.Longitude, zoom, sizeMeters, alsoTryLatitude: nudgedLat, alsoTryLongitude: nudgedLon);
var flightRows = rows.Where(r => r.FlightId == flightId).ToList();
if (flightRows.Count != 1)
{
throw new Exception(
$"AZ-503 AC-4: expected exactly 1 uav row after float-different upload (integer-keyed UPSERT must collapse), got {flightRows.Count}. " +
$"Rows: [{string.Join(", ", flightRows.Select(r => $"id={r.Id} lat={r.Latitude} lon={r.Longitude}"))}]");
}
Console.WriteLine(" ✓ Two uploads at float-different lat/lon but same (tile_x, tile_y) collapsed to a single row");
}
private static async Task NoToken_Returns401(string apiUrl)
{
Console.WriteLine();
@@ -402,6 +548,56 @@ public static class UavUploadTests
return sources;
}
private sealed record UavRowProjection(Guid Id, Guid? FlightId, Guid LocationHash, double Latitude, double Longitude, string FilePath);
private static async Task<List<UavRowProjection>> QueryUavRowsByFlightAsync(
string connectionString,
double latitude,
double longitude,
int zoom,
double sizeMeters,
double? alsoTryLatitude = null,
double? alsoTryLongitude = null)
{
await using var conn = new NpgsqlConnection(connectionString);
await conn.OpenAsync();
// The UPSERT preserves the latitude/longitude of the row that won the
// race; for AC-3 / AC-4 we need to find rows produced from EITHER input
// coordinate, so widen the lookup by a few meters of float wiggle room.
const string sql = @"
SELECT id, flight_id, location_hash, latitude, longitude, file_path
FROM tiles
WHERE source = 'uav'
AND tile_zoom = @zoom
AND tile_size_meters = @size
AND (
(latitude = @lat AND longitude = @lon)
OR (latitude = @lat2 AND longitude = @lon2)
);";
var rows = new List<UavRowProjection>();
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("lat", latitude);
cmd.Parameters.AddWithValue("lon", longitude);
cmd.Parameters.AddWithValue("lat2", alsoTryLatitude ?? latitude);
cmd.Parameters.AddWithValue("lon2", alsoTryLongitude ?? longitude);
cmd.Parameters.AddWithValue("zoom", zoom);
cmd.Parameters.AddWithValue("size", sizeMeters);
await using var reader = await cmd.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
rows.Add(new UavRowProjection(
reader.GetGuid(0),
reader.IsDBNull(1) ? null : reader.GetGuid(1),
reader.GetGuid(2),
reader.GetDouble(3),
reader.GetDouble(4),
reader.GetString(5)));
}
return rows;
}
private static async Task ExecuteAsync(string connectionString, string sql, params (string Name, object Value)[] parameters)
{
await using var conn = new NpgsqlConnection(connectionString);