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.
194 lines
7.5 KiB
C#
194 lines
7.5 KiB
C#
// 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);
|
|
}
|
|
}
|