インフラ・DevOps
NixにSQLiteを組み込む3つの方法
Three ways to smuggle SQLite into Nix (fzakaria.com)
要約
この記事では、Nixパッケージマネージャーで大量のデータを扱う際に、JSONファイルよりも効率的なSQLiteデータベースの組み込み方法を3つ紹介しています。`builtins.exec`、`builtins.importNative`、そしてデータをNixの式として直接扱う方法が解説されており、それぞれの利点と欠点が説明されています。
全文翻訳
nixpkgs-multiverseの核心部分は、Nix APIとCLIを取り除くと、インデックスです。これは(属性、バージョン)からそれを出荷したリビジョンへのマップをJSONファイルとして保持しています。
実際には、統計や「高速モード」などの他の機能を提供するファイルもいくつかありますが、それらもすべてJSONです。
`ls -lh index/`
`index/` の中身:
-rw-r--r--. 1 fmzakari fmzakari 7.5M Aug 19 13:57 history.json
-rw-r--r--. 1 fmzakari fmzakari 5.3M Aug 19 13:57 versions.json
9cc0209時点では、versions.jsonは5.3 MiB、history.jsonは7.5MiBで、31,904パッケージと1,534リビジョンにわたる305,492パッケージバージョンをカバーしています。
Nix APIはJSONファイルを遅延読み込みし、すべて `builtins.fromJSON` を介して読み込まれます。
`index = builtins.fromJSON (builtins.readFile ./index/versions.json);`
さらに多くの情報でデータをリッチにしたいのですが、それはコストを伴います: データが増えれば問題も増えます。プロジェクトの目標は、ダウンロードされるNixpkgsの数を最小限に抑えることです。巨大なNixpkgsの取得を巨大なJSONの取得に置き換えるだけでは、明確な勝利とは言えません。今のところ、JSONファイルに何を保存するかを慎重に選び、データを小さくコンパクトにするための巧妙なエンコーディングスキームを考える必要があります。もしNixの組み込み関数に制約されなければ、効率的にデータセットをエンコードし、複数のクエリパターンを可能にする確立された技術、つまりデータベースを活用するでしょう!JSONに制約されないとしたら、他にどのような選択肢があるでしょうか?
§1つのルックアップでファイル全体を消費する
なぜ巨大なJSONファイルは問題なのでしょうか? `builtins.fromJSON` は即時評価です。Nixには遅延JSONやストリーミングパース(つまり「このキーだけをください」)はありません。結果に触れた瞬間に、5.3 MBすべてをパースし、305,492個すべての値をNixのヒープにマテリアライズしてしまいます。multiverseの場合、1つのパッケージを要求するコストは、すべてのパッケージを要求するコストと同じです。
注: ルックアップ自体は問題ではありません。Nixの属性セットはソートされた配列なので、アクセスはバイナリサーチでありスキャンではありません。コストは完全にJSONのパースと、値の割り当て、そして巨大なファイルのダウンロードにあります。インデックスに対して代替の質問をしたい場合は、アクセスパターンによりよく一致するように、回答を効率的に保存し続ける必要があります。私たちが望むものは明白です。効率的にデータをエンコードする方法と、クエリを宣言的に定義する方法、つまりSQLiteが欲しいのです!
nixpkgs-multiverseはすでにパッケージとしてSQLiteデータベースをエクスポートしており、他の人がこのデータを探索するのに役立っています。
`sqlite3 index.db "SELECT version, rev FROM versions WHERE attr='hello'"`
`2.10|728 ... 0.01s, 4 MB`
Nixはデフォルトではこれを実行できません。残念ながら `builtins.sqlite` はありませんが、あるべきだと思います…しかし、私たちが望むものを手に入れるために触れることができるノブやパッチを当てることができるソースがあることが判明しました。ただし、それぞれに注意点があります。
😈
§1: `builtins.exec`
この組み込み関数について知らなかったことに驚きました。2017年4月のリリース1.11.9から存在しています。利用可能なものでは実現できないさまざまなユースケースのための究極のエスケープハッチです。`builtins.exec` は文字列のリストを受け取り、プログラムを実行し、その標準出力をNix式としてパースします。これは、評価中のネイティブコードを許可しない設定によって制限されており、安全でないことが明確に示されています。
`$ nix eval --option allow-unsafe-native-code-during-evaluation true \ --expr 'builtins.exec [ "/bin/sh" "-c" "echo 42" ]'`
`42`
統合のために、SQLiteはNix構文を直接出力する能力があります。Nixパーサーを介したシリアライゼーションフォーマットは不要です。SQLiteが直接属性セットを出力するためです。
`let versionsOf = attr: builtins.exec [ "${sqlite}/bin/sqlite3" "-noheader" "-separator" "" "./index.db" '' SELECT '{' || group_concat( '"' || version || '" = ' || COALESCE(CAST(rev AS TEXT), 'null') || ';', ' ') || '}' FROM versions WHERE attr = '${attr}'; '' ]; in versionsOf "hello"`
`$ nix eval --impure -f query.nix \ --option allow-unsafe-native-code-during-evaluation true`
`{ "2.10" = 728; "2.12" = 822; "2.12.1" = 1369; "2.12.2" = 1486; "2.12.3" = null; "2.7" = 0; "2.8" = 13; }`
注意点は、すべてのクエリがフォーク、実行、SQLiteのプロセスイメージ、そしてNixパーサーによる出力の再パースになることです。多くのクエリを実行する予定がない場合、そのオーバーヘッドは、統合の単純さを考えると許容範囲内でしょう。
§2: `builtins.importNative`
`builtins.exec` を調査する中で、`builtins.importNative` を発見しました。これは共有オブジェクトへのパスとシンボル名を受け取り、それをdlopenし、そのシンボルを呼び出します。これは1.8(2014年12月)に導入されました。
共有オブジェクトは以下のシグネチャを実装する必要があります。
`extern "C" typedef void (*ValueInitializer)(EvalState & state, Value & v);`
入力に対してバージョンを返す新しいネイティブ関数を定義できます。
`extern "C" void nix_sqlite_versions(EvalState & state, Value & v) { v.mkPrimOp(new PrimOp{ .name = "nix_sqlite_versions", .args = {"dbPath", "attr"}, .arity = 2, .impl = versions, }); }`
実装は通常のC++で、Nix APIを使用します。以下はその実装のスニペットで、SQLite3ハンドルをキャッシュして `builtins.exec` と同じ起動ペナルティを回避しています。
`/* 全体のポイント: データベースハンドルは単一のクエリを超えて存続するため、アクセスされたbツリーページは評価全体でウォーム状態を保ちます。*/`
`std::map<std::string, sqlite3 *> handles;`
`void versions(EvalState & state, const PosIdx pos, Value ** args, Value & v) {`
` std::string path(state.forceStringNoCtx(*args[0], pos, "..."));`
`std::string attr(state.forceStringNoCtx(*args[1], pos, "..."));`
`// コール間でキャッシュ`
`auto * db = openOnce(state, pos, path);`
`sqlite3_stmt * stmt = nullptr;`
`sqlite3_prepare_v2(db, "SELECT version, rev "`
`"FROM versions "`
`"WHERE attr = ?1", -1, &stmt, nullptr);`
`sqlite3_bind_text(stmt, 1, attr.data(), attr.size(), SQLITE_TRANSIENT);`
`/* ...行を収集... */`
`/* 属性セットを直接構築します。テキストは一切存在しません。*/`
`auto bindings = state.buildBindings(rows.size());`
`for (auto & [version, rev] : rows) {`
`auto & slot = bindings.alloc(state.symbols.create(version));`
`if (rev) slot.mkInt(*rev); else slot.mkNull();`
`}`
`v.mkAttrs(bindings);`
`}`
使用方法は以下のようになります。
`$ nix eval --impure \ --option allow-unsafe-native-code-during-evaluation true \ --expr '(builtins.importNative ./libnixsqlite.so "nix_sqlite_versions" ) "./index.db" "hello"'`
`{ "2.10" = 728; "2.12" = 822; "2.12.1" = 1369; "2.12.2" = 1486; "2.12.3" = null; "2.7" = 0; "2.8" = 13; }`
§3: 巨大なNixファイル
この記事は公開後にrickynilsのアイデアに基づいて追加されました。NixはJSONに似ているとよく言われ、JSONからNixへの変換は非常に簡単です。JSONを読む代わりに、同じ内容を.nixファイルとして読むとしたらどうでしょうか?理論的には、パーサーの境界もなく、`fromJSON` もなく、シリアライゼーションフォーマットもまったく存在しないはずです。インデックスは、評価者がすでに読み方を知っている式になります。アイデアはNixの遅延性を活用することです。Nixの属性セットの値はソート済みなので、原則として非常に大きな式をインポートし、1つの属性に触れるだけで、残りのインスタンス化にコストを支払う必要がなくなります。インデックスの変換は、Pythonで十数行で済み、JSONに非常に似たものが出力されます。
`{ revisionCount = 1534; attrs = { "2048-in-terminal" = { "2015-01-15" = 157; "2017-11-29" = 166; }; "2bwm" = { "0.2" = 166; }; "389-ds-base" = { "1.3.3.9" = 14; "1.3.5.15" = 100; "1.3.5.19" = 166; }; # ... 31,901 more }; }`
JSONの5.3 MiBに対して6.0 MiBのNixで、同じデータを含んでいます。
`$ nix eval --impure --expr '(import ./index.nix).attrs.hello'`
`{ "2.10" = 728; "2.12" = 822; "2.12.1" = 1369; "2.12.2" = 1486; "2.12.3" = null; "2.7" = 0; "2.8" = 13; }`
§4: `builtins.wasm`
Determinate Systemsは2026年3月に別のオプション、`builtins.wasm` をリリースしました。これはWebAssemblyモジュール内の関数を呼び出します。
EelcoはSCALE 23xでこれに関する講演を行いました。動機は、Nixの表面積を拡張したいが組み込み関数を増やしたくないという要望と同様でした。Wasmはサンドボックス化されており決定論的なので、上記の2つの組み込み関数とは異なり、安全なエスケープハッチを提供することを目的としています。WebAssemblyはバイナリ...