Featured image of post コードテイマー【Exponential Backoff】焦りの雷鳥は、嵐の前に羽を使い果たす〜待つ刻を倍にし、揺らぎで群れを散らす〜

コードテイマー【Exponential Backoff】焦りの雷鳥は、嵐の前に羽を使い果たす〜待つ刻を倍にし、揺らぎで群れを散らす〜

魔力嵐で本塔が黙るたび、通信士は雷鳥を連発した。だが連発するほど届かない——焦りの雷鳥は、嵐が去る前に羽を使い果たす。失敗ごとに待ちを倍へ伸ばす指数バックオフと、群れを散らすFull Jitterの契約を、sleepを論理時計に変えた模擬戦で検証する物語。

届かぬ報せ

魔物使いが来たのは、嵐が抜けた翌朝だった。

俺は辺境の中継伝達所をひとりで預かっている。仕事は単純だ。麓の村々から上がってくる報せを、雷鳥(伝令の獣)に結んで、稜線の向こうの本塔へ継ぐ。本塔がそれを受けて、王都へ流す。止まり木に並んだ雷鳥を一羽放ち、向こうが受け取れば、それで一件。何年もそうやってきた。

厄介なのは、魔力嵐だ。嵐が稜線を覆うと、本塔は黙り込む。雷鳥を放っても、結界に弾かれて戻ってくる。そういうときの俺のやり方は、決まっていた。もう一羽放つ。それでも戻れば、また放つ。届くまで放つ。リトライ——失敗した送りを、もう一度試みること。そんな上等な名前があると知ったのは、あの女が来てからだ。俺にとっては、ただの「数撃ちゃ当たる」だった。

先の大嵐までは、それで通っていた。

呼んだ覚えはない。嵐の夜、狼煙は雨に死に、伝書の鳩も飛べず、残ったのは止まり木の雷鳥だけだった。俺は最後の一羽に「腕利きの魔物使いを寄越してくれ」と短く結んで、行き先も決めず、闇雲に空へ放った。当てなんてなかった。

その一羽が、たまたまこの女のいる方角へ落ちたらしい。カイナと名乗った。稜線を越えて、嵐の晴れ間に、ひとりで歩いてきた。

「悪い報せほど、早く飛ぶ」とカイナは止まり木を見て言った。「お前があの夜に放った雷鳥は、何十羽だ。その中で、ちゃんと行き先に届いたのは——一羽だけだったろう」

虚を突かれた。確かに、当てずっぽうに放った最後の一羽だけが、こうして使い手を連れてきた。連発した残りは、どこへ消えたのか俺も知らない。だがその皮肉に俺が気づくのは、もっと後だ。

数撃てば、当たるはずだった

「本塔が嵐で黙る。雷鳥が弾かれる。なら、もっと放つ」俺は止まり木の雷鳥を顎で示した。「届くまで放てば、いつか一羽は通る。今までずっと、そうやって嵐をやり過ごしてきた」

カイナは何も言わず、俺の術式(コード)を写した巻物へ目を落とした。俺は続けた。半分は言い訳だったかもしれない。

「だが、先の大嵐は違った。放っても放っても弾かれる。だから手数を増やした。間を詰めて、立て続けに放った。なのに——本塔は、よけいに深く黙り込んだ。最後は一羽も通らねえ。報せが丸ごと落ちた」声が掠れた。「数撃ちゃ当たるはずだ。なのに、撃つほど当たらねえ。どういうことなんだ、それは」

巻物に書いてあるのは、こういう術式だった。失敗したら、すぐ次を放つ。当たり前のことしか書いていない。

 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
// 一度の送信試行(雷鳥を一羽放つ)。成功すれば応答 T、一時障害なら reject
export type Attempt<T> = () => Promise<T>;

// 待機(DI)。本番は実時間待ち、模擬戦では即解決+累積を論理時計に使う
export type Sleep = (ms: number) => Promise<void>;

// 揺らぎ源(DI)。[0,1) を返す。模擬戦では決定的に固定/差し替える
export type Random = () => number;

