複数のWebhookがデッドラインカラムを更新する様子を図示し、その結果データが破損する「BEFORE」状態と、「短縮方向だけを止めるクランプ」によってデータ整合性が保たれる「AFTER」状態を、プログラマーのキャラクターが解説しています。TypeScriptやE2Eテストのアイコンも配置されています。

はじめに

課金システムで、既存ユーザー向けに「90日間は権限を保護する」といった猶予期間を設けることがあります。
この猶予の終了日時を1つのカラムに持たせ、そこへ複数の Webhook ハンドラが書き込む設計にすると、静かに壊れる不具合を生みやすくなります。

実運用しているサービスで、決済失敗の再試行イベントとサブスクリプション更新イベントが、猶予期限のカラムを無条件に上書きしていました。
その結果、本来は90日残っているはずのユーザーの期限が数日〜1週間程度に縮んだり、NULL になって権限が即座に失効したりしました。
この記事では、修正に使った「短縮方向だけを止めるクランプ」のパターンと、既に壊れた行の復旧まで含めた進め方を紹介します。

こんな人におすすめ

  • Stripe などの Webhook を複数ハンドラで処理している方
  • 1つのカラムを複数の経路から更新する設計に不安がある方
  • Webhook の再送やリトライで状態が壊れた経験がある方
  • 「if で保護中なら何もしない」で済ませてよいのか迷っている方

何が起きていたのか

各ハンドラは単体では正しく動いていました。
決済失敗ハンドラは「次の再試行日、または7日後」を猶予終了日として書き、更新ハンドラは「Stripe 側の期間終了日」を書きます。
問題は、どちらも「このユーザーは猶予保護中かどうか」を見ていなかったことです。

保護開始: 10/01(保護期限は 12/30 のはず)
10/05 決済失敗イベント → 期限を 10/12 に上書き
10/06 更新イベント     → 期限を NULL に上書き → 権限失効

複数のイベントソースが同じ列を書く設計では、暗黙の優先順位が生まれます。
個々のロジックが正しくても、「他のハンドラが設定した業務上重要な状態を守る」という観点は抜け落ちやすいです。

修正方針:上書きを禁止せず、短縮だけをクランプする

最初に思いつくのは次のような早期 return です。

if (isProtected) return // 保護中は書き込まない

しかしこれだと、Stripe 側がより遅い期限を渡してきた正当な延長まで止めてしまいます。
そこで「保証値より手前に縮む場合だけ、保証値に引き上げる」クランプ関数を、書き込みの直前に挟みました。

async function clampProtectedDeadline(
  db: AppDatabase,
  subscriptionId: string,
  proposedDeadline: string | null,
): Promise<string | null> {
  if (!(await hasProtectionColumns(db))) return proposedDeadline

  const row = await findProtectionState(db, subscriptionId)
  if (!row) return proposedDeadline
  if (!isProtectedFlagSet(row.is_protected)) return proposedDeadline
  if (!row.protection_started_at) return proposedDeadline

  const guaranteed = computeGuaranteedDeadline(row.protection_started_at)
  const proposedMs = proposedDeadline ? Date.parse(proposedDeadline) : Number.NaN
  if (Number.isFinite(proposedMs) && proposedMs > Date.parse(guaranteed)) {
    return proposedDeadline // より遅い期限は尊重する
  }
  return guaranteed
}

ポイントは3つあります。

  • 保護フラグが立っていない行は、入力をそのまま返します
  • 保護の起算点が NULL の行も素通しします。「まだ保護が始まっていない」と「保護中」を混同すると、初回のフラグ付与タイミングで別の不具合を作り込むためです
  • 提案値が保証値より遅ければ、そのまま採用します

呼び出し側の型の落とし穴

クランプ関数の戻り値は string | null です。
入力が非 null だったケースでは、フォールバックを残す必要がありました。
「保護なしなら入力をそのまま返す」という約束は、型だけでは表現しきれません。

const proposedDeadline = nextAttempt ?? new Date(Date.now() + SEVEN_DAYS_MS).toISOString()
const graceEnd =
  (await clampProtectedDeadline(db, subscriptionId, proposedDeadline)) ?? proposedDeadline

あわせて、同じカラムへの書き込み経路を横断的に洗い出しました。
今回は決済失敗ハンドラと更新ハンドラの2か所でしたが、1箇所だけ直して終わりにしていたら再発していたはずです。
一方で、返金による契約の即時失効のように「本当に今すぐ失効させたい」操作は、意図的にクランプの対象外にしました。
全ての書き込みを一律に止めると、必要な操作まで妨げてしまいます。

既に壊れた行の復旧とテスト

この種の修正が守るのは、今後の書き込みだけです。
修正前に短縮・NULL化された行は自然には直りません。
そこで、「保護中のはずなのに保証値より手前になっている行」を探すクエリを runbook に残しました。

SELECT id, status, protection_started_at, protected_deadline
FROM subscriptions
WHERE is_protected = 1
  AND protection_started_at IS NOT NULL
  AND (protected_deadline IS NULL
       OR protected_deadline < protection_started_at + interval '90 days');

テストでは、単発のユニットテストに加えて、E2E で「同じ種類のイベントが時間差で複数回届いても状態が壊れないこと」をシナリオ化しました。
Webhook の再送やリトライによる累積的な破壊は、1回の呼び出しを検証するだけでは見つかりません。

つまづきやすいポイント

  • ハンドラ単体のレビューだけでは見つからない:個々のコードは正しく見えるので、「この列を誰が書くか」を一覧化して初めて気づけます
  • early return で済ませると延長まで止まる:守りたいのは下限であり、更新そのものではありません
  • 起算点が未確定の行の扱い:NULL を保護中として扱うか素通しにするかを、先に決めておきます
  • 修正後も過去の被害は残る:復旧クエリと手順を、修正と同時に用意しておくと安心です

まとめ

複数の Webhook が同じ列を書く設計では、ハンドラごとの正しさとは別に、列単位の不変条件を守る仕組みが必要です。
「上書き禁止」ではなく「短縮方向だけをクランプする」ことで、保護と正当な延長を両立できました。
書き込み経路の洗い出し、対象外ケースの明示、既存の壊れた行の復旧、再送を想定したE2Eテストまでを1セットで進めるのが目安です。