Featured image of post コードテイマー【Eventual Consistency】取りこぼした叫びのあと、その階層だけが古いまま黙り込む〜叫びと巡回の二本を版で束ね、遅れても必ず追いつかせる〜

コードテイマー【Eventual Consistency】取りこぼした叫びのあと、その階層だけが古いまま黙り込む〜叫びと巡回の二本を版で束ね、遅れても必ず追いつかせる〜

ギルドの中継係は、速さを誇りにしてきた。主が落ちた瞬間、迷宮の全階層の討伐状況板へ叫びを飛ばし、寸分の狂いもなく揃える。だが全階層の即時の復唱を強いるほど、こだまは反響を増幅し、魔力回線を灼いて自ら黙る。灼けた隙に叫びを取りこぼした階層は、討伐済の主を未討伐のまま掲げ続け、永久に追いつけない。叫び(push)は速いが取りこぼす。では巡回(poll)だけにすれば、今度は自分が仕留めた報せすら次の巡回まで自分の板に出ない(read-your-writes の崩れ)。速さを捨てず、即時の強制だけを捨てる。叫びは残し、取りこぼしの保険に定期巡回を足し、二本の報せを版で冪等に束ねる。ズレを禁じるのではなく、ズレに刻限を与える。取りこぼしを次の巡回で必ず拾い、古い叫びの後着では倒した主を蘇らせない結果整合性を、決定的な模擬戦で確かめる TypeScript の契約の物語。

灼け落ちた中継室

焦げた魔力の匂いが、まだ抜けない。

俺は冒険者ギルドの中継係だ。迷宮は四十二層。各階層に「討伐状況板」が立っている。どの層の主(ぬし)が討伐済みで、どれがまだ生きているか——それを、全部の階層で揃えるのが俺の役目だ。冒険者は階を跨いで動く。三層で大蛇が落ちたという報せが、十二層の板にも、二十層の板にも、すぐに灯らなきゃいけない。そうでなきゃ、倒したはずの魔物にまた挑む阿呆が出る。

だから俺は、速さを誇ってきた。主が落ちた、その瞬間に、全階層へ叫びを飛ばす。全部の板が「受け取った」と復唱を返すまで、見届ける。誰よりも速く、寸分の狂いもなく揃える。それが中継係の腕だと、信じていた。

それが、灼けた。

地下の主が立て続けに落ちた、繁忙の刻だった。三層で大蛇、五層で双頭の狼、七層で岩の巨人。報せが、次々に飛び込んでくる。俺は、いつものように、全部を全階層へ響かせた。一つの討伐を四十二層すべてへ叫び、四十二の板すべてから「受け取った」の復唱を待つ。だが、その復唱が返るより先に、次の討伐の叫びが重なった。叫びが叫びを呼ぶ。復唱が復唱と擦れ合う。反響が、膨れ上がっていく。こだまが、こだまを呼ぶ。回線が、熱を持ち始めた。最初は、指先に伝わる程度の温み。それが、みるみる、触れれば火傷するほどに。魔力回線が、悲鳴のような高い音を立てて——灼け落ちた。壁一面に並んだ板の写しが、ひとつ、またひとつと暗くなって、半分が、闇に沈んだ。こだまが、止んだ。耳が痛いほどの、静寂だった。

回線を繋ぎ直し、暗かった板に、灯りが戻った。俺は、ひとつずつ、源の台帳と板の写しを照らし合わせていった。ほとんどは、合っていた。だが、三層の板の前で、手が止まった。討伐済みのはずの大蛇が、まだ「未討伐」と灯っている。あの夜、討伐の報せは、確かに来た。俺は、確かに、叫びを飛ばした。なのに、三層にだけ、届いていない。その朝も、冒険者が一人、三層から引き返してきた。「まだ大蛇がいると板に出てたから、装備を整え直してきた」と言って。とっくに、誰かが斬り伏せた相手だ。

回線を診に来た技師が、灼けた線の束を指でつまんで、言った。「これは配線の不具合じゃない。回線を灼く獣だ。市の外れに、変わった魔物使いがいる。そいつを訪ねろ」。それから、ひとこと付け足した。「この回線は、古い結界を通っている。だから、なおさら厄介だ」。

