Repository navigation
Conversation
There was a problem hiding this comment.
🟡 Changes recommended
The new reconnect backoff logic can retry immediately after a long blocking reconnect attempt, defeating the intended throttling during extended outages.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR addresses NVMe-oF (NoF) I/O recovery after a target outage/restart by detecting failed IO qpairs in cached NoF segment handles and attempting reconnection (with controller reset fallback) so both master heartbeat probes and client I/O can resume without restarting the process.
Changes:
- Add NoF qpair health-check (
IsNofSegmentFailed) and recovery (ReconnectNofSegment) APIs toSpdkWrapper, including controller-reset fallback. - Reconnect failed NoF segments from the owning
SpdkNofWorkerPoolthread before submitting queued I/O, with per-segment throttling. - Extend the NoF heartbeat TCP E2E to include a target restart + re-registration phase and verify recovery.
File summaries
| File | Description |
|---|---|
| mooncake-store/include/spdk/spdk_wrapper.h | Declares new NoF qpair failure detection and reconnection APIs. |
| mooncake-store/src/spdk/spdk_wrapper.cpp | Implements qpair failure detection and reconnect/reset logic; probes now reconnect before heartbeat read. |
| mooncake-store/src/transfer_task.cpp | Adds worker-thread reconnection attempt with a per-segment backoff before submitting queued I/O. |
| mooncake-store/tests/e2e/run_nof_heartbeat_tcp_e2e.sh | Extends E2E to restart the target and assert the master/client recover without repeated unmount. |
Review details
- Files reviewed: 4/4 changed files
- Comments generated: 1
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
|
Thanks for the PR! I have a few comments below:
|
SPDK NoF application I/O had no bound: keep-alive is forced off, no per-request timeout is registered, and a task completes only after every submitted sub-I/O's callback has run. When the target stops answering while its connection stays up, process_completions() returns 0 forever and Client::TransferData() blocks on the future indefinitely (kvcache-ai#3864). Register SPDK's per-request timeout (MC_NOF_IO_TIMEOUT_MS, default 30000, 0 disables) on each controller. The callback only flags the segment being polled; the worker that owns the qpair then disconnects it locally (spdk_nvme_ctrlr_disconnect_io_qpair), which completes every outstanding request through the normal callback path as aborted without needing the target to respond. The segment stays in kDraining, with no new submissions, until every outstanding sub-I/O has been reclaimed, so no SpdkNofSubTask slot and no caller buffer is reused while SPDK can still reference it. The caller is failed only after that drain. Along the way make caller completion exactly-once (an error callback on a partially submitted task and the submit loop could both call set_completed()), return the sub-task slot when SPDK rejects a request at submission, and stop polling segments with nothing outstanding. Reconnecting the disconnected qpair is left to kvcache-ai#3886; the new nof_seg_handle::disconnected flag records the state for it, since such a qpair still reports SPDK_NVME_QPAIR_FAILURE_NONE. Addresses kvcache-ai#3864.
SPDK NoF application I/O had no bound: keep-alive is forced off, no per-request timeout is registered, and a task completes only after every submitted sub-I/O's callback has run. When the target stops answering while its connection stays up, process_completions() returns 0 forever and Client::TransferData() blocks on the future indefinitely (kvcache-ai#3864). Register SPDK's per-request timeout (MC_NOF_IO_TIMEOUT_MS, default 30000, 0 disables) on each controller. The callback only flags the segment being polled; the worker that owns the qpair then disconnects it locally (spdk_nvme_ctrlr_disconnect_io_qpair), which completes every outstanding request through the normal callback path as aborted without needing the target to respond. The segment stays in kDraining, with no new submissions, until every outstanding sub-I/O has been reclaimed, so no SpdkNofSubTask slot and no caller buffer is reused while SPDK can still reference it. The caller is failed only after that drain. Along the way make caller completion exactly-once (an error callback on a partially submitted task and the submit loop could both call set_completed()), return the sub-task slot when SPDK rejects a request at submission, and stop polling segments with nothing outstanding. Reconnecting the disconnected qpair is left to kvcache-ai#3886; the new nof_seg_handle::disconnected flag records the state for it, since such a qpair still reports SPDK_NVME_QPAIR_FAILURE_NONE. Addresses kvcache-ai#3864.
Signed-off-by: pjdurden <prajjwalchittori1@gmail.com>
Rename ReconnectNofSegment to TryRecoverNofSegment and make it non-blocking. Controller resets now run on a background recovery thread that holds a per-controller io_mutex exclusively, which every submit, poll and probe path holds shared via a try-lock, so a reset never runs while another worker uses the controller and a worker skips a resetting controller instead of waiting on it. Cached namespace pointers and block sizes are refreshed under that lock after a reset, and each owner reconnects its own io qpair once the reset has succeeded. Recovery logs now carry the target endpoint and namespace ID.
|
@cage-goat thanks, all three were fair. Pushed a49f294 (rebased on main):
Built against the SPDK v23.01.1 headers with -fsyntax-only, but I have no NoF target here, so the e2e is the real check. |
|
Thanks for working on this issue. I opened an alternative implementation in #4656 for #3863. Linking it here to make the overlap and design considerations clear for reviewers. The main considerations were:
The PR also includes an integration test covering controller recovery and qpair reconnection after a target restart with I/O outstanding. |
Signed-off-by: pjdurden <prajjwalchittori1@gmail.com> # Conflicts: # mooncake-store/include/spdk/spdk_wrapper.h # mooncake-store/src/spdk/spdk_wrapper.cpp # mooncake-store/src/transfer_task.cpp # mooncake-store/tests/e2e/run_nof_heartbeat_tcp_e2e.sh
|
merged current main in (34be261). the conflict was #3424 deleting spdk_wrapper.{h,cpp}, so the recovery moved into SpdkInitiator::Impl: the per-controller io_mutex, reset_generation and background reset thread live there, and ~Impl stops that thread before tearing down the controllers. NVMeoFInitiator gains TryLockSegmentIo, IsSegmentFailed and TryRecoverSegment with defaults that never refuse and never fail, so the test stub initiators are unchanged. NofWorkerPool::workerThread calls them through initiator_ and keeps main's cached nof_qos->block_size, ProbeSegment recovers before probing as before, and register_nof_segment in the e2e uses main's MOONCAKE_PYTHON_BIN / mooncake.store invocation. I could not compile it here (no SPDK), so CI is the first build of this version. |
NoF qpair recovery after target restart (issue #3863)
1. Root cause
SpdkWrapper::OpenNofSegment()(mooncake-store/src/spdk/spdk_wrapper.cpp)memoizes exactly one
nof_seg_handleper(controller, namespace)pair, andeach handle owns exactly one
spdk_nvme_qpairallocated on first use:Nothing in the tree ever inspected the qpair's health or called
spdk_nvme_ctrlr_reconnect_io_qpair()/spdk_nvme_ctrlr_reset(). When theNoF target goes away the qpair enters a permanent failure state, so from that
moment on:
spdk_nvme_ns_cmd_read/write()(viaSpdkWrapper::SubmitRequest) returns-ENXIO, andSpdkNofWorkerPool::workerThread()just marks the task failed;spdk_nvme_qpair_process_completions()returns-ENXIO, and the worker onlylogs
poll completion error;SpdkWrapper::ProbeNofSegment()reuses the same dead handle, so the master'sNoF heartbeat fails forever.
Because the handle is cached, this does not clear itself when the target comes
back. The master unmounts the segment after the failure threshold, but once the
segment is registered again the heartbeat still probes the stale qpair and
unmounts it again, and client I/O submitted through
TransferSubmitter::submitSpdkNofOperation()keeps hitting the same deadqpair. That is the "keeps reusing failed qpairs and cannot automatically
recover I/O after target recovery" behaviour reported in the issue.
2. The fix and why
Keep the cached handle (its address is used as a map key by
SpdkNofWorkerPool::seg_to_worker_and by the per-workerseg_to_qosmap, soit must stay stable), but heal the qpair inside it instead of reusing a failed
one.
SpdkWrappergains:IsNofSegmentFailed():spdk_nvme_qpair_get_failure_reason() != SPDK_NVME_QPAIR_FAILURE_NONE. Cheap enough to call on every submit round.TryLockNofSegmentIo(): a try-lock on a new per-controllerstd::shared_mutex io_mutex. Every thread that submits to, polls, or probesa qpair of that controller holds it shared. It never waits: while a reset is
queued or running it returns an unowned lock and the caller skips that
segment for the round.
TryRecoverNofSegment(): non-blocking, called by the qpair's owner whileholding the io lock. If the controller has been reset since this qpair last
connected, it disconnects and reconnects the qpair on the owning thread (SPDK
requires that, and it is also where aborted requests complete). Otherwise it
queues a controller reset and returns false.
Controller resets run on a background recovery thread inside
SpdkWrapper.It takes
io_mutexexclusively, sospdk_nvme_ctrlr_reset()never runs whileanother thread is using the controller, as SPDK requires. After a successful
reset it refreshes every cached
nspointer of that controller (the reset mayfree namespace structs) and the cached block size, then bumps a reset
generation that tells each qpair owner to reconnect.
GetBlockSize()nowreturns the cached value, so callers outside the workers never dereference a
namespace pointer.
Callers:
SpdkNofWorkerPool::workerThread()takes the io lock per segment for thesubmit round and for polling, and calls
TryRecoverNofSegment()for a failedsegment with queued work, at most once per second per segment. A worker
never blocks on a dead target, so healthy segments sharing it keep moving.
SpdkWrapper::ProbeNofSegment()does the same before probing and reportscontroller_resettingorrecoveringinstead of probing a dead qpair.If recovery has not completed, behaviour is unchanged from today: the submit
fails the task and the probe reports a heartbeat failure, and a later round
tries again. Recovery logs carry the target endpoint (
traddr:trsvcid/subnqn)and namespace ID.
3. Files changed
mooncake-store/include/spdk/spdk_wrapper.h: declare the new API and therecovery thread state.
mooncake-store/src/spdk/spdk_wrapper.cpp: implement it, givenof_seg_handleits owningctrlr_info *, nsid, cached block size andconnected generation, and recover in
ProbeNofSegment().mooncake-store/src/transfer_task.cpp: io lock around submit and poll,recovery from the owning worker with per-segment back off.
mooncake-store/tests/e2e/run_nof_heartbeat_tcp_e2e.sh: extend the existingNoF heartbeat e2e with a target recovery phase.
4. Risk and uncertainty
dropped. Telling the two apart needs a connect attempt, which blocks while
the target is down, and that is exactly what the workers must not do. The
cost is that a reset also makes the other qpairs of that controller
reconnect once.
round after it succeeds reconnects the qpair. For the master heartbeat that
is one more failed probe after the target is back.
connect. The target answered the reset moments earlier, so it normally takes
one round trip, but a target that dies again in that window would block that
one worker for a connect timeout.
target and no hugepages, and
USE_NOFisOFFby default, so the NoF codeis not part of the normal build here.
5. How I verified it
spdk_wrapper.cppagainst the real SPDK v23.01.1 headers (theversion pinned by
dependencies.sh) withg++ -std=c++20 -fsyntax-only, and the same for the extractedMaybeRecoverSpdkNofSegment()helper plus the worker's lock/poll calls.Both pass. The reset and reconnect semantics above were checked against
SPDK v23.01.1
lib/nvme/nvme_ctrlr.c. A full build was not possible here (no SPDK, and the store's otherthird-party headers are not installed).
clang-format(the formatter driven by the repo's pre-commitmooncake-code-formathook) reports no changes for the three C++ files.pre-commititself is not installed in this environment.bash -n mooncake-store/tests/e2e/run_nof_heartbeat_tcp_e2e.shpasses.target and asserted the master unmounts the segment; it now restarts the
target, re-registers the segment, and asserts that the segment is not
unmounted a second time. Before this change the master's heartbeat keeps
probing the stale qpair, so the segment is unmounted again within
HEARTBEAT_INTERVAL * HEARTBEAT_FAILURESand the assertion fails. Theclient process is deliberately kept alive across the outage so its cached
qpair is exercised too, and in NoF-only mode
(
CLIENT_GLOBAL_SEGMENT_SIZE=0) the script additionally requires that thesame client resumes successful put/get after recovery. Running it needs an
SPDK build, hugepages and root, none of which are available here.