マガジン

たまに失敗する処理の原因を探す:AI製コードのawait漏れと実行順の確認

AIを使いこなす

Claudeで執筆し、GPTで自動校閲・修正しています。

保存処理と結果確認の順序をメモで追っている机上の様子
記事の内容をもとに制作したAI生成イメージです。実際の製品画面や検証結果の写真ではありません。

AIが書いたプログラムが「動く日もあれば失敗する日もある」とき、非同期処理の完了待ち(await)の抜けは最初に疑う価値があります。見分けるコツは二つです。①Promiseを返す関数の呼び出しにawaitもreturnも付いていない箇所を探す。②処理の「開始」と「完了」を連番つきでログに出し、後続の処理が完了前に走っていないかを見る。この記事では、この二つを具体的に示します。 なぜ「たまに」なのか。awaitのない呼び出しは、処理の完了を待たずに次の行へ進みます。MDNでも、awaitを付けない場合はPromiseオブジェクトがすぐ返される、と説明されています(await - MDN)。待たなかった処理が後続より先に終われば成功し、後に終われば失敗します。通信やディスクの所要時間はそのつど変わるので、結果も日によって変わります。

この記事の目次
  1. 典型パターンの見分け方
  2. 実行順を見るログの入れ方
  3. AIに任せる範囲と、人が確認する範囲
  4. 直したあとの確認

典型パターンの見分け方

AIが書いたコードで、次の形を探してください。コード全体を読まなくても、検索と目視でかなり絞れます。

  • saveUser(user); のように呼び出しだけの行、何が起きるか:完了を待たず次へ進む、見つけ方:関数名がsave/fetch/load/sendなどで、行頭にawaitがない
  • items.forEach(async (x) => { await ... })、何が起きるか:forEachは非同期コールバックを待たない、見つけ方:forEach(asyncを検索
  • if (isValid(x)) でisValidがasync、何が起きるか:Promiseオブジェクト自体は真なので、解決後の真偽値を判定できない、見つけ方:async functionの名前を一覧し、ifや&&の中での使用を探す
  • try { fetchData(); } catch (e) {}、何が起きるか:失敗がcatchに入らない、見つけ方:try内にPromiseを返す呼び出しがawaitなしである
  • .then(...)の中で結果をreturnしていない、何が起きるか:次の.thenに値が渡らず、完了待ちも切れる、見つけ方:.then(の直後のブロックにreturnがない

forEachについては、MDNが「同期関数を想定しており、Promiseを待たない」と明記し、forEach内の非同期処理の後に値を読むと更新前の値が出る例を載せています(Array.prototype.forEach() - MDN)。順番に処理したいならfor...ofの中でawait、同時に走らせて全部待つならawait Promise.all(items.map(...))、という置き換えが基本です。

エラーが静かに消える、あるいはプロセスが落ちる症状も手がかりです。Node.jsは、拒否(reject)されたPromiseに処理が付いていないとunhandledRejectionイベントを発生させます。未処理の拒否を例外として扱うかはNode.jsの起動設定で変わり、現在の既定では例外として扱われます(process - Node.js)。「たまに」プロセスごと落ちるなら、待たれずに失敗したPromiseが残っている可能性があります。実行環境やフレームワークが独自に捕捉している場合は挙動が違うため、ご自身の環境のNodeのバージョンと起動設定も確認してください。

実行順を見るログの入れ方

原因がawait漏れかどうかは、推測より順番を見るほうが早く決着します。ポイントは、開始と完了の両方を、連番と経過時間つきで出すことです。

以下は説明用の例です。実際に実行して得た出力ではなく、コードを読んだ上での想定を後ろに書いています。

js
// 説明用の例:await漏れを再現する小さなコード
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
let seq = 0;
const t0 = Date.now();
const log = (msg) => console.log(`#${++seq} +${Date.now() - t0}ms ${msg}`);

const db = [];

async function saveUser(user) {
  log(`save 開始 id=${user.id}`);
  await sleep(Math.random() * 100); // 保存にかかる時間が毎回違う想定
  db.push(user);
  log(`save 完了 id=${user.id}`);
}

async function main() {
  saveUser({ id: 1 });      // ← await漏れ
  await sleep(50);          // 別の処理を挟んだつもり
  log(`件数を確認 count=${db.length}`);
}

main();

保存は0〜100ミリ秒のどこかで終わり、件数確認は約50ミリ秒後に走ります。したがって、この例ではsave 完了が件数を確認より前に出る場合(count=1)と、後に出る場合(count=0)があります。これは説明用コードから予測できる順序の違いです。saveUser({ id: 1 });をawait saveUser({ id: 1 });にすると、save 完了は件数確認より先に出ます。

ログの読み方

  • 開始の連番が続き、完了が後ろに回っていれば、待たずに進んでいます。
  • 同じコードを5〜10回実行し、「完了」と「その結果を使う行」の前後が入れ替わる回があれば、完了待ちの抜けが疑われます。前後が入れ替わらなくても、回数が少ないだけでは原因を除外できません。
  • 毎回同じ順で失敗するなら、別の原因(値の誤りなど)を先に疑ってください。

ログは「開始」「完了」「結果を使う直前」の3か所で十分です。全行に入れると順番が埋もれます。ログに入れる値はIDや件数にとどめ、本物のトークンや個人情報は出さないでください。

AIに任せる範囲と、人が確認する範囲

任せやすいのは、怪しい箇所の洗い出しとログの挿入です。人が決めるべきなのは、順番に処理するか同時に処理するか、失敗したとき止めるか続けるかです。ここはAIに丸投げすると、動くだけの直し方になりがちです。

AIに渡す依頼文の例です(コードは機密を除いて貼ってください)。

次のNode.jsコードは、成功する日と失敗する日があります。
await漏れを疑っています。まだ修正はせず、次の順で答えてください。
1. Promiseを返す関数の呼び出しで、await/return/.thenのいずれもない行を行番号つきで列挙
2. forEach(async ...)、if内のasync関数、try内のawait無し呼び出しがあれば別に列挙
3. 各候補に「開始」「完了」「結果を使う直前」のconsole.logを入れる案(連番と経過ミリ秒つき)
4. 直し方は「順番に」「同時に」の2案を並べ、私が選べるように違いを説明
ログ以外のコードは変更しないでください。

[ここにコード]

「まだ修正しない」「ログ以外は変更しない」と書くのが肝心です。原因の特定前に直されると、直ったのか偶然通ったのか区別できなくなります。

直したあとの確認

  1. ログ入りのまま、修正前と同じ回数(5〜10回)実行し、「完了」が「結果を使う直前」より常に前に出るか見ます。
  2. 失敗を起こす条件を人為的に作ります。上の例ならsleepの時間を最大値に固定します。通信なら遅い回線や遅延を入れた状態で試すのが有効です。たまたま通らない状態を作れると、再発を確認できます。
  3. awaitを足した結果、処理が直列になって遅くなっていないか確認します。順番が不要な繰り返しはPromise.allで同時実行にできますが、同時に走らせると相手先の制限や書き込みの競合が出ることもあるため、件数が多い処理は人が判断してください。
  4. 確認用のログは、不要なら削除してからコミットします。

ここまでで順序が揃っても失敗が続く場合は、await漏れ以外(外部サービスの一時的な不調、共有データの同時更新など)に原因があります。その場合も、先ほどのログが「どこまで進んだか」の記録として次の調査に使えます。

出典・参考資料