feat: add SequenceSet for sparse deletion tracking with secure erase (Gap 1.7)
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).
This commit is contained in:
@@ -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<ulong, (long Offset, int Length)> _index = new();
|
||||
private readonly HashSet<ulong> _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<ulong> _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
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Count of soft-deleted messages.</summary>
|
||||
/// <summary>Count of soft-deleted messages. Mirrors Go's msgBlock.dmap.Size().</summary>
|
||||
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 <paramref name="secureErase"/> is <c>true</c>, 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).
|
||||
/// </summary>
|
||||
/// <param name="sequence">The sequence number to delete.</param>
|
||||
/// <param name="secureErase">
|
||||
/// When <c>true</c>, payload bytes are filled with random data before the
|
||||
/// record is written back. Defaults to <c>false</c>.
|
||||
/// </param>
|
||||
/// <returns>True if the message was deleted; false if already deleted or not found.</returns>
|
||||
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<byte> 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(); }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exposes the set of soft-deleted sequence numbers for read-only inspection.
|
||||
/// Returns a snapshot as a <see cref="HashSet{T}"/> so callers can use
|
||||
/// standard <see cref="IReadOnlySet{T}"/> operations.
|
||||
/// Reference: golang/nats-server/server/filestore.go — dmap access for state queries.
|
||||
/// </summary>
|
||||
public IReadOnlySet<ulong> DeletedSequences
|
||||
@@ -512,7 +547,7 @@ public sealed class MsgBlock : IDisposable
|
||||
get
|
||||
{
|
||||
_lock.EnterReadLock();
|
||||
try { return new HashSet<ulong>(_deleted); }
|
||||
try { return _deleted.ToHashSet(); }
|
||||
finally { _lock.ExitReadLock(); }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user