Featured image of post コードテイマー【Circuit Breaker】倒れた一匹を見捨てられず、群れごと魔力が枯れ果てる〜倒れた相手への鎖を断ち、頃合いを見て繋ぎ直す〜

コードテイマー【Circuit Breaker】倒れた一匹を見捨てられず、群れごと魔力が枯れ果てる〜倒れた相手への鎖を断ち、頃合いを見て繋ぎ直す〜

中央郵便局の差配は、辺境の便も絶やさぬことを矜持にしてきた。末端の一配送所が魔力災害で倒れた夜、その一つを諦めきれず使い魔を送り続けた——だが倒れた相手を待つほど、待つこと自体が使い魔を食い潰す。応答を失った中央局は黙り込み、その沈黙が呼び出しの鎖を登って、倒れた配送所とは無縁の健全な地方の便まで凍りつかせた。差配は「倒れた配送所を補強すれば(早馬=リトライを増やせば)防げる」と信じて来るが、それは倒れた相手に群がる狼を増やし共倒れを早めるだけだ。直せないのは倒れた相手で、守るべきは待つ側。失敗が続けば鎖を断ち(Open)即座に軽い間に合わせ(Fallback)を返して使い魔を空け、頃合いを見て一筋だけ試しに送り(Half-Open の単一プローブ)、通れば繋ぎ直す(Closed)。倒れた配送所を Deferred で握り論理時計で刻を進める決定的な模擬戦で、連鎖がどこで断たれるかと、回復中の群がりを一筋に絞る理由まで確かめる TypeScript の契約の物語。

最果ての配達先を、訪ねる

倒れたのは、網の端の、たった一つの配送所だった。なのに、王国じゅうの便が、止まった。

わたしは王国魔法郵便網の、中央局の差配だ。各地の配送所へ便を割り振り、使い魔を出して運ばせる。使い魔は、中央局が抱える運び手だ。数には限りがある。一頭が便を抱えて発てば、戻るまでその一頭は塞がる。戻れば、また次の便を託せる。その采配が、わたしの仕事だった。

聖域へ続く道は、網のいちばん端にある。岩がちな尾根を越えた先の、めったに便など来ない辺境だ。届け先は、ただ一軒。理屈で言えば、こんな道は閉じてしまっていい。だがわたしは、一つの宛先も切り捨てたくなくて、人手の足りない時は自分で使い魔を引いて、ここまで通した。だから、この奥に住む主が、ただ者でないことも、配達のついでに、それとなく知っていた。獣を、力でなく契約で鎮める者がいる、と。

石造りの庵は、幾重もの封印に囲まれていた。古い石に、見たことのない印が、層をなして刻まれている。その封印の一つに指を当てて、線をなぞっていた人が、振り向かずに言った。

「お前か。いちばん遠い便を、絶やさなかった差配は」

わたしは、足を止めた。理屈の通らないこの道に、それでも便が絶えず届くのを、この人は端から訝しんでいたのだろう。来るはずのないものが、来続けていた。その出どころを、わたしの几帳面を、宛先の主のほうが見ていた。

「カイナ、さんですか」と、わたしは言った。「網が、止まったんです。中央局ごと。……視ていただきたい」

カイナと名乗ったその人は、封印から指を離して、ようやくこちらを向いた。急ぐふうは、まるでなかった。

倒れた一つを、見捨てられなかった

わたしは、あの夜のことを話した。淡々と話すつもりが、途中で、何度か言葉に詰まった。

「末端の配送所が、一つ、倒れたんです。北の外れの、小さな所でした。魔力災害で、応答が返らなくなった。便を送っても、受け取ったとも、断るとも、何も言ってこない。ただ、黙る」

そこまでは、よくあることだった。問題は、その次だ。

「わたしは、その配送所宛の便を、見捨てられませんでした。届くはずの便です。誰かが待っている。だから、使い魔を出し続けた。応答がなくても、また次を送り、また次を送った。……そうしているうちに」

声が、掠れた。

「中央局そのものが、どの便にも応えられなくなったんです。使い魔が、一頭も戻ってこない。そして——倒れた配送所とは何の関わりもない、南の、東の、健全な地方への便まで、いっせいに凍りついた。網が、丸ごと止まった」

カイナは、急かさずに聞いていた。わたしは、いちばん分からないことを、口にした。

「倒れたのは、末端の一つだけです。健全な地方は、何も壊れていない。なのに、なぜ網全体が止まったのか。それが、どうしても分からない」

分からないまま、わたしは一つの答えを握って、ここへ来ていた。

「だから——あの倒れた配送所を、補強したいんです。待つ刻をもっと延ばして、使い魔を増やして、倒れても何度でも繋ぎ直せるようにする。そうすれば、二度と取りこぼさない。その手伝いを、お願いに来ました」

それが、正しい道だと信じていた。見捨てないというのは、もっと粘る、ということだと。

直せないのは、倒れた相手のほうだ

カイナは、わたしの補強案に、すぐには乗らなかった。代わりに、中央局の送りの手順を見せろ、と言った。

わたしは、便を送る仕組みを写した板を取り出した。難しいことは、何もしていない。使い魔を一頭確保して、配送所へ送る。それだけだ。だからこそ、ここに穴があるとは、思えなかった。

まず、使い魔の采配を写したものを見せた。

 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
47
// 便(配送物)と受領証。delivered:false = 間に合わせ(Fallback)の応答。
export interface Parcel {
  region: string;
  id: string;
}

export interface Receipt {
  region: string;
  delivered: boolean;
}

// 配送所(外部依存)。send は Promise。倒れた配送所は応答を握ったままハングする。
export interface Branch {
  send(parcel: Parcel): Promise<Receipt>;
}

// 中央局の使い魔(並行スロット)。有限。空くまで次の便は待つ。
export class CourierPool {
  private available: number;
  private readonly queue: Array<() => void> = [];
  private running = 0; // 稼働中の使い魔数

  constructor(capacity: number) {
    this.available = capacity;
  }

  async run<T>(task: () => Promise<T>): Promise<T> {
    if (this.available > 0) {
      this.available--;
    } else {
      await new Promise<void>((res) => this.queue.push(res)); // 空くのを待つ
    }
    this.running++;
    try {
      return await task();
    } finally {
      this.running--;
      const next = this.queue.shift();
      if (next) next(); // 次の待ち手にスロットを手渡し
      else this.available++;
    }
  }

  get inFlight(): number {
    return this.running;
  }
}

「使い魔は、数が決まっています」とわたしは言った。「run に便を渡すと、空いた一頭を確保して運ばせる。空きがなければ、待つ。一頭が戻れば、待っていた便に渡す。……当たり前の采配です」

そして、配送所へ送る手順を見せた。

1
2
3
4
5
6
7
8
9
// Before: 中央局は使い魔を1つ確保し、配送所へ直接送る。倒れていても送り、
//   応答(or タイムアウト)まで使い魔を保持する。
export async function dispatchDirect(
  pool: CourierPool,
  branch: Branch,
  parcel: Parcel,
): Promise<Receipt> {
  return pool.run(() => branch.send(parcel));
}

「使い魔を一頭確保して、配送所へ送る。応答が返るまで、その使い魔は戻りません。……これの、どこが」

カイナは、板に指を這わせた。生態を見るような、静かな手つきだった。そして、低く言った。

「お前は、倒れた配送所を直したいと言う。だが——倒れた相手は、お前の手では直せん。向こうの地の魔力災害は、お前の領分じゃない」

わたしは、食い下がった。「では、せめて待って、繋ぎ直し続ければ……」

「それが、群れを殺す」

カイナの声が、わずかに落ちた。

倒れた一匹を、群れが支えようとする

「お前の網で暴れているのは、鎖で繋がった獣だ」とカイナは言った。「群れで生きる。一匹が倒れると、他の狼が、鎖を通して魔力を送る。倒れた仲間を、支えようとする」

わたしは、その姿を思い浮かべた。

「だが、倒れた一匹は、受け取るばかりで、立たない。支える狼も、やがて魔力が尽きて倒れる。すると、また次の狼が、倒れたそいつを支えようと、魔力を送る。——倒れが、鎖を伝って、次の狼へ、また次の狼へと移っていく。最後は、群れごと枯れ果てる」

背筋が、冷えた。

「……わたしが、していたことだ」

倒れた配送所を見捨てられず、使い魔を送り続けた。応答のない相手に、次々と。その使い魔が戻らず、中央局が枯れ、無関係な地方まで凍りついた。倒れた一匹を支えようとして、群れごと枯らす。それは、比喩ではなかった。

「お前が見たのは、これだ」とカイナは言った。「倒れた一匹を見捨てられず、群れごと魔力が枯れた。お前の網も、同じだった」

待つことが、使い魔を食い潰す

倒れた配送所を、こちらの手で倒す

「災害なら、もう起きた後だ」とカイナは言った。「あの倒れた配送所を、もう一度、この手で倒す」

カイナが組んだのは、沈黙を握る檻だった。倒れた配送所を真似た偽物は、便を送られても、応答を返さない。いつ黙りを破るか――その頃合いまで、こちらの掌の上にある。

 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
// Deferred: Promise の解決権を外から握るユーティリティ。
export function deferred<T>(): {
  promise: Promise<T>;
  resolve: (v: T) => void;
  reject: (e: unknown) => void;
} {
  let resolve!: (v: T) => void;
  let reject!: (e: unknown) => void;
  const promise = new Promise<T>((res, rej) => {
    resolve = res;
    reject = rej;
  });
  return { promise, resolve, reject };
}

// 倒れた配送所: send を呼ぶたびに新しい Deferred を返してハングする。
// テストは任意の刻に reject(タイムアウト相当)/ resolve(回復相当)できる。
export class DownedBranch implements Branch {
  callCount = 0;
  readonly pending: Array<{
    promise: Promise<Receipt>;
    resolve: (v: Receipt) => void;
    reject: (e: unknown) => void;
  }> = [];

  send(_parcel: Parcel): Promise<Receipt> {
    this.callCount++;
    const d = deferred<Receipt>();
    this.pending.push(d);
    return d.promise;
  }
}

握りの道具は、deferred だ。約束を外から保留しておき、好きな刻に「返した」「失敗した」と決める。倒れた配送所は、送られた便の数を callCount で数え、応答を pending に溜めて、握ったまま黙る。健全な配送所のほうは、送れば即座に「届けた」と返す。

「網が立て込む繁忙のときだけ、倒れた相手への便がたまたま積もる」とカイナは言った。「だから、平時の見回りでは捕まらない。その稀な折を、こちらから狙って、檻の中へ呼び込む」

ここで握るのは、二つ。倒れた相手の沈黙と、刻だ。沈黙は、ただ黙らせるだけではない。待ちあぐねた使い魔が「もう戻らぬ」と諦める——現の網でいうタイムアウト——その瞬間も、檻には時計も自動で打ち切る仕掛けも置かず、こちらが手で reject を打って起こす。刻もまた、生の時計は使わない。こちらが手で進める論理の刻にする。沈黙をいつ断ち、刻をどこまで進めるか、その両方を握るから、何度走らせても、ぴたりと同じことが起きる。結果が、その時の運に転ぶことはない。

健全な便が、出ていかない

カイナは、使い魔を二頭だけ置いた檻で、最初の災害を流した。倒れた配送所へ、二便。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
const pool = new CourierPool(2); // 使い魔は二人(容量2)
const downed = new DownedBranch();
const healthy = new HealthyBranch();

// 倒れた配送所へ2便→両方とも使い魔を確保し、応答を待ってハングする。
const p1 = dispatchDirect(pool, downed, { region: "downed", id: "p1" });
const p2 = dispatchDirect(pool, downed, { region: "downed", id: "p2" });

await Promise.resolve();
await Promise.resolve();
assert.equal(pool.inFlight, 2); // 使い魔は二人とも、応答のない相手の前で塞がっている

二便とも、使い魔を確保して、配送所へ発った。だが配送所は黙ったまま。使い魔は、応答を待って、戻ってこない。inFlight は二——二頭とも、塞がっている。

そこへ、健全な地方への便を、一つ流す。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// 健全な地方への便→空き使い魔が無く、まだ送られないまま待たされる。
let healthyDone = false;
const healthyPromise = dispatchDirect(pool, healthy, {
  region: "healthy",
  id: "h1",
}).then((receipt) => {
  healthyDone = true;
  return receipt;
});

// マイクロタスクを数回まわしても、健全便はまだ通らない。
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
assert.equal(healthyDone, false); // 健全な便が、倒れた相手に巻き込まれて凍っている
assert.equal(healthy.callCount, 0); // healthy.send 自体がまだ呼ばれていない(使い魔が無い)

何度、巡回の隙をまわしても、健全な便は出ていかなかった。healthyDone は偽のまま。健全な配送所の send は、まだ一度も呼ばれていない。届け先は無事なのに、運ぶ使い魔が、一頭もいないからだ。

これは「まだ通っていないだけ」ではない。健全な便は、空く使い魔を待つ列に積まれて、止まっている。その列が動く引き金は、ただ一つ——先に出た使い魔が戻ることだけだ。だが倒れた相手を待つ二頭は、こちらが諦めさせない限り、戻らない。動かす引き金がどこにもないのだから、巡回の隙をいくら掃いても、結果は一分も変わらない。だから「まだ」ではなく「この刻には、決して通らない」と言い切れる。

わたしは、声を失った。「健全な地方は、何も壊れていない。なのに、便が出ていかない……?」

「使い魔は二頭とも、倒れた相手の前で、応答を待って立ち尽くしている」とカイナは言った。「健全な便を運ぶ者が、一頭も残っていない」

カイナは、刻を進めた。倒れた相手への二便が、ようやくタイムアウトで諦め、失敗する。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// タイムアウト相当:倒れた配送所への2便を reject して使い魔を解放する。
const [d1, d2] = downed.pending;
d1!.reject(new Error("timeout"));
d2!.reject(new Error("timeout"));

await assert.rejects(p1);
await assert.rejects(p2);

// 使い魔が空き、健全便が初めて通る。
const healthyReceipt = await healthyPromise;
assert.equal(healthyDone, true);
assert.deepEqual(healthyReceipt, { region: "healthy", delivered: true });

倒れた相手への二便が失敗して、使い魔が空いた。その瞬間、待たされていた健全な便が、初めて出ていった。届いた。

そこで、わたしは、自分の言葉でようやく像を結んだ。

「待つこと、そのものが……使い魔を食っていたのか。倒れた相手に送った使い魔は、応答が返らないから、ずっと戻ってこない。戻らないから、健全な便を運ぶ者がいなくなる。倒れたのは末端の一つなのに、待つわたしの側で、使い魔が尽きた」

カイナは、うなずいた。そして、その先を継いだ。

「そうして応答を失った中央局は、黙り込む。すると、中央局を頼る上の者が、今度は中央局の沈黙を待つ。倒れが、鎖を登っていく」

一部の倒れが、繋がりを伝って全体へ広がる。これを カスケード障害——連鎖障害——という。倒れた配送所そのものが網を壊したのではない。倒れたと分かっても送り続け、待ち続けたことが、倒れを鎖伝いに運んだのだ。

もっとも、この檻で目に見せたのは、鎖の一段ぶんだ。使い魔が尽きて、健全な便が凍る、そこまでを確かめた。その先、凍った中央局を頼る上の者がまた枯れていく多段の連鎖は、同じ理屈の繰り返しとして地続きに続く。逆に言えば、一段を断てれば、その上へは登られない。だから手を打つのは、この一段でいい。

カイナは手元の羊皮紙に、すばやい運筆で魔力の流れを描き出していった。インクが吸い込まれるにつれて、そこに一つの冷厳な理が浮かび上がる。 「お前が引き起こした連鎖は、こういうことだ」 差し出された図には、中央局と、倒れた配送所、および本来なら無事であるはずの健全な地方の三者が結ばれていた。

Infographic sequence diagram showing cascading failure in a courier system with shared resources

描かれた線の交点を見つめながら、喉の奥が乾くのを感じた。 「倒れたのは末端の一つ……なのに、待つために放った使い魔たちが、戻らない鎖の結び目になってしまう」 「そうだ。一対一の接続で使い魔を使い果たせば、別の健全な接続を試す機会すら奪われる。これが連鎖の正体だ」 カイナはそう言って、煤けたランプの光を近づけた。

早馬を増やせば、群れはもっと早く枯れる

わたしは、それでも、握ってきた答えにすがった。

「なら——使い魔を、増やせばいい。早馬を増やして、倒れても何度でも送り直せば、いつかは……」

カイナは、その場で、使い捨ての檻をもう一つ組んだ。倒れた相手への送りを、三度まで繰り返す——わたしが言った「送り直し」を、そのまま形にしたものだ。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// 却下案の反証専用:dispatchDirect を retries 回までリトライでくるむ使い捨て。
async function dispatchWithRetry(
  pool: CourierPool,
  branch: Branch,
  parcel: Parcel,
  retries: number,
): Promise<void> {
  let lastError: unknown;
  for (let attempt = 0; attempt < retries; attempt++) {
    try {
      await pool.run(() => branch.send(parcel));
      return;
    } catch (e) {
      lastError = e;
    }
  }
  throw lastError;
}

カイナは、送り直しの回数を変えて、倒れた配送所への到達——callCount——を数えた。

1
2
3
4
5
6
7
// 送り直しなし(1回):倒れた配送所への到達は1回だけ。
// …reject まで進めて…
assert.equal(downedNoRetry.callCount, 1); // 到達は1回のみ

// 送り直し3回:倒れた配送所への到達が3回に増える=群がる狼が3倍。
// …1回目 reject → 2回目 → 3回目…
assert.equal(downedRetry3.callCount, 3); // 到達は3回=送り直しなしの3倍

「送り直しを増やせば、倒れた相手への到達が、そのぶん増える」とカイナは言った。「一便のために、三度叩く。倒れた相手に群がる狼が、三倍になる。使い魔は、三倍長く塞がる。——お前の言う補強は、群れを、もっと早く枯らすだけだ」

送り直しが、いつも悪いわけではない、とカイナは付け加えた。「相手が一時の不調で、待てば返るなら、もう一度試すのは正しい。前に、嵐の中で何度も試させる獣を診たろう。あれは、それで鎮まった。だが今日の相手は、倒れて、待っても返らん。“もう一度試せ"の獣とは、向きが逆だ。倒れた相手には、“これ以上試すな"が要る」

そして、カイナは、わたしの握っていた前提を、根元から裏返した。

「守るのは、倒れた相手じゃない。倒れた相手を待つ、お前の側だ」

わたしは、その一言で、足元が変わるのを感じた。

「倒れた相手を……直すんじゃない。待つのを、やめる」と、わたしは口にした。だが、すぐに、胸の奥が抵抗した。「でも、それは……見捨てることに、ならないんですか」

カイナは、即答しなかった。「鎖を断て。倒れたと分かったら、もう送るな」とだけ言って、次の檻へ手を伸ばした。

鎖を断ち、頃合いを見て繋ぎ直す

封印の自動結界

カイナが施そうとした契約を、封印の自動結界——失敗が続く送りを、一時的に遮断して、連鎖を防ぐ仕掛け——といった。Circuit Breaker と呼ぶ、とカイナは言った。

それは、三つの構えを持つ、とカイナは説明した。わたしの理解に合わせて、一つずつ。

  • 繋がっている構え。鎖は通じていて、普通に送る。ただし、失敗の数を、ずっと数えておく。これを Closed(閉) という
  • 断つ構え。失敗が 閾値——鎖を断つと決める、失敗の数の境目——を超えたら、鎖を断つ。以降は送らず、即座に 間に合わせの返答(Fallback)——遮断中に返す、軽くて安全な、間に合わせの答え——を返す。これを Open(開) という
  • 試す構え。一定の刻が過ぎたら、試しに一筋だけ送ってみる。通れば繋ぎ直し、まだ駄目なら、また断つ。これを Half-Open(半開) という

「肝は、ここだ」とカイナは言った。「断てば、送らない。送らねば、使い魔は、倒れた相手の前で立ち尽くさない。使い魔が空いていれば、健全な便は、倒れた相手に巻き込まれない。——倒れを、鎖の途中で断ち切る」

カイナが刻んだ結界は、これだ。

 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
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
export type BreakerState = "closed" | "open" | "half-open";

export interface BreakerOptions {
  failureThreshold: number; // closed→open:連続失敗がこの数に達したら断つ
  openDuration: number; // open→half-open:この論理時間が経過したら試験へ
  now: () => number; // 論理時計(テストが手で進める)
  fallback: (parcel: Parcel) => Receipt; // 軽量・安全な既定値
}

// 封印の自動結界=失敗が続く送りを一時的に遮断し、連鎖障害を防ぐ仕組み。
export class CircuitBreaker {
  private state: BreakerState = "closed";
  private failures = 0;
  private openedAt = 0; // 断った論理時刻
  private probing = false; // half-open:プローブが1本だけ飛行中か

  private readonly opts: BreakerOptions;

  constructor(opts: BreakerOptions) {
    this.opts = opts;
  }

  async call(
    parcel: Parcel,
    send: (p: Parcel) => Promise<Receipt>,
  ): Promise<Receipt> {
    // Open:刻が満ちていれば Half-Open へ(※遅延遷移=刻が満ちても"次の便"が来て
    //   初めて移る。背景タイマーを使わない=模擬戦で決定的)。満ちていなければ即 Fallback。
    if (this.state === "open") {
      if (this.opts.now() - this.openedAt >= this.opts.openDuration) {
        this.state = "half-open";
        this.probing = false;
      } else {
        return this.opts.fallback(parcel); // 鎖は断たれている:send を呼ばない
      }
    }
    // Half-Open:プローブは一筋だけ。既に飛んでいれば他は即 Fallback(群がりで再沈させない)
    if (this.state === "half-open") {
      if (this.probing) return this.opts.fallback(parcel);
      this.probing = true; // この便がプローブになる
    }
    // Closed、または Half-Open のプローブ:実際に送る
    try {
      const receipt = await send(parcel);
      this.onSuccess();
      return receipt;
    } catch {
      this.onFailure();
      return this.opts.fallback(parcel); // 失敗も握り潰して間に合わせを返す=応答性を保つ
    }
  }

  private onSuccess(): void {
    if (this.state === "half-open") this.state = "closed"; // プローブ成功=繋ぎ直す
    this.failures = 0;
    this.probing = false;
  }

  private onFailure(): void {
    if (this.state === "half-open") {
      this.trip(); // プローブ失敗=再び断つ
      return;
    }
    this.failures++;
    if (this.failures >= this.opts.failureThreshold) this.trip();
  }

  private trip(): void {
    this.state = "open";
    this.openedAt = this.opts.now();
    this.probing = false;
  }

  peek(): BreakerState {
    return this.state;
  }
}

そして、送りの手順は、こう変わった。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// After の送り:結界を「プールの外側」に置く。Open のとき結界は send を呼ばず
//   pool.run にすら入らない=「断てば使い魔を出さない」が文字通り真になる。
export async function dispatchGuarded(
  pool: CourierPool,
  breaker: CircuitBreaker,
  branch: Branch,
  parcel: Parcel,
): Promise<Receipt> {
  return breaker.call(parcel, () => pool.run(() => branch.send(parcel)));
}

「変えたのは、一点だけだ」とカイナは言った。「前の送りの中身——使い魔を確保して、配送所へ送る——を、そっくりそのまま、結界で包んだ。使い魔も、その数も、配送所も、何一つ変えていない」

dispatchDirect の本体は pool.run(() => branch.send(parcel)) だった。dispatchGuarded は、それを breaker.call(parcel, () => …) で丸ごとくるんだだけだ。差分は、その包み一つ。

そして、その包む位置に、意味があった。結界は、使い魔の采配の、外側にある。だから断たれているとき、結界は配送所へ送らないどころか、使い魔の采配にすら入らない。使い魔を、一頭も出さない。「断てば、待たない。掴まない」が、言葉のうえだけでなく、仕掛けのうえでも、そのまま真になる。

同じ災害を、結界で受ける

カイナは、Before と同じ災害を、今度は結界ごしに流した。

ただし、ここでカイナは、正直なところを隠さなかった。

「結界は、最初から見抜くわけじゃない。まず数便、送って、倒れていると知る。断つのは、知った後だ」

 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
const pool = new CourierPool(2); // 使い魔は二人(Beforeと同一)
const downed = new DownedBranch();
let clock = 0;
const downedBreaker = new CircuitBreaker({
  failureThreshold: 2,
  openDuration: 1000,
  now: () => clock,
  fallback,
});

// 学習期:最初の2便はまだ結界が倒れを知らないので、実際に送って待つ
//   (Before と同じく使い魔を確保する)。
const p1 = dispatchGuarded(pool, downedBreaker, downed, { region: "downed", id: "p1" });
const p2 = dispatchGuarded(pool, downedBreaker, downed, { region: "downed", id: "p2" });
await Promise.resolve();
await Promise.resolve();
assert.equal(pool.inFlight, 2); // 学習期は Before と同じく使い魔が塞がる
assert.equal(downedBreaker.peek(), "closed");

// 1便目が失敗→失敗1。まだ Closed。
downed.pending[0]!.reject(new Error("timeout"));
await p1;
assert.equal(downedBreaker.peek(), "closed");

// 2便目も失敗→失敗2=閾値到達→Open(断つ)。
downed.pending[1]!.reject(new Error("timeout"));
await p2;
assert.equal(downedBreaker.peek(), "open");

最初の二便は、結界もまだ倒れを知らないから、実際に送って、待った。Before と同じく、使い魔が二頭、塞がった。だが、その二便が失敗したところで、結界は閾値に達し、断った。状態は open

「結界が消すのは、最初の失敗そのものじゃない」とカイナは言った。「倒れと知ってなお送り続ける、際限のない連鎖のほうだ」

断った後が、Before との分かれ目だった。

1
2
3
4
5
6
// 断った後:倒れた配送所への便は結界がプール外で即 Fallback(pool.run に入らない=
//   使い魔を確保しない)。
const before3Calls = downed.callCount;
const p3 = await dispatchGuarded(pool, downedBreaker, downed, { region: "downed", id: "p3" });
assert.deepEqual(p3, { region: "downed", delivered: false }); // 間に合わせが返る
assert.equal(downed.callCount, before3Calls); // send は呼ばれていない=使い魔を出していない

断った後の、倒れた配送所への便は、即座に間に合わせの返答を受け取った。delivered は偽——本物の配達ではない、間に合わせだ。そして肝心なのは、callCount が増えていないこと。倒れた配送所へは、もう送られていない。使い魔は、一頭も出ていない。

だから、健全な便は、待たされる相手がいなかった。

1
2
3
4
5
6
7
// 健全な相手には、倒れた相手とは別の結界(healthyBreaker)を立ててある(結界は配送所ごとに分ける・後述)。
// 健全な地方への便は、空いた使い魔ですぐ出ていく(Before と違い凍らない)。
const healthyReceipt = await dispatchGuarded(pool, healthyBreaker, healthy, {
  region: "healthy",
  id: "h1",
});
assert.deepEqual(healthyReceipt, { region: "healthy", delivered: true });

健全な便は、すぐに届いた。Before では、倒れた相手を待つ使い魔に塞がれて、凍りついていた、あの便だ。同じ災害を流したのに、断った後は、凍らない。

わたしは、ようやく、息をついた。

「倒れていると知ってからは、倒れた相手への便は、使い魔すら出さずに、間に合わせを返す。だから使い魔が空いていて、健全な便が出ていける。同じ災害なのに、断った後は、健全な便が凍らない」

「倒れを、鎖の途中で断った」とカイナは言った。「倒れた相手より先は、道連れにしない。これが、結界の仕事だ」

ここで一つ、わたしは気づいたことがあった。カイナは、倒れた配送所の結界と、健全な配送所の結界を、別々に立てていた。使い魔の采配は一つきり、皆で分け合う。だが結界は、配送所ごとに分けてある。

「結界は、倒れた一つにだけ掛けろ」とカイナは言った。「道全部にまとめて掛けると、一つの不調で、健全な相手への道まで締め出す。それは、また別の暴走だ」

試すのは、一筋だけ

「断ったままでは」とわたしは訊いた。「倒れた配送所が直っても、ずっと繋がらないのでは?」

「だから、試す構えがある」とカイナは言った。刻を進めて、結界を半開——Half-Open——にした。ただし、ここでもカイナは、仕掛けの正直なところを言い添えた。

「刻が満ちても、結界が勝手に試し始めるわけじゃない。刻が満ちて、次の便が来て、初めて、試しの一筋が飛ぶ」

背景でひとりでに動く時計は、どこにもない。次の便が結界を叩いたとき、刻が満ちていれば半開へ移り、その便がそのまま試しの一筋になる。だから、模擬戦は、何度走らせても同じ順序をたどる。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// 刻を満たし Half-Open へ。便Aがプローブとして実際に飛ぶ(Deferred のまま未解決)。
clock = openDuration;
const callsBeforeProbe = downed.callCount;
const probeA = dispatchGuarded(pool, breaker, downed, { region: "downed", id: "A" });
await Promise.resolve();
assert.equal(breaker.peek(), "half-open");
assert.equal(downed.callCount, callsBeforeProbe + 1); // Aが唯一のプローブとして飛んだ

// Aが飛行中(未解決)のまま、同時に便Bを送る→プローブは既に飛んでいるので即 Fallback。
const receiptB = await dispatchGuarded(pool, breaker, downed, { region: "downed", id: "B" });
assert.deepEqual(receiptB, { region: "downed", delivered: false });

// 倒れた配送所への send 呼び出しは、Aの1回だけ(Bは2本目を送らせない)。
assert.equal(downed.callCount, callsBeforeProbe + 1);

便Aが、試しの一筋として飛んだ。配送所が返事をする前に——Aがまだ宙にある間に——便Bが来る。だが結界は、Bを送らせなかった。即座に間に合わせを返した。callCount は、Aの一回きり。Bは、二本目の試しにならない。

Bがその場で返るのは、結界が「試しはもう出ている」と見て、配送所へも使い魔の采配へも触れずに、手元で間に合わせを返すからだ。Bの応答は、Aの宙吊りとは別の糸で動く。だからAがどれだけ返事を待っていても、Bがそれに巻き込まれて止まることはない。回復しかけた相手へ、溜まった便が群がって押し倒す——その道そのものが、ここで断たれている。

「なぜ、試すのが一筋だけなんですか」と、わたしは訊いた。

「回復したばかりの相手に、溜まった便を一斉に送れば、また潰れる」とカイナは言った。「まず一筋。それが無事に通って初めて、皆を通す」

倒れたばかりの配送所は、まだ本調子ではない。そこへ、断っている間に溜まった便がいっせいに殺到すれば、立ち直りかけた相手を、もう一度押し倒す。だから、試すのは一筋だけ。その一筋の成否で、繋ぎ直すか、また断つかを決める。

その二つの結末を、カイナは見せた。一筋が通れば、繋ぎ直す。

1
2
3
4
5
// プローブ成功(resolve)→ Closed に繋ぎ直す。
downed.pending[downed.pending.length - 1]!.resolve({ region: "downed", delivered: true });
const probeReceipt = await probe;
assert.deepEqual(probeReceipt, { region: "downed", delivered: true });
assert.equal(breaker.peek(), "closed");

