197 lines
8.0 KiB
C#
197 lines
8.0 KiB
C#
// 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();
|
|
}
|
|
}
|