Featured image of post コードテイマー【Bulkhead】汲み上げの大食らいが船中の魔力を飲み干し、守りの防壁が飢えて落ちる〜魔力槽を機能ごとに隔て、守りの取り分を絶やさない〜

コードテイマー【Bulkhead】汲み上げの大食らいが船中の魔力を飲み干し、守りの防壁が飢えて落ちる〜魔力槽を機能ごとに隔て、守りの取り分を絶やさない〜

機関長の信条は、船のどの機能も絶やさぬことだった。嵐の夜、浸水で汲み上げの詠唱が忙しくなったその折に、船を守る防壁が落ちた——だが防壁の術式に欠陥は一つも無い。一度も失敗していない。健全なのに、発動すらしなかった。原因は防壁の中にはない。どの詠唱も同じ一つの炉から魔力の取り出し口を引く設計が、汲み上げの暴食に取り出し口を喰い尽くされ、防壁の番を永遠に来させなかった。失敗していないものを、断つ結界(Circuit Breaker)では救えない。これは断つ獣ではなく、分ける獣だ。汲み上げを絞っても、空いた隙間は別の大食らいが喰う——取り分は絞って空けるのでなく、初めから機能ごとに分けて取って置く。魔力炉を機能ごとの区画(Bulkhead)へ隔て、防壁の取り出し口を汲み上げの槽とは別オブジェクトのカウンタにすることで、大食らいが構造的に触れられなくする。汲み上げを 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
// 詠唱の結果。domain は詠唱を発したドメイン名、ok は発動できたか。
export interface SpellResult {
  domain: string;
  ok: boolean;
}

// 魔力の取り出し口(並行スロット)。有限。空くまで次の詠唱は待つ。
//   =available カウンタと待ち行列を持つ、カウンティングセマフォの実装そのもの。
export class ManaPool {
  private available: number;
  private readonly queue: Array<() => void> = [];
  private running = 0; // 稼働中の詠唱数

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

  async run<T>(spell: () => 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 spell();
    } finally {
      this.running--;
      const next = this.queue.shift();
      if (next) next(); // 次の待ち手に取り出し口を手渡し
      else this.available++;
    }
  }

  get inFlight(): number {
    return this.running; // 今この炉で走っている詠唱の数
  }

  get free(): number {
    return this.available; // 空いている取り出し口の数
  }
}

そして、その炉から詠唱を発する手順は、こうだ。

1
2
3
4
5
6
7
8
// Before: 全機能が一つの魔力炉を共有する。防壁も汲み上げも推進も、
//   同じ furnace から取り出し口を引く。=取り出し口を奪い合う構造。
export async function castShared(
  furnace: ManaPool,
  spell: () => Promise<SpellResult>,
): Promise<SpellResult> {
  return furnace.run(spell);
}

「炉から取り出し口を一つ引いて、詠唱を走らせ、終えたら返す」と、わたしは言った。「空きが無ければ、待つ。当たり前の配りだ。……これの、どこに穴がある」

カイナは、板から目を上げた。

一つの餌場に、皆を繋いだ

「お前の炉で暴れているのは、餌場を分け合う獣だ」とカイナは言った。「一つの餌場に、多くの魔獣が繋がれている。普段は、おとなしい。だが、汲み上げの獣は——浸水のような重荷がかかると、満腹を知らずに魔力を喰らう性質だ。餌がある限り、際限なく、喰らい続ける」

わたしは、あの夜の汲み上げを思い出した。水を掻き出すために、何度も、何度も、魔力を引いた。

「同じ餌場に繋がれた、防壁の獣はどうなる」とカイナは続けた。「汲み上げが餌を喰い尽くせば、防壁の取り分は、無くなる。飢えて、動けなくなる」

背筋が、冷えた。炉が、ふっと火を落としたときのように。

「防壁は……敵にやられて、落ちたんじゃない」と、わたしは言った。「飢えて、動けなかったのか。汲み上げに、魔力を喰い尽くされて」

「汲み上げの獣に、罪は無い」とカイナは言った。声は、静かだった。「あれは、己の仕事——船から水を掻き出すこと——に、忠実なだけだ。罪があるとすれば、皆を一つの餌場に繋いだことだ」

飢えは、絞っても止まらない

汲み上げに、口を塞がせる

「飢えは、汲み上げが口を塞いだ刹那にだけ顔を出す」とカイナは言った。「その刹那を、ここで、こしらえる」

カイナが組んだ檻の仕掛けは、三つ。

一つ、汲み上げの重い詠唱は、送られたきり、いつまでも応えを返さない。返す刻を、こちらが握る。本物の網でいう、いつまでも終わらない重い処理だ。二つ、炉の取り出し口は、二つきり(容量2)にする。三つ、防壁の詠唱は軽く、取り出し口さえ引ければ、即座に終わる。

 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
// 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 };
}

// 大食らい(汲み上げ・推進)相当の詠唱者: spell を呼ぶたびに新しい Deferred を返して
//   炉に居座る。テストは任意の刻に resolve/reject できる。callCount で呼び出し回数を数える。
export class GluttonSpellcaster {
  domain: string;
  callCount = 0;
  readonly pending: Array<{
    promise: Promise<SpellResult>;
    resolve: (v: SpellResult) => void;
    reject: (e: unknown) => void;
  }> = [];

  constructor(domain: string) {
    this.domain = domain;
  }

  cast = (): Promise<SpellResult> => {
    this.callCount++;
    const d = deferred<SpellResult>();
    this.pending.push(d);
    return d.promise;
  };
}

// 防壁相当の詠唱者: spell が即座に ok:true で解決する(軽量・即応)。
export class LightSpellcaster {
  domain: string;
  callCount = 0;

  constructor(domain: string) {
    this.domain = domain;
  }

  cast = async (): Promise<SpellResult> => {
    this.callCount++;
    return { domain: this.domain, ok: true };
  };
}

実際の網では、浸水で汲み上げが立て込んだ、その稀な巡り合わせのときにだけ、これが起きる。だから平時には露れない。その巡り合わせを、檻の中で、こちらが握って起こす。汲み上げをいつ返させるか——それを手で決めるから、何度走らせても、判で押したように同じことが起きる。だから、まぐれ当たりに頼らずに済む。

わたしは、半信半疑で見ていた。欠陥の無い術式を、どう模擬戦にするのか。

防壁の番が、永遠に来ない

カイナは、取り出し口を二つだけ持つ炉で、汲み上げの詠唱を二つ、流した。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
const furnace = new ManaPool(2); // 共有炉(容量2)
const bilge = new GluttonSpellcaster("bilge"); // 汲み上げ(大食らい)
const defense = new LightSpellcaster("defense"); // 防壁(軽量・即応)

// 汲み上げ2詠唱→取り出し口を二つとも占有し、応答を待って居座る。
const b1 = castShared(furnace, bilge.cast);
const b2 = castShared(furnace, bilge.cast);

await Promise.resolve();
await Promise.resolve();
assert.equal(furnace.inFlight, 2); // 取り出し口は二つとも、汲み上げに占有されている
assert.equal(furnace.free, 0);

二つの詠唱が、取り出し口を二つとも握った。汲み上げは、応答を握ったまま返さない。inFlight は二、free は零。炉の口は、塞がりきった。

そこへ、防壁の詠唱を一つ、流す。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// 防壁詠唱→空き取り出し口が無く、まだ発動しないまま待たされる。
let defenseDone = false;
const defensePromise = castShared(furnace, defense.cast).then((result) => {
  defenseDone = true;
  return result;
});

// マイクロタスクを数回まわしても、防壁はまだ発動しない。
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
assert.equal(defenseDone, false); // 防壁が、汲み上げに巻き込まれて飢えている
assert.equal(defense.callCount, 0); // defense.cast 自体がまだ呼ばれていない(取り出し口が無い)

何度、巡回の隙をまわしても、防壁は発動しなかった。defenseDone は偽のまま。防壁の cast は、まだ一度も呼ばれていない。

これは「まだ発動していないだけ」ではない。防壁が動き出す引き金は、ただ一つ——汲み上げが取り出し口を返すことだけだ。だが汲み上げは、こちらが返させない限り、握って離さない。動かす引き金がどこにも無いのだから、巡回の隙をいくら掃いても、結果は一分も変わらない。だから「まだ」ではなく「この刻には、決して発動しない」と言い切れる。

わたしは、声を失った。

「防壁は、無事だ。術式も、繋ぎも、どこも壊れていない。なのに——発動しない。取り出し口が、一つも空いていないからか」

「壊れて落ちたんじゃ、なかった」と、わたしは続けた。自分の言葉で、像が結べた。「引く魔力が無くて、動けなかっただけだ。汲み上げが、取り出し口を二つとも握ったまま離さないから、防壁の番が、永遠に来ない。——あの嵐の夜と、同じだ」

カイナは、刻を進めた。汲み上げに、応答を返させる。

1
2
3
4
5
6
7
8
// 刻を進める=汲み上げを resolve→取り出し口が空き、防壁が初めて発動する。
const [d1, d2] = bilge.pending;
d1!.resolve({ domain: "bilge", ok: true });
d2!.resolve({ domain: "bilge", ok: true });

const defenseResult = await defensePromise;
assert.equal(defenseDone, true);
assert.deepEqual(defenseResult, { domain: "defense", ok: true });

