// Go reference: jetstream_api.go:200-300 — API requests at non-leader nodes must be // forwarded to the current leader. Mutating operations return a not-leader error with // a leader_hint field; read-only operations are handled locally on any node. using System.Text; using NATS.Server.JetStream; using NATS.Server.JetStream.Api; using NATS.Server.JetStream.Cluster; namespace NATS.Server.Tests.JetStream.Api; public class LeaderForwardingTests { /// /// When this node IS the leader, mutating requests are handled locally. /// Go reference: jetstream_api.go — leader handles requests directly. /// [Fact] public void Route_WhenLeader_HandlesLocally() { // selfIndex=1 matches default leaderIndex=1, so this node is the leader. var metaGroup = new JetStreamMetaGroup(nodes: 3, selfIndex: 1); var streamManager = new StreamManager(metaGroup); var consumerManager = new ConsumerManager(); var router = new JetStreamApiRouter(streamManager, consumerManager, metaGroup); // Create a stream first so the purge has something to operate on. var createPayload = Encoding.UTF8.GetBytes("""{"name":"TEST","subjects":["test.>"]}"""); var createResult = router.Route("$JS.API.STREAM.CREATE.TEST", createPayload); createResult.Error.ShouldBeNull(); createResult.StreamInfo.ShouldNotBeNull(); // A mutating operation (delete) should succeed locally. var deleteResult = router.Route("$JS.API.STREAM.DELETE.TEST", ReadOnlySpan.Empty); deleteResult.Error.ShouldBeNull(); deleteResult.Success.ShouldBeTrue(); } /// /// When this node is NOT the leader, mutating operations return a not-leader error /// with the current leader's identifier in the leader_hint field. /// Go reference: jetstream_api.go:200-300 — not-leader response. /// [Fact] public void Route_WhenNotLeader_MutatingOp_ReturnsNotLeaderError() { // selfIndex=2, leaderIndex defaults to 1 — this node is NOT the leader. var metaGroup = new JetStreamMetaGroup(nodes: 3, selfIndex: 2); var streamManager = new StreamManager(metaGroup); var consumerManager = new ConsumerManager(); var router = new JetStreamApiRouter(streamManager, consumerManager, metaGroup); var payload = Encoding.UTF8.GetBytes("""{"name":"TEST","subjects":["test.>"]}"""); var result = router.Route("$JS.API.STREAM.CREATE.TEST", payload); result.Error.ShouldNotBeNull(); result.Error!.Code.ShouldBe(10003); result.Error.Description.ShouldBe("not leader"); result.Error.LeaderHint.ShouldNotBeNull(); result.Error.LeaderHint.ShouldBe("meta-1"); } /// /// Read-only operations (INFO, NAMES, LIST) are handled locally even when /// this node is not the leader. /// Go reference: jetstream_api.go — read operations do not require leadership. /// [Fact] public void Route_WhenNotLeader_ReadOp_HandlesLocally() { // selfIndex=2, leaderIndex defaults to 1 — this node is NOT the leader. var metaGroup = new JetStreamMetaGroup(nodes: 3, selfIndex: 2); var streamManager = new StreamManager(metaGroup); var consumerManager = new ConsumerManager(); var router = new JetStreamApiRouter(streamManager, consumerManager, metaGroup); // $JS.API.INFO is a read-only operation. var infoResult = router.Route("$JS.API.INFO", ReadOnlySpan.Empty); infoResult.Error.ShouldBeNull(); // $JS.API.STREAM.NAMES is a read-only operation. var namesResult = router.Route("$JS.API.STREAM.NAMES", ReadOnlySpan.Empty); namesResult.Error.ShouldBeNull(); namesResult.StreamNames.ShouldNotBeNull(); // $JS.API.STREAM.LIST is a read-only operation. var listResult = router.Route("$JS.API.STREAM.LIST", ReadOnlySpan.Empty); listResult.Error.ShouldBeNull(); listResult.StreamNames.ShouldNotBeNull(); } /// /// When there is no meta-group (single-server mode), all operations are handled /// locally regardless of the subject type. /// Go reference: jetstream_api.go — standalone servers have no meta-group. /// [Fact] public void Route_NoMetaGroup_HandlesLocally() { // No meta-group — single server mode. var streamManager = new StreamManager(); var consumerManager = new ConsumerManager(); var router = new JetStreamApiRouter(streamManager, consumerManager, metaGroup: null); var payload = Encoding.UTF8.GetBytes("""{"name":"TEST","subjects":["test.>"]}"""); var result = router.Route("$JS.API.STREAM.CREATE.TEST", payload); // Should succeed — no leader check in single-server mode. result.Error.ShouldBeNull(); result.StreamInfo.ShouldNotBeNull(); result.StreamInfo!.Config.Name.ShouldBe("TEST"); } /// /// IsLeaderRequired returns true for Create, Update, Delete, and Purge operations. /// Go reference: jetstream_api.go:200-300 — mutating operations require leader. /// [Fact] public void IsLeaderRequired_CreateUpdate_ReturnsTrue() { JetStreamApiRouter.IsLeaderRequired("$JS.API.STREAM.CREATE.TEST").ShouldBeTrue(); JetStreamApiRouter.IsLeaderRequired("$JS.API.STREAM.UPDATE.TEST").ShouldBeTrue(); JetStreamApiRouter.IsLeaderRequired("$JS.API.STREAM.DELETE.TEST").ShouldBeTrue(); JetStreamApiRouter.IsLeaderRequired("$JS.API.STREAM.PURGE.TEST").ShouldBeTrue(); JetStreamApiRouter.IsLeaderRequired("$JS.API.STREAM.RESTORE.TEST").ShouldBeTrue(); JetStreamApiRouter.IsLeaderRequired("$JS.API.STREAM.MSG.DELETE.TEST").ShouldBeTrue(); JetStreamApiRouter.IsLeaderRequired("$JS.API.CONSUMER.CREATE.STREAM.CON").ShouldBeTrue(); JetStreamApiRouter.IsLeaderRequired("$JS.API.CONSUMER.DELETE.STREAM.CON").ShouldBeTrue(); } /// /// IsLeaderRequired returns false for Info, Names, List, and other read operations. /// Go reference: jetstream_api.go — read-only operations do not need leadership. /// [Fact] public void IsLeaderRequired_InfoList_ReturnsFalse() { JetStreamApiRouter.IsLeaderRequired("$JS.API.INFO").ShouldBeFalse(); JetStreamApiRouter.IsLeaderRequired("$JS.API.STREAM.INFO.TEST").ShouldBeFalse(); JetStreamApiRouter.IsLeaderRequired("$JS.API.STREAM.NAMES").ShouldBeFalse(); JetStreamApiRouter.IsLeaderRequired("$JS.API.STREAM.LIST").ShouldBeFalse(); JetStreamApiRouter.IsLeaderRequired("$JS.API.STREAM.MSG.GET.TEST").ShouldBeFalse(); JetStreamApiRouter.IsLeaderRequired("$JS.API.STREAM.SNAPSHOT.TEST").ShouldBeFalse(); JetStreamApiRouter.IsLeaderRequired("$JS.API.CONSUMER.INFO.STREAM.CON").ShouldBeFalse(); JetStreamApiRouter.IsLeaderRequired("$JS.API.CONSUMER.NAMES.STREAM").ShouldBeFalse(); JetStreamApiRouter.IsLeaderRequired("$JS.API.CONSUMER.LIST.STREAM").ShouldBeFalse(); JetStreamApiRouter.IsLeaderRequired("$JS.API.CONSUMER.MSG.NEXT.STREAM.CON").ShouldBeFalse(); JetStreamApiRouter.IsLeaderRequired("$JS.API.DIRECT.GET.TEST").ShouldBeFalse(); } }