その魔物使いが、いま、中継室にいる。カイナ、と名乗った。灼けた回線の断面を、指で一度なぞって、それから三層の板の暗い灯りを、じっと見ている。急ぐ気配が、まるでなかった。俺が四十二層を報せで駆け回る、その速さで生きてきたのとは、逆の——ゆっくりとした時間が、その人の周りだけ、流れているようだった。

「速いのは、悪いことですか」と、俺は訊いた。問いというより、ぼやきに近かった。「全部を、今すぐ、寸分違わず揃えようとした。なのに、速くしようとするほど——かえって、何も届かなくなった。倒した大蛇が、三層じゃまだ生きてることになってる。冒険者が、まだいるぞって引き返してくる」。

カイナは振り返らずに、ひとつだけ訊いた。「お前の叫びは、“届かなかったこと"を、お前に知らせるか」。

俺は、詰まった。「届かなかった……? 叫べば、届くものでしょう。届かなかったなんて、どうやって——」。

「そこだ」。カイナは灼けた回線の断面を、もう一度なぞった。「まず、その目で見せてやる」。

俺は、自分のやり方を見せた。難しいことは、何もしていない。主が落ちたら、その報せを叫びで全階層へ。受けた板は、その通りに書き換える。それだけだ。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// 討伐状況板の共通の型:版(version, 単調増加)と、主が討伐済か。
export interface BoardState {
  version: number; // 単調増加。報せの新しさを順序づける番号。
  defeated: boolean; // その階層の主が討伐済か。
}

// Before: 叫び一本。受けた報せで無条件に上書きする(版の照合なし・巡回なし)。
export class SyncBoard {
  private state: BoardState = { version: 0, defeated: false };

  // 叫び(push)を受けたら、そのまま書き換える。
  onCry(incoming: BoardState): void {
    this.state = incoming; // ← 版を見ない。古い叫びでも上書きする。
  }

  read(): BoardState {
    return this.state;
  }
}

報せには番号を振ってある。版(version)だ。主の様子が変わるたびに、ひとつ繰り上がる。三層の大蛇なら、未討伐が版1、討伐済みが版2。onCry が叫びを受けて、state を丸ごと書き換える。速いだろう。叫び一本に、全部を賭けた仕組みだ。

俺は、この単純さが、自慢だった。余計なものは、何もない。届いたら、書く。それだけ。仕組みが単純なら、速い。速ければ、親切だ。ずっと、そう信じてきた。

カイナは、コードを見て、何も言わなかった。ただ、模擬戦を組む、と言った。

叫び一本の、二つの綻び

「源で、三層の主を討つ」とカイナは言った。源、というのは中央の台帳のことだ。迷宮のどこかにあって、本当の討伐状況を持っている。「版を1から2へ。討伐済みだ。その叫びを、三層の板へ飛ばす。——だが今日は、回線が灼けている。この一つの叫びを、落とす」。

カイナは、三層への onCry を、呼ばなかった。それだけだ。叫びを落とす、というのは、コードの上ではただ「呼ばない」ことだった。

1
2
3
4
5
6
7
8
9
const board = new SyncBoard();

// 板はまず版1(未討伐)の叫びを正常に受け取っている(平時の同期)。
board.onCry({ version: 1, defeated: false });

// 源で三層の主を討伐 → 版1から版2(討伐済)へ。
// だが回線が灼けた隙に、この版2の叫びを「落とす」= onCry を一度も呼ばない。

board.read(); // → { version: 1, defeated: false } のまま

三層の板は、版1のまま。未討伐。源は、もう版2だ。倒した大蛇が、三層では生きたまま、黙り込んでいる。

「三層は、自分が古いと、気づけるか」とカイナが訊いた。

俺は、ようやく、さっきの問いの意味が分かった。「……気づけない。叫びが来なかったことは、板には分からない。叫びは、来たときしか、何も言わないから」。

「だろう」とカイナは言った。「叫びは"来たこと"しか伝えない。“来なかったこと"は、誰も伝えない」。

俺は、食い下がった。速さを捨てたくなかったんだと思う。「なら——もう一度、叫べばいい。取りこぼしたぶん、叫び直せば」。

