Compare commits

..
Author SHA1 Message Date
copilot-swe-agent[bot] 02a3922595 Initial plan 2026-10-05 14:25:10 +00:00
Stanko Krtalić c73ea37e0c Merge pull request #300 from rubys/fix-autocomplete-json-accept
Ask for JSON when autocompleting people to ping
2026-10-05 16:17:10 +02:00
Stanko Krtalić e0e31846c9 Merge pull request #299 from rubys/fix-boost-soft-keyboard-action
Wire the inline boost link to the soft keyboard
2026-10-05 16:16:25 +02:00
Stanko Krtalić f2bdfde610 Merge pull request #298 from namespaceMarcello/search-operator-words
Search for operator words instead of parsing them
2026-10-05 16:15:23 +02:00
Stanko Krtalić acef0c71cf Merge pull request #295 from namespaceMarcello/messages-room-created-at-index
Index messages by room and creation time
2026-10-05 15:53:19 +02:00
David Heinemeier Hansson 345e63718f Update README with latest Campfire benchmark comparison (#315) 2026-10-05 07:52:23 -04:00
Marcello CostagliolaandClaude Opus 5.5 e12651240c Drop the query plan test
The index is a schema change; a test that pins SQLite's plan for each
pagination scope locks in more than the change needs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0142qgjggdJ2KDdGk7RF9Xm9
2026-10-05 13:51:56 +02:00
Sam RubyandClaude Opus 5.5 b8be941b99 Ask for JSON when autocompleting people to ping
`as: "json"` is a @rails/request.js option; plain fetch ignores it and
sends `Accept: */*`, so Autocompletable::UsersController answered with
its HTML format and response.json() threw. The new ping form never
showed any suggestions. Send an explicit Accept header instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 20:06:53 -04:00
Sam RubyandClaude Opus 5.5 c057484dfb Wire the inline boost link to the soft keyboard
link_to passed `action: "soft-keyboard#open"` as a plain option, so it
rendered as an `action` attribute, which Stimulus never reads. The link
in messages/_actions.html.erb already passes it under `data:`; this one
now does too, so tapping "Add a boost" on a touch device opens the
keyboard for the boost form as the menu's link does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 20:06:53 -04:00
Marcello CostagliolaandClaude Opus 5.5 74bdbb0764 Keep the room_id index and date the migration in UTC
The composite index doesn't serve everything the room_id one did. In
SQLite that index is (room_id, rowid), so it also reads a room's messages
in id order, and it is the narrower one to count a room with. Keeping both,
as memberships already does, leaves those lookups as they were.

The migration was named after local time, two hours ahead of the UTC
timestamp Rails generates; it now carries a UTC one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NuTtwJKhb77Dv7EQqv2C3C
2026-10-05 01:39:36 +02:00
Marcello CostagliolaandClaude Opus 5.5 6cdcb459b9 Search for operator words instead of parsing them
The search box keeps only word characters, but FTS5 still reads AND, OR,
NOT and NEAR in the query as operators, so searching for "AND" or for
"salt AND" raised "fts5: syntax error" and answered with a 500. Quoting
each word makes FTS5 look for it as text, which is how it already treats
the same words in lower case.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NuTtwJKhb77Dv7EQqv2C3C
2026-10-05 01:08:53 +02:00
Marcello CostagliolaandClaude Opus 5.5 5cb4316133 Index messages by room and creation time
Every page of a room's messages (opening the room, scrolling back, the
refresh after reconnecting, the bot API) selects WHERE room_id = ? ORDER BY
created_at LIMIT 40. With only the room_id index, SQLite reads every
message in the room and sorts them in a temporary B-tree to return 40, so
these requests grow with the room's history.

