どちらにも「予約されました」 — await ひとつが分けるもの

7 分
目標同じ時間を二人が同時に取る。言葉で説明すると二つの処理は全く同じことをする — 空いているか見て、空いていれば取る。一方は二人ともに確定を送り、もう一方は送らない。違いは途中にある await ひとつだ。

予約の道具で最も悪い失敗は、予約が取れないことではない。

二人ともに「ご予約されました」を送ることだ。取れなければやり直せばいいが、どちらも取れたと言われれば、その次は人が電話で解決することになる。

二つの処理、同じ説明

このサンプルにはスロットを取る処理が二つある。言葉で説明すると完全に同じだ — 空いているか見て、空いていれば取る。

一方はこうだ。

// 1. Look.
final free = slot.takenBy == null;

// 2. Anything at all in here — a database round trip, a payment check, a
//    log write, a lookup of the customer's name — hands control to
//    whatever else was in flight.
await Future<void>.delayed(Duration.zero);

// 3. Write, using what was true in step 1.
if (free) { slot.takenBy = who; ... }

もう一方はこうだ。

if (slot.takenBy != null) {
  return '$at is already taken by ${slot.takenBy}';
}
slot.takenBy = who;
slot.confirmations.add(who);

await Future<void>.delayed(Duration.zero); // the slow part, after the claim

同じ await が位置だけ違う。 前者は確認と記録の間にあり、後者は記録の後にある。

そしてその位置が二重予約を作る。

実際に起こす

unsafe -> Smith:  "CONFIRMED 10:30 for Smith"
unsafe -> Jones: "CONFIRMED 10:30 for Jones"
unsafe result: 1 slot confirmed to more than one person

競合のあと — 10:30 の一枠に二人が yes を聞いた。TOLD YES TWICE は 1 で、その行に二人の名前が残っている

画面が現実の症状を正確に見せている。 スロットの持ち主はParkひとりなのに、「予約された」と言われた人は二人だ。

これが実際の店で起きる形だ。帳簿は一人、扉の前には二人が来る。

だからサーバーは確定を送った相手を全員覚えている。

/// Everybody who has ever been told they got this slot. In a correct
/// booking system this never has more than one name in it, which is exactly
/// why it is worth keeping.
final confirmations = <String>[];

正常なら常に一人だけの一覧だ。常に一人でなければならない一覧こそ、数えてみる値打ちがある。

そして起こらないようにする

同じ二人、同じ時間、隙間だけ閉じて。

--- cleared, same two people, same slot, gap closed ---
safe -> Smith:  "CONFIRMED 10:30 for Smith"
safe -> Jones: "10:30 is already taken by Smith"
safe result: nobody was told yes twice

同じ競合でサーバーが一度に一つだけ取るとき — 10:30 は Smith 一人、TOLD YES TWICE は 0。下段は "One slot, one yes"

拒否が「失敗しました」ではなく「Suhさんが取りました」だ。 二人目が知るべきなのは自分が失敗したという事実ではなく、その枠が出たという事実であり、そうしてはじめて次の時間を選べる。

良い場合だけ検査すれば通ってしまう

このサンプルの検証は変わっている。バグが起きたかを検査する。

unsafe = s.call("book.stampede", {"at": "10:30", "first": "Smith", "second": "Jones", "mode": "unsafe"})
assert unsafe["doubleCount"] == 1, unsafe      # the race must actually happen, or this run proves nothing
safe = s.call("book.stampede", {"at": "10:30", "first": "Smith", "second": "Jones", "mode": "safe"})
assert safe["doubleCount"] == 0, safe

理由は単純だ。予約のテストが「一人だけ確定した」だけを検査すれば、バグの生きているサーバーでも通ってしまう。 競合が実際に起きなければ順番に処理され、結果は正常だからだ。

だから順序はこうだ。

  1. 安全でないほうで競合が実際に起きたことを確認し
  2. 安全なほうで同じ競合で起きないことを確認する

一つ目がなければ二つ目は何の意味もない。

もうひとつある。隙間が消えれば失敗する。

# The two handlers must stay near-identical apart from where the await sits.
# If somebody "fixes" the unsafe one, this sample stops teaching anything.
grep -q "await Future<void>.delayed(Duration.zero);" booking_server/bin/server.dart \
  || { echo "   the gap in the unsafe handler is gone — nothing is demonstrated"; exit 1; }

作りながら競合が起きなかった

最初の版ではバグが出なかった。

ハーネスから道具の呼び出しを二つ同時に撃ったのに結果が正常だった。

take_unsafe -> Smith:  "CONFIRMED 10:30 for Smith"
take_unsafe -> Jones: "10:30 is already taken by Smith"
take_unsafe result: nobody was told yes twice

転送層が直列化していた。 stdio はリクエスト一行を読み、その処理を最後まで回し、それから次の行を読む。重なる区間がそもそもない。

ここで二手に分かれた。ひとつは「この転送では出ないから問題ない」と流すこと。もうひとつは重なりを作って確認すること。

後者を選んだ。HTTPやSSEに変えればその重なりは誰も作らなくても届くからだ。転送がたまたま守ってくれているものは守られたことにならない。

// The overlap is made here, inside the server, and that is on purpose.
// ...
// On an HTTP or SSE transport the overlap arrives by itself and nobody
// has to arrange it. So this tool arranges what that transport would have
// handed the server anyway.

これはこのシリーズで初めての種類の発見だ。 先行する編の欠陥はすべて私のコードにあったが、これは環境が欠陥を隠してくれた場合だ。そして隠してくれる環境はいつでも変わる。

このサンプルの範囲

重なりは私が作った。 サーバーの中で二つの処理を同時に立ち上げる。本当に同時のリクエストが転送を通って入ってきて起きる競合ではない。stdio ではその競合が再現されないというのがこの編が突き止めた事実であり、だからこのサンプルは「HTTPだったらこうなる」を見せるものだ。 HTTP転送で実際に確認したわけではない。

単一プロセス内の競合だけを扱った。 サーバーが二台に増えればこの解決策は丸ごと無効だ。そのときはデータベースの一意制約やロックが必要で、「確認と記録の間に何もない」をプロセスの外で保証する仕事ははるかに難しい。扱っていない。

キャンセルも待ちもない。 取ったものを手放すこと、手放された枠を待っていた人に渡すことがない。実際の予約道具の半分はそちら側だ。

決済がない。 決済が付けば「枠は取れたが決済が失敗した」という状態が生まれ、その状態をどれだけ掴んでおくかが新しい問題になる。

残るもの

この編のコードは十行に満たない。ところがその十行が言葉で説明すると区別できない。

「空いているか見て、空いていれば取る」— 二つの処理どちらもそう説明される。コードレビューでも通るだろうし、仕様書に書いても同じように書かれるだろう。

見える場所は実行だけだ。 だからこの種の欠陥は読んで見つからず、起こしてみて見つける。

そして起こしておけば、それがそのまま検査になる。

練習課題

同じ予約を続けて二回送ってください。二回目で予約がもうひとつ作られないことを示し、どちら側がそれを保証したかを説明してください。

関連記事Told Yes Twice — What One await Decides