cable_room 0.6.2.beta1 → 0.6.2.beta3

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 34ff8028043d9407e2f5f9487c472873b8ad56c1cda136ad50038ca28be578ff
4
- data.tar.gz: 401c6500ac0d1c065967a4901a4a2e3d0ed75b1a8ffc3101260e820b5fff5bc4
3
+ metadata.gz: 5984faba13ce4bcbfc7cdcde1eee9a268ca8ea59227efd2cbe41532d67892782
4
+ data.tar.gz: 651af8589e9709f27e3cfee877fe2359c931919d3c853c74d097147b245d37f1
5
5
  SHA512:
6
- metadata.gz: 83df8225dca28fd3c4fa70434698fea93bfd05ab495c5cac8d8718cb5acfe7a8c5800548a2332d2200247efc1dd29cd900d161e1914a75b3f34c908c972c4d24
7
- data.tar.gz: 25e0451d688da7390793a0cca348e488b618c89dc8e98b510b775e7babdaf4df912c583b1bb1b0d04b8567c899646075c5abb27062e9e71de13f2fd458eb7169
6
+ metadata.gz: 9f85aa97a935a9cc11049b5ce408f06cacca2ff1309800e13b4b4a9b750c68612d3991410f081e5c99a43b0282bbc42071418d856d728c3bda03381ec48ba85c
7
+ data.tar.gz: f94b0ac04d23f0bbb411b7f1e9432b09f778756046bcc6b6f3ed02557ca1020f97dc62e6c238b4259d581a92647cba726251037d2b59fd2c93c884c139a59ed4
data/README.md CHANGED
@@ -1,643 +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
- - [Development](#development)
32
-
33
- ## How it works.
34
-
35
- Every Room is backed by a synthetic ActionCable channel that has no browser connection behind it.
36
- That channel holds a Redis lock on the Room's key, which is what guarantees a single instance
37
- cluster-wide. Members don't talk to the Room directly. They publish to, and subscribe from, Redis
38
- pubsub keys called ports.
39
-
40
- ```
41
- Browser Your ActionCable Channel The Room
42
- | (include RoomMember) (Room::Base subclass)
43
- | | |
44
- | --- websocket message --> | --- to_room port -------------> | handle_received_message
45
- | | |
46
- | <-- websocket message --- | <-- from_room port ----------- | broadcast / self <<
47
- | | <-- <token> port ------------- | reply
48
- | | <-- <user> / <tag> port ------ | broadcast(tag: :admin)
49
- ```
50
-
51
- Four things follow from that design:
52
-
53
- - **One instance, many processes.** Any process can call `MyRoom.ensure(key)`. The first one to
54
- win the Redis lock runs the Room; the rest get `false` and just publish to its ports.
55
- - **No connection affinity.** Members can be spread across every app server. They only need Redis.
56
- - **Single-threaded Room state.** Messages and timers run one at a time, so you can touch
57
- instance variables without locks.
58
- - **Rooms are disposable.** A Room is expected to die when it's idle and be re-created on demand.
59
- Persist anything you can't lose.
60
-
61
- ## Requirements.
62
-
63
- - Ruby 3.4 (CI runs 3.4)
64
- - Rails 7.2 through 8.x
65
- - Redis, for both the ActionCable adapter and the Room locks
66
-
67
- ## Installation.
68
-
69
- ```ruby
70
- gem "cable_room"
71
- ```
72
-
73
- Then point it at Redis:
74
-
75
- ```sh
76
- export CABLEROOM_REDIS_URL=redis://localhost:6379/1
77
- ```
78
-
79
- See [Configuration](#configuration) for the full list of variables.
80
-
81
- ## Quick start.
82
-
83
- Define a Room:
84
-
85
- ```ruby
86
- class ChatRoom < CableRoom::Room::Base
87
- # Shut down 30 seconds after the last member leaves
88
- reap_when { connected_clients.empty? }
89
-
90
- after_startup do
91
- @history = []
92
- end
93
-
94
- # Handles { "type": "chat", "body": "..." } from any member
95
- def on_chat(msg)
96
- entry = { user: message_origin.user, body: msg["body"], at: Time.current }
97
- @history << entry
98
- broadcast({ type: "chat", **entry })
99
- end
100
-
101
- # Send the backlog only to the member who just connected
102
- on_port_connected do
103
- reply({ type: "history", entries: @history })
104
- end
105
- end
106
- ```
107
-
108
- Define a channel that joins it:
109
-
110
- ```ruby
111
- class ChatChannel < ApplicationCable::Channel
112
- include CableRoom::RoomProxyChannel
113
-
114
- subscribe_to_room do
115
- join_room ChatRoom, params[:room_id], create: true, tags: params[:tags]
116
- end
117
- end
118
- ```
119
-
120
- That's the whole loop. `RoomProxyChannel` forwards everything the client sends into the Room and
121
- everything the Room broadcasts back out to the client. `create: true` means this channel will
122
- provision the Room if it isn't already running somewhere.
123
-
124
- ## Defining a Room.
125
-
126
- ### Lifecycle.
127
-
128
- ```ruby
129
- class MyRoom < CableRoom::Room::Base
130
- before_startup { } # streams aren't open yet
131
- after_startup { } # aliased as on_startup
132
- before_shutdown { } # last chance to broadcast
133
- after_shutdown { } # aliased as on_shutdown
134
- end
135
- ```
136
-
137
- You can also just define `startup` and `shutdown` methods; they run inside the corresponding
138
- callback chain.
139
-
140
- Out of the box a Room broadcasts `{ type: "room_opened" }` after startup and
141
- `{ type: "room_closed", reason: ... }` before shutdown.
142
-
143
- To stop a Room from inside itself:
144
-
145
- ```ruby
146
- shutdown!("everyone left") # graceful: drains queued messages first
147
- stop! # immediate: drops anything pending
148
- ```
149
-
150
- `lifecycle_state` returns `:initializing`, `:starting`, `:started`, `:shutting_down`, or `:dead`.
151
-
152
- ### Handling messages.
153
-
154
- Inbound messages arrive on the `:to_room` port and dispatch by `type`. A message of type
155
- `"start_quiz"` (or `"StartQuiz"`) calls `on_start_quiz`. Unknown types log a warning.
156
-
157
- ```ruby
158
- def on_start_quiz(msg)
159
- logger.info "starting with #{msg['question_count']} questions"
160
- end
161
- ```
162
-
163
- Inside a handler:
164
-
165
- | Helper | What it gives you |
166
- | ----------------- | ------------------------------------------------------- |
167
- | `message` | The raw message hash |
168
- | `message_origin` | The `PortClient` that sent it |
169
- | `reply(data)` | Send back to that port alone |
170
- | `broadcast(data)` | Send to every member |
171
-
172
- Override `handle_received_message(message)` if you'd rather dispatch yourself. Call `super` for
173
- anything you don't handle, so the built-in port and user bookkeeping keeps working.
174
-
175
- Sending the string `"KILL"` to the `:to_room` port shuts the Room down. It's a blunt instrument,
176
- useful in a console.
177
-
178
- ### Timers.
179
-
180
- ```ruby
181
- class MyRoom < CableRoom::Room::Base
182
- periodically :tick, every: 5.seconds
183
- periodically -> { broadcast({ type: "still_here" }) }, every: 1.minute
184
-
185
- def tick; end
186
- end
187
- ```
188
-
189
- Timer bodies run on the Room's thread, so they're serialized against message handling.
190
-
191
- ## Ports and messaging.
192
-
193
- A port is one Redis pubsub key derived from the Room class, the Room key, and a port name. Two
194
- are reserved:
195
-
196
- - `:to_room` — many-to-one. Members publish here; the Room streams from it.
197
- - `:from_room` — one-to-many. The Room publishes here; every member streams from it.
198
-
199
- Every member also gets a private port named after its random token, plus a port for the user it
200
- joined as and one for each tag it carries. That's how targeted delivery works without the Room
201
- tracking connections.
202
-
203
- ### Sending.
204
-
205
- ```ruby
206
- self << { type: "tick" } # to :from_room, i.e. everyone
207
- broadcast({ type: "tick" }) # same thing
208
- broadcast({ type: "secret" }, client_port: token) # one port
209
- broadcast({ type: "hi" }, user: "user_42") # every port that user joined from
210
- broadcast({ type: "tools" }, tag: :admin) # every port carrying the tag
211
- reply({ type: "pong" }) # the port whose message you're handling
212
- message_origin << { type: "pong" } # the same, spelled differently
213
- ```
214
-
215
- Combining a target with a tag makes the tag a filter, not a second audience.
216
- `broadcast(msg, user: "user_42", tag: :admin)` reaches that user only if one of their ports is
217
- tagged `admin`, and sends nothing otherwise.
218
-
219
- ### Scoping.
220
-
221
- `with_port_scope` sets an ambient target so nested code doesn't have to pass it around:
222
-
223
- ```ruby
224
- with_port_scope(tag: :admin) do
225
- broadcast({ type: "diagnostics", data: expensive_report })
226
- end
227
- ```
228
-
229
- Scopes merge when nested. `without_port_scope` clears them. `with_port_scope!` skips the block
230
- entirely when nothing matches, which is the cheap way to avoid building a payload nobody will
231
- receive. The block form of `reply` does the same for a single port:
232
-
233
- ```ruby
234
- reply do
235
- broadcast({ type: "a" })
236
- broadcast({ type: "b" })
237
- end
238
- ```
239
-
240
- ### Custom ports.
241
-
242
- Ports aren't limited to the built-ins. Open your own for a side channel:
243
-
244
- ```ruby
245
- ports[:telemetry] << { fps: 60 }
246
-
247
- stream_port(:control) do |message|
248
- logger.info "control: #{message.inspect}"
249
- end
250
- ```
251
-
252
- Ports opened with `stream_port` close automatically at shutdown.
253
-
254
- ### Port liveness.
255
-
256
- Members ping every 10 seconds. A port that goes quiet for 30 seconds
257
- (`PortManagement::PORT_TIMEOUT`) is dropped, and `on_port_disconnected` runs for it with
258
- `message_origin` still set, so cleanup can tell which port went away.
259
-
260
- ```ruby
261
- on_port_connected { logger.info "port #{message_origin.token} joined" }
262
- on_port_disconnected { logger.info "port #{message_origin.token} gone" }
263
- ```
264
-
265
- `connected_clients` returns the live `PortClient` objects. Each one carries a `token`, its `tags`,
266
- its `user`, and any extra metadata the member passed in. Read and write metadata with `[]` and
267
- `[]=`.
268
-
269
- ## Users.
270
-
271
- Members can join *as* a user. CableRoom then collapses that user's ports into a single identity,
272
- so a person with three browser tabs joins once and leaves once.
273
-
274
- ```ruby
275
- class MyRoom < CableRoom::Room::Base
276
- on_user_joined { broadcast({ type: "joined", user: message_origin.user }) }
277
- on_user_left { broadcast({ type: "left", user: message_origin.user }) }
278
- end
279
- ```
280
-
281
- `on_user_joined` fires on the first port for that user; `on_user_left` fires when the last one
282
- goes away. `connected_users` lists them, and `all_user_tags(user)` unions the tags across every
283
- port that user is connected from.
284
-
285
- A `RoomMember` channel that defines `current_user` passes it automatically. Pass `as:` to override
286
- it, or `as: nil` for an anonymous port. The value is serialized with ActiveJob's argument
287
- serializer, so an ActiveRecord object survives the trip and arrives as the same record.
288
-
289
- ## Authorization.
290
-
291
- Two layers, and they compose. Use guards for anything that depends on the message; use tag
292
- policies for anything that depends on who's asking.
293
-
294
- ### Guards.
295
-
296
- ```ruby
297
- class MyRoom < CableRoom::Room::Base
298
- # Block, symbol, or proc. Return false to drop the message.
299
- authorize_inbound { |message| message["body"].to_s.length < 1_000 }
300
- authorize_inbound :quiz_running?, only: [:answer, :skip]
301
- authorize_inbound :not_locked?, except: :leave
302
-
303
- protected
304
-
305
- # Zero-arity guards read `message` themselves
306
- def quiz_running? = @state == :running
307
- def not_locked?(msg) = !@locked
308
- end
309
- ```
310
-
311
- A dropped message logs a warning and never reaches a handler.
312
-
313
- ### Tag policies.
314
-
315
- Members join with tags (`join_room MyRoom, key, tags: [:admin]`). Policies then say which tags
316
- may trigger which handlers.
317
-
318
- ```ruby
319
- class MyRoom < CableRoom::Room::Base
320
- inbound_tag_policy do
321
- deny :muted, :chat # muted members can't chat...
322
- allow :*, :chat # ...but everyone else can
323
- allow :admin, [:kick, :ban] # admins get the moderation verbs
324
- end
325
- end
326
- ```
327
-
328
- Two rules govern how this resolves:
329
-
330
- 1. **Declaring any policy flips the default to deny.** Before you write one, everything is
331
- allowed. After, only what you allow is allowed. The built-in connection and user messages stay
332
- permitted, so members can still join and leave.
333
- 2. **Highest priority wins.** Rules default to priority 10. Pass `priority:` to layer a base
334
- policy under, or an override over, another. `inbound_tag_policy(priority: -10)` adds
335
- permissions without flipping the default.
336
-
337
- Within a priority, the first matching rule decides, and rules match in declaration order. That
338
- means a `deny` exception has to come **before** the broad `allow` it carves out of — write
339
- `allow :*, :chat` first and it swallows every member, muted ones included. When the ordering
340
- matters a lot, give the two rules different priorities instead of relying on where they sit in
341
- the block:
342
-
343
- ```ruby
344
- inbound_tag_policy(priority: 20) { deny :muted, :chat }
345
- inbound_tag_policy(priority: 10) { allow :*, :chat }
346
- ```
347
-
348
- Group related handlers behind one name with `define_tag_alias`. A rule written against the alias
349
- covers everything it implies:
350
-
351
- ```ruby
352
- class MyRoom < CableRoom::Room::Base
353
- define_tag_alias :moderation, [:kick, :ban, :mute]
354
-
355
- inbound_tag_policy do
356
- allow :admin, :moderation
357
- end
358
- end
359
- ```
360
-
361
- Aliases are per Room class and inherited by subclasses, so one Room's vocabulary can't change how
362
- another Room reads its policies.
363
-
364
- ### System message types.
365
-
366
- Some message types are the framework's, not the client's. Members can't forge them:
367
-
368
- ```ruby
369
- class MyRoom < CableRoom::Room::Base
370
- system_message_types :score_awarded, :quiz_finished
371
- end
372
- ```
373
-
374
- Attempts to send one from a member are dropped with a warning at the sender. `port_connected`,
375
- `port_disconnected`, `port_ping`, `user_joined`, and `user_left` are already protected.
376
-
377
- ## Reaping and the watchdog.
378
-
379
- Rooms hold memory and a Redis lock, so they need to know when to quit. `reap_when` declares a
380
- check that runs on a timer:
381
-
382
- ```ruby
383
- class MyRoom < CableRoom::Room::Base
384
- # Idle for 30 seconds with nobody connected -> shut down
385
- reap_when { connected_clients.empty? }
386
-
387
- # Tighter window, and named so the shutdown reason says which check fired
388
- reap_when(key: :abandoned, grace: 5.minutes, interval: 30.seconds) do
389
- connected_users.empty?
390
- end
391
-
392
- # Return :reap to skip the grace period entirely
393
- reap_when(grace: 1.hour) { @cancelled ? :reap : false }
394
- end
395
- ```
396
-
397
- - **Truthy** starts the grace clock. Once the condition has held for `grace:` (default 30
398
- seconds), the Room shuts down.
399
- - **Falsey** resets the clock and pings the watchdog.
400
- - **`:reap`** shuts down now, whatever the grace period says.
401
-
402
- Declare as many checks as you like; each gets its own timer and its own grace clock. Call
403
- `check_reapers_now!` to run them all immediately instead of waiting for the next tick.
404
-
405
- ### The watchdog.
406
-
407
- Separately, every Room is supervised. Every five seconds its channel extends the Redis lock and
408
- confirms the Room has pinged its watchdog within the last 15 seconds
409
- (`Room::Base::WATCH_DOG_INTERVAL`). Lose the lock and the Room stops, since another process may
410
- now own the key. Miss the ping and it shuts down as wedged.
411
-
412
- **Reaper checks are what ping the watchdog.** A Room that declares no `reap_when` has nothing
413
- pinging it, so the watchdog will shut it down about 15 seconds after startup. Every long-lived
414
- Room needs at least one `reap_when` — or its own timer calling `ping_watchdog` — to stay up.
415
-
416
- ## Joining a Room from a channel.
417
-
418
- ### The proxy shortcut.
419
-
420
- When the client only needs a pipe to the Room, `RoomProxyChannel` is the whole channel:
421
-
422
- ```ruby
423
- class QuizChannel < ApplicationCable::Channel
424
- include CableRoom::RoomProxyChannel
425
-
426
- subscribe_to_room do
427
- join_room QuizRoom, params[:quiz_id], create: true
428
- end
429
- end
430
- ```
431
-
432
- It wires up `subscribed`, `receive`, and `unsubscribed`, and forwards messages both ways.
433
-
434
- ### Full control.
435
-
436
- `RoomMember` gives you the membership without the forwarding, so the channel can filter,
437
- transform, or fan out:
438
-
439
- ```ruby
440
- class QuizChannel < ApplicationCable::Channel
441
- include CableRoom::RoomMember
442
-
443
- def subscribed
444
- @membership = join_room(
445
- QuizRoom,
446
- params[:quiz_id],
447
- create: true,
448
- tags: current_user.teacher? ? [:admin] : [:student],
449
- extra: { device: params[:device] },
450
- on_joined: ->(m) { transmit(type: "ready") },
451
- on_message: ->(msg) { transmit(msg) if msg["type"] != "internal" },
452
- on_room_closed: ->(m) { transmit(type: "over") },
453
- on_left: ->(m) { logger.info "left #{m.key}" }
454
- )
455
- end
456
-
457
- def answer(data)
458
- @membership << { type: "answer", choice: data["choice"] }
459
- end
460
-
461
- def unsubscribed
462
- @membership&.leave!
463
- end
464
- end
465
- ```
466
-
467
- `join_room` options:
468
-
469
- | Option | Meaning |
470
- | ------------------------------- | -------------------------------------------------------------------- |
471
- | `create:` | Provision the Room if it isn't running. Defaults to `false`. |
472
- | `as:` | The user identity. Defaults to `current_user` when the channel has one. |
473
- | `tags:` | Tags this port carries, for policies and targeted broadcasts. |
474
- | `extra:` | Extra metadata, readable on the Room's `PortClient`. |
475
- | `forward:` | Pipe every Room message straight to the websocket. |
476
- | `on_joined:` | The Room acknowledged this port. |
477
- | `on_message:` | Any message from the Room. |
478
- | `on_room_opened:` | The Room opened while we were connecting. Not guaranteed. |
479
- | `on_room_closed:` | The Room closed while we were connected. Not guaranteed. |
480
- | `on_left:` | This membership ended. |
481
-
482
- The returned membership responds to `<<`, `connected?`, `left?`, `key`, `ping!`, `leave!`, and
483
- `rejoin!`.
484
-
485
- With `create: true`, provisioning is retried on every ping, not just at join. If the Room's host
486
- process dies, the next ping from any member brings it back somewhere else.
487
-
488
- ### From outside a channel.
489
-
490
- ```ruby
491
- QuizRoom.ensure("quiz_9") # => true if this process now runs it
492
- QuizRoom.send_message("quiz_9", { type: "extend", by: 60 }) # publish to :to_room
493
- QuizRoom.room_port_key("quiz_9", :from_room) # the raw pubsub key
494
- ```
495
-
496
- ## Background work.
497
-
498
- A Room is single-threaded on purpose. Slow work belongs off its thread:
499
-
500
- ```ruby
501
- def on_export(msg)
502
- token = message_origin.token # capture before leaving the Room's thread
503
-
504
- async do
505
- report = build_expensive_report
506
-
507
- on_room_thread do
508
- broadcast({ type: "export_ready", url: report.url }, client_port: token)
509
- end
510
- end
511
- end
512
- ```
513
-
514
- `async` borrows a thread from the pool shared by every Room in the process and runs concurrently
515
- with the Room, so **the block must not touch Room state.** Capture what it needs first. Inside
516
- it, `message` is nil, and `message_origin` and `reply` point at whatever the Room is handling
517
- *now* rather than what it was handling when you called `async`.
518
-
519
- `on_room_thread` queues work back onto the Room's thread, where state is safe again. Prefer
520
- handing results back that way over blocking on `async` work, since a blocked Room thread can
521
- starve its neighbours.
522
-
523
- ## Instrumentation and errors.
524
-
525
- Rooms swallow exceptions so one bad message can't take the Room down. That makes the error
526
- handler the only place you'll hear about it:
527
-
528
- ```ruby
529
- CableRoom.error_handler = ->(error, context) do
530
- Sentry.capture_exception(error, extra: context)
531
- end
532
- ```
533
-
534
- The context includes the Room, its class, its key, and the channel. An
535
- `error.cable_room` notification fires either way.
536
-
537
- ActiveSupport notifications:
538
-
539
- | Event | Payload |
540
- | -------------------------------- | -------------------------- |
541
- | `room_opened.cable_room` | `room` |
542
- | `room_closed.cable_room` | `room`, `reason` |
543
- | `message_received.cable_room` | `room`, `message` |
544
- | `port_connected.cable_room` | `room`, `message` |
545
- | `port_disconnected.cable_room` | `room`, `reason`, `message` |
546
- | `user_joined.cable_room` | `room`, `user` |
547
- | `user_left.cable_room` | `room`, `user` |
548
- | `error.cable_room` | `error`, plus context |
549
-
550
- `port_disconnected` reports a `reason` of `:left` for a clean departure and `:timeout` for a port
551
- that stopped pinging.
552
-
553
- Every Room also gets a tagged logger, so `logger.info` from inside a Room is prefixed with the
554
- Room class and a short UUID. That UUID is how you follow one instance through the logs.
555
-
556
- ## Configuration.
557
-
558
- CableRoom keeps its own Redis pool, separate from the ActionCable adapter's, for locking.
559
- Configure it with these variables:
560
-
561
- | Variable | Purpose |
562
- | ---------------------------- | -------------------------------------------------------- |
563
- | `CABLEROOM_REDIS_URL` | The connection URL. |
564
- | `CABLEROOM_REDIS_PROVIDER` | Name of another variable holding the URL. |
565
- | `CABLEROOM_REDIS_POOL_SIZE` | Pool size. Defaults to `RAILS_MAX_THREADS`, then five. |
566
-
567
- Without a prefixed variable it falls back to `REDIS_PROVIDER` and `REDIS_URL`, so a single-Redis
568
- app needs no CableRoom-specific configuration at all. Reach the pool directly with
569
- `CableRoom.redis { |conn| ... }` and the lock manager with `CableRoom.lock_manager`.
570
-
571
- Room threads come from a pool sized by ActionCable's own `worker_pool_size`.
572
-
573
- Timings live in constants:
574
-
575
- | Constant | Default | What it controls |
576
- | --------------------------------- | -------------- | --------------------------------------- |
577
- | `Room::Base::LOCK_DURATION` | `15.seconds` | Redis lock TTL, extended on every beat. |
578
- | `Room::Base::WATCH_DOG_INTERVAL` | `15.seconds` | How stale a watchdog ping may get. |
579
- | `PortManagement::PORT_TIMEOUT` | `30.seconds` | How long a silent port survives. |
580
- | `ChannelTracker::BEAT_INTERVAL` | `5.seconds` | Lock extension and watchdog sweep. |
581
-
582
- The first two are read as `self::CONSTANT`, so a Room subclass can redefine them. The other two
583
- are module constants that apply process-wide.
584
-
585
- ### Shutdown.
586
-
587
- On process exit, CableRoom asks every local Room to shut down gracefully and waits up to 15
588
- seconds for them to drain. It also hooks ActionCable's `restart`, so a code reload in development
589
- stops Rooms instead of orphaning their locks.
590
-
591
- ## Introspection.
592
-
593
- ```ruby
594
- CableRoom::Room.locally_open_rooms # every Room running in this process
595
- QuizRoom.locally_running_instances # just the QuizRooms
596
- ```
597
-
598
- Both are process-local. There's no cluster-wide registry — the Redis lock is the only source of
599
- truth about who owns a key.
600
-
601
- ## Subclassing.
602
-
603
- Room classes build a private `Channel` and `PortClient` for each subclass, chained to the
604
- parent's. Periodic timers, callbacks, policies, and tag aliases all inherit correctly through
605
- however many levels you need:
606
-
607
- ```ruby
608
- class BaseGameRoom < CableRoom::Room::Base
609
- periodically :tick, every: 1.second
610
- reap_when { connected_users.empty? }
611
- end
612
-
613
- class TriviaRoom < BaseGameRoom
614
- # keeps tick and the reaper, adds its own
615
- periodically :rotate_question, every: 30.seconds
616
- end
617
- ```
618
-
619
- Note that a Room's pubsub keys derive from its class name, so anonymous Room classes won't work.
620
-
621
- ## Development.
622
-
623
- Rooms need Redis and, for the test suite, Postgres:
624
-
625
- ```sh
626
- bundle install
627
- bundle exec rspec
628
- ```
629
-
630
- To run against every supported Rails version:
631
-
632
- ```sh
633
- bundle exec appraisal install
634
- bundle exec appraisal rspec
635
- ```
636
-
637
- The suite has two halves. Unit specs use `RoomHarness#build_room`, which runs a Room against a
638
- stub channel with no Redis and no pubsub, so logic is testable synchronously. End-to-end specs
639
- run the async ActionCable adapter and real message delivery, and wait on observable conditions
640
- with `wait_until` rather than sleeping.
641
-
642
- `spec/internal` holds a Combustion app, so `rackup` boots a minimal Rails host if you want to
643
- poke at Rooms by hand.
data/cable_room.gemspec CHANGED
File without changes
@@ -6,18 +6,8 @@ module CableRoom
6
6
  @ports_proxy ||= PortsProxy.new(self)
7
7
  end
8
8
 
9
- # Subscribe the channel to `port`, delivering each decoded message to the block.
10
- #
11
- # `on_live`, when given, runs once the pubsub adapter has confirmed the subscription — the
12
- # first moment a message published to the port is guaranteed to reach the block. Subscribing
13
- # is asynchronous, so anything published before then can be lost.
14
- def stream_port(port, auto_close: true, on_live: nil, &blk)
15
- broadcasting = room_port_key(port)
16
- if on_live
17
- _stream_from_observing_liveness(broadcasting, on_live, &blk)
18
- else
19
- @cable_channel.stream_from(broadcasting, coder: ActiveSupport::JSON, &blk)
20
- end
9
+ def stream_port(port, auto_close: true, &blk)
10
+ @cable_channel.stream_from(room_port_key(port), coder: ActiveSupport::JSON, &blk)
21
11
  _streamed_ports << port if auto_close
22
12
  end
23
13
 
@@ -42,34 +32,6 @@ module CableRoom
42
32
  @_streamed_ports ||= Set.new
43
33
  end
44
34
 
45
- # ActionCable::Channel::Streams#stream_from confirms the channel subscription once every one of
46
- # its streams is live, but offers no per-stream hook. This is its body — unchanged from Rails
47
- # 6.1 through 8.1 — with `on_live` added to the adapter's success callback.
48
- #
49
- # When the channel has replaced `stream_from` (ActionCable's channel test stubs, recorders,
50
- # `StubRoomChannel`) there is no subscription to observe: defer to it and report live at once.
51
- def _stream_from_observing_liveness(broadcasting, on_live, &blk)
52
- chan = @cable_channel
53
- unless chan.method(:stream_from).owner == ActionCable::Channel::Streams
54
- chan.stream_from(broadcasting, coder: ActiveSupport::JSON, &blk)
55
- on_live.call
56
- return
57
- end
58
- return if chan.respond_to?(:unsubscribed?, true) && chan.send(:unsubscribed?)
59
-
60
- chan.send(:defer_subscription_confirmation!)
61
- handler = chan.send(:worker_pool_stream_handler, broadcasting, blk, coder: ActiveSupport::JSON)
62
- chan.send(:streams)[broadcasting] = handler
63
-
64
- chan.connection.server.event_loop.post do
65
- chan.send(:pubsub).subscribe(broadcasting, handler, lambda do
66
- chan.send(:ensure_confirmation_sent)
67
- chan.logger.info "#{chan.class.name} is streaming from #{broadcasting}"
68
- on_live.call
69
- end)
70
- end
71
- end
72
-
73
35
  class PortsProxy
74
36
  def initialize(ports_concern)
75
37
  @ports_concern = ports_concern
@@ -3,7 +3,12 @@ module CableRoom
3
3
  module PortManagement
4
4
  extend ActiveSupport::Concern
5
5
 
6
- PORT_TIMEOUT = 30.seconds
6
+ # A port that the room has not heard from for this long is dropped. Any inbound message
7
+ # counts as hearing from it (see the receive_message callback below), and members only
8
+ # ping when they have been otherwise silent for RoomMember::PING_INTERVAL, so the longest
9
+ # gap a live member produces is 2 x PING_INTERVAL. Keep this comfortably above that: under
10
+ # load the room processes its queue late, and a reaped port is a lost student.
11
+ PORT_TIMEOUT = 45.seconds
7
12
 
8
13
  class_methods do
9
14
  def on_port_connected(...)
@@ -29,7 +34,13 @@ module CableRoom
29
34
  on_port_connected { reply({type: 'port_acknowledged' }) }
30
35
 
31
36
  set_callback(:receive_message, :around) do |_, blk|
32
- with_message_origin(message['mtok']) { blk.call }
37
+ with_message_origin(message['mtok']) do
38
+ # Every message from a known port is proof of life, not just port_ping — so a member
39
+ # that is actively sending never needs to ping (RoomMembership#ping!). Unknown origins
40
+ # (a port_connected creating its port) are touched when the port is created.
41
+ message_origin&.touch_activity!
42
+ blk.call
43
+ end
33
44
  end
34
45
 
35
46
  system_message_types(:port_connected, :port_disconnected, :port_ping)
@@ -107,7 +118,10 @@ module CableRoom
107
118
  def handle_received_message(message)
108
119
  case message['type']
109
120
  when 'port_connected'
110
- mo = @_port_clients[@current_message_origin] ||= PortClient.new(self, @current_message_origin)
121
+ mo = @_port_clients[@current_message_origin]
122
+ return if mo
123
+
124
+ mo = @_port_clients[@current_message_origin] = PortClient.new(self, @current_message_origin)
111
125
 
112
126
  mo.touch_activity!
113
127
  mo.tag!(message['tags'])
@@ -2,8 +2,15 @@ module CableRoom
2
2
  module RoomMember
3
3
  extend ActiveSupport::Concern
4
4
 
5
+ # How often a silent member reminds its room that it is alive. A member that has sent
6
+ # anything within this interval skips the ping (the room counts every inbound message as
7
+ # activity), so the room hears from a live member at least every 2 x PING_INTERVAL — which
8
+ # must stay under Room::PortManagement::PORT_TIMEOUT with margin for queue lag. Pings were
9
+ # ~30% of all room messages at 10 s with no skipping.
10
+ PING_INTERVAL = 15.seconds
11
+
5
12
  included do
6
- periodically :ping_room_memberships, every: 10.seconds
13
+ periodically :ping_room_memberships, every: PING_INTERVAL
7
14
 
8
15
  after_unsubscribe do
9
16
  to_close = _room_memberships.to_a
@@ -102,14 +109,8 @@ module CableRoom
102
109
  def ping!
103
110
  return if left?
104
111
  if @has_established
105
- port_transmit(room_class::ROOM_IN_CHANNEL, { type: 'port_ping' }, secure_context: true)
106
- elsif @streams_live
107
- # Announced but never acknowledged. Our streams were already live when we announced (see
108
- # initiate_connection), so this is not the subscribe race — the announcement or the
109
- # acknowledgement was lost in transit, or the room did not exist yet. An unestablished
110
- # membership silently drops everything the client sends (see #<<), so re-announce instead
111
- # of pinging: port_connected is idempotent on the room side (the port is merged,
112
- # user_joined fires only once).
112
+ port_transmit(room_class::ROOM_IN_CHANNEL, { type: 'port_ping' }, secure_context: true) unless recently_transmitted?
113
+ else
113
114
  transmit_port_connected
114
115
  end
115
116
  @mutex.synchronize do
@@ -140,37 +141,30 @@ module CableRoom
140
141
 
141
142
  def key; @room_key; end
142
143
 
143
- # Every stream this membership opens reports back once it is live; the last one to come up
144
- # sends port_connected (see initiate_connection).
145
- #
146
- # The adapter runs the liveness callback on its own terms — the async and inline adapters call
147
- # it while holding their subscriber-map lock, the Redis adapter on the event loop — so nothing
148
- # may happen inside it except a hop to a worker thread: taking @mutex there deadlocks against
149
- # an initiate_connection that is mid-broadcast, and broadcasting from it re-enters the lock.
150
- def stream_port(port, on_live: nil, **kwargs, &blk)
151
- generation = @stream_generation
152
- @streams_pending += 1
153
- connection = @cable_channel.connection
154
- super(port, **kwargs, on_live: lambda do
155
- connection.worker_pool.async_invoke(self, :_stream_became_live, generation, connection: connection)
156
- on_live&.call
157
- end, &blk)
158
- end
159
-
160
144
  protected
161
145
 
162
146
  def port_transmit(port, data, secure_context: false)
163
147
  data[:mtok] = @token
164
148
 
149
+ type = (data[:type] || data['type'])&.to_sym
165
150
  unless secure_context
166
- t = (data[:type] || data['type']).to_sym
167
- if room_class._system_message_types.include?(t)
168
- logger.warn "Dropping attempt to send system message type: #{t.inspect}"
151
+ if room_class._system_message_types.include?(type)
152
+ logger.warn "Dropping attempt to send system message type: #{type.inspect}"
169
153
  return
170
154
  end
171
155
  end
172
156
 
173
157
  super(port, data)
158
+ # Pings don't count: a ping must never be the reason the next ping is skipped.
159
+ @last_transmit_at = monotonic_now unless type == :port_ping
160
+ end
161
+
162
+ def recently_transmitted?
163
+ @last_transmit_at && (monotonic_now - @last_transmit_at) < RoomMember::PING_INTERVAL
164
+ end
165
+
166
+ def monotonic_now
167
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
174
168
  end
175
169
 
176
170
  def room_port_key(port)
@@ -187,9 +181,7 @@ module CableRoom
187
181
  @has_established = true
188
182
  @on_joined&.call(self) if first_acknowledgement
189
183
  when 'room_opened'
190
- # If our streams are not all live yet, the pending announcement covers this (and an early
191
- # one would be acknowledged into a stream nobody is listening to yet).
192
- transmit_port_connected if @streams_live
184
+ transmit_port_connected
193
185
  @on_room_opened&.call(self)
194
186
  when 'room_closed'
195
187
  leave!
@@ -211,18 +203,6 @@ module CableRoom
211
203
  @has_established = false
212
204
  @cable_channel._room_memberships << self
213
205
 
214
- # port_connected must not go out until every stream below is live on the pubsub adapter.
215
- # Subscribing is asynchronous and the room acknowledges on the private @token stream, so
216
- # announcing first lets a fast room — in practice one on another server — reply into a
217
- # stream nobody is listening to yet; the acknowledgement is lost and the membership never
218
- # establishes. stream_port (overridden above) counts the streams it opens and the last one
219
- # to come up announces (_stream_became_live). The generation discards confirmations from
220
- # a previous connection that land after a rejoin!.
221
- @stream_generation = (@stream_generation || 0) + 1
222
- @streams_pending = 0
223
- @streams_armed = false
224
- @streams_live = false
225
-
226
206
  # Listen to public/broadcast channel
227
207
  stream_port(room_class::ROOM_OUT_CHANNEL) do |message|
228
208
  handle_received_message(message)
@@ -248,30 +228,14 @@ module CableRoom
248
228
 
249
229
  @preconfigure&.call(self)
250
230
 
251
- # Streams whose adapter confirmed synchronously are already counted down; announce now if
252
- # that was all of them, otherwise the last confirmation will.
253
- @streams_armed = true
254
- _announce_if_streams_live
231
+ transmit_port_connected
255
232
 
256
233
  maybe_provision_room
257
234
  end
258
235
  end
259
236
 
260
- def _stream_became_live(generation)
261
- @mutex.synchronize do
262
- return unless generation == @stream_generation
263
-
264
- @streams_pending -= 1
265
- _announce_if_streams_live
266
- end
267
- end
268
-
269
- # Under @mutex. Sends port_connected once per connection, when every stream opened by
270
- # initiate_connection has been confirmed live.
271
- def _announce_if_streams_live
272
- return if @streams_live || !@streams_armed || @streams_pending > 0 || left?
273
-
274
- @streams_live = true
237
+ def transmit_subscription_confirmation(...)
238
+ super
275
239
  transmit_port_connected
276
240
  end
277
241
 
@@ -1,3 +1,3 @@
1
1
  module CableRoom
2
- VERSION = "0.6.2.beta1".freeze
2
+ VERSION = "0.6.2.beta3".freeze
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: cable_room
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.2.beta1
4
+ version: 0.6.2.beta3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ethan Knapp