Rebounder Tech Blog

運用している当事者が書く、本番システムの記録。

supabase-js の count クエリが失敗してもnullで、監視は自分の異常に気づけなかった

公開 読了時間 約9分執筆: Rebounder 開発チーム(当該システムの運用当事者)

※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。

結論

日次ブリーフの連携ヘルス監視は、収集クエリが失敗してもerrorを見ずにcount・dataのnullフォールバックだけを使っていたため、監視自身の失敗が「異常なし」と区別できなかった。

結論

日次ブリーフの連携ヘルス監視は、監視データを集めるクエリ自体が失敗しても「異常なし」と同じ見た目になっていた。 countdata?? 0 ?? [] でフォールバックし、error を見ずに使っていたためで、真の0件(正常)と収集失敗(監視が効いていない)が区別できなかった。直し方は単純で、各クエリの error を集約し、1件でもあれば他のどの異常よりも先に必ず表示する。

症状(見つかった経緯)

本番で実際にアラートが漏れて発覚したわけではない。堅牢化のためのレビューで、Reviewer が次の点を指摘した。

  • v2 死活・sync_outbox の滞留・素材未紐付け会社を集める日次ブリーフの「連携ヘルス」セクションが、収集クエリ自体の失敗を一切表現できていない
  • クエリが失敗しても例外にはならず、countdatanull になるだけなので、呼び出し側がそれを「0件=異常なし」として扱ってしまう経路がある

このブリーフは「一人運用でダッシュボードを毎日見る人はいない」という前提で、異常があれば能動的に Slack へ出すために作られたものだった。その能動的に出す経路自体が、収集失敗時には「何も出さない」という最悪の形で沈黙する。

原因

buildIntegrationHealthLines は、Slack へ出す行を組み立てる純粋関数として書かれている。

