본문으로 건너뛰기

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동기 메서드라 디스크 쓰기를 직접 하지 않는다. 각 키마다:

  1. obj ref_count_up() + _put_tasks.add(key) 로 자원/슬롯 선점
  2. 코루틴(_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 1run_coroutine_threadsafe로 코루틴을 이벤트 루프에 enqueue호출자 스레드 → 루프 스레드
hop 2코루틴 안 asyncio.to_thread(core.put_many)로 thread pool worker에 실제 블로킹 I/O 위임루프 스레드 → worker 스레드

이 PR이 막는 실패는 hop 1(enqueue) 실패다.

3. cleanup은 두 경로로 나뉜다

담당 항목실행 스레드코드
경로 A: 코루틴 finallydispatch 성공한 항목이벤트 루프 스레드 (나중에)_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 Exceptionexcept 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) 일관 변경 필요. 미적용 + 근거 답글도 무방.