カイナは、俺を見た。「その再送も、落ちたら? 落ちたことに、誰が気づく?」。

黙るしかなかった。叫び直しても、それが落ちたかどうかは、やっぱり叫びでは分からない。叫びという一本の道しか持たない限り、取りこぼした板は、自分が古いことに、永久に気づけない——だから、ただ黙り込む。落ちたかどうかを、確かめに行く者が、いる。叫びの届かない先を、こっちから覗きに行く何かが。俺は、その「何か」を、まだ言葉にできなかった。

カイナは、もう一つ、綻びを見せた。「繁忙で、叫びが渋滞すると、こうなる」。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
const board = new SyncBoard();

// 先に新しい叫び(版2・討伐済)が届く。
board.onCry({ version: 2, defeated: true });
board.read(); // → { version: 2, defeated: true }

// 渋滞で遅れていた古い叫び(版1・未討伐)が、後から届く。
board.onCry({ version: 1, defeated: false });

// SyncBoard は版を見ないので、後着の古い報せで無条件に上書きする。
board.read(); // → { version: 1, defeated: false } ← 倒した主が蘇る

版2が先に届いて、討伐済みになった。そのあとに、渋滞で遅れていた版1が、のろのろと届く。SyncBoard は版を見ない。後から来たものを、そのまま書く。だから——倒したはずの大蛇が、板にまた「未討伐」として現れた。蘇った。模擬戦の中の出来事だと分かっていても、俺は思わず、目を逸らした。あの朝、三層から引き返してきた冒険者の、怪訝そうな顔が、浮かんだ。

「叫びは、届く順を約束しない」とカイナは言った。「速い道は、順不同だ」。

俺は、二つの綻びを並べて、背筋が寒くなった。叫びは取りこぼす。叫びは順番を守らない。俺が誇ってきた速さは、その二つを、ずっと見ないふりをしていた。

カイナは手元の木札をいくつか並べ、中継の糸が途切れた時、そして絡まり合った時に何が起きるかを示した。目の前に現れたのは、沈黙する三層の板と、死から蘇る大蛇の悪夢だった。

Infographic sequence diagram showing double-submission race condition due to packet loss causing permanent divergence and delayed messages causing rollback

速さだけを求めた報せが自滅し、あるいは過去に引き戻されて破綻する。その残酷な道理が、頭の中で像を結んでいた。俺は乾いた喉を鳴らし、次の言葉を待った。

叫びと巡回、二本の道

俺は、勢いで、逆に振れた。

「分かった。叫びが取りこぼすなら、順番も守らないなら——叫びを、捨てる」。我ながら、思い切った提案だと思った。「各階層が、定期的に、源へ"最新は?“と訊きに行けばいい。巡回だ。巡回だけにすれば、取りこぼしも、順不同も、関係ない。源が答える最新が、いつも正しいんだから」。

カイナは、否定しなかった。「やってみろ」とだけ言った。

巡回だけの模擬戦は、確かに、取りこぼさなかった。源へ訊きに行って、最新を受け取る。落ちる叫びも、渋滞する叫びも、もうない。俺は、勝った気でいた。

「ただし」とカイナは言った。「こうなるぞ」。

「お前が、たった今、三層で大蛇を仕留めた。源には、版2が立つ。だが、お前が見ている三層の板は、源そのものじゃない。源を写した、手元の写しだ。次の巡回でその写しを書き直すまで——版1のまま。未討伐のまま。お前自身が、お前の討伐を、自分の手元で見られない。自分の報せを、自分で待つことになる」。

俺は、その一言で、固まった。

「俺が……俺の報せを、待つ?」。それは、想像しただけで、おかしかった。三層で大蛇を斬り倒して、振り返って、板を見る。なのに板は「未討伐」と灯っている。俺が、たった今、倒したのに。中継係の仕事は、報せを、誰よりも早く渡すことだ。その俺が、自分の渡した報せを、一番近いはずの自分の板で、待たされる。「それは——速さを、捨てることだ。即座に知れるはずのことを、わざわざ待つなんて」。

「もう一つある」とカイナは続けた。「全階層が、常に巡回の間隔ぶん、遅れる。繁忙でも、緊急でも、待つのは巡回の刻だ。速い道を捨てたぶん、誰も、即座には知れない」。

俺は、二つの代償を、並べた。叫びを捨てた世界は、取りこぼさない代わりに、誰もが遅れる。自分の手柄すら、待たされる。——叫びには、叫びの良さが、あったのだ。

カイナは、答えを言わなかった。代わりに、二つに分けて訊いた。「叫びは——何が良かった。巡回は——何が良かった」。

俺は、問われて、一つずつ、並べた。「叫びは……速い。落ちた瞬間、自分も、仲間も、即座に知れる。巡回は……遅いけど、取りこぼさない。確実だ」。

並べて、初めて、結べた。「——どっちか、じゃない。叫びは速さ、巡回は保険だ。両方、要る。叫びで即座に届けて、取りこぼしたぶんは、巡回が後ろから拾う」。

カイナは、小さく頷いたように見えた。「そこに、たどり着いたか」。

だが、すぐに、次の壁にぶつかった。「でも——叫びと巡回、二本あったら。同じ報せが二回来たり、片方が古かったりする。どっちを信じればいいんです?」。

「番号を使え」とカイナは言った。「報せが新しくなるたび、ひとつ繰り上がる番号——版を。叫びでも巡回でも、来た報せの番号が、手元より新しいときだけ、書き換える。同じか、古いなら、無視だ。これで、同じ報せが二本の道から二度来ても、一度しか効かない。順不同で来ても、一番新しい番号が、必ず勝つ」。

版、と俺は繰り返した。番号を振って、新しい方だけ採る。それなら、二度来ても、順番が狂っても、揺らがない。番号をふるのは、中央の台帳——ただ一つの源だ。源が一つだから、大きい版は、必ず新しいと言い切れる。

「前に、版で"誰かが割り込んだか"を見張る蔵があった、と聞くだろう」とカイナは言った。「あれは、書き戻すときに版を照らして、ぶつかった手をやり直させる、見張りの目だった。今すぐ揃えろ、と急いた蔵だ。——ここでは、違う。版は、二本の道から来た報せを、取り違えずに重ねるための、糊だ。同じ番号、別の役目だ」。

見張りの目ではなく、二本の道をつなぐ糊。同じ版という道具が、急ぐ蔵では衝突を弾き、ここでは、遅れてくる報せを、正しい順に積み直す。

妙な話だ、と思った。番号をふって、新しい方だけ採る。たったそれだけのことが、片方の蔵では「割り込みを許さない」厳しさになり、こっちの板では「遅れて来た者を、慌てず正しい場所に座らせる」寛さになる。道具は、同じ。何を守りたいかで、役目が、裏返る。

俺は、もうひとつ、引っかかっていたことを訊いた。中継係として、伝達の間(ま)には敏感だ。「待ってください。巡回は、源へ訊いて、答えが返るまで、間がある。その訊いてる間に、新しい叫びが届いたら? 巡回が持って帰る答えは、もう古いんじゃ?」。

「いい問いだ」とカイナは言った。

俺は、自分で言いかけて、気づいた。「巡回が、空中で古くなる……なら、持ち帰った報せが古いかどうか、手元と比べるしかない。番号をふって、新しい方だけ採るしか——」。

「そこまで言えれば」とカイナは言った。「もう半分は、契約できている」。

ズレに、刻限を与える

カイナが施した契約は、拍子抜けするほど、コードが少なかった。

 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
// After: 叫びと巡回を、単一の入口 merge へ集約する。
// merge は「手元より新しい版だけ反映」=単調・冪等。これが二本の道の糊。
export class ConvergentBoard {
  private state: BoardState = { version: 0, defeated: false };

  // 二本の道(叫び・巡回)の唯一の入口。版で冪等・単調にマージする。
  private merge(incoming: BoardState): void {
    if (incoming.version > this.state.version) {
      this.state = incoming;
    }
    // incoming.version <= 手元 → 既知/古い。無視(冪等・巻き戻らない)。
  }