汲み上げが取り出し口を返すと、その瞬間、待たされていた防壁が、初めて発動した。

「見ての通り、防壁は、一度も失敗していない」とカイナは言った。「reject も、例外も、出していない。——ここが、肝だ。失敗していないものを、断つ結界では救えん。失敗が、無いんだからな」

わたしは、その言葉に引っかかった。「断つ結界、とは」

「倒れた相手への詠唱を、失敗が続いたら断つ仕掛けがある」とカイナは言った。「だが、あれが効くのは、相手が"倒れて、失敗を返す"ときだ。今日の防壁は、倒れていない。失敗を、一つも返していない。ただ、飢えている。だから、断つ仕掛けは、何も掴めん。空振りする。——これは、断つ獣じゃない。分ける獣だ」

カイナの言う「分ける獣」という言葉が、頭の中で火花のようにはじけた。 手元の羊皮紙に記された魔力の流れが、急速に色褪せていくように感じられた。 わたしたちが直面していたのは、防壁の術式そのものの崩壊ではなく、背後の炉で密かに起きていた「静かなる枯渇」だったのだ。 カイナは指先で魔力を操り、空間に青白い光の軌跡を描き出した。 そこ浮かび上がったのは、共有炉の取り出し口を貪り尽くす汲み上げの使い魔と、その影で光を失い、詠唱の番すら与えられない防壁の姿だった。

Infographic sequence diagram showing a gluttonous Bilge Pump acquiring all Shared Furnace slots and starving the healthy Shield Spell

青白い魔力の残光が消え、暗い船倉に再び重苦しい静寂が戻る。 図が示す真実は残酷だった。防壁は敗れたのではない、戦うための魔力という土俵にすら上がらせてもらえなかったのだ。 確かに、これではいくら防壁の紋様を研ぎ澄まそうが意味がないと、わたしは低く唸り、こめかみを押さえた。炉の取り出し口という限られた「席」を、汲み上げがすべて占有してしまえば、防壁の術式はただ入口で立ち尽くすしかない。 だが、カイナ。ならば単純に、汲み上げの使い魔が一度に引き出せる魔力の量を制限すれば済む話ではないか、とわたしは次の問いを投げかけた。

絞った隙間は、別の大食らいが喰う

それでも、わたしは、手を思いついた。

「なら、汲み上げを絞ればいい。汲み上げが一度に喰う量に、上限をつける。一度に一つしか喰えないようにすれば、取り出し口が一つ空く。そこを、防壁が使えるだろう」

カイナは、その場で、もう一つの檻を組んだ。汲み上げの詠唱を、柵門で一つに絞る——わたしが言った「絞り」を、そのまま形にしたものだ。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// 却下案の反証専用:本筋の Before/After には混ぜない使い捨てヘルパー。
// 汲み上げだけを柵門(同時1のセマフォ)で絞ってから共有炉へ通す。gate を握ったまま
// furnace を待つ入れ子だが、ここでは「汲み上げを1件に絞った状態」を作るのが目的。
function castThrottled(
  gate: ManaPool,
  furnace: ManaPool,
  spell: () => Promise<SpellResult>,
): Promise<SpellResult> {
  return gate.run(() => furnace.run(spell));
}

だが、とカイナは言った。嵐では、汲み上げだけが忙しいわけじゃない。帆を駆る推進も、立て込む。推進もまた、餌があれば喰らう、もう一頭の大食らいだ。

 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
const furnace = new ManaPool(2); // 共有炉(容量2・Beforeと同一)
const gate = new ManaPool(1); // 汲み上げ専用の柵門(同時1に絞る)
const bilge = new GluttonSpellcaster("bilge");
const drive = new GluttonSpellcaster("drive"); // もう一頭の大食らい(推進)
const defense = new LightSpellcaster("defense");

// 汲み上げを柵門越しに1つ送る→柵門を通り、furnace の取り出し口を1つ確保する。
const bilgePromise = castThrottled(gate, furnace, bilge.cast);
await Promise.resolve();
await Promise.resolve();
assert.equal(bilge.callCount, 1); // 柵門を通った1件だけが furnace に到達

// 推進も忙しい→柵門を介さず furnace へ直接、もう一つの取り出し口を確保する。
const drivePromise = castShared(furnace, drive.cast);
await Promise.resolve();
await Promise.resolve();
assert.equal(furnace.inFlight, 2); // 汲み上げ1+推進1で取り出し口は二つとも埋まる
assert.equal(furnace.free, 0);

// 防壁詠唱→絞ったはずなのに、空いた取り出し口は無い=やはり飢える。
let defenseDone = false;
const defensePromise = castShared(furnace, defense.cast).then((result) => {
  defenseDone = true;
  return result;
});
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
assert.equal(defenseDone, false); // 絞っても、防壁の取り分はどこにも取って置かれていない
assert.equal(defense.callCount, 0); // 確保した取り出し口=0

