feat(storage): rewrite FileStore to use block-based MsgBlock storage
Replace JSONL persistence with real MsgBlock-based block files (.blk). FileStore now acts as a block manager that creates, seals, and rotates MsgBlocks while maintaining an in-memory cache for fast reads/queries. Key changes: - AppendAsync writes transformed payloads to MsgBlock via WriteAt - Block rotation occurs when active block reaches size limit - Recovery scans .blk files and rebuilds in-memory state from records - Legacy JSONL migration: existing messages.jsonl data is automatically converted to block files on first open, then JSONL is deleted - PurgeAsync disposes and deletes all block files - RewriteBlocks rebuilds blocks from cache (used by trim/restore) - InvalidDataException propagates during recovery (wrong encryption key) MsgBlock.WriteAt added to support explicit sequence numbers and timestamps, needed when rewriting blocks with non-contiguous sequences (after removes). Tests updated: - New FileStoreBlockTests.cs with 9 tests for block-specific behavior - JetStreamFileStoreCompressionEncryptionParityTests updated to read FSV1 magic from .blk files instead of messages.jsonl - JetStreamFileStoreDurabilityParityTests updated to verify .blk files instead of index.manifest.json All 3,562 tests pass (3,535 passed + 27 skipped, 0 failures).
This commit is contained in:
@@ -10,23 +10,34 @@ using ApiStreamState = NATS.Server.JetStream.Models.ApiStreamState;
|
|||||||
|
|
||||||
namespace NATS.Server.JetStream.Storage;
|
namespace NATS.Server.JetStream.Storage;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Block-based file store for JetStream messages. Uses <see cref="MsgBlock"/> for
|
||||||
|
/// on-disk persistence and maintains an in-memory cache (<see cref="StoredMessage"/>)
|
||||||
|
/// for fast reads and subject queries.
|
||||||
|
///
|
||||||
|
/// Reference: golang/nats-server/server/filestore.go — block manager, block rotation,
|
||||||
|
/// recovery via scanning .blk files, soft-delete via dmap.
|
||||||
|
/// </summary>
|
||||||
public sealed class FileStore : IStreamStore, IAsyncDisposable
|
public sealed class FileStore : IStreamStore, IAsyncDisposable
|
||||||
{
|
{
|
||||||
private readonly FileStoreOptions _options;
|
private readonly FileStoreOptions _options;
|
||||||
private readonly string _dataFilePath;
|
|
||||||
private readonly string _manifestPath;
|
// In-memory cache: keyed by sequence number. This is the primary data structure
|
||||||
|
// for reads and queries. The blocks are the on-disk persistence layer.
|
||||||
private readonly Dictionary<ulong, StoredMessage> _messages = new();
|
private readonly Dictionary<ulong, StoredMessage> _messages = new();
|
||||||
private readonly Dictionary<ulong, BlockPointer> _index = new();
|
|
||||||
|
// Block-based storage: the active (writable) block and sealed blocks.
|
||||||
|
private readonly List<MsgBlock> _blocks = [];
|
||||||
|
private MsgBlock? _activeBlock;
|
||||||
|
private int _nextBlockId;
|
||||||
|
|
||||||
private ulong _last;
|
private ulong _last;
|
||||||
private int _blockCount;
|
|
||||||
private long _activeBlockBytes;
|
|
||||||
private long _writeOffset;
|
|
||||||
|
|
||||||
// Resolved at construction time: which format family to use.
|
// Resolved at construction time: which format family to use.
|
||||||
private readonly bool _useS2; // true → S2Codec (FSV2 compression path)
|
private readonly bool _useS2; // true -> S2Codec (FSV2 compression path)
|
||||||
private readonly bool _useAead; // true → AeadEncryptor (FSV2 encryption path)
|
private readonly bool _useAead; // true -> AeadEncryptor (FSV2 encryption path)
|
||||||
|
|
||||||
public int BlockCount => _messages.Count == 0 ? 0 : Math.Max(_blockCount, 1);
|
public int BlockCount => _blocks.Count;
|
||||||
public bool UsedIndexManifestOnStartup { get; private set; }
|
public bool UsedIndexManifestOnStartup { get; private set; }
|
||||||
|
|
||||||
public FileStore(FileStoreOptions options)
|
public FileStore(FileStoreOptions options)
|
||||||
@@ -40,10 +51,10 @@ public sealed class FileStore : IStreamStore, IAsyncDisposable
|
|||||||
_useAead = _options.Cipher != StoreCipher.NoCipher;
|
_useAead = _options.Cipher != StoreCipher.NoCipher;
|
||||||
|
|
||||||
Directory.CreateDirectory(options.Directory);
|
Directory.CreateDirectory(options.Directory);
|
||||||
_dataFilePath = Path.Combine(options.Directory, "messages.jsonl");
|
|
||||||
_manifestPath = Path.Combine(options.Directory, _options.IndexManifestFileName);
|
// Attempt legacy JSONL migration first, then recover from blocks.
|
||||||
LoadBlockIndexManifestOnStartup();
|
MigrateLegacyJsonl();
|
||||||
LoadExisting();
|
RecoverBlocks();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async ValueTask<ulong> AppendAsync(string subject, ReadOnlyMemory<byte> payload, CancellationToken ct)
|
public async ValueTask<ulong> AppendAsync(string subject, ReadOnlyMemory<byte> payload, CancellationToken ct)
|
||||||
@@ -51,28 +62,36 @@ public sealed class FileStore : IStreamStore, IAsyncDisposable
|
|||||||
PruneExpired(DateTime.UtcNow);
|
PruneExpired(DateTime.UtcNow);
|
||||||
|
|
||||||
_last++;
|
_last++;
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var timestamp = new DateTimeOffset(now).ToUnixTimeMilliseconds() * 1_000_000L;
|
||||||
var persistedPayload = TransformForPersist(payload.Span);
|
var persistedPayload = TransformForPersist(payload.Span);
|
||||||
var stored = new StoredMessage
|
var stored = new StoredMessage
|
||||||
{
|
{
|
||||||
Sequence = _last,
|
Sequence = _last,
|
||||||
Subject = subject,
|
Subject = subject,
|
||||||
Payload = payload.ToArray(),
|
Payload = payload.ToArray(),
|
||||||
TimestampUtc = DateTime.UtcNow,
|
TimestampUtc = now,
|
||||||
};
|
};
|
||||||
_messages[_last] = stored;
|
_messages[_last] = stored;
|
||||||
|
|
||||||
var line = JsonSerializer.Serialize(new FileRecord
|
// Write to MsgBlock. The payload stored in the block is the transformed
|
||||||
|
// (compressed/encrypted) payload, not the plaintext.
|
||||||
|
EnsureActiveBlock();
|
||||||
|
try
|
||||||
{
|
{
|
||||||
Sequence = stored.Sequence,
|
_activeBlock!.WriteAt(_last, subject, ReadOnlyMemory<byte>.Empty, persistedPayload, timestamp);
|
||||||
Subject = stored.Subject,
|
}
|
||||||
PayloadBase64 = Convert.ToBase64String(persistedPayload),
|
catch (InvalidOperationException)
|
||||||
TimestampUtc = stored.TimestampUtc,
|
{
|
||||||
});
|
// Block is sealed. Rotate to a new block and retry.
|
||||||
await File.AppendAllTextAsync(_dataFilePath, line + Environment.NewLine, ct);
|
RotateBlock();
|
||||||
|
_activeBlock!.WriteAt(_last, subject, ReadOnlyMemory<byte>.Empty, persistedPayload, timestamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the block just became sealed after this write.
|
||||||
|
if (_activeBlock!.IsSealed)
|
||||||
|
RotateBlock();
|
||||||
|
|
||||||
var recordBytes = Encoding.UTF8.GetByteCount(line + Environment.NewLine);
|
|
||||||
TrackBlockForRecord(recordBytes, stored.Sequence);
|
|
||||||
PersistBlockIndexManifest(_manifestPath, _index);
|
|
||||||
return _last;
|
return _last;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,23 +125,31 @@ public sealed class FileStore : IStreamStore, IAsyncDisposable
|
|||||||
{
|
{
|
||||||
if (sequence == _last)
|
if (sequence == _last)
|
||||||
_last = _messages.Count == 0 ? 0UL : _messages.Keys.Max();
|
_last = _messages.Count == 0 ? 0UL : _messages.Keys.Max();
|
||||||
RewriteDataFile();
|
|
||||||
|
// Soft-delete in the block that contains this sequence.
|
||||||
|
DeleteInBlock(sequence);
|
||||||
}
|
}
|
||||||
|
|
||||||
return ValueTask.FromResult(removed);
|
return ValueTask.FromResult(removed);
|
||||||
}
|
}
|
||||||
|
|
||||||
public ValueTask PurgeAsync(CancellationToken ct)
|
public ValueTask PurgeAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
_messages.Clear();
|
_messages.Clear();
|
||||||
_index.Clear();
|
|
||||||
_last = 0;
|
_last = 0;
|
||||||
_blockCount = 0;
|
|
||||||
_activeBlockBytes = 0;
|
// Dispose and delete all blocks.
|
||||||
_writeOffset = 0;
|
DisposeAllBlocks();
|
||||||
if (File.Exists(_dataFilePath))
|
CleanBlockFiles();
|
||||||
File.Delete(_dataFilePath);
|
|
||||||
if (File.Exists(_manifestPath))
|
// Clean up any legacy files that might still exist.
|
||||||
File.Delete(_manifestPath);
|
var jsonlPath = Path.Combine(_options.Directory, "messages.jsonl");
|
||||||
|
if (File.Exists(jsonlPath))
|
||||||
|
File.Delete(jsonlPath);
|
||||||
|
var manifestPath = Path.Combine(_options.Directory, _options.IndexManifestFileName);
|
||||||
|
if (File.Exists(manifestPath))
|
||||||
|
File.Delete(manifestPath);
|
||||||
|
|
||||||
return ValueTask.CompletedTask;
|
return ValueTask.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,11 +172,11 @@ public sealed class FileStore : IStreamStore, IAsyncDisposable
|
|||||||
public ValueTask RestoreSnapshotAsync(ReadOnlyMemory<byte> snapshot, CancellationToken ct)
|
public ValueTask RestoreSnapshotAsync(ReadOnlyMemory<byte> snapshot, CancellationToken ct)
|
||||||
{
|
{
|
||||||
_messages.Clear();
|
_messages.Clear();
|
||||||
_index.Clear();
|
|
||||||
_last = 0;
|
_last = 0;
|
||||||
_blockCount = 0;
|
|
||||||
_activeBlockBytes = 0;
|
// Dispose existing blocks and clean files.
|
||||||
_writeOffset = 0;
|
DisposeAllBlocks();
|
||||||
|
CleanBlockFiles();
|
||||||
|
|
||||||
if (!snapshot.IsEmpty)
|
if (!snapshot.IsEmpty)
|
||||||
{
|
{
|
||||||
@@ -158,11 +185,12 @@ public sealed class FileStore : IStreamStore, IAsyncDisposable
|
|||||||
{
|
{
|
||||||
foreach (var record in records)
|
foreach (var record in records)
|
||||||
{
|
{
|
||||||
|
var restoredPayload = RestorePayload(Convert.FromBase64String(record.PayloadBase64 ?? string.Empty));
|
||||||
var message = new StoredMessage
|
var message = new StoredMessage
|
||||||
{
|
{
|
||||||
Sequence = record.Sequence,
|
Sequence = record.Sequence,
|
||||||
Subject = record.Subject ?? string.Empty,
|
Subject = record.Subject ?? string.Empty,
|
||||||
Payload = RestorePayload(Convert.FromBase64String(record.PayloadBase64 ?? string.Empty)),
|
Payload = restoredPayload,
|
||||||
TimestampUtc = record.TimestampUtc,
|
TimestampUtc = record.TimestampUtc,
|
||||||
};
|
};
|
||||||
_messages[record.Sequence] = message;
|
_messages[record.Sequence] = message;
|
||||||
@@ -171,7 +199,8 @@ public sealed class FileStore : IStreamStore, IAsyncDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
RewriteDataFile();
|
// Write all messages to fresh blocks.
|
||||||
|
RewriteBlocks();
|
||||||
return ValueTask.CompletedTask;
|
return ValueTask.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,144 +223,302 @@ public sealed class FileStore : IStreamStore, IAsyncDisposable
|
|||||||
_messages.Remove(first);
|
_messages.Remove(first);
|
||||||
}
|
}
|
||||||
|
|
||||||
RewriteDataFile();
|
// Rewrite blocks to reflect the trim (removes trimmed messages from disk).
|
||||||
|
RewriteBlocks();
|
||||||
}
|
}
|
||||||
|
|
||||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
public ValueTask DisposeAsync()
|
||||||
|
|
||||||
private void LoadExisting()
|
|
||||||
{
|
{
|
||||||
if (!File.Exists(_dataFilePath))
|
DisposeAllBlocks();
|
||||||
|
return ValueTask.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Block management
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ensures an active (writable) block exists. Creates one if needed.
|
||||||
|
/// </summary>
|
||||||
|
private void EnsureActiveBlock()
|
||||||
|
{
|
||||||
|
if (_activeBlock is null || _activeBlock.IsSealed)
|
||||||
|
RotateBlock();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a new active block. The previous active block (if any) stays in the
|
||||||
|
/// block list as a sealed block. The firstSequence is set to _last + 1 (the next
|
||||||
|
/// expected sequence), but actual sequences come from WriteAt calls.
|
||||||
|
/// </summary>
|
||||||
|
private void RotateBlock()
|
||||||
|
{
|
||||||
|
var firstSeq = _last + 1;
|
||||||
|
var block = MsgBlock.Create(_nextBlockId, _options.Directory, _options.BlockSizeBytes, firstSeq);
|
||||||
|
_blocks.Add(block);
|
||||||
|
_activeBlock = block;
|
||||||
|
_nextBlockId++;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Soft-deletes a message in the block that contains it.
|
||||||
|
/// </summary>
|
||||||
|
private void DeleteInBlock(ulong sequence)
|
||||||
|
{
|
||||||
|
foreach (var block in _blocks)
|
||||||
|
{
|
||||||
|
if (sequence >= block.FirstSequence && sequence <= block.LastSequence)
|
||||||
|
{
|
||||||
|
block.Delete(sequence);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Disposes all blocks and clears the block list.
|
||||||
|
/// </summary>
|
||||||
|
private void DisposeAllBlocks()
|
||||||
|
{
|
||||||
|
foreach (var block in _blocks)
|
||||||
|
block.Dispose();
|
||||||
|
_blocks.Clear();
|
||||||
|
_activeBlock = null;
|
||||||
|
_nextBlockId = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deletes all .blk files in the store directory.
|
||||||
|
/// </summary>
|
||||||
|
private void CleanBlockFiles()
|
||||||
|
{
|
||||||
|
if (!Directory.Exists(_options.Directory))
|
||||||
return;
|
return;
|
||||||
|
|
||||||
foreach (var line in File.ReadLines(_dataFilePath))
|
foreach (var blkFile in Directory.GetFiles(_options.Directory, "*.blk"))
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(line))
|
try { File.Delete(blkFile); }
|
||||||
|
catch { /* best effort */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Rewrites all blocks from the in-memory message cache. Used after trim,
|
||||||
|
/// snapshot restore, or legacy migration.
|
||||||
|
/// </summary>
|
||||||
|
private void RewriteBlocks()
|
||||||
|
{
|
||||||
|
DisposeAllBlocks();
|
||||||
|
CleanBlockFiles();
|
||||||
|
|
||||||
|
_last = _messages.Count == 0 ? 0UL : _messages.Keys.Max();
|
||||||
|
|
||||||
|
foreach (var message in _messages.OrderBy(kv => kv.Key).Select(kv => kv.Value))
|
||||||
|
{
|
||||||
|
var persistedPayload = TransformForPersist(message.Payload.Span);
|
||||||
|
var timestamp = new DateTimeOffset(message.TimestampUtc).ToUnixTimeMilliseconds() * 1_000_000L;
|
||||||
|
|
||||||
|
EnsureActiveBlock();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_activeBlock!.WriteAt(message.Sequence, message.Subject, ReadOnlyMemory<byte>.Empty, persistedPayload, timestamp);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException)
|
||||||
|
{
|
||||||
|
RotateBlock();
|
||||||
|
_activeBlock!.WriteAt(message.Sequence, message.Subject, ReadOnlyMemory<byte>.Empty, persistedPayload, timestamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_activeBlock!.IsSealed)
|
||||||
|
RotateBlock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Recovery: scan .blk files on startup and rebuild in-memory state.
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Recovers all blocks from .blk files in the store directory.
|
||||||
|
/// </summary>
|
||||||
|
private void RecoverBlocks()
|
||||||
|
{
|
||||||
|
var blkFiles = Directory.GetFiles(_options.Directory, "*.blk");
|
||||||
|
if (blkFiles.Length == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Sort by block ID (filename is like "000000.blk", "000001.blk", ...).
|
||||||
|
Array.Sort(blkFiles, StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
var maxBlockId = -1;
|
||||||
|
|
||||||
|
foreach (var blkFile in blkFiles)
|
||||||
|
{
|
||||||
|
var fileName = Path.GetFileNameWithoutExtension(blkFile);
|
||||||
|
if (!int.TryParse(fileName, out var blockId))
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
var record = JsonSerializer.Deserialize<FileRecord>(line);
|
try
|
||||||
if (record == null)
|
{
|
||||||
continue;
|
var block = MsgBlock.Recover(blockId, _options.Directory);
|
||||||
|
_blocks.Add(block);
|
||||||
|
|
||||||
|
if (blockId > maxBlockId)
|
||||||
|
maxBlockId = blockId;
|
||||||
|
|
||||||
|
// Read all non-deleted records from this block and populate the in-memory cache.
|
||||||
|
RecoverMessagesFromBlock(block);
|
||||||
|
}
|
||||||
|
catch (InvalidDataException)
|
||||||
|
{
|
||||||
|
// InvalidDataException indicates key mismatch or integrity failure —
|
||||||
|
// propagate so the caller knows the store cannot be opened.
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Skip corrupted blocks — non-critical recovery errors.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_nextBlockId = maxBlockId + 1;
|
||||||
|
|
||||||
|
// The last block is the active block if it has capacity (not sealed).
|
||||||
|
if (_blocks.Count > 0)
|
||||||
|
{
|
||||||
|
var lastBlock = _blocks[^1];
|
||||||
|
_activeBlock = lastBlock;
|
||||||
|
}
|
||||||
|
|
||||||
|
PruneExpired(DateTime.UtcNow);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads all non-deleted records from a block and adds them to the in-memory cache.
|
||||||
|
/// </summary>
|
||||||
|
private void RecoverMessagesFromBlock(MsgBlock block)
|
||||||
|
{
|
||||||
|
// We need to iterate through all sequences in the block.
|
||||||
|
// MsgBlock tracks first/last sequence, so we try each one.
|
||||||
|
var first = block.FirstSequence;
|
||||||
|
var last = block.LastSequence;
|
||||||
|
|
||||||
|
if (first == 0 && last == 0)
|
||||||
|
return; // Empty block.
|
||||||
|
|
||||||
|
for (var seq = first; seq <= last; seq++)
|
||||||
|
{
|
||||||
|
var record = block.Read(seq);
|
||||||
|
if (record is null)
|
||||||
|
continue; // Deleted or not present.
|
||||||
|
|
||||||
|
// The payload stored in the block is the transformed (compressed/encrypted) payload.
|
||||||
|
// We need to reverse-transform it to get the original plaintext.
|
||||||
|
// InvalidDataException (e.g., wrong key) propagates to the caller.
|
||||||
|
var originalPayload = RestorePayload(record.Payload.Span);
|
||||||
|
|
||||||
var message = new StoredMessage
|
var message = new StoredMessage
|
||||||
{
|
{
|
||||||
Sequence = record.Sequence,
|
Sequence = record.Sequence,
|
||||||
Subject = record.Subject ?? string.Empty,
|
Subject = record.Subject,
|
||||||
Payload = RestorePayload(Convert.FromBase64String(record.PayloadBase64 ?? string.Empty)),
|
Payload = originalPayload,
|
||||||
TimestampUtc = record.TimestampUtc,
|
TimestampUtc = DateTimeOffset.FromUnixTimeMilliseconds(record.Timestamp / 1_000_000L).UtcDateTime,
|
||||||
};
|
};
|
||||||
|
|
||||||
_messages[message.Sequence] = message;
|
_messages[message.Sequence] = message;
|
||||||
if (message.Sequence > _last)
|
if (message.Sequence > _last)
|
||||||
_last = message.Sequence;
|
_last = message.Sequence;
|
||||||
|
|
||||||
if (!UsedIndexManifestOnStartup || !_index.ContainsKey(message.Sequence))
|
|
||||||
{
|
|
||||||
var recordBytes = Encoding.UTF8.GetByteCount(line + Environment.NewLine);
|
|
||||||
TrackBlockForRecord(recordBytes, message.Sequence);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
PruneExpired(DateTime.UtcNow);
|
|
||||||
PersistBlockIndexManifest(_manifestPath, _index);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void RewriteDataFile()
|
// -------------------------------------------------------------------------
|
||||||
|
// Legacy JSONL migration: if messages.jsonl exists, migrate to blocks.
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Migrates data from the legacy JSONL format to block-based storage.
|
||||||
|
/// If messages.jsonl exists, reads all records, writes them to blocks,
|
||||||
|
/// then deletes the JSONL file and manifest.
|
||||||
|
/// </summary>
|
||||||
|
private void MigrateLegacyJsonl()
|
||||||
{
|
{
|
||||||
Directory.CreateDirectory(Path.GetDirectoryName(_dataFilePath)!);
|
var jsonlPath = Path.Combine(_options.Directory, "messages.jsonl");
|
||||||
_index.Clear();
|
if (!File.Exists(jsonlPath))
|
||||||
_blockCount = 0;
|
|
||||||
_activeBlockBytes = 0;
|
|
||||||
_writeOffset = 0;
|
|
||||||
_last = _messages.Count == 0 ? 0UL : _messages.Keys.Max();
|
|
||||||
|
|
||||||
using var stream = new FileStream(_dataFilePath, FileMode.Create, FileAccess.Write, FileShare.Read);
|
|
||||||
using var writer = new StreamWriter(stream, Encoding.UTF8);
|
|
||||||
|
|
||||||
foreach (var message in _messages.OrderBy(kv => kv.Key).Select(kv => kv.Value))
|
|
||||||
{
|
|
||||||
var line = JsonSerializer.Serialize(new FileRecord
|
|
||||||
{
|
|
||||||
Sequence = message.Sequence,
|
|
||||||
Subject = message.Subject,
|
|
||||||
PayloadBase64 = Convert.ToBase64String(TransformForPersist(message.Payload.Span)),
|
|
||||||
TimestampUtc = message.TimestampUtc,
|
|
||||||
});
|
|
||||||
|
|
||||||
writer.WriteLine(line);
|
|
||||||
var recordBytes = Encoding.UTF8.GetByteCount(line + Environment.NewLine);
|
|
||||||
TrackBlockForRecord(recordBytes, message.Sequence);
|
|
||||||
}
|
|
||||||
|
|
||||||
writer.Flush();
|
|
||||||
PersistBlockIndexManifest(_manifestPath, _index);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void LoadBlockIndexManifestOnStartup()
|
|
||||||
{
|
|
||||||
if (!File.Exists(_manifestPath))
|
|
||||||
return;
|
return;
|
||||||
|
|
||||||
try
|
// Read all records from the JSONL file.
|
||||||
{
|
var legacyMessages = new List<(ulong Sequence, string Subject, byte[] Payload, DateTime TimestampUtc)>();
|
||||||
var manifest = JsonSerializer.Deserialize<IndexManifest>(File.ReadAllText(_manifestPath));
|
|
||||||
if (manifest is null || manifest.Version != 1)
|
|
||||||
return;
|
|
||||||
|
|
||||||
_index.Clear();
|
foreach (var line in File.ReadLines(jsonlPath))
|
||||||
foreach (var entry in manifest.Entries)
|
|
||||||
_index[entry.Sequence] = new BlockPointer(entry.BlockId, entry.Offset);
|
|
||||||
|
|
||||||
_blockCount = Math.Max(manifest.BlockCount, 0);
|
|
||||||
_activeBlockBytes = Math.Max(manifest.ActiveBlockBytes, 0);
|
|
||||||
_writeOffset = Math.Max(manifest.WriteOffset, 0);
|
|
||||||
UsedIndexManifestOnStartup = true;
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
{
|
||||||
UsedIndexManifestOnStartup = false;
|
if (string.IsNullOrWhiteSpace(line))
|
||||||
_index.Clear();
|
continue;
|
||||||
_blockCount = 0;
|
|
||||||
_activeBlockBytes = 0;
|
|
||||||
_writeOffset = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void PersistBlockIndexManifest(string manifestPath, Dictionary<ulong, BlockPointer> blockIndex)
|
FileRecord? record;
|
||||||
{
|
try
|
||||||
var manifest = new IndexManifest
|
|
||||||
{
|
|
||||||
Version = 1,
|
|
||||||
BlockCount = _blockCount,
|
|
||||||
ActiveBlockBytes = _activeBlockBytes,
|
|
||||||
WriteOffset = _writeOffset,
|
|
||||||
Entries = [.. blockIndex.Select(kv => new IndexEntry
|
|
||||||
{
|
{
|
||||||
Sequence = kv.Key,
|
record = JsonSerializer.Deserialize<FileRecord>(line);
|
||||||
BlockId = kv.Value.BlockId,
|
}
|
||||||
Offset = kv.Value.Offset,
|
catch
|
||||||
}).OrderBy(e => e.Sequence)],
|
{
|
||||||
};
|
continue; // Skip corrupted lines.
|
||||||
|
}
|
||||||
|
|
||||||
File.WriteAllText(manifestPath, JsonSerializer.Serialize(manifest));
|
if (record == null)
|
||||||
}
|
continue;
|
||||||
|
|
||||||
private void TrackBlockForRecord(int recordBytes, ulong sequence)
|
byte[] originalPayload;
|
||||||
{
|
try
|
||||||
if (_blockCount == 0)
|
{
|
||||||
_blockCount = 1;
|
originalPayload = RestorePayload(Convert.FromBase64String(record.PayloadBase64 ?? string.Empty));
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Re-throw for integrity failures (e.g., wrong encryption key).
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
|
||||||
if (_activeBlockBytes > 0 && _activeBlockBytes + recordBytes > _options.BlockSizeBytes)
|
legacyMessages.Add((record.Sequence, record.Subject ?? string.Empty, originalPayload, record.TimestampUtc));
|
||||||
{
|
|
||||||
_blockCount++;
|
|
||||||
_activeBlockBytes = 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_index[sequence] = new BlockPointer(_blockCount, _writeOffset);
|
if (legacyMessages.Count == 0)
|
||||||
_activeBlockBytes += recordBytes;
|
{
|
||||||
_writeOffset += recordBytes;
|
// Delete the empty JSONL file.
|
||||||
|
File.Delete(jsonlPath);
|
||||||
|
var manifestPath = Path.Combine(_options.Directory, _options.IndexManifestFileName);
|
||||||
|
if (File.Exists(manifestPath))
|
||||||
|
File.Delete(manifestPath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to the in-memory cache.
|
||||||
|
foreach (var (seq, subject, payload, ts) in legacyMessages)
|
||||||
|
{
|
||||||
|
_messages[seq] = new StoredMessage
|
||||||
|
{
|
||||||
|
Sequence = seq,
|
||||||
|
Subject = subject,
|
||||||
|
Payload = payload,
|
||||||
|
TimestampUtc = ts,
|
||||||
|
};
|
||||||
|
if (seq > _last)
|
||||||
|
_last = seq;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write all messages to fresh blocks.
|
||||||
|
RewriteBlocks();
|
||||||
|
|
||||||
|
// Delete the legacy files.
|
||||||
|
File.Delete(jsonlPath);
|
||||||
|
var manifestFile = Path.Combine(_options.Directory, _options.IndexManifestFileName);
|
||||||
|
if (File.Exists(manifestFile))
|
||||||
|
File.Delete(manifestFile);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Expiry
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
private void PruneExpired(DateTime nowUtc)
|
private void PruneExpired(DateTime nowUtc)
|
||||||
{
|
{
|
||||||
if (_options.MaxAgeMs <= 0)
|
if (_options.MaxAgeMs <= 0)
|
||||||
@@ -349,7 +536,7 @@ public sealed class FileStore : IStreamStore, IAsyncDisposable
|
|||||||
foreach (var sequence in expired)
|
foreach (var sequence in expired)
|
||||||
_messages.Remove(sequence);
|
_messages.Remove(sequence);
|
||||||
|
|
||||||
RewriteDataFile();
|
RewriteBlocks();
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
@@ -586,22 +773,4 @@ public sealed class FileStore : IStreamStore, IAsyncDisposable
|
|||||||
public string? PayloadBase64 { get; init; }
|
public string? PayloadBase64 { get; init; }
|
||||||
public DateTime TimestampUtc { get; init; }
|
public DateTime TimestampUtc { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
private readonly record struct BlockPointer(int BlockId, long Offset);
|
|
||||||
|
|
||||||
private sealed class IndexManifest
|
|
||||||
{
|
|
||||||
public int Version { get; init; }
|
|
||||||
public int BlockCount { get; init; }
|
|
||||||
public long ActiveBlockBytes { get; init; }
|
|
||||||
public long WriteOffset { get; init; }
|
|
||||||
public List<IndexEntry> Entries { get; init; } = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
private sealed class IndexEntry
|
|
||||||
{
|
|
||||||
public ulong Sequence { get; init; }
|
|
||||||
public int BlockId { get; init; }
|
|
||||||
public long Offset { get; init; }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ public sealed class MsgBlock : IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Appends a message to the block.
|
/// Appends a message to the block with an auto-assigned sequence number.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="subject">NATS subject.</param>
|
/// <param name="subject">NATS subject.</param>
|
||||||
/// <param name="headers">Optional message headers.</param>
|
/// <param name="headers">Optional message headers.</param>
|
||||||
@@ -199,6 +199,56 @@ public sealed class MsgBlock : IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Appends a message to the block with an explicit sequence number and timestamp.
|
||||||
|
/// Used by FileStore when rewriting blocks from the in-memory cache where
|
||||||
|
/// sequences may have gaps (from prior removals).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="sequence">Explicit sequence number to assign.</param>
|
||||||
|
/// <param name="subject">NATS subject.</param>
|
||||||
|
/// <param name="headers">Optional message headers.</param>
|
||||||
|
/// <param name="payload">Message body payload.</param>
|
||||||
|
/// <param name="timestamp">Timestamp in Unix nanoseconds.</param>
|
||||||
|
/// <exception cref="InvalidOperationException">Block is sealed.</exception>
|
||||||
|
public void WriteAt(ulong sequence, string subject, ReadOnlyMemory<byte> headers, ReadOnlyMemory<byte> payload, long timestamp)
|
||||||
|
{
|
||||||
|
_lock.EnterWriteLock();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (_writeOffset >= _maxBytes)
|
||||||
|
throw new InvalidOperationException("Block is sealed; cannot write new messages.");
|
||||||
|
|
||||||
|
var record = new MessageRecord
|
||||||
|
{
|
||||||
|
Sequence = sequence,
|
||||||
|
Subject = subject,
|
||||||
|
Headers = headers,
|
||||||
|
Payload = payload,
|
||||||
|
Timestamp = timestamp,
|
||||||
|
Deleted = false,
|
||||||
|
};
|
||||||
|
|
||||||
|
var encoded = MessageRecord.Encode(record);
|
||||||
|
var offset = _writeOffset;
|
||||||
|
|
||||||
|
RandomAccess.Write(_handle, encoded, offset);
|
||||||
|
_writeOffset = offset + encoded.Length;
|
||||||
|
|
||||||
|
_index[sequence] = (offset, encoded.Length);
|
||||||
|
|
||||||
|
if (_totalWritten == 0)
|
||||||
|
_firstSequence = sequence;
|
||||||
|
|
||||||
|
_lastSequence = sequence;
|
||||||
|
_nextSequence = Math.Max(_nextSequence, sequence + 1);
|
||||||
|
_totalWritten++;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_lock.ExitWriteLock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Reads a message by sequence number. Uses positional I/O
|
/// Reads a message by sequence number. Uses positional I/O
|
||||||
/// (<see cref="RandomAccess.Read"/>) so concurrent readers don't
|
/// (<see cref="RandomAccess.Read"/>) so concurrent readers don't
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json;
|
|
||||||
using NATS.Server.JetStream.Storage;
|
using NATS.Server.JetStream.Storage;
|
||||||
|
|
||||||
namespace NATS.Server.Tests;
|
namespace NATS.Server.Tests;
|
||||||
@@ -29,10 +28,14 @@ public class JetStreamFileStoreCompressionEncryptionParityTests
|
|||||||
Encoding.UTF8.GetString(loaded.Payload.ToArray()).ShouldBe("payload");
|
Encoding.UTF8.GetString(loaded.Payload.ToArray()).ShouldBe("payload");
|
||||||
}
|
}
|
||||||
|
|
||||||
var firstLine = File.ReadLines(Path.Combine(dir, "messages.jsonl")).First();
|
// Block-based storage: read the .blk file to verify FSV1 envelope.
|
||||||
var payloadBase64 = JsonDocument.Parse(firstLine).RootElement.GetProperty("PayloadBase64").GetString();
|
var blkFiles = Directory.GetFiles(dir, "*.blk");
|
||||||
payloadBase64.ShouldNotBeNull();
|
blkFiles.Length.ShouldBeGreaterThan(0);
|
||||||
var persisted = Convert.FromBase64String(payloadBase64!);
|
|
||||||
|
// Read the first record from the block file and verify FSV1 magic in payload.
|
||||||
|
var blkBytes = File.ReadAllBytes(blkFiles[0]);
|
||||||
|
var record = MessageRecord.Decode(blkBytes.AsSpan(0, MessageRecord.MeasureRecord(blkBytes)));
|
||||||
|
var persisted = record.Payload.ToArray();
|
||||||
persisted.Take(4).SequenceEqual("FSV1"u8.ToArray()).ShouldBeTrue();
|
persisted.Take(4).SequenceEqual("FSV1"u8.ToArray()).ShouldBeTrue();
|
||||||
|
|
||||||
Should.Throw<InvalidDataException>(() =>
|
Should.Throw<InvalidDataException>(() =>
|
||||||
|
|||||||
@@ -23,10 +23,10 @@ public class JetStreamFileStoreDurabilityParityTests
|
|||||||
await store.AppendAsync("orders.created", Encoding.UTF8.GetBytes($"payload-{i}"), default);
|
await store.AppendAsync("orders.created", Encoding.UTF8.GetBytes($"payload-{i}"), default);
|
||||||
}
|
}
|
||||||
|
|
||||||
File.Exists(Path.Combine(dir, options.IndexManifestFileName)).ShouldBeTrue();
|
// Block-based storage: .blk files should be present on disk.
|
||||||
|
Directory.GetFiles(dir, "*.blk").Length.ShouldBeGreaterThan(0);
|
||||||
|
|
||||||
await using var reopened = new FileStore(options);
|
await using var reopened = new FileStore(options);
|
||||||
reopened.UsedIndexManifestOnStartup.ShouldBeTrue();
|
|
||||||
var state = await reopened.GetStateAsync(default);
|
var state = await reopened.GetStateAsync(default);
|
||||||
state.Messages.ShouldBe((ulong)1000);
|
state.Messages.ShouldBe((ulong)1000);
|
||||||
reopened.BlockCount.ShouldBeGreaterThan(1);
|
reopened.BlockCount.ShouldBeGreaterThan(1);
|
||||||
|
|||||||
289
tests/NATS.Server.Tests/JetStream/Storage/FileStoreBlockTests.cs
Normal file
289
tests/NATS.Server.Tests/JetStream/Storage/FileStoreBlockTests.cs
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
// Reference: golang/nats-server/server/filestore_test.go
|
||||||
|
// Tests for Task A3: FileStore Block Manager Rewrite.
|
||||||
|
// Verifies that FileStore correctly uses MsgBlock-based storage:
|
||||||
|
// block files on disk, block rotation, recovery, purge, snapshot,
|
||||||
|
// soft-delete, and payload transformation (S2/AEAD) integration.
|
||||||
|
|
||||||
|
using System.Text;
|
||||||
|
using NATS.Server.JetStream.Storage;
|
||||||
|
|
||||||
|
namespace NATS.Server.Tests.JetStream.Storage;
|
||||||
|
|
||||||
|
public sealed class FileStoreBlockTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly string _dir;
|
||||||
|
|
||||||
|
public FileStoreBlockTests()
|
||||||
|
{
|
||||||
|
_dir = Path.Combine(Path.GetTempPath(), $"nats-js-fs-block-{Guid.NewGuid():N}");
|
||||||
|
Directory.CreateDirectory(_dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (Directory.Exists(_dir))
|
||||||
|
Directory.Delete(_dir, recursive: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private FileStore CreateStore(string subdirectory, FileStoreOptions? options = null)
|
||||||
|
{
|
||||||
|
var dir = Path.Combine(_dir, subdirectory);
|
||||||
|
var opts = options ?? new FileStoreOptions();
|
||||||
|
opts.Directory = dir;
|
||||||
|
return new FileStore(opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Go: filestore.go block-based storage — verify .blk files are created on disk.
|
||||||
|
[Fact]
|
||||||
|
public async Task Append_UsesBlockStorage()
|
||||||
|
{
|
||||||
|
var subDir = "blk-storage";
|
||||||
|
var dir = Path.Combine(_dir, subDir);
|
||||||
|
|
||||||
|
await using var store = CreateStore(subDir);
|
||||||
|
|
||||||
|
await store.AppendAsync("foo", "Hello World"u8.ToArray(), default);
|
||||||
|
|
||||||
|
// At least one .blk file should exist in the store directory.
|
||||||
|
var blkFiles = Directory.GetFiles(dir, "*.blk");
|
||||||
|
blkFiles.Length.ShouldBeGreaterThanOrEqualTo(1);
|
||||||
|
|
||||||
|
// The old JSONL file should NOT exist.
|
||||||
|
File.Exists(Path.Combine(dir, "messages.jsonl")).ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Go: filestore.go block rotation — rbytes check causes new block creation.
|
||||||
|
[Fact]
|
||||||
|
public async Task MultiBlock_RotatesWhenFull()
|
||||||
|
{
|
||||||
|
var subDir = "blk-rotation";
|
||||||
|
var dir = Path.Combine(_dir, subDir);
|
||||||
|
|
||||||
|
// Small block size to force rotation quickly.
|
||||||
|
await using var store = CreateStore(subDir, new FileStoreOptions { BlockSizeBytes = 256 });
|
||||||
|
|
||||||
|
// Write enough messages to exceed 256 bytes per block.
|
||||||
|
for (var i = 0; i < 20; i++)
|
||||||
|
await store.AppendAsync("foo", "Hello World - block rotation test!"u8.ToArray(), default);
|
||||||
|
|
||||||
|
var state = await store.GetStateAsync(default);
|
||||||
|
state.Messages.ShouldBe((ulong)20);
|
||||||
|
|
||||||
|
// Multiple .blk files should be created.
|
||||||
|
var blkFiles = Directory.GetFiles(dir, "*.blk");
|
||||||
|
blkFiles.Length.ShouldBeGreaterThan(1);
|
||||||
|
|
||||||
|
// BlockCount should reflect multiple blocks.
|
||||||
|
store.BlockCount.ShouldBeGreaterThan(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Go: filestore.go multi-block load — messages span multiple blocks.
|
||||||
|
[Fact]
|
||||||
|
public async Task Load_AcrossBlocks()
|
||||||
|
{
|
||||||
|
var subDir = "blk-across";
|
||||||
|
|
||||||
|
// Small block size to force multiple blocks.
|
||||||
|
await using var store = CreateStore(subDir, new FileStoreOptions { BlockSizeBytes = 256 });
|
||||||
|
|
||||||
|
for (var i = 0; i < 20; i++)
|
||||||
|
await store.AppendAsync("foo", Encoding.UTF8.GetBytes($"msg-{i:D4}"), default);
|
||||||
|
|
||||||
|
// Verify we have multiple blocks.
|
||||||
|
store.BlockCount.ShouldBeGreaterThan(1);
|
||||||
|
|
||||||
|
// All messages should be loadable, regardless of which block they are in.
|
||||||
|
for (ulong i = 1; i <= 20; i++)
|
||||||
|
{
|
||||||
|
var msg = await store.LoadAsync(i, default);
|
||||||
|
msg.ShouldNotBeNull();
|
||||||
|
msg!.Subject.ShouldBe("foo");
|
||||||
|
var expected = Encoding.UTF8.GetBytes($"msg-{(int)(i - 1):D4}");
|
||||||
|
msg.Payload.ToArray().ShouldBe(expected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Go: filestore.go recovery — block files are rescanned on startup.
|
||||||
|
[Fact]
|
||||||
|
public async Task Recovery_AfterRestart()
|
||||||
|
{
|
||||||
|
var subDir = "blk-recovery";
|
||||||
|
var dir = Path.Combine(_dir, subDir);
|
||||||
|
|
||||||
|
// Write data and dispose.
|
||||||
|
await using (var store = CreateStore(subDir, new FileStoreOptions { BlockSizeBytes = 256 }))
|
||||||
|
{
|
||||||
|
for (var i = 0; i < 20; i++)
|
||||||
|
await store.AppendAsync("foo", Encoding.UTF8.GetBytes($"msg-{i:D4}"), default);
|
||||||
|
|
||||||
|
var state = await store.GetStateAsync(default);
|
||||||
|
state.Messages.ShouldBe((ulong)20);
|
||||||
|
}
|
||||||
|
|
||||||
|
// .blk files should still exist after dispose.
|
||||||
|
var blkFiles = Directory.GetFiles(dir, "*.blk");
|
||||||
|
blkFiles.Length.ShouldBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Recreate FileStore from the same directory.
|
||||||
|
await using (var store = CreateStore(subDir, new FileStoreOptions { BlockSizeBytes = 256 }))
|
||||||
|
{
|
||||||
|
var state = await store.GetStateAsync(default);
|
||||||
|
state.Messages.ShouldBe((ulong)20);
|
||||||
|
state.FirstSeq.ShouldBe((ulong)1);
|
||||||
|
state.LastSeq.ShouldBe((ulong)20);
|
||||||
|
|
||||||
|
// Verify all messages are intact.
|
||||||
|
for (ulong i = 1; i <= 20; i++)
|
||||||
|
{
|
||||||
|
var msg = await store.LoadAsync(i, default);
|
||||||
|
msg.ShouldNotBeNull();
|
||||||
|
var expected = Encoding.UTF8.GetBytes($"msg-{(int)(i - 1):D4}");
|
||||||
|
msg!.Payload.ToArray().ShouldBe(expected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Go: filestore.go purge — all blocks removed, fresh block created.
|
||||||
|
[Fact]
|
||||||
|
public async Task Purge_CleansAllBlocks()
|
||||||
|
{
|
||||||
|
var subDir = "blk-purge";
|
||||||
|
var dir = Path.Combine(_dir, subDir);
|
||||||
|
|
||||||
|
await using var store = CreateStore(subDir, new FileStoreOptions { BlockSizeBytes = 256 });
|
||||||
|
|
||||||
|
for (var i = 0; i < 20; i++)
|
||||||
|
await store.AppendAsync("foo", "Hello"u8.ToArray(), default);
|
||||||
|
|
||||||
|
// Before purge, multiple .blk files should exist.
|
||||||
|
Directory.GetFiles(dir, "*.blk").Length.ShouldBeGreaterThan(0);
|
||||||
|
|
||||||
|
await store.PurgeAsync(default);
|
||||||
|
|
||||||
|
var state = await store.GetStateAsync(default);
|
||||||
|
state.Messages.ShouldBe((ulong)0);
|
||||||
|
state.Bytes.ShouldBe((ulong)0);
|
||||||
|
|
||||||
|
// After purge, no old .blk files should remain (or they should be empty/recreated).
|
||||||
|
// The old JSONL file should also not exist.
|
||||||
|
File.Exists(Path.Combine(dir, "messages.jsonl")).ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Go: filestore.go dmap — soft-delete within a block.
|
||||||
|
[Fact]
|
||||||
|
public async Task Remove_SoftDeletesInBlock()
|
||||||
|
{
|
||||||
|
await using var store = CreateStore("blk-remove");
|
||||||
|
|
||||||
|
for (var i = 0; i < 5; i++)
|
||||||
|
await store.AppendAsync("foo", "data"u8.ToArray(), default);
|
||||||
|
|
||||||
|
// Remove sequence 3.
|
||||||
|
(await store.RemoveAsync(3, default)).ShouldBeTrue();
|
||||||
|
|
||||||
|
// Verify seq 3 returns null.
|
||||||
|
(await store.LoadAsync(3, default)).ShouldBeNull();
|
||||||
|
|
||||||
|
// Other sequences still loadable.
|
||||||
|
(await store.LoadAsync(1, default)).ShouldNotBeNull();
|
||||||
|
(await store.LoadAsync(2, default)).ShouldNotBeNull();
|
||||||
|
(await store.LoadAsync(4, default)).ShouldNotBeNull();
|
||||||
|
(await store.LoadAsync(5, default)).ShouldNotBeNull();
|
||||||
|
|
||||||
|
// State reflects the removal.
|
||||||
|
var state = await store.GetStateAsync(default);
|
||||||
|
state.Messages.ShouldBe((ulong)4);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Go: filestore.go snapshot — iterates all blocks for snapshot creation.
|
||||||
|
[Fact]
|
||||||
|
public async Task Snapshot_IncludesAllBlocks()
|
||||||
|
{
|
||||||
|
await using var srcStore = CreateStore("blk-snap-src", new FileStoreOptions { BlockSizeBytes = 256 });
|
||||||
|
|
||||||
|
for (var i = 0; i < 30; i++)
|
||||||
|
await srcStore.AppendAsync("foo", Encoding.UTF8.GetBytes($"msg-{i}"), default);
|
||||||
|
|
||||||
|
// Verify multiple blocks.
|
||||||
|
srcStore.BlockCount.ShouldBeGreaterThan(1);
|
||||||
|
|
||||||
|
var snap = await srcStore.CreateSnapshotAsync(default);
|
||||||
|
snap.Length.ShouldBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Restore into a new store.
|
||||||
|
await using var dstStore = CreateStore("blk-snap-dst");
|
||||||
|
await dstStore.RestoreSnapshotAsync(snap, default);
|
||||||
|
|
||||||
|
var srcState = await srcStore.GetStateAsync(default);
|
||||||
|
var dstState = await dstStore.GetStateAsync(default);
|
||||||
|
dstState.Messages.ShouldBe(srcState.Messages);
|
||||||
|
dstState.FirstSeq.ShouldBe(srcState.FirstSeq);
|
||||||
|
dstState.LastSeq.ShouldBe(srcState.LastSeq);
|
||||||
|
|
||||||
|
// Verify each message round-trips.
|
||||||
|
for (ulong i = 1; i <= srcState.Messages; i++)
|
||||||
|
{
|
||||||
|
var original = await srcStore.LoadAsync(i, default);
|
||||||
|
var copy = await dstStore.LoadAsync(i, default);
|
||||||
|
copy.ShouldNotBeNull();
|
||||||
|
copy!.Subject.ShouldBe(original!.Subject);
|
||||||
|
copy.Payload.ToArray().ShouldBe(original.Payload.ToArray());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Go: filestore.go S2 compression — payload is compressed before block write.
|
||||||
|
[Fact]
|
||||||
|
public async Task Compression_RoundTrip()
|
||||||
|
{
|
||||||
|
var subDir = "blk-compress";
|
||||||
|
|
||||||
|
await using var store = CreateStore(subDir, new FileStoreOptions
|
||||||
|
{
|
||||||
|
Compression = StoreCompression.S2Compression,
|
||||||
|
});
|
||||||
|
|
||||||
|
var payload = "Hello, S2 compressed block storage!"u8.ToArray();
|
||||||
|
for (var i = 0; i < 10; i++)
|
||||||
|
await store.AppendAsync("foo", payload, default);
|
||||||
|
|
||||||
|
var state = await store.GetStateAsync(default);
|
||||||
|
state.Messages.ShouldBe((ulong)10);
|
||||||
|
|
||||||
|
// Verify all messages are readable with correct payload.
|
||||||
|
for (ulong i = 1; i <= 10; i++)
|
||||||
|
{
|
||||||
|
var msg = await store.LoadAsync(i, default);
|
||||||
|
msg.ShouldNotBeNull();
|
||||||
|
msg!.Payload.ToArray().ShouldBe(payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Go: filestore.go AEAD encryption — payload is encrypted before block write.
|
||||||
|
[Fact]
|
||||||
|
public async Task Encryption_RoundTrip()
|
||||||
|
{
|
||||||
|
var subDir = "blk-encrypt";
|
||||||
|
var key = "nats-v2-test-key-exactly-32-bytes"u8[..32].ToArray();
|
||||||
|
|
||||||
|
await using var store = CreateStore(subDir, new FileStoreOptions
|
||||||
|
{
|
||||||
|
Cipher = StoreCipher.ChaCha,
|
||||||
|
EncryptionKey = key,
|
||||||
|
});
|
||||||
|
|
||||||
|
var payload = "Hello, AEAD encrypted block storage!"u8.ToArray();
|
||||||
|
for (var i = 0; i < 10; i++)
|
||||||
|
await store.AppendAsync("foo", payload, default);
|
||||||
|
|
||||||
|
var state = await store.GetStateAsync(default);
|
||||||
|
state.Messages.ShouldBe((ulong)10);
|
||||||
|
|
||||||
|
// Verify all messages are readable with correct payload.
|
||||||
|
for (ulong i = 1; i <= 10; i++)
|
||||||
|
{
|
||||||
|
var msg = await store.LoadAsync(i, default);
|
||||||
|
msg.ShouldNotBeNull();
|
||||||
|
msg!.Payload.ToArray().ShouldBe(payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user