mirror of
https://github.com/azaion/satellite-provider.git
synced 2026-06-27 09:51:14 +00:00
304 lines
10 KiB
C#
304 lines
10 KiB
C#
using System.Diagnostics;
|
|
using System.Security.Claims;
|
|
using Grpc.Core;
|
|
using Satellite.V1;
|
|
using SatelliteProvider.TestSupport;
|
|
using SixLabors.ImageSharp;
|
|
using SixLabors.ImageSharp.Formats.Jpeg;
|
|
using SixLabors.ImageSharp.PixelFormats;
|
|
|
|
namespace SatelliteProvider.IntegrationTests;
|
|
|
|
// AZ-492: bootstrap helpers invoked by scripts/run-performance-tests.sh.
|
|
// Each helper is a short-circuit subcommand that prints/writes its output
|
|
// and exits before the integration-test runner does any HTTP or DB work.
|
|
// All token-minting goes through the canonical JwtTokenFactory (AZ-491)
|
|
// so the shell script does NOT inline a third copy of the JWT logic.
|
|
internal static class PerfBootstrap
|
|
{
|
|
public const string PerfSubject = "perf-tests";
|
|
public const string GpsPermission = "GPS";
|
|
public const string PermissionsClaimType = "permissions";
|
|
public static readonly TimeSpan PerfTokenLifetime = TimeSpan.FromHours(4);
|
|
|
|
public static int MintToken()
|
|
{
|
|
string secret;
|
|
string issuer;
|
|
string audience;
|
|
try
|
|
{
|
|
secret = JwtTestHelpers.ResolveSecretOrThrow();
|
|
issuer = JwtTestHelpers.ResolveIssuerOrThrow();
|
|
audience = JwtTestHelpers.ResolveAudienceOrThrow();
|
|
}
|
|
catch (InvalidOperationException ex)
|
|
{
|
|
Console.Error.WriteLine($"--mint-only: {ex.Message}");
|
|
return 1;
|
|
}
|
|
|
|
var token = JwtTokenFactory.Create(
|
|
secret,
|
|
PerfSubject,
|
|
PerfTokenLifetime,
|
|
new[] { new Claim(PermissionsClaimType, GpsPermission) },
|
|
issuer: issuer,
|
|
audience: audience);
|
|
|
|
Console.Out.Write(token);
|
|
return 0;
|
|
}
|
|
|
|
public static int GenerateUavFixture(string[] args)
|
|
{
|
|
if (args.Length < 2 || string.IsNullOrWhiteSpace(args[1]))
|
|
{
|
|
Console.Error.WriteLine("--gen-uav-fixture: missing output path. Usage: --gen-uav-fixture <path>");
|
|
return 2;
|
|
}
|
|
|
|
var path = args[1];
|
|
var directory = Path.GetDirectoryName(Path.GetFullPath(path));
|
|
if (!string.IsNullOrEmpty(directory))
|
|
{
|
|
Directory.CreateDirectory(directory);
|
|
}
|
|
|
|
var bytes = CreateValidJpeg();
|
|
File.WriteAllBytes(path, bytes);
|
|
Console.Out.WriteLine(path);
|
|
return 0;
|
|
}
|
|
|
|
// Mirrors the random-noise JPEG produced by UavUploadTests.CreateValidJpeg so
|
|
// that the perf harness exercises the same quality-gate path as the integration
|
|
// tests. Pixel pattern is high-variance enough to pass the UAV quality gate.
|
|
private static byte[] CreateValidJpeg(int width = 256, int height = 256, int seed = 42)
|
|
{
|
|
using var image = new Image<Rgba32>(width, height);
|
|
var random = new Random(seed);
|
|
image.ProcessPixelRows(accessor =>
|
|
{
|
|
for (var y = 0; y < accessor.Height; y++)
|
|
{
|
|
var row = accessor.GetRowSpan(y);
|
|
for (var x = 0; x < row.Length; x++)
|
|
{
|
|
row[x] = new Rgba32(
|
|
(byte)random.Next(256),
|
|
(byte)random.Next(256),
|
|
(byte)random.Next(256));
|
|
}
|
|
}
|
|
});
|
|
|
|
using var stream = new MemoryStream();
|
|
image.Save(stream, new JpegEncoder { Quality = 95 });
|
|
return stream.ToArray();
|
|
}
|
|
|
|
private static bool TryResolvePerfJwt(out string token)
|
|
{
|
|
var preMinted = Environment.GetEnvironmentVariable("PERF_JWT_TOKEN");
|
|
if (!string.IsNullOrWhiteSpace(preMinted))
|
|
{
|
|
token = preMinted;
|
|
return true;
|
|
}
|
|
|
|
try
|
|
{
|
|
var secret = JwtTestHelpers.ResolveSecretOrThrow();
|
|
var issuer = JwtTestHelpers.ResolveIssuerOrThrow();
|
|
var audience = JwtTestHelpers.ResolveAudienceOrThrow();
|
|
token = JwtTokenFactory.Create(
|
|
secret,
|
|
PerfSubject,
|
|
PerfTokenLifetime,
|
|
new[] { new Claim(PermissionsClaimType, GpsPermission) },
|
|
issuer: issuer,
|
|
audience: audience);
|
|
return true;
|
|
}
|
|
catch (InvalidOperationException ex)
|
|
{
|
|
Console.Error.WriteLine($"--run-pt10: {ex.Message}");
|
|
token = string.Empty;
|
|
return false;
|
|
}
|
|
}
|
|
|
|
public static async Task<int> RunPt10Async()
|
|
{
|
|
var apiUrl = Environment.GetEnvironmentVariable("API_URL") ?? "https://localhost:18980";
|
|
var repeatCount = int.TryParse(Environment.GetEnvironmentVariable("PERF_REPEAT_COUNT"), out var n) ? n : 20;
|
|
var slowMs = int.TryParse(Environment.GetEnvironmentVariable("PERF_PT10_SLOW_MS"), out var slow) ? slow : 50;
|
|
|
|
if (repeatCount < 1)
|
|
{
|
|
Console.Error.WriteLine("--run-pt10: PERF_REPEAT_COUNT must be >= 1");
|
|
return 1;
|
|
}
|
|
|
|
string token;
|
|
if (!TryResolvePerfJwt(out token))
|
|
{
|
|
return 1;
|
|
}
|
|
|
|
RouteTileDelivery.RouteTileDeliveryClient client;
|
|
try
|
|
{
|
|
client = GrpcTestHelpers.CreateClient(apiUrl, token);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Console.Error.WriteLine($"--run-pt10: failed to open gRPC channel to {apiUrl}: {ex.Message}");
|
|
return 1;
|
|
}
|
|
|
|
var headers = GrpcTestHelpers.AuthMetadata(token);
|
|
var firstBatchTimes = new List<long>();
|
|
var totalStreamTimes = new List<long>();
|
|
var failed = 0;
|
|
|
|
for (var i = 0; i < repeatCount; i++)
|
|
{
|
|
var result = await ProbeStreamAsync(client, headers, Guid.NewGuid(), delayMsBetweenEvents: null);
|
|
if (result.Success)
|
|
{
|
|
firstBatchTimes.Add(result.FirstBatchMs);
|
|
totalStreamTimes.Add(result.TotalStreamMs);
|
|
Console.Error.WriteLine($" iteration {i + 1}/{repeatCount}: first_batch_ms={result.FirstBatchMs} total_stream_ms={result.TotalStreamMs}");
|
|
}
|
|
else
|
|
{
|
|
failed++;
|
|
Console.Error.WriteLine($" iteration {i + 1}/{repeatCount}: FAILED — {result.FailureReason}");
|
|
}
|
|
}
|
|
|
|
if (firstBatchTimes.Count == 0)
|
|
{
|
|
Console.Error.WriteLine("--run-pt10: zero successful iterations");
|
|
Console.Out.WriteLine("PT10_ITERATIONS_OK=0");
|
|
Console.Out.WriteLine($"PT10_ITERATIONS_FAILED={failed}");
|
|
Console.Out.WriteLine("PT10_SLOW_CONSUMER=FAIL");
|
|
return 1;
|
|
}
|
|
|
|
var slowResult = await ProbeStreamAsync(client, headers, Guid.NewGuid(), delayMsBetweenEvents: slowMs);
|
|
var slowPass = slowResult.Success && slowResult.TileCount >= 1;
|
|
|
|
Console.Out.WriteLine($"PT10_FIRST_BATCH_P50={Percentile(firstBatchTimes, 50)}");
|
|
Console.Out.WriteLine($"PT10_FIRST_BATCH_P95={Percentile(firstBatchTimes, 95)}");
|
|
Console.Out.WriteLine($"PT10_TOTAL_STREAM_P50={Percentile(totalStreamTimes, 50)}");
|
|
Console.Out.WriteLine($"PT10_TOTAL_STREAM_P95={Percentile(totalStreamTimes, 95)}");
|
|
Console.Out.WriteLine($"PT10_ITERATIONS_OK={firstBatchTimes.Count}");
|
|
Console.Out.WriteLine($"PT10_ITERATIONS_FAILED={failed}");
|
|
Console.Out.WriteLine($"PT10_SLOW_CONSUMER={(slowPass ? "PASS" : "FAIL")}");
|
|
if (!slowPass)
|
|
{
|
|
Console.Error.WriteLine($"--run-pt10: slow-consumer check failed — {slowResult.FailureReason ?? "no tiles received"}");
|
|
return 1;
|
|
}
|
|
|
|
return failed > 0 ? 1 : 0;
|
|
}
|
|
|
|
internal static long Percentile(IReadOnlyList<long> values, int pct)
|
|
{
|
|
if (values.Count == 0)
|
|
{
|
|
return 0;
|
|
}
|
|
|
|
var sorted = values.OrderBy(v => v).ToList();
|
|
var idx = (int)Math.Ceiling(sorted.Count * pct / 100.0) - 1;
|
|
idx = Math.Clamp(idx, 0, sorted.Count - 1);
|
|
return sorted[idx];
|
|
}
|
|
|
|
private static async Task<StreamProbeResult> ProbeStreamAsync(
|
|
RouteTileDelivery.RouteTileDeliveryClient client,
|
|
Metadata headers,
|
|
Guid routeId,
|
|
int? delayMsBetweenEvents)
|
|
{
|
|
var request = GrpcTestHelpers.BuildValidRequest(routeId: routeId);
|
|
var sw = Stopwatch.StartNew();
|
|
long firstBatchMs = -1;
|
|
var tileCount = 0;
|
|
DeliveryComplete? complete = null;
|
|
DeliveryError? error = null;
|
|
|
|
try
|
|
{
|
|
using var call = client.DeliverRouteTiles(request, headers);
|
|
await foreach (var evt in call.ResponseStream.ReadAllAsync())
|
|
{
|
|
switch (evt.PayloadCase)
|
|
{
|
|
case RouteTileEvent.PayloadOneofCase.Batch:
|
|
if (firstBatchMs < 0)
|
|
{
|
|
firstBatchMs = sw.ElapsedMilliseconds;
|
|
}
|
|
|
|
tileCount += evt.Batch.Tiles.Count;
|
|
break;
|
|
case RouteTileEvent.PayloadOneofCase.Complete:
|
|
complete = evt.Complete;
|
|
break;
|
|
case RouteTileEvent.PayloadOneofCase.Error:
|
|
error = evt.Error;
|
|
break;
|
|
}
|
|
|
|
if (delayMsBetweenEvents.HasValue)
|
|
{
|
|
await Task.Delay(delayMsBetweenEvents.Value);
|
|
}
|
|
}
|
|
}
|
|
catch (RpcException ex)
|
|
{
|
|
return StreamProbeResult.Fail($"RpcException {ex.StatusCode}: {ex.Status.Detail}");
|
|
}
|
|
|
|
var totalStreamMs = sw.ElapsedMilliseconds;
|
|
|
|
if (error is not null)
|
|
{
|
|
return StreamProbeResult.Fail($"DeliveryError {error.Code}: {error.Message}");
|
|
}
|
|
|
|
if (complete is null)
|
|
{
|
|
return StreamProbeResult.Fail("stream ended without DeliveryComplete");
|
|
}
|
|
|
|
if (firstBatchMs < 0)
|
|
{
|
|
return StreamProbeResult.Fail("no Batch event before DeliveryComplete");
|
|
}
|
|
|
|
return StreamProbeResult.Ok(firstBatchMs, totalStreamMs, tileCount);
|
|
}
|
|
|
|
private readonly record struct StreamProbeResult(
|
|
bool Success,
|
|
long FirstBatchMs,
|
|
long TotalStreamMs,
|
|
int TileCount,
|
|
string? FailureReason)
|
|
{
|
|
public static StreamProbeResult Ok(long firstBatchMs, long totalStreamMs, int tileCount) =>
|
|
new(true, firstBatchMs, totalStreamMs, tileCount, null);
|
|
|
|
public static StreamProbeResult Fail(string reason) =>
|
|
new(false, 0, 0, 0, reason);
|
|
}
|
|
}
|