// src/lib/integration-health.ts:30-40(修正前)
export function buildIntegrationHealthLines(
  input: IntegrationHealthInput
): string[] {
  const issues: string[] = [];
  const { senderConfigured, v2Health, outbox, unlinkedDeliveringCompanies } =
    input;

  if (v2Health === "ng") {

型定義にも、収集そのものが失敗したかどうかを表すフィールドが無い。

// src/lib/integration-health.ts:17-26(修正前)
export type IntegrationHealthInput = {
  senderConfigured: boolean;
  v2Health: V2HealthStatus;
  outbox: { pending: number; failed: number; dead: number };
  unlinkedDeliveringCompanies: string[];
};

呼び出し元の日次 cron はこう書かれていた。

// src/app/api/cron/notify/route.ts:234-297(修正前、抜粋)
const [outboxPendingRes, outboxFailedRes, outboxDeadRes] = await Promise.all([
  admin.from("sync_outbox").select("id", { count: "exact", head: true }).eq("status", "pending"),
  admin.from("sync_outbox").select("id", { count: "exact", head: true }).eq("status", "failed"),
  admin.from("sync_outbox").select("id", { count: "exact", head: true }).eq("status", "dead"),
]);
const outboxCounts = {
  pending: outboxPendingRes.count ?? 0,
  failed: outboxFailedRes.count ?? 0,
  dead: outboxDeadRes.count ?? 0,
};
// ...
const { data: deliveringCreatives } = await admin
  .from("creatives")
  .select("company_id")
  .in("status", ["配信中", "承認"])
  .not("company_id", "is", null)
  .limit(2000);
// ...
if (deliveringCompanyIds.length > 0) {
  const { data: cos } = await admin
    .from("companies")
    .select("id, name, kimiteras_v2_advertiser_id")
    .in("id", deliveringCompanyIds);
  // ...
}

supabase-js はクエリが失敗しても例外を投げず、{data, error, count} をそのまま返す。この呼び出し方は error を一切受け取っていないため、失敗しても outboxPendingRes.countundefined(→ ?? 00)に、deliveringCreativescosundefined(→ ?? []・空配列扱い)になるだけで、呼び出し側からは「対象が本当に0件だった」場合と見分けがつかない。

そして buildIntegrationHealthLines は、その関数自身のコメントにある通り「0件(=異常なし)のセクションは出さない」という設計になっている。収集クエリが1本でも失敗すると、outbox{pending:0, failed:0, dead:0} に、unlinkedDeliveringCompanies[] に見え、関数から見れば「全部正常」としか判定しようがない。 監視の失敗自体が、監視対象が「異常なし」だったときと完全に同じ入力になっていた。

直し方

型に queryErrors を追加し、収集側で各クエリの error を明示的に拾って集約する。

// src/lib/integration-health.ts:17-31(修正後)
export type IntegrationHealthInput = {
  senderConfigured: boolean;
  v2Health: V2HealthStatus;
  outbox: { pending: number; failed: number; dead: number };
  unlinkedDeliveringCompanies: string[];
  /**
   * ヘルス情報の収集自体に失敗したクエリのエラー(fail-to-green 防止)。
   * 監視データが取れない状態を「異常なし」と区別して必ず表示する。
   */
  queryErrors?: string[];
};
// src/app/api/cron/notify/route.ts:237-320(修正後、抜粋)
const healthQueryErrors: string[] = [];
const [outboxPendingRes, outboxFailedRes, outboxDeadRes] = await Promise.all([/* ... */]);
for (const [label, res] of [
  ["outbox(pending)", outboxPendingRes],
  ["outbox(failed)", outboxFailedRes],
  ["outbox(dead)", outboxDeadRes],
] as const) {
  if (res.error) healthQueryErrors.push(`${label}: ${res.error.message}`);
}
// ...
const { data: deliveringCreatives, error: delivErr } = await admin
  .from("creatives")
  .select("company_id")
  .in("status", ["配信中", "承認"])
  .not("company_id", "is", null)
  .limit(2000);
if (delivErr) healthQueryErrors.push(`creatives: ${delivErr.message}`);
// ...
const { data: cos, error: cosErr } = await admin
  .from("companies")
  .select("id, name, kimiteras_v2_advertiser_id")
  .in("id", deliveringCompanyIds);
if (cosErr) healthQueryErrors.push(`companies: ${cosErr.message}`);
// ...
const healthLines = buildIntegrationHealthLines({
  senderConfigured,
  v2Health,
  outbox: outboxCounts,
  unlinkedDeliveringCompanies,
  queryErrors: healthQueryErrors,
});

受け取った側は、queryErrors が1件でもあれば他のどの異常より先に表示する。

// src/lib/integration-health.ts:38-53(修正後)
export function buildIntegrationHealthLines(
  input: IntegrationHealthInput
): string[] {
  const issues: string[] = [];
  const { senderConfigured, v2Health, outbox, unlinkedDeliveringCompanies } =
    input;
  const queryErrors = input.queryErrors ?? [];

  if (queryErrors.length > 0) {
    const shown = queryErrors
      .slice(0, 2)
      .map((e) => escapeSlack(e))
      .join(" / ");
    issues.push(
      `⚠️ ヘルス情報の取得失敗 ${queryErrors.length}件(以下の検知は不完全な可能性): ${shown}`
    );
  }
  if (v2Health === "ng") {

「先頭に必ず出す」のが要点になる。queryErrors のチェックを他のどの if よりも前に置くことで、収集自体が壊れているときは他の判定結果(v2Healthoutbox の値)も信用できないという事実を、読む人に最初に伝えられる。

一般化できる形

この形は、この監視に限らない。「0件=正常」という判定ロジックと、「クエリ失敗時にnullへフォールバックする呼び出し方」を組み合わせると、必ず同じ穴が空く。

  • supabase-js に限らず、例外を投げずに {data, error} のようなタプルでエラーを返す SDK では、data(や count)だけを見て分岐する前に error を確認する必要がある
  • 「異常があるときだけ出す」という設計の監視・アラートは、監視自体の失敗という第三の状態を必ず持つ。正常と異常の2値だけで設計すると、収集失敗は「正常」側に吸収される
  • 一人運用のように「誰かが能動的にダッシュボードを見て気づく」という前提が成立しない環境では、この吸収がそのまま「監視が壊れていることに誰も気づけない」という結果に直結する

よくある質問

Q1なぜクエリの失敗が「異常なし」に見えてしまうのですか?

ヘルス行を組み立てる関数は「0件(=異常なし)のセクションは出さない」という方針で書かれています。収集側のcountやdataがクエリ失敗時にnullになり、それを`?? 0`や`?? []`でフォールバックしていたため、真の0件と収集失敗の両方が同じ『0』として渡り、監視自身の異常が握りつぶされていました。

Q2supabase-jsのcountクエリはエラー時に何を返しますか?

supabase-jsは例外を投げず`{data, error, count}`をそのまま返します。クエリが失敗すると`count`はnullになりますが、例外にはならないため呼び出し側が`error`を明示的に確認しない限り失敗は伝わりません。`count ?? 0`のようなフォールバックだけを書くと、失敗と真の0件が区別できなくなります。

Q3なぜ一人運用だとこの誤報がより深刻なのですか?

この日次ブリーフ自体が『ダッシュボードを毎日見る人はいないので、異常があれば能動的にSlackへ出す』という設計思想で作られています。監視の失敗が『異常なし』に化けると、能動的に知らせる唯一の経路が沈黙するため、誰にも気づかれないまま監視自体が壊れた状態が続きます。

確認した環境

  • Next.js 16.2.7 / @supabase/supabase-js ^2.106.2 / kimiteras-portal
  • 2026-06-12、機能追加コミットと同日のレビュー指摘対応コミットで修正

この記事の根拠

  • TypeScriptファイル 17〜26行目コミット dd75ddf
  • TypeScriptファイル 30〜40行目コミット dd75ddf
  • TypeScriptファイル 234〜297行目コミット dd75ddf
  • TypeScriptファイル 17〜31行目コミット dbec9f7
  • TypeScriptファイル 38〜53行目コミット dbec9f7
  • TypeScriptファイル 237〜320行目コミット dbec9f7

本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。