Ghost Cursor Eviction in Online Classrooms — Realtime Contracts for Disconnect Recovery
本文为原文前 6,000 字符的节选翻译,完整内容请查看原文。
Short answer: for realtime stale user cleanup, put each online-classroom user behind an expiring data-contract lease, then make cleanup and fan-out idempotent with a server revision. This keeps a vanished browser from becoming a permanent ghost while preserving the separate authorization record that protects actual edits.
简短回答:为了清理实时过期的用户,应为在线课堂的每位用户设置一个带有过期时间的数据契约租约,并通过服务器版本号使清理和分发操作具备幂等性。这样既能防止离线浏览器变成永久的“幽灵”,又能保留保护实际编辑内容的独立授权记录。
The failure is easy to reproduce. A student closes a laptop lid during a collaborative lesson; the tab never sends its final message, but the teacher still sees that student’s cursor. A later reconnect can race the cleanup worker. If the old cleanup wins, a live session disappears. If cleanup trusts every disconnect callback, duplicate deliveries turn a transient network event into a false roster change.
这种故障很容易重现。学生在协作课程中合上笔记本电脑盖;标签页没能发送最后一条消息,但老师仍能看到该学生的鼠标光标。随后的重新连接可能会与清理进程产生竞争。如果旧的清理任务获胜,活跃会话就会消失。如果清理进程盲目信任每一个断开连接的回调,重复的交付就会将短暂的网络事件变成错误的名单变更。
I run cron and queue infrastructure in production, so missed jobs and duplicate deliveries are the first things I look for. The invariant is straightforward: an older event may be delivered late or twice, but it must not move the room backwards. Keep it boring.
我在生产环境中运行定时任务和队列基础设施,因此任务丢失和重复交付是我首先排查的问题。其不变性原则很简单:旧事件可以延迟或重复交付,但绝不能让房间状态倒退。保持方案简单平庸即可。
What should realtime stale user cleanup contracts contain for an online classroom? Use a lease envelope with an identity, a session, an expiry, and a monotonic revision. The event ID is stable across retries. A cursor packet is ephemeral; the document edit log and membership authorization are durable streams with different guarantees.
在线课堂的实时过期用户清理契约应包含什么?应使用一个包含身份、会话、过期时间和单调递增版本号的租约信封。事件 ID 在重试过程中保持稳定。光标数据包是瞬时的;而文档编辑日志和成员授权是具有不同保证的持久流。
package presence
import "time"
type Lease struct {
UserID string `json:"user_id"`
SessionID string `json:"session_id"`
RoomID string `json:"room_id"`
Revision uint64 `json:"revision"`
ExpiresAt time.Time `json:"expires_at"`
}
type Event struct {
EventID string `json:"event_id"`
Kind string `json:"kind"` // lease.renewed or lease.expired
Lease Lease `json:"lease"`
}
The server evaluates now >= ExpiresAt using its own clock. A renewal is accepted only for an authorized session and only when its revision is newer than the stored value. A client timestamp can help diagnostics, but it cannot decide liveness. Renew at a fraction of the lease duration and add jitter; otherwise thirty browsers can wake up together and create an avoidable fan-out spike.
服务器使用自己的时钟评估 now >= ExpiresAt。只有经过授权的会话,且版本号大于存储值时,续期请求才会被接受。客户端时间戳有助于诊断,但不能决定活跃状态。应在租约周期的某个比例点进行续期并添加抖动(jitter);否则,三十个浏览器可能会同时唤醒,造成不必要的分发峰值。
The exact interval depends on device and network behavior, and your mileage may vary. The reason is part of the contract. lease.expired, membership.revoked, and session.closed can all hide a cursor, but they have different reconnect and audit consequences. Log the room, session, event ID, prior revision, new revision, and decision (accepted, ignored_old_revision, or already_applied). A request ID linking renewal to expiry makes a postmortem much shorter.
具体的时间间隔取决于设备和网络行为,实际效果可能因人而异。原因也是契约的一部分。lease.expired、membership.revoked 和 session.closed 都可以隐藏光标,但它们在重新连接和审计方面有不同的后果。记录房间、会话、事件 ID、旧版本号、新版本号以及决策结果(已接受、忽略旧版本或已应用)。将续期与过期关联的请求 ID 可以大大缩短事后分析的时间。
How does cleanup survive fan-out races and duplicate delivery? The worker should claim an expired lease with a compare-and-delete operation. Only the worker that deletes the exact stored revision publishes the expiry. A retry after a process restart sees no matching lease and emits nothing.
清理进程如何应对分发竞争和重复交付?工作进程应通过“比较并删除”操作来认领过期租约。只有成功删除存储版本号的工作进程才能发布过期事件。进程重启后的重试操作会发现没有匹配的租约,因此不会发出任何消息。
package cleanup
import (
"context"
"time"
)
type Lease struct {
UserID string
SessionID string
RoomID string
Revision uint64
ExpiresAt time.Time
}
type Store interface {
FindExpired(context.Context, time.Time) ([]Lease, error)
DeleteIfRevision(context.Context, string, string, string, uint64) (bool, error)
}
type Publisher interface {
Publish(context.Context, Lease) error
}
func Expire(ctx context.Context, store Store, publisher Publisher, now time.Time) error {
leases, err := store.FindExpired(ctx, now)
if err != nil { return err }
for _, lease := range leases {
deleted, err := store.DeleteIfRevision(ctx, lease.RoomID, lease.UserID, lease.SessionID, lease.Revision)
if err != nil { return err }
if !deleted { continue } // A newer renewal won the race.
if err := publisher.Publish(ctx, lease); err != nil { return err }
}
return nil
}
The important line is the conditional delete, not the scheduler. Test the interleaving directly: scan an expired lease, renew it, then let cleanup attempt the delete. The delete must return false. Deliver one expiry twice to a subscriber and assert that the cursor is hidden once while the membership record remains.
关键的一行代码是条件删除,而不是调度器。直接测试交错执行:扫描一个过期租约,续期它,然后让清理进程尝试删除。删除操作必须返回 false。向订阅者交付两次过期事件,并断言光标只被隐藏一次,同时成员记录保持不变。
That race deserves more than a unit test that calls methods in their happy order. In a contract test, freeze the server clock, insert revision 41 with an expiry in the past, and have the worker read it. Before the worker deletes anything, issue a renewal for revision 42 from the same session. The worker’s compare must fail because the stored revision changed.
这种竞争情况需要的不仅仅是按顺序调用方法的单元测试。在契约测试中,冻结服务器时钟,插入一个版本号为 41 且已过期的租约,并让工作进程读取它。在工作进程删除之前,从同一会话发出版本号 42 的续期请求。工作进程的比较操作必须失败,因为存储的版本号已经改变。
Now repeat the run with two workers reading revision 43 at the same time; exactly one conditional delete can succeed, and the publisher should receive one expiry. Finally, replay that expiry after a reconnect. The subscriber records the event ID, ignores the second copy, and keeps its room revision monotonic. Those checks exercise the actual invariant: stale work is harmless.
现在重复运行,让两个工作进程同时读取版本号 43;只有一个条件删除能成功,发布者应该只收到一个过期事件。最后,在重新连接后重放该过期事件。订阅者记录事件 ID,忽略第二个副本,并保持房间版本号的单调性。这些检查验证了实际的不变性:过期的工作是无害的。
They also expose accidental coupling between a queue retry policy and presence semantics, which is the kind of coupling that turns a small browser disconnect into a long incident. Which reconnect and delivery guarantees fit cursor fan-out? On reconnect, send the session ID and last applied room revision. Return either events after that revision or a snapshot with a new revision.
它们还暴露了队列重试策略与在线状态语义之间意外的耦合,这种耦合往往会将一次小小的浏览器断开连接变成长时间的事故。什么样的重连和交付保证适合光标分发?重连时,发送会话 ID 和最后应用的房间版本号。返回该版本号之后的事件,或者返回带有新版本号的快照。
Apply the snapshot first, replay in order, and only then render new cursors. Keep a bounded buffer for packets received during reconciliation; an unbounded queue converts a bad connection into a memory leak. Managed fan-out can reduce connection operations. An application-owned relay gives tighter control over replay, ordering, and backpressure.
先应用快照,按顺序重放,然后才渲染新的光标。为协调期间接收的数据包保留一个有界缓冲区;无界队列会将糟糕的网络连接变成内存泄漏。托管分发可以减少连接操作。由应用程序控制的中继可以更好地控制重放、排序和背压。
Both still need the same contract. Choose the managed shape when its reconnect behavior meets the classroom objective; own the relay when audit or ordering requirements justify another on-call surface. Cost is a constraint, not a delivery guarantee. WebRTC connection state is useful telemetry, but it does not prove that an application lease is fresh. The W3C recommendation defines peer-connection state; your presence service still needs its own clock and revision. A WebSocket close event has the same limitation: it is a signal.
两者都需要相同的契约。当托管方案的重连行为符合课堂目标时选择它;当审计或排序需求证明有必要增加运维负担时,则自行构建中继。成本是一种约束,而不是交付保证。WebRTC 连接状态是有用的遥测数据,但它不能证明应用程序租约是新鲜的。W3C 建议定义了对等连接状态;你的在线状态服务仍然需要自己的时钟和版本号。WebSocket 关闭事件也有同样的局限性:它仅仅是一个信号。