テックブログ

firebase-admin SDK のキャッシュバグを発見して OSS 貢献した話

Firebase Data Connect の Admin SDK で getDataConnect() のキャッシュバグを発見し、修正 PR を提出した経験を共有します。マルチコネクター構成で発生するこのバグの原因と、私たちがどのように解決に至ったかを詳しく解説します。

Caprer Tech Team

firebaseossdata-connectbug-fixcontribution

TL;DR

  • firebase-admin SDK の getDataConnect() 関数にキャッシュのバグを発見
  • 原因: キャッシュキーが ${location}-${serviceId} のみで、connector フィールドが含まれていなかった
  • 解決策: ConnectorConfig 全体をソートして JSON.stringify() でシリアライズする方式に変更(Client JS SDK と同じアプローチ)
  • PR: firebase-admin-node #3055
  • ワークアラウンド: 独自のキャッシュ管理を実装(記事末尾にコード例あり)

はじめに

Firebase Data Connect を使って本番アプリケーションを開発していたところ、奇妙なエラーに遭遇しました。

Error: operation "app-user/getUserProfile" not found

このクエリは確かに存在しています。ローカル環境では正常に動作するのに、本番環境でだけエラーになる。しかも、リクエストによって動いたり動かなかったりする…。

調査の結果、これは firebase-admin SDK の getDataConnect() 関数に潜んでいたキャッシュのバグでした。この記事では、バグの発見から PR 提出までの経緯を共有します。

問題の再現

マルチコネクター構成

私たちのプロジェクトでは、Firebase Data Connect を 複数のコネクター に分割して運用しています。

dataconnect/
├── core/        # 共通エンティティ(管理者・内部用)
├── app-public/  # 公開API(認証不要)
├── app-user/    # ユーザー用(要認証)
└── admin/       # 管理者専用

この分割により、コネクター単位で認可ルールを管理できます。例えば、app-public には @auth(level: PUBLIC) のクエリのみ、app-user には @auth(level: USER) のクエリのみを配置します。

発生した問題

Server Components から複数のコネクターを使用する際、2番目以降のコネクターへのリクエストが失敗していました。

// 1番目: 正常に動作
const publicDc = getPublicDataConnect();
const items = await listItems(publicDc, {});

// 2番目: エラー!
const userDc = getUserDataConnect();
const profile = await getUserProfile(userDc, { userId }); // "operation not found"

原因の特定

デバッグの過程

最初は「クエリの定義が間違っているのでは?」と疑いました。しかし、app-user コネクターを単独で呼び出すと正常に動作します。

次に、呼び出し順序を入れ替えてみました。

// app-user を先に呼ぶと...
const userDc = getUserDataConnect();
const profile = await getUserProfile(userDc, { userId }); // 成功!

// app-public が失敗する
const publicDc = getPublicDataConnect();
const items = await listItems(publicDc, {}); // "operation not found"

これで確信しました。「最初に呼ばれたコネクターの設定が、後続のリクエストに使い回されている」 のです。

ソースコードの調査

firebase-admin のソースコードを確認しました。

// src/data-connect/data-connect-service.ts (修正前)
getDataConnect(connectorConfig: ConnectorConfig): DataConnect {
  const id = `${connectorConfig.location}-${connectorConfig.serviceId}`;

  if (!this.appInternal.instances.has(id)) {
    this.appInternal.instances.set(id, new DataConnect(this.appInternal, connectorConfig));
  }
  return this.appInternal.instances.get(id)!;
}

問題が見つかりました。

キャッシュキーが locationserviceId だけで構成されており、connector フィールドが含まれていません。

私たちのケースでは、全てのコネクターが同じ location(例: asia-east1)と serviceId(例: my-service)を共有しています。そのため、異なる connector を指定しても、最初にキャッシュされたインスタンスが返されるという状況でした。

修正 PR の提出

修正内容

ConnectorConfig 全体をキャッシュキーとして使用するように変更しました。Client JS SDK と同じアプローチを採用しています。

// 修正後
getDataConnect(connectorConfig: ConnectorConfig): DataConnect {
  // プロパティをソートしてからJSON化(キーの定義順序に依存しない)
  const orderedConfig = Object.keys(connectorConfig)
    .sort()
    .reduce((obj, key) => {
      obj[key] = connectorConfig[key as keyof ConnectorConfig];
      return obj;
    }, {} as any);
  const id = JSON.stringify(orderedConfig);

  if (!this.appInternal.instances.has(id)) {
    this.appInternal.instances.set(id, new DataConnect(this.appInternal, connectorConfig));
  }
  return this.appInternal.instances.get(id)!;
}

この方式の利点:

  • ConnectorConfig に将来フィールドが追加されても対応可能
  • キーの定義順序に依存しない
  • connectorundefined の場合と空文字列 "" の場合を区別できる

Issue と PR

PR では以下を含めました。

  1. 修正コード
  2. ユニットテスト(異なるコネクターで異なるインスタンスが返されることを検証)
  3. 問題の再現手順

暫定的なワークアラウンド

PR がマージされるまでの間、以下のワークアラウンドを使用しています。

// lib/dataconnect/admin.ts
import { getDataConnect as getDataConnectOriginal } from "firebase-admin/data-connect";

// コネクターごとに個別のインスタンスを保持
const instances = new Map<string, ReturnType<typeof getDataConnectOriginal>>();

export function getDataConnect(config: ConnectorConfig) {
  const key = `${config.location}-${config.serviceId}-${config.connector}`;

  if (!instances.has(key)) {
    instances.set(key, getDataConnectOriginal(config));
  }

  return instances.get(key)!;
}

Note: このワークアラウンドは簡易的な実装です。正式な修正では ConnectorConfig 全体を JSON.stringify() でシリアライズする方式を採用しており、将来のフィールド追加にも対応できます。

学んだこと

1. 「動いたり動かなかったり」はキャッシュを疑え

エラーの再現性が低い場合、キャッシュが関係していることが多いです。特に「順序によって結果が変わる」場合は、ほぼ確実にキャッシュの問題です。

2. OSS への貢献は思ったより簡単

「Issue を立てる → PR を出す」という流れは、思っていたよりもシンプルでした。重要なのは以下の点です。

  • 再現手順を明確にする: 「こうすると壊れる」を具体的に
  • 原因を特定する: 推測ではなく、コードレベルで
  • テストを書く: 修正が正しいことを証明する

3. マルチコネクター構成は珍しい

調査の過程で気づいたのですが、Firebase Data Connect でマルチコネクター構成を使用している事例は非常に少ないようです。そのため、このバグが長期間発見されずにいたのでしょう。

まとめ

今回の経験を通じて、以下のことを実感しました。

  1. 本番環境でしか発現しないバグは存在する
  2. OSS のソースコードを読むスキルは重要
  3. バグを見つけたら報告・修正することで、コミュニティに貢献できる

Firebase Data Connect を本番で使用している方は少ないかもしれませんが、もしマルチコネクター構成を検討しているなら、この問題に注意してください。PR がマージされるまでは、ワークアラウンドを使用することをおすすめします。


参考リンク