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?

実行記録を slice(-200) で切り詰めていたら、66日後に最古の2件が黙って消えた。気づいたのは同じ期間を数え続けていた照合1本だった

0
Posted at

Windows のタスクスケジューラで AI のエージェントを1日に3〜4回起動しています。起動のたびに、起動時刻と終わり方を JSON の配列に1件足して書き戻す小さなスクリプトがあります。

その書き戻しは、2026-08-10 からこう書いてありました。

runs.push({ slot, startedAt, endedAt: null, exit: null, outcome: null });
writeJsonAtomic(RUNS, runs.slice(-200));

「記録が無限に伸びないように、最新200件だけ残す」という、よく見る形です。この記事は、この1行が実際に何をしたかの記録です。

何が起きたか

出来事 日時(JST)
slice(-200) を入れたコミット 2026-08-10 13:40:56
記録に残っていた最古の起動 2026-08-10 13:41:31(入れた35秒後)
201件目の起動 = 最初に1件消えた 2026-10-15 11:37:02
202件目の起動 = 2件目が消えた 2026-10-15 13:05:01
切り詰めをやめたコミット 2026-10-15 13:09:49

200件に届くまでの約66日間、この1行は何もしていません。201件目を書いた瞬間から、いちばん古い記録を1件ずつ捨て始めました。消えたのは 2026-08-10 の昼の起動2件で、つまりこの切り詰めを入れた直後の、最初の2回の起動そのものです(この記録を書く仕組みは、切り詰めと同じコミットで作ったものです)。2件とも途中で終わった回の記録でした。

消えたことはどこにも残りません。ファイルは正しい JSON のままで、件数はちょうど200件で、例外も警告も出ません。

気づいたのは、別の目的で書いた照合だった

気づいたきっかけは、以前に技術記事を公開したときに書いた掲載前照合のスクリプトです。記事に書いた数字が保存したデータと合うかを確かめるもので、その中にこういう行があります。

check("窓を消す前 終わった起動", PRE.length, 56);

「ある修正より前の期間に、終わった起動は56回」という、記事に書いた数字です。期間の上の端(2026-08-28 より前)は日付で固定してあり、下の端は記録の先頭まで含めて数えます。だから記録が増えてもこの数は変わらず、変わるとしたら、その期間の記録が消えたときだけです。消えた2件はこの期間の中にあったので、この数が減りました。

2件消えた日のうちに、この照合が 56 ではなく 54 を数えて赤くなりました。もともと「記事が古くならないか」を見るための照合で、記録の消失を見張るつもりで書いたものではありません。

手元で再現する

依存の無いスクリプトで、同じことを一時フォルダの中だけで起こします。1日1件の記録を199件用意し、4件足します。後から数える側は「最初の10日に何件あるか」を数えます(期間を固定した照合と同じ形です)。

A 200件で切り詰め: ファイルに残る記録の印 199 → 200 / 読める 200件 / 最古 2026-01-04 / 最初の10日の件数 10 → 9 → 8 → 7
B 切り詰めない: ファイルに残る記録の印 199 → 203 / 読める 203件 / 最古 2026-01-01 / 最初の10日の件数 10 → 10 → 10 → 10

A の「最初の10日の件数」は、1件目を足した時点(200件ちょうど)では 10 のままで、2件目から 9、8、7 と減ります。A のファイルはどの時点でも正しい JSON で、1件足したあとの件数はいつも上限の200件ちょうどです。ファイルだけを見ても、何かが消えたことは分かりません。

直し方は単純で、件数で切り詰めるのをやめました。1日に3〜4件しか増えない記録で、200件という上限に理由がありませんでした。

export function appendRun(runs, entry) {
  if (!Array.isArray(runs)) {
    throw new TypeError("実行記録が配列ではありません。空として扱うと全履歴を上書きします");
  }
  return [...runs, entry];
}

直したあとも残っている、もっと大きい穴

切り詰めを外しても、このスクリプトにはもう1つ同じ形の穴が残っています。記録を読む関数が、読めなかったときに空配列を返すことです(コメントと、先頭の BOM を落とす処理は省いています)。

