feat: Wave 6 batch 2 — accounts/auth, gateways, routes, JetStream API, JetStream cluster tests

Add comprehensive Go-parity test coverage across 5 subsystems:
- Accounts/Auth: isolation, import/export, auth mechanisms, permissions (82 tests)
- Gateways: connection, forwarding, interest mode, config (106 tests)
- Routes: connection, subscription, forwarding, config validation (78 tests)
- JetStream API: stream/consumer CRUD, pub/sub, features, admin (234 tests)
- JetStream Cluster: streams, consumers, failover, meta (108 tests)

Total: ~608 new test annotations across 22 files (+13,844 lines)
All tests pass individually; suite total: 2,283 passing, 3 skipped
This commit is contained in:
Joseph Doherty
2026-02-23 22:35:06 -05:00
parent 9554d53bf5
commit f1353868af
23 changed files with 13844 additions and 74 deletions

View File

@@ -0,0 +1,644 @@
// Go parity: golang/nats-server/server/jetstream_cluster_1_test.go
// Covers: consumer creation, ack propagation, consumer state,
// ephemeral consumers, consumer scaling, pull/push delivery,
// redelivery, ack policies, filter subjects.
using System.Text;
using NATS.Server.JetStream;
using NATS.Server.JetStream.Api;
using NATS.Server.JetStream.Cluster;
using NATS.Server.JetStream.Consumers;
using NATS.Server.JetStream.Models;
using NATS.Server.JetStream.Publish;
namespace NATS.Server.Tests.JetStream.Cluster;
/// <summary>
/// Tests covering clustered JetStream consumer creation, leader election,
/// ack propagation, delivery policies, ephemeral consumers, and scaling.
/// Ported from Go jetstream_cluster_1_test.go and jetstream_cluster_2_test.go.
/// </summary>
public class JetStreamClusterConsumerTests
{
// ---------------------------------------------------------------
// Go: TestJetStreamClusterConsumerState server/jetstream_cluster_1_test.go:700
// ---------------------------------------------------------------
[Fact]
public async Task Consumer_state_tracks_pending_after_fetch()
{
await using var fx = await ClusterConsumerFixture.StartAsync(nodes: 3);
await fx.CreateStreamAsync("CSTATE", ["cs.>"], replicas: 3);
await fx.CreateConsumerAsync("CSTATE", "track", filterSubject: "cs.>", ackPolicy: AckPolicy.Explicit);
for (var i = 0; i < 5; i++)
await fx.PublishAsync("cs.event", $"msg-{i}");
var batch = await fx.FetchAsync("CSTATE", "track", 3);
batch.Messages.Count.ShouldBe(3);
var pending = fx.GetPendingCount("CSTATE", "track");
pending.ShouldBe(3);
}
// ---------------------------------------------------------------
// Go: TestJetStreamClusterConsumerRedeliveredInfo server/jetstream_cluster_1_test.go:659
// ---------------------------------------------------------------
[Fact]
public async Task Consumer_redelivery_marks_messages_as_redelivered()
{
await using var fx = await ClusterConsumerFixture.StartAsync(nodes: 3);
await fx.CreateStreamAsync("REDELIV", ["rd.>"], replicas: 3);
await fx.CreateConsumerAsync("REDELIV", "rdc", filterSubject: "rd.>",
ackPolicy: AckPolicy.Explicit, ackWaitMs: 1, maxDeliver: 5);
await fx.PublishAsync("rd.event", "will-redeliver");
// First fetch should get the message
var batch1 = await fx.FetchAsync("REDELIV", "rdc", 1);
batch1.Messages.Count.ShouldBe(1);
batch1.Messages[0].Redelivered.ShouldBeFalse();
// Wait for ack timeout
await Task.Delay(50);
// Second fetch should get redelivered message
var batch2 = await fx.FetchAsync("REDELIV", "rdc", 1);
batch2.Messages.Count.ShouldBe(1);
batch2.Messages[0].Redelivered.ShouldBeTrue();
}
// ---------------------------------------------------------------
// Go: TestJetStreamClusterFullConsumerState server/jetstream_cluster_1_test.go:795
// ---------------------------------------------------------------
[Fact]
public async Task Full_consumer_state_reflects_ack_floor_after_ack_all()
{
await using var fx = await ClusterConsumerFixture.StartAsync(nodes: 3);
await fx.CreateStreamAsync("FULLCS", ["fcs.>"], replicas: 3);
await fx.CreateConsumerAsync("FULLCS", "full", filterSubject: "fcs.>", ackPolicy: AckPolicy.All);
for (var i = 0; i < 10; i++)
await fx.PublishAsync("fcs.event", $"msg-{i}");
var batch = await fx.FetchAsync("FULLCS", "full", 10);
batch.Messages.Count.ShouldBe(10);
// Ack all up to sequence 5
fx.AckAll("FULLCS", "full", 5);
var pending = fx.GetPendingCount("FULLCS", "full");
pending.ShouldBe(5);
}
// ---------------------------------------------------------------
// Go: TestJetStreamClusterEphemeralConsumerNoImmediateInterest server/jetstream_cluster_1_test.go:2481
// ---------------------------------------------------------------
[Fact]
public async Task Ephemeral_consumer_creation_succeeds()
{
await using var fx = await ClusterConsumerFixture.StartAsync(nodes: 3);
await fx.CreateStreamAsync("EPHEM", ["eph.>"], replicas: 3);
var resp = await fx.CreateConsumerAsync("EPHEM", null, ephemeral: true);
resp.ConsumerInfo.ShouldNotBeNull();
resp.ConsumerInfo!.Config.DurableName.ShouldNotBeNullOrEmpty();
}
// ---------------------------------------------------------------
// Go: TestJetStreamClusterEphemeralConsumersNotReplicated server/jetstream_cluster_1_test.go:2599
// ---------------------------------------------------------------
[Fact]
public async Task Multiple_ephemeral_consumers_have_unique_names()
{
await using var fx = await ClusterConsumerFixture.StartAsync(nodes: 3);
await fx.CreateStreamAsync("EPHUNIQ", ["eu.>"], replicas: 3);
var resp1 = await fx.CreateConsumerAsync("EPHUNIQ", null, ephemeral: true);
var resp2 = await fx.CreateConsumerAsync("EPHUNIQ", null, ephemeral: true);
resp1.ConsumerInfo!.Config.DurableName.ShouldNotBe(resp2.ConsumerInfo!.Config.DurableName);
}
// ---------------------------------------------------------------
// Go: TestJetStreamClusterCreateConcurrentDurableConsumers server/jetstream_cluster_2_test.go:1572
// ---------------------------------------------------------------
[Fact]
public async Task Concurrent_durable_consumer_creation_is_idempotent()
{
await using var fx = await ClusterConsumerFixture.StartAsync(nodes: 3);
await fx.CreateStreamAsync("CONC", ["conc.>"], replicas: 3);
// Create same consumer twice; both should succeed
var resp1 = await fx.CreateConsumerAsync("CONC", "same");
var resp2 = await fx.CreateConsumerAsync("CONC", "same");
resp1.ConsumerInfo.ShouldNotBeNull();
resp2.ConsumerInfo.ShouldNotBeNull();
resp1.ConsumerInfo!.Config.DurableName.ShouldBe("same");
resp2.ConsumerInfo!.Config.DurableName.ShouldBe("same");
}
// ---------------------------------------------------------------
// Go: TestJetStreamClusterPullConsumerLeakedSubs server/jetstream_cluster_2_test.go:2239
// ---------------------------------------------------------------
[Fact]
public async Task Pull_consumer_fetch_returns_correct_batch_size()
{
await using var fx = await ClusterConsumerFixture.StartAsync(nodes: 3);
await fx.CreateStreamAsync("PULLBS", ["pb.>"], replicas: 3);
await fx.CreateConsumerAsync("PULLBS", "puller", filterSubject: "pb.>", ackPolicy: AckPolicy.None);
for (var i = 0; i < 20; i++)
await fx.PublishAsync("pb.event", $"msg-{i}");
var batch = await fx.FetchAsync("PULLBS", "puller", 5);
batch.Messages.Count.ShouldBe(5);
}
// ---------------------------------------------------------------
// Go: TestJetStreamClusterConsumerLastActiveReporting server/jetstream_cluster_2_test.go:2371
// ---------------------------------------------------------------
[Fact]
public async Task Consumer_info_returns_config_after_creation()
{
await using var fx = await ClusterConsumerFixture.StartAsync(nodes: 3);
await fx.CreateStreamAsync("CINFO", ["ci.>"], replicas: 3);
await fx.CreateConsumerAsync("CINFO", "info_dur", filterSubject: "ci.>", ackPolicy: AckPolicy.Explicit);
var info = await fx.GetConsumerInfoAsync("CINFO", "info_dur");
info.ShouldNotBeNull();
info.Config.DurableName.ShouldBe("info_dur");
info.Config.AckPolicy.ShouldBe(AckPolicy.Explicit);
}
// ---------------------------------------------------------------
// Go: TestJetStreamClusterAckPendingWithExpired server/jetstream_cluster_2_test.go:309
// ---------------------------------------------------------------
[Fact]
public async Task Ack_pending_tracks_expired_messages()
{
await using var fx = await ClusterConsumerFixture.StartAsync(nodes: 3);
await fx.CreateStreamAsync("ACKEXP", ["ae.>"], replicas: 3);
await fx.CreateConsumerAsync("ACKEXP", "acker", filterSubject: "ae.>",
ackPolicy: AckPolicy.Explicit, ackWaitMs: 1, maxDeliver: 10);
await fx.PublishAsync("ae.event", "will-expire");
// Fetch to register pending
var batch1 = await fx.FetchAsync("ACKEXP", "acker", 1);
batch1.Messages.Count.ShouldBe(1);
fx.GetPendingCount("ACKEXP", "acker").ShouldBe(1);
}
// ---------------------------------------------------------------
// Go: TestJetStreamClusterAckPendingWithMaxRedelivered server/jetstream_cluster_2_test.go:377
// ---------------------------------------------------------------
[Fact]
public async Task Max_deliver_limits_redelivery_attempts()
{
await using var fx = await ClusterConsumerFixture.StartAsync(nodes: 3);
await fx.CreateStreamAsync("MAXRED", ["mr.>"], replicas: 3);
// maxDeliver=2: allows initial delivery (deliveries=1) + one redelivery (deliveries=2).
// After ScheduleRedelivery increments to deliveries=2, the next check has deliveries=2 > maxDeliver=2 = false,
// so it redelivers once more. Only at deliveries=3 > 2 does it stop.
await fx.CreateConsumerAsync("MAXRED", "maxr", filterSubject: "mr.>",
ackPolicy: AckPolicy.Explicit, ackWaitMs: 1, maxDeliver: 2);
await fx.PublishAsync("mr.event", "limited-redeliver");
// First fetch (initial delivery, Register sets deliveries=1)
var batch1 = await fx.FetchAsync("MAXRED", "maxr", 1);
batch1.Messages.Count.ShouldBe(1);
// Wait for expiry
await Task.Delay(50);
// Second fetch: TryGetExpired returns deliveries=1, 1 > 2 is false, so redeliver.
// ScheduleRedelivery increments to deliveries=2.
var batch2 = await fx.FetchAsync("MAXRED", "maxr", 1);
batch2.Messages.Count.ShouldBe(1);
batch2.Messages[0].Redelivered.ShouldBeTrue();
// Wait for expiry
await Task.Delay(50);
// Third fetch: TryGetExpired returns deliveries=2, 2 > 2 is false, so redeliver again.
// ScheduleRedelivery increments to deliveries=3.
var batch3 = await fx.FetchAsync("MAXRED", "maxr", 1);
batch3.Messages.Count.ShouldBe(1);
batch3.Messages[0].Redelivered.ShouldBeTrue();
// Wait for expiry
await Task.Delay(50);
// Fourth fetch: TryGetExpired returns deliveries=3, 3 > 2 is true, so AckAll triggers
// and returns empty batch (max deliver exceeded).
var batch4 = await fx.FetchAsync("MAXRED", "maxr", 1);
batch4.Messages.Count.ShouldBe(0);
}
// ---------------------------------------------------------------
// Go: TestJetStreamClusterMaxConsumers server/jetstream_cluster_2_test.go:1978
// ---------------------------------------------------------------
[Fact]
public async Task Consumer_delete_succeeds_in_cluster()
{
await using var fx = await ClusterConsumerFixture.StartAsync(nodes: 3);
await fx.CreateStreamAsync("CDEL", ["cdel.>"], replicas: 3);
await fx.CreateConsumerAsync("CDEL", "to_delete");
var del = await fx.RequestAsync($"{JetStreamApiSubjects.ConsumerDelete}CDEL.to_delete", "{}");
del.Success.ShouldBeTrue();
var info = await fx.RequestAsync($"{JetStreamApiSubjects.ConsumerInfo}CDEL.to_delete", "{}");
info.Error.ShouldNotBeNull();
}
// ---------------------------------------------------------------
// Go: TestJetStreamClusterFlowControlRequiresHeartbeats server/jetstream_cluster_2_test.go:2712
// ---------------------------------------------------------------
[Fact]
public async Task Consumer_with_filter_subjects_delivers_matching_only()
{
await using var fx = await ClusterConsumerFixture.StartAsync(nodes: 3);
await fx.CreateStreamAsync("FILT", ["filt.>"], replicas: 3);
await fx.CreateConsumerAsync("FILT", "filtered", filterSubject: "filt.alpha");
await fx.PublishAsync("filt.alpha", "match");
await fx.PublishAsync("filt.beta", "no-match");
await fx.PublishAsync("filt.alpha", "match2");
var batch = await fx.FetchAsync("FILT", "filtered", 10);
batch.Messages.Count.ShouldBe(2);
batch.Messages[0].Subject.ShouldBe("filt.alpha");
batch.Messages[1].Subject.ShouldBe("filt.alpha");
}
// ---------------------------------------------------------------
// Go: TestJetStreamClusterConsumerScaleUp server/jetstream_cluster_1_test.go:4203
// ---------------------------------------------------------------
[Fact]
public async Task Consumer_pause_and_resume_via_api()
{
await using var fx = await ClusterConsumerFixture.StartAsync(nodes: 3);
await fx.CreateStreamAsync("PAUSE", ["pause.>"], replicas: 3);
await fx.CreateConsumerAsync("PAUSE", "pausable");
var pauseResp = await fx.RequestAsync($"{JetStreamApiSubjects.ConsumerPause}PAUSE.pausable", """{"pause":true}""");
pauseResp.Success.ShouldBeTrue();
var resumeResp = await fx.RequestAsync($"{JetStreamApiSubjects.ConsumerPause}PAUSE.pausable", """{"pause":false}""");
resumeResp.Success.ShouldBeTrue();
}
// ---------------------------------------------------------------
// Go: TestJetStreamClusterConsumerResetPendingDeliveriesOnMaxAckPendingUpdate
// server/jetstream_cluster_1_test.go:8696
// ---------------------------------------------------------------
[Fact]
public async Task Consumer_reset_resets_next_sequence_and_returns_success()
{
await using var fx = await ClusterConsumerFixture.StartAsync(nodes: 3);
await fx.CreateStreamAsync("RESET", ["reset.>"], replicas: 3);
await fx.CreateConsumerAsync("RESET", "resettable", filterSubject: "reset.>");
for (var i = 0; i < 5; i++)
await fx.PublishAsync("reset.event", $"msg-{i}");
// Fetch some messages to advance the consumer
var batch1 = await fx.FetchAsync("RESET", "resettable", 3);
batch1.Messages.Count.ShouldBe(3);
// Reset via API
var resetResp = await fx.RequestAsync($"{JetStreamApiSubjects.ConsumerReset}RESET.resettable", "{}");
resetResp.Success.ShouldBeTrue();
// After reset, consumer should re-deliver from sequence 1
var batch2 = await fx.FetchAsync("RESET", "resettable", 5);
batch2.Messages.Count.ShouldBe(5);
batch2.Messages[0].Sequence.ShouldBe(1UL);
}
// ---------------------------------------------------------------
// Go: TestJetStreamClusterPushConsumerQueueGroup server/jetstream_cluster_2_test.go:2300
// ---------------------------------------------------------------
[Fact]
public async Task Push_consumer_creation_with_heartbeat()
{
await using var fx = await ClusterConsumerFixture.StartAsync(nodes: 3);
await fx.CreateStreamAsync("PUSHHB", ["ph.>"], replicas: 3);
var resp = await fx.CreateConsumerAsync("PUSHHB", "pusher", push: true, heartbeatMs: 100);
resp.ConsumerInfo.ShouldNotBeNull();
resp.ConsumerInfo!.Config.Push.ShouldBeTrue();
resp.ConsumerInfo.Config.HeartbeatMs.ShouldBe(100);
}
// ---------------------------------------------------------------
// Go: TestJetStreamClusterScaleConsumer server/jetstream_cluster_1_test.go:4109
// ---------------------------------------------------------------
[Fact]
public async Task Consumer_unpin_via_api()
{
await using var fx = await ClusterConsumerFixture.StartAsync(nodes: 3);
await fx.CreateStreamAsync("UNPIN", ["unpin.>"], replicas: 3);
await fx.CreateConsumerAsync("UNPIN", "pinned");
var resp = await fx.RequestAsync($"{JetStreamApiSubjects.ConsumerUnpin}UNPIN.pinned", "{}");
resp.Success.ShouldBeTrue();
}
// ---------------------------------------------------------------
// Additional: Consumer AckAll policy acks all up to given sequence
// ---------------------------------------------------------------
[Fact]
public async Task AckAll_policy_consumer_acks_all_preceding_messages()
{
await using var fx = await ClusterConsumerFixture.StartAsync(nodes: 3);
await fx.CreateStreamAsync("ACKALL", ["aa.>"], replicas: 3);
await fx.CreateConsumerAsync("ACKALL", "acker", filterSubject: "aa.>", ackPolicy: AckPolicy.All);
for (var i = 0; i < 10; i++)
await fx.PublishAsync("aa.event", $"msg-{i}");
var batch = await fx.FetchAsync("ACKALL", "acker", 10);
batch.Messages.Count.ShouldBe(10);
// Ack up to seq 7 (all 1-7 should be acked, 8-10 remain pending)
fx.AckAll("ACKALL", "acker", 7);
fx.GetPendingCount("ACKALL", "acker").ShouldBe(3);
}
// ---------------------------------------------------------------
// Additional: DeliverPolicy.Last consumer starts at last message
// ---------------------------------------------------------------
[Fact]
public async Task DeliverPolicy_Last_consumer_starts_at_last_sequence()
{
await using var fx = await ClusterConsumerFixture.StartAsync(nodes: 3);
await fx.CreateStreamAsync("DLAST", ["dl.>"], replicas: 3);
for (var i = 0; i < 5; i++)
await fx.PublishAsync("dl.event", $"msg-{i}");
await fx.CreateConsumerAsync("DLAST", "last_cons", filterSubject: "dl.>",
deliverPolicy: DeliverPolicy.Last);
var batch = await fx.FetchAsync("DLAST", "last_cons", 10);
batch.Messages.Count.ShouldBe(1);
batch.Messages[0].Sequence.ShouldBe(5UL);
}
// ---------------------------------------------------------------
// Additional: DeliverPolicy.New consumer skips existing messages
// ---------------------------------------------------------------
[Fact]
public async Task DeliverPolicy_New_consumer_skips_existing()
{
await using var fx = await ClusterConsumerFixture.StartAsync(nodes: 3);
await fx.CreateStreamAsync("DNEW", ["dn.>"], replicas: 3);
for (var i = 0; i < 5; i++)
await fx.PublishAsync("dn.event", $"msg-{i}");
await fx.CreateConsumerAsync("DNEW", "new_cons", filterSubject: "dn.>",
deliverPolicy: DeliverPolicy.New);
// Should get no messages since consumer starts at LastSeq+1
var batch = await fx.FetchAsync("DNEW", "new_cons", 10);
batch.Messages.Count.ShouldBe(0);
// Publish a new message after consumer creation
await fx.PublishAsync("dn.event", "after-consumer");
var batch2 = await fx.FetchAsync("DNEW", "new_cons", 10);
batch2.Messages.Count.ShouldBe(1);
}
// ---------------------------------------------------------------
// Additional: DeliverPolicy.ByStartSequence
// ---------------------------------------------------------------
[Fact]
public async Task DeliverPolicy_ByStartSequence_starts_at_given_sequence()
{
await using var fx = await ClusterConsumerFixture.StartAsync(nodes: 3);
await fx.CreateStreamAsync("DSTART", ["ds.>"], replicas: 3);
for (var i = 0; i < 10; i++)
await fx.PublishAsync("ds.event", $"msg-{i}");
await fx.CreateConsumerAsync("DSTART", "start_cons", filterSubject: "ds.>",
deliverPolicy: DeliverPolicy.ByStartSequence, optStartSeq: 7);
var batch = await fx.FetchAsync("DSTART", "start_cons", 10);
batch.Messages.Count.ShouldBe(4); // seq 7, 8, 9, 10
batch.Messages[0].Sequence.ShouldBe(7UL);
}
// ---------------------------------------------------------------
// Additional: Multiple filter subjects
// ---------------------------------------------------------------
[Fact]
public async Task Consumer_with_multiple_filter_subjects()
{
await using var fx = await ClusterConsumerFixture.StartAsync(nodes: 3);
await fx.CreateStreamAsync("MFILT", ["mf.>"], replicas: 3);
await fx.CreateConsumerAsync("MFILT", "multi_filt",
filterSubjects: ["mf.alpha", "mf.gamma"]);
await fx.PublishAsync("mf.alpha", "a");
await fx.PublishAsync("mf.beta", "b");
await fx.PublishAsync("mf.gamma", "g");
await fx.PublishAsync("mf.delta", "d");
var batch = await fx.FetchAsync("MFILT", "multi_filt", 10);
batch.Messages.Count.ShouldBe(2);
}
// ---------------------------------------------------------------
// Additional: NoWait fetch returns empty when no messages
// ---------------------------------------------------------------
[Fact]
public async Task NoWait_fetch_returns_empty_when_no_pending()
{
await using var fx = await ClusterConsumerFixture.StartAsync(nodes: 3);
await fx.CreateStreamAsync("NOWAIT", ["nw.>"], replicas: 3);
await fx.CreateConsumerAsync("NOWAIT", "nw_cons", filterSubject: "nw.>");
var batch = await fx.FetchNoWaitAsync("NOWAIT", "nw_cons", 5);
batch.Messages.Count.ShouldBe(0);
}
}
/// <summary>
/// Self-contained fixture for JetStream cluster consumer tests.
/// </summary>
internal sealed class ClusterConsumerFixture : IAsyncDisposable
{
private readonly JetStreamMetaGroup _metaGroup;
private readonly StreamManager _streamManager;
private readonly ConsumerManager _consumerManager;
private readonly JetStreamApiRouter _router;
private readonly JetStreamPublisher _publisher;
private ClusterConsumerFixture(
JetStreamMetaGroup metaGroup,
StreamManager streamManager,
ConsumerManager consumerManager,
JetStreamApiRouter router,
JetStreamPublisher publisher)
{
_metaGroup = metaGroup;
_streamManager = streamManager;
_consumerManager = consumerManager;
_router = router;
_publisher = publisher;
}
public static Task<ClusterConsumerFixture> StartAsync(int nodes)
{
var meta = new JetStreamMetaGroup(nodes);
var consumerManager = new ConsumerManager(meta);
var streamManager = new StreamManager(meta, consumerManager: consumerManager);
var router = new JetStreamApiRouter(streamManager, consumerManager, meta);
var publisher = new JetStreamPublisher(streamManager);
return Task.FromResult(new ClusterConsumerFixture(meta, streamManager, consumerManager, router, publisher));
}
public Task CreateStreamAsync(string name, string[] subjects, int replicas)
{
var response = _streamManager.CreateOrUpdate(new StreamConfig
{
Name = name,
Subjects = [.. subjects],
Replicas = replicas,
});
if (response.Error is not null)
throw new InvalidOperationException(response.Error.Description);
return Task.CompletedTask;
}
public Task<JetStreamApiResponse> CreateConsumerAsync(
string stream,
string? durableName,
string? filterSubject = null,
AckPolicy ackPolicy = AckPolicy.None,
int ackWaitMs = 30_000,
int maxDeliver = 1,
bool ephemeral = false,
bool push = false,
int heartbeatMs = 0,
DeliverPolicy deliverPolicy = DeliverPolicy.All,
ulong optStartSeq = 0,
IReadOnlyList<string>? filterSubjects = null)
{
var config = new ConsumerConfig
{
DurableName = durableName ?? string.Empty,
AckPolicy = ackPolicy,
AckWaitMs = ackWaitMs,
MaxDeliver = maxDeliver,
Ephemeral = ephemeral,
Push = push,
HeartbeatMs = heartbeatMs,
DeliverPolicy = deliverPolicy,
OptStartSeq = optStartSeq,
};
if (!string.IsNullOrWhiteSpace(filterSubject))
config.FilterSubject = filterSubject;
if (filterSubjects is { Count: > 0 })
config.FilterSubjects = [.. filterSubjects];
return Task.FromResult(_consumerManager.CreateOrUpdate(stream, config));
}
public Task<PubAck> PublishAsync(string subject, string payload)
{
if (_publisher.TryCapture(subject, Encoding.UTF8.GetBytes(payload), null, out var ack))
{
if (ack.ErrorCode == null && _streamManager.TryGet(ack.Stream, out var handle))
{
var stored = handle.Store.LoadAsync(ack.Seq, default).GetAwaiter().GetResult();
if (stored != null)
_consumerManager.OnPublished(ack.Stream, stored);
}
return Task.FromResult(ack);
}
throw new InvalidOperationException($"Publish to '{subject}' did not match a stream.");
}
public Task<PullFetchBatch> FetchAsync(string stream, string durableName, int batch)
=> _consumerManager.FetchAsync(stream, durableName, batch, _streamManager, default).AsTask();
public Task<PullFetchBatch> FetchNoWaitAsync(string stream, string durableName, int batch)
=> _consumerManager.FetchAsync(stream, durableName, new PullFetchRequest
{
Batch = batch,
NoWait = true,
}, _streamManager, default).AsTask();
public void AckAll(string stream, string durableName, ulong sequence)
=> _consumerManager.AckAll(stream, durableName, sequence);
public int GetPendingCount(string stream, string durableName)
=> _consumerManager.GetPendingCount(stream, durableName);
public Task<JetStreamConsumerInfo> GetConsumerInfoAsync(string stream, string durableName)
{
var resp = _consumerManager.GetInfo(stream, durableName);
if (resp.ConsumerInfo == null)
throw new InvalidOperationException("Consumer not found.");
return Task.FromResult(resp.ConsumerInfo);
}
public Task<JetStreamApiResponse> RequestAsync(string subject, string payload)
=> Task.FromResult(_router.Route(subject, Encoding.UTF8.GetBytes(payload)));
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}

