競合を止めるレーン
目標
検索ボックス・「いいね」・リスト更新で状態を壊す非同期の競合を、自分で 防いでみます。ファイル1つに、4つの関数を作ります。
なぜ重要なのか
同期の世界では、「最後に呼んだものが最後に反映される」ことは、ただで手に入ります。非同期ではただではありません。先に送ったリクエストがあとに届くことがあり、そのとき状態を書き換える場所に 順序の概念がないと、新しい値が古い値に黙って上書きされます。開発者のマシンではレスポンスが 速すぎて再現しないため、こうしたバグはユーザーにしか見えません。
ロールバックも同じです。楽観的更新を逆演算で戻すと、その間に入ってきた別の 更新まで一緒に消えてしまいます。ロールバックは引き算ではなく、待機リストから取り除いて再 計算することです。このラボは、その違いを手で作って体験させます。
作るもの
/root/work/race/lane.mjsから、次のものをexportします。
| export | 契約 |
|---|---|
createLane() |
run(task)は、最新のリクエストだけを反映します。新しいrunは前のrunのシグナルを切ります |
createDedupe() |
run(key, task)は、進行中の同じキーに同じ約束を分け合います |
createOptimistic(base) |
begin・rollback・settle・getState |
createCache() |
set(key, value, now)・get(key, now, staleMs) |
ステップ
- 最新優先のレーン
- キャンセルシグナル
- キーごとの重複排除
- 楽観的更新とロールバック
- サーバーのレスポンスでベース値を差し替える
- 古さの判定
- 自分で測って書く:
/root/work/race/07-measure.txt - まとめ:
/root/work/race/08-notes.md
参考
- npm installは使えません。ネットワークがなく、必要でもありません。
AbortControllerとqueueMicrotaskは、node 22に標準で入っています。 - 採点ツールは皆さんの
lane.mjsをimportして、契約だけを確認します。画面も フレームワークも使いません。 - ステップ7は、皆さんが書いた数値を採点ツールがその場で測り直して照合します。 でっち上げた数値では通りません。
最新優先のレーン
/root/work/race/lane.mjsからcreateLane()をexportしてください。返すオブジェクトのrun(task)は、task()の結果を待ち、その間により新しいrunが始まっていたら{applied: false}を、そうでなければ{applied: true, value}を返します。
mkdir -p /root/work/raceを実行します。レーンの中に増加する番号を1つ持ち、runが始まるときに自分の番号を覚えておいて、awaitのあとで現在の番号と比べます。拡張子が.mjsなので、package.jsonは不要です。
キャンセルシグナル
run(task)がtask(signal)でAbortSignalを渡すようにしてください。新しいrunが始まったら、前のrunに渡していたシグナルがaborted === trueになり、いちばん新しいrunのシグナルは切られていない必要があります。
new AbortController()をrunごとに作り、レーンが最後のものを保持すれば足ります。結果を捨てること(ステップ1)と処理を止めることは別のことです。番号だけでは、ネットワークとサーバーは働き続けます。
キーごとの重複排除
createDedupe()を追加でexportしてください。run(key, task)は、同じkeyの作業が進行中なら、taskを再び呼ばずに同じ約束(Promise)を返す必要があります。終わったあとで再び呼んだら、新しく始めます。
進行中の約束をMapにキーごとに入れておき、終わったら(成功でも失敗でも)消します。finallyがその場所です。失敗した約束を残しておくと、次の呼び出しが永遠に同じ失敗を受け取ります。
ロールバックは引き算ではない
createOptimistic(base)をexportしてください。begin(patch)は待機中の更新を1つ追加してチケットを返し、rollback(표)はその更新だけを取り除いて、残りの更新をベース値の上に重ね直し、状態を計算する必要があります(プレースホルダーはbeginが返すチケットです)。getState()がその結果です。
逆演算で戻さないでください。ベース値と待機リストを別々に持ち、状態を問われるたびにpending.reduce((s, p) => p(s), base)で計算すれば、順序のある更新でも正しくなります。ラベルをAに変える更新とBに変える更新が一緒に待機中のとき、前のほうをロールバックするとラベルはBでなければなりません。
サーバーのレスポンスがベース値を差し替える
createOptimisticにsettle(표, 서버상태)を追加してください(プレースホルダーはbeginが返すチケットとサーバーの状態です)。その更新を待機リストから取り除き、ベース値をサーバーが返した状態に差し替えたあとで、まだ残っている待機中の更新をその上に順番に重ね直す必要があります。
settleがrollbackと違うのは1点だけで、ベース値も変えるということです。まだ答えが来ていない別の楽観的更新が、このときに消えてはいけません。採点ツールは、待機中の2つのうち片方にだけサーバーのレスポンスが来たときを確認します。
古さと破棄は別の物差し
createCache()をexportしてください。set(key, value, now)で入れ、get(key, now, staleMs)は{value, stale}を返します。入れてからstaleMs以上経っていたらstaleが真でなければならず、存在しないキーにはundefinedを返します。
値だけを入れず、入れた時刻も一緒に入れないと、古さを問い合わせられません。境界は「以上」です。ちょうどstaleMsが経った瞬間も古いものとして扱います。古いからといって値を捨てないでください。見ているものを見せながら、裏で新しく取得するのが要点です。
自分で測って書く
次のシナリオを自分で実行して得た数値を、/root/work/race/07-measure.txtに이름=값の形式で3行書いてください(プレースホルダーは名前と値です)。dedupe_callsは、同じキーで同時に5回runしたとき、taskが実際に実行された回数です。latest_applied・latest_discardedは、1つのレーンで5回連続してrunしたとき、appliedが真だった回数と偽だった回数です。
短い.mjsを1つ作って実行し、出てきた値を書き写せば足ります。採点ツールが同じシナリオを皆さんのlane.mjsで再実行して書かれた数値と照合するので、目分量の値では通りません。まず3行の合計が合っているかを確認してください。
何が順序を守ってくれたのか
/root/work/race/08-notes.mdに3行以上書いてください。最新優先がないと画面に何が残るのか、キャンセルがなぜ番号の比較とは別なのか、ロールバックを引き算で組むと何が一緒に消えるのかを書きます。
本文に최신・취소・되돌리기を含めてください(順に、韓国語で「最新」「キャンセル」「ロールバック」を意味する語です)。この3つは、TanStack Query・SWR・RTK Queryがそれぞれ違う形をしていても、同じように解いている問題です。