日次でランキングのスナップショットを作るバッチがある。その日の観測値を集めてから順位を付け、data/rankings/<日付>.json に書き出す。同じ日に二度走っても上書きしないよう、ファイルがあればスキップする。
冪等性としては正しい。ところが取得の前に集計を走らせると、その正しさが裏目に出る。観測0件のまま「その日のスナップショット」が確定し、以後スキップされ続ける。
実際に起きたのはこうだった。順位付けを単独で実行したところ、観測件数が0で全101件のスコアが null のファイルが書かれた。スコア取得を走らせてから再実行しても「同日のスナップショットが既にある」と言われる。強制フラグを付けて作り直したら観測95件になった。取得経路は最初から壊れていない。壊したのは順序である。
何が問題なのか
このバッチは次の2つを区別していなかった。
- 観測できたが値が無い(その日は本当にデータが無い)
- 観測していない(取得を走らせていないので値が無い)
どちらも「スコアが null」として同じ形で残る。欠測としてファイルに書けてしまい、書いた以上は冪等ガードが守りに入る。
さらに厄介なのは、このバッチが失敗扱いにならなかったことである。exit code は 0。欠測の警告は出ていたが、その回は起票済みの既存Issueへのコメントに落ちた。自動実行では誰も気づかない。
再現する
rank.js として保存する。実際の構造を最小化したものである。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
| "use strict"; const fs = require("node:fs"); const path = require("node:path");
const OUT_DIR = "rankings"; const today = process.env.FAKE_DATE || new Date().toISOString().slice(0, 10); const force = process.argv.includes("--force");
const scores = fs.existsSync("scores.json") ? JSON.parse(fs.readFileSync("scores.json", "utf8")) : {};
const titles = ["alpha", "bravo", "charlie", "delta", "echo"]; const items = titles.map((slug, i) => ({ slug, rank: i + 1, score: scores[slug] ?? null }));
const observed = items.filter((x) => x.score !== null).length;
fs.mkdirSync(OUT_DIR, { recursive: true }); const out = path.join(OUT_DIR, `${today}.json`);
if (fs.existsSync(out) && !force) { console.log(JSON.stringify({ ok: true, alreadyExists: true, path: out }, null, 2)); process.exit(0); }
fs.writeFileSync(out, JSON.stringify({ date: today, observed, items }, null, 2)); console.log(JSON.stringify({ ok: true, observed, total: items.length, path: out }, null, 2));
|
取得側は scores.js とする。
1 2 3 4 5 6
| "use strict"; const fs = require("node:fs"); fs.writeFileSync("scores.json", JSON.stringify({ alpha: 9.1, bravo: 8.7, charlie: 8.4, delta: 8.0, echo: 7.6 }, null, 2)); console.log("scores.json を書いた");
|
順序を間違えて実行する。
1 2 3 4 5
| rm -rf rankings scores.json node rank.js node scores.js node rank.js node -e "console.log(require('./rankings/'+(process.env.FAKE_DATE||new Date().toISOString().slice(0,10))+'.json').observed)"
|
最後の行は 0 を出す。2回目の rank.js は alreadyExists: true で何もしていない。
検査で塞ぐ
直し方は3つある。順に効き方が違う。
1. 観測0件では書き出さない。 これが本命である。「観測していない」を欠測として保存しない。
1 2 3 4 5 6 7 8
| if (observed === 0 && items.length > 0) { console.error(JSON.stringify({ ok: false, reason: "no-observation", hint: "先に取得(scores.js)を走らせること。スナップショットは書いていない" }, null, 2)); process.exit(2); }
|
書かなければ冪等ガードも発動しない。次の実行が正しい値で作り直せる。
2. 閾値を決めて部分欠測と区別する。 全件欠測だけでなく、半分落ちた回も拾いたい。
1 2
| const ratio = observed / items.length; if (ratio < 0.5) { }
|
3. 依存を宣言して、呼び出し順を人に委ねない。 集計側から取得の鮮度を見る。
1 2 3 4 5 6
| const stat = fs.existsSync("scores.json") && fs.statSync("scores.json"); const ageH = stat ? (Date.now() - stat.mtimeMs) / 3600e3 : Infinity; if (ageH > 12) { console.error(`scores.json が ${ageH.toFixed(1)} 時間前のもの。先に取得を走らせること`); process.exit(2); }
|
実際に試す
前提: Node.js 18 以降、bash(Windows なら Git Bash)。上の rank.js に検査1を足した版で、順序を間違えたときに止まることを確かめる。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46
| mkdir -p /tmp/snapshot-demo && cd /tmp/snapshot-demo export FAKE_DATE=2026-09-23
cat > scores.js <<'EOF' "use strict"; const fs = require("node:fs"); fs.writeFileSync("scores.json", JSON.stringify({ alpha: 9.1, bravo: 8.7, charlie: 8.4, delta: 8.0, echo: 7.6 }, null, 2)); console.log("scores.json を書いた"); EOF
cat > rank.js <<'EOF' "use strict"; const fs = require("node:fs"); const path = require("node:path"); const OUT_DIR = "rankings"; const today = process.env.FAKE_DATE || new Date().toISOString().slice(0, 10); const force = process.argv.includes("--force"); const scores = fs.existsSync("scores.json") ? JSON.parse(fs.readFileSync("scores.json", "utf8")) : {}; const items = ["alpha", "bravo", "charlie", "delta", "echo"] .map((slug, i) => ({ slug, rank: i + 1, score: scores[slug] ?? null })); const observed = items.filter((x) => x.score !== null).length;
if (observed === 0 && items.length > 0) { console.error(JSON.stringify({ ok: false, reason: "no-observation", hint: "先に取得を走らせること。スナップショットは書いていない" }, null, 2)); process.exit(2); }
fs.mkdirSync(OUT_DIR, { recursive: true }); const out = path.join(OUT_DIR, today + ".json"); if (fs.existsSync(out) && !force) { console.log(JSON.stringify({ ok: true, alreadyExists: true, path: out }, null, 2)); process.exit(0); } fs.writeFileSync(out, JSON.stringify({ date: today, observed, items }, null, 2)); console.log(JSON.stringify({ ok: true, observed, total: items.length, path: out }, null, 2)); EOF
rm -rf rankings scores.json node rank.js; echo "exit=$?" node scores.js node rank.js; echo "exit=$?" node -e "console.log('observed =', require('./rankings/'+process.env.FAKE_DATE+'.json').observed)"
|
期待される出力。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| { "ok": false, "reason": "no-observation", "hint": "先に取得を走らせること。スナップショットは書いていない" } exit=2 scores.json を書いた { "ok": true, "observed": 5, "total": 5, "path": "rankings/2026-09-23.json" } exit=0 observed = 5
|
path の区切り文字は実行環境による(Windows では rankings\2026-09-23.json と出る)。path.join の出力をそのまま印字しているためで、動作に違いはない。
検査を入れる前は最後の行が observed = 0 になり、しかも exit code は両方 0 だった。
運用側で分かったこと
この構造は、日次で動かしているサイト群で実際に踏んだものである。順位データはアニメ人気ランキング(anime-trend)として毎日公開している。公開側のテンプレートはスコアを条件付きで出す作りで、値が null ならスコアの一文ごと出力しない。つまり画面は崩れず、エラーも出ず、スコアの行が静かに消えるだけになる。表示が壊れないことが発見を遅らせた。
同じ日次の枠で動かしているアニメ海外の反応まとめ(anime-reactions)は、話数ごとに実在の投稿だけを載せる作りにしてあり、取れなかった回は掲載件数が0になる。こちらは欠測が件数として見えるので、同種の事故が起きにくい。欠測が画面に出る設計かどうかが、そのまま気づきやすさの差になっている。
まとめ
- 「観測していない」を「値が無い」として保存しない。保存した瞬間、冪等ガードが欠測を守り始める
- 書き出す前に観測件数を検査し、0件(または閾値未満)なら書かずに専用の exit code で止める
- 取得物の鮮度(mtime)を集計側から見れば、呼び出し順を人の記憶に頼らずに済む
- 表示側が欠測を隠さない作りかどうかで、発見までの日数が変わる