Skip to content

[fix] exit a storage unit whose worker dies instead of hanging callers - #201

Merged
0oshowero0 merged 4 commits into
Ascend:mainfrom
OutstanderWang:fix/exit-storage-unit-on-worker-death
Oct 23, 2026
Merged

0oshowero0 merged 4 commits into
Ascend:mainfrom
OutstanderWang:fix/exit-storage-unit-on-worker-death

Conversation

@OutstanderWang

@OutstanderWang OutstanderWang commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

Problem

A SimpleStorageUnit answers requests on two daemon threads: a proxy that forwards between the client-facing ROUTER and the worker, and a worker that handles each request. An unhandled exception in either thread only ends that thread. The unit process keeps running and its ROUTER stays bound. The kernel keeps completing handshakes and queueing requests: RCVHWM/SNDHWM are 0, so nothing is rejected. No thread is left to answer them.

To a caller, this looks like a slow unit. Each caller blocks until its own receive timeout, and raising that timeout only makes the stall longer. Ray still reports the actor as alive, so nothing points at the unit that broke.

Change

  • _worker_routine now runs the request loop, moved unchanged into a new _worker_loop, inside try/except BaseException. On an unhandled exception it logs the death with its traceback at CRITICAL, calls _abort_process, and re-raises. Socket cleanup moves into finally.
  • An unexpected error in _proxy_routine is now logged with its traceback (logger.exception instead of logger.error) and also calls _abort_process. Without the proxy nothing reaches the worker, so the unit is just as unreachable.
  • _abort_process(reason) logs the reason at CRITICAL, flushes the log output, and calls os._exit(70). Exit code 70 marks the cause in Ray's actor-failure logs. os._exit skips atexit and flush handlers, which could block on the same ZMQ state that just failed.
  • Because os._exit also skips flushing, _flush_log_output first flushes every handler up the logger's propagation chain, plus stdout and stderr. get_logger adds no handler when an ancestor already has one, so flushing only the module logger's own handlers could flush nothing and drop the line that explains the exit.
  • No abort once _shutdown_event is set: an exception that races a normal teardown is expected, not a crash.
  • TQ_STORAGE_EXIT_ON_WORKER_DEATH=0 (default 1) keeps the old behavior, for example to attach a profiler to the dead unit. A CRITICAL log then says that requests to this unit will hang.

Callers do not fail any faster: a DEALER keeps queueing to a dead peer, so a request to the dead unit still runs to its send/recv timeout and retries. The unit fails visibly instead: Ray reports the actor dead with exit code 70, and the probe after the last timeout reports tcp=down rather than tcp=up verdict=unit_not_serving.

Behavior is unchanged while both threads are healthy.

Tests

New tests/test_storage_worker_death_exits.py, which runs without Ray or real sockets:

  • an unhandled exception in the worker loop exits the process with code 70, and the exception is still re-raised;
  • a proxy failure exits the process;
  • a proxy failure during shutdown, and an abort after _shutdown_event is set, do not exit;
  • TQ_STORAGE_EXIT_ON_WORKER_DEATH=0 keeps the process alive.

ruff check and ruff format --check pass for the changed files. python -m pytest -q tests/test_storage_worker_death_exits.py tests/test_simple_storage_unit.py tests/test_storage_request_retry.py tests/test_storage_key_race.py tests/test_accept_probe.py: 92 passed.

…ng callers

The worker and proxy threads are daemons, so an unhandled exception in either only
unwinds that thread. The unit process stays up with its ROUTER still bound, the
kernel keeps completing handshakes and queueing requests (RCVHWM/SNDHWM are 0, so
nothing rejects them), and no one is left to answer. Callers cannot tell this from
a slow unit, so each one blocks until its own recv timeout and raising that timeout
only makes the stall longer.

Exit the process instead. A unit that cannot answer is worth less than no unit at
all, because its silence is indistinguishable from work in progress; exiting turns
it into a visible Ray actor failure at the moment it breaks. Exit code 70 marks the
cause, and os._exit avoids the atexit and flush handlers that could block on the
same ZMQ state that just failed.

The proxy gets the same treatment: without it nothing reaches the worker, so the
unit is equally unreachable.

Aborts are suppressed once _shutdown_event is set, since an exception racing a
normal teardown is expected rather than a crash. TQ_STORAGE_EXIT_ON_WORKER_DEATH=0
restores the old behavior for anyone who needs to attach a profiler to the dead
process, which is what TQ_HANG_HOLD_SECONDS already does on the manager side.

Signed-off-by: OutstanderWang <wangweiyanster@gmail.com>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 22, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-22T12:23:52.698619Z 6de1343 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@OutstanderWang OutstanderWang changed the title [storage] fix: exit a storage unit whose worker dies instead of hanging out [storage] fix: exit a storage unit whose worker dies instead of hanging callers Oct 22, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6de1343bd3

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

logger.info(f"[{self.storage_unit_id}]: worker thread started...")
perf_monitor = IntervalPerfMonitor(caller_name=f"{self.storage_unit_id}")

try:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Guard worker setup failures before the loop

If create_zmq_socket, connect, poller construction/registration, or IntervalPerfMonitor raises (for example because ZMQ cannot allocate another socket), this new try has not started yet. Because _worker_routine runs in a daemon thread, that exception is not propagated to __init__; _start_process_put_get still launches the proxy and publishes a storage unit with no worker, recreating the hanging-unit state this change is meant to eliminate. Include setup in the fatal guard while conditionally cleaning up resources that were initialized.

Useful? React with 👍 / 👎.

Comment on lines +1134 to +1135
for handler in logger.handlers:
handler.flush()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Make abort survive logging flush failures

If a configured logging handler's flush() raises, such as for a closed or broken Ray log stream, execution never reaches os._exit, so both abort paths merely unwind their daemon thread and leave the unresponsive actor alive. This direct flush is outside any protection even though the hard exit is intended to bypass failing cleanup; ensure the exit runs even when handler flushing fails.

Useful? React with 👍 / 👎.

…s fail fast

Exiting the unit does not shorten a caller's wait: a DEALER keeps queueing to a dead peer, so each request still runs to its send/recv timeout and retries. What the exit changes is that the unit shows up as a dead Ray actor with exit code 70, and the post-timeout probe reports tcp=down.

Signed-off-by: OutstanderWang <wangweiyanster@gmail.com>
… dead worker once

get_logger adds no handler when an ancestor already has one, so flushing only the module logger's own handlers could flush nothing, and os._exit then drops the abort log that explains the exit. Flush every handler up the propagation chain, and stdout and stderr.

The worker's CRITICAL line also said the process and ROUTER stay up, right before the exit; it now only reports the death with its traceback. The stay-up warning remains in the TQ_STORAGE_EXIT_ON_WORKER_DEATH=0 branch, where it holds.

Signed-off-by: OutstanderWang <wangweiyanster@gmail.com>
Signed-off-by: OutstanderWang <wangweiyanster@gmail.com>
@OutstanderWang OutstanderWang changed the title [storage] fix: exit a storage unit whose worker dies instead of hanging callers [fix] exit a storage unit whose worker dies instead of hanging callers Oct 22, 2026
@OutstanderWang
OutstanderWang force-pushed the fix/exit-storage-unit-on-worker-death branch from 4b8cde8 to 4824c3c Compare October 22, 2026 12:36
@0oshowero0
0oshowero0 merged commit d999b4c into Ascend:main Oct 23, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants