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

    View full-size slide

  2. palkan_tula
    palkan
    rubyonrails.org
    2

    View full-size slide

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

    View full-size slide

  4. palkan_tula
    palkan
    REACTIVE
    4

    View full-size slide

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

    View full-size slide

  6. palkan_tula
    palkan
    RailsConf 2021
    6

    View full-size 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 full-size slide

  8. palkan_tula
    palkan
    github.com/palkan
    8

    View full-size slide

  9. palkan_tula
    palkan
    9

    View full-size slide

  10. palkan_tula
    palkan
    anycable.io
    10

    View full-size slide

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

    View full-size slide

  12. Real-time
    Synchronous


    Discrete


    Win-or-lose


    One-One

    View full-size slide

  13. Real-time
    Async (Bi-directional)


    Continuous


    Delivery guarantees


    One-Many

    View full-size slide

  14. 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 full-size slide

  15. Personalization
    Once and for all

    View full-size slide

  16. palkan_tula
    palkan
    Request-response
    18
    ?

    View full-size slide

  17. palkan_tula
    palkan
    Pub/sub
    19

    View full-size slide

  18. palkan_tula
    palkan
    20

    View full-size slide

  19. 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 full-size slide

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


    Other users receive data via Cable
    23

    View full-size slide

  21. <%# 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 full-size slide

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


    Cable vs AJAX race conditions
    26

    View full-size slide

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


    Send broadcasts to all connected users
    27

    View full-size slide

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

    View full-size slide

  25. <%# 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 full-size slide

  26. 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 full-size slide

  27. 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 full-size slide

  28. 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 full-size slide

  29. 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 full-size slide

  30. <%# 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 full-size slide

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

    View full-size slide

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

    View full-size slide

  33. 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 full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

  37. 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 full-size slide

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

    View full-size slide

  39. 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 full-size slide

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

    View full-size slide

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


    Not supported by AnyCable 🙃
    46

    View full-size slide

  42. Consistency
    Deliver it or not

    View full-size slide

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

    View full-size slide

  44. palkan_tula
    palkan
    Network is unreliable


    Action Cable provides the at-most-once delivery
    guarantee


    Network is (still) unreliable
    49
    Sent != Delivered

    View full-size slide

  45. 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 full-size slide

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


    Request recent data after reconnecting
    52

    View full-size slide

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


    Request recent data after reconnecting connecting
    53

    View full-size slide

  48. Late to the


    party

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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


    Request recent data after connecting
    58

    View full-size slide

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

    View full-size slide

  53. 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 full-size slide

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

    View full-size slide

  55. 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 full-size slide

  56. Idempotence
    From at-least-once


    to exactly-once

    View full-size slide

  57. 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 full-size slide

  58. palkan_tula
    palkan
    Solution?
    Ad-hoc
    65

    View full-size slide

  59. 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 full-size slide

  60. palkan_tula
    palkan
    Solution?
    Ad-hoc


    What about Hotwire?
    67

    View full-size slide

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

    View full-size slide

  62. 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 full-size slide

  63. 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 full-size slide

  64. 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 full-size slide

  65. 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 full-size slide

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

    View full-size slide

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


    Something's not right here 🤔

    View full-size slide

  68. 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 full-size slide

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

    View full-size slide

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

    View full-size slide

  71. 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 full-size slide

  72. Presence


    isn't perfect
    Or yet another consistency story

    View full-size slide

  73. 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 full-size slide

  74. 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 full-size slide

  75. 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 full-size slide

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

    View full-size slide

  77. 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 full-size slide

  78. 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 full-size slide

  79. 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 full-size slide

  80. 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 full-size slide

  81. 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 full-size slide

  82. 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 full-size slide

  83. palkan_tula
    palkan
    Other pitfalls
    92
    Transport Performance

    View full-size slide

  84. 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 full-size slide

  85. palkan_tula
    palkan
    bit.ly/anycable22
    95

    View full-size slide

  86. @palkan


    @palkan_tula
    evilmartians.com


    @evilmartians
    anycable.io
    Thanks!

    View full-size slide