汲み上げを一つに絞った。柵門が二件目以降を堰き止めるから、炉に立つ汲み上げは、一件きりだ。だが、その絞って空けた取り出し口を、推進が喰った。炉の口は、また塞がりきった。防壁は、やはり飢えた。

「絞るのは、“この一頭が喰う量"を抑えるだけだ」とカイナは言った。「抑えて空いた隙間は、別の大食らいが喰う。今日はそれが推進だった。だが、推進がいなくても、同じことだ。絞りが空けた口は、防壁のために取って置かれてはいない。たまたま今日は推進が、別の日なら最初に手を伸ばした何かが、その口を取る。絞りは"一頭の喰う量"を抑えるだけで、“防壁の取り分"を、どこにも約束しない。だから、絞っても、防壁はまた飢える」

わたしは、足元が動くのを感じた。

「絞るんじゃ、ない……? 防壁の取り分を、初めから——別に、取って置く、ということか」

「そうだ」とカイナは言った。「取り分は、絞って空けるんじゃない。初めから、分けて取って置く。空いた隙間を当てにする限り、誰かが先に喰えば、防壁は飢える」

そこで、わたしは、この害の正体を、ようやく言葉にできた。害は、汲み上げそのものじゃない。皆を一つの炉に繋ぎ、取り分を分けずに、奪い合わせたことだ。一つの炉を皆で奪い合い、一頭の暴食が、無関係な他を飢えさせる。カイナは、それを リソース枯渇の連鎖——行儀の悪い隣人——と呼んだ。同じ導管に繋いだ末の、取り合いだ。わたしの言葉で言えば、そういうことだった。

魔力槽を、機能ごとに隔てる

隔壁——船の、水密の仕切り

カイナが施そうとした契約を、隔壁——機能ごとにリソースを別の区画へ分け、一つの区画の枯渇が他へ波及しないようにする仕掛け——といった。Bulkhead と呼ぶ、とカイナは言った。

「炉を一つにせず、機能ごとに分ける」とカイナは言った。「防壁には防壁の槽、汲み上げには汲み上げの槽。それぞれに、取り出し口を初めから取って置く。船の、水密の隔壁と同じだ。一つの区画が浸かっても、隣へは回らない」

水密の隔壁、という言葉で、わたしは、舷の外をふと見やった。港を囲む、あの大結界。一枚岩のように見えるが、よく見れば、無数の仕切りで、いくつもの区画に分かれている。

「あれと、同じだ」とカイナは、わたしの視線を追って言った。「お前が今日、この船に切る仕切り。あれと同じものが、あの大結界にも、いくつもある。一つが破れても、隣まで道連れにしないために。堅牢な結界が堅牢なのは、一枚岩だからじゃない。区画で、仕切られているからだ」

それ以上は、言わなかった。わたしも、訊かなかった。今日の用は、この船だ。

「まず、防壁を最優先で守る」とカイナは、炉へ向き直って言った。「防壁に、専用の槽を切る。今日の船を、まず沈ませない。これが応急だ」

そして、カイナは、この契約の肝を、一息に言った。

「防壁の取り出し口を数えるカウンタは、汲み上げの槽とは別物だ。汲み上げが、自分の槽をどれだけ干しても、防壁の槽の取り出し口には、指一本触れられん。数える数が、初めから別だからだ。——奪い合う炉が、無くなる」

わたしには、まだ半分しか掴めなかった。「術式を、正しく書けば、防げるものじゃないのか」

「いや」とカイナは言った。「どの詠唱が、何本同時に走るかは、その時々の値だ。術式の形では、決められん。形をいくら正しく書いても、走る数は嵐次第で、取り出し口の取り合いは止まらん。守るのは、形じゃない。“どの槽から引くか"を、機能ごとに別の槽に分ける——その仕掛けで守るんだ」

カイナが刻んだ契約は、これだ。

 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
export type Domain = "defense" | "bilge" | "drive";

// After: 機能ドメインごとに独立した区画(別オブジェクトの ManaPool)を持つ。
//   詠唱は、自分のドメインの区画からだけ取り出し口を引く。
//   =防壁の available カウンタは、汲み上げの区画とは物理的に別オブジェクト。
export class Bulkheads {
  private readonly compartments: Record<Domain, ManaPool>;

  constructor(sizes: Record<Domain, number>) {
    this.compartments = {
      defense: new ManaPool(sizes.defense),
      bilge: new ManaPool(sizes.bilge),
      drive: new ManaPool(sizes.drive),
    };
  }

