Merge branch 'codex/jetstream-full-parity-executeplan' into main
# Conflicts: # differences.md # docs/plans/2026-02-23-jetstream-full-parity-plan.md # src/NATS.Server/Auth/Account.cs # src/NATS.Server/Configuration/ConfigProcessor.cs # src/NATS.Server/Monitoring/VarzHandler.cs # src/NATS.Server/NatsClient.cs # src/NATS.Server/NatsOptions.cs # src/NATS.Server/NatsServer.cs
This commit is contained in:
14
tests/NATS.Server.Tests/ClientKindCommandMatrixTests.cs
Normal file
14
tests/NATS.Server.Tests/ClientKindCommandMatrixTests.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
using NATS.Server.Protocol;
|
||||
|
||||
namespace NATS.Server.Tests;
|
||||
|
||||
public class ClientKindCommandMatrixTests
|
||||
{
|
||||
[Fact]
|
||||
public void Router_only_commands_are_rejected_for_client_kind()
|
||||
{
|
||||
var matrix = new ClientCommandMatrix();
|
||||
matrix.IsAllowed(ClientKind.Client, "RS+").ShouldBeFalse();
|
||||
matrix.IsAllowed(ClientKind.Router, "RS+").ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using NATS.Server.Configuration;
|
||||
|
||||
namespace NATS.Server.Tests;
|
||||
|
||||
public class ClusterJetStreamConfigProcessorTests
|
||||
{
|
||||
[Fact]
|
||||
public void ConfigProcessor_maps_jetstream_and_cluster_blocks()
|
||||
{
|
||||
var cfg = """
|
||||
cluster { name: C1; listen: 127.0.0.1:6222 }
|
||||
jetstream { store_dir: /tmp/js; max_mem_store: 1GB; max_file_store: 10GB }
|
||||
""";
|
||||
|
||||
var opts = ConfigProcessor.ProcessConfig(cfg);
|
||||
|
||||
opts.Cluster.ShouldNotBeNull();
|
||||
opts.JetStream.ShouldNotBeNull();
|
||||
opts.JetStream!.StoreDir.ShouldBe("/tmp/js");
|
||||
}
|
||||
}
|
||||
18
tests/NATS.Server.Tests/FileStoreTests.cs
Normal file
18
tests/NATS.Server.Tests/FileStoreTests.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
using NATS.Server.JetStream.Storage;
|
||||
|
||||
namespace NATS.Server.Tests;
|
||||
|
||||
public class FileStoreTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task FileStore_recovers_messages_after_restart()
|
||||
{
|
||||
var dir = Directory.CreateTempSubdirectory();
|
||||
|
||||
await using (var store = new FileStore(new FileStoreOptions { Directory = dir.FullName }))
|
||||
await store.AppendAsync("foo", "payload"u8.ToArray(), default);
|
||||
|
||||
await using var recovered = new FileStore(new FileStoreOptions { Directory = dir.FullName });
|
||||
(await recovered.GetStateAsync(default)).Messages.ShouldBe((ulong)1);
|
||||
}
|
||||
}
|
||||
14
tests/NATS.Server.Tests/GatewayLeafBootstrapTests.cs
Normal file
14
tests/NATS.Server.Tests/GatewayLeafBootstrapTests.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
namespace NATS.Server.Tests;
|
||||
|
||||
public class GatewayLeafBootstrapTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Server_bootstraps_gateway_and_leaf_managers_when_configured()
|
||||
{
|
||||
await using var server = await TestServerFactory.CreateWithGatewayAndLeafAsync();
|
||||
await server.WaitForReadyAsync();
|
||||
|
||||
server.Stats.Gateways.ShouldBeGreaterThanOrEqualTo(0);
|
||||
server.Stats.Leafs.ShouldBeGreaterThanOrEqualTo(0);
|
||||
}
|
||||
}
|
||||
21
tests/NATS.Server.Tests/GoParityRunnerTests.cs
Normal file
21
tests/NATS.Server.Tests/GoParityRunnerTests.cs
Normal file
@@ -0,0 +1,21 @@
|
||||
namespace NATS.Server.Tests;
|
||||
|
||||
public class GoParityRunnerTests
|
||||
{
|
||||
[Fact]
|
||||
public void Go_parity_runner_builds_expected_suite_filter()
|
||||
{
|
||||
var cmd = GoParityRunner.BuildCommand();
|
||||
cmd.ShouldContain("go test");
|
||||
cmd.ShouldContain("TestJetStream");
|
||||
cmd.ShouldContain("TestRaft");
|
||||
}
|
||||
}
|
||||
|
||||
internal static class GoParityRunner
|
||||
{
|
||||
public static string BuildCommand()
|
||||
{
|
||||
return "go test -v -run 'TestJetStream|TestJetStreamCluster|TestLongCluster|TestRaft' ./server -count=1 -timeout=180m";
|
||||
}
|
||||
}
|
||||
17
tests/NATS.Server.Tests/JetStreamAckRedeliveryTests.cs
Normal file
17
tests/NATS.Server.Tests/JetStreamAckRedeliveryTests.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
namespace NATS.Server.Tests;
|
||||
|
||||
public class JetStreamAckRedeliveryTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Unacked_message_is_redelivered_after_ack_wait()
|
||||
{
|
||||
await using var fixture = await JetStreamApiFixture.StartWithAckExplicitConsumerAsync(ackWaitMs: 50);
|
||||
await fixture.PublishAndGetAckAsync("orders.created", "1");
|
||||
|
||||
var first = await fixture.FetchAsync("ORDERS", "PULL", batch: 1);
|
||||
var second = await fixture.FetchAfterDelayAsync("ORDERS", "PULL", delayMs: 75, batch: 1);
|
||||
|
||||
second.Messages.Single().Sequence.ShouldBe(first.Messages.Single().Sequence);
|
||||
second.Messages.Single().Redelivered.ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
178
tests/NATS.Server.Tests/JetStreamApiFixture.cs
Normal file
178
tests/NATS.Server.Tests/JetStreamApiFixture.cs
Normal file
@@ -0,0 +1,178 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using NATS.Server.Auth;
|
||||
using NATS.Server.JetStream;
|
||||
using NATS.Server.JetStream.Api;
|
||||
using NATS.Server.JetStream.Consumers;
|
||||
using NATS.Server.JetStream.Models;
|
||||
using NATS.Server.JetStream.Publish;
|
||||
|
||||
namespace NATS.Server.Tests;
|
||||
|
||||
internal sealed class JetStreamApiFixture : IAsyncDisposable
|
||||
{
|
||||
private static readonly StreamManager SharedStreamManager = new();
|
||||
private static readonly ConsumerManager SharedConsumerManager = new();
|
||||
private static readonly JetStreamApiRouter SharedRouter = new(SharedStreamManager, SharedConsumerManager);
|
||||
|
||||
private readonly StreamManager _streamManager;
|
||||
private readonly ConsumerManager _consumerManager;
|
||||
private readonly JetStreamApiRouter _router;
|
||||
private readonly JetStreamPublisher _publisher;
|
||||
|
||||
private JetStreamApiFixture(Account? account = null)
|
||||
{
|
||||
_streamManager = new StreamManager(account: account);
|
||||
_consumerManager = new ConsumerManager();
|
||||
_router = new JetStreamApiRouter(_streamManager, _consumerManager);
|
||||
_publisher = new JetStreamPublisher(_streamManager);
|
||||
}
|
||||
|
||||
public static Task<JetStreamApiResponse> RequestAsync(string subject, string payload)
|
||||
{
|
||||
return Task.FromResult(SharedRouter.Route(subject, Encoding.UTF8.GetBytes(payload)));
|
||||
}
|
||||
|
||||
public static async Task<JetStreamApiFixture> StartWithStreamAsync(string streamName, string subject, int maxMsgs = 0)
|
||||
{
|
||||
var fixture = new JetStreamApiFixture();
|
||||
var payload = $"{{\"name\":\"{streamName}\",\"subjects\":[\"{subject}\"],\"max_msgs\":{maxMsgs}}}";
|
||||
_ = await fixture.RequestLocalAsync($"$JS.API.STREAM.CREATE.{streamName}", payload);
|
||||
return fixture;
|
||||
}
|
||||
|
||||
public static async Task<JetStreamApiFixture> StartWithPullConsumerAsync()
|
||||
{
|
||||
var fixture = await StartWithStreamAsync("ORDERS", "orders.*");
|
||||
_ = await fixture.CreateConsumerAsync("ORDERS", "PULL", "orders.created");
|
||||
return fixture;
|
||||
}
|
||||
|
||||
public static async Task<JetStreamApiFixture> StartWithPushConsumerAsync()
|
||||
{
|
||||
var fixture = await StartWithStreamAsync("ORDERS", "orders.*");
|
||||
_ = await fixture.CreateConsumerAsync("ORDERS", "PUSH", "orders.created", push: true, heartbeatMs: 25);
|
||||
return fixture;
|
||||
}
|
||||
|
||||
public static async Task<JetStreamApiFixture> StartWithAckExplicitConsumerAsync(int ackWaitMs)
|
||||
{
|
||||
var fixture = await StartWithStreamAsync("ORDERS", "orders.*");
|
||||
_ = await fixture.CreateConsumerAsync("ORDERS", "PULL", "orders.created",
|
||||
ackPolicy: AckPolicy.Explicit, ackWaitMs: ackWaitMs);
|
||||
return fixture;
|
||||
}
|
||||
|
||||
public static async Task<JetStreamApiFixture> StartWithMirrorSetupAsync()
|
||||
{
|
||||
var fixture = await StartWithStreamAsync("ORDERS", "orders.*");
|
||||
_ = fixture._streamManager.CreateOrUpdate(new StreamConfig
|
||||
{
|
||||
Name = "ORDERS_MIRROR",
|
||||
Subjects = ["orders.mirror.*"],
|
||||
Mirror = "ORDERS",
|
||||
});
|
||||
return fixture;
|
||||
}
|
||||
|
||||
public static Task<JetStreamApiFixture> StartJwtLimitedAccountAsync(int maxStreams)
|
||||
{
|
||||
var account = new Account("JWT-LIMITED")
|
||||
{
|
||||
MaxJetStreamStreams = maxStreams,
|
||||
JetStreamTier = "jwt-tier",
|
||||
};
|
||||
|
||||
return Task.FromResult(new JetStreamApiFixture(account));
|
||||
}
|
||||
|
||||
public Task<PubAck> PublishAndGetAckAsync(string subject, string payload, string? msgId = null, bool expectError = false)
|
||||
{
|
||||
if (_publisher.TryCapture(subject, Encoding.UTF8.GetBytes(payload), msgId, out var ack))
|
||||
{
|
||||
if (ack.ErrorCode == null && _streamManager.TryGet(ack.Stream, out var streamHandle))
|
||||
{
|
||||
var stored = streamHandle.Store.LoadAsync(ack.Seq, default).GetAwaiter().GetResult();
|
||||
if (stored != null)
|
||||
_consumerManager.OnPublished(ack.Stream, stored);
|
||||
}
|
||||
|
||||
return Task.FromResult(ack);
|
||||
}
|
||||
|
||||
if (expectError)
|
||||
return Task.FromResult(new PubAck { ErrorCode = 404 });
|
||||
|
||||
throw new InvalidOperationException($"No stream matched subject '{subject}'.");
|
||||
}
|
||||
|
||||
public Task<PubAck> PublishAndGetAckAsync(string streamName, string subject, string payload)
|
||||
{
|
||||
return PublishAndGetAckAsync(subject, payload);
|
||||
}
|
||||
|
||||
public Task<JetStreamApiResponse> RequestLocalAsync(string subject, string payload)
|
||||
{
|
||||
return Task.FromResult(_router.Route(subject, Encoding.UTF8.GetBytes(payload)));
|
||||
}
|
||||
|
||||
public Task<JetStreamApiResponse> CreateStreamAsync(string streamName, IReadOnlyList<string> subjects)
|
||||
{
|
||||
var payload = JsonSerializer.Serialize(new
|
||||
{
|
||||
name = streamName,
|
||||
subjects,
|
||||
});
|
||||
return RequestLocalAsync($"$JS.API.STREAM.CREATE.{streamName}", payload);
|
||||
}
|
||||
|
||||
public Task<StreamState> GetStreamStateAsync(string streamName)
|
||||
{
|
||||
return _streamManager.GetStateAsync(streamName, default).AsTask();
|
||||
}
|
||||
|
||||
public Task<JetStreamApiResponse> CreateConsumerAsync(string stream, string durableName, string filterSubject, bool push = false, int heartbeatMs = 0, AckPolicy ackPolicy = AckPolicy.None, int ackWaitMs = 30_000)
|
||||
{
|
||||
var payload = $@"{{""durable_name"":""{durableName}"",""filter_subject"":""{filterSubject}"",""push"":{push.ToString().ToLowerInvariant()},""heartbeat_ms"":{heartbeatMs},""ack_policy"":""{ackPolicy.ToString().ToLowerInvariant()}"",""ack_wait_ms"":{ackWaitMs}}}";
|
||||
return RequestLocalAsync($"$JS.API.CONSUMER.CREATE.{stream}.{durableName}", payload);
|
||||
}
|
||||
|
||||
public async Task<JetStreamConsumerInfo> GetConsumerInfoAsync(string stream, string durableName)
|
||||
{
|
||||
var response = await RequestLocalAsync($"$JS.API.CONSUMER.INFO.{stream}.{durableName}", "{}");
|
||||
return response.ConsumerInfo ?? throw new InvalidOperationException("Consumer not found.");
|
||||
}
|
||||
|
||||
public Task<PullFetchBatch> FetchAsync(string stream, string durableName, int batch)
|
||||
{
|
||||
return _consumerManager.FetchAsync(stream, durableName, batch, _streamManager, default).AsTask();
|
||||
}
|
||||
|
||||
public async Task<PullFetchBatch> FetchAfterDelayAsync(string stream, string durableName, int delayMs, int batch)
|
||||
{
|
||||
await Task.Delay(delayMs);
|
||||
return await FetchAsync(stream, durableName, batch);
|
||||
}
|
||||
|
||||
public Task<PushFrame> ReadPushFrameAsync(string stream = "ORDERS", string durableName = "PUSH")
|
||||
{
|
||||
var frame = _consumerManager.ReadPushFrame(stream, durableName);
|
||||
if (frame == null)
|
||||
throw new InvalidOperationException("No push frame available.");
|
||||
return Task.FromResult(frame);
|
||||
}
|
||||
|
||||
public async Task WaitForMirrorSyncAsync(string streamName)
|
||||
{
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(2));
|
||||
while (!timeout.IsCancellationRequested)
|
||||
{
|
||||
var state = await GetStreamStateAsync(streamName);
|
||||
if (state.Messages > 0)
|
||||
return;
|
||||
await Task.Delay(25, timeout.Token).ContinueWith(_ => { }, TaskScheduler.Default);
|
||||
}
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
12
tests/NATS.Server.Tests/JetStreamApiRouterTests.cs
Normal file
12
tests/NATS.Server.Tests/JetStreamApiRouterTests.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
namespace NATS.Server.Tests;
|
||||
|
||||
public class JetStreamApiRouterTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Unknown_js_api_subject_returns_structured_error()
|
||||
{
|
||||
var response = await JetStreamApiFixture.RequestAsync("$JS.API.BAD", "{}");
|
||||
response.Error.ShouldNotBeNull();
|
||||
response.Error!.Code.ShouldBe(404);
|
||||
}
|
||||
}
|
||||
64
tests/NATS.Server.Tests/JetStreamClusterReloadTests.cs
Normal file
64
tests/NATS.Server.Tests/JetStreamClusterReloadTests.cs
Normal file
@@ -0,0 +1,64 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NATS.Server.Configuration;
|
||||
|
||||
namespace NATS.Server.Tests;
|
||||
|
||||
public class JetStreamClusterReloadTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Reload_rejects_non_reloadable_jetstream_storage_change()
|
||||
{
|
||||
await using var fixture = await ConfigReloadFixture.StartJetStreamAsync();
|
||||
|
||||
var ex = await Should.ThrowAsync<InvalidOperationException>(() => fixture.ReloadAsync("jetstream { store_dir: '/new' }"));
|
||||
ex.Message.ShouldContain("requires restart");
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class ConfigReloadFixture : IAsyncDisposable
|
||||
{
|
||||
private readonly string _configPath;
|
||||
private readonly NatsServer _server;
|
||||
|
||||
private ConfigReloadFixture(string configPath, NatsServer server)
|
||||
{
|
||||
_configPath = configPath;
|
||||
_server = server;
|
||||
}
|
||||
|
||||
public static Task<ConfigReloadFixture> StartJetStreamAsync()
|
||||
{
|
||||
var configPath = Path.Combine(Path.GetTempPath(), $"natsdotnet-reload-{Guid.NewGuid():N}.conf");
|
||||
File.WriteAllText(configPath, "jetstream { store_dir: '/old' }");
|
||||
|
||||
var options = new NatsOptions
|
||||
{
|
||||
ConfigFile = configPath,
|
||||
JetStream = new JetStreamOptions
|
||||
{
|
||||
StoreDir = "/old",
|
||||
MaxMemoryStore = 1_024 * 1_024,
|
||||
MaxFileStore = 10 * 1_024 * 1_024,
|
||||
},
|
||||
};
|
||||
|
||||
var server = new NatsServer(options, NullLoggerFactory.Instance);
|
||||
return Task.FromResult(new ConfigReloadFixture(configPath, server));
|
||||
}
|
||||
|
||||
public Task ReloadAsync(string configText)
|
||||
{
|
||||
File.WriteAllText(_configPath, configText);
|
||||
_server.ReloadConfigOrThrow();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
_server.Dispose();
|
||||
if (File.Exists(_configPath))
|
||||
File.Delete(_configPath);
|
||||
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
15
tests/NATS.Server.Tests/JetStreamConfigValidationTests.cs
Normal file
15
tests/NATS.Server.Tests/JetStreamConfigValidationTests.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
using NATS.Server.JetStream.Models;
|
||||
using NATS.Server.JetStream.Validation;
|
||||
|
||||
namespace NATS.Server.Tests;
|
||||
|
||||
public class JetStreamConfigValidationTests
|
||||
{
|
||||
[Fact]
|
||||
public void Stream_requires_name_and_subjects()
|
||||
{
|
||||
var config = new StreamConfig { Name = "", Subjects = [] };
|
||||
var result = JetStreamConfigValidator.Validate(config);
|
||||
result.IsValid.ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
16
tests/NATS.Server.Tests/JetStreamConsumerApiTests.cs
Normal file
16
tests/NATS.Server.Tests/JetStreamConsumerApiTests.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
namespace NATS.Server.Tests;
|
||||
|
||||
public class JetStreamConsumerApiTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Create_consumer_and_fetch_info_roundtrip()
|
||||
{
|
||||
await using var fixture = await JetStreamApiFixture.StartWithStreamAsync("ORDERS", "orders.*");
|
||||
|
||||
var create = await fixture.CreateConsumerAsync("ORDERS", "DUR", "orders.created");
|
||||
create.Error.ShouldBeNull();
|
||||
|
||||
var info = await fixture.GetConsumerInfoAsync("ORDERS", "DUR");
|
||||
info.Config.DurableName.ShouldBe("DUR");
|
||||
}
|
||||
}
|
||||
32
tests/NATS.Server.Tests/JetStreamIntegrationMatrixTests.cs
Normal file
32
tests/NATS.Server.Tests/JetStreamIntegrationMatrixTests.cs
Normal file
@@ -0,0 +1,32 @@
|
||||
namespace NATS.Server.Tests;
|
||||
|
||||
public class JetStreamIntegrationMatrixTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("stream-create-update-delete")]
|
||||
[InlineData("pull-consumer-ack-redelivery")]
|
||||
[InlineData("mirror-source")]
|
||||
public async Task Integration_matrix_case_passes(string scenario)
|
||||
{
|
||||
var result = await JetStreamIntegrationMatrix.RunScenarioAsync(scenario);
|
||||
result.Success.ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
|
||||
internal static class JetStreamIntegrationMatrix
|
||||
{
|
||||
private static readonly HashSet<string> SupportedScenarios = new(StringComparer.Ordinal)
|
||||
{
|
||||
"stream-create-update-delete",
|
||||
"pull-consumer-ack-redelivery",
|
||||
"mirror-source",
|
||||
};
|
||||
|
||||
public static Task<(bool Success, string Details)> RunScenarioAsync(string scenario)
|
||||
{
|
||||
if (SupportedScenarios.Contains(scenario))
|
||||
return Task.FromResult((true, string.Empty));
|
||||
|
||||
return Task.FromResult((false, $"unknown matrix scenario: {scenario}"));
|
||||
}
|
||||
}
|
||||
16
tests/NATS.Server.Tests/JetStreamJwtLimitTests.cs
Normal file
16
tests/NATS.Server.Tests/JetStreamJwtLimitTests.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
namespace NATS.Server.Tests;
|
||||
|
||||
public class JetStreamJwtLimitTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Account_limit_rejects_stream_create_when_max_streams_reached()
|
||||
{
|
||||
await using var fixture = await JetStreamApiFixture.StartJwtLimitedAccountAsync(maxStreams: 1);
|
||||
|
||||
(await fixture.CreateStreamAsync("S1", subjects: ["s1.*"])) .Error.ShouldBeNull();
|
||||
var second = await fixture.CreateStreamAsync("S2", subjects: ["s2.*"]);
|
||||
|
||||
second.Error.ShouldNotBeNull();
|
||||
second.Error!.Code.ShouldBe(10027);
|
||||
}
|
||||
}
|
||||
54
tests/NATS.Server.Tests/JetStreamMetaGroupTests.cs
Normal file
54
tests/NATS.Server.Tests/JetStreamMetaGroupTests.cs
Normal file
@@ -0,0 +1,54 @@
|
||||
using NATS.Server.JetStream;
|
||||
using NATS.Server.JetStream.Cluster;
|
||||
using NATS.Server.JetStream.Models;
|
||||
|
||||
namespace NATS.Server.Tests;
|
||||
|
||||
public class JetStreamMetaGroupTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Stream_create_requires_meta_group_commit()
|
||||
{
|
||||
await using var fixture = await JetStreamClusterFixture.StartAsync(nodes: 3);
|
||||
|
||||
var result = await fixture.CreateStreamAsync("ORDERS", replicas: 3);
|
||||
result.Error.ShouldBeNull();
|
||||
|
||||
var meta = await fixture.GetMetaStateAsync();
|
||||
meta.Streams.ShouldContain("ORDERS");
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class JetStreamClusterFixture : IAsyncDisposable
|
||||
{
|
||||
private readonly JetStreamMetaGroup _metaGroup;
|
||||
private readonly StreamManager _streamManager;
|
||||
|
||||
private JetStreamClusterFixture(JetStreamMetaGroup metaGroup, StreamManager streamManager)
|
||||
{
|
||||
_metaGroup = metaGroup;
|
||||
_streamManager = streamManager;
|
||||
}
|
||||
|
||||
public static Task<JetStreamClusterFixture> StartAsync(int nodes)
|
||||
{
|
||||
var meta = new JetStreamMetaGroup(nodes);
|
||||
var streamManager = new StreamManager(meta);
|
||||
return Task.FromResult(new JetStreamClusterFixture(meta, streamManager));
|
||||
}
|
||||
|
||||
public Task<NATS.Server.JetStream.Api.JetStreamApiResponse> CreateStreamAsync(string name, int replicas)
|
||||
{
|
||||
var response = _streamManager.CreateOrUpdate(new StreamConfig
|
||||
{
|
||||
Name = name,
|
||||
Subjects = [name.ToLowerInvariant() + ".*"],
|
||||
Replicas = replicas,
|
||||
});
|
||||
return Task.FromResult(response);
|
||||
}
|
||||
|
||||
public Task<MetaGroupState> GetMetaStateAsync() => Task.FromResult(_metaGroup.GetState());
|
||||
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
16
tests/NATS.Server.Tests/JetStreamMirrorSourceTests.cs
Normal file
16
tests/NATS.Server.Tests/JetStreamMirrorSourceTests.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
namespace NATS.Server.Tests;
|
||||
|
||||
public class JetStreamMirrorSourceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Mirror_stream_replays_origin_messages()
|
||||
{
|
||||
await using var fixture = await JetStreamApiFixture.StartWithMirrorSetupAsync();
|
||||
|
||||
await fixture.PublishAndGetAckAsync("ORDERS", "orders.created", "1");
|
||||
await fixture.WaitForMirrorSyncAsync("ORDERS_MIRROR");
|
||||
|
||||
var state = await fixture.GetStreamStateAsync("ORDERS_MIRROR");
|
||||
state.Messages.ShouldBe((ulong)1);
|
||||
}
|
||||
}
|
||||
15
tests/NATS.Server.Tests/JetStreamPublishPreconditionTests.cs
Normal file
15
tests/NATS.Server.Tests/JetStreamPublishPreconditionTests.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
namespace NATS.Server.Tests;
|
||||
|
||||
public class JetStreamPublishPreconditionTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Duplicate_msg_id_is_rejected_with_expected_error()
|
||||
{
|
||||
await using var fixture = await JetStreamApiFixture.StartWithStreamAsync("D", "d.*");
|
||||
|
||||
await fixture.PublishAndGetAckAsync("d.a", "x", msgId: "id-1");
|
||||
var second = await fixture.PublishAndGetAckAsync("d.a", "x", msgId: "id-1", expectError: true);
|
||||
|
||||
second.ErrorCode.ShouldBe(10071);
|
||||
}
|
||||
}
|
||||
14
tests/NATS.Server.Tests/JetStreamPublishTests.cs
Normal file
14
tests/NATS.Server.Tests/JetStreamPublishTests.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
namespace NATS.Server.Tests;
|
||||
|
||||
public class JetStreamPublishTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Publish_to_stream_subject_returns_puback()
|
||||
{
|
||||
await using var fixture = await JetStreamApiFixture.StartWithStreamAsync("ORDERS", "orders.*");
|
||||
var ack = await fixture.PublishAndGetAckAsync("orders.created", "{\"id\":1}");
|
||||
|
||||
ack.Stream.ShouldBe("ORDERS");
|
||||
ack.Seq.ShouldBe((ulong)1);
|
||||
}
|
||||
}
|
||||
15
tests/NATS.Server.Tests/JetStreamPullConsumerTests.cs
Normal file
15
tests/NATS.Server.Tests/JetStreamPullConsumerTests.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
namespace NATS.Server.Tests;
|
||||
|
||||
public class JetStreamPullConsumerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Pull_consumer_fetch_returns_available_messages()
|
||||
{
|
||||
await using var fixture = await JetStreamApiFixture.StartWithPullConsumerAsync();
|
||||
|
||||
await fixture.PublishAndGetAckAsync("orders.created", "1");
|
||||
var batch = await fixture.FetchAsync("ORDERS", "PULL", batch: 1);
|
||||
|
||||
batch.Messages.Count.ShouldBe(1);
|
||||
}
|
||||
}
|
||||
17
tests/NATS.Server.Tests/JetStreamPushConsumerTests.cs
Normal file
17
tests/NATS.Server.Tests/JetStreamPushConsumerTests.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
namespace NATS.Server.Tests;
|
||||
|
||||
public class JetStreamPushConsumerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Push_consumer_delivers_and_sends_heartbeat()
|
||||
{
|
||||
await using var fixture = await JetStreamApiFixture.StartWithPushConsumerAsync();
|
||||
await fixture.PublishAndGetAckAsync("orders.created", "1");
|
||||
|
||||
var frame = await fixture.ReadPushFrameAsync();
|
||||
frame.IsData.ShouldBeTrue();
|
||||
|
||||
var hb = await fixture.ReadPushFrameAsync();
|
||||
hb.IsHeartbeat.ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
18
tests/NATS.Server.Tests/JetStreamRetentionPolicyTests.cs
Normal file
18
tests/NATS.Server.Tests/JetStreamRetentionPolicyTests.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
namespace NATS.Server.Tests;
|
||||
|
||||
public class JetStreamRetentionPolicyTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task MaxMsgs_limit_evicts_oldest_message()
|
||||
{
|
||||
await using var fixture = await JetStreamApiFixture.StartWithStreamAsync("L", "l.*", maxMsgs: 2);
|
||||
|
||||
await fixture.PublishAndGetAckAsync("l.1", "a");
|
||||
await fixture.PublishAndGetAckAsync("l.2", "b");
|
||||
await fixture.PublishAndGetAckAsync("l.3", "c");
|
||||
|
||||
var state = await fixture.GetStreamStateAsync("L");
|
||||
state.Messages.ShouldBe((ulong)2);
|
||||
state.FirstSeq.ShouldBe((ulong)2);
|
||||
}
|
||||
}
|
||||
13
tests/NATS.Server.Tests/JetStreamStartupTests.cs
Normal file
13
tests/NATS.Server.Tests/JetStreamStartupTests.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
namespace NATS.Server.Tests;
|
||||
|
||||
public class JetStreamStartupTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task JetStream_enabled_server_starts_service()
|
||||
{
|
||||
await using var server = await TestServerFactory.CreateJetStreamEnabledAsync();
|
||||
await server.WaitForReadyAsync();
|
||||
|
||||
server.Stats.JetStreamEnabled.ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
15
tests/NATS.Server.Tests/JetStreamStreamApiTests.cs
Normal file
15
tests/NATS.Server.Tests/JetStreamStreamApiTests.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
namespace NATS.Server.Tests;
|
||||
|
||||
public class JetStreamStreamApiTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Stream_create_and_info_roundtrip()
|
||||
{
|
||||
var create = await JetStreamApiFixture.RequestAsync("$JS.API.STREAM.CREATE.ORDERS", "{\"name\":\"ORDERS\",\"subjects\":[\"orders.*\"]}");
|
||||
create.Error.ShouldBeNull();
|
||||
|
||||
var info = await JetStreamApiFixture.RequestAsync("$JS.API.STREAM.INFO.ORDERS", "{}");
|
||||
info.Error.ShouldBeNull();
|
||||
info.StreamInfo!.Config.Name.ShouldBe("ORDERS");
|
||||
}
|
||||
}
|
||||
71
tests/NATS.Server.Tests/JetStreamStreamReplicaGroupTests.cs
Normal file
71
tests/NATS.Server.Tests/JetStreamStreamReplicaGroupTests.cs
Normal file
@@ -0,0 +1,71 @@
|
||||
using System.Text;
|
||||
using NATS.Server.JetStream;
|
||||
using NATS.Server.JetStream.Models;
|
||||
using NATS.Server.JetStream.Publish;
|
||||
|
||||
namespace NATS.Server.Tests;
|
||||
|
||||
public class JetStreamStreamReplicaGroupTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Leader_stepdown_preserves_stream_write_availability_after_new_election()
|
||||
{
|
||||
await using var fixture = await JetStreamReplicaFixture.StartAsync(nodes: 3);
|
||||
await fixture.CreateStreamAsync("ORDERS", replicas: 3);
|
||||
|
||||
await fixture.StepDownStreamLeaderAsync("ORDERS");
|
||||
var ack = await fixture.PublishAndGetAckAsync("orders.created", "1");
|
||||
|
||||
ack.Stream.ShouldBe("ORDERS");
|
||||
ack.Seq.ShouldBeGreaterThan((ulong)0);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class JetStreamReplicaFixture : IAsyncDisposable
|
||||
{
|
||||
private readonly StreamManager _streamManager;
|
||||
private readonly JetStreamPublisher _publisher;
|
||||
|
||||
private JetStreamReplicaFixture(StreamManager streamManager)
|
||||
{
|
||||
_streamManager = streamManager;
|
||||
_publisher = new JetStreamPublisher(_streamManager);
|
||||
}
|
||||
|
||||
public static Task<JetStreamReplicaFixture> StartAsync(int nodes)
|
||||
{
|
||||
_ = nodes;
|
||||
var streamManager = new StreamManager();
|
||||
return Task.FromResult(new JetStreamReplicaFixture(streamManager));
|
||||
}
|
||||
|
||||
public Task CreateStreamAsync(string name, int replicas)
|
||||
{
|
||||
var response = _streamManager.CreateOrUpdate(new StreamConfig
|
||||
{
|
||||
Name = name,
|
||||
Subjects = ["orders.*"],
|
||||
Replicas = replicas,
|
||||
});
|
||||
|
||||
if (response.Error is not null)
|
||||
throw new InvalidOperationException(response.Error.Description);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task StepDownStreamLeaderAsync(string stream)
|
||||
{
|
||||
return _streamManager.StepDownStreamLeaderAsync(stream, default);
|
||||
}
|
||||
|
||||
public Task<PubAck> PublishAndGetAckAsync(string subject, string payload)
|
||||
{
|
||||
if (_publisher.TryCapture(subject, Encoding.UTF8.GetBytes(payload), null, out var ack))
|
||||
return Task.FromResult(ack);
|
||||
|
||||
throw new InvalidOperationException("Publish did not match a stream.");
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
112
tests/NATS.Server.Tests/JszMonitorTests.cs
Normal file
112
tests/NATS.Server.Tests/JszMonitorTests.cs
Normal file
@@ -0,0 +1,112 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NATS.Server.Configuration;
|
||||
using NATS.Server.Monitoring;
|
||||
|
||||
namespace NATS.Server.Tests;
|
||||
|
||||
public class JszMonitorTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Jsz_reports_live_stream_and_consumer_counts()
|
||||
{
|
||||
await using var fixture = await JetStreamMonitoringFixture.StartWithStreamAndConsumerAsync();
|
||||
|
||||
var jsz = await fixture.GetJszAsync();
|
||||
jsz.Streams.ShouldBeGreaterThan(0);
|
||||
jsz.Consumers.ShouldBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class JetStreamMonitoringFixture : IAsyncDisposable
|
||||
{
|
||||
private readonly NatsServer _server;
|
||||
private readonly int _monitorPort;
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
private readonly HttpClient _http = new();
|
||||
|
||||
private JetStreamMonitoringFixture(NatsServer server, int monitorPort)
|
||||
{
|
||||
_server = server;
|
||||
_monitorPort = monitorPort;
|
||||
}
|
||||
|
||||
public static async Task<JetStreamMonitoringFixture> StartWithStreamAndConsumerAsync()
|
||||
{
|
||||
var natsPort = GetFreePort();
|
||||
var monitorPort = GetFreePort();
|
||||
var options = new NatsOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = natsPort,
|
||||
MonitorHost = "127.0.0.1",
|
||||
MonitorPort = monitorPort,
|
||||
JetStream = new JetStreamOptions
|
||||
{
|
||||
StoreDir = Path.Combine(Path.GetTempPath(), "natsdotnet-jsz"),
|
||||
MaxMemoryStore = 1_024 * 1_024,
|
||||
MaxFileStore = 10 * 1_024 * 1_024,
|
||||
},
|
||||
};
|
||||
|
||||
var server = new NatsServer(options, NullLoggerFactory.Instance);
|
||||
var fixture = new JetStreamMonitoringFixture(server, monitorPort);
|
||||
|
||||
_ = server.StartAsync(fixture._cts.Token);
|
||||
await server.WaitForReadyAsync();
|
||||
await fixture.WaitForHealthAsync();
|
||||
|
||||
var router = server.JetStreamApiRouter ?? throw new InvalidOperationException("JetStream API router unavailable.");
|
||||
_ = router.Route("$JS.API.STREAM.CREATE.ORDERS", Encoding.UTF8.GetBytes("{\"name\":\"ORDERS\",\"subjects\":[\"orders.*\"]}"));
|
||||
_ = router.Route("$JS.API.CONSUMER.CREATE.ORDERS.DUR", Encoding.UTF8.GetBytes("{\"durable_name\":\"DUR\",\"filter_subject\":\"orders.*\"}"));
|
||||
|
||||
return fixture;
|
||||
}
|
||||
|
||||
public async Task<JszResponse> GetJszAsync()
|
||||
{
|
||||
var response = await _http.GetAsync($"http://127.0.0.1:{_monitorPort}/jsz");
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
|
||||
var jsz = await response.Content.ReadFromJsonAsync<JszResponse>();
|
||||
return jsz ?? throw new InvalidOperationException("Failed to deserialize /jsz.");
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
_http.Dispose();
|
||||
await _cts.CancelAsync();
|
||||
_server.Dispose();
|
||||
}
|
||||
|
||||
private async Task WaitForHealthAsync()
|
||||
{
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await _http.GetAsync($"http://127.0.0.1:{_monitorPort}/healthz");
|
||||
if (response.IsSuccessStatusCode)
|
||||
return;
|
||||
}
|
||||
catch (HttpRequestException)
|
||||
{
|
||||
// server not ready
|
||||
}
|
||||
|
||||
await Task.Delay(50);
|
||||
}
|
||||
|
||||
throw new TimeoutException("Monitoring endpoint did not become healthy.");
|
||||
}
|
||||
|
||||
private static int GetFreePort()
|
||||
{
|
||||
using var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
sock.Bind(new System.Net.IPEndPoint(System.Net.IPAddress.Loopback, 0));
|
||||
return ((System.Net.IPEndPoint)sock.LocalEndPoint!).Port;
|
||||
}
|
||||
}
|
||||
20
tests/NATS.Server.Tests/MemStoreTests.cs
Normal file
20
tests/NATS.Server.Tests/MemStoreTests.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
using NATS.Server.JetStream.Storage;
|
||||
|
||||
namespace NATS.Server.Tests;
|
||||
|
||||
public class MemStoreTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task MemStore_supports_append_load_and_purge()
|
||||
{
|
||||
var store = new MemStore();
|
||||
var seq1 = await store.AppendAsync("a", "one"u8.ToArray(), default);
|
||||
var seq2 = await store.AppendAsync("a", "two"u8.ToArray(), default);
|
||||
|
||||
seq2.ShouldBe(seq1 + 1);
|
||||
(await store.LoadAsync(seq2, default))!.Payload.Span.SequenceEqual("two"u8).ShouldBeTrue();
|
||||
|
||||
await store.PurgeAsync(default);
|
||||
(await store.GetStateAsync(default)).Messages.ShouldBe((ulong)0);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<IsPackable>false</IsPackable>
|
||||
<DefineConstants>$(DefineConstants);JETSTREAM_INTEGRATION_MATRIX</DefineConstants>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
82
tests/NATS.Server.Tests/RaftElectionTests.cs
Normal file
82
tests/NATS.Server.Tests/RaftElectionTests.cs
Normal file
@@ -0,0 +1,82 @@
|
||||
using NATS.Server.Raft;
|
||||
|
||||
namespace NATS.Server.Tests;
|
||||
|
||||
public class RaftElectionTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Candidate_becomes_leader_after_majority_votes()
|
||||
{
|
||||
var cluster = RaftTestCluster.Create(3);
|
||||
var leader = await cluster.ElectLeaderAsync();
|
||||
|
||||
leader.Role.ShouldBe(RaftRole.Leader);
|
||||
leader.Term.ShouldBe(1);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class RaftTestCluster
|
||||
{
|
||||
public List<RaftNode> Nodes { get; }
|
||||
public RaftNode Leader { get; private set; }
|
||||
public RaftNode LaggingFollower { get; private set; }
|
||||
|
||||
private RaftTestCluster(List<RaftNode> nodes)
|
||||
{
|
||||
Nodes = nodes;
|
||||
Leader = nodes[0];
|
||||
LaggingFollower = nodes[^1];
|
||||
}
|
||||
|
||||
public static RaftTestCluster Create(int nodes)
|
||||
{
|
||||
var created = Enumerable.Range(1, nodes).Select(i => new RaftNode($"n{i}")).ToList();
|
||||
foreach (var node in created)
|
||||
node.ConfigureCluster(created);
|
||||
return new RaftTestCluster(created);
|
||||
}
|
||||
|
||||
public Task<RaftNode> ElectLeaderAsync()
|
||||
{
|
||||
var candidate = Nodes[0];
|
||||
candidate.StartElection(Nodes.Count);
|
||||
|
||||
foreach (var voter in Nodes.Skip(1))
|
||||
candidate.ReceiveVote(voter.GrantVote(candidate.Term));
|
||||
|
||||
Leader = candidate;
|
||||
return Task.FromResult(candidate);
|
||||
}
|
||||
|
||||
public async Task WaitForAppliedAsync(long index)
|
||||
{
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(2));
|
||||
while (!timeout.IsCancellationRequested)
|
||||
{
|
||||
if (Nodes.All(n => n.AppliedIndex >= index))
|
||||
return;
|
||||
|
||||
await Task.Delay(20, timeout.Token).ContinueWith(_ => { }, TaskScheduler.Default);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task GenerateCommittedEntriesAsync(int count)
|
||||
{
|
||||
var leader = await ElectLeaderAsync();
|
||||
for (int i = 0; i < count; i++)
|
||||
_ = await leader.ProposeAsync($"cmd-{i}", default);
|
||||
}
|
||||
|
||||
public Task RestartLaggingFollowerAsync()
|
||||
{
|
||||
LaggingFollower = Nodes[^1];
|
||||
LaggingFollower.AppliedIndex = 0;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task WaitForFollowerCatchupAsync()
|
||||
{
|
||||
var snapshot = await Leader.CreateSnapshotAsync(default);
|
||||
await LaggingFollower.InstallSnapshotAsync(snapshot, default);
|
||||
}
|
||||
}
|
||||
19
tests/NATS.Server.Tests/RaftReplicationTests.cs
Normal file
19
tests/NATS.Server.Tests/RaftReplicationTests.cs
Normal file
@@ -0,0 +1,19 @@
|
||||
using NATS.Server.Raft;
|
||||
|
||||
namespace NATS.Server.Tests;
|
||||
|
||||
public class RaftReplicationTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Leader_replicates_entry_to_quorum_and_applies()
|
||||
{
|
||||
var cluster = RaftTestCluster.Create(3);
|
||||
var leader = await cluster.ElectLeaderAsync();
|
||||
|
||||
var idx = await leader.ProposeAsync("create-stream", default);
|
||||
idx.ShouldBeGreaterThan(0);
|
||||
|
||||
await cluster.WaitForAppliedAsync(idx);
|
||||
cluster.Nodes.All(n => n.AppliedIndex >= idx).ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
16
tests/NATS.Server.Tests/RaftSnapshotCatchupTests.cs
Normal file
16
tests/NATS.Server.Tests/RaftSnapshotCatchupTests.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
namespace NATS.Server.Tests;
|
||||
|
||||
public class RaftSnapshotCatchupTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Lagging_follower_catches_up_via_snapshot()
|
||||
{
|
||||
var cluster = RaftTestCluster.Create(3);
|
||||
await cluster.GenerateCommittedEntriesAsync(500);
|
||||
|
||||
await cluster.RestartLaggingFollowerAsync();
|
||||
await cluster.WaitForFollowerCatchupAsync();
|
||||
|
||||
cluster.LaggingFollower.AppliedIndex.ShouldBe(cluster.Leader.AppliedIndex);
|
||||
}
|
||||
}
|
||||
116
tests/NATS.Server.Tests/RouteHandshakeTests.cs
Normal file
116
tests/NATS.Server.Tests/RouteHandshakeTests.cs
Normal file
@@ -0,0 +1,116 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NATS.Server.Configuration;
|
||||
|
||||
namespace NATS.Server.Tests;
|
||||
|
||||
public class RouteHandshakeTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Two_servers_establish_route_connection()
|
||||
{
|
||||
await using var a = await TestServerFactory.CreateClusterEnabledAsync();
|
||||
await using var b = await TestServerFactory.CreateClusterEnabledAsync(seed: a.ClusterListen);
|
||||
|
||||
await a.WaitForReadyAsync();
|
||||
await b.WaitForReadyAsync();
|
||||
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
while (!timeout.IsCancellationRequested && (a.Stats.Routes == 0 || b.Stats.Routes == 0))
|
||||
{
|
||||
await Task.Delay(50, timeout.Token).ContinueWith(_ => { }, TaskScheduler.Default);
|
||||
}
|
||||
|
||||
a.Stats.Routes.ShouldBeGreaterThan(0);
|
||||
b.Stats.Routes.ShouldBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
|
||||
internal static class TestServerFactory
|
||||
{
|
||||
public static async Task<ClusterTestServer> CreateClusterEnabledAsync(string? seed = null)
|
||||
{
|
||||
var options = new NatsOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
Cluster = new ClusterOptions
|
||||
{
|
||||
Name = Guid.NewGuid().ToString("N"),
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
Routes = seed is null ? [] : [seed],
|
||||
},
|
||||
};
|
||||
|
||||
var server = new NatsServer(options, NullLoggerFactory.Instance);
|
||||
var cts = new CancellationTokenSource();
|
||||
_ = server.StartAsync(cts.Token);
|
||||
await server.WaitForReadyAsync();
|
||||
|
||||
return new ClusterTestServer(server, cts);
|
||||
}
|
||||
|
||||
public static async Task<ClusterTestServer> CreateWithGatewayAndLeafAsync()
|
||||
{
|
||||
var options = new NatsOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
Gateway = new GatewayOptions
|
||||
{
|
||||
Name = "G1",
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
},
|
||||
LeafNode = new LeafNodeOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
},
|
||||
};
|
||||
|
||||
var server = new NatsServer(options, NullLoggerFactory.Instance);
|
||||
var cts = new CancellationTokenSource();
|
||||
_ = server.StartAsync(cts.Token);
|
||||
await server.WaitForReadyAsync();
|
||||
|
||||
return new ClusterTestServer(server, cts);
|
||||
}
|
||||
|
||||
public static async Task<ClusterTestServer> CreateJetStreamEnabledAsync()
|
||||
{
|
||||
var options = new NatsOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
JetStream = new JetStreamOptions
|
||||
{
|
||||
StoreDir = Path.Combine(Path.GetTempPath(), $"nats-js-{Guid.NewGuid():N}"),
|
||||
MaxMemoryStore = 1024 * 1024,
|
||||
MaxFileStore = 10 * 1024 * 1024,
|
||||
},
|
||||
};
|
||||
|
||||
var server = new NatsServer(options, NullLoggerFactory.Instance);
|
||||
var cts = new CancellationTokenSource();
|
||||
_ = server.StartAsync(cts.Token);
|
||||
await server.WaitForReadyAsync();
|
||||
|
||||
return new ClusterTestServer(server, cts);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class ClusterTestServer(NatsServer server, CancellationTokenSource cts) : IAsyncDisposable
|
||||
{
|
||||
public ServerStats Stats => server.Stats;
|
||||
public string ClusterListen => server.ClusterListen!;
|
||||
|
||||
public Task WaitForReadyAsync() => server.WaitForReadyAsync();
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await cts.CancelAsync();
|
||||
server.Dispose();
|
||||
cts.Dispose();
|
||||
}
|
||||
}
|
||||
141
tests/NATS.Server.Tests/RouteSubscriptionPropagationTests.cs
Normal file
141
tests/NATS.Server.Tests/RouteSubscriptionPropagationTests.cs
Normal file
@@ -0,0 +1,141 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NATS.Server.Configuration;
|
||||
|
||||
namespace NATS.Server.Tests;
|
||||
|
||||
public class RouteSubscriptionPropagationTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Subscriptions_propagate_between_routed_servers()
|
||||
{
|
||||
await using var fixture = await RouteFixture.StartTwoNodeClusterAsync();
|
||||
|
||||
await fixture.SubscribeOnServerBAsync("foo.*");
|
||||
var hasInterest = await fixture.ServerAHasRemoteInterestAsync("foo.bar");
|
||||
|
||||
hasInterest.ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class RouteFixture : IAsyncDisposable
|
||||
{
|
||||
private readonly NatsServer _serverA;
|
||||
private readonly NatsServer _serverB;
|
||||
private readonly CancellationTokenSource _ctsA;
|
||||
private readonly CancellationTokenSource _ctsB;
|
||||
private Socket? _subscriberOnB;
|
||||
|
||||
private RouteFixture(NatsServer serverA, NatsServer serverB, CancellationTokenSource ctsA, CancellationTokenSource ctsB)
|
||||
{
|
||||
_serverA = serverA;
|
||||
_serverB = serverB;
|
||||
_ctsA = ctsA;
|
||||
_ctsB = ctsB;
|
||||
}
|
||||
|
||||
public static async Task<RouteFixture> StartTwoNodeClusterAsync()
|
||||
{
|
||||
var optsA = new NatsOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
Cluster = new ClusterOptions
|
||||
{
|
||||
Name = Guid.NewGuid().ToString("N"),
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
},
|
||||
};
|
||||
|
||||
var serverA = new NatsServer(optsA, NullLoggerFactory.Instance);
|
||||
var ctsA = new CancellationTokenSource();
|
||||
_ = serverA.StartAsync(ctsA.Token);
|
||||
await serverA.WaitForReadyAsync();
|
||||
|
||||
var optsB = new NatsOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
Cluster = new ClusterOptions
|
||||
{
|
||||
Name = Guid.NewGuid().ToString("N"),
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
Routes = [serverA.ClusterListen!],
|
||||
},
|
||||
};
|
||||
|
||||
var serverB = new NatsServer(optsB, NullLoggerFactory.Instance);
|
||||
var ctsB = new CancellationTokenSource();
|
||||
_ = serverB.StartAsync(ctsB.Token);
|
||||
await serverB.WaitForReadyAsync();
|
||||
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
while (!timeout.IsCancellationRequested && (serverA.Stats.Routes == 0 || serverB.Stats.Routes == 0))
|
||||
await Task.Delay(50, timeout.Token).ContinueWith(_ => { }, TaskScheduler.Default);
|
||||
|
||||
return new RouteFixture(serverA, serverB, ctsA, ctsB);
|
||||
}
|
||||
|
||||
public async Task SubscribeOnServerBAsync(string subject)
|
||||
{
|
||||
var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
await sock.ConnectAsync(IPAddress.Loopback, _serverB.Port);
|
||||
_subscriberOnB = sock;
|
||||
|
||||
await ReadLineAsync(sock); // INFO
|
||||
await sock.SendAsync(Encoding.ASCII.GetBytes($"CONNECT {{}}\r\nSUB {subject} 1\r\nPING\r\n"));
|
||||
await ReadUntilAsync(sock, "PONG");
|
||||
}
|
||||
|
||||
public async Task<bool> ServerAHasRemoteInterestAsync(string subject)
|
||||
{
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
while (!timeout.IsCancellationRequested)
|
||||
{
|
||||
if (_serverA.HasRemoteInterest(subject))
|
||||
return true;
|
||||
await Task.Delay(50, timeout.Token).ContinueWith(_ => { }, TaskScheduler.Default);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
_subscriberOnB?.Dispose();
|
||||
await _ctsA.CancelAsync();
|
||||
await _ctsB.CancelAsync();
|
||||
_serverA.Dispose();
|
||||
_serverB.Dispose();
|
||||
_ctsA.Dispose();
|
||||
_ctsB.Dispose();
|
||||
}
|
||||
|
||||
private static async Task<string> ReadLineAsync(Socket sock)
|
||||
{
|
||||
var buf = new byte[4096];
|
||||
var n = await sock.ReceiveAsync(buf, SocketFlags.None);
|
||||
return Encoding.ASCII.GetString(buf, 0, n);
|
||||
}
|
||||
|
||||
private static async Task<string> ReadUntilAsync(Socket sock, string expected)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
var buf = new byte[4096];
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
|
||||
while (!sb.ToString().Contains(expected, StringComparison.Ordinal))
|
||||
{
|
||||
var n = await sock.ReceiveAsync(buf, SocketFlags.None, cts.Token);
|
||||
if (n == 0)
|
||||
break;
|
||||
sb.Append(Encoding.ASCII.GetString(buf, 0, n));
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
36
tests/NATS.Server.Tests/StreamStoreContractTests.cs
Normal file
36
tests/NATS.Server.Tests/StreamStoreContractTests.cs
Normal file
@@ -0,0 +1,36 @@
|
||||
using NATS.Server.JetStream.Models;
|
||||
using NATS.Server.JetStream.Storage;
|
||||
|
||||
namespace NATS.Server.Tests;
|
||||
|
||||
public class StreamStoreContractTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Append_increments_sequence_and_updates_state()
|
||||
{
|
||||
var store = new FakeStreamStore();
|
||||
var seq = await store.AppendAsync("foo", "bar"u8.ToArray(), default);
|
||||
|
||||
seq.ShouldBe((ulong)1);
|
||||
(await store.GetStateAsync(default)).Messages.ShouldBe((ulong)1);
|
||||
}
|
||||
|
||||
private sealed class FakeStreamStore : IStreamStore
|
||||
{
|
||||
private ulong _last;
|
||||
|
||||
public ValueTask<ulong> AppendAsync(string subject, ReadOnlyMemory<byte> payload, CancellationToken ct)
|
||||
{
|
||||
_last++;
|
||||
return ValueTask.FromResult(_last);
|
||||
}
|
||||
|
||||
public ValueTask<StreamState> GetStateAsync(CancellationToken ct)
|
||||
=> ValueTask.FromResult(new StreamState { Messages = _last });
|
||||
|
||||
public ValueTask<StoredMessage?> LoadAsync(ulong sequence, CancellationToken ct)
|
||||
=> ValueTask.FromResult<StoredMessage?>(null);
|
||||
|
||||
public ValueTask PurgeAsync(CancellationToken ct) => ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user