cable_room 0.6.1 → 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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +122 -0
  3. data/README.md +597 -49
  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/input_handling.rb +1 -1
  24. data/lib/cable_room/room/lifecycle.rb +26 -9
  25. data/lib/cable_room/room/port_management.rb +70 -2
  26. data/lib/cable_room/room/reaping.rb +34 -1
  27. data/lib/cable_room/room/snapshotting.rb +78 -0
  28. data/lib/cable_room/room/threading.rb +2 -2
  29. data/lib/cable_room/room/user_management.rb +27 -0
  30. data/lib/cable_room/room.rb +5 -2
  31. data/lib/cable_room/room_member.rb +282 -21
  32. data/lib/cable_room/room_proxy_channel.rb +13 -2
  33. data/lib/cable_room/snapshot.rb +136 -0
  34. data/lib/cable_room/version.rb +1 -1
  35. data/lib/cable_room.rb +57 -2
  36. metadata +25 -9
  37. data/lib/cable_room/channel_base.rb +0 -247
  38. data/lib/cable_room/channel_tracker.rb +0 -130
  39. 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: 10.seconds
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 ||= Set.new
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
@@ -66,6 +154,7 @@ module CableRoom
66
154
 
67
155
  @has_left = false
68
156
  @has_established = false
157
+ @hello_received = false
69
158
 
70
159
  @cable_channel = cable_channel
71
160
  @room_class = room_class
@@ -99,17 +188,94 @@ module CableRoom
99
188
  @mutex.synchronize { @has_left }
100
189
  end
101
190
 
191
+ def hello_received?
192
+ @hello_received
193
+ end
194
+
195
+ # Whether room→member traffic reaches this object. Under ActionCable the streams opened in
196
+ # initiate_connection dispatch into handle_received_message. Under AnyCable anycable-go holds
197
+ # the streams and delivers them straight to the socket, so this process never sees a room
198
+ # message: acknowledgements, room_opened, and room_closed are invisible here, and none of the
199
+ # on_* callbacks can fire.
200
+ def hears_room?
201
+ !@cable_channel.anycable_channel?
202
+ end
203
+
204
+ # Whether the room has acknowledged this port, as far as this process can tell. Under
205
+ # ActionCable that is the acknowledgement itself. Under AnyCable the acknowledgement went to
206
+ # the socket, so the most this process knows is that hello went out; the browser, which does
207
+ # see port_acknowledged, owns the retry (README "Using AnyCable").
208
+ def presumed_established?
209
+ hears_room? ? @has_established : @hello_received
210
+ end
211
+
212
+ # Whether client input can be handed to the room. Under ActionCable input waits for the
213
+ # acknowledgement, because until then the room has no port to attribute it to. Under AnyCable
214
+ # it goes as soon as hello has: hello's port_connected and the input travel the same Bus
215
+ # channel from the same process, so the room sees them in that order, and a room that doesn't
216
+ # exist yet drops both the way ActionCable would have dropped the input.
217
+ def accepts_input?
218
+ !left? && presumed_established?
219
+ end
220
+
221
+ # What MembershipStore::AnyCable writes into the channel state: enough to rebuild this
222
+ # membership on a later RPC call (see .restore). Callbacks are procs and can't go; `extra`
223
+ # travels in the ActiveJob-serialized form the room deserializes, which needs no lookup here.
224
+ def persisted_identity
225
+ {
226
+ "token" => @token,
227
+ "room_class" => room_class.name,
228
+ "room_key" => @room_key,
229
+ "tags" => @tags,
230
+ "extra" => serialized_extra,
231
+ "create" => @allow_create,
232
+ "hello_received" => @hello_received,
233
+ }
234
+ end
235
+
236
+ # The inverse of #persisted_identity, for a channel instance anycable-rails built for one RPC
237
+ # call. The result can hello!, ping!, leave!, and forward input with the token the subscribe
238
+ # call created. It does not open streams (anycable-go still holds the ones subscribe opened)
239
+ # and does not announce itself (only hello! does); it carries no callbacks.
240
+ def self.restore(cable_channel, record)
241
+ membership = allocate
242
+ membership.send(:restore_from, cable_channel, record)
243
+ membership
244
+ end
245
+
246
+ # The client has said hello: its subscription is confirmed, so every stream this membership
247
+ # opened is live and the room's acknowledgement has somewhere to land. This is the only thing
248
+ # that lets port_connected go out — a membership that never hears hello never announces.
249
+ #
250
+ # Hello is remembered for the life of the membership, across rejoin!: the browser says it
251
+ # once per subscription, and a rejoin happens server-side without the browser knowing.
252
+ def hello!
253
+ return if left?
254
+
255
+ ActiveSupport::Notifications.instrument(
256
+ "hello_received.cable_room",
257
+ { membership: self, room_class: room_class, room_key: @room_key, channel: @cable_channel }
258
+ ) do
259
+ @hello_received = true
260
+ # Under AnyCable the next call starts from a fresh channel; it has to know hello happened.
261
+ @cable_channel._room_memberships.persist!
262
+ # A repeated hello re-announces, which is harmless: port_connected is idempotent room-side.
263
+ transmit_port_connected
264
+ end
265
+ end
266
+
102
267
  def ping!
