--- BODY ---
バッチ処理の入力検証で throw すると、1件の不備が全体停止になる
複数の入力ファイルを処理するバッチで、入力検証に throw を使っていました。
その結果、次のような不備が混ざったときに、処理全体が途中で止まりました。
- 指定された入力ファイルが存在しない
- フラットな staging ディレクトリへコピーするとき、別ディレクトリ由来の同名ファイルが衝突する
実作業では、この種の入力不備で自動処理の約69%が失敗しました。問題は「不備を検出したこと」ではなく、「検出した瞬間に throw して、残りの正常な入力まで巻き添えにしたこと」でした。
この記事では、小さな Node.js の例で再現し、バッチ処理ではどこで throw し、どこで結果として回収するべきかを整理します。
再現するケース
次のような入力を考えます。
input/
a/
card.json
b/
card.json
c/
note.md
a/card.json と b/card.json は別ディレクトリにありますが、basename はどちらも card.json です。
これを次のようなフラットな staging に集めるとします。
staging/
card.json
note.md
この設計では、a/card.json と b/card.json のどちらか一方しか staging/card.json になれません。上書きすれば片方が消えます。上書きを防ぐなら衝突として検出する必要があります。
悪い例: 検証関数の中で throw する
まず、ありがちな実装です。
import fs from "node:fs";
import path from "node:path";
const files = [
"input/a/card.json",
"input/b/card.json",
"input/c/note.md",
"input/missing.md"
];
function validateForFlatStaging(file, seenBasenames) {
if (!fs.existsSync(file)) {
throw new Error(`missing input: ${file}`);
}
const base = path.basename(file);
if (seenBasenames.has(base)) {
throw new Error(`basename collision: ${base}`);
}
seenBasenames.add(base);
}
function main() {
const seenBasenames = new Set();
for (const file of files) {
validateForFlatStaging(file, seenBasenames);
console.log(`copy ${file} -> staging/${path.basename(file)}`);
}
}
main();
このコードは、最初の衝突または欠落を見つけた時点で停止します。
copy input/a/card.json -> staging/card.json
Error: basename collision: card.json
この挙動は、単発処理なら問題になりにくいです。入力が1件だけなら、失敗した時点で止めれば十分だからです。
しかし、バッチ処理では話が変わります。100件中31件が処理可能でも、先頭付近の1件で throw すると、残りの正常な入力を確認する前に終了します。さらに、欠落が何件あるのか、衝突が何組あるのかも分かりません。
バッチでは「例外」と「検証結果」を分ける
入力不備は、必ずしもプログラムの異常終了ではありません。
バッチ処理では、次のように分けると扱いやすくなります。
- プログラムを続行できない異常:
throwする - 入力ごとの不備: エラー結果として集める
- 処理可能な入力: そのまま進める
たとえば、入力ファイルの存在チェックや basename 衝突は、個別入力の検証結果として回収できます。
改善例: validate は throw せず、結果を返す
次の例では、検証関数は throw しません。代わりに、処理可能なファイルと問題のあるファイルを分けて返します。
import fs from "node:fs";
import path from "node:path";
const files = [
"input/a/card.json",
"input/b/card.json",
"input/c/note.md",
"input/missing.md"
];
function validateInputsForFlatStaging(files) {
const byBasename = new Map();
const valid = [];
const errors = [];
for (const file of files) {
if (!fs.existsSync(file)) {
errors.push({
file,
reason: "missing_input_file"
});
continue;
}
const base = path.basename(file);
const previous = byBasename.get(base);
if (previous) {
errors.push({
file,
reason: "basename_collision",
detail: `${previous} and ${file} both map to ${base}`
});
continue;
}
byBasename.set(base, file);
valid.push({
source: file,
stagedName: base
});
}
return { valid, errors };
}
function main() {
const { valid, errors } = validateInputsForFlatStaging(files);
for (const item of valid) {
console.log(`copy ${item.source} -> staging/${item.stagedName}`);
}
if (errors.length > 0) {
console.error("input validation failed");
console.error(JSON.stringify(errors, null, 2));
process.exitCode = 1;
}
}
main();
この形にすると、正常な入力は処理できます。同時に、失敗理由もまとめて確認できます。
copy input/a/card.json -> staging/card.json
copy input/c/note.md -> staging/note.md
input validation failed
[
{
"file": "input/b/card.json",
"reason": "basename_collision",
"detail": "input/a/card.json and input/b/card.json both map to card.json"
},
{
"file": "input/missing.md",
"reason": "missing_input_file"
}
]
ポイントは、終了コードを失敗にしつつ、処理できるものは処理することです。
CI やスケジューラから見れば、このバッチは失敗として検知できます。一方で、ログには全件分の検証結果が残るため、次の修正作業がしやすくなります。
staging をフラットにしない選択肢
basename 衝突が頻発するなら、フラットな staging 自体を見直す手もあります。
たとえば、元の相対パスを保ったまま staging に配置します。
import path from "node:path";
function stagedPath(rootDir, file) {
const relative = path.relative(rootDir, file);
return path.join("staging", relative);
}
console.log(stagedPath("input", "input/a/card.json"));
console.log(stagedPath("input", "input/b/card.json"));
出力は次のようになります。
staging/a/card.json
staging/b/card.json
この方式なら、basename が同じでも衝突しません。
ただし、下流の処理が staging/card.json のようなフラット構造を前提にしている場合は、別の修正が必要です。既存仕様を変えられないなら、衝突をエラーとして集める方式のほうが小さく直せます。
PowerShell でも同じ
Node.js に限った話ではありません。PowerShell でも、入力検証中に throw すると、その時点でパイプライン全体が止まります。
$files = @(
"input/a/card.json",
"input/b/card.json",
"input/c/note.md",
"input/missing.md"
)
$seen = @{}
$valid = @()
$errors = @()
foreach ($file in $files) {
if (-not (Test-Path -LiteralPath $file)) {
$errors += [pscustomobject]@{
file = $file
reason = "missing_input_file"
}
continue
}
$base = Split-Path -Leaf $file
if ($seen.ContainsKey($base)) {
$errors += [pscustomobject]@{
file = $file
reason = "basename_collision"
detail = "$($seen[$base]) and $file both map to $base"
}
continue
}
$seen[$base] = $file
$valid += [pscustomobject]@{
source = $file
stagedName = $base
}
}
$valid | ForEach-Object {
"copy $($_.source) -> staging/$($_.stagedName)"
}
if ($errors.Count -gt 0) {
$errors | ConvertTo-Json -Depth 4
exit 1
}
バッチの入力検証では、言語よりも設計のほうが重要です。1件の不備で止めるのか、全件を検証してから失敗として返すのかを先に決めます。
どこでは throw してよいか
throw が悪いわけではありません。
次のようなケースでは、早く止めたほうがよいです。
- 設定ファイルのスキーマが壊れていて、入力一覧そのものを作れない
- 書き込み先ディレクトリを作れない
- 認証や権限の問題で、処理を続けても全件失敗する
- プログラム側の不変条件が破れている
逆に、次のようなケースは、バッチ内の検証結果として集めるほうが扱いやすいです。
- 入力ファイルが一部だけ存在しない
- 一部のファイル名が衝突している
- 一部のレコードだけ必須項目が欠けている
- 一部の変換だけ失敗した
つまり、throw は「このプロセス全体を続ける意味がない」場合に使い、入力単位の失敗はデータとして返す、という分け方です。
まとめ
バッチ処理の入力検証で throw を多用すると、1件の不備が全体停止になります。
特に、存在しない入力ファイルや basename 衝突のような「入力単位で説明できる失敗」は、例外で即停止するより、検証結果として集めたほうが運用しやすいです。
実装上は、次の形にすると安定します。
- 検証関数は
validとerrorsを返す - 正常な入力は処理する
- 不備は理由コードつきでまとめる
- 最後に
process.exitCode = 1やexit 1でバッチ全体の失敗を通知する
これだけで、失敗したことを隠さず、処理できるものまで止めないバッチにできます。