一筋が、まだ駄目なら、また断つ。そして、次に試すのは、刻がもう一度満ちてからだ。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// プローブ失敗(reject)→ 再び Open。openedAt が更新される。
downed.pending[downed.pending.length - 1]!.reject(new Error("still down"));
const probeReceipt = await probe;
assert.deepEqual(probeReceipt, { region: "downed", delivered: false });
assert.equal(breaker.peek(), "open");

// 以降は即 Fallback、刻が再び満ちるまで試験しない。
const callsBeforeRetry = downed.callCount;
const r1 = await dispatchGuarded(pool, breaker, downed, { region: "downed", id: "retry-too-soon" });
assert.deepEqual(r1, { region: "downed", delivered: false });
assert.equal(downed.callCount, callsBeforeRetry); // send は呼ばれていない=まだ断たれたまま

試しの一筋が失敗すると、結界はまた断ち、断った刻を覚え直す。そこから刻がもう一度満ちるまで、倒れた相手へは、一便も送らない。回復を、急かさない。

カイナは封印の基盤となる三つの刻印を指さした。それぞれの印が青白く、あるいは鈍く光りながら、見えない魔力の鎖で結ばれている。 「これが、自動結界の封印が持つ『三つの構え』と、その移り変わりだ」 その言葉に従うように、羊皮紙の上に三つの状態と、それらを繋ぐ移行の掟が描き出された。

Infographic state machine diagram showing Circuit Breaker states Closed, Open, and Half-Open with transition rules

「閉じた鎖、開いた鎖、と……半ば開かれた、試しの鎖」 わたしは印の並びをなぞりながら、その精緻な循環を理解し始めていた。 「ただ頑強に閉じるのでもなく、絶望して断ち切るだけでもない。再び繋ぎ直すために、あらかじめ『間』を用意してあるのですね」 「そうだ。その『間』を正しく刻むことだけが、群れを救う」 カイナの指先が、最後に半開(Half-Open)の印から閉(Closed)の印へと戻る道を力強く指し示した。

模擬戦を、まとめて走らせる

カイナは、檻のすべてを、まとめて走らせた。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
$ node --test
▶ Before: dispatchDirect(自動結界なし・直接送り)
  ✔ 1【連鎖】倒れた相手を待つ使い魔が塞がり、健全な便が凍る
  ✔ 2【誤診反証・使い捨て】早馬(リトライ)を増やすと、倒れた相手への到達がN倍に増えるだけ
▶ After: CircuitBreaker / dispatchGuarded(自動結界で送りを包む)
  ✔ 3【局所化】結界が断たれた後、健全な便は倒れた相手に巻き込まれない(Beforeと同じ災害)
  ✔ 4【Closed→Open】失敗が閾値に達すると断つ(境界=閾値ちょうど)
  ✔ 5【Open→Half-Open】刻が満ちると次の便で試験へ(境界=刻ちょうど・遅延遷移)
  ✔ 6【Half-Open単一プローブ・ゲート】回復中は一筋だけ通す(並行・群がり軸)
  ✔ 7【Half-Open→Closed】プローブ成功で繋ぎ直す(以降は普通に送れる・失敗カウントもリセット)
  ✔ 8【Half-Open→Open】プローブ失敗で再び断つ(刻が再び満ちるまで試験しない)
ℹ tests 8
ℹ pass 8
ℹ fail 0

八本、すべて緑。Before の網は、倒れた相手を待つ使い魔に塞がれて、健全な便まで凍りつかせた。After の網は、まるまる同じ災害を流しても、倒れと知った後は鎖を断ち、使い魔を空けて、健全な便を通した。刻が満ちれば一筋だけ試し、通れば繋ぎ直し、駄目ならまた断った。

変えたのは、送りを結界で包む、その一点だけだった。使い魔も、その数も、配送所も、タイムアウトも、健全と不全の別も、すべて Before と同じ。違うのは、倒れた相手への鎖を、失敗が続いた時だけ断つかどうか。その一点が、連鎖を断った。

結界が、守らないもの

カイナは、最後に、この結界が守らないものを、一つずつ線引きした。

「まず、倒れた配送所そのものは、直せん。結界が守るのは、呼ぶ側——お前の中央局だ。倒れた相手の魔力災害は、向こうが立ち直るのを待つしかない」

「間に合わせの返答は、軽くて安全なものに限れ。もし間に合わせが、本来の倍も手間のかかる別の重い道だったら、その間に合わせ自体が、新たな連鎖を生む。間に合わせは、ただの控えの答えにしておけ」

「使い魔を、機能ごとに分けて隔てるのは、また別の作法だ。結界は"送るのをやめる”。使い魔を"分けて隔てる"のは、別の獣の話だ。今日はやらない」

「さっきの、何度も試させる獣——あれと、向きが逆なのは言ったな。だが、敵ではない。一時の不調には試させる獣を、倒れて返らぬ相手には断つ結界を。組み合わせるなら、試させる獣の、外側に結界を掛ける」

わたしは、その線引きを、一つずつ、胸に刻んだ。

断つのは、繋ぎ直すための間

聖域を辞す前に、わたしは、ずっと胸につかえていたことを、自分で解いた。

「わたしは、見捨てたくなくて、抱え込んで、群れごと枯らしました。でも——鎖を断つのは、見捨てることじゃ、ないんですね」

カイナは、こちらを見た。

