- RaftGroup, StreamAssignment, ConsumerAssignment types matching Go structs (jetstream_cluster.go:154-266) - PlacementEngine.SelectPeerGroup: topology-aware peer selection with cluster affinity, tag filtering, exclude tags, and storage-weighted sorting (Go ref: selectPeerGroup at line 7212) - JetStreamMetaGroup: backward-compatible rewrite with full assignment tracking, consumer proposal workflow, and delete operations - 41 new tests in ClusterAssignmentAndPlacementTests
246 lines
7.4 KiB
C#
246 lines
7.4 KiB
C#
// Go parity: golang/nats-server/server/jetstream_cluster.go
|
|
// Covers: RaftGroup quorum calculation, HasQuorum checks, StreamAssignment
|
|
// and ConsumerAssignment creation, consumer dictionary operations,
|
|
// Preferred peer tracking.
|
|
using NATS.Server.JetStream.Cluster;
|
|
|
|
namespace NATS.Server.Tests.JetStream.Cluster;
|
|
|
|
/// <summary>
|
|
/// Tests for ClusterAssignmentTypes: RaftGroup quorum semantics,
|
|
/// StreamAssignment lifecycle, and ConsumerAssignment defaults.
|
|
/// Go reference: jetstream_cluster.go:154-266 (raftGroup, streamAssignment, consumerAssignment).
|
|
/// </summary>
|
|
public class AssignmentSerializationTests
|
|
{
|
|
// ---------------------------------------------------------------
|
|
// RaftGroup quorum calculation
|
|
// Go reference: jetstream_cluster.go:154-163 raftGroup.quorumNeeded()
|
|
// ---------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public void RaftGroup_quorum_size_for_single_node_is_one()
|
|
{
|
|
var group = new RaftGroup { Name = "test-r1", Peers = ["peer-1"] };
|
|
|
|
group.QuorumSize.ShouldBe(1);
|
|
}
|
|
|
|
[Fact]
|
|
public void RaftGroup_quorum_size_for_three_nodes_is_two()
|
|
{
|
|
var group = new RaftGroup { Name = "test-r3", Peers = ["p1", "p2", "p3"] };
|
|
|
|
group.QuorumSize.ShouldBe(2);
|
|
}
|
|
|
|
[Fact]
|
|
public void RaftGroup_quorum_size_for_five_nodes_is_three()
|
|
{
|
|
var group = new RaftGroup { Name = "test-r5", Peers = ["p1", "p2", "p3", "p4", "p5"] };
|
|
|
|
group.QuorumSize.ShouldBe(3);
|
|
}
|
|
|
|
[Fact]
|
|
public void RaftGroup_quorum_size_for_empty_peers_is_one()
|
|
{
|
|
var group = new RaftGroup { Name = "test-empty", Peers = [] };
|
|
|
|
// (0 / 2) + 1 = 1
|
|
group.QuorumSize.ShouldBe(1);
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// HasQuorum checks
|
|
// Go reference: jetstream_cluster.go raftGroup quorum check
|
|
// ---------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public void HasQuorum_returns_true_when_acks_meet_quorum()
|
|
{
|
|
var group = new RaftGroup { Name = "q-test", Peers = ["p1", "p2", "p3"] };
|
|
|
|
group.HasQuorum(2).ShouldBeTrue();
|
|
group.HasQuorum(3).ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public void HasQuorum_returns_false_when_acks_below_quorum()
|
|
{
|
|
var group = new RaftGroup { Name = "q-test", Peers = ["p1", "p2", "p3"] };
|
|
|
|
group.HasQuorum(1).ShouldBeFalse();
|
|
group.HasQuorum(0).ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public void HasQuorum_single_node_requires_one_ack()
|
|
{
|
|
var group = new RaftGroup { Name = "q-r1", Peers = ["p1"] };
|
|
|
|
group.HasQuorum(1).ShouldBeTrue();
|
|
group.HasQuorum(0).ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public void HasQuorum_five_nodes_requires_three_acks()
|
|
{
|
|
var group = new RaftGroup { Name = "q-r5", Peers = ["p1", "p2", "p3", "p4", "p5"] };
|
|
|
|
group.HasQuorum(2).ShouldBeFalse();
|
|
group.HasQuorum(3).ShouldBeTrue();
|
|
group.HasQuorum(5).ShouldBeTrue();
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// RaftGroup property defaults
|
|
// Go reference: jetstream_cluster.go:154-163
|
|
// ---------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public void RaftGroup_defaults_storage_to_file()
|
|
{
|
|
var group = new RaftGroup { Name = "defaults" };
|
|
|
|
group.StorageType.ShouldBe("file");
|
|
}
|
|
|
|
[Fact]
|
|
public void RaftGroup_defaults_cluster_to_empty()
|
|
{
|
|
var group = new RaftGroup { Name = "defaults" };
|
|
|
|
group.Cluster.ShouldBe(string.Empty);
|
|
}
|
|
|
|
[Fact]
|
|
public void RaftGroup_preferred_peer_tracking()
|
|
{
|
|
var group = new RaftGroup { Name = "pref-test", Peers = ["p1", "p2", "p3"] };
|
|
|
|
group.Preferred.ShouldBe(string.Empty);
|
|
|
|
group.Preferred = "p2";
|
|
group.Preferred.ShouldBe("p2");
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// StreamAssignment creation
|
|
// Go reference: jetstream_cluster.go:166-184 streamAssignment
|
|
// ---------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public void StreamAssignment_created_with_defaults()
|
|
{
|
|
var group = new RaftGroup { Name = "sa-group", Peers = ["p1"] };
|
|
var sa = new StreamAssignment
|
|
{
|
|
StreamName = "TEST-STREAM",
|
|
Group = group,
|
|
};
|
|
|
|
sa.StreamName.ShouldBe("TEST-STREAM");
|
|
sa.Group.ShouldBeSameAs(group);
|
|
sa.ConfigJson.ShouldBe("{}");
|
|
sa.SyncSubject.ShouldBe(string.Empty);
|
|
sa.Responded.ShouldBeFalse();
|
|
sa.Recovering.ShouldBeFalse();
|
|
sa.Reassigning.ShouldBeFalse();
|
|
sa.Consumers.ShouldBeEmpty();
|
|
sa.Created.ShouldBeGreaterThan(DateTime.MinValue);
|
|
}
|
|
|
|
[Fact]
|
|
public void StreamAssignment_consumers_dictionary_operations()
|
|
{
|
|
var group = new RaftGroup { Name = "sa-cons", Peers = ["p1", "p2", "p3"] };
|
|
var sa = new StreamAssignment
|
|
{
|
|
StreamName = "MY-STREAM",
|
|
Group = group,
|
|
};
|
|
|
|
var consumerGroup = new RaftGroup { Name = "cons-group", Peers = ["p1"] };
|
|
var ca = new ConsumerAssignment
|
|
{
|
|
ConsumerName = "durable-1",
|
|
StreamName = "MY-STREAM",
|
|
Group = consumerGroup,
|
|
};
|
|
|
|
sa.Consumers["durable-1"] = ca;
|
|
sa.Consumers.Count.ShouldBe(1);
|
|
sa.Consumers["durable-1"].ConsumerName.ShouldBe("durable-1");
|
|
|
|
sa.Consumers.Remove("durable-1");
|
|
sa.Consumers.ShouldBeEmpty();
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// ConsumerAssignment creation
|
|
// Go reference: jetstream_cluster.go:250-266 consumerAssignment
|
|
// ---------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public void ConsumerAssignment_created_with_defaults()
|
|
{
|
|
var group = new RaftGroup { Name = "ca-group", Peers = ["p1"] };
|
|
var ca = new ConsumerAssignment
|
|
{
|
|
ConsumerName = "my-consumer",
|
|
StreamName = "MY-STREAM",
|
|
Group = group,
|
|
};
|
|
|
|
ca.ConsumerName.ShouldBe("my-consumer");
|
|
ca.StreamName.ShouldBe("MY-STREAM");
|
|
ca.Group.ShouldBeSameAs(group);
|
|
ca.ConfigJson.ShouldBe("{}");
|
|
ca.Responded.ShouldBeFalse();
|
|
ca.Recovering.ShouldBeFalse();
|
|
ca.Created.ShouldBeGreaterThan(DateTime.MinValue);
|
|
}
|
|
|
|
[Fact]
|
|
public void ConsumerAssignment_mutable_flags()
|
|
{
|
|
var group = new RaftGroup { Name = "ca-flags", Peers = ["p1"] };
|
|
var ca = new ConsumerAssignment
|
|
{
|
|
ConsumerName = "c1",
|
|
StreamName = "S1",
|
|
Group = group,
|
|
};
|
|
|
|
ca.Responded = true;
|
|
ca.Recovering = true;
|
|
|
|
ca.Responded.ShouldBeTrue();
|
|
ca.Recovering.ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public void StreamAssignment_mutable_flags()
|
|
{
|
|
var group = new RaftGroup { Name = "sa-flags", Peers = ["p1"] };
|
|
var sa = new StreamAssignment
|
|
{
|
|
StreamName = "S1",
|
|
Group = group,
|
|
};
|
|
|
|
sa.Responded = true;
|
|
sa.Recovering = true;
|
|
sa.Reassigning = true;
|
|
sa.ConfigJson = """{"subjects":["test.>"]}""";
|
|
sa.SyncSubject = "$JS.SYNC.S1";
|
|
|
|
sa.Responded.ShouldBeTrue();
|
|
sa.Recovering.ShouldBeTrue();
|
|
sa.Reassigning.ShouldBeTrue();
|
|
sa.ConfigJson.ShouldBe("""{"subjects":["test.>"]}""");
|
|
sa.SyncSubject.ShouldBe("$JS.SYNC.S1");
|
|
}
|
|
}
|