[Store] Fix SSD offload publish-before-commit race (#2799)#2818
Conversation
In BucketStorageBackend::BatchOffload, commit the local index before calling NotifyOffloadSuccess. This closes the race where Master redirects reads to this node before object_bucket_map_ contains the key, causing INVALID_KEY errors. If NotifyOffloadSuccess fails after local commit, roll back the local index via RollbackCommittedBucket: remove metadata entries, wait for in-flight reads, and clean up on-disk files. On timeout we leave the orphan files rather than risk I/O errors for active readers. Also evict cached file handles in CleanupOrphanedBucket before deleting files to avoid stale handles.
There was a problem hiding this comment.
Code Review
This pull request modifies the BatchOffload process to commit metadata to the local index before notifying the master. If the notification fails, a new RollbackCommittedBucket method is invoked to safely revert the local commit, wait for inflight reads to drain, and clean up the on-disk files. Additionally, CleanupOrphanedBucket is updated to evict cached file handles before deletion. Feedback points out a use-after-move issue in BatchOffload where metadatas[i] is moved into object_bucket_map_ but later passed to complete_handler; copying the metadata instead of moving it is recommended to prevent fragile behavior.
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.
…commit Keep the StorageObjectMetadata values intact when inserting into object_bucket_map_, instead of std::move-ing them. This guarantees that complete_handler receives fully populated metadata entries and avoids the moved-from state that required a post-hoc comment.
|
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
he-yufeng
left a comment
There was a problem hiding this comment.
Thanks for tracking this down, @LujhCoconut — I walked the fix through the offload and read paths and the logic is correct. Committing the local index ahead of NotifyOffloadSuccess is the right way to close the #2799 window: a concurrent BatchLoad that arrives after the Master redirects reads here now finds the key in object_bucket_map_ instead of returning INVALID_KEY.
A few things I verified while going through it, for the record:
-
Empty
transport_endpointin the local index is fine. The copy inserted intoobject_bucket_map_carries an emptytransport_endpoint(it only gets populated insidecomplete_handlerbefore the RPC). The local read path (BatchLoad→ReadPlan) only consumesbucket_id/offset/key_size/data_size, andstorage_backend.cppnever readstransport_endpointoff a map entry — that field only matters on the copy that travels to the Master. Switching the insert fromstd::moveto a copy is the correct call here, and it also resolves the use-after-move the bot flagged. -
Size accounting stays balanced. Commit adds
bucket->data_size + bucket->meta_size, and sinceBuildBucketsetsbucket->data_size = Σ(object_total_size + key_size), the rollback's per-keyΣ(data_size + key_size)plusmeta_sizesubtracts exactly what was added. It also matches the existing eviction path, sototal_size_doesn't drift on rollback. -
No TOCTOU between rollback and in-flight reads.
BatchLoadacquires theBucketReadGuard(bumpinginflight_reads_) while still holding the sharedmutex_, in the same critical section as the lookup. Rollback removes the entries under the exclusive lock, so a reader either grabbed its guard first (Phase 2 drains it) or can't find the key after removal. The spin-then-sleep drain mirrorsDeleteBucket, and leaving orphans on a drain timeout instead of deleting under an active reader is the safe choice.
One follow-up I'd like to see: the rollback path (complete_handler failing after the local commit) has no test yet — this PR only touches the header and the .cpp. storage_backend_test.cpp already exercises BatchOffload, and you can drive the rollback deterministically by passing a complete_handler that returns a non-OK ErrorCode, then asserting the keys are gone from object_bucket_map_, total_size_ is back to its pre-offload value, and the on-disk bucket file is removed. This is data-integrity recovery code, so a regression here would be silent — worth locking down. The logic checks out so I'm approving, but I'd strongly encourage landing that test alongside it.
Minor, non-blocking: on the 10s drain timeout, Phase 1 has already decremented total_size_ while the files stay on disk until the next Init() orphan scan, so physical usage briefly exceeds the accounted size. That's the same trade-off DeleteBucket makes and the path should be effectively unreachable in practice — just flagging it for awareness.
Description
Fixed #2799 .
In BucketStorageBackend::BatchOffload, commit the local index before calling NotifyOffloadSuccess. This closes the race where Master redirects reads to this node before object_bucket_map_ contains the key, causing INVALID_KEY errors.
If NotifyOffloadSuccess fails after local commit, roll back the local index via RollbackCommittedBucket: remove metadata entries, wait for in-flight reads, and clean up on-disk files. On timeout we leave the orphan files rather than risk I/O errors for active readers.
Also evict cached file handles in CleanupOrphanedBucket before deleting files to avoid stale handles.
Module
mooncake-transfer-engine)mooncake-store)mooncake-ep)mooncake-pg)mooncake-integration)mooncake-p2p-store)mooncake-wheel)mooncake-common)mooncake-rl)Type of Change
Checklist
./scripts/code_format.shpre-commit run --all-filesand all hooks pass