mirror of
https://github.com/azaion/missions.git
synced 2026-06-22 22:11:07 +00:00
[AZ-585] [AZ-586] ResLim+Perf NFT tests; close test cycle 1
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>
This commit is contained in:
@@ -0,0 +1,380 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user