// 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;
///
/// Represents a multi-cluster super-cluster connected via NATS gateways.
/// Mirrors Go supercluster struct from server/jetstream_helpers_test.go.
///
internal sealed class TestSuperCluster : IDisposable
{
// =========================================================================
// Properties
// =========================================================================
/// All clusters that form this super-cluster.
public TestCluster[] Clusters { get; }
private bool _disposed;
// =========================================================================
// Constructor
// =========================================================================
private TestSuperCluster(TestCluster[] clusters)
{
Clusters = clusters;
}
// =========================================================================
// Static factory
// =========================================================================
///
/// Creates a JetStream super-cluster consisting of clusters,
/// each with servers, connected via gateways.
/// Cluster names are C1, C2, … Cn.
/// Mirrors Go createJetStreamSuperCluster.
///
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
// =========================================================================
///
/// Finds the JetStream meta-leader across all clusters.
/// Returns null if no leader is elected.
/// Mirrors Go sc.leader().
///
public NatsServer? Leader()
{
foreach (var c in Clusters)
{
var l = c.Leader();
if (l != null) return l;
}
return null;
}
///
/// Returns a random running server from a random cluster.
/// Mirrors Go sc.randomServer().
///
public NatsServer RandomServer()
{
var cluster = Clusters[Random.Shared.Next(Clusters.Length)];
return cluster.RandomServer();
}
///
/// Searches all clusters for a server with the given name.
/// Mirrors Go sc.serverByName.
///
public NatsServer? ServerByName(string name)
{
foreach (var c in Clusters)
{
var s = c.ServerByName(name);
if (s != null) return s;
}
return null;
}
///
/// Returns the with the given cluster name (e.g. "C1").
/// Mirrors Go sc.clusterForName.
///
public TestCluster? ClusterForName(string name)
{
foreach (var c in Clusters)
{
if (c.Name == name) return c;
}
return null;
}
// =========================================================================
// Wait helpers
// =========================================================================
///
/// Waits until a JetStream meta-leader is elected across all clusters.
/// Mirrors Go sc.waitOnLeader().
///
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;
});
}
///
/// Waits until the named stream has an elected leader across all clusters.
/// Mirrors Go sc.waitOnStreamLeader.
///
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
// =========================================================================
///
public void Dispose()
{
if (_disposed) return;
_disposed = true;
foreach (var c in Clusters)
{
try { c.Dispose(); } catch { /* best effort */ }
}
}
}