cable_room 0.6.2 → 0.7.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.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +122 -0
  3. data/README.md +1190 -0
  4. data/cable_room.gemspec +5 -2
  5. data/exe/cable_room +8 -0
  6. data/lib/cable_room/broadcaster.rb +116 -0
  7. data/lib/cable_room/bus.rb +372 -0
  8. data/lib/cable_room/cli.rb +237 -0
  9. data/lib/cable_room/config.rb +112 -0
  10. data/lib/cable_room/host/bus_inbound.rb +36 -0
  11. data/lib/cable_room/host/runner.rb +571 -0
  12. data/lib/cable_room/host/supervisor.rb +275 -0
  13. data/lib/cable_room/host/worker_pool.rb +37 -0
  14. data/lib/cable_room/host.rb +477 -0
  15. data/lib/cable_room/membership_store.rb +105 -0
  16. data/lib/cable_room/migration.rb +586 -0
  17. data/lib/cable_room/periodic_timer.rb +18 -0
  18. data/lib/cable_room/placement.rb +258 -0
  19. data/lib/cable_room/ports.rb +19 -11
  20. data/lib/cable_room/railtie.rb +3 -12
  21. data/lib/cable_room/room/base.rb +45 -39
  22. data/lib/cable_room/room/host_adapter.rb +52 -0
  23. data/lib/cable_room/room/lifecycle.rb +26 -9
  24. data/lib/cable_room/room/port_management.rb +57 -0
  25. data/lib/cable_room/room/reaping.rb +34 -1
  26. data/lib/cable_room/room/snapshotting.rb +78 -0
  27. data/lib/cable_room/room/threading.rb +2 -2
  28. data/lib/cable_room/room/user_management.rb +27 -0
  29. data/lib/cable_room/room.rb +5 -2
  30. data/lib/cable_room/room_member.rb +260 -84
  31. data/lib/cable_room/room_proxy_channel.rb +13 -2
  32. data/lib/cable_room/snapshot.rb +136 -0
  33. data/lib/cable_room/version.rb +1 -1
  34. data/lib/cable_room.rb +57 -2
  35. metadata +25 -9
  36. data/lib/cable_room/channel_base.rb +0 -262
  37. data/lib/cable_room/channel_tracker.rb +0 -130
  38. data/lib/cable_room/room/channel_adapter.rb +0 -18
data/cable_room.gemspec CHANGED
@@ -18,14 +18,17 @@ Gem::Specification.new do |spec|
18
18
  spec.summary = "Build live Rooms on top of ActionCable"
19
19
  spec.homepage = "https://instructure.com"
20
20
 
21
- spec.files = Dir["{app,config,db,lib}/**/*", "README.md", "*.gemspec"]
21
+ spec.files = Dir["{app,config,db,exe,lib}/**/*", "README.md", "CHANGELOG.md", "*.gemspec"]
22
+ spec.bindir = "exe"
23
+ spec.executables = ["cable_room"]
22
24
  spec.require_paths = ['lib']
23
25
 
24
26
  spec.add_dependency "rails", ">= 7.2", "< 9.0"
25
27
  spec.add_dependency "rufus-scheduler", "~> 3.6"
26
28
  spec.add_dependency "redlock", "~> 2.0"
27
29
  spec.add_dependency "rediconn", "~> 0.1.2"
30
+ # rediconn builds pools of redis-rb clients but doesn't declare the gem; the Bus needs it too
31
+ spec.add_dependency "redis", ">= 5.0"
28
32
 
29
- spec.add_development_dependency "redis"
30
33
  spec.add_development_dependency 'rspec', '~> 3'
31
34
  end
