![]()
CPython sqlite3: create_collation 중 SQLITE_BUSY가 터질 때 크래시하던 경로가 막혔다(gh-146090)
meta_description: CPython sqlite3 모듈에서 Connection.create_collation()이 실행 중인 statement 때문에 SQLITE_BUSY로 실패하는 경우, 특정 참조/해제 경로에서 크래시가 날 수 있던 버그가 수정됐다(gh-146090). 이 글은 왜 BUSY가 나오는지, 애플리케이션/라이브러리에서 재현을 줄이는 패턴, 회귀 테스트 아이디어, 그리고 업그레이드 우선순위를 정리한다. meta_keywords: python,sqlite3,cpython,create_collation,SQLITE_BUSY,OperationalError,collation,statement,커서,트랜잭션,MemoryError,SystemError,callback context,레퍼런스카운트,회귀테스트,업그레이드,안정성,크래시 meta_robots: index,follow
SQLite는 “가볍고 빠른 내장 DB”라는 이미지가 강하지만, 파이썬에서 sqlite3를 조금만 깊게 쓰기 시작하면 ‘DB가 바쁜 상태(BUSY)’를 마주칠 때가 있다.
대표적으로 이런 케이스다.
- 같은 연결에서 커서를 열어
SELECT ... ORDER BY ... COLLATE mycoll을 실행 중인데 - 그 와중에
create_collation("mycoll", ...)로 같은 이름의 collation을 교체하려고 한다
이때 SQLite는 자연스럽게 SQLITE_BUSY를 반환한다. (실행 중인 statement가 collation을 쓰고 있으니 “지금은 바꾸지 마”라는 뜻)
문제는 예전의 CPython sqlite3 구현에서 이 실패 경로가 파이썬 예외(OperationalError)로 끝나지 않고, 특정 조건에서 크래시까지 이어질 수 있는 모서리가 있었다는 점이다.
지난 72시간 내 병합된 CPython PR gh-146090은 이 부분을 정리한다.
create_collation()이 BUSY로 실패하는 상황을 테스트로 고정하고- 내부 callback context의 메모리/참조 관리가 일관되게 동작하도록 수정해서
- “운 나쁘면 크래시”가 아니라 “정상적인 예외”로 떨어지도록 만든다.
![]()
1) 이슈의 핵심: SQLITE_BUSY 자체가 문제가 아니라, 실패 경로의 정리(cleanup)다
먼저 오해를 하나 정리하자.
SQLITE_BUSY는 버그가 아니다.- ‘동시에’ 접근했거나, 실행 중인 statement가 리소스를 잡고 있으면 정상적으로 나올 수 있다.
진짜 문제는 “BUSY로 실패했을 때, 파이썬이 내부 상태를 어떻게 정리하느냐”다.
PR diff를 보면 두 축이 있다.
(A) callback context 할당 실패 시 예외 타입 정리
기존엔 callback context를 만드는 과정에서 메모리 할당이 실패할 경우, 상황에 따라 SystemError처럼 애매한 예외로 보일 여지가 있었다.
이번 변경은 할당 실패 시 PyErr_NoMemory()를 명시해, MemoryError를 제대로 올리도록 한다.
- “내 코드가 뭘 잘못했나?”가 아니라
- “메모리가 부족해서 실패했구나”로 정확히 분류된다.
운영에서는 이 차이가 크다. 재시도/알림 우선순위가 달라지니까.
(B) BUSY 실패 시 크래시를 막는 참조 카운트 경로
PR에서는 BUSY로 실패했을 때 callback context를 해제하는 함수가 free_callback_context()에서 decref_callback_context()로 바뀐다.
이 변화는 단순한 함수명 변경이 아니라,
- “무조건 free” vs
- “refcount를 감소시키고, 0일 때만 실제 해제”
의 차이다.
즉, 내부적으로 공유될 수 있는 컨텍스트를 BUSY 상황에서 잘못 해제해 use-after-free/크래시로 가는 길을 막는 쪽에 가깝다.
2) 테스트가 보여주는 재현 시나리오(현실에서 충분히 가능)
이번 PR에 추가된 테스트는 아주 실전적이다.
- collation을 등록한다
- 그 collation을 사용해 ORDER BY 하는 SELECT를 실행한다
- 커서에서 한 번
next()로 row를 꺼내 statement를 “활성 상태”로 만든다 - 그 상태에서 동일 이름의 collation을 교체 등록한다 →
OperationalError(BUSY)
중요한 건 여기다.
“SELECT를 다 읽지 않은 상태(커서가 살아있음)”라는 조건은,
- 스트리밍 처리
- 제너레이터 기반 파이프라인
- 웹 요청 중간에 일부만 읽고 다음 작업으로 넘어감
같은 패턴에서 꽤 흔하게 등장한다.
그래서 sqlite3를 쓰는 서비스/라이브러리는 ‘내 코드가 그런가?’를 한 번 점검하는 게 좋다.
3) 애플리케이션 코드에서의 실전 가이드: collation/함수/집계 등록은 “초기화 단계”로 밀어라
sqlite3는 실행 중인 statement가 있을 때 특정 스키마/메타 레벨 작업을 거부할 수 있다.
collation 등록/교체는 그 대표격이다.
그래서 권장 패턴은 단순하다.
1) 연결을 만들자마자(또는 커서를 열기 전에) 2) 필요한 collation/함수 등록을 “한 번에” 하고 3) 그 다음에 쿼리를 돌린다
예:
import sqlite3
def init_connection(con: sqlite3.Connection) -> None:
con.create_collation("mycoll", lambda a, b: (a > b) - (a < b))
# create_function / create_aggregate 같은 것도 여기서 같이
con = sqlite3.connect("app.db")
init_connection(con)
# 그 다음부터는 쿼리
cur = con.execute("SELECT x FROM t ORDER BY x COLLATE mycoll")
for row in cur:
...
이렇게 하면 BUSY를 피하는 것뿐 아니라, “요청 중간에 동적으로 바꾸는” 위험한 패턴 자체를 없앨 수 있다.
4) 그래도 런타임에 바꿔야 한다면: BUSY를 ‘예외’가 아니라 ‘상태’로 취급하라
어쩔 수 없이 동적으로 collation을 바꿔야 한다면, 다음 3가지를 같이 가져가야 한다.
4-1) 활성 커서를 명확히 닫는다
for row in cur:로 끝까지 소진하지 않는다면, 최소한 커서를 닫아 statement를 끝내는 습관이 필요하다.
cur = con.execute(...)
try:
row = next(cur)
...
finally:
cur.close()
4-2) 같은 connection에서 ‘동시 작업’을 피한다
sqlite3는 기본적으로 connection이 상태를 공유한다.
- 하나의 connection을 여러 작업이 함께 쓰는 구조라면
- “등록” 작업은 별도 connection에서 하거나
- 아예 프로세스 시작 시점에만 하도록 설계를 바꾸는 편이 낫다.
4-3) 재시도(backoff)는 되지만, 무한 재시도는 금지
BUSY는 일시적일 수 있으니 재시도는 합리적이다.
다만 무한 재시도는 결국 더 큰 병목/대기열을 만든다.
- 제한된 횟수
- 짧은 backoff
- 그리고 실패 시 안전한 대체 경로
까지가 한 세트다.
5) 회귀 방지 테스트 아이디어: “커서를 일부만 읽고 설정을 바꾸기”
라이브러리를 만든다면, 이번 PR 테스트를 그대로 참고하는 게 좋다.
핵심은 “statement가 활성인 상태”를 테스트에 만드는 것이다.
- SELECT를 실행
- row를 하나만 꺼내고(= statement 활성)
- 같은 connection에서 create_collation을 호출
OperationalError가 나는지 확인- 그리고 무엇보다 프로세스가 죽지 않는지 확인
크래시는 테스트 러너를 통째로 날려버리기 때문에, CI에서 한 번만 터져도 비용이 크다.
6) 업그레이드 관점: 이건 성능이 아니라 ‘안정성’ 패치다
이번 변경은 “빨라진다”가 아니라,
- 특정 조건에서 발생 가능한 크래시를
- 예외로 돌려서
- 서비스 전체가 죽지 않게 만드는
안정성 패치다.
그래서 sqlite3를 깊게 쓰는 쪽(특히 create_collation/create_function을 런타임에 만지는 코드가 있는 팀)이라면,
- 해당 변경이 포함된 파이썬 버전으로의 업그레이드를
- 단순 마이너 업데이트보다 우선순위 높게
검토할 가치가 있다.
추가로, “우리 서비스는 런타임에 collation을 안 바꾸는데요?”라고 생각할 수도 있다.
그런데 아래 케이스는 종종 숨어 있다.
- 멀티테넌트에서 테넌트별 정렬 규칙을 달리하려고 collation을 교체한다
- 테스트에서만 동적으로 교체한다(프로덕션엔 없다고 믿지만, 코드 경로는 살아있다)
- ORM/DB 레이어가 내부적으로 connection 초기화를 다시 호출한다(재연결 시)
즉, 코드베이스 어딘가에서 “등록 함수”가 요청 핸들링 중간에 호출될 가능성이 조금이라도 있으면, 이 패치는 의미가 있다.
그리고 이런 류의 버그는 실제로 크래시 형태로 나타날 때가 많아서, 발견 자체가 어렵다.
- 애플리케이션 로그엔 남는 게 없고
- 프로세스만 갑자기 죽고
- 재현은 운에 맡겨진다
그래서 개인적으로는: 이런 안정성 패치를 ‘나중에’로 미루기보다는, 파이썬 업그레이드 창이 열릴 때 같이 태우는 걸 선호한다.
Keywords
python,sqlite3,cpython,create_collation,SQLITE_BUSY,OperationalError,collation,statement,cursor,transaction,MemoryError,SystemError,callback context,refcount,regression test,stability,crash
이미지 크레딧/라이선스
- CSS code on a screen (Unsplash).jpg — Sai Kiran Anagani _imkiran / CC0
- https://commons.wikimedia.org/wiki/File:CSS_code_on_a_screen_(Unsplash).jpg
- http://creativecommons.org/publicdomain/zero/1.0/deed.en
댓글
댓글 쓰기