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

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.

52cc8a838bf44a589d2572833b2dd1b9?s=128

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 rubyonrails.org 2

  3. 3 REACTIVE UX 33 42 BEAUTIFUL UI 7 ZERO JS

  4. palkan_tula palkan REACTIVE 4

  5. palkan_tula palkan Realtime-i fi cation of Rails 5 Action Cable

    (2015) Hotwire (2021) StimulusRe fl ex (2018)
  6. palkan_tula palkan RailsConf 2021 6

  7. palkan_tula palkan Going real-time with Rails is as easy as

    adding turbo-rails to the Gem fi le... or is it? 7
  8. palkan_tula palkan github.com/palkan 8

  9. palkan_tula palkan 9

  10. palkan_tula palkan anycable.io 10

  11. 11

  12. The Paradigm Shift Have you heard about this new thing,

    cables? Neigh!
  13. Real-time Synchronous Discrete Win-or-lose One-One

  14. Real-time Async (Bi-directional) Continuous Delivery guarantees One-Many

  15. 15

  16. 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
  17. Personalization Once and for all

  18. palkan_tula palkan Request-response 18 ?

  19. palkan_tula palkan Pub/sub 19

  20. palkan_tula palkan 20

  21. 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
  22. 22

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

    data in response to action (AJAX) Other users receive data via Cable 23
  24. <%# 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
  25. 25

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

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

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

    you have no knowledge of exact receivers
  29. <%# 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
  30. 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)
  31. 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
  32. 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)
  33. palkan_tula palkan Attempt #3: Signal + Fetch Broadcast an update

    event to clients (w/o any payload) Clients perform requests to obtain data 33
  34. <%# 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
  35. palkan_tula palkan Signal + Fetch: Cons Possible self-DDoS 🔥 35

  36. What is the way? Or have you spoken?

  37. 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
  38. palkan_tula palkan 38 evilmartians.com/chronicles/hotwire-reactive-rails-with-no-javascript

  39. 39 evilmartians.com/chronicles/hotwire-reactive-rails-with-no-javascript

  40. 40 evilmartians.com/chronicles/hotwire-reactive-rails-with-no-javascript

  41. 41

  42. 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?
  43. palkan_tula palkan 43 noti.st/palkan/5Xx4vl/html-over-websockets-from-liveview-to-hotwire

  44. 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
  45. palkan_tula palkan stream_from + intercept (Potentially) signi fi cant performance

    overhead 45
  46. palkan_tula palkan stream_from + intercept (Potentially) signi fi cant performance

    overhead Not supported by AnyCable 🙃 46
  47. Consistency Deliver it or not

  48. palkan_tula palkan –Rails Guides “Anything transmitted by the broadcaster is

    sent directly to the channel subscribers.” 48
  49. palkan_tula palkan Network is unreliable Action Cable provides the at-most-once

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

  51. 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
  52. palkan_tula palkan How to catch-up? Use a source of truth

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

    (usually, a database) Request recent data after reconnecting connecting 53
  54. Late to the party

  55. palkan_tula palkan 55 FRAMEWORK USER USER connect welcome subscribe con

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

  57. palkan_tula palkan 57 Even a stable network connection doesn't guarantee

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

    (usually, a database) Request recent data after connecting 58
  59. palkan_tula palkan broadcast transmit 59 FRAMEWORK USER USER connect welcome

    history con fi rm subscribe
  60. 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
  61. palkan_tula palkan broadcast transmit 61 FRAMEWORK USER USER connect welcome

    history con fi rm broadcast subscribe broadcast
  62. 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
  63. Idempotence From at-least-once to exactly-once

  64. 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
  65. palkan_tula palkan Solution? Ad-hoc 65

  66. 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
  67. palkan_tula palkan Solution? Ad-hoc What about Hotwire? 67

  68. palkan_tula palkan Hotwire Demysti fi ed 68 @jamie_gaskings (RailsConf 2021)

  69. 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) } ) } / /... }
  70. 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>
  71. 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
  72. 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
  73. palkan_tula palkan Hotwire vs idempotence 73 turbo/src/core/streams/stream_actions.ts

  74. palkan_tula palkan 74 We level-up Action Cable delivery guarantees by

    writing custom application-level code. Something's not right here 🤔
  75. 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
  76. 76

  77. palkan_tula palkan Protocol extensions 77 Each broadcasted message contains a

    metadata on its position within the stream
  78. palkan_tula palkan Protocol extensions 78 Client automatically requests performs a

    history request containing last consumed stream positions
  79. 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
  80. Presence isn't perfect Or yet another consistency story

  81. 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
  82. 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 🙂
  83. 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
  84. palkan_tula palkan 84 Action Cable Presence bit.ly/ac-presence

  85. 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
  86. 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%
  87. 87

  88. 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>
  89. 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
  90. 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
  91. 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
  92. palkan_tula palkan Other pitfalls 92 Transport Performance

  93. 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
  94. Co-founder

  95. palkan_tula palkan bit.ly/anycable22 95

  96. @palkan @palkan_tula evilmartians.com @evilmartians anycable.io Thanks!