A composite index on (room_id, created_at) lets SQLite read the 40 rows in
order straight from the index. It covers every lookup the room_id index
served, so that one goes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NuTtwJKhb77Dv7EQqv2C3C
2026-10-05 00:55:24 +02:00
David Heinemeier Hansson 254dd1d46f Add Django and Laravel benchmarks and label the baseline Rails (#294) 2026-10-04 18:18:31 -04:00
David Heinemeier Hansson 6c7f8fa15f Document other Campfire implementations and benchmarks (#293)
* Document other Campfire implementations and benchmarks

* Remeasure README benchmarks with the latest Ruby optimizations

* Shorten the README benchmark context
2026-10-04 14:10:38 -04:00
David Heinemeier Hansson 659f95748a Preload only uncached messages and reduce rendering overhead (#292)
* Preload only uncached messages and reduce rendering overhead

* Keep benchmark summaries without raw JSON results

* Use Ruby benchmark drivers and keep generated results out of the repo
2026-10-04 12:36:36 -04:00
26 changed files with 651 additions and 28 deletions

No files matched your search

+15
View File
@@ -49,6 +49,21 @@ If you'd rather run the Docker image yourself, you can read more about that in t
> that people have someone to contact if they need help with their account. If that bothers you, put in any
> email address you want and create yourself a new admin account.
## Other implementations
Campfire also has implementations in Django, Laravel, Express, Elixir, Go and Rust:
| HTTP workload (requests/sec) | Rails | [Django](https://github.com/basecamp/once-campfire-django) | [Laravel](https://github.com/basecamp/once-campfire-laravel) | [Express](https://github.com/basecamp/once-campfire-express) | [Elixir](https://github.com/basecamp/once-campfire-elixir) | [Go](https://github.com/basecamp/once-campfire-go) | [Rust](https://github.com/basecamp/once-campfire-rust) |
|---|---:|---:|---:|---:|---:|---:|---:|
| Room page | 241 | 170 | 164 | 559 | 722 | 3,860 | 36,260 |
| Messages page | 413 | 196 | 175 | 777 | 1,053 | 5,573 | 40,872 |
| Sidebar | 552 | 615 | 715 | 4,125 | 1,275 | 19,753 | 34,672 |
| Search | 435 | 315 | 305 | 1,294 | 1,156 | 7,053 | 33,299 |
| Post a message | 273 | 154 | 137 | 256 | 801 | 4,767 | 6,896 |
Measured with 16 concurrent clients on an AMD Ryzen AI MAX+ 395,
with four hardware threads allocated to each app.
## Development
You are welcome - and encouraged - to modify Campfire to your liking.
+3 -3
View File
@@ -62,11 +62,11 @@ class MessagesController < ApplicationController
def find_paged_messages
case
when params[:before].present?
@room.messages.with_creator.page_before(@room.messages.find(params[:before]))
@room.messages.with_presentation.page_before(@room.messages.find(params[:before]))
when params[:after].present?
@room.messages.with_creator.page_after(@room.messages.find(params[:after]))
@room.messages.with_presentation.page_after(@room.messages.find(params[:after]))
else
@room.messages.with_creator.last_page
@room.messages.with_presentation.last_page
end
end
@@ -4,8 +4,8 @@ class Rooms::RefreshesController < ApplicationController
before_action :set_last_updated_at
def show
@new_messages = @room.messages.with_creator.page_created_since(@last_updated_at)
@updated_messages = @room.messages.without(@new_messages).with_creator.page_updated_since(@last_updated_at)
@new_messages = @room.messages.with_presentation.page_created_since(@last_updated_at)
@updated_messages = @room.messages.without(@new_messages).with_presentation.page_updated_since(@last_updated_at)
end
private
+2 -2
View File
@@ -44,9 +44,9 @@ class RoomsController < ApplicationController
end
def find_messages
messages = @room.messages.with_creator.with_attachment_details.with_boosts
messages = @room.messages.with_presentation
if show_first_message = messages.find_by(id: params[:message_id])
if params[:message_id].present? && (show_first_message = messages.find_by(id: params[:message_id]))
@messages = messages.page_around(show_first_message)
else
@messages = messages.last_page
+1 -1
View File
@@ -20,7 +20,7 @@ class SearchesController < ApplicationController
private
def set_messages
if query.present?
@messages = Current.user.reachable_messages.search(query).last(100)
@messages = Current.user.reachable_messages.search(query).with_presentation.last_page_of(100)
else
@messages = Message.none
end
+2 -7
View File
@@ -2,18 +2,13 @@ class Users::SidebarsController < ApplicationController
DIRECT_PLACEHOLDERS = 20
def show
all_memberships = Current.user.memberships.visible.with_ordered_room
@direct_memberships = extract_direct_memberships(all_memberships)
@other_memberships = all_memberships.without(@direct_memberships)
@direct_memberships, @other_memberships = Current.user.memberships.visible.with_ordered_room.partition { |membership| membership.room.direct? }
@direct_memberships = @direct_memberships.sort_by { |membership| membership.room.updated_at }.reverse
@direct_placeholder_users = find_direct_placeholder_users
end
private
def extract_direct_memberships(all_memberships)
all_memberships.select { |m| m.room.direct? }.sort_by { |m| m.room.updated_at }.reverse
end
def find_direct_placeholder_users
exclude_user_ids = user_ids_already_in_direct_rooms_with_current_user.including(Current.user.id)
User.active.where.not(id: exclude_user_ids).order(:created_at).limit([ DIRECT_PLACEHOLDERS - exclude_user_ids.count, 0 ].max)
@@ -120,7 +120,7 @@ export default class BaseAutocompleteHandler {
#fetchAutocompletables(url) {
if (url) {
return fetch(url, { as: "json" }).then(response => response.json())
return fetch(url, { headers: { Accept: "application/json" } }).then(response => response.json())
} else {
return Promise.resolve()
}
+3 -3
View File
@@ -14,11 +14,11 @@ class Message < ApplicationRecord
scope :ordered, -> { order(:created_at) }
scope :with_creator, -> { preload(creator: :avatar_attachment) }
scope :with_attachment_details, -> {
with_rich_text_body_and_embeds
with_attached_attachment
with_rich_text_body_and_embeds.with_attached_attachment
.includes(attachment_blob: :variant_records)
}
scope :with_boosts, -> { includes(boosts: :booster) }
scope :with_boosts, -> { includes(boosts: { booster: :avatar_attachment }) }
scope :with_presentation, -> { with_creator.with_attachment_details.with_boosts.preload(:room) }
def plain_text_body
body.to_plain_text.presence || attachment&.filename&.to_s || ""
+3 -1
View File
@@ -12,8 +12,10 @@ module Message::Broadcasts
# Fanned out to the room's members rather than published on one global stream, so
# that the timing of activity in a room only reaches people who are in it.
def broadcast_unread_room
payload = ActiveSupport::JSON.encode(roomId: room.id)
room.memberships.pluck(:user_id).each do |user_id|
ActionCable.server.broadcast UnreadRoomsChannel.stream_name_for(user_id), { roomId: room.id }
ActionCable.server.broadcast UnreadRoomsChannel.stream_name_for(user_id), payload, coder: nil
end
end
end
+29 -3
View File
@@ -3,9 +3,31 @@ module Message::Pagination
PAGE_SIZE = 40
# Expose Rails' collection-preloading hook while preserving Array pagination and
# validators. The renderer passes only cache misses to preload_associations.
class Page < Array
def self.load(relation, direction, size)
new(relation.skip_preloading!.public_send(direction, size), relation)
end
def initialize(records, relation)
super(records)
@relation = relation
end
def loaded?
true
end
def preload_associations(records)
@relation.preload_associations(records)
end
end
included do
scope :last_page, -> { ordered.last(PAGE_SIZE) }
scope :first_page, -> { ordered.first(PAGE_SIZE) }
# Keep presentation data lazy until the collection cache knows which messages missed.
scope :last_page, -> { last_page_of(PAGE_SIZE) }
scope :first_page, -> { Page.load(ordered, :first, PAGE_SIZE) }
scope :before, ->(message) { where("created_at < ?", message.created_at) }
scope :after, ->(message) { where("created_at > ?", message.created_at) }
@@ -18,8 +40,12 @@ module Message::Pagination
end
class_methods do
def last_page_of(size)
Page.load(ordered, :last, size)
end
def page_around(message)
page_before(message) + [ message ] + page_after(message)
Page.new(page_before(message) + [ message ] + page_after(message), ordered)
end
def paged?
+9 -1
View File
@@ -6,7 +6,15 @@ module Message::Searchable
after_update_commit :update_in_index
after_destroy_commit :remove_from_index
scope :search, ->(query) { joins("join message_search_index idx on messages.id = idx.rowid").where("idx.body match ?", query).ordered }
scope :search, ->(query) { joins("join message_search_index idx on messages.id = idx.rowid").where("idx.body match ?", match_terms(query)).ordered }
end
class_methods do
# Quotes each word, so that FTS5 searches for AND, OR, NOT and NEAR rather than
# parsing them as operators, which fails on a query like "AND" or "salt AND".
def match_terms(query)
query.split.map { |word| %("#{word.gsub('"', '""')}") }.join(" ")
end
end
private
+1 -1
View File
@@ -7,7 +7,7 @@
<%= turbo_frame_tag message, :new_boost do %>
<div class="flex-inline message__boost-inline" data-controller="soft-keyboard">
<%= link_to new_message_boost_path(message), class: "boost__action txt-small btn", action: "soft-keyboard#open" do %>
<%= link_to new_message_boost_path(message), class: "boost__action txt-small btn", data: { action: "soft-keyboard#open" } do %>
<%= image_tag "boost.svg", size: 20, aria: { hidden: "true" } %>
<span class="for-screen-reader">Add a boost</span>
<% end %>
+1 -1
View File
@@ -54,7 +54,7 @@
</div>
<%= search_results_tag do %>
<%= render @messages %>
<%= render partial: "messages/message", collection: @messages, cached: true %>
<% end %>
</div>
+26
View File
@@ -0,0 +1,26 @@
# Message benchmarks
The drivers use Ruby's standard library and Docker; no separate load generator is required.
Provide a frozen baseline source directory, an isolated seed containing `db/`, `storage/`
and `labels.json`, and a matching application image with compiled assets.
```sh
ruby bench/compare_message_hot_paths.rb --baseline PATH --baseline-ref SHA --seed SEED
ruby bench/compare_http.rb --baseline PATH --seed SEED
ruby bench/compare_http.rb --baseline PATH --seed SEED --paths sidebar,search --concurrencies 16 --duration 10
```
Both drivers alternate before/after order and reset fixture storage for each run.
Results default to ignored `tmp/rails-optimization/results/`; override with `--output PATH`.
Use `--image` to override `campfire-reference:app`, and `--cpus` to override server CPUs
`8-11`. HTTP clients use CPUs `12-15` by default (`--client-cpus`). `--help` lists options.
The rendering probe checks exact response bodies, selected headers and unread payloads,
and records timing, queries and allocations with MemoryStore, frozen time and fixture-only
CSRF disabling. Unread fanout excludes adapter I/O.
The HTTP driver uses production Puma/Redis with one worker and five threads. Ruby threads
each maintain a keep-alive connection, request uncompressed responses, and consume the
whole body. Login uses normal CSRF protection; all warmup and measured responses must be
HTTP 200 without transport errors. Measurements exclude Thruster, TLS and gzip. Client
CPU, JIT warmup and GC can affect throughput; repeat runs and check client saturation.
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env ruby
# Compare warm production Puma/Redis requests using isolated seeded containers.
require "socket"
require_relative "support"
require_relative "http_client"
include BenchmarkSupport
options = parse_options("Compare HTTP throughput with Ruby keep-alive clients; every response must be HTTP 200.",
duration: 3.0, paths: "room,messages,sidebar,search", concurrencies: "1,16", client_cpus: "12-15",
output: File.join(WORK, "results/http"))
labels = JSON.parse(File.read(File.join(options[:seed], "labels.json")))
paths = {
"room" => "/rooms/#{labels.fetch('rooms.watercooler')}",
"messages" => "/rooms/#{labels.fetch('rooms.watercooler')}/messages?before=#{labels.fetch('messages.busy_060')}",
"sidebar" => "/users/me/sidebar", "search" => "/searches?q=coffee"
}.slice(*options[:paths].split(","))
abort "--paths must select room,messages,sidebar,search" unless paths.size == options[:paths].split(",").size && !paths.empty?
concurrencies = options[:concurrencies].split(",").map { |value| Integer(value) }
abort "--duration and --concurrencies must be positive" unless options[:duration].positive? && !concurrencies.empty? && concurrencies.all?(&:positive?)
run("taskset", "-pc", options[:client_cpus], Process.pid.to_s)
work = File.join(WORK, "http")
assets = prepare_assets(options[:image])
network = "cf-ruby-bench-#{Process.pid}"
redis = "#{network}-redis"
app = "#{network}-app"
begin
run("docker", "network", "create", network)
run("docker", "run", "-d", "--name", redis, "--network", network, "redis:7-alpine")
options[:rounds].times do |iteration|
sides = iteration.even? ? %w[before after] : %w[after before]
sides.each do |side|
remove_container(app)
data = File.join(work, "data")
prepare_storage(options[:seed], File.join(data, "storage"))
FileUtils.mkdir_p([ File.join(data, "tmp/pids"), File.join(data, "log") ])
run("docker", "exec", redis, "redis-cli", "FLUSHALL")
port = TCPServer.open("127.0.0.1", 0) { |socket| socket.addr[1] }
client = BenchmarkHTTPClient.new("http://127.0.0.1:#{port}")
source = side == "before" ? options[:baseline] : ROOT
command = [ "docker", "run", "-d", "--name", app, "--entrypoint", "", "--network", network,
"--cpuset-cpus", options[:cpus], "-p", "127.0.0.1:#{port}:3000" ]
command.concat mounts(source => "/rails", File.join(data, "storage") => "/rails/storage",
File.join(data, "tmp") => "/rails/tmp", File.join(data, "log") => "/rails/log", assets => "/rails/public/assets")
command.concat environment(RAILS_ENV: "production", SECRET_KEY_BASE: "isolated-benchmark-fixture-key", DISABLE_SSL: true,
SKIP_TELEMETRY: true, RAILS_LOG_LEVEL: "fatal", WEB_CONCURRENCY: 1, JOB_CONCURRENCY: 1, RAILS_MAX_THREADS: 5,
REDIS_URL: "redis://#{redis}:6379/0")
command.concat [ options[:image], "bundle", "exec", "puma", "-C", "config/puma.rb" ]
run(*command)
deadline = clock + 45
until client.ready?
if clock > deadline
File.write(File.join(work, "server.log"), run("docker", "logs", app))
raise "server did not become ready; see #{work}/server.log"
end
sleep 0.1
end
cookie = client.login(labels)
results = {}
paths.each do |name, path|
client.measure(path, cookie, concurrency: 1, duration: 3)
concurrencies.each do |concurrency|
results["#{name}_#{concurrency}"] = client.measure(path, cookie, concurrency: concurrency, duration: options[:duration])
end
end
write_json(File.join(options[:output], "#{side}-#{iteration + 1}.json"), results)
puts "#{iteration + 1}/#{options[:rounds]}: #{side}"
end
end
ensure
remove_container(app)
remove_container(redis)
Open3.capture3("docker", "network", "rm", network)
end
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env ruby
# Compare frozen Rails sources with isolated fixtures and an existing Ruby image.
require "digest"
require_relative "support"
include BenchmarkSupport
options = parse_options("Compare rendering queries, allocations, timing and response parity.",
baseline_ref: nil, rounds: 4, output: File.join(WORK, "results/requests"))
work = File.join(WORK, "runtime")
FileUtils.mkdir_p([ File.join(work, "tmp"), File.join(work, "log") ])
assets = prepare_assets(options[:image])
runs = { "before" => [], "after" => [] }
order = []
options[:rounds].times do |iteration|
sides = iteration.even? ? %w[before after] : %w[after before]
order << sides
sides.each do |side|
storage = File.join(work, "storage")
prepare_storage(options[:seed], storage)
source = side == "before" ? options[:baseline] : ROOT
command = [ "docker", "run", "--rm", "--entrypoint", "", "--cpuset-cpus", options[:cpus] ]
command.concat mounts(source => "/rails", storage => "/rails/storage", File.join(work, "tmp") => "/rails/tmp",
File.join(work, "log") => "/rails/log", assets => "/rails/public/assets", File.join(ROOT, "bench") => "/bench",
File.join(options[:seed], "labels.json") => "/bench-labels.json")
command.concat environment(RAILS_ENV: "production", SECRET_KEY_BASE: "isolated-benchmark-fixture-key", DISABLE_SSL: true,
SKIP_TELEMETRY: true, RAILS_LOG_LEVEL: "fatal", BENCH_LABELS: "/bench-labels.json")
command.concat [ options[:image], "bundle", "exec", "ruby", "-r", "/rails/config/environment.rb", "/bench/message_hot_paths.rb" ]
data = JSON.parse(run(*command))
runs[side] << data
write_json(File.join(options[:output], "#{side}-#{iteration + 1}.json"), data)
puts "#{iteration + 1}/#{options[:rounds]}: #{side}"
end
end
summary = {}
runs["before"].first.fetch("results").each_key do |name|
values = runs.transform_values { |rows| rows.map { |row| row.fetch("results").fetch(name) } }
%w[body_sha256 headers payload_sha256].each do |field|
expected = values["before"].first[field]
raise "#{name}: #{field} differs; no winning summary written" unless values.values.flatten.all? { |row| row[field] == expected }
end
summary[name] = values.transform_values do |rows|
fields = %w[milliseconds allocations queries].select { |field| rows.first.key?(field) }
fields.to_h { |field| [ field, median(rows.map { |row| median(row.fetch(field)) }) ] }
.merge("round_medians_ms" => rows.map { |row| median(row.fetch("milliseconds")) })
end
summary[name]["speedup"] = summary[name]["before"]["milliseconds"] / summary[name]["after"]["milliseconds"]
end
metadata = {
baseline_sha: options[:baseline_ref], candidate_head: run("git", "-C", ROOT, "rev-parse", "HEAD").strip,
candidate_app_diff_sha256: Digest::SHA256.hexdigest(run("git", "-C", ROOT, "diff", "--", "app")),
platform: RUBY_PLATFORM, cpus: options[:cpus], image: options[:image],
image_id: run("docker", "image", "inspect", "-f", "{{.Id}}", options[:image]).strip,
body_and_selected_header_parity: "exact across all measured baseline/candidate requests",
order: order, ruby: runs["before"].first.fetch("ruby"), rails: runs["before"].first.fetch("rails"),
limits: "In-process Rails requests, MemoryStore, frozen clock and fixture-controller CSRF disabled. Fanout excludes adapter I/O. Not network throughput or connection capacity."
}
write_json(File.join(options[:output], "summary.json"), metadata: metadata, results: summary)
summary.each do |name, value|
puts "%s: %.2f -> %.2f ms (%.2fx)" % [ name, value["before"]["milliseconds"], value["after"]["milliseconds"], value["speedup"] ]
end
+102
View File
@@ -0,0 +1,102 @@
require "cgi"
require "net/http"
class BenchmarkHTTPClient
def initialize(base)
@base = URI(base)
end
def ready?
connection.start { |http| http.get("/up").code == "200" }
rescue IOError, SystemCallError, Timeout::Error, SocketError
false
end
def login(labels)
cookies = {}
connection.start do |http|
response = http.get("/session/new", "Accept-Encoding" => "identity")
raise "sign-in page: HTTP #{response.code}" unless response.code == "200"
merge_cookies(cookies, response)
token = response.body[/<meta name="csrf-token" content="([^"]*)"/, 1]
raise "sign-in page has no CSRF token" unless token
request = Net::HTTP::Post.new("/session")
request["Cookie"] = cookie_header(cookies)
request["Origin"] = @base.to_s
request["Sec-Fetch-Site"] = "same-origin"
request.set_form_data(email_address: labels.fetch("emails.david"), password: labels.fetch("passwords.all"),
authenticity_token: CGI.unescapeHTML(token))
response = http.request(request)
merge_cookies(cookies, response)
raise "login failed: HTTP #{response.code}" unless response.code == "302" && cookies.key?("session_token")
end
cookie_header(cookies)
end
def measure(path, cookie, concurrency:, duration:)
start = clock
deadline = start + duration
workers = Array.new(concurrency) do
Thread.new do
result = { latencies: [], statuses: Hash.new(0), bytes: 0, errors: 0 }
while clock < deadline
begin
connection.start do |http|
while clock < deadline
requested = clock
response = http.get(path, "Cookie" => cookie, "Accept-Encoding" => "identity")
result[:latencies] << (clock - requested) * 1000
result[:statuses][response.code] += 1
result[:bytes] += response.body.bytesize
end
end
rescue IOError, SystemCallError, Timeout::Error, SocketError, Net::HTTPBadResponse
result[:errors] += 1
sleep 0.01
end
end
result
end
end
samples = workers.map(&:value)
elapsed = clock - start
latencies = samples.flat_map { |sample| sample[:latencies] }.sort
statuses = Hash.new(0)
samples.each { |sample| sample[:statuses].each { |status, count| statuses[status] += count } }
errors = samples.sum { |sample| sample[:errors] }
raise "#{path}: HTTP statuses #{statuses}, #{errors} transport errors" unless errors.zero? && statuses.keys == [ "200" ]
{ path: path, conc: concurrency, gzip: false, secs: elapsed, rps: latencies.size / elapsed,
ok: latencies.size, statuses: statuses, errors: errors,
avg_bytes: samples.sum { |sample| sample[:bytes] } / latencies.size,
latency_ms: { p50: percentile(latencies, 0.50), p95: percentile(latencies, 0.95), p99: percentile(latencies, 0.99) } }
end
private
def connection
Net::HTTP.new(@base.host, @base.port, nil).tap do |http|
http.open_timeout = 1
http.read_timeout = 5
http.write_timeout = 5
http.max_retries = 0
end
end
def merge_cookies(cookies, response)
response.get_fields("set-cookie").to_a.each do |header|
name, value = header.split(";", 2).first.split("=", 2)
cookies[name] = value
end
end
def cookie_header(cookies)
cookies.map { |name, value| "#{name}=#{value}" }.join("; ")
end
def percentile(values, fraction)
values[(values.size * fraction).ceil - 1]
end
def clock
Process.clock_gettime(Process::CLOCK_MONOTONIC)
end
end
+92
View File
@@ -0,0 +1,92 @@
# Run through compare_message_hot_paths.rb against isolated production fixtures.
require "json"
require "digest"
require "active_support/testing/time_helpers"
include ActiveSupport::Testing::TimeHelpers
travel_to Time.utc(2026, 10, 4, 12)
# Keep framework/rendering measurements independent of Redis/network variance.
Rails.cache = ActiveSupport::Cache::MemoryStore.new
ActionView::PartialRenderer.collection_cache = Rails.cache
ApplicationController.cache_store = Rails.cache
ApplicationController.descendants.each do |controller|
controller.cache_store = Rails.cache
controller.allow_forgery_protection = false
end
ApplicationController.allow_forgery_protection = false
labels = JSON.parse(File.read(ENV.fetch("BENCH_LABELS")))
client = ActionDispatch::Integration::Session.new(Rails.application)
client.host! "campfire-benchmark.test"
client.post "/session", params: { email_address: labels.fetch("emails.david"), password: labels.fetch("passwords.all") }
raise "login failed" unless client.response.redirect?
paths = {
room: "/rooms/#{labels.fetch('rooms.watercooler')}",
messages: "/rooms/#{labels.fetch('rooms.watercooler')}/messages?before=#{labels.fetch('messages.busy_060')}",
sidebar: Rails.application.routes.url_helpers.user_sidebar_path,
search: "/searches?q=coffee"
}
queries = []
subscriber = ActiveSupport::Notifications.subscribe("sql.active_record") do |*, event|
queries << event[:sql] unless event[:cached] || event[:name] == "SCHEMA"
end
results = {}
paths.each do |name, path|
%w[cold warm].each do |cache|
2.times { client.get path }
times, counts, allocations, hashes, headers = [], [], [], [], []
Integer(ENV.fetch("BENCH_ITERATIONS", "20")).times do
Rails.cache.clear if cache == "cold"
queries.clear
start_allocations = GC.stat(:total_allocated_objects)
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
client.get path
times << (Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000
allocations << GC.stat(:total_allocated_objects) - start_allocations
raise "#{name}: HTTP #{client.response.status}" unless client.response.status == 200
counts << queries.length
hashes << Digest::SHA256.hexdigest(client.response.body)
headers << client.response.headers.slice("content-type", "etag", "cache-control", "vary")
end
results["#{name}_#{cache}"] = { milliseconds: times, queries: counts, allocations: allocations,
body_sha256: hashes.uniq, headers: headers.uniq, body_bytes: client.response.body.bytesize }
end
end
ActiveSupport::Notifications.unsubscribe(subscriber)
# Real Action Cable encoding/instrumentation, with adapter I/O removed. Keep fanout
# separate from network delivery capacity, which this probe does not measure.
room = Room.find(labels.fetch("rooms.watercooler"))
class BenchmarkPubsub
attr_reader :messages
def initialize
@messages = []
end
def broadcast(stream, payload)
@messages << [ stream, payload ]
end
end
adapter = BenchmarkPubsub.new
ActionCable.server.instance_variable_set(:@pubsub, adapter)
message = Message.new(room: room)
recipient_ids = (1..1000).to_a
memberships = Object.new
memberships.define_singleton_method(:pluck) { |_| recipient_ids }
room.define_singleton_method(:memberships) { memberships }
fanout_times, fanout_allocations = [], []
20.times do
adapter.messages.clear
allocated = GC.stat(:total_allocated_objects)
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
message.send(:broadcast_unread_room)
fanout_times << (Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000
fanout_allocations << GC.stat(:total_allocated_objects) - allocated
raise "wrong fanout count" unless adapter.messages.size == recipient_ids.size
raise "wrong payload" unless adapter.messages.all? { |_, body| JSON.parse(body) == { "roomId" => room.id } }
end
results["unread_fanout_1000"] = { milliseconds: fanout_times, allocations: fanout_allocations,
payload_sha256: Digest::SHA256.hexdigest(adapter.messages.first.last) }
puts JSON.pretty_generate({ ruby: RUBY_VERSION, rails: Rails.version, iterations: ENV.fetch("BENCH_ITERATIONS", "20").to_i,
cache: "MemoryStore", results: results })
+84
View File
@@ -0,0 +1,84 @@
require "fileutils"
require "json"
require "open3"
require "optparse"
module BenchmarkSupport
ROOT = File.expand_path("..", __dir__)
WORK = File.join(ROOT, "tmp/rails-optimization")
def parse_options(description, defaults)
options = { baseline: nil, seed: nil, image: "campfire-reference:app", cpus: "8-11", rounds: 2 }.merge(defaults)
parser = OptionParser.new do |parser|
parser.banner = "Usage: ruby #{$PROGRAM_NAME} --baseline PATH --seed PATH [options]\n#{description}"
options.each do |name, default|
type = case default
when Integer then Integer
when Float then Float
else String
end
parser.on("--#{name.to_s.tr('_', '-')} VALUE", type) { |value| options[name] = value }
end
parser.on("-h", "--help") { puts parser; exit }
end
parser.parse!
raise OptionParser::InvalidArgument, "unexpected arguments: #{ARGV.join(' ')}" unless ARGV.empty?
raise OptionParser::MissingArgument, "--baseline and --seed are required" unless options[:baseline] && options[:seed]
raise OptionParser::InvalidArgument, "--rounds must be even and at least 2" unless options[:rounds] >= 2 && options[:rounds].even?
%i[baseline seed output].each { |name| options[name] = File.expand_path(options.fetch(name)) }
options
rescue OptionParser::ParseError => error
abort "#{error.message}\n#{parser}"
end
def run(*command, input: nil)
output, errors, status = Open3.capture3(*command, stdin_data: input, binmode: true)
raise "#{command.first} failed (#{status.exitstatus}): #{errors}" unless status.success?
output
end
def remove_container(name)
Open3.capture3("docker", "rm", "-f", name)
end
def prepare_storage(seed, destination)
FileUtils.rm_rf(destination)
FileUtils.mkdir_p(destination)
FileUtils.cp_r(File.join(seed, "db"), File.join(destination, "db"))
FileUtils.cp_r(File.join(seed, "storage"), File.join(destination, "files"))
end
def prepare_assets(image)
assets = File.join(WORK, "runtime/assets")
FileUtils.mkdir_p(assets)
if Dir.empty?(assets)
archive = run("docker", "run", "--rm", "--entrypoint", "", image,
"tar", "-C", "/rails/public/assets", "-cf", "-", ".")
run("tar", "-xf", "-", "-C", assets, input: archive)
end
assets
end
def mounts(paths)
paths.flat_map { |host, target| [ "-v", "#{host}:#{target}" ] }
end
def environment(values)
values.flat_map { |name, value| [ "-e", "#{name}=#{value}" ] }
end
def write_json(path, data)
FileUtils.mkdir_p(File.dirname(path))
File.write(path, JSON.pretty_generate(data) + "\n")
end
def median(values)
values = values.sort
middle = values.length / 2
values.length.odd? ? values[middle] : (values[middle - 1] + values[middle]) / 2.0
end
def clock
Process.clock_gettime(Process::CLOCK_MONOTONIC)
end
end
@@ -0,0 +1,5 @@
class AddRoomIdAndCreatedAtIndexToMessages < ActiveRecord::Migration[8.2]
def change
add_index :messages, %i[ room_id created_at ]
end
end
Generated
+2 -1
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.2].define(version: 2025_12_12_154340) do
ActiveRecord::Schema[8.2].define(version: 2026_10_04_225000) do
create_table "accounts", force: :cascade do |t|
t.datetime "created_at", null: false
t.text "custom_styles"
@@ -101,6 +101,7 @@ ActiveRecord::Schema[8.2].define(version: 2025_12_12_154340) do
t.integer "room_id", null: false
t.datetime "updated_at", null: false
t.index ["creator_id"], name: "index_messages_on_creator_id"
t.index ["room_id", "created_at"], name: "index_messages_on_room_id_and_created_at"
t.index ["room_id"], name: "index_messages_on_room_id"
end
@@ -6,6 +6,13 @@ class Messages::BoostsControllerTest < ActionDispatch::IntegrationTest
@message = messages(:first)
end
test "index wires the new boost link to the soft keyboard" do
get message_boosts_url(@message)
assert_response :success
assert_select ".message__boost-inline a.boost__action[data-action='soft-keyboard#open']"
end
test "create" do
assert_turbo_stream_broadcasts [ @message.room, :messages ], count: 1 do
assert_difference -> { @message.boosts.count }, 1 do
+48
View File
@@ -0,0 +1,48 @@
require "test_helper"
require "active_record/testing/query_assertions"
class MessagesCachingTest < ActionDispatch::IntegrationTest
include ActiveRecord::Assertions::QueryAssertions
setup do
sign_in :david
end
test "cached pages skip presentation queries and refresh edited messages" do
with_memory_cache do
get room_messages_url(rooms(:watercooler))
assert_response :success
original = response.body
assert_no_queries_match(/action_text_rich_texts|active_storage_attachments|boosts/) do
get room_messages_url(rooms(:watercooler))
end
assert_response :success
assert_equal original, response.body
messages(:fourth).update! body: "Updated cached message"
get room_messages_url(rooms(:watercooler))
assert_response :success
assert_select "#" + dom_id(messages(:fourth)), text: /Updated cached message/
end
end
private
def with_memory_cache
old_cache = Rails.cache
old_collection_cache = ActionView::PartialRenderer.collection_cache
old_controller_cache = MessagesController.cache_store
old_caching = MessagesController.perform_caching
Rails.cache = ActiveSupport::Cache::MemoryStore.new
ActionView::PartialRenderer.collection_cache = Rails.cache
MessagesController.cache_store = Rails.cache
MessagesController.perform_caching = true
yield
ensure
Rails.cache = old_cache
ActionView::PartialRenderer.collection_cache = old_collection_cache
MessagesController.cache_store = old_controller_cache
MessagesController.perform_caching = old_caching
end
end
@@ -20,6 +20,24 @@ class SearchesControllerTest < ActionDispatch::IntegrationTest
assert_select ".message", text: /Hello world!/
end
test "operator words are searched for as words" do
rooms(:designers).messages.create! body: "Salt and pepper", client_message_id: "operators", creator: users(:david)
get searches_url, params: { q: "salt AND" }
assert_response :success
assert_select ".message", text: /Salt and pepper/
end
test "searching for an operator word alone doesn't fail" do
%w[ AND OR NOT ].each do |word|
get searches_url, params: { q: word }
assert_response :success
assert_select ".message", count: 0
end
end
test "unreachable messages are not found" do
memberships(:david_designers).destroy!
+45 -1
View File
@@ -1,7 +1,8 @@
require "test_helper"
require "active_record/testing/query_assertions"
class MessageTest < ActiveSupport::TestCase
include ActionCable::TestHelper, ActiveJob::TestHelper
include ActionCable::TestHelper, ActiveJob::TestHelper, ActiveRecord::Assertions::QueryAssertions
test "creating a message enqueues to push later" do
assert_enqueued_jobs 1, only: [ Room::PushMessageJob ] do
@@ -27,6 +28,49 @@ class MessageTest < ActiveSupport::TestCase
assert_equal [], message_mentioning_a_non_member.mentionees
end
test "presentation associations load together" do
messages(:first).attachment.attach io: StringIO.new("hello"), filename: "hello.txt", content_type: "text/plain"
presented = Message.where(id: [ messages(:first).id, messages(:thirteenth).id ]).with_presentation.to_a
assert_no_queries do
presented.each do |message|
message.room.name
message.creator.avatar.attached?
message.body.body.to_html
message.body.embeds.each(&:filename)
message.attachment.blob&.filename
message.boosts.each { |boost| boost.booster.avatar.attached? }
end
end
end
test "presentation pages preload only the rendered messages" do
page = rooms(:watercooler).messages.with_presentation.last_page
assert_kind_of Array, page
assert page.all? { |message| !message.association(:rich_text_body).loaded? }
rendered = page.first(2)
page.preload_associations(rendered)
assert_no_queries { rendered.each { |message| message.body.to_plain_text } }
assert page.drop(2).all? { |message| !message.association(:rich_text_body).loaded? }
end
test "pagination retains the original ordering even when timestamps tie" do
room = rooms(:watercooler)
room.messages.update_all(created_at: Time.current)
expected = room.messages.ordered.last(Message::Pagination::PAGE_SIZE).map(&:id)
assert_equal expected, room.messages.last_page.map(&:id)
assert_equal room.messages.ordered.first(Message::Pagination::PAGE_SIZE).map(&:id), room.messages.first_page.map(&:id)
end
test "search reads quotes and operator words in the query as text" do
message = rooms(:designers).messages.create!(body: "Say hi, NOT bye", client_message_id: "quoted", creator: users(:david))
assert_equal [ message ], Message.search(%(say "hi NOT)).to_a
end
private
def create_new_message_in(room)
room.messages.create!(creator: users(:jason), body: "Hello", client_message_id: "123")
+15
View File
@@ -0,0 +1,15 @@
require "application_system_test_case"
class StartingPingsTest < ApplicationSystemTestCase
setup do
sign_in "kevin@37signals.com"
end
test "suggesting people to ping" do
visit new_rooms_direct_url
find("[data-autocomplete-target='input']").send_keys("Jas")
assert_selector "suggestion-option[role='option']", text: "Jason"
end
end