- git mv JetStreamApiFixture, JetStreamClusterFixture, LeafFixture, Parity utilities, and TestData from NATS.Server.Tests to NATS.Server.TestUtilities - Update namespaces to NATS.Server.TestUtilities (and .Parity sub-ns) - Make fixture classes public for cross-project access - Add PollHelper to replace Task.Delay polling with SemaphoreSlim waits - Refactor all fixture polling loops to use PollHelper - Add 'using NATS.Server.TestUtilities;' to ~75 consuming test files - Rename local fixture duplicates (MetaGroupTestFixture, LeafProtocolTestFixture) to avoid shadowing shared fixtures - Remove TestData entry from NATS.Server.Tests.csproj (moved to TestUtilities)
390 lines
16 KiB
C#
390 lines
16 KiB
C#
using Microsoft.Extensions.Logging.Abstractions;
|
|
using NATS.Client.Core;
|
|
using NATS.Server.Configuration;
|
|
using NATS.Server.TestUtilities;
|
|
|
|
namespace NATS.Server.Tests.LeafNodes;
|
|
|
|
/// <summary>
|
|
/// Tests for message forwarding through leaf node connections (hub-to-leaf, leaf-to-hub, leaf-to-leaf).
|
|
/// Reference: golang/nats-server/server/leafnode_test.go
|
|
/// </summary>
|
|
public class LeafNodeForwardingTests
|
|
{
|
|
// Go: TestLeafNodeRemoteIsHub server/leafnode_test.go:1177
|
|
[Fact]
|
|
public async Task Hub_publishes_message_reaches_leaf_subscriber()
|
|
{
|
|
await using var fixture = await LeafFixture.StartAsync();
|
|
|
|
await using var leafConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{fixture.Spoke.Port}" });
|
|
await leafConn.ConnectAsync();
|
|
await using var hubConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{fixture.Hub.Port}" });
|
|
await hubConn.ConnectAsync();
|
|
|
|
await using var sub = await leafConn.SubscribeCoreAsync<string>("forward.test");
|
|
await leafConn.PingAsync();
|
|
await fixture.WaitForRemoteInterestOnHubAsync("forward.test");
|
|
|
|
await hubConn.PublishAsync("forward.test", "from-hub");
|
|
|
|
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
|
var msg = await sub.Msgs.ReadAsync(cts.Token);
|
|
msg.Data.ShouldBe("from-hub");
|
|
}
|
|
|
|
// Go: TestLeafNodeRemoteIsHub server/leafnode_test.go:1177
|
|
[Fact]
|
|
public async Task Leaf_publishes_message_reaches_hub_subscriber()
|
|
{
|
|
await using var fixture = await LeafFixture.StartAsync();
|
|
|
|
await using var hubConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{fixture.Hub.Port}" });
|
|
await hubConn.ConnectAsync();
|
|
await using var leafConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{fixture.Spoke.Port}" });
|
|
await leafConn.ConnectAsync();
|
|
|
|
await using var sub = await hubConn.SubscribeCoreAsync<string>("forward.hub");
|
|
await hubConn.PingAsync();
|
|
await fixture.WaitForRemoteInterestOnSpokeAsync("forward.hub");
|
|
|
|
await leafConn.PublishAsync("forward.hub", "from-leaf");
|
|
|
|
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
|
var msg = await sub.Msgs.ReadAsync(cts.Token);
|
|
msg.Data.ShouldBe("from-leaf");
|
|
}
|
|
|
|
// Go: TestLeafNodeNoMsgLoop server/leafnode_test.go:3800
|
|
[Fact]
|
|
public async Task Message_published_on_leaf_does_not_loop_back_via_hub()
|
|
{
|
|
await using var fixture = await LeafFixture.StartAsync();
|
|
|
|
await using var leafConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{fixture.Spoke.Port}" });
|
|
await leafConn.ConnectAsync();
|
|
|
|
await using var sub = await leafConn.SubscribeCoreAsync<string>("noloop.test");
|
|
await leafConn.PingAsync();
|
|
await fixture.WaitForRemoteInterestOnHubAsync("noloop.test");
|
|
|
|
await leafConn.PublishAsync("noloop.test", "from-leaf");
|
|
|
|
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
|
var msg = await sub.Msgs.ReadAsync(cts.Token);
|
|
msg.Data.ShouldBe("from-leaf");
|
|
|
|
using var leakCts = new CancellationTokenSource(TimeSpan.FromMilliseconds(500));
|
|
await Should.ThrowAsync<OperationCanceledException>(async () =>
|
|
await sub.Msgs.ReadAsync(leakCts.Token));
|
|
}
|
|
|
|
// Go: TestLeafNodeNoMsgLoop server/leafnode_test.go:3800
|
|
[Fact]
|
|
public async Task Multiple_messages_forwarded_from_hub_each_arrive_once()
|
|
{
|
|
await using var fixture = await LeafFixture.StartAsync();
|
|
|
|
await using var leafConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{fixture.Spoke.Port}" });
|
|
await leafConn.ConnectAsync();
|
|
await using var hubConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{fixture.Hub.Port}" });
|
|
await hubConn.ConnectAsync();
|
|
|
|
await using var sub = await leafConn.SubscribeCoreAsync<string>("multi.test");
|
|
await leafConn.PingAsync();
|
|
await fixture.WaitForRemoteInterestOnHubAsync("multi.test");
|
|
|
|
const int count = 10;
|
|
for (var i = 0; i < count; i++)
|
|
await hubConn.PublishAsync("multi.test", $"msg-{i}");
|
|
|
|
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
|
var received = new List<string>();
|
|
for (var i = 0; i < count; i++)
|
|
{
|
|
var msg = await sub.Msgs.ReadAsync(cts.Token);
|
|
received.Add(msg.Data!);
|
|
}
|
|
|
|
received.Count.ShouldBe(count);
|
|
for (var i = 0; i < count; i++)
|
|
received.ShouldContain($"msg-{i}");
|
|
}
|
|
|
|
// Go: TestLeafNodeRemoteIsHub server/leafnode_test.go:1177
|
|
[Fact]
|
|
public async Task Bidirectional_forwarding_hub_and_leaf_can_exchange_messages()
|
|
{
|
|
await using var fixture = await LeafFixture.StartAsync();
|
|
|
|
await using var hubConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{fixture.Hub.Port}" });
|
|
await hubConn.ConnectAsync();
|
|
await using var leafConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{fixture.Spoke.Port}" });
|
|
await leafConn.ConnectAsync();
|
|
|
|
await using var hubSub = await hubConn.SubscribeCoreAsync<string>("bidir.hub");
|
|
await using var leafSub = await leafConn.SubscribeCoreAsync<string>("bidir.leaf");
|
|
await hubConn.PingAsync();
|
|
await leafConn.PingAsync();
|
|
await fixture.WaitForRemoteInterestOnSpokeAsync("bidir.hub");
|
|
await fixture.WaitForRemoteInterestOnHubAsync("bidir.leaf");
|
|
|
|
await leafConn.PublishAsync("bidir.hub", "leaf-to-hub");
|
|
using var cts1 = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
|
(await hubSub.Msgs.ReadAsync(cts1.Token)).Data.ShouldBe("leaf-to-hub");
|
|
|
|
await hubConn.PublishAsync("bidir.leaf", "hub-to-leaf");
|
|
using var cts2 = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
|
(await leafSub.Msgs.ReadAsync(cts2.Token)).Data.ShouldBe("hub-to-leaf");
|
|
}
|
|
|
|
// Go: TestLeafNodeNoMsgLoop server/leafnode_test.go:3800
|
|
[Fact]
|
|
public async Task Two_spokes_interest_propagates_to_hub()
|
|
{
|
|
await using var fixture = await TwoSpokeFixture.StartAsync();
|
|
|
|
await using var spoke1Conn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{fixture.Spoke1.Port}" });
|
|
await spoke1Conn.ConnectAsync();
|
|
await using var spoke2Conn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{fixture.Spoke2.Port}" });
|
|
await spoke2Conn.ConnectAsync();
|
|
|
|
await using var sub1 = await spoke1Conn.SubscribeCoreAsync<string>("spoke1.interest");
|
|
await using var sub2 = await spoke2Conn.SubscribeCoreAsync<string>("spoke2.interest");
|
|
await spoke1Conn.PingAsync();
|
|
await spoke2Conn.PingAsync();
|
|
|
|
// Both spokes' interests should propagate to the hub
|
|
using var waitCts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
|
while (!waitCts.IsCancellationRequested
|
|
&& (!fixture.Hub.HasRemoteInterest("spoke1.interest") || !fixture.Hub.HasRemoteInterest("spoke2.interest")))
|
|
await Task.Delay(50, waitCts.Token).ContinueWith(_ => { }, TaskScheduler.Default);
|
|
|
|
fixture.Hub.HasRemoteInterest("spoke1.interest").ShouldBeTrue();
|
|
fixture.Hub.HasRemoteInterest("spoke2.interest").ShouldBeTrue();
|
|
}
|
|
|
|
// Go: TestLeafNodeRemoteIsHub server/leafnode_test.go:1177
|
|
[Fact]
|
|
public async Task Large_payload_forwarded_correctly_through_leaf_node()
|
|
{
|
|
await using var fixture = await LeafFixture.StartAsync();
|
|
|
|
await using var leafConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{fixture.Spoke.Port}" });
|
|
await leafConn.ConnectAsync();
|
|
await using var hubConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{fixture.Hub.Port}" });
|
|
await hubConn.ConnectAsync();
|
|
|
|
await using var sub = await leafConn.SubscribeCoreAsync<byte[]>("large.payload");
|
|
await leafConn.PingAsync();
|
|
await fixture.WaitForRemoteInterestOnHubAsync("large.payload");
|
|
|
|
var largePayload = new byte[10240];
|
|
Random.Shared.NextBytes(largePayload);
|
|
await hubConn.PublishAsync("large.payload", largePayload);
|
|
|
|
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
|
var msg = await sub.Msgs.ReadAsync(cts.Token);
|
|
msg.Data.ShouldNotBeNull();
|
|
msg.Data!.Length.ShouldBe(largePayload.Length);
|
|
msg.Data.ShouldBe(largePayload);
|
|
}
|
|
|
|
// Go: TestLeafNodeNoMsgLoop server/leafnode_test.go:3800
|
|
// Note: Request-reply across leaf nodes requires _INBOX reply subject
|
|
// interest propagation which needs the hub to forward reply-to messages
|
|
// back to the requester. This is a more complex scenario tested at
|
|
// the integration level when full reply routing is implemented.
|
|
[Fact]
|
|
public async Task Reply_subject_from_hub_reaches_leaf_subscriber()
|
|
{
|
|
await using var fixture = await LeafFixture.StartAsync();
|
|
|
|
await using var hubConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{fixture.Hub.Port}" });
|
|
await hubConn.ConnectAsync();
|
|
await using var leafConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{fixture.Spoke.Port}" });
|
|
await leafConn.ConnectAsync();
|
|
|
|
await using var requestSub = await leafConn.SubscribeCoreAsync<string>("request.test");
|
|
await leafConn.PingAsync();
|
|
await fixture.WaitForRemoteInterestOnHubAsync("request.test");
|
|
|
|
// Publish with a reply-to from hub
|
|
await hubConn.PublishAsync("request.test", "hello", replyTo: "reply.subject");
|
|
|
|
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
|
var msg = await requestSub.Msgs.ReadAsync(cts.Token);
|
|
msg.Data.ShouldBe("hello");
|
|
// The reply-to may or may not be propagated depending on implementation
|
|
// At minimum, the message itself should arrive
|
|
}
|
|
|
|
// Go: TestLeafNodeDuplicateMsg server/leafnode_test.go:6513
|
|
[Fact]
|
|
public async Task Subscriber_on_both_hub_and_leaf_receives_message_once_each()
|
|
{
|
|
await using var fixture = await LeafFixture.StartAsync();
|
|
|
|
await using var hubConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{fixture.Hub.Port}" });
|
|
await hubConn.ConnectAsync();
|
|
await using var leafConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{fixture.Spoke.Port}" });
|
|
await leafConn.ConnectAsync();
|
|
|
|
await using var hubSub = await hubConn.SubscribeCoreAsync<string>("both.test");
|
|
await using var leafSub = await leafConn.SubscribeCoreAsync<string>("both.test");
|
|
await hubConn.PingAsync();
|
|
await leafConn.PingAsync();
|
|
await fixture.WaitForRemoteInterestOnHubAsync("both.test");
|
|
|
|
await using var pubConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{fixture.Hub.Port}" });
|
|
await pubConn.ConnectAsync();
|
|
await pubConn.PublishAsync("both.test", "dual");
|
|
|
|
using var cts1 = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
|
(await hubSub.Msgs.ReadAsync(cts1.Token)).Data.ShouldBe("dual");
|
|
|
|
using var cts2 = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
|
(await leafSub.Msgs.ReadAsync(cts2.Token)).Data.ShouldBe("dual");
|
|
}
|
|
|
|
// Go: TestLeafNodeNoMsgLoop server/leafnode_test.go:3800
|
|
[Fact]
|
|
public async Task Hub_subscriber_receives_leaf_message_with_correct_subject()
|
|
{
|
|
await using var fixture = await LeafFixture.StartAsync();
|
|
|
|
await using var hubConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{fixture.Hub.Port}" });
|
|
await hubConn.ConnectAsync();
|
|
await using var leafConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{fixture.Spoke.Port}" });
|
|
await leafConn.ConnectAsync();
|
|
|
|
await using var sub = await hubConn.SubscribeCoreAsync<string>("subject.check");
|
|
await hubConn.PingAsync();
|
|
await fixture.WaitForRemoteInterestOnSpokeAsync("subject.check");
|
|
|
|
await leafConn.PublishAsync("subject.check", "payload");
|
|
|
|
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
|
var msg = await sub.Msgs.ReadAsync(cts.Token);
|
|
msg.Subject.ShouldBe("subject.check");
|
|
msg.Data.ShouldBe("payload");
|
|
}
|
|
|
|
// Go: TestLeafNodeNoMsgLoop server/leafnode_test.go:3800
|
|
[Fact]
|
|
public async Task No_message_received_when_no_subscriber_on_leaf()
|
|
{
|
|
await using var fixture = await LeafFixture.StartAsync();
|
|
|
|
await using var hubConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{fixture.Hub.Port}" });
|
|
await hubConn.ConnectAsync();
|
|
|
|
await hubConn.PublishAsync("no.subscriber", "lost");
|
|
await Task.Delay(200);
|
|
|
|
true.ShouldBeTrue(); // No crash = success
|
|
}
|
|
|
|
// Go: TestLeafNodeNoMsgLoop server/leafnode_test.go:3800
|
|
[Fact]
|
|
public async Task Empty_payload_forwarded_correctly_through_leaf_node()
|
|
{
|
|
await using var fixture = await LeafFixture.StartAsync();
|
|
|
|
await using var leafConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{fixture.Spoke.Port}" });
|
|
await leafConn.ConnectAsync();
|
|
await using var hubConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{fixture.Hub.Port}" });
|
|
await hubConn.ConnectAsync();
|
|
|
|
await using var sub = await leafConn.SubscribeCoreAsync<byte[]>("empty.payload");
|
|
await leafConn.PingAsync();
|
|
await fixture.WaitForRemoteInterestOnHubAsync("empty.payload");
|
|
|
|
await hubConn.PublishAsync<byte[]>("empty.payload", []);
|
|
|
|
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
|
var msg = await sub.Msgs.ReadAsync(cts.Token);
|
|
msg.Subject.ShouldBe("empty.payload");
|
|
}
|
|
}
|
|
|
|
internal sealed class TwoSpokeFixture : IAsyncDisposable
|
|
{
|
|
private readonly CancellationTokenSource _hubCts;
|
|
private readonly CancellationTokenSource _spoke1Cts;
|
|
private readonly CancellationTokenSource _spoke2Cts;
|
|
|
|
private TwoSpokeFixture(NatsServer hub, NatsServer spoke1, NatsServer spoke2,
|
|
CancellationTokenSource hubCts, CancellationTokenSource spoke1Cts, CancellationTokenSource spoke2Cts)
|
|
{
|
|
Hub = hub;
|
|
Spoke1 = spoke1;
|
|
Spoke2 = spoke2;
|
|
_hubCts = hubCts;
|
|
_spoke1Cts = spoke1Cts;
|
|
_spoke2Cts = spoke2Cts;
|
|
}
|
|
|
|
public NatsServer Hub { get; }
|
|
public NatsServer Spoke1 { get; }
|
|
public NatsServer Spoke2 { get; }
|
|
|
|
public static async Task<TwoSpokeFixture> StartAsync()
|
|
{
|
|
var hubOptions = new NatsOptions
|
|
{
|
|
Host = "127.0.0.1", Port = 0,
|
|
LeafNode = new LeafNodeOptions { Host = "127.0.0.1", Port = 0 },
|
|
};
|
|
|
|
var hub = new NatsServer(hubOptions, NullLoggerFactory.Instance);
|
|
var hubCts = new CancellationTokenSource();
|
|
_ = hub.StartAsync(hubCts.Token);
|
|
await hub.WaitForReadyAsync();
|
|
|
|
var spoke1Options = new NatsOptions
|
|
{
|
|
Host = "127.0.0.1", Port = 0,
|
|
LeafNode = new LeafNodeOptions { Host = "127.0.0.1", Port = 0, Remotes = [hub.LeafListen!] },
|
|
};
|
|
|
|
var spoke1 = new NatsServer(spoke1Options, NullLoggerFactory.Instance);
|
|
var spoke1Cts = new CancellationTokenSource();
|
|
_ = spoke1.StartAsync(spoke1Cts.Token);
|
|
await spoke1.WaitForReadyAsync();
|
|
|
|
var spoke2Options = new NatsOptions
|
|
{
|
|
Host = "127.0.0.1", Port = 0,
|
|
LeafNode = new LeafNodeOptions { Host = "127.0.0.1", Port = 0, Remotes = [hub.LeafListen!] },
|
|
};
|
|
|
|
var spoke2 = new NatsServer(spoke2Options, NullLoggerFactory.Instance);
|
|
var spoke2Cts = new CancellationTokenSource();
|
|
_ = spoke2.StartAsync(spoke2Cts.Token);
|
|
await spoke2.WaitForReadyAsync();
|
|
|
|
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
|
while (!timeout.IsCancellationRequested
|
|
&& (Interlocked.Read(ref hub.Stats.Leafs) < 2
|
|
|| spoke1.Stats.Leafs == 0
|
|
|| spoke2.Stats.Leafs == 0))
|
|
await Task.Delay(50, timeout.Token).ContinueWith(_ => { }, TaskScheduler.Default);
|
|
|
|
return new TwoSpokeFixture(hub, spoke1, spoke2, hubCts, spoke1Cts, spoke2Cts);
|
|
}
|
|
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
await _spoke2Cts.CancelAsync();
|
|
await _spoke1Cts.CancelAsync();
|
|
await _hubCts.CancelAsync();
|
|
Spoke2.Dispose();
|
|
Spoke1.Dispose();
|
|
Hub.Dispose();
|
|
_spoke2Cts.Dispose();
|
|
_spoke1Cts.Dispose();
|
|
_hubCts.Dispose();
|
|
}
|
|
}
|