Real-time¶
Real-time in Tomoda runs on two per-pod WebSocket hubs that fan messages across pods via Redis pub/sub. Each hub owns the live TCP sockets of its connected clients; cross-pod fanout closes the loop so a sender on pod A reaches a receiver on pod B without ingress session affinity.
This page is the system-level view: topology, routing, what "real-time" means here. Both hubs live in the platform/ws bundle (backend/internal/platform/ws/session_hub.go, client_hub.go); the chat domain owns the upgrade handlers (backend/internal/services/chat/handler.go, client_hub_handler.go). For hub internals (Register/Broadcast channels, ping/pong, dedup) see backend/infrastructure/websocket.
Two hubs, one transport¶
| Hub | Endpoint | Scope | Used for |
|---|---|---|---|
SessionHub |
/ws/chats/:id |
one chat room | live messages, reactions, edits inside an open chat |
ClientHub |
/ws/client |
one user (all devices) | per-user push (notifications, list-staleness fixes, multi-device sync) |
Both hubs share the same Redis pub/sub primitive (session:{id} and client:{user_id} channel namespaces). Splitting by scope keeps routing predictable and lets ClientHub keep delivering when no chat session is open.
Events on /ws/client¶
| Event type | Direction | Payload | Producer |
|---|---|---|---|
notification:new |
server → client | full Notification row |
NotificationService.Notify (Inbox channel) |
notification:unread |
server → client | { unread_count } |
bumped after every Inbox write or MarkSeen |
notification:resolved |
server → client | { group_key, resolution } |
NotificationService.AcknowledgeByGroupKey after an inline action |
notification:transient |
server → client | { kind, target_*, payload } |
non-inbox kinds (chat) for live UI |
chat:new_message |
server → client | { chat_id, message_id, sender_id, content, timestamp, ... } |
ChatService.fanoutMessageToParticipants, excludes sender |
chat:mark_read |
server → client | { chat_id, last_read_at } |
ChatService.MarkRead (multi-device sync) |
friend:graph_changed |
server → client | { reason } |
FriendService after send/accept/reject |
event:updated |
server → client | { event_id, changed: [...] } |
EventService.UpdateEvent / CancelEvent on the allowlisted fields |
Push (APNs / FCM / Web Push) delivery shares the same emission path. NotificationService consults the per-kind channel mask and dispatches through PushDispatcher (no-op today; provider lands as a follow-up).
The inbox grouping window is hybrid: actionable kinds (friend.request_received, event.join_requested) keep merging into the same row until the user resolves it (the inline Accept / Decline / Approve / Reject path); passive kinds restart grouping once the user opens the panel and the inbox flips seen_at. See Backend → Notifications for the full rule.
Surfaces still not on WS¶
| Surface | Channel |
|---|---|
| Presence + location | Short HTTP requests to /api/v1/presence/* writing Redis keys with TTL |
| OS push delivery | PushDispatcher interface, currently a no-op |
Presence is high-frequency, idempotent, and survives reloads cleanly via Redis TTL keys. OS push is interface-isolated so the provider can drop in without touching callers.
Topology¶
┌─ Pod 1 ─────────────────┐ ┌─ Pod 2 ─────────────────┐
│ ┌─────────────────────┐ │ │ ┌─────────────────────┐ │
│ │ Client A (room E) │ │ │ │ Client B (room E) │ │
│ └──────────┬──────────┘ │ │ └──────────▲──────────┘ │
│ ▼ │ │ │ │
│ ┌──────────────┐ │ │ ┌──────────────┐ │
│ │ Hub (pod 1) │ │ │ │ Hub (pod 2) │ │
│ │ Room E │ │ │ │ Room E │ │
│ └──────┬───────┘ │ │ └──────▲───────┘ │
└───────────┼─────────────┘ └───────────┼─────────────┘
│ PUBLISH session:{E} │
└──────────────► Redis ───────────┘
(PSUBSCRIBE session:*)
Each pod runs a SessionHub that owns its local Room registry and a long-lived PSUBSCRIBE session:* goroutine (ClientHub runs the sibling client:* subscriber for the per-user bus). When a client on pod 1 sends a message, the hub does two things in order:
- Local fanout: push to every connected client in the matching room on pod 1.
- Remote publish:
PUBLISH session:{E}with an envelope tagged bypodID, so sibling pods can fan out to their own local clients.
Sibling pods drop messages whose origin podID matches their own (those were already delivered locally). The dedup contract is the only invariant required for correctness — there is no shared sequencing or global ordering across pods.
What "real-time" guarantees in Tomoda¶
| Property | Holds? |
|---|---|
| Per-room ordering on one pod | Yes — local fanout is sequential through the Hub's Run() loop. |
| Per-room ordering across pods | Best-effort — Redis pub/sub is ordered per-channel but cross-pod arrivals interleave. |
| Delivery to offline clients | No — pub/sub is at-most-once. The chat message is persisted by ChatService.SendMessage before fanout, so reconnecting clients recover history via REST. |
| Cross-pod presence count | No — Hub.GetOnlineCount is per-pod. Use the chat:online:* Redis keys for event-wide counts. |
| WS auth = REST auth | Yes — same JWT, same JWTAuth middleware, no separate WS token. |
Horizontal scaling¶
The Hub supports replicas > 1. No ingress session affinity is required: any client can connect to any pod and still receive messages from senders on other pods, because the Redis pub/sub fanout closes the loop.
What the Hub explicitly does not do today:
- Per-room sharding. Every pod's subscriber receives every event's messages, even ones it doesn't have local clients for. The cost is a cheap channel-name match per published message; cheap enough at current chat volume. If a single Redis instance ever becomes the bottleneck, the next step is to shard the channel namespace, not change the data model.
- Cross-pod presence aggregation. For event-wide presence numbers, use the
chat:online:*Redis keys thatChatServicealready maintains. Those are pod-independent.
See Technical Decisions for the rationale and the in-process-only history.
Where to read next¶
- Hub internals — Register/Broadcast channels, ping/pong constants, message-type enumeration, pod-ID dedup mechanics: Backend → WebSocket Hub.
- Frontend connection lifecycle, reconnect/backoff, WS URL resolution: Frontend → Real-time.
- Presence service (heartbeat, active location sharing): Backend → Presence.