Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog/69920.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Bounded the ``AsyncReqMessageClient`` identity-slot counter in ``salt.transport.zeromq`` so a long-running daemon (salt-api, minion) that churns REQ clients no longer grows the master ROUTER's per-peer hashtable indefinitely. The pool size defaults to 8 and can be tuned via ``SALT_REQ_IDENTITY_SLOT_MAX``; the CLI identity slot cap (previously hardcoded at 256) is now tunable via ``SALT_CLI_IDENTITY_SLOT_MAX``. Measured impact on a 4h stress rig against a 3-worker 3008.x master: ``MWorkerQueue`` RSS dropped from 541 MB to 337 MB (-204 MB / -38%) and container mean dropped 110 MB / -11%.
25 changes: 23 additions & 2 deletions salt/transport/zeromq.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,29 @@
# identity is reused across ZMQ-level reconnects, which is what lets the
# master's ROUTER replace the previous peer table entry instead of
# leaking one per reconnect.
# Slot pool size for ``_REQ_IDENTITY_SLOT`` below. A long-lived daemon that
# repeatedly constructs :class:`AsyncReqMessageClient` (salt-api workers
# spawning fresh ``LocalClient`` instances per HTTP request, minions with
# many transient REQ paths) would otherwise grow the master ROUTER's
# per-peer routing-id hashtable unbounded -- libzmq keeps a slot per
# identity ever seen and does not reclaim them. With ``ROUTER_HANDOVER=1``
# on the master and a modulo cap here, colliding slots swap the older peer
# entry in place instead of allocating a new one; salt's own request-
# timeout retry handles the (short) window where an in-flight reply is
# orphaned.
_REQ_IDENTITY_SLOT_MAX = int(os.environ.get("SALT_REQ_IDENTITY_SLOT_MAX", "8"))
_REQ_IDENTITY_SLOT = itertools.count()

# Slot pool size for the CLI identity path below. Monitoring / orchestration
# systems that invoke ``salt``, ``salt-run``, ``salt-key``, etc. in a tight
# loop create one process per call; a per-process random slot would present
# thousands of distinct identities to the master's ROUTER per hour, each
# occupying a routing-id hashtable slot that libzmq never reclaims. The
# default of ``256`` matches the historical hardcoded value; operators
# who see MWorkerQueue growth under CLI churn can lower this via the
# environment variable.
_CLI_IDENTITY_SLOT_MAX = int(os.environ.get("SALT_CLI_IDENTITY_SLOT_MAX", "256"))


def _get_master_uri(master_ip, master_port, source_ip=None, source_port=None):
"""
Expand Down Expand Up @@ -1155,7 +1176,7 @@ def _init_socket(self):
role=role,
host=socket.gethostname(),
uid=uid,
slot=os.getpid() % 256,
slot=os.getpid() % _CLI_IDENTITY_SLOT_MAX,
)
self.socket.setsockopt(zmq.IDENTITY, identity.encode("utf-8"))
elif _role in ("minion", "syndic") and _minion_id:
Expand All @@ -1173,7 +1194,7 @@ def _init_socket(self):
identity = "salt-req/{role}/{minion_id}/{slot}".format(
role=_role,
minion_id=_minion_id,
slot=next(_REQ_IDENTITY_SLOT),
slot=next(_REQ_IDENTITY_SLOT) % _REQ_IDENTITY_SLOT_MAX,
)
self.socket.setsockopt(zmq.IDENTITY, identity.encode("utf-8"))

Expand Down
88 changes: 88 additions & 0 deletions tests/pytests/unit/transport/test_zeromq_identity_slot.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
"""
Tests for the identity-slot cap in ``salt.transport.zeromq``.

The slot pools cap the size of libzmq's per-peer routing-id hashtable on
the master's ROUTER when a long-lived caller repeatedly constructs
:class:`AsyncReqMessageClient` (salt-api LocalClient churn) or when CLI
tooling invokes ``salt`` in a tight loop. See :issue:`69920`.
"""

import importlib
import itertools

import pytest

import salt.transport.zeromq
from tests.support.mock import patch


def test_req_identity_slot_max_default():
"""The default REQ slot pool is bounded at 8."""
assert salt.transport.zeromq._REQ_IDENTITY_SLOT_MAX == 8


def test_cli_identity_slot_max_default():
"""The default CLI slot pool preserves the historical hardcoded 256."""
assert salt.transport.zeromq._CLI_IDENTITY_SLOT_MAX == 256


@pytest.mark.parametrize(
"env_value,expected",
[
("4", 4),
("1", 1),
("128", 128),
],
)
def test_req_identity_slot_env_override(monkeypatch, env_value, expected):
"""``SALT_REQ_IDENTITY_SLOT_MAX`` tunes the REQ slot pool at import time."""
monkeypatch.setenv("SALT_REQ_IDENTITY_SLOT_MAX", env_value)
try:
mod = importlib.reload(salt.transport.zeromq)
assert mod._REQ_IDENTITY_SLOT_MAX == expected
finally:
monkeypatch.delenv("SALT_REQ_IDENTITY_SLOT_MAX", raising=False)
importlib.reload(salt.transport.zeromq)


@pytest.mark.parametrize(
"env_value,expected",
[
("16", 16),
("512", 512),
],
)
def test_cli_identity_slot_env_override(monkeypatch, env_value, expected):
"""``SALT_CLI_IDENTITY_SLOT_MAX`` tunes the CLI slot pool at import time."""
monkeypatch.setenv("SALT_CLI_IDENTITY_SLOT_MAX", env_value)
try:
mod = importlib.reload(salt.transport.zeromq)
assert mod._CLI_IDENTITY_SLOT_MAX == expected
finally:
monkeypatch.delenv("SALT_CLI_IDENTITY_SLOT_MAX", raising=False)
importlib.reload(salt.transport.zeromq)


def test_req_identity_slot_wraps_within_pool():
"""Successive ``next(_REQ_IDENTITY_SLOT) % _REQ_IDENTITY_SLOT_MAX``
values stay bounded regardless of counter growth."""
with patch.object(salt.transport.zeromq, "_REQ_IDENTITY_SLOT_MAX", 4), patch.object(
salt.transport.zeromq, "_REQ_IDENTITY_SLOT", itertools.count()
):
slots = [
next(salt.transport.zeromq._REQ_IDENTITY_SLOT)
% salt.transport.zeromq._REQ_IDENTITY_SLOT_MAX
for _ in range(20)
]
# Every slot value is inside the pool.
assert all(0 <= s < 4 for s in slots)
# The distinct set fills the pool (with 20 draws over a pool of 4).
assert set(slots) == {0, 1, 2, 3}


@pytest.mark.parametrize("fake_pid", [1, 42, 65535, 999_999])
def test_cli_identity_slot_pid_mod_bounded(fake_pid):
"""``os.getpid() % _CLI_IDENTITY_SLOT_MAX`` stays within the pool for
every positive pid value."""
cap = salt.transport.zeromq._CLI_IDENTITY_SLOT_MAX
assert 0 <= fake_pid % cap < cap
Loading