Featured image of post コードテイマー【Optimistic Locking】二人の手が同じ数を書き、一方の更新が静かに消える〜帳に版を刻み、後から来た手にやり直させる〜

コードテイマー【Optimistic Locking】二人の手が同じ数を書き、一方の更新が静かに消える〜帳に版を刻み、後から来た手にやり直させる〜

隊商の差配役は、四十年、帳面で食ってきた。注文を二つ受け、どちらも正しく記した。書き損じはない。なのに棚卸しで数が合わない——台帳には二つの引き当てが立つのに、蔵の残りは片方しか減っていない。片方の取引が、最初から無かったかのように消える。残りを問う早馬が戻る間に、別の窓口が同じ古い残りを持ち帰り、後から書いた手が先の手を音もなく塗り潰す(Lost Update)。JS が一度に一つしか書かなくても、読んでから書くまでの隙に二人が同じ古い数を握れば、一方の更新は消える。残りの数でなく版を壺に刻み、書き戻す時に読んだ版と今の版を照らして、ぶつかった手だけを読み直させてやり直させる(Optimistic Locking)。消失を決定的に注入した模擬戦で、版が単調に増えるからこそ ABA の往復も見逃さないことまで確かめる TypeScript の契約の物語。

消えた取引

台帳には、確かに二つ立っている。なのに、蔵の数は、片方しか引かれていない。

わたしは隊商の差配役だ。街道に点々と宿場を構え、そのどこからでも同じ品を卸せるよう、中央の蔵と、一冊の在庫元帳を預かっている。触媒の壺が幾つ残っているか——それを記すのが、わたしの四十年の仕事だった。注文が来れば、宿場の窓口が蔵に「あと幾つ残っているか」を早馬で問い、戻った数から引いて、引いた後の数を書き戻す。それだけの、素直な帳面だ。

棚卸しの朝だった。現物の壺と、帳面の残りを照らし合わせていて、手が止まった。今朝、星砂の触媒に、二つの引き当てがあった。一つは三壺、もう一つは五壺。どちらも、この手でちゃんと記した。台帳には、二つとも立っている。だが——蔵の残りは、五壺しか引かれていない。三壺の引き当ては、まるで最初から無かったかのように、数に効いていない。

魔物使いは、帳場の隅で、わたしの元帳と棚の壺とを、交互に検めていた。広げたままの二つの取引の行を上から下へ読んでは、棚に残る壺の数へ視線を返す。それを幾度も繰り返す。問いも、慰めも、口にしない。ただ、数の出入りを勘定し直しているようだった。

市を回る連れの口上は、いつも半分が与太だ。“勘定の合わぬ蔵を視る、変わった魔物使いが市の外れにいる”——その半分に賭けて、商いの外へ使いを出した。お抱えの記帳役に何度検めさせても、原因が掴めなかったからだ。来たのが、カイナと名乗る、この人だった。

四十年、書き損じたことはない

わたしは、自分の帳面を誇りはしない。だが、静かな矜持はある。

「四十年、この帳面で食ってきました。書き損じたことは、ありません。今朝の二つの引き当ても、両方、この手でちゃんと記した。書き間違いも、書き漏れも、ない」

ここで、一拍置いた。不可解を、どう差し出せばいいのか、言葉を探した。

「なのに、片方の取引が、蔵の数に効いていないんです。台帳には、立っている。確かに記した。なのに——最初から無かったように、残りが減っていない。書いたものが、消えるんです」

どちらの手跡が悪いのか、いくら見ても分からなかった。どちらも、正しく書かれている。正しく書いて、なお消える。勘定家の言うことではないと、自分でも思う。だが、本当に、そうとしか言えなかった。

カイナは、ようやく口を開いた。わたしの記帳を貶める響きは、なかった。

「お前の手跡に、間違いは一つもない。二つとも、正しく書かれている。——だが、片方が"なかったこと"になるのは、書いた後じゃない。書く前に、種が蒔かれている。読んでから書くまでの間に、何が起きるか。それを、この目で見てからだ」

「読んでから、書くまで……?」わたしには、意味が掴めなかった。「早馬が、往って還る、あの間のことですか」

残りを読んで、引いて、書き戻す

わたしは、窓口の手順を写した板を取り出した。難しいことは、何もしていない。だからこそ、ここに穴があるとは、思えなかった。

「注文が来たら、蔵にあと幾つ残っているか、早馬で問います。残りが戻ったら、注文の数を引いて、引いた後の数を書き戻す。それだけです」

