PR #3698 — RawBlock put-dispatch ref/put-task 롤백 fix
⚠️ 명칭 주의: 이건 "dedup PR"이 아니다. 원래 L2 브랜치가 dedup 일괄화 + dispatch 배칭을 시도했으나, 벤치(L2 §9)에서 NVMe 구간 regression으로 배칭·dedup 모두 폐기하고 dev 원본 fan-out 구조로 되돌렸다. PR #3698에 남은 건 dispatch 실패 시 자원 누수 롤백(= 원본 리뷰의 F1, +F3 흡수) 한 가지뿐이다.
1. 무엇을 고치나
batched_submit_put_task는 동기 메서드라 디스크 쓰기를 직접 하지 않는다. 각 키마다:
- obj
ref_count_up()+_put_tasks.add(key)로 자원/슬롯 선점 - 코루틴(
_submit_put_one/_submit_put_many)을 만들어asyncio.run_coroutine_threadsafe(coro, loop)로 다른 스레드의 asyncio 이벤트 루프에 제출(dispatch)
문제: run_coroutine_threadsafe는 대상 loop가 closed/stopped면 RuntimeError를
raise한다. 즉 backend.close()로 루프가 내려가는 중 put이 들어오면 enqueue(dispatch)
자체가 실패한다. 이때 직전에 선점한 ref_count_up + _put_tasks 항목이 코루틴의
finally로 정리되지 못하고(코루틴이 시작조차 안 됨) 영구 누수된다:
- obj ref_count +1 영구 잔류 → MemoryObj pin → 메모리 회수 불가
_put_tasks에 key 영구 잔류 → 그 key의 이후 put 영구 차단close()폴링은 leak가 안 사라져 timeout까지 끌려감
2. "dispatch"의 정확한 의미 (double-hop)
| hop | 행위 | 위치 |
|---|---|---|
| hop 1 | run_coroutine_threadsafe로 코루틴을 이벤트 루프에 enqueue | 호출자 스레드 → 루프 스레드 |
| hop 2 | 코루틴 안 asyncio.to_thread(core.put_many)로 thread pool worker에 실제 블로킹 I/O 위임 | 루프 스레드 → worker 스레드 |
이 PR이 막는 실패는 hop 1(enqueue) 실패다.
3. cleanup은 두 경로로 나뉜다
| 담당 항목 | 실행 스레드 | 코드 | |
|---|---|---|---|
경로 A: 코루틴 finally | dispatch 성공한 항목 | 이벤트 루프 스레드 (나중에) | _submit_put_one L465-468, _submit_put_many L521-525 |
| 경로 B: except 롤백 | dispatch 못 한 tail pending[scheduled_count:] | 호출자 스레드 (즉시) | batched_submit_put_task L437-443 |
두 경로 모두 obj.ref_count_down() + _put_tasks.discard(key). scheduled_count가
경계선으로, 책임이 겹치지도 빠지지도 않게 나눈다.
4. 그림 (per-key fan-out 경로, posix engine)
호출자 스레드 이벤트 루프 스레드 (별도)
(batched_submit_put_task, 동기) (loop in thread)
───────────────────────────────── ──────────────────────────────
pending = [k0, k1, k2, k3, k4]
각 obj: ref_count_up() ✔
각 key: _put_tasks.add() ✔
│
▼
┌──────────────────────────────┐
│ for key,spec,obj in pending: │
│ coro = _submit_put_one(...) │
│ run_coroutine_threadsafe ───┼──► [k0] enqueue ✔ ─┐
│ scheduled_count=1 │ │ 나중에 루프에서 실행
│ run_coroutine_threadsafe ───┼──► [k1] enqueue ✔ ─┤ await to_thread(put_many)
│ scheduled_count=2 │ │ │
│ run_coroutine_threadsafe ───┼──► ✗ RuntimeError │ ┌────▼─────────────┐
│ (loop이 close되는 중!) │ (shutting down) │ │ finally: │ ◄─ 경로 A
│ except Exception: │ │ │ ref_count_down() │
│ coro.close() ← k2 코루틴 │ │ │ _put_tasks.discard│
│ raise │ │ └───────────────────┘
└───────────┬──────────────────┘ │ (k0,k1 = 정리됨 ✔)
▼ │
┌──────────────────────────────────────┐ │
│ except Exception: ← 경로 B │ ◄──────────┘
│ # 미-dispatch tail = pending[2:] │
│ for _,_,obj in pending[2:]: │ scheduled_count=2 →
│ obj.ref_count_down() # k2,k3,k4 │ tail = [k2,k3,k4]
│ for key,_,_ in pending[2:]: │
│ _put_tasks.discard(key) │ (k2,k3,k4 = 정리됨 ✔)
│ raise │
└────────────────────────────────────────┘
coro.close()(L432)는 enqueue 실패한 그 코루틴 객체(k2)를 닫아 "never awaited" 경고/누수를 막는 것. k2의 ref/put_task 정리는 경로 B가pending[2:]로 처리.- io_uring 배치 경로(L416-424)는 코루틴 1개뿐 → dispatch 성공 시
scheduled_count =len(pending)(전부 경로 A), 실패 시scheduled_count=0(전부 경로 B).
5. severity & 발생 케이스
- 드물다:
close()로 이벤트 루프가 내려가는 시점과 put dispatch가 정확히 겹치는 shutdown race 한정. 정상 운영 중엔 미발생. (L2 §흡수이유: "info/minor 수준") - 그러나 진짜 버그: 발생 시 결과가 영구 누수 + 종료 지연. 변경은 단일 함수/작은 범위, on-disk format·공개 시그니처 미변경 → 단독 fix PR로 정당.
- per-key 누수 자체는 dev에도 원래 있던 갭(except 블록 부재). 배치화 버전에선 "한 batch N키 동시 leak"으로 악화됐던 게 우려였고, PR #3698은 fan-out 복원 + 롤백으로 per-key 갭까지 닫음.
6. Gemini 리뷰 (2026-06-16)
medium 1건: 롤백 블록의 except Exception → except BaseException 제안
(KeyboardInterrupt/SystemExit 중에도 cleanup 보장).
- 원론상 타당(cleanup-and-reraise 패턴,
pd_backend_async.py에 선례 있음). - 실효성 낮음: 실제 타깃 실패 모드 "loop shutting down"은
RuntimeError(=Exception 하위)라 이미 커버. BaseException-only 케이스(KeyboardInterrupt/SystemExit)는 메인 스레드에서만 전달되는데 이 메서드는 worker 스레드 경로. plugins/ 컨벤션은Exception. - 적용 시 3곳(L420/L431/L437) 일관 변경 필요. 미적용 + 근거 답글도 무방.