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:
Joseph Doherty
2026-03-13 03:11:11 -04:00
parent 37575dc41c
commit 4de691c9c5
30 changed files with 1514 additions and 185 deletions

View File

@@ -226,17 +226,95 @@ public sealed class FileStoreEncryptionTests : IDisposable
}
// Go: TestFileStoreDoubleCompactWithWriteInBetweenEncryptedBug server/filestore_test.go:3924
[Fact(Skip = "Compact not yet implemented in .NET FileStore")]
[Fact]
public async Task Encrypted_double_compact_with_write_in_between()
{
await Task.CompletedTask;
await using var store = CreateStore("enc-double-compact");
const string subject = "foo";
var payload = "ouch"u8.ToArray();
// Write 10 messages (seqs 1-10).
for (var i = 0; i < 10; i++)
await store.AppendAsync(subject, payload, default);
// First compact: remove seqs 1-4 (seq < 5).
store.Compact(5);
// 6 messages remain (seqs 5-10).
var stateAfterFirstCompact = await store.GetStateAsync(default);
stateAfterFirstCompact.Messages.ShouldBe(6UL);
stateAfterFirstCompact.LastSeq.ShouldBe(10UL);
// Write 5 more messages (seqs 11-15).
for (var i = 0; i < 5; i++)
await store.AppendAsync(subject, payload, default);
// Second compact: remove seqs 5-9 (seq < 10).
store.Compact(10);
// 6 messages remain (seqs 10-15).
var stateAfterSecondCompact = await store.GetStateAsync(default);
stateAfterSecondCompact.Messages.ShouldBe(6UL);
stateAfterSecondCompact.LastSeq.ShouldBe(15UL);
stateAfterSecondCompact.FirstSeq.ShouldBe(10UL);
// All remaining messages (seqs 10-15) must be loadable and readable.
for (var seq = 10UL; seq <= 15UL; seq++)
{
var msg = await store.LoadAsync(seq, default);
msg.ShouldNotBeNull($"seq {seq} should still be loadable after double compact");
msg!.Subject.ShouldBe(subject);
msg.Payload.ToArray().ShouldBe(payload);
}
// Compacted-away sequences must not be loadable.
(await store.LoadAsync(1, default)).ShouldBeNull();
(await store.LoadAsync(9, default)).ShouldBeNull();
}
// Go: TestFileStoreEncryptedKeepIndexNeedBekResetBug server/filestore_test.go:3956
[Fact(Skip = "Block encryption key reset not yet implemented in .NET FileStore")]
// Verifies that after all messages in a block are removed (leaving the block empty),
// subsequent writes to that block are readable — i.e., the block encryption key
// (BEK) is correctly reset when new data follows a fully-emptied block.
// Go: TestFileStoreEncryptedKeepIndexNeedBekResetBug server/filestore_test.go:3956
[Fact]
public async Task Encrypted_keep_index_bek_reset()
{
await Task.CompletedTask;
await using var store = CreateStore("enc-bek-reset");
var payload = "ouch"u8.ToArray();
// Write 5 messages (seqs 1-5) into the active block.
for (var i = 0; i < 5; i++)
await store.AppendAsync("foo", payload, default);
// Remove all 5 — the block is now empty, mirroring the Go test's TTL-expiry path.
for (var seq = 1UL; seq <= 5UL; seq++)
(await store.RemoveAsync(seq, default)).ShouldBeTrue();
var emptyState = await store.GetStateAsync(default);
emptyState.Messages.ShouldBe((ulong)0);
// Write 5 more messages into the same (now-empty) block.
// The BEK must be reset so that encryption/decryption is valid for the new data.
var firstNewSeq = await store.AppendAsync("foo", payload, default);
for (var i = 1; i < 5; i++)
await store.AppendAsync("foo", payload, default);
var state = await store.GetStateAsync(default);
state.Messages.ShouldBe((ulong)5);
// Every message written after the block was emptied must decrypt correctly.
var msg = await store.LoadAsync(firstNewSeq, default);
msg.ShouldNotBeNull();
msg!.Subject.ShouldBe("foo");
msg.Payload.ToArray().ShouldBe(payload);
// Spot-check the last seq as well.
var lastMsg = await store.LoadAsync(firstNewSeq + 4, default);
lastMsg.ShouldNotBeNull();
lastMsg!.Payload.ToArray().ShouldBe(payload);
}
// Verify encryption with no-op key (empty key) does not crash.