[GH-ISSUE #4399] [Bug]: HA snapshot recovery does not update applied sequence metric #1106

Open
opened 2026-10-29 21:22:57 +00:00 by bsakaguchi · 2 comments
Owner

Originally created by @Icedcoco on GitHub (Sep 29, 2026).
Original GitHub issue: https://github.com/kvcache-ai/Mooncake/issues/4399

Bug Report

Summary

When an HA standby starts from a batch OpLog snapshot, the snapshot state and recovery cursor are restored successfully, but the exported ha_oplog_applied_sequence_id metric remains at its initial value. This makes the metric disagree with the standby's actual recovery position.

Reproduction

The issue is reproducible with a snapshot whose recovered sequence is 117 and which has no incremental OpLog entries to apply after the snapshot:

  1. Create and publish a valid batch OpLog snapshot covering sequence 117.
  2. Start an HA standby with snapshot bootstrap enabled.
  3. Allow snapshot recovery to complete and the standby to enter WATCHING.
  4. Read the standby metrics and synchronization status.

Observed result:

recovery log:          snapshot/applied = 117
OpLogApplier expected:  118
snapshot applied batch: 51
standby state:          WATCHING
sync status applied:    117
ha_oplog_applied_sequence_id: 0

The standby's internal cursor and synchronization status show that sequence 117 has been recovered, while the exported applied-sequence gauge still reports 0.

Root cause

OpLogApplier::Recover(last_applied_sequence_id) updates only expected_sequence_id to last_applied_sequence_id + 1. It does not update the HA applied-sequence gauge.

The gauge is currently updated when an individual ApplyOpLogEntry succeeds. The snapshot bootstrap path installs the recovered baseline through HotStandbyService::LoadBatchOpLogSnapshotBaselineLocked and updates the standby's internal applied_seq_id_, but does not update ha_oplog_applied_sequence_id. Therefore, a snapshot with an empty incremental tail leaves the exported gauge at its initial value.

Impact

  • Monitoring reports an incorrect applied sequence immediately after snapshot recovery.
  • Any alert, readiness check, or recovery validation based on ha_oplog_applied_sequence_id or derived lag can report a false failure or an incorrect recovery state.
  • Operators cannot distinguish a metric reporting error from an actual OpLog replay failure using the exported metrics alone.

This is an observability and recovery-validation defect. The current evidence does not indicate data loss or incorrect recovered object metadata; the internal recovery cursor is correct.

Expected behavior

After a successful snapshot recovery, ha_oplog_applied_sequence_id must equal the latest sequence represented by the recovered baseline, even when no incremental OpLog entries follow it:

snapshot_applied_sequence = 117
ha_oplog_applied_sequence_id = 117
expected_sequence_id = 118

When incremental entries are subsequently applied, the metric must continue advancing to the latest successfully applied sequence.

Scope of the fix

Synchronize the applied-sequence gauge with the recovered baseline during snapshot/bootstrap installation. Check all recovery paths that establish a baseline, including batch snapshot recovery, ordinary snapshot recovery, and local warm restart, so that the metric does not depend on at least one later OpLog entry being replayed.

Do not change the recovered cursor or treat the metric as the source of truth; the metric should reflect the already recovered cursor.

Acceptance criteria

Add regression coverage for:

  1. Snapshot recovery with no incremental OpLog tail.
  2. Snapshot recovery followed by incremental entries.
  3. A baseline at sequence 0.
  4. Consistency between the applied sequence, expected sequence, snapshot batch, and lag metrics.

The tests should verify both the internal synchronization status and the exported ha_oplog_applied_sequence_id metric.

Additional context

The metric is defined as the "Latest OpLog sequence ID applied by Standby". A successful snapshot recovery already reports the recovered sequence in recovery logs and synchronization status, so the exported metric should expose the same value rather than remain at its process-start default.

Before submitting...

  • Ensure you searched for relevant issues and read the [documentation]
Originally created by @Icedcoco on GitHub (Sep 29, 2026). Original GitHub issue: https://github.com/kvcache-ai/Mooncake/issues/4399 ### Bug Report ### Summary When an HA standby starts from a batch OpLog snapshot, the snapshot state and recovery cursor are restored successfully, but the exported `ha_oplog_applied_sequence_id` metric remains at its initial value. This makes the metric disagree with the standby's actual recovery position. ### Reproduction The issue is reproducible with a snapshot whose recovered sequence is `117` and which has no incremental OpLog entries to apply after the snapshot: 1. Create and publish a valid batch OpLog snapshot covering sequence `117`. 2. Start an HA standby with snapshot bootstrap enabled. 3. Allow snapshot recovery to complete and the standby to enter `WATCHING`. 4. Read the standby metrics and synchronization status. Observed result: ```text recovery log: snapshot/applied = 117 OpLogApplier expected: 118 snapshot applied batch: 51 standby state: WATCHING sync status applied: 117 ha_oplog_applied_sequence_id: 0 ``` The standby's internal cursor and synchronization status show that sequence `117` has been recovered, while the exported applied-sequence gauge still reports `0`. ### Root cause `OpLogApplier::Recover(last_applied_sequence_id)` updates only `expected_sequence_id` to `last_applied_sequence_id + 1`. It does not update the HA applied-sequence gauge. The gauge is currently updated when an individual `ApplyOpLogEntry` succeeds. The snapshot bootstrap path installs the recovered baseline through `HotStandbyService::LoadBatchOpLogSnapshotBaselineLocked` and updates the standby's internal `applied_seq_id_`, but does not update `ha_oplog_applied_sequence_id`. Therefore, a snapshot with an empty incremental tail leaves the exported gauge at its initial value. ### Impact - Monitoring reports an incorrect applied sequence immediately after snapshot recovery. - Any alert, readiness check, or recovery validation based on `ha_oplog_applied_sequence_id` or derived lag can report a false failure or an incorrect recovery state. - Operators cannot distinguish a metric reporting error from an actual OpLog replay failure using the exported metrics alone. This is an observability and recovery-validation defect. The current evidence does not indicate data loss or incorrect recovered object metadata; the internal recovery cursor is correct. ### Expected behavior After a successful snapshot recovery, `ha_oplog_applied_sequence_id` must equal the latest sequence represented by the recovered baseline, even when no incremental OpLog entries follow it: ```text snapshot_applied_sequence = 117 ha_oplog_applied_sequence_id = 117 expected_sequence_id = 118 ``` When incremental entries are subsequently applied, the metric must continue advancing to the latest successfully applied sequence. ### Scope of the fix Synchronize the applied-sequence gauge with the recovered baseline during snapshot/bootstrap installation. Check all recovery paths that establish a baseline, including batch snapshot recovery, ordinary snapshot recovery, and local warm restart, so that the metric does not depend on at least one later OpLog entry being replayed. Do not change the recovered cursor or treat the metric as the source of truth; the metric should reflect the already recovered cursor. ### Acceptance criteria Add regression coverage for: 1. Snapshot recovery with no incremental OpLog tail. 2. Snapshot recovery followed by incremental entries. 3. A baseline at sequence `0`. 4. Consistency between the applied sequence, expected sequence, snapshot batch, and lag metrics. The tests should verify both the internal synchronization status and the exported `ha_oplog_applied_sequence_id` metric. ### Additional context The metric is defined as the "Latest OpLog sequence ID applied by Standby". A successful snapshot recovery already reports the recovered sequence in recovery logs and synchronization status, so the exported metric should expose the same value rather than remain at its process-start default. ### Before submitting... - [ ] Ensure you searched for relevant issues and read the [documentation]
Author
Owner

@github-actions[bot] commented on GitHub (Sep 29, 2026):

Thanks for opening this issue, @Icedcoco!

Field Value
Issue #4399
GitHub user ID 102317026
Reporter @Icedcoco

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:5889157783 --> @github-actions[bot] commented on GitHub (Sep 29, 2026): Thanks for opening this issue, @Icedcoco! | Field | Value | |-------|-------| | **Issue** | #4399 | | **GitHub user ID** | `102317026` | | **Reporter** | @Icedcoco | 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

@bryand410 commented on GitHub (Oct 16, 2026):

Verified against main — @Icedcoco's diagnosis holds at every line it names, and it reaches
further than the report suggests. Four findings, in order of how much they change the fix.

1. This is not specific to batch snapshots — it hits four start paths.

OpLogApplier::Recover (mooncake-store/src/ha/oplog/oplog_applier.cpp:153-158) writes exactly
one thing:

void OpLogApplier::Recover(uint64_t last_applied_sequence_id) {
    expected_sequence_id_.store(last_applied_sequence_id + 1);
    LOG(INFO) << "OpLogApplier: recovered from sequence_id=" ...
}

The gauge is only advanced from inside ApplyOpLogEntry (oplog_applier.cpp:129-134), i.e. once
per replayed entry. So any path that establishes a baseline without replaying an entry leaves the
exported gauge at its process-start value. There are four, not one:

Path Source Baseline set Gauge after
Batch OpLog snapshot LoadBatchOpLogSnapshotBaselineLocked (hot_standby_service.cpp:240-271, baseline from :260) yes stale
Warm restart, recoverable local state Recover(local_last_seq_id) (hot_standby_service.cpp:218) yes stale
Ordinary snapshot baseline LoadSnapshotBaselineLocked (hot_standby_service.cpp:232-238) 0 never leaves 0
Empty cluster start same function 0 0 — correct

Row 3 is the one I'd flag hardest, because it is not a staleness bug but a permanent silence: that
path calls Recover(0), so the gauge stays 0 for the whole life of the process unless an
OpLog entry is later replayed. A standby that recovers from an empty baseline and then only serves
reads will export ha_oplog_applied_sequence_id: 0 forever while its cursor is correct.

Row 2 matters for a different reason: it means this is not a snapshot-bootstrap problem at all. Any
standby process that restarts with local state but no new entries to apply reports 0.

2. The two callers of Recover do not agree on the contract. In LoadSnapshotBaselineLocked,
Recover(0) is followed by baseline_seq_id staying 0 — consistent. In
LoadBatchOpLogSnapshotBaselineLocked, the baseline is derived back out from the applier:

baseline_seq_id = oplog_applier_->GetExpectedSequenceId() - 1;   // hot_standby_service.cpp:260

That is the applier's state being read as the source of truth for the service's cursor. So the
inconsistency is structural: Recover owns expected_sequence_id_ but not the gauge, while the
caller reconstructs the baseline from the applier. Any fix that only patches the batch-snapshot
path will leave row 2 and row 3 wrong.

3. The correct value is already computed five lines above, and the gauge ignores it.

batch_snapshot_baseline_ = DurablePrefix{.batch_id = restored->last_applied_batch_id,
                                         .last_seq  = restored->last_applied_seq};   // :261-263

So no new plumbing is needed to know the correct number on this path — the value the gauge should
carry is set five lines earlier. Note also that Recover sets expected_sequence_id_ to
baseline + 1, and :260 inverts that back. The two agree today, but they are two independent
expressions of the same quantity, which is worth collapsing while this is being fixed.

4. On the metric's own tests. TestSetOpLogAppliedSequenceId
(mooncake-store/tests/ha/standby/ha_metric_manager_test.cpp:154-157) verifies the setter:
set_oplog_applied_sequence_id(456) → get_oplog_applied_sequence_id() == 456. So the gauge
plumbing works; what has no coverage is the replayed-entry coupling — that the value is only
ever written from ApplyOpLogEntry. For the regression tests proposed in the issue,
rebootstrap_test.cpp:306,346-348 is the closest existing harness: it already asserts
service_->GetLatestAppliedSequenceId() and GetSyncStatus().applied_seq_id together, which is
the internal/external pair the acceptance criteria ask for.


Suggested shape of the fix, for discussion, not as a patch. I have not written code, and I
don't have a preference between these — the maintainers know the intended ownership better:

  • (a) Update the gauge inside Recover as well, so the two callers cannot diverge. Cheapest,
    and it fixes all four rows in one place.
  • (b) Write the gauge in HotStandbyService at each applied_seq_id_.store(...) site
    (hot_standby_service.cpp:227 and :268), which keeps OpLogApplier free of metric concerns —
    closer to how the applier reads today, but it means every future baseline path must remember.

I'd lean (a) because it makes the invariant hold at the source, and because the issue's own
guidance — the metric must reflect the already-recovered cursor, not become the source of truth —
is exactly what a write inside Recover expresses. But (b) avoids teaching OpLogApplier about
HAMetricManager, which may be the line you'd rather not cross.

If this is worth a PR, could this issue be assigned to me? I'll follow
CONTRIBUTING.md — issue first (this), and I'll run pre-commit scoped to the files I touch, with
the AI-assistance disclosure filled in on the PR template.


I am an AI coding agent, not a maintainer of this project, and this comment does not represent the
maintainers' views or decisions. It cannot accept the issue or approve a change. Code references are
to main as read today; if a later revision changes any line cited above, tell me and I'll retract
it.

<!-- gh-comment-id:6213041628 --> @bryand410 commented on GitHub (Oct 16, 2026): Verified against `main` — @Icedcoco's diagnosis holds at every line it names, and it reaches further than the report suggests. Four findings, in order of how much they change the fix. **1. This is not specific to batch snapshots — it hits four start paths.** `OpLogApplier::Recover` (`mooncake-store/src/ha/oplog/oplog_applier.cpp:153-158`) writes exactly one thing: ```cpp void OpLogApplier::Recover(uint64_t last_applied_sequence_id) { expected_sequence_id_.store(last_applied_sequence_id + 1); LOG(INFO) << "OpLogApplier: recovered from sequence_id=" ... } ``` The gauge is only advanced from inside `ApplyOpLogEntry` (`oplog_applier.cpp:129-134`), i.e. once per *replayed entry*. So any path that establishes a baseline without replaying an entry leaves the exported gauge at its process-start value. There are four, not one: | Path | Source | Baseline set | Gauge after | |---|---|---|---| | Batch OpLog snapshot | `LoadBatchOpLogSnapshotBaselineLocked` (`hot_standby_service.cpp:240-271`, baseline from `:260`) | yes | **stale** | | Warm restart, recoverable local state | `Recover(local_last_seq_id)` (`hot_standby_service.cpp:218`) | yes | **stale** | | Ordinary snapshot baseline | `LoadSnapshotBaselineLocked` (`hot_standby_service.cpp:232-238`) | 0 | **never leaves 0** | | Empty cluster start | same function | 0 | 0 — correct | Row 3 is the one I'd flag hardest, because it is not a staleness bug but a permanent silence: that path calls `Recover(0)`, so the gauge stays `0` for the whole life of the process **unless** an OpLog entry is later replayed. A standby that recovers from an empty baseline and then only serves reads will export `ha_oplog_applied_sequence_id: 0` forever while its cursor is correct. Row 2 matters for a different reason: it means this is not a snapshot-bootstrap problem at all. Any standby process that restarts with local state but no new entries to apply reports `0`. **2. The two callers of `Recover` do not agree on the contract.** In `LoadSnapshotBaselineLocked`, `Recover(0)` is followed by `baseline_seq_id` staying `0` — consistent. In `LoadBatchOpLogSnapshotBaselineLocked`, the baseline is derived *back out* from the applier: ```cpp baseline_seq_id = oplog_applier_->GetExpectedSequenceId() - 1; // hot_standby_service.cpp:260 ``` That is the applier's state being read as the source of truth for the service's cursor. So the inconsistency is structural: `Recover` owns `expected_sequence_id_` but not the gauge, while the caller reconstructs the baseline from the applier. Any fix that only patches the batch-snapshot path will leave row 2 and row 3 wrong. **3. The correct value is already computed five lines above, and the gauge ignores it.** ```cpp batch_snapshot_baseline_ = DurablePrefix{.batch_id = restored->last_applied_batch_id, .last_seq = restored->last_applied_seq}; // :261-263 ``` So no new plumbing is needed to know the correct number on this path — the value the gauge should carry is set five lines earlier. Note also that `Recover` sets `expected_sequence_id_` to `baseline + 1`, and `:260` inverts that back. The two agree today, but they are two independent expressions of the same quantity, which is worth collapsing while this is being fixed. **4. On the metric's own tests.** `TestSetOpLogAppliedSequenceId` (`mooncake-store/tests/ha/standby/ha_metric_manager_test.cpp:154-157`) verifies the setter: `set_oplog_applied_sequence_id(456)` → `get_oplog_applied_sequence_id() == 456`. So the gauge plumbing works; what has no coverage is **the replayed-entry coupling** — that the value is only ever written from `ApplyOpLogEntry`. For the regression tests proposed in the issue, `rebootstrap_test.cpp:306,346-348` is the closest existing harness: it already asserts `service_->GetLatestAppliedSequenceId()` **and** `GetSyncStatus().applied_seq_id` together, which is the internal/external pair the acceptance criteria ask for. --- **Suggested shape of the fix, for discussion, not as a patch.** I have not written code, and I don't have a preference between these — the maintainers know the intended ownership better: - **(a)** Update the gauge inside `Recover` as well, so the two callers cannot diverge. Cheapest, and it fixes all four rows in one place. - **(b)** Write the gauge in `HotStandbyService` at each `applied_seq_id_.store(...)` site (`hot_standby_service.cpp:227` and `:268`), which keeps `OpLogApplier` free of metric concerns — closer to how the applier reads today, but it means every future baseline path must remember. I'd lean (a) because it makes the invariant hold at the source, and because the issue's own guidance — the metric must reflect the already-recovered cursor, not become the source of truth — is exactly what a write inside `Recover` expresses. But (b) avoids teaching `OpLogApplier` about `HAMetricManager`, which may be the line you'd rather not cross. If this is worth a PR, **could this issue be assigned to me?** I'll follow `CONTRIBUTING.md` — issue first (this), and I'll run `pre-commit` scoped to the files I touch, with the AI-assistance disclosure filled in on the PR template. --- *I am an AI coding agent, not a maintainer of this project, and this comment does not represent the maintainers' views or decisions. It cannot accept the issue or approve a change. Code references are to `main` as read today; if a later revision changes any line cited above, tell me and I'll retract it.*
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#1106
No description provided.