Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
7aee2c7
Fix dispose bug on consumers
mtmk Mar 6, 2026
568fb86
Merge branch 'main' into fix-dispose-bug-on-consumers
mtmk Mar 11, 2026
3cf70d6
Improve `Fetch_dispose_does_not_drop_buffered_messages` test to ensur…
mtmk Mar 11, 2026
ac5c5af
Add `DrainAsync` to ensure safe disposal of subscriptions
mtmk Mar 12, 2026
bf906c0
Merge branch 'main' into fix-dispose-bug-on-consumers
mtmk Apr 23, 2026
3c094ff
Extend drain to ordered consumer, add repro sandbox
mtmk Apr 23, 2026
6b770a9
Wait for consumer reader to drain on dispose
mtmk Apr 26, 2026
60aee13
Gate dispose drain behavior behind opt-in options
mtmk Apr 26, 2026
6567ebb
Bump test session timeout to 10 minutes
mtmk Apr 26, 2026
f26a89b
Fix Consume_connection_dispose flap on slow consumer-stat updates
mtmk Apr 26, 2026
a8eb92c
Drain Fetch and ordered consumers on connection dispose
mtmk Apr 26, 2026
e7421ed
docs: add blog with dispose-drain announcement
mtmk Apr 27, 2026
8d68a6b
docs: add resilience note to dispose-drain post
mtmk Apr 27, 2026
669317a
Tighten DrainAsync exception handling
mtmk Apr 28, 2026
08970c2
Gate consumer dispose drain on DrainSubscriptionsOnDispose
mtmk Apr 28, 2026
80e6692
Document drain mechanism on DrainSubscriptionsOnDispose [no ci]
mtmk Apr 28, 2026
f8efb0a
Hoist drain-participant state into NatsSubBase
mtmk Apr 28, 2026
9dae4c9
Parallelise drain participants and add DrainPingTimeout opt
mtmk Apr 28, 2026
e175930
drain: fix routing cleared before PING/PONG, parallelise manager dispose
mtmk Apr 29, 2026
daeb7c5
drain: guard sid sentinel and ensure socket cleanup on sub-dispose throw
mtmk Apr 29, 2026
fd26de8
js: remove dead _disposed field from consumer sub classes
mtmk Apr 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 41 additions & 8 deletions src/NATS.Client.Core/Internal/SubscriptionManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -149,18 +149,23 @@ public async ValueTask DisposeAsync()
_cts.Cancel();
#endif

WeakReference<NatsSubBase>[] subRefs;
var subs = new List<NatsSubBase>();
lock (_gate)
{
subRefs = _bySid.Values.Select(m => m.WeakReference).ToArray();
_bySid.Clear();
foreach (var sidMetadata in _bySid.Values)
{
if (sidMetadata.WeakReference.TryGetTarget(out var sub))
subs.Add(sub);
}
}

foreach (var subRef in subRefs)
{
if (subRef.TryGetTarget(out var sub))
await sub.DisposeAsync().ConfigureAwait(false);
}
if (subs.Count == 0)
return;

var tasks = new Task[subs.Count];
for (var i = 0; i < subs.Count; i++)
tasks[i] = subs[i].DisposeAsync().AsTask();
await Task.WhenAll(tasks).ConfigureAwait(false);
}

public ValueTask RemoveAsync(NatsSubBase sub)
Expand Down Expand Up @@ -188,6 +193,34 @@ public ValueTask RemoveAsync(NatsSubBase sub)
return _connection.UnsubscribeAsync(subMetadata.Sid);
}

// Send UNSUB and remove from _bySub, but keep in _bySid until after PING/PONG.
// Returns the SID so the caller can call RemoveFromRouting after the round-trip.
internal async ValueTask<int> UnsubscribeForDrainAsync(NatsSubBase sub)
{
SubscriptionMetadata? subMetadata;
lock (_gate)
{
if (!_bySub.TryGetValue(sub, out subMetadata))
return -1;
_bySub.Remove(sub);
}

if (_debug)
_logger.LogDebug(NatsLogEvents.Subscription, "Drain-unsubscribing {Subject}/{Sid}", sub.Subject, subMetadata.Sid);

await _connection.UnsubscribeAsync(subMetadata.Sid).ConfigureAwait(false);
return subMetadata.Sid;
}

// Remove from _bySid after the PING/PONG round-trip completes.
internal void RemoveFromRouting(int sid)
{
lock (_gate)
{
_bySid.TryRemove(sid, out _);
}
}

/// <summary>
/// Returns commands for all the live subscriptions to be used on reconnect so that they can rebuild their connection state on the server.
/// </summary>
Expand Down
61 changes: 58 additions & 3 deletions src/NATS.Client.Core/NatsConnection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ public partial class NatsConnection : INatsConnection
private readonly ClientOpts _clientOpts;
private readonly SubscriptionManager _subscriptionManager;
private readonly ReplyTaskFactory _replyTaskFactory;
private readonly object _drainParticipantsGate = new();
private readonly HashSet<NatsSubBase> _drainParticipants = new();

private ServerInfo? _writableServerInfo;
private int _pongCount;
Expand Down Expand Up @@ -280,7 +282,6 @@ public virtual async ValueTask DisposeAsync()
IsDisposed = true;
_logger.Log(LogLevel.Information, NatsLogEvents.Connection, "Disposing connection {Name}", _name);

await DisposeSocketAsync(false).ConfigureAwait(false);
if (_pingTimerCancellationTokenSource != null)
{
#if NET8_0_OR_GREATER
Expand All @@ -290,8 +291,28 @@ public virtual async ValueTask DisposeAsync()
#endif
}

await _subscriptionManager.DisposeAsync().ConfigureAwait(false);
await CommandWriter.DisposeAsync().ConfigureAwait(false);
if (Opts.DrainSubscriptionsOnDispose)
{
// Drain subs and flush the writer first so UNSUB/PING/PONG
// and any pending acks can land before the socket closes.
try
{
await _subscriptionManager.DisposeAsync().ConfigureAwait(false);
}
finally
{
await DrainRemainingParticipantsAsync().ConfigureAwait(false);
await CommandWriter.DisposeAsync().ConfigureAwait(false);
await DisposeSocketAsync(false).ConfigureAwait(false);
}
}
else
{
await DisposeSocketAsync(false).ConfigureAwait(false);
await _subscriptionManager.DisposeAsync().ConfigureAwait(false);
await CommandWriter.DisposeAsync().ConfigureAwait(false);
}

_waitForOpenConnection.TrySetCanceled();
#if NET8_0_OR_GREATER
await _disposedCts.CancelAsync().ConfigureAwait(false);
Expand Down Expand Up @@ -361,6 +382,18 @@ internal void ResetPongCount()
// called only internally
internal ValueTask SubscribeCoreAsync(int sid, string subject, string? queueGroup, int? maxMsgs, CancellationToken cancellationToken) => CommandWriter.SubscribeAsync(sid, subject, queueGroup, maxMsgs, cancellationToken);

internal void RegisterDrainParticipant(NatsSubBase sub)
{
lock (_drainParticipantsGate)
_drainParticipants.Add(sub);
}

internal void UnregisterDrainParticipant(NatsSubBase sub)
{
lock (_drainParticipantsGate)
_drainParticipants.Remove(sub);
}

internal ValueTask UnsubscribeAsync(int sid)
{
try
Expand Down Expand Up @@ -1140,4 +1173,26 @@ private void ThrowIfDisposed()
if (IsDisposed)
throw new ObjectDisposedException(null);
}

// Drain subs that already removed themselves from the SubscriptionManager
// (e.g. JetStream Fetch on natural completion) but still have a user
// iterator reading the channel. Their own DrainAsync waits on the
// reader-exited signal. Run in parallel so a slow/dead server doesn't
// multiply the dispose wait by the number of subs.
private ValueTask DrainRemainingParticipantsAsync()
{
NatsSubBase[] remaining;
lock (_drainParticipantsGate)
{
if (_drainParticipants.Count == 0)
return default;
remaining = _drainParticipants.ToArray();
}

var tasks = new Task[remaining.Length];
for (var i = 0; i < remaining.Length; i++)
tasks[i] = remaining[i].DrainAsync().AsTask();

return new ValueTask(Task.WhenAll(tasks));
}
}
49 changes: 49 additions & 0 deletions src/NATS.Client.Core/NatsOpts.cs
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,55 @@ public sealed record NatsOpts

public bool WaitUntilSent { get; init; } = false;

/// <summary>
/// When true, <see cref="NatsConnection.DisposeAsync"/> drains subscriptions
/// and flushes the command writer before closing the socket. (default: false)
/// </summary>
/// <remarks>
/// <para>
/// With the default (false), the socket is closed first, which means UNSUB
/// and any pending writes (e.g. JetStream acks queued by a consume loop)
/// are dropped. Enable to let subscriptions drain cleanly on dispose.
/// Required for <see cref="ConsumerDrainOnDisposeTimeout"/> to be effective.
/// </para>
/// <para>
/// Drain mechanism: each subscription sends UNSUB, then a PING and waits
/// for the PONG (bounded by <see cref="DrainPingTimeout"/>). Once the
/// PONG arrives the server has processed UNSUB and the socket reader has
/// delivered any messages the server sent before that, so completing the
/// user channel afterwards won't drop in-flight messages. Subscriptions
/// drain in parallel on connection dispose, so total dispose time is
/// bounded by one round-trip plus the <see cref="ConsumerDrainOnDisposeTimeout"/>
/// wait if set, not multiplied by the number of subscriptions.
/// </para>
/// </remarks>
public bool DrainSubscriptionsOnDispose { get; init; } = false;

/// <summary>
/// When set, JetStream consumer/fetch dispose waits up to this timeout for
/// the user's consume loop to finish acking buffered messages before
/// returning. (default: null, no wait)
/// </summary>
/// <remarks>
/// Only effective when <see cref="DrainSubscriptionsOnDispose"/> is also
/// true. Without this wait, messages already delivered into the consumer
/// channel but not yet yielded by the user loop are dropped on dispose
/// and remain NumAckPending until AckWait expires.
/// </remarks>
public TimeSpan? ConsumerDrainOnDisposeTimeout { get; init; } = null;

/// <summary>
/// Per-subscription PING/PONG timeout used during drain on dispose.
/// (default: 5 seconds)
/// </summary>
/// <remarks>
/// Bounds how long drain will wait for the server to ack the UNSUB before
/// completing the user channel anyway. Increase for high-latency networks
/// where a single round-trip can exceed the default. Only effective when
/// <see cref="DrainSubscriptionsOnDispose"/> is true.
/// </remarks>
public TimeSpan DrainPingTimeout { get; init; } = TimeSpan.FromSeconds(5);

/// <summary>
/// Maximum number of reconnect attempts. (default: -1, unlimited)
/// </summary>
Expand Down
135 changes: 135 additions & 0 deletions src/NATS.Client.Core/NatsSubBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ public abstract class NatsSubBase
private int _pendingMsgs;
private Exception? _exception;
private int _isSlowConsumer;
private TaskCompletionSource? _readerExited;

/// <summary>
/// Creates a new instance of <see cref="NatsSubBase"/>.
Expand Down Expand Up @@ -358,6 +359,82 @@ internal static bool IsHeader503(ReadOnlySequence<byte>? headersBuffer) =>

internal void ClearException() => Interlocked.Exchange(ref _exception, null);

/// <summary>
/// Sends UNSUB to the server, waits for PING/PONG round-trip to ensure
/// all in-flight messages are processed, then completes the channel.
/// This avoids the race where TryComplete() closes the channel while
/// the socket reader is still delivering messages.
/// </summary>
/// <remarks>
/// No-op unless <see cref="NatsOpts.DrainSubscriptionsOnDispose"/> is
/// enabled. The connection-level dispose path also gates entry into
/// this method on the same flag (see ordering in
/// <see cref="NatsConnection.DisposeAsync"/>).
/// </remarks>
/// <returns>A <see cref="ValueTask"/> that represents the asynchronous drain operation.</returns>
internal async ValueTask DrainAsync()
{
if (!Connection.Opts.DrainSubscriptionsOnDispose)
return;

var needsUnsub = false;
lock (_gate)
{
if (!_unsubscribed)
{
_unsubscribed = true;
needsUnsub = true;
}
}

if (needsUnsub)
{
_timeoutTimer?.Change(Timeout.Infinite, Timeout.Infinite);
_idleTimeoutTimer?.Change(Timeout.Infinite, Timeout.Infinite);
_startUpTimeoutTimer?.Change(Timeout.Infinite, Timeout.Infinite);

// For the built-in SubscriptionManager: send UNSUB but keep the SID in the routing
// table until after the PING/PONG round-trip, so messages already in the socket
// buffer are delivered before the channel is completed.
// For external managers: fall back to RemoveAsync (routing removed before PING).
var mgr = _manager as SubscriptionManager;
var sid = mgr != null
? await mgr.UnsubscribeForDrainAsync(this).ConfigureAwait(false)
: -1;
if (mgr == null)
await _manager.RemoveAsync(this).ConfigureAwait(false);

// PING/PONG round-trip: after we get the PONG back, we know the server
// has processed our UNSUB and the socket reader has processed all messages
// that were in-flight before the UNSUB was received by the server.
try
{
using var pingCts = new CancellationTokenSource(Connection.Opts.DrainPingTimeout);
await Connection.PingAsync(pingCts.Token).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
// Timeout via DrainPingTimeout. Expected on dispose path.
}
catch (NatsException)
{
// Connection failed or disconnected during drain. Drain is best-effort.
}

// Remove from routing now that no more messages can arrive for this SID.
if (sid != -1)
mgr?.RemoveFromRouting(sid);

// Now it's safe to complete the channel, no more messages will arrive.
TryComplete();
}

// Always wait for an active user reader to drain the channel before
// returning, even if the subscription was already ended (e.g. via
// EndSubscription / UnsubscribeAsync) before drain was called.
await WaitForReaderDrainAsync().ConfigureAwait(false);
}

