kvcache-ai / kvcache-ai/Mooncake

[RFC]: Reliable Metadata Across Node Replacement

Open
#2,762 3 comments 1 reaction 0 assignees View on GitHub
RFC
Dominant language
C++
Stars
6.6k
Forks
1.2k
Avg merge
3d 5h
Merged PRs (30d)
312

Description

### Changes proposed

## Summary

A Transfer Engine process may restart while keeping the same segment name.
Remote nodes can then retain metadata and RDMA resources that belong to the old
process.

We propose a small recovery protocol:

1. Refresh cached remote metadata after a bounded TTL.
2. Give every process incarnation a random `instance_id`.
3. Treat a segment descriptor as one immutable snapshot.
4. When `instance_id` changes, retire all resources derived from the old peer.
5. On transport failure, force a refresh instead of waiting for TTL.

The work is split into four stages so that cache behavior, wire compatibility,
and RDMA recovery can be reviewed independently.

## Problem

A segment name identifies a logical endpoint, not a particular process. After a
replacement, the new process may publish different:

- NIC addresses and device attributes;
- memory addresses and registration keys;
- topology and routing information.

Comparing those fields is useful, but it is not a reliable replacement signal.
A process restarted on the same host may reuse NIC identity, virtual addresses,
or registration key values.

A version counter alone also does not solve the problem. If every process starts
at version 1, two different instances are indistinguishable. Seeding the counter
from wall-clock time adds clock assumptions without providing a strict identity.

The required property is:

> A consumer eventually detects that a segment name is owned by a new process,
> stops creating work from the old snapshot, retires old derived resources, and
> either recovers on the new process or returns a bounded failure.

## Scope and Assumptions

This RFC assumes:

- one legitimate publisher per segment name at a time;
- atomic read and write of one metadata key;
- a complete segment descriptor is stored under that key;
- RDMA endpoints remain alive until their outstanding work drains;
- metadata requests and RDMA operations can fail.

This RFC covers remote metadata caching and RDMA recovery after process
replacement. It does not cover:

- two live publishers competing for the same segment name;
- ordering concurrent writers within one process;
- safe memory deregistration while remote work is in flight;
- metadata-service replication or disaster recovery.

If exclusive ownership cannot be guaranteed, a lease and fencing protocol is
required in addition to this design.

## Design

### Process incarnation

Add an opaque process identity to `SegmentDesc`:

```cpp
struct SegmentDesc {
std::string instance_id;
// Existing descriptor fields.
};
```

`instance_id` must:

- be generated once when Transfer Engine starts;
- contain at least 128 random bits;
- remain unchanged for the process lifetime;
- change on every process start;
- have no ordering or wall-clock semantics.

All segments published by one Transfer Engine instance use the same ID.

### Immutable snapshot

The descriptor, including `instance_id`, devices, and buffers, is published as
one metadata value. A reader installs it only after the complete value has been
fetched, decoded, and validated:

```text
fetch -> decode -> validate -> atomically replace cached shared_ptr
```

A failed or malformed refresh must not overwrite a valid snapshot. Network I/O
must not run while holding `segment_lock_`.

### Bounded refresh

Each remote cache entry records its last successful refresh:

```cpp
struct SegmentCacheEntry {
std::shared_ptr snapshot;
uint64_t refreshed_at_ns;
};
```

Lookup behavior is:

| Condition | Result |
|---|---|
| Local segment | Return the local snapshot |
| Remote snapshot within TTL | Return the cached snapshot |
| Remote snapshot past TTL | Fetch and install a new snapshot |
| Ordinary refresh fails | Keep and return the old snapshot |
| Forced refresh | Bypass TTL |
| Forced refresh fails | Return failure, not stale success |

TTL controls when replacement is discovered during normal operation. It is not
the replacement identity.

### Replacement handling

The RDMA worker tracks minimal state per target:

```cpp
struct TargetPeerState {
std::string instance_id;
std::vector peer_nic_paths;
};
```

When a newly observed `instance_id` differs from the recorded value:

1. update the target state to the new instance;
2. retire every endpoint associated with the old instance;
3. clear rail failure and pause state for its paths;
4. rebuild queued slices from the new descriptor;
5. establish new endpoints lazily.

Endpoint deletion must occur after releasing the target-state mutex. Retired
endpoints continue through the existing two-phase destruction path so in-flight
work can drain safely.

The first implementation invalidates the complete target. Field-level
invalidation is an optional optimization for later.

