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.
97 lines
4.0 KiB
C#
97 lines
4.0 KiB
C#
using Microsoft.AspNetCore.Builder;
|
|
using Microsoft.AspNetCore.Hosting;
|
|
using Microsoft.AspNetCore.Hosting.Server;
|
|
using Microsoft.AspNetCore.Hosting.Server.Features;
|
|
using Microsoft.AspNetCore.Server.Kestrel.Core;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Options;
|
|
using ZB.MOM.WW.ScadaBridge.Communication;
|
|
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.LoadHarness.Probes;
|
|
|
|
/// <summary>
|
|
/// The site half of the wire-path stream probe: a real Kestrel HTTP/2 (h2c) listener on
|
|
/// a loopback ephemeral port, serving the PRODUCTION <see cref="SiteStreamGrpcServer"/>
|
|
/// over the site's real <see cref="ZB.MOM.WW.ScadaBridge.SiteRuntime.Streaming.SiteStreamManager"/>.
|
|
///
|
|
/// <para>
|
|
/// This is the hop <see cref="StreamSubscriberProbe"/> deliberately substitutes with a
|
|
/// controllable in-process reader (which the slow-subscriber scenario needs), and
|
|
/// therefore the hop the original WP-4 runs never exercised: the
|
|
/// <c>SiteStreamEventBatcher</c> pump (R2), protobuf serialization,
|
|
/// <c>IServerStreamWriter.WriteAsync</c>, and HTTP/2 flow control. The server is built
|
|
/// through its production DI constructor with default <see cref="CommunicationOptions"/>,
|
|
/// so the batch negotiation (100 events / 25 ms window), channel capacities and the
|
|
/// concurrency cap are exactly what ships.
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// Deliberately NOT in the path: <c>ControlPlaneAuthInterceptor</c> (it lives in the
|
|
/// Host project and authenticates once per CALL — one header compare per stream
|
|
/// lifetime, nothing on the per-event path) and TLS (production is h2c too).
|
|
/// </para>
|
|
/// </summary>
|
|
public sealed class SiteWireStreamHost : IAsyncDisposable
|
|
{
|
|
private readonly WebApplication _app;
|
|
|
|
/// <summary>The hosted production gRPC server (drop counters, active stream count).</summary>
|
|
public SiteStreamGrpcServer Server { get; }
|
|
|
|
/// <summary>The dialable endpoint, e.g. <c>http://127.0.0.1:54321</c>.</summary>
|
|
public string Endpoint { get; }
|
|
|
|
private SiteWireStreamHost(WebApplication app, SiteStreamGrpcServer server, string endpoint)
|
|
{
|
|
_app = app;
|
|
Server = server;
|
|
Endpoint = endpoint;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds, starts and readies the listener for one site.
|
|
/// </summary>
|
|
/// <param name="site">The site whose stream manager and actor system back the server.</param>
|
|
/// <returns>The started host.</returns>
|
|
public static async Task<SiteWireStreamHost> StartAsync(SiteRuntimeFixture site)
|
|
{
|
|
var server = new SiteStreamGrpcServer(
|
|
site.StreamManager,
|
|
Microsoft.Extensions.Logging.Abstractions.NullLogger<SiteStreamGrpcServer>.Instance,
|
|
Options.Create(new CommunicationOptions()));
|
|
|
|
var builder = WebApplication.CreateSlimBuilder();
|
|
builder.Logging.ClearProviders();
|
|
builder.WebHost.ConfigureKestrel(options =>
|
|
options.Listen(System.Net.IPAddress.Loopback, 0, listenOptions =>
|
|
listenOptions.Protocols = HttpProtocols.Http2));
|
|
builder.Services.AddGrpc();
|
|
builder.Services.AddSingleton(server);
|
|
|
|
var app = builder.Build();
|
|
app.MapGrpcService<SiteStreamGrpcServer>();
|
|
await app.StartAsync();
|
|
|
|
// Same post-start readiness handoff the production host performs.
|
|
server.SetReady(site.System);
|
|
|
|
var address = app.Services.GetRequiredService<IServer>()
|
|
.Features.Get<IServerAddressesFeature>()?.Addresses.FirstOrDefault()
|
|
?? throw new InvalidOperationException("Kestrel bound no address.");
|
|
|
|
return new SiteWireStreamHost(app, server, address);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
// Mirrors the production shutdown ordering: cancel active streams first so
|
|
// their pumps observe cancellation, then stop the listener.
|
|
Server.CancelAllStreams();
|
|
await _app.StopAsync();
|
|
await _app.DisposeAsync();
|
|
}
|
|
}
|