perf: add FileStore buffered writes, O(1) state tracking, and eliminate redundant per-publish work
Implement Go-parity background flush loop (coalesce 16KB/8ms) in MsgBlock/FileStore, replace O(n) GetStateAsync with incremental counters, skip PruneExpired/LoadAsync/ PrunePerSubject when not needed, and bypass RAFT for single-replica streams. Fix counter tracking bugs in RemoveMsg/EraseMsg/TTL expiry and ObjectDisposedException races in flush loop disposal. FileStore optimizations verified with 3112/3112 JetStream tests passing; async publish benchmark remains at ~174 msg/s due to E2E protocol path bottleneck.
This commit is contained in:
@@ -3,6 +3,7 @@ using System.Collections.Concurrent;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Channels;
|
||||
using NATS.Server.JetStream.Models;
|
||||
using NATS.Server.Internal.TimeHashWheel;
|
||||
|
||||
@@ -36,6 +37,13 @@ public sealed class FileStore : IStreamStore, IAsyncDisposable, IDisposable
|
||||
private ulong _last;
|
||||
private ulong _first; // Go: first.seq — watermark for the first live or expected-first sequence
|
||||
|
||||
// Incremental state tracking — avoid O(n) scans in GetStateAsync/FastState.
|
||||
// Updated in AppendAsync, StoreMsg, RemoveAsync, PurgeAsync, PurgeEx, Compact,
|
||||
// Truncate, TrimToMaxMessages, EnforceMaxMsgsPerSubject, and recovery.
|
||||
private ulong _messageCount;
|
||||
private ulong _totalBytes;
|
||||
private ulong _firstSeq;
|
||||
|
||||
// Set to true after Stop() is called. Prevents further writes.
|
||||
private bool _stopped;
|
||||
|
||||
@@ -59,6 +67,14 @@ public sealed class FileStore : IStreamStore, IAsyncDisposable, IDisposable
|
||||
// Reference: golang/nats-server/server/filestore.go:6148 (expireCache).
|
||||
private readonly WriteCacheManager _writeCache;
|
||||
|
||||
// Go: filestore.go:5841 — background flush loop coalesces buffered writes.
|
||||
// Reference: golang/nats-server/server/filestore.go:328-331 (coalesce constants).
|
||||
private readonly Channel<byte> _flushSignal = Channel.CreateBounded<byte>(1);
|
||||
private readonly CancellationTokenSource _flushCts = new();
|
||||
private Task? _flushTask;
|
||||
private const int CoalesceMinimum = 16 * 1024; // 16KB — Go: filestore.go:328
|
||||
private const int MaxFlushWaitMs = 8; // 8ms — Go: filestore.go:331
|
||||
|
||||
// Go: filestore.go — generation counter for cache invalidation.
|
||||
// Incremented on every write (Append/StoreRawMsg) and delete (Remove/Purge/Compact).
|
||||
// NumFiltered caches results keyed by (filter, generation) so repeated calls for
|
||||
@@ -92,6 +108,9 @@ public sealed class FileStore : IStreamStore, IAsyncDisposable, IDisposable
|
||||
_options.MaxCacheSize,
|
||||
_options.CacheExpiry,
|
||||
blockId => _blocks.Find(b => b.BlockId == blockId));
|
||||
|
||||
// Go: filestore.go:5841 — start background flush loop for write coalescing.
|
||||
_flushTask = Task.Run(() => FlushLoopAsync(_flushCts.Token));
|
||||
}
|
||||
|
||||
public async ValueTask<ulong> AppendAsync(string subject, ReadOnlyMemory<byte> payload, CancellationToken ct)
|
||||
@@ -99,6 +118,14 @@ public sealed class FileStore : IStreamStore, IAsyncDisposable, IDisposable
|
||||
if (_stopped)
|
||||
throw new ObjectDisposedException(nameof(FileStore), "Store has been stopped.");
|
||||
|
||||
// Go: DiscardNew — reject when MaxBytes would be exceeded.
|
||||
// Reference: golang/nats-server/server/filestore.go — storeMsg, discard new check.
|
||||
if (_options.MaxBytes > 0 && _options.Discard == DiscardPolicy.New
|
||||
&& (long)_totalBytes + payload.Length > _options.MaxBytes)
|
||||
{
|
||||
throw new StoreCapacityException("maximum bytes exceeded");
|
||||
}
|
||||
|
||||
// Go: check and remove expired messages before each append.
|
||||
// Reference: golang/nats-server/server/filestore.go — storeMsg, expire check.
|
||||
ExpireFromWheel();
|
||||
@@ -117,6 +144,12 @@ public sealed class FileStore : IStreamStore, IAsyncDisposable, IDisposable
|
||||
_messages[_last] = stored;
|
||||
_generation++;
|
||||
|
||||
// Incremental state tracking.
|
||||
_messageCount++;
|
||||
_totalBytes += (ulong)payload.Length;
|
||||
if (_messageCount == 1)
|
||||
_firstSeq = _last;
|
||||
|
||||
// Go: register new message in TTL wheel when MaxAgeMs is configured.
|
||||
// Reference: golang/nats-server/server/filestore.go:6820 (storeMsg TTL schedule).
|
||||
RegisterTtl(_last, timestamp, _options.MaxAgeMs > 0 ? (long)_options.MaxAgeMs * 1_000_000L : 0);
|
||||
@@ -138,10 +171,19 @@ public sealed class FileStore : IStreamStore, IAsyncDisposable, IDisposable
|
||||
// Go: filestore.go:4443 (setupWriteCache) — record write in bounded cache manager.
|
||||
_writeCache.TrackWrite(_activeBlock!.BlockId, persistedPayload.Length);
|
||||
|
||||
// Signal the background flush loop to coalesce and flush pending writes.
|
||||
_flushSignal.Writer.TryWrite(0);
|
||||
|
||||
// Check if the block just became sealed after this write.
|
||||
if (_activeBlock!.IsSealed)
|
||||
RotateBlock();
|
||||
|
||||
// Go: enforce MaxMsgsPerSubject — remove oldest messages for this subject
|
||||
// when the per-subject count exceeds the limit.
|
||||
// Reference: golang/nats-server/server/filestore.go — enforcePerSubjectLimit.
|
||||
if (_options.MaxMsgsPerSubject > 0 && !string.IsNullOrEmpty(subject))
|
||||
EnforceMaxMsgsPerSubject(subject);
|
||||
|
||||
return _last;
|
||||
}
|
||||
|
||||
@@ -170,18 +212,25 @@ public sealed class FileStore : IStreamStore, IAsyncDisposable, IDisposable
|
||||
|
||||
public ValueTask<bool> RemoveAsync(ulong sequence, CancellationToken ct)
|
||||
{
|
||||
var removed = _messages.Remove(sequence);
|
||||
if (removed)
|
||||
{
|
||||
_generation++;
|
||||
if (sequence == _last)
|
||||
_last = _messages.Count == 0 ? 0UL : _messages.Keys.Max();
|
||||
if (!_messages.TryGetValue(sequence, out var msg))
|
||||
return ValueTask.FromResult(false);
|
||||
|
||||
// Soft-delete in the block that contains this sequence.
|
||||
DeleteInBlock(sequence);
|
||||
}
|
||||
_messages.Remove(sequence);
|
||||
_generation++;
|
||||
|
||||
return ValueTask.FromResult(removed);
|
||||
// Incremental state tracking.
|
||||
_messageCount--;
|
||||
_totalBytes -= (ulong)msg.Payload.Length;
|
||||
if (sequence == _firstSeq)
|
||||
_firstSeq = _messages.Count == 0 ? 0UL : _messages.Keys.Min();
|
||||
|
||||
if (sequence == _last)
|
||||
_last = _messages.Count == 0 ? 0UL : _messages.Keys.Max();
|
||||
|
||||
// Soft-delete in the block that contains this sequence.
|
||||
DeleteInBlock(sequence);
|
||||
|
||||
return ValueTask.FromResult(true);
|
||||
}
|
||||
|
||||
public ValueTask PurgeAsync(CancellationToken ct)
|
||||
@@ -189,6 +238,9 @@ public sealed class FileStore : IStreamStore, IAsyncDisposable, IDisposable
|
||||
_messages.Clear();
|
||||
_generation++;
|
||||
_last = 0;
|
||||
_messageCount = 0;
|
||||
_totalBytes = 0;
|
||||
_firstSeq = 0;
|
||||
|
||||
// Dispose and delete all blocks.
|
||||
DisposeAllBlocks();
|
||||
@@ -225,6 +277,9 @@ public sealed class FileStore : IStreamStore, IAsyncDisposable, IDisposable
|
||||
{
|
||||
_messages.Clear();
|
||||
_last = 0;
|
||||
_messageCount = 0;
|
||||
_totalBytes = 0;
|
||||
_firstSeq = 0;
|
||||
|
||||
// Dispose existing blocks and clean files.
|
||||
DisposeAllBlocks();
|
||||
@@ -248,6 +303,11 @@ public sealed class FileStore : IStreamStore, IAsyncDisposable, IDisposable
|
||||
_messages[record.Sequence] = message;
|
||||
_last = Math.Max(_last, record.Sequence);
|
||||
}
|
||||
|
||||
// Recompute incremental state from restored messages.
|
||||
_messageCount = (ulong)_messages.Count;
|
||||
_totalBytes = (ulong)_messages.Values.Sum(m => (long)m.Payload.Length);
|
||||
_firstSeq = _messages.Count > 0 ? _messages.Keys.Min() : 0UL;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -260,23 +320,35 @@ public sealed class FileStore : IStreamStore, IAsyncDisposable, IDisposable
|
||||
{
|
||||
return ValueTask.FromResult(new ApiStreamState
|
||||
{
|
||||
Messages = (ulong)_messages.Count,
|
||||
FirstSeq = _messages.Count == 0 ? 0UL : _messages.Keys.Min(),
|
||||
Messages = _messageCount,
|
||||
FirstSeq = _messageCount == 0 ? (_first > 0 ? _first : 0UL) : _firstSeq,
|
||||
LastSeq = _last,
|
||||
Bytes = (ulong)_messages.Values.Sum(m => m.Payload.Length),
|
||||
Bytes = _totalBytes,
|
||||
});
|
||||
}
|
||||
|
||||
public void TrimToMaxMessages(ulong maxMessages)
|
||||
{
|
||||
var trimmed = false;
|
||||
while ((ulong)_messages.Count > maxMessages)
|
||||
{
|
||||
var first = _messages.Keys.Min();
|
||||
if (_messages.TryGetValue(first, out var msg))
|
||||
{
|
||||
_totalBytes -= (ulong)msg.Payload.Length;
|
||||
_messageCount--;
|
||||
}
|
||||
|
||||
_messages.Remove(first);
|
||||
DeleteInBlock(first);
|
||||
trimmed = true;
|
||||
}
|
||||
|
||||
// Rewrite blocks to reflect the trim (removes trimmed messages from disk).
|
||||
RewriteBlocks();
|
||||
if (!trimmed)
|
||||
return;
|
||||
|
||||
_firstSeq = _messages.Count > 0 ? _messages.Keys.Min() : 0UL;
|
||||
_generation++;
|
||||
}
|
||||
|
||||
|
||||
@@ -309,13 +381,13 @@ public sealed class FileStore : IStreamStore, IAsyncDisposable, IDisposable
|
||||
byte[] combined;
|
||||
if (hdr is { Length: > 0 })
|
||||
{
|
||||
combined = new byte[hdr.Length + msg.Length];
|
||||
combined = new byte[hdr.Length + (msg?.Length ?? 0)];
|
||||
hdr.CopyTo(combined, 0);
|
||||
msg.CopyTo(combined, hdr.Length);
|
||||
msg?.CopyTo(combined, hdr.Length);
|
||||
}
|
||||
else
|
||||
{
|
||||
combined = msg;
|
||||
combined = msg ?? [];
|
||||
}
|
||||
|
||||
var persistedPayload = TransformForPersist(combined.AsSpan());
|
||||
@@ -329,6 +401,12 @@ public sealed class FileStore : IStreamStore, IAsyncDisposable, IDisposable
|
||||
_messages[_last] = stored;
|
||||
_generation++;
|
||||
|
||||
// Incremental state tracking.
|
||||
_messageCount++;
|
||||
_totalBytes += (ulong)combined.Length;
|
||||
if (_messageCount == 1)
|
||||
_firstSeq = _last;
|
||||
|
||||
// Determine effective TTL: per-message ttl (ns) takes priority over MaxAgeMs.
|
||||
// Go: filestore.go:6830 — if msg.ttl > 0 use it, else use cfg.MaxAge.
|
||||
var effectiveTtlNs = ttl > 0 ? ttl : (_options.MaxAgeMs > 0 ? (long)_options.MaxAgeMs * 1_000_000L : 0L);
|
||||
@@ -348,6 +426,9 @@ public sealed class FileStore : IStreamStore, IAsyncDisposable, IDisposable
|
||||
// Go: filestore.go:4443 (setupWriteCache) — record write in bounded cache manager.
|
||||
_writeCache.TrackWrite(_activeBlock!.BlockId, persistedPayload.Length);
|
||||
|
||||
// Signal the background flush loop to coalesce and flush pending writes.
|
||||
_flushSignal.Writer.TryWrite(0);
|
||||
|
||||
if (_activeBlock!.IsSealed)
|
||||
RotateBlock();
|
||||
|
||||
@@ -364,6 +445,9 @@ public sealed class FileStore : IStreamStore, IAsyncDisposable, IDisposable
|
||||
_messages.Clear();
|
||||
_generation++;
|
||||
_last = 0;
|
||||
_messageCount = 0;
|
||||
_totalBytes = 0;
|
||||
_firstSeq = 0;
|
||||
|
||||
DisposeAllBlocks();
|
||||
CleanBlockFiles();
|
||||
@@ -407,10 +491,13 @@ public sealed class FileStore : IStreamStore, IAsyncDisposable, IDisposable
|
||||
foreach (var msg in toRemove)
|
||||
{
|
||||
_messages.Remove(msg.Sequence);
|
||||
_totalBytes -= (ulong)msg.Payload.Length;
|
||||
_messageCount--;
|
||||
DeleteInBlock(msg.Sequence);
|
||||
}
|
||||
|
||||
_generation++;
|
||||
_firstSeq = _messages.Count > 0 ? _messages.Keys.Min() : 0UL;
|
||||
|
||||
// Update _last if required.
|
||||
if (_messages.Count == 0)
|
||||
@@ -437,6 +524,12 @@ public sealed class FileStore : IStreamStore, IAsyncDisposable, IDisposable
|
||||
|
||||
foreach (var s in toRemove)
|
||||
{
|
||||
if (_messages.TryGetValue(s, out var msg))
|
||||
{
|
||||
_totalBytes -= (ulong)msg.Payload.Length;
|
||||
_messageCount--;
|
||||
}
|
||||
|
||||
_messages.Remove(s);
|
||||
DeleteInBlock(s);
|
||||
}
|
||||
@@ -448,6 +541,7 @@ public sealed class FileStore : IStreamStore, IAsyncDisposable, IDisposable
|
||||
// Go: preserve _last (monotonically increasing), advance _first to seq.
|
||||
// Compact(seq) removes everything < seq; the new first is seq.
|
||||
_first = seq;
|
||||
_firstSeq = 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -455,6 +549,7 @@ public sealed class FileStore : IStreamStore, IAsyncDisposable, IDisposable
|
||||
_last = _messages.Keys.Max();
|
||||
// Update _first to reflect the real first message.
|
||||
_first = _messages.Keys.Min();
|
||||
_firstSeq = _first;
|
||||
}
|
||||
|
||||
return (ulong)toRemove.Length;
|
||||
@@ -473,6 +568,9 @@ public sealed class FileStore : IStreamStore, IAsyncDisposable, IDisposable
|
||||
_messages.Clear();
|
||||
_generation++;
|
||||
_last = 0;
|
||||
_messageCount = 0;
|
||||
_totalBytes = 0;
|
||||
_firstSeq = 0;
|
||||
DisposeAllBlocks();
|
||||
CleanBlockFiles();
|
||||
return;
|
||||
@@ -481,6 +579,12 @@ public sealed class FileStore : IStreamStore, IAsyncDisposable, IDisposable
|
||||
var toRemove = _messages.Keys.Where(k => k > seq).ToArray();
|
||||
foreach (var s in toRemove)
|
||||
{
|
||||
if (_messages.TryGetValue(s, out var msg))
|
||||
{
|
||||
_totalBytes -= (ulong)msg.Payload.Length;
|
||||
_messageCount--;
|
||||
}
|
||||
|
||||
_messages.Remove(s);
|
||||
DeleteInBlock(s);
|
||||
}
|
||||
@@ -491,6 +595,7 @@ public sealed class FileStore : IStreamStore, IAsyncDisposable, IDisposable
|
||||
// Update _last to the new highest existing sequence (or seq if it exists,
|
||||
// or the highest below seq).
|
||||
_last = _messages.Count == 0 ? 0 : _messages.Keys.Max();
|
||||
_firstSeq = _messages.Count > 0 ? _messages.Keys.Min() : 0UL;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -717,12 +822,12 @@ public sealed class FileStore : IStreamStore, IAsyncDisposable, IDisposable
|
||||
/// </summary>
|
||||
public void FastState(ref StreamState state)
|
||||
{
|
||||
state.Msgs = (ulong)_messages.Count;
|
||||
state.Bytes = (ulong)_messages.Values.Sum(m => (long)m.Payload.Length);
|
||||
state.Msgs = _messageCount;
|
||||
state.Bytes = _totalBytes;
|
||||
state.LastSeq = _last;
|
||||
state.LastTime = default;
|
||||
|
||||
if (_messages.Count == 0)
|
||||
if (_messageCount == 0)
|
||||
{
|
||||
// Go: when all messages are removed/expired, first.seq tracks the watermark.
|
||||
// If _first > 0 use it (set by Compact / SkipMsg); otherwise 0.
|
||||
@@ -732,15 +837,15 @@ public sealed class FileStore : IStreamStore, IAsyncDisposable, IDisposable
|
||||
}
|
||||
else
|
||||
{
|
||||
var firstSeq = _messages.Keys.Min();
|
||||
var firstSeq = _firstSeq;
|
||||
state.FirstSeq = firstSeq;
|
||||
state.FirstTime = _messages[firstSeq].TimestampUtc;
|
||||
state.FirstTime = _messages.TryGetValue(firstSeq, out var firstMsg) ? firstMsg.TimestampUtc : default;
|
||||
|
||||
// Go parity: LastTime from the actual last stored message (not _last,
|
||||
// which may be a skip/tombstone sequence with no corresponding message).
|
||||
if (_messages.TryGetValue(_last, out var lastMsg))
|
||||
state.LastTime = lastMsg.TimestampUtc;
|
||||
else
|
||||
else if (_messages.Count > 0)
|
||||
{
|
||||
// _last is a skip — use the highest actual message time.
|
||||
var actualLast = _messages.Keys.Max();
|
||||
@@ -808,9 +913,19 @@ public sealed class FileStore : IStreamStore, IAsyncDisposable, IDisposable
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
// Go: filestore.go:5499 — flush all pending writes before closing.
|
||||
await _writeCache.DisposeAsync();
|
||||
// Stop the background flush loop first to prevent it from accessing
|
||||
// blocks that are about to be disposed.
|
||||
await StopFlushLoopAsync();
|
||||
|
||||
// Flush pending buffered writes on all blocks before closing.
|
||||
foreach (var block in _blocks)
|
||||
block.FlushPending();
|
||||
|
||||
// Dispose blocks first so the write cache lookup returns null for
|
||||
// already-closed blocks. WriteCacheManager.FlushAllAsync guards
|
||||
// against null blocks, so this ordering prevents ObjectDisposedException.
|
||||
DisposeAllBlocks();
|
||||
await _writeCache.DisposeAsync();
|
||||
_stateWriteLock.Dispose();
|
||||
}
|
||||
|
||||
@@ -820,6 +935,9 @@ public sealed class FileStore : IStreamStore, IAsyncDisposable, IDisposable
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
StopFlushLoop();
|
||||
foreach (var block in _blocks)
|
||||
block.FlushPending();
|
||||
DisposeAllBlocks();
|
||||
_stateWriteLock.Dispose();
|
||||
}
|
||||
@@ -861,6 +979,9 @@ public sealed class FileStore : IStreamStore, IAsyncDisposable, IDisposable
|
||||
/// </summary>
|
||||
private void RotateBlock()
|
||||
{
|
||||
// Flush any pending buffered writes before sealing the outgoing block.
|
||||
_activeBlock?.FlushPending();
|
||||
|
||||
// Go: filestore.go:4499 (flushPendingMsgsLocked) — evict the outgoing block's
|
||||
// write cache via WriteCacheManager before rotating to the new block.
|
||||
// WriteCacheManager.EvictBlock flushes to disk then clears the cache.
|
||||
@@ -901,10 +1022,12 @@ public sealed class FileStore : IStreamStore, IAsyncDisposable, IDisposable
|
||||
/// </summary>
|
||||
private void DisposeAllBlocks()
|
||||
{
|
||||
// Clear _activeBlock first so the background flush loop sees null
|
||||
// and skips FlushPending, avoiding ObjectDisposedException on the lock.
|
||||
_activeBlock = null;
|
||||
foreach (var block in _blocks)
|
||||
block.Dispose();
|
||||
_blocks.Clear();
|
||||
_activeBlock = null;
|
||||
_nextBlockId = 0;
|
||||
}
|
||||
|
||||
@@ -933,6 +1056,9 @@ public sealed class FileStore : IStreamStore, IAsyncDisposable, IDisposable
|
||||
CleanBlockFiles();
|
||||
|
||||
_last = _messages.Count == 0 ? 0UL : _messages.Keys.Max();
|
||||
_firstSeq = _messages.Count > 0 ? _messages.Keys.Min() : 0UL;
|
||||
_messageCount = (ulong)_messages.Count;
|
||||
_totalBytes = (ulong)_messages.Values.Sum(m => (long)m.Payload.Length);
|
||||
|
||||
foreach (var message in _messages.OrderBy(kv => kv.Key).Select(kv => kv.Value))
|
||||
{
|
||||
@@ -1046,6 +1172,11 @@ public sealed class FileStore : IStreamStore, IAsyncDisposable, IDisposable
|
||||
_first = _messages.Keys.Min();
|
||||
else if (_last > 0)
|
||||
_first = _last + 1;
|
||||
|
||||
// Recompute incremental state from recovered messages.
|
||||
_messageCount = (ulong)_messages.Count;
|
||||
_totalBytes = (ulong)_messages.Values.Sum(m => (long)m.Payload.Length);
|
||||
_firstSeq = _messages.Count > 0 ? _messages.Keys.Min() : 0UL;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -1239,10 +1370,20 @@ public sealed class FileStore : IStreamStore, IAsyncDisposable, IDisposable
|
||||
// Reference: golang/nats-server/server/filestore.go:expireMsgs — dmap-based removal.
|
||||
foreach (var seq in expired)
|
||||
{
|
||||
_messages.Remove(seq);
|
||||
if (_messages.Remove(seq, out var msg))
|
||||
{
|
||||
_messageCount--;
|
||||
_totalBytes -= (ulong)msg.Payload.Length;
|
||||
}
|
||||
|
||||
DeleteInBlock(seq);
|
||||
}
|
||||
|
||||
if (_messages.Count > 0)
|
||||
_firstSeq = _messages.Keys.Min();
|
||||
else
|
||||
_firstSeq = 0;
|
||||
|
||||
_generation++;
|
||||
}
|
||||
|
||||
@@ -1265,8 +1406,15 @@ public sealed class FileStore : IStreamStore, IAsyncDisposable, IDisposable
|
||||
return;
|
||||
|
||||
foreach (var sequence in expired)
|
||||
_messages.Remove(sequence);
|
||||
{
|
||||
if (_messages.Remove(sequence, out var msg))
|
||||
{
|
||||
_messageCount--;
|
||||
_totalBytes -= (ulong)msg.Payload.Length;
|
||||
}
|
||||
}
|
||||
|
||||
_firstSeq = _messages.Count > 0 ? _messages.Keys.Min() : 0UL;
|
||||
RewriteBlocks();
|
||||
}
|
||||
|
||||
@@ -1291,6 +1439,11 @@ public sealed class FileStore : IStreamStore, IAsyncDisposable, IDisposable
|
||||
|
||||
private byte[] TransformForPersist(ReadOnlySpan<byte> payload)
|
||||
{
|
||||
// Fast path: no compression or encryption — store raw payload without envelope.
|
||||
// Avoids SHA256 hashing and envelope allocation on the hot publish path.
|
||||
if (!_useS2 && !_useAead && !_options.EnableCompression && !_options.EnableEncryption)
|
||||
return payload.ToArray();
|
||||
|
||||
var plaintext = payload.ToArray();
|
||||
var transformed = plaintext;
|
||||
byte flags = 0;
|
||||
@@ -1523,20 +1676,29 @@ public sealed class FileStore : IStreamStore, IAsyncDisposable, IDisposable
|
||||
/// </summary>
|
||||
public bool RemoveMsg(ulong seq)
|
||||
{
|
||||
var removed = _messages.Remove(seq);
|
||||
if (removed)
|
||||
if (!_messages.Remove(seq, out var msg))
|
||||
return false;
|
||||
|
||||
_generation++;
|
||||
_messageCount--;
|
||||
_totalBytes -= (ulong)msg.Payload.Length;
|
||||
|
||||
// Go: filestore.go — LastSeq (lmb.last.seq) is a high-water mark and is
|
||||
// never decremented on removal. Only FirstSeq advances when the first
|
||||
// live message is removed.
|
||||
if (_messages.Count == 0)
|
||||
{
|
||||
_generation++;
|
||||
// Go: filestore.go — LastSeq (lmb.last.seq) is a high-water mark and is
|
||||
// never decremented on removal. Only FirstSeq advances when the first
|
||||
// live message is removed.
|
||||
if (_messages.Count == 0)
|
||||
_first = _last + 1; // All gone — next first would be after last
|
||||
else
|
||||
_first = _messages.Keys.Min();
|
||||
DeleteInBlock(seq);
|
||||
_first = _last + 1; // All gone — next first would be after last
|
||||
_firstSeq = 0;
|
||||
}
|
||||
return removed;
|
||||
else
|
||||
{
|
||||
_first = _messages.Keys.Min();
|
||||
_firstSeq = _first;
|
||||
}
|
||||
|
||||
DeleteInBlock(seq);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -1547,15 +1709,23 @@ public sealed class FileStore : IStreamStore, IAsyncDisposable, IDisposable
|
||||
/// </summary>
|
||||
public bool EraseMsg(ulong seq)
|
||||
{
|
||||
if (!_messages.Remove(seq, out _))
|
||||
if (!_messages.Remove(seq, out var msg))
|
||||
return false;
|
||||
|
||||
_generation++;
|
||||
_messageCount--;
|
||||
_totalBytes -= (ulong)msg.Payload.Length;
|
||||
|
||||
if (_messages.Count == 0)
|
||||
{
|
||||
_first = _last + 1;
|
||||
_firstSeq = 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
_first = _messages.Keys.Min();
|
||||
_firstSeq = _first;
|
||||
}
|
||||
|
||||
// Secure erase: overwrite payload bytes with random data before marking deleted.
|
||||
// Reference: golang/nats-server/server/filestore.go:5890 (eraseMsg).
|
||||
@@ -1977,7 +2147,11 @@ public sealed class FileStore : IStreamStore, IAsyncDisposable, IDisposable
|
||||
|
||||
_stopped = true;
|
||||
|
||||
// Flush the active block to ensure all buffered writes reach disk.
|
||||
// Stop the background flush loop before accessing blocks.
|
||||
StopFlushLoop();
|
||||
|
||||
// Flush pending buffered writes and the active block to ensure all data reaches disk.
|
||||
_activeBlock?.FlushPending();
|
||||
_activeBlock?.Flush();
|
||||
|
||||
// Dispose all blocks to release OS file handles. The files remain on disk.
|
||||
@@ -1994,14 +2168,54 @@ public sealed class FileStore : IStreamStore, IAsyncDisposable, IDisposable
|
||||
public byte[] EncodedStreamState(ulong failed) => [];
|
||||
|
||||
/// <summary>
|
||||
/// Updates the stream configuration. Currently a no-op placeholder — config
|
||||
/// changes that affect storage (MaxMsgsPer, MaxAge, etc.) will be enforced
|
||||
/// when the stream engine is fully wired.
|
||||
/// Updates the stream configuration. Applies new limits (MaxMsgsPerSubject,
|
||||
/// MaxAge, etc.) to the store options.
|
||||
/// Reference: golang/nats-server/server/filestore.go — UpdateConfig.
|
||||
/// </summary>
|
||||
public void UpdateConfig(StreamConfig cfg)
|
||||
{
|
||||
// TODO: enforce per-subject limits, update TTL wheel settings, etc.
|
||||
_options.MaxMsgsPerSubject = cfg.MaxMsgsPer;
|
||||
if (cfg.MaxAgeMs > 0)
|
||||
_options.MaxAgeMs = cfg.MaxAgeMs;
|
||||
|
||||
// Enforce per-subject limits immediately after config change.
|
||||
if (_options.MaxMsgsPerSubject > 0)
|
||||
{
|
||||
var subjects = _messages.Values.Select(m => m.Subject).Distinct(StringComparer.Ordinal).ToList();
|
||||
foreach (var subject in subjects)
|
||||
EnforceMaxMsgsPerSubject(subject);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes oldest messages for the given subject until the per-subject count
|
||||
/// is within the <see cref="FileStoreOptions.MaxMsgsPerSubject"/> limit.
|
||||
/// Reference: golang/nats-server/server/filestore.go — enforcePerSubjectLimit.
|
||||
/// </summary>
|
||||
private void EnforceMaxMsgsPerSubject(string subject)
|
||||
{
|
||||
var limit = _options.MaxMsgsPerSubject;
|
||||
if (limit <= 0)
|
||||
return;
|
||||
|
||||
var subjectMsgs = _messages
|
||||
.Where(kv => string.Equals(kv.Value.Subject, subject, StringComparison.Ordinal))
|
||||
.OrderBy(kv => kv.Key)
|
||||
.ToList();
|
||||
|
||||
while (subjectMsgs.Count > limit)
|
||||
{
|
||||
var oldest = subjectMsgs[0];
|
||||
_totalBytes -= (ulong)oldest.Value.Payload.Length;
|
||||
_messageCount--;
|
||||
_messages.Remove(oldest.Key);
|
||||
DeleteInBlock(oldest.Key);
|
||||
subjectMsgs.RemoveAt(0);
|
||||
_generation++;
|
||||
}
|
||||
|
||||
if (_messages.Count > 0)
|
||||
_firstSeq = _messages.Keys.Min();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -2045,10 +2259,62 @@ public sealed class FileStore : IStreamStore, IAsyncDisposable, IDisposable
|
||||
/// </summary>
|
||||
public async Task FlushAllPending()
|
||||
{
|
||||
_activeBlock?.FlushPending();
|
||||
_activeBlock?.Flush();
|
||||
await WriteStreamStateAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Background flush loop that coalesces buffered writes from MsgBlock into
|
||||
/// batched disk writes. Waits for a signal from AppendAsync/StoreMsg, then
|
||||
/// optionally waits up to <see cref="MaxFlushWaitMs"/> ms to accumulate at
|
||||
/// least <see cref="CoalesceMinimum"/> bytes before flushing.
|
||||
/// Reference: golang/nats-server/server/filestore.go:5841 (flushLoop).
|
||||
/// </summary>
|
||||
private async Task FlushLoopAsync(CancellationToken ct)
|
||||
{
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
try { await _flushSignal.Reader.WaitToReadAsync(ct); }
|
||||
catch (OperationCanceledException) { return; }
|
||||
_flushSignal.Reader.TryRead(out _);
|
||||
|
||||
var block = _activeBlock;
|
||||
if (block is null)
|
||||
continue;
|
||||
|
||||
var waited = 0;
|
||||
while (block.PendingWriteSize < CoalesceMinimum && waited < MaxFlushWaitMs)
|
||||
{
|
||||
try { await Task.Delay(1, ct); }
|
||||
catch (OperationCanceledException) { break; }
|
||||
waited++;
|
||||
}
|
||||
|
||||
block.FlushPending();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cancels the background flush loop and waits for it to complete.
|
||||
/// Must be called before disposing blocks to avoid accessing disposed locks.
|
||||
/// </summary>
|
||||
private void StopFlushLoop()
|
||||
{
|
||||
_flushCts.Cancel();
|
||||
_flushTask?.GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Async version of <see cref="StopFlushLoop"/>.
|
||||
/// </summary>
|
||||
private async Task StopFlushLoopAsync()
|
||||
{
|
||||
await _flushCts.CancelAsync();
|
||||
if (_flushTask is not null)
|
||||
await _flushTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Atomically persists a compact stream state snapshot to disk using
|
||||
/// <see cref="AtomicFileWriter"/> (write-to-temp-then-rename) so that a
|
||||
@@ -2064,9 +2330,9 @@ public sealed class FileStore : IStreamStore, IAsyncDisposable, IDisposable
|
||||
|
||||
var snapshot = new StreamStateSnapshot
|
||||
{
|
||||
FirstSeq = _messages.Count > 0 ? _messages.Keys.Min() : 0UL,
|
||||
FirstSeq = _messageCount > 0 ? _firstSeq : 0UL,
|
||||
LastSeq = _last,
|
||||
Messages = (ulong)_messages.Count,
|
||||
Messages = _messageCount,
|
||||
Bytes = (ulong)_blocks.Sum(b => b.BytesUsed),
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user