CRUMB a card from devarno-cloud

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:

ChannelBufferCarries
document_channels[doc]1024operations, sync, snapshots
presence_channels[doc]256join, 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 Binary

The 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::presence containing offset/selection metadata; carried inside PresenceMessage::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