  cast(domain: Domain, spell: () => Promise<SpellResult>): Promise<SpellResult> {
    return this.compartments[domain].run(spell); // 区画ごとの取り出し口から引く
  }

  inFlight(domain: Domain): number {
    return this.compartments[domain].inFlight;
  }

  free(domain: Domain): number {
    return this.compartments[domain].free;
  }
}

「変えたのは、一点だけだ」とカイナは言った。「前は、皆が同じ炉から引いた。今は、機能ごとに、別の槽から引く。詠唱の中身も、ManaPool の仕掛けも、変えていない。防壁には、防壁だけの取り出し口を、一つ、取って置いた。汲み上げが、それを奪うことは、もうできん。炉を一つ共有するか、機能ごとに分けて持つか——変えたのは、その一点だけだ」

castShared は、皆を同じ furnace.run に通していた。Bulkheads は、それを compartments[domain].run に変えただけだ。ManaPool の中身も、詠唱も、変えていない。差分は、引く先が一つの炉か、機能ごとの槽か——その一点だけだ。

同じ嵐でも、防壁は落ちない

カイナは、Before と寸分同じ汲み上げ殺到を、今度は区画ごしに流した。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// 防壁・汲み上げ・推進、それぞれに専用の取り出し口を1つずつ取って置く。
const bulkheads = new Bulkheads({ defense: 1, bilge: 1, drive: 1 });
const bilge = new GluttonSpellcaster("bilge");
const defense = new LightSpellcaster("defense");

// 汲み上げ2詠唱→Beforeの飢餓テストと寸分同じ殺到を、汲み上げ区画にだけ流す。
const b1 = bulkheads.cast("bilge", bilge.cast);
const b2 = bulkheads.cast("bilge", bilge.cast);
await Promise.resolve();
await Promise.resolve();
assert.equal(bulkheads.inFlight("bilge"), 1); // 汲み上げ区画の容量1が1件で埋まる
assert.equal(bulkheads.free("bilge"), 0); // もう1件は汲み上げ区画自身で待機

汲み上げが二つ、また殺到した。だが、汲み上げの槽は、容量が一つ。一つ目が取り出し口を握り、二つ目は、汲み上げの槽の中で待たされた。

そこへ、防壁を流す。汲み上げは、まだ取り出し口を握ったまま、返していない。

1
2
3
4
5
6
7
8
9
// ここで汲み上げをまだ resolve しない(Deferred 未解決のまま)。
// 防壁詠唱→防壁の専用区画は汲み上げの占有と無関係に空いている=即発動して await が通る。
const defenseResult = await bulkheads.cast("defense", defense.cast);
assert.equal(defenseResult.ok, true);
assert.equal(defense.callCount, 1);

// 同時性の確定:防壁が通った時点で、汲み上げはまだ一つも取り出し口を返していない。
assert.equal(bulkheads.inFlight("bilge"), 1); // 汲み上げ区画は依然として占有されたまま
assert.equal(bilge.callCount, 1); // 区画容量1ゆえ、2件目はまだ spell 自体を呼ばれていない

防壁は、発動した。即座に。

ここが、肝だった。防壁が通ったとき、汲み上げは、まだ一つも取り出し口を返していない。inFlight("bilge") は、依然として一。汲み上げは、握ったままだ。

「汲み上げが空けたから、防壁が通ったんじゃない」と、わたしは言った。順序が、それを証していた。「汲み上げは、まだ握っている。なのに、防壁は通った。防壁の槽が、初めから別だから、汲み上げの居座りと、関わりなく通ったんだ」

そこで、わたしは、もう一つ気づいた。汲み上げは、自分の槽の取り出し口を、一つしか使えなくなった。前は、炉の口を二つとも独占できたのに。

「これは……汲み上げが、遅くなった、ということか?」

「遅くなったんじゃない」とカイナは言った。「分を超えて喰えなくなっただけだ。その独占こそが、防壁を飢えさせていた。一つの大きな炉なら、空いた口を、混んだ機能へ回せた——その融通を捨てて、取り分の保証を買った。それが、この取引だ」

わたしは、口の数を、勘定してみた。前は、炉に二口。皆で奪い合う、二口だった。今は、防壁に一口、汲み上げに一口、推進に一口——締めて三口に、増えている。だが、防壁の取り分は、前も今も、一口きりだ。増えた一口は、防壁への足し前じゃない。汲み上げと推進が、もう互いの口にも、防壁の口にも手を出せなくなった、その代わりに、空きを融通し合えなくなった——その分だ。取り出し口を足して飢えを止めたんじゃない。奪い合いを、断っただけだ。

わたしは、息をついた。

「同じ嵐だ。汲み上げが、また喰らいに来た。取り出し口を、握って離さない。だが——防壁は、落ちない。防壁の槽は、汲み上げの槽とは別だから、汲み上げがいくら暴れても、防壁の取り出し口は、ちゃんと空いている。番が、ちゃんと来る」