  // 叫び(push):低遅延。自分と仲間が即座に知る。取りこぼしうる。
  onCry(incoming: BoardState): void {
    this.merge(incoming);
  }

  // 巡回(pull):取りこぼしの保険。源から最新を取り、マージする。
  async poll(fetchLatest: () => Promise<BoardState>): Promise<void> {
    const latest = await fetchLatest();
    this.merge(latest);
  }

  read(): BoardState {
    return this.state;
  }
}

外から見れば、叫びの入口 onCry は、前と同じだ。源の形(BoardState)も、変えていない。変わったのは二つ。叫びを受ける口を、版を照らす merge に通したこと。そして、巡回 poll を、第二の道として足したこと。

「二つ変えたじゃないか、と思うか」とカイナは言った。俺の顔に出ていたんだろう。「だが、これは別々の修理じゃない。一つの契約の、二つの輪だ」。

カイナの説明は、こうだった。巡回は、源へ訊いて、答えが返るまで間がある。その間に新しい叫びが来れば、巡回が持ち帰る答えは、もう古い。版がなければ、巡回は、自分で巻き戻りを起こす。だから——版は、巡回の安全帯だ。巡回を足すなら、版は付いてくる。別々には、選べない。巡回だけでは順不同で巻き戻るし、版だけでは取りこぼしを拾えない。両方そろって、初めて「ズレても、必ず、正しく追いつく」が立つ。

俺は、模擬戦で、確かめた。

まず、署名になる一戦。さっきと同じに、版2の叫びを落とす。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
const board = new ConvergentBoard();

// 板はまず版1(未討伐)を正常に受け取っている。
board.onCry({ version: 1, defeated: false });

// 源は版2(討伐済)へ進んでいるが、その叫びを「落とす」= onCry を呼ばない。
// 巡回が無ければ、ここで板は版1のまま(一時のズレ・刻限内)。
board.read(); // → { version: 1, defeated: false }

// 巡回(poll)が源へ最新を取りに行く。源は版2(討伐済)を返す。
await board.poll(() => Promise.resolve({ version: 2, defeated: true }));

// 巡回が拾って merge(2>1) → 反映。取りこぼしても巡回が必ず後ろから拾う=収束。
board.read(); // → { version: 2, defeated: true }

叫びを落としても、板は永久には黙り込まなかった。次の巡回が、源から版2を拾ってきて、追いついた。一時はズレる。だが、そのズレは、次の巡回までだ。

俺は、その様子を、何度も走らせて見た。叫びを落とす。板がズレる。巡回が来る。揃う。また落とす。ズレる。巡回。揃う。何度繰り返しても、ズレは、次の巡回をまたがなかった。永久に黙り込んでいた、あの三層の板が——落としても、必ず、追いついてくる。

後着の古い叫びも、試した。版2へ到達したあとに、渋滞で遅れた版1の叫びが届く。merge は、1 が手元の 2 以下だから、無視する。倒した大蛇は、もう蘇らない。

そして、俺が引っかかっていた、あの一戦。巡回が空中にある間に、新しい叫びが追い越したら。これを、決定的に再現するには、巡回の答えを、こっちで握って止めておく道具が要った。deferred だ。約束(Promise)の「解決する権利」を、外へ取り出しておくだけの、小さな道具だ。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// deferred: Promise の resolve を外に取り出して握っておくヘルパ。
function deferred<T>() {
  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 };
}

これで、源への問い合わせを latestV2.promise として渡しておき、好きな刻に latestV2.resolve(...) で答えを返せる。巡回が答えを持ち帰る、その瞬間を、こちらの手で決められる。源への問い合わせを、まだ返さないまま、空中に留めておけるわけだ。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
const board = new ConvergentBoard();

// 巡回の fetchLatest を Deferred にして、版2をまだ返さないまま poll を起動する。
// poll は呼ばれた直後、await fetchLatest() で即サスペンドし(latestV2 が未解決)、制御が戻る。
// =この時点で、巡回の継続(merge)にはまだ到達していない。
const latestV2 = deferred<BoardState>();
const pollPromise = board.poll(() => latestV2.promise);

