Rebounder Tech Blog

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

Promise.allで同じ行を並行replace-saveするとlast-writer-winsで消える

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

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

結論

同じ(クラス,日付,セクション)行に対する並行replace-saveはPromise.all内でlast-writer-winsになり、後から解決した書き込みが先の書き込みを上書きして消す。

結論

AIが複数日分の予定をまとめて返す編集ターンで、仕様に反して当日(基準日)を「当日1日分(top-level)」と「複数日まとめ(days)」の両方に出すと、同じ(クラス, 日付, セクション)の行に対する2つのreplace-save系Server ActionがPromise.allの中で並行実行され、後にコミットが解決した方が先に解決した方をlast-writer-winsで上書きして消す。 直し方は、top-levelがどのセクションを書くかを先に確定させ、daysループ側で当日かつ同じセクションのぶんだけ書き込みをスキップするガードを足すこと。

症状になりうる状態

対象はAIチャットで学級・学年の予定盤面を編集するEditorChatコンポーネントのonApply。1回の反映で、当日1日分(d)と、複数日にまたがる追加分(days)の両方を1つのops配列にまとめ、最後にPromise.all(ops)でまとめて保存する構造になっている。

修正前のコードは、当日分の書き込みとdaysループの書き込みを完全に独立して組み立てていた。

const ops: (ReturnType<typeof setScheduleAction> | null)[] = [
  willWriteSection("schedules", d, board, allowed, additiveCurrentDay)
    ? setScheduleAction(scope, targetId, date, d.schedules)
    : null,
  willWriteSection("notices", d, board, allowed, additiveCurrentDay)
    ? setNoticesAction(
        scope,
        targetId,
        date,
        preservePinnedNotices(pinnedNotices, date, d.notices),
      )
    : null,
  willWriteSection("assignments", d, board, allowed, additiveCurrentDay)
    ? setAssignmentsAction(scope, targetId, date, d.assignments)
    : null,
];
// 複数日まとめ(days): 各日の非空セクションのみを、その日付へ置換保存する。
for (const day of days) {
  if (day.schedules.length > 0) {
    ops.push(setScheduleAction(scope, targetId, day.date, day.schedules));
  }
  if (day.notices.length > 0) {
    ops.push(
      setNoticesAction(
        scope,
        targetId,
        day.date,
        preservePinnedNotices(pinnedNotices, day.date, day.notices),
      ),
    );
  }
  if (day.assignments.length > 0) {
    ops.push(setAssignmentsAction(scope, targetId, day.date, day.assignments));
  }
}
const results = await Promise.all(ops);

仕様上、複数日まとめターンのdaysには当日(基準日date)と同じ日付を含めてはいけない。当日ぶんは前段のops(top-level)が担当するはずだった。だがAIの出力がこの仕様を守らず、当日と同じ日付のエントリをdays側にも混ぜて返すことがある。すると、たとえば「連絡(notices)」というセクション1つに対して、top-level由来のsetNoticesAction(scope, targetId, date, ...)と、daysループ由来のsetNoticesAction(scope, targetId, day.date, ...)day.date === date)という、まったく同じ引数の宛先を持つ2つのServer Actionが同じops配列に積まれる。

原因

この2つのActionはPromise.all(ops)の中で並行に発火する。どちらも同じ(クラス, 日付, セクション)の行を対象にしたreplace-save——既存の内容を読まずに指定した内容へ丸ごと置き換えるUPSERT——であり、Promise.allは各Promiseの実行順序も完了順序も保証しない。2つのUPSERTのうち後にDB側の書き込みが確定した方が、その行を自分の値で丸ごと上書きする。先に確定していたもう一方の書き込みは、跡形もなく消える。

これはアプリケーション側のエラーにもDBの制約違反にもならない。どちらのActionも単体では正しく成功しており、results.some((r) => r !== null && !r.ok)によるエラー検出もすり抜ける。盤面を開き直したときに初めて「該当セクションだけ意図と違う内容になっている」という形で気づく。

直し方

top-levelが実際にどのセクションを書くかを、daysループより先に一度だけ確定させ、days側では「当日と同じ日付」かつ「top-levelが既に書くセクション」の組み合わせだけを明示的にスキップするようにした。

