スナップショット生成が欠測を確定させる 順序依存を検査で塞ぐ

日次でランキングのスナップショットを作るバッチがある。その日の観測値を集めてから順位を付け、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");

// 観測値ストア。scores.json が無ければ「観測していない」
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.jsalreadyExists: 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) { /* 上と同じく書かずに exit 2 */ }

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)を集計側から見れば、呼び出し順を人の記憶に頼らずに済む
  • 表示側が欠測を隠さない作りかどうかで、発見までの日数が変わる

スナップショット生成が欠測を確定させる 順序依存を検査で塞ぐ
https://blog.hashito.biz/2026/09/23/snapshot-generators-that-freeze-a-missing-measurement/
著者
hashito
作成日
2026年9月23日
著作権