const readJson = (f, fallback) => {
  try {
    return JSON.parse(fs.readFileSync(f, "utf8"));
  } catch {
    return fallback;
  }
};
const runs = readJson(RUNS, []);

ファイルが読めなかったとき(壊れた JSON でも、読み出しそのものの失敗でも)、runs は空配列になり、そこへ1件足して書き戻すので、**それまでの全履歴が1件で上書きされます。**上の appendRun の型チェックは、渡されるのが空配列なので働きません。

再現スクリプトでは、199件の JSON の前半4,000文字だけを残した壊れたファイルを用意し、2通りで1件足しました。

C 壊れたら空として読む: ファイルに残る記録の印 142 → 1 / 読める 1件 / 最古 2026-07-19 / 最初の10日の件数 0
D 壊れたら止まる: ファイルに残る記録の印 142 → 142 / 読める 0件 / 最古 null / 最初の10日の件数 (書いていない) / 止まった: SyntaxError

D の「読める 0件」は、壊れたファイルを読めないので 0件という意味です。「ファイルに残る記録の印」は、生の文字列に "day" が何回出るかです。壊れたファイルには142件ぶんの記録の文字がまだ残っていて、手で救える可能性があります。C はそれを1件で上書きして消し、D は書かずに止まるので142件ぶんが残ります。

D の読み方は、「まだファイルが無い(ENOENT)」だけを空配列にし、それ以外の失敗は例外のまま上へ返すものです。

const readStrict = (f) => {
  let text;
  try { text = fs.readFileSync(f, "utf8"); } catch (e) {
    if (e.code === "ENOENT") return [];
    throw e;
  }
  const v = JSON.parse(text); // 壊れていれば例外のまま上へ返す
  if (!Array.isArray(v)) throw new TypeError("記録が配列ではありません");
  return v;
};

なお、私のスクリプトの書き込みは一時ファイルへ書いてから名前を付け替える形なので、書き込みの途中で止まって壊れる形は起きにくいはずです。それでも catch は読み出しの失敗も JSON の解析の失敗も同じように空配列にします。どの失敗で実際に空になりうるかは測っていません。

**私の実際のスクリプトは、まだ C の形のままです。**この記事を書いた時点で、読めなければ空として扱う呼び出しが4か所あります。直すと「起動の記録を書けないので起動を止める」かどうかの判断を伴うので、別に扱います。

やめたこと

  • **件数で切り詰めること。**上限を置くなら、溢れたときに「何件、いつの分を捨てたか」を別に残す。残せないなら捨てない
  • **読めなかったことを空として扱うこと。**空として読んで書き戻す処理は、読めなかった瞬間に全部を消す側に倒れる

測っていないこと

  • 消えた2件を戻せたのは、8月21日時点の記録の写しを別の目的で保存していたからです。写しが無ければ戻せませんでした
  • 記録が実際に壊れて C の形で上書きされたことは、私の環境ではまだ一度もありません。上の C は再現で起こしたものです
  • 他のログ(稼働確認のログなど)に同じ切り詰めがあるかは、この記事を書いた時点では調べていません
  • 再現は Windows 11 / Node v24 の1台で流しただけです

再現スクリプト全文

// repro-ring-truncation.mjs — 件数で切り詰める実行記録が、何を黙って失うか
//
// 依存0。一時フォルダの中だけで読み書きし、最後に消す。通信しない。
// 出力に時刻もパスも出さないので、何度流しても同じ JSON になる。
//
//   node repro-ring-truncation.mjs          … 人が読む形
//   node repro-ring-truncation.mjs --json   … JSON
import fs from "node:fs";
import os from "node:os";
import path from "node:path";

const LIMIT = 200;

// 記録1件。日付は 2026-01-01 から1件ずつ進める(1日1件)
const entry = (i) => ({ n: i, day: new Date(Date.UTC(2026, 0, 1 + i)).toISOString().slice(0, 10) });

// 素朴な読み方: 読めなければ空配列として扱う
const readOrEmpty = (f) => {
  try { return JSON.parse(fs.readFileSync(f, "utf8")); } catch { return []; }
};

