// Reference: golang/nats-server/server/store_test.go // Tests ported in this file: // TestStoreLoadNextMsgWildcardStartBeforeFirstMatch → LoadNextMsg_WildcardStartBeforeFirstMatch // TestStoreSubjectStateConsistency → SubjectStateConsistency_UpdatesFirstAndLast // TestStoreCompactCleansUpDmap → Compact_CleansUpDmap (parameterised) // TestStoreTruncateCleansUpDmap → Truncate_CleansUpDmap (parameterised) // TestStorePurgeExZero → PurgeEx_ZeroSeq_EquivalentToPurge // TestStoreUpdateConfigTTLState → UpdateConfigTTLState_MessageSurvivesWhenTtlDisabled // TestStoreStreamInteriorDeleteAccounting → InteriorDeleteAccounting_MemStore (subset without FileStore restart) // TestStoreGetSeqFromTimeWithInteriorDeletesGap → GetSeqFromTime_WithInteriorDeletesGap // TestStoreGetSeqFromTimeWithTrailingDeletes → GetSeqFromTime_WithTrailingDeletes // TestStoreMaxMsgsPerUpdateBug → MaxMsgsPerUpdateBug_ReducesOnConfigUpdate // TestFileStoreMultiLastSeqsAndLoadLastMsgWithLazySubjectState → MultiLastSeqs_AndLoadLastMsg_WithLazySubjectState using NATS.Server.JetStream.Models; using NATS.Server.JetStream.Storage; namespace NATS.Server.JetStream.Tests.JetStream.Storage; /// /// IStreamStore interface contract tests. Validates behaviour shared by all store /// implementations using MemStore (the simplest implementation). /// Each test mirrors a specific Go test from golang/nats-server/server/store_test.go. /// public sealed class StoreInterfaceTests { // Helper: cast MemStore to IStreamStore to access sync interface methods. private static IStreamStore Sync(MemStore ms) => ms; // ------------------------------------------------------------------------- // LoadNextMsg — wildcard start before first match // ------------------------------------------------------------------------- // Go: TestStoreLoadNextMsgWildcardStartBeforeFirstMatch server/store_test.go:118 [Fact] public void LoadNextMsg_WildcardStartBeforeFirstMatch() { var ms = new MemStore(); var s = Sync(ms); // Fill non-matching subjects first so the first wildcard match starts // strictly after the requested start sequence. for (var i = 0; i < 100; i++) s.StoreMsg($"bar.{i}", null, [], 0); var (seq, _) = s.StoreMsg("foo.1", null, [], 0); seq.ShouldBe(101UL); // Loading with wildcard "foo.*" from seq 1 should find the message at seq 101. var sm = new StoreMsg(); var (msg, skip) = s.LoadNextMsg("foo.*", true, 1, sm); msg.Subject.ShouldBe("foo.1"); // skip = seq - start, so seq 101 - start 1 = 100 skip.ShouldBe(100UL); msg.Sequence.ShouldBe(101UL); // Loading after seq 101 should throw — no more foo.* messages. Should.Throw(() => s.LoadNextMsg("foo.*", true, 102, null)); } // ------------------------------------------------------------------------- // SubjectStateConsistency // ------------------------------------------------------------------------- // Go: TestStoreSubjectStateConsistency server/store_test.go:179 [Fact] public void SubjectStateConsistency_UpdatesFirstAndLast() { var ms = new MemStore(); var s = Sync(ms); SimpleState GetSubjectState() { var ss = s.SubjectsState("foo"); ss.TryGetValue("foo", out var state); return state; } ulong LoadFirstSeq() { var sm = new StoreMsg(); var (msg, _) = s.LoadNextMsg("foo", false, 0, sm); return msg.Sequence; } ulong LoadLastSeq() { var sm = new StoreMsg(); var msg = s.LoadLastMsg("foo", sm); return msg.Sequence; } // Publish an initial batch of messages. for (var i = 0; i < 4; i++) s.StoreMsg("foo", null, [], 0); // Expect 4 msgs, with first=1, last=4. var ss = GetSubjectState(); ss.Msgs.ShouldBe(4UL); ss.First.ShouldBe(1UL); LoadFirstSeq().ShouldBe(1UL); ss.Last.ShouldBe(4UL); LoadLastSeq().ShouldBe(4UL); // Remove first message — first should update to seq 2. s.RemoveMsg(1).ShouldBeTrue(); ss = GetSubjectState(); ss.Msgs.ShouldBe(3UL); ss.First.ShouldBe(2UL); LoadFirstSeq().ShouldBe(2UL); ss.Last.ShouldBe(4UL); LoadLastSeq().ShouldBe(4UL); // Remove last message — last should update to seq 3. s.RemoveMsg(4).ShouldBeTrue(); ss = GetSubjectState(); ss.Msgs.ShouldBe(2UL); ss.First.ShouldBe(2UL); LoadFirstSeq().ShouldBe(2UL); ss.Last.ShouldBe(3UL); LoadLastSeq().ShouldBe(3UL); // Remove first message again. s.RemoveMsg(2).ShouldBeTrue(); // Only one message left — first and last should both equal 3. ss = GetSubjectState(); ss.Msgs.ShouldBe(1UL); ss.First.ShouldBe(3UL); LoadFirstSeq().ShouldBe(3UL); ss.Last.ShouldBe(3UL); LoadLastSeq().ShouldBe(3UL); // Publish some more messages so we can test another scenario. for (var i = 0; i < 3; i++) s.StoreMsg("foo", null, [], 0); ss = GetSubjectState(); ss.Msgs.ShouldBe(4UL); ss.First.ShouldBe(3UL); LoadFirstSeq().ShouldBe(3UL); ss.Last.ShouldBe(7UL); LoadLastSeq().ShouldBe(7UL); // Remove last sequence. s.RemoveMsg(7).ShouldBeTrue(); // Remove first sequence. s.RemoveMsg(3).ShouldBeTrue(); // Remove (now) first sequence 5. s.RemoveMsg(5).ShouldBeTrue(); // ss.First and ss.Last should both be recalculated and equal each other. ss = GetSubjectState(); ss.Msgs.ShouldBe(1UL); ss.First.ShouldBe(6UL); LoadFirstSeq().ShouldBe(6UL); ss.Last.ShouldBe(6UL); LoadLastSeq().ShouldBe(6UL); // Store a new message and immediately remove it (marks lastNeedsUpdate), // then store another — that new one becomes the real last. s.StoreMsg("foo", null, [], 0); // seq 8 s.RemoveMsg(8).ShouldBeTrue(); s.StoreMsg("foo", null, [], 0); // seq 9 ss = GetSubjectState(); ss.Msgs.ShouldBe(2UL); ss.First.ShouldBe(6UL); LoadFirstSeq().ShouldBe(6UL); ss.Last.ShouldBe(9UL); LoadLastSeq().ShouldBe(9UL); } // ------------------------------------------------------------------------- // CompactCleansUpDmap — parameterised over compact sequences 2, 3, 4 // ------------------------------------------------------------------------- // Go: TestStoreCompactCleansUpDmap server/store_test.go:449 [Theory] [InlineData(2UL)] [InlineData(3UL)] [InlineData(4UL)] public void Compact_CleansUpDmap(ulong compactSeq) { var ms = new MemStore(); var s = Sync(ms); // Publish 3 messages — no interior deletes. for (var i = 0; i < 3; i++) s.StoreMsg("foo", null, [], 0); var state = s.State(); state.NumDeleted.ShouldBe(0); state.Deleted.ShouldBeNull(); // Removing the middle message creates an interior delete. s.RemoveMsg(2).ShouldBeTrue(); state = s.State(); state.NumDeleted.ShouldBe(1); state.Deleted.ShouldNotBeNull(); state.Deleted!.Length.ShouldBe(1); // Compacting must always clean up the interior delete. s.Compact(compactSeq); state = s.State(); state.NumDeleted.ShouldBe(0); state.Deleted.ShouldBeNull(); // Validate first/last sequence. var expectedFirst = Math.Max(3UL, compactSeq); state.FirstSeq.ShouldBe(expectedFirst); state.LastSeq.ShouldBe(3UL); } // ------------------------------------------------------------------------- // TruncateCleansUpDmap — parameterised over truncate sequences 0, 1 // ------------------------------------------------------------------------- // Go: TestStoreTruncateCleansUpDmap server/store_test.go:500 [Theory] [InlineData(0UL)] [InlineData(1UL)] public void Truncate_CleansUpDmap(ulong truncateSeq) { var ms = new MemStore(); var s = Sync(ms); // Publish 3 messages — no interior deletes. for (var i = 0; i < 3; i++) s.StoreMsg("foo", null, [], 0); var state = s.State(); state.NumDeleted.ShouldBe(0); state.Deleted.ShouldBeNull(); // Removing the middle message creates an interior delete. s.RemoveMsg(2).ShouldBeTrue(); state = s.State(); state.NumDeleted.ShouldBe(1); state.Deleted.ShouldNotBeNull(); state.Deleted!.Length.ShouldBe(1); // Truncating must always clean up the interior delete. s.Truncate(truncateSeq); state = s.State(); state.NumDeleted.ShouldBe(0); state.Deleted.ShouldBeNull(); // Validate first/last sequence after truncate. var expectedFirst = Math.Min(1UL, truncateSeq); state.FirstSeq.ShouldBe(expectedFirst); state.LastSeq.ShouldBe(truncateSeq); } // ------------------------------------------------------------------------- // PurgeEx with zero sequence — must equal Purge // ------------------------------------------------------------------------- // Go: TestStorePurgeExZero server/store_test.go:552 [Fact] public void PurgeEx_ZeroSeq_EquivalentToPurge() { var ms = new MemStore(); var s = Sync(ms); // Simple purge all — stream should be empty but first seq = last seq + 1. s.Purge(); var ss = s.State(); ss.FirstSeq.ShouldBe(1UL); ss.LastSeq.ShouldBe(0UL); // PurgeEx with seq=0 must produce the same result. s.PurgeEx("", 0, 0); ss = s.State(); ss.FirstSeq.ShouldBe(1UL); ss.LastSeq.ShouldBe(0UL); } // ------------------------------------------------------------------------- // UpdateConfig TTL state — message survives when TTL is disabled // ------------------------------------------------------------------------- // Go: TestStoreUpdateConfigTTLState server/store_test.go:574 [Fact] [SlopwatchSuppress("SW004", "TTL expiry test requires real wall-clock time to elapse; Thread.Sleep waits for message TTL to expire or survive")] public void UpdateConfigTTLState_MessageSurvivesWhenTtlDisabled() { var cfg = new StreamConfig { Name = "TEST", Subjects = ["foo"], Storage = StorageType.Memory, AllowMsgTtl = false, }; var ms = new MemStore(cfg); var s = Sync(ms); // TTLs disabled — message with ttl=1s should survive even after 2s. var (seq, _) = s.StoreMsg("foo", null, [], 1); Thread.Sleep(2_000); // Should not throw — message should still be present. var loaded = s.LoadMsg(seq, null); loaded.Sequence.ShouldBe(seq); // Now enable TTLs. cfg.AllowMsgTtl = true; s.UpdateConfig(cfg); // TTLs enabled — message with ttl=1s should expire. var (seq2, _) = s.StoreMsg("foo", null, [], 1); Thread.Sleep(2_500); // Should throw — message should have expired. Should.Throw(() => s.LoadMsg(seq2, null)); // Now disable TTLs again. cfg.AllowMsgTtl = false; s.UpdateConfig(cfg); // TTLs disabled — message with ttl=1s should survive. var (seq3, _) = s.StoreMsg("foo", null, [], 1); Thread.Sleep(2_000); // Should not throw — TTL wheel is gone so message stays. var loaded3 = s.LoadMsg(seq3, null); loaded3.Sequence.ShouldBe(seq3); } // ------------------------------------------------------------------------- // StreamInteriorDeleteAccounting — MemStore subset (no FileStore restart) // ------------------------------------------------------------------------- // Go: TestStoreStreamInteriorDeleteAccounting server/store_test.go:621 // Tests TruncateWithRemove and TruncateWithErase variants on MemStore. [Theory] [InlineData(false, "TruncateWithRemove")] [InlineData(false, "TruncateWithErase")] [InlineData(false, "SkipMsg")] [InlineData(false, "SkipMsgs")] [InlineData(true, "TruncateWithRemove")] [InlineData(true, "TruncateWithErase")] [InlineData(true, "SkipMsg")] [InlineData(true, "SkipMsgs")] public void InteriorDeleteAccounting_StateIsCorrect(bool empty, string actionTitle) { var ms = new MemStore(); var s = Sync(ms); ulong lseq = 0; if (!empty) { var (storedSeq, _) = s.StoreMsg("foo", null, [], 0); storedSeq.ShouldBe(1UL); lseq = storedSeq; } lseq++; switch (actionTitle) { case "TruncateWithRemove": { var (storedSeq, _) = s.StoreMsg("foo", null, [], 0); storedSeq.ShouldBe(lseq); s.RemoveMsg(lseq).ShouldBeTrue(); s.Truncate(lseq); break; } case "TruncateWithErase": { var (storedSeq, _) = s.StoreMsg("foo", null, [], 0); storedSeq.ShouldBe(lseq); s.EraseMsg(lseq).ShouldBeTrue(); s.Truncate(lseq); break; } case "SkipMsg": { s.SkipMsg(0); break; } case "SkipMsgs": { s.SkipMsgs(lseq, 1); break; } } // Confirm state. var before = s.State(); if (empty) { before.Msgs.ShouldBe(0UL); before.FirstSeq.ShouldBe(2UL); before.LastSeq.ShouldBe(1UL); } else { before.Msgs.ShouldBe(1UL); before.FirstSeq.ShouldBe(1UL); before.LastSeq.ShouldBe(2UL); } } // ------------------------------------------------------------------------- // GetSeqFromTime with interior deletes gap // ------------------------------------------------------------------------- // Go: TestStoreGetSeqFromTimeWithInteriorDeletesGap server/store_test.go:874 [Fact] public void GetSeqFromTime_WithInteriorDeletesGap() { var ms = new MemStore(); var s = Sync(ms); // Use StoreRawMsg with explicit timestamps to avoid flakiness under CPU pressure. var baseTs = (DateTime.UtcNow.Ticks - DateTime.UnixEpoch.Ticks) * 100L; for (var i = 0; i < 10; i++) s.StoreRawMsg("foo", null, [], (ulong)(i + 1), baseTs + i * 1_000_000L, 0, false); var startTs = baseTs + 1_000_000L; // timestamp of seq 2 (i == 1) // Create a delete gap in the middle: seqs 4-7 deleted. // A naive binary search would hit deleted sequences and return wrong result. for (var seq = 4UL; seq <= 7UL; seq++) s.RemoveMsg(seq).ShouldBeTrue(); // Convert Unix nanoseconds timestamp to DateTime. var t = new DateTime(startTs / 100L + DateTime.UnixEpoch.Ticks, DateTimeKind.Utc); var found = s.GetSeqFromTime(t); found.ShouldBe(2UL); } // ------------------------------------------------------------------------- // GetSeqFromTime with trailing deletes // ------------------------------------------------------------------------- // Go: TestStoreGetSeqFromTimeWithTrailingDeletes server/store_test.go:900 [Fact] public void GetSeqFromTime_WithTrailingDeletes() { var ms = new MemStore(); var s = Sync(ms); // Use StoreRawMsg with explicit timestamps to avoid flakiness under CPU pressure. var baseTs = (DateTime.UtcNow.Ticks - DateTime.UnixEpoch.Ticks) * 100L; for (var i = 0; i < 3; i++) s.StoreRawMsg("foo", null, [], (ulong)(i + 1), baseTs + i * 1_000_000L, 0, false); var startTs = baseTs + 1_000_000L; // timestamp of seq 2 (i == 1) // Delete last message — trailing delete. s.RemoveMsg(3).ShouldBeTrue(); var t = new DateTime(startTs / 100L + DateTime.UnixEpoch.Ticks, DateTimeKind.Utc); var found = s.GetSeqFromTime(t); found.ShouldBe(2UL); } // ------------------------------------------------------------------------- // MaxMsgsPerUpdateBug — per-subject limit enforced on config update // ------------------------------------------------------------------------- // Go: TestStoreMaxMsgsPerUpdateBug server/store_test.go:405 [Fact] public void MaxMsgsPerUpdateBug_ReducesOnConfigUpdate() { var cfg = new StreamConfig { Name = "TEST", Subjects = ["foo"], MaxMsgsPer = 0, Storage = StorageType.Memory, }; var ms = new MemStore(cfg); var s = Sync(ms); for (var i = 0; i < 5; i++) s.StoreMsg("foo", null, [], 0); var state = s.State(); state.Msgs.ShouldBe(5UL); state.FirstSeq.ShouldBe(1UL); state.LastSeq.ShouldBe(5UL); // Update max messages per-subject from 0 (infinite) to 1. // Since the per-subject limit was not specified before, messages should be // removed upon config update, leaving only the most recent. cfg.MaxMsgsPer = 1; s.UpdateConfig(cfg); state = s.State(); state.Msgs.ShouldBe(1UL); state.FirstSeq.ShouldBe(5UL); state.LastSeq.ShouldBe(5UL); } // ------------------------------------------------------------------------- // MultiLastSeqs and LoadLastMsg with lazy subject state // ------------------------------------------------------------------------- // Go: TestFileStoreMultiLastSeqsAndLoadLastMsgWithLazySubjectState server/store_test.go:921 [Fact] public void MultiLastSeqs_AndLoadLastMsg_WithLazySubjectState() { var ms = new MemStore(); var s = Sync(ms); for (var i = 0; i < 3; i++) s.StoreMsg("foo", null, [], 0); // MultiLastSeqs for "foo" should return [3]. var seqs = s.MultiLastSeqs(["foo"], 0, -1); seqs.Length.ShouldBe(1); seqs[0].ShouldBe(3UL); // Remove last message — lazy last needs update. s.RemoveMsg(3).ShouldBeTrue(); seqs = s.MultiLastSeqs(["foo"], 0, -1); seqs.Length.ShouldBe(1); seqs[0].ShouldBe(2UL); // Store another and load it as last. s.StoreMsg("foo", null, [], 0); // seq 4 var lastMsg = s.LoadLastMsg("foo", null); lastMsg.Sequence.ShouldBe(4UL); // Remove seq 4 — lazy last update again. s.RemoveMsg(4).ShouldBeTrue(); lastMsg = s.LoadLastMsg("foo", null); lastMsg.Sequence.ShouldBe(2UL); } }