Files
natsdotnet/tests/NATS.Server.Tests/JetStream/Api/ClusteredApiTests.cs
Joseph Doherty f6d024c50d feat: add clustered stream/consumer API handlers (Gap 2.12)
Implement HandleClusteredCreateAsync, HandleClusteredUpdateAsync, and
HandleClusteredDeleteAsync on StreamApiHandlers, and HandleClusteredCreateAsync
and HandleClusteredDeleteAsync on ConsumerApiHandlers. These handlers propose
operations to the meta RAFT group (JetStreamMetaGroup) instead of operating on
the local StreamManager/ConsumerManager, matching the Go jsClusteredStreamRequest
and jsClusteredConsumerRequest patterns (jetstream_cluster.go:7620-8265).

Ten tests in ClusteredApiTests.cs verify: stream create proposes to meta group,
duplicate-stream error, not-leader error (code 10003), stream update, stream
delete, not-found-on-delete, consumer create on stream, consumer-on-missing-stream
error, consumer delete, and not-found consumer delete.
2026-02-25 10:43:49 -05:00

242 lines
10 KiB
C#

// 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.Tests.JetStream.Api;
public class ClusteredApiTests
{
// ---------------------------------------------------------------
// Stream clustered handlers
// ---------------------------------------------------------------
/// <summary>
/// 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.
/// </summary>
[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();
}
/// <summary>
/// A duplicate clustered create for the same stream name returns an error response.
/// Go reference: jetstream_cluster.go — duplicate stream proposal returns error.
/// </summary>
[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");
}
/// <summary>
/// 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.
/// </summary>
[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");
}
/// <summary>
/// Clustered update proposes a config change to an existing stream assignment.
/// Go reference: jetstream_cluster.go jsClusteredStreamUpdateRequest.
/// </summary>
[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();
}
/// <summary>
/// Clustered delete proposes removal of a stream from the meta group.
/// Go reference: jetstream_cluster.go processStreamRemoval via meta leader.
/// </summary>
[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();
}
/// <summary>
/// Clustered delete of a non-existent stream returns a 404 not-found error.
/// Go reference: jetstream_cluster.go — delete missing stream returns error.
/// </summary>
[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
// ---------------------------------------------------------------
/// <summary>
/// Clustered consumer create proposes to the meta group, adding the consumer to the
/// stream's assignment map.
/// Go reference: jetstream_cluster.go:8100 jsClusteredConsumerRequest.
/// </summary>
[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();
}
/// <summary>
/// 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.
/// </summary>
[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");
}
/// <summary>
/// Clustered consumer delete removes the consumer from the stream assignment.
/// Go reference: jetstream_cluster.go processConsumerRemoval via meta leader.
/// </summary>
[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();
}
/// <summary>
/// Deleting a non-existent consumer returns a 404 not-found error.
/// Go reference: jetstream_cluster.go — consumer delete validates existence.
/// </summary>
[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);
}
}