TL;DR

  • ClickHouse의 네 가지 Postgres 확장 프로그램은 Postgres의 MemoryContext와 오류 처리 방식이 C++의 예외·소멸자 모델과 충돌하므로, 각각 C++ 제거·경계 격리·별도 프로세스 분리·예외 처리를 적용함.
  • palloc/pfree와 MemoryContext는 트랜잭션이나 집계 종료 시 메모리를 해제하고 콜백으로 정리 작업을 수행하는 Postgres 방식임.
  • Postgres의 setjmp/longjmp 기반 오류 처리는 C++ 소멸자를 건너뛰며, 비자명한 자동 객체의 소멸자를 건너뛰는 동작은 메모리 누수만이 아니라 정의되지 않은 동작임.
  • pg_clickhouse는 C++를 완전히 제거하고 clickhouse-c와 pg-clickhouse-c를 사용하며, pg_re2는 C++ 코드를 별도 래퍼에 한정함.
  • pg_chdb는 C++ 런타임을 헬퍼 프로세스로 분리하고, pg_stat_ch는 예외를 FATAL로 처리하지만 이는 일반적인 크래시 격리 보장이 아니며 장기적으로 헬퍼 프로세스 분리를 검토함.

Postgres 확장 프로그램에서 C와 C++가 충돌하는 이유

  • ClickHouse에서 개발한 Postgres 확장 프로그램은 pg_clickhouse(ClickHouse 외부 데이터 래퍼), pg_re2(ClickHouse와 같은 정규식 엔진인 RE2 통합), pg_chdb(COPY용 ClickHouse 객체 스토리지 기능), pg_stat_ch(메트릭과 로그 전송)임.
  • 이 확장 프로그램에는 C++를 Postgres 확장 프로그램에 도입하는 작업이 포함됨. Postgres는 C를 사용하지만, 두 언어의 메모리 관리와 오류 처리 방식은 서로 잘 맞지 않음.
  • Postgres는 malloc/free 대신 palloc/pfree를 사용하며, 할당은 MemoryContext에 연결됨. MemoryContext는 트랜잭션이나 집계 등이 끝날 때 할당 메모리를 한꺼번에 해제하는 아레나 할당자처럼 동작하고, 소멸자 역할을 하는 콜백도 지원함.
  • 메모리 할당 실패 시 Postgres는 오류를 발생시키며, setjmp/longjmp를 기반으로 한 PG_TRY/PG_CATCH/PG_FINALLY 매크로로 처리함. PG_FINALLY는 RAII와 유사한 정리 로직을 구성하는 또 다른 방법임.
  • C++는 보통 생성자와 소멸자를 호출하는 new/delete를 사용하고, 할당 실패 시 std::bad_alloc을 발생시킴. setjmp/longjmp는 C++ 정리 작업을 건너뛰며, 비자명한 소멸자를 가진 자동 객체를 건너뛰는 것은 단순한 메모리 누수가 아니라 정의되지 않은 동작임.
  • PG_FINALLY와 MemoryContext 콜백도 이런 점프를 안전하게 만들지 못하며, C++ 예외는 PG_CATCH/PG_FINALLY를 우회함. 처리되지 않은 예외는 프로세스를 중단시키고, 백그라운드 워커에서도 공유 메모리 손상 우려로 Postgres의 포스트마스터가 전체 클러스터를 재시작하게 만들 수 있음.

`pg_clickhouse`

  • pg_clickhouse에서는 C++ 사용을 완전히 없앰. clickhouse-cpp를 새 C 라이브러리인 clickhouse-c로 대체한 뒤, Postgres 관련 로직을 통합하면서 clickhouse-c를 범용 라이브러리로 유지하기 위해 pg-clickhouse-c로 한 겹 더 감쌈.
  • clickhouse-c는 전송 방식에 종속되지 않도록 설계되어, Native 프로토콜 외부에서도 Native 형식을 지원함. 이에 따라 HTTP 드라이버는 HTTP를 통한 Native 형식을 사용해 바이너리 드라이버와 디코딩·인코딩 로직 상당 부분을 공유함.
  • HTTP에는 Postgres 환경과 함께 사용하기에 충분히 낮은 수준의 인터페이스를 제공하는 libcurl을 사용함.
  • 이전 구현은 ClickHouse 문자열을 std::string에 담은 뒤 cstring_to_text_with_len으로 Postgres 텍스트 값으로 변환했음. 이 변환 함수는 palloc을 사용하므로, 할당 실패 시 Postgres가 오류 처리기로 점프하면서 std::string의 소멸자를 건너뜀.
  • 주변의 C++ try/catch는 Postgres의 점프를 잡을 수 없으므로, 이 경로에서 C++ 객체를 사용하는 방식은 안전하지 않음.

