Skip to content

Split View: SQLite WAL-reset 버그 — 15년을 숨어 있던 데이터 손상 경합

|

SQLite WAL-reset 버그 — 15년을 숨어 있던 데이터 손상 경합

들어가며 — "Choose any three"에 붙은 각주

SQLite 문서 페이지 상단에는 늘 같은 문장이 붙어 있습니다. "Small. Fast. Reliable. Choose any three." 그리고 SQLite는 대체로 이 약속을 지켜 왔습니다. SQLite는 어디에나 있다 편에서 정리했듯이 이 엔진은 여러분의 폰, 브라우저, 항공전자 장비 안에서 조용히 돌고 있고, 그 신뢰성은 거의 인프라 상수처럼 취급됩니다.

그래서 2026년 3월의 사건이 흥미롭습니다. 공식 문서의 표현으로 "SQLite 개발자 중 한 명(Dan)"이 2026년 3월 3일에 WAL 모드에서 드물게 데이터베이스를 손상시킬 수 있는 버그를 찾아 고쳤습니다. SQLite 팀이 직접 붙인 이름은 "WAL-reset bug"입니다. 그리고 이 버그는 WAL이 처음 등장한 3.7.0(2010-07-21)부터 3.51.2(2026-01-09)까지 살아 있었습니다. 릴리스 노트의 표현을 그대로 옮기면 "15년 묵은 데이터베이스 손상 버그가 며칠 전에 발견됐고 이제 고쳐졌다"입니다.

먼저 온도를 맞춰 두겠습니다. 이건 여러분이 오늘 밤 페이저를 받아야 하는 종류의 사건이 아닙니다. SQLite 문서 스스로 "긴급 상황은 아니다(this is not an emergency)"라고 못 박고 있고, 그 근거도 함께 제시합니다. 다만 이 버그가 어떻게 생겼고 어떻게 잡혔는지는, WAL을 쓰는 사람이라면 읽어 둘 값이 있습니다. 락 하나가 아니라 락 두 개가 필드 하나를 나눠 지키면 무슨 일이 벌어지는지에 대한, 아주 깔끔한 교과서적 사례이기 때문입니다.

WAL 되감기 — 이 버그가 사는 동네

버그를 보기 전에 무대를 알아야 합니다. WAL의 기본 구조는 SQLite 내부 구조 편에서 다뤘으니, 여기서는 이 버그에 직접 관련된 부분만 짚습니다.

WAL 모드에서 쓰기는 데이터베이스 파일이 아니라 WAL 파일 끝에 프레임(frame)으로 추가됩니다. 읽기는 데이터베이스 파일과 WAL을 함께 봅니다. 그런데 WAL이 무한히 커지면 읽기가 느려집니다 — 문서의 표현으로, 읽기 성능은 WAL 파일이 커질수록 나빠지고 WAL을 확인하는 시간은 WAL 크기에 비례하기 때문입니다.

그래서 체크포인트(checkpoint) 가 돕니다. 체크포인트는 WAL의 내용을 데이터베이스 파일로 되돌려 씁니다(backfill). 기본 전략은 WAL이 약 1000페이지가 될 때까지 키운 다음, WAL이 1000페이지 아래로 다시 줄어들 때까지 이후의 매 COMMIT마다 체크포인트를 도는 것입니다.

여기서 이 글의 핵심 개념이 나옵니다. WAL 전체가 데이터베이스로 옮겨졌고 동기화까지 끝났으며 어떤 리더도 WAL을 쓰고 있지 않다면, 다음 writer는 WAL을 처음으로 되감고(rewind) 새 트랜잭션을 WAL의 맨 앞에 쓰기 시작합니다. 이게 "WAL reset"입니다. 파일을 지우고 새로 만드는 게 아니라, 커서를 0으로 되돌리고 앞에서부터 덮어쓰는 것입니다. 이 되감기 덕분에 WAL 파일이 무한정 커지지 않습니다.

되감기는 순수한 최적화이고, 대부분의 시간 동안 완벽하게 동작합니다. 문제는 "되감아도 되는가"를 판단하는 회계 장부에 있었습니다.

장부 두 줄 — mxFrame과 nBackfill

그 장부는 wal-index(공유 메모리, 흔히 -shm 파일) 헤더에 있습니다. 이 글에 필요한 필드는 둘입니다.

mxFrame     WAL 안의 유효한 프레임 개수.
            프레임은 1부터 번호가 매겨지므로, 마지막 유효 커밋 프레임의 인덱스이기도 하다.
            0이면 WAL이 비었다는 뜻 -> 모든 내용을 DB 파일에서 바로 읽으면 된다.

nBackfill   WAL 프레임 중 이미 데이터베이스로 복사돼 들어간 개수.
            항상 nBackfill <= mxFrame.

            mxFrame == nBackfill  ->  WAL 내용이 전부 DB에 반영됨
                                  ->  WAL_READ_LOCK(N>0) 을 잡은 연결이 없다면
                                      다음 writer는 WAL을 되감아도 된다.

mxFrame == nBackfill 이 되감기의 허가증입니다. 그리고 nBackfill 은 체크포인터가 "여기까지는 이미 옮겼으니 다음 체크포인트는 이 지점부터 시작하면 된다"고 적어 두는 진행 표시이기도 합니다.

이제 이 글 전체에서 가장 중요한 두 문장이 나옵니다. SQLite 문서(walformat.html)를 그대로 옮기면 이렇습니다.

  • nBackfillWAL_CKPT_LOCK을 잡은 상태에서만 증가시킬 수 있다.
  • 그러나 nBackfill 은 WAL reset 동안 0으로 바뀌며, 이 일은 WAL_WRITE_LOCK을 잡은 상태에서 일어난다.

