// Go reference: jetstream_cluster.go:7620-8265 — clustered stream/consumer API handlers // propose to the meta RAFT group rather than applying locally to StreamManager/ConsumerManager. using System.Text; using NATS.Server.JetStream.Api.Handlers; using NATS.Server.JetStream.Cluster; namespace NATS.Server.JetStream.Tests.JetStream.Api; public class ClusteredApiTests { // --------------------------------------------------------------- // Stream clustered handlers // --------------------------------------------------------------- /// /// A successful clustered create proposes to the meta group, resulting in a new stream /// assignment tracked under the provided name. /// Go reference: jetstream_cluster.go:7620 jsClusteredStreamRequest. /// [Fact] public async Task HandleClusteredCreate_proposes_to_meta_group() { var metaGroup = new JetStreamMetaGroup(nodes: 1); var payload = Encoding.UTF8.GetBytes("""{"name":"ORDERS","subjects":["orders.>"]}"""); var response = await StreamApiHandlers.HandleClusteredCreateAsync( "$JS.API.STREAM.CREATE.ORDERS", payload, metaGroup, CancellationToken.None); response.Error.ShouldBeNull(); response.Success.ShouldBeTrue(); metaGroup.GetStreamAssignment("ORDERS").ShouldNotBeNull(); } /// /// A duplicate clustered create for the same stream name returns an error response. /// Go reference: jetstream_cluster.go — duplicate stream proposal returns error. /// [Fact] public async Task HandleClusteredCreate_returns_error_for_duplicate() { var metaGroup = new JetStreamMetaGroup(nodes: 1); var payload = Encoding.UTF8.GetBytes("""{"name":"ORDERS","subjects":["orders.>"]}"""); // First create succeeds. var first = await StreamApiHandlers.HandleClusteredCreateAsync( "$JS.API.STREAM.CREATE.ORDERS", payload, metaGroup, CancellationToken.None); first.Error.ShouldBeNull(); // Second create for same name returns error. var second = await StreamApiHandlers.HandleClusteredCreateAsync( "$JS.API.STREAM.CREATE.ORDERS", payload, metaGroup, CancellationToken.None); second.Error.ShouldNotBeNull(); second.Error!.Description.ShouldContain("ORDERS"); } /// /// When this node is not the meta-group leader, clustered create returns a not-leader error. /// Go reference: jetstream_cluster.go:7620 — leader check before proposing. /// [Fact] public async Task HandleClusteredCreate_returns_error_when_not_leader() { // selfIndex=2, leaderIndex defaults to 1 — this node is NOT the leader. var metaGroup = new JetStreamMetaGroup(nodes: 3, selfIndex: 2); var payload = Encoding.UTF8.GetBytes("""{"name":"ORDERS","subjects":["orders.>"]}"""); var response = await StreamApiHandlers.HandleClusteredCreateAsync( "$JS.API.STREAM.CREATE.ORDERS", payload, metaGroup, CancellationToken.None); response.Error.ShouldNotBeNull(); response.Error!.Code.ShouldBe(10003); response.Error.Description.ShouldBe("not leader"); } /// /// Clustered update proposes a config change to an existing stream assignment. /// Go reference: jetstream_cluster.go jsClusteredStreamUpdateRequest. /// [Fact] public async Task HandleClusteredUpdate_updates_existing_stream() { var metaGroup = new JetStreamMetaGroup(nodes: 1); // Create the stream first. var createPayload = Encoding.UTF8.GetBytes("""{"name":"EVENTS","subjects":["events.>"]}"""); await StreamApiHandlers.HandleClusteredCreateAsync( "$JS.API.STREAM.CREATE.EVENTS", createPayload, metaGroup, CancellationToken.None); // Now update it with a max_msgs constraint. var updatePayload = Encoding.UTF8.GetBytes("""{"name":"EVENTS","subjects":["events.>"],"max_msgs":500}"""); var response = await StreamApiHandlers.HandleClusteredUpdateAsync( "$JS.API.STREAM.UPDATE.EVENTS", updatePayload, metaGroup, CancellationToken.None); response.Error.ShouldBeNull(); response.Success.ShouldBeTrue(); // The assignment should still exist. metaGroup.GetStreamAssignment("EVENTS").ShouldNotBeNull(); } /// /// Clustered delete proposes removal of a stream from the meta group. /// Go reference: jetstream_cluster.go processStreamRemoval via meta leader. /// [Fact] public async Task HandleClusteredDelete_proposes_deletion() { var metaGroup = new JetStreamMetaGroup(nodes: 1); var createPayload = Encoding.UTF8.GetBytes("""{"name":"ORDERS","subjects":["orders.>"]}"""); await StreamApiHandlers.HandleClusteredCreateAsync( "$JS.API.STREAM.CREATE.ORDERS", createPayload, metaGroup, CancellationToken.None); metaGroup.GetStreamAssignment("ORDERS").ShouldNotBeNull(); var response = await StreamApiHandlers.HandleClusteredDeleteAsync( "$JS.API.STREAM.DELETE.ORDERS", metaGroup, CancellationToken.None); response.Error.ShouldBeNull(); response.Success.ShouldBeTrue(); metaGroup.GetStreamAssignment("ORDERS").ShouldBeNull(); } /// /// Clustered delete of a non-existent stream returns a 404 not-found error. /// Go reference: jetstream_cluster.go — delete missing stream returns error. /// [Fact] public async Task HandleClusteredDelete_returns_error_for_missing_stream() { var metaGroup = new JetStreamMetaGroup(nodes: 1); var response = await StreamApiHandlers.HandleClusteredDeleteAsync( "$JS.API.STREAM.DELETE.GHOST", metaGroup, CancellationToken.None); response.Error.ShouldNotBeNull(); response.Error!.Code.ShouldBe(404); } // --------------------------------------------------------------- // Consumer clustered handlers // --------------------------------------------------------------- /// /// Clustered consumer create proposes to the meta group, adding the consumer to the /// stream's assignment map. /// Go reference: jetstream_cluster.go:8100 jsClusteredConsumerRequest. /// [Fact] public async Task Consumer_clustered_create_proposes_to_meta() { var metaGroup = new JetStreamMetaGroup(nodes: 1); // Create parent stream first. await StreamApiHandlers.HandleClusteredCreateAsync( "$JS.API.STREAM.CREATE.ORDERS", Encoding.UTF8.GetBytes("""{"name":"ORDERS","subjects":["orders.>"]}"""), metaGroup, CancellationToken.None); var consumerPayload = Encoding.UTF8.GetBytes("""{"durable_name":"MON","filter_subject":"orders.created"}"""); var response = await ConsumerApiHandlers.HandleClusteredCreateAsync( "$JS.API.CONSUMER.CREATE.ORDERS.MON", consumerPayload, metaGroup, CancellationToken.None); response.Error.ShouldBeNull(); response.Success.ShouldBeTrue(); metaGroup.GetConsumerAssignment("ORDERS", "MON").ShouldNotBeNull(); } /// /// Creating a consumer on a stream that does not exist in the meta group returns an error. /// Go reference: jetstream_cluster.go — consumer proposal validates stream existence. /// [Fact] public async Task Consumer_clustered_create_returns_error_for_missing_stream() { var metaGroup = new JetStreamMetaGroup(nodes: 1); var payload = Encoding.UTF8.GetBytes("""{"durable_name":"MON","filter_subject":"orders.created"}"""); var response = await ConsumerApiHandlers.HandleClusteredCreateAsync( "$JS.API.CONSUMER.CREATE.GHOST.MON", payload, metaGroup, CancellationToken.None); response.Error.ShouldNotBeNull(); response.Error!.Description.ShouldContain("GHOST"); } /// /// Clustered consumer delete removes the consumer from the stream assignment. /// Go reference: jetstream_cluster.go processConsumerRemoval via meta leader. /// [Fact] public async Task Consumer_clustered_delete_removes_consumer() { var metaGroup = new JetStreamMetaGroup(nodes: 1); // Set up stream and consumer. await StreamApiHandlers.HandleClusteredCreateAsync( "$JS.API.STREAM.CREATE.ORDERS", Encoding.UTF8.GetBytes("""{"name":"ORDERS","subjects":["orders.>"]}"""), metaGroup, CancellationToken.None); await ConsumerApiHandlers.HandleClusteredCreateAsync( "$JS.API.CONSUMER.CREATE.ORDERS.MON", Encoding.UTF8.GetBytes("""{"durable_name":"MON"}"""), metaGroup, CancellationToken.None); metaGroup.GetConsumerAssignment("ORDERS", "MON").ShouldNotBeNull(); var response = await ConsumerApiHandlers.HandleClusteredDeleteAsync( "$JS.API.CONSUMER.DELETE.ORDERS.MON", metaGroup, CancellationToken.None); response.Error.ShouldBeNull(); response.Success.ShouldBeTrue(); metaGroup.GetConsumerAssignment("ORDERS", "MON").ShouldBeNull(); } /// /// Deleting a non-existent consumer returns a 404 not-found error. /// Go reference: jetstream_cluster.go — consumer delete validates existence. /// [Fact] public async Task Consumer_clustered_delete_returns_not_found_for_missing() { var metaGroup = new JetStreamMetaGroup(nodes: 1); // Create the stream but not the consumer. await StreamApiHandlers.HandleClusteredCreateAsync( "$JS.API.STREAM.CREATE.ORDERS", Encoding.UTF8.GetBytes("""{"name":"ORDERS","subjects":["orders.>"]}"""), metaGroup, CancellationToken.None); var response = await ConsumerApiHandlers.HandleClusteredDeleteAsync( "$JS.API.CONSUMER.DELETE.ORDERS.GHOST", metaGroup, CancellationToken.None); response.Error.ShouldNotBeNull(); response.Error!.Code.ShouldBe(404); } }