[GH-ISSUE #4666] [RFC]: [Store] Shutdown handoff: index-preserving planned restart for a standalone master #1186

Open
opened 2026-10-29 21:23:44 +00:00 by bsakaguchi · 3 comments
Owner

Originally created by @Juhyun-Kim-Memphis on GitHub (Oct 15, 2026).
Original GitHub issue: https://github.com/kvcache-ai/Mooncake/issues/4666

Changes proposed

Summary

A standalone (non-HA) Mooncake master loses its whole object index on every restart. In our deployment, almost every master restart is planned: an operator rolls a new image or changes a flag, and the process gets SIGTERM first.

This RFC proposes an opt-in shutdown handoff for that case. On SIGTERM, the old master stops serving, stops its background mutators, writes its final state to a local directory, and exits. The next master claims that state once, rebuilds the index and allocator state, and only then opens RPC.

The proposal rests on three properties:

  1. A standalone master keeps its index across a planned restart.
  2. Zero steady-state cost. There is no background work, no extra memory, and no new wait on the request path. The feature is off unless --handoff_dir is set.
  3. A failed or partial handoff can only lose entries (cache misses). It never serves an entry whose memory may belong to something else.

It cannot be enabled together with HA and does not change the HA path. We run it in a fork and would like to upstream it in small PRs (Section 7), after hearing the maintainers' view on the direction and the failure policy (Section 5).

Related: #3774, #3808, #3167, #3771, #4406.

1. Problem

After #4406, a standalone master has no restart path that keeps the index. The remaining path is HA: batch OpLog in etcd plus standbys (#3808, #3167), which requires enable_ha with etcd. The removed legacy path did not keep the index in practice either: its default restore dropped objects whose lease had expired (#3761), and on 245e7106 it kept 0 of 25M keys.

In #3774 (an HA deployment), @Icedcoco explained that the batch OpLog design rebuilds the index from durable etcd history, and that recovery must fail closed. We agree with both points. This RFC is about deployments that do not run HA.

What this costs us. One production cell has 12 stores and up to about 25M keys. In the 30 days before early October 2026, its master was replaced 6 times. All 6 were operator rolls, with no crash. Each roll dropped the whole index, and after one of them the cell took about 8 minutes to refill 17.3M keys. Because a master roll empties the cache, we also postponed rolling a master image that carried upstream fixes.

The restart that hurts is the one the operator starts on purpose, which is also the easiest one to make safe.

2. Proposal

Configuration. --handoff_dir=<path> names a directory on the master node's local disk. It is empty by default, which disables the feature. Config validation rejects it together with --enable_ha before any worker or server starts. The first version supports only the OFFSET allocator; with any other allocator, the master logs the reason and starts empty.

Shutdown (old master, on SIGTERM).

  1. Stop the RPC and admin servers, and wait for in-flight requests.
  2. Quiesce: stop and join every background thread that changes metadata or allocator state. From here on nothing allocates or frees store memory, because the master is the only allocator.
  3. Admission: check the cgroup memory budget for the write and for the later restore. If it does not fit, skip the snapshot; the next start is then empty, as today.
  4. Write the objects with their COMPLETE replicas, the segment identities, and the quarantine ranges. The write uses the batch snapshot chunk codec (N03) with parallel workers, under a watchdog deadline shorter than the grace period.
  5. Run fsync, publish READY by rename, and exit.

Startup (new master).

  1. Take a flock on the directory. Refuse to restore if a POISON marker shows that two masters shared it.
  2. Rename READY to CLAIMED before allocating anything, so each snapshot is restored at most once. Ignore snapshots older than --handoff_max_age_sec.
  3. Run admission, restore the index, and rebuild the real allocator state. Any error resets the master to empty before RPC opens.
  4. Mount the restored segments as neither readable nor allocatable, then open RPC.

Remount by segment identity. The memory tier needs no client or wire change. Stores call ReMountSegment after NEED_REMOUNT, and the Segment they send already carries a per-mount random id and the base address.

  • If client_id (a random UUID per process), segment id, base, size, and endpoint all match, the segment opens. The check takes about 0.01 ms, whatever the key count.
  • On any mismatch, for example a store that restarted during the gap, all restored entries of that segment are dropped.
  • A store that never returns is removed by the normal client TTL path.

Today's ValidateStandbyRemountSegment compares only name, endpoint, and capacity. That is why A3 adds segment id and base to the persisted segment description.

Not carried, so each becomes a miss: in-flight puts (their ranges are quarantined), offload and promotion tasks, and Transfer Engine metadata. Stores re-publish their Transfer Engine metadata within 0.6–1.6 s.

3. Why this cannot serve wrong data

Principle: a missing entry is a cache miss; a wrong entry is silent corruption. Every failure path here ends in a missing entry.

#3771 was closed because a restored snapshot can point at memory that was reused after the snapshot, which was taken by a master that kept serving. The handoff rules this out by construction. Its snapshot is the final state of the only allocator. Nothing allocates while no master runs. The new master rebuilds the same allocator state before allocating anything, and restored entries open only after their store proves it has not changed.

Mechanism What it prevents
Serving and all background mutators stop before the snapshot Allocation or free after the snapshot
In-flight puts are not exported; their ranges, and ranges the old master still held back, stay quarantined for the remaining hold time (up to put_start_release_timeout) A late RDMA write landing on a new object
Restore only from READY, claimed before allocating; flock, POISON, maximum age Double restore, stale restore, two masters sharing a directory
Identity must match before reads open; a mismatch drops the whole segment Trusting memory of a store that restarted during the gap
Restored objects keep the old master's read lease (export time + default_kv_lease_ttl) Reallocating a range that a reader is still transferring from
Versioned format and per-chunk CRC; any error resets to empty Serving a partially restored index

We wrote the argument as a step-by-step proof that ties each step to the code enforcing it. AI-assisted review passes checked it against our fork's code. We fixed the gaps they found, and the last pass found none. The proof also lists what code cannot enforce. The main items are that a writer stops using an allocation within put_start_release_timeout, and that both masters use the same timeouts Appendix §D.

4. Evidence

Where these numbers come from. The handoff was measured on our fork implementation: a branch on upstream 245e7106, and its port to our production fork (vessl-ai/Mooncake#26). We have ported it to current upstream main (1204367d) as the commit series in Section 7; each commit builds and passes its unit tests, but we have not re-run the rig on that port.

Measurement Result Conditions
Handoff roll 100.00% of pre-roll keys readable after the roll (4,656,623 / 4,656,623), 0 wrong reads. RPC accepted 13.8 s after SIGTERM, clients back at 15.0 s. Production fork port. Kubernetes Recreate, 4 CPU-only stores over TCP with L4 on. 12M keys plus remove/re-put churn, checked by a byte-exact oracle. One run.
Cold restart, same rig 0% retained, 0 wrong reads. RPC accepted at 6.6 s. Same as above.
Store SIGKILLed during the gap Only that store's quarter of the keys was dropped (74.99% kept), 0 wrong reads. 245e7106 branch, same rig design. One run.
25M keys, 12 segments Write 9.9 s, restore with allocator rebuild 13.3 s, peak RSS 26.3 GiB, remount 0.01 ms per store. 245e7106 branch, 14-CPU Xeon pod, no load. Median of 5.

The handoff makes the master gap longer (13.8 s instead of 6.6 s in the same rig). In exchange, clients come back to a full cache, where a cold restart left our production cell refilling for about 8 minutes Appendix §A.

Why not HA for planned restarts? HA covers more, including crashes, but carries structural costs under any implementation: a 3-member etcd cluster to operate (compaction and quota are open as N13), a standby holding a full metadata copy, and an OpLog record on every PutEnd. Our measurement on current main also showed its durability coupled to the steady-state path through eviction's use of the OpLog writer Appendix §B. A deployment that needs only planned restarts pays for that all the time; the handoff costs nothing outside the restart.

5. Failure policy

In #3774, the maintainers said that silently serving an empty or partially reconstructed index "is not an acceptable fallback." They also said that dropping a descriptor needs explicit identity, ownership, and durability semantics. Our answer:

  • Baseline. A standalone master already serves an empty index after every restart. A failed handoff returns to exactly that state, so it adds no new fallback.

  • Durability. The deployment guide calls empty serving a bug in HA because it disagrees with the durable OpLog and poisons later recovery. A standalone master has no durable history to disagree with. The handoff file is not durable state: it has one producer (the quiesced old master), at most one consumer (the next master, which claims it before allocating), and no reader after the claim.

  • Never a wrong index. Restore is all or nothing for each snapshot. Dropping is all or nothing for each segment, and it means the same as that store restarting today. Upstream already skips replicas on unloaded segments in standby restore (#4553).

  • Identity and ownership. An entry belongs to a segment, and a segment belongs to a store process (identity as in Section 2). Store memory is written only into ranges that the master allocated. So the same process with the same mount still holds the recorded bytes, apart from the quarantined ranges. Anything else is treated as possibly reused.

  • Not silent. Every outcome has a result and a reason in the metrics (master_handoff_last_snapshot_result, master_handoff_restore_result, master_handoff_segments_{confirmed,dropped,pending}) and in the logs.

  • Operator choice (proposal). --handoff_failure_policy:

    • serve_empty (default): start empty, as today, and report why.
    • refuse: if a handoff was expected but cannot be used, stay non-serving like the R02 gate until the operator clears the directory. "Expected but unusable" means a READY that is invalid, too old, or over budget, or a failed write recorded at shutdown.

    We are happy to make refuse the default, or to require an explicit choice, if maintainers prefer.

6. Relation to the HA roadmap (#3808)

  • It complements HA and cannot be enabled with it, so master state keeps one authority. It is not a second persistence system: the file is a one-time transfer between two consecutive processes, never listed, published, or selected as recovery state.
  • It is consistent with #3167. The rule that the primary is not a snapshot worker keeps snapshot work away from a serving primary. The handoff writes only after serving has stopped and all mutators have been joined.
  • It builds on upstream work: the N03 chunk codec (#3447), the R02 serving-gate order (#3497), and the per-entry restore semantics of #3806.
  • Parts of it may help HA. At 25M keys on 245e7106, about 195 s of a 279 s snapshot restore was spent in RestoreFromStandbyState's per-chunk loop, which promotion also runs. A shard-parallel restore read the same file in 11.2 s. An identity-only remount takes about 0.01 ms, where the key scan took 26.5 s per store (#4291 is open) Appendix §C.

7. Delivery plan

Each PR builds and passes tests on its own. We will split further wherever reviewers ask.

PR Content Useful alone?
A1 This RFC –
A2 Streaming, shard-parallel restore from batch snapshot chunks, with #3806 semantics Yes, if it fits N07
A3 Segment id and base as compatible fields in StandbySegmentInfo; identity-only remount Yes
A4 An explicit Quiesce() that stops and joins background mutators, separate from the destructor Yes
A5 The handoff: config validation, SIGTERM path, admission, watchdog, READY/CLAIMED lifecycle, restore wiring, failure policy, metrics. Default off. –
A6 L4 (LOCAL_DISK) state in the handoff After track B

Track B (prerequisite for A6 only): a separate bug report and fix for wrong reads in the existing L4 offload path. We reproduced these without any master restart.

Separately from this RFC, we will file the observations in Appendix §F as their own issues with reproductions.

8. Non-goals

Crashes and OOM kills (HA's job), zero-downtime restarts (a gap of about 15 s remains), any change to the HA paths, the memory of a store that restarts during the gap, and allocators other than OFFSET.

9. Questions for maintainers

  1. Failure policy: is serve_empty acceptable as the standalone default, or do you prefer refuse, or an explicit choice?
  2. Configuration: are the flag names acceptable? Should they form a master config group, following #4360?
  3. Segment identity: should segment id and base go into StandbySegmentInfo, which HA shares, or into a handoff-only manifest? Does this touch the roadmap's V-series?
  4. File format: should the handoff write through BatchOpLogSnapshotWriter and the N03 manifest, so that one reader serves both paths?
  5. Persisted lease: restored objects get a lease until the wall-clock export time plus default_kv_lease_ttl. Does this meet the rule for persisted time fields (#3808 invariant 6, #3321)? Or should they start without a lease, protected by the remount gate?
  6. A2 and N07: should the parallel restore be part of N07, or a separate change?

AI assistance disclosure

  • No AI tools were used
  • AI tools were used

Claude Code helped with the design, the fork implementation and tests, the rig runs, the proof review passes, and this draft. The submitter checked every number against the source data and is responsible for defending the design and, in each PR, every changed line.

Before submitting a new issue...

  • Make sure you already searched for relevant issues and read the documentation
Originally created by @Juhyun-Kim-Memphis on GitHub (Oct 15, 2026). Original GitHub issue: https://github.com/kvcache-ai/Mooncake/issues/4666 ### Changes proposed ### Summary A standalone (non-HA) Mooncake master loses its whole object index on every restart. In our deployment, almost every master restart is planned: an operator rolls a new image or changes a flag, and the process gets SIGTERM first. This RFC proposes an opt-in **shutdown handoff** for that case. On SIGTERM, the old master stops serving, stops its background mutators, writes its final state to a local directory, and exits. The next master claims that state once, rebuilds the index and allocator state, and only then opens RPC. The proposal rests on three properties: 1. **A standalone master keeps its index across a planned restart.** 2. **Zero steady-state cost.** There is no background work, no extra memory, and no new wait on the request path. The feature is off unless `--handoff_dir` is set. 3. **A failed or partial handoff can only lose entries (cache misses).** It never serves an entry whose memory may belong to something else. It cannot be enabled together with HA and does not change the HA path. We run it in a fork and would like to upstream it in small PRs (Section 7), after hearing the maintainers' view on the direction and the failure policy (Section 5). Related: #3774, #3808, #3167, #3771, #4406. ### 1. Problem **After #4406, a standalone master has no restart path that keeps the index.** The remaining path is HA: batch OpLog in etcd plus standbys (#3808, #3167), which requires `enable_ha` with etcd. The removed legacy path did not keep the index in practice either: its default restore dropped objects whose lease had expired (#3761), and on `245e7106` it kept 0 of 25M keys. In #3774 (an HA deployment), @Icedcoco explained that the batch OpLog design rebuilds the index from durable etcd history, and that recovery must fail closed. We agree with both points. This RFC is about deployments that do not run HA. **What this costs us.** One production cell has 12 stores and up to about 25M keys. In the 30 days before early October 2026, its master was replaced 6 times. All 6 were operator rolls, with no crash. Each roll dropped the whole index, and after one of them the cell took about 8 minutes to refill 17.3M keys. Because a master roll empties the cache, we also postponed rolling a master image that carried upstream fixes. The restart that hurts is the one the operator starts on purpose, which is also the easiest one to make safe. ### 2. Proposal **Configuration.** `--handoff_dir=<path>` names a directory on the master node's local disk. It is empty by default, which disables the feature. Config validation rejects it together with `--enable_ha` before any worker or server starts. The first version supports only the OFFSET allocator; with any other allocator, the master logs the reason and starts empty. **Shutdown (old master, on SIGTERM).** 1. Stop the RPC and admin servers, and wait for in-flight requests. 2. **Quiesce:** stop and join every background thread that changes metadata or allocator state. From here on nothing allocates or frees store memory, because the master is the only allocator. 3. **Admission:** check the cgroup memory budget for the write and for the later restore. If it does not fit, skip the snapshot; the next start is then empty, as today. 4. Write the objects with their COMPLETE replicas, the segment identities, and the quarantine ranges. The write uses the batch snapshot chunk codec (N03) with parallel workers, under a watchdog deadline shorter than the grace period. 5. Run fsync, publish `READY` by `rename`, and exit. **Startup (new master).** 1. Take a `flock` on the directory. Refuse to restore if a `POISON` marker shows that two masters shared it. 2. Rename `READY` to `CLAIMED` before allocating anything, so each snapshot is restored at most once. Ignore snapshots older than `--handoff_max_age_sec`. 3. Run admission, restore the index, and rebuild the real allocator state. Any error resets the master to empty before RPC opens. 4. Mount the restored segments as neither readable nor allocatable, then open RPC. **Remount by segment identity.** The memory tier needs no client or wire change. Stores call `ReMountSegment` after `NEED_REMOUNT`, and the `Segment` they send already carries a per-mount random id and the base address. - If `client_id` (a random UUID per process), segment id, base, size, and endpoint all match, the segment opens. The check takes about 0.01 ms, whatever the key count. - On any mismatch, for example a store that restarted during the gap, **all** restored entries of that segment are dropped. - A store that never returns is removed by the normal client TTL path. Today's `ValidateStandbyRemountSegment` compares only name, endpoint, and capacity. That is why A3 adds segment id and base to the persisted segment description. **Not carried, so each becomes a miss:** in-flight puts (their ranges are quarantined), offload and promotion tasks, and Transfer Engine metadata. Stores re-publish their Transfer Engine metadata within 0.6–1.6 s. ### 3. Why this cannot serve wrong data **Principle: a missing entry is a cache miss; a wrong entry is silent corruption.** Every failure path here ends in a missing entry. #3771 was closed because a restored snapshot can point at memory that was reused after the snapshot, which was taken by a master that kept serving. The handoff rules this out by construction. Its snapshot is the final state of the only allocator. Nothing allocates while no master runs. The new master rebuilds the same allocator state before allocating anything, and restored entries open only after their store proves it has not changed. | Mechanism | What it prevents | | -- | -- | | Serving and all background mutators stop before the snapshot | Allocation or free after the snapshot | | In-flight puts are not exported; their ranges, and ranges the old master still held back, stay quarantined for the remaining hold time (up to `put_start_release_timeout`) | A late RDMA write landing on a new object | | Restore only from `READY`, claimed before allocating; `flock`, `POISON`, maximum age | Double restore, stale restore, two masters sharing a directory | | Identity must match before reads open; a mismatch drops the whole segment | Trusting memory of a store that restarted during the gap | | Restored objects keep the old master's read lease (export time + `default_kv_lease_ttl`) | Reallocating a range that a reader is still transferring from | | Versioned format and per-chunk CRC; any error resets to empty | Serving a partially restored index | We wrote the argument as a step-by-step proof that ties each step to the code enforcing it. **AI-assisted review passes** checked it against our fork's code. We fixed the gaps they found, and the last pass found none. The proof also lists what code cannot enforce. The main items are that a writer stops using an allocation within `put_start_release_timeout`, and that both masters use the same timeouts [Appendix §D](https://github.com/kvcache-ai/Mooncake/issues/4666#issuecomment-6183291673). ### 4. Evidence **Where these numbers come from.** The handoff was measured on our fork implementation: a branch on upstream `245e7106`, and its port to our production fork (vessl-ai/Mooncake#26). We have ported it to current upstream `main` (`1204367d`) as the commit series in Section 7; each commit builds and passes its unit tests, but we have not re-run the rig on that port. | Measurement | Result | Conditions | | -- | -- | -- | | Handoff roll | **100.00%** of pre-roll keys readable after the roll (4,656,623 / 4,656,623), **0 wrong reads**. RPC accepted 13.8 s after SIGTERM, clients back at 15.0 s. | Production fork port. Kubernetes `Recreate`, 4 CPU-only stores over TCP with L4 on. 12M keys plus remove/re-put churn, checked by a byte-exact oracle. One run. | | Cold restart, same rig | 0% retained, 0 wrong reads. RPC accepted at 6.6 s. | Same as above. | | Store SIGKILLed during the gap | Only that store's quarter of the keys was dropped (74.99% kept), 0 wrong reads. | `245e7106` branch, same rig design. One run. | | 25M keys, 12 segments | Write 9.9 s, restore with allocator rebuild 13.3 s, peak RSS 26.3 GiB, remount 0.01 ms per store. | `245e7106` branch, 14-CPU Xeon pod, no load. Median of 5. | The handoff makes the master gap longer (13.8 s instead of 6.6 s in the same rig). In exchange, clients come back to a full cache, where a cold restart left our production cell refilling for about 8 minutes [Appendix §A](https://github.com/kvcache-ai/Mooncake/issues/4666#issuecomment-6183291673). **Why not HA for planned restarts?** HA covers more, including crashes, but carries structural costs under any implementation: a 3-member etcd cluster to operate (compaction and quota are open as N13), a standby holding a full metadata copy, and an OpLog record on every PutEnd. Our measurement on current `main` also showed its durability coupled to the steady-state path through eviction's use of the OpLog writer [Appendix §B](https://github.com/kvcache-ai/Mooncake/issues/4666#issuecomment-6183291673). A deployment that needs only planned restarts pays for that all the time; the handoff costs nothing outside the restart. ### 5. Failure policy In #3774, the maintainers said that silently serving an empty or partially reconstructed index "is not an acceptable fallback." They also said that dropping a descriptor needs explicit identity, ownership, and durability semantics. Our answer: - **Baseline.** A standalone master already serves an empty index after every restart. A failed handoff returns to exactly that state, so it adds no new fallback. - **Durability.** The deployment guide calls empty serving a bug in HA because it disagrees with the durable OpLog and poisons later recovery. A standalone master has no durable history to disagree with. The handoff file is not durable state: it has one producer (the quiesced old master), at most one consumer (the next master, which claims it before allocating), and no reader after the claim. - **Never a wrong index.** Restore is all or nothing for each snapshot. Dropping is all or nothing for each segment, and it means the same as that store restarting today. Upstream already skips replicas on unloaded segments in standby restore (#4553). - **Identity and ownership.** An entry belongs to a segment, and a segment belongs to a store process (identity as in Section 2). Store memory is written only into ranges that the master allocated. So the same process with the same mount still holds the recorded bytes, apart from the quarantined ranges. Anything else is treated as possibly reused. - **Not silent.** Every outcome has a result and a reason in the metrics (`master_handoff_last_snapshot_result`, `master_handoff_restore_result`, `master_handoff_segments_{confirmed,dropped,pending}`) and in the logs. - **Operator choice (proposal).** `--handoff_failure_policy`: - `serve_empty` (default): start empty, as today, and report why. - `refuse`: if a handoff was expected but cannot be used, stay non-serving like the R02 gate until the operator clears the directory. "Expected but unusable" means a `READY` that is invalid, too old, or over budget, or a failed write recorded at shutdown. We are happy to make `refuse` the default, or to require an explicit choice, if maintainers prefer. ### 6. Relation to the HA roadmap (#3808) - **It complements HA** and cannot be enabled with it, so master state keeps one authority. It is not a second persistence system: the file is a one-time transfer between two consecutive processes, never listed, published, or selected as recovery state. - **It is consistent with #3167.** The rule that the primary is not a snapshot worker keeps snapshot work away from a serving primary. The handoff writes only after serving has stopped and all mutators have been joined. - **It builds on upstream work:** the N03 chunk codec (#3447), the R02 serving-gate order (#3497), and the per-entry restore semantics of #3806. - **Parts of it may help HA.** At 25M keys on `245e7106`, about 195 s of a 279 s snapshot restore was spent in `RestoreFromStandbyState`'s per-chunk loop, which promotion also runs. A shard-parallel restore read the same file in 11.2 s. An identity-only remount takes about 0.01 ms, where the key scan took 26.5 s per store (#4291 is open) [Appendix §C](https://github.com/kvcache-ai/Mooncake/issues/4666#issuecomment-6183291673). ### 7. Delivery plan Each PR builds and passes tests on its own. We will split further wherever reviewers ask. | PR | Content | Useful alone? | | -- | -- | -- | | A1 | This RFC | – | | A2 | Streaming, shard-parallel restore from batch snapshot chunks, with #3806 semantics | Yes, if it fits N07 | | A3 | Segment id and base as compatible fields in `StandbySegmentInfo`; identity-only remount | Yes | | A4 | An explicit `Quiesce()` that stops and joins background mutators, separate from the destructor | Yes | | A5 | The handoff: config validation, SIGTERM path, admission, watchdog, `READY`/`CLAIMED` lifecycle, restore wiring, failure policy, metrics. Default off. | – | | A6 | L4 (`LOCAL_DISK`) state in the handoff | After track B | **Track B (prerequisite for A6 only):** a separate bug report and fix for wrong reads in the existing L4 offload path. We reproduced these without any master restart. **Separately from this RFC,** we will file the observations in [Appendix §F](https://github.com/kvcache-ai/Mooncake/issues/4666#issuecomment-6183291673) as their own issues with reproductions. ### 8. Non-goals Crashes and OOM kills (HA's job), zero-downtime restarts (a gap of about 15 s remains), any change to the HA paths, the memory of a store that restarts during the gap, and allocators other than OFFSET. ### 9. Questions for maintainers 1. **Failure policy:** is `serve_empty` acceptable as the standalone default, or do you prefer `refuse`, or an explicit choice? 2. **Configuration:** are the flag names acceptable? Should they form a master config group, following #4360? 3. **Segment identity:** should segment id and base go into `StandbySegmentInfo`, which HA shares, or into a handoff-only manifest? Does this touch the roadmap's V-series? 4. **File format:** should the handoff write through `BatchOpLogSnapshotWriter` and the N03 manifest, so that one reader serves both paths? 5. **Persisted lease:** restored objects get a lease until the wall-clock export time plus `default_kv_lease_ttl`. Does this meet the rule for persisted time fields (#3808 invariant 6, #3321)? Or should they start without a lease, protected by the remount gate? 6. **A2 and N07:** should the parallel restore be part of N07, or a separate change? ### AI assistance disclosure - [ ] No AI tools were used - [x] AI tools were used Claude Code helped with the design, the fork implementation and tests, the rig runs, the proof review passes, and this draft. The submitter checked every number against the source data and is responsible for defending the design and, in each PR, every changed line. ### Before submitting a new issue... - [x] Make sure you already searched for relevant issues and read the [documentation](https://kvcache-ai.github.io/Mooncake/)
Author
Owner

@Juhyun-Kim-Memphis commented on GitHub (Oct 15, 2026):

Appendix to the shutdown handoff RFC

This appendix holds the measurement details, the full list of safety mechanisms, and a summary of the correctness argument. The RFC body is self-contained; read this only for the details.

All handoff numbers were measured on our fork implementation: a branch on upstream 245e7106, and its port to our production fork. The port to current upstream main will be verified with unit tests; the rig was not re-run for it.

Commits named here:

  • b140aa92: upstream main on 2026-10-14. Used for the HA cost measurement (§B) and the restore path comparison (§C).
  • 245e7106: upstream main that our first implementation was built on. Used for the scale benchmarks (§A.3).
  • 145c50db, 778aeb07: our implementation on top of 245e7106.
  • vessl-ai/Mooncake#26: the same feature ported to our production fork. Used for the end-to-end rig in §A.2.

Contents:

  • §A Handoff measurements
  • §B HA steady-state cost (M3)
  • §C Restore path cost (M4)
  • §D Safety mechanisms and correctness argument
  • §E Failure cases and observability
  • §F Upstream observations we plan to report separately
  • §G Unplanned failovers observed in the M3 rig

§A Handoff measurements

A.1 Production baseline (cold restart, before the handoff)

Fact Value Source
Master replacements in 30 days (to early October 2026) 6. All were operator rolls; the master restart count was 0 Production records of one cell
Index lost per replacement The whole index, up to about 25M keys Same
Refill time after the 2026-09-05 restart About 8 minutes to reach 17.3M keys Same
Store count / master size 12 stores; master pod 14 CPU, 105 GiB limit Same

L4 context, which belongs to track B: after the 2026-09-10 restart, the stores re-registered their disk keys all at once, from 0 to 24M keys in 40 s. The engines saw tens of thousands of RPC failures during that time.

A.2 End-to-end rolls on Kubernetes

Rig.

  • Master: Deployment with Recreate and a hostPath handoff directory. 4 CPU, 32 GiB. Flags --client_ttl=60 -enable_offload=true.
  • Stores: 4 CPU-only stores, 1,280 MB segment each, TCP, L4 into an emptyDir, offload heartbeat 10 s.
  • Static set: 12M static keys. This is more than the stores' memory, so eviction and L4 both run.
  • Churn: 8 × 250k keys with put, get, remove, and re-put at about 8,000 ops/s. A byte-exact oracle classifies every read as correct, stale, resurrected, or garbage.
  • Readers: 4 static readers.
  • Runs: one run per phase. "Client back" is the first successful client operation after SIGTERM to the old master.

Production fork port (vessl-ai/Mooncake#26 lineage).

Phase Wrong reads Pre-roll keys readable after Old master exit New master accepts RPC Client back Write / restore
Handoff roll 0 100.00% (4,656,623 / 4,656,623) 4.9 s 13.8 s 15.0 s 4.6 s / 6.7 s
Cold restart (no --handoff_dir) 0 0% (expected) 4.4 s 6.6 s 8.6 s 4.0 s / –
Handoff roll, final fork HEAD (1M keys) 0 100.00% (1,000,000 / 1,000,000) – – – –

In the handoff roll, the old master exported 5,191,681 objects. The new master claimed the snapshot once and confirmed all 4 segments, with 0 dropped. No store saw SEGMENT_NOT_FOUND or re-mounted, and no store expired.

Build 145c50db on top of 245e7106.

Phase Wrong reads Pre-roll keys readable after New master listening Client back Store re-registrations Stores expired
Control, no roll (120 s) 0 92.31% (see note) – – 0 0
Handoff roll 0 100.00% (4,483,379 / 4,483,379) 16.0 s 17.2 s 0 0
One store SIGKILLed in the gap 0 74.99% (the killed store's quarter is dropped) 15.9 s 17.1 s 1 (the killed store's new process) 0
Cold restart 0 0% (expected) 7.4 s 8.6 s 4 (all) 0

Note: the control's 92.31% is a liveness cost of a time-based offload hold that this build used after a cold start. Objects put during that hold had no disk copy, so eviction removed them. The later L4 task-id change (track B) removed the hold.

Earlier finding from the same rig (build 828b43d7). Before the L4 fixes, wrong reads appeared even without any roll: 8 in 4.05M gets (about 2 per million) under remove/re-put churn. After a cold restart there were 33,209, because the stores re-registered their disk keys. We traced every observed wrong read to a cause in the existing L4 offload path:

  1. A late offload completion re-created a removed key.
  2. A re-registration attached an old file to a key.
  3. The store copied a buffer that had been freed and reused.

This is the subject of track B. With the track B fixes, every phase above has 0 wrong reads.

A.3 Scale (25M keys, 12 segments)

Hardware and method. CPU-only pod with 14 CPU and 80 GiB, Intel Xeon 8462Y+, cgroup v2, no concurrent load. The base was upstream 245e7106. The synthetic keys are about 1,030 B of metadata per key; production is about 1,250 B.

Step 25M keys 32M keys Notes
Write, streaming, 14 workers, fsync included 9.9 s 13.4 s In an earlier run of the same writer design: extra anonymous memory ≤ 0.02 GiB (≤ 0.08 GiB at 32M), dirty page cache ≤ 0.01 GiB
Snapshot size 4.53 GB – Chunk codec without compression
Restore, allocator rebuild included 13.3 s (of which allocator 3.3 s) 16.5 s (4.3 s) Median of 5 runs
Peak RSS during restore 26.3 GiB 34.0 GiB Final RSS is within about 1 GiB of the peak
Remount, exclusive-lock hold per store 0.01 ms 0.01 ms 12 concurrent remounts in total: 42 ms / 45 ms
Dropping a restarted store's segment MountSegment 9.5 ms; background deletion 16.6 s – Maximum Ping latency during the deletion: 6.6 ms

Real mooncake_master binary, 25M keys. Production flags, an index published by a test tool on 12 fake segments, 3 runs.

Metric Value
SIGTERM to exit 12.2 / 12.7 / 12.9 s
Inside that: stop RPC 2–11 ms
Inside that: quiesce 35–38 ms
Inside that: write (8 workers) 11.1–11.7 s
Start to RPC 15.18–15.26 s
RSS when ready 25.2 GiB (peak 26.3 GiB)

A crash during the restore led to a cold start on the next start (aborted_previous), 1.06 s later.

With L4 state (track B / A6, for reference). 25M memory keys plus 24M disk keys.

No L4 Disk keys overlap memory keys Disk keys do not overlap
Snapshot size 4.53 GB 6.47 GB 9.49 GB
SIGTERM to exit 12.7–12.9 s 17.3–19.3 s 27.1–27.4 s
Start to ready 15.3–15.5 s 16.2–16.5 s 25.0 s
Peak restore memory 26.3 GiB 30.3 GiB 46.3 GiB

The admission model is (memory objects × 1,144 B + extra disk replicas × 184 B + disk-only objects × 904 B) × 1.15. It was 15–17% above every measured peak in the cases above.


§B HA steady-state cost (M3)

This section backs one sentence of the RFC: on current main, HA's durability is coupled to the steady-state path. It is not an argument that HA is slow. Most of the cost below comes from an implementation detail of eviction, which can be fixed. We will report it separately (§F).

B.1 Setup

Item Value
Binary Upstream main b140aa92, Release, STORE_USE_ETCD=ON. Both modes ran the same binary; only the flags differed.
Master 14 CPU / 105 GiB pod, the same size as our production master. Intel Xeon 8462Y+.
Standby Second node (shared with etcd), 14 CPU / 105 GiB.
etcd v3.5.21, 3 members on one node (only two nodes were available), 4 CPU / 16 GiB each, hardware RAID SSD. Quota 8 GiB, periodic auto-compaction every 5 min. Master-to-etcd TCP round trip p50 0.27 ms. WAL fsync average 0.33 ms.
HA flags The HA example in docs/source/deployment/mooncake-store-deployment-guide.md: enable_ha, 3 etcd endpoints, enable_oplog, oplog_poll_interval_ms=1000, oplog_batch_max_entries=1024, enable_oplog_snapshot (600 s, local object store).
Common flags As in our production master: --client_ttl=60 -enable_offload=true, eviction defaults (high watermark 0.90, ratio 0.05).
Load A tool based on upstream master_bench.cpp that calls only master RPCs. 12 fake segments × 675 GiB (8.7 TB, the same as production). Fill: 16.4M objects of 470,000 B at 40k items/s (88.6% full). Measured phase: 360 s of put 9,300 items/s (64 per request) and get 64,000 items/s (128 per request), both at a constant rate. This is our 7-day production median.
Repetition Standalone (sa) and HA (ha) alternated, 3 runs each. Master, standby, and etcd were recreated for every run.

B.2 Code reading (b140aa92, paths under mooncake-store/)

Path Does the RPC reply wait for etcd? Does freeing memory wait for etcd? When the writer reservation is full
PutStart No; there is no OpLog record – –
PutEnd No. It commits the record and returns (AppendOpLogVisibleBeforeDurable) – Logs a warning and returns success; the standby misses the object (src/master_service.cpp:5756-5764)
Remove No Yes; the replica is marked REMOVED and freed in the durable callback Returns error -1401
Eviction (BatchEvict) – (background) Yes (:11150-11157, :11268-11335) Stops this cycle (:11284-11291)
Put lease expiry cleanup – Yes, for objects with a completed replica Deferred to the next cycle

The writer has one etcd transaction in flight at a time. While it waits, it accepts up to oplog_batch_max_entries (default 1,024) reserved entries (src/ha/oplog/ordered_oplog_writer.cpp:279-300). PutEnd, Remove, and eviction share these entries.

Two more facts from the code:

  • ha_oplog_dropped_put_end_total is defined but never incremented (src/ha_metric_manager.cpp:128).
  • With OpLog enabled, the offload_on_evict branch is bypassed (src/master_service.cpp:11205-11207).

B.3 Results

The latency rows are the per-second p50 and p99 over t = 5–140 s, the window before HA memory filled up.

Metric sa (3 runs) sa median ha (3 runs) ha median
Memory use, median / max 0.879 / 0.901 0.879 / 0.901 0.925–0.938 / 0.956 0.932 / 0.956
Eviction rate, keys/s (t = 30–150 s) 13,883–13,884 13,883 615–657 645
First put rejected none none 148 s in every run 148 s
PutStart p50 / p99 (µs) 1,572–1,601 / 1,704–1,726 1,590 / 1,716 1,864–1,895 / 30,472–31,395 1,894 / 30,842
PutEnd p50 / p99 (µs) 454–465 / 553–572 455 / 569 790–800 / 25,375–26,722 791 / 26,453
get p50 / p99 (µs) 695–758 / 803–905 752 / 881 912–914 / 35,127–36,604 913 / 35,708
Master CPU, cores (360 s average) 0.78–0.79 0.79 4.54–6.27 5.36
Master RSS max (GiB) 19.0–19.8 19.5 26.2–26.8 26.3
PutEnd records skipped (t = 0–140 s) 0 0 1,895–2,136 2,132
Eviction cycles stopped early 0 0 65–93 78
Standby RSS max (GiB) – – 30.9–31.5 31.2
etcd DB per member at the end (GB) – – 5.72–5.78 5.76
etcd RSS per member (GiB) – – 12.8–13.6 13.0

Before and during eviction. Each cell is the range over 3 runs, in µs.

sa, t = 5–28 s ha, t = 5–28 s sa, t = 35–140 s ha, t = 35–140 s
PutEnd p50 / p99 453–461 / 548–552 770–794 / 1,094–1,199 455–467 / 553–575 796–804 / 27,691–28,134
PutStart p99 1,710–1,874 1,907–2,025 1,658–1,726 31,768–33,252
get p99 830–1,018 1,047–1,158 792–914 37,295–38,020

B.4 Interpretation

The OpLog record itself is cheap. Before eviction starts, only PutEnd is slower, by about 0.33 ms. This is serialization inside the shard lock plus the writer mutex. Nothing waits for etcd.

The large cost comes from how eviction interacts with the reservation.

  • Standalone evicts about 830k objects in one 4.3 s cycle, about every 90 s.
  • HA runs a cycle about every 2.05 s, evicts 1,200–1,700 objects, and stops with BatchEvict: OpLog reservation failed ... stopping eviction scan.
  • The writer was not busy: transactions averaged 3.1–4.4 ms and 38 entries. The reservation fills because eviction uses its 1,024 entries within milliseconds, and the entries free up only when the in-flight transaction completes.
  • Each new cycle collects candidates from the whole index again, which takes about 2 s at 17M keys. This also keeps RPC p99 at 25–38 ms almost every second.
  • Memory therefore rose by about 0.47 percentage points per 10 s, and allocation failed at 95.6% (17,694,720 keys in all 3 runs).

Raising oplog_batch_max_entries. A larger value raises the limit. At about 316 B per entry, the default etcd request limit of 1.5 MiB allows about 4,900 entries. We estimate that this would give about 2,400 evictions/s, still below the 9,300 puts/s. We did not measure this.

Skipped PutEnd records happen in the same seconds as eviction cycles. During the 40k items/s fill, 6–7% of PutEnd records were skipped (1.0–1.19M).

What would remain if eviction were fixed:

  • the PutEnd +0.33 ms;
  • standby memory, about 1.2 times the primary's;
  • three etcd members, each with about 13 GiB RSS and a 5–6 GB database (about 306 B per OpLog entry);
  • etcd capacity operations (N13).

B.5 Pre-registered criteria

Criterion Result
1. Put rejections exceed standalone by ≥ 0.1% of put items Met: 0 vs. rejections from 148 s on
2. ≥ 1 skipped PutEnd record or stopped eviction cycle Met
3. PutStart / PutEnd p99 worse by ≥ 20% and ≥ 1 ms Met
4. p99 delay of memory release ≥ 10 ms Not decided; the histogram buckets were too coarse
5. Master CPU higher by ≥ 1 core Met

B.6 Limits

  • The load calls master RPCs only. There is no data transfer, no store process, and no RDMA, and the object size is fixed.
  • All 3 etcd members ran on one node, so member-to-member round trips are shorter than in a real deployment.
  • We chose the etcd compaction interval (§G depends on it).
  • The build had MOONCAKE_ENABLE_OPLOG_PERF_METRICS=ON, which adds one histogram observation per entry in HA.
  • When the load tool fell behind, it kept sending the backlog. The tables use the scheduled 361 s window.
  • We did not measure the sensitivity to oplog_batch_max_entries or the production peak rate (22k items/s).

§C Restore path cost (M4)

Restoring a batch snapshot file at 25M keys (upstream 245e7106, same hardware as §A.3, median of 3):

Path Time Peak RSS
StandbyMetadataStore fill, then RestoreFromBatchOpLogPromotion, 1 thread 279 s 29.6 GiB
Same path with 14 decode threads 291 s 38.2 GiB
Shard-parallel streaming restore, 1 / 14 threads 67.0 s / 11.2 s 25.0 GiB

Where the 279 s goes.

  • About 195 s is the per-chunk loop of RestoreFromStandbyState, which also runs at promotion. That loop takes a per-object shard lock, builds a per-chunk hash map, and inserts every replica into a per-segment std::map.
  • About 45 s is filling StandbyMetadataStore.
  • 15 s is DrainChunk.
  • 9 s is ValidateReplicaIds.
  • Decode threads do not help, because the store has one mutex.

Changes on main since 245e7106. Three commits touch the promotion restore: #3806, #4553, and #4559. We timed the restore call alone at 10M keys on both commits. This used the same input and a small harness that runs StandbyMetadataStore and then RestoreFromBatchOpLogPromotion, on one AMD EPYC 9454P node (5 CPU), alternating 3 runs per commit.

10M keys 245e7106 b140aa92 Change
Restore time 75.73 s 65.47 s −13.5%
Peak RSS during restore 11.21 GiB 17.17 GiB +5.96 GiB (+53%)

The extra memory matches two things: #4559 keeps the standby copy (3.8 GiB) until serving starts, and #3806 keeps installed_keys for rollback. So the 279 s at 25M is conservative for main, and the 38.2 GiB is now an underestimate. We did not re-measure at 25M.

Remount scan. On 245e7106, one ReMountSegment after a restore took 26.5 s at 25M keys, and 317 s for 12 stores in sequence. The call holds client_mutex_ and the snapshot mutex exclusively. #4291, which would change this, is still open. With segment identity persisted and the allocator rebuilt at restore, the remount is an identity comparison of about 0.01 ms (§A.3).


§D Safety mechanisms and correctness argument

D.1 Mechanisms

# Mechanism Reason
1 Stop the RPC and admin servers, wait for in-flight requests, then stop and join every background mutator (11 threads in our build) before the snapshot No allocation or free after the snapshot
2 Do not export in-flight puts. For a partially completed object, export only its completed replicas. Quarantine the ranges of the incomplete replicas, and of ranges the old master still held back (discarded_replicas_), for the remaining hold time (up to put_start_release_timeout) A late RDMA write cannot land on a new object, and the gap does not grow
3 Restore only a snapshot marked READY; rename it to CLAIMED before any allocation; lock file; POISON if two masters share the directory; maximum age (120 s by default) No double restore, no stale restore, no repeated failure loop
4 A restored memory segment is reconnected only if its identity (segment id, base, size, endpoint) and owner client_id match. Otherwise every restored entry of the segment is dropped. Restored disk replicas stay closed until an RPC from the same client_id arrives Do not trust memory or disk of a store that restarted during the gap
5 Do not delete objects whose lease expired during the gap; restored objects keep the old master's read lease (export time + default_kv_lease_ttl) The legacy restore deleted every object not read within the lease (0 of 25M kept); readers that were transferring at SIGTERM stay protected
6 Versioned format; an unreadable or corrupt snapshot means an empty start Failures fall to the safe side (index loss)
7 (A6 / track B) An offload copy still in flight at SIGTERM reports with the old master's task id, which the new master refuses. The store also re-checks that its copy source is unchanged before reporting A copy of memory that was freed or reused is never registered
8 (track B) Register a disk replica only from the completion of the current offload task taken by that store (task id = master epoch + sequence). Refuse registrations without a task. A store clears its offload directory on a new registration instead of re-registering The existing L4 path could revive removed keys or copy freed memory; we reproduced this without a roll

D.2 Proof summary

The claim: every replica descriptor that the master hands out for key K leads a reader who follows the read protocol to the bytes written by the put that created the current object, or to an error (a miss). Anything else counts as wrong: an older or newer value, another key's bytes, or a removed key coming back.

Handoff steps (memory replicas).

  • H1 Freeze. The servers stop before the mutators. The writer runs only after BackgroundWorkQuiesced(), so the export sees one consistent state S, and every put or remove acknowledged to a client happened before S.
  • H2 Snapshot rules. The rules for in-flight, incomplete, and held-back ranges in D.1 #2. Replicas on segments that are gone are neither exported nor quarantined.
  • H3 Gap. While no master runs, store memory changes only through writes into ranges the old master allocated. Those writes are either finished in S or quarantined.
  • H4 Restore validation. Standalone only. Codec fingerprint and mode, quarantine ranges inside their segments, unique non-zero descriptor ids, COMPLETE state, sizes, exactly one segment per range. Any violation resets the master to empty.
  • H5 Gate. Restored segments are mounted as not allocatable and not readable.
  • H6 Identity. A mismatch drops the whole segment. Only a remount by the recorded owner with the same Segment opens it.
  • H7 Allocator rebuild. Live ranges and quarantine ranges are sorted, and any overlap fails the restore. The allocator imports exactly those ranges, and Replica::next_id_ continues from the old master's value.
  • H8 Reads after confirmation. Same process, same mount, same addresses, and memory changed only by finished or quarantined writes.
  • H9 Not carried. In-flight objects, dropped segments, offload and promotion tasks. All of these become misses.

Assumptions that code cannot enforce:

Assumption Statement
A1 Only the master allocates ranges in segments.
A2 PutEnd is sent only after the write finished.
A3 A writer stops using an allocation within put_start_release_timeout.
A5 Per-process and per-mount UUIDs do not collide.
A8 Readers finish within their read lease, and both masters use the same default_kv_lease_ttl.
A9 One master per handoff directory. A rollback to an image without the handoff must clear the directory.
A12 The wall clock does not jump far forward.
Timeouts Both masters use the same put_start_release_timeout.

The L4 part of the proof (steps L0–L7, the task-id uniqueness assumption, and the mixed-version analysis) belongs to track B and A6. HA mode is out of the proof's scope; the handoff refuses to run with HA.

Verification history.

  • The proof was checked against the code in AI-assisted review passes: adversarial review agents (Claude Code), run separately from the session that wrote the proof.
  • The passes found real gaps, which we fixed: copies of items a store had not taken; offload takes made before a cold start; a hold that was not carried by a roll.
  • The fourth pass, on 145c50db, found no remaining hole. The fork port (35ef7acb) has its own proof revision and verification.

§E Failure cases and observability

Tested with the real binary and real stores over TCP. Every case below started empty and safely, with 0 wrong reads:

  • no snapshot;
  • one byte flipped in a chunk (CRC mismatch);
  • write deadline exceeded;
  • write interrupted;
  • crash during the restore;
  • two masters sharing one directory.

Further tested cases.

  • A store restarted during the gap: its old segment was dropped, and only new data was read.
  • 11 kinds of corrupt or incompatible snapshots were covered by unit tests; each one started an empty master.
  • A memory shortfall on either side led to a skipped handoff, and the reason appeared in logs and metrics.

Metrics:

  • master_handoff_last_snapshot_result, _bytes, _objects, _workers
  • master_handoff_last_shutdown_duration_seconds
  • master_handoff_restore_result, _duration_seconds, _objects
  • master_handoff_snapshot_age_seconds
  • master_handoff_segments_confirmed, _dropped, _pending

Log lines:

  • shutdown: handoff_shutdown=start|serving_stopped|quiesced|deadline|...
  • write: handoff_write=ok|...
  • startup: handoff_startup=ok|claim_failed|poison_failed|deadline|...

Result reasons include: skipped_restore_memory, skipped_processing, skipped_not_owner, skipped_poisoned, stale, deadline, poisoned.

Flags in the fork. Upstream names and defaults are open (RFC Q2).

Flag Default
--handoff_dir "" (off)
--handoff_shutdown_deadline_sec 45
--handoff_restore_deadline_sec 180
--handoff_max_age_sec 120
--handoff_max_workers 8
--handoff_restore_workers 0 (= number of CPUs)
--handoff_restore_margin_percent 15

§F Upstream observations we plan to report separately

We will file each of these separately from this RFC discussion, as its own issue with a reproduction. They are bug reports, not arguments in favor of the handoff. They are listed here only so that maintainers know they are coming.

Status on 2026-10-22: items 1–3 and 5 are reported as linked below. Items 2 and 3 were already under discussion elsewhere, so we added our data to those threads instead of opening new issues. Item 4 is addressed by #4802 instead of a separate issue.

  1. Eviction under OpLog. When the reservation is full, an eviction cycle stops, and the next cycle collects candidates from the whole index again. Under a steady put rate this limits eviction to about 645 keys/s in our setup (§B.4). Reported in #4818.
  2. The skipped PutEnd counter. ha_oplog_dropped_put_end_total is defined but never incremented. A skipped PutEnd record is visible only in a warning log. #4546 already covers this counter, and #4582 would delete it; we added our data there: https://github.com/kvcache-ai/Mooncake/issues/4546#issuecomment-6308811788
  3. offload_on_evict with OpLog. The offload_on_evict branch is bypassed when OpLog is enabled. We may have missed a documented reason. This was already raised in #4723; we added our reading there: https://github.com/kvcache-ai/Mooncake/pull/4723#issuecomment-6308812084
  4. Remount identity check. ValidateStandbyRemountSegment compares segment name, endpoint, and capacity only. We have not demonstrated a failure on the promotion path. A3 proposes adding segment id and base; this is now #4802.
  5. Graceful exit time. A standalone master spends most of its graceful exit destroying the index. Re-measured on main at 8c19b728: SIGTERM to exit took 20.7 s with 12M objects and 48.2 s with 25M objects (median of 3 runs; after SIGKILL the kernel's teardown took 1.1 s and 2.4 s). Reported in #4856. The first measurement (21.9 s with a 12M-object index) was a single run on build 828b43d7, in the cold-restart phase.

§G Unplanned failovers observed in the M3 rig (facts only)

This section depends on our etcd configuration: 5-minute periodic compaction, chosen by us. We do not draw conclusions about rates from it.

  • In every HA run, an etcd compaction started 145–176 s into the load and took 80–88 s on a 5.7–5.8 GB database. During it, single OpLog transactions took 3–5 s.
  • The primary's etcd requests timed out. It lost its leadership lease and stopped serving.
  • The standby was promoted and restored 16.5M objects in 114 s in one run, and 16.7M in about 104 s in another. No master served for about 190 s and 160–170 s, respectively.
  • The new primary had fewer keys than the old one: 1,187,092 fewer in one run and 998,366 fewer in the other. The cumulative skipped PutEnd records in those runs were 1,189,417 and 1,001,474, which agree within 0.3%.
  • The put rejections at 148 s came from the memory limit, not from this stall. In one run, rejections began before the first etcd timeout.
<!-- gh-comment-id:6183291673 --> @Juhyun-Kim-Memphis commented on GitHub (Oct 15, 2026): # Appendix to the shutdown handoff RFC This appendix holds the measurement details, the full list of safety mechanisms, and a summary of the correctness argument. The RFC body is self-contained; read this only for the details. All handoff numbers were measured on our fork implementation: a branch on upstream `245e7106`, and its port to our production fork. The port to current upstream `main` will be verified with unit tests; the rig was not re-run for it. Commits named here: - `b140aa92`: upstream `main` on 2026-10-14. Used for the HA cost measurement (§B) and the restore path comparison (§C). - `245e7106`: upstream `main` that our first implementation was built on. Used for the scale benchmarks (§A.3). - `145c50db`, `778aeb07`: our implementation on top of `245e7106`. - vessl-ai/Mooncake#26: the same feature ported to our production fork. Used for the end-to-end rig in §A.2. Contents: - §A Handoff measurements - §B HA steady-state cost (M3) - §C Restore path cost (M4) - §D Safety mechanisms and correctness argument - §E Failure cases and observability - §F Upstream observations we plan to report separately - §G Unplanned failovers observed in the M3 rig --- ## §A Handoff measurements ### A.1 Production baseline (cold restart, before the handoff) | Fact | Value | Source | | -- | -- | -- | | Master replacements in 30 days (to early October 2026) | 6. All were operator rolls; the master restart count was 0 | Production records of one cell | | Index lost per replacement | The whole index, up to about 25M keys | Same | | Refill time after the 2026-09-05 restart | About 8 minutes to reach 17.3M keys | Same | | Store count / master size | 12 stores; master pod 14 CPU, 105 GiB limit | Same | L4 context, which belongs to track B: after the 2026-09-10 restart, the stores re-registered their disk keys all at once, from 0 to 24M keys in 40 s. The engines saw tens of thousands of RPC failures during that time. ### A.2 End-to-end rolls on Kubernetes **Rig.** - Master: Deployment with `Recreate` and a hostPath handoff directory. 4 CPU, 32 GiB. Flags `--client_ttl=60 -enable_offload=true`. - Stores: 4 CPU-only stores, 1,280 MB segment each, TCP, L4 into an emptyDir, offload heartbeat 10 s. - Static set: 12M static keys. This is more than the stores' memory, so eviction and L4 both run. - Churn: 8 × 250k keys with put, get, remove, and re-put at about 8,000 ops/s. A byte-exact oracle classifies every read as correct, stale, resurrected, or garbage. - Readers: 4 static readers. - Runs: one run per phase. "Client back" is the first successful client operation after SIGTERM to the old master. **Production fork port (vessl-ai/Mooncake#26 lineage).** | Phase | Wrong reads | Pre-roll keys readable after | Old master exit | New master accepts RPC | Client back | Write / restore | | -- | -- | -- | -- | -- | -- | -- | | Handoff roll | 0 | 100.00% (4,656,623 / 4,656,623) | 4.9 s | 13.8 s | 15.0 s | 4.6 s / 6.7 s | | Cold restart (no `--handoff_dir`) | 0 | 0% (expected) | 4.4 s | 6.6 s | 8.6 s | 4.0 s / – | | Handoff roll, final fork HEAD (1M keys) | 0 | 100.00% (1,000,000 / 1,000,000) | – | – | – | – | In the handoff roll, the old master exported 5,191,681 objects. The new master claimed the snapshot once and confirmed all 4 segments, with 0 dropped. No store saw `SEGMENT_NOT_FOUND` or re-mounted, and no store expired. **Build `145c50db` on top of `245e7106`.** | Phase | Wrong reads | Pre-roll keys readable after | New master listening | Client back | Store re-registrations | Stores expired | | -- | -- | -- | -- | -- | -- | -- | | Control, no roll (120 s) | 0 | 92.31% (see note) | – | – | 0 | 0 | | Handoff roll | 0 | 100.00% (4,483,379 / 4,483,379) | 16.0 s | 17.2 s | 0 | 0 | | One store SIGKILLed in the gap | 0 | 74.99% (the killed store's quarter is dropped) | 15.9 s | 17.1 s | 1 (the killed store's new process) | 0 | | Cold restart | 0 | 0% (expected) | 7.4 s | 8.6 s | 4 (all) | 0 | Note: the control's 92.31% is a liveness cost of a time-based offload hold that this build used after a cold start. Objects put during that hold had no disk copy, so eviction removed them. The later L4 task-id change (track B) removed the hold. **Earlier finding from the same rig (build `828b43d7`).** Before the L4 fixes, wrong reads appeared even without any roll: 8 in 4.05M gets (about 2 per million) under remove/re-put churn. After a cold restart there were 33,209, because the stores re-registered their disk keys. We traced every observed wrong read to a cause in the existing L4 offload path: 1. A late offload completion re-created a removed key. 2. A re-registration attached an old file to a key. 3. The store copied a buffer that had been freed and reused. This is the subject of track B. With the track B fixes, every phase above has 0 wrong reads. ### A.3 Scale (25M keys, 12 segments) **Hardware and method.** CPU-only pod with 14 CPU and 80 GiB, Intel Xeon 8462Y+, cgroup v2, no concurrent load. The base was upstream `245e7106`. The synthetic keys are about 1,030 B of metadata per key; production is about 1,250 B. | Step | 25M keys | 32M keys | Notes | | -- | -- | -- | -- | | Write, streaming, 14 workers, fsync included | 9.9 s | 13.4 s | In an earlier run of the same writer design: extra anonymous memory ≤ 0.02 GiB (≤ 0.08 GiB at 32M), dirty page cache ≤ 0.01 GiB | | Snapshot size | 4.53 GB | – | Chunk codec without compression | | Restore, allocator rebuild included | 13.3 s (of which allocator 3.3 s) | 16.5 s (4.3 s) | Median of 5 runs | | Peak RSS during restore | 26.3 GiB | 34.0 GiB | Final RSS is within about 1 GiB of the peak | | Remount, exclusive-lock hold per store | 0.01 ms | 0.01 ms | 12 concurrent remounts in total: 42 ms / 45 ms | | Dropping a restarted store's segment | `MountSegment` 9.5 ms; background deletion 16.6 s | – | Maximum Ping latency during the deletion: 6.6 ms | **Real `mooncake_master` binary, 25M keys.** Production flags, an index published by a test tool on 12 fake segments, 3 runs. | Metric | Value | | -- | -- | | SIGTERM to exit | 12.2 / 12.7 / 12.9 s | | Inside that: stop RPC | 2–11 ms | | Inside that: quiesce | 35–38 ms | | Inside that: write (8 workers) | 11.1–11.7 s | | Start to RPC | 15.18–15.26 s | | RSS when ready | 25.2 GiB (peak 26.3 GiB) | A crash during the restore led to a cold start on the next start (`aborted_previous`), 1.06 s later. **With L4 state (track B / A6, for reference).** 25M memory keys plus 24M disk keys. | | No L4 | Disk keys overlap memory keys | Disk keys do not overlap | | -- | -- | -- | -- | | Snapshot size | 4.53 GB | 6.47 GB | 9.49 GB | | SIGTERM to exit | 12.7–12.9 s | 17.3–19.3 s | 27.1–27.4 s | | Start to ready | 15.3–15.5 s | 16.2–16.5 s | 25.0 s | | Peak restore memory | 26.3 GiB | 30.3 GiB | 46.3 GiB | The admission model is `(memory objects × 1,144 B + extra disk replicas × 184 B + disk-only objects × 904 B) × 1.15`. It was 15–17% above every measured peak in the cases above. --- ## §B HA steady-state cost (M3) This section backs one sentence of the RFC: on current `main`, HA's durability is coupled to the steady-state path. It is not an argument that HA is slow. Most of the cost below comes from an implementation detail of eviction, which can be fixed. We will report it separately (§F). ### B.1 Setup | Item | Value | | -- | -- | | Binary | Upstream `main` `b140aa92`, Release, `STORE_USE_ETCD=ON`. Both modes ran the same binary; only the flags differed. | | Master | 14 CPU / 105 GiB pod, the same size as our production master. Intel Xeon 8462Y+. | | Standby | Second node (shared with etcd), 14 CPU / 105 GiB. | | etcd | v3.5.21, 3 members on one node (only two nodes were available), 4 CPU / 16 GiB each, hardware RAID SSD. Quota 8 GiB, periodic auto-compaction every 5 min. Master-to-etcd TCP round trip p50 0.27 ms. WAL fsync average 0.33 ms. | | HA flags | The HA example in `docs/source/deployment/mooncake-store-deployment-guide.md`: `enable_ha`, 3 etcd endpoints, `enable_oplog`, `oplog_poll_interval_ms=1000`, `oplog_batch_max_entries=1024`, `enable_oplog_snapshot` (600 s, local object store). | | Common flags | As in our production master: `--client_ttl=60 -enable_offload=true`, eviction defaults (high watermark 0.90, ratio 0.05). | | Load | A tool based on upstream `master_bench.cpp` that calls only master RPCs. 12 fake segments × 675 GiB (8.7 TB, the same as production). Fill: 16.4M objects of 470,000 B at 40k items/s (88.6% full). Measured phase: 360 s of put 9,300 items/s (64 per request) and get 64,000 items/s (128 per request), both at a constant rate. This is our 7-day production median. | | Repetition | Standalone (sa) and HA (ha) alternated, 3 runs each. Master, standby, and etcd were recreated for every run. | ### B.2 Code reading (`b140aa92`, paths under `mooncake-store/`) | Path | Does the RPC reply wait for etcd? | Does freeing memory wait for etcd? | When the writer reservation is full | | -- | -- | -- | -- | | PutStart | No; there is no OpLog record | – | – | | PutEnd | No. It commits the record and returns (`AppendOpLogVisibleBeforeDurable`) | – | Logs a warning and returns success; the standby misses the object (`src/master_service.cpp:5756-5764`) | | Remove | No | Yes; the replica is marked `REMOVED` and freed in the durable callback | Returns error -1401 | | Eviction (`BatchEvict`) | – (background) | Yes (`:11150-11157`, `:11268-11335`) | Stops this cycle (`:11284-11291`) | | Put lease expiry cleanup | – | Yes, for objects with a completed replica | Deferred to the next cycle | The writer has one etcd transaction in flight at a time. While it waits, it accepts up to `oplog_batch_max_entries` (default 1,024) reserved entries (`src/ha/oplog/ordered_oplog_writer.cpp:279-300`). PutEnd, Remove, and eviction share these entries. Two more facts from the code: - `ha_oplog_dropped_put_end_total` is defined but never incremented (`src/ha_metric_manager.cpp:128`). - With OpLog enabled, the `offload_on_evict` branch is bypassed (`src/master_service.cpp:11205-11207`). ### B.3 Results The latency rows are the per-second p50 and p99 over t = 5–140 s, the window before HA memory filled up. | Metric | sa (3 runs) | sa median | ha (3 runs) | ha median | | -- | -- | -- | -- | -- | | Memory use, median / max | 0.879 / 0.901 | 0.879 / 0.901 | 0.925–0.938 / 0.956 | 0.932 / 0.956 | | Eviction rate, keys/s (t = 30–150 s) | 13,883–13,884 | 13,883 | 615–657 | 645 | | First put rejected | none | none | 148 s in every run | 148 s | | PutStart p50 / p99 (µs) | 1,572–1,601 / 1,704–1,726 | 1,590 / 1,716 | 1,864–1,895 / 30,472–31,395 | 1,894 / 30,842 | | PutEnd p50 / p99 (µs) | 454–465 / 553–572 | 455 / 569 | 790–800 / 25,375–26,722 | 791 / 26,453 | | get p50 / p99 (µs) | 695–758 / 803–905 | 752 / 881 | 912–914 / 35,127–36,604 | 913 / 35,708 | | Master CPU, cores (360 s average) | 0.78–0.79 | 0.79 | 4.54–6.27 | 5.36 | | Master RSS max (GiB) | 19.0–19.8 | 19.5 | 26.2–26.8 | 26.3 | | PutEnd records skipped (t = 0–140 s) | 0 | 0 | 1,895–2,136 | 2,132 | | Eviction cycles stopped early | 0 | 0 | 65–93 | 78 | | Standby RSS max (GiB) | – | – | 30.9–31.5 | 31.2 | | etcd DB per member at the end (GB) | – | – | 5.72–5.78 | 5.76 | | etcd RSS per member (GiB) | – | – | 12.8–13.6 | 13.0 | **Before and during eviction.** Each cell is the range over 3 runs, in µs. | | sa, t = 5–28 s | ha, t = 5–28 s | sa, t = 35–140 s | ha, t = 35–140 s | | -- | -- | -- | -- | -- | | PutEnd p50 / p99 | 453–461 / 548–552 | 770–794 / 1,094–1,199 | 455–467 / 553–575 | 796–804 / 27,691–28,134 | | PutStart p99 | 1,710–1,874 | 1,907–2,025 | 1,658–1,726 | 31,768–33,252 | | get p99 | 830–1,018 | 1,047–1,158 | 792–914 | 37,295–38,020 | ### B.4 Interpretation **The OpLog record itself is cheap.** Before eviction starts, only PutEnd is slower, by about 0.33 ms. This is serialization inside the shard lock plus the writer mutex. Nothing waits for etcd. **The large cost comes from how eviction interacts with the reservation.** - Standalone evicts about 830k objects in one 4.3 s cycle, about every 90 s. - HA runs a cycle about every 2.05 s, evicts 1,200–1,700 objects, and stops with `BatchEvict: OpLog reservation failed ... stopping eviction scan`. - The writer was not busy: transactions averaged 3.1–4.4 ms and 38 entries. The reservation fills because eviction uses its 1,024 entries within milliseconds, and the entries free up only when the in-flight transaction completes. - Each new cycle collects candidates from the whole index again, which takes about 2 s at 17M keys. This also keeps RPC p99 at 25–38 ms almost every second. - Memory therefore rose by about 0.47 percentage points per 10 s, and allocation failed at 95.6% (17,694,720 keys in all 3 runs). **Raising `oplog_batch_max_entries`.** A larger value raises the limit. At about 316 B per entry, the default etcd request limit of 1.5 MiB allows about 4,900 entries. We estimate that this would give about 2,400 evictions/s, still below the 9,300 puts/s. We did not measure this. **Skipped PutEnd records** happen in the same seconds as eviction cycles. During the 40k items/s fill, 6–7% of PutEnd records were skipped (1.0–1.19M). **What would remain if eviction were fixed:** - the PutEnd +0.33 ms; - standby memory, about 1.2 times the primary's; - three etcd members, each with about 13 GiB RSS and a 5–6 GB database (about 306 B per OpLog entry); - etcd capacity operations (N13). ### B.5 Pre-registered criteria | Criterion | Result | | -- | -- | | 1. Put rejections exceed standalone by ≥ 0.1% of put items | Met: 0 vs. rejections from 148 s on | | 2. ≥ 1 skipped PutEnd record or stopped eviction cycle | Met | | 3. PutStart / PutEnd p99 worse by ≥ 20% and ≥ 1 ms | Met | | 4. p99 delay of memory release ≥ 10 ms | Not decided; the histogram buckets were too coarse | | 5. Master CPU higher by ≥ 1 core | Met | ### B.6 Limits - The load calls master RPCs only. There is no data transfer, no store process, and no RDMA, and the object size is fixed. - All 3 etcd members ran on one node, so member-to-member round trips are shorter than in a real deployment. - We chose the etcd compaction interval (§G depends on it). - The build had `MOONCAKE_ENABLE_OPLOG_PERF_METRICS=ON`, which adds one histogram observation per entry in HA. - When the load tool fell behind, it kept sending the backlog. The tables use the scheduled 361 s window. - We did not measure the sensitivity to `oplog_batch_max_entries` or the production peak rate (22k items/s). --- ## §C Restore path cost (M4) **Restoring a batch snapshot file at 25M keys** (upstream `245e7106`, same hardware as §A.3, median of 3): | Path | Time | Peak RSS | | -- | -- | -- | | `StandbyMetadataStore` fill, then `RestoreFromBatchOpLogPromotion`, 1 thread | 279 s | 29.6 GiB | | Same path with 14 decode threads | 291 s | 38.2 GiB | | Shard-parallel streaming restore, 1 / 14 threads | 67.0 s / 11.2 s | 25.0 GiB | **Where the 279 s goes.** - About 195 s is the per-chunk loop of `RestoreFromStandbyState`, which also runs at promotion. That loop takes a per-object shard lock, builds a per-chunk hash map, and inserts every replica into a per-segment `std::map`. - About 45 s is filling `StandbyMetadataStore`. - 15 s is `DrainChunk`. - 9 s is `ValidateReplicaIds`. - Decode threads do not help, because the store has one mutex. **Changes on `main` since `245e7106`.** Three commits touch the promotion restore: #3806, #4553, and #4559. We timed the restore call alone at 10M keys on both commits. This used the same input and a small harness that runs `StandbyMetadataStore` and then `RestoreFromBatchOpLogPromotion`, on one AMD EPYC 9454P node (5 CPU), alternating 3 runs per commit. | 10M keys | `245e7106` | `b140aa92` | Change | | -- | -- | -- | -- | | Restore time | 75.73 s | 65.47 s | −13.5% | | Peak RSS during restore | 11.21 GiB | 17.17 GiB | +5.96 GiB (+53%) | The extra memory matches two things: #4559 keeps the standby copy (3.8 GiB) until serving starts, and #3806 keeps `installed_keys` for rollback. So the 279 s at 25M is conservative for `main`, and the 38.2 GiB is now an underestimate. We did not re-measure at 25M. **Remount scan.** On `245e7106`, one `ReMountSegment` after a restore took 26.5 s at 25M keys, and 317 s for 12 stores in sequence. The call holds `client_mutex_` and the snapshot mutex exclusively. #4291, which would change this, is still open. With segment identity persisted and the allocator rebuilt at restore, the remount is an identity comparison of about 0.01 ms (§A.3). --- ## §D Safety mechanisms and correctness argument ### D.1 Mechanisms | # | Mechanism | Reason | | -- | -- | -- | | 1 | Stop the RPC and admin servers, wait for in-flight requests, then stop and join every background mutator (11 threads in our build) before the snapshot | No allocation or free after the snapshot | | 2 | Do not export in-flight puts. For a partially completed object, export only its completed replicas. Quarantine the ranges of the incomplete replicas, and of ranges the old master still held back (`discarded_replicas_`), for the remaining hold time (up to `put_start_release_timeout`) | A late RDMA write cannot land on a new object, and the gap does not grow | | 3 | Restore only a snapshot marked `READY`; rename it to `CLAIMED` before any allocation; lock file; `POISON` if two masters share the directory; maximum age (120 s by default) | No double restore, no stale restore, no repeated failure loop | | 4 | A restored memory segment is reconnected only if its identity (segment id, base, size, endpoint) and owner `client_id` match. Otherwise every restored entry of the segment is dropped. Restored disk replicas stay closed until an RPC from the same `client_id` arrives | Do not trust memory or disk of a store that restarted during the gap | | 5 | Do not delete objects whose lease expired during the gap; restored objects keep the old master's read lease (export time + `default_kv_lease_ttl`) | The legacy restore deleted every object not read within the lease (0 of 25M kept); readers that were transferring at SIGTERM stay protected | | 6 | Versioned format; an unreadable or corrupt snapshot means an empty start | Failures fall to the safe side (index loss) | | 7 | (A6 / track B) An offload copy still in flight at SIGTERM reports with the old master's task id, which the new master refuses. The store also re-checks that its copy source is unchanged before reporting | A copy of memory that was freed or reused is never registered | | 8 | (track B) Register a disk replica only from the completion of the current offload task taken by that store (task id = master epoch + sequence). Refuse registrations without a task. A store clears its offload directory on a new registration instead of re-registering | The existing L4 path could revive removed keys or copy freed memory; we reproduced this without a roll | ### D.2 Proof summary The claim: every replica descriptor that the master hands out for key `K` leads a reader who follows the read protocol to the bytes written by the put that created the current object, or to an error (a miss). Anything else counts as wrong: an older or newer value, another key's bytes, or a removed key coming back. **Handoff steps (memory replicas).** - **H1 Freeze.** The servers stop before the mutators. The writer runs only after `BackgroundWorkQuiesced()`, so the export sees one consistent state `S`, and every put or remove acknowledged to a client happened before `S`. - **H2 Snapshot rules.** The rules for in-flight, incomplete, and held-back ranges in D.1 #2. Replicas on segments that are gone are neither exported nor quarantined. - **H3 Gap.** While no master runs, store memory changes only through writes into ranges the old master allocated. Those writes are either finished in `S` or quarantined. - **H4 Restore validation.** Standalone only. Codec fingerprint and mode, quarantine ranges inside their segments, unique non-zero descriptor ids, COMPLETE state, sizes, exactly one segment per range. Any violation resets the master to empty. - **H5 Gate.** Restored segments are mounted as not allocatable and not readable. - **H6 Identity.** A mismatch drops the whole segment. Only a remount by the recorded owner with the same `Segment` opens it. - **H7 Allocator rebuild.** Live ranges and quarantine ranges are sorted, and any overlap fails the restore. The allocator imports exactly those ranges, and `Replica::next_id_` continues from the old master's value. - **H8 Reads after confirmation.** Same process, same mount, same addresses, and memory changed only by finished or quarantined writes. - **H9 Not carried.** In-flight objects, dropped segments, offload and promotion tasks. All of these become misses. **Assumptions that code cannot enforce:** | Assumption | Statement | | -- | -- | | A1 | Only the master allocates ranges in segments. | | A2 | PutEnd is sent only after the write finished. | | A3 | A writer stops using an allocation within `put_start_release_timeout`. | | A5 | Per-process and per-mount UUIDs do not collide. | | A8 | Readers finish within their read lease, and both masters use the same `default_kv_lease_ttl`. | | A9 | One master per handoff directory. A rollback to an image without the handoff must clear the directory. | | A12 | The wall clock does not jump far forward. | | Timeouts | Both masters use the same `put_start_release_timeout`. | The L4 part of the proof (steps L0–L7, the task-id uniqueness assumption, and the mixed-version analysis) belongs to track B and A6. HA mode is out of the proof's scope; the handoff refuses to run with HA. **Verification history.** - The proof was checked against the code in **AI-assisted review passes**: adversarial review agents (Claude Code), run separately from the session that wrote the proof. - The passes found real gaps, which we fixed: copies of items a store had not taken; offload takes made before a cold start; a hold that was not carried by a roll. - The fourth pass, on `145c50db`, found no remaining hole. The fork port (`35ef7acb`) has its own proof revision and verification. --- ## §E Failure cases and observability **Tested with the real binary and real stores over TCP.** Every case below started empty and safely, with 0 wrong reads: - no snapshot; - one byte flipped in a chunk (CRC mismatch); - write deadline exceeded; - write interrupted; - crash during the restore; - two masters sharing one directory. **Further tested cases.** - A store restarted during the gap: its old segment was dropped, and only new data was read. - 11 kinds of corrupt or incompatible snapshots were covered by unit tests; each one started an empty master. - A memory shortfall on either side led to a skipped handoff, and the reason appeared in logs and metrics. **Metrics:** - `master_handoff_last_snapshot_result`, `_bytes`, `_objects`, `_workers` - `master_handoff_last_shutdown_duration_seconds` - `master_handoff_restore_result`, `_duration_seconds`, `_objects` - `master_handoff_snapshot_age_seconds` - `master_handoff_segments_confirmed`, `_dropped`, `_pending` **Log lines:** - shutdown: `handoff_shutdown=start|serving_stopped|quiesced|deadline|...` - write: `handoff_write=ok|...` - startup: `handoff_startup=ok|claim_failed|poison_failed|deadline|...` **Result reasons include:** `skipped_restore_memory`, `skipped_processing`, `skipped_not_owner`, `skipped_poisoned`, `stale`, `deadline`, `poisoned`. **Flags in the fork.** Upstream names and defaults are open (RFC Q2). | Flag | Default | | -- | -- | | `--handoff_dir` | `""` (off) | | `--handoff_shutdown_deadline_sec` | 45 | | `--handoff_restore_deadline_sec` | 180 | | `--handoff_max_age_sec` | 120 | | `--handoff_max_workers` | 8 | | `--handoff_restore_workers` | 0 (= number of CPUs) | | `--handoff_restore_margin_percent` | 15 | --- ## §F Upstream observations we plan to report separately We will file each of these separately from this RFC discussion, as its own issue with a reproduction. They are bug reports, not arguments in favor of the handoff. They are listed here only so that maintainers know they are coming. Status on 2026-10-22: items 1–3 and 5 are reported as linked below. Items 2 and 3 were already under discussion elsewhere, so we added our data to those threads instead of opening new issues. Item 4 is addressed by #4802 instead of a separate issue. 1. **Eviction under OpLog.** When the reservation is full, an eviction cycle stops, and the next cycle collects candidates from the whole index again. Under a steady put rate this limits eviction to about 645 keys/s in our setup (§B.4). Reported in #4818. 2. **The skipped PutEnd counter.** `ha_oplog_dropped_put_end_total` is defined but never incremented. A skipped PutEnd record is visible only in a warning log. #4546 already covers this counter, and #4582 would delete it; we added our data there: https://github.com/kvcache-ai/Mooncake/issues/4546#issuecomment-6308811788 3. **`offload_on_evict` with OpLog.** The `offload_on_evict` branch is bypassed when OpLog is enabled. We may have missed a documented reason. This was already raised in #4723; we added our reading there: https://github.com/kvcache-ai/Mooncake/pull/4723#issuecomment-6308812084 4. **Remount identity check.** `ValidateStandbyRemountSegment` compares segment name, endpoint, and capacity only. We have not demonstrated a failure on the promotion path. A3 proposes adding segment id and base; this is now #4802. 5. **Graceful exit time.** A standalone master spends most of its graceful exit destroying the index. Re-measured on `main` at `8c19b728`: SIGTERM to exit took 20.7 s with 12M objects and 48.2 s with 25M objects (median of 3 runs; after SIGKILL the kernel's teardown took 1.1 s and 2.4 s). Reported in #4856. The first measurement (21.9 s with a 12M-object index) was a single run on build `828b43d7`, in the cold-restart phase. --- ## §G Unplanned failovers observed in the M3 rig (facts only) This section depends on our etcd configuration: 5-minute periodic compaction, chosen by us. We do not draw conclusions about rates from it. - In every HA run, an etcd compaction started 145–176 s into the load and took 80–88 s on a 5.7–5.8 GB database. During it, single OpLog transactions took 3–5 s. - The primary's etcd requests timed out. It lost its leadership lease and stopped serving. - The standby was promoted and restored 16.5M objects in 114 s in one run, and 16.7M in about 104 s in another. No master served for about 190 s and 160–170 s, respectively. - The new primary had fewer keys than the old one: 1,187,092 fewer in one run and 998,366 fewer in the other. The cumulative skipped PutEnd records in those runs were 1,189,417 and 1,001,474, which agree within 0.3%. - The put rejections at 148 s came from the memory limit, not from this stall. In one run, rejections began before the first etcd timeout.
Author
Owner

@github-actions[bot] commented on GitHub (Oct 15, 2026):

Thanks for opening this issue, @Juhyun-Kim-Memphis!

Field Value
Issue #4666
GitHub user ID 32131411
Reporter @Juhyun-Kim-Memphis

A maintainer will triage this when possible. To help us respond faster, please include:

  • Mooncake version or commit SHA
  • Environment (OS, CUDA/driver, RDMA stack if relevant)
  • Steps to reproduce and expected vs. actual behavior

Useful links: Documentation · Contributing guide

This message was posted automatically by the issue bot.

<!-- gh-comment-id:6183292617 --> @github-actions[bot] commented on GitHub (Oct 15, 2026): Thanks for opening this issue, @Juhyun-Kim-Memphis! | Field | Value | |-------|-------| | **Issue** | #4666 | | **GitHub user ID** | `32131411` | | **Reporter** | @Juhyun-Kim-Memphis | A maintainer will triage this when possible. To help us respond faster, please include: - Mooncake version or commit SHA - Environment (OS, CUDA/driver, RDMA stack if relevant) - Steps to reproduce and expected vs. actual behavior Useful links: [Documentation](https://kvcache-ai.github.io/Mooncake/) · [Contributing guide](https://github.com/kvcache-ai/Mooncake/blob/main/CONTRIBUTING.md) > This message was posted automatically by the issue bot.
Author
Owner

@Juhyun-Kim-Memphis commented on GitHub (Oct 20, 2026):

The implementation is now up for review as three PRs: #4802 (parallel promotion restore and a segment identity check on remount, useful for HA on its own), #4804 (the shutdown handoff itself, including its L4 state), and #4687 (the L4 wrong-read fix that the L4 part depends on). #4804 is open for review in parallel with the discussion here; the failure policy in Section 5 is still the main open question for it. I would be glad to hear the maintainers' view on that and on the questions in Section 9 whenever you have time.

<!-- gh-comment-id:6288345665 --> @Juhyun-Kim-Memphis commented on GitHub (Oct 20, 2026): The implementation is now up for review as three PRs: #4802 (parallel promotion restore and a segment identity check on remount, useful for HA on its own), #4804 (the shutdown handoff itself, including its L4 state), and #4687 (the L4 wrong-read fix that the L4 part depends on). #4804 is open for review in parallel with the discussion here; the failure policy in Section 5 is still the main open question for it. I would be glad to hear the maintainers' view on that and on the questions in Section 9 whenever you have time.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
kvcache-ai/Mooncake#1186
No description provided.