`pg_re2`

  • pg_re2에서는 C++ 코드를 re2_wrapper.cpp에 한정함.
  • C++ 할당은 모두 C++ try/catch 내부에서 이뤄지며, 해당 코드는 Postgres C 코드로 호출을 넘기지 않음. 이 관심사 분리가 두 메모리·오류 처리 체계 사이에 명확한 경계를 만듦.

`pg_chdb`

  • pg_chdb는 Native 블록 디코딩·인코딩에 pg-clickhouse-c를 재사용함.
  • 처음에는 백그라운드 워커 안에서 libchdb를 로드했지만, 이후 libchdb를 별도의 헬퍼 실행 파일로 옮김. 핵심은 C++ 런타임, 스레드, 메모리 할당, 실패를 Postgres 백엔드나 관리 대상 백그라운드 워커 바깥에 두는 것임.
  • 백엔드가 헬퍼를 포크하고 실행하므로 헬퍼는 포스트마스터의 자손 프로세스이지만, 포스트마스터가 백그라운드 워커로 관리하지는 않음.
  • 헬퍼에는 ClickHouse 쿼리를 실행하고 Native 블록을 교환하는 데 필요한 정보만 전달되므로 Postgres 헤더가 필요하지 않음. 따라서 libchdb 충돌은 호출 중인 작업을 실패시킬 수 있지만, 그 자체로 Postgres 클러스터의 크래시 복구를 촉발하지 않음.

`pg_stat_ch`

  • pg_stat_ch는 OpenTelemetry 라이브러리와 Arrow의 C++ 바인딩에 의존함. nanoarrow에는 IPC 기능이 다수 부족하기 때문임.
  • C++ 코드는 Postgres 백그라운드 워커에 한정하고, 해당 워커에서 C++ 예외 처리와 Postgres 예외 처리를 함께 적용함.
  • 종료 처리기(terminate handler)는 처리되지 않은 예외와 std::terminate를 기본 동작인 abort()/SIGABRT 대신 ereport(FATAL)로 전달함. FATAL은 일반적으로 Postgres 프로세스 종료 정리를 실행하고 상태 코드 1로 종료함.
  • 공유 메모리 분리도 정상적으로 완료되면 포스트마스터는 이를 크래시가 아닌 종료로 받아들이며, bgw_restart_time에 따라 워커를 재시작함. 반면 비정상적인 워커 종료는 클러스터 전체의 크래시 복구를 촉발하고 다른 세션의 연결을 끊을 수 있음.
  • 이는 완화책이지 일반적인 크래시 격리 보장이 아님. FATAL은 일관성이 깨진 공유 메모리를 복구할 수 없으며, 정리 실패 시 포스트마스터가 종료를 크래시로 판단할 수 있음.
  • 프로세스 전체 종료 처리기가 라이브러리 스레드에서 호출될 가능성도 검토해야 함. 이 처리기는 임의의 스레드에서 Postgres 오류 처리와 종료 콜백을 안전하게 호출할 수 있게 만들지 않음.
  • 이 글을 작성하는 과정에서 구체적인 소유권 문제가 드러났으며, 이는 PR #126에서 해결함. 중단된 큐 제거 작업이 슬롯에 이미 해제된 오류 텍스트나 이미 반환된 쿼리 텍스트를 가리키게 둘 수 있었음.
  • 복구 과정은 꼬리 위치가 이동하기 전에 해당 슬롯을 재시도할 수 있어, 같은 참조를 두 번 해제하거나 반환할 가능성이 있었음.
  • 장기적으로는 pg_chdb처럼 C++ 의존성을 헬퍼 실행 파일로 옮겨 pg_stat_ch를 더 견고하게 만들 계획임. PR #129는 첫 단계로, Postgres 의존성이 없는 src/exporter에 C++를 격리하고 bgworker.c가 C/C++ 경계를 처리하도록 함.