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?

slugだけで冪等性を見ていたら、記事が出ないデッドロックに入った

0
Posted at

背景

自動投稿の仕組みで、記事が1本も出ない日が出ました。

エラーは出ていません。
ログは「作りません」「昇格しません」と丁寧に理由を書いて、正常終了している。
2つのガードがそれぞれ正しく働いた結果、出口が塞がっていた、というやつです。

今日その修正をしたので、書いておきます。

何が起きていたか

仕組みは単純です。
朝に下書きを pending/ へ作って、夜にそれを各媒体へ公開する。
公開したら記録を published.json に積む。

この話の前提として、冪等という言葉を先に置いておきます。
冪等とは「同じ操作を何回繰り返しても結果が変わらない」性質のことで、自動投稿の文脈では「同じ記事を二度出さない」ことを指します。
そのために「この記事はもう出したか」を判定するキーが要る。
今回壊れたのは、そのキーの選び方でした。

ガードが2つあります。

ひとつめ。未公開の下書きが残っている媒体には、新しい下書きを作らない。
滞留したまま下書きだけ増えるのを止めるための決まりです。

ふたつめ。過日の下書きを当日へ昇格するとき、1媒体でも公開済みのファイルは昇格しない。
昇格するとファイル名の日付が変わり、slug + date で見ている冪等性のキーが変わってしまうので、成功済みの媒体まで再投稿してしまう。
それを防ぐためのものです。

ここで、こういう状態になりました。

  • 9/19 の朝に下書きが4本できた。品質ゲートに引っかかって公開されず、pending/ に残った
  • その日、別の題材の記事が4本公開された。ファイル名の付け方が同じなので、slug も同じになった
  • 翌日。ひとつめのガードが「未公開が残っている」と判断して新規生成を止める
  • ふたつめのガードが「この slug+date は公開済みだ」と判断して昇格を止める

生成も公開もされない状態が固定されます。
片方だけなら翌日に解けるのですが、互いの前提が相手の出力なので、自力では抜けられない…。

実際のログはこうでした。

[daily-draft] 未公開の過日下書きが残っているため、次の媒体は当日分を作りません: zenn/qiita/hatena/blogger。
[daily-publish] 過日下書きを当日へ昇格しません(二重投稿防止): hatena へ公開済みの部分成功ファイル
[daily-publish] 本日(2026-09-20)は下書きが1件も無く、公開実績もゼロです。

原因は「キーが一意でなかった」こと

昇格の可否を判定していたのは、この関数です。
base は pending/ のファイル名(2026-09-19-fleet-devlog-cve-watch-qiita.md のような形)、registry は published.json を読んだ配列です。

function stalePromotable(base, registry) {
  const m = String(base).match(/^(\d{4}-\d{2}-\d{2})/);
  if (!m) return { ok: false, reason: "先頭が YYYY-MM-DD でない", okPlatforms: [] };
  const date = m[1];
  const slug = String(base).replace(/\.md$/, "");
  const okPlatforms = [...okPlatformsFor(registry, slug, date)];
  if (okPlatforms.length)
    return { ok: false, reason: `${okPlatforms.join("/")} へ公開済み`, okPlatforms };
  return { ok: true, reason: "", okPlatforms: [] };
}

okPlatformsFor() は registry から slug と date が一致する記録を拾って、成功した媒体名の集合を返します。
空でなければ「この下書きはもう一部が出ている」と判断して止める。

ここが間違いでした。
slug + date は、記事を一意に指していない。

slug の付け方が <日付>-fleet-devlog-<サイト名>-<媒体名> という決まりなので、同じ日に同じサイトの話を書けば、題材が違っても同じ slug になります。
つまり「slug+date が一致した」は「同じ記事だ」を意味しない。
未公開の別記事が、他人の公開記録を見て「自分は公開済み」と判定していた、というのが正体です。

タイトルで突き合わせる

published.json の各レコードには title が入っていました。
なので、そこを比べれば別記事だと分かります。