읽고 넘어가기 쉬운 문장이지만, 여기에 버그가 통째로 들어 있습니다. 필드는 하나인데 그 필드를 만지는 두 경로가 서로 다른 락을 잡습니다. 체크포인터는 CKPT 락을 들고 nBackfill 을 올리고, 되감는 writer는 WRITE 락을 들고 nBackfill 을 0으로 내립니다. 둘은 서로를 배제하지 않습니다 — 다른 자물쇠를 쥐고 같은 방에 들어가는 셈입니다.

이 설계가 15년간 굴러간 데는 이유가 있습니다. 정상 경로에서는 체크포인터가 nBackfill 을 올리려는 시점과 writer가 그걸 0으로 만드는 시점이 다른 상태(state)에 속하고, 다른 검사들이 그 사이를 막아 줍니다. 문제는 "체크포인트가 막 시작되는 찰나"라는 아주 좁은 창문이었습니다.

손상은 6단계로 일어난다

SQLite 문서가 정리한 시나리오는 정확히 6단계입니다. 순서대로 보겠습니다.

1. 연결 A가 체크포인트를 완주한다.
   반드시 "완전한" 체크포인트여야 한다 — WAL의 모든 내용을 DB로 옮기고,
   WAL이 되감길 수 있는 상태로 남겨 둔다.  (즉 mxFrame == nBackfill)

2. 그 직후, 두 번째 체크포인트가 시작된다.

3. 2번 체크포인트가 "시작하는 중"일 때, 또 다른 연결이 트랜잭션을 커밋한다.
   이 커밋이 WAL을 되감고(reset), 새 내용을 WAL의 맨 앞에 쓴다.

4. 데이터 경합 발생. 2번 체크포인트는 WAL이 3번에서 되감겼다는 사실을 알지 못한다.
   그래서 wal-index 헤더의 필드를 잘못된 값으로 남긴다 —
   "WAL의 이 부분은 이미 체크포인트됐다"고 적어 두지만, 실제로는 아니다.

5. 추가 트랜잭션들이 커밋되어, WAL의 프레임 수가
   1번 체크포인트 시점보다 많아진다.

6. 나중에 세 번째 체크포인트가 돌 때,
   3번에서 쓴 트랜잭션의 전부 또는 일부를 건너뛴다.
   -> 그 트랜잭션이 DB 파일에 영영 도달하지 못한다.
   -> 데이터베이스 손상.

4단계가 사고의 순간입니다. 되감기가 일어나면 nBackfill 은 0이 되어야 맞습니다 — WAL이 처음부터 새로 쓰이고 있으니 "이미 옮긴 프레임"은 0개니까요. 그런데 막 시작하던 2번 체크포인트는 되감기 이전의 세계관을 들고 있습니다. 그 체크포인트가 남긴 nBackfill 은 "앞쪽 N개 프레임은 이미 처리했다"고 주장합니다. 하지만 되감기 이후 그 앞쪽 N개 프레임은 완전히 다른 데이터입니다. 3단계에서 새로 쓴 트랜잭션이죠.

그래서 6단계의 체크포인터는 그 프레임들을 "이미 했다"고 믿고 건너뜁니다. 건너뛴 데이터는 DB 파일에 도달하지 못하고, WAL은 결국 되감겨 그 위에 덮어써집니다. 커밋되었다고 응답받은 트랜잭션이 조용히 증발하는 것입니다. 이게 durability 위반이자 손상입니다.

주목할 점은 손상이 즉시 드러나지 않는다는 것입니다. 4단계에서 잘못된 값이 기록되고, 실제 피해는 6단계에서 발생합니다. 그 사이에 5단계(추가 커밋들)가 끼어야 합니다. 사고와 증상 사이에 시간과 트랜잭션이 놓여 있습니다 — 사후 분석을 지독하게 어렵게 만드는 종류의 버그입니다.

필요조건 — 왜 대부분의 사람은 무사한가

문서가 명시한 발동 조건은 이렇습니다.

  • 데이터베이스가 WAL 모드여야 한다.
  • 같은 파일에 두 개 이상의 연결이 열려 있어야 하고, 그 연결들이 서로 다른 스레드나 프로세스에 있어야 한다.
  • 그 두 연결이 같은 순간에 쓰기 또는 체크포인트를 시도해야 한다.

여기서 첫 번째 정직한 관찰. 이 조건은 단일 프로세스 단일 스레드 앱 — 데스크톱 앱, 모바일 앱, CLI 도구, 테스트 픽스처 — 을 통째로 제외합니다. SQLite 배포량의 압도적 다수가 여기에 속합니다.

동시에 두 번째 정직한 관찰도 해야 공정합니다. 남는 조건 — "WAL 모드 + 여러 프로세스가 한 파일에 쓰기" — 은 하필 요즘 유행하는 배포 형태와 정확히 겹칩니다. 워커 프로세스 여러 개를 띄운 웹 앱이 로컬 SQLite 파일 하나를 공유하는 구성 말이죠. 즉 이 버그의 필요조건은 SQLite의 전통적 사용처가 아니라 최근에 늘어난 사용처를 겨냥합니다.

그렇다고 겁을 줄 생각은 없습니다. 조건을 만족한다는 것과 실제로 터진다는 것은 전혀 다른 이야기이고, 그 간극이 이 사건에서 가장 흥미로운 부분입니다.

재현할 수 없는 버그를 어떻게 "고쳤다"고 말하는가

여기가 이 사건의 진짜 볼거리입니다. SQLite 문서의 서술을 옮기면 이렇습니다.

개발자들은 이 버그를 실험실에서 자연적으로(organically) 재현하는 데 한 번도 성공하지 못했다.

