Files
natsdotnet/tests/NATS.Server.Benchmark.Tests/Transport/WebSocketPubSubTests.cs
Joseph Doherty 1d4b87e5f9 docs: refresh benchmark comparison with increased message counts
Increase message counts across all 14 benchmark test files to reduce
run-to-run variance (e.g. PubSub 16B: 10K→50K, FanOut: 10K→15K,
SinglePub: 100K→500K, JS tests: 5K→25K). Rewrite benchmarks_comparison.md
with fresh numbers from two-batch runs. Key changes: multi 4x4 reached
parity (1.01x), fan-out improved to 0.84x, TLS pub/sub shows 4.70x .NET
advantage, previous small-count anomalies corrected.
2026-03-13 17:52:03 -04:00

210 lines
7.3 KiB
C#

using System.Net.WebSockets;
using System.Text;
using NATS.Client.Core;
using NATS.Server.Benchmark.Tests.Harness;
using NATS.Server.Benchmark.Tests.Infrastructure;
using Xunit.Abstractions;
namespace NATS.Server.Benchmark.Tests.Transport;
[Collection("Benchmark-WebSocket")]
public class WebSocketPubSubTests(WebSocketServerFixture fixture, ITestOutputHelper output)
{
[Fact]
[Trait("Category", "Benchmark")]
public async Task WsPubSub1To1_128B()
{
const int payloadSize = 128;
const int messageCount = 25_000;
var dotnetResult = await RunWsPubSub("WebSocket PubSub 1:1 (128B)", "DotNet", fixture.DotNetWsPort, fixture.CreateDotNetNatsClient, payloadSize, messageCount);
if (fixture.GoAvailable)
{
var goResult = await RunWsPubSub("WebSocket PubSub 1:1 (128B)", "Go", fixture.GoWsPort, fixture.CreateGoNatsClient, payloadSize, messageCount);
BenchmarkResultWriter.WriteComparison(output, goResult, dotnetResult);
}
else
{
BenchmarkResultWriter.WriteSingle(output, dotnetResult);
}
}
[Fact]
[Trait("Category", "Benchmark")]
public async Task WsPubNoSub_128B()
{
const int payloadSize = 128;
const int messageCount = 50_000;
var dotnetResult = await RunWsPubOnly("WebSocket Pub-Only (128B)", "DotNet", fixture.DotNetWsPort, payloadSize, messageCount);
if (fixture.GoAvailable)
{
var goResult = await RunWsPubOnly("WebSocket Pub-Only (128B)", "Go", fixture.GoWsPort, payloadSize, messageCount);
BenchmarkResultWriter.WriteComparison(output, goResult, dotnetResult);
}
else
{
BenchmarkResultWriter.WriteSingle(output, dotnetResult);
}
}
private static async Task<BenchmarkResult> RunWsPubSub(string name, string serverType, int wsPort, Func<NatsConnection> createNatsClient, int payloadSize, int messageCount)
{
var payload = new byte[payloadSize];
var subject = $"bench.ws.pubsub.{Guid.NewGuid():N}";
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(120));
using var ws = new ClientWebSocket();
await ws.ConnectAsync(new Uri($"ws://127.0.0.1:{wsPort}"), cts.Token);
var reader = new WsLineReader(ws);
// Read INFO
await reader.ReadLineAsync(cts.Token);
// Send CONNECT + SUB + PING
await WsSend(ws, "CONNECT {\"verbose\":false,\"protocol\":1}\r\n", cts.Token);
await WsSend(ws, $"SUB {subject} 1\r\n", cts.Token);
await WsSend(ws, "PING\r\n", cts.Token);
await WaitForPong(reader, cts.Token);
// NATS publisher
await using var natsPub = createNatsClient();
await natsPub.ConnectAsync();
await natsPub.PingAsync(cts.Token);
var sw = System.Diagnostics.Stopwatch.StartNew();
for (var i = 0; i < messageCount; i++)
await natsPub.PublishAsync(subject, payload, cancellationToken: cts.Token);
await natsPub.PingAsync(cts.Token);
// Read all MSG responses from WebSocket
var received = 0;
while (received < messageCount)
{
var line = await reader.ReadLineAsync(cts.Token);
if (line.StartsWith("MSG ", StringComparison.Ordinal))
{
await reader.ReadLineAsync(cts.Token);
received++;
}
}
sw.Stop();
await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, null, cts.Token);
return new BenchmarkResult
{
Name = name,
ServerType = serverType,
TotalMessages = messageCount,
TotalBytes = (long)messageCount * payloadSize,
Duration = sw.Elapsed,
};
}
private static async Task<BenchmarkResult> RunWsPubOnly(string name, string serverType, int wsPort, int payloadSize, int messageCount)
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(120));
using var ws = new ClientWebSocket();
await ws.ConnectAsync(new Uri($"ws://127.0.0.1:{wsPort}"), cts.Token);
var reader = new WsLineReader(ws);
// Read INFO
await reader.ReadLineAsync(cts.Token);
// Send CONNECT
await WsSend(ws, "CONNECT {\"verbose\":false,\"protocol\":1}\r\n", cts.Token);
await WsSend(ws, "PING\r\n", cts.Token);
await WaitForPong(reader, cts.Token);
// Build a PUB command with raw binary payload
var subject = $"bench.ws.pubonly.{Guid.NewGuid():N}";
var pubLine = $"PUB {subject} {payloadSize}\r\n";
var pubPayload = new byte[payloadSize];
var pubCmd = Encoding.ASCII.GetBytes(pubLine)
.Concat(pubPayload)
.Concat(Encoding.ASCII.GetBytes("\r\n"))
.ToArray();
var sw = System.Diagnostics.Stopwatch.StartNew();
for (var i = 0; i < messageCount; i++)
await ws.SendAsync(pubCmd, WebSocketMessageType.Binary, true, cts.Token);
// Flush with PING/PONG
await WsSend(ws, "PING\r\n", cts.Token);
await WaitForPong(reader, cts.Token);
sw.Stop();
await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, null, cts.Token);
return new BenchmarkResult
{
Name = name,
ServerType = serverType,
TotalMessages = messageCount,
TotalBytes = (long)messageCount * payloadSize,
Duration = sw.Elapsed,
};
}
/// <summary>
/// Reads lines until PONG is received, skipping any INFO lines
/// (Go server sends a second INFO after CONNECT with connect_info:true).
/// </summary>
private static async Task WaitForPong(WsLineReader reader, CancellationToken ct)
{
while (true)
{
var line = await reader.ReadLineAsync(ct);
if (line == "PONG")
return;
}
}
private static async Task WsSend(ClientWebSocket ws, string data, CancellationToken ct)
{
var bytes = Encoding.ASCII.GetBytes(data);
await ws.SendAsync(bytes, WebSocketMessageType.Binary, true, ct);
}
/// <summary>
/// Buffers incoming WebSocket frames and returns one NATS protocol line at a time.
/// </summary>
private sealed class WsLineReader(ClientWebSocket ws)
{
private readonly byte[] _recvBuffer = new byte[65536];
private readonly StringBuilder _pending = new();
public async Task<string> ReadLineAsync(CancellationToken ct)
{
while (true)
{
var full = _pending.ToString();
var crlfIdx = full.IndexOf("\r\n", StringComparison.Ordinal);
if (crlfIdx >= 0)
{
var line = full[..crlfIdx];
_pending.Clear();
_pending.Append(full[(crlfIdx + 2)..]);
return line;
}
var result = await ws.ReceiveAsync(_recvBuffer, ct);
if (result.MessageType == WebSocketMessageType.Close)
throw new InvalidOperationException("WebSocket closed unexpectedly while reading");
var chunk = Encoding.ASCII.GetString(_recvBuffer, 0, result.Count);
_pending.Append(chunk);
}
}
}
}