Rebounder Tech Blog

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

GCSのifGenerationMatchでautoRetryの412を「送信済み」と誤読する

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

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

結論

GCSのifGenerationMatch: 0によるclaim処理は、クライアントの自動再試行が有効だと、応答だけ失われた成功を412 Precondition Failedとして受け取り直し、「既に送信済み」と誤読する。

結論

GCSオブジェクトの ifGenerationMatch: 0 で「今日はまだ送っていない」を一意に判定する処理は、クライアントの自動再試行が有効だと、1回目の書き込みがサーバ側で成功したのに応答だけ失われた場合、再試行が受け取った412 Precondition Failedを「既に送信済み」と誤読する。new Storage({ retryOptions: { autoRetry: false } }) で自動再試行そのものを止め、失敗はthrowさせて次の実行でclaimし直す方式に変えた。

症状

平日朝の「✅ TV監視稼働中」ダイジェストをSlackへ1日1回だけ送るJobがある。毎分起動し、窓(平日08:30〜09:29 JST)の中で最初に成功した回だけが送る設計で、「今日はもう送った」の記録はGCSの空オブジェクト tv-liveness/digest/<YYYY-MM-DD> に置いていた。

export async function decideDailyDigest(
  now: Date,
  marker: DigestMarkerPort | null,
): Promise<DigestDecision> {
  if (!isDailyDigestWindow(now)) return { send: false };
  const day = jstDay(now);
  if (marker === null) {
    return isDailyDigestMinute(now) ? { send: true, day, claimed: false } : { send: false };
  }
  try {
    return (await marker.claim(day)) ? { send: true, day, claimed: true } : { send: false };
  } catch (err) {
    console.warn(
      JSON.stringify({
        event: "tv.health_check.digest_marker_error",
        message: err instanceof Error ? err.message : "unknown",
      }),
    );
    return isDailyDigestMinute(now) ? { send: true, day, claimed: false } : { send: false };
  }
}

記録の作成は ifGenerationMatch: 0(オブジェクトが存在しないときだけ作成)で原子的に行っているので、毎分の実行や多少の再試行が重なっても送るのは1回だけになるはずだった。ところがネットワークが一過性に不安定な日に、このダイジェストが届かないことがあった。GCS側にはオブジェクトが作られているので「送信済み」の記録は残っているのに、実際にはSlackへは一度も送られていない。

原因

claim() は、書き込みが失敗したとき412 Precondition Failedだけを「既に今日の記録がある=送信済み」として握り潰す作りだった。

export function createGcsDigestMarker(bucketName: string): DigestMarkerPort {
  const bucket = new Storage().bucket(bucketName);
  const objectFor = (day: string) => bucket.file(`tv-liveness/digest/${day}`);
  return {
    async claim(day) {
      try {
        await objectFor(day).save("", {
          resumable: false,
          contentType: "text/plain",
          preconditionOpts: { ifGenerationMatch: 0 },
        });
        return true;
      } catch (err) {
        // 412 Precondition Failed = 既に今日の記録がある(=送信済み or 送信中)。
        if ((err as { code?: number }).code === 412) return false;
        throw err;
      }
    },
    async release(day) {
      await objectFor(day).delete({ ignoreNotFound: true });
    },
  };
}

new Storage() は既定でクライアント側の自動再試行が有効になっている。1回目の save() がサーバ側では成功していても、その成功レスポンスだけがネットワークの問題でクライアントに届かなければ、クライアントは「応答が無かった」と判断して同じ書き込みをもう一度自動で送る。2回目の書き込みは、1回目で既にオブジェクトが作られているため ifGenerationMatch: 0 の条件を満たせず、412を受ける。

claim() はこの412を無条件に「既に今日の記録がある」と解釈するので、false を返す。decideDailyDigest はこの false を「今回は送らなくていい(他の回が送った)」という意味で扱うため、send: false となってSlackへは送信されない。しかし実際には1回目の書き込みがサーバ側で成功した時点でオブジェクトは存在し続けているので、その日はもう誰もclaimできず、ダイジェストはその日限り永久に送られなくなる。応答が1回失われただけの一過性の事故が、1日分のダイジェストを丸ごと失わせていた。

直す

Storage クライアントの自動再試行を切り、応答が失われた場合は例外をそのままthrowさせるようにした。