「倒れた相手を、待ち潰さない。健全な便を、守る。そして、頃合いを見て、また繋ぎに戻る。断つのは、繋ぎ直すための、間(ま)だ。……見捨てるのとは、逆だ」

見捨てない、というわたしの矜持は、間違っていなかった。ただ、抱え込むことと、守ることを、取り違えていた。賢く断つことで初めて、わたしは、一つも見捨てずに済む。

「網に、結界を張ります」と、わたしは言った。「倒れたら断ち、頃合いを見て繋ぎ直す。配送所ごとに、一つずつ。……最果てのこの道も、切り捨てずに、守れるように」

辞そうとして、わたしは、この庵を囲む封印に、もう一度目をやった。配達のたび通った道の終点に、なぜ幾重もの結界を編む人が住むのか。古い石に層をなす印は、わたしの網に今日張る小さな結界と、どこか同じ気配がした。

カイナは、多くを語らなかった。ただ、一言だけ。

「お前が今日、網に張る小さな結界。あれは、もっと古くて大きな結界を保つのと、同じ作法だ」

それ以上は、言わなかった。わたしも、訊かなかった。系譜の深さは、今日のわたしには、まだ遠い。

最果ての道を、わたしは戻っていった。これまでこの道は、切り捨てられないから通う、重荷だった。けれど今は、違う。倒れたら断ち、頃合いを見て繋ぎ直す——その作法を知った、一筋の鎖だ。重荷ではない。守り方を、わたしはもう、知っている。

中央局に着いたら、まず、北の外れの、あの小さな配送所の道に、結界を一つ、張ろう。


📜 カイナの魔獣契約録(Tamer’s Registry)

  • 魔獣名(クラス/パターン名): リンク・ウルフ(接続の鎖獣)/ カスケード障害(連鎖障害)。契約=封印の自動結界(Circuit Breaker=失敗が続く送りを遮断し連鎖を断つ/三状態 Closed・Open・Half-Open)
  • 危険度(難易度/バグの影響度): ★★★★★(倒れたのは末端一つでも、待ち続けることで中央局が応答を失い、沈黙が呼び出しの鎖を登って、無関係な健全機能まで全停止=部分障害が全体障害へ。エラーを返さず「待ち」で固まるため発覚も復旧も遅い)
  • 主な生態(アンチパターンの特徴):
    • 倒れた依存先を待つこと自体が並行リソース(使い魔/スレッド/接続)を食い潰す。倒れたと分かっても送り続けるため失敗が止まらず、応答性の喪失が呼び出しの鎖を登って伝播する(カスケード障害)
    • 「見捨てたくない」一念での待ち延長・送り直しの増強は逆効果=倒れた相手への群がり(到達回数)を増やし、共倒れを早める(リトライ増幅)
    • 害はタイミング依存で、繁忙時に倒れた相手への便が積もったときだけ全停止に至る=平時に露見しにくい
  • 契約のポイント(設計の要点):
    • 三状態の自動結界:Closed(通常・失敗を数える)→失敗が閾値超過で Open(断つ・即 Fallback で使い魔を出さない)→刻が満ちて Half-Open(次の便で一筋だけプローブ・遅延遷移)→成功で Closed(繋ぎ直す)/失敗で Open(再び断つ)
    • 論理的保証:Open=送らない=待たない=使い魔を出さない=健全な便が倒れた相手に巻き込まれない。結界をプールの外側に置き「断てば使い魔を出さない」を実装でも真にする
    • Half-Open は単一プローブ・ゲート=回復中の相手に溜まった便を一斉送出すると再沈するため、プローブは一筋のみ通し他は即 Fallback(群がり/thundering herd の回避)
    • 1:1 単一差分=Before の本体 pool.run(() => branch.send(parcel))breaker.call(parcel, () => …) で丸ごと包む一点のみ。使い魔(CourierPool)・容量・配送所・タイムアウトは不変。学習期(倒れと知るまでの数便)は実際に待つ=結界が断つのは閾値到達後の連鎖であり、最初の失敗そのものは防がない
  • 契約外事項(保証しないこと):
    • 倒れた配送所そのものは直せない(結界は呼ぶ側=中央局を守る)
    • Fallback は軽量・安全な既定値に限る。重い代替経路は新たな連鎖を生む(フォールバック増幅)
    • リソースの物理隔離(使い魔を機能ごとに分けて隔てる)は別の獣。結界は「送るのをやめる」、隔離は「分けて隔てる」
    • 送り直し(一時的障害からの回復=「もう一度試せ」)とは逆向き(「これ以上試すな」)。組み合わせる場合は送り直しの外側に結界を掛ける
    • 倒れの単位を取り違える誤用:配送所ごとに結界を分けねば、一つの不調で健全な相手への道まで断つ(部分障害を全体障害に誤変換)。結界は倒れた一つにだけ掛ける
  • 現在のステータス: 🟢 倒れた相手への鎖を、失敗が続いた時だけ断ち、即座に軽い間に合わせを返して使い魔を空け、頃合いを見て一筋だけ試して繋ぎ直す自動結界の契約成立(呼ぶ側を守り、倒れを鎖の途中で断つ)。断つことは、見捨てることではなく、繋ぎ直すための間——抱え込んで共倒れする一念を、配送所ごとの結界へと組み替えた
comments powered by Disqus
Hugo で構築されています。
テーマ StackJimmy によって設計されています。