export type RelayOptions<T> = {
  attempt: Attempt<T>;
  sleep: Sleep;
  random: Random;
  maxRetries: number; // 最大リトライ回数(試行は maxRetries+1 回)
  baseMs: number;     // 初回の待機基準
  capMs: number;      // 待機の上限(頭打ち)
};

export class RelayBefore<T> {
  private attempt: Attempt<T>;
  private sleep: Sleep;     // 持ってはいるが、使っていない
  private random: Random;   // 同上
  private maxRetries: number;
  private baseMs: number;
  private capMs: number;

  constructor(opts: RelayOptions<T>) {
    this.attempt = opts.attempt;
    this.sleep = opts.sleep;
    this.random = opts.random;
    this.maxRetries = opts.maxRetries;
    this.baseMs = opts.baseMs;
    this.capMs = opts.capMs;
  }

  async send(): Promise<T> {
    let lastErr: unknown;
    for (let n = 0; n <= this.maxRetries; n++) {
      try {
        return await this.attempt();   // 雷鳥を一羽放つ
      } catch (err) {
        lastErr = err;                 // 失敗。——間を空けず、次の周回へ
      }
    }
    throw lastErr;                     // 撃ち尽くして最終失敗
  }
}

カイナは巻物を端まで追ってから、止まり木の雷鳥に手をやり、稜線の向こうの本塔の影へ目をやった。すぐには名を下さなかった。

「お前の雷鳥は、速い。速すぎる」と、低く言った。「嵐は、お前の雷鳥より、ずっと長く吹く」

意味が掴めなかった。速いのは美徳だろう。一刻も早く届けるのが、伝達所の仕事だ。

カイナは空の一点を指した。嵐が抜けかけて、本塔の影がかすかに輪郭を取り戻そうとしている、その方角だ。「あの塔は今、ようやく立ち上がろうとしている。嵐に打たれて、膝をついて、それでも起きようとしている。——そこへお前は、何十羽を一度に叩きつけた。立ちかけた者の肩を、もう一度押し倒すように」

俺は黙った。答えは、まだ返ってこなかった。

嵐を、檻に閉じ込める

カイナは伝達所の卓に、奇妙な仕掛けを組みはじめた。本人いわく「模擬戦」——嵐は、半日も吹く。その半日を、卓の上の数行へ畳み込む檻だ。

仕掛けの作りを、俺にも分かるように説明してくれた。本塔は、ある刻まで嵐で黙り、その刻を過ぎれば応える。その「嵐が去る刻」を、こちらで決められるようにする。雷鳥を放つ術(attempt)も、待つ間(sleep)も、外から握れる作り物に差し替える。

肝はここだった。待つ間(sleep)は、模擬戦では実際には待たない。代わりに、待つはずだった時間を一つの帳面に足し込んでいって、その合計を「今、何刻まで来たか」とみなす。待ち時間の累計が、そのまま時計の針になる。放った雷鳥の数も数える。これで、嵐が吹く前と、過ぎた後を、思いのままに行き来できる。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// 嵐の本塔と論理時計を組む共通ハーネス
function makeStorm(stormEndsAt: number) {
  let elapsed = 0;             // 積み上げた待機=論理時刻(時計の針)
  const sleeps: number[] = []; // 何ミリ秒待ったかの記録
  const sleep = (ms: number): Promise<void> => {
    elapsed += ms;             // 待つ代わりに、針を進める
    sleeps.push(ms);
    return Promise.resolve();
  };
  let attempts = 0;
  const attempt = (): Promise<string> => {
    attempts++;
    // 針が「嵐が去る刻」を越えていれば応答、まだ嵐なら一時障害で弾く
    return elapsed >= stormEndsAt
      ? Promise.resolve("delivered")
      : Promise.reject(new Error("storm"));
  };
  return { sleep, sleeps, attempt, elapsed: () => elapsed, attempts: () => attempts };
}

