Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.PerformanceTests/AuditLog/HotPathLatencyTests.cs
T

240 lines
11 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Diagnostics;
using System.Text;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.AuditLog.Configuration;
using ZB.MOM.WW.ScadaBridge.AuditLog.Redaction;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.PerformanceTests.AuditLog;
/// <summary>
/// Bundle D (M5-T9) / C7 (Task 2.5) hot-path latency budget for
/// <see cref="ScadaBridgeAuditRedactor"/>. The redactor sits between event
/// construction and persistence on every audit row — site SQLite hot-path and
/// central direct-write both — so it MUST stay out of the way of script-thread
/// latency.
/// </summary>
/// <remarks>
/// <para>
/// Methodology: warm-up + N iterations, time each
/// <see cref="ScadaBridgeAuditRedactor.Apply"/> with
/// <see cref="Stopwatch"/>, sort, take p95, assert under threshold. Matches
/// the simple-loop style of the existing <c>StaggeredStartupTests</c> /
/// <c>HealthAggregationTests</c> in this project (no BenchmarkDotNet).
/// </para>
/// <para>
/// <b>C7 re-baseline rationale.</b> The canonical <see cref="ScadaBridgeAuditRedactor"/>
/// works on a single <c>DetailsJson</c> bag (one JSON parse + one re-serialize per
/// call) whereas the former typed-field approach touched many individual
/// fields. Empirical calibration measurements on Apple M-series (2026-06-02, Release build;
/// the CI test itself runs MeasureIterations=2 000 after WarmupIterations=200):
/// <list type="bullet">
/// <item>4 KB body (2 body-regex matches + truncation): p50 ≈ 13 µs, p95 ≈ 14 µs.</item>
/// <item>Small DetailsJson, no redactors (JSON parse + rewrite only): p50 ≈ 1.5 µs, p95 ≈ 2 µs.</item>
/// <item>True no-op (null DetailsJson, Target within cap — fast path): p50 ≈ 0 µs, p95 ≈ 0 µs.</item>
/// </list>
/// Thresholds are set conservatively to absorb a 15× CI-slowdown factor (shared,
/// contested runners can be significantly slower than a dev laptop). The env-var
/// override lets a still-slower runner pass without requiring a code change, while a
/// true 50× regression still exceeds even the fallback ceiling.
/// </para>
/// </remarks>
public class HotPathLatencyTests
{
private const int WarmupIterations = 200;
private const int MeasureIterations = 2_000;
// C3/C7 (Task 2.5): the hot-path redactor is the canonical
// ScadaBridgeAuditRedactor (IAuditRedactor) operating on the canonical record.
private static ScadaBridgeAuditRedactor Filter(AuditLogOptions opts) => new(
new StaticMonitor(opts),
NullLogger<ScadaBridgeAuditRedactor>.Instance);
private static AuditEvent NewEvent(string request) =>
ScadaBridgeAuditEventFactory.Create(
channel: AuditChannel.ApiOutbound,
kind: AuditKind.ApiCall,
status: AuditStatus.Delivered,
target: "esg.target",
requestSummary: request);
/// <summary>
/// Run <paramref name="fn"/> N times, returning the p95 in microseconds.
/// Single-threaded; <see cref="Stopwatch.GetTimestamp"/> for high-res timing.
/// </summary>
private static double MeasureP95Microseconds(int iterations, Action fn)
{
var samples = new double[iterations];
var ticksToMicroseconds = 1_000_000d / Stopwatch.Frequency;
for (var i = 0; i < iterations; i++)
{
var start = Stopwatch.GetTimestamp();
fn();
var end = Stopwatch.GetTimestamp();
samples[i] = (end - start) * ticksToMicroseconds;
}
Array.Sort(samples);
var p95Index = (int)Math.Ceiling(iterations * 0.95) - 1;
return samples[p95Index];
}
/// <summary>
/// C7 re-baselined slow-path budget: 4 KiB <c>DetailsJson</c> with two global
/// body-regex patterns and truncation. The canonical redactor deserializes
/// DetailsJson, runs header → body-regex → truncation, then re-serializes.
/// </summary>
/// <remarks>
/// Empirical p95 ≈ 14 µs on an Apple M-series (Release build, 2026-06-02).
/// Budget set to 200 µs (~15× headroom) so a contested shared CI runner does not
/// produce a flaky red. Override via <c>SCADABRIDGE_AUDIT_FILTER_4KB_P95_US</c>
/// when running on a known-slow machine without a code change.
/// </remarks>
[Trait("Category", "Performance")]
[Fact]
public void Filter_Apply_4KB_DetailsJson_DefaultRedactors_P95_LessThan_200_Microseconds()
{
// 4 KiB request body inside DetailsJson, laced with a 16-digit token
// + a `password` field so the body-regex stage matches twice and the
// truncation stage fires (DetailsJson serialised form > DefaultCapBytes).
var opts = new AuditLogOptions
{
// Keep the cap modest so the truncation path actually fires.
DefaultCapBytes = 4096,
GlobalBodyRedactors = new List<string>
{
"\"password\":\\s*\"[^\"]*\"",
"\\d{16}",
},
};
var pad = new string('x', 4 * 1024);
var body = "{\"user\":\"alice\",\"password\":\"hunter2\",\"card\":\"4111111111111111\",\"pad\":\"" + pad + "\"}";
// Sanity: the request body we embed must be > 4 KiB so the truncate stage runs.
Assert.True(Encoding.UTF8.GetByteCount(body) > 4096);
var filter = Filter(opts);
// NewEvent serialises requestSummary into DetailsJson via AuditDetailsCodec —
// so the canonical JSON parse+rewrite cost IS measured, not elided.
var evt = NewEvent(body);
// Warm-up — JIT, regex compile, dictionary populate.
for (var i = 0; i < WarmupIterations; i++) _ = filter.Apply(evt);
var p95Us = MeasureP95Microseconds(MeasureIterations, () => _ = filter.Apply(evt));
// C7 re-baseline: empirical p95 ≈ 14 µs; budget 200 µs (≈ 15× headroom for CI).
// Old budget was 50 µs (pre-C3 typed-field path, no JSON parse).
// Override: SCADABRIDGE_AUDIT_FILTER_4KB_P95_US (µs, positive double).
var threshold = GetThresholdMicroseconds("SCADABRIDGE_AUDIT_FILTER_4KB_P95_US", 200d);
Assert.True(p95Us < threshold,
$"4KB DetailsJson slow-path p95 = {p95Us:F1} µs; threshold = {threshold:F1} µs " +
$"(empirical baseline ≈ 14 µs; budget allows 15× CI headroom)");
}
/// <summary>
/// C7 re-baselined small-payload budget: small <c>DetailsJson</c> within the
/// default cap, no redactors configured. The redactor still deserializes and
/// re-serializes DetailsJson (target IS within cap so the check passes, but
/// DetailsJson is non-empty so the slow path runs).
/// </summary>
/// <remarks>
/// Empirical p95 ≈ 2 µs on an Apple M-series (Release build, 2026-06-02).
/// Budget set to 30 µs (~15× headroom). Override via
/// <c>SCADABRIDGE_AUDIT_FILTER_RAW_P95_US</c>.
/// </remarks>
[Trait("Category", "Performance")]
[Fact]
public void Filter_Apply_SmallDetailsJson_NoRedactors_P95_LessThan_30_Microseconds()
{
// No redactors configured — body-regex list is empty, SQL-param redactor is
// gated on AuditChannel.DbOutbound (we're ApiOutbound), header redactor
// short-circuits on non-object DetailsJson fields. Still goes through the
// slow path because DetailsJson is non-empty (requestSummary was serialized
// into it by NewEvent). Measures JSON parse + re-serialize baseline.
var opts = new AuditLogOptions();
var filter = Filter(opts);
// Small payload that fits well under the 8 KiB default cap.
var evt = NewEvent("hello world");
for (var i = 0; i < WarmupIterations; i++) _ = filter.Apply(evt);
var p95Us = MeasureP95Microseconds(MeasureIterations, () => _ = filter.Apply(evt));
// C7 re-baseline: empirical p95 ≈ 2 µs; budget 30 µs (≈ 15× headroom for CI).
// Old budget was 10 µs (pre-C3 typed-field path, no JSON parse).
// Override: SCADABRIDGE_AUDIT_FILTER_RAW_P95_US (µs, positive double).
var threshold = GetThresholdMicroseconds("SCADABRIDGE_AUDIT_FILTER_RAW_P95_US", 30d);
Assert.True(p95Us < threshold,
$"Small-DetailsJson no-redactors p95 = {p95Us:F1} µs; threshold = {threshold:F1} µs " +
$"(empirical baseline ≈ 2 µs; budget allows 15× CI headroom)");
}
/// <summary>
/// Fast-path budget: null/empty <c>DetailsJson</c> AND Target within cap — the
/// redactor returns the input instance immediately without any JSON work.
/// </summary>
/// <remarks>
/// Empirical p95 ≈ 0.05 µs (effectively free) on an Apple M-series (Release
/// build, 2026-06-02). Budget set to 5 µs — large headroom on an essentially
/// zero-cost path. Override via <c>SCADABRIDGE_AUDIT_FILTER_NOOP_P95_US</c>.
/// </remarks>
[Trait("Category", "Performance")]
[Fact]
public void Filter_Apply_NoDetailsJson_FastPath_P95_LessThan_5_Microseconds()
{
// Construct a minimal canonical event with no DetailsJson and a null Target
// so BOTH fast-path conditions are met (detailsEmpty && targetWithinCap).
// Apply() must return the same instance without deserializing anything.
var opts = new AuditLogOptions();
var filter = Filter(opts);
var evt = new AuditEvent
{
EventId = Guid.NewGuid(),
OccurredAtUtc = DateTimeOffset.UtcNow,
Actor = string.Empty,
Action = "ApiOutbound.ApiCall",
Category = "ApiOutbound",
Outcome = AuditOutcome.Success,
// DetailsJson intentionally null — exercises the true fast path.
};
for (var i = 0; i < WarmupIterations; i++) _ = filter.Apply(evt);
var p95Us = MeasureP95Microseconds(MeasureIterations, () => _ = filter.Apply(evt));
// Verify same-instance return (fast path must return the input unchanged).
var result = filter.Apply(evt);
Assert.Same(evt, result);
// C7 baseline: empirical p95 ≈ 0 µs (branch + two null checks); budget 5 µs.
// Override: SCADABRIDGE_AUDIT_FILTER_NOOP_P95_US (µs, positive double).
var threshold = GetThresholdMicroseconds("SCADABRIDGE_AUDIT_FILTER_NOOP_P95_US", 5d);
Assert.True(p95Us < threshold,
$"No-op fast-path p95 = {p95Us:F1} µs; threshold = {threshold:F1} µs " +
$"(empirical baseline ≈ 0 µs; fast path returns input unchanged)");
}
private static double GetThresholdMicroseconds(string envVar, double defaultUs)
{
var raw = Environment.GetEnvironmentVariable(envVar);
if (raw != null && double.TryParse(raw, out var parsed) && parsed > 0)
{
return parsed;
}
return defaultUs;
}
private sealed class StaticMonitor : IOptionsMonitor<AuditLogOptions>
{
private readonly AuditLogOptions _value;
public StaticMonitor(AuditLogOptions value) => _value = value;
public AuditLogOptions CurrentValue => _value;
public AuditLogOptions Get(string? name) => _value;
public IDisposable? OnChange(Action<AuditLogOptions, string?> listener) => null;
}
}