Skip to content

[Store] Delete the mooncake master's mass client expiry, cause and outcome - #13

Merged
Juhyun-Kim-Memphis merged 6 commits into
our_mainfrom
inf-461-master-mass-expiry-guard
Sep 8, 2026
Merged

Juhyun-Kim-Memphis merged 6 commits into
our_mainfrom
inf-461-master-mass-expiry-guard

Conversation

@Juhyun-Kim-Memphis

@Juhyun-Kim-Memphis Juhyun-Kim-Memphis commented Sep 7, 2026 •

Copy link
Copy Markdown
Collaborator

2026-09-05 solar-pro4-prod 사고 — store 파드 한 대가 붙어 480 GB 세그먼트를
remount 하는 동안 마스터가 멈추고, 그 침묵이 client_ttl(기본 10초)을 넘겨
멀쩡한 클라이언트 12개가 한꺼번에 만료되고 키 13.9M 개가 지워진 것 — 의
원인과 결과를 각각 하나씩 지운다. 스냅샷도 HA도 없으니 만료는 되돌릴 수 없다.
INF-461 P2.

커밋 두 개는 일부러 분리해 뒀다. (A) 는 upstream 백포트라 sync merge 때
자동으로 빠지고, (B) 는 우리 고유 수정이라 나중에 upstream 에 따로 낼 수
있어야 한다.

커밋 무엇
(A) [Store] Skip full metadata scan in ReMountSegment… upstream c5635ff3 (#3533) 백포트. ReMountSegment 의 1024-shard 순회를 any_standby_kept_alive 뒤로 가둔다
(B) [Store] Hold back a client mass expiry whose cause is the master's own lock ClientMonitorFunc 의 mass-expiry 서킷 브레이커 + 플래그 2개 + 지표 2개 + 유닛 테스트

앞의 세 커밋([Bench] …)은 재현 하네스이고 PR #12 에 있던 것을 그대로 옮겨온
것이다. #12 는 이 PR 로 대체하고 닫는다.

(A) — 원인을 지운다

enable_ha=0 인 우리 셀에서 standby_accounted_memory_bytes_ 는 항상 비어
있으므로, 그 순회는 전량 낭비였다. cherry-pick 은 충돌 없이 붙었고, 코드는
upstream 과 바이트 단위로 같다(주석 한 줄만 (B) 에서 붙였다).

측정(리그, 프로덕션과 같은 8 CPU / 16 rpc 스레드 형상):

키 수 백포트 전 후
1.0M 724,017 µs 3,629 µs
14.0M 9,760,564 µs 74,571 µs

(B) — 결과를 지운다

만료는 세그먼트를 언마운트하고 키를 지운다. 그러니 마스터는 "내 클라이언트가
죽었다" 와 "내가 하트비트를 처리하지 못했다" 를 구분해야 한다. 이 판단
규칙은 이번이 세 번째다.
앞의 두 개는 모두 증상 — 하트비트가 오지 않는다는
사실 — 을 근거로 삼았고, 그래서 둘 다 반증됐다.

  • 한 tick 에 만료되는 클라이언트의 비율 은 tick 경계가 어디 떨어지느냐에
    달려 있다. 한 번의 stall 이 만료를 연속된 tick 에 나눠 놓으면 각 tick 은
    소수만 들고 있어 임계값을 못 넘는다. 게다가 이 조건은 마스터가 "그 deadline
    이후에도 하트비트를 처리했다" 는 적극적 증거를 가진 tick 에서만 발동하는데,
    그건 정확히 만료 대상이 진짜로 죽은 상황이다. 즉 진짜 rack 단위 소실을
    유예하고, 진짜 stall 은 놓친다.
  • 마스터 자신의 침묵 은, 만료 대상에 "마스터가 가장 최근에 소식을 들은
    클라이언트" 가 포함되면 구조적으로 항상 참이다(deadline = 그 tick 의
    now + ttl, last_ping_batch_at = 같은 tick 의 now). 클라이언트가 하나인
    셀에서는 언제나 그렇고, 그래서 평범한 store 파드 재시작이 grace 60초
    내내 유예됐다.
    solar-pro4-bf16gqa 가 지금 1-클라이언트 셀이다.

증상으로는 "내가 처리하지 못했다" 와 "아무도 보내지 않았다" 를 가를 수 없다.
같은 관측이기 때문이다. 그래서 원인을 측정한다.