버그가 터지려면 여러 세부 사항이 딱 맞는 순간에 정렬해야 합니다. 특히 3단계의 커밋이 2단계 체크포인트의 "시작하는 중"이라는 좁은 구간에 정확히 들어가야 합니다. 이건 나노초 단위의 창문입니다. 그래서 SQLite 팀이 택한 방법은 이렇습니다.

SQLite 소스 자체를 수정해서, sqlite3_test_control() 로 제어되는 콜백을 심었습니다. 그 콜백은 테스트 프로그램이 2번 체크포인트가 도는 도중 정확히 그 순간에 3단계의 쓰기 트랜잭션을 발동시킬 수 있게 해 줍니다. 즉 경합을 기다리는 대신, 경합을 명령으로 만들어 냅니다. 문서의 표현으로, 이 코드 해킹 없이는 개발 및 테스트 과정에서 이 문제가 관측된 적이 한 번도 없었습니다.

이 접근이 왜 옳은지 짚고 넘어갈 값이 있습니다. 확률적으로만 터지는 경합에 대해 "고쳤다"고 주장하는 방법은 두 가지뿐입니다.

  1. 증명한다 (모델 체킹, 형식 검증).
  2. 경합을 결정론적으로 만든 뒤 테스트한다.

SQLite는 2번을 골랐고, 그러기 위해 프로덕션 코드에 테스트 전용 훅을 넣는 비용을 지불했습니다. 이건 공짜가 아닙니다 — 검증 가능성을 위해 코드 표면적을 늘리는, 명시적인 트레이드오프입니다. 하지만 대안은 "고친 것 같다"이고, 데이터 손상 버그에서 "같다"는 답이 아닙니다.

여기에 실무적 교훈이 하나 있습니다. 여러분의 시스템에도 확률적 경합이 있다면, "부하를 많이 주면 언젠가 재현되겠지"는 전략이 아닙니다. SQLite조차 그렇게 하지 못했습니다. 결정론적으로 스케줄을 조종할 수 있는 훅을 심는 쪽이 — 지저분해 보여도 — 실제로 답을 주는 유일한 길인 경우가 많습니다. (같은 철학을 시스템 전체 규모로 밀어붙이면 결정론적 시뮬레이션 테스트가 됩니다.)

얼마나 위험한가 — 문서가 직접 밝힌 확률

이 부분은 SQLite 문서의 표현을 최대한 그대로 전하겠습니다. 제가 수치를 만들어 붙일 자리가 아닙니다.

문서는 먼저 심각성을 인정합니다. 이 버그는 드물지만 심각한 결과를 낳으므로, 애플리케이션 개발자는 문제가 고쳐진 버전으로 업그레이드해야 한다고 말합니다.

그리고 곧바로 온도를 낮춥니다. "그러나 이것은 긴급 상황이 아니다." 근거는 이렇습니다 — 가용한 텔레메트리에 기반할 때, 이 문제가 실제 환경에서 발생하는 비율은 SSD 오작동이나 우주 방사선 히트의 예상 발생률 이하로 보인다는 것입니다. 그래서 패치되지 않은 버전을 돌리고 있더라도, 정말 특이한 짓을 하고 있는 게 아니라면 이 문제를 만날 일은 없을 것이라고 씁니다.

이 비교를 제대로 읽는 법이 중요합니다. "SSD 고장률 이하"는 "0"이 아닙니다. 그건 이미 여러분이 감수하고 있는 하드웨어 실패 확률의 잡음에 묻힌다는 뜻입니다. 여러분이 SSD 고장이나 비트 반전에 대비해 백업과 무결성 검사를 이미 갖추고 있다면, 이 버그는 그 대비 안에 들어옵니다. 반대로 그런 대비가 없다면, 걱정해야 할 건 이 버그가 아니라 SSD입니다.

그리고 caveat를 하나 남겨 둡니다. 문서는 "가용한 텔레메트리에 기반할 때(based on available telemetry)"라고 조건을 답니다. 어떤 텔레메트리인지, 표본이 얼마인지는 공개돼 있지 않습니다. 또한 앞서 본 것처럼 이 손상은 원인과 증상이 시간적으로 분리돼 있어서, 만약 발생했더라도 "SQLite 버그"로 귀속되지 않고 "원인 미상의 손상"이나 "디스크 문제"로 분류됐을 가능성이 구조적으로 존재합니다. 즉 이 추정치는 낙관 쪽으로 편향될 여지가 있는 종류의 추정치입니다. SQLite 팀이 부정직하다는 뜻이 아니라, 이런 버그의 관측 자체가 원래 어렵다는 뜻입니다.

릴리스가 꼬인 2주 — 3.52.0의 철회

여기서 이야기가 조금 이상해집니다. 그리고 이 부분은 오해하기 딱 좋으므로 정확히 짚겠습니다.

타임라인은 이렇습니다.

2026-01-09   3.51.2 릴리스        (버그가 살아 있는 마지막 버전)
2026-03-03   Dan이 WAL-reset 버그를 발견하고 고침
2026-03-06   3.52.0 릴리스        (WAL-reset 수정 + CLI 대개편 포함)
2026-03-13   3.52.0 철회(Withdrawn)
             같은 날, 3.51.3 릴리스  (WAL-reset 수정만 백포트)
2026-04-09   3.53.0 릴리스        (3.52.0의 재출시 + 추가 수정)
2026-05-05   3.53.1  /  2026-06-03  3.53.2  /  2026-06-26  3.53.3

발견 사흘 만에 메이저 릴리스가 나갔고, 일주일 뒤 그 릴리스가 철회됐습니다.

