世界を動かす技術を、日本語で。

CRDTやOTを使わない共同テキスト編集

概要

  • 本記事は、 CRDTOT なしで実現するシンプルな 協調テキスト編集手法 を紹介
  • 各文字に グローバル一意ID を付与し、「insert after」操作で編集を管理する提案
  • サーバ・クライアント間の 楽観的ローカル更新リコンシリエーション 手法も説明
  • 既存のCRDT/OTライブラリの 複雑さ・拡張性の課題 を回避可能
  • 本手法は リスト編集全般 にも応用が可能

CRDT・OT不要のシンプルな協調テキスト編集アプローチ

背景と課題

  • 協調テキスト編集は 複数ユーザー による同時編集時の 操作競合 が最大の課題
  • 典型的なアプローチ(例:「index 17に挿入」)は 並行編集 で破綻しやすいことを確認
  • サーバは「 どの操作をどう解釈し、どのように適用するか」を明確にする必要があることを強調
  • この問題は Google Docs のようなリアルタイム編集だけでなく、一般的なリスト編集にも波及することを認識

既存手法の問題点

  • CRDT :各文字に不変なIDを割り当て、全体順序を数学的に定義するが、アルゴリズムが難解で実装が困難
  • OT :操作を並行編集に合わせて「変換」するが、変換則の組み合わせが膨大でバグも多い
  • これらの手法は ブラックボックス的なライブラリ 利用が前提となり、アプリ固有の拡張やカスタマイズが困難
  • 例:部分的なロード、サブドキュメント権限、Google Docs風の「提案」機能、柔軟なストレージ形式などが難しい

提案するシンプルな手法

  • 各文字に グローバル一意ID(例:UUID) を付与し、Array<{ id: ID; char: string }>型で管理することを提案

  • クライアントは「 既存IDの直後に挿入」という形で編集操作をサーバに送信することを推奨

  • 例:「f1bdb70aの後ろに'the'を挿入する」

  • サーバはこの「insert after」操作を そのまま解釈 し、該当IDの直後に新文字を挿入することで、競合を直感的に解決

  • 削除操作時は、IDを保持しつつisDeleted: trueとすることで、削除後も参照可能性を維持することを提案

    • 型定義例:Array<{ id: ID; char?: string; isDeleted: boolean }>
    • テキスト生成例:list.filter(elt => !elt.isDeleted).map(elt => elt.char).join('')
  • クライアントが文字を入力する際の流れ

    • 挿入位置直前のIDを特定すること
    • 新しいUUIDを生成すること
    • 「insert ${char} after ${before} with id ${id}」形式でサーバに送信すること
    • サーバ側は該当IDの直後に{ id, char, isDeleted: false }を挿入すること
  • 削除時の流れ

    • 削除対象文字のIDを特定すること
    • 「delete the entry with id ${id}」形式でサーバに送信すること
    • サーバは該当エントリのisDeletedtrueにすること

実用上の懸念と最適化

  • 文字ごとにUUIDを持つのは ストレージ効率が悪い ため、後述の最適化が必要であることを認識

クライアント側の楽観的ローカル更新とサーバリコンシリエーション

  • Google Docs風の即時反映 には「楽観的ローカル更新」が不可欠であることを確認

  • ローカルで未送信の操作群がある状態で、サーバから新たなリモート操作が届く場合の処理手順

    • 未送信のローカル操作を 一旦巻き戻す こと
    • 新しいリモート操作を適用すること
    • 未送信ローカル操作を再適用すること(リコンシリエーション)
  • この手法は CRDT不要 であり、提案手法と完全に両立することを強調

  • より簡易な戦略として「クライアントは常にサーバ応答待ちで新規操作を禁止する」手もあるが、実用性は低いことを指摘

応用範囲

  • 本手法は テキスト編集 だけでなく、 リスト編集一般 (レシピの材料リスト、スライド一覧、スプレッドシート行列など)にも適用可能であることを明示

このアプローチにより、従来のCRDT/OTに頼らず 柔軟で拡張性の高い協調編集アプリ を自作できることが期待される提案。

Hackerたちの意見

これに関しては専門家じゃないけど、AutomergeみたいなCRDTとの主な違いはサーバーの調整にあるみたいだね。例えばこの記事を見てみて [1]。Automergeは、同時挿入をシーケンス番号を使って処理して、挿入が同時のときはエージェントIDの合意された順序に頼ってるけど、この方式はサーバーが受け取った順番で処理することに依存してるんだ。この記事にはこう書いてあるよ:> これは、IDがかっこいいアルゴリズムによって自動的に順序付けられるテキスト編集用のCRDTとは対照的だね。その順序付けアルゴリズムが、いろんなテキスト編集用CRDTの違いを生んでいて、それがCRDTに関する論文での複雑な部分なんだ。私たちはそれを完全に回避できるけど、多くのアプリには中央サーバーがあるから「かっこいいアルゴリズム」を避けられるって考えには賛成だけど、サーバーの調整にはローカルの編集を元に戻したり再生したりする必要があるから、それが本当に簡単なのかは100%はっきりしないな。

同意する、元に戻したり再生したりするのは全然シンプルじゃないよね。Persistent B+Treeもあんまりシンプルじゃないし。

