【ITニュース解説】Realtime Channel and Message Queue Durability for Collaborative Cursor Notifications
2026年10月01日に「Dev.to」が公開したITニュース「Realtime Channel and Message Queue Durability for Collaborative Cursor Notifications」について初心者にもわかりやすく解説しています。
ITニュース概要
共同編集カーソル通知はリアルタイムチャンネルで即座に伝える。切断後の復旧には、メッセージキューなどで最新の状態を永続的に保存する必要がある。古い動きではなく、最新の有用なカーソル位置を正確に再現するため、更新にリビジョン番号を付与し、古い情報は破棄する仕組みが重要だ。
ITニュース解説
共同編集機能、例えば複数の人が同時にドキュメントや図面を編集する際、他の参加者のカーソルがリアルタイムで動く様子は、非常に自然でスムーズなユーザー体験を提供する。しかし、この「カーソルの即時通知」を実現することと、ユーザーが一時的にネットワークから切断された後に戻ってきたときに、他の参加者のカーソルが「正しい最新の位置」に表示されるように「状態を永続化」することの両立は、技術的に複雑な課題を伴う。
この課題に対処するには、リアルタイムチャンネルとメッセージキュー、または永続的なデータストアという、目的の異なる二つの技術を組み合わせる必要がある。リアルタイムチャンネルは、データを即座に多数の受信者に届けることに特化した仕組みである。これは、WebSocketsのような技術を利用して、サーバーとクライアントが常時接続状態を保ち、データが発生するとすぐに通知されるようにする。カーソルの動きのように、頻繁に更新され、かつ「今その動きを見ている人」にだけ届けば良い情報には最適だ。リアルタイムチャンネルは、誰かがカーソルを動かした瞬間に、その動きを見ている全員に低遅延で情報を拡散する。もし誰も見ていなければ、その動きに関する情報は消滅しても問題ないと設計されているため、即時性を最大限に高めることができる。この「揮発性」がリアルタイムチャンネルの重要な特性である。
一方で、メッセージキューや永続的なデータストアは、データの永続性を保証することに重点を置いている。これは、サーバーが一時的に停止したり、ユーザーのネットワーク接続が切れたりしても、データが失われることなく保存され、後で確実に回復できるようにするための仕組みだ。リアルタイムチャンネルが「今」を重視するのに対し、永続的なデータストアは「いつでも」を重視する。ユーザーがネットワークから切断され、数分後に再接続した場合、そのユーザーは他の共同作業者が今どこにカーソルを置いているかを知る必要がある。リアルタイムチャンネルだけでは、切断中に発生した情報は失われてしまうため、このような状況に対応できない。
したがって、共同編集におけるカーソル通知では、リアルタイムチャンネルを「高速パス」として即時更新を担わせ、メッセージキューや永続的なデータストアを「永続パス」として最新のカーソル位置の状態を記録し、切断後の回復を担わせるという使い分けが重要になる。
しかし、単にこの二つのパスを併用するだけでは、新たな問題が発生する。例えば、ユーザーが再接続した際に、永続パスから過去のカーソル移動履歴すべてを再生してしまうと、カーソルが数分前の位置から現在の位置まで高速で動き続けるという、不自然なユーザー体験を提供してしまう。共同編集で本当に必要なのは、最新の「有用な状態」、つまり「他の共同作業者が今どこにいるか」という情報だけだ。
さらに、データの一貫性も大きな課題となる。例えば、あるカーソル更新(リビジョン番号418)が永続パスに保存された直後に、それよりも古いカーソル更新(リビジョン番号417)がリアルタイムチャンネル経由で届くといった「順序の逆転」が発生することがある。クライアントが届いた順に情報を適用してしまうと、カーソルが瞬間的に過去の位置に戻ってしまうといった奇妙な挙動を引き起こす。また、ブラウザがスリープ状態から復帰した際に、他のユーザーがすでにカーソルを動かしているにもかかわらず、画面が古い情報のまま更新されないという問題も発生しうる。
これらの問題を解決するためには、より洗練されたプロトコル設計が必要となる。その鍵となるのが、「リビジョン番号」と「最新の状態のスナップショット」だ。具体的には、各カーソル更新データに、エディターとユーザーごとに単調に増加する「リビジョン番号」を付与する。そして、常に最新のカーソル状態だけを永続パスに保存する。ライブパス(リアルタイムチャンネル)でも、このリビジョン番号付きの最新の状態を送信する。
クライアントが接続または再接続する際には、まず永続パスから最新のカーソル状態(スナップショット)を読み込み、それをレンダリングして画面を最新の状態にする。その後、リアルタイムチャンネルを購読し、そこから届くリアルタイム更新を受け取る。ここで重要なのは、リアルタイムチャンネルから届いたイベントのリビジョン番号が、すでにレンダリングされたスナップショットのリビジョン番号より新しくない場合は、そのイベントを無視することだ。これにより、古い情報が適用されてしまうことを防ぎ、順序の逆転による問題も回避できる。
より堅牢なクライアントの接続処理は次のようになる。まず、リアルタイムチャンネルを購読し、そのチャンネルから届いたリアルタイムイベントを一時的にバッファに貯めておく。次に、永続パスから最新のスナップショットを読み込み、それを画面にレンダリングする。このスナップショットに含まれるリビジョン番号よりも新しいリビジョン番号を持つバッファ済みのイベントだけを順に適用していく。これにより、接続中に取りこぼした最新の情報も確実に反映できる。万が一、読み込んだスナップショットよりも古いリビジョン番号のイベントがバッファに含まれていたり、後からリアルタイムチャンネル経由で届いたりしても、それらは無視されるため、常に正しい状態を維持できる。
このアプローチは、TypeScriptで記述された簡単なコード例でモデル化できる。Cursorというデータ構造でカーソルの位置とリビジョン番号を定義し、CursorStoreインターフェースで永続化の操作(最新のカーソル情報を保存し、読み出す)、CursorChannelインターフェースでリアルタイム通信の操作(情報を発行し、購読する)を定義する。saveAndPublish関数は、カーソル情報を永続ストアに保存した後、リアルタイムチャンネルに発行する。このとき、永続ストアへの書き込みが「権威ある」情報となり、リアルタイムチャンネルはあくまで高速な伝達手段と位置づける。もしリアルタイムチャンネルでの送信が失敗しても、永続ストアにデータが残っていれば、後で回復が可能だ。
様々なリアルタイム通信やメッセージングのサービスが存在するが、それらを導入する際にもこのプロトコルの概念は非常に重要だ。例えば、Socket.IOは自己管理型のリアルタイムセッションに適しており、Pusher Channelsはマネージドなファンアウトとキャッシュ機能を提供する。Ablyは接続回復やメッセージ履歴を扱い、Apache Kafkaはリプレイ可能なストリームと永続的なレコードが特徴だ。しかし、これらのベンダーが提供する機能だけに頼り切るのではなく、カーソルの識別方法、リビジョン番号のルール、そして切断後のデータ復元(バックフィル)の挙動といった、アプリケーション固有のプロトコルを自分たちで明確に定義し、コードで管理することが重要となる。これらは製品の振る舞いを決定する本質的な部分だからだ。
システムを大規模に拡張する際には、さらにいくつかの最適化を考える必要がある。まず、カーソルの動きすべてを永続化するのではなく、動きを統合(coalesce)することだ。例えば、1秒間に60回更新されるカーソルをすべて永続化するのではなく、数秒に1回や、カーソルが停止した時、ツールを変更した時など、意味のあるタイミングでだけ永続化する。リアルタイムチャンネルでは高頻度で送信しつつ、永続パスでは更新頻度を落とすことで、永続ストアへの負荷を軽減できる。次に、エディターごとにカーソル情報を分離し、アクティブな共同作業者の数を制限することも重要だ。離れたユーザーのカーソル情報は、一定期間が経過したら削除するといったルールを設けることで、不要なデータを保持し続けることを避ける。最後に、システムのパフォーマンスを常に計測することだ。再接続回数、スナップショットの古さ、重複したリビジョンや拒否された古いリビジョン、そして再接続から完全なカーソルセットが表示されるまでの時間などを測定し、実際のユーザー体験に基づいて改善を続けることが不可欠だ。
結論として、共同編集におけるカーソル通知のように、即時性と永続性の両方が求められる機能では、リアルタイムチャンネルと永続パス(メッセージキューやデータストア)を戦略的に組み合わせることが成功の鍵となる。重要なのは、ただデータを永続化するのではなく、「最新の有用な状態」と、その順序を正しく管理するためのリビジョン番号のようなプロトコルをアプリケーションレベルで設計することである。これにより、ユーザーはスムーズなリアルタイム体験を享受しつつ、いかなる状況でも正確な情報を受け取ることが可能になる。