// 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; /// /// 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. /// 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(); 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(); } } /// /// Self-contained fixture for JetStream cluster stream tests. Wires up /// meta group, stream manager, consumer manager, API router, and publisher. /// 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 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 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 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 GetStreamInfoAsync(string name) => Task.FromResult(_streamManager.GetInfo(name)); public Task GetStreamStateAsync(string name) => _streamManager.GetStateAsync(name, default).AsTask(); public string GetStoreBackendType(string name) => _streamManager.GetStoreBackendType(name); public Task 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 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 RequestAsync(string subject, string payload) => Task.FromResult(_router.Route(subject, Encoding.UTF8.GetBytes(payload))); public ValueTask DisposeAsync() => ValueTask.CompletedTask; }