Request idle heartbeats for ordered consumers - #1005
Conversation
An ordered consumer recovers by resetting when its inner message stream ends. Nothing ended that stream when the consumer stopped answering: messages() left the heartbeat unset, so no monitor ran and no heartbeats were requested from the server, and the iterator waited forever. Default the heartbeat, holding it under the request expiry the server enforces. The monitor could not have ended the stream anyway -- a missed window only reset the pending counts and asked again, which loops indefinitely against a consumer that is gone. Keep that retry for the first miss, where a batch may simply have been lost, and treat a second consecutive miss as terminal. This stalled the Windows CI job for hours at a time: after a restart the consumer no longer exists, so pull requests go unanswered and the test iterating messages never returns. Bound that test's wait as well, so a regression fails rather than hanging the run.
|
Claude finished @caspervonb's task in 4m 10s —— View job PR Review
The fix is correct and well-motivated. The root cause analysis is accurate: Logic / correctness The two-strike approach ( The The One medium issue: the disconnect/reconnect callbacks registered in One minor issue: the terminal path calls Tests cover the key cases well: auto-heartbeat computation across the |
| self._heartbeat_task: asyncio.Task | None = None | ||
| self._started = False | ||
| self._heartbeat_deadline = time.time() + (heartbeat * 2) if heartbeat is not None else None | ||
| self._missed_heartbeats = 0 |
There was a problem hiding this comment.
The disconnect/reconnect callbacks registered just below this (in the same __init__ block) are never removed in _cleanup(). For ordered consumers, each inner stream reset creates a fresh PullMessageStream and registers new callbacks, while the old (now-dead) ones accumulate on the client. Since this PR makes heartbeats mandatory for ordered consumers, the leak now occurs on every reset rather than only when the caller explicitly sets heartbeat.
Consider deregistering in _cleanup():
client.remove_disconnected_callback(self._pause_heartbeat_timer)
client.remove_reconnected_callback(self._resume_heartbeat_timer)(Worth tracking as a follow-up if nats-core doesn't currently expose remove variants.)
There was a problem hiding this comment.
Confirmed, and the amplification you describe is right — this PR turns it from opt-in to every inner-stream reset.
Not fixing it here though: #963 ("Deregister pull consumer heartbeat callbacks on stop") already adds _deregister_callbacks() to both PullMessageBatch and PullMessageStream and calls it from _cleanup(). Two PRs editing the same _cleanup() would only conflict.
remove_disconnected_callback / remove_reconnected_callback do exist in nats-core (client/__init__.py:1553 and :1571), so that fix is sound as written. The terminal path added here still reaches _cleanup() via __anext__, so it will be covered once #963 lands.
The practical upshot is that #963 matters more after this PR than before it.
| # and await the task it is running on. | ||
| self._terminated = True | ||
| with contextlib.suppress(Exception): | ||
| await self._subscription.unsubscribe() |
There was a problem hiding this comment.
When the terminal path fires here, unsubscribe() is called to wake the pending __anext__. That __anext__'s exception handler then calls _cleanup(), which calls unsubscribe() a second time (line 523). This is likely benign if unsubscribe() is idempotent, but any error from the second call in _cleanup() is not suppressed — worth confirming.
There was a problem hiding this comment.
Confirmed idempotent, so no change needed. Subscription.unsubscribe() is guarded (nats-core/src/nats/client/subscription.py:294):
if not self._closed:
await self._client._unsubscribe(self._sid)
self._pending_queue.shutdown(immediate=True)
...
self._closed = TrueThe second call from _cleanup() is a no-op that cannot raise, so there is nothing for the unsuppressed path to surface.
| @@ -1085,6 +1085,33 @@ async def test_consumer_info_timestamp(jetstream: JetStream): | |||
|
|
|||
|
|
|||
| @pytest.mark.asyncio | |||
There was a problem hiding this comment.
@pytest.mark.asyncio is redundant — nats-jetstream uses asyncio_mode = "auto". Other tests in this file don't carry it. Nit only.
There was a problem hiding this comment.
The premise is off — 33 of the 34 async tests in this file carry @pytest.mark.asyncio, so it is the convention here rather than the exception.
There was a real bug underneath it though. Inserting this test above test_consumer_reset_to_seq took over that test's decorator, leaving it without one and pulling an unrelated test into the diff. Harmless under asyncio_mode = "auto", but wrong.
Fixed in 4403aeb: the marker is back on test_consumer_reset_to_seq, the count is 34/34, and the diff to this file is now purely additive.
Inserting the heartbeat test above it took over its decorator, leaving the reset test without one. Harmless under asyncio_mode = auto, but it put an unrelated test in the diff.
|
PTAL @claude |
|
Claude finished @caspervonb's task in 3m 22s —— View job PR Review
The fix is correct and ready to merge. Root cause, implementation, and test coverage are all sound. Addressing each aspect in turn:
|
An ordered consumer recovers by resetting when its inner message stream ends (
OrderedMessageStream.__anext__). Nothing ended that stream when the consumer stopped answering:messages()left the heartbeat unset, so no monitor ran and noidle_heartbeatwas requested from the server, and the iterator waited forever.The monitor could not have ended it anyway — a missed window only reset the pending counts and asked again, which loops indefinitely against a consumer that no longer exists. That retry is kept for the first miss, where a batch may simply have been lost; a second consecutive miss is now terminal.
This is what has been stalling the
nats-jetstreamWindows job for hours since the job was added in #998.test_ordered_consumer_messages_server_restartrestarts the server, after which the consumer (memory storage) is gone; its pull requests go unanswered and the iterator never returns. That test now bounds its wait too, so a regression fails instead of hanging the run.Not reproducible on Linux or macOS, where the reconnect happens to produce a reply that ends the inner stream. Verified by checking the new tests fail against
main.