Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
fe97e06
[perf, fix] Pool and reuse ZMQ request sockets instead of one per req…
OutstanderWang Aug 10, 2026
b3635fa
[fix] Keep supporting a standalone zmq_context in StorageManager
OutstanderWang Aug 10, 2026
e48611d
[fix] Key pooled sockets by endpoint and reject pool sizes below 1
OutstanderWang Aug 10, 2026
f1d3026
[fix] Discard late socket returns from a superseded endpoint
OutstanderWang Aug 10, 2026
7f7170f
[fix] Retire superseded endpoints in every pool owner, and stop leaki…
OutstanderWang Aug 10, 2026
5b7b644
[refactor] Give each request scenario its own socket pool
OutstanderWang Aug 10, 2026
09a57e1
[fix] Retire only the moved peer's own former address
OutstanderWang Sep 7, 2026
3504edb
[fix] Do not park a socket the lease body already closed
OutstanderWang Sep 7, 2026
0801c74
[fix] Keep close() working when the base constructor raised
OutstanderWang Sep 7, 2026
947beac
[fix] Let close() tolerate a subclass that raised before super().__in…
OutstanderWang Sep 7, 2026
b59bf68
[refactor] Drop endpoint-migration tracking, which nothing can reach
OutstanderWang Sep 7, 2026
341b8ec
[refactor] Borrow the controller's ZMQ context in the metrics exporter
OutstanderWang Sep 7, 2026
e6c48e1
[perf] Raise the default pooled-socket cap from 8 to 64
OutstanderWang Sep 7, 2026
2f2469c
[fix] Narrow max_sockets by identity rather than by a parallel flag
OutstanderWang Sep 7, 2026
71736a6
[fix] Scope the pooled-socket knob to the pool it configures
OutstanderWang Sep 7, 2026
2c9e45f
[docs] Show the async client awaiting on one loop, not asyncio.run pe…
OutstanderWang Sep 7, 2026
505466a
[test] Assert socket reuse from the peer, not from the pool's internals
OutstanderWang Sep 7, 2026
6587f65
[test] Assert each role's socket reuse where that role is tested
OutstanderWang Sep 8, 2026
6db3a70
[test, docs] Make the poisoned-lease test real, and trim example noise
OutstanderWang Sep 9, 2026
365c3ff
[refactor] Trim the comments this branch added
OutstanderWang Sep 9, 2026
9f9f847
[feat] Size every role's socket pool from TQ_SOCKET_POOL_SIZE
OutstanderWang Sep 9, 2026
96e218a
[fix] Default the socket pool to 8 and warn when it can exhaust the c…
OutstanderWang Sep 9, 2026
ab3e45c
[fix] Stop the metrics collector accumulating one socket per storage …
OutstanderWang Sep 9, 2026
b6ec5ef
[perf, fix] Wait for a pooled socket instead of opening one past the cap
OutstanderWang Sep 9, 2026
de7a96f
[fix] Raise the controller context's socket ceiling above libzmq's de…
OutstanderWang Sep 9, 2026
5e7cc3e
[fix] Lower the default socket pool size to 4
OutstanderWang Sep 9, 2026
4b75fba
[refactor] Trim the comments added since the last pass
OutstanderWang Sep 9, 2026
56f2e7d
[fix] Keep the storage identity allowlist through the rebase
OutstanderWang Sep 10, 2026
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
24 changes: 24 additions & 0 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ def __init__(self, controller_id="controller_0"):
)

self.running = True
self.caller_identities: set[bytes] = set()
self.request_thread = Thread(target=self._handle_requests, daemon=True)
self.request_thread.start()

Expand All @@ -85,6 +86,9 @@ def _handle_requests(self):
if self.request_socket in socks:
messages = self.request_socket.recv_multipart(copy=False)
identity = messages.pop(0)
# Each pooled socket dials with its own identity, so this records how
# many distinct connections the client opened.
self.caller_identities.add(bytes(identity))
serialized_msg = messages
request_msg = ZMQMessage.deserialize(serialized_msg)

Expand Down Expand Up @@ -1330,6 +1334,26 @@ def test_kv_retrieve_keys_type_validation(self, client_setup):
)


# =====================================================
# Controller RPC Socket Pool Tests
# =====================================================


def test_controller_rpc_reuses_one_socket(client_setup):
"""Repeated calls must share a single connection to the controller.

This is the client-side payoff of the socket pool: TransferQueueClient runs every call
on the one event loop it builds in __init__, so all of them land in the same pool
bucket. Counted from the controller, which sees one ZMQ identity per socket dialled.
"""
client, mock_controller, _ = client_setup

for _ in range(5):
assert client.get_partition_list() == ["partition_0", "partition_1", "test_partition"]

assert len(mock_controller.caller_identities) == 1, "each request opened its own socket"


# =====================================================
# Checkpoint Interface Tests
# =====================================================
Expand Down
23 changes: 22 additions & 1 deletion tests/test_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
import torch
import zmq

from transfer_queue.controller import TransferQueueController
from transfer_queue.controller import TQ_CONTROLLER_ZMQ_MAX_SOCKETS, TransferQueueController
from transfer_queue.utils.zmq_utils import ZMQMessage, ZMQRequestType, create_zmq_socket

# Set up logging
Expand Down Expand Up @@ -1465,6 +1465,27 @@ def test_controller_checkpoint_load_nonexistent_file(self, ray_setup, tmp_path):
print("✓ load_checkpoint raises on missing file")


