[Astro] #147 DuckDB WASM をブラウザで動かす — CSV / Parquet 高速集計アナライザーの実装記録

[Astro] #147 DuckDB WASM をブラウザで動かす — CSV / Parquet 高速集計アナライザーの実装記録

はじめに

ブラウザ上でサーバーを一切介さず、CSV / Parquet / JSON ファイルを SQL でリアルタイム集計できる Web アプリ「DuckDB WASM Data Analyzer」を構築しました。

DuckDB はその列指向エンジンと WASM ビルドの存在により、ブラウザ上でも本格的な分析 SQL を実行できます。100万行の GROUP BY 集計が 91ms で返ってくるほどのパフォーマンスを、サーバーレス・データアップロードなしで実現しています。

モジュール内容
WASM ENGINEDuckDB WASM 初期化、Web Worker、バンドル選択
FILE LOADERregisterFileHandle によるゼロコピー大容量ファイル登録
QUERY ENGINEArrow → JSON 変換、Ctrl+Enter 実行、プリセット SQL
VISUALIZERChart.js 5種グラフ(BAR / LINE / PIE / SCATTER / TABLE)
MULTI FILE複数ファイル同時登録、JOIN テンプレート自動生成
EXPORTERCSV エクスポート、COPY TO + copyFileToBuffer による Parquet エクスポート

スクリーンショット

メイン UI

DuckDB WASM Data Analyzer メイン UI

クエリ実行

DuckDB WASM Data Analyzer クエリ実行

Chart.js グラフ可視化

DuckDB WASM Data Analyzer グラフ可視化

動画(GIF)

DuckDB WASM Data Analyzer

1. 技術スタック

ライブラリバージョン役割
@duckdb/duckdb-wasmlatestDuckDB WASM エンジン本体
chart.jslatestグラフ描画
react-chartjs-2latestReact 向け Chart.js ラッパー
React19UI コンポーネント
Astro5ページフレームワーク

対応ファイル形式: .parquet / .csv / .tsv / .json

2. DuckDB WASM の初期化

DuckDB WASM はメインスレッドをブロックしないよう Web Worker 上で動作させます。CDN からバンドルを取得して環境(マルチスレッド対応 / シングルスレッド)に応じたモジュールを自動選択します。

useEffect(() => {
  const init = async () => {
    // jsDelivr CDN からバンドル情報を取得
    const bundles = duckdb.getJsDelivrBundles();
    // ブラウザ環境に最適なバンドルを選択(SIMD対応等を自動判定)
    const bundle = await duckdb.selectBundle(bundles);

    // Worker スクリプトを動的生成して Web Worker を起動
    const workerBlob = new Blob(
      [`importScripts("${bundle.mainWorker}");`],
      { type: 'text/javascript' }
    );
    const worker = new Worker(URL.createObjectURL(workerBlob));

    const duckDbInstance = new duckdb.AsyncDuckDB(
      new duckdb.ConsoleLogger(),
      worker
    );
    // WASM モジュールをインスタンス化(pthread Worker も同時に起動)
    await duckDbInstance.instantiate(bundle.mainModule, bundle.pthreadWorker);
    // BigInt を Double にキャストして JS で扱いやすくする
    await duckDbInstance.open({ query: { castBigIntToDouble: true } });

    setDb(duckDbInstance);
  };
  init();
}, []);

castBigIntToDouble: true は重要な設定です。JavaScript は BigInt と Number を混在させるとエラーになるため、DuckDB 側で自動変換させることでテーブル表示やグラフ描画の互換性を確保します。

3. ファイル登録と大容量処理

DuckDB WASM は registerFileHandle でブラウザの File オブジェクトを直接 WASM の仮想ファイルシステムに登録します。ファイルの内容をメモリにコピーせず、必要なバイト範囲だけを FileReader 経由で読み込む仕組みです。

await db.registerFileHandle(
  file.name,                                    // 仮想 FS 上のパス
  file,                                         // ブラウザの File オブジェクト
  duckdb.DuckDBDataProtocol.BROWSER_FILEREADER, // FileReader プロトコル
  true                                          // 既存登録を上書き
);

登録後はファイル名をそのまま SQL のテーブルとして参照できます:

SELECT * FROM 'flights-1m.parquet' LIMIT 100;
SELECT COUNT(*) FROM 'data.csv';

100万行・7MB の Parquet ファイルで初回クエリ(LIMIT 100)が 72ms で返ってくるのは、DuckDB が Parquet のカラムメタデータを先読みして不要な行グループをスキップするためです。

4. クエリ実行と Arrow → JSON 変換

DuckDB WASM のクエリ結果は Apache Arrow 形式で返ってきます。toArray().map(r => r.toJSON()) で各行を通常の JavaScript オブジェクトに変換します。

const conn = await db.connect();
const arrowResult = await conn.query(targetSql);

// Arrow → JavaScript オブジェクト配列に変換
const rows = arrowResult.toArray().map(r => r.toJSON());

// カラム名の抽出
const keys = rows.length > 0 ? Object.keys(rows[0]) : [];

performance.now() で実行時間を計測して UI に表示することで、クエリのパフォーマンスを可視化しています。

5. ワンクリック GROUP BY 集計

左サイドバーのカラムリストから「⚡ GROUP BY & COUNT」ボタンをクリックすると、そのカラムの集計 SQL が即座に生成・実行されます。

const runGroupBy = (colName: string) => {
  const sql = `SELECT ${colName}, COUNT(*) as count
               FROM '${fileName}'
               GROUP BY ${colName}
               ORDER BY count DESC
               LIMIT 50;`;
  setQuery(sql);
  executeQuery(undefined, sql);
};

実際の計測例(100万行の Parquet):

クエリ実行時間
SELECT * LIMIT 10072ms
GROUP BY DISTANCE, AVG(DEP_DELAY)91ms
SUMMARIZE SELECT *~300ms

6. Chart.js による5種グラフ可視化

クエリ結果を TABLE / BAR / LINE / PIE / SCATTER の5モードで切り替えて可視化できます。

import {
  Chart as ChartJS,
  CategoryScale, LinearScale, BarElement, LineElement,
  PointElement, ArcElement, Title, Tooltip, Legend
} from 'chart.js';
import { Bar, Line, Pie, Scatter } from 'react-chartjs-2';

ChartJS.register(
  CategoryScale, LinearScale, BarElement, LineElement,
  PointElement, ArcElement, Title, Tooltip, Legend
);

クエリ結果の1列目をラベル(X軸)、2列目を値(Y軸)として自動マッピングします。GROUP BY & COUNT の結果をそのまま流せる設計です。

const chartData = {
  labels: results.slice(0, 50).map(r => String(r[resultKeys[0]] ?? '')),
  datasets: [{
    label: resultKeys[1] ?? '',
    data: results.slice(0, 50).map(r => Number(r[resultKeys[1]]) || 0),
    backgroundColor: chartColors.map(c => c + 'bb'),
    borderColor: chartColors,
    borderWidth: 1,
  }],
};

SCATTER は { x, y } 形式のポイントデータが必要なため、1列目・2列目をそれぞれ数値として読み込む専用の scatterData を用意しています。

7. 複数ファイル登録と JOIN

複数の CSV / Parquet をドロップすることで、registerFileHandle を繰り返し呼んで複数ファイルを仮想 FS に登録できます。各ファイルはそのまま SQL の FROM 句で参照可能なので、JOIN も自然に書けます。

-- 2ファイルを登録した状態でのJOINクエリ例
SELECT a.user_id, a.action, b.status
FROM 'events.csv' a
JOIN 'users.parquet' b ON a.user_id = b.id
LIMIT 100;

UI の「⚡ GENERATE JOIN TEMPLATE」ボタンは最初の2ファイルをもとに JOIN 雛形 SQL を生成します。実際のカラム名に合わせて ON 句を書き換えるだけで使えます。

実装上の注意点として、DuckDB WASM の registerFileHandle は同名ファイルの再登録(overwrite: true)に対応しているため、ファイルを差し替えたい場合も同じファイル名でドロップするだけで済みます。

8. Parquet エクスポート

DuckDB の COPY TO 文で現在のクエリ結果を WASM 仮想 FS 上の Parquet ファイルに書き出し、copyFileToBuffer でバイナリとして取り出してダウンロードします。

const exportParquet = async () => {
  const conn = await db.connect();
  const outName = `export_${Date.now()}.parquet`;

  // 現在の SQL をラップして Parquet として仮想 FS に出力
  await conn.query(
    `COPY (${query.trim().replace(/;$/, '')})
     TO '${outName}' (FORMAT parquet, COMPRESSION snappy);`
  );
  await conn.close();

  // 仮想 FS からバッファを取り出す
  const buf = await db.copyFileToBuffer(outName);
  const blob = new Blob([buf], { type: 'application/octet-stream' });

  // ダウンロードトリガー
  const url = URL.createObjectURL(blob);
  const a = document.createElement('a');
  a.href = url;
  a.download = outName;
  a.click();
  URL.revokeObjectURL(url);
};

Snappy 圧縮を指定しているため、CSV と比較してファイルサイズが大幅に削減されます。GROUP BY の集計結果(4行)を Parquet として出力すると 384バイト になります。出力した Parquet はそのままツールにドロップして再クエリできます。


9. プリセット SQL

DuckDB の分析特化コマンドをワンクリックで実行できるプリセットを用意しています。

ボタンSQL用途
TOP 100SELECT * FROM '...' LIMIT 100先頭100行プレビュー
COUNT(*)SELECT COUNT(*) AS total_rows FROM '...'総行数確認
SUMMARIZESUMMARIZE SELECT * FROM '...'全カラムの統計サマリー
DESCRIBEDESCRIBE SELECT * FROM '...'スキーマ確認

特に SUMMARIZE は DuckDB 独自コマンドで、各カラムの min / max / mean / stddev / null 率 / 上位5値などを一括出力します。


10. まとめ

DuckDB WASM はブラウザ完結型のデータ分析ツールとして十分な実用性を持っています。

  • 100万行 GROUP BY が 91ms — サーバーなし、アップロードなしでこの速度
  • Parquet のネイティブサポート — read / write ともに対応
  • 標準 SQL + DuckDB 拡張SUMMARIZE, COPY TO 等の分析特化コマンドが使える
  • 複数ファイル JOIN — 仮想 FS に複数登録して SQL で自由に結合

今後の拡張として、SQLクエリ履歴の保存、HTTP 直読み(SELECT * FROM read_parquet('https://...'))、チャートのX/Y軸カラム指定 UI などを検討しています。