「待たずに時計だけ進めるなら、嵐の半日も、瞬きの間に通り抜けられる」とカイナは言った。本塔がいつ口を開くかは stormEndsAt の一語で決まり、針がそこを越えたかどうかで、雷鳥が弾かれるか通るかが決まる。針が必ず「進んでから」次の試行で読まれるのは、send が待ち(sleep)と試行(attempt)を一つずつ順に await していくからだ。同じ刻に二羽が並んで判定を奪い合うことはない——だから、待ちの累計をそのまま時計と呼べる。曖昧なところは、どこにもない。

そして、俺の術式を——即時連射のやつを——その檻に載せた。嵐は、針にして千の刻まで吹くことにした。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
it("即座に連射し、嵐が去る前に手持ちを撃ち尽くして最終失敗する", async () => {
  const h = makeStorm(1000); // 嵐は論理時刻 1000 まで
  const relay = new RelayBefore<string>({
    attempt: h.attempt, sleep: h.sleep, random: () => 0.5,
    maxRetries: 5, baseMs: 100, capMs: 2000,
  });

  await assert.rejects(() => relay.send(), { message: "storm" });
  assert.equal(h.sleeps.length, 0); // 一度も待っていない
  assert.equal(h.elapsed(), 0);     // 時計は 0 のまま=全弾が「嵐の中」
  assert.equal(h.attempts(), 6);    // maxRetries+1 羽を撃ち尽くした
});

走らせると、結果は素っ気なかった。雷鳥は間髪入れず連射され、六羽——手持ちの全部——が一息に飛び、一息に弾かれた。時計の針は、0 から一刻も動いていない。

嵐が去る前に、羽は尽きる

「待て」と俺は言った。「なんで一羽も通らねえんだ。嵐だって、いつかは止むんだろう」

カイナは答えなかった。ただ、時計の針を——elapsed を指した。「数えてみろ」

俺は見た。雷鳥が六羽すべて飛び終わってもなお、針は 0。嵐が去る刻は千だというのに、針はそこへ一歩も近づいていない。カイナはそれ以上、何も言わなかった。その沈黙が、俺に続きを考えさせた。針が動いていない。ということは——待っていない。一秒も。

「……嵐が去る前に、手持ちを全部使い切ったのか」声が低くなった。「嵐が明けた後に放てる雷鳥が、もう一羽も残っちゃいねえ。明ける頃には、俺はとっくに諦めてる」

掴めなかった逆説が、誰に教えられたわけでもなく、自分の口から像を結んだ。連発は、当たる確率を上げるどころか、嵐の後に残るはずの最後の手を、自分で先に潰していた。

カイナは本塔の影を見たまま、刻むように続けた。

「お前の雷鳥は、自分が届くことしか考えていない。嵐で弱った塔に、群がって覆いかぶさる」

「で、つまり?」俺は急かした。

「塔が息を継ごうとするたび、お前の鳥がまた口をふさぐ。応えようにも、応える隙がない」

「……じゃあ、俺の連発が、本塔の立ち直りを、よけいに遅らせてたってのか」

「待つというのは」とカイナは俺を見た。「お前の雷鳥のためじゃない。叩かれる塔のためだ。塔に息を継がせる間を空ける。それだけが、嵐を早く終わらせる」

リトライは利己的なのだ、とカイナは言った。自分が通りたいがために、向こうに負荷を押しつける。待つのは譲歩じゃない。弱った相手を立ち直らせて、結果として自分の報せを通すための、唯一の筋道だった。

カイナは止まり木へ顎をしゃくった。「お前が飼っているのは、ただの伝令じゃない。焦りの雷鳥だ。急くほどに羽を散らして、嵐の前に使い果たす」

俺は、どうにも納得のいかないことを口にした。「だったら、放つ前に分かりゃいい。今は嵐だから待て、今は晴れたから放て、って。先に分かりゃ、無駄撃ちせずに済むだろう」

「『今、向こうが嵐か』は」とカイナ。「放って、弾かれて、初めて分かる。放つ前には決まっていない。だから、撃つ前に止めるんじゃない——弾かれた後に、どう退くかを、術式に持たせるしかない」

書いた時点では、向こうの空模様は誰にも見えない。見えないものを先回りで防ぐことはできない。できるのは、弾かれてから、どう身を引くかだ。

