duckdb-wasm で OPFS (Origin Private File System) に読み書きし IndexedDB とパフォーマンスを比較する

databasewasm

OPFS (Origin Private File System) は同一オリジンからのみアクセス可能なファイルシステム。2023年3月以降の主要ブラウザで対応している。ユーザーのファイルシステムには直接アクセスしないため許可なしで利用できる。Chrome の IndexedDB は LevelDB をバックエンドとしておりランダムアクセスに向かないが、OPFS はランダムアクセスが可能な API になっている。

GoogleのkvsライブラリLevelDBを使う - sambaiz-net

デフォルトでは空き容量不足時に削除されるが、navigator.storage.persist() で永続化を要求できる。Firefox ではユーザーの許可が必要だが、Chrome ではインタラクション履歴に基づいて自動判定される。

duckdb-wasm も OPFS をサポートしている。DuckDB は Parquet や CSV を直接クエリすることもできるが、行単位の更新が可能でトランザクションもサポートする、Parquet のような列指向のデータベースファイルフォーマットも持っており、db.open() で opfs:// を指定すると OPFS にデータベースファイルが作成される。

DuckDB の Go クライアントで Google Sheets のデータを SQL で取得する - sambaiz-net

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <title>DuckDB WASM + OPFS</title>
</head>
<body>
  <script type="module">
    import * as duckdb from 'https://cdn.jsdelivr.net/npm/@duckdb/duckdb-wasm@latest/+esm';

    const JSDELIVR_BUNDLES = duckdb.getJsDelivrBundles();
    const bundle = await duckdb.selectBundle(JSDELIVR_BUNDLES);

    const worker_url = URL.createObjectURL(
      new Blob([`importScripts("${bundle.mainWorker}");`], { type: 'text/javascript' })
    );

    const worker = new Worker(worker_url);
    const logger = new duckdb.ConsoleLogger();
    const db = new duckdb.AsyncDuckDB(logger, worker);

    await db.instantiate(bundle.mainModule, bundle.pthreadWorker);

    await db.open({
      path: 'opfs://mydb.db',
      accessMode: duckdb.DuckDBAccessMode.READ_WRITE
    });

    const conn = await db.connect();

    await conn.query(`
      CREATE TABLE IF NOT EXISTS users (
        id BIGINT PRIMARY KEY,
        name VARCHAR,
        created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
      )
    `);

    await conn.query(`INSERT INTO users (id, name) VALUES (${Date.now()}, 'Alice')`);
    await conn.query('CHECKPOINT'); // flush changes

    const result = await conn.query('SELECT * FROM users');
    console.log(result.toArray());

    await conn.close();
  </script>
</body>
</html>

IndexedDB を読み書きしたときとパフォーマンスを比較する。

function openIDB() {
  return new Promise((resolve, reject) => {
    const req = indexedDB.open('benchmark', 1);
    req.onupgradeneeded = () => {
      const db = req.result;
      if (!db.objectStoreNames.contains('users')) {
        db.createObjectStore('users', { keyPath: 'id' });
      }
    };
    req.onsuccess = () => resolve(req.result);
    req.onerror = () => reject(req.error);
  });
}

async function idbWrite(db, data) {
  return new Promise((resolve, reject) => {
    const tx = db.transaction('users', 'readwrite');
    const store = tx.objectStore('users');
    for (const item of data) {
      store.put(item);
    }
    tx.oncomplete = () => resolve();
    tx.onerror = () => reject(tx.error);
  });
}

async function idbRead(db, ids) {
  return new Promise((resolve, reject) => {
    const tx = db.transaction('users', 'readonly');
    const store = tx.objectStore('users');
    const results = [];
    for (const id of ids) {
      const req = store.get(id);
      req.onsuccess = () => results.push(req.result);
    }
    tx.oncomplete = () => resolve(results);
    tx.onerror = () => reject(tx.error);
  });
}

Chrome でそれぞれ 10 万行のデータを書き込み ID での取得と集計クエリの実行時間を計測したところ、集計クエリは DuckDB の方が速いが、ID での取得は IndexedDB の方が速かった。 ただし複数取得する場合は DuckDB でも IndexedDB 以上のパフォーマンスが出ることが分かった。

Operation IndexedDB DuckDB + OPFS
Write 100000 rows 7301ms 4425ms (Batch)
Aggregation (COUNT, AVG, SUM) 352ms 31ms
- Read 351ms -
- Compute 2ms -
Read 1000 rows by ID (individual) 189ms 912ms
Read 1000 rows by ID (batch) 22ms 9ms (IN clause)