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.
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. 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.
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.
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. 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. 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. 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, not an authoritative attendance decision.
Where is this lease model the wrong tool?
The catch is legal attendance. Lease expiry is evidence that a client stopped renewing, not proof that a person left class. Keep an append-only attendance record and an explicit sign-out action when that distinction matters. Stick with a local heartbeat when a room is tiny, connectivity is controlled, and losing a cursor has no operational cost; revisioned cleanup adds machinery there.
Use this small decision matrix during design review:
| Situation | Contract decision | Why |
|---|---|---|
| Duplicate expiry | Deduplicate by event ID and revision | A retry cannot hide a cursor twice |
| Renewal during cleanup | Conditional delete on the stored revision | A live session wins the race |
| Reconnect after a gap | Snapshot, then ordered replay | The client regains an authoritative state |
| Attendance or compliance | Separate append-only record | Liveness is not proof of presence |
Before release, run a three-session drill: one teacher, one student renewing, and one student going offline. Expire one lease, duplicate its event, force a revision gap, and revoke membership while a cursor packet is in flight. The expected result is precise: one cursor vanishes, the renewed session stays, revoked users cannot edit, and a reconnect converges on one snapshot. If the team cannot state that result in a few sentences, the contract is not ready. The same drill belongs in deployment checks after changing the relay, queue, or browser client, because delivery guarantees are behavior, not a checkbox in an architecture diagram.
Top comments (0)