蔵は遠い。残りを問うのも、書き戻すのも、早馬を出して、戻るのを待つ。その往復を、術式の言葉では readwrite と呼ぶのだと、カイナは言った。早馬が戻るのを待つ間、帳場は手を遊ばせない。次の注文が来れば、また別の窓口が、蔵へ早馬を出す。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// 在庫元帳。蔵の残りを問う/書き戻すのは早馬の往復=待ちが入る境界。
export interface StockRecord {
  id: string; // 品(魔法触媒)の識別子
  remaining: number; // 蔵に残っている数
}

export interface LedgerStore {
  read(id: string): Promise<StockRecord>; // 残りを問う(早馬・往復)
  write(record: StockRecord): Promise<void>; // 引いた後の数を書き戻す(早馬・往復)
}

readwrite も、返ってくるのに間がある。だから Promise——「いずれ返ってくる」という約束で包まれ、その約束を待つのが await だ。窓口の手順は、こうなる。

1
2
3
4
5
6
// Before: 残りを読み、引いて、書き戻す。読んだ後・書く前の「隙」に他の窓口が割り込む。
export async function reserveBad(store: LedgerStore, id: string, qty: number): Promise<void> {
  const record = await store.read(id); // ① read(早馬の往復。ここで別の窓口に手番が移る)
  const remaining = record.remaining - qty; // ② 手元の残りから引く
  await store.write({ id, remaining }); // ③ write(自分の握った数で塗る=前の書き手の更新を見ない)
}

カイナは、手順を最後まで追って、低く言った。「読み、引き、書く。一点の曇りもない。——だが、await store.read のところで、早馬が出る。戻るまで、この窓口は手を止める。その間に、巡回は他の窓口へ回る。読んでから書くまでの間に、別の手が、同じ蔵を覗ける」

わたしは、まだ実感が湧かなかった。一度に一つしか書けないのに、と心の中で繰り返すばかりだった。

早馬を、途中で止める

「その不整合は、ここで起こせる」とカイナは言った。「二つの注文を流し、早馬を、途中で止めるだけだ」

順に戻れば、数は合う

カイナがまず組んだのは、蔵を真似た小さな帳面だった。残りを問えば数を返し、書き戻せば数を改める。本物の蔵と同じ作法で、しかし早馬の往復を、こちらの手で握れる仕掛けだ。

そして、二つの注文を素直に流した。星砂を三壺引く窓口と、五壺引く窓口。早馬が順に戻れば——一つ目が読んで、引いて、書き終えてから、二つ目が読む——どちらも正しく引けた。十から三で七、七から五で二。残りは二壺。消えない。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
it("早馬が順に戻れば(直列なら)両方とも正しく引かれる(運次第・平時は露見しない)", async () => {
  const ledger = new GatedLedger({ id: "catalyst", remaining: 10 });

  // a を先に完走させてから b を起動する=直列(read 保留→解放→write 完了の後で b 開始)
  const a = reserveBad(ledger, "catalyst", 3);
  ledger.releaseAllReads(); // a の read だけが保留されている
  await a;

  const b = reserveBad(ledger, "catalyst", 5);
  ledger.releaseAllReads(); // b の read を解放
  await b;

  assert.equal(ledger.remaining, 2); // 10-3-5=2。直列なら消失しない
  assert.deepEqual(ledger.writeLog, [7, 2]); // a: 10-3=7 / b: 7-5=2(直列ゆえ b は最新の7を読む)
});

カイナは、同じ流しを何度か繰り返した。合うこともあれば——と、ここで初めて、合わないことが混じった。

「これだ」とカイナは言った。「お前が棚卸しまで掴めなかったのは、これが運任せに紛れるからだ。早馬の還る順しだいで、合うこともある。合わないこともある。だが、運に任せていては、正体は掴めん。消える刻を、こちらで作る」

(蔵の数や書き戻しの記録を直に覗く窓——remainingwriteLog——は、模擬戦のために添えてある。writeLog は、書き戻された数を順に控える帳面だ。台帳に立った取引を、そのまま並べたものだと思っていい。)

同じ刻に、同じ数を持ち帰らせる

「鍵は、早馬だ」とカイナは言った。「残りを問う早馬を、還る途中で止めておく。二つの窓口の早馬を、両方止めて、同じ"十"を持たせてから、揃って還す。そうすれば、二人は必ず、同じ古い数を握る」

これが、肝だった。本物の蔵では、二つの早馬が同じ刻に同じ数を持ち帰るのは、繁忙のときの、稀な巡り合わせだ。だからこそ、棚卸しまで露れない。カイナは、その稀な巡り合わせを、檻の中で狙って起こした。早馬を二つとも止め、両方に「残り十」を持たせ、それから順に書き戻させる。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
it("二人が同じ残りを読むと、後の書き戻しが先を上書きし一方の引き当てが消える(Lost Update の決定的再現)", async () => {
  const ledger = new GatedLedger({ id: "catalyst", remaining: 10 });

  // 二つの reserveBad を起動 → 両方の read 保留点に到達させる(最悪順序の注入)
  const a = reserveBad(ledger, "catalyst", 3); // 窓口A(3引く)
  const b = reserveBad(ledger, "catalyst", 5); // 窓口B(5引く)

  // 両方が read で保留中であることを確認(足場の健全性チェック)
  assert.equal(ledger.pendingReadCount, 2);

  // 同じ残り「10」を、登録順(a→b)で一斉解放=両者が同じ古い数を握る
  ledger.releaseAllReads();
  await Promise.all([a, b]);

  // 台帳には二取引が両方書かれた(writeLog に7と5が両方立つ)のに、
  // 蔵の残りは後勝ち=5(3引いた取引が"最初から無かったこと"になる)
  assert.deepEqual(ledger.writeLog, [7, 5]);
  assert.equal(ledger.remaining, 5); // 正しくは10-3-5=2のはずが、5のまま(3引いた取引が消えた)
});

一つ目の窓口は、十から三を引いて、七と書いた。二つ目の窓口は、その七を見ないまま——自分が握った十から五を引いて、五と書いた。蔵の残りは、五。

わたしは、帳面(writeLog)を覗いた。七と五、二つの書き戻しが、どちらも記されている。確かに、両方とも書かれた。なのに、蔵の残りに効いたのは、後の五だけだった。正しくは、十から三を引き、さらに五を引いて、二壺のはず。それが、五壺のまま。三を引いた取引が、消えていた。台帳に二つ、残りは片方。棚卸しのあのズレが、ここに、狙って起こせる形で立った。

わたしは、ようやく像を結びかけた。「……二人とも、十を握っていました。一人が七と書いて、もう一人が、その七を見ずに、五で塗った。早馬が、同じ"十"を持ち帰ったから」

頭の中で、早馬たちが交差して帳面を塗りつぶしていく光景が、一枚の絵になって浮かび上がった。

Infographic sequence diagram showing double-submission lost update due to await suspension point

読んでから書くまでの、隙

カイナは、二つの早馬の符牒を、卓に並べた。

「種は、読んでから書くまでの隙だ。残りを問う早馬が還るまで、巡回は他の窓口へ回る。その隙に、もう一人が同じ古い残りを読む。書くのは一つずつでも、古い数を二人が握ることは、起きる」

「一度に一つしか書けないなら」とわたしは食い下がった。「なぜ、重なるんです」

「書く瞬間は、重ならない。お前の言う通り、一つずつだ。だが、消えるのは書く瞬間の衝突じゃない。読んだ数が、書く頃にはもう古い——その隙だ。一つずつ書いても、古い数を二人が握れば、後の手が先の手を、塗り消す」

ここで、わたしの思い込みが、ひとつ覆った。一度に一つしか書けないから、安全だと思っていた。だが、消えるのは書くときではなく、読んだあの瞬間から、もう種が蒔かれていたのだ。

カイナは、面白いところだ、と言った。「どちらの窓口の手順も、型の上では一点の曇りもない。残りを読み、引き、書く。型は、“数を引き忘れた"とか"品を取り違えた"なら叱る。だが、“二人が同じ古い数を読んだ”——その重なりは、型のどこにも書かれない。走らせて、二つの早馬が同じ刻に同じ数を持ち帰ったときだけ、立ち上がる」

二つ以上の処理が、同じ資源を読んで、書き換えて、書き戻す。その手順が重なって、一方の書き込みがもう一方を上書きして消す。これを Lost Update(ロストアップデート)——読んで書き戻すまでの隙に割り込まれ、更新が静かに失われる事故——という。そして、読んで・直して・書き戻すこの一連の手順を、read-modify-write と呼ぶ。隙が生まれるのは、いつもこの真ん中、読んでから書くまでの間だ。

「一つ、はっきりさせておく」とカイナは続けた。「前に、別の工房で、二つの錬金釜が、互いの鍵を握ったまま、相手の鍵が空くのを待って、にらみ合って石になったことがある。あれは、動かなくなる獣だった。互いを待って、どちらも前に進めない。——だが、これは逆だ。お前の二つの窓口は、止まらない。両方とも、最後まで走り切る。走り切って、なお、片方が消える。止まる病と、消える病。似て、別の獣だ」