Ping 핸들러가 하는 일은 ok_client_ 의 shared-lock 읽기와 lock-free 큐
push 뿐이다. 따라서 client_mutex_ 를 exclusive 로 잡은 쪽만이 이 핸들러의
실행을 막을 수 있고
, 마스터는 자기가 그 락을 잡고 있는 시점을 안다.

defer = guard_enabled
        && grace > 0
        && !expired.empty()
        && ( exclusive_hold_overlapped(newest_expired_deadline - client_ttl, now)
             || (master_silent && tracked >= 2) )

1차 조건 — exclusive client_mutex_ hold 가
[newest_expired_deadline - client_ttl, now] 와 겹쳤는가.
그 구간이 "가장
늦게 만료된 후보의 하트비트가 갱신될 수 있었는데 갱신되지 않은 창" 이다.
client_mutex_ 를 exclusive 로 잡는 모든 지점이 hold 구간을 relaxed atomic
세 개에 기록한다(ReMountSegment, UpdateClientHostId,
ResetStateAfterFailedRestoreAttempt). 진행 중인 hold 도 센다 — 지금 잡고
있으면 지금 하트비트를 막고 있는 것이다. 최소 hold 시간 같은 상수는 두지
않았다. ClientMonitorFunc 자신의 만료 분기는 일부러 계측하지 않는다:
같은 락을 exclusive 로 잡으므로, 계측하면 지난 tick 의 만료 작업이 "마스터가
stall 했다" 는 증거로 되돌아온다.

이건 클라이언트가 아니라 마스터에 대한 진술이라서, tick 경계도 후보
개수도 흔들 수 없다.

2차 조건 — 마스터가 그 이후 ping 을 하나도 처리하지 못했고, 추적 중인
클라이언트가 2개 이상인가.
exclusive hold 만이 하트비트를 굶기는 길은
아니다 — io 스레드가 전부 snapshot_mutex_ 나 shard 락에 걸리면 Ping 도
같이 굶고 그때는 hold 가 기록되지 않는다. 그래서 침묵은 약한 증거로
남긴다. 클라이언트 수 조건이 이걸 증거로 만들어 주는 부분이다: 침묵이 마스터에
대해 뭔가 말해 주는 건 마스터를 대신해 말해 줄 다른 누군가가 있었을 때
뿐이고, 추적 클라이언트가 하나면 "아무도 ping 하지 않았다" 와 "그 하나가
죽었다" 는 같은 문장이다.

둘은 OR 다. 유예 쪽으로 기울지만 그게 유계인 방향이다 — 틀렸을 때 비용이
client_mass_expiry_grace_sec 만큼의 stale 세그먼트인 반면, 반대로 틀리면
셀의 L3 캐시 전체다.

케이스 표 (코드의 주석과 같은 표이고, 각 행이 테스트다)

상황 동작
1-클라이언트 셀, 그 클라이언트가 죽음, 마스터 정상 즉시 만료
1-클라이언트 셀, ReMountSegment stall 유예
13개, 한 번의 stall 이 6/7 로 갈림 유예 (hold 만으로)
13개, 한 번의 stall, 1/12 로 갈림 유예
13개, 마스터 정상, 과반이 진짜로 죽음 즉시 만료
13개, 마스터 정상, 전원이 진짜로 죽음 유예 후 grace 상한에서 만료

마지막 행은 2차 조건을 남긴 대가이고 회피할 수 없다. 전원이 사라진 플릿과
전면 stall 한 마스터는 동일한 하트비트 스트림을 만든다(pop 없음, 추적
클라이언트 전원 overdue, hold 없음). 비용은 이미 전부 사라진 소유자들의
세그먼트에만 떨어지고, grace 로 유계다.

남은 구멍 (residual gap)

client_mutex_ hold 가 아닌 원인으로 굶는 1-클라이언트 셀은 키를 잃는다.
io 스레드가 전부 shard 락에 걸린 경우가 그 형태다 — hold 가 기록되지 않고,
추적 클라이언트 하나는 증거가 되지 못한다. 막으려면 데이터 경로와 분리된
liveness 신호가 필요하고, 이 PR 은 그것을 만들지 않는다. 숨기지 않고 코드
주석과 커밋 메시지에 명시했다.

