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.
472 lines
16 KiB
C#
472 lines
16 KiB
C#
// Reference: golang/nats-server/server/filestore_test.go
|
|
// Tests ported from: TestFileStorePurge, TestFileStoreCompact,
|
|
// TestFileStoreCompactLastPlusOne, TestFileStoreCompactMsgCountBug,
|
|
// TestFileStorePurgeExWithSubject, TestFileStorePurgeExKeepOneBug,
|
|
// TestFileStorePurgeExNoTombsOnBlockRemoval,
|
|
// TestFileStoreStreamTruncate
|
|
|
|
using System.Text;
|
|
using NATS.Server.JetStream.Storage;
|
|
|
|
namespace NATS.Server.JetStream.Tests.JetStream.Storage;
|
|
|
|
public sealed class FileStorePurgeTests : IDisposable
|
|
{
|
|
private readonly string _dir;
|
|
|
|
public FileStorePurgeTests()
|
|
{
|
|
_dir = Path.Combine(Path.GetTempPath(), $"nats-js-fs-purge-{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: TestFileStorePurge server/filestore_test.go:709
|
|
[Fact]
|
|
public async Task Purge_removes_all_messages()
|
|
{
|
|
await using var store = CreateStore("purge-all");
|
|
|
|
for (var i = 0; i < 100; i++)
|
|
await store.AppendAsync("foo", new byte[128], default);
|
|
|
|
(await store.GetStateAsync(default)).Messages.ShouldBe((ulong)100);
|
|
|
|
await store.PurgeAsync(default);
|
|
|
|
var state = await store.GetStateAsync(default);
|
|
state.Messages.ShouldBe((ulong)0);
|
|
state.Bytes.ShouldBe((ulong)0);
|
|
}
|
|
|
|
// Go: TestFileStorePurge server/filestore_test.go:740
|
|
[Fact]
|
|
public async Task Purge_recovers_same_state_after_restart()
|
|
{
|
|
var subDir = "purge-restart";
|
|
|
|
await using (var store = CreateStore(subDir))
|
|
{
|
|
for (var i = 0; i < 50; i++)
|
|
await store.AppendAsync("foo", "Hello"u8.ToArray(), default);
|
|
|
|
await store.PurgeAsync(default);
|
|
}
|
|
|
|
await using (var store = CreateStore(subDir))
|
|
{
|
|
var state = await store.GetStateAsync(default);
|
|
state.Messages.ShouldBe((ulong)0);
|
|
state.Bytes.ShouldBe((ulong)0);
|
|
}
|
|
}
|
|
|
|
// Go: TestFileStorePurge server/filestore_test.go:776
|
|
[Fact]
|
|
public async Task Store_after_purge_works()
|
|
{
|
|
await using var store = CreateStore("purge-then-store");
|
|
|
|
for (var i = 0; i < 20; i++)
|
|
await store.AppendAsync("foo", "Hello"u8.ToArray(), default);
|
|
|
|
await store.PurgeAsync(default);
|
|
|
|
// New messages after purge.
|
|
for (var i = 0; i < 10; i++)
|
|
{
|
|
var seq = await store.AppendAsync("foo", "After purge"u8.ToArray(), default);
|
|
seq.ShouldBeGreaterThan((ulong)0);
|
|
}
|
|
|
|
var state = await store.GetStateAsync(default);
|
|
state.Messages.ShouldBe((ulong)10);
|
|
}
|
|
|
|
// Go: TestFileStoreCompact server/filestore_test.go:822
|
|
[Fact]
|
|
public async Task Compact_removes_messages_below_sequence()
|
|
{
|
|
await using var store = CreateStore("compact-below-seq");
|
|
|
|
for (var i = 0; i < 10; i++)
|
|
await store.AppendAsync("foo", "data"u8.ToArray(), default);
|
|
|
|
// Compact removes all messages with seq < 5, leaving seqs 5-10 (6 messages).
|
|
var removed = store.Compact(5);
|
|
removed.ShouldBe((ulong)4);
|
|
|
|
var state = await store.GetStateAsync(default);
|
|
state.Messages.ShouldBe((ulong)6);
|
|
state.FirstSeq.ShouldBe((ulong)5);
|
|
state.LastSeq.ShouldBe((ulong)10);
|
|
|
|
// Seqs 1-4 must be gone.
|
|
for (ulong seq = 1; seq <= 4; seq++)
|
|
(await store.LoadAsync(seq, default)).ShouldBeNull();
|
|
|
|
// Seqs 5-10 must still be present.
|
|
for (ulong seq = 5; seq <= 10; seq++)
|
|
(await store.LoadAsync(seq, default)).ShouldNotBeNull();
|
|
}
|
|
|
|
// Go: TestFileStoreCompact server/filestore_test.go:851
|
|
[Fact]
|
|
public async Task Compact_beyond_last_seq_resets_first()
|
|
{
|
|
await using var store = CreateStore("compact-beyond-last");
|
|
|
|
for (var i = 0; i < 10; i++)
|
|
await store.AppendAsync("foo", "data"u8.ToArray(), default);
|
|
|
|
// Compact at seq 100 (beyond last seq 10) removes all messages.
|
|
var removed = store.Compact(100);
|
|
removed.ShouldBe((ulong)10);
|
|
|
|
var apiState = await store.GetStateAsync(default);
|
|
apiState.Messages.ShouldBe((ulong)0);
|
|
|
|
// FastState / State() should report _first watermark = 100.
|
|
var state = store.State();
|
|
state.Msgs.ShouldBe((ulong)0);
|
|
state.FirstSeq.ShouldBe((ulong)100);
|
|
}
|
|
|
|
// Go: TestFileStoreCompact server/filestore_test.go:862
|
|
[Fact]
|
|
public async Task Compact_recovers_after_restart()
|
|
{
|
|
var subDir = "compact-restart";
|
|
|
|
await using (var store = CreateStore(subDir))
|
|
{
|
|
for (var i = 0; i < 10; i++)
|
|
await store.AppendAsync("foo", "data"u8.ToArray(), default);
|
|
|
|
store.Compact(5);
|
|
}
|
|
|
|
// Reopen the same directory and verify state is preserved.
|
|
await using (var store = CreateStore(subDir))
|
|
{
|
|
var state = await store.GetStateAsync(default);
|
|
state.Messages.ShouldBe((ulong)6);
|
|
state.FirstSeq.ShouldBe((ulong)5);
|
|
state.LastSeq.ShouldBe((ulong)10);
|
|
}
|
|
}
|
|
|
|
// Go: TestFileStoreCompactLastPlusOne server/filestore_test.go:875
|
|
[Fact]
|
|
public async Task Compact_last_plus_one_clears_all()
|
|
{
|
|
await using var store = CreateStore("compact-last-plus-one");
|
|
|
|
for (var i = 0; i < 10; i++)
|
|
await store.AppendAsync("foo", "data"u8.ToArray(), default);
|
|
|
|
var lastSeq = (await store.GetStateAsync(default)).LastSeq;
|
|
lastSeq.ShouldBe((ulong)10);
|
|
|
|
// Compact at lastSeq+1 removes all messages.
|
|
var removed = store.Compact(lastSeq + 1);
|
|
removed.ShouldBe((ulong)10);
|
|
|
|
var state = await store.GetStateAsync(default);
|
|
state.Messages.ShouldBe((ulong)0);
|
|
}
|
|
|
|
// Go: TestFileStoreCompactMsgCountBug server/filestore_test.go:916
|
|
[Fact]
|
|
public async Task Compact_with_prior_deletes_counts_correctly()
|
|
{
|
|
await using var store = CreateStore("compact-prior-deletes");
|
|
|
|
for (var i = 0; i < 10; i++)
|
|
await store.AppendAsync("foo", "data"u8.ToArray(), default);
|
|
|
|
// Remove seq 3 and 7 before compacting.
|
|
await store.RemoveAsync(3, default);
|
|
await store.RemoveAsync(7, default);
|
|
|
|
// Compact at seq 5: removes seqs < 5 that still exist (1, 2, 4 — seq 3 already gone).
|
|
store.Compact(5);
|
|
|
|
// Remaining: seqs 5, 6, 8, 9, 10 (seq 7 was already deleted).
|
|
var state = await store.GetStateAsync(default);
|
|
state.Messages.ShouldBe((ulong)5);
|
|
state.FirstSeq.ShouldBe((ulong)5);
|
|
state.LastSeq.ShouldBe((ulong)10);
|
|
|
|
// Confirm seq 5, 6, 8, 9, 10 are loadable; 3, 7 are gone.
|
|
(await store.LoadAsync(5, default)).ShouldNotBeNull();
|
|
(await store.LoadAsync(6, default)).ShouldNotBeNull();
|
|
(await store.LoadAsync(8, default)).ShouldNotBeNull();
|
|
(await store.LoadAsync(9, default)).ShouldNotBeNull();
|
|
(await store.LoadAsync(10, default)).ShouldNotBeNull();
|
|
(await store.LoadAsync(3, default)).ShouldBeNull();
|
|
(await store.LoadAsync(7, default)).ShouldBeNull();
|
|
}
|
|
|
|
// Go: TestFileStoreStreamTruncate server/filestore_test.go:991
|
|
[Fact]
|
|
public async Task Truncate_removes_messages_after_sequence()
|
|
{
|
|
await using var store = CreateStore("truncate-after-seq");
|
|
|
|
for (var i = 0; i < 10; i++)
|
|
await store.AppendAsync("foo", "data"u8.ToArray(), default);
|
|
|
|
// Truncate at seq 5: removes seqs > 5, leaving seqs 1-5.
|
|
store.Truncate(5);
|
|
|
|
var state = await store.GetStateAsync(default);
|
|
state.Messages.ShouldBe((ulong)5);
|
|
state.FirstSeq.ShouldBe((ulong)1);
|
|
state.LastSeq.ShouldBe((ulong)5);
|
|
|
|
// Seqs 6-10 must be gone.
|
|
for (ulong seq = 6; seq <= 10; seq++)
|
|
(await store.LoadAsync(seq, default)).ShouldBeNull();
|
|
|
|
// Seqs 1-5 must still be present.
|
|
for (ulong seq = 1; seq <= 5; seq++)
|
|
(await store.LoadAsync(seq, default)).ShouldNotBeNull();
|
|
}
|
|
|
|
// Go: TestFileStoreStreamTruncate server/filestore_test.go:1025
|
|
[Fact]
|
|
public async Task Truncate_with_interior_deletes()
|
|
{
|
|
await using var store = CreateStore("truncate-interior-deletes");
|
|
|
|
for (var i = 0; i < 10; i++)
|
|
await store.AppendAsync("foo", "data"u8.ToArray(), default);
|
|
|
|
// Remove seq 3 and 7 before truncating.
|
|
await store.RemoveAsync(3, default);
|
|
await store.RemoveAsync(7, default);
|
|
|
|
// Truncate at seq 5: removes seqs > 5 that still exist (6, 8, 9, 10 — seq 7 already gone).
|
|
store.Truncate(5);
|
|
|
|
// Remaining: seqs 1, 2, 4, 5 (seq 3 was already deleted).
|
|
var state = await store.GetStateAsync(default);
|
|
state.Messages.ShouldBe((ulong)4);
|
|
state.LastSeq.ShouldBe((ulong)5);
|
|
|
|
(await store.LoadAsync(1, default)).ShouldNotBeNull();
|
|
(await store.LoadAsync(2, default)).ShouldNotBeNull();
|
|
(await store.LoadAsync(3, default)).ShouldBeNull();
|
|
(await store.LoadAsync(4, default)).ShouldNotBeNull();
|
|
(await store.LoadAsync(5, default)).ShouldNotBeNull();
|
|
(await store.LoadAsync(6, default)).ShouldBeNull();
|
|
(await store.LoadAsync(7, default)).ShouldBeNull();
|
|
}
|
|
|
|
// Go: TestFileStorePurgeExWithSubject server/filestore_test.go:3743
|
|
[Fact]
|
|
public async Task PurgeEx_with_subject_removes_matching()
|
|
{
|
|
await using var store = CreateStore("purgeex-subject");
|
|
|
|
// Interleave "foo" and "bar" messages.
|
|
for (var i = 0; i < 5; i++)
|
|
{
|
|
await store.AppendAsync("foo", "foo-data"u8.ToArray(), default);
|
|
await store.AppendAsync("bar", "bar-data"u8.ToArray(), default);
|
|
}
|
|
|
|
var before = await store.GetStateAsync(default);
|
|
before.Messages.ShouldBe((ulong)10);
|
|
|
|
// PurgeEx with subject="foo", seq=0, keep=0: removes all "foo" messages.
|
|
var removed = store.PurgeEx("foo", 0, 0);
|
|
removed.ShouldBe((ulong)5);
|
|
|
|
var state = await store.GetStateAsync(default);
|
|
state.Messages.ShouldBe((ulong)5);
|
|
|
|
// All remaining messages should be on "bar".
|
|
var messages = await store.ListAsync(default);
|
|
messages.Count.ShouldBe(5);
|
|
foreach (var msg in messages)
|
|
msg.Subject.ShouldBe("bar");
|
|
}
|
|
|
|
// Go: TestFileStorePurgeExKeepOneBug server/filestore_test.go:3382
|
|
[Fact]
|
|
public async Task PurgeEx_keep_one_preserves_last()
|
|
{
|
|
await using var store = CreateStore("purgeex-keep-one");
|
|
|
|
ulong lastSeq = 0;
|
|
for (var i = 0; i < 5; i++)
|
|
lastSeq = await store.AppendAsync("foo", "data"u8.ToArray(), default);
|
|
|
|
lastSeq.ShouldBe((ulong)5);
|
|
|
|
// PurgeEx with keep=1: should remove 4 messages, keeping only the last one.
|
|
var removed = store.PurgeEx("foo", 0, 1);
|
|
removed.ShouldBe((ulong)4);
|
|
|
|
var state = await store.GetStateAsync(default);
|
|
state.Messages.ShouldBe((ulong)1);
|
|
|
|
// The remaining message must be the one with the highest sequence.
|
|
var remaining = await store.ListAsync(default);
|
|
remaining.Count.ShouldBe(1);
|
|
remaining[0].Sequence.ShouldBe(lastSeq);
|
|
}
|
|
|
|
// Go: TestFileStorePurgeExNoTombsOnBlockRemoval server/filestore_test.go:3823
|
|
[Fact]
|
|
public async Task PurgeEx_no_tombstones_on_block_removal()
|
|
{
|
|
await using var store = CreateStore("purgeex-no-tombs");
|
|
|
|
// Store messages on "foo" and "bar".
|
|
for (var i = 0; i < 5; i++)
|
|
await store.AppendAsync("foo", "foo-data"u8.ToArray(), default);
|
|
|
|
var barSeqs = new List<ulong>();
|
|
for (var i = 0; i < 5; i++)
|
|
barSeqs.Add(await store.AppendAsync("bar", "bar-data"u8.ToArray(), default));
|
|
|
|
// PurgeEx removes all "foo" messages.
|
|
store.PurgeEx("foo", 0, 0);
|
|
|
|
// "bar" messages must still be loadable and state must be consistent.
|
|
foreach (var seq in barSeqs)
|
|
{
|
|
var msg = await store.LoadAsync(seq, default);
|
|
msg.ShouldNotBeNull();
|
|
msg!.Subject.ShouldBe("bar");
|
|
}
|
|
|
|
var state = await store.GetStateAsync(default);
|
|
state.Messages.ShouldBe((ulong)5);
|
|
}
|
|
|
|
// Go: TestFileStorePurge server/filestore_test.go:709
|
|
[Fact]
|
|
public async Task Purge_then_list_returns_empty()
|
|
{
|
|
await using var store = CreateStore("purge-list");
|
|
|
|
for (var i = 0; i < 10; i++)
|
|
await store.AppendAsync("foo", "data"u8.ToArray(), default);
|
|
|
|
await store.PurgeAsync(default);
|
|
|
|
var messages = await store.ListAsync(default);
|
|
messages.Count.ShouldBe(0);
|
|
}
|
|
|
|
// Go: TestFileStorePurge server/filestore_test.go:709
|
|
[Fact]
|
|
public async Task Multiple_purges_are_safe()
|
|
{
|
|
await using var store = CreateStore("multi-purge");
|
|
|
|
for (var i = 0; i < 5; i++)
|
|
await store.AppendAsync("foo", "data"u8.ToArray(), default);
|
|
|
|
await store.PurgeAsync(default);
|
|
await store.PurgeAsync(default); // Double purge should not error.
|
|
|
|
(await store.GetStateAsync(default)).Messages.ShouldBe((ulong)0);
|
|
}
|
|
|
|
// Go: TestFileStorePurge server/filestore_test.go:709
|
|
[Fact]
|
|
public async Task Purge_empty_store_is_safe()
|
|
{
|
|
await using var store = CreateStore("purge-empty");
|
|
|
|
await store.PurgeAsync(default);
|
|
|
|
(await store.GetStateAsync(default)).Messages.ShouldBe((ulong)0);
|
|
}
|
|
|
|
// Go: TestFileStorePurge server/filestore_test.go:709
|
|
[Fact]
|
|
public async Task Purge_with_prior_removes()
|
|
{
|
|
await using var store = CreateStore("purge-prior-rm");
|
|
|
|
for (var i = 0; i < 10; i++)
|
|
await store.AppendAsync("foo", "data"u8.ToArray(), default);
|
|
|
|
// Remove some messages first.
|
|
await store.RemoveAsync(2, default);
|
|
await store.RemoveAsync(4, default);
|
|
await store.RemoveAsync(6, default);
|
|
|
|
(await store.GetStateAsync(default)).Messages.ShouldBe((ulong)7);
|
|
|
|
await store.PurgeAsync(default);
|
|
|
|
var state = await store.GetStateAsync(default);
|
|
state.Messages.ShouldBe((ulong)0);
|
|
state.Bytes.ShouldBe((ulong)0);
|
|
}
|
|
|
|
// Go: TestFileStorePurge server/filestore_test.go:776
|
|
[Fact]
|
|
public async Task Purge_then_store_then_purge_again()
|
|
{
|
|
await using var store = CreateStore("purge-cycle");
|
|
|
|
for (var i = 0; i < 5; i++)
|
|
await store.AppendAsync("foo", "data"u8.ToArray(), default);
|
|
|
|
await store.PurgeAsync(default);
|
|
|
|
for (var i = 0; i < 3; i++)
|
|
await store.AppendAsync("foo", "new data"u8.ToArray(), default);
|
|
|
|
(await store.GetStateAsync(default)).Messages.ShouldBe((ulong)3);
|
|
|
|
await store.PurgeAsync(default);
|
|
(await store.GetStateAsync(default)).Messages.ShouldBe((ulong)0);
|
|
}
|
|
|
|
// Go: TestFileStorePurge server/filestore_test.go:709
|
|
[Fact]
|
|
public async Task Purge_data_file_is_deleted()
|
|
{
|
|
var subDir = "purge-file";
|
|
var dir = Path.Combine(_dir, subDir);
|
|
|
|
await using (var store = CreateStore(subDir))
|
|
{
|
|
for (var i = 0; i < 10; i++)
|
|
await store.AppendAsync("foo", "data"u8.ToArray(), default);
|
|
|
|
await store.PurgeAsync(default);
|
|
}
|
|
|
|
// The data file should be cleaned up or empty after purge.
|
|
var dataFile = Path.Combine(dir, "messages.jsonl");
|
|
if (File.Exists(dataFile))
|
|
{
|
|
var content = File.ReadAllText(dataFile);
|
|
content.Trim().ShouldBeEmpty();
|
|
}
|
|
}
|
|
}
|