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

ActionCableの相手はブラウザだけじゃない — Railsでデバイス群をリアルタイム管制する

Sponsored · Your Podcast. Everywhere. Effortlessly. Share. Educate. Inspire. Entertain. You do you. We'll handle the rest. →
Avatar for Yuji Teshima Yuji Teshima
October 17, 2026
18

ActionCableの相手はブラウザだけじゃない — Railsでデバイス群をリアルタイム管制する

ActionCable の相手は、いつもブラウザとは限りません。センサーを積んだ Raspberry Pi をコンシューマにすると、Rails はデバイス群にコマンドを配り、結果を受け取る「制御プレーン」になります。

本発表では、その設計を実機の Raspberry Pi を繋いだ動くシステムで紹介します。物理デバイスを Device モデルと状態機械で扱い、heartbeat と定期照合で生死を追いかける。ActionCable を双方向の transport にして、結果は冪等に取り込む。ActiveJob で「実行先が物理デバイス」なジョブを組み、落ちる前提でリトライとバックプレッシャを設計する——おなじみの道具だけで、ここまで作れます。

山場は「リアルタイムで Turbo Streams と生 ActionCable をどう使い分けるか」。毎秒届くスペクトルを HTML で撒くと転送量は約26倍、毎フレーム257個の DOM ノードが生まれます。実測ベンチから、低頻度の状態変化は Turbo Streams、高頻度の数値ストリームは生 ActionCable + canvas という判断基準を導きます。通知やダッシュボードなど、ふつうの Rails 開発にそのまま効く勘所です。

ライブデモとともに、見慣れた Rails の意外な射程をお見せします。

Avatar for Yuji Teshima

Yuji Teshima

October 17, 2026