そう言って、カイナは符牒の一つを指で弾いた。「これは、分裂する妖精だ。一匹に見えて、二匹いる。双子は、互いを見ない。同じ壺の数字を、別々の刻に握って、別々に塗り替える。互いの仕事を、知らないまま」

平時には、決して露れない。二つの早馬が同じ刻に同じ数を持ち帰った、ちょうどその巡り合わせでだけ、片方が消える。だから、わたしは四十年、書き損じもないのに、棚卸しまで気づけなかった。

帳に、版を刻む

「壺の数字に、版を添える」とカイナは言った。

後から来た手に、やり直させる

「書き換えるたび、版を一つ繰り上げる。零版、一版、二版、と。誰かが書けば、版が進む。残りの数とは別に、書かれた回数の証だ。肝心なのは——版が書かれるたびに、必ず一つ増えること。減ることも、途中を飛ばすこともない。常に、前へ進むだけだ」

数えるのは滅多にぶつからない、と楽観して、皆に好きに読ませ、好きに書かせる。ただし書き戻す時だけ、版を照らす。この手当てを、Optimistic Locking(楽観的ロック)——衝突は稀だと構えて、ぶつかったときだけやり直させる仕組み——という。

「書き戻すとき、こう検める」とカイナは言った。「自分が読んだのは、何版か。読んだのが零版で、今の壺も零版なら——わたしが読んでから、誰も書いていない。だから、書いてよい。版を一版に繰り上げて。だが、読んだのは零版なのに、今の壺が一版になっていたら——読んでから書くまでの間に、誰かが書いた証拠だ。その時は、書かせない。退いて、読み直して、最新の数から、やり直す」

この「読んだ版と今の版を照らし、同じときだけ書く」一手を、compareAndSwap——読んだときの値と今の値を見比べ、変わっていなければ書き換える操作——という。版が合わなければ、衝突の合図を投げる。版が必ず一つずつ前へ進むからこそ、「読んだ版と今の版が同じ」は「読んでから誰も書いていない」と、ぴたり同じ意味になる。間に誰か一人でも書いていれば、版はもう進んでいて、決して元の数には戻っていないからだ。

照らすのと書くのは、一続きで行う。照らしてから書き入れるまでの間に、また別の手が割り込めるなら、せっかくの照合が無駄になる。だから、版を確かめて書き換えるこの一手は、途中で割り込ませない、ひとかたまりの所作にする。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 版(version)付きの在庫記録。version は書き換えるたび繰り上がる(単調増加)。
export interface VersionedRecord {
  id: string;
  remaining: number;
  version: number; // 書かれた回数の証=0版→1版→2版…。戻らない
}

// 版が合わない=読んでから書くまでに誰かが書いた、という衝突の合図。
export class OptimisticLockError extends Error {
  constructor(id: string, expected: number, actual: number) {
    super(`版が合わない(${id}): 読んだ版=${expected}, 今の版=${actual}`);
    this.name = "OptimisticLockError";
  }
}

export interface VersionedStore {
  read(id: string): Promise<VersionedRecord>;
  // 読んだ版(expectedVersion)が今の版と一致するときだけ書き、版を繰り上げる。違えば衝突を投げる。
  compareAndSwap(
    id: string,
    expectedVersion: number,
    next: { remaining: number },
  ): Promise<VersionedRecord>;
}

そして、窓口の手順は、こう変わった。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// After: 読み直し→再計算→版を照らして書く。版違い(衝突)なら最新を読み直してやり直す。
export async function reserveSafe(
  store: VersionedStore,
  id: string,
  qty: number,
  maxRetries = 5,
) {
  for (let attempt = 0; attempt < maxRetries; attempt++) {
    const record = await store.read(id); // 読み直し(最新の残りと版)。やり直すたび最新を握る
    const remaining = record.remaining - qty; // 再計算(古い手元値でなく、今読んだ最新値から引く)
    try {
      // 読んだ版を添えて書く。版が今も一致すれば書けて版が繰り上がる。違えば OptimisticLockError。
      return await store.compareAndSwap(id, record.version, { remaining });
    } catch (err) {
      if (!(err instanceof OptimisticLockError)) throw err;
      // 版違い=この隙に誰かが書いた。次のループで読み直し、最新値からやり直す。
    }
  }
  throw new Error(`やり直しの上限(${maxRetries})に達した: ${id}`);
}

