mirror of
https://github.com/azaion/missions.git
synced 2026-06-21 15:31:07 +00:00
001e80fe96
Batch 4 of test implementation cycle 1 (existing-code Step 6, final batch).
- AZ-585 SteadyStateLoadTests + ColdStartRssTests: NFT-RES-LIM-01..04.
SteadyStateLoadFixture runs one 5-min sustained-load window and samples
RSS (docker stats), Npgsql conns (pg_stat_activity), and FDs
(/proc/1/fd) every 5s; three test methods assert independently. All
SkippableFact-gated on docker primitives.
- AZ-586 PerformanceTests: NFT-PERF-01..04. Sequential single-client,
5 warm-ups + N measured calls, P50+P95 via LatencyPercentiles, recorded
to PERF_RESULTS_FILE. Tagged Category=Perf so default gate excludes them.
Infrastructure:
- entrypoint.sh now applies --filter "${TEST_FILTER:-Category!=Perf}"
per AZ-586 (default CI gate excludes performance).
- MetricCsvRecorder: idempotent CSV appender keyed on env var, used by
both Perf and ResLim categories.
Step 6 (Implement Tests) is complete. Final report at
_docs/03_implementation/implementation_report_tests.md handoffs the
full-suite gate to test-run/SKILL.md (Step 7).
Co-authored-by: Cursor <cursoragent@cursor.com>
381 lines
15 KiB
C#
381 lines
15 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// NFT-PERF-01..04 — wall-clock latency observations against the dockerised
|
|
/// <c>missions</c> service. Excluded from the default CI gate via
|
|
/// <c>--filter "Category!=Perf"</c> in <c>entrypoint.sh</c>; run via
|
|
/// <c>scripts/run-performance-tests.sh</c>.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Each scenario follows the same shape: seed deterministic data, warm-up
|
|
/// 5 calls (excluded from the percentile), run N measured sequential calls
|
|
/// recording <see cref="Stopwatch"/> wall-clock, compute P50 + P95, record
|
|
/// them to the runtime CSV referenced by <c>PERF_RESULTS_FILE</c>, then
|
|
/// assert against the documented gate. Sequential single-client execution
|
|
/// keeps HTTP/1.1 connection-reuse and JIT warm-up deterministic.
|
|
/// </remarks>
|
|
[Collection("Perf")]
|
|
[Trait("Category", "Perf")]
|
|
public sealed class PerformanceTests : TestBase, IClassFixture<DbResetFixture>
|
|
{
|
|
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<double>(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<double>(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<Guid> 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<List<double>> MeasureSequentialDeletesAsync(IReadOnlyList<Guid> missionIds)
|
|
{
|
|
var latencies = new List<double>(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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
private static (List<Guid> Measured, List<Guid> Warmup) SeedSequentialMissions(
|
|
int count, int waypointsPerMission)
|
|
{
|
|
using var conn = new NpgsqlConnection(TestEnvironment.DbSideChannel);
|
|
conn.Open();
|
|
using var tx = conn.BeginTransaction();
|
|
|
|
var ids = new List<Guid>(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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Seeds <paramref name="count"/> missions, each with the F3 cascade shape:
|
|
/// 3 map_objects + 2 waypoints + (per waypoint: 2 media → 2 annotations → 2 detection).
|
|
/// </summary>
|
|
private static (List<Guid> Measured, List<Guid> Warmup) SeedF3MissionsCascadeChains(int count)
|
|
{
|
|
using var conn = new NpgsqlConnection(TestEnvironment.DbSideChannel);
|
|
conn.Open();
|
|
var ids = new List<Guid>(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);
|
|
}
|
|
}
|