### Failure-triggered recovery

TTL leaves an observation window between replacement and the next refresh.
Recoverable handshake, QP, CQ, or work-request failures therefore trigger:

```text
retire failed endpoint
-> force metadata refresh
-> detect replacement
-> reselect buffer, device, rkey, and NIC path
-> retry with the existing bounded budget
```

A forced-refresh failure consumes the retry budget or fails the operation. It
must not create an unbounded retry loop.

## Delivery Plan

### Stage 1: Bounded metadata cache

**Goal:** ensure remote descriptors do not remain cached forever.

Changes:

- add a successful-refresh timestamp per remote cache entry;
- apply `metadata_cache_ttl_ms` to remote lookups;
- preserve local-segment lookup behavior;
- fetch metadata without holding the cache lock;
- define ordinary and forced-refresh failure behavior.

Exit criteria:

- TTL and forced-refresh tests pass;
- malformed or failed refreshes cannot replace a valid snapshot;
- concurrent readers always observe a complete descriptor.

This stage does not change the wire format or invalidate RDMA resources.

### Stage 2: Incarnation identity

**Goal:** distinguish two processes that publish the same segment name.

Changes:

- generate one random `instance_id` at process startup;
- add backward-compatible JSON encoding and decoding;
- publish the ID in every local segment descriptor;
- treat a missing ID as a legacy peer rather than synthesizing one.

Exit criteria:

- two restarts always produce different IDs;
- old descriptors remain decodable;
- new descriptors can be read by older implementations that ignore unknown
fields.

This stage exposes identity but does not yet act on replacement.

### Stage 3: RDMA replacement recovery

**Goal:** stop using resources derived from a replaced peer.

Changes:

- track `instance_id` and paths per target in `WorkerPool`;
- detect incarnation changes after metadata lookup;
- retire old endpoints and clear old rail state;
- rebuild queued slices from the new descriptor;
- force refresh from recoverable transport-failure paths.

Exit criteria:

- replacement is detected even when descriptor resource fields are reused;
- old endpoints follow two-phase destruction;
- the new peer can establish an endpoint and complete the transfer;
- refresh and retry behavior remains bounded.

### Stage 4: Hardening and rollout

**Goal:** make replacement recovery observable and safe to deploy gradually.

Changes:

- add replacement, refresh, endpoint-retirement, and retry-exhaustion logs;
- add refresh and replacement counters;
- run mixed-version compatibility tests;
- add an end-to-end restart test;
- document TTL semantics and select a production default.

Exit criteria:

- old and new nodes can coexist during rollout;
- operators can identify refresh failures and replacement recovery;
- the restart test passes without endpoint leaks, deadlocks, or infinite
retries.

## Compatibility

Readers accept descriptors with or without `instance_id`.

For a legacy peer without an ID:

- TTL refresh still updates descriptor contents;
- transport failure still retires the failed endpoint and forces a refresh;
- proactive and unambiguous replacement detection is unavailable.

Rollout order is:

1. deploy readers that accept both formats;
2. enable writers to publish `instance_id`;
3. enable RDMA invalidation on ID changes;
4. remove legacy behavior only after the compatibility window closes.

## Validation

The essential scenarios are:

1. A cached descriptor is refreshed after TTL but not before it.
2. A transport failure bypasses TTL.
3. A new `instance_id` retires old endpoints and rail state.
4. The same `instance_id` does not cause replacement cleanup.
5. Replacement is detected when NIC names, addresses, and simulated rkeys are
unchanged.
6. A failed refresh or retry returns a bounded failure.
7. Concurrent lookup and replacement do not expose partial snapshots or create
lock-order deadlocks.
8. Old descriptors remain usable during rolling upgrade.

### Before submitting a new issue...

- [x] Make sure you already searched for relevant issues and read the [documentation](https://kvcache-ai.github.io/Mooncake/)

Contributor guide

Open the contributing guide

Research direction

Start by locating SegmentDesc serialization, remote metadata cache lookup, and WorkerPool target-state and transport-failure paths. Review the TTL, forced-refresh, instance-change, compatibility, and restart scenarios in the validation plan. Done means bounded refresh behavior, safe snapshot replacement, old-endpoint retirement, mixed-version compatibility, and restart recovery without leaks or deadlocks.

Written by the indexing model from the issue text.

Assessment

Tech stack
cpp
Domain
distributed-systems, networking
Issue type
Feature
Difficulty
5/5
Estimated time
Over a week
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
30/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.