Upgrade to Pro — share decks privately, control downloads, hide ads and more …

[SF Ruby] Presence ain't perfect

[SF Ruby] Presence ain't perfect

Exploring patterns & pitfalls of presence tracking in Ruby on Rails applications with Action Cable and AnyCable.

Vladimir Dementyev

February 11, 2025
Tweet

More Decks by Vladimir Dementyev

Other Decks in Programming

Transcript

  1. Real-Time Web Applications with Ruby on Rails Action Cable Hotwire

    AnyCable Patterns & Pitfalls Not Coming Soon
  2. Real-Time Web Applications with Ruby on Rails Not Coming Soon

    Patterns & Pitfalls Action Cable Hotwire AnyCable
  3. class OnlineChannel < ApplicationCable::Channel def subscribed stream_from "online" broadcast_to( "online",

    {user:, event: "join"} ) end def unsubscribed broadcast_to( "online", {user:, event: "leave"} ) end end app/channels/online_channel.rb online_channel.rb 1 2 3 4 5 7 8 9 10 11 12 13 14 15 16 17 18 19
  4. class OnlineChannel < ApplicationCable::Channel def subscribed stream_from "online" user.online_sessions.create!(session_id:) broadcast_to("online",

    {user:, event: "join"}) if user.online_sessions.size.one? end def unsubscribed user.online_sessions.where(session_id:) .delete_all broadcast_to("online", {user:, event: "leave"}) unless user.online_sessions.any? end end app/channels/online_channel.rb online_channel.rb 1 2 3 4 5 7 8 9 10 11 12 13 14 15 16 17 18 19 20
  5. class OnlineChannel < ApplicationCable::Channel def subscribed stream_from "online" user.online_sessions.create!(session_id:) broadcast_to("online",

    {user:, event: "join"}) if user.online_sessions.size.one? end def unsubscribed user.online_sessions.where(session_id:) .delete_all broadcast_to("online", {user:, event: "leave"}) unless user.online_sessions.any? end end app/channels/online_channel.rb online_channel.rb 1 2 3 4 5 7 8 9 10 11 12 13 14 15 16 17 18 19 20 Problem solved?
  6. Web Server Connection Client Socket WebSocket Channel Worker Pool HTTP

    CLOSE #disconnect #unsubscribed The route of #unsubscribed
  7. Web Server Connection Client Socket WebSocket Channel Worker Pool CLOSE

    #disconnect #unsubscribed The route of #unsubscribed: happy path
  8. Web Server Connection Client Socket WebSocket Channel Worker Pool WRITE

    ERROR #disconnect #unsubscribed The route of #unsubscribed: abnormal closure ping retry retry ~15min unless tcp_retries2 is tuned
  9. Web Server Connection Client Socket WebSocket Channel Worker Pool HTTP

    The route of #unsubscribed: server crash / forceful termination
  10. Presence patterns Channel subscription callbacks Expiration-based presence (#connected_at, Redis sorted

    sets) Application-level heartbeat (pong, #refresh) To much cere-meow-ny!
  11. class OnlineChannel < ApplicationCable::Channel def subscribed stream_from "online_users" join_presence( id:

    user.id, info: {name: user.username} ) end end app/channels/online_channel.rb online_channel.rb 1 2 3 4 5 7 8 9 10 11 12 Online indicators AnyCable Presence API
  12. import { Controller } from "@hotwired/stimulus" import cable from "cable"

    export default class extends Controller { static targets = ["user"]; async connect() { this.channel = cable.subscribeTo("OnlineChannel"); this.channel.on("presence", this.handlePresence); this.userTargets.forEach(this.userTargetConnected); } async userTargetConnected(el) { const presence = await this.channel.presence.info(); if (presence[userId]) { el.classList.add("online"); } } handlePresence(event) { this.userTargets.forEach(el => { if (el.dataset.id === event.id) { el.classList.toggle("online", event.type === "join"); } }); } } app/javascript/controllers/online_controller.js online_controller.js 1 2 3 4 5 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 Stimulus target callback AnyCable Presence API
  13. Pub/sub Engine Presence Engine AnyCable WebSocket Channel #join_presence AnyCable Presence

    Rails (AnyCable RPC) SUBSCRIBE join presence subscribe WebSocket WebSocket {type: "join", ...} {type: "join", ...}
  14. Pub/sub Engine Presence Engine AnyCable WebSocket AnyCable Presence Rails (AnyCable

    RPC) presence ping keep-alive AnyCable PONG extension takes care of abnormal closures pong
  15. Pub/sub Engine Presence Engine AnyCable WebSocket AnyCable Presence Rails (AnyCable

    RPC) WebSocket WebSocket {type: "leave", ...} {type: "leave", ...}
  16. Pub/sub Engine Presence Engine AnyCable WebSocket AnyCable Presence presence WebSocket

    WebSocket {type: "join", ...} {type: "join", ...} join subscribe Client can join presence on his own (no RPC required)
  17. <turbo-cable-presence-source signed-stream-name="<%= signed_stream_name([channel, :presence]) %>" presence-id="<%= dom_id(user, :presence) %>" ignore-self>

    <div> <span data-presence-counter>0</span> <div> <div id="<%= dom_id(channel, :presence) %>"> </div> </div> </div> <template> <%= turbo_stream.append dom_id(channel, :presence) do %> <div id="<%= dom_id(user, :presence) %>" class="u-<%=user.color %>"> @<%= user.username %> </div> <% end %> </template> </turbo-cable-presence-source> app/views/channels/_presence.html.erb _presence.html.erb 1 2 3 4 5 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
  18. <turbo-cable-presence-source signed-stream-name="<%= signed_stream_name([channel, :presence]) %>" presence-id="<%= dom_id(user, :presence) %>" ignore-self>

    <div> <span data-presence-counter>0</span> <div> <div id="<%= dom_id(channel, :presence) %>"> </div> </div> </div> <template> <%= turbo_stream.append dom_id(channel, :presence) do %> <div id="<%= dom_id(user, :presence) %>" class="u-<%=user.color %>"> @<%= user.username %> </div> <% end %> </template> </turbo-cable-presence-source> app/views/channels/_presence.html.erb _presence.html.erb 1 2 3 4 5 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 Custom element similar to Turbo Stream source
  19. <turbo-cable-presence-source signed-stream-name="<%= signed_stream_name([channel, :presence]) %>" presence-id="<%= dom_id(user, :presence) %>" ignore-self>

    <div> <span data-presence-counter>0</span> <div> <div id="<%= dom_id(channel, :presence) %>"> </div> </div> </div> <template> <%= turbo_stream.append dom_id(channel, :presence) do %> <div id="<%= dom_id(user, :presence) %>" class="u-<%=user.color %>"> @<%= user.username %> </div> <% end %> </template> </turbo-cable-presence-source> app/views/channels/_presence.html.erb _presence.html.erb 1 2 3 4 5 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 Special selector to show the total number of present users
  20. <turbo-cable-presence-source signed-stream-name="<%= signed_stream_name([channel, :presence]) %>" presence-id="<%= dom_id(user, :presence) %>" ignore-self>

    <div> <span data-presence-counter>0</span> <div> <div id="<%= dom_id(channel, :presence) %>"> </div> </div> </div> <template> <%= turbo_stream.append dom_id(channel, :presence) do %> <div id="<%= dom_id(user, :presence) %>" class="u-<%=user.color %>"> @<%= user.username %> </div> <% end %> </template> </turbo-cable-presence-source> app/views/channels/_presence.html.erb _presence.html.erb 1 2 3 4 5 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 Presence info is just a Turbo Action (executed on join)
  21. AnyCable Presence No application-level heartbeats and state maintenance logic Minimalistic

    API (both client and server side) Let the server take care of all the pitfalls!
  22. AnyCable Presence API to get presence information from your application

    Webhooks to get notified of "join"-s and "leave"-s Coming soon