「変えたのは、書き戻す時の一手だけだ」とカイナは言った。「前は、握った数を、そのまま塗った。今度は、塗る前に版を照らす。読んだ版と、今の版。同じなら塗る、違えば退いて、読み直す。読む・引く・書く、の形は、何も変わっていない」

ここで、カイナは念を押した。指したのは、ぶつかった後の読み直しのところだ。「肝は、ぶつかったら、必ず読み直すことだ。退いておいて、さっき握った古い数で、また同じものを書こうとしたら——版は照らせても、塗るのは古い計算だ。それでは検めただけで、直していない。やり直すなら、まず今の数を、もう一度読む。それから、引く」

reserveSafe の中で、ぶつかった後にもう一度 store.read を呼び、その新しい record.remaining から引き直しているのは、そのためだった。古い手元の数を握り直すのではなく、最新を読み直す。それでこそ、消えた更新が、次の計算に乗る。

さあ、と、カイナは同じ檻を組んだ。さっきと寸分同じ注入——二つの早馬を止め、両方に「残り十・零版」を持ち帰らせる。

 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
it("同じ版を読んでも、後の手は版違いで衝突を検知し読み直してやり直す→両方反映(残り2・版2)", async () => {
  const ledger = new GatedVersionedLedger({ id: "catalyst", remaining: 10 });

  const a = reserveSafe(ledger, "catalyst", 3); // 窓口A(3引く)
  const b = reserveSafe(ledger, "catalyst", 5); // 窓口B(5引く)

  assert.equal(ledger.pendingReadCount, 2);

  // 第1段階: 両者に版0・残10を読ませる(同じ古い版を握らせる=Before と寸分同じ注入)
  ledger.releaseAllReads();
  await Promise.resolve();
  await Promise.resolve();
  await Promise.resolve();
  await Promise.resolve();

  // a の CAS 成功(版0→1・残7)/ b は版違いで衝突→ループ先頭で新たな read を保留している
  assert.equal(ledger.pendingReadCount, 1);
  assert.deepEqual(ledger.current, { id: "catalyst", remaining: 7, version: 1 });

  // 第2段階: b のリトライ read を解放 → 最新(版1・残7)を読み、7-5=2 で再 CAS 成功(版1→2)
  ledger.releaseAllReads();
  await Promise.all([a, b]);

  assert.equal(ledger.current.remaining, 2); // 10-3-5=2。両方反映(消失なし)
  assert.equal(ledger.current.version, 2); // 二度書かれた証
});

一つ目の窓口は、読んだ零版が今も零版だったから、七と書けた。版は、一版へ。二つ目の窓口は、読んだのは零版なのに、今の壺は一版になっていた。一つ目が、書いたのだ。だから、衝突。二つ目は退いて、読み直す——今度は、最新の「残り七・一版」を握る。七から五を引いて、二。再び、版を照らす。読んだ一版が今も一版だから、書けた。残りは、二壺。版は、二版へ。

三を引いた手も、五を引いた手も、どちらも消えなかった。

版を刻み、弾かれた手が退いて読み直す一連の流動が、今度は整然とした歩みとなって見えた。

Infographic sequence diagram showing version-based Compare-And-Swap mechanism in Optimistic Locking

「これは、運じゃない」とカイナは、念を押すように言った。「後から書こうとした手は、必ず版違いに気づく。零版で読んで、壺が一版なら、間に誰かが書いた——それは数を照らさずとも、版が告げる。気づいたら、必ず読み直す。読み直せば、必ず最新の七を握る。だから、どちらの引き当ても、消えはしない」

断っておくが、と、カイナは付け加えた。「版が消すのは、読んでから書くまでに割り込まれた、その上書きだ。ぶつかった手は、退いて、やり直す。そのやり直しの間に、もう一度ぶつかることは、ある——次でまた、別の手が書けば。だが、ぶつかるたび版が告げ、読み直すたび最新を握る。だから、いつまでも消えはしない」

わたしは、ひとつ訊いた。後から来た手を、待たせはしないのか、と。

「待たせはしない」とカイナは言った。「先に書いた者は、止めない。好きに書かせる。——ぶつかった者にだけ、もう一度、書かせる」

数は嘘をつけても、版は嘘をつけない

「ですが」とわたしは、勘定家の常識から、口を挟んだ。「版などなくとも、残りの数を照らせばよいのでは。読んだ時が十で、書く時も十なら、誰も触っていない。数が同じなら、無事でしょう」

カイナは、口で否定しなかった。代わりに、版でなく「残りの数」を照らす照合を、その場で、仮に組んで見せた。本筋では使わない、反証のための、使い捨ての手だ。

