本文へスキップ
nomunomu0504.dev

BLOG #RUST

hidane: Java なしで動く Firestore エミュレータを Rust で作った

Hiroki Nomura 7 MIN READ rust firestore firebase oss
目次

なぜ作ったか

firebase-tools 15 から、公式の Cloud Firestore エミュレータを動かすには Java 21 以上が必須になった。ローカルのデータベースを立てるためだけに JDK を入れ、CI でも setup-java を走らせて jar を落としてくる。ずっと小さな違和感があった。

違和感は測ると数字になった。手元の計測環境(AWS c7i.xlarge、Ubuntu 24.04、Java 21、firebase-tools 15.33.0)で公式エミュレータ v1.22.0 を動かすと、ポートが開くまで 981 ms、アイドルで 148 MiB。リスナーを 1 本付けたまま 500 件ずつ書き込むと、保存済みドキュメントが 1 万件増えるごとにコミットが 195 ms ずつ遅くなり、10 万件を書き切るのに 191 秒かかった。この退行は firebase-tools#3477 として昔から知られている。

公式エミュレータはクローズドソースの Java アプリなので、直すことはできない。置き換えるしかない。

何を作ったか

hidane(火種)は、公式エミュレータの置き換えとして動く Rust の単一バイナリだ。

hidane.dev のトップページ
hidane.dev。インストールは 1 行で、公式エミュレータの置き換えとして動く
  • gRPC、REST、WebChannel の 3 つのワイヤプロトコルを公式と同じく 1 ポートで話す
  • Security Rules を評価する
  • export / import は公式と同じ形式を読み書きし、どちらのエミュレータで作ったものも相互に読める
  • firebase-tools の下で動く。firebase emulators:start の前に hidane exec -- を付けるだけ
curl -fsSL https://hidane.dev/install.sh | sh    # または brew / npm / cargo / dart pub / Docker
hidane exec -- firebase emulators:exec "npm test"

同じ環境で hidane v0.6.0 を測ると、ポートが開くまで 1.56 ms、アイドルで 9.4 MiB、リスナー付きの 10 万件書き込みは 2.51 秒。コミットのコストは変更したドキュメント数だけで決まり、保存済みの総数には依存しない。

ひとつ正直に書いておくと、Admin SDK がエミュレータに送る owner credential ではルール評価が飛ぶため、その条件では公式エミュレータも hidane とほぼ同じ速さで書ける。差が大きいのはサインアウト状態のクライアントと、リスナーを付けたときだ。ベンチマークの公平性の条件は bench/ に書いてある。

決めたこと

設計の判断は ADR として docs/adr/ に残している。大きいものを 3 つ。

ストレージはインメモリ(ADR 0002)。 公式エミュレータも永続化はせず、export / import がその役割を持つ。同じにした。コミットごとに単調増加するシーケンス番号を read_time の源にし、直近のコミットを変更ログとして持って resume_token からの再開に答える。変更通知は「変わったドキュメント数」に比例するコストで出す。これが上のリスナー付き書き込みの差になっている。

firebase-tools の下での起動(ADR 0006)。 firebase-tools は java -version でバージョンを確かめてから java -jar <jar> を実行する。jar 以外を起動する公式の手段はない。そこで hidane exec -- <command> は、一時ディレクトリを PATH の先頭に置き、その中に java という名前で hidane 自身を置く。バージョン確認には 21 と答え、-jar の後ろの引数で自分を起動する。そのコマンドの間だけ効き、マシン全体の java は汚さない。

How hidane exec starts under firebase-toolshidane exec --firebase emulators:starttemp dir first on PATHfirebase-toolsjava -version → java -jar <jar>the only way it knows to startresolves java on PATHjava (= the hidane shim)answers 21 to -version-jar … → starts hidane itselfhidane · one Rust binarygRPC + REST + WebChannel · :8080official jarnever downloadedThe shim exists only for thiscommand; the machine-widejava is untouched.your app / SDKfirebase-admin, js-sdk,Flutter, Go, Python …
hidane exec が firebase-tools の下で起動する仕組み。PATH の先頭に置いた java が hidane 自身を起動する

互換性の証明(ADR 0008)。 公式の jar はブラックボックスで、再配布も逆コンパイルもできない。そこで tools/oracle/ のスクリプトがワイヤ越しに公式エミュレータへ質問し、答えを JSON の fixture として記録する。CI はそれを Java なしで hidane に再生する。v0.6.0 では 57 の記録、26,556 件の答えを再生している。公式の性能バグや既知の欠陥のように意図的に再現しない差分は docs/parity-exceptions.md に列挙し、それぞれを固定するテストを名指ししている。

全体はクリーンルームで書いた。公開の protobuf 定義、公開ドキュメント、そして公式エミュレータの黒箱観察だけが根拠で、何がどこから来たかは docs/clean-room/ に書いてある。

エミュレータの周り

v0.5 からは道具を足している。hidane doctor は移行前のプロジェクトを何も変えずに点検し、hidane migrate は差分を見せてから --apply で書き換える。hidane benchmark は自分のテストを両方のエミュレータで回して比べ、savings で月あたりの CI 時間と費用に換算する。GitHub Actions には setup-hidane があり、リリースの SHA-256 とビルド来歴を検証してから入れる。Testcontainers では Cloud SDK イメージの代わりに使える。

ベンチマークは benchmark-results に、毎晩と毎リリース、同じマシンで両方を測った結果を生サンプルごと置いている。README が引く数字はすべてそこへのリンク付きだ。

学び

  • 「速い」と言うなら測り方ごと公開する。都合のいい 1 回ではなく中央値を、他の人が同じスクリプトで再現できる形で
  • 互換性は「テストがあるものだけ」を名乗る。verified と not verified を分けて表に出すと、使う側が判断できる
  • 差分ファザー(同じリクエストを両方に投げて答えを比べる)は、人が思いつかない差分を見つけてくれた。v0.6.0 だけで 6 バッチ分の差分を潰した
hidane.dev の対応クライアント表
hidane.dev の対応クライアント。公式エミュレータと突き合わせたものだけを verified にしている

これから

Flutter のモバイル / デスクトップ版は同じ gRPC を話すが、まだ誰も突き合わせていない。エンタープライズ版のパイプライン(ExecutePipeline)も未実装。どちらも Issues で追っている。パリティのずれを見つけたら、hidane report の出力を添えて教えてほしい。

  1. Forecall: MCP ツールの説明文を採点するリンターを作って分かったこと 6 min
Hiroki Nomura @nomunomu0504 · CTO, MIRAI STUDIO

Flutter / AWS / 自宅サーバー。個人開発と OSS をいくつか。設計相談や協業の話は X の DM か About から。 About

COMMENTS · GISCUS