Merge branch 'worktree-agent-a24b291a'

This commit is contained in:
Joseph Doherty
2026-03-01 12:06:35 -05:00
8 changed files with 1125 additions and 0 deletions

View File

@@ -0,0 +1,117 @@
// 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 checkFor from server/test_test.go.
using System.Diagnostics;
using ZB.MOM.NatsNet.Server;
namespace ZB.MOM.NatsNet.Server.IntegrationTests.Helpers;
/// <summary>
/// Retry/polling helpers for integration tests.
/// Mirrors Go <c>checkFor</c> from server/test_test.go.
/// </summary>
internal static class CheckHelper
{
/// <summary>
/// Polls <paramref name="check"/> repeatedly until it returns null (success)
/// or the timeout expires, in which case the last exception is thrown.
/// Mirrors Go <c>checkFor(t, timeout, interval, func() error)</c>.
/// </summary>
public static void CheckFor(TimeSpan timeout, TimeSpan interval, Func<Exception?> check)
{
var sw = Stopwatch.StartNew();
Exception? last = null;
while (sw.Elapsed < timeout)
{
last = check();
if (last == null) return;
Thread.Sleep(interval);
}
// One final attempt after the sleep boundary.
last = check();
if (last == null) return;
throw new TimeoutException(
$"CheckFor timed out after {timeout}: {last.Message}", last);
}
/// <summary>
/// Async version of <see cref="CheckFor"/>. Uses <c>Task.Delay</c> instead of
/// <c>Thread.Sleep</c> to avoid blocking the thread pool.
/// </summary>
public static async Task CheckForAsync(
TimeSpan timeout,
TimeSpan interval,
Func<Task<Exception?>> check,
CancellationToken cancellationToken = default)
{
var sw = Stopwatch.StartNew();
Exception? last = null;
while (sw.Elapsed < timeout)
{
last = await check().ConfigureAwait(false);
if (last == null) return;
await Task.Delay(interval, cancellationToken).ConfigureAwait(false);
}
// One final attempt.
last = await check().ConfigureAwait(false);
if (last == null) return;
throw new TimeoutException(
$"CheckForAsync timed out after {timeout}: {last.Message}", last);
}
/// <summary>
/// Waits until all servers in <paramref name="servers"/> have formed a cluster
/// (each server sees at least <c>servers.Length - 1</c> routes).
/// Uses a 10-second timeout with 100 ms poll interval.
/// Mirrors Go <c>checkClusterFormed</c>.
/// </summary>
public static void CheckClusterFormed(params NatsServer[] servers)
{
var expected = servers.Length - 1;
CheckFor(TimeSpan.FromSeconds(10), TimeSpan.FromMilliseconds(100), () =>
{
foreach (var s in servers)
{
var routes = s.NumRoutes();
if (routes < expected)
return new Exception(
$"Server {s.Options.ServerName} has {routes} routes, expected {expected}.");
}
return null;
});
}
/// <summary>
/// Waits until the given server has at least <paramref name="expected"/>
/// leaf node connections.
/// Uses a 10-second timeout with 100 ms poll interval.
/// Mirrors Go <c>checkLeafNodeConnectedCount</c>.
/// </summary>
public static void CheckLeafNodeConnectedCount(NatsServer server, int expected)
{
CheckFor(TimeSpan.FromSeconds(10), TimeSpan.FromMilliseconds(100), () =>
{
var count = server.NumLeafNodes();
if (count < expected)
return new Exception(
$"Server {server.Options.ServerName} has {count} leaf nodes, expected {expected}.");
return null;
});
}
}

View File

