Skip to content

Commit 46ee27b

Browse files
[fix,refactor] Isolate notify_data_update ZMQ I/O into a dedicated background asyncio loop (#117)
Root cause: `notify_data_update` ran on the caller's asyncio loop. Under heavy compute workloads (PyTorch, Ray), the loop would stall, causing `asyncio.wait_for` timers to fire before the controller ACK was ever read. Changes: - Spin up a dedicated daemon thread (`_notify_thread`) running an isolated `_notify_loop` per StorageManager instance, keeping notify I/O immune to caller-side event loop starvation - Bridge calls via `run_coroutine_threadsafe` + `wrap_future` so the caller-side `await` remains non-blocking - Add `_notify_lock` to serialize concurrent `notify_data_update` calls on the dynamic socket, preventing send/recv interleaving across coroutines - Guard `close()` with `hasattr(_notify_loop)` to avoid `AttributeError` in `__del__` when `__init__` fails before the loop is created --------- Signed-off-by: yxstev <zhangyixiang9@huawei.com> Signed-off-by: 0oshowero0 <o0shower0o@outlook.com> Co-authored-by: 0oshowero0 <o0shower0o@outlook.com>
1 parent 170b8ed commit 46ee27b

1 file changed

Lines changed: 85 additions & 67 deletions

File tree

  • transfer_queue/storage/managers

transfer_queue/storage/managers/base.py

Lines changed: 85 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
import asyncio
1717
import itertools
1818
import os
19+
import threading
1920
import time
2021
import weakref
2122
from abc import ABC, abstractmethod
@@ -45,6 +46,15 @@
4546
TQ_STORAGE_HANDSHAKE_MAX_RETRIES = int(os.environ.get("TQ_STORAGE_HANDSHAKE_MAX_RETRIES", 3))
4647
TQ_DATA_UPDATE_RESPONSE_TIMEOUT = int(os.environ.get("TQ_DATA_UPDATE_RESPONSE_TIMEOUT", 30))
4748

49+
50+
def _run_notify_loop(notify_loop: asyncio.AbstractEventLoop) -> None:
51+
asyncio.set_event_loop(notify_loop)
52+
try:
53+
notify_loop.run_forever()
54+
finally:
55+
notify_loop.close()
56+
57+
4858
LIMIT_THREADS_PER_MANAGER_IN_DRIVER = 8
4959
LIMIT_THREADS_PER_MANAGER_IN_RAY_ACTOR = 4
5060

@@ -61,9 +71,19 @@ def __init__(self, controller_info: ZMQServerInfo, config: DictConfig):
6171
# Handshake socket is sync (used only during initialization)
6272
self.controller_handshake_socket: zmq.Socket | None = None
6373

64-
self.zmq_context: zmq.asyncio.Context | None = None
74+
self.zmq_context = zmq.asyncio.Context()
6575
self._connect_to_controller()
6676

77+
# Dedicated asyncio loop for ZMQ notify traffic, isolated from the caller's loop
78+
self._notify_loop = asyncio.new_event_loop()
79+
self._notify_thread = threading.Thread(
80+
target=_run_notify_loop,
81+
args=(self._notify_loop,),
82+
daemon=True,
83+
name=f"{self.storage_manager_id}-notify_data_status_loop",
84+
)
85+
self._notify_thread.start()
86+
6787
def _connect_to_controller(self) -> None:
6888
"""Initialize ZMQ sockets between storage unit and controller for handshake."""
6989
if not isinstance(self.controller_info, ZMQServerInfo):
@@ -90,9 +110,6 @@ def _connect_to_controller(self) -> None:
90110
self.controller_handshake_socket = None
91111
sync_zmq_context.term()
92112

93-
# create async context for data status update
94-
self.zmq_context = zmq.asyncio.Context()
95-
96113
except Exception as e:
97114
logger.error(f"Failed to connect to controller: {e}")
98115
raise
@@ -205,54 +222,51 @@ async def notify_data_update(
205222
logger.warning(f"No controller connected for storage manager {self.storage_manager_id}")
206223
return
207224

208-
# create dynamic socket
209-
identity = f"{self.storage_manager_id}-data_update-{uuid4().hex[:8]}".encode()
210-
sock = create_zmq_socket(self.zmq_context, zmq.DEALER, self.controller_info.ip, identity)
225+
normalized_field_schema = {}
226+
for field_name, field in field_schema.items():
227+
field_copy = field.copy()
228+
per_sample_shapes = field_copy.get("per_sample_shapes", None)
229+
if isinstance(per_sample_shapes, list | tuple):
230+
if len(per_sample_shapes) != len(global_indexes):
231+
raise ValueError(
232+
f"per_sample_shapes length ({len(per_sample_shapes)}) does not match "
233+
f"number of global_indexes ({len(global_indexes)}) for field '{field_name}'. "
234+
)
235+
field_copy["per_sample_shapes"] = {
236+
global_indexes[i]: per_sample_shapes[i] for i in range(len(global_indexes))
237+
}
238+
normalized_field_schema[field_name] = field_copy
211239

212-
try:
213-
sock.connect(self.controller_info.to_addr("request_handle_socket"))
214-
215-
normalized_field_schema = {}
216-
for field_name, field in field_schema.items():
217-
# Work on a shallow copy to avoid mutating caller-provided schema
218-
field_copy = field.copy()
219-
per_sample_shapes = field_copy.get("per_sample_shapes", None)
220-
if isinstance(per_sample_shapes, list | tuple):
221-
if len(per_sample_shapes) != len(global_indexes):
222-
raise ValueError(
223-
f"per_sample_shapes length ({len(per_sample_shapes)}) does not match "
224-
f"number of global_indexes ({len(global_indexes)}) for field '{field_name}'; "
225-
f"skipping per_sample_shapes normalization."
226-
)
227-
else:
228-
field_copy["per_sample_shapes"] = {
229-
global_indexes[i]: per_sample_shapes[i] for i in range(len(global_indexes))
230-
}
231-
232-
normalized_field_schema[field_name] = field_copy
233-
234-
# convert per_sample_shapes into dict
235-
for field in field_schema.values():
236-
per_sample_shapes = field.get("per_sample_shapes", None)
237-
if per_sample_shapes:
238-
per_sample_shapes = {global_indexes[i]: per_sample_shapes[i] for i in range(len(global_indexes))}
239-
field["per_sample_shapes"] = per_sample_shapes
240-
241-
request_msg = ZMQMessage.create(
242-
request_type=ZMQRequestType.NOTIFY_DATA_UPDATE, # type: ignore[arg-type]
243-
sender_id=self.storage_manager_id,
244-
body={
245-
"partition_id": partition_id,
246-
"global_indexes": global_indexes,
247-
"field_schema": normalized_field_schema,
248-
"custom_backend_meta": custom_backend_meta,
249-
},
250-
).serialize()
240+
request_msg = ZMQMessage.create(
241+
request_type=ZMQRequestType.NOTIFY_DATA_UPDATE, # type: ignore[arg-type]
242+
sender_id=self.storage_manager_id,
243+
body={
244+
"partition_id": partition_id,
245+
"global_indexes": global_indexes,
246+
"field_schema": normalized_field_schema,
247+
"custom_backend_meta": custom_backend_meta,
248+
},
249+
).serialize()
250+
251+
thread_future = asyncio.run_coroutine_threadsafe(
252+
self._notify_and_wait(request_msg),
253+
self._notify_loop,
254+
)
255+
await asyncio.wrap_future(thread_future)
256+
257+
async def _notify_and_wait(self, request_msg: list) -> None:
258+
"""Send a data status notification to the controller and block until ACK is received."""
259+
identity = f"{self.storage_manager_id}-notify-{uuid4().hex[:8]}".encode()
260+
sock = create_zmq_socket(
261+
ctx=self.zmq_context, socket_type=zmq.DEALER, ip=self.controller_info.ip, identity=identity
262+
)
263+
sock.setsockopt(zmq.LINGER, 0)
264+
sock.connect(self.controller_info.to_addr("request_handle_socket"))
251265

266+
try:
252267
await sock.send_multipart(request_msg)
253268
logger.debug(
254-
f"[{self.storage_manager_id}]: Send data status update request "
255-
f"from storage manager id #{self.storage_manager_id} "
269+
f"[{self.storage_manager_id}]: Sent data status update request "
256270
f"to controller id #{self.controller_info.id} successfully."
257271
)
258272

@@ -262,7 +276,10 @@ async def notify_data_update(
262276
while not response_received and timeout > 0:
263277
try:
264278
poll_interval = min(TQ_STORAGE_POLLER_TIMEOUT, timeout)
265-
messages = await asyncio.wait_for(sock.recv_multipart(copy=False), timeout=poll_interval)
279+
messages = await asyncio.wait_for(
280+
sock.recv_multipart(copy=False),
281+
timeout=poll_interval,
282+
)
266283
response_msg = ZMQMessage.deserialize(messages)
267284

268285
if response_msg.request_type == ZMQRequestType.NOTIFY_DATA_UPDATE_ACK: # type: ignore[arg-type]
@@ -271,30 +288,22 @@ async def notify_data_update(
271288
f"[{self.storage_manager_id}]: Get data status update ACK response "
272289
f"from controller id #{response_msg.sender_id} successfully."
273290
)
291+
break
274292
except asyncio.TimeoutError:
275293
timeout -= poll_interval
276294
except Exception as e:
277295
logger.warning(f"[{self.storage_manager_id}]: Error receiving response: {e}")
278296
break
279297

280298
if not response_received:
281-
logger.error(f"[{self.storage_manager_id}]: Did not receive data status update ACK.")
282-
283-
except Exception as e:
284-
logger.error(f"[{self.storage_manager_id}]: Error during notify_data_update: {e}")
285-
try:
286-
error_msg = ZMQMessage.create(
287-
request_type=ZMQRequestType.NOTIFY_DATA_UPDATE_ERROR, # type: ignore[arg-type]
288-
sender_id=self.storage_manager_id,
289-
body={"message": f"Failed to notify: {str(e)}"},
290-
).serialize()
291-
await sock.send_multipart(error_msg)
292-
except Exception:
293-
pass
299+
logger.error(
300+
f"[{self.storage_manager_id}]: Timeout waiting for data status update ACK "
301+
f"from controller after {TQ_DATA_UPDATE_RESPONSE_TIMEOUT}s."
302+
)
294303
finally:
295304
try:
296305
if not sock.closed:
297-
sock.close(linger=-1)
306+
sock.close(linger=0)
298307
except Exception:
299308
pass
300309

@@ -344,17 +353,26 @@ async def clear_data(self, metadata: BatchMeta) -> None:
344353
raise NotImplementedError("Subclasses must implement clear_data")
345354

346355
def close(self) -> None:
347-
"""Close all ZMQ sockets and context to prevent resource leaks."""
348-
# Close handshake socket if it exists
356+
"""Close all ZMQ sockets/contexts and stop the notify loop."""
357+
349358
if self.controller_handshake_socket:
350359
try:
351360
if not self.controller_handshake_socket.closed:
352361
self.controller_handshake_socket.close(linger=0)
353362
except Exception as e:
354363
logger.error(f"[{self.storage_manager_id}]: Error closing controller_handshake_socket: {str(e)}")
355364

356-
if self.zmq_context:
357-
self.zmq_context.term()
365+
if hasattr(self, "_notify_loop") and self._notify_loop.is_running():
366+
self._notify_loop.call_soon_threadsafe(self._notify_loop.stop)
367+
368+
if hasattr(self, "_notify_thread") and self._notify_thread is not None:
369+
self._notify_thread.join(timeout=5.0)
370+
if self._notify_thread.is_alive():
371+
logger.warning(f"[{self.storage_manager_id}]: Notify ZMQ thread did not stop within 5 second timeout.")
372+
else:
373+
logger.debug(f"[{self.storage_manager_id}]: Notify ZMQ thread shut down.")
374+
375+
self.zmq_context.term()
358376

359377
def __del__(self):
360378
"""Destructor to ensure resources are cleaned up."""

0 commit comments

Comments
 (0)