コンテンツにスキップ

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 を 理解する鍵になります。

await client.mutate("markDone", { id: "t1" }) のタイムライン。

  1. outbox エントリをエンキューする。 新しい単調増加の mutationID を持つレコードが、[clientID, mutationID] を キーとして IDB ストア _plasma_outbox に入ります。
  2. optimistic エンジンに対して mutator を呼び出す。 db.update(todos).set({...}).where(...) がユーザーに見える ストアに対して実行されます。IDB の put は即座に反映されます。
  3. reactive hub を発火する。 ソースに todos を含む live query が通知されます。React は次の tick で再レンダリングします。
  4. Promise を返す。 呼び出し側は mutate() が解決したのを 見ます。

ステップ 1〜4 はすべて同じ async tick の中で起きます。典型的には 10ms を十分に下回ります。ユーザーはすでに、自分のアクションが UI に反映されたのを見ています。

mutate() の呼び出し箇所とは独立に、次が進みます。

  1. push ループ が outbox エントリを拾い上げ、push エンベロープ に梱包して /sync/push へ POST します。Worker の sync ハンドラ が D1 に対して mutator を canonical に実行します。
  2. pull ループ がポーリングし(または WebSocket の poke で 起きて)/sync/pull を取得します。サーバーは client の causal cookie 以降の change log を返します。
  3. applyPatchToBase が届いた変更を base ストアに書き込み ます。
  4. rebuildOptimistic がユーザーに見えるストアを消去し、 (更新されたばかりの)base ストアから詰め直し、残っている outbox エントリをすべて上から再生します。

sync ラウンドが成功した末尾では、ユーザーに見えるストアは、 ユーザーがすでに見ていたものと同一に見えます。ただし今や base ストアがそれに同意しています。

サーバーにも todos.t1 に対する変更があったとします(別の デバイスから、あるいはスケジュールジョブから)。push の後:

  • サーバーはあなたの markDone mutator を canonical に実行し、 並行する書き込みを見て、テーブルのルールに従って調停しました (デフォルトでは last-write wins、CRDT カラムはそれぞれのマージ、 宣言されていれば明示的な resolveConflict)。
  • それに続く pull が、調停された行を持ち帰ります。
  • rebuildOptimistic は調停された base の上にあなたの outbox を 再生します。しかしこの時点で、サーバーはすでにあなたの mutation を確認しているため、outbox エントリは再生されず dropConfirmed によって落とされます。

ユーザーに見えるストアは、あなたのローカルビューから調停された ビューへ遷移します。React は 1 回だけ再レンダリングします。見える フラッシュはありません。plasma は rebuildOptimistic の間 live query の通知を一時停止し、再開時に 1 回のバッチ通知を発火する からです。

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 は元に戻ります。

outbox は「この mutation は起きたが、まだ確認されていない」という 唯一 の記録です。2 つの不変条件があります。

  1. すべての mutation は、optimistic 適用の前に outbox へちょうど 1 回だけ書き込む。 ステップ 1 とステップ 2 の間でブラウザが クラッシュしても、次回の起動時に rebuildOptimistic の間に mutator が再度呼び出されます — 同じ結果になります。
  2. outbox エントリが落とされるのは、サーバーが確認したとき (lastMutationIDs が「この ID は見た」と言う)か、呼び出し側が 明示的に client.discardMutation(id) を呼んだときだけです。

outbox はタブごと(clientID の複合キー)なので、保留中の mutation を抱えたまま閉じたタブはそれらを失います。タブをまたぐ 耐久性が必要なら、clientGroupID を共有し、片方のタブが開かれる 前にもう片方が push できるようにしてください。