using System.Text; using System.Text.Json; using NATS.Server.JetStream.Cluster; namespace NATS.Server.JetStream.Tests.JetStream.Cluster; /// /// Tests for AssignmentCodec: binary serialization for stream and consumer assignments /// with optional S2/Snappy compression for large payloads. /// Go reference: jetstream_cluster.go:8703-9246 (encodeAddStreamAssignment, /// encodeAddConsumerAssignment, decodeStreamAssignment, decodeConsumerAssignment, /// encodeAddConsumerAssignmentCompressed, decodeConsumerAssignmentCompressed). /// public class AssignmentCodecTests { // --------------------------------------------------------------- // StreamAssignment round-trip // Go reference: jetstream_cluster.go:8703 encodeAddStreamAssignment / // 8733 decodeStreamAssignment // --------------------------------------------------------------- [Fact] public void Encode_decode_stream_assignment_round_trip() { // Go reference: jetstream_cluster.go:8703 encodeAddStreamAssignment + 8733 decodeStreamAssignment var created = new DateTime(2025, 3, 15, 9, 0, 0, DateTimeKind.Utc); var sa = new StreamAssignment { StreamName = "orders", Group = new RaftGroup { Name = "rg-orders", Peers = ["peer-1", "peer-2", "peer-3"], StorageType = "file", Cluster = "cluster-east", Preferred = "peer-1", DesiredReplicas = 3, }, Created = created, ConfigJson = """{"subjects":["orders.>"],"storage":"file","replicas":3}""", SyncSubject = "$JS.SYNC.orders", Responded = true, Recovering = false, Reassigning = true, }; var encoded = AssignmentCodec.EncodeStreamAssignment(sa); encoded.ShouldNotBeEmpty(); var decoded = AssignmentCodec.DecodeStreamAssignment(encoded); decoded.ShouldNotBeNull(); decoded!.StreamName.ShouldBe("orders"); decoded.Group.Name.ShouldBe("rg-orders"); decoded.Group.Peers.ShouldBe(["peer-1", "peer-2", "peer-3"]); decoded.Group.StorageType.ShouldBe("file"); decoded.Group.Cluster.ShouldBe("cluster-east"); decoded.Group.Preferred.ShouldBe("peer-1"); decoded.Group.DesiredReplicas.ShouldBe(3); decoded.Created.ShouldBe(created); decoded.ConfigJson.ShouldBe("""{"subjects":["orders.>"],"storage":"file","replicas":3}"""); decoded.SyncSubject.ShouldBe("$JS.SYNC.orders"); decoded.Responded.ShouldBeTrue(); decoded.Recovering.ShouldBeFalse(); decoded.Reassigning.ShouldBeTrue(); } // --------------------------------------------------------------- // ConsumerAssignment round-trip // Go reference: jetstream_cluster.go:9175 encodeAddConsumerAssignment / // 9195 decodeConsumerAssignment // --------------------------------------------------------------- [Fact] public void Encode_decode_consumer_assignment_round_trip() { // Go reference: jetstream_cluster.go:9175 encodeAddConsumerAssignment + 9195 decodeConsumerAssignment var created = new DateTime(2025, 6, 1, 12, 0, 0, DateTimeKind.Utc); var ca = new ConsumerAssignment { ConsumerName = "push-consumer", StreamName = "events", Group = new RaftGroup { Name = "rg-push", Peers = ["node-a", "node-b"], StorageType = "memory", DesiredReplicas = 2, }, Created = created, ConfigJson = """{"deliver_subject":"push.out","filter_subject":"events.>"}""", Responded = true, Recovering = true, }; var encoded = AssignmentCodec.EncodeConsumerAssignment(ca); encoded.ShouldNotBeEmpty(); var decoded = AssignmentCodec.DecodeConsumerAssignment(encoded); decoded.ShouldNotBeNull(); decoded!.ConsumerName.ShouldBe("push-consumer"); decoded.StreamName.ShouldBe("events"); decoded.Group.Name.ShouldBe("rg-push"); decoded.Group.Peers.ShouldBe(["node-a", "node-b"]); decoded.Group.StorageType.ShouldBe("memory"); decoded.Group.DesiredReplicas.ShouldBe(2); decoded.Created.ShouldBe(created); decoded.ConfigJson.ShouldBe("""{"deliver_subject":"push.out","filter_subject":"events.>"}"""); decoded.Responded.ShouldBeTrue(); decoded.Recovering.ShouldBeTrue(); } // --------------------------------------------------------------- // Error handling // Go reference: jetstream_cluster.go:8733 error return on bad unmarshal // --------------------------------------------------------------- [Fact] public void Decode_returns_null_for_invalid_data() { // Go reference: jetstream_cluster.go:8736 json.Unmarshal error → nil, error var garbage = new byte[] { 0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x01, 0x02, 0x03 }; var result = AssignmentCodec.DecodeStreamAssignment(garbage); result.ShouldBeNull(); } [Fact] public void Decode_returns_null_for_empty_data() { // Go reference: jetstream_cluster.go:8733 empty buf → json.Unmarshal fails → nil var result = AssignmentCodec.DecodeStreamAssignment(ReadOnlySpan.Empty); result.ShouldBeNull(); var caResult = AssignmentCodec.DecodeConsumerAssignment(ReadOnlySpan.Empty); caResult.ShouldBeNull(); } // --------------------------------------------------------------- // Compression: CompressIfLarge // Go reference: jetstream_cluster.go:9226 encodeAddConsumerAssignmentCompressed // uses s2.NewWriter for large consumer configs // --------------------------------------------------------------- [Fact] public void CompressIfLarge_compresses_when_above_threshold() { // Go reference: jetstream_cluster.go:9226 — S2 compression applied to large consumer assignments var largeData = Encoding.UTF8.GetBytes(new string('X', 2048)); var compressed = AssignmentCodec.CompressIfLarge(largeData, threshold: 1024); // Snappy compressed data with the stream magic is larger for uniform input but will differ // The important thing is that the result is NOT the same bytes as the input compressed.ShouldNotBeSameAs(largeData); // Compressed form of repeated bytes should typically be shorter compressed.Length.ShouldBeLessThan(largeData.Length); } [Fact] public void CompressIfLarge_no_compress_below_threshold() { // Go reference: jetstream_cluster.go — small consumer assignments sent uncompressed var smallData = Encoding.UTF8.GetBytes("""{"stream_name":"foo"}"""); var result = AssignmentCodec.CompressIfLarge(smallData, threshold: 1024); result.ShouldBe(smallData); } // --------------------------------------------------------------- // Compression: DecompressIfNeeded // Go reference: jetstream_cluster.go:9238 decodeConsumerAssignmentCompressed // --------------------------------------------------------------- [Fact] public void DecompressIfNeeded_decompresses_snappy_data() { // Go reference: jetstream_cluster.go:9238 decodeConsumerAssignmentCompressed var original = Encoding.UTF8.GetBytes("""{"stream_name":"test","group":{"name":"rg"}}"""); var compressed = AssignmentCodec.CompressIfLarge(original, threshold: 0); // force compress var decompressed = AssignmentCodec.DecompressIfNeeded(compressed); decompressed.ShouldBe(original); } [Fact] public void DecompressIfNeeded_returns_raw_for_non_compressed() { // Go reference: jetstream_cluster.go:9195 decodeConsumerAssignment (non-compressed path) var plainJson = Encoding.UTF8.GetBytes("""{"stream_name":"test"}"""); var result = AssignmentCodec.DecompressIfNeeded(plainJson); result.ShouldBe(plainJson); } // --------------------------------------------------------------- // Consumer preservation in StreamAssignment round-trip // Go reference: jetstream_cluster.go streamAssignment.Consumers map serialization // --------------------------------------------------------------- [Fact] public void Stream_assignment_preserves_consumer_assignments() { // Go reference: jetstream_cluster.go streamAssignment consumers map preserved in encoding var sa = new StreamAssignment { StreamName = "events", Group = new RaftGroup { Name = "rg-events", Peers = ["n1", "n2", "n3"] }, ConfigJson = """{"subjects":["events.>"]}""", }; sa.Consumers["consumer-alpha"] = new ConsumerAssignment { ConsumerName = "consumer-alpha", StreamName = "events", Group = new RaftGroup { Name = "rg-alpha", Peers = ["n1"] }, ConfigJson = """{"deliver_subject":"out.alpha"}""", Responded = true, }; sa.Consumers["consumer-beta"] = new ConsumerAssignment { ConsumerName = "consumer-beta", StreamName = "events", Group = new RaftGroup { Name = "rg-beta", Peers = ["n2"] }, Recovering = true, }; sa.Consumers["consumer-gamma"] = new ConsumerAssignment { ConsumerName = "consumer-gamma", StreamName = "events", Group = new RaftGroup { Name = "rg-gamma", Peers = ["n3"] }, }; var encoded = AssignmentCodec.EncodeStreamAssignment(sa); var decoded = AssignmentCodec.DecodeStreamAssignment(encoded); decoded.ShouldNotBeNull(); decoded!.Consumers.Count.ShouldBe(3); decoded.Consumers["consumer-alpha"].ConsumerName.ShouldBe("consumer-alpha"); decoded.Consumers["consumer-alpha"].Responded.ShouldBeTrue(); decoded.Consumers["consumer-beta"].Recovering.ShouldBeTrue(); decoded.Consumers["consumer-gamma"].Group.Name.ShouldBe("rg-gamma"); } // --------------------------------------------------------------- // RaftGroup peer list preservation // Go reference: jetstream_cluster.go raftGroup.Peers serialization // --------------------------------------------------------------- [Fact] public void Stream_assignment_preserves_raft_group_peers() { // Go reference: jetstream_cluster.go:154 raftGroup.Peers in assignment encoding var sa = new StreamAssignment { StreamName = "telemetry", Group = new RaftGroup { Name = "rg-telemetry", Peers = ["peer-alpha", "peer-beta", "peer-gamma"], DesiredReplicas = 3, }, }; var encoded = AssignmentCodec.EncodeStreamAssignment(sa); var decoded = AssignmentCodec.DecodeStreamAssignment(encoded); decoded.ShouldNotBeNull(); decoded!.Group.Peers.Count.ShouldBe(3); decoded.Group.Peers.ShouldContain("peer-alpha"); decoded.Group.Peers.ShouldContain("peer-beta"); decoded.Group.Peers.ShouldContain("peer-gamma"); decoded.Group.DesiredReplicas.ShouldBe(3); } // --------------------------------------------------------------- // Large ConfigJson round-trip through compression // Go reference: jetstream_cluster.go:9226 encodeAddConsumerAssignmentCompressed // for large consumer configs // --------------------------------------------------------------- [Fact] public void Compress_decompress_round_trip_with_large_config() { // Go reference: jetstream_cluster.go:9226 — compressed consumer assignment with large config var largeConfig = """{"subjects":[""" + string.Join(",", Enumerable.Range(1, 50).Select(i => $"\"events.topic.{i}.>\"")) + """],"storage":"file","replicas":3,"max_msgs":1000000,"max_bytes":1073741824}"""; var ca = new ConsumerAssignment { ConsumerName = "large-config-consumer", StreamName = "big-stream", Group = new RaftGroup { Name = "rg-large", Peers = ["n1", "n2", "n3"], }, ConfigJson = largeConfig, }; var encoded = AssignmentCodec.EncodeConsumerAssignment(ca); var compressed = AssignmentCodec.CompressIfLarge(encoded, threshold: 512); // Compressed should be present (input is large) compressed.Length.ShouldBeGreaterThan(0); var decompressed = AssignmentCodec.DecompressIfNeeded(compressed); var decoded = AssignmentCodec.DecodeConsumerAssignment(decompressed); decoded.ShouldNotBeNull(); decoded!.ConsumerName.ShouldBe("large-config-consumer"); decoded.ConfigJson.ShouldBe(largeConfig); decoded.Group.Peers.Count.ShouldBe(3); } // --------------------------------------------------------------- // Golden fixture test: known-good JSON bytes decode correctly // Go reference: jetstream_cluster.go decodeStreamAssignment / decodeConsumerAssignment // --------------------------------------------------------------- [Fact] public void Golden_fixture_known_bytes() { // Go reference: jetstream_cluster.go:8733 decodeStreamAssignment — format stability test. // This fixture encodes a specific known StreamAssignment JSON and verifies that // the codec can decode it correctly, ensuring the serialization format remains stable. // // The JSON uses snake_case property names (JsonNamingPolicy.SnakeCaseLower). // Created timestamp: 2025-01-15T00:00:00Z = 638717280000000000 ticks. const string goldenJson = """ { "stream_name": "golden-stream", "group": { "name": "rg-golden", "peers": ["node-1", "node-2", "node-3"], "storage_type": "file", "cluster": "us-east", "preferred": "node-1", "desired_replicas": 3 }, "created": "2025-01-15T00:00:00Z", "config_json": "{\"subjects\":[\"golden.>\"]}", "sync_subject": "$JS.SYNC.golden-stream", "responded": true, "recovering": false, "reassigning": false, "consumers": {} } """; var bytes = Encoding.UTF8.GetBytes(goldenJson); var decoded = AssignmentCodec.DecodeStreamAssignment(bytes); decoded.ShouldNotBeNull(); decoded!.StreamName.ShouldBe("golden-stream"); decoded.Group.Name.ShouldBe("rg-golden"); decoded.Group.Peers.ShouldBe(["node-1", "node-2", "node-3"]); decoded.Group.StorageType.ShouldBe("file"); decoded.Group.Cluster.ShouldBe("us-east"); decoded.Group.Preferred.ShouldBe("node-1"); decoded.Group.DesiredReplicas.ShouldBe(3); decoded.Created.ShouldBe(new DateTime(2025, 1, 15, 0, 0, 0, DateTimeKind.Utc)); decoded.ConfigJson.ShouldBe("""{"subjects":["golden.>"]}"""); decoded.SyncSubject.ShouldBe("$JS.SYNC.golden-stream"); decoded.Responded.ShouldBeTrue(); decoded.Recovering.ShouldBeFalse(); decoded.Reassigning.ShouldBeFalse(); decoded.Consumers.ShouldBeEmpty(); } }