// Go reference: server/jetstream_api.go — jsStreamSnapshotT / jsStreamRestoreT // Tests for TAR-based stream snapshot/restore with Snappy (S2) compression. using System.Formats.Tar; using System.Text; using System.Text.Json; using IronSnappy; using NATS.Server.JetStream; using NATS.Server.JetStream.Models; using NATS.Server.JetStream.Snapshots; using NATS.Server.JetStream.Storage; using Shouldly; namespace NATS.Server.Tests.JetStream.Snapshots; public sealed class StreamSnapshotTests { // ────────────────────────────────────────────────────────────────────────── // Helpers // ────────────────────────────────────────────────────────────────────────── private static StreamHandle MakeHandle(string name = "TEST") { var config = new StreamConfig { Name = name }; IStreamStore store = new MemStore(config); return new StreamHandle(config, store); } private static async Task StoreAsync(StreamHandle h, string subject, string payload) => await h.Store.AppendAsync(subject, Encoding.UTF8.GetBytes(payload), default); private static async Task<(byte[] tarBytes, List entryNames)> DecompressAndListEntries( byte[] snappyBytes) { var tarBytes = Snappy.Decode(snappyBytes); using var ms = new MemoryStream(tarBytes); using var reader = new TarReader(ms, leaveOpen: false); var names = new List(); TarEntry? entry; while ((entry = await reader.GetNextEntryAsync()) is not null) names.Add(entry.Name); return (tarBytes, names); } // ────────────────────────────────────────────────────────────────────────── // Test 1 — stream.json is present in the TAR // ────────────────────────────────────────────────────────────────────────── // Go ref: server/jetstream_api.go:jsStreamSnapshotT — snapshot includes config JSON. [Fact] public async Task CreateTarSnapshot_includes_stream_config() { var svc = new StreamSnapshotService(); var handle = MakeHandle("MYSTREAM"); var compressed = await svc.CreateTarSnapshotAsync(handle, default); var (_, names) = await DecompressAndListEntries(compressed); names.ShouldContain("stream.json"); } // ────────────────────────────────────────────────────────────────────────── // Test 2 — all messages appear in the TAR // ────────────────────────────────────────────────────────────────────────── // Go ref: server/jetstream_api.go:jsStreamSnapshotT — snapshot contains all stored messages. [Fact] public async Task CreateTarSnapshot_includes_all_messages() { var svc = new StreamSnapshotService(); var handle = MakeHandle(); for (var i = 1; i <= 5; i++) await StoreAsync(handle, $"foo.{i}", $"payload-{i}"); var compressed = await svc.CreateTarSnapshotAsync(handle, default); var (_, names) = await DecompressAndListEntries(compressed); var messageEntries = names.Where(n => n.StartsWith("messages/")).ToList(); messageEntries.Count.ShouldBe(5); } // ────────────────────────────────────────────────────────────────────────── // Test 3 — the snapshot bytes are Snappy-compressed // ────────────────────────────────────────────────────────────────────────── // Go ref: server/filestore.go — S2 / Snappy compression is used for snapshots. [Fact] public async Task CreateTarSnapshot_compresses_with_snappy() { var svc = new StreamSnapshotService(); var handle = MakeHandle(); await StoreAsync(handle, "test.subject", "test payload data"); var compressed = await svc.CreateTarSnapshotAsync(handle, default); // Round-trip decode must succeed — this verifies it is valid Snappy data. var decoded = Should.NotThrow(() => Snappy.Decode(compressed)); decoded.ShouldNotBeEmpty(); } // ────────────────────────────────────────────────────────────────────────── // Test 4 — restore replays messages back into the store // ────────────────────────────────────────────────────────────────────────── // Go ref: server/jetstream_api.go:jsStreamRestoreT — restore re-populates the store. [Fact] public async Task RestoreTarSnapshot_replays_messages() { var svc = new StreamSnapshotService(); var handle = MakeHandle(); await StoreAsync(handle, "a.1", "first"); await StoreAsync(handle, "a.2", "second"); await StoreAsync(handle, "a.3", "third"); var compressed = await svc.CreateTarSnapshotAsync(handle, default); // Purge the store to simulate data loss. await handle.Store.PurgeAsync(default); var stateAfterPurge = await handle.Store.GetStateAsync(default); stateAfterPurge.Messages.ShouldBe(0UL); // Restore from the snapshot. await svc.RestoreTarSnapshotAsync(handle, compressed, default); var stateAfterRestore = await handle.Store.GetStateAsync(default); stateAfterRestore.Messages.ShouldBe(3UL); } // ────────────────────────────────────────────────────────────────────────── // Test 5 — restore returns correct stats // ────────────────────────────────────────────────────────────────────────── // Go ref: server/jetstream_api.go:jsStreamRestoreT — response reports bytes/messages restored. [Fact] public async Task RestoreTarSnapshot_returns_correct_stats() { var svc = new StreamSnapshotService(); var handle = MakeHandle("STATSSTREAM"); await StoreAsync(handle, "s.1", "hello"); await StoreAsync(handle, "s.2", "world"); var compressed = await svc.CreateTarSnapshotAsync(handle, default); await handle.Store.PurgeAsync(default); var result = await svc.RestoreTarSnapshotAsync(handle, compressed, default); result.StreamName.ShouldBe("STATSSTREAM"); result.MessagesRestored.ShouldBe(2); result.BytesRestored.ShouldBeGreaterThan(0L); } // ────────────────────────────────────────────────────────────────────────── // Test 6 — restore rejects a TAR that is missing stream.json // ────────────────────────────────────────────────────────────────────────── // Go ref: server/jetstream_api.go:jsStreamRestoreT — invalid snapshot returns an error. [Fact] public async Task RestoreTarSnapshot_validates_stream_json() { var svc = new StreamSnapshotService(); var handle = MakeHandle(); // Build a TAR that contains no stream.json entry. using var tarBuffer = new MemoryStream(); using (var writer = new TarWriter(tarBuffer, TarEntryFormat.Pax, leaveOpen: true)) { var bogus = new PaxTarEntry(TarEntryType.RegularFile, "other/file.txt") { DataStream = new MemoryStream("not a config"u8.ToArray()), }; await writer.WriteEntryAsync(bogus); } var badSnappy = Snappy.Encode(tarBuffer.ToArray()); await Should.ThrowAsync( () => svc.RestoreTarSnapshotAsync(handle, badSnappy, default)); } // ────────────────────────────────────────────────────────────────────────── // Test 7 — snapshot with deadline completes when deadline is generous // ────────────────────────────────────────────────────────────────────────── // Go ref: server/filestore.go — snapshot creation must respect deadlines. [Fact] public async Task CreateTarSnapshotWithDeadline_completes_within_deadline() { var svc = new StreamSnapshotService(); var handle = MakeHandle(); await StoreAsync(handle, "d.1", "data"); var compressed = await svc.CreateTarSnapshotWithDeadlineAsync( handle, TimeSpan.FromSeconds(30), default); compressed.ShouldNotBeEmpty(); } // ────────────────────────────────────────────────────────────────────────── // Test 8 — snapshot of an empty stream produces a valid archive // ────────────────────────────────────────────────────────────────────────── // Go ref: server/jetstream_api.go:jsStreamSnapshotT — empty stream is a valid snapshot. [Fact] public async Task CreateTarSnapshot_empty_stream() { var svc = new StreamSnapshotService(); var handle = MakeHandle("EMPTY"); var compressed = await svc.CreateTarSnapshotAsync(handle, default); compressed.ShouldNotBeEmpty(); var (_, names) = await DecompressAndListEntries(compressed); names.ShouldContain("stream.json"); names.Where(n => n.StartsWith("messages/")).ShouldBeEmpty(); } // ────────────────────────────────────────────────────────────────────────── // Test 9 — round-trip preserves message subjects // ────────────────────────────────────────────────────────────────────────── // Go ref: server/jetstream_api.go — snapshot/restore is lossless for subjects. [Fact] public async Task Roundtrip_snapshot_preserves_message_subjects() { var svc = new StreamSnapshotService(); var handle = MakeHandle("SUBJECTS"); var subjects = new[] { "alpha.1", "beta.2", "gamma.3" }; foreach (var s in subjects) await StoreAsync(handle, s, $"payload for {s}"); var compressed = await svc.CreateTarSnapshotAsync(handle, default); // Restore to a fresh store. var newConfig = new StreamConfig { Name = "SUBJECTS" }; IStreamStore newStore = new MemStore(newConfig); var newHandle = new StreamHandle(newConfig, newStore); await svc.RestoreTarSnapshotAsync(newHandle, compressed, default); var restored = await newStore.ListAsync(default); var restoredSubjects = restored.Select(m => m.Subject).OrderBy(x => x).ToArray(); var expectedSubjects = subjects.OrderBy(x => x).ToArray(); restoredSubjects.ShouldBe(expectedSubjects); } // ────────────────────────────────────────────────────────────────────────── // Test 10 — round-trip preserves message payloads // ────────────────────────────────────────────────────────────────────────── // Go ref: server/jetstream_api.go — snapshot/restore is lossless for payload bytes. [Fact] public async Task Roundtrip_snapshot_preserves_message_payloads() { var svc = new StreamSnapshotService(); var handle = MakeHandle("PAYLOADS"); var payloads = new[] { "hello world"u8.ToArray(), new byte[] { 0, 1, 2, 3, 255 } }; await handle.Store.AppendAsync("p.1", payloads[0], default); await handle.Store.AppendAsync("p.2", payloads[1], default); var compressed = await svc.CreateTarSnapshotAsync(handle, default); // Restore to a fresh store. var newConfig = new StreamConfig { Name = "PAYLOADS" }; IStreamStore newStore = new MemStore(newConfig); var newHandle = new StreamHandle(newConfig, newStore); await svc.RestoreTarSnapshotAsync(newHandle, compressed, default); var restored = (await newStore.ListAsync(default)).OrderBy(m => m.Sequence).ToList(); restored.Count.ShouldBe(2); restored[0].Payload.ToArray().ShouldBe(payloads[0]); restored[1].Payload.ToArray().ShouldBe(payloads[1]); } }