103
268
  return if left?
104
- if @has_established
105
- port_transmit(room_class::ROOM_IN_CHANNEL, { type: 'port_ping' }, secure_context: true)
106
- else
107
- # The port_connected sent by initiate_connection can be lost: stream subscriptions are
108
- # asynchronous, so a room on another server can acknowledge before our private stream is
109
- # live — and an unestablished membership silently drops everything the client sends
110
- # (see #<<). Until acknowledged, keep re-announcing instead of pinging: port_connected is
111
- # idempotent on the room side (the port is merged, user_joined fires only once), and the
112
- # re-acknowledgement lands once our subscription is up.
269
+ if presumed_established?
270
+ # The room already heard from us if we sent anything since the last interval; the next
271
+ # ping lands within 2 x PING_INTERVAL of that, well inside PORT_TIMEOUT.
272
+ port_transmit(room_class::ROOM_IN_CHANNEL, { type: 'port_ping' }, secure_context: true) unless recently_transmitted?
273
+ elsif @hello_received
274
+ # Announced but never acknowledged: the announcement or the acknowledgement was lost in
275
+ # transit, or the room did not exist yet. An unestablished membership silently drops
276
+ # everything the client sends (see RoomProxyChannel#receive), so re-announce instead of
277
+ # pinging: port_connected is idempotent on the room side (the port is merged, user_joined
278
+ # fires only once). Before hello there is nothing to heal — the client isn't ready.
113
279
  transmit_port_connected
114
280
  end
115
281
  @mutex.synchronize do
@@ -140,20 +306,54 @@ module CableRoom
140
306
 
141
307
  def key; @room_key; end
142
308
 
143
- protected
309
+ # -- The member side of Ports ---------------------------------------------------------------
144
310
 
311
+ # Everything a member sends goes to its room, so it's published on the room's Bus channel for
312
+ # `port` (see Room::Base.inbound_channel). The room's Host is subscribed there and queues the
313
+ # message on the room. Public because `ports[:x] << msg` reaches it through a PortProxy.
145
314
  def port_transmit(port, data, secure_context: false)
146
315
  data[:mtok] = @token
147
316
 
317
+ type = (data[:type] || data['type'])&.to_sym
148
318
  unless secure_context
149
- t = (data[:type] || data['type']).to_sym
150
- if room_class._system_message_types.include?(t)
151
- logger.warn "Dropping attempt to send system message type: #{t.inspect}"
319
+ if room_class._system_message_types.include?(type)
320
+ logger.warn "Dropping attempt to send system message type: #{type.inspect}"
152
321
  return
153
322
  end
154
323
  end
155
324
 
156
- super(port, data)
325
+ CableRoom.bus.publish(room_class.inbound_channel(@room_key, port), data)
326
+ # Pings don't count: a ping must never be the reason the next ping is skipped.
327
+ @last_transmit_at = monotonic_now unless type == :port_ping
328
+ end
329
+
330
+ # Room→member traffic arrives on ActionCable streams (the broadcaster publishes to them), so
331
+ # listening on a port is a plain stream_from on the member's channel.
332
+ #
333
+ # Subscribing is asynchronous: anything published to the port before the pubsub adapter
334
+ # confirms the subscription can be lost. ActionCable confirms the channel subscription to the
335
+ # client only after every stream is live, which is why members wait for the client's `hello`
336
+ # (see #hello!) rather than announcing themselves from here.
337
+ def stream_port(port, auto_close: true, &blk)
338
+ @cable_channel.stream_from(room_port_key(port), coder: ActiveSupport::JSON, &blk)
339
+ _streamed_ports << port if auto_close
340
+ end
341
+
342
+ def close_streamed_ports!
343
+ _streamed_ports.each do |port|
344
+ @cable_channel.stop_stream_from(room_port_key(port))
345
+ end
346
+ _streamed_ports.clear
347
+ end
348
+
349
+ protected
350
+
351
+ def recently_transmitted?
352
+ @last_transmit_at && (monotonic_now - @last_transmit_at) < RoomMember::PING_INTERVAL
353
+ end
354
+
355
+ def monotonic_now
356
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
157
357
  end
158
358
 
