Code Review: 356270ae — Harden raw-block checkpoint entry recovery
커밋: 356270aeea74be335d6d26108becb27cfb64e0b8
제목: Harden raw-block checkpoint entry recovery
작성자: Daegyu Han daegyu94.han@samsung.com
일자: 2026-05-28
리뷰 모드: 9-angle finder × verify × sweep (extra-high recall)
변경 요약
lmcache/v1/storage_backend/raw_block/core.py의 _apply_loaded_state 안에 인라인되어
있던 per-entry 디코드 로직을 새 헬퍼 _decode_checkpoint_entry로 추출하고, 호출부에서
try/except Exception으로 감싸 잘못된 체크포인트 엔트리 하나가 전체 복구를 실패시키지
않도록 변경.
- 기존: 한 엔트리에서
int("not-an-int"),torch.Size(non-iterable),torch.tensor(...)등이 raise되면_apply_loaded_state전체가 예외를 버블링 →_load_checkpoint_from_device→RawBlockCore.__init__까지 올라가_cleanup_after_init_failure가 발동(= 백엔드 init 실패). - 신규: 엔트리별로
try/except. 실패하면log.warning후continue, 다른 엔트리는 계속 적용. - 회귀 테스트: bad-offset / bad-shape 두 종을 섞은 체크포인트로 partial recovery 검증.
분류 요약
| 카테고리 | 건수 | 가장 심각한 항목 |
|---|---|---|
| Correctness — 슬롯/계정 누수 | 3 | _index.clear()로 인한 partial-mutation, 슬롯 영구 누수 |
| Correctness — 시멘틱 변화 | 2 | fail-fast → silent partial recovery 전환에 신호 없음 |
| Robustness — 입력 검증 부족 | 5 | int() float 절단, torch.Size 음수 허용, fmt 대소문자 drift |
| Maintenance | 2 | broad except Exception, Raises: Exception docstring |
| Reuse | 1 | _snapshot_state/_decode_checkpoint_entry 스키마 중복 |
| Test gap | 2 | 슬롯 누수 unasserted, dict-iteration order 가정 |
Tier 1 — 머지 전 반드시 다뤄야 할 항목 (Error 수준)
T1-1. 스킵된 엔트리의 슬롯이 영구 누수됨
if decoded_entry is None:
continue
self._index[encoded_key] = decoded_entry
스킵된 엔트리의 offset이 가리키던 슬롯은:
_index에서 제외됨 (당연)_free_slots에 추가되지 않음_next_slot아래에 위치 (체크포인트의next_slot을 그대로 채택)
→ _allocate_slot_locked()은 _free_slots에서 pop하거나 _next_slot++로만 슬롯을
얻기 때문에, 누수된 슬롯은 재시도 없이 영구히 사용 불가.
대조: core.py:1432 부근의 _validate_loaded_entries는
헤더 검증에서 거부된 엔트리에 대해 _append_free_slot_locked(...)를 호출해 슬롯을 회수함.
디코드 거부 경로만 회수 누락이라 일관성이 깨짐.
시나리오
체크포인트: next_slot=5, free_slots=[], entries 슬롯 0..4
슬롯 2 엔트리의 shape=object() → 디코드 raise → 스킵
복구 후: _index={0,1,3,4}, _free_slots=[], _next_slot=5
→ 슬롯 2는 영원히 봉인. 매 재부팅에서 손상 엔트리를 만날 때마다 capacity 단조 감소.
수정 방향 (택일):
- offset이
_is_valid_checkpoint_entry를 통과한 경우에 한해, 디코드 실패 시_append_free_slot_locked(self._offset_to_slot(offset))로 슬롯을 회수. - 헬퍼 시그니처를
(applied_entry, freed_slot_or_None)로 바꿔 호출자가 명시적으로 결정.
T1-2. fail-fast → silent partial recovery 전환에 운영 신호 없음
core.py:1364 +
core.py:1388 +
core.py:1495 부근의 _load_checkpoint_from_device
이 PR의 가장 큰 시멘틱 변화는 체크포인트 손상 시의 행동 계약 변경이다:
| 시점 | 손상 엔트리 1건 발생 시 |
|---|---|
| 변경 전 | _apply_loaded_state 안에서 raise → RawBlockCore.__init__까지 버블 → _cleanup_after_init_failure → 백엔드 부팅 실패 → 운영자 알림 |
| 변경 후 | per-entry log.warning → 부팅 성공 → _index에 partial 데이터 → 누락 키는 cache miss로만 표면화 |
문제는 partial이라는 사실 자체가 반환값과 status에 노출되지 않는다는 것:
apply_loaded_state반환값은 여전히True(entries 적용 비율과 무관)_load_checkpoint_from_device는 성공 로그에len(self._index)만 남기고 skipped 카운트는 없음report_status같은 진단 함수에partial_recovery=True/skipped_entries=N같은 필드 없음- L2 어댑터의
_seed_usage_from_core_snapshot도 살아남은 키만 집계 (T1-3 참고)
→ 운영자가 실제 문제를 검출할 수 있는 유일한 수단은 로그 grep이며, 메트릭/알람으로 연결되지 않는다.
수정 방향:
# _apply_loaded_state가 (applied, skipped) 카운터를 반환하도록 변경
# _load_checkpoint_from_device에서 skipped > 0이면 WARNING 레벨 한 줄 + report_status에 노출
# RawBlockCore에 self._last_recovery_skipped_count 같은 필드 추가
또는 더 보수적으로:
- 임계값(예: skipped/total > 5%)을 넘기면
_apply_loaded_state가False를 반환해 체크포인트 전체를 거부 → 기존 fail-fast 계약 보존 + 1~2건 손상은 허용.
T1-3. partial recovery 시 L2 capacity 회계 어긋남
raw_block_l2_adapter.py:590 의 _seed_usage_from_core_snapshot
recovered_keys = self._snapshot_indexed_object_keys() # _index에 살아남은 키만
total_delta = len(recovered_keys) * slot_bytes
self._total_bytes_used += total_delta
스킵된 엔트리의 슬롯은 (T1-1에서 본 것처럼) 디스크에는 그대로 존재하지만 _index에는
없어서 _total_bytes_used에 잡히지 않는다.
시나리오:
1000 엔트리 중 100개가 디코드 실패 → _index 900개 → _total_bytes_used = 900*slot_bytes
실제 디스크 점유 = 1000*slot_bytes (스킵된 슬롯도 free에 안 들어감, T1-1)
→ 100*slot_bytes만큼 회계 누락.
eviction controller / per-cache-salt quota는 더 많은 여유가 있다고 판단,
계속 put_many 시도 → 결국 'no free slot'인데 보고된 사용량은 capacity_bytes 미만.
T1-1과 합쳐지면 이중 누수: 디스크에서도 못 쓰고, 회계상으로도 빈 자리가 있는 듯 보임.
T1-4. partial 복구 후 다음 체크포인트 사이클이 손상 엔트리를 영구 삭제
self._meta_dirty_total = 0
self._meta_persisted = 0
partial recovery 직후 _meta_dirty_total이 0으로 리셋되므로, 이후 첫 mutation 한 번에
_checkpoint_once가 발동되면 새 체크포인트는 손상 엔트리를 포함하지 않은 채 디스크에
기록된다.
기본 체크포인트 주기(_checkpoint_loop, 디폴트 ~60s)가 짧기 때문에:
- 시간 T: 부팅, partial 복구, 운영자가 로그 못 봄.
- 시간 T+60s: 정상 mutation 한 번 + idle quiet → 새 체크포인트 기록.
- 멀티-카피 메타 영역도 순차 회전 → 이전 체크포인트 사본도 결국 덮어써짐.
- 운영자가 알아챘을 때는 손상 엔트리의 메타데이터 trail이 디스크에서 완전 소멸.
슬롯의 페이로드 자체는 디스크에 남아있을 수 있지만, 그 슬롯이 어떤 키였는지를 복구할 수단이 사라진다.
수정 방향:
partial_recovery=True인 동안 자동 체크포인트를 일시 중지(quiesce)하고 운영자 개입을 기다리는 모드 추가, 또는- 손상 엔트리를 별도 영역(
recovery_quarantine)에 보관해 다음 N회의 체크포인트에서도 유지.
T1-5. listener에 on_l2_keys_deleted가 통지되지 않음
raw_block_l2_adapter.py:524 register_listener
keys = self._snapshot_indexed_object_keys() # 살아남은 키만
listener.on_l2_keys_stored(keys)
리스너가 재시작 사이에 자체 인덱스를 유지하는 구현(예: cross-restart eviction LRU)이라면,
이전 부팅에서 on_l2_keys_stored(K)를 받았던 키 K가 이번 부팅에서 손상으로 누락되었을 때
on_l2_keys_deleted(K)가 호출되지 않으므로 리스너 입장에서는 K가 여전히 살아있다고 믿게
된다.
→ eviction 우선순위가 어댑터가 서빙 못하는 키를 가리키게 되어, lookup miss + 순위 왜곡.
수정 방향: register_listener에서 "이전 체크포인트에서는 보였지만 이번에는 누락된 키"를
계산해 on_l2_keys_deleted로 한 번 통지. 이는 별도 정보(이전 체크포인트 키 집합)를
요구하므로 좀 더 복잡한 상태 관리가 필요.
Tier 2 — Robustness (Warning 수준)
T2-1. int()의 silent float truncation / bool coercion
offset = int(entry.get("offset", 0))
size = int(entry.get("size", 0))
int(1.7)→1(silent truncation, no raise)int(True)→1,int(False)→0(bool은 int의 서브클래스)int(None)→TypeError(catch됨, 스킵)int("not-an-int")→ValueError(catch됨, 스킵)
위험 케이스: 시리얼라이저 회귀로 offset: 4096.0 같은 float가 기록 →
int(4096.0) == 4096 → 슬롯 정렬에 맞으면 _is_valid_checkpoint_entry 통과 → 잘못된
오프셋으로 admit. 페이로드를 다른 슬롯에서 읽음 → 조용한 데이터 손상.
수정 방향:
raw_offset = entry.get("offset")
if not isinstance(raw_offset, int) or isinstance(raw_offset, bool):
return None
offset = raw_offset
T2-2. torch.Size(list(shape_list))가 음수/이상값을 silent하게 통과
torch.Size([-1, 8])는 torch.Size로는 받아들여지지만, 이 모양으로 텐서 재구성을 시도할 때
load_many_into 깊숙한 곳에서 의미 불명 에러로 실패. 즉 체크포인트 복구 시점이
아니라 첫 load 시점에 실패해 추적이 어려워짐.
수정 방향: 디코드 시점에 element가 모두 양의 정수인지 검증.
T2-3. torch.tensor(..., dtype=torch.long)의 silent float→int rounding
torch.tensor([1.7, 2.9], dtype=torch.long) → [1, 2] (raise 없이 절단).
producer 쪽이 float position을 실수로 기록한 경우, partial-prefix lookup이 1 토큰씩
어긋난 결과를 반환. 가장 뼈아픈 종류의 silent corruption.
T2-4. MemoryFormat[fmt_name]가 case-shift 시 silent fallback
fmt = (
MemoryFormat[fmt_name]
if isinstance(fmt_name, str) and fmt_name in MemoryFormat.__members__
else MemoryFormat.UNDEFINED
)
fmt_name='kv_2ltd'가 들어오면(과거 버전이 lowercase로 기록했다면) 조용히
UNDEFINED로 폴백. 엔트리는 admit되지만 잘못된 메모리 레이아웃 가정으로 향후 read가
깨짐. 디코드 실패가 아니라 wrong-but-valid 엔트리가 만들어진다는 점이 위험.
수정 방향: 알 수 없는 fmt면 엔트리 자체를 스킵 (return None).
T2-5. _recover_checkpoint_dtype가 str(encoded_key)로 호출됨
decoded_entry = self._decode_checkpoint_entry(
str(encoded_key),
entry,
)
JSON 체크포인트라면 모든 키가 이미 str이라 영향 없음. 하지만 향후 코덱이 msgpack/CBOR로
바뀌면 bytes 키가 들어오고, str(b'foo') == "b'foo'"로 망가진 형태가 legacy dtype
복구에 사용된다. 한편 실제 인덱스는 core.py:1373에서
원본 encoded_key(=bytes) 로 저장됨 → 시스템적으로 일관성 없는 키 표현이 두 군데에 공존.
지금은 작동하지만, 다른 직렬화 포맷이 도입되는 즉시 isinstance(encoded_key, str) 체크 또는 적절한 디코딩 없이는 쉽게 깨진다.
Tier 3 — Maintenance (Info 수준)
T3-1. except Exception이 너무 넓음
except Exception as e:
logger.warning(...)
continue
이 블록은 다음을 모두 같은 양동이에 담는다:
- 정당한 corruption:
ValueError,TypeError,KeyError,OverflowError - 인프라 문제: torch
RuntimeError(CUDA OOM 등),MemoryError - 프로그래밍 버그:
AttributeError(예:DiskCacheMetadata.shape을tensor_shape로 rename했지만 디코더는 미수정)
특히 (3)이 위험. 리팩터링이 모든 엔트리에 대해 AttributeError를 일으켜도 테스트가
"apply_loaded_state 반환 True"만 검증한다면 통과한다 → 운영에서 cache miss로만 표면화.
수정 방향:
except (ValueError, TypeError, KeyError, OverflowError) as e:
T3-2. Raises: Exception docstring은 계약이 아님
Raises:
Exception: If the entry cannot be parsed into metadata.
코딩 표준 §3 ("docstrings must match actual current behavior", "every public function has a complete docstring")에 따르면 실제로 raise되는 구체적 예외 타입을 명시해야 한다.
Raises:
ValueError: If offset/size are non-numeric strings.
TypeError: If shape is not iterable or cached_positions is malformed.
KeyError: If dtype lookup fails for an unrecognised legacy key.
위 T3-1의 narrow except와 짝을 이룸.
T3-3. _snapshot_state와 _decode_checkpoint_entry의 스키마 중복 (Reuse)
core.py:1119-1141의 _snapshot_state 와
core.py:1249-1281의 _decode_checkpoint_entry
두 함수가 동일한 필드 집합 (offset, size, shape, fmt, cached_positions, dtype)을
서로 독립적으로 하드코딩한다. 한쪽만 변경되어도 컴파일 에러는 안 나고, 다음 재부팅에서
디코더가 모든 엔트리를 스킵 → T1-2의 silent partial recovery가 100% 스케일로 발동.
수정 방향:
- 작은 dataclass
_CheckpointEntry에to_dict()/from_dict()를 두고 양쪽이 그것만 사용하도록. - 또는 모듈 상수
_CHECKPOINT_ENTRY_FIELDS = ("offset", "size", "shape", "fmt", ...)를 공유.
Tier 4 — Test gap
T4-1. 슬롯 누수가 테스트되지 않음
테스트 픽스처:
"next_slot": 2,
"free_slots": [],
"entries": {
valid_spec.encoded: {"offset": valid_offset, ...}, # 슬롯 0
"bad-offset-entry": {"offset": "not-an-int", ...}, # 슬롯 모름
"bad-shape-entry": {"offset": valid_offset + slot_bytes, # 슬롯 1
"shape": object(), ...},
}
bad-shape-entry의 offset은 유효하고 슬롯 1에 정확히 매핑된다. 하지만 테스트는
contains_key만 단언하고, 슬롯 1이 회수되었는지 (free_slots에 들어갔거나, 새 put이
거기에 할당되거나) 전혀 검증하지 않는다.
→ T1-1의 회귀를 잡을 수 없다.
수정 방향:
# 슬롯 1이 누수되지 않았는지 확인 — put_many 4번 시도 시 슬롯 1을 재사용하거나,
# report_status / free_slots에 슬롯 1이 잡히는지 단언
status = recovered.report_status()
assert status["free_slot_count"] >= 1 # 슬롯 1이 회수되어야 함
T4-2. dict-iteration 순서 의존성
bad 엔트리가 valid 엔트리 뒤에 위치한다. _apply_loaded_state의 루프가 향후 어떤
이유로 abort-on-first-error로 회귀해도, valid가 먼저 적용된 뒤 bad에서 멈추므로
contains_key(valid) is True 단언은 통과한다.
수정 방향: pytest.parametrize로 (bad-first / valid-first / interleaved) 3가지 순서를
모두 검증.
머지 권장도
Request changes (Tier 1을 먼저 처리하지 않는 한 머지 보류 권장).
이 PR의 의도("한 엔트리의 손상이 전체 복구를 죽이지 않게")는 정당하지만, 현재 구현은 fail-fast → silent-partial-recovery의 시멘틱 전환을 운영 가시성과 슬롯 회계 보강 없이 수행한다. 그 결과:
- T1-1 + T1-3: 디스크에서도 못 쓰고, 회계도 안 잡히는 슬롯이 매 재부팅마다 누적.
- T1-2: 운영자가 partial 발생을 메트릭으로 알 방법 없음.
- T1-4: 다음 자동 체크포인트가 손상 trail을 덮어쓰며 복구 윈도우 완전 소실.
최소한 다음 두 가지가 같이 들어가야 안전하다:
- (필수) 디코드 거부 슬롯 회수: offset이 valid면
_append_free_slot_locked호출. - (필수) skipped 카운트 노출:
_apply_loaded_state반환 또는report_status에partial_recovery_skipped_count추가, partial이면WARNING단일 라인.
추가로 강력 권장:
- T2-1, T2-4: 엔트리 시멘틱을 silent하게 바꾸지 말고 디코드 시점에 거부.
- T3-1, T3-2: except 좁히고 docstring 구체화.
- T4-1, T4-2: 회귀 테스트 보강.
변경 후에도 유지되어야 할 좋은 점
- 헬퍼 추출 자체는 깔끔;
_apply_loaded_state의 가독성이 개선됨. - 회귀 테스트 추가는 방향이 맞음 (단, 슬롯 누수까지 커버 필요).
- 시그니처 (
encoded_key,entry: dict) →_Entry | None의 의미가 명확함.