using System.Threading.Channels; using NATS.Server.JetStream.Cluster; using NATS.Server.Raft; namespace NATS.Server.JetStream.Tests.JetStream.Cluster; /// /// Tests for JetStreamClusterMonitor — background meta entry processing. /// Go reference: jetstream_cluster.go:1455-1825 (monitorCluster). /// public class JetStreamClusterMonitorTests { // Each test uses a 5-second CancellationToken as a hard upper bound so a // hung monitor doesn't stall the test run indefinitely. private static CancellationTokenSource TestTimeout() => new(TimeSpan.FromSeconds(5)); [Fact] public async Task Monitor_processes_stream_assignment_entry() { // Go reference: jetstream_cluster.go monitorCluster assignStream op var meta = new JetStreamMetaGroup(3); var channel = Channel.CreateUnbounded(); var monitor = new JetStreamClusterMonitor(meta, channel.Reader); using var cts = TestTimeout(); var monitorTask = monitor.StartAsync(cts.Token); var assignJson = System.Text.Json.JsonSerializer.Serialize(new { Op = "assignStream", StreamName = "test-stream", Peers = new[] { "n1", "n2", "n3" }, Config = """{"subjects":["test.>"]}""", }); await channel.Writer.WriteAsync(new RaftLogEntry(1, 1, assignJson)); await monitor.WaitForProcessedAsync(1, cts.Token); meta.StreamCount.ShouldBe(1); meta.GetStreamAssignment("test-stream").ShouldNotBeNull(); cts.Cancel(); await monitorTask; } [Fact] public async Task Monitor_processes_consumer_assignment_entry() { // Go reference: jetstream_cluster.go monitorCluster assignConsumer op var meta = new JetStreamMetaGroup(3); var channel = Channel.CreateUnbounded(); var monitor = new JetStreamClusterMonitor(meta, channel.Reader); using var cts = TestTimeout(); var monitorTask = monitor.StartAsync(cts.Token); var streamJson = System.Text.Json.JsonSerializer.Serialize(new { Op = "assignStream", StreamName = "s1", Peers = new[] { "n1", "n2", "n3" }, Config = """{"subjects":["x.>"]}""", }); await channel.Writer.WriteAsync(new RaftLogEntry(1, 1, streamJson)); var consumerJson = System.Text.Json.JsonSerializer.Serialize(new { Op = "assignConsumer", StreamName = "s1", ConsumerName = "c1", Peers = new[] { "n1", "n2", "n3" }, }); await channel.Writer.WriteAsync(new RaftLogEntry(2, 1, consumerJson)); await monitor.WaitForProcessedAsync(2, cts.Token); meta.ConsumerCount.ShouldBe(1); cts.Cancel(); await monitorTask; } [Fact] public async Task Monitor_processes_stream_removal() { // Go reference: jetstream_cluster.go monitorCluster removeStream op var meta = new JetStreamMetaGroup(3); var channel = Channel.CreateUnbounded(); var monitor = new JetStreamClusterMonitor(meta, channel.Reader); using var cts = TestTimeout(); var monitorTask = monitor.StartAsync(cts.Token); var assignJson = System.Text.Json.JsonSerializer.Serialize(new { Op = "assignStream", StreamName = "to-remove", Peers = new[] { "n1", "n2", "n3" }, Config = """{"subjects":["rm.>"]}""", }); await channel.Writer.WriteAsync(new RaftLogEntry(1, 1, assignJson)); var removeJson = System.Text.Json.JsonSerializer.Serialize(new { Op = "removeStream", StreamName = "to-remove", }); await channel.Writer.WriteAsync(new RaftLogEntry(2, 1, removeJson)); await monitor.WaitForProcessedAsync(2, cts.Token); meta.StreamCount.ShouldBe(0); cts.Cancel(); await monitorTask; } [Fact] public async Task Monitor_applies_meta_snapshot() { // Go reference: jetstream_cluster.go monitorCluster snapshot op — replaces all state var meta = new JetStreamMetaGroup(3); var channel = Channel.CreateUnbounded(); var monitor = new JetStreamClusterMonitor(meta, channel.Reader); using var cts = TestTimeout(); var monitorTask = monitor.StartAsync(cts.Token); var assignments = new Dictionary { ["snap-stream"] = new StreamAssignment { StreamName = "snap-stream", Group = new RaftGroup { Name = "rg-snap", Peers = ["n1", "n2", "n3"] }, }, }; var snapshotB64 = Convert.ToBase64String(MetaSnapshotCodec.Encode(assignments)); var snapshotJson = System.Text.Json.JsonSerializer.Serialize(new { Op = "snapshot", Data = snapshotB64, }); await channel.Writer.WriteAsync(new RaftLogEntry(1, 1, snapshotJson)); await monitor.WaitForProcessedAsync(1, cts.Token); meta.StreamCount.ShouldBe(1); meta.GetStreamAssignment("snap-stream").ShouldNotBeNull(); cts.Cancel(); await monitorTask; } [Fact] public async Task Monitor_processes_consumer_removal() { // Go reference: jetstream_cluster.go monitorCluster removeConsumer op var meta = new JetStreamMetaGroup(3); var channel = Channel.CreateUnbounded(); var monitor = new JetStreamClusterMonitor(meta, channel.Reader); using var cts = TestTimeout(); var monitorTask = monitor.StartAsync(cts.Token); var streamJson = System.Text.Json.JsonSerializer.Serialize(new { Op = "assignStream", StreamName = "s1", Peers = new[] { "n1", "n2", "n3" }, }); await channel.Writer.WriteAsync(new RaftLogEntry(1, 1, streamJson)); var consumerJson = System.Text.Json.JsonSerializer.Serialize(new { Op = "assignConsumer", StreamName = "s1", ConsumerName = "c1", Peers = new[] { "n1", "n2", "n3" }, }); await channel.Writer.WriteAsync(new RaftLogEntry(2, 1, consumerJson)); await monitor.WaitForProcessedAsync(2, cts.Token); meta.ConsumerCount.ShouldBe(1); var removeJson = System.Text.Json.JsonSerializer.Serialize(new { Op = "removeConsumer", StreamName = "s1", ConsumerName = "c1", }); await channel.Writer.WriteAsync(new RaftLogEntry(3, 1, removeJson)); await monitor.WaitForProcessedAsync(3, cts.Token); meta.ConsumerCount.ShouldBe(0); cts.Cancel(); await monitorTask; } [Fact] public async Task Monitor_skips_malformed_entries() { // Go reference: jetstream_cluster.go monitorCluster — malformed entries must not abort the loop var meta = new JetStreamMetaGroup(3); var channel = Channel.CreateUnbounded(); var monitor = new JetStreamClusterMonitor(meta, channel.Reader); using var cts = TestTimeout(); var monitorTask = monitor.StartAsync(cts.Token); await channel.Writer.WriteAsync(new RaftLogEntry(1, 1, "not-json")); var assignJson = System.Text.Json.JsonSerializer.Serialize(new { Op = "assignStream", StreamName = "after-bad", Peers = new[] { "n1", "n2", "n3" }, }); await channel.Writer.WriteAsync(new RaftLogEntry(2, 1, assignJson)); await monitor.WaitForProcessedAsync(2, cts.Token); meta.StreamCount.ShouldBe(1); meta.GetStreamAssignment("after-bad").ShouldNotBeNull(); cts.Cancel(); await monitorTask; } [Fact] public async Task Monitor_stops_on_cancellation() { // Go reference: jetstream_cluster.go monitorCluster shuts down cleanly when stop channel closes var meta = new JetStreamMetaGroup(3); var channel = Channel.CreateUnbounded(); var monitor = new JetStreamClusterMonitor(meta, channel.Reader); using var cts = TestTimeout(); var monitorTask = monitor.StartAsync(cts.Token); cts.Cancel(); await monitorTask; // Should complete without throwing } [Fact] public async Task Monitor_ignores_entry_with_no_op_field() { // Entries missing the "Op" property are silently ignored (forward-compat). var meta = new JetStreamMetaGroup(3); var channel = Channel.CreateUnbounded(); var monitor = new JetStreamClusterMonitor(meta, channel.Reader); using var cts = TestTimeout(); var monitorTask = monitor.StartAsync(cts.Token); await channel.Writer.WriteAsync(new RaftLogEntry(1, 1, """{"NotOp":"whatever"}""")); var assignJson = System.Text.Json.JsonSerializer.Serialize(new { Op = "assignStream", StreamName = "after-no-op", Peers = new[] { "n1" }, }); await channel.Writer.WriteAsync(new RaftLogEntry(2, 1, assignJson)); await monitor.WaitForProcessedAsync(2, cts.Token); meta.StreamCount.ShouldBe(1); cts.Cancel(); await monitorTask; } [Fact] public async Task Monitor_ignores_unknown_op() { // Unknown op names are silently ignored — forward compatibility. var meta = new JetStreamMetaGroup(3); var channel = Channel.CreateUnbounded(); var monitor = new JetStreamClusterMonitor(meta, channel.Reader); using var cts = TestTimeout(); var monitorTask = monitor.StartAsync(cts.Token); await channel.Writer.WriteAsync(new RaftLogEntry(1, 1, """{"Op":"futureFoo","Data":"xyz"}""")); var assignJson = System.Text.Json.JsonSerializer.Serialize(new { Op = "assignStream", StreamName = "after-unknown-op", Peers = new[] { "n1" }, }); await channel.Writer.WriteAsync(new RaftLogEntry(2, 1, assignJson)); await monitor.WaitForProcessedAsync(2, cts.Token); meta.StreamCount.ShouldBe(1); cts.Cancel(); await monitorTask; } [Fact] public async Task Monitor_snapshot_replaces_existing_state() { // Go reference: jetstream_cluster.go — snapshot apply wipes old assignments var meta = new JetStreamMetaGroup(3); var channel = Channel.CreateUnbounded(); var monitor = new JetStreamClusterMonitor(meta, channel.Reader); using var cts = TestTimeout(); var monitorTask = monitor.StartAsync(cts.Token); var assignJson = System.Text.Json.JsonSerializer.Serialize(new { Op = "assignStream", StreamName = "old-stream", Peers = new[] { "n1" }, }); await channel.Writer.WriteAsync(new RaftLogEntry(1, 1, assignJson)); await monitor.WaitForProcessedAsync(1, cts.Token); meta.StreamCount.ShouldBe(1); var newAssignments = new Dictionary { ["new-stream"] = new StreamAssignment { StreamName = "new-stream", Group = new RaftGroup { Name = "rg-new", Peers = ["n1", "n2", "n3"] }, }, }; var snapshotB64 = Convert.ToBase64String(MetaSnapshotCodec.Encode(newAssignments)); var snapshotJson = System.Text.Json.JsonSerializer.Serialize(new { Op = "snapshot", Data = snapshotB64, }); await channel.Writer.WriteAsync(new RaftLogEntry(2, 1, snapshotJson)); await monitor.WaitForProcessedAsync(2, cts.Token); meta.StreamCount.ShouldBe(1); meta.GetStreamAssignment("old-stream").ShouldBeNull(); meta.GetStreamAssignment("new-stream").ShouldNotBeNull(); cts.Cancel(); await monitorTask; } }