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
This commit is contained in:
@@ -0,0 +1,245 @@
|
||||
// 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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user