書き込み先が無いと黙って0件で終わる適用処理

外部から集めたデータを既存のレコードへ反映する処理を、取得と適用の2コマンドに分けている。取得はネットワークを叩くので失敗しうる。適用は手元のファイルを書くだけなので確実に成功する。分けておけば、取得だけやり直せる。

設計としては素直である。ただしこの形には、取得も適用も成功したのに何も起きない状態がある。

アニメ海外の反応まとめ の更新処理で実際に起きた。掲示板から実在の投稿を52件取得し、適用コマンドを通し、結果は次のようになった。

1
2
3
applied: 0
skipped: 0
errors: []

エラーは無い。スキップも無い。52件はどこへも行かなかった。

原因は「写像先が無い」

適用コマンドは、取得したデータを既存の episodes[] の該当する話数へ書き込む。ところが取得したのは、まだレコードとして作られていない話数だった。

  • 既存データ: 第1話〜第6話
  • 取得したもの: 第7話の投稿52件
  • 適用の動き: episodes から第7話を探す → 無い → 何もしない

「無いものには書けない」という動きそのものは正しい。問題は、その結果が applied: 0 としか表現されなかったことである。0件は「入れるものが無かった」でも起こる。2つの違う状態が同じ出力になっていた。

skipped にも積まれていないのは、スキップの判定がレコード側から始まるためである。既存の話を1件ずつ見て、対応する取得データがあるかを調べる。存在しない話は、そもそもループに入らない。

最小の再現

apply.js として保存する。Node 18 以降で動く。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
"use strict";

/** 取得データを既存レコードへ写す(素朴な実装) */
function apply(record, incoming) {
let applied = 0;
let skipped = 0;
for (const ep of record.episodes) {
const hit = incoming.find((x) => x.num === ep.num);
if (!hit) continue; // 取得データが無い話は飛ばす
if (ep.sourced) { skipped++; continue; }
ep.items = hit.items;
ep.sourced = true;
applied++;
}
return { applied, skipped };
}

module.exports = { apply };

呼んでみる。

1
2
3
4
5
6
7
const { apply } = require("./apply");

const record = { episodes: [{ num: 1, sourced: true }, { num: 2, sourced: true }] };
const incoming = [{ num: 7, items: new Array(52).fill("post") }];

console.log(apply(record, incoming));
// { applied: 0, skipped: 0 }

52件渡して { applied: 0, skipped: 0 } である。呼び出し側がこの戻り値を見ても、何が起きたか分からない。

直し方: 写せなかった入力を数える

レコード側だけを見ているのが原因なので、入力側からも突き合わせる

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
"use strict";

function apply(record, incoming) {
const byNum = new Map(record.episodes.map((ep) => [ep.num, ep]));
let applied = 0;
let skipped = 0;
const unknown = [];

for (const x of incoming) {
const ep = byNum.get(x.num);
if (!ep) { unknown.push(x.num); continue; } // 写像先が無い
if (ep.sourced) { skipped++; continue; }
ep.items = x.items;
ep.sourced = true;
applied++;
}
return { applied, skipped, unknown };
}

module.exports = { apply };

ループを入力側から回すように変えただけである。これで、写せなかった入力が unknown に残る。

終了コードも分ける。「入れるものが無かった」と「入れる先が無かった」は、呼び出し側の対処が違うからである。

1
2
3
4
5
6
7
const r = apply(record, incoming);
if (r.unknown.length) {
console.error(`写像先の無い話数: ${r.unknown.join(", ")}`);
console.error("先に雛形を作ってから適用し直すこと");
process.exit(4); // 3(入れるものが無い)とは別の番号
}
if (r.applied === 0) process.exit(3);

退行ガードを置く。

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
"use strict";
const { test } = require("node:test");
const assert = require("node:assert/strict");
const { apply } = require("./apply");

test("写像先が無い入力は unknown に出る", () => {
const record = { episodes: [{ num: 1, sourced: true }] };
const r = apply(record, [{ num: 7, items: ["a"] }]);
assert.deepEqual(r.unknown, [7]);
assert.equal(r.applied, 0);
});

test("写像先があれば適用される", () => {
const record = { episodes: [{ num: 7, sourced: false }] };
const r = apply(record, [{ num: 7, items: ["a", "b"] }]);
assert.equal(r.applied, 1);
assert.deepEqual(r.unknown, []);
assert.deepEqual(record.episodes[0].items, ["a", "b"]);
});

test("既に埋まっている話は skipped になる", () => {
const record = { episodes: [{ num: 7, sourced: true }] };
const r = apply(record, [{ num: 7, items: ["a"] }]);
assert.equal(r.skipped, 1);
assert.equal(r.applied, 0);
});

運用側の順序も変える

コードを直しても、正しい順序を知らないと使えない。この処理の正しい順序は次のとおりである。

  1. 対象の話がレコードに存在するかを確かめる
  2. 無ければ雛形を作る(レコードの行を先に用意する)
  3. 取得する
  4. 適用する

2を飛ばすと4が空振りする。手順書側にもこの順序を書いておく必要がある。実際、同じ失敗を繰り返さないための対処は「exit code を分ける」と「手順に2を明記する」の両方だった。

一般化

取得と適用を分けた設計では、両者のあいだに「対象の存在」という前提が挟まる。この前提は片方のコマンドだけを見ていると見えない。

チェックすべきことは1つである。

適用処理は、渡された入力のうち処理しなかったものを数えているか

数えていなければ、入力が全部捨てられても 0件・エラーなし・終了コード0 で終わる。ログを見返しても、何も起きなかった日と区別が付かない。