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

@@ -6,6 +6,7 @@
// TestFileStoreUpdateMaxMsgsPerSubject
using System.Text;
using NATS.Server.JetStream.Models;
using NATS.Server.JetStream.Storage;
namespace NATS.Server.JetStream.Tests.JetStream.Storage;
@@ -145,6 +146,7 @@ public sealed class FileStoreLimitsTests : IDisposable
}
// Go: TestFileStoreAgeLimit server/filestore_test.go:616
[SlopwatchSuppress("SW004", "MaxAge TTL expiry test requires real wall-clock time to elapse; no synchronisation primitive can replace observing time-based expiration")]
[Fact]
public async Task MaxAge_expires_old_messages()
{
@@ -168,6 +170,7 @@ public sealed class FileStoreLimitsTests : IDisposable
}
// Go: TestFileStoreAgeLimit server/filestore_test.go:660
[SlopwatchSuppress("SW004", "MaxAge TTL expiry test requires real wall-clock time to elapse; no synchronisation primitive can replace observing time-based expiration")]
[Fact]
public async Task MaxAge_timer_fires_again_for_second_batch()
{
@@ -193,6 +196,7 @@ public sealed class FileStoreLimitsTests : IDisposable
}
// Go: TestFileStoreAgeLimit server/filestore_test.go:616
[SlopwatchSuppress("SW004", "MaxAge TTL expiry test requires real wall-clock time to elapse; verifying zero-age means no expiration needs a delay window")]
[Fact]
public async Task MaxAge_zero_means_no_expiration()
{
@@ -261,31 +265,105 @@ public sealed class FileStoreLimitsTests : IDisposable
}
// Go: TestFileStoreBytesLimitWithDiscardNew server/filestore_test.go:583
[Fact(Skip = "DiscardNew policy not yet implemented in .NET FileStore")]
[Fact]
public async Task Bytes_limit_with_discard_new_rejects_over_limit()
{
await Task.CompletedTask;
var payload = new byte[7];
await using var store = CreateStore("bytes-discard-new", new FileStoreOptions
{
MaxBytes = 20,
Discard = DiscardPolicy.New,
});
// 2 messages fit (14 bytes <= 20)
await store.AppendAsync("tiny", payload, default);
await store.AppendAsync("tiny", payload, default);
// 3rd rejected (14 + 7 = 21 > 20)
await Should.ThrowAsync<StoreCapacityException>(
async () => await store.AppendAsync("tiny", payload, default));
var state = await store.GetStateAsync(default);
state.Messages.ShouldBe(2UL);
state.Bytes.ShouldBe(14UL);
}
// Go: TestFileStoreMaxMsgsPerSubject server/filestore_test.go:4065
[Fact(Skip = "MaxMsgsPerSubject not yet implemented in .NET FileStore")]
[Fact]
public async Task MaxMsgsPerSubject_enforces_per_subject_limit()
{
await Task.CompletedTask;
await using var store = CreateStore("max-per-subj", new FileStoreOptions { MaxMsgsPerSubject = 2 });
// Store 5 messages on "foo" — only last 2 should survive.
for (var i = 0; i < 5; i++)
await store.AppendAsync("foo", Encoding.UTF8.GetBytes($"foo-{i}"), default);
// Store 3 messages on "bar" — only last 2 should survive.
for (var i = 0; i < 3; i++)
await store.AppendAsync("bar", Encoding.UTF8.GetBytes($"bar-{i}"), default);
var state = await store.GetStateAsync(default);
state.Messages.ShouldBe((ulong)4); // 2 foo + 2 bar
// Verify oldest foo messages were evicted.
(await store.LoadAsync(1, default)).ShouldBeNull();
(await store.LoadAsync(2, default)).ShouldBeNull();
(await store.LoadAsync(3, default)).ShouldBeNull();
// Last 2 foo messages should survive (seqs 4 and 5).
(await store.LoadAsync(4, default)).ShouldNotBeNull();
(await store.LoadAsync(5, default)).ShouldNotBeNull();
}
// Go: TestFileStoreMaxMsgsAndMaxMsgsPerSubject server/filestore_test.go:4098
[Fact(Skip = "MaxMsgsPerSubject not yet implemented in .NET FileStore")]
[Fact]
public async Task MaxMsgs_and_MaxMsgsPerSubject_combined()
{
await Task.CompletedTask;
await using var store = CreateStore("max-combined", new FileStoreOptions { MaxMsgsPerSubject = 3 });
// Store messages across multiple subjects.
for (var i = 0; i < 5; i++)
await store.AppendAsync("foo", Encoding.UTF8.GetBytes($"foo-{i}"), default);
for (var i = 0; i < 5; i++)
await store.AppendAsync("bar", Encoding.UTF8.GetBytes($"bar-{i}"), default);
var state = await store.GetStateAsync(default);
// Each subject limited to 3 → 6 total.
state.Messages.ShouldBe((ulong)6);
// Verify per-subject: last 3 of each subject survive.
var fooLast = await store.LoadLastBySubjectAsync("foo", default);
fooLast.ShouldNotBeNull();
fooLast!.Sequence.ShouldBe((ulong)5);
var barLast = await store.LoadLastBySubjectAsync("bar", default);
barLast.ShouldNotBeNull();
barLast!.Sequence.ShouldBe((ulong)10);
}
// Go: TestFileStoreUpdateMaxMsgsPerSubject server/filestore_test.go:4563
[Fact(Skip = "UpdateConfig not yet implemented in .NET FileStore")]
[Fact]
public async Task UpdateConfig_changes_MaxMsgsPerSubject()
{
await Task.CompletedTask;
await using var store = CreateStore("update-max-per-subj");
// Store 10 messages on "foo".
for (var i = 0; i < 10; i++)
await store.AppendAsync("foo", Encoding.UTF8.GetBytes($"foo-{i}"), default);
(await store.GetStateAsync(default)).Messages.ShouldBe((ulong)10);
// Update config to limit to 3 per subject.
store.UpdateConfig(new StreamConfig { MaxMsgsPer = 3 });
var state = await store.GetStateAsync(default);
state.Messages.ShouldBe((ulong)3);
// Only the last 3 messages should remain.
(await store.LoadAsync(8, default)).ShouldNotBeNull();
(await store.LoadAsync(9, default)).ShouldNotBeNull();
(await store.LoadAsync(10, default)).ShouldNotBeNull();
(await store.LoadAsync(7, default)).ShouldBeNull();
}
// Go: TestFileStoreMsgLimit server/filestore_test.go:484
@@ -312,6 +390,7 @@ public sealed class FileStoreLimitsTests : IDisposable
}
// Go: TestFileStoreAgeLimit server/filestore_test.go:616
[SlopwatchSuppress("SW004", "MaxAge TTL expiry test requires real wall-clock time to elapse; no synchronisation primitive can replace observing time-based expiration")]
[Fact]
public async Task MaxAge_with_interior_deletes()
{