Skip to content

4.1.0 writes broadcasts late when the broadcasting thread stays busy between them #92

Open

Description

@GermanDZ

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.shutdown

Output, 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 broadcast write 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!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions