Files
natsdotnet/tests/NATS.Server.Tests/JetStream/Cluster/AssignmentSerializationTests.cs
Joseph Doherty a323715495 feat(cluster): add stream/consumer assignments, placement engine, and meta proposal workflow (B7+B8+B9)
- 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
2026-02-24 17:13:28 -05:00

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");
}
}