「……間を空けりゃいいのか」俺は唸った。「だが、どれだけ空ける? やみくもに長く待ったら、今度は嵐が明けてるのに気づかねえ。それじゃ遅すぎる」

問いが、自分から次へ転がっていた。

間を置く、それだけの契約

カイナの直しは、呆気ないものだった。術式を組み替えるでも、新しい鎖を足すでもない。俺の catch——失敗を受け止めるあの場所に、待つ一行を、ただ一つ挿んだだけだった。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
async send(): Promise<T> {
  let lastErr: unknown;
  for (let n = 0; n <= this.maxRetries; n++) {
    try {
      return await this.attempt();
    } catch (err) {
      lastErr = err;
      if (n < this.maxRetries) {
        await this.sleep(this.backoff(n)); // ★ここだけ:次を放つ前に、間を空ける
      }
    }
  }
  throw lastErr;
}

「変えたのは、待つか待たないか。それだけだ」とカイナは言った。雷鳥を放つ、通れば返す、上限まで試す、尽きれば諦める——術のすることは何も変わっていない。変わったのは、失敗と次の試行の間に、間ができたこと。たったそれだけだった。

どれだけ待つかを決める

「待つ、と決めた。あとは——どれだけ待つかだ」

カイナは backoff を示した。待ち時間を決める、短い手だ。

1
2
3
4
5
// 賢い待ち方の「内訳」:指数で伸ばし cap で頭打ち、揺らぎで散らす
private backoff(n: number): number {
  const exp = Math.min(this.capMs, this.baseMs * 2 ** n); // 指数で伸ばし、capで頭打ち
  return this.random() * exp;                             // 0〜expの間で揺らす
}

「一度目の失敗は、短く退く。二度目はその倍。三度目はさらに倍」。失敗のたびに、次までの待ちを倍々に伸ばす。これを指数バックオフ——失敗のたびに再試行までの待機を指数的に(倍々に)伸ばすやり方、という。「嵐が長引くほど、深く退く。立ち上がろうとする塔に、たっぷり間を与えるためだ」

ただし、際限なく伸ばしはしない。ある上限で頭打ちにする。これをcap——待ち時間の天井、と呼ぶ。「退きすぎれば、嵐が明けても気づくのが遅れる」。退きそのものが塔のための譲歩なら、この天井だけは、こちら側の都合だ。報せを抱えたまま無限に沈み込まない、その歯止め。「深く退くにも、限度を決めておく」

俺は、もう一つ目に留まったものを指した。待ち時間に、何か乱数のようなものが掛かっている。

「これか」とカイナは this.random() の一語を指したが、深くは語らなかった。「これは、お前一羽のための仕掛けじゃない。意味は、後で分かる」。それ以上は説明せず、術式はそのまま——最初から、その乱数を含んだ形——で置かれた。

同じ嵐で、直した術式を走らせた。今度は針が進む。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
it("失敗ごとに待ち、嵐が去った後の一羽が届いて成功する", async () => {
  const h = makeStorm(1000);
  const relay = new RelayAfter<string>({
    attempt: h.attempt, sleep: h.sleep,
    random: () => 1.0,  // 揺らぎを最大に固定=指数値そのもの(決定的に確かめるため)
    maxRetries: 10, baseMs: 100, capMs: 2000,
  });

  const result = await relay.send();
  assert.equal(result, "delivered");  // 嵐の後に届いた
  assert.ok(h.elapsed() >= 1000);     // 累積した待ちが、嵐の長さを越えてから成功
});

一羽放って、弾かれる。百の刻、退く。また放つ。弾かれる。二百、退く。三度退いて累計は七百、まだ嵐の中だ。四度目に八百退いて、累計は千五百——ここで初めて嵐の千を越え、本塔はもう口を開いていた。次の一羽が、通った。

