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.

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

    View Slide

  2. palkan_tula
    palkan
    rubyonrails.org
    2

    View Slide

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

    View Slide

  4. palkan_tula
    palkan
    REACTIVE
    4

    View Slide

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

    View Slide

  6. palkan_tula
    palkan
    RailsConf 2021
    6

    View Slide

  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

    View Slide

  8. palkan_tula
    palkan
    github.com/palkan
    8

    View Slide

  9. palkan_tula
    palkan
    9

    View Slide

  10. palkan_tula
    palkan
    anycable.io
    10

    View Slide

  11. 11

    View Slide

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

    View Slide

  13. Real-time
    Synchronous


    Discrete


    Win-or-lose


    One-One

    View Slide

  14. Real-time
    Async (Bi-directional)


    Continuous


    Delivery guarantees


    One-Many

    View Slide

  15. 15

    View Slide

  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

    View Slide

  17. Personalization
    Once and for all

    View Slide

  18. palkan_tula
    palkan
    Request-response
    18
    ?

    View Slide

  19. palkan_tula
    palkan
    Pub/sub
    19

    View Slide

  20. palkan_tula
    palkan
    20

    View Slide

  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

    View Slide

  22. 22

    View Slide

  23. palkan_tula
    palkan
    Attempt #1: Sync + Async
    Current user receives data in response to action
    (AJAX)


    Other users receive data via Cable
    23

    View Slide

  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


    -%>





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

    View Slide

  25. 25

    View Slide

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


    Cable vs AJAX race conditions
    26

    View Slide

  27. palkan_tula
    palkan
    Attempt #2: Channel-per-User
    Each user streams from its personal channel


    Send broadcasts to all connected users
    27

    View Slide

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

    View Slide

  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

    View Slide

  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)

    View Slide

  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

    View Slide

  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)

    View Slide

  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

    View Slide

  34. <%# views/messages/_message_update.html.erb %>


    -
    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

    View Slide

  35. palkan_tula
    palkan
    Signal + Fetch: Cons
    Possible self-DDoS 🔥
    35

    View Slide

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

    View Slide

  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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  41. 41

    View Slide

  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?

    View Slide

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

    View Slide

  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

    View Slide

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

    View Slide

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


    Not supported by AnyCable 🙃
    46

    View Slide

  47. Consistency
    Deliver it or not

    View Slide

  48. palkan_tula
    palkan
    –Rails Guides
    “Anything transmitted by the
    broadcaster is sent directly to the
    channel subscribers.”
    48

    View Slide

  49. palkan_tula
    palkan
    Network is unreliable


    Action Cable provides the at-most-once delivery
    guarantee


    Network is (still) unreliable
    49
    Sent != Delivered

    View Slide

  50. 50

    View Slide

  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

    View Slide

  52. palkan_tula
    palkan
    How to catch-up?
    Use a source of truth (usually, a database)


    Request recent data after reconnecting
    52

    View Slide

  53. palkan_tula
    palkan
    How to catch-up?
    Use a source of truth (usually, a database)


    Request recent data after reconnecting connecting
    53

    View Slide

  54. Late to the


    party

    View Slide

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

    View Slide

  56. 56

    View Slide

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

    View Slide

  58. palkan_tula
    palkan
    How to catch-up?
    Use a source of truth (usually, a database)


    Request recent data after connecting
    58

    View Slide

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

    View Slide

  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

    View Slide

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

    View Slide

  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

    View Slide

  63. Idempotence
    From at-least-once


    to exactly-once

    View Slide

  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

    View Slide

  65. palkan_tula
    palkan
    Solution?
    Ad-hoc
    65

    View Slide

  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

    View Slide

  67. palkan_tula
    palkan
    Solution?
    Ad-hoc


    What about Hotwire?
    67

    View Slide

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

    View Slide

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


    )


    }


    / /...

    }

    View Slide

  70. palkan_tula
    palkan
    PoC: turbo_history
    70
    index.html.erb
    -- >




    <%= turbo_history_stream_from channel, params: {channel_id: channel.id, model: Channel},


    cursor: "#messages .message:last
    -
    child" %>


    <%= render messages %>



    div>


    _message.html.erb
    -->

    -
    cursor="<%= message.id %>" class="message">


    <%= message.content %>



    div>

    View Slide

  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

    View Slide

  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

    View Slide

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

    View Slide

  74. palkan_tula
    palkan
    74
    We level-up Action Cable delivery guarantees by
    writing custom application-level code.


    Something's not right here 🤔

    View Slide

  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

    View Slide

  76. 76

    View Slide

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

    View Slide

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

    View Slide

  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

    View Slide

  80. Presence


    isn't perfect
    Or yet another consistency story

    View Slide

  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

    View Slide

  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
    🙂

    View Slide

  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

    View Slide

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

    View Slide

  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

    View Slide

  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%

    View Slide

  87. 87

    View Slide

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




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




    👀 <%= channel.online_users.size %>



    div>

    View Slide

  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

    View Slide

  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

    View Slide

  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

    View Slide

  92. palkan_tula
    palkan
    Other pitfalls
    92
    Transport Performance

    View Slide

  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

    View Slide

  94. Co-founder

    View Slide

  95. palkan_tula
    palkan
    bit.ly/anycable22
    95

    View Slide

  96. @palkan


    @palkan_tula
    evilmartians.com


    @evilmartians
    anycable.io
    Thanks!

    View Slide