중요: 3.52.0이 철회된 이유는 WAL-reset 버그가 아닙니다. 이건 시간 순서상 착각하기 쉬운 지점이라 분명히 해 둡니다. 철회 사유는 전혀 다른 문제였습니다 — 3.52.0의 새 기능 일부가 이전 릴리스와 100% 호환되지 않았고, 그 기능과 관련 API를 재작업해야 했기 때문입니다.

구체적으로, 릴리스 노트는 범위를 이렇게 좁힙니다. 3.52.0은 데이터베이스에 표현식 인덱스(expression index)VIRTUAL 계산 열에 대한 인덱스 가 없는 한 잘 동작하고 완전히 하위 호환됩니다. 문제가 되는 건 그 인덱스의 표현식이 텍스트나 JSONB 입력에서 파생된 부동소수점 값으로 평가되는 경우입니다. 그런 인덱스가 있으면 드문 경우에 3.52.0이 이전 릴리스와 제대로 상호운용되지 않을 수 있습니다.

참고로 같은 릴리스의 변경 로그에는 부동소수점과 텍스트 사이 변환을 다시 구현했고 반올림 기본값을 이전 모든 버전의 유효숫자 15자리에서 17자리로 바꿨다는 항목이 따로 있습니다. 두 항목이 같은 영역을 건드린다는 점은 눈에 띄지만, 공개된 문서는 이 변경이 위 호환성 문제의 원인이라고 명시하지 않습니다 — 그러니 여기서 인과로 단정하지는 않겠습니다.

그럼 팀은 어떻게 대응했을까요. 여기가 배울 대목입니다. 그들은 "3.53.0 나올 때까지 기다리세요"라고 하지 않았습니다. 대신 3.51 브랜치에 패치 릴리스 3.51.3을 냈습니다. 3.51.3은 WAL-reset 버그와 3.51.2 이후 드러난 사소한 문제들만 고칩니다. 새 기능은 없습니다.

이 선택의 의미를 풀어 보면 이렇습니다. 손상 버그 수정이 CLI 대개편, 새 SQL 문법, QRF 라이브러리, 부동소수점 변환 개편과 한 덩어리로 묶여 있었습니다. 그리고 그 덩어리에서 호환성 문제가 터졌습니다. 만약 3.52.0이 유일한 경로였다면, 사용자는 "손상 버그를 고치려면 호환성 리스크가 있는 기능 릴리스를 통째로 삼켜라"는 선택을 강요받았을 것입니다. 낡은 안정 브랜치에 백포트를 내는 것은 그 강요를 없애는 일입니다 — 리스크를 분리해서, 손상 수정만 원하는 사람이 손상 수정만 가져갈 수 있게 하는 것.

그리고 이건 이론이 아니었습니다. node-sqlite3 저장소에는 철회 당일인 2026-03-13에 "철회된 3.52.0을 안정 버전 3.51.3으로 교체하자"는 이슈가 올라왔고, 보고자는 자신이 피해를 입은 게 아니라 문제를 예방하려는 정리 작업이라고 명시했습니다. 백포트가 없었다면 이 저장소의 선택지는 훨씬 나빴을 것입니다.

3.53.0(2026-04-09)은 릴리스 노트의 표현으로 "3.52.0의 재출시(re-release)이며, 스테일 표현식 인덱스 문제를 다루는 개선이 추가된" 버전입니다. 그리고 WAL-reset 버그에 대해 "3.52.0을 치지 않는다면, 이 수정을 담은 첫 메이저 릴리스"라고 적으며 업그레이드를 권합니다.

덧붙일 각주가 하나 있습니다. 3.52.0 릴리스 노트에는 이런 문장도 있었습니다 — SQLite의 릴리스 주기를 6개월에 한 번 정도로 늦추려 노력 중이며, 이건 약속이 아니라 목표라고. 그 문장이 나온 릴리스가 일주일 만에 철회됐다는 건, 공감이 가는 종류의 아이러니입니다.

당신이 실제로 해야 할 일

정리하면 행동 목록은 짧습니다.

1. 버전을 확인한다. 애플리케이션이 실제로 링크하고 있는 SQLite 버전을 확인하십시오. 패키지 매니저가 보고하는 버전이 아니라, 런타임이 보고하는 버전입니다.

SELECT sqlite_version();

2. 기준선은 3.51.3(2026-03-13) 이상입니다. 그보다 낮은 3.7.0 이상의 모든 버전에 이 버그가 있습니다. 메이저 릴리스로 가고 싶다면 3.53.0 이상이고, 이 글을 쓰는 2026-07-16 기준 최신은 3.53.3(2026-06-26)입니다.

3. 올릴 수 없다면 백포트가 있습니다. SQLite 문서는 일부 이전 릴리스에 대한 백포트를 제공합니다 — 3.44.6과 3.50.7. 다만 이건 정식 릴리스 타르볼이 아니라 Fossil 체크인 형태로 제공된다는 점에 유의하십시오.

4. 그리고 이게 진짜 함정입니다 — 한 번 올렸다고 끝이 아닙니다.

이 마지막 항목을 위해 실제 사례를 하나 보겠습니다. OpenAI의 codex 저장소에는 2026년 6월에 "번들 SQLite를 WAL-reset이 고쳐진 버전으로 핀 고정"이라는 PR이 올라와 2026-06-14에 머지됐습니다. PR 설명에 적힌 사고 경위는 이렇습니다.

SQLx 0.9libsqlite3-sys 에 대해 넓은 버전 범위를 허용하고 있었습니다. 그래서 이 문제와 아무 상관 없는 락파일 갱신 한 번이 libsqlite3-sys 를 0.37.0에서 0.35.0으로 되돌렸고, 번들된 SQLite 런타임이 3.51.3에서 3.50.2로 조용히 다운그레이드됐습니다. 즉 이미 고쳐진 버전에 도달했던 프로젝트가, 무관한 의존성 정리 작업 하나에 의해 취약한 버전으로 되돌아간 것입니다.