ここで random() => 1.0 に固定しているのには、わけがある。揺らぎを最大の 1.0 に張りつければ、待ち時間は指数の値そのものになり、毎回きっかり 100, 200, 400, 800 と積み上がる。乱数を使う術式でも、乱数の出方をこちらで握ってしまえば、結果は一通りに定まる。模擬戦が、まぐれでも気まぐれでもなく、いつ走らせても同じ針を刻む——だから「待ったから届いた」と、迷いなく言い切れる。

待ちが倍々に伸び、天井で頭打ちになることも、針を覗けば確かめられた。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
it("待機は失敗ごとに倍へ伸び、cap で頭打ちになる", async () => {
  const h = makeStorm(Number.POSITIVE_INFINITY); // 永遠の嵐=必ず全失敗
  const relay = new RelayAfter<string>({
    attempt: h.attempt, sleep: h.sleep, random: () => 1.0,
    maxRetries: 6, baseMs: 100, capMs: 800,
  });

  await assert.rejects(() => relay.send());
  // min(800, 100*2^n): 100,200,400,800,800,800(最後の失敗の後は、次が無いので待たない)
  assert.deepEqual(h.sleeps, [100, 200, 400, 800, 800, 800]);
});

100, 200, 400 と倍に伸び、800 で天井に着いて、あとは 800 のまま。退きは深くなり、しかし無限には沈まない。「待ったから、嵐の後まで雷鳥が生き残った」とカイナは言った。「お前の前のやり方は、嵐の前に羽を使い果たしていた。それだけの違いだ」

焦りの雷鳥は、嵐の前に羽を使い果たす。直したのは、それを使い果たさないように、間を置いただけ。

群れは、同じ刻に飛ぶ

これで終わりか、と肩の力が抜けかけたとき、カイナが横から新しい事実を置いた。

「辺境の伝達所は、お前のところだけじゃない」

そうだ。稜線沿いには、同じような中継所がいくつもある。どこも同じ本塔へ報せを継いでいる。

「嵐が来れば、どの伝達所も、お前と同じように雷鳥を退かせる。皆が同じだけ退き、皆が同じ刻に待ち終え——」カイナは言葉を切った。「皆が、同じ刻に、一斉に飛び立つ」

背筋が冷えた。嵐が明けた、まさにその瞬間。立ち直りかけた本塔へ、稜線じゅうの伝達所の雷鳥が、揃って殺到する。せっかく退いて与えた間が、明けた途端に帳消しになる。群れごと覆いかぶさって、塔をまた焼く。これをThundering Herd——殺到する群れ、と呼ぶらしい。俺がひとりで間を詰めて自滅するのが連射の暴走なら、こっちは、退きを覚えた伝達所どうしが、皆そろって同じ拍子で戻ってきてぶつかる暴走だ。原因は逆を向いているのに、潰れるのは同じ本塔だ。皆が同じ拍子で退くから、同じ拍子で戻ってくる。

「待てよ」俺は当然の反論をした。「俺一羽が退く刻をずらしたって、他の連中が揃って飛んだら、波は立つだろう。俺だけがバラけて、何になる」

「波は立つ」とカイナは認めた。「だが、その波を作る側を降りる第一歩は、お前の術式だ。それに——」カイナは止まり木の雷鳥へ目をやった。「辺境の伝達所は、どれも同じ古い術式の写しを使っている。お前が直した一枚が、巡って、皆に配られる」

連発が利己なら、退きをずらすのは、譲り合いだ。自分が波を立てない側に回る。それが巡って、本塔を守り、結局は自分の報せを通す。

俺は、さっきの乱数を思い出した。

「……待つ刻が、皆同じだからだ。同じだけ待ちゃ、同じ刻に飛ぶ。なら——待つ刻を、一羽ずつ、わざとバラけさせりゃいい」

「それが、さっきの random() だ」とカイナ。「最初から、そこにあった」

待ち時間を、0 から、さっき決めた指数の値までの間で、ランダムに選ぶ。これをFull Jitter——待ちを「0から指数値までの一様乱数」で選ぶ揺らぎの入れ方、という。退きの深さは指数で決め、その範囲のどこに着地するかは、一羽ずつ運任せにする。群れの飛び立ちが時間に散らばり、本塔は一羽ずつ捌けるようになる。「揃えて待つな。散らして待て」