@@ -0,0 +1,124 @@
// 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.
//
// Config templates mirror Go templates from server/jetstream_helpers_test.go.
// Note: C# string.Format uses {{ }} to escape literal braces.
namespace ZB.MOM.NatsNet.Server.IntegrationTests.Helpers;
/// <summary>
/// Config templates and temp config file management for integration tests.
/// Templates mirror the Go originals from server/jetstream_helpers_test.go.
/// </summary>
internal static class ConfigHelper
{
// =========================================================================
// Config templates
// =========================================================================
/// <summary>
/// Standard JetStream cluster template.
/// Placeholders: {0}=server_name, {1}=store_dir, {2}=cluster_name,
/// {3}=cluster_port, {4}=routes.
/// Mirrors Go <c>jsClusterTempl</c>.
/// </summary>
public const string JsClusterTemplate = @"
listen: 127.0.0.1:-1
server_name: {0}
jetstream: {{max_mem_store: 2GB, max_file_store: 2GB, store_dir: '{1}'}}
leaf {{
listen: 127.0.0.1:-1
}}
cluster {{
name: {2}
listen: 127.0.0.1:{3}
routes = [{4}]
}}
# For access to system account.
accounts {{ $SYS {{ users = [ {{ user: ""admin"", pass: ""s3cr3t!"" }} ] }} }}
";
/// <summary>
/// JetStream cluster template with multiple named accounts.
/// Placeholders: {0}=server_name, {1}=store_dir, {2}=cluster_name,
/// {3}=cluster_port, {4}=routes.
/// Mirrors Go <c>jsClusterAccountsTempl</c>.
/// </summary>
public const string JsClusterAccountsTemplate = @"
listen: 127.0.0.1:-1
server_name: {0}
jetstream: {{max_mem_store: 2GB, max_file_store: 2GB, store_dir: '{1}'}}
leaf {{
listen: 127.0.0.1:-1
}}
cluster {{
name: {2}
listen: 127.0.0.1:{3}
routes = [{4}]
}}
no_auth_user: one
accounts {{
ONE {{ users = [ {{ user: ""one"", pass: ""p"" }} ]; jetstream: enabled }}
TWO {{ users = [ {{ user: ""two"", pass: ""p"" }} ]; jetstream: enabled }}
NOJS {{ users = [ {{ user: ""nojs"", pass: ""p"" }} ] }}
$SYS {{ users = [ {{ user: ""admin"", pass: ""s3cr3t!"" }} ] }}
}}
";
/// <summary>
/// Super-cluster gateway wrapper template.
/// Placeholders: {0}=inner_cluster_config, {1}=gateway_name,
/// {2}=gateway_port, {3}=gateway_list.
/// Mirrors Go <c>jsSuperClusterTempl</c>.
/// </summary>
public const string JsSuperClusterTemplate = @"
{0}
gateway {{
name: {1}
listen: 127.0.0.1:{2}
gateways = [{3}
]
}}
system_account: ""$SYS""
";
/// <summary>
/// Gateway entry template used inside <see cref="JsSuperClusterTemplate"/>.
/// Placeholders: {0}=prefix_whitespace, {1}=gateway_name, {2}=urls.
/// Mirrors Go <c>jsGWTempl</c>.
/// </summary>
public const string JsGatewayEntryTemplate = @"{0}{{name: {1}, urls: [{2}]}}";
// =========================================================================
// File helpers
// =========================================================================
/// <summary>
/// Writes <paramref name="content"/> to a temporary file and returns the path.
/// The caller is responsible for deleting the file when done.
/// </summary>
public static string CreateConfigFile(string content)
{
var path = Path.Combine(Path.GetTempPath(), "nats-test-" + Guid.NewGuid().ToString("N")[..8] + ".conf");
File.WriteAllText(path, content);
return path;
}
}

View File

@@ -0,0 +1,57 @@
// 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.
using Xunit.Abstractions;
namespace ZB.MOM.NatsNet.Server.IntegrationTests;
/// <summary>
/// Abstract base class for all integration tests.
/// Skips the entire test class if the server cannot boot (i.e., the .NET server
/// runtime is not yet complete). Individual test classes inherit from this class.
/// </summary>
[Trait("Category", "Integration")]
public abstract class IntegrationTestBase : IDisposable
{
// =========================================================================
// Constructor — Skip guard
// =========================================================================
/// <summary>
/// Initializes the test base and verifies that the server can boot.
/// If <see cref="Helpers.TestServerHelper.CanBoot()"/> returns false the test
/// is skipped via <c>Xunit.SkippableFact</c>'s <c>Skip.If</c> mechanism.
/// </summary>
protected IntegrationTestBase(ITestOutputHelper output)
{
Output = output;
Skip.If(!Helpers.TestServerHelper.CanBoot(), "Server cannot boot — skipping integration tests.");
}
// =========================================================================
// Protected members
// =========================================================================
/// <summary>xUnit output helper, available to derived test classes.</summary>
protected ITestOutputHelper Output { get; }
// =========================================================================
// IDisposable
// =========================================================================
/// <summary>
/// Override in subclasses to perform per-test cleanup (e.g., shut down servers,
/// delete temp dirs). The base implementation does nothing.
/// </summary>
public virtual void Dispose() { }
}

View File

@@ -0,0 +1,66 @@
// 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 natsConnect helpers from test files.
using NATS.Client.Core;
using ZB.MOM.NatsNet.Server;
namespace ZB.MOM.NatsNet.Server.IntegrationTests.Helpers;
/// <summary>
/// NATS.Client.Core wrapper helpers for integration test connections.
/// Mirrors Go <c>natsConnect</c> pattern from test helper files.
/// </summary>
internal static class NatsTestClient
{
// Default test connection options applied unless overridden.
private static readonly NatsOpts DefaultTestOpts = new()
{
Name = "test-client",
ConnectTimeout = TimeSpan.FromSeconds(5),
RequestTimeout = TimeSpan.FromSeconds(10),
};
/// <summary>
/// Creates a <see cref="NatsConnection"/> to the given <paramref name="url"/> with
/// sensible test defaults. Settings in <paramref name="opts"/> override the defaults.
/// </summary>
public static NatsConnection Connect(string url, NatsOpts? opts = null)
{
var effective = opts ?? DefaultTestOpts;
// Always override the URL; apply default name when not supplied.
effective = effective with { Url = url };
if (string.IsNullOrEmpty(effective.Name))
effective = effective with { Name = DefaultTestOpts.Name };
return new NatsConnection(effective);
}
/// <summary>
/// Creates a <see cref="NatsConnection"/> to the given <paramref name="server"/>.
/// The URL is derived from the server's client port — uses the value from
/// <see cref="ServerOptions.Port"/> (resolved during server setup). When the server
/// was configured with port -1 (random), the actual port is stored in
/// <see cref="ServerOptions.Port"/> after Start().
/// </summary>
public static NatsConnection ConnectToServer(NatsServer server, NatsOpts? opts = null)
{
var port = server.Options.Port;
// Fallback to well-known port if options still show 0 or -1.
if (port <= 0) port = 4222;
return Connect($"nats://127.0.0.1:{port}", opts);
}
}

View File

@@ -0,0 +1,331 @@
// 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;
/// <summary>
/// Represents a multi-server JetStream cluster for integration tests.
/// Mirrors Go <c>cluster</c> struct from server/jetstream_helpers_test.go.
/// </summary>
internal sealed class TestCluster : IDisposable
{
// =========================================================================
// Properties
// =========================================================================
/// <summary>Running server instances in the cluster.</summary>
public NatsServer[] Servers { get; }
/// <summary>Options used to configure each server.</summary>
public ServerOptions[] Options { get; }
/// <summary>Name of this cluster (e.g. "HUB").</summary>
public string Name { get; }
private bool _disposed;
// =========================================================================
// Constructor
// =========================================================================
private TestCluster(NatsServer[] servers, ServerOptions[] options, string name)
{
Servers = servers;
Options = options;
Name = name;
}
/// <summary>
/// Internal factory used by <see cref="TestSuperCluster"/> to wrap pre-started servers.
/// </summary>
internal static TestCluster FromServers(NatsServer[] servers, ServerOptions[] options, string name)
=> new(servers, options, name);
// =========================================================================
// Static factory: standard JetStream cluster
// =========================================================================
/// <summary>
/// Creates a JetStream cluster using the default <see cref="ConfigHelper.JsClusterTemplate"/>.
/// Mirrors Go <c>createJetStreamCluster</c>.
/// </summary>
public static TestCluster CreateJetStreamCluster(int numServers, string name) =>
CreateJetStreamClusterWithTemplate(ConfigHelper.JsClusterTemplate, numServers, name);
/// <summary>
/// Creates a JetStream cluster using the provided config <paramref name="template"/>.
/// 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 <c>createJetStreamClusterWithTemplate</c>.
/// </summary>
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
// =========================================================================
/// <summary>
/// Waits until all servers in the cluster have formed routes to one another.
/// Mirrors Go <c>checkClusterFormed</c>.
/// </summary>
public void WaitOnClusterReady()
{
CheckHelper.CheckClusterFormed(Servers);
}
/// <summary>
/// Waits until at least one server in the cluster reports as JetStream meta-leader.
/// Mirrors Go <c>c.waitOnLeader</c>.
/// </summary>
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;
});
}
/// <summary>
/// Waits until the named stream has an elected leader in the given account.
/// Mirrors Go <c>c.waitOnStreamLeader</c>.
/// </summary>
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;
});
}
/// <summary>
/// Waits until the named consumer has an elected leader.
/// Mirrors Go <c>c.waitOnConsumerLeader</c>.
/// </summary>
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
// =========================================================================
/// <summary>
/// Returns the server that is currently the JetStream meta-leader,
/// or null if no leader is elected.
/// Mirrors Go <c>c.leader()</c>.
/// </summary>
public NatsServer? Leader()
{
foreach (var s in Servers)
{
if (s.JetStreamIsLeader())
return s;
}
return null;
}
/// <summary>
/// Returns the server that is leader for the named stream in the given account,
/// or null if no leader is elected.
/// Mirrors Go <c>c.streamLeader</c>.
/// </summary>
public NatsServer? StreamLeader(string account, string stream)
{
foreach (var s in Servers)
{
if (s.JetStreamIsStreamLeader(account, stream))
return s;
}
return null;
}
/// <summary>
/// Returns the server that is leader for the named consumer,
/// or null if no leader is elected.
/// Mirrors Go <c>c.consumerLeader</c>.
/// </summary>
public NatsServer? ConsumerLeader(string account, string stream, string consumer)
{
foreach (var s in Servers)
{
if (s.JetStreamIsConsumerLeader(account, stream, consumer))
return s;
}
return null;
}
/// <summary>
/// Returns a random running server from the cluster.
/// Mirrors Go <c>c.randomServer()</c>.
/// </summary>
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)];
}
/// <summary>
/// Finds a server by its <see cref="ServerOptions.ServerName"/>.
/// Returns null if not found.
/// Mirrors Go <c>c.serverByName</c>.
/// </summary>
public NatsServer? ServerByName(string name)
{
foreach (var s in Servers)
{
if (s.Options.ServerName == name)
return s;
}
return null;
}
// =========================================================================
// Lifecycle
// =========================================================================
/// <summary>Stops all servers in the cluster.</summary>
public void StopAll()
{
foreach (var s in Servers)
{
try { s.Shutdown(); } catch { /* best effort */ }
}
}
/// <summary>
/// Restarts all stopped servers.
/// Note: a true restart would re-create the server; here we call Start() if not running.
/// </summary>
public void RestartAll()
{
foreach (var (server, i) in Servers.Select((s, i) => (s, i)))
{
if (!server.Running())
{
try { server.Start(); } catch { /* best effort */ }
}
}
}
/// <summary>Shuts down and disposes all servers and cleans up temp files.</summary>
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 */ }
}
}
}
}

