test(batch48): add integration test harness infrastructure
Create 7 helper files under ZB.MOM.NatsNet.Server.IntegrationTests/Helpers/ and add Xunit.SkippableFact package. All tests skip gracefully via IntegrationTestBase.CanBoot() guard until the .NET server runtime is complete.
This commit is contained in:
@@ -0,0 +1,294 @@
|
||||
// Copyright 2012-2026 The NATS Authors
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
// Mirrors Go supercluster struct and createJetStreamSuperCluster* helpers from
|
||||
// server/jetstream_helpers_test.go.
|
||||
|
||||
using ZB.MOM.NatsNet.Server;
|
||||
|
||||
namespace ZB.MOM.NatsNet.Server.IntegrationTests.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a multi-cluster super-cluster connected via NATS gateways.
|
||||
/// Mirrors Go <c>supercluster</c> struct from server/jetstream_helpers_test.go.
|
||||
/// </summary>
|
||||
internal sealed class TestSuperCluster : IDisposable
|
||||
{
|
||||
// =========================================================================
|
||||
// Properties
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>All clusters that form this super-cluster.</summary>
|
||||
public TestCluster[] Clusters { get; }
|
||||
|
||||
private bool _disposed;
|
||||
|
||||
// =========================================================================
|
||||
// Constructor
|
||||
// =========================================================================
|
||||
|
||||
private TestSuperCluster(TestCluster[] clusters)
|
||||
{
|
||||
Clusters = clusters;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Static factory
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Creates a JetStream super-cluster consisting of <paramref name="numClusters"/> clusters,
|
||||
/// each with <paramref name="numPerCluster"/> servers, connected via gateways.
|
||||
/// Cluster names are C1, C2, … Cn.
|
||||
/// Mirrors Go <c>createJetStreamSuperCluster</c>.
|
||||
/// </summary>
|
||||
public static TestSuperCluster CreateJetStreamSuperCluster(int numPerCluster, int numClusters)
|
||||
{
|
||||
if (numClusters <= 1)
|
||||
throw new ArgumentException("numClusters must be > 1.", nameof(numClusters));
|
||||
if (numPerCluster < 1)
|
||||
throw new ArgumentException("numPerCluster must be >= 1.", nameof(numPerCluster));
|
||||
|
||||
// Allocate gateway ports — one per server across all clusters.
|
||||
var totalServers = numClusters * numPerCluster;
|
||||
var gatewayPorts = new int[totalServers];
|
||||
for (var i = 0; i < totalServers; i++)
|
||||
gatewayPorts[i] = TestServerHelper.GetFreePort();
|
||||
|
||||
// Build gateway remote-entry lines for each cluster.
|
||||
// Each cluster has numPerCluster gateway ports.
|
||||
var gwEntries = new string[numClusters];
|
||||
for (var ci = 0; ci < numClusters; ci++)
|
||||
{
|
||||
var clusterName = $"C{ci + 1}";
|
||||
var baseIndex = ci * numPerCluster;
|
||||
var urls = string.Join(
|
||||
",",
|
||||
Enumerable.Range(baseIndex, numPerCluster)
|
||||
.Select(idx => $"nats-gw://127.0.0.1:{gatewayPorts[idx]}"));
|
||||
|
||||
gwEntries[ci] = string.Format(
|
||||
ConfigHelper.JsGatewayEntryTemplate,
|
||||
"\n\t\t\t",
|
||||
clusterName,
|
||||
urls);
|
||||
}
|
||||
var allGwConf = string.Join(string.Empty, gwEntries);
|
||||
|
||||
// Create each cluster with the super-cluster gateway wrapper.
|
||||
var clusters = new TestCluster[numClusters];
|
||||
|
||||
for (var ci = 0; ci < numClusters; ci++)
|
||||
{
|
||||
var clusterName = $"C{ci + 1}";
|
||||
var gwBaseIndex = ci * numPerCluster;
|
||||
|
||||
// Allocate cluster-route ports for this sub-cluster.
|
||||
var clusterPorts = Enumerable.Range(0, numPerCluster)
|
||||
.Select(_ => TestServerHelper.GetFreePort())
|
||||
.ToArray();
|
||||
var routeUrls = string.Join(
|
||||
",",
|
||||
clusterPorts.Select(p => $"nats-route://127.0.0.1:{p}"));
|
||||
|
||||
var servers = new NatsServer[numPerCluster];
|
||||
var opts = new ServerOptions[numPerCluster];
|
||||
|
||||
for (var si = 0; si < numPerCluster; si++)
|
||||
{
|
||||
var serverName = $"{clusterName}-S{si + 1}";
|
||||
var storeDir = TestServerHelper.CreateTempDir($"js-sc-{clusterName}-{si + 1}-");
|
||||
var gwPort = gatewayPorts[gwBaseIndex + si];
|
||||
|
||||
// Inner cluster config (using JsClusterTemplate).
|
||||
var innerConf = string.Format(
|
||||
ConfigHelper.JsClusterTemplate,
|
||||
serverName,
|
||||
storeDir,
|
||||
clusterName,
|
||||
clusterPorts[si],
|
||||
routeUrls);
|
||||
|
||||
// Wrap with super-cluster template (gateway section).
|
||||
var fullConf = string.Format(
|
||||
ConfigHelper.JsSuperClusterTemplate,
|
||||
innerConf,
|
||||
clusterName,
|
||||
gwPort,
|
||||
allGwConf);
|
||||
|
||||
var configFile = ConfigHelper.CreateConfigFile(fullConf);
|
||||
|
||||
var serverOpts = new ServerOptions
|
||||
{
|
||||
ServerName = serverName,
|
||||
Host = "127.0.0.1",
|
||||
Port = -1,
|
||||
NoLog = true,
|
||||
NoSigs = true,
|
||||
JetStream = true,
|
||||
StoreDir = storeDir,
|
||||
ConfigFile = configFile,
|
||||
Cluster = new ClusterOpts
|
||||
{
|
||||
Name = clusterName,
|
||||
Host = "127.0.0.1",
|
||||
Port = clusterPorts[si],
|
||||
},
|
||||
Gateway = new GatewayOpts
|
||||
{
|
||||
Name = clusterName,
|
||||
Host = "127.0.0.1",
|
||||
Port = gwPort,
|
||||
Gateways = Enumerable.Range(0, numClusters)
|
||||
.Where(gci => gci != ci)
|
||||
.Select(gci =>
|
||||
{
|
||||
var remoteName = $"C{gci + 1}";
|
||||
var remoteBase = gci * numPerCluster;
|
||||
return new RemoteGatewayOpts
|
||||
{
|
||||
Name = remoteName,
|
||||
Urls = Enumerable.Range(remoteBase, numPerCluster)
|
||||
.Select(idx => new Uri($"nats-gw://127.0.0.1:{gatewayPorts[idx]}"))
|
||||
.ToList(),
|
||||
};
|
||||
})
|
||||
.ToList(),
|
||||
},
|
||||
Routes = clusterPorts
|
||||
.Where((_, idx) => idx != si)
|
||||
.Select(p => new Uri($"nats-route://127.0.0.1:{p}"))
|
||||
.ToList(),
|
||||
};
|
||||
|
||||
var (server, _) = TestServerHelper.RunServer(serverOpts);
|
||||
servers[si] = server;
|
||||
opts[si] = serverOpts;
|
||||
}
|
||||
|
||||
clusters[ci] = TestCluster.FromServers(servers, opts, clusterName);
|
||||
}
|
||||
|
||||
var sc = new TestSuperCluster(clusters);
|
||||
sc.WaitOnLeader();
|
||||
return sc;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Accessors
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Finds the JetStream meta-leader across all clusters.
|
||||
/// Returns null if no leader is elected.
|
||||
/// Mirrors Go <c>sc.leader()</c>.
|
||||
/// </summary>
|
||||
public NatsServer? Leader()
|
||||
{
|
||||
foreach (var c in Clusters)
|
||||
{
|
||||
var l = c.Leader();
|
||||
if (l != null) return l;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a random running server from a random cluster.
|
||||
/// Mirrors Go <c>sc.randomServer()</c>.
|
||||
/// </summary>
|
||||
public NatsServer RandomServer()
|
||||
{
|
||||
var cluster = Clusters[Random.Shared.Next(Clusters.Length)];
|
||||
return cluster.RandomServer();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Searches all clusters for a server with the given name.
|
||||
/// Mirrors Go <c>sc.serverByName</c>.
|
||||
/// </summary>
|
||||
public NatsServer? ServerByName(string name)
|
||||
{
|
||||
foreach (var c in Clusters)
|
||||
{
|
||||
var s = c.ServerByName(name);
|
||||
if (s != null) return s;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the <see cref="TestCluster"/> with the given cluster name (e.g. "C1").
|
||||
/// Mirrors Go <c>sc.clusterForName</c>.
|
||||
/// </summary>
|
||||
public TestCluster? ClusterForName(string name)
|
||||
{
|
||||
foreach (var c in Clusters)
|
||||
{
|
||||
if (c.Name == name) return c;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Wait helpers
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Waits until a JetStream meta-leader is elected across all clusters.
|
||||
/// Mirrors Go <c>sc.waitOnLeader()</c>.
|
||||
/// </summary>
|
||||
public void WaitOnLeader()
|
||||
{
|
||||
CheckHelper.CheckFor(TimeSpan.FromSeconds(30), TimeSpan.FromMilliseconds(100), () =>
|
||||
{
|
||||
if (Leader() == null)
|
||||
return new Exception("SuperCluster: no JetStream meta-leader elected yet.");
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Waits until the named stream has an elected leader across all clusters.
|
||||
/// Mirrors Go <c>sc.waitOnStreamLeader</c>.
|
||||
/// </summary>
|
||||
public void WaitOnStreamLeader(string account, string stream)
|
||||
{
|
||||
CheckHelper.CheckFor(TimeSpan.FromSeconds(30), TimeSpan.FromMilliseconds(100), () =>
|
||||
{
|
||||
foreach (var c in Clusters)
|
||||
{
|
||||
if (c.StreamLeader(account, stream) != null) return null;
|
||||
}
|
||||
return new Exception(
|
||||
$"SuperCluster: no leader for stream '{stream}' in account '{account}'.");
|
||||
});
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Lifecycle
|
||||
// =========================================================================
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
|
||||
foreach (var c in Clusters)
|
||||
{
|
||||
try { c.Dispose(); } catch { /* best effort */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user