perf: batch flush signaling and fetch path optimizations (Round 6)

Implement Go's pcd (per-client deferred flush) pattern to reduce write-loop
wakeups during fan-out delivery, optimize ack reply string construction with
stack-based formatting, cache CompiledFilter on ConsumerHandle, and pool
fetch message lists. Durable consumer fetch improves from 0.60x to 0.74x Go.
This commit is contained in:
Joseph Doherty
2026-03-13 09:35:57 -04:00
parent 0a4e7a822f
commit 0be321fa53
13 changed files with 680 additions and 153 deletions
@@ -375,6 +375,32 @@ public sealed class ConsumerManager : IDisposable
public sealed record ConsumerHandle(string Stream, ConsumerConfig Config)
{
/// <summary>
/// Compiled filter derived from Config. Cached per-instance; invalidated when Config
/// changes (either via <c>with { Config = newConfig }</c> or in-place mutation of
/// FilterSubject/FilterSubjects).
/// Go reference: consumer.go — filter subjects resolved once at consumer creation.
/// </summary>
private Consumers.CompiledFilter? _compiledFilter;
private string? _compiledFilterSubject;
private int _compiledFilterSubjectsCount;
public Consumers.CompiledFilter CompiledFilter
{
get
{
// Detect both reference change (with expression) and in-place mutation
if (_compiledFilter == null
|| _compiledFilterSubject != Config.FilterSubject
|| _compiledFilterSubjectsCount != Config.FilterSubjects.Count)
{
_compiledFilter = Consumers.CompiledFilter.FromConfig(Config);
_compiledFilterSubject = Config.FilterSubject;
_compiledFilterSubjectsCount = Config.FilterSubjects.Count;
}
return _compiledFilter;
}
}
public ulong NextSequence { get; set; } = 1;
public bool Paused { get; set; }
@@ -95,6 +95,9 @@ public sealed class CompiledFilter
public sealed class PullConsumerEngine
{
// Reusable fetch buffer to avoid per-fetch List allocation.
// Go reference: consumer.go — reuses slice backing array across fetch calls.
[ThreadStatic] private static List<StoredMessage>? t_fetchBuf;
// 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 =
@@ -155,7 +158,11 @@ public sealed class PullConsumerEngine
public async ValueTask<PullFetchBatch> FetchAsync(StreamHandle stream, ConsumerHandle consumer, PullFetchRequest request, CancellationToken ct)
{
var batch = Math.Max(request.Batch, 1);
var messages = new List<StoredMessage>(batch);
// Use thread-static buffer to avoid per-fetch List allocation.
// Results are snapshot'd into PullFetchBatch before the buffer is reused.
var messages = t_fetchBuf ??= new List<StoredMessage>();
messages.Clear();
if (messages.Capacity < batch) messages.Capacity = batch;
// Go: consumer.go — enforce ExpiresMs timeout on pull fetch requests.
// When ExpiresMs > 0, create a linked CancellationTokenSource that fires
@@ -215,8 +222,8 @@ public sealed class PullConsumerEngine
return new PullFetchBatch(messages);
}
// Use CompiledFilter for efficient multi-filter matching
var compiledFilter = CompiledFilter.FromConfig(consumer.Config);
// Use cached CompiledFilter from ConsumerHandle (avoids per-fetch allocation)
var compiledFilter = consumer.CompiledFilter;
var sequence = consumer.NextSequence;
// Go: consumer.go — MaxBytes caps the total byte payload returned in one pull request
@@ -358,7 +365,8 @@ public sealed class PullFetchBatch
public PullFetchBatch(IReadOnlyList<StoredMessage> messages, bool timedOut = false)
{
Messages = messages;
// Snapshot: caller may reuse the list (ThreadStatic pooling), so take a copy.
Messages = messages.Count == 0 ? [] : messages.ToArray();
TimedOut = timedOut;
}
}