이게 SQLite가 라이브러리라는 사실의 대가입니다. SQLite는 어디에나 있다 편에서 서버 없는 설계의 장점을 이야기했지만, 동전의 뒷면이 여기 있습니다. 서버가 없다는 건 패치할 중앙 지점이 없다는 뜻이기도 합니다. PostgreSQL이라면 서버 하나를 올리면 모든 클라이언트가 고쳐집니다. SQLite는 그 코드를 링크한 모든 바이너리가 각자 고쳐져야 하고, 그 버전은 대개 여러분이 직접 고른 게 아니라 의존성 트리 어딘가에서 전이적으로 결정됩니다. 그리고 전이적 의존성은 여러분이 보지 않는 사이에 움직입니다.

실무적으로: 번들 SQLite를 쓰는 프로젝트라면 버전을 상한/하한으로 고정하고, CI에서 SELECT sqlite_version() 의 결과를 실제로 단언(assert)하는 편이 낫습니다. 락파일 diff를 사람이 읽어서 잡기를 기대하지 마십시오 — codex 사례가 정확히 그게 실패한 사례입니다.

이 사건에서 남는 것

몇 가지를 정리하고 싶습니다.

첫째, 15년은 코드가 옳다는 증거가 아닙니다. 이 버그는 SQLite 역사상 가장 많이 테스트된 코드 경로 중 하나에, 업계에서 가장 편집증적인 테스트 스위트를 갖춘 프로젝트 안에, 15년간 살아 있었습니다. 이유는 단순합니다 — 테스트는 스케줄을 제어하지 못했고, 이 버그는 스케줄에만 존재했습니다. 커버리지 100%도 인터리빙 100%를 뜻하지 않습니다.

둘째, 불변식은 필드가 아니라 락에 붙습니다. 이 사건을 한 문장으로 줄이면 "nBackfill 을 증가시키는 경로와 0으로 만드는 경로가 서로 다른 락을 잡았다"입니다. 두 경로 각각은 자기 락을 정확히 지켰습니다. 각각은 옳았습니다. 틀린 건 "이 필드를 지키는 락은 무엇인가"라는 질문에 답이 두 개였다는 사실입니다. 여러분의 코드에서 공유 상태를 리뷰할 때 물어야 하는 건 "락을 잡았는가"가 아니라 "이 상태를 만지는 모든 경로가 같은 락을 잡는가"입니다.

셋째, 좋은 공시(disclosure)의 모범입니다. SQLite 팀이 한 일을 보십시오. 전용 문서 섹션을 만들고, 영향 버전 범위를 정확히 명시하고, 6단계 메커니즘을 공개하고, 재현하지 못했다는 사실을 인정하고, 재현을 위해 소스를 해킹해야 했다는 것까지 적고, 발생률 추정치와 그 추정의 근거를 밝히고, "긴급 상황은 아니다"라고 온도를 조절하고, How To Corrupt An SQLite Database File 문서의 손상 원인 목록에 자기 버그를 항목으로 추가했습니다. 마지막 항목이 특히 좋습니다 — 그 문서는 원래 "손상은 대개 당신 잘못이다"를 설명하는 페이지인데, 거기에 "혹은 우리 잘못일 수도 있다"를 스스로 써 넣은 것입니다.

넷째, 리스크를 묶지 마십시오. 3.52.0의 교훈은 호환성 문제 자체가 아니라, 손상 수정이 기능 릴리스와 한 덩어리로 묶여 있었다는 구조입니다. 낡은 브랜치에 백포트를 낼 수 있는 능력은 릴리스 엔지니어링의 사치품이 아니라 안전 장비입니다.

마치며

정직한 결론은 이렇습니다. WAL-reset 버그는 여러분이 겪었을 가능성이 매우 낮고, SQLite 문서 스스로 그 발생률이 SSD 고장률의 잡음에 묻힌다고 밝히고 있습니다. 이 글은 여러분을 놀라게 하려고 쓴 게 아닙니다.

하지만 조치는 간단합니다 — 3.51.3 이상으로 올리고, CI에서 그 버전이 유지되는지 단언하십시오. 비용이 거의 없는 일이고, 문서가 권장하는 일입니다.

그리고 이 사건을 읽는 더 나은 방법이 있다고 생각합니다. 이건 SQLite의 신뢰성이 과대평가였다는 이야기가 아닙니다. 오히려 반대입니다 — 15년간 실무에서 아무도 자연 발생시키지 못한 경합을 개발자가 코드를 읽다가 찾아냈고, 재현하기 위해 테스트 훅을 심었고, 사흘 만에 고쳤고, 릴리스가 꼬이자 낡은 브랜치에 백포트를 냈고, 전 과정을 문서로 공개했습니다. "Reliable"은 버그가 없다는 뜻이 아닙니다. 버그를 이렇게 다룬다는 뜻입니다.

참고 자료

The SQLite WAL-Reset Bug: A Data Corruption Race That Hid for 15 Years

Introduction — The Footnote on "Choose Any Three"

The top of SQLite's documentation pages always carries the same line: "Small. Fast. Reliable. Choose any three." And for the most part, SQLite has kept that promise. As covered in SQLite Is Everywhere, this engine runs quietly inside your phone, your browser, and your avionics, and its reliability is treated as close to an infrastructure constant.

