Skip to content

Add Rails comparison: Action Cable / Solid Cable / AsyncCable / AnyCable#2

Open
irinanazarova wants to merge 16 commits into
add-socketioxidefrom
rails-comparison
Open

Add Rails comparison: Action Cable / Solid Cable / AsyncCable / AnyCable#2
irinanazarova wants to merge 16 commits into
add-socketioxidefrom
rails-comparison

Conversation

@irinanazarova

Copy link
Copy Markdown
Collaborator

Target apps, harness changes, and results for the Rails WebSocket adapter comparison behind anycable.io/compare/rails-actioncable.

Stacked on add-socketioxide. Based on that branch (not main) because the Rails work builds on the same harness files the socketioxide branch touches (README.md, backend/src/bench/tests-manifest.ts), so it conflicts when applied directly to main. Retarget to main once socketioxide merges.

Target apps

  • cable-bench/ — Rails 8.1 app; BENCH_MODE selects Action Cable (Redis) or Solid Cable (database). Also serves as the AnyCable gRPC RPC backend.
  • cable-bench-falcon/ — the 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 (AnyCable held 600K with 0 failures)

Deep dive in docs/rails-comparison.md; summary section in README.md.

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.
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.
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%.
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.
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.
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.
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.
…/cableUrl)

Was hardcoded to /bench-avalanche-socketio with no Rails param passthrough.
Now selects /bench-avalanche-<protocol> (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.
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.
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.
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.
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.
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.
Its ConnectionMonitor calls addEventListener/removeEventListener and reads
document.visibilityState, which don't exist in Node -> ReferenceError. Provide
no-op stubs before the client loads.
… 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant