Fix message loss on consumer dispose#1085
Conversation
…e messages are not dropped during dispose.
Streamlines the dispose logic by adding `DrainAsync` to handle UNSUB, PING/PONG, and channel completion to avoid message loss during shutdown. Applied changes to `NatsJSFetch` and `NatsJSConsume` for proper cleanup and consistent behavior.
Apply DrainAsync to NatsJSOrderedConsume so fetch, consume, and ordered-consume share the same UNSUB -> PING/PONG -> TryComplete dispose path. Replace the flaky in-test stress repro with a sandbox console app (SubCleanDispose) that exercises all four subscription types and checks for sequence gaps.
Disposing the connection mid-consume tore down CommandWriter before the loop could ack buffered messages, throwing ObjectDisposedException and leaving messages stuck NumAckPending until AckWait expired. Reorder NatsConnection.DisposeAsync so subscription drain and writer flush run before the socket closes, and add a reader-active flag so DrainAsync waits for the user iterator's finally to run. Drop the SubCleanDispose sandbox; the new test in ConsumerConsumeTest guards the regression.
The previous commit changed dispose semantics in two ways: subs are now drained before the socket closes, and consumer dispose waits for in-flight consume loops to finish acking buffered messages. Both are strictly more correct, but the wait can block dispose for up to the configured timeout, which is observable. Add NatsOpts.DrainSubscriptionsOnDispose (bool, default false) for the dispose order, and NatsOpts.ConsumerDrainOnDisposeTimeout (TimeSpan?, default null) for the wait. Defaults preserve current behavior; set both to opt into the graceful path.
Polling server-side AckFloor to time the dispose was racing the pull consumer's threshold top-up: the reader could process a second batch before the server stat caught up, so the strict consumed == pullBatch check fired Expected 20 / Actual 30 on Linux runners. Use a client-side TaskCompletionSource to signal once the reader has acked bailAt messages, and assert what actually matters: NumAckPending is zero and AckFloor matches the client-side count, regardless of how many batches the consumer pulled.
Fetch removes itself from the SubscriptionManager via EndSubscription on natural completion (NoMsgs, RequestsPending, MaxBytes, Timeout), so SubscriptionManager.DisposeAsync no longer sees it and the per-sub DrainAsync path that waits for the user reader never fired. Ordered consumer's outer loop tried to RecreateConsumer after the inner cc finished draining, hitting the disposed CommandWriter. Track active reader subs in a per-connection drain participants set, populated from MarkReaderActive / cleared from MarkReaderInactive, and run their DrainAsync from NatsConnection.DisposeAsync between SubscriptionManager.DisposeAsync and CommandWriter.DisposeAsync. Move WaitForReaderDrainAsync outside DrainAsync's _unsubscribed early return so it always runs even when EndSubscription already sent UNSUB. Have the ordered consumer's outer loop bail when the connection is already disposed instead of attempting to recreate. Adds Fetch_connection_dispose_drains_buffered_messages and OrderedConsume_connection_dispose_drains_buffered_messages.
|
Claude finished @mtmk's task in 9m 23s —— View job PR #1085 Review: Fix message loss on consumer dispose
SummaryThe PR addresses a real and important bug (channel completed before wire messages land, acks dropped on dispose). The Issues1. Routing table cleared before PING/PONG – drain protocol is incomplete
// SubscriptionManager.DisposeAsync()
lock (_gate) {
subRefs = _bySid.Values.Select(m => m.WeakReference).ToArray();
_bySid.Clear(); // ← routing gone NOW
}
foreach (var subRef in subRefs)
await sub.DisposeAsync(); // DrainAsync/PING runs here, routing already gone
The drain works well for the test scenarios (all batch messages were already in A correct drain order would keep the sub in 2. Sequential drain contradicts the "parallel" documentationThe PR description says:
But foreach (var subRef in subRefs)
await sub.DisposeAsync().ConfigureAwait(false); // sequential – N subs = N × RTTOnly 3.
|
|
Claude finished @mtmk's task in 8m 27s —— View job PR #1085 Review: Fix message loss on consumer dispose
SummaryAll four issues from the previous review are addressed. The routing-before-PING fix ( Issues1.
|
|
Claude finished @mtmk's task in 7m 51s —— View job PR #1085 Review: Fix message loss on consumer dispose
SummaryAll six issues from the two previous reviews are correctly addressed. The drain ordering ( One new issue and one smaller observation below. Issues1.
|
| @@ -220,6 +220,7 @@ public override async ValueTask DisposeAsync() | |||
| Interlocked.Exchange(ref _disposed, 1); | |||
There was a problem hiding this comment.
The int _disposed field is now dead code — Interlocked.Exchange is called but the return value is discarded and _disposed is never read. Its original use in ReceiveInternalAsync (if (Volatile.Read(ref _disposed) == 0)) was correctly removed by this PR. Idempotency is provided by NatsSubBase._disposed (in base.DisposeAsync()). Either drop the field or guard on the return value:
| Interlocked.Exchange(ref _disposed, 1); | |
| if (Interlocked.Exchange(ref _disposed, 1) != 0) | |
| { | |
| await base.DisposeAsync().ConfigureAwait(false); | |
| return; | |
| } |
| @@ -165,6 +165,7 @@ public override async ValueTask DisposeAsync() | |||
| Interlocked.Exchange(ref _disposed, 1); | |||
There was a problem hiding this comment.
Same dead-code pattern as NatsJSConsume — _disposed is written but never read. Consider adding the early-exit guard or removing the field:
| Interlocked.Exchange(ref _disposed, 1); | |
| if (Interlocked.Exchange(ref _disposed, 1) != 0) | |
| { | |
| await base.DisposeAsync().ConfigureAwait(false); | |
| return; | |
| } |
|
@claude fixed your comments, review again |
|
Claude finished @mtmk's task in 3m 40s —— View job PR #1085 Review: Fix message loss on consumer dispose
SummaryAll issues from the three previous review rounds are correctly addressed. The latest commit ( The complete drain ordering is now correct end-to-end:
No new issues found. The PR is in good shape. Smaller observations (no action required)
|
JetStream consumer dispose was dropping in-flight messages. The per-sub path completed the channel before all wire messages had landed, and connection dispose killed the socket before subs got a chance to drain, leaving NumAckPending pinned until AckWait. NatsSubBase now has a DrainAsync (UNSUB -> PING/PONG -> TryComplete) that per-sub dispose calls to flush the wire cleanly. On connection dispose, drain participants run in parallel so the wait is one round-trip total, not multiplied by the number of subs.
New NatsOpts:
DrainSubscriptionsOnDispose(defaultfalse) - opt-in for the dispose reorder.ConsumerDrainOnDisposeTimeout(defaultnull) - wait for active consume loops to finish acking.DrainPingTimeout(default5s) - per-sub UNSUB ack budget.Defaults preserve current behavior.