Zenn
Open12

duckdbのextensionづくりメモ

ktz_aliasktz_alias

どうやって実装し始めるのか1㍉も分からないので、参考になりそうなことを記録していくスレ

すべての開始地点はこいつをクローンすることから

https://github.com/duckdb/extension-template

c-apiを使って実装する場合は、こっちっぽい

https://github.com/duckdb/extension-template-c

  • extension名の設定
    • LOAD/INSTALLコマンドのことを考えると、名前にハイフンは含めないほうがよさそう
python3 ./scripts/bootstrap-template.py <extension名>
  • 該当バージョンのduckdbをチェックアウト(cd duckdb && git checkout <VERSION>)
    • バージョンタグをメタデータとしてextensionに埋め込む
    • extensionをロードさせる際に、埋め込まれたバージョンタグを検証
      • 古い場合はロードできない

ローカルでの確認

  1. duckdb_cli -unsignedで起動
  2. LOAD /path/to/build/release/extension/<EXTENSION_NAME>/<EXTENSION_NAME>.duckdb_extension`
    • デバッグビルドなら、/path/to/build/debug/〜
    • インストールとロードを一括で行ってくれる

duckdb-wasmでのローカルでの確認

署名チェックのバイパス

署名をしていない機能拡張を扱うため、署名チェックをバイパスする必要がある。
通常ならSET allow_unsigned_extensions = trueを発行する必要があるが、duckdb-wasmの場合は接続後に変更しちゃダメと怒られる。

かわりにDuckDBConfigを介して指定する方法が提供されている。

https://shell.duckdb.org/docs/interfaces/index.DuckDBConfig.html

署名チェックをバイパス手順

  1. duckdb-wasmをいつも通りインスタンス化する
  2. AsyncDuckDB.open関数にDuckDBConfig.allowUnsignedExtensions = trueで実行する

機能拡張のロード

機能拡張はhttpでアクセスできるパスでなければならない。
パス解決のため、SET custom_extension_repository = <レポジトリのURL>で変更する

  • extension_directoryでいけるかは未確認

viteでデバッグ起動した手元の環境(duckdb-wasm 1.29.0)では、以下のパスからのロードを求められた。

http://localhost:5173/v1.1.1/wasm_eh/<EXTENSION_NAME>.duckdb_extension.wasm

なので、public/v1.1.1/wasm_eh/<EXTENSION_NAME>.duckdb_extension.wasmに機能拡張を配置した。

加えて他の機能拡張に依存する場合は、そっちを先にロードしておかないと、シンボルエラーで死ぬ。

Error: IO Error: Extension "http://localhost:5173/v1.1.1/wasm_eh/extract_ast.duckdb_extension.wasm" could not be loaded: Could not load dynamic lib: extract_ast
Error: bad export type for '_ZTVN6duckdb14JsonSerializerE': undefined

実行例

const db: AsyncDuckDB = ...
await db.open({allowUnsignedExtensions: true})
const conn = await db.connect()

// json機能拡張に依存している場合
await conn.query(`LOAD json`)
// レポジトリの変更
await conn.query(`SET custom_extension_repository = '${import.meta.resolve!('/').slice(0, -1)}'`)
// 自身の機能拡張の有効化
await conn.query(`INSTALL some_extension; LOAD some_extension`)
ktz_aliasktz_alias

duckdb-wasm用のextensionをビルドするための構成例

https://github.com/carlopi/quackMcWasm/blob/main/.github/workflows/WasmToGithubActions.yml

  1. duckdb-wamのレポジトリをclone (全てのサブモジュール含めて)

  2. submodules/duckdb/extensionフォルダに、extensionのレポジトリをclone

  3. duckdb-waqsm本体のビルド

    • WASM_LOADABLE_EXTENSIONS=1 GEN=ninja ./scripts/wasm_build_lib.sh relsize eh
      • BUILD_TYPEはdev, debug, relsize, relperfの4つ
      • PLATFORM_TYPE
        • mvp: minimum viable product
        • eh: exception handling
        • coi: cross origin isolation
    • extensionは共有ライブラリとしてビルドされる
  4. community extensionのビルド

    • emcc build/relsize/eh/third_party/duckdb/src/duckdb_ep-build/extension/${{ github.event.repository.name }}/${{ env.extension-name }}.duckdb_extension -o ../${{ env.extension-name }}.extension.wasm -sSIDE_MODULE

    • ここは今と変わってる可能性あり

    • 静的ライブラリをwasmに変換する呼び出し

      • 現在の呼び出しの際の引数が少し変わっているので注意が必要
emcc $<TARGET_FILE:${TARGET_NAME}> \
    -o $<TARGET_FILE:${TARGET_NAME}>.wasm \
    -O3 -sSIDE_MODULE=2 \
    -sEXPORTED_FUNCTIONS="${EXPORTED_FUNCTIONS}" \
    ${WASM_THREAD_FLAGS} \
    ${DUCKDB_EXTENSION_${EXTENSION_NAME_UPPERCASE}_LINKED_LIBS}
  • TARGET_NAMEは同じ
  • EXPORTED_FUNCTIONSは上記の通り
  • WASM_THREAD_FLAGSPLATFORM_TYPE = coiの場合に指定する
    • -pthread -sSHARED_MEMORY=1
  • XXXX_LINKED_LIBSは追加でリンクする静的ライブラリか?
    • 現状そこまで指定しているextension見つからない
ktz_aliasktz_alias

extensionのエントリーポイント

duckdbのエンジンが、extensionから<EXTENSION名>_init関数を呼び出してインストールする。

extern "C" {
    // xxxxをextension名と一致させる
    DUCKDB_EXTENSION_API void xxxx_init(duckdb::DatabaseInstance &db) {
        // (snip)
    }
}

あとどこで呼ばれているか分からないが、<EXTENSION名>_versionも必要っぽい

DUCKDB_EXTENSION_API const char *xxxx_version() {
    // (snip)
}

cppのABIでextensionを作成する場合、xxxx_initと共にwasmからエクスポートさせる
CABIの場合、xxxx_init_c_apiのみをエクスポートさせるだけで十分のため不要っぽい。

(CMakeLists.txtより)

   if (${ABI_TYPE} STREQUAL "CPP")
     set(EXPORTED_FUNCTIONS "_${NAME}_init,_${NAME}_version")
   elseif (${ABI_TYPE} STREQUAL "C_STRUCT")
     set(EXPORTED_FUNCTIONS "_${NAME}_init_c_api")
   endif()

DUCKDB_EXTENSION_APIは関数をシンボルとしてエクスポートさせるおまじない。

extensionLOAD/INSTALLextension_helper.hppExtensionHelperクラスが担っている。
ExtensionHelper::InstallExtension関数の中で、動的にエントリポイントを解決している。

ktz_aliasktz_alias

拡張ポイント

duckdb::ParserExtension

duckdb/parser/parser_extension.hpp
パースできない構文にぶつかった時に移譲される。

メンバ

  • parse_function_t parse_function
    • typedef ParserExtensionParseResult (*parse_function_t)(ParserExtensionInfo *info, const string &query)
    • クエリを受け取りパース結果を返す関数を渡す
  • plan_function_t plan_function
    • typedef ParserExtensionPlanResult (*plan_function_t)(ParserExtensionInfo *info, ClientContext &context, unique_ptr<ParserExtensionParseData> parse_data)
    • extensionで表関数を提供したい場合に、その論理プランの構築を行う関数を渡す
    • 表関数の追加はReplacement scanで事足りるのかも
      • https://duckdb.org/docs/api/c/replacement_scans
      • Replacement scanSELECT * FROM 'a.csv'SELECT * FROM rewad_csv_auto('a.csv')として実行するような目的のもの
      • 新たに表関数を追加する場合に使用するものではない
  • shared_ptr<ParserExtensionInfo> parser_info
    • plan_functionに渡す追加情報をここに定義する。
    • 一種のユーザデータ

duckdb::OperatorExtension

duckdb/planner/operator_extension.hpp

  • 構文木から論理プランを構築する過程で必要に応じて移譲
  • duckdb::Planner::CreatePlanで失敗した際に移譲される
    • planner/planner.cpp#L62

メンバ

  • bind_function_t Bind
    • typedef BoundStatement (*bind_function_t)(ClientContext &context, Binder &binder, OperatorExtensionInfo *info, SQLStatement &statement);
  • shared_ptr<OperatorExtensionInfo> operator_info

Bind関数にパース結果と追加情報のinfoを渡して、duckdb::BoundStatementを作成させる関数を渡す。

  • GetNameメソッド
  • Deserializeメソッド

上記の関数が純粋仮装関数として宣言されている。

duckdb::OptimizerExtension

duckdb/optimizer/optimizer_extension.hpp
論理プランの最適化の過程で必要に応じて移譲

メンバ

  • optimize_function_t optimize_function

  • shared_ptr<OptimizerExtensionInfo> optimizer_info

  • 最適化は、PreparedStatementを作成する際、構築された論理プランの後処理として行われる。

    • client_context.cpp#L354
  • optimize_functionに論理プランと追加情報のoptimizer_infoを渡して、論理プランを改変する関数を渡す

    • 戻り値はなく、論理プランをダイレクトに改変する

duckdb::StorageExtension

duckdb/storage/storage_extension.hpp
外部データベースのアタッチとかに関与してるっぽい。

メンバ

  • attach_function_t attach
    • typedef unique_ptr<Catalog> (*attach_function_t)(StorageExtensionInfo *storage_info, ClientContext &context, AttachedDatabase &db, const string &name, AttachInfo &info, AccessMode access_mode)
  • create_transaction_manager_t create_transaction_manager;
    • typedef unique_ptr<TransactionManager> (*create_transaction_manager_t)(StorageExtensionInfo *storage_info, AttachedDatabase &db, Catalog &catalog)
  • shared_ptr<StorageExtensionInfo> storage_info;

attached_database.cppAttachedDatabase::AttachedDatabaseメソッドから呼ばれる。

まずattachを呼び、次いでcreate_transaction_manager追加情報のstorage_infoなどを渡してトランザクションマネージャを構築する。

拡張ポイントの追加

duckdb::DBConfigに以下のメンバフィールドが用意されている。

  • vector<ParserExtension> parser_extensions
  • vector<OptimizerExtension> optimizer_extensions
  • vector<unique_ptr<OperatorExtension>> operator_extensions
  • case_insensitive_map_t<duckdb::unique_ptr<StorageExtension>> storage_extensions

データベースの初期化時に、突っ込むときめ細やかな制御が可能になるのかな?

スカラ関数の追加

論理プラン構築の際、関数名と引数の型で、関数カタログを引く。
そのため、カタログに追加しておけば、多分事足りる。

ktz_aliasktz_alias

duckcb-wasm用のextensionduckdb/extension-ci-tools見るのがよさそう。

https://github.com/duckdb/extension-ci-tools

以下の入力を指定した上で

# Inputs
EXT_NAME          : Upper case string describing the name of the out-of-tree extension
EXT_CONFIG        : Path to the extension config file specifying how to build the extension
EXT_FLAGS         : Extra CMake flags to pass to the build
EXT_RELEASE_FLAGS : Extra CMake flags to pass to the release build
EXT_DEBUG_FLAGS   : Extra CMake flags to pass to the debug build
SKIP_TESTS        : Replaces all test targets with a NOP step

make <PLATFORM>でビルドしてるように見える。
例えばwasm-ehでビルドするのなら

make wasm_eh
  • EXT_NAMEEXT_CONFIGextention-templateで指定済み
  • 他のオプションは未使用

なのでオプションの指定は不要そう。

ktz_aliasktz_alias

サンプルのビルド

extension-templateとして用意されたサンプルのビルドの注意点

  • cmakeはversion 3.30以降が必要

homebrewemscriptenをインストールした場合、パスが解決できずエラーになる。
以下の環境変数を設定すると先に進む。

  • export EMSDK=$(brew -prefix emscripten)
  • export EMSCRIPTEN_ROOT=$EMSDK/libexec

extension-ci-tools/makefiles/duckdb_extension.MakefileのVCPKG_EMSDK_FLAGSのパスがhomebrewでインストールしたemscriptenとは合致しない。
以下のように変更した。

# 変更前
# VCPKG_EMSDK_FLAGS=-DVCPKG_CHAINLOAD_TOOLCHAIN_FILE=$(EMSDK)/upstream/emscripten/cmake/Modules/Platform/Emscripten.cmake
# 変更後
VCPKG_EMSDK_FLAGS=-DVCPKG_CHAINLOAD_TOOLCHAIN_FILE=$(EMSDK)/libexec/cmake/Modules/Platform/Emscripten.cmake

サンプルはOpenSSLを使用しているため、READMEに従ってvcpkgを導入し、

vcpkg add port openssl

でプロジェクトに追加した。
明示的にopensslを追加する必要があるかどうかはよくわかってない。
vcpkg.jsonへの追加は必須。installまでは不要。

ktz_aliasktz_alias

ビルドしたextensionの確認

READMEにも書かれているように、release/duckdbextensionが組み込み済みでビルドされる。
READMEではmakeでのビルドを指示しているが、全ターゲットのビルドでクッソ時間がかかるのでmake releaseだけにしておくのが得策だと思う。

  • bootstrap-template.pyextension名変更すると、関数名も合わせて変更される。
    • select quack('Jane')が動かなくて焦った。
ktz_aliasktz_alias

表関数の追加

TableFunction

duckdb::TableFunction型をインスタンス化し、duckdb::ExtractAstFunctionでラップして、カタログに追加する流れ。

TableFunctionのコンストラクタは以下の引数を受け取る。

  • name (必須)
    • 関数名
  • arguments (必須)
    • 引数の型
  • function (必須)
    • 実際に実行する関数を渡す。
    • 引数の型によるオーバーロードを実現したい場合は、nullptrを渡し、AddFunctionメソッドで後で追加する。
  • bind (オプショナル)
    • 構文木の要素から論理プランを作成する際に呼ばれる関数を渡す。
      • 論理プランを作ることがPreparedStatementの最大の目的なので、nullptrを渡すケースってなんなんだろう?
    • return_types引数に戻り値の各列の型を追記して型解決
    • names引数に戻り値の列名を追記
    • 引数などの文脈的な情報は、duckdb::TableFunctionDataのサブクラスを用意し値を詰めて返す
  • init_global (オプショナル)
    • 関数を実行前の初期化フェーズで呼ばれる関数を渡す。
    • マルチスレッドで関数を実行する場合、スレッド間で共有すべき情報をここで初期化する
      • オプショナル引数とか
    • 共有すべき情報が特になければnullptrを渡す。
  • init_local (オプショナル)
    • 関数を実行前の初期化フェーズで呼ばれる関数を渡す。
    • 各スレッドごとの状態はここで初期化する。
    • シングルスレッドで回すのであれば、nullptrを渡す。

TableFunctionData

duckdb::FunctionDataのサブクラスで、実行する関数のバインド情報を保持する目的の型。
この型のライフサイクルはPreparedStatementのライフサイクルと一致する。

GlobalTableFunctionState

実行器(duckdb::Executor)の最初で初期化され、実行終了後破棄される。

Scanner

function引数として渡した関数が呼び出される際に、入力として

  • duckdb::FunctionData
  • duckdb::GlobalTableFunctionState
  • duckdb::LocalTableFunctionState

が渡される。
引数は、このいずれかで保持させておかなければならない(たいていFunctionData)。

Scan結果はoutput引数に突っ込む。
合わせて、output.Setcardinarityメソッドも呼んで、投入件数を更新する。

  • これしないと、0件扱いにされる。
ktz_aliasktz_alias

表関数まわりの実行器

  • TableFunction::functionにコールバックが割り当てられている場合、第一に選択される。
  • TableFunction::function未割り当てで、TableFunction::in_out_functionにコールバックが割り当てられている場合、そっちが選択される
    • 加えて、TableFunction::in_out_function_finalも割り当てられている場合
      1. TableFunction::in_out_function_finalを実行
      2. TableFunction::in_out_functionを実行
      3. 1, 2で件数0なら、再度TableFunction::in_out_function_finalを実行
  • Scanner関数はデータが消化し切ったと認識されるまで何度も呼ばれる
    • output引数が0件のまま呼び出しを抜ければ、終了と判断してくれる
      • そのために進捗を GlobalTableFunctionStateで管理する必要がある
作成者以外のコメントは許可されていません