灼け落ちた中継室
焦げた魔力の匂いが、まだ抜けない。
俺は冒険者ギルドの中継係だ。迷宮は四十二層。各階層に「討伐状況板」が立っている。どの層の主(ぬし)が討伐済みで、どれがまだ生きているか——それを、全部の階層で揃えるのが俺の役目だ。冒険者は階を跨いで動く。三層で大蛇が落ちたという報せが、十二層の板にも、二十層の板にも、すぐに灯らなきゃいけない。そうでなきゃ、倒したはずの魔物にまた挑む阿呆が出る。
だから俺は、速さを誇ってきた。主が落ちた、その瞬間に、全階層へ叫びを飛ばす。全部の板が「受け取った」と復唱を返すまで、見届ける。誰よりも速く、寸分の狂いもなく揃える。それが中継係の腕だと、信じていた。
それが、灼けた。
地下の主が立て続けに落ちた、繁忙の刻だった。三層で大蛇、五層で双頭の狼、七層で岩の巨人。報せが、次々に飛び込んでくる。俺は、いつものように、全部を全階層へ響かせた。一つの討伐を四十二層すべてへ叫び、四十二の板すべてから「受け取った」の復唱を待つ。だが、その復唱が返るより先に、次の討伐の叫びが重なった。叫びが叫びを呼ぶ。復唱が復唱と擦れ合う。反響が、膨れ上がっていく。こだまが、こだまを呼ぶ。回線が、熱を持ち始めた。最初は、指先に伝わる程度の温み。それが、みるみる、触れれば火傷するほどに。魔力回線が、悲鳴のような高い音を立てて——灼け落ちた。壁一面に並んだ板の写しが、ひとつ、またひとつと暗くなって、半分が、闇に沈んだ。こだまが、止んだ。耳が痛いほどの、静寂だった。
回線を繋ぎ直し、暗かった板に、灯りが戻った。俺は、ひとつずつ、源の台帳と板の写しを照らし合わせていった。ほとんどは、合っていた。だが、三層の板の前で、手が止まった。討伐済みのはずの大蛇が、まだ「未討伐」と灯っている。あの夜、討伐の報せは、確かに来た。俺は、確かに、叫びを飛ばした。なのに、三層にだけ、届いていない。その朝も、冒険者が一人、三層から引き返してきた。「まだ大蛇がいると板に出てたから、装備を整え直してきた」と言って。とっくに、誰かが斬り伏せた相手だ。
回線を診に来た技師が、灼けた線の束を指でつまんで、言った。「これは配線の不具合じゃない。回線を灼く獣だ。市の外れに、変わった魔物使いがいる。そいつを訪ねろ」。それから、ひとこと付け足した。「この回線は、古い結界を通っている。だから、なおさら厄介だ」。
その魔物使いが、いま、中継室にいる。カイナ、と名乗った。灼けた回線の断面を、指で一度なぞって、それから三層の板の暗い灯りを、じっと見ている。急ぐ気配が、まるでなかった。俺が四十二層を報せで駆け回る、その速さで生きてきたのとは、逆の——ゆっくりとした時間が、その人の周りだけ、流れているようだった。
「速いのは、悪いことですか」と、俺は訊いた。問いというより、ぼやきに近かった。「全部を、今すぐ、寸分違わず揃えようとした。なのに、速くしようとするほど——かえって、何も届かなくなった。倒した大蛇が、三層じゃまだ生きてることになってる。冒険者が、まだいるぞって引き返してくる」。
カイナは振り返らずに、ひとつだけ訊いた。「お前の叫びは、“届かなかったこと"を、お前に知らせるか」。
俺は、詰まった。「届かなかった……? 叫べば、届くものでしょう。届かなかったなんて、どうやって——」。
「そこだ」。カイナは灼けた回線の断面を、もう一度なぞった。「まず、その目で見せてやる」。
俺は、自分のやり方を見せた。難しいことは、何もしていない。主が落ちたら、その報せを叫びで全階層へ。受けた板は、その通りに書き換える。それだけだ。
| |
報せには番号を振ってある。版(version)だ。主の様子が変わるたびに、ひとつ繰り上がる。三層の大蛇なら、未討伐が版1、討伐済みが版2。onCry が叫びを受けて、state を丸ごと書き換える。速いだろう。叫び一本に、全部を賭けた仕組みだ。
俺は、この単純さが、自慢だった。余計なものは、何もない。届いたら、書く。それだけ。仕組みが単純なら、速い。速ければ、親切だ。ずっと、そう信じてきた。
カイナは、コードを見て、何も言わなかった。ただ、模擬戦を組む、と言った。
叫び一本の、二つの綻び
「源で、三層の主を討つ」とカイナは言った。源、というのは中央の台帳のことだ。迷宮のどこかにあって、本当の討伐状況を持っている。「版を1から2へ。討伐済みだ。その叫びを、三層の板へ飛ばす。——だが今日は、回線が灼けている。この一つの叫びを、落とす」。
カイナは、三層への onCry を、呼ばなかった。それだけだ。叫びを落とす、というのは、コードの上ではただ「呼ばない」ことだった。
| |
三層の板は、版1のまま。未討伐。源は、もう版2だ。倒した大蛇が、三層では生きたまま、黙り込んでいる。
「三層は、自分が古いと、気づけるか」とカイナが訊いた。
俺は、ようやく、さっきの問いの意味が分かった。「……気づけない。叫びが来なかったことは、板には分からない。叫びは、来たときしか、何も言わないから」。
「だろう」とカイナは言った。「叫びは"来たこと"しか伝えない。“来なかったこと"は、誰も伝えない」。
俺は、食い下がった。速さを捨てたくなかったんだと思う。「なら——もう一度、叫べばいい。取りこぼしたぶん、叫び直せば」。
カイナは、俺を見た。「その再送も、落ちたら? 落ちたことに、誰が気づく?」。
黙るしかなかった。叫び直しても、それが落ちたかどうかは、やっぱり叫びでは分からない。叫びという一本の道しか持たない限り、取りこぼした板は、自分が古いことに、永久に気づけない——だから、ただ黙り込む。落ちたかどうかを、確かめに行く者が、いる。叫びの届かない先を、こっちから覗きに行く何かが。俺は、その「何か」を、まだ言葉にできなかった。
カイナは、もう一つ、綻びを見せた。「繁忙で、叫びが渋滞すると、こうなる」。
| |
版2が先に届いて、討伐済みになった。そのあとに、渋滞で遅れていた版1が、のろのろと届く。SyncBoard は版を見ない。後から来たものを、そのまま書く。だから——倒したはずの大蛇が、板にまた「未討伐」として現れた。蘇った。模擬戦の中の出来事だと分かっていても、俺は思わず、目を逸らした。あの朝、三層から引き返してきた冒険者の、怪訝そうな顔が、浮かんだ。
「叫びは、届く順を約束しない」とカイナは言った。「速い道は、順不同だ」。
俺は、二つの綻びを並べて、背筋が寒くなった。叫びは取りこぼす。叫びは順番を守らない。俺が誇ってきた速さは、その二つを、ずっと見ないふりをしていた。
カイナは手元の木札をいくつか並べ、中継の糸が途切れた時、そして絡まり合った時に何が起きるかを示した。目の前に現れたのは、沈黙する三層の板と、死から蘇る大蛇の悪夢だった。