「大食らいは、自分の槽しか干せん」とカイナは言った。「隣の槽には、届かない。飢えを、一つの区画に、閉じ込めた」

皆に、取り分を定める

「防壁は、守れた」と、わたしは言った。「だが、汲み上げと推進は——それぞれの取り分は、まだ定まっていない」

「応急は、まず防壁の取り分を確保した」とカイナは言った。「恒久は、汲み上げにも推進にも、それぞれの取り分を定める。機能ごとに槽を切り、どの槽をいくつにするか——そして、槽が満ちたときの作法まで、決める。それで初めて、船全体が、律せられる」

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 区画 防壁=1・汲み上げ=1・推進=1(全機能の取り分を定める=恒久)。
const bulkheads = new Bulkheads({ defense: 1, bilge: 1, drive: 1 });
const bilge = new GluttonSpellcaster("bilge");

// 汲み上げ区画(容量1)へ1件目→容量ちょうどで満杯(境界)。
const b1 = bulkheads.cast("bilge", bilge.cast);
await Promise.resolve();
assert.equal(bulkheads.free("bilge"), 0); // 容量ちょうどで満杯

// 汲み上げ区画へ2件目→自区画で待機する(他区画へは波及しない)。
let secondDone = false;
const b2 = bulkheads.cast("bilge", bilge.cast).then((r) => {
  secondDone = true;
  return r;
});
await Promise.resolve();
await Promise.resolve();
assert.equal(secondDone, false); // 2件目は汲み上げ区画自身でだけ待たされている

// 防壁・推進の区画は無傷(一区画の満杯が他区画のfreeに影響しない)。
assert.equal(bulkheads.free("defense"), 1);
assert.equal(bulkheads.free("drive"), 1);

汲み上げの槽が満杯になっても、溢れた詠唱は、汲み上げの槽の中でだけ、待たされた。防壁の槽も、推進の槽も、空いたままだ。一区画が満ちても、隣の取り分は、一切減らない。

「槽が満ちたら、二つの作法がある」とカイナは続けた。「次の詠唱を、空くまで待たせる。あるいは、待たせず"今は引けん"と、即座に返す。際限なく待ち行列を伸ばすより、早く返したほうがいい時もある」

待たせず即座に返す作法は、こう書ける。

1
2
3
4
5
6
7
8
9
// 溢れの作法(fail-fast 版): 区画が満杯(free===0)のとき、待ち行列を伸ばさず
//   即座に ok:false で返す。spell は一切呼ばれない=取り出し口を確保しない。
async tryCast(domain: Domain, spell: () => Promise<SpellResult>): Promise<SpellResult> {
  const compartment = this.compartments[domain];
  if (compartment.free === 0) {
    return { domain, ok: false }; // 待たせず即「今は引けん」
  }
  return compartment.run(spell);
}
1
2
3
4
5
6
7
8
9
// 満杯の汲み上げ区画へ tryCast→待たされず即座に ok:false で返る。
const callsBefore = bilge.callCount;
const overflow = await bulkheads.tryCast("bilge", bilge.cast);
assert.deepEqual(overflow, { domain: "bilge", ok: false });
assert.equal(bilge.callCount, callsBefore); // spell自体は一切呼ばれていない=待ち行列を伸ばさない

// 防壁区画は無傷(汲み上げ区画の満杯と無関係に発動できる)。
const defenseResult = await bulkheads.cast("defense", defense.cast);
assert.deepEqual(defenseResult, { domain: "defense", ok: true });

満杯の汲み上げの槽は、次の詠唱を待たせず、その場で突き返した。詠唱は、取り出し口を確保すらしない。待ち行列が、際限なく伸びることもない。そして、防壁の槽は、その間も、無傷だった。

ここに、並行の勘どころが一つある。tryCast は、free を確かめてから、run が取り出し口を確保するまで、その間に await を一つも挟まない。単一のイベントループでは、この「確かめて、取る」は、誰にも割り込まれない一続きだ。だから、複数の詠唱が同時に空きを見て、二重に取り出し口を確保してしまう——そんな取り合いは、ここでは起きない。確かめと確保が地続きだから、free === 0 の判定は、嘘をつかない。

イベントループという不可視の「刻の流れ」の中で、状態の確認と確保が完全に一撃で行われる。その確実さが,この防壁の再起を支えているのだ。 カイナは、船倉の壁に掛けられた古ぼけた航海図の裏に、チョークで二つの図を並べて描きだした。 それは、わたしたちがこれまで頼ってきた「共有の魔力炉」と、今この手に構築しつつある「隔壁化された魔力槽」の構造的な違いを、冷酷なまでに明快に示していた。

