DuckDB-Wasm 永続化検証レポート

GeoSuite プラグイン — 読み込みタイミング・セッション内再利用・ブラウザ再起動時の挙動(実測)

1. 概要

duckdb: レイヤーが利用する DuckDB-Wasm について、以下を検証・修正した。

  • 本当に必要時(初回クエリ時)に遅延ロードされるか
  • 一度ロードした後、再ダウンロード・再初期化なしに継続利用できるか
  • ブラウザ再起動・再訪問時にキャッシュが生きるか
結論: セッション内では「初回のみ ~15–17s の初期化、以後 warm(~3–9s)で継続利用」を実現した。 ただしブラウザ再起動・再訪では アセットは必ず再ダウンロードされる。 これは Re:Earth プラグイン iframe のサンドボックス制約(opaque origin)によるもので、アプリ側では回避不可能。

2. 発見した問題

2.1 パネル iframe はレイヤー追加のたびに破棄される

DuckDB-Wasm は当初 layers-and-tiles-list の UI iframe 内でロードしていた。 しかし addGeoJsonValueLayer() が無条件に safeShowUI() (reearth.ui.show(getUI(), {extended:true}))を呼ぶため、 レイヤー追加のたびに iframe が再生成され window._ddbModule (DuckDB インスタンス)が破棄されていた。

2.2 旧構成の計測値

呼び出し所要時間状態
1回目(cold)18.7swasm DL + 初期化
2回目(iframe 再生成後)22.1s再初期化発生
3回目13.4s再初期化発生

jsDelivr は cache-control: immutable, max-age=31536000 を返すが、 サンドボックス iframe では HTTP キャッシュが効かず(後述)、wasm インスタンス化 + httpfs/spatial/parquet 拡張の INSTALL/LOAD が毎回フルに走っていた。

3. 実装: 永続ホストへの移行

DuckDB の実行を basemap-widget の UI iframe に移した。 この widget は ui.show() を初期化時に一度だけ呼ぶため iframe が永続し、 パネルの再描画(safeShowUI)をまたいでインスタンスが生存する。

パネル ddbPost ── extensionMessage(ddbExec) ──> basemap 拡張
                                                └ postToUI(ddbLoad) → 永続 iframe (wasm 実行)
                    <── extensionMessage(ddbAck / ddbResult) ──
                                     ↓
                            パネル handleDdbResult
  • ddbAck: ホスト iframe がクエリを受理した即時応答
  • ddbResult: クエリ完了時の GeoJSON / エラー返送
  • フォールバック: ddbAck が 3 秒以内に来なければ (widget 非表示等でホスト iframe 不在)パネル自身の iframe で実行。 パネル側にも同一の ddbLoad ハンドラを温存している
  • window._ddbModule は Promise キャッシュ。初期化失敗時は null に戻り再試行可能

変更ファイル: geo_suite/src/basemap-widget.ts(ddbExec 中継・ddbLoad 実行・ddbAck/ddbResult 返送)、 geo_suite/src/layers-and-tiles-list.ts(ddbPost ホスト優先+フォールバック、handleDdbResult、ddbAck/ddbResult 受信)

4. セッション内の実測(修正後)

確認項目結果
初回クエリ前window._ddbModule = undefined → 遅延ロード確認
1回目(cold)17.7s — wasm DL + 初期化 + 拡張ロード
2回目(パネル再描画後)9.5s — 同一インスタンス(リモート parquet 取得+クエリのみ)
3回目3.1s — 同上
  • basemap iframe の CDP ターゲット ID は全工程で不変(iframe 再生成なし)
  • _ddbModule にタグを付けて確認 → パネル再生成をまたいで同一オブジェクトを維持
  • wasm 再初期化・再ダウンロードは発生しない

5. ブラウザ再起動・再訪の検証

5.1 実測

セッション所要時間解釈
A(新規プロファイル)15.3sコールド
B(再起動・同一プロファイル)16.9sコールドと同等 — キャッシュ未利用

5.2 原因の特定(CDP 実測)

  • 同一 iframe・同一セッション内で同一 URL を 2 回 fetch → 2回目も transferSize=4456(全バイト再転送)。HTTP キャッシュが機能しない
  • caches.open() → SecurityError: Cache storage is disabled because the context is sandboxed and lacks the 'allow-same-origin' flag
  • iframe 属性: sandbox="allow-scripts allow-downloads allow-popups" — allow-same-origin が無く opaque origin になる
opaque origin のため HTTP キャッシュ・CacheStorage・localStorage・IndexedDB・ Service Worker のすべてが利用不可。KASUGAI Canvas の sw-duckdb.js(Service Worker + CacheStorage で wasm を永続キャッシュ)方式は 構造上移植できない。

6. 回避策の検討

案判定理由
HTTP キャッシュ依存不可opaque origin で無効(実測)
Service Worker + CacheStorage(KASUGAI 方式)不可sandbox で SecurityError
拡張側 clientStorage に wasm バイナリ保存→次回注入理論上可能だが非推奨wasm+worker+拡張で ~6–8MB → base64 ~10MB 超。localStorage 系上限・メッセージ橋のバイナリ転送制約あり
永続 iframe ホスト(basemap-widget)採用セッション内の再初期化を排除(本実装)

7. フォーマット別の使いどころ(GeoJSON / FlatGeobuf / GeoParquet / duckdb:)

GeoSuite にはベクトルデータの読込経路が4種類ある。利用者視点で 「ファイルサイズ」「ダウンロード→描画の速さ」「選び方の目安」をまとめる。

7.1 フォーマット特性

構文実体圧縮読込単位備考
geojson:テキスト (JSON)なし 全量標準フォーマット。どのツールでも出力できる
flatgeobuf: / fgb:バイナリ+空間インデックスなし 全量(本実装)本来は index による bbox 読みも可能な規格だが、現実装は全件デコード
parquet: / geoparquet:カラムナー (Parquet)snappy 等 全行・全列(hyparquet)Range 対応だが実質全件読み。属性列が多いほど圧縮が効く
duckdb:同上 (Parquet 等)同上 行グループ単位の部分読込where=/limit=/columns=/bbox=auto を SQL プッシュダウン。covering.bbox で非該当行グループは転送自体が発生しない

7.2 ファイルサイズの比較(実測 + 目安)

データセットGeoJSONFlatGeobufGeoParquet
countries(179地物・実測) 258 KB(100%) 205 KB(約80%) 〜(別データのため未計測)
一般的な目安: FlatGeobuf ≈ GeoJSON の 70–90%、GeoParquet(snappy) ≈ GeoJSON の 30–60%。 属性列が多い・座標が単純(点群など)ほど Parquet の列圧縮が効く。 逆に地物数が少ない小さいファイルでは、Parquet のフッタ/メタデータの固定オーバーヘッドが効き GeoJSON より大きくなることもある(例: 5地物の example.parquet = 28KB)。

ファイルサイズが小さい順の目安: GeoParquet < FlatGeobuf < GeoJSON(中〜大ファイルの場合)

7.3 ダウンロード→描画までの速さ

小さいファイル(~数MB 以下)

経路構成実測(countries 205KB, 179地物)
geojson:DL + JSON.parse のみ最速(デコーダ不要、~0.5s)
fgb: / parquet:DL + デコーダ import + デコードDL 494ms + モジュールロード 1.6s + デコード 31ms
モジュールは初回のみ。以後はデコードのみで速い
duckdb:DL + wasm 初期化 + クエリwasm 初期化 ~15s(セッション1回目のみ)が支配的 → 小さいファイルでは割に合わない

小さいファイルでは geojson: が最速。バイナリ系はモジュールロード分だけ初回に負ける。

大きいファイル(~50–100MB 以上・属性多数・表示範囲で絞れる)

経路転送量描画まで
geojson:全量(100%)最悪 — 全量DL + JSON.parse がメインスレッドをブロック
fgb:~70–90%ストリームデコードだが全件転送は必要
parquet:~30–60%転送量は減るが全行デコード
duckdb:該当行グループのみ(桁違いに小さいことも)where/bbox 絞込み後の少量転送 + wasm は2回目以降 warm

巨大ファイルかつ「表示範囲だけ欲しい」「条件で絞りたい」用途では duckdb: が圧倒的に速い。 15s の初期化はセッションに1回だけなので、複数の duckdb レイヤーや bbox=auto のカメラ連動再クエリを使うほど元が取れる。

7.4 選び方の目安

ファイルサイズ / 用途推奨理由
〜10MB 程度geojson: または fgb:デコード基盤なしで最速。手軽さ優先
~10–50MBfgb: / parquet:転送量削減が効いてくる。属性が多いなら parquet
~50MB 以上、または表示範囲・条件で絞って使うduckdb:部分読込で転送量を最小化。15s 初期化を回収できる
属性で検索・集計したい(where=)duckdb:SQL プッシュダウンは duckdb 経路のみ
複数レイヤーをまとめてクエリするduckdb:wasm 初期化はセッション1回で使い回せる

7.5 その他の留意点

  • 変換: GeoJSON → FlatGeobuf / GeoParquet は QGIS・ogr2ogr・geopandas 等で変換可能
  • 属性が多いデータ: カラムナーの Parquet が圧倒的に有利。duckdb: の columns= で必要列だけ取り込める
  • 全量読込系はメモリ常駐: geojson/fgb/parquet 経路は FeatureCollection を丸ごとメモリに持つ。 巨大データを「一部だけ表示」するなら duckdb: 一択
  • GeoParquet 1.1 の covering.bbox 付きファイルが理想: duckdb: で自動検出され、行グループスキップ+表示範囲連動が最大効果を発揮する。 無い場合は covering=xmin,xmax,ymin,ymax 列指定か、空間フィルタ無しの where/limit のみになる

8. 関連する環境制約・今回の発見

調査過程で判明した、プラグイン開発上の重要な制約を記録しておく。

8.1 上流への機能要望(Issue 登録済み)

reearth-visualizer#2497 — プラグイン UI iframe が opaque origin で HTTP キャッシュ・CacheStorage・localStorage・ IndexedDB・Service Worker・SharedArrayBuffer が全て使えない件を feature request として登録。 提案: (1) iframe を実オリジン URL から配信、(2) 拡張ブリッジ経由のバイナリ対応キャッシュ API、 (3) COOP/COEP opt-in による COI 解放。WASM 系プラグイン(DuckDB/GDAL/ML/タイルデコーダ)や COI 必須の Cesium 最新機能が使えない現状の抜本解決を狙う。

8.2 QuickJS 拡張コンテキストには setTimeout が無い

拡張側(QuickJS サンドボックス)で遅延処理が必要な場合は、既存のタイマー委譲機構を使うこと: waitViaUiTimer(ms) → UI iframe がブラウザの setTimeout を実行 → timerDone で拡張へ返送。拡張側で直接 setTimeout を書くと ReferenceError になる(DuckDB のカメラデバウンスで実際に踏んだ)。

8.3 Workbench の resize script 注入バグ

Workbench が iframe HTML の </body> 直前に _zushi_resize スクリプトを 挿入する際、JS 文字列リテラル内の '</body></html>' に誤マッチし、 スクリプトの中間へ注入される → 注入された </script> で script 要素が早期終了し、 iframe の JS が途中で切れて無反応になる。
対策: 生成 HTML の JS 文字列リテラル内には生の </body>/</script> を 書かず '<\/body><\/html>' のようにスラッシュをエスケープする(ai-chat.ts で修正済み)。

8.4 サンドボックスでも動作するもの

  • blob URL Worker は生成可能 — DuckDB-Wasm のワーカー起動が通る
  • crossOriginIsolated=false でも DuckDB は eh(非スレッド)バンドルで動作。 httpfs / spatial / parquet 拡張も全て INSTALL/LOAD 成功
  • ESM 動的 import()(jsDelivr CDN)は制限なく使える

つまり「重い WASM が動くか」は問題ないが「永続化できない」ことが根本的な制約。

9. 結論

現状の最適構成 — 再訪時は最初の1回だけ ~15s の初期化待ち、 以後はセッション内でインスタンスを使い回す。 修正前はレイヤー追加のたびに 13–22s の再初期化が発生していたため、 セッションあたりの初期化回数が「毎回」→「1回」に改善された。

検証: tsc --noEmit 0 エラー / inline script チェック全通過 / Workbench AI テスト 44/44 全パス(コンソールエラーなし)