feat(consumer): add pause/resume with auto-resume timer

Adds PauseUntilUtc to ConsumerHandle, a new Pause(DateTime) overload,
Resume, IsPaused, and GetPauseUntil to ConsumerManager. A System.Threading.Timer
fires when the deadline passes and calls AutoResume, raising OnAutoResumed so
tests can synchronise via SemaphoreSlim instead of Task.Delay. ConsumerManager
now implements IDisposable to clean up outstanding timers. Timer is also
cancelled on explicit Resume and Delete.

Go reference: consumer.go (pauseConsumer / resumeConsumer / isPaused).
This commit is contained in:
Joseph Doherty
2026-02-25 02:21:08 -05:00
parent 8fb80acafe
commit dcc3e4460e
2 changed files with 229 additions and 1 deletions

View File

@@ -0,0 +1,103 @@
using NATS.Server.JetStream;
using NATS.Server.JetStream.Models;
namespace NATS.Server.Tests.JetStream.Consumers;
/// <summary>
/// Tests for consumer pause/resume with auto-resume timer.
/// Go reference: consumer.go (pause/resume).
/// </summary>
public class ConsumerPauseResumeTests
{
private static ConsumerManager CreateManager() => new();
private static void CreateConsumer(ConsumerManager mgr, string stream, string name)
{
mgr.CreateOrUpdate(stream, new ConsumerConfig { DurableName = name });
}
[Fact]
public void Pause_with_deadline_sets_paused()
{
var mgr = CreateManager();
CreateConsumer(mgr, "test-stream", "test-consumer");
var until = DateTime.UtcNow.AddSeconds(5);
mgr.Pause("test-stream", "test-consumer", until);
mgr.IsPaused("test-stream", "test-consumer").ShouldBeTrue();
mgr.GetPauseUntil("test-stream", "test-consumer").ShouldBe(until);
}
[Fact]
public void Resume_clears_pause()
{
var mgr = CreateManager();
CreateConsumer(mgr, "test-stream", "test-consumer");
mgr.Pause("test-stream", "test-consumer", DateTime.UtcNow.AddSeconds(5));
mgr.Resume("test-stream", "test-consumer");
mgr.IsPaused("test-stream", "test-consumer").ShouldBeFalse();
mgr.GetPauseUntil("test-stream", "test-consumer").ShouldBeNull();
}
[Fact]
public async Task Pause_auto_resumes_after_deadline()
{
var mgr = CreateManager();
CreateConsumer(mgr, "test-stream", "test-consumer");
// Use a semaphore to synchronize on the actual timer callback rather than a blind delay.
using var resumed = new SemaphoreSlim(0, 1);
mgr.OnAutoResumed += (_, _) => resumed.Release();
mgr.Pause("test-stream", "test-consumer", DateTime.UtcNow.AddMilliseconds(100));
var signalled = await resumed.WaitAsync(TimeSpan.FromSeconds(5));
signalled.ShouldBeTrue("auto-resume timer did not fire within 5 seconds");
mgr.IsPaused("test-stream", "test-consumer").ShouldBeFalse();
}
[Fact]
public void IsPaused_returns_false_for_unknown_consumer()
{
var mgr = CreateManager();
mgr.IsPaused("unknown", "unknown").ShouldBeFalse();
}
[Fact]
public void GetPauseUntil_returns_null_for_unknown_consumer()
{
var mgr = CreateManager();
mgr.GetPauseUntil("unknown", "unknown").ShouldBeNull();
}
[Fact]
public void Resume_returns_false_for_unknown_consumer()
{
var mgr = CreateManager();
mgr.Resume("unknown", "unknown").ShouldBeFalse();
}
[Fact]
public void Pause_returns_false_for_unknown_consumer()
{
var mgr = CreateManager();
mgr.Pause("unknown", "unknown", DateTime.UtcNow.AddSeconds(5)).ShouldBeFalse();
}
[Fact]
public void IsPaused_auto_resumes_expired_deadline()
{
var mgr = CreateManager();
CreateConsumer(mgr, "test-stream", "c1");
// Pause with a deadline in the past
mgr.Pause("test-stream", "c1", DateTime.UtcNow.AddMilliseconds(-100));
// IsPaused should detect the expired deadline and auto-resume
mgr.IsPaused("test-stream", "c1").ShouldBeFalse();
}
}