Infographic architecture diagram comparing shared mana pool and bulkhead isolation compartment structures

チョークの粉が舞う中、わたしはその対比図を凝視した。 一つの巨大な池から全員で水を汲むかつての形と、水密隔壁のように機能を完全に区画化した今の形。 これこそが、魔獣たちの暴食を互いに干渉させないための、絶対的な契約の壁なのだ。 これが、隔壁(Bulkhead)の真髄か、という漏れ出たわたしの呟きに、カイナは小さく頷いた。 構造そのものを変えねば、一時しのぎの呪文など何の意味もない、と彼は言い、さて、この仕掛けが本当に嵐の中で耐えうるか、模擬戦で証明してみせようと告げた。

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

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
$ node --test
▶ Before: castShared(共有炉・全機能が同じ取り出し口を奪い合う)
  ✔ 1【飢餓】共有炉が大食らいに干され、健全な防壁が発動できない(防壁は一度も失敗しない)
  ✔ 2【反証・使い捨て】汲み上げを柵門で絞っても、もう一頭の大食らい(推進)が隙間を埋め防壁は飢える
▶ After: Bulkheads / cast(domain, …)(機能ごとに分けた専用区画)
  ✔ 3【隔離・同時性】汲み上げが取り出し口を握ったままでも、防壁は専用区画で即発動する
  ✔ 4【全区画化+境界】各区画の容量ちょうどで満杯→溢れは自区画でだけ待ち、他区画のfreeに影響しない
  ✔ 5【溢れの作法・reject】区画満杯時は待たせず即座に「今は引けん」を返す(fail-fast・防壁区画は無傷)
ℹ tests 5
ℹ pass 5
ℹ fail 0

五本、すべて緑。Before の炉は、汲み上げの大食らいに干され、健全な防壁を飢えさせた。柵門で絞っても、隙間を別の大食らいが喰い、やはり飢えた。After の槽は、同じ殺到をそのまま流しても、防壁を落とさなかった。汲み上げが握り続けたまま、防壁は通った。

変えたのは、引く先を「一つの炉」から「機能ごとの槽」に分けた、その一点だけ。詠唱も、ManaPool の仕掛けも、Before と同じ。防壁に、防壁だけの取り出し口を取って置いた——それだけが、飢えを、一つの区画に閉じ込めた。

区画が、守らないもの

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

「まず、上で槽を分けても、その先で、皆が同じ一本の井戸から汲むなら、井戸で再び奪い合う」とカイナは言った。「隔離は、井戸まで貫いて、初めて効く。上で分けても、下で一本に戻れば、隔離は見せかけだ」。船の槽を機能ごとに分けても、その魔力の源泉が一つきりなら、源泉で取り合いが起きる——区画化は、呼ぶ側が握れる層までしか、隔てられない。

「次に、槽を細かく切りすぎると、別の無駄が出る」とカイナは続けた。「防壁の槽が暇でも、汲み上げは、それを借りられん。一つの大きな炉なら、混んだ方へ空きを回せた。区画化は、その融通を捨てて、取り分の保証を買う取引だ。切りすぎれば、どの槽も暇なのに、どの詠唱も待つ、という間抜けが起きる」

「槽の大きさも、難しい」。どの機能に、取り出し口をいくつ取って置くか。小さすぎれば、その機能が、自分の槽で詰まる。大きすぎれば、区画化の意味が薄れ、共有炉に戻る。正解は、過去の繁忙の実績から、逆算するしかない——カイナは、そう言った。

「最後に、さっきの断つ結界——あれと、役割が違う」とカイナは言った。「区画化は、取り分を分ける。断つ結界は、失敗が続く相手への詠唱をやめる。別の獣だ。だが、敵じゃない。組み合わせるなら、区画の中で、個々の詠唱を断つ結界で包む。分けた槽の中で、倒れた相手への詠唱だけを、断つ」

わたしは、その線引きを、一つずつ、帳面に刻むように、胸に入れた。

守りだけは、絶やさせない

カイナは、船を下りていった。わたしは、見送らなかった。代わりに、区画を切り直した炉の点検に、取りかかった。次の航海の、支度だ。

一つの大きな炉は、確かに融通が利いた。混んだ機能へ、空いた口を回せた。だが、その融通が、防壁を飢えさせた。わたしは、融通を捨てて、取り分を買った。

——それでいい。

船のどの機能も絶やさぬ、というのが、わたしの信条だった。だが、何もかもを一つの炉に繋いで、奪い合わせるのが、絶やさぬことではなかった。賢く分けて、守りに取り分を取って置くことで、初めて、絶やさずに済む。守りだけは、何があっても、絶やさせない。それが、区画を切るということだった。