1
2
3
4
5
6
// もし版でなく「残りの数」を照らしていたら——(反証用の仮の照合・本筋では使わない)
//   if (current.remaining !== 読んだときの残り) throw new Error("衝突");
//
// 壺は10。ある手が3引いて7にし(書ける)、別の手が補充で3足して10に戻す(書ける)。
// 最初に「10」を読んだ手が、今また「10」を見て――
//   10 === 10 で「誰も触っていない」と素通しする=間の二度の書き換えを、まるごと見逃す。

「壺が十だった」とカイナは言った。「ある手が三引いて七にし、別の手が、補充で三足して十に戻した。お前が読んだ十と、書こうとする今の十。数は、同じだ。だが、その間に壺は二度、書き換わっている。数だけ照らせば、“誰も触っていない"と見える。間に挟まった二つの書き換えを、まるごと見逃す」

そして、版に戻した。「数は、元に戻れる。十から七、また十へ。だが版は、戻らない。零版、一版、二版、と進むだけだ。同じ手が今読めば、壺は二版。自分が握ったのは、零版。食い違う。だから、版なら——往復しても、必ず気づく。数は嘘をつけても、版は嘘をつけない。照らすのは、数じゃない。版だ」

値が同じ場所を二度読んだとき、その間に別の手が値を変えて、また元へ戻していても、二度の読みでは見分けられない。この見逃しを ABA 問題——値が A から B へ、また A へ戻ると、何も起きなかったように見えてしまう罠——という。版は単調に増えるだけで戻らないから、間の書き換えを構造として見逃さない。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
it("残数が10→7→10と往復しても版は単調増加ゆえ「誰も触っていない」と誤判定しない(ABA)", async () => {
  const ledger = new GatedVersionedLedger({ id: "catalyst", remaining: 10 });

  const held = ledger.current; // 最初に版0・残10を読む(手元に保持する想定)
  assert.equal(held.version, 0);
  assert.equal(held.remaining, 10);

  await ledger.compareAndSwap("catalyst", 0, { remaining: 7 }); // 10→7(版0→1)
  await ledger.compareAndSwap("catalyst", 1, { remaining: 10 }); // 7→10(版1→2・残数だけ往復)

  assert.equal(ledger.current.remaining, 10); // 値だけ見れば「最初と同じ」に見える
  assert.equal(ledger.current.version, 2); // しかし版は2(間に2度書かれた証)

  // 手元の版0で CAS を叩くと、残数は 10==10 でも版 0≠2 で衝突する(値比較なら見逃す往復を版比較は検知)
  await assert.rejects(
    () => ledger.compareAndSwap("catalyst", held.version, { remaining: 5 }),
    (err: unknown) => err instanceof OptimisticLockError,
  );
});

版は、ただ一つ繰り上がる数でいい、とカイナは言った。凝った印は要らない。戻らない——それだけが、効く。

待つか、やり直すか

「いっそ」とわたしは、別の手を思いついて訊いた。「片方を待たせて、触らせなければよいのでは。一人が書き終えるまで、もう一人を蔵に入れない。そうすれば、二人が同じ数を読むことも、ない」

「それも、一つの手だ」とカイナは言った。「さっき話した、鍵で釜を縛る手——一人が鍵を握れば、もう一人は外で待つ。あれを壺に掛ければ、二人が同じ数を読むことは、起きない。ぶつかる前に、待たせる。“どうせぶつかる"と構えて、先に締める手だ」

だが、と、カイナは続けた。「待たせれば、列ができる。ぶつかりが滅多に起きないのに、皆を一列に待たせるのは、惜しい。版を照らす手は、待たせない。皆、好きに読んで、好きに書く。ぶつかった時だけ、やり直す。——ぶつかりが稀なら、こちらが速い。ぶつかりが多いなら、待たせるほうが、無駄がない。どちらが上でもない。お前の蔵で、二人が同じ壺を奪い合うのが、年に何度か。なら、版を照らす手だ」

待たせて防ぐか、やり直してすり抜けるか。それは、衝突がどれだけ起きるかで選ぶものなのだと、わたしは知った。

やり直しに、刻限を

「やり直しは、いつまで続くのです」とわたしは訊いた。「ぶつかり続けたら、永遠に?」

カイナは即答せず、わたしの帳面の隅に、小さく版の数字を書き添えた。