// 巡回が源への問い合わせ中(空中)の間に、同期的に新しい叫び(版3)が追い越して
// 手元を版3へ進める。onCry は同期的に完了するので、ここで手元は確実に版3になる。
board.onCry({ version: 3, defeated: true });
board.read(); // → { version: 3, defeated: true }

// ここで初めて、空中にあった巡回の答え(版2)を解決する。
latestV2.resolve({ version: 2, defeated: true });
await pollPromise; // 巡回側の継続(merge(2))を完了させる

board.read(); // → { version: 3, defeated: true } ← 巻き戻らない

この一戦の順番には、理屈がある。まず、board.poll(...) を呼んだ瞬間、巡回はすぐ await fetchLatest() にぶつかる。だが fetchLatest() が返すのは、まだ解決していない約束(latestV2.promise)だ。だから巡回は、その一行で止まって、制御を呼び出し元へ返す——巡回の続き(merge)には、まだ一歩も進んでいない。その止まっている隙に、次の行の onCry が走る。onCry は、同期的に手元を版3へ進める。割り込みも、待ちもない。一方、空中の巡回が答え(版2)を持ち帰ってからの続き——つまり merge(2) は、await の先にある。await の先は、いますぐには走らない。マイクロタスクとして、いったん脇に置かれ、順番が来てから走る。だから merge(2) が動く頃には、手元はもう版3だ。23 以下だから、無視される。空中で古くなった巡回の答えが、追い越した新しい叫びを、踏み潰すことはない。

仮に順番が逆でも——巡回の答えが先に届いて、叫びが後でも——版が単調に増える限り、新しい方が勝つ。どちらが先でも、収束は壊れない。版は、巡回の安全帯だった。

版という盾があるからこそ、巡回は背後を守られ、叫びは先陣を切れる。二つの異なる歩幅が、版の刻印によって一つの足並みに揃う様子を、俺は頭の中で描き直した。

>Board: Return v2 (defeated)
Board->>Board: merge() 2 > 1 -> Update to v2\nPolling recovers lost data (Convergence)
Note over Source,Board: \-\- Out-of-Order Cry Arrives Delayed \-\-
Source-)Board: Cry(v1: undefeated) [Delayed]
Board->>Board: merge() 1 <= 2 -> Ignore\nNo Rollback (Boss stays defeated)
Note over Source,Board: \-\- In-Flight Poll Overtaken by Cry \-\-
Board->>Source: poll() (awaiting response for v2)
Source-)Board: Cry(v3: defeated) [Overtakes]
Board->>Board: merge() 3 > 2 -> Update to v3
Source-->>Board: Return v2 (Arrived late)
Board->>Board: merge() 2 <= 3 -> Ignore\n(Late response rejected by version guard)
 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
-->
![Infographic sequence diagram showing Eventual Consistency via Push and Poll with Versioning preventing out-of-order rollbacks](/public_images/2026/code-tamer-eventual-consistency/infographic_2.webp)

空中で交差する叫びと巡回の残響が、どれほどねじれようとも、結果は常に正しい場所に収束する。その確信を得た俺たちの前に、模擬戦の決定的な結末が、文字となって浮かび上がった。

模擬戦を、まとめて走らせた。出力は、Before と After を、並べて見せた。

