Optimistic vs Canonical
client.mutate("markDone", { id: "t1" }) は Promise を返します。
その Promise は optimistic なビューが最新になった瞬間に解決
されます。サーバーが書き込みを確認したときではありません。
このページでは、plasma における「optimistic」の意味、canonical なビューとは何か、そしてこの 2 つがあなたのコードに中間状態を 見せることなくどう調停されるかを説明します。
ユーザーテーブルごとに 2 つのストア
Section titled “ユーザーテーブルごとに 2 つのストア”すべてのユーザーテーブルは、IDB の中で 2 つ のオブジェクト ストアとして存在します。
- base ストア (
_plasma_base_todos) — 各行について最後に 確認されたサーバー状態。ここに書き込むのは pull ループだけです。 - ユーザーに見えるストア (
todos) — base ストアに、未確認の すべての mutation を上から再生したもの。db.select().from(todos)が読むのはこちらです。
あなたが base ストアに触れることはありません。目にすることさえ ありません。しかしそれが存在すると知っておくことが、rebase を 理解する鍵になります。
mutate() が行うこと
Section titled “mutate() が行うこと”await client.mutate("markDone", { id: "t1" }) のタイムライン。
- outbox エントリをエンキューする。 新しい単調増加の
mutationIDを持つレコードが、[clientID, mutationID]を キーとして IDB ストア_plasma_outboxに入ります。 - optimistic エンジンに対して mutator を呼び出す。
db.update(todos).set({...}).where(...)がユーザーに見える ストアに対して実行されます。IDB の put は即座に反映されます。 - reactive hub を発火する。 ソースに
todosを含む live query が通知されます。React は次の tick で再レンダリングします。 - Promise を返す。 呼び出し側は
mutate()が解決したのを 見ます。
ステップ 1〜4 はすべて同じ async tick の中で起きます。典型的には 10ms を十分に下回ります。ユーザーはすでに、自分のアクションが UI に反映されたのを見ています。
sync ループが次に行うこと
Section titled “sync ループが次に行うこと”mutate() の呼び出し箇所とは独立に、次が進みます。
- push ループ が outbox エントリを拾い上げ、push エンベロープ
に梱包して
/sync/pushへ POST します。Worker の sync ハンドラ が D1 に対して mutator を canonical に実行します。 - pull ループ がポーリングし(または WebSocket の poke で
起きて)
/sync/pullを取得します。サーバーは client の causal cookie 以降の change log を返します。 applyPatchToBaseが届いた変更を base ストアに書き込み ます。rebuildOptimisticがユーザーに見えるストアを消去し、 (更新されたばかりの)base ストアから詰め直し、残っている outbox エントリをすべて上から再生します。
sync ラウンドが成功した末尾では、ユーザーに見えるストアは、 ユーザーがすでに見ていたものと同一に見えます。ただし今や base ストアがそれに同意しています。
サーバーが食い違うとき
Section titled “サーバーが食い違うとき”サーバーにも todos.t1 に対する変更があったとします(別の
デバイスから、あるいはスケジュールジョブから)。push の後:
- サーバーはあなたの
markDonemutator を canonical に実行し、 並行する書き込みを見て、テーブルのルールに従って調停しました (デフォルトでは last-write wins、CRDT カラムはそれぞれのマージ、 宣言されていれば明示的なresolveConflict)。 - それに続く pull が、調停された行を持ち帰ります。
rebuildOptimisticは調停された base の上にあなたの outbox を 再生します。しかしこの時点で、サーバーはすでにあなたの mutation を確認しているため、outbox エントリは再生されずdropConfirmedによって落とされます。
ユーザーに見えるストアは、あなたのローカルビューから調停された
ビューへ遷移します。React は 1 回だけ再レンダリングします。見える
フラッシュはありません。plasma は rebuildOptimistic の間 live
query の通知を一時停止し、再開時に 1 回のバッチ通知を発火する
からです。
サーバーが拒否するとき
Section titled “サーバーが拒否するとき”server 側の mutator がスローした場合 — PlasmaAuthorizationError、
制約違反、その他何であれ — outbox エントリは落とされ、あなたの
onError コールバックが発火します。
createPlasmaClient({ ..., onError: (err) => { if (err.kind === "push-http" && err.status >= 400 && err.status < 500) { toast.error(`could not save; server rejected the push`) } },})次の rebuildOptimistic では、ユーザーに見えるストアは失敗した
mutation をもはや反映しません。サーバーが last_mutation_id を
その先まで進め(poison mutation の drop)、client の outbox が次の
pull でそのエントリを落とすためです。UI は元に戻ります。
retry と outbox の契約
Section titled “retry と outbox の契約”outbox は「この mutation は起きたが、まだ確認されていない」という 唯一 の記録です。2 つの不変条件があります。
- すべての mutation は、optimistic 適用の前に outbox へちょうど
1 回だけ書き込む。 ステップ 1 とステップ 2 の間でブラウザが
クラッシュしても、次回の起動時に
rebuildOptimisticの間に mutator が再度呼び出されます — 同じ結果になります。 - outbox エントリが落とされるのは、サーバーが確認したとき
(
lastMutationIDsが「この ID は見た」と言う)か、呼び出し側が 明示的にclient.discardMutation(id)を呼んだときだけです。
outbox はタブごと(clientID の複合キー)なので、保留中の
mutation を抱えたまま閉じたタブはそれらを失います。タブをまたぐ
耐久性が必要なら、clientGroupID を共有し、片方のタブが開かれる
前にもう片方が push できるようにしてください。
次に読むもの
Section titled “次に読むもの”- Push, pull, rebase — sync ループの詳細
- Conflict resolution —
デフォルトの調停で足りないときに
resolveConflictを書く - CRDT columns — counter、register、set 形状の フィールドの自動収束