function stalePromotable(base, registry, draftTitle) {
  // ...(date と slug の取り出しは同じ)
  const okPlatforms = [...okPlatformsFor(registry, slug, date)];
  if (okPlatforms.length) {
    const mine = normalizeTitle(draftTitle);          // 空白を落として比較用に正規化
    const recorded = titlesFor(registry, slug, date); // その slug+date に記録されたタイトル群
    if (mine && recorded.length && !recorded.includes(mine))
      return { ok: true, reason: "slug は衝突しているがタイトルが別", okPlatforms: [], differentArticle: true };
    return { ok: false, reason: `${okPlatforms.join("/")} へ公開済み`, okPlatforms };
  }
  return { ok: true, reason: "", okPlatforms: [] };
}

draftTitle は下書きの front-matter から読んだ title です。
呼び出し側では、すでに front-matter をパースしていたので、その meta.title を渡すだけで済みました。

意図的に保守側へ倒してあります。
draftTitle が空のとき、記録側に title が無いとき、どちらかが欠けたら従来どおり止める。
判断材料が足りないときに通してしまうと、今度は本当に二重投稿します。
安全側のデフォルトは動かさない、という方針です。

これだけでは抜けられなかった

ここで一度「直った」と思ったのですが、追いかけると抜けていませんでした。

昇格はファイル名の先頭の日付を当日へ差し替える処理です。
2026-09-19-fleet-devlog-cve-watch-qiita.md は 2026-09-20-... になる。
ところが、その 2026-09-20-... という slug は、当日すでに公開された別記事が使っている。

昇格したあと、公開対象の媒体を決めるところで okPlatformsFor(新しい slug, 当日) を引くと、他人の成功が返ってきます。
公開すべき媒体が空集合になって、何も出ないまま pending/ に残る。
そして翌日、そのファイルは「部分成功ファイル」として扱われる。

同じデッドロックを作り直していたわけです…。

なので、名前を付けるところも直しました。

function promotedName(basename, todayKey, taken) {
  const renamed = String(basename).replace(/^\d{4}-\d{2}-\d{2}/, String(todayKey));
  const used = taken instanceof Set ? taken : new Set(taken || []);
  if (!used.size) return renamed;
  const stem = renamed.replace(/\.md$/, "");
  if (!used.has(stem)) return renamed;
  for (let i = 2; i <= 9; i++) {
    const suffix = `-${i}`;
    const cut = stem.slice(0, Math.max(1, 50 - suffix.length)); // Zenn の slug 上限に収める
    if (!used.has(`${cut}${suffix}`)) return `${cut}${suffix}.md`;
  }
  return renamed;
}

taken には、published.json のうち当日日付のレコードの slug を集めて渡します。
衝突していなければ今までどおり日付だけ差し替えるので、既存の挙動は変わりません。

50 - suffix.length で切っているのは、Zenn の slug が 12〜50 文字という制約を持っているためです。
接尾辞を足して 51 文字になると、今度は公開のほうで落ちる。

テストが教えてくれたこと

この修正を入れたら、既存のテストが1本落ちました。

「日付以外が同名の滞留下書きが2件あっても、昇格するのは1件だけ」というものです。
私は接尾辞で衝突を避ける処理を、公開済み記事との衝突にも、昇格待ちどうしの衝突にも適用していました。
結果、2件とも別名で昇格して、同じ媒体へ2本流れることになっていた。

避けるべき衝突と、避けてはいけない衝突があるという話です。
公開済みの別記事とは名前を分ける。
昇格待ちどうしは、1件ずつ順番に出す(=2件目はスキップして翌日に回す)。

結局、taken に積むのは published.json 由来の slug だけにして、昇格待ちの分は従来どおり後勝ちでスキップさせました。
テストが無かったら、たぶん気づかずに入れていました…。

追加したテストは6本です。
タイトルが違えば昇格する、タイトルが同じなら止まる、タイトルを渡さなければ止まる、記録側に title が無ければ止まる、接尾辞が付く、接尾辞を付けても 50 文字に収まる。
このサイトの一覧は https://cve.autoarticles.net にあります。

持ち帰り

冪等性のキーを決めるときは、そのキーが単体で対象を一意に指せるかを確かめてください。
slug + date は一見それらしく見えますが、slug の生成規則に日付が入っていて、かつ日付をずらす処理が同居していると、一意性は簡単に壊れます。

そしてガードを足すときは、そのガードが閉じたあとに出口が残っているかを確認すること。
片方向のガードは翌日に解けますが、互いの前提が相手の出力になっている2つは、外から手を入れるまで解けません。


本記事はAI補助で執筆した、個人開発の紹介記事です。

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?