// 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 */ } } } }