Compare commits

...
10 Commits
Author SHA1 Message Date
Stanko K.R. 094ef7b462 Replace Redis with the Solid trifecta 2026-07-21 11:42:37 +02:00
Stanko K.R. df5ed25e14 Recommend the latest tag over main 2026-07-16 21:17:17 +02:00
Stanko K.R. 048ef90ae7 Add the missing development guide 2026-07-16 21:17:17 +02:00
Mike Dalessio ea994053ea Bump sqlite3 to 2.9.5 (#222)
to resolve security advisories
2026-07-16 02:13:05 -04:00
Mike Dalessio 018a0b5dd9 Bump websocket-driver to 0.8.2
Conservative lockfile-only bump clearing four advisories affecting
websocket-driver < 0.8.2: GHSA-2x63-gw47-w4mm, CVE-2026-54463,
CVE-2026-54464, CVE-2026-54465.

Notes: https://github.com/faye/websocket-driver-ruby/blob/main/CHANGELOG.md
2026-07-16 01:52:48 -04:00
Mike Dalessio 38741c53f1 Upgrade loofah to 2.25.2 and rails-html-sanitizer to 1.7.1 (#221)
Coordinated security releases; 1.7.1 requires loofah >= 2.25.2.

- https://github.com/flavorjones/loofah/releases/tag/v2.25.2
- https://github.com/rails/rails-html-sanitizer/releases/tag/v1.7.1
2026-07-16 00:51:35 -04:00
Stanko K.R. cf0a67fcaf Add a development guide 2026-07-15 13:43:14 +02:00
Stanko K.R. e945d4217b Explain how we tag docker images 2026-07-15 13:39:42 +02:00
Stanko K.R. cccfb8f774 Add backup and restore instructions 2026-07-15 13:34:09 +02:00
Stanko K.R. 55fbf16629 Improve self-hosting instructions 2026-07-15 13:29:13 +02:00
28 changed files with 1128 additions and 159 deletions

No files matched your search

-16
View File
@@ -71,12 +71,6 @@ jobs:
runs-on: ubuntu-latest
permissions:
contents: read
services:
redis:
image: redis # zizmor: ignore[unpinned-images] -- version tag is fine for service containers
ports:
- 6379:6379
options: --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5
steps:
- name: Install packages
run: sudo apt-get update && sudo apt-get install --no-install-recommends -y libsqlite3-0 libvips curl ffmpeg
@@ -88,8 +82,6 @@ jobs:
- name: Set up Ruby
uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0
env:
REDIS_URL: redis://localhost:6379/0
with:
ruby-version: .ruby-version
bundler-cache: true
@@ -101,12 +93,6 @@ jobs:
runs-on: ubuntu-latest
permissions:
contents: read
services:
redis:
image: redis # zizmor: ignore[unpinned-images] -- version tag is fine for service containers
ports:
- 6379:6379
options: --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5
steps:
- name: Install packages
run: sudo apt-get update && sudo apt-get install --no-install-recommends -y libsqlite3-0 libvips curl ffmpeg
@@ -118,8 +104,6 @@ jobs:
- name: Set up Ruby
uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0
env:
REDIS_URL: redis://localhost:6379/0
with:
ruby-version: .ruby-version
bundler-cache: true
+1 -1
View File
@@ -9,7 +9,7 @@ WORKDIR /rails
# Install base packages
RUN apt-get update -qq && \
apt-get install --no-install-recommends -y curl libsqlite3-0 libvips libjemalloc2 ffmpeg redis && \
apt-get install --no-install-recommends -y curl libsqlite3-0 libvips libjemalloc2 ffmpeg && \
ln -s /usr/lib/$(uname -m)-linux-gnu/libjemalloc.so.2 /usr/local/lib/libjemalloc.so && \
rm -rf /var/lib/apt/lists /var/cache/apt/archive
+4 -5
View File
@@ -9,14 +9,14 @@ gem "benchmark"
# Drivers
gem "sqlite3"
gem "redis", "~> 5.4"
# Deployment
gem "puma", "~> 7.2", ">= 7.2.1"
# Jobs
gem "resque", "~> 2.7.0"
gem "resque-pool", "~> 0.7.1"
# Jobs, caching, WebSockets
gem "solid_queue"
gem "solid_cache"
gem "solid_cable"
# Assets
gem "propshaft", github: "rails/propshaft"
@@ -41,7 +41,6 @@ gem "rails_autolink"
gem "geared_pagination"
gem "jbuilder"
gem "net-http-persistent"
gem "kredis"
gem "platform_agent"
gem "thruster"
+33 -46
View File
@@ -166,12 +166,17 @@ GEM
drb (2.2.3)
erb (6.0.4)
erubi (1.13.1)
et-orbi (1.4.0)
tzinfo
faker (3.5.2)
i18n (>= 1.8.11, < 2)
ffi (1.17.2-aarch64-linux-gnu)
ffi (1.17.2-arm64-darwin)
ffi (1.17.2-x86_64-darwin)
ffi (1.17.2-x86_64-linux-gnu)
fugit (1.13.0)
et-orbi (~> 1.4)
raabro (~> 1.4)
geared_pagination (1.2.0)
activesupport (>= 5.0)
addressable (>= 2.5.0)
@@ -194,14 +199,10 @@ GEM
json (2.20.0)
jwt (3.2.0)
base64
kredis (1.8.0)
activemodel (>= 6.0.0)
activesupport (>= 6.0.0)
redis (>= 4.2, < 6)
language_server-protocol (3.17.0.5)
lint_roller (1.1.0)
logger (1.7.0)
loofah (2.24.1)
loofah (2.25.2)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
mail (2.9.0)
@@ -218,10 +219,6 @@ GEM
minitest (5.26.2)
mocha (2.7.1)
ruby2_keywords (>= 0.0.5)
mono_logger (1.1.2)
multi_json (1.17.0)
mustermann (3.0.4)
ruby2_keywords (~> 0.0.1)
net-http-persistent (4.0.6)
connection_pool (~> 2.2, >= 2.2.4)
net-imap (0.6.4.1)
@@ -261,12 +258,9 @@ GEM
public_suffix (6.0.2)
puma (7.2.1)
nio4r (~> 2.0)
raabro (1.4.0)
racc (1.8.1)
rack (3.2.6)
rack-protection (4.2.1)
base64 (>= 0.1.0)
logger (>= 1.6.0)
rack (>= 3.0.0, < 4)
rack-session (2.1.2)
base64 (>= 0.1.0)
rack (>= 3.0.0)
@@ -278,8 +272,8 @@ GEM
activesupport (>= 5.0.0)
minitest
nokogiri (>= 1.6)
rails-html-sanitizer (1.6.2)
loofah (~> 2.21)
rails-html-sanitizer (1.7.1)
loofah (~> 2.25, >= 2.25.2)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
rails_autolink (1.1.8)
actionview (> 3.1)
@@ -291,23 +285,9 @@ GEM
erb
psych (>= 4.0.0)
tsort
redis (5.4.1)
redis-client (>= 0.22.0)
redis-client (0.25.2)
connection_pool
redis-namespace (1.11.0)
redis (>= 4)
regexp_parser (2.11.2)
reline (0.6.3)
io-console (~> 0.5)
resque (2.7.0)
mono_logger (~> 1)
multi_json (~> 1.0)
redis-namespace (~> 1.6)
sinatra (>= 0.9.2)
resque-pool (0.7.1)
rake (>= 10.0, < 14.0)
resque (>= 1.22, < 3)
rexml (3.4.4)
rqrcode (3.2.0)
chunky_png (~> 1.0)
@@ -360,17 +340,26 @@ GEM
sentry-ruby (5.26.0)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2)
sinatra (4.2.1)
logger (>= 1.6.0)
mustermann (~> 3.0)
rack (>= 3.0.0, < 4)
rack-protection (= 4.2.1)
rack-session (>= 2.0.0, < 3)
tilt (~> 2.0)
sqlite3 (2.7.3-aarch64-linux-gnu)
sqlite3 (2.7.3-arm64-darwin)
sqlite3 (2.7.3-x86_64-darwin)
sqlite3 (2.7.3-x86_64-linux-gnu)
solid_cable (4.0.2)
actioncable (>= 7.2)
activejob (>= 7.2)
activerecord (>= 7.2)
railties (>= 7.2)
solid_cache (1.0.10)
activejob (>= 7.2)
activerecord (>= 7.2)
railties (>= 7.2)
solid_queue (1.4.0)
activejob (>= 7.1)
activerecord (>= 7.1)
concurrent-ruby (>= 1.3.1)
fugit (~> 1.11)
railties (>= 7.1)
thor (>= 1.3.1)
sqlite3 (2.9.5-aarch64-linux-gnu)
sqlite3 (2.9.5-arm64-darwin)
sqlite3 (2.9.5-x86_64-darwin)
sqlite3 (2.9.5-x86_64-linux-gnu)
stimulus-rails (1.3.4)
railties (>= 6.0.0)
stringio (3.1.8)
@@ -379,7 +368,6 @@ GEM
thruster (0.1.15-arm64-darwin)
thruster (0.1.15-x86_64-darwin)
thruster (0.1.15-x86_64-linux)
tilt (2.6.1)
timeout (0.6.1)
tsort (0.2.0)
tzinfo (2.0.6)
@@ -397,7 +385,7 @@ GEM
crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0)
websocket (1.2.11)
websocket-driver (0.8.0)
websocket-driver (0.8.2)
base64
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
@@ -423,7 +411,6 @@ DEPENDENCIES
image_processing (>= 1.2)
importmap-rails!
jbuilder
kredis
mocha
net-http-persistent
ostruct
@@ -432,14 +419,14 @@ DEPENDENCIES
puma (~> 7.2, >= 7.2.1)
rails!
rails_autolink
redis (~> 5.4)
resque (~> 2.7.0)
resque-pool (~> 0.7.1)
rqrcode
rubocop-rails-omakase
selenium-webdriver
sentry-rails
sentry-ruby
solid_cable
solid_cache
solid_queue
sqlite3
stimulus-rails
thruster
+1 -2
View File
@@ -1,3 +1,2 @@
web: bundle exec thrust bin/start-app
redis: redis-server config/redis.conf
workers: FORK_PER_JOB=false INTERVAL=0.1 bundle exec resque-pool
workers: bundle exec bin/jobs
+28 -34
View File
@@ -11,51 +11,45 @@ expect, including:
- @mentions
- API, with support for bot integrations
## Deploying with Docker
## Running your own Campfire instance
Campfire's Docker image contains everything needed for a fully-functional,
single-machine deployment. This includes the web app, background jobs, caching,
file serving, and SSL.
file serving, and SSL. You can use our pre-built image at
`ghcr.io/basecamp/once-campfire:latest`, or build your own from this repo.
To persist storage of the database and file attachments, map a volume to `/rails/storage`.
### Deploying with ONCE
To configure additional features, you can set the following environment variables:
The easiest way to self-host Campfire is with [ONCE](https://github.com/basecamp/once).
It will guide you through the initial set up and then keep your instance up to date automatically.
- `SSL_DOMAIN` - enable automatic SSL via Let's Encrypt for the given domain name
- `DISABLE_SSL` - alternatively, set `DISABLE_SSL` to serve over plain HTTP
- `VAPID_PUBLIC_KEY`/`VAPID_PRIVATE_KEY` - set these to a valid keypair to
allow sending Web Push notifications. You can generate a new keypair by running
`/script/admin/create-vapid-key`
- `SENTRY_DSN` - to enable error reporting to sentry in production, supply your
DSN here
If you don't already have `once` installed, run this on the machine you want to run Campfire on:
For example:
```sh
curl https://get.once.com | sh
```
docker build -t campfire .
`once` will launch as soon as the install is finished.
docker run \
--publish 80:80 --publish 443:443 \
--restart unless-stopped \
--volume campfire:/rails/storage \
--env SECRET_KEY_BASE=$YOUR_SECRET_KEY_BASE \
--env VAPID_PUBLIC_KEY=$YOUR_PUBLIC_KEY \
--env VAPID_PRIVATE_KEY=$YOUR_PRIVATE_KEY \
--env TLS_DOMAIN=chat.example.com \
campfire
Choose Campfire from the list of applications, follow the instructions, and ONCE will take care of the rest.
## Running in development
If you prefer the command line to the dashboard, you can deploy directly:
bin/setup
bin/rails server
```sh
once deploy ghcr.io/basecamp/once-campfire --host chat.example.com
```
## Worth Noting
### Deploying with Docker
When you start Campfire for the first time, you’ll be guided through
creating an admin account.
The email address of this admin account will be shown on the login page
so that people who forget their password know who to contact for help.
(You can change this email later in the settings)
If you'd rather run the Docker image yourself, you can read more about that in the [self-hosting guide](docs/self-hosting.md).
Campfire is single-tenant: any rooms designated "public" will be accessible by
all users in the system. To support entirely distinct groups of customers, you
would deploy multiple instances of the application.
> [!TIP]
> When you start Campfire for the first time, you'll be guided through a wizard to create an admin account.
> The email address that you enter for the admin account will be visible on the sign-in page, it's there so
> 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.
## Development
You are welcome - and encouraged - to modify Campfire to your liking.
Please see our [development guide](docs/development.md) for how to get Campfire set up for local development.
Executable
+186
View File
@@ -0,0 +1,186 @@
#!/usr/bin/env ruby
require "optparse"
require "json"
require "net/http"
require "fileutils"
require "etc"
require "time"
options = {
users: 3000, ramp: 480, hold: 120, cpus: 2, memory: "2g",
send_interval: 1, sample: 25, build: true, port: 3000, image: "campfire-bench"
}
OptionParser.new do |parser|
parser.on("--label LABEL", "Run label (required), e.g. redis-2cpu-2g") { |v| options[:label] = v }
parser.on("--users N", Integer, "Ramp target (default 3000, max 10000)") { |v| options[:users] = v }
parser.on("--ramp SECONDS", Integer, "Ramp duration (default 480)") { |v| options[:ramp] = v }
parser.on("--hold SECONDS", Integer, "Hold duration at target (default 120)") { |v| options[:hold] = v }
parser.on("--cpus N", Integer, "App container CPU count via cpuset (default 2)") { |v| options[:cpus] = v }
parser.on("--memory SIZE", "App container memory limit (default 2g)") { |v| options[:memory] = v }
parser.on("--send-interval S", Float, "Seconds between messages (default 1)") { |v| options[:send_interval] = v }
parser.on("--sample N", Integer, "1-in-N receivers log latency lines (default 25)") { |v| options[:sample] = v }
parser.on("--port PORT", Integer, "Host port for the app (default 3000)") { |v| options[:port] = v }
parser.on("--image TAG", "Image tag (default campfire-bench)") { |v| options[:image] = v }
parser.on("--no-build", "Skip docker build") { options[:build] = false }
end.parse!
abort "--label is required" unless options[:label]
class Bench
APP_CONTAINER = "campfire-bench-app"
K6_CONTAINER = "campfire-bench-k6"
attr_reader :opts, :results_dir
def initialize(opts)
@opts = opts
@root = File.expand_path("..", __dir__)
@perf_dir = File.join(@root, "test/performance")
@results_dir = File.join(@root, "tmp/bench", opts[:label])
end
def run
FileUtils.mkdir_p(results_dir)
build if opts[:build]
write_meta
start_app
wait_for_app
sampler = start_stats_sampler
run_k6
ensure
stop(sampler)
capture_app_state
cleanup
end
private
def build
sh "docker", "build", "-t", opts[:image], @root
end
def write_meta
meta = opts.merge(
git_revision: `git rev-parse HEAD`.strip,
git_branch: `git rev-parse --abbrev-ref HEAD`.strip,
host_cpus: Etc.nprocessors,
started_at: Time.now.utc.iso8601
)
File.write(File.join(results_dir, "meta.json"), JSON.pretty_generate(meta))
end
def start_app
system "docker", "rm", "-f", APP_CONTAINER, err: File::NULL, out: File::NULL
sh "docker", "run", "-d", "--name", APP_CONTAINER,
"--cpuset-cpus", app_cpuset,
"--memory", opts[:memory], "--memory-swap", opts[:memory],
"-p", "#{opts[:port]}:80",
"-e", "SECRET_KEY_BASE=dummy",
"-e", "RAILS_ENV=performance",
opts[:image]
end
def app_cpuset
(0...opts[:cpus]).to_a.join(",")
end
def wait_for_app
timeout_at = Time.now + 300
uri = URI("http://127.0.0.1:#{opts[:port]}/")
loop do
raise "App not up after 300s" if Time.now > timeout_at
begin
break if Net::HTTP.get_response(uri).code.to_i < 400
rescue Errno::ECONNREFUSED, Errno::ECONNRESET, EOFError
end
puts "Waiting for app (seeding 10k users on first boot)..."
sleep 2
end
puts "App is up."
end
def start_stats_sampler
stats_path = File.join(results_dir, "stats.csv")
fork do
File.open(stats_path, "w") do |file|
file.puts "epoch_ms,cpu_perc,mem_bytes"
file.sync = true
loop do
line = `docker stats --no-stream --format "{{.CPUPerc}} {{.MemUsage}}" #{APP_CONTAINER} 2>/dev/null`.strip
if line =~ /([\d.]+)%\s+([\d.]+)(\w+)/
file.puts "#{(Time.now.to_f * 1000).to_i},#{$1},#{to_bytes($2.to_f, $3)}"
end
sleep 1
end
end
end
end
def to_bytes(value, unit)
factors = { "B" => 1, "KiB" => 1024, "MiB" => 1024**2, "GiB" => 1024**3,
"kB" => 1000, "MB" => 1000**2, "GB" => 1000**3 }
(value * factors.fetch(unit, 1)).to_i
end
def run_k6
total = opts[:ramp] + opts[:hold]
puts "Running k6: ramp to #{opts[:users]} users over #{opts[:ramp]}s, hold #{opts[:hold]}s (total #{total}s)..."
system "docker", "rm", "-f", K6_CONTAINER, err: File::NULL, out: File::NULL
k6_log = File.join(results_dir, "k6.log")
summary = File.join(results_dir, "summary.json")
finished = system(
"docker", "run", "--rm", "--name", K6_CONTAINER,
"--network", "host",
"--cpuset-cpus", k6_cpuset,
"--ulimit", "nofile=262144:262144",
"-u", "#{Process.uid}:#{Process.gid}",
"-v", "#{@perf_dir}:/src",
"-v", "#{results_dir}:/results",
"-e", "HOST=127.0.0.1", "-e", "PORT=#{opts[:port]}",
"-e", "USERS=#{opts[:users]}",
"-e", "RAMP_S=#{opts[:ramp]}", "-e", "HOLD_S=#{opts[:hold]}",
"-e", "SEND_INTERVAL_S=#{opts[:send_interval]}",
"-e", "SAMPLE=#{opts[:sample]}",
"grafana/k6", "run",
"--summary-export", "/results/summary.json",
"--quiet",
"/src/ramp.js",
out: k6_log, err: [ k6_log, "a" ]
)
if finished
puts "k6 finished."
else
puts "k6 exited non-zero — results still captured in #{k6_log}."
end
end
def k6_cpuset
(opts[:cpus]...Etc.nprocessors).to_a.join(",")
end
def stop(sampler)
Process.kill("TERM", sampler)
Process.wait(sampler)
rescue Errno::ESRCH, Errno::ECHILD
nil
end
def capture_app_state
oom = `docker inspect --format '{{.State.OOMKilled}}' #{APP_CONTAINER} 2>/dev/null`.strip
File.write(File.join(results_dir, "oom.txt"), oom)
system "docker logs --tail 200 #{APP_CONTAINER} > #{File.join(results_dir, 'app.log')} 2>&1"
puts "App container was OOM-killed during the run." if oom == "true"
end
def cleanup
system "docker", "rm", "-f", APP_CONTAINER, err: File::NULL, out: File::NULL
end
def sh(*cmd)
puts "+ #{cmd.join(' ')}"
system(*cmd) || abort("Command failed: #{cmd.join(' ')}")
end
end
Bench.new(options).run
puts "Results in tmp/bench/#{options[:label]}/"
+160
View File
@@ -0,0 +1,160 @@
#!/usr/bin/env ruby
require "json"
require "csv"
BIN_MS = 10_000
SLO_P95_MS = 500
class Run
attr_reader :label, :dir
def initialize(label)
@label = label
@dir = File.expand_path("../tmp/bench/#{label}", __dir__)
abort "No results in tmp/bench/#{label}" unless File.exist?(File.join(dir, "k6.log"))
end
def report
write_series
print_summary
end
def series
@series ||= build_series
end
def meta
@meta ||= JSON.parse(File.read(File.join(dir, "meta.json")))
end
def summary
@summary ||= JSON.parse(File.read(File.join(dir, "summary.json")))
rescue Errno::ENOENT
{}
end
def capacity
breach = first_sustained_breach
if breach
breach[:connected]
else
nil
end
end
private
def build_series
events = parse_k6_log
stats = parse_stats
first_ts = events.map { |e| e[:ts] }.min
return [] unless first_ts
bins = Hash.new { |h, k| h[k] = { lats: [], conns: 0, sents: 0, errs: 0, cpu: [], mem: [] } }
events.each do |event|
bin = (event[:ts] - first_ts) / BIN_MS
case event[:kind]
when "CONN" then bins[bin][:conns] += 1
when "LAT" then bins[bin][:lats] << event[:value]
when "SENT" then bins[bin][:sents] += 1
when "ERR" then bins[bin][:errs] += 1
end
end
stats.each do |stat|
bin = (stat[:ts] - first_ts) / BIN_MS
next if bin < 0
bins[bin][:cpu] << stat[:cpu]
bins[bin][:mem] << stat[:mem]
end
connected = 0
(0..bins.keys.max).map do |bin|
data = bins[bin]
connected = [ connected + data[:conns], meta["users"] ].min
{
elapsed_s: bin * BIN_MS / 1000,
connected: connected,
sent: data[:sents],
samples: data[:lats].size,
lat_p50: percentile(data[:lats], 50),
lat_p95: percentile(data[:lats], 95),
lat_max: data[:lats].max,
errors: data[:errs],
cpu_perc: average(data[:cpu]),
mem_mb: average(data[:mem])&.then { |bytes| (bytes / 1024.0 / 1024.0).round(1) }
}
end
end
def parse_k6_log
events = []
File.foreach(File.join(dir, "k6.log")) do |line|
if line =~ /BENCH (CONN|LAT|SENT|ERR) (\d+)(?: (\S+))?/
events << { kind: $1, ts: $2.to_i, value: $3.to_i }
end
end
events
end
def parse_stats
path = File.join(dir, "stats.csv")
return [] unless File.exist?(path)
CSV.read(path, headers: true).map do |row|
{ ts: row["epoch_ms"].to_i, cpu: row["cpu_perc"].to_f, mem: row["mem_bytes"].to_i }
end
end
def percentile(values, pct)
return nil if values.empty?
sorted = values.sort
sorted[((pct / 100.0) * (sorted.size - 1)).round]
end
def average(values)
return nil if values.empty?
(values.sum / values.size.to_f).round(1)
end
def write_series
CSV.open(File.join(dir, "series.csv"), "w") do |csv|
csv << series.first.keys
series.each { |row| csv << row.values }
end
end
def first_sustained_breach
series.each_cons(2) do |a, b|
if breached?(a) && breached?(b)
return a
end
end
nil
end
def breached?(bin)
if bin[:samples] > 0 && bin[:lat_p95] && bin[:lat_p95] > SLO_P95_MS
true
else
bin[:errors] > 0
end
end
def print_summary
latency = summary.dig("metrics", "delivery_latency") || {}
puts
puts "== #{label} (#{meta['cpus']} CPU, #{meta['memory']} RAM, ramp to #{meta['users']}) =="
puts " delivery latency: p50=#{latency['med']&.round(1)}ms p95=#{latency['p(95)']&.round(1)}ms max=#{latency['max']&.round(1)}ms"
puts " messages received: #{summary.dig('metrics', 'bench_messages_received', 'count')}"
puts " socket errors: #{summary.dig('metrics', 'bench_socket_errors', 'count') || 0}"
peak = series.map { |bin| bin[:mem_mb] }.compact.max
puts " peak container memory: #{peak} MB"
if capacity
puts " capacity at SLO (p95 <= #{SLO_P95_MS}ms, no errors): ~#{capacity} concurrent users"
else
puts " capacity at SLO: not reached (>= #{series.last[:connected]} concurrent users)"
end
puts " series: tmp/bench/#{label}/series.csv"
end
end
abort "Usage: bin/bench-report LABEL [LABEL...]" if ARGV.empty?
ARGV.each { |label| Run.new(label).report }
Executable
+6
View File
@@ -0,0 +1,6 @@
#!/usr/bin/env ruby
require_relative "../config/environment"
require "solid_queue/cli"
SolidQueue::Cli.start(ARGV)
-22
View File
@@ -8,9 +8,6 @@ app_root="$(
)"
export PATH="$app_root/bin:$PATH"
REDIS_PORT=6379
REDIS_HOST=localhost
if [ "$RAILS_ENV" = "production" ]; then
echo "RAILS_ENV is production; bailing out"
exit 1
@@ -45,10 +42,6 @@ step() {
return $exit_code
}
redis_running() {
nc -z "$REDIS_HOST" "$REDIS_PORT" 2>/dev/null
}
echo
gum style --foreground 214 " ) "
gum style --foreground 208 " ) \\ campfire"
@@ -88,21 +81,6 @@ if [[ $* == *--reset* ]]; then
fi
step "Preparing the database" rails db:prepare
# Start Redis if not running
if ! redis_running; then
if command -v docker &>/dev/null; then
if docker ps -aq -f name=campfire-redis | grep -q .; then
step "Starting Redis" docker start campfire-redis
else
step "Setting up Redis" docker run -d --name campfire-redis -p "$REDIS_PORT:$REDIS_PORT" redis:7
fi
else
echo "Couldn't start Redis"
echo "Install either docker or redis and then run this command again"
exit 1
fi
fi
# Install GitHub Actions linting tools
for tool in actionlint shellcheck zizmor; do
if ! command -v "$tool" &> /dev/null; then
+17 -10
View File
@@ -1,18 +1,25 @@
default: &default
adapter: redis
url: redis://localhost:6379
# Async adapter only works within the same process, so for manually triggering cable updates from a console,
# and seeing results in the browser, you must do so from the web console (running inside the dev process),
# not a terminal started via bin/rails console! Add "console" to any action or any ERB template view
# to make the web console appear.
development:
<<: *default
channel_prefix: campfire_development
adapter: async
test:
adapter: test
performance:
<<: *default
channel_prefix: campfire_performance
adapter: solid_cable
connects_to:
database:
writing: cable
polling_interval: 0.05.seconds
message_retention: 1.day
production:
<<: *default
channel_prefix: campfire_production
adapter: solid_cable
connects_to:
database:
writing: cable
polling_interval: 0.05.seconds
message_retention: 1.day
+21
View File
@@ -0,0 +1,21 @@
default: &default
store_options:
# Cap age of oldest cache entry to fulfill retention policies
# max_age: <%= 60.days.to_i %>
max_size: <%= 256.megabytes %>
namespace: <%= Rails.env %>
development:
database: cache
<<: *default
test:
<<: *default
performance:
database: cache
<<: *default
production:
database: cache
<<: *default
+28
View File
@@ -14,6 +14,10 @@ development:
primary:
<<: *default
database: storage/db/development.sqlite3
cache:
<<: *default
database: storage/db/development-cache.sqlite3
migrations_paths: db/cache_migrate
# Warning: The database defined as "test" will be erased and
# re-generated from your development database when you run "rake".
@@ -27,8 +31,32 @@ performance:
primary:
<<: *default
database: storage/db/performance.sqlite3
queue:
<<: *default
database: storage/db/performance-queue.sqlite3
migrations_paths: db/queue_migrate
cache:
<<: *default
database: storage/db/performance-cache.sqlite3
migrations_paths: db/cache_migrate
cable:
<<: *default
database: storage/db/performance-cable.sqlite3
migrations_paths: db/cable_migrate
production:
primary:
<<: *default
database: storage/db/production.sqlite3
queue:
<<: *default
database: storage/db/production-queue.sqlite3
migrations_paths: db/queue_migrate
cache:
<<: *default
database: storage/db/production-cache.sqlite3
migrations_paths: db/cache_migrate
cable:
<<: *default
database: storage/db/production-cable.sqlite3
migrations_paths: db/cable_migrate
+2 -2
View File
@@ -21,7 +21,7 @@ Rails.application.configure do
config.action_controller.perform_caching = true
config.action_controller.enable_fragment_cache_logging = true
config.cache_store = :redis_cache_store
config.cache_store = :solid_cache_store
config.public_file_server.headers = {
"cache-control" => "public, max-age=#{2.days.to_i}"
}
@@ -65,7 +65,7 @@ Rails.application.configure do
# config.action_cable.disable_request_forgery_protection = true
# Uncomment to test with production class job queue
# config.active_job.queue_adapter = :resque
# config.active_job.queue_adapter = :solid_queue
# Highlight code that enqueued background job in logs.
config.active_job.verbose_enqueue_logs = true
+3 -2
View File
@@ -68,7 +68,7 @@ Rails.application.configure do
config.active_support.report_deprecations = false
# Cache in memory for now
config.cache_store = :redis_cache_store
config.cache_store = :solid_cache_store
# Assets are cacheable
config.public_file_server.headers = {
@@ -92,5 +92,6 @@ Rails.application.configure do
# Only use :id for inspections in production.
config.active_record.attributes_for_inspect = [ :id ]
config.active_job.queue_adapter = :resque
config.active_job.queue_adapter = :solid_queue
config.solid_queue.connects_to = { database: { writing: :queue } }
end
-2
View File
@@ -35,8 +35,6 @@ pidfile ENV.fetch("PIDFILE") { "tmp/pids/server.pid" }
worker_count = (Concurrent.processor_count * 0.666).ceil
workers ENV.fetch("WEB_CONCURRENCY") { worker_count }
ENV["JOB_CONCURRENCY"] ||= worker_count.to_s
# Use the `preload_app!` method when specifying a `workers` number.
# This directive tells Puma to first boot the application and load code
# before forking the application. This takes advantage of Copy On Write
+21
View File
@@ -0,0 +1,21 @@
default: &default
dispatchers:
- polling_interval: 1
batch_size: 500
workers:
- queues: "*"
threads: 3
processes: <%= ENV.fetch("JOB_CONCURRENCY") { (Concurrent.processor_count * 0.5).ceil } %>
polling_interval: 0.1
development:
<<: *default
test:
<<: *default
performance:
<<: *default
production:
<<: *default
+15
View File
@@ -0,0 +1,15 @@
# examples:
# periodic_cleanup:
# class: CleanSoftDeletedRecordsJob
# queue: background
# args: [ 1000, { batch_size: 500 } ]
# schedule: every hour
# periodic_cleanup_with_command:
# command: "SoftDeletedRecord.due.delete_all"
# priority: 2
# schedule: at 5am every day
production:
clear_solid_queue_finished_jobs:
command: "SolidQueue::Job.clear_finished_in_batches(sleep_between_batches: 0.3)"
schedule: every hour at minute 12
-4
View File
@@ -1,4 +0,0 @@
port 6379
daemonize no
appendonly no
save ""
-1
View File
@@ -1 +0,0 @@
default: <%= (Concurrent.processor_count * 0.5).ceil %>
+11
View File
@@ -0,0 +1,11 @@
ActiveRecord::Schema[7.1].define(version: 1) 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"], name: "index_solid_cable_messages_on_channel"
t.index ["channel_hash"], name: "index_solid_cable_messages_on_channel_hash"
t.index ["created_at"], name: "index_solid_cable_messages_on_created_at"
end
end
+12
View File
@@ -0,0 +1,12 @@
ActiveRecord::Schema[7.2].define(version: 1) do
create_table "solid_cache_entries", force: :cascade do |t|
t.binary "key", limit: 1024, null: false
t.binary "value", limit: 536870912, null: false
t.datetime "created_at", null: false
t.integer "key_hash", limit: 8, null: false
t.integer "byte_size", limit: 4, null: false
t.index ["byte_size"], name: "index_solid_cache_entries_on_byte_size"
t.index ["key_hash", "byte_size"], name: "index_solid_cache_entries_on_key_hash_and_byte_size"
t.index ["key_hash"], name: "index_solid_cache_entries_on_key_hash", unique: true
end
end
+129
View File
@@ -0,0 +1,129 @@
ActiveRecord::Schema[7.1].define(version: 1) do
create_table "solid_queue_blocked_executions", force: :cascade do |t|
t.bigint "job_id", null: false
t.string "queue_name", null: false
t.integer "priority", default: 0, null: false
t.string "concurrency_key", null: false
t.datetime "expires_at", null: false
t.datetime "created_at", null: false
t.index [ "concurrency_key", "priority", "job_id" ], name: "index_solid_queue_blocked_executions_for_release"
t.index [ "expires_at", "concurrency_key" ], name: "index_solid_queue_blocked_executions_for_maintenance"
t.index [ "job_id" ], name: "index_solid_queue_blocked_executions_on_job_id", unique: true
end
create_table "solid_queue_claimed_executions", force: :cascade do |t|
t.bigint "job_id", null: false
t.bigint "process_id"
t.datetime "created_at", null: false
t.index [ "job_id" ], name: "index_solid_queue_claimed_executions_on_job_id", unique: true
t.index [ "process_id", "job_id" ], name: "index_solid_queue_claimed_executions_on_process_id_and_job_id"
end
create_table "solid_queue_failed_executions", force: :cascade do |t|
t.bigint "job_id", null: false
t.text "error"
t.datetime "created_at", null: false
t.index [ "job_id" ], name: "index_solid_queue_failed_executions_on_job_id", unique: true
end
create_table "solid_queue_jobs", force: :cascade do |t|
t.string "queue_name", null: false
t.string "class_name", null: false
t.text "arguments"
t.integer "priority", default: 0, null: false
t.string "active_job_id"
t.datetime "scheduled_at"
t.datetime "finished_at"
t.string "concurrency_key"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index [ "active_job_id" ], name: "index_solid_queue_jobs_on_active_job_id"
t.index [ "class_name" ], name: "index_solid_queue_jobs_on_class_name"
t.index [ "finished_at" ], name: "index_solid_queue_jobs_on_finished_at"
t.index [ "queue_name", "finished_at" ], name: "index_solid_queue_jobs_for_filtering"
t.index [ "scheduled_at", "finished_at" ], name: "index_solid_queue_jobs_for_alerting"
end
create_table "solid_queue_pauses", force: :cascade do |t|
t.string "queue_name", null: false
t.datetime "created_at", null: false
t.index [ "queue_name" ], name: "index_solid_queue_pauses_on_queue_name", unique: true
end
create_table "solid_queue_processes", force: :cascade do |t|
t.string "kind", null: false
t.datetime "last_heartbeat_at", null: false
t.bigint "supervisor_id"
t.integer "pid", null: false
t.string "hostname"
t.text "metadata"
t.datetime "created_at", null: false
t.string "name", null: false
t.index [ "last_heartbeat_at" ], name: "index_solid_queue_processes_on_last_heartbeat_at"
t.index [ "name", "supervisor_id" ], name: "index_solid_queue_processes_on_name_and_supervisor_id", unique: true
t.index [ "supervisor_id" ], name: "index_solid_queue_processes_on_supervisor_id"
end
create_table "solid_queue_ready_executions", force: :cascade do |t|
t.bigint "job_id", null: false
t.string "queue_name", null: false
t.integer "priority", default: 0, null: false
t.datetime "created_at", null: false
t.index [ "job_id" ], name: "index_solid_queue_ready_executions_on_job_id", unique: true
t.index [ "priority", "job_id" ], name: "index_solid_queue_poll_all"
t.index [ "queue_name", "priority", "job_id" ], name: "index_solid_queue_poll_by_queue"
end
create_table "solid_queue_recurring_executions", force: :cascade do |t|
t.bigint "job_id", null: false
t.string "task_key", null: false
t.datetime "run_at", null: false
t.datetime "created_at", null: false
t.index [ "job_id" ], name: "index_solid_queue_recurring_executions_on_job_id", unique: true
t.index [ "task_key", "run_at" ], name: "index_solid_queue_recurring_executions_on_task_key_and_run_at", unique: true
end
create_table "solid_queue_recurring_tasks", force: :cascade do |t|
t.string "key", null: false
t.string "schedule", null: false
t.string "command", limit: 2048
t.string "class_name"
t.text "arguments"
t.string "queue_name"
t.integer "priority", default: 0
t.boolean "static", default: true, null: false
t.text "description"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index [ "key" ], name: "index_solid_queue_recurring_tasks_on_key", unique: true
t.index [ "static" ], name: "index_solid_queue_recurring_tasks_on_static"
end
create_table "solid_queue_scheduled_executions", force: :cascade do |t|
t.bigint "job_id", null: false
t.string "queue_name", null: false
t.integer "priority", default: 0, null: false
t.datetime "scheduled_at", null: false
t.datetime "created_at", null: false
t.index [ "job_id" ], name: "index_solid_queue_scheduled_executions_on_job_id", unique: true
t.index [ "scheduled_at", "priority", "job_id" ], name: "index_solid_queue_dispatch_all"
end
create_table "solid_queue_semaphores", force: :cascade do |t|
t.string "key", null: false
t.integer "value", default: 1, null: false
t.datetime "expires_at", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index [ "expires_at" ], name: "index_solid_queue_semaphores_on_expires_at"
t.index [ "key", "value" ], name: "index_solid_queue_semaphores_on_key_and_value"
t.index [ "key" ], name: "index_solid_queue_semaphores_on_key", unique: true
end
add_foreign_key "solid_queue_blocked_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
add_foreign_key "solid_queue_claimed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
add_foreign_key "solid_queue_failed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
add_foreign_key "solid_queue_ready_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
add_foreign_key "solid_queue_recurring_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
add_foreign_key "solid_queue_scheduled_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
end
+69
View File
@@ -0,0 +1,69 @@
## Development
### Setting up
First, get everything installed and configured with:
```sh
bin/setup
```
This installs the system packages Campfire needs (SQLite, ffmpeg), the right Ruby version (via [mise](https://mise.jdx.dev)), and the app's gems; prepares the database; and starts Redis (in a Docker container called `campfire-redis`, if it isn't already running locally).
If you want to start over at any point, run:
```sh
bin/setup --reset
```
### Running the server
Start the development server with:
```sh
bin/dev
```
You'll be able to access the app at http://localhost:3000.
On first run you'll be guided through creating your admin account, and you can sign in with that account from then on.
Note that Campfire needs Redis (for Action Cable, caching, and background jobs), so if you've restarted your machine or stopped the container, `docker start campfire-redis` will bring it back.
### Web Push notifications
Campfire uses VAPID (Voluntary Application Server Identification) keys to send browser push notifications. For notifications to work in development you'll need to generate a key pair and set these environment variables:
- `VAPID_PRIVATE_KEY`
- `VAPID_PUBLIC_KEY`
You can generate a fresh pair (along with a secret key base, which you can ignore in development) by running:
```sh
script/admin/generate-secrets
```
### Running tests
Run the unit tests with:
```sh
bin/rails test
```
And the browser-based system tests with:
```sh
bin/rails test:system
```
Before pushing your changes, you can run the full CI suite locally - style checks, security audits, and all the tests - with a single command:
```sh
bin/ci
```
### Contributing
You are welcome - and encouraged - to modify Campfire to your liking.
If you'd like to contribute your changes back, please read our [contributing guide](../CONTRIBUTING.md) first.
+213
View File
@@ -0,0 +1,213 @@
## Self-hosting Campfire
Campfire's Docker image contains everything needed for a fully-functional, single-machine deployment.
This includes the web app, background jobs, caching, file serving, and SSL.
> [!TIP]
> The easiest way to self-host Campfire is with [ONCE](https://github.com/basecamp/once), which handles installation, updates, and backups for you. See the [README](../README.md#deploying-with-once) for details. This guide covers running the Docker image by hand.
We recommend using `ghcr.io/basecamp/once-campfire:latest`, which always points to the most recent tagged release - the most stable and battle-tested version of Campfire.
We provide a tagged release for every major, minor and patch version of Campfire, so you can also pin your deployment to a specific version if you want to avoid unexpected changes. For example:
```bash
# exactly version 1.4.4
ghcr.io/basecamp/once-campfire:1.4.4
# any 1.4.x version
# e.g. 1.4.4 or 1.4.5
# the last number is usually changed for bug fixes
ghcr.io/basecamp/once-campfire:1.4
# any 1.x version
# e.g. 1.4.4 or 1.5.0
# the middle number is usually changed for changes to, or addition of, features
ghcr.io/basecamp/once-campfire:1
```
If you want to live on the bleeding edge, the `main` tag tracks the main release branch instead.
It changes with every merged pull request, so it's the newest - but least battle-tested - version of Campfire.
To run it you'll need three things:
1. a machine that runs Docker
2. a mounted volume (so that your database and file attachments are kept around between restarts)
3. some environment variables for configuration
If you'd rather build the image yourself from your own copy of the source, you can do that too:
```sh
docker build -t campfire .
```
### Mounting a storage volume
Campfire keeps all of its storage - the database and uploaded file attachments - inside the path `/rails/storage`.
By default Docker containers don't persist storage between runs, so you'll want to mount a persistent volume into that location.
The simplest way to do this is with the `--volume` flag with `docker run`. For example:
```sh
docker run --volume campfire:/rails/storage ghcr.io/basecamp/once-campfire:latest
```
That will create a named volume (called `campfire`) and mount it into the correct path.
Docker will manage where that volume is actually stored on your server.
You can also specify the data location yourself, mount a network drive, and more.
Check the Docker documentation to find out more about what's available.
### Configuring with environment variables
To configure your Campfire installation, you can use environment variables.
At a minimum you'll want to configure your secret key and your SSL domain.
#### Secrets
Campfire needs a few secret values that are specific to your instance:
- `SECRET_KEY_BASE` - the basis for cryptographic features like signed cookies. This should be a long, unguessable random string.
- `VAPID_PRIVATE_KEY`/`VAPID_PUBLIC_KEY` - a key pair used for sending Web Push notifications.
You can generate them by running:
```sh
docker run --rm ghcr.io/basecamp/once-campfire:latest script/admin/generate-secrets
```
It prints a fresh set of values ready to set as environment variables:
```
SECRET_KEY_BASE=...
VAPID_PRIVATE_KEY=...
VAPID_PUBLIC_KEY=...
```
Keep them safe and reuse the same values across restarts and upgrades - changing them later will invalidate sessions and push notification subscriptions.
#### SSL
If you want the Campfire container to handle its own SSL (HTTPS) automatically (via Let's Encrypt), you just need to specify the domain name that you're running it on.
You can do that with the `TLS_DOMAIN` environment variable.
> [!NOTE]
> If you're using SSL, you'll want to allow traffic on ports 80 and 443.
So if you were running on `chat.example.com` you could enable SSL like this:
```sh
docker run --publish 80:80 --publish 443:443 --env TLS_DOMAIN=chat.example.com ...
```
If you are terminating SSL in some other proxy in front of Campfire, or aren't using SSL at all (for example, if you want to run it locally on your laptop), then you should set `DISABLE_SSL=true` instead and just publish port 80:
```sh
docker run --publish 80:80 --env DISABLE_SSL=true ...
```
#### Error reporting (optional)
To enable error reporting to Sentry in production, supply your DSN in the `SENTRY_DSN` environment variable.
To disable Sentry initialization entirely, set `SKIP_TELEMETRY=true`.
### Example
Putting it all together, here's a complete `docker run` invocation:
```sh
docker run \
--name campfire \
--publish 80:80 --publish 443:443 \
--restart unless-stopped \
--volume campfire:/rails/storage \
--env SECRET_KEY_BASE=$YOUR_SECRET_KEY_BASE \
--env VAPID_PUBLIC_KEY=$YOUR_PUBLIC_KEY \
--env VAPID_PRIVATE_KEY=$YOUR_PRIVATE_KEY \
--env TLS_DOMAIN=chat.example.com \
ghcr.io/basecamp/once-campfire:latest
```
And here's an equivalent `docker-compose.yml` that you could use to run Campfire via `docker compose up`:
```yaml
services:
web:
image: ghcr.io/basecamp/once-campfire:latest
restart: unless-stopped
ports:
- "80:80"
- "443:443"
environment:
- SECRET_KEY_BASE=abcdefabcdef
- TLS_DOMAIN=chat.example.com
- VAPID_PRIVATE_KEY=myvapidprivatekey
- VAPID_PUBLIC_KEY=myvapidpublickey
volumes:
- campfire:/rails/storage
volumes:
campfire:
```
### First run
When you start Campfire for the first time, you'll be guided through creating an admin account.
> [!TIP]
> The email address of this admin account will be shown on the login page so that people who forget their password know who to contact for help.
> (You can change this email later in the settings.)
Campfire is single-tenant: any rooms designated "public" will be accessible by all users in the system.
To support entirely distinct groups of customers, you would deploy multiple instances of the application.
### Upgrading
All of Campfire's state lives in the mounted volume, so upgrading is a matter of pulling a newer image and recreating the container:
```sh
docker pull ghcr.io/basecamp/once-campfire:latest
```
Any pending database migrations run automatically when the container boots.
### Backups
To back up your instance, back up the contents of the `/rails/storage` volume.
Because the SQLite database may be written to at any moment, you shouldn't copy its files directly while Campfire is running.
Instead, first run `script/admin/prepare-backup` inside the running container to produce a consistent snapshot of the database (it's written to `storage/backups/` inside the volume):
```sh
docker exec campfire script/admin/prepare-backup
```
(If you're using Docker Compose, replace `docker exec campfire` with `docker compose exec web`)
Then archive the whole storage volume to a file on the host:
```sh
docker run --rm \
--user root \
--volume campfire:/rails/storage \
--volume "$PWD":/backup \
ghcr.io/basecamp/once-campfire:latest \
tar czf "/backup/campfire-backup.tar.gz" -C /rails storage
```
This gives you a `campfire-backup.tar.gz` in your current directory containing the database snapshot and all uploaded files.
Copy it somewhere safe, ideally off the machine.
To restore, extract the archive back into a (stopped) instance's volume, and replace the live database with the snapshot:
```sh
docker run --rm \
--user root \
--volume campfire:/rails/storage \
--volume "$PWD":/backup \
ghcr.io/basecamp/once-campfire:latest \
bash -c "tar xzf /backup/campfire-backup.tar.gz -C /rails &&
cp /rails/storage/backups/production.sqlite3 /rails/storage/db/production.sqlite3 &&
rm -f /rails/storage/db/production.sqlite3-wal /rails/storage/db/production.sqlite3-shm &&
chown -R rails:rails /rails/storage"
```
Then start Campfire again.
-12
View File
@@ -1,12 +0,0 @@
task "resque:setup" do
require_relative "../../config/environment"
end
task "resque:pool:setup" do
ActiveRecord::Base.connection.disconnect!
Resque::Pool.after_prefork do |job|
ActiveRecord::Base.establish_connection
Resque.redis.client.close
end
end
+10
View File
@@ -0,0 +1,10 @@
#!/usr/bin/env ruby
require "bundler/setup"
require "securerandom"
require "web_push"
vapid_key = WebPush.generate_key
puts "SECRET_KEY_BASE=#{SecureRandom.hex(64)}"
puts "VAPID_PRIVATE_KEY=#{vapid_key.private_key}"
puts "VAPID_PUBLIC_KEY=#{vapid_key.public_key}"
+158
View File
@@ -0,0 +1,158 @@
import ws from 'k6/ws';
import http from 'k6/http';
import { sleep } from 'k6';
import { Trend, Counter } from 'k6/metrics';
import papaparse from 'https://jslib.k6.io/papaparse/5.1.1/index.js';
import { SharedArray } from 'k6/data';
// Stream names signed with the performance environment's SECRET_KEY_BASE=dummy:
// the rooms list, room 1 messages, and user 1 rooms.
const turboSignedStreamNames = [
"InJvb21zIg==--54acd827f0a7db144c75316a9fc488c0a949f9635b1e47956ce1bd9d1cf2c41d",
"IloybGtPaTh2WTJGdGNHWnBjbVV2VW05dmJYTTZPa05zYjNObFpDOHg6bWVzc2FnZXMi--84f0f3dde5d23eb0fdb410746c2fb76813a4ddff1e2798aac4be0c3d969702ba",
"IloybGtPaTh2WTJGdGNHWnBjbVV2VlhObGNpOHg6cm9vbXMi--df547a679cd41f7531b53d9e48f9883c02481a4da0d862453106441d8546d084"
];
const dummyCookies = new SharedArray('cookies', function () {
return papaparse.parse(open('cookies.txt'), { header: false }).data;
});
const host = __ENV.HOST || "127.0.0.1";
let port = ":3000";
if (__ENV.PORT) {
port = `:${__ENV.PORT}`;
}
const users = parseInt(__ENV.USERS || "3000");
const rampS = parseInt(__ENV.RAMP_S || "480");
const holdS = parseInt(__ENV.HOLD_S || "120");
const sendIntervalS = parseFloat(__ENV.SEND_INTERVAL_S || "1");
const sample = parseInt(__ENV.SAMPLE || "25");
const totalS = rampS + holdS;
const deliveryLatency = new Trend('delivery_latency', true);
const messagesReceived = new Counter('bench_messages_received');
const socketErrors = new Counter('bench_socket_errors');
export const options = {
discardResponseBodies: true,
scenarios: {
receivers: {
executor: 'ramping-vus',
exec: 'receiver',
startVUs: 0,
stages: [
{ duration: `${rampS}s`, target: users },
{ duration: `${holdS}s`, target: users },
],
gracefulRampDown: '0s',
gracefulStop: '5s',
},
sender: {
executor: 'constant-vus',
exec: 'sender',
vus: 1,
duration: `${totalS}s`,
gracefulStop: '10s',
},
},
};
export function receiver() {
const cookie = dummyCookies[(__VU - 1) % dummyCookies.length][0];
const url = `ws://${host}${port}/cable`;
const params = {
headers: { 'Origin': 'http://localhost', 'Cookie': `session_token=${cookie}` }
};
const subscriptions = [
'{"channel":"PresenceChannel", "room_id":1}',
'{"channel":"UnreadRoomsChannel"}',
'{"channel":"HeartbeatChannel"}',
...turboSignedStreamNames.map((name) =>
`{"channel":"Turbo::StreamsChannel", "signed_stream_name":"${name}"}`)
];
const res = ws.connect(url, params, function (socket) {
let confirmed = 0;
socket.on('open', function () {
subscriptions.forEach((identifier) => {
socket.send(JSON.stringify({ command: 'subscribe', identifier: identifier }));
});
});
socket.on('message', function (message) {
if (message.includes('confirm_subscription')) {
confirmed++;
if (confirmed === subscriptions.length) {
console.log(`BENCH CONN ${Date.now()} ${__VU}`);
}
} else if (message.includes('reject_subscription')) {
socketErrors.add(1);
console.log(`BENCH ERR ${Date.now()} reject_subscription`);
} else if (message.includes('bench:')) {
const match = message.match(/bench:(\d+):(\d+)/);
if (match) {
const latency = Date.now() - parseInt(match[1]);
deliveryLatency.add(latency);
messagesReceived.add(1);
if (__VU % sample === 0) {
console.log(`BENCH LAT ${Date.now()} ${latency}`);
}
}
}
});
socket.on('error', function (e) {
if (e.error() != 'websocket: close sent') {
socketErrors.add(1);
console.log(`BENCH ERR ${Date.now()} ${e.error()}`);
}
});
socket.setTimeout(() => socket.close(), totalS * 1000);
});
if (!res || res.status >= 400) {
socketErrors.add(1);
let status = "nil";
if (res) {
status = res.status;
}
console.log(`BENCH ERR ${Date.now()} connect_status_${status}`);
}
}
export function sender() {
const cookie = `session_token=${dummyCookies[0][0]}`;
const page = http.get(`http://${host}${port}/rooms/1`, {
headers: { "Cookie": cookie }, responseType: "text"
});
const csrfToken = page.body.match(/<meta name="csrf-token" content="([^"]*)"/i)[1];
const postHeaders = {
"Cookie": cookie,
"Accept": "text/vnd.turbo-stream.html, text/html, application/xhtml+xml"
};
let seq = 0;
const deadline = Date.now() + totalS * 1000;
while (Date.now() < deadline) {
seq++;
const payload = {
"message[body]": `bench:${Date.now()}:${seq}`,
"message[client_message_id]": `bench-${seq}-${Math.random().toString(36)}`,
"authenticity_token": csrfToken
};
const res = http.post(`http://${host}${port}/rooms/1/messages`, payload, {
headers: postHeaders, responseType: "none", tags: { name: 'send_message' }
});
if (res.status >= 400) {
console.log(`BENCH ERR ${Date.now()} post_status_${res.status}`);
} else {
console.log(`BENCH SENT ${Date.now()} ${seq}`);
}
sleep(sendIntervalS);
}
}