コンテンツにスキップ

Conflict Resolution

plasma におけるほとんどの並行編集シナリオは特別な処理を必要としません: sync ループが両方の mutation を push し、サーバーがそれらを _plasma_changes に直列化し、クライアントがその上に rebase します。「conflict」は知覚できません。

このページは、知覚できないわけではないケースについてです。

デフォルト: row_version による last-write-wins

Section titled “デフォルト: row_version による last-write-wins”

2 つのクライアントが同じ行を並行更新すると、plasma はサーバー上でそれらの push を直列化します。各 mutator は現在の行に対して実行され、新しい行を生成します。サーバー側のトランザクションは一度に 1 つの書き込みをコミットする ため、2 番目の書き込みは 1 番目の行を見て、そこから進みます。

ほとんどのフィールドではこれで問題ありません: サーバーに 2 番目に到達した mutation の title / body / updatedAt が表示されます。

微妙なケース: 書いた mutator が、先の更新後にはもはや成立しない仮定をしているかもしれません。例:

markUrgentIfTitleContainsUrgent: async ({ db, args }) => {
const rows = await db.select().from(todos).where(eq(todos.id, args.id))
if (rows[0]?.title.toLowerCase().includes("urgent")) {
await db.update(todos).set({ priority: 1 }).where(eq(todos.id, args.id))
}
}

クライアント A が title を “Buy milk” から “Buy milk URGENT” に編集します。この mutator は(クライアント上で)新しい title を見て、priority を 1 に設定します。

一方でクライアント B が title から “URGENT” を削除しました。サーバーの順序: B の title 編集が先に着地し、次に A の mutator が実行される — “Buy milk”(“urgent” なし)を見て、priority を設定しません。サーバーではクライアントの optimistic apply と異なる結果になります。

クライアントの optimistic ビューは今やサーバーと食い違います。次の pull で、rebuildOptimistic は A の mutator をベースストア(B の title 編集を持つ)に対して再実行します — そして A の mutator は今度は正しく “urgent” なしを見て、priority を設定しません。optimistic ビューが取り消されます。

これが設計です。 mutator は両側で同じロジックで実行され、最終状態は収束します。ただし UI は取り消される前に一瞬 priority = 1 を表示しました。

デフォルトで十分なのは:

  • 並行編集が同じ行の異なる column を触るとき。
  • 並行編集が同じ column を同じ値に設定するとき。
  • mutator のロジックがどんな状態に対してもクリーンに再実行できるとき。

resolveConflict や CRDT column にエスカレートするのは:

  • 2 つの並行編集が両方とも同じ column を意味的に変更し、どちらも失われるべきでないとき。(「A はノート本文に ‘meeting @ 3pm’ を追加し、B は同じ本文に ‘call John’ を追加する」)
  • 意図されるセマンティクスが加算的なとき。(「両方のクリックがカウントされるべき; カウンターは 1 ではなく 2 になるべき」)
  • 意図されるセマンティクスが集合的なとき。(「A が like; B が like; reactions は両方を含むべき」)

エスカレーション 1 — CRDT column

Section titled “エスカレーション 1 — CRDT column”

カウンター、レジスター、集合には、CRDT columns が正しい答えです:

  • 増えるだけのカウンター: crdtCounter
  • 両方向に振れるカウンター: crdtPnCounter
  • last-writer-wins セマンティクスだが予測可能なタイブレークを持つレジスター: crdtLwwRegister
  • 並行 add/remove セマンティクスを持つ集合: crdtOrSet

サーバーがこれらを自動マージします。resolveConflict を宣言する必要はありません。

エスカレーション 2 — resolveConflict

Section titled “エスカレーション 2 — resolveConflict”

カスタムマージには — 通常はフィールドがカウンター/集合ではないが並行セマンティクスに注意が必要なため — TableOptions.resolveConflict に宣言します:

