朝7時に走る参照データ更新タスクが、ある日ネットワークの瞬断で途中終了しました。ログには何の異常も残らず、ただ前日のファイルがそのまま残りました。問題はそこからです。昼12時に走る記事生成タスクは、その参照データを黙って読み、前日と同じニュースを題材に「新しい記事」を作って公開してしまいました。誰もエラーに気づかないまま、鮮度の落ちた成果物だけが積み上がっていく——無人スケジューラで最も静かで、最も厄介な事故のかたちです。
この事故の根っこは、コード上のバグではありません。多くのスケジューラには「タスクAが終わってからタスクBを走らせる」という依存関係の概念そのものが無い、という構造の問題です。各タスクは決められた時刻に独立して起動するだけで、上流が本当に今日ちゃんと動いたのかを下流は知りません。だから下流は「たぶん動いたはず」という暗黙の期待で走り出します。その暗黙の期待を明示的な検証に置き換えるために、ここでは**完了台帳(completion ledger)と依存バリア(dependency barrier)**という2つの部品を設計していきます。
「動いたはず」がなぜ危険なのか — 見落とされる3つ目の状態
依存関係を人力で考えるとき、私たちはつい2値で捉えます。上流は「成功した」か「失敗した」か。しかし無人運用で本当に効いてくるのは、多くの実装が取りこぼす3つ目の状態です。
| 状態 | 意味 | 下流が取るべき行動 |
| 未実行 (not-run) | 上流が今日まだ走っていない、または途中で落ちた | 止まる(劣化した成果物を作らない) |
| 実行・空 (ran-empty) | 上流は正常に走ったが、今日は正当に成果物ゼロだった | スキップする(エラーではない) |
| 実行・産出 (ran-produced) | 上流が走り、下流が期待する成果物を出した | 進む |
厄介なのは「実行・空」と「未実行」を区別できない実装が多いことです。たとえば下流が「参照ファイルが存在するか」だけを見ていると、前日の残骸が存在するために「未実行」を「実行・産出」と誤認します。逆に「ファイルが今日更新されたか」だけを見ていると、上流が正当に空を返した日(新しいニュースが無い日など)を「異常」と誤認して、無用にアラートを鳴らします。3状態をきちんと分けることが、依存バリアのすべての出発点になります。
私自身、複数サイトの自動投稿を回している中で、この「空でも正当」を異常扱いして下流を止めてしまい、静かなはずの休日にパイプライン全体が空回りしたことがありました。上流の意図(空を返すのは正常な結果だった)が下流に伝わっていなかったのが原因です。だから台帳には、成否だけでなく「意図された空」を表現できる語彙が要ります。
完了台帳のデータ構造
依存バリアの中核は、各タスクが1日の実行結果を書き残す小さな台帳です。派手なジョブキューは要りません。1行1レコードの追記型 JSON で十分機能します。まず語彙を型で固定します。
// ledger.ts
export type RunStatus = "ok" | "empty" | "error";
export interface RunRecord {
task: string; // 例: "daily-reference-and-ticker"
date: string; // JST の YYYY-MM-DD(日境界の正本)
status: RunStatus; // ok=産出 / empty=正当な空 / error=失敗
fingerprint: string | null; // 産出物の内容ハッシュ。empty/error では null
artifactPath: string | null;
finishedAt: string; // ISO8601(監査用)
note?: string; // 空や失敗の理由を人が読めるように
}
ここで status と fingerprint を別々に持つのが肝です。status: "empty" は「上流は責務を果たしたが、今日は正当に成果物が無かった」を意味し、fingerprint: null を伴います。これで「実行・空」と「未実行(そもそもレコードが無い)」が構造的に区別できます。date を JST 固定にしているのは、UTC のまま扱うと日境界がずれて前日のレコードを今日のものと取り違える事故が起きるためです。ここは過去に痛い目を見た箇所で、日付は必ずタイムゾーンを明示して生成するようにしています。
上流の完了を原子的に記録する
台帳への書き込みには、無人運用特有の落とし穴があります。書き込みの最中にプロセスが落ちると、台帳そのものが壊れた JSON になり、翌日以降のすべての読み取りが失敗します。台帳を守る成果物が台帳を壊す、という本末転倒を避けるため、一時ファイルに書いてから rename で差し替える原子的書き込みを使います。POSIX では同一ファイルシステム上の rename が原子的であることが保証されているので、読み手は常に「古い完全な台帳」か「新しい完全な台帳」のどちらかしか見ません。
// record.ts
import { createHash } from "node:crypto";
import { readFile, writeFile, rename, mkdtemp } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { RunRecord } from "./ledger";
export function fingerprintOf(content: string): string {
return createHash("sha256").update(content).digest("hex").slice(0, 16);
}
// JST の YYYY-MM-DD を明示的に生成する(UTC ずれ防止)
export function jstDate(now = new Date()): string {
const jst = new Date(now.getTime() + 9 * 60 * 60 * 1000);
return jst.toISOString().slice(0, 10);
}
export async function recordRun(
ledgerPath: string,
rec: RunRecord
): Promise<void> {
let ledger: RunRecord[] = [];
try {
ledger = JSON.parse(await readFile(ledgerPath, "utf8"));
} catch {
ledger = []; // 初回、または壊れていた場合は作り直す
}
// 同じ task+date は最新で置き換える(再実行に対して冪等)
const key = (r: RunRecord) => `${r.task}::${r.date}`;
ledger = ledger.filter((r) => key(r) !== key(rec));
ledger.push(rec);
// 台帳が無限に伸びないよう直近 60 日だけ残す
const cutoff = jstDate(new Date(Date.now() - 60 * 864e5));
ledger = ledger.filter((r) => r.date >= cutoff);
// 一時ファイル → rename の原子的差し替え
const dir = await mkdtemp(join(tmpdir(), "ledger-"));
const tmp = join(dir, "ledger.json");
await writeFile(tmp, JSON.stringify(ledger, null, 2), "utf8");
await rename(tmp, ledgerPath);
}
上流タスクは、自分の処理が終わった最後に必ずこの recordRun を呼びます。成果物を出したなら status: "ok" とその内容ハッシュを、正当に空だったなら status: "empty" と理由を、途中で回復不能な失敗をしたなら status: "error" を記録します。ここで大事なのは、成功時だけでなく「正当な空」も明示的に記録することです。空を記録しないと、下流からは「未実行」と見分けがつかなくなります。
下流が依存バリアで前提を検証する
台帳が揃えば、下流タスクは処理の冒頭で「自分が依存する上流が、今日、期待どおりの状態か」を明示的に検証できます。これが依存バリアです。ただ存在確認をするのではなく、先ほどの3状態に応じて 止まる/スキップする/進む を返し分けるのが要点です。
// barrier.ts
import { readFile } from "node:fs/promises";
import type { RunRecord } from "./ledger";
import { jstDate, fingerprintOf } from "./record";
export type Gate =
| { decision: "proceed"; upstream: RunRecord }
| { decision: "skip"; reason: string }
| { decision: "halt"; reason: string };
export async function checkUpstream(
ledgerPath: string,
upstreamTask: string,
opts: { expectArtifact?: string } = {}
): Promise<Gate> {
const today = jstDate();
let ledger: RunRecord[] = [];
try {
ledger = JSON.parse(await readFile(ledgerPath, "utf8"));
} catch {
return { decision: "halt", reason: "台帳が読めません(破損 or 未生成)" };
}
const rec = ledger.find((r) => r.task === upstreamTask && r.date === today);
// 1) 未実行 — 前日の残骸で走らないよう、止まる
if (!rec) {
return { decision: "halt", reason: `${upstreamTask} が今日まだ完了していません` };
}
// 2) 失敗 — 上流の成果物は信用できない、止まる
if (rec.status === "error") {
return { decision: "halt", reason: `${upstreamTask} が失敗しています: ${rec.note ?? ""}` };
}
// 3) 正当な空 — エラーではない、静かにスキップ
if (rec.status === "empty") {
return { decision: "skip", reason: `${upstreamTask} は今日正当に空でした` };
}
// 4) 産出 — 下流が実際に読むファイルが、上流が記録した内容と一致するか照合
if (opts.expectArtifact && rec.fingerprint) {
const actual = fingerprintOf(await readFile(opts.expectArtifact, "utf8"));
if (actual !== rec.fingerprint) {
return {
decision: "halt",
reason: `成果物のハッシュ不一致(stale clone か部分書き込みの疑い)`,
};
}
}
return { decision: "proceed", upstream: rec };
}
checkUpstream の4番目のブロックが、単なる「今日動いたか」チェックを一段引き上げています。上流が「このハッシュのファイルを産出した」と台帳に書いた内容と、下流が実際にディスクから読むファイルのハッシュを突き合わせるのです。これで、上流は成功しているのに下流が古いクローンや同期途中のファイルを見ている、という環境のズレまで捕まえられます。私は複数のマシンとクラウド同期をまたいで運用しているので、「上流の記録」と「下流が見ている実体」が食い違う瞬間が現実にあり、このハッシュ照合が最後の安全網になっています。
下流タスクへの組み込みと、fail-loud の作法
下流タスクの入り口はこれだけです。判断が halt のときは、劣化した成果物を作らずに、はっきりとログへ理由を残して終了します。無人運用では「静かに何もしない」より「うるさく止まる」ほうが安全だからです。
// downstream.ts
import { checkUpstream } from "./barrier";
const LEDGER = "/data/pipeline/ledger.json";
async function main() {
const gate = await checkUpstream(LEDGER, "daily-reference-and-ticker", {
expectArtifact: "/data/reference/claudelab.md",
});
if (gate.decision === "halt") {
console.error(`[BARRIER] 中止: ${gate.reason}`);
process.exit(1); // スケジューラのログに失敗として残す
}
if (gate.decision === "skip") {
console.log(`[BARRIER] スキップ: ${gate.reason}`);
return; // 正常終了。成果物は作らない
}
// ここに来たときだけ、上流の鮮度が保証されている
console.log(`[BARRIER] 続行: 上流 ${gate.upstream.finishedAt} 完了`);
await generateArticleFromReference();
}
async function generateArticleFromReference() {
// 実際の生成処理
}
main().catch((e) => {
console.error(`[FATAL] ${e.message}`);
process.exit(1);
});
halt で exit(1) を返すのが地味に効きます。スケジューラの実行ログに「失敗」として明確に残るため、静かな劣化が「気づける失敗」に変わります。一方 skip は exit(0) で正常終了させ、成果物を作らないだけにします。ここを取り違えて空の日に毎回 exit(1) すると、正常な休日にアラートが鳴り続けて、狼少年のように本当の異常を見逃すようになります。前半で触れた「空でも正当」を、終了コードのレベルでも守るということです。
スケジューラにDAGが無い前提での運用判断
ここまでの設計は、ジョブスケジューラに本格的な依存グラフ(DAG)機能が無いことを前提にしています。本来ならワークフローエンジンに依存関係を宣言するのが筋ですが、個人開発で複数サイトの自動投稿を回す規模では、専用エンジンの運用コストが見合わないことが多いのです。私自身、時刻をずらして起動する素朴なスケジュールタスクの集合で運用してきました。だからこそ、依存関係をデータ(台帳)として外部化し、各タスクが自律的に前提を検証するこの方式が現実的でした。
運用してみて効いたのは、次の3点です。
- 上流と下流の起動時刻に十分な間隔を空けること。バリアはあくまで安全網であって、上流の完了を待つ機能ではありません。私の運用では、上流と下流のあいだに最低でも数時間の余裕を確保することを推奨します。
- 台帳を1つの正本に集約し、各タスクが同じファイルを読み書きすること。サイトごとに台帳を散らすと、横断的な依存が見えなくなります。Dolice Labs の複数サイトでも、台帳は1つに束ねて運用しています。
halt のログを毎日ざっと眺める習慣を持つこと。個人的には、バリアが止めた回数を上流の不安定さを測る指標として毎朝確認しています。止まった記録が増えてきたら、それは下流を直す前に上流を直す合図です。本番運用では、この地味な観察がいちばん効きました。
依存バリアは、無人パイプラインに「疑う力」を与える部品だと考えています。上流を信じないことは冷たい設計に見えて、実は静かな劣化から成果物の品質を守る、いちばん誠実なやり方でした。
まず何から始めるか
いきなり全タスクに台帳を通す必要はありません。まずは「上流が失敗したときに最も痛い1組」——たとえば毎日の参照データ更新と、それを読む生成タスクの1ペアだけに、recordRun と checkUpstream を入れてみてください。上流の最後に1行、下流の冒頭に数行足すだけで、「前日の残り物で走る」という最も静かな事故が、その日から「気づける失敗」に変わります。そこで手応えがあれば、依存の連鎖を1本ずつ台帳に載せていけば十分です。
最後までお読みいただき、ありがとうございました。同じように無人運用の静かな事故と向き合っている方の、設計の足がかりになれば嬉しいです。