カイナは、それを数で見せた。同じ術式を、百の伝達所ぶん並べて走らせる。揺らぎを殺した場合と、Full Jitter を効かせた場合で。断っておくと、「揺らぎを殺す」といっても、別の術式に差し替えるわけじゃない。さっきの random() に毎回きっかり 1.0 を返させ、待ちを指数の上限へ張りつけて、全員を同じ値に揃えるだけだ。式は一文字も変えていない。揺らぎを入れるか殺すかは、供給する乱数の出方だけで決まる。

 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
// N 個の伝達所が同じ嵐で最初に退く「待ち時間」を集める
async function firstWaits(N: number, random: Random): Promise<number[]> {
  const waits: number[] = [];
  for (let i = 0; i < N; i++) {
    const sleeps: number[] = [];
    const sleep: Sleep = (ms) => { sleeps.push(ms); return Promise.resolve(); };
    const attempt: Attempt<string> = () => Promise.reject(new Error("storm"));
    const relay = new RelayAfter<string>({
      attempt, sleep, random, maxRetries: 1, baseMs: 100, capMs: 2000,
    });
    await relay.send().catch(() => {});
    waits.push(sleeps[0]); // 最初の再送までの待ち
  }
  return waits;
}

it("群れ: 揺らぎなしは同一刻に集中し、Full Jitter は分散する", async () => {
  const N = 100;

  // 揺らぎなし:全員 random=1.0 → 全員 backoff(0)=100 で同値
  const synced = await firstWaits(N, () => 1.0);
  assert.equal(peakInBucket(synced, 10), N); // 100羽が同じ窓に殺到

  // Full Jitter:決定的な擬似乱数で [0,100) に散らす(シード固定=再現可能)
  let seed = 1;
  const rand: Random = () => {
    seed = (seed * 1103515245 + 12345) & 0x7fffffff;
    return seed / 0x7fffffff;
  };
  const jittered = await firstWaits(N, rand);
  // 実測ピーク値: 15(N/2=50 を十分下回り安定)
  assert.ok(peakInBucket(jittered, 10) < N / 2);
});

ここで使う物差しが peakInBucket だ。退いた刻を、十刻ごとの窓に振り分けて、一番混んだ窓に何羽詰まったかを数える。これが、群れの殺到の度合いになる。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
function peakInBucket(values: number[], bucketMs: number): number {
  const counts = new Map<number, number>();
  let peak = 0;
  for (const v of values) {
    const b = Math.floor(v / bucketMs);
    const c = (counts.get(b) ?? 0) + 1;
    counts.set(b, c);
    if (c > peak) peak = c;
  }
  return peak;
}

揺らぎを殺すと、百羽が一つの窓に揃って詰まった。ピークは、きっかり百。全部が同じ刻に飛ぶ。Full Jitter を効かせると、同じ百羽が刻の上に散り、一番混んだ窓でも十五羽までしか重ならなかった。百が十五へ。[0,100) の幅に百羽を撒けば、十刻ごとの窓は十個ぶん——一窓あたり平均すれば十羽前後で、十五はそのばらつきの内に収まる。だからこの十五は窓の幅(bucketMs)しだいで動く目安であって、きっかりの数そのものに意味があるわけじゃない。テストが見るのも「ピークが半分(五十)を割るか」——群れがほどけたか否かの境であって、何羽きっかりか、ではない。これだけ散れば、本塔も一羽ずつ受けられる。

ここでも、揺らぎは運任せに見えて、種(シード)を固定した擬似乱数を使っている。だから「散った」という結果も、まぐれではなく、いつ走らせても同じ十五に落ち着く。乱数を使う仕掛けほど、検める時には乱数の出所をこちらで握る。それで初めて、「揺らぎが群れを解いた」と数で言い切れる。

