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

[RailsWorld 2023] Untangling cables & demystify...

[RailsWorld 2023] Untangling cables & demystifying twisted transistors

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

---

More and more Rails applications adopt real-time features, and it’s not surprising—Action Cable and Hotwire brought development experience to the next level regarding dealing with WebSockets. You need zero knowledge of the underlying tech to start crafting a new masterpiece of web art! However, you will need this knowledge later to deal with ever-sophisticated feature requirements and security and scalability concerns.

The variety of questions that arise when developers work with Rails’ real-time tooling is broad, from “Which delivery guarantees does Action Cable provide?” to “Can I scale my Hotwire application to handle dozens of thousands of concurrent users?”. To answer them, we need to learn our tools first.

In my talk, I will help you better to understand Rails’ real-time component—Action Cable. I want to open this black box for you and sort through the internals so you can work with Action Cable efficiently and confidently.

Vladimir Dementyev

October 06, 2023
Tweet

More Decks by Vladimir Dementyev

Other Decks in Programming

Transcript

  1. Vladimir Dementyev Evil Martians Untangling Cables & Demystifying Twisted Transistors

    2023 1. It's Rails 2. Freak on a Cable 3. Y'all Want a Stream 4. Make Me ping 5. Wire Tongue 6. Threaded Transistor 7. Throw me & Forget 8. Turbo Streams Everywhere 9. (Not) Alone I Reconnect Bonus: See You on the Other Cable Lyrics by Vladimir "Palkan" Dementyev Produced by Evil Martians, inc.
  2. palkan_tula palkan Channels Layer 10 class ChatChannel < ApplicationCable::Channel def

    subscribed @room = Room.find_by(id: params[:id]) return reject unless @room stream_for @room end def speak(data) broadcast_to(@room, event: "newMessage", user_id: user.id, body: data.fetch("body") ) end end
  3. 11 palkan_tula palkan class ChatChannel < ApplicationCable::Channel def subscribed @room

    = Room.find_by(id: params[:id]) return reject unless @room stream_for @room end def speak(data) broadcast_to(@room, event: "newMessage", user_id: user.id, body: data.fetch("body") ) end end import { createConsumer } from "@rails/actioncable" let cable = createConsumer() let sub = cable.subscriptions.create( {channel: "ChatChannel", id: 2023}, { received({user_id, body}) { console.log(`Message from ${user_id}:${body}`); } } ) sub.perform("speak", {body: "Hoi!"})
  4. 12

  5. palkan_tula palkan 13 Protocol Web Server Action Cable Server I/O

    loop Rails Executor Connection Channel Pub/Sub Subscriber Map Client Socket Consumer Web Socket Sub Pubsub Adapter Monitor Channel Channel Worker Pool Internal Pool Sub Sub Map of Action Cable HTTP rack.hijack #connect #subscribed { command: "subscribe" } OPEN { command: "message" } #receive CLOSE #disconnect #unsubscribed #stream_from #subscribe #unsubscribe broadcast #invoke_callback #send
  6. palkan_tula palkan @rails/actioncable 15 import { createConsumer } from "@rails/actioncable"

    let cable = createConsumer() cable.subscriptions.create( {channel: "ChatChannel", id: 2023}, { received({user_id, body}) { console.log(`Message from ${user_id}:${body}`); } } )
  7. palkan_tula palkan @rails/actioncable 16 import { createConsumer } from "@rails/actioncable"

    let cable = createConsumer() cable.subscriptions.create( {channel: "ChatChannel", id: 2023}, { received({user_id, body}) { console.log(`Message from ${user_id}:${body}`); } } ) ~ WebSocket connection Abstraction to subscribe for and receive updates
  8. palkan_tula palkan One consumer per session (e.g., a browser tab)

    Many subscriptions per consumer (multiplexing) 19 @rails/actioncable
  9. palkan_tula palkan One consumer per session (e.g., a browser tab)

    Many subscriptions per consumer (multiplexing) — how many? 20 @rails/actioncable
  10. We may* have as many subscriptions as canals in Amsterdam

    21 palkan_tula palkan * Doesn't mean we should though Consumer Monitor Sub Sub Sub Web Socket Sub Sub
  11. palkan_tula palkan @rails/actioncable 22 import { createConsumer } from "@rails/actioncable"

    let cable = createConsumer() let a = cable.subscriptions.create("UserChannel") // SUBSCRIBE let b = cable.subscriptions.create("UserChannel") // <NONE> a.unsubscribe() // <NONE> b.unsubscribe() // UNSUBSCRIBE
  12. palkan_tula palkan One consumer per session (e.g., a browser tab)

    Many subscriptions per consumer Monitor to keep consumer connected 23 @rails/actioncable
  13. palkan_tula palkan Action Cable Pings Help to identify broken TCP

    connections (from both sides, but not always) 25
  14. palkan_tula palkan Action Cable Pings Help to identify broken TCP

    connections (from both sides, but not always) Hardly configurable (and may be too frequent to cause battery drain for mobile devices) 25
  15. 26 palkan_tula palkan # config/application.rb # Re-define internal constant module

    ActionCable::Server::Connections BEAT_INTERVAL = 10 end import { ConnetionMonitor } from "@rails/actioncable" // Adjust stale timeout (2xBEAT_INTERVAL) ConnectionMonitor.staleThreshold = 20
  16. Protocol is a language in which the server and client

    speak to each other 29 palkan_tula palkan Protocol Web Server Consumer Web Socket HTTP
  17. 31 palkan_tula palkan { "command":"subscribe", "identifier":"{\"channel\" \"UserChannel\"}" } { "command":"message",

    "identifier":"{\"channel\":\"UserChannel\"}", "data":"{\"action\":\"speak\",\"text\":\"hoi\"}" } { "command":"unsubscribe", "identifier":"{\"channel\":\"UserChannel\"}" } {"type":"welcome"} { "type":"confirm_subscription", "identifier":"{\"channel\":\"UserChannel\"}" } { "type":"reject_subscription", "identifier":"{\"channel\":\"UserChannel\"}" } { "identifier":"{\"channel\":\"UserChannel\"}", "message":"{\"text\":\"hoi\"}" } {"type":"ping"} { "type":"disconnect", "reason":"unauthorized", "reconnect":false } No type!
  18. palkan_tula palkan 33 let cable = createConsumer() for(let i=0; i<10;

    i++) { let sub = cable.subscriptions.create("UserChannel") sub.unsubscribe() } // Will I be subscribed or not? cable.subscriptions.create("UserChannel")
  19. palkan_tula palkan github.com/palkan/wsdirector - loop: multiplier: 10 actions: - send:

    data: &sub command: "subscribe" identifier: "{\"channel\":\"BenchmarkChannel\"}" - send: data: command: "unsubscribe" identifier: "{\"channel\":\"BenchmarkChannel\"}" - send: <<: *sub - send: data: command: "message" identifier: "{\"channel\":\"BenchmarkChannel\"}" data: "{\"action\":\"echo\",\"test\":42}" - receive: data: identifier: "{\"channel\":\"BenchmarkChannel\"}" message: {action: "echo", test: 42} 34
  20. 35

  21. 36

  22. 36 RuntimeError - Unable to find subscription with identifier: {"channel":"BenchmarkChannel"}

    RuntimeError - Already subscribed to {"channel":"BenchmarkChannel"}
  23. palkan_tula palkan 39 let cable = createConsumer() for(let i=0; i<10;

    i++) { let sub = cable.subscriptions.create("UserChannel") sub.unsubscribe() } // Will I be subscribed or not? It depends. Why so? cable.subscriptions.create("UserChannel")
  24. palkan_tula palkan 41 Web Server Action Cable Server I/O loop

    Client Socket Web Socket HTTP rack.hijack Action Cable Executor
  25. palkan_tula palkan 41 Web Server Action Cable Server I/O loop

    Client Socket Web Socket Worker Pool HTTP rack.hijack OPEN Action Cable Executor
  26. palkan_tula palkan 41 Web Server Action Cable Server I/O loop

    Rails Executor Client Socket Web Socket Worker Pool HTTP rack.hijack OPEN Action Cable Executor
  27. palkan_tula palkan 41 Web Server Action Cable Server I/O loop

    Rails Executor Connection Client Socket Web Socket Worker Pool HTTP rack.hijack #connect OPEN Action Cable Executor
  28. palkan_tula palkan 41 Web Server Action Cable Server I/O loop

    Rails Executor Connection Channel Client Socket Web Socket Worker Pool HTTP rack.hijack #connect {command: "subscribe"} OPEN #subscribed Action Cable Executor
  29. palkan_tula palkan 41 Web Server Action Cable Server I/O loop

    Rails Executor Connection Channel Client Socket Web Socket Worker Pool HTTP rack.hijack #connect {command: "subscribe"} OPEN {command: "message"} #receive #subscribed Action Cable Executor
  30. palkan_tula palkan 41 Web Server Action Cable Server I/O loop

    Rails Executor Connection Channel Client Socket Web Socket Worker Pool HTTP rack.hijack #connect #unsubscribed {command: "subscribe"} OPEN {command: "message"} #receive CLOSE #disconnect #subscribed Action Cable Executor
  31. palkan_tula palkan Action Cable Server Thread pool executor (4 threads

    by default) Must be taken into account for shared resources (e.g., database pool) Uses Rails executor to cleanup after work 42
  32. 43 palkan_tula palkan class Connection identified_by :user def connect self.user

    = find_verified_user Current.account = user.account end end
  33. 44 palkan_tula palkan class Connection identified_by :user def connect self.user

    = find_verified_user # BAD — will be reset at the end # of the #handle_open call Current.account = user.account end end
  34. class Connection identified_by :user before_command do # GOOD — set

    for every command Current.account = user.account end def connect self.user = find_verified_user end end 44 palkan_tula palkan class Connection identified_by :user def connect self.user = find_verified_user # BAD — will be reset at the end # of the #handle_open call Current.account = user.account end end
  35. palkan_tula palkan 47 Rails Executor Connection Pub/Sub Subscriber Map Client

    Socket Pubsub Adapter Channel Worker Pool Internal Pool Pub/Sub
  36. palkan_tula palkan 47 Rails Executor Connection Pub/Sub Subscriber Map Client

    Socket Pubsub Adapter Channel Worker Pool Internal Pool broadcast message Pub/Sub
  37. palkan_tula palkan 47 Rails Executor Connection Pub/Sub Subscriber Map Client

    Socket Pubsub Adapter Channel Worker Pool Internal Pool broadcast message #broadcast Pub/Sub
  38. palkan_tula palkan 47 Rails Executor Connection Pub/Sub Subscriber Map Client

    Socket Pubsub Adapter Channel Worker Pool Internal Pool broadcast message #invoke_callback #broadcast Pub/Sub
  39. palkan_tula palkan 47 Rails Executor Connection Pub/Sub Subscriber Map Client

    Socket Pubsub Adapter Channel Worker Pool Internal Pool broadcast message #invoke_callback #broadcast #stream_from callback is executed Pub/Sub
  40. palkan_tula palkan 47 Rails Executor Connection Pub/Sub Subscriber Map Client

    Socket Pubsub Adapter Channel Worker Pool Internal Pool broadcast message #invoke_callback #send #broadcast #transmit #stream_from callback is executed Pub/Sub
  41. palkan_tula palkan 47 Rails Executor Connection Pub/Sub Subscriber Map Client

    Socket Pubsub Adapter Channel Worker Pool Internal Pool Pub/Sub at-most once
  42. palkan_tula palkan Yet Another Pitfall 49 100.times { ActionCable.server.broadcast "test",

    {text: "Count: #{_1}"} } # Will a client receive 1,2,3,4,...,99 in order?
  43. 50

  44. 51

  45. palkan_tula palkan And One More 52 def subscribed stream_from "test"

    # Q: Will this client receive the message? ActionCable.server.broadcast "test", {text: "Welkom!"} end
  46. palkan_tula palkan Turbo Streams z Automatic subscriptions via HTML (<turbo-stream-source>)

    Built-in Turbo::StreamsChannel Zero application code to connect cables 55
  47. 58

  48. palkan_tula palkan Server Restart N clients re-connect N clients re-subscribe

    each to M channels each Threaded executor's work queue is: (N+1)*M 61
  49. palkan_tula palkan Server Restart Threaded executor's work queue is: (N+1)*M

    Server can not keep up with confirmations, clients re-issue subscribe commands—! 62
  50. palkan_tula palkan Server Restart Threaded executor's work queue is: (N+1)*M

    Server can not keep up with confirmations, clients re-issue subscribe commands—! 62 Thundering Herd
  51. palkan_tula palkan Server Restart Threaded executor's work queue is: (N+1)*M

    Server can not keep up with confirmations, clients re-issue subscribe commands—! 62 Connection Avalanche
  52. 66

  53. palkan_tula palkan Avalanche Protection Reconnect with backoff & jitter Merge

    subscriptions Avoid slow calls in #subscribed Serialize subscribe commands 67
  54. palkan_tula palkan 68 import { createCable } from "@anycable/web" let

    cable = createCable({ concurrentSubscribes: false })
  55. palkan_tula palkan Isolate WebSocket and regular HTTP clusters AnyCable: disconnect-less

    deploys AnyCable: resumable sessions 69 Avalanche Protection
  56. palkan_tula palkan 71 Map of AnyCable Protocol+ AnyCable-Go I/O poll

    Rails Executor Connection Channel Cache Session Cable Web Socket Channel Monitor Goroutine Pools Sub HTTP #connect #subscribed #disconnect #unsubscribed broadcast Encoder Transport Hub Sub Channel Channel Logger gRPC Pool RPC Controller Read Chan Write Chan Broadcaster Broker Hub Pub/Sub Shard Shard subscribe OPEN DATA CLOSE