出航の号令をかける。区画を切り直した炉に、火を入れる。防壁、汲み上げ、推進——それぞれの槽の取り出し口が、機能ごとに、満ちていく。防壁の槽は、常に、空けてある。何が立て込もうと、守りの番だけは、必ず来る。

舫いを解く。クラーケン号が、ゆっくりと、港を出ていく。

次の嵐が来ても、防壁は、もう飢えない。


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

  • 魔獣名(クラス/パターン名): 餌場を独占する大食らい(汲み上げの魔獣)/ リソース枯渇の連鎖(noisy neighbor)。契約=機能ごとの魔力槽(Bulkhead=リソースを機能ドメインごとの区画へ分け、一区画の枯渇を他へ波及させない/別オブジェクトのカウンタで取り分を保証)
  • 危険度(難易度/バグの影響度): ★★★★☆(加害機能も被害機能も一度も失敗していない=エラーも例外も出ない。健全な機能が「飢えて発動しないだけ」なのでログにも例外にも残らず、原因究明が遅れる。繁忙時にだけ顕れ平時に露見しない。失敗が無いため、断つ結界=Circuit Breaker では救えない)
  • 主な生態(アンチパターンの特徴):
    • 全機能が単一の共有リソースプール(並行スロット/接続プール)から引くため、一機能の過負荷(遅い・重い詠唱)がプールを占有し、無関係な健全機能がスロットを得られず飢える(リソース枯渇の連鎖/noisy neighbor)
    • 被害機能は失敗していない——飢えて発動できないだけ。failure が無いので Circuit Breaker は作動しない(断つべき失敗が存在しない)
    • 「絞る(総量制限)」だけでは取り分は保証されない——絞って空いた隙間を別の大食らいが埋めれば、被害機能はまた飢える
    • 害はタイミング依存で、繁忙時に重い機能がスロットを占めたときだけ顕れる=平時に露見しにくい
  • 契約のポイント(設計の要点):
    • 機能ドメインごとに別オブジェクトの区画(セマフォ)を持たせ、自ドメインの区画からだけスロットを引く。実装上の核心は新しい並行制御アルゴリズムでなく「どの呼び出しをどの区画へ振り分けるか」のルーティング設計
    • 論理的保証:防壁の available カウンタは汲み上げの区画とは別オブジェクト。汲み上げがどれだけ自区画を干しても防壁の available は参照も減算もされない=大食らいは構造的に防壁の取り分へ触れられない。並行度は実行時の値ゆえ型では守れず、実行時設計(区画の物理分割)で守る
    • 取り分は「絞って空ける」のでなく「初めから分けて取って置く」(総量制限≠取り分の保証)。同時性で確定:汲み上げが取り出し口を返さないまま防壁が通る=「空いたから」でなく「初めから別の槽だから」
    • 1:1 単一差分=全機能が furnace.run(spell)(共有)→ 機能ごとに compartments[domain].run(spell)(区画)。ManaPool 実装・詠唱内容は不変(変えたのは引く先の槽のみ。防壁の取り分を増やしたのでなく、汲み上げが奪えなくした)
    • 溢れの作法:区画満杯時に queue(待たせる)か reject(即拒否=fail-fast)を選ぶ
  • 契約外事項(保証しないこと):
    • 見せかけの隔離(cosmetic isolation):上で区画化しても下流の共有資源(DB接続プール等)が一本なら、そこで再び奪い合い隔離は無効。隔離は呼ぶ側が握れる層までしか効かない
    • 細かく切りすぎる弊害(over-partitioning):区画化は統計的多重化(融通)を捨てて取り分の保証を買う取引。暇な区画の空きを混んだ区画へ回せず、全体スループットは落ちる
    • 槽の大きさ(sizing):小さすぎれば自区画で詰まり、大きすぎれば共有炉に戻る。過去の負荷実績からの逆算が要る(静的分割の硬直性)
    • 断つ結界(Circuit Breaker)とは役割が直交:区画化は「取り分を分ける」、CB は「失敗が続く相手への呼び出しをやめる」。併用は区画の内側を CB で包む(Bulkhead 外側・CB 内側)
  • 現在のステータス: 🟢 共有炉を機能ごとの魔力槽(区画)へ隔て、防壁の取り出し口を汲み上げの槽とは別オブジェクトのカウンタにすることで、大食らいが構造的に触れられなくする契約成立(取り分を絞って空けるのでなく初めから分けて取って置く/一区画の枯渇を他へ波及させない)。見せかけの隔離・融通の喪失・槽の見積もりは契約外として残置。守りの槽は、常に空けてある——何が立て込もうと、防壁の番だけは必ず来る
comments powered by Disqus
Hugo で構築されています。
テーマ StackJimmy によって設計されています。