mirror of
https://github.com/kvcache-ai/Mooncake.git
synced 2026-11-05 01:17:55 +00:00
[GH-ISSUE #4710] [RFC]: [Store] HA: graceful leader shutdown on SIGTERM/SIGINT (release the lease before exit) #1199
Labels
No labels
ascend
auto-closed
bug
bug
CI/Build
Common
documentation
enhancement
feature request
good first issue
help wanted
high priority
MetaX
nightly-failure
pull-request
PyTorch Backend
question
RFC
Roadmap
stale
Store
Tests
Transfer Engine
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
kvcache-ai/Mooncake#1199
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Originally created by @tensorcopy on GitHub (Oct 16, 2026).
Original GitHub issue: https://github.com/kvcache-ai/Mooncake/issues/4710
Changes proposed
Summary
In HA mode, SIGTERM ends the master process with no cleanup. When that master is the leader, its etcd lease stays alive until it expires, and only then can a standby acquire leadership and begin its warmup. A planned restart (pod delete, rollout) therefore costs as much as a crash.
This RFC proposes that the HA master handle SIGTERM and SIGINT. It stops taking new work, waits for admitted admin handlers before the release and drains the healthy OpLog writer's ready queue (retaining today's failure behavior), revokes the leadership lease exactly once, and exits within a fixed deadline. A planned leader restart then costs about one warmup instead of lease expiry plus warmup. There are no data-plane changes, no new persistent state and no wire changes. The work is split into two PRs of about 520 and 440 lines (Section 9).
All code references are to
mainat3aa0f716. Related: #3808 (HA roadmap), #4559, #4666, #4706.#4601, merged after
3aa0f716, runs RPC handlers on a worker pool that the serving exit drains after the server future returns and before step 2 of 2.3; the ordered exit keeps that drain. It shifts later line numbers inmaster_service_supervisor.cppandmaster.cpp.1. Current behavior
main()installs SIGINT/SIGTERM handlers only in the non-HA branch (mooncake-store/src/master.cpp:1653-1658). The HA branch builds aMasterServiceSupervisorand returnssupervisor.Start()(master.cpp:1612-1618). Inmooncake-store/srcthese are the only SIGINT/SIGTERM handlers on the master's path, so in HA mode SIGTERM takes its default action and ends the process without running destructors. The etcd backend's Goc-sharedlibrary does not install a SIGTERM handler either: Go installs handlers for asynchronous signals in that mode only whensignal.Notifyis called, and the wrapper does not call it.mooncake-store/src/ha/leadership/master_service_supervisor.cpp:330) has no stop condition. It leaves only throughreturn -1: on a fatal backend error, or when the RPC listener fails to start (:667-680).DEFAULT_MASTER_VIEW_LEASE_TTL_SEC(5 s,mooncake-store/include/types.h:101) and creates themaster_viewkey under that lease (mooncake-store/src/ha/leadership/backends/etcd/etcd_leader_coordinator.cpp:216-228), so a revoke deletes the key. For an acquired leadership session, normal cleanup revokes throughReleaseLeadership(etcd_leader_coordinator.cpp:567-613) or the destructor (:155,:653-704). SIGTERM's default termination does not run those cleanup paths.WarmupLeadership,master_service_supervisor.cpp:189-217; the deadline is set at:191).master_viewkey (etcd_leader_coordinator.cpp:436-531), in 1 s slices (master_service_supervisor.cpp:31,:392-393,:409-410). It sees a delete at once (within one 1 s slice if the old leader was still warming up).With lease TTL
T, the two cases compare like this:Tafter the killT, then restore and serving startupT, then restore and serving startupThe saving is up to
Tper planned leader restart. It grows with the TTL, which #4706 makes configurable up to 60 s.2. Proposed behavior
2.1 Signal intake
main()installs SIGINT and SIGTERM handlers in the HA branch, beforesupervisor.Start(), so no lease can be acquired without them. If installation fails, the master refuses to start.sigactionandSA_ONSTACK. The etcd and k8s HA backends are Goc-sharedlibraries in the master process (mooncake-common/etcd/CMakeLists.txt:3,CMakeLists.txt:83), and Go requiresSA_ONSTACKon any handler that non-Go code installs (os/signal docs). Thestd::signalcalls in the non-HA branch (master.cpp:1657-1658) are not a model for this._exit(128 + signo)(130 for SIGINT, 143 for SIGTERM), the convention the Transfer Engine watcher already follows (graceful_shutdown.cpp:151). This is the escape hatch for a shutdown that does not finish.std::function<bool()>) that defaults to the process flag, so tests can inject their own.The Transfer Engine already has a SIGTERM/SIGINT watcher that uses a self-pipe (
mooncake-transfer-engine/src/graceful_shutdown.cpp:146-167,:181-211). The master's HA path does not construct aTransferEngine(inmooncake-store/src, onlyclient_service.cppdoes), andmooncake_masterdoes not linktransfer_engine(mooncake-store/src/CMakeLists.txt:712-725), so that watcher is not active here. Its design is a reference for open question 4.2.2 Supervisor: a shutdown request is handled like a lease loss, then the loop exits
The loop already has a release path for every phase after acquisition, because each failure calls
ReleaseLeadership(HandleLeadershipPhaseError,master_service_supervisor.cpp:179-187, and theLogLeadershipReleaseWarningcall sites). The change makes each phase notice the request and then leaves the loop instead of retrying.:330-338):343-419)TryAcquireLeadership; exits with no lease held:378-383):428-458)renewal.Lost()check (:482):494-516)Tin 1 s sleepsWarmupLeadershipalso checks the request each iteration and returns early:556-597)renewal.Lost()(:586):599-705)ServingStateGate(mooncake-store/include/ha/leadership/master_service_supervisor.h:14-45) before listener startup and local readiness activation; the backend publication stays outside the gate and uses a request check before the call:725)async_start(:656) and joined before the serving objects are destroyed, polls the request and callsserving_state.RequestShutdown(...)with the same fence the leadership monitor uses (:632-639): readiness off, leader label cleared, state standby,server.stop()The gate is sticky and both activation and the fence go through it (
:656,:690), so a request that races readiness publication cannot leave the master serving. (PublishServiceReadyat:688runs outside the gate and can still write the ready address intomaster_viewafter the fence; a successful revoke deletes it after cleanup, and on forced exit before the revoke it can remain until TTL expiry.) If a lease-loss fence wins the gate first, the trigger'sRequestShutdownreturns false, so the serving exit tests the process flag, not which fence ran.Catch-up, promotion and restore cannot be interrupted today. A request during one of them is handled when the call returns, and the deadline in 2.4 bounds the wait.
2.3 Ordered exit of a leader
On shutdown, a session that holds the lease runs this sequence once, on the supervisor thread. It is the full serving form; in earlier phases the exit helper skips the steps with nothing to act on:
:728).:729). A healthy writer commits its queued entries before its thread exits (mooncake-store/src/ha/oplog/ordered_oplog_writer.cpp:521-527); a writer already in retry abandons its batch (:563-564,:671-675), as it does on lease loss today.DeactivateServingStateclears serving state and the leader label (:235-240).mooncake-store/src/master_admin_service.cpp:328-337) before the revoke. Per the comment atmaster_admin_service.cpp:871-873,stop()drains the HTTP executor; the quota handlers are synchronous and run on that executor. This matters because an admitted admin request can still write to etcd without a lease check: the quota write routes (PUT and DEL,master_admin_service.cpp:1469-1478) reachEtcdTenantQuotaPolicyStore::Save, which is a plainEtcdHelper::Put(mooncake-store/src/tenant/quota_policy_store.cpp:431-444). Turning off readiness rejects new requests but does not finish admitted ones. PR A will test that an admitted quota write finishes before the revoke. If the stop path does not provide that guarantee, PR A must add the required wait before it can claim graceful release.ReleaseLeadershipcancels it.ReleaseLeadershipruns exactly once, and its result is kept for the exit status. A second call is not a no-op: after the first call clears the keepalive state, it sends anotherRevokeLeasefor the same lease (etcd_leader_coordinator.cpp:574-612). The wrapper treats lease-not-found as success (etcd_wrapper.go:442-446), so the extra call only costs a round trip of up to 5 s inside the deadline, and a release after the lease already expired still counts as released.StopStandbyends standby replication (mooncake-store/src/ha/standby_controller.cpp:252-263) and the loop returns.On a path where today's code releases and re-enters standby after a fence (for example the startup cancellation at
:657-666), the shutdown check runs first, so those calls do not run on a shutdown.Each session records any release attempt and its result, including an attempt already in progress when shutdown is requested. Shutdown consumes that result instead of releasing the same session again. Error exits check the request before service destruction and standby re-entry.
The successor fences the old producer through
ClaimProducerView(master_service_supervisor.cpp:268-290) as it does today. The only new thing is that the lease is revoked instead of left to expire.A shutdown exit does not re-enter standby and does not run the #4559 cursor preservation (
return_to_standby,:460-480). That preservation restores an in-process baseline for the next standby start in the same process, and the process is about to exit.2.4 Deadline
An independent watchdog thread bounds the whole shutdown. It is armed next to the handler installation and stays armed through the supervisor's shutdown and
main()'s bounded cleanup aftersupervisor.Start()returns.Dis 15 s._exitwith a nonzero status. It writes nothing to stderr first, because a write to a stalled pipe could block and defeat the bound._exitdirectly, without flushing or logging. Normal shutdown may callgoogle::FlushLogFiles(google::GLOG_INFO)on the supervisor thread while the watchdog remains armed. Forced exit can lose buffered logs; preserving the exit bound takes priority.If the watchdog fires before a confirmed revoke, the lease may remain until TTL expiry. If release has already succeeded, the successor need not wait for the old lease.
2.5 Exit status
128 + signo(130 SIGINT, 143 SIGTERM)A nonzero status means failure or deadline expiry; it does not by itself prove that the lease is still held.
3. Scope
master.cpp(HA branch only),master_service_supervisor.{h,cpp}, and a small process-level shutdown utility.ReleaseLeadership(mooncake-store/src/ha/leadership/backends/k8s/k8s_leader_coordinator.cpp:334-365). The tests in this RFC cover etcd only.ReadCurrentViewandTryAcquireLeadershipboth returnUNAVAILABLE_IN_CURRENT_MODE(mooncake-store/src/ha/leadership/backends/redis/redis_leader_coordinator.cpp:140-150), so the supervisor exits at its first iteration. Nothing changes there.4. Non-goals
main().Putafter the release. That window exists today and is out of scope here; it could be a follow-up.5. Relationship to #4559
#4559 ("fix(store): keep HA leadership through large oplog catch-up") moved lease renewal to a side thread that starts at acquisition (
LeadershipRenewalGuard,master_service_supervisor.cpp:92-154, started at:428), addedrenewal.Lost()checks after catch-up (:482) and after restore (:586), and keeps the promotion cursor for a retry in the same process (:460-480).This design targets that structure directly:
ReleaseLeadershipstops it immediately before the single revoke (etcd_leader_coordinator.cpp:600-604,:612).renewal.Lost()checks and reuse their exits.6. Relationship to #4666
#4666 (shutdown handoff for a standalone master, by @Juhyun-Kim-Memphis) and this RFC are complementary. #4666 states that the handoff "cannot be enabled together with HA and does not change the HA path"; this RFC changes only the HA path. Their features do not overlap.
Both need a process-level SIGTERM path and a watchdog with a deadline: #4666's delivery plan lists "SIGTERM path, admission, watchdog" in A5. This RFC adds two parts that #4666 does not describe: an async-signal-safe request flag and a second-signal escape hatch. The fork flag table in the #4666 appendix (§E) lists
--handoff_shutdown_deadline_secwith a default of 45; the appendix notes that upstream names and defaults are still open. To avoid two versions:ha/. Whichever PR lands first owns it and the other reuses it. If #4666's A5 lands first, this work adapts to it.master.cpp:1653-1658.One observation in the #4666 appendix (§F, item 5) affects this design: a graceful exit of an upstream standalone master with a 12M-object index took 21.9 s, mostly spent destroying the index. In the HA serving path the lease is released before the index is destroyed (
master_service_supervisor.cpp:731runs before:734), so teardown cannot delay failover. It can still run pastD, which is open question 5.7. Relationship to #4706
#4706 adds
--ha_leader_lease_ttl_sec(2-60, default 5), carried fromMasterConfigto the etcd coordinator'sGrantLeaseand the session'slease_ttl. The two changes are independent in code: #4706 touchesBuildHABackendSpec, the coordinator's acquire path and the flag sections ofmaster.cpp, while this work touches the loop body,Start()and the HA branch ofmain()(master.cpp:1612-1618). Whichever lands second needs at most a small textual rebase. If #4706 lands first, the deployment notes in the second PR below refer to the flag instead of the constant.8. Deployment notes
Let
Tbe the lease TTL,Dthe shutdown deadline (15 s proposed),Pthe time of any preStop hook, andGthe pod'sterminationGracePeriodSeconds. On Kubernetes,Gdefaults to 30 s, the countdown includes the preStop hook, and the kubelet sends SIGKILL when it expires (Pod Lifecycle).Ggreater thanP + Dplus a margin of a few seconds. With the defaults (G= 30,D= 15) that leaves room for a preStop of up to about 10 s. The master itself needs no preStop hook.Tdoes not enter that inequality, because a clean release does not wait forT.Tmatters on fallback paths only if the old lease has not already been revoked: after forced exit the successor may wait for its remaining TTL.Gis too small, the kubelet kills the master during cleanup and the restart becomes today's crash failover. That is slow but safe.Dlonger than today. The second signal shortens it./healthand metrics stop answering during the shutdown.9. Delivery plan
high_availability_testalready doesterminationGracePeriodSecondsWithout B, a shutdown stuck in catch-up or restore waits until the phase returns, the second signal, or the kubelet's SIGKILL at
G. That is why B follows A directly. If maintainers prefer, the two can be reviewed together or merged as one.10. Test plan
PR A:
128 + signo.mooncake-store/tests/ha/leadership/high_availability_test.cpp, which already runs a supervisor in a child process against a real etcd (SupervisorChildProcess,:179-234;RunSupervisorChild,:264-301). Today that test ends the child only with SIGKILL (:224). New cases:master_viewkey is gone right after exit, well beforeT;The existing child harness runs with the OpLog disabled (
high_availability_test.cpp:297). PR A adds an OpLog-enabled serving-leader case, with a nonemptycluster_idand actual queued records, verifying that the healthy writer commits those records before release and that the successor can replay them.PR B:
Dplus a small slack; a disarmed watchdog never fires; expiry still ends the process when stderr is a full pipe, and while the supervisor thread is blocked inside a log flush.mooncake-store/tests/ha/leadership/etcd_leader_hang_e2e.sh: two realmooncake_masterprocesses and a local etcd. SIGTERM to the leader must exit 0 and let the standby acquire before the old lease's TTL would have run out; a SIGKILL control must wait for the lease's remaining TTL.11. Rollout risks
D. Scripts that kill a master and immediately reuse its ports may need to wait for exit.D(Section 8).leader_label_reconciler.h:61-83); a shutdown exit can return before the clear is applied. PR A either waits for it or documents it.12. Questions for maintainers
mooncake-store/, not underha/.Dbe a flag or a constant? If a flag, should HA and the handoff share one flag with mode-specific defaults, or keep separate flags?128 + signofor the second signal acceptable (130 for SIGINT, 143 for SIGTERM, as the Transfer Engine watcher does atgraceful_shutdown.cpp:151), or do you prefer one fixed status?sigwaitthread copied fromreal_client.cpp:812-857is not sufficient here: the Go runtime's threads start beforemain()can block anything, and the Go docs say non-Go code should not change the signal mask on Go-created threads.)_exit(0)after the ordered shutdown steps complete and release succeeds (or no lease is held), instead of destroying the index? Normal log flushing would run while the watchdog remains armed. Watchdog expiry means "shutdown did not finish by the deadline"; it does not prove that the lease remains held. The alternative is normal destruction with a largerD.high_availability_test.cppalready has aFakeLeaderCoordinatorat:111; it is not injectable into the supervisor.)AI assistance disclosure
An AI coding assistant (Claude, by Anthropic) helped read the code and draft this RFC. A human author checked every code reference against
mainat3aa0f716and is responsible for the design and, in each PR, for every changed line.Before submitting a new issue...
@github-actions[bot] commented on GitHub (Oct 16, 2026):
Thanks for opening this issue, @tensorcopy!
10591164A maintainer will triage this when possible. To help us respond faster, please include:
Useful links: Documentation · Contributing guide
@he-yufeng commented on GitHub (Oct 18, 2026):
Confirmed the current-behavior claims against main: the only SIGINT/SIGTERM handlers on the master path sit in the non-HA branch of master.cpp (1691-1692 now; the line drift you flagged from #4601 checks out), and MasterServiceSupervisor installs none, so in HA mode a SIGTERM really does cost lease expiry plus warmup instead of just warmup. The direction is worth pursuing.
Two design questions worth answering in the first PR's description or here:
The revoke-once discipline covers the lease, but what bounds the split-brain window on the data plane? After revoke, the standby can acquire leadership and start serving while the old leader may still hold in-flight client writes that were admitted before the drain began. "Stops taking new work" plus "waits for admitted admin handlers" covers the admin surface; is the plan to also quiesce or fence the data-plane path before revoking, or is the argument that the lease fencing token already makes those writes harmless?
For the two-PR split, landing signal handling plus drain first (without revoke) is safe to merge and observable on its own; the revoke half then becomes a small behavioral delta that is easy to roll back. Is that the order you have in mind for the 520/440 split?
No concerns about no data-plane or wire changes as scoped. Happy to review the first PR when it is up.
@tensorcopy commented on GitHub (Oct 18, 2026):
Thanks for checking the claims against main — answers to both questions:
1. Data plane before the revoke. The ordered exit keeps the serving-exit order that main already has, and releases only at the end. In
master_service_supervisor.cppon current main:server.stop()goes through the sameServingStateGatefence as the leadership monitor, so the listener admits no new request;worker_pool.reset()(:741, from #4601) then waits for handler work that was already admitted;StopBatchOpLogWriter()(:745) lets a healthy writer commit its ready batches and joins the writer and callback threads, and after thatCommitreturnsUNAVAILABLE_IN_CURRENT_STATUS(ordered_oplog_writer.cpp:316-317). Only then doesReleaseLeadershiprun (:747). The graceful shutdown adds the admin drain before that release. So on the graceful path, the old leader has no admitted data-plane work left when a successor can acquire.The fencing token is the second line, for exits where the drain does not finish (watchdog, second signal, SIGKILL). With the OpLog enabled, every batch write compares
producer_view(oplog_batch_storage.cpp:376-381), and the successor claims it before catch-up and promotion (master_service_supervisor.cpp:269-290, called at:431). A late batch from the old term then fails its compare and the writer goes terminal. Warmup is unchanged, so a leader whose lease merely expired is covered as today. Deletingmaster_viewis not itself a write fence: it does not fence direct client/store data transfers or the plain admin quotaPut.Residuals that stay as today: calls drained after
server.stop()take effect, but their responses can be lost (same as on lease loss); with the OpLog off there is no producer fence. #4745's tests pin the ordering directly: a serving leader exits 0 on SIGTERM withmaster_viewstill present right after exit and gone by TTL, and the OpLog case shows queued entries committed in the durable log after exit and counted by a successor's restore.2. Order. Adopted — with one addition. #4745 is the signals, the phase checks, the ordered drain and the watchdog, and it makes no deliberate lease release: the process exits 0 and the lease expires by TTL, so failover still costs the lease TTL plus warmup until the follow-up. The watchdog sits in this PR so the drain itself is bounded (15 s; a second signal ends it at once). One detail that made the explicit split necessary:
~EtcdLeaderCoordinatorrevokes any held lease (etcd_leader_coordinator.cpp:155, throughShutdownKeepAliveThreadat:694), so a "drain without revoke" PR would either need new code to suppress that revoke or leave it implicit at destructor order. This PR therefore ends in the terminal in-loop exit that RFC Q5 asked about, after its explicit cleanup and before any unwinding; the follow-up inserts the singleReleaseLeadershipat the one marked point immediately before that same exit, so reverting it restores this PR's behavior exactly.The follow-up also stops the renewal guard and adds the SIGTERM-vs-TTL failover comparison and the deployment-guide notes. Thanks for offering to review — #4745 is up; @he-yufeng, it would be great if you could take a look.