mirror of
https://github.com/azaion/missions.git
synced 2026-06-22 20:21: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,38 @@
|
||||
namespace Azaion.Missions.E2E.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// Median + percentile helper for the NFT-PERF-* and NFT-RES-LIM-01
|
||||
/// scenarios. Inputs are wall-clock latency samples (or RSS samples)
|
||||
/// in any orderable numeric type; the helper sorts a defensive copy
|
||||
/// and uses the "nearest-rank" definition of percentile (matching the
|
||||
/// percentile defaults used in `docker stats` and most CI dashboards).
|
||||
/// </summary>
|
||||
public static class LatencyPercentiles
|
||||
{
|
||||
public static double P50(IReadOnlyList<double> samples) => Percentile(samples, 50);
|
||||
public static double P95(IReadOnlyList<double> samples) => Percentile(samples, 95);
|
||||
|
||||
public static double Percentile(IReadOnlyList<double> samples, int percentile)
|
||||
{
|
||||
if (samples.Count == 0)
|
||||
throw new ArgumentException("samples must contain at least one value", nameof(samples));
|
||||
if (percentile < 0 || percentile > 100)
|
||||
throw new ArgumentOutOfRangeException(nameof(percentile), "percentile must be in [0, 100]");
|
||||
|
||||
var sorted = samples.ToArray();
|
||||
Array.Sort(sorted);
|
||||
|
||||
// Nearest-rank: rank = ceil(p/100 * N); index = rank - 1.
|
||||
var rank = (int)Math.Ceiling(percentile / 100.0 * sorted.Length);
|
||||
if (rank < 1) rank = 1;
|
||||
if (rank > sorted.Length) rank = sorted.Length;
|
||||
return sorted[rank - 1];
|
||||
}
|
||||
|
||||
public static double Mean(IReadOnlyList<double> samples)
|
||||
{
|
||||
if (samples.Count == 0)
|
||||
throw new ArgumentException("samples must contain at least one value", nameof(samples));
|
||||
return samples.Average();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace Azaion.Missions.E2E.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// Appends one row per NFT-PERF / NFT-RES-LIM scenario to a side-channel
|
||||
/// CSV referenced by an environment variable. The Reporting.Cli converter
|
||||
/// only knows about compile-time <c>[Trait]</c> data — runtime measurements
|
||||
/// (P50/P95, MAX_FD, P95_RSS_MiB, etc.) need this separate file so
|
||||
/// deployment planning + trend dashboards can read them.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// File schema (idempotent header written on first append):
|
||||
/// <code>Timestamp,Category,Scenario,Result,Traces,ErrorMessage</code>
|
||||
/// The Traces column carries the dynamic key=value pairs the spec requires
|
||||
/// (e.g., <c>"AC-3.6; P50_MS=23.4; P95_MS=41.8"</c>); the recorder just
|
||||
/// joins them with semicolons — callers compose the right shape.
|
||||
/// </remarks>
|
||||
public sealed class MetricCsvRecorder
|
||||
{
|
||||
private readonly string? _path;
|
||||
private static readonly object Lock = new();
|
||||
|
||||
/// <param name="envVar">name of the env var that carries the target CSV path
|
||||
/// (e.g., <c>PERF_RESULTS_FILE</c> for NFT-PERF, <c>RESLIM_RESULTS_FILE</c>
|
||||
/// for NFT-RES-LIM). When the env var is missing or whitespace, every
|
||||
/// <see cref="Record"/> call is a no-op — the recorder is intentionally
|
||||
/// silent inside the standard CI run.</param>
|
||||
public MetricCsvRecorder(string envVar)
|
||||
{
|
||||
var v = Environment.GetEnvironmentVariable(envVar);
|
||||
_path = string.IsNullOrWhiteSpace(v) ? null : v;
|
||||
}
|
||||
|
||||
public bool IsEnabled => _path is not null;
|
||||
|
||||
public void Record(string category, string scenario, string result, string traces, string? errorMessage = null)
|
||||
{
|
||||
if (_path is null) return;
|
||||
lock (Lock)
|
||||
{
|
||||
var dir = Path.GetDirectoryName(_path);
|
||||
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
|
||||
var newFile = !File.Exists(_path);
|
||||
using var sw = new StreamWriter(_path, append: true);
|
||||
if (newFile)
|
||||
sw.WriteLine("Timestamp,Category,Scenario,Result,Traces,ErrorMessage");
|
||||
sw.WriteLine(
|
||||
$"{DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture)},"
|
||||
+ $"{Csv(category)},{Csv(scenario)},{Csv(result)},{Csv(traces)},{Csv(errorMessage ?? "")}");
|
||||
}
|
||||
}
|
||||
|
||||
private static string Csv(string value) =>
|
||||
value.Contains(',') || value.Contains('"') || value.Contains('\n')
|
||||
? "\"" + value.Replace("\"", "\"\"", StringComparison.Ordinal) + "\""
|
||||
: value;
|
||||
}
|
||||
Reference in New Issue
Block a user