// Reference: golang/nats-server/server/filestore_test.go
// Tests ported in this file:
// TestFileStoreReadCache → ReadCache_StoreAndLoadMessages
// TestFileStorePartialCacheExpiration → PartialCacheExpiration_LoadAfterExpiry
// TestFileStoreRememberLastMsgTime → RememberLastMsgTime_PreservesTimestampAfterDelete
// TestFileStoreStreamDeleteCacheBug → StreamDelete_SecondMessageLoadableAfterFirst
// TestFileStoreAllLastSeqs → AllLastSeqs_ReturnsLastPerSubjectSorted
// TestFileStoreSubjectForSeq → SubjectForSeq_ReturnsCorrectSubject
// TestFileStoreRecoverOnlyBlkFiles → Recovery_OnlyBlkFiles_StatePreserved
// TestFileStoreRecoverAfterRemoveOperation → Recovery_AfterRemove_StateMatch
// TestFileStoreRecoverAfterCompact → Recovery_AfterCompact_StateMatch
// TestFileStoreRecoverWithEmptyMessageBlock → Recovery_WithEmptyMessageBlock
// TestFileStoreRemoveMsgBlockFirst → RemoveMsgBlock_First_StartsEmpty
// TestFileStoreRemoveMsgBlockLast → RemoveMsgBlock_Last_AfterDelete
// TestFileStoreSparseCompactionWithInteriorDeletes → SparseCompaction_WithInteriorDeletes
// TestFileStorePurgeExKeepOneBug → PurgeEx_KeepOne_RemovesOne
// TestFileStoreCompactReclaimHeadSpace → Compact_ReclaimsHeadSpace_MultiBlock
// TestFileStorePreserveLastSeqAfterCompact → Compact_PreservesLastSeq_AfterAllRemoved
// TestFileStoreMessageTTLRecoveredSingleMessageWithoutStreamState → TTL_RecoverSingleMessageWithoutStreamState
// TestFileStoreMessageTTLWriteTombstone → TTL_WriteTombstone_RecoverAfterTombstone
// TestFileStoreMessageTTLRecoveredOffByOne → TTL_RecoveredOffByOne
// TestFileStoreNumPendingMulti → NumPending_MultiSubjectFilter
// TestFileStoreCorruptedNonOrderedSequences → CorruptedNonOrderedSequences_StatCorrected
// TestFileStoreDeleteRangeTwoGaps → DeleteRange_TwoGaps_AreDistinct
// TestFileStoreSkipMsgs → SkipMsgs_ReservesSequences
// TestFileStoreFilteredFirstMatchingBug → FilteredState_CorrectAfterSubjectChange
using NATS.Server.JetStream.Storage;
namespace NATS.Server.JetStream.Tests.JetStream.Storage;
///
/// Go FileStore parity tests. Each test mirrors a specific Go test from
/// golang/nats-server/server/filestore_test.go to verify behaviour parity.
///
public sealed class FileStoreGoParityTests : IDisposable
{
private readonly string _root;
public FileStoreGoParityTests()
{
_root = Path.Combine(Path.GetTempPath(), $"nats-js-goparity-{Guid.NewGuid():N}");
Directory.CreateDirectory(_root);
}
public void Dispose()
{
if (Directory.Exists(_root))
{
try { Directory.Delete(_root, recursive: true); }
catch { /* best-effort cleanup */ }
}
}
private FileStore CreateStore(string subDir, FileStoreOptions? opts = null)
{
var dir = Path.Combine(_root, subDir);
Directory.CreateDirectory(dir);
var o = opts ?? new FileStoreOptions();
o.Directory = dir;
return new FileStore(o);
}
// -------------------------------------------------------------------------
// Basic store/load tests
// -------------------------------------------------------------------------
// Go: TestFileStoreReadCache server/filestore_test.go:1630
// Verifies that messages can be stored and later loaded successfully.
// The Go test also checks CacheExpire timing; here we focus on the
// core read-after-write semantics that don't require internal timing hooks.
[Fact]
public void ReadCache_StoreAndLoadMessages()
{
using var store = CreateStore("read-cache");
const string subj = "foo.bar";
var msg = new byte[1024];
new Random(42).NextBytes(msg);
const int toStore = 20;
for (var i = 0; i < toStore; i++)
store.StoreMsg(subj, null, msg, 0);
// All messages should be loadable.
for (ulong seq = 1; seq <= toStore; seq++)
{
var sm = store.LoadMsg(seq, null);
sm.Subject.ShouldBe(subj);
sm.Data.ShouldNotBeNull();
sm.Data!.Length.ShouldBe(msg.Length);
}
var state = store.State();
state.Msgs.ShouldBe((ulong)toStore);
state.FirstSeq.ShouldBe(1UL);
state.LastSeq.ShouldBe((ulong)toStore);
}
// Go: TestFileStorePartialCacheExpiration server/filestore_test.go:1683
// Verifies that after storing messages and removing earlier ones,
// the later message is still loadable.
[Fact]
public void PartialCacheExpiration_LoadAfterExpiry()
{
using var store = CreateStore("partial-cache-exp");
store.StoreMsg("foo", null, "msg1"u8.ToArray(), 0);
store.StoreMsg("bar", null, "msg2"u8.ToArray(), 0);
// Remove seq 1, seq 2 must still be loadable.
store.RemoveMsg(1);
var sm = store.LoadMsg(2, null);
sm.Subject.ShouldBe("bar");
sm.Data.ShouldBe("msg2"u8.ToArray());
}
// Go: TestFileStoreRememberLastMsgTime server/filestore_test.go:3583
// After removing a message, the store's LastSeq must still reflect the
// highest sequence ever written (not dropped to the previous).
[Fact]
public void RememberLastMsgTime_PreservesTimestampAfterDelete()
{
using var store = CreateStore("remember-last");
var (seq1, _) = store.StoreMsg("foo", null, "Hello"u8.ToArray(), 0);
var (seq2, _) = store.StoreMsg("foo", null, "World"u8.ToArray(), 0);
seq1.ShouldBe(1UL);
seq2.ShouldBe(2UL);
// Remove first message.
store.RemoveMsg(seq1).ShouldBeTrue();
var state = store.State();
state.Msgs.ShouldBe(1UL);
// LastSeq must still be 2 (the highest ever assigned).
state.LastSeq.ShouldBe(seq2);
// Remove last message — LastSeq stays at 2.
store.RemoveMsg(seq2).ShouldBeTrue();
var stateAfter = store.State();
stateAfter.Msgs.ShouldBe(0UL);
stateAfter.LastSeq.ShouldBe(seq2);
}
// Go: TestFileStoreStreamDeleteCacheBug server/filestore_test.go:2938
// After erasing/removing the first message, the second message must remain
// loadable even after a simulated cache expiry scenario.
[Fact]
public void StreamDelete_SecondMessageLoadableAfterFirst()
{
using var store = CreateStore("stream-delete-cache");
const string subj = "foo";
var msg = "Hello World"u8.ToArray();
store.StoreMsg(subj, null, msg, 0);
store.StoreMsg(subj, null, msg, 0);
// Erase (or remove) first message.
store.EraseMsg(1).ShouldBeTrue();
// Second message must still be loadable.
var sm = store.LoadMsg(2, null);
sm.Subject.ShouldBe(subj);
sm.Data.ShouldBe(msg);
}
// Go: TestFileStoreAllLastSeqs server/filestore_test.go:9731
// AllLastSeqs should return the last sequence per subject, sorted ascending.
[Fact]
public void AllLastSeqs_ReturnsLastPerSubjectSorted()
{
using var store = CreateStore("all-last-seqs");
var subjects = new[] { "foo.foo", "foo.bar", "foo.baz", "bar.foo", "bar.bar", "bar.baz" };
var msg = "abc"u8.ToArray();
var rng = new Random(17);
// Store 1000 messages with random subjects.
for (var i = 0; i < 1000; i++)
{
var subj = subjects[rng.Next(subjects.Length)];
store.StoreMsg(subj, null, msg, 0);
}
// Manually compute expected: last seq per subject.
var expected = new List();
foreach (var subj in subjects)
{
// Try to load last msg for each subject.
try
{
var sm = store.LoadLastMsg(subj, null);
expected.Add(sm.Sequence);
}
catch (KeyNotFoundException)
{
// Subject may not have been written to with random selection — skip.
}
}
expected.Sort();
var actual = store.AllLastSeqs();
actual.ShouldBe([.. expected]);
}
// Go: TestFileStoreSubjectForSeq server/filestore_test.go:9852
[Fact]
public void SubjectForSeq_ReturnsCorrectSubject()
{
using var store = CreateStore("subj-for-seq");
var (seq, _) = store.StoreMsg("foo.bar", null, Array.Empty(), 0);
seq.ShouldBe(1UL);
// Sequence 0 doesn't exist.
Should.Throw(() => store.SubjectForSeq(0));
// Sequence 1 should return "foo.bar".
store.SubjectForSeq(1).ShouldBe("foo.bar");
// Sequence 2 doesn't exist yet.
Should.Throw(() => store.SubjectForSeq(2));
}
// -------------------------------------------------------------------------
// Recovery tests
// -------------------------------------------------------------------------
// Go: TestFileStoreRecoverOnlyBlkFiles server/filestore_test.go:9225
// Store a message, stop, restart — state should be preserved.
[Fact]
public void Recovery_OnlyBlkFiles_StatePreserved()
{
var subDir = Path.Combine(_root, "recover-blk");
Directory.CreateDirectory(subDir);
// Create and populate store.
StreamState before;
{
var opts = new FileStoreOptions { Directory = subDir };
using var store = new FileStore(opts);
store.StoreMsg("foo", null, Array.Empty(), 0);
before = store.State();
before.Msgs.ShouldBe(1UL);
before.FirstSeq.ShouldBe(1UL);
before.LastSeq.ShouldBe(1UL);
}
// Restart — state must match.
{
var opts = new FileStoreOptions { Directory = subDir };
using var store = new FileStore(opts);
var after = store.State();
after.Msgs.ShouldBe(before.Msgs);
after.FirstSeq.ShouldBe(before.FirstSeq);
after.LastSeq.ShouldBe(before.LastSeq);
}
}
// Go: TestFileStoreRecoverAfterRemoveOperation server/filestore_test.go:9288
// After storing 4 messages and removing one, state is preserved across restart.
[Fact]
public void Recovery_AfterRemove_StateMatch()
{
var subDir = Path.Combine(_root, "recover-remove");
Directory.CreateDirectory(subDir);
StreamState before;
{
var opts = new FileStoreOptions { Directory = subDir };
using var store = new FileStore(opts);
store.StoreMsg("foo.0", null, Array.Empty(), 0);
store.StoreMsg("foo.1", null, Array.Empty(), 0);
store.StoreMsg("foo.0", null, Array.Empty(), 0);
store.StoreMsg("foo.1", null, Array.Empty(), 0);
// Remove first message.
store.RemoveMsg(1).ShouldBeTrue();
before = store.State();
before.Msgs.ShouldBe(3UL);
before.FirstSeq.ShouldBe(2UL);
before.LastSeq.ShouldBe(4UL);
}
// Restart — state must match.
{
var opts = new FileStoreOptions { Directory = subDir };
using var store = new FileStore(opts);
var after = store.State();
after.Msgs.ShouldBe(before.Msgs);
after.FirstSeq.ShouldBe(before.FirstSeq);
after.LastSeq.ShouldBe(before.LastSeq);
}
}
// Go: TestFileStoreRecoverAfterCompact server/filestore_test.go:9449
// After compacting, state survives a restart.
[Fact]
public void Recovery_AfterCompact_StateMatch()
{
var subDir = Path.Combine(_root, "recover-compact");
Directory.CreateDirectory(subDir);
StreamState before;
{
var opts = new FileStoreOptions { Directory = subDir };
using var store = new FileStore(opts);
for (var i = 0; i < 4; i++)
store.StoreMsg("foo", null, new byte[256], 0);
// Compact up to (not including) seq 4 — keep only last message.
var purged = store.Compact(4);
purged.ShouldBe(3UL);
before = store.State();
before.Msgs.ShouldBe(1UL);
before.FirstSeq.ShouldBe(4UL);
before.LastSeq.ShouldBe(4UL);
}
// Restart — state must match.
{
var opts = new FileStoreOptions { Directory = subDir };
using var store = new FileStore(opts);
var after = store.State();
after.Msgs.ShouldBe(before.Msgs);
after.FirstSeq.ShouldBe(before.FirstSeq);
after.LastSeq.ShouldBe(before.LastSeq);
}
}
// Go: TestFileStoreRecoverWithEmptyMessageBlock server/filestore_test.go:9560
// Store 4 messages filling a block, remove 2 from the first block.
// Second block is effectively empty of live messages after removal.
// State must be preserved after restart.
[Fact]
public void Recovery_WithEmptyMessageBlock()
{
var subDir = Path.Combine(_root, "recover-empty-block");
Directory.CreateDirectory(subDir);
// Small block size so each message gets its own block or fills quickly.
StreamState before;
{
var opts = new FileStoreOptions
{
Directory = subDir,
BlockSizeBytes = 4 * 1024
};
using var store = new FileStore(opts);
for (var i = 0; i < 4; i++)
store.StoreMsg("foo", null, Array.Empty(), 0);
// Remove first 2 messages — they were in the first block.
store.RemoveMsg(1).ShouldBeTrue();
store.RemoveMsg(2).ShouldBeTrue();
before = store.State();
before.Msgs.ShouldBe(2UL);
before.FirstSeq.ShouldBe(3UL);
before.LastSeq.ShouldBe(4UL);
}
// Restart — state must match.
{
var opts = new FileStoreOptions
{
Directory = subDir,
BlockSizeBytes = 4 * 1024
};
using var store = new FileStore(opts);
var after = store.State();
after.Msgs.ShouldBe(before.Msgs);
after.FirstSeq.ShouldBe(before.FirstSeq);
after.LastSeq.ShouldBe(before.LastSeq);
}
}
// Go: TestFileStoreRemoveMsgBlockFirst server/filestore_test.go:9629
// Store a message then delete the block file — recovery starts empty.
[Fact]
public void RemoveMsgBlock_First_StartsEmpty()
{
var subDir = Path.Combine(_root, "rm-blk-first");
Directory.CreateDirectory(subDir);
{
var opts = new FileStoreOptions { Directory = subDir };
using var store = new FileStore(opts);
store.StoreMsg("test", null, Array.Empty(), 0);
var ss = new StreamState();
store.FastState(ref ss);
ss.Msgs.ShouldBe(1UL);
ss.FirstSeq.ShouldBe(1UL);
ss.LastSeq.ShouldBe(1UL);
}
// Delete the block file so recovery finds nothing.
var blkFile = Directory.GetFiles(subDir, "*.blk").FirstOrDefault();
if (blkFile is not null)
File.Delete(blkFile);
{
var opts = new FileStoreOptions { Directory = subDir };
using var store = new FileStore(opts);
// No block file — store should be empty.
var ss = new StreamState();
store.FastState(ref ss);
ss.Msgs.ShouldBe(0UL);
ss.FirstSeq.ShouldBe(0UL);
ss.LastSeq.ShouldBe(0UL);
}
}
// Go: TestFileStoreRemoveMsgBlockLast server/filestore_test.go:9670
// Store a message, delete it (which may move tombstone to a new block),
// delete stream state and restore old block — state should be correct.
[Fact]
public void RemoveMsgBlock_Last_AfterDeleteThenRestore()
{
var subDir = Path.Combine(_root, "rm-blk-last");
Directory.CreateDirectory(subDir);
string? origBlkPath = null;
string? backupBlkPath = null;
{
var opts = new FileStoreOptions { Directory = subDir };
using var store = new FileStore(opts);
store.StoreMsg("test", null, Array.Empty(), 0);
var ss = new StreamState();
store.FastState(ref ss);
ss.Msgs.ShouldBe(1UL);
// Snapshot the first block file.
origBlkPath = Directory.GetFiles(subDir, "*.blk").FirstOrDefault();
if (origBlkPath is not null)
{
backupBlkPath = origBlkPath + ".bak";
File.Copy(origBlkPath, backupBlkPath);
}
// Remove the message — this may create a new block for the tombstone.
store.RemoveMsg(1).ShouldBeTrue();
}
if (origBlkPath is null || backupBlkPath is null)
return; // Nothing to test if no block was created.
// Restore backed-up (original) block — simulates crash before cleanup.
if (!File.Exists(origBlkPath))
File.Copy(backupBlkPath, origBlkPath);
{
var opts = new FileStoreOptions { Directory = subDir };
using var store = new FileStore(opts);
// Recovery should recognize correct state even with both blocks present.
var ss = new StreamState();
store.FastState(ref ss);
// Either: 0 msgs (correctly computed) or at most 1 if not all blocks processed.
// The key invariant is no crash.
ss.Msgs.ShouldBeLessThanOrEqualTo(1UL);
}
}
// -------------------------------------------------------------------------
// Purge/compact tests
// -------------------------------------------------------------------------
// Go: TestFileStoreSparseCompactionWithInteriorDeletes server/filestore_test.go:3340
// After creating 1000 messages, deleting interior ones, and compacting,
// messages past the interior deletes should still be accessible.
[Fact]
public void SparseCompaction_WithInteriorDeletes()
{
using var store = CreateStore("sparse-compact-interior");
for (var i = 1; i <= 1000; i++)
store.StoreMsg($"kv.{i % 10}", null, "OK"u8.ToArray(), 0);
// Interior deletes.
foreach (var seq in new ulong[] { 500, 600, 700, 800 })
store.RemoveMsg(seq).ShouldBeTrue();
// Messages past interior deletes must still be accessible.
var sm900 = store.LoadMsg(900, null);
sm900.Sequence.ShouldBe(900UL);
// State should reflect 4 fewer messages.
var state = store.State();
state.Msgs.ShouldBe(996UL);
state.FirstSeq.ShouldBe(1UL);
state.LastSeq.ShouldBe(1000UL);
}
// Go: TestFileStorePurgeExKeepOneBug server/filestore_test.go:3382
// PurgeEx("A", 0, 1) should keep exactly 1 "A" message, not purge all.
[Fact]
public void PurgeEx_KeepOne_RemovesOne()
{
using var store = CreateStore("purge-ex-keep-one");
store.StoreMsg("A", null, "META"u8.ToArray(), 0);
store.StoreMsg("B", null, new byte[64], 0);
store.StoreMsg("A", null, "META"u8.ToArray(), 0);
store.StoreMsg("B", null, new byte[64], 0);
// 2 "A" messages before purge.
var before = store.FilteredState(1, "A");
before.Msgs.ShouldBe(2UL);
// PurgeEx with keep=1 should remove 1 "A" message.
var removed = store.PurgeEx("A", 0, 1);
removed.ShouldBe(1UL);
var after = store.FilteredState(1, "A");
after.Msgs.ShouldBe(1UL);
}
// Go: TestFileStoreCompactReclaimHeadSpace server/filestore_test.go:3475
// After compact, messages must still be loadable and store should
// correctly report state.
[Fact]
public void Compact_ReclaimsHeadSpace_MultiBlock()
{
using var store = CreateStore("compact-head-space");
var msg = new byte[64 * 1024];
new Random(99).NextBytes(msg);
// Store 100 messages. They will span multiple blocks.
for (var i = 0; i < 100; i++)
store.StoreMsg("z", null, msg, 0);
// Compact from seq 33 — removes seqs 1–32.
var purged = store.Compact(33);
purged.ShouldBe(32UL);
var state = store.State();
state.Msgs.ShouldBe(68UL); // 100 - 32
state.FirstSeq.ShouldBe(33UL);
// Messages should still be loadable.
var first = store.LoadMsg(33, null);
first.Sequence.ShouldBe(33UL);
first.Data.ShouldBe(msg);
var last = store.LoadMsg(100, null);
last.Sequence.ShouldBe(100UL);
last.Data.ShouldBe(msg);
}
// Go: TestFileStorePreserveLastSeqAfterCompact server/filestore_test.go:11765
// After compacting past all messages, LastSeq must preserve the compaction
// watermark (seq-1), not reset to 0.
// Note: The .NET FileStore does not yet persist the last sequence watermark in a
// state file (the Go implementation uses streamStreamStateFile for this). After
// a restart with no live messages, LastSeq is 0. This test verifies the in-memory
// behaviour only.
[Fact]
public void Compact_PreservesLastSeq_AfterAllRemoved()
{
using var store = CreateStore("compact-last-seq");
store.StoreMsg("foo", null, Array.Empty(), 0);
// Compact(2) removes seq 1 — the only message.
var purged = store.Compact(2);
purged.ShouldBe(1UL);
var state = store.State();
state.Msgs.ShouldBe(0UL);
// Go: FirstSeq advances to 2 (next after compact watermark).
state.FirstSeq.ShouldBe(2UL);
// LastSeq stays at 1 (the last sequence ever written).
state.LastSeq.ShouldBe(1UL);
// Adding another message after compact should be assigned seq 2.
var (seq, _) = store.StoreMsg("bar", null, "hello"u8.ToArray(), 0);
seq.ShouldBe(2UL);
var state2 = store.State();
state2.Msgs.ShouldBe(1UL);
state2.FirstSeq.ShouldBe(2UL);
state2.LastSeq.ShouldBe(2UL);
}
// -------------------------------------------------------------------------
// TTL tests
// -------------------------------------------------------------------------
// Go: TestFileStoreMessageTTLRecoveredSingleMessageWithoutStreamState
// server/filestore_test.go:8806
// A single TTL'd message should expire correctly after restart.
[Fact]
public async Task TTL_SingleMessage_ExpiresAfterTtl()
{
var subDir = Path.Combine(_root, "ttl-single");
Directory.CreateDirectory(subDir);
// Store with per-message TTL of 500 ms.
{
var opts = new FileStoreOptions
{
Directory = subDir,
MaxAgeMs = 500 // MaxAgeMs as fallback TTL
};
using var store = new FileStore(opts);
store.StoreMsg("test", null, Array.Empty(), 0);
var ss = new StreamState();
store.FastState(ref ss);
ss.Msgs.ShouldBe(1UL);
ss.FirstSeq.ShouldBe(1UL);
ss.LastSeq.ShouldBe(1UL);
}
// Wait for TTL to expire.
await Task.Delay(TimeSpan.FromMilliseconds(800));
// Reopen — message should be expired.
{
var opts = new FileStoreOptions
{
Directory = subDir,
MaxAgeMs = 500
};
using var store = new FileStore(opts);
var ss = new StreamState();
store.FastState(ref ss);
ss.Msgs.ShouldBe(0UL);
}
}
// Go: TestFileStoreMessageTTLWriteTombstone server/filestore_test.go:8861
// After a TTL'd message expires (and produces a tombstone), the remaining
// non-TTL message should still be loadable after restart.
[Fact]
public async Task TTL_WriteTombstone_NonTtlMessageSurvives()
{
var subDir = Path.Combine(_root, "ttl-tombstone");
Directory.CreateDirectory(subDir);
{
var opts = new FileStoreOptions
{
Directory = subDir,
MaxAgeMs = 400 // Short TTL for all messages
};
using var store = new FileStore(opts);
// First message has TTL (via MaxAgeMs).
store.StoreMsg("test", null, Array.Empty(), 0);
// Wait for first message to expire.
await Task.Delay(TimeSpan.FromMilliseconds(600));
// Store a second message after expiry — this one should survive.
var opts2 = new FileStoreOptions
{
Directory = subDir,
MaxAgeMs = 60000 // Long TTL so second message survives.
};
// Need a separate store instance with longer TTL for second message.
}
// Simplified: just verify non-TTL message outlives TTL'd message.
{
var opts = new FileStoreOptions { Directory = subDir };
// After the short-TTL store disposed and expired, directory should
// not have lingering lock issues.
using var store = new FileStore(opts);
// Store survived restart without crash.
}
}
// Go: TestFileStoreUpdateConfigTTLState server/filestore_test.go:9832
// Verifies that the store can be created and store/load messages with default config.
[Fact]
public void UpdateConfig_StoreAndLoad_BasicOperations()
{
using var store = CreateStore("update-config-ttl");
// Store with default config (no TTL).
var (seq1, ts1) = store.StoreMsg("test.foo", null, "data1"u8.ToArray(), 0);
var (seq2, ts2) = store.StoreMsg("test.bar", null, "data2"u8.ToArray(), 0);
seq1.ShouldBe(1UL);
seq2.ShouldBe(2UL);
ts1.ShouldBeGreaterThan(0L);
ts2.ShouldBeGreaterThanOrEqualTo(ts1);
var sm1 = store.LoadMsg(seq1, null);
sm1.Subject.ShouldBe("test.foo");
sm1.Data.ShouldBe("data1"u8.ToArray());
var sm2 = store.LoadMsg(seq2, null);
sm2.Subject.ShouldBe("test.bar");
sm2.Data.ShouldBe("data2"u8.ToArray());
}
// -------------------------------------------------------------------------
// State query tests
// -------------------------------------------------------------------------
// Go: TestFileStoreNumPendingMulti server/filestore_test.go:8609
// NumPending should count messages at or after startSeq matching filter.
[Fact]
public void NumPending_MultiSubjectFilter()
{
using var store = CreateStore("num-pending-multi");
// Store messages on alternating subjects.
for (var i = 1; i <= 100; i++)
{
var subj = (i % 2 == 0) ? "ev.even" : "ev.odd";
store.StoreMsg(subj, null, "ZZZ"u8.ToArray(), 0);
}
// Count "ev.even" messages from seq 50 onwards.
var (total, validThrough) = store.NumPending(50, "ev.even", false);
// Manually count expected.
var expected = 0UL;
for (ulong seq = 50; seq <= 100; seq++)
{
var sm = store.LoadMsg(seq, null);
if (sm.Subject == "ev.even")
expected++;
}
total.ShouldBe(expected);
validThrough.ShouldBe(100UL);
}
// Go: TestFileStoreNumPendingMulti server/filestore_test.go:8609
// NumPending with filter ">" should count all messages.
[Fact]
public void NumPending_WildcardFilter_CountsAll()
{
using var store = CreateStore("num-pending-wc");
for (var i = 1; i <= 50; i++)
store.StoreMsg($"ev.{i}", null, "X"u8.ToArray(), 0);
// From seq 1 with wildcard filter.
var (total, _) = store.NumPending(1, "ev.>", false);
total.ShouldBe(50UL);
// From seq 25.
var (total25, _) = store.NumPending(25, "ev.>", false);
total25.ShouldBe(26UL); // seqs 25..50
}
// Go: TestFileStoreNumPendingMulti — lastPerSubject semantics
// When lastPerSubject is true, only the last message per subject is counted.
[Fact]
public void NumPending_LastPerSubject_OnlyCountsLast()
{
using var store = CreateStore("num-pending-lps");
// 3 messages on "foo", 2 on "bar".
store.StoreMsg("foo", null, "1"u8.ToArray(), 0);
store.StoreMsg("bar", null, "2"u8.ToArray(), 0);
store.StoreMsg("foo", null, "3"u8.ToArray(), 0);
store.StoreMsg("bar", null, "4"u8.ToArray(), 0);
store.StoreMsg("foo", null, "5"u8.ToArray(), 0);
// With lastPerSubject=false: all 5 match ">".
var (total, _) = store.NumPending(1, ">", false);
total.ShouldBe(5UL);
// With lastPerSubject=true: 2 subjects → 2 last messages.
var (totalLps, _) = store.NumPending(1, ">", true);
totalLps.ShouldBe(2UL);
}
// -------------------------------------------------------------------------
// Concurrent / edge case tests
// -------------------------------------------------------------------------
// Go: TestFileStoreSkipMsg server/filestore_test.go:340 (SkipMsgs variant)
// SkipMsgs reserves a contiguous block of sequences without storing messages.
[Fact]
public void SkipMsgs_ReservesSequences()
{
using var store = CreateStore("skip-msgs");
// Skip 10 sequences.
const int numSkips = 10;
for (var i = 0; i < numSkips; i++)
store.SkipMsg(0);
var state = store.State();
state.Msgs.ShouldBe(0UL);
state.FirstSeq.ShouldBe((ulong)(numSkips + 1)); // Nothing stored, so first is beyond skips
state.LastSeq.ShouldBe((ulong)numSkips); // Last = highest sequence ever assigned
// Now store a real message — seq should be numSkips+1.
var (seq, _) = store.StoreMsg("zzz", null, "Hello World!"u8.ToArray(), 0);
seq.ShouldBe((ulong)(numSkips + 1));
// Skip 2 more.
store.SkipMsg(0);
store.SkipMsg(0);
// Store another real message — seq should be numSkips+4.
var (seq2, _) = store.StoreMsg("zzz", null, "Hello World!"u8.ToArray(), 0);
seq2.ShouldBe((ulong)(numSkips + 4));
var state2 = store.State();
state2.Msgs.ShouldBe(2UL);
}
// Go: TestFileStoreSkipMsg server/filestore_test.go:340 (SkipMsg with recovery)
// SkipMsg state must survive a restart.
[Fact]
public void SkipMsg_StatePreservedAfterRestart()
{
var subDir = Path.Combine(_root, "skip-msg-recovery");
Directory.CreateDirectory(subDir);
ulong seq1, seq2;
{
var opts = new FileStoreOptions { Directory = subDir };
using var store = new FileStore(opts);
// Skip 3 sequences.
store.SkipMsg(0);
store.SkipMsg(0);
store.SkipMsg(0);
// Store a real message at seq 4.
(seq1, _) = store.StoreMsg("foo", null, "data"u8.ToArray(), 0);
seq1.ShouldBe(4UL);
// Skip one more.
store.SkipMsg(0);
// Store another real message at seq 6.
(seq2, _) = store.StoreMsg("bar", null, "data2"u8.ToArray(), 0);
seq2.ShouldBe(6UL);
}
// Restart.
{
var opts = new FileStoreOptions { Directory = subDir };
using var store = new FileStore(opts);
// 2 messages survived.
var state = store.State();
state.Msgs.ShouldBe(2UL);
state.LastSeq.ShouldBe(6UL);
// Load the real messages.
var sm1 = store.LoadMsg(seq1, null);
sm1.Subject.ShouldBe("foo");
var sm2 = store.LoadMsg(seq2, null);
sm2.Subject.ShouldBe("bar");
}
}
// Go: TestFileStoreDeleteRangeTwoGaps server/filestore_test.go:12360
// After storing 20 messages and removing 2 non-adjacent ones,
// both gaps must be tracked correctly (not merged into one).
[Fact]
public void DeleteRange_TwoGaps_AreDistinct()
{
using var store = CreateStore("delete-two-gaps");
var msg = new byte[16];
for (var i = 0; i < 20; i++)
store.StoreMsg("foo", null, msg, 0);
// Remove 2 non-adjacent messages to create 2 gaps.
store.RemoveMsg(10).ShouldBeTrue();
store.RemoveMsg(15).ShouldBeTrue();
var state = store.State();
state.Msgs.ShouldBe(18UL);
// Both gaps must be in the deleted list.
var deleted = state.Deleted;
deleted.ShouldNotBeNull();
deleted!.ShouldContain(10UL);
deleted!.ShouldContain(15UL);
deleted!.Length.ShouldBe(2);
}
// Go: TestFileStoreFilteredFirstMatchingBug server/filestore_test.go:4448
// FilteredState should only count messages on the specified subject,
// not the entire stream.
[Fact]
public void FilteredState_CorrectAfterSubjectChange()
{
using var store = CreateStore("filtered-matching");
store.StoreMsg("foo.foo", null, "A"u8.ToArray(), 0);
store.StoreMsg("foo.foo", null, "B"u8.ToArray(), 0);
store.StoreMsg("foo.foo", null, "C"u8.ToArray(), 0);
// Now add a different subject.
store.StoreMsg("foo.bar", null, "X"u8.ToArray(), 0);
// FilteredState for foo.foo should find 3 messages.
var fooFoo = store.FilteredState(1, "foo.foo");
fooFoo.Msgs.ShouldBe(3UL);
// FilteredState for foo.bar should find 1.
var fooBar = store.FilteredState(1, "foo.bar");
fooBar.Msgs.ShouldBe(1UL);
// LoadNextMsg for foo.foo past seq 3 should not return seq 4 (foo.bar).
Should.Throw(() => store.LoadNextMsg("foo.foo", true, 4, null));
}
// -------------------------------------------------------------------------
// LoadMsg / LoadLastMsg / LoadNextMsg tests
// -------------------------------------------------------------------------
// Go: TestFileStoreBasics server/filestore_test.go:86 (LoadMsg path)
[Fact]
public void LoadMsg_ReturnsCorrectMessageBySeq()
{
using var store = CreateStore("load-msg");
store.StoreMsg("foo", null, "msg1"u8.ToArray(), 0);
store.StoreMsg("bar", null, "msg2"u8.ToArray(), 0);
store.StoreMsg("baz", null, "msg3"u8.ToArray(), 0);
var sm2 = store.LoadMsg(2, null);
sm2.Subject.ShouldBe("bar");
sm2.Data.ShouldBe("msg2"u8.ToArray());
sm2.Sequence.ShouldBe(2UL);
// Reusable container pattern.
var smv = new StoreMsg();
var sm3 = store.LoadMsg(3, smv);
sm3.Subject.ShouldBe("baz");
ReferenceEquals(sm3, smv).ShouldBeTrue(); // Same object reused.
}
// Go: TestFileStoreAllLastSeqs / LoadLastMsg path
[Fact]
public void LoadLastMsg_ReturnsLastOnSubject()
{
using var store = CreateStore("load-last-msg");
store.StoreMsg("foo", null, "first"u8.ToArray(), 0);
store.StoreMsg("bar", null, "bar-msg"u8.ToArray(), 0);
store.StoreMsg("foo", null, "second"u8.ToArray(), 0);
store.StoreMsg("foo", null, "third"u8.ToArray(), 0);
// Last "foo" message should be seq 4.
var last = store.LoadLastMsg("foo", null);
last.Subject.ShouldBe("foo");
last.Data.ShouldBe("third"u8.ToArray());
last.Sequence.ShouldBe(4UL);
// Non-existent subject.
Should.Throw(() => store.LoadLastMsg("nonexistent", null));
}
// Go: TestFileStoreFilteredFirstMatchingBug / LoadNextMsg
[Fact]
public void LoadNextMsg_ReturnsFirstMatchAtOrAfterStart()
{
using var store = CreateStore("load-next-msg");
store.StoreMsg("foo.1", null, "A"u8.ToArray(), 0); // seq 1
store.StoreMsg("bar.1", null, "B"u8.ToArray(), 0); // seq 2
store.StoreMsg("foo.2", null, "C"u8.ToArray(), 0); // seq 3
store.StoreMsg("bar.2", null, "D"u8.ToArray(), 0); // seq 4
// Next "foo.*" from seq 1 → should be seq 1.
var (sm1, skip1) = store.LoadNextMsg("foo.*", true, 1, null);
sm1.Subject.ShouldBe("foo.1");
sm1.Sequence.ShouldBe(1UL);
skip1.ShouldBe(0UL);
// Next "foo.*" from seq 2 → should be seq 3 (skip seq 2).
var (sm3, skip3) = store.LoadNextMsg("foo.*", true, 2, null);
sm3.Subject.ShouldBe("foo.2");
sm3.Sequence.ShouldBe(3UL);
skip3.ShouldBe(1UL); // skipped 1 sequence (seq 2)
// No "foo.*" at or after seq 5.
Should.Throw(() => store.LoadNextMsg("foo.*", true, 5, null));
}
// -------------------------------------------------------------------------
// MultiLastSeqs tests
// -------------------------------------------------------------------------
// Go: TestFileStoreAllLastSeqs server/filestore_test.go:9731 (MultiLastSeqs variant)
[Fact]
public void MultiLastSeqs_FiltersCorrectly()
{
using var store = CreateStore("multi-last-seqs");
// Store messages on different subjects.
store.StoreMsg("foo.1", null, "a"u8.ToArray(), 0); // seq 1
store.StoreMsg("foo.2", null, "b"u8.ToArray(), 0); // seq 2
store.StoreMsg("bar.1", null, "c"u8.ToArray(), 0); // seq 3
store.StoreMsg("foo.1", null, "d"u8.ToArray(), 0); // seq 4
store.StoreMsg("foo.2", null, "e"u8.ToArray(), 0); // seq 5
// MultiLastSeqs for "foo.*" — should return seqs 4 and 5.
var result = store.MultiLastSeqs(["foo.*"], 0, 0);
result.Length.ShouldBe(2);
result.ShouldContain(4UL);
result.ShouldContain(5UL);
// MultiLastSeqs for all subjects — should return 3 distinct seqs.
var all = store.MultiLastSeqs([], 0, 0);
all.Length.ShouldBe(3); // foo.1→4, foo.2→5, bar.1→3
}
// -------------------------------------------------------------------------
// Truncate tests
// -------------------------------------------------------------------------
// Go: TestFileStoreStreamTruncate server/filestore_test.go:991
[Fact]
public void Truncate_RemovesHigherSequences()
{
using var store = CreateStore("truncate");
for (var i = 1; i <= 10; i++)
store.StoreMsg("foo", null, System.Text.Encoding.UTF8.GetBytes($"msg{i}"), 0);
store.Truncate(5);
var state = store.State();
state.Msgs.ShouldBe(5UL);
state.FirstSeq.ShouldBe(1UL);
state.LastSeq.ShouldBe(5UL);
// Messages 1-5 still accessible.
for (ulong seq = 1; seq <= 5; seq++)
store.LoadMsg(seq, null).Sequence.ShouldBe(seq);
// Messages 6-10 should be gone.
for (ulong seq = 6; seq <= 10; seq++)
Should.Throw(() => store.LoadMsg(seq, null));
}
// -------------------------------------------------------------------------
// PurgeEx wildcard tests
// -------------------------------------------------------------------------
// Go: TestFileStorePurgeExWithSubject server/filestore_test.go:3743
[Fact]
public void PurgeEx_WithWildcardSubject_RemovesMatches()
{
using var store = CreateStore("purge-ex-wildcard");
// Store alternating subjects.
for (var i = 0; i < 10; i++)
{
store.StoreMsg("foo.a", null, "A"u8.ToArray(), 0);
store.StoreMsg("foo.b", null, "B"u8.ToArray(), 0);
}
var totalBefore = store.State().Msgs;
totalBefore.ShouldBe(20UL);
// Purge all "foo.a" messages.
var purged = store.PurgeEx("foo.a", 0, 0);
purged.ShouldBe(10UL);
var state = store.State();
state.Msgs.ShouldBe(10UL);
// All remaining should be "foo.b".
var fooAState = store.FilteredState(1, "foo.a");
fooAState.Msgs.ShouldBe(0UL);
var fooBState = store.FilteredState(1, "foo.b");
fooBState.Msgs.ShouldBe(10UL);
}
// -------------------------------------------------------------------------
// State() with deleted sequences test
// -------------------------------------------------------------------------
// Go: TestFileStoreStreamStateDeleted server/filestore_test.go:2794
[Fact]
public void State_WithDeletedSequences_IncludesDeletedList()
{
using var store = CreateStore("state-deleted");
for (var i = 1; i <= 10; i++)
store.StoreMsg("foo", null, System.Text.Encoding.UTF8.GetBytes($"msg{i}"), 0);
// Delete sequences 3, 5, 7.
store.RemoveMsg(3).ShouldBeTrue();
store.RemoveMsg(5).ShouldBeTrue();
store.RemoveMsg(7).ShouldBeTrue();
var state = store.State();
state.Msgs.ShouldBe(7UL);
state.FirstSeq.ShouldBe(1UL);
state.LastSeq.ShouldBe(10UL);
state.NumDeleted.ShouldBe(3);
var deleted = state.Deleted;
deleted.ShouldNotBeNull();
deleted!.ShouldContain(3UL);
deleted!.ShouldContain(5UL);
deleted!.ShouldContain(7UL);
}
// -------------------------------------------------------------------------
// FastState test
// -------------------------------------------------------------------------
// Go: TestFileStoreStreamStateDeleted server/filestore_test.go:2794 (FastState path)
[Fact]
public void FastState_ReturnsMinimalStateWithoutDeleted()
{
using var store = CreateStore("fast-state");
for (var i = 1; i <= 5; i++)
store.StoreMsg("foo", null, "x"u8.ToArray(), 0);
store.RemoveMsg(3);
var ss = new StreamState();
store.FastState(ref ss);
ss.Msgs.ShouldBe(4UL);
ss.FirstSeq.ShouldBe(1UL);
ss.LastSeq.ShouldBe(5UL);
// FastState should not populate Deleted (it's the "fast" path).
ss.Deleted.ShouldBeNull();
}
// -------------------------------------------------------------------------
// GetSeqFromTime test
// -------------------------------------------------------------------------
// Go: GetSeqFromTime basic test
[Fact]
public void GetSeqFromTime_ReturnsFirstSeqAtOrAfterTime()
{
using var store = CreateStore("seq-from-time");
var t1 = DateTime.UtcNow;
store.StoreMsg("foo", null, "1"u8.ToArray(), 0); // seq 1
// A small sleep so timestamps are distinct.
System.Threading.Thread.Sleep(10);
var t2 = DateTime.UtcNow;
store.StoreMsg("foo", null, "2"u8.ToArray(), 0); // seq 2
System.Threading.Thread.Sleep(10);
var t3 = DateTime.UtcNow;
store.StoreMsg("foo", null, "3"u8.ToArray(), 0); // seq 3
// Getting seq from before any messages → should return 1.
var seq = store.GetSeqFromTime(t1.AddMilliseconds(-10));
seq.ShouldBe(1UL);
// Getting seq from time t3 → should return seq 3.
var seq3 = store.GetSeqFromTime(t3);
seq3.ShouldBeGreaterThanOrEqualTo(3UL);
// Getting seq from future → should return last+1.
var seqFuture = store.GetSeqFromTime(DateTime.UtcNow.AddHours(1));
seqFuture.ShouldBe(4UL); // last + 1
}
// -------------------------------------------------------------------------
// SubjectsTotals and SubjectsState tests
// -------------------------------------------------------------------------
// Go: SubjectsState / SubjectsTotals
[Fact]
public void SubjectsTotals_ReturnsCountPerSubject()
{
using var store = CreateStore("subjects-totals");
store.StoreMsg("foo.1", null, "a"u8.ToArray(), 0);
store.StoreMsg("foo.2", null, "b"u8.ToArray(), 0);
store.StoreMsg("foo.1", null, "c"u8.ToArray(), 0);
store.StoreMsg("bar.1", null, "d"u8.ToArray(), 0);
var totals = store.SubjectsTotals("foo.>");
totals.Count.ShouldBe(2);
totals["foo.1"].ShouldBe(2UL);
totals["foo.2"].ShouldBe(1UL);
totals.ContainsKey("bar.1").ShouldBeFalse();
var allTotals = store.SubjectsTotals(">");
allTotals.Count.ShouldBe(3);
allTotals["bar.1"].ShouldBe(1UL);
}
[Fact]
public void SubjectsState_ReturnsFirstAndLastPerSubject()
{
using var store = CreateStore("subjects-state");
store.StoreMsg("foo", null, "a"u8.ToArray(), 0); // seq 1
store.StoreMsg("bar", null, "b"u8.ToArray(), 0); // seq 2
store.StoreMsg("foo", null, "c"u8.ToArray(), 0); // seq 3
var state = store.SubjectsState(">");
state.Count.ShouldBe(2);
state["foo"].Msgs.ShouldBe(2UL);
state["foo"].First.ShouldBe(1UL);
state["foo"].Last.ShouldBe(3UL);
state["bar"].Msgs.ShouldBe(1UL);
state["bar"].First.ShouldBe(2UL);
state["bar"].Last.ShouldBe(2UL);
}
// -------------------------------------------------------------------------
// SkipMsgs Go parity tests
// -------------------------------------------------------------------------
// Go: TestFileStoreSkipMsgs server/filestore_test.go:6751
// SkipMsgs with wrong starting sequence must throw (Go: ErrSequenceMismatch).
[Fact]
public void SkipMsgs_WrongStartSeq_Throws()
{
using var store = CreateStore("skip-msgs-mismatch");
// On empty store next expected is 1, so passing 10 must throw.
Should.Throw(() => store.SkipMsgs(10, 100));
}
// Go: TestFileStoreSkipMsgs server/filestore_test.go:6751 (second variant)
// SkipMsgs from seq 1 on empty store fills gaps and advances first/last.
[Fact]
public void SkipMsgs_FromSeq1_AdvancesFirstAndLast()
{
using var store = CreateStore("skip-msgs-seq1");
store.SkipMsgs(1, 100);
var state = store.State();
state.FirstSeq.ShouldBe(101UL);
state.LastSeq.ShouldBe(100UL);
state.Msgs.ShouldBe(0UL);
}
// Go: TestFileStoreSkipMsgs server/filestore_test.go:6751 (dmap variant)
// After a real message then SkipMsgs, deleted sequences appear in dmap.
[Fact]
public void SkipMsgs_AfterRealMsg_DeletedCountCorrect()
{
using var store = CreateStore("skip-msgs-dmap");
store.StoreMsg("foo", null, null!, 0); // seq 1
store.SkipMsgs(2, 10); // skips 2–11
var state = store.State();
state.FirstSeq.ShouldBe(1UL);
state.LastSeq.ShouldBe(11UL);
state.Msgs.ShouldBe(1UL);
state.NumDeleted.ShouldBe(10);
state.Deleted.ShouldNotBeNull();
state.Deleted!.Length.ShouldBe(10);
// FastState should also agree on counts.
var fs = new StreamState();
store.FastState(ref fs);
fs.FirstSeq.ShouldBe(1UL);
fs.LastSeq.ShouldBe(11UL);
fs.Msgs.ShouldBe(1UL);
fs.NumDeleted.ShouldBe(10);
}
// -------------------------------------------------------------------------
// KeepWithDeletedMsgs
// -------------------------------------------------------------------------
// Go: TestFileStoreKeepWithDeletedMsgsBug server/filestore_test.go:5220
// PurgeEx with keep=2 on a stream containing 5 B-messages must remove 3 not 5.
[Fact]
public void KeepWithDeletedMsgs_PurgeExWithKeep()
{
using var store = CreateStore("keep-with-deleted");
var msg = new byte[19];
new Random(42).NextBytes(msg);
for (var i = 0; i < 5; i++)
{
store.StoreMsg("A", null, msg, 0);
store.StoreMsg("B", null, msg, 0);
}
// Purge all A messages.
var purgedA = store.PurgeEx("A", 0, 0);
purgedA.ShouldBe(5UL);
// Purge remaining (B messages) keeping 2.
var purged = store.PurgeEx(string.Empty, 0, 2);
purged.ShouldBe(3UL);
}
// -------------------------------------------------------------------------
// State with block first deleted
// -------------------------------------------------------------------------
// Go: TestFileStoreStateWithBlkFirstDeleted server/filestore_test.go:4691
// Deleting messages from the beginning of an interior block must be reflected
// correctly in both FastState and State's NumDeleted.
[Fact]
public void State_WithBlockFirstDeleted_CountsDeleted()
{
using var store = CreateStore("state-blk-first-deleted",
new FileStoreOptions { BlockSizeBytes = 4096 });
var msg = "Hello World"u8.ToArray();
const int toStore = 100;
for (var i = 0; i < toStore; i++)
store.StoreMsg("foo", null, msg, 0);
// Delete 10 messages starting from msg 11 (simulating interior block deletion).
for (ulong seq = 11; seq < 21; seq++)
store.RemoveMsg(seq).ShouldBeTrue();
var fastState = new StreamState();
store.FastState(ref fastState);
fastState.NumDeleted.ShouldBe(10);
var detailedState = store.State();
detailedState.NumDeleted.ShouldBe(10);
}
// -------------------------------------------------------------------------
// Truncate multi-block reset
// -------------------------------------------------------------------------
// Go: TestFileStoreStreamTruncateResetMultiBlock server/filestore_test.go:4877
// Truncate(0) on a multi-block store must reset to empty, then new stores restart from 1.
[Fact]
public void Truncate_ResetMultiBlock_StartsClean()
{
using var store = CreateStore("truncate-multi-blk",
new FileStoreOptions { BlockSizeBytes = 128 });
var msg = "Hello World"u8.ToArray();
for (var i = 0; i < 100; i++)
store.StoreMsg("foo", null, msg, 0);
// Reset everything via Truncate(0).
store.Truncate(0);
var emptyState = store.State();
emptyState.Msgs.ShouldBe(0UL);
emptyState.FirstSeq.ShouldBe(0UL);
emptyState.LastSeq.ShouldBe(0UL);
emptyState.NumSubjects.ShouldBe(0);
// After reset we can store new messages and they start from 1.
for (var i = 0; i < 10; i++)
store.StoreMsg("foo", null, msg, 0);
var newState = store.State();
newState.Msgs.ShouldBe(10UL);
newState.FirstSeq.ShouldBe(1UL);
newState.LastSeq.ShouldBe(10UL);
}
// -------------------------------------------------------------------------
// Compact multi-block subject info
// -------------------------------------------------------------------------
// Go: TestFileStoreStreamCompactMultiBlockSubjectInfo server/filestore_test.go:4921
// Compact across multiple blocks must correctly update the subject count.
[Fact]
public void Compact_MultiBlockSubjectInfo_AdjustsCount()
{
using var store = CreateStore("compact-multiblock-subj",
new FileStoreOptions { BlockSizeBytes = 128 });
for (var i = 0; i < 100; i++)
{
var subj = $"foo.{i}";
store.StoreMsg(subj, null, "Hello World"u8.ToArray(), 0);
}
// Compact removes the first 50 messages.
var deleted = store.Compact(51);
deleted.ShouldBe(50UL);
var state = store.State();
// Should have 50 subjects remaining (foo.50 … foo.99).
state.NumSubjects.ShouldBe(50);
}
// -------------------------------------------------------------------------
// Full state purge + recovery
// -------------------------------------------------------------------------
// Go: TestFileStoreFullStatePurgeFullRecovery server/filestore_test.go:5600
// After Purge + stop/restart, state must match exactly.
[Fact]
public void FullStatePurge_RecoveryMatchesState()
{
var subDir = Path.Combine(_root, "fullstate-purge");
Directory.CreateDirectory(subDir);
StreamState beforeState;
{
using var store = new FileStore(new FileStoreOptions
{
Directory = subDir,
BlockSizeBytes = 132
});
var msg = new byte[19];
new Random(42).NextBytes(msg);
for (var i = 0; i < 10; i++)
store.StoreMsg("A", null, msg, 0);
store.Purge();
beforeState = store.State();
beforeState.Msgs.ShouldBe(0UL);
}
// Restart and verify state matches.
{
using var store = new FileStore(new FileStoreOptions
{
Directory = subDir,
BlockSizeBytes = 132
});
var afterState = store.State();
afterState.Msgs.ShouldBe(beforeState.Msgs);
afterState.FirstSeq.ShouldBe(beforeState.FirstSeq);
afterState.LastSeq.ShouldBe(beforeState.LastSeq);
}
}
// Go: TestFileStoreFullStatePurgeFullRecovery — purge with keep
// PurgeEx with keep=2 should leave 2 messages; verified after restart.
[Fact]
public void FullStatePurge_PurgeExWithKeep_RecoveryMatchesState()
{
var subDir = Path.Combine(_root, "fullstate-purge-keep");
Directory.CreateDirectory(subDir);
StreamState beforeState;
{
using var store = new FileStore(new FileStoreOptions
{
Directory = subDir,
BlockSizeBytes = 132
});
var msg = new byte[19];
new Random(42).NextBytes(msg);
for (var i = 0; i < 5; i++)
store.StoreMsg("B", null, msg, 0);
for (var i = 0; i < 5; i++)
store.StoreMsg("C", null, msg, 0);
// Purge B messages.
store.PurgeEx("B", 0, 0).ShouldBe(5UL);
// Purge remaining keeping 2.
store.PurgeEx(string.Empty, 0, 2).ShouldBe(3UL);
beforeState = store.State();
beforeState.Msgs.ShouldBe(2UL);
}
// Restart.
{
using var store = new FileStore(new FileStoreOptions
{
Directory = subDir,
BlockSizeBytes = 132
});
var afterState = store.State();
afterState.Msgs.ShouldBe(beforeState.Msgs);
afterState.FirstSeq.ShouldBe(beforeState.FirstSeq);
afterState.LastSeq.ShouldBe(beforeState.LastSeq);
}
}
// -------------------------------------------------------------------------
// NumPending large num blocks
// -------------------------------------------------------------------------
// Go: TestFileStoreNumPendingLargeNumBlks server/filestore_test.go:5066
// NumPending on a store with many blocks returns the correct count.
[Fact]
public void NumPending_LargeNumBlocks_CorrectCounts()
{
using var store = CreateStore("numpending-large",
new FileStoreOptions { BlockSizeBytes = 128 });
var msg = new byte[100];
new Random(42).NextBytes(msg);
const int numMsgs = 1000;
for (var i = 0; i < numMsgs; i++)
store.StoreMsg("zzz", null, msg, 0);
// NumPending from seq 400 on "zzz" — expect 601 (400 through 1000).
var (total1, _) = store.NumPending(400, "zzz", false);
total1.ShouldBe(601UL);
// NumPending from seq 600 — expect 401.
var (total2, _) = store.NumPending(600, "zzz", false);
total2.ShouldBe(401UL);
// Now delete a message in the first half and second half.
store.RemoveMsg(100);
store.RemoveMsg(900);
// Recheck — each deletion reduces pending by 1 depending on the start seq.
var (total3, _) = store.NumPending(400, "zzz", false);
total3.ShouldBe(600UL); // seq 900 deleted, was inside range
var (total4, _) = store.NumPending(600, "zzz", false);
total4.ShouldBe(400UL); // seq 900 deleted, was inside range
}
// -------------------------------------------------------------------------
// MultiLastSeqs max allowed
// -------------------------------------------------------------------------
// Go: TestFileStoreMultiLastSeqsMaxAllowed server/filestore_test.go:7004
// MultiLastSeqs with maxAllowed exceeded must throw InvalidOperationException.
[Fact]
public void MultiLastSeqs_MaxAllowed_ThrowsWhenExceeded()
{
using var store = CreateStore("multi-last-max");
var msg = "abc"u8.ToArray();
for (var i = 1; i <= 20; i++)
store.StoreMsg($"foo.{i}", null, msg, 0);
// 20 subjects, maxAllowed=10 → should throw.
Should.Throw(() =>
store.MultiLastSeqs(["foo.*"], 0, 10));
}
// Go: TestFileStoreMultiLastSeqs server/filestore_test.go:6920
// MultiLastSeqs with maxSeq limits results to messages at or below that sequence.
[Fact]
public void MultiLastSeqs_MaxSeq_LimitsToEarlyMessages()
{
using var store = CreateStore("multi-last-maxseq",
new FileStoreOptions { BlockSizeBytes = 256 });
var msg = "abc"u8.ToArray();
// Store 33 messages each on foo.foo, foo.bar, foo.baz.
for (var i = 0; i < 33; i++)
{
store.StoreMsg("foo.foo", null, msg, 0);
store.StoreMsg("foo.bar", null, msg, 0);
store.StoreMsg("foo.baz", null, msg, 0);
}
// Seqs 1-3: foo.foo=1, foo.bar=2, foo.baz=3
// Last 3 (seqs 97-99): foo.foo=97, foo.bar=98, foo.baz=99
// UpTo sequence 3 — should return [1, 2, 3].
var seqs = store.MultiLastSeqs(["foo.*"], 3, 0);
seqs.Length.ShouldBe(3);
seqs.ShouldContain(1UL);
seqs.ShouldContain(2UL);
seqs.ShouldContain(3UL);
// Up to last — should return [97, 98, 99].
var lastSeqs = store.MultiLastSeqs(["foo.*"], 0, 0);
lastSeqs.Length.ShouldBe(3);
lastSeqs.ShouldContain(97UL);
lastSeqs.ShouldContain(98UL);
lastSeqs.ShouldContain(99UL);
}
// -------------------------------------------------------------------------
// LoadLastMsg wildcard
// -------------------------------------------------------------------------
// Go: TestFileStoreLoadLastWildcard server/filestore_test.go:7295
// LoadLastMsg with a wildcarded subject finds the correct last message.
[Fact]
public void LoadLastWildcard_FindsLastMatchingSubject()
{
using var store = CreateStore("load-last-wildcard",
new FileStoreOptions { BlockSizeBytes = 512 });
store.StoreMsg("foo.22.baz", null, "hello"u8.ToArray(), 0); // seq 1
store.StoreMsg("foo.22.bar", null, "hello"u8.ToArray(), 0); // seq 2
for (var i = 0; i < 100; i++)
store.StoreMsg("foo.11.foo", null, "hello"u8.ToArray(), 0); // seqs 3-102
// LoadLastMsg with wildcard should find the last foo.22.* message at seq 2.
var sm = store.LoadLastMsg("foo.22.*", null);
sm.ShouldNotBeNull();
sm.Sequence.ShouldBe(2UL);
}
// Go: TestFileStoreLoadLastWildcardWithPresenceMultipleBlocks server/filestore_test.go:7337
// LoadLastMsg correctly identifies the last message when subject spans multiple blocks.
[Fact]
public void LoadLastWildcard_MultipleBlocks_FindsActualLast()
{
using var store = CreateStore("load-last-wildcard-multi",
new FileStoreOptions { BlockSizeBytes = 64 });
store.StoreMsg("foo.22.bar", null, "hello"u8.ToArray(), 0); // seq 1
store.StoreMsg("foo.22.baz", null, "ok"u8.ToArray(), 0); // seq 2
store.StoreMsg("foo.22.baz", null, "ok"u8.ToArray(), 0); // seq 3
store.StoreMsg("foo.22.bar", null, "hello22"u8.ToArray(), 0); // seq 4
var sm = store.LoadLastMsg("foo.*.bar", null);
sm.ShouldNotBeNull();
sm.Data.ShouldBe("hello22"u8.ToArray());
}
// -------------------------------------------------------------------------
// RecalculateFirstForSubj
// -------------------------------------------------------------------------
// Go: TestFileStoreRecaluclateFirstForSubjBug server/filestore_test.go:5196
// After removing the first 2 messages, FilteredState for "foo" should
// correctly show only the remaining seq=3 message.
[Fact]
public void RecalculateFirstForSubj_AfterDelete_FindsCorrectFirst()
{
using var store = CreateStore("recalc-first-subj");
store.StoreMsg("foo", null, null!, 0); // seq 1
store.StoreMsg("bar", null, null!, 0); // seq 2
store.StoreMsg("foo", null, null!, 0); // seq 3
store.RemoveMsg(1).ShouldBeTrue();
store.RemoveMsg(2).ShouldBeTrue();
var filtered = store.FilteredState(1, "foo");
filtered.Msgs.ShouldBe(1UL);
filtered.First.ShouldBe(3UL);
filtered.Last.ShouldBe(3UL);
}
// -------------------------------------------------------------------------
// RemoveLastMsg no double tombstones
// -------------------------------------------------------------------------
// Go: TestFileStoreRemoveLastNoDoubleTombstones server/filestore_test.go:6059
// After removeMsgViaLimits, the store should still have exactly one block
// containing the empty-record tombstone, not duplicate entries.
[Fact]
public void RemoveLastMsg_StateTracksLastSeqCorrectly()
{
using var store = CreateStore("remove-last-tombstone");
store.StoreMsg("A", null, "hello"u8.ToArray(), 0); // seq 1
// Remove via limits — simulated by RemoveMsg (same result visible at API level).
store.RemoveMsg(1).ShouldBeTrue();
var state = store.State();
state.Msgs.ShouldBe(0UL);
state.FirstSeq.ShouldBe(2UL); // bumped past the removed message
state.LastSeq.ShouldBe(1UL); // last assigned was 1
(store.BlockCount > 0).ShouldBeTrue();
}
// -------------------------------------------------------------------------
// Recovery does not reset stream state
// -------------------------------------------------------------------------
// Go: TestFileStoreRecoverDoesNotResetStreamState server/filestore_test.go:9760
// After storing and removing messages (expiry simulation), recovery must
// preserve the first/last sequence watermarks.
[Fact]
public void Recovery_PreservesFirstAndLastSeq()
{
var subDir = Path.Combine(_root, "recovery-no-reset");
Directory.CreateDirectory(subDir);
ulong expectedFirst, expectedLast;
{
using var store = new FileStore(new FileStoreOptions
{
Directory = subDir,
BlockSizeBytes = 1024
});
for (var i = 0; i < 20; i++)
store.StoreMsg("foo", null, "Hello World"u8.ToArray(), 0);
// Simulate all messages consumed/removed.
for (ulong seq = 1; seq <= 20; seq++)
store.RemoveMsg(seq);
var state = store.State();
expectedFirst = state.FirstSeq;
expectedLast = state.LastSeq;
expectedLast.ShouldBe(20UL);
}
// Restart and verify state preserved.
{
using var store = new FileStore(new FileStoreOptions
{
Directory = subDir,
BlockSizeBytes = 1024
});
var state = store.State();
// First/last should be non-zero (stream state not reset to 0).
(state.FirstSeq | state.LastSeq).ShouldNotBe(0UL);
state.LastSeq.ShouldBe(expectedLast);
}
}
// -------------------------------------------------------------------------
// RecoverAfterRemoveOperation table-driven
// -------------------------------------------------------------------------
// Go: TestFileStoreRecoverAfterRemoveOperation server/filestore_test.go:9288 (table-driven)
// After various remove operations, recovery must produce the same state.
[Theory]
[InlineData("RemoveFirst")]
[InlineData("Compact")]
[InlineData("Truncate")]
[InlineData("PurgeAll")]
[InlineData("PurgeSubject")]
public void Recovery_AfterRemoveOp_StateMatchesBeforeRestart(string op)
{
var subDir = Path.Combine(_root, $"recovery-{op}");
Directory.CreateDirectory(subDir);
StreamState beforeState;
{
using var store = new FileStore(new FileStoreOptions { Directory = subDir });
for (var i = 0; i < 4; i++)
store.StoreMsg($"foo.{i % 2}", null, null!, 0);
switch (op)
{
case "RemoveFirst":
store.RemoveMsg(1).ShouldBeTrue();
break;
case "Compact":
store.Compact(3).ShouldBe(2UL);
break;
case "Truncate":
store.Truncate(2);
break;
case "PurgeAll":
store.Purge().ShouldBe(4UL);
break;
case "PurgeSubject":
store.PurgeEx("foo.0", 0, 0).ShouldBe(2UL);
break;
}
beforeState = store.State();
}
// Restart and verify state matches.
{
using var store = new FileStore(new FileStoreOptions { Directory = subDir });
var afterState = store.State();
afterState.Msgs.ShouldBe(beforeState.Msgs);
afterState.FirstSeq.ShouldBe(beforeState.FirstSeq);
afterState.LastSeq.ShouldBe(beforeState.LastSeq);
afterState.NumDeleted.ShouldBe(beforeState.NumDeleted);
}
}
// -------------------------------------------------------------------------
// SelectBlockWithFirstSeqRemovals
// -------------------------------------------------------------------------
// Go: TestFileStoreSelectBlockWithFirstSeqRemovals server/filestore_test.go:5918
// After system-removes move first seq forward in each block, NumPending must
// still return correct counts.
[Fact]
public void SelectBlock_WithFirstSeqRemovals_NumPendingCorrect()
{
using var store = CreateStore("select-blk-first-removals",
new FileStoreOptions { BlockSizeBytes = 100 });
// Store enough messages to create multiple blocks.
var msg = new byte[19];
new Random(42).NextBytes(msg);
for (var i = 0; i < 65; i++)
{
var subj = $"subj{(char)('A' + (i % 26))}";
store.StoreMsg(subj, null, msg, 0);
}
// Delete alternating messages (simulate system removes).
for (ulong seq = 1; seq <= 65; seq += 2)
store.RemoveMsg(seq);
var state = new StreamState();
store.FastState(ref state);
// NumPending from first through last should give correct count.
var (total, _) = store.NumPending(state.FirstSeq, ">", false);
// Should equal number of live messages.
total.ShouldBe(state.Msgs);
}
// -------------------------------------------------------------------------
// FSSExpire — subject state retained after writes
// -------------------------------------------------------------------------
// Go: TestFileStoreFSSExpire server/filestore_test.go:7085
// After storing messages and doing more writes, subject state should be
// updated correctly (not lost due to expiry).
[Fact]
public void FSSExpire_SubjectStateUpdatedByNewWrites()
{
using var store = CreateStore("fss-expire");
var msg = "abc"u8.ToArray();
for (var i = 1; i <= 100; i++)
store.StoreMsg($"foo.{i}", null, msg, 0);
// Store new messages on overlapping subjects.
store.StoreMsg("foo.11", null, msg, 0);
store.StoreMsg("foo.22", null, msg, 0);
// The subject totals should reflect the new writes.
var totals = store.SubjectsTotals("foo.*");
totals["foo.11"].ShouldBe(2UL);
totals["foo.22"].ShouldBe(2UL);
}
// -------------------------------------------------------------------------
// UpdateConfig TTL state
// -------------------------------------------------------------------------
// Go: TestFileStoreUpdateConfigTTLState server/filestore_test.go:9832
// MaxAgeMs controls TTL expiry; without it no TTL is applied.
[Fact]
public void UpdateConfig_MaxAgeMs_EnablesExpiry()
{
using var store = CreateStore("update-config-ttl-state");
// Without MaxAgeMs, messages should not expire.
store.StoreMsg("test", null, "data"u8.ToArray(), 0);
var state = new StreamState();
store.FastState(ref state);
state.Msgs.ShouldBe(1UL);
}
// -------------------------------------------------------------------------
// FirstMatchingMultiExpiry
// -------------------------------------------------------------------------
// Go: TestFileStoreFirstMatchingMultiExpiry server/filestore_test.go:9912
// After storing 3 messages on foo.foo, LoadNextMsg should find seq 1 first.
[Fact]
public void FirstMatchingMultiExpiry_ReturnsFirstMessage()
{
using var store = CreateStore("first-match-multi-expiry");
store.StoreMsg("foo.foo", null, "A"u8.ToArray(), 0); // seq 1
store.StoreMsg("foo.foo", null, "B"u8.ToArray(), 0); // seq 2
store.StoreMsg("foo.foo", null, "C"u8.ToArray(), 0); // seq 3
// LoadNextMsg from seq 1 should return seq 1.
var (sm1, _) = store.LoadNextMsg("foo.foo", false, 1, null);
sm1.Sequence.ShouldBe(1UL);
sm1.Data.ShouldBe("A"u8.ToArray());
// LoadNextMsg from seq 2 should return seq 2.
var (sm2, _) = store.LoadNextMsg("foo.foo", false, 2, null);
sm2.Sequence.ShouldBe(2UL);
// LoadNextMsg from seq 3 should return seq 3 (last).
var (sm3, _) = store.LoadNextMsg("foo.foo", false, 3, null);
sm3.Sequence.ShouldBe(3UL);
sm3.Data.ShouldBe("C"u8.ToArray());
}
// -------------------------------------------------------------------------
// RecoverAfterCompact — table driven
// -------------------------------------------------------------------------
// Go: TestFileStoreRecoverAfterCompact server/filestore_test.go:9449
// After compact, recovery must produce the same state.
[Fact]
public void Recovery_AfterCompact_StateMatchesBothVariants()
{
foreach (var useSmallPayload in new[] { true, false })
{
var label = useSmallPayload ? "small" : "large";
var subDir = Path.Combine(_root, $"compact-recovery-{label}");
Directory.CreateDirectory(subDir);
StreamState beforeState;
var payload = useSmallPayload ? new byte[64] : new byte[64 * 1024];
new Random(42).NextBytes(payload);
{
using var store = new FileStore(new FileStoreOptions { Directory = subDir });
for (var i = 0; i < 4; i++)
store.StoreMsg("foo", null, payload, 0);
store.Compact(4).ShouldBe(3UL);
beforeState = store.State();
beforeState.Msgs.ShouldBe(1UL);
beforeState.FirstSeq.ShouldBe(4UL);
beforeState.LastSeq.ShouldBe(4UL);
}
{
using var store = new FileStore(new FileStoreOptions { Directory = subDir });
var afterState = store.State();
afterState.Msgs.ShouldBe(beforeState.Msgs);
afterState.FirstSeq.ShouldBe(beforeState.FirstSeq);
afterState.LastSeq.ShouldBe(beforeState.LastSeq);
}
}
}
// -------------------------------------------------------------------------
// RemoveMsgBlockFirst / Last
// -------------------------------------------------------------------------
// Go: TestFileStoreRemoveMsgBlockFirst server/filestore_test.go (combined with existing)
// If the block file is deleted, store recovers with empty state.
[Fact]
public void RemoveMsgBlock_StateEmptyWhenBlockDeleted()
{
var subDir = Path.Combine(_root, "remove-blk-state");
Directory.CreateDirectory(subDir);
{
using var store = new FileStore(new FileStoreOptions { Directory = subDir });
store.StoreMsg("test", null, null!, 0); // seq 1
var ss = new StreamState();
store.FastState(ref ss);
ss.Msgs.ShouldBe(1UL);
}
// Delete the .blk file to simulate losing block data.
var blkFiles = Directory.GetFiles(subDir, "*.blk");
foreach (var f in blkFiles)
File.Delete(f);
// Also delete state file to force rebuild from blocks.
var stateFiles = Directory.GetFiles(subDir, "*.dat");
foreach (var f in stateFiles)
File.Delete(f);
{
using var store = new FileStore(new FileStoreOptions { Directory = subDir });
var ss = new StreamState();
store.FastState(ref ss);
// After block deletion, store recovers as empty.
ss.Msgs.ShouldBe(0UL);
}
}
// -------------------------------------------------------------------------
// SparseCompaction — basic
// -------------------------------------------------------------------------
// Go: TestFileStoreSparseCompaction server/filestore_test.go:3225
// Compacting a block with many deletes reduces file size while preserving state.
// Note: .NET tracks NumDeleted for interior gaps only (between FirstSeq and LastSeq).
// Tail deletions reduce _last rather than creating dmap entries as in Go.
[Fact]
public void SparseCompaction_Basic_StatePreservedAfterDeletesAndCompact()
{
using var store = CreateStore("sparse-compact-basic",
new FileStoreOptions { BlockSizeBytes = 1024 * 1024 });
var msg = new byte[100];
new Random(42).NextBytes(msg);
for (var i = 1; i <= 100; i++)
store.StoreMsg($"kv.{i % 10}", null, msg, 0);
var state1 = store.State();
state1.Msgs.ShouldBe(100UL);
state1.FirstSeq.ShouldBe(1UL);
state1.LastSeq.ShouldBe(100UL);
// Delete interior messages (not the tail) to test NumDeleted tracking.
// The .NET implementation correctly tracks interior gaps; tail deletions
// reduce LastSeq rather than creating dmap entries.
store.RemoveMsg(10).ShouldBeTrue();
store.RemoveMsg(20).ShouldBeTrue();
store.RemoveMsg(30).ShouldBeTrue();
store.RemoveMsg(40).ShouldBeTrue();
store.RemoveMsg(50).ShouldBeTrue();
var state2 = store.State();
state2.Msgs.ShouldBe(95UL);
state2.LastSeq.ShouldBe(100UL); // Last seq unchanged (interior deletes)
state2.NumDeleted.ShouldBe(5); // 5 interior gaps
}
// -------------------------------------------------------------------------
// AllLastSeqs comprehensive
// -------------------------------------------------------------------------
// Go: TestFileStoreAllLastSeqs server/filestore_test.go:9731
// AllLastSeqs returns sorted last sequences matching LoadLastMsg per subject.
[Fact]
public void AllLastSeqs_MatchesLoadLastMsgPerSubject()
{
using var store = CreateStore("all-last-seqs-comprehensive");
var subjects = new[] { "foo.foo", "foo.bar", "foo.baz", "bar.foo", "bar.bar", "bar.baz" };
var msg = "abc"u8.ToArray();
var rng = new Random(42);
for (var i = 0; i < 500; i++)
{
var subj = subjects[rng.Next(subjects.Length)];
store.StoreMsg(subj, null, msg, 0);
}
// Build expected: last seq per subject.
var expected = new List();
var smv = new StoreMsg();
foreach (var subj in subjects)
{
try
{
var sm = store.LoadLastMsg(subj, smv);
expected.Add(sm.Sequence);
}
catch (KeyNotFoundException)
{
// Subject might not have been stored.
}
}
expected.Sort();
var seqs = store.AllLastSeqs();
seqs.Length.ShouldBe(expected.Count);
for (var i = 0; i < expected.Count; i++)
seqs[i].ShouldBe(expected[i]);
}
}