Replace HashSet<ulong> _deleted in MsgBlock with SequenceSet — a sorted-range
list that compresses contiguous deletions into (Start, End) intervals. Adds
O(log n) Contains/Add via binary search on range count, matching Go's avl.SequenceSet
semantics with a simpler implementation.
- Add SequenceSet.cs: sorted-range compressed set with Add/Remove/Contains/Count/Clear
and IEnumerable<ulong> in ascending order. Binary search for all O(log n) ops.
- Replace HashSet<ulong> _deleted and _skipSequences in MsgBlock with SequenceSet.
- Add secureErase parameter (default false) to MsgBlock.Delete(): when true, payload
bytes are overwritten with RandomNumberGenerator.Fill() before the delete record is
written, making original content unrecoverable on disk.
- Update FileStore.DeleteInBlock() to propagate secureErase flag.
- Update FileStore.EraseMsg() to use secureErase: true via block layer instead of
delegating to RemoveMsg().
- Add SequenceSetTests.cs: 25 tests covering Add, Remove, Contains, Count, range
compression, gap filling, bridge merges, enumeration, boundary values, round-trip.
- Add FileStoreTombstoneTrackingTests.cs: 12 tests covering SequenceSet tracking in
MsgBlock, tombstone persistence through RebuildIndex recovery, secure erase
payload overwrite verification, and FileStore.EraseMsg integration.
Go reference: filestore.go:5267 (removeMsg), filestore.go:5890 (eraseMsg),
avl/seqset.go (SequenceSet).
396 lines
12 KiB
C#
396 lines
12 KiB
C#
// 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;
|
|
|
|
/// <summary>
|
|
/// Unit tests for <see cref="SequenceSet"/> — the range-compressed sorted set
|
|
/// used to track soft-deleted sequences in JetStream FileStore blocks.
|
|
///
|
|
/// Reference: golang/nats-server/server/avl/seqset_test.go
|
|
/// </summary>
|
|
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<ulong>();
|
|
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);
|
|
}
|
|
}
|