의도적으로 하지 않은 것 — 유예 중 세그먼트 격리

리뷰에서 "유예하는 동안 해당 세그먼트를 DRAINING 으로 내려 쓰기를 막자" 는
제안이 있었지만 일부러 넣지 않았다. 원인 기반 규칙에서 유예되는 케이스는
진짜 stall 이고, 그때 클라이언트들은 살아 있어서 그쪽으로 가는 쓰기가 정상
성공한다. 그러니 얻는 것이 없고, 대신 브레이커를 운영자가 몰던 drain 상태
기계와 엮어 버린다. 뒤집으려면 "유예된 클라이언트가 실제로 죽어 있고 쓰기를
받고 있는" 케이스가 필요하다.

삭제한 것

--mass_expiry_fraction 과 그 validator·기본값 상수·config 키·builder
setter·문서, 바닥값 kMinMassExpiryClients, 판정과 로그의 bulk/threshold.
튜너블이 둘로 줄었다.

구조

판정 자체가 한 tick 의 관측값에 대한 순수 함수 EvaluateClientMassExpiry
(ClientMassExpiryInputs → ClientMassExpiryDecision)다. 그래서 경계 케이스가
sleep 없는 테이블 테스트가 되고, grace 비교도 초 단위로 절단하지 않고 raw
duration 으로 한다.

유예는 아무것도 하지 않는 것이다. TTL 엔트리가 그대로 남고, 언마운트도
준비되지 않고, stale-handle sweep 도 돌지 않는다. deadline 을 손으로 갱신하지도
않는다 — 회복은 모니터가 매 tick 하는 큐 drain 이 알아서 하고, 그래야 grace 가
에피소드 전체의 상한으로 남는다.

플래그 기본값 뜻
--client_mass_expiry_guard true 브레이커 자체의 on/off
--client_mass_expiry_grace_sec 60 한 에피소드의 상한

둘 다 "조용히 끄는 값" 이라서, guard=false 든 grace=0 든 시작 시
WARNING 을 한 줄 남긴다. grace validator 의 술어는 config 파일 경로와
공유한다 — DEFINE_validator 는 커맨드라인 플래그에만 돌기 때문에, yaml 로
들어온 음수 값은 이전에는 검사를 통째로 건너뛰고 브레이커를 조용히 껐다.

지표 종류 뜻
master_client_expiry_deferred_total counter 유예한 tick 수
master_client_expiry_deferred_clients gauge 지금 유예 중인 클라이언트 수 — 알람이 볼 값

gauge 는 프로세스 전역 싱글턴인데 쓰는 쪽은 인스턴스별 모니터 스레드라서,
절대값 set 대신 active_clients 와 같은 inc/dec 델타로 발행한다. 한 프로세스
안의 두 MasterService 가 서로를 덮어쓰지 않고, 모니터 루프가 유예 중에
끝나도 소멸자가 자기 몫을 되돌린다.

검증

유닛 테스트는 리그에서 실행했다.

스위트 결과
master_service_test (162개) PASSED (295 s)
master_service_config_test (9개) PASSED
브레이커 관련만 (11개: wall-clock 7 + sleep 없는 순수 4) PASSED (33 s)

테스트가 옳은 이유로 통과하는지도 확인했다. 두 번째 설계가 리뷰까지
살아남은 이유가 "엉뚱한 이유로 통과하는 테스트" 였으므로, 두 조건을 차례로
빌드에서 지우고 다시 돌렸다.

변형 결과
1차 조건 삭제 (exclusive_hold_overlap = false 강제) SingleClientFleetStallIsDeferred FAILED, ClientMassExpirySplitAcrossTicksIsDeferred FAILED. 나머지 둘은 그대로 통과 — 1차 조건에 기대지 않는 케이스라 맞다
bulk 재삽입 MajorityGenuineDeathOnHealthyMasterExpiresAtOnce FAILED, CaseTable FAILED

리그 게이트

프로덕션과 같은 8 CPU / 60 Gi / 16 rpc 스레드 형상의 마스터 파드에서,
12개 클라이언트 + 480 GB 세그먼트 + BatchExistKey 폐루프 + --independent_ping.

