Files
natsdotnet/tests/NATS.Server.Tests/JetStream/Consumers/PriorityGroupTests.cs

238 lines
9.7 KiB
C#

// Go: consumer.go:500-600 — Priority group tests for sticky consumer assignment.
// Validates that the lowest-priority-numbered consumer is "active" and that
// failover occurs correctly when consumers register/unregister.
using System.Collections.Concurrent;
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 PriorityGroupTests
{
// -------------------------------------------------------------------------
// Test 1 — Single consumer registered is active
//
// Go reference: consumer.go:500 — when only one consumer is in a priority
// group, it is unconditionally the active consumer.
// -------------------------------------------------------------------------
[Fact]
public void Register_SingleConsumer_IsActive()
{
var mgr = new PriorityGroupManager();
mgr.Register("group1", "consumer-a", priority: 1);
mgr.IsActive("group1", "consumer-a").ShouldBeTrue();
mgr.GetActiveConsumer("group1").ShouldBe("consumer-a");
}
// -------------------------------------------------------------------------
// Test 2 — Multiple consumers: lowest priority number wins
//
// Go reference: consumer.go:510 — the consumer with the lowest priority
// number is the active consumer. Priority 1 < Priority 5, so 1 wins.
// -------------------------------------------------------------------------
[Fact]
public void Register_MultipleConsumers_LowestPriorityIsActive()
{
var mgr = new PriorityGroupManager();
mgr.Register("group1", "consumer-high", priority: 5);
mgr.Register("group1", "consumer-low", priority: 1);
mgr.Register("group1", "consumer-mid", priority: 3);
mgr.GetActiveConsumer("group1").ShouldBe("consumer-low");
mgr.IsActive("group1", "consumer-low").ShouldBeTrue();
mgr.IsActive("group1", "consumer-high").ShouldBeFalse();
mgr.IsActive("group1", "consumer-mid").ShouldBeFalse();
}
// -------------------------------------------------------------------------
// Test 3 — Unregister active consumer: next takes over
//
// Go reference: consumer.go:530 — when the active consumer disconnects,
// the next-lowest-priority consumer becomes active (failover).
// -------------------------------------------------------------------------
[Fact]
public void Unregister_ActiveConsumer_NextTakesOver()
{
var mgr = new PriorityGroupManager();
mgr.Register("group1", "consumer-a", priority: 1);
mgr.Register("group1", "consumer-b", priority: 2);
mgr.Register("group1", "consumer-c", priority: 3);
mgr.GetActiveConsumer("group1").ShouldBe("consumer-a");
mgr.Unregister("group1", "consumer-a");
mgr.GetActiveConsumer("group1").ShouldBe("consumer-b");
mgr.IsActive("group1", "consumer-b").ShouldBeTrue();
mgr.IsActive("group1", "consumer-a").ShouldBeFalse();
}
// -------------------------------------------------------------------------
// Test 4 — Unregister non-active consumer: active unchanged
//
// Go reference: consumer.go:540 — removing a non-active consumer does not
// change the active assignment.
// -------------------------------------------------------------------------
[Fact]
public void Unregister_NonActiveConsumer_ActiveUnchanged()
{
var mgr = new PriorityGroupManager();
mgr.Register("group1", "consumer-a", priority: 1);
mgr.Register("group1", "consumer-b", priority: 2);
mgr.GetActiveConsumer("group1").ShouldBe("consumer-a");
mgr.Unregister("group1", "consumer-b");
mgr.GetActiveConsumer("group1").ShouldBe("consumer-a");
mgr.IsActive("group1", "consumer-a").ShouldBeTrue();
}
// -------------------------------------------------------------------------
// Test 5 — Same priority: first registered wins
//
// Go reference: consumer.go:520 — when two consumers share the same
// priority, the first to register is treated as the active consumer.
// -------------------------------------------------------------------------
[Fact]
public void Register_SamePriority_FirstRegisteredWins()
{
var mgr = new PriorityGroupManager();
mgr.Register("group1", "consumer-first", priority: 1);
mgr.Register("group1", "consumer-second", priority: 1);
mgr.GetActiveConsumer("group1").ShouldBe("consumer-first");
mgr.IsActive("group1", "consumer-first").ShouldBeTrue();
mgr.IsActive("group1", "consumer-second").ShouldBeFalse();
}
// -------------------------------------------------------------------------
// Test 6 — Empty group returns null
//
// Go reference: consumer.go:550 — calling GetActiveConsumer on an empty
// or nonexistent group returns nil (null).
// -------------------------------------------------------------------------
[Fact]
public void GetActiveConsumer_EmptyGroup_ReturnsNull()
{
var mgr = new PriorityGroupManager();
mgr.GetActiveConsumer("nonexistent").ShouldBeNull();
mgr.IsActive("nonexistent", "any-consumer").ShouldBeFalse();
}
// -------------------------------------------------------------------------
// Test 7 — Idle heartbeat sent after timeout
//
// Go reference: consumer.go:5222 — sendIdleHeartbeat is invoked by a
// background timer when no data frames are delivered within HeartbeatMs.
// -------------------------------------------------------------------------
[Fact]
public async Task IdleHeartbeat_SentAfterTimeout()
{
var engine = new PushConsumerEngine();
var consumer = new ConsumerHandle("TEST-STREAM", new ConsumerConfig
{
DurableName = "HB-CONSUMER",
Push = true,
DeliverSubject = "deliver.hb",
HeartbeatMs = 50, // 50ms heartbeat interval
});
var sent = new ConcurrentBag<(string Subject, string ReplyTo, byte[] Headers, byte[] Payload)>();
ValueTask SendCapture(string subject, string replyTo, ReadOnlyMemory<byte> headers, ReadOnlyMemory<byte> payload, CancellationToken ct)
{
sent.Add((subject, replyTo, headers.ToArray(), payload.ToArray()));
return ValueTask.CompletedTask;
}
using var cts = new CancellationTokenSource();
engine.StartDeliveryLoop(consumer, SendCapture, cts.Token);
// Wait long enough for at least one idle heartbeat to fire
await Task.Delay(200);
engine.StopDeliveryLoop();
engine.IdleHeartbeatsSent.ShouldBeGreaterThan(0);
// Verify the heartbeat messages were sent to the deliver subject
var hbMessages = sent.Where(s =>
Encoding.ASCII.GetString(s.Headers).Contains("Idle Heartbeat")).ToList();
hbMessages.Count.ShouldBeGreaterThan(0);
hbMessages.ShouldAllBe(m => m.Subject == "deliver.hb");
}
// -------------------------------------------------------------------------
// Test 8 — Idle heartbeat resets on data delivery
//
// Go reference: consumer.go:5222 — the idle heartbeat timer is reset
// whenever a data frame is delivered, so heartbeats only fire during
// periods of inactivity.
// -------------------------------------------------------------------------
[Fact]
public async Task IdleHeartbeat_ResetOnDataDelivery()
{
var engine = new PushConsumerEngine();
var consumer = new ConsumerHandle("TEST-STREAM", new ConsumerConfig
{
DurableName = "HB-RESET",
Push = true,
DeliverSubject = "deliver.hbreset",
HeartbeatMs = 100, // 100ms heartbeat interval
});
var dataFramesSent = new ConcurrentBag<string>();
var heartbeatsSent = new ConcurrentBag<string>();
ValueTask SendCapture(string subject, string replyTo, ReadOnlyMemory<byte> headers, ReadOnlyMemory<byte> payload, CancellationToken ct)
{
var headerStr = Encoding.ASCII.GetString(headers.Span);
if (headerStr.Contains("Idle Heartbeat"))
heartbeatsSent.Add(subject);
else
dataFramesSent.Add(subject);
return ValueTask.CompletedTask;
}
using var cts = new CancellationTokenSource();
engine.StartDeliveryLoop(consumer, SendCapture, cts.Token);
// Continuously enqueue data messages faster than the heartbeat interval
// to keep the timer resetting. Each data delivery resets the idle heartbeat.
for (var i = 0; i < 5; i++)
{
engine.Enqueue(consumer, new StoredMessage
{
Sequence = (ulong)(i + 1),
Subject = "test.data",
Payload = Encoding.UTF8.GetBytes($"msg-{i}"),
TimestampUtc = DateTime.UtcNow,
});
await Task.Delay(30); // 30ms between messages — well within 100ms heartbeat
}
// Wait a bit after last message for potential heartbeat
await Task.Delay(50);
engine.StopDeliveryLoop();
// Data frames should have been sent
dataFramesSent.Count.ShouldBeGreaterThan(0);
// During continuous data delivery, idle heartbeats from the timer should
// NOT have fired because the timer is reset on each data frame.
// (The queue-based heartbeat frames still fire as part of Enqueue, but
// the idle heartbeat timer counter should be 0 or very low since data
// kept flowing within the heartbeat interval.)
engine.IdleHeartbeatsSent.ShouldBe(0);
}
}