非数値のenv変数はNaNとして??をすり抜け、embeddingが0件・fetchが即abortになった
※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。
結論
raw ? Number.parseInt(raw) : undefined という書き方はtruthyチェックしかしておらず、非数値の env 変数が生む NaN は後段の ?? 既定値も nullish しか弾かないためそのまま素通りする。
結論
raw ? Number.parseInt(raw) : undefined という書き方はtruthyチェックしかしておらず、非数値の env 変数が生む NaN は後段の ?? 既定値 も nullish しか弾かないためそのまま素通りする。 2つのジョブでこのパターンが使われており、片方は embedding を1件も生成しない無言失敗になり、もう片方は setTimeout(abort, NaN) が実質0msで発火し続けて全 fetch が即中断されていた。
症状
バッチジョブ環境で2つの独立した不具合が同時に見つかった。
1本目は embedding 生成ジョブ。EMBED_BATCH_SIZE という env 変数でバッチサイズを制御しているが、この値が非数値だと分割ループが1周も回らず、embedding が0件しか生成されない。エラーは出ない。ジョブは正常終了したように見えるが、実際には何も処理していない。
2本目は鉄道遅延情報を取得するジョブ。RAILWAY_FETCH_TIMEOUT_MS という env 変数で fetch のタイムアウトを制御しているが、この値が非数値だと fetch が開始した直後に abort される。データは恒久的に stale なまま更新が止まる。
どちらも env 変数の値そのものが不正という単純な話ではない。問題は「非数値の env 変数を、コードがどう解釈したか」にあった。
原因
embedding ジョブのエントリーポイントは、こう書かれていた。
const batchSizeRaw = env.EMBED_BATCH_SIZE;
const config: RunEmbeddingBatchConfig = {
// ...
batchSize: batchSizeRaw ? Number.parseInt(batchSizeRaw, 10) : undefined,
};
鉄道ジョブのエントリーポイントも同型だった。
const timeoutRaw = env.RAILWAY_FETCH_TIMEOUT_MS;
const config: RunRailwayFetchConfig = {
// ...
timeoutMs: timeoutRaw ? Number.parseInt(timeoutRaw, 10) : undefined,
};
raw ? Number.parseInt(raw) : undefined という三項演算子は、一見すると「未設定なら既定値に任せる」ための安全な書き方に見える。しかしこの ? が見ているのは raw のtruthy性であって、パース結果の妥当性ではない。EMBED_BATCH_SIZE="abc" のような非空の非数値文字列は truthy なので Number.parseInt("abc", 10) が実行され、その戻り値である NaN がそのまま batchSize に代入される。ガードをすり抜けているのは「未設定」のケースだけで、「設定されているが不正」なケースは素通りする。
この NaN は、次の層でも止まらなかった。
embedding 側は embed-content.ts で受け取った batchSize をこう処理する。
const batchSize = Math.max(1, Math.trunc(options.batchSize ?? 32));
options.batchSize が NaN の場合、NaN ?? 32 は NaN を返す。?? は左辺が null または undefined のときだけ右辺を返す演算子であり、NaN はそのどちらでもないため素通しする。結果として Math.trunc(NaN) も Math.max(1, NaN) も NaN になり、batchSize が NaN のまま分割ループに渡る。ループ条件が満たされないため1周も回らず、embedding は0件のまま処理が終わる。
鉄道ジョブ側は run.ts の fetchMeitetsuStatus でこう処理する。
const timeoutMs = config.timeoutMs ?? 10_000;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
ここも同じ理由で config.timeoutMs が NaN のまま timeoutMs に流れる。setTimeout(callback, NaN) は仕様上ほぼ即座に実行されるため、AbortController が呼び出しの直後に abort() を発火し、全ての fetch が開始と同時に中断され続ける。
2箇所とも同じ構造の欠陥だった。「未設定を弾くガード」と「値の妥当性を検証するガード」を1つの条件式で兼任させると、非数値という第三の状態がどちらのガードにも引っかからずに通過する。
直し方
optionalIntEnv というヘルパーを追加し、Number.parseInt の結果を Number.isFinite で検証してから返すようにした。
function optionalIntEnv(name: string): number | undefined {
const raw = env[name];
if (!raw) return undefined;
const n = Number.parseInt(raw, 10);
return Number.isFinite(n) && n > 0 ? n : undefined;
}
呼び出し側は batchSizeRaw ? Number.parseInt(batchSizeRaw, 10) : undefined を optionalIntEnv("EMBED_BATCH_SIZE") に置き換えるだけで、非数値なら確実に undefined になり、既定値(32 / 10秒)に落ちる。
ただしエントリーポイントを直しただけでは、埋め込みロジックや run.ts に直接 NaN が渡る経路(テストや他の呼び出し元)を防げない。そのため受け取り側にも Number.isFinite のガードを足した。
// embed-content.ts
const rawBatch = Math.trunc(options.batchSize ?? 32);
const batchSize = Number.isFinite(rawBatch) ? Math.max(1, rawBatch) : 32;
// run.ts
const timeoutMs = Number.isFinite(config.timeoutMs) ? (config.timeoutMs as number) : 10_000;
エントリーポイントで防ぐのが本筋だが、受け取り側でも ?? を Number.isFinite に置き換える多層防御にすることで、どちらか片方の層が将来また同じ三項演算子のパターンで書き直されても、もう片方の層で NaN を止められるようにした。
再発防止
修正コミットには回帰テストを追加し、batchSize=NaN を渡しても全件が embedding されること、timeoutMs=NaN を渡しても即 abort しないことを固定した。env から読んだ値をそのまま数値として扱う箇所は他にも存在しうるため、「?? は NaN を弾かない」という事実そのものをコードのコメントとして残し、次に同種の env パース処理を書く人が同じ三項演算子のパターンを再現しないようにしている。
環境変数の既定値まわりでは、環境変数の既定値が staging バケットのままだと、destroy で本番画像が消える でも「既定値に落ちる条件」自体の設計ミスを扱った。あの記事は既定値の中身(どの環境を指すか)が事故の原因だったが、今回は既定値に落ちる条件判定(truthyチェックと nullish coalescing のどちらも NaN を弾かない)そのものが漏れていた。env 変数を数値として扱うコードは、値の中身だけでなく「非数値だったときにどのガードで止まるか」まで確認したほうがいい。
よくある質問
Q1raw ? Number.parseInt(raw) : undefined の何が問題なのですか?
raw が空文字や未設定なら undefined になります。しかし raw が「abc」のような非空の非数値文字列だと truthy チェックを通過し、Number.parseInt(raw) の結果である NaN がそのまま返ります。呼び出し側は NaN を有効な数値として受け取ってしまいます。
Q2?? の既定値はなぜ NaN を防げないのですか?
?? は左辺が null または undefined のときだけ右辺の既定値を返す演算子です。NaN は null でも undefined でもないため nullish coalescing の対象にならず、config.timeoutMs ?? 10_000 のような式は NaN をそのまま素通りさせます。
Q3この不具合はどうやって直しましたか?
optionalIntEnv というヘルパーを追加し、Number.parseInt の結果を Number.isFinite で検証してから返すようにしました。呼び出し側の embed-content.ts と run.ts でも Number.isFinite でガードする多層防御にし、既定値をすり抜けても最終防衛線で弾けるようにしました。
確認した環境
- TypeScript ^6.0.3(apps/jobs)
- 2026-06-16 の修正コミット時点
この記事の根拠
- TypeScriptファイル 84〜93行目コミット f0a7543
- TypeScriptファイル 30〜65行目コミット f0a7543
- TypeScriptファイル 31〜63行目コミット f0a7543
- TypeScriptファイル 78〜86行目コミット f0a7543
本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。