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.
420 lines
16 KiB
C#
420 lines
16 KiB
C#
// Reference: golang/nats-server/server/filestore_test.go
|
|
// Tests ported from: TestFileStorePurgeEx, TestFileStorePurgeExWithSubject,
|
|
// TestFileStorePurgeExKeepOneBug, TestFileStoreCompact, TestFileStoreStreamTruncate,
|
|
// TestFileStoreState, TestFileStoreFilteredState, TestFileStoreSubjectsState,
|
|
// TestFileStoreGetSeqFromTime
|
|
|
|
using System.Text;
|
|
using NATS.Server.JetStream.Storage;
|
|
|
|
namespace NATS.Server.JetStream.Tests.JetStream.Storage;
|
|
|
|
/// <summary>
|
|
/// Tests for FileStore tombstone tracking and purge operations:
|
|
/// PurgeEx, Compact, Truncate, FilteredState, SubjectsState, SubjectsTotals,
|
|
/// State (with deleted sequences), and GetSeqFromTime.
|
|
/// Reference: golang/nats-server/server/filestore_test.go
|
|
/// </summary>
|
|
public sealed class FileStorePurgeBlockTests : IDisposable
|
|
{
|
|
private readonly string _dir;
|
|
|
|
public FileStorePurgeBlockTests()
|
|
{
|
|
_dir = Path.Combine(Path.GetTempPath(), $"nats-js-purgeblock-{Guid.NewGuid():N}");
|
|
Directory.CreateDirectory(_dir);
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
if (Directory.Exists(_dir))
|
|
Directory.Delete(_dir, recursive: true);
|
|
}
|
|
|
|
private FileStore CreateStore(string subdirectory, FileStoreOptions? options = null)
|
|
{
|
|
var dir = Path.Combine(_dir, subdirectory);
|
|
var opts = options ?? new FileStoreOptions();
|
|
opts.Directory = dir;
|
|
return new FileStore(opts);
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// PurgeEx tests
|
|
// -------------------------------------------------------------------------
|
|
|
|
// Go: TestFileStorePurgeExWithSubject — filestore_test.go:~867
|
|
[Fact]
|
|
public async Task PurgeEx_BySubject_RemovesMatchingMessages()
|
|
{
|
|
await using var store = CreateStore("purgex-subject");
|
|
|
|
// Store 5 messages on "foo" and 5 on "bar"
|
|
for (var i = 0; i < 5; i++)
|
|
await store.AppendAsync("foo", Encoding.UTF8.GetBytes($"foo-{i}"), default);
|
|
for (var i = 0; i < 5; i++)
|
|
await store.AppendAsync("bar", Encoding.UTF8.GetBytes($"bar-{i}"), default);
|
|
|
|
var stateBeforePurge = await store.GetStateAsync(default);
|
|
stateBeforePurge.Messages.ShouldBe(10UL);
|
|
|
|
// Purge all "foo" messages (seq=0 means no upper limit; keep=0 means keep none)
|
|
var purged = store.PurgeEx("foo", 0, 0);
|
|
purged.ShouldBe(5UL);
|
|
|
|
// Only "bar" messages remain
|
|
var state = await store.GetStateAsync(default);
|
|
state.Messages.ShouldBe(5UL);
|
|
|
|
var remaining = await store.ListAsync(default);
|
|
remaining.All(m => m.Subject == "bar").ShouldBeTrue();
|
|
}
|
|
|
|
// Go: TestFileStorePurgeExKeepOneBug — filestore_test.go:~910
|
|
[Fact]
|
|
public async Task PurgeEx_WithKeep_RetainsNewestMessages()
|
|
{
|
|
await using var store = CreateStore("purgex-keep");
|
|
|
|
// Store 10 messages on "events"
|
|
for (var i = 0; i < 10; i++)
|
|
await store.AppendAsync("events", Encoding.UTF8.GetBytes($"msg-{i}"), default);
|
|
|
|
// Purge keeping the 3 newest
|
|
var purged = store.PurgeEx("events", 0, 3);
|
|
purged.ShouldBe(7UL);
|
|
|
|
var remaining = await store.ListAsync(default);
|
|
remaining.Count.ShouldBe(3);
|
|
|
|
// The retained messages should be the 3 highest sequences (8, 9, 10)
|
|
var seqs = remaining.Select(m => m.Sequence).OrderBy(s => s).ToArray();
|
|
seqs[0].ShouldBe(8UL);
|
|
seqs[1].ShouldBe(9UL);
|
|
seqs[2].ShouldBe(10UL);
|
|
}
|
|
|
|
// Go: TestFileStorePurgeEx — filestore_test.go:~855
|
|
[Fact]
|
|
public async Task PurgeEx_WithSeqLimit_OnlyPurgesBelowSequence()
|
|
{
|
|
await using var store = CreateStore("purgex-seqlimit");
|
|
|
|
// Store 10 messages on "data"
|
|
for (var i = 1; i <= 10; i++)
|
|
await store.AppendAsync("data", Encoding.UTF8.GetBytes($"d{i}"), default);
|
|
|
|
// Purge "data" messages with seq <= 5 (keep=0)
|
|
var purged = store.PurgeEx("data", 5, 0);
|
|
purged.ShouldBe(5UL);
|
|
|
|
// Messages 6-10 should remain
|
|
var remaining = await store.ListAsync(default);
|
|
remaining.Count.ShouldBe(5);
|
|
remaining.Min(m => m.Sequence).ShouldBe(6UL);
|
|
remaining.Max(m => m.Sequence).ShouldBe(10UL);
|
|
}
|
|
|
|
// Go: PurgeEx with wildcard subject — filestore_test.go:~867
|
|
[Fact]
|
|
public async Task PurgeEx_WithWildcardSubject_RemovesAllMatchingSubjects()
|
|
{
|
|
await using var store = CreateStore("purgex-wildcard");
|
|
|
|
await store.AppendAsync("foo.a", "m1"u8.ToArray(), default);
|
|
await store.AppendAsync("foo.b", "m2"u8.ToArray(), default);
|
|
await store.AppendAsync("bar.a", "m3"u8.ToArray(), default);
|
|
await store.AppendAsync("foo.c", "m4"u8.ToArray(), default);
|
|
|
|
var purged = store.PurgeEx("foo.*", 0, 0);
|
|
purged.ShouldBe(3UL);
|
|
|
|
var remaining = await store.ListAsync(default);
|
|
remaining.Count.ShouldBe(1);
|
|
remaining[0].Subject.ShouldBe("bar.a");
|
|
}
|
|
|
|
// Go: PurgeEx with > wildcard — filestore_test.go:~867
|
|
[Fact]
|
|
public async Task PurgeEx_WithGtWildcard_RemovesAllMatchingSubjects()
|
|
{
|
|
await using var store = CreateStore("purgex-gt-wildcard");
|
|
|
|
await store.AppendAsync("a.b.c", "m1"u8.ToArray(), default);
|
|
await store.AppendAsync("a.b.d", "m2"u8.ToArray(), default);
|
|
await store.AppendAsync("a.x", "m3"u8.ToArray(), default);
|
|
await store.AppendAsync("b.x", "m4"u8.ToArray(), default);
|
|
|
|
var purged = store.PurgeEx("a.>", 0, 0);
|
|
purged.ShouldBe(3UL);
|
|
|
|
var remaining = await store.ListAsync(default);
|
|
remaining.Count.ShouldBe(1);
|
|
remaining[0].Subject.ShouldBe("b.x");
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Compact tests
|
|
// -------------------------------------------------------------------------
|
|
|
|
// Go: TestFileStoreCompact — filestore_test.go:~964
|
|
[Fact]
|
|
public async Task Compact_RemovesMessagesBeforeSequence()
|
|
{
|
|
await using var store = CreateStore("compact-basic");
|
|
|
|
// Store 10 messages
|
|
for (var i = 1; i <= 10; i++)
|
|
await store.AppendAsync("test", Encoding.UTF8.GetBytes($"msg{i}"), default);
|
|
|
|
// Compact to remove messages with seq < 5 (removes 1, 2, 3, 4)
|
|
var removed = store.Compact(5);
|
|
removed.ShouldBe(4UL);
|
|
|
|
var remaining = await store.ListAsync(default);
|
|
remaining.Count.ShouldBe(6); // 5-10
|
|
|
|
remaining.Min(m => m.Sequence).ShouldBe(5UL);
|
|
remaining.Max(m => m.Sequence).ShouldBe(10UL);
|
|
|
|
// Sequence 1-4 should no longer be loadable
|
|
(await store.LoadAsync(1, default)).ShouldBeNull();
|
|
(await store.LoadAsync(4, default)).ShouldBeNull();
|
|
|
|
// Sequence 5 should still exist
|
|
(await store.LoadAsync(5, default)).ShouldNotBeNull();
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Truncate tests
|
|
// -------------------------------------------------------------------------
|
|
|
|
// Go: TestFileStoreStreamTruncate — filestore_test.go:~1035
|
|
[Fact]
|
|
public async Task Truncate_RemovesMessagesAfterSequence()
|
|
{
|
|
await using var store = CreateStore("truncate-basic");
|
|
|
|
// Store 10 messages
|
|
for (var i = 1; i <= 10; i++)
|
|
await store.AppendAsync("stream", Encoding.UTF8.GetBytes($"m{i}"), default);
|
|
|
|
// Truncate at seq=5 (removes 6, 7, 8, 9, 10)
|
|
store.Truncate(5);
|
|
|
|
var remaining = await store.ListAsync(default);
|
|
remaining.Count.ShouldBe(5); // 1-5
|
|
|
|
remaining.Min(m => m.Sequence).ShouldBe(1UL);
|
|
remaining.Max(m => m.Sequence).ShouldBe(5UL);
|
|
|
|
// Messages 6-10 should be gone
|
|
(await store.LoadAsync(6, default)).ShouldBeNull();
|
|
(await store.LoadAsync(10, default)).ShouldBeNull();
|
|
|
|
// Message 5 should still exist
|
|
(await store.LoadAsync(5, default)).ShouldNotBeNull();
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// FilteredState tests
|
|
// -------------------------------------------------------------------------
|
|
|
|
// Go: TestFileStoreFilteredState — filestore_test.go:~1200
|
|
[Fact]
|
|
public async Task FilteredState_ReturnsCorrectState()
|
|
{
|
|
await using var store = CreateStore("filteredstate");
|
|
|
|
// Store 5 messages on "orders" and 5 on "invoices"
|
|
for (var i = 1; i <= 5; i++)
|
|
await store.AppendAsync("orders", Encoding.UTF8.GetBytes($"o{i}"), default);
|
|
for (var i = 1; i <= 5; i++)
|
|
await store.AppendAsync("invoices", Encoding.UTF8.GetBytes($"inv{i}"), default);
|
|
|
|
// FilteredState for "orders" from seq=1
|
|
var ordersState = store.FilteredState(1, "orders");
|
|
ordersState.Msgs.ShouldBe(5UL);
|
|
ordersState.First.ShouldBe(1UL);
|
|
ordersState.Last.ShouldBe(5UL);
|
|
|
|
// FilteredState for "invoices" from seq=1
|
|
var invoicesState = store.FilteredState(1, "invoices");
|
|
invoicesState.Msgs.ShouldBe(5UL);
|
|
invoicesState.First.ShouldBe(6UL);
|
|
invoicesState.Last.ShouldBe(10UL);
|
|
|
|
// FilteredState from seq=7 (only 4 invoices remain)
|
|
var lateInvoices = store.FilteredState(7, "invoices");
|
|
lateInvoices.Msgs.ShouldBe(4UL);
|
|
lateInvoices.First.ShouldBe(7UL);
|
|
lateInvoices.Last.ShouldBe(10UL);
|
|
|
|
// No match for non-existent subject
|
|
var noneState = store.FilteredState(1, "orders.unknown");
|
|
noneState.Msgs.ShouldBe(0UL);
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// SubjectsState tests
|
|
// -------------------------------------------------------------------------
|
|
|
|
// Go: TestFileStoreSubjectsState — filestore_test.go:~1266
|
|
[Fact]
|
|
public async Task SubjectsState_ReturnsPerSubjectState()
|
|
{
|
|
await using var store = CreateStore("subjectsstate");
|
|
|
|
await store.AppendAsync("a.1", "msg"u8.ToArray(), default);
|
|
await store.AppendAsync("a.2", "msg"u8.ToArray(), default);
|
|
await store.AppendAsync("a.1", "msg"u8.ToArray(), default);
|
|
await store.AppendAsync("b.1", "msg"u8.ToArray(), default);
|
|
|
|
var state = store.SubjectsState("a.>");
|
|
|
|
state.ShouldContainKey("a.1");
|
|
state.ShouldContainKey("a.2");
|
|
state.ShouldNotContainKey("b.1");
|
|
|
|
state["a.1"].Msgs.ShouldBe(2UL);
|
|
state["a.1"].First.ShouldBe(1UL);
|
|
state["a.1"].Last.ShouldBe(3UL);
|
|
|
|
state["a.2"].Msgs.ShouldBe(1UL);
|
|
state["a.2"].First.ShouldBe(2UL);
|
|
state["a.2"].Last.ShouldBe(2UL);
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// SubjectsTotals tests
|
|
// -------------------------------------------------------------------------
|
|
|
|
// Go: TestFileStoreSubjectsTotals — filestore_test.go:~1300
|
|
[Fact]
|
|
public async Task SubjectsTotals_ReturnsPerSubjectCounts()
|
|
{
|
|
await using var store = CreateStore("subjectstotals");
|
|
|
|
await store.AppendAsync("x.1", "m"u8.ToArray(), default);
|
|
await store.AppendAsync("x.1", "m"u8.ToArray(), default);
|
|
await store.AppendAsync("x.2", "m"u8.ToArray(), default);
|
|
await store.AppendAsync("y.1", "m"u8.ToArray(), default);
|
|
await store.AppendAsync("x.3", "m"u8.ToArray(), default);
|
|
|
|
var totals = store.SubjectsTotals("x.*");
|
|
|
|
totals.ShouldContainKey("x.1");
|
|
totals.ShouldContainKey("x.2");
|
|
totals.ShouldContainKey("x.3");
|
|
totals.ShouldNotContainKey("y.1");
|
|
|
|
totals["x.1"].ShouldBe(2UL);
|
|
totals["x.2"].ShouldBe(1UL);
|
|
totals["x.3"].ShouldBe(1UL);
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// State (with deleted sequences) tests
|
|
// -------------------------------------------------------------------------
|
|
|
|
// Go: TestFileStoreState — filestore_test.go:~420
|
|
[Fact]
|
|
public async Task State_IncludesDeletedSequences()
|
|
{
|
|
await using var store = CreateStore("state-deleted");
|
|
|
|
// Store 10 messages
|
|
for (var i = 1; i <= 10; i++)
|
|
await store.AppendAsync("events", Encoding.UTF8.GetBytes($"e{i}"), default);
|
|
|
|
// Remove messages 3, 5, 7
|
|
await store.RemoveAsync(3, default);
|
|
await store.RemoveAsync(5, default);
|
|
await store.RemoveAsync(7, default);
|
|
|
|
var state = store.State();
|
|
|
|
state.Msgs.ShouldBe(7UL);
|
|
state.FirstSeq.ShouldBe(1UL);
|
|
state.LastSeq.ShouldBe(10UL);
|
|
state.NumDeleted.ShouldBe(3);
|
|
|
|
state.Deleted.ShouldNotBeNull();
|
|
state.Deleted!.ShouldContain(3UL);
|
|
state.Deleted.ShouldContain(5UL);
|
|
state.Deleted.ShouldContain(7UL);
|
|
state.Deleted.Length.ShouldBe(3);
|
|
|
|
// NumSubjects: all messages are on "events"
|
|
state.NumSubjects.ShouldBe(1);
|
|
state.Subjects.ShouldNotBeNull();
|
|
state.Subjects!["events"].ShouldBe(7UL);
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// GetSeqFromTime tests
|
|
// -------------------------------------------------------------------------
|
|
|
|
// Go: TestFileStoreGetSeqFromTime — filestore_test.go:~1570
|
|
[Fact]
|
|
public async Task GetSeqFromTime_ReturnsCorrectSequence()
|
|
{
|
|
await using var store = CreateStore("getseqfromtime");
|
|
|
|
// Store 5 messages; we'll query by the timestamp of the 3rd message
|
|
var timestamps = new List<DateTime>();
|
|
for (var i = 1; i <= 5; i++)
|
|
{
|
|
await store.AppendAsync("time.test", Encoding.UTF8.GetBytes($"t{i}"), default);
|
|
var msgs = await store.ListAsync(default);
|
|
timestamps.Add(msgs[^1].TimestampUtc);
|
|
// Small delay to ensure distinct timestamps
|
|
await Task.Delay(5);
|
|
}
|
|
|
|
// Query for first seq at or after the timestamp of msg 3
|
|
var targetTime = timestamps[2]; // timestamp of sequence 3
|
|
var seq = store.GetSeqFromTime(targetTime);
|
|
seq.ShouldBe(3UL);
|
|
|
|
// Query with a time before all messages: should return 1
|
|
var beforeAll = timestamps[0].AddMilliseconds(-100);
|
|
store.GetSeqFromTime(beforeAll).ShouldBe(1UL);
|
|
|
|
// Query with a time after all messages: should return last+1
|
|
var afterAll = timestamps[^1].AddSeconds(1);
|
|
store.GetSeqFromTime(afterAll).ShouldBe(6UL); // _last + 1
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// MsgBlock enhancements
|
|
// -------------------------------------------------------------------------
|
|
|
|
// Go: filestore.go dmap — soft-delete tracking and enumeration
|
|
[Fact]
|
|
public async Task MsgBlock_IsDeleted_AndEnumerateNonDeleted_Work()
|
|
{
|
|
await using var store = CreateStore("block-enumerate");
|
|
|
|
// Store 5 messages on 2 subjects
|
|
await store.AppendAsync("a.1", "m1"u8.ToArray(), default);
|
|
await store.AppendAsync("a.2", "m2"u8.ToArray(), default);
|
|
await store.AppendAsync("a.1", "m3"u8.ToArray(), default);
|
|
await store.AppendAsync("b.1", "m4"u8.ToArray(), default);
|
|
await store.AppendAsync("a.2", "m5"u8.ToArray(), default);
|
|
|
|
// Delete sequences 2 and 4
|
|
await store.RemoveAsync(2, default);
|
|
await store.RemoveAsync(4, default);
|
|
|
|
// Verify the state after deletion
|
|
var all = await store.ListAsync(default);
|
|
all.Count.ShouldBe(3);
|
|
all.Select(m => m.Sequence).ShouldBe([1UL, 3UL, 5UL]);
|
|
|
|
// FilteredState should only see non-deleted
|
|
var aState = store.FilteredState(1, "a.1");
|
|
aState.Msgs.ShouldBe(2UL); // sequences 1 and 3
|
|
}
|
|
}
|