feat: add cluster-aware pending request tracking for pull consumers (Gap 3.14)

Adds ProposeWaitingRequest, RegisterClusterPending, RemoveClusterPending,
GetClusterPendingRequests, and ClusterPendingCount to PullConsumerEngine,
backed by a ConcurrentDictionary keyed by reply subject. Includes 10 xUnit
tests covering quorum checks, pending tracking, and concurrent access patterns.
This commit is contained in:
Joseph Doherty
2026-02-25 11:21:21 -05:00
parent a113dd686d
commit b9f6a8cc0b
2 changed files with 254 additions and 0 deletions

View File

@@ -1,3 +1,5 @@
using System.Collections.Concurrent;
using NATS.Server.JetStream.Cluster;
using NATS.Server.JetStream.Storage;
using NATS.Server.JetStream.Models;
using NATS.Server.Subscriptions;
@@ -93,6 +95,60 @@ public sealed class CompiledFilter
public sealed class PullConsumerEngine
{
// Go: consumer.go — cluster-wide pending pull request tracking keyed by reply subject.
// Reference: golang/nats-server/server/consumer.go waitingRequestsPending / proposeWaitingRequest
private readonly ConcurrentDictionary<string, PullWaitingRequest> _clusterPending =
new(StringComparer.Ordinal);
/// <summary>
/// Number of pending pull requests currently tracked across the cluster.
/// Go reference: consumer.go — cluster pending count (waitingRequestsPending).
/// </summary>
public int ClusterPendingCount => _clusterPending.Count;
/// <summary>
/// Proposes a waiting pull request through the consumer's RAFT group.
/// Returns true if quorum is available and the request was registered; false otherwise.
/// Go reference: consumer.go proposeWaitingRequest — propose via consumer RAFT group.
/// </summary>
public bool ProposeWaitingRequest(PullWaitingRequest request, RaftGroup group)
{
if (!group.HasQuorum(group.Peers.Count))
return false;
var replyKey = request.Reply ?? string.Empty;
_clusterPending[replyKey] = request;
return true;
}
/// <summary>
/// Registers a pull request in the cluster pending tracker, keyed by reply subject.
/// Go reference: consumer.go — cluster pending registration on proposal acceptance.
/// </summary>
public void RegisterClusterPending(PullWaitingRequest request)
{
var replyKey = request.Reply ?? string.Empty;
_clusterPending[replyKey] = request;
}
/// <summary>
/// Removes and returns a pending pull request by its reply subject.
/// Returns null if no request is registered for that reply subject.
/// Go reference: consumer.go — cluster pending removal on fulfillment or expiry.
/// </summary>
public PullWaitingRequest? RemoveClusterPending(string replySubject)
{
_clusterPending.TryRemove(replySubject, out var request);
return request;
}
/// <summary>
/// Returns all currently pending pull requests tracked across the cluster.
/// Go reference: consumer.go — enumerate waitingRequestsPending for expiry sweep.
/// </summary>
public IReadOnlyCollection<PullWaitingRequest> GetClusterPendingRequests()
=> _clusterPending.Values.ToArray();
public async ValueTask<PullFetchBatch> FetchAsync(StreamHandle stream, ConsumerHandle consumer, int batch, CancellationToken ct)
=> await FetchAsync(stream, consumer, new PullFetchRequest { Batch = batch }, ct);