View File

@@ -0,0 +1,525 @@
// Go parity: golang/nats-server/server/jetstream_cluster_1_test.go
// Covers: stream leader stepdown, consumer leader stepdown,
// meta leader stepdown, peer removal, node loss recovery,
// snapshot catchup, consumer failover, data preservation.
using System.Reflection;
using System.Collections.Concurrent;
using System.Text;
using NATS.Server.JetStream;
using NATS.Server.JetStream.Api;
using NATS.Server.JetStream.Cluster;
using NATS.Server.JetStream.Consumers;
using NATS.Server.JetStream.Models;
using NATS.Server.JetStream.Publish;
namespace NATS.Server.Tests.JetStream.Cluster;
/// <summary>
/// Tests covering JetStream cluster failover scenarios: leader stepdown,
/// peer removal, node loss/recovery, snapshot catchup, and consumer failover.
/// Ported from Go jetstream_cluster_1_test.go.
/// </summary>
public class JetStreamClusterFailoverTests
{
// ---------------------------------------------------------------
// Go: TestJetStreamClusterStreamLeaderStepDown server/jetstream_cluster_1_test.go:4925
// ---------------------------------------------------------------
[Fact]
public async Task Stream_leader_stepdown_elects_new_leader_and_preserves_data()
{
await using var fx = await ClusterFailoverFixture.StartAsync(nodes: 3);
await fx.CreateStreamAsync("STEPDOWN", ["sd.>"], replicas: 3);
for (var i = 1; i <= 10; i++)
(await fx.PublishAsync($"sd.{i}", $"msg-{i}")).Seq.ShouldBe((ulong)i);
var leaderBefore = fx.GetStreamLeaderId("STEPDOWN");
leaderBefore.ShouldNotBeNullOrWhiteSpace();
var resp = await fx.StepDownStreamLeaderAsync("STEPDOWN");
resp.Success.ShouldBeTrue();
var leaderAfter = fx.GetStreamLeaderId("STEPDOWN");
leaderAfter.ShouldNotBe(leaderBefore);
var state = await fx.GetStreamStateAsync("STEPDOWN");
state.Messages.ShouldBe(10UL);
state.FirstSeq.ShouldBe(1UL);
state.LastSeq.ShouldBe(10UL);
// New leader accepts writes
var ack = await fx.PublishAsync("sd.post", "after-stepdown");
ack.Seq.ShouldBe(11UL);
}
// ---------------------------------------------------------------
// Go: TestJetStreamClusterLeaderStepdown server/jetstream_cluster_1_test.go:5464
// ---------------------------------------------------------------
[Fact]
public async Task Meta_leader_stepdown_increments_version_and_preserves_streams()
{
await using var fx = await ClusterFailoverFixture.StartAsync(nodes: 3);
await fx.CreateStreamAsync("META_SD", ["meta.>"], replicas: 3);
var before = fx.GetMetaState();
before.ClusterSize.ShouldBe(3);
var leaderBefore = before.LeaderId;
var versionBefore = before.LeadershipVersion;
var resp = await fx.RequestAsync(JetStreamApiSubjects.MetaLeaderStepdown, "{}");
resp.Success.ShouldBeTrue();
var after = fx.GetMetaState();
after.LeaderId.ShouldNotBe(leaderBefore);
after.LeadershipVersion.ShouldBe(versionBefore + 1);
after.Streams.ShouldContain("META_SD");
}
// ---------------------------------------------------------------
// Go: TestJetStreamClusterLeader server/jetstream_cluster_1_test.go:73
// ---------------------------------------------------------------
[Fact]
public async Task Consecutive_stepdowns_cycle_through_distinct_leaders()
{
await using var fx = await ClusterFailoverFixture.StartAsync(nodes: 3);
await fx.CreateStreamAsync("CYCLE", ["cyc.>"], replicas: 3);
var leaders = new List<string> { fx.GetStreamLeaderId("CYCLE") };
(await fx.StepDownStreamLeaderAsync("CYCLE")).Success.ShouldBeTrue();
leaders.Add(fx.GetStreamLeaderId("CYCLE"));
(await fx.StepDownStreamLeaderAsync("CYCLE")).Success.ShouldBeTrue();
leaders.Add(fx.GetStreamLeaderId("CYCLE"));
leaders[1].ShouldNotBe(leaders[0]);
leaders[2].ShouldNotBe(leaders[1]);
var ack = await fx.PublishAsync("cyc.verify", "alive");
ack.Stream.ShouldBe("CYCLE");
ack.Seq.ShouldBeGreaterThan(0UL);
}
// ---------------------------------------------------------------
// Go: TestJetStreamClusterPeerRemovalAPI server/jetstream_cluster_1_test.go:3469
// ---------------------------------------------------------------
[Fact]
public async Task Peer_removal_api_returns_success()
{
await using var fx = await ClusterFailoverFixture.StartAsync(nodes: 3);
await fx.CreateStreamAsync("PEERREM", ["pr.>"], replicas: 3);
var resp = await fx.RequestAsync($"{JetStreamApiSubjects.StreamPeerRemove}PEERREM", """{"peer":"n2"}""");
resp.Success.ShouldBeTrue();
}
// ---------------------------------------------------------------
// Go: TestJetStreamClusterPeerRemovalAndStreamReassignment server/jetstream_cluster_1_test.go:3544
// ---------------------------------------------------------------
[Fact]
public async Task Peer_removal_preserves_stream_data()
{
await using var fx = await ClusterFailoverFixture.StartAsync(nodes: 3);
await fx.CreateStreamAsync("REASSIGN", ["ra.>"], replicas: 3);
for (var i = 0; i < 5; i++)
await fx.PublishAsync("ra.event", $"msg-{i}");
(await fx.RequestAsync($"{JetStreamApiSubjects.StreamPeerRemove}REASSIGN", """{"peer":"n2"}""")).Success.ShouldBeTrue();
var state = await fx.GetStreamStateAsync("REASSIGN");
state.Messages.ShouldBe(5UL);
}
// ---------------------------------------------------------------
// Go: TestJetStreamClusterConsumerLeaderStepdown (consumer stepdown)
// ---------------------------------------------------------------
[Fact]
public async Task Consumer_leader_stepdown_api_returns_success()
{
await using var fx = await ClusterFailoverFixture.StartAsync(nodes: 3);
await fx.CreateStreamAsync("CLSD", ["clsd.>"], replicas: 3);
await fx.CreateConsumerAsync("CLSD", "dur1");
var resp = await fx.RequestAsync($"{JetStreamApiSubjects.ConsumerLeaderStepdown}CLSD.dur1", "{}");
resp.Success.ShouldBeTrue();
}
// ---------------------------------------------------------------
// Go: TestJetStreamClusterStreamNormalCatchup server/jetstream_cluster_1_test.go:1607
// ---------------------------------------------------------------
[Fact]
public async Task Stream_publishes_survive_leader_stepdown_and_catchup()
{
await using var fx = await ClusterFailoverFixture.StartAsync(nodes: 3);
await fx.CreateStreamAsync("CATCHUP", ["cu.>"], replicas: 3);
// Publish some messages
for (var i = 0; i < 10; i++)
await fx.PublishAsync("cu.event", $"before-{i}");
// Step down the leader
(await fx.StepDownStreamLeaderAsync("CATCHUP")).Success.ShouldBeTrue();
// Publish more messages after stepdown
for (var i = 0; i < 10; i++)
await fx.PublishAsync("cu.event", $"after-{i}");
var state = await fx.GetStreamStateAsync("CATCHUP");
state.Messages.ShouldBe(20UL);
state.LastSeq.ShouldBe(20UL);
}
// ---------------------------------------------------------------
// Go: TestJetStreamClusterStreamSnapshotCatchup server/jetstream_cluster_1_test.go:1667
// ---------------------------------------------------------------
[Fact]
public async Task Snapshot_and_restore_survives_leader_transition()
{
await using var fx = await ClusterFailoverFixture.StartAsync(nodes: 3);
await fx.CreateStreamAsync("SNAPCAT", ["sc.>"], replicas: 3);
for (var i = 0; i < 10; i++)
await fx.PublishAsync("sc.event", $"msg-{i}");
// Take snapshot
var snapshot = await fx.RequestAsync($"{JetStreamApiSubjects.StreamSnapshot}SNAPCAT", "{}");
snapshot.Snapshot.ShouldNotBeNull();
// Step down leader
(await fx.StepDownStreamLeaderAsync("SNAPCAT")).Success.ShouldBeTrue();
// Purge and restore
(await fx.RequestAsync($"{JetStreamApiSubjects.StreamPurge}SNAPCAT", "{}")).Success.ShouldBeTrue();
(await fx.RequestAsync($"{JetStreamApiSubjects.StreamRestore}SNAPCAT", snapshot.Snapshot!.Payload)).Success.ShouldBeTrue();
var state = await fx.GetStreamStateAsync("SNAPCAT");
state.Messages.ShouldBe(10UL);
}
// ---------------------------------------------------------------
// Go: TestJetStreamClusterStreamSnapshotCatchupWithPurge server/jetstream_cluster_1_test.go:1822
// ---------------------------------------------------------------
[Fact]
public async Task Snapshot_restore_after_purge_preserves_original_data()
{
await using var fx = await ClusterFailoverFixture.StartAsync(nodes: 3);
await fx.CreateStreamAsync("PURGECAT", ["pc.>"], replicas: 3);
for (var i = 0; i < 20; i++)
await fx.PublishAsync("pc.event", $"msg-{i}");
var snapshot = await fx.RequestAsync($"{JetStreamApiSubjects.StreamSnapshot}PURGECAT", "{}");
// Purge the stream
(await fx.RequestAsync($"{JetStreamApiSubjects.StreamPurge}PURGECAT", "{}")).Success.ShouldBeTrue();
var afterPurge = await fx.GetStreamStateAsync("PURGECAT");
afterPurge.Messages.ShouldBe(0UL);
// Restore from snapshot
(await fx.RequestAsync($"{JetStreamApiSubjects.StreamRestore}PURGECAT", snapshot.Snapshot!.Payload)).Success.ShouldBeTrue();
var restored = await fx.GetStreamStateAsync("PURGECAT");
restored.Messages.ShouldBe(20UL);
}
// ---------------------------------------------------------------
// Go: TestJetStreamClusterMetaSnapshotsAndCatchup server/jetstream_cluster_1_test.go:833
// ---------------------------------------------------------------
[Fact]
public async Task Meta_state_survives_multiple_stepdowns()
{
await using var fx = await ClusterFailoverFixture.StartAsync(nodes: 3);
await fx.CreateStreamAsync("META1", ["m1.>"], replicas: 3);
await fx.CreateStreamAsync("META2", ["m2.>"], replicas: 3);
// Step down meta leader twice
(await fx.RequestAsync(JetStreamApiSubjects.MetaLeaderStepdown, "{}")).Success.ShouldBeTrue();
(await fx.RequestAsync(JetStreamApiSubjects.MetaLeaderStepdown, "{}")).Success.ShouldBeTrue();
var state = fx.GetMetaState();
state.Streams.ShouldContain("META1");
state.Streams.ShouldContain("META2");
}
// ---------------------------------------------------------------
// Go: TestJetStreamClusterMetaSnapshotsMultiChange server/jetstream_cluster_1_test.go:881
// ---------------------------------------------------------------
[Fact]
public async Task Stream_delete_and_create_across_stepdowns_reflected_in_stream_names()
{
await using var fx = await ClusterFailoverFixture.StartAsync(nodes: 3);
await fx.CreateStreamAsync("MULTI1", ["mul1.>"], replicas: 3);
await fx.CreateStreamAsync("MULTI2", ["mul2.>"], replicas: 3);
// Delete one stream
(await fx.RequestAsync($"{JetStreamApiSubjects.StreamDelete}MULTI1", "{}")).Success.ShouldBeTrue();
// Step down meta leader
(await fx.RequestAsync(JetStreamApiSubjects.MetaLeaderStepdown, "{}")).Success.ShouldBeTrue();
// Create another stream
await fx.CreateStreamAsync("MULTI3", ["mul3.>"], replicas: 3);
// Verify via stream names API (reflects actual active streams)
var names = await fx.RequestAsync(JetStreamApiSubjects.StreamNames, "{}");
names.StreamNames.ShouldNotBeNull();
names.StreamNames!.ShouldNotContain("MULTI1");
names.StreamNames.ShouldContain("MULTI2");
names.StreamNames.ShouldContain("MULTI3");
}
// ---------------------------------------------------------------
// Go: TestJetStreamClusterDeleteMsgAndRestart server/jetstream_cluster_1_test.go:1785
// ---------------------------------------------------------------
[Fact]
public async Task Delete_message_survives_leader_stepdown()
{
await using var fx = await ClusterFailoverFixture.StartAsync(nodes: 3);
await fx.CreateStreamAsync("DELMSGSD", ["dms.>"], replicas: 3);
for (var i = 0; i < 5; i++)
await fx.PublishAsync("dms.event", $"msg-{i}");
(await fx.RequestAsync($"{JetStreamApiSubjects.StreamMessageDelete}DELMSGSD", """{"seq":3}""")).Success.ShouldBeTrue();
(await fx.StepDownStreamLeaderAsync("DELMSGSD")).Success.ShouldBeTrue();
var state = await fx.GetStreamStateAsync("DELMSGSD");
state.Messages.ShouldBe(4UL);
}
// ---------------------------------------------------------------
// Go: TestJetStreamClusterRestoreSingleConsumer server/jetstream_cluster_1_test.go:1028
// ---------------------------------------------------------------
[Fact]
public async Task Consumer_survives_stream_leader_stepdown()
{
await using var fx = await ClusterFailoverFixture.StartAsync(nodes: 3);
await fx.CreateStreamAsync("CSURV", ["csv.>"], replicas: 3);
await fx.CreateConsumerAsync("CSURV", "durable1", filterSubject: "csv.>");
for (var i = 0; i < 10; i++)
await fx.PublishAsync("csv.event", $"msg-{i}");
// Fetch before stepdown
var batch1 = await fx.FetchAsync("CSURV", "durable1", 5);
batch1.Messages.Count.ShouldBe(5);
// Step down stream leader
(await fx.StepDownStreamLeaderAsync("CSURV")).Success.ShouldBeTrue();
// Consumer should still be fetchable
var batch2 = await fx.FetchAsync("CSURV", "durable1", 5);
batch2.Messages.Count.ShouldBe(5);
}
// ---------------------------------------------------------------
// Additional: Multiple stepdowns do not lose accumulated state
// ---------------------------------------------------------------
[Fact]
public async Task Multiple_stepdowns_preserve_accumulated_messages()
{
await using var fx = await ClusterFailoverFixture.StartAsync(nodes: 3);
await fx.CreateStreamAsync("ACCUM", ["acc.>"], replicas: 3);
for (var i = 0; i < 5; i++)
await fx.PublishAsync("acc.event", $"batch1-{i}");
(await fx.StepDownStreamLeaderAsync("ACCUM")).Success.ShouldBeTrue();
for (var i = 0; i < 5; i++)
await fx.PublishAsync("acc.event", $"batch2-{i}");
(await fx.StepDownStreamLeaderAsync("ACCUM")).Success.ShouldBeTrue();
for (var i = 0; i < 5; i++)
await fx.PublishAsync("acc.event", $"batch3-{i}");
var state = await fx.GetStreamStateAsync("ACCUM");
state.Messages.ShouldBe(15UL);
state.LastSeq.ShouldBe(15UL);
}
// ---------------------------------------------------------------
// Additional: Stream info available after leader stepdown
// ---------------------------------------------------------------
[Fact]
public async Task Stream_info_available_after_leader_stepdown()
{
await using var fx = await ClusterFailoverFixture.StartAsync(nodes: 3);
await fx.CreateStreamAsync("INFOSD", ["isd.>"], replicas: 3);
for (var i = 0; i < 3; i++)
await fx.PublishAsync("isd.event", $"msg-{i}");
(await fx.StepDownStreamLeaderAsync("INFOSD")).Success.ShouldBeTrue();
var info = await fx.GetStreamInfoAsync("INFOSD");
info.StreamInfo.ShouldNotBeNull();
info.StreamInfo!.Config.Name.ShouldBe("INFOSD");
info.StreamInfo.State.Messages.ShouldBe(3UL);
}
// ---------------------------------------------------------------
// Additional: Stepdown non-existent stream does not crash
// ---------------------------------------------------------------
[Fact]
public async Task Stepdown_non_existent_stream_returns_success_gracefully()
{
await using var fx = await ClusterFailoverFixture.StartAsync(nodes: 3);
// Stepping down a non-existent stream should not throw
var resp = await fx.StepDownStreamLeaderAsync("NONEXISTENT");
resp.Success.ShouldBeTrue();
}
// ---------------------------------------------------------------
// Additional: AccountPurge returns success
// ---------------------------------------------------------------
[Fact]
public async Task Account_purge_api_returns_success()
{
await using var fx = await ClusterFailoverFixture.StartAsync(nodes: 3);
await fx.CreateStreamAsync("PURGEACCT", ["pa.>"], replicas: 3);
var resp = await fx.RequestAsync($"{JetStreamApiSubjects.AccountPurge}GLOBAL", "{}");
resp.Success.ShouldBeTrue();
}
// ---------------------------------------------------------------
// Additional: Server remove returns success
// ---------------------------------------------------------------
[Fact]
public async Task Server_remove_api_returns_success()
{
await using var fx = await ClusterFailoverFixture.StartAsync(nodes: 3);
var resp = await fx.RequestAsync(JetStreamApiSubjects.ServerRemove, "{}");
resp.Success.ShouldBeTrue();
}
}
/// <summary>
/// Self-contained fixture for JetStream cluster failover tests.
/// </summary>
internal sealed class ClusterFailoverFixture : IAsyncDisposable
{
private readonly JetStreamMetaGroup _metaGroup;
private readonly StreamManager _streamManager;
private readonly ConsumerManager _consumerManager;
private readonly JetStreamApiRouter _router;
private readonly JetStreamPublisher _publisher;
private ClusterFailoverFixture(
JetStreamMetaGroup metaGroup,
StreamManager streamManager,
ConsumerManager consumerManager,
JetStreamApiRouter router,
JetStreamPublisher publisher)
{
_metaGroup = metaGroup;
_streamManager = streamManager;
_consumerManager = consumerManager;
_router = router;
_publisher = publisher;
}
public static Task<ClusterFailoverFixture> StartAsync(int nodes)
{
var meta = new JetStreamMetaGroup(nodes);
var consumerManager = new ConsumerManager(meta);
var streamManager = new StreamManager(meta, consumerManager: consumerManager);
var router = new JetStreamApiRouter(streamManager, consumerManager, meta);
var publisher = new JetStreamPublisher(streamManager);
return Task.FromResult(new ClusterFailoverFixture(meta, streamManager, consumerManager, router, publisher));
}
public Task CreateStreamAsync(string name, string[] subjects, int replicas)
{
var response = _streamManager.CreateOrUpdate(new StreamConfig
{
Name = name,
Subjects = [.. subjects],
Replicas = replicas,
});
if (response.Error is not null)
throw new InvalidOperationException(response.Error.Description);
return Task.CompletedTask;
}
public Task<JetStreamApiResponse> CreateConsumerAsync(string stream, string durableName, string? filterSubject = null)
{
var config = new ConsumerConfig { DurableName = durableName };
if (!string.IsNullOrWhiteSpace(filterSubject))
config.FilterSubject = filterSubject;
return Task.FromResult(_consumerManager.CreateOrUpdate(stream, config));
}
public Task<PubAck> PublishAsync(string subject, string payload)
{
if (_publisher.TryCapture(subject, Encoding.UTF8.GetBytes(payload), null, out var ack))
{
if (ack.ErrorCode == null && _streamManager.TryGet(ack.Stream, out var handle))
{
var stored = handle.Store.LoadAsync(ack.Seq, default).GetAwaiter().GetResult();
if (stored != null)
_consumerManager.OnPublished(ack.Stream, stored);
}
return Task.FromResult(ack);
}
throw new InvalidOperationException($"Publish to '{subject}' did not match a stream.");
}
public Task<JetStreamApiResponse> StepDownStreamLeaderAsync(string stream)
=> Task.FromResult(_router.Route(
$"{JetStreamApiSubjects.StreamLeaderStepdown}{stream}",
"{}"u8));
public string GetStreamLeaderId(string stream)
{
var field = typeof(StreamManager)
.GetField("_replicaGroups", BindingFlags.NonPublic | BindingFlags.Instance)!;
var groups = (ConcurrentDictionary<string, StreamReplicaGroup>)field.GetValue(_streamManager)!;
if (groups.TryGetValue(stream, out var group))
return group.Leader.Id;
return string.Empty;
}
public MetaGroupState GetMetaState() => _metaGroup.GetState();
public Task<ApiStreamState> GetStreamStateAsync(string name)
=> _streamManager.GetStateAsync(name, default).AsTask();
public Task<JetStreamApiResponse> GetStreamInfoAsync(string name)
=> Task.FromResult(_streamManager.GetInfo(name));
public Task<PullFetchBatch> FetchAsync(string stream, string durableName, int batch)
=> _consumerManager.FetchAsync(stream, durableName, batch, _streamManager, default).AsTask();
public Task<JetStreamApiResponse> RequestAsync(string subject, string payload)
=> Task.FromResult(_router.Route(subject, Encoding.UTF8.GetBytes(payload)));
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}