data/exe/cable_room ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Loads only the CLI, not the gem: `cable_room server` boots the Rails app itself, and the app's
5
+ # Gemfile is what should load CableRoom (so its Railtie runs at the usual time).
6
+ require "cable_room/cli"
7
+
8
+ exit CableRoom::CLI.new(ARGV).run
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CableRoom
4
+ # How a Room pushes messages out to its members. Every room→member message (`from_room`, tag,
5
+ # user, and `mtok` streams, plus `Room::Base.send_message`) goes through one of these, so
6
+ # swapping the transport is a config change, not a code change.
7
+ #
8
+ # `CableRoom.config.broadcaster` picks the implementation:
9
+ #
10
+ # :action_cable -> ActionCableBroadcaster (the default; today's behavior)
11
+ # :anycable -> AnyCableBroadcaster (needs the optional `anycable-rails` gem)
12
+ #
13
+ # Every broadcaster answers `broadcast(stream, payload)`. Specs can swap in a recording fake
14
+ # with `Broadcaster.current = fake`; `CableRoom.reset_config!` drops it again.
15
+ module Broadcaster
16
+ # Raised when the config asks for a broadcaster whose gem isn't in the bundle.
17
+ class MissingDependency < LoadError; end
18
+
19
+ class << self
20
+ # The broadcaster the current config selects. Built once per config object and reused,
21
+ # so `CableRoom.reset_config!` or `CableRoom.configure` picks up a fresh one on the next
22
+ # call without anyone having to remember to clear a cache.
23
+ def current
24
+ config = CableRoom.config
25
+ return @current if @current && @current_config.equal?(config)
26
+
27
+ @current_config = config
28
+ @current = build(config.broadcaster)
29
+ end
30
+
31
+ # Replace the broadcaster for the life of the current config object. Meant for specs that
32
+ # need a fake without rspec-mocks; `CableRoom.reset_config!` builds a new config, which
33
+ # makes `current` rebuild from it and forget this one.
34
+ def current=(broadcaster)
35
+ @current_config = CableRoom.config
36
+ @current = broadcaster
37
+ end
38
+
39
+ # Builds the broadcaster named by `name`. Config already rejects unknown names, so the
40
+ # `else` branch only fires if someone bypasses config.
41
+ def build(name)
42
+ case name&.to_sym
43
+ when :action_cable
44
+ ActionCableBroadcaster.new
45
+ when :anycable
46
+ AnyCableBroadcaster.new
47
+ else
48
+ raise ArgumentError,
49
+ "CableRoom broadcaster must be one of #{Config::BROADCASTERS.map(&:inspect).join(', ')} " \
50
+ "(got #{name.inspect})"
51
+ end
52
+ end
53
+ end
54
+
55
+ # Publishes through the ActionCable server, which hands the message to whatever pubsub
56
+ # adapter the app configured (Redis, async, ...).
57
+ class ActionCableBroadcaster
58
+ def broadcast(stream, payload)
59
+ ActionCable.server.broadcast(stream, payload)
60
+ end
61
+ end
62
+
63
+ # Publishes through `AnyCable.broadcast`, so anycable-go fans the message out to sockets.
64
+ # Stream names are the same ones ActionCableBroadcaster uses, so a member subscribed through
65
+ # anycable-go sees the same traffic it would through ActionCable.
66
+ #
67
+ # `anycable-rails` is optional at runtime: it's only loaded when this broadcaster is built, and
68
+ # a missing gem raises MissingDependency naming what to add.
69
+ #
70
+ # Payload encoding: `ActionCable.server.broadcast` runs every message through its coder
71
+ # (ActiveSupport::JSON by default) and hands the pubsub adapter a JSON string; anycable-rails's
72
+ # own ActionCable adapter forwards that string to `AnyCable.broadcast` untouched, and
73
+ # anycable-go expects the `data` field to be a pre-encoded string. So we encode here with the
74
+ # same coder, and a member decodes the message exactly as it would from ActionCable.
75
+ class AnyCableBroadcaster
76
+ CODER = ActiveSupport::JSON
77
+ GEM_NAME = "anycable-rails"
78
+
79
+ class << self
80
+ # Loads AnyCable on first use. `anycable` (a dependency of anycable-rails) is what defines
81
+ # `AnyCable.broadcast`; requiring it rather than `anycable-rails` avoids re-running the
82
+ # Rails integration in a process that already booted without it.
83
+ def ensure_loaded!
84
+ return if defined?(::AnyCable) && ::AnyCable.respond_to?(:broadcast)
85
+
86
+ begin
87
+ require "anycable"
88
+ rescue LoadError
89
+ raise MissingDependency,
90
+ "CableRoom broadcaster :anycable needs the #{GEM_NAME} gem. " \
91
+ "Add `gem \"#{GEM_NAME}\"` to your Gemfile, or set broadcaster to :action_cable."
92
+ end
93
+ end
94
+ end
95
+
96
+ def initialize
97
+ self.class.ensure_loaded!
98
+ end
99
+
100
+ def broadcast(stream, payload)
101
+ publish(stream, encode(payload))
102
+ end
103
+
104
+ protected
105
+
106
+ # The one call that leaves the process. Fakes override this to record instead of publish.
107
+ def publish(stream, encoded_payload)
108
+ ::AnyCable.broadcast(stream, encoded_payload)
109
+ end
110
+
111
+ def encode(payload)
112
+ CODER.encode(payload)
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,372 @@
1
+ require 'set'
2
+ require 'active_support/json'
3
+
4
+ module CableRoom
5
+ # The Redis bus that carries member-to-room traffic in every mode: `to_room` messages,
6
+ # provision requests, and the handoff data a room leaves behind when it migrates to another
7
+ # host. It's a thin layer over the `CABLEROOM_*` Redis connection (see `CableRoom.redis_pool`)
8
+ # that adds three things:
9
+ #
10
+ # * One place that knows the channel and key names (`cr:{room_key}:in` and friends).
11
+ # * JSON encoding on the way in and decoding on the way out, using the same coder the room
12
+ # ports already use for ActionCable streams, so a bad payload fails at the publisher.
13
+ # * A single subscriber thread that owns a dedicated pub/sub connection and hands each
14
+ # decoded message to the handler registered for its channel.
15
+ #
16
+ # Ordering: Redis delivers messages on one channel in publish order, and one thread dispatches
17
+ # them one at a time, so a handler sees them in the order they were published. `subscribe`
18
+ # blocks until Redis confirms the subscription, so anything published after it returns is
19
+ # delivered.
20
+ #
21
+ # Handlers run on the subscriber thread. Keep them short (hand the work to a queue) and never
22
+ # block in them waiting on the bus itself. A handler is allowed to call `subscribe` and
23
+ # `unsubscribe`; those calls return without waiting when made from the subscriber thread,
24
+ # because the confirmation they'd wait for is delivered by that same thread.
25
+ class Bus
26
+ # Raised at the publisher when a payload can't be turned into JSON.
27
+ class EncodeError < ArgumentError; end
28
+
29
+ # Raised when Redis doesn't confirm a subscribe or unsubscribe within the timeout.
30
+ class TimeoutError < StandardError; end
31
+
32
+ KEY_PREFIX = "cr".freeze
33
+ DEFAULT_TIMEOUT = 5
34
+
35
+ class << self
36
+ # Pub/sub channel that a room's host listens on for member-to-room messages. A room's main
37
+ # inbound port has no suffix (`cr:{room_key}:in`); a custom inbound port gets one
38
+ # (`cr:{room_key}:in:{port}`), so every channel a room listens on shares one prefix.
39
+ # `Room::Base.inbound_channel` is what turns a room class, key, and port into these.
40
+ def inbound_channel(room_key, port = nil)
41
+ channel = room_channel(room_key, "in")
42
+ port.nil? ? channel : "#{channel}:#{port}"
43
+ end
44
+
45
+ # Pub/sub channel every host listens on for "someone wants room X to exist" requests.
46
+ def provision_channel
47
+ "#{KEY_PREFIX}:provision"
48
+ end
49
+
50
+ # Redis list where a migrating room's old host parks inbound messages until the new host
51
+ # has adopted the room.
52
+ def handoff_list(room_key)
53
+ room_channel(room_key, "handoff")
54
+ end
55
+
56
+ # Redis string (with a TTL) holding the frozen room's snapshot during a migration.
57
+ def snapshot_key(room_key)
58
+ room_channel(room_key, "snapshot")
59
+ end
60
+
61
+ # Pub/sub channel the adopting host publishes on once it has taken over the room.
62
+ def adopted_channel(room_key)
63
+ room_channel(room_key, "adopted")
64
+ end
65
+
66
+ private
67
+
68
+ def room_channel(room_key, suffix)
69
+ key = room_key.to_s
70
+ raise ArgumentError, "room_key can't be blank" if key.empty?
71
+ "#{KEY_PREFIX}:#{key}:#{suffix}"
72
+ end
73
+ end
74
+
75
+ # `redis_pool` defaults to `CableRoom.redis_pool` (looked up lazily so specs can swap it).
76
+ def initialize(redis_pool: nil)
77
+ @redis_pool = redis_pool
78
+
79
+ # Everything below is guarded by @mutex and changes are announced on @changed
80
+ @mutex = Mutex.new
81
+ @changed = ConditionVariable.new
82
+ @handlers = {} # channel => handler; the channels we want to be subscribed to
83
+ @requested = Set.new # channels we've sent SUBSCRIBE for in the current session
84
+ @confirmed = Set.new # channels Redis has confirmed in the current session
85
+ @thread = nil # the subscriber thread, nil when no channels are wanted
86
+ @session_live = false # true while @subscriber_redis accepts commands from other threads
87
+ @subscriber_redis = nil
88
+ end
89
+
90
+ # ---- Publisher and plain key operations --------------------------------------------------
91
+
92
+ # Publish a JSON-encodable payload. Returns the number of subscribers that received it.
93
+ def publish(channel, payload)
94
+ encoded = encode(payload)
95
+ redis { |r| r.publish(channel, encoded) }
96
+ end
97
+
98
+ # Append one or more payloads to the end of a list. Returns the list's new length. With `ttl`
99
+ # (seconds or a Duration) the list's expiry is reset in the same round trip, so a list nobody
100
+ # deletes (a host that died mid-handoff) still goes away on its own.
101
+ def rpush(key, *payloads, ttl: nil)
102
+ encoded = payloads.map { |payload| encode(payload) }
103
+ return redis { |r| r.rpush(key, encoded) } unless ttl
104
+
105
+ seconds = ttl.to_i
106
+ raise ArgumentError, "ttl must be a positive number of seconds" unless seconds.positive?
107
+ length, = redis { |r| r.pipelined { |p| p.rpush(key, encoded); p.expire(key, seconds) } }
108
+ length
109
+ end
110
+
111
+ # The whole list, oldest first, decoded.
112
+ def lrange(key)
113
+ redis { |r| r.lrange(key, 0, -1) }.map { |raw| decode(raw) }
114
+ end
115
+
116
+ # Delete keys. Returns how many existed.
117
+ def del(*keys)
118
+ redis { |r| r.del(*keys) }
119
+ end
120
+
121
+ # Store a payload under a key that expires after `ttl` (seconds or an
122
+ # ActiveSupport::Duration). Migration uses this for snapshots so a dead handoff cleans
123
+ # itself up.
124
+ def set(key, payload, ttl:)
125
+ seconds = ttl.to_i
126
+ raise ArgumentError, "ttl must be a positive number of seconds" unless seconds.positive?
127
+ encoded = encode(payload)
128
+ redis { |r| r.set(key, encoded, ex: seconds) }
129
+ end
130
+
131
+ # The decoded payload stored under a key, or nil when there isn't one.
132
+ def get(key)
133
+ raw = redis { |r| r.get(key) }
134
+ raw.nil? ? nil : decode(raw)
135
+ end
136
+
137
+ # ---- Subscriber -------------------------------------------------------------------------
138
+
139
+ # Register `handler` for `channel` and block until Redis confirms the subscription. The
140
+ # handler is called as `handler.call(message, channel)` with the decoded message on the
141
+ # subscriber thread. Subscribing again to the same channel replaces the handler.
142
+ #
143
+ # Raises TimeoutError (and forgets the handler) if the subscription isn't confirmed in time,
144
+ # which usually means Redis is unreachable.
145
+ def subscribe(channel, timeout: DEFAULT_TIMEOUT, &handler)
146
+ raise ArgumentError, "subscribe needs a block to handle messages" unless handler
147
+ channel = channel.to_s
148
+
149
+ @mutex.synchronize do
150
+ @handlers[channel] = handler
151
+ if @thread
152
+ reconcile
153
+ else
154
+ start_subscriber_thread
155
+ end
156
+
157
+ return true if on_subscriber_thread?
158
+
159
+ begin
160
+ wait_until(timeout, "subscription to #{channel}") { @confirmed.include?(channel) }
161
+ rescue TimeoutError
162
+ @handlers.delete(channel)
163
+ reconcile
164
+ raise
165
+ end
166
+ end
167
+ true
168
+ end
169
+
170
+ # Drop the handler for `channel` and block until Redis confirms the unsubscribe. When no
171
+ # channels are left the subscriber thread exits, and this also waits for that. Returns
172
+ # false if the channel wasn't subscribed.
173
+ def unsubscribe(channel, timeout: DEFAULT_TIMEOUT)
174
+ channel = channel.to_s
175
+
176
+ @mutex.synchronize do
177
+ return false unless @handlers.delete(channel)
178
+ reconcile
179
+
180
+ return true if on_subscriber_thread?
181
+
182
+ wait_until(timeout, "unsubscribe from #{channel}") do
183
+ !@confirmed.include?(channel) && (@thread.nil? || @handlers.any?)
184
+ end
185
+ end
186
+ true
187
+ end
188
+
189
+ # Unsubscribe from everything and wait for the subscriber thread to exit.
190
+ def shutdown(timeout: DEFAULT_TIMEOUT)
191
+ @mutex.synchronize do
192
+ @handlers.clear
193
+ reconcile
194
+ return true if on_subscriber_thread?
195
+ wait_until(timeout, "subscriber thread to exit") { @thread.nil? }
196
+ end
197
+ true
198
+ end
199
+
200
+ # Channels Redis has confirmed we're subscribed to right now.
201
+ def subscribed_channels
202
+ @mutex.synchronize { @confirmed.to_a }
203
+ end
204
+
205
+ def subscribed?(channel)
206
+ @mutex.synchronize { @confirmed.include?(channel.to_s) }
207
+ end
208
+
209
+ def subscriber_alive?
210
+ thread = @mutex.synchronize { @thread }
211
+ !!thread&.alive?
212
+ end
213
+
214
+ private
215
+
216
+ def redis_pool
217
+ @redis_pool || CableRoom.redis_pool
218
+ end
219
+
220
+ def redis(&blk)
221
+ redis_pool.with(&blk)
222
+ end
223
+
224
+ def encode(payload)
225
+ ActiveSupport::JSON.encode(payload)
226
+ rescue StandardError => e
227
+ raise EncodeError, "payload can't be encoded as JSON: #{e.class}: #{e.message}"
228
+ end
229
+
230
+ def decode(raw)
231
+ ActiveSupport::JSON.decode(raw)
232
+ end
233
+
234
+ def on_subscriber_thread?
235
+ Thread.current == @thread
236
+ end
237
+
238
+ # The subscriber needs a connection of its own: a pub/sub connection can't run other
239
+ # commands, and it lives as long as there are subscriptions, so it must not be a pooled
240
+ # one (a small pool would run dry). Borrow a pooled client just long enough to dup it,
241
+ # which gives an unconnected client with the same settings and no pool ties.
242
+ def build_subscriber_redis
243
+ redis(&:dup)
244
+ end
245
+
246
+ def start_subscriber_thread
247
+ @thread = Thread.new { subscriber_loop }
248
+ @thread.name = "cable_room-bus-subscriber"
249
+ @thread.report_on_exception = false
250
+ end
251
+
252
+ # Runs on the subscriber thread. Each pass opens one pub/sub session covering every wanted
253
+ # channel; a session ends when the last channel is unsubscribed (normal) or the connection
254
+ # fails (reported, then retried with backoff). The loop exits once nothing is wanted.
255
+ def subscriber_loop
256
+ backoff = 0.1
257
+ loop do
258
+ channels = @mutex.synchronize do
259
+ if @handlers.empty?
260
+ @thread = nil
261
+ @changed.broadcast
262
+ return
263
+ end
264
+ @handlers.keys
265
+ end
266
+
267
+ begin
268
+ run_session(channels)
269
+ backoff = 0.1
270
+ rescue StandardError => e
271
+ CableRoom.report_error(e, bus: self, channels: channels)
272
+ sleep backoff
273
+ backoff = [backoff * 2, 5].min
274
+ end
275
+ end
276
+ end
277
+
278
+ def run_session(channels)
279
+ redis = build_subscriber_redis
280
+ @mutex.synchronize { @requested = Set.new(channels) }
281
+
282
+ # redis-rb blocks here until the subscription count drops to zero, calling back for every
283
+ # event. Other threads add and remove channels by sending commands on the same client.
284
+ redis.subscribe(*channels) do |on|
285
+ on.subscribe do |channel, _count|
286
+ @mutex.synchronize do
287
+ unless @session_live
288
+ # redis-rb has the pub/sub socket up once the first confirmation arrives, so from
289
+ # here on other threads may send SUBSCRIBE/UNSUBSCRIBE through it
290
+ @session_live = true
291
+ @subscriber_redis = redis
292
+ end
293
+ @confirmed << channel
294
+ reconcile
295
+ @changed.broadcast
296
+ end
297
+ end
298
+
299
+ on.unsubscribe do |channel, count|
300
+ @mutex.synchronize do
301
+ @confirmed.delete(channel)
302
+ @requested.delete(channel)
303
+ if count.zero?
304
+ # redis-rb closes the socket as soon as this callback returns, so stop other
305
+ # threads from writing to it. Anything wanted by then starts a fresh session.
306
+ @session_live = false
307
+ @subscriber_redis = nil
308
+ else
309
+ reconcile
310
+ end
311
+ @changed.broadcast
312
+ end
313
+ end
314
+
315
+ on.message do |channel, raw|
316
+ dispatch(channel, raw)
317
+ end
318
+ end
319
+ ensure
320
+ @mutex.synchronize do
321
+ @session_live = false
322
+ @subscriber_redis = nil
323
+ @requested.clear
324
+ @confirmed.clear
325
+ @changed.broadcast
326
+ end
327
+ redis&.close
328
+ end
329
+
330
+ # Bring the server-side subscription set in line with the handlers we hold. Only possible
331
+ # while a session is live; before that, the thread's first confirmation calls this to pick
332
+ # up anything added or removed while it was connecting. Caller must hold @mutex.
333
+ def reconcile
334
+ return unless @session_live
335
+
336
+ wanted = @handlers.keys
337
+ (wanted - @requested.to_a).each do |channel|
338
+ @requested << channel
339
+ @subscriber_redis.subscribe(channel)
340
+ end
341
+ (@requested.to_a - wanted).each do |channel|
342
+ @requested.delete(channel)
343
+ @subscriber_redis.unsubscribe(channel)
344
+ end
345
+ end
346
+
347
+ # Decode and hand one message to its handler. A handler that raises is reported and the
348
+ # thread carries on; one bad message must never take the bus down.
349
+ def dispatch(channel, raw)
350
+ handler = @mutex.synchronize { @handlers[channel] }
351
+ return unless handler # unsubscribed while the message was in flight
352
+
353
+ handler.call(decode(raw), channel)
354
+ rescue StandardError => e
355
+ CableRoom.report_error(e, bus: self, channel: channel, raw_message: raw)
356
+ end
357
+
358
+ # Wait on @changed until the block is true or the timeout passes. Caller must hold @mutex.
359
+ def wait_until(timeout, reason)
360
+ deadline = monotonic_now + timeout
361
+ until yield
362
+ remaining = deadline - monotonic_now
363
+ raise TimeoutError, "Timed out after #{timeout}s waiting for #{reason}" if remaining <= 0
364
+ @changed.wait(@mutex, remaining)
365
+ end
366
+ end
367
+
368
+ def monotonic_now
369
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
370
+ end
371
+ end
372
+ end