Slide 1

Slide 1 text

THE PITFALLS of real ti me-i fi ca ti on Vladimir Dementyev Evil Martians

Slide 2

Slide 2 text

palkan_tula palkan rubyonrails.org 2

Slide 3

Slide 3 text

3 REACTIVE UX 33 42 BEAUTIFUL UI 7 ZERO JS

Slide 4

Slide 4 text

palkan_tula palkan REACTIVE 4

Slide 5

Slide 5 text

palkan_tula palkan Realtime-i fi cation of Rails 5 Action Cable (2015) Hotwire (2021) StimulusRe fl ex (2018)

Slide 6

Slide 6 text

palkan_tula palkan RailsConf 2021 6

Slide 7

Slide 7 text

palkan_tula palkan Going real-time with Rails is as easy as adding turbo-rails to the Gem fi le... or is it? 7

Slide 8

Slide 8 text

palkan_tula palkan github.com/palkan 8

Slide 9

Slide 9 text

palkan_tula palkan 9

Slide 10

Slide 10 text

palkan_tula palkan anycable.io 10

Slide 11

Slide 11 text

11

Slide 12

Slide 12 text

The Paradigm Shift Have you heard about this new thing, cables? Neigh!

Slide 13

Slide 13 text

Real-time Synchronous Discrete Win-or-lose One-One

Slide 14

Slide 14 text

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

Slide 15

Slide 15 text

15

Slide 16

Slide 16 text

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

Slide 17

Slide 17 text

Personalization Once and for all

Slide 18

Slide 18 text

palkan_tula palkan Request-response 18 ?

Slide 19

Slide 19 text

palkan_tula palkan Pub/sub 19

Slide 20

Slide 20 text

palkan_tula palkan 20

Slide 21

Slide 21 text

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

Slide 22

Slide 22 text

22

Slide 23

Slide 23 text

palkan_tula palkan Attempt #1: Sync + Async Current user receives data in response to action (AJAX) Other users receive data via Cable 23

Slide 24

Slide 24 text

<%# 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 -%>
<%= 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

Slide 25

Slide 25 text

25

Slide 26

Slide 26 text

palkan_tula palkan Sync + Async: Cons Current user != current browser tab Cable vs AJAX race conditions 26

Slide 27

Slide 27 text

palkan_tula palkan Attempt #2: Channel-per-User Each user streams from its personal channel Send broadcasts to all connected users 27

Slide 28

Slide 28 text

palkan_tula palkan 28 The whole idea of pub/sub is that you have no knowledge of exact receivers

Slide 29

Slide 29 text

<%# 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

Slide 30

Slide 30 text

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)

Slide 31

Slide 31 text

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

Slide 32

Slide 32 text

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)

Slide 33

Slide 33 text

palkan_tula palkan Attempt #3: Signal + Fetch Broadcast an update event to clients (w/o any payload) Clients perform requests to obtain data 33

Slide 34

Slide 34 text

<%# views/messages/_message_update.html.erb %> 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

Slide 35

Slide 35 text

palkan_tula palkan Signal + Fetch: Cons Possible self-DDoS ๐Ÿ”ฅ 35

Slide 36

Slide 36 text

What is the way? Or have you spoken?

Slide 37

Slide 37 text

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

Slide 38

Slide 38 text

palkan_tula palkan 38 evilmartians.com/chronicles/hotwire-reactive-rails-with-no-javascript

Slide 39

Slide 39 text

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

Slide 40

Slide 40 text

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

Slide 41

Slide 41 text

41

Slide 42

Slide 42 text

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?

Slide 43

Slide 43 text

palkan_tula palkan 43 noti.st/palkan/5Xx4vl/html-over-websockets-from-liveview-to-hotwire

Slide 44

Slide 44 text

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

Slide 45

Slide 45 text

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

Slide 46

Slide 46 text

palkan_tula palkan stream_from + intercept (Potentially) signi fi cant performance overhead Not supported by AnyCable ๐Ÿ™ƒ 46

Slide 47

Slide 47 text

Consistency Deliver it or not

Slide 48

Slide 48 text