```text
=== Before: SyncBoard(叫び一本・版なし・巡回なし) ===
[源] 三層の主 大蛇 討伐 → v2(討伐済)
[叫び→三層] v2 …取りこぼし(回線過負荷)
[三層 read] { version: 1, defeated: false }  ← 倒したのに未討伐のまま(永久発散)
[叫び→三層] 渋滞で先に v2 到着 → { version: 2, defeated: true }
[叫び→三層] 遅れて v1 後着 → { version: 1, defeated: false }  ← 古い叫びで巻き戻り(蘇る)
=> 永久発散/巻き戻り

=== After: ConvergentBoard(叫び+巡回・版で冪等マージ) ===
[源] v2(討伐済)
[叫び→三層] v2 …取りこぼし
[三層 read] { version: 1, defeated: false }  ← 一時のズレ(刻限内)
[巡回→三層] poll:源は v2 → merge 2>1 → { version: 2, defeated: true }  ← 巡回が拾って収束
[叫び→三層] 遅れて v1 後着 → merge 1≤2 → 無視 → { version: 2, defeated: true }  ← 蘇らない
[巡回 in-flight] poll(v2)を待つ間に 叫び v3 追い越し → merge 3>2 → v3
[巡回 返着] poll の答え v2 → merge 2≤3 → 無視 → { version: 3, ... }  ← 空中の古い巡回も弾く
=> 収束(巻き戻らない)

板が、揃った。三層の大蛇は、全階層で討伐済み。取りこぼしても、次の巡回で追いつく。古い叫びでは、蘇らない。

俺は、ようやく、自分のしてきたことが、分かった。「俺は……ズレを、無くそうとしてたんだ。一寸の狂いもなく、今すぐ、全部を揃えようと。だから、回線が灼けた」。

カイナは、何も言わずに、聞いていた。

「そうじゃ、なかった」と俺は続けた。「ズレを、無くすんじゃない。ズレに、刻限を与えればいい。叫びで即座に届けて——取りこぼしても、次の巡回までには、必ず追いつく。その"までには"を、決めておけば、よかったんだ」。

「“いつか揃う"だけなら」とカイナが言った。「何も言っていないのと、同じだ。ただの、先送りだ。だが、“次の巡回までには揃う"と、刻限を切れば——それは、諦めじゃない。上限を決めた、設計だ」。

俺は、刻限を与える、という言葉を、胸の中で何度か転がした。ズレを禁じるな。ズレに、刻限を与えろ。それが、こだまを灼かずに、迷宮全体を調和させる、たった一つの作法だった。

速さは、捨てなくていい。叫びは、残す。捨てるのは、“全部が今すぐ揃うまで待て"という、あの強制だけだ。

鈴を、鳴らす

カイナは、契約の証に、巡回の鈴をひとつ、卓に置いた。一定の刻で鳴って、各階層に「源へ訊きに行け」と促す、小さな鈴だ。

「この刻限は、どこまで短くできるんです」と俺は訊いた。「鈴を、もっと頻繁に鳴らせば、ズレは減る」。

「減る」とカイナは言った。「だが、全階層が一斉に、頻繁に訊きに行けば、今度は源に殺到して、また回線が悲鳴を上げる。訊きに行く刻を、少しずつ散らす工夫もある。変化がなければ、間隔を伸ばす工夫もある。——だが、それはこの板の話じゃない。今日の獣は、もう鎮まった」。

それから、カイナは付け足した。縛れるものと、縛れないものの境を。「巡回と版が消すのは、取りこぼしの永久発散と、後着の巻き戻りだけだ。遅れを一切許せない一貫性——たとえば、残りいくつ、という数を、今すぐ揃えねば困るもの——は、これでは律せない。あれは、前の蔵の賭け方だ。どこまで遅れを許せるか。それが、どちらの獣を選ぶかを、決める」。

俺は、卓の鈴を、手に取った。澄んだ音が、静かになった中継室に、ひとつ、渡った。揃うのを急がず、刻限を信じる——その作法の、最初の一打だった。これからは、叫びひとつに、巡回ひとつ。即座に届けて、こぼしたら拾う。揃うのを、急がない。

鈴の音が、引いていく。そのとき、気づいた。中継室の床の下——迷宮の古い結界の方から、長く、低い軋みが、まだ止まずに響いている。収まらない反響。ふと、思った。いま俺が、やめたこと。“全部を、即座に、寸分違わず揃えろ"を。あの軋みは——まだ、どこかで、続けている気がする。

それ以上は、分からなかった。

カイナは、もう、中継室を出ようと、背を向けていた。振り返らずに、言った。「——あれを、灼かずに鎮める作法は、まだ誰も知らん。いずれ、な」。それだけ言って、出ていった。

俺は、鈴を、もう一度、握り直した。次の一巡の刻を、自分で決める。床下の軋みは、まだ低く続いている。けれど、それは、いつか手をつける獣として、遠くに置いておけばいい。今日揃えたのは、この板だ。倒した大蛇は、もう、どの階層でも、二度と蘇らない。

鈴を、鳴らす。


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

  • 魔獣名(クラス/パターン名): エコー(伝播するこだま)/ 結果整合性(Eventual Consistency=即時完全同期を諦め、ズレに刻限を与えて後で必ず収束させる。叫び=push と巡回=poll のハイブリッドを版で冪等・単調にマージする)
  • 危険度(難易度/バグの影響度): ★★★★☆(板の文字は化けない。だが、揃えようと急ぐほど回線が灼け、灼けた隙の取りこぼしで一階層が永久に古いまま黙り込む=倒した主が未討伐のまま。叫び直せば後着の古い叫びで蘇る。繁忙時にだけ露れて発覚が遅い=整合性の障害)
  • 主な生態(アンチパターンの特徴):
    • 即時完全同期の自滅=全階層の即時の一致を強制し、反響が増幅して回線が過負荷で灼ける。「今すぐ・全部・寸分違わず」を求める設計そのものが過負荷を生む
    • 取りこぼし→永久発散=叫び(push)は速いが取りこぼす。取りこぼした階層は「来なかったこと」に気づけず、第二の経路が無ければ永久に追いつけない
    • 後着の古い叫び→巻き戻り=叫びは届く順を約束しない。版で順序づけねば、遅れて届いた古い叫びが新しい報せを上書きし、倒した主が蘇る(out-of-order/ロールバック)
  • 契約のポイント(設計の要点):
    • 結果整合性=即時完全一致を諦め、「新たな更新が無ければ、いずれ最後の値へ収束する」を受け入れ、一時のズレを許す
    • ハイブリッド同期=叫び(push・低遅延・自分と仲間が即座に・取りこぼす)を残し、巡回(pull・確実・遅い)を取りこぼしの保険に足す。push の弱点を pull が、pull の弱点(read-your-writes・遅延)を push が補う
    • 版による冪等マージ=叫び・巡回を単一の入口 merge に通し、手元より新しい版だけ反映(単調・冪等)。二度来ても一度しか効かず、順不同でも最新が勝つ。版は衝突検知(前話の楽観ロック)でなく、二本の報せを取り違えず重ねる糊(同じ道具・役割反転)
    • 収束の刻限=巡回間隔が「最悪どれだけズレるか」の上限。取りこぼしても次の巡回で必ず追いつく=発散しない(“いつか揃う"でなく"次の巡回までに揃う"と上限を切るから設計になる)
    • 1:1=Before(SyncBoard.onCry で版を見ず無条件上書き・巡回なし)→ After(ConvergentBoard:版照合のマージ+巡回 poll を保険に追加)。外部入口と源の形は不変、差分は「マージを版照合にし、巡回を足した」一手=“叫び一本に賭けた"根を断つ、一つの契約の不可分な両輪(版=巡回の安全帯)
  • 契約外事項(保証しないこと):
    • 強整合(前話の楽観ロック/悲観ロック)は逆の賭け方=「今すぐ揃える」。遅延を一切許せない一貫性(残高・引き当て)には結果整合を選ばない。要件(許容できる遅延)で選ぶ
    • 巡回の殺到=全階層の一斉巡回は源に殺到する。巡回の刻を散らす(Full Jitter)・適応的ポーリング(変化が無ければ間隔を伸ばす)が要る(本話の最小形は固定間隔+版マージに絞る)
    • CRDT・分散合意・実 DB レプリケーション=衝突の構造的決定解決、合意アルゴリズム、データストアのレプリケーション機構は別レイヤー。本話はアプリ層の最小形
    • トランスポート詳細/read-your-writes の厳密保証=WebSocket/SSE 接続・再接続、セッション保証等は契約外。本番は確立されたライブラリが自動フォールバックを担う
  • 現在のステータス: 🟢 テイム完了(叫びを残し巡回を保険に足し、二本を版で冪等・単調にマージ。取りこぼしを次の巡回で必ず拾い、後着の古い叫びでは巻き戻らない。即時の強制は手放し、ズレに刻限を与えた)。アーク「整合性と状態」結幕——“今すぐ揃える"を諦め、収束に刻限を与えて律する。回線の奥の軋みは、まだ遠くに
comments powered by Disqus
Hugo で構築されています。
テーマ StackJimmy によって設計されています。