검색/테스트를 통과하고 크래시에도 버티는 데이터베이스 인덱스 확장 만들기

테스트를 통과하고 크래시에도 버티는 데이터베이스 인덱스 확장 만들기

AI가 데이터베이스 인덱스 확장을 만들거나 고칠 때 사용합니다. 모든 테스트 실패를 진짜로 다루고, 커밋 전에 정해진 순서를 지키며, 크래시 복구와 스탠바이가 제대로 동작하도록 모든 페이지 변경을 로그에 남깁니다.

이 AI 규칙을 사용하려면
## 다시 설치하면 서버를 재시작하고, 먼저 스토리지 사양서 읽기 당신은 데이터베이스 자체의 릴레이션 파일 내부에 계층화된 디스크 스토리지를 기반으로 인덱스 접근 방법(access method)을 구현하는 데이터베이스 확장 기능에 기여한다. 이 작업은 내구성(durability), 복구, 동시성과 밀접하게 맞닿아 있으므로, 모든 판단에서 전달 속도보다 정확성이 우선한다. 항상 지켜야 할 운영 전제: - 확장 기능은 서버 시작 시점에 로드된다. 확장 기능을 생성하기 전에 이를 서버의 사전 로드(preload) 설정에 추가하고 서버를 재시작해야 하며, 개발 중 바이너리를 재설치한 후에도 추가 재시작이 필요하다. 오래된(stale) 로드 바이너리는 오해를 부르는 테스트 결과를 만들어낸다. 방금 빌드한 소스와 동작이 모순될 때는 먼저 재시작 누락을 의심할 것. - 프로젝트가 허용하는 한, 확장 기능의 객체는 기본 public 네임스페이스가 아니라 전용 스키마에 설치하여 네임스페이스를 깔끔하게 유지한다. - 문제를 완전히 해결하는 가장 작은 변경을 우선한다. 작업이 요구하지 않은 인접 코드는 리팩터링하지 않는다. - 저장소의 설계 명세는 구속력을 가진다. 쓰기, 읽기, 스필(spill) 경로를 건드리기 전에 스토리지 명세를 읽고, 설계 자체가 바뀔 때는 같은 변경 안에서 명세도 함께 갱신한다. ## 테스트 실패를 무관하다고 넘기지 않고 조사해서 고치기 메인 브랜치는 항상 그린(green) 상태를 유지한다. 모든 테스트 실패는 중요하다. - 실패를 "기존부터 있던 문제" 또는 "무관한 문제"로 치부하지 않는다. 실제로는 거의 그런 경우가 없다. - 어떤 실패가 자신의 변경과 정말 무관하다고 생각되면 이를 검증한다: 메인 브랜치를 체크아웃해서 같은 테스트를 그곳에서 실행해 본다. - 메인 브랜치에서도 재현된다는 사실이 실패를 정당화하지는 않는다. 여전히 조사하고 수정해야 하며, 그냥 넘기거나 "이미 알려진 문제"로 주석 처리해서는 안 된다. - 실패한 실행을 그린으로 바꾸기 위한 목적만으로 테스트를 건너뛰기(skip)로 표시하거나, 어서션을 완화하거나, 기대 출력 파일을 수정하지 않는다. 기대값은 새로운 출력이 왜 옳은지 정확히 설명할 수 있을 때만 변경한다. - 자신이 유발한 실패를 포함해, 실패는 정직하고 신속하게 보고한다. 실패한 실행을 숨기는 것은 버그 자체보다 나쁘다. ## 커밋할 때마다 회귀 테스트를 돌리고 차이 파일까지 보기 변경 사항을 커밋하기 전에 아래 각 단계를 모두 완료한다. "사소해 보인다"는 이유로 순서를 바꾸거나 단계를 생략하지 않는다. 1. 확장 기능을 컴파일한다. 2. 설치된 빌드에 대해 선언형 회귀 테스트 스위트를 실행한다. 3. 통과/실패 요약뿐 아니라 실행 결과로 생성된 회귀 차이(diff) 파일을 직접 확인한다. 스위트는 자신이 만든 차이를 감춘 채로 성공을 보고할 수 있다. 4. 사용자에게 보이는 메시지를 수정했다면 그에 맞춰 대응하는 기대 출력 파일을 갱신하고, 왜 메시지가 바뀌었는지 커밋에 명시한다. 5. 포맷팅 검사를 실행한다. 6. 기대값 파일을 갱신한 후에는 회귀 테스트 스위트를 다시 실행하여, 최종적으로 기록되는 실행 결과가 실제 커밋되는 트리를 반영하도록 한다. 정규 스위트에 더해, 변경이 잠금(locking), 쓰기 경로, 복구, 세그먼트 라이프사이클을 건드릴 때마다 동시성, 크래시 복구, 다중 백엔드, 스트레스 계층의 테스트를 실행한다. 어떤 계층을 실행했고 어떤 계층을 실행하지 않았는지 명확히 말한다. ## 각 변경을 문제를 해결하는 가장 낮은 코드 계층에 두기 소스는 계층으로 조직되며, 모든 변경은 그 문제를 완전히 해결할 수 있는 가장 낮은 계층에 속해야 한다. 스토리지 세부 사항을 위쪽 데이터베이스 인터페이스 계층으로 새어 나가게 하지 말고, 반대로 추상화를 건너뛰어 아래쪽으로 파고들어 수정을 지름길로 삼지도 않는다. - 1계층, 데이터베이스 인터페이스: 접근 방법 핸들러, 인덱스 빌드, 스캔, 진공(vacuum) 진입점; 사용자 정의 데이터 타입; 플래너 훅과 비용 추정. - 2계층, 인덱스 조정(coordination): 랭킹과 스코어링, top-k 최적화, 인덱스 상태 라이프사이클, 레지스트리, 메타페이지 처리, 그리고 포스팅 소스에 대한 추상화. - 3계층, 스토리지: 레벨-제로 쓰기 버퍼, 불변 세그먼트, 병합(merge), 압축. - 계층 횡단(cross-cutting): 디버그 및 덤프 유틸리티, 모듈 초기화. 변경이 여러 계층에 걸칠 때는 그 경계 교차를 변경 설명에 명시적으로 서술한다. 인터페이스를 조용히 넓히는 수정은 사실상 설계 변경이며, 그렇게 리뷰되어야 한다. ## 스토리지를 바꾸기 전에 쓰기 버퍼와 불변 세그먼트 구조 이해하기 이 인덱스는 로그 구조(log-structured)의 계층화된 스토리지 방식을 따른다. 어느 부분을 변경하기 전이든 먼저 이 모델을 이해해야 한다. - 레벨 제로는 쓰기 버퍼다: 인덱스 릴레이션 자체 안에 저장되고 메타페이지에 고정(anchor)된, 추가(append) 지향 방식의 페이지 체인이다. 쓰기는 행 식별자, 길이, 패킹된 페이로드를 담은 레코드를 테일 페이지에 추가한다. 모든 변경은 표준 버퍼 잠금 하에서 이루어지며 선행 기록 로그(write-ahead log)에 기록된다. - 세그먼트는 불변의 블록 구조 디스크 구조체로, 스킵 엔트리를 포함하여 스캔이 모든 포스팅을 순회하지 않고도 특정 블록으로 바로 탐색할 수 있게 한다. - 쓰기 버퍼에서 새 세그먼트로의 스필(spill)은 임계값에 의해 트리거되며, 한 레벨에 세그먼트가 충분히 쌓이면 레벨 단위로 압축(compact)된다. - top-k 쿼리는 블록 단위 상한값을 사용하여 결과에 기여할 수 없는 블록을 완전히 건너뛰며, 남은 상한값이 현재 결과 집합을 넘어설 수 없게 되면 조기 종료한다. 인메모리 캐시가 속도를 위해 쿼리 읽기를 처리할 수는 있지만, 디스크상의 내구성 있는 체인이 유일한 신뢰 원본(source of truth)으로 남으며, 읽기 전용 스탠바이는 항상 캐시가 아니라 이 내구성 체인을 읽는다. ## 모든 페이지 변경을 자체 방식이 아닌 표준 선행 기록 로그로 남기기 확장 기능 전용 커스텀 리소스 매니저를 등록하는 대신, 모든 페이지 변경은 데이터베이스의 범용 선행 기록 로깅(write-ahead logging) 기능을 통해 기록한다. 이 제약이 존재하는 이유: 범용 로깅을 사용하면, 표준 데이터베이스 리플레이 메커니즘이 크래시 복구 시와 스트리밍 스탠바이에서 쓰기 버퍼 체인 페이지, 세그먼트 페이지, 메타페이지를 포함한 모든 페이지를 재구성할 수 있다. 바로 이 덕분에 단일 페이지 재실행(redo) 헬퍼와, 확장 기능의 공유 라이브러리를 로드할 수 없는 그 밖의 모든 리플레이 컨텍스트가 올바르게 동작할 수 있다. 지켜야 할 결과: - 커스텀 리소스 매니저를 도입하는 것은 구현 세부 사항이 아니라 중대한 설계 결정이다. 이는 확장 기능을 로드할 수 없는 모든 리플레이 컨텍스트를 깨뜨린다. 무관한 변경의 일부로 암묵적으로 도입해서는 안 되며, 별도의 검토를 받는 독립된 제안으로 제기해야 한다. - 새로운 디스크 구조는 반드시 로그에 기록된 레코드만으로 표준 리플레이가 재구성할 수 있어야 한다. 확장 코드로만 재구성 가능한 구조라면 그 설계는 잘못된 것이다. - 버려도 된다고 생각되는 구조라 하더라도, 버퍼 매니저를 우회하거나 로깅된 변경 바깥에서 페이지를 쓰지 않는다. ## 병합으로 비운 페이지는 스탠바이 쿼리가 다 읽을 때까지 반환하지 않기 병합(merge)은 밀려난 원본 페이지를 즉시 여유 공간 맵(free space map)으로 반환해서는 안 된다. 이는 프라이머리에서는 안전하지만, 핫 스탠바이에서 진행 중인 읽기 쿼리에는 안전하지 않다. 커스텀 리소스 매니저가 없는 상태에서는 리플레이 중 복구 충돌을 해소할 메커니즘이 없기 때문이며, 이 경우 스탠바이가 실행 중인 쿼리가 여전히 읽고 있는 페이지를 재사용해 버릴 수 있다. 요구되는 패턴: - 밀려난 페이지는 메타페이지에서 앵커된, 병합의 트랜잭션 식별자로 스탬프가 찍힌 로깅된 툼스톤(tombstone) 체인에 보관(park)한다. - 보관된 페이지는 이후의 진공(vacuum) 또는 다음 병합이 그 스탬프가 제거 불가능한 가장 오래된 트랜잭션 지평선(horizon)보다 앞선다는 것을 확인한 뒤에만 여유 공간 맵으로 반환한다. 그 지평선이 바로 스탠바이에 안전한 회수 경계다. - 배포 환경에서 쿼리를 처리하는 핫 스탠바이로부터의 피드백이 활성화되어 있어야 한다. 그러면 스탠바이의 가장 오래된 스냅샷이 보관된 페이지를 다 읽을 때까지 프라이머리의 지평선을 뒤로 잡아둔다. - 현재 몇 개의 페이지가 보관 중인지 보고하는 진단 함수를 노출하여, 운영자가 추측하지 않고 적체(backlog) 증가를 관찰할 수 있게 한다. 이 원칙을 일반화하면: 원격 또는 지연된 리더가 여전히 보유하고 있을 수 있는 공유 자원을 어떤 컴포넌트가 해제할 때는, 그 해제를 관찰 가능한 지평선 뒤로 미루고 운영자에게 미뤄진 집합을 측정할 방법을 제공한다. ## 랭킹 점수로 거르지 말고 정렬에 쓰기 랭킹 점수는 정렬을 위한 것이지 필터링을 위한 것이 아니다. 그 수치값은 불투명하며 의미 있는 임계값을 갖지 않으므로, 테스트, 예제, 문서에서 편의상의 필터로 점수를 상수와 비교하는 것은 안티패턴이다. 규칙: - "일치하는" 행을 선택하기 위해 랭킹 점수를 숫자와 비교하는 필터 조건을 작성하지 않는다. - 랭킹 연산자를 포함한 필터 조건은 인덱스 스캔이 아니라 순차 스캔(sequential scan)을 통한 단독 스코어링을 유발한다. 인덱스 스캔에는 정렬(ordering) 절이 필요하다. - 올바른 패턴은 랭킹 표현식으로 정렬한 뒤 행 개수 제한(row limit)을 적용하는 것이다. - 일치하는 문서 수를 세려면, 점수로 필터링하는 대신 정렬된 서브쿼리로 감싸서 그 행 수를 센다. - 예외: 인덱스 스캔 결과와 단독 스코어링 결과를 비교하는 테스트처럼, 단독 스코어링 경로 자체를 명시적으로 테스트하는 목적을 가진 테스트는 필터 조건에서 이 연산자를 사용해도 된다. 그런 테스트는 나중에 읽는 사람이 실수로 이 패턴을 따라 하지 않도록 그 의도를 주석에 명시해야 한다. 일반적인 교훈: API가 하나의 의도된 형태를 가질 때는, 팀이 내놓는 모든 예제에서 잘못된 형태가 눈에 띄게 잘못되도록 만들고, 문서화된 의도가 있을 때만 예외를 둔다. ## 모든 설정을 안전한 기본값과 함께 한 표에 적기 모든 런타임 튜닝 항목은 이름, 설명, 기본값과 함께 하나의 표에 문서화한다. 문서화되지 않은 튜닝 항목은 결함으로 간주한다. - 일반적인 배포 환경에서 안전하고 동작에 중립적인 기본값을 선택한다. 임계값 성격의 설정에서는 0을 "비활성화"로, 예산(budget) 성격의 설정에서는 0을 "무제한"으로 사용하고, 이를 설명에 명시한다. - 결함을 주입하거나 강제로 실패시키는 설정은 테스트 전용으로만 존재해야 하며, 권한이 있는 사용자로 제한되어야 하고, 설명에 테스트 전용임을 표시해야 한다. - 스코어링 세부 사항, 최적화 통계, 캐시 결과에 대한 로깅 스위치는 기본값이 꺼짐(off)이다. 이는 통상적인 운영이 아니라 진단 보조 도구다. - 메모리 예산은 단일한 강한 벽이 아니라 단계별(tiered)로 구성된다: 인덱스별 소프트 상한은 해당 인덱스를 내구성 있는 폴백 경로로 저하시키고, 전역 소프트 상한은 호출자를 제외한 가장 큰 소비자를 축출(evict)하며, 전역 하드 상한은 새로운 할당을 아예 거부한다. 각 단계는 로그에서 관찰 가능해야 한다. - 텍스트 분석 설정이나 랭킹 모델의 포화(saturation) 및 길이 정규화 매개변수 같은 인덱스 수준 옵션도 동일한 엄격함으로 문서화하며, 필수 옵션은 조용한 기본값을 주는 대신 필수(required)로 표시한다. ## 죽은 문서를 표시하고, 다음 병합까지 통계가 낡는다는 점 적어 두기 진공(vacuum)은 세그먼트를 재구축하는 대신, 세그먼트별 생존 비트셋(alive bitset)에 문서당 1비트로 죽은(dead) 문서를 표시한다. 비용은 전체 코퍼스 크기가 아니라 죽은 문서 수에 비례한다. 죽은 문서는 스코어링 중에 걸러지고, 다음 세그먼트 병합 시점에 물리적으로 제거된다. 이 알려진 트레이드오프는 숨기지 말고 계속 문서화해야 한다: 문서를 죽은 것으로 표시해도 세그먼트의 전체 문서 수, 전체 길이 통계, 용어별 문서 빈도(document frequency)는 갱신되지 않는다. 따라서 랭킹은 다음 병합 또는 압축이 이를 바로잡을 때까지 약간 오래된(stale) 코퍼스 통계를 사용한다. 삭제 건수가 적으면 그 영향은 무시할 만하지만, 대량 삭제는 다음 병합 전까지 랭킹 품질에 눈에 띄는 영향을 줄 수 있다. 이를 하나의 템플릿으로 취급한다. 어떤 최적화가 정확성이나 신선도를 비용과 맞바꿀 때는, 유지보수자가 찾을 수 있는 곳에 그 트레이드오프를 기록하고, 그것이 유의미해지는 조건을 명시하며, 그것을 해소하는 이벤트의 이름을 밝힌다. 문서화되지 않은 근사(approximation)는 나중에 설명되지 않는 버그 리포트가 된다. ## 테스트 계층을 나누고 내장 진단 함수 제공하기 서로 다른 테스트 계층을 유지하고 각 계층이 무엇을 지키는지 파악한다: - 핵심 기능, 스코어링 정확성, 스토리지와 세그먼트, 쿼리 최적화 경로, 파티션 테이블·상속 테이블 같은 테이블 변형, 그리고 엣지 케이스에 대한 선언형 회귀 테스트로, 참조 출력은 버전 관리 하에 보관한다. - 다중 세션 동시성, 크래시 복구, 다중 백엔드 세그먼트 동작, 장시간 스트레스를 위한 스크립트 기반 테스트. 지속적 통합(CI)은 지원되는 모든 데이터베이스 메이저 버전, 새니타이저(sanitizer) 빌드, 포맷팅 검증, 버전 업그레이드 테스트, 릴리스 패키징, 벤치마크, 그리고 메모리 누수 탐지를 포함한 야간 스트레스 실행을 모두 다룬다. 진단 기능은 임시 출력물이 아니라 일급(first-class) 함수로 제공한다: 용어 사전, 포스팅 리스트, 문서 엔트리를 포함한 내부 구조 덤프; 전체 인덱스를 파일로 덤프; 고수준 통계 요약; 강제 스필 실행 및 이동된 엔트리 수 보고; 보관된(parked) 페이지 수 보고; 페이지 레이아웃 시각화 렌더링. 벤치마크는 재현 가능하게 유지한다: 빠른 신호 확인을 위한 소규모 고전적 정보 검색(IR) 컬렉션, 규모 검증을 위한 더 큰 공개 데이터셋, 메모리와 용량 스트레스 스크립트, 그리고 결과가 단순히 자기 일관적(self-consistent)일 뿐이 아님을 검증하기 위한 기존 외부 엔진과의 비교. ## 자동 포매터를 쓰고, push 전에 main으로 리베이스하기 - 포맷팅은 자동화되어 있다. 트리 전체를 포맷하는 명령, 수정 없이 포맷을 검사하는 명령, 차이를 보여주는 명령, 단일 파일을 포맷하는 명령을 제공하고 사용한다. 도구를 거스르며 손으로 포맷하지 않는다. - 독자적인 규칙을 새로 만들지 말고, 호스트 데이터베이스 프로젝트의 코딩 컨벤션과 메모리 할당 패턴을 따르며, 줄바꿈은 프로젝트에 설정된 폭에 맞춘다. - 동시성 불변식: 모든 공유 구조는 적절한 잠금으로 보호되며, 인터닝(interning) 구조는 해시 테이블 잠금을 통해 안전하게 만들고, 트랜잭션 격리는 커밋 경로와 어보트(abort) 경로 양쪽 모두에서 규율 있는 정리(cleanup)를 통해 유지된다. - 풀 리퀘스트가 머지된 후에는, 그것이 다른 사람에 의한 머지였더라도 항상 메인 브랜치가 이동했는지 확인한다. 충돌이 쌓이는 것을 막기 위해 활성 브랜치를 갱신된 메인 위로 페치(fetch) 및 리베이스한다. - 푸시하기 전에, 갈라진 히스토리로 인해 작은 변경이 충돌투성이 머지가 되지 않도록 브랜치가 메인과 최신 상태인지 확인한다. - 태그나 아티팩트를 즉흥적으로 만들지 말고, 저장소에 문서화된 릴리스 절차를 따른다.