View File

@@ -0,0 +1,135 @@
// 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 test helpers: RunServer, GetFreePort, etc. from server/test_test.go.
using System.Net;
using System.Net.Sockets;
using Xunit.Abstractions;
using ZB.MOM.NatsNet.Server;
namespace ZB.MOM.NatsNet.Server.IntegrationTests.Helpers;
/// <summary>
/// Server lifecycle helpers for integration tests.
/// Mirrors Go patterns from server/test_test.go: RunServer, GetFreePort, etc.
/// </summary>
internal static class TestServerHelper
{
/// <summary>
/// Returns true if a NatsServer can be instantiated with basic options.
/// Used as a Skip guard — if the server can't boot, all integration tests skip gracefully.
/// </summary>
public static bool CanBoot()
{
try
{
var opts = new ServerOptions
{
Host = "127.0.0.1",
Port = -1,
NoLog = true,
NoSigs = true,
};
var (server, err) = NatsServer.NewServer(opts);
if (err != null || server == null)
return false;
server.Shutdown();
return true;
}
catch
{
return false;
}
}
/// <summary>
/// Creates and starts a NatsServer with the given options.
/// Returns the running server and the options used.
/// Mirrors Go <c>RunServer</c>.
/// </summary>
public static (NatsServer Server, ServerOptions Options) RunServer(ServerOptions opts)
{
var (server, err) = NatsServer.NewServer(opts);
if (err != null)
throw new InvalidOperationException($"Failed to create server: {err.Message}", err);
if (server == null)
throw new InvalidOperationException("Failed to create server: NewServer returned null.");
server.Start();
return (server, opts);
}
/// <summary>
/// Creates and starts a NatsServer with JetStream enabled and a temp store directory.
/// Mirrors Go <c>RunServer</c> with JetStream options.
/// </summary>
public static NatsServer RunBasicJetStreamServer(ITestOutputHelper? output = null)
{
var storeDir = CreateTempDir("js-store-");
var opts = new ServerOptions
{
Host = "127.0.0.1",
Port = -1,
NoLog = true,
NoSigs = true,
JetStream = true,
StoreDir = storeDir,
};
var (server, _) = RunServer(opts);
return server;
}
/// <summary>
/// Creates and starts a NatsServer using the options parsed from a config file path.
/// The config file content is read and minimal parsing extracts key options.
/// Returns the running server and the options.
/// </summary>
public static (NatsServer Server, ServerOptions Options) RunServerWithConfig(string configFile)
{
var opts = new ServerOptions
{
ConfigFile = configFile,
NoLog = true,
NoSigs = true,
};
return RunServer(opts);
}
/// <summary>
/// Finds a free TCP port on loopback.
/// Mirrors Go <c>GetFreePort</c>.
/// </summary>
public static int GetFreePort()
{
var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
listener.Stop();
return port;
}
/// <summary>
/// Creates a uniquely named temp directory with the given prefix.
/// The caller is responsible for deleting it when done.
/// </summary>
public static string CreateTempDir(string prefix = "nats-test-")
{
var path = Path.Combine(Path.GetTempPath(), prefix + Guid.NewGuid().ToString("N")[..8]);
Directory.CreateDirectory(path);
return path;
}
}

View File

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

View File

@@ -20,6 +20,7 @@
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
<PackageReference Include="Shouldly" Version="*" />
<PackageReference Include="NSubstitute" Version="*" />
<PackageReference Include="Xunit.SkippableFact" Version="*" />
</ItemGroup>
<ItemGroup>