cable_room 0.7.0.beta3 → 0.8.0.beta1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/README.md CHANGED
@@ -1,1288 +1 @@
1
1
  # CableRoom
2
-
3
- Build live Rooms on top of ActionCable.
4
-
5
- A **Room** is a long-lived, server-side object that owns a piece of shared realtime state — a
6
- quiz session, a collaborative document, a game lobby, a live dashboard. Exactly one instance of a
7
- Room runs across your whole cluster at a time, it processes messages one at a time on its own
8
- thread, and clients attach to it as **ports**.
9
-
10
- ActionCable gives you channels, which are per-connection and stateless. CableRoom gives you the
11
- thing on the other side of those channels: a single authoritative object that outlives any one
12
- connection, holds state in memory, and shuts itself down when nobody needs it any more.
13
-
14
- ## Contents.
15
-
16
- - [How it works](#how-it-works)
17
- - [Requirements](#requirements)
18
- - [Installation](#installation)
19
- - [Quick start](#quick-start)
20
- - [Defining a Room](#defining-a-room)
21
- - [Ports and messaging](#ports-and-messaging)
22
- - [Users](#users)
23
- - [Authorization](#authorization)
24
- - [Reaping and the watchdog](#reaping-and-the-watchdog)
25
- - [Joining a Room from a channel](#joining-a-room-from-a-channel)
26
- - [Background work](#background-work)
27
- - [Instrumentation and errors](#instrumentation-and-errors)
28
- - [Configuration](#configuration)
29
- - [Introspection](#introspection)
30
- - [Subclassing](#subclassing)
31
- - [Upgrading from 0.6 to 1.0](#upgrading-from-06-to-10)
32
- - [Development](#development)
33
-
34
- ## How it works.
35
-
36
- Every Room runs inside a **Host**, one per process, on a shared pool of threads. The Host holds a
37
- Redis lock on the Room's key, which is what guarantees a single instance cluster-wide, and renews
38
- it every few seconds. Members don't talk to the Room directly. They send and receive on named
39
- lanes called ports. Member→room traffic rides cable_room's own Redis bus (the `CABLEROOM_*`
40
- connection) to whichever process hosts the Room; room→member traffic goes out through the
41
- configured broadcaster (ActionCable or AnyCable) on pubsub streams the members listen to.
42
-
43
- Where the Host lives is a deployment choice. By default it runs inside every web process
44
- (`room_host = :inline`). With `room_host = :remote`, only `cable_room server` processes host
45
- Rooms, and web processes just carry members. See [Configuration](#configuration).
46
-
47
- ```
48
- Browser Your ActionCable Channel The Room
49
- | (include RoomMember) (Room::Base subclass)
50
- | | |
51
- | --- hello (action) -----> | --- to_room port --------------> | port_connected
52
- | <-- port_acknowledged --- | <-- <token> port ------------- |
53
- | | |
54
- | --- websocket message --> | --- to_room port -------------> | handle_received_message
55
- | | |
56
- | <-- websocket message --- | <-- from_room port ----------- | broadcast / self <<
57
- | | <-- <token> port ------------- | reply
58
- | | <-- <user> / <tag> port ------ | broadcast(tag: :admin)
59
- ```
60
-
61
- Four things follow from that design:
62
-
63
- - **One instance, many processes.** A `create: true` join asks the hosts to start the Room. Every
64
- host races for the Redis lock; the one that wins runs the Room and the rest do nothing. Members
65
- everywhere just publish to its ports.
66
- - **No connection affinity.** Members can be spread across every app server, and the Room can run
67
- on none of them. They only need Redis.
68
- - **Single-threaded Room state.** Messages and timers run one at a time, so you can touch
69
- instance variables without locks.
70
- - **Rooms are disposable.** A Room is expected to die when it's idle and be re-created on demand.
71
- Persist anything you can't lose.
72
-
73
- ## Requirements.
74
-
75
- - Ruby 3.4 (CI runs 3.4)
76
- - Rails 7.2 through 8.x
77
- - Redis, reachable from every web process and every rooms host, for the bus and the Room locks
78
- (ActionCable's adapter or anycable-go need one too; it can be the same server)
79
-
80
- ## Installation.
81
-
82
- ```ruby
83
- gem "cable_room"
84
- ```
85
-
86
- Then point it at Redis:
87
-
88
- ```sh
89
- export CABLEROOM_REDIS_URL=redis://localhost:6379/1
90
- ```
91
-
92
- See [Configuration](#configuration) for the full list of variables.
93
-
94
- ## Quick start.
95
-
96
- Define a Room:
97
-
98
- ```ruby
99
- class ChatRoom < CableRoom::Room::Base
100
- # Shut down 30 seconds after the last member leaves
101
- reap_when { connected_clients.empty? }
102
-
103
- after_startup do
104
- @history = []
105
- end
106
-
107
- # Handles { "type": "chat", "body": "..." } from any member
108
- def on_chat(msg)
109
- entry = { user: message_origin.user, body: msg["body"], at: Time.current }
110
- @history << entry
111
- broadcast({ type: "chat", **entry })
112
- end
113
-
114
- # Send the backlog only to the member who just connected
115
- on_port_connected do
116
- reply({ type: "history", entries: @history })
117
- end
118
- end
119
- ```
120
-
121
- Define a channel that joins it:
122
-
123
- ```ruby
124
- class ChatChannel < ApplicationCable::Channel
125
- include CableRoom::RoomProxyChannel
126
-
127
- subscribe_to_room do
128
- join_room ChatRoom, params[:room_id], create: true, tags: params[:tags]
129
- end
130
- end
131
- ```
132
-
133
- Connect from the browser, and say `hello` once the subscription is confirmed:
134
-
135
- ```js
136
- consumer.subscriptions.create({ channel: "ChatChannel", room_id: 42 }, {
137
- connected() { this.perform("hello") },
138
- received(message) { console.log(message) },
139
- })
140
- ```
141
-
142
- That's the whole loop. `RoomProxyChannel` forwards everything the client sends into the Room and
143
- everything the Room broadcasts back out to the client. `hello` is what announces the member to the
144
- Room; nothing is announced until the client sends it (see [The hello handshake](#the-hello-handshake)).
145
- `create: true` means this channel will provision the Room if it isn't already running somewhere.
146
-
147
- ## Defining a Room.
148
-
149
- ### Lifecycle.
150
-
151
- ```ruby
152
- class MyRoom < CableRoom::Room::Base
153
- before_startup { } # streams aren't open yet
154
- after_startup { } # aliased as on_startup
155
- before_shutdown { } # last chance to broadcast
156
- after_shutdown { } # aliased as on_shutdown
157
-
158
- before_work { } # about to run on a Host thread: a message/timer handler, startup, shutdown...
159
- after_work { } # ...same set, on the way back out
160
- around_work { |room, blk| blk.call } # wrap the whole thing (see Multi-tenancy)
161
- end
162
- ```
163
-
164
- You can also just define `startup` and `shutdown` methods; they run inside the corresponding
165
- callback chain.
166
-
167
- `before_work`/`after_work`/`around_work` wrap *every* piece of Room code that ever runs on a Host
168
- thread — not just messages, but `startup`, `restore_state`, `snapshot_state`, and `shutdown` too
169
- (see [Multi-tenancy](#multi-tenancy)). Define them on `MyRoom` for just this Room, or on your own
170
- shared base Room class for all of them — ordinary callback inheritance, so a subclass's own
171
- `around_work` nests inside whatever its ancestors already declared.
172
-
173
- Out of the box a Room broadcasts `{ type: "room_opened" }` after startup and
174
- `{ type: "room_closed", reason: ... }` before shutdown.
175
-
176
- To stop a Room from inside itself:
177
-
178
- ```ruby
179
- shutdown!("everyone left") # graceful: drains queued messages first
180
- stop! # immediate: drops anything pending
181
- ```
182
-
183
- `lifecycle_state` returns `:initializing`, `:starting`, `:started`, `:shutting_down`, or `:dead`.
184
- A Room being moved to another host passes through `:freezing` and `:frozen` on the way (see
185
- [Migration hooks](#migration-hooks)).
186
-
187
- ### Handling messages.
188
-
189
- Inbound messages arrive on the `:to_room` port and dispatch by `type`. A message of type
190
- `"start_quiz"` (or `"StartQuiz"`) calls `on_start_quiz`. Unknown types log a warning.
191
-
192
- ```ruby
193
- def on_start_quiz(msg)
194
- logger.info "starting with #{msg['question_count']} questions"
195
- end
196
- ```
197
-
198
- Inside a handler:
199
-
200
- | Helper | What it gives you |
201
- | ----------------- | ------------------------------------------------------- |
202
- | `message` | The raw message hash |
203
- | `message_origin` | The `PortClient` that sent it |
204
- | `reply(data)` | Send back to that port alone |
205
- | `broadcast(data)` | Send to every member |
206
-
207
- Override `handle_received_message(message)` if you'd rather dispatch yourself. Call `super` for
208
- anything you don't handle, so the built-in port and user bookkeeping keeps working.
209
-
210
- Sending the string `"KILL"` to the `:to_room` port shuts the Room down. It's a blunt instrument,
211
- useful in a console.
212
-
213
- ### Timers.
214
-
215
- ```ruby
216
- class MyRoom < CableRoom::Room::Base
217
- periodically :tick, every: 5.seconds
218
- periodically -> { broadcast({ type: "still_here" }) }, every: 1.minute
219
-
220
- def tick; end
221
- end
222
- ```
223
-
224
- Timer bodies run on the Room's thread, so they're serialized against message handling.
225
-
226
- ### Migration hooks.
227
-
228
- When a host shuts down on purpose, it can move its Rooms to another host instead of closing them.
229
- Members don't notice: their streams stay open, and the new host picks up where the old one left
230
- off. The gem carries what it owns — every port with its token, tags, user, and last-seen time; the
231
- user map; each reaper's deadline; and the Room's tenant, if `join_room` was given one (see
232
- [Multi-tenancy](#multi-tenancy)). A Room's own state moves through two optional hooks:
233
-
234
- ```ruby
235
- class QuizRoom < CableRoom::Room::Base
236
- def snapshot_state
237
- { question_id: @question.id, answers: @answers } # JSON only; refer to records by id
238
- end
239
-
240
- def restore_state(state)
241
- @question = Question.find(state[:question_id]) # state[:key] and state["key"] both work
242
- @answers = state[:answers]
243
- end
244
- end
245
- ```
246
-
247
- `snapshot_state` runs once the Room is frozen (nothing else is touching its state) and must return
248
- plain JSON: hashes, arrays, strings, numbers, booleans, and `nil`. The gem checks this when it
249
- takes the snapshot and raises `CableRoom::Snapshot::NotSerializable`, naming the Room class and
250
- key, if anything else is in there — a `Time`, a record, or a `Symbol` value, since those would
251
- quietly come back as something different.
252
-
253
- On the new host, a Room that defines `restore_state` gets it instead of `startup`, with whatever
254
- `snapshot_state` returned. A Room without these hooks is restored with its ports and users intact
255
- and runs `startup` again to rebuild its own state. Either way the `before_startup` and
256
- `after_startup` callbacks run as usual, `restored?` is true inside them, and the gem doesn't
257
- broadcast `room_opened` — the members were there the whole time.
258
-
259
- ## Ports and messaging.
260
-
261
- A port is a name derived from the Room class, the Room key, and a port name. Members send on it
262
- over the Redis bus (`cr:RoomClass:key:in`, with a `:port` suffix for custom ports) and the Room
263
- publishes on it as a broadcaster stream (`RoomClass:key:port`). Two are reserved:
264
-
265
- - `:to_room` — many-to-one. Members publish here; the Room streams from it.
266
- - `:from_room` — one-to-many. The Room publishes here; every member streams from it.
267
-
268
- Every member also gets a private port named after its random token, plus a port for the user it
269
- joined as and one for each tag it carries. That's how targeted delivery works without the Room
270
- tracking connections.
271
-
272
- ### Sending.
273
-
274
- ```ruby
275
- self << { type: "tick" } # to :from_room, i.e. everyone
276
- broadcast({ type: "tick" }) # same thing
277
- broadcast({ type: "secret" }, client_port: token) # one port
278
- broadcast({ type: "hi" }, user: "user_42") # every port that user joined from
279
- broadcast({ type: "tools" }, tag: :admin) # every port carrying the tag
280
- reply({ type: "pong" }) # the port whose message you're handling
281
- message_origin << { type: "pong" } # the same, spelled differently
282
- ```
283
-
284
- Combining a target with a tag makes the tag a filter, not a second audience.
285
- `broadcast(msg, user: "user_42", tag: :admin)` reaches that user only if one of their ports is
286
- tagged `admin`, and sends nothing otherwise.
287
-
288
- ### Scoping.
289
-
290
- `with_port_scope` sets an ambient target so nested code doesn't have to pass it around:
291
-
292
- ```ruby
293
- with_port_scope(tag: :admin) do
294
- broadcast({ type: "diagnostics", data: expensive_report })
295
- end
296
- ```
297
-
298
- Scopes merge when nested. `without_port_scope` clears them. `with_port_scope!` skips the block
299
- entirely when nothing matches, which is the cheap way to avoid building a payload nobody will
300
- receive. The block form of `reply` does the same for a single port:
301
-
302
- ```ruby
303
- reply do
304
- broadcast({ type: "a" })
305
- broadcast({ type: "b" })
306
- end
307
- ```
308
-
309
- ### Custom ports.
310
-
311
- Ports aren't limited to the built-ins. Open your own for a side channel:
312
-
313
- ```ruby
314
- ports[:telemetry] << { fps: 60 }
315
-
316
- stream_port(:control) do |message|
317
- logger.info "control: #{message.inspect}"
318
- end
319
- ```
320
-
321
- Ports opened with `stream_port` close automatically at shutdown.
322
-
323
- ### Port liveness.
324
-
325
- Every message a member sends counts as activity. A member that has been silent for 15 seconds
326
- (`RoomMember::PING_INTERVAL`) pings instead, so the room hears from a live member at least every
327
- 30 seconds. A port that goes quiet for 45 seconds (`PortManagement::PORT_TIMEOUT`) is dropped,
328
- and `on_port_disconnected` runs for it with `message_origin` still set, so cleanup can tell
329
- which port went away. Under AnyCable the channel's timer never runs and the browser sends the
330
- ping instead; see [Using AnyCable](#using-anycable).
331
-
332
- ```ruby
333
- on_port_connected { logger.info "port #{message_origin.token} joined" }
334
- on_port_disconnected { logger.info "port #{message_origin.token} gone" }
335
- ```
336
-
337
- `connected_clients` returns the live `PortClient` objects. Each one carries a `token`, its `tags`,
338
- its `user`, and any extra metadata the member passed in. Read and write metadata with `[]` and
339
- `[]=`.
340
-
341
- ## Users.
342
-
343
- Members can join *as* a user. CableRoom then collapses that user's ports into a single identity,
344
- so a person with three browser tabs joins once and leaves once.
345
-
346
- ```ruby
347
- class MyRoom < CableRoom::Room::Base
348
- on_user_joined { broadcast({ type: "joined", user: message_origin.user }) }
349
- on_user_left { broadcast({ type: "left", user: message_origin.user }) }
350
- end
351
- ```
352
-
353
- `on_user_joined` fires on the first port for that user; `on_user_left` fires when the last one
354
- goes away. `connected_users` lists them, and `all_user_tags(user)` unions the tags across every
355
- port that user is connected from.
356
-
357
- A `RoomMember` channel that defines `current_user` passes it automatically. Pass `as:` to override
358
- it, or `as: nil` for an anonymous port. The value is serialized with ActiveJob's argument
359
- serializer, so an ActiveRecord object survives the trip and arrives as the same record.
360
-
361
- ## Authorization.
362
-
363
- Two layers, and they compose. Use guards for anything that depends on the message; use tag
364
- policies for anything that depends on who's asking.
365
-
366
- ### Guards.
367
-
368
- ```ruby
369
- class MyRoom < CableRoom::Room::Base
370
- # Block, symbol, or proc. Return false to drop the message.
371
- authorize_inbound { |message| message["body"].to_s.length < 1_000 }
372
- authorize_inbound :quiz_running?, only: [:answer, :skip]
373
- authorize_inbound :not_locked?, except: :leave
374
-
375
- protected
376
-
377
- # Zero-arity guards read `message` themselves
378
- def quiz_running? = @state == :running
379
- def not_locked?(msg) = !@locked
380
- end
381
- ```
382
-
383
- A dropped message logs a warning and never reaches a handler.
384
-
385
- ### Tag policies.
386
-
387
- Members join with tags (`join_room MyRoom, key, tags: [:admin]`). Policies then say which tags
388
- may trigger which handlers.
389
-
390
- ```ruby
391
- class MyRoom < CableRoom::Room::Base
392
- inbound_tag_policy do
393
- deny :muted, :chat # muted members can't chat...
394
- allow :*, :chat # ...but everyone else can
395
- allow :admin, [:kick, :ban] # admins get the moderation verbs
396
- end
397
- end
398
- ```
399
-
400
- Two rules govern how this resolves:
401
-
402
- 1. **Declaring any policy flips the default to deny.** Before you write one, everything is
403
- allowed. After, only what you allow is allowed. The built-in connection and user messages stay
404
- permitted, so members can still join and leave.
405
- 2. **Highest priority wins.** Rules default to priority 10. Pass `priority:` to layer a base
406
- policy under, or an override over, another. `inbound_tag_policy(priority: -10)` adds
407
- permissions without flipping the default.
408
-
409
- Within a priority, the first matching rule decides, and rules match in declaration order. That
410
- means a `deny` exception has to come **before** the broad `allow` it carves out of — write
411
- `allow :*, :chat` first and it swallows every member, muted ones included. When the ordering
412
- matters a lot, give the two rules different priorities instead of relying on where they sit in
413
- the block:
414
-
415
- ```ruby
416
- inbound_tag_policy(priority: 20) { deny :muted, :chat }
417
- inbound_tag_policy(priority: 10) { allow :*, :chat }
418
- ```
419
-
420
- Group related handlers behind one name with `define_tag_alias`. A rule written against the alias
421
- covers everything it implies:
422
-
423
- ```ruby
424
- class MyRoom < CableRoom::Room::Base
425
- define_tag_alias :moderation, [:kick, :ban, :mute]
426
-
427
- inbound_tag_policy do
428
- allow :admin, :moderation
429
- end
430
- end
431
- ```
432
-
433
- Aliases are per Room class and inherited by subclasses, so one Room's vocabulary can't change how
434
- another Room reads its policies.
435
-
436
- ### System message types.
437
-
438
- Some message types are the framework's, not the client's. Members can't forge them:
439
-
440
- ```ruby
441
- class MyRoom < CableRoom::Room::Base
442
- system_message_types :score_awarded, :quiz_finished
443
- end
444
- ```
445
-
446
- Attempts to send one from a member are dropped with a warning at the sender. `port_connected`,
447
- `port_disconnected`, `port_ping`, `user_joined`, and `user_left` are already protected.
448
-
449
- ## Reaping and the watchdog.
450
-
451
- Rooms hold memory and a Redis lock, so they need to know when to quit. `reap_when` declares a
452
- check that runs on a timer:
453
-
454
- ```ruby
455
- class MyRoom < CableRoom::Room::Base
456
- # Idle for 30 seconds with nobody connected -> shut down
457
- reap_when { connected_clients.empty? }
458
-
459
- # Tighter window, and named so the shutdown reason says which check fired
460
- reap_when(key: :abandoned, grace: 5.minutes, interval: 30.seconds) do
461
- connected_users.empty?
462
- end
463
-
464
- # Return :reap to skip the grace period entirely
465
- reap_when(grace: 1.hour) { @cancelled ? :reap : false }
466
- end
467
- ```
468
-
469
- - **Truthy** starts the grace clock. Once the condition has held for `grace:` (default 30
470
- seconds), the Room shuts down.
471
- - **Falsey** resets the clock and pings the watchdog.
472
- - **`:reap`** shuts down now, whatever the grace period says.
473
-
474
- Declare as many checks as you like; each gets its own timer and its own grace clock. Call
475
- `check_reapers_now!` to run them all immediately instead of waiting for the next tick.
476
-
477
- ### The watchdog.
478
-
479
- Separately, every Room is supervised. Every five seconds its channel extends the Redis lock and
480
- confirms the Room has pinged its watchdog within the last 15 seconds
481
- (`Room::Base::WATCH_DOG_INTERVAL`). Lose the lock and the Room stops, since another process may
482
- now own the key. Miss the ping and it shuts down as wedged.
483
-
484
- **Reaper checks are what ping the watchdog.** A Room that declares no `reap_when` has nothing
485
- pinging it, so the watchdog will shut it down about 15 seconds after startup. Every long-lived
486
- Room needs at least one `reap_when` — or its own timer calling `ping_watchdog` — to stay up.
487
-
488
- ## Joining a Room from a channel.
489
-
490
- ### The proxy shortcut.
491
-
492
- When the client only needs a pipe to the Room, `RoomProxyChannel` is the whole channel:
493
-
494
- ```ruby
495
- class QuizChannel < ApplicationCable::Channel
496
- include CableRoom::RoomProxyChannel
497
-
498
- subscribe_to_room do
499
- join_room QuizRoom, params[:quiz_id], create: true
500
- end
501
- end
502
- ```
503
-
504
- It wires up `subscribed`, `receive`, `unsubscribed`, and the `hello` action, and forwards messages
505
- both ways.
506
-
507
- ### Full control.
508
-
509
- `RoomMember` gives you the membership without the forwarding, so the channel can filter,
510
- transform, or fan out:
511
-
512
- ```ruby
513
- class QuizChannel < ApplicationCable::Channel
514
- include CableRoom::RoomMember
515
-
516
- def subscribed
517
- @membership = join_room(
518
- QuizRoom,
519
- params[:quiz_id],
520
- create: true,
521
- tags: current_user.teacher? ? [:admin] : [:student],
522
- extra: { device: params[:device] },
523
- on_joined: ->(m) { transmit(type: "ready") },
524
- on_message: ->(msg) { transmit(msg) if msg["type"] != "internal" },
525
- on_room_closed: ->(m) { transmit(type: "over") },
526
- on_left: ->(m) { logger.info "left #{m.key}" }
527
- )
528
- end
529
-
530
- def answer(data)
531
- @membership << { type: "answer", choice: data["choice"] }
532
- end
533
-
534
- def unsubscribed
535
- @membership&.leave!
536
- end
537
- end
538
- ```
539
-
540
- `join_room` options:
541
-
542
- | Option | Meaning |
543
- | ------------------------------- | -------------------------------------------------------------------- |
544
- | `create:` | Provision the Room if it isn't running. Defaults to `false`. |
545
- | `as:` | The user identity. Defaults to `current_user` when the channel has one. |
546
- | `tags:` | Tags this port carries, for policies and targeted broadcasts. |
547
- | `extra:` | Extra metadata, readable on the Room's `PortClient`. |
548
- | `tenant:` | This Room's tenant, for a multi-tenant app. Defaults to the ambient tenant right here (see [Multi-tenancy](#multi-tenancy)). |
549
- | `forward:` | Pipe every Room message straight to the websocket. |
550
- | `on_joined:` | The Room acknowledged this port. |
551
- | `on_message:` | Any message from the Room. |
552
- | `on_room_opened:` | The Room opened while we were connecting. Not guaranteed. |
553
- | `on_room_closed:` | The Room closed while we were connected. Not guaranteed. |
554
- | `on_left:` | This membership ended. |
555
-
556
- The returned membership responds to `<<`, `connected?`, `accepts_input?`, `hello!`,
557
- `hello_received?`, `left?`, `key`, `ping!`, `leave!`, and `rejoin!`.
558
-
559
- Under AnyCable an instance variable set in `subscribed` is gone by the next call, because
560
- anycable-rails builds a new channel object for every one (see [Using AnyCable](#using-anycable)).
561
- Read `room_memberships` in your actions instead of `@membership`; it holds the memberships
562
- `join_room` created, rebuilt from the channel state when needed.
563
-
564
- With `create: true`, the membership doesn't start the Room itself. It publishes a provision
565
- request on the bus, and one of the Rooms hosts starts the Room (see
566
- [Provisioning](#provisioning)). The request goes out at join and again on every ping until the
567
- Room acknowledges the port, so a lost request costs at most one ping interval, and if the Room's
568
- host process dies the next ping from any member brings it back somewhere else.
569
-
570
- ### Multi-tenancy.
571
-
572
- A Room's own work runs on its Host's worker pool, not on the joining member's request thread, so
573
- there's no Rack middleware and no request to derive an app's tenant from once the Room is up. Pass
574
- `tenant:` to `join_room` for exactly this — whatever it's given rides along on the provision
575
- request, comes back out as `Runner#tenant` (and `Room#tenant`), and survives a host migration
576
- (it's part of what a Room's snapshot carries, alongside its ports and users; see
577
- [Migration hooks](#migration-hooks)). Leave it out and it defaults to whatever
578
- `Apartment::Tenant.current` returns right where `join_room` is called — the member's own request
579
- thread, the last place with real request context before the Room's work moves to a Host thread:
580
-
581
- ```ruby
582
- join_room(QuizRoom, params[:quiz_id], create: true) # tenant: Apartment::Tenant.current, captured here
583
- join_room(QuizRoom, params[:quiz_id], create: true, tenant: "some-other-org") # explicit override
584
- ```
585
-
586
- cable_room doesn't depend on Apartment (or anything else) to use this — `tenant` is just a value it
587
- carries around for you, the same way `key` or `extra` are. **It never switches anything itself.**
588
- Whatever ends up on `Runner#tenant` still has to actually be applied before a Room's DB calls run,
589
- the same way a request's tenant has to be applied before a controller action's do.
590
-
591
- `around_work` (alongside `before_work`/`after_work`, see [Lifecycle](#lifecycle)) is the hook for
592
- that. It wraps every piece of Room code that runs on a Host thread — `startup`/`restore_state`,
593
- message and timer handlers, `snapshot_state`, and `shutdown` alike — not just message dispatch, so
594
- there's exactly one place to apply a tenant no matter which of those runs first for a given Room.
595
- Define it once on your own shared base Room class (every app Room already inherits from
596
- `CableRoom::Room::Base`, directly or through one of your own) to cover every Room, or again on a
597
- specific Room subclass for one that needs something different — ordinary callback inheritance,
598
- nothing cable_room-specific:
599
-
600
- ```ruby
601
- class ApplicationRoom < CableRoom::Room::Base
602
- # Lazily switch a worker thread to the right tenant only when it's about to touch the DB.
603
- # Actively switching up front checks out a connection and runs SET search_path even for a
604
- # message that never queries anything, so this only stages the tenant (no DB call) and lets
605
- # the connection pool's own checkout hook apply it the moment a connection is actually acquired.
606
- around_work do |room, blk|
607
- Thread.current[:cable_tenant] = {
608
- adapter: Apartment::Tenant.adapter,
609
- tenant: room.tenant,
610
- }
611
-
612
- # If this thread already holds a connection from earlier work, release it so the checkout
613
- # hook below gets a fresh checkout to apply the schema to.
614
- pool = Apartment.connection_class.connection_pool
615
- pool.release_connection if pool.active_connection?
616
-
617
- Apartment::Tenant.adapter.instance_variable_set(:@current, room.tenant)
618
-
619
- blk.call
620
- ensure
621
- Thread.current[:cable_tenant] = nil
622
- end
623
- end
624
-
625
- ActiveSupport.on_load(:active_record) do
626
- ActiveRecord::ConnectionAdapters::AbstractAdapter.set_callback :checkout, :after do |conn|
627
- next unless (ct = Thread.current[:cable_tenant]).present?
628
- next unless Apartment::Tenant.adapter.is_a?(Apartment::Adapters::PostgresqlSchemaAdapter)
629
- next unless conn.pool == Apartment.connection_class.connection_pool
630
-
631
- adapter = ct[:adapter]
632
- adapter.instance_variable_set(:@current, ct[:tenant])
633
- conn.schema_search_path = adapter.send :full_search_path
634
- end
635
- end
636
- ```
637
-
638
- `room` is the Room instance itself — `room.tenant` (delegated to its `Host::Runner`) is read fresh
639
- on every call, so a Room only ever sees its own tenant even though many Rooms for many orgs share
640
- the same small pool of Host worker threads. `around_work` never has to guard against running
641
- twice: cable_room only ever enters it once per thread, even when one piece of Room code calls
642
- another (a message handler that shuts the Room down, say) — the inner call just runs inside the
643
- outer one's context.
644
-
645
- This replaces reaching for `ActionCable::Server::Worker.set_callback :work, :around` the way
646
- PandaPal does for ordinary channels — that hook only ever fired for message dispatch, and never
647
- for a Room's `startup`, `snapshot_state`, or `shutdown`, which run directly on a Host thread
648
- instead. It's structural, not just a convention: `Host::WorkerPool` isn't an
649
- `ActionCable::Server::Worker` subclass, so it doesn't share ActionCable's `:work` callback chain at
650
- all. If PandaPal (or anything else) already has a `:work` hook installed, it's harmless to leave in
651
- place — it simply has nothing to attach to for Room work, so it can neither conflict with,
652
- double-apply with, nor be relied on in place of `around_work`. Define `around_work` and that's
653
- the one thing actually switching a Room's tenant.
654
-
655
- ### The hello handshake.
656
-
657
- A membership doesn't announce itself to the Room when the channel subscribes. Stream subscriptions
658
- are asynchronous, so the Room's acknowledgement could land on a stream nobody is listening to yet,
659
- and the member would stay deaf on the inbound side. Instead, the client performs `hello` once
660
- ActionCable confirms the subscription (`connected()` in the JS client), and the membership sends
661
- `port_connected` then. `RoomMember` defines `hello` as a public channel action, so both
662
- `RoomProxyChannel` and `RoomMember` channels like the `QuizChannel` above accept
663
- `perform("hello")` with no extra wiring. A channel that learns the client is ready some other way
664
- can call `hello_room_memberships` itself.
665
-
666
- If the announcement or the acknowledgement is lost, or the Room isn't running yet, the membership
667
- re-announces on every ping until the Room acknowledges it, so a lost message costs at most one
668
- ping interval. That only starts after `hello`. `hello_received.cable_room` fires each time a
669
- membership hears it. Under AnyCable the server never sees the acknowledgement, so the browser
670
- does the retrying; see [Using AnyCable](#using-anycable).
671
-
672
- **Breaking change in 1.0.** Clients must send `hello`, and there is no fallback. A client that
673
- only subscribes still receives broadcasts on the shared stream, but the Room never sees it and
674
- everything it sends is dropped. Browser tabs open across the deploy speak the old handshake and
675
- need a reload.
676
-
677
- ### From outside a channel.
678
-
679
- ```ruby
680
- QuizRoom.ensure("quiz_9") # => true if this process now runs it
681
- QuizRoom.send_message("quiz_9", { type: "extend", by: 60 }) # publish to :to_room over the bus
682
- QuizRoom.room_port_key("quiz_9", :from_room) # the raw stream name members listen on
683
- QuizRoom.inbound_channel("quiz_9") # the raw bus channel the Room listens on
684
- ```
685
-
686
- `ensure` only works in a process that hosts Rooms. In `remote` mode that's the `cable_room server`
687
- process; a web process raises `CableRoom::Host::NotHosting` (see [Remote rooms](#remote-rooms)).
688
-
689
- ## Background work.
690
-
691
- A Room is single-threaded on purpose. Slow work belongs off its thread:
692
-
693
- ```ruby
694
- def on_export(msg)
695
- token = message_origin.token # capture before leaving the Room's thread
696
-
697
- async do
698
- report = build_expensive_report
699
-
700
- on_room_thread do
701
- broadcast({ type: "export_ready", url: report.url }, client_port: token)
702
- end
703
- end
704
- end
705
- ```
706
-
707
- `async` borrows a thread from the pool shared by every Room in the process and runs concurrently
708
- with the Room, so **the block must not touch Room state.** Capture what it needs first. Inside
709
- it, `message` is nil, and `message_origin` and `reply` point at whatever the Room is handling
710
- *now* rather than what it was handling when you called `async`.
711
-
712
- `on_room_thread` queues work back onto the Room's thread, where state is safe again. Prefer
713
- handing results back that way over blocking on `async` work, since a blocked Room thread can
714
- starve its neighbours.
715
-
716
- ## Instrumentation and errors.
717
-
718
- Rooms swallow exceptions so one bad message can't take the Room down. That makes the error
719
- handler the only place you'll hear about it:
720
-
721
- ```ruby
722
- CableRoom.error_handler = ->(error, context) do
723
- Sentry.capture_exception(error, extra: context)
724
- end
725
- ```
726
-
727
- For an error inside a Room the context has `room`, `room_class`, `room_key`, and `runner` (the
728
- `Host::Runner` driving it; 0.6 called this key `channel`). Errors from the other moving parts
729
- name themselves instead: `bus`, `host`, `placement`, `migration`, or `adoption`, with
730
- `room_class` and `room_key` where they apply. An `error.cable_room` notification fires either way.
731
-
732
- ActiveSupport notifications, every one the gem emits:
733
-
734
- | Event | Fires | Payload |
735
- | -------------------------------- | ------------------------------------ | -------------------------- |
736
- | `room_opened.cable_room` | Around a Room's startup | `room` |
737
- | `room_restored.cable_room` | Around a restore, in place of `room_opened` | `room` |
738
- | `room_closed.cable_room` | Around a Room's shutdown | `room`, `reason` |
739
- | `room_snapshotted.cable_room` | When a frozen Room is snapshotted | `room` |
740
- | `room_migrated.cable_room` | On the old host, once a peer has the Room | `room`, `room_class`, `room_key`, `duration`, `relayed_messages`, `to_host`, `from_host`, `reason` |
741
- | `host_draining.cable_room` | Around a whole `drain!` | `host`, `rooms`, `reason`; on finish also `migrated`, `closed` |
742
- | `provision_requested.cable_room` | Member side, each `provision` request | `membership`, `room_class`, `room_key`, `channel`, `request` |
743
- | `provision_claimed.cable_room` | Host side, each lock attempt | `host`, `room_class`, `room_key`, `delay`, `open_rooms`, `request`; on finish also `handoff`, `claimed` |
744
- | `hello_received.cable_room` | Member side, each `hello` | `membership`, `room_class`, `room_key`, `channel` |
745
- | `message_received.cable_room` | Each inbound message a Room handles | `room`, `message` |
746
- | `port_connected.cable_room` | A port joins | `room`, `message` |
747
- | `port_disconnected.cable_room` | A port leaves or times out | `room`, `reason`, `message` |
748
- | `user_joined.cable_room` | A user's first port joins | `room`, `user` |
749
- | `user_left.cable_room` | A user's last port leaves | `room`, `user` |
750
- | `error.cable_room` | Any reported error | `error`, plus context |
751
-
752
- `port_disconnected` reports a `reason` of `:left` for a clean departure and `:timeout` for a port
753
- that stopped pinging (no `message` in that case). `room_closed` always has a `reason`: the string
754
- passed to `shutdown!`, the reaper that fired, `"Server shutting down"`, `"Watchdog timeout"`, or
755
- the signal that started a drain.
756
-
757
- Every Room also gets a tagged logger, so `logger.info` from inside a Room is prefixed with the
758
- Room class and a short UUID. That UUID is how you follow one instance through the logs.
759
-
760
- ## Configuration.
761
-
762
- ### Deployment knobs.
763
-
764
- CableRoom works with no setup. Rooms run inside the web process and broadcast through
765
- ActionCable. To change that, add an initializer:
766
-
767
- ```ruby
768
- CableRoom.configure do |c|
769
- c.room_host = :inline # :inline | :remote
770
- c.broadcaster = :action_cable # :action_cable | :anycable
771
- c.provision_delay_ms = 20
772
- c.drain_timeout = 10.minutes
773
- c.handoff_timeout = 10.seconds
774
- end
775
- ```
776
-
777
- The env vars `CABLE_ROOM_HOST` and `CABLE_ROOM_BROADCASTER` override `room_host` and
778
- `broadcaster`, even when the block sets them. An unknown value raises at boot with the list
779
- of valid options. Read the current settings with `CableRoom.config`.
780
-
781
- ### Deployment modes.
782
-
783
- The two knobs combine freely. Pick where rooms run and what serves the websockets:
784
-
785
- | `room_host` | `broadcaster` | Where rooms run | Sockets served by | Notes |
786
- | ----------- | -------------- | -------------------- | ---------------------- | --------------------------------------- |
787
- | `inline` | `action_cable` | web process (thread) | Passenger + ActionCable | The default. Today's behavior. |
788
- | `remote` | `action_cable` | rooms pool | Passenger + ActionCable | Valid. Covered by the remote-host integration spec. Measure this step first. |
789
- | `remote` | `anycable` | rooms pool | anycable-go + RPC | The target for large installs. |
790
- | `inline` | `anycable` | web process | anycable-go + RPC | Valid. Suits small installs. |
791
-
792
- `broadcaster` only changes how a Room pushes messages *out* to members. Stream names stay the
793
- same in both modes, so a member on ActionCable and a member on anycable-go would see the same
794
- traffic — but a Room publishes to one broadcaster, so members on the other stack hear nothing.
795
- Running both stacks at once is covered in [Dual-stack](#dual-stack-actioncable-and-anycable-side-by-side).
796
-
797
- ### Remote rooms.
798
-
799
- With `room_host = :remote`, a web process never hosts a Room. Channels still join, say `hello`,
800
- send on the bus, and receive on their streams exactly as in `inline`; the only difference is where
801
- the Room lives. Two guards keep it that way:
802
-
803
- - `join_room(..., create: true)` starts nothing in the web process. It publishes a provision
804
- request, and a `cable_room server` process starts the Room (see [Provisioning](#provisioning)).
805
- - `CableRoom::Host.instance` raises `CableRoom::Host::NotHosting` in a web process, so
806
- `MyRoom.ensure(key)` (and anything else that would start a Room) fails loudly instead of
807
- quietly hosting a Room in the web tier. `CableRoom::Host.current` answers `nil` there, and
808
- `CableRoom::Room.locally_open_rooms` is empty.
809
-
810
- Rooms run in a separate pool of processes started with the gem's command:
811
-
812
- ```sh
813
- CABLE_ROOM_HOST=remote bundle exec cable_room server
814
- ```
815
-
816
- `cable_room server` boots the Rails app in the current directory (its `config/environment.rb`),
817
- refuses to run unless `room_host` is `remote`, starts a Host, and runs until SIGTERM or SIGINT.
818
- On exit it asks every Room to finish its queued work and shut down, which sends `room_closed` to
819
- members. Pass `--require PATH` to boot from another app directory or a boot file.
820
-
821
- `--workers N` sets how many processes host Rooms. It defaults to the machine's core count. With
822
- `--workers 1` the command hosts Rooms in its own process. With more, it boots the app once and
823
- then forks `N` workers, each with its own Host, bus subscription, and Redis connections; the
824
- parent hosts nothing and only supervises. A worker that dies is replaced (with a growing delay
825
- if it keeps dying at boot, so a broken app can't fork-bomb the box). SIGTERM or SIGINT to the
826
- parent is relayed to every worker, and the parent waits for all of them before it exits `0`,
827
- so wrap it in the container's own deadline (`timeout -k 2m 24h bundle exec cable_room server`).
828
- Each worker tags what its Rooms log (the ActionCable logger) with `cable_room worker N` and shows
829
- up in `ps` as `cable_room server: worker N`.
830
-
831
- The rooms host exposes its load as `CableRoom::Host.current.open_ports`, the number of member
832
- ports attached across every Room it runs. Publish that as a gauge to scale the pool on.
833
-
834
- ### Provisioning.
835
-
836
- A `create: true` join publishes a `provision` request on the bus (`cr:provision`) naming the
837
- Room class and key. Every host hears it, in `inline` and `remote` alike: in `inline` that's the
838
- web process's own host, in `remote` it's every `cable_room server`. Each host waits a delay
839
- proportional to how many Rooms it already runs, then races for the Room's Redis lock. Exactly one
840
- wins and starts the Room, and it's usually the least loaded one; the others find the lock held and
841
- do nothing. A host that is draining never claims. The member keeps re-publishing the request on
842
- every ping until the Room acknowledges it, so a lost request costs at most one ping interval, and
843
- a member that joined before the Room existed completes its join as soon as the Room comes up.
844
-
845
- The delay is `provision_delay_ms × (open_rooms + jitter)`, with `jitter` a random number in
846
- `0..1` and `provision_delay_ms` defaulting to 20. A host running nothing waits at most one
847
- `provision_delay_ms`; a host with more Rooms always waits longer than one with fewer, so the
848
- jitter only breaks ties. `provision_requested.cable_room` fires on the member side for each request
849
- and `provision_claimed.cable_room` on the host side for each lock attempt (with `room_class`,
850
- `room_key`, `delay`, `open_rooms`, and `claimed`).
851
-
852
- `MyRoom.ensure(key)` still works inside a process that hosts Rooms, for starting a Room from a
853
- console or an initializer without any member asking.
854
-
855
- ### Using AnyCable.
856
-
857
- `broadcaster = :anycable` sends room broadcasts through `AnyCable.broadcast`, so anycable-go
858
- fans them out to sockets. The gem doesn't depend on AnyCable; add it yourself:
859
-
860
- ```ruby
861
- gem "anycable-rails"
862
- ```
863
-
864
- Boot with `:anycable` set and the gem missing, and CableRoom raises at startup naming the gem to
865
- add. AnyCable reads its own settings from the environment. Point it at the same Redis as
866
- anycable-go:
867
-
868
- ```sh
869
- export ANYCABLE_BROADCAST_ADAPTER=redis
870
- export ANYCABLE_REDIS_URL=redis://localhost:6379/1
871
- ```
872
-
873
- Room broadcasts are JSON-encoded with the same coder ActionCable uses, so a channel decodes them
874
- the same way whichever broadcaster sent them.
875
-
876
- #### What AnyCable buys, and what it doesn't.
877
-
878
- AnyCable moves two things out of Ruby: holding sockets, and fanning a broadcast out to them.
879
- It does not move the inbound path. Know which side of that line your traffic is on before you
880
- adopt it.
881
-
882
- **Outbound gets cheap.** Under ActionCable a Room broadcast to *N* members is *N* Redis
883
- deliveries, each decoded, re-encoded, and written by a Ruby worker thread; plus ActionCable's
884
- own 3-second heartbeat to every socket, also written by Ruby. Under AnyCable the Room publishes
885
- once and anycable-go writes *N* frames; heartbeats never touch Ruby. In a quiz deployment
886
- measured in August 2026 a 30-student room produced ~250 inbound messages, ~1,400 outbound socket
887
- deliveries, and ~700 heartbeats over its life — Ruby's share fell from all ~2,400 to the ~250
888
- inbound plus a few dozen publishes.
889
-
890
- **Inbound does not get cheaper.** Every client message is an RPC round trip from anycable-go to
891
- Ruby. On each one anycable-rails rebuilds the connection from its serialized identifiers,
892
- builds a new channel object, restores channel state, runs `receive`, and serializes state back.
893
- The socket read and JSON decode leave Ruby; the object rebuild and state round-trip arrive; the
894
- Room still processes the message once, as before. Per inbound message it is a wash, plus roughly
895
- half a millisecond to a millisecond of RPC latency on the reply. A protocol that is mostly
896
- inbound — cursor positions, keystroke sync, anything chatty from the client — gains little from
897
- AnyCable and should batch on the client instead. (AnyCable's "whispers" carry client-to-client
898
- messages without Ruby, but a whisper never reaches a Room.)
899
-
900
- **Two things make inbound worse than a wash if you let them:**
901
-
902
- - **Identifiers are deserialized on every RPC.** `identified_by :current_user` holding a record
903
- is serialized as a GlobalID and located again on each call — a database read per inbound
904
- message. Identify the connection by a primitive (a user id, a session key) and resolve the
905
- record lazily in the channel or the Room. Room→member scoping by user (`notify: user`,
906
- `broadcast(user: ...)`) works on whatever `as:` you pass to `join_room`; pass the same primitive.
907
- - **The gRPC server is one Ruby process.** `bundle exec anycable` serves every RPC from one
908
- interpreter with a thread pool (30 threads by default; anycable-go's concurrency limit defaults
909
- to 28), so a receive-heavy load hits one GVL. HTTP RPC mode (`--rpc_host http://...`) sends each
910
- command to the Rails app as a short HTTP request, which an app server spreads across its process
911
- pool; prefer it when inbound volume matters, and protect the endpoint with the bearer token.
912
-
913
- Everything else AnyCable changes is structural rather than per-message: sockets live in Go, so
914
- socket memory, `worker_connections`-style limits, and app-server routing rules for long-lived
915
- connections stop being Ruby's problem.
916
-
917
- #### What changes for the channel.
918
-
919
- anycable-rails builds a fresh channel object for every call the socket makes (subscribe, each
920
- message, unsubscribe, disconnect) and never re-runs `subscribed`. Three things follow:
921
-
922
- - **Memberships live in the channel state.** `join_room` records each membership's identity
923
- (token, Room, key, tags, `extra`, `create`, whether `hello` happened) with anycable-rails'
924
- `state_attr_accessor`, and `hello`, `receive`, `ping`, and `unsubscribed` rebuild it from there
925
- (`CableRoom::MembershipStore`). A rebuilt membership keeps the token subscribe created, opens
926
- no streams (anycable-go still holds them), and announces nothing until `hello`. Under plain
927
- ActionCable the channel object lives for the socket and none of this runs.
928
- - **No `on_*` callbacks fire.** Room→member traffic goes anycable-go → socket and never passes
929
- through this process, so `on_joined`, `on_message`, `on_room_opened`, and `on_room_closed` have
930
- nothing to fire on, and a rebuilt membership carries no procs, so `on_left` doesn't either.
931
- `RoomProxyChannel` needs none of them: the forwarding it does under ActionCable is what
932
- anycable-go does natively. `connected?` stays `false` on the server for the same reason;
933
- input is forwarded once `hello` has gone out (`accepts_input?`). The `port_connected` from
934
- `hello` and the input travel the same Bus channel from the same process, so the Room sees them
935
- in that order.
936
- - **Channel timers don't run.** `periodically` is disabled by anycable-rails, so the member-side
937
- ping and re-announce never happen on their own. The browser has to drive them.
938
- - **Unsupported `join_room` features raise.** `forward: false`, any `on_*:` callback, and a
939
- preconfigure block all need a message to pass through this process, so on an AnyCable-backed
940
- channel `join_room` raises `CableRoom::AnyCableUnsupported` at subscribe time naming the feature.
941
- `RoomProxyChannel`'s default join (`forward: true`, no callbacks) is the supported shape.
942
-
943
- #### What the browser must send.
944
-
945
- 1. Subscribe, and wait for `confirm_subscription`.
946
- 2. `perform("hello")`. Expect a `port_acknowledged` message within a few seconds. If it doesn't
947
- arrive, `perform("hello")` again: the server can't see that the acknowledgement was lost, so
948
- the retry that `ping!` does under ActionCable is the browser's job here. `port_connected` is
949
- idempotent Room-side, so a repeat is harmless.
950
- 3. `perform("ping")` every 15 seconds (`RoomMember::PING_INTERVAL`) for as long as the
951
- subscription is open. Each ping becomes a `port_ping` for the membership, so the Room keeps the
952
- port past `PORT_TIMEOUT`, and retries provisioning for a `create: true` join whose Room went
953
- away. A browser that stops pinging loses its port after 45 seconds.
954
- 4. On `room_closed`, leave: stop pinging and sending, and unsubscribe the channel. That is what
955
- the membership does by itself under ActionCable (`leave!`, which sends `port_disconnected` —
956
- the same thing unsubscribing does here). Under AnyCable the server never sees the broadcast,
957
- so a tab that keeps pinging would keep the port alive and, with `create: true`, re-provision
958
- the Room on its next ping. To rejoin, resubscribe — as under ActionCable.
959
-
960
- `KILL` is an administrative break-glass message. Under ActionCable the membership leaves when it
961
- sees one; under AnyCable the bare string reaches the socket and nothing else happens, which is
962
- fine — use `room_closed` (`shutdown!`) to end a Room for its members.
963
-
964
- Unsubscribing or closing the socket sends `port_disconnected` as before: anycable-go turns both
965
- into an RPC call that rebuilds the channel with its state. Both actions exist under plain
966
- ActionCable too, where `ping` is harmless and unnecessary.
967
-
968
- #### Dual-stack: ActionCable and AnyCable side by side.
969
-
970
- You may want some channels on AnyCable and others on ActionCable — high-fan-out Rooms on
971
- anycable-go, a channel that still needs `on_*` callbacks or `forward: false` on ActionCable, or a
972
- migration where the two overlap. Half of that works today and half needs one addition.
973
-
974
- **What already works: the member side is decided per connection, not per process.** The gem
975
- checks `connection.anycabled?` on the channel it is given. A channel reached through anycable-go
976
- gets the AnyCable behavior above (state store, `hello`/`ping` actions, no callbacks); the same
977
- channel class reached through a Passenger-served ActionCable socket gets the classic behavior.
978
- Both can run in one Rails process at once, provided both socket servers are up and routed:
979
-
980
- ```nginx
981
- location /cable { proxy_pass http://127.0.0.1:8080; proxy_http_version 1.1;
982
- proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; }
983
- location /cable-classic { passenger_app_group_name myapp_action_cable;
984
- passenger_force_max_concurrent_requests_per_process 0; }
985
- ```
986
-
987
- Which stack a channel uses is then the browser's choice of consumer, per feature:
988
-
989
- ```js
990
- const fast = createConsumer("/cable") // anycable-go
991
- const classic = createConsumer("/cable-classic") // ActionCable
992
- fast.subscriptions.create({ channel: "QuizChannel", ... }, { connected() { this.perform("hello") }, ... })
993
- classic.subscriptions.create({ channel: "LegacyDashboardChannel" }, { ... })
994
- ```
995
-
996
- `config/cable.yml` keeps its `redis` adapter for the classic sockets; anycable-go and
997
- `AnyCable.broadcast` use `ANYCABLE_*`. Nothing in the gem needs to know which channel classes go
998
- where. If you want to enforce it server-side, a channel can `reject` in `subscribed` when
999
- `anycable_channel?` disagrees with where it belongs.
1000
-
1001
- **What does not work yet: a Room broadcasts to one stack.** `broadcaster` is process-wide and
1002
- chooses *either* `ActionCable.server.broadcast` *or* `AnyCable.broadcast`, so with
1003
- `broadcaster = :anycable` a Room's messages reach anycable-go sockets only, and members joined
1004
- through `/cable-classic` hear nothing (their `hello` and input still reach the Room; the Room's
1005
- replies don't come back). Dual-stack therefore needs a `:both` broadcaster that publishes every
1006
- Room message through both adapters. It is one class and one allowed value in
1007
- `CableRoom::Config::BROADCASTERS`; the cost is a second publish per Room broadcast (per fan-out,
1008
- not per socket — the ActionCable adapter then does the classic *N* deliveries for the classic
1009
- members, as it does today). Until it exists, run one stack at a time, or make sure every member
1010
- of a given Room joins through the same stack.
1011
-
1012
- Rooms themselves never know which stack a member is on: `port_connected`, `port_ping`, input,
1013
- and `port_disconnected` arrive on the same Bus channel from either path, and stream names are
1014
- identical.
1015
-
1016
- ### Redis.
1017
-
1018
- Three things need a Redis, and each reads its own variables. They can all point at one server
1019
- to start; splitting them later is a config change.
1020
-
1021
- | Variable | Read by | Purpose |
1022
- | ----------------------------------------------- | ---------------------- | -------------------------------------------------------- |
1023
- | `CABLEROOM_REDIS_URL` | cable_room | The bus (member→room traffic, provisioning, handoff) and the Room locks. Must be reachable from every web process and every rooms host. |
1024
- | `REDIS_URL` | ActionCable's adapter | Room→member streams under `broadcaster = :action_cable` (`config/cable.yml` decides; this is the usual name). |
1025
- | `ANYCABLE_BROADCAST_ADAPTER` / `ANYCABLE_REDIS_URL` | anycable-go and `AnyCable.broadcast` | Room→member streams under `broadcaster = :anycable`. Set the adapter to `redis` and point both the Go process and the Rails process at the same URL. |
1026
-
1027
- CableRoom keeps its own pool for the bus and the locks, separate from the ActionCable adapter's:
1028
-
1029
- | Variable | Purpose |
1030
- | ---------------------------- | -------------------------------------------------------- |
1031
- | `CABLEROOM_REDIS_URL` | The connection URL. |
1032
- | `CABLEROOM_REDIS_PROVIDER` | Name of another variable holding the URL. |
1033
- | `CABLEROOM_REDIS_POOL_SIZE` | Pool size. Defaults to `RAILS_MAX_THREADS`, then five. |
1034
-
1035
- Without a prefixed variable it falls back to `REDIS_PROVIDER` and `REDIS_URL`, so a single-Redis
1036
- app needs no CableRoom-specific configuration at all. Reach the pool directly with
1037
- `CableRoom.redis { |conn| ... }`, the lock manager with `CableRoom.lock_manager`, and the bus with
1038
- `CableRoom.bus`.
1039
-
1040
- Room threads come from a pool sized by ActionCable's own `worker_pool_size`.
1041
-
1042
- Timings live in constants:
1043
-
1044
- | Constant | Default | What it controls |
1045
- | --------------------------------- | -------------- | --------------------------------------- |
1046
- | `Room::Base::LOCK_DURATION` | `15.seconds` | Redis lock TTL, extended on every beat. |
1047
- | `Room::Base::WATCH_DOG_INTERVAL` | `15.seconds` | How stale a watchdog ping may get. |
1048
- | `PortManagement::PORT_TIMEOUT` | `45.seconds` | How long a silent port survives. |
1049
- | `Host::BEAT_INTERVAL` | `5.seconds` | Lock extension and watchdog sweep. |
1050
-
1051
- The first two are read as `self::CONSTANT`, so a Room subclass can redefine them. The other two
1052
- are module constants that apply process-wide.
1053
-
1054
- ### Shutdown.
1055
-
1056
- On process exit, CableRoom asks every local Room to shut down gracefully and waits up to 15
1057
- seconds for them to drain. It also hooks ActionCable's `restart`, so a code reload in development
1058
- stops Rooms instead of orphaning their locks. `cable_room server` with no Rooms open does the
1059
- same when it gets SIGTERM or SIGINT; with Rooms open it migrates them first (next section).
1060
-
1061
- ### Planned shutdown and migration.
1062
-
1063
- A `cable_room server` that gets SIGTERM or SIGINT with Rooms open doesn't close them. It hands
1064
- them to its peers, one Redis round trip at a time, and members never notice: their streams stay
1065
- open, their messages keep arriving in order, and the Room's broadcasts resume from the new host.
1066
- The same thing is available from code as `CableRoom::Host.current.drain!(reason: "...")`.
1067
-
1068
- What moves is what the gem owns — every port with its token, tags, user, and last-seen time, the
1069
- user map, and each reaper's deadline — plus whatever the Room returns from `snapshot_state`
1070
- (see [Migration hooks](#migration-hooks)). A Room without `snapshot_state` still moves; it runs
1071
- `startup` again on the new host to rebuild its own state.
1072
-
1073
- Per Room, in order:
1074
-
1075
- 1. **Freeze.** The Room finishes what it has queued and stops. Its inbound subscription stays up;
1076
- from here on every message a member sends is pushed to a Redis list instead of handled.
1077
- 2. **Snapshot.** The gem takes the snapshot. A Room whose `snapshot_state` isn't JSON, or raises,
1078
- can't move: it closes with `room_closed`, the error is reported, and the drain moves on.
1079
- 3. **Offer.** The snapshot is parked in Redis with a 60 second TTL, the Room's lock is released,
1080
- and a provision request flagged `handoff` goes out. Every peer hears it and races for the lock
1081
- the same load-weighted way it does for a new Room; the draining host never claims.
1082
- 4. **Adopt.** The winner rebuilds the Room from the snapshot, replays the relayed messages, and
1083
- takes over the inbound subscription. A short exchange of markers between the two hosts decides,
1084
- message by message, which of them runs each one, so nothing is lost, doubled, or reordered.
1085
- 5. **Let go.** The old host drops its copy without a word to the members and reports
1086
- `room_migrated.cable_room`.
1087
-
1088
- Nobody adopts within `handoff_timeout` (default 10 seconds) — a fleet of one, say — and the Room
1089
- takes its lock back and closes with `room_closed`; members re-provision on their next ping and get
1090
- a fresh Room somewhere. Rooms go oldest first, four at a time. When `drain_timeout` (default 10
1091
- minutes) passes with Rooms still waiting, all of them are offered at once, so the drain ends within
1092
- about one more `handoff_timeout` whatever the fleet does. Then the host shuts down.
1093
-
1094
- Both timeouts are config knobs (`c.handoff_timeout`, `c.drain_timeout`). To know when a drain has
1095
- finished — to complete an ASG termination lifecycle action, say — register a callback:
1096
-
1097
- ```ruby
1098
- CableRoom::Host.after_drain do |host, result|
1099
- result.migrated # the Rooms that moved (CableRoom::Migration objects)
1100
- result.closed # the Rooms that closed instead
1101
- result.duration # seconds
1102
- Aws::AutoScaling::Client.new.complete_lifecycle_action(...)
1103
- end
1104
- ```
1105
-
1106
- The container has to give the process that long. With CodeDeploy, set the `ApplicationStop` hook
1107
- timeout to `drain_timeout` plus two minutes; on an ASG, put a termination lifecycle hook in front of
1108
- the instance with a heartbeat timeout at least as long, and complete it from `after_drain`. The
1109
- `--workers N` supervisor relays the signal to every worker and waits for all of them, without a
1110
- deadline of its own.
1111
-
1112
- In `inline` mode the gem doesn't migrate on exit: there's no fleet of Room hosts by design, and a
1113
- web process that exits closes its Rooms the way it always has. An app that runs several inline
1114
- processes can still call `drain!` from its own signal handling; other inline processes will adopt.
1115
-
1116
- ## Introspection.
1117
-
1118
- ```ruby
1119
- CableRoom::Room.locally_open_rooms # every Room running in this process
1120
- QuizRoom.locally_running_instances # just the QuizRooms
1121
- CableRoom::Host.current # this process's Host, or nil if it hosts no Rooms
1122
- CableRoom::Host.current&.open_ports # member ports attached across every local Room
1123
- ```
1124
-
1125
- All of these are process-local. There's no cluster-wide registry — the Redis lock is the only
1126
- source of truth about who owns a key.
1127
-
1128
- ## Subclassing.
1129
-
1130
- Room classes build a private `PortClient` for each subclass, chained to the parent's.
1131
- Periodic timers, callbacks, policies, and tag aliases all inherit correctly through
1132
- however many levels you need:
1133
-
1134
- ```ruby
1135
- class BaseGameRoom < CableRoom::Room::Base
1136
- periodically :tick, every: 1.second
1137
- reap_when { connected_users.empty? }
1138
- end
1139
-
1140
- class TriviaRoom < BaseGameRoom
1141
- # keeps tick and the reaper, adds its own
1142
- periodically :rotate_question, every: 30.seconds
1143
- end
1144
- ```
1145
-
1146
- Note that a Room's pubsub keys derive from its class name, so anonymous Room classes won't work.
1147
-
1148
- ## Upgrading from 0.6 to 1.0.
1149
-
1150
- 1.0 is one gem release, and an app flips every knob in one deploy. Room definitions don't change.
1151
- The channel contract and the deployment do. `CHANGELOG.md` lists every breaking change; this is
1152
- the order to work through them.
1153
-
1154
- ### Before the deploy.
1155
-
1156
- 1. **Send `hello` from every client.** Add `connected() { this.perform("hello") }` to each
1157
- subscription that joins a Room. Nothing is announced until it arrives, and there's no
1158
- fallback. Shipping it early is harmless: a 0.6 server logs an unknown action and carries on.
1159
- Shipping it late isn't: a 1.0 server never hears a member that doesn't say it.
1160
- 2. **Plan for open tabs.** A browser tab open across the deploy speaks the old handshake. It
1161
- still receives the shared stream, but the Room never sees it and drops everything it sends.
1162
- Deploy when few people are in a Room, and have the client show "reload to continue" when it
1163
- gets `room_closed` after the deploy.
1164
- 3. **Find internal names you reached for.** Inside a Room, `@cable_channel` is `@runner`; an
1165
- error handler reading `context[:channel]` should read `context[:runner]`; `Room#params` is
1166
- gone; `ChannelTracker::BEAT_INTERVAL` is `Host::BEAT_INTERVAL`. Anything that called
1167
- `MyRoom.ensure` from a web process needs a look: with `room_host = :remote` it raises
1168
- `CableRoom::Host::NotHosting`, and a `create: true` join provisions the Room for you.
1169
- 4. **Give the bus a Redis every process can reach.** `CABLEROOM_REDIS_URL` (or its `REDIS_URL`
1170
- fallback) now carries member→room traffic, not just locks. A web process and a rooms host
1171
- that can't both see it can't talk. Same server as ActionCable is fine.
1172
- 5. **Check `PORT_TIMEOUT`.** It's 45 seconds now, not 30. If you tuned client ping intervals or
1173
- tests around the old number, adjust them.
1174
- 6. **Check `room_closed` handling.** The `reason` is never `nil` any more. A client that
1175
- treated `reason: nil` as "server restart" should look for `"Server shutting down"` or the
1176
- signal name instead.
1177
- 7. **Add `snapshot_state` and `restore_state`** to any Room whose in-memory state should
1178
- survive a host moving it. Rooms without them still move; they run `startup` again. Do this
1179
- before turning on `:remote`, or the first drain rebuilds every Room from scratch.
1180
- 8. **Decide the deployment mode.** Stay on `inline` + `action_cable` and nothing else changes.
1181
- For `remote`:
1182
- - run `bundle exec cable_room server --workers N` on the rooms hosts under a deadline the
1183
- container owns (`timeout -k 2m 24h ...`);
1184
- - set `CABLE_ROOM_HOST=remote` on web and rooms hosts alike;
1185
- - give the container `drain_timeout` plus two minutes to stop, and complete any ASG
1186
- termination lifecycle hook from `CableRoom::Host.after_drain`;
1187
- - publish `CableRoom::Host.current.open_ports` as the gauge to scale on.
1188
-
1189
- For `anycable`, also add `gem "anycable-rails"`, run anycable-go and the `anycable` RPC next
1190
- to each web process, set `CABLE_ROOM_BROADCASTER=anycable` on web and rooms hosts, set
1191
- `ANYCABLE_BROADCAST_ADAPTER=redis` and `ANYCABLE_REDIS_URL`, and make the browser drive
1192
- liveness: `perform("ping")` every 15 seconds and a second `hello` if `port_acknowledged`
1193
- doesn't arrive (see [What the browser must send](#what-the-browser-must-send)).
1194
-
1195
- ### After the deploy.
1196
-
1197
- - Watch `hello_received.cable_room` and `port_connected.cable_room`. Members that subscribe but
1198
- never `hello` are the old tabs.
1199
- - In `remote`, watch `provision_claimed.cable_room` with `claimed: true` on the rooms hosts and
1200
- `CableRoom::Room.locally_open_rooms` staying empty on web.
1201
- - Trigger one planned restart of a rooms host and confirm `room_migrated.cable_room` fires for
1202
- each open Room with `closed: 0` in `host_draining.cable_room`.
1203
-
1204
- ### Attribution runbook.
1205
-
1206
- If load or latency regresses after the all-knobs deploy, several things changed at once. Flip
1207
- them back one at a time, in this order, and re-measure after each:
1208
-
1209
- 1. **`broadcaster` back to `action_cable`** (`CABLE_ROOM_BROADCASTER=action_cable` on web and
1210
- rooms hosts, and route `/cable` back to ActionCable; one deploy). Rooms stay remote. If this
1211
- fixes it, the problem is in the anycable-go path.
1212
- 2. **`room_host` back to `inline`** (`CABLE_ROOM_HOST=inline`, one deploy; stop the
1213
- `cable_room server` pool). If this fixes it, the problem is in the remote hosting path: bus
1214
- latency, provisioning, or the rooms pool's sizing.
1215
-
1216
- Both are env changes, so neither needs a code change or a gem downgrade. `hello` stays either
1217
- way; it's part of 1.0 in every mode.
1218
-
1219
- ## Development.
1220
-
1221
- Rooms need Redis and, for the test suite, Postgres:
1222
-
1223
- ```sh
1224
- bundle install
1225
- bundle exec rspec
1226
- ```
1227
-
1228
- To run against every supported Rails version:
1229
-
1230
- ```sh
1231
- bundle exec appraisal install
1232
- bundle exec appraisal rspec
1233
- ```
1234
-
1235
- The suite has two halves. Unit specs use `RoomHarness#build_room`, which runs a Room against a
1236
- stub channel with no Redis and no pubsub, so logic is testable synchronously. End-to-end specs
1237
- run the async ActionCable adapter and real message delivery, and wait on observable conditions
1238
- with `wait_until` rather than sleeping.
1239
-
1240
- `spec/internal` holds a Combustion app, so `rackup` boots a minimal Rails host if you want to
1241
- poke at Rooms by hand.
1242
-
1243
- ### The anycable-go E2E.
1244
-
1245
- `spec/e2e` proves the member protocol on a real wire: a WebSocket client subscribes through
1246
- anycable-go, says `hello`, gets `port_acknowledged`, sends a message and gets the Room's reply,
1247
- pings, and unsubscribes or closes, with anycable-rails rebuilding the channel from its state on
1248
- every call. It's excluded from `bundle exec rspec` and runs only when `CABLE_ROOM_E2E=1` is set.
1249
- CI runs it in its own job. `spec/cable_room/anycable_channel_state_spec.rb` drives the same
1250
- anycable-rails RPC handler in-process, without anycable-go, and is part of the default suite.
1251
-
1252
- Locally it needs Redis and anycable-go. Start anycable-go from Docker, pointed at the RPC server
1253
- the spec starts inside its own process:
1254
-
1255
- ```sh
1256
- docker run --rm -p 8080:8080 \
1257
- -e ANYCABLE_HOST=0.0.0.0 \
1258
- -e ANYCABLE_RPC_HOST=host.docker.internal:50051 \
1259
- -e ANYCABLE_BROADCAST_ADAPTER=redis \
1260
- -e ANYCABLE_REDIS_URL=redis://host.docker.internal:6379/0 \
1261
- anycable/anycable-go:1.6
1262
- ```
1263
-
1264
- Then, in another shell:
1265
-
1266
- ```sh
1267
- CABLE_ROOM_E2E=1 ANYCABLE_REDIS_URL=redis://localhost:6379/0 bundle exec rspec spec/e2e
1268
- ```
1269
-
1270
- The spec process is the Rails app, the AnyCable RPC server (listening on `0.0.0.0:50051`; set
1271
- `ANYCABLE_RPC_HOST` to change it), and the room host. Set `CABLE_ROOM_E2E_WS_URL` if anycable-go
1272
- isn't at `ws://127.0.0.1:8080/cable`. If anycable-go isn't reachable the spec fails; it never
1273
- skips.
1274
-
1275
- Things that bite on a laptop:
1276
-
1277
- - A Homebrew Redis listens on `127.0.0.1` only, so the container can't reach it. Run one from
1278
- Docker instead (`docker run -d -p 6380:6379 redis:7.2`) and point both `ANYCABLE_REDIS_URL`s at
1279
- port 6380.
1280
- - On plain Docker Engine (Linux) add `--add-host host.docker.internal:host-gateway`. Don't on
1281
- Docker Desktop or Rancher Desktop; there it overrides the built-in name with the VM's bridge
1282
- address and the RPC becomes unreachable.
1283
- - If something else already has port 8080 (an ssh tunnel, VS Code's port forwarding), publish a
1284
- different one (`-p 18080:8080`) and set `CABLE_ROOM_E2E_WS_URL=ws://127.0.0.1:18080/cable`. If a
1285
- tool grabs the loopback port after Docker does, use your machine's LAN address in the URL.
1286
- - Restart anycable-go between runs. Each rspec process stops its RPC on exit, and anycable-go's
1287
- gRPC client then backs off for minutes; until it reconnects every socket is closed with "Auth
1288
- Error". CI gets a fresh container per run for the same reason.