Move 29 clustering/routing test files from NATS.Server.Tests to a dedicated NATS.Server.Clustering.Tests project. Update namespaces, replace private GetFreePort/ReadUntilAsync helpers with TestUtilities calls, and extract TestServerFactory/ClusterTestServer to TestUtilities to fix cross-project reference from JetStreamStartupTests.
316 lines
11 KiB
C#
316 lines
11 KiB
C#
using Microsoft.Extensions.Logging.Abstractions;
|
|
using NATS.Client.Core;
|
|
using NATS.Server.Configuration;
|
|
|
|
namespace NATS.Server.Clustering.Tests.Routes;
|
|
|
|
/// <summary>
|
|
/// Tests cluster route formation and message forwarding between servers.
|
|
/// Ported from Go: server/routes_test.go — TestRouteConfig, TestSeedSolicitWorks.
|
|
/// </summary>
|
|
public class RouteConfigTests
|
|
{
|
|
[Fact]
|
|
public async Task Two_servers_form_full_mesh_cluster()
|
|
{
|
|
// Reference: Go TestSeedSolicitWorks — verifies that two servers
|
|
// with one pointing Routes at the other form a connected cluster.
|
|
var clusterName = Guid.NewGuid().ToString("N");
|
|
|
|
var optsA = new NatsOptions
|
|
{
|
|
Host = "127.0.0.1",
|
|
Port = 0,
|
|
Cluster = new ClusterOptions
|
|
{
|
|
Name = clusterName,
|
|
Host = "127.0.0.1",
|
|
Port = 0,
|
|
},
|
|
};
|
|
|
|
var serverA = new NatsServer(optsA, NullLoggerFactory.Instance);
|
|
var ctsA = new CancellationTokenSource();
|
|
_ = serverA.StartAsync(ctsA.Token);
|
|
await serverA.WaitForReadyAsync();
|
|
|
|
var optsB = new NatsOptions
|
|
{
|
|
Host = "127.0.0.1",
|
|
Port = 0,
|
|
Cluster = new ClusterOptions
|
|
{
|
|
Name = clusterName,
|
|
Host = "127.0.0.1",
|
|
Port = 0,
|
|
Routes = [serverA.ClusterListen!],
|
|
},
|
|
};
|
|
|
|
var serverB = new NatsServer(optsB, NullLoggerFactory.Instance);
|
|
var ctsB = new CancellationTokenSource();
|
|
_ = serverB.StartAsync(ctsB.Token);
|
|
await serverB.WaitForReadyAsync();
|
|
|
|
try
|
|
{
|
|
// Wait for both servers to see a route connection
|
|
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
|
while (!timeout.IsCancellationRequested
|
|
&& (Interlocked.Read(ref serverA.Stats.Routes) == 0
|
|
|| Interlocked.Read(ref serverB.Stats.Routes) == 0))
|
|
{
|
|
await Task.Delay(50, timeout.Token).ContinueWith(_ => { }, TaskScheduler.Default);
|
|
}
|
|
|
|
Interlocked.Read(ref serverA.Stats.Routes).ShouldBeGreaterThan(0);
|
|
Interlocked.Read(ref serverB.Stats.Routes).ShouldBeGreaterThan(0);
|
|
}
|
|
finally
|
|
{
|
|
await ctsA.CancelAsync();
|
|
await ctsB.CancelAsync();
|
|
serverA.Dispose();
|
|
serverB.Dispose();
|
|
ctsA.Dispose();
|
|
ctsB.Dispose();
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Route_forwards_messages_between_clusters()
|
|
{
|
|
// Reference: Go TestSeedSolicitWorks — sets up a seed + one server,
|
|
// subscribes on one, publishes on the other, verifies delivery.
|
|
var clusterName = Guid.NewGuid().ToString("N");
|
|
|
|
var optsA = new NatsOptions
|
|
{
|
|
Host = "127.0.0.1",
|
|
Port = 0,
|
|
Cluster = new ClusterOptions
|
|
{
|
|
Name = clusterName,
|
|
Host = "127.0.0.1",
|
|
Port = 0,
|
|
},
|
|
};
|
|
|
|
var serverA = new NatsServer(optsA, NullLoggerFactory.Instance);
|
|
var ctsA = new CancellationTokenSource();
|
|
_ = serverA.StartAsync(ctsA.Token);
|
|
await serverA.WaitForReadyAsync();
|
|
|
|
var optsB = new NatsOptions
|
|
{
|
|
Host = "127.0.0.1",
|
|
Port = 0,
|
|
Cluster = new ClusterOptions
|
|
{
|
|
Name = clusterName,
|
|
Host = "127.0.0.1",
|
|
Port = 0,
|
|
Routes = [serverA.ClusterListen!],
|
|
},
|
|
};
|
|
|
|
var serverB = new NatsServer(optsB, NullLoggerFactory.Instance);
|
|
var ctsB = new CancellationTokenSource();
|
|
_ = serverB.StartAsync(ctsB.Token);
|
|
await serverB.WaitForReadyAsync();
|
|
|
|
try
|
|
{
|
|
// Wait for route formation
|
|
using var routeTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
|
while (!routeTimeout.IsCancellationRequested
|
|
&& (Interlocked.Read(ref serverA.Stats.Routes) == 0
|
|
|| Interlocked.Read(ref serverB.Stats.Routes) == 0))
|
|
{
|
|
await Task.Delay(50, routeTimeout.Token).ContinueWith(_ => { }, TaskScheduler.Default);
|
|
}
|
|
|
|
// Connect subscriber to server A
|
|
await using var subscriber = new NatsConnection(new NatsOpts
|
|
{
|
|
Url = $"nats://127.0.0.1:{serverA.Port}",
|
|
});
|
|
await subscriber.ConnectAsync();
|
|
|
|
await using var sub = await subscriber.SubscribeCoreAsync<string>("foo");
|
|
await subscriber.PingAsync();
|
|
|
|
// Wait for remote interest to propagate from A to B
|
|
using var interestTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
|
while (!interestTimeout.IsCancellationRequested
|
|
&& !serverB.HasRemoteInterest("foo"))
|
|
{
|
|
await Task.Delay(50, interestTimeout.Token).ContinueWith(_ => { }, TaskScheduler.Default);
|
|
}
|
|
|
|
// Connect publisher to server B and publish
|
|
await using var publisher = new NatsConnection(new NatsOpts
|
|
{
|
|
Url = $"nats://127.0.0.1:{serverB.Port}",
|
|
});
|
|
await publisher.ConnectAsync();
|
|
await publisher.PublishAsync("foo", "Hello");
|
|
|
|
// Verify message arrives on server A's subscriber
|
|
using var receiveTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
|
var msg = await sub.Msgs.ReadAsync(receiveTimeout.Token);
|
|
msg.Data.ShouldBe("Hello");
|
|
}
|
|
finally
|
|
{
|
|
await ctsA.CancelAsync();
|
|
await ctsB.CancelAsync();
|
|
serverA.Dispose();
|
|
serverB.Dispose();
|
|
ctsA.Dispose();
|
|
ctsB.Dispose();
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Route_reconnects_after_peer_restart()
|
|
{
|
|
// Verifies that when a peer is stopped and restarted, the route
|
|
// re-forms and message forwarding resumes.
|
|
var clusterName = Guid.NewGuid().ToString("N");
|
|
|
|
var optsA = new NatsOptions
|
|
{
|
|
Host = "127.0.0.1",
|
|
Port = 0,
|
|
Cluster = new ClusterOptions
|
|
{
|
|
Name = clusterName,
|
|
Host = "127.0.0.1",
|
|
Port = 0,
|
|
},
|
|
};
|
|
|
|
var serverA = new NatsServer(optsA, NullLoggerFactory.Instance);
|
|
var ctsA = new CancellationTokenSource();
|
|
_ = serverA.StartAsync(ctsA.Token);
|
|
await serverA.WaitForReadyAsync();
|
|
|
|
var clusterListenA = serverA.ClusterListen!;
|
|
|
|
var optsB = new NatsOptions
|
|
{
|
|
Host = "127.0.0.1",
|
|
Port = 0,
|
|
Cluster = new ClusterOptions
|
|
{
|
|
Name = clusterName,
|
|
Host = "127.0.0.1",
|
|
Port = 0,
|
|
Routes = [clusterListenA],
|
|
},
|
|
};
|
|
|
|
var serverB = new NatsServer(optsB, NullLoggerFactory.Instance);
|
|
var ctsB = new CancellationTokenSource();
|
|
_ = serverB.StartAsync(ctsB.Token);
|
|
await serverB.WaitForReadyAsync();
|
|
|
|
try
|
|
{
|
|
// Wait for initial route formation
|
|
using var timeout1 = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
|
while (!timeout1.IsCancellationRequested
|
|
&& (Interlocked.Read(ref serverA.Stats.Routes) == 0
|
|
|| Interlocked.Read(ref serverB.Stats.Routes) == 0))
|
|
{
|
|
await Task.Delay(50, timeout1.Token).ContinueWith(_ => { }, TaskScheduler.Default);
|
|
}
|
|
|
|
Interlocked.Read(ref serverA.Stats.Routes).ShouldBeGreaterThan(0);
|
|
|
|
// Stop server B
|
|
await ctsB.CancelAsync();
|
|
serverB.Dispose();
|
|
ctsB.Dispose();
|
|
|
|
// Wait for server A to notice the route drop
|
|
using var dropTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
|
while (!dropTimeout.IsCancellationRequested
|
|
&& Interlocked.Read(ref serverA.Stats.Routes) != 0)
|
|
{
|
|
await Task.Delay(50, dropTimeout.Token).ContinueWith(_ => { }, TaskScheduler.Default);
|
|
}
|
|
|
|
// Restart server B with the same cluster route target
|
|
var optsB2 = new NatsOptions
|
|
{
|
|
Host = "127.0.0.1",
|
|
Port = 0,
|
|
Cluster = new ClusterOptions
|
|
{
|
|
Name = clusterName,
|
|
Host = "127.0.0.1",
|
|
Port = 0,
|
|
Routes = [clusterListenA],
|
|
},
|
|
};
|
|
|
|
serverB = new NatsServer(optsB2, NullLoggerFactory.Instance);
|
|
ctsB = new CancellationTokenSource();
|
|
_ = serverB.StartAsync(ctsB.Token);
|
|
await serverB.WaitForReadyAsync();
|
|
|
|
// Wait for route to re-form
|
|
using var timeout2 = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
|
while (!timeout2.IsCancellationRequested
|
|
&& (Interlocked.Read(ref serverA.Stats.Routes) == 0
|
|
|| Interlocked.Read(ref serverB.Stats.Routes) == 0))
|
|
{
|
|
await Task.Delay(50, timeout2.Token).ContinueWith(_ => { }, TaskScheduler.Default);
|
|
}
|
|
|
|
Interlocked.Read(ref serverA.Stats.Routes).ShouldBeGreaterThan(0);
|
|
Interlocked.Read(ref serverB.Stats.Routes).ShouldBeGreaterThan(0);
|
|
|
|
// Verify message forwarding works after reconnect
|
|
await using var subscriber = new NatsConnection(new NatsOpts
|
|
{
|
|
Url = $"nats://127.0.0.1:{serverA.Port}",
|
|
});
|
|
await subscriber.ConnectAsync();
|
|
|
|
await using var sub = await subscriber.SubscribeCoreAsync<string>("bar");
|
|
await subscriber.PingAsync();
|
|
|
|
// Wait for remote interest to propagate
|
|
using var interestTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
|
while (!interestTimeout.IsCancellationRequested
|
|
&& !serverB.HasRemoteInterest("bar"))
|
|
{
|
|
await Task.Delay(50, interestTimeout.Token).ContinueWith(_ => { }, TaskScheduler.Default);
|
|
}
|
|
|
|
await using var publisher = new NatsConnection(new NatsOpts
|
|
{
|
|
Url = $"nats://127.0.0.1:{serverB.Port}",
|
|
});
|
|
await publisher.ConnectAsync();
|
|
await publisher.PublishAsync("bar", "AfterReconnect");
|
|
|
|
using var receiveTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
|
var msg = await sub.Msgs.ReadAsync(receiveTimeout.Token);
|
|
msg.Data.ShouldBe("AfterReconnect");
|
|
}
|
|
finally
|
|
{
|
|
await ctsA.CancelAsync();
|
|
await ctsB.CancelAsync();
|
|
serverA.Dispose();
|
|
serverB.Dispose();
|
|
ctsA.Dispose();
|
|
ctsB.Dispose();
|
|
}
|
|
}
|
|
}
|