palkan_tula palkan โ€“Rails Guides โ€œAnything transmitted by the broadcaster is sent directly to the channel subscribers.โ€ 48

Slide 49

Slide 49 text

palkan_tula palkan Network is unreliable Action Cable provides the at-most-once delivery guarantee Network is (still) unreliable 49 Sent != Delivered

Slide 50

Slide 50 text

50

Slide 51

Slide 51 text

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

Slide 52

Slide 52 text

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

Slide 53

Slide 53 text

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

Slide 54

Slide 54 text

Late to the party

Slide 55

Slide 55 text

palkan_tula palkan 55 FRAMEWORK USER USER connect welcome subscribe con fi rm broadcast broadcast broadcast broadcast GET HTML/JSON

Slide 56

Slide 56 text

56

Slide 57

Slide 57 text

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

Slide 58

Slide 58 text

palkan_tula palkan How to catch-up? Use a source of truth (usually, a database) Request recent data after connecting 58

Slide 59

Slide 59 text

palkan_tula palkan broadcast transmit 59 FRAMEWORK USER USER connect welcome history con fi rm subscribe

Slide 60

Slide 60 text

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

Slide 61

Slide 61 text

palkan_tula palkan broadcast transmit 61 FRAMEWORK USER USER connect welcome history con fi rm broadcast subscribe broadcast

Slide 62

Slide 62 text

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

Slide 63

Slide 63 text

Idempotence From at-least-once to exactly-once

Slide 64

Slide 64 text

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

Slide 65

Slide 65 text

palkan_tula palkan Solution? Ad-hoc 65

Slide 66

Slide 66 text

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

Slide 67

Slide 67 text

palkan_tula palkan Solution? Ad-hoc What about Hotwire? 67

Slide 68

Slide 68 text

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

Slide 69

Slide 69 text

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) } ) } / /... }

Slide 70

Slide 70 text

palkan_tula palkan PoC: turbo_history 70
<%= turbo_history_stream_from channel, params: {channel_id: channel.id, model: Channel}, cursor: "#messages .message:last - child" %> <%= render messages %> div>
<%= message.content %> div>

Slide 71

Slide 71 text

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

Slide 72

Slide 72 text

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

Slide 73

Slide 73 text

palkan_tula palkan Hotwire vs idempotence 73 turbo/src/core/streams/stream_actions.ts

Slide 74

Slide 74 text

palkan_tula palkan 74 We level-up Action Cable delivery guarantees by writing custom application-level code. Something's not right here ๐Ÿค”

Slide 75

Slide 75 text

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

Slide 76

Slide 76 text

76

Slide 77

Slide 77 text

palkan_tula palkan Protocol extensions 77 Each broadcasted message contains a metadata on its position within the stream

Slide 78

Slide 78 text

palkan_tula palkan Protocol extensions 78 Client automatically requests performs a history request containing last consumed stream positions

Slide 79

Slide 79 text

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

Slide 80

Slide 80 text

Presence isn't perfect Or yet another consistency story

Slide 81

Slide 81 text

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

Slide 82

Slide 82 text

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 ๐Ÿ™‚

Slide 83

Slide 83 text

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

Slide 84

Slide 84 text

palkan_tula palkan 84 Action Cable Presence bit.ly/ac-presence

Slide 85

Slide 85 text

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

Slide 86

Slide 86 text

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%

Slide 87

Slide 87 text

87

Slide 88

Slide 88 text

palkan_tula palkan PoC: turbo_presence 88
<%= render "channels/presence", channel: %> <%= turbo_presence_stream_from channel, params: {channel_id: channel.id, model: Channel}, presence: channel.id %> <%= render messages %> div>
๐Ÿ‘€ <%= channel.online_users.size %> div>

Slide 89

Slide 89 text

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

Slide 90

Slide 90 text

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

Slide 91

Slide 91 text

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

Slide 92

Slide 92 text

palkan_tula palkan Other pitfalls 92 Transport Performance

Slide 93

Slide 93 text

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

Slide 94

Slide 94 text

Co-founder

Slide 95

Slide 95 text

palkan_tula palkan bit.ly/anycable22 95

Slide 96

Slide 96 text

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