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.
242 lines
10 KiB
C#
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);
|
|
}
|
|
}
|