Repository navigation
[INF-599] Fence the local disk tier on the first unrecoverable disk error - #22
Merged
Merged
Conversation
A dead offload disk today keeps being used: every failed read or write returns a FILE_* error code that cannot tell a dead device from a short write or a full disk, the errno is dropped, and the heartbeat keeps taking offload work and re-mounting the LOCAL_DISK segment. Keep the errno and decide at the first failure: - StorageFile records the errno of its first failed syscall (sys_errno()); PosixFile sets it for write/pwritev/read/preadv/ fdatasync. Short reads and writes stay logical (errno 0). - StorageBackendInterface gains SetDiskErrorObserver (default no-op). BucketStorageBackend reports open/pwritev/fdatasync/write/read/preadv failures with their errno. - FileStorage classifies the errno inline on the failing thread: ENODEV/ENXIO/EROFS/ESHUTDOWN fence; EIO and the block-layer and corruption errnos run one filesystem check (syncfs to force the XFS log, then fstat on a held offload-dir fd, which fails with EIO exactly when XFS is shut down) and fence only if it fails; everything else (ENOSPC, ENOENT, ...) is ignored. No counters or time windows. - A fence is latched for the process lifetime: reads short-circuit and offload stops at once; the heartbeat (woken immediately) disables offloading and unmounts the segment through the drain latch. - The drain latch is no longer reset when the unmount RPC fails. While draining, each heartbeat retries the unmount until the master acknowledges it (the master treats an already-unmounted segment as success) and never re-mounts. - New gauge mooncake_ssd_disk_fenced (0/1). (cherry picked from commit 5495213, upstream-candidate branch inf-599/disk-fence on 245e710. Conflicts: file_storage.h includes (kept our_main <unordered_set> beside the new <condition_variable>/<mutex>); file_storage_test.cpp helper block (dropped upstream-only ConcurrentOffloadBackend context from kvcache-ai#4097, kept the new FailingReadBackend). No behaviour change.)
Follow-up to the disk fence, from review: - ApplyDiskFence now sends one OffloadObjectHeartbeat with offloading disabled before the unmount (skipped if a drain already unmounted). The unmount drops the master's offload queue for this store but not the source-replica references its tasks hold, so those memory replicas stayed pinned until the master's TTL reaper (600 s). The heartbeat makes the master release them and stop enqueuing; it is best effort. - The filesystem check runs syncfs only after a write-path failure (pwritev, fdatasync, write, open). A failed read leaves the log nothing to write (read failures alone never shut XFS down on the rig), and one bad file otherwise caused a whole-filesystem flush on every failed read. Read failures check with fstat only. - If the offload dir fd could not be opened, the check falls back to stat on the path instead of silently never fencing. - mooncake_ssd_disk_fenced is set to 0 at construction, so the series exists before a fence. - The unmount retry logs failures at WARNING; the vector_read and vector_write failure logs carry the errno; disk_error_not_fenced names the check that ran (fstat or syncfs+fstat). Tests: the fenced scenarios now queue a real offload task and check that the master released it (Upsert succeeds) instead of checking a local flag; the fenced OffloadObjects assertion uses a key with a memory replica, so it fails without the fence; read failures are checked without syncfs and write failures with it; the gauge is exported before any failure.
our_main only (the kvcache-ai#3006 deferred carry is not upstream). After a fence no OffloadObjects call comes to re-emit the backend's ungrouped pool, so the tasks carried for it (this cycle's deferrals, and the pool's carryover that a fence-aborted first bucket never reached) were neither written nor NACKed and pinned their source replicas until the master's TTL reaper. ApplyDiskFence now NACKs every carried task and drops the pool, once, on the heartbeat thread that owns the carry map. The fence check in OffloadObjects stays where it is: keys it leaves unbucketed are NACKed by the sweep unless carried, and carried keys are NACKed here. Test: a task deferred to the pool before the fence is released by it (Upsert succeeds), and the carry map and pool are empty.
Juhyun-Kim-Memphis
marked this pull request as ready for review
October 7, 2026 09:00
This was referenced Oct 8, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
무엇을 바꾸나
L4(SSD offload) 디스크가 죽었을 때, store 가 첫 번째 복구 불가능한 디스크 오류에서 로컬 디스크 tier 를 격리(fence) 하도록 합니다. 격리되면 그 store 는 디스크 읽기·쓰기를 즉시 멈추고, master 에서 LOCAL_DISK segment 를 내립니다. 메모리 tier 는 계속 서빙합니다.
이 PR 은 upstream 후보 브랜치
inf-599/disk-fence(upstream main245e7106기준, upstream PR kvcache-ai#4490)를our_main위로 cherry-pick 한 것입니다. 커밋은 셋입니다.6fe89e5e54952130과 동일e0dbb2a8syncfs없이 검사, gauge 0 초기화, 로그 보강75fe664c과 동일b5d2bab7our_main전용): 격리 시 kvcache-ai#3006 carry 로 들고 있던 task NACK왜 필요한가
2026-09-10 사고에서 디스크 한 장이 죽자 모든 엔진이 1시간 동안
FILE_OPEN_FAIL을 받았습니다. 지금 코드에서는 다음 세 가지가 겹칩니다.FILE_*오류 코드만 돌려주고 errno 는 버려집니다. 그래서 "장치가 죽었다"와 "짧은 쓰기·디스크 가득 참"을 구분할 수 없습니다.SEGMENT_NOT_FOUND를 보면 segment 를 다시 mount 합니다.draining_latch 가 풀리고, 다음 heartbeat 가 segment 를 다시 mount 합니다. 리그에서 master 의 디스크 index 6.2만~7.6만 건이 사라지는 것을 재현했습니다.어떻게 동작하나
StorageFile이 처음 실패한 syscall 의 errno 를 기록합니다(sys_errno()).PosixFile의write/read/pwritev/preadv/fdatasync실패 지점에서 설정합니다. 짧은 읽기·쓰기는 errno 0 으로 남겨 논리 오류로 취급합니다.StorageBackendInterface::SetDiskErrorObserver를 추가했습니다(기본은 아무것도 안 함).BucketStorageBackend가open/preadv/pwritev/fdatasync/write/read실패를 errno 와 함께 알립니다. 다른 backend 는 영향이 없습니다.FileStorage::OnDiskError가 실패한 스레드에서 바로 errno 를 분류합니다. 카운터나 시간 창은 쓰지 않습니다.ENODEV,ENXIO,EROFS,ESHUTDOWNEIO,EUCLEAN,EBADMSG,ENODATA,EREMOTEIO,EILSEQ,ETIMEDOUT,ENOLINKEIO는 리그에서 실측(모든 실패 경로의 유일한 errno). 나머지는 block layer·XFS verifier 조사 결과ENOSPC,EDQUOT,EFBIG,ENOENT,EEXIST,EACCES,EMFILE,ENOMEM,EBADF, …ENOSPC는 실측. 캐시는 evict 할 수 있으므로 격리 사유가 아님syncfs+fstat(열어 둔 offload 디렉터리 fd). 생성자에서 offload 디렉터리를O_RDONLY|O_DIRECTORY로 열어 두고, 검사할 때syncfs로 XFS 로그를 강제로 쓴 뒤fstat의 errno 를 봅니다.fstat이 무시 분류가 아닌 errno 로 실패하면 격리합니다.syncfs는 쓰기 계열 실패(pwritev·fdatasync·write·open)에서만 돌립니다. 읽기 실패(preadv·read)는fstat만 봅니다. 읽기 실패는 로그에 쓸 것을 남기지 않고(리그에서 읽기 실패만으로는 XFS 가 shutdown 된 적이 없음), 나쁜 파일 하나가 읽기 실패마다 파일시스템 전체 flush 를 일으켰기 때문입니다(E3: 150초에 1,435회). 그 대가로, offload 쓰기가 전혀 없는 store 는 장치가 죽어도 첫 쓰기 실패 때 격리됩니다. 격리하지 않은 실패의 로그 줄(action=disk_error_not_fenced)에 어느 검사를 했는지(check=fstat또는check=syncfs+fstat) 남깁니다.stat을 써서 같은 신호를 봅니다(예전에는 검사가 EBADF 로 조용히 꺼졌음).fstat: XFS 가 shutdown 된 경우에만 정확히EIO를 돌려주고, 장치 I/O 를 하지 않으며,dmsetup suspend중에도 멈추지 않았습니다(실측).syncfs가 필요한 이유: XFS 는 로그 쓰기가 실패해야 shutdown 됩니다. 죽은 장치에서 store 가 처음 보는EIO는 보통 shutdown 이전이고, idle 상태에서는 180초 동안 shutdown 되지 않았습니다.syncfs가 로그를 밀어 shutdown 을 일으키므로 바로 뒤의fstat이 그것을 봅니다.statfs를 쓰지 않은 이유:statfs와 XFS geometry ioctl 은 shutdown 이후에도 성공합니다. 검사로 쓸 수 없습니다.syncfs가EIO를 한 번 내도fstat은 성공하므로 격리하지 않습니다(의도한 동작).filesystem_check_running_). 검사가 도는 동안 들어온 실패는 자기 검사를 건너뛰고 돌고 있는 검사의 결론을 따릅니다.FileStorage::BatchLoad가 장치를 건드리지 않고FILE_READ_FAIL을 돌려줍니다(원격BatchGet, 로컬BatchGetLocal, promotion 모두 이 한 곳을 지남).OffloadObjects는 다음 bucket 경계에서 멈춥니다. bucket 에 들어가지 못한 task 는 기존 NACK sweep 이 반납하지만,our_main의 [Bug]: [SSD] Offload keys deferred to the ungrouped pool lose their tasks — NACKed at deferral, silently dropped next cycle, never written to SSD kvcache-ai/Mooncake#3006 carry 대상(이번 cycle 에 deferred 된 키와, 중단된 첫 bucket 에 실려 있던 ungrouped pool 의 carryover 키)은 sweep 이 NACK 하지 않고 carry 맵에 남깁니다. 이 task 들은 아래 heartbeat 단계에서 NACK 합니다(b5d2bab7).enable_offloading_ = false,draining_ = true로 만듭니다. 그다음 한 번만 다음을 하고 LOCAL_DISK segment 를 unmount 합니다.our_main전용).OffloadObjectHeartbeat(false)를 보냅니다. master 는 이 store 앞으로 쌓인 offload 대기열을 반납하고, 그 task 들이 잡고 있던 source memory replica 의 refcount 를 내리며, 더는 enqueue 하지 않습니다. drain 이 이미 unmount 했으면 건너뜁니다. best effort 이고, 실패하면 master 의 TTL reaper(600초)가 예전처럼 회수합니다.enable_offloading_ = false만 바꿨는데, heartbeat 가 draining 이면 바로 return 하므로 이 값은 master 에 전달되지 않았습니다. unmount(UnregisterClient)는 대기열을 버리지만 task 와 refcount 는 그대로 두므로, 해당 memory replica 들이 최대 600초 동안 evict 되지 않았습니다(리뷰 S1). 지금은 위 두 단계가 이를 반납합니다.mooncake_ssd_disk_fenced(0/1). 생성자에서 0 으로 설정하므로 격리 전에도 시리즈가 있습니다(처음 버전에서는 첫 격리 전까지 시리즈가 없었음). 격리된 store 는 프로세스도 살아 있고 메모리 tier 도 서빙하므로 겉보기에는 정상입니다. 이 gauge 가 알람을 걸 수 있는 유일한 신호입니다.DrainLocalDiskSegment에서 unmount RPC 가 실패해도draining_을 더 이상 되돌리지 않습니다. draining 중에는 heartbeat 가 매 tick 마다 unmount 를 다시 시도하고(master 는 이미 unmount 된 segment 를 성공으로 처리하므로 재시도는 안전), 다시 mount 하는SEGMENT_NOT_FOUND분기에는 도달하지 않습니다.observer 는 RPC 를 하지 않고, I/O 경로나 heartbeat 가 잡는 lock 도 잡지 않습니다.
enable_offloading_은 heartbeat 가 RPC 를 하는 동안 잡고 있는offloading_mutex_아래에 있으므로, 그 변경은 heartbeat 스레드에서만 합니다.our_main 으로 옮기며 생긴 충돌
our_main(16f99c46= upstream7faef38bsync + kvcache-ai#4183 carry)은245e7106보다 upstream 커밋 86개 뒤에 있습니다. 충돌은 두 곳이었고 동작에는 영향이 없습니다.mooncake-store/include/file_storage.hinclude 블록:our_main의<unordered_set>과 새<condition_variable>/<mutex>를 모두 유지했습니다.mooncake-store/tests/file_storage_test.cpp테스트 helper 블록: upstream 에만 있는ConcurrentOffloadBackend([Store] Report skipped-duplicate keys to the Master so source replicas release on time kvcache-ai/Mooncake#4097) 문맥은 버리고, 새FailingReadBackend만 유지했습니다.자동으로 합쳐진 나머지 hunk 는 원본 패치와 추가·삭제 줄이 한 줄도 다르지 않음을 diff 로 확인했습니다. 리뷰 반영 1(
e0dbb2a8)은 충돌 없이 cherry-pick 되었고, 추가·삭제 줄이 upstream75fe664c과 같습니다.our_main의OffloadObjects는 deferred task carry(fork PR #15, upstream kvcache-ai#3009 backport)를 갖고 있습니다. 처음 버전 본문에는 "격리 시PartitionUnbucketedTasks가 같은 방식으로 미처리 task 를 NACK 한다"고 적었지만, carry 대상 키에는 틀린 설명이었습니다. 격리 검사는 키를all_bucket_keys에 넣기 전에 loop 를 끊으므로, carryover 키는 "이전에 carry 된, 이번에 emit 되지 않은 키"로 다시 carry 되고 NACK 되지 않습니다. 격리 뒤에는OffloadObjects가 다시 불리지 않으므로 이 task 들은 TTL 까지 남았습니다. 격리 검사 위치는 그대로 두고, 격리를 적용하는 heartbeat 에서 carry 맵 전체를 NACK 하도록 고쳤습니다(b5d2bab7). carry 맵은 heartbeat 스레드만 만지므로 새 동기화는 필요 없고, 격리 뒤 첫 heartbeat 가 반드시 이 단계를 지나므로 carry 된 task 는 경로와 무관하게 모두 반납됩니다. clang-format 20.1.0 을 적용해도 바뀌는 것이 없습니다.테스트
리그(ganymede
inf599-rig, Release,USE_CUDA=OFF)에서 이 브랜치를 별도 소스·빌드 디렉터리로 전체 빌드했고(pybindstore.so포함), 아래 suite 가 모두 통과했습니다.합계 238개, 실패 0 (리뷰 반영 후
b5d2bab7기준). 전체 target 빌드(725 단계,ninja -j32245초)와import mooncake.store도 확인했습니다. upstream 후보 브랜치와 file_storage_test 개수가 같은 것은our_main에 없는 upstream 테스트(kvcache-ai#4097 등)가 하나 빠지고our_main전용 테스트가 하나 더해졌기 때문입니다. storage_backend_test 가 2개 적은 것도 같은 이유입니다.새 테스트 11개(파일 3개)는 각각 결정 하나씩을 고정합니다. 분류표, errno 기록, backend 연결, 죽은 파일시스템·살아 있는 파일시스템·
ENOSPC·ENODEV에서의 격리 여부, 단일 검사 보장, 그리고 R3 latch 입니다. R3 latch 테스트(DrainUnmountFailureKeepsLatchAndRetries)는 upstream base 에서 컴파일되고 실패하며(4개 assertion), 이 브랜치에서 통과합니다.리뷰 반영으로 바뀐 테스트:
enable_offloading_)만 봤습니다.OffloadObjects가 backend 에 닿지 않는다" assertion 은 memory replica 가 있는 키로 바꿨습니다. 처음 버전의 키는 replica 가 없어 격리 검사가 없어도 통과했습니다.syncfs없이, 쓰기 실패의 검사는syncfs와 함께 도는지 봅니다. gauge 가 실패 전부터 직렬화되는지 봅니다.DiskFenceNacksCarriedOffloadTasks(our_main전용): 격리 전에 pool 로 deferred 된 키의 task 가 격리 후 반납되고, carry 맵과 pool 이 비는지 봅니다.리뷰 반영 후 리그 E2E 재실행(upstream 브랜치 빌드,
our_main과 공통 코드):op=open), 같은 ms 에release_offload_queue OK, +0.418초 unmount ackop=fdatasync, 쓰기 계열이라syncfs로 shutdown 유도), +0.864초 unmount ackdisk_error_not_fenced1,555줄 전부op=preadv … check=fstat, store a 의syncfs호출 0회(strace 60초), 검증 실패 키 500개 = 불량 bucket 키 500개RPC_TIMEOUT(+1.05초) → heartbeat 재시도 OK(+11.14초), 재mount 054952130)는 976개가OBJECT_HAS_REPLICATION_TASK(source replica 고정), 수정 후는 E1·E2·E7 모두 1,200개 성공알려진 한계 (구현하지 않음)
syncfs가 실패한 스레드를 막습니다.drop_writes)는 감지하지 못합니다.UringFile, 운영은USE_URING=false), errno 를 남기지 않는std::filesystem호출, eviction 중 unlink,close()오류,.meta의 writeback 오류는 observer 에 보고되지 않습니다.EIO외의 "검사" 분류 errno 는 조사 결과일 뿐 리그에서 만들어 보지 못했습니다. 잘못 분류돼도 비용은syncfs+fstat한 번입니다.umount할 수 없습니다(EBUSY). 격리를 풀려면 어차피 재시작해야 하므로 운영 절차는 "store 재시작 → 디스크 교체" 순서입니다.op가 다를 수 있습니다(리뷰 N1, 반영하지 않음).LoadBucketMetadata의 EIO 로 격리되면Init이 mount·재등록을 마친 뒤 첫 heartbeat 가 곧바로 unmount 합니다(무해, 리뷰 N7). 실패한 drain(HTTP 500)도 latch 는 되돌릴 수 없다는 점은 응답 본문에 적지 않았습니다(리뷰 N8).링크
inf-599/disk-fence(54952130+ 리뷰 반영75fe664c, base245e7106), upstream PR [Store] Fence the local disk tier on the first unrecoverable disk error kvcache-ai/Mooncake#4490