e9e44a9ef0
The WP-4 runs measured the stream path only up to the bounded send channel; the probe deliberately substituted the socket hop with a controllable reader, which is exactly where the R2 SiteStreamEventBatcher lives — so batching had never been in any benchmark's hot path. The harness now also attaches per-site wire probes: a real Kestrel h2c listener hosting the production SiteStreamGrpcServer, dialed by the production SiteStreamGrpcClient with batching negotiated, batch sizes read from the production batch_size histogram via a MeterListener (--wire-probes-per-site, default 10; 0 restores the old shape). Full-scale run (10x500x75, 20-min window, results doc §8 + raw JSON): 899,807 events in 12,462 frames (mean 72.2 ev/frame, p50=p99=75), wire P99 43.4 ms vs the 250 ms budget — every batch pays the full 25 ms linger since a 75-event burst never hits the 100 cap — zero drops, zero faults, offered load 100.0% of nominal with fan-out doubled. CI smoke Fact now asserts the wire path carries traffic and observes frames.
159 lines
8.1 KiB
C#
159 lines
8.1 KiB
C#
namespace ZB.MOM.WW.ScadaBridge.LoadHarness;
|
|
|
|
/// <summary>
|
|
/// Scale and duration knobs for a load-harness run. Defaults are the Phase-8 WP-4
|
|
/// target scale (10 sites x 500 instances x 75 tags = 375,000 subscriptions).
|
|
/// Every value is overridable from the command line so the same binary serves both
|
|
/// the full-scale protocol run and the scaled-down CI smoke.
|
|
/// </summary>
|
|
public sealed record HarnessConfig
|
|
{
|
|
/// <summary>Number of simulated sites (WP-4 acceptance criterion <c>[2.5-1]</c>).</summary>
|
|
public int Sites { get; init; } = 10;
|
|
|
|
/// <summary>Instance Actors per site (WP-4 <c>[2.5-2]</c>).</summary>
|
|
public int InstancesPerSite { get; init; } = 500;
|
|
|
|
/// <summary>Data-sourced attributes ("live tags") per instance (WP-4 <c>[2.5-3]</c>).</summary>
|
|
public int TagsPerInstance { get; init; } = 75;
|
|
|
|
/// <summary>
|
|
/// Nominal per-tag update period. The driver emits each tag once per period, so
|
|
/// the site-wide event rate is <c>InstancesPerSite * TagsPerInstance / period</c>.
|
|
/// The 10 s default puts the target-scale fleet at 37,500 tag updates/second.
|
|
/// </summary>
|
|
public TimeSpan TagUpdatePeriod { get; init; } = TimeSpan.FromSeconds(10);
|
|
|
|
/// <summary>
|
|
/// Length of the steady-state measurement window, measured AFTER the ramp
|
|
/// completes and after the post-ramp settle. Memory-growth and CPU figures are
|
|
/// computed over exactly this window.
|
|
/// </summary>
|
|
public TimeSpan SustainDuration { get; init; } = TimeSpan.FromMinutes(20);
|
|
|
|
/// <summary>
|
|
/// Quiet period between the last instance starting and the start of the
|
|
/// measurement window — lets startup allocations settle so the memory-growth
|
|
/// slope reflects steady state, not the ramp.
|
|
/// </summary>
|
|
public TimeSpan SettleDuration { get; init; } = TimeSpan.FromMinutes(2);
|
|
|
|
/// <summary>Resource-sampling cadence (working set, GC heap, CPU, thread count).</summary>
|
|
public TimeSpan SampleInterval { get; init; } = TimeSpan.FromSeconds(10);
|
|
|
|
/// <summary>
|
|
/// Health report cadence. Defaults to the production
|
|
/// <c>HealthMonitoringOptions.ReportInterval</c> (30 s) so the timing measured is
|
|
/// the one that ships; only the CI smoke shortens it, because a 20-second smoke
|
|
/// window would otherwise never see a single tick.
|
|
/// </summary>
|
|
public TimeSpan HealthReportInterval { get; init; } = TimeSpan.FromSeconds(30);
|
|
|
|
/// <summary>How often to take a debug view snapshot of a random live instance.</summary>
|
|
public TimeSpan DebugProbeInterval { get; init; } = TimeSpan.FromSeconds(5);
|
|
|
|
/// <summary>
|
|
/// Pause between the last Instance Actor starting and the tag driver starting, to
|
|
/// let every instance complete its DCL subscribe round-trip. The adapter callback
|
|
/// is captured at subscribe time, so an emit before that lands is silently
|
|
/// discarded — this window is what keeps <c>SkippedNoCallback</c> at zero.
|
|
/// </summary>
|
|
public TimeSpan SubscribeSettleDuration { get; init; } = TimeSpan.FromSeconds(30);
|
|
|
|
/// <summary>
|
|
/// Instances per site that carry a live stream subscriber (a real
|
|
/// <c>StreamRelayActor</c> + bounded DropOldest channel, i.e. the production
|
|
/// Debug View / central shape). Every subscriber's stream graph sees the FULL
|
|
/// site event flow and filters it, so this is the fan-out multiplier.
|
|
/// </summary>
|
|
public int StreamProbesPerSite { get; init; } = 10;
|
|
|
|
/// <summary>
|
|
/// Instances per site additionally watched over a REAL gRPC hop: a per-site
|
|
/// Kestrel h2c listener hosts the production <c>SiteStreamGrpcServer</c> and the
|
|
/// production <c>SiteStreamGrpcClient</c> dials in with batching negotiated, so
|
|
/// the R2 <c>SiteStreamEventBatcher</c>, protobuf serialization and HTTP/2 flow
|
|
/// control are inside the measured path (they are NOT for
|
|
/// <see cref="StreamProbesPerSite"/>, whose final hop is deliberately an
|
|
/// in-process reader). 0 disables the wire path entirely. Like the in-process
|
|
/// probes, each wire subscriber's stream graph sees the full site event flow and
|
|
/// filters it, so this adds to the fan-out multiplier.
|
|
/// </summary>
|
|
public int WireProbesPerSite { get; init; } = 10;
|
|
|
|
/// <summary>Store-and-forward messages enqueued for the drain-rate measurement (register row 50).</summary>
|
|
public int StoreAndForwardDrainMessages { get; init; } = 20_000;
|
|
|
|
/// <summary>Events published at the slow-subscriber isolation probe (register row 50).</summary>
|
|
public int SlowSubscriberEvents { get; init; } = 200_000;
|
|
|
|
/// <summary>Directory for the site SQLite files. A temp directory is used when null.</summary>
|
|
public string? DataDirectory { get; init; }
|
|
|
|
/// <summary>Path the JSON metrics document is written to.</summary>
|
|
public string ResultsPath { get; init; } = "loadharness-results.json";
|
|
|
|
/// <summary>Total live tag subscriptions across the fleet.</summary>
|
|
public int TotalSubscriptions => Sites * InstancesPerSite * TagsPerInstance;
|
|
|
|
/// <summary>Nominal fleet-wide tag updates per second implied by the scale and update period.</summary>
|
|
public double NominalUpdatesPerSecond => TotalSubscriptions / TagUpdatePeriod.TotalSeconds;
|
|
|
|
/// <summary>
|
|
/// Parses <c>--key value</c> / <c>--key=value</c> arguments over the defaults.
|
|
/// Unknown keys throw so a typo in a 20-minute run's command line fails fast
|
|
/// rather than silently measuring the wrong scale.
|
|
/// </summary>
|
|
/// <param name="args">Raw command-line arguments.</param>
|
|
/// <returns>The parsed configuration.</returns>
|
|
public static HarnessConfig Parse(string[] args)
|
|
{
|
|
var config = new HarnessConfig();
|
|
for (var i = 0; i < args.Length; i++)
|
|
{
|
|
var arg = args[i];
|
|
if (!arg.StartsWith("--", StringComparison.Ordinal))
|
|
throw new ArgumentException($"Unexpected argument '{arg}' (expected --key value).");
|
|
|
|
string key;
|
|
string value;
|
|
var eq = arg.IndexOf('=', StringComparison.Ordinal);
|
|
if (eq >= 0)
|
|
{
|
|
key = arg[2..eq];
|
|
value = arg[(eq + 1)..];
|
|
}
|
|
else
|
|
{
|
|
key = arg[2..];
|
|
if (i + 1 >= args.Length)
|
|
throw new ArgumentException($"Option '--{key}' requires a value.");
|
|
value = args[++i];
|
|
}
|
|
|
|
config = key switch
|
|
{
|
|
"sites" => config with { Sites = int.Parse(value) },
|
|
"instances-per-site" => config with { InstancesPerSite = int.Parse(value) },
|
|
"tags-per-instance" => config with { TagsPerInstance = int.Parse(value) },
|
|
"tag-update-period-seconds" => config with { TagUpdatePeriod = TimeSpan.FromSeconds(double.Parse(value)) },
|
|
"sustain-minutes" => config with { SustainDuration = TimeSpan.FromMinutes(double.Parse(value)) },
|
|
"settle-minutes" => config with { SettleDuration = TimeSpan.FromMinutes(double.Parse(value)) },
|
|
"subscribe-settle-seconds" => config with { SubscribeSettleDuration = TimeSpan.FromSeconds(double.Parse(value)) },
|
|
"sample-seconds" => config with { SampleInterval = TimeSpan.FromSeconds(double.Parse(value)) },
|
|
"health-interval-seconds" => config with { HealthReportInterval = TimeSpan.FromSeconds(double.Parse(value)) },
|
|
"debug-probe-interval-seconds" => config with { DebugProbeInterval = TimeSpan.FromSeconds(double.Parse(value)) },
|
|
"stream-probes-per-site" => config with { StreamProbesPerSite = int.Parse(value) },
|
|
"wire-probes-per-site" => config with { WireProbesPerSite = int.Parse(value) },
|
|
"sf-drain-messages" => config with { StoreAndForwardDrainMessages = int.Parse(value) },
|
|
"slow-subscriber-events" => config with { SlowSubscriberEvents = int.Parse(value) },
|
|
"data-dir" => config with { DataDirectory = value },
|
|
"results" => config with { ResultsPath = value },
|
|
_ => throw new ArgumentException($"Unknown option '--{key}'."),
|
|
};
|
|
}
|
|
|
|
return config;
|
|
}
|
|
}
|