Automergeは内部的にすべての操作を最終的に一貫した全順序で保存していると思うんだけど、それをサーバーの代わりに使えるんだよね(参考: https://mattweidner.com/2025/05/21/text-without-crdts.html#d...)。でも、実際にはAutomergeはそうしてないんだ。代わりに、伝統的なCRDTであるRGAを使ってテキスト操作を処理してる。多分、君が指摘したように、操作を元に戻したり再生したりするのが実装するのが簡単じゃないからだと思う。

これについての書き込みが見れて嬉しい!数年前に同じ方法を発見して、なんで学術文献に出てこないのか不思議に思ってたんだ。私はこれを分散型の文脈でCRDTとして実装したから、その特性(可換性、冪等性、結合性)が保たれてるよ。

CRDTの代替を作るのが目的なら、CRDTとして構築することで何を得たの?

つまり、最適化されてないCRDTってこと?最大セットサイズを1にして、あとはやってみるって感じ?

これは一種の不可逆的な複雑さとして非常に魅力的だね。実際に起こっていることに近い感じがするし、シンプルだし、もし最適化されてないならなおさらハハハ。

この投稿の要点は、CRDTやOTの完全な複雑さは中央サーバーがない場合にのみ必要だってこと?

OTは中央集権的なサーバーが必要だね。

中央サーバーがなくても、最終的に操作を全順序で整理してその順序で適用する分散型の方法があれば、CRDT/OTの複雑さを避けることができるよ: https://mattweidner.com/2025/05/21/text-without-crdts.html#d... 他のコメントでも言われているように、これは技術的にはCRDTだね(完全に一般的なものだけど)。それに、操作を元に戻したり再生したりするのも実装が簡単じゃない。でも、これが各データ型に対して伝統的なCRDT/OTを使うよりはまだシンプルであることを願っているよ。

これで共同テキスト編集やその関連がついに解決するの?すごく素晴らしいアプローチだね。

それは可能かな?もし君と私が同時に人のリストを編集していて、君が年齢順に並べようとして、私はアルファベット順に並べているとしたら、結果を調整しなきゃいけないよね。

他のデータ構造、例えば辞書やマップ、任意の型の配列についての議論が全然ないのに驚いたよ。これらは簡単に拡張できるといいな。私の経験では、アプリは純粋な共同テキスト編集よりも、協調データ構造が必要なことが多いと思う。動機となる例(更新の検証、部分的な読み込み、高レベルの操作)は面白いけど、Yjsなどがこれらの機能を欠いている理由が根本的なCRDTの実装にあるとは思えないな。むしろ、これらの機能を構築するのが本質的に難しいからだと思う。

他のデータ構造、例えば辞書やマップ、任意の型の配列についての議論が全然ないのに驚いたよ。これらは簡単に拡張できると思うけど。私の経験では、アプリは純粋な共同テキスト編集よりも、協力的なデータ構造を必要とすることが多いよね。完全に同意するよ。「原子的」なオブジェクトの配列を作るのは、オブジェクトのプロパティを変更できないようにして、自分の型に置き換えればできると思う。オブジェクト内部の変更は多分もっと難しいけど、効率的にツリーを保存・走査することに関する問題かもね。あと、ヘルパーライブラリの利用者(OPの用語で言うと)自身の軽量な「セマンティックモデル」ロジックをフックできるものを作るのも可能だと思ってた。無効な状態を防ぐためにね。例えば、todoアイテムが同時にisDone: trueとstate: inProgressを持つことはできないから。リンクされた記事で言及されているリッチテキストフォーマットのセマンティクスに似てるよね。

サーバーの調整を使うと、クライアント側の調整が難しいんじゃないかと思う…サーバーの更新を受け取るときに、スムーズなエディタのUXをどうやって保つの?例えば、クライアントから送った文字を挿入するリクエストが失敗したら、リクエストを再試行するだけ?その間に更新が来たらどうするの?(編集: 彼らは「クライアント側」のセクションでこのケースを認めていて、提案としては巻き戻して再生すること、そして保留中のキューがフラッシュされるまでブロックするという簡単な提案がある)フロントエンドの観点から見ると、CRDTの方が全体的にシンプルになるような、あまり具体化されていないUI/UXのエッジケースが長く続く気がする。それに、NYCの地下鉄でカバーが不安定な中でエディタを使うのはどう感じるのかな?

ProseMirrorと新しいバージョンのCodeMirrorは、これに対してかなりエレガントな解決策を持っているよ:ドキュメントへの各修正は、ノードやテキストのアイデンティティではなくインデックスを追跡するステップとしてモデル化されていて、バッファされたステップを通じてマッピングできる「位置マップ」というデータ構造を使って、更新された位置を持つステップを作成し、それをドキュメントに適用するんだ。実際には、かなりうまく機能しているよ。もっと詳しい情報はこちら: https://marijnhaverbeke.nl/blog/collaborative-editing.html https://marijnhaverbeke.nl/blog/collaborative-editing-cm.htm...

それはすごく面白いね。アルゴリズムはこうだ:- 各テキスト文字にグローバルにユニークなID(例えばUUID)を付けて、時間を超えて一貫した方法で参照できるようにする - 常に変わる配列インデックスを使う代わりに。 - クライアントはサーバーに「挿入後」操作を送信して、既存のIDを参照する。サーバーはターゲットIDを調べて、新しい文字をその直後に挿入する。 - 削除は表示目的で文字を隠すけど、「挿入後」の位置のためにはまだ保持されている。これ、テキスト編集以外でも可能性があるかも。ゲームの世界の同期とか、ね。

これって本当に新しいの?中央プロセスを使って分散システムをシリアライズするのは常識的なことだと思うんだけど、最初はここから始まったんじゃなかったっけ?ネットワークの分断やCAP、その他の問題を考えなければならないまではね。それに、単一障害点もできちゃうし。ちょっと流し読みしたけど、パフォーマンスについては話されてた?

あなたが説明しているのはCRDTだよね?

これは文字通り退化したCRDTだね。中央サーバーでのタイブレークはGoogle Waveに戻る。

ctrl+a ctrl+x ctrl+v 頑張って!