diff --git a/src/NATS.Server/JetStream/Storage/FileStore.cs b/src/NATS.Server/JetStream/Storage/FileStore.cs index ffade76..12e510d 100644 --- a/src/NATS.Server/JetStream/Storage/FileStore.cs +++ b/src/NATS.Server/JetStream/Storage/FileStore.cs @@ -732,14 +732,17 @@ public sealed class FileStore : IStreamStore, IAsyncDisposable, IDisposable /// /// Soft-deletes a message in the block that contains it. + /// When is true, payload bytes are + /// overwritten with random data before the delete record is written. + /// Reference: golang/nats-server/server/filestore.go:5890 (eraseMsg). /// - private void DeleteInBlock(ulong sequence) + private void DeleteInBlock(ulong sequence, bool secureErase = false) { foreach (var block in _blocks) { if (sequence >= block.FirstSequence && sequence <= block.LastSequence) { - block.Delete(sequence); + block.Delete(sequence, secureErase); return; } } @@ -1386,15 +1389,25 @@ public sealed class FileStore : IStreamStore, IAsyncDisposable, IDisposable } /// - /// Overwrites a message with zeros and then soft-deletes it. + /// Secure-erases a message: overwrites its payload bytes with random data on disk, + /// then soft-deletes it (same in-memory semantics as ). /// Returns true if the sequence existed and was erased. - /// Reference: golang/nats-server/server/filestore.go — EraseMsg. + /// Reference: golang/nats-server/server/filestore.go:5890 (eraseMsg). /// public bool EraseMsg(ulong seq) { - // In .NET we don't do physical overwrite — just remove from the in-memory - // cache and soft-delete in the block layer (same semantics as RemoveMsg). - return RemoveMsg(seq); + if (!_messages.Remove(seq, out _)) + return false; + + if (_messages.Count == 0) + _first = _last + 1; + else + _first = _messages.Keys.Min(); + + // Secure erase: overwrite payload bytes with random data before marking deleted. + // Reference: golang/nats-server/server/filestore.go:5890 (eraseMsg). + DeleteInBlock(seq, secureErase: true); + return true; } /// diff --git a/src/NATS.Server/JetStream/Storage/MsgBlock.cs b/src/NATS.Server/JetStream/Storage/MsgBlock.cs index 78ed6b3..7abc5e2 100644 --- a/src/NATS.Server/JetStream/Storage/MsgBlock.cs +++ b/src/NATS.Server/JetStream/Storage/MsgBlock.cs @@ -10,6 +10,7 @@ // sequentially as binary records (using MessageRecord). Blocks are sealed // (read-only) when they reach a configurable size limit. +using System.Security.Cryptography; using Microsoft.Win32.SafeHandles; namespace NATS.Server.JetStream.Storage; @@ -25,10 +26,14 @@ public sealed class MsgBlock : IDisposable private readonly FileStream _file; private readonly SafeFileHandle _handle; private readonly Dictionary _index = new(); - private readonly HashSet _deleted = new(); + // Go: msgBlock.dmap — avl.SequenceSet for sparse deletion tracking. + // Reference: golang/nats-server/server/avl/seqset.go (SequenceSet). + // .NET uses a sorted-range list (see SequenceSet.cs) for O(log n) ops with + // range compression for contiguous deletion runs (TTL, bulk remove). + private readonly SequenceSet _deleted = new(); // Go: SkipMsg writes tombstone records with empty subject — tracked separately so // recovery can distinguish intentional sequence gaps from soft-deleted messages. - private readonly HashSet _skipSequences = new(); + private readonly SequenceSet _skipSequences = new(); private readonly long _maxBytes; private readonly ReaderWriterLockSlim _lock = new(); private long _writeOffset; // Tracks the append position independently of FileStream.Position @@ -95,7 +100,7 @@ public sealed class MsgBlock : IDisposable } } - /// Count of soft-deleted messages. + /// Count of soft-deleted messages. Mirrors Go's msgBlock.dmap.Size(). public ulong DeletedCount { get @@ -357,10 +362,18 @@ public sealed class MsgBlock : IDisposable /// Soft-deletes a message by sequence number. Re-encodes the record on disk /// with the deleted flag set (and updated checksum) so the deletion survives recovery. /// Also evicts the sequence from the write cache. + /// When is true, the payload bytes inside + /// the encoded record are overwritten with cryptographically random data before + /// the record is re-written — ensuring the original payload is unrecoverable. + /// Reference: golang/nats-server/server/filestore.go:5890 (eraseMsg). /// /// The sequence number to delete. + /// + /// When true, payload bytes are filled with random data before the + /// record is written back. Defaults to false. + /// /// True if the message was deleted; false if already deleted or not found. - public bool Delete(ulong sequence) + public bool Delete(ulong sequence, bool secureErase = false) { _lock.EnterWriteLock(); try @@ -377,12 +390,23 @@ public sealed class MsgBlock : IDisposable RandomAccess.Read(_handle, buffer, entry.Offset); var record = MessageRecord.Decode(buffer); + ReadOnlyMemory payload = record.Payload; + if (secureErase && payload.Length > 0) + { + // Go: eraseMsg — overwrite payload region with random bytes so the + // original content is unrecoverable from disk. + // Reference: golang/nats-server/server/filestore.go:5890 (eraseMsg). + var randomPayload = new byte[payload.Length]; + RandomNumberGenerator.Fill(randomPayload); + payload = randomPayload; + } + var deletedRecord = new MessageRecord { Sequence = record.Sequence, Subject = record.Subject, Headers = record.Headers, - Payload = record.Payload, + Payload = payload, Timestamp = record.Timestamp, Deleted = true, }; @@ -498,13 +522,24 @@ public sealed class MsgBlock : IDisposable get { _lock.EnterReadLock(); - try { return _skipSequences.Count > 0 ? _skipSequences.Max() : 0UL; } + try + { + if (_skipSequences.IsEmpty) + return 0UL; + // SequenceSet enumerates in ascending order; last element is max. + ulong max = 0; + foreach (var seq in _skipSequences) + max = seq; // last wins since iteration is ascending + return max; + } finally { _lock.ExitReadLock(); } } } /// /// Exposes the set of soft-deleted sequence numbers for read-only inspection. + /// Returns a snapshot as a so callers can use + /// standard operations. /// Reference: golang/nats-server/server/filestore.go — dmap access for state queries. /// public IReadOnlySet DeletedSequences @@ -512,7 +547,7 @@ public sealed class MsgBlock : IDisposable get { _lock.EnterReadLock(); - try { return new HashSet(_deleted); } + try { return _deleted.ToHashSet(); } finally { _lock.ExitReadLock(); } } } diff --git a/src/NATS.Server/JetStream/Storage/SequenceSet.cs b/src/NATS.Server/JetStream/Storage/SequenceSet.cs new file mode 100644 index 0000000..11bf7e1 --- /dev/null +++ b/src/NATS.Server/JetStream/Storage/SequenceSet.cs @@ -0,0 +1,230 @@ +// Reference: golang/nats-server/server/avl/seqset.go +// Go uses an AVL tree with bitmask nodes (2048 sequences per node). +// .NET port uses a sorted list of (Start, End) ranges — simpler, still O(log n) +// via binary search for the common case of mostly-sequential deletions in JetStream. +// +// Range compression: adding sequences 1, 2, 3 stores as a single range [1, 3]. +// This is significantly more memory efficient than HashSet for contiguous +// deletion runs (e.g. TTL expiry, bulk removes), which are the dominant pattern. +// +// Not thread-safe — callers must hold their own lock (MsgBlock uses ReaderWriterLockSlim). + +namespace NATS.Server.JetStream.Storage; + +/// +/// A memory-efficient sparse set for storing unsigned sequence numbers, using +/// range compression to merge contiguous sequences into (Start, End) intervals. +/// +/// Analogous to Go's avl.SequenceSet but implemented with a sorted list +/// of ranges for simplicity. Binary search gives O(log n) Contains/Add on the +/// number of distinct ranges (not the total count of sequences). +/// +/// Reference: golang/nats-server/server/avl/seqset.go (SequenceSet struct). +/// +internal sealed class SequenceSet : IEnumerable +{ + // Sorted list of non-overlapping, non-adjacent ranges in ascending order. + // Invariant: for all i, ranges[i].End + 1 < ranges[i+1].Start (strict gap between consecutive ranges). + private readonly List<(ulong Start, ulong End)> _ranges = []; + + /// Total number of sequences across all ranges. + public int Count + { + get + { + var total = 0; + foreach (var (start, end) in _ranges) + total += (int)(end - start + 1); + return total; + } + } + + /// True when the set contains no sequences. + public bool IsEmpty => _ranges.Count == 0; + + /// + /// Adds to the set. + /// Merges adjacent or overlapping ranges automatically. + /// Returns true if the sequence was not already present. + /// Reference: golang/nats-server/server/avl/seqset.go:44 (Insert). + /// + public bool Add(ulong seq) + { + // Strategy: find the position where seq belongs (binary search by Start), + // then check if seq is already in the previous range or the range at that position, + // and determine left/right adjacency for merging. + + // Binary search: find the first index i where _ranges[i].Start > seq. + // Everything before i has Start <= seq. The range that might contain seq + // is at i-1 (if it exists and its End >= seq). + var lo = 0; + var hi = _ranges.Count; // exclusive upper bound + while (lo < hi) + { + var mid = lo + (hi - lo) / 2; + if (_ranges[mid].Start <= seq) + lo = mid + 1; + else + hi = mid; + } + // lo == first index where Start > seq, so lo-1 is the last range with Start <= seq. + var leftIdx = lo - 1; // may be -1 if no range has Start <= seq + + // Check if seq is inside the range at leftIdx. + if (leftIdx >= 0 && _ranges[leftIdx].End >= seq) + return false; // already present + + // At this point seq is not in any range. Determine insertion context. + // leftIdx: the range immediately to the left of seq (Start <= seq, End < seq) + // rightIdx = lo: the range immediately to the right of seq (Start > seq) + var rightIdx = lo; + + // Check adjacency with left neighbor (End + 1 == seq, i.e. seq extends the right edge). + // Safe: if End == ulong.MaxValue the range would already contain seq. + var leftAdjacent = leftIdx >= 0 && _ranges[leftIdx].End + 1 == seq; + + // Check adjacency with right neighbor (Start - 1 == seq, i.e. seq extends the left edge). + // Need to guard against Start == 0 (underflow), but if Start == 0 then Start > seq is impossible + // since seq >= 0 and Start <= seq would have been caught by leftIdx check. + var rightAdjacent = rightIdx < _ranges.Count && _ranges[rightIdx].Start > 0 + && _ranges[rightIdx].Start - 1 == seq; + + if (leftAdjacent && rightAdjacent) + { + // seq bridges the gap between the two neighbors → merge into one range. + var newStart = _ranges[leftIdx].Start; + var newEnd = _ranges[rightIdx].End; + _ranges.RemoveAt(rightIdx); // remove right neighbor first (higher index) + _ranges[leftIdx] = (newStart, newEnd); + } + else if (leftAdjacent) + { + // Extend the left neighbor's right edge to include seq. + _ranges[leftIdx] = (_ranges[leftIdx].Start, seq); + } + else if (rightAdjacent) + { + // Extend the right neighbor's left edge to include seq. + _ranges[rightIdx] = (seq, _ranges[rightIdx].End); + } + else + { + // No adjacency — insert a new single-element range at the correct position. + _ranges.Insert(rightIdx, (seq, seq)); + } + + return true; + } + + /// + /// Removes from the set. + /// Splits ranges if necessary. + /// Returns true if the sequence was present. + /// Reference: golang/nats-server/server/avl/seqset.go:80 (Delete). + /// + public bool Remove(ulong seq) + { + // Binary search for the range that contains seq. + var lo = 0; + var hi = _ranges.Count - 1; + while (lo <= hi) + { + var mid = lo + (hi - lo) / 2; + var (rs, re) = _ranges[mid]; + if (seq < rs) + hi = mid - 1; + else if (seq > re) + lo = mid + 1; + else + { + // Found the range [rs, re] that contains seq. + if (rs == re) + { + // Single-element range → remove entirely. + _ranges.RemoveAt(mid); + } + else if (seq == rs) + { + // Trim left edge. + _ranges[mid] = (rs + 1, re); + } + else if (seq == re) + { + // Trim right edge. + _ranges[mid] = (rs, re - 1); + } + else + { + // Split: [rs, seq-1] and [seq+1, re]. + _ranges[mid] = (seq + 1, re); + _ranges.Insert(mid, (rs, seq - 1)); + } + return true; + } + } + return false; + } + + /// + /// Returns true if is a member of the set. + /// Binary search: O(log R) where R is the number of distinct ranges. + /// Reference: golang/nats-server/server/avl/seqset.go:52 (Exists). + /// + public bool Contains(ulong seq) + { + var lo = 0; + var hi = _ranges.Count - 1; + while (lo <= hi) + { + var mid = lo + (hi - lo) / 2; + var (rs, re) = _ranges[mid]; + if (seq < rs) + hi = mid - 1; + else if (seq > re) + lo = mid + 1; + else + return true; + } + return false; + } + + /// + /// Removes all sequences from the set. + /// Reference: golang/nats-server/server/avl/seqset.go:107 (Empty). + /// + public void Clear() => _ranges.Clear(); + + /// + /// Copies all sequences in this set into a new . + /// Used when callers require an snapshot. + /// + public HashSet ToHashSet() + { + var set = new HashSet(Count); + foreach (var seq in this) + set.Add(seq); + return set; + } + + /// + /// Returns the number of distinct compressed ranges stored internally. + /// + internal int RangeCount => _ranges.Count; + + /// + /// Enumerates all sequences in ascending order. + /// Reference: golang/nats-server/server/avl/seqset.go:122 (Range). + /// + public IEnumerator GetEnumerator() + { + foreach (var (start, end) in _ranges) + for (var seq = start; seq <= end; seq++) + { + yield return seq; + if (seq == ulong.MaxValue) yield break; // prevent overflow + } + } + + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() + => GetEnumerator(); +} diff --git a/tests/NATS.Server.Tests/JetStream/Storage/FileStoreTombstoneTrackingTests.cs b/tests/NATS.Server.Tests/JetStream/Storage/FileStoreTombstoneTrackingTests.cs new file mode 100644 index 0000000..4329265 --- /dev/null +++ b/tests/NATS.Server.Tests/JetStream/Storage/FileStoreTombstoneTrackingTests.cs @@ -0,0 +1,358 @@ +// Reference: golang/nats-server/server/filestore.go:5267 (removeMsg) +// golang/nats-server/server/filestore.go:5890 (eraseMsg) +// +// Tests verifying: +// 1. SequenceSet correctly tracks deleted sequences in MsgBlock +// 2. Tombstones survive MsgBlock recovery (RebuildIndex populates SequenceSet) +// 3. Secure erase (Delete with secureErase=true) overwrites payload bytes +// 4. EraseMsg at FileStore level marks the sequence as deleted +// +// Go test analogs: +// TestFileStoreEraseMsgDoesNotLoseTombstones (filestore_test.go:10781) +// TestFileStoreTombstonesNoFirstSeqRollback (filestore_test.go:10911) +// TestFileStoreRemoveMsg (filestore_test.go:5267) + +using System.Security.Cryptography; +using System.Text; +using NATS.Server.JetStream.Storage; + +namespace NATS.Server.Tests.JetStream.Storage; + +/// +/// Tests for SequenceSet-backed deletion tracking and secure erase in MsgBlock. +/// Reference: golang/nats-server/server/filestore.go eraseMsg / removeMsg. +/// +public sealed class FileStoreTombstoneTrackingTests : IDisposable +{ + private readonly string _testDir; + + public FileStoreTombstoneTrackingTests() + { + _testDir = Path.Combine(Path.GetTempPath(), $"nats-tombstone-tracking-{Guid.NewGuid():N}"); + Directory.CreateDirectory(_testDir); + } + + public void Dispose() + { + if (Directory.Exists(_testDir)) + Directory.Delete(_testDir, recursive: true); + } + + private string UniqueDir() + { + var dir = Path.Combine(_testDir, Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + return dir; + } + + // ------------------------------------------------------------------------- + // SequenceSet tracking in MsgBlock + // ------------------------------------------------------------------------- + + // Go: removeMsg — after Delete, IsDeleted returns true and DeletedCount == 1 + [Fact] + public void MsgBlock_Delete_TracksDeletionInSequenceSet() + { + var dir = UniqueDir(); + using var block = MsgBlock.Create(0, dir, maxBytes: 1024 * 1024); + + block.Write("a", ReadOnlyMemory.Empty, "payload"u8.ToArray()); + block.Write("b", ReadOnlyMemory.Empty, "payload"u8.ToArray()); + block.Write("c", ReadOnlyMemory.Empty, "payload"u8.ToArray()); + + block.Delete(2).ShouldBeTrue(); + + block.IsDeleted(2).ShouldBeTrue(); + block.IsDeleted(1).ShouldBeFalse(); + block.IsDeleted(3).ShouldBeFalse(); + block.DeletedCount.ShouldBe(1UL); + block.MessageCount.ShouldBe(2UL); + } + + // Multiple deletes tracked correctly — SequenceSet merges contiguous ranges. + [Fact] + public void MsgBlock_MultipleDeletes_AllTrackedInSequenceSet() + { + var dir = UniqueDir(); + using var block = MsgBlock.Create(0, dir, maxBytes: 1024 * 1024); + + for (var i = 0; i < 10; i++) + block.Write($"subj.{i}", ReadOnlyMemory.Empty, "payload"u8.ToArray()); + + // Delete seqs 3, 4, 5 (contiguous — SequenceSet will merge into one range). + block.Delete(3).ShouldBeTrue(); + block.Delete(4).ShouldBeTrue(); + block.Delete(5).ShouldBeTrue(); + + block.DeletedCount.ShouldBe(3UL); + block.MessageCount.ShouldBe(7UL); + + block.IsDeleted(3).ShouldBeTrue(); + block.IsDeleted(4).ShouldBeTrue(); + block.IsDeleted(5).ShouldBeTrue(); + block.IsDeleted(2).ShouldBeFalse(); + block.IsDeleted(6).ShouldBeFalse(); + } + + // ------------------------------------------------------------------------- + // Tombstones survive recovery (RebuildIndex populates SequenceSet) + // ------------------------------------------------------------------------- + + // Go: TestFileStoreTombstonesNoFirstSeqRollback — after restart, deleted seqs still deleted. + // Reference: filestore.go RebuildIndex reads ebit from block file. + [Fact] + public void MsgBlock_Recovery_TombstonesInSequenceSet() + { + var dir = UniqueDir(); + + // Phase 1: write messages and delete one, then close. + using (var block = MsgBlock.Create(0, dir, 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); // marks seq 2 with ebit on disk + block.Flush(); + } + + // Phase 2: recover from file — SequenceSet must be populated by RebuildIndex. + using var recovered = MsgBlock.Recover(0, dir); + recovered.DeletedCount.ShouldBe(1UL); + recovered.MessageCount.ShouldBe(2UL); + recovered.IsDeleted(1).ShouldBeFalse(); + recovered.IsDeleted(2).ShouldBeTrue(); + recovered.IsDeleted(3).ShouldBeFalse(); + + // Read should return null for deleted seq. + recovered.Read(2).ShouldBeNull(); + recovered.Read(1).ShouldNotBeNull(); + recovered.Read(3).ShouldNotBeNull(); + } + + // Multiple tombstones survive recovery. + [Fact] + public void MsgBlock_Recovery_MultipleDeletedSeqs_AllInSequenceSet() + { + var dir = UniqueDir(); + + using (var block = MsgBlock.Create(0, dir, maxBytes: 1024 * 1024)) + { + for (var i = 0; i < 10; i++) + block.Write($"subj", ReadOnlyMemory.Empty, "payload"u8.ToArray()); + + block.Delete(1); + block.Delete(3); + block.Delete(5); + block.Delete(7); + block.Delete(9); + block.Flush(); + } + + using var recovered = MsgBlock.Recover(0, dir); + recovered.DeletedCount.ShouldBe(5UL); + recovered.MessageCount.ShouldBe(5UL); + + for (ulong seq = 1; seq <= 9; seq += 2) + recovered.IsDeleted(seq).ShouldBeTrue($"seq {seq} should be deleted"); + for (ulong seq = 2; seq <= 10; seq += 2) + recovered.IsDeleted(seq).ShouldBeFalse($"seq {seq} should NOT be deleted"); + } + + // Skip records (WriteSkip) survive recovery and appear in SequenceSet. + [Fact] + public void MsgBlock_Recovery_SkipRecordsInSequenceSet() + { + var dir = UniqueDir(); + + using (var block = MsgBlock.Create(0, dir, maxBytes: 1024 * 1024, firstSequence: 1)) + { + block.Write("a", ReadOnlyMemory.Empty, "payload"u8.ToArray()); // seq=1 + block.WriteSkip(2); // tombstone + block.WriteSkip(3); // tombstone + block.Write("b", ReadOnlyMemory.Empty, "payload"u8.ToArray()); // seq=4 + block.Flush(); + } + + using var recovered = MsgBlock.Recover(0, dir); + // Seqs 2 and 3 are skip records → deleted. + recovered.IsDeleted(2).ShouldBeTrue(); + recovered.IsDeleted(3).ShouldBeTrue(); + recovered.IsDeleted(1).ShouldBeFalse(); + recovered.IsDeleted(4).ShouldBeFalse(); + recovered.DeletedCount.ShouldBe(2UL); + recovered.MessageCount.ShouldBe(2UL); + } + + // ------------------------------------------------------------------------- + // Secure erase — payload bytes are overwritten with random data + // ------------------------------------------------------------------------- + + // Go: eraseMsg (filestore.go:5890) — payload bytes replaced with random bytes. + [Fact] + public void MsgBlock_SecureErase_OverwritesPayloadBytes() + { + var dir = UniqueDir(); + var original = Encoding.UTF8.GetBytes("this is a secret payload"); + + using (var block = MsgBlock.Create(0, dir, maxBytes: 1024 * 1024)) + { + block.Write("secret", ReadOnlyMemory.Empty, original); + + // Perform secure erase — overwrites payload bytes in-place on disk. + block.Delete(1, secureErase: true).ShouldBeTrue(); + block.Flush(); + } + + // Read the raw block file and verify the original payload bytes are gone. + var blockFile = Path.Combine(dir, "000000.blk"); + var rawBytes = File.ReadAllBytes(blockFile); + + // The payload "this is a secret payload" should no longer appear as a substring. + var payloadBytes = Encoding.UTF8.GetBytes("this is a secret"); + var rawAsSpan = rawBytes.AsSpan(); + var found = false; + for (var i = 0; i <= rawBytes.Length - payloadBytes.Length; i++) + { + if (rawAsSpan[i..].StartsWith(payloadBytes.AsSpan())) + { + found = true; + break; + } + } + found.ShouldBeFalse("Secret payload bytes should have been overwritten by secure erase"); + } + + // After secure erase, the message appears deleted (returns null on Read). + [Fact] + public void MsgBlock_SecureErase_MessageAppearsDeleted() + { + var dir = UniqueDir(); + using var block = MsgBlock.Create(0, dir, maxBytes: 1024 * 1024); + + block.Write("sensitive", ReadOnlyMemory.Empty, "secret data"u8.ToArray()); + block.Write("other", ReadOnlyMemory.Empty, "normal"u8.ToArray()); + + block.Delete(1, secureErase: true).ShouldBeTrue(); + + block.IsDeleted(1).ShouldBeTrue(); + block.Read(1).ShouldBeNull(); + block.Read(2).ShouldNotBeNull(); // other message unaffected + block.DeletedCount.ShouldBe(1UL); + block.MessageCount.ShouldBe(1UL); + } + + // Secure erase with secureErase=false is identical to regular delete (no overwrite). + [Fact] + public void MsgBlock_Delete_WithSecureEraseFalse_NormalDelete() + { + var dir = UniqueDir(); + using var block = MsgBlock.Create(0, dir, maxBytes: 1024 * 1024); + + block.Write("x", ReadOnlyMemory.Empty, "content"u8.ToArray()); + block.Delete(1, secureErase: false).ShouldBeTrue(); + block.IsDeleted(1).ShouldBeTrue(); + block.Read(1).ShouldBeNull(); + } + + // Double secure erase returns false on second call. + [Fact] + public void MsgBlock_SecureErase_DoubleErase_ReturnsFalse() + { + var dir = UniqueDir(); + using var block = MsgBlock.Create(0, dir, maxBytes: 1024 * 1024); + + block.Write("x", ReadOnlyMemory.Empty, "content"u8.ToArray()); + block.Delete(1, secureErase: true).ShouldBeTrue(); + block.Delete(1, secureErase: true).ShouldBeFalse(); // already deleted + } + + // ------------------------------------------------------------------------- + // DeletedSequences property returns snapshot of SequenceSet + // ------------------------------------------------------------------------- + + // DeletedSequences snapshot contains all deleted seqs (still IReadOnlySet from HashSet copy). + [Fact] + public void DeletedSequences_ReturnsCorrectSnapshot() + { + var dir = UniqueDir(); + using var block = MsgBlock.Create(0, dir, 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.Write("d", ReadOnlyMemory.Empty, "four"u8.ToArray()); + + block.Delete(2); + block.Delete(4); + + var snapshot = block.DeletedSequences; + snapshot.Count.ShouldBe(2); + snapshot.ShouldContain(2UL); + snapshot.ShouldContain(4UL); + snapshot.ShouldNotContain(1UL); + snapshot.ShouldNotContain(3UL); + } + + // ------------------------------------------------------------------------- + // FileStore EraseMsg integration + // ------------------------------------------------------------------------- + + // Go: eraseMsg — after EraseMsg, message is gone and state reflects deletion. + [Fact] + public void FileStore_EraseMsg_MessageGoneAfterErase() + { + var dir = UniqueDir(); + var opts = new FileStoreOptions { Directory = dir }; + using var store = new FileStore(opts); + + store.StoreMsg("foo", null, "secret"u8.ToArray(), 0); + store.StoreMsg("foo", null, "normal"u8.ToArray(), 0); + + var state1 = store.State(); + state1.Msgs.ShouldBe(2UL); + + store.EraseMsg(1).ShouldBeTrue(); + + var state2 = store.State(); + state2.Msgs.ShouldBe(1UL); + + // Erasing same seq twice returns false. + store.EraseMsg(1).ShouldBeFalse(); + } + + // Go: TestFileStoreEraseMsgDoesNotLoseTombstones — erase does not disturb other tombstones. + // Reference: filestore_test.go:10781 + [Fact] + public void FileStore_EraseMsg_DoesNotLoseTombstones() + { + var dir = UniqueDir(); + var opts = new FileStoreOptions { Directory = dir }; + using var store = new FileStore(opts); + + store.StoreMsg("foo", null, [], 0); // seq=1 + store.StoreMsg("foo", null, [], 0); // seq=2 (tombstone) + store.StoreMsg("foo", null, "secret"u8.ToArray(), 0); // seq=3 (erased) + + store.RemoveMsg(2); // tombstone seq=2 + store.StoreMsg("foo", null, [], 0); // seq=4 + + store.EraseMsg(3); // erase seq=3 + + var state = store.State(); + state.Msgs.ShouldBe(2UL); // msgs 1 and 4 remain + state.NumDeleted.ShouldBe(2); // seqs 2 and 3 deleted + state.Deleted.ShouldNotBeNull(); + state.Deleted!.ShouldContain(2UL); + state.Deleted.ShouldContain(3UL); + + // Restart — state should be identical. + store.Dispose(); + using var store2 = new FileStore(opts); + var after = store2.State(); + after.Msgs.ShouldBe(2UL); + after.NumDeleted.ShouldBe(2); + after.Deleted.ShouldNotBeNull(); + after.Deleted!.ShouldContain(2UL); + after.Deleted.ShouldContain(3UL); + } +} diff --git a/tests/NATS.Server.Tests/JetStream/Storage/SequenceSetTests.cs b/tests/NATS.Server.Tests/JetStream/Storage/SequenceSetTests.cs new file mode 100644 index 0000000..7639dea --- /dev/null +++ b/tests/NATS.Server.Tests/JetStream/Storage/SequenceSetTests.cs @@ -0,0 +1,395 @@ +// Reference: golang/nats-server/server/avl/seqset_test.go +// Tests ported / inspired by: +// TestSequenceSetBasic → Add_Contains_Count_BasicOperations +// TestSequenceSetRange → GetEnumerator_ReturnsAscendingOrder +// TestSequenceSetDelete → Remove_SplitsAndTrimsRanges +// (range compression) → Add_ContiguousSequences_CompressesToOneRange +// (binary search) → Contains_BinarySearchCorrectness +// (boundary) → Add_Remove_AtBoundaries + +using NATS.Server.JetStream.Storage; + +namespace NATS.Server.Tests.JetStream.Storage; + +/// +/// Unit tests for — the range-compressed sorted set +/// used to track soft-deleted sequences in JetStream FileStore blocks. +/// +/// Reference: golang/nats-server/server/avl/seqset_test.go +/// +public sealed class SequenceSetTests +{ + // ------------------------------------------------------------------------- + // Basic Add / Contains / Count + // ------------------------------------------------------------------------- + + // Go: TestSequenceSetBasic — empty set has zero count + [Fact] + public void Count_EmptySet_ReturnsZero() + { + var ss = new SequenceSet(); + ss.Count.ShouldBe(0); + ss.IsEmpty.ShouldBeTrue(); + } + + // Go: TestSequenceSetBasic — single element is found + [Fact] + public void Add_SingleSequence_ContainsIt() + { + var ss = new SequenceSet(); + ss.Add(42).ShouldBeTrue(); + ss.Contains(42).ShouldBeTrue(); + ss.Count.ShouldBe(1); + ss.IsEmpty.ShouldBeFalse(); + } + + // Go: duplicate insert returns false (already present) + [Fact] + public void Add_DuplicateSequence_ReturnsFalse() + { + var ss = new SequenceSet(); + ss.Add(10).ShouldBeTrue(); + ss.Add(10).ShouldBeFalse(); + ss.Count.ShouldBe(1); + } + + // Go: non-member returns false on Contains + [Fact] + public void Contains_NonMember_ReturnsFalse() + { + var ss = new SequenceSet(); + ss.Add(5); + ss.Contains(4).ShouldBeFalse(); + ss.Contains(6).ShouldBeFalse(); + ss.Contains(0).ShouldBeFalse(); + ss.Contains(ulong.MaxValue).ShouldBeFalse(); + } + + // ------------------------------------------------------------------------- + // Range compression + // ------------------------------------------------------------------------- + + // Adding three contiguous sequences should compress to a single range. + // This is the key efficiency property of SequenceSet vs HashSet. + [Fact] + public void Add_ContiguousSequences_CompressesToOneRange() + { + var ss = new SequenceSet(); + ss.Add(1); + ss.Add(2); + ss.Add(3); + + ss.Count.ShouldBe(3); + ss.RangeCount.ShouldBe(1); // single range [1, 3] + ss.Contains(1).ShouldBeTrue(); + ss.Contains(2).ShouldBeTrue(); + ss.Contains(3).ShouldBeTrue(); + } + + // Adding in reverse order should still compress. + [Fact] + public void Add_ContiguousReverse_CompressesToOneRange() + { + var ss = new SequenceSet(); + ss.Add(3); + ss.Add(2); + ss.Add(1); + + ss.Count.ShouldBe(3); + ss.RangeCount.ShouldBe(1); // single range [1, 3] + } + + // Two separate gaps should stay as two ranges. + [Fact] + public void Add_WithGap_TwoRanges() + { + var ss = new SequenceSet(); + ss.Add(1); + ss.Add(2); + ss.Add(4); // gap at 3 + ss.Add(5); + + ss.Count.ShouldBe(4); + ss.RangeCount.ShouldBe(2); // [1,2] and [4,5] + } + + // Filling the gap merges to one range. + [Fact] + public void Add_FillsGap_MergesToOneRange() + { + var ss = new SequenceSet(); + ss.Add(1); + ss.Add(2); + ss.Add(4); + ss.Add(5); + ss.RangeCount.ShouldBe(2); + + // Fill the gap. + ss.Add(3); + ss.RangeCount.ShouldBe(1); // [1, 5] + ss.Count.ShouldBe(5); + } + + // Large run of contiguous sequences stays as one range. + [Fact] + public void Add_LargeContiguousRun_OnlyOneRange() + { + var ss = new SequenceSet(); + for (ulong i = 1; i <= 10_000; i++) + ss.Add(i); + + ss.Count.ShouldBe(10_000); + ss.RangeCount.ShouldBe(1); + } + + // ------------------------------------------------------------------------- + // Remove / split / trim + // ------------------------------------------------------------------------- + + // Removing from an empty set returns false. + [Fact] + public void Remove_EmptySet_ReturnsFalse() + { + var ss = new SequenceSet(); + ss.Remove(1).ShouldBeFalse(); + } + + // Removing a non-member returns false and doesn't change count. + [Fact] + public void Remove_NonMember_ReturnsFalse() + { + var ss = new SequenceSet(); + ss.Add(5); + ss.Remove(4).ShouldBeFalse(); + ss.Count.ShouldBe(1); + } + + // Removing the only element empties the set. + [Fact] + public void Remove_SingleElement_EmptiesSet() + { + var ss = new SequenceSet(); + ss.Add(7); + ss.Remove(7).ShouldBeTrue(); + ss.Count.ShouldBe(0); + ss.IsEmpty.ShouldBeTrue(); + ss.Contains(7).ShouldBeFalse(); + } + + // Removing the left edge of a range trims it. + [Fact] + public void Remove_LeftEdge_TrimsRange() + { + var ss = new SequenceSet(); + ss.Add(1); ss.Add(2); ss.Add(3); + ss.RangeCount.ShouldBe(1); + + ss.Remove(1).ShouldBeTrue(); + ss.Count.ShouldBe(2); + ss.Contains(1).ShouldBeFalse(); + ss.Contains(2).ShouldBeTrue(); + ss.Contains(3).ShouldBeTrue(); + ss.RangeCount.ShouldBe(1); // still one range [2, 3] + } + + // Removing the right edge of a range trims it. + [Fact] + public void Remove_RightEdge_TrimsRange() + { + var ss = new SequenceSet(); + ss.Add(1); ss.Add(2); ss.Add(3); + + ss.Remove(3).ShouldBeTrue(); + ss.Count.ShouldBe(2); + ss.Contains(3).ShouldBeFalse(); + ss.RangeCount.ShouldBe(1); // still [1, 2] + } + + // Removing the middle element splits a range into two. + [Fact] + public void Remove_MiddleElement_SplitsRange() + { + var ss = new SequenceSet(); + ss.Add(1); ss.Add(2); ss.Add(3); ss.Add(4); ss.Add(5); + ss.RangeCount.ShouldBe(1); + + ss.Remove(3).ShouldBeTrue(); + ss.Count.ShouldBe(4); + ss.Contains(3).ShouldBeFalse(); + ss.Contains(1).ShouldBeTrue(); + ss.Contains(2).ShouldBeTrue(); + ss.Contains(4).ShouldBeTrue(); + ss.Contains(5).ShouldBeTrue(); + ss.RangeCount.ShouldBe(2); // [1,2] and [4,5] + } + + // ------------------------------------------------------------------------- + // Enumeration + // ------------------------------------------------------------------------- + + // GetEnumerator returns all sequences in ascending order. + [Fact] + public void GetEnumerator_ReturnsAscendingOrder() + { + var ss = new SequenceSet(); + ss.Add(5); ss.Add(3); ss.Add(1); ss.Add(2); ss.Add(4); + + var list = ss.ToList(); + list.ShouldBe([1, 2, 3, 4, 5]); + } + + // Enumeration over a compressed range expands correctly. + [Fact] + public void GetEnumerator_CompressedRange_ExpandsAll() + { + var ss = new SequenceSet(); + for (ulong i = 100; i <= 200; i++) + ss.Add(i); + + var list = ss.ToList(); + list.Count.ShouldBe(101); + list[0].ShouldBe(100UL); + list[^1].ShouldBe(200UL); + } + + // Enumeration over multiple disjoint ranges returns all in order. + [Fact] + public void GetEnumerator_MultipleRanges_AllInOrder() + { + var ss = new SequenceSet(); + ss.Add(10); ss.Add(11); + ss.Add(20); ss.Add(21); ss.Add(22); + ss.Add(30); + + var list = ss.ToList(); + list.ShouldBe([10UL, 11UL, 20UL, 21UL, 22UL, 30UL]); + } + + // ------------------------------------------------------------------------- + // Clear + // ------------------------------------------------------------------------- + + [Fact] + public void Clear_RemovesAll() + { + var ss = new SequenceSet(); + ss.Add(1); ss.Add(2); ss.Add(3); + ss.Clear(); + ss.Count.ShouldBe(0); + ss.IsEmpty.ShouldBeTrue(); + ss.Contains(1).ShouldBeFalse(); + } + + // ------------------------------------------------------------------------- + // ToHashSet snapshot + // ------------------------------------------------------------------------- + + [Fact] + public void ToHashSet_ReturnsAllElements() + { + var ss = new SequenceSet(); + ss.Add(1); ss.Add(2); ss.Add(5); ss.Add(6); ss.Add(7); + + var hs = ss.ToHashSet(); + hs.Count.ShouldBe(5); + hs.ShouldContain(1UL); + hs.ShouldContain(2UL); + hs.ShouldContain(5UL); + hs.ShouldContain(6UL); + hs.ShouldContain(7UL); + } + + // ------------------------------------------------------------------------- + // Binary search correctness — sparse insertions + // ------------------------------------------------------------------------- + + // Reference: Go seqset_test.go — large number of non-contiguous sequences. + [Fact] + public void Add_Contains_SparseInsertions_AllFound() + { + var ss = new SequenceSet(); + var expected = new List(); + for (ulong i = 1; i <= 1000; i += 3) // every 3rd: 1, 4, 7, ... + { + ss.Add(i); + expected.Add(i); + } + + ss.Count.ShouldBe(expected.Count); + + foreach (var seq in expected) + ss.Contains(seq).ShouldBeTrue($"Expected seq {seq} to be present"); + + // Non-members should not appear. + ss.Contains(2).ShouldBeFalse(); + ss.Contains(3).ShouldBeFalse(); + ss.Contains(999).ShouldBeFalse(); + } + + // ------------------------------------------------------------------------- + // Boundary conditions + // ------------------------------------------------------------------------- + + [Fact] + public void Add_SequenceZero_Works() + { + var ss = new SequenceSet(); + ss.Add(0).ShouldBeTrue(); + ss.Contains(0).ShouldBeTrue(); + ss.Count.ShouldBe(1); + } + + [Fact] + public void Add_AdjacentToZero_Merges() + { + var ss = new SequenceSet(); + ss.Add(0); + ss.Add(1); + ss.RangeCount.ShouldBe(1); // [0, 1] + ss.Count.ShouldBe(2); + } + + [Fact] + public void Add_Remove_RoundTrip() + { + var ss = new SequenceSet(); + for (ulong i = 1; i <= 100; i++) + ss.Add(i); + + // Remove all odd sequences. + for (ulong i = 1; i <= 100; i += 2) + ss.Remove(i); + + ss.Count.ShouldBe(50); + for (ulong i = 2; i <= 100; i += 2) + ss.Contains(i).ShouldBeTrue(); + for (ulong i = 1; i <= 99; i += 2) + ss.Contains(i).ShouldBeFalse(); + } + + // ------------------------------------------------------------------------- + // Merging at boundaries of existing ranges (not just single adjacency) + // ------------------------------------------------------------------------- + + [Fact] + public void Add_BridgesMultipleGaps_CorrectState() + { + var ss = new SequenceSet(); + // Create three separate ranges: [1,2], [4,5], [7,8] + ss.Add(1); ss.Add(2); + ss.Add(4); ss.Add(5); + ss.Add(7); ss.Add(8); + ss.RangeCount.ShouldBe(3); + ss.Count.ShouldBe(6); + + // Fill gap between [1,2] and [4,5]: add 3 + ss.Add(3); + ss.RangeCount.ShouldBe(2); // [1,5] and [7,8] + ss.Count.ShouldBe(7); + + // Fill gap between [1,5] and [7,8]: add 6 + ss.Add(6); + ss.RangeCount.ShouldBe(1); // [1,8] + ss.Count.ShouldBe(8); + } +}