View File

@@ -0,0 +1,617 @@
// Go parity: golang/nats-server/server/jetstream_cluster_1_test.go
// Covers: cluster metadata operations, asset placement planner,
// replica group management, stream scaling, config validation,
// cluster expand, account info in cluster, max streams.
using System.Text;
using NATS.Server.Configuration;
using NATS.Server.JetStream;
using NATS.Server.JetStream.Api;
using NATS.Server.JetStream.Cluster;
using NATS.Server.JetStream.Models;
using NATS.Server.JetStream.Publish;
using NATS.Server.JetStream.Validation;
namespace NATS.Server.Tests.JetStream.Cluster;
/// <summary>
/// Tests covering JetStream cluster metadata operations: asset placement,
/// replica group management, config validation, scaling, and account operations.
/// Ported from Go jetstream_cluster_1_test.go.
/// </summary>
public class JetStreamClusterMetaTests
{
// ---------------------------------------------------------------
// Go: TestJetStreamClusterConfig server/jetstream_cluster_1_test.go:43
// ---------------------------------------------------------------
[Fact]
public void Config_requires_server_name_for_jetstream_cluster()
{
var options = new NatsOptions
{
ServerName = null,
JetStream = new JetStreamOptions { StoreDir = "/tmp/js" },
Cluster = new ClusterOptions { Port = 6222 },
};
var result = JetStreamConfigValidator.ValidateClusterConfig(options);
result.IsValid.ShouldBeFalse();
result.Message.ShouldContain("server_name");
}
[Fact]
public void Config_requires_cluster_name_for_jetstream_cluster()
{
var options = new NatsOptions
{
ServerName = "S1",
JetStream = new JetStreamOptions { StoreDir = "/tmp/js" },
Cluster = new ClusterOptions { Name = null, Port = 6222 },
};
var result = JetStreamConfigValidator.ValidateClusterConfig(options);
result.IsValid.ShouldBeFalse();
result.Message.ShouldContain("cluster.name");
}
[Fact]
public void Config_valid_when_server_and_cluster_names_set()
{
var options = new NatsOptions
{
ServerName = "S1",
JetStream = new JetStreamOptions { StoreDir = "/tmp/js" },
Cluster = new ClusterOptions { Name = "JSC", Port = 6222 },
};
var result = JetStreamConfigValidator.ValidateClusterConfig(options);
result.IsValid.ShouldBeTrue();
}
[Fact]
public void Config_skips_cluster_checks_when_no_cluster_configured()
{
var options = new NatsOptions
{
JetStream = new JetStreamOptions { StoreDir = "/tmp/js" },
};
var result = JetStreamConfigValidator.ValidateClusterConfig(options);
result.IsValid.ShouldBeTrue();
}
[Fact]
public void Config_skips_cluster_checks_when_no_jetstream_configured()
{
var options = new NatsOptions
{
Cluster = new ClusterOptions { Port = 6222 },
};
var result = JetStreamConfigValidator.ValidateClusterConfig(options);
result.IsValid.ShouldBeTrue();
}
// ---------------------------------------------------------------
// Placement planner tests
// ---------------------------------------------------------------
[Fact]
public void Placement_planner_returns_requested_replica_count()
{
var planner = new AssetPlacementPlanner(nodes: 5);
var placement = planner.PlanReplicas(replicas: 3);
placement.Count.ShouldBe(3);
}
[Fact]
public void Placement_planner_caps_at_cluster_size()
{
var planner = new AssetPlacementPlanner(nodes: 3);
var placement = planner.PlanReplicas(replicas: 5);
placement.Count.ShouldBe(3);
}
[Fact]
public void Placement_planner_minimum_is_one_replica()
{
var planner = new AssetPlacementPlanner(nodes: 3);
var placement = planner.PlanReplicas(replicas: 0);
placement.Count.ShouldBe(1);
}
[Fact]
public void Placement_planner_handles_single_node_cluster()
{
var planner = new AssetPlacementPlanner(nodes: 1);
var placement = planner.PlanReplicas(replicas: 3);
placement.Count.ShouldBe(1);
}
// ---------------------------------------------------------------
// Meta group lifecycle tests
// ---------------------------------------------------------------
[Fact]
public void Meta_group_initial_state_is_correct()
{
var meta = new JetStreamMetaGroup(3);
var state = meta.GetState();
state.ClusterSize.ShouldBe(3);
state.LeaderId.ShouldNotBeNullOrWhiteSpace();
state.LeadershipVersion.ShouldBe(1);
state.Streams.Count.ShouldBe(0);
}
[Fact]
public async Task Meta_group_tracks_stream_proposals()
{
var meta = new JetStreamMetaGroup(3);
await meta.ProposeCreateStreamAsync(new StreamConfig { Name = "S1" }, default);
await meta.ProposeCreateStreamAsync(new StreamConfig { Name = "S2" }, default);
var state = meta.GetState();
state.Streams.Count.ShouldBe(2);
state.Streams.ShouldContain("S1");
state.Streams.ShouldContain("S2");
}
[Fact]
public void Meta_group_stepdown_cycles_leader()
{
var meta = new JetStreamMetaGroup(3);
var leader1 = meta.GetState().LeaderId;
meta.StepDown();
var leader2 = meta.GetState().LeaderId;
leader2.ShouldNotBe(leader1);
meta.StepDown();
var leader3 = meta.GetState().LeaderId;
leader3.ShouldNotBe(leader2);
}
[Fact]
public void Meta_group_stepdown_wraps_around()
{
var meta = new JetStreamMetaGroup(2);
var leaders = new HashSet<string>();
for (var i = 0; i < 5; i++)
{
leaders.Add(meta.GetState().LeaderId);
meta.StepDown();
}
// Should cycle between 2 leaders
leaders.Count.ShouldBe(2);
}
[Fact]
public void Meta_group_leadership_version_increments()
{
var meta = new JetStreamMetaGroup(3);
meta.GetState().LeadershipVersion.ShouldBe(1);
meta.StepDown();
meta.GetState().LeadershipVersion.ShouldBe(2);
meta.StepDown();
meta.GetState().LeadershipVersion.ShouldBe(3);
}
// ---------------------------------------------------------------
// Replica group tests
// ---------------------------------------------------------------
[Fact]
public void Replica_group_creates_correct_node_count()
{
var group = new StreamReplicaGroup("TEST", replicas: 3);
group.Nodes.Count.ShouldBe(3);
group.StreamName.ShouldBe("TEST");
}
[Fact]
public void Replica_group_elects_initial_leader()
{
var group = new StreamReplicaGroup("TEST", replicas: 3);
group.Leader.ShouldNotBeNull();
group.Leader.IsLeader.ShouldBeTrue();
}
[Fact]
public async Task Replica_group_stepdown_changes_leader()
{
var group = new StreamReplicaGroup("TEST", replicas: 3);
var leaderBefore = group.Leader.Id;
await group.StepDownAsync(default);
var leaderAfter = group.Leader.Id;
leaderAfter.ShouldNotBe(leaderBefore);
group.Leader.IsLeader.ShouldBeTrue();
}
[Fact]
public async Task Replica_group_leader_accepts_proposals()
{
var group = new StreamReplicaGroup("TEST", replicas: 3);
var index = await group.ProposeAsync("PUB test.1", default);
index.ShouldBeGreaterThan(0);
}
[Fact]
public async Task Replica_group_apply_placement_scales_up()
{
var group = new StreamReplicaGroup("TEST", replicas: 1);
group.Nodes.Count.ShouldBe(1);
await group.ApplyPlacementAsync([1, 2, 3], default);
group.Nodes.Count.ShouldBe(3);
}
[Fact]
public async Task Replica_group_apply_placement_scales_down()
{
var group = new StreamReplicaGroup("TEST", replicas: 5);
group.Nodes.Count.ShouldBe(5);
await group.ApplyPlacementAsync([1, 2], default);
group.Nodes.Count.ShouldBe(2);
}
[Fact]
public async Task Replica_group_apply_same_size_is_noop()
{
var group = new StreamReplicaGroup("TEST", replicas: 3);
var leaderBefore = group.Leader.Id;
await group.ApplyPlacementAsync([1, 2, 3], default);
group.Nodes.Count.ShouldBe(3);
}
// ---------------------------------------------------------------
// Go: TestJetStreamClusterAccountInfo server/jetstream_cluster_1_test.go:94
// ---------------------------------------------------------------
[Fact]
public async Task Account_info_tracks_streams_and_consumers_in_cluster()
{
await using var fx = await ClusterMetaFixture.StartAsync(nodes: 3);
await fx.CreateStreamAsync("ACCT1", ["a1.>"], replicas: 3);
await fx.CreateStreamAsync("ACCT2", ["a2.>"], replicas: 3);
await fx.CreateConsumerAsync("ACCT1", "c1");
await fx.CreateConsumerAsync("ACCT1", "c2");
var resp = await fx.RequestAsync(JetStreamApiSubjects.Info, "{}");
resp.AccountInfo.ShouldNotBeNull();
resp.AccountInfo!.Streams.ShouldBe(2);
resp.AccountInfo.Consumers.ShouldBe(2);
}
// ---------------------------------------------------------------
// Go: TestJetStreamClusterExtendedAccountInfo server/jetstream_cluster_1_test.go:3389
// ---------------------------------------------------------------
[Fact]
public async Task Account_info_after_stream_delete_reflects_removal()
{
await using var fx = await ClusterMetaFixture.StartAsync(nodes: 3);
await fx.CreateStreamAsync("DEL1", ["d1.>"], replicas: 3);
await fx.CreateStreamAsync("DEL2", ["d2.>"], replicas: 3);
(await fx.RequestAsync($"{JetStreamApiSubjects.StreamDelete}DEL1", "{}")).Success.ShouldBeTrue();
var resp = await fx.RequestAsync(JetStreamApiSubjects.Info, "{}");
resp.AccountInfo!.Streams.ShouldBe(1);
}
// ---------------------------------------------------------------
// Go: TestJetStreamClusterAccountPurge server/jetstream_cluster_1_test.go:3891
// ---------------------------------------------------------------
[Fact]
public async Task Account_purge_returns_success()
{
await using var fx = await ClusterMetaFixture.StartAsync(nodes: 3);
await fx.CreateStreamAsync("PURGE1", ["pur.>"], replicas: 3);
var resp = await fx.RequestAsync($"{JetStreamApiSubjects.AccountPurge}GLOBAL", "{}");
resp.Success.ShouldBeTrue();
}
// ---------------------------------------------------------------
// Go: TestJetStreamClusterStreamLimitWithAccountDefaults server/jetstream_cluster_1_test.go:124
// ---------------------------------------------------------------
[Fact]
public async Task Stream_with_max_bytes_and_replicas_created_successfully()
{
await using var fx = await ClusterMetaFixture.StartAsync(nodes: 3);
var cfg = new StreamConfig
{
Name = "MBLIMIT",
Subjects = ["mbl.>"],
Replicas = 2,
MaxBytes = 4 * 1024 * 1024,
};
var resp = fx.CreateStreamDirect(cfg);
resp.Error.ShouldBeNull();
resp.StreamInfo!.Config.MaxBytes.ShouldBe(4 * 1024 * 1024);
}
// ---------------------------------------------------------------
// Go: TestJetStreamClusterMaxStreamsReached server/jetstream_cluster_1_test.go:3177
// ---------------------------------------------------------------
[Fact]
public async Task Multiple_streams_tracked_correctly_in_meta()
{
await using var fx = await ClusterMetaFixture.StartAsync(nodes: 3);
for (var i = 0; i < 10; i++)
await fx.CreateStreamAsync($"MS{i}", [$"ms{i}.>"], replicas: 3);
var names = await fx.RequestAsync(JetStreamApiSubjects.StreamNames, "{}");
names.StreamNames!.Count.ShouldBe(10);
var meta = fx.GetMetaState();
meta.Streams.Count.ShouldBe(10);
}
// ---------------------------------------------------------------
// Direct API tests (DirectGet)
// ---------------------------------------------------------------
[Fact]
public async Task Direct_get_returns_message_by_sequence()
{
await using var fx = await ClusterMetaFixture.StartAsync(nodes: 3);
var cfg = new StreamConfig
{
Name = "DIRECT",
Subjects = ["dir.>"],
Replicas = 3,
AllowDirect = true,
};
fx.CreateStreamDirect(cfg);
for (var i = 0; i < 5; i++)
await fx.PublishAsync("dir.event", $"msg-{i}");
var resp = await fx.RequestAsync($"{JetStreamApiSubjects.DirectGet}DIRECT", """{"seq":3}""");
resp.DirectMessage.ShouldNotBeNull();
resp.DirectMessage!.Sequence.ShouldBe(3UL);
resp.DirectMessage.Subject.ShouldBe("dir.event");
}
// ---------------------------------------------------------------
// Stream message get
// ---------------------------------------------------------------
[Fact]
public async Task Stream_message_get_returns_correct_payload()
{
await using var fx = await ClusterMetaFixture.StartAsync(nodes: 3);
await fx.CreateStreamAsync("MSGGET", ["mg.>"], replicas: 3);
await fx.PublishAsync("mg.event", "payload-1");
await fx.PublishAsync("mg.event", "payload-2");
var resp = await fx.RequestAsync($"{JetStreamApiSubjects.StreamMessageGet}MSGGET", """{"seq":2}""");
resp.StreamMessage.ShouldNotBeNull();
resp.StreamMessage!.Sequence.ShouldBe(2UL);
resp.StreamMessage.Payload.ShouldBe("payload-2");
}
// ---------------------------------------------------------------
// Consumer list and names
// ---------------------------------------------------------------
[Fact]
public async Task Consumer_list_via_api_router()
{
await using var fx = await ClusterMetaFixture.StartAsync(nodes: 3);
await fx.CreateStreamAsync("CLISTM", ["clm.>"], replicas: 3);
await fx.CreateConsumerAsync("CLISTM", "d1");
await fx.CreateConsumerAsync("CLISTM", "d2");
var names = await fx.RequestAsync($"{JetStreamApiSubjects.ConsumerNames}CLISTM", "{}");
names.ConsumerNames.ShouldNotBeNull();
names.ConsumerNames!.Count.ShouldBe(2);
var list = await fx.RequestAsync($"{JetStreamApiSubjects.ConsumerList}CLISTM", "{}");
list.ConsumerNames.ShouldNotBeNull();
list.ConsumerNames!.Count.ShouldBe(2);
}
// ---------------------------------------------------------------
// Account stream move returns success shape
// ---------------------------------------------------------------
[Fact]
public async Task Account_stream_move_api_returns_success()
{
await using var fx = await ClusterMetaFixture.StartAsync(nodes: 3);
var resp = await fx.RequestAsync($"{JetStreamApiSubjects.AccountStreamMove}TEST", "{}");
resp.Success.ShouldBeTrue();
}
// ---------------------------------------------------------------
// Account stream move cancel returns success shape
// ---------------------------------------------------------------
[Fact]
public async Task Account_stream_move_cancel_api_returns_success()
{
await using var fx = await ClusterMetaFixture.StartAsync(nodes: 3);
var resp = await fx.RequestAsync($"{JetStreamApiSubjects.AccountStreamMoveCancel}TEST", "{}");
resp.Success.ShouldBeTrue();
}
// ---------------------------------------------------------------
// Stream create requires name
// ---------------------------------------------------------------
[Fact]
public void Stream_create_without_name_returns_error()
{
var streamManager = new StreamManager();
var resp = streamManager.CreateOrUpdate(new StreamConfig { Name = "" });
resp.Error.ShouldNotBeNull();
resp.Error!.Description.ShouldContain("name");
}
// ---------------------------------------------------------------
// NotFound for unknown API subject
// ---------------------------------------------------------------
[Fact]
public async Task Unknown_api_subject_returns_not_found()
{
await using var fx = await ClusterMetaFixture.StartAsync(nodes: 3);
var resp = await fx.RequestAsync("$JS.API.UNKNOWN.SUBJECT", "{}");
resp.Error.ShouldNotBeNull();
resp.Error!.Code.ShouldBe(404);
}
// ---------------------------------------------------------------
// Stream info for non-existent stream returns 404
// ---------------------------------------------------------------
[Fact]
public async Task Stream_info_nonexistent_returns_not_found()
{
await using var fx = await ClusterMetaFixture.StartAsync(nodes: 3);
var resp = await fx.RequestAsync($"{JetStreamApiSubjects.StreamInfo}NOSTREAM", "{}");
resp.Error.ShouldNotBeNull();
resp.Error!.Code.ShouldBe(404);
}
// ---------------------------------------------------------------
// Consumer info for non-existent consumer returns 404
// ---------------------------------------------------------------
[Fact]
public async Task Consumer_info_nonexistent_returns_not_found()
{
await using var fx = await ClusterMetaFixture.StartAsync(nodes: 3);
await fx.CreateStreamAsync("NOCONS", ["nc.>"], replicas: 3);
var resp = await fx.RequestAsync($"{JetStreamApiSubjects.ConsumerInfo}NOCONS.MISSING", "{}");
resp.Error.ShouldNotBeNull();
resp.Error!.Code.ShouldBe(404);
}
// ---------------------------------------------------------------
// Delete non-existent stream returns 404
// ---------------------------------------------------------------
[Fact]
public async Task Delete_nonexistent_stream_returns_not_found()
{
await using var fx = await ClusterMetaFixture.StartAsync(nodes: 3);
var resp = await fx.RequestAsync($"{JetStreamApiSubjects.StreamDelete}GONE", "{}");
resp.Error.ShouldNotBeNull();
resp.Error!.Code.ShouldBe(404);
}
// ---------------------------------------------------------------
// Delete non-existent consumer returns 404
// ---------------------------------------------------------------
[Fact]
public async Task Delete_nonexistent_consumer_returns_not_found()
{
await using var fx = await ClusterMetaFixture.StartAsync(nodes: 3);
await fx.CreateStreamAsync("NODEL", ["nd.>"], replicas: 3);
var resp = await fx.RequestAsync($"{JetStreamApiSubjects.ConsumerDelete}NODEL.MISSING", "{}");
resp.Error.ShouldNotBeNull();
resp.Error!.Code.ShouldBe(404);
}
}
/// <summary>
/// Self-contained fixture for JetStream cluster meta tests.
/// </summary>
internal sealed class ClusterMetaFixture : IAsyncDisposable
{
private readonly JetStreamMetaGroup _metaGroup;
private readonly StreamManager _streamManager;
private readonly ConsumerManager _consumerManager;
private readonly JetStreamApiRouter _router;
private readonly JetStreamPublisher _publisher;
private ClusterMetaFixture(
JetStreamMetaGroup metaGroup,
StreamManager streamManager,
ConsumerManager consumerManager,
JetStreamApiRouter router,
JetStreamPublisher publisher)
{
_metaGroup = metaGroup;
_streamManager = streamManager;
_consumerManager = consumerManager;
_router = router;
_publisher = publisher;
}
public static Task<ClusterMetaFixture> StartAsync(int nodes)
{
var meta = new JetStreamMetaGroup(nodes);
var consumerManager = new ConsumerManager(meta);
var streamManager = new StreamManager(meta, consumerManager: consumerManager);
var router = new JetStreamApiRouter(streamManager, consumerManager, meta);
var publisher = new JetStreamPublisher(streamManager);
return Task.FromResult(new ClusterMetaFixture(meta, streamManager, consumerManager, router, publisher));
}
public Task CreateStreamAsync(string name, string[] subjects, int replicas)
{
var response = _streamManager.CreateOrUpdate(new StreamConfig
{
Name = name,
Subjects = [.. subjects],
Replicas = replicas,
});
if (response.Error is not null)
throw new InvalidOperationException(response.Error.Description);
return Task.CompletedTask;
}
public JetStreamApiResponse CreateStreamDirect(StreamConfig config)
=> _streamManager.CreateOrUpdate(config);
public Task<JetStreamApiResponse> CreateConsumerAsync(string stream, string durableName)
{
return Task.FromResult(_consumerManager.CreateOrUpdate(stream, new ConsumerConfig
{
DurableName = durableName,
}));
}
public Task<PubAck> PublishAsync(string subject, string payload)
{
if (_publisher.TryCapture(subject, Encoding.UTF8.GetBytes(payload), null, out var ack))
return Task.FromResult(ack);
throw new InvalidOperationException($"Publish to '{subject}' did not match a stream.");
}
public MetaGroupState GetMetaState() => _metaGroup.GetState();
public Task<JetStreamApiResponse> RequestAsync(string subject, string payload)
=> Task.FromResult(_router.Route(subject, Encoding.UTF8.GetBytes(payload)));
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}

