Repository navigation
Conversation
There was a problem hiding this comment.
🟡 Changes recommended
There is a small but concrete performance/implementation issue in the new NoF-restore path (unnecessary per-replica DummyBufferAllocator allocation) that should be adjusted before merging.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Fixes a HA-restore correctness bug where NoF (NVMe-oF) replicas restored from a standby snapshot could remain “readable” despite lacking allocator state, potentially aliasing later reallocated offsets and causing silent wrong-data reads. The change forces restored NoF replicas to be treated as invalid immediately, so existing “invalid handle” filtering and cleanup logic prevents serving stale descriptors and reclaims them.
Changes:
MasterService::RestoreFromStandbySnapshot()no longer keeps a placeholder allocator alive for restored NoF replicas, making them immediately reporthas_invalid_nof_handle().- Adds an HA regression test ensuring restored NoF-only objects are not served and are reclaimed by
ClearInvalidHandles(). - Introduces small test helpers to construct standby NoF replica/object metadata and to assert invalid-NoF-handle state.
File summaries
| File | Description |
|---|---|
| mooncake-store/src/master_service.cpp | Restored NoF replicas are invalidated by not retaining a keepalive allocator, preventing stale replicas from being served post-restore. |
| mooncake-store/tests/ha/master_service_ha_test.cpp | Adds a focused regression test and helpers to validate NoF restore invalidation and subsequent cleanup. |
Review details
- Files reviewed: 2/2 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.
|
ci caught a real conflict here, so flagging it rather than papering over it. the new test the two assertions that fail:
so my comment in the bigger problem is that the two tests encode opposite answers to question 1 in #3826, whether NoF replicas are expected to remain readable after HA promotion. #3796 says yes, this pr says no. that is a design call and not one i should make by overwriting a test that landed the same day, so i would rather have your read first. as i see it the issue leaves three options:
happy to take any of the three, just say which. i lean 2 as the right end state if NoF HA recovery is meant to be supported at all. the review note about the per-replica separately, |
|
Thanks for calling this out so clearly. Catching the conflict with a test that had just landed — and pausing instead of quietly rewriting it — is exactly the kind of care we want around HA recovery. Of the three options, I also lean toward option 2 as the end state, with the fail-closed step first as @he-yufeng suggested in #3826. There is just one part of the current patch that makes me hesitate: the temporary allocator does close the immediate read window, but Would it be possible to model this as an explicit restored-NoF quarantine instead — keep the complete descriptor, hide it from reads, and prevent fresh allocation on that endpoint until remount has imported all restored @he-yufeng, is that roughly the two-step split you had in mind? And if the project would rather declare restored NoF data disposable after promotion, that's a valid decision too — I would just prefer that we make it explicit and update the #3796 contract, rather than getting there indirectly through an expired weak pointer. Happy to follow the maintainers' call here. |
he-yufeng
left a comment
There was a problem hiding this comment.
The approach checks out against what I traced in #3826. Keeping the DummyBufferAllocator scoped to the loop iteration lets the replica's weak handle expire, so the existing invalid-handle machinery does the rest: IsReplicaReadable excludes it, GetReplicaList answers REPLICA_IS_NOT_READY, and ClearInvalidHandles reaps it on the regular sweep. No new state, no new path. That is the step-1 shape from the issue.
The red CI is one test, not a build break. In the CTest lane (job 100702250265) the suite runs green except:
[ FAILED ] MasterServiceHATest.RestoreFromStandbyPreservesNoFBufferDescriptor
That test has two halves. The descriptor half still passes under your change, because the restored Replica keeps its NoFDescriptor with size/address/protocol/endpoint intact. The failing half is the tail:
auto public_replicas =
service.GetReplicaList("standby_restore_nof_key", kDefaultTenant);
ASSERT_TRUE(public_replicas.has_value());That assertion pins exactly the behavior #3826 reports: a restored NoF replica being served as readable while the namespace may have handed its offset to someone else. So the test should flip, not the fix. Keep the descriptor assertions (preservation is still the contract), rename the tail to assert the fail-closed read, i.e. ASSERT_FALSE(public_replicas.has_value()) with REPLICA_IS_NOT_READY, mirroring your new RestoreDoesNotKeepNoFReplicasUsable. The wheel lanes look red from the same suite; once this one test is updated the gate should go green.
One thing worth a second opinion from the HA folks: after ClearInvalidHandles reaps the replica, the object is left with zero replicas, so a client that Put before the failover and reads after it gets REPLICA_IS_NOT_READY and has to re-Put. That is the intended fail-closed tradeoff, but it deserves a line in the PR description so the behavior change is explicit for release notes.
|
yes, that is implementable, and you are right that the expired weak pointer is the wrong mechanism. the quarantine you are describing already exists in this tree for memory replicas, i just did not reuse it. the memory path does exactly this:
applying that shape to NoF is a small change on the restore side: keep the allocator alive rather than scoping it to the loop, and add that also removes the reaping you flagged. one caveat on the keepalive. this also recovers most of #3796. on blocking fresh allocation, i want to correct my own PR description. i argued option 2 was not implementable because the snapshot carries no NoF base or capacity. that is true at restore time, but it is the wrong place to look. which splits the way @he-yufeng suggested in #3826:
i would like to keep this PR at step 1 and do step 2 as a follow up, since this one is a corruption fix. say the word if you would rather see them together. two things i would like your read on before i push:
and if the project would rather declare restored NoF data disposable after promotion, i am happy to keep what is pushed now and update the #3796 contract instead. i just agree it should be an explicit decision rather than a side effect of an expired weak pointer. |
…andles Issue kvcache-ai#3826: NoF replicas rebuilt by RestoreFromStandbySnapshot() point at physical offsets in a remote NVMe namespace, but the snapshot carries no NoF allocator state. The next NoF mount builds an empty allocator over the whole namespace and can hand those offsets to a different key, so the restored descriptor keeps reporting COMPLETE while the bytes underneath belong to another object. The previous revision of this change scoped the placeholder allocator to the loop iteration so the replica reported has_invalid_nof_handle(). That closed the read window but also let ClearInvalidHandles() delete the replica and cost get_descriptor() its transport_endpoint_, throwing away the state a later import needs (review feedback on kvcache-ai#3858). Model it as a quarantine instead, mirroring what restored memory replicas already do when their segment is absent: - Keep the placeholder allocator alive, in a NoF-specific keepalive map. The descriptor stays complete and has_invalid_nof_handle() stays false, so BuildStaleHandleCleanupPlan() no longer treats the replica as stale. - Record the endpoint in invalid_nof_replica_endpoints_, which IsReplicaReadable() consults, so the replica is hidden from every read path. - Refuse MountNoFSegment()/ReMountNoFSegment() for a quarantined namespace, so no fresh allocation can alias a live descriptor while it survives. The NoF keepalive and quarantine sets are kept separate from their memory counterparts because ReMountSegment() erases those by endpoint, so a NoF te_endpoint colliding with a memory segment alias would otherwise be released by an unrelated memory remount. This is the fail-closed half. Making the data readable again needs a NoF import path (a replace_nof_buffer and a NoF ReplaceAllocators) so a mount can adopt the restored ranges and lift the quarantine; that is left as follow-up. RestoreFromStandbyPreservesNoFBufferDescriptor (kvcache-ai#3796) keeps its descriptor assertions, which the quarantine satisfies including transport_endpoint_. Its tail asserted that GetReplicaList() serves the replica, which is the aliasing window itself, so that expectation is updated. Signed-off-by: pjdurden <prajjwalchittori1@gmail.com>
|
pushed the fail-closed half as what is in it:
two things i would rather you decide than have me guess, both in the description but repeating the first here because it is the real cost of fail-closed: while an endpoint is quarantined the whole namespace cannot be mounted, not just the restored ranges, so NoF is unavailable after a promotion that had NoF replicas until the import path lands. if you want a narrower gate or an operator override, i will add it. and nothing lifts the quarantine yet, so objects on a permanently gone endpoint stay in the index, the same way restored memory replicas do today. usual caveat, i still cannot build this checkout, so the new tests have not been run. worth treating CI as the gate. and the @he-yufeng does this match the two-step split you had in mind in #3826? |
catyans
left a comment
There was a problem hiding this comment.
Thanks @pjdurden—this is a much cleaner fail-closed shape. LGTM.
The restored NoF descriptor now stays intact for a future allocator import, while the separate keepalive/quarantine state keeps it out of every read path and prevents a fresh mount from reusing its offsets. Separating the NoF state from the memory-remount maps also avoids the endpoint-aliasing lifetime problem we discussed. The updated HA test captures the important contract well: preserved, not served, and not reaped.
This also lines up with @he-yufeng's two-step split from #3826. Making quarantined replicas usable again can stay as the follow-up; keeping the namespace unavailable until that import path exists is the right safety tradeoff for this corruption fix. Appreciate how carefully you worked through the conflicting contracts here.
…andles Issue kvcache-ai#3826: NoF replicas rebuilt by RestoreFromStandbySnapshot() point at physical offsets in a remote NVMe namespace, but the snapshot carries no NoF allocator state. The next NoF mount builds an empty allocator over the whole namespace and can hand those offsets to a different key, so the restored descriptor keeps reporting COMPLETE while the bytes underneath belong to another object. The previous revision of this change scoped the placeholder allocator to the loop iteration so the replica reported has_invalid_nof_handle(). That closed the read window but also let ClearInvalidHandles() delete the replica and cost get_descriptor() its transport_endpoint_, throwing away the state a later import needs (review feedback on kvcache-ai#3858). Model it as a quarantine instead, mirroring what restored memory replicas already do when their segment is absent: - Keep the placeholder allocator alive, in a NoF-specific keepalive map. The descriptor stays complete and has_invalid_nof_handle() stays false, so BuildStaleHandleCleanupPlan() no longer treats the replica as stale. - Record the endpoint in invalid_nof_replica_endpoints_, which IsReplicaReadable() consults, so the replica is hidden from every read path. - Refuse MountNoFSegment()/ReMountNoFSegment() for a quarantined namespace, so no fresh allocation can alias a live descriptor while it survives. The NoF keepalive and quarantine sets are kept separate from their memory counterparts because ReMountSegment() erases those by endpoint, so a NoF te_endpoint colliding with a memory segment alias would otherwise be released by an unrelated memory remount. This is the fail-closed half. Making the data readable again needs a NoF import path (a replace_nof_buffer and a NoF ReplaceAllocators) so a mount can adopt the restored ranges and lift the quarantine; that is left as follow-up. RestoreFromStandbyPreservesNoFBufferDescriptor (kvcache-ai#3796) keeps its descriptor assertions, which the quarantine satisfies including transport_endpoint_. Its tail asserted that GetReplicaList() serves the replica, which is the aliasing window itself, so that expectation is updated. Signed-off-by: pjdurden <prajjwalchittori1@gmail.com>
catyans
left a comment
There was a problem hiding this comment.
LGTM on the rebased head. I checked the range-diff rather than relying on the stale approval: the separate NoF keepalive/quarantine contract is preserved, and the conflict resolution cleanly adapts it to main's new restore-state and readable-descriptor paths. CI is green.
Thanks again @pjdurden for turning the original allocator-lifetime conflict into an explicit, testable quarantine state. The fail-closed half still looks like the right boundary for this corruption fix; allocator import can remain the follow-up.
|
One question about the descriptor-preservation guarantee: does NoFBatchEvict need to check the quarantine set too? Its predicate appears to accept these restored replicas (NoF, COMPLETE, refcnt == 0), so allocation pressure could remove their descriptors after the lease expires. I may be missing a guard here—could you confirm what prevents that path? |
|
good catch, it did. pushed d2e85c6, which adds the same readability check to the NoF predicate, plus |
|
Thanks for addressing the eviction gap. The readability check and the new regression test cover the descriptor-preservation issue I raised. The CTest build is still failing because these tests use interfaces that have moved to MasterServiceTestPeer on main. Please change the eviction call to MasterServiceTestPeer(service).RunNoFBatchEvictForTesting(...), and use MasterServiceTestPeer::MetadataAccessorRO and MasterServiceTestPeer::ObjectIdentity in HasInvalidNoFHandleForTesting. Both errors appear in the current CI log. Once those accesses are updated and the HA tests pass, my previous concern is resolved. |
|
@pjdurden Thanks for the fix! Are you planning to follow up with the allocator recovery step discussed in #3826, so restored NoF replicas can become usable again? cc @he-yufeng |
…andles Issue kvcache-ai#3826: NoF replicas rebuilt by RestoreFromStandbySnapshot() point at physical offsets in a remote NVMe namespace, but the snapshot carries no NoF allocator state. The next NoF mount builds an empty allocator over the whole namespace and can hand those offsets to a different key, so the restored descriptor keeps reporting COMPLETE while the bytes underneath belong to another object. The previous revision of this change scoped the placeholder allocator to the loop iteration so the replica reported has_invalid_nof_handle(). That closed the read window but also let ClearInvalidHandles() delete the replica and cost get_descriptor() its transport_endpoint_, throwing away the state a later import needs (review feedback on kvcache-ai#3858). Model it as a quarantine instead, mirroring what restored memory replicas already do when their segment is absent: - Keep the placeholder allocator alive, in a NoF-specific keepalive map. The descriptor stays complete and has_invalid_nof_handle() stays false, so BuildStaleHandleCleanupPlan() no longer treats the replica as stale. - Record the endpoint in invalid_nof_replica_endpoints_, which IsReplicaReadable() consults, so the replica is hidden from every read path. - Refuse MountNoFSegment()/ReMountNoFSegment() for a quarantined namespace, so no fresh allocation can alias a live descriptor while it survives. The NoF keepalive and quarantine sets are kept separate from their memory counterparts because ReMountSegment() erases those by endpoint, so a NoF te_endpoint colliding with a memory segment alias would otherwise be released by an unrelated memory remount. This is the fail-closed half. Making the data readable again needs a NoF import path (a replace_nof_buffer and a NoF ReplaceAllocators) so a mount can adopt the restored ranges and lift the quarantine; that is left as follow-up. RestoreFromStandbyPreservesNoFBufferDescriptor (kvcache-ai#3796) keeps its descriptor assertions, which the quarantine satisfies including transport_endpoint_. Its tail asserted that GetReplicaList() serves the replica, which is the aliasing window itself, so that expectation is updated. Signed-off-by: pjdurden <prajjwalchittori1@gmail.com>
The NoF eviction predicate only checked type, completeness and refcount, so a restored replica on a quarantined endpoint became evictable once its lease ran out, and eviction removed the descriptor the quarantine is meant to preserve. Gate it on IsReplicaReadable(), the same check the memory path already gets through IsEvictableMemoryReplica(). Signed-off-by: pjdurden <prajjwalchittori1@gmail.com>
Private MasterService internals used by the new HA tests moved to MasterServiceTestPeer on main. Also restore the NoF keepalive and quarantine sets in rollback_restored_state, alongside the memory ones.
|
@fcczzz thanks, rebased onto current main and switched both to I can't build this tree locally, so CI is the check here. |
Main routes every readability check through IsAwaitingRemount() and TryGetReadableReplicaDescriptor() now defers to IsReplicaReadable(), so the NoF quarantine check moves into IsAwaitingRemount(): a NoF replica on an endpoint in invalid_nof_replica_endpoints_ is unreadable, everything else keeps main's invalid_replica_endpoints_ check. Signed-off-by: pjdurden <prajjwalchittori1@gmail.com>
|
merged current main in (794bcb9). main now gates reads through IsAwaitingRemount(), so the NoF quarantine check lives there instead of in TryGetReadableReplicaDescriptor; behaviour is the same. |
Signed-off-by: pjdurden <prajjwalchittori1@gmail.com>
Issue #3826 — NoF replicas restored from a standby snapshot can alias reallocated offsets
Revised after review. The first revision invalidated the restored handle; this one quarantines the endpoint instead. Discussion in the thread below.
1. Root cause
MasterService::RestoreFromStandbySnapshot()rebuilds in-memoryReplicaobjects from the standby snapshot. For a NoF (NVMe-oF SSD) descriptor it created aDummyBufferAllocatorand kept it alive instandby_allocator_keepalive_, so the replica'sweak_ptrnever expired and it looked like a live,COMPLETE, readable replica everywhere.But
DummyBufferAllocatorholds no allocation state, and the snapshot carries no NoF segment registry:MountNoFSegmentemits noSEGMENT_MOUNToplog entry, andStandbySegmentInfohas no base-offset field. So when the namespace is later mounted,ScopedNoFSegmentAccess::MountSegment()builds a fresh, empty allocator over the whole[base, base+size)(segment.cpp, viaCreateBufferAllocator). It considers the offsets held by restored replicas free and can hand them to a different(tenant_id, user_key). Nothing validates that two keys map to the same physical range, so the old descriptor keeps reportingCOMPLETEwhile the bytes underneath belong to another object: a silent wrong-data read.Memory replicas already have both halves of the protection this needs.
ReMountSegment()collects the restored descriptors for the remounted endpoint, callsBuildRegionLiveAllocations()+ImportOffsetBufferAllocator()/ImportCachelibBufferAllocator(), and swaps in an allocator that already owns those ranges viaReplaceAllocators. Until that import happens, restored replicas whose segment is absent are recorded ininvalid_replica_endpoints_so they cannot be read. The NoF path had neither.2. The fix
Quarantine the NoF endpoint, mirroring the memory path.
get_descriptor()therefore still returns the complete descriptor includingtransport_endpoint_(which it reads off the allocator), andhas_invalid_nof_handle()stays false, soBuildStaleHandleCleanupPlan()no longer classifies the replica as stale andClearInvalidHandles()leaves it alone. The state a future import needs survives.invalid_nof_replica_endpoints_, whichIsReplicaReadable()consults, so the replica is excluded fromGetReplicaList,GetReplicaListForAdmin,BatchGetReplicaListand the eviction paths.MountNoFSegment()andReMountNoFSegment()refuse a namespace whosete_endpointornameis quarantined, returningUNAVAILABLE_IN_CURRENT_STATUS. Nothing can alias the offset while the descriptor survives.The NoF keepalive and quarantine sets are deliberately separate from
standby_allocator_keepalive_andinvalid_replica_endpoints_.ReMountSegment()erases both of those by endpoint when a memory segment comes back, so a NoFte_endpointthat collides with a memory segment alias would otherwise be un-quarantined, and its placeholder dropped, by an unrelated memory remount. NoF replicas are still checked againstinvalid_replica_endpoints_as before, so this is strictly additive on the read path.Why not simply invalidate the handle, as the first revision did? It closes the read window, but
ClearInvalidHandles()then permanently removes the replica andget_descriptor()loses the endpoint once the allocator expires, so the breadcrumbs a later remount needs are gone. Reaching "restored NoF data is disposable" through an expired weak pointer also decides a #3796 contract question by side effect rather than explicitly.Scope: this is the fail-closed half
Making the data readable again is the second half and is not in this PR. It needs a NoF analog of the memory import path, so that a mount can adopt the restored
(offset, size)ranges, swap in an allocator that owns them, replace the buffers and lift the quarantine. Neitherreplace_nof_buffernor a NoFReplaceAllocatorsexists in the tree today. Happy to fold it in here instead if you would rather see them together.Note this corrects a claim in the first revision of this description. I argued that rebuilding the allocator was not implementable because the snapshot carries no NoF base or capacity. That is true at restore time, but the gate belongs at mount time, where the real
NoFSegmentwithbase,sizeandte_endpointis in hand. The snapshot gap does not block it.3. Files changed
mooncake-store/include/master_service.h—invalid_nof_replica_endpoints_,standby_nof_allocator_keepalive_,IsNoFSegmentQuarantined().mooncake-store/src/master_service.cpp— quarantine on restore;IsReplicaReadable()honours it;MountNoFSegment()/ReMountNoFSegment()fail closed; restore summary logs the quarantine count.mooncake-store/tests/ha/master_service_ha_test.cpp—RestoreQuarantinesNoFReplicas(descriptor intact, endpoint preserved, not served, not reaped),QuarantinedNoFEndpointCannotBeMounted(USE_NOFonly), and the updatedRestoreFromStandbyPreservesNoFBufferDescriptortail.4. Risk and open questions
RestoreFromStandbyPreservesNoFBufferDescriptorkeeps its descriptor assertions, which quarantine satisfies includingtransport_endpoint_. Only its tail, which assertedGetReplicaListserves the replica, is updated, because serving it is the aliasing window. That is the narrow part of the contract I believe has to change; if the project reads [TENT] Add native UB endpoint bootstrap and READ/WRITE data path #3796 as promising readability, this needs a different answer.master_service_ha_test. The mount gate is insideUSE_NOF, and CI builds withUSE_NOF=OFF, soQuarantinedNoFEndpointCannotBeMountedwill not run there.snapshot_mutex_(3) beforesegment_mutex_(6), matching memoryMountSegment(). Both are shared locks and the only callers are the RPC entry points, so there is no nesting.5. How I verified it
IsReplicaReadable()and theVisitReplicasfilters, andBuildStaleHandleCleanupPlan()/ClearStaleHandles(), confirming the stale predicate keys only onhas_invalid_mem_handle() || has_invalid_nof_handle() || has_stale_local_disk_client()and never consults the endpoint sets, which is what makes a quarantined replica survive the sweep.get_descriptor()readstransport_endpoint_off the allocator, which is why the keepalive is what preserves it.MountSegment()(memory) establishes thesnapshot_mutex_before segment-access order the new gates follow.clang-format --style=fileclean on all three files, with a pre-existing unrelated diff in the test file left untouched.scripts/run_ci_test.shcannot run here.master_service_ha_testneeds to be built and run before this merges, and I would treat CI as the gate rather than my reading.