const topLevelWrites = {
  schedules: willWriteSection("schedules", d, board, allowed, additiveCurrentDay),
  notices: willWriteSection("notices", d, board, allowed, additiveCurrentDay),
  assignments: willWriteSection("assignments", d, board, allowed, additiveCurrentDay),
};
const ops: (ReturnType<typeof setScheduleAction> | null)[] = [
  topLevelWrites.schedules ? setScheduleAction(scope, targetId, date, d.schedules) : null,
  // notices / assignments も同様に topLevelWrites.* を参照する形へ変更
];
for (const day of days) {
  const isCurrentDate = day.date === date;
  if (day.schedules.length > 0 && !(isCurrentDate && topLevelWrites.schedules)) {
    ops.push(setScheduleAction(scope, targetId, day.date, day.schedules));
  }
  if (day.notices.length > 0 && !(isCurrentDate && topLevelWrites.notices)) {
    ops.push(/* ... */);
  }
  if (day.assignments.length > 0 && !(isCurrentDate && topLevelWrites.assignments)) {
    ops.push(setAssignmentsAction(scope, targetId, day.date, day.assignments));
  }
}

top-levelが空でそのセクションを書かない場合(=仕様どおり、複数日まとめの中に当日ぶんが正しくdays側だけに入っているケース)は、topLevelWrites.*falseになるためガードには引っかからず、従来どおりdays側がそのまま書く。データが欠落するのは「top-levelとdaysの両方が同じセクションを書こうとした」場合だけなので、この条件分岐は該当ケースだけを狙い撃ちできる。

同じ(クラス, 日付, セクション)への書き込みを2本立てでPromise.allに積まないという不変条件を、配列を組み立てる時点で1箇所に集約したことになる。

再発防止

このコミットには、同時に直された別のバグ(ニュース取得のURL重複によるON CONFLICT失敗)にはリグレッションテストが追加されているが、このtop-level/days二重書き込みガードそのものにテストが追加された記述は根拠に無い。

見つかりにくかった理由は、単体のAction(setNoticesActionなど)を単独で呼ぶテストでは再現しないことにある。この事故は「AIの出力が仕様(当日をdaysに含めない)を破ったとき、ops配列の組み立て側がそれを検知せず、結果として同じ宛先へ2本のPromiseを積んでしまう」という、複数のPromiseの組み合わせが引き起こす。個々のServer Actionは仕様どおりに動作しており、壊れているのはそれらを束ねるops配列の構築ロジック側だった。

よくある質問

Q1本番で実際に盤面が化けたのですか?

根拠のコミットメッセージに本番障害の報告は無く、「バグ探索スイープ」という3件まとめのコミットの一部として直されている。実際に盤面が化けたという事故報告は見当たらない。

Q2なぜこの二重書き込みが起きるのですか?

AIが複数日まとめて予定を返すとき、仕様上は当日を含めてはいけないdaysの中に、仕様違反で当日と同じ日付のエントリを混ぜて出すことがある。当日はtop-level処理でも別に書き込むため、同じ(クラス,日付,セクション)行に対する2つのreplace-saveがPromise.all内で並行実行される。

Q3なぜ後勝ちが先勝ちを消すのですか?

setScheduleAction等のreplace-save系アクションは指定した日付・セクションの内容を丸ごと置換するupsertのため、2回目の書き込みが完了すると1回目の内容は跡形もなく上書きされる。Promise.allは実行順序を保証しないため、どちらが後勝ちになるかは非決定的。

Q4この修正にはテストが追加されていますか?

同じコミットに含まれる3件の修正のうち、フィードURL重複除去にはリグレッションテストが追加されたが、このtop-level/days二重書き込みガードにテストが追加された記述は根拠に無い。

確認した環境

  • Next.js ^16.0.0 / React ^19.0.0
  • 2026-07-13 マージ(#1295「バグ探索スイープ3件」の3件目・本番障害の報告なし)

この記事の根拠

  • TypeScriptファイル 374〜411行目コミット 0da560c
  • TypeScriptファイル 375〜421行目コミット 97c43ca

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