Which is what makes the March 2026 incident interesting. In the documentation's own words, "one of the SQLite developers (Dan)" found and fixed, on March 3, 2026, a bug that could rarely corrupt a database in WAL mode. The SQLite team's own name for it is the "WAL-reset bug." And this bug had been alive since 3.7.0 (2010-07-21), when WAL first shipped, all the way through 3.51.2 (2026-01-09). In the release notes' own wording: "A 15-year-old database corruption bug was discovered a few days ago and has now been fixed."

Let's set the temperature first. This is not the kind of thing that should page you tonight. SQLite's own documentation states plainly that "this is not an emergency," and backs that up with reasoning. But how this bug came to exist and how it was caught is worth reading if you use WAL — it's a remarkably clean, textbook case of what happens when two locks, not one, split the guardianship of a single field.

WAL Reset — The Neighborhood This Bug Lives In

Before looking at the bug, we need the stage. WAL's basic structure was covered in SQLite Internals; here we'll touch only the parts directly relevant to this bug.

In WAL mode, writes are appended as frames to the end of the WAL file rather than to the database file itself. Reads look at both the database file and the WAL together. But if the WAL grows without bound, reads get slower — in the documentation's words, read performance degrades as the WAL file grows, because the time to check the WAL is proportional to its size.

That's why checkpointing happens. A checkpoint writes the WAL's contents back into the database file (backfill). The default strategy is to let the WAL grow to roughly 1000 pages, then run a checkpoint on every subsequent COMMIT until the WAL shrinks back below 1000 pages.

Here comes the central concept of this post. Once the entire WAL has been transferred into the database, the sync has completed, and no reader is still reading the WAL, the next writer rewinds the WAL back to the start and begins writing the new transaction at the front of the WAL. This is the "WAL reset." It isn't deleting and recreating the file — it's resetting the cursor to zero and overwriting from the front. This rewind is what keeps the WAL file from growing forever.

The reset is a pure optimization, and it works flawlessly the vast majority of the time. The problem lay in the bookkeeping that decides "is it safe to reset."

Two Ledger Lines — mxFrame and nBackfill

That bookkeeping lives in the wal-index (shared memory, usually the -shm file) header. Two fields matter for this post.

mxFrame     Number of valid frames in the WAL.
            Since frames are numbered starting at 1, this also equals the
            index of the last valid commit frame.
            0 means the WAL is empty -> everything can be read straight from the DB file.

nBackfill   Number of WAL frames already copied into the database.
            Always nBackfill <= mxFrame.

            mxFrame == nBackfill  ->  the entire WAL has been applied to the DB
                                  ->  if no connection holds WAL_READ_LOCK(N>0),
                                      the next writer is free to reset the WAL.

In other words, mxFrame == nBackfill is the permit slip for a reset. And nBackfill also doubles as the checkpointer's progress marker — a note that says "everything up to here has already been carried over, so the next checkpoint can start from this point."

Now come the two most important sentences in this entire post. Quoting SQLite's documentation (walformat.html) directly:

  • nBackfill can only be incremented while holding WAL_CKPT_LOCK.
  • But nBackfill is reset to zero during a WAL reset, and that happens while holding WAL_WRITE_LOCK.

Easy sentences to skim past, but the entire bug is contained in them. There is one field, and the two code paths that touch it take two different locks. The checkpointer holds the CKPT lock while raising nBackfill; the writer doing the reset holds the WRITE lock while dropping it to zero. Neither excludes the other — it's two people entering the same room holding different keys.

There's a reason this design ran fine for 15 years. On the normal path, the moment the checkpointer wants to raise nBackfill and the moment a writer zeroes it belong to different states, and other checks close the gap between them. The problem was one very narrow window: the instant a checkpoint is just starting.

Corruption Happens in Six Steps

SQLite's documentation lays out the scenario in exactly six steps. Let's walk through them in order.

1. Connection A completes a checkpoint.
   It must be a "complete" checkpoint — every byte of the WAL has been
   carried into the DB, and the WAL is left in a state where it can be
   reset.  (i.e. mxFrame == nBackfill)

2. Immediately after, a second checkpoint begins.

3. While checkpoint #2 is "in the process of starting," another
   connection commits a transaction. This commit resets the WAL and
   writes the new content at the very front of the WAL.

4. Data race. Checkpoint #2 has no way of knowing the WAL was reset
   in step 3. So it leaves the wal-index header field with a wrong
   value -- claiming "this part of the WAL has already been
   checkpointed," when it hasn't.

5. Additional transactions commit, and the WAL's frame count grows
   past what it was at the time of checkpoint #1.

6. Later, when a third checkpoint runs, it skips all or part of the
   transaction written in step 3.
   -> That transaction never reaches the database file.
   -> Database corruption.

Step 4 is the moment of the accident. When a reset happens, nBackfill should become 0 — the WAL is being written from scratch, so "frames already carried over" is genuinely zero. But checkpoint #2, which had just started, is still holding the pre-reset picture of the world. The nBackfill it leaves behind asserts "the first N frames have already been handled." But after the reset, those first N frames are completely different data — the transaction freshly written in step 3.

So the checkpointer in step 6 believes those frames are "already done" and skips them. The skipped data never reaches the DB file, and the WAL eventually gets reset again and overwritten on top of it. A transaction that was acknowledged as committed silently evaporates. That's a durability violation, and it's corruption.

Worth noting: the corruption doesn't surface immediately. The wrong value is recorded in step 4, but the actual damage happens in step 6. Step 5 (more commits) has to intervene in between. There is time and there are transactions sitting between the accident and the symptom — exactly the kind of bug that makes postmortems miserable.

The Preconditions — Why Most People Are Fine

The documentation spells out the triggering conditions explicitly:

  • The database must be in WAL mode.
  • Two or more connections must be open on the same file, and those connections must be in different threads or processes.
  • Those two connections must attempt a write or checkpoint at the same instant.

