同期と信頼性¶
Taylis の設計でいちばん重視しているのは、メッセージを失わず、重複させず、どの端末でも同じ順番で表示する ことです。 このページでは、そのための仕組みを説明します。
原則¶
- サーバが正しい状態を持つ。アプリの画面は、サーバから受け取った内容を端末のデータベースに入れてから描きます。
- REST が本体、WebSocket は合図。WebSocket で受け取れる情報は、すべて REST でも取得できます。WebSocket の イベントが届かなくても、後から取り戻せます。
- プッシュ通知も合図。通知を受け取ったら、アプリはサーバと同期します。通知の中身を表示の根拠にはしません。
順番:チャンネルごとの連番(seq)¶
メッセージの並び順は、端末の時計ではなく、サーバがチャンネルごとに振る 連番(seq) で決めます。
- メッセージの作成・編集・削除、リアクションの追加と削除のたびに、そのチャンネルの seq を 1 つ進めます。
- seq は隙間なく 1, 2, 3, … と続きます(データベースの 1 つの更新で採番するので、コミットの順と seq の順が一致します)。
- seq を 1 つ進めるたびに、その seq を持つイベントがちょうど 1 つ作られます。アプリは届いたイベントの seq が 飛んでいれば、取りこぼしに気づけます。
WebSocket でイベントを受け取ったアプリは、自分の持っている seq(synced_seq)と比べて次のように扱います。
flowchart TD
E["イベントが届く(seq = n)"] --> Q{"n と synced_seq"}
Q -- "n = synced_seq + 1" --> OK["そのまま反映<br>synced_seq = n"]
Q -- "n > synced_seq + 1" --> GAP["取りこぼしあり<br>差分を取り寄せる"]
Q -- "n ≤ synced_seq" --> SKIP["反映済み<br>何もしない"]
重複しない送信(冪等性)¶
アプリはメッセージを送る前に、ランダムな ID(client_msg_id)を作ります。
- サーバは「送った人 + client_msg_id」を一意にしているので、通信が切れて同じメッセージを何度送り直しても、 サーバに保存されるのは 1 つだけです。
- 送信中のメッセージは、端末の送信待ちの列に保存されます。アプリを閉じても、次に開いたときに送り直します。
- 画面には送信中のメッセージをすぐに出し、サーバの応答(seq の付いた本物)が来たら置き換えます。
通知を失わない:トランザクショナル・アウトボックス¶
メッセージを保存するときは、同じ PostgreSQL のトランザクションの中で、
- メッセージを保存し、
- 「メッセージができた」というイベント(outbox)を保存してから、
コミットします。バックグラウンドの処理が outbox を読んで、WebSocket で配り、プッシュ通知を計画します。 メッセージのコミットの直後にサーバが落ちても、イベントはデータベースに残っているので、再起動の後に処理されます。 処理は何度実行しても結果が同じ(冪等)になるように作っています。この仕組みは PostgreSQL だけで動いていて、 Kafka や RabbitMQ は使っていません。
メッセージを送ってから、ほかの人の画面に出るまでの流れです。
sequenceDiagram
participant A as 送る人のアプリ
participant S as サーバ
participant DB as PostgreSQL
participant B as ほかの人のアプリ
A->>A: client_msg_id を作り<br>送信待ちの列に入れる
A->>S: 投稿(client_msg_id)
S->>DB: 1 つのトランザクションで<br>メッセージ(seq 101)と outbox を保存
S-->>A: 保存したメッセージ(seq 101)
Note over A,S: 応答が届かず送り直しても、<br>同じメッセージが返るだけ
S->>DB: outbox を読む(バックグラウンドの処理)
S-)B: WebSocket:message.created(seq 101)
S-)B: プッシュ通知(スマートフォン、合図だけ)
再接続:足りない分を取り寄せる¶
アプリはチャンネルごとに「この seq までは反映済み」というカーソルを覚えています。
- 起動したとき・WebSocket がつながり直したとき・スリープから戻ったとき・プッシュを受け取ったときに、 サーバにカーソルより後の変更を問い合わせ、足りない分を取り寄せます。
- 通信が一時的に切れた・アプリが止められた・端末を再起動した・プッシュが届かなかった、のどれでも同じ手順で回復します。
- 同じイベントを何度受け取っても、端末の状態は同じになります(新しい
updated_seqのときだけ上書き)。
たとえば、切断している間に 3 件の変更があった場合は次のようになります。
sequenceDiagram
participant B as アプリ
participant S as サーバ
Note over B: synced_seq = 100 のまま切断
Note over S: 切断の間に<br>101 投稿・102 リアクション・103 削除
B->>S: WebSocket に接続し直す
S-->>B: hello(ここから後の変更はイベントで届く)
B->>S: 全体の状態(bootstrap)
B->>S: since_seq=100 より後の変更
S-->>B: 変わったメッセージ(101 の投稿は 👍 付き、削除の印)
Note over B: synced_seq = 103
イベントを 1 つずつ再生するのではなく、「変わったメッセージの今の状態」をまとめて受け取ります。
既読の同期¶
既読は「メッセージ 1 件ごと」ではなく、「人 × チャンネルごとに、どこまで読んだか(last_read_seq)」の 1 行で持ちます。
- 未読の数は、チャンネルの seq と既読の位置から計算できます。
- 読んだときの既読の位置は前にしか進まないので、複数の端末で読んでも同じ値に収束します。パソコンで読んだ分は、スマートフォンでも 既読になります。位置を後ろに戻すのは「ここから未読にする」だけで、これもほかの端末に伝わります。
保証すること・しないこと¶
| 保証する | 保証しない |
|---|---|
| 同じチャンネルの中の順番は seq で一意に決まる | WebSocket のイベントが必ず届くこと(取り寄せで補います) |
| 切断の間の変更は、再接続の後にすべて回復できる | チャンネルをまたいだ順番 |
| 同じ送信を何度送り直しても、メッセージは 1 つ | 端末の時計とサーバの時計が合っていること |
| 既読の位置は複数の端末で同じ値に収束する |
詳しくは docs/SYNC_PROTOCOL.md と docs/DATA_MODEL.md の「ID とシーケンスの役割」を ご覧ください。