Fix PullSubscription.fetch hang due to orphan lingering request - #934
Conversation
This fixes an issue in the nats-py code where a race condition occurs that leaves an orphaned lingering request on the server, causing `fetch` to hang until the timeout expires. fixes nats-io#933
|
Claude finished @phemmer's task in 4m 20s —— View job PR Review: Fix PullSubscription.fetch hang due to orphan lingering request
ReviewThe fix is correct and the root cause analysis is accurate. The lingering pull request was being published with the original full
|
| raise asyncio.TimeoutError | ||
| next_req["expires"] = remaining_expires | ||
| elif expires: | ||
| next_req["expires"] = expires |
There was a problem hiding this comment.
The elif expires: fallback is only reachable when timeout is None (since deadline is None iff timeout is None). When timeout is None, expires is also None, so neither branch sets next_req["expires"] — which is correct.
However, _fetch_one (batch=1) has the same class of orphan bug and is not fixed here. In _fetch_one, the single lingering request is published with the original expires (computed from the full timeout at the call site), and start_time is measured only after that publish. If the Python asyncio timer fires before the server's 408 arrives, the lingering request is abandoned on the server with up to timeout_ns - 100_000 ns remaining. The probability is lower than in _fetch_n (no no-wait probe to consume time first), but the race is real.
|
Added another fix. Completely separate issue, but related, so throwing it in this same PR. ab04fa6 Also one thing that might be worth discussing: I preserved the original behavior in that if messages are sitting in |
825011c to
efe51d7
Compare
When _fetch_n drains messages from _pending_queue at the start of a fetch, it then sends a no_wait probe to collect more from the server. The probe includes an expires field, which causes NATS server ≥ 2.10 to ignore no_wait and treat the request as a regular lingering pull. If the stream has no further messages available (they were already delivered into the queue before fetch() ran), the probe blocks for the full expires duration before returning — stalling the caller even though the drained messages are ready to return immediately. This fixes the issue by omitting expires from the no_wait probe when the drain step already collected messages. Without expires, the server correctly honors no_wait and responds immediately with any available messages or a 404. When the drain step found nothing, expires is still included so that the existing server-side lingering behaviour is preserved.
efe51d7 to
ab04fa6
Compare
| # timeout window. | ||
| deadline = JetStreamContext._time_until(timeout, start_time) | ||
| if deadline is not None and deadline <= 0: | ||
| raise asyncio.TimeoutError |
There was a problem hiding this comment.
This might also be worth discussing. This and line 1296 below raise asyncio.TimeoutError. This is consistent with line 1234 above. However I question whether this is the right behavior. There's also FetchTimeoutError. So the current code is consistent, but is it consistently wrong? Should it be FetchTimeoutError? And fix line 1234 as well?
There was a problem hiding this comment.
FYI we just upgraded to 2.15.0 in production and we're hitting many asyncio.TimeoutError that are uncaught because we expect nats.errors.TimeoutError instead. We have to revert to 2.14.0 because of this since we can't update all our applications to catch this new Exception.
|
Merged, looks good. Thank you very much! |
This fixes an issue in the nats-py code where a race condition occurs that leaves an orphaned lingering request on the server, causing
fetchto hang until the timeout expires.fixes #933