Here's the first honest observation. This condition rules out single-process, single-thread apps entirely — desktop apps, mobile apps, CLI tools, test fixtures. The overwhelming majority of SQLite deployments fall into that bucket.

But fairness demands a second honest observation too. The condition that remains — "WAL mode plus multiple processes writing to one file" — happens to line up exactly with a deployment shape that's grown popular recently: a web app running multiple worker processes sharing a single local SQLite file. In other words, the preconditions for this bug target not SQLite's traditional use cases, but its recently expanding ones.

That's not meant to scare anyone. Meeting the preconditions and actually hitting the bug are two very different things, and that gap is the most interesting part of this whole story.

How Do You Say You "Fixed" an Unreproducible Bug?

This is where the real spectacle of the incident is. In SQLite's own words:

The developers were never able to reproduce this bug organically in the lab.

For this bug to fire, a number of details have to line up at exactly the right moment. In particular, the commit in step 3 has to land precisely inside the narrow "in the process of starting" window of the checkpoint in step 2. That's a nanosecond-scale window. So here's the approach the SQLite team took.

They modified SQLite's own source to embed a callback controlled by sqlite3_test_control(). That callback lets a test program fire the write transaction from step 3 at exactly the right moment while checkpoint #2 is in flight. In other words, instead of waiting for the race, they turned the race into an order they could issue on command. In the documentation's own words, without this code hack, this problem had never once been observed during development and testing.

It's worth pausing on why this approach is the right one. There are exactly two ways to credibly claim you "fixed" a race that only fires probabilistically:

  1. Prove it (model checking, formal verification).
  2. Make the race deterministic, then test it.

SQLite chose option two, and paid the cost of putting a test-only hook into production code to do it. That's not free — it's an explicit tradeoff, expanding the code surface in exchange for verifiability. But the alternative is "we think we fixed it," and "we think" is not an acceptable answer for a data corruption bug.

There's a practical lesson here. If your own systems have probabilistic races, "throw enough load at it and it'll eventually reproduce" is not a strategy. Even SQLite couldn't make that work. Embedding a hook that lets you deterministically steer the schedule is — even if it looks messy — often the only path that actually gets you an answer. (Push that same philosophy to the scale of an entire system, and you get deterministic simulation testing.)

How Dangerous Is It — The Probability, in the Documentation's Own Words

I want to carry this section as close to SQLite's own phrasing as possible. This isn't a place for me to invent a number.

The documentation first acknowledges the severity. This bug is rare but has severe consequences, so application developers should upgrade to a version with the fix.

And then it immediately lowers the temperature. "However, this is not an emergency." The reasoning given is this: based on available telemetry, the rate at which this problem occurs in the field appears to be below the expected rate of SSD malfunctions or cosmic-ray bit flips. So, it writes, even if you are running an unpatched version, you are unlikely to ever encounter this problem unless you are doing something quite unusual.

It's worth reading that comparison correctly. "Below the SSD failure rate" is not "zero." It means this risk is buried in the noise of a hardware-failure probability you are already accepting. If you already have backups and integrity checks in place to guard against SSD failure or bit flips, this bug falls inside that same safety net. If you don't have those safeguards, this bug isn't what you should be worrying about — your SSD is.

And there's a caveat worth leaving in. The documentation qualifies this with "based on available telemetry." What telemetry, and what sample size, is not disclosed. And as we saw above, because the cause and the symptom of this corruption are separated in time, there is a structural possibility that even when it did occur, it was never attributed to "an SQLite bug" and instead got filed under "corruption of unknown cause" or "disk problem." That means this is the kind of estimate that could plausibly be biased toward optimism. That's not a claim that the SQLite team is being dishonest — it's a statement that observing bugs like this one is inherently hard.

Two Messy Weeks — The Withdrawal of 3.52.0

Here's where the story gets a little strange. And this part is easy to misread, so let's be precise about it.

The timeline runs like this:

2026-01-09   3.51.2 released       (the last version with the bug still alive)
2026-03-03   Dan finds and fixes the WAL-reset bug
2026-03-06   3.52.0 released       (includes the WAL-reset fix + a CLI overhaul)
2026-03-13   3.52.0 withdrawn
             The same day, 3.51.3 released (WAL-reset fix backported only)
2026-04-09   3.53.0 released       (re-release of 3.52.0 + additional fixes)
2026-05-05   3.53.1  /  2026-06-03  3.53.2  /  2026-06-26  3.53.3

A major release shipped three days after discovery, and that release was withdrawn a week later.

Important: 3.52.0 was not withdrawn because of the WAL-reset bug. This is exactly the point where the timeline can trick you, so let's be explicit. The reason for the withdrawal was a completely different problem — some of 3.52.0's new features were not 100% compatible with earlier releases, and the feature and its related APIs had to be reworked.

Specifically, the release notes narrow the scope like this: 3.52.0 works fine and is fully backward-compatible unless your database has an expression index or an index on a VIRTUAL generated column. The problem hits when that index's expression evaluates to a floating-point value derived from text or JSONB input. If such an index exists, 3.52.0 could, in rare cases, fail to interoperate correctly with earlier releases.

For what it's worth, that same release's changelog has a separate entry noting that floating-point-to-text conversion was reimplemented, and the default rounding was changed from 15 significant digits (used by every prior version) to 17. It's notable that both entries touch the same territory, but the published documentation does not state that this change caused the compatibility problem above — so I won't assert causation here.

So how did the team respond? This is the part worth learning from. They did not say "wait for 3.53.0." Instead, they shipped a patch release, 3.51.3, on the 3.51 branch. 3.51.3 fixes only the WAL-reset bug and a handful of minor issues found since 3.51.2. No new features.