「やり直しにも、限りを設ける。何度ぶつかっても通らなければ、諦めて、呼び手に返す。さもなくば、運悪くぶつかり続けて、いつまでも書けない手が出る」

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
it("衝突が続けばmaxRetriesで諦めて投げる(やり直しに刻限)", async () => {
  const ledger = new GatedVersionedLedger({ id: "catalyst", remaining: 10 });

  // read のたびに内部 version を直接 ++ させる細工= CAS が必ず衝突する
  ledger.setReadHook(() => {
    ledger.bumpVersionDirectly();
  });

  const attempt = reserveSafe(ledger, "catalyst", 3, 3); // maxRetries=3
  attempt.catch(() => {});

  // 保留が立つたび解放する(maxRetries 回ぶん。周回ごとのマイクロタスク段数に依存しない)
  for (let i = 0; i < 3; i++) {
    while (ledger.pendingReadCount === 0) {
      await Promise.resolve();
    }
    ledger.releaseAllReads();
  }

  await assert.rejects(() => attempt, /やり直しの上限/);
});

「もし大繁盛で、十人が同じ壺を狙えば」とカイナは言った。「皆が一斉に弾かれ、一斉にやり直して、また一斉にぶつかる。その時は、やり直す刻を、少しずつ散らす別の工夫が要る。前に、嵐の中で雷鳥が揃って飛び立って、塔を潰した話があった——あのとき効いた、揺らぎを混ぜる手だ。だが、それはこの蔵の話じゃない」

ここで、わたしは、最初に口にした疑問を思い出した。「そういえば——わたしは初め、“一度に一つしか書けないのに、なぜ重なる"と言いました。一度に一つなら、後から来た方を、はじく札を立てればいいのでは。一つの窓口が、二度書こうとするのを止めるように」

「前に、一つの転送陣が、連打で同じ荷を二度送った話がある」とカイナは言った。「あれは、一人が二度動くのを止める札だった。一人の手が、一度きりになるように、入口で締める。——だが、今度は逆だ。二人が、それぞれ一度ずつ、正しく動く。一度ずつなら、入口の札は立たない。立てるものが、無い。同じ"重なり"でも、一人の重なりと、二人の重なりは、別の獣だ。一人を止める札では、二人には届かない」

一人の連打を止める仕組みと、独立した二人の書き手が同じ数を握る競合は、別のものだ。前者は入口で一人を締めればいい。後者は、入口では締められない。だから、書き戻す時に版を照らす。わたしは、ようやく、その違いが腑に落ちた。

数と帳面を、照らす

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
$ node --test
▶ Before: reserveBad(無防備な read-modify-write)
  ✔ 早馬が順に戻れば(直列なら)両方とも正しく引かれる(運次第・平時は露見しない)
  ✔ 二人が同じ残りを読むと、後の書き戻しが先を上書きし一方の引き当てが消える(Lost Update の決定的再現)
▶ After: reserveSafe(版照合+読み直しリトライ)
  ✔ 同じ版を読んでも、後の手は版違いで衝突を検知し読み直してやり直す→両方反映(残り2・版2)
  ✔ 衝突が無ければ(直列)一発で書ける(版が素直に0→1→2へ進む)
  ✔ 残数が10→7→10と往復しても版は単調増加ゆえ「誰も触っていない」と誤判定しない(ABA)
  ✔ 衝突が続けばmaxRetriesで諦めて投げる(やり直しに刻限)
ℹ tests 6
ℹ pass 6
ℹ fail 0

六本、すべて緑。Before の窓口は、早馬が順に戻る素直な流しなら両方とも正しく引き、同じ数を二人に握らせた途端、後の書き戻しが先を塗り消した。After の窓口は、まったく同じ注入を流しても、後から来た手が版違いに気づいて読み直し、二つの引き当てが、どちらも残った。

変えたのは、書き戻す時の一手だけだった。残りを問う早馬も、引く計算も、外から見た「予約する」作法も、Before と After でまるごと同じ。違うのは、塗る前に版を照らすか、照らさずに塗るか。その一点が、消える取引を消した。

数の隣に、版を置く

蔵の数と、帳面の残りが、もう食い違わなくなった。棚卸しのズレは、消えた。

「わたしは、書くことばかり見ていました」とわたしは言った。「正しく書けば、正しく残ると。でも——書く前に読んだ数が、書く頃にはもう古いことがある。版は、その"古さ"を、教えてくれる。読んだ時の版と、書く時の版。それが食い違えば、わたしの手元の数は、もう古い。四十年、数だけを信じてきました。数の隣に、版を置くべきだったんですね」

カイナは、最後に一つだけ、別の帳場の話をした。版が消すのは、読んでから書くまでに割り込まれた、その上書きだけだ、と。待たせて防ぐ手も、しばらくズレを許して後でゆっくり揃える手も、大きな帳場——本物のデータベース——での版の掛け方も、それぞれ別の獣、別の帳場の話だと。詳しくは、また別の機会に、とだけ言い添えた。

