cable_room 0.8.0.beta1 → 0.8.0.beta2
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 +4 -4
- data/cable_room.gemspec +9 -2
- data/exe/cable_room +8 -0
- data/lib/cable_room/bus.rb +342 -0
- data/lib/cable_room/cli.rb +266 -0
- data/lib/cable_room/config.rb +78 -0
- data/lib/cable_room/host/bus_inbound.rb +41 -0
- data/lib/cable_room/host/placement.rb +261 -0
- data/lib/cable_room/host/runner.rb +15 -21
- data/lib/cable_room/host/supervisor.rb +301 -0
- data/lib/cable_room/host.rb +126 -10
- data/lib/cable_room/ports.rb +19 -9
- data/lib/cable_room/railtie.rb +8 -2
- data/lib/cable_room/room/base.rb +27 -24
- data/lib/cable_room/room/host_adapter.rb +9 -4
- data/lib/cable_room/room.rb +3 -1
- data/lib/cable_room/room_member.rb +63 -8
- data/lib/cable_room/version.rb +1 -1
- data/lib/cable_room.rb +68 -0
- metadata +20 -7
- data/lib/cable_room/host/action_cable_inbound.rb +0 -35
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d0506851a3e7f5f160cc74d3c114f0233b91322b2c0037ae63a641c7486876c7
|
|
4
|
+
data.tar.gz: 448745b3feb776e0d3e32f3a720f3c25b549cc66659056911b2d6a6ae5116891
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7c41faeb794a2f112471ecf9202693a237f0839e63493df9100383b21bf151029f915eff74f8b2cf70980a51290eeb5a85a5cdc1cb675168d1aa1ee25eb7db2c
|
|
7
|
+
data.tar.gz: c0cdfaa13741102891ed4951d59b521ddd82671e73634ec23c68eac4eb9528a829c02a323feff95219e46b312119ef8c797e29ad021547551772e132583ca0be
|
data/cable_room.gemspec
CHANGED
|
@@ -18,14 +18,21 @@ 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", "*.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
|
+
# Workaround approved October 2, 2026: rediconn requires "redis" at load time but only declares
|
|
31
|
+
# it as a development dependency, so cable_room declares it instead. Drop this once rediconn
|
|
32
|
+
# does. The Bus needs >= 5: redis-rb 4 holds its client lock for the whole SUBSCRIBE loop, so
|
|
33
|
+
# another thread can't add a channel to a live session. The ceiling matches ActionCable 8.1's
|
|
34
|
+
# Redis adapter (7.2's is "< 6"), so apps on that adapter still resolve.
|
|
35
|
+
spec.add_dependency "redis", ">= 5", "< 7"
|
|
28
36
|
|
|
29
|
-
spec.add_development_dependency "redis"
|
|
30
37
|
spec.add_development_dependency 'rspec', '~> 3'
|
|
31
38
|
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,342 @@
|
|
|
1
|
+
require 'set'
|
|
2
|
+
require 'redis'
|
|
3
|
+
require 'active_support/json'
|
|
4
|
+
|
|
5
|
+
module CableRoom
|
|
6
|
+
# The Redis pub/sub bus for member-to-room traffic. It's a thin layer over the `CABLEROOM_*`
|
|
7
|
+
# Redis connection (see `CableRoom.redis_pool`) that adds three things:
|
|
8
|
+
#
|
|
9
|
+
# * One place that knows the channel names (`cr:{room_port_key}:in` and friends, and
|
|
10
|
+
# `cr:provision`).
|
|
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
|
+
# Reconnects: when the pub/sub connection drops, the subscriber thread reports the error and
|
|
22
|
+
# opens a new session for every channel it still has a handler for, backing off from 0.1 s up
|
|
23
|
+
# to 5 s between attempts. Redis pub/sub doesn't queue, so anything published while the
|
|
24
|
+
# session is down is lost. Callers that can't afford that need their own recovery (members
|
|
25
|
+
# re-announce on their ping, for example).
|
|
26
|
+
#
|
|
27
|
+
# Handlers run on the subscriber thread. Keep them short (hand the work to a queue) and never
|
|
28
|
+
# block in them waiting on the bus itself. A handler is allowed to call `subscribe` and
|
|
29
|
+
# `unsubscribe`; those calls return without waiting when made from the subscriber thread,
|
|
30
|
+
# because the confirmation they'd wait for is delivered by that same thread.
|
|
31
|
+
class Bus
|
|
32
|
+
# Raised at the publisher when a payload can't be turned into JSON.
|
|
33
|
+
class EncodeError < ArgumentError; end
|
|
34
|
+
|
|
35
|
+
# Raised when Redis doesn't confirm a subscribe or unsubscribe within the timeout.
|
|
36
|
+
class TimeoutError < StandardError; end
|
|
37
|
+
|
|
38
|
+
KEY_PREFIX = "cr".freeze
|
|
39
|
+
DEFAULT_TIMEOUT = 5
|
|
40
|
+
|
|
41
|
+
INITIAL_BACKOFF = 0.1
|
|
42
|
+
MAX_BACKOFF = 5
|
|
43
|
+
|
|
44
|
+
class << self
|
|
45
|
+
# Pub/sub channel that a room's host listens on for member-to-room messages. A room's main
|
|
46
|
+
# inbound port has no suffix (`cr:{room_port_key}:in`); a custom inbound port gets one
|
|
47
|
+
# (`cr:{room_port_key}:in:{port}`), so every channel a room listens on shares one prefix.
|
|
48
|
+
# The room part is the room's own stream name, so an app's `broadcasting_for` override
|
|
49
|
+
# (PandaPal's tenant prefix) keeps two tenants' rooms apart here too.
|
|
50
|
+
def inbound_channel(room_class, room_key, port = nil)
|
|
51
|
+
channel = room_channel(room_class.room_port_key(room_key), "in")
|
|
52
|
+
port.nil? ? channel : "#{channel}:#{port}"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# The one process-wide channel `create: true` members ask for rooms on, and every rooms
|
|
56
|
+
# Host's Placement listens to (see Host::Placement). It isn't per tenant: the tenant rides in
|
|
57
|
+
# the request, and the Host that claims the room switches into it.
|
|
58
|
+
def provision_channel
|
|
59
|
+
"#{KEY_PREFIX}:provision"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def room_channel(port_key, suffix)
|
|
65
|
+
key = port_key.to_s
|
|
66
|
+
raise ArgumentError, "room_port_key can't be blank" if key.empty?
|
|
67
|
+
"#{KEY_PREFIX}:#{key}:#{suffix}"
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# `redis_pool` defaults to `CableRoom.redis_pool` (looked up lazily so specs can swap it).
|
|
72
|
+
def initialize(redis_pool: nil)
|
|
73
|
+
@redis_pool = redis_pool
|
|
74
|
+
|
|
75
|
+
# Everything below is guarded by @mutex and changes are announced on @changed
|
|
76
|
+
@mutex = Mutex.new
|
|
77
|
+
@changed = ConditionVariable.new
|
|
78
|
+
@handlers = {} # channel => handler; the channels we want to be subscribed to
|
|
79
|
+
@requested = Set.new # channels we've sent SUBSCRIBE for in the current session
|
|
80
|
+
@confirmed = Set.new # channels Redis has confirmed in the current session
|
|
81
|
+
@thread = nil # the subscriber thread, nil when no channels are wanted
|
|
82
|
+
@session_live = false # true while @subscriber_redis accepts commands from other threads
|
|
83
|
+
@subscriber_redis = nil
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# ---- Publisher --------------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
# Publish a JSON-encodable payload. Returns the number of subscribers that received it.
|
|
89
|
+
def publish(channel, payload)
|
|
90
|
+
encoded = encode(payload)
|
|
91
|
+
redis { |r| r.publish(channel, encoded) }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# ---- Subscriber -------------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
# Register `handler` for `channel` and block until Redis confirms the subscription. The
|
|
97
|
+
# handler is called as `handler.call(message, channel)` with the decoded message on the
|
|
98
|
+
# subscriber thread. Subscribing again to the same channel replaces the handler.
|
|
99
|
+
#
|
|
100
|
+
# Raises TimeoutError (and forgets the handler) if the subscription isn't confirmed in time,
|
|
101
|
+
# which usually means Redis is unreachable.
|
|
102
|
+
def subscribe(channel, timeout: DEFAULT_TIMEOUT, &handler)
|
|
103
|
+
raise ArgumentError, "subscribe needs a block to handle messages" unless handler
|
|
104
|
+
channel = channel.to_s
|
|
105
|
+
|
|
106
|
+
@mutex.synchronize do
|
|
107
|
+
@handlers[channel] = handler
|
|
108
|
+
if @thread
|
|
109
|
+
reconcile
|
|
110
|
+
else
|
|
111
|
+
start_subscriber_thread
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
return true if on_subscriber_thread?
|
|
115
|
+
|
|
116
|
+
begin
|
|
117
|
+
wait_until(timeout, "subscription to #{channel}") { @confirmed.include?(channel) }
|
|
118
|
+
rescue TimeoutError
|
|
119
|
+
@handlers.delete(channel)
|
|
120
|
+
reconcile
|
|
121
|
+
raise
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
true
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Drop the handler for `channel` and block until Redis confirms the unsubscribe. When no
|
|
128
|
+
# channels are left the subscriber thread exits, and this also waits for that. Returns
|
|
129
|
+
# false if the channel wasn't subscribed. With `handler:`, only unsubscribes while that
|
|
130
|
+
# handler is still the one registered (a later `subscribe` may have replaced it).
|
|
131
|
+
def unsubscribe(channel, handler: nil, timeout: DEFAULT_TIMEOUT)
|
|
132
|
+
channel = channel.to_s
|
|
133
|
+
|
|
134
|
+
@mutex.synchronize do
|
|
135
|
+
return false if handler && !@handlers[channel].equal?(handler)
|
|
136
|
+
return false unless @handlers.delete(channel)
|
|
137
|
+
reconcile
|
|
138
|
+
|
|
139
|
+
return true if on_subscriber_thread?
|
|
140
|
+
|
|
141
|
+
wait_until(timeout, "unsubscribe from #{channel}") do
|
|
142
|
+
!@confirmed.include?(channel) && (@thread.nil? || @handlers.any?)
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
true
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Unsubscribe from everything and wait for the subscriber thread to exit.
|
|
149
|
+
def shutdown(timeout: DEFAULT_TIMEOUT)
|
|
150
|
+
@mutex.synchronize do
|
|
151
|
+
@handlers.clear
|
|
152
|
+
reconcile
|
|
153
|
+
return true if on_subscriber_thread?
|
|
154
|
+
wait_until(timeout, "subscriber thread to exit") { @thread.nil? }
|
|
155
|
+
end
|
|
156
|
+
true
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Channels Redis has confirmed we're subscribed to right now.
|
|
160
|
+
def subscribed_channels
|
|
161
|
+
@mutex.synchronize { @confirmed.to_a }
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def subscribed?(channel)
|
|
165
|
+
@mutex.synchronize { @confirmed.include?(channel.to_s) }
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def subscriber_alive?
|
|
169
|
+
thread = @mutex.synchronize { @thread }
|
|
170
|
+
!!thread&.alive?
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
private
|
|
174
|
+
|
|
175
|
+
def redis_pool
|
|
176
|
+
@redis_pool || CableRoom.redis_pool
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def redis(&blk)
|
|
180
|
+
redis_pool.with(&blk)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def encode(payload)
|
|
184
|
+
ActiveSupport::JSON.encode(payload)
|
|
185
|
+
rescue StandardError => e
|
|
186
|
+
raise EncodeError, "payload can't be encoded as JSON: #{e.class}: #{e.message}"
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def decode(raw)
|
|
190
|
+
ActiveSupport::JSON.decode(raw)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def on_subscriber_thread?
|
|
194
|
+
Thread.current == @thread
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# The subscriber needs a connection of its own: a pub/sub connection can't run other
|
|
198
|
+
# commands, and it lives as long as there are subscriptions, so it must not be a pooled
|
|
199
|
+
# one (a small pool would run dry). Borrow a pooled client just long enough to dup it,
|
|
200
|
+
# which gives an unconnected client with the same settings and no pool ties.
|
|
201
|
+
def build_subscriber_redis
|
|
202
|
+
redis(&:dup)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def start_subscriber_thread
|
|
206
|
+
@thread = Thread.new { subscriber_loop }
|
|
207
|
+
@thread.name = "cable_room-bus-subscriber"
|
|
208
|
+
@thread.report_on_exception = false
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Runs on the subscriber thread. Each pass opens one pub/sub session covering every wanted
|
|
212
|
+
# channel; a session ends when the last channel is unsubscribed (normal) or the connection
|
|
213
|
+
# fails (reported, then retried with backoff). The loop exits once nothing is wanted.
|
|
214
|
+
def subscriber_loop
|
|
215
|
+
backoff = INITIAL_BACKOFF
|
|
216
|
+
loop do
|
|
217
|
+
channels = @mutex.synchronize do
|
|
218
|
+
if @handlers.empty?
|
|
219
|
+
@thread = nil
|
|
220
|
+
@changed.broadcast
|
|
221
|
+
return
|
|
222
|
+
end
|
|
223
|
+
@handlers.keys
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
begin
|
|
227
|
+
run_session(channels)
|
|
228
|
+
backoff = INITIAL_BACKOFF
|
|
229
|
+
rescue StandardError => e
|
|
230
|
+
CableRoom.report_error(e, bus: self, channels: channels)
|
|
231
|
+
sleep backoff
|
|
232
|
+
backoff = [backoff * 2, MAX_BACKOFF].min
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def run_session(channels)
|
|
238
|
+
redis = build_subscriber_redis
|
|
239
|
+
@mutex.synchronize { @requested = Set.new(channels) }
|
|
240
|
+
|
|
241
|
+
# redis-rb blocks here until the subscription count drops to zero, calling back for every
|
|
242
|
+
# event. Other threads add and remove channels by sending commands on the same client.
|
|
243
|
+
redis.subscribe(*channels) do |on|
|
|
244
|
+
on.subscribe do |channel, _count|
|
|
245
|
+
@mutex.synchronize do
|
|
246
|
+
unless @session_live
|
|
247
|
+
# redis-rb has the pub/sub socket up once the first confirmation arrives, so from
|
|
248
|
+
# here on other threads may send SUBSCRIBE/UNSUBSCRIBE through it
|
|
249
|
+
@session_live = true
|
|
250
|
+
@subscriber_redis = redis
|
|
251
|
+
end
|
|
252
|
+
@confirmed << channel
|
|
253
|
+
reconcile
|
|
254
|
+
@changed.broadcast
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
on.unsubscribe do |channel, count|
|
|
259
|
+
@mutex.synchronize do
|
|
260
|
+
@confirmed.delete(channel)
|
|
261
|
+
@requested.delete(channel)
|
|
262
|
+
if count.zero?
|
|
263
|
+
# redis-rb closes the socket as soon as this callback returns, so stop other
|
|
264
|
+
# threads from writing to it. Anything wanted by then starts a fresh session.
|
|
265
|
+
@session_live = false
|
|
266
|
+
@subscriber_redis = nil
|
|
267
|
+
else
|
|
268
|
+
reconcile
|
|
269
|
+
end
|
|
270
|
+
@changed.broadcast
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
on.message do |channel, raw|
|
|
275
|
+
dispatch(channel, raw)
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
ensure
|
|
279
|
+
@mutex.synchronize do
|
|
280
|
+
@session_live = false
|
|
281
|
+
@subscriber_redis = nil
|
|
282
|
+
@requested.clear
|
|
283
|
+
@confirmed.clear
|
|
284
|
+
@changed.broadcast
|
|
285
|
+
end
|
|
286
|
+
redis&.close
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# Bring the server-side subscription set in line with the handlers we hold. Only possible
|
|
290
|
+
# while a session is live; before that, the thread's first confirmation calls this to pick
|
|
291
|
+
# up anything added or removed while it was connecting. Caller must hold @mutex.
|
|
292
|
+
#
|
|
293
|
+
# The writes below happen under @mutex, which is safe because they only queue a command on
|
|
294
|
+
# the socket: nothing here waits on a reply, and the subscriber thread never needs anything
|
|
295
|
+
# but @mutex to make progress.
|
|
296
|
+
def reconcile
|
|
297
|
+
return unless @session_live
|
|
298
|
+
|
|
299
|
+
wanted = @handlers.keys
|
|
300
|
+
(wanted - @requested.to_a).each do |channel|
|
|
301
|
+
@requested << channel
|
|
302
|
+
@subscriber_redis.subscribe(channel)
|
|
303
|
+
end
|
|
304
|
+
(@requested.to_a - wanted).each do |channel|
|
|
305
|
+
@requested.delete(channel)
|
|
306
|
+
@subscriber_redis.unsubscribe(channel)
|
|
307
|
+
end
|
|
308
|
+
rescue Redis::BaseConnectionError, RedisClient::ConnectionError
|
|
309
|
+
# The socket died under us. Stop writing to it and let the subscriber thread notice, report
|
|
310
|
+
# it, and open a new session for every channel still wanted. A caller waiting in
|
|
311
|
+
# `subscribe` rides on that session's confirmation (or times out) rather than seeing the
|
|
312
|
+
# connection error for a subscription the bus is about to retry anyway.
|
|
313
|
+
@session_live = false
|
|
314
|
+
@subscriber_redis = nil
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
# Decode and hand one message to its handler. A handler that raises is reported and the
|
|
318
|
+
# thread carries on; one bad message must never take the bus down.
|
|
319
|
+
def dispatch(channel, raw)
|
|
320
|
+
handler = @mutex.synchronize { @handlers[channel] }
|
|
321
|
+
return unless handler # unsubscribed while the message was in flight
|
|
322
|
+
|
|
323
|
+
handler.call(decode(raw), channel)
|
|
324
|
+
rescue StandardError => e
|
|
325
|
+
CableRoom.report_error(e, bus: self, channel: channel, raw_message: raw)
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
# Wait on @changed until the block is true or the timeout passes. Caller must hold @mutex.
|
|
329
|
+
def wait_until(timeout, reason)
|
|
330
|
+
deadline = monotonic_now + timeout
|
|
331
|
+
until yield
|
|
332
|
+
remaining = deadline - monotonic_now
|
|
333
|
+
raise TimeoutError, "Timed out after #{timeout}s waiting for #{reason}" if remaining <= 0
|
|
334
|
+
@changed.wait(@mutex, remaining)
|
|
335
|
+
end
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def monotonic_now
|
|
339
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
340
|
+
end
|
|
341
|
+
end
|
|
342
|
+
end
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
require "etc"
|
|
5
|
+
require "logger"
|
|
6
|
+
require_relative "version"
|
|
7
|
+
|
|
8
|
+
module CableRoom
|
|
9
|
+
# The `cable_room` command. Its one command, `server`, is the rooms process for
|
|
10
|
+
# `room_host = :remote`: boot the Rails app, become this process's rooms Host, run until SIGTERM
|
|
11
|
+
# or SIGINT, then shut every room down (members get room_closed). Rooms aren't migrated
|
|
12
|
+
# anywhere on the way out.
|
|
13
|
+
#
|
|
14
|
+
# It refuses to run in :inline, where the web processes host rooms themselves and a server here
|
|
15
|
+
# would only race them for locks.
|
|
16
|
+
#
|
|
17
|
+
# What the rooms process needs from the app:
|
|
18
|
+
#
|
|
19
|
+
# * The app's own config/cable.yml with a Redis adapter. Rooms still talk to members with
|
|
20
|
+
# `ActionCable.server.broadcast`, which reaches web processes only through a shared
|
|
21
|
+
# pubsub; the async and inline adapters keep broadcasts inside this process.
|
|
22
|
+
# * The same CABLEROOM_* Redis settings as the web processes. Members publish to rooms on
|
|
23
|
+
# the Bus there, and room locks live there.
|
|
24
|
+
# * A tenant for every room it starts, in a multi-tenant app. Provision requests carry the
|
|
25
|
+
# member's tenant; a room started here by hand needs `MyRoom.ensure(key, tenant: ...)`.
|
|
26
|
+
#
|
|
27
|
+
# Rooms start here through provisioning: a `create: true` join on the web side publishes a
|
|
28
|
+
# provision request on the Bus, and each rooms Host's Placement (started by `Host.start!`)
|
|
29
|
+
# waits in proportion to its load, then races for the room's lock (see Host::Placement).
|
|
30
|
+
#
|
|
31
|
+
# Boot works the way sidekiq and good_job do it: `require` the app's config/environment.rb from
|
|
32
|
+
# the current directory, or from `--require PATH` (an app directory or a boot file). Bundler is
|
|
33
|
+
# already set up because the command runs under `bundle exec`. Nothing from the gem beyond this
|
|
34
|
+
# file and `version` loads before the app boots, so the app's Gemfile decides when the Railtie
|
|
35
|
+
# loads, the same as in a web process. Nothing subscribes to Redis or starts a thread until
|
|
36
|
+
# the app has booted and been eager-loaded, so a supervisor can fork after that point.
|
|
37
|
+
#
|
|
38
|
+
# `--workers N` picks the process model. With N = 1 this process hosts rooms itself, as a
|
|
39
|
+
# single rooms process always has; the container's restart policy is its supervisor. With
|
|
40
|
+
# N > 1 (the default is the machine's processor count) the app is booted once, here, and then
|
|
41
|
+
# `Host::Supervisor` forks N children that each host rooms with their own Bus connection;
|
|
42
|
+
# this parent only watches them, replaces one that dies, and relays SIGTERM and SIGINT. The
|
|
43
|
+
# parent never builds a Host, subscribes the Bus, or keeps a Redis or database connection:
|
|
44
|
+
# the children build their own after the fork, so no two processes ever share a socket or a
|
|
45
|
+
# thread.
|
|
46
|
+
#
|
|
47
|
+
# `run` returns an exit status rather than exiting, so specs can drive it in-process.
|
|
48
|
+
class CLI
|
|
49
|
+
STOP_SIGNALS = %w[TERM INT].freeze
|
|
50
|
+
|
|
51
|
+
# How long a room's startup waits for Redis to confirm each Bus subscription here. A healthy
|
|
52
|
+
# Redis confirms in milliseconds; this only bounds a sick one, and failing sooner frees the
|
|
53
|
+
# room's lock sooner. The web-side default (Bus::DEFAULT_TIMEOUT, 5 s) is sized for a member
|
|
54
|
+
# join that can afford to wait.
|
|
55
|
+
SUBSCRIBE_TIMEOUT = 2
|
|
56
|
+
|
|
57
|
+
attr_reader :options, :command
|
|
58
|
+
|
|
59
|
+
def initialize(argv, stdout: $stdout, stderr: $stderr)
|
|
60
|
+
@argv = argv.dup
|
|
61
|
+
@stdout = stdout
|
|
62
|
+
@stderr = stderr
|
|
63
|
+
@options = { require: Dir.pwd, workers: nil }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Parse and run. Returns the process exit status.
|
|
67
|
+
def run
|
|
68
|
+
parse!
|
|
69
|
+
return 0 if @help_shown
|
|
70
|
+
|
|
71
|
+
case command
|
|
72
|
+
when "server" then server
|
|
73
|
+
when nil
|
|
74
|
+
@stderr.puts parser
|
|
75
|
+
1
|
|
76
|
+
else
|
|
77
|
+
@stderr.puts "Unknown command #{command.inspect}.", parser
|
|
78
|
+
1
|
|
79
|
+
end
|
|
80
|
+
rescue OptionParser::ParseError => e
|
|
81
|
+
@stderr.puts e.message, parser
|
|
82
|
+
1
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# How many worker processes `server` runs: `--workers`, or one per processor
|
|
86
|
+
def workers
|
|
87
|
+
options[:workers] || Etc.nprocessors
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def parser
|
|
93
|
+
@parser ||= OptionParser.new do |o|
|
|
94
|
+
o.banner = "Usage: cable_room server [options]"
|
|
95
|
+
o.separator ""
|
|
96
|
+
o.separator "Hosts CableRoom rooms for a Rails app with room_host = :remote: boots the app in"
|
|
97
|
+
o.separator "the current directory and runs until SIGTERM or SIGINT. Needs the app's"
|
|
98
|
+
o.separator "config/cable.yml (a Redis adapter) and the same CABLEROOM_* Redis as the web."
|
|
99
|
+
o.separator ""
|
|
100
|
+
o.on("-r", "--require PATH", "App directory (with config/environment.rb) or a boot file to require") do |path|
|
|
101
|
+
@options[:require] = path
|
|
102
|
+
end
|
|
103
|
+
o.on("-w", "--workers N", Integer,
|
|
104
|
+
"Worker processes to fork (default: one per processor, #{Etc.nprocessors} here). 1 runs in this process.") do |n|
|
|
105
|
+
@options[:workers] = n
|
|
106
|
+
end
|
|
107
|
+
o.on("-v", "--version", "Print the version and exit") do
|
|
108
|
+
@stdout.puts CableRoom::VERSION
|
|
109
|
+
@help_shown = true
|
|
110
|
+
end
|
|
111
|
+
o.on("-h", "--help", "Show this help") do
|
|
112
|
+
@stdout.puts o
|
|
113
|
+
@help_shown = true
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def parse!
|
|
119
|
+
rest = parser.parse(@argv)
|
|
120
|
+
@command = rest.shift
|
|
121
|
+
raise OptionParser::ParseError, "Unexpected arguments: #{rest.join(' ')}" if rest.any?
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def server
|
|
125
|
+
# Check the cheap things before booting Rails, so a bad flag fails in milliseconds
|
|
126
|
+
unless workers >= 1
|
|
127
|
+
@stderr.puts "cable_room server: --workers must be at least 1 (got #{workers})."
|
|
128
|
+
return 1
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
boot_app!
|
|
132
|
+
|
|
133
|
+
unless CableRoom.remote?
|
|
134
|
+
@stderr.puts "cable_room server: CableRoom.room_host is #{CableRoom.room_host.inspect}, but a rooms " \
|
|
135
|
+
"process only makes sense with :remote (in :inline the web processes host rooms " \
|
|
136
|
+
"themselves). Set CABLE_ROOM_HOST=remote or `CableRoom.room_host = :remote` and try again."
|
|
137
|
+
return 1
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
warn_about_local_cable_adapter
|
|
141
|
+
return run_host if workers == 1
|
|
142
|
+
|
|
143
|
+
supervise_workers
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Load the app: a directory means its config/environment.rb, anything else is required as-is.
|
|
147
|
+
# Then eager-load it, as production does anyway, so every room class is loaded before any
|
|
148
|
+
# room starts (and before a supervisor forks).
|
|
149
|
+
def boot_app!
|
|
150
|
+
path = File.expand_path(options[:require])
|
|
151
|
+
path = File.join(path, "config", "environment.rb") if File.directory?(path)
|
|
152
|
+
require path
|
|
153
|
+
|
|
154
|
+
Rails.application.eager_load! if defined?(Rails) && Rails.application && !Rails.application.config.eager_load
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def warn_about_local_cable_adapter
|
|
158
|
+
cable = ActionCable.server.config.cable || {}
|
|
159
|
+
adapter = (cable[:adapter] || cable["adapter"]).to_s
|
|
160
|
+
return unless %w[async inline test].include?(adapter)
|
|
161
|
+
|
|
162
|
+
@stderr.puts "cable_room server: warning: ActionCable's adapter is #{adapter.inspect}, so room broadcasts " \
|
|
163
|
+
"never leave this process and members won't hear their rooms. Use the app's Redis cable.yml."
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Fork `workers` children that each run `run_host`, and watch them until a stop signal has
|
|
167
|
+
# come and gone. This parent hosts nothing itself (see the class comment for why).
|
|
168
|
+
def supervise_workers
|
|
169
|
+
Process.setproctitle("cable_room server: supervisor (#{workers} workers)")
|
|
170
|
+
# Fail now, once, if the CABLEROOM_* Redis is unreachable, rather than in N workers that
|
|
171
|
+
# would each die and come back with backoff. Then let go of what the ping (or the app's
|
|
172
|
+
# boot) opened, so the children inherit no Redis or database sockets.
|
|
173
|
+
CableRoom.redis(&:ping)
|
|
174
|
+
release_connections_before_fork
|
|
175
|
+
say "supervising #{workers} workers (pid #{Process.pid}, room_host=#{CableRoom.room_host})"
|
|
176
|
+
|
|
177
|
+
supervisor = CableRoom::Host::Supervisor.new(count: workers, logger: supervisor_logger) do |index|
|
|
178
|
+
run_host(worker: index)
|
|
179
|
+
end
|
|
180
|
+
status = supervisor.run
|
|
181
|
+
say "shut down"
|
|
182
|
+
status
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Close the parent's idle pooled Redis connections and its ActiveRecord connections. The
|
|
186
|
+
# children would drop their copies anyway (see `CableRoom.after_fork!`); closing them here
|
|
187
|
+
# means there are no copies to drop, and the parent, which only waits on children from now
|
|
188
|
+
# on, holds nothing it won't use. Both pools reconnect lazily if anything asks again.
|
|
189
|
+
def release_connections_before_fork
|
|
190
|
+
CableRoom.redis_pool.reload(&:close)
|
|
191
|
+
ActiveRecord::Base.connection_handler.clear_all_connections!(:all) if defined?(ActiveRecord::Base)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Logs from the supervisor go to the same place as the CLI's own messages, in the same style.
|
|
195
|
+
# Unbuffered, so a "forked worker" line shows up when it happens, not when the process exits.
|
|
196
|
+
def supervisor_logger
|
|
197
|
+
@stdout.sync = true if @stdout.respond_to?(:sync=)
|
|
198
|
+
Logger.new(@stdout).tap do |logger|
|
|
199
|
+
logger.formatter = ->(_severity, _time, _progname, message) { "cable_room server: #{message}\n" }
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Host rooms in this process until SIGTERM or SIGINT, then shut them down. This is the whole
|
|
204
|
+
# life of a single-process server and of each forked worker (`worker` is the worker's index,
|
|
205
|
+
# nil when there's no supervisor). Returns the exit status.
|
|
206
|
+
def run_host(worker: nil)
|
|
207
|
+
# Trap before anything slow, so a signal that lands while rooms are still coming up (the
|
|
208
|
+
# supervisor relaying a SIGTERM that arrived mid-boot, say) is kept, not fatal
|
|
209
|
+
signals = trap_stop_signals
|
|
210
|
+
|
|
211
|
+
if worker
|
|
212
|
+
Process.setproctitle("cable_room server: worker #{worker}")
|
|
213
|
+
tag_logger("cable_room worker #{worker}")
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Fail now, not at the first room, if the CABLEROOM_* Redis is unreachable
|
|
217
|
+
CableRoom.redis(&:ping)
|
|
218
|
+
|
|
219
|
+
host = CableRoom::Host.start!(subscribe_timeout: SUBSCRIBE_TIMEOUT)
|
|
220
|
+
say "hosting rooms (pid #{Process.pid}, room_host=#{CableRoom.room_host})", worker: worker
|
|
221
|
+
|
|
222
|
+
signal = signals.pop
|
|
223
|
+
say "got SIG#{signal}, shutting down #{host.open_rooms_count} room(s)", worker: worker
|
|
224
|
+
# A second signal from here on gets the previous handler (the default one kills at once)
|
|
225
|
+
restore_signal_handlers
|
|
226
|
+
host.shutdown!
|
|
227
|
+
CableRoom.bus.shutdown
|
|
228
|
+
say "shut down", worker: worker
|
|
229
|
+
0
|
|
230
|
+
ensure
|
|
231
|
+
restore_signal_handlers
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Every room on this Host logs through `ActionCable.server.logger` (Host#logger); give that
|
|
235
|
+
# logger a tag naming the worker, so lines from the N workers sharing one stdout can be told
|
|
236
|
+
# apart. `tagged` without a block returns a logger whose tags apply on every thread, which is
|
|
237
|
+
# what we need: rooms log from the worker pool, not from this thread. A logger without tags
|
|
238
|
+
# (a plain Logger, say) is left as it is.
|
|
239
|
+
def tag_logger(tag)
|
|
240
|
+
logger = ActionCable.server.logger
|
|
241
|
+
return unless logger.respond_to?(:tagged)
|
|
242
|
+
|
|
243
|
+
tagged = logger.tagged(tag)
|
|
244
|
+
ActionCable.server.config.logger = tagged if tagged.respond_to?(:info)
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Signal handlers may only do trivial work, so each one just drops the signal's name on a
|
|
248
|
+
# queue for the main thread to pick up
|
|
249
|
+
def trap_stop_signals
|
|
250
|
+
signals = Queue.new
|
|
251
|
+
@previous_handlers = STOP_SIGNALS.to_h { |sig| [sig, trap(sig) { signals << sig }] }
|
|
252
|
+
signals
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def restore_signal_handlers
|
|
256
|
+
@previous_handlers&.each { |sig, handler| trap(sig, handler) }
|
|
257
|
+
@previous_handlers = nil
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def say(message, worker: nil)
|
|
261
|
+
prefix = worker ? "cable_room server (worker #{worker})" : "cable_room server"
|
|
262
|
+
@stdout.puts "#{prefix}: #{message}"
|
|
263
|
+
@stdout.flush
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
end
|