Presence Broadcast Channels
chronicle intermediate 4 min read
ELI5
Presence is the relay’s “who is here” wall: each document gets a tiny PA system. When you join, move your cursor, or leave, the relay pins a card on the wall and shouts the change. Everyone in the room hears it; people outside the room don’t.
Technical Deep Dive
Storage
AppState.presence is DashMap<Uuid, DashMap<String, UserPresence>> — outer keyed by document_id, inner by user_id (relay/src/services/state.rs:40). It is in-memory only; presence is not persisted.
Channels
Two tokio::sync::broadcast channels exist per document:
| Channel | Buffer | Carries |
|---|---|---|
document_channels[doc] | 1024 | operations, sync, snapshots |
presence_channels[doc] | 256 | join, leave, cursor updates |
A subscriber that lags more than buffer size receives RecvError::Lagged(n) and is implicitly dropped from the missed window — for presence this is acceptable because the next update overwrites the previous state.
Presence Service Operations
flowchart LR Join[join user_id, name, color] --> Store1[insert into presence map] Store1 --> Bcast1[PresenceMessage::Join → presence_channel.send] Cursor[update_cursor user_id, pos] --> Store2[mutate presence.cursor] Store2 --> Bcast2[PresenceMessage::CursorUpdate → presence_channel.send] HB[heartbeat user_id] --> Touch[set last_seen = now] Leave[leave user_id] --> Store3[remove from presence map] Store3 --> Bcast3[PresenceMessage::Leave → presence_channel.send]Delivery
sequenceDiagram autonumber participant Alice as Alice ws participant Bob as Bob ws participant Bus as presence_channel(doc) participant Pres as PresenceService Alice->>Bus: subscribe (presence_rx) Bob->>Bus: subscribe (presence_rx) Alice->>Pres: update_cursor(pos=42) Pres->>Bus: send(PresenceMessage::CursorUpdate) Bus-->>Alice: bytes Bus-->>Bob: bytes Note over Alice,Bob: send_task drops if SessionState gating fails Alice->>Alice: drop (own update) Bob->>Bob: forward to client as BinaryThe send task does not currently filter “your own message” — that de-dupe happens at the client (the user who originated the cursor update ignores echoes by user_id).
Heartbeat
HeartbeatMsg { document_id, timestamp } updates presence.last_seen only — it is not rebroadcast. Stale presence cleanup is a TODO; today, the only way an entry leaves the map is via leave_document or socket close handling.
Key Terms
- PresenceMessage → enum with
Join | Leave | CursorUpdate; serialized as JSON-bytes over the broadcast channel. - CursorPosition → struct on
models::presencecontaining offset/selection metadata; carried insidePresenceMessage::CursorUpdate. - lagged subscriber → tokio broadcast pattern: a slow consumer that misses messages gets a
Lagged(n)error rather than a panic; for presence this self-heals on the next update.
Q&A
Q: Will presence survive a relay restart? A: No. The presence DashMap is in-memory; clients re-emit join + cursor on reconnect.
Q: Why are document and presence on separate channels with different buffer sizes? A: Operations are critical correctness data (1024 buffer absorbs bursty edits without dropping); presence is best-effort UI state where dropping intermediate cursor positions is fine (256 is sufficient).
Q: How does the relay know a user disconnected without explicit leave?
A: The WebSocket close path in handlers/websocket.rs is the trigger; the handler calls into PresenceService::leave so the broadcast goes out. A pure heartbeat-timeout cleanup is not implemented.
Examples
Three users in one room: each update_cursor produces one broadcast frame fanning out to three subscribers. Buffer size 256 means a user’s tab freezing for ~256 cursor frames (≈ several seconds at typing rate) is tolerated; longer than that, they receive Lagged and the next normal update re-syncs them.
neighbors on the map
- WebSocket Session Lifecycle adding a new privileged WS handler
- Auth-First WebSocket Handshake debugging AUTH_REQUIRED errors on the relay