Transcript

  1. ActionCable isn't just for browsers Running a fleet of Raspberry

    Pis from Rails, in real time Yuji Teshima · @yujiteshima · Day 2, Magenta Hall
  2. 00 · Opening Yuji Teshima @yujiteshima Stadium Inc. — I

    build FANTS in Ruby. Hobby: talking to hardware from Ruby. I wrote mruby-gpu-narray (GPU arrays for mruby on Vulkan) — it runs inside the Raspberry Pi in today's demo. Earlier this year I talked about the GPU side (RubyKaigi 2026 LT, 名古屋 Ruby会議05). Today is the other side: the Rails that receives the results. 2
  3. 00 · Opening Who is on the other side of

    your Rails app? Usually a person. Someone moving a mouse, filling a form, pressing a button. Rails builds HTML for that person. This talk is about what happens when it isn't a person. 3
  4. 00 · Opening A browser, a Rails app — and

    four machines behind it Left card: a real Raspberry Pi 5, running an FFT on its GPU, wired to this laptop. Other three: simulated devices — the same client program, with a synthetic waveform instead of the GPU FFT. No sensor today — the Pi’s FFT runs on a synthetic input. From Rails' point of view they are all the same thing: a Device. 4
  5. 00 · Opening 70% of Raspberry Pi's revenue comes from

    industrial customers. Hobbyists are the minority. A local computer that aggregates a sensor fleet and forwards upstream has a name: the IoT gateway. Predictive maintenance, building management, precision agriculture — the same shape, over and over. This talk builds that gateway's control plane with Rails. Raspberry Pi Holdings plc, FY2024 disclosures (≈70–72% industrial/embedded). 5
  6. 00 · Opening Two things to take home 1 When

    to use Turbo Streams, and when to use raw ActionCable. Not an IoT lesson — it applies to notifications, live updates, dashboards: any real-time feature that got heavy. Measured. With numbers. 2 Rails still has room beyond the browser. An ActiveRecord model, a state enum, ActiveJob, Turbo Streams — the model design you already do, pointed at a fleet of machines. Where Rails fell short, one person with one Raspberry Pi took it upstream: three reports and proposals in a month. That space is open, and within reach. 6
  7. 01 · Devices as domain models Rails as the control

    plane “Control plane” is a networking Browser · live dashboard Turbo Streams for state · raw ActionCable for numbers term (SDN): the layer that decides, separate from the plane that moves data. Fog-computing papers call the gateway layer Rails · control plane exactly that, and Kubernetes device registry & state machine · ActiveJob dispatch · idempotent intake · reconciliation borrowed the same word. frames · heartbeat · done commands Raspberry Pi fleet · data plane sensor → GPU spectrum (mruby-gpu-narray) → frames 7
  8. 01 · Devices as domain models A device is just

    an ActiveRecord model class Device < ApplicationRecord enum :state, { provisioning: "provisioning", # never connected online: "online", # connected, idle busy: "busy", # analysis running degraded: "degraded", # heartbeat late offline: "offline" # heartbeat expired }, default: "provisioning", validate: true provisioning registered has_many :job_runs validates :token, presence: true, uniqueness: true end degraded late online idle busy analyzing offline gone 8
  9. 01 · Devices as domain models Transitions have rules —

    and one entrance TRANSITIONS = { "provisioning" => %w[online offline], "online" => %w[busy degraded offline], "busy" => %w[online degraded offline], "degraded" => %w[online busy offline], "offline" => %w[online] # never → busy directly }.freeze def transition_to!(next_state) return true if state == next_state # 1. no-op raise InvalidTransition unless can_transition_to?(next_state) update!(state: next_state) broadcast_status_change # 2. only on real change end 9 1 Same state → nothing happens. Idempotent by design; it matters when a report arrives twice. 2 Turbo Streams fires only when the state really changed. Low frequency, by construction.
  10. 01 · Devices as domain models The Pi never says

    goodbye class ReconciliationJob < ApplicationJob def perform Device.live.find_each do |d| age = d.heartbeat_age # now - last_seen_at if age > 60.seconds d.transition_to!("offline") elsif age > 15.seconds d.transition_to!("degraded") end end JobRun.running .where("updated_at < ?", 90.seconds.ago) .update_all(state: "stalled") end end 10 every 3 s heartbeat arrives 15 s silent degraded 60 s silent offline 90 s no progress job → stalled Don't wait to be told. Reconcile the ledger yourself.
  11. 02 · Two-way ActionCable ActionCable speaks JSON over WebSocket. That's

    it. C→S S→C S→C S→C C→S {"command":"subscribe","identifier":"{\"channel\":\"SpectrumChannel\"}"} {"type":"welcome"} {"type":"confirm_subscription","identifier":"..."} {"type":"ping","message":1760000000} # every 3 s {"command":"message","identifier":"...","data":"{\"action\":\"frame\",...}"} Protocol name: actioncable-v1-json . Unchanged since Rails 5.0 (2016) — additions only, never a changed meaning. Anything that speaks WebSocket and JSON can be a consumer. 11
  12. 02 · Two-way ActionCable identified_by :current_device class Connection < ActionCable::Connection::Base

    identified_by :current_device Token → a device. No token → a browser. def connect token = request.params[:token] if token.present? self.current_device = Device.find_by(token:) unless current_device reject_unauthorized_connection end else self.current_device = nil # a browser end end end 12 Wrong token → rejected. That is the entire authentication story for this demo.
  13. 02 · Two-way ActionCable Two channels — Rails is the

    switchboard DeviceCommandChannel SpectrumChannel subscribed by the Pi · two-way subscribed by the browser · one-way ↓ start / stop ↓ compact numbers only, never HTML ↑ heartbeat · frame · done # DeviceCommandChannel — a frame arrives from the Pi 15× per second def frame(data) SpectrumChannel.broadcast_to(current_device, seq: data["seq"], bins: data["bins"], spectrum: data["spectrum"]) end What this does not do, 60 times a second: touch the database, or build HTML. 13
  14. 02 · Two-way ActionCable Assume the device will say it

    twice class JobRun < ApplicationRecord validates :job_id, uniqueness: true # unique index def self.complete!(job_id:, device:, outcome:, seq_last: nil, error: nil) run = find_or_create_by!(job_id:) do |r| r.device = device end return run unless run.running? # terminal → no-op final = outcome == "error" ? "failed" : "succeeded" run.update!(state: final, seq_last: seq_last, ended_at: Time.current) end end 14 • A second done for the same job_id is ignored. • A late, contradicting done (“failed” after “succeeded”) does not overwrite the first verdict. • Frames are never persisted — the browser drops seq ≤ last seen. • Idempotent intake: the receiver absorbs duplication, so the sender may be sloppy.
  15. 02 · Two-way ActionCable …and assume it will go silent

    mid-job No progress for 90 seconds? The job is marked stalled. Progress rides on the heartbeat, not on every frame. Device judged offline? Every job it was running is reaped with it. The device that crashed will not tell you it crashed. The same ReconciliationJob that expires heartbeats closes its jobs. 15
  16. 03 · Dispatching with ActiveJob A plain ActiveJob — run

    by a physical device class AnalysisDispatchJob < ApplicationJob discard_on ActiveRecord::RecordNotFound # device is gone: don't retry retry_on StandardError, wait: :polynomially_longer, attempts: 3 def perform(device_id, params = {}) device = Device.find(device_id) return :skipped unless device.online? # backpressure, not an exception job_id = SecureRandom.uuid JobRun.create!(job_id:, device:, state: "running", started_at: Time.current) device.transition_to!("busy") # → Turbo Streams flips the badge DeviceCommandChannel.broadcast_to(device, command: "start", job_id:, params:) end end Backpressure — busy is a normal state for a machine, so skip, don't raise. Retry — transient failures, spaced out. Discard — a deleted device is not worth retrying. 16
  17. 04 · Turbo Streams or raw ActionCable 60 frames per

    second. 256 numbers each. Put it on screen. Here is the real topic. 17
  18. 04 · Turbo Streams or raw ActionCable First attempt: everything

    through Turbo Streams Turbo Streams is wonderful. The server sends finished HTML, the browser swaps it in, and you write no JavaScript. So the obvious move: render the spectrum as 256 bars of HTML and replace it every frame. <%# spectra/_bars.html.erb — 256 <div>s, once per frame %> <div class="spectrum-bars"> <% mags.each do |m| %> <div class="bar" style="height:<%= (m * 100.0 / 255).round %>%"></div> <% end %> </div> Turbo::StreamsChannel.broadcast_replace_to("fleet", target: "spectrum_pi-01", partial: "spectra/bars", locals: { mags: mags }) 18
  19. 04 · Turbo Streams or raw ActionCable Same frame, measured

    two ways per frame (256 bins) Turbo Streams · HTML raw ActionCable · compact HTML is 10.96 KB 0.42 KB 26× heavier Server work 0.09 ms (render) ≈ 0 ms (base64) 31× Client paint 0.236 ms (rebuild + reflow) 0.032 ms (one canvas column) 7.4× 257 0 ∞ Payload on the wire DOM nodes created ruby 3.4 · Rails 8.1 · headless Chrome · 5,000 / 3,000 iterations. Full data in bench/results.md. 19
  20. 04 · Turbo Streams or raw ActionCable Scale it up:

    bandwidth is the first wall 5.1 MB/s is what 16 devices at 30 fps push through a single WebSocket as “replace this HTML” instructions. First wall: bandwidth. Then main-thread time and GC from 123,000 DOM nodes a second. 20
  21. 04 · Turbo Streams or raw ActionCable Honestly: at demo

    scale, Turbo Streams is fine 4 devices × 15 fps = 60 updates a second. The browser does not freeze. I checked. The gap opens with high rate × many devices × many bins. So the question is not “does it freeze?” It is “where do you switch?” 21
  22. 04 · Turbo Streams or raw ActionCable The rule Low-frequency

    state changes High-frequency numeric streams a device went offline · a notification · a badge waveforms · sensor values · tens of Hz → Turbo Streams → raw ActionCable + canvas Server-rendered HTML, zero client code. The swap cost is negligible at this rate. Send compact numbers, paint on the client, throttle on the server. Switching point: a few hundred messages per second, or tens of thousands of DOM nodes per second. Outside IoT, the same line: new notifications and comments → Turbo Streams. An order book, a live traffic graph, dozens of progress bars moving at once → numbers on the wire, painting on the client. 22
  23. 04 · Turbo Streams or raw ActionCable Both — on

    the same screen Badge, buttons, job list Turbo Streams. A few changes a minute. Waveform raw ActionCable → Stimulus → canvas. 15 columns a second. Not either/or. Split by role. 23
  24. 04 · Turbo Streams or raw ActionCable One trap: never

    let Turbo replace the canvas <%# devices/_device.html.erb %> <div id="<%= dom_id(device) %>" class="card"> <%= render "devices/status", device: device %> <%# ← Turbo replaces only this %> <canvas data-controller="spectrogram" data-spectrogram-device-id-value="<%= device.id %>"></canvas> <%# ← never touched %> </div> If the replaced subtree contains the canvas, the canvas is recreated — and the waveform you were drawing vanishes on every status update. Split the DOM by role, the same way you split the transport by role. 24
  25. 04 · Turbo Streams or raw ActionCable Live demo start

    badge turns blue · ActiveJob → ActionCable → the Pi streams start again skipped: busy (backpressure) · no new job pull the Pi's cable degraded (amber) → offline (grey) · its job → stalled plug it back, start online · a fresh job · the waveform resumes meanwhile the other three never stopped
  26. 05 · Lessons Three things that broke because the client

    wasn't a browser 1 Origin 2 Half-open 3 Clocks 26 Browsers send an Origin, devices don't, so Action Cable turns the Pi away. Action Controller lets the same request in — “We accept blank origin headers because some user agents don't send it.” RFC 6455 only requires a browser to send one. The shortcut is to switch the protection off; ours stayed off for three months. Pull the cable and nobody says goodbye. The server keeps the dead connection, and the dead connection can take a returned device down with it. The protocol carries timestamps. None of them decides anything. On purpose.
  27. 05 · Lessons How long until the server notices? what

    happened to the device the server notices after cable pulled — server on macOS 20 – 40 s cable pulled — server on Linux, defaults about 16 min its process froze never any of the above, with pongs (rails/rails #58798) 4–8s On a Mac the dead connection cleans itself up in seconds, so you never see the problem. Production runs on Linux. Found on the real Pi: it reconnected — and ten seconds later the old connection finally closed and marked it offline. Only a device's current connection may take it offline. 27
  28. 05 · Lessons Half-open: a PR since 2023, remade in

    2026 “I have IoT devices connecting to my Rails app via ActionCable … when a device gets unplugged from the network, its ActionCable connection doesn't close and can linger in the system for up to half an hour.” rails/rails #50039 (2023), remade as #58798 on the new server architecture (Sep 2026) A client offering the actioncable-ext-pong extension answers ping with pong; six silent seconds and the server closes it (no_pong). Still open. Verified on a real Raspberry Pi 5: 9 failure injections, every one cleaned up within 8 s (4.3 – 8.0 s) — cable pulled, or the client process frozen. Reported on the PR. Proposed on rails-core: accept a missing Origin, as Action Controller already does. 28
  29. 05 · Lessons Don't trust the device's clock decision what

    decides it device clock? last_seen_at server time on arrival no degraded / offline age of last_seen_at no job stalled time since its last progress no frame order seq, a device-local counter no clock pi-04 BUSY cpu 30% · 50.2℃ clock −34d 14h Shown on its card, in amber. It is still streaming. A Pi 5 ships with an RTC but no battery; a Pico 2 W has no battery-backed clock either. Ours booted 34 days in the past and stayed there for 81 s, until NTP answered. The device's ts is compared with the server's clock only to show the skew. It decides nothing. 29
  30. 05 · Lessons Rails 8.0: more than one process —

    without Redis “Solid Cable is the last piece before we can fully claim that Rails 8 only requires a database as a dependency to get access to all frameworks and functionality.” DHH, rails/rails #52889 (Sep 2024) · release post: “Rails 8: No PaaS Required” (Nov 2024) Messages go into a table; every process polls it. No Redis. The stated goal is fewer accessory services and a single-server deploy. That is where an edge gateway lives: usually a small box in a factory closet, no ops team. One fewer moving part is one fewer thing that breaks. What it costs, measured at 61 broadcasts/s: 5.3 million rows and 3.5 GiB a day, and +55 ms to deliver. Rows only need to outlive the slowest poller; with message_retention: 5.minutes the table levels off near 18,000 rows (the maintainer suggests 5 minutes or more). Proposed as README guidance in rails/solid_cable #94; “PRs welcome” came back in 19 minutes, and PR #95 is open. 30
  31. 05 · Lessons Rails 8.2: swap the server, keep your

    channels “…separating low-level, implementation specific details (e.g., handling WebSockets, dealing with concurrency) from abstractions (Connection, Channel classes) … to use user-level Action Cable code with alternative web servers or execution models (e.g., Fibers) without rewriting it.” rails/rails #50979 “Action Cable server adapterization”, merged May 2026 #50979 merged Connection and Channel no longer depend on how sockets and threads are handled. #57803 open config.action_cable.server_class — Falcon/Async or AnyCable under the same channels. #58703 open fastlane broadcasts — encode once per stream: less CPU, fewer reordered frames. A fleet is many long-lived, mostly idle connections — one heartbeat every 3 s: the shape fibers and Go servers handle well. Start on one box with the built-in server; grow without touching DeviceCommandChannel. 8.2 is unreleased; two of these are still open pull requests (Oct 2026). 31
  32. 05 · What's next Physical AI: the next fleet to

    orchestrate “Physical AI has arrived — every industrial company will become a robotics company.” Jensen Huang, 2026. AI that perceives, reasons and acts in the physical world: robots, vehicles, factories, drones. A software category already exists for it: fleet management for physical-AI devices. NVIDIA Fleet Command InOrbit.AI Jetson fleets · OTA · model deployment enterprise robot operations Boston Dynamics Orbit task orchestration 32 Open-RMF (ROS 2) heterogeneous fleets — plus rmf-web, a separate web dashboard for operators
  33. 05 · What's next That layer has no Ruby in

    it. Yet. IoT gateways today: Java (Eclipse Kura, ThingsBoard), Python (Home Assistant), Node.js (Node-RED), Go (EdgeX). Physical-AI fleet tools: the same list. Ruby does not appear. One precedent: Tamashii (RubyKaigi 2017) — “a Rack-based device management server inspired by ActionCable,” a Raspberry Pi agent talking to a Rails manager over WebSocket. Almost exactly this talk, nine years early. Last commit: November 2017. The brain of a robot is Isaac or ROS 2 — not Rails. The bridge — where humans watch the fleet, dispatch work, and track state — has no settled answer. Even ROS 2 puts it in a separate web app. That bridge is what Rails has spent a decade polishing. 33
  34. Low-frequency state → Turbo Streams. High-frequency numbers → raw ActionCable

    + canvas + throttling. Same screen. Split by role. Rails still has room beyond the browser. Wherever a person is watching machines — a factory corner, the next bench along from a robot — I want Rails to be there. The unexpected reach of a familiar Rails. ActionCable isn't just for browsers. Thank you. github.com/yujiteshima/rails-edge-control-plane