Skip to content

Drop abandoned requests when draining the ZeroMQ send queue (#68660) - #70283

Open
bretep wants to merge 1 commit into
saltstack:3006.xfrom
bretep:fix/68660-3006x
Open

bretep wants to merge 1 commit into
saltstack:3006.xfrom
bretep:fix/68660-3006x

Conversation

@bretep

@bretep bretep commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

Skips requests whose caller future is already done when draining
AsyncReqMessageClient._queue, instead of sending them.

What issues does this PR fix or reference?

Fixes #68660

Note: #68660 is currently closed, but the fix is not present on any
supported branch. I verified 3006.x, 3007.x, 3008.x and master — none
contain a future.done() check in the drain loop, and the regression commit
145a06e ("Refactor send recv to avoid race conditions and limit memory usage")
is an ancestor of all four. Requesting reopen.

Previous Behavior

send() enqueues (future, message) and arms _timeout_message(), which
completes the future when the caller's timeout expires. The caller is released,
but its queue entry is not removed — it keeps pinning its serialized payload
until the drain loop reaches it.

Before 145a06e, in-flight requests lived in self._send_future_map, a dict, so
a timed-out entry was removed by key in O(1). Queue has no
removal-from-middle and self._queue has no maxsize, so an abandoned entry
becomes unreachable garbage that still occupies the queue.

A REQ socket permits one request/reply in flight, each drain iteration polls
for up to 300 ms, and _send_recv() restarts from scratch on every reconnect.
Under sustained load the enqueue rate outruns the drain rate and the queue
grows without bound.

Observed on a 120-minion master: one process at ~53 GiB, OOM-killed roughly
fortnightly. The original reporter measured the same growth from 3006.16 and
confirmed 3006.15/3006.14 were unaffected.

New Behavior

A request whose future is already done is dropped — the reply can no longer be
delivered to anyone, so there is no reason to spend a round trip on it.

Tests written?

Yes. test_client_send_recv_drops_abandoned_request asserts the abandoned
payload never reaches socket.send. Verified it fails without the fix
(AssertionError: Expected 'send' to not have been called. Called 1 times.)
and passes with it.

Two existing tests queued a single already-done future and relied on it
reaching the send path to terminate the drain loop. With the entry now dropped
they fall through to the idle poll branch, which calls .result() on an
AsyncMock coroutine. Both now queue a _REQ_QUEUE_SHUTDOWN sentinel so the
loop exits.

Worth reviewer attention: this makes the #68506 repro structurally unreachable
on this path — a timed-out future never reaches socket.send — so
test_client_send_recv_no_double_set_exception_after_timeout now asserts only
its invariant (the original timeout exception survives untouched) rather than
exercising the double-set path. The if not future.done() guards in the
exception handlers are untouched and still protect the remaining paths.

Full tests/pytests/unit/transport/test_zeromq.py: 35 passed, 2 skipped, 0
failed. pre-commit clean on the changed files; the one lint-salt finding
(E0712 at ZeroMQSocketMonitor.stop) is pre-existing and byte-identical on
unmodified 3006.x.

Commits signed with GPG?

No

`AsyncReqMessageClient.send()` enqueues `(future, message)` on `self._queue`
and arms `_timeout_message()` to complete the future when the caller's timeout
expires. When that fires the caller is released, but the queue entry is not
removed -- it keeps pinning its serialized payload until the drain loop
happens to reach it.

Before 145a06e in-flight requests were tracked in `self._send_future_map`, a
dict, so a timed-out entry could be removed by key. `Queue` has no
removal-from-middle and `self._queue` has no maxsize, so an abandoned entry
becomes unreachable garbage that still occupies the queue.

A REQ socket permits one request/reply in flight, each drain iteration polls
for up to 300ms, and `_send_recv()` restarts from scratch on every reconnect.
Under sustained load the enqueue rate outruns the drain rate and the queue
grows without bound. Observed on a 120-minion master: one process at ~53 GiB,
OOM-killed roughly fortnightly.

Skip requests whose future is already done: the reply can no longer be
delivered to anyone, so there is no reason to spend a round trip on it.

Two existing tests queued a single already-done future and relied on it
reaching the send path to terminate the loop. With the entry now dropped they
fall through to the idle poll branch, which calls `.result()` on the
AsyncMock's coroutine. Both now queue a shutdown sentinel so the loop exits.
Note this makes the saltstack#68506 repro structurally unreachable on this path -- a
timed-out future never reaches `socket.send` -- so that test now asserts only
its invariant, that the original timeout exception survives untouched.

Fixes saltstack#68660

This branch was successfully deployed

1 active deployment
ci — 22fb248f Deployed Sep 23, 2026 by twangboy via Build Onedir Packages / macOS (arm64) #27219
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

test:full Run the full test suite

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants