using System.Diagnostics;
using System.Globalization;
using System.Net.Http.Headers;
using Azaion.Missions.E2E.Fixtures;
using Azaion.Missions.E2E.Helpers;
using Npgsql;
using Xunit;
namespace Azaion.Missions.E2E.Tests.Performance;
///
/// NFT-PERF-01..04 — wall-clock latency observations against the dockerised
/// missions service. Excluded from the default CI gate via
/// --filter "Category!=Perf" in entrypoint.sh; run via
/// scripts/run-performance-tests.sh.
///
///
/// Each scenario follows the same shape: seed deterministic data, warm-up
/// 5 calls (excluded from the percentile), run N measured sequential calls
/// recording wall-clock, compute P50 + P95, record
/// them to the runtime CSV referenced by PERF_RESULTS_FILE, then
/// assert against the documented gate. Sequential single-client execution
/// keeps HTTP/1.1 connection-reuse and JIT warm-up deterministic.
///
[Collection("Perf")]
[Trait("Category", "Perf")]
public sealed class PerformanceTests : TestBase, IClassFixture
{
private static readonly MetricCsvRecorder Csv = new("PERF_RESULTS_FILE");
private const int WarmupCalls = 5;
[Fact(Timeout = 60_000)]
[Trait("Traces", "AC-3.6")]
[Trait("max_ms", "30000")]
public async Task NFT_PERF_01_minimal_cascade_delete_p50_within_50ms()
{
// Arrange — 105 missions (100 measured + 5 warmup), each with one waypoint.
DbResetFixture.ResetDatabase(TestEnvironment.DbSideChannel);
Seeds.Apply(Seeds.OneDefaultVehicle.Sql);
var (measured, warmup) = SeedSequentialMissions(105, waypointsPerMission: 1);
await AttachAuthAsync();
await WarmupDeletesAsync(warmup);
// Act
var latenciesMs = await MeasureSequentialDeletesAsync(measured);
var p50 = LatencyPercentiles.P50(latenciesMs);
var p95 = LatencyPercentiles.P95(latenciesMs);
Csv.Record(
category: "Perf",
scenario: "NFT-PERF-01",
result: p50 <= 50.0 ? "pass" : "fail",
traces: $"AC-3.6; P50_MS={p50.ToString("F2", CultureInfo.InvariantCulture)}; "
+ $"P95_MS={p95.ToString("F2", CultureInfo.InvariantCulture)}");
// Assert
Assert.True(p50 <= 50.0,
$"NFT-PERF-01 P50 budget exceeded: P50={p50:F2}ms (gate=50ms), P95={p95:F2}ms");
}
[Fact(Timeout = 120_000)]
[Trait("Traces", "AC-3.1,AC-3.6")]
[Trait("max_ms", "60000")]
[Trait("provisional", "yes")]
public async Task NFT_PERF_02_full_chain_cascade_delete_p50_within_200ms_provisional()
{
// PROVISIONAL — lock at measured + 50% on first green run.
// Arrange — 55 F3-shaped missions (50 measured + 5 warmup).
DbResetFixture.ResetDatabase(TestEnvironment.DbSideChannel);
Seeds.Apply(Seeds.OneDefaultVehicle.Sql);
StubSchema.EnsureCreated();
var (measured, warmup) = SeedF3MissionsCascadeChains(55);
await AttachAuthAsync();
await WarmupDeletesAsync(warmup);
// Act
var latenciesMs = await MeasureSequentialDeletesAsync(measured);
var p50 = LatencyPercentiles.P50(latenciesMs);
var p95 = LatencyPercentiles.P95(latenciesMs);
Csv.Record(
category: "Perf",
scenario: "NFT-PERF-02",
result: p50 <= 200.0 ? "pass" : "fail",
traces: $"AC-3.1; P50_MS={p50.ToString("F2", CultureInfo.InvariantCulture)}; "
+ $"P95_MS={p95.ToString("F2", CultureInfo.InvariantCulture)}");
// Assert
Assert.True(p50 <= 200.0,
$"NFT-PERF-02 P50 (provisional 200ms) exceeded: P50={p50:F2}ms, P95={p95:F2}ms");
}
[Fact(Timeout = 30_000)]
[Trait("Traces", "AC-7.3")]
[Trait("max_ms", "5000")]
public async Task NFT_PERF_03_health_p50_within_10ms()
{
// Arrange — no seed needed; /health is anonymous.
for (int i = 0; i < WarmupCalls; i++)
{
using var resp = await Missions.GetAsync("/health");
resp.EnsureSuccessStatusCode();
}
// Act
var latenciesMs = new List(100);
for (int i = 0; i < 100; i++)
{
var sw = Stopwatch.StartNew();
using var resp = await Missions.GetAsync("/health");
sw.Stop();
resp.EnsureSuccessStatusCode();
latenciesMs.Add(sw.Elapsed.TotalMilliseconds);
}
var p50 = LatencyPercentiles.P50(latenciesMs);
var p95 = LatencyPercentiles.P95(latenciesMs);
Csv.Record(
category: "Perf",
scenario: "NFT-PERF-03",
result: p50 <= 10.0 ? "pass" : "fail",
traces: $"AC-7.3; P50_MS={p50.ToString("F2", CultureInfo.InvariantCulture)}; "
+ $"P95_MS={p95.ToString("F2", CultureInfo.InvariantCulture)}");
// Assert
Assert.True(p50 <= 10.0,
$"NFT-PERF-03 P50 budget exceeded: P50={p50:F2}ms (gate=10ms), P95={p95:F2}ms");
}
[Fact(Timeout = 90_000)]
[Trait("Traces", "AC-2.3")]
[Trait("max_ms", "30000")]
[Trait("provisional", "yes")]
public async Task NFT_PERF_04_missions_list_pagination_p95_within_100ms_provisional()
{
// PROVISIONAL — lock at measured + 50% on first green run.
// Arrange — 1000 missions referencing seed_one_default_vehicle.
DbResetFixture.ResetDatabase(TestEnvironment.DbSideChannel);
Seeds.Apply(Seeds.OneDefaultVehicle.Sql);
SeedSequentialMissionsNoWaypoints(1000);
await AttachAuthAsync();
for (int i = 0; i < WarmupCalls; i++)
{
using var resp = await Missions.GetAsync("/missions?page=1&pageSize=20");
resp.EnsureSuccessStatusCode();
}
// Act
var latenciesMs = new List(100);
for (int i = 0; i < 100; i++)
{
var sw = Stopwatch.StartNew();
using var resp = await Missions.GetAsync("/missions?page=1&pageSize=20");
sw.Stop();
resp.EnsureSuccessStatusCode();
latenciesMs.Add(sw.Elapsed.TotalMilliseconds);
}
var p50 = LatencyPercentiles.P50(latenciesMs);
var p95 = LatencyPercentiles.P95(latenciesMs);
Csv.Record(
category: "Perf",
scenario: "NFT-PERF-04",
result: p95 <= 100.0 ? "pass" : "fail",
traces: $"AC-2.3; P50_MS={p50.ToString("F2", CultureInfo.InvariantCulture)}; "
+ $"P95_MS={p95.ToString("F2", CultureInfo.InvariantCulture)}");
// Assert
Assert.True(p95 <= 100.0,
$"NFT-PERF-04 P95 (provisional 100ms) exceeded: P50={p50:F2}ms, P95={p95:F2}ms");
}
private async Task AttachAuthAsync()
{
var t = await Tokens.MintDefaultAsync();
Missions.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", t.Jwt);
}
private async Task WarmupDeletesAsync(IReadOnlyList warmupMissionIds)
{
foreach (var id in warmupMissionIds)
{
using var resp = await Missions.DeleteAsync($"/missions/{id}");
// 200 or 204 are both acceptable; the cascade walks regardless.
// 4xx would indicate a seed problem — fail loudly.
if (!resp.IsSuccessStatusCode && (int)resp.StatusCode != 404)
throw new InvalidOperationException(
$"warmup DELETE /missions/{id} returned {(int)resp.StatusCode}");
}
}
private async Task> MeasureSequentialDeletesAsync(IReadOnlyList missionIds)
{
var latencies = new List(missionIds.Count);
foreach (var id in missionIds)
{
var sw = Stopwatch.StartNew();
using var resp = await Missions.DeleteAsync($"/missions/{id}");
sw.Stop();
if (!resp.IsSuccessStatusCode && (int)resp.StatusCode != 404)
throw new InvalidOperationException(
$"measured DELETE /missions/{id} returned {(int)resp.StatusCode}");
latencies.Add(sw.Elapsed.TotalMilliseconds);
}
return latencies;
}
///
/// Returns (measured, warmup) where the FIRST 5 IDs are the warmup set
/// and the remaining (count-5) IDs are the measured set. Each mission
/// gets the requested number of waypoints with deterministic IDs.
///
private static (List Measured, List Warmup) SeedSequentialMissions(
int count, int waypointsPerMission)
{
using var conn = new NpgsqlConnection(TestEnvironment.DbSideChannel);
conn.Open();
using var tx = conn.BeginTransaction();
var ids = new List(count);
var seed = new Random(98765);
using (var insertMission = conn.CreateCommand())
{
insertMission.Transaction = tx;
insertMission.CommandText = """
INSERT INTO missions (id, name, vehicle_id)
VALUES (@id, @name, @vehicle_id);
""";
insertMission.Parameters.Add(new NpgsqlParameter("id", NpgsqlTypes.NpgsqlDbType.Uuid));
insertMission.Parameters.Add(new NpgsqlParameter("name", NpgsqlTypes.NpgsqlDbType.Text));
insertMission.Parameters.Add(new NpgsqlParameter("vehicle_id", NpgsqlTypes.NpgsqlDbType.Uuid));
using var insertWaypoint = conn.CreateCommand();
insertWaypoint.Transaction = tx;
insertWaypoint.CommandText = """
INSERT INTO waypoints (id, mission_id, lat, lon, mgrs, order_num)
VALUES (@id, @mission_id, 50.45, 30.52, '36UYA1234567', @order_num);
""";
insertWaypoint.Parameters.Add(new NpgsqlParameter("id", NpgsqlTypes.NpgsqlDbType.Uuid));
insertWaypoint.Parameters.Add(new NpgsqlParameter("mission_id", NpgsqlTypes.NpgsqlDbType.Uuid));
insertWaypoint.Parameters.Add(new NpgsqlParameter("order_num", NpgsqlTypes.NpgsqlDbType.Integer));
for (int i = 0; i < count; i++)
{
var id = NewDeterministicGuid(seed);
ids.Add(id);
insertMission.Parameters["id"].Value = id;
insertMission.Parameters["name"].Value = $"perf-mission-{i:D4}";
insertMission.Parameters["vehicle_id"].Value = Seeds.OneDefaultVehicle.Id;
insertMission.ExecuteNonQuery();
for (int w = 0; w < waypointsPerMission; w++)
{
insertWaypoint.Parameters["id"].Value = NewDeterministicGuid(seed);
insertWaypoint.Parameters["mission_id"].Value = id;
insertWaypoint.Parameters["order_num"].Value = w;
insertWaypoint.ExecuteNonQuery();
}
}
}
tx.Commit();
var warmup = ids.Take(WarmupCalls).ToList();
var measured = ids.Skip(WarmupCalls).ToList();
return (measured, warmup);
}
private static void SeedSequentialMissionsNoWaypoints(int count)
{
using var conn = new NpgsqlConnection(TestEnvironment.DbSideChannel);
conn.Open();
using var tx = conn.BeginTransaction();
using var cmd = conn.CreateCommand();
cmd.Transaction = tx;
cmd.CommandText = """
INSERT INTO missions (id, name, vehicle_id)
VALUES (@id, @name, @vehicle_id);
""";
cmd.Parameters.Add(new NpgsqlParameter("id", NpgsqlTypes.NpgsqlDbType.Uuid));
cmd.Parameters.Add(new NpgsqlParameter("name", NpgsqlTypes.NpgsqlDbType.Text));
cmd.Parameters.Add(new NpgsqlParameter("vehicle_id", NpgsqlTypes.NpgsqlDbType.Uuid));
var seed = new Random(13579);
for (int i = 0; i < count; i++)
{
cmd.Parameters["id"].Value = NewDeterministicGuid(seed);
cmd.Parameters["name"].Value = $"list-perf-{i:D4}";
cmd.Parameters["vehicle_id"].Value = Seeds.OneDefaultVehicle.Id;
cmd.ExecuteNonQuery();
}
tx.Commit();
}
///
/// Seeds missions, each with the F3 cascade shape:
/// 3 map_objects + 2 waypoints + (per waypoint: 2 media → 2 annotations → 2 detection).
///
private static (List Measured, List Warmup) SeedF3MissionsCascadeChains(int count)
{
using var conn = new NpgsqlConnection(TestEnvironment.DbSideChannel);
conn.Open();
var ids = new List(count);
var seed = new Random(24680);
for (int i = 0; i < count; i++)
{
using var tx = conn.BeginTransaction();
var missionId = NewDeterministicGuid(seed);
ids.Add(missionId);
ExecScalar(conn, tx, """
INSERT INTO missions (id, name, vehicle_id) VALUES (@id, @name, @vid);
""", ("id", missionId), ("name", $"f3-perf-{i:D4}"),
("vid", Seeds.OneDefaultVehicle.Id));
for (int m = 0; m < 3; m++)
ExecScalar(conn, tx, """
INSERT INTO map_objects (id, mission_id, h3_index, mgrs)
VALUES (@id, @mid, '8a2a1072b59ffff', '36UYA1234567');
""", ("id", NewDeterministicGuid(seed)), ("mid", missionId));
for (int w = 0; w < 2; w++)
{
var wpId = NewDeterministicGuid(seed);
ExecScalar(conn, tx, """
INSERT INTO waypoints (id, mission_id, lat, lon, mgrs, order_num)
VALUES (@id, @mid, 50.45, 30.52, '36UYA1234567', @ord);
""", ("id", wpId), ("mid", missionId), ("ord", w));
for (int md = 0; md < 2; md++)
{
var mediaId = $"media-{Guid.NewGuid():N}";
ExecScalar(conn, tx, """
INSERT INTO media (id, waypoint_id) VALUES (@id, @wid);
""", ("id", mediaId), ("wid", wpId));
var annId = $"ann-{Guid.NewGuid():N}";
ExecScalar(conn, tx, """
INSERT INTO annotations (id, media_id) VALUES (@id, @mid);
""", ("id", annId), ("mid", mediaId));
ExecScalar(conn, tx, """
INSERT INTO detection (id, annotation_id) VALUES (@id, @aid);
""", ("id", NewDeterministicGuid(seed)), ("aid", annId));
}
}
tx.Commit();
}
var warmup = ids.Take(WarmupCalls).ToList();
var measured = ids.Skip(WarmupCalls).ToList();
return (measured, warmup);
}
private static void ExecScalar(NpgsqlConnection conn, NpgsqlTransaction tx, string sql,
params (string Name, object Value)[] args)
{
using var cmd = conn.CreateCommand();
cmd.Transaction = tx;
cmd.CommandText = sql;
foreach (var (name, value) in args)
cmd.Parameters.AddWithValue(name, value);
cmd.ExecuteNonQuery();
}
private static Guid NewDeterministicGuid(Random rng)
{
var bytes = new byte[16];
rng.NextBytes(bytes);
// Force version 4 + variant 1 so the value is a valid UUID Postgres accepts.
bytes[7] = (byte)((bytes[7] & 0x0F) | 0x40);
bytes[8] = (byte)((bytes[8] & 0x3F) | 0x80);
return new Guid(bytes);
}
}