게이트 1 — (B) 단독, 트리거를 살려둔 상태. (A) 를 빌드 시점에 되돌리고
18.0M 키. 합류 클라이언트의 ReMountSegment 가 13,170,621 µs (10초
client_ttl 대비 13.2초) — 트리거가 확실히 임계값을 넘었다.

                clients  keys       ping    defer dclients
11:00:33          12     18000000   14216       0     0   <- stall, ping 정지
 ... (동일 샘플 6개 더)
11:00:39          12     18000000   14216       1    12   <- 브레이커 판정
11:00:40          12     18000000   14216       2    13
11:00:41          13     18000000   14235       3    13   <- 해제, ping drain
11:00:42          13     18000000   14248       3     0   <- 에피소드 종료
W action=client_mass_expiry_deferred, expired_count=12, tracked_clients=13,
  exclusive_hold_overlap=1, master_silent=1, silent_for_sec=9, deferred_for_sec=0, grace_sec=60

만료 0건, 키 18,000,000개 그대로, master_active_clients 12 미만으로 내려간
적 없음.
로그가 어느 조건이 발동했는지 (exclusive_hold_overlap=1) 밝힌다.
세 tick 만으로 끝났다 — stall 이 풀리고 다음 drain 이 13개 deadline 을 전부
갱신하자 에피소드가 스스로 닫혔다.

같은 형상을 15.0M 키에서 먼저 돌렸는데, 그때는 스캔이 9,831,669 µs 로
10초 TTL 을 0.17초 차로 넘지 못해 만료가 아예 발생하지 않았다. 프로그램 문서의
제약("만료는 14.2-14.4M 근처의 임계 사건") 그대로이고, 트리거가 임계값 통과에
달린 게이트는 게이트가 아니라서
18M 으로 다시 돌렸다.

게이트 1 의 teardown 이 두 행을 공짜로 줬다 (15M·18M 두 번 모두 동일):

I 11:06:37  action=client_expired            <- 1개 단독, 유예 없음
I 11:07:00  action=client_expired            <- 10개가 한 tick 에, 유예 없음 (x10)
W 11:07:10  action=client_mass_expiry_deferred, expired_count=2, tracked_clients=2,
            exclusive_hold_overlap=0, master_silent=1
E 11:08:10  action=client_mass_expiry_grace_exceeded, ..., deferred_for_sec=60, grace_sec=60

12개 중 10개가 한 tick 에 유예 없이 만료된 것이 defect 2 의 수정을 마스터가
직접 진술한 것
이다 — 옛 bulk 는 10 ≥ ceil(0.5 x 12) = 6 로 읽어 살아 있는
클라이언트들의 하트비트가 마스터의 건강을 증명하는 동안 열 개를 60초 붙잡았다.
마지막 2개는 전원 소실 행 그대로: grace 만큼 유예 후 만료.

게이트 2 — 1-클라이언트 셀의 두 케이스. 세그먼트 1개(추적 클라이언트 1개),
--client_ttl=2, 3.2M 키.

2b — 정상 마스터에서 단독 사망: 즉시 만료. graceful exit 은 명시적 release 라
만료 경로가 아니므로 SIGKILL 로 죽였다.

deferred_total before the kill: 116
10:48:06 clients=1 keys=3200000 defer=116 dclients=0   <- SIGKILL
10:48:08 clients=0 keys=2860989 defer=116 dclients=0   <- 만료, TTL 하나 뒤
10:48:13 clients=0 keys=      0 defer=116 dclients=0
 ... (샘플 16개 더: defer 116 고정, dclients 0 고정)

kill 부터 만료까지 정확히 TTL 하나(2초), 유예 카운터는 한 번도 안 움직였다.
옛 규칙에서는 master_silent 이 구조적으로 참이라 60초 grace 를 통째로
기다렸을 자리다. defect 1 을 닫는 측정이다.

2a — 같은 셀의 ReMountSegment stall 중: 유예, 키 보존. 에피소드 첫 tick:

W action=client_mass_expiry_deferred, expired_count=1, tracked_clients=2,
  exclusive_hold_overlap=1, master_silent=0, deferred_for_sec=0, grace_sec=60

master_silent=0 — 그 순간 마스터는 하트비트를 처리하고 있었으므로 침묵 조건은
아무 기여도 하지 않았고, 기록된 hold 가 판정 전부다. 이 런의 유예 tick 116개 중
13개가 이 형태이고, hold 없이 침묵만으로 유예된 tick 은 0개다. 그동안
master_active_clients 는 1, master_key_count 는 3,200,000 에서
움직이지 않았다.