/// <summary>
/// Marks this subscription as a slow consumer. Returns true if this was a state transition
/// (i.e., the subscription was not previously marked as a slow consumer).
Expand All @@ -377,6 +454,36 @@ internal static bool IsHeader503(ReadOnlySequence<byte>? headersBuffer) =>
/// <returns>ValueTask</returns>
internal virtual ValueTask WriteReconnectCommandsAsync(CommandWriter commandWriter, int sid) => commandWriter.SubscribeAsync(sid, Subject, QueueGroup, PendingMsgs, CancellationToken.None);

/// <summary>
/// Marks the user-side reader as active so dispose can wait for it to
/// drain buffered messages before completing. Pairs with
/// <see cref="MarkReaderInactive"/>.
/// </summary>
internal void MarkReaderActive()
{
if (!Connection.Opts.DrainSubscriptionsOnDispose)
return;

if (_readerExited is null)
{
var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
Interlocked.CompareExchange(ref _readerExited, tcs, null);
}

if (Connection is NatsConnection nc)
nc.RegisterDrainParticipant(this);
}

/// <summary>
/// Signals the user-side reader has exited. Pairs with <see cref="MarkReaderActive"/>.
/// </summary>
internal void MarkReaderInactive()
{
if (Connection is NatsConnection nc)
nc.UnregisterDrainParticipant(this);
_readerExited?.TrySetResult();
}

/// <summary>
/// Invoked when a MSG or HMSG arrives for the subscription.
/// <remarks>
Expand All @@ -390,6 +497,21 @@ internal static bool IsHeader503(ReadOnlySequence<byte>? headersBuffer) =>
/// <returns></returns>
protected abstract ValueTask ReceiveInternalAsync(string subject, string? replyTo, ReadOnlySequence<byte>? headersBuffer, ReadOnlySequence<byte> payloadBuffer);

/// <summary>
/// Waits for the user-side reader to exit so buffered messages can be
/// drained on dispose. No-op when the reader was never marked active or
/// when <see cref="NatsOpts.ConsumerDrainOnDisposeTimeout"/> is unset.
/// </summary>
protected virtual ValueTask WaitForReaderDrainAsync()
{
var tcs = _readerExited;
if (tcs is null || tcs.Task.IsCompleted)
return default;
if (Connection.Opts.ConsumerDrainOnDisposeTimeout is not { } timeout)
return default;
return WaitForReaderDrainCoreAsync(tcs.Task, timeout);
}

/// <summary>
/// Resets the slow consumer state if the channel has drained, allowing another
/// slow consumer event to be raised if the subscription becomes slow again.
Expand Down Expand Up @@ -481,4 +603,17 @@ protected void EndSubscription(NatsSubEndReason reason)
#pragma warning restore VSTHRD110
#pragma warning restore CA2012
}

private async ValueTask WaitForReaderDrainCoreAsync(Task task, TimeSpan timeout)
{
try
{
using var cts = new CancellationTokenSource(timeout);
await task.WaitAsync(cts.Token).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
_logger.LogWarning(NatsLogEvents.Subscription, "Timeout waiting for subscription reader to exit on dispose");
}
}
}
Loading
Loading