-
Notifications
You must be signed in to change notification settings - Fork 263
Request idle heartbeats for ordered consumers #1005
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,7 @@ | ||
| from __future__ import annotations | ||
|
|
||
| import asyncio | ||
| import contextlib | ||
| import json | ||
| import logging | ||
| import time | ||
|
|
@@ -31,6 +32,10 @@ | |
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
| # Consecutive missed heartbeat windows before a stream is considered dead. One | ||
| # miss can be a lost batch; a second means nothing is answering. | ||
| _MAX_MISSED_HEARTBEATS = 2 | ||
|
|
||
|
|
||
| def _message_size(msg: ClientMessage) -> int: | ||
| """Calculate the size of a message per ADR-37. | ||
|
|
@@ -233,6 +238,7 @@ class PullMessageStream(MessageStream): | |
| _heartbeat_deadline: float | None | ||
| _heartbeat_paused: bool | ||
| _heartbeat_remaining: float | None | ||
| _missed_heartbeats: int | ||
|
|
||
| def __init__( | ||
| self, | ||
|
|
@@ -275,6 +281,7 @@ def __init__( | |
| 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 | ||
|
|
||
| # Register disconnect/reconnect callbacks for heartbeat timer (ADR-37) | ||
| if heartbeat is not None: | ||
|
|
@@ -326,6 +333,7 @@ async def __anext__(self) -> Message: | |
| # Reset heartbeat timer on any message (ADR-37) | ||
| if self._heartbeat is not None: | ||
| self._heartbeat_deadline = time.time() + (self._heartbeat * 2) | ||
| self._missed_heartbeats = 0 | ||
|
|
||
| # Handle status messages | ||
| if raw_msg.status is not None: | ||
|
|
@@ -455,12 +463,29 @@ async def _heartbeat_monitor(self): | |
|
|
||
| # Check if heartbeat timeout has been reached (2x idle_heartbeat) | ||
| if self._heartbeat_deadline is not None and time.time() > self._heartbeat_deadline: | ||
| self._missed_heartbeats += 1 | ||
| logger.warning( | ||
| "Heartbeat timeout: no message received within %.2fs (2x idle_heartbeat of %.2fs)", | ||
| "Heartbeat timeout %d: no message received within %.2fs (2x idle_heartbeat of %.2fs)", | ||
| self._missed_heartbeats, | ||
| self._heartbeat * 2, | ||
| self._heartbeat, | ||
| ) | ||
| # Reset pending counts and request more messages (non-terminal) | ||
|
|
||
| if self._missed_heartbeats >= _MAX_MISSED_HEARTBEATS: | ||
| # The consumer is unreachable -- it may have been deleted | ||
| # or lost with the server it lived on. End the stream so | ||
| # the caller sees it; an ordered consumer takes this as | ||
| # its cue to reset and recreate. Unsubscribing wakes a | ||
| # pending __anext__, which finishes cleanup; this task | ||
| # must not run _cleanup itself because that would cancel | ||
| # 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. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. When the terminal path fires here,
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Confirmed idempotent, so no change needed. if not self._closed:
await self._client._unsubscribe(self._sid)
self._pending_queue.shutdown(immediate=True)
...
self._closed = TrueThe second call from |
||
| return | ||
|
|
||
| # First miss: the batch may simply have been lost, so reset | ||
| # the pending counts and ask again before giving up. | ||
| self._pending_messages = 0 | ||
| self._pending_bytes = 0 | ||
| await self._send_request() | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1084,6 +1084,34 @@ async def test_consumer_info_timestamp(jetstream: JetStream): | |
| assert info.timestamp.tzinfo is not None | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The premise is off — 33 of the 34 async tests in this file carry There was a real bug underneath it though. Inserting this test above Fixed in 4403aeb: the marker is back on |
||
| async def test_messages_end_after_repeated_missed_heartbeats(jetstream: JetStream): | ||
| """A stream whose consumer stops answering ends instead of waiting forever. | ||
|
|
||
| One missed heartbeat window can be a lost batch, so the stream asks again. | ||
| A second means nothing is answering, and the iterator has to end -- an | ||
| ordered consumer takes that as its cue to reset. | ||
| """ | ||
| stream = await jetstream.create_stream(name="hb_miss", subjects=["HBM.*"]) | ||
| consumer = await stream.create_consumer(name="hb_miss", durable_name="hb_miss", ack_policy="explicit") | ||
|
|
||
| messages = await consumer.messages(heartbeat=0.2, max_wait=5.0) | ||
| pending = asyncio.create_task(messages.__anext__()) | ||
| await asyncio.sleep(0.1) | ||
|
|
||
| # Simulate the consumer going silent while the connection stays up: stop the | ||
| # request loop and neuter the retry, so both heartbeat windows lapse unanswered. | ||
| async def _silent() -> None: | ||
| return None | ||
|
|
||
| if messages._request_task is not None: | ||
| messages._request_task.cancel() | ||
| messages._send_request = _silent # type: ignore[method-assign] | ||
|
|
||
| with pytest.raises(StopAsyncIteration): | ||
| await asyncio.wait_for(pending, timeout=10) | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_consumer_reset_to_seq(jetstream: JetStream): | ||
| """Reset a consumer forward to a specific stream sequence (ADR-60). | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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 freshPullMessageStreamand 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 setsheartbeat.Consider deregistering in
_cleanup():(Worth tracking as a follow-up if
nats-coredoesn't currently expose remove variants.)There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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 bothPullMessageBatchandPullMessageStreamand calls it from_cleanup(). Two PRs editing the same_cleanup()would only conflict.remove_disconnected_callback/remove_reconnected_callbackdo exist in nats-core (client/__init__.py:1553and: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.