[Bug]: [SSD] DSV4 enable SSD offload,stress testing, repeat offloading the same batch key, lead to OBJECT_ALREADY_EXISTS/persist failed/INVALID_KEY#2967
Conversation
…sting, repeat offloading the same batch key, lead to OBJECT_ALREADY_EXISTS/persist failed/INVALID_KEY
There was a problem hiding this comment.
Code Review
This pull request introduces the IsPerBucketSoftOffloadError helper to classify certain errors, such as OBJECT_ALREADY_EXISTS and INVALID_READ, as soft, per-bucket failures during object offloading. This prevents aborting the entire offload cycle and leaking tasks when duplicate offloads occur. However, a critical leak path remains: if a soft error occurs and populates failed_tasks, but a subsequent bucket encounters a hard error, the early return will bypass the NACK reporting block, causing the accumulated failed_tasks to leak.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| if (IsPerBucketSoftOffloadError(offload_res.error())) { | ||
| // Only this bucket failed to persist; report its keys back to | ||
| // the master as failed (releasing their offloading tasks and | ||
| // source-replica refcounts) and keep processing the remaining | ||
| // buckets instead of aborting the whole offload cycle. | ||
| for (const auto& [key, _] : host_batch_object) { | ||
| failed_tasks.push_back(task_by_storage_key.at(key)); | ||
| } |
There was a problem hiding this comment.
While accumulating failed tasks in failed_tasks for soft errors is a great improvement, there is still a critical leak path. If a soft error occurs in an earlier bucket (populating failed_tasks), but a subsequent bucket encounters a hard error (such as KEYS_ULTRA_LIMIT or any other unhandled error), the function will execute an early return tl::make_unexpected(...) (e.g., on line 634 or 645). This early return bypasses the NACK reporting block at the end of the function, causing all previously accumulated failed_tasks to be leaked on the master (dangling tasks and unreleased replica refcounts).\n\nTo completely prevent this, we should ensure that any early return path also reports the accumulated failed_tasks (and ideally any remaining unprocessed tasks in the cycle) to the master before returning, or use an RAII cleanup guard to automate the reporting of any tasks that were not successfully completed.
There was a problem hiding this comment.
Pull request overview
Fixes a concurrency-triggered SSD offload failure mode where BucketStorageBackend::BatchOffload can legitimately return OBJECT_ALREADY_EXISTS for an entire bucket, but FileStorage::OffloadObjects previously treated that as fatal—aborting the cycle and skipping the “failed tasks” NACK reporting back to the master, which could leave master/SSD metadata inconsistent and surface as INVALID_KEY on later reads.
Changes:
- Introduce
FileStorage::IsPerBucketSoftOffloadError(ErrorCode)to classify per-bucket “soft” failures. - Update
FileStorage::OffloadObjectsto treatOBJECT_ALREADY_EXISTSthe same way asINVALID_READ: record the bucket’s keys as failed and continue processing remaining buckets (instead of aborting the whole cycle). - Add a unit test verifying the error classification behavior for relevant error codes.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated no comments.
| File | Description |
|---|---|
| mooncake-store/include/file_storage.h | Declares and documents the new private helper predicate for per-bucket soft offload errors. |
| mooncake-store/src/file_storage.cpp | Implements the predicate and uses it to handle OBJECT_ALREADY_EXISTS without aborting the full offload cycle. |
| mooncake-store/tests/file_storage_test.cpp | Adds a regression/unit test ensuring OBJECT_ALREADY_EXISTS is classified as a per-bucket soft offload error. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
Thanks for this PR. Overall this fix is solid: both The leak Gemini flagged is pre-existing and bounded by the TTL reaper ( Two more non-blocking suggestions, both cheap enough to ride along:
Lastly, the actual duplicate source — worth a separate follow-up: in |
Address review on kvcache-ai#2967: the KEYS_ULTRA_LIMIT and hard-error paths in OffloadObjects returned immediately, leaving every drained key (the failing bucket plus all unvisited buckets) without a failure NACK, so their offloading tasks and source-replica refcounts leaked until the put_start_release_timeout_sec_ TTL reaper fired. Record the aborting error and break instead of returning, then fall through to the existing not-in-any-bucket sweep + NACK flush. The failing bucket's keys are pushed before the break; unvisited buckets are not yet in all_bucket_keys so the sweep NACKs them. The original error is still propagated to the caller after the flush. Also skip BatchOffload when host_batch_object is empty (every object in the bucket failed D2H staging): those keys are already in failed_tasks, and an empty map would otherwise return INVALID_KEY and trip the abort.
|
Thanks for the detailed pass. Pushed a follow-up (a38cbe6) that closes the leak and folds in the two cheap suggestions:
Left the |
Issue #2827 — SSD offload duplicate-key storm (
OBJECT_ALREADY_EXISTS→persist failed →
INVALID_KEY)Upstream issue: #2827
Symptom (DeepSeek‑V4, PD‑disaggregated, SSD offload enabled, 32‑concurrency
stress): the same batch KV keys are offloaded repeatedly, the P node logs
Duplicate key detected in BatchOffload … Returning OBJECT_ALREADY_EXISTS,whole‑batch
persist failed, and the D node then gets floods ofKey not found … BatchQuerySlices/BatchLoad … INVALID_KEY. A single requestnever triggers it — only concurrent/repeat offload of the same key does.
1. Root cause
Under concurrency, two offload flows on the same node can target an overlapping
KV key (shared prefix block → identical block hash). The bucket storage backend
is, by design, a strict single‑writer‑per‑key store that commits a bucket
atomically:
BucketStorageBackend::BatchOffloadfirst callsPrepareEviction(
mooncake-store/src/storage_backend.cpp), which reserves every key of thebucket in
pending_write_keys_and returnsOBJECT_ALREADY_EXISTSfor theentire bucket if any key is already committed (
object_bucket_map_),already reserved (
pending_write_keys_), or being evicted(
pending_eviction_keys_).This atomic‑bucket rejection is intentional and is locked in by existing tests
(
storage_backend_test.cpp:BucketStorageBackend_DuplicateKeyRejected,BucketStorageBackend_DuplicateBatchPartialRejection— the latter asserts abatch
[keyA(dup), keyB(new)]rejects the whole batch andkeyBis notwritten).
The actual bug is in the caller,
FileStorage::OffloadObjects(
mooncake-store/src/file_storage.cpp). Its per‑bucket error handling treatedonly
INVALID_READas a recoverable, per‑bucket condition and treatedeverything else — including
OBJECT_ALREADY_EXISTS— as fatal:So a single duplicate key would
returnout ofOffloadObjects, which:failed_tasksNACK reporting at the end of the function(the early
returnjumps over it). Those keys were drained from themaster's offloading queue but are never resolved.
offloading_tasksdangling and never releasesthe source‑replica refcounts (
dec_refcnt), so master and local‑SSDmetadata drift apart. Keys the master believes are already offloaded are
not actually on this node's SSD.
When the D node later reads those keys, the master redirects the read to the P
node's SSD,
object_bucket_map_has no entry →BatchLoad/BatchQuerySlicesreturn
INVALID_KEY— exactly the flood in the report.2. The fix and why
Treat
OBJECT_ALREADY_EXISTSfromBatchOffloadthe same wayINVALID_READis already treated: a per‑bucket soft failure, not a fatal one. The
bucket's keys are pushed into
failed_tasksand the loop continues to the nextbucket; at the end of
OffloadObjectsthose keys are reported back to themaster with the existing
data_size == -1NACK sentinel.This is the minimal change that resolves the reported failure chain, and it is
aligned with the issue's own primary recommendation (#1: handle
OBJECT_ALREADY_EXISTSidempotently, do not fail the whole batch). It doesnot touch the backend's deliberate atomic‑bucket contract.
Why it is correct and safe:
MasterService::NotifyOffloadSuccess, themetadata.data_size < 0branch) only erases the key'soffloading_taskanddec_refcnts the source replica if a task is present; it never removesor overwrites an existing valid replica. So NACK'ing a key that another flow
legitimately committed is a harmless no‑op on that valid replica, and it
correctly releases this flow's dangling task/refcount. This holds under any
ordering of the concurrent success/NACK RPCs.
LOCAL_DISKreplicas → no more spuriousINVALID_KEYon theread path.
resolved on the master (success or NACK), so tasks/refcounts no longer leak
(which otherwise also blocks future writes via
OBJECT_HAS_REPLICATION_TASK).The classification is factored into a small, documented, testable predicate
FileStorage::IsPerBucketSoftOffloadError(ErrorCode). Genuinely global errors(
KEYS_ULTRA_LIMIT,INTERNAL_ERROR,BUCKET_NOT_FOUND, …) still abort thecycle as before.
Trade‑off: on the rare colliding bucket, the non‑duplicate keys in that bucket
are not persisted this round (they are NACK'd and can be re‑offloaded or
recomputed later). This is not a regression — the previous behaviour
discarded the whole bucket too, and additionally aborted the entire cycle and
leaked master state. Correctness (no data loss on the read path) is preserved
because a NACK never deletes a valid replica.
3. Files changed
mooncake-store/include/file_storage.h— declare the private staticpredicate
IsPerBucketSoftOffloadError(ErrorCode)with rationale doc.mooncake-store/src/file_storage.cpp— define the predicate and use it inOffloadObjects' per‑bucket error handling soOBJECT_ALREADY_EXISTSishandled alongside
INVALID_READ(report keys as failed, continue) instead ofaborting the cycle.
mooncake-store/tests/file_storage_test.cpp— addDuplicateOffloadErrorIsPerBucketSoftError(with a static funnel, mirroringthe repo's existing friend‑access pattern) asserting the classification:
OBJECT_ALREADY_EXISTSandINVALID_READare per‑bucket soft errors, whileKEYS_ULTRA_LIMIT/INTERNAL_ERROR/INVALID_KEY/OKare not.4. Risk / uncertainty
failed_tasks→ NACK pathand adds one error code to a classification. The backend contract and all its
duplicate‑key tests are untouched.
produces
OBJECT_ALREADY_EXISTSat runtime —pending_write_keys_reservation vs.
pending_eviction_keys_vs. commit‑time race) was analysedstatically but not reproduced live; the fix is deliberately defensive and
correct regardless of which interleaving triggers it.
full concurrent end‑to‑end reproduction. A deterministic end‑to‑end test
would need to force the transient backend state (in‑flight eviction /
concurrent writer) and heavy
InProcMaster+ real MEM‑replica setup; thatwas judged disproportionate and flaky for a one‑condition caller fix.
5. How I verified it
storage_backend.cpp(BatchOffload/PrepareEviction/RollbackCommittedBucket/ eviction),file_storage.cpp(
OffloadObjects/BatchQuerySegmentSlices/NotifyEvictedDiskReplicas),and
master_service.cpp(NotifyOffloadSuccessNACK path,OffloadObjectHeartbeat) — confirming (a) where the whole‑bucketOBJECT_ALREADY_EXISTSoriginates, (b) that the earlyreturnskips thefailed_tasksNACK, and (c) that the master NACK path never removes a validreplica and
dec_refcnts at most once per task.storage_backend_test.cppduplicate‑key tests, so the fix belongs in thecaller.
DuplicateOffloadErrorIsPerBucketSoftErrorcovering the changedclassification.
ColumnLimit: 80(Google‑based.clang-format) and match surrounding4‑space indentation and naming.
environment (no configured build; RDMA/uring/etc. toolchain not provisioned),
so runtime verification is limited to the reasoning above.
git commit/push/ghwere intentionally not run.