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:
@@ -39,6 +39,10 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
|
||||
private readonly ILogger<NatsServer> _logger;
|
||||
private readonly ILoggerFactory _loggerFactory;
|
||||
private readonly ServerStats _stats = new();
|
||||
|
||||
// Per-client deferred flush set. Collects unique clients during fan-out delivery,
|
||||
// then flushes each once. Go reference: client.go addToPCD / flushClients.
|
||||
[ThreadStatic] private static HashSet<INatsClient>? t_pcd;
|
||||
private readonly TaskCompletionSource _listeningStarted = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
private AuthService _authService;
|
||||
private readonly ConcurrentDictionary<string, Account> _accounts = new(StringComparer.Ordinal);
|
||||
@@ -1333,7 +1337,9 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
|
||||
var response = _jetStreamApiRouter.Route(subject, payload.Span);
|
||||
Interlocked.Increment(ref _stats.JetStreamApiTotal);
|
||||
if (response.Error != null)
|
||||
{
|
||||
Interlocked.Increment(ref _stats.JetStreamApiErrors);
|
||||
}
|
||||
|
||||
// Replicate successful mutating operations to cluster peers.
|
||||
// Go reference: jetstream_cluster.go — RAFT proposal replication.
|
||||
@@ -1399,13 +1405,18 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
|
||||
var result = subList.Match(subject);
|
||||
var delivered = false;
|
||||
|
||||
// Per-client deferred flush: collect unique clients during fan-out, signal each once.
|
||||
// Go reference: client.go:3905 addToPCD / client.go:1324 flushClients.
|
||||
var pcd = t_pcd ??= new HashSet<INatsClient>();
|
||||
pcd.Clear();
|
||||
|
||||
// Deliver to plain subscribers
|
||||
foreach (var sub in result.PlainSubs)
|
||||
{
|
||||
if (sub.Client == null || sub.Client == sender && !(sender.ClientOpts?.Echo ?? true))
|
||||
continue;
|
||||
|
||||
DeliverMessage(sub, subject, replyTo, headers, payload);
|
||||
DeliverMessage(sub, subject, replyTo, headers, payload, pcd);
|
||||
delivered = true;
|
||||
}
|
||||
|
||||
@@ -1416,7 +1427,7 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
|
||||
|
||||
// Simple round-robin -- pick based on total delivered across group
|
||||
var idx = Math.Abs((int)Interlocked.Increment(ref sender.OutMsgs)) % queueGroup.Length;
|
||||
// Undo the OutMsgs increment -- it will be incremented properly in SendMessage
|
||||
// Undo the OutMsgs increment -- it will be incremented properly in SendMessageNoFlush
|
||||
Interlocked.Decrement(ref sender.OutMsgs);
|
||||
|
||||
for (int attempt = 0; attempt < queueGroup.Length; attempt++)
|
||||
@@ -1424,13 +1435,19 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
|
||||
var sub = queueGroup[(idx + attempt) % queueGroup.Length];
|
||||
if (sub.Client != null && (sub.Client != sender || (sender.ClientOpts?.Echo ?? true)))
|
||||
{
|
||||
DeliverMessage(sub, subject, replyTo, headers, payload);
|
||||
DeliverMessage(sub, subject, replyTo, headers, payload, pcd);
|
||||
delivered = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Flush all unique clients once after fan-out.
|
||||
// Go reference: client.go:1324 flushClients — iterates pcd map, one signal per client.
|
||||
foreach (var client in pcd)
|
||||
client.SignalFlush();
|
||||
pcd.Clear();
|
||||
|
||||
// Check for service imports that match this subject.
|
||||
// When a client in the importer account publishes to a subject
|
||||
// that matches a service import "From" pattern, we forward the
|
||||
@@ -1482,6 +1499,7 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
|
||||
int batch = 1;
|
||||
int expiresMs = 0;
|
||||
bool noWait = false;
|
||||
int idleHeartbeatMs = 0;
|
||||
if (payload.Length > 0)
|
||||
{
|
||||
try
|
||||
@@ -1493,6 +1511,8 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
|
||||
noWait = true;
|
||||
if (doc.RootElement.TryGetProperty("expires", out var expEl) && expEl.TryGetInt64(out var expNs))
|
||||
expiresMs = (int)(expNs / 1_000_000);
|
||||
if (doc.RootElement.TryGetProperty("idle_heartbeat", out var hbEl) && hbEl.TryGetInt64(out var hbNs))
|
||||
idleHeartbeatMs = (int)(hbNs / 1_000_000);
|
||||
}
|
||||
catch (System.Text.Json.JsonException ex)
|
||||
{
|
||||
@@ -1500,10 +1520,6 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
var fetchResult = _jetStreamConsumerManager!.FetchAsync(
|
||||
streamName, consumerName, new JetStream.Consumers.PullFetchRequest { Batch = batch, NoWait = noWait, ExpiresMs = expiresMs },
|
||||
_jetStreamStreamManager!, default).GetAwaiter().GetResult();
|
||||
|
||||
// Find the sender's inbox subscription so we can deliver directly.
|
||||
// Go reference: consumer.go deliverMsg — delivers directly to the client, bypassing pub/sub echo checks.
|
||||
var subList = sender.Account?.SubList ?? _globalAccount.SubList;
|
||||
@@ -1521,35 +1537,193 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
|
||||
if (inboxSub == null)
|
||||
return;
|
||||
|
||||
if (noWait || expiresMs <= 0)
|
||||
{
|
||||
// Synchronous path for no_wait (used by FetchAsync client path).
|
||||
// Fetch all immediately available messages and return.
|
||||
var fetchResult = _jetStreamConsumerManager!.FetchAsync(
|
||||
streamName, consumerName,
|
||||
new JetStream.Consumers.PullFetchRequest { Batch = batch, NoWait = true },
|
||||
_jetStreamStreamManager!, default).GetAwaiter().GetResult();
|
||||
|
||||
DeliverFetchedMessages(inboxSub, streamName, consumerName, replyTo, fetchResult.Messages);
|
||||
|
||||
// Send terminal status
|
||||
ReadOnlyMemory<byte> statusHeader = fetchResult.Messages.Count == 0
|
||||
? System.Text.Encoding.UTF8.GetBytes("NATS/1.0 404 No Messages\r\n\r\n")
|
||||
: System.Text.Encoding.UTF8.GetBytes("NATS/1.0 408 Request Timeout\r\n\r\n");
|
||||
DeliverMessage(inboxSub, replyTo, null, statusHeader, default);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Async path for ConsumeAsync: deliver messages incrementally without blocking
|
||||
// the client's read loop. Go reference: consumer.go processNextMsgRequest —
|
||||
// registers a waiting request and returns to the read loop; messages are delivered
|
||||
// asynchronously as they become available.
|
||||
var capturedSub = inboxSub;
|
||||
_ = Task.Run(() => DeliverPullFetchMessagesAsync(
|
||||
streamName, consumerName, batch, expiresMs, idleHeartbeatMs,
|
||||
replyTo, capturedSub, sender));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Background task that delivers pull fetch messages incrementally.
|
||||
/// Polls the stream store for messages and delivers each one as it becomes available.
|
||||
/// Sends idle heartbeats to keep the client connection alive.
|
||||
/// Go reference: consumer.go — waiting request fulfillment loop.
|
||||
/// </summary>
|
||||
private async Task DeliverPullFetchMessagesAsync(
|
||||
string streamName, string consumerName, int batch, int expiresMs, int idleHeartbeatMs,
|
||||
string replyTo, Subscription inboxSub, NatsClient sender)
|
||||
{
|
||||
using var expiresCts = new CancellationTokenSource(TimeSpan.FromMilliseconds(expiresMs));
|
||||
var ct = expiresCts.Token;
|
||||
|
||||
if (!_jetStreamConsumerManager!.TryGet(streamName, consumerName, out var consumer))
|
||||
{
|
||||
DeliverMessage(inboxSub, replyTo, null,
|
||||
System.Text.Encoding.UTF8.GetBytes("NATS/1.0 404 No Messages\r\n\r\n"), default);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_jetStreamStreamManager!.TryGet(streamName, out var streamHandle))
|
||||
{
|
||||
DeliverMessage(inboxSub, replyTo, null,
|
||||
System.Text.Encoding.UTF8.GetBytes("NATS/1.0 404 No Messages\r\n\r\n"), default);
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve initial sequence if needed
|
||||
if (consumer.NextSequence == 1)
|
||||
{
|
||||
var state = await streamHandle.Store.GetStateAsync(ct);
|
||||
consumer.NextSequence = consumer.Config.DeliverPolicy switch
|
||||
{
|
||||
JetStream.Models.DeliverPolicy.Last when state.LastSeq > 0 => state.LastSeq,
|
||||
JetStream.Models.DeliverPolicy.New when consumer.Config.OptStartSeq > 0 => consumer.Config.OptStartSeq,
|
||||
JetStream.Models.DeliverPolicy.New when state.LastSeq > 0 => state.LastSeq + 1,
|
||||
JetStream.Models.DeliverPolicy.ByStartSequence when consumer.Config.OptStartSeq > 0 => consumer.Config.OptStartSeq,
|
||||
_ => state.FirstSeq > 0 ? state.FirstSeq : 1,
|
||||
};
|
||||
}
|
||||
|
||||
// Use cached CompiledFilter from ConsumerHandle (avoids per-fetch allocation)
|
||||
var compiledFilter = consumer.CompiledFilter;
|
||||
var sequence = consumer.NextSequence;
|
||||
ReadOnlyMemory<byte> minHeaders = "NATS/1.0\r\n\r\n"u8.ToArray();
|
||||
var ackPrefix = $"$JS.ACK.{streamName}.{consumerName}.1.";
|
||||
int deliverySeq = 0;
|
||||
int delivered = 0;
|
||||
var lastDeliveryTime = DateTime.UtcNow;
|
||||
var hbInterval = idleHeartbeatMs > 0 ? TimeSpan.FromMilliseconds(idleHeartbeatMs) : TimeSpan.FromSeconds(15);
|
||||
|
||||
try
|
||||
{
|
||||
while (delivered < batch && !ct.IsCancellationRequested)
|
||||
{
|
||||
var message = await streamHandle.Store.LoadAsync(sequence, ct);
|
||||
if (message != null)
|
||||
{
|
||||
// Check filter
|
||||
if (!compiledFilter.Matches(message.Subject))
|
||||
{
|
||||
sequence++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip already-acked messages
|
||||
if (message.Sequence <= consumer.AckProcessor.AckFloor)
|
||||
{
|
||||
sequence++;
|
||||
continue;
|
||||
}
|
||||
|
||||
deliverySeq++;
|
||||
delivered++;
|
||||
var tsNanos = new DateTimeOffset(message.TimestampUtc).ToUnixTimeMilliseconds() * 1_000_000L;
|
||||
var numPending = batch - delivered;
|
||||
var ackReply = BuildAckReply(ackPrefix, message.Sequence, deliverySeq, tsNanos, numPending);
|
||||
|
||||
DeliverMessage(inboxSub, message.Subject, ackReply, minHeaders, message.Payload);
|
||||
|
||||
if (consumer.Config.AckPolicy is JetStream.Models.AckPolicy.Explicit or JetStream.Models.AckPolicy.All)
|
||||
{
|
||||
if (consumer.Config.MaxAckPending > 0 && consumer.AckProcessor.PendingCount >= consumer.Config.MaxAckPending)
|
||||
break;
|
||||
consumer.AckProcessor.Register(message.Sequence, consumer.Config.AckWaitMs);
|
||||
}
|
||||
|
||||
sequence++;
|
||||
lastDeliveryTime = DateTime.UtcNow;
|
||||
}
|
||||
else
|
||||
{
|
||||
// No message available — send idle heartbeat if needed
|
||||
if (DateTime.UtcNow - lastDeliveryTime >= hbInterval)
|
||||
{
|
||||
// Go reference: consumer.go sendIdleHeartbeat — status 100 with headers
|
||||
var hbHeader = System.Text.Encoding.UTF8.GetBytes(
|
||||
"NATS/1.0 100 Idle Heartbeat\r\nNats-Last-Consumer: " + consumerName +
|
||||
"\r\nNats-Last-Stream: " + streamName + "\r\n\r\n");
|
||||
DeliverMessage(inboxSub, replyTo, null, hbHeader, default);
|
||||
lastDeliveryTime = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
// Poll briefly before retrying
|
||||
await Task.Delay(5, ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (expiresCts.IsCancellationRequested)
|
||||
{
|
||||
// ExpiresMs timeout — expected
|
||||
}
|
||||
|
||||
consumer.NextSequence = sequence;
|
||||
|
||||
// Send terminal status
|
||||
ReadOnlyMemory<byte> statusHeader = delivered == 0
|
||||
? System.Text.Encoding.UTF8.GetBytes("NATS/1.0 404 No Messages\r\n\r\n")
|
||||
: System.Text.Encoding.UTF8.GetBytes("NATS/1.0 408 Request Timeout\r\n\r\n");
|
||||
DeliverMessage(inboxSub, replyTo, null, statusHeader, default);
|
||||
}
|
||||
|
||||
private void DeliverFetchedMessages(Subscription inboxSub, string streamName, string consumerName,
|
||||
string replyTo, IReadOnlyList<JetStream.Storage.StoredMessage> messages)
|
||||
{
|
||||
ReadOnlyMemory<byte> minHeaders = "NATS/1.0\r\n\r\n"u8.ToArray();
|
||||
int deliverySeq = 0;
|
||||
int numPending = fetchResult.Messages.Count;
|
||||
int numPending = messages.Count;
|
||||
|
||||
foreach (var msg in fetchResult.Messages)
|
||||
// Pre-compute constant ack prefix to avoid per-message string interpolation.
|
||||
// Go reference: consumer.go — ack reply format is $JS.ACK.<stream>.<consumer>.1.<seq>.<deliverySeq>.<ts>.<pending>
|
||||
var ackPrefix = $"$JS.ACK.{streamName}.{consumerName}.1.";
|
||||
|
||||
// Use pcd pattern: all messages go to the same client, one flush after the loop.
|
||||
var pcd = t_pcd ??= new HashSet<INatsClient>();
|
||||
pcd.Clear();
|
||||
|
||||
foreach (var msg in messages)
|
||||
{
|
||||
deliverySeq++;
|
||||
numPending--;
|
||||
|
||||
var tsNanos = new DateTimeOffset(msg.TimestampUtc).ToUnixTimeMilliseconds() * 1_000_000L;
|
||||
var ackReply = $"$JS.ACK.{streamName}.{consumerName}.1.{msg.Sequence}.{deliverySeq}.{tsNanos}.{numPending}";
|
||||
var ackReply = BuildAckReply(ackPrefix, msg.Sequence, deliverySeq, tsNanos, numPending);
|
||||
|
||||
// Send with the ORIGINAL stream subject (not the inbox) so the NATS client
|
||||
// can distinguish data messages from control/status messages.
|
||||
// Go reference: consumer.go deliverMsg — uses original subject on wire, inbox SID.
|
||||
DeliverMessage(inboxSub, msg.Subject, ackReply, minHeaders, msg.Payload);
|
||||
DeliverMessage(inboxSub, msg.Subject, ackReply, minHeaders, msg.Payload, pcd);
|
||||
}
|
||||
|
||||
// Send terminal status to end the fetch
|
||||
ReadOnlyMemory<byte> statusHeader;
|
||||
if (fetchResult.Messages.Count == 0 || noWait)
|
||||
statusHeader = System.Text.Encoding.UTF8.GetBytes("NATS/1.0 404 No Messages\r\n\r\n");
|
||||
else
|
||||
statusHeader = System.Text.Encoding.UTF8.GetBytes("NATS/1.0 408 Request Timeout\r\n\r\n");
|
||||
DeliverMessage(inboxSub, replyTo, null, statusHeader, default);
|
||||
// Flush once after all messages delivered
|
||||
foreach (var client in pcd)
|
||||
client.SignalFlush();
|
||||
pcd.Clear();
|
||||
}
|
||||
|
||||
private void DeliverMessage(Subscription sub, string subject, string? replyTo,
|
||||
ReadOnlyMemory<byte> headers, ReadOnlyMemory<byte> payload)
|
||||
ReadOnlyMemory<byte> headers, ReadOnlyMemory<byte> payload,
|
||||
HashSet<INatsClient>? pcd = null)
|
||||
{
|
||||
var client = sub.Client;
|
||||
if (client == null) return;
|
||||
@@ -1569,7 +1743,18 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
|
||||
if (client.Permissions?.IsDeliveryAllowed(subject) == false)
|
||||
return;
|
||||
|
||||
client.SendMessage(subject, sub.Sid, replyTo, headers, payload);
|
||||
// When pcd (per-client deferred flush) set is provided, queue data without
|
||||
// signaling the write loop. The caller flushes all unique clients once after
|
||||
// the fan-out loop. Go reference: client.go addToPCD / flushClients.
|
||||
if (pcd != null)
|
||||
{
|
||||
client.SendMessageNoFlush(subject, sub.Sid, replyTo, headers, payload);
|
||||
pcd.Add(client);
|
||||
}
|
||||
else
|
||||
{
|
||||
client.SendMessage(subject, sub.Sid, replyTo, headers, payload);
|
||||
}
|
||||
|
||||
// Track reply subject for response permissions
|
||||
if (replyTo != null && client.Permissions?.ResponseTracker != null)
|
||||
@@ -1579,6 +1764,23 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds an ack reply subject from pre-computed prefix and per-message values.
|
||||
/// Uses stack-based formatting to avoid string interpolation boxing/allocations.
|
||||
/// </summary>
|
||||
private static string BuildAckReply(string ackPrefix, ulong sequence, int deliverySeq, long tsNanos, int numPending)
|
||||
{
|
||||
// Max digits: ulong=20, int=11, long=20, int=11 + 3 dots = 65 chars max for suffix
|
||||
Span<char> buf = stackalloc char[ackPrefix.Length + 65];
|
||||
ackPrefix.AsSpan().CopyTo(buf);
|
||||
var pos = ackPrefix.Length;
|
||||
sequence.TryFormat(buf[pos..], out var w); pos += w; buf[pos++] = '.';
|
||||
deliverySeq.TryFormat(buf[pos..], out w); pos += w; buf[pos++] = '.';
|
||||
tsNanos.TryFormat(buf[pos..], out w); pos += w; buf[pos++] = '.';
|
||||
numPending.TryFormat(buf[pos..], out w); pos += w;
|
||||
return new string(buf[..pos]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Processes a service import by transforming the subject from the importer's
|
||||
/// subject space to the exporter's subject space, then delivering to matching
|
||||
|
||||
Reference in New Issue
Block a user