Code Review: 4803df4a — Add LRU eviction retry for rust raw-block backend
커밋: 4803df4a
브랜치: bitbucket/priv/dg/raw-block-cache-eviction
제목: Add LRU eviction retry for rust raw-block backend
작성자: Daegyu Han daegyu94.han@samsung.com
일자: 2026-06-01
변경 요약
RustRawBlockBackend에 파이썬 레이어 LRU 트래커(OrderedDict)를 추가하고,
put 시 슬롯이 꽉 찼을 때 LRU 키를 evict한 뒤 재시도하는 로직을 도입.
핵심 변경
| 파일 | 변경 내용 |
|---|---|
rust_raw_block_backend.py | _lru_keys: OrderedDict 추가, _sync_lru_with_index(), _touch_lru(), _evict_one_lru() 신규, _submit_put_one에 evict-retry 루프 도입 |
core.py | RawBlockPutManyResult에 no_free_slot_keys: list[str] 필드 추가, put_many 내 슬롯 부족 케이스에서 해당 필드 채움 |
test_rust_raw_block_backend.py | 기존 test_rust_raw_block_backend_rejects_when_full 대체 + 신규 테스트 8건 추가 |
동작 흐름 (신규)
put_many → 성공 → _touch_lru → 완료
→ 슬롯 부족 → _evict_one_lru → 재시도 (루프)
┕ evict 불가 (전부 pinned) → RuntimeError
→ 기타 실패 → RuntimeError (evict 없이 즉시)
- capacity miss 판별:
put_result.no_free_slot_keys로 슬롯 부족인지 아닌지 구분 → 슬롯 부족이 아닌 실패에서는 LRU 항목을 건드리지 않음 - pinned 키 보호:
_pinned_keysset에 있는 키는 evict 후보에서 제외 - 체크포인트 복구:
__init__및apply_loaded_state이후_sync_lru_with_index()로 코어 인덱스와 LRU 상태 동기화
분류 요약
| 카테고리 | 건수 | 가장 심각한 항목 |
|---|---|---|
| Correctness — 락 보유 중 I/O | 1 | _pin_lock 보유 중 delete_many / contains_key 호출 |
| Correctness — 레이스 조건 | 1 | apply_loaded_state 후 _sync_lru_with_index 호출 시 진행 중 put과 레이스 |
| Design — 에러 메시지 모호성 | 1 | 크기 초과와 슬롯 부족이 동일 메시지 |
| Naming — 락 이름 | 1 | _pin_lock이 LRU + pin 상태를 함께 보호 |
Test — assert 사용 | 1 | 테스트 헬퍼에서 코딩 표준 위반 |
| Info — 성능 | 1 | _evict_one_lru 내 전체 키 목록 복사 |
상세 리뷰
[error] 락 순서 불일치 — 잠재적 데드락
위치: rust_raw_block_backend.py:626-638 (_evict_one_lru)
_evict_one_lru()는 _pin_lock을 보유한 채로 self._core.delete_many()와
self._core.contains_key()를 호출합니다. 이 Rust 코어 메서드들은 내부 뮤텍스를 취득합니다.
현재 코드베이스에서는 역방향 락 취득 경로(코어 내부 → 파이썬 _pin_lock)가 없으므로
실제 데드락은 발생하지 않습니다. 그러나 향후 코어에서 파이썬 콜백을 호출하거나
다른 메서드가 락 순서를 역전시키는 경우 감지가 어렵습니다.
권장 수정: 락 보유 중 코어 I/O를 호출하지 않는 패턴으로 리팩터링하거나, 최소한 주석으로 "이 락을 보유한 채 코어 I/O를 호출해도 안전한 이유"를 명문화.
def _evict_one_lru(self) -> bool:
# Candidate selection under lock, I/O outside.
with self._pin_lock:
candidate = next(
(k for k in self._lru_keys if k not in self._pinned_keys),
None,
)
if candidate is None:
return False
# _pin_lock NOT held during core I/O.
deleted = self._core.delete_many([candidate], force=False)[0]
with self._pin_lock:
if deleted:
self._lru_keys.pop(candidate, None)
return True
if not self._core.contains_key(candidate, lock=False):
self._lru_keys.pop(candidate, None)
return False
단, 위 패턴은 선택 후 삭제 사이에 다른 스레드가 같은 키를 evict할 수 있으므로 그 케이스를 처리하는 코드가 필요합니다.
[error] apply_loaded_state 후 _sync_lru_with_index 레이스
위치: rust_raw_block_backend.py:213-216 (apply_loaded_state)
apply_loaded_state는 _put_lock 없이 호출됩니다. 진행 중인 _submit_put_one이
_touch_lru를 막 완료하거나 실행 중인 상태에서 _sync_lru_with_index가 새
OrderedDict로 교체하면, 직전에 _touch_lru로 추가된 키가 새 딕셔너리에 누락됩니다.
_pin_lock으로 딕셔너리 객체 자체의 동시 접근은 막혀 있지만, _lru_keys 참조 교체와
_touch_lru 삽입 사이의 순서가 _pin_lock 단일 구간으로 완전히 묶이지 않아
논리적 일관성이 깨질 수 있습니다.
권장 수정: apply_loaded_state 호출을 문서화된 "no active puts" 전제 조건으로
명시하거나, _put_lock도 함께 취득한 뒤 _sync_lru_with_index를 호출.
[warning] 크기 초과 에러 메시지가 슬롯 부족과 동일
위치: rust_raw_block_backend.py:398-399
if len(memory_obj.byte_array) > self.slot_bytes - self.header_bytes:
raise RuntimeError(f"Failed to persist raw-block key {spec.encoded}")
사전 크기 검사 실패와 슬롯 부족 evict 실패가 같은 문자열을 사용합니다.
운영 중 로그나 테스트 match= 패턴으로 두 케이스를 구분할 수 없습니다.
raise RuntimeError(
f"Key {spec.encoded}: data size {len(memory_obj.byte_array)} bytes "
f"exceeds slot capacity {self.slot_bytes - self.header_bytes} bytes"
)
[warning] _pin_lock 이름이 역할을 반영하지 않음
위치: rust_raw_block_backend.py:145
_pin_lock은 _pinned_keys뿐 아니라 _lru_keys도 보호합니다.
이름만 보면 LRU 상태도 같이 보호한다는 것을 알기 어렵습니다.
_eviction_lock 또는 _state_lock이 더 명확합니다.
[warning] _sync_lru_with_index — 복구 키의 LRU 순서가 재시작 전 접근 이력을 보존하지 않음
위치: rust_raw_block_backend.py:611-618
snapshot_indexed_keys()는 _index dict의 삽입 순서, 즉 최초 put 순서를 반환합니다.
따라서 복구 후 LRU 순서는 "원래 put된 순서 (oldest-first)"로 리셋됩니다.
재시작 전에 get으로 최근 접근된 키가 있었더라도 그 정보는 체크포인트에 저장되지 않으므로
재시작 후에는 put 순서 기준으로 먼저 evict 대상이 됩니다.
우연히 oldest-put-first이므로 LRU 의미론과 부분적으로 일치하지만,
재시작 전 access 패턴과 다를 수 있다는 점을 _sync_lru_with_index docstring에 명시해야 합니다.
[info] _evict_one_lru 내 전체 키 목록 복사
위치: rust_raw_block_backend.py:628
for encoded_key in list(self._lru_keys.keys()):
_pin_lock 보유 중 전체 키를 복사합니다. 락 보유 중 _lru_keys를 수정하기 때문에
복사가 필요한 것으로 보이지만, 이유를 주석으로 남겨 두면 좋습니다.
캐시 규모가 커지면 성능 영향이 있을 수 있습니다.
[info] 테스트 헬퍼에서 assert 사용
위치: test_rust_raw_block_backend.py:262-265 (_make_test_raw_block_obj)
assert obj is not None and obj.tensor is not None
코딩 표준에 따르면 runtime 검사에는 if/raise ValueError를 사용해야 합니다.
테스트 코드라 우선순위는 낮지만 일관성을 위해 수정 권장.
테스트 커버리지 평가
| 테스트 | 커버 시나리오 |
|---|---|
test_rust_raw_block_backend_evicts_lru_when_full | 기본 evict-retry: k1 최근 접근 → k2 evict |
test_rust_raw_block_backend_eviction_skips_pinned_key | pinned k1 보존, k2 evict |
test_rust_raw_block_backend_put_fails_when_all_candidates_locked | 모든 후보 pinned → RuntimeError |
test_rust_raw_block_backend_write_failure_does_not_evict_lru | 비용량 실패 시 LRU 변경 없음 |
test_rust_raw_block_backend_remove_clears_lru_entry | remove 후 LRU에서 정리 |
test_rust_raw_block_backend_checkpoint_rebuilds_lru_for_eviction | 체크포인트 복구 후 evict 가능 |
test_rust_raw_block_backend_apply_loaded_state_rebuilds_lru | apply_loaded_state 후 LRU 재구성 |
test_rust_raw_block_backend_concurrent_puts_preserve_slot_invariants | 동시 put, 슬롯 수 불변식 유지 |
test_rust_raw_block_backend_pinned_read_survives_concurrent_eviction_pressure | 동시 put pressure 중 pinned 읽기 보존 |
핵심 시나리오들이 잘 커버되어 있습니다. 누락된 케이스:
_submit_put_one진행 중apply_loaded_state동시 호출 (위 [error] 레이스 재현)- 크기 초과 사전 검사 경로 (현재 테스트 없음)