ClickHouse では、いくつかの Postgres エクステンションを開発してきました。
- pg_clickhouse (ClickHouse FDW)
- pg_re2 (ClickHouse でも使われている正規表現エンジン RE2 を Postgres に統合)
- pg_chdb (Postgres の COPY 向け ClickHouse オブジェクトストレージ機能)
- pg_stat_ch (メトリクスとログを送信)
これらはすべて、Postgres エクステンション内に C++ を組み込む必要がありました。Postgres は C 言語で書かれています。C と C++ なら問題なく共存できるはず、と思えるかもしれません。
しかし実際はそうではありません。Postgres には MemoryContext を中心に構築された独自のメモリ割り当てパターンがあります。malloc/free を使う代わりに、palloc/pfree を使用する必要があります。これらの関数はメモリ割り当てを MemoryContext に関連付けます。MemoryContext はアリーナアロケータとして機能し、トランザクションや集計などの終了時にすべてを一括で解放できます。また、MemoryContext にはデストラクタとして機能するコールバックを持たせることも可能です。Postgres はメモリ割り当ての失敗をエラーの送出によって処理し、そのために setjmp/longjmp 上に構築された一連の PG_TRY/PG_CATCH/PG_FINALLY マクロパッケージを備えています。PG_FINALLY は、RAII に似たデストラクタロジックを構築するためのもう1つの手段です。
一方、C++ コードは通常 new/delete を使用します。これはコンストラクタやデストラクタを呼び出し、割り当てに失敗した際には std::bad_alloc を送出します。両者の仕組みは相性がよくありません。setjmp/longjmp は C++ のクリーンアップ処理をスキップします。非自明なデストラクタを持つ自動オブジェクトを飛び越えてジャンプすることは、単なるメモリリークにとどまらず未定義動作になります。PG_FINALLY や MemoryContext のコールバックを使っても、このようなジャンプは安全にはなりません。また、C++ 例外は PG_CATCH/PG_FINALLY をバイパスします。さらに悪いことに、キャッチされない例外はプロセスの異常終了 (abort) を引き起こし、バックグラウンドワーカーであっても、Postgres の postmaster プロセスは共有メモリが破損した恐れがあると判断してすべてを再起動せざるを得なくなります。
これらのエクステンションはそれぞれ、メモリ安全性に関して異なるアプローチを採用しました。
pg_clickhouse
pg_clickhouse では、C++ の使用を完全に廃止しました。これには、clickhouse-cpp を完全に新しい C ライブラリである clickhouse-c に置き換える作業が含まれました。さらに最近では、clickhouse-c を汎用ライブラリとして保ちながら Postgres 向けのロジックを統合するため、別のライブラリ pg-clickhouse-c でラップしました。clickhouse-c はトランスポートに依存しないよう設計されており、Native プロトコル以外でも Native フォーマットをサポートしています。そのため現在、HTTP ドライバーは HTTP 経由で Native フォーマットを使用することで、デコード/エンコードのロジックの大半をバイナリドライバーと共有しています。HTTP 通信には libcurl を採用しており、Postgres の環境と適切に連動できる十分低レベルなインターフェースを提供しています。
例えば以前は、binary.cpp 内の make_datum で ClickHouse の String を次のように変換していました。
auto s = std::string(col->AsStrict<ColumnString>()->At(row));
ret = PointerGetDatum(cstring_to_text_with_len(s.data(), s.size()));cstring_to_text_with_len は palloc を使って Postgres の text 値を割り当てます。この割り当てが失敗すると、Postgres は ERROR を送出して自身のエラーハンドラへジャンプし、s のデストラクタをバイパスしてしまいます。これを C++ の try/catch で囲んでいても、このジャンプをキャッチすることはできません。
pg_re2
pg_re2 では、C++ の使用範囲を re2_wrapper.cpp 内のみに限定しています。C++ のすべてのメモリ割り当ては C++ の try/catch 内で行われ、Postgres の C コードを呼び出すことはありません。関心事の分離が徹底されているため、このカプセル化によって両システムの間に明確な境界線を引くことができています。
pg_chdb
pg_chdb は、ネイティブブロックのデコード/エンコードに pg-clickhouse-c を再利用しています。当初はバックグラウンドワーカー内に libchdb をロードしていましたが、最終的に libchdb を独立したヘルパー実行ファイルへと移行しました。libchdb を隔離することこそが狙いです。C++ ランタイム、スレッド、メモリ割り当て、障害のすべてが Postgres バックエンドや管理下のバックグラウンドワーカーの外に置かれます。バックエンドがこのヘルパーを fork して exec するため、postmaster の子孫プロセスであり続けますが、postmaster はこれをバックグラウンドワーカーとしては管理しません。このヘルパーは ClickHouse クエリの実行と Native ブロックのやり取りに必要な情報しか必要としないため、Postgres のヘッダーファイルも不要です。これにより、仮に libchdb がクラッシュしても、Postgres クラスターのクラッシュリカバリを誘発することなく、呼び出し元の処理を失敗させるだけで済みます。
pg_stat_ch
nanoarrow には多くの IPC 機能が不足しているため、pg_stat_ch は OpenTelemetry ライブラリと Arrow の C++ バインディング に依存しています。C++ コードは Postgres のバックグラウンドワーカー内に隔離されており、そこに C++ の例外処理と Postgres の例外処理の両方を組み込んでいます。terminate ハンドラは、キャッチされない例外や std::terminate をデフォルトの abort() / SIGABRT ではなく ereport(FATAL) 経由でルーティングします。FATAL は通常、Postgres のプロセス終了クリーンアップを実行し、ステータス 1 で終了します。共有メモリのデタッチも正常に完了すれば、postmaster はこれをクラッシュ以外の終了と見なします。その後、ワーカーの再起動は bgw_restart_time の設定に従って行われます。これとは対照的に、ワーカーの異常終了はクラスター全体のクラッシュリカバリを引き起こし、他のセッションを切断してしまう可能性があります。
これはあくまで緩和策であり、完全なクラッシュ隔離を保証するものではありません。FATAL で不整合な共有メモリを修復することはできず、クリーンアップに失敗した場合は postmaster が終了をクラッシュとして扱う可能性があります。また、ライブラリのスレッドからプロセス全体の terminate ハンドラが呼び出された場合にも注意が必要です。任意の外部スレッドから Postgres のエラーハンドリングや終了コールバックを呼び出しても安全になるわけではありません。
このブログ記事の執筆中に、具体的な所有権の問題が見つかり、PR #126 で対処されました。デキュー処理が中断されると、解放済みのエラーテキストやリリース済みのクエリテキストをスロットが参照したままになる可能性がありました。末尾 (tail) が進む前にリカバリがそのスロットを再試行すると、同一の参照を2回解放またはリリースしてしまう恐れがあります。長期的には、pg_chdb で実施したように C++ の依存関係をヘルパー実行ファイルへ分離することで、pg_stat_ch の堅牢性をさらに高める予定です。PR #129 はその第一歩であり、Postgres への依存を持たせない形で C++ を src/exporter に隔離し、bgworker.c で C/C++ の境界を処理するようにしています。
今すぐ始める
実際のデータで ClickHouse がどのように動作するか確認してみませんか?わずか数分で ClickHouse Cloud を使い始めることができ、300 ドル分の無料クレジットも進呈されます。
サインアップ


