// 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 cluster struct and createJetStreamCluster* helpers from // server/jetstream_helpers_test.go. using ZB.MOM.NatsNet.Server; namespace ZB.MOM.NatsNet.Server.IntegrationTests.Helpers; /// /// Represents a multi-server JetStream cluster for integration tests. /// Mirrors Go cluster struct from server/jetstream_helpers_test.go. /// internal sealed class TestCluster : IDisposable { // ========================================================================= // Properties // ========================================================================= /// Running server instances in the cluster. public NatsServer[] Servers { get; } /// Options used to configure each server. public ServerOptions[] Options { get; } /// Name of this cluster (e.g. "HUB"). public string Name { get; } private bool _disposed; // ========================================================================= // Constructor // ========================================================================= private TestCluster(NatsServer[] servers, ServerOptions[] options, string name) { Servers = servers; Options = options; Name = name; } /// /// Internal factory used by to wrap pre-started servers. /// internal static TestCluster FromServers(NatsServer[] servers, ServerOptions[] options, string name) => new(servers, options, name); // ========================================================================= // Static factory: standard JetStream cluster // ========================================================================= /// /// Creates a JetStream cluster using the default . /// Mirrors Go createJetStreamCluster. /// public static TestCluster CreateJetStreamCluster(int numServers, string name) => CreateJetStreamClusterWithTemplate(ConfigHelper.JsClusterTemplate, numServers, name); /// /// Creates a JetStream cluster using the provided config . /// Allocates free ports for each server's client and cluster listeners, builds route /// URLs, generates per-server config from the template, starts all servers, and /// waits for the cluster to form. /// Mirrors Go createJetStreamClusterWithTemplate. /// public static TestCluster CreateJetStreamClusterWithTemplate( string template, int numServers, string name) { // Allocate cluster (route) ports — one per server. var clusterPorts = new int[numServers]; for (var i = 0; i < numServers; i++) clusterPorts[i] = TestServerHelper.GetFreePort(); // Build the routes string shared by all servers in this cluster. var routeUrls = string.Join(",", clusterPorts.Select(p => $"nats-route://127.0.0.1:{p}")); var servers = new NatsServer[numServers]; var opts = new ServerOptions[numServers]; for (var i = 0; i < numServers; i++) { var serverName = $"{name}-S{i + 1}"; var storeDir = TestServerHelper.CreateTempDir($"js-{name}-{i + 1}-"); // Format template: {0}=server_name, {1}=store_dir, {2}=cluster_name, // {3}=cluster_port, {4}=routes var configContent = string.Format( template, serverName, storeDir, name, clusterPorts[i], routeUrls); var configFile = ConfigHelper.CreateConfigFile(configContent); 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 = name, Host = "127.0.0.1", Port = clusterPorts[i], }, Routes = clusterPorts .Where((_, idx) => idx != i) .Select(p => new Uri($"nats-route://127.0.0.1:{p}")) .ToList(), }; var (server, _) = TestServerHelper.RunServer(serverOpts); servers[i] = server; opts[i] = serverOpts; } var cluster = new TestCluster(servers, opts, name); cluster.WaitOnClusterReady(); return cluster; } // ========================================================================= // Wait helpers // ========================================================================= /// /// Waits until all servers in the cluster have formed routes to one another. /// Mirrors Go checkClusterFormed. /// public void WaitOnClusterReady() { CheckHelper.CheckClusterFormed(Servers); } /// /// Waits until at least one server in the cluster reports as JetStream meta-leader. /// Mirrors Go c.waitOnLeader. /// public void WaitOnLeader() { CheckHelper.CheckFor(TimeSpan.FromSeconds(30), TimeSpan.FromMilliseconds(100), () => { var leader = Leader(); if (leader == null) return new Exception($"Cluster {Name}: no JetStream meta-leader elected yet."); return null; }); } /// /// Waits until the named stream has an elected leader in the given account. /// Mirrors Go c.waitOnStreamLeader. /// public void WaitOnStreamLeader(string account, string stream) { CheckHelper.CheckFor(TimeSpan.FromSeconds(30), TimeSpan.FromMilliseconds(100), () => { var leader = StreamLeader(account, stream); if (leader == null) return new Exception( $"Cluster {Name}: no leader for stream '{stream}' in account '{account}'."); return null; }); } /// /// Waits until the named consumer has an elected leader. /// Mirrors Go c.waitOnConsumerLeader. /// public void WaitOnConsumerLeader(string account, string stream, string consumer) { CheckHelper.CheckFor(TimeSpan.FromSeconds(30), TimeSpan.FromMilliseconds(100), () => { var leader = ConsumerLeader(account, stream, consumer); if (leader == null) return new Exception( $"Cluster {Name}: no leader for consumer '{consumer}' in stream '{stream}', account '{account}'."); return null; }); } // ========================================================================= // Accessors // ========================================================================= /// /// Returns the server that is currently the JetStream meta-leader, /// or null if no leader is elected. /// Mirrors Go c.leader(). /// public NatsServer? Leader() { foreach (var s in Servers) { if (s.JetStreamIsLeader()) return s; } return null; } /// /// Returns the server that is leader for the named stream in the given account, /// or null if no leader is elected. /// Mirrors Go c.streamLeader. /// public NatsServer? StreamLeader(string account, string stream) { foreach (var s in Servers) { if (s.JetStreamIsStreamLeader(account, stream)) return s; } return null; } /// /// Returns the server that is leader for the named consumer, /// or null if no leader is elected. /// Mirrors Go c.consumerLeader. /// public NatsServer? ConsumerLeader(string account, string stream, string consumer) { foreach (var s in Servers) { if (s.JetStreamIsConsumerLeader(account, stream, consumer)) return s; } return null; } /// /// Returns a random running server from the cluster. /// Mirrors Go c.randomServer(). /// public NatsServer RandomServer() { var candidates = Servers.Where(s => s.Running()).ToArray(); if (candidates.Length == 0) throw new InvalidOperationException($"Cluster {Name}: no running servers."); return candidates[Random.Shared.Next(candidates.Length)]; } /// /// Finds a server by its . /// Returns null if not found. /// Mirrors Go c.serverByName. /// public NatsServer? ServerByName(string name) { foreach (var s in Servers) { if (s.Options.ServerName == name) return s; } return null; } // ========================================================================= // Lifecycle // ========================================================================= /// Stops all servers in the cluster. public void StopAll() { foreach (var s in Servers) { try { s.Shutdown(); } catch { /* best effort */ } } } /// /// Restarts all stopped servers. /// Note: a true restart would re-create the server; here we call Start() if not running. /// public void RestartAll() { foreach (var (server, i) in Servers.Select((s, i) => (s, i))) { if (!server.Running()) { try { server.Start(); } catch { /* best effort */ } } } } /// Shuts down and disposes all servers and cleans up temp files. public void Dispose() { if (_disposed) return; _disposed = true; foreach (var (server, i) in Servers.Select((s, i) => (s, i))) { try { server.Shutdown(); } catch { /* best effort */ } // Clean up temp store dir. var dir = Options[i].StoreDir; if (!string.IsNullOrEmpty(dir) && Directory.Exists(dir)) { try { Directory.Delete(dir, recursive: true); } catch { /* best effort */ } } // Clean up temp config file. var cfg = Options[i].ConfigFile; if (!string.IsNullOrEmpty(cfg) && File.Exists(cfg)) { try { File.Delete(cfg); } catch { /* best effort */ } } } } }