feat: execute post-baseline jetstream parity plan
This commit is contained in:
16
tests/NATS.Server.Tests/DifferencesParityClosureTests.cs
Normal file
16
tests/NATS.Server.Tests/DifferencesParityClosureTests.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
namespace NATS.Server.Tests;
|
||||
|
||||
public class DifferencesParityClosureTests
|
||||
{
|
||||
[Fact]
|
||||
public void Differences_md_has_no_remaining_jetstream_baseline_or_n_rows()
|
||||
{
|
||||
var repositoryRoot = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", ".."));
|
||||
var differencesPath = Path.Combine(repositoryRoot, "differences.md");
|
||||
File.Exists(differencesPath).ShouldBeTrue();
|
||||
|
||||
var markdown = File.ReadAllText(differencesPath);
|
||||
markdown.ShouldContain("### JetStream");
|
||||
markdown.ShouldContain("None in scope after this plan; all in-scope parity rows moved to `Y`.");
|
||||
}
|
||||
}
|
||||
20
tests/NATS.Server.Tests/GatewayAdvancedSemanticsTests.cs
Normal file
20
tests/NATS.Server.Tests/GatewayAdvancedSemanticsTests.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
using NATS.Server.Gateways;
|
||||
|
||||
namespace NATS.Server.Tests;
|
||||
|
||||
public class GatewayAdvancedSemanticsTests
|
||||
{
|
||||
[Fact]
|
||||
public void Gateway_forwarding_remaps_reply_subject_with_gr_prefix_and_restores_on_return()
|
||||
{
|
||||
const string originalReply = "_INBOX.123";
|
||||
const string clusterId = "CLUSTER-A";
|
||||
|
||||
var mapped = ReplyMapper.ToGatewayReply(originalReply, clusterId);
|
||||
mapped.ShouldStartWith("_GR_.");
|
||||
mapped.ShouldContain(clusterId);
|
||||
|
||||
ReplyMapper.TryRestoreGatewayReply(mapped, out var restored).ShouldBeTrue();
|
||||
restored.ShouldBe(originalReply);
|
||||
}
|
||||
}
|
||||
84
tests/NATS.Server.Tests/InterServerAccountProtocolTests.cs
Normal file
84
tests/NATS.Server.Tests/InterServerAccountProtocolTests.cs
Normal file
@@ -0,0 +1,84 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using NATS.Server.Gateways;
|
||||
using NATS.Server.Subscriptions;
|
||||
|
||||
namespace NATS.Server.Tests;
|
||||
|
||||
public class InterServerAccountProtocolTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Aplus_Aminus_frames_include_account_scope_and_do_not_leak_interest_across_accounts()
|
||||
{
|
||||
using var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
|
||||
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||
using var remoteSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
await remoteSocket.ConnectAsync(IPAddress.Loopback, port);
|
||||
using var gatewaySocket = await listener.AcceptSocketAsync();
|
||||
await using var gateway = new GatewayConnection(gatewaySocket);
|
||||
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
var handshakeTask = gateway.PerformOutboundHandshakeAsync("LOCAL", timeout.Token);
|
||||
(await ReadLineAsync(remoteSocket, timeout.Token)).ShouldBe("GATEWAY LOCAL");
|
||||
await WriteLineAsync(remoteSocket, "GATEWAY REMOTE", timeout.Token);
|
||||
await handshakeTask;
|
||||
|
||||
var received = new TaskCompletionSource<RemoteSubscription>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
gateway.RemoteSubscriptionReceived = sub =>
|
||||
{
|
||||
received.TrySetResult(sub);
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
gateway.StartLoop(timeout.Token);
|
||||
|
||||
await WriteLineAsync(remoteSocket, "A+ A orders.*", timeout.Token);
|
||||
var aPlus = await received.Task.WaitAsync(timeout.Token);
|
||||
aPlus.Account.ShouldBe("A");
|
||||
aPlus.Subject.ShouldBe("orders.*");
|
||||
aPlus.IsRemoval.ShouldBeFalse();
|
||||
|
||||
var subList = new SubList();
|
||||
subList.ApplyRemoteSub(aPlus);
|
||||
subList.HasRemoteInterest("A", "orders.created").ShouldBeTrue();
|
||||
subList.HasRemoteInterest("B", "orders.created").ShouldBeFalse();
|
||||
|
||||
var removedTcs = new TaskCompletionSource<RemoteSubscription>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
gateway.RemoteSubscriptionReceived = sub =>
|
||||
{
|
||||
removedTcs.TrySetResult(sub);
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
|
||||
await WriteLineAsync(remoteSocket, "A- A orders.*", timeout.Token);
|
||||
var aMinus = await removedTcs.Task.WaitAsync(timeout.Token);
|
||||
aMinus.Account.ShouldBe("A");
|
||||
aMinus.IsRemoval.ShouldBeTrue();
|
||||
|
||||
subList.ApplyRemoteSub(aMinus);
|
||||
subList.HasRemoteInterest("A", "orders.created").ShouldBeFalse();
|
||||
}
|
||||
|
||||
private static async Task<string> ReadLineAsync(Socket socket, CancellationToken ct)
|
||||
{
|
||||
var bytes = new List<byte>(64);
|
||||
var single = new byte[1];
|
||||
while (true)
|
||||
{
|
||||
var read = await socket.ReceiveAsync(single, SocketFlags.None, ct);
|
||||
if (read == 0)
|
||||
break;
|
||||
if (single[0] == (byte)'\n')
|
||||
break;
|
||||
if (single[0] != (byte)'\r')
|
||||
bytes.Add(single[0]);
|
||||
}
|
||||
|
||||
return Encoding.ASCII.GetString([.. bytes]);
|
||||
}
|
||||
|
||||
private static Task WriteLineAsync(Socket socket, string line, CancellationToken ct)
|
||||
=> socket.SendAsync(Encoding.ASCII.GetBytes($"{line}\r\n"), SocketFlags.None, ct).AsTask();
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using NATS.Server.JetStream.Cluster;
|
||||
|
||||
namespace NATS.Server.Tests;
|
||||
|
||||
public class JetStreamClusterGovernanceParityTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Cluster_governance_applies_planned_replica_placement()
|
||||
{
|
||||
var planner = new AssetPlacementPlanner(nodes: 3);
|
||||
var placement = planner.PlanReplicas(replicas: 2);
|
||||
placement.Count.ShouldBe(2);
|
||||
|
||||
var group = new StreamReplicaGroup("ORDERS", replicas: 1);
|
||||
await group.ApplyPlacementAsync(placement, default);
|
||||
group.Nodes.Count.ShouldBe(2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using NATS.Server.JetStream;
|
||||
using NATS.Server.JetStream.Models;
|
||||
|
||||
namespace NATS.Server.Tests;
|
||||
|
||||
public class JetStreamConsumerBackoffParityTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Redelivery_honors_backoff_schedule_and_stops_after_max_deliver()
|
||||
{
|
||||
var streams = new StreamManager();
|
||||
streams.CreateOrUpdate(new StreamConfig
|
||||
{
|
||||
Name = "ORDERS",
|
||||
Subjects = ["orders.*"],
|
||||
});
|
||||
|
||||
streams.Capture("orders.created", "x"u8.ToArray());
|
||||
|
||||
var consumers = new ConsumerManager();
|
||||
consumers.CreateOrUpdate("ORDERS", new ConsumerConfig
|
||||
{
|
||||
DurableName = "C1",
|
||||
AckPolicy = AckPolicy.Explicit,
|
||||
AckWaitMs = 1,
|
||||
MaxDeliver = 3,
|
||||
BackOffMs = [1, 1],
|
||||
});
|
||||
|
||||
var deliveries = new List<ulong>();
|
||||
for (var i = 0; i < 6; i++)
|
||||
{
|
||||
var batch = await consumers.FetchAsync("ORDERS", "C1", 1, streams, default);
|
||||
if (batch.Messages.Count > 0 && batch.Messages[0].Redelivered)
|
||||
deliveries.Add(batch.Messages[0].Sequence);
|
||||
await Task.Delay(2);
|
||||
}
|
||||
|
||||
deliveries.Count.ShouldBe(3);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using NATS.Server.JetStream;
|
||||
using NATS.Server.JetStream.Models;
|
||||
|
||||
namespace NATS.Server.Tests;
|
||||
|
||||
public class JetStreamConsumerDeliverPolicyParityTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Deliver_policy_start_sequence_and_start_time_and_last_per_subject_match_expected_start_positions()
|
||||
{
|
||||
var streams = new StreamManager();
|
||||
streams.CreateOrUpdate(new StreamConfig
|
||||
{
|
||||
Name = "ORDERS",
|
||||
Subjects = ["orders.*"],
|
||||
});
|
||||
|
||||
streams.Capture("orders.created", "1"u8.ToArray());
|
||||
streams.Capture("orders.updated", "2"u8.ToArray());
|
||||
streams.Capture("orders.created", "3"u8.ToArray());
|
||||
|
||||
var consumers = new ConsumerManager();
|
||||
consumers.CreateOrUpdate("ORDERS", new ConsumerConfig
|
||||
{
|
||||
DurableName = "BYSEQ",
|
||||
DeliverPolicy = DeliverPolicy.ByStartSequence,
|
||||
OptStartSeq = 3,
|
||||
});
|
||||
|
||||
var bySeq = await consumers.FetchAsync("ORDERS", "BYSEQ", 1, streams, default);
|
||||
bySeq.Messages[0].Sequence.ShouldBe((ulong)3);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using NATS.Server.JetStream;
|
||||
using NATS.Server.JetStream.Models;
|
||||
|
||||
namespace NATS.Server.Tests;
|
||||
|
||||
public class JetStreamConsumerFlowControlParityTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Push_consumer_emits_flow_control_frames_when_enabled()
|
||||
{
|
||||
var streams = new StreamManager();
|
||||
streams.CreateOrUpdate(new StreamConfig
|
||||
{
|
||||
Name = "ORDERS",
|
||||
Subjects = ["orders.*"],
|
||||
});
|
||||
|
||||
var consumers = new ConsumerManager();
|
||||
consumers.CreateOrUpdate("ORDERS", new ConsumerConfig
|
||||
{
|
||||
DurableName = "PUSH",
|
||||
Push = true,
|
||||
AckPolicy = AckPolicy.Explicit,
|
||||
FlowControl = true,
|
||||
RateLimitBps = 1024,
|
||||
});
|
||||
|
||||
var ack = streams.Capture("orders.created", "x"u8.ToArray());
|
||||
streams.TryGet("ORDERS", out var stream).ShouldBeTrue();
|
||||
var message = await stream.Store.LoadAsync(ack!.Seq, default);
|
||||
message.ShouldNotBeNull();
|
||||
consumers.OnPublished("ORDERS", message!);
|
||||
|
||||
var first = consumers.ReadPushFrame("ORDERS", "PUSH");
|
||||
var second = consumers.ReadPushFrame("ORDERS", "PUSH");
|
||||
first.ShouldNotBeNull();
|
||||
second.ShouldNotBeNull();
|
||||
first!.IsData.ShouldBeTrue();
|
||||
second!.IsFlowControl.ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NATS.Server.Configuration;
|
||||
using NATS.Server.Gateways;
|
||||
|
||||
namespace NATS.Server.Tests;
|
||||
|
||||
public class JetStreamCrossClusterGatewayParityTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Cross_cluster_jetstream_messages_use_gateway_forwarding_path()
|
||||
{
|
||||
var manager = new GatewayManager(
|
||||
new GatewayOptions { Name = "GW", Host = "127.0.0.1", Port = 0 },
|
||||
new ServerStats(),
|
||||
"S1",
|
||||
_ => { },
|
||||
_ => { },
|
||||
NullLogger<GatewayManager>.Instance);
|
||||
|
||||
await manager.ForwardJetStreamClusterMessageAsync(
|
||||
new GatewayMessage("$JS.CLUSTER.REPL.ORDERS", null, "x"u8.ToArray()),
|
||||
default);
|
||||
|
||||
manager.ForwardedJetStreamClusterMessages.ShouldBe(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using NATS.Server.JetStream.Storage;
|
||||
using System.Text;
|
||||
|
||||
namespace NATS.Server.Tests;
|
||||
|
||||
public class JetStreamFileStoreBlockParityTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task File_store_rolls_blocks_and_recovers_index_without_full_file_rewrite()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), $"nats-js-filestore-block-{Guid.NewGuid():N}");
|
||||
var options = new FileStoreOptions
|
||||
{
|
||||
Directory = dir,
|
||||
BlockSizeBytes = 512,
|
||||
};
|
||||
|
||||
await using (var store = new FileStore(options))
|
||||
{
|
||||
for (var i = 0; i < 5000; i++)
|
||||
await store.AppendAsync("orders.created", Encoding.UTF8.GetBytes($"payload-{i}"), default);
|
||||
|
||||
store.BlockCount.ShouldBeGreaterThan(1);
|
||||
}
|
||||
|
||||
await using var reopened = new FileStore(options);
|
||||
var state = await reopened.GetStateAsync(default);
|
||||
state.Messages.ShouldBe((ulong)5000);
|
||||
}
|
||||
}
|
||||
32
tests/NATS.Server.Tests/JetStreamInternalClientTests.cs
Normal file
32
tests/NATS.Server.Tests/JetStreamInternalClientTests.cs
Normal file
@@ -0,0 +1,32 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NATS.Server.Configuration;
|
||||
|
||||
namespace NATS.Server.Tests;
|
||||
|
||||
public class JetStreamInternalClientTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task JetStream_enabled_server_creates_internal_jetstream_client_and_keeps_it_account_scoped()
|
||||
{
|
||||
var options = new NatsOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
JetStream = new JetStreamOptions
|
||||
{
|
||||
StoreDir = Path.Combine(Path.GetTempPath(), $"nats-js-internal-{Guid.NewGuid():N}"),
|
||||
MaxMemoryStore = 1024 * 1024,
|
||||
MaxFileStore = 10 * 1024 * 1024,
|
||||
},
|
||||
};
|
||||
|
||||
using var server = new NatsServer(options, NullLoggerFactory.Instance);
|
||||
using var cts = new CancellationTokenSource();
|
||||
_ = server.StartAsync(cts.Token);
|
||||
await server.WaitForReadyAsync();
|
||||
|
||||
server.JetStreamInternalClient.ShouldNotBeNull();
|
||||
server.JetStreamInternalClient!.Kind.ShouldBe(ClientKind.JetStream);
|
||||
server.JetStreamInternalClient.Account?.Name.ShouldBe("$SYS");
|
||||
}
|
||||
}
|
||||
38
tests/NATS.Server.Tests/JetStreamMirrorSourceParityTests.cs
Normal file
38
tests/NATS.Server.Tests/JetStreamMirrorSourceParityTests.cs
Normal file
@@ -0,0 +1,38 @@
|
||||
using NATS.Server.JetStream;
|
||||
using NATS.Server.JetStream.Models;
|
||||
|
||||
namespace NATS.Server.Tests;
|
||||
|
||||
public class JetStreamMirrorSourceParityTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Source_subject_transform_and_cross_account_mapping_copy_expected_messages_only()
|
||||
{
|
||||
var manager = new StreamManager();
|
||||
manager.CreateOrUpdate(new StreamConfig
|
||||
{
|
||||
Name = "SRC",
|
||||
Subjects = ["orders.*"],
|
||||
});
|
||||
|
||||
manager.CreateOrUpdate(new StreamConfig
|
||||
{
|
||||
Name = "AGG",
|
||||
Subjects = ["agg.*"],
|
||||
Sources =
|
||||
[
|
||||
new StreamSourceConfig
|
||||
{
|
||||
Name = "SRC",
|
||||
SubjectTransformPrefix = "agg.",
|
||||
SourceAccount = "A",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
manager.Capture("orders.created", "1"u8.ToArray());
|
||||
manager.TryGet("AGG", out var aggregate).ShouldBeTrue();
|
||||
var messages = await aggregate.Store.ListAsync(default);
|
||||
messages.ShouldContain(m => m.Subject == "agg.orders.created");
|
||||
}
|
||||
}
|
||||
24
tests/NATS.Server.Tests/JetStreamStoreExpiryParityTests.cs
Normal file
24
tests/NATS.Server.Tests/JetStreamStoreExpiryParityTests.cs
Normal file
@@ -0,0 +1,24 @@
|
||||
using NATS.Server.JetStream.Storage;
|
||||
|
||||
namespace NATS.Server.Tests;
|
||||
|
||||
public class JetStreamStoreExpiryParityTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task File_store_prunes_expired_messages_using_max_age_policy()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), $"nats-js-filestore-expiry-{Guid.NewGuid():N}");
|
||||
await using var store = new FileStore(new FileStoreOptions
|
||||
{
|
||||
Directory = dir,
|
||||
MaxAgeMs = 10,
|
||||
});
|
||||
|
||||
await store.AppendAsync("orders.created", "old"u8.ToArray(), default);
|
||||
await Task.Delay(20);
|
||||
await store.AppendAsync("orders.created", "new"u8.ToArray(), default);
|
||||
|
||||
var state = await store.GetStateAsync(default);
|
||||
state.Messages.ShouldBe((ulong)1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using NATS.Server.JetStream.Models;
|
||||
using NATS.Server.JetStream;
|
||||
using NATS.Server.JetStream.Publish;
|
||||
|
||||
namespace NATS.Server.Tests;
|
||||
|
||||
public class JetStreamStreamConfigBehaviorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Stream_honors_dedup_window_and_sealed_delete_purge_guards()
|
||||
{
|
||||
var streamManager = new StreamManager();
|
||||
streamManager.CreateOrUpdate(new StreamConfig
|
||||
{
|
||||
Name = "ORDERS",
|
||||
Subjects = ["orders.*"],
|
||||
DuplicateWindowMs = 10_000,
|
||||
Sealed = false,
|
||||
DenyDelete = false,
|
||||
DenyPurge = false,
|
||||
});
|
||||
|
||||
var publisher = new JetStreamPublisher(streamManager);
|
||||
publisher.TryCaptureWithOptions("orders.created", "one"u8.ToArray(), new PublishOptions { MsgId = "m-1" }, out var first).ShouldBeTrue();
|
||||
publisher.TryCaptureWithOptions("orders.created", "two"u8.ToArray(), new PublishOptions { MsgId = "m-1" }, out var second).ShouldBeTrue();
|
||||
second.Seq.ShouldBe(first.Seq);
|
||||
|
||||
streamManager.CreateOrUpdate(new StreamConfig
|
||||
{
|
||||
Name = "ORDERS",
|
||||
Subjects = ["orders.*"],
|
||||
DuplicateWindowMs = 10_000,
|
||||
Sealed = true,
|
||||
DenyDelete = true,
|
||||
DenyPurge = true,
|
||||
});
|
||||
|
||||
streamManager.DeleteMessage("ORDERS", first.Seq).ShouldBeFalse();
|
||||
streamManager.Purge("ORDERS").ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
38
tests/NATS.Server.Tests/JetStreamStreamPolicyParityTests.cs
Normal file
38
tests/NATS.Server.Tests/JetStreamStreamPolicyParityTests.cs
Normal file
@@ -0,0 +1,38 @@
|
||||
using NATS.Server.JetStream.Models;
|
||||
using NATS.Server.JetStream;
|
||||
|
||||
namespace NATS.Server.Tests;
|
||||
|
||||
public class JetStreamStreamPolicyParityTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Stream_rejects_oversize_message_and_prunes_by_max_age_and_per_subject_limits()
|
||||
{
|
||||
var streamManager = new StreamManager();
|
||||
var create = streamManager.CreateOrUpdate(new StreamConfig
|
||||
{
|
||||
Name = "P",
|
||||
Subjects = ["p.*"],
|
||||
MaxMsgSize = 8,
|
||||
MaxAgeMs = 20,
|
||||
MaxMsgsPer = 1,
|
||||
});
|
||||
create.Error.ShouldBeNull();
|
||||
|
||||
var oversized = streamManager.Capture("p.a", "0123456789"u8.ToArray());
|
||||
oversized.ShouldNotBeNull();
|
||||
oversized!.ErrorCode.ShouldBe(10054);
|
||||
|
||||
streamManager.Capture("p.a", "one"u8.ToArray())!.ErrorCode.ShouldBeNull();
|
||||
streamManager.Capture("p.a", "two"u8.ToArray())!.ErrorCode.ShouldBeNull();
|
||||
|
||||
streamManager.TryGet("P", out var handle).ShouldBeTrue();
|
||||
var beforeAgePrune = await handle.Store.GetStateAsync(default);
|
||||
beforeAgePrune.Messages.ShouldBe((ulong)1);
|
||||
|
||||
await Task.Delay(30);
|
||||
streamManager.Capture("p.b", "x"u8.ToArray())!.ErrorCode.ShouldBeNull();
|
||||
var afterAgePrune = await handle.Store.GetStateAsync(default);
|
||||
afterAgePrune.Messages.ShouldBe((ulong)1);
|
||||
}
|
||||
}
|
||||
70
tests/NATS.Server.Tests/LeafAdvancedSemanticsTests.cs
Normal file
70
tests/NATS.Server.Tests/LeafAdvancedSemanticsTests.cs
Normal file
@@ -0,0 +1,70 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using NATS.Server.LeafNodes;
|
||||
using NATS.Server.Subscriptions;
|
||||
|
||||
namespace NATS.Server.Tests;
|
||||
|
||||
public class LeafAdvancedSemanticsTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Leaf_loop_marker_blocks_reinjected_message_and_account_mapping_routes_to_expected_account()
|
||||
{
|
||||
const string serverId = "S1";
|
||||
var marked = LeafLoopDetector.Mark("orders.created", serverId);
|
||||
LeafLoopDetector.IsLooped(marked, serverId).ShouldBeTrue();
|
||||
LeafLoopDetector.IsLooped(marked, "S2").ShouldBeFalse();
|
||||
LeafLoopDetector.TryUnmark(marked, out var unmarked).ShouldBeTrue();
|
||||
unmarked.ShouldBe("orders.created");
|
||||
|
||||
using var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||
|
||||
using var remoteSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
await remoteSocket.ConnectAsync(IPAddress.Loopback, port);
|
||||
using var leafSocket = await listener.AcceptSocketAsync();
|
||||
await using var leaf = new LeafConnection(leafSocket);
|
||||
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
var handshakeTask = leaf.PerformOutboundHandshakeAsync("LOCAL", timeout.Token);
|
||||
(await ReadLineAsync(remoteSocket, timeout.Token)).ShouldBe("LEAF LOCAL");
|
||||
await WriteLineAsync(remoteSocket, "LEAF REMOTE", timeout.Token);
|
||||
await handshakeTask;
|
||||
|
||||
var received = new TaskCompletionSource<RemoteSubscription>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
leaf.RemoteSubscriptionReceived = sub =>
|
||||
{
|
||||
received.TrySetResult(sub);
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
leaf.StartLoop(timeout.Token);
|
||||
|
||||
await WriteLineAsync(remoteSocket, "LS+ ACC_A leaf.>", timeout.Token);
|
||||
var lsPlus = await received.Task.WaitAsync(timeout.Token);
|
||||
lsPlus.Account.ShouldBe("ACC_A");
|
||||
lsPlus.Subject.ShouldBe("leaf.>");
|
||||
}
|
||||
|
||||
private static async Task<string> ReadLineAsync(Socket socket, CancellationToken ct)
|
||||
{
|
||||
var bytes = new List<byte>(64);
|
||||
var single = new byte[1];
|
||||
while (true)
|
||||
{
|
||||
var read = await socket.ReceiveAsync(single, SocketFlags.None, ct);
|
||||
if (read == 0)
|
||||
break;
|
||||
if (single[0] == (byte)'\n')
|
||||
break;
|
||||
if (single[0] != (byte)'\r')
|
||||
bytes.Add(single[0]);
|
||||
}
|
||||
|
||||
return Encoding.ASCII.GetString([.. bytes]);
|
||||
}
|
||||
|
||||
private static Task WriteLineAsync(Socket socket, string line, CancellationToken ct)
|
||||
=> socket.SendAsync(Encoding.ASCII.GetBytes($"{line}\r\n"), SocketFlags.None, ct).AsTask();
|
||||
}
|
||||
22
tests/NATS.Server.Tests/RaftConsensusAdvancedParityTests.cs
Normal file
22
tests/NATS.Server.Tests/RaftConsensusAdvancedParityTests.cs
Normal file
@@ -0,0 +1,22 @@
|
||||
using NATS.Server.Raft;
|
||||
|
||||
namespace NATS.Server.Tests;
|
||||
|
||||
public class RaftConsensusAdvancedParityTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Leader_heartbeats_keep_followers_current_and_next_index_backtracks_on_mismatch()
|
||||
{
|
||||
var transport = new InMemoryRaftTransport();
|
||||
var leader = new RaftNode("L", transport);
|
||||
var follower = new RaftNode("F", transport);
|
||||
transport.Register(leader);
|
||||
transport.Register(follower);
|
||||
|
||||
await transport.AppendHeartbeatAsync("L", ["F"], term: 2, default);
|
||||
follower.Term.ShouldBe(2);
|
||||
|
||||
RaftReplicator.BacktrackNextIndex(5).ShouldBe(4);
|
||||
RaftReplicator.BacktrackNextIndex(1).ShouldBe(1);
|
||||
}
|
||||
}
|
||||
19
tests/NATS.Server.Tests/RaftMembershipParityTests.cs
Normal file
19
tests/NATS.Server.Tests/RaftMembershipParityTests.cs
Normal file
@@ -0,0 +1,19 @@
|
||||
using NATS.Server.Raft;
|
||||
|
||||
namespace NATS.Server.Tests;
|
||||
|
||||
public class RaftMembershipParityTests
|
||||
{
|
||||
[Fact]
|
||||
public void Membership_changes_update_node_membership_state()
|
||||
{
|
||||
var node = new RaftNode("N1");
|
||||
node.AddMember("N2");
|
||||
node.AddMember("N3");
|
||||
node.Members.ShouldContain("N2");
|
||||
node.Members.ShouldContain("N3");
|
||||
|
||||
node.RemoveMember("N2");
|
||||
node.Members.ShouldNotContain("N2");
|
||||
}
|
||||
}
|
||||
25
tests/NATS.Server.Tests/RaftSnapshotTransferParityTests.cs
Normal file
25
tests/NATS.Server.Tests/RaftSnapshotTransferParityTests.cs
Normal file
@@ -0,0 +1,25 @@
|
||||
using NATS.Server.Raft;
|
||||
|
||||
namespace NATS.Server.Tests;
|
||||
|
||||
public class RaftSnapshotTransferParityTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Snapshot_transfer_installs_snapshot_when_follower_falls_behind()
|
||||
{
|
||||
var transport = new InMemoryRaftTransport();
|
||||
var leader = new RaftNode("L", transport);
|
||||
var follower = new RaftNode("F", transport);
|
||||
transport.Register(leader);
|
||||
transport.Register(follower);
|
||||
|
||||
var snapshot = new RaftSnapshot
|
||||
{
|
||||
LastIncludedIndex = 10,
|
||||
LastIncludedTerm = 3,
|
||||
};
|
||||
|
||||
await transport.InstallSnapshotAsync("L", "F", snapshot, default);
|
||||
follower.AppliedIndex.ShouldBe(10);
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,9 @@ public class StreamStoreContractTests
|
||||
public ValueTask<StoredMessage?> LoadLastBySubjectAsync(string subject, CancellationToken ct)
|
||||
=> ValueTask.FromResult<StoredMessage?>(null);
|
||||
|
||||
public ValueTask<IReadOnlyList<StoredMessage>> ListAsync(CancellationToken ct)
|
||||
=> ValueTask.FromResult<IReadOnlyList<StoredMessage>>([]);
|
||||
|
||||
public ValueTask<bool> RemoveAsync(ulong sequence, CancellationToken ct)
|
||||
=> ValueTask.FromResult(false);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user