カイナは帳場を出る前に、棚の壺を一つ、手に取った。中の星砂を、灯りに透かして、また戻す。

「数は、元へ戻る。だが、お前が今日添えた版は、戻らない。——覚えておくのは、それだけでいい」

それだけ言い残して、カイナは帳場を後にした。勘定で、答えの出ることだけを。

わたしは、元帳を開いた。星砂の残りの数の隣に、小さく「零」と、版を一つ、書き添えた。これから書き換えるたびに、ここを一つ繰り上げる。インクが乾くのを、しばらく、見ていた。


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

  • 魔獣名(クラス/パターン名): ピクシー(分裂する妖精)/ データレース・ロストアップデート(Optimistic Locking=版の照合で衝突を検知し読み直してやり直す/対:Pessimistic Locking=事前に締めて待たせる)
  • 危険度(難易度/バグの影響度): ★★★★☆(書き損じも例外も無く、更新が静かに消える。台帳に二取引立つのに残りは片方しか減らず、在庫が実際より多く見える=幽霊在庫から、やがて売り越し・二重引き当てに化ける。エラーを出さないぶん発覚が遅く、棚卸しまで露れない)
  • 主な生態(アンチパターンの特徴):
    • read→(早馬の往復=中断点)→modify→write の「読んでから書くまでの隙」に、別フローが同じ古い値を読む。二人とも古い値を握り、後の write が先の write を無音で上書きする(Lost Update)。デッドロックの循環待機(互いを待って止まる・liveness)と異なり、両フローは止まらず完走して、なお一方が消える(safety)
    • JS はシングルスレッドで「書く瞬間」は重ならないが、消失は書く瞬間でなく「読んだ値が書く頃には古い」隙に宿る。一度に一つ書いても、古い値を二人が握れば消える
    • 害はタイミング依存。早馬(I/O)が順に戻れば(直列)露見せず、同じ刻に同じ値を持ち帰った時だけ消える=棚卸しまで気づけない
  • 契約のポイント(設計の要点):
    • Optimistic Locking=レコードに version(単調増加)を持たせ、書き戻し時に compareAndSwap(読んだ version=現在 version のときだけ書き、version を繰り上げる)。不一致=衝突=間に誰かが書いた証拠ゆえ書かせない
    • 読み直しリトライ=衝突したら最新値を読み直して再計算し再 CAS(古い手元値の再送は、検知だけで直さない誤実装)。maxRetries で高競合の無限ループを防ぐ
    • 1:1 の単一差分=Before(store.write で無検証上書き)→ After(store.compareAndSwap で版照合+for ループの読み直しリトライ)。read→modify の骨格と外部窓口は不変、差分は「書き戻しを版照合にし、衝突時に読み直す」のみ
    • なぜ値でなく版か(ABA)=値(remaining)は 10→7→10 と往復しうるが version は戻らない。値比較は往復を見逃すが、版比較は単調増加ゆえ間の更新を構造として検知
  • 契約外事項(保証しないこと):
    • Pessimistic Locking(鍵/Mutex)は別の賭け方=事前に締めて待たせる。衝突が稀なら楽観が速く、頻繁なら悲観/待ち行列が無駄が少ない(優劣でなく衝突頻度で選ぶ)。ABA は楽観に特有で、悲観には無い
    • リトライストームは高競合で多発=やり直す刻を散らす Jitter(揺らぎ)で対称を破る。本話の最小形は version+maxRetries に絞り、深入りしない
    • 再入ガード(単一書き手の連打を止める札)では防げない=独立した二人が各一度ずつ書く競合には届かない
    • 結果整合性は逆の設計思想=「今すぐ揃える」楽観ロックに対し「ズレを許して後で揃える」。別の獣の領分
    • 本話はアプリ層の最小形(in-memory+version)。実 DB(WHERE version=?・条件付き書き込み)への一般化は接続のみ
  • 現在のステータス: 🟢 壺に版を刻み、版の照合で「読んでから誰かが書いたか」を検知、ぶつかった手だけ読み直してやり直させ、更新の消失を構造から排除した契約成立(衝突は事後検知・先着は止めない)/待たせる悲観ロック・高競合の揺らぎ・結果整合性は別の獣として後日。型で消せぬ同時更新の競合を、版の照合で律する一手が、また加わった
comments powered by Disqus
Hugo で構築されています。
テーマ StackJimmy によって設計されています。