feat(storage): add MsgBlock block-based message storage unit
MsgBlock is the unit of storage in the file store — a single append-only block file containing sequentially written binary message records. Blocks are sealed (read-only) when they reach a configurable byte-size limit. Key features: - Write: appends MessageRecord-encoded messages with auto-incrementing sequence numbers and configurable first sequence offset - Read: positional I/O via RandomAccess.Read for concurrent reader safety - Delete: soft-delete with on-disk persistence (re-encodes flags byte + checksum so deletions survive recovery) - Recovery: rebuilds in-memory index by scanning block file using MessageRecord.MeasureRecord for record boundary detection - Thread safety: ReaderWriterLockSlim allows concurrent reads during writes Also adds MessageRecord.MeasureRecord() — computes a record's byte length by parsing varint field headers without full decode, needed for sequential record scanning during block recovery. Reference: golang/nats-server/server/filestore.go:217-267 (msgBlock struct) 12 tests covering write, read, delete, seal, recovery, concurrency, and custom sequence offsets.
This commit is contained in:
263
tests/NATS.Server.Tests/JetStream/Storage/MsgBlockTests.cs
Normal file
263
tests/NATS.Server.Tests/JetStream/Storage/MsgBlockTests.cs
Normal file
@@ -0,0 +1,263 @@
|
||||
// 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.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<byte>.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<byte>.Empty, "one"u8.ToArray());
|
||||
var s2 = block.Write("b", ReadOnlyMemory<byte>.Empty, "two"u8.ToArray());
|
||||
var s3 = block.Write("c", ReadOnlyMemory<byte>.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<byte>.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<byte>.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<byte>.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<byte>.Empty, new byte[32]);
|
||||
block.IsSealed.ShouldBeTrue();
|
||||
|
||||
Should.Throw<InvalidOperationException>(() =>
|
||||
block.Write("b", ReadOnlyMemory<byte>.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<byte>.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<byte>.Empty, "one"u8.ToArray());
|
||||
block.Write("b", ReadOnlyMemory<byte>.Empty, "two"u8.ToArray());
|
||||
block.Write("c", ReadOnlyMemory<byte>.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<byte>.Empty, "first"u8.ToArray());
|
||||
block.Write("c.d", ReadOnlyMemory<byte>.Empty, "second"u8.ToArray());
|
||||
block.Write("e.f", ReadOnlyMemory<byte>.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<byte>.Empty, "one"u8.ToArray());
|
||||
block.Write("b", ReadOnlyMemory<byte>.Empty, "two"u8.ToArray());
|
||||
block.Write("c", ReadOnlyMemory<byte>.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<byte>.Empty, Encoding.UTF8.GetBytes($"msg-{i}"));
|
||||
|
||||
// Run concurrent reads and writes
|
||||
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2));
|
||||
var exceptions = new List<Exception>();
|
||||
|
||||
var writerTask = Task.Run(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
while (!cts.Token.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
block.Write("concurrent", ReadOnlyMemory<byte>.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<byte>.Empty, "a"u8.ToArray());
|
||||
var s2 = block.Write("y", ReadOnlyMemory<byte>.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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user