cable_room 0.6.2.beta1 → 0.7.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/CHANGELOG.md +122 -0
- data/README.md +662 -48
- data/cable_room.gemspec +5 -2
- data/exe/cable_room +8 -0
- data/lib/cable_room/broadcaster.rb +116 -0
- data/lib/cable_room/bus.rb +372 -0
- data/lib/cable_room/cli.rb +237 -0
- data/lib/cable_room/config.rb +112 -0
- data/lib/cable_room/host/bus_inbound.rb +36 -0
- data/lib/cable_room/host/runner.rb +577 -0
- data/lib/cable_room/host/supervisor.rb +275 -0
- data/lib/cable_room/host/worker_pool.rb +37 -0
- data/lib/cable_room/host.rb +482 -0
- data/lib/cable_room/membership_store.rb +105 -0
- data/lib/cable_room/migration.rb +586 -0
- data/lib/cable_room/periodic_timer.rb +18 -0
- data/lib/cable_room/placement.rb +260 -0
- data/lib/cable_room/ports.rb +20 -50
- data/lib/cable_room/railtie.rb +3 -12
- data/lib/cable_room/room/base.rb +45 -39
- data/lib/cable_room/room/host_adapter.rb +52 -0
- data/lib/cable_room/room/lifecycle.rb +26 -9
- data/lib/cable_room/room/port_management.rb +70 -2
- data/lib/cable_room/room/reaping.rb +34 -1
- data/lib/cable_room/room/snapshotting.rb +82 -0
- data/lib/cable_room/room/threading.rb +2 -2
- data/lib/cable_room/room/user_management.rb +27 -0
- data/lib/cable_room/room.rb +5 -2
- data/lib/cable_room/room_harness.rb +168 -0
- data/lib/cable_room/room_member.rb +293 -69
- data/lib/cable_room/room_proxy_channel.rb +13 -2
- data/lib/cable_room/snapshot.rb +136 -0
- data/lib/cable_room/version.rb +1 -1
- data/lib/cable_room.rb +57 -2
- metadata +26 -9
- data/lib/cable_room/channel_base.rb +0 -247
- data/lib/cable_room/channel_tracker.rb +0 -130
- data/lib/cable_room/room/channel_adapter.rb +0 -18
|
@@ -1,9 +1,30 @@
|
|
|
1
1
|
module CableRoom
|
|
2
|
+
# Raised at subscribe time when `join_room` asks for something AnyCable can't deliver
|
|
3
|
+
# (see RoomMember#refuse_unsupported_under_anycable!).
|
|
4
|
+
class AnyCableUnsupported < ArgumentError; end
|
|
5
|
+
|
|
2
6
|
module RoomMember
|
|
3
7
|
extend ActiveSupport::Concern
|
|
4
8
|
|
|
9
|
+
# How often a silent member reminds its room that it is alive. A member that has sent
|
|
10
|
+
# anything within this interval skips the ping (the room counts every inbound message as
|
|
11
|
+
# activity), so the room hears from a live member at least every 2 x PING_INTERVAL — which
|
|
12
|
+
# must stay under Room::PortManagement::PORT_TIMEOUT with margin for queue lag. Pings were
|
|
13
|
+
# ~30% of all room messages at 10 s with no skipping.
|
|
14
|
+
PING_INTERVAL = 15.seconds
|
|
15
|
+
|
|
5
16
|
included do
|
|
6
|
-
periodically :ping_room_memberships, every:
|
|
17
|
+
periodically :ping_room_memberships, every: PING_INTERVAL
|
|
18
|
+
|
|
19
|
+
# Where MembershipStore::AnyCable keeps the memberships between anycable-rails' per-call
|
|
20
|
+
# channel instances. `state_attr_accessor` only exists once anycable-rails is loaded; without
|
|
21
|
+
# it the channel lives for the socket and the in-memory store is all there is. Private
|
|
22
|
+
# because every public method of a channel is an action a client can perform, and a client
|
|
23
|
+
# that could write this could claim another member's token.
|
|
24
|
+
if respond_to?(:state_attr_accessor)
|
|
25
|
+
state_attr_accessor MembershipStore::AnyCable::STATE_ATTRIBUTE
|
|
26
|
+
private MembershipStore::AnyCable::STATE_ATTRIBUTE, :"#{MembershipStore::AnyCable::STATE_ATTRIBUTE}="
|
|
27
|
+
end
|
|
7
28
|
|
|
8
29
|
after_unsubscribe do
|
|
9
30
|
to_close = _room_memberships.to_a
|
|
@@ -12,13 +33,51 @@ module CableRoom
|
|
|
12
33
|
end
|
|
13
34
|
end
|
|
14
35
|
|
|
36
|
+
# The channel's memberships (a MembershipStore). RoomMembership registers itself here, so this
|
|
37
|
+
# has to stay public; treat it as internal and read `room_memberships` instead.
|
|
15
38
|
def _room_memberships
|
|
16
|
-
@_room_memberships ||=
|
|
39
|
+
@_room_memberships ||= MembershipStore.for(self)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# True when anycable-rails is handling this channel: the connection carries an AnyCable socket,
|
|
43
|
+
# this instance exists for one RPC call only, its streams are held by anycable-go, and its
|
|
44
|
+
# timers never run. `anycabled?` is what anycable-rails itself checks; it only exists once the
|
|
45
|
+
# gem is loaded. (Public methods are actions; performing this one is harmless.)
|
|
46
|
+
def anycable_channel?
|
|
47
|
+
connection.respond_to?(:anycabled?) && !!connection.anycabled?
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# The `hello` channel action. The browser performs it once ActionCable has confirmed the
|
|
51
|
+
# subscription (`connected()` in the JS client), which is the first moment every stream this
|
|
52
|
+
# channel opened is guaranteed live. Each membership announces itself to its room in response;
|
|
53
|
+
# nothing is announced before then. A channel with no memberships ignores it.
|
|
54
|
+
#
|
|
55
|
+
# Public methods on a channel are its actions, so every RoomMember channel accepts
|
|
56
|
+
# `perform("hello")` without further wiring.
|
|
57
|
+
def hello(_data = nil)
|
|
58
|
+
hello_room_memberships
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# The `ping` channel action. Under ActionCable the channel's own timer pings, so a client that
|
|
62
|
+
# performs this is doing harmless extra work. Under AnyCable channel timers never run (there is
|
|
63
|
+
# no long-lived channel object to run them on), so the browser performs this every
|
|
64
|
+
# PING_INTERVAL and each membership turns it into port_ping. See README "Using AnyCable".
|
|
65
|
+
def ping(_data = nil)
|
|
66
|
+
ping_room_memberships
|
|
17
67
|
end
|
|
18
68
|
|
|
19
69
|
protected
|
|
20
70
|
|
|
71
|
+
# The memberships `join_room` created on this channel. Under AnyCable the instance variable
|
|
72
|
+
# `subscribed` assigned is gone by the next call; this still has them, rebuilt from the
|
|
73
|
+
# channel state.
|
|
74
|
+
def room_memberships
|
|
75
|
+
_room_memberships
|
|
76
|
+
end
|
|
77
|
+
|
|
21
78
|
def join_room(room_class, room_key = nil, as: :not_given, forward: false, **kwargs, &blk)
|
|
79
|
+
refuse_unsupported_under_anycable!(forward: forward, callbacks: kwargs, block: blk)
|
|
80
|
+
|
|
22
81
|
if forward
|
|
23
82
|
# raise ArgumentError, "Cannot specify both `forward: true` and `on_message:`" if kwargs[:on_message]
|
|
24
83
|
original_on_message = kwargs[:on_message]
|
|
@@ -41,6 +100,35 @@ module CableRoom
|
|
|
41
100
|
def ping_room_memberships
|
|
42
101
|
_room_memberships.each(&:ping!)
|
|
43
102
|
end
|
|
103
|
+
|
|
104
|
+
# Under AnyCable, room→member broadcasts go anycable-go → socket and never pass through this
|
|
105
|
+
# process, and the channel object is rebuilt per call, so nothing here can run a proc when a
|
|
106
|
+
# message arrives. That rules out `forward: false` (nothing would deliver messages to the app),
|
|
107
|
+
# every `on_*:` callback, and a preconfigure block (custom stream handlers). Failing at
|
|
108
|
+
# subscribe time beats a join that silently never fires anything. RoomProxyChannel's own
|
|
109
|
+
# `forward` wrapper is added after this check, so it is exempt. Plain ActionCable: no-op.
|
|
110
|
+
UNSUPPORTED_UNDER_ANYCABLE_CALLBACKS = %i[on_joined on_message on_room_opened on_room_closed on_left].freeze
|
|
111
|
+
|
|
112
|
+
def refuse_unsupported_under_anycable!(forward:, callbacks:, block:)
|
|
113
|
+
return unless anycable_channel?
|
|
114
|
+
|
|
115
|
+
unsupported = []
|
|
116
|
+
unsupported << "forward: false" unless forward
|
|
117
|
+
unsupported.concat(UNSUPPORTED_UNDER_ANYCABLE_CALLBACKS.select { |cb| callbacks[cb] }.map { |cb| "#{cb}:" })
|
|
118
|
+
unsupported << "a preconfigure block" if block
|
|
119
|
+
return if unsupported.empty?
|
|
120
|
+
|
|
121
|
+
raise AnyCableUnsupported,
|
|
122
|
+
"join_room used #{unsupported.join(', ')} on an AnyCable-backed channel (#{self.class.name}). " \
|
|
123
|
+
"Under AnyCable room messages never pass through this process, so these can't work; " \
|
|
124
|
+
"use forward: true without callbacks (see README, \"Using AnyCable\")."
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Server-side entry point for the hello signal, for channels that learn the client is ready
|
|
128
|
+
# some other way than the `hello` action (a custom handshake message, for instance).
|
|
129
|
+
def hello_room_memberships
|
|
130
|
+
_room_memberships.each(&:hello!)
|
|
131
|
+
end
|
|
44
132
|
end
|
|
45
133
|
|
|
46
134
|
class RoomMembership
|
|
@@ -60,18 +148,28 @@ module CableRoom
|
|
|
60
148
|
on_left: nil,
|
|
61
149
|
tags: [],
|
|
62
150
|
extra: nil,
|
|
151
|
+
tenant: nil,
|
|
63
152
|
&preconfigure
|
|
64
153
|
)
|
|
65
154
|
@mutex = Monitor.new
|
|
66
155
|
|
|
67
156
|
@has_left = false
|
|
68
157
|
@has_established = false
|
|
158
|
+
@hello_received = false
|
|
69
159
|
|
|
70
160
|
@cable_channel = cable_channel
|
|
71
161
|
@room_class = room_class
|
|
72
162
|
@room_key = room_key
|
|
73
163
|
@allow_create = create
|
|
74
164
|
|
|
165
|
+
# Defaults to the ambient tenant right here, at join_room's own call site -- the member's
|
|
166
|
+
# own request thread, the last point before this room's work moves to a Host thread with no
|
|
167
|
+
# request of its own to derive one from (see Runner#initialize). An app only needs to pass
|
|
168
|
+
# `tenant:` explicitly to ask for something other than "whatever tenant this join is
|
|
169
|
+
# happening under", which is the common case. nil isn't a real tenant either way, so it
|
|
170
|
+
# doubles as "not given".
|
|
171
|
+
@tenant = tenant || (Apartment::Tenant.current if defined?(Apartment))
|
|
172
|
+
|
|
75
173
|
@on_room_opened = on_room_opened
|
|
76
174
|
@on_joined = on_joined
|
|
77
175
|
@on_message = on_message
|
|
@@ -99,17 +197,95 @@ module CableRoom
|
|
|
99
197
|
@mutex.synchronize { @has_left }
|
|
100
198
|
end
|
|
101
199
|
|
|
200
|
+
def hello_received?
|
|
201
|
+
@hello_received
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Whether room→member traffic reaches this object. Under ActionCable the streams opened in
|
|
205
|
+
# initiate_connection dispatch into handle_received_message. Under AnyCable anycable-go holds
|
|
206
|
+
# the streams and delivers them straight to the socket, so this process never sees a room
|
|
207
|
+
# message: acknowledgements, room_opened, and room_closed are invisible here, and none of the
|
|
208
|
+
# on_* callbacks can fire.
|
|
209
|
+
def hears_room?
|
|
210
|
+
!@cable_channel.anycable_channel?
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Whether the room has acknowledged this port, as far as this process can tell. Under
|
|
214
|
+
# ActionCable that is the acknowledgement itself. Under AnyCable the acknowledgement went to
|
|
215
|
+
# the socket, so the most this process knows is that hello went out; the browser, which does
|
|
216
|
+
# see port_acknowledged, owns the retry (README "Using AnyCable").
|
|
217
|
+
def presumed_established?
|
|
218
|
+
hears_room? ? @has_established : @hello_received
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Whether client input can be handed to the room. Under ActionCable input waits for the
|
|
222
|
+
# acknowledgement, because until then the room has no port to attribute it to. Under AnyCable
|
|
223
|
+
# it goes as soon as hello has: hello's port_connected and the input travel the same Bus
|
|
224
|
+
# channel from the same process, so the room sees them in that order, and a room that doesn't
|
|
225
|
+
# exist yet drops both the way ActionCable would have dropped the input.
|
|
226
|
+
def accepts_input?
|
|
227
|
+
!left? && presumed_established?
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# What MembershipStore::AnyCable writes into the channel state: enough to rebuild this
|
|
231
|
+
# membership on a later RPC call (see .restore). Callbacks are procs and can't go; `extra`
|
|
232
|
+
# travels in the ActiveJob-serialized form the room deserializes, which needs no lookup here.
|
|
233
|
+
def persisted_identity
|
|
234
|
+
{
|
|
235
|
+
"token" => @token,
|
|
236
|
+
"room_class" => room_class.name,
|
|
237
|
+
"room_key" => @room_key,
|
|
238
|
+
"tags" => @tags,
|
|
239
|
+
"extra" => serialized_extra,
|
|
240
|
+
"create" => @allow_create,
|
|
241
|
+
"hello_received" => @hello_received,
|
|
242
|
+
"tenant" => @tenant,
|
|
243
|
+
}
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# The inverse of #persisted_identity, for a channel instance anycable-rails built for one RPC
|
|
247
|
+
# call. The result can hello!, ping!, leave!, and forward input with the token the subscribe
|
|
248
|
+
# call created. It does not open streams (anycable-go still holds the ones subscribe opened)
|
|
249
|
+
# and does not announce itself (only hello! does); it carries no callbacks.
|
|
250
|
+
def self.restore(cable_channel, record)
|
|
251
|
+
membership = allocate
|
|
252
|
+
membership.send(:restore_from, cable_channel, record)
|
|
253
|
+
membership
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# The client has said hello: its subscription is confirmed, so every stream this membership
|
|
257
|
+
# opened is live and the room's acknowledgement has somewhere to land. This is the only thing
|
|
258
|
+
# that lets port_connected go out — a membership that never hears hello never announces.
|
|
259
|
+
#
|
|
260
|
+
# Hello is remembered for the life of the membership, across rejoin!: the browser says it
|
|
261
|
+
# once per subscription, and a rejoin happens server-side without the browser knowing.
|
|
262
|
+
def hello!
|
|
263
|
+
return if left?
|
|
264
|
+
|
|
265
|
+
ActiveSupport::Notifications.instrument(
|
|
266
|
+
"hello_received.cable_room",
|
|
267
|
+
{ membership: self, room_class: room_class, room_key: @room_key, channel: @cable_channel }
|
|
268
|
+
) do
|
|
269
|
+
@hello_received = true
|
|
270
|
+
# Under AnyCable the next call starts from a fresh channel; it has to know hello happened.
|
|
271
|
+
@cable_channel._room_memberships.persist!
|
|
272
|
+
# A repeated hello re-announces, which is harmless: port_connected is idempotent room-side.
|
|
273
|
+
transmit_port_connected
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
|
|
102
277
|
def ping!
|
|
103
278
|
return if left?
|
|
104
|
-
if
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
#
|
|
110
|
-
#
|
|
111
|
-
#
|
|
112
|
-
#
|
|
279
|
+
if presumed_established?
|
|
280
|
+
# The room already heard from us if we sent anything since the last interval; the next
|
|
281
|
+
# ping lands within 2 x PING_INTERVAL of that, well inside PORT_TIMEOUT.
|
|
282
|
+
port_transmit(room_class::ROOM_IN_CHANNEL, { type: 'port_ping' }, secure_context: true) unless recently_transmitted?
|
|
283
|
+
elsif @hello_received
|
|
284
|
+
# Announced but never acknowledged: the announcement or the acknowledgement was lost in
|
|
285
|
+
# transit, or the room did not exist yet. An unestablished membership silently drops
|
|
286
|
+
# everything the client sends (see RoomProxyChannel#receive), so re-announce instead of
|
|
287
|
+
# pinging: port_connected is idempotent on the room side (the port is merged, user_joined
|
|
288
|
+
# fires only once). Before hello there is nothing to heal — the client isn't ready.
|
|
113
289
|
transmit_port_connected
|
|
114
290
|
end
|
|
115
291
|
@mutex.synchronize do
|
|
@@ -140,37 +316,54 @@ module CableRoom
|
|
|
140
316
|
|
|
141
317
|
def key; @room_key; end
|
|
142
318
|
|
|
143
|
-
#
|
|
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
|
-
protected
|
|
319
|
+
# -- The member side of Ports ---------------------------------------------------------------
|
|
161
320
|
|
|
321
|
+
# Everything a member sends goes to its room, so it's published on the room's Bus channel for
|
|
322
|
+
# `port` (see Room::Base.inbound_channel). The room's Host is subscribed there and queues the
|
|
323
|
+
# message on the room. Public because `ports[:x] << msg` reaches it through a PortProxy.
|
|
162
324
|
def port_transmit(port, data, secure_context: false)
|
|
163
325
|
data[:mtok] = @token
|
|
164
326
|
|
|
327
|
+
type = (data[:type] || data['type'])&.to_sym
|
|
165
328
|
unless secure_context
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
logger.warn "Dropping attempt to send system message type: #{t.inspect}"
|
|
329
|
+
if room_class._system_message_types.include?(type)
|
|
330
|
+
logger.warn "Dropping attempt to send system message type: #{type.inspect}"
|
|
169
331
|
return
|
|
170
332
|
end
|
|
171
333
|
end
|
|
172
334
|
|
|
173
|
-
|
|
335
|
+
CableRoom.bus.publish(room_class.inbound_channel(@room_key, port), data)
|
|
336
|
+
# Pings don't count: a ping must never be the reason the next ping is skipped.
|
|
337
|
+
@last_transmit_at = monotonic_now unless type == :port_ping
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
# Room→member traffic arrives on ActionCable streams (the broadcaster publishes to them), so
|
|
341
|
+
# listening on a port is a plain stream_from on the member's channel.
|
|
342
|
+
#
|
|
343
|
+
# Subscribing is asynchronous: anything published to the port before the pubsub adapter
|
|
344
|
+
# confirms the subscription can be lost. ActionCable confirms the channel subscription to the
|
|
345
|
+
# client only after every stream is live, which is why members wait for the client's `hello`
|
|
346
|
+
# (see #hello!) rather than announcing themselves from here.
|
|
347
|
+
def stream_port(port, auto_close: true, &blk)
|
|
348
|
+
@cable_channel.stream_from(room_port_key(port), coder: ActiveSupport::JSON, &blk)
|
|
349
|
+
_streamed_ports << port if auto_close
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
def close_streamed_ports!
|
|
353
|
+
_streamed_ports.each do |port|
|
|
354
|
+
@cable_channel.stop_stream_from(room_port_key(port))
|
|
355
|
+
end
|
|
356
|
+
_streamed_ports.clear
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
protected
|
|
360
|
+
|
|
361
|
+
def recently_transmitted?
|
|
362
|
+
@last_transmit_at && (monotonic_now - @last_transmit_at) < RoomMember::PING_INTERVAL
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
def monotonic_now
|
|
366
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
174
367
|
end
|
|
175
368
|
|
|
176
369
|
def room_port_key(port)
|
|
@@ -187,9 +380,10 @@ module CableRoom
|
|
|
187
380
|
@has_established = true
|
|
188
381
|
@on_joined&.call(self) if first_acknowledgement
|
|
189
382
|
when 'room_opened'
|
|
190
|
-
#
|
|
191
|
-
#
|
|
192
|
-
|
|
383
|
+
# The room we were waiting for just came up. Announce again if the client is ready;
|
|
384
|
+
# before hello the announcement waits for hello itself, and a premature one would be
|
|
385
|
+
# acknowledged into a stream the client isn't listening on yet.
|
|
386
|
+
transmit_port_connected if @hello_received
|
|
193
387
|
@on_room_opened&.call(self)
|
|
194
388
|
when 'room_closed'
|
|
195
389
|
leave!
|
|
@@ -211,17 +405,11 @@ module CableRoom
|
|
|
211
405
|
@has_established = false
|
|
212
406
|
@cable_channel._room_memberships << self
|
|
213
407
|
|
|
214
|
-
#
|
|
215
|
-
#
|
|
216
|
-
#
|
|
217
|
-
#
|
|
218
|
-
#
|
|
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
|
|
408
|
+
# Subscribing is asynchronous, and the room acknowledges on the private @token stream. We
|
|
409
|
+
# never announce here: port_connected waits for the client's hello (see hello!), which
|
|
410
|
+
# only arrives after ActionCable has confirmed the subscription — that is, after every one
|
|
411
|
+
# of these streams is live. A rejoin! is the exception: hello already happened for this
|
|
412
|
+
# socket, so announce now and let ping! repeat it if the room misses it.
|
|
225
413
|
|
|
226
414
|
# Listen to public/broadcast channel
|
|
227
415
|
stream_port(room_class::ROOM_OUT_CHANNEL) do |message|
|
|
@@ -248,31 +436,27 @@ module CableRoom
|
|
|
248
436
|
|
|
249
437
|
@preconfigure&.call(self)
|
|
250
438
|
|
|
251
|
-
|
|
252
|
-
# that was all of them, otherwise the last confirmation will.
|
|
253
|
-
@streams_armed = true
|
|
254
|
-
_announce_if_streams_live
|
|
439
|
+
transmit_port_connected if @hello_received
|
|
255
440
|
|
|
256
441
|
maybe_provision_room
|
|
257
442
|
end
|
|
258
443
|
end
|
|
259
444
|
|
|
260
|
-
def
|
|
261
|
-
@mutex.
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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?
|
|
445
|
+
def restore_from(cable_channel, record)
|
|
446
|
+
@mutex = Monitor.new
|
|
447
|
+
@has_left = false
|
|
448
|
+
# Never learned here (see #hears_room?); the browser sees the acknowledgement instead.
|
|
449
|
+
@has_established = false
|
|
273
450
|
|
|
274
|
-
@
|
|
275
|
-
|
|
451
|
+
@cable_channel = cable_channel
|
|
452
|
+
@room_class = record.fetch("room_class").constantize
|
|
453
|
+
@room_key = record.fetch("room_key")
|
|
454
|
+
@token = record.fetch("token")
|
|
455
|
+
@tags = Array(record["tags"]).map(&:to_sym)
|
|
456
|
+
@serialized_extra = record["extra"]
|
|
457
|
+
@allow_create = record["create"] == true
|
|
458
|
+
@hello_received = record["hello_received"] == true
|
|
459
|
+
@tenant = record["tenant"]
|
|
276
460
|
end
|
|
277
461
|
|
|
278
462
|
def transmit_port_connected
|
|
@@ -280,16 +464,56 @@ module CableRoom
|
|
|
280
464
|
type: 'port_connected',
|
|
281
465
|
tags: @tags,
|
|
282
466
|
}
|
|
283
|
-
msg[:extra] =
|
|
467
|
+
msg[:extra] = serialized_extra if serialized_extra
|
|
284
468
|
port_transmit(room_class::ROOM_IN_CHANNEL, msg, secure_context: true)
|
|
285
469
|
end
|
|
286
470
|
|
|
471
|
+
# `extra` as the room receives it. Serialized once: a restored membership only ever has this
|
|
472
|
+
# form (see #persisted_identity), a fresh one builds it from what join_room was given.
|
|
473
|
+
def serialized_extra
|
|
474
|
+
return @serialized_extra if defined?(@serialized_extra)
|
|
475
|
+
|
|
476
|
+
@serialized_extra = @extra ? ::ActiveJob::Arguments.serialize([@extra]) : nil
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
# Ask the rooms hosts to start our room if `create: true` asked for it and nobody has
|
|
480
|
+
# acknowledged us yet. This never starts a room in the member's process: it publishes a
|
|
481
|
+
# `provision` request on the Bus, and every Host (the web process's own in :inline, the
|
|
482
|
+
# `cable_room server` fleet in :remote) hears it and races for the room's lock, the least
|
|
483
|
+
# loaded one first (see CableRoom::Placement). One code path, both modes.
|
|
484
|
+
#
|
|
485
|
+
# Called at join and again on every ping until the room acknowledges the port, so a lost
|
|
486
|
+
# request costs at most one ping interval, and a room whose host died comes back on the next
|
|
487
|
+
# ping from any member. Once acknowledged the room plainly exists, so we stop asking.
|
|
287
488
|
def maybe_provision_room
|
|
288
489
|
return if left?
|
|
289
490
|
return unless @allow_create
|
|
290
|
-
return if
|
|
491
|
+
return if @has_established
|
|
291
492
|
|
|
292
|
-
|
|
493
|
+
if CableRoom.config.inline?
|
|
494
|
+
# In :inline this process hosts rooms itself, so make sure its Host is up and listening
|
|
495
|
+
# before asking; otherwise the very first request in a fresh process would go unheard.
|
|
496
|
+
return if Host.instance.shutdown?
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
request = {
|
|
500
|
+
type: "provision",
|
|
501
|
+
room_class: room_class.name,
|
|
502
|
+
# Keys travel the way `extra` does, so a record key arrives on the host as the record
|
|
503
|
+
room_key: ::ActiveJob::Arguments.serialize([@room_key]),
|
|
504
|
+
requested_at: Time.current,
|
|
505
|
+
# Whatever `join_room` was given for `tenant:` (nil for a single-tenant app). The Host has
|
|
506
|
+
# no request of its own to derive this from, so a multi-tenant app has to hand it over
|
|
507
|
+
# explicitly here rather than have the Runner guess at ambient state (see Runner#initialize).
|
|
508
|
+
tenant: @tenant,
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
ActiveSupport::Notifications.instrument(
|
|
512
|
+
"provision_requested.cable_room",
|
|
513
|
+
{ membership: self, room_class: room_class, room_key: @room_key, channel: @cable_channel, request: request }
|
|
514
|
+
) do
|
|
515
|
+
CableRoom.bus.publish(Bus.provision_channel, request)
|
|
516
|
+
end
|
|
293
517
|
end
|
|
294
518
|
end
|
|
295
519
|
end
|
|
@@ -13,12 +13,17 @@ module CableRoom
|
|
|
13
13
|
@room_membership = subscribe_to_room
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
+
# The channel's actions are `hello` and `ping` (inherited from RoomMember: the client performs
|
|
17
|
+
# hello once its subscription is confirmed, and the membership announces itself to the room)
|
|
18
|
+
# and `receive`, which pipes everything else the client sends into the room. Until the room
|
|
19
|
+
# has acknowledged the membership (or, under AnyCable, until hello has gone out — see
|
|
20
|
+
# RoomMembership#accepts_input?) there is nowhere for input to go, so it is dropped.
|
|
16
21
|
def receive(data)
|
|
17
|
-
|
|
22
|
+
room_membership << data if room_membership&.accepts_input?
|
|
18
23
|
end
|
|
19
24
|
|
|
20
25
|
def unsubscribed
|
|
21
|
-
|
|
26
|
+
room_membership&.leave!
|
|
22
27
|
end
|
|
23
28
|
|
|
24
29
|
protected
|
|
@@ -27,6 +32,12 @@ module CableRoom
|
|
|
27
32
|
raise NotImplementedError
|
|
28
33
|
end
|
|
29
34
|
|
|
35
|
+
# The membership `subscribed` created. Under AnyCable this channel instance may not be the one
|
|
36
|
+
# that ran `subscribed`, so fall back to the memberships rebuilt from the channel state.
|
|
37
|
+
def room_membership
|
|
38
|
+
@room_membership ||= room_memberships.first
|
|
39
|
+
end
|
|
40
|
+
|
|
30
41
|
def join_room(*args, **kwargs, &blk)
|
|
31
42
|
kwargs[:forward] = true
|
|
32
43
|
super
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
module CableRoom
|
|
2
|
+
# The gem-owned picture of a running room, as one JSON document, so a room can be rebuilt on
|
|
3
|
+
# another host with its members none the wiser. `take` produces it from a frozen room (see
|
|
4
|
+
# Host::Runner#freeze!); `Host#restore_room` consumes it. Version 1 looks like this (string
|
|
5
|
+
# keys, because it has been through JSON):
|
|
6
|
+
#
|
|
7
|
+
# {
|
|
8
|
+
# "version" => 1,
|
|
9
|
+
# "room_class" => "QuizRoom",
|
|
10
|
+
# "key" => <room key>, # ActiveJob-serialized, so a record key travels as a GlobalID
|
|
11
|
+
# "port_clients" => [
|
|
12
|
+
# {
|
|
13
|
+
# "token" => "3f9a...",
|
|
14
|
+
# "tags" => ["admin"],
|
|
15
|
+
# "as" => <user or nil>, # ActiveJob-serialized (GlobalID for records)
|
|
16
|
+
# "last_seen_at" => "2026-08-27T18:02:11.123456Z",
|
|
17
|
+
# "metadata" => { ... } # everything else on the PortClient: what `extra:`
|
|
18
|
+
# } # merged in, plus anything the room set on it
|
|
19
|
+
# ],
|
|
20
|
+
# "user_state" => [{ "user" => <user>, "port_tokens" => ["3f9a..."] }],
|
|
21
|
+
# "reaper_state" => [{ "key" => "idle", "index" => 0, "last_keep_at" => "<ISO8601>" | nil }],
|
|
22
|
+
# "app_state" => <whatever the Room's snapshot_state returned, or nil>
|
|
23
|
+
# }
|
|
24
|
+
#
|
|
25
|
+
# `reaper_state` carries wall-clock times, not remaining durations, so a room restored on
|
|
26
|
+
# another host keeps the same deadline it had. `app_state` has to be JSON: plain hashes,
|
|
27
|
+
# arrays, strings, numbers, booleans, and nil. We check that here, at snapshot time, because a
|
|
28
|
+
# value that JSON would quietly turn into something else (a Time into a string, a record into
|
|
29
|
+
# its attributes) is a bug that would otherwise only show up in `restore_state` on some other
|
|
30
|
+
# machine.
|
|
31
|
+
module Snapshot
|
|
32
|
+
VERSION = 1
|
|
33
|
+
|
|
34
|
+
class Error < StandardError; end
|
|
35
|
+
|
|
36
|
+
# `snapshot_state` returned something JSON can't carry faithfully.
|
|
37
|
+
class NotSerializable < Error; end
|
|
38
|
+
|
|
39
|
+
# The snapshot was written by a gem version this one doesn't understand.
|
|
40
|
+
class UnknownVersion < Error; end
|
|
41
|
+
|
|
42
|
+
# The snapshot doesn't name a room class this process knows.
|
|
43
|
+
class UnknownRoomClass < Error; end
|
|
44
|
+
|
|
45
|
+
class << self
|
|
46
|
+
# The snapshot of `room`, already round-tripped through JSON, so what you get back is
|
|
47
|
+
# exactly what a restore will see after a trip through Redis.
|
|
48
|
+
def take(room)
|
|
49
|
+
room.send(:_snapshot)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Check a snapshot before anything is built from it. Returns the snapshot with string keys,
|
|
53
|
+
# so callers can read it the same way whether it came straight from `take` or from Redis.
|
|
54
|
+
def validate!(snapshot)
|
|
55
|
+
snapshot = snapshot.to_h.deep_stringify_keys
|
|
56
|
+
version = snapshot["version"]
|
|
57
|
+
unless version == VERSION
|
|
58
|
+
raise UnknownVersion, "Snapshot version #{version.inspect} isn't supported (this gem writes version #{VERSION})"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
snapshot
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def room_class_for(snapshot)
|
|
65
|
+
name = snapshot["room_class"]
|
|
66
|
+
klass = name.to_s.safe_constantize
|
|
67
|
+
unless klass.is_a?(Class) && klass <= Room::Base
|
|
68
|
+
raise UnknownRoomClass, "Snapshot names #{name.inspect}, which isn't a CableRoom::Room::Base subclass here"
|
|
69
|
+
end
|
|
70
|
+
klass
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def key_for(snapshot)
|
|
74
|
+
deserialize_argument(snapshot["key"])
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Encode and decode the whole document, so the result is the post-JSON shape (string keys,
|
|
78
|
+
# ISO8601 times) rather than the Ruby objects the room held.
|
|
79
|
+
def round_trip(document)
|
|
80
|
+
ActiveSupport::JSON.decode(ActiveSupport::JSON.encode(document))
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Walk `value` and raise NotSerializable at the first thing JSON can't carry unchanged. Hash
|
|
84
|
+
# keys may be symbols (they come back as strings, and restore_state gets an indifferent-access
|
|
85
|
+
# hash); symbol *values* are refused because they'd come back as plain strings.
|
|
86
|
+
def assert_json!(value, room:, path: "app_state")
|
|
87
|
+
case value
|
|
88
|
+
when nil, true, false, String, Integer
|
|
89
|
+
nil
|
|
90
|
+
when Float
|
|
91
|
+
raise_not_serializable(room, path, "#{value} isn't a finite number") unless value.finite?
|
|
92
|
+
when Hash
|
|
93
|
+
value.each do |k, v|
|
|
94
|
+
unless k.is_a?(String) || k.is_a?(Symbol)
|
|
95
|
+
raise_not_serializable(room, path, "has a #{k.class} key (#{k.inspect}); JSON object keys must be strings")
|
|
96
|
+
end
|
|
97
|
+
assert_json!(v, room: room, path: "#{path}[#{k.inspect}]")
|
|
98
|
+
end
|
|
99
|
+
when Array
|
|
100
|
+
value.each_with_index { |v, i| assert_json!(v, room: room, path: "#{path}[#{i}]") }
|
|
101
|
+
when Symbol
|
|
102
|
+
raise_not_serializable(room, path, "is the Symbol #{value.inspect}; JSON has no symbols, so restore_state would get a String back. Use a string")
|
|
103
|
+
else
|
|
104
|
+
raise_not_serializable(room, path, "is a #{value.class}, which JSON can't carry. Reduce it to hashes, arrays, strings, numbers, booleans, and nil (records by id)")
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Room keys and users go through the same serializer members use on the wire
|
|
109
|
+
# (RoomMembership#transmit_port_connected), so a record travels as its GlobalID and
|
|
110
|
+
# strings, numbers, and symbols come back as themselves.
|
|
111
|
+
def serialize_argument(value)
|
|
112
|
+
::ActiveJob::Arguments.serialize([value]).first
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def deserialize_argument(value)
|
|
116
|
+
::ActiveJob::Arguments.deserialize([value]).first
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def encode_time(time)
|
|
120
|
+
time&.getutc&.iso8601(6)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def decode_time(value)
|
|
124
|
+
return nil if value.blank?
|
|
125
|
+
value.is_a?(Time) ? value : Time.iso8601(value)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
private
|
|
129
|
+
|
|
130
|
+
def raise_not_serializable(room, path, detail)
|
|
131
|
+
raise NotSerializable,
|
|
132
|
+
"#{room.class.name}[#{room.key.inspect}]: snapshot_state returned something that isn't JSON: #{path} #{detail}"
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
data/lib/cable_room/version.rb
CHANGED