diff --git a/src/NATS.Server/ClientTraceInfo.cs b/src/NATS.Server/ClientTraceInfo.cs new file mode 100644 index 0000000..5653da1 --- /dev/null +++ b/src/NATS.Server/ClientTraceInfo.cs @@ -0,0 +1,83 @@ +namespace NATS.Server; + +/// +/// Per-client trace configuration and echo control. +/// Go reference: server/client.go — c.trace, c.echo fields. +/// +public sealed class ClientTraceInfo +{ + private bool _traceEnabled; + private bool _echoEnabled = true; // default: echo is enabled + private readonly List _traceLog = new(); + private readonly Lock _lock = new(); + + /// Whether message delivery tracing is enabled for this client. + public bool TraceEnabled + { + get => Volatile.Read(ref _traceEnabled); + set => Volatile.Write(ref _traceEnabled, value); + } + + /// Whether this client echoes its own published messages back. + public bool EchoEnabled + { + get => Volatile.Read(ref _echoEnabled); + set => Volatile.Write(ref _echoEnabled, value); + } + + /// + /// Records a message delivery trace if tracing is enabled. + /// Go reference: server/client.go — traceMsg / TraceMsgDelivery. + /// + public void TraceMsgDelivery(string subject, string destination, int payloadSize) + { + if (!TraceEnabled) return; + lock (_lock) + { + _traceLog.Add(new TraceRecord + { + Subject = subject, + Destination = destination, + PayloadSize = payloadSize, + TimestampUtc = DateTime.UtcNow, + }); + } + } + + /// + /// Returns whether a message from this client should be delivered back to it. + /// When echo is disabled, messages published by this client are not delivered to + /// subscriptions on the same client. + /// Go reference: server/client.go — c.echo check in deliverMsg. + /// + public bool ShouldEcho(string publisherClientId, string subscriberClientId) + { + if (EchoEnabled) return true; + return !string.Equals(publisherClientId, subscriberClientId, StringComparison.Ordinal); + } + + /// Returns all trace records and clears the log. + public IReadOnlyList DrainTraceLog() + { + lock (_lock) + { + var copy = _traceLog.ToList(); + _traceLog.Clear(); + return copy; + } + } + + /// Current trace log count. + public int TraceLogCount + { + get { lock (_lock) return _traceLog.Count; } + } +} + +public sealed record TraceRecord +{ + public string Subject { get; init; } = string.Empty; + public string Destination { get; init; } = string.Empty; + public int PayloadSize { get; init; } + public DateTime TimestampUtc { get; init; } +} diff --git a/tests/NATS.Server.Tests/Auth/AccountGoParityTests.cs b/tests/NATS.Server.Tests/Auth/AccountGoParityTests.cs index 1419e21..fffbdd1 100644 --- a/tests/NATS.Server.Tests/Auth/AccountGoParityTests.cs +++ b/tests/NATS.Server.Tests/Auth/AccountGoParityTests.cs @@ -3,7 +3,7 @@ using NATS.Server.Auth; using NATS.Server.Imports; -using NATS.Server.Subscriptions; +using ServerSubscriptions = NATS.Server.Subscriptions; namespace NATS.Server.Tests.Auth; @@ -28,7 +28,7 @@ public class AccountGoParityTests using var accB = new Account("B"); // Add subscriptions to account A's SubList - var subA = new Subscription { Subject = "foo", Sid = "1" }; + var subA = new ServerSubscriptions.Subscription { Subject = "foo", Sid = "1" }; accA.SubList.Insert(subA); // Account B should not see account A's subscriptions @@ -52,8 +52,8 @@ public class AccountGoParityTests // Go: TestAccountWildcardRouteMapping — wildcards work per-account. using var acc = new Account("TEST"); - var sub1 = new Subscription { Subject = "orders.*", Sid = "1" }; - var sub2 = new Subscription { Subject = "orders.>", Sid = "2" }; + var sub1 = new ServerSubscriptions.Subscription { Subject = "orders.*", Sid = "1" }; + var sub2 = new ServerSubscriptions.Subscription { Subject = "orders.>", Sid = "2" }; acc.SubList.Insert(sub1); acc.SubList.Insert(sub2); diff --git a/tests/NATS.Server.Tests/ClientTraceTests.cs b/tests/NATS.Server.Tests/ClientTraceTests.cs new file mode 100644 index 0000000..9b4ba7c --- /dev/null +++ b/tests/NATS.Server.Tests/ClientTraceTests.cs @@ -0,0 +1,136 @@ +using NATS.Server; +using Shouldly; + +namespace NATS.Server.Tests; + +/// +/// Tests for per-client trace delivery and echo control. +/// Go reference: server/client.go — c.trace, c.echo fields and TraceMsgDelivery / deliverMsg logic. +/// +public class ClientTraceTests +{ + // 1. TraceEnabled defaults to false + [Fact] + public void TraceEnabled_defaults_to_false() + { + var info = new ClientTraceInfo(); + info.TraceEnabled.ShouldBeFalse(); + } + + // 2. EchoEnabled defaults to true + [Fact] + public void EchoEnabled_defaults_to_true() + { + var info = new ClientTraceInfo(); + info.EchoEnabled.ShouldBeTrue(); + } + + // 3. TraceMsgDelivery records when enabled + [Fact] + public void TraceMsgDelivery_records_when_enabled() + { + var info = new ClientTraceInfo(); + info.TraceEnabled = true; + + info.TraceMsgDelivery("foo.bar", "client-1", 42); + + info.TraceLogCount.ShouldBe(1); + } + + // 4. TraceMsgDelivery skips when disabled + [Fact] + public void TraceMsgDelivery_skips_when_disabled() + { + var info = new ClientTraceInfo(); + // TraceEnabled is false by default + + info.TraceMsgDelivery("foo.bar", "client-1", 42); + + info.TraceLogCount.ShouldBe(0); + } + + // 5. TraceMsgDelivery captures subject, destination and payload size correctly + [Fact] + public void TraceMsgDelivery_captures_subject_destination_size() + { + var info = new ClientTraceInfo(); + info.TraceEnabled = true; + var before = DateTime.UtcNow; + + info.TraceMsgDelivery("orders.new", "client-42", 128); + + var records = info.DrainTraceLog(); + records.Count.ShouldBe(1); + var rec = records[0]; + rec.Subject.ShouldBe("orders.new"); + rec.Destination.ShouldBe("client-42"); + rec.PayloadSize.ShouldBe(128); + rec.TimestampUtc.ShouldBeGreaterThanOrEqualTo(before); + rec.TimestampUtc.ShouldBeLessThanOrEqualTo(DateTime.UtcNow); + } + + // 6. ShouldEcho returns true when echo is enabled (same client) + [Fact] + public void ShouldEcho_true_when_echo_enabled() + { + var info = new ClientTraceInfo(); + info.EchoEnabled = true; + + info.ShouldEcho("client-1", "client-1").ShouldBeTrue(); + } + + // 7. ShouldEcho returns false when echo is disabled and same client + [Fact] + public void ShouldEcho_false_when_echo_disabled_same_client() + { + var info = new ClientTraceInfo(); + info.EchoEnabled = false; + + info.ShouldEcho("client-1", "client-1").ShouldBeFalse(); + } + + // 8. ShouldEcho returns true when echo is disabled but different client + [Fact] + public void ShouldEcho_true_when_echo_disabled_different_client() + { + var info = new ClientTraceInfo(); + info.EchoEnabled = false; + + info.ShouldEcho("client-1", "client-2").ShouldBeTrue(); + } + + // 9. DrainTraceLog returns records and clears the log + [Fact] + public void DrainTraceLog_returns_and_clears() + { + var info = new ClientTraceInfo(); + info.TraceEnabled = true; + info.TraceMsgDelivery("a", "dest-a", 10); + info.TraceMsgDelivery("b", "dest-b", 20); + + var drained = info.DrainTraceLog(); + + drained.Count.ShouldBe(2); + info.TraceLogCount.ShouldBe(0); + } + + // 10. TraceLogCount reflects current entries before and after drain + [Fact] + public void TraceLogCount_reflects_current_entries() + { + var info = new ClientTraceInfo(); + info.TraceEnabled = true; + + info.TraceLogCount.ShouldBe(0); + + info.TraceMsgDelivery("x", "dest-x", 5); + info.TraceMsgDelivery("y", "dest-y", 15); + info.TraceMsgDelivery("z", "dest-z", 25); + + info.TraceLogCount.ShouldBe(3); + + info.DrainTraceLog(); + + info.TraceLogCount.ShouldBe(0); + } +}