159
359
  def room_port_key(port)
@@ -170,7 +370,10 @@ module CableRoom
170
370
  @has_established = true
171
371
  @on_joined&.call(self) if first_acknowledgement
172
372
  when 'room_opened'
173
- transmit_port_connected
373
+ # The room we were waiting for just came up. Announce again if the client is ready;
374
+ # before hello the announcement waits for hello itself, and a premature one would be
375
+ # acknowledged into a stream the client isn't listening on yet.
376
+ transmit_port_connected if @hello_received
174
377
  @on_room_opened&.call(self)
175
378
  when 'room_closed'
176
379
  leave!
@@ -192,6 +395,12 @@ module CableRoom
192
395
  @has_established = false
193
396
  @cable_channel._room_memberships << self
194
397
 
398
+ # Subscribing is asynchronous, and the room acknowledges on the private @token stream. We
399
+ # never announce here: port_connected waits for the client's hello (see hello!), which
400
+ # only arrives after ActionCable has confirmed the subscription — that is, after every one
401
+ # of these streams is live. A rejoin! is the exception: hello already happened for this
402
+ # socket, so announce now and let ping! repeat it if the room misses it.
403
+
195
404
  # Listen to public/broadcast channel
196
405
  stream_port(room_class::ROOM_OUT_CHANNEL) do |message|
197
406
  handle_received_message(message)
@@ -217,27 +426,79 @@ module CableRoom
217
426
 
218
427
  @preconfigure&.call(self)
219
428
 
220
- transmit_port_connected
429
+ transmit_port_connected if @hello_received
221
430
 
222
431
  maybe_provision_room
223
432
  end
224
433
  end
225
434
 
435
+ def restore_from(cable_channel, record)
436
+ @mutex = Monitor.new
437
+ @has_left = false
438
+ # Never learned here (see #hears_room?); the browser sees the acknowledgement instead.
439
+ @has_established = false
440
+
441
+ @cable_channel = cable_channel
442
+ @room_class = record.fetch("room_class").constantize
443
+ @room_key = record.fetch("room_key")
444
+ @token = record.fetch("token")
445
+ @tags = Array(record["tags"]).map(&:to_sym)
446
+ @serialized_extra = record["extra"]
447
+ @allow_create = record["create"] == true
448
+ @hello_received = record["hello_received"] == true
449
+ end
450
+
226
451
  def transmit_port_connected
227
452
  msg = {
228
453
  type: 'port_connected',
229
454
  tags: @tags,
230
455
  }
231
- msg[:extra] = ::ActiveJob::Arguments.serialize([@extra]) if @extra
456
+ msg[:extra] = serialized_extra if serialized_extra
232
457
  port_transmit(room_class::ROOM_IN_CHANNEL, msg, secure_context: true)
233
458
  end
234
459
 
460
+ # `extra` as the room receives it. Serialized once: a restored membership only ever has this
461
+ # form (see #persisted_identity), a fresh one builds it from what join_room was given.
462
+ def serialized_extra
463
+ return @serialized_extra if defined?(@serialized_extra)
464
+
465
+ @serialized_extra = @extra ? ::ActiveJob::Arguments.serialize([@extra]) : nil
466
+ end
467
+
468
+ # Ask the rooms hosts to start our room if `create: true` asked for it and nobody has
469
+ # acknowledged us yet. This never starts a room in the member's process: it publishes a
470
+ # `provision` request on the Bus, and every Host (the web process's own in :inline, the
471
+ # `cable_room server` fleet in :remote) hears it and races for the room's lock, the least
472
+ # loaded one first (see CableRoom::Placement). One code path, both modes.
473
+ #
474
+ # Called at join and again on every ping until the room acknowledges the port, so a lost
475
+ # request costs at most one ping interval, and a room whose host died comes back on the next
476
+ # ping from any member. Once acknowledged the room plainly exists, so we stop asking.
235
477
  def maybe_provision_room
236
478
  return if left?
237
479
  return unless @allow_create
238
- return if ChannelTracker.instance.shutdown?
480
+ return if @has_established
481
+
482
+ if CableRoom.config.inline?
483
+ # In :inline this process hosts rooms itself, so make sure its Host is up and listening
484
+ # before asking; otherwise the very first request in a fresh process would go unheard.
485
+ return if Host.instance.shutdown?
486
+ end
487
+
488
+ request = {
489
+ type: "provision",
490
+ room_class: room_class.name,
491
+ # Keys travel the way `extra` does, so a record key arrives on the host as the record
492
+ room_key: ::ActiveJob::Arguments.serialize([@room_key]),
493
+ requested_at: Time.current,
494
+ }
239
495
 
