From 8fcb4666fe26631346cb4f4128395d70ba802778 Mon Sep 17 00:00:00 2001 From: Irina Nazarova Date: Sun, 28 Jun 2026 14:40:49 -0700 Subject: [PATCH 01/18] Add Rails comparison: Action Cable / Solid Cable / AsyncCable / AnyCable Target apps and harness for the Rails WebSocket adapter comparison behind anycable.io/compare/rails-actioncable. Targets: - cable-bench/ Rails 8.1 app, BENCH_MODE selects Action Cable (Redis) or Solid Cable (database); also the AnyCable RPC backend - cable-bench-falcon/ same app booted on Falcon via actioncable-next + async-cable (the AsyncCable target) Harness: - idle-multi.ts: forward CHANNEL/AC_PROTOCOL and send the bench-runner auth token, so idle/capacity runs can target a real Rails channel - jitter-multi.ts: forward CHANNEL + AC_PROTOCOL for Rails targets - idle-runner.ts / jitter-runners.ts / server.ts: channel + acProtocol params - tests-manifest.ts: Rails latency/jitter/idle/avalanche/capacity specs Results (sharded, one shared-tenant Railway window): - backend/results/rails-sharded-2026-06-28.json latency/jitter/10K/idle/avalanche - backend/results/rails-capacity-break-2026-06-28.json idle-to-break per adapter Deep dive in docs/rails-comparison.md; summary in README. --- README.md | 61 ++ backend/results/rails-2026-06-27.json | 242 ++++++++ backend/results/rails-sharded-2026-06-28.json | 195 ++++++ backend/src/bench-runner/server.ts | 35 +- backend/src/bench/idle-multi.ts | 10 +- backend/src/bench/jitter-multi.ts | 4 + backend/src/bench/tests-manifest.ts | 231 +++++++ backend/src/lib/avalanche-anycable-runner.ts | 183 ++++++ backend/src/lib/idle-runner.ts | 17 +- backend/src/lib/jitter-runners.ts | 19 +- cable-bench-falcon/.dockerignore | 15 + cable-bench-falcon/.gitattributes | 9 + cable-bench-falcon/.gitignore | 35 ++ cable-bench-falcon/.rubocop.yml | 8 + cable-bench-falcon/.ruby-version | 1 + cable-bench-falcon/Dockerfile | 36 ++ cable-bench-falcon/Gemfile | 63 ++ cable-bench-falcon/Gemfile.lock | 587 ++++++++++++++++++ cable-bench-falcon/README.md | 24 + cable-bench-falcon/Rakefile | 6 + cable-bench-falcon/app/assets/images/.keep | 0 .../app/assets/stylesheets/application.css | 10 + .../app/channels/application_cable/channel.rb | 4 + .../channels/application_cable/connection.rb | 7 + .../app/channels/benchmark_channel.rb | 16 + .../app/controllers/application_controller.rb | 7 + .../app/controllers/bench_controller.rb | 41 ++ .../app/controllers/concerns/.keep | 0 .../app/helpers/application_helper.rb | 2 + .../app/javascript/application.js | 3 + .../app/javascript/controllers/application.js | 9 + .../controllers/hello_controller.js | 7 + .../app/javascript/controllers/index.js | 4 + .../app/jobs/application_job.rb | 7 + .../app/models/application_record.rb | 3 + cable-bench-falcon/app/models/concerns/.keep | 0 .../app/views/layouts/application.html.erb | 29 + .../app/views/pwa/manifest.json.erb | 22 + .../app/views/pwa/service-worker.js | 26 + cable-bench-falcon/bin/brakeman | 7 + cable-bench-falcon/bin/bundler-audit | 6 + cable-bench-falcon/bin/ci | 6 + cable-bench-falcon/bin/dev | 2 + cable-bench-falcon/bin/falcon-entrypoint | 21 + cable-bench-falcon/bin/importmap | 4 + cable-bench-falcon/bin/jobs | 6 + cable-bench-falcon/bin/rails | 4 + cable-bench-falcon/bin/rake | 4 + cable-bench-falcon/bin/rubocop | 8 + cable-bench-falcon/bin/setup | 35 ++ cable-bench-falcon/bin/thrust | 5 + cable-bench-falcon/config.ru | 6 + cable-bench-falcon/config/anycable.yml | 17 + cable-bench-falcon/config/application.rb | 42 ++ cable-bench-falcon/config/boot.rb | 4 + cable-bench-falcon/config/bundler-audit.yml | 5 + cable-bench-falcon/config/cable.yml | 15 + cable-bench-falcon/config/cache.yml | 16 + cable-bench-falcon/config/ci.rb | 20 + cable-bench-falcon/config/credentials.yml.enc | 1 + cable-bench-falcon/config/database.yml | 48 ++ cable-bench-falcon/config/environment.rb | 5 + .../config/environments/development.rb | 71 +++ .../config/environments/production.rb | 75 +++ .../config/environments/test.rb | 42 ++ cable-bench-falcon/config/importmap.rb | 7 + .../config/initializers/assets.rb | 7 + .../initializers/content_security_policy.rb | 29 + .../initializers/filter_parameter_logging.rb | 8 + .../config/initializers/inflections.rb | 16 + cable-bench-falcon/config/locales/en.yml | 31 + cable-bench-falcon/config/puma.rb | 45 ++ cable-bench-falcon/config/queue.yml | 18 + cable-bench-falcon/config/recurring.yml | 15 + cable-bench-falcon/config/routes.rb | 22 + cable-bench-falcon/db/cable_schema.rb | 11 + cable-bench-falcon/db/cache_schema.rb | 12 + cable-bench-falcon/db/queue_schema.rb | 129 ++++ cable-bench-falcon/db/seeds.rb | 9 + cable-bench-falcon/docker-compose.yml | 21 + cable-bench-falcon/lib/tasks/.keep | 0 cable-bench-falcon/public/400.html | 135 ++++ cable-bench-falcon/public/404.html | 135 ++++ .../public/406-unsupported-browser.html | 135 ++++ cable-bench-falcon/public/422.html | 135 ++++ cable-bench-falcon/public/500.html | 135 ++++ cable-bench-falcon/public/icon.png | Bin 0 -> 4166 bytes cable-bench-falcon/public/icon.svg | 3 + cable-bench-falcon/public/robots.txt | 1 + cable-bench-falcon/railway.toml | 11 + cable-bench-falcon/script/.keep | 0 cable-bench-falcon/storage/.keep | 0 cable-bench-falcon/vendor/.keep | 0 cable-bench-falcon/vendor/javascript/.keep | 0 cable-bench/.dockerignore | 15 + cable-bench/.gitattributes | 9 + cable-bench/.gitignore | 35 ++ cable-bench/.rubocop.yml | 8 + cable-bench/.ruby-version | 1 + cable-bench/Dockerfile | 36 ++ cable-bench/Gemfile | 58 ++ cable-bench/Gemfile.lock | 560 +++++++++++++++++ cable-bench/README.md | 24 + cable-bench/Rakefile | 6 + cable-bench/app/assets/images/.keep | 0 .../app/assets/stylesheets/application.css | 10 + .../app/channels/application_cable/channel.rb | 4 + .../channels/application_cable/connection.rb | 7 + cable-bench/app/channels/benchmark_channel.rb | 16 + .../app/controllers/application_controller.rb | 7 + .../app/controllers/bench_controller.rb | 41 ++ cable-bench/app/controllers/concerns/.keep | 0 cable-bench/app/helpers/application_helper.rb | 2 + cable-bench/app/javascript/application.js | 3 + .../app/javascript/controllers/application.js | 9 + .../controllers/hello_controller.js | 7 + .../app/javascript/controllers/index.js | 4 + cable-bench/app/jobs/application_job.rb | 7 + cable-bench/app/models/application_record.rb | 3 + cable-bench/app/models/concerns/.keep | 0 .../app/views/layouts/application.html.erb | 29 + cable-bench/app/views/pwa/manifest.json.erb | 22 + cable-bench/app/views/pwa/service-worker.js | 26 + cable-bench/bin/bench-entrypoint | 33 + cable-bench/bin/brakeman | 7 + cable-bench/bin/bundler-audit | 6 + cable-bench/bin/ci | 6 + cable-bench/bin/dev | 2 + cable-bench/bin/importmap | 4 + cable-bench/bin/jobs | 6 + cable-bench/bin/rails | 4 + cable-bench/bin/rake | 4 + cable-bench/bin/rubocop | 8 + cable-bench/bin/setup | 35 ++ cable-bench/bin/thrust | 5 + cable-bench/config.ru | 6 + cable-bench/config/anycable.yml | 17 + cable-bench/config/application.rb | 42 ++ cable-bench/config/boot.rb | 4 + cable-bench/config/bundler-audit.yml | 5 + cable-bench/config/cable.yml | 29 + cable-bench/config/cache.yml | 16 + cable-bench/config/ci.rb | 20 + cable-bench/config/credentials.yml.enc | 1 + cable-bench/config/database.yml | 48 ++ cable-bench/config/environment.rb | 5 + .../config/environments/development.rb | 71 +++ cable-bench/config/environments/production.rb | 75 +++ cable-bench/config/environments/test.rb | 42 ++ cable-bench/config/importmap.rb | 7 + cable-bench/config/initializers/assets.rb | 7 + .../initializers/content_security_policy.rb | 29 + .../initializers/filter_parameter_logging.rb | 8 + .../config/initializers/inflections.rb | 16 + cable-bench/config/locales/en.yml | 31 + cable-bench/config/puma.rb | 45 ++ cable-bench/config/queue.yml | 18 + cable-bench/config/recurring.yml | 15 + cable-bench/config/routes.rb | 23 + cable-bench/db/cable_schema.rb | 11 + cable-bench/db/cache_schema.rb | 12 + cable-bench/db/queue_schema.rb | 129 ++++ cable-bench/db/seeds.rb | 9 + cable-bench/docker-compose.yml | 56 ++ cable-bench/lib/tasks/.keep | 0 cable-bench/log/.keep | 0 cable-bench/public/400.html | 135 ++++ cable-bench/public/404.html | 135 ++++ .../public/406-unsupported-browser.html | 135 ++++ cable-bench/public/422.html | 135 ++++ cable-bench/public/500.html | 135 ++++ cable-bench/public/icon.png | Bin 0 -> 4166 bytes cable-bench/public/icon.svg | 3 + cable-bench/public/robots.txt | 1 + cable-bench/railway.toml | 11 + cable-bench/script/.keep | 0 cable-bench/storage/.keep | 0 cable-bench/tmp/.keep | 0 cable-bench/tmp/pids/.keep | 0 cable-bench/tmp/storage/.keep | 0 cable-bench/vendor/.keep | 0 cable-bench/vendor/javascript/.keep | 0 docs/rails-comparison.md | 203 ++++++ 183 files changed, 6274 insertions(+), 8 deletions(-) create mode 100644 backend/results/rails-2026-06-27.json create mode 100644 backend/results/rails-sharded-2026-06-28.json create mode 100644 backend/src/lib/avalanche-anycable-runner.ts create mode 100644 cable-bench-falcon/.dockerignore create mode 100644 cable-bench-falcon/.gitattributes create mode 100644 cable-bench-falcon/.gitignore create mode 100644 cable-bench-falcon/.rubocop.yml create mode 100644 cable-bench-falcon/.ruby-version create mode 100644 cable-bench-falcon/Dockerfile create mode 100644 cable-bench-falcon/Gemfile create mode 100644 cable-bench-falcon/Gemfile.lock create mode 100644 cable-bench-falcon/README.md create mode 100644 cable-bench-falcon/Rakefile create mode 100644 cable-bench-falcon/app/assets/images/.keep create mode 100644 cable-bench-falcon/app/assets/stylesheets/application.css create mode 100644 cable-bench-falcon/app/channels/application_cable/channel.rb create mode 100644 cable-bench-falcon/app/channels/application_cable/connection.rb create mode 100644 cable-bench-falcon/app/channels/benchmark_channel.rb create mode 100644 cable-bench-falcon/app/controllers/application_controller.rb create mode 100644 cable-bench-falcon/app/controllers/bench_controller.rb create mode 100644 cable-bench-falcon/app/controllers/concerns/.keep create mode 100644 cable-bench-falcon/app/helpers/application_helper.rb create mode 100644 cable-bench-falcon/app/javascript/application.js create mode 100644 cable-bench-falcon/app/javascript/controllers/application.js create mode 100644 cable-bench-falcon/app/javascript/controllers/hello_controller.js create mode 100644 cable-bench-falcon/app/javascript/controllers/index.js create mode 100644 cable-bench-falcon/app/jobs/application_job.rb create mode 100644 cable-bench-falcon/app/models/application_record.rb create mode 100644 cable-bench-falcon/app/models/concerns/.keep create mode 100644 cable-bench-falcon/app/views/layouts/application.html.erb create mode 100644 cable-bench-falcon/app/views/pwa/manifest.json.erb create mode 100644 cable-bench-falcon/app/views/pwa/service-worker.js create mode 100755 cable-bench-falcon/bin/brakeman create mode 100755 cable-bench-falcon/bin/bundler-audit create mode 100755 cable-bench-falcon/bin/ci create mode 100755 cable-bench-falcon/bin/dev create mode 100755 cable-bench-falcon/bin/falcon-entrypoint create mode 100755 cable-bench-falcon/bin/importmap create mode 100755 cable-bench-falcon/bin/jobs create mode 100755 cable-bench-falcon/bin/rails create mode 100755 cable-bench-falcon/bin/rake create mode 100755 cable-bench-falcon/bin/rubocop create mode 100755 cable-bench-falcon/bin/setup create mode 100755 cable-bench-falcon/bin/thrust create mode 100644 cable-bench-falcon/config.ru create mode 100644 cable-bench-falcon/config/anycable.yml create mode 100644 cable-bench-falcon/config/application.rb create mode 100644 cable-bench-falcon/config/boot.rb create mode 100644 cable-bench-falcon/config/bundler-audit.yml create mode 100644 cable-bench-falcon/config/cable.yml create mode 100644 cable-bench-falcon/config/cache.yml create mode 100644 cable-bench-falcon/config/ci.rb create mode 100644 cable-bench-falcon/config/credentials.yml.enc create mode 100644 cable-bench-falcon/config/database.yml create mode 100644 cable-bench-falcon/config/environment.rb create mode 100644 cable-bench-falcon/config/environments/development.rb create mode 100644 cable-bench-falcon/config/environments/production.rb create mode 100644 cable-bench-falcon/config/environments/test.rb create mode 100644 cable-bench-falcon/config/importmap.rb create mode 100644 cable-bench-falcon/config/initializers/assets.rb create mode 100644 cable-bench-falcon/config/initializers/content_security_policy.rb create mode 100644 cable-bench-falcon/config/initializers/filter_parameter_logging.rb create mode 100644 cable-bench-falcon/config/initializers/inflections.rb create mode 100644 cable-bench-falcon/config/locales/en.yml create mode 100644 cable-bench-falcon/config/puma.rb create mode 100644 cable-bench-falcon/config/queue.yml create mode 100644 cable-bench-falcon/config/recurring.yml create mode 100644 cable-bench-falcon/config/routes.rb create mode 100644 cable-bench-falcon/db/cable_schema.rb create mode 100644 cable-bench-falcon/db/cache_schema.rb create mode 100644 cable-bench-falcon/db/queue_schema.rb create mode 100644 cable-bench-falcon/db/seeds.rb create mode 100644 cable-bench-falcon/docker-compose.yml create mode 100644 cable-bench-falcon/lib/tasks/.keep create mode 100644 cable-bench-falcon/public/400.html create mode 100644 cable-bench-falcon/public/404.html create mode 100644 cable-bench-falcon/public/406-unsupported-browser.html create mode 100644 cable-bench-falcon/public/422.html create mode 100644 cable-bench-falcon/public/500.html create mode 100644 cable-bench-falcon/public/icon.png create mode 100644 cable-bench-falcon/public/icon.svg create mode 100644 cable-bench-falcon/public/robots.txt create mode 100644 cable-bench-falcon/railway.toml create mode 100644 cable-bench-falcon/script/.keep create mode 100644 cable-bench-falcon/storage/.keep create mode 100644 cable-bench-falcon/vendor/.keep create mode 100644 cable-bench-falcon/vendor/javascript/.keep create mode 100644 cable-bench/.dockerignore create mode 100644 cable-bench/.gitattributes create mode 100644 cable-bench/.gitignore create mode 100644 cable-bench/.rubocop.yml create mode 100644 cable-bench/.ruby-version create mode 100644 cable-bench/Dockerfile create mode 100644 cable-bench/Gemfile create mode 100644 cable-bench/Gemfile.lock create mode 100644 cable-bench/README.md create mode 100644 cable-bench/Rakefile create mode 100644 cable-bench/app/assets/images/.keep create mode 100644 cable-bench/app/assets/stylesheets/application.css create mode 100644 cable-bench/app/channels/application_cable/channel.rb create mode 100644 cable-bench/app/channels/application_cable/connection.rb create mode 100644 cable-bench/app/channels/benchmark_channel.rb create mode 100644 cable-bench/app/controllers/application_controller.rb create mode 100644 cable-bench/app/controllers/bench_controller.rb create mode 100644 cable-bench/app/controllers/concerns/.keep create mode 100644 cable-bench/app/helpers/application_helper.rb create mode 100644 cable-bench/app/javascript/application.js create mode 100644 cable-bench/app/javascript/controllers/application.js create mode 100644 cable-bench/app/javascript/controllers/hello_controller.js create mode 100644 cable-bench/app/javascript/controllers/index.js create mode 100644 cable-bench/app/jobs/application_job.rb create mode 100644 cable-bench/app/models/application_record.rb create mode 100644 cable-bench/app/models/concerns/.keep create mode 100644 cable-bench/app/views/layouts/application.html.erb create mode 100644 cable-bench/app/views/pwa/manifest.json.erb create mode 100644 cable-bench/app/views/pwa/service-worker.js create mode 100755 cable-bench/bin/bench-entrypoint create mode 100755 cable-bench/bin/brakeman create mode 100755 cable-bench/bin/bundler-audit create mode 100755 cable-bench/bin/ci create mode 100755 cable-bench/bin/dev create mode 100755 cable-bench/bin/importmap create mode 100755 cable-bench/bin/jobs create mode 100755 cable-bench/bin/rails create mode 100755 cable-bench/bin/rake create mode 100755 cable-bench/bin/rubocop create mode 100755 cable-bench/bin/setup create mode 100755 cable-bench/bin/thrust create mode 100644 cable-bench/config.ru create mode 100644 cable-bench/config/anycable.yml create mode 100644 cable-bench/config/application.rb create mode 100644 cable-bench/config/boot.rb create mode 100644 cable-bench/config/bundler-audit.yml create mode 100644 cable-bench/config/cable.yml create mode 100644 cable-bench/config/cache.yml create mode 100644 cable-bench/config/ci.rb create mode 100644 cable-bench/config/credentials.yml.enc create mode 100644 cable-bench/config/database.yml create mode 100644 cable-bench/config/environment.rb create mode 100644 cable-bench/config/environments/development.rb create mode 100644 cable-bench/config/environments/production.rb create mode 100644 cable-bench/config/environments/test.rb create mode 100644 cable-bench/config/importmap.rb create mode 100644 cable-bench/config/initializers/assets.rb create mode 100644 cable-bench/config/initializers/content_security_policy.rb create mode 100644 cable-bench/config/initializers/filter_parameter_logging.rb create mode 100644 cable-bench/config/initializers/inflections.rb create mode 100644 cable-bench/config/locales/en.yml create mode 100644 cable-bench/config/puma.rb create mode 100644 cable-bench/config/queue.yml create mode 100644 cable-bench/config/recurring.yml create mode 100644 cable-bench/config/routes.rb create mode 100644 cable-bench/db/cable_schema.rb create mode 100644 cable-bench/db/cache_schema.rb create mode 100644 cable-bench/db/queue_schema.rb create mode 100644 cable-bench/db/seeds.rb create mode 100644 cable-bench/docker-compose.yml create mode 100644 cable-bench/lib/tasks/.keep create mode 100644 cable-bench/log/.keep create mode 100644 cable-bench/public/400.html create mode 100644 cable-bench/public/404.html create mode 100644 cable-bench/public/406-unsupported-browser.html create mode 100644 cable-bench/public/422.html create mode 100644 cable-bench/public/500.html create mode 100644 cable-bench/public/icon.png create mode 100644 cable-bench/public/icon.svg create mode 100644 cable-bench/public/robots.txt create mode 100644 cable-bench/railway.toml create mode 100644 cable-bench/script/.keep create mode 100644 cable-bench/storage/.keep create mode 100644 cable-bench/tmp/.keep create mode 100644 cable-bench/tmp/pids/.keep create mode 100644 cable-bench/tmp/storage/.keep create mode 100644 cable-bench/vendor/.keep create mode 100644 cable-bench/vendor/javascript/.keep create mode 100644 docs/rails-comparison.md diff --git a/README.md b/README.md index 3c938ba..6dfe392 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,8 @@ Setups under test: default Socket.io, Socket.io + Connection State Recovery, uWe Sixth target, [socketioxide](https://github.com/totodore/socketioxide) (Rust Socket.io server), benchmarked head-to-head with AnyCable on the same Railway hardware. Results in [its own section below](#socketioxide-rust-socketio); deep dive in [`docs/socketioxide-comparison.md`](./docs/socketioxide-comparison.md). +The same harness also drives the **Rails** comparison (Action Cable vs Solid Cable vs AsyncCable vs AnyCable) behind [anycable.io/compare/rails-actioncable](https://anycable.io/compare/rails-actioncable): one Rails app, four adapters, same box. Results in [its own section below](#rails-action-cable--solid-cable--asynccable--anycable); deep dive in [`docs/rails-comparison.md`](./docs/rails-comparison.md). + Methodology, traps, and the bugs we caught in our own setup: [`docs/methodology.md`](./docs/methodology.md). Below: the numbers and how to rerun them. ## Headlines @@ -110,6 +112,45 @@ Three knobs that shape the numbers. Full reasoning in [`docs/methodology.md`](./ **The takeaway.** Rust fixes Socket.io's capacity ceiling, the single-event-loop wall that caps Node around 120K. It leaves two things untouched: at-most-once delivery (no replay protocol) and deploy fragility (the WS layer still dies with its app). Both live in the protocol and the topology, so swapping the language to Rust leaves them intact, and socketioxide collapses under jitter and deploy storms at scale the same way Node Socket.io does. AnyCable holds 100% on both because the WS layer is a separate process with replay. Full numbers and the deploy story: [`docs/socketioxide-comparison.md`](./docs/socketioxide-comparison.md). +## Rails (Action Cable / Solid Cable / AsyncCable / AnyCable) + +The repo behind [anycable.io/compare/rails-actioncable](https://anycable.io/compare/rails-actioncable). Four WebSocket adapters for the **same Rails 8.1 app**, same Railway box, same shared-tenant window: Action Cable on Redis (Puma), Solid Cable on the database (Puma), AsyncCable on Falcon (`socketry/async-cable`, fiber reactor), and AnyCable in RPC mode (Rails gRPC backend + `anycable-go` gateway). All four are Action Cable-compatible at the app and client level, so the channels and Turbo Streams are identical; only `config/cable.yml`, the runtime, and the process topology change. The Puma targets are in `cable-bench/`, the Falcon target in `cable-bench-falcon/`; the AnyCable JS driver serves all four over the existing `bench-*-anycable` endpoints, parameterized by `cableUrl` / `broadcastUrl` / `channel` / `acProtocol`. + +The one wire difference: Action Cable, Solid Cable and AsyncCable speak the base `actioncable-v1-json` protocol (at-most-once, no resume); AnyCable speaks the extended `actioncable-v1-ext-json` protocol (per-stream history, resume on reconnect). Switching Puma for Falcon (AsyncCable) changes the runtime, not the guarantees. + +All numbers below are **sharded** (13 drivers, ~250 to 770 cables each). A single bench-runner holding thousands of cables saturates its own event loop and produces wrong numbers in both directions; see [Running the latency test](#running-the-latency-and-jitter-test-shard-it). + +**Roundtrip latency (steady network, 100% delivery).** AnyCable leads at every scale: fan-out runs in Go, off the Ruby process. + +| Adapter | 1K p50 / p99 | 5K p50 / p99 | +| --- | --- | --- | +| Solid Cable | 62 / 119 ms | 74 / 164 ms | +| AsyncCable (Falcon) | 11 / 80 ms | 20 / 71 ms | +| Action Cable (Puma) | 9 / 47 ms | 13 / 57 ms | +| AnyCable | **4 / 23 ms** | **7 / 31 ms** | + +**Delivery under jitter (5K, ~2 s drops).** The base protocol has no resume, so the three in-process adapters lose the same ~22%; AnyCable replays per-stream history. + +| Adapter | Delivery | +| --- | --- | +| Solid Cable | **78.1%** | +| Action Cable | **78.1%** | +| AsyncCable (Falcon) | **78.1%** | +| AnyCable | **99.9%** | + +**Capacity: 10K under load + idle-to-break.** All four hold 10K at 100% delivery; AnyCable keeps the tightest tail (p99 31 ms vs 84 ms Action Cable, 112 ms AsyncCable, 200 ms Solid Cable). Pushed to failure on identical 32 GB boxes (default 8-worker config): Puma Action Cable and Solid Cable wall at ~52K on a file-descriptor ceiling (~2.5 GB RAM, not memory-bound); AsyncCable on Falcon runs out of memory at ~97K (~290 KB/conn); AnyCable held **600K with zero failures** across a 50-runner fleet (~47 KB/conn, ~27 GB, ~84% of the box), not driven to failure: the load fleet maxed out, not the server, so treat 600K as a floor. Finding a gateway's true ceiling takes ~1 load driver per 10K connections (a single Node driver tops out near 10K cables), so capacity-to-break runs scale the driver count to the target. Raw data in [`backend/results/rails-capacity-break-2026-06-28.json`](./backend/results/rails-capacity-break-2026-06-28.json). + +**Deploy survival (avalanche, 5K, real app redeploy).** In-process drops every connection, Puma and Falcon alike, and stays down ~7.5 to 8 s before ~96% reconnect; AnyCable's gateway never sees the deploy, so 0 s of downtime. "Down for" is how long connections stayed dropped before the reconnect storm settled. + +| Adapter | Dropped | Down for | Reconnected | +| --- | --- | --- | --- | +| Action Cable | all 5,000 | 7.5 s | 96.3% (187 still out at cutoff) | +| Solid Cable | all 5,000 | 7.6 s | 95.7% (215 still out at cutoff) | +| AsyncCable (Falcon) | all 5,000 | 8.0 s | 96.4% (179 still out at cutoff) | +| AnyCable | **0** | **0 s** | n/a | + +**The takeaway.** On Rails, AnyCable leads on latency at every scale (7 ms p50 at 5K) and wins the things that decide whether realtime holds up: 100% delivery under jitter where the base protocol drops about a fifth of broadcasts, and connections that survive every app deploy. Capacity ties at everyday sizes (all four hold 10K at 100%) but splits past that: AnyCable held 600K idle connections where the Puma adapters wall at ~52K on a file-descriptor ceiling and Falcon runs out of memory at ~97K. AsyncCable on Falcon is a real alternative runtime to Puma with latency in the same range, but shares the in-process limits (at-most-once, deploy-fragile) and is the most memory-hungry by far. Solid Cable's edge is operational (no Redis), at the cost of a polling-latency floor. Full numbers: [`docs/rails-comparison.md`](./docs/rails-comparison.md). Raw results: [`backend/results/rails-sharded-2026-06-28.json`](./backend/results/rails-sharded-2026-06-28.json). + ## Repository layout ``` @@ -314,6 +355,26 @@ INCLUDE_AVALANCHE=1 # add 5 avalanche tests (auto-redeploys server) Full sweep: ~90 minutes wall-clock. +### Running the latency (and jitter) test: shard it + +**Latency and jitter must be sharded. One bench-runner holding thousands of client cables saturates its single Node event loop, and the measured receive latency then reflects the test driver queueing frames, not the server.** This bites hardest for the `actioncable-v1-ext-json` client (AnyCable), which does extra per-message offset/ack work, so an unsharded run makes AnyCable look several times slower than it is. Keep each shard light: **~250 client cables per bench-runner** (the nodejs page uses 250/shard; 1K = 4 shards, 5K = 20 shards, 10K = 40 shards). The coordinator merges the union latency distribution across shards (`mergeJitterResults`), so percentiles stay honest. See [`docs/methodology.md`](./docs/methodology.md#cross-shard-percentile-honesty). + +Set `numShards` + `perShardN` on the spec in `tests-manifest.ts` (e.g. `numShards: 20, perShardN: 250` for 5K), then point `BENCH_RUNNER_URLS` at **at least that many ONLINE runners**. The default pool assumes `bench-runner` + `bench-runner-2..50`; if some are stopped, list the live ones explicitly: + +```bash +cd backend +# Build a URL list from a contiguous range of online runners (here 14..33 = 20 shards): +URLS=$(for i in $(seq 14 33); do printf 'https://bench-runner-%s-production.up.railway.app,' "$i"; done) + +BENCH_RUNNER_TOKEN= \ +BENCH_RUNNER_URLS="${URLS%,}" \ +FILTER=latency \ +OUTPUT_DIR=tmp/latency-run \ + npm run bench:rebaseline +``` + +A spec without `numShards`/`perShardN` runs on a single bench-runner: fine for connection-count or delivery checks, wrong for latency. If a latency p50 jumps super-linearly with subscriber count (e.g. 15 ms at 1K to 225 ms at 5K), suspect an unsharded run before you suspect the server. + **Baselines vs the page numbers.** The page was captured during a noisy Railway shared-tenant window. The `baseline` field in `tests-manifest.ts` is what the same tests deliver on a quieter window: latencies ~50% better, everything else the same. So the page is the cautious "worst seen under shared-infra load" view, and the rebaseline tests against today's quieter floor. A green rebaseline says "we still beat today's floor", which is stricter than the page promises. When we refresh the page, baselines and page numbers move together. Per-run history lives at `tmp/v1.6.14-bench-results/runs/{ISO-ts}/`. To watch each headline number move across runs: diff --git a/backend/results/rails-2026-06-27.json b/backend/results/rails-2026-06-27.json new file mode 100644 index 0000000..1c5e052 --- /dev/null +++ b/backend/results/rails-2026-06-27.json @@ -0,0 +1,242 @@ +{ + "benchmark": "rails-actioncable", + "page": "https://anycable.io/compare/rails-actioncable", + "capturedAt": "2026-06-27", + "hardware": "Railway shared-tenant window; Rails 8.1, Puma 8 workers x 5 threads; anycable-go gateway + Rails gRPC RPC (anycable-rails 1.6)", + "targets": [ + "Action Cable (Redis adapter)", + "Solid Cable (DB adapter)", + "AnyCable (RPC mode, anycable-go gateway)" + ], + "results": { + "latency": { + "solidcable-1k": { + "clients": 1000, + "connectedClients": 1000, + "deliveryRatePct": 100, + "connectFailures": 0, + "latencyRawMs": { + "avg": 62, + "p50": 62, + "p95": 108, + "p99": 115, + "max": 148 + } + }, + "solidcable-5k": { + "clients": 5000, + "connectedClients": 5000, + "deliveryRatePct": 100, + "connectFailures": 0, + "latencyRawMs": { + "avg": 85, + "p50": 86, + "p95": 141, + "p99": 163, + "max": 276 + } + }, + "actioncable-1k": { + "clients": 1000, + "connectedClients": 1000, + "deliveryRatePct": 100, + "connectFailures": 0, + "latencyRawMs": { + "avg": 19, + "p50": 18, + "p95": 30, + "p99": 70, + "max": 96 + } + }, + "actioncable-5k": { + "clients": 5000, + "connectedClients": 5000, + "deliveryRatePct": 100, + "connectFailures": 0, + "latencyRawMs": { + "avg": 61, + "p50": 58, + "p95": 109, + "p99": 245, + "max": 309 + } + }, + "anycable-1k": { + "clients": 1000, + "connectedClients": 1000, + "deliveryRatePct": 100, + "connectFailures": 0, + "latencyRawMs": { + "avg": 15, + "p50": 15, + "p95": 25, + "p99": 32, + "max": 50 + } + }, + "anycable-5k": { + "clients": 5000, + "connectedClients": 5000, + "deliveryRatePct": 100, + "connectFailures": 0, + "latencyRawMs": { + "avg": 175, + "p50": 225, + "p95": 446, + "p99": 685, + "max": 712 + } + } + }, + "jitter": { + "solidcable-5k": { + "clients": 5000, + "connectedClients": 5000, + "deliveryRatePct": 28.05, + "connectFailures": 0, + "latencyRawMs": { + "avg": 84, + "p50": 84, + "p95": 151, + "p99": 183, + "max": 216 + } + }, + "actioncable-5k": { + "clients": 5000, + "connectedClients": 5000, + "deliveryRatePct": 26.7, + "connectFailures": 0, + "latencyRawMs": { + "avg": 51, + "p50": 45, + "p95": 101, + "p99": 239, + "max": 270 + } + }, + "anycable-5k": { + "clients": 5000, + "connectedClients": 5000, + "deliveryRatePct": 100, + "connectFailures": 0, + "latencyRawMs": { + "avg": 684, + "p50": 230, + "p95": 3908, + "p99": 5976, + "max": 8862 + } + } + }, + "capacityUnderLoad": { + "solidcable-10k": { + "clients": 10000, + "connectedClients": 6003, + "deliveryRatePct": 0, + "connectFailures": 0, + "latencyRawMs": { + "avg": 0, + "p50": 0, + "p95": 0, + "p99": 0, + "max": 0 + } + }, + "actioncable-10k": { + "clients": 10000, + "connectedClients": 6001, + "deliveryRatePct": 0, + "connectFailures": 0, + "latencyRawMs": { + "avg": 0, + "p50": 0, + "p95": 0, + "p99": 0, + "max": 0 + } + }, + "anycable-10k": { + "clients": 10000, + "connectedClients": 10000, + "deliveryRatePct": 100, + "connectFailures": 0, + "latencyRawMs": { + "avg": 304, + "p50": 255, + "p95": 705, + "p99": 921, + "max": 9764 + } + } + }, + "idle": { + "solidcable": { + "connected": 52000, + "welcomed": 52000, + "subscribed": 52000, + "failed": 0 + }, + "actioncable": { + "connected": 52000, + "welcomed": 52000, + "subscribed": 52000, + "failed": 0 + }, + "anycable": { + "connected": 156000, + "welcomed": 156000, + "subscribed": 156000, + "failed": 0 + } + }, + "avalanche": { + "solidcable-5k": { + "clients": 5000, + "initiallyConnected": 5000, + "disconnected": 5000, + "reconnected": 4785, + "reconnectRatePct": 95.7, + "neverReconnected": 215, + "recoveryTimeMs": 7640, + "reconnectMs": { + "p50": 4460, + "p95": 7137, + "p99": 7604, + "max": 7744 + } + }, + "actioncable-5k": { + "clients": 5000, + "initiallyConnected": 5000, + "disconnected": 5000, + "reconnected": 4813, + "reconnectRatePct": 96.26, + "neverReconnected": 187, + "recoveryTimeMs": 7526, + "reconnectMs": { + "p50": 4379, + "p95": 7091, + "p99": 7567, + "max": 7750 + } + }, + "anycable-5k": { + "clients": 5000, + "initiallyConnected": 5000, + "disconnected": 0, + "reconnected": 0, + "reconnectRatePct": 0, + "neverReconnected": 5000, + "recoveryTimeMs": 0, + "reconnectMs": { + "p50": 0, + "p95": 0, + "p99": 0, + "max": 0 + } + } + } + } +} \ No newline at end of file diff --git a/backend/results/rails-sharded-2026-06-28.json b/backend/results/rails-sharded-2026-06-28.json new file mode 100644 index 0000000..63ceed8 --- /dev/null +++ b/backend/results/rails-sharded-2026-06-28.json @@ -0,0 +1,195 @@ +{ + "benchmark": "rails-actioncable (sharded re-run)", + "capturedAt": "2026-06-28", + "method": "13 bench-runner shards (~77-770 cables/shard) via jitter-multi; corrects single-runner event-loop saturation", + "targets": [ + "Action Cable (Redis/Puma)", + "Solid Cable (DB/Puma)", + "AsyncCable (Falcon)", + "AnyCable (anycable-go RPC)" + ], + "latency": { + "solidcable": { + "1k": { + "p50": 62, + "p95": 110, + "p99": 119 + }, + "5k": { + "p50": 74, + "p95": 134, + "p99": 164 + } + }, + "actioncable": { + "1k": { + "p50": 9, + "p95": 17, + "p99": 47 + }, + "5k": { + "p50": 13, + "p95": 27, + "p99": 57 + } + }, + "asynccable": { + "1k": { + "p50": 11, + "p95": 17, + "p99": 80 + }, + "5k": { + "p50": 20, + "p95": 35, + "p99": 71 + } + }, + "anycable": { + "1k": { + "p50": 4, + "p95": 7, + "p99": 23 + }, + "5k": { + "p50": 7, + "p95": 11, + "p99": 31 + } + } + }, + "jitter": { + "solidcable": { + "deliveryPct": 78.08, + "lat": { + "p50": 71, + "p95": 125, + "p99": 153 + } + }, + "actioncable": { + "deliveryPct": 78.14, + "lat": { + "p50": 12, + "p95": 26, + "p99": 53 + } + }, + "asynccable": { + "deliveryPct": 78.14, + "lat": { + "p50": 18, + "p95": 34, + "p99": 82 + } + }, + "anycable": { + "deliveryPct": 99.92, + "lat": { + "p50": 7, + "p95": 4030, + "p99": 5964 + } + } + }, + "capacity10k": { + "solidcable": { + "connected": 10010, + "deliveryPct": 100, + "received": 1000994, + "expected": 1001000, + "trulyLost": 6, + "lat": { + "p50": 88, + "p95": 156, + "p99": 200 + } + }, + "actioncable": { + "connected": 10010, + "deliveryPct": 100, + "received": 1001000, + "expected": 1001000, + "trulyLost": 0, + "lat": { + "p50": 17, + "p95": 38, + "p99": 84 + } + }, + "asynccable": { + "connected": 10010, + "deliveryPct": 100, + "received": 1000990, + "expected": 1001000, + "trulyLost": 10, + "lat": { + "p50": 32, + "p95": 62, + "p99": 112 + } + }, + "anycable": { + "connected": 10010, + "deliveryPct": 100, + "received": 1001000, + "expected": 1001000, + "trulyLost": 0, + "lat": { + "p50": 11, + "p95": 19, + "p99": 31 + } + } + }, + "idle": { + "solidcable": { + "held": 52000, + "failed": 0, + "note": "prior same-method session" + }, + "actioncable": { + "held": 52000, + "failed": 0, + "note": "prior same-method session" + }, + "anycable": { + "held": 156000, + "failed": 0, + "note": "prior same-method session" + }, + "asynccable": { + "held": 52000, + "failed": 0 + } + }, + "avalanche": { + "asynccable": { + "disconnected": 4988, + "reconnectRatePct": 96.42, + "neverReconnected": 179, + "recoveryTimeMs": 7984 + }, + "_priorSession": { + "solidcable": { + "disconnected": 5000, + "reconnectRatePct": 95.7, + "recoveryTimeMs": 7640 + }, + "actioncable": { + "disconnected": 5000, + "reconnectRatePct": 96.26, + "recoveryTimeMs": 7526 + }, + "anycable": { + "disconnected": 0, + "reconnectRatePct": null, + "recoveryTimeMs": 0 + } + } + }, + "notes": { + "anycable_remeasured": "AnyCable latency+10K re-run in isolation 2026-06-28; the batch values (5K p99=2087ms, 10K 88%) were transient shared-tenant load noise (AnyCable ran last in a 30-min back-to-back batch). Isolated: 5K p99=31ms, 10K 100%/p99=31ms.", + "anycable_jitter_tail": "AnyCable jitter p99~6s is the resume backfill (missed messages replayed late but delivered), expected and matches the nodejs page." + } +} \ No newline at end of file diff --git a/backend/src/bench-runner/server.ts b/backend/src/bench-runner/server.ts index 993c4fc..b1e54ca 100644 --- a/backend/src/bench-runner/server.ts +++ b/backend/src/bench-runner/server.ts @@ -29,6 +29,7 @@ import { runJitterAnycableTraced } from "../lib/jitter-anycable-traced.js"; import { runAnycableTrace } from "../lib/anycable-trace.js"; import { runIdleAnycable, runIdleSocketio, runIdleUws } from "../lib/idle-runner.js"; import { runAvalancheSocketio } from "../lib/avalanche-runner.js"; +import { runAvalancheAnycable } from "../lib/avalanche-anycable-runner.js"; import { runDeployImpactSocketio } from "../lib/deploy-impact-runner.js"; import { runStandaloneDeployImpactSocketio } from "../lib/standalone-deploy-impact-runner.js"; import { runStandaloneDeployImpactAnycable } from "../lib/standalone-deploy-impact-anycable-runner.js"; @@ -170,11 +171,18 @@ app.post("/bench-jitter-anycable", async (req, res) => { const params = paramsFromQuery(req); const cableUrl = (req.query.cableUrl as string) || ANYCABLE_URL; const broadcastUrl = (req.query.broadcastUrl as string) || ANYCABLE_BROADCAST_URL; + // `?channel=BenchmarkChannel&acProtocol=actioncable-v1-json` targets a real + // Rails app (Action Cable / Solid Cable); the defaults target anycable-go's + // $pubsub channel over the extended protocol. + const channel = (req.query.channel as string) || undefined; + const acProtocol = (req.query.acProtocol as string) || undefined; await respondAsync(req, res, () => runJitterAnycable(params, { cableUrl, broadcastUrl, broadcastSecret: ANYCABLE_BROADCAST_SECRET || undefined, + channel, + acProtocol, }), ); }); @@ -285,9 +293,11 @@ app.post("/bench-idle-anycable", async (req, res) => { const stream = (req.query.stream as string) || "idle-probe"; const shardLabel = (req.query.shard as string) || undefined; const cableUrl = (req.query.cableUrl as string) || ANYCABLE_URL; + const channel = (req.query.channel as string) || undefined; + const acProtocol = (req.query.acProtocol as string) || undefined; await respondAsync(req, res, () => - runIdleAnycable({ n, holdSec, rampPerSec, stream }, cableUrl, shardLabel), + runIdleAnycable({ n, holdSec, rampPerSec, stream, channel, acProtocol }, cableUrl, shardLabel), ); }); @@ -351,6 +361,29 @@ app.post("/bench-avalanche-socketio", async (req, res) => { ); }); +// Action Cable avalanche — connect N cables (AnyCable / Action Cable / Solid +// Cable), wait for an externally-triggered redeploy, measure recovery. For the +// in-process adapters (redeploy Puma) connections drop and reconnect; for +// AnyCable (redeploy the Rails RPC backend) the gateway holds them and +// `disconnected` stays ~0. `?channel=` + `?acProtocol=` select the target. +app.post("/bench-avalanche-anycable", async (req, res) => { + const n = parseInt((req.query.n as string) || "1000", 10); + const rampPerSec = parseInt((req.query.ramp as string) || "200", 10); + const prearmSec = parseInt((req.query.prearm as string) || "120", 10); + const recoveryWaitSec = parseInt((req.query.recoveryWait as string) || "240", 10); + const stream = (req.query.stream as string) || "avalanche-ac"; + const cableUrl = (req.query.cableUrl as string) || ANYCABLE_URL; + const channel = (req.query.channel as string) || undefined; + const acProtocol = (req.query.acProtocol as string) || undefined; + + await respondAsync(req, res, () => + runAvalancheAnycable( + { n, rampPerSec, prearmSec, recoveryWaitSec, stream }, + { cableUrl, channel, acProtocol }, + ), + ); +}); + // Deploy-impact for clustered Socket.io + Redis adapter. Holds N clients // across the cluster nodes (round-robin), runs a publisher loop at a // fixed rate, and measures per-client gap (last-msg-before-disconnect diff --git a/backend/src/bench/idle-multi.ts b/backend/src/bench/idle-multi.ts index 9543cb9..090a1d9 100644 --- a/backend/src/bench/idle-multi.ts +++ b/backend/src/bench/idle-multi.ts @@ -22,6 +22,7 @@ import { writeFileSync } from "fs"; import { Agent, setGlobalDispatcher } from "undici"; import type { IdleResult } from "../lib/idle-runner.js"; +import { benchRunnerFetch } from "../lib/core/bench-runner-client.js"; import { fetchMetric, readRailwayToken } from "../lib/core/railway-api.js"; import { chart } from "../lib/core/chart.js"; import { resultPath } from "../lib/core/results-dir.js"; @@ -53,6 +54,11 @@ const stream = process.env.STREAM || "idle-probe"; // Optional override sent to each shard so the bench-runner targets a // different anycable-go service (e.g. anycable-go-pro for the Pro variant). const cableUrl = process.env.CABLE_URL; +// For a real Rails channel: CHANNEL=BenchmarkChannel and the base protocol +// AC_PROTOCOL=actioncable-v1-json (vanilla Action Cable / Solid Cable / +// AsyncCable). Omitted for standalone anycable-go ($pubsub over ext-json). +const channel = process.env.CHANNEL; +const acProtocol = process.env.AC_PROTOCOL; // TARGET=socketio switches the test to /bench-idle-socketio (Node-based // Socket.io). TARGET=uws targets /bench-idle-uws (uWebSockets.js). @@ -89,6 +95,8 @@ async function runShard(url: string, label: string): Promise { shard: label, }); if (target === "anycable" && cableUrl) qs.set("cableUrl", cableUrl); + if (target === "anycable" && channel) qs.set("channel", channel); + if (target === "anycable" && acProtocol) qs.set("acProtocol", acProtocol); if (target === "socketio" && socketioServerUrl) qs.set("serverUrl", socketioServerUrl); if (target === "uws" && uwsWsUrl) qs.set("wsUrl", uwsWsUrl); const endpoint = @@ -101,7 +109,7 @@ async function runShard(url: string, label: string): Promise { const ctrl = new AbortController(); const t = setTimeout(() => ctrl.abort(), SHARD_TIMEOUT_MS); try { - const res = await fetch(`${url}/${endpoint}?${qs.toString()}`, { + const res = await benchRunnerFetch(`${url}/${endpoint}?${qs.toString()}`, { method: "POST", signal: ctrl.signal, }); diff --git a/backend/src/bench/jitter-multi.ts b/backend/src/bench/jitter-multi.ts index 7e3dec8..00a82f0 100644 --- a/backend/src/bench/jitter-multi.ts +++ b/backend/src/bench/jitter-multi.ts @@ -87,6 +87,10 @@ if (protocol === "anycable") { if (process.env.CABLE_URL) protocolQuery.cableUrl = process.env.CABLE_URL; if (process.env.BROADCAST_URL) protocolQuery.broadcastUrl = process.env.BROADCAST_URL; + // Rails targets subscribe to a real channel over the base or extended + // Action Cable wire protocol; the nodejs $pubsub targets leave these unset. + if (process.env.CHANNEL) protocolQuery.channel = process.env.CHANNEL; + if (process.env.AC_PROTOCOL) protocolQuery.acProtocol = process.env.AC_PROTOCOL; } if (protocol === "socketio" || protocol === "socketio-csr") { if (process.env.SERVER_URL) protocolQuery.serverUrl = process.env.SERVER_URL; diff --git a/backend/src/bench/tests-manifest.ts b/backend/src/bench/tests-manifest.ts index ad89784..051a587 100644 --- a/backend/src/bench/tests-manifest.ts +++ b/backend/src/bench/tests-manifest.ts @@ -101,8 +101,35 @@ const TARGETS = { // See docs/socketioxide-comparison.md for the open question to the // library author. socketioxide: "http://socketioxide-server.railway.internal:3000", + + // Rails broadcasting comparison (AnyCable vs Action Cable vs Solid Cable). + // One Rails app (cable-bench/), three deployments selected by BENCH_MODE. + // Action Cable / Solid Cable terminate WebSockets in Puma and expose the + // app's POST /_bench/broadcast publish endpoint; the bench-runner reuses the + // anycable jitter/idle/avalanche endpoints with ?channel=BenchmarkChannel and + // ?acProtocol=actioncable-v1-json. AnyCable terminates in a separate + // anycable-go gateway (RPC -> the Rails app) and publishes via the gateway's + // /_broadcast, exactly like the standalone AnyCable target, but over the + // extended protocol and a real BenchmarkChannel. + railsSolidCable: "ws://rails-solidcable.railway.internal:3000/cable", + railsSolidCableBroadcast: "http://rails-solidcable.railway.internal:3000/_bench/broadcast", + railsActionCable: "ws://rails-actioncable.railway.internal:3000/cable", + railsActionCableBroadcast: "http://rails-actioncable.railway.internal:3000/_bench/broadcast", + railsAnyCable: "ws://anycable-go-rails.railway.internal:8080/cable", + railsAnyCableBroadcast: "http://anycable-go-rails.railway.internal:8080/_broadcast", + // AsyncCable: standard Action Cable wire protocol, served in-process by + // Falcon (async/fibers) instead of Puma. Same /cable + /_bench/broadcast + // surface as the other in-process Rails targets. + railsAsyncCable: "ws://rails-asynccable.railway.internal:3000/cable", + railsAsyncCableBroadcast: "http://rails-asynccable.railway.internal:3000/_bench/broadcast", }; +// Action Cable subscribe presets. BenchmarkChannel is the channel the Rails +// app exposes (cable-bench/app/channels/benchmark_channel.rb). Vanilla Action +// Cable / Solid Cable speak the base protocol; AnyCable the extended one. +const RAILS_BASE = { channel: "BenchmarkChannel", acProtocol: "actioncable-v1-json" }; +const RAILS_EXT = { channel: "BenchmarkChannel", acProtocol: "actioncable-v1-ext-json" }; + // Common knobs reused across tests. Keep these explicit so the manifest // is self-documenting; copy-paste is fine when a test deviates. const LATENCY_1K = { msgs: 100, interval: 500, ramp: 100, duration: 90, jitter: 999999 }; @@ -629,4 +656,208 @@ export const tests: TestSpec[] = [ baseline: {}, driftThresholdPct: 100, }, + + // =========================================================================== + // Rails broadcasting: AnyCable vs Action Cable vs Solid Cable + // + // One Rails app, three cable backends. All speak Action Cable at the app + // level (same BenchmarkChannel), but: Solid Cable and Action Cable terminate + // WebSockets in Puma (in-process Ruby; Solid Cable also polls the DB), while + // AnyCable offloads them to anycable-go (Rails is only the gRPC backend) and + // speaks the extended protocol with delivery guarantees. Baselines are empty + // until the first same-window Railway sweep. Reuses the anycable bench-runner + // endpoints via ?channel + ?acProtocol; no new endpoints for latency/jitter. + // =========================================================================== + + // Latency (jitter-disabled roundtrip) + { + id: "latency-rails-solidcable-1k", + description: "Roundtrip latency, Rails + Solid Cable, 1K subs", + category: "latency", + endpoint: "bench-jitter-anycable", + mode: "sync", + params: { n: 1000, ...LATENCY_1K, ...RAILS_BASE, cableUrl: TARGETS.railsSolidCable, broadcastUrl: TARGETS.railsSolidCableBroadcast }, + baseline: {}, + }, + { + id: "latency-rails-solidcable-5k", + description: "Roundtrip latency, Rails + Solid Cable, 5K subs", + category: "latency", + endpoint: "bench-jitter-anycable", + mode: "sync", + params: { n: 5000, ...LATENCY_10K, ...RAILS_BASE, cableUrl: TARGETS.railsSolidCable, broadcastUrl: TARGETS.railsSolidCableBroadcast }, + baseline: {}, + }, + { + id: "latency-rails-actioncable-1k", + description: "Roundtrip latency, Rails + Action Cable (Redis), 1K subs", + category: "latency", + endpoint: "bench-jitter-anycable", + mode: "sync", + params: { n: 1000, ...LATENCY_1K, ...RAILS_BASE, cableUrl: TARGETS.railsActionCable, broadcastUrl: TARGETS.railsActionCableBroadcast }, + baseline: {}, + }, + { + id: "latency-rails-actioncable-5k", + description: "Roundtrip latency, Rails + Action Cable (Redis), 5K subs", + category: "latency", + endpoint: "bench-jitter-anycable", + mode: "sync", + params: { n: 5000, ...LATENCY_10K, ...RAILS_BASE, cableUrl: TARGETS.railsActionCable, broadcastUrl: TARGETS.railsActionCableBroadcast }, + baseline: {}, + }, + { + id: "latency-rails-anycable-1k", + description: "Roundtrip latency, Rails + AnyCable (Go gateway), 1K subs", + category: "latency", + endpoint: "bench-jitter-anycable", + mode: "sync", + params: { n: 1000, ...LATENCY_1K, ...RAILS_EXT, cableUrl: TARGETS.railsAnyCable, broadcastUrl: TARGETS.railsAnyCableBroadcast }, + baseline: {}, + }, + { + id: "latency-rails-anycable-5k", + description: "Roundtrip latency, Rails + AnyCable (Go gateway), 5K subs", + category: "latency", + endpoint: "bench-jitter-anycable", + mode: "sync", + params: { n: 5000, ...LATENCY_10K, ...RAILS_EXT, cableUrl: TARGETS.railsAnyCable, broadcastUrl: TARGETS.railsAnyCableBroadcast }, + baseline: {}, + }, + + // Reliability under WiFi jitter. The headline: AnyCable's extended protocol + // resumes the stream and backfills missed messages (delivery ~100%); vanilla + // Action Cable and Solid Cable have no resume, so each offline window drops + // broadcasts for good. + { + id: "jitter-rails-solidcable-5k", + description: "Reliability under WiFi jitter, Rails + Solid Cable, 5K", + category: "jitter", + endpoint: "bench-jitter-anycable", + mode: "async", + params: { n: 5000, ...JITTER_10K, ...RAILS_BASE, cableUrl: TARGETS.railsSolidCable, broadcastUrl: TARGETS.railsSolidCableBroadcast, samplesCap: 5000 }, + baseline: {}, + driftThresholdPct: 15, + }, + { + id: "jitter-rails-actioncable-5k", + description: "Reliability under WiFi jitter, Rails + Action Cable (Redis), 5K", + category: "jitter", + endpoint: "bench-jitter-anycable", + mode: "async", + params: { n: 5000, ...JITTER_10K, ...RAILS_BASE, cableUrl: TARGETS.railsActionCable, broadcastUrl: TARGETS.railsActionCableBroadcast, samplesCap: 5000 }, + baseline: {}, + driftThresholdPct: 15, + }, + { + id: "jitter-rails-anycable-5k", + description: "Reliability under WiFi jitter, Rails + AnyCable, 5K", + category: "jitter", + endpoint: "bench-jitter-anycable", + mode: "async", + params: { n: 5000, ...JITTER_10K, ...RAILS_EXT, cableUrl: TARGETS.railsAnyCable, broadcastUrl: TARGETS.railsAnyCableBroadcast, samplesCap: 5000 }, + baseline: {}, + driftThresholdPct: 15, + }, + + // Idle capacity. In-process Puma (Solid/Action Cable) tops out far below the + // Go gateway; targets are sized to find each ceiling (in-process ~200K probe, + // AnyCable 1M). Fill targetServiceId after deploy to attach Railway memory/CPU. + { + id: "idle-rails-solidcable", + description: "Idle connections held, Rails + Solid Cable", + category: "idle", + endpoint: "bench-idle-anycable", + mode: "multi-shard", + numShards: 13, + perShardN: 4000, + params: { hold: 120, ramp: 200, stream: "idle-rails", ...RAILS_BASE, cableUrl: TARGETS.railsSolidCable }, + baseline: {}, + driftThresholdPct: 60, + }, + { + id: "idle-rails-actioncable", + description: "Idle connections held, Rails + Action Cable (Redis)", + category: "idle", + endpoint: "bench-idle-anycable", + mode: "multi-shard", + numShards: 13, + perShardN: 4000, + params: { hold: 120, ramp: 200, stream: "idle-rails", ...RAILS_BASE, cableUrl: TARGETS.railsActionCable }, + baseline: {}, + driftThresholdPct: 60, + }, + { + id: "idle-rails-anycable", + description: "Idle connections held, Rails + AnyCable (Go gateway)", + category: "idle", + endpoint: "bench-idle-anycable", + mode: "multi-shard", + numShards: 13, + perShardN: 12000, + params: { hold: 120, ramp: 200, stream: "idle-rails", ...RAILS_EXT, cableUrl: TARGETS.railsAnyCable }, + baseline: {}, + driftThresholdPct: 60, + }, + { + id: "idle-rails-asynccable", + description: "Idle connections held, Rails + AsyncCable (Falcon)", + category: "idle", + endpoint: "bench-idle-anycable", + mode: "multi-shard", + numShards: 13, + perShardN: 4000, + params: { hold: 120, ramp: 200, stream: "idle-rails", ...RAILS_BASE, cableUrl: TARGETS.railsAsyncCable }, + baseline: {}, + driftThresholdPct: 60, + }, + + // Deploy survival. Redeploy the Rails service mid-test. Action Cable / Solid + // Cable run WebSockets in Puma, so a deploy drops every connection; AnyCable + // runs them in anycable-go, so redeploying the Rails RPC backend leaves the + // fleet connected (expected disconnected ~0). + { + id: "avalanche-rails-solidcable-5k", + description: "Avalanche: 5K Rails + Solid Cable clients, app redeploy", + category: "avalanche", + endpoint: "bench-avalanche-anycable", + mode: "avalanche", + redeployServiceName: "rails-solidcable", + params: { n: 5000, ramp: 200, prearm: 180, recoveryWait: 300, stream: "avalanche-rails-sc", ...RAILS_BASE, cableUrl: TARGETS.railsSolidCable }, + baseline: {}, + driftThresholdPct: 100, + }, + { + id: "avalanche-rails-actioncable-5k", + description: "Avalanche: 5K Rails + Action Cable clients, app redeploy", + category: "avalanche", + endpoint: "bench-avalanche-anycable", + mode: "avalanche", + redeployServiceName: "rails-actioncable", + params: { n: 5000, ramp: 200, prearm: 180, recoveryWait: 300, stream: "avalanche-rails-ac", ...RAILS_BASE, cableUrl: TARGETS.railsActionCable }, + baseline: {}, + driftThresholdPct: 100, + }, + { + id: "avalanche-rails-anycable-5k", + description: "Avalanche: 5K Rails + AnyCable clients, RPC backend redeploy (should survive)", + category: "avalanche", + endpoint: "bench-avalanche-anycable", + mode: "avalanche", + redeployServiceName: "rails-anycable", + params: { n: 5000, ramp: 200, prearm: 180, recoveryWait: 300, stream: "avalanche-rails-any", ...RAILS_EXT, cableUrl: TARGETS.railsAnyCable }, + baseline: {}, + driftThresholdPct: 100, + }, + { + id: "avalanche-rails-asynccable-5k", + description: "Avalanche: 5K Rails + AsyncCable (Falcon) clients, app redeploy", + category: "avalanche", + endpoint: "bench-avalanche-anycable", + mode: "avalanche", + redeployServiceName: "rails-asynccable", + params: { n: 5000, ramp: 200, prearm: 180, recoveryWait: 300, stream: "avalanche-rails-asc", ...RAILS_BASE, cableUrl: TARGETS.railsAsyncCable }, + baseline: {}, + driftThresholdPct: 100, + }, ]; diff --git a/backend/src/lib/avalanche-anycable-runner.ts b/backend/src/lib/avalanche-anycable-runner.ts new file mode 100644 index 0000000..ad5b475 --- /dev/null +++ b/backend/src/lib/avalanche-anycable-runner.ts @@ -0,0 +1,183 @@ +// Avalanche runner for the Action Cable protocol (AnyCable, Action Cable, +// Solid Cable). Connects N @anycable/core cables, waits for an externally +// triggered redeploy of the target service, and measures how the fleet +// recovers. Mirrors runAvalancheSocketio so results land in the same +// AvalancheResult shape and the rebaseline classifier works unchanged. +// +// The architectural contrast this captures: +// - Action Cable / Solid Cable terminate WebSockets in the Puma process, +// so redeploying the Rails app drops every connection (disconnected ~= N) +// and they must reconnect. +// - AnyCable terminates WebSockets in the anycable-go gateway; redeploying +// the Rails RPC backend leaves the gateway (and the held connections) +// untouched, so disconnected stays ~0 — the fleet survives the deploy. +// +// Listeners are attached at cable creation, before any redeploy can fire, so +// the disconnect that signals the restart is never missed. + +import WebSocket from "ws"; +import { createCable } from "@anycable/core"; + +import { percentile } from "./core/stats.js"; +import { settleAfterRamp } from "./core/timing.js"; +import type { AvalancheParams, AvalancheResult } from "./avalanche-runner.js"; + +export interface AvalancheAnycableUrls { + cableUrl: string; + channel?: string; + acProtocol?: string; +} + +export async function runAvalancheAnycable( + p: AvalancheParams, + urls: AvalancheAnycableUrls +): Promise { + const protocol = urls.acProtocol ?? "actioncable-v1-ext-json"; + const channelName = urls.channel ?? "$pubsub"; + console.log( + `[avalanche-ac] target=${urls.cableUrl} channel=${channelName} proto=${protocol} n=${p.n} ramp=${p.rampPerSec}/s prearm=${p.prearmSec}s recoveryWait=${p.recoveryWaitSec}s` + ); + + const cables: ReturnType[] = []; + const startedAt = Date.now(); + + let initiallyConnected = 0; + let initialConnectDone = false; + let tearingDown = false; + + let disconnected = 0; + let firstDisconnectAt = 0; + let allDisconnectedAt = 0; + let restartDetectedAt = 0; + + let reconnected = 0; + let firstReconnectAt = 0; + let allReconnectedAt = 0; + const reconnectTimes: number[] = []; + + for (let i = 0; i < p.n; i++) { + const cable = createCable(urls.cableUrl, { + websocketImplementation: WebSocket as unknown as typeof globalThis.WebSocket, + protocol: protocol as never, + logLevel: "error" as never, + }); + + cable.on("connect", (event?: { reconnect?: boolean }) => { + if (tearingDown) return; + // First successful connect during the ramp. + if (!initialConnectDone) { + if (!(event && event.reconnect)) initiallyConnected++; + return; + } + // After ramp, a connect once a restart was detected is a recovery. + if (restartDetectedAt > 0) { + reconnected++; + const now = Date.now(); + reconnectTimes.push(now - restartDetectedAt); + if (reconnected === 1) firstReconnectAt = now; + if (reconnected >= initiallyConnected * 0.95 && !allReconnectedAt) { + allReconnectedAt = now; + } + } + }); + + const onDrop = () => { + if (tearingDown || !initialConnectDone) return; + disconnected++; + const now = Date.now(); + if (disconnected === 1) { + firstDisconnectAt = now; + restartDetectedAt = now; + } + if (disconnected === initiallyConnected) allDisconnectedAt = now; + }; + cable.on("disconnect", onDrop); + cable.on("close", onDrop); + + // Subscribing triggers the connection. "$pubsub" -> streamFrom (signed + // pub/sub), any other channel -> a real Rails channel with { stream_name }. + if (channelName !== "$pubsub") { + cable.subscribeTo(channelName, { stream_name: p.stream }); + } else { + cable.streamFrom(p.stream); + } + cables.push(cable); + + if ((i + 1) % p.rampPerSec === 0) { + await new Promise((r) => setTimeout(r, 1000)); + if ((i + 1) % 1000 === 0) console.log(`[avalanche-ac] ramped ${i + 1}/${p.n}`); + } + } + + await settleAfterRamp(); + initialConnectDone = true; + const rampElapsedMs = Date.now() - startedAt; + console.log( + `[avalanche-ac] all ramped (${rampElapsedMs}ms): ${initiallyConnected}/${p.n} connected` + ); + console.log(`[avalanche-ac] ready for redeploy — caller has ${p.prearmSec}s + recovery`); + + const armDeadline = Date.now() + p.prearmSec * 1000; + while (Date.now() < armDeadline && restartDetectedAt === 0) { + await new Promise((r) => setTimeout(r, 200)); + } + + if (restartDetectedAt === 0) { + // No disconnect observed during the window. For AnyCable this is the + // expected, healthy outcome: the gateway held every connection across the + // Rails redeploy. + console.log( + `[avalanche-ac] no disconnect within ${p.prearmSec}s — connections survived the deploy` + ); + } else { + console.log( + `[avalanche-ac] disconnect detected — waiting for reconnects (up to ${p.recoveryWaitSec}s)` + ); + const recoveryDeadline = restartDetectedAt + p.recoveryWaitSec * 1000; + while (Date.now() < recoveryDeadline) { + if (reconnected >= initiallyConnected * 0.95) break; + await new Promise((r) => setTimeout(r, 500)); + } + if (!allReconnectedAt) allReconnectedAt = Date.now(); + } + + tearingDown = true; + for (const c of cables) { + try { + c.disconnect(); + } catch { + /* ignore */ + } + } + + reconnectTimes.sort((a, b) => a - b); + const recoveryTimeMs = restartDetectedAt > 0 ? allReconnectedAt - restartDetectedAt : 0; + const neverReconnected = Math.max(0, initiallyConnected - reconnected); + + return { + clients: p.n, + initiallyConnected, + disconnected, + reconnected, + reconnectRatePct: + initiallyConnected > 0 + ? Number(((reconnected / initiallyConnected) * 100).toFixed(2)) + : 0, + neverReconnected, + neverReconnectedPct: + initiallyConnected > 0 + ? Number(((neverReconnected / initiallyConnected) * 100).toFixed(2)) + : 0, + disconnectSpreadMs: allDisconnectedAt > 0 ? allDisconnectedAt - firstDisconnectAt : 0, + recoveryTimeMs, + reconnectMs: { + p50: percentile(reconnectTimes, 50), + p95: percentile(reconnectTimes, 95), + p99: percentile(reconnectTimes, 99), + max: percentile(reconnectTimes, 100), + }, + totalDowntimeMs: recoveryTimeMs, + rampElapsedMs, + totalElapsedMs: Date.now() - startedAt, + }; +} diff --git a/backend/src/lib/idle-runner.ts b/backend/src/lib/idle-runner.ts index a9de5b7..d3427b9 100644 --- a/backend/src/lib/idle-runner.ts +++ b/backend/src/lib/idle-runner.ts @@ -108,6 +108,12 @@ export interface IdleParams { holdSec: number; rampPerSec: number; stream: string; + // AnyCable/Action Cable only. Channel to subscribe to (default "$pubsub" for + // the standalone anycable-go targets; "BenchmarkChannel" for a real Rails + // app) and the WebSocket subprotocol (extended "actioncable-v1-ext-json" for + // AnyCable, base "actioncable-v1-json" for vanilla Action Cable / Solid Cable). + channel?: string; + acProtocol?: string; } export interface IdleResult { @@ -135,8 +141,10 @@ export async function runIdleAnycable( const sockets: WebSocket[] = []; const startedAt = Date.now(); + const acProtocol = p.acProtocol ?? "actioncable-v1-ext-json"; + const channel = p.channel ?? "$pubsub"; for (let i = 0; i < p.n; i++) { - const ws = new WebSocket(cableUrl, ["actioncable-v1-ext-json"]); + const ws = new WebSocket(cableUrl, [acProtocol]); sockets.push(ws); // `failed` should count connection ATTEMPTS that never opened — @@ -156,13 +164,14 @@ export async function runIdleAnycable( const msg = JSON.parse(raw.toString()); if (msg.type === "welcome") { result.welcomed++; - // Subscribe to a $pubsub stream — works without RPC since - // anycable-go is started with ANYCABLE_PUBLIC=true. + // Subscribe. For "$pubsub" this works without RPC (anycable-go runs + // with ANYCABLE_PUBLIC=true); for a real Rails channel the gateway / + // Puma routes the subscribe command to the app. ws.send( JSON.stringify({ command: "subscribe", identifier: JSON.stringify({ - channel: "$pubsub", + channel, stream_name: p.stream, }), }) diff --git a/backend/src/lib/jitter-runners.ts b/backend/src/lib/jitter-runners.ts index d7870eb..c37d849 100644 --- a/backend/src/lib/jitter-runners.ts +++ b/backend/src/lib/jitter-runners.ts @@ -117,6 +117,16 @@ export interface AnycableUrls { cableUrl: string; broadcastUrl: string; broadcastSecret?: string; + // Channel to subscribe to. Defaults to "$pubsub" (anycable-go's public + // pub/sub channel, used by the standalone OSS/Pro targets via streamFrom). + // For a real Rails app, pass "BenchmarkChannel" and the driver subscribes + // to that named channel with { stream_name } params instead. + channel?: string; + // WebSocket subprotocol. AnyCable uses the extended Action Cable protocol + // ("actioncable-v1-ext-json") which carries the delivery-guarantee / + // resume machinery; vanilla Action Cable and Solid Cable speak the base + // protocol ("actioncable-v1-json"). + acProtocol?: string; } export async function runJitterAnycable( @@ -137,7 +147,7 @@ export async function runJitterAnycable( const cable = createCable(urls.cableUrl, { websocketImplementation: WebSocket as unknown as typeof globalThis.WebSocket, - protocol: "actioncable-v1-ext-json", + protocol: (urls.acProtocol ?? "actioncable-v1-ext-json") as never, // The @anycable/core types don't include "error" yet; the runtime // accepts any of error|warn|info|debug. logLevel: "error" as never, @@ -147,7 +157,12 @@ export async function runJitterAnycable( cable.on("connect", () => { stat.everConnected = true; }); - const channel = cable.streamFrom(p.stream); + // "$pubsub" -> anycable-go's signed pub/sub channel (streamFrom). Any + // other value -> a real Rails channel subscribed with { stream_name }. + const channel = + urls.channel && urls.channel !== "$pubsub" + ? cable.subscribeTo(urls.channel, { stream_name: p.stream }) + : cable.streamFrom(p.stream); channel.on("message", (msg: unknown) => recordMsg(stat, msg)); cables.push(cable); diff --git a/cable-bench-falcon/.dockerignore b/cable-bench-falcon/.dockerignore new file mode 100644 index 0000000..6751f09 --- /dev/null +++ b/cable-bench-falcon/.dockerignore @@ -0,0 +1,15 @@ +.git +.gitignore +# No encrypted credentials are used (SECRET_KEY_BASE_DUMMY=1 supplies the key). +# Copying master.key as a root-owned 0600 file breaks boot under USER rails. +/config/master.key +/log/* +/tmp/* +/storage/* +!/storage/.keep +/node_modules +/.bundle +/vendor/bundle +*.sqlite3 +*.sqlite3-* +.DS_Store diff --git a/cable-bench-falcon/.gitattributes b/cable-bench-falcon/.gitattributes new file mode 100644 index 0000000..8dc4323 --- /dev/null +++ b/cable-bench-falcon/.gitattributes @@ -0,0 +1,9 @@ +# See https://git-scm.com/docs/gitattributes for more about git attribute files. + +# Mark the database schema as having been generated. +db/schema.rb linguist-generated + +# Mark any vendored files as having been vendored. +vendor/* linguist-vendored +config/credentials/*.yml.enc diff=rails_credentials +config/credentials.yml.enc diff=rails_credentials diff --git a/cable-bench-falcon/.gitignore b/cable-bench-falcon/.gitignore new file mode 100644 index 0000000..fbcab40 --- /dev/null +++ b/cable-bench-falcon/.gitignore @@ -0,0 +1,35 @@ +# See https://help.github.com/articles/ignoring-files for more about ignoring files. +# +# Temporary files generated by your text editor or operating system +# belong in git's global ignore instead: +# `$XDG_CONFIG_HOME/git/ignore` or `~/.config/git/ignore` + +# Ignore bundler config. +/.bundle + +# Ignore all environment files. +/.env* + +# Ignore all logfiles and tempfiles. +/log/* +/tmp/* +!/log/.keep +!/tmp/.keep + +# Ignore pidfiles, but keep the directory. +/tmp/pids/* +!/tmp/pids/ +!/tmp/pids/.keep + +# Ignore storage (uploaded files in development and any SQLite databases). +/storage/* +!/storage/.keep +/tmp/storage/* +!/tmp/storage/ +!/tmp/storage/.keep + +/public/assets + +# Ignore key files for decrypting credentials and more. +/config/*.key + diff --git a/cable-bench-falcon/.rubocop.yml b/cable-bench-falcon/.rubocop.yml new file mode 100644 index 0000000..f9d86d4 --- /dev/null +++ b/cable-bench-falcon/.rubocop.yml @@ -0,0 +1,8 @@ +# Omakase Ruby styling for Rails +inherit_gem: { rubocop-rails-omakase: rubocop.yml } + +# Overwrite or add rules to create your own house style +# +# # Use `[a, [b, c]]` not `[ a, [ b, c ] ]` +# Layout/SpaceInsideArrayLiteralBrackets: +# Enabled: false diff --git a/cable-bench-falcon/.ruby-version b/cable-bench-falcon/.ruby-version new file mode 100644 index 0000000..f989260 --- /dev/null +++ b/cable-bench-falcon/.ruby-version @@ -0,0 +1 @@ +3.4.4 diff --git a/cable-bench-falcon/Dockerfile b/cable-bench-falcon/Dockerfile new file mode 100644 index 0000000..791b4e8 --- /dev/null +++ b/cable-bench-falcon/Dockerfile @@ -0,0 +1,36 @@ +# syntax=docker/dockerfile:1 +# Single image for the three Rails cable benchmark modes; bin/bench-entrypoint +# picks the adapter and process from BENCH_MODE. Mirrors the anycable-pro / +# socketioxide per-service pattern (railway.toml + --path-as-root cable-bench/). +ARG RUBY_VERSION=3.4.4 +FROM ruby:$RUBY_VERSION-slim AS base +WORKDIR /app +ENV RAILS_ENV=production \ + BUNDLE_DEPLOYMENT=1 \ + BUNDLE_WITHOUT=development:test \ + BUNDLE_PATH=/usr/local/bundle \ + RAILS_LOG_TO_STDOUT=1 \ + RAILS_SERVE_STATIC_FILES=1 \ + SECRET_KEY_BASE_DUMMY=1 +RUN apt-get update -qq && \ + apt-get install --no-install-recommends -y curl && \ + rm -rf /var/lib/apt/lists/* + +FROM base AS build +RUN apt-get update -qq && \ + apt-get install --no-install-recommends -y build-essential git pkg-config libyaml-dev libssl-dev && \ + rm -rf /var/lib/apt/lists/* +COPY Gemfile Gemfile.lock ./ +RUN bundle install && rm -rf "${BUNDLE_PATH}"/ruby/*/cache +COPY . . +RUN ./bin/rails assets:precompile + +FROM base +COPY --from=build /usr/local/bundle /usr/local/bundle +COPY --from=build /app /app +RUN useradd rails --create-home --shell /bin/bash && \ + mkdir -p storage tmp/pids log && \ + chown -R rails:rails db log storage tmp +USER rails +EXPOSE 3000 +ENTRYPOINT ["./bin/falcon-entrypoint"] diff --git a/cable-bench-falcon/Gemfile b/cable-bench-falcon/Gemfile new file mode 100644 index 0000000..4ec43fd --- /dev/null +++ b/cable-bench-falcon/Gemfile @@ -0,0 +1,63 @@ +source "https://rubygems.org" + +# actioncable-next must load before Rails / Action Cable so its load-path entry +# wins for `require "action_cable/..."`. It is the drop-in Action Cable fork +# (AnyCable) that adds the ActionCable::Server::Socket abstraction async-cable +# builds on. require:false — it is pulled in via the explicit action_cable +# requires in config/application.rb, not auto-required by name. +gem "actioncable-next", require: false + +# Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main" +gem "rails", "~> 8.1.3" +# The modern asset pipeline for Rails [https://github.com/rails/propshaft] +gem "propshaft" +# Use sqlite3 as the database for Active Record +gem "sqlite3", ">= 2.1" + +# AsyncCable target: fiber-based Action Cable served in-process by Falcon +# (async ecosystem) instead of Puma's threads. Same Action Cable wire protocol; +# the WebSockets are handled by Async::Cable::Middleware on a Falcon reactor. +gem "falcon" +gem "async-cable", "~> 0.3.1", require: "async/cable" + +# Use JavaScript with ESM import maps [https://github.com/rails/importmap-rails] +gem "importmap-rails" +# Hotwire's SPA-like page accelerator [https://turbo.hotwired.dev] +gem "turbo-rails" +# Hotwire's modest JavaScript framework [https://stimulus.hotwired.dev] +gem "stimulus-rails" + +# Windows does not include zoneinfo files, so bundle the tzinfo-data gem +gem "tzinfo-data", platforms: %i[ windows jruby ] + +# Use the database-backed adapters for Rails.cache, Active Job, and Action Cable +gem "solid_cache" +gem "solid_queue" +gem "solid_cable" + +# Cross-process broadcast for the Falcon workers, matching the Action Cable +# Redis-adapter target so the only variable is the WS engine (Falcon fibers +# vs Puma threads). +gem "redis", ">= 4.0" + +# Reduces boot times through caching; required in config/boot.rb +gem "bootsnap", require: false + +group :development, :test do + # See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem + gem "debug", platforms: %i[ mri windows ], require: "debug/prelude" + + # Audits gems for known security defects (use config/bundler-audit.yml to ignore issues) + gem "bundler-audit", require: false + + # Static analysis for security vulnerabilities [https://brakemanscanner.org/] + gem "brakeman", require: false + + # Omakase Ruby styling [https://github.com/rails/rubocop-rails-omakase/] + gem "rubocop-rails-omakase", require: false +end + +group :development do + # Use console on exceptions pages [https://github.com/rails/web-console] + gem "web-console" +end diff --git a/cable-bench-falcon/Gemfile.lock b/cable-bench-falcon/Gemfile.lock new file mode 100644 index 0000000..e23491a --- /dev/null +++ b/cable-bench-falcon/Gemfile.lock @@ -0,0 +1,587 @@ +GEM + remote: https://rubygems.org/ + specs: + action_text-trix (2.1.19) + railties + actioncable (8.1.3) + actionpack (= 8.1.3) + activesupport (= 8.1.3) + nio4r (~> 2.0) + websocket-driver (>= 0.6.1) + zeitwerk (~> 2.6) + actioncable-next (0.3.4) + actionpack (>= 7.0, <= 8.2) + activesupport (>= 7.0, <= 8.2) + nio4r (~> 2.0) + websocket-driver (>= 0.6.1) + zeitwerk (~> 2.6) + actionmailbox (8.1.3) + actionpack (= 8.1.3) + activejob (= 8.1.3) + activerecord (= 8.1.3) + activestorage (= 8.1.3) + activesupport (= 8.1.3) + mail (>= 2.8.0) + actionmailer (8.1.3) + actionpack (= 8.1.3) + actionview (= 8.1.3) + activejob (= 8.1.3) + activesupport (= 8.1.3) + mail (>= 2.8.0) + rails-dom-testing (~> 2.2) + actionpack (8.1.3) + actionview (= 8.1.3) + activesupport (= 8.1.3) + nokogiri (>= 1.8.5) + rack (>= 2.2.4) + rack-session (>= 1.0.1) + rack-test (>= 0.6.3) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) + useragent (~> 0.16) + actiontext (8.1.3) + action_text-trix (~> 2.1.15) + actionpack (= 8.1.3) + activerecord (= 8.1.3) + activestorage (= 8.1.3) + activesupport (= 8.1.3) + globalid (>= 0.6.0) + nokogiri (>= 1.8.5) + actionview (8.1.3) + activesupport (= 8.1.3) + builder (~> 3.1) + erubi (~> 1.11) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) + activejob (8.1.3) + activesupport (= 8.1.3) + globalid (>= 0.3.6) + activemodel (8.1.3) + activesupport (= 8.1.3) + activerecord (8.1.3) + activemodel (= 8.1.3) + activesupport (= 8.1.3) + timeout (>= 0.4.0) + activestorage (8.1.3) + actionpack (= 8.1.3) + activejob (= 8.1.3) + activerecord (= 8.1.3) + activesupport (= 8.1.3) + marcel (~> 1.0) + activesupport (8.1.3) + base64 + bigdecimal + concurrent-ruby (~> 1.0, >= 1.3.1) + connection_pool (>= 2.2.5) + drb + i18n (>= 1.6, < 2) + json + logger (>= 1.4.2) + minitest (>= 5.1) + securerandom (>= 0.3) + tzinfo (~> 2.0, >= 2.0.5) + uri (>= 0.13.1) + ast (2.4.3) + async (2.40.0) + console (~> 1.29) + fiber-annotation + io-event (~> 1.11) + metrics (~> 0.12) + traces (~> 0.18) + async-cable (0.3.1) + actioncable-next + async (~> 2.9) + async-websocket + async-container (0.37.0) + async (~> 2.22) + async-http (0.95.1) + async (>= 2.10.2) + async-pool (~> 0.11) + io-endpoint (~> 0.14) + io-stream (~> 0.6) + metrics (~> 0.12) + protocol-http (~> 0.62) + protocol-http1 (~> 0.39) + protocol-http2 (~> 0.26) + protocol-url (~> 0.2) + traces (~> 0.10) + async-http-cache (0.4.6) + async-http (~> 0.56) + async-pool (0.11.2) + async (>= 2.0) + async-service (0.24.1) + async + async-container (~> 0.34) + string-format (~> 0.2) + async-utilization (0.4.0) + console (~> 1.0) + async-websocket (0.30.1) + async-http (~> 0.76) + protocol-http (~> 0.34) + protocol-rack (~> 0.7) + protocol-websocket (~> 0.17) + bake (0.25.0) + bigdecimal + samovar (~> 2.1) + base64 (0.3.0) + bigdecimal (4.1.2) + bindex (0.8.1) + bootsnap (1.24.6) + msgpack (~> 1.2) + brakeman (8.0.5) + racc + builder (3.3.0) + bundler-audit (0.9.3) + bundler (>= 1.2.0) + thor (~> 1.0) + concurrent-ruby (1.3.7) + connection_pool (3.0.2) + console (1.36.0) + fiber-annotation + fiber-local (~> 1.1) + json + crass (1.0.7) + date (3.5.1) + debug (1.11.1) + irb (~> 1.10) + reline (>= 0.3.8) + drb (2.2.3) + erb (6.0.4) + erubi (1.13.1) + et-orbi (1.4.0) + tzinfo + falcon (0.55.5) + async + async-container (~> 0.20) + async-http (~> 0.75) + async-http-cache (~> 0.4) + async-service (~> 0.19) + async-utilization (~> 0.3) + bundler + localhost (~> 1.1) + openssl (>= 3.0) + protocol-http (~> 0.31) + protocol-rack (~> 0.7) + samovar (~> 2.3) + fiber-annotation (0.2.0) + fiber-local (1.1.0) + fiber-storage + fiber-storage (1.0.1) + fugit (1.12.2) + et-orbi (~> 1.4) + raabro (~> 1.4) + globalid (1.4.0) + activesupport (>= 6.1) + i18n (1.15.2) + concurrent-ruby (~> 1.0) + importmap-rails (2.2.3) + actionpack (>= 6.0.0) + activesupport (>= 6.0.0) + railties (>= 6.0.0) + io-console (0.8.2) + io-endpoint (0.17.2) + io-event (1.16.4) + io-stream (0.13.1) + irb (1.18.0) + pp (>= 0.6.0) + prism (>= 1.3.0) + rdoc (>= 4.0.0) + reline (>= 0.4.2) + json (2.20.0) + language_server-protocol (3.17.0.5) + lint_roller (1.1.0) + localhost (1.8.0) + bake + logger (1.7.0) + loofah (2.25.1) + crass (~> 1.0.2) + nokogiri (>= 1.12.0) + mail (2.9.0) + logger + mini_mime (>= 0.1.1) + net-imap + net-pop + net-smtp + marcel (1.2.1) + metrics (0.15.0) + mini_mime (1.1.5) + minitest (6.0.6) + drb (~> 2.0) + prism (~> 1.5) + msgpack (1.8.3) + net-imap (0.6.4.1) + date + net-protocol + net-pop (0.1.2) + net-protocol + net-protocol (0.2.2) + timeout + net-smtp (0.5.1) + net-protocol + nio4r (2.7.5) + nokogiri (1.19.4-aarch64-linux-gnu) + racc (~> 1.4) + nokogiri (1.19.4-aarch64-linux-musl) + racc (~> 1.4) + nokogiri (1.19.4-arm-linux-gnu) + racc (~> 1.4) + nokogiri (1.19.4-arm-linux-musl) + racc (~> 1.4) + nokogiri (1.19.4-arm64-darwin) + racc (~> 1.4) + nokogiri (1.19.4-x86_64-darwin) + racc (~> 1.4) + nokogiri (1.19.4-x86_64-linux-gnu) + racc (~> 1.4) + nokogiri (1.19.4-x86_64-linux-musl) + racc (~> 1.4) + openssl (4.0.2) + parallel (2.1.0) + parser (3.3.11.1) + ast (~> 2.4.1) + racc + pp (0.6.4) + prettyprint + prettyprint (0.2.0) + prism (1.9.0) + propshaft (1.3.2) + actionpack (>= 7.0.0) + activesupport (>= 7.0.0) + rack + protocol-hpack (1.5.1) + protocol-http (0.62.2) + protocol-http1 (0.39.0) + protocol-http (~> 0.62) + protocol-http2 (0.26.0) + protocol-hpack (~> 1.4) + protocol-http (~> 0.62) + protocol-rack (0.22.1) + io-stream (>= 0.10) + protocol-http (~> 0.58) + rack (>= 1.0) + protocol-url (0.4.0) + protocol-websocket (0.21.1) + protocol-http (~> 0.2) + raabro (1.4.0) + racc (1.8.1) + rack (3.2.6) + rack-session (2.1.2) + base64 (>= 0.1.0) + rack (>= 3.0.0) + rack-test (2.2.0) + rack (>= 1.3) + rackup (2.3.1) + rack (>= 3) + rails (8.1.3) + actioncable (= 8.1.3) + actionmailbox (= 8.1.3) + actionmailer (= 8.1.3) + actionpack (= 8.1.3) + actiontext (= 8.1.3) + actionview (= 8.1.3) + activejob (= 8.1.3) + activemodel (= 8.1.3) + activerecord (= 8.1.3) + activestorage (= 8.1.3) + activesupport (= 8.1.3) + bundler (>= 1.15.0) + railties (= 8.1.3) + rails-dom-testing (2.3.0) + activesupport (>= 5.0.0) + minitest + nokogiri (>= 1.6) + rails-html-sanitizer (1.7.0) + loofah (~> 2.25) + 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) + railties (8.1.3) + actionpack (= 8.1.3) + activesupport (= 8.1.3) + irb (~> 1.13) + rackup (>= 1.0.0) + rake (>= 12.2) + thor (~> 1.0, >= 1.2.2) + tsort (>= 0.2) + zeitwerk (~> 2.6) + rainbow (3.1.1) + rake (13.4.2) + rbs (4.0.3) + logger + prism (>= 1.6.0) + tsort + rdoc (8.0.0) + erb + prism (>= 1.6.0) + rbs (>= 4.0.0) + tsort + redis (5.4.1) + redis-client (>= 0.22.0) + redis-client (0.30.0) + connection_pool + regexp_parser (2.12.0) + reline (0.6.3) + io-console (~> 0.5) + rubocop (1.88.0) + json (~> 2.3) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.1.0) + parallel (>= 1.10) + parser (>= 3.3.0.2) + rainbow (>= 2.2.2, < 4.0) + regexp_parser (>= 2.9.3, < 3.0) + rubocop-ast (>= 1.49.0, < 2.0) + ruby-progressbar (~> 1.7) + unicode-display_width (>= 2.4.0, < 4.0) + rubocop-ast (1.49.1) + parser (>= 3.3.7.2) + prism (~> 1.7) + rubocop-performance (1.26.1) + lint_roller (~> 1.1) + rubocop (>= 1.75.0, < 2.0) + rubocop-ast (>= 1.47.1, < 2.0) + rubocop-rails (2.35.5) + activesupport (>= 4.2.0) + lint_roller (~> 1.1) + rack (>= 1.1) + rubocop (>= 1.75.0, < 2.0) + rubocop-ast (>= 1.44.0, < 2.0) + rubocop-rails-omakase (1.1.0) + rubocop (>= 1.72) + rubocop-performance (>= 1.24) + rubocop-rails (>= 2.30) + ruby-progressbar (1.13.0) + samovar (2.5.1) + console (~> 1.0) + securerandom (0.4.1) + solid_cable (4.0.0) + 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-aarch64-linux-musl) + sqlite3 (2.9.5-arm-linux-gnu) + sqlite3 (2.9.5-arm-linux-musl) + sqlite3 (2.9.5-arm64-darwin) + sqlite3 (2.9.5-x86_64-darwin) + sqlite3 (2.9.5-x86_64-linux-gnu) + sqlite3 (2.9.5-x86_64-linux-musl) + stimulus-rails (1.3.4) + railties (>= 6.0.0) + string-format (0.2.0) + thor (1.5.0) + timeout (0.6.1) + traces (0.18.2) + tsort (0.2.0) + turbo-rails (2.0.23) + actionpack (>= 7.1.0) + railties (>= 7.1.0) + tzinfo (2.0.6) + concurrent-ruby (~> 1.0) + unicode-display_width (3.2.0) + unicode-emoji (~> 4.1) + unicode-emoji (4.2.0) + uri (1.1.1) + useragent (0.16.11) + web-console (4.3.0) + actionview (>= 8.0.0) + bindex (>= 0.4.0) + railties (>= 8.0.0) + websocket-driver (0.8.2) + base64 + websocket-extensions (>= 0.1.0) + websocket-extensions (0.1.5) + zeitwerk (2.8.2) + +PLATFORMS + aarch64-linux-gnu + aarch64-linux-musl + arm-linux-gnu + arm-linux-musl + arm64-darwin + x86_64-darwin + x86_64-linux-gnu + x86_64-linux-musl + +DEPENDENCIES + actioncable-next + async-cable (~> 0.3.1) + bootsnap + brakeman + bundler-audit + debug + falcon + importmap-rails + propshaft + rails (~> 8.1.3) + redis (>= 4.0) + rubocop-rails-omakase + solid_cable + solid_cache + solid_queue + sqlite3 (>= 2.1) + stimulus-rails + turbo-rails + tzinfo-data + web-console + +CHECKSUMS + action_text-trix (2.1.19) sha256=7012f59421009cf284aa651294896414d653a61a2417c9b8714c8476d2f74009 + actioncable (8.1.3) sha256=e5bc7f75e44e6a22de29c4f43176927c3a9ce4824464b74ed18d8226e75a80f0 + actioncable-next (0.3.4) sha256=c4246f50c0c534500d18e6d0e49987aa33cbf3c598deb59e95e685182d9201ca + actionmailbox (8.1.3) sha256=df7da474eaa0e70df4ed5a6fef66eb3b3b0f2dbf7f14518deee8d77f1b4aae59 + actionmailer (8.1.3) sha256=831f724891bb70d0aaa4d76581a6321124b6a752cb655c9346aae5479318448d + actionpack (8.1.3) sha256=af998cae4d47c5d581a2cc363b5c77eb718b7c4b45748d81b1887b25621c29a3 + actiontext (8.1.3) sha256=d291019c00e1ea9e6463011fa214f6081a56d7b9a1d224e7d3f6384c1dafc7d2 + actionview (8.1.3) sha256=1347c88c7f3edb38100c5ce0e9fb5e62d7755f3edc1b61cce2eb0b2c6ea2fd5d + activejob (8.1.3) sha256=a149b1766aa8204c3c3da7309e4becd40fcd5529c348cffbf6c9b16b565fe8d3 + activemodel (8.1.3) sha256=90c05cbe4cef3649b8f79f13016191ea94c4525ce4a5c0fb7ef909c4b91c8219 + activerecord (8.1.3) sha256=8003be7b2466ba0a2a670e603eeb0a61dd66058fccecfc49901e775260ac70ab + activestorage (8.1.3) sha256=0564ce9309143951a67615e1bb4e090ee54b8befed417133cae614479b46384d + activesupport (8.1.3) sha256=21a5e0dfbd4c3ddd9e1317ec6a4d782fa226e7867dc70b0743acda81a1dca20e + ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383 + async (2.40.0) sha256=52c7cf92b7e12fec4054f721b2fc9df401940d65a076f7e771cd6af18947af66 + async-cable (0.3.1) sha256=437eaf75ffb52381167391aa4178bf5b3d899d10cfd6a4a8add31d05433c4974 + async-container (0.37.0) sha256=16bd50a6d3ba818d917160a96a965a2cde0fd0e9959715741cbeeba90d68d315 + async-http (0.95.1) sha256=0c3dd458c204c06d5c4b20b01bbec4794a1203db627fb2ce536e1799ec14786c + async-http-cache (0.4.6) sha256=2038d1f093182f16b50b4db271c25085e3938da10bfcfc2904cadb0530fddfd6 + async-pool (0.11.2) sha256=0a43a17b02b04d9c451b7d12fafa9a50e55dc6dd00d4369aca00433f16a7e3ed + async-service (0.24.1) sha256=3ee313fa2d6c1427ffac68f42f272889c496fa1088cf1432b1e7a5f5bff56b06 + async-utilization (0.4.0) sha256=4da53cb1733a12c9cf70ff22bd37d29c10fa9162a03ddb10f34e12acd171fe32 + async-websocket (0.30.1) sha256=54bb8a8f184e4aa64434c7a78ecc55850a67a3d1dcd02e3ae787376e2c673936 + bake (0.25.0) sha256=a47bdc6a26addc048827debc36fe27bb4d5d71ac2958ad910b5386e9baf49869 + base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b + bigdecimal (4.1.2) sha256=53d217666027eab4280346fba98e7d5b66baaae1b9c3c1c0ffe89d48188a3fbd + bindex (0.8.1) sha256=7b1ecc9dc539ed8bccfc8cb4d2732046227b09d6f37582ff12e50a5047ceb17e + bootsnap (1.24.6) sha256=c60bab88c70332290f0a2636a288f675299eb4f804a02a3c085b42eca9da164a + brakeman (8.0.5) sha256=03735f9690d3fd4b32d66aacbf0a6d15a84266bdd06b32c05c8ecc8f6021d2be + builder (3.3.0) sha256=497918d2f9dca528fdca4b88d84e4ef4387256d984b8154e9d5d3fe5a9c8835f + bundler-audit (0.9.3) sha256=81c8766c71e47d0d28a0f98c7eed028539f21a6ea3cd8f685eb6f42333c9b4e9 + concurrent-ruby (1.3.7) sha256=4412caec3a5ea2e5fdc52076724c071a81f2c0593d83b2ac8cbb8ca63b3151b0 + connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a + console (1.36.0) sha256=45599ea906cf80a73d8941f03abf873fe66a6a954e0bac5bc1c01e2cdc406f07 + crass (1.0.7) sha256=94868719948664c89ddcaf0a37c65048413dfcb1c869470a5f7a7ceb5390b295 + date (3.5.1) sha256=750d06384d7b9c15d562c76291407d89e368dda4d4fff957eb94962d325a0dc0 + debug (1.11.1) sha256=2e0b0ac6119f2207a6f8ac7d4a73ca8eb4e440f64da0a3136c30343146e952b6 + drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373 + erb (6.0.4) sha256=38e3803694be357fe2bfe312487c74beaf9fb4e5beb3e22498952fe1645b95d9 + erubi (1.13.1) sha256=a082103b0885dbc5ecf1172fede897f9ebdb745a4b97a5e8dc63953db1ee4ad9 + et-orbi (1.4.0) sha256=6c7e3c90779821f9e3b324c5e96fda9767f72995d6ae435b96678a4f3e2de8bc + falcon (0.55.5) sha256=8622db11361b6678578dd7e06b953aaef10e5006929ced618069bb62b0ff118d + fiber-annotation (0.2.0) sha256=7abfadf1d119f508867d4103bf231c0354d019cc39a5738945dec2edadaf6c03 + fiber-local (1.1.0) sha256=c885f94f210fb9b05737de65d511136ea602e00c5105953748aa0f8793489f06 + fiber-storage (1.0.1) sha256=f48e5b6d8b0be96dac486332b55cee82240057065dc761c1ea692b2e719240e1 + fugit (1.12.2) sha256=643f2bf28db263bd400cbf8e0dd8b76b2c9b94bdb130e12d2394de04d9c20e5e + globalid (1.4.0) sha256=037f12fbf1d9d7a014d501c2d5c77356fd4ddd96d7a7991d6700bba96706f427 + i18n (1.15.2) sha256=00f9eb62412fe593b2a65a97daa75300d37abb8f7202ec748e94b6d46a9dd1b5 + importmap-rails (2.2.3) sha256=7101be2a4dc97cf1558fb8f573a718404c5f6bcfe94f304bf1f39e444feeb16a + io-console (0.8.2) sha256=d6e3ae7a7cc7574f4b8893b4fca2162e57a825b223a177b7afa236c5ef9814cc + io-endpoint (0.17.2) sha256=3feaf766c116b35839c11fac68b6aaadc47887bb488902a57bf8e1d288fb3338 + io-event (1.16.4) sha256=98b04e3a5e374fe0ce20f69956d435a3430335920544e3c1a492bdf37c4bf6d6 + io-stream (0.13.1) sha256=570d7c4dfb0fbd767480b4a222048a2be6d9b78febc1ec68258d0e0a4cde20de + irb (1.18.0) sha256=de9454a0703a54704b9811a5ef31a60c86949fbf4013fcf244fabc7c775248e3 + json (2.20.0) sha256=9362bc6e55a952b056abf9167cf053358181c904cb70cd6eee0808ea830fc32b + language_server-protocol (3.17.0.5) sha256=fd1e39a51a28bf3eec959379985a72e296e9f9acfce46f6a79d31ca8760803cc + lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87 + localhost (1.8.0) sha256=df7ea825b4f64949c588c17efac86bc47ddc4460d723778abe933b71759b2701 + logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203 + loofah (2.25.1) sha256=d436c73dbd0c1147b16c4a41db097942d217303e1f7728704b37e4df9f6d2e04 + mail (2.9.0) sha256=6fa6673ecd71c60c2d996260f9ee3dd387d4673b8169b502134659ece6d34941 + marcel (1.2.1) sha256=1678e9360e32f9eafa917c80029e2f6d10b2715c66a4b87b6d0da9b9cd1f859f + metrics (0.15.0) sha256=61ded5bac95118e995b1bc9ed4a5f19bc9814928a312a85b200abbdac9039072 + mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef + minitest (6.0.6) sha256=153ea36d1d987a62942382b61075745042a2b3123b1cd48f4c3675af9cc7d6f1 + msgpack (1.8.3) sha256=8bda4a6428d3244e50d6bd55854d354edbada88a4e1f4f5731a39a0f86bee6a1 + net-imap (0.6.4.1) sha256=29f0360d75a7efd3539f16ac1957dea5c0a51ddeceb348db4553c3120914ea0d + net-pop (0.1.2) sha256=848b4e982013c15b2f0382792268763b748cce91c9e91e36b0f27ed26420dff3 + net-protocol (0.2.2) sha256=aa73e0cba6a125369de9837b8d8ef82a61849360eba0521900e2c3713aa162a8 + net-smtp (0.5.1) sha256=ed96a0af63c524fceb4b29b0d352195c30d82dd916a42f03c62a3a70e5b70736 + nio4r (2.7.5) sha256=6c90168e48fb5f8e768419c93abb94ba2b892a1d0602cb06eef16d8b7df1dca1 + nokogiri (1.19.4-aarch64-linux-gnu) sha256=1269fb644a6de405057a53dd5c762b1209b43ca7424f839454d3dbc677c31a8f + nokogiri (1.19.4-aarch64-linux-musl) sha256=35c65b9ce72b3bb03207bdbe7067915019dc18c1b9b59139684bd6690fdd01af + nokogiri (1.19.4-arm-linux-gnu) sha256=a301313e38bb065d68239e79734bcd6f56fb6efaacebde29e9abf2a4735340ca + nokogiri (1.19.4-arm-linux-musl) sha256=588923c101bcfa78869734d247d25b598674323e7f22474fc468f6e5647311eb + nokogiri (1.19.4-arm64-darwin) sha256=a46db9853286e6597b36ebc6953817d15acf3a299583eb3f89fdc6f91dd63527 + nokogiri (1.19.4-x86_64-darwin) sha256=7fd17057d3e1f00e9954a74b3cd76595d3d4a5ef233b7ed9599047c204f70551 + nokogiri (1.19.4-x86_64-linux-gnu) sha256=379fae440b28915e3f19d752ce2dcf8465ed2b2fbefd2a7ca0dd497bc981a06a + nokogiri (1.19.4-x86_64-linux-musl) sha256=17dfb7c1fa194ae02fbf7c51a7afc8d278045ab3fdacfd86f91d02d7b274470b + openssl (4.0.2) sha256=1037ad2868ae58df9ad917891c0c0f9815a1172f6846d4bcdd508e4c2ee747c2 + parallel (2.1.0) sha256=b35258865c2e31134c5ecb708beaaf6772adf9d5efae28e93e99260877b09356 + parser (3.3.11.1) sha256=d17ace7aabe3e72c3cc94043714be27cc6f852f104d81aa284c2281aecc65d54 + pp (0.6.4) sha256=dfcb0fce700c41456265922884f9fe195d7fbb0674a3578e6c0f69588e82b570 + prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193 + prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85 + propshaft (1.3.2) sha256=1d56a3e56a92c21bfc29caf07406b5386b00d4c47ddf357cf989a5a234b1389e + protocol-hpack (1.5.1) sha256=6feca238b8078da1cd295677d6f306c6001af92d75fe0643d33e6956cbc3ad91 + protocol-http (0.62.2) sha256=e1c1f2f56029c1af8c4e2b8a67d0d096c76620f3afd8d99d1dcd2f6b8ffa773b + protocol-http1 (0.39.0) sha256=e49b3f4cda6f5d94c76a323d2b7f6977cba3ebd082d2da437039594da77ad8eb + protocol-http2 (0.26.0) sha256=bac89cd78082b241ccd0cf7246f5160e4bb0c9c975fb4bf7deef5f88cc317486 + protocol-rack (0.22.1) sha256=1185d245927ef9849a603700d6991ca353bc89724fbf98efa4a4333ed62a9fc3 + protocol-url (0.4.0) sha256=64d4c03b6b51ad815ac6fdaf77a1d91e5baf9220d26becb846c5459dacdea9e1 + protocol-websocket (0.21.1) sha256=34325e4325697f0956877e67784bcc838cfd51ebbf4f8e9e5201be292041ee61 + raabro (1.4.0) sha256=d4fa9ff5172391edb92b242eed8be802d1934b1464061ae5e70d80962c5da882 + racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f + rack (3.2.6) sha256=5ed78e1f73b2e25679bec7d45ee2d4483cc4146eb1be0264fc4d94cb5ef212c2 + rack-session (2.1.2) sha256=595434f8c0c3473ae7d7ac56ecda6cc6dfd9d37c0b2b5255330aa1576967ffe8 + rack-test (2.2.0) sha256=005a36692c306ac0b4a9350355ee080fd09ddef1148a5f8b2ac636c720f5c463 + rackup (2.3.1) sha256=6c79c26753778e90983761d677a48937ee3192b3ffef6bc963c0950f94688868 + rails (8.1.3) sha256=6d017ba5348c98fc909753a8169b21d44de14d2a0b92d140d1a966834c3c9cd3 + rails-dom-testing (2.3.0) sha256=8acc7953a7b911ca44588bf08737bc16719f431a1cc3091a292bca7317925c1d + rails-html-sanitizer (1.7.0) sha256=28b145cceaf9cc214a9874feaa183c3acba036c9592b19886e0e45efc62b1e89 + railties (8.1.3) sha256=913eb0e0cb520aac687ffd74916bd726d48fa21f47833c6292576ef6a286de22 + rainbow (3.1.1) sha256=039491aa3a89f42efa1d6dec2fc4e62ede96eb6acd95e52f1ad581182b79bc6a + rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701 + rbs (4.0.3) sha256=5a7bf70e2628549d9a1f44eae447b2cfe55968a9c60cfff52693a4bdcc020e14 + rdoc (8.0.0) sha256=03bf8c08a9639658855a0cfd77c0abca8325c227693f7f33f82957811348c469 + redis (5.4.1) sha256=b5e675b57ad22b15c9bcc765d5ac26f60b675408af916d31527af9bd5a81faae + redis-client (0.30.0) sha256=743f11ed42f0a41a0341554087b077479fec7e2d47a7c123fd90a12c0db5e477 + regexp_parser (2.12.0) sha256=35a916a1d63190ab5c9009457136ae5f3c0c7512d60291d0d1378ba18ce08ebb + reline (0.6.3) sha256=1198b04973565b36ec0f11542ab3f5cfeeec34823f4e54cebde90968092b1835 + rubocop (1.88.0) sha256=e420ddf1662d0ef34bc8a2910ac4b396a7ddda0b51a708264405241734b08e0b + rubocop-ast (1.49.1) sha256=4412f3ee70f6fe4546cc489548e0f6fcf76cafcfa80fa03af67098ffed755035 + rubocop-performance (1.26.1) sha256=cd19b936ff196df85829d264b522fd4f98b6c89ad271fa52744a8c11b8f71834 + rubocop-rails (2.35.5) sha256=f00b3c936002ba8e9ac62e8607c54bb24cda44b36e41b9c7e4f3872e1b0f3fe3 + rubocop-rails-omakase (1.1.0) sha256=2af73ac8ee5852de2919abbd2618af9c15c19b512c4cfc1f9a5d3b6ef009109d + ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33 + samovar (2.5.1) sha256=8a9fc41eb8868084f0321eb41678c485cfbc7d282fb306c0be67c3284b1d2394 + securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1 + solid_cable (4.0.0) sha256=8379680ef6bf36e195eb876a6306ea290f87d5fa10bc4a757bc2a918f83229b5 + solid_cache (1.0.10) sha256=bc05a2fb3ac78a6f43cbb5946679cf9db67dd30d22939ededc385cb93e120d41 + solid_queue (1.4.0) sha256=e6a18d196f0b27cb6e3c77c5b31258b05fb634f8ed64fb1866ed164047216c2a + sqlite3 (2.9.5-aarch64-linux-gnu) sha256=78075b6337d3d182c6d2b4691049ed45cd220826160c9ea18946bf6a1de200dc + sqlite3 (2.9.5-aarch64-linux-musl) sha256=18c801185deb4adc01ddb281e8f672a39e3d1729979ca91e39439cd3eac0402d + sqlite3 (2.9.5-arm-linux-gnu) sha256=1bdfca0c7d63998c60b0f4a8e3c8df2d33800ccc4abd2d612eddbbbc92a4c48b + sqlite3 (2.9.5-arm-linux-musl) sha256=bae1109d12b2e9f588455967729b008e1ff4feb7761749df695019c9079913c6 + sqlite3 (2.9.5-arm64-darwin) sha256=d0cf444a70fc9395d513cfbcc1e6719e224aa645314e3824cb0474c721425aa2 + sqlite3 (2.9.5-x86_64-darwin) sha256=8e9caae38bd7ebb29cbeee3e7ab1d12dc2327d9a1b92c7fcf0dda05589627a81 + sqlite3 (2.9.5-x86_64-linux-gnu) sha256=233dbcb6714148dd23bc5aeb33e8efd6eac974969564ddd5794c23d5f52b231e + sqlite3 (2.9.5-x86_64-linux-musl) sha256=e7d3a7474e8af0f96150c21abc203fbab5437206bfcdf11deab7741c0ca516f2 + stimulus-rails (1.3.4) sha256=765676ffa1f33af64ce026d26b48e8ffb2e0b94e0f50e9119e11d6107d67cb06 + string-format (0.2.0) sha256=bc981c14116b061f12134549f32fa2d61a17b5a35dd6fd36596c21722a789af6 + thor (1.5.0) sha256=e3a9e55fe857e44859ce104a84675ab6e8cd59c650a49106a05f55f136425e73 + timeout (0.6.1) sha256=78f57368a7e7bbadec56971f78a3f5ecbcfb59b7fcbb0a3ed6ddc08a5094accb + traces (0.18.2) sha256=80f1649cb4daace1d7174b81f3b3b7427af0b93047759ba349960cb8f315e214 + tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f + turbo-rails (2.0.23) sha256=ee0d90733aafff056cf51ff11e803d65e43cae258cc55f6492020ec1f9f9315f + tzinfo (2.0.6) sha256=8daf828cc77bcf7d63b0e3bdb6caa47e2272dcfaf4fbfe46f8c3a9df087a829b + unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42 + unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f + uri (1.1.1) sha256=379fa58d27ffb1387eaada68c749d1426738bd0f654d812fcc07e7568f5c57c6 + useragent (0.16.11) sha256=700e6413ad4bb954bb63547fa098dddf7b0ebe75b40cc6f93b8d54255b173844 + web-console (4.3.0) sha256=e13b71301cdfc2093f155b5aa3a622db80b4672d1f2f713119cc7ec7ac6a6da4 + websocket-driver (0.8.2) sha256=97c556b019bf3410b4961002ac501621e9322d3f8a7bc02161a09301cc4c4146 + websocket-extensions (0.1.5) sha256=1c6ba63092cda343eb53fc657110c71c754c56484aad42578495227d717a8241 + zeitwerk (2.8.2) sha256=7212a61311083c604184b1ea2574b9aa05cd14f855a0841c06985cabe9181d12 + +BUNDLED WITH + 4.0.4 diff --git a/cable-bench-falcon/README.md b/cable-bench-falcon/README.md new file mode 100644 index 0000000..7db80e4 --- /dev/null +++ b/cable-bench-falcon/README.md @@ -0,0 +1,24 @@ +# README + +This README would normally document whatever steps are necessary to get the +application up and running. + +Things you may want to cover: + +* Ruby version + +* System dependencies + +* Configuration + +* Database creation + +* Database initialization + +* How to run the test suite + +* Services (job queues, cache servers, search engines, etc.) + +* Deployment instructions + +* ... diff --git a/cable-bench-falcon/Rakefile b/cable-bench-falcon/Rakefile new file mode 100644 index 0000000..9a5ea73 --- /dev/null +++ b/cable-bench-falcon/Rakefile @@ -0,0 +1,6 @@ +# Add your own tasks in files placed in lib/tasks ending in .rake, +# for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. + +require_relative "config/application" + +Rails.application.load_tasks diff --git a/cable-bench-falcon/app/assets/images/.keep b/cable-bench-falcon/app/assets/images/.keep new file mode 100644 index 0000000..e69de29 diff --git a/cable-bench-falcon/app/assets/stylesheets/application.css b/cable-bench-falcon/app/assets/stylesheets/application.css new file mode 100644 index 0000000..fe93333 --- /dev/null +++ b/cable-bench-falcon/app/assets/stylesheets/application.css @@ -0,0 +1,10 @@ +/* + * This is a manifest file that'll be compiled into application.css. + * + * With Propshaft, assets are served efficiently without preprocessing steps. You can still include + * application-wide styles in this file, but keep in mind that CSS precedence will follow the standard + * cascading order, meaning styles declared later in the document or manifest will override earlier ones, + * depending on specificity. + * + * Consider organizing styles into separate files for maintainability. + */ diff --git a/cable-bench-falcon/app/channels/application_cable/channel.rb b/cable-bench-falcon/app/channels/application_cable/channel.rb new file mode 100644 index 0000000..d672697 --- /dev/null +++ b/cable-bench-falcon/app/channels/application_cable/channel.rb @@ -0,0 +1,4 @@ +module ApplicationCable + class Channel < ActionCable::Channel::Base + end +end diff --git a/cable-bench-falcon/app/channels/application_cable/connection.rb b/cable-bench-falcon/app/channels/application_cable/connection.rb new file mode 100644 index 0000000..333d36f --- /dev/null +++ b/cable-bench-falcon/app/channels/application_cable/connection.rb @@ -0,0 +1,7 @@ +module ApplicationCable + # The benchmark connects anonymous clients (the load generator opens raw + # WebSockets and subscribes to BenchmarkChannel). No app-level auth is + # performed, so every adapter is measured on the same handshake cost. + class Connection < ActionCable::Connection::Base + end +end diff --git a/cable-bench-falcon/app/channels/benchmark_channel.rb b/cable-bench-falcon/app/channels/benchmark_channel.rb new file mode 100644 index 0000000..c3b2ca2 --- /dev/null +++ b/cable-bench-falcon/app/channels/benchmark_channel.rb @@ -0,0 +1,16 @@ +# The one channel the harness subscribes to. A client subscribes with +# `{ channel: "BenchmarkChannel", stream_name: "" }` and receives every +# message broadcast to that stream. This is identical Action Cable code for +# all three adapters (Solid Cable, classic Action Cable / Redis, AnyCable) — +# what differs is the transport underneath, not the app. +class BenchmarkChannel < ApplicationCable::Channel + def subscribed + stream_from params[:stream_name] + end + + # Client-to-client fan-out used by the optional whispers test. Mirrors the + # Node socket.io server's whisper handler so the harness can reuse its driver. + def whisper(data) + ActionCable.server.broadcast(params[:stream_name], data) + end +end diff --git a/cable-bench-falcon/app/controllers/application_controller.rb b/cable-bench-falcon/app/controllers/application_controller.rb new file mode 100644 index 0000000..c353756 --- /dev/null +++ b/cable-bench-falcon/app/controllers/application_controller.rb @@ -0,0 +1,7 @@ +class ApplicationController < ActionController::Base + # Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has. + allow_browser versions: :modern + + # Changes to the importmap will invalidate the etag for HTML responses + stale_when_importmap_changes +end diff --git a/cable-bench-falcon/app/controllers/bench_controller.rb b/cable-bench-falcon/app/controllers/bench_controller.rb new file mode 100644 index 0000000..2ca8a13 --- /dev/null +++ b/cable-bench-falcon/app/controllers/bench_controller.rb @@ -0,0 +1,41 @@ +# The harness publishes through Rails so every adapter shares one publish +# path: POST /_bench/broadcast {stream, data} -> ActionCable.server.broadcast. +# For AnyCable this is patched to publish through the broker (extended +# protocol, reliable streams); for Action Cable / Solid Cable it goes through +# the Redis / DB adapter. Same code, different transport. +class BenchController < ActionController::Base + # The load generator posts JSON without a CSRF token. + skip_forgery_protection + + before_action :authorize, only: :broadcast + + # GET /health — liveness probe (open, no auth). + def health + render json: { status: "ok", mode: ENV.fetch("CABLE_ADAPTER", "solid_cable") } + end + + # POST /_bench/broadcast {stream, data} + # `data` arrives as a JSON string (the harness pre-serializes it); broadcast + # the parsed object so subscribers receive { seq, sentAt, text }. + def broadcast + payload = + begin + JSON.parse(params.require(:data)) + rescue JSON::ParserError, TypeError + params[:data] + end + ActionCable.server.broadcast(params.require(:stream), payload) + head :ok + end + + private + + # Optional bearer gate, matching the bench-runner's BENCH_RUNNER_TOKEN. If + # the env var is unset (local dev), the endpoint is open. + def authorize + expected = ENV["BENCH_RUNNER_TOKEN"] + return if expected.blank? + provided = request.headers["Authorization"].to_s.delete_prefix("Bearer ") + head :unauthorized unless ActiveSupport::SecurityUtils.secure_compare(provided, expected) + end +end diff --git a/cable-bench-falcon/app/controllers/concerns/.keep b/cable-bench-falcon/app/controllers/concerns/.keep new file mode 100644 index 0000000..e69de29 diff --git a/cable-bench-falcon/app/helpers/application_helper.rb b/cable-bench-falcon/app/helpers/application_helper.rb new file mode 100644 index 0000000..de6be79 --- /dev/null +++ b/cable-bench-falcon/app/helpers/application_helper.rb @@ -0,0 +1,2 @@ +module ApplicationHelper +end diff --git a/cable-bench-falcon/app/javascript/application.js b/cable-bench-falcon/app/javascript/application.js new file mode 100644 index 0000000..0d7b494 --- /dev/null +++ b/cable-bench-falcon/app/javascript/application.js @@ -0,0 +1,3 @@ +// Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails +import "@hotwired/turbo-rails" +import "controllers" diff --git a/cable-bench-falcon/app/javascript/controllers/application.js b/cable-bench-falcon/app/javascript/controllers/application.js new file mode 100644 index 0000000..1213e85 --- /dev/null +++ b/cable-bench-falcon/app/javascript/controllers/application.js @@ -0,0 +1,9 @@ +import { Application } from "@hotwired/stimulus" + +const application = Application.start() + +// Configure Stimulus development experience +application.debug = false +window.Stimulus = application + +export { application } diff --git a/cable-bench-falcon/app/javascript/controllers/hello_controller.js b/cable-bench-falcon/app/javascript/controllers/hello_controller.js new file mode 100644 index 0000000..5975c07 --- /dev/null +++ b/cable-bench-falcon/app/javascript/controllers/hello_controller.js @@ -0,0 +1,7 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + connect() { + this.element.textContent = "Hello World!" + } +} diff --git a/cable-bench-falcon/app/javascript/controllers/index.js b/cable-bench-falcon/app/javascript/controllers/index.js new file mode 100644 index 0000000..1156bf8 --- /dev/null +++ b/cable-bench-falcon/app/javascript/controllers/index.js @@ -0,0 +1,4 @@ +// Import and register all your controllers from the importmap via controllers/**/*_controller +import { application } from "controllers/application" +import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading" +eagerLoadControllersFrom("controllers", application) diff --git a/cable-bench-falcon/app/jobs/application_job.rb b/cable-bench-falcon/app/jobs/application_job.rb new file mode 100644 index 0000000..d394c3d --- /dev/null +++ b/cable-bench-falcon/app/jobs/application_job.rb @@ -0,0 +1,7 @@ +class ApplicationJob < ActiveJob::Base + # Automatically retry jobs that encountered a deadlock + # retry_on ActiveRecord::Deadlocked + + # Most jobs are safe to ignore if the underlying records are no longer available + # discard_on ActiveJob::DeserializationError +end diff --git a/cable-bench-falcon/app/models/application_record.rb b/cable-bench-falcon/app/models/application_record.rb new file mode 100644 index 0000000..b63caeb --- /dev/null +++ b/cable-bench-falcon/app/models/application_record.rb @@ -0,0 +1,3 @@ +class ApplicationRecord < ActiveRecord::Base + primary_abstract_class +end diff --git a/cable-bench-falcon/app/models/concerns/.keep b/cable-bench-falcon/app/models/concerns/.keep new file mode 100644 index 0000000..e69de29 diff --git a/cable-bench-falcon/app/views/layouts/application.html.erb b/cable-bench-falcon/app/views/layouts/application.html.erb new file mode 100644 index 0000000..95012f0 --- /dev/null +++ b/cable-bench-falcon/app/views/layouts/application.html.erb @@ -0,0 +1,29 @@ + + + + <%= content_for(:title) || "Cable Bench" %> + + + + + <%= csrf_meta_tags %> + <%= csp_meta_tag %> + + <%= yield :head %> + + <%# Enable PWA manifest for installable apps (make sure to enable in config/routes.rb too!) %> + <%#= tag.link rel: "manifest", href: pwa_manifest_path(format: :json) %> + + + + + + <%# Includes all stylesheet files in app/assets/stylesheets %> + <%= stylesheet_link_tag :app, "data-turbo-track": "reload" %> + <%= javascript_importmap_tags %> + + + + <%= yield %> + + diff --git a/cable-bench-falcon/app/views/pwa/manifest.json.erb b/cable-bench-falcon/app/views/pwa/manifest.json.erb new file mode 100644 index 0000000..9cbe4ae --- /dev/null +++ b/cable-bench-falcon/app/views/pwa/manifest.json.erb @@ -0,0 +1,22 @@ +{ + "name": "CableBench", + "icons": [ + { + "src": "/icon.png", + "type": "image/png", + "sizes": "512x512" + }, + { + "src": "/icon.png", + "type": "image/png", + "sizes": "512x512", + "purpose": "maskable" + } + ], + "start_url": "/", + "display": "standalone", + "scope": "/", + "description": "CableBench.", + "theme_color": "red", + "background_color": "red" +} diff --git a/cable-bench-falcon/app/views/pwa/service-worker.js b/cable-bench-falcon/app/views/pwa/service-worker.js new file mode 100644 index 0000000..b3a13fb --- /dev/null +++ b/cable-bench-falcon/app/views/pwa/service-worker.js @@ -0,0 +1,26 @@ +// Add a service worker for processing Web Push notifications: +// +// self.addEventListener("push", async (event) => { +// const { title, options } = await event.data.json() +// event.waitUntil(self.registration.showNotification(title, options)) +// }) +// +// self.addEventListener("notificationclick", function(event) { +// event.notification.close() +// event.waitUntil( +// clients.matchAll({ type: "window" }).then((clientList) => { +// for (let i = 0; i < clientList.length; i++) { +// let client = clientList[i] +// let clientPath = (new URL(client.url)).pathname +// +// if (clientPath == event.notification.data.path && "focus" in client) { +// return client.focus() +// } +// } +// +// if (clients.openWindow) { +// return clients.openWindow(event.notification.data.path) +// } +// }) +// ) +// }) diff --git a/cable-bench-falcon/bin/brakeman b/cable-bench-falcon/bin/brakeman new file mode 100755 index 0000000..ace1c9b --- /dev/null +++ b/cable-bench-falcon/bin/brakeman @@ -0,0 +1,7 @@ +#!/usr/bin/env ruby +require "rubygems" +require "bundler/setup" + +ARGV.unshift("--ensure-latest") + +load Gem.bin_path("brakeman", "brakeman") diff --git a/cable-bench-falcon/bin/bundler-audit b/cable-bench-falcon/bin/bundler-audit new file mode 100755 index 0000000..e2ef226 --- /dev/null +++ b/cable-bench-falcon/bin/bundler-audit @@ -0,0 +1,6 @@ +#!/usr/bin/env ruby +require_relative "../config/boot" +require "bundler/audit/cli" + +ARGV.concat %w[ --config config/bundler-audit.yml ] if ARGV.empty? || ARGV.include?("check") +Bundler::Audit::CLI.start diff --git a/cable-bench-falcon/bin/ci b/cable-bench-falcon/bin/ci new file mode 100755 index 0000000..4137ad5 --- /dev/null +++ b/cable-bench-falcon/bin/ci @@ -0,0 +1,6 @@ +#!/usr/bin/env ruby +require_relative "../config/boot" +require "active_support/continuous_integration" + +CI = ActiveSupport::ContinuousIntegration +require_relative "../config/ci.rb" diff --git a/cable-bench-falcon/bin/dev b/cable-bench-falcon/bin/dev new file mode 100755 index 0000000..5f91c20 --- /dev/null +++ b/cable-bench-falcon/bin/dev @@ -0,0 +1,2 @@ +#!/usr/bin/env ruby +exec "./bin/rails", "server", *ARGV diff --git a/cable-bench-falcon/bin/falcon-entrypoint b/cable-bench-falcon/bin/falcon-entrypoint new file mode 100755 index 0000000..46f7eaa --- /dev/null +++ b/cable-bench-falcon/bin/falcon-entrypoint @@ -0,0 +1,21 @@ +#!/bin/bash +# AsyncCable target: Rails served by Falcon (async/fiber reactor) instead of +# Puma. WebSockets at /cable are handled in-process by Async::Cable::Middleware; +# cross-process broadcast goes through the Redis cable adapter (matching the +# Action Cable Redis target so the only variable is the WS engine). +set -e + +export SECRET_KEY_BASE_DUMMY="${SECRET_KEY_BASE_DUMMY:-1}" +PORT="${PORT:-3000}" +# Falcon forks this many reactor processes. Match the Puma worker count used by +# the other targets (WEB_CONCURRENCY) so the box-level parallelism is the same. +COUNT="${WEB_CONCURRENCY:-8}" + +echo "[entrypoint] AsyncCable on Falcon: port=$PORT count=$COUNT adapter=redis" + +# Primary + solid_cache/solid_queue tables (cable fan-out is Redis, no DB). +./bin/rails db:prepare + +# Bind on IPv6 so Railway's private network can reach it. http:// = plaintext +# HTTP/1.1 + h2c; the WebSocket upgrade rides HTTP/1.1. +exec bundle exec falcon serve --bind "http://[::]:${PORT}" --count "${COUNT}" diff --git a/cable-bench-falcon/bin/importmap b/cable-bench-falcon/bin/importmap new file mode 100755 index 0000000..36502ab --- /dev/null +++ b/cable-bench-falcon/bin/importmap @@ -0,0 +1,4 @@ +#!/usr/bin/env ruby + +require_relative "../config/application" +require "importmap/commands" diff --git a/cable-bench-falcon/bin/jobs b/cable-bench-falcon/bin/jobs new file mode 100755 index 0000000..dcf59f3 --- /dev/null +++ b/cable-bench-falcon/bin/jobs @@ -0,0 +1,6 @@ +#!/usr/bin/env ruby + +require_relative "../config/environment" +require "solid_queue/cli" + +SolidQueue::Cli.start(ARGV) diff --git a/cable-bench-falcon/bin/rails b/cable-bench-falcon/bin/rails new file mode 100755 index 0000000..efc0377 --- /dev/null +++ b/cable-bench-falcon/bin/rails @@ -0,0 +1,4 @@ +#!/usr/bin/env ruby +APP_PATH = File.expand_path("../config/application", __dir__) +require_relative "../config/boot" +require "rails/commands" diff --git a/cable-bench-falcon/bin/rake b/cable-bench-falcon/bin/rake new file mode 100755 index 0000000..4fbf10b --- /dev/null +++ b/cable-bench-falcon/bin/rake @@ -0,0 +1,4 @@ +#!/usr/bin/env ruby +require_relative "../config/boot" +require "rake" +Rake.application.run diff --git a/cable-bench-falcon/bin/rubocop b/cable-bench-falcon/bin/rubocop new file mode 100755 index 0000000..5a20504 --- /dev/null +++ b/cable-bench-falcon/bin/rubocop @@ -0,0 +1,8 @@ +#!/usr/bin/env ruby +require "rubygems" +require "bundler/setup" + +# Explicit RuboCop config increases performance slightly while avoiding config confusion. +ARGV.unshift("--config", File.expand_path("../.rubocop.yml", __dir__)) + +load Gem.bin_path("rubocop", "rubocop") diff --git a/cable-bench-falcon/bin/setup b/cable-bench-falcon/bin/setup new file mode 100755 index 0000000..81be011 --- /dev/null +++ b/cable-bench-falcon/bin/setup @@ -0,0 +1,35 @@ +#!/usr/bin/env ruby +require "fileutils" + +APP_ROOT = File.expand_path("..", __dir__) + +def system!(*args) + system(*args, exception: true) +end + +FileUtils.chdir APP_ROOT do + # This script is a way to set up or update your development environment automatically. + # This script is idempotent, so that you can run it at any time and get an expectable outcome. + # Add necessary setup steps to this file. + + puts "== Installing dependencies ==" + system("bundle check") || system!("bundle install") + + # puts "\n== Copying sample files ==" + # unless File.exist?("config/database.yml") + # FileUtils.cp "config/database.yml.sample", "config/database.yml" + # end + + puts "\n== Preparing database ==" + system! "bin/rails db:prepare" + system! "bin/rails db:reset" if ARGV.include?("--reset") + + puts "\n== Removing old logs and tempfiles ==" + system! "bin/rails log:clear tmp:clear" + + unless ARGV.include?("--skip-server") + puts "\n== Starting development server ==" + STDOUT.flush # flush the output before exec(2) so that it displays + exec "bin/dev" + end +end diff --git a/cable-bench-falcon/bin/thrust b/cable-bench-falcon/bin/thrust new file mode 100755 index 0000000..36bde2d --- /dev/null +++ b/cable-bench-falcon/bin/thrust @@ -0,0 +1,5 @@ +#!/usr/bin/env ruby +require "rubygems" +require "bundler/setup" + +load Gem.bin_path("thruster", "thrust") diff --git a/cable-bench-falcon/config.ru b/cable-bench-falcon/config.ru new file mode 100644 index 0000000..4a3c09a --- /dev/null +++ b/cable-bench-falcon/config.ru @@ -0,0 +1,6 @@ +# This file is used by Rack-based servers to start the application. + +require_relative "config/environment" + +run Rails.application +Rails.application.load_server diff --git a/cable-bench-falcon/config/anycable.yml b/cable-bench-falcon/config/anycable.yml new file mode 100644 index 0000000..b2ebab2 --- /dev/null +++ b/cable-bench-falcon/config/anycable.yml @@ -0,0 +1,17 @@ +# AnyCable (RPC) configuration for the AnyCable benchmark mode only. The other +# two modes (solid_cable, redis) never load this — Rails serves /cable itself. +# +# Here Rails runs the gRPC RPC server (`bundle exec anycable`) and anycable-go +# is the separate WebSocket gateway. Broadcasts go over Redis Streams (redisx) +# so the gateway's broker can keep stream history and resume the extended +# Action Cable protocol on reconnect (the delivery-guarantee path). Wire the +# Redis URL and RPC bind via env in deployment (ANYCABLE_REDIS_URL, +# ANYCABLE_RPC_HOST). +default: &default + broadcast_adapter: redisx + +development: + <<: *default + +production: + <<: *default diff --git a/cable-bench-falcon/config/application.rb b/cable-bench-falcon/config/application.rb new file mode 100644 index 0000000..0aeaddf --- /dev/null +++ b/cable-bench-falcon/config/application.rb @@ -0,0 +1,42 @@ +require_relative "boot" + +require "rails" +# Pick the frameworks you want: +require "active_model/railtie" +require "active_job/railtie" +require "active_record/railtie" +# require "active_storage/engine" +require "action_controller/railtie" +# require "action_mailer/railtie" +# require "action_mailbox/engine" +# require "action_text/engine" +require "action_view/railtie" +require "action_cable/engine" +# require "rails/test_unit/railtie" + +# Require the gems listed in Gemfile, including any gems +# you've limited to :test, :development, or :production. +Bundler.require(*Rails.groups) + +module CableBench + class Application < Rails::Application + # Initialize configuration defaults for originally generated Rails version. + config.load_defaults 8.1 + + # Please, add to the `ignore` list any other `lib` subdirectories that do + # not contain `.rb` files, or that should not be reloaded or eager loaded. + # Common ones are `templates`, `generators`, or `middleware`, for example. + config.autoload_lib(ignore: %w[assets tasks]) + + # Configuration for the application, engines, and railties goes here. + # + # These settings can be overridden in specific environments using the files + # in config/environments, which are processed later. + # + # config.time_zone = "Central Time (US & Canada)" + # config.eager_load_paths << Rails.root.join("extras") + + # Don't generate system test files. + config.generators.system_tests = nil + end +end diff --git a/cable-bench-falcon/config/boot.rb b/cable-bench-falcon/config/boot.rb new file mode 100644 index 0000000..988a5dd --- /dev/null +++ b/cable-bench-falcon/config/boot.rb @@ -0,0 +1,4 @@ +ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) + +require "bundler/setup" # Set up gems listed in the Gemfile. +require "bootsnap/setup" # Speed up boot time by caching expensive operations. diff --git a/cable-bench-falcon/config/bundler-audit.yml b/cable-bench-falcon/config/bundler-audit.yml new file mode 100644 index 0000000..e74b3af --- /dev/null +++ b/cable-bench-falcon/config/bundler-audit.yml @@ -0,0 +1,5 @@ +# Audit all gems listed in the Gemfile for known security problems by running bin/bundler-audit. +# CVEs that are not relevant to the application can be enumerated on the ignore list below. + +ignore: + - CVE-THAT-DOES-NOT-APPLY diff --git a/cable-bench-falcon/config/cable.yml b/cable-bench-falcon/config/cable.yml new file mode 100644 index 0000000..7877b67 --- /dev/null +++ b/cable-bench-falcon/config/cable.yml @@ -0,0 +1,15 @@ +# AsyncCable target. The WebSockets are served in-process by Falcon via +# Async::Cable::Middleware; this adapter is only the pub/sub fan-out path +# between Falcon worker processes. We use Redis to match the Action Cable +# Redis-adapter target exactly, so the only variable under test is the WS +# engine (Falcon fibers vs Puma threads). +development: + adapter: async + +test: + adapter: test + +production: + adapter: redis + url: <%= ENV.fetch("REDIS_URL", "redis://localhost:6379/1") %> + channel_prefix: cable_bench_falcon diff --git a/cable-bench-falcon/config/cache.yml b/cable-bench-falcon/config/cache.yml new file mode 100644 index 0000000..19d4908 --- /dev/null +++ b/cable-bench-falcon/config/cache.yml @@ -0,0 +1,16 @@ +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: + <<: *default + +test: + <<: *default + +production: + database: cache + <<: *default diff --git a/cable-bench-falcon/config/ci.rb b/cable-bench-falcon/config/ci.rb new file mode 100644 index 0000000..239b343 --- /dev/null +++ b/cable-bench-falcon/config/ci.rb @@ -0,0 +1,20 @@ +# Run using bin/ci + +CI.run do + step "Setup", "bin/setup --skip-server" + + step "Style: Ruby", "bin/rubocop" + + step "Security: Gem audit", "bin/bundler-audit" + step "Security: Importmap vulnerability audit", "bin/importmap audit" + step "Security: Brakeman code analysis", "bin/brakeman --quiet --no-pager --exit-on-warn --exit-on-error" + + + # Optional: set a green GitHub commit status to unblock PR merge. + # Requires the `gh` CLI and `gh extension install basecamp/gh-signoff`. + # if success? + # step "Signoff: All systems go. Ready for merge and deploy.", "gh signoff" + # else + # failure "Signoff: CI failed. Do not merge or deploy.", "Fix the issues and try again." + # end +end diff --git a/cable-bench-falcon/config/credentials.yml.enc b/cable-bench-falcon/config/credentials.yml.enc new file mode 100644 index 0000000..1d889f4 --- /dev/null +++ b/cable-bench-falcon/config/credentials.yml.enc @@ -0,0 +1 @@ +eagm44xPbLb4jZgrmBiNBwIRQM+rbzjDEq8VpI1j0dSmvihIBuvdsO5hGvn88a9kr8ijIThnuR3el+OX7M8RLvfImeDVz6MgKj95zBAkHGwbi5CgMzjEd4jR2XyDdCxYpqi9WD1M3PAMymdHJpheREPMhOvF0CQ/Ac/57VKqiqOKybCSZGV7G30XOubAruk+kGKTdGEWgsMlTqhH+qbpDHP+ktn9Gu/v7LEoKkxCL1ETA6E53/6mwofWwcXuTClxn0oHkrOkm6NU4q6TfDBvTSPxuPVG600t29hPeBPt9RH/fa8ytsVpdbe7RUNobSr87bi7F5QudH0MOJBRezCou2LxeZgneokS7QJTh1/QhY572+UU3YePJhxyCukDIp07gewWOGPzz24/h/XNcyKLpUf9KIEGFQ7OQ++WcIIp+JSU/2yUCc+TiT0amnubSYOYjfzfPhKVydEYCkMOuH/2+ptJC8n7ETrp2L0v5pnXv732Cxnsgp3o2K96--5zE13fNJAxth31ZH--93XIYtVm7Aub41gBda9/Xw== \ No newline at end of file diff --git a/cable-bench-falcon/config/database.yml b/cable-bench-falcon/config/database.yml new file mode 100644 index 0000000..4e71461 --- /dev/null +++ b/cable-bench-falcon/config/database.yml @@ -0,0 +1,48 @@ +# SQLite. Versions 3.8.0 and up are supported. +# gem install sqlite3 +# +# Ensure the SQLite 3 gem is defined in your Gemfile +# gem "sqlite3" +# +default: &default + adapter: sqlite3 + max_connections: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> + timeout: 5000 + +development: + <<: *default + database: storage/development.sqlite3 + +# Warning: The database defined as "test" will be erased and +# re-generated from your development database when you run "rake". +# Do not set this db to the same as development or production. +test: + <<: *default + database: storage/test.sqlite3 + +# SQLite3 write its data on the local filesystem, as such it requires +# persistent disks. If you are deploying to a managed service, you should +# make sure it provides disk persistence, as many don't. +# +# Similarly, if you deploy your application as a Docker container, you must +# ensure the database is located in a persisted volume. +# Benchmark data is disposable: the SQLite files live on the container's +# ephemeral disk and are recreated by `db:prepare` on every boot. The cable DB +# is the one that matters (Solid Cable's message stream); cache/queue are +# unused but kept so db:prepare succeeds. +production: + primary: + <<: *default + database: storage/production.sqlite3 + cache: + <<: *default + database: storage/production_cache.sqlite3 + migrations_paths: db/cache_migrate + queue: + <<: *default + database: storage/production_queue.sqlite3 + migrations_paths: db/queue_migrate + cable: + <<: *default + database: storage/production_cable.sqlite3 + migrations_paths: db/cable_migrate diff --git a/cable-bench-falcon/config/environment.rb b/cable-bench-falcon/config/environment.rb new file mode 100644 index 0000000..cac5315 --- /dev/null +++ b/cable-bench-falcon/config/environment.rb @@ -0,0 +1,5 @@ +# Load the Rails application. +require_relative "application" + +# Initialize the Rails application. +Rails.application.initialize! diff --git a/cable-bench-falcon/config/environments/development.rb b/cable-bench-falcon/config/environments/development.rb new file mode 100644 index 0000000..64dd9a0 --- /dev/null +++ b/cable-bench-falcon/config/environments/development.rb @@ -0,0 +1,71 @@ +require "active_support/core_ext/integer/time" + +Rails.application.configure do + # Settings specified here will take precedence over those in config/application.rb. + + # Make code changes take effect immediately without server restart. + config.enable_reloading = true + + # The load generator opens raw WebSockets with no Origin header (same as in + # production). Skip the Action Cable origin check so local smoke tests and + # the bench-runner can connect. + config.action_cable.disable_request_forgery_protection = true + + # Do not eager load code on boot. + config.eager_load = false + + # Show full error reports. + config.consider_all_requests_local = true + + # Enable server timing. + config.server_timing = true + + # Enable/disable Action Controller caching. By default Action Controller caching is disabled. + # Run rails dev:cache to toggle Action Controller caching. + if Rails.root.join("tmp/caching-dev.txt").exist? + config.action_controller.perform_caching = true + config.action_controller.enable_fragment_cache_logging = true + config.public_file_server.headers = { "cache-control" => "public, max-age=#{2.days.to_i}" } + else + config.action_controller.perform_caching = false + end + + # Change to :null_store to avoid any caching. + config.cache_store = :memory_store + + # Print deprecation notices to the Rails logger. + config.active_support.deprecation = :log + + # Raise an error on page load if there are pending migrations. + config.active_record.migration_error = :page_load + + # Highlight code that triggered database queries in logs. + config.active_record.verbose_query_logs = true + + # Append comments with runtime information tags to SQL queries in logs. + config.active_record.query_log_tags_enabled = true + + # Highlight code that enqueued background job in logs. + config.active_job.verbose_enqueue_logs = true + + # Highlight code that triggered redirect in logs. + config.action_dispatch.verbose_redirect_logs = true + + # Suppress logger output for asset requests. + config.assets.quiet = true + + # Raises error for missing translations. + # config.i18n.raise_on_missing_translations = true + + # Annotate rendered view with file names. + config.action_view.annotate_rendered_view_with_filenames = true + + # Uncomment if you wish to allow Action Cable access from any origin. + # config.action_cable.disable_request_forgery_protection = true + + # Raise error when a before_action's only/except options reference missing actions. + config.action_controller.raise_on_missing_callback_actions = true + + # Apply autocorrection by RuboCop to files generated by `bin/rails generate`. + # config.generators.apply_rubocop_autocorrect_after_generate! +end diff --git a/cable-bench-falcon/config/environments/production.rb b/cable-bench-falcon/config/environments/production.rb new file mode 100644 index 0000000..df867ef --- /dev/null +++ b/cable-bench-falcon/config/environments/production.rb @@ -0,0 +1,75 @@ +require "active_support/core_ext/integer/time" + +Rails.application.configure do + # Settings specified here will take precedence over those in config/application.rb. + + # Code is not reloaded between requests. + config.enable_reloading = false + + # Eager load code on boot for better performance and memory savings (ignored by Rake tasks). + config.eager_load = true + + # Full error reports are disabled. + config.consider_all_requests_local = false + + # Turn on fragment caching in view templates. + config.action_controller.perform_caching = true + + # Cache assets for far-future expiry since they are all digest stamped. + config.public_file_server.headers = { "cache-control" => "public, max-age=#{1.year.to_i}" } + + # Enable serving of images, stylesheets, and JavaScripts from an asset server. + # config.asset_host = "http://assets.example.com" + + # This app only ever receives traffic from the bench-runner fleet over + # Railway's private network (plain HTTP/WS, no TLS-terminating proxy in + # front), so SSL enforcement would just break internal connections. + config.assume_ssl = false + config.force_ssl = false + + # The load generator opens raw WebSockets without a matching Origin header; + # there is no browser session to protect, so skip the Action Cable origin check. + config.action_cable.disable_request_forgery_protection = true + + # Skip http-to-https redirect for the default health check endpoint. + # config.ssl_options = { redirect: { exclude: ->(request) { request.path == "/up" } } } + + # Log to STDOUT with the current request id as a default log tag. + config.log_tags = [ :request_id ] + config.logger = ActiveSupport::TaggedLogging.logger(STDOUT) + + # Change to "debug" to log everything (including potentially personally-identifiable information!). + config.log_level = ENV.fetch("RAILS_LOG_LEVEL", "info") + + # Prevent health checks from clogging up the logs. + config.silence_healthcheck_path = "/up" + + # Don't log any deprecations. + config.active_support.report_deprecations = false + + # Replace the default in-process memory cache store with a durable alternative. + config.cache_store = :solid_cache_store + + # Replace the default in-process and non-durable queuing backend for Active Job. + config.active_job.queue_adapter = :solid_queue + config.solid_queue.connects_to = { database: { writing: :queue } } + + # Enable locale fallbacks for I18n (makes lookups for any locale fall back to + # the I18n.default_locale when a translation cannot be found). + config.i18n.fallbacks = true + + # Do not dump schema after migrations. + config.active_record.dump_schema_after_migration = false + + # Only use :id for inspections in production. + config.active_record.attributes_for_inspect = [ :id ] + + # Enable DNS rebinding protection and other `Host` header attacks. + # config.hosts = [ + # "example.com", # Allow requests from example.com + # /.*\.example\.com/ # Allow requests from subdomains like `www.example.com` + # ] + # + # Skip DNS rebinding protection for the default health check endpoint. + # config.host_authorization = { exclude: ->(request) { request.path == "/up" } } +end diff --git a/cable-bench-falcon/config/environments/test.rb b/cable-bench-falcon/config/environments/test.rb new file mode 100644 index 0000000..14bc29e --- /dev/null +++ b/cable-bench-falcon/config/environments/test.rb @@ -0,0 +1,42 @@ +# The test environment is used exclusively to run your application's +# test suite. You never need to work with it otherwise. Remember that +# your test database is "scratch space" for the test suite and is wiped +# and recreated between test runs. Don't rely on the data there! + +Rails.application.configure do + # Settings specified here will take precedence over those in config/application.rb. + + # While tests run files are not watched, reloading is not necessary. + config.enable_reloading = false + + # Eager loading loads your entire application. When running a single test locally, + # this is usually not necessary, and can slow down your test suite. However, it's + # recommended that you enable it in continuous integration systems to ensure eager + # loading is working properly before deploying your code. + config.eager_load = ENV["CI"].present? + + # Configure public file server for tests with cache-control for performance. + config.public_file_server.headers = { "cache-control" => "public, max-age=3600" } + + # Show full error reports. + config.consider_all_requests_local = true + config.cache_store = :null_store + + # Render exception templates for rescuable exceptions and raise for other exceptions. + config.action_dispatch.show_exceptions = :rescuable + + # Disable request forgery protection in test environment. + config.action_controller.allow_forgery_protection = false + + # Print deprecation notices to the stderr. + config.active_support.deprecation = :stderr + + # Raises error for missing translations. + # config.i18n.raise_on_missing_translations = true + + # Annotate rendered view with file names. + # config.action_view.annotate_rendered_view_with_filenames = true + + # Raise error when a before_action's only/except options reference missing actions. + config.action_controller.raise_on_missing_callback_actions = true +end diff --git a/cable-bench-falcon/config/importmap.rb b/cable-bench-falcon/config/importmap.rb new file mode 100644 index 0000000..909dfc5 --- /dev/null +++ b/cable-bench-falcon/config/importmap.rb @@ -0,0 +1,7 @@ +# Pin npm packages by running ./bin/importmap + +pin "application" +pin "@hotwired/turbo-rails", to: "turbo.min.js" +pin "@hotwired/stimulus", to: "stimulus.min.js" +pin "@hotwired/stimulus-loading", to: "stimulus-loading.js" +pin_all_from "app/javascript/controllers", under: "controllers" diff --git a/cable-bench-falcon/config/initializers/assets.rb b/cable-bench-falcon/config/initializers/assets.rb new file mode 100644 index 0000000..4873244 --- /dev/null +++ b/cable-bench-falcon/config/initializers/assets.rb @@ -0,0 +1,7 @@ +# Be sure to restart your server when you modify this file. + +# Version of your assets, change this if you want to expire all your assets. +Rails.application.config.assets.version = "1.0" + +# Add additional assets to the asset load path. +# Rails.application.config.assets.paths << Emoji.images_path diff --git a/cable-bench-falcon/config/initializers/content_security_policy.rb b/cable-bench-falcon/config/initializers/content_security_policy.rb new file mode 100644 index 0000000..d51d713 --- /dev/null +++ b/cable-bench-falcon/config/initializers/content_security_policy.rb @@ -0,0 +1,29 @@ +# Be sure to restart your server when you modify this file. + +# Define an application-wide content security policy. +# See the Securing Rails Applications Guide for more information: +# https://guides.rubyonrails.org/security.html#content-security-policy-header + +# Rails.application.configure do +# config.content_security_policy do |policy| +# policy.default_src :self, :https +# policy.font_src :self, :https, :data +# policy.img_src :self, :https, :data +# policy.object_src :none +# policy.script_src :self, :https +# policy.style_src :self, :https +# # Specify URI for violation reports +# # policy.report_uri "/csp-violation-report-endpoint" +# end +# +# # Generate session nonces for permitted importmap, inline scripts, and inline styles. +# config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s } +# config.content_security_policy_nonce_directives = %w(script-src style-src) +# +# # Automatically add `nonce` to `javascript_tag`, `javascript_include_tag`, and `stylesheet_link_tag` +# # if the corresponding directives are specified in `content_security_policy_nonce_directives`. +# # config.content_security_policy_nonce_auto = true +# +# # Report violations without enforcing the policy. +# # config.content_security_policy_report_only = true +# end diff --git a/cable-bench-falcon/config/initializers/filter_parameter_logging.rb b/cable-bench-falcon/config/initializers/filter_parameter_logging.rb new file mode 100644 index 0000000..c0b717f --- /dev/null +++ b/cable-bench-falcon/config/initializers/filter_parameter_logging.rb @@ -0,0 +1,8 @@ +# Be sure to restart your server when you modify this file. + +# Configure parameters to be partially matched (e.g. passw matches password) and filtered from the log file. +# Use this to limit dissemination of sensitive information. +# See the ActiveSupport::ParameterFilter documentation for supported notations and behaviors. +Rails.application.config.filter_parameters += [ + :passw, :email, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn, :cvv, :cvc +] diff --git a/cable-bench-falcon/config/initializers/inflections.rb b/cable-bench-falcon/config/initializers/inflections.rb new file mode 100644 index 0000000..3860f65 --- /dev/null +++ b/cable-bench-falcon/config/initializers/inflections.rb @@ -0,0 +1,16 @@ +# Be sure to restart your server when you modify this file. + +# Add new inflection rules using the following format. Inflections +# are locale specific, and you may define rules for as many different +# locales as you wish. All of these examples are active by default: +# ActiveSupport::Inflector.inflections(:en) do |inflect| +# inflect.plural /^(ox)$/i, "\\1en" +# inflect.singular /^(ox)en/i, "\\1" +# inflect.irregular "person", "people" +# inflect.uncountable %w( fish sheep ) +# end + +# These inflection rules are supported but not enabled by default: +# ActiveSupport::Inflector.inflections(:en) do |inflect| +# inflect.acronym "RESTful" +# end diff --git a/cable-bench-falcon/config/locales/en.yml b/cable-bench-falcon/config/locales/en.yml new file mode 100644 index 0000000..6c349ae --- /dev/null +++ b/cable-bench-falcon/config/locales/en.yml @@ -0,0 +1,31 @@ +# Files in the config/locales directory are used for internationalization and +# are automatically loaded by Rails. If you want to use locales other than +# English, add the necessary files in this directory. +# +# To use the locales, use `I18n.t`: +# +# I18n.t "hello" +# +# In views, this is aliased to just `t`: +# +# <%= t("hello") %> +# +# To use a different locale, set it with `I18n.locale`: +# +# I18n.locale = :es +# +# This would use the information in config/locales/es.yml. +# +# To learn more about the API, please read the Rails Internationalization guide +# at https://guides.rubyonrails.org/i18n.html. +# +# Be aware that YAML interprets the following case-insensitive strings as +# booleans: `true`, `false`, `on`, `off`, `yes`, `no`. Therefore, these strings +# must be quoted to be interpreted as strings. For example: +# +# en: +# "yes": yup +# enabled: "ON" + +en: + hello: "Hello world" diff --git a/cable-bench-falcon/config/puma.rb b/cable-bench-falcon/config/puma.rb new file mode 100644 index 0000000..92ca455 --- /dev/null +++ b/cable-bench-falcon/config/puma.rb @@ -0,0 +1,45 @@ +# This configuration file will be evaluated by Puma. The top-level methods that +# are invoked here are part of Puma's configuration DSL. For more information +# about methods provided by the DSL, see https://puma.io/puma/Puma/DSL.html. +# +# Puma starts a configurable number of processes (workers) and each process +# serves each request in a thread from an internal thread pool. +# +# You can control the number of workers using ENV["WEB_CONCURRENCY"]. You +# should only set this value when you want to run 2 or more workers. The +# default is already 1. You can set it to `auto` to automatically start a worker +# for each available processor. +# +# The ideal number of threads per worker depends both on how much time the +# application spends waiting for IO operations and on how much you wish to +# prioritize throughput over latency. +# +# As a rule of thumb, increasing the number of threads will increase how much +# traffic a given process can handle (throughput), but due to CRuby's +# Global VM Lock (GVL) it has diminishing returns and will degrade the +# response time (latency) of the application. +# +# The default is set to 3 threads as it's deemed a decent compromise between +# throughput and latency for the average Rails application. +# +# Any libraries that use a connection pool or another resource pool should +# be configured to provide at least as many connections as the number of +# threads. This includes Active Record's `pool` parameter in `database.yml`. +threads_count = ENV.fetch("RAILS_MAX_THREADS", 3) +threads threads_count, threads_count + +# Bind IPv6 dual-stack (`[::]`) rather than the default IPv4 `0.0.0.0`: Railway's +# private network routes over IPv6, so internal clients (the bench-runner fleet) +# can only reach Puma on `[::]`. On Linux this also accepts IPv4, and it works +# locally on macOS too. A single bind avoids an address-in-use clash with `port`. +bind "tcp://[::]:#{ENV.fetch('PORT', 3000)}" + +# Allow puma to be restarted by `bin/rails restart` command. +plugin :tmp_restart + +# Run the Solid Queue supervisor inside of Puma for single-server deployments. +plugin :solid_queue if ENV["SOLID_QUEUE_IN_PUMA"] + +# Specify the PID file. Defaults to tmp/pids/server.pid in development. +# In other environments, only set the PID file if requested. +pidfile ENV["PIDFILE"] if ENV["PIDFILE"] diff --git a/cable-bench-falcon/config/queue.yml b/cable-bench-falcon/config/queue.yml new file mode 100644 index 0000000..9eace59 --- /dev/null +++ b/cable-bench-falcon/config/queue.yml @@ -0,0 +1,18 @@ +default: &default + dispatchers: + - polling_interval: 1 + batch_size: 500 + workers: + - queues: "*" + threads: 3 + processes: <%= ENV.fetch("JOB_CONCURRENCY", 1) %> + polling_interval: 0.1 + +development: + <<: *default + +test: + <<: *default + +production: + <<: *default diff --git a/cable-bench-falcon/config/recurring.yml b/cable-bench-falcon/config/recurring.yml new file mode 100644 index 0000000..b4207f9 --- /dev/null +++ b/cable-bench-falcon/config/recurring.yml @@ -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 diff --git a/cable-bench-falcon/config/routes.rb b/cable-bench-falcon/config/routes.rb new file mode 100644 index 0000000..b0923ee --- /dev/null +++ b/cable-bench-falcon/config/routes.rb @@ -0,0 +1,22 @@ +Rails.application.routes.draw do + # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html + + # Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500. + # Can be used by load balancers and uptime monitors to verify that the app is live. + get "up" => "rails/health#show", as: :rails_health_check + + # Benchmark harness surface. + get "health" => "bench#health" + post "_bench/broadcast" => "bench#broadcast" + + # WebSockets at /cable are handled by Async::Cable::Middleware (inserted by + # the async-cable railtie), which intercepts the WS upgrade before the router + # and dispatches to ActionCable on the Falcon reactor. No explicit mount. + + # Render dynamic PWA files from app/views/pwa/* (remember to link manifest in application.html.erb) + # get "manifest" => "rails/pwa#manifest", as: :pwa_manifest + # get "service-worker" => "rails/pwa#service_worker", as: :pwa_service_worker + + # Defines the root path route ("/") + # root "posts#index" +end diff --git a/cable-bench-falcon/db/cable_schema.rb b/cable-bench-falcon/db/cable_schema.rb new file mode 100644 index 0000000..2366660 --- /dev/null +++ b/cable-bench-falcon/db/cable_schema.rb @@ -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 diff --git a/cable-bench-falcon/db/cache_schema.rb b/cable-bench-falcon/db/cache_schema.rb new file mode 100644 index 0000000..81a410d --- /dev/null +++ b/cable-bench-falcon/db/cache_schema.rb @@ -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 diff --git a/cable-bench-falcon/db/queue_schema.rb b/cable-bench-falcon/db/queue_schema.rb new file mode 100644 index 0000000..85194b6 --- /dev/null +++ b/cable-bench-falcon/db/queue_schema.rb @@ -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 diff --git a/cable-bench-falcon/db/seeds.rb b/cable-bench-falcon/db/seeds.rb new file mode 100644 index 0000000..4fbd6ed --- /dev/null +++ b/cable-bench-falcon/db/seeds.rb @@ -0,0 +1,9 @@ +# This file should ensure the existence of records required to run the application in every environment (production, +# development, test). The code here should be idempotent so that it can be executed at any point in every environment. +# The data can then be loaded with the bin/rails db:seed command (or created alongside the database with db:setup). +# +# Example: +# +# ["Action", "Comedy", "Drama", "Horror"].each do |genre_name| +# MovieGenre.find_or_create_by!(name: genre_name) +# end diff --git a/cable-bench-falcon/docker-compose.yml b/cable-bench-falcon/docker-compose.yml new file mode 100644 index 0000000..637d9b0 --- /dev/null +++ b/cable-bench-falcon/docker-compose.yml @@ -0,0 +1,21 @@ +# Local validation of the AsyncCable (Falcon) target against the production +# image. Mirrors the Railway topology (Falcon + Redis cable adapter) so we can +# shake out config before deploying. +# +# docker compose up --build +# +# AsyncCable: ws://localhost:3003/cable broadcast http://localhost:3003/_bench/broadcast +services: + redis: + image: redis:7-alpine + ports: ["6379:6379"] + + rails-asynccable: + build: . + image: cable-bench-falcon:local + environment: + PORT: "3000" + WEB_CONCURRENCY: "2" + REDIS_URL: redis://redis:6379/1 + ports: ["3003:3000"] + depends_on: [redis] diff --git a/cable-bench-falcon/lib/tasks/.keep b/cable-bench-falcon/lib/tasks/.keep new file mode 100644 index 0000000..e69de29 diff --git a/cable-bench-falcon/public/400.html b/cable-bench-falcon/public/400.html new file mode 100644 index 0000000..640de03 --- /dev/null +++ b/cable-bench-falcon/public/400.html @@ -0,0 +1,135 @@ + + + + + + + The server cannot process the request due to a client error (400 Bad Request) + + + + + + + + + + + + + +
+
+ +
+
+

The server cannot process the request due to a client error. Please check the request and try again. If you're the application owner check the logs for more information.

+
+
+ + + + diff --git a/cable-bench-falcon/public/404.html b/cable-bench-falcon/public/404.html new file mode 100644 index 0000000..d7f0f14 --- /dev/null +++ b/cable-bench-falcon/public/404.html @@ -0,0 +1,135 @@ + + + + + + + The page you were looking for doesn't exist (404 Not found) + + + + + + + + + + + + + +
+
+ +
+
+

The page you were looking for doesn't exist. You may have mistyped the address or the page may have moved. If you're the application owner check the logs for more information.

+
+
+ + + + diff --git a/cable-bench-falcon/public/406-unsupported-browser.html b/cable-bench-falcon/public/406-unsupported-browser.html new file mode 100644 index 0000000..43d2811 --- /dev/null +++ b/cable-bench-falcon/public/406-unsupported-browser.html @@ -0,0 +1,135 @@ + + + + + + + Your browser is not supported (406 Not Acceptable) + + + + + + + + + + + + + +
+
+ +
+
+

Your browser is not supported.
Please upgrade your browser to continue.

+
+
+ + + + diff --git a/cable-bench-falcon/public/422.html b/cable-bench-falcon/public/422.html new file mode 100644 index 0000000..f12fb4a --- /dev/null +++ b/cable-bench-falcon/public/422.html @@ -0,0 +1,135 @@ + + + + + + + The change you wanted was rejected (422 Unprocessable Entity) + + + + + + + + + + + + + +
+
+ +
+
+

The change you wanted was rejected. Maybe you tried to change something you didn't have access to. If you're the application owner check the logs for more information.

+
+
+ + + + diff --git a/cable-bench-falcon/public/500.html b/cable-bench-falcon/public/500.html new file mode 100644 index 0000000..e4eb18a --- /dev/null +++ b/cable-bench-falcon/public/500.html @@ -0,0 +1,135 @@ + + + + + + + We're sorry, but something went wrong (500 Internal Server Error) + + + + + + + + + + + + + +
+
+ +
+
+

We're sorry, but something went wrong.
If you're the application owner check the logs for more information.

+
+
+ + + + diff --git a/cable-bench-falcon/public/icon.png b/cable-bench-falcon/public/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..c4c9dbfbbd2f7c1421ffd5727188146213abbcef GIT binary patch literal 4166 zcmd6qU;WFw?|v@m)Sk^&NvB8tcujdV-r1b=i(NJxn&7{KTb zX$3(M+3TP2o^#KAo{#tIjl&t~(8D-k004kqPglzn0HFG(Q~(I*AKsD#M*g7!XK0T7 zN6P7j>HcT8rZgKl$v!xr806dyN19Bd4C0x_R*I-a?#zsTvb_89cyhuC&T**i|Rc zq5b8M;+{8KvoJ~uj9`u~d_f6`V&3+&ZX9x5pc8s)d175;@pjm(?dapmBcm0&vl9+W zx1ZD2o^nuyUHWj|^A8r>lUorO`wFF;>9XL-Jy!P}UXC{(z!FO%SH~8k`#|9;Q|eue zqWL0^Bp(fg_+Pkm!fDKRSY;+^@BF?AJE zCUWpXPst~hi_~u)SzYBDZroR+Z4xeHIlm_3Yc_9nZ(o_gg!jDgVa=E}Y8uDgem9`b zf=mfJ_@(BXSkW53B)F2s!&?_R4ptb1fYXlF++@vPhd=marQgEGRZS@B4g1Mu?euknL= z67P~tZ?*>-Hmi7GwlisNHHJDku-dSm7g@!=a}9cSL6Pa^w^2?&?$Oi8ibrr>w)xqx zOH_EMU@m05)9kuNR>>4@H%|){U$^yvVQ(YgOlh;5oU_-vivG-p4=LrN-k7D?*?u1u zsWly%tfAzKd6Fb=`eU2un_uaTXmcT#tlOL+aRS=kZZf}A7qT8lvcTx~7j` z*b>=z)mwg7%B2_!D0!1IZ?Nq{^Y$uI4Qx*6T!E2Col&2{k?ImCO=dD~A&9f9diXy^$x{6CwkBimn|1E09 zAMSezYtiL?O6hS37KpvDM?22&d{l)7h-!F)C-d3j8Z`c@($?mfd{R82)H>Qe`h{~G z!I}(2j(|49{LR?w4Jspl_i!(4T{31|dqCOpI52r5NhxYV+cDAu(xp*4iqZ2e-$YP= zoFOPmm|u*7C?S{Fp43y+V;>~@FFR76bCl@pTtyB93vNWy5yf;HKr8^0d7&GVIslYm zo3Tgt@M!`8B6IW&lK{Xk>%zp41G%`(DR&^u z5^pwD4>E6-w<8Kl2DzJ%a@~QDE$(e87lNhy?-Qgep!$b?5f7+&EM7$e>|WrX+=zCb z=!f5P>MxFyy;mIRxjc(H*}mceXw5a*IpC0PEYJ8Y3{JdoIW)@t97{wcUB@u+$FCCO z;s2Qe(d~oJC^`m$7DE-dsha`glrtu&v&93IZadvl_yjp!c89>zo;Krk+d&DEG4?x$ zufC1n+c1XD7dolX1q|7}uelR$`pT0Z)1jun<39$Sn2V5g&|(j~Z!wOddfYiZo7)A< z!dK`aBHOOk+-E_xbWCA3VR-+o$i5eO9`rMI#p_0xQ}rjEpGW;U!&&PKnivOcG(|m9 z!C8?WC6nCXw25WVa*eew)zQ=h45k8jSIPbq&?VE{oG%?4>9rwEeB4&qe#?-y_es4c|7ufw%+H5EY#oCgv!Lzv291#-oNlX~X+Jl5(riC~r z=0M|wMOP)Tt8@hNg&%V@Z9@J|Q#K*hE>sr6@oguas9&6^-=~$*2Gs%h#GF@h)i=Im z^iKk~ipWJg1VrvKS;_2lgs3n1zvNvxb27nGM=NXE!D4C!U`f*K2B@^^&ij9y}DTLB*FI zEnBL6y{jc?JqXWbkIZd7I16hA>(f9T!iwbIxJj~bKPfrO;>%*5nk&Lf?G@c2wvGrY&41$W{7HM9+b@&XY@>NZM5s|EK_Dp zQX60CBuantx>|d#DsaZ*8MW(we|#KTYZ=vNa#d*DJQe6hr~J6{_rI#?wi@s|&O}FR zG$kfPxheXh1?IZ{bDT-CWB4FTvO-k5scW^mi8?iY5Q`f8JcnnCxiy@m@D-%lO;y0pTLhh6i6l@x52j=#^$5_U^os}OFg zzdHbo(QI`%9#o*r8GCW~T3UdV`szO#~)^&X_(VW>o~umY9-ns9-V4lf~j z`QBD~pJ4a#b`*6bJ^3RS5y?RAgF7K5$ll97Y8#WZduZ`j?IEY~H(s^doZg>7-tk*t z4_QE1%%bb^p~4F5SB$t2i1>DBG1cIo;2(xTaj*Y~hlM{tSDHojL-QPg%Mo%6^7FrpB*{ z4G0@T{-77Por4DCMF zB_5Y~Phv%EQ64W8^GS6h?x6xh;w2{z3$rhC;m+;uD&pR74j+i22P5DS-tE8ABvH(U~indEbBUTAAAXfHZg5QpB@TgV9eI<)JrAkOI z8!TSOgfAJiWAXeM&vR4Glh;VxH}WG&V$bVb`a`g}GSpwggti*&)taV1@Ak|{WrV|5 zmNYx)Ans=S{c52qv@+jmGQ&vd6>6yX6IKq9O$3r&0xUTdZ!m1!irzn`SY+F23Rl6# zFRxws&gV-kM1NX(3(gnKpGi0Q)Dxi~#?nyzOR9!en;Ij>YJZVFAL*=R%7y%Mz9hU% zs>+ZB?qRmZ)nISx7wxY)y#cd$iaC~{k0avD>BjyF1q^mNQ1QcwsxiTySe<6C&cC6P zE`vwO9^k-d`9hZ!+r@Jnr+MF*2;2l8WjZ}DrwDUHzSF{WoG zucbSWguA!3KgB3MU%HH`R;XqVv0CcaGq?+;v_A5A2kpmk5V%qZE3yzQ7R5XWhq=eR zyUezH=@V)y>L9T-M-?tW(PQYTRBKZSVb_!$^H-Pn%ea;!vS_?M<~Tm>_rWIW43sPW z=!lY&fWc1g7+r?R)0p8(%zp&vl+FK4HRkns%BW+Up&wK8!lQ2~bja|9bD12WrKn#M zK)Yl9*8$SI7MAwSK$%)dMd>o+1UD<2&aQMhyjS5R{-vV+M;Q4bzl~Z~=4HFj_#2V9 zB)Gfzx3ncy@uzx?yzi}6>d%-?WE}h7v*w)Jr_gBl!2P&F3DX>j_1#--yjpL%<;JMR z*b70Gr)MMIBWDo~#<5F^Q0$VKI;SBIRneuR7)yVsN~A9I@gZTXe)E?iVII+X5h0~H zx^c(fP&4>!*q>fb6dAOC?MI>Cz3kld#J*;uik+Ps49cwm1B4 zZc1|ZxYyTv;{Z!?qS=D)sgRKx^1AYf%;y_V&VgZglfU>d+Ufk5&LV$sKv}Hoj+s; xK3FZRYdhbXT_@RW*ff3@`D1#ps#~H)p+y&j#(J|vk^lW{fF9OJt5(B-_&*Xgn9~3N literal 0 HcmV?d00001 diff --git a/cable-bench-falcon/public/icon.svg b/cable-bench-falcon/public/icon.svg new file mode 100644 index 0000000..04b34bf --- /dev/null +++ b/cable-bench-falcon/public/icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/cable-bench-falcon/public/robots.txt b/cable-bench-falcon/public/robots.txt new file mode 100644 index 0000000..c19f78a --- /dev/null +++ b/cable-bench-falcon/public/robots.txt @@ -0,0 +1 @@ +# See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file diff --git a/cable-bench-falcon/railway.toml b/cable-bench-falcon/railway.toml new file mode 100644 index 0000000..134d13f --- /dev/null +++ b/cable-bench-falcon/railway.toml @@ -0,0 +1,11 @@ +# Per-service Railway config for the Rails cable-bench targets +# (rails-solidcable / rails-actioncable / rails-anycable). +# +# The repo-root railway.toml points at backend/Dockerfile, which is wrong for +# this service. Uploading this file as the build root via +# `railway up --service --path-as-root cable-bench/` overrides the repo +# default and uses this local Dockerfile. The three services share the image +# and differ only by the BENCH_MODE env var. + +[build] +dockerfilePath = "Dockerfile" diff --git a/cable-bench-falcon/script/.keep b/cable-bench-falcon/script/.keep new file mode 100644 index 0000000..e69de29 diff --git a/cable-bench-falcon/storage/.keep b/cable-bench-falcon/storage/.keep new file mode 100644 index 0000000..e69de29 diff --git a/cable-bench-falcon/vendor/.keep b/cable-bench-falcon/vendor/.keep new file mode 100644 index 0000000..e69de29 diff --git a/cable-bench-falcon/vendor/javascript/.keep b/cable-bench-falcon/vendor/javascript/.keep new file mode 100644 index 0000000..e69de29 diff --git a/cable-bench/.dockerignore b/cable-bench/.dockerignore new file mode 100644 index 0000000..6751f09 --- /dev/null +++ b/cable-bench/.dockerignore @@ -0,0 +1,15 @@ +.git +.gitignore +# No encrypted credentials are used (SECRET_KEY_BASE_DUMMY=1 supplies the key). +# Copying master.key as a root-owned 0600 file breaks boot under USER rails. +/config/master.key +/log/* +/tmp/* +/storage/* +!/storage/.keep +/node_modules +/.bundle +/vendor/bundle +*.sqlite3 +*.sqlite3-* +.DS_Store diff --git a/cable-bench/.gitattributes b/cable-bench/.gitattributes new file mode 100644 index 0000000..8dc4323 --- /dev/null +++ b/cable-bench/.gitattributes @@ -0,0 +1,9 @@ +# See https://git-scm.com/docs/gitattributes for more about git attribute files. + +# Mark the database schema as having been generated. +db/schema.rb linguist-generated + +# Mark any vendored files as having been vendored. +vendor/* linguist-vendored +config/credentials/*.yml.enc diff=rails_credentials +config/credentials.yml.enc diff=rails_credentials diff --git a/cable-bench/.gitignore b/cable-bench/.gitignore new file mode 100644 index 0000000..fbcab40 --- /dev/null +++ b/cable-bench/.gitignore @@ -0,0 +1,35 @@ +# See https://help.github.com/articles/ignoring-files for more about ignoring files. +# +# Temporary files generated by your text editor or operating system +# belong in git's global ignore instead: +# `$XDG_CONFIG_HOME/git/ignore` or `~/.config/git/ignore` + +# Ignore bundler config. +/.bundle + +# Ignore all environment files. +/.env* + +# Ignore all logfiles and tempfiles. +/log/* +/tmp/* +!/log/.keep +!/tmp/.keep + +# Ignore pidfiles, but keep the directory. +/tmp/pids/* +!/tmp/pids/ +!/tmp/pids/.keep + +# Ignore storage (uploaded files in development and any SQLite databases). +/storage/* +!/storage/.keep +/tmp/storage/* +!/tmp/storage/ +!/tmp/storage/.keep + +/public/assets + +# Ignore key files for decrypting credentials and more. +/config/*.key + diff --git a/cable-bench/.rubocop.yml b/cable-bench/.rubocop.yml new file mode 100644 index 0000000..f9d86d4 --- /dev/null +++ b/cable-bench/.rubocop.yml @@ -0,0 +1,8 @@ +# Omakase Ruby styling for Rails +inherit_gem: { rubocop-rails-omakase: rubocop.yml } + +# Overwrite or add rules to create your own house style +# +# # Use `[a, [b, c]]` not `[ a, [ b, c ] ]` +# Layout/SpaceInsideArrayLiteralBrackets: +# Enabled: false diff --git a/cable-bench/.ruby-version b/cable-bench/.ruby-version new file mode 100644 index 0000000..f989260 --- /dev/null +++ b/cable-bench/.ruby-version @@ -0,0 +1 @@ +3.4.4 diff --git a/cable-bench/Dockerfile b/cable-bench/Dockerfile new file mode 100644 index 0000000..c4ee015 --- /dev/null +++ b/cable-bench/Dockerfile @@ -0,0 +1,36 @@ +# syntax=docker/dockerfile:1 +# Single image for the three Rails cable benchmark modes; bin/bench-entrypoint +# picks the adapter and process from BENCH_MODE. Mirrors the anycable-pro / +# socketioxide per-service pattern (railway.toml + --path-as-root cable-bench/). +ARG RUBY_VERSION=3.4.4 +FROM ruby:$RUBY_VERSION-slim AS base +WORKDIR /app +ENV RAILS_ENV=production \ + BUNDLE_DEPLOYMENT=1 \ + BUNDLE_WITHOUT=development:test \ + BUNDLE_PATH=/usr/local/bundle \ + RAILS_LOG_TO_STDOUT=1 \ + RAILS_SERVE_STATIC_FILES=1 \ + SECRET_KEY_BASE_DUMMY=1 +RUN apt-get update -qq && \ + apt-get install --no-install-recommends -y curl && \ + rm -rf /var/lib/apt/lists/* + +FROM base AS build +RUN apt-get update -qq && \ + apt-get install --no-install-recommends -y build-essential git pkg-config libyaml-dev && \ + rm -rf /var/lib/apt/lists/* +COPY Gemfile Gemfile.lock ./ +RUN bundle install && rm -rf "${BUNDLE_PATH}"/ruby/*/cache +COPY . . +RUN ./bin/rails assets:precompile + +FROM base +COPY --from=build /usr/local/bundle /usr/local/bundle +COPY --from=build /app /app +RUN useradd rails --create-home --shell /bin/bash && \ + mkdir -p storage tmp/pids log && \ + chown -R rails:rails db log storage tmp +USER rails +EXPOSE 3000 50051 +ENTRYPOINT ["./bin/bench-entrypoint"] diff --git a/cable-bench/Gemfile b/cable-bench/Gemfile new file mode 100644 index 0000000..ba9a907 --- /dev/null +++ b/cable-bench/Gemfile @@ -0,0 +1,58 @@ +source "https://rubygems.org" + +# Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main" +gem "rails", "~> 8.1.3" +# The modern asset pipeline for Rails [https://github.com/rails/propshaft] +gem "propshaft" +# Use sqlite3 as the database for Active Record +gem "sqlite3", ">= 2.1" +# Use the Puma web server [https://github.com/puma/puma] +gem "puma", ">= 5.0" +# Use JavaScript with ESM import maps [https://github.com/rails/importmap-rails] +gem "importmap-rails" +# Hotwire's SPA-like page accelerator [https://turbo.hotwired.dev] +gem "turbo-rails" +# Hotwire's modest JavaScript framework [https://stimulus.hotwired.dev] +gem "stimulus-rails" + +# Use Active Model has_secure_password [https://guides.rubyonrails.org/active_model_basics.html#securepassword] +# gem "bcrypt", "~> 3.1.7" + +# Windows does not include zoneinfo files, so bundle the tzinfo-data gem +gem "tzinfo-data", platforms: %i[ windows jruby ] + +# Use the database-backed adapters for Rails.cache, Active Job, and Action Cable +gem "solid_cache" +gem "solid_queue" +gem "solid_cable" + +# Benchmark targets: classic Action Cable Redis adapter + AnyCable (Go gateway +# over gRPC with the extended Action Cable protocol). Adapter is chosen at boot +# by CABLE_ADAPTER (see config/cable.yml). +gem "redis", ">= 4.0" +gem "anycable-rails", "~> 1.6" + +# Reduces boot times through caching; required in config/boot.rb +gem "bootsnap", require: false + +# Add HTTP asset caching/compression and X-Sendfile acceleration to Puma [https://github.com/basecamp/thruster/] +gem "thruster", require: false + +group :development, :test do + # See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem + gem "debug", platforms: %i[ mri windows ], require: "debug/prelude" + + # Audits gems for known security defects (use config/bundler-audit.yml to ignore issues) + gem "bundler-audit", require: false + + # Static analysis for security vulnerabilities [https://brakemanscanner.org/] + gem "brakeman", require: false + + # Omakase Ruby styling [https://github.com/rails/rubocop-rails-omakase/] + gem "rubocop-rails-omakase", require: false +end + +group :development do + # Use console on exceptions pages [https://github.com/rails/web-console] + gem "web-console" +end diff --git a/cable-bench/Gemfile.lock b/cable-bench/Gemfile.lock new file mode 100644 index 0000000..13e829c --- /dev/null +++ b/cable-bench/Gemfile.lock @@ -0,0 +1,560 @@ +GEM + remote: https://rubygems.org/ + specs: + action_text-trix (2.1.19) + railties + actioncable (8.1.3) + actionpack (= 8.1.3) + activesupport (= 8.1.3) + nio4r (~> 2.0) + websocket-driver (>= 0.6.1) + zeitwerk (~> 2.6) + actionmailbox (8.1.3) + actionpack (= 8.1.3) + activejob (= 8.1.3) + activerecord (= 8.1.3) + activestorage (= 8.1.3) + activesupport (= 8.1.3) + mail (>= 2.8.0) + actionmailer (8.1.3) + actionpack (= 8.1.3) + actionview (= 8.1.3) + activejob (= 8.1.3) + activesupport (= 8.1.3) + mail (>= 2.8.0) + rails-dom-testing (~> 2.2) + actionpack (8.1.3) + actionview (= 8.1.3) + activesupport (= 8.1.3) + nokogiri (>= 1.8.5) + rack (>= 2.2.4) + rack-session (>= 1.0.1) + rack-test (>= 0.6.3) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) + useragent (~> 0.16) + actiontext (8.1.3) + action_text-trix (~> 2.1.15) + actionpack (= 8.1.3) + activerecord (= 8.1.3) + activestorage (= 8.1.3) + activesupport (= 8.1.3) + globalid (>= 0.6.0) + nokogiri (>= 1.8.5) + actionview (8.1.3) + activesupport (= 8.1.3) + builder (~> 3.1) + erubi (~> 1.11) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) + activejob (8.1.3) + activesupport (= 8.1.3) + globalid (>= 0.3.6) + activemodel (8.1.3) + activesupport (= 8.1.3) + activerecord (8.1.3) + activemodel (= 8.1.3) + activesupport (= 8.1.3) + timeout (>= 0.4.0) + activestorage (8.1.3) + actionpack (= 8.1.3) + activejob (= 8.1.3) + activerecord (= 8.1.3) + activesupport (= 8.1.3) + marcel (~> 1.0) + activesupport (8.1.3) + base64 + bigdecimal + concurrent-ruby (~> 1.0, >= 1.3.1) + connection_pool (>= 2.2.5) + drb + i18n (>= 1.6, < 2) + json + logger (>= 1.4.2) + minitest (>= 5.1) + securerandom (>= 0.3) + tzinfo (~> 2.0, >= 2.0.5) + uri (>= 0.13.1) + anycable (1.6.4) + anycable-core (= 1.6.4) + grpc (~> 1.6) + anycable-core (1.6.4) + anyway_config (~> 2.2) + base64 (>= 0.2) + google-protobuf (~> 4) + stringio (~> 3) + anycable-rails (1.6.2) + anycable + anycable-rails-core (= 1.6.2) + anycable-rails-core (1.6.2) + actioncable (>= 7.0, < 9.0) + anycable-core (~> 1.6.0) + globalid + anyway_config (2.8.0) + ruby-next-core (~> 1.0) + ast (2.4.3) + base64 (0.3.0) + bigdecimal (4.1.2) + bindex (0.8.1) + bootsnap (1.24.6) + msgpack (~> 1.2) + brakeman (8.0.5) + racc + builder (3.3.0) + bundler-audit (0.9.3) + bundler (>= 1.2.0) + thor (~> 1.0) + concurrent-ruby (1.3.7) + connection_pool (3.0.2) + crass (1.0.6) + date (3.5.1) + debug (1.11.1) + irb (~> 1.10) + reline (>= 0.3.8) + drb (2.2.3) + erb (6.0.4) + erubi (1.13.1) + et-orbi (1.4.0) + tzinfo + fugit (1.12.2) + et-orbi (~> 1.4) + raabro (~> 1.4) + globalid (1.4.0) + activesupport (>= 6.1) + google-protobuf (4.35.1) + bigdecimal + rake (~> 13.3) + google-protobuf (4.35.1-aarch64-linux-gnu) + bigdecimal + rake (~> 13.3) + google-protobuf (4.35.1-aarch64-linux-musl) + bigdecimal + rake (~> 13.3) + google-protobuf (4.35.1-arm64-darwin) + bigdecimal + rake (~> 13.3) + google-protobuf (4.35.1-x86_64-darwin) + bigdecimal + rake (~> 13.3) + google-protobuf (4.35.1-x86_64-linux-gnu) + bigdecimal + rake (~> 13.3) + google-protobuf (4.35.1-x86_64-linux-musl) + bigdecimal + rake (~> 13.3) + googleapis-common-protos-types (1.23.0) + google-protobuf (~> 4.26) + grpc (1.81.1) + google-protobuf (>= 3.25, < 5.0) + googleapis-common-protos-types (~> 1.0) + grpc (1.81.1-aarch64-linux-gnu) + google-protobuf (>= 3.25, < 5.0) + googleapis-common-protos-types (~> 1.0) + grpc (1.81.1-aarch64-linux-musl) + google-protobuf (>= 3.25, < 5.0) + googleapis-common-protos-types (~> 1.0) + grpc (1.81.1-arm64-darwin) + google-protobuf (>= 3.25, < 5.0) + googleapis-common-protos-types (~> 1.0) + grpc (1.81.1-x86_64-darwin) + google-protobuf (>= 3.25, < 5.0) + googleapis-common-protos-types (~> 1.0) + grpc (1.81.1-x86_64-linux-gnu) + google-protobuf (>= 3.25, < 5.0) + googleapis-common-protos-types (~> 1.0) + grpc (1.81.1-x86_64-linux-musl) + google-protobuf (>= 3.25, < 5.0) + googleapis-common-protos-types (~> 1.0) + i18n (1.15.2) + concurrent-ruby (~> 1.0) + importmap-rails (2.2.3) + actionpack (>= 6.0.0) + activesupport (>= 6.0.0) + railties (>= 6.0.0) + io-console (0.8.2) + irb (1.18.0) + pp (>= 0.6.0) + prism (>= 1.3.0) + rdoc (>= 4.0.0) + reline (>= 0.4.2) + json (2.20.0) + language_server-protocol (3.17.0.5) + lint_roller (1.1.0) + logger (1.7.0) + loofah (2.25.1) + crass (~> 1.0.2) + nokogiri (>= 1.12.0) + mail (2.9.0) + logger + mini_mime (>= 0.1.1) + net-imap + net-pop + net-smtp + marcel (1.2.1) + mini_mime (1.1.5) + minitest (6.0.6) + drb (~> 2.0) + prism (~> 1.5) + msgpack (1.8.3) + net-imap (0.6.4.1) + date + net-protocol + net-pop (0.1.2) + net-protocol + net-protocol (0.2.2) + timeout + net-smtp (0.5.1) + net-protocol + nio4r (2.7.5) + nokogiri (1.19.4-aarch64-linux-gnu) + racc (~> 1.4) + nokogiri (1.19.4-aarch64-linux-musl) + racc (~> 1.4) + nokogiri (1.19.4-arm-linux-gnu) + racc (~> 1.4) + nokogiri (1.19.4-arm-linux-musl) + racc (~> 1.4) + nokogiri (1.19.4-arm64-darwin) + racc (~> 1.4) + nokogiri (1.19.4-x86_64-darwin) + racc (~> 1.4) + nokogiri (1.19.4-x86_64-linux-gnu) + racc (~> 1.4) + nokogiri (1.19.4-x86_64-linux-musl) + racc (~> 1.4) + parallel (2.1.0) + parser (3.3.11.1) + ast (~> 2.4.1) + racc + pp (0.6.4) + prettyprint + prettyprint (0.2.0) + prism (1.9.0) + propshaft (1.3.2) + actionpack (>= 7.0.0) + activesupport (>= 7.0.0) + rack + psych (5.4.0) + date + stringio + puma (8.0.2) + nio4r (~> 2.0) + raabro (1.4.0) + racc (1.8.1) + rack (3.2.6) + rack-session (2.1.2) + base64 (>= 0.1.0) + rack (>= 3.0.0) + rack-test (2.2.0) + rack (>= 1.3) + rackup (2.3.1) + rack (>= 3) + rails (8.1.3) + actioncable (= 8.1.3) + actionmailbox (= 8.1.3) + actionmailer (= 8.1.3) + actionpack (= 8.1.3) + actiontext (= 8.1.3) + actionview (= 8.1.3) + activejob (= 8.1.3) + activemodel (= 8.1.3) + activerecord (= 8.1.3) + activestorage (= 8.1.3) + activesupport (= 8.1.3) + bundler (>= 1.15.0) + railties (= 8.1.3) + rails-dom-testing (2.3.0) + activesupport (>= 5.0.0) + minitest + nokogiri (>= 1.6) + rails-html-sanitizer (1.7.0) + loofah (~> 2.25) + 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) + railties (8.1.3) + actionpack (= 8.1.3) + activesupport (= 8.1.3) + irb (~> 1.13) + rackup (>= 1.0.0) + rake (>= 12.2) + thor (~> 1.0, >= 1.2.2) + tsort (>= 0.2) + zeitwerk (~> 2.6) + rainbow (3.1.1) + rake (13.4.2) + rdoc (7.2.0) + erb + psych (>= 4.0.0) + tsort + redis (5.4.1) + redis-client (>= 0.22.0) + redis-client (0.30.0) + connection_pool + regexp_parser (2.12.0) + reline (0.6.3) + io-console (~> 0.5) + rubocop (1.88.0) + json (~> 2.3) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.1.0) + parallel (>= 1.10) + parser (>= 3.3.0.2) + rainbow (>= 2.2.2, < 4.0) + regexp_parser (>= 2.9.3, < 3.0) + rubocop-ast (>= 1.49.0, < 2.0) + ruby-progressbar (~> 1.7) + unicode-display_width (>= 2.4.0, < 4.0) + rubocop-ast (1.49.1) + parser (>= 3.3.7.2) + prism (~> 1.7) + rubocop-performance (1.26.1) + lint_roller (~> 1.1) + rubocop (>= 1.75.0, < 2.0) + rubocop-ast (>= 1.47.1, < 2.0) + rubocop-rails (2.35.5) + activesupport (>= 4.2.0) + lint_roller (~> 1.1) + rack (>= 1.1) + rubocop (>= 1.75.0, < 2.0) + rubocop-ast (>= 1.44.0, < 2.0) + rubocop-rails-omakase (1.1.0) + rubocop (>= 1.72) + rubocop-performance (>= 1.24) + rubocop-rails (>= 2.30) + ruby-next-core (1.2.0) + ruby-progressbar (1.13.0) + securerandom (0.4.1) + solid_cable (4.0.0) + 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-aarch64-linux-musl) + sqlite3 (2.9.5-arm-linux-gnu) + sqlite3 (2.9.5-arm-linux-musl) + sqlite3 (2.9.5-arm64-darwin) + sqlite3 (2.9.5-x86_64-darwin) + sqlite3 (2.9.5-x86_64-linux-gnu) + sqlite3 (2.9.5-x86_64-linux-musl) + stimulus-rails (1.3.4) + railties (>= 6.0.0) + stringio (3.2.0) + thor (1.5.0) + thruster (0.1.21) + thruster (0.1.21-aarch64-linux) + thruster (0.1.21-arm64-darwin) + thruster (0.1.21-x86_64-darwin) + thruster (0.1.21-x86_64-linux) + timeout (0.6.1) + tsort (0.2.0) + turbo-rails (2.0.23) + actionpack (>= 7.1.0) + railties (>= 7.1.0) + tzinfo (2.0.6) + concurrent-ruby (~> 1.0) + unicode-display_width (3.2.0) + unicode-emoji (~> 4.1) + unicode-emoji (4.2.0) + uri (1.1.1) + useragent (0.16.11) + web-console (4.3.0) + actionview (>= 8.0.0) + bindex (>= 0.4.0) + railties (>= 8.0.0) + websocket-driver (0.8.2) + base64 + websocket-extensions (>= 0.1.0) + websocket-extensions (0.1.5) + zeitwerk (2.8.2) + +PLATFORMS + aarch64-linux + aarch64-linux-gnu + aarch64-linux-musl + arm-linux-gnu + arm-linux-musl + arm64-darwin + x86_64-darwin + x86_64-linux + x86_64-linux-gnu + x86_64-linux-musl + +DEPENDENCIES + anycable-rails (~> 1.6) + bootsnap + brakeman + bundler-audit + debug + importmap-rails + propshaft + puma (>= 5.0) + rails (~> 8.1.3) + redis (>= 4.0) + rubocop-rails-omakase + solid_cable + solid_cache + solid_queue + sqlite3 (>= 2.1) + stimulus-rails + thruster + turbo-rails + tzinfo-data + web-console + +CHECKSUMS + action_text-trix (2.1.19) sha256=7012f59421009cf284aa651294896414d653a61a2417c9b8714c8476d2f74009 + actioncable (8.1.3) sha256=e5bc7f75e44e6a22de29c4f43176927c3a9ce4824464b74ed18d8226e75a80f0 + actionmailbox (8.1.3) sha256=df7da474eaa0e70df4ed5a6fef66eb3b3b0f2dbf7f14518deee8d77f1b4aae59 + actionmailer (8.1.3) sha256=831f724891bb70d0aaa4d76581a6321124b6a752cb655c9346aae5479318448d + actionpack (8.1.3) sha256=af998cae4d47c5d581a2cc363b5c77eb718b7c4b45748d81b1887b25621c29a3 + actiontext (8.1.3) sha256=d291019c00e1ea9e6463011fa214f6081a56d7b9a1d224e7d3f6384c1dafc7d2 + actionview (8.1.3) sha256=1347c88c7f3edb38100c5ce0e9fb5e62d7755f3edc1b61cce2eb0b2c6ea2fd5d + activejob (8.1.3) sha256=a149b1766aa8204c3c3da7309e4becd40fcd5529c348cffbf6c9b16b565fe8d3 + activemodel (8.1.3) sha256=90c05cbe4cef3649b8f79f13016191ea94c4525ce4a5c0fb7ef909c4b91c8219 + activerecord (8.1.3) sha256=8003be7b2466ba0a2a670e603eeb0a61dd66058fccecfc49901e775260ac70ab + activestorage (8.1.3) sha256=0564ce9309143951a67615e1bb4e090ee54b8befed417133cae614479b46384d + activesupport (8.1.3) sha256=21a5e0dfbd4c3ddd9e1317ec6a4d782fa226e7867dc70b0743acda81a1dca20e + anycable (1.6.4) sha256=a440dd44d4d8dc45b18751b041e775e0b98df458b1d8043b511903423a2f9118 + anycable-core (1.6.4) sha256=17073a3c744d7057e43bc7ed2cbdc1d50f173bb0c874109c55a49c7b95819396 + anycable-rails (1.6.2) sha256=954fd30f0f91825a06122da85abbf5b962d22fe0f4f3a2b5b262d4b98f5f3582 + anycable-rails-core (1.6.2) sha256=868e31c1df0aa3096073fa59df5c9815cdaaf2e798578d874f598044418f6d13 + anyway_config (2.8.0) sha256=f6797a7231f81202dcd3d0c07284e836e45713e761d320180348b13a5c7c9306 + ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383 + base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b + bigdecimal (4.1.2) sha256=53d217666027eab4280346fba98e7d5b66baaae1b9c3c1c0ffe89d48188a3fbd + bindex (0.8.1) sha256=7b1ecc9dc539ed8bccfc8cb4d2732046227b09d6f37582ff12e50a5047ceb17e + bootsnap (1.24.6) sha256=c60bab88c70332290f0a2636a288f675299eb4f804a02a3c085b42eca9da164a + brakeman (8.0.5) sha256=03735f9690d3fd4b32d66aacbf0a6d15a84266bdd06b32c05c8ecc8f6021d2be + builder (3.3.0) sha256=497918d2f9dca528fdca4b88d84e4ef4387256d984b8154e9d5d3fe5a9c8835f + bundler-audit (0.9.3) sha256=81c8766c71e47d0d28a0f98c7eed028539f21a6ea3cd8f685eb6f42333c9b4e9 + concurrent-ruby (1.3.7) sha256=4412caec3a5ea2e5fdc52076724c071a81f2c0593d83b2ac8cbb8ca63b3151b0 + connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a + crass (1.0.6) sha256=dc516022a56e7b3b156099abc81b6d2b08ea1ed12676ac7a5657617f012bd45d + date (3.5.1) sha256=750d06384d7b9c15d562c76291407d89e368dda4d4fff957eb94962d325a0dc0 + debug (1.11.1) sha256=2e0b0ac6119f2207a6f8ac7d4a73ca8eb4e440f64da0a3136c30343146e952b6 + drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373 + erb (6.0.4) sha256=38e3803694be357fe2bfe312487c74beaf9fb4e5beb3e22498952fe1645b95d9 + erubi (1.13.1) sha256=a082103b0885dbc5ecf1172fede897f9ebdb745a4b97a5e8dc63953db1ee4ad9 + et-orbi (1.4.0) sha256=6c7e3c90779821f9e3b324c5e96fda9767f72995d6ae435b96678a4f3e2de8bc + fugit (1.12.2) sha256=643f2bf28db263bd400cbf8e0dd8b76b2c9b94bdb130e12d2394de04d9c20e5e + globalid (1.4.0) sha256=037f12fbf1d9d7a014d501c2d5c77356fd4ddd96d7a7991d6700bba96706f427 + google-protobuf (4.35.1) sha256=a3a6471331d918f58dfa4d014a8f6286f0af2cf4840216bde52fcf2ea3fe3726 + google-protobuf (4.35.1-aarch64-linux-gnu) sha256=50ca44d0eeff3f8475e630a1accdd974256f3510694d574e2c9d6119ea8bc9e1 + google-protobuf (4.35.1-aarch64-linux-musl) sha256=d5c65cef6bd6498a9e5ed5f88cf6cf7e341c10b0a005e32137d5d1a2b6e8c18a + google-protobuf (4.35.1-arm64-darwin) sha256=d9c957df04fa89c749fa9a72a7b383eb4296efc9b2303dc6fd6fbe39c698ad6b + google-protobuf (4.35.1-x86_64-darwin) sha256=66b62b4df00931018a692806df66393efa960d6d2b7da69735187249f950d3ee + google-protobuf (4.35.1-x86_64-linux-gnu) sha256=c786439087512a3fbd199e9897d265b855f951d4027e218ea55e858d45969edd + google-protobuf (4.35.1-x86_64-linux-musl) sha256=91890eb0002934a339fdb7d77a147c46b7474b6799db27872b747b905837f744 + googleapis-common-protos-types (1.23.0) sha256=992e740a523794d9fc5f29a504465d8fc737aaa16c930fe7228e3346860faf0a + grpc (1.81.1) sha256=9e5772153fe13a389654e9d397a90400f0077307fb4369f79f941d96c72e89cb + grpc (1.81.1-aarch64-linux-gnu) sha256=aa24d7253a2b15d2c3570265a2f58bb8aca0ecd2e1116b7a259d633ebf582445 + grpc (1.81.1-aarch64-linux-musl) sha256=36af13b712e33b323878096ddcab7ec75d107d2e03ffce4aa2fcb70835747c8c + grpc (1.81.1-arm64-darwin) sha256=6def610da088597e1a94bafbb36a92f19c456cb7df7bbe9618aeb63e8805ae9a + grpc (1.81.1-x86_64-darwin) sha256=f8c44cdfc26270247f2beb49ecbc983d0c184b6c24ac4670d1d24f0efba811ed + grpc (1.81.1-x86_64-linux-gnu) sha256=cf1053a594d026d2ec560457111258cd207bc67847aab24f96e04e2fbeddc4dd + grpc (1.81.1-x86_64-linux-musl) sha256=c4e2d53566dedfb7e76b2226c18f92f6378b29fefb80879b2dabe17480faa555 + i18n (1.15.2) sha256=00f9eb62412fe593b2a65a97daa75300d37abb8f7202ec748e94b6d46a9dd1b5 + importmap-rails (2.2.3) sha256=7101be2a4dc97cf1558fb8f573a718404c5f6bcfe94f304bf1f39e444feeb16a + io-console (0.8.2) sha256=d6e3ae7a7cc7574f4b8893b4fca2162e57a825b223a177b7afa236c5ef9814cc + irb (1.18.0) sha256=de9454a0703a54704b9811a5ef31a60c86949fbf4013fcf244fabc7c775248e3 + json (2.20.0) sha256=9362bc6e55a952b056abf9167cf053358181c904cb70cd6eee0808ea830fc32b + language_server-protocol (3.17.0.5) sha256=fd1e39a51a28bf3eec959379985a72e296e9f9acfce46f6a79d31ca8760803cc + lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87 + logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203 + loofah (2.25.1) sha256=d436c73dbd0c1147b16c4a41db097942d217303e1f7728704b37e4df9f6d2e04 + mail (2.9.0) sha256=6fa6673ecd71c60c2d996260f9ee3dd387d4673b8169b502134659ece6d34941 + marcel (1.2.1) sha256=1678e9360e32f9eafa917c80029e2f6d10b2715c66a4b87b6d0da9b9cd1f859f + mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef + minitest (6.0.6) sha256=153ea36d1d987a62942382b61075745042a2b3123b1cd48f4c3675af9cc7d6f1 + msgpack (1.8.3) sha256=8bda4a6428d3244e50d6bd55854d354edbada88a4e1f4f5731a39a0f86bee6a1 + net-imap (0.6.4.1) sha256=29f0360d75a7efd3539f16ac1957dea5c0a51ddeceb348db4553c3120914ea0d + net-pop (0.1.2) sha256=848b4e982013c15b2f0382792268763b748cce91c9e91e36b0f27ed26420dff3 + net-protocol (0.2.2) sha256=aa73e0cba6a125369de9837b8d8ef82a61849360eba0521900e2c3713aa162a8 + net-smtp (0.5.1) sha256=ed96a0af63c524fceb4b29b0d352195c30d82dd916a42f03c62a3a70e5b70736 + nio4r (2.7.5) sha256=6c90168e48fb5f8e768419c93abb94ba2b892a1d0602cb06eef16d8b7df1dca1 + nokogiri (1.19.4-aarch64-linux-gnu) sha256=1269fb644a6de405057a53dd5c762b1209b43ca7424f839454d3dbc677c31a8f + nokogiri (1.19.4-aarch64-linux-musl) sha256=35c65b9ce72b3bb03207bdbe7067915019dc18c1b9b59139684bd6690fdd01af + nokogiri (1.19.4-arm-linux-gnu) sha256=a301313e38bb065d68239e79734bcd6f56fb6efaacebde29e9abf2a4735340ca + nokogiri (1.19.4-arm-linux-musl) sha256=588923c101bcfa78869734d247d25b598674323e7f22474fc468f6e5647311eb + nokogiri (1.19.4-arm64-darwin) sha256=a46db9853286e6597b36ebc6953817d15acf3a299583eb3f89fdc6f91dd63527 + nokogiri (1.19.4-x86_64-darwin) sha256=7fd17057d3e1f00e9954a74b3cd76595d3d4a5ef233b7ed9599047c204f70551 + nokogiri (1.19.4-x86_64-linux-gnu) sha256=379fae440b28915e3f19d752ce2dcf8465ed2b2fbefd2a7ca0dd497bc981a06a + nokogiri (1.19.4-x86_64-linux-musl) sha256=17dfb7c1fa194ae02fbf7c51a7afc8d278045ab3fdacfd86f91d02d7b274470b + parallel (2.1.0) sha256=b35258865c2e31134c5ecb708beaaf6772adf9d5efae28e93e99260877b09356 + parser (3.3.11.1) sha256=d17ace7aabe3e72c3cc94043714be27cc6f852f104d81aa284c2281aecc65d54 + pp (0.6.4) sha256=dfcb0fce700c41456265922884f9fe195d7fbb0674a3578e6c0f69588e82b570 + prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193 + prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85 + propshaft (1.3.2) sha256=1d56a3e56a92c21bfc29caf07406b5386b00d4c47ddf357cf989a5a234b1389e + psych (5.4.0) sha256=14f72d69a611af663d7d70e4a7b67d9eb1f3ae9f8d916b478961d5a0075ba5b7 + puma (8.0.2) sha256=c8ed871dfbbe66448ea9ffd46692342d9804d4071522b52b5331b7b6e7b686fb + raabro (1.4.0) sha256=d4fa9ff5172391edb92b242eed8be802d1934b1464061ae5e70d80962c5da882 + racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f + rack (3.2.6) sha256=5ed78e1f73b2e25679bec7d45ee2d4483cc4146eb1be0264fc4d94cb5ef212c2 + rack-session (2.1.2) sha256=595434f8c0c3473ae7d7ac56ecda6cc6dfd9d37c0b2b5255330aa1576967ffe8 + rack-test (2.2.0) sha256=005a36692c306ac0b4a9350355ee080fd09ddef1148a5f8b2ac636c720f5c463 + rackup (2.3.1) sha256=6c79c26753778e90983761d677a48937ee3192b3ffef6bc963c0950f94688868 + rails (8.1.3) sha256=6d017ba5348c98fc909753a8169b21d44de14d2a0b92d140d1a966834c3c9cd3 + rails-dom-testing (2.3.0) sha256=8acc7953a7b911ca44588bf08737bc16719f431a1cc3091a292bca7317925c1d + rails-html-sanitizer (1.7.0) sha256=28b145cceaf9cc214a9874feaa183c3acba036c9592b19886e0e45efc62b1e89 + railties (8.1.3) sha256=913eb0e0cb520aac687ffd74916bd726d48fa21f47833c6292576ef6a286de22 + rainbow (3.1.1) sha256=039491aa3a89f42efa1d6dec2fc4e62ede96eb6acd95e52f1ad581182b79bc6a + rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701 + rdoc (7.2.0) sha256=8650f76cd4009c3b54955eb5d7e3a075c60a57276766ebf36f9085e8c9f23192 + redis (5.4.1) sha256=b5e675b57ad22b15c9bcc765d5ac26f60b675408af916d31527af9bd5a81faae + redis-client (0.30.0) sha256=743f11ed42f0a41a0341554087b077479fec7e2d47a7c123fd90a12c0db5e477 + regexp_parser (2.12.0) sha256=35a916a1d63190ab5c9009457136ae5f3c0c7512d60291d0d1378ba18ce08ebb + reline (0.6.3) sha256=1198b04973565b36ec0f11542ab3f5cfeeec34823f4e54cebde90968092b1835 + rubocop (1.88.0) sha256=e420ddf1662d0ef34bc8a2910ac4b396a7ddda0b51a708264405241734b08e0b + rubocop-ast (1.49.1) sha256=4412f3ee70f6fe4546cc489548e0f6fcf76cafcfa80fa03af67098ffed755035 + rubocop-performance (1.26.1) sha256=cd19b936ff196df85829d264b522fd4f98b6c89ad271fa52744a8c11b8f71834 + rubocop-rails (2.35.5) sha256=f00b3c936002ba8e9ac62e8607c54bb24cda44b36e41b9c7e4f3872e1b0f3fe3 + rubocop-rails-omakase (1.1.0) sha256=2af73ac8ee5852de2919abbd2618af9c15c19b512c4cfc1f9a5d3b6ef009109d + ruby-next-core (1.2.0) sha256=f6a7d00bb5186cecbb02f7f1845a0f3a2c9788d35b6ccff5c9be3f0d46799b86 + ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33 + securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1 + solid_cable (4.0.0) sha256=8379680ef6bf36e195eb876a6306ea290f87d5fa10bc4a757bc2a918f83229b5 + solid_cache (1.0.10) sha256=bc05a2fb3ac78a6f43cbb5946679cf9db67dd30d22939ededc385cb93e120d41 + solid_queue (1.4.0) sha256=e6a18d196f0b27cb6e3c77c5b31258b05fb634f8ed64fb1866ed164047216c2a + sqlite3 (2.9.5-aarch64-linux-gnu) sha256=78075b6337d3d182c6d2b4691049ed45cd220826160c9ea18946bf6a1de200dc + sqlite3 (2.9.5-aarch64-linux-musl) sha256=18c801185deb4adc01ddb281e8f672a39e3d1729979ca91e39439cd3eac0402d + sqlite3 (2.9.5-arm-linux-gnu) sha256=1bdfca0c7d63998c60b0f4a8e3c8df2d33800ccc4abd2d612eddbbbc92a4c48b + sqlite3 (2.9.5-arm-linux-musl) sha256=bae1109d12b2e9f588455967729b008e1ff4feb7761749df695019c9079913c6 + sqlite3 (2.9.5-arm64-darwin) sha256=d0cf444a70fc9395d513cfbcc1e6719e224aa645314e3824cb0474c721425aa2 + sqlite3 (2.9.5-x86_64-darwin) sha256=8e9caae38bd7ebb29cbeee3e7ab1d12dc2327d9a1b92c7fcf0dda05589627a81 + sqlite3 (2.9.5-x86_64-linux-gnu) sha256=233dbcb6714148dd23bc5aeb33e8efd6eac974969564ddd5794c23d5f52b231e + sqlite3 (2.9.5-x86_64-linux-musl) sha256=e7d3a7474e8af0f96150c21abc203fbab5437206bfcdf11deab7741c0ca516f2 + stimulus-rails (1.3.4) sha256=765676ffa1f33af64ce026d26b48e8ffb2e0b94e0f50e9119e11d6107d67cb06 + stringio (3.2.0) sha256=c37cb2e58b4ffbd33fe5cd948c05934af997b36e0b6ca6fdf43afa234cf222e1 + thor (1.5.0) sha256=e3a9e55fe857e44859ce104a84675ab6e8cd59c650a49106a05f55f136425e73 + thruster (0.1.21) sha256=dc67928f36e5894844579a95e45637a5091db7a7ea05468ee8c2c6eb0a3f77cf + thruster (0.1.21-aarch64-linux) sha256=f5aff78fb7a6431ed3d6ab4bde03a89c461e9a73981dbc97d6990d85c3db235c + thruster (0.1.21-arm64-darwin) sha256=bd8db9f57fae2cbb3fe08ebab49cb47fe49608122dac23daf0ce709adfb9bfc8 + thruster (0.1.21-x86_64-darwin) sha256=ccd6acd144fad27856800edfa0573944018333fac8e10a2e5d09726b70c8b0db + thruster (0.1.21-x86_64-linux) sha256=6e2fbcf826540a72d3710ae4db072c2333287ac2ee57e7e52f35bc10900d74a7 + timeout (0.6.1) sha256=78f57368a7e7bbadec56971f78a3f5ecbcfb59b7fcbb0a3ed6ddc08a5094accb + tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f + turbo-rails (2.0.23) sha256=ee0d90733aafff056cf51ff11e803d65e43cae258cc55f6492020ec1f9f9315f + tzinfo (2.0.6) sha256=8daf828cc77bcf7d63b0e3bdb6caa47e2272dcfaf4fbfe46f8c3a9df087a829b + unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42 + unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f + uri (1.1.1) sha256=379fa58d27ffb1387eaada68c749d1426738bd0f654d812fcc07e7568f5c57c6 + useragent (0.16.11) sha256=700e6413ad4bb954bb63547fa098dddf7b0ebe75b40cc6f93b8d54255b173844 + web-console (4.3.0) sha256=e13b71301cdfc2093f155b5aa3a622db80b4672d1f2f713119cc7ec7ac6a6da4 + websocket-driver (0.8.2) sha256=97c556b019bf3410b4961002ac501621e9322d3f8a7bc02161a09301cc4c4146 + websocket-extensions (0.1.5) sha256=1c6ba63092cda343eb53fc657110c71c754c56484aad42578495227d717a8241 + zeitwerk (2.8.2) sha256=7212a61311083c604184b1ea2574b9aa05cd14f855a0841c06985cabe9181d12 + +BUNDLED WITH + 4.0.4 diff --git a/cable-bench/README.md b/cable-bench/README.md new file mode 100644 index 0000000..7db80e4 --- /dev/null +++ b/cable-bench/README.md @@ -0,0 +1,24 @@ +# README + +This README would normally document whatever steps are necessary to get the +application up and running. + +Things you may want to cover: + +* Ruby version + +* System dependencies + +* Configuration + +* Database creation + +* Database initialization + +* How to run the test suite + +* Services (job queues, cache servers, search engines, etc.) + +* Deployment instructions + +* ... diff --git a/cable-bench/Rakefile b/cable-bench/Rakefile new file mode 100644 index 0000000..9a5ea73 --- /dev/null +++ b/cable-bench/Rakefile @@ -0,0 +1,6 @@ +# Add your own tasks in files placed in lib/tasks ending in .rake, +# for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. + +require_relative "config/application" + +Rails.application.load_tasks diff --git a/cable-bench/app/assets/images/.keep b/cable-bench/app/assets/images/.keep new file mode 100644 index 0000000..e69de29 diff --git a/cable-bench/app/assets/stylesheets/application.css b/cable-bench/app/assets/stylesheets/application.css new file mode 100644 index 0000000..fe93333 --- /dev/null +++ b/cable-bench/app/assets/stylesheets/application.css @@ -0,0 +1,10 @@ +/* + * This is a manifest file that'll be compiled into application.css. + * + * With Propshaft, assets are served efficiently without preprocessing steps. You can still include + * application-wide styles in this file, but keep in mind that CSS precedence will follow the standard + * cascading order, meaning styles declared later in the document or manifest will override earlier ones, + * depending on specificity. + * + * Consider organizing styles into separate files for maintainability. + */ diff --git a/cable-bench/app/channels/application_cable/channel.rb b/cable-bench/app/channels/application_cable/channel.rb new file mode 100644 index 0000000..d672697 --- /dev/null +++ b/cable-bench/app/channels/application_cable/channel.rb @@ -0,0 +1,4 @@ +module ApplicationCable + class Channel < ActionCable::Channel::Base + end +end diff --git a/cable-bench/app/channels/application_cable/connection.rb b/cable-bench/app/channels/application_cable/connection.rb new file mode 100644 index 0000000..333d36f --- /dev/null +++ b/cable-bench/app/channels/application_cable/connection.rb @@ -0,0 +1,7 @@ +module ApplicationCable + # The benchmark connects anonymous clients (the load generator opens raw + # WebSockets and subscribes to BenchmarkChannel). No app-level auth is + # performed, so every adapter is measured on the same handshake cost. + class Connection < ActionCable::Connection::Base + end +end diff --git a/cable-bench/app/channels/benchmark_channel.rb b/cable-bench/app/channels/benchmark_channel.rb new file mode 100644 index 0000000..c3b2ca2 --- /dev/null +++ b/cable-bench/app/channels/benchmark_channel.rb @@ -0,0 +1,16 @@ +# The one channel the harness subscribes to. A client subscribes with +# `{ channel: "BenchmarkChannel", stream_name: "" }` and receives every +# message broadcast to that stream. This is identical Action Cable code for +# all three adapters (Solid Cable, classic Action Cable / Redis, AnyCable) — +# what differs is the transport underneath, not the app. +class BenchmarkChannel < ApplicationCable::Channel + def subscribed + stream_from params[:stream_name] + end + + # Client-to-client fan-out used by the optional whispers test. Mirrors the + # Node socket.io server's whisper handler so the harness can reuse its driver. + def whisper(data) + ActionCable.server.broadcast(params[:stream_name], data) + end +end diff --git a/cable-bench/app/controllers/application_controller.rb b/cable-bench/app/controllers/application_controller.rb new file mode 100644 index 0000000..c353756 --- /dev/null +++ b/cable-bench/app/controllers/application_controller.rb @@ -0,0 +1,7 @@ +class ApplicationController < ActionController::Base + # Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has. + allow_browser versions: :modern + + # Changes to the importmap will invalidate the etag for HTML responses + stale_when_importmap_changes +end diff --git a/cable-bench/app/controllers/bench_controller.rb b/cable-bench/app/controllers/bench_controller.rb new file mode 100644 index 0000000..2ca8a13 --- /dev/null +++ b/cable-bench/app/controllers/bench_controller.rb @@ -0,0 +1,41 @@ +# The harness publishes through Rails so every adapter shares one publish +# path: POST /_bench/broadcast {stream, data} -> ActionCable.server.broadcast. +# For AnyCable this is patched to publish through the broker (extended +# protocol, reliable streams); for Action Cable / Solid Cable it goes through +# the Redis / DB adapter. Same code, different transport. +class BenchController < ActionController::Base + # The load generator posts JSON without a CSRF token. + skip_forgery_protection + + before_action :authorize, only: :broadcast + + # GET /health — liveness probe (open, no auth). + def health + render json: { status: "ok", mode: ENV.fetch("CABLE_ADAPTER", "solid_cable") } + end + + # POST /_bench/broadcast {stream, data} + # `data` arrives as a JSON string (the harness pre-serializes it); broadcast + # the parsed object so subscribers receive { seq, sentAt, text }. + def broadcast + payload = + begin + JSON.parse(params.require(:data)) + rescue JSON::ParserError, TypeError + params[:data] + end + ActionCable.server.broadcast(params.require(:stream), payload) + head :ok + end + + private + + # Optional bearer gate, matching the bench-runner's BENCH_RUNNER_TOKEN. If + # the env var is unset (local dev), the endpoint is open. + def authorize + expected = ENV["BENCH_RUNNER_TOKEN"] + return if expected.blank? + provided = request.headers["Authorization"].to_s.delete_prefix("Bearer ") + head :unauthorized unless ActiveSupport::SecurityUtils.secure_compare(provided, expected) + end +end diff --git a/cable-bench/app/controllers/concerns/.keep b/cable-bench/app/controllers/concerns/.keep new file mode 100644 index 0000000..e69de29 diff --git a/cable-bench/app/helpers/application_helper.rb b/cable-bench/app/helpers/application_helper.rb new file mode 100644 index 0000000..de6be79 --- /dev/null +++ b/cable-bench/app/helpers/application_helper.rb @@ -0,0 +1,2 @@ +module ApplicationHelper +end diff --git a/cable-bench/app/javascript/application.js b/cable-bench/app/javascript/application.js new file mode 100644 index 0000000..0d7b494 --- /dev/null +++ b/cable-bench/app/javascript/application.js @@ -0,0 +1,3 @@ +// Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails +import "@hotwired/turbo-rails" +import "controllers" diff --git a/cable-bench/app/javascript/controllers/application.js b/cable-bench/app/javascript/controllers/application.js new file mode 100644 index 0000000..1213e85 --- /dev/null +++ b/cable-bench/app/javascript/controllers/application.js @@ -0,0 +1,9 @@ +import { Application } from "@hotwired/stimulus" + +const application = Application.start() + +// Configure Stimulus development experience +application.debug = false +window.Stimulus = application + +export { application } diff --git a/cable-bench/app/javascript/controllers/hello_controller.js b/cable-bench/app/javascript/controllers/hello_controller.js new file mode 100644 index 0000000..5975c07 --- /dev/null +++ b/cable-bench/app/javascript/controllers/hello_controller.js @@ -0,0 +1,7 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + connect() { + this.element.textContent = "Hello World!" + } +} diff --git a/cable-bench/app/javascript/controllers/index.js b/cable-bench/app/javascript/controllers/index.js new file mode 100644 index 0000000..1156bf8 --- /dev/null +++ b/cable-bench/app/javascript/controllers/index.js @@ -0,0 +1,4 @@ +// Import and register all your controllers from the importmap via controllers/**/*_controller +import { application } from "controllers/application" +import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading" +eagerLoadControllersFrom("controllers", application) diff --git a/cable-bench/app/jobs/application_job.rb b/cable-bench/app/jobs/application_job.rb new file mode 100644 index 0000000..d394c3d --- /dev/null +++ b/cable-bench/app/jobs/application_job.rb @@ -0,0 +1,7 @@ +class ApplicationJob < ActiveJob::Base + # Automatically retry jobs that encountered a deadlock + # retry_on ActiveRecord::Deadlocked + + # Most jobs are safe to ignore if the underlying records are no longer available + # discard_on ActiveJob::DeserializationError +end diff --git a/cable-bench/app/models/application_record.rb b/cable-bench/app/models/application_record.rb new file mode 100644 index 0000000..b63caeb --- /dev/null +++ b/cable-bench/app/models/application_record.rb @@ -0,0 +1,3 @@ +class ApplicationRecord < ActiveRecord::Base + primary_abstract_class +end diff --git a/cable-bench/app/models/concerns/.keep b/cable-bench/app/models/concerns/.keep new file mode 100644 index 0000000..e69de29 diff --git a/cable-bench/app/views/layouts/application.html.erb b/cable-bench/app/views/layouts/application.html.erb new file mode 100644 index 0000000..95012f0 --- /dev/null +++ b/cable-bench/app/views/layouts/application.html.erb @@ -0,0 +1,29 @@ + + + + <%= content_for(:title) || "Cable Bench" %> + + + + + <%= csrf_meta_tags %> + <%= csp_meta_tag %> + + <%= yield :head %> + + <%# Enable PWA manifest for installable apps (make sure to enable in config/routes.rb too!) %> + <%#= tag.link rel: "manifest", href: pwa_manifest_path(format: :json) %> + + + + + + <%# Includes all stylesheet files in app/assets/stylesheets %> + <%= stylesheet_link_tag :app, "data-turbo-track": "reload" %> + <%= javascript_importmap_tags %> + + + + <%= yield %> + + diff --git a/cable-bench/app/views/pwa/manifest.json.erb b/cable-bench/app/views/pwa/manifest.json.erb new file mode 100644 index 0000000..9cbe4ae --- /dev/null +++ b/cable-bench/app/views/pwa/manifest.json.erb @@ -0,0 +1,22 @@ +{ + "name": "CableBench", + "icons": [ + { + "src": "/icon.png", + "type": "image/png", + "sizes": "512x512" + }, + { + "src": "/icon.png", + "type": "image/png", + "sizes": "512x512", + "purpose": "maskable" + } + ], + "start_url": "/", + "display": "standalone", + "scope": "/", + "description": "CableBench.", + "theme_color": "red", + "background_color": "red" +} diff --git a/cable-bench/app/views/pwa/service-worker.js b/cable-bench/app/views/pwa/service-worker.js new file mode 100644 index 0000000..b3a13fb --- /dev/null +++ b/cable-bench/app/views/pwa/service-worker.js @@ -0,0 +1,26 @@ +// Add a service worker for processing Web Push notifications: +// +// self.addEventListener("push", async (event) => { +// const { title, options } = await event.data.json() +// event.waitUntil(self.registration.showNotification(title, options)) +// }) +// +// self.addEventListener("notificationclick", function(event) { +// event.notification.close() +// event.waitUntil( +// clients.matchAll({ type: "window" }).then((clientList) => { +// for (let i = 0; i < clientList.length; i++) { +// let client = clientList[i] +// let clientPath = (new URL(client.url)).pathname +// +// if (clientPath == event.notification.data.path && "focus" in client) { +// return client.focus() +// } +// } +// +// if (clients.openWindow) { +// return clients.openWindow(event.notification.data.path) +// } +// }) +// ) +// }) diff --git a/cable-bench/bin/bench-entrypoint b/cable-bench/bin/bench-entrypoint new file mode 100755 index 0000000..f5cebde --- /dev/null +++ b/cable-bench/bin/bench-entrypoint @@ -0,0 +1,33 @@ +#!/bin/bash +# One image, three benchmark targets. BENCH_MODE selects the cable adapter and +# the process to run: +# solidcable (default) -> Puma serving /cable, Solid Cable (DB polling) +# actioncable -> Puma serving /cable, Action Cable Redis adapter +# anycable -> AnyCable gRPC RPC server (anycable-go is separate) +set -e + +export SECRET_KEY_BASE_DUMMY="${SECRET_KEY_BASE_DUMMY:-1}" + +MODE="${BENCH_MODE:-solidcable}" +case "$MODE" in + actioncable) export CABLE_ADAPTER=redis ;; + solidcable) export CABLE_ADAPTER=solid_cable ;; + anycable) export CABLE_ADAPTER=any_cable ;; + *) echo "[entrypoint] unknown BENCH_MODE='$MODE' (actioncable|solidcable|anycable)" >&2; exit 1 ;; +esac +echo "[entrypoint] BENCH_MODE=$MODE CABLE_ADAPTER=$CABLE_ADAPTER PORT=${PORT:-3000}" + +# Create + load the SQLite databases (primary and, for solid_cable, the cable +# stream table). Idempotent across restarts. +./bin/rails db:prepare + +if [ "$MODE" = "anycable" ]; then + # Bind the RPC server on IPv6 so the anycable-go gateway can dial it over + # Railway's private network. Override ANYCABLE_RPC_HOST in deploy if needed. + export ANYCABLE_RPC_HOST="${ANYCABLE_RPC_HOST:-[::]:50051}" + echo "[entrypoint] starting AnyCable RPC server on $ANYCABLE_RPC_HOST" + exec bundle exec anycable +else + echo "[entrypoint] starting Puma" + exec ./bin/rails server -e production +fi diff --git a/cable-bench/bin/brakeman b/cable-bench/bin/brakeman new file mode 100755 index 0000000..ace1c9b --- /dev/null +++ b/cable-bench/bin/brakeman @@ -0,0 +1,7 @@ +#!/usr/bin/env ruby +require "rubygems" +require "bundler/setup" + +ARGV.unshift("--ensure-latest") + +load Gem.bin_path("brakeman", "brakeman") diff --git a/cable-bench/bin/bundler-audit b/cable-bench/bin/bundler-audit new file mode 100755 index 0000000..e2ef226 --- /dev/null +++ b/cable-bench/bin/bundler-audit @@ -0,0 +1,6 @@ +#!/usr/bin/env ruby +require_relative "../config/boot" +require "bundler/audit/cli" + +ARGV.concat %w[ --config config/bundler-audit.yml ] if ARGV.empty? || ARGV.include?("check") +Bundler::Audit::CLI.start diff --git a/cable-bench/bin/ci b/cable-bench/bin/ci new file mode 100755 index 0000000..4137ad5 --- /dev/null +++ b/cable-bench/bin/ci @@ -0,0 +1,6 @@ +#!/usr/bin/env ruby +require_relative "../config/boot" +require "active_support/continuous_integration" + +CI = ActiveSupport::ContinuousIntegration +require_relative "../config/ci.rb" diff --git a/cable-bench/bin/dev b/cable-bench/bin/dev new file mode 100755 index 0000000..5f91c20 --- /dev/null +++ b/cable-bench/bin/dev @@ -0,0 +1,2 @@ +#!/usr/bin/env ruby +exec "./bin/rails", "server", *ARGV diff --git a/cable-bench/bin/importmap b/cable-bench/bin/importmap new file mode 100755 index 0000000..36502ab --- /dev/null +++ b/cable-bench/bin/importmap @@ -0,0 +1,4 @@ +#!/usr/bin/env ruby + +require_relative "../config/application" +require "importmap/commands" diff --git a/cable-bench/bin/jobs b/cable-bench/bin/jobs new file mode 100755 index 0000000..dcf59f3 --- /dev/null +++ b/cable-bench/bin/jobs @@ -0,0 +1,6 @@ +#!/usr/bin/env ruby + +require_relative "../config/environment" +require "solid_queue/cli" + +SolidQueue::Cli.start(ARGV) diff --git a/cable-bench/bin/rails b/cable-bench/bin/rails new file mode 100755 index 0000000..efc0377 --- /dev/null +++ b/cable-bench/bin/rails @@ -0,0 +1,4 @@ +#!/usr/bin/env ruby +APP_PATH = File.expand_path("../config/application", __dir__) +require_relative "../config/boot" +require "rails/commands" diff --git a/cable-bench/bin/rake b/cable-bench/bin/rake new file mode 100755 index 0000000..4fbf10b --- /dev/null +++ b/cable-bench/bin/rake @@ -0,0 +1,4 @@ +#!/usr/bin/env ruby +require_relative "../config/boot" +require "rake" +Rake.application.run diff --git a/cable-bench/bin/rubocop b/cable-bench/bin/rubocop new file mode 100755 index 0000000..5a20504 --- /dev/null +++ b/cable-bench/bin/rubocop @@ -0,0 +1,8 @@ +#!/usr/bin/env ruby +require "rubygems" +require "bundler/setup" + +# Explicit RuboCop config increases performance slightly while avoiding config confusion. +ARGV.unshift("--config", File.expand_path("../.rubocop.yml", __dir__)) + +load Gem.bin_path("rubocop", "rubocop") diff --git a/cable-bench/bin/setup b/cable-bench/bin/setup new file mode 100755 index 0000000..81be011 --- /dev/null +++ b/cable-bench/bin/setup @@ -0,0 +1,35 @@ +#!/usr/bin/env ruby +require "fileutils" + +APP_ROOT = File.expand_path("..", __dir__) + +def system!(*args) + system(*args, exception: true) +end + +FileUtils.chdir APP_ROOT do + # This script is a way to set up or update your development environment automatically. + # This script is idempotent, so that you can run it at any time and get an expectable outcome. + # Add necessary setup steps to this file. + + puts "== Installing dependencies ==" + system("bundle check") || system!("bundle install") + + # puts "\n== Copying sample files ==" + # unless File.exist?("config/database.yml") + # FileUtils.cp "config/database.yml.sample", "config/database.yml" + # end + + puts "\n== Preparing database ==" + system! "bin/rails db:prepare" + system! "bin/rails db:reset" if ARGV.include?("--reset") + + puts "\n== Removing old logs and tempfiles ==" + system! "bin/rails log:clear tmp:clear" + + unless ARGV.include?("--skip-server") + puts "\n== Starting development server ==" + STDOUT.flush # flush the output before exec(2) so that it displays + exec "bin/dev" + end +end diff --git a/cable-bench/bin/thrust b/cable-bench/bin/thrust new file mode 100755 index 0000000..36bde2d --- /dev/null +++ b/cable-bench/bin/thrust @@ -0,0 +1,5 @@ +#!/usr/bin/env ruby +require "rubygems" +require "bundler/setup" + +load Gem.bin_path("thruster", "thrust") diff --git a/cable-bench/config.ru b/cable-bench/config.ru new file mode 100644 index 0000000..4a3c09a --- /dev/null +++ b/cable-bench/config.ru @@ -0,0 +1,6 @@ +# This file is used by Rack-based servers to start the application. + +require_relative "config/environment" + +run Rails.application +Rails.application.load_server diff --git a/cable-bench/config/anycable.yml b/cable-bench/config/anycable.yml new file mode 100644 index 0000000..b2ebab2 --- /dev/null +++ b/cable-bench/config/anycable.yml @@ -0,0 +1,17 @@ +# AnyCable (RPC) configuration for the AnyCable benchmark mode only. The other +# two modes (solid_cable, redis) never load this — Rails serves /cable itself. +# +# Here Rails runs the gRPC RPC server (`bundle exec anycable`) and anycable-go +# is the separate WebSocket gateway. Broadcasts go over Redis Streams (redisx) +# so the gateway's broker can keep stream history and resume the extended +# Action Cable protocol on reconnect (the delivery-guarantee path). Wire the +# Redis URL and RPC bind via env in deployment (ANYCABLE_REDIS_URL, +# ANYCABLE_RPC_HOST). +default: &default + broadcast_adapter: redisx + +development: + <<: *default + +production: + <<: *default diff --git a/cable-bench/config/application.rb b/cable-bench/config/application.rb new file mode 100644 index 0000000..0aeaddf --- /dev/null +++ b/cable-bench/config/application.rb @@ -0,0 +1,42 @@ +require_relative "boot" + +require "rails" +# Pick the frameworks you want: +require "active_model/railtie" +require "active_job/railtie" +require "active_record/railtie" +# require "active_storage/engine" +require "action_controller/railtie" +# require "action_mailer/railtie" +# require "action_mailbox/engine" +# require "action_text/engine" +require "action_view/railtie" +require "action_cable/engine" +# require "rails/test_unit/railtie" + +# Require the gems listed in Gemfile, including any gems +# you've limited to :test, :development, or :production. +Bundler.require(*Rails.groups) + +module CableBench + class Application < Rails::Application + # Initialize configuration defaults for originally generated Rails version. + config.load_defaults 8.1 + + # Please, add to the `ignore` list any other `lib` subdirectories that do + # not contain `.rb` files, or that should not be reloaded or eager loaded. + # Common ones are `templates`, `generators`, or `middleware`, for example. + config.autoload_lib(ignore: %w[assets tasks]) + + # Configuration for the application, engines, and railties goes here. + # + # These settings can be overridden in specific environments using the files + # in config/environments, which are processed later. + # + # config.time_zone = "Central Time (US & Canada)" + # config.eager_load_paths << Rails.root.join("extras") + + # Don't generate system test files. + config.generators.system_tests = nil + end +end diff --git a/cable-bench/config/boot.rb b/cable-bench/config/boot.rb new file mode 100644 index 0000000..988a5dd --- /dev/null +++ b/cable-bench/config/boot.rb @@ -0,0 +1,4 @@ +ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) + +require "bundler/setup" # Set up gems listed in the Gemfile. +require "bootsnap/setup" # Speed up boot time by caching expensive operations. diff --git a/cable-bench/config/bundler-audit.yml b/cable-bench/config/bundler-audit.yml new file mode 100644 index 0000000..e74b3af --- /dev/null +++ b/cable-bench/config/bundler-audit.yml @@ -0,0 +1,5 @@ +# Audit all gems listed in the Gemfile for known security problems by running bin/bundler-audit. +# CVEs that are not relevant to the application can be enumerated on the ignore list below. + +ignore: + - CVE-THAT-DOES-NOT-APPLY diff --git a/cable-bench/config/cable.yml b/cable-bench/config/cable.yml new file mode 100644 index 0000000..b7b385a --- /dev/null +++ b/cable-bench/config/cable.yml @@ -0,0 +1,29 @@ +# Three benchmark adapters selected at boot by CABLE_ADAPTER: +# solid_cable (default) — Rails 8 out-of-the-box, DB-polling, in-process Puma +# redis — classic Action Cable pub/sub, in-process Puma +# any_cable — out-of-process anycable-go gateway, Rails over gRPC +# +# polling_interval / message_retention can be tuned via env for the Solid Cable +# runs without touching code. +<% adapter = ENV.fetch("CABLE_ADAPTER", "solid_cable") %> +development: + adapter: async + +test: + adapter: test + +production: +<% if adapter == "redis" %> + adapter: redis + url: <%= ENV.fetch("REDIS_URL", "redis://localhost:6379/1") %> + channel_prefix: cable_bench +<% elsif adapter == "any_cable" %> + adapter: any_cable +<% else %> + adapter: solid_cable + connects_to: + database: + writing: cable + polling_interval: <%= ENV.fetch("SOLID_CABLE_POLLING_INTERVAL", "0.1") %>.seconds + message_retention: <%= ENV.fetch("SOLID_CABLE_RETENTION", "1.day") %> +<% end %> diff --git a/cable-bench/config/cache.yml b/cable-bench/config/cache.yml new file mode 100644 index 0000000..19d4908 --- /dev/null +++ b/cable-bench/config/cache.yml @@ -0,0 +1,16 @@ +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: + <<: *default + +test: + <<: *default + +production: + database: cache + <<: *default diff --git a/cable-bench/config/ci.rb b/cable-bench/config/ci.rb new file mode 100644 index 0000000..239b343 --- /dev/null +++ b/cable-bench/config/ci.rb @@ -0,0 +1,20 @@ +# Run using bin/ci + +CI.run do + step "Setup", "bin/setup --skip-server" + + step "Style: Ruby", "bin/rubocop" + + step "Security: Gem audit", "bin/bundler-audit" + step "Security: Importmap vulnerability audit", "bin/importmap audit" + step "Security: Brakeman code analysis", "bin/brakeman --quiet --no-pager --exit-on-warn --exit-on-error" + + + # Optional: set a green GitHub commit status to unblock PR merge. + # Requires the `gh` CLI and `gh extension install basecamp/gh-signoff`. + # if success? + # step "Signoff: All systems go. Ready for merge and deploy.", "gh signoff" + # else + # failure "Signoff: CI failed. Do not merge or deploy.", "Fix the issues and try again." + # end +end diff --git a/cable-bench/config/credentials.yml.enc b/cable-bench/config/credentials.yml.enc new file mode 100644 index 0000000..1d889f4 --- /dev/null +++ b/cable-bench/config/credentials.yml.enc @@ -0,0 +1 @@ +eagm44xPbLb4jZgrmBiNBwIRQM+rbzjDEq8VpI1j0dSmvihIBuvdsO5hGvn88a9kr8ijIThnuR3el+OX7M8RLvfImeDVz6MgKj95zBAkHGwbi5CgMzjEd4jR2XyDdCxYpqi9WD1M3PAMymdHJpheREPMhOvF0CQ/Ac/57VKqiqOKybCSZGV7G30XOubAruk+kGKTdGEWgsMlTqhH+qbpDHP+ktn9Gu/v7LEoKkxCL1ETA6E53/6mwofWwcXuTClxn0oHkrOkm6NU4q6TfDBvTSPxuPVG600t29hPeBPt9RH/fa8ytsVpdbe7RUNobSr87bi7F5QudH0MOJBRezCou2LxeZgneokS7QJTh1/QhY572+UU3YePJhxyCukDIp07gewWOGPzz24/h/XNcyKLpUf9KIEGFQ7OQ++WcIIp+JSU/2yUCc+TiT0amnubSYOYjfzfPhKVydEYCkMOuH/2+ptJC8n7ETrp2L0v5pnXv732Cxnsgp3o2K96--5zE13fNJAxth31ZH--93XIYtVm7Aub41gBda9/Xw== \ No newline at end of file diff --git a/cable-bench/config/database.yml b/cable-bench/config/database.yml new file mode 100644 index 0000000..4e71461 --- /dev/null +++ b/cable-bench/config/database.yml @@ -0,0 +1,48 @@ +# SQLite. Versions 3.8.0 and up are supported. +# gem install sqlite3 +# +# Ensure the SQLite 3 gem is defined in your Gemfile +# gem "sqlite3" +# +default: &default + adapter: sqlite3 + max_connections: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> + timeout: 5000 + +development: + <<: *default + database: storage/development.sqlite3 + +# Warning: The database defined as "test" will be erased and +# re-generated from your development database when you run "rake". +# Do not set this db to the same as development or production. +test: + <<: *default + database: storage/test.sqlite3 + +# SQLite3 write its data on the local filesystem, as such it requires +# persistent disks. If you are deploying to a managed service, you should +# make sure it provides disk persistence, as many don't. +# +# Similarly, if you deploy your application as a Docker container, you must +# ensure the database is located in a persisted volume. +# Benchmark data is disposable: the SQLite files live on the container's +# ephemeral disk and are recreated by `db:prepare` on every boot. The cable DB +# is the one that matters (Solid Cable's message stream); cache/queue are +# unused but kept so db:prepare succeeds. +production: + primary: + <<: *default + database: storage/production.sqlite3 + cache: + <<: *default + database: storage/production_cache.sqlite3 + migrations_paths: db/cache_migrate + queue: + <<: *default + database: storage/production_queue.sqlite3 + migrations_paths: db/queue_migrate + cable: + <<: *default + database: storage/production_cable.sqlite3 + migrations_paths: db/cable_migrate diff --git a/cable-bench/config/environment.rb b/cable-bench/config/environment.rb new file mode 100644 index 0000000..cac5315 --- /dev/null +++ b/cable-bench/config/environment.rb @@ -0,0 +1,5 @@ +# Load the Rails application. +require_relative "application" + +# Initialize the Rails application. +Rails.application.initialize! diff --git a/cable-bench/config/environments/development.rb b/cable-bench/config/environments/development.rb new file mode 100644 index 0000000..64dd9a0 --- /dev/null +++ b/cable-bench/config/environments/development.rb @@ -0,0 +1,71 @@ +require "active_support/core_ext/integer/time" + +Rails.application.configure do + # Settings specified here will take precedence over those in config/application.rb. + + # Make code changes take effect immediately without server restart. + config.enable_reloading = true + + # The load generator opens raw WebSockets with no Origin header (same as in + # production). Skip the Action Cable origin check so local smoke tests and + # the bench-runner can connect. + config.action_cable.disable_request_forgery_protection = true + + # Do not eager load code on boot. + config.eager_load = false + + # Show full error reports. + config.consider_all_requests_local = true + + # Enable server timing. + config.server_timing = true + + # Enable/disable Action Controller caching. By default Action Controller caching is disabled. + # Run rails dev:cache to toggle Action Controller caching. + if Rails.root.join("tmp/caching-dev.txt").exist? + config.action_controller.perform_caching = true + config.action_controller.enable_fragment_cache_logging = true + config.public_file_server.headers = { "cache-control" => "public, max-age=#{2.days.to_i}" } + else + config.action_controller.perform_caching = false + end + + # Change to :null_store to avoid any caching. + config.cache_store = :memory_store + + # Print deprecation notices to the Rails logger. + config.active_support.deprecation = :log + + # Raise an error on page load if there are pending migrations. + config.active_record.migration_error = :page_load + + # Highlight code that triggered database queries in logs. + config.active_record.verbose_query_logs = true + + # Append comments with runtime information tags to SQL queries in logs. + config.active_record.query_log_tags_enabled = true + + # Highlight code that enqueued background job in logs. + config.active_job.verbose_enqueue_logs = true + + # Highlight code that triggered redirect in logs. + config.action_dispatch.verbose_redirect_logs = true + + # Suppress logger output for asset requests. + config.assets.quiet = true + + # Raises error for missing translations. + # config.i18n.raise_on_missing_translations = true + + # Annotate rendered view with file names. + config.action_view.annotate_rendered_view_with_filenames = true + + # Uncomment if you wish to allow Action Cable access from any origin. + # config.action_cable.disable_request_forgery_protection = true + + # Raise error when a before_action's only/except options reference missing actions. + config.action_controller.raise_on_missing_callback_actions = true + + # Apply autocorrection by RuboCop to files generated by `bin/rails generate`. + # config.generators.apply_rubocop_autocorrect_after_generate! +end diff --git a/cable-bench/config/environments/production.rb b/cable-bench/config/environments/production.rb new file mode 100644 index 0000000..df867ef --- /dev/null +++ b/cable-bench/config/environments/production.rb @@ -0,0 +1,75 @@ +require "active_support/core_ext/integer/time" + +Rails.application.configure do + # Settings specified here will take precedence over those in config/application.rb. + + # Code is not reloaded between requests. + config.enable_reloading = false + + # Eager load code on boot for better performance and memory savings (ignored by Rake tasks). + config.eager_load = true + + # Full error reports are disabled. + config.consider_all_requests_local = false + + # Turn on fragment caching in view templates. + config.action_controller.perform_caching = true + + # Cache assets for far-future expiry since they are all digest stamped. + config.public_file_server.headers = { "cache-control" => "public, max-age=#{1.year.to_i}" } + + # Enable serving of images, stylesheets, and JavaScripts from an asset server. + # config.asset_host = "http://assets.example.com" + + # This app only ever receives traffic from the bench-runner fleet over + # Railway's private network (plain HTTP/WS, no TLS-terminating proxy in + # front), so SSL enforcement would just break internal connections. + config.assume_ssl = false + config.force_ssl = false + + # The load generator opens raw WebSockets without a matching Origin header; + # there is no browser session to protect, so skip the Action Cable origin check. + config.action_cable.disable_request_forgery_protection = true + + # Skip http-to-https redirect for the default health check endpoint. + # config.ssl_options = { redirect: { exclude: ->(request) { request.path == "/up" } } } + + # Log to STDOUT with the current request id as a default log tag. + config.log_tags = [ :request_id ] + config.logger = ActiveSupport::TaggedLogging.logger(STDOUT) + + # Change to "debug" to log everything (including potentially personally-identifiable information!). + config.log_level = ENV.fetch("RAILS_LOG_LEVEL", "info") + + # Prevent health checks from clogging up the logs. + config.silence_healthcheck_path = "/up" + + # Don't log any deprecations. + config.active_support.report_deprecations = false + + # Replace the default in-process memory cache store with a durable alternative. + config.cache_store = :solid_cache_store + + # Replace the default in-process and non-durable queuing backend for Active Job. + config.active_job.queue_adapter = :solid_queue + config.solid_queue.connects_to = { database: { writing: :queue } } + + # Enable locale fallbacks for I18n (makes lookups for any locale fall back to + # the I18n.default_locale when a translation cannot be found). + config.i18n.fallbacks = true + + # Do not dump schema after migrations. + config.active_record.dump_schema_after_migration = false + + # Only use :id for inspections in production. + config.active_record.attributes_for_inspect = [ :id ] + + # Enable DNS rebinding protection and other `Host` header attacks. + # config.hosts = [ + # "example.com", # Allow requests from example.com + # /.*\.example\.com/ # Allow requests from subdomains like `www.example.com` + # ] + # + # Skip DNS rebinding protection for the default health check endpoint. + # config.host_authorization = { exclude: ->(request) { request.path == "/up" } } +end diff --git a/cable-bench/config/environments/test.rb b/cable-bench/config/environments/test.rb new file mode 100644 index 0000000..14bc29e --- /dev/null +++ b/cable-bench/config/environments/test.rb @@ -0,0 +1,42 @@ +# The test environment is used exclusively to run your application's +# test suite. You never need to work with it otherwise. Remember that +# your test database is "scratch space" for the test suite and is wiped +# and recreated between test runs. Don't rely on the data there! + +Rails.application.configure do + # Settings specified here will take precedence over those in config/application.rb. + + # While tests run files are not watched, reloading is not necessary. + config.enable_reloading = false + + # Eager loading loads your entire application. When running a single test locally, + # this is usually not necessary, and can slow down your test suite. However, it's + # recommended that you enable it in continuous integration systems to ensure eager + # loading is working properly before deploying your code. + config.eager_load = ENV["CI"].present? + + # Configure public file server for tests with cache-control for performance. + config.public_file_server.headers = { "cache-control" => "public, max-age=3600" } + + # Show full error reports. + config.consider_all_requests_local = true + config.cache_store = :null_store + + # Render exception templates for rescuable exceptions and raise for other exceptions. + config.action_dispatch.show_exceptions = :rescuable + + # Disable request forgery protection in test environment. + config.action_controller.allow_forgery_protection = false + + # Print deprecation notices to the stderr. + config.active_support.deprecation = :stderr + + # Raises error for missing translations. + # config.i18n.raise_on_missing_translations = true + + # Annotate rendered view with file names. + # config.action_view.annotate_rendered_view_with_filenames = true + + # Raise error when a before_action's only/except options reference missing actions. + config.action_controller.raise_on_missing_callback_actions = true +end diff --git a/cable-bench/config/importmap.rb b/cable-bench/config/importmap.rb new file mode 100644 index 0000000..909dfc5 --- /dev/null +++ b/cable-bench/config/importmap.rb @@ -0,0 +1,7 @@ +# Pin npm packages by running ./bin/importmap + +pin "application" +pin "@hotwired/turbo-rails", to: "turbo.min.js" +pin "@hotwired/stimulus", to: "stimulus.min.js" +pin "@hotwired/stimulus-loading", to: "stimulus-loading.js" +pin_all_from "app/javascript/controllers", under: "controllers" diff --git a/cable-bench/config/initializers/assets.rb b/cable-bench/config/initializers/assets.rb new file mode 100644 index 0000000..4873244 --- /dev/null +++ b/cable-bench/config/initializers/assets.rb @@ -0,0 +1,7 @@ +# Be sure to restart your server when you modify this file. + +# Version of your assets, change this if you want to expire all your assets. +Rails.application.config.assets.version = "1.0" + +# Add additional assets to the asset load path. +# Rails.application.config.assets.paths << Emoji.images_path diff --git a/cable-bench/config/initializers/content_security_policy.rb b/cable-bench/config/initializers/content_security_policy.rb new file mode 100644 index 0000000..d51d713 --- /dev/null +++ b/cable-bench/config/initializers/content_security_policy.rb @@ -0,0 +1,29 @@ +# Be sure to restart your server when you modify this file. + +# Define an application-wide content security policy. +# See the Securing Rails Applications Guide for more information: +# https://guides.rubyonrails.org/security.html#content-security-policy-header + +# Rails.application.configure do +# config.content_security_policy do |policy| +# policy.default_src :self, :https +# policy.font_src :self, :https, :data +# policy.img_src :self, :https, :data +# policy.object_src :none +# policy.script_src :self, :https +# policy.style_src :self, :https +# # Specify URI for violation reports +# # policy.report_uri "/csp-violation-report-endpoint" +# end +# +# # Generate session nonces for permitted importmap, inline scripts, and inline styles. +# config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s } +# config.content_security_policy_nonce_directives = %w(script-src style-src) +# +# # Automatically add `nonce` to `javascript_tag`, `javascript_include_tag`, and `stylesheet_link_tag` +# # if the corresponding directives are specified in `content_security_policy_nonce_directives`. +# # config.content_security_policy_nonce_auto = true +# +# # Report violations without enforcing the policy. +# # config.content_security_policy_report_only = true +# end diff --git a/cable-bench/config/initializers/filter_parameter_logging.rb b/cable-bench/config/initializers/filter_parameter_logging.rb new file mode 100644 index 0000000..c0b717f --- /dev/null +++ b/cable-bench/config/initializers/filter_parameter_logging.rb @@ -0,0 +1,8 @@ +# Be sure to restart your server when you modify this file. + +# Configure parameters to be partially matched (e.g. passw matches password) and filtered from the log file. +# Use this to limit dissemination of sensitive information. +# See the ActiveSupport::ParameterFilter documentation for supported notations and behaviors. +Rails.application.config.filter_parameters += [ + :passw, :email, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn, :cvv, :cvc +] diff --git a/cable-bench/config/initializers/inflections.rb b/cable-bench/config/initializers/inflections.rb new file mode 100644 index 0000000..3860f65 --- /dev/null +++ b/cable-bench/config/initializers/inflections.rb @@ -0,0 +1,16 @@ +# Be sure to restart your server when you modify this file. + +# Add new inflection rules using the following format. Inflections +# are locale specific, and you may define rules for as many different +# locales as you wish. All of these examples are active by default: +# ActiveSupport::Inflector.inflections(:en) do |inflect| +# inflect.plural /^(ox)$/i, "\\1en" +# inflect.singular /^(ox)en/i, "\\1" +# inflect.irregular "person", "people" +# inflect.uncountable %w( fish sheep ) +# end + +# These inflection rules are supported but not enabled by default: +# ActiveSupport::Inflector.inflections(:en) do |inflect| +# inflect.acronym "RESTful" +# end diff --git a/cable-bench/config/locales/en.yml b/cable-bench/config/locales/en.yml new file mode 100644 index 0000000..6c349ae --- /dev/null +++ b/cable-bench/config/locales/en.yml @@ -0,0 +1,31 @@ +# Files in the config/locales directory are used for internationalization and +# are automatically loaded by Rails. If you want to use locales other than +# English, add the necessary files in this directory. +# +# To use the locales, use `I18n.t`: +# +# I18n.t "hello" +# +# In views, this is aliased to just `t`: +# +# <%= t("hello") %> +# +# To use a different locale, set it with `I18n.locale`: +# +# I18n.locale = :es +# +# This would use the information in config/locales/es.yml. +# +# To learn more about the API, please read the Rails Internationalization guide +# at https://guides.rubyonrails.org/i18n.html. +# +# Be aware that YAML interprets the following case-insensitive strings as +# booleans: `true`, `false`, `on`, `off`, `yes`, `no`. Therefore, these strings +# must be quoted to be interpreted as strings. For example: +# +# en: +# "yes": yup +# enabled: "ON" + +en: + hello: "Hello world" diff --git a/cable-bench/config/puma.rb b/cable-bench/config/puma.rb new file mode 100644 index 0000000..92ca455 --- /dev/null +++ b/cable-bench/config/puma.rb @@ -0,0 +1,45 @@ +# This configuration file will be evaluated by Puma. The top-level methods that +# are invoked here are part of Puma's configuration DSL. For more information +# about methods provided by the DSL, see https://puma.io/puma/Puma/DSL.html. +# +# Puma starts a configurable number of processes (workers) and each process +# serves each request in a thread from an internal thread pool. +# +# You can control the number of workers using ENV["WEB_CONCURRENCY"]. You +# should only set this value when you want to run 2 or more workers. The +# default is already 1. You can set it to `auto` to automatically start a worker +# for each available processor. +# +# The ideal number of threads per worker depends both on how much time the +# application spends waiting for IO operations and on how much you wish to +# prioritize throughput over latency. +# +# As a rule of thumb, increasing the number of threads will increase how much +# traffic a given process can handle (throughput), but due to CRuby's +# Global VM Lock (GVL) it has diminishing returns and will degrade the +# response time (latency) of the application. +# +# The default is set to 3 threads as it's deemed a decent compromise between +# throughput and latency for the average Rails application. +# +# Any libraries that use a connection pool or another resource pool should +# be configured to provide at least as many connections as the number of +# threads. This includes Active Record's `pool` parameter in `database.yml`. +threads_count = ENV.fetch("RAILS_MAX_THREADS", 3) +threads threads_count, threads_count + +# Bind IPv6 dual-stack (`[::]`) rather than the default IPv4 `0.0.0.0`: Railway's +# private network routes over IPv6, so internal clients (the bench-runner fleet) +# can only reach Puma on `[::]`. On Linux this also accepts IPv4, and it works +# locally on macOS too. A single bind avoids an address-in-use clash with `port`. +bind "tcp://[::]:#{ENV.fetch('PORT', 3000)}" + +# Allow puma to be restarted by `bin/rails restart` command. +plugin :tmp_restart + +# Run the Solid Queue supervisor inside of Puma for single-server deployments. +plugin :solid_queue if ENV["SOLID_QUEUE_IN_PUMA"] + +# Specify the PID file. Defaults to tmp/pids/server.pid in development. +# In other environments, only set the PID file if requested. +pidfile ENV["PIDFILE"] if ENV["PIDFILE"] diff --git a/cable-bench/config/queue.yml b/cable-bench/config/queue.yml new file mode 100644 index 0000000..9eace59 --- /dev/null +++ b/cable-bench/config/queue.yml @@ -0,0 +1,18 @@ +default: &default + dispatchers: + - polling_interval: 1 + batch_size: 500 + workers: + - queues: "*" + threads: 3 + processes: <%= ENV.fetch("JOB_CONCURRENCY", 1) %> + polling_interval: 0.1 + +development: + <<: *default + +test: + <<: *default + +production: + <<: *default diff --git a/cable-bench/config/recurring.yml b/cable-bench/config/recurring.yml new file mode 100644 index 0000000..b4207f9 --- /dev/null +++ b/cable-bench/config/recurring.yml @@ -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 diff --git a/cable-bench/config/routes.rb b/cable-bench/config/routes.rb new file mode 100644 index 0000000..b384a11 --- /dev/null +++ b/cable-bench/config/routes.rb @@ -0,0 +1,23 @@ +Rails.application.routes.draw do + # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html + + # Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500. + # Can be used by load balancers and uptime monitors to verify that the app is live. + get "up" => "rails/health#show", as: :rails_health_check + + # Benchmark harness surface. + get "health" => "bench#health" + post "_bench/broadcast" => "bench#broadcast" + + # Serve Action Cable in-process for the solid_cable / redis modes. In + # anycable mode the Rails process runs the gRPC server (not Puma), so this + # mount is never hit and stays harmless. + mount ActionCable.server => "/cable" + + # Render dynamic PWA files from app/views/pwa/* (remember to link manifest in application.html.erb) + # get "manifest" => "rails/pwa#manifest", as: :pwa_manifest + # get "service-worker" => "rails/pwa#service_worker", as: :pwa_service_worker + + # Defines the root path route ("/") + # root "posts#index" +end diff --git a/cable-bench/db/cable_schema.rb b/cable-bench/db/cable_schema.rb new file mode 100644 index 0000000..2366660 --- /dev/null +++ b/cable-bench/db/cable_schema.rb @@ -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 diff --git a/cable-bench/db/cache_schema.rb b/cable-bench/db/cache_schema.rb new file mode 100644 index 0000000..81a410d --- /dev/null +++ b/cable-bench/db/cache_schema.rb @@ -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 diff --git a/cable-bench/db/queue_schema.rb b/cable-bench/db/queue_schema.rb new file mode 100644 index 0000000..85194b6 --- /dev/null +++ b/cable-bench/db/queue_schema.rb @@ -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 diff --git a/cable-bench/db/seeds.rb b/cable-bench/db/seeds.rb new file mode 100644 index 0000000..4fbd6ed --- /dev/null +++ b/cable-bench/db/seeds.rb @@ -0,0 +1,9 @@ +# This file should ensure the existence of records required to run the application in every environment (production, +# development, test). The code here should be idempotent so that it can be executed at any point in every environment. +# The data can then be loaded with the bin/rails db:seed command (or created alongside the database with db:setup). +# +# Example: +# +# ["Action", "Comedy", "Drama", "Horror"].each do |genre_name| +# MovieGenre.find_or_create_by!(name: genre_name) +# end diff --git a/cable-bench/docker-compose.yml b/cable-bench/docker-compose.yml new file mode 100644 index 0000000..4e47859 --- /dev/null +++ b/cable-bench/docker-compose.yml @@ -0,0 +1,56 @@ +# Local validation of the three cable-bench modes against the production image. +# Mirrors the Railway topology so we can shake out config before deploying. +# +# docker compose up --build +# +# Solid Cable: ws://localhost:3001/cable broadcast http://localhost:3001/_bench/broadcast +# Action Cable: ws://localhost:3002/cable broadcast http://localhost:3002/_bench/broadcast +# AnyCable: ws://localhost:8080/cable broadcast http://localhost:8080/_broadcast (via gateway) +# (rails-anycable runs the gRPC backend; anycable-go is the WS gateway) +services: + redis: + image: redis:7-alpine + ports: ["6379:6379"] + + rails-solidcable: + build: . + image: cable-bench:local + environment: + BENCH_MODE: solidcable + PORT: "3000" + ports: ["3001:3000"] + + rails-actioncable: + image: cable-bench:local + environment: + BENCH_MODE: actioncable + PORT: "3000" + REDIS_URL: redis://redis:6379/1 + ports: ["3002:3000"] + depends_on: [redis] + + rails-anycable: + image: cable-bench:local + environment: + BENCH_MODE: anycable + # IPv4 bind for the local docker bridge (Railway uses [::] over its + # IPv6 private network; the entrypoint defaults to that). + ANYCABLE_RPC_HOST: 0.0.0.0:50051 + expose: ["50051"] + + anycable-go: + image: anycable/anycable-go:latest + environment: + ANYCABLE_HOST: "0.0.0.0" + ANYCABLE_PORT: "8080" + ANYCABLE_RPC_HOST: rails-anycable:50051 + # Broker preset + memory broker enables reliable streams (the extended + # protocol resume that backfills messages missed during a disconnect). + ANYCABLE_PRESETS: broker + ANYCABLE_BROKER: memory + ANYCABLE_BROADCAST_ADAPTER: http + # Serve the HTTP broadcast handler on the main port so it's reachable at + # :8080/_broadcast (matches the Railway target URL). Default is 8090. + ANYCABLE_HTTP_BROADCAST_PORT: "8080" + ports: ["8080:8080"] + depends_on: [rails-anycable] diff --git a/cable-bench/lib/tasks/.keep b/cable-bench/lib/tasks/.keep new file mode 100644 index 0000000..e69de29 diff --git a/cable-bench/log/.keep b/cable-bench/log/.keep new file mode 100644 index 0000000..e69de29 diff --git a/cable-bench/public/400.html b/cable-bench/public/400.html new file mode 100644 index 0000000..640de03 --- /dev/null +++ b/cable-bench/public/400.html @@ -0,0 +1,135 @@ + + + + + + + The server cannot process the request due to a client error (400 Bad Request) + + + + + + + + + + + + + +
+
+ +
+
+

The server cannot process the request due to a client error. Please check the request and try again. If you're the application owner check the logs for more information.

+
+
+ + + + diff --git a/cable-bench/public/404.html b/cable-bench/public/404.html new file mode 100644 index 0000000..d7f0f14 --- /dev/null +++ b/cable-bench/public/404.html @@ -0,0 +1,135 @@ + + + + + + + The page you were looking for doesn't exist (404 Not found) + + + + + + + + + + + + + +
+
+ +
+
+

The page you were looking for doesn't exist. You may have mistyped the address or the page may have moved. If you're the application owner check the logs for more information.

+
+
+ + + + diff --git a/cable-bench/public/406-unsupported-browser.html b/cable-bench/public/406-unsupported-browser.html new file mode 100644 index 0000000..43d2811 --- /dev/null +++ b/cable-bench/public/406-unsupported-browser.html @@ -0,0 +1,135 @@ + + + + + + + Your browser is not supported (406 Not Acceptable) + + + + + + + + + + + + + +
+
+ +
+
+

Your browser is not supported.
Please upgrade your browser to continue.

+
+
+ + + + diff --git a/cable-bench/public/422.html b/cable-bench/public/422.html new file mode 100644 index 0000000..f12fb4a --- /dev/null +++ b/cable-bench/public/422.html @@ -0,0 +1,135 @@ + + + + + + + The change you wanted was rejected (422 Unprocessable Entity) + + + + + + + + + + + + + +
+
+ +
+
+

The change you wanted was rejected. Maybe you tried to change something you didn't have access to. If you're the application owner check the logs for more information.

+
+
+ + + + diff --git a/cable-bench/public/500.html b/cable-bench/public/500.html new file mode 100644 index 0000000..e4eb18a --- /dev/null +++ b/cable-bench/public/500.html @@ -0,0 +1,135 @@ + + + + + + + We're sorry, but something went wrong (500 Internal Server Error) + + + + + + + + + + + + + +
+
+ +
+
+

We're sorry, but something went wrong.
If you're the application owner check the logs for more information.

+
+
+ + + + diff --git a/cable-bench/public/icon.png b/cable-bench/public/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..c4c9dbfbbd2f7c1421ffd5727188146213abbcef GIT binary patch literal 4166 zcmd6qU;WFw?|v@m)Sk^&NvB8tcujdV-r1b=i(NJxn&7{KTb zX$3(M+3TP2o^#KAo{#tIjl&t~(8D-k004kqPglzn0HFG(Q~(I*AKsD#M*g7!XK0T7 zN6P7j>HcT8rZgKl$v!xr806dyN19Bd4C0x_R*I-a?#zsTvb_89cyhuC&T**i|Rc zq5b8M;+{8KvoJ~uj9`u~d_f6`V&3+&ZX9x5pc8s)d175;@pjm(?dapmBcm0&vl9+W zx1ZD2o^nuyUHWj|^A8r>lUorO`wFF;>9XL-Jy!P}UXC{(z!FO%SH~8k`#|9;Q|eue zqWL0^Bp(fg_+Pkm!fDKRSY;+^@BF?AJE zCUWpXPst~hi_~u)SzYBDZroR+Z4xeHIlm_3Yc_9nZ(o_gg!jDgVa=E}Y8uDgem9`b zf=mfJ_@(BXSkW53B)F2s!&?_R4ptb1fYXlF++@vPhd=marQgEGRZS@B4g1Mu?euknL= z67P~tZ?*>-Hmi7GwlisNHHJDku-dSm7g@!=a}9cSL6Pa^w^2?&?$Oi8ibrr>w)xqx zOH_EMU@m05)9kuNR>>4@H%|){U$^yvVQ(YgOlh;5oU_-vivG-p4=LrN-k7D?*?u1u zsWly%tfAzKd6Fb=`eU2un_uaTXmcT#tlOL+aRS=kZZf}A7qT8lvcTx~7j` z*b>=z)mwg7%B2_!D0!1IZ?Nq{^Y$uI4Qx*6T!E2Col&2{k?ImCO=dD~A&9f9diXy^$x{6CwkBimn|1E09 zAMSezYtiL?O6hS37KpvDM?22&d{l)7h-!F)C-d3j8Z`c@($?mfd{R82)H>Qe`h{~G z!I}(2j(|49{LR?w4Jspl_i!(4T{31|dqCOpI52r5NhxYV+cDAu(xp*4iqZ2e-$YP= zoFOPmm|u*7C?S{Fp43y+V;>~@FFR76bCl@pTtyB93vNWy5yf;HKr8^0d7&GVIslYm zo3Tgt@M!`8B6IW&lK{Xk>%zp41G%`(DR&^u z5^pwD4>E6-w<8Kl2DzJ%a@~QDE$(e87lNhy?-Qgep!$b?5f7+&EM7$e>|WrX+=zCb z=!f5P>MxFyy;mIRxjc(H*}mceXw5a*IpC0PEYJ8Y3{JdoIW)@t97{wcUB@u+$FCCO z;s2Qe(d~oJC^`m$7DE-dsha`glrtu&v&93IZadvl_yjp!c89>zo;Krk+d&DEG4?x$ zufC1n+c1XD7dolX1q|7}uelR$`pT0Z)1jun<39$Sn2V5g&|(j~Z!wOddfYiZo7)A< z!dK`aBHOOk+-E_xbWCA3VR-+o$i5eO9`rMI#p_0xQ}rjEpGW;U!&&PKnivOcG(|m9 z!C8?WC6nCXw25WVa*eew)zQ=h45k8jSIPbq&?VE{oG%?4>9rwEeB4&qe#?-y_es4c|7ufw%+H5EY#oCgv!Lzv291#-oNlX~X+Jl5(riC~r z=0M|wMOP)Tt8@hNg&%V@Z9@J|Q#K*hE>sr6@oguas9&6^-=~$*2Gs%h#GF@h)i=Im z^iKk~ipWJg1VrvKS;_2lgs3n1zvNvxb27nGM=NXE!D4C!U`f*K2B@^^&ij9y}DTLB*FI zEnBL6y{jc?JqXWbkIZd7I16hA>(f9T!iwbIxJj~bKPfrO;>%*5nk&Lf?G@c2wvGrY&41$W{7HM9+b@&XY@>NZM5s|EK_Dp zQX60CBuantx>|d#DsaZ*8MW(we|#KTYZ=vNa#d*DJQe6hr~J6{_rI#?wi@s|&O}FR zG$kfPxheXh1?IZ{bDT-CWB4FTvO-k5scW^mi8?iY5Q`f8JcnnCx
iy@m@D-%lO;y0pTLhh6i6l@x52j=#^$5_U^os}OFg zzdHbo(QI`%9#o*r8GCW~T3UdV`szO#~)^&X_(VW>o~umY9-ns9-V4lf~j z`QBD~pJ4a#b`*6bJ^3RS5y?RAgF7K5$ll97Y8#WZduZ`j?IEY~H(s^doZg>7-tk*t z4_QE1%%bb^p~4F5SB$t2i1>DBG1cIo;2(xTaj*Y~hlM{tSDHojL-QPg%Mo%6^7FrpB*{ z4G0@T{-77Por4DCMF zB_5Y~Phv%EQ64W8^GS6h?x6xh;w2{z3$rhC;m+;uD&pR74j+i22P5DS-tE8ABvH(U~indEbBUTAAAXfHZg5QpB@TgV9eI<)JrAkOI z8!TSOgfAJiWAXeM&vR4Glh;VxH}WG&V$bVb`a`g}GSpwggti*&)taV1@Ak|{WrV|5 zmNYx)Ans=S{c52qv@+jmGQ&vd6>6yX6IKq9O$3r&0xUTdZ!m1!irzn`SY+F23Rl6# zFRxws&gV-kM1NX(3(gnKpGi0Q)Dxi~#?nyzOR9!en;Ij>YJZVFAL*=R%7y%Mz9hU% zs>+ZB?qRmZ)nISx7wxY)y#cd$iaC~{k0avD>BjyF1q^mNQ1QcwsxiTySe<6C&cC6P zE`vwO9^k-d`9hZ!+r@Jnr+MF*2;2l8WjZ}DrwDUHzSF{WoG zucbSWguA!3KgB3MU%HH`R;XqVv0CcaGq?+;v_A5A2kpmk5V%qZE3yzQ7R5XWhq=eR zyUezH=@V)y>L9T-M-?tW(PQYTRBKZSVb_!$^H-Pn%ea;!vS_?M<~Tm>_rWIW43sPW z=!lY&fWc1g7+r?R)0p8(%zp&vl+FK4HRkns%BW+Up&wK8!lQ2~bja|9bD12WrKn#M zK)Yl9*8$SI7MAwSK$%)dMd>o+1UD<2&aQMhyjS5R{-vV+M;Q4bzl~Z~=4HFj_#2V9 zB)Gfzx3ncy@uzx?yzi}6>d%-?WE}h7v*w)Jr_gBl!2P&F3DX>j_1#--yjpL%<;JMR z*b70Gr)MMIBWDo~#<5F^Q0$VKI;SBIRneuR7)yVsN~A9I@gZTXe)E?iVII+X5h0~H zx^c(fP&4>!*q>fb6dAOC?MI>Cz3kld#J*;uik+Ps49cwm1B4 zZc1|ZxYyTv;{Z!?qS=D)sgRKx^1AYf%;y_V&VgZglfU>d+Ufk5&LV$sKv}Hoj+s; xK3FZRYdhbXT_@RW*ff3@`D1#ps#~H)p+y&j#(J|vk^lW{fF9OJt5(B-_&*Xgn9~3N literal 0 HcmV?d00001 diff --git a/cable-bench/public/icon.svg b/cable-bench/public/icon.svg new file mode 100644 index 0000000..04b34bf --- /dev/null +++ b/cable-bench/public/icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/cable-bench/public/robots.txt b/cable-bench/public/robots.txt new file mode 100644 index 0000000..c19f78a --- /dev/null +++ b/cable-bench/public/robots.txt @@ -0,0 +1 @@ +# See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file diff --git a/cable-bench/railway.toml b/cable-bench/railway.toml new file mode 100644 index 0000000..134d13f --- /dev/null +++ b/cable-bench/railway.toml @@ -0,0 +1,11 @@ +# Per-service Railway config for the Rails cable-bench targets +# (rails-solidcable / rails-actioncable / rails-anycable). +# +# The repo-root railway.toml points at backend/Dockerfile, which is wrong for +# this service. Uploading this file as the build root via +# `railway up --service --path-as-root cable-bench/` overrides the repo +# default and uses this local Dockerfile. The three services share the image +# and differ only by the BENCH_MODE env var. + +[build] +dockerfilePath = "Dockerfile" diff --git a/cable-bench/script/.keep b/cable-bench/script/.keep new file mode 100644 index 0000000..e69de29 diff --git a/cable-bench/storage/.keep b/cable-bench/storage/.keep new file mode 100644 index 0000000..e69de29 diff --git a/cable-bench/tmp/.keep b/cable-bench/tmp/.keep new file mode 100644 index 0000000..e69de29 diff --git a/cable-bench/tmp/pids/.keep b/cable-bench/tmp/pids/.keep new file mode 100644 index 0000000..e69de29 diff --git a/cable-bench/tmp/storage/.keep b/cable-bench/tmp/storage/.keep new file mode 100644 index 0000000..e69de29 diff --git a/cable-bench/vendor/.keep b/cable-bench/vendor/.keep new file mode 100644 index 0000000..e69de29 diff --git a/cable-bench/vendor/javascript/.keep b/cable-bench/vendor/javascript/.keep new file mode 100644 index 0000000..e69de29 diff --git a/docs/rails-comparison.md b/docs/rails-comparison.md new file mode 100644 index 0000000..19ab1ae --- /dev/null +++ b/docs/rails-comparison.md @@ -0,0 +1,203 @@ +# Rails in the comparison: Action Cable vs Solid Cable vs AsyncCable vs AnyCable + +The repo behind [anycable.io/compare/rails-actioncable](https://anycable.io/compare/rails-actioncable). +Four WebSocket adapters for the same Rails app, same Railway box, same +shared-tenant window: + +- **Action Cable** on the Redis adapter (the Rails default). WebSockets + terminate in-process inside Puma's Ruby threads; Redis carries pub/sub. +- **Solid Cable** (Rails 8 default-stack adapter). Same in-process + termination, no Redis; the adapter polls a database table (default + every 100 ms) for new messages. +- **AsyncCable** ([socketry/async-cable](https://github.com/socketry/async-cable)): + serves Action Cable in-process on [Falcon](https://github.com/socketry/falcon), + a fiber-based reactor, instead of Puma's threads. Same wire protocol; a + different concurrency engine. Built on `actioncable-next`, so it runs on + stable Rails 8.1. +- **AnyCable** in full RPC mode: Rails runs a gRPC backend + (`bundle exec anycable`) for auth and commands, and `anycable-go` + (a separate Go process) holds the WebSockets and fans out broadcasts. + +The whole point of the comparison: all four are **Action Cable-compatible +at the app and client level**. The channel code, the `stream_from` calls, +and the Turbo Stream broadcasts are identical in all four. Only one line +of `config/cable.yml` and the process topology change. So any behavioral +difference is the adapter, not the app. + +## The one thing that differs on the wire + +Action Cable, Solid Cable and AsyncCable speak the base +`actioncable-v1-json` protocol, which is **at-most-once**: a broadcast that +goes out while a client is briefly offline is gone, because there is no +history to replay on reconnect. AnyCable speaks the extended +`actioncable-v1-ext-json` protocol, which keeps **per-stream history with an +epoch and offset**, so a reconnecting client resumes from the last message +it saw. The AnyCable JS client negotiates the extended protocol +automatically; a plain Action Cable client still connects over the base +protocol. That single difference drives the reliability gap below. + +Note AsyncCable: switching Puma threads for Falcon fibers changes the +runtime, not the guarantees. It is still in-process (drops on deploy) and +still speaks the base protocol (loses messages under jitter, same as the +others). Falcon does not close the reliability or deploy gap, because those +gaps come from the protocol and the topology. + +## Bench harness + +No new bench-runner endpoints. The AnyCable JS driver (`@anycable/core`) +already speaks both protocols, so the existing `bench-jitter-anycable`, +`bench-idle-anycable`, and `bench-avalanche-anycable` endpoints serve all +four Rails targets, parameterized by: + +- `cableUrl`: the `ws://…/cable` endpoint per target. +- `broadcastUrl`: the Rails `POST /_bench/broadcast` trigger + (`ActionCable.server.broadcast`), or anycable-go's `/_broadcast`. +- `channel`: `BenchmarkChannel` (real Rails channel), vs `$pubsub` for + the bare-stream nodejs targets. +- `acProtocol`: `actioncable-v1-json` for Action Cable / Solid Cable / + AsyncCable, `actioncable-v1-ext-json` for AnyCable. + +The Puma/Redis and Solid Cable targets live in `cable-bench/` (named +`cable-bench` because `rails` is a reserved Railway app name): one Docker +image, two modes via `BENCH_MODE`. AnyCable runs the same app as its gRPC +RPC backend with `anycable-go-rails` as the gateway. AsyncCable runs from +`cable-bench-falcon/`, a copy of the app booted on Falcon +(`bundle exec falcon serve`) with `actioncable-next` + `async-cable`. +Manifest entries are in `backend/src/bench/tests-manifest.ts` under each +rubric. + +### Sharding is mandatory for latency and jitter + +All numbers below are **sharded**. A single bench-runner holding thousands +of `@anycable/core` cables saturates its own Node event loop and distorts +the measurement in both directions: receive latency inflates and delivery +deflates, worst for the extended-protocol AnyCable client because it does +more per-message work. So latency, jitter, and capacity runs are sharded +across 13 drivers (~250 to 770 cables each), keeping each driver well under +the saturation point; the percentiles are merged across the union of all +samples via `mergeJitterResults`. See the bench repo README, "Running the +latency (and jitter) test." + +## Results + +All numbers from sharded runs in one shared-tenant Railway window +(2026-06-28), captured in +`backend/results/rails-sharded-2026-06-28.json`. The in-process targets ran +Puma with 8 workers × 5 threads; AsyncCable ran Falcon with 8 processes. + +### Roundtrip latency (steady network, 100% delivery) + +AnyCable is fastest at both sizes: fan-out happens in the Go gateway rather +than in Ruby, so it stays at single-digit milliseconds where the in-process +adapters climb with load. Action Cable on Puma is close behind; AsyncCable +on Falcon sits a touch higher; Solid Cable carries a fixed floor from its +100 ms database poll. + +| Adapter | 1K p50 / p99 | 5K p50 / p99 | +| --- | --- | --- | +| Solid Cable | 62 / 119 ms | 74 / 164 ms | +| AsyncCable (Falcon) | 11 / 80 ms | 20 / 71 ms | +| Action Cable (Puma) | 9 / 47 ms | 13 / 57 ms | +| AnyCable | **4 / 23 ms** | **7 / 31 ms** | + +### Delivery under jitter (5K subscribers, ~2 s drops every ~15 s) + +The strongest AnyCable win. The base protocol has no resume, so whatever +lands during an offline window is lost. All three in-process adapters behave +identically here, because the loss is in the protocol, not the runtime. + +| Adapter | Delivery | p50 | p99 (replay tail) | +| --- | --- | --- | --- | +| Solid Cable | **78.1%** | 71 ms | no resume | +| Action Cable | **78.1%** | 12 ms | no resume | +| AsyncCable (Falcon) | **78.1%** | 18 ms | no resume | +| AnyCable | **99.9%** | 7 ms | ~6.0 s | + +AnyCable's longer p99 is the resumed history landing a beat late on +reconnect. It lands; the in-process adapters drop about a fifth of +broadcasts outright. + +### Capacity: 10K under load, and idle-to-break + +In-process Action Cable has a reputation for collapsing under load. With a +properly sharded load generator, it does not at everyday sizes. + +**Under broadcast load, all four hold 10K at 100%.** AnyCable keeps the +tightest tail; Solid Cable's poll shows in its p99. + +| Adapter | 10K subs (delivery / p99) | +| --- | --- | +| Solid Cable | 100% / 200 ms | +| Action Cable | 100% / 84 ms | +| AsyncCable (Falcon) | 100% / 112 ms | +| AnyCable | **100% / 31 ms** | + +**Idle-to-break, on identical 32 GB boxes, default 8-worker config.** Ramped +idle connections until the holding box failed (raw data in +`backend/results/rails-capacity-break-2026-06-28.json`): + +| Adapter | Max held (0 fail) | Peak RAM | What capped it | +| --- | --- | --- | --- | +| Action Cable (Puma) | ~52K | 2.3 GB | 8-worker file-descriptor ceiling | +| Solid Cable (Puma) | ~52K | 2.5 GB | 8-worker file-descriptor ceiling | +| AsyncCable (Falcon) | ~97K | 27 GB | memory, ~290 KB/conn | +| AnyCable (Go gateway) | **600K+** | 27 GB | not broken; load fleet maxed out | + +Three findings. The Puma adapters wall at ~52K using only ~2.5 GB: an +8-worker file-descriptor ceiling, not memory (raising worker fd limits +lifts it). AsyncCable on Falcon is memory-bound at ~97K, because each +fiber-backed connection costs roughly 290 KB, about six times the others. +AnyCable held 600K idle connections with zero failures across a 50-runner +fleet before we ran out of load generators; its gateway memory scaled +linearly to 27 GB (~47 KB/conn, the same per-connection cost as Puma), +about 84% of the box, so its real ceiling is near ~700K and memory-bound. +The per-connection RAM is similar to Puma's; what differs is that one Go +process has no per-worker fd wall, so it uses the whole box instead of +stalling at 52K with 90% of the box idle. + +Finding a gateway's true ceiling takes roughly one load driver per 10K +connections: a single Node driver tops out around 10K cables, so reaching +600K needed the full 50-runner fleet. With fewer drivers the fleet caps the +result before the server does, so capacity-to-break runs scale the driver +count to the target. + +### Deploy survival (avalanche, 5K clients, real app redeploy) + +In-process WebSocket servers drop every connection when the app restarts, +Puma and Falcon alike. On our test the in-process adapters went fully dark +on the redeploy and stayed down for roughly 7.5 to 8 seconds before about +96% of clients reconnected. For AnyCable the redeploy hit the Rails gRPC +backend, not the gateway holding the sockets, so its connections were never +dropped: zero seconds of downtime. + +The "Down for" column below is how long connections stayed dropped after the +redeploy before the reconnect storm settled. + +| Adapter | Dropped | Down for | Reconnected | +| --- | --- | --- | --- | +| Action Cable | all 5,000 | 7.5 s | 96.3% (187 still out at cutoff) | +| Solid Cable | all 5,000 | 7.6 s | 95.7% (215 still out at cutoff) | +| AsyncCable (Falcon) | all 5,000 | 8.0 s | 96.4% (179 still out at cutoff) | +| AnyCable | **0** | **0 s** | n/a | + +The AnyCable run redeployed its Rails RPC backend the same way; the gateway +reported no disconnect across the full 180 s observation window, so the +downtime is zero by construction. + +## The takeaway + +On Rails, AnyCable leads on latency at every scale (7 ms p50 at 5K vs 13 ms +for Action Cable, 20 ms for AsyncCable, 74 ms for Solid Cable), because +fan-out runs in Go off the Ruby process. It wins everything that decides +whether realtime holds up in production: 100% delivery under jitter where +the base protocol drops about a fifth of broadcasts, and every connection +kept alive across an app deploy where the in-process adapters drop all of +them. Capacity ties at everyday sizes (all four hold 10K at 100%) but splits +sharply past that: AnyCable held 600K idle connections where the Puma +adapters wall at ~52K on a file-descriptor ceiling and Falcon runs out of +memory at ~97K. AsyncCable on Falcon is a real alternative runtime to +Puma with latency and capacity in the same range, but it shares the +in-process limits: at-most-once and deploy-fragile. Solid Cable's own edge +is operational: no Redis, just the database, at the cost of a fixed +polling-latency floor. All four keep your channels and Turbo Streams exactly +as written. From 1915aa5a4a7283a9de946cc49bd28f420495f60b Mon Sep 17 00:00:00 2001 From: Irina Nazarova Date: Mon, 29 Jun 2026 08:58:35 -0700 Subject: [PATCH 02/18] docs: rename AsyncCable to Async::Cable (match the gem's own naming) --- README.md | 18 +++++++++--------- docs/rails-comparison.md | 32 ++++++++++++++++---------------- 2 files changed, 25 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 6dfe392..058046e 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Setups under test: default Socket.io, Socket.io + Connection State Recovery, uWe Sixth target, [socketioxide](https://github.com/totodore/socketioxide) (Rust Socket.io server), benchmarked head-to-head with AnyCable on the same Railway hardware. Results in [its own section below](#socketioxide-rust-socketio); deep dive in [`docs/socketioxide-comparison.md`](./docs/socketioxide-comparison.md). -The same harness also drives the **Rails** comparison (Action Cable vs Solid Cable vs AsyncCable vs AnyCable) behind [anycable.io/compare/rails-actioncable](https://anycable.io/compare/rails-actioncable): one Rails app, four adapters, same box. Results in [its own section below](#rails-action-cable--solid-cable--asynccable--anycable); deep dive in [`docs/rails-comparison.md`](./docs/rails-comparison.md). +The same harness also drives the **Rails** comparison (Action Cable vs Solid Cable vs Async::Cable vs AnyCable) behind [anycable.io/compare/rails-actioncable](https://anycable.io/compare/rails-actioncable): one Rails app, four adapters, same box. Results in [its own section below](#rails-action-cable--solid-cable--asynccable--anycable); deep dive in [`docs/rails-comparison.md`](./docs/rails-comparison.md). Methodology, traps, and the bugs we caught in our own setup: [`docs/methodology.md`](./docs/methodology.md). Below: the numbers and how to rerun them. @@ -112,11 +112,11 @@ Three knobs that shape the numbers. Full reasoning in [`docs/methodology.md`](./ **The takeaway.** Rust fixes Socket.io's capacity ceiling, the single-event-loop wall that caps Node around 120K. It leaves two things untouched: at-most-once delivery (no replay protocol) and deploy fragility (the WS layer still dies with its app). Both live in the protocol and the topology, so swapping the language to Rust leaves them intact, and socketioxide collapses under jitter and deploy storms at scale the same way Node Socket.io does. AnyCable holds 100% on both because the WS layer is a separate process with replay. Full numbers and the deploy story: [`docs/socketioxide-comparison.md`](./docs/socketioxide-comparison.md). -## Rails (Action Cable / Solid Cable / AsyncCable / AnyCable) +## Rails (Action Cable / Solid Cable / Async::Cable / AnyCable) -The repo behind [anycable.io/compare/rails-actioncable](https://anycable.io/compare/rails-actioncable). Four WebSocket adapters for the **same Rails 8.1 app**, same Railway box, same shared-tenant window: Action Cable on Redis (Puma), Solid Cable on the database (Puma), AsyncCable on Falcon (`socketry/async-cable`, fiber reactor), and AnyCable in RPC mode (Rails gRPC backend + `anycable-go` gateway). All four are Action Cable-compatible at the app and client level, so the channels and Turbo Streams are identical; only `config/cable.yml`, the runtime, and the process topology change. The Puma targets are in `cable-bench/`, the Falcon target in `cable-bench-falcon/`; the AnyCable JS driver serves all four over the existing `bench-*-anycable` endpoints, parameterized by `cableUrl` / `broadcastUrl` / `channel` / `acProtocol`. +The repo behind [anycable.io/compare/rails-actioncable](https://anycable.io/compare/rails-actioncable). Four WebSocket adapters for the **same Rails 8.1 app**, same Railway box, same shared-tenant window: Action Cable on Redis (Puma), Solid Cable on the database (Puma), Async::Cable on Falcon (`socketry/async-cable`, fiber reactor), and AnyCable in RPC mode (Rails gRPC backend + `anycable-go` gateway). All four are Action Cable-compatible at the app and client level, so the channels and Turbo Streams are identical; only `config/cable.yml`, the runtime, and the process topology change. The Puma targets are in `cable-bench/`, the Falcon target in `cable-bench-falcon/`; the AnyCable JS driver serves all four over the existing `bench-*-anycable` endpoints, parameterized by `cableUrl` / `broadcastUrl` / `channel` / `acProtocol`. -The one wire difference: Action Cable, Solid Cable and AsyncCable speak the base `actioncable-v1-json` protocol (at-most-once, no resume); AnyCable speaks the extended `actioncable-v1-ext-json` protocol (per-stream history, resume on reconnect). Switching Puma for Falcon (AsyncCable) changes the runtime, not the guarantees. +The one wire difference: Action Cable, Solid Cable and Async::Cable speak the base `actioncable-v1-json` protocol (at-most-once, no resume); AnyCable speaks the extended `actioncable-v1-ext-json` protocol (per-stream history, resume on reconnect). Switching Puma for Falcon (Async::Cable) changes the runtime, not the guarantees. All numbers below are **sharded** (13 drivers, ~250 to 770 cables each). A single bench-runner holding thousands of cables saturates its own event loop and produces wrong numbers in both directions; see [Running the latency test](#running-the-latency-and-jitter-test-shard-it). @@ -125,7 +125,7 @@ All numbers below are **sharded** (13 drivers, ~250 to 770 cables each). A singl | Adapter | 1K p50 / p99 | 5K p50 / p99 | | --- | --- | --- | | Solid Cable | 62 / 119 ms | 74 / 164 ms | -| AsyncCable (Falcon) | 11 / 80 ms | 20 / 71 ms | +| Async::Cable (Falcon) | 11 / 80 ms | 20 / 71 ms | | Action Cable (Puma) | 9 / 47 ms | 13 / 57 ms | | AnyCable | **4 / 23 ms** | **7 / 31 ms** | @@ -135,10 +135,10 @@ All numbers below are **sharded** (13 drivers, ~250 to 770 cables each). A singl | --- | --- | | Solid Cable | **78.1%** | | Action Cable | **78.1%** | -| AsyncCable (Falcon) | **78.1%** | +| Async::Cable (Falcon) | **78.1%** | | AnyCable | **99.9%** | -**Capacity: 10K under load + idle-to-break.** All four hold 10K at 100% delivery; AnyCable keeps the tightest tail (p99 31 ms vs 84 ms Action Cable, 112 ms AsyncCable, 200 ms Solid Cable). Pushed to failure on identical 32 GB boxes (default 8-worker config): Puma Action Cable and Solid Cable wall at ~52K on a file-descriptor ceiling (~2.5 GB RAM, not memory-bound); AsyncCable on Falcon runs out of memory at ~97K (~290 KB/conn); AnyCable held **600K with zero failures** across a 50-runner fleet (~47 KB/conn, ~27 GB, ~84% of the box), not driven to failure: the load fleet maxed out, not the server, so treat 600K as a floor. Finding a gateway's true ceiling takes ~1 load driver per 10K connections (a single Node driver tops out near 10K cables), so capacity-to-break runs scale the driver count to the target. Raw data in [`backend/results/rails-capacity-break-2026-06-28.json`](./backend/results/rails-capacity-break-2026-06-28.json). +**Capacity: 10K under load + idle-to-break.** All four hold 10K at 100% delivery; AnyCable keeps the tightest tail (p99 31 ms vs 84 ms Action Cable, 112 ms Async::Cable, 200 ms Solid Cable). Pushed to failure on identical 32 GB boxes (default 8-worker config): Puma Action Cable and Solid Cable wall at ~52K on a file-descriptor ceiling (~2.5 GB RAM, not memory-bound); Async::Cable on Falcon runs out of memory at ~97K (~290 KB/conn); AnyCable held **600K with zero failures** across a 50-runner fleet (~47 KB/conn, ~27 GB, ~84% of the box), not driven to failure: the load fleet maxed out, not the server, so treat 600K as a floor. Finding a gateway's true ceiling takes ~1 load driver per 10K connections (a single Node driver tops out near 10K cables), so capacity-to-break runs scale the driver count to the target. Raw data in [`backend/results/rails-capacity-break-2026-06-28.json`](./backend/results/rails-capacity-break-2026-06-28.json). **Deploy survival (avalanche, 5K, real app redeploy).** In-process drops every connection, Puma and Falcon alike, and stays down ~7.5 to 8 s before ~96% reconnect; AnyCable's gateway never sees the deploy, so 0 s of downtime. "Down for" is how long connections stayed dropped before the reconnect storm settled. @@ -146,10 +146,10 @@ All numbers below are **sharded** (13 drivers, ~250 to 770 cables each). A singl | --- | --- | --- | --- | | Action Cable | all 5,000 | 7.5 s | 96.3% (187 still out at cutoff) | | Solid Cable | all 5,000 | 7.6 s | 95.7% (215 still out at cutoff) | -| AsyncCable (Falcon) | all 5,000 | 8.0 s | 96.4% (179 still out at cutoff) | +| Async::Cable (Falcon) | all 5,000 | 8.0 s | 96.4% (179 still out at cutoff) | | AnyCable | **0** | **0 s** | n/a | -**The takeaway.** On Rails, AnyCable leads on latency at every scale (7 ms p50 at 5K) and wins the things that decide whether realtime holds up: 100% delivery under jitter where the base protocol drops about a fifth of broadcasts, and connections that survive every app deploy. Capacity ties at everyday sizes (all four hold 10K at 100%) but splits past that: AnyCable held 600K idle connections where the Puma adapters wall at ~52K on a file-descriptor ceiling and Falcon runs out of memory at ~97K. AsyncCable on Falcon is a real alternative runtime to Puma with latency in the same range, but shares the in-process limits (at-most-once, deploy-fragile) and is the most memory-hungry by far. Solid Cable's edge is operational (no Redis), at the cost of a polling-latency floor. Full numbers: [`docs/rails-comparison.md`](./docs/rails-comparison.md). Raw results: [`backend/results/rails-sharded-2026-06-28.json`](./backend/results/rails-sharded-2026-06-28.json). +**The takeaway.** On Rails, AnyCable leads on latency at every scale (7 ms p50 at 5K) and wins the things that decide whether realtime holds up: 100% delivery under jitter where the base protocol drops about a fifth of broadcasts, and connections that survive every app deploy. Capacity ties at everyday sizes (all four hold 10K at 100%) but splits past that: AnyCable held 600K idle connections where the Puma adapters wall at ~52K on a file-descriptor ceiling and Falcon runs out of memory at ~97K. Async::Cable on Falcon is a real alternative runtime to Puma with latency in the same range, but shares the in-process limits (at-most-once, deploy-fragile) and is the most memory-hungry by far. Solid Cable's edge is operational (no Redis), at the cost of a polling-latency floor. Full numbers: [`docs/rails-comparison.md`](./docs/rails-comparison.md). Raw results: [`backend/results/rails-sharded-2026-06-28.json`](./backend/results/rails-sharded-2026-06-28.json). ## Repository layout diff --git a/docs/rails-comparison.md b/docs/rails-comparison.md index 19ab1ae..b2c8673 100644 --- a/docs/rails-comparison.md +++ b/docs/rails-comparison.md @@ -1,4 +1,4 @@ -# Rails in the comparison: Action Cable vs Solid Cable vs AsyncCable vs AnyCable +# Rails in the comparison: Action Cable vs Solid Cable vs Async::Cable vs AnyCable The repo behind [anycable.io/compare/rails-actioncable](https://anycable.io/compare/rails-actioncable). Four WebSocket adapters for the same Rails app, same Railway box, same @@ -9,7 +9,7 @@ shared-tenant window: - **Solid Cable** (Rails 8 default-stack adapter). Same in-process termination, no Redis; the adapter polls a database table (default every 100 ms) for new messages. -- **AsyncCable** ([socketry/async-cable](https://github.com/socketry/async-cable)): +- **Async::Cable** ([socketry/async-cable](https://github.com/socketry/async-cable)): serves Action Cable in-process on [Falcon](https://github.com/socketry/falcon), a fiber-based reactor, instead of Puma's threads. Same wire protocol; a different concurrency engine. Built on `actioncable-next`, so it runs on @@ -26,7 +26,7 @@ difference is the adapter, not the app. ## The one thing that differs on the wire -Action Cable, Solid Cable and AsyncCable speak the base +Action Cable, Solid Cable and Async::Cable speak the base `actioncable-v1-json` protocol, which is **at-most-once**: a broadcast that goes out while a client is briefly offline is gone, because there is no history to replay on reconnect. AnyCable speaks the extended @@ -36,7 +36,7 @@ it saw. The AnyCable JS client negotiates the extended protocol automatically; a plain Action Cable client still connects over the base protocol. That single difference drives the reliability gap below. -Note AsyncCable: switching Puma threads for Falcon fibers changes the +Note Async::Cable: switching Puma threads for Falcon fibers changes the runtime, not the guarantees. It is still in-process (drops on deploy) and still speaks the base protocol (loses messages under jitter, same as the others). Falcon does not close the reliability or deploy gap, because those @@ -55,12 +55,12 @@ four Rails targets, parameterized by: - `channel`: `BenchmarkChannel` (real Rails channel), vs `$pubsub` for the bare-stream nodejs targets. - `acProtocol`: `actioncable-v1-json` for Action Cable / Solid Cable / - AsyncCable, `actioncable-v1-ext-json` for AnyCable. + Async::Cable, `actioncable-v1-ext-json` for AnyCable. The Puma/Redis and Solid Cable targets live in `cable-bench/` (named `cable-bench` because `rails` is a reserved Railway app name): one Docker image, two modes via `BENCH_MODE`. AnyCable runs the same app as its gRPC -RPC backend with `anycable-go-rails` as the gateway. AsyncCable runs from +RPC backend with `anycable-go-rails` as the gateway. Async::Cable runs from `cable-bench-falcon/`, a copy of the app booted on Falcon (`bundle exec falcon serve`) with `actioncable-next` + `async-cable`. Manifest entries are in `backend/src/bench/tests-manifest.ts` under each @@ -83,20 +83,20 @@ latency (and jitter) test." All numbers from sharded runs in one shared-tenant Railway window (2026-06-28), captured in `backend/results/rails-sharded-2026-06-28.json`. The in-process targets ran -Puma with 8 workers × 5 threads; AsyncCable ran Falcon with 8 processes. +Puma with 8 workers × 5 threads; Async::Cable ran Falcon with 8 processes. ### Roundtrip latency (steady network, 100% delivery) AnyCable is fastest at both sizes: fan-out happens in the Go gateway rather than in Ruby, so it stays at single-digit milliseconds where the in-process -adapters climb with load. Action Cable on Puma is close behind; AsyncCable +adapters climb with load. Action Cable on Puma is close behind; Async::Cable on Falcon sits a touch higher; Solid Cable carries a fixed floor from its 100 ms database poll. | Adapter | 1K p50 / p99 | 5K p50 / p99 | | --- | --- | --- | | Solid Cable | 62 / 119 ms | 74 / 164 ms | -| AsyncCable (Falcon) | 11 / 80 ms | 20 / 71 ms | +| Async::Cable (Falcon) | 11 / 80 ms | 20 / 71 ms | | Action Cable (Puma) | 9 / 47 ms | 13 / 57 ms | | AnyCable | **4 / 23 ms** | **7 / 31 ms** | @@ -110,7 +110,7 @@ identically here, because the loss is in the protocol, not the runtime. | --- | --- | --- | --- | | Solid Cable | **78.1%** | 71 ms | no resume | | Action Cable | **78.1%** | 12 ms | no resume | -| AsyncCable (Falcon) | **78.1%** | 18 ms | no resume | +| Async::Cable (Falcon) | **78.1%** | 18 ms | no resume | | AnyCable | **99.9%** | 7 ms | ~6.0 s | AnyCable's longer p99 is the resumed history landing a beat late on @@ -129,7 +129,7 @@ tightest tail; Solid Cable's poll shows in its p99. | --- | --- | | Solid Cable | 100% / 200 ms | | Action Cable | 100% / 84 ms | -| AsyncCable (Falcon) | 100% / 112 ms | +| Async::Cable (Falcon) | 100% / 112 ms | | AnyCable | **100% / 31 ms** | **Idle-to-break, on identical 32 GB boxes, default 8-worker config.** Ramped @@ -140,12 +140,12 @@ idle connections until the holding box failed (raw data in | --- | --- | --- | --- | | Action Cable (Puma) | ~52K | 2.3 GB | 8-worker file-descriptor ceiling | | Solid Cable (Puma) | ~52K | 2.5 GB | 8-worker file-descriptor ceiling | -| AsyncCable (Falcon) | ~97K | 27 GB | memory, ~290 KB/conn | +| Async::Cable (Falcon) | ~97K | 27 GB | memory, ~290 KB/conn | | AnyCable (Go gateway) | **600K+** | 27 GB | not broken; load fleet maxed out | Three findings. The Puma adapters wall at ~52K using only ~2.5 GB: an 8-worker file-descriptor ceiling, not memory (raising worker fd limits -lifts it). AsyncCable on Falcon is memory-bound at ~97K, because each +lifts it). Async::Cable on Falcon is memory-bound at ~97K, because each fiber-backed connection costs roughly 290 KB, about six times the others. AnyCable held 600K idle connections with zero failures across a 50-runner fleet before we ran out of load generators; its gateway memory scaled @@ -177,7 +177,7 @@ redeploy before the reconnect storm settled. | --- | --- | --- | --- | | Action Cable | all 5,000 | 7.5 s | 96.3% (187 still out at cutoff) | | Solid Cable | all 5,000 | 7.6 s | 95.7% (215 still out at cutoff) | -| AsyncCable (Falcon) | all 5,000 | 8.0 s | 96.4% (179 still out at cutoff) | +| Async::Cable (Falcon) | all 5,000 | 8.0 s | 96.4% (179 still out at cutoff) | | AnyCable | **0** | **0 s** | n/a | The AnyCable run redeployed its Rails RPC backend the same way; the gateway @@ -187,7 +187,7 @@ downtime is zero by construction. ## The takeaway On Rails, AnyCable leads on latency at every scale (7 ms p50 at 5K vs 13 ms -for Action Cable, 20 ms for AsyncCable, 74 ms for Solid Cable), because +for Action Cable, 20 ms for Async::Cable, 74 ms for Solid Cable), because fan-out runs in Go off the Ruby process. It wins everything that decides whether realtime holds up in production: 100% delivery under jitter where the base protocol drops about a fifth of broadcasts, and every connection @@ -195,7 +195,7 @@ kept alive across an app deploy where the in-process adapters drop all of them. Capacity ties at everyday sizes (all four hold 10K at 100%) but splits sharply past that: AnyCable held 600K idle connections where the Puma adapters wall at ~52K on a file-descriptor ceiling and Falcon runs out of -memory at ~97K. AsyncCable on Falcon is a real alternative runtime to +memory at ~97K. Async::Cable on Falcon is a real alternative runtime to Puma with latency and capacity in the same range, but it shares the in-process limits: at-most-once and deploy-fragile. Solid Cable's own edge is operational: no Redis, just the database, at the cost of a fixed From b136387d7cc0c482ea083f147f14857593cfe7e3 Mon Sep 17 00:00:00 2001 From: Irina Nazarova Date: Mon, 29 Jun 2026 14:43:26 -0700 Subject: [PATCH 03/18] cable-bench-falcon: enable actioncable-next fastlane broadcasts Encode each stream payload once per channel identifier instead of once per subscriber (~2x faster broadcasts). Stock Action Cable has no equivalent, so this measures the optimized actioncable-next path on the Async::Cable/Falcon target. --- cable-bench-falcon/config/initializers/action_cable.rb | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 cable-bench-falcon/config/initializers/action_cable.rb diff --git a/cable-bench-falcon/config/initializers/action_cable.rb b/cable-bench-falcon/config/initializers/action_cable.rb new file mode 100644 index 0000000..f6dcc77 --- /dev/null +++ b/cable-bench-falcon/config/initializers/action_cable.rb @@ -0,0 +1,9 @@ +# actioncable-next supports "fastlane" broadcasts: a stream's payload is +# JSON-encoded once per channel identifier instead of once per subscriber, +# which roughly halves broadcast latency on large fan-outs. This is an +# actioncable-next optimization (stock Action Cable has no equivalent), so we +# enable it on the Async::Cable/Falcon target to measure that path. +# https://github.com/anycable/actioncable-next#actioncableserverconfigfastlane_broadcasts_enabled--true +Rails.application.config.after_initialize do + ActionCable.server.config.fastlane_broadcasts_enabled = true +end From 89a79a1ed3c17fd1d6fb81e95b136f095d37c4e6 Mon Sep 17 00:00:00 2001 From: Irina Nazarova Date: Mon, 29 Jun 2026 16:27:26 -0700 Subject: [PATCH 04/18] Add raw_transmit shim so fastlane broadcasts deliver on Falcon actioncable-next fastlane sends pre-encoded frames via Socket#raw_transmit, which async-cable 0.3.1's Socket does not implement; without this shim every fastlane broadcast raises NoMethodError and delivery is 0%. --- .../async_cable_fastlane_compat.rb | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 cable-bench-falcon/config/initializers/async_cable_fastlane_compat.rb diff --git a/cable-bench-falcon/config/initializers/async_cable_fastlane_compat.rb b/cable-bench-falcon/config/initializers/async_cable_fastlane_compat.rb new file mode 100644 index 0000000..9c5abd0 --- /dev/null +++ b/cable-bench-falcon/config/initializers/async_cable_fastlane_compat.rb @@ -0,0 +1,20 @@ +# Fastlane broadcasts (config/initializers/action_cable.rb) make +# actioncable-next encode each broadcast once per channel identifier and send +# it through Connection#raw_transmit -> Socket#raw_transmit, bypassing the +# per-subscriber coder. ActionCable::Server::Socket implements #raw_transmit, +# but the Falcon WS adapter Async::Cable::Socket (async-cable 0.3.1) only +# implements #transmit (which JSON-encodes via its coder) and has no +# #raw_transmit. So every fastlane broadcast raises NoMethodError inside the +# stream callback and the client receives nothing (0% delivery). +# +# Add the missing method: push the already-encoded frame straight onto the +# outbound queue, skipping the coder, exactly as +# ActionCable::Server::Socket#raw_transmit sends a pre-encoded message as-is. +# Without this shim, actioncable-next fastlane + async-cable cannot deliver. +if defined?(Async::Cable::Socket) && !Async::Cable::Socket.method_defined?(:raw_transmit) + Async::Cable::Socket.class_eval do + def raw_transmit(data) + @output.push(data) + end + end +end From 73cd86ed74a67758fcfd574bceafdce36f460fbc Mon Sep 17 00:00:00 2001 From: Irina Nazarova Date: Mon, 29 Jun 2026 16:45:10 -0700 Subject: [PATCH 05/18] Wire throughput runner for real Rails channels throughput.ts / bench-runner /bench-throughput-anycable / throughput-multi.ts now accept channel + acProtocol, mirroring the jitter path, so the throughput suite can target a Rails BenchmarkChannel over the base protocol instead of only anycable-go $pubsub over the extended protocol. --- backend/src/bench-runner/server.ts | 118 ++++++++++++++++++++++++++ backend/src/bench/throughput-multi.ts | 13 +++ backend/src/lib/throughput.ts | 17 +++- 3 files changed, 146 insertions(+), 2 deletions(-) diff --git a/backend/src/bench-runner/server.ts b/backend/src/bench-runner/server.ts index b1e54ca..88b833b 100644 --- a/backend/src/bench-runner/server.ts +++ b/backend/src/bench-runner/server.ts @@ -49,6 +49,14 @@ import { runThroughputUws, type ThroughputParams, } from "../lib/throughput.js"; +import { + runJitterCentrifugo, + runThroughputCentrifugo, + runWhispersCentrifugo, + runIdleCentrifugo, + runAvalancheCentrifugo, + type CentrifugoUrls, +} from "../lib/centrifugo-runners.js"; const SOCKETIO_URL = process.env.SOCKETIO_URL || "http://socketio-server.railway.internal:3000"; @@ -82,6 +90,30 @@ const UWS_WS_URL = process.env.UWS_WS_URL || "ws://uws-server.railway.internal:3000/ws"; const UWS_HTTP_URL = process.env.UWS_HTTP_URL || "http://uws-server.railway.internal:3000"; +// Centrifugo target (standalone Go WS server, like anycable-go). WS endpoint +// is /connection/websocket; server API + publish live on the same port. +const CENTRIFUGO_WS_URL = + process.env.CENTRIFUGO_WS_URL || + "ws://centrifugo.railway.internal:8000/connection/websocket"; +const CENTRIFUGO_HTTP_URL = + process.env.CENTRIFUGO_HTTP_URL || "http://centrifugo.railway.internal:8000"; +const CENTRIFUGO_API_KEY = + process.env.CENTRIFUGO_API_KEY || "bench-centrifugo-api-key"; +const CENTRIFUGO_TOKEN_SECRET = + process.env.CENTRIFUGO_TOKEN_SECRET || "bench-centrifugo-secret"; + +// Bundle the Centrifugo target config from query overrides + env defaults, so +// every centrifugo endpoint targets the same service (or an override) the same +// way the anycable endpoints accept ?cableUrl=. +function centrifugoUrls(req: express.Request): CentrifugoUrls { + return { + wsUrl: (req.query.wsUrl as string) || CENTRIFUGO_WS_URL, + httpBase: (req.query.httpUrl as string) || CENTRIFUGO_HTTP_URL, + apiKey: (req.query.apiKey as string) || CENTRIFUGO_API_KEY, + tokenSecret: (req.query.tokenSecret as string) || CENTRIFUGO_TOKEN_SECRET, + channelNamespace: (req.query.namespace as string) || undefined, + }; +} const app = express(); app.use(express.json()); @@ -567,6 +599,85 @@ app.post("/bench-avalanche-uws", async (req, res) => { ); }); +// --------------------------------------------------------------------------- +// Centrifugo benches. Centrifugo is a standalone Go WS server like anycable-go +// (broadcast over HTTP), with built-in history/recovery, presence, and JWT — +// the closest comparator to AnyCable in this suite. Each endpoint mirrors its +// anycable twin; `?wsUrl=`, `?httpUrl=`, `?apiKey=`, `?tokenSecret=`, +// `?namespace=` override the target. + +app.post("/bench-jitter-centrifugo", async (req, res) => { + const params = paramsFromQuery(req); + const urls = centrifugoUrls(req); + await respondAsync(req, res, () => runJitterCentrifugo(params, urls)); +}); + +app.post("/bench-idle-centrifugo", async (req, res) => { + const n = parseInt((req.query.n as string) || "10000", 10); + const holdSec = parseInt((req.query.hold as string) || "60", 10); + const rampPerSec = parseInt((req.query.ramp as string) || "200", 10); + const stream = (req.query.stream as string) || "idle-probe"; + const shardLabel = (req.query.shard as string) || undefined; + const urls = centrifugoUrls(req); + await respondAsync(req, res, () => + runIdleCentrifugo({ n, holdSec, rampPerSec, stream }, urls, shardLabel), + ); +}); + +app.post("/bench-whispers-centrifugo", async (req, res) => { + const params = whispersParamsFromQuery(req); + const urls = centrifugoUrls(req); + // Whispers default to the "whisper" namespace (allow_publish_for_subscriber) + // unless the caller overrides with ?namespace=. + await respondAsync(req, res, () => + runWhispersCentrifugo(params, { ...urls, channelNamespace: urls.channelNamespace ?? "whisper" }), + ); +}); + +app.post("/bench-throughput-centrifugo", async (req, res) => { + const base = throughputParamsFromQuery(req, `tp-cfgo-${Date.now()}`); + const urls = centrifugoUrls(req); + // Centrifugo's HTTP publisher supports serial/pool/fireforget; map the + // AnyCable-only "nats" mode onto pool so a shared driver flag still works. + const publisher = + base.publisher === "pool" || base.publisher === "fireforget" + ? base.publisher + : base.publisher === "serial" + ? "serial" + : "pool"; + await respondAsync(req, res, () => + runThroughputCentrifugo( + { + n: base.n, + totalMessages: base.totalMessages, + intervalMs: base.intervalMs, + rampPerSec: base.rampPerSec, + stream: base.stream, + drainSec: base.drainSec, + publisher, + publisherConcurrency: base.publisherConcurrency, + }, + urls, + ), + ); +}); + +// Centrifugo avalanche / deploy resilience. Like AnyCable, Centrifugo is a +// standalone process, so an app deploy never severs its connections (expected +// disconnected ~= 0). Redeploying the centrifugo service itself is the only +// thing that drops them; the operator triggers that during the prearm window. +app.post("/bench-avalanche-centrifugo", async (req, res) => { + const n = parseInt((req.query.n as string) || "1000", 10); + const rampPerSec = parseInt((req.query.ramp as string) || "200", 10); + const prearmSec = parseInt((req.query.prearm as string) || "120", 10); + const recoveryWaitSec = parseInt((req.query.recoveryWait as string) || "240", 10); + const stream = (req.query.stream as string) || "avalanche-cfgo"; + const urls = centrifugoUrls(req); + await respondAsync(req, res, () => + runAvalancheCentrifugo({ n, rampPerSec, prearmSec, recoveryWaitSec, stream }, urls), + ); +}); + // --------------------------------------------------------------------------- // Throughput (msg/sec) benches. 10K subscribers × N broadcasts at intervalMs. // Sweep the rate from a CLI driver: 1, 10, 100, 1000 msg/sec target. Where @@ -596,6 +707,11 @@ app.post("/bench-throughput-anycable", async (req, res) => { const natsUrl = (req.query.natsUrl as string) || ANYCABLE_NATS_URL || undefined; const natsSubject = (req.query.natsSubject as string) || ANYCABLE_NATS_SUBJECT || undefined; + // `?channel=BenchmarkChannel&acProtocol=actioncable-v1-json` targets a real + // Rails app over the base protocol; defaults keep the anycable-go $pubsub + // channel over the extended protocol. + const channel = (req.query.channel as string) || undefined; + const acProtocol = (req.query.acProtocol as string) || undefined; await respondAsync(req, res, () => runThroughputAnycable(params, { cableUrl, @@ -603,6 +719,8 @@ app.post("/bench-throughput-anycable", async (req, res) => { broadcastSecret: ANYCABLE_BROADCAST_SECRET || undefined, natsUrl, natsSubject, + channel, + acProtocol, }), ); }); diff --git a/backend/src/bench/throughput-multi.ts b/backend/src/bench/throughput-multi.ts index e6f5849..4f9795b 100644 --- a/backend/src/bench/throughput-multi.ts +++ b/backend/src/bench/throughput-multi.ts @@ -82,6 +82,7 @@ const PROTOCOL_TO_ENDPOINT: Record = { socketio: "bench-throughput-socketio", "socketio-csr": "bench-throughput-socketio-csr", uws: "bench-throughput-uws", + centrifugo: "bench-throughput-centrifugo", }; const endpoint = PROTOCOL_TO_ENDPOINT[protocol]; if (!endpoint) { @@ -98,6 +99,10 @@ if (protocol === "anycable") { if (process.env.CABLE_URL) protocolQuery.cableUrl = process.env.CABLE_URL; if (process.env.BROADCAST_URL) protocolQuery.broadcastUrl = process.env.BROADCAST_URL; + // Target a real Rails channel over the base protocol when set; otherwise the + // runner defaults to anycable-go's $pubsub channel + extended protocol. + if (process.env.CHANNEL) protocolQuery.channel = process.env.CHANNEL; + if (process.env.AC_PROTOCOL) protocolQuery.acProtocol = process.env.AC_PROTOCOL; } if (protocol === "socketio" || protocol === "socketio-csr") { if (process.env.SERVER_URL) protocolQuery.serverUrl = process.env.SERVER_URL; @@ -106,6 +111,14 @@ if (protocol === "uws") { if (process.env.UWS_WS_URL) protocolQuery.wsUrl = process.env.UWS_WS_URL; if (process.env.UWS_HTTP_URL) protocolQuery.httpUrl = process.env.UWS_HTTP_URL; } +if (protocol === "centrifugo") { + if (process.env.CENTRIFUGO_WS_URL) protocolQuery.wsUrl = process.env.CENTRIFUGO_WS_URL; + if (process.env.CENTRIFUGO_HTTP_URL) protocolQuery.httpUrl = process.env.CENTRIFUGO_HTTP_URL; + if (process.env.CENTRIFUGO_API_KEY) protocolQuery.apiKey = process.env.CENTRIFUGO_API_KEY; + if (process.env.CENTRIFUGO_TOKEN_SECRET) + protocolQuery.tokenSecret = process.env.CENTRIFUGO_TOKEN_SECRET; + if (process.env.NAMESPACE) protocolQuery.namespace = process.env.NAMESPACE; +} console.log( `Multi-shard throughput: ${shardUrls.length} shards × ${perShardN} = ${shardUrls.length * perShardN} clients (target: ${totalClients})`, diff --git a/backend/src/lib/throughput.ts b/backend/src/lib/throughput.ts index cbb09e1..1a9201e 100644 --- a/backend/src/lib/throughput.ts +++ b/backend/src/lib/throughput.ts @@ -123,6 +123,14 @@ export interface AnycableUrls { // Optional NATS broadcaster — used when publisher mode is "nats". natsUrl?: string; // e.g. nats://anycable-go-pro.railway.internal:4242 natsSubject?: string; // default __anycable__ (matches anycable-go default) + // Channel to subscribe to. Defaults to "$pubsub" (anycable-go's public + // pub/sub channel via streamFrom). For a real Rails app, pass + // "BenchmarkChannel" and the driver subscribes to it with { stream_name }. + channel?: string; + // WebSocket subprotocol. AnyCable uses the extended Action Cable protocol + // ("actioncable-v1-ext-json"); vanilla Action Cable / Async::Cable speak the + // base protocol ("actioncable-v1-json"). + acProtocol?: string; } async function runAnycablePublisher( @@ -225,7 +233,7 @@ export async function runThroughputAnycable( stats.push(stat); const cable = createCable(urls.cableUrl, { websocketImplementation: WebSocket as unknown as typeof globalThis.WebSocket, - protocol: "actioncable-v1-ext-json", + protocol: (urls.acProtocol ?? "actioncable-v1-ext-json") as never, logLevel: "error" as never, }); cable.on("close", () => {}); @@ -233,7 +241,12 @@ export async function runThroughputAnycable( cable.on("connect", () => { stat.everConnected = true; }); - const channel = cable.streamFrom(p.stream); + // "$pubsub" -> anycable-go's signed pub/sub channel (streamFrom). Any + // other value -> a real Rails channel subscribed with { stream_name }. + const channel = + urls.channel && urls.channel !== "$pubsub" + ? cable.subscribeTo(urls.channel, { stream_name: p.stream }) + : cable.streamFrom(p.stream); channel.on("message", (msg: unknown) => recordMsg(stat, msg)); cables.push(cable); await maybePauseForRamp(p, i, "tp-ac"); From 2f680916490e08d87656a31dfe118752703346ab Mon Sep 17 00:00:00 2001 From: Irina Nazarova Date: Mon, 29 Jun 2026 16:50:59 -0700 Subject: [PATCH 06/18] Fix throughput-multi publish-rate key (interval -> intervalMs) The runner reads req.query.intervalMs; throughput-multi sent 'interval', so every run silently used the 100ms default and ignored the requested rate. Coordinator-only fix; no runner rebuild needed. --- backend/src/bench/throughput-multi.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/backend/src/bench/throughput-multi.ts b/backend/src/bench/throughput-multi.ts index 4f9795b..f91d174 100644 --- a/backend/src/bench/throughput-multi.ts +++ b/backend/src/bench/throughput-multi.ts @@ -145,7 +145,9 @@ const shardSpecs: ShardSpec[] = shardUrls.map((url, i) => ({ query: { n: perShardN, total: totalMessages, - interval: intervalMs, + // Runner reads `intervalMs` (throughputParamsFromQuery); sending `interval` + // silently fell back to the 100ms default and ignored the requested rate. + intervalMs: intervalMs, ramp: rampPerSec, drain: drainSec, publisher, From 5afa764821f013380855c357def5c80085d3e2b5 Mon Sep 17 00:00:00 2001 From: Irina Nazarova Date: Mon, 29 Jun 2026 18:39:14 -0700 Subject: [PATCH 07/18] Falcon target: native raw_transmit + vendored fiber Executor Pin async-cable to @27181dff1 (native Socket#raw_transmit, Rails 8.1 compatible) and drop the raw_transmit shim. Vendor Async::Cable::Executor (from async-cable dddef54c, whose released form requires edge Rails 8.2) and install it via ActionCable::Server::Base#executor, so broadcast-delivery callbacks (SubscriberMap::Async#invoke_callback -> executor.post) run on the reactor instead of bouncing through Action Cable's thread pool. This is the documented fix for Falcon broadcast latency; re-measure vs the 0.3.1 numbers. --- cable-bench-falcon/Gemfile | 10 +- cable-bench-falcon/Gemfile.lock | 18 +- .../initializers/async_cable_executor.rb | 156 ++++++++++++++++++ .../async_cable_fastlane_compat.rb | 20 --- 4 files changed, 177 insertions(+), 27 deletions(-) create mode 100644 cable-bench-falcon/config/initializers/async_cable_executor.rb delete mode 100644 cable-bench-falcon/config/initializers/async_cable_fastlane_compat.rb diff --git a/cable-bench-falcon/Gemfile b/cable-bench-falcon/Gemfile index 4ec43fd..3468427 100644 --- a/cable-bench-falcon/Gemfile +++ b/cable-bench-falcon/Gemfile @@ -18,7 +18,15 @@ gem "sqlite3", ">= 2.1" # (async ecosystem) instead of Puma's threads. Same Action Cable wire protocol; # the WebSockets are handled by Async::Cable::Middleware on a Falcon reactor. gem "falcon" -gem "async-cable", "~> 0.3.1", require: "async/cable" +# Pinned to async-cable @27181dff1 (2026-05-29): the 0.3.1 release lacks +# Socket#raw_transmit, which actioncable-next fastlane broadcasts require +# (without it fastlane raises NoMethodError -> 0% delivery). This commit adds +# it natively and still allows Rails 8.1 (gemspec actioncable >= 8.1.0.alpha). +# We do NOT use main HEAD: the later Executor commit (dddef54c) bumped the +# gemspec to actioncable >= 8.2.0.alpha (edge Rails only). We instead vendor +# that fiber Executor in config/initializers/async_cable_executor.rb, since +# its code is self-contained and async-cable does not auto-wire it anyway. +gem "async-cable", github: "socketry/async-cable", ref: "27181dff124d2e5a933cb9abf2581c8e86532956", require: "async/cable" # Use JavaScript with ESM import maps [https://github.com/rails/importmap-rails] gem "importmap-rails" diff --git a/cable-bench-falcon/Gemfile.lock b/cable-bench-falcon/Gemfile.lock index e23491a..8e0ba1b 100644 --- a/cable-bench-falcon/Gemfile.lock +++ b/cable-bench-falcon/Gemfile.lock @@ -1,3 +1,13 @@ +GIT + remote: https://github.com/socketry/async-cable.git + revision: 27181dff124d2e5a933cb9abf2581c8e86532956 + ref: 27181dff124d2e5a933cb9abf2581c8e86532956 + specs: + async-cable (0.3.1) + actioncable (>= 8.1.0.alpha) + async (~> 2.9) + async-websocket + GEM remote: https://rubygems.org/ specs: @@ -88,10 +98,6 @@ GEM io-event (~> 1.11) metrics (~> 0.12) traces (~> 0.18) - async-cable (0.3.1) - actioncable-next - async (~> 2.9) - async-websocket async-container (0.37.0) async (~> 2.22) async-http (0.95.1) @@ -415,7 +421,7 @@ PLATFORMS DEPENDENCIES actioncable-next - async-cable (~> 0.3.1) + async-cable! bootsnap brakeman bundler-audit @@ -451,7 +457,7 @@ CHECKSUMS activesupport (8.1.3) sha256=21a5e0dfbd4c3ddd9e1317ec6a4d782fa226e7867dc70b0743acda81a1dca20e ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383 async (2.40.0) sha256=52c7cf92b7e12fec4054f721b2fc9df401940d65a076f7e771cd6af18947af66 - async-cable (0.3.1) sha256=437eaf75ffb52381167391aa4178bf5b3d899d10cfd6a4a8add31d05433c4974 + async-cable (0.3.1) async-container (0.37.0) sha256=16bd50a6d3ba818d917160a96a965a2cde0fd0e9959715741cbeeba90d68d315 async-http (0.95.1) sha256=0c3dd458c204c06d5c4b20b01bbec4794a1203db627fb2ce536e1799ec14786c async-http-cache (0.4.6) sha256=2038d1f093182f16b50b4db271c25085e3938da10bfcfc2904cadb0530fddfd6 diff --git a/cable-bench-falcon/config/initializers/async_cable_executor.rb b/cable-bench-falcon/config/initializers/async_cable_executor.rb new file mode 100644 index 0000000..1141c64 --- /dev/null +++ b/cable-bench-falcon/config/initializers/async_cable_executor.rb @@ -0,0 +1,156 @@ +# Install async-cable's fiber-based executor on this Falcon target. +# +# Action Cable dispatches pub/sub callback invocations and periodic timers +# through `ActionCable::Server::Base#executor`. Stock Rails (and the pinned +# async-cable @27181dff1) back this with a Concurrent::ThreadPoolExecutor, so +# under Falcon's fiber reactor every broadcast dispatch bounces through an OS +# thread before it reaches the socket. That thread hop is the prime suspect for +# the broadcast-latency gap vs Puma. +# +# Samuel Williams added `Async::Cable::Executor` (a fiber-based replacement) in +# async-cable commit dddef54c, but that same commit bumped the gemspec to +# `actioncable >= 8.2.0.alpha` (edge Rails only), and this app is Rails 8.1.3 + +# actioncable-next. The Executor code itself is self-contained (it only needs +# `async`) and async-cable does not auto-wire it anyway, so we vendor it here +# verbatim and install it by overriding the server's #executor. Vendored from +# https://github.com/socketry/async-cable/blob/dddef54c/lib/async/cable/executor.rb +require "async" + +module Async + module Cable + # Fiber-based replacement for `ActionCable::Server::ThreadedExecutor`. + # Tasks posted from inside a reactor run on the caller's reactor (no thread + # hop); tasks posted from outside, and recurring timers, run on a dedicated + # reactor thread owned by the executor. + class Executor + def initialize + @mutex = ::Thread::Mutex.new + @inbox = nil + @thread = nil + end + + def post(task = nil, &block) + block ||= task + + if current = ::Async::Task.current? + current.async { block.call } + else + inbox.push(proc { block.call }) + end + + return self + end + + def timer(interval, &block) + timer = Timer.new + + if current = ::Async::Task.current? + timer.task = current.async do |inner| + run_timer(inner, interval, block) + end + + return timer + end + + inbox = timer.inbox = self.inbox + begin + operation = proc do |task| + timer.task = task.async do |inner| + run_timer(inner, interval, block) + end + end + + inbox.push(operation) + rescue ::ClosedQueueError + # Executor is shutting down; match best-effort post-during-shutdown. + end + + return timer + end + + def shutdown + @mutex.synchronize do + return unless @thread + @inbox.close + @thread.join + @thread = nil + @inbox = nil + end + end + + class Timer + attr_writer :inbox + + def initialize + @inbox = nil + @mutex = ::Thread::Mutex.new + @task = nil + end + + def task=(task) + @mutex.synchronize { @task = task } + end + + def shutdown + task = nil + + @mutex.synchronize do + task = @task + @task = nil + end + return unless task + + if inbox = @inbox + begin + inbox.push(proc { task.stop }) + rescue ::ClosedQueueError + # Executor already shut down; timer stopped with its reactor. + end + else + task.stop + end + end + end + + private + + def inbox + @inbox || @mutex.synchronize { @inbox ||= start_thread } + end + + def run_timer(task, interval, block) + loop do + task.sleep(interval) + block.call + end + end + + def start_thread + inbox = ::Thread::Queue.new + + @thread = ::Thread.new do + ::Thread.current.name = "async-cable executor" + + Sync do |task| + while operation = inbox.pop + operation.call(task) + end + end + end + + return inbox + end + end + end +end + +# Override the Action Cable server's executor to use the fiber executor instead +# of the thread-pool ThreadedExecutor. Lazy + mutex-guarded, mirroring +# actioncable-next's own ActionCable::Server::Base#executor. +require "action_cable" +module AsyncCableFiberExecutor + def executor + @executor || @mutex.synchronize { @executor ||= Async::Cable::Executor.new } + end +end +ActionCable::Server::Base.prepend(AsyncCableFiberExecutor) diff --git a/cable-bench-falcon/config/initializers/async_cable_fastlane_compat.rb b/cable-bench-falcon/config/initializers/async_cable_fastlane_compat.rb deleted file mode 100644 index 9c5abd0..0000000 --- a/cable-bench-falcon/config/initializers/async_cable_fastlane_compat.rb +++ /dev/null @@ -1,20 +0,0 @@ -# Fastlane broadcasts (config/initializers/action_cable.rb) make -# actioncable-next encode each broadcast once per channel identifier and send -# it through Connection#raw_transmit -> Socket#raw_transmit, bypassing the -# per-subscriber coder. ActionCable::Server::Socket implements #raw_transmit, -# but the Falcon WS adapter Async::Cable::Socket (async-cable 0.3.1) only -# implements #transmit (which JSON-encodes via its coder) and has no -# #raw_transmit. So every fastlane broadcast raises NoMethodError inside the -# stream callback and the client receives nothing (0% delivery). -# -# Add the missing method: push the already-encoded frame straight onto the -# outbound queue, skipping the coder, exactly as -# ActionCable::Server::Socket#raw_transmit sends a pre-encoded message as-is. -# Without this shim, actioncable-next fastlane + async-cable cannot deliver. -if defined?(Async::Cable::Socket) && !Async::Cable::Socket.method_defined?(:raw_transmit) - Async::Cable::Socket.class_eval do - def raw_transmit(data) - @output.push(data) - end - end -end From 8a1b361988b5b12911d525f69617d21230e30c8b Mon Sep 17 00:00:00 2001 From: Irina Nazarova Date: Mon, 29 Jun 2026 18:56:04 -0700 Subject: [PATCH 08/18] Puma target: honor WEB_CONCURRENCY for worker count Stock Rails puma.rb omits the workers directive, so WEB_CONCURRENCY was ignored and the Action Cable target ran a single Puma process regardless of the env var. Set it explicitly so Puma's process count matches the Falcon target's falcon --count for a matched WS-engine comparison. --- cable-bench/config/puma.rb | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/cable-bench/config/puma.rb b/cable-bench/config/puma.rb index 92ca455..f78c735 100644 --- a/cable-bench/config/puma.rb +++ b/cable-bench/config/puma.rb @@ -28,6 +28,13 @@ threads_count = ENV.fetch("RAILS_MAX_THREADS", 3) threads threads_count, threads_count +# Run WEB_CONCURRENCY worker processes (default 1). The stock Rails puma.rb +# omits this directive, so WEB_CONCURRENCY was silently ignored and Puma ran a +# single process regardless. We set it explicitly so the Action Cable target's +# process count matches the Falcon target's `falcon serve --count` for an +# apples-to-apples WS-engine comparison. +workers ENV.fetch("WEB_CONCURRENCY", 1).to_i + # Bind IPv6 dual-stack (`[::]`) rather than the default IPv4 `0.0.0.0`: Railway's # private network routes over IPv6, so internal clients (the bench-runner fleet) # can only reach Puma on `[::]`. On Linux this also accepts IPv4, and it works From 0cf66c37d69b42954105c90ff1b4c23c66c4c570 Mon Sep 17 00:00:00 2001 From: Irina Nazarova Date: Tue, 30 Jun 2026 08:55:41 -0700 Subject: [PATCH 09/18] avalanche-multi: support Rails targets (endpoint + channel/acProtocol/cableUrl) Was hardcoded to /bench-avalanche-socketio with no Rails param passthrough. Now selects /bench-avalanche- (defaults to anycable for rails-* services) and forwards channel/acProtocol/cableUrl, so the deploy-survival test can drive Action Cable / Solid Cable / Async::Cable / AnyCable. --- backend/src/bench/avalanche-multi.ts | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/backend/src/bench/avalanche-multi.ts b/backend/src/bench/avalanche-multi.ts index f5db0fa..b46be3b 100644 --- a/backend/src/bench/avalanche-multi.ts +++ b/backend/src/bench/avalanche-multi.ts @@ -45,6 +45,19 @@ const railwayService = process.env.RAILWAY_SERVICE || "socketio-server"; // regular one (e.g. socketio-server-csr.railway.internal:3000). const serverUrl = process.env.SERVER_URL; +// Protocol selects the bench-runner endpoint (/bench-avalanche-). +// Default to anycable for Rails RPC services, else socketio. The anycable +// endpoint also drives the Rails Action Cable / Solid Cable / Async::Cable +// targets when given channel + acProtocol + cableUrl (base or ext protocol). +const protocol = ( + process.env.PROTOCOL || + (railwayService.startsWith("rails-") ? "anycable" : "socketio") +).toLowerCase(); +// Rails target overrides, forwarded to /bench-avalanche-anycable. +const channel = process.env.CHANNEL; +const acProtocol = process.env.AC_PROTOCOL; +const cableUrl = process.env.CABLE_URL; + const scales = (process.env.SCALES || "1000,2500,5000,10000,20000") .split(",") .map((s) => parseInt(s.trim(), 10)) @@ -103,10 +116,15 @@ async function runOneScale(n: number): Promise { stream: `avalanche-${n}`, }); if (serverUrl) qs.set("serverUrl", serverUrl); + // Rails targets: forward the channel + wire protocol + WS URL so the + // anycable avalanche endpoint connects to the real Rails app. + if (channel) qs.set("channel", channel); + if (acProtocol) qs.set("acProtocol", acProtocol); + if (cableUrl) qs.set("cableUrl", cableUrl); const startedAt = Date.now(); const responsePromise = benchRunnerFetch( - `${benchRunnerUrl}/bench-avalanche-socketio?${qs.toString()}`, + `${benchRunnerUrl}/bench-avalanche-${protocol}?${qs.toString()}`, { method: "POST" } ); From 37287d3b31d6655e0ccaf429ad5ffc4515269ecf Mon Sep 17 00:00:00 2001 From: Irina Nazarova Date: Tue, 30 Jun 2026 10:37:48 -0700 Subject: [PATCH 10/18] Add sharded multi-runner avalanche for AnyCable/Rails targets Adapts avalanche-multi-uws.ts to the /bench-avalanche-anycable endpoint so the post-redeploy reconnect storm is generated across many bench-runners (~250 clients each) instead of one Node process, removing the load-generator limit on the deploy-survival test. Fires one serviceInstanceRedeploy, aggregates time-to-95%-reconnect across shards. prearm/recovery tuned under Railway's 5-min proxy timeout. --- backend/src/bench/avalanche-multi-anycable.ts | 249 ++++++++++++++++++ 1 file changed, 249 insertions(+) create mode 100644 backend/src/bench/avalanche-multi-anycable.ts diff --git a/backend/src/bench/avalanche-multi-anycable.ts b/backend/src/bench/avalanche-multi-anycable.ts new file mode 100644 index 0000000..4e0da25 --- /dev/null +++ b/backend/src/bench/avalanche-multi-anycable.ts @@ -0,0 +1,249 @@ +// Multi-shard avalanche driver for AnyCable / Rails Action Cable targets. +// +// Mirrors avalanche-multi-uws.ts but drives the /bench-avalanche-anycable +// endpoint, so the reconnect storm after a real Railway redeploy is generated +// by MANY bench-runner shards in parallel instead of one (a single Node +// process reconnecting thousands of cables is itself load-generator-limited). +// Each shard ramps PER_SHARD_N clients to the same Rails target (channel + +// acProtocol + cableUrl), then the coordinator fires ONE serviceInstanceRedeploy +// against TARGET_SERVICE_ID; every shard sees the disconnect storm at the same +// wall-clock moment, counts its own reconnects, and we aggregate. +// +// prearm/recovery are kept short by default so the blocking shard request stays +// under Railway's ~5-minute public-proxy timeout (reconnect-to-95% is ~8s in +// practice, so a 120s recovery window is ample). +// +// Usage: +// SHARDS=https://bench-runner-2-production.up.railway.app,... \ +// PER_SHARD_N=250 \ +// CABLE_URL=ws://rails-actioncable.railway.internal:3000/cable \ +// CHANNEL=BenchmarkChannel AC_PROTOCOL=actioncable-v1-json \ +// TARGET_SERVICE_ID= TARGET_ENV_ID= \ +// tsx src/bench/avalanche-multi-anycable.ts + +import { readFileSync, writeFileSync } from "fs"; +import { homedir } from "os"; +import { Agent, setGlobalDispatcher } from "undici"; + +import { percentile } from "../lib/core/stats.js"; +import { resultPath } from "../lib/core/results-dir.js"; + +setGlobalDispatcher( + new Agent({ headersTimeout: 30 * 60 * 1000, bodyTimeout: 30 * 60 * 1000 }) +); + +const shardCsv = process.env.SHARDS; +if (!shardCsv) { + console.error("SHARDS env var is required (comma-separated bench-runner URLs)"); + process.exit(1); +} +const shardUrls = shardCsv.split(",").map((s) => s.trim()).filter(Boolean); +if (shardUrls.length === 0) { + console.error("SHARDS must contain at least one URL"); + process.exit(1); +} + +const perShardN = parseInt(process.env.PER_SHARD_N || "250", 10); +const rampPerSec = parseInt(process.env.RAMP_PER_SEC || "200", 10); +const prearmSec = parseInt(process.env.PREARM_SEC || "60", 10); +const recoveryWaitSec = parseInt(process.env.RECOVERY_WAIT_SEC || "120", 10); + +const cableUrl = process.env.CABLE_URL; +if (!cableUrl) { + console.error("CABLE_URL env var is required"); + process.exit(1); +} +const channel = process.env.CHANNEL; +const acProtocol = process.env.AC_PROTOCOL; + +const targetServiceId = process.env.TARGET_SERVICE_ID; +const targetEnvId = process.env.TARGET_ENV_ID; +if (!targetServiceId || !targetEnvId) { + console.error("TARGET_SERVICE_ID and TARGET_ENV_ID env vars are required"); + process.exit(1); +} + +const bearerToken = process.env.BENCH_RUNNER_TOKEN; + +function readRailwayToken(): string { + if (process.env.RAILWAY_TOKEN) return process.env.RAILWAY_TOKEN; + const cfg = JSON.parse(readFileSync(`${homedir()}/.railway/config.json`, "utf-8")); + return cfg.user.token; +} + +interface AvalancheShardResult { + initiallyConnected: number; + disconnected: number; + reconnected: number; + reconnectRatePct: number; + neverReconnected: number; + recoveryTimeMs: number; + reconnectMs: { p50: number; p95: number; p99: number; max: number }; +} + +const totalTarget = perShardN * shardUrls.length; +const rampSec = Math.ceil(perShardN / rampPerSec); +const shardTimeoutMs = parseInt( + process.env.SHARD_TIMEOUT_MS || + String((rampSec + 5 + prearmSec + recoveryWaitSec + 90) * 1000), + 10 +); + +console.log(`Multi-shard AnyCable/Rails avalanche: ${shardUrls.length} shards × ${perShardN} = ${totalTarget} clients`); +console.log(` ramp: ${rampPerSec}/s per shard (~${rampSec}s)`); +console.log(` prearm: ${prearmSec}s recoveryWait: ${recoveryWaitSec}s shard timeout: ${(shardTimeoutMs / 1000).toFixed(0)}s`); +console.log(` target: ${cableUrl} channel=${channel} proto=${acProtocol}`); +console.log(` redeploy service: ${targetServiceId}\n`); + +interface ShardOutcome { + label: string; + ok: boolean; + reason?: string; + result?: AvalancheShardResult; +} + +async function runShard(url: string, label: string): Promise { + const qs = new URLSearchParams({ + n: String(perShardN), + ramp: String(rampPerSec), + prearm: String(prearmSec), + recoveryWait: String(recoveryWaitSec), + stream: `avalanche-ac-multi-${label}-${Date.now()}`, + cableUrl: cableUrl as string, + }); + if (channel) qs.set("channel", channel); + if (acProtocol) qs.set("acProtocol", acProtocol); + + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), shardTimeoutMs); + try { + const res = await fetch(`${url}/bench-avalanche-anycable?${qs.toString()}`, { + method: "POST", + headers: bearerToken ? { Authorization: `Bearer ${bearerToken}` } : {}, + signal: ctrl.signal, + }); + if (!res.ok) { + console.log(` ✗ ${label}: ${res.status} ${res.statusText}`); + return { label, ok: false, reason: `HTTP ${res.status}` }; + } + const result = (await res.json()) as AvalancheShardResult; + console.log( + ` ✓ ${label}: connected=${result.initiallyConnected} reconnected=${result.reconnected} (${result.reconnectRatePct}%) recovery=${result.recoveryTimeMs}ms` + ); + return { label, ok: true, result }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.log(` ✗ ${label}: ${msg}`); + return { label, ok: false, reason: msg }; + } finally { + clearTimeout(t); + } +} + +async function fireRedeploy(): Promise { + const token = readRailwayToken(); + const res = await fetch("https://backboard.railway.com/graphql/v2", { + method: "POST", + headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, + body: JSON.stringify({ + query: + "mutation R($id: String!, $env: String!) { serviceInstanceRedeploy(serviceId: $id, environmentId: $env) }", + variables: { id: targetServiceId, env: targetEnvId }, + }), + }); + const data = (await res.json()) as { + data?: { serviceInstanceRedeploy?: boolean }; + errors?: unknown[]; + }; + if (data.errors) { + console.error(" ! redeploy mutation failed:", JSON.stringify(data.errors)); + return false; + } + return data.data?.serviceInstanceRedeploy === true; +} + +const startedAt = new Date(); +console.log(`Test started at ${startedAt.toISOString()}\n`); + +const shardPromises = shardUrls.map((u, i) => runShard(u, `shard-${i + 1}`)); + +const triggerInSec = rampSec + 10; +console.log(`(ramping in parallel; firing redeploy in ${triggerInSec}s)`); +await new Promise((r) => setTimeout(r, triggerInSec * 1000)); + +console.log(`\n>>> Firing redeploy on ${targetServiceId}...`); +const ok = await fireRedeploy(); +if (!ok) { + console.error("Redeploy mutation failed; bailing."); + await Promise.allSettled(shardPromises); + process.exit(1); +} +console.log(`Redeploy triggered. Awaiting shard results...\n`); + +const settled = await Promise.allSettled(shardPromises); +const endedAt = new Date(); +console.log(`\nTest ended at ${endedAt.toISOString()}`); +console.log(`Total elapsed: ${((endedAt.getTime() - startedAt.getTime()) / 1000).toFixed(1)}s`); + +const ok_results: AvalancheShardResult[] = []; +const errors: string[] = []; +settled.forEach((s) => { + if (s.status === "fulfilled") { + if (s.value.ok && s.value.result) ok_results.push(s.value.result); + else errors.push(`${s.value.label}: ${s.value.reason}`); + } else { + errors.push(String(s.reason)); + } +}); + +const totals = ok_results.reduce( + (acc, r) => ({ + initiallyConnected: acc.initiallyConnected + r.initiallyConnected, + reconnected: acc.reconnected + r.reconnected, + neverReconnected: acc.neverReconnected + r.neverReconnected, + disconnected: acc.disconnected + r.disconnected, + }), + { initiallyConnected: 0, reconnected: 0, neverReconnected: 0, disconnected: 0 } +); + +const recoveryMs = ok_results.map((r) => r.recoveryTimeMs).sort((a, b) => a - b); +const p50s = ok_results.map((r) => r.reconnectMs.p50).sort((a, b) => a - b); +const p95s = ok_results.map((r) => r.reconnectMs.p95).sort((a, b) => a - b); +const p99s = ok_results.map((r) => r.reconnectMs.p99).sort((a, b) => a - b); + +console.log(`\n=== Aggregate (across ${ok_results.length}/${shardUrls.length} surviving shards) ===`); +console.log(` Target connections: ${totalTarget.toLocaleString()}`); +console.log(` Initially connected: ${totals.initiallyConnected.toLocaleString()}`); +console.log(` Disconnected events: ${totals.disconnected.toLocaleString()}`); +console.log(` Reconnected: ${totals.reconnected.toLocaleString()}`); +console.log( + ` Reconnect rate: ${ + totals.initiallyConnected > 0 + ? ((totals.reconnected / totals.initiallyConnected) * 100).toFixed(2) + : "—" + }%` +); +console.log(` Never reconnected: ${totals.neverReconnected.toLocaleString()}`); +console.log( + ` Recovery (time to 95%): median=${percentile(recoveryMs, 50)}ms p95=${percentile(recoveryMs, 95)}ms max=${percentile(recoveryMs, 100)}ms` +); +console.log( + ` Per-shard reconnect: median p50=${percentile(p50s, 50)} p95=${percentile(p95s, 50)} p99=${percentile(p99s, 50)}` +); + +if (errors.length > 0) { + console.log(`\n${errors.length} shard(s) errored or timed out:`); + for (const e of errors) console.log(` - ${e}`); +} + +const dump = { + startedAt: startedAt.toISOString(), + endedAt: endedAt.toISOString(), + config: { perShardN, rampPerSec, prearmSec, recoveryWaitSec, cableUrl, channel, acProtocol, totalTarget, shards: shardUrls.length }, + totals, + per_shard: ok_results, + errors, +}; +const path = resultPath(`avalanche-multi-anycable-${startedAt.toISOString().replace(/[:.]/g, "-")}.json`); +writeFileSync(path, JSON.stringify(dump, null, 2)); +console.log(`\nWrote ${path}`); From 47a9683112e71bb78d000205d44dd2e705abf724 Mon Sep 17 00:00:00 2001 From: Irina Nazarova Date: Tue, 30 Jun 2026 14:45:37 -0700 Subject: [PATCH 11/18] Configurable client reconnect backoff (reconnectBaseMs) The @anycable/core client's default reconnect backoff is multi-second, so the resume-tail p99 after a transient drop is dominated by reconnect wait, not server delivery. Add reconnectBaseMs (job param + RECONNECT_BASE_MS env): first reconnect fires in ~base ms (then x2 up to 5s). Set ~200 to collapse the tail. --- backend/src/bench-runner/server.ts | 6 ++++++ backend/src/bench/jitter-multi.ts | 13 +++++++++++++ backend/src/lib/jitter-runners.ts | 16 +++++++++++++++- 3 files changed, 34 insertions(+), 1 deletion(-) diff --git a/backend/src/bench-runner/server.ts b/backend/src/bench-runner/server.ts index 88b833b..c1bb360 100644 --- a/backend/src/bench-runner/server.ts +++ b/backend/src/bench-runner/server.ts @@ -208,6 +208,11 @@ app.post("/bench-jitter-anycable", async (req, res) => { // $pubsub channel over the extended protocol. const channel = (req.query.channel as string) || undefined; const acProtocol = (req.query.acProtocol as string) || undefined; + // `?reconnectBaseMs=200` makes the client's first reconnect fire in ~200ms + // (vs the multi-second @anycable/core default), shrinking the resume-tail p99. + const reconnectBaseMs = req.query.reconnectBaseMs + ? parseInt(req.query.reconnectBaseMs as string, 10) + : undefined; await respondAsync(req, res, () => runJitterAnycable(params, { cableUrl, @@ -215,6 +220,7 @@ app.post("/bench-jitter-anycable", async (req, res) => { broadcastSecret: ANYCABLE_BROADCAST_SECRET || undefined, channel, acProtocol, + reconnectBaseMs, }), ); }); diff --git a/backend/src/bench/jitter-multi.ts b/backend/src/bench/jitter-multi.ts index 00a82f0..82d2ecc 100644 --- a/backend/src/bench/jitter-multi.ts +++ b/backend/src/bench/jitter-multi.ts @@ -71,6 +71,7 @@ const PROTOCOL_TO_ENDPOINT: Record = { socketio: "bench-jitter-socketio", "socketio-csr": "bench-jitter-socketio-csr", uws: "bench-jitter-uws", + centrifugo: "bench-jitter-centrifugo", }; const endpoint = PROTOCOL_TO_ENDPOINT[protocol]; if (!endpoint) { @@ -91,6 +92,10 @@ if (protocol === "anycable") { // Action Cable wire protocol; the nodejs $pubsub targets leave these unset. if (process.env.CHANNEL) protocolQuery.channel = process.env.CHANNEL; if (process.env.AC_PROTOCOL) protocolQuery.acProtocol = process.env.AC_PROTOCOL; + // Faster client reconnect (first attempt ~RECONNECT_BASE_MS) to shrink the + // resume-tail p99 after transient drops. + if (process.env.RECONNECT_BASE_MS) + protocolQuery.reconnectBaseMs = process.env.RECONNECT_BASE_MS; } if (protocol === "socketio" || protocol === "socketio-csr") { if (process.env.SERVER_URL) protocolQuery.serverUrl = process.env.SERVER_URL; @@ -99,6 +104,14 @@ if (protocol === "uws") { if (process.env.UWS_WS_URL) protocolQuery.wsUrl = process.env.UWS_WS_URL; if (process.env.UWS_HTTP_URL) protocolQuery.httpUrl = process.env.UWS_HTTP_URL; } +if (protocol === "centrifugo") { + if (process.env.CENTRIFUGO_WS_URL) protocolQuery.wsUrl = process.env.CENTRIFUGO_WS_URL; + if (process.env.CENTRIFUGO_HTTP_URL) protocolQuery.httpUrl = process.env.CENTRIFUGO_HTTP_URL; + if (process.env.CENTRIFUGO_API_KEY) protocolQuery.apiKey = process.env.CENTRIFUGO_API_KEY; + if (process.env.CENTRIFUGO_TOKEN_SECRET) + protocolQuery.tokenSecret = process.env.CENTRIFUGO_TOKEN_SECRET; + if (process.env.NAMESPACE) protocolQuery.namespace = process.env.NAMESPACE; +} console.log( `Multi-shard jitter: ${shardUrls.length} shards × ${perShardN} = ${shardUrls.length * perShardN} clients (target: ${totalClients})`, diff --git a/backend/src/lib/jitter-runners.ts b/backend/src/lib/jitter-runners.ts index c37d849..a2a694f 100644 --- a/backend/src/lib/jitter-runners.ts +++ b/backend/src/lib/jitter-runners.ts @@ -10,7 +10,7 @@ // 4. After publishing finishes (or `durationSec` elapses), summarizes. import WebSocket from "ws"; -import { createCable } from "@anycable/core"; +import { createCable, backoffWithJitter } from "@anycable/core"; import { io as ioClient, Socket } from "socket.io-client"; import { ClientStat, JitterResult, newStat, recordMsg, summarize } from "./core/stats.js"; @@ -127,6 +127,11 @@ export interface AnycableUrls { // resume machinery; vanilla Action Cable and Solid Cable speak the base // protocol ("actioncable-v1-json"). acProtocol?: string; + // Base delay (ms) for the client's reconnect backoff. When set, the first + // reconnect fires in ~reconnectBaseMs (then exponential x2 up to 5s) instead + // of @anycable/core's multi-second default. Smaller values shrink the + // resume-tail p99 after a transient drop (the tail = drop + reconnect delay). + reconnectBaseMs?: number; } export async function runJitterAnycable( @@ -151,6 +156,15 @@ export async function runJitterAnycable( // The @anycable/core types don't include "error" yet; the runtime // accepts any of error|warn|info|debug. logLevel: "error" as never, + ...(urls.reconnectBaseMs && urls.reconnectBaseMs > 0 + ? { + reconnectStrategy: backoffWithJitter(urls.reconnectBaseMs, { + backoffRate: 2, + jitterRatio: 0.2, + maxInterval: 5000, + }), + } + : {}), }); cable.on("close", () => {}); cable.on("disconnect", () => {}); From f862cc90c3b390d006b54cebcf9cef55c9eb3828 Mon Sep 17 00:00:00 2001 From: Irina Nazarova Date: Tue, 30 Jun 2026 19:22:44 -0700 Subject: [PATCH 12/18] Jitter: enforce a real offline window (standard 2s network drop) The AnyCable jitter loop force-closed the socket then waited jitterDurationMs, during which the client's Monitor reconnected on its backoff -> the offline period was the backoff delay, not a fixed outage, so delivery depended on the client's reconnect config. Now re-terminate any reconnect until the window elapses, so it measures a standard 2s drop; the client backoff only governs recovery speed after the outage. --- backend/src/bench/mem-probe.ts | 36 +++++++++++++++++++++++++++++ backend/src/lib/core/railway-api.ts | 2 +- backend/src/lib/jitter-runners.ts | 18 ++++++++++++--- 3 files changed, 52 insertions(+), 4 deletions(-) create mode 100644 backend/src/bench/mem-probe.ts diff --git a/backend/src/bench/mem-probe.ts b/backend/src/bench/mem-probe.ts new file mode 100644 index 0000000..d24db37 --- /dev/null +++ b/backend/src/bench/mem-probe.ts @@ -0,0 +1,36 @@ +// Standalone Railway memory probe (avoids idle-multi's undici dispatcher gzip +// bug). Prints peak MEMORY_USAGE_GB for a service over the last WINDOW_MIN +// minutes. Usage: +// SERVICE_ID= [WINDOW_MIN=10] tsx src/bench/mem-probe.ts +import { fetchMetric, readRailwayToken } from "../lib/core/railway-api.js"; + +const projectId = process.env.PROJECT_ID || "fd842a43-8d78-48c0-879f-4b5311c8c004"; +const serviceId = process.env.SERVICE_ID; +if (!serviceId) { + console.error("SERVICE_ID required"); + process.exit(1); +} +const windowMin = parseInt(process.env.WINDOW_MIN || "10", 10); +const end = new Date(); +const start = new Date(end.getTime() - windowMin * 60 * 1000); + +const token = readRailwayToken(); +const points = await fetchMetric({ + token, + projectId, + serviceId, + startDate: start.toISOString(), + endDate: end.toISOString(), + measurement: "MEMORY_USAGE_GB", + sampleRate: 15, +}); +if (!points.length) { + console.log("no data"); + process.exit(0); +} +const vals = points.map((p) => p.value); +const peak = Math.max(...vals); +const last = vals[vals.length - 1]; +console.log( + `samples=${vals.length} peakGB=${peak.toFixed(3)} lastGB=${last.toFixed(3)} minGB=${Math.min(...vals).toFixed(3)}` +); diff --git a/backend/src/lib/core/railway-api.ts b/backend/src/lib/core/railway-api.ts index f8cb1db..3fd613c 100644 --- a/backend/src/lib/core/railway-api.ts +++ b/backend/src/lib/core/railway-api.ts @@ -59,7 +59,7 @@ export async function fetchMetric(args: FetchMetricArgs): Promise { start: args.startDate, end: args.endDate, measurement: args.measurement, - sampleRate: args.sampleRate ?? 30, + sampleRate: args.sampleRate ?? 60, }, }), }); diff --git a/backend/src/lib/jitter-runners.ts b/backend/src/lib/jitter-runners.ts index a2a694f..1d1076e 100644 --- a/backend/src/lib/jitter-runners.ts +++ b/backend/src/lib/jitter-runners.ts @@ -214,9 +214,21 @@ export async function runJitterAnycable( if (terminateCableWs(cable)) { stat.jitterCount++; } - // Hold the "offline" window. Reconnect attempts may fire - // during or after this window — that's the system under test. - await new Promise((r) => setTimeout(r, p.jitterDurationMs)); + // Enforce a real ~jitterDurationMs network outage. The Monitor + // reconnects on its own backoff, so without this the offline + // window would be the backoff delay, not a fixed outage — making + // the delivery number depend on the client's reconnect config + // rather than measuring a standard 2s network drop. Re-terminate + // any socket that comes back up until the window elapses, keeping + // the client offline for the full duration regardless of backoff. + // After the window we stop and let it reconnect + resume, so the + // client's (fast) backoff only governs how quickly it recovers, + // not how much it loses. + const offlineUntil = Date.now() + p.jitterDurationMs; + while (Date.now() < offlineUntil) { + await new Promise((r) => setTimeout(r, 50)); + terminateCableWs(cable); + } next = Date.now() + (p.jitterIntervalSec + Math.random() * 5) * 1000; } await new Promise((r) => setTimeout(r, 500)); From 7b753c4def4f3a350cebdc06932702ec819339c9 Mon Sep 17 00:00:00 2001 From: Irina Nazarova Date: Tue, 30 Jun 2026 19:44:24 -0700 Subject: [PATCH 13/18] Jitter: clean fixed-length outage via cable.disconnect()/connect() Re-terminating fought the Monitor (flapping reconnects + backoff escalation). Instead cable.disconnect() emits close -> Monitor cancels reconnect, client stays cleanly offline for the outage window (sid retained), then connect() reconnects once and AnyCable resumes. Outage length is now fixed and backoff-independent, a true standard network drop. --- backend/src/lib/jitter-runners.ts | 30 ++++++++++++------------------ 1 file changed, 12 insertions(+), 18 deletions(-) diff --git a/backend/src/lib/jitter-runners.ts b/backend/src/lib/jitter-runners.ts index 1d1076e..97d8e7c 100644 --- a/backend/src/lib/jitter-runners.ts +++ b/backend/src/lib/jitter-runners.ts @@ -211,24 +211,18 @@ export async function runJitterAnycable( // a connection. If the cable is already mid-reconnect (no `ws` // ref), we skip the count so csrResumeRatePct denominators stay // honest. - if (terminateCableWs(cable)) { - stat.jitterCount++; - } - // Enforce a real ~jitterDurationMs network outage. The Monitor - // reconnects on its own backoff, so without this the offline - // window would be the backoff delay, not a fixed outage — making - // the delivery number depend on the client's reconnect config - // rather than measuring a standard 2s network drop. Re-terminate - // any socket that comes back up until the window elapses, keeping - // the client offline for the full duration regardless of backoff. - // After the window we stop and let it reconnect + resume, so the - // client's (fast) backoff only governs how quickly it recovers, - // not how much it loses. - const offlineUntil = Date.now() + p.jitterDurationMs; - while (Date.now() < offlineUntil) { - await new Promise((r) => setTimeout(r, 50)); - terminateCableWs(cable); - } + // Simulate a standard ~jitterDurationMs network outage. Cleanly + // take the cable offline: cable.disconnect() emits `close`, which + // makes the Monitor CANCEL (not schedule) reconnect, so the client + // stays offline for exactly the outage window regardless of its + // reconnect backoff. The session id is retained (never cleared), so + // on reconnect AnyCable resumes the messages broadcast during the + // outage; at-most-once adapters simply lose them. Backoff only + // governs recovery speed elsewhere, not the outage length here. + stat.jitterCount++; + cable.disconnect(); + await new Promise((r) => setTimeout(r, p.jitterDurationMs)); + cable.connect().catch(() => {}); next = Date.now() + (p.jitterIntervalSec + Math.random() * 5) * 1000; } await new Promise((r) => setTimeout(r, 500)); From 568163f815f401cdb32d6cc2e6e3f6b57be05897 Mon Sep 17 00:00:00 2001 From: Irina Nazarova Date: Tue, 30 Jun 2026 20:20:04 -0700 Subject: [PATCH 14/18] Native JS client per adapter: @rails/actioncable for Action Cable family Add @rails/actioncable and a clientLib=actioncable path in the jitter runner so Action Cable / Solid Cable / Async::Cable are driven by the official Rails client (base protocol, its own reconnect monitor, no resume), while AnyCable keeps @anycable/core (extended protocol, resume). Realistic per-server client instead of using @anycable/core for everything. --- backend/package-lock.json | 113 ++++++++++++++++++ backend/package.json | 4 + backend/src/bench-runner/server.ts | 5 + backend/src/bench/jitter-multi.ts | 3 + backend/src/lib/jitter-runners.ts | 144 ++++++++++++++--------- backend/src/types/rails-actioncable.d.ts | 20 ++++ 6 files changed, 236 insertions(+), 53 deletions(-) create mode 100644 backend/src/types/rails-actioncable.d.ts diff --git a/backend/package-lock.json b/backend/package-lock.json index ab259a2..549232c 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -9,8 +9,10 @@ "version": "1.0.0", "dependencies": { "@anycable/core": "^1.1.6", + "@rails/actioncable": "^8.1.300", "@socket.io/redis-adapter": "^8.3.0", "@socket.io/redis-streams-adapter": "^0.3.1", + "centrifuge": "^5.7.0", "express": "^4.21.0", "ioredis": "^5.10.1", "nats": "^2.29.3", @@ -497,6 +499,69 @@ "node": ">= 10" } }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", + "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", + "license": "BSD-3-Clause" + }, + "node_modules/@rails/actioncable": { + "version": "8.1.300", + "resolved": "https://registry.npmjs.org/@rails/actioncable/-/actioncable-8.1.300.tgz", + "integrity": "sha512-zOENQsq3NM2jyBY6Z2qtZa3V/R/6OEqA+LGKixQbBMl7kk/J3FXDRcszPe74LsHNgB01jCl/DXu/xA8sHt4I/g==", + "license": "MIT" + }, "node_modules/@socket.io/component-emitter": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", @@ -807,6 +872,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/centrifuge": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/centrifuge/-/centrifuge-5.7.0.tgz", + "integrity": "sha512-Ptx7ELyVc7/KgzpadVlISTtdTWsuzumze5/vo9sH4RsvtFulJJMhmKr/cNDg6se1eKKbS6ZywIBl4eSZxqY3fw==", + "license": "MIT", + "dependencies": { + "events": "^3.3.0", + "protobufjs": "^7.6.0" + } + }, "node_modules/cluster-key-slot": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", @@ -1111,6 +1186,15 @@ "node": ">= 0.6" } }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, "node_modules/express": { "version": "4.22.2", "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", @@ -1396,6 +1480,12 @@ "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", "license": "MIT" }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -1568,6 +1658,29 @@ "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", "license": "MIT" }, + "node_modules/protobufjs": { + "version": "7.6.4", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.4.tgz", + "integrity": "sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", diff --git a/backend/package.json b/backend/package.json index fb52b33..aee9e29 100644 --- a/backend/package.json +++ b/backend/package.json @@ -11,6 +11,8 @@ "dev:socketio-csr": "SOCKETIO_CSR=1 tsx src/socketio/server.ts", "dev:uws": "tsx src/uws/server.ts", "dev:bench-runner": "tsx src/bench-runner/server.ts", + "dev:centrifugo": "docker run --rm -p 8000:8000 -v $PWD/../centrifugo/config.json:/centrifugo/config.json:ro centrifugo/centrifugo:v6.2.2 centrifugo -c /centrifugo/config.json", + "smoke:centrifugo": "tsx src/bench/smoke-centrifugo.ts", "bench:jitter:socketio": "tsx src/bench/jitter-socketio.ts", "bench:jitter:socketio-csr": "tsx src/bench/jitter-socketio-csr.ts", "bench:jitter:anycable": "tsx src/bench/jitter-anycable.ts", @@ -41,8 +43,10 @@ }, "dependencies": { "@anycable/core": "^1.1.6", + "@rails/actioncable": "^8.1.300", "@socket.io/redis-adapter": "^8.3.0", "@socket.io/redis-streams-adapter": "^0.3.1", + "centrifuge": "^5.7.0", "express": "^4.21.0", "ioredis": "^5.10.1", "nats": "^2.29.3", diff --git a/backend/src/bench-runner/server.ts b/backend/src/bench-runner/server.ts index c1bb360..703b19b 100644 --- a/backend/src/bench-runner/server.ts +++ b/backend/src/bench-runner/server.ts @@ -213,6 +213,10 @@ app.post("/bench-jitter-anycable", async (req, res) => { const reconnectBaseMs = req.query.reconnectBaseMs ? parseInt(req.query.reconnectBaseMs as string, 10) : undefined; + // `?clientLib=actioncable` drives the official @rails/actioncable client + // (for Action Cable / Solid Cable / Async::Cable); default @anycable/core. + const clientLib = + (req.query.clientLib as string) === "actioncable" ? "actioncable" : undefined; await respondAsync(req, res, () => runJitterAnycable(params, { cableUrl, @@ -221,6 +225,7 @@ app.post("/bench-jitter-anycable", async (req, res) => { channel, acProtocol, reconnectBaseMs, + clientLib, }), ); }); diff --git a/backend/src/bench/jitter-multi.ts b/backend/src/bench/jitter-multi.ts index 82d2ecc..583ca93 100644 --- a/backend/src/bench/jitter-multi.ts +++ b/backend/src/bench/jitter-multi.ts @@ -96,6 +96,9 @@ if (protocol === "anycable") { // resume-tail p99 after transient drops. if (process.env.RECONNECT_BASE_MS) protocolQuery.reconnectBaseMs = process.env.RECONNECT_BASE_MS; + // Drive the official @rails/actioncable client instead of @anycable/core + // (for Action Cable / Solid Cable / Async::Cable targets). + if (process.env.CLIENT_LIB) protocolQuery.clientLib = process.env.CLIENT_LIB; } if (protocol === "socketio" || protocol === "socketio-csr") { if (process.env.SERVER_URL) protocolQuery.serverUrl = process.env.SERVER_URL; diff --git a/backend/src/lib/jitter-runners.ts b/backend/src/lib/jitter-runners.ts index 97d8e7c..2f81225 100644 --- a/backend/src/lib/jitter-runners.ts +++ b/backend/src/lib/jitter-runners.ts @@ -11,6 +11,15 @@ import WebSocket from "ws"; import { createCable, backoffWithJitter } from "@anycable/core"; +import * as ActionCable from "@rails/actioncable"; + +// @rails/actioncable is browser-oriented; give it a WebSocket implementation +// so the official Rails client runs under Node. Used for the Action Cable / +// Solid Cable / Async::Cable targets (clientLib="actioncable") so the bench +// exercises the client a real Rails app ships with — its own reconnect monitor +// and no resume — while AnyCable keeps @anycable/core (extended protocol). +(ActionCable.adapters as { WebSocket: unknown }).WebSocket = + WebSocket as unknown; import { io as ioClient, Socket } from "socket.io-client"; import { ClientStat, JitterResult, newStat, recordMsg, summarize } from "./core/stats.js"; @@ -131,7 +140,13 @@ export interface AnycableUrls { // reconnect fires in ~reconnectBaseMs (then exponential x2 up to 5s) instead // of @anycable/core's multi-second default. Smaller values shrink the // resume-tail p99 after a transient drop (the tail = drop + reconnect delay). + // Only applies to the @anycable/core client. reconnectBaseMs?: number; + // Which JS client to drive with. "anycable" (default) = @anycable/core + // (extended protocol, resume) for the AnyCable target. "actioncable" = + // @rails/actioncable (the official Rails client, base protocol, no resume) + // for the Action Cable / Solid Cable / Async::Cable targets. + clientLib?: "anycable" | "actioncable"; } export async function runJitterAnycable( @@ -144,41 +159,76 @@ export async function runJitterAnycable( const rss = trackPeakRss(); const stats: ClientStat[] = []; - const cables: ReturnType[] = []; + // Unified control surface over the two client libraries so the jitter loop + // and teardown stay client-agnostic. disconnect()/connect() take the client + // cleanly offline and back — a standard fixed-length outage regardless of + // the client's own backoff. + interface JitterConn { + disconnect(): void; + connect(): void; + } + const conns: JitterConn[] = []; + const useActionCable = urls.clientLib === "actioncable"; for (let i = 0; i < p.n; i++) { const stat = newStat(); stats.push(stat); - const cable = createCable(urls.cableUrl, { - websocketImplementation: WebSocket as unknown as typeof globalThis.WebSocket, - protocol: (urls.acProtocol ?? "actioncable-v1-ext-json") as never, - // The @anycable/core types don't include "error" yet; the runtime - // accepts any of error|warn|info|debug. - logLevel: "error" as never, - ...(urls.reconnectBaseMs && urls.reconnectBaseMs > 0 - ? { - reconnectStrategy: backoffWithJitter(urls.reconnectBaseMs, { - backoffRate: 2, - jitterRatio: 0.2, - maxInterval: 5000, - }), - } - : {}), - }); - cable.on("close", () => {}); - cable.on("disconnect", () => {}); - cable.on("connect", () => { - stat.everConnected = true; - }); - // "$pubsub" -> anycable-go's signed pub/sub channel (streamFrom). Any - // other value -> a real Rails channel subscribed with { stream_name }. - const channel = - urls.channel && urls.channel !== "$pubsub" - ? cable.subscribeTo(urls.channel, { stream_name: p.stream }) - : cable.streamFrom(p.stream); - channel.on("message", (msg: unknown) => recordMsg(stat, msg)); - cables.push(cable); + if (useActionCable) { + // Official Rails client. createConsumer connects lazily; the + // subscription re-establishes on reconnect (no resume, base protocol). + const consumer = ActionCable.createConsumer(urls.cableUrl); + consumer.subscriptions.create( + { channel: urls.channel ?? "BenchmarkChannel", stream_name: p.stream }, + { + connected() { + stat.everConnected = true; + }, + received(data: unknown) { + recordMsg(stat, data); + }, + } + ); + conns.push({ + disconnect: () => consumer.disconnect(), + connect: () => consumer.connect(), + }); + } else { + const cable = createCable(urls.cableUrl, { + websocketImplementation: WebSocket as unknown as typeof globalThis.WebSocket, + protocol: (urls.acProtocol ?? "actioncable-v1-ext-json") as never, + // The @anycable/core types don't include "error" yet; the runtime + // accepts any of error|warn|info|debug. + logLevel: "error" as never, + ...(urls.reconnectBaseMs && urls.reconnectBaseMs > 0 + ? { + reconnectStrategy: backoffWithJitter(urls.reconnectBaseMs, { + backoffRate: 2, + jitterRatio: 0.2, + maxInterval: 5000, + }), + } + : {}), + }); + cable.on("close", () => {}); + cable.on("disconnect", () => {}); + cable.on("connect", () => { + stat.everConnected = true; + }); + // "$pubsub" -> anycable-go's signed pub/sub channel (streamFrom). Any + // other value -> a real Rails channel subscribed with { stream_name }. + const channel = + urls.channel && urls.channel !== "$pubsub" + ? cable.subscribeTo(urls.channel, { stream_name: p.stream }) + : cable.streamFrom(p.stream); + channel.on("message", (msg: unknown) => recordMsg(stat, msg)); + conns.push({ + disconnect: () => cable.disconnect(), + connect: () => { + cable.connect().catch(() => {}); + }, + }); + } await maybePauseForRamp(p, i, "jitter-ac"); } @@ -195,34 +245,22 @@ export async function runJitterAnycable( }); const endAt = Date.now() + p.durationSec * 1000; - const jitterTasks = cables.map((cable, i) => + const jitterTasks = conns.map((conn, i) => (async () => { const stat = stats[i]; let next = Date.now() + (5 + Math.random() * p.jitterIntervalSec) * 1000; while (Date.now() < endAt) { if (Date.now() >= next) { - // Force-close the underlying TCP socket — same semantics as - // the Socket.io test (raw.terminate()). The cable's Monitor - // detects the close and reconnects with its built-in backoff, - // mirroring socket.io-client's retry path. Don't call - // cable.connect() manually — let the reconnect machinery run. - // - // Only count the jitter event when terminate actually severed - // a connection. If the cable is already mid-reconnect (no `ws` - // ref), we skip the count so csrResumeRatePct denominators stay - // honest. - // Simulate a standard ~jitterDurationMs network outage. Cleanly - // take the cable offline: cable.disconnect() emits `close`, which - // makes the Monitor CANCEL (not schedule) reconnect, so the client - // stays offline for exactly the outage window regardless of its - // reconnect backoff. The session id is retained (never cleared), so - // on reconnect AnyCable resumes the messages broadcast during the - // outage; at-most-once adapters simply lose them. Backoff only - // governs recovery speed elsewhere, not the outage length here. + // Simulate a standard ~jitterDurationMs network outage. disconnect() + // takes the client cleanly offline (its reconnect monitor is stopped, + // so the outage length is fixed regardless of backoff); after the + // window connect() brings it back. AnyCable resumes the messages + // broadcast during the outage (session id retained), the + // @rails/actioncable at-most-once clients simply lose them. stat.jitterCount++; - cable.disconnect(); + conn.disconnect(); await new Promise((r) => setTimeout(r, p.jitterDurationMs)); - cable.connect().catch(() => {}); + conn.connect(); next = Date.now() + (p.jitterIntervalSec + Math.random() * 5) * 1000; } await new Promise((r) => setTimeout(r, 500)); @@ -232,9 +270,9 @@ export async function runJitterAnycable( await Promise.all([publishTask, ...jitterTasks]); - for (const c of cables) { + for (const conn of conns) { try { - c.disconnect(); + conn.disconnect(); } catch { /* tear-down errors are not interesting */ } diff --git a/backend/src/types/rails-actioncable.d.ts b/backend/src/types/rails-actioncable.d.ts new file mode 100644 index 0000000..0b7cb08 --- /dev/null +++ b/backend/src/types/rails-actioncable.d.ts @@ -0,0 +1,20 @@ +// Minimal ambient declaration for the official Rails Action Cable JS client. +// We only use createConsumer(...) and adapters.WebSocket in the bench runner. +declare module "@rails/actioncable" { + export const adapters: { WebSocket: unknown; logger: unknown }; + export interface Subscription { + unsubscribe(): void; + } + export interface Subscriptions { + create( + params: string | Record, + mixin?: Record, + ): Subscription; + } + export interface Consumer { + subscriptions: Subscriptions; + connect(): void; + disconnect(): void; + } + export function createConsumer(url?: string): Consumer; +} From 2883eb43f3b15789dec24e0ffa45dc8f46326bdb Mon Sep 17 00:00:00 2001 From: Irina Nazarova Date: Tue, 30 Jun 2026 20:30:27 -0700 Subject: [PATCH 15/18] Stub browser globals so @rails/actioncable runs under Node Its ConnectionMonitor calls addEventListener/removeEventListener and reads document.visibilityState, which don't exist in Node -> ReferenceError. Provide no-op stubs before the client loads. --- backend/src/lib/jitter-runners.ts | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/backend/src/lib/jitter-runners.ts b/backend/src/lib/jitter-runners.ts index 2f81225..9a626a5 100644 --- a/backend/src/lib/jitter-runners.ts +++ b/backend/src/lib/jitter-runners.ts @@ -14,10 +14,25 @@ import { createCable, backoffWithJitter } from "@anycable/core"; import * as ActionCable from "@rails/actioncable"; // @rails/actioncable is browser-oriented; give it a WebSocket implementation -// so the official Rails client runs under Node. Used for the Action Cable / -// Solid Cable / Async::Cable targets (clientLib="actioncable") so the bench -// exercises the client a real Rails app ships with — its own reconnect monitor -// and no resume — while AnyCable keeps @anycable/core (extended protocol). +// and stub the browser globals its ConnectionMonitor touches (addEventListener +// for online/offline + visibility events, document.visibilityState) so the +// official Rails client runs under Node. Used for the Action Cable / Solid +// Cable / Async::Cable targets (clientLib="actioncable") so the bench exercises +// the client a real Rails app ships with — its own reconnect monitor and no +// resume — while AnyCable keeps @anycable/core (extended protocol). +{ + const g = globalThis as unknown as Record; + if (typeof g.addEventListener !== "function") g.addEventListener = () => {}; + if (typeof g.removeEventListener !== "function") + g.removeEventListener = () => {}; + if (typeof g.document === "undefined") { + g.document = { + visibilityState: "visible", + addEventListener: () => {}, + removeEventListener: () => {}, + }; + } +} (ActionCable.adapters as { WebSocket: unknown }).WebSocket = WebSocket as unknown; import { io as ioClient, Socket } from "socket.io-client"; From 649525560d32e98340c5a43ad90e2f91784f04df Mon Sep 17 00:00:00 2001 From: Irina Nazarova Date: Tue, 30 Jun 2026 22:41:22 -0700 Subject: [PATCH 16/18] Native @rails/actioncable in jitter (native drop) + avalanche; shared Node shim Jitter: Action Cable family now drops the socket uncleanly and recovers on the official client's own poll-based monitor (native, seconds) rather than a forced immediate reconnect. Avalanche: same clientLib branch so deploy-survival uses each server's real client. Extract the Node WebSocket+globals shim to a shared module. --- backend/src/bench-runner/server.ts | 4 +- backend/src/bench/avalanche-multi-anycable.ts | 2 + backend/src/lib/avalanche-anycable-runner.ts | 116 +++++++++++------- backend/src/lib/core/actioncable-node.ts | 24 ++++ backend/src/lib/jitter-runners.ts | 43 +++---- 5 files changed, 117 insertions(+), 72 deletions(-) create mode 100644 backend/src/lib/core/actioncable-node.ts diff --git a/backend/src/bench-runner/server.ts b/backend/src/bench-runner/server.ts index 703b19b..9c1ed77 100644 --- a/backend/src/bench-runner/server.ts +++ b/backend/src/bench-runner/server.ts @@ -418,11 +418,13 @@ app.post("/bench-avalanche-anycable", async (req, res) => { const cableUrl = (req.query.cableUrl as string) || ANYCABLE_URL; const channel = (req.query.channel as string) || undefined; const acProtocol = (req.query.acProtocol as string) || undefined; + const clientLib = + (req.query.clientLib as string) === "actioncable" ? "actioncable" : undefined; await respondAsync(req, res, () => runAvalancheAnycable( { n, rampPerSec, prearmSec, recoveryWaitSec, stream }, - { cableUrl, channel, acProtocol }, + { cableUrl, channel, acProtocol, clientLib }, ), ); }); diff --git a/backend/src/bench/avalanche-multi-anycable.ts b/backend/src/bench/avalanche-multi-anycable.ts index 4e0da25..62c21f3 100644 --- a/backend/src/bench/avalanche-multi-anycable.ts +++ b/backend/src/bench/avalanche-multi-anycable.ts @@ -55,6 +55,7 @@ if (!cableUrl) { } const channel = process.env.CHANNEL; const acProtocol = process.env.AC_PROTOCOL; +const clientLib = process.env.CLIENT_LIB; const targetServiceId = process.env.TARGET_SERVICE_ID; const targetEnvId = process.env.TARGET_ENV_ID; @@ -113,6 +114,7 @@ async function runShard(url: string, label: string): Promise { }); if (channel) qs.set("channel", channel); if (acProtocol) qs.set("acProtocol", acProtocol); + if (clientLib) qs.set("clientLib", clientLib); const ctrl = new AbortController(); const t = setTimeout(() => ctrl.abort(), shardTimeoutMs); diff --git a/backend/src/lib/avalanche-anycable-runner.ts b/backend/src/lib/avalanche-anycable-runner.ts index ad5b475..8e1d897 100644 --- a/backend/src/lib/avalanche-anycable-runner.ts +++ b/backend/src/lib/avalanche-anycable-runner.ts @@ -20,12 +20,17 @@ import { createCable } from "@anycable/core"; import { percentile } from "./core/stats.js"; import { settleAfterRamp } from "./core/timing.js"; +import { ActionCable } from "./core/actioncable-node.js"; import type { AvalancheParams, AvalancheResult } from "./avalanche-runner.js"; export interface AvalancheAnycableUrls { cableUrl: string; channel?: string; acProtocol?: string; + // "actioncable" drives the official @rails/actioncable client (native + // reconnect, base protocol) for Action Cable / Solid Cable / Async::Cable; + // default @anycable/core for AnyCable. + clientLib?: "anycable" | "actioncable"; } export async function runAvalancheAnycable( @@ -38,7 +43,8 @@ export async function runAvalancheAnycable( `[avalanche-ac] target=${urls.cableUrl} channel=${channelName} proto=${protocol} n=${p.n} ramp=${p.rampPerSec}/s prearm=${p.prearmSec}s recoveryWait=${p.recoveryWaitSec}s` ); - const cables: ReturnType[] = []; + const conns: { disconnect(): void }[] = []; + const useActionCable = urls.clientLib === "actioncable"; const startedAt = Date.now(); let initiallyConnected = 0; @@ -55,53 +61,73 @@ export async function runAvalancheAnycable( let allReconnectedAt = 0; const reconnectTimes: number[] = []; - for (let i = 0; i < p.n; i++) { - const cable = createCable(urls.cableUrl, { - websocketImplementation: WebSocket as unknown as typeof globalThis.WebSocket, - protocol: protocol as never, - logLevel: "error" as never, - }); - - cable.on("connect", (event?: { reconnect?: boolean }) => { - if (tearingDown) return; - // First successful connect during the ramp. - if (!initialConnectDone) { - if (!(event && event.reconnect)) initiallyConnected++; - return; - } - // After ramp, a connect once a restart was detected is a recovery. - if (restartDetectedAt > 0) { - reconnected++; - const now = Date.now(); - reconnectTimes.push(now - restartDetectedAt); - if (reconnected === 1) firstReconnectAt = now; - if (reconnected >= initiallyConnected * 0.95 && !allReconnectedAt) { - allReconnectedAt = now; - } - } - }); - - const onDrop = () => { - if (tearingDown || !initialConnectDone) return; - disconnected++; + // Shared connect/disconnect accounting for both client libraries. A connect + // during the ramp counts an initial connection; a connect after a detected + // restart counts a recovery (and its time-to-reconnect). + const handleConnect = (isReconnect: boolean) => { + if (tearingDown) return; + if (!initialConnectDone) { + if (!isReconnect) initiallyConnected++; + return; + } + if (restartDetectedAt > 0) { + reconnected++; const now = Date.now(); - if (disconnected === 1) { - firstDisconnectAt = now; - restartDetectedAt = now; + reconnectTimes.push(now - restartDetectedAt); + if (reconnected === 1) firstReconnectAt = now; + if (reconnected >= initiallyConnected * 0.95 && !allReconnectedAt) { + allReconnectedAt = now; } - if (disconnected === initiallyConnected) allDisconnectedAt = now; - }; - cable.on("disconnect", onDrop); - cable.on("close", onDrop); - - // Subscribing triggers the connection. "$pubsub" -> streamFrom (signed - // pub/sub), any other channel -> a real Rails channel with { stream_name }. - if (channelName !== "$pubsub") { - cable.subscribeTo(channelName, { stream_name: p.stream }); + } + }; + const handleDrop = () => { + if (tearingDown || !initialConnectDone) return; + disconnected++; + const now = Date.now(); + if (disconnected === 1) { + firstDisconnectAt = now; + restartDetectedAt = now; + } + if (disconnected === initiallyConnected) allDisconnectedAt = now; + }; + + for (let i = 0; i < p.n; i++) { + if (useActionCable) { + // Official Rails client — recovers on its own native monitor after the + // deploy drops it (no forced reconnect), so we measure its real recovery. + const consumer = ActionCable.createConsumer(urls.cableUrl); + consumer.subscriptions.create( + { channel: channelName, stream_name: p.stream }, + { + connected() { + handleConnect(false); + }, + disconnected() { + handleDrop(); + }, + } + ); + conns.push({ disconnect: () => consumer.disconnect() }); } else { - cable.streamFrom(p.stream); + const cable = createCable(urls.cableUrl, { + websocketImplementation: WebSocket as unknown as typeof globalThis.WebSocket, + protocol: protocol as never, + logLevel: "error" as never, + }); + cable.on("connect", (event?: { reconnect?: boolean }) => + handleConnect(!!(event && event.reconnect)) + ); + cable.on("disconnect", handleDrop); + cable.on("close", handleDrop); + // Subscribing triggers the connection. "$pubsub" -> streamFrom (signed + // pub/sub), any other channel -> a real Rails channel with { stream_name }. + if (channelName !== "$pubsub") { + cable.subscribeTo(channelName, { stream_name: p.stream }); + } else { + cable.streamFrom(p.stream); + } + conns.push({ disconnect: () => cable.disconnect() }); } - cables.push(cable); if ((i + 1) % p.rampPerSec === 0) { await new Promise((r) => setTimeout(r, 1000)); @@ -142,7 +168,7 @@ export async function runAvalancheAnycable( } tearingDown = true; - for (const c of cables) { + for (const c of conns) { try { c.disconnect(); } catch { diff --git a/backend/src/lib/core/actioncable-node.ts b/backend/src/lib/core/actioncable-node.ts new file mode 100644 index 0000000..e45df48 --- /dev/null +++ b/backend/src/lib/core/actioncable-node.ts @@ -0,0 +1,24 @@ +// Run the official @rails/actioncable client under Node: inject a WebSocket +// implementation and stub the browser globals its ConnectionMonitor touches +// (addEventListener/removeEventListener for online/offline + visibility events, +// document.visibilityState). Import this module before creating any consumer. +import WebSocket from "ws"; +import * as ActionCable from "@rails/actioncable"; + +{ + const g = globalThis as unknown as Record; + if (typeof g.addEventListener !== "function") g.addEventListener = () => {}; + if (typeof g.removeEventListener !== "function") + g.removeEventListener = () => {}; + if (typeof g.document === "undefined") { + g.document = { + visibilityState: "visible", + addEventListener: () => {}, + removeEventListener: () => {}, + }; + } +} +(ActionCable.adapters as { WebSocket: unknown }).WebSocket = + WebSocket as unknown; + +export { ActionCable }; diff --git a/backend/src/lib/jitter-runners.ts b/backend/src/lib/jitter-runners.ts index 9a626a5..4b44bb9 100644 --- a/backend/src/lib/jitter-runners.ts +++ b/backend/src/lib/jitter-runners.ts @@ -11,30 +11,9 @@ import WebSocket from "ws"; import { createCable, backoffWithJitter } from "@anycable/core"; -import * as ActionCable from "@rails/actioncable"; - -// @rails/actioncable is browser-oriented; give it a WebSocket implementation -// and stub the browser globals its ConnectionMonitor touches (addEventListener -// for online/offline + visibility events, document.visibilityState) so the -// official Rails client runs under Node. Used for the Action Cable / Solid -// Cable / Async::Cable targets (clientLib="actioncable") so the bench exercises -// the client a real Rails app ships with — its own reconnect monitor and no -// resume — while AnyCable keeps @anycable/core (extended protocol). -{ - const g = globalThis as unknown as Record; - if (typeof g.addEventListener !== "function") g.addEventListener = () => {}; - if (typeof g.removeEventListener !== "function") - g.removeEventListener = () => {}; - if (typeof g.document === "undefined") { - g.document = { - visibilityState: "visible", - addEventListener: () => {}, - removeEventListener: () => {}, - }; - } -} -(ActionCable.adapters as { WebSocket: unknown }).WebSocket = - WebSocket as unknown; +// The official Rails client (for the Action Cable / Solid Cable / Async::Cable +// targets), set up to run under Node. AnyCable keeps @anycable/core. +import { ActionCable } from "./core/actioncable-node.js"; import { io as ioClient, Socket } from "socket.io-client"; import { ClientStat, JitterResult, newStat, recordMsg, summarize } from "./core/stats.js"; @@ -205,8 +184,20 @@ export async function runJitterAnycable( } ); conns.push({ - disconnect: () => consumer.disconnect(), - connect: () => consumer.connect(), + // Drop the underlying socket uncleanly (like a network blip) but leave + // the ConnectionMonitor running, so the official Rails client recovers + // on its OWN native, poll-based schedule (seconds) rather than an + // immediate reconnect. This is what a real Action Cable app experiences. + disconnect: () => { + const conn = ( + consumer as unknown as { + connection?: { webSocket?: { close?: () => void } }; + } + ).connection; + conn?.webSocket?.close?.(); + }, + // No-op: the native monitor drives reconnection. + connect: () => {}, }); } else { const cable = createCable(urls.cableUrl, { From 0627042de9afaa8fed545df9c95a0f8a85afcfd5 Mon Sep 17 00:00:00 2001 From: Irina Nazarova Date: Wed, 1 Jul 2026 12:02:59 -0700 Subject: [PATCH 17/18] Remove centrifugo; keep published result files Strip the half-committed Centrifugo work that leaked into this Rails PR: server.ts imported ../lib/centrifugo-runners.js (never committed), so the bench-runner failed to build. Remove the centrifugo endpoints, env config, and centrifugoUrls helper from server.ts, the centrifugo protocol wiring from jitter-multi/throughput-multi, and the dev:centrifugo/smoke:centrifugo scripts from package.json. Keep the curated Rails result files in the repo (README/docs cite them): un-ignore backend/results/rails-*.json and socketioxide-*.json, and add rails-capacity-break-2026-06-28.json (previously referenced but untracked). Raw per-run dumps stay ignored. --- .gitignore | 8 +- backend/package.json | 2 - .../rails-capacity-break-2026-06-28.json | 225 ++++++++++++++++++ backend/src/bench-runner/server.ts | 111 --------- backend/src/bench/jitter-multi.ts | 9 - backend/src/bench/throughput-multi.ts | 9 - 6 files changed, 230 insertions(+), 134 deletions(-) create mode 100644 backend/results/rails-capacity-break-2026-06-28.json diff --git a/.gitignore b/.gitignore index dafc8dd..9ddd298 100644 --- a/.gitignore +++ b/.gitignore @@ -2,11 +2,13 @@ node_modules/ dist/ *.log -# Bench result outputs go in backend/results/. The directory is tracked -# via .gitkeep so scripts can write into it on a fresh clone; the actual -# result files (CSV/JSON) are ignored. +# Bench result outputs go in backend/results/. Raw per-run dumps (timestamped +# CSV/JSON) are ignored, but the curated, published result files that the +# README and docs cite are kept in the repo. backend/results/* !backend/results/.gitkeep +!backend/results/rails-*.json +!backend/results/socketioxide-*.json # AnyCable Pro binaries are licensed and MUST NEVER be committed to this # public repo. These patterns match the asset names from the private diff --git a/backend/package.json b/backend/package.json index aee9e29..5177a41 100644 --- a/backend/package.json +++ b/backend/package.json @@ -11,8 +11,6 @@ "dev:socketio-csr": "SOCKETIO_CSR=1 tsx src/socketio/server.ts", "dev:uws": "tsx src/uws/server.ts", "dev:bench-runner": "tsx src/bench-runner/server.ts", - "dev:centrifugo": "docker run --rm -p 8000:8000 -v $PWD/../centrifugo/config.json:/centrifugo/config.json:ro centrifugo/centrifugo:v6.2.2 centrifugo -c /centrifugo/config.json", - "smoke:centrifugo": "tsx src/bench/smoke-centrifugo.ts", "bench:jitter:socketio": "tsx src/bench/jitter-socketio.ts", "bench:jitter:socketio-csr": "tsx src/bench/jitter-socketio-csr.ts", "bench:jitter:anycable": "tsx src/bench/jitter-anycable.ts", diff --git a/backend/results/rails-capacity-break-2026-06-28.json b/backend/results/rails-capacity-break-2026-06-28.json new file mode 100644 index 0000000..a63d86a --- /dev/null +++ b/backend/results/rails-capacity-break-2026-06-28.json @@ -0,0 +1,225 @@ +{ + "test": "idle-capacity-to-break", + "date": "2026-06-28", + "box": "Railway, 32GB plan limit per service, one shared-tenant window", + "load": "13 sharded bench-runners (Puma/Falcon ceilings); AnyCable rerun with 50 runners to 600K", + "in_process_config": "Puma WEB_CONCURRENCY=8 x RAILS_MAX_THREADS=5; Falcon COUNT=8", + "notes": [ + "Connections ramped per-shard until the holding box failed (connected<95% target).", + "Puma adapters (Action Cable, Solid Cable) hard-cap ~52K at only ~2.5GB RAM: an 8-worker file-descriptor ceiling, not memory.", + "AsyncCable on Falcon is memory-bound: ~97K held at ~27GB (fibers ~290KB/conn, ~6x the others).", + "AnyCable (anycable-go gateway) held 600K with 0 failures on a 50-runner fleet; not broken. ~47KB/conn, memory wall near ~700K on the 32GB box. ~11x the Puma adapters.", + "All four hold 10K subscribers at 100% delivery (see rails-sharded-2026-06-28.json).", + "Per-connection RAM: Action Cable ~45KB, Solid Cable ~50KB, AnyCable ~47KB, AsyncCable/Falcon ~290KB. Puma's low ceiling is the per-worker fd wall reached well below the memory limit." + ], + "results": { + "solidcable": { + "rows": [ + { + "per_shard": 4000, + "target": 52000, + "connected": 52000, + "failed": 0, + "shards_ok": 13, + "shards_err": 0, + "peak_mb": 2516.3, + "elapsed_s": 113.5, + "window": [ + "2026-06-28T16:32:12.314Z", + "2026-06-28T16:33:44.932Z" + ] + }, + { + "per_shard": 8000, + "target": 104000, + "connected": 51993, + "failed": 52007, + "shards_ok": 13, + "shards_err": 0, + "peak_mb": 2977.9, + "elapsed_s": 130.3, + "window": [ + "2026-06-28T16:34:20.845Z", + "2026-06-28T16:36:10.305Z" + ] + } + ], + "done": true, + "broke": true + }, + "actioncable": { + "rows": [ + { + "per_shard": 4000, + "target": 52000, + "connected": 52000, + "failed": 0, + "shards_ok": 13, + "shards_err": 0, + "peak_mb": 2310.3, + "elapsed_s": 113.2, + "window": [ + "2026-06-28T16:36:51.103Z", + "2026-06-28T16:38:23.447Z" + ] + }, + { + "per_shard": 8000, + "target": 104000, + "connected": 50722, + "failed": 53278, + "shards_ok": 13, + "shards_err": 0, + "peak_mb": 2682.3, + "elapsed_s": 130.4, + "window": [ + "2026-06-28T16:38:59.231Z", + "2026-06-28T16:40:48.698Z" + ] + } + ], + "done": true, + "broke": true + }, + "asynccable": { + "rows": [ + { + "per_shard": 4000, + "target": 52000, + "connected": 51713, + "failed": 287, + "shards_ok": 13, + "shards_err": 0, + "peak_mb": 13413.7, + "elapsed_s": 113.5, + "window": [ + "2026-06-28T16:41:29.618Z", + "2026-06-28T16:43:02.215Z" + ] + }, + { + "per_shard": 8000, + "target": 104000, + "connected": 97390, + "failed": 6610, + "shards_ok": 13, + "shards_err": 0, + "peak_mb": 27797.1, + "elapsed_s": 130.5, + "window": [ + "2026-06-28T16:43:38.123Z", + "2026-06-28T16:45:27.668Z" + ] + } + ], + "done": true, + "broke": true + }, + "anycable": { + "rows": [ + { + "per_shard": 4000, + "target": 52000, + "connected": 52000, + "failed": 0, + "shards_ok": 13, + "shards_err": 0, + "peak_mb": 1874.4, + "elapsed_s": 115.2, + "window": [ + "2026-06-28T16:46:08.666Z", + "2026-06-28T16:47:41.190Z" + ] + }, + { + "per_shard": 8000, + "target": 104000, + "connected": 104000, + "failed": 0, + "shards_ok": 13, + "shards_err": 0, + "peak_mb": 4594.4, + "elapsed_s": 130.4, + "window": [ + "2026-06-28T16:48:18.857Z", + "2026-06-28T16:50:08.328Z" + ] + }, + { + "per_shard": 12000, + "target": 156000, + "connected": 131801, + "failed": 24199, + "shards_ok": 13, + "shards_err": 0, + "peak_mb": 7453.5, + "elapsed_s": 147.5, + "window": [ + "2026-06-28T16:50:44.289Z", + "2026-06-28T16:52:50.830Z" + ] + } + ], + "done": true, + "broke": true + } + }, + "anycable_bigfleet": { + "runners": 50, + "rows": [ + { + "per_shard": 4000, + "target": 200000, + "connected": 200000, + "failed": 0, + "shards_ok": 50, + "shards_err": 0, + "per_shard_min": 4000, + "per_shard_max": 4000, + "gateway_peak_mb": 7358.3, + "rpc_peak_mb": 138.2, + "elapsed_s": 138.3, + "window": [ + "2026-06-28T17:32:53.792Z", + "2026-06-28T17:34:50.836Z" + ] + }, + { + "per_shard": 8000, + "target": 400000, + "connected": 400000, + "failed": 0, + "shards_ok": 50, + "shards_err": 0, + "per_shard_min": 8000, + "per_shard_max": 8000, + "gateway_peak_mb": 17057.2, + "rpc_peak_mb": 141.3, + "elapsed_s": 189.1, + "window": [ + "2026-06-28T17:35:27.157Z", + "2026-06-28T17:38:15.128Z" + ] + }, + { + "per_shard": 12000, + "target": 600000, + "connected": 600000, + "failed": 0, + "shards_ok": 50, + "shards_err": 0, + "per_shard_min": 12000, + "per_shard_max": 12000, + "gateway_peak_mb": 27657.8, + "rpc_peak_mb": 143.5, + "elapsed_s": 240.1, + "window": [ + "2026-06-28T17:38:51.275Z", + "2026-06-28T17:42:30.271Z" + ] + } + ], + "broke_at": null, + "note": "AnyCable held 600K idle with 0 failures across all 50 shards; not broken (fleet-limited at 50x12K). Gateway RAM scaled linearly to 27GB at 600K (27657.8 MB = 27.0 GB; ~47KB/conn gross), ~84% of the 32GB box, so the real wall is ~700K, memory-bound. The RPC backend stayed flat at ~140MB. The earlier 132K (13 runners) was a load-fleet limit, not anycable-go." + } +} \ No newline at end of file diff --git a/backend/src/bench-runner/server.ts b/backend/src/bench-runner/server.ts index 9c1ed77..5972257 100644 --- a/backend/src/bench-runner/server.ts +++ b/backend/src/bench-runner/server.ts @@ -49,14 +49,6 @@ import { runThroughputUws, type ThroughputParams, } from "../lib/throughput.js"; -import { - runJitterCentrifugo, - runThroughputCentrifugo, - runWhispersCentrifugo, - runIdleCentrifugo, - runAvalancheCentrifugo, - type CentrifugoUrls, -} from "../lib/centrifugo-runners.js"; const SOCKETIO_URL = process.env.SOCKETIO_URL || "http://socketio-server.railway.internal:3000"; @@ -90,30 +82,6 @@ const UWS_WS_URL = process.env.UWS_WS_URL || "ws://uws-server.railway.internal:3000/ws"; const UWS_HTTP_URL = process.env.UWS_HTTP_URL || "http://uws-server.railway.internal:3000"; -// Centrifugo target (standalone Go WS server, like anycable-go). WS endpoint -// is /connection/websocket; server API + publish live on the same port. -const CENTRIFUGO_WS_URL = - process.env.CENTRIFUGO_WS_URL || - "ws://centrifugo.railway.internal:8000/connection/websocket"; -const CENTRIFUGO_HTTP_URL = - process.env.CENTRIFUGO_HTTP_URL || "http://centrifugo.railway.internal:8000"; -const CENTRIFUGO_API_KEY = - process.env.CENTRIFUGO_API_KEY || "bench-centrifugo-api-key"; -const CENTRIFUGO_TOKEN_SECRET = - process.env.CENTRIFUGO_TOKEN_SECRET || "bench-centrifugo-secret"; - -// Bundle the Centrifugo target config from query overrides + env defaults, so -// every centrifugo endpoint targets the same service (or an override) the same -// way the anycable endpoints accept ?cableUrl=. -function centrifugoUrls(req: express.Request): CentrifugoUrls { - return { - wsUrl: (req.query.wsUrl as string) || CENTRIFUGO_WS_URL, - httpBase: (req.query.httpUrl as string) || CENTRIFUGO_HTTP_URL, - apiKey: (req.query.apiKey as string) || CENTRIFUGO_API_KEY, - tokenSecret: (req.query.tokenSecret as string) || CENTRIFUGO_TOKEN_SECRET, - channelNamespace: (req.query.namespace as string) || undefined, - }; -} const app = express(); app.use(express.json()); @@ -612,85 +580,6 @@ app.post("/bench-avalanche-uws", async (req, res) => { ); }); -// --------------------------------------------------------------------------- -// Centrifugo benches. Centrifugo is a standalone Go WS server like anycable-go -// (broadcast over HTTP), with built-in history/recovery, presence, and JWT — -// the closest comparator to AnyCable in this suite. Each endpoint mirrors its -// anycable twin; `?wsUrl=`, `?httpUrl=`, `?apiKey=`, `?tokenSecret=`, -// `?namespace=` override the target. - -app.post("/bench-jitter-centrifugo", async (req, res) => { - const params = paramsFromQuery(req); - const urls = centrifugoUrls(req); - await respondAsync(req, res, () => runJitterCentrifugo(params, urls)); -}); - -app.post("/bench-idle-centrifugo", async (req, res) => { - const n = parseInt((req.query.n as string) || "10000", 10); - const holdSec = parseInt((req.query.hold as string) || "60", 10); - const rampPerSec = parseInt((req.query.ramp as string) || "200", 10); - const stream = (req.query.stream as string) || "idle-probe"; - const shardLabel = (req.query.shard as string) || undefined; - const urls = centrifugoUrls(req); - await respondAsync(req, res, () => - runIdleCentrifugo({ n, holdSec, rampPerSec, stream }, urls, shardLabel), - ); -}); - -app.post("/bench-whispers-centrifugo", async (req, res) => { - const params = whispersParamsFromQuery(req); - const urls = centrifugoUrls(req); - // Whispers default to the "whisper" namespace (allow_publish_for_subscriber) - // unless the caller overrides with ?namespace=. - await respondAsync(req, res, () => - runWhispersCentrifugo(params, { ...urls, channelNamespace: urls.channelNamespace ?? "whisper" }), - ); -}); - -app.post("/bench-throughput-centrifugo", async (req, res) => { - const base = throughputParamsFromQuery(req, `tp-cfgo-${Date.now()}`); - const urls = centrifugoUrls(req); - // Centrifugo's HTTP publisher supports serial/pool/fireforget; map the - // AnyCable-only "nats" mode onto pool so a shared driver flag still works. - const publisher = - base.publisher === "pool" || base.publisher === "fireforget" - ? base.publisher - : base.publisher === "serial" - ? "serial" - : "pool"; - await respondAsync(req, res, () => - runThroughputCentrifugo( - { - n: base.n, - totalMessages: base.totalMessages, - intervalMs: base.intervalMs, - rampPerSec: base.rampPerSec, - stream: base.stream, - drainSec: base.drainSec, - publisher, - publisherConcurrency: base.publisherConcurrency, - }, - urls, - ), - ); -}); - -// Centrifugo avalanche / deploy resilience. Like AnyCable, Centrifugo is a -// standalone process, so an app deploy never severs its connections (expected -// disconnected ~= 0). Redeploying the centrifugo service itself is the only -// thing that drops them; the operator triggers that during the prearm window. -app.post("/bench-avalanche-centrifugo", async (req, res) => { - const n = parseInt((req.query.n as string) || "1000", 10); - const rampPerSec = parseInt((req.query.ramp as string) || "200", 10); - const prearmSec = parseInt((req.query.prearm as string) || "120", 10); - const recoveryWaitSec = parseInt((req.query.recoveryWait as string) || "240", 10); - const stream = (req.query.stream as string) || "avalanche-cfgo"; - const urls = centrifugoUrls(req); - await respondAsync(req, res, () => - runAvalancheCentrifugo({ n, rampPerSec, prearmSec, recoveryWaitSec, stream }, urls), - ); -}); - // --------------------------------------------------------------------------- // Throughput (msg/sec) benches. 10K subscribers × N broadcasts at intervalMs. // Sweep the rate from a CLI driver: 1, 10, 100, 1000 msg/sec target. Where diff --git a/backend/src/bench/jitter-multi.ts b/backend/src/bench/jitter-multi.ts index 583ca93..a4d5ef6 100644 --- a/backend/src/bench/jitter-multi.ts +++ b/backend/src/bench/jitter-multi.ts @@ -71,7 +71,6 @@ const PROTOCOL_TO_ENDPOINT: Record = { socketio: "bench-jitter-socketio", "socketio-csr": "bench-jitter-socketio-csr", uws: "bench-jitter-uws", - centrifugo: "bench-jitter-centrifugo", }; const endpoint = PROTOCOL_TO_ENDPOINT[protocol]; if (!endpoint) { @@ -107,14 +106,6 @@ if (protocol === "uws") { if (process.env.UWS_WS_URL) protocolQuery.wsUrl = process.env.UWS_WS_URL; if (process.env.UWS_HTTP_URL) protocolQuery.httpUrl = process.env.UWS_HTTP_URL; } -if (protocol === "centrifugo") { - if (process.env.CENTRIFUGO_WS_URL) protocolQuery.wsUrl = process.env.CENTRIFUGO_WS_URL; - if (process.env.CENTRIFUGO_HTTP_URL) protocolQuery.httpUrl = process.env.CENTRIFUGO_HTTP_URL; - if (process.env.CENTRIFUGO_API_KEY) protocolQuery.apiKey = process.env.CENTRIFUGO_API_KEY; - if (process.env.CENTRIFUGO_TOKEN_SECRET) - protocolQuery.tokenSecret = process.env.CENTRIFUGO_TOKEN_SECRET; - if (process.env.NAMESPACE) protocolQuery.namespace = process.env.NAMESPACE; -} console.log( `Multi-shard jitter: ${shardUrls.length} shards × ${perShardN} = ${shardUrls.length * perShardN} clients (target: ${totalClients})`, diff --git a/backend/src/bench/throughput-multi.ts b/backend/src/bench/throughput-multi.ts index f91d174..65bd6e8 100644 --- a/backend/src/bench/throughput-multi.ts +++ b/backend/src/bench/throughput-multi.ts @@ -82,7 +82,6 @@ const PROTOCOL_TO_ENDPOINT: Record = { socketio: "bench-throughput-socketio", "socketio-csr": "bench-throughput-socketio-csr", uws: "bench-throughput-uws", - centrifugo: "bench-throughput-centrifugo", }; const endpoint = PROTOCOL_TO_ENDPOINT[protocol]; if (!endpoint) { @@ -111,14 +110,6 @@ if (protocol === "uws") { if (process.env.UWS_WS_URL) protocolQuery.wsUrl = process.env.UWS_WS_URL; if (process.env.UWS_HTTP_URL) protocolQuery.httpUrl = process.env.UWS_HTTP_URL; } -if (protocol === "centrifugo") { - if (process.env.CENTRIFUGO_WS_URL) protocolQuery.wsUrl = process.env.CENTRIFUGO_WS_URL; - if (process.env.CENTRIFUGO_HTTP_URL) protocolQuery.httpUrl = process.env.CENTRIFUGO_HTTP_URL; - if (process.env.CENTRIFUGO_API_KEY) protocolQuery.apiKey = process.env.CENTRIFUGO_API_KEY; - if (process.env.CENTRIFUGO_TOKEN_SECRET) - protocolQuery.tokenSecret = process.env.CENTRIFUGO_TOKEN_SECRET; - if (process.env.NAMESPACE) protocolQuery.namespace = process.env.NAMESPACE; -} console.log( `Multi-shard throughput: ${shardUrls.length} shards × ${perShardN} = ${shardUrls.length * perShardN} clients (target: ${totalClients})`, From 2326ef91f612790478e19cf80a4379e44cc1c699 Mon Sep 17 00:00:00 2001 From: Irina Nazarova Date: Wed, 1 Jul 2026 12:31:24 -0700 Subject: [PATCH 18/18] Fix review issues: jitter consumer leak, avalanche double-count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - jitter-runners: add destroy() to the JitterConn surface and use it at teardown. The @rails/actioncable path's disconnect() only closes the socket (leaving the ConnectionMonitor polling), which is correct for the in-run outage but orphaned a reconnecting consumer in the long-lived bench-runner at end of run. destroy() calls consumer.disconnect(), which also stops the monitor. - jitter-runners: document the outage asymmetry explicitly — @anycable/core gets a fixed jitterDurationMs offline window; @rails/actioncable stays down for jitterDurationMs + its native monitor's reconnect latency, so its delivery reflects both no-resume and real client recovery time. - avalanche-anycable-runner: track per-connection up/down state so a single drop that fires both "disconnect" and "close" (or any repeat event) counts once, instead of double-incrementing disconnected. - README: results/ note now reflects tracked published files vs ignored dumps. - async_cable_executor: note the dependency on ActionCable::Server::Base's @mutex/@executor ivars. --- README.md | 2 +- backend/src/lib/avalanche-anycable-runner.ts | 33 +++++++++++------- backend/src/lib/jitter-runners.ts | 34 ++++++++++++++----- .../initializers/async_cable_executor.rb | 3 ++ 4 files changed, 50 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 058046e..8c87cd9 100644 --- a/README.md +++ b/README.md @@ -164,7 +164,7 @@ benchmark/ └── backend/ ├── Dockerfile # One image; SERVICE_ENTRY picks the entry point ├── package.json - ├── results/ # CSV/JSON output (gitignored) + ├── results/ # published rails-*/socketioxide-* results tracked; raw run dumps ignored └── src/ ├── publisher.ts # Standalone HTTP publisher (legacy) ├── socketio/server.ts # /_broadcast + /publish-local diff --git a/backend/src/lib/avalanche-anycable-runner.ts b/backend/src/lib/avalanche-anycable-runner.ts index 8e1d897..ab3412e 100644 --- a/backend/src/lib/avalanche-anycable-runner.ts +++ b/backend/src/lib/avalanche-anycable-runner.ts @@ -61,13 +61,18 @@ export async function runAvalancheAnycable( let allReconnectedAt = 0; const reconnectTimes: number[] = []; - // Shared connect/disconnect accounting for both client libraries. A connect - // during the ramp counts an initial connection; a connect after a detected - // restart counts a recovery (and its time-to-reconnect). - const handleConnect = (isReconnect: boolean) => { + // Per-connection up/down state so accounting is idempotent: @anycable/core + // fires both "disconnect" and "close" on a single drop, and clients can emit + // repeat events, so we only count actual transitions (up->down, down->up) + // rather than raw events. A first connect during the ramp counts an initial + // connection; a connect after a detected restart counts a recovery. + const up: boolean[] = new Array(p.n).fill(false); + const handleUp = (i: number) => { if (tearingDown) return; + if (up[i]) return; // already up — ignore duplicate connect events + up[i] = true; if (!initialConnectDone) { - if (!isReconnect) initiallyConnected++; + initiallyConnected++; return; } if (restartDetectedAt > 0) { @@ -80,8 +85,10 @@ export async function runAvalancheAnycable( } } }; - const handleDrop = () => { + const handleDown = (i: number) => { if (tearingDown || !initialConnectDone) return; + if (!up[i]) return; // already down — collapse close+disconnect into one drop + up[i] = false; disconnected++; const now = Date.now(); if (disconnected === 1) { @@ -100,10 +107,10 @@ export async function runAvalancheAnycable( { channel: channelName, stream_name: p.stream }, { connected() { - handleConnect(false); + handleUp(i); }, disconnected() { - handleDrop(); + handleDown(i); }, } ); @@ -114,11 +121,11 @@ export async function runAvalancheAnycable( protocol: protocol as never, logLevel: "error" as never, }); - cable.on("connect", (event?: { reconnect?: boolean }) => - handleConnect(!!(event && event.reconnect)) - ); - cable.on("disconnect", handleDrop); - cable.on("close", handleDrop); + // Both "disconnect" and "close" can fire for a single drop; handleDown is + // idempotent per connection, so the drop is counted once. + cable.on("connect", () => handleUp(i)); + cable.on("disconnect", () => handleDown(i)); + cable.on("close", () => handleDown(i)); // Subscribing triggers the connection. "$pubsub" -> streamFrom (signed // pub/sub), any other channel -> a real Rails channel with { stream_name }. if (channelName !== "$pubsub") { diff --git a/backend/src/lib/jitter-runners.ts b/backend/src/lib/jitter-runners.ts index 4b44bb9..9fc81ae 100644 --- a/backend/src/lib/jitter-runners.ts +++ b/backend/src/lib/jitter-runners.ts @@ -156,10 +156,13 @@ export async function runJitterAnycable( // Unified control surface over the two client libraries so the jitter loop // and teardown stay client-agnostic. disconnect()/connect() take the client // cleanly offline and back — a standard fixed-length outage regardless of - // the client's own backoff. + // the client's own backoff. destroy() fully tears the client down at the end + // of the run (stops the reconnect monitor too), so no consumer keeps + // reconnecting inside the long-lived bench-runner process. interface JitterConn { disconnect(): void; connect(): void; + destroy(): void; } const conns: JitterConn[] = []; const useActionCable = urls.clientLib === "actioncable"; @@ -198,6 +201,11 @@ export async function runJitterAnycable( }, // No-op: the native monitor drives reconnection. connect: () => {}, + // Full teardown: consumer.disconnect() also stops the ConnectionMonitor, + // so it does not keep reconnecting after the run (a bare socket close + // would leave the monitor polling and orphan a live consumer in the + // long-lived bench-runner process). + destroy: () => consumer.disconnect(), }); } else { const cable = createCable(urls.cableUrl, { @@ -233,6 +241,9 @@ export async function runJitterAnycable( connect: () => { cable.connect().catch(() => {}); }, + // @anycable/core's disconnect() already stops its Monitor, so teardown + // is the same call. + destroy: () => cable.disconnect(), }); } @@ -257,12 +268,19 @@ export async function runJitterAnycable( let next = Date.now() + (5 + Math.random() * p.jitterIntervalSec) * 1000; while (Date.now() < endAt) { if (Date.now() >= next) { - // Simulate a standard ~jitterDurationMs network outage. disconnect() - // takes the client cleanly offline (its reconnect monitor is stopped, - // so the outage length is fixed regardless of backoff); after the - // window connect() brings it back. AnyCable resumes the messages - // broadcast during the outage (session id retained), the - // @rails/actioncable at-most-once clients simply lose them. + // Trigger a ~jitterDurationMs network drop. The two clients recover + // differently on purpose, so each reflects its real behavior: + // - @anycable/core: disconnect() stops the Monitor (fixed offline + // window = jitterDurationMs), connect() brings it back, and + // AnyCable resumes messages broadcast during the outage (sid + // retained). Effective outage ~= jitterDurationMs. + // - @rails/actioncable: disconnect() drops the socket, connect() is a + // no-op, and the client's own poll-based ConnectionMonitor + // reconnects on its native schedule. Effective outage = + // jitterDurationMs + native reconnect latency (seconds), and with + // no resume every message in that window is lost. So its delivery + // reflects both the missing replay AND the real recovery latency of + // the official client — not just a fixed 2s drop. stat.jitterCount++; conn.disconnect(); await new Promise((r) => setTimeout(r, p.jitterDurationMs)); @@ -278,7 +296,7 @@ export async function runJitterAnycable( for (const conn of conns) { try { - conn.disconnect(); + conn.destroy(); } catch { /* tear-down errors are not interesting */ } diff --git a/cable-bench-falcon/config/initializers/async_cable_executor.rb b/cable-bench-falcon/config/initializers/async_cable_executor.rb index 1141c64..96ee453 100644 --- a/cable-bench-falcon/config/initializers/async_cable_executor.rb +++ b/cable-bench-falcon/config/initializers/async_cable_executor.rb @@ -149,6 +149,9 @@ def start_thread # actioncable-next's own ActionCable::Server::Base#executor. require "action_cable" module AsyncCableFiberExecutor + # Reuses ActionCable::Server::Base's own @mutex/@executor ivars (set in its + # #initialize), matching actioncable-next's lazy #executor. If a future Rails + # bump stops initializing @mutex, this needs a guard. def executor @executor || @mutex.synchronize { @executor ||= Async::Cable::Executor.new } end