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

[RailsConf 2022] The pitfalls of realtime-ification

[RailsConf 2022] The pitfalls of realtime-ification

Video: https://www.youtube.com/watch?v=TgpSs2ffJL0

https://railsconf.com/program/sessions#session-1266

Building realtime applications with Rails has become a no-brainer since Action Cable came around. With Hotwire, we don't even need to leave the comfort zone of HTML and controllers to introduce live updates to a Rails app. Realtime-ification in every house!

Switching to realtime hides many pitfalls you'd better learn beforehand. How to broadcast personalized data? How not to miss updates during connection losses? Who's online? Does it scale?

Let me dig into these problems and demonstrate how to resolve them for Action Cable and Hotwire.

Vladimir Dementyev

May 19, 2022
Tweet

More Decks by Vladimir Dementyev

Other Decks in Programming

Transcript

  1. THE PITFALLS of real ti me-i fi ca ti on

    Vladimir Dementyev Evil Martians
  2. palkan_tula palkan Realtime-i fi cation of Rails 5 Action Cable

    (2015) Hotwire (2021) StimulusRe fl ex (2018)
  3. palkan_tula palkan Going real-time with Rails is as easy as

    adding turbo-rails to the Gem fi le... or is it? 7
  4. 11

  5. 15

  6. palkan_tula palkan Rails provides convenient APIs to build real-time features

    (Action Cable / Hotwire) You shouldn't worry about low-level stuff... or should you? 16
  7. palkan_tula palkan Personalization Mine vs theirs (e.g., chat messages) Permissions-related

    UI Localization (language, time zones, 5/19/2022 vs. 19.05.2022) 21
  8. 22

  9. palkan_tula palkan Attempt #1: Sync + Async Current user receives

    data in response to action (AJAX) Other users receive data via Cable 23
  10. <%# views/messages/create.turbo_stream.erb %> <%= turbo_stream.append "messages" do %> <%= render

    @message %> <% end %> <%# views/messages/_message.html.erb %> <%- klass = if current_user&.id = = message.user_id "mine" else "theirs" end -%> <div class="message <%= klass %>"> <%= message.content %> </ div> # models/channel.rb class Message < ApplicationRecord belongs_to :channel, touch: true belongs_to :user after_commit on: :create do broadcast_append_to( channel, partial: "messages/message", locals: { message: self, current_user: nil }, target: "messages" ) end end Stub current user
  11. 25

  12. palkan_tula palkan Sync + Async: Cons Current user != current

    browser tab Cable vs AJAX race conditions 26
  13. palkan_tula palkan Attempt #2: Channel-per-User Each user streams from its

    personal channel Send broadcasts to all connected users 27
  14. palkan_tula palkan 28 The whole idea of pub/sub is that

    you have no knowledge of exact receivers
  15. <%# views/somewhere.html.erb %> <%= turbo_stream_from current_user %> # models/channel.rb class

    Message < ApplicationRecord belongs_to :channel, touch: true belongs_to :user after_commit on: :create do channel.subscribers.each do |user| broadcast_append_to( user, partial: "messages/message", locals: { message: self, current_user: user }, target: "messages" ) end end end
  16. palkan_tula palkan Channel-per-User: Cons Unacceptable overhead when online selectivity* is

    low 30 * Online selectivity is the number of online subscribers << the total number of subscribers (=broadcasts)
  17. palkan_tula palkan Off-topic: Channel-per-Group Group clients by traits (something that

    affects the UI: roles, locales, etc.) Send O(1) broadcasts for each update => low overhead 31
  18. palkan_tula palkan Localized Hotwire 32 # helper.rb module ApplicationHelper def

    turbo_stream_from( ... ) super(I18n.locale, ... ) end end # patch.rb Turbo :: StreamsChannel.singleton_class.prepend(Module.new do def broadcast_render_to( ... ) I18n.available_locales.each do |locale| I18n.with_locale(locale) { super(locale, .. . ) } end end def broadcast_action_to( ... ) I18n.available_locales.each do |locale| I18n.with_locale(locale) { super(locale, .. . ) } end end end)
  19. palkan_tula palkan Attempt #3: Signal + Fetch Broadcast an update

    event to clients (w/o any payload) Clients perform requests to obtain data 33
  20. <%# views/messages/_message_update.html.erb %> <turbo - frame id="<%= dom_id(message, :frame) %>"

    src="<%= message_path(message)"> </ turbo - frame> # models/channel.rb class Message < ApplicationRecord belongs_to :channel, touch: true belongs_to :user after_commit on: :create do broadcast_append_to( channel, partial: "messages/message_update", locals: { message: self }, target: "messages" ) end end
  21. palkan_tula palkan CFB + CSE Broadcast common data to everyone

    (which could be a bit redundant for some clients) Personalize via client-side code 37 Context-free broadcasts and client-side enhancements
  22. 41

  23. palkan_tula palkan Personalization is hard 42 All of the above

    are ad-hoc Why there is no a universal way of dealing with this? How do others solve this problem?
  24. palkan_tula palkan Signal + Transmit Channels (server) intercept broadcasts and

    generate the fi nal data (for each subscription) 44 stream_for room do |event| message = channel.messages.find(event["message_id"]) transmit_append target: "message", partial: "messages/message", locals: {message:, current_user:} end
  25. palkan_tula palkan Network is unreliable Action Cable provides the at-most-once

    delivery guarantee Network is (still) unreliable 49 Sent != Delivered
  26. 50

  27. palkan_tula palkan 51 connect welcome subscribe con fi rm broadcast

    broadcast FRAMEWORK USER USER connect welcome subscribe con fi rm broadcast broadcast broadcast broadcast
  28. palkan_tula palkan How to catch-up? Use a source of truth

    (usually, a database) Request recent data after reconnecting 52
  29. palkan_tula palkan How to catch-up? Use a source of truth

    (usually, a database) Request recent data after reconnecting connecting 53
  30. palkan_tula palkan 55 FRAMEWORK USER USER connect welcome subscribe con

    fi rm broadcast broadcast broadcast broadcast GET HTML/JSON
  31. 56

  32. palkan_tula palkan How to catch-up? Use a source of truth

    (usually, a database) Request recent data after connecting 58
  33. palkan_tula palkan 60 // channel.js consumer.subscriptions.create({ channel: "ChatChannel", room_id: "2022"

    }, { received(data) { this.appendMessage(data) }, connected() { this.perform( "history", { last_id: getLastMessageId() } ) } }) # chat_channel.rb class ChatChannel < ApplicationCable : : Channel def history(data) last_id = data.fetch("last_id") room = Room.find(params["room_id"]) room.messages .where("id > ?", last_id) .order(id: :asc) .each do transmit serialize(_1) end end end Problem Solved
  34. palkan_tula palkan 62 // channel.js consumer.subscriptions.create({ channel: "ChatChannel", room_id: "2022"

    }, { received(data) { if (data.type === "history_ack") { this.pending = false while(this.pendingMessages.length > 0){ this.received(this.pendingMessages.shift()) } return } if (this.pending) { return this.pendingMessages.push(data) } this.appendMessage(data) }, / / ... }) # chat_channel.rb class ChatChannel < ApplicationCable : : Channel def history(data) last_id = data.fetch("last_id") room = Room.find(params["room_id"]) room.messages .where("id > ?", last_id) .order(id: :asc) .each do transmit serialize(_1) end transmit(type: "history_ack") end end Using a buffer to resolve race conditions Action Cable doesn't support call acknowledgements, we have to DIY
  35. palkan_tula palkan 64 // channel.js consumer.subscriptions.create({ channel: "ChatChannel", room_id: "2022"

    }, { received(data) { if (data.type === "history_ack") { this.pending = false while(this.pendingMessages.length > 0){ this.received(this.pendingMessages.shift()) } return } if (this.pending) { return this.pendingMessages.push(data) } if (!hasMessageId(data.id)) { this.appendMessage(data) } }, / / ... }) # chat_channel.rb class ChatChannel < ApplicationCable : : Channel def history(data) last_id = data.fetch("last_id") room = Room.find(params["room_id"]) room.messages .where("id > ?", last_id) .order(id: :asc) .each do transmit serialize(_1) end transmit(type: "history_ack") end end Handling duplicates => idempotence
  36. palkan_tula palkan 66 consumer.subscriptions.create({ channel: "ChatChannel", room_id: "2022" }, {

    received(data) { this.appendMessage(data) } }) consumer.subscriptions.create({ channel: "ChatChannel", room_id: "2022" }, { initialized() { this.pending = true this.pendingMessages = [] }, disconnected() { this.pending = true }, received(data) { if (data.type = == "history_ack") { this.pending = false while(this.pendingMessages.length > 0){ this.received(this.pendingMessages.shift()) } return } if (this.pending) { return this.pendingMessages.push(data) } if (!hasMessageId(data.id)) { this.appendMessage(data) } }, connected() { this.perform( "history", { last_id: getLastMessageId() } ) } }) + Ruby code
  37. palkan_tula palkan Hotwire Cable 69 class TurboCableStreamSourceElement extends HTMLElement {

    async connectedCallback() { connectStreamSource(this) this.subscription = await subscribeTo( this.channel, { received: this.dispatchMessageEvent.bind(this) } ) } / /... }
  38. palkan_tula palkan PoC: turbo_history 70 <!- - index.html.erb -- >

    <div id="messages"> <%= turbo_history_stream_from channel, params: {channel_id: channel.id, model: Channel}, cursor: "#messages .message:last - child" %> <%= render messages %> </ div> <!- - _message.html.erb --> <div id="<%= dom_id(message) %>" data - cursor="<%= message.id %>" class="message"> <%= message.content %> </ div>
  39. palkan_tula palkan PoC: turbo_history 71 class Channel < ApplicationRecord def

    self.turbo_history(turbo_channel, last_id, params) channel = Channel.find(params[:channel_id]) channel.messages .where("id > ?", last_id) .order(id: :asc).each do |message| turbo_channel.transmit_append target: "messages", partial: "messages/message", locals: {message:} end end end
  40. palkan_tula palkan PoC: turbo_history Custom HTMLElement with a history-aware subscription

    implementation Turbo StreamChannel extensions to transmit streams and handle history calls A model-level .turbo_history API 72 https://bit.ly/turbo-history
  41. palkan_tula palkan 74 We level-up Action Cable delivery guarantees by

    writing custom application-level code. Something's not right here 🤔
  42. palkan_tula palkan AnyCable v1.5 Extended Action Cable protocol Hot cache

    for streams history Session recovery mechanism (no need to resubscribe on reconnect) 75 Coming soon
  43. 76

  44. palkan_tula palkan Protocol extensions 78 Client automatically requests performs a

    history request containing last consumed stream positions
  45. palkan_tula palkan AnyCable v1.5 Extended Action Cable protocol Hot cache

    for streams history Session recovery mechanism (no need to resubscribe on reconnect) Zero application-level changes 79 Coming soon
  46. palkan_tula palkan OnlineChannel? 81 # channels/online_channel.rb class OnlineChannel < ApplicationCable

    : : Channel def subscribed current_user.update!(is_online: true) end def unsubscribed current_user.update!(is_online: false) end end
  47. palkan_tula palkan OnlineChannel 82 # channels/online_channel.rb class OnlineChannel < ApplicationCable

    : : Channel def subscribed current_user.update!(is_online: true) end def unsubscribed current_user.update!(is_online: false) end end 🙂
  48. palkan_tula palkan 83 # models/user.rb class User < ApplicationRecord kredis_counter

    :active_sessions def online? active_sessions.positive? end end # channels/online_channel.rb class OnlineChannel < ApplicationCable : : Channel def subscribed current_user.active_sessions.increment end def unsubscribed current_user.active_sessions.decrement end end
  49. palkan_tula palkan Disconnect reliability How quickly server detects abnormally disconnected

    clients? What happens when server crashes? ...or when is terminated forcefully? (And we had thousands of connections to invoke #disconnect) 85
  50. palkan_tula palkan Additional heartbeat could make it 100% Timestamps are

    better than fl ags and counters (last_pinged_at) Redis ordered sets with score are awesome! 86 Disconnect reliability < 100%
  51. 87

  52. palkan_tula palkan PoC: turbo_presence 88 <!- - index.html.erb -- >

    <div id="messages"> <%= render "channels/presence", channel: %> <%= turbo_presence_stream_from channel, params: {channel_id: channel.id, model: Channel}, presence: channel.id %> <%= render messages %> </ div> <!- - _presence.html.erb --> <div id="presence"> 👀 <%= channel.online_users.size %> </ div>
  53. palkan_tula palkan PoC: turbo_presence 89 class Channel < ApplicationRecord def

    self.turbo_broadcast_presence(params) channel = Channel.find(params[:channel_id]) channel.broadcast_replace_to channel, partial: "channels/presence", locals: {channel:}, target: "presence" end def online_users User.where(id: Turbo :: Presence.for(channel.id)) end end
  54. palkan_tula palkan PoC: turbo_presence Presence tracking engine (Redis ordered sets)

    Custom HTMLElement with keep-alive Turbo channel extensions (#after_subscribe, #after_unsubscribe) A model-level .turbo_broadcast_presence API 90 https://bit.ly/turbo-presence
  55. palkan_tula palkan AnyCable vX.Y Built-in presence keep-alive and expiration Robust

    presence engine implementation(-s) Protocol-level presence support A simple presence reading API via Redis Functions 91 Coming someday
  56. palkan_tula palkan 93 Real-time is different but not dif fi

    cult Don't be tricked by cozy abstractions; know your tools, avoid pitfalls