// Go: consumer.go — Pull consumer timeout enforcement and compiled filter tests. // ExpiresMs support per consumer.go pull request handling. // CompiledFilter optimizes multi-subject filter matching for consumers. using System.Text; using NATS.Server.JetStream; using NATS.Server.JetStream.Consumers; using NATS.Server.JetStream.Models; using NATS.Server.JetStream.Storage; namespace NATS.Server.Tests.JetStream.Consumers; public class PullConsumerTimeoutTests { private static StreamHandle MakeStream(MemStore store) => new(new StreamConfig { Name = "TEST", Subjects = ["test.>"] }, store); private static ConsumerHandle MakeConsumer(ConsumerConfig? config = null) => new("TEST", config ?? new ConsumerConfig { DurableName = "C1" }); // ------------------------------------------------------------------------- // Test 1 — ExpiresMs returns partial batch when timeout fires // // Go reference: consumer.go — pull fetch with expires returns whatever // messages are available when the timeout fires, even if batch is not full. // ------------------------------------------------------------------------- [Fact] public async Task FetchAsync_ExpiresMs_ReturnsPartialBatch() { var store = new MemStore(); var stream = MakeStream(store); // Store only 2 messages, but request a batch of 10 await store.AppendAsync("test.a", Encoding.UTF8.GetBytes("msg1"), CancellationToken.None); await store.AppendAsync("test.b", Encoding.UTF8.GetBytes("msg2"), CancellationToken.None); var consumer = MakeConsumer(); var engine = new PullConsumerEngine(); var result = await engine.FetchAsync(stream, consumer, new PullFetchRequest { Batch = 10, ExpiresMs = 100, }, CancellationToken.None); // Should get the 2 available messages (partial batch) result.Messages.Count.ShouldBe(2); result.Messages[0].Subject.ShouldBe("test.a"); result.Messages[1].Subject.ShouldBe("test.b"); } // ------------------------------------------------------------------------- // Test 2 — ExpiresMs sets TimedOut = true on partial result // // Go reference: consumer.go — when a pull request expires and the batch // is not fully filled, the response indicates a timeout occurred. // ------------------------------------------------------------------------- [Fact] public async Task FetchAsync_ExpiresMs_ReturnsTimedOutTrue() { var store = new MemStore(); var stream = MakeStream(store); // Store no messages — the fetch should time out with empty results var consumer = MakeConsumer(); var engine = new PullConsumerEngine(); var result = await engine.FetchAsync(stream, consumer, new PullFetchRequest { Batch = 5, ExpiresMs = 50, }, CancellationToken.None); result.TimedOut.ShouldBeTrue(); result.Messages.Count.ShouldBe(0); } // ------------------------------------------------------------------------- // Test 3 — No ExpiresMs waits for full batch (returns what's available) // // Go reference: consumer.go — without expires, the fetch returns available // messages up to batch size without a timeout constraint. // ------------------------------------------------------------------------- [Fact] public async Task FetchAsync_NoExpires_WaitsForFullBatch() { var store = new MemStore(); var stream = MakeStream(store); await store.AppendAsync("test.a", Encoding.UTF8.GetBytes("msg1"), CancellationToken.None); await store.AppendAsync("test.b", Encoding.UTF8.GetBytes("msg2"), CancellationToken.None); await store.AppendAsync("test.c", Encoding.UTF8.GetBytes("msg3"), CancellationToken.None); var consumer = MakeConsumer(); var engine = new PullConsumerEngine(); var result = await engine.FetchAsync(stream, consumer, new PullFetchRequest { Batch = 3, ExpiresMs = 0, // No timeout }, CancellationToken.None); result.Messages.Count.ShouldBe(3); result.TimedOut.ShouldBeFalse(); } // ------------------------------------------------------------------------- // Test 4 — CompiledFilter with no filters matches everything // // Go reference: consumer.go — a consumer with no filter subjects receives // all messages from the stream. // ------------------------------------------------------------------------- [Fact] public void CompiledFilter_NoFilters_MatchesEverything() { var filter = new CompiledFilter([]); filter.Matches("test.a").ShouldBeTrue(); filter.Matches("foo.bar.baz").ShouldBeTrue(); filter.Matches("anything").ShouldBeTrue(); } // ------------------------------------------------------------------------- // Test 5 — CompiledFilter with single exact filter matches only that subject // // Go reference: consumer.go — single filter_subject matches via MatchLiteral. // ------------------------------------------------------------------------- [Fact] public void CompiledFilter_SingleFilter_MatchesExact() { var filter = new CompiledFilter(["test.specific"]); filter.Matches("test.specific").ShouldBeTrue(); filter.Matches("test.other").ShouldBeFalse(); filter.Matches("test").ShouldBeFalse(); } // ------------------------------------------------------------------------- // Test 6 — CompiledFilter with single wildcard filter // // Go reference: consumer.go — wildcard filter_subject uses MatchLiteral // which supports * (single token) and > (multi-token) wildcards. // ------------------------------------------------------------------------- [Fact] public void CompiledFilter_SingleWildcard_MatchesPattern() { var starFilter = new CompiledFilter(["test.*"]); starFilter.Matches("test.a").ShouldBeTrue(); starFilter.Matches("test.b").ShouldBeTrue(); starFilter.Matches("test.a.b").ShouldBeFalse(); starFilter.Matches("other.a").ShouldBeFalse(); var fwcFilter = new CompiledFilter(["test.>"]); fwcFilter.Matches("test.a").ShouldBeTrue(); fwcFilter.Matches("test.a.b").ShouldBeTrue(); fwcFilter.Matches("test.a.b.c").ShouldBeTrue(); fwcFilter.Matches("other.a").ShouldBeFalse(); } // ------------------------------------------------------------------------- // Test 7 — CompiledFilter with multiple filters matches any // // Go reference: consumer.go — filter_subjects (plural) matches if ANY of // the patterns match. Uses HashSet for exact subjects + MatchLiteral for // wildcard patterns. // ------------------------------------------------------------------------- [Fact] public void CompiledFilter_MultipleFilters_MatchesAny() { var filter = new CompiledFilter(["orders.us", "orders.eu", "events.>"]); // Exact matches filter.Matches("orders.us").ShouldBeTrue(); filter.Matches("orders.eu").ShouldBeTrue(); // Wildcard match filter.Matches("events.created").ShouldBeTrue(); filter.Matches("events.updated.v2").ShouldBeTrue(); } // ------------------------------------------------------------------------- // Test 8 — CompiledFilter with multiple filters rejects non-matching // // Go reference: consumer.go — subjects that match none of the filter // patterns are excluded from delivery. // ------------------------------------------------------------------------- [Fact] public void CompiledFilter_MultipleFilters_RejectsNonMatching() { var filter = new CompiledFilter(["orders.us", "orders.eu", "events.>"]); filter.Matches("orders.jp").ShouldBeFalse(); filter.Matches("billing.us").ShouldBeFalse(); filter.Matches("events").ShouldBeFalse(); // ">" requires at least one token after filter.Matches("random.subject").ShouldBeFalse(); } }