Open10

Firestore で格安な全文検索・ページネーションを実現するには

9sako69sako6

モチベ

Firestore を使った Web アプリを開発しており、全文検索に対応したくなった。
Firestore は全文検索に対応していないので、公式では Elasticsearch, Algolia 等の使用を勧めている
しかし、Elasticsearch を稼働させる VM のランニングコストの採算が取れない。部分的に RDB を使うにしても料金高いしなあ。

実現したいこと

  • 全文検索
    • クエリは単純で、2つのフィールドに LIKE 検索をかけるくらい
  • offset, limit を指定してのページネーション
  • 月1,000円以内のランニングコスト

妥協できる点

  • レイテンシの増大 (何秒まで許容できるかは動かしながら探る)
  • インデックス反映までの時間

データ量

私のユースケースでは、検索対象のドキュメントのサイズは 30 MiB (1024文字 * 3 (日本語) * 10,000ドキュメント) あればおさまる程度。

9sako69sako6

flexsearch, AWS EFS, AWS Lambda を使った全文検索

というときに見つけたのが flexserach, AWS Lambda, AWS EFS を使って作る面白い全文検索。

https://medium.com/@chrisswhitneyy/full-text-search-in-aws-without-the-price-tag-server-less-full-text-search-with-persistent-c2478bc94015

私のユースケースだとレイテンシーが数秒超えそうな匂いがするが、実際どんなもんかを検証したい。

Google Cloud 版

Google Cloud の Cloud Filestore + Cloud Functions でも実装できないかと思ったが、Filestore は今回のユースケースだと高額になってしまった。Filestore の料金説明には

インスタンス容量: 未使用の場合でも、割り当てられたストレージ容量に対して課金されます。

とあり、最低 1 TiB の割り当てしかできないのでインスタンスタイプ「リージョン」だと $460.80/月。

9sako69sako6

BigQuery を使う

BigQuery にデータを保持しておき、SQL で全文検索なりページネーションクエリを捌けばいいのでは?
Firestore のデータを BigQuery に流し込む公式の extension があってお手軽に実装できる説がある。

https://extensions.dev/extensions/firebase/firestore-bigquery-export

今回のユースケースではストレージ料金は無視できるほど安い。なんなら無料枠に収まる。
問題はクエリの料金だが検索対象のサイズが小さいので問題なさそうな見込み。

extension のインストール。

firebase ext:install firebase/firestore-bigquery-export --project=PROJECT_ID

同期したいコレクションのパスは途中で質問される。ワイルドカードを使わない場合、コレクション1つに対して1つの拡張インストールが必要っぽい。

Collection path: What is the path of the collection that you would like to export? You may use {wildcard} notation to match a subcollection of all documents in a collection (for example: chatrooms/{chatid}/posts). Parent Firestore Document IDs from {wildcards}  can be returned in path_params as a JSON formatted string.
? Enter a value for Collection path: (posts)

初期設定のためにいくつか質問されるが、インストールが完了すると extensions/firestore-bigquery-export.env ファイルが生成される。設定内容はこのファイルに記載されているので、変更したければファイルをいじればいい。

extensions/firestore-bigquery-export.env
BIGQUERY_PROJECT_ID=<PROJECT_ID>
COLLECTION_PATH=works
DATASET_ID=firestore_export
DATASET_LOCATION=asia-northeast1
EXCLUDE_OLD_DATA=yes
firebaseextensions.v1beta.function/location=us-central1
MAX_DISPATCHES_PER_SECOND=100
TABLE_ID=works
TABLE_PARTITIONING=NONE
TIME_PARTITIONING_FIELD_TYPE=omit
USE_COLLECTION_GROUP_QUERY=yes
USE_NEW_SNAPSHOT_QUERY_SYNTAX=yes
WILDCARD_IDS=true

各オプションの意味:

  • BIGQUERY_PROJECT_ID
    • BigQuery インスタンスを置くプロジェクト ID。Firebase と別プロジェクトにすることも可能な模様
  • COLLECTION_PATH
    • エクスポートしたいコレクションのパス
  • DATASET_ID
    • BigQuery dataset ID
  • DATASET_LOCATION
    • BigQuery dataset を作るロケーション
  • EXCLUDE_OLD_DATA
    • onDocumentUpdate イベントで変更される前のスナップショットを含めるかどうか
    • BigQuery 上では old_data というカラムで保存されるようだ
  • firebaseextensions.v1beta.function/location
    • (推測)extension で自動生成される functions のロケーション
  • MAX_DISPATCHES_PER_SECOND
    • 1秒あたりの最大同期ドキュメント数
  • TABLE_ID
    • BigQuery のテーブルの prefix として使われる文字列
  • TABLE_PARTITIONING
    • パーティショニングするかどうかと、パーティショニングの粒度
    • パーティショニングはクエリのパフォーマンスに影響するのでした方がいいだろう
  • TIME_PARTITIONING_FIELD_TYPE
    • パーティショニングのカラム名
  • USE_COLLECTION_GROUP_QUERY
    • コレクショングループクエリを有効にするか。つまり、対象のコレクションがサブコレクションの場合は有効にする必要がある
  • USE_NEW_SNAPSHOT_QUERY_SYNTAX
    • スナップショットを新しい syntax で生成する。パフォーマンス向上らしい
  • WILDCARD_IDS
    • 全てのドキュメントを同期する

firebase.json には拡張が自動的に設定されている。

diff --git a/firebase.json b/firebase.json
index 006b2dd..833d57d 100644
--- a/firebase.json
+++ b/firebase.json
@@ -32,5 +32,8 @@
       "enabled": true,
       "host": "0.0.0.0"
     }
+  },
+  "extensions": {
+    "firestore-bigquery-export": "firebase/firestore-bigquery-export@0.1.51"
   }
 }
9sako69sako6

デプロイ。

firebase deploy --only extensions --project=PROJECT_ID

Firestore でドキュメントを追加すると BigQuery にも反映された。Firestore ドキュメントの中身は data フィールドに JSON として格納されている。


あとやりたいこと:

参考になりそうな記事

https://note.shiftinc.jp/n/nc6cad687ea74

9sako69sako6

JSON を任意の View に変換する

下記の手順に従う。

extensions/firestore-bigquery-export/guides/GENERATE_SCHEMA_VIEWS.md at master · firebase/extensions

スキーマ定義のファイルを作って

schema.json
{
  "fields": [
    {
      "name": "title",
      "type": "string"
    },
    {
      "name": "description",
      "type": "string"
    }
  ]
}

コマンドを実行するとエラーになった。

$ npx @firebaseextensions/fs-bq-schema-views \
  --non-interactive \
  --project=<PROJECT_ID> \
  --big-query-project=<PROJECT_ID> \    
  --dataset=firestore_export \   
  --table-name-prefix=works \            
  --schema-files=./schema.json 
Need to install the following packages:
@firebaseextensions/fs-bq-schema-views@0.4.7
Ok to proceed? (y) 
npm WARN deprecated inflight@1.0.6: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.
npm WARN deprecated glob@8.1.0: Glob versions prior to v9 are no longer supported
npm WARN deprecated glob@7.1.5: Glob versions prior to v9 are no longer supported
error: unknown option '--big-query-project=<PROJECT_ID>'

--big-query-project を抜いて実行したらできた。

結果
npx @firebaseextensions/fs-bq-schema-views \
  --non-interactive \
  --project=<PROJECT_ID> \
  --dataset=firestore_export \
  --table-name-prefix=works \
  --schema-files=./schema.json
(node:38565) [DEP0040] DeprecationWarning: The `punycode` module is deprecated. Please use a userland alternative instead.
(Use `node --trace-deprecation ...` to show where the warning was created)
BigQuery creating schema view works_schema_schema_changelog:
Schema:
{"fields":[{"name":"title","type":"string","extractor":"title"},{"name":"description","type":"string","extractor":"description"}]}
Query:
SELECT
  document_name,
  document_id,
  timestamp,
  operation,
  JSON_EXTRACT_SCALAR(data, '$.title') AS title,
  JSON_EXTRACT_SCALAR(data, '$.description') AS description
FROM
  `<PROJECT_ID>.firestore_export.works_raw_changelog`
BigQuery created schema view works_schema_schema_changelog

BigQuery creating schema view works_schema_schema_latest:
Schema:
{"fields":[{"name":"title","type":"string","extractor":"title"},{"name":"description","type":"string","extractor":"description"}]}
Query:
-- Given a user-defined schema over a raw JSON changelog, returns the
-- schema elements of the latest set of live documents in the collection.
--   timestamp: The Firestore timestamp at which the event took place.
--   operation: One of INSERT, UPDATE, DELETE, IMPORT.
--   event_id: The event that wrote this row.
--   <schema-fields>: This can be one, many, or no typed-columns
--                    corresponding to fields defined in the schema.
SELECT
  document_name,
  document_id,
  timestamp,
  operation,
  title,
  description
FROM
  (
    SELECT
      document_name,
      document_id,
      FIRST_VALUE(timestamp) OVER(
        PARTITION BY document_name
        ORDER BY
          timestamp DESC
      ) AS timestamp,
      FIRST_VALUE(operation) OVER(
        PARTITION BY document_name
        ORDER BY
          timestamp DESC
      ) AS operation,
      FIRST_VALUE(operation) OVER(
        PARTITION BY document_name
        ORDER BY
          timestamp DESC
      ) = "DELETE" AS is_deleted,
      FIRST_VALUE(JSON_EXTRACT_SCALAR(data, '$.title')) OVER(
        PARTITION BY document_name
        ORDER BY
          timestamp DESC
      ) AS title,
      FIRST_VALUE(JSON_EXTRACT_SCALAR(data, '$.description')) OVER(
        PARTITION BY document_name
        ORDER BY
          timestamp DESC
      ) AS description
    FROM
      `<PROJECT_ID>.firestore_export.works_raw_latest`
  )
WHERE
  NOT is_deleted
GROUP BY
  document_name,
  document_id,
  timestamp,
  operation,
  title,
  description
BigQuery created view works_schema_schema_latest
done.

works_schema_schema_latest テーブルが作られた。最高!

9sako69sako6

ローカルではエミュレータを使う

ローカルでの開発や CI でのテストのために、Firebase と BigQuery をそれぞれエミュレータで動かしてデータの同期をしたい。

https://note.shiftinc.jp/n/nb19116e23665
https://github.com/goccy/bigquery-emulator

BigQuery エミュレータと Firebase Local Emulator をそれぞれ起動した。
エミュレータ用のプロジェクト ID は demo-bq-firestore にした。demo-* にするとデモプロジェクトとなり、実際のプロジェクトは使用されない
BigQuery エミュレータは起動してる。

$ bq --api http://localhost:9050 query --project_id=demo-bq-firestore 'SELECT 1'
+-------+
| $col1 |
+-------+
|     1 |
+-------+

Emulator に --project オプションで指定すると demo-bq-firestore が使われるようになる。

firebase emulators:start --project=demo-bq-firestore
i  emulators: Detected demo project ID "demo-bq-firestore", emulated services will use a demo configuration and attempts to access non-emulated services for this project will fail.
⚠  Extensions: The following Extensions make calls to Google Cloud APIs that do not have Emulators. demo-bq-firestore is a demo project, so these Extensions may not work as expected.
┌─────────────────────────┬──────────────────────────┬──────────────────────────────┬────────────────────────────────────────────┐
│ API Name                │ Instances using this API │ Enabled on demo-bq-firestore │ Enable this API                            │
├─────────────────────────┼──────────────────────────┼──────────────────────────────┼────────────────────────────────────────────┤
│ bigquery.googleapis.com │ firestore-bigquery-expo… │ No                           │ https://firebase.tools/l/LoMXec6nyrFK9XGx6 │
└─────────────────────────┴──────────────────────────┴──────────────────────────────┴────────────────────────────────────────────┘

Enabled on demo-bq-firestore

が No になっており、Firebase Emulator は BigQuery に対応していないのでそれはそう。

試しに Firestore にドキュメントを作ると、同期に失敗してエラーが出る。

⚠  functions: Error: Failed to determine service account. Initialize the SDK with service account credentials or set service account ID as an app option.
9sako69sako6

Firebase Emulator と BigQuery Emulator を接続することを一旦やめて、Firebase Emulator と本物の BigQuery が接続できるかを検証してみる。
結局これになってしまった。

Error: Failed to determine service account. Initialize the SDK with service account credentials or set service account ID as an app option.

Firebase Emulator 起動時の警告

⚠  Function 'fsimportexistingdocs is missing a trigger in extension.yaml. Please add one, as triggers defined in code are ignored.
⚠  Function 'syncBigQuery is missing a trigger in extension.yaml. Please add one, as triggers defined in code are ignored.
⚠  Function 'initBigQuerySync is missing a trigger in extension.yaml. Please add one, as triggers defined in code are ignored.
⚠  Function 'setupBigQuerySync is missing a trigger in extension.yaml. Please add one, as triggers defined in code are ignored.
⚠  functions: The functions emulator is configured but there is no functions source directory. Have you run firebase init functions?
⚠  functions: The following emulators are not running, calls to these services from the Functions emulator will affect production: database, hosting, pubsub, storage
i  firestore: Firestore Emulator logging to firestore-debug.log
✔  firestore: Firestore Emulator UI websocket is running on 9150.
i  ui: Emulator UI logging to ui-debug.log
i  functions: Watching "/Users/9sako6/.cache/firebase/extensions/firebase/firestore-bigquery-export@0.1.51/functions" for Cloud Functions...
✔  functions: Loaded functions definitions from source: fsexportbigquery, fsimportexistingdocs, syncBigQuery, initBigQuerySync, setupBigQuerySync.
✔  functions[asia-northeast1-ext-firestore-bigquery-export-cnf7-fsexportbigquery]: firestore function initialized.
⚠  Unsupported function type on ext-firestore-bigquery-export-cnf7-fsimportexistingdocs. Expected either an httpsTrigger, eventTrigger, or blockingTrigger.
i  functions[asia-northeast1-ext-firestore-bigquery-export-cnf7-fsimportexistingdocs]: function ignored because the unknown emulator does not exist or is not running.
⚠  Unsupported function type on ext-firestore-bigquery-export-cnf7-syncBigQuery. Expected either an httpsTrigger, eventTrigger, or blockingTrigger.
i  functions[asia-northeast1-ext-firestore-bigquery-export-cnf7-syncBigQuery]: function ignored because the unknown emulator does not exist or is not running.
⚠  Unsupported function type on ext-firestore-bigquery-export-cnf7-initBigQuerySync. Expected either an httpsTrigger, eventTrigger, or blockingTrigger.
i  functions[asia-northeast1-ext-firestore-bigquery-export-cnf7-initBigQuerySync]: function ignored because the unknown emulator does not exist or is not running.
⚠  Unsupported function type on ext-firestore-bigquery-export-cnf7-setupBigQuerySync. Expected either an httpsTrigger, eventTrigger, or blockingTrigger.
i  functions[asia-northeast1-ext-firestore-bigquery-export-cnf7-setupBigQuerySync]: function ignored because the unknown emulator does not exist or is not running.

この警告は Firebase Emulator 側がサポートしていない機能を使っているために出る。
https://github.com/firebase/firebase-tools/issues/6693#issuecomment-1894374152
https://github.com/firebase/firebase-tools/issues/4884

9sako69sako6

https://note.shiftinc.jp/n/nc6cad687ea74

SHIFT さんの記事にあったバージョンに合わせると、上記エラーも警告も出なくなった。

firebase/firestore-bigquery-export@0.1.51 -> firebase/firestore-bigquery-export@0.1.31

が、下記エラーが出るようになった。

https://github.com/firebase/extensions/issues/1533

この問題はすでに修正してくれているようなので、修正が取り込まれているバージョンに切り替えて試す。
0.1.34 にて修正が取り込まれたようだ。
しかし残念なことに、0.1.33 から下記警告が出るようになり、service account に関するエラーも再発した。したがって 0.1.34 に上げることができない。uh...

⚠  Function 'syncBigQuery is missing a trigger in extension.yaml. Please add one, as triggers defined in code are ignored.
⚠  Function 'setupBigQuerySync is missing a trigger in extension.yaml. Please add one, as triggers defined in code are ignored.
Error when mirroring data to BigQuery FirebaseFunctionsError: Failed to determine service account. Initialize the SDK with service account credentials or set service account ID as an app option.
9sako69sako6

0.1.32 から 0.1.33 にかけての差分はこちら。

https://github.com/firebase/extensions/compare/firestore-bigquery-export-v0.1.32...firestore-bigquery-export-v0.1.33

影響がありそうな変更

https://github.com/firebase/extensions/commit/0fd9b79cca9357986ac3d5bb7855f8ecd84f242f

Firebase Emulator 側が対応してくれるのが一番いい。しかし、issue は2年くらいオープンなままなのでサポートを期待するのはきつそう。

https://github.com/firebase/firebase-tools/issues/4884

firebase/firestore-bigquery-export を使うのは諦めて、自前で BigQuery に連携することにした。