即時訊息排序:課堂重新連線與歷史補傳中的交付保證
Realtime Message Order: Delivery Guarantees Across Classroom Reconnects and Backfills
課堂即時訊息的排序保證只適用於明確界定的通道範圍,不能代表跨連線、分頁或歷史補傳查詢的全域時間順序。耐久事件應使用伺服器指派的遞增序號,並以接收端遊標補回事件;輸入提示等短暫狀態則應設有效期限。重新連線時先補傳至高水位標記,再按序合併緩衝中的即時事件,可避免重複及狀態倒退。
TL;DR: Message order describes the sequence a receiver is allowed to observe on one channel; delivery order is the sequence events actually reach each learner after delay, loss, retries, and reconnects. In plain terms, a realtime ordering guarantee has a boundary. For classroom typing indicators and read receipts, do not pretend transport arrival is a global timeline. Put a monotonic sequence on durable events, make ephemeral state expire, and backfill from a receiver-owned cursor before resuming the live stream.
That decision separates two very different promises. A typing indicator can be dropped because its value fades in seconds. A read receipt changes durable state and must survive reconnect. Treating both as ordinary messages creates the worst kind of classroom bug: the UI looks certain while its history is ambiguous.
Why don't realtime message order and delivery guarantees match?
A sender creates an event at one instant, but delivery is a path, not an instant. Two events can wait in different buffers, take different retry paths, or be observed on opposite sides of a reconnect. Even when a channel provides ordered delivery, that promise applies within that channel's defined scope. It does not manufacture one clock across several connections, tabs, processes, or a backfill query.
WebRTC exposes this distinction directly through the RTCDataChannel.ordered attribute. The specification says its default is true and defines it as whether the channel must preserve message order. That is useful, but narrow: an ordered data channel does not decide how an application merges stored events with a newly opened live channel.
Order is scoped.
The mismatch is best explained with three terms. Send order is the sequence in which a producer submits events. Transport order is what one connection promises. Applied order is the sequence the application accepts after validation and deduplication. The UI should reflect applied order.
Build the merge rule before the socket
Use a server-assigned sequence within a clearly named scope, such as one classroom conversation. The sequence is an ordering token, not a timestamp. Persist read receipts with that token. Keep typing state ephemeral and attach an expiry time so a delayed typing_started event cannot leave a learner shown as typing forever.
On reconnect, capture a high-water mark, fetch durable events after the client's last applied sequence through that mark, buffer newer live events, then drain the buffer in sequence order. This closes the handoff gap between history and live traffic. Duplicates are normal at that boundary, so event IDs must be idempotency keys.
Here is a small executable model. It deliberately accepts duplicate and shuffled durable input, while typing signals use freshness rather than sequence continuity.
from dataclasses import dataclass
from time import time
@dataclass(frozen=True)
class Event:
event_id: str
kind: str
sequence: int | None
expires_at: float | None = None
def reconcile(events: list[Event], last_sequence: int) -> tuple[list[Event], int]:
now = time()
seen: set[str] = set()
durable: list[Event] = []
ephemeral: list[Event] = []
for event in events:
if event.event_id in seen:
continue
seen.add(event.event_id)
if event.kind.startswith("typing_"):
if event.expires_at is not None and event.expires_at > now:
ephemeral.append(event)
continue
if event.sequence is not None and event.sequence > last_sequence:
durable.append(event)
durable.sort(key=lambda event: event.sequence)
applied = durable + ephemeral
next_sequence = max(
(event.sequence for event in durable if event.sequence is not None),
default=last_sequence,
)
return applied, next_sequence
events = [
Event("receipt-43", "message_read", 43),
Event("receipt-42", "message_read", 42),
Event("receipt-43", "message_read", 43),
Event("typing-a", "typing_started", None, time() + 3),
]
applied, cursor = reconcile(events, last_sequence=41)
print([event.event_id for event in applied], cursor)
The example has four inputs but only three accepted events. The duplicate receipt disappears, receipts 42 and 43 are applied in sequence, and the typing signal survives only while fresh. Short-lived state stays cheap in attention and storage; durable state earns the stronger recovery path.
What should the client do during reconnect?
Make recovery an explicit state, not an incidental socket callback. While recovering, the client can still render the last confirmed read position, but it should avoid advancing that position from an unmerged live event. Once backfill reaches the captured high-water mark and buffered events are drained, switch to live mode and persist the new cursor locally.
There is a sharp trade-off. Buffering live events during backfill adds memory and a little latency, yet it gives one deterministic merge point. Applying live events immediately makes the interface feel quicker, but an older receipt from backfill may then appear to move state backward unless every reducer rejects stale sequences. For read receipts, I would take the deterministic merge. For typing indicators, I would take freshness and loss tolerance.
Do not infer a read from a typing event. Do not infer either one from connection health. They answer different questions.
Keep those states apart.
Cursor scope matters too. A single global cursor forces unrelated classrooms to share an ordering domain and can turn one busy room into head-of-line pressure for another. A per-conversation cursor is easier to reason about, though reconnecting many open conversations requires several compact checkpoints. The correct boundary is the smallest stream whose events truly need a shared order.
Evaluate the guarantee, not the happy path
A notebook demo tends to send event 42, then 43, and celebrate when the screen shows that order. The production test should inject the cases the notebook skipped: deliver 43 before 42, repeat 43, let a typing event expire in transit, disconnect between the high-water read and subscription, and reconnect twice with the same cursor.
The assertions are more useful than a transport benchmark. A durable reducer must never move its cursor backward. Applying the same event ID twice must leave state unchanged. A stale typing indicator must not become visible. After backfill plus buffered live delivery, every durable event above the old cursor and at or below the high-water mark must have one application result.
This also keeps prompt and evaluation costs under control if an AI tutor consumes the event stream. Feed the tutor confirmed message and receipt state, not every typing pulse or every duplicate delivery. The model context then represents classroom history rather than transport noise.
Operate the boundary you designed
Instrument the cursor, not just connection uptime. Record the last applied sequence, backfill duration, buffered-event count, duplicate count, and expired-ephemeral count by stream. Alerting on a growing distance between the server high-water mark and the client cursor exposes recovery trouble that a green connected indicator cannot.
Deployment needs compatibility rules as well. Additive event fields should be tolerated by older consumers, and reducers should reject unknown state transitions without corrupting their cursor. Roll out producers after consumers understand the new event shape. If a schema change alters ordering semantics, give it a new event kind instead of silently changing the old contract.
The operational checklist is brief in prose: name the ordering scope, assign durable sequences at one authority, deduplicate by event ID, expire typing state, checkpoint the applied cursor, and test the live/backfill handoff under forced disconnects. Document which guarantees stop at the channel boundary. Then the interface can be honest about what is current, what is confirmed, and what is merely in flight. This is the practical difference between saying a transport is ordered and proving that an edtech interface recovers correctly: the former is one channel property, while the latter joins storage, cursors, reducers, expiry, deployment compatibility, and observability into one application guarantee.
Sources
來源:DEV Community · dev.to