// Reference: golang/nats-server/server/filestore.go:217-267 (msgBlock struct) // Go block write: filestore.go:6700-6760 (writeMsgRecord) // Go block load: filestore.go:8140-8260 (loadMsgs / msgFromBufEx) // Go deletion: filestore.go dmap (avl.SequenceSet) for soft-deletes // // These tests verify the .NET MsgBlock abstraction — a block of messages stored // in a single append-only file on disk, with in-memory index and soft-delete support. using System.Text; using NATS.Server.JetStream.Storage; namespace NATS.Server.JetStream.Tests.JetStream.Storage; public sealed class MsgBlockTests : IDisposable { private readonly string _testDir; public MsgBlockTests() { _testDir = Path.Combine(Path.GetTempPath(), $"msgblock_test_{Guid.NewGuid():N}"); Directory.CreateDirectory(_testDir); } public void Dispose() { try { Directory.Delete(_testDir, recursive: true); } catch { /* best effort cleanup */ } } // Go: writeMsgRecord — single message write returns first sequence [Fact] public void Write_SingleMessage_ReturnsSequence() { using var block = MsgBlock.Create(0, _testDir, maxBytes: 1024 * 1024); var seq = block.Write("foo.bar", ReadOnlyMemory.Empty, "hello"u8.ToArray()); seq.ShouldBe(1UL); } // Go: writeMsgRecord — sequential writes increment sequence [Fact] public void Write_MultipleMessages_IncrementsSequence() { using var block = MsgBlock.Create(0, _testDir, maxBytes: 1024 * 1024); var s1 = block.Write("a", ReadOnlyMemory.Empty, "one"u8.ToArray()); var s2 = block.Write("b", ReadOnlyMemory.Empty, "two"u8.ToArray()); var s3 = block.Write("c", ReadOnlyMemory.Empty, "three"u8.ToArray()); s1.ShouldBe(1UL); s2.ShouldBe(2UL); s3.ShouldBe(3UL); block.MessageCount.ShouldBe(3UL); } // Go: loadMsgs / msgFromBufEx — read back by sequence number [Fact] public void Read_BySequence_ReturnsMessage() { using var block = MsgBlock.Create(0, _testDir, maxBytes: 1024 * 1024); block.Write("test.subject", ReadOnlyMemory.Empty, "payload data"u8.ToArray()); var record = block.Read(1); record.ShouldNotBeNull(); record.Sequence.ShouldBe(1UL); record.Subject.ShouldBe("test.subject"); Encoding.UTF8.GetString(record.Payload.Span).ShouldBe("payload data"); } // Go: loadMsgs — reading a non-existent sequence returns nil [Fact] public void Read_NonexistentSequence_ReturnsNull() { using var block = MsgBlock.Create(0, _testDir, maxBytes: 1024 * 1024); block.Write("a", ReadOnlyMemory.Empty, "data"u8.ToArray()); var record = block.Read(999); record.ShouldBeNull(); } // Go: filestore.go rbytes check — block seals when size exceeds maxBytes [Fact] public void IsSealed_ReturnsTrueWhenFull() { // Use a very small maxBytes so the block seals quickly. // A single record with subject "a", empty headers, and 32-byte payload is ~61 bytes. // Set maxBytes to 50 so one write seals the block. using var block = MsgBlock.Create(0, _testDir, maxBytes: 50); var payload = new byte[32]; block.Write("a", ReadOnlyMemory.Empty, payload); block.IsSealed.ShouldBeTrue(); } // Go: filestore.go errNoRoom — cannot write to sealed block [Fact] public void Write_ThrowsWhenSealed() { using var block = MsgBlock.Create(0, _testDir, maxBytes: 50); block.Write("a", ReadOnlyMemory.Empty, new byte[32]); block.IsSealed.ShouldBeTrue(); Should.Throw(() => block.Write("b", ReadOnlyMemory.Empty, "more"u8.ToArray())); } // Go: dmap soft-delete — deleted message reads back as null [Fact] public void Delete_MarksSequenceAsDeleted() { using var block = MsgBlock.Create(0, _testDir, maxBytes: 1024 * 1024); block.Write("a", ReadOnlyMemory.Empty, "data"u8.ToArray()); block.Delete(1).ShouldBeTrue(); block.Read(1).ShouldBeNull(); } // Go: dmap — MessageCount reflects only non-deleted messages [Fact] public void Delete_DecreasesMessageCount() { using var block = MsgBlock.Create(0, _testDir, maxBytes: 1024 * 1024); block.Write("a", ReadOnlyMemory.Empty, "one"u8.ToArray()); block.Write("b", ReadOnlyMemory.Empty, "two"u8.ToArray()); block.Write("c", ReadOnlyMemory.Empty, "three"u8.ToArray()); block.MessageCount.ShouldBe(3UL); block.DeletedCount.ShouldBe(0UL); block.Delete(2).ShouldBeTrue(); block.MessageCount.ShouldBe(2UL); block.DeletedCount.ShouldBe(1UL); // Double delete returns false block.Delete(2).ShouldBeFalse(); } // Go: recovery path — rebuild index from existing block file [Fact] public void Recover_RebuildsIndexFromFile() { // Write messages and dispose using (var block = MsgBlock.Create(0, _testDir, maxBytes: 1024 * 1024)) { block.Write("a.b", ReadOnlyMemory.Empty, "first"u8.ToArray()); block.Write("c.d", ReadOnlyMemory.Empty, "second"u8.ToArray()); block.Write("e.f", ReadOnlyMemory.Empty, "third"u8.ToArray()); block.Flush(); } // Recover and verify all messages readable using var recovered = MsgBlock.Recover(0, _testDir); recovered.MessageCount.ShouldBe(3UL); recovered.FirstSequence.ShouldBe(1UL); recovered.LastSequence.ShouldBe(3UL); var r1 = recovered.Read(1); r1.ShouldNotBeNull(); r1.Subject.ShouldBe("a.b"); Encoding.UTF8.GetString(r1.Payload.Span).ShouldBe("first"); var r2 = recovered.Read(2); r2.ShouldNotBeNull(); r2.Subject.ShouldBe("c.d"); var r3 = recovered.Read(3); r3.ShouldNotBeNull(); r3.Subject.ShouldBe("e.f"); } // Go: recovery with dmap — deleted records still show as null after recovery [Fact] public void Recover_PreservesDeletedState() { // Write messages, delete one, flush and dispose using (var block = MsgBlock.Create(0, _testDir, maxBytes: 1024 * 1024)) { block.Write("a", ReadOnlyMemory.Empty, "one"u8.ToArray()); block.Write("b", ReadOnlyMemory.Empty, "two"u8.ToArray()); block.Write("c", ReadOnlyMemory.Empty, "three"u8.ToArray()); block.Delete(2); block.Flush(); } // Recover — seq 2 should still be deleted using var recovered = MsgBlock.Recover(0, _testDir); recovered.MessageCount.ShouldBe(2UL); recovered.DeletedCount.ShouldBe(1UL); recovered.Read(1).ShouldNotBeNull(); recovered.Read(2).ShouldBeNull(); recovered.Read(3).ShouldNotBeNull(); } // Go: sync.RWMutex on msgBlock — concurrent reads during writes should not crash [Fact] public async Task ConcurrentReads_DuringWrite() { using var block = MsgBlock.Create(0, _testDir, maxBytes: 1024 * 1024); // Pre-populate some messages for (var i = 0; i < 10; i++) block.Write($"subj.{i}", ReadOnlyMemory.Empty, Encoding.UTF8.GetBytes($"msg-{i}")); // Run concurrent reads and writes var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); var exceptions = new List(); var writerTask = Task.Run(() => { try { while (!cts.Token.IsCancellationRequested) { try { block.Write("concurrent", ReadOnlyMemory.Empty, "data"u8.ToArray()); } catch (InvalidOperationException) { // Block sealed — expected eventually break; } } } catch (Exception ex) { lock (exceptions) { exceptions.Add(ex); } } }); var readerTasks = Enumerable.Range(0, 4).Select(t => Task.Run(() => { try { while (!cts.Token.IsCancellationRequested) { for (ulong seq = 1; seq <= 10; seq++) _ = block.Read(seq); } } catch (Exception ex) { lock (exceptions) { exceptions.Add(ex); } } })).ToArray(); await Task.WhenAll([writerTask, .. readerTasks]).WaitAsync(TimeSpan.FromSeconds(5)); exceptions.ShouldBeEmpty(); } // Go: msgBlock first/last — custom firstSequence offsets sequence numbering [Fact] public void Write_WithCustomFirstSequence() { using var block = MsgBlock.Create(0, _testDir, maxBytes: 1024 * 1024, firstSequence: 100); var s1 = block.Write("x", ReadOnlyMemory.Empty, "a"u8.ToArray()); var s2 = block.Write("y", ReadOnlyMemory.Empty, "b"u8.ToArray()); s1.ShouldBe(100UL); s2.ShouldBe(101UL); block.FirstSequence.ShouldBe(100UL); block.LastSequence.ShouldBe(101UL); var r = block.Read(100); r.ShouldNotBeNull(); r.Subject.ShouldBe("x"); } }