feat(jetstream): add API leader forwarding and stream purge options (C7+C8)
C7: JetStreamApiRouter now checks leadership before mutating operations. Non-leader nodes return error code 10003 with a leader_hint field. JetStreamMetaGroup gains IsLeader() and Leader for cluster-aware routing. C8: StreamApiHandlers.HandlePurge accepts PurgeRequest options (filter, seq, keep). StreamManager.PurgeEx implements subject-filtered purge, sequence-based purge, keep-last-N, and filter+keep combinations.
This commit is contained in:
150
tests/NATS.Server.Tests/JetStream/Api/LeaderForwardingTests.cs
Normal file
150
tests/NATS.Server.Tests/JetStream/Api/LeaderForwardingTests.cs
Normal file
@@ -0,0 +1,150 @@
|
||||
// Go reference: jetstream_api.go:200-300 — API requests at non-leader nodes must be
|
||||
// forwarded to the current leader. Mutating operations return a not-leader error with
|
||||
// a leader_hint field; read-only operations are handled locally on any node.
|
||||
|
||||
using System.Text;
|
||||
using NATS.Server.JetStream;
|
||||
using NATS.Server.JetStream.Api;
|
||||
using NATS.Server.JetStream.Cluster;
|
||||
|
||||
namespace NATS.Server.Tests.JetStream.Api;
|
||||
|
||||
public class LeaderForwardingTests
|
||||
{
|
||||
/// <summary>
|
||||
/// When this node IS the leader, mutating requests are handled locally.
|
||||
/// Go reference: jetstream_api.go — leader handles requests directly.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Route_WhenLeader_HandlesLocally()
|
||||
{
|
||||
// selfIndex=1 matches default leaderIndex=1, so this node is the leader.
|
||||
var metaGroup = new JetStreamMetaGroup(nodes: 3, selfIndex: 1);
|
||||
var streamManager = new StreamManager(metaGroup);
|
||||
var consumerManager = new ConsumerManager();
|
||||
var router = new JetStreamApiRouter(streamManager, consumerManager, metaGroup);
|
||||
|
||||
// Create a stream first so the purge has something to operate on.
|
||||
var createPayload = Encoding.UTF8.GetBytes("""{"name":"TEST","subjects":["test.>"]}""");
|
||||
var createResult = router.Route("$JS.API.STREAM.CREATE.TEST", createPayload);
|
||||
createResult.Error.ShouldBeNull();
|
||||
createResult.StreamInfo.ShouldNotBeNull();
|
||||
|
||||
// A mutating operation (delete) should succeed locally.
|
||||
var deleteResult = router.Route("$JS.API.STREAM.DELETE.TEST", ReadOnlySpan<byte>.Empty);
|
||||
deleteResult.Error.ShouldBeNull();
|
||||
deleteResult.Success.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// When this node is NOT the leader, mutating operations return a not-leader error
|
||||
/// with the current leader's identifier in the leader_hint field.
|
||||
/// Go reference: jetstream_api.go:200-300 — not-leader response.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Route_WhenNotLeader_MutatingOp_ReturnsNotLeaderError()
|
||||
{
|
||||
// selfIndex=2, leaderIndex defaults to 1 — this node is NOT the leader.
|
||||
var metaGroup = new JetStreamMetaGroup(nodes: 3, selfIndex: 2);
|
||||
var streamManager = new StreamManager(metaGroup);
|
||||
var consumerManager = new ConsumerManager();
|
||||
var router = new JetStreamApiRouter(streamManager, consumerManager, metaGroup);
|
||||
|
||||
var payload = Encoding.UTF8.GetBytes("""{"name":"TEST","subjects":["test.>"]}""");
|
||||
var result = router.Route("$JS.API.STREAM.CREATE.TEST", payload);
|
||||
|
||||
result.Error.ShouldNotBeNull();
|
||||
result.Error!.Code.ShouldBe(10003);
|
||||
result.Error.Description.ShouldBe("not leader");
|
||||
result.Error.LeaderHint.ShouldNotBeNull();
|
||||
result.Error.LeaderHint.ShouldBe("meta-1");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read-only operations (INFO, NAMES, LIST) are handled locally even when
|
||||
/// this node is not the leader.
|
||||
/// Go reference: jetstream_api.go — read operations do not require leadership.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Route_WhenNotLeader_ReadOp_HandlesLocally()
|
||||
{
|
||||
// selfIndex=2, leaderIndex defaults to 1 — this node is NOT the leader.
|
||||
var metaGroup = new JetStreamMetaGroup(nodes: 3, selfIndex: 2);
|
||||
var streamManager = new StreamManager(metaGroup);
|
||||
var consumerManager = new ConsumerManager();
|
||||
var router = new JetStreamApiRouter(streamManager, consumerManager, metaGroup);
|
||||
|
||||
// $JS.API.INFO is a read-only operation.
|
||||
var infoResult = router.Route("$JS.API.INFO", ReadOnlySpan<byte>.Empty);
|
||||
infoResult.Error.ShouldBeNull();
|
||||
|
||||
// $JS.API.STREAM.NAMES is a read-only operation.
|
||||
var namesResult = router.Route("$JS.API.STREAM.NAMES", ReadOnlySpan<byte>.Empty);
|
||||
namesResult.Error.ShouldBeNull();
|
||||
namesResult.StreamNames.ShouldNotBeNull();
|
||||
|
||||
// $JS.API.STREAM.LIST is a read-only operation.
|
||||
var listResult = router.Route("$JS.API.STREAM.LIST", ReadOnlySpan<byte>.Empty);
|
||||
listResult.Error.ShouldBeNull();
|
||||
listResult.StreamNames.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// When there is no meta-group (single-server mode), all operations are handled
|
||||
/// locally regardless of the subject type.
|
||||
/// Go reference: jetstream_api.go — standalone servers have no meta-group.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Route_NoMetaGroup_HandlesLocally()
|
||||
{
|
||||
// No meta-group — single server mode.
|
||||
var streamManager = new StreamManager();
|
||||
var consumerManager = new ConsumerManager();
|
||||
var router = new JetStreamApiRouter(streamManager, consumerManager, metaGroup: null);
|
||||
|
||||
var payload = Encoding.UTF8.GetBytes("""{"name":"TEST","subjects":["test.>"]}""");
|
||||
var result = router.Route("$JS.API.STREAM.CREATE.TEST", payload);
|
||||
|
||||
// Should succeed — no leader check in single-server mode.
|
||||
result.Error.ShouldBeNull();
|
||||
result.StreamInfo.ShouldNotBeNull();
|
||||
result.StreamInfo!.Config.Name.ShouldBe("TEST");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// IsLeaderRequired returns true for Create, Update, Delete, and Purge operations.
|
||||
/// Go reference: jetstream_api.go:200-300 — mutating operations require leader.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void IsLeaderRequired_CreateUpdate_ReturnsTrue()
|
||||
{
|
||||
JetStreamApiRouter.IsLeaderRequired("$JS.API.STREAM.CREATE.TEST").ShouldBeTrue();
|
||||
JetStreamApiRouter.IsLeaderRequired("$JS.API.STREAM.UPDATE.TEST").ShouldBeTrue();
|
||||
JetStreamApiRouter.IsLeaderRequired("$JS.API.STREAM.DELETE.TEST").ShouldBeTrue();
|
||||
JetStreamApiRouter.IsLeaderRequired("$JS.API.STREAM.PURGE.TEST").ShouldBeTrue();
|
||||
JetStreamApiRouter.IsLeaderRequired("$JS.API.STREAM.RESTORE.TEST").ShouldBeTrue();
|
||||
JetStreamApiRouter.IsLeaderRequired("$JS.API.STREAM.MSG.DELETE.TEST").ShouldBeTrue();
|
||||
JetStreamApiRouter.IsLeaderRequired("$JS.API.CONSUMER.CREATE.STREAM.CON").ShouldBeTrue();
|
||||
JetStreamApiRouter.IsLeaderRequired("$JS.API.CONSUMER.DELETE.STREAM.CON").ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// IsLeaderRequired returns false for Info, Names, List, and other read operations.
|
||||
/// Go reference: jetstream_api.go — read-only operations do not need leadership.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void IsLeaderRequired_InfoList_ReturnsFalse()
|
||||
{
|
||||
JetStreamApiRouter.IsLeaderRequired("$JS.API.INFO").ShouldBeFalse();
|
||||
JetStreamApiRouter.IsLeaderRequired("$JS.API.STREAM.INFO.TEST").ShouldBeFalse();
|
||||
JetStreamApiRouter.IsLeaderRequired("$JS.API.STREAM.NAMES").ShouldBeFalse();
|
||||
JetStreamApiRouter.IsLeaderRequired("$JS.API.STREAM.LIST").ShouldBeFalse();
|
||||
JetStreamApiRouter.IsLeaderRequired("$JS.API.STREAM.MSG.GET.TEST").ShouldBeFalse();
|
||||
JetStreamApiRouter.IsLeaderRequired("$JS.API.STREAM.SNAPSHOT.TEST").ShouldBeFalse();
|
||||
JetStreamApiRouter.IsLeaderRequired("$JS.API.CONSUMER.INFO.STREAM.CON").ShouldBeFalse();
|
||||
JetStreamApiRouter.IsLeaderRequired("$JS.API.CONSUMER.NAMES.STREAM").ShouldBeFalse();
|
||||
JetStreamApiRouter.IsLeaderRequired("$JS.API.CONSUMER.LIST.STREAM").ShouldBeFalse();
|
||||
JetStreamApiRouter.IsLeaderRequired("$JS.API.CONSUMER.MSG.NEXT.STREAM.CON").ShouldBeFalse();
|
||||
JetStreamApiRouter.IsLeaderRequired("$JS.API.DIRECT.GET.TEST").ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
193
tests/NATS.Server.Tests/JetStream/Api/StreamPurgeOptionsTests.cs
Normal file
193
tests/NATS.Server.Tests/JetStream/Api/StreamPurgeOptionsTests.cs
Normal file
@@ -0,0 +1,193 @@
|
||||
// Go reference: jetstream_api.go:1200-1350 — stream purge supports options: subject filter,
|
||||
// sequence cutoff, and keep-last-N. Combinations like filter+keep allow keeping the last N
|
||||
// messages per matching subject.
|
||||
|
||||
using System.Text;
|
||||
using NATS.Server.JetStream;
|
||||
using NATS.Server.JetStream.Api;
|
||||
|
||||
namespace NATS.Server.Tests.JetStream.Api;
|
||||
|
||||
public class StreamPurgeOptionsTests
|
||||
{
|
||||
private static JetStreamApiRouter CreateRouterWithStream(string streamName, string subjectPattern, out StreamManager streamManager)
|
||||
{
|
||||
streamManager = new StreamManager();
|
||||
var consumerManager = new ConsumerManager();
|
||||
var router = new JetStreamApiRouter(streamManager, consumerManager);
|
||||
|
||||
var payload = Encoding.UTF8.GetBytes($$$"""{"name":"{{{streamName}}}","subjects":["{{{subjectPattern}}}"]}""");
|
||||
var result = router.Route($"$JS.API.STREAM.CREATE.{streamName}", payload);
|
||||
result.Error.ShouldBeNull();
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
private static async Task PublishAsync(StreamManager streamManager, string subject, string payload)
|
||||
{
|
||||
var stream = streamManager.FindBySubject(subject);
|
||||
stream.ShouldNotBeNull();
|
||||
await stream.Store.AppendAsync(subject, Encoding.UTF8.GetBytes(payload), default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Purge with no options removes all messages and returns the count.
|
||||
/// Go reference: jetstream_api.go — basic purge with empty request body.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Purge_NoOptions_RemovesAll()
|
||||
{
|
||||
var router = CreateRouterWithStream("TEST", "test.>", out var sm);
|
||||
|
||||
await PublishAsync(sm, "test.a", "1");
|
||||
await PublishAsync(sm, "test.b", "2");
|
||||
await PublishAsync(sm, "test.c", "3");
|
||||
|
||||
var result = router.Route("$JS.API.STREAM.PURGE.TEST", Encoding.UTF8.GetBytes("{}"));
|
||||
result.Error.ShouldBeNull();
|
||||
result.Success.ShouldBeTrue();
|
||||
result.Purged.ShouldBe(3UL);
|
||||
|
||||
var state = await sm.GetStateAsync("TEST", default);
|
||||
state.Messages.ShouldBe(0UL);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Purge with a subject filter removes only messages matching the pattern.
|
||||
/// Go reference: jetstream_api.go:1200-1350 — filter option.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Purge_WithSubjectFilter_RemovesOnlyMatching()
|
||||
{
|
||||
var router = CreateRouterWithStream("TEST", ">", out var sm);
|
||||
|
||||
await PublishAsync(sm, "orders.a", "1");
|
||||
await PublishAsync(sm, "orders.b", "2");
|
||||
await PublishAsync(sm, "logs.x", "3");
|
||||
await PublishAsync(sm, "orders.c", "4");
|
||||
|
||||
var payload = Encoding.UTF8.GetBytes("""{"filter":"orders.*"}""");
|
||||
var result = router.Route("$JS.API.STREAM.PURGE.TEST", payload);
|
||||
result.Error.ShouldBeNull();
|
||||
result.Success.ShouldBeTrue();
|
||||
result.Purged.ShouldBe(3UL);
|
||||
|
||||
var state = await sm.GetStateAsync("TEST", default);
|
||||
state.Messages.ShouldBe(1UL);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Purge with seq option removes all messages with sequence strictly less than the given value.
|
||||
/// Go reference: jetstream_api.go:1200-1350 — seq option.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Purge_WithSeq_RemovesBelowSequence()
|
||||
{
|
||||
var router = CreateRouterWithStream("TEST", "test.>", out var sm);
|
||||
|
||||
await PublishAsync(sm, "test.a", "1"); // seq 1
|
||||
await PublishAsync(sm, "test.b", "2"); // seq 2
|
||||
await PublishAsync(sm, "test.c", "3"); // seq 3
|
||||
await PublishAsync(sm, "test.d", "4"); // seq 4
|
||||
await PublishAsync(sm, "test.e", "5"); // seq 5
|
||||
|
||||
// Remove all messages with seq < 4 (i.e., sequences 1, 2, 3).
|
||||
var payload = Encoding.UTF8.GetBytes("""{"seq":4}""");
|
||||
var result = router.Route("$JS.API.STREAM.PURGE.TEST", payload);
|
||||
result.Error.ShouldBeNull();
|
||||
result.Success.ShouldBeTrue();
|
||||
result.Purged.ShouldBe(3UL);
|
||||
|
||||
var state = await sm.GetStateAsync("TEST", default);
|
||||
state.Messages.ShouldBe(2UL);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Purge with keep option retains the last N messages globally.
|
||||
/// Go reference: jetstream_api.go:1200-1350 — keep option.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Purge_WithKeep_KeepsLastN()
|
||||
{
|
||||
var router = CreateRouterWithStream("TEST", "test.>", out var sm);
|
||||
|
||||
await PublishAsync(sm, "test.a", "1"); // seq 1
|
||||
await PublishAsync(sm, "test.b", "2"); // seq 2
|
||||
await PublishAsync(sm, "test.c", "3"); // seq 3
|
||||
await PublishAsync(sm, "test.d", "4"); // seq 4
|
||||
await PublishAsync(sm, "test.e", "5"); // seq 5
|
||||
|
||||
// Keep the last 2 messages (seq 4, 5); purge 1, 2, 3.
|
||||
var payload = Encoding.UTF8.GetBytes("""{"keep":2}""");
|
||||
var result = router.Route("$JS.API.STREAM.PURGE.TEST", payload);
|
||||
result.Error.ShouldBeNull();
|
||||
result.Success.ShouldBeTrue();
|
||||
result.Purged.ShouldBe(3UL);
|
||||
|
||||
var state = await sm.GetStateAsync("TEST", default);
|
||||
state.Messages.ShouldBe(2UL);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Purge with both filter and keep retains the last N messages per matching subject.
|
||||
/// Go reference: jetstream_api.go:1200-1350 — filter+keep combination.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Purge_FilterAndKeep_KeepsLastNPerFilter()
|
||||
{
|
||||
var router = CreateRouterWithStream("TEST", ">", out var sm);
|
||||
|
||||
// Publish multiple messages on two subjects.
|
||||
await PublishAsync(sm, "orders.a", "o1"); // seq 1
|
||||
await PublishAsync(sm, "orders.a", "o2"); // seq 2
|
||||
await PublishAsync(sm, "orders.a", "o3"); // seq 3
|
||||
await PublishAsync(sm, "logs.x", "l1"); // seq 4 — not matching filter
|
||||
await PublishAsync(sm, "orders.b", "ob1"); // seq 5
|
||||
await PublishAsync(sm, "orders.b", "ob2"); // seq 6
|
||||
|
||||
// Keep last 1 per matching subject "orders.*".
|
||||
// orders.a has 3 msgs -> keep seq 3, purge seq 1, 2
|
||||
// orders.b has 2 msgs -> keep seq 6, purge seq 5
|
||||
// logs.x is unaffected (does not match filter)
|
||||
var payload = Encoding.UTF8.GetBytes("""{"filter":"orders.*","keep":1}""");
|
||||
var result = router.Route("$JS.API.STREAM.PURGE.TEST", payload);
|
||||
result.Error.ShouldBeNull();
|
||||
result.Success.ShouldBeTrue();
|
||||
result.Purged.ShouldBe(3UL);
|
||||
|
||||
var state = await sm.GetStateAsync("TEST", default);
|
||||
// Remaining: orders.a seq 3, logs.x seq 4, orders.b seq 6 = 3 messages
|
||||
state.Messages.ShouldBe(3UL);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Purge on a non-existent stream returns a 404 not-found error.
|
||||
/// Go reference: jetstream_api.go — stream not found.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Purge_InvalidStream_ReturnsNotFound()
|
||||
{
|
||||
var streamManager = new StreamManager();
|
||||
var consumerManager = new ConsumerManager();
|
||||
var router = new JetStreamApiRouter(streamManager, consumerManager);
|
||||
|
||||
var result = router.Route("$JS.API.STREAM.PURGE.NONEXISTENT", Encoding.UTF8.GetBytes("{}"));
|
||||
result.Error.ShouldNotBeNull();
|
||||
result.Error!.Code.ShouldBe(404);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Purge on an empty stream returns success with zero purged count.
|
||||
/// Go reference: jetstream_api.go — purge on empty stream.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Purge_EmptyStream_ReturnsZeroPurged()
|
||||
{
|
||||
var router = CreateRouterWithStream("TEST", "test.>", out _);
|
||||
|
||||
var result = router.Route("$JS.API.STREAM.PURGE.TEST", Encoding.UTF8.GetBytes("{}"));
|
||||
result.Error.ShouldBeNull();
|
||||
result.Success.ShouldBeTrue();
|
||||
result.Purged.ShouldBe(0UL);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user