240
- @room_class.ensure(@room_key)
496
+ ActiveSupport::Notifications.instrument(
497
+ "provision_requested.cable_room",
498
+ { membership: self, room_class: room_class, room_key: @room_key, channel: @cable_channel, request: request }
499
+ ) do
500
+ CableRoom.bus.publish(Bus.provision_channel, request)
501
+ end
241
502
  end
242
503
  end
243
504
  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
- @room_membership << data if @room_membership&.connected?
22
+ room_membership << data if room_membership&.accepts_input?
18
23
  end
19
24
 
20
25
  def unsubscribed
21
- @room_membership&.leave!
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
@@ -1,3 +1,3 @@
1
1
  module CableRoom
2
- VERSION = "0.6.1".freeze
2
+ VERSION = "0.7.0.beta1".freeze
3
3
  end
data/lib/cable_room.rb CHANGED
@@ -8,10 +8,18 @@ require 'redlock'
8
8
  require 'rufus-scheduler'
9
9
 
10
10
  require_relative 'cable_room/railtie'
11
+ require_relative 'cable_room/config'
12
+ require_relative 'cable_room/broadcaster'
11
13
 
12
- require_relative 'cable_room/channel_base'
13
- require_relative 'cable_room/channel_tracker'
14
+ require_relative 'cable_room/bus'
15
+
16
+ require_relative 'cable_room/periodic_timer'
17
+ require_relative 'cable_room/snapshot'
18
+ require_relative 'cable_room/host'
19
+ require_relative 'cable_room/placement'
20
+ require_relative 'cable_room/migration'
14
21
  require_relative 'cable_room/ports'
22
+ require_relative 'cable_room/membership_store'
15
23
  require_relative 'cable_room/room_member'
16
24
  require_relative 'cable_room/room_proxy_channel'
17
25
  require_relative 'cable_room/room/'
@@ -36,6 +44,27 @@ module CableRoom
36
44
  warn "CableRoom.error_handler raised #{handler_error.class}: #{handler_error.message}"
37
45
  end
38
46
 
47
+ # The active CableRoom::Config. Built on first use with the defaults plus any env
48
+ # overrides, so an app that never calls `configure` still gets a valid config.
49
+ def config
50
+ @config ||= Config.new.apply_env_overrides.validate!
51
+ end
52
+
53
+ # Yields the config so an app can set the knobs, then re-applies the env overrides
54
+ # (CABLE_ROOM_HOST, CABLE_ROOM_BROADCASTER) and checks every value. Bad values raise
55
+ # here, at boot, instead of somewhere deep in a room later.
56
+ def configure
57
+ cfg = @config || Config.new
58
+ yield cfg if block_given?
59
+ @config = cfg.apply_env_overrides.validate!
60
+ end
61
+
62
+ # Drops the current config so the next `config` call rebuilds it, which also makes
63
+ # `Broadcaster.current` rebuild (and forget any `Broadcaster.current=` override). Meant for specs.
64
+ def reset_config!
65
+ @config = nil
66
+ end
67
+
39
68
  def redis_pool
40
69
  require 'rediconn'
41
70
  @redis_pool ||= RediConn::RedisConnection.create(env_prefix: "CABLEROOM")
@@ -50,5 +79,31 @@ module CableRoom
50
79
  CableRoom.redis,
51
80
  ])
52
81
  end
82
+
83
+ # The process-wide Redis bus (see CableRoom::Bus). Built on first use.
84
+ def bus
85
+ @bus ||= Bus.new
86
+ end
87
+
88
+ # Call this in a child process right after `fork`, before it touches Redis or hosts a room.
89
+ # `cable_room server --workers N` does it for every worker (see Host::Supervisor).
90
+ #
91
+ # A forked child starts with copies of the parent's objects but only one thread, so anything
92
+ # that owns a socket or a thread is broken in the child: the Bus subscriber thread doesn't
93
+ # exist, its mutex may be held by nobody, and a Redis socket is shared with the parent, so
94
+ # both processes would read each other's replies. Dropping the memos means the next caller
95
+ # builds fresh ones. The connection pool and redis-client also notice the fork on their own
96
+ # (both hook `Process._fork` and drop inherited sockets), and Rails does the same for
97
+ # ActiveRecord, so those need nothing from us; this keeps the gem's rule simple: nothing
98
+ # Redis-backed survives a fork.
99
+ #
100
+ # The Host goes too. A parent that forks workers must never host rooms itself, and a Host
101
+ # copied from a parent would have no worker threads and no scheduler thread behind it.
102
+ def after_fork!
103
+ @bus = nil
104
+ @lock_manager = nil
105
+ @redis_pool = nil
106
+ Host.replace_current(nil)
107
+ end
53
108
  end
54
109
  end