速さだけを求めた報せが自滅し、あるいは過去に引き戻されて破綻する。その残酷な道理が、頭の中で像を結んでいた。俺は乾いた喉を鳴らし、次の言葉を待った。
叫びと巡回、二本の道
俺は、勢いで、逆に振れた。
「分かった。叫びが取りこぼすなら、順番も守らないなら——叫びを、捨てる」。我ながら、思い切った提案だと思った。「各階層が、定期的に、源へ"最新は?“と訊きに行けばいい。巡回だ。巡回だけにすれば、取りこぼしも、順不同も、関係ない。源が答える最新が、いつも正しいんだから」。
カイナは、否定しなかった。「やってみろ」とだけ言った。
巡回だけの模擬戦は、確かに、取りこぼさなかった。源へ訊きに行って、最新を受け取る。落ちる叫びも、渋滞する叫びも、もうない。俺は、勝った気でいた。
「ただし」とカイナは言った。「こうなるぞ」。
「お前が、たった今、三層で大蛇を仕留めた。源には、版2が立つ。だが、お前が見ている三層の板は、源そのものじゃない。源を写した、手元の写しだ。次の巡回でその写しを書き直すまで——版1のまま。未討伐のまま。お前自身が、お前の討伐を、自分の手元で見られない。自分の報せを、自分で待つことになる」。
俺は、その一言で、固まった。
「俺が……俺の報せを、待つ?」。それは、想像しただけで、おかしかった。三層で大蛇を斬り倒して、振り返って、板を見る。なのに板は「未討伐」と灯っている。俺が、たった今、倒したのに。中継係の仕事は、報せを、誰よりも早く渡すことだ。その俺が、自分の渡した報せを、一番近いはずの自分の板で、待たされる。「それは——速さを、捨てることだ。即座に知れるはずのことを、わざわざ待つなんて」。
「もう一つある」とカイナは続けた。「全階層が、常に巡回の間隔ぶん、遅れる。繁忙でも、緊急でも、待つのは巡回の刻だ。速い道を捨てたぶん、誰も、即座には知れない」。
俺は、二つの代償を、並べた。叫びを捨てた世界は、取りこぼさない代わりに、誰もが遅れる。自分の手柄すら、待たされる。——叫びには、叫びの良さが、あったのだ。
カイナは、答えを言わなかった。代わりに、二つに分けて訊いた。「叫びは——何が良かった。巡回は——何が良かった」。
俺は、問われて、一つずつ、並べた。「叫びは……速い。落ちた瞬間、自分も、仲間も、即座に知れる。巡回は……遅いけど、取りこぼさない。確実だ」。
並べて、初めて、結べた。「——どっちか、じゃない。叫びは速さ、巡回は保険だ。両方、要る。叫びで即座に届けて、取りこぼしたぶんは、巡回が後ろから拾う」。
カイナは、小さく頷いたように見えた。「そこに、たどり着いたか」。
だが、すぐに、次の壁にぶつかった。「でも——叫びと巡回、二本あったら。同じ報せが二回来たり、片方が古かったりする。どっちを信じればいいんです?」。
「番号を使え」とカイナは言った。「報せが新しくなるたび、ひとつ繰り上がる番号——版を。叫びでも巡回でも、来た報せの番号が、手元より新しいときだけ、書き換える。同じか、古いなら、無視だ。これで、同じ報せが二本の道から二度来ても、一度しか効かない。順不同で来ても、一番新しい番号が、必ず勝つ」。
版、と俺は繰り返した。番号を振って、新しい方だけ採る。それなら、二度来ても、順番が狂っても、揺らがない。番号をふるのは、中央の台帳——ただ一つの源だ。源が一つだから、大きい版は、必ず新しいと言い切れる。
「前に、版で"誰かが割り込んだか"を見張る蔵があった、と聞くだろう」とカイナは言った。「あれは、書き戻すときに版を照らして、ぶつかった手をやり直させる、見張りの目だった。今すぐ揃えろ、と急いた蔵だ。——ここでは、違う。版は、二本の道から来た報せを、取り違えずに重ねるための、糊だ。同じ番号、別の役目だ」。
見張りの目ではなく、二本の道をつなぐ糊。同じ版という道具が、急ぐ蔵では衝突を弾き、ここでは、遅れてくる報せを、正しい順に積み直す。
妙な話だ、と思った。番号をふって、新しい方だけ採る。たったそれだけのことが、片方の蔵では「割り込みを許さない」厳しさになり、こっちの板では「遅れて来た者を、慌てず正しい場所に座らせる」寛さになる。道具は、同じ。何を守りたいかで、役目が、裏返る。
俺は、もうひとつ、引っかかっていたことを訊いた。中継係として、伝達の間(ま)には敏感だ。「待ってください。巡回は、源へ訊いて、答えが返るまで、間がある。その訊いてる間に、新しい叫びが届いたら? 巡回が持って帰る答えは、もう古いんじゃ?」。
「いい問いだ」とカイナは言った。
俺は、自分で言いかけて、気づいた。「巡回が、空中で古くなる……なら、持ち帰った報せが古いかどうか、手元と比べるしかない。番号をふって、新しい方だけ採るしか——」。
「そこまで言えれば」とカイナは言った。「もう半分は、契約できている」。
ズレに、刻限を与える
カイナが施した契約は、拍子抜けするほど、コードが少なかった。
| |
外から見れば、叫びの入口 onCry は、前と同じだ。源の形(BoardState)も、変えていない。変わったのは二つ。叫びを受ける口を、版を照らす merge に通したこと。そして、巡回 poll を、第二の道として足したこと。
「二つ変えたじゃないか、と思うか」とカイナは言った。俺の顔に出ていたんだろう。「だが、これは別々の修理じゃない。一つの契約の、二つの輪だ」。
カイナの説明は、こうだった。巡回は、源へ訊いて、答えが返るまで間がある。その間に新しい叫びが来れば、巡回が持ち帰る答えは、もう古い。版がなければ、巡回は、自分で巻き戻りを起こす。だから——版は、巡回の安全帯だ。巡回を足すなら、版は付いてくる。別々には、選べない。巡回だけでは順不同で巻き戻るし、版だけでは取りこぼしを拾えない。両方そろって、初めて「ズレても、必ず、正しく追いつく」が立つ。
俺は、模擬戦で、確かめた。
まず、署名になる一戦。さっきと同じに、版2の叫びを落とす。
| |
叫びを落としても、板は永久には黙り込まなかった。次の巡回が、源から版2を拾ってきて、追いついた。一時はズレる。だが、そのズレは、次の巡回までだ。
俺は、その様子を、何度も走らせて見た。叫びを落とす。板がズレる。巡回が来る。揃う。また落とす。ズレる。巡回。揃う。何度繰り返しても、ズレは、次の巡回をまたがなかった。永久に黙り込んでいた、あの三層の板が——落としても、必ず、追いついてくる。
後着の古い叫びも、試した。版2へ到達したあとに、渋滞で遅れた版1の叫びが届く。merge は、1 が手元の 2 以下だから、無視する。倒した大蛇は、もう蘇らない。
そして、俺が引っかかっていた、あの一戦。巡回が空中にある間に、新しい叫びが追い越したら。これを、決定的に再現するには、巡回の答えを、こっちで握って止めておく道具が要った。deferred だ。約束(Promise)の「解決する権利」を、外へ取り出しておくだけの、小さな道具だ。
| |
これで、源への問い合わせを latestV2.promise として渡しておき、好きな刻に latestV2.resolve(...) で答えを返せる。巡回が答えを持ち帰る、その瞬間を、こちらの手で決められる。源への問い合わせを、まだ返さないまま、空中に留めておけるわけだ。
| |
この一戦の順番には、理屈がある。まず、board.poll(...) を呼んだ瞬間、巡回はすぐ await fetchLatest() にぶつかる。だが fetchLatest() が返すのは、まだ解決していない約束(latestV2.promise)だ。だから巡回は、その一行で止まって、制御を呼び出し元へ返す——巡回の続き(merge)には、まだ一歩も進んでいない。その止まっている隙に、次の行の onCry が走る。onCry は、同期的に手元を版3へ進める。割り込みも、待ちもない。一方、空中の巡回が答え(版2)を持ち帰ってからの続き——つまり merge(2) は、await の先にある。await の先は、いますぐには走らない。マイクロタスクとして、いったん脇に置かれ、順番が来てから走る。だから merge(2) が動く頃には、手元はもう版3だ。2 は 3 以下だから、無視される。空中で古くなった巡回の答えが、追い越した新しい叫びを、踏み潰すことはない。
仮に順番が逆でも——巡回の答えが先に届いて、叫びが後でも——版が単調に増える限り、新しい方が勝つ。どちらが先でも、収束は壊れない。版は、巡回の安全帯だった。
版という盾があるからこそ、巡回は背後を守られ、叫びは先陣を切れる。二つの異なる歩幅が、版の刻印によって一つの足並みに揃う様子を、俺は頭の中で描き直した。
>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)
| |
板が、揃った。三層の大蛇は、全階層で討伐済み。取りこぼしても、次の巡回で追いつく。古い叫びでは、蘇らない。
俺は、ようやく、自分のしてきたことが、分かった。「俺は……ズレを、無くそうとしてたんだ。一寸の狂いもなく、今すぐ、全部を揃えようと。だから、回線が灼けた」。
カイナは、何も言わずに、聞いていた。
「そうじゃ、なかった」と俺は続けた。「ズレを、無くすんじゃない。ズレに、刻限を与えればいい。叫びで即座に届けて——取りこぼしても、次の巡回までには、必ず追いつく。その"までには"を、決めておけば、よかったんだ」。
「“いつか揃う"だけなら」とカイナが言った。「何も言っていないのと、同じだ。ただの、先送りだ。だが、“次の巡回までには揃う"と、刻限を切れば——それは、諦めじゃない。上限を決めた、設計だ」。
俺は、刻限を与える、という言葉を、胸の中で何度か転がした。ズレを禁じるな。ズレに、刻限を与えろ。それが、こだまを灼かずに、迷宮全体を調和させる、たった一つの作法だった。
速さは、捨てなくていい。叫びは、残す。捨てるのは、“全部が今すぐ揃うまで待て"という、あの強制だけだ。
鈴を、鳴らす
カイナは、契約の証に、巡回の鈴をひとつ、卓に置いた。一定の刻で鳴って、各階層に「源へ訊きに行け」と促す、小さな鈴だ。
「この刻限は、どこまで短くできるんです」と俺は訊いた。「鈴を、もっと頻繁に鳴らせば、ズレは減る」。
「減る」とカイナは言った。「だが、全階層が一斉に、頻繁に訊きに行けば、今度は源に殺到して、また回線が悲鳴を上げる。訊きに行く刻を、少しずつ散らす工夫もある。変化がなければ、間隔を伸ばす工夫もある。——だが、それはこの板の話じゃない。今日の獣は、もう鎮まった」。
それから、カイナは付け足した。縛れるものと、縛れないものの境を。「巡回と版が消すのは、取りこぼしの永久発散と、後着の巻き戻りだけだ。遅れを一切許せない一貫性——たとえば、残りいくつ、という数を、今すぐ揃えねば困るもの——は、これでは律せない。あれは、前の蔵の賭け方だ。どこまで遅れを許せるか。それが、どちらの獣を選ぶかを、決める」。
俺は、卓の鈴を、手に取った。澄んだ音が、静かになった中継室に、ひとつ、渡った。揃うのを急がず、刻限を信じる——その作法の、最初の一打だった。これからは、叫びひとつに、巡回ひとつ。即座に届けて、こぼしたら拾う。揃うのを、急がない。
鈴の音が、引いていく。そのとき、気づいた。中継室の床の下——迷宮の古い結界の方から、長く、低い軋みが、まだ止まずに響いている。収まらない反響。ふと、思った。いま俺が、やめたこと。“全部を、即座に、寸分違わず揃えろ"を。あの軋みは——まだ、どこかで、続けている気がする。
それ以上は、分からなかった。
カイナは、もう、中継室を出ようと、背を向けていた。振り返らずに、言った。「——あれを、灼かずに鎮める作法は、まだ誰も知らん。いずれ、な」。それだけ言って、出ていった。
俺は、鈴を、もう一度、握り直した。次の一巡の刻を、自分で決める。床下の軋みは、まだ低く続いている。けれど、それは、いつか手をつける獣として、遠くに置いておけばいい。今日揃えたのは、この板だ。倒した大蛇は、もう、どの階層でも、二度と蘇らない。
鈴を、鳴らす。
📜 カイナの魔獣契約録(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 接続・再接続、セッション保証等は契約外。本番は確立されたライブラリが自動フォールバックを担う
- 現在のステータス: 🟢 テイム完了(叫びを残し巡回を保険に足し、二本を版で冪等・単調にマージ。取りこぼしを次の巡回で必ず拾い、後着の古い叫びでは巻き戻らない。即時の強制は手放し、ズレに刻限を与えた)。アーク「整合性と状態」結幕——“今すぐ揃える"を諦め、収束に刻限を与えて律する。回線の奥の軋みは、まだ遠くに