// 読めないことと、まだ無いことを分ける読み方
const readStrict = (f) => {
  let text;
  try { text = fs.readFileSync(f, "utf8"); } catch (e) {
    if (e.code === "ENOENT") return [];
    throw e;
  }
  const v = JSON.parse(text); // 壊れていれば例外のまま上へ返す
  if (!Array.isArray(v)) throw new TypeError("記録が配列ではありません");
  return v;
};

const appendTrunc = (runs, e) => [...runs, e].slice(-LIMIT);
const appendKeep = (runs, e) => {
  if (!Array.isArray(runs)) throw new TypeError("記録が配列ではありません");
  return [...runs, e];
};

// 後から数える側: 最初の10日に何件あるか(期間を固定して数える照合の形)
const firstTenDays = (runs) => runs.filter((r) => r.day < "2026-01-11").length;

function run(dir, append, read, start, adds, corrupt = false) {
  const f = path.join(dir, `runs-${Math.random().toString(36).slice(2)}.json`);
  fs.writeFileSync(f, JSON.stringify(Array.from({ length: start }, (_, i) => entry(i))));
  // 書き込みが途中で切れた形: 正しい JSON の前半だけが残る
  if (corrupt) fs.writeFileSync(f, fs.readFileSync(f, "utf8").slice(0, 4000));
  const rawBefore = fs.readFileSync(f, "utf8");
  const counts = [];
  let error = null;
  for (let k = 0; k < adds; k++) {
    try {
      const next = append(read(f), entry(start + k));
      fs.writeFileSync(f, JSON.stringify(next));
    } catch (e) {
      error = e.name;
      break;
    }
    counts.push(firstTenDays(readOrEmpty(f)));
  }
  const after = readOrEmpty(f);
  const rawAfter = fs.readFileSync(f, "utf8");
  // ファイルの生の文字列に、記録1件ぶんの印("day")が何個残っているか
  const marks = (t) => (t.match(/"day"/g) || []).length;
  return {
    recordsInBytesBefore: marks(rawBefore),
    recordsInBytesAfter: marks(rawAfter),
    stored: Array.isArray(after) ? after.length : null,
    oldestDay: Array.isArray(after) && after.length ? after[0].day : null,
    firstTenDaysAfterEachAppend: counts,
    error,
  };
}

const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ring-trunc-"));
let out;
try {
  out = {
    limit: LIMIT,
    // A: 199件から4件足す。200件目までは何も起きず、201件目から古い順に消える
    truncate: run(dir, appendTrunc, readOrEmpty, 199, 4),
    // B: 同じことを切り詰めずに
    keep: run(dir, appendKeep, readOrEmpty, 199, 4),
    // C: ファイルが壊れていたら、空として読んで1件だけ書き戻す
    corruptReadOrEmpty: run(dir, appendKeep, readOrEmpty, 199, 1, true),
    // D: 壊れていたら書かずに止まる
    corruptReadStrict: run(dir, appendKeep, readStrict, 199, 1, true),
  };
} finally {
  fs.rmSync(dir, { recursive: true, force: true });
}

if (process.argv.includes("--json")) {
  console.log(JSON.stringify(out, null, 2));
} else {
  const line = (name, r) =>
    console.log(`${name}: ファイルに残る記録の印 ${r.recordsInBytesBefore} → ${r.recordsInBytesAfter} / 読める ${r.stored}件 / 最古 ${r.oldestDay} / 最初の10日の件数 ${r.firstTenDaysAfterEachAppend.join(" → ") || "(書いていない)"}${r.error ? ` / 止まった: ${r.error}` : ""}`);
  line("A 200件で切り詰め", out.truncate);
  line("B 切り詰めない", out.keep);
  line("C 壊れたら空として読む", out.corruptReadOrEmpty);
  line("D 壊れたら止まる", out.corruptReadStrict);
}

無人で動かしている処理の、こういう「失敗しても失敗したように見えない」形を見る小さな診断道具を置いています。
https://h26005.tailf680df.ts.net:10000/free.html

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?