const todos = table("todos", {
id: id(),
title: text(),
done: int().default(0),
updatedAt: int(),
}, {
resolveConflict: ({ server, client }) => ({
...server,
...client,
// "done" フラグを union: 誰かが done にしたら、done のまま。
done: Math.max(server.done ?? 0, client.done ?? 0),
// 最新の updatedAt を保持。
updatedAt: Math.max(server.updatedAt ?? 0, client.updatedAt ?? 0),
}),
})

resolveConflict は、mutator を実行しただけで得られる行(client)と現在のサーバー行(server)を受け取ります。実際にコミットされるべき調整済みの行を返してください。

  • サーバー上で、現在の行がすでに存在する insert / update 時(sql-engine.tsneedsExistingLookup がフェッチをトリガーします)。
  • クライアント上では実行されません。クライアントの optimistic apply が client 値を生成します。サーバーがそれを異なる形でマージすると、pull が調整済みの行を戻し、rebuildOptimistic が mutator をその上で再実行します。

resolveConflict に入れてはいけないもの

Section titled “resolveConflict に入れてはいけないもの”
  • 非同期処理。 述語はサーバー側のトランザクション内で実行されます。fetch なし、外部呼び出しなし。
  • ctx 依存のロジック。 resolveConflictserverclient のみを受け取ります。どちらの行がどの呼び出し側の ctx で生成されたかにはアクセスできません。ctx が必要なら、代わりに mutator 内でマージしてください。
  • CRDT 形状のロジック。 resolveConflict でカウンターセマンティクスを実装しているなら、代わりに crdtCounter を使ってください。

エスカレーション 3 — アプリケーションレベルの事前チェック

Section titled “エスカレーション 3 — アプリケーションレベルの事前チェック”

一部の conflict はマージレイヤーでは解決できません — セマンティクスが「2 番目のクリックはエラーになるべき」です。予約システム:

mutators.ts
book: async ({ db, args }) => {
const rows = await db.select().from(slots).where(eq(slots.id, args.slotId))
if (rows[0]?.bookedBy) {
throw new SlotAlreadyBookedError(rows[0].bookedBy)
}
await db.update(slots).set({ bookedBy: args.userId }).where(eq(slots.id, args.slotId))
}

2 つのクライアントが book を並行呼び出しします。クライアント上では両方がスロットを空きと見て両方が mutate を呼びます — 両方が optimistic な予約を得ます。サーバー上では push が直列化されます:

  • 最初の push が book を実行し、bookedBy を設定し、トランザクションがコミットします。
  • 2 番目の push が book を実行し、SlotAlreadyBookedError を throw し、トランザクションがロールバックし、last_mutation_id が進み(汚染が破棄される)、processed[]mutation-error を報告します。

2 番目のクライアントの onError が発火し、ローカルの optimistic 予約が次の rebuildOptimistic で取り消されます。

UX が重要なときのクライアント側の事前チェック

Section titled “UX が重要なときのクライアント側の事前チェック”

可視フラッシュを許容できない場合、mutate を呼ぶ前に React コンポーネントで現在の状態をチェックします:

function BookButton({ slot, currentUserId }) {
const rows = useLiveQuery(
() => plasma.db.select().from(slots).where(eq(slots.id, slot.id)),
[slot.id],
)
const isBooked = !!rows[0]?.bookedBy
return (
<button
disabled={isBooked}
onClick={() => book.mutate({ slotId: slot.id, userId: currentUserId })}
>
{isBooked ? "Booked" : "Book"}
</button>
)
}

ボタンは、ローカルの optimistic ビューが行を予約済みと見た瞬間に無効化されます。クリックとローカルの optimistic apply の間の競合状態は事実上不可能です。

複数の conflict メカニズムが関連するとき、それらは組み合わさります:

  1. CRDT columnmergeCrdtColumns で最初にマージします。
  2. resolveConflict がマージ済みの行に対して実行されます。
  3. サーバー側の mutator ロジック(不変条件違反での throw)が解決済みの行に対して実行されます。
  4. クライアント側の事前チェック(React 内)が、ローカルビューがすでに失敗状態を示しているときに mutation の発火を防ぎます。