Repository navigation
4.1.0 writes broadcasts late when the broadcasting thread stays busy between them #92
Description
Since 4.1.0 (#87), broadcast doesn't write the row itself. It queues the message for the BatchedBroadcaster writer thread. When the broadcasting thread keeps running Ruby code between broadcasts, it holds the GVL, so the writer thread rarely gets to run. The rows are written late, most of them in one burst after the broadcasting thread finishes. In 4.0.2, broadcast wrote the row in the calling thread, so this didn't happen.
Where we hit it
A job streams an LLM reply to the browser with Turbo Streams. It broadcasts a rendered partial every 80 ms, and renders the next one in between. After we upgraded from 4.0.2 to 4.1.0, the reply stopped streaming: every chunk reached the browser at once, when the job finished. Our job container gets a fraction of a CPU, which keeps the job busy most of the time. Downgrading to 4.0.2 fixed it.
Reproduction
The script below broadcasts 20 times from a thread that does 80 ms of Ruby work between broadcasts. It then compares each row's created_at (when it was written) with the time of its broadcast.
# A thread that stays busy between broadcasts holds the GVL, so solid_cable
# 4.1.0's BatchedBroadcaster writer thread can't write the queued broadcasts
# until that thread finishes. A job streaming an LLM reply behaves like this:
# it renders a partial for every chunk. This script prints when each row was
# written, taken from its created_at, compared with when it was broadcast.
#
# createdb solid_cable_repro
# DATABASE_URL=postgres:///solid_cable_repro SOLID_CABLE_VERSION=4.1.0 ruby solid_cable_busy_broadcaster.rb
# DATABASE_URL=postgres:///solid_cable_repro SOLID_CABLE_VERSION=4.0.2 ruby solid_cable_busy_broadcaster.rb
require "bundler/inline"
gemfile(true) do
source "https://rubygems.org"
gem "rails", "~> 8.1.0"
gem "pg"
gem "solid_cable", ENV.fetch("SOLID_CABLE_VERSION", "4.1.0"), require: false
end
require "rails"
require "active_record/railtie"
require "active_job/railtie"
require "action_cable/engine"
require "solid_cable"
require "tmpdir"
require "fileutils"
root = Dir.mktmpdir
FileUtils.mkdir_p(File.join(root, "config"))
File.write(File.join(root, "config/cable.yml"), <<~YAML)
development:
adapter: solid_cable
polling_interval: 0.1.seconds
message_retention: 1.day
YAML
class ReproApp < Rails::Application
config.load_defaults 8.1
config.eager_load = true
config.logger = Logger.new(nil)
config.secret_key_base = "repro"
end
ReproApp.config.root = root
Rails.application.initialize!
ActiveRecord::Base.establish_connection(ENV.fetch("DATABASE_URL"))
ActiveRecord::Schema.verbose = false
ActiveRecord::Schema.define do
create_table "solid_cable_messages", force: :cascade do |t|
t.binary "channel", limit: 1024, null: false
t.binary "payload", limit: 536870912, null: false
t.datetime "created_at", null: false
t.integer "channel_hash", limit: 8, null: false
t.index ["channel"]
t.index ["channel_hash"]
t.index ["created_at"]
end
end
cable_config = ActionCable::Server::Configuration.new
cable_config.cable = { "adapter" => "solid_cable" }
cable_config.logger = Logger.new(nil)
pubsub = ActionCable::Server::Base.new(config: cable_config).pubsub
sent = []
Thread.new do
Rails.application.executor.wrap do
20.times do |i|
pubsub.broadcast("stream", "chunk #{i}")
sent << Time.current
# Stands in for rendering the next chunk: 80 ms of Ruby work, GVL held.
finish = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 0.08
nil while Process.clock_gettime(Process::CLOCK_MONOTONIC) < finish
end
end
end.join
finished = Time.current
sleep 1 # let the writer catch up
written = SolidCable::Message.where(channel: "stream").order(:id).pluck(:created_at)
lags = written.each_with_index.map { |at, i| ((at - sent[i]) * 1000).round }
puts "ruby #{RUBY_VERSION}, rails #{Rails.version}, solid_cable #{SolidCable::VERSION}"
puts "written before the broadcasting thread finished: #{written.count { |at| at < finished }}/#{sent.size}"
puts "write delay per broadcast (ms): #{lags.inspect}"
pubsub.shutdownOutput, Ruby 3.4.8, Rails 8.1.4, PostgreSQL 16.15, macOS arm64:
ruby 3.4.8, rails 8.1.4, solid_cable 4.1.0
written before the broadcasting thread finished: 3/20
write delay per broadcast (ms): [217, 137, 57, 1382, 1302, 1222, 1142, 1063, 983, 903, 823, 743, 663, 583, 503, 424, 344, 264, 184, 105]
ruby 3.4.8, rails 8.1.4, solid_cable 4.0.2
written before the broadcasting thread finished: 20/20
write delay per broadcast (ms): [-20, -1, -2, -2, -2, -1, -1, -1, -2, -2, -1, -1, -1, -1, -1, -2, -1, -1, -2, -1]
With 4.0.2 the delay is negative only because the script records the broadcast time after broadcast returns.
What we tried on 4.1.0
We ran the same 20 broadcasts, from inside the Rails executor as a job runs:
| Change | Rows written before the thread finished | Notes |
|---|---|---|
| none | 0–3 of 20 | |
Thread.pass after each broadcast |
0 of 20 | |
sleep 0.001 after each broadcast |
20 of 20 | up to 0.73 s behind |
RUBY_THREAD_TIMESLICE=10 |
9 of 20 |
Each flush needs the GVL several times: to wake from queue.pop, to wait out writer_batch_delay, and before and after the insert's I/O. So the writer thread falls further behind for every broadcast. writer_batch_size and writer_batch_delay change the batching, but a broadcast still has to wait for the writer thread.
Possible directions
We haven't worked through these:
- an option that makes
broadcastwrite inline in the calling thread, as 4.0.2 did; - writing inline when the writer is idle, and batching only when broadcasts pile up.
This is separate from #91, which is about reading a row that commits after a higher id. The delay here is on the writing side.
We've pinned 4.0.2 for now. Thanks for solid_cable!
Activity
fix(chat): stop live replies ending cut off after a turn (#220)