断っておくと、この百羽の模擬戦で並べたのは、さっき直した術式(RelayAfter)を、ただ百個こしらえただけだ。術式そのものは一文字も変えていない。群れに効かせるからといって、別物に組み替えたわけじゃない。群れの散り方は最初の退きで決まるから、ここでは一度だけ退かせれば足りる(maxRetries: 1)。回数を絞ったのは、測りたいものに合わせて手数を減らしただけで、中身は直したときのままだ。変えたのは、相変わらず、あの待つ一行だけだった。 この「群れがほどける」という現象を、伝達所と本塔のやり取りに引き写して考えてみる。

まずは、揺らぎを殺して(Jitterなし)全員を同じ待ち時間に揃えた場合だ。

Infographic showing a ‘No Jitter’ retry storm in a dark fantasy style. Three outposts send retry signals at the exact same time (t=8) to the Central Tower. The signals collide simultaneously, creating a red and amber magic explosion of overload at the tower, illustrating how synchronized retries crash a system.

そしてこちらが、揺らぎ(Full Jitter)を効かせて、各自がバラバラの待ち時間を選択した場合だ。

Infographic showing a ‘Full Jitter’ decentralized retry mechanism in a dark fantasy style. Three outposts stagger their retry signals at different times (t=3, t=6, and t=9) to the Central Tower. Each signal arrives separately and is safely processed with a green checkmark, illustrating how randomized delay prevents system overload.

揺らぎがない上の状況では、三所の雷鳥が同じ刻にぶつかって、本塔がまた潰れてしまう(再送衝突)。しかし、揺らぎを効かせた下の状況では、退く刻が一羽ずつずれて、本塔が順に捌いていくことができる。退きを倍々にするだけでは、群れは揃ったまま戻ってくる。揺らぎを足して初めて、群れがほどけるのだ。

待っても、縛れないもの

「これで」と俺は訊いた。「嵐は、もう怖くねえか」

カイナは止まり木の雷鳥を一羽、指の腹で撫でた。羽が落ち着くのを待つように、ひと呼吸おいてから口を開いた。

「この退きが効くのは、嵐がいつか去るときだけだ。一時の不調——それが大前提だ」。一時的に塞がっているだけで、待てば必ず通じる相手。そういう障害を一時的障害と呼ぶ。「本塔が嵐じゃなく、崩れて完全に死んでいるなら、何度退いても応えはしない。待つだけ無駄に雷鳥を捨てることになる。何度退いても無駄な相手には、いっそ放つのをやめ、結界を下ろして傷を広げない見極めが要る。それは別の獣の領分だ」。失敗が続けば送りを自動で遮断して、連鎖の倒れを防ぐ仕組み——Circuit Breaker、封印の自動結界。それはまた、別のいつか名を付けることになる、とカイナは言った。

「それと」とカイナは俺を見た。「同じ報せを二度放っても害がない——それが、退いて放ち直すことの、もう一つの前提だ」。同じことを何度繰り返しても結果が変わらない性質を、冪等という。「お前の雷鳥が運ぶのが、ただの報せなら、二度届いても害はない。だが、もし運ぶのが『荷を一つ送れ』のような、二度効けば二度起きる命令だったら——退いて放ち直した一羽が、向こうにもう一度、荷を送らせる。待つことが、そのまま二重の事故になる」

二度効けば、二度起きる。俺はその図を思い浮かべて、ぞっとした。

「届いたか分からぬまま、もう一羽放つ。両方が効いて、荷が二つ出る。——それは、別の街で双頭の犬を飼っていた、どこかの誰かの話だ」とカイナは言った。誰のことかは知らない。だが、再送という手は、二度効いて困らない命令にだけ許される。それだけは、肝に銘じた。

縛れる相手と、そうでない相手。その境が、ようやく見えた。退きで救えるのは、いつか目を覚ます塔だけだ。死んだ塔にも、二度効けば二度起きる荷にも、退きは届かない——そこには別の手がいる。それでも、嵐に黙るだけの塔が相手なら、俺はもう、焦って羽を撒き散らしはしない。

見送り

