기본 콘텐츠로 건너뛰기

sqlite3 Collation Busy Crash Fix

thumbnail

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

댓글

이 블로그의 인기 게시물

Django에서 트랜잭션 관리하기

Django에서 트랜잭션 관리하기 안녕하세요! 오늘은 Django에서 데이터베이스 트랜잭션을 효과적으로 관리하는 방법에 대해 알아보겠습니다. 1. 트랜잭션의 중요성 트랜잭션은 데이터베이스의 일관성과 무결성을 보장하는 중요한 개념입니다. Django에서는 여러 가지 방법으로 트랜잭션을 관리할 수 있습니다. 1.1 기본 개념 원자성(Atomicity) : 트랜잭션은 모두 실행되거나 모두 실행되지 않아야 합니다. 일관성(Consistency) : 트랜잭션 전후로 데이터베이스의 일관성이 유지되어야 합니다. 격리성(Isolation) : 동시에 실행되는 트랜잭션들이 서로 영향을 주지 않아야 합니다. 지속성(Durability) : 완료된 트랜잭션의 결과는 영구적으로 저장되어야 합니다. 2. Django의 트랜잭션 관리 2.1 기본 설정 # settings.py DATABASES = { 'default': { 'ENGINE': 'django.db.backends.postgresql', 'NAME': 'mydatabase', 'USER': 'myuser', 'PASSWORD': 'mypassword', 'HOST': 'localhost', 'PORT': '5432', 'ATOMIC_REQUESTS': True, # 모든 뷰를 트랜잭션으로 래핑 } } 2.2 데코레이터 사용 from django.db import transaction @transaction.atomic def create_order(user, items): order = Order.objects.create(user=...

AWS S3 + CloudFront로 정적 파일 서빙 완전 가이드

AWS S3 + CloudFront로 정적 파일 서빙 완전 가이드 안녕하세요! 오늘은 AWS S3와 CloudFront를 사용하여 정적 파일을 효율적으로 서빙하는 방법에 대해 알아보겠습니다. 왜 S3와 CloudFront를 사용할까요? 높은 가용성 : AWS의 글로벌 인프라를 활용 빠른 전송 속도 : CloudFront의 CDN 기능으로 전 세계 사용자에게 빠른 전송 비용 효율성 : 사용한 만큼만 지불 보안 : AWS의 보안 기능 활용 확장성 : 트래픽 증가에 자동 대응 1. S3 버킷 설정 1.1 버킷 생성 및 설정 import boto3 def create_s3_bucket(): s3 = boto3.client('s3') # 버킷 생성 bucket_name = 'your-static-files-bucket' s3.create_bucket( Bucket=bucket_name, CreateBucketConfiguration={ 'LocationConstraint': 'ap-northeast-2' } ) # 버킷 정책 설정 bucket_policy = { "Version": "2012-10-17", "Statement": [ { "Sid": "PublicReadGetObject", "Effect": "Allow", "Principal": "*", "Action": "s3:GetObje...

RDS에서 Django 앱 성능을 높이는 데이터베이스 설정 팁

RDS에서 Django 앱 성능을 높이는 데이터베이스 설정 팁 안녕하세요! 오늘은 AWS RDS를 사용하는 Django 애플리케이션의 성능을 최적화하는 방법에 대해 알아보겠습니다. 1. RDS 인스턴스 최적화 1.1 인스턴스 타입 선택 # RDS 인스턴스 크기 조정 import boto3 def resize_rds_instance(): rds = boto3.client('rds') response = rds.modify_db_instance( DBInstanceIdentifier='your-db', DBInstanceClass='db.t3.large', # 워크로드에 맞는 인스턴스 타입 선택 ApplyImmediately=True ) return response['DBInstance'] 1.2 파라미터 그룹 설정 def create_parameter_group(): rds = boto3.client('rds') # PostgreSQL 파라미터 그룹 생성 response = rds.create_db_parameter_group( DBParameterGroupName='django-optimized', DBParameterGroupFamily='postgres13', Description='Optimized parameters for Django applications' ) # 성능 관련 파라미터 설정 parameters = [ { 'ParameterName': 'shared_buffers', 'ParameterValue': '2GB...