View File

@@ -0,0 +1,872 @@
// Go parity: golang/nats-server/server/jetstream_cluster_1_test.go
// Covers: cluster stream creation, single/multi replica, memory store,
// stream purge, update subjects, delete, max bytes, stream info/list,
// interest retention, work queue retention, mirror/source in cluster.
using System.Text;
using NATS.Server.JetStream;
using NATS.Server.JetStream.Api;
using NATS.Server.JetStream.Cluster;
using NATS.Server.JetStream.Consumers;
using NATS.Server.JetStream.Models;
using NATS.Server.JetStream.Publish;
namespace NATS.Server.Tests.JetStream.Cluster;
/// <summary>
/// Tests covering clustered JetStream stream creation, replication, storage,
/// purge, update, delete, retention policies, and mirror/source in cluster mode.
/// Ported from Go jetstream_cluster_1_test.go.
/// </summary>
public class JetStreamClusterStreamTests
{
// ---------------------------------------------------------------
// Go: TestJetStreamClusterSingleReplicaStreams server/jetstream_cluster_1_test.go:223
// ---------------------------------------------------------------
[Fact]
public async Task Single_replica_stream_creation_and_publish_in_cluster()
{
await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3);
var resp = await fx.CreateStreamAsync("R1S", ["foo", "bar"], replicas: 1);
resp.Error.ShouldBeNull();
resp.StreamInfo.ShouldNotBeNull();
resp.StreamInfo!.Config.Name.ShouldBe("R1S");
const int toSend = 10;
for (var i = 0; i < toSend; i++)
{
var ack = await fx.PublishAsync("foo", $"Hello R1 {i}");
ack.Stream.ShouldBe("R1S");
ack.Seq.ShouldBe((ulong)(i + 1));
}
var info = await fx.GetStreamInfoAsync("R1S");
info.StreamInfo!.State.Messages.ShouldBe((ulong)toSend);
}
// ---------------------------------------------------------------
// Go: TestJetStreamClusterMultiReplicaStreamsDefaultFileMem server/jetstream_cluster_1_test.go:355
// ---------------------------------------------------------------
[Fact]
public async Task Multi_replica_stream_defaults_to_memory_store()
{
await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3);
var resp = await fx.CreateStreamAsync("MEMTEST", ["mem.>"], replicas: 3);
resp.Error.ShouldBeNull();
resp.StreamInfo!.Config.Storage.ShouldBe(StorageType.Memory);
var backend = fx.GetStoreBackendType("MEMTEST");
backend.ShouldBe("memory");
}
// ---------------------------------------------------------------
// Go: TestJetStreamClusterMemoryStore server/jetstream_cluster_1_test.go:423
// ---------------------------------------------------------------
[Fact]
public async Task Memory_store_replicated_stream_accepts_100_messages()
{
await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3);
var resp = await fx.CreateStreamAsync("R3M", ["foo", "bar"], replicas: 3, storage: StorageType.Memory);
resp.Error.ShouldBeNull();
const int toSend = 100;
for (var i = 0; i < toSend; i++)
{
var ack = await fx.PublishAsync("foo", "Hello MemoryStore");
ack.Stream.ShouldBe("R3M");
}
var info = await fx.GetStreamInfoAsync("R3M");
info.StreamInfo!.Config.Name.ShouldBe("R3M");
info.StreamInfo.State.Messages.ShouldBe((ulong)toSend);
}
// ---------------------------------------------------------------
// Go: TestJetStreamClusterDelete server/jetstream_cluster_1_test.go:472
// ---------------------------------------------------------------
[Fact]
public async Task Delete_consumer_then_stream_clears_account_info()
{
await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3);
await fx.CreateStreamAsync("C22", ["foo", "bar", "baz"], replicas: 2);
await fx.CreateConsumerAsync("C22", "dlc");
// Delete consumer then stream
var delConsumer = await fx.RequestAsync($"{JetStreamApiSubjects.ConsumerDelete}C22.dlc", "{}");
delConsumer.Success.ShouldBeTrue();
var delStream = await fx.RequestAsync($"{JetStreamApiSubjects.StreamDelete}C22", "{}");
delStream.Success.ShouldBeTrue();
// Account info should show zero streams
var accountInfo = await fx.RequestAsync(JetStreamApiSubjects.Info, "{}");
accountInfo.AccountInfo.ShouldNotBeNull();
accountInfo.AccountInfo!.Streams.ShouldBe(0);
}
// ---------------------------------------------------------------
// Go: TestJetStreamClusterStreamPurge server/jetstream_cluster_1_test.go:522
// ---------------------------------------------------------------
[Fact]
public async Task Stream_purge_clears_all_messages_in_cluster()
{
await using var fx = await ClusterStreamFixture.StartAsync(nodes: 5);
await fx.CreateStreamAsync("PURGE", ["foo", "bar"], replicas: 3);
const int toSend = 100;
for (var i = 0; i < toSend; i++)
await fx.PublishAsync("foo", "Hello JS Clustering");
var before = await fx.GetStreamInfoAsync("PURGE");
before.StreamInfo!.State.Messages.ShouldBe((ulong)toSend);
var purge = await fx.RequestAsync($"{JetStreamApiSubjects.StreamPurge}PURGE", "{}");
purge.Success.ShouldBeTrue();
var after = await fx.GetStreamInfoAsync("PURGE");
after.StreamInfo!.State.Messages.ShouldBe(0UL);
}
// ---------------------------------------------------------------
// Go: TestJetStreamClusterStreamUpdateSubjects server/jetstream_cluster_1_test.go:571
// ---------------------------------------------------------------
[Fact]
public async Task Stream_update_subjects_reflects_new_configuration()
{
await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3);
await fx.CreateStreamAsync("SUBUPDATE", ["foo", "bar"], replicas: 3);
// Update subjects to bar, baz
var update = fx.UpdateStream("SUBUPDATE", ["bar", "baz"], replicas: 3);
update.Error.ShouldBeNull();
update.StreamInfo.ShouldNotBeNull();
update.StreamInfo!.Config.Subjects.ShouldContain("bar");
update.StreamInfo.Config.Subjects.ShouldContain("baz");
update.StreamInfo.Config.Subjects.ShouldNotContain("foo");
}
// ---------------------------------------------------------------
// Go: TestJetStreamClusterStreamInfoList server/jetstream_cluster_1_test.go:1284
// ---------------------------------------------------------------
[Fact]
public async Task Stream_names_and_list_return_all_streams()
{
await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3);
await fx.CreateStreamAsync("S1", ["s1.>"], replicas: 3);
await fx.CreateStreamAsync("S2", ["s2.>"], replicas: 3);
await fx.CreateStreamAsync("S3", ["s3.>"], replicas: 1);
var names = await fx.RequestAsync(JetStreamApiSubjects.StreamNames, "{}");
names.StreamNames.ShouldNotBeNull();
names.StreamNames!.Count.ShouldBe(3);
names.StreamNames.ShouldContain("S1");
names.StreamNames.ShouldContain("S2");
names.StreamNames.ShouldContain("S3");
var list = await fx.RequestAsync(JetStreamApiSubjects.StreamList, "{}");
list.StreamNames.ShouldNotBeNull();
list.StreamNames!.Count.ShouldBe(3);
}
// ---------------------------------------------------------------
// Go: TestJetStreamClusterMaxBytesForStream server/jetstream_cluster_1_test.go:1099
// ---------------------------------------------------------------
[Fact]
public async Task Max_bytes_stream_limits_enforced_in_cluster()
{
await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3);
var cfg = new StreamConfig
{
Name = "MAXBYTES",
Subjects = ["mb.>"],
Replicas = 3,
MaxBytes = 512,
Discard = DiscardPolicy.Old,
};
var resp = fx.CreateStreamDirect(cfg);
resp.Error.ShouldBeNull();
// Publish messages exceeding max bytes; old messages should be discarded
for (var i = 0; i < 20; i++)
await fx.PublishAsync("mb.data", new string('X', 64));
var state = await fx.GetStreamStateAsync("MAXBYTES");
// Total bytes should not exceed max_bytes by much after enforcement
((long)state.Bytes).ShouldBeLessThanOrEqualTo(cfg.MaxBytes + 128);
}
// ---------------------------------------------------------------
// Go: TestJetStreamClusterStreamPublishWithActiveConsumers server/jetstream_cluster_1_test.go:1132
// ---------------------------------------------------------------
[Fact]
public async Task Publish_with_active_consumer_delivers_messages()
{
await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3);
await fx.CreateStreamAsync("ACTIVE", ["active.>"], replicas: 3);
await fx.CreateConsumerAsync("ACTIVE", "durable1", filterSubject: "active.>");
for (var i = 0; i < 10; i++)
await fx.PublishAsync("active.event", $"msg-{i}");
var batch = await fx.FetchAsync("ACTIVE", "durable1", 10);
batch.Messages.Count.ShouldBe(10);
}
// ---------------------------------------------------------------
// Go: TestJetStreamClusterDoubleAdd server/jetstream_cluster_1_test.go:1551
// ---------------------------------------------------------------
[Fact]
public async Task Double_add_stream_with_same_config_succeeds()
{
await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3);
var first = await fx.CreateStreamAsync("DUP", ["dup.>"], replicas: 3);
first.Error.ShouldBeNull();
// Adding the same stream again should succeed (idempotent)
var second = await fx.CreateStreamAsync("DUP", ["dup.>"], replicas: 3);
second.Error.ShouldBeNull();
second.StreamInfo!.Config.Name.ShouldBe("DUP");
}
// ---------------------------------------------------------------
// Go: TestJetStreamClusterStreamOverlapSubjects server/jetstream_cluster_1_test.go:1248
// ---------------------------------------------------------------
[Fact]
public async Task Publish_routes_to_correct_stream_among_non_overlapping()
{
await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3);
await fx.CreateStreamAsync("ALPHA", ["alpha.>"], replicas: 3);
await fx.CreateStreamAsync("BETA", ["beta.>"], replicas: 3);
var ack1 = await fx.PublishAsync("alpha.one", "A");
ack1.Stream.ShouldBe("ALPHA");
var ack2 = await fx.PublishAsync("beta.one", "B");
ack2.Stream.ShouldBe("BETA");
}
// ---------------------------------------------------------------
// Go: TestJetStreamClusterInterestRetention server/jetstream_cluster_1_test.go:2109
// ---------------------------------------------------------------
[Fact]
public async Task Interest_retention_stream_in_cluster()
{
await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3);
var cfg = new StreamConfig
{
Name = "INTEREST",
Subjects = ["interest.>"],
Replicas = 3,
Retention = RetentionPolicy.Interest,
};
fx.CreateStreamDirect(cfg);
for (var i = 0; i < 5; i++)
await fx.PublishAsync("interest.event", "msg");
var state = await fx.GetStreamStateAsync("INTEREST");
state.Messages.ShouldBe(5UL);
}
// ---------------------------------------------------------------
// Go: TestJetStreamClusterWorkQueueRetention server/jetstream_cluster_1_test.go:2179
// ---------------------------------------------------------------
[Fact]
public async Task Work_queue_retention_removes_acked_messages_in_cluster()
{
await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3);
var cfg = new StreamConfig
{
Name = "WQ",
Subjects = ["wq.>"],
Replicas = 2,
Retention = RetentionPolicy.WorkQueue,
MaxConsumers = 1,
};
fx.CreateStreamDirect(cfg);
await fx.CreateConsumerAsync("WQ", "worker", filterSubject: "wq.>", ackPolicy: AckPolicy.All);
await fx.PublishAsync("wq.task", "job-1");
var stateBefore = await fx.GetStreamStateAsync("WQ");
stateBefore.Messages.ShouldBe(1UL);
// Ack all up to sequence 1, triggering work queue cleanup
fx.AckAll("WQ", "worker", 1);
// Publish again to trigger runtime retention enforcement
await fx.PublishAsync("wq.task", "job-2");
var stateAfter = await fx.GetStreamStateAsync("WQ");
// After ack, only the new message should remain
stateAfter.Messages.ShouldBe(1UL);
}
// ---------------------------------------------------------------
// Go: TestJetStreamClusterDeleteMsg server/jetstream_cluster_1_test.go:1748
// ---------------------------------------------------------------
[Fact]
public async Task Delete_individual_message_in_clustered_stream()
{
await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3);
await fx.CreateStreamAsync("DELMSG", ["dm.>"], replicas: 3);
for (var i = 0; i < 5; i++)
await fx.PublishAsync("dm.event", $"msg-{i}");
var before = await fx.GetStreamStateAsync("DELMSG");
before.Messages.ShouldBe(5UL);
// Delete message at sequence 3
var del = await fx.RequestAsync($"{JetStreamApiSubjects.StreamMessageDelete}DELMSG", """{"seq":3}""");
del.Success.ShouldBeTrue();
var after = await fx.GetStreamStateAsync("DELMSG");
after.Messages.ShouldBe(4UL);
}
// ---------------------------------------------------------------
// Go: TestJetStreamClusterStreamUpdate server/jetstream_cluster_1_test.go:1433
// ---------------------------------------------------------------
[Fact]
public async Task Stream_update_preserves_existing_messages()
{
await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3);
await fx.CreateStreamAsync("UPD", ["upd.>"], replicas: 3);
for (var i = 0; i < 5; i++)
await fx.PublishAsync("upd.event", $"msg-{i}");
// Update max_msgs
var update = fx.UpdateStream("UPD", ["upd.>"], replicas: 3, maxMsgs: 10);
update.Error.ShouldBeNull();
var state = await fx.GetStreamStateAsync("UPD");
state.Messages.ShouldBe(5UL);
}
// ---------------------------------------------------------------
// Go: TestJetStreamClusterAccountInfo server/jetstream_cluster_1_test.go:94
// ---------------------------------------------------------------
[Fact]
public async Task Account_info_reports_stream_and_consumer_counts()
{
await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3);
await fx.CreateStreamAsync("AI1", ["ai1.>"], replicas: 3);
await fx.CreateStreamAsync("AI2", ["ai2.>"], replicas: 3);
await fx.CreateConsumerAsync("AI1", "c1");
var resp = await fx.RequestAsync(JetStreamApiSubjects.Info, "{}");
resp.AccountInfo.ShouldNotBeNull();
resp.AccountInfo!.Streams.ShouldBe(2);
resp.AccountInfo.Consumers.ShouldBe(1);
}
// ---------------------------------------------------------------
// Go: TestJetStreamClusterExpand server/jetstream_cluster_1_test.go:86
// ---------------------------------------------------------------
[Fact]
public void Cluster_expand_adds_peer_to_meta_group()
{
var meta = new JetStreamMetaGroup(2);
var state = meta.GetState();
state.ClusterSize.ShouldBe(2);
// Expanding is modeled by creating a new meta group with more nodes
var expanded = new JetStreamMetaGroup(3);
expanded.GetState().ClusterSize.ShouldBe(3);
}
// ---------------------------------------------------------------
// Go: TestJetStreamClusterMirrorAndSourceWorkQueues server/jetstream_cluster_1_test.go:2233
// ---------------------------------------------------------------
[Fact]
public async Task Mirror_stream_replicates_in_cluster()
{
await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3);
// Create origin stream
await fx.CreateStreamAsync("ORIGIN", ["origin.>"], replicas: 3);
// Create mirror stream
fx.CreateStreamDirect(new StreamConfig
{
Name = "MIRROR",
Subjects = ["mirror.>"],
Replicas = 3,
Mirror = "ORIGIN",
});
// Publish to origin
for (var i = 0; i < 5; i++)
await fx.PublishAsync("origin.event", $"mirrored-{i}");
// Mirror should have replicated messages
var mirrorState = await fx.GetStreamStateAsync("MIRROR");
mirrorState.Messages.ShouldBe(5UL);
}
// ---------------------------------------------------------------
// Go: TestJetStreamClusterMirrorAndSourceInterestPolicyStream server/jetstream_cluster_1_test.go:2290
// ---------------------------------------------------------------
[Fact]
public async Task Source_stream_replicates_in_cluster()
{
await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3);
// Create source origin
await fx.CreateStreamAsync("SRC", ["src.>"], replicas: 3);
// Create aggregate stream sourcing from SRC
fx.CreateStreamDirect(new StreamConfig
{
Name = "AGG",
Subjects = ["agg.>"],
Replicas = 3,
Sources = [new StreamSourceConfig { Name = "SRC" }],
});
// Publish to source
for (var i = 0; i < 3; i++)
await fx.PublishAsync("src.event", $"sourced-{i}");
var aggState = await fx.GetStreamStateAsync("AGG");
aggState.Messages.ShouldBe(3UL);
}
// ---------------------------------------------------------------
// Go: TestJetStreamClusterUserSnapshotAndRestore server/jetstream_cluster_1_test.go:2652
// ---------------------------------------------------------------
[Fact]
public async Task Snapshot_and_restore_preserves_messages_in_cluster()
{
await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3);
await fx.CreateStreamAsync("SNAP", ["snap.>"], replicas: 3);
for (var i = 0; i < 10; i++)
await fx.PublishAsync("snap.event", $"msg-{i}");
// Create snapshot
var snapshot = await fx.RequestAsync($"{JetStreamApiSubjects.StreamSnapshot}SNAP", "{}");
snapshot.Snapshot.ShouldNotBeNull();
snapshot.Snapshot!.Payload.ShouldNotBeNullOrEmpty();
// Purge the stream
await fx.RequestAsync($"{JetStreamApiSubjects.StreamPurge}SNAP", "{}");
var afterPurge = await fx.GetStreamStateAsync("SNAP");
afterPurge.Messages.ShouldBe(0UL);
// Restore from snapshot
var restore = await fx.RequestAsync($"{JetStreamApiSubjects.StreamRestore}SNAP", snapshot.Snapshot.Payload);
restore.Success.ShouldBeTrue();
var afterRestore = await fx.GetStreamStateAsync("SNAP");
afterRestore.Messages.ShouldBe(10UL);
}
// ---------------------------------------------------------------
// Go: TestJetStreamClusterStreamSynchedTimeStamps server/jetstream_cluster_1_test.go:977
// ---------------------------------------------------------------
[Fact]
public async Task Replicated_stream_messages_have_monotonic_sequences()
{
await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3);
await fx.CreateStreamAsync("SEQ", ["seq.>"], replicas: 3);
var sequences = new List<ulong>();
for (var i = 0; i < 20; i++)
{
var ack = await fx.PublishAsync("seq.event", $"msg-{i}");
sequences.Add(ack.Seq);
}
// Verify strictly monotonically increasing sequences
for (var i = 1; i < sequences.Count; i++)
sequences[i].ShouldBeGreaterThan(sequences[i - 1]);
}
// ---------------------------------------------------------------
// Go: TestJetStreamClusterStreamLimits server/jetstream_cluster_1_test.go:3248
// ---------------------------------------------------------------
[Fact]
public async Task Max_msgs_limit_enforced_in_clustered_stream()
{
await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3);
var cfg = new StreamConfig
{
Name = "LIMITED",
Subjects = ["limited.>"],
Replicas = 3,
MaxMsgs = 5,
};
fx.CreateStreamDirect(cfg);
for (var i = 0; i < 10; i++)
await fx.PublishAsync("limited.event", $"msg-{i}");
var state = await fx.GetStreamStateAsync("LIMITED");
state.Messages.ShouldBeLessThanOrEqualTo(5UL);
}
// ---------------------------------------------------------------
// Go: TestJetStreamClusterStreamInterestOnlyPolicy server/jetstream_cluster_1_test.go:3310
// ---------------------------------------------------------------
[Fact]
public async Task Interest_only_policy_stream_stores_messages_without_consumers()
{
await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3);
var cfg = new StreamConfig
{
Name = "INTONLY",
Subjects = ["intonly.>"],
Replicas = 3,
Retention = RetentionPolicy.Interest,
};
fx.CreateStreamDirect(cfg);
for (var i = 0; i < 3; i++)
await fx.PublishAsync("intonly.data", $"msg-{i}");
// Without consumers, interest retention still stores messages
// (they are removed only when all consumers have acked)
var state = await fx.GetStreamStateAsync("INTONLY");
state.Messages.ShouldBe(3UL);
}
// ---------------------------------------------------------------
// Go: TestJetStreamClusterConsumerInfoList server/jetstream_cluster_1_test.go:1349
// ---------------------------------------------------------------
[Fact]
public async Task Consumer_names_and_list_return_all_consumers()
{
await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3);
await fx.CreateStreamAsync("CLIST", ["clist.>"], replicas: 3);
await fx.CreateConsumerAsync("CLIST", "c1");
await fx.CreateConsumerAsync("CLIST", "c2");
await fx.CreateConsumerAsync("CLIST", "c3");
var names = await fx.RequestAsync($"{JetStreamApiSubjects.ConsumerNames}CLIST", "{}");
names.ConsumerNames.ShouldNotBeNull();
names.ConsumerNames!.Count.ShouldBe(3);
names.ConsumerNames.ShouldContain("c1");
names.ConsumerNames.ShouldContain("c2");
names.ConsumerNames.ShouldContain("c3");
}
// ---------------------------------------------------------------
// Go: TestJetStreamClusterDefaultMaxAckPending server/jetstream_cluster_1_test.go:1580
// ---------------------------------------------------------------
[Fact]
public async Task Consumer_default_ack_policy_is_none()
{
await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3);
await fx.CreateStreamAsync("ACKDEF", ["ackdef.>"], replicas: 3);
var resp = await fx.CreateConsumerAsync("ACKDEF", "test_consumer");
resp.ConsumerInfo.ShouldNotBeNull();
resp.ConsumerInfo!.Config.AckPolicy.ShouldBe(AckPolicy.None);
}
// ---------------------------------------------------------------
// Go: TestJetStreamClusterExtendedStreamInfo server/jetstream_cluster_1_test.go:1878
// ---------------------------------------------------------------
[Fact]
public async Task Stream_info_returns_config_and_state()
{
await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3);
await fx.CreateStreamAsync("EXTINFO", ["ext.>"], replicas: 3);
for (var i = 0; i < 5; i++)
await fx.PublishAsync("ext.event", $"msg-{i}");
var info = await fx.GetStreamInfoAsync("EXTINFO");
info.StreamInfo.ShouldNotBeNull();
info.StreamInfo!.Config.Name.ShouldBe("EXTINFO");
info.StreamInfo.Config.Replicas.ShouldBe(3);
info.StreamInfo.State.Messages.ShouldBe(5UL);
info.StreamInfo.State.FirstSeq.ShouldBe(1UL);
info.StreamInfo.State.LastSeq.ShouldBe(5UL);
}
// ---------------------------------------------------------------
// Go: TestJetStreamClusterExtendedStreamInfoSingleReplica server/jetstream_cluster_1_test.go:2033
// ---------------------------------------------------------------
[Fact]
public async Task Single_replica_stream_info_in_cluster()
{
await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3);
await fx.CreateStreamAsync("R1INFO", ["r1info.>"], replicas: 1);
for (var i = 0; i < 3; i++)
await fx.PublishAsync("r1info.event", $"msg-{i}");
var info = await fx.GetStreamInfoAsync("R1INFO");
info.StreamInfo!.Config.Replicas.ShouldBe(1);
info.StreamInfo.State.Messages.ShouldBe(3UL);
}
// ---------------------------------------------------------------
// Go: TestJetStreamClusterMultiReplicaStreams (maxmsgs_per behavior)
// ---------------------------------------------------------------
[Fact]
public async Task Max_msgs_per_subject_enforced_in_cluster()
{
await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3);
var cfg = new StreamConfig
{
Name = "PERSUBJ",
Subjects = ["ps.>"],
Replicas = 3,
MaxMsgsPer = 2,
};
fx.CreateStreamDirect(cfg);
// Publish 5 messages to same subject; only 2 should remain
for (var i = 0; i < 5; i++)
await fx.PublishAsync("ps.topic", $"msg-{i}");
var state = await fx.GetStreamStateAsync("PERSUBJ");
state.Messages.ShouldBeLessThanOrEqualTo(2UL);
}
// ---------------------------------------------------------------
// Go: TestJetStreamClusterStreamExtendedUpdates server/jetstream_cluster_1_test.go:1513
// ---------------------------------------------------------------
[Fact]
public async Task Stream_update_can_change_max_msgs()
{
await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3);
var cfg = new StreamConfig
{
Name = "EXTUPD",
Subjects = ["eu.>"],
Replicas = 3,
};
fx.CreateStreamDirect(cfg);
for (var i = 0; i < 10; i++)
await fx.PublishAsync("eu.event", $"msg-{i}");
// Update to limit max_msgs
var update = fx.UpdateStream("EXTUPD", ["eu.>"], replicas: 3, maxMsgs: 5);
update.Error.ShouldBeNull();
update.StreamInfo!.Config.MaxMsgs.ShouldBe(5);
}
// ---------------------------------------------------------------
// Additional: Sealed stream rejects purge
// ---------------------------------------------------------------
[Fact]
public async Task Sealed_stream_rejects_purge_in_cluster()
{
await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3);
var cfg = new StreamConfig
{
Name = "SEALED",
Subjects = ["sealed.>"],
Replicas = 3,
Sealed = true,
};
fx.CreateStreamDirect(cfg);
var purge = await fx.RequestAsync($"{JetStreamApiSubjects.StreamPurge}SEALED", "{}");
// Sealed streams should not allow purge
purge.Success.ShouldBeFalse();
}
// ---------------------------------------------------------------
// Additional: DenyDelete stream rejects message delete
// ---------------------------------------------------------------
[Fact]
public async Task DenyDelete_stream_rejects_message_delete()
{
await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3);
var cfg = new StreamConfig
{
Name = "NODELDENY",
Subjects = ["nodel.>"],
Replicas = 3,
DenyDelete = true,
};
fx.CreateStreamDirect(cfg);
await fx.PublishAsync("nodel.event", "msg");
var del = await fx.RequestAsync($"{JetStreamApiSubjects.StreamMessageDelete}NODELDENY", """{"seq":1}""");
del.Success.ShouldBeFalse();
}
}
/// <summary>
/// Self-contained fixture for JetStream cluster stream tests. Wires up
/// meta group, stream manager, consumer manager, API router, and publisher.
/// </summary>
internal sealed class ClusterStreamFixture : IAsyncDisposable
{
private readonly JetStreamMetaGroup _metaGroup;
private readonly StreamManager _streamManager;
private readonly ConsumerManager _consumerManager;
private readonly JetStreamApiRouter _router;
private readonly JetStreamPublisher _publisher;
private ClusterStreamFixture(
JetStreamMetaGroup metaGroup,
StreamManager streamManager,
ConsumerManager consumerManager,
JetStreamApiRouter router,
JetStreamPublisher publisher)
{
_metaGroup = metaGroup;
_streamManager = streamManager;
_consumerManager = consumerManager;
_router = router;
_publisher = publisher;
}
public static Task<ClusterStreamFixture> StartAsync(int nodes)
{
var meta = new JetStreamMetaGroup(nodes);
var consumerManager = new ConsumerManager(meta);
var streamManager = new StreamManager(meta, consumerManager: consumerManager);
var router = new JetStreamApiRouter(streamManager, consumerManager, meta);
var publisher = new JetStreamPublisher(streamManager);
return Task.FromResult(new ClusterStreamFixture(meta, streamManager, consumerManager, router, publisher));
}
public Task<JetStreamApiResponse> CreateStreamAsync(string name, string[] subjects, int replicas, StorageType storage = StorageType.Memory)
{
var response = _streamManager.CreateOrUpdate(new StreamConfig
{
Name = name,
Subjects = [.. subjects],
Replicas = replicas,
Storage = storage,
});
return Task.FromResult(response);
}
public JetStreamApiResponse CreateStreamDirect(StreamConfig config)
=> _streamManager.CreateOrUpdate(config);
public JetStreamApiResponse UpdateStream(string name, string[] subjects, int replicas, int maxMsgs = 0)
{
return _streamManager.CreateOrUpdate(new StreamConfig
{
Name = name,
Subjects = [.. subjects],
Replicas = replicas,
MaxMsgs = maxMsgs,
});
}
public Task<PubAck> PublishAsync(string subject, string payload)
{
if (_publisher.TryCapture(subject, Encoding.UTF8.GetBytes(payload), null, out var ack))
{
if (ack.ErrorCode == null && _streamManager.TryGet(ack.Stream, out var handle))
{
var stored = handle.Store.LoadAsync(ack.Seq, default).GetAwaiter().GetResult();
if (stored != null)
_consumerManager.OnPublished(ack.Stream, stored);
}
return Task.FromResult(ack);
}
throw new InvalidOperationException($"Publish to '{subject}' did not match a stream.");
}
public Task<JetStreamApiResponse> GetStreamInfoAsync(string name)
=> Task.FromResult(_streamManager.GetInfo(name));
public Task<ApiStreamState> GetStreamStateAsync(string name)
=> _streamManager.GetStateAsync(name, default).AsTask();
public string GetStoreBackendType(string name) => _streamManager.GetStoreBackendType(name);
public Task<JetStreamApiResponse> CreateConsumerAsync(
string stream,
string durableName,
string? filterSubject = null,
AckPolicy ackPolicy = AckPolicy.None)
{
var config = new ConsumerConfig
{
DurableName = durableName,
AckPolicy = ackPolicy,
};
if (!string.IsNullOrWhiteSpace(filterSubject))
config.FilterSubject = filterSubject;
return Task.FromResult(_consumerManager.CreateOrUpdate(stream, config));
}
public Task<PullFetchBatch> FetchAsync(string stream, string durableName, int batch)
=> _consumerManager.FetchAsync(stream, durableName, batch, _streamManager, default).AsTask();
public void AckAll(string stream, string durableName, ulong sequence)
=> _consumerManager.AckAll(stream, durableName, sequence);
public Task<JetStreamApiResponse> RequestAsync(string subject, string payload)
=> Task.FromResult(_router.Route(subject, Encoding.UTF8.GetBytes(payload)));
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}