檻は、そのまま伝達所に置いていくという。連射する古い術式も、退きを覚えた術式も、掛けたままだ。連射する方には「嵐の前に尽きる」ことを、退く方には「嵐の後に届く」ことを、それぞれ刻みつけて。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
$ node --test
▶ After: RelayAfter(手懐けた魔獣・賢く待つ)
  ✔ 失敗ごとに待ち、嵐が去った後の一羽が届いて成功する
  ✔ 待機は失敗ごとに倍へ伸び、cap で頭打ちになる
  ✔ 群れ: 揺らぎなしは同一刻に集中し、Full Jitter は分散する
▶ Before: RelayBefore(暴れる魔獣・即時連射)
  ✔ 即座に連射し、嵐が去る前に手持ちを撃ち尽くして最終失敗する
ℹ tests 4
ℹ pass 4
ℹ fail 0

これからは、この檻が見張る。次に誰かが——あるいは俺自身が、また気が急いて——間を詰めようとすれば、嵐の前に羽が尽きるその姿が、卓の上で先に捕まる。

その日の夕、嵐は本当に去った。稜線の影が晴れて、本塔の輪郭が戻った。俺は止まり木から雷鳥を一羽だけ取って、空へ放った。そして——放った手を、次の一羽へ伸ばさなかった。待った。

「待つのは、性に合わねえ」と、つい口に出た。

「だが、待てない奴の雷鳥は」とカイナは稜線の方を向いたまま言った。「嵐の前に、羽を使い果たす」

しばらくして、稜線の向こうの本塔に、小さく灯がともった。一羽、届いた。連発しなくても、たった一羽で、届いた。

カイナはもう、伝達所に背を向けて、晴れた稜線の下を歩き出していた。呼び止めようとして、やめた。次に何かが黙り込んでも——今度は、自分で待てる気がした。


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

  • 魔獣名(クラス/パターン名): サンダーバード(焦りの雷鳥)/ 即時連射の自滅(単一クライアント)と Thundering Herd(群れの同期再送)(指数バックオフ+Full Jitter で馴致)
  • 危険度(難易度/バグの影響度): ★★★★☆(平時は無害。相手が一時障害で弱った刻にだけ牙をむき、即時連射が復旧を妨げ、群れが揃えば回復しかけた相手をもう一度倒す)
  • 主な生態(アンチパターンの特徴):
    • 失敗するたび、間を空けず次を放つ(catch で何もせず周回)。一時障害の相手には、嵐が去る前に手持ち(maxRetries+1 回)を撃ち尽くし最終失敗する
    • 弱った相手へ高頻度で叩き込むため、相手の復旧そのものを遅らせる(リトライは利己的)
    • 複数のクライアントが同じ拍子で退くと、同じ刻に揃って再送し、回復しかけた相手をまた倒す(クラスタリング)
  • 契約のポイント(設計の要点):
    • 変えたのは「待つか待たないか」1点。catch 内に await this.sleep(this.backoff(n)) を一つ挿むだけ。for・上限・成功時 return・最終 throw のロジックは Before と同一
    • 待ちの中身は、指数で倍々に伸ばし(Exponential Backoff)、cap で頭打ちにし、Full Jitter(random() * exp)で散らす。相手に息を継がせ、群れの再送を脱同期する
    • sleep を論理時計に流用し、random を固定して、実時間ゼロ・決定的に検証。群れの脱同期は同じ術式を N 個並べて定量化(術式自体は不変)
  • 契約外事項(保証しないこと):
    • 嵐がいつか去る(一時障害)前提。完全に死んだ相手には待つだけ無駄=遮断(Circuit Breaker)の領分
    • 二度効けば二度起きる命令(非冪等)の再送は二重実行になる。冪等な送りにのみ許される
    • 多層で各層が独立に退くと、総試行は層の数だけ掛け算で膨らむ(各層が3回退けば3層で27倍)。退くのは単一の層だけにするのが原則
    • 一時障害かどうかの判定(待つ価値のある失敗か)は本話では前提とし、別途見極めが要る
  • 現在のステータス: 🟢 焦りの雷鳥を馴致(嵐がいつか去る前提で・群れは揺らぎで散らす)/死んだ塔への見極めと、二度効く荷は別領分
comments powered by Disqus
Hugo で構築されています。
テーマ StackJimmy によって設計されています。