Unpacking what that choice means: the corruption fix had been bundled together with a CLI overhaul, new SQL syntax, a QRF library, and a floating-point conversion rework. And it was that bundle where the compatibility problem broke out. If 3.52.0 had been the only path forward, users would have been forced into a choice: "swallow an entire feature release with compatibility risk, just to get the corruption fix." Shipping a backport to an older, stable branch removes that forced choice — it decouples the risks, so someone who only wants the corruption fix can take only the corruption fix.

And this wasn't theoretical. The node-sqlite3 repository received an issue on the very day of the withdrawal, 2026-03-13, titled "replace the withdrawn 3.52.0 with the stable 3.51.3" — and the reporter explicitly noted they hadn't been harmed by it, they were doing preventive cleanup. Without the backport, that repository's options would have been considerably worse.

3.53.0 (2026-04-09) is, in the release notes' own words, "a re-release of 3.52.0, with improvements addressing the stale expression index issue" added. And regarding the WAL-reset bug, it notes: "if you haven't hit 3.52.0, this is the first major release carrying this fix," and recommends upgrading.

One more footnote worth adding. The 3.52.0 release notes also included a line saying the team is trying to slow SQLite's release cadence to roughly once every six months — and that this is a goal, not a promise. That the release carrying that sentence got withdrawn a week later is the kind of irony you can't help but sympathize with.

What You Should Actually Do

Boiled down, the action list is short.

1. Check your version. Verify the SQLite version your application is actually linked against — not the version your package manager reports, but the version your runtime reports.

SELECT sqlite_version();

2. Your baseline is 3.51.3 (2026-03-13) or later. Every version at or above 3.7.0 and below that has this bug. If you want to move to a major release, that's 3.53.0 or later; as of this writing (2026-07-16), the latest is 3.53.3 (2026-06-26).

3. If you can't upgrade, backports exist. SQLite's documentation provides backports for a couple of older releases — 3.44.6 and 3.50.7. Note, however, that these ship as Fossil check-ins rather than official release tarballs.

4. And here's the real trap — upgrading once is not the end of the story.

For this last point, let's walk through a real case. OpenAI's codex repository merged a PR in June 2026, titled "pin bundled SQLite to a version with the WAL-reset fix," on 2026-06-14. According to the PR description, here's how the incident happened:

SQLx 0.9 was allowing a wide version range for libsqlite3-sys. As a result, a single lockfile update completely unrelated to this issue rolled libsqlite3-sys back from 0.37.0 to 0.35.0, which silently downgraded the bundled SQLite runtime from 3.51.3 to 3.50.2. In other words, a project that had already reached a fixed version was pushed back onto a vulnerable one by an unrelated dependency cleanup.

This is the price of SQLite being a library. SQLite Is Everywhere covered the upsides of the serverless design; here's the flip side of that coin. Having no server also means there is no central point to patch. With PostgreSQL, upgrade one server and every client is fixed. With SQLite, every binary that links that code has to be fixed individually, and the version is usually not something you chose directly — it's determined transitively, somewhere in your dependency tree. And transitive dependencies move while you're not looking.

Practically speaking: if your project bundles SQLite, pin the version with both an upper and lower bound, and actually assert the result of SELECT sqlite_version() in CI. Don't count on a human catching it by eyeballing a lockfile diff — the codex case is exactly that failure mode playing out.

What This Incident Leaves Behind

A few things are worth pulling out.

First, fifteen years is not proof that code is correct. This bug lived for 15 years inside one of the most heavily tested code paths in SQLite's history, in a project with arguably the most paranoid test suite in the industry. The reason is simple — tests never controlled the scheduler, and this bug lived entirely in the scheduler. 100% coverage does not mean 100% interleaving.

Second, invariants attach to locks, not to fields. This whole incident compresses into one sentence: "the path that increments nBackfill and the path that zeroes it took different locks." Each path, on its own, held its own lock correctly. Each was individually right. What was wrong was that the question "which lock guards this field" had two answers. When you review shared state in your own code, the question to ask isn't "did we take a lock" — it's "does every path that touches this state take the same lock."

Third, this is a model of good disclosure. Look at what the SQLite team did. They built a dedicated documentation section, stated the affected version range precisely, published the six-step mechanism, admitted they couldn't reproduce it, wrote down that they had to hack the source to reproduce it at all, disclosed a rate estimate along with its basis, dialed the temperature down explicitly with "this is not an emergency," and added their own bug as an entry in the corruption-cause list of How To Corrupt An SQLite Database File. That last part is especially good — that document exists mainly to explain "corruption is usually your fault," and they wrote "or possibly ours" into it themselves.

Fourth, don't bundle your risks together. The lesson of 3.52.0 isn't the compatibility bug itself — it's the structure where a corruption fix was bundled with a feature release. The ability to ship a backport to an old branch is not a luxury of release engineering; it's safety equipment.

Closing

The honest conclusion is this: you very likely never hit the WAL-reset bug, and SQLite's own documentation states its occurrence rate is buried in the noise of SSD failure rates. This post wasn't written to alarm you.

But the fix is simple — upgrade to 3.51.3 or later, and assert in CI that the version stays there. It costs almost nothing, and it's exactly what the documentation recommends.

And I think there's a better way to read this whole incident. This isn't a story about SQLite's reliability being overrated. If anything, it's the opposite — a developer found, by reading the code, a race that nobody in 15 years of real-world use had ever triggered organically; embedded a test hook to force it into existence; fixed it in three days; when the release got tangled, shipped a backport to an old branch; and disclosed the entire process in public. "Reliable" doesn't mean bug-free. It means this is how you handle one.

References