class TestControllerSocketBudget:
"""The controller's context must hold more sockets than libzmq's default 1023.

The metrics exporter borrows this context to query storage units, so the budget has to
cover the fleet rather than just the controller's own two ROUTERs. Past the ceiling a
socket cannot be opened at all, which would take out the control plane too.
"""

def test_context_ceiling_is_raised_above_the_libzmq_default(self, ray_setup):
controller = TransferQueueController.remote()
try:
# Read it from inside the actor: the ceiling is only meaningful on the context
# the controller actually built.
budget = ray.get(controller.__ray_call__.remote(lambda self: self.zmq_context.get(zmq.MAX_SOCKETS)))
finally:
ray.kill(controller)

assert budget > 1023, "the controller kept libzmq's default socket ceiling"
assert budget == TQ_CONTROLLER_ZMQ_MAX_SOCKETS


class TestTransferQueueControllerBadRequests:
"""The request loop must survive requests it cannot decode, does not handle, or fails on.

Expand Down
22 changes: 22 additions & 0 deletions tests/test_kv_storage_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,28 @@
from transfer_queue.storage.managers.base import KVStorageManager


def test_close_is_quiet_when_config_is_rejected_before_super_init():
"""A config rejected before super().__init__() leaves nothing for close() to release.

Every KV manager validates its config first, so none of the base attributes close()
reaches for exist yet. __del__ calls close() regardless, and an AttributeError there
buries the real ValueError under a spurious teardown error.
"""
built = []

class Probe(KVStorageManager):
def __init__(self, *args, **kwargs):
built.append(self)
super().__init__(*args, **kwargs)

with pytest.raises(ValueError, match="Missing client_name"):
Probe(None, {})

manager = built[0]
assert not hasattr(manager, "notify_pool"), "the test no longer exercises the pre-super path"
manager.close()


def get_meta(data, global_indexes=None):
if not global_indexes:
global_indexes = list(range(data.batch_size[0]))
Expand Down
78 changes: 77 additions & 1 deletion tests/test_metrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,17 @@
"""Unit tests for the Prometheus metrics exporter (transfer_queue.metrics)."""

import time
from unittest.mock import MagicMock
from threading import Thread
from unittest.mock import MagicMock, patch

import pytest

try:
import zmq

from transfer_queue.metrics import TQMetricsExporter
from transfer_queue.utils.enum_utils import Role
from transfer_queue.utils.zmq_utils import ZMQMessage, ZMQRequestType, ZMQServerInfo

_HAS_DEPS = True
except (ImportError, OSError):
Expand Down Expand Up @@ -207,6 +212,77 @@ def test_multiple_ops_tracked_independently(self):
# ---------------------------------------------------------------------------


class TestStorageQuerySocketPool:
def test_pool_borrows_the_owner_context(self):
"""The exporter must query storage units over the context it was handed.

The controller already holds a long-lived synchronous context. A second one would
add another native I/O thread and leave a context nobody closes, since the exporter
lives for the whole life of its Ray actor.
"""
ctx = zmq.Context()
try:
exporter = TQMetricsExporter(zmq_context=ctx)
with patch("zmq.Context") as minted:
exporter._get_socket_pool()
minted.assert_not_called()
finally:
ctx.destroy(linger=0)

def test_missing_context_is_reported(self):
"""Without a context there is nothing to query over, so say so rather than mint one."""
exporter = TQMetricsExporter()
with pytest.raises(RuntimeError, match="without a ZMQ context"):
exporter._get_socket_pool()

def test_collector_parks_no_socket_between_queries(self):
"""A queried unit must leave nothing in the pool.

Collection walks every unit once per cycle, so a parked socket is reused only a
cycle later while holding a slot in the controller context's budget for the whole
walk. At a few thousand units that budget is what runs out first.
"""
identities: set[bytes] = set()
ctx_peer = zmq.Context()
router = ctx_peer.socket(zmq.ROUTER)
port = router.bind_to_random_port("tcp://127.0.0.1")
running = True

def serve():
poller = zmq.Poller()
poller.register(router, zmq.POLLIN)
while running:
if not dict(poller.poll(50)):
continue
identity, _ = router.recv_multipart()
identities.add(bytes(identity))
response = ZMQMessage.create(
request_type=ZMQRequestType.METRICS_RESPONSE,
sender_id="storage_0",
body={},
)
router.send_multipart([identity, *response.serialize()])

server = Thread(target=serve, daemon=True)
server.start()

su_info = ZMQServerInfo(role=Role.STORAGE, id="storage_0", ip="127.0.0.1", ports={"put_get_socket": port})
ctx = zmq.Context()
try:
exporter = TQMetricsExporter(zmq_context=ctx)
for _ in range(3):
assert exporter._query_storage_unit(su_info, "storage_0") == {}
# A parked socket would be reused, so a fresh identity per query is the
# externally visible proof that nothing was kept.
assert len(identities) == 3, "a queried unit left its socket in the pool"
finally:
running = False
server.join(timeout=2.0)
ctx.destroy(linger=0)
router.close(linger=0)
ctx_peer.term()


class TestStorageMetricsCollection:
def test_collect_with_no_storage_units(self):
"""No storage units registered — collect should be a no-op."""
Expand Down
Loading
Loading