본문으로 건너뛰기

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_deviceRawBlockCore.__init__까지 올라가 _cleanup_after_init_failure가 발동(= 백엔드 init 실패).
  • 신규: 엔트리별로 try/except. 실패하면 log.warningcontinue, 다른 엔트리는 계속 적용.
  • 회귀 테스트: bad-offset / bad-shape 두 종을 섞은 체크포인트로 partial recovery 검증.

분류 요약

카테고리건수가장 심각한 항목
Correctness — 슬롯/계정 누수3_index.clear()로 인한 partial-mutation, 슬롯 영구 누수
Correctness — 시멘틱 변화2fail-fast → silent partial recovery 전환에 신호 없음
Robustness — 입력 검증 부족5int() float 절단, torch.Size 음수 허용, fmt 대소문자 drift
Maintenance2broad except Exception, Raises: Exception docstring
Reuse1_snapshot_state/_decode_checkpoint_entry 스키마 중복
Test gap2슬롯 누수 unasserted, dict-iteration order 가정

Tier 1 — 머지 전 반드시 다뤄야 할 항목 (Error 수준)

T1-1. 스킵된 엔트리의 슬롯이 영구 누수됨

core.py:1373

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 단조 감소.

수정 방향 (택일):

  1. offset이 _is_valid_checkpoint_entry를 통과한 경우에 한해, 디코드 실패 시 _append_free_slot_locked(self._offset_to_slot(offset))로 슬롯을 회수.
  2. 헬퍼 시그니처를 (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_stateFalse를 반환해 체크포인트 전체를 거부 → 기존 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 복구 후 다음 체크포인트 사이클이 손상 엔트리를 영구 삭제

core.py:1383

self._meta_dirty_total = 0
self._meta_persisted = 0

partial recovery 직후 _meta_dirty_total이 0으로 리셋되므로, 이후 첫 mutation 한 번에 _checkpoint_once가 발동되면 새 체크포인트는 손상 엔트리를 포함하지 않은 채 디스크에 기록된다.

기본 체크포인트 주기(_checkpoint_loop, 디폴트 ~60s)가 짧기 때문에:

  1. 시간 T: 부팅, partial 복구, 운영자가 로그 못 봄.
  2. 시간 T+60s: 정상 mutation 한 번 + idle quiet → 새 체크포인트 기록.
  3. 멀티-카피 메타 영역도 순차 회전 → 이전 체크포인트 사본도 결국 덮어써짐.
  4. 운영자가 알아챘을 때는 손상 엔트리의 메타데이터 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

core.py:1249 + core.py:1250

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하게 통과

core.py:1259

torch.Size([-1, 8])torch.Size로는 받아들여지지만, 이 모양으로 텐서 재구성을 시도할 때 load_many_into 깊숙한 곳에서 의미 불명 에러로 실패. 즉 체크포인트 복구 시점이 아니라 첫 load 시점에 실패해 추적이 어려워짐.

수정 방향: 디코드 시점에 element가 모두 양의 정수인지 검증.

T2-3. torch.tensor(..., dtype=torch.long)의 silent float→int rounding

core.py:1266

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

core.py:1261

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_dtypestr(encoded_key)로 호출됨

core.py:1361

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이 너무 넓음

core.py:1364

except Exception as e:
logger.warning(...)
continue

이 블록은 다음을 모두 같은 양동이에 담는다:

  • 정당한 corruption: ValueError, TypeError, KeyError, OverflowError
  • 인프라 문제: torch RuntimeError (CUDA OOM 등), MemoryError
  • 프로그래밍 버그: AttributeError (예: DiskCacheMetadata.shapetensor_shape로 rename했지만 디코더는 미수정)

특히 (3)이 위험. 리팩터링이 모든 엔트리에 대해 AttributeError를 일으켜도 테스트가 "apply_loaded_state 반환 True"만 검증한다면 통과한다 → 운영에서 cache miss로만 표면화.

수정 방향:

except (ValueError, TypeError, KeyError, OverflowError) as e:

T3-2. Raises: Exception docstring은 계약이 아님

core.py:1247

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_statecore.py:1249-1281의 _decode_checkpoint_entry

두 함수가 동일한 필드 집합 (offset, size, shape, fmt, cached_positions, dtype)을 서로 독립적으로 하드코딩한다. 한쪽만 변경되어도 컴파일 에러는 안 나고, 다음 재부팅에서 디코더가 모든 엔트리를 스킵 → T1-2의 silent partial recovery가 100% 스케일로 발동.

수정 방향:

  • 작은 dataclass _CheckpointEntryto_dict() / from_dict()를 두고 양쪽이 그것만 사용하도록.
  • 또는 모듈 상수 _CHECKPOINT_ENTRY_FIELDS = ("offset", "size", "shape", "fmt", ...)를 공유.

Tier 4 — Test gap

T4-1. 슬롯 누수가 테스트되지 않음

test_raw_block_core.py:175 부근

테스트 픽스처:

"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 순서 의존성

test_raw_block_core.py:159

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을 덮어쓰며 복구 윈도우 완전 소실.

최소한 다음 두 가지가 같이 들어가야 안전하다:

  1. (필수) 디코드 거부 슬롯 회수: offset이 valid면 _append_free_slot_locked 호출.
  2. (필수) skipped 카운트 노출: _apply_loaded_state 반환 또는 report_statuspartial_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 의 의미가 명확함.