/** GCS 実装。認証は Workload Identity(ADC)、鍵ファイルは使わない(ルール5)。 */
export function createGcsDigestMarker(bucketName: string): DigestMarkerPort {
  // 自動再試行は切る: 1 回目がサーバ側で成功し応答だけ失われると、再試行が 412 を受けて「送信済み」と誤読し
  // その日のダイジェストが出なくなる。失敗は throw → 次の分で claim し直す方が安全。
  const bucket = new Storage({ retryOptions: { autoRetry: false } }).bucket(bucketName);
  const objectFor = (day: string) => bucket.file(`tv-liveness/digest/${day}`);
  return {
    async claim(day) {
      try {
        await objectFor(day).save("", {
          resumable: false,
          contentType: "text/plain",
          preconditionOpts: { ifGenerationMatch: 0 },
        });
        return true;
      } catch (err) {
        // 412 Precondition Failed = 既に今日の記録がある(=送信済み or 送信中)。
        if ((err as { code?: number }).code === 412) return false;
        throw err;
      }
    },
    async release(day) {
      await objectFor(day).delete({ ignoreNotFound: true });
    },
  };
}

claim() 自身の412判定は変えていない。変えたのは「応答が失われたときに自動でもう一度同じ書き込みを送る」という、クライアントライブラリ内部の再試行だけを止めたことだ。これにより、応答喪失はそのまま例外として decideDailyDigest のcatchまで伝わり、既存の「この回は記録に失敗したので、窓内であれば単発判定で送る」というフォールバックに乗る。毎分起動するJob自体が次の分でもう一度claimし直すので、再試行の責任はクライアントライブラリではなく外側のスケジューラが持つ形になった。

再発防止

この事故に対する具体的な追加対策は根拠にはなく、書けるのは「なぜこの誤読に気づきにくかったか」という構造だけだ。

自動再試行が有効なままだと、応答喪失から412への経路は claim() 内部の catch で完全に捕捉され、false を返すだけで処理が終わる。decideDailyDigest 側の catch(tv.health_check.digest_marker_error を console.warn するログ経路)には一度も入らないため、外から見えるログには「この回はsend: falseだった」という通常の判定結果しか残らない。応答喪失という一過性の事故と、本当に別の回が先に送信済みだったという正常なケースが、ログ上では区別できなかった。

自動再試行を切ったことで、応答喪失はクライアント内部で握り潰されずに例外としてthrowされるようになり、既存の digest_marker_error ログ経路に乗る。一過性の応答喪失と正常な「既に送信済み」を、追加の監視を増やさずに既存のログの中で区別できるようになった。

よくある質問

Q1なぜ自動再試行が有効だと412を誤読するのですか?

ifGenerationMatch: 0での書き込みは、1回目がサーバ側で成功していても、その成功レスポンスだけがクライアントに届かないことがある。クライアントライブラリの自動再試行は応答が来なかったという理由で同じ書き込みをもう一度送り、今度はオブジェクトが既に存在するため412 Precondition Failedを受ける。claim()はこの412を『既に今日の記録がある=送信済み』と判定するコードだったため、サーバ側は1回目で成功していたのに2回目の412だけを見て『もう送った』と誤読していた。

Q2応答が失われるのはどういうときですか?

ネットワークが一過性に不安定なとき、GCSへの書き込みリクエスト自体はサーバ側に届いて処理が完了しているのに、その成功レスポンスだけがクライアントに返ってこない場合に起きる。クライアントからは『送ったのに返事が無い』状態に見えるため、既定で有効な自動再試行が同じ書き込みをもう一度送ってしまう。

Q3直し方は何を変えたのですか?

new Storage()にretryOptions: { autoRetry: false }を渡し、クライアント側の自動再試行そのものを止めた。応答が失われた場合は例外がそのままthrowされ、decideDailyDigest側のcatchで捕まって次の分でもう一度claimし直す動きになる。再試行の責任を、クライアントライブラリ内部ではなく毎分起動するJob自体に委ねる形にした。

確認した環境

  • @google-cloud/storage ^7.19.0(apps/jobs/package.json)
  • 2026-10-08 に本番で修正(commit 580f712)

この記事の根拠

  • TypeScriptファイル 74〜97行目コミット 5dfadde
  • TypeScriptファイル 50〜72行目コミット 5dfadde
  • TypeScriptファイル 74〜99行目コミット 580f712

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