0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

バッチ処理の入力検証で throw すると、1件の不備が全体停止になる

0
Posted at

--- BODY ---

バッチ処理の入力検証で throw すると、1件の不備が全体停止になる

複数の入力ファイルを処理するバッチで、入力検証に throw を使っていました。

その結果、次のような不備が混ざったときに、処理全体が途中で止まりました。

  • 指定された入力ファイルが存在しない
  • フラットな staging ディレクトリへコピーするとき、別ディレクトリ由来の同名ファイルが衝突する

実作業では、この種の入力不備で自動処理の約69%が失敗しました。問題は「不備を検出したこと」ではなく、「検出した瞬間に throw して、残りの正常な入力まで巻き添えにしたこと」でした。

この記事では、小さな Node.js の例で再現し、バッチ処理ではどこで throw し、どこで結果として回収するべきかを整理します。

再現するケース

次のような入力を考えます。

input/
  a/
    card.json
  b/
    card.json
  c/
    note.md

a/card.jsonb/card.json は別ディレクトリにありますが、basename はどちらも card.json です。

これを次のようなフラットな staging に集めるとします。

staging/
  card.json
  note.md

この設計では、a/card.jsonb/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 衝突のような「入力単位で説明できる失敗」は、例外で即停止するより、検証結果として集めたほうが運用しやすいです。

実装上は、次の形にすると安定します。

  • 検証関数は validerrors を返す
  • 正常な入力は処理する
  • 不備は理由コードつきでまとめる
  • 最後に process.exitCode = 1exit 1 でバッチ全体の失敗を通知する

これだけで、失敗したことを隠さず、処理できるものまで止めないバッチにできます。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?