Fix E2E test gaps and add comprehensive E2E + parity test suites

- Fix pull consumer fetch: send original stream subject in HMSG (not inbox)
  so NATS client distinguishes data messages from control messages
- Fix MaxAge expiry: add background timer in StreamManager for periodic pruning
- Fix JetStream wire format: Go-compatible anonymous objects with string enums,
  proper offset-based pagination for stream/consumer list APIs
- Add 42 E2E black-box tests (core messaging, auth, TLS, accounts, JetStream)
- Add ~1000 parity tests across all subsystems (gaps closure)
- Update gap inventory docs to reflect implementation status
This commit is contained in:
Joseph Doherty
2026-03-12 14:09:23 -04:00
parent 79c1ee8776
commit c30e67a69d
226 changed files with 17801 additions and 709 deletions

View File

@@ -1,10 +1,23 @@
// Go reference: server/events.go:2081-2090 — compressionType, snappyCompression,
// and events.go:578-598 — internalSendLoop compression via s2.WriterSnappyCompat().
using System.IO.Compression;
using IronSnappy;
namespace NATS.Server.Events;
/// <summary>
/// Compression encodings supported for event API responses.
/// Go reference: events.go compressionType constants.
/// </summary>
public enum EventCompressionType : sbyte
{
None = 0,
Gzip = 1,
Snappy = 2,
Unsupported = 3,
}
/// <summary>
/// Provides S2 (Snappy-compatible) compression for system event payloads.
/// Maps to Go's compressionType / snappyCompression handling in events.go:2082-2098
@@ -12,6 +25,9 @@ namespace NATS.Server.Events;
/// </summary>
public static class EventCompressor
{
public const string AcceptEncodingHeader = "Accept-Encoding";
public const string ContentEncodingHeader = "Content-Encoding";
// Default threshold: only compress payloads larger than this many bytes.
// Compressing tiny payloads wastes CPU and may produce larger output.
private const int DefaultThresholdBytes = 256;
@@ -56,11 +72,23 @@ public static class EventCompressor
/// <param name="payload">Raw bytes to compress.</param>
/// <returns>Compressed bytes. Returns an empty array for empty input.</returns>
public static byte[] Compress(ReadOnlySpan<byte> payload)
=> Compress(payload, EventCompressionType.Snappy);
/// <summary>
/// Compresses <paramref name="payload"/> using the requested <paramref name="compression"/>.
/// </summary>
public static byte[] Compress(ReadOnlySpan<byte> payload, EventCompressionType compression)
{
if (payload.IsEmpty)
return [];
return Snappy.Encode(payload);
return compression switch
{
EventCompressionType.None => payload.ToArray(),
EventCompressionType.Gzip => CompressGzip(payload),
EventCompressionType.Snappy => Snappy.Encode(payload),
_ => throw new InvalidOperationException($"Unsupported compression type: {compression}."),
};
}
/// <summary>
@@ -71,11 +99,23 @@ public static class EventCompressor
/// <returns>Decompressed bytes. Returns an empty array for empty input.</returns>
/// <exception cref="Exception">Propagated from IronSnappy if data is corrupt.</exception>
public static byte[] Decompress(ReadOnlySpan<byte> compressed)
=> Decompress(compressed, EventCompressionType.Snappy);
/// <summary>
/// Decompresses <paramref name="compressed"/> using the selected <paramref name="compression"/>.
/// </summary>
public static byte[] Decompress(ReadOnlySpan<byte> compressed, EventCompressionType compression)
{
if (compressed.IsEmpty)
return [];
return Snappy.Decode(compressed);
return compression switch
{
EventCompressionType.None => compressed.ToArray(),
EventCompressionType.Gzip => DecompressGzip(compressed),
EventCompressionType.Snappy => Snappy.Decode(compressed),
_ => throw new InvalidOperationException($"Unsupported compression type: {compression}."),
};
}
/// <summary>
@@ -105,6 +145,15 @@ public static class EventCompressor
public static (byte[] Data, bool Compressed) CompressIfBeneficial(
ReadOnlySpan<byte> payload,
int thresholdBytes = DefaultThresholdBytes)
=> CompressIfBeneficial(payload, EventCompressionType.Snappy, thresholdBytes);
/// <summary>
/// Compresses using <paramref name="compression"/> when payload size exceeds threshold.
/// </summary>
public static (byte[] Data, bool Compressed) CompressIfBeneficial(
ReadOnlySpan<byte> payload,
EventCompressionType compression,
int thresholdBytes = DefaultThresholdBytes)
{
if (!ShouldCompress(payload.Length, thresholdBytes))
{
@@ -112,7 +161,7 @@ public static class EventCompressor
return (payload.ToArray(), false);
}
var compressed = Compress(payload);
var compressed = Compress(payload, compression);
Interlocked.Increment(ref _totalCompressed);
var saved = payload.Length - compressed.Length;
if (saved > 0)
@@ -135,4 +184,45 @@ public static class EventCompressor
return (double)compressedSize / originalSize;
}
/// <summary>
/// Parses an HTTP Accept-Encoding value into a supported compression type.
/// Go reference: events.go getAcceptEncoding().
/// </summary>
public static EventCompressionType GetAcceptEncoding(string? acceptEncoding)
{
if (string.IsNullOrWhiteSpace(acceptEncoding))
return EventCompressionType.None;
var value = acceptEncoding.ToLowerInvariant();
if (value.Contains("snappy", StringComparison.Ordinal)
|| value.Contains("s2", StringComparison.Ordinal))
{
return EventCompressionType.Snappy;
}
if (value.Contains("gzip", StringComparison.Ordinal))
return EventCompressionType.Gzip;
return EventCompressionType.Unsupported;
}
private static byte[] CompressGzip(ReadOnlySpan<byte> payload)
{
using var output = new MemoryStream();
using (var gzip = new GZipStream(output, CompressionLevel.Fastest, leaveOpen: true))
{
gzip.Write(payload);
}
return output.ToArray();
}
private static byte[] DecompressGzip(ReadOnlySpan<byte> compressed)
{
using var input = new MemoryStream(compressed.ToArray());
using var gzip = new GZipStream(input, CompressionMode.Decompress);
using var output = new MemoryStream();
gzip.CopyTo(output);
return output.ToArray();
}
}

