Files
Joseph Doherty 78b4bc2486 refactor: extract NATS.Server.JetStream.Tests project
Move 225 JetStream-related test files from NATS.Server.Tests into a
dedicated NATS.Server.JetStream.Tests project. This includes root-level
JetStream*.cs files, storage test files (FileStore, MemStore,
StreamStoreContract), and the full JetStream/ subfolder tree (Api,
Cluster, Consumers, MirrorSource, Snapshots, Storage, Streams).

Updated all namespaces, added InternalsVisibleTo, registered in the
solution file, and added the JETSTREAM_INTEGRATION_MATRIX define.
2026-03-12 15:58:10 -04:00

440 lines
15 KiB
C#

// Reference: golang/nats-server/server/filestore_test.go
// Tests ported from: TestFileStoreRemovePartialRecovery,
// TestFileStoreRemoveOutOfOrderRecovery,
// TestFileStoreAgeLimitRecovery, TestFileStoreBitRot,
// TestFileStoreEraseAndNoIndexRecovery,
// TestFileStoreExpireMsgsOnStart,
// TestFileStoreRebuildStateDmapAccountingBug,
// TestFileStoreRecalcFirstSequenceBug,
// TestFileStoreFullStateBasics
using System.Text;
using NATS.Server.JetStream.Storage;
namespace NATS.Server.JetStream.Tests.JetStream.Storage;
public sealed class FileStoreRecoveryTests : IDisposable
{
private readonly string _dir;
public FileStoreRecoveryTests()
{
_dir = Path.Combine(Path.GetTempPath(), $"nats-js-fs-recovery-{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: TestFileStoreRemovePartialRecovery server/filestore_test.go:1076
[Fact]
public async Task Remove_half_then_recover()
{
var subDir = "partial-recovery";
await using (var store = CreateStore(subDir))
{
for (var i = 0; i < 100; i++)
await store.AppendAsync("foo", "Hello World"u8.ToArray(), default);
// Remove first half.
for (ulong i = 1; i <= 50; i++)
await store.RemoveAsync(i, default);
var state = await store.GetStateAsync(default);
state.Messages.ShouldBe((ulong)50);
}
// Recover and verify state matches.
await using (var store = CreateStore(subDir))
{
var state = await store.GetStateAsync(default);
state.Messages.ShouldBe((ulong)50);
state.FirstSeq.ShouldBe((ulong)51);
state.LastSeq.ShouldBe((ulong)100);
// Verify removed messages are gone.
for (ulong i = 1; i <= 50; i++)
(await store.LoadAsync(i, default)).ShouldBeNull();
// Verify remaining messages are present.
for (ulong i = 51; i <= 100; i++)
(await store.LoadAsync(i, default)).ShouldNotBeNull();
}
}
// Go: TestFileStoreRemoveOutOfOrderRecovery server/filestore_test.go:1119
[Fact]
public async Task Remove_evens_then_recover()
{
var subDir = "ooo-recovery";
await using (var store = CreateStore(subDir))
{
for (var i = 0; i < 100; i++)
await store.AppendAsync("foo", "Hello World"u8.ToArray(), default);
// Remove even-numbered sequences.
for (var i = 2; i <= 100; i += 2)
(await store.RemoveAsync((ulong)i, default)).ShouldBeTrue();
var state = await store.GetStateAsync(default);
state.Messages.ShouldBe((ulong)50);
}
// Recover and verify.
await using (var store = CreateStore(subDir))
{
var state = await store.GetStateAsync(default);
state.Messages.ShouldBe((ulong)50);
// Seq 1 should exist.
(await store.LoadAsync(1, default)).ShouldNotBeNull();
// Even sequences should be gone.
for (var i = 2; i <= 100; i += 2)
(await store.LoadAsync((ulong)i, default)).ShouldBeNull();
// Odd sequences should exist.
for (var i = 1; i <= 99; i += 2)
(await store.LoadAsync((ulong)i, default)).ShouldNotBeNull();
}
}
// Go: TestFileStoreAgeLimitRecovery server/filestore_test.go:1183
[Fact]
public async Task Age_limit_recovery_expires_on_restart()
{
var subDir = "age-recovery";
await using (var store = CreateStore(subDir, new FileStoreOptions { MaxAgeMs = 200 }))
{
for (var i = 0; i < 20; i++)
await store.AppendAsync("foo", "Hello World"u8.ToArray(), default);
(await store.GetStateAsync(default)).Messages.ShouldBe((ulong)20);
}
// Wait for messages to age out.
await Task.Delay(300);
// Reopen — expired messages should be pruned on load.
await using (var store = CreateStore(subDir, new FileStoreOptions { MaxAgeMs = 200 }))
{
// Trigger prune by appending.
await store.AppendAsync("foo", "trigger"u8.ToArray(), default);
var state = await store.GetStateAsync(default);
state.Messages.ShouldBe((ulong)1);
}
}
// Go: TestFileStoreEraseAndNoIndexRecovery server/filestore_test.go:1363
[Fact]
public async Task Remove_evens_then_recover_without_index()
{
var subDir = "no-index-recovery";
var dir = Path.Combine(_dir, subDir);
await using (var store = CreateStore(subDir))
{
for (var i = 0; i < 100; i++)
await store.AppendAsync("foo", "Hello World"u8.ToArray(), default);
// Remove even-numbered sequences.
for (var i = 2; i <= 100; i += 2)
(await store.RemoveAsync((ulong)i, default)).ShouldBeTrue();
(await store.GetStateAsync(default)).Messages.ShouldBe((ulong)50);
}
// Remove the index manifest file to force a full rebuild.
var manifestPath = Path.Combine(dir, "index.manifest.json");
if (File.Exists(manifestPath))
File.Delete(manifestPath);
// Recover without index manifest.
await using (var store = CreateStore(subDir))
{
var state = await store.GetStateAsync(default);
state.Messages.ShouldBe((ulong)50);
// Even sequences should still be gone.
for (var i = 2; i <= 100; i += 2)
(await store.LoadAsync((ulong)i, default)).ShouldBeNull();
// Odd sequences should exist.
for (var i = 1; i <= 99; i += 2)
(await store.LoadAsync((ulong)i, default)).ShouldNotBeNull();
}
}
// Go: TestFileStoreBitRot server/filestore_test.go:1229
[Fact]
public async Task Corrupted_data_file_loses_messages_but_store_recovers()
{
var subDir = "bitrot";
var dir = Path.Combine(_dir, subDir);
await using (var store = CreateStore(subDir))
{
for (var i = 0; i < 20; i++)
await store.AppendAsync("foo", "Hello World"u8.ToArray(), default);
}
// Corrupt the data file by writing random bytes in the middle.
var dataFile = Path.Combine(dir, "messages.jsonl");
if (File.Exists(dataFile))
{
var content = File.ReadAllBytes(dataFile);
if (content.Length > 50)
{
// Corrupt some bytes in the middle.
content[content.Length / 2] = 0xFF;
content[content.Length / 2 + 1] = 0xFE;
File.WriteAllBytes(dataFile, content);
}
}
// Recovery should not throw; it may lose some messages though.
await using (var store = CreateStore(subDir))
{
var state = await store.GetStateAsync(default);
// We may lose messages due to corruption, but at least some should survive
// if the corruption only affected one record.
// The key point is that the store recovered without throwing.
state.Messages.ShouldBeGreaterThanOrEqualTo((ulong)0);
}
}
// Go: TestFileStoreFullStateBasics server/filestore_test.go:5461
[Fact]
public async Task Full_state_recovery_preserves_all_messages()
{
var subDir = "full-state";
await using (var store = CreateStore(subDir))
{
for (var i = 0; i < 50; i++)
await store.AppendAsync("foo", Encoding.UTF8.GetBytes($"msg-{i}"), default);
for (var i = 0; i < 50; i++)
await store.AppendAsync("bar", Encoding.UTF8.GetBytes($"msg-{i}"), default);
}
await using (var store = CreateStore(subDir))
{
var state = await store.GetStateAsync(default);
state.Messages.ShouldBe((ulong)100);
state.FirstSeq.ShouldBe((ulong)1);
state.LastSeq.ShouldBe((ulong)100);
var msg1 = await store.LoadAsync(1, default);
msg1.ShouldNotBeNull();
msg1!.Subject.ShouldBe("foo");
var msg51 = await store.LoadAsync(51, default);
msg51.ShouldNotBeNull();
msg51!.Subject.ShouldBe("bar");
}
}
// Go: TestFileStoreExpireMsgsOnStart server/filestore_test.go:3018
[Fact]
public async Task Expire_on_restart_with_different_maxage()
{
var subDir = "expire-on-start";
// Store with no age limit.
await using (var store = CreateStore(subDir))
{
for (var i = 0; i < 10; i++)
await store.AppendAsync("foo", "Hello"u8.ToArray(), default);
}
await Task.Delay(100);
// Reopen with an age limit that will expire all old messages.
await using (var store = CreateStore(subDir, new FileStoreOptions { MaxAgeMs = 50 }))
{
// Trigger pruning.
await store.AppendAsync("foo", "trigger"u8.ToArray(), default);
var state = await store.GetStateAsync(default);
state.Messages.ShouldBe((ulong)1);
}
}
// Go: TestFileStoreRemovePartialRecovery server/filestore_test.go:1076
[Fact]
public async Task Remove_then_append_then_recover()
{
var subDir = "rm-append-recover";
await using (var store = CreateStore(subDir))
{
for (var i = 0; i < 10; i++)
await store.AppendAsync("foo", "Hello"u8.ToArray(), default);
await store.RemoveAsync(5, default);
await store.AppendAsync("foo", "After remove"u8.ToArray(), default);
var state = await store.GetStateAsync(default);
state.Messages.ShouldBe((ulong)10);
state.LastSeq.ShouldBe((ulong)11);
}
await using (var store = CreateStore(subDir))
{
var state = await store.GetStateAsync(default);
state.Messages.ShouldBe((ulong)10);
state.LastSeq.ShouldBe((ulong)11);
(await store.LoadAsync(5, default)).ShouldBeNull();
(await store.LoadAsync(11, default)).ShouldNotBeNull();
}
}
// Go: TestFileStoreRecalcFirstSequenceBug server/filestore_test.go:5405
[Fact]
public async Task Recovery_preserves_first_seq_after_removes()
{
var subDir = "first-seq-recovery";
await using (var store = CreateStore(subDir))
{
for (var i = 0; i < 20; i++)
await store.AppendAsync("foo", "data"u8.ToArray(), default);
// Remove first 10.
for (ulong i = 1; i <= 10; i++)
await store.RemoveAsync(i, default);
var state = await store.GetStateAsync(default);
state.FirstSeq.ShouldBe((ulong)11);
}
await using (var store = CreateStore(subDir))
{
var state = await store.GetStateAsync(default);
state.FirstSeq.ShouldBe((ulong)11);
state.Messages.ShouldBe((ulong)10);
}
}
// Go: TestFileStoreRebuildStateDmapAccountingBug server/filestore_test.go:3692
[Fact]
public async Task Recovery_with_scattered_deletes_preserves_count()
{
var subDir = "scattered-deletes";
await using (var store = CreateStore(subDir))
{
for (var i = 0; i < 50; i++)
await store.AppendAsync("foo", "data"u8.ToArray(), default);
// Delete scattered: every 3rd.
for (var i = 3; i <= 50; i += 3)
await store.RemoveAsync((ulong)i, default);
var expectedCount = 50 - (50 / 3);
var state = await store.GetStateAsync(default);
state.Messages.ShouldBe((ulong)expectedCount);
}
await using (var store = CreateStore(subDir))
{
var expectedCount = 50 - (50 / 3);
var state = await store.GetStateAsync(default);
state.Messages.ShouldBe((ulong)expectedCount);
}
}
// Go: TestFileStoreBasicWriteMsgsAndRestore server/filestore_test.go:181
[Fact]
public async Task Recovery_preserves_message_payloads()
{
var subDir = "payload-recovery";
await using (var store = CreateStore(subDir))
{
for (var i = 0; i < 10; i++)
await store.AppendAsync("foo", Encoding.UTF8.GetBytes($"message-{i}"), default);
}
await using (var store = CreateStore(subDir))
{
for (ulong i = 1; i <= 10; i++)
{
var msg = await store.LoadAsync(i, default);
msg.ShouldNotBeNull();
msg!.Subject.ShouldBe("foo");
var expected = Encoding.UTF8.GetBytes($"message-{i - 1}");
msg.Payload.ToArray().ShouldBe(expected);
}
}
}
// Go: TestFileStoreBasicWriteMsgsAndRestore server/filestore_test.go:181
[Fact]
public async Task Recovery_preserves_subjects()
{
var subDir = "subject-recovery";
await using (var store = CreateStore(subDir))
{
await store.AppendAsync("alpha", "one"u8.ToArray(), default);
await store.AppendAsync("beta", "two"u8.ToArray(), default);
await store.AppendAsync("gamma", "three"u8.ToArray(), default);
}
await using (var store = CreateStore(subDir))
{
var msg1 = await store.LoadAsync(1, default);
msg1.ShouldNotBeNull();
msg1!.Subject.ShouldBe("alpha");
var msg2 = await store.LoadAsync(2, default);
msg2.ShouldNotBeNull();
msg2!.Subject.ShouldBe("beta");
var msg3 = await store.LoadAsync(3, default);
msg3.ShouldNotBeNull();
msg3!.Subject.ShouldBe("gamma");
}
}
// Go: TestFileStoreRemoveOutOfOrderRecovery server/filestore_test.go:1119
[Fact]
public async Task Recovery_with_large_message_count()
{
var subDir = "large-recovery";
await using (var store = CreateStore(subDir))
{
for (var i = 0; i < 500; i++)
await store.AppendAsync("foo", Encoding.UTF8.GetBytes($"msg-{i:D4}"), default);
}
await using (var store = CreateStore(subDir))
{
var state = await store.GetStateAsync(default);
state.Messages.ShouldBe((ulong)500);
state.FirstSeq.ShouldBe((ulong)1);
state.LastSeq.ShouldBe((ulong)500);
}
}
}