Repository navigation
[Store] Add restore lifecycle state machine and worker admission - #4736
Conversation
d21bc56 to
c0e2c0c
Compare
|
CI status update: To keep this branch verifiable, it now also carries the one-line fix from #4742 ( |
There was a problem hiding this comment.
Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.
An organization owner can choose the repository organization as the billing source for reviews requested by members with a paid Copilot license. Learn about Copilot code review billing.
c0e2c0c to
2f2f677
Compare
|
Rebased onto current |
2f2f677 to
110691b
Compare
|
Concurrency self-review of the slice, as a follow-up commit ( 1. Overlapping restores. The wrapper was not a single-flight admission: it loaded the state, then stored Admission is now the transition itself: 2. What worker admission does and does not guarantee. It is evaluated once per worker iteration, so it gates the start of business work, not work already in progress: a worker that passed the check before the window opened finishes its current iteration. In this slice that is safe only because the restore core holds 3. Failure / retry / stop boundaries. Covered and verified:
Re-ran everything on the new commit: lifecycle 9/9, |
|
@kunkunblueberry @he-yufeng — a scoped test-reliability finding on
Independent local validation: compiled a standalone C++20 scheduling harness with Zig 0.15.2's C++ driver, using the same admission-CAS/state ordering and a stub restore core. Promises forced each allowed schedule; a terminate handler made the result observable: This is not a native Mooncake/gtest run, a measured CI flake rate, or HA/RDMA validation. It isolates the scheduler/ #include <cstdlib>
#include <exception>
#include <future>
#include <thread>
void fatal_assertion_path() {
std::promise<void> finished;
auto done = finished.get_future();
std::thread first([&] { finished.set_value(); });
done.wait();
return; // Fatal ASSERT_* returns too; first is still joinable.
first.join();
}
int main() {
std::set_terminate([] { std::_Exit(86); });
fatal_assertion_path();
}Possible small fix without a new production hook: the existing
This would make the regression test the intended overlap rather than scheduler luck, while preserving the PR's explicit distinction between worker admission and actual quiescence. Happy to keep this as an author-side adjustment rather than open a competing lifecycle PR. AI-assisted review; the scheduling harness was executed locally, with the native-test limitation above. |
|
Both paths are real, and the terminate behaviour is the worse half of it: Deterministic window instead of a lucky one. The test now takes Cleanup before fatal checks. The overlapping call runs on its own thread with a bounded wait. Evidence. Lifecycle suite 9/9. The specific case 50/50 with Also worth saying explicitly, since it constrains the next slice: the test now depends on admission preceding the core lock. If a later change moves admission inside the core critical section, this test will fail rather than silently pass, which is the right failure mode — the single-flight property would genuinely be gone at that point. |
37e42ab to
95750b2
Compare
95750b2 to
0e5b4b3
Compare
|
CI note + review request. The last CTest failure was infrastructure, not this change. The job died in That is @he-yufeng @Icedcoco — this is ready for review. It is the focused slice @Icedcoco asked for: restore lifecycle + admission, no lock removal. Three commits, ~600 lines including tests:
Three things I would most like an opinion on:
Non-goals, to keep the review bounded: no remount, no LOCAL_DISK, no lock-site changes, no oplog changes. Local results on the rebased tree (gcc 13 Release): lifecycle 9/9, |
|
Thanks for the update. I appreciate the work on this lifecycle foundation, and I agree that introducing it in stages makes sense as the later slices add admission control. I had one clarifying question about what I’m mainly hoping to understand the lifecycle contract that the later admission-control work will build on. Thanks again for the thoughtful change. |
0e5b4b3 to
6b5a6c5
Compare
|
Thanks — that reading is right, and it exposed a real inconsistency that I fixed rather than deferred.
One correction to the framing, though. The non-HA path does commit before RPC registration, but the HA supervisor does the opposite:
For HA this is not a style choice: a failed publication never falls back to What the next slices can rely on is the same on all three paths: once Changes in
Worker behaviour is unchanged: Local results (gcc 13 Release): lifecycle 9/9, |
First slice of RFC kvcache-ai#4700 (Rethinking the Master Global State Lock). PR kvcache-ai#4406 removed the legacy Master snapshot path but kept snapshot_mutex_ (renamed master_state_mutex_) and its ~69 guard sites, many of which now protect lifecycle interactions that merely accumulated around the original barrier. This slice adds the explicit Master lifecycle proposed in RFC Section 7.1 without changing any locking behavior: - RestoreLifecycleState {kConstructed, kRecovering, kReady, kServing, kFailed} with transitions wired where they exist today: the HA promotion restore moves Constructed -> Recovering -> {Ready, Failed}; the supervisor's ActivateServingState takes Ready -> Serving; the non-HA path opens Constructed -> Ready -> Serving explicitly. - RestoreFromStandbyState becomes a lifecycle wrapper around the existing implementation (renamed RestoreFromStandbyStateCore). Entry from Serving is refused per the RFC's proposed no-cycles-out-of-Serving constraint; the existing restore retry semantics (replacement restore from Ready or Failed, exercised by the HA tests) are preserved. - AdmitsBusinessWorkers() pauses the business background workers (eviction, client monitor, task cleanup, job dispatch, NoF heartbeat, dynamic replication, replica cleanup) while a promotion restore is in flight, independent of master_state_mutex_. kConstructed keeps workers admitted so tests and embedded users keep the constructor-starts-workers behavior; tightening that is left to the follow-up slices. master_state_mutex_ stays exactly where it was; this slice changes no locking behavior. The follow-up slices (remount prepare/rebind/publish, endpoint readiness ownership, LOCAL_DISK registration admission) build on this state machine before the global lock is removed from ordinary paths. RFC: kvcache-ai#4700
Self-review of the lifecycle slice found one real interleaving: the wrapper loaded the state and then stored Recovering as two separate steps, so two overlapping restores could both pass admission, and the first one to finish would publish Ready while the second was still rewriting metadata. Workers are admitted on Ready, so that window also resumed business work against a half-rebuilt state. Admission is now the transition itself: compare_exchange into Recovering. A second restore that overlaps an in-flight one is refused with UNAVAILABLE_IN_CURRENT_STATUS instead of running. Sequential replacement restores (the retry path the HA tests exercise) are unchanged, because they start after the previous attempt has published Ready or Failed. Also documents what AdmitsBusinessWorkers() does and does not guarantee: it is evaluated once per worker iteration, so it gates the start of business work rather than draining work in progress; in this slice the exclusive master_state_mutex_ in the restore core is what actually excludes in-flight work, and a slice that removes that lock must add an explicit quiesce step rather than lean on this predicate. Test: MasterServiceTest.RestoreLifecycleOverlappingRestoreIsRefused starts a 20k-object restore, waits for Recovering, and asserts the overlapping call is refused and the in-flight one still reaches Ready with workers admitted.
Review found two failure paths in RestoreLifecycleOverlappingRestoreIsRefused that depended on the scheduler rather than on the code under test, and both returned from the test with a joinable std::thread, so the destructor called std::terminate instead of reporting a normal failure: - the first restore could finish before the parent observed Recovering; - the parent could be descheduled after observing Recovering, letting the first restore publish Ready, which turns the second call into a legal sequential replacement. The window is now held open deterministically: the test takes the restore core's exclusive master_state_mutex_ through MasterServiceTestPeer, launches the first restore, and waits for Recovering. Admission is a compare_exchange that runs before the core takes that lock, so Recovering is published while the core cannot make progress. The overlapping call is then refused at admission without ever touching the core lock, and one object is enough. The overlapping call runs on its own thread with a bounded wait, so a future regression that turns admission into a blocking wait fails the test instead of hanging the suite. The lock is released and both threads are joined before any fatal assertion, so a failed expectation reports normally.
EmbeddedMaster published its service while the restore lifecycle was still Constructed, so the state machine described a different thing depending on which entry point was used. This closes that gap and writes down the contract the follow-up admission slices will build on. Serving is a commitment about restore, not an observation about reachability: after it, this instance refuses restore for the rest of its life. It does not mean the RPC endpoint is already accepting requests. The commit point lands on both sides of the publish boundary, and the placement is per-path: - non-HA master: Constructed -> Ready -> Serving before RegisterRpcService, because there is no restore path and committing early costs nothing; - EmbeddedMaster: same, now aligned with the non-HA path; - HA supervisor: Ready -> Serving after PublishServiceReady succeeds. A failed publication destroys the per-term service through release_service and the retry builds a fresh instance, so the retry path never needs a Serving -> Recovering edge. The ActivateServing doc comment said "called once the RPC service is registered and exposed", which the non-HA path already contradicted. It now states the contract above, including the per-path commit points. Worker behaviour is unchanged: kConstructed and kServing both admit business workers, so the embedded master's background threads behave exactly as before. The only difference is that the state now says what it always meant. Test: EmbeddedMasterTest.StartCommitsRestoreLifecycleBeforeServing asserts kServing after Start() and again after a Stop()/Start() cycle, since a restart builds a fresh service and must re-commit rather than inherit.
6b5a6c5 to
3300f17
Compare
Description
First implementation slice of the RFC discussion in #4700 (Rethinking the Master Global State Lock). See the issue for the full audit (69 guard sites, interleaving risks, alternatives A/B/C). This PR implements Section 7.1 (restore lifecycle) only.
Motivation: PR #4406 removed the legacy Master snapshot path but kept
snapshot_mutex_(renamedmaster_state_mutex_) and its ~69 guard sites, many of which now protect lifecycle interactions that merely accumulated around the original barrier. Before any of those guard sites can be removed, the restore window needs an explicit owner.What this slice does:
MasterService::RestoreLifecycleState(kConstructed,kRecovering,kReady,kServing,kFailed) with the transitions wired where they exist today: HA promotion restore movesConstructed -> Recovering -> {Ready, Failed}; the supervisor'sActivateServingStatetakesReady -> Serving; the non-HA path andEmbeddedMasteropenConstructed -> Ready -> Servingexplicitly.RestoreFromStandbyStatebecomes a lifecycle wrapper around the existing implementation (renamedRestoreFromStandbyStateCore). Entry fromServingis refused per the RFC's proposed no-cycles-out-of-Serving constraint; the existing restore retry semantics (replacement restore fromReady/Failed, exercised by the HA tests) are preserved.AdmitsBusinessWorkers()and pauses the business background workers (eviction, client monitor, task cleanup, job dispatch, NoF heartbeat, dynamic replication, replica cleanup) while a promotion restore is in flight, independent ofmaster_state_mutex_.kConstructedkeeps workers admitted so direct construction in tests keeps the constructor-starts-workers behavior; tightening that (pause-until-activation) is deliberately left to the follow-up slices once callers carry explicit activation.What
ServingmeansServingis a commitment about restore, not an observation about reachability. After it, the instance refuses restore for the rest of its life; it says nothing about whether the RPC endpoint has accepted a request yet. The commit point lands on both sides of the publish boundary, and the placement is per-path:RegisterRpcServiceEmbeddedMasterRegisterRpcServicePublishServiceReadysucceedsA failed HA publication never falls back to
Recovering:release_service()destroys the per-termWrappedMasterService, and the retry builds a fresh instance that starts atConstructed. That is why the retry path needs noServing -> Recoveringedge.The invariant the follow-up admission slices can rely on is the same on all three paths: once
Servingis observed, restore cannot happen for that instance, so an admission decision taken in that state needs no restore-window reasoning. It must not be used as a "service is up" flag.master_state_mutex_stays exactly where it was. This slice changes no locking behavior — it is the foundation the follow-up slices (remount prepare/rebind/publish, endpoint readiness ownership, LOCAL_DISK registration admission) build on before the global lock is removed from ordinary paths.RFC: #4700
Suggested Merge Priority
Module
mooncake-transfer-engine)mooncake-store)mooncake-conductor)mooncake-reshard)mooncake-ep)mooncake-pg)mooncake-integration)mooncake-p2p-store)mooncake-wheel)mooncake-common)mooncake-rl)Type of Change
How Has This Been Tested?
Release build (gcc 13, Ninja) plus a new dedicated unit-test target. Rebased onto
main@b7a50b39:Test commands:
Test results:
New
master_service_restore_lifecycle_testcovers: fresh service startskConstructedwith workers admitted; successful restore reacheskReady; restore afterActivateServingis refused (UNAVAILABLE_IN_CURRENT_STATUS, noServing -> Recoveringedge); retry after a failed restore still reacheskReady(HA-tested retry semantics preserved); the non-HAConstructed -> Ready -> Servingpath; the worker-admission predicate across all five states; and a scheduler-independent overlapping-restore admission test.New
EmbeddedMasterTest.StartCommitsRestoreLifecycleBeforeServingasserts the embedded path reacheskServingbefore it serves RPCs, and re-commits after aStop()/Start()cycle rather than inheriting the old state.Full disclosure on
master_service_ha_test, which reports 4 failures in this environment:main@b46254dewith this PR's files reverted, so they are environmental rather than caused by this change:WeightMetadataAcceptedTerminalFailureCompletesWithoutPublishTimeout,BatchEvictQueueSaturationDoesNotRequestAnotherPass,BatchEvictStopsWithoutRetryWhenOpLogWriterIsFenced,BatchEvictCommitFailureRestoresRemovedReplicas. All four areCaptureStderrassertions that observe an empty capture. I verified the reproduction on unmodified main rather than root-causing the glog/stderr interaction in this build.None of the four exercise the lifecycle code this PR adds, and CTest on CI is green.
Checklist
./scripts/code_format.shAI Assistance Disclosure
The change was drafted with AI assistance (state-machine design, worker gating points, and the new test suite); the human submitter reviewed every changed line and can defend the change end to end. Build and test results above were produced on a private build host (gcc 13, conda-forge deps, rdma-core v46).