게이트 3 — 마감 게이트, (A)+(B) 를 사고와 같은 형상에서. 14.0M 키,
클라이언트 12개, 부하 중 13번째 합류, 그리고 손으로 8번 더 합류(30초 간격 4번 +
연속 3번).

  • 주 합류의 ReMountSegment 298,598 µs (P1 의 무보정 9,760,564 µs 대비 세
    자릿수 개선). ping gap 없음, 유예 0.
  • 30초 간격 4번: 163,846 / 345,621 / 27,313 / 423,088 µs — 전부 0.5초 미만.
    각 합류자의 클라이언트는 프로세스 종료 후 단독으로 즉시 만료(유예 없음).
  • 연속 3번은 flaw 2 의 ClearStaleHandles sweep 을 재현: 9,563,521 /
    8,833,041 / 8,539,128 µs
    . 그리고 이 세션에서 가장 강한 로그 한 줄을 줬다:
W action=client_mass_expiry_deferred, expired_count=1, tracked_clients=15,
  exclusive_hold_overlap=1, master_silent=0, deferred_for_sec=0, grace_sec=60

15개 중 1개, 그리고 master_silent=0. 이번에 교체한 두 규칙 어느 쪽도 이
tick 을 유예하지 못한다 — 비율 조건은 8개가 필요한데 1개였고, 침묵 조건은
마스터가 방금 하트비트를 처리했으니 거짓이다. hold 조건 단독으로 유예했고,
이 에피소드 26 tick 중 25개가 같은 형태다. tick-split 유닛 테스트와 케이스 표의
"1/12 로 갈림" 행의 라이브 대응물이고, 첫 설계가 깨진 바로 그 케이스이며 두
번째 설계도 놓쳤을 케이스다.

에피소드는 26 tick, grace 초과 0. sweep 이 끝나고 마스터가 그 deadline
이후의 하트비트를 처리하자 합류자 3개를 만료시켰다 — 스스로를 보증할 수 없는
동안 유예하고, 보증할 수 있게 된 순간 만료.

아무것도 잃지 않았다. 부하 구간 600 샘플 전체에서:

값
master_key_count min 14,000,000, max 14,000,006 — 내려간 적 없음(6개는 합류자들이 쓴 키)
master_active_clients min 12, max 16
유예 tick 26개, 에피소드 1개
grace_exceeded 0
client_expired 20개 — 합류자 7개 + teardown 13개. 런 중 장수 클라이언트는 하나도 만료되지 않음

상태 정정 (2026-09-21, INF-522)

Upstream: kvcache-ai#3533
Absorbed: no (upstream took only the kvcache-ai#3533 part; the breaker was removed in #16 and the three [Bench] master_bench commits stay carried)

Juhyun Kim and others added 4 commits September 7, 2026 11:50
master_bench mounts every segment before the load starts, so the one
control-plane event a production master actually has to absorb while it is
loaded -- a store pod joining and mounting its segment -- cannot be
expressed. A master that stops answering Ping for longer than
client_live_ttl_sec expires every client it has, unmounts their segments and
erases the keys those segments held, so how long a mount holds the segment
lock on a loaded master decides whether the whole cache survives.

Add the event and the two views needed to read it:

- --mount_extra_at_sec / --extra_segment_size: at that second of the load
  phase a further client connects and mounts one more segment. It is
  constructed outside segment_clients_mutex, because holding the mutex the
  ping thread walks would stop this benchmark's own pings for the mount's
  duration -- an observer manufacturing the symptom it is measuring.
- --metrics_server / --metrics_csv / --metrics_names: a once-per-second
  scrape of the master's own metrics endpoint into a long-format CSV, plus
  the scrape's own latency, which is itself a reading when the master is
  stalled. --metrics_names defaults to empty and keeps every metric
  exported; a guessed name that the master does not export reads exactly
  like a metric that never moved.
- --ping_csv: each client's Ping outcome and latency, and each remount's
  latency. The master-side scrape cannot show a Ping that never reached the
  master, which is the observation this is for.

Ping and remount failures are now recorded and returned from rather than
thrown out of the ping thread. Throwing loses the only client observing the
master at exactly the moment of interest, and the master answering again
afterwards -- the recovery -- is part of what has to be measured.

Every default keeps the previous behaviour: no extra mount, no sampler, no
ping log.

Upstream: none (not submitted yet)
Two gaps that stop master_bench from standing in for a serving cell's
master.

A client only enters the master's ok_client_ set by remounting, and a
remount of a segment that already holds replicas fails: with HA off,
standby_accounted_memory_bytes_ is empty, so ReMountSegment returns
INTERNAL_ERROR for every segment whose descriptors it had to restore. The
existing bench mounts and starts load in the same breath, so its clients
never register, master_active_clients stays 0 for the whole run, and the
master has nothing it could expire -- the state a real cell is in for its
entire life is unreachable. --wait_register_sec waits for the first Ping to
come back OK on every client before the load barrier, which is the order a
store fleet starts in. It defaults to 0, the previous behaviour.

BatchExistKey is the request a hicache client actually makes -- one lookup
per prefix-cache probe -- and it dominates master traffic on a serving
cell, but the bench could only Put and Get. Add it as an --operation,
prefilling like the other read operations do.

Upstream: none (not submitted yet)
One thread pinging every client in turn cannot tell a master that stopped
answering from a pinger blocked on the first client in its list: when the
master holds an exclusive lock, the shared pinger stalls inside client 0's
Ping and never issues client 1..N's at all, so their silence is the
benchmark's own and not the master's. A store pod pings on its own timer,
and whether every client independently observes the stall is exactly the
question when the master's response to that silence is to expire all of
them.

--independent_ping gives each client its own 1 Hz ping thread. Off by
default; the shared pinger is unchanged and still the one that runs.

Upstream: none (not submitted yet)
…tored replicas pending (kvcache-ai#3517) (kvcache-ai#3533)

Backport of upstream c5635ff. ReMountSegment walked all 1024 metadata
shards while holding client_mutex_ and snapshot_mutex_ both exclusively,
and Ping needs client_mutex_ shared, so no client could heartbeat for the
duration of the scan. The scan only has work to do for segments whose
standby-restored replicas are still accounted, which never happens without
HA -- so on a non-HA master it consumed 0.72 s at 1M keys and 13.64 s at
18M keys to produce nothing, against a 10 s client_ttl.

Co-authored-by: jiayuzailiu <jiayuzailiu@tencent.com>

Upstream: kvcache-ai#3533
(cherry picked from commit c5635ff)
…n lock

A master expires a client by unmounting its segments and erasing its keys, and
with no snapshot and no HA that is unrecoverable -- one bad tick costs a cell
its whole L3 cache. ClientMonitorFunc had no way to tell "my clients died" from
"I failed to process their heartbeats", and the second is what actually
happens: an exclusive lock held across a key-count-proportional scan stops the
master servicing Ping at all, so client after client lapses with nothing wrong
on their side.

The absence of heartbeats cannot separate those two, because it is the same
observation either way. So measure the cause. Ping needs client_mutex_ shared,
which makes an exclusive holder of that lock the one thing that can stop the
handler running -- and the master knows when it holds its own lock. Every site
that takes client_mutex_ exclusively now records its hold window into atomics
(ReMountSegment, UpdateClientHostId, ResetStateAfterFailedRestoreAttempt), and
the monitor holds an expiry back when a recorded hold overlapped
[newest_expired_deadline - client_ttl, now], the window in which the newest
candidate's heartbeat could have been refreshed and was not. ClientMonitorFunc's
own expiry branch takes the lock exclusively too and is deliberately not
instrumented: a previous tick's expiry work must not come back as evidence that
the master was stalling.

The master's silence stays as weaker, secondary evidence, because an exclusive
hold is not the only way to starve heartbeats -- io threads all blocked on
snapshot_mutex_ or on shard locks starve Ping too, and record no hold. It is
gated on the monitor tracking at least two clients. Silence says something
about the master only when there was somebody else who could have spoken for
it; with one tracked client, "nobody pinged" and "that client died" are the
same sentence, and reading it as a stall would hold back every store pod
restart in a single-client cell for the whole grace.

The two are OR-ed, so the breaker errs towards holding back: being wrong that
way costs client_mass_expiry_grace_sec of stale segments, being wrong the other
way costs the cell its cache. The cases, all of them covered by tests:

  1 client, it dies, master healthy               expire now
  1 client, a ReMountSegment stall                hold back
  13 clients, one stall split 6/7 across ticks    hold back
  13 clients, one stall, split 1/12               hold back
  13 clients, healthy master, a majority die      expire now
  13 clients, healthy master, whole fleet dies    hold back, then expire at
                                                  the grace bound

The last row is the price of keeping the silence evidence, and it is not
avoidable: a fleet that has entirely gone and a master that has entirely
stalled produce the identical heartbeat stream. The cost falls on segments
whose owners are all gone, and it is bounded by the grace.

The residual gap is a single-client cell starved by something other than a
client_mutex_ hold -- all io threads busy on shard locks, say. No hold is
recorded and one tracked client is not evidence, so that cell's keys go.
Closing it needs a liveness signal off the data path, which this does not
build.

The share of clients expiring in one tick, which an earlier version of this
used, is gone. It fires only when the master has positive evidence it served
heartbeats after those deadlines lapsed -- which is exactly when the expiring
clients really are dead -- so it held back a genuine rack loss while the master
was demonstrably healthy, and it was defeated in the other direction by where a
tick boundary fell. That also cuts the tunables to two.

Holding back is doing nothing at all: client_ttl keeps its entry, no unmount is
prepared and no stale-handle sweep runs, and no deadline is refreshed by hand.
Recovery is the monitor's own queue drain, so a client whose heartbeat lands
during the deferral stops being a candidate on its own, and the grace stays a
bound on the whole episode rather than on the interval between refreshes.

The judgement itself is a pure function of one tick's readings
(EvaluateClientMassExpiry), so its boundary cases are a table-driven test with
no sleeping, and the grace is compared on raw durations rather than on whole
seconds.

client_mass_expiry_guard, default true, is the named off switch; it and a zero
client_mass_expiry_grace_sec both log a startup warning, because they are
silent-disable values. The grace's validator predicate is shared with the
config-file path, which the DEFINE_validator does not reach.
master_client_expiry_deferred_total counts the ticks held back;
master_client_expiry_deferred_clients is how many clients are held back right
now, published as a delta so two MasterService instances in one process
compose, and taken back on every exit from the monitor loop.

Upstream: none (not submitted yet)
@Juhyun-Kim-Memphis
Juhyun-Kim-Memphis force-pushed the inf-461-master-mass-expiry-guard branch from 4a5b3e2 to 7f5e4ca Compare September 7, 2026 11:49
…r owes

The breaker deferred an expiry when a recorded exclusive client_mutex_ hold
overlapped [newest_expired_deadline - client_ttl, now]. That left edge is
pinned to one instant -- the candidate's own last heartbeat pop -- and never
advances while now does, so any hold that ever fell inside a dying client's
last ttl window stayed evidence for the whole grace. A routine few-millisecond
mount by another client was enough: a client that genuinely died had its
segments kept for 60 s while every other client's heartbeats were flowing and
proving the master healthy.

The window is now [last_ping_batch_at, now], the stretch this master has no
heartbeat of its own to show for. A completed hold counts only while no
heartbeat has been popped since it released; once one has, the master is
demonstrably serving Ping again, that hold is refuted as an explanation, and a
candidate still overdue is genuinely gone. A hold in progress still counts
unconditionally, because it is stopping heartbeats at this instant.

The narrowing cannot lose keys. It never widens the window and never narrows
below the old one where the old one was doing real work: a candidate's last pop
is by definition at or before last_ping_batch_at, and the two coincide exactly
when the newest candidate is the last client the master heard from -- the
single-client cell and the whole fleet, which keep the protection they had. It
is the heartbeat stream and not the wall clock for the same reason: after a
hold releases the master can stay starved for many more ticks, and through that
no heartbeat is popped, so last_ping_batch_at does not advance and the released
hold stays evidence. A window of "holds within the last tick or two" would have
aged out mid-stall and erased the cache.

client_live_ttl is no longer an input to the judgement, so it is gone from
ClientMassExpiryInputs. No flag, metric or log field changes.

Two wall-clock cases had encoded the pinned edge and now say what the rule
does: the split-across-ticks case stalls the master with a real exclusive hold
instead of leaning on another client's released registration hold, and the
growing-candidate-set case gets a ttl longer than its own stagger, because
under the new rule each group's registration heartbeat is proof the master is
serving and would expire the group before it. The pure decision table gains the
stale-hold row this change is about, the recovery-gap row that must still
defer, and per-row expectations for exclusive_hold_overlap and master_silent so
a row says which condition carried it.
@Juhyun-Kim-Memphis Juhyun-Kim-Memphis added upstream:open upstream PR 제출됨 (열림) and removed upstream:not-submitted upstream에 제출하지 않음 labels Sep 8, 2026
@Juhyun-Kim-Memphis

Copy link
Copy Markdown
Collaborator Author

(B) 를 upstream 에 제출했습니다: kvcache-ai#3936 (draft).

upstream/main 8715bb7c 위에 단일 커밋으로 리베이스했고, 브랜치는 Juhyun-Kim-Memphis:inf461-master-mass-expiry-guard, 커밋은 a4016a2b 입니다. 이 PR 의 (A) 는 upstream 에 이미 머지된 kvcache-ai#3533 백포트라 제출 대상이 아닙니다.

리베이스에서 실제로 손이 간 곳:

  • upstream 이 master_service_test.cpp 를 시나리오 DSL 로 옮기면서 MasterServiceTest fixture 를 새 master_service_test_fixture.h 로 빼냈습니다. 테스트 헬퍼 7개는 그 헤더로, 테스트 11개는 .cpp 로 재배치했고, 같은 헤더를 쓰는 master_service_group_test 도 빌드·실행해 13/13 통과를 확인했습니다.
  • master_config.h 충돌은 우리 포크 전용 malloc_trim_growth_ratio 가 새 필드가 들어갈 자리에 있던 것이고, upstream 쪽에는 없으므로 새 필드만 남겼습니다.
  • master_service.h 는 upstream 의 ShrinkBucketsIfSparse ([Store] Shrink metadata maps after eviction cycles kvcache-ai/Mooncake#3576) 와 같은 지점이라 양쪽 다 유지했습니다.

upstream 기준 검증: 빌드 무경고, master_service_test 80/80, master_service_config_test 9/9, 브레이커 11/11, group 13/13. 포맷은 upstream 이 요구하는 clang-format 20.1.0 으로 손댄 파일 전부 0 replacement 입니다 (리그의 16 이 잡던 master_metric_manager.cpp 지적은 버전 차이였습니다).

upstream 제출용으로 뺀 것: 사내 이슈 키·모델·클러스터·노드 이름 전부, 그리고 ReMountSegment/standby 쪽 곁가지 주석 두 개 — 후자는 브레이커가 아니라 upstream 코드를 설명하는 것이라 PR 초점을 넓히고, "HA 아닌 마스터의 맵은 항상 비어 있다"는 단정이 standby 없는 우리 검증으로는 뒷받침되지 않기 때문입니다. 7f5e4ca2 에 그대로 남아 있습니다.

upstream 템플릿이 500 LOC 초과 변경에 RFC 이슈를 먼저 요구하는데, 이 변경은 1637 insertions (테스트·fixture 985, 프로덕션 코드 약 660) 이라 해당됩니다. 체크박스를 비워 두고 "원하면 RFC 를 먼저 열고 이 PR 을 그 뒤에 세우겠다"고 본문에 적었습니다.

@Juhyun-Kim-Memphis

Copy link
Copy Markdown
Collaborator Author

upstream 템플릿의 500 LOC 규칙에 따라 RFC 이슈를 먼저 열었습니다: kvcache-ai#3937. upstream PR kvcache-ai#3936 본문은 두괄식으로 다시 써서 RFC 를 참조하도록 교체했고, RFC 에 문제 정의·대안 비교·행동 변화·메인테이너 질문 4개를 실었습니다.

@Juhyun-Kim-Memphis
Juhyun-Kim-Memphis marked this pull request as ready for review September 8, 2026 05:50
@Juhyun-Kim-Memphis
Juhyun-Kim-Memphis merged commit 22df4bf into our_main Sep 8, 2026
1 check passed
@Juhyun-Kim-Memphis Juhyun-Kim-Memphis added upstream:merged upstream에 머지됨 (다음 sync 때 탈락 대상) and removed upstream:open upstream PR 제출됨 (열림) labels Sep 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

run-ci store upstream:merged upstream에 머지됨 (다음 sync 때 탈락 대상)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants