Files
natsdotnet/tests/NATS.Server.JetStream.Tests/JetStream/Storage/FileStorePermutationTests.cs
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

931 lines
38 KiB
C#

// Reference: golang/nats-server/server/filestore_test.go
// Go's testFileStoreAllPermutations (line 55) runs every test across 6 combinations:
// {NoCipher, ChaCha, AES} x {NoCompression, S2Compression}
// This file ports 16 representative tests from that matrix to .NET using
// [Theory] + [MemberData] so each test case executes all 6 permutations
// automatically, giving ~96 total executions.
//
// Covered Go tests (each appears 6 times):
// TestFileStoreBasics (line 86)
// TestFileStoreMsgHeaders (line 152)
// TestFileStoreBasicWriteMsgsAndRestore (line 181)
// TestFileStoreSelectNextFirst (line 304)
// TestFileStoreMsgLimit (line 484)
// TestFileStoreMsgLimitBug (line 518)
// TestFileStoreBytesLimit (line 537)
// TestFileStoreAgeLimit (line 616)
// TestFileStoreTimeStamps (line 683)
// TestFileStorePurge (line 710)
// TestFileStoreCollapseDmap (line 1561)
// TestFileStoreWriteAndReadSameBlock (line 1510)
// TestFileStoreAndRetrieveMultiBlock (line 1527)
// TestFileStoreSnapshot (line 1799)
// TestFileStoreBasics (large payload variant)
// TestFileStoreBasics (sequential ordering variant)
using System.Text;
using NATS.Server.JetStream.Storage;
namespace NATS.Server.JetStream.Tests.JetStream.Storage;
public sealed class FileStorePermutationTests : IDisposable
{
private readonly string _dir;
public FileStorePermutationTests()
{
_dir = Path.Combine(Path.GetTempPath(), $"nats-js-fs-perm-{Guid.NewGuid():N}");
Directory.CreateDirectory(_dir);
}
public void Dispose()
{
if (Directory.Exists(_dir))
Directory.Delete(_dir, recursive: true);
}
// -------------------------------------------------------------------------
// Permutation matrix: {NoCipher, ChaCha, Aes} x {NoCompression, S2Compression}
// Mirrors Go's testFileStoreAllPermutations (filestore_test.go:55).
// -------------------------------------------------------------------------
public static IEnumerable<object[]> AllPermutations()
{
foreach (var cipher in new[] { StoreCipher.NoCipher, StoreCipher.ChaCha, StoreCipher.Aes })
foreach (var compression in new[] { StoreCompression.NoCompression, StoreCompression.S2Compression })
yield return [cipher, compression];
}
/// <summary>
/// Creates a FileStore wired for a specific cipher/compression permutation.
/// Mirrors Go's prf() + newFileStoreWithCreated() pattern (filestore_test.go:73-84).
/// </summary>
private FileStore CreatePermutedStore(string subdir, StoreCipher cipher, StoreCompression compression,
FileStoreOptions? extraOptions = null)
{
var dir = Path.Combine(_dir, subdir);
byte[]? key = null;
if (cipher != StoreCipher.NoCipher)
{
key = new byte[32];
Random.Shared.NextBytes(key);
}
var opts = extraOptions ?? new FileStoreOptions();
opts.Directory = dir;
opts.Cipher = cipher;
opts.Compression = compression;
opts.EncryptionKey = key;
// Keep the legacy boolean flags in sync so existing code paths are not confused.
opts.EnableCompression = compression != StoreCompression.NoCompression;
opts.EnableEncryption = cipher != StoreCipher.NoCipher;
return new FileStore(opts);
}
/// <summary>
/// Creates a permuted store re-using the same key as a previously-created store
/// so that encrypted recovery tests can re-open with the correct key.
/// </summary>
private FileStore ReopenPermutedStore(string subdir, StoreCipher cipher, StoreCompression compression,
byte[]? key, FileStoreOptions? extraOptions = null)
{
var dir = Path.Combine(_dir, subdir);
var opts = extraOptions ?? new FileStoreOptions();
opts.Directory = dir;
opts.Cipher = cipher;
opts.Compression = compression;
opts.EncryptionKey = key;
opts.EnableCompression = compression != StoreCompression.NoCompression;
opts.EnableEncryption = cipher != StoreCipher.NoCipher;
return new FileStore(opts);
}
// Helper: build a stable subdir name from the permutation so test isolation is clear.
private static string PermSubdir(string prefix, StoreCipher cipher, StoreCompression compression)
=> $"{prefix}-{cipher}-{compression}";
// Helper: extract the key from an already-created store's options by re-reading the
// options that were supplied. Because we cannot reach into the store's private field,
// we use a separate dictionary keyed by subdir name.
private readonly Dictionary<string, byte[]?> _keyStore = new();
private FileStore CreatePermutedStoreTracked(string subdir, StoreCipher cipher, StoreCompression compression,
FileStoreOptions? extraOptions = null)
{
var dir = Path.Combine(_dir, subdir);
byte[]? key = null;
if (cipher != StoreCipher.NoCipher)
{
key = new byte[32];
Random.Shared.NextBytes(key);
}
_keyStore[subdir] = key;
var opts = extraOptions ?? new FileStoreOptions();
opts.Directory = dir;
opts.Cipher = cipher;
opts.Compression = compression;
opts.EncryptionKey = key;
opts.EnableCompression = compression != StoreCompression.NoCompression;
opts.EnableEncryption = cipher != StoreCipher.NoCipher;
return new FileStore(opts);
}
private FileStore ReopenTrackedStore(string subdir, StoreCipher cipher, StoreCompression compression,
FileStoreOptions? extraOptions = null)
{
var dir = Path.Combine(_dir, subdir);
var key = _keyStore.GetValueOrDefault(subdir);
var opts = extraOptions ?? new FileStoreOptions();
opts.Directory = dir;
opts.Cipher = cipher;
opts.Compression = compression;
opts.EncryptionKey = key;
opts.EnableCompression = compression != StoreCompression.NoCompression;
opts.EnableEncryption = cipher != StoreCipher.NoCipher;
return new FileStore(opts);
}
// =========================================================================
// Test 1: Basic store and load round-trip
// Go: TestFileStoreBasics server/filestore_test.go:86
// =========================================================================
[Theory]
[MemberData(nameof(AllPermutations))]
public async Task Store_and_load_basic(StoreCipher cipher, StoreCompression compression)
{
// Go: TestFileStoreBasics line 86 — store 5 messages and load by sequence.
var subdir = PermSubdir("basic", cipher, compression);
await using var store = CreatePermutedStore(subdir, cipher, compression);
const string subject = "foo";
var payload = "Hello World"u8.ToArray();
for (var i = 1; i <= 5; i++)
{
var seq = await store.AppendAsync(subject, payload, default);
seq.ShouldBe((ulong)i);
}
var state = await store.GetStateAsync(default);
state.Messages.ShouldBe((ulong)5);
var msg2 = await store.LoadAsync(2, default);
msg2.ShouldNotBeNull();
msg2!.Subject.ShouldBe(subject);
msg2.Payload.ToArray().ShouldBe(payload);
var msg3 = await store.LoadAsync(3, default);
msg3.ShouldNotBeNull();
msg3!.Subject.ShouldBe(subject);
}
// =========================================================================
// Test 2: Store multiple messages, load by sequence
// Go: TestFileStoreBasics server/filestore_test.go:86 (extended variant)
// =========================================================================
[Theory]
[MemberData(nameof(AllPermutations))]
public async Task Store_multiple_messages_load_by_sequence(StoreCipher cipher, StoreCompression compression)
{
// Go: TestFileStoreBasics — verify every message is independently loadable.
var subdir = PermSubdir("multi-seq", cipher, compression);
await using var store = CreatePermutedStore(subdir, cipher, compression);
const int count = 20;
for (var i = 0; i < count; i++)
{
var payload = Encoding.UTF8.GetBytes($"msg-{i:D4}");
await store.AppendAsync("foo", payload, default);
}
var state = await store.GetStateAsync(default);
state.Messages.ShouldBe((ulong)count);
state.FirstSeq.ShouldBe((ulong)1);
state.LastSeq.ShouldBe((ulong)count);
for (ulong i = 1; i <= count; i++)
{
var msg = await store.LoadAsync(i, default);
msg.ShouldNotBeNull();
msg!.Subject.ShouldBe("foo");
var expected = Encoding.UTF8.GetBytes($"msg-{(int)(i - 1):D4}");
msg.Payload.ToArray().ShouldBe(expected);
}
}
// =========================================================================
// Test 3: LoadLastBySubjectAsync
// Go: TestFileStoreBasics server/filestore_test.go:86 (per-subject lookup)
// =========================================================================
[Theory]
[MemberData(nameof(AllPermutations))]
public async Task LoadLastBySubject_returns_most_recent_for_subject(StoreCipher cipher, StoreCompression compression)
{
// Go: TestFileStoreBasics — per-subject last-message lookup.
var subdir = PermSubdir("last-subj", cipher, compression);
await using var store = CreatePermutedStore(subdir, cipher, compression);
await store.AppendAsync("foo", "first"u8.ToArray(), default);
await store.AppendAsync("bar", "other"u8.ToArray(), default);
await store.AppendAsync("foo", "second"u8.ToArray(), default);
await store.AppendAsync("foo", "third"u8.ToArray(), default);
var last = await store.LoadLastBySubjectAsync("foo", default);
last.ShouldNotBeNull();
last!.Payload.ToArray().ShouldBe("third"u8.ToArray());
last.Sequence.ShouldBe((ulong)4);
last.Subject.ShouldBe("foo");
// Non-existent subject returns null.
(await store.LoadLastBySubjectAsync("does.not.exist", default)).ShouldBeNull();
}
// =========================================================================
// Test 4: RemoveAsync single message
// Go: TestFileStoreBasics server/filestore_test.go:129
// =========================================================================
[Theory]
[MemberData(nameof(AllPermutations))]
public async Task Remove_single_message_updates_state(StoreCipher cipher, StoreCompression compression)
{
// Go: TestFileStoreBasics remove section (line 129).
var subdir = PermSubdir("remove-single", cipher, compression);
await using var store = CreatePermutedStore(subdir, cipher, compression);
var payload = "Hello World"u8.ToArray();
for (var i = 0; i < 5; i++)
await store.AppendAsync("foo", payload, default);
// Remove first (seq 1).
(await store.RemoveAsync(1, default)).ShouldBeTrue();
(await store.GetStateAsync(default)).Messages.ShouldBe((ulong)4);
// Remove last (seq 5).
(await store.RemoveAsync(5, default)).ShouldBeTrue();
(await store.GetStateAsync(default)).Messages.ShouldBe((ulong)3);
// Remove middle (seq 3).
(await store.RemoveAsync(3, default)).ShouldBeTrue();
(await store.GetStateAsync(default)).Messages.ShouldBe((ulong)2);
// Surviving sequences loadable.
(await store.LoadAsync(2, default)).ShouldNotBeNull();
(await store.LoadAsync(4, default)).ShouldNotBeNull();
// Removed sequences return null.
(await store.LoadAsync(1, default)).ShouldBeNull();
(await store.LoadAsync(3, default)).ShouldBeNull();
(await store.LoadAsync(5, default)).ShouldBeNull();
}
// =========================================================================
// Test 5: PurgeAsync clears all messages
// Go: TestFileStorePurge server/filestore_test.go:710
// =========================================================================
[Theory]
[MemberData(nameof(AllPermutations))]
public async Task Purge_clears_all_messages(StoreCipher cipher, StoreCompression compression)
{
// Go: TestFileStorePurge line 710 — purge empties the store.
var subdir = PermSubdir("purge", cipher, compression);
await using var store = CreatePermutedStore(subdir, cipher, compression);
for (var i = 0; i < 20; i++)
await store.AppendAsync("foo", "Hello"u8.ToArray(), default);
(await store.GetStateAsync(default)).Messages.ShouldBe((ulong)20);
await store.PurgeAsync(default);
var state = await store.GetStateAsync(default);
state.Messages.ShouldBe((ulong)0);
state.Bytes.ShouldBe((ulong)0);
// Can still append after purge.
var seq = await store.AppendAsync("foo", "after purge"u8.ToArray(), default);
seq.ShouldBeGreaterThan((ulong)0);
var msg = await store.LoadAsync(seq, default);
msg.ShouldNotBeNull();
msg!.Payload.ToArray().ShouldBe("after purge"u8.ToArray());
}
// =========================================================================
// Test 6: TrimToMaxMessages enforcement
// Go: TestFileStoreMsgLimitBug server/filestore_test.go:518
// =========================================================================
[Theory]
[MemberData(nameof(AllPermutations))]
public async Task TrimToMaxMessages_enforces_limit(StoreCipher cipher, StoreCompression compression)
{
// Go: TestFileStoreMsgLimitBug line 518.
var subdir = PermSubdir("trim-limit", cipher, compression);
await using var store = CreatePermutedStore(subdir, cipher, compression);
for (var i = 0; i < 10; i++)
await store.AppendAsync("foo", "Hello World"u8.ToArray(), default);
store.TrimToMaxMessages(5);
var state = await store.GetStateAsync(default);
state.Messages.ShouldBe((ulong)5);
state.FirstSeq.ShouldBe((ulong)6);
state.LastSeq.ShouldBe((ulong)10);
// Evicted messages not loadable.
for (ulong i = 1; i <= 5; i++)
(await store.LoadAsync(i, default)).ShouldBeNull();
// Remaining messages loadable.
for (ulong i = 6; i <= 10; i++)
(await store.LoadAsync(i, default)).ShouldNotBeNull();
}
// =========================================================================
// Test 7: Block rotation when exceeding block size
// Go: TestFileStoreAndRetrieveMultiBlock server/filestore_test.go:1527
// =========================================================================
[Theory]
[MemberData(nameof(AllPermutations))]
public async Task Block_rotation_when_exceeding_block_size(StoreCipher cipher, StoreCompression compression)
{
// Go: TestFileStoreAndRetrieveMultiBlock line 1527 — small block forces rotation.
// Both the initial and the reopened store must share the same key so
// the encrypted data file can be decrypted on reopen.
var subdir = PermSubdir("multi-block", cipher, compression);
// Generate a single key for the lifetime of this test (reopen must reuse it).
byte[]? key = null;
if (cipher != StoreCipher.NoCipher)
{
key = new byte[32];
Random.Shared.NextBytes(key);
}
var opts1 = new FileStoreOptions
{
BlockSizeBytes = 256,
Cipher = cipher,
Compression = compression,
EncryptionKey = key,
EnableCompression = compression != StoreCompression.NoCompression,
EnableEncryption = cipher != StoreCipher.NoCipher,
};
opts1.Directory = Path.Combine(_dir, subdir);
await using (var store = new FileStore(opts1))
{
for (var i = 0; i < 20; i++)
await store.AppendAsync("foo", "Hello World!"u8.ToArray(), default);
var state = await store.GetStateAsync(default);
state.Messages.ShouldBe((ulong)20);
// With a 256-byte block and ~100 bytes per record, multiple blocks form.
store.BlockCount.ShouldBeGreaterThan(1);
}
// Reopen with the same key — all messages must survive block rotation.
var opts2 = new FileStoreOptions
{
BlockSizeBytes = 256,
Cipher = cipher,
Compression = compression,
EncryptionKey = key,
EnableCompression = compression != StoreCompression.NoCompression,
EnableEncryption = cipher != StoreCipher.NoCipher,
};
opts2.Directory = Path.Combine(_dir, subdir);
await using (var store = new FileStore(opts2))
{
for (ulong i = 1; i <= 20; i++)
{
var msg = await store.LoadAsync(i, default);
msg.ShouldNotBeNull();
msg!.Subject.ShouldBe("foo");
}
}
}
// =========================================================================
// Test 8: GetStateAsync returns correct counts
// Go: TestFileStoreBasics server/filestore_test.go:104
// =========================================================================
[Theory]
[MemberData(nameof(AllPermutations))]
public async Task GetState_returns_correct_counts(StoreCipher cipher, StoreCompression compression)
{
// Go: TestFileStoreBasics — state tracks Msgs, Bytes, FirstSeq, LastSeq.
var subdir = PermSubdir("state-counts", cipher, compression);
await using var store = CreatePermutedStore(subdir, cipher, compression);
var state = await store.GetStateAsync(default);
state.Messages.ShouldBe((ulong)0);
state.Bytes.ShouldBe((ulong)0);
state.FirstSeq.ShouldBe((ulong)0);
state.LastSeq.ShouldBe((ulong)0);
var payload = new byte[100];
for (var i = 0; i < 5; i++)
await store.AppendAsync("foo", payload, default);
state = await store.GetStateAsync(default);
state.Messages.ShouldBe((ulong)5);
state.Bytes.ShouldBe((ulong)(5 * 100));
state.FirstSeq.ShouldBe((ulong)1);
state.LastSeq.ShouldBe((ulong)5);
// Remove first and last — state updates accordingly.
await store.RemoveAsync(1, default);
await store.RemoveAsync(5, default);
state = await store.GetStateAsync(default);
state.Messages.ShouldBe((ulong)3);
state.FirstSeq.ShouldBe((ulong)2);
state.LastSeq.ShouldBe((ulong)4);
state.Bytes.ShouldBe((ulong)(3 * 100));
}
// =========================================================================
// Test 9: CreateSnapshotAsync and RestoreSnapshotAsync round-trip
// Go: TestFileStoreSnapshot server/filestore_test.go:1799
// =========================================================================
[Theory]
[MemberData(nameof(AllPermutations))]
public async Task Snapshot_and_restore_round_trip(StoreCipher cipher, StoreCompression compression)
{
// Go: TestFileStoreSnapshot line 1799.
//
// The snapshot blob is produced by CreateSnapshotAsync which calls
// TransformForPersist on each message (i.e. the data is encrypted with the
// src store's key before being embedded in the snapshot). RestoreSnapshotAsync
// then calls RestorePayload on those bytes using its own store's key.
// Therefore src and dst MUST share the same key for encrypted permutations.
var srcSubdir = PermSubdir("snap-src", cipher, compression);
var dstSubdir = PermSubdir("snap-dst", cipher, compression);
// One key shared by both stores.
byte[]? sharedKey = null;
if (cipher != StoreCipher.NoCipher)
{
sharedKey = new byte[32];
Random.Shared.NextBytes(sharedKey);
}
var srcOpts = new FileStoreOptions
{
Cipher = cipher,
Compression = compression,
EncryptionKey = sharedKey,
EnableCompression = compression != StoreCompression.NoCompression,
EnableEncryption = cipher != StoreCipher.NoCipher,
};
srcOpts.Directory = Path.Combine(_dir, srcSubdir);
await using var src = new FileStore(srcOpts);
for (var i = 0; i < 30; i++)
await src.AppendAsync("foo", Encoding.UTF8.GetBytes($"msg-{i}"), default);
var snap = await src.CreateSnapshotAsync(default);
snap.Length.ShouldBeGreaterThan(0);
var dstOpts = new FileStoreOptions
{
Cipher = cipher,
Compression = compression,
EncryptionKey = sharedKey,
EnableCompression = compression != StoreCompression.NoCompression,
EnableEncryption = cipher != StoreCipher.NoCipher,
};
dstOpts.Directory = Path.Combine(_dir, dstSubdir);
await using var dst = new FileStore(dstOpts);
await dst.RestoreSnapshotAsync(snap, default);
var srcState = await src.GetStateAsync(default);
var dstState = await dst.GetStateAsync(default);
dstState.Messages.ShouldBe(srcState.Messages);
dstState.FirstSeq.ShouldBe(srcState.FirstSeq);
dstState.LastSeq.ShouldBe(srcState.LastSeq);
// Verify every message round-trips correctly.
for (ulong i = 1; i <= srcState.Messages; i++)
{
var original = await src.LoadAsync(i, default);
var copy = await dst.LoadAsync(i, default);
copy.ShouldNotBeNull();
copy!.Subject.ShouldBe(original!.Subject);
copy.Payload.ToArray().ShouldBe(original.Payload.ToArray());
}
}
// =========================================================================
// Test 10: ListAsync returns ordered messages
// Go: TestFileStoreTimeStamps server/filestore_test.go:683
// =========================================================================
[Theory]
[MemberData(nameof(AllPermutations))]
public async Task ListAsync_returns_ordered_messages(StoreCipher cipher, StoreCompression compression)
{
// Go: TestFileStoreTimeStamps line 683 — messages returned in sequence order.
var subdir = PermSubdir("list-ordered", cipher, compression);
await using var store = CreatePermutedStore(subdir, cipher, compression);
await store.AppendAsync("foo", "one"u8.ToArray(), default);
await store.AppendAsync("bar", "two"u8.ToArray(), default);
await store.AppendAsync("baz", "three"u8.ToArray(), default);
var messages = await store.ListAsync(default);
messages.Count.ShouldBe(3);
messages[0].Sequence.ShouldBe((ulong)1);
messages[1].Sequence.ShouldBe((ulong)2);
messages[2].Sequence.ShouldBe((ulong)3);
messages[0].Subject.ShouldBe("foo");
messages[1].Subject.ShouldBe("bar");
messages[2].Subject.ShouldBe("baz");
}
// =========================================================================
// Test 11: Max age TTL prunes expired messages
// Go: TestFileStoreAgeLimit server/filestore_test.go:616 (partial — skip
// compression/cipher guard that Go applies to some variants)
// =========================================================================
[Theory]
[MemberData(nameof(AllPermutations))]
public async Task MaxAge_prunes_expired_messages(StoreCipher cipher, StoreCompression compression)
{
// Go: TestFileStoreAgeLimit line 616.
var subdir = PermSubdir("max-age", cipher, compression);
var opts = new FileStoreOptions { MaxAgeMs = 200 };
await using var store = CreatePermutedStore(subdir, cipher, compression, opts);
for (var i = 0; i < 5; i++)
await store.AppendAsync("foo", "Hello World"u8.ToArray(), default);
(await store.GetStateAsync(default)).Messages.ShouldBe((ulong)5);
// Wait for messages to age out.
await Task.Delay(350);
// Trigger pruning by appending a new message.
await store.AppendAsync("foo", "trigger"u8.ToArray(), default);
var state = await store.GetStateAsync(default);
// Only the freshly-appended trigger message should remain.
state.Messages.ShouldBe((ulong)1);
}
// =========================================================================
// Test 12: Recovery after reopen
// Go: TestFileStoreBasicWriteMsgsAndRestore server/filestore_test.go:181
// =========================================================================
[Theory]
[MemberData(nameof(AllPermutations))]
public async Task Recovery_after_reopen_preserves_messages(StoreCipher cipher, StoreCompression compression)
{
// Go: TestFileStoreBasicWriteMsgsAndRestore line 181 — stop and restart.
var subdir = PermSubdir("recovery", cipher, compression);
byte[]? key = null;
if (cipher != StoreCipher.NoCipher)
{
key = new byte[32];
Random.Shared.NextBytes(key);
}
_keyStore[subdir] = key;
await using (var store = ReopenPermutedStore(subdir, cipher, compression, key))
{
for (var i = 1; i <= 100; i++)
{
var payload = Encoding.UTF8.GetBytes($"[{i:D8}] Hello World!");
var seq = await store.AppendAsync("foo", payload, default);
seq.ShouldBe((ulong)i);
}
(await store.GetStateAsync(default)).Messages.ShouldBe((ulong)100);
}
// Reopen with same key and verify all 100 messages survived.
await using (var store = ReopenPermutedStore(subdir, cipher, compression, key))
{
var state = await store.GetStateAsync(default);
state.Messages.ShouldBe((ulong)100);
state.FirstSeq.ShouldBe((ulong)1);
state.LastSeq.ShouldBe((ulong)100);
// Spot-check a few messages.
var msg1 = await store.LoadAsync(1, default);
msg1.ShouldNotBeNull();
msg1!.Payload.ToArray().ShouldBe(Encoding.UTF8.GetBytes("[00000001] Hello World!"));
var msg50 = await store.LoadAsync(50, default);
msg50.ShouldNotBeNull();
msg50!.Payload.ToArray().ShouldBe(Encoding.UTF8.GetBytes("[00000050] Hello World!"));
}
}
// =========================================================================
// Test 13: Large payload (64 KB) store and load
// Go: TestFileStoreBasics server/filestore_test.go:86 (large payload variant)
// =========================================================================
[Theory]
[MemberData(nameof(AllPermutations))]
public async Task Large_payload_store_and_load(StoreCipher cipher, StoreCompression compression)
{
// Go: TestFileStoreBasics — large random payloads must round-trip exactly.
var subdir = PermSubdir("large-payload", cipher, compression);
await using var store = CreatePermutedStore(subdir, cipher, compression);
var payload = new byte[64 * 1024]; // 64 KiB
Random.Shared.NextBytes(payload);
var seq = await store.AppendAsync("foo", payload, default);
seq.ShouldBe((ulong)1);
var msg = await store.LoadAsync(1, default);
msg.ShouldNotBeNull();
msg!.Subject.ShouldBe("foo");
msg.Payload.ToArray().ShouldBe(payload);
}
// =========================================================================
// Test 14: Multiple subjects, filter by subject
// Go: TestFileStoreBasics server/filestore_test.go:86 (multi-subject variant)
// =========================================================================
[Theory]
[MemberData(nameof(AllPermutations))]
public async Task Multiple_subjects_filter_by_subject(StoreCipher cipher, StoreCompression compression)
{
// Go: TestFileStoreBasics — multiple subjects stored; each LoadLastBySubject
// returns the correct one.
var subdir = PermSubdir("multi-subj", cipher, compression);
await using var store = CreatePermutedStore(subdir, cipher, compression);
await store.AppendAsync("foo.bar", "one"u8.ToArray(), default);
await store.AppendAsync("baz.qux", "two"u8.ToArray(), default);
await store.AppendAsync("foo.bar", "three"u8.ToArray(), default);
await store.AppendAsync("baz.qux", "four"u8.ToArray(), default);
var state = await store.GetStateAsync(default);
state.Messages.ShouldBe((ulong)4);
// Check each message's subject.
(await store.LoadAsync(1, default))!.Subject.ShouldBe("foo.bar");
(await store.LoadAsync(2, default))!.Subject.ShouldBe("baz.qux");
(await store.LoadAsync(3, default))!.Subject.ShouldBe("foo.bar");
(await store.LoadAsync(4, default))!.Subject.ShouldBe("baz.qux");
// LoadLastBySubject picks the correct last message per subject.
var lastFoo = await store.LoadLastBySubjectAsync("foo.bar", default);
lastFoo.ShouldNotBeNull();
lastFoo!.Sequence.ShouldBe((ulong)3);
lastFoo.Payload.ToArray().ShouldBe("three"u8.ToArray());
var lastBaz = await store.LoadLastBySubjectAsync("baz.qux", default);
lastBaz.ShouldNotBeNull();
lastBaz!.Sequence.ShouldBe((ulong)4);
lastBaz.Payload.ToArray().ShouldBe("four"u8.ToArray());
}
// =========================================================================
// Test 15: Sequential writes maintain sequence ordering
// Go: TestFileStoreSelectNextFirst server/filestore_test.go:304
// =========================================================================
[Theory]
[MemberData(nameof(AllPermutations))]
public async Task Sequential_writes_maintain_ordering(StoreCipher cipher, StoreCompression compression)
{
// Go: TestFileStoreSelectNextFirst line 304 — remove a run, verify FirstSeq jumps.
var subdir = PermSubdir("seq-order", cipher, compression);
await using var store = CreatePermutedStore(subdir, cipher, compression);
for (var i = 0; i < 10; i++)
await store.AppendAsync("zzz", "Hello World"u8.ToArray(), default);
(await store.GetStateAsync(default)).Messages.ShouldBe((ulong)10);
// Delete 2-7 forming a contiguous gap.
for (var i = 2; i <= 7; i++)
(await store.RemoveAsync((ulong)i, default)).ShouldBeTrue();
var state = await store.GetStateAsync(default);
state.Messages.ShouldBe((ulong)4);
state.FirstSeq.ShouldBe((ulong)1);
// Remove seq 1 — first should jump to 8.
(await store.RemoveAsync(1, default)).ShouldBeTrue();
state = await store.GetStateAsync(default);
state.Messages.ShouldBe((ulong)3);
state.FirstSeq.ShouldBe((ulong)8);
// Sequences 8, 9, 10 must be loadable.
for (ulong i = 8; i <= 10; i++)
(await store.LoadAsync(i, default)).ShouldNotBeNull();
}
// =========================================================================
// Test 16: Store to new directory, verify files created on disk
// Go: TestFileStoreBasics server/filestore_test.go:86 (disk-presence variant)
// =========================================================================
[Theory]
[MemberData(nameof(AllPermutations))]
public async Task Store_creates_files_on_disk(StoreCipher cipher, StoreCompression compression)
{
// Go: TestFileStoreBasics — the store must actually persist data on disk.
var subdir = PermSubdir("disk-presence", cipher, compression);
var dir = Path.Combine(_dir, subdir);
await using var store = CreatePermutedStore(subdir, cipher, compression);
await store.AppendAsync("foo", "Hello World"u8.ToArray(), default);
// The store directory must exist.
Directory.Exists(dir).ShouldBeTrue();
// At least one file must be present (data file or manifest).
var files = Directory.GetFiles(dir, "*", SearchOption.AllDirectories);
files.Length.ShouldBeGreaterThan(0);
}
// =========================================================================
// Test 17: Write-and-read in the same block
// Go: TestFileStoreWriteAndReadSameBlock server/filestore_test.go:1510
// =========================================================================
[Theory]
[MemberData(nameof(AllPermutations))]
public async Task Write_and_read_same_block(StoreCipher cipher, StoreCompression compression)
{
// Go: TestFileStoreWriteAndReadSameBlock line 1510 — interleaved store+load.
var subdir = PermSubdir("same-block", cipher, compression);
await using var store = CreatePermutedStore(subdir, cipher, compression);
const string subject = "foo";
var payload = "Hello World!"u8.ToArray();
for (ulong i = 1; i <= 10; i++)
{
var seq = await store.AppendAsync(subject, payload, default);
seq.ShouldBe(i);
var msg = await store.LoadAsync(i, default);
msg.ShouldNotBeNull();
msg!.Subject.ShouldBe(subject);
msg.Payload.ToArray().ShouldBe(payload);
}
}
// =========================================================================
// Test 18: Timestamps are non-decreasing
// Go: TestFileStoreTimeStamps server/filestore_test.go:683
// =========================================================================
[Theory]
[MemberData(nameof(AllPermutations))]
public async Task Stored_messages_have_non_decreasing_timestamps(StoreCipher cipher, StoreCompression compression)
{
// Go: TestFileStoreTimeStamps line 683.
var subdir = PermSubdir("timestamps", cipher, compression);
await using var store = CreatePermutedStore(subdir, cipher, compression);
for (var i = 0; i < 10; i++)
await store.AppendAsync("foo", "Hello World"u8.ToArray(), default);
var messages = await store.ListAsync(default);
messages.Count.ShouldBe(10);
DateTime? previous = null;
foreach (var msg in messages)
{
if (previous.HasValue)
msg.TimestampUtc.ShouldBeGreaterThanOrEqualTo(previous.Value);
previous = msg.TimestampUtc;
}
}
// =========================================================================
// Test 19: CollapseDmap — out-of-order removes, FirstSeq collapses properly
// Go: TestFileStoreCollapseDmap server/filestore_test.go:1561
// =========================================================================
[Theory]
[MemberData(nameof(AllPermutations))]
public async Task Remove_out_of_order_collapses_first_seq(StoreCipher cipher, StoreCompression compression)
{
// Go: TestFileStoreCollapseDmap line 1561.
var subdir = PermSubdir("dmap", cipher, compression);
await using var store = CreatePermutedStore(subdir, cipher, compression);
for (var i = 0; i < 10; i++)
await store.AppendAsync("foo", "Hello World!"u8.ToArray(), default);
(await store.GetStateAsync(default)).Messages.ShouldBe((ulong)10);
// Remove out of order, forming gaps.
(await store.RemoveAsync(2, default)).ShouldBeTrue();
(await store.RemoveAsync(4, default)).ShouldBeTrue();
(await store.RemoveAsync(8, default)).ShouldBeTrue();
var state = await store.GetStateAsync(default);
state.Messages.ShouldBe((ulong)7);
// Remove first — seq 1 gone, FirstSeq advances to 3.
(await store.RemoveAsync(1, default)).ShouldBeTrue();
state = await store.GetStateAsync(default);
state.Messages.ShouldBe((ulong)6);
state.FirstSeq.ShouldBe((ulong)3);
// Remove seq 3 — FirstSeq advances to 5.
(await store.RemoveAsync(3, default)).ShouldBeTrue();
state = await store.GetStateAsync(default);
state.Messages.ShouldBe((ulong)5);
state.FirstSeq.ShouldBe((ulong)5);
}
// =========================================================================
// Test 20: Snapshot after removes — removed sequences absent from restore
// Go: TestFileStoreSnapshot server/filestore_test.go:1904
// =========================================================================
[Theory]
[MemberData(nameof(AllPermutations))]
public async Task Snapshot_after_removes_preserves_remaining(StoreCipher cipher, StoreCompression compression)
{
// Go: TestFileStoreSnapshot line 1904 — snapshot taken after removes; removed
// sequences must not appear in the restored store.
//
// src and dst share the same key: see comment in Snapshot_and_restore_round_trip.
var srcSubdir = PermSubdir("snap-rm-src", cipher, compression);
var dstSubdir = PermSubdir("snap-rm-dst", cipher, compression);
byte[]? sharedKey = null;
if (cipher != StoreCipher.NoCipher)
{
sharedKey = new byte[32];
Random.Shared.NextBytes(sharedKey);
}
var srcOpts = new FileStoreOptions
{
Cipher = cipher,
Compression = compression,
EncryptionKey = sharedKey,
EnableCompression = compression != StoreCompression.NoCompression,
EnableEncryption = cipher != StoreCipher.NoCipher,
};
srcOpts.Directory = Path.Combine(_dir, srcSubdir);
await using var src = new FileStore(srcOpts);
for (var i = 0; i < 20; i++)
await src.AppendAsync("foo", Encoding.UTF8.GetBytes($"msg-{i}"), default);
// Remove first 5 messages.
for (ulong i = 1; i <= 5; i++)
await src.RemoveAsync(i, default);
var snap = await src.CreateSnapshotAsync(default);
var dstOpts = new FileStoreOptions
{
Cipher = cipher,
Compression = compression,
EncryptionKey = sharedKey,
EnableCompression = compression != StoreCompression.NoCompression,
EnableEncryption = cipher != StoreCipher.NoCipher,
};
dstOpts.Directory = Path.Combine(_dir, dstSubdir);
await using var dst = new FileStore(dstOpts);
await dst.RestoreSnapshotAsync(snap, default);
var dstState = await dst.GetStateAsync(default);
dstState.Messages.ShouldBe((ulong)15);
dstState.FirstSeq.ShouldBe((ulong)6);
// Removed sequences must not be present.
for (ulong i = 1; i <= 5; i++)
(await dst.LoadAsync(i, default)).ShouldBeNull();
// Remaining sequences must be present.
for (ulong i = 6; i <= 20; i++)
(await dst.LoadAsync(i, default)).ShouldNotBeNull();
}
}