View File

@@ -25,8 +25,9 @@ public static class EventSubjects
// Remote server and leaf node events
public const string RemoteServerShutdown = "$SYS.SERVER.{0}.REMOTE.SHUTDOWN";
public const string RemoteServerUpdate = "$SYS.SERVER.{0}.REMOTE.UPDATE";
public const string LeafNodeConnected = "$SYS.SERVER.{0}.LEAFNODE.CONNECT";
public const string LeafNodeConnected = "$SYS.ACCOUNT.{0}.LEAFNODE.CONNECT";
public const string LeafNodeDisconnected = "$SYS.SERVER.{0}.LEAFNODE.DISCONNECT";
public const string RemoteLatency = "$SYS.SERVER.{0}.ACC.{1}.LATENCY.M2";
// Request-reply subjects (server-specific)
public const string ServerReq = "$SYS.REQ.SERVER.{0}.{1}";
@@ -36,13 +37,22 @@ public static class EventSubjects
// Account-scoped request subjects
public const string AccountReq = "$SYS.REQ.ACCOUNT.{0}.{1}";
public const string UserDirectInfo = "$SYS.REQ.USER.INFO";
public const string UserDirectReq = "$SYS.REQ.USER.{0}.INFO";
public const string AccountNumSubsReq = "$SYS.REQ.ACCOUNT.NSUBS";
public const string AccountSubs = "$SYS._INBOX_.{0}.NSUBS";
public const string ClientKickReq = "$SYS.REQ.SERVER.{0}.KICK";
public const string ClientLdmReq = "$SYS.REQ.SERVER.{0}.LDM";
public const string ServerStatsPingReq = "$SYS.REQ.SERVER.PING.STATSZ";
public const string ServerReloadReq = "$SYS.REQ.SERVER.{0}.RELOAD";
// Inbox for responses
public const string InboxResponse = "$SYS._INBOX_.{0}";
// OCSP advisory events
// Go reference: ocsp.go — OCSP peer reject and chain validation subjects.
public const string OcspPeerReject = "$SYS.SERVER.{0}.OCSP.PEER.REJECT";
public const string OcspPeerReject = "$SYS.SERVER.{0}.OCSP.PEER.CONN.REJECT";
public const string OcspPeerChainlinkInvalid = "$SYS.SERVER.{0}.OCSP.PEER.LINK.INVALID";
public const string OcspChainValidation = "$SYS.SERVER.{0}.OCSP.CHAIN.VALIDATION";
// JetStream advisory events

View File

@@ -2,6 +2,35 @@ using System.Text.Json.Serialization;
namespace NATS.Server.Events;
/// <summary>
/// Server capability flags.
/// Go reference: events.go ServerCapability constants.
/// </summary>
[Flags]
public enum ServerCapability : ulong
{
None = 0,
JetStreamEnabled = 1UL << 0,
BinaryStreamSnapshot = 1UL << 1,
AccountNRG = 1UL << 2,
}
/// <summary>
/// Server identity response model used by IDZ requests.
/// Go reference: events.go ServerID.
/// </summary>
public sealed class ServerID
{
[JsonPropertyName("name")]
public string Name { get; set; } = string.Empty;
[JsonPropertyName("host")]
public string Host { get; set; } = string.Empty;
[JsonPropertyName("id")]
public string Id { get; set; } = string.Empty;
}
/// <summary>
/// Server identity block embedded in all system events.
/// Go reference: events.go:249-265 ServerInfo struct.
@@ -53,6 +82,27 @@ public sealed class EventServerInfo
[JsonPropertyName("time")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public DateTime Time { get; set; }
public void SetJetStreamEnabled()
{
JetStream = true;
Flags |= (ulong)ServerCapability.JetStreamEnabled;
}
public bool JetStreamEnabled() =>
(Flags & (ulong)ServerCapability.JetStreamEnabled) != 0;
public void SetBinaryStreamSnapshot() =>
Flags |= (ulong)ServerCapability.BinaryStreamSnapshot;
public bool BinaryStreamSnapshot() =>
(Flags & (ulong)ServerCapability.BinaryStreamSnapshot) != 0;
public void SetAccountNRG() =>
Flags |= (ulong)ServerCapability.AccountNRG;
public bool AccountNRG() =>
(Flags & (ulong)ServerCapability.AccountNRG) != 0;
}
/// <summary>
@@ -536,10 +586,66 @@ public sealed class OcspPeerRejectEventMsg
[JsonPropertyName("server")]
public EventServerInfo Server { get; set; } = new();
[JsonPropertyName("peer")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public EventCertInfo? Peer { get; set; }
[JsonPropertyName("reason")]
public string Reason { get; set; } = string.Empty;
}
/// <summary>
/// Certificate identity block used by OCSP peer advisories.
/// Go reference: certidp.CertInfo payload embedded in events.go OCSP messages.
/// </summary>
public sealed class EventCertInfo
{
[JsonPropertyName("subject")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Subject { get; set; }
[JsonPropertyName("issuer")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Issuer { get; set; }
[JsonPropertyName("fingerprint")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Fingerprint { get; set; }
[JsonPropertyName("raw")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Raw { get; set; }
}
/// <summary>
/// OCSP chain-link invalid advisory.
/// Go reference: events.go OCSPPeerChainlinkInvalidEventMsg.
/// </summary>
public sealed class OcspPeerChainlinkInvalidEventMsg
{
public const string EventType = "io.nats.server.advisory.v1.ocsp_peer_link_invalid";
[JsonPropertyName("type")]
public string Type { get; set; } = EventType;
[JsonPropertyName("id")]
public string Id { get; set; } = string.Empty;
[JsonPropertyName("timestamp")]
public DateTime Time { get; set; }
[JsonPropertyName("server")]
public EventServerInfo Server { get; set; } = new();
[JsonPropertyName("link")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public EventCertInfo? Link { get; set; }
[JsonPropertyName("peer")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public EventCertInfo? Peer { get; set; }
}
/// <summary>
/// OCSP chain validation advisory, published when a certificate's OCSP status
/// is checked during TLS handshake.
@@ -803,6 +909,149 @@ public sealed class AccNumConnsReq
public string Account { get; set; } = string.Empty;
}
/// <summary>
/// Account subscription-count request.
/// Go reference: events.go accNumSubsReq.
/// </summary>
public sealed class AccNumSubsReq
{
[JsonPropertyName("server")]
public EventServerInfo Server { get; set; } = new();
[JsonPropertyName("acc")]
public string Account { get; set; } = string.Empty;
}
/// <summary>
/// Shared request filter options for system request subjects.
/// Go reference: events.go EventFilterOptions.
/// </summary>
public class EventFilterOptions
{
[JsonPropertyName("name")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Name { get; set; }
[JsonPropertyName("cluster")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Cluster { get; set; }
[JsonPropertyName("host")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Host { get; set; }
[JsonPropertyName("tags")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string[]? Tags { get; set; }
[JsonPropertyName("domain")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Domain { get; set; }
}
public sealed class StatszEventOptions : EventFilterOptions;
public sealed class AccInfoEventOptions : EventFilterOptions;
public sealed class ConnzEventOptions : EventFilterOptions;
public sealed class RoutezEventOptions : EventFilterOptions;
public sealed class SubszEventOptions : EventFilterOptions;
public sealed class VarzEventOptions : EventFilterOptions;
public sealed class GatewayzEventOptions : EventFilterOptions;
public sealed class LeafzEventOptions : EventFilterOptions;
public sealed class AccountzEventOptions : EventFilterOptions;
public sealed class AccountStatzEventOptions : EventFilterOptions;
public sealed class JszEventOptions : EventFilterOptions;
public sealed class HealthzEventOptions : EventFilterOptions;
public sealed class ProfilezEventOptions : EventFilterOptions;
public sealed class ExpvarzEventOptions : EventFilterOptions;
public sealed class IpqueueszEventOptions : EventFilterOptions;
public sealed class RaftzEventOptions : EventFilterOptions;
/// <summary>
/// Generic server API error payload.
/// Go reference: events.go server API response errors.
/// </summary>
public sealed class ServerAPIError
{
[JsonPropertyName("code")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public int Code { get; set; }
[JsonPropertyName("description")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Description { get; set; }
}
/// <summary>
/// Generic server request/response envelope for $SYS.REQ services.
/// Go reference: events.go ServerAPIResponse.
/// </summary>
public class ServerAPIResponse
{
[JsonPropertyName("server")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public EventServerInfo? Server { get; set; }
[JsonPropertyName("data")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public object? Data { get; set; }
[JsonPropertyName("error")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public ServerAPIError? Error { get; set; }
}
public sealed class ServerAPIConnzResponse : ServerAPIResponse;
public sealed class ServerAPIRoutezResponse : ServerAPIResponse;
public sealed class ServerAPIGatewayzResponse : ServerAPIResponse;
public sealed class ServerAPIJszResponse : ServerAPIResponse;
public sealed class ServerAPIHealthzResponse : ServerAPIResponse;
public sealed class ServerAPIVarzResponse : ServerAPIResponse;
public sealed class ServerAPISubszResponse : ServerAPIResponse;
public sealed class ServerAPILeafzResponse : ServerAPIResponse;
public sealed class ServerAPIAccountzResponse : ServerAPIResponse;
public sealed class ServerAPIExpvarzResponse : ServerAPIResponse;
public sealed class ServerAPIpqueueszResponse : ServerAPIResponse;
public sealed class ServerAPIRaftzResponse : ServerAPIResponse;
/// <summary>
/// Kick client request payload.
/// Go reference: events.go KickClientReq.
/// </summary>
public sealed class KickClientReq
{
[JsonPropertyName("cid")]
public ulong ClientId { get; set; }
}
/// <summary>
/// Lame duck mode client request payload.
/// Go reference: events.go LDMClientReq.
/// </summary>
public sealed class LDMClientReq
{
[JsonPropertyName("cid")]
public ulong ClientId { get; set; }
}
/// <summary>
/// User info payload for direct user info requests.
/// Go reference: events.go UserInfo.
/// </summary>
public sealed class UserInfo
{
[JsonPropertyName("user")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? User { get; set; }
[JsonPropertyName("acc")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Account { get; set; }
[JsonPropertyName("permissions")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Permissions { get; set; }
}
/// <summary>
/// Factory helpers that construct fully-populated system event messages,
/// mirroring Go's inline struct initialization patterns in events.go.

View File

@@ -61,7 +61,14 @@ public sealed record ConnectEventDetail(
string RemoteAddress,
string? AccountName,
string? UserName,
DateTime ConnectedAt);
DateTime ConnectedAt,
string? Jwt = null,
string? IssuerKey = null,
string[]? Tags = null,
string? NameTag = null,
string? Kind = null,
string? ClientType = null,
string? MqttClientId = null);
/// <summary>
/// Detail payload for a client disconnect advisory.
@@ -73,7 +80,15 @@ public sealed record DisconnectEventDetail(
string RemoteAddress,
string? AccountName,
string Reason,
DateTime DisconnectedAt);
DateTime DisconnectedAt,
long RttNanos = 0,
string? Jwt = null,
string? IssuerKey = null,
string[]? Tags = null,
string? NameTag = null,
string? Kind = null,
string? ClientType = null,
string? MqttClientId = null);
/// <summary>
/// Manages the server's internal event system with Channel-based send/receive loops.
@@ -114,14 +129,29 @@ public sealed class InternalEventSystem : IAsyncDisposable
SystemAccount = systemAccount;
SystemClient = systemClient;
// Hash server name for inbox routing (matches Go's shash)
ServerHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(serverName)))[..8].ToLowerInvariant();
// Hash server name for inbox routing (matches Go's shash).
ServerHash = GetHash(serverName, GetHashSize());
_sendQueue = Channel.CreateUnbounded<PublishMessage>(new UnboundedChannelOptions { SingleReader = true });
_receiveQueue = Channel.CreateUnbounded<InternalSystemMessage>(new UnboundedChannelOptions { SingleReader = true });
_receiveQueuePings = Channel.CreateUnbounded<InternalSystemMessage>(new UnboundedChannelOptions { SingleReader = true });
}
/// <summary>
/// Equivalent to Go getHashSize(): default short hash width used in eventing subjects.
/// </summary>
public static int GetHashSize() => 8;
/// <summary>
/// Equivalent to Go getHash() / getHashSize() helpers for server hash identifiers.
/// </summary>
public static string GetHash(string value, int size)
{
ArgumentOutOfRangeException.ThrowIfLessThan(size, 1);
var full = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))).ToLowerInvariant();
return size >= full.Length ? full : full[..size];
}
public void Start(NatsServer server)
{
_server = server;
@@ -316,6 +346,13 @@ public sealed class InternalEventSystem : IAsyncDisposable
Account = detail.AccountName,
User = detail.UserName,
Start = detail.ConnectedAt,
Jwt = detail.Jwt,
IssuerKey = detail.IssuerKey,
Tags = detail.Tags,
NameTag = detail.NameTag,
Kind = detail.Kind,
ClientType = detail.ClientType,
MqttClient = detail.MqttClientId,
},
};
@@ -341,6 +378,14 @@ public sealed class InternalEventSystem : IAsyncDisposable
Host = detail.RemoteAddress,
Account = detail.AccountName,
Stop = detail.DisconnectedAt,
RttNanos = detail.RttNanos,
Jwt = detail.Jwt,
IssuerKey = detail.IssuerKey,
Tags = detail.Tags,
NameTag = detail.NameTag,
Kind = detail.Kind,
ClientType = detail.ClientType,
MqttClient = detail.MqttClientId,
},
Reason = detail.Reason,
};