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); } }