Add TileProvision configuration and gRPC service for tile delivery
ci/woodpecker/push/01-test Pipeline failed
ci/woodpecker/push/02-build-push unknown status

- Introduced new TileProvision settings in appsettings.json, including MaxTilesPerBatch and ProgressEmitIntervalSeconds.
- Configured TileProvisionConfig in Program.cs to bind the new settings.
- Added gRPC service for RouteTileDelivery in Program.cs to handle tile delivery requests.
- Updated SatelliteProvider.Api.csproj to include Grpc.AspNetCore package and added protobuf file for tile provision.
- Enhanced AuthenticationServiceCollectionExtensions to handle JWT token extraction from the Authorization header.
- Registered additional services in RouteManagementServiceCollectionExtensions for tile processing.

These changes enhance the API's capability to manage tile provisioning and delivery efficiently.
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-06-23 13:18:59 +03:00
parent 62d6b8310a
commit 275ee1b554
22 changed files with 1469 additions and 3 deletions
@@ -0,0 +1,64 @@
using System.Security.Cryptography;
using FluentAssertions;
using SatelliteProvider.Services.RouteManagement.TileProvision;
namespace SatelliteProvider.Tests;
public class ClientTileCatalogTests
{
private static readonly byte[] ServerHash = SHA256.HashData("server-tile"u8.ToArray());
private static readonly byte[] ClientHash = SHA256.HashData("client-tile"u8.ToArray());
[Fact]
public void ShouldSkipForClient_HashMatch_Skips()
{
var client = new ClientTileSnapshot(18, 1, 2, 1.0, DateTime.UtcNow.AddDays(-1), ServerHash);
var server = new ServerTileProspect(2.0, DateTime.UtcNow, ServerHash);
ClientTileCatalog.ShouldSkipForClient(client, server).Should().BeTrue();
}
[Fact]
public void ShouldSkipForClient_MetadataSufficient_Skips()
{
var client = new ClientTileSnapshot(18, 1, 2, 0.5, DateTime.UtcNow, null);
var server = new ServerTileProspect(1.0, DateTime.UtcNow.AddHours(-1), null);
ClientTileCatalog.ShouldSkipForClient(client, server).Should().BeTrue();
}
[Fact]
public void ShouldSkipForClient_WorseResolution_DoesNotSkip()
{
var client = new ClientTileSnapshot(18, 1, 2, 2.0, DateTime.UtcNow, null);
var server = new ServerTileProspect(1.0, DateTime.UtcNow, null);
ClientTileCatalog.ShouldSkipForClient(client, server).Should().BeFalse();
}
[Fact]
public void ShouldSkipForClient_OlderCapture_DoesNotSkip()
{
var client = new ClientTileSnapshot(18, 1, 2, 0.5, DateTime.UtcNow.AddDays(-2), null);
var server = new ServerTileProspect(1.0, DateTime.UtcNow, null);
ClientTileCatalog.ShouldSkipForClient(client, server).Should().BeFalse();
}
[Fact]
public void ShouldSkipForClient_NoClientRecord_DoesNotSkip()
{
var server = new ServerTileProspect(1.0, DateTime.UtcNow, ServerHash);
ClientTileCatalog.ShouldSkipForClient(null, server).Should().BeFalse();
}
[Fact]
public void ShouldSkipForClient_DifferentHashWithInsufficientMetadata_DoesNotSkip()
{
var client = new ClientTileSnapshot(18, 1, 2, 2.0, DateTime.UtcNow.AddDays(-2), ClientHash);
var server = new ServerTileProspect(1.0, DateTime.UtcNow, ServerHash);
ClientTileCatalog.ShouldSkipForClient(client, server).Should().BeFalse();
}
}
@@ -0,0 +1,193 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Moq;
using SatelliteProvider.Common.Configs;
using SatelliteProvider.Common.Enums;
using SatelliteProvider.Common.Interfaces;
using SatelliteProvider.Common.Utils;
using SatelliteProvider.DataAccess.Models;
using SatelliteProvider.DataAccess.Repositories;
using SatelliteProvider.Services.RouteManagement;
using SatelliteProvider.Services.RouteManagement.TileProvision;
namespace SatelliteProvider.Tests;
public class RouteTileDeliveryOrchestratorTests
{
[Fact]
public async Task DeliverAsync_AllTilesSkippedByClient_EmitsManifestAndCompleteWithZeroDelivered()
{
var expander = new RouteTileExpander(
new RoutePointGraphBuilder(Options.Create(new ProcessingConfig { MaxRoutePointSpacingMeters = 200 })),
new GeofenceGridCalculator());
var tileRepo = new Mock<ITileRepository>();
tileRepo
.Setup(r => r.GetTilesByLocationHashesAsync(It.IsAny<IReadOnlyList<Guid>>()))
.ReturnsAsync(new Dictionary<Guid, TileEntity>());
var tileService = new Mock<ITileService>();
var orchestrator = new RouteTileDeliveryOrchestrator(
expander,
tileRepo.Object,
tileService.Object,
Options.Create(new MapConfig { TileSizePixels = 256, AllowedZoomLevels = [18] }),
Options.Create(new ProcessingConfig { MaxConcurrentDownloads = 2 }),
Options.Create(new TileProvisionConfig { MaxTilesPerBatch = 100 }),
NullLogger<RouteTileDeliveryOrchestrator>.Instance);
var waypoints = new List<(double Lat, double Lon)> { (47.0, 37.0), (47.001, 37.001) };
var candidates = expander.Expand(waypoints, 400, 18, [], false);
candidates.Should().NotBeEmpty();
var clientTiles = candidates.Select(c =>
{
var center = GeoUtils.TileToWorldPos(c.X, c.Y, c.Z);
var resolution = TileResolutionHelper.ResolutionMetersPerPixel(c.Z, center.Lat, 256);
return new ClientTileSnapshot(c.Z, c.X, c.Y, resolution, DateTime.UtcNow.AddHours(1), null);
}).ToList();
var sink = new RecordingSink();
var job = new RouteTileDeliveryJob(
Guid.NewGuid(),
waypoints,
400,
18,
[],
false,
clientTiles);
await orchestrator.DeliverAsync(job, sink, CancellationToken.None);
sink.Manifest.Should().NotBeNull();
sink.Manifest!.Value.Total.Should().Be((uint)candidates.Count);
sink.Manifest.Value.Skipped.Should().Be((uint)candidates.Count);
sink.Manifest.Value.ToDeliver.Should().Be(0);
sink.Complete.Should().NotBeNull();
sink.Complete!.Value.Delivered.Should().Be(0);
sink.Complete.Value.SkippedClient.Should().Be((uint)candidates.Count);
sink.Batches.Should().BeEmpty();
tileService.Verify(
s => s.DownloadAndStoreSingleTileAsync(It.IsAny<double>(), It.IsAny<double>(), It.IsAny<int>(), It.IsAny<CancellationToken>()),
Times.Never);
}
[Fact]
public async Task DeliverAsync_CachedTileOnDisk_EmitsBatchWithoutDownload()
{
var tilesDir = Path.Combine(Path.GetTempPath(), "sp-grpc-test-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tilesDir);
try
{
var expander = new RouteTileExpander(
new RoutePointGraphBuilder(Options.Create(new ProcessingConfig { MaxRoutePointSpacingMeters = 200 })),
new GeofenceGridCalculator());
var waypoints = new List<(double Lat, double Lon)> { (47.0, 37.0), (47.001, 37.001) };
var candidates = expander.Expand(waypoints, 400, 18, [], false);
var candidate = candidates[0];
var jpeg = new byte[] { 0xFF, 0xD8, 0xFF, 0xD9 };
var tilePath = Path.Combine(tilesDir, "tile.jpg");
await File.WriteAllBytesAsync(tilePath, jpeg);
var hash = System.Security.Cryptography.SHA256.HashData(jpeg);
var center = GeoUtils.TileToWorldPos(candidate.X, candidate.Y, candidate.Z);
var locationHash = Uuidv5.LocationHashForTile(candidate.Z, candidate.X, candidate.Y);
var entity = new TileEntity
{
Id = Guid.NewGuid(),
TileZoom = candidate.Z,
TileX = candidate.X,
TileY = candidate.Y,
Latitude = center.Lat,
Longitude = center.Lon,
TileSizeMeters = 100,
TileSizePixels = 256,
ImageType = "jpg",
FilePath = tilePath,
Source = TileSourceConverter.GoogleMapsWireValue,
CapturedAt = DateTime.UtcNow,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
LocationHash = locationHash,
ContentSha256 = hash,
};
var tileRepo = new Mock<ITileRepository>();
tileRepo
.Setup(r => r.GetTilesByLocationHashesAsync(It.IsAny<IReadOnlyList<Guid>>()))
.ReturnsAsync((IReadOnlyList<Guid> hashes) =>
{
var dict = new Dictionary<Guid, TileEntity>();
if (hashes.Contains(locationHash))
{
dict[locationHash] = entity;
}
return dict;
});
var tileService = new Mock<ITileService>();
var orchestrator = new RouteTileDeliveryOrchestrator(
expander,
tileRepo.Object,
tileService.Object,
Options.Create(new MapConfig { TileSizePixels = 256, AllowedZoomLevels = [18] }),
Options.Create(new ProcessingConfig { MaxConcurrentDownloads = 2 }),
Options.Create(new TileProvisionConfig { MaxTilesPerBatch = 100 }),
NullLogger<RouteTileDeliveryOrchestrator>.Instance);
var sink = new RecordingSink();
var job = new RouteTileDeliveryJob(Guid.NewGuid(), waypoints, 400, 18, [], false, []);
await orchestrator.DeliverAsync(job, sink, CancellationToken.None);
sink.Manifest.Should().NotBeNull();
sink.Manifest!.Value.ToDeliver.Should().BeGreaterThan(0);
sink.Batches.Should().NotBeEmpty();
sink.Batches.SelectMany(b => b).Should().Contain(t => t.Candidate.X == candidate.X && t.Candidate.Y == candidate.Y);
sink.Complete.Should().NotBeNull();
sink.Complete!.Value.Delivered.Should().BeGreaterThan(0u);
}
finally
{
if (Directory.Exists(tilesDir))
{
Directory.Delete(tilesDir, recursive: true);
}
}
}
private sealed class RecordingSink : IRouteTileDeliverySink
{
public (uint Total, uint Skipped, uint ToDeliver)? Manifest { get; private set; }
public List<IReadOnlyList<PreparedTileDelivery>> Batches { get; } = new();
public (uint Delivered, uint SkippedClient, uint SkippedServer)? Complete { get; private set; }
public ValueTask WriteManifestAsync(uint totalCandidates, uint skippedByClient, uint toDeliver, CancellationToken cancellationToken)
{
Manifest = (totalCandidates, skippedByClient, toDeliver);
return ValueTask.CompletedTask;
}
public ValueTask WriteBatchAsync(uint batchSeq, IReadOnlyList<PreparedTileDelivery> tiles, CancellationToken cancellationToken)
{
Batches.Add(tiles);
return ValueTask.CompletedTask;
}
public ValueTask WriteProgressAsync(uint delivered, uint total, uint downloading, CancellationToken cancellationToken)
=> ValueTask.CompletedTask;
public ValueTask WriteCompleteAsync(uint delivered, uint skippedClient, uint skippedServerFilter, CancellationToken cancellationToken)
{
Complete = (delivered, skippedClient, skippedServerFilter);
return ValueTask.CompletedTask;
}
public ValueTask WriteErrorAsync(string code, string message, bool retryable, CancellationToken cancellationToken)
=> ValueTask.CompletedTask;
}
}
@@ -0,0 +1,68 @@
using FluentAssertions;
using Microsoft.Extensions.Options;
using SatelliteProvider.Common.Configs;
using SatelliteProvider.Services.RouteManagement;
using SatelliteProvider.Services.RouteManagement.TileProvision;
namespace SatelliteProvider.Tests;
public class RouteTileExpanderTests
{
private readonly RouteTileExpander _expander = new(
new RoutePointGraphBuilder(Options.Create(new ProcessingConfig { MaxRoutePointSpacingMeters = 200 })),
new GeofenceGridCalculator());
[Fact]
public void Expand_ShortRoute_ProducesTilesForEachWaypointCorridor()
{
var waypoints = new List<(double Lat, double Lon)>
{
(47.0, 37.0),
(47.001, 37.001),
};
var tiles = _expander.Expand(waypoints, regionSizeMeters: 400, zoom: 18, [], includeGeofenceTiles: false);
tiles.Should().NotBeEmpty();
tiles.Select(t => (t.Z, t.X, t.Y)).Should().OnlyHaveUniqueItems();
tiles.Should().OnlyContain(t => t.Z == 18);
}
[Fact]
public void Expand_LongSegment_AddsIntermediateCorridors()
{
var waypoints = new List<(double Lat, double Lon)>
{
(47.0, 37.0),
(47.01, 37.0),
};
var tiles = _expander.Expand(waypoints, regionSizeMeters: 400, zoom: 18, [], includeGeofenceTiles: false);
tiles.Should().NotBeEmpty();
tiles.Select(t => t.RoutePriority).Max().Should().BeGreaterThan(1u);
}
[Fact]
public void Expand_WithGeofence_IncludesGeofenceTilesWhenRequested()
{
var waypoints = new List<(double Lat, double Lon)>
{
(47.0, 37.0),
(47.001, 37.001),
};
var geofence = new List<(double Lat, double Lon)>
{
(47.002, 37.002),
(47.003, 37.002),
(47.003, 37.003),
(47.002, 37.003),
};
var without = _expander.Expand(waypoints, 400, 18, [], false);
var with = _expander.Expand(waypoints, 400, 18, [geofence], true);
with.Count.Should().BeGreaterThan(without.Count);
}
}