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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +122 -0
  3. data/README.md +662 -48
  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 +577 -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 +482 -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 +260 -0
  19. data/lib/cable_room/ports.rb +20 -50
  20. data/lib/cable_room/railtie.rb +3 -12
  21. data/lib/cable_room/room/base.rb +45 -39
  22. data/lib/cable_room/room/host_adapter.rb +52 -0
  23. data/lib/cable_room/room/lifecycle.rb +26 -9
  24. data/lib/cable_room/room/port_management.rb +70 -2
  25. data/lib/cable_room/room/reaping.rb +34 -1
  26. data/lib/cable_room/room/snapshotting.rb +82 -0
  27. data/lib/cable_room/room/threading.rb +2 -2
  28. data/lib/cable_room/room/user_management.rb +27 -0
  29. data/lib/cable_room/room.rb +5 -2
  30. data/lib/cable_room/room_harness.rb +168 -0
  31. data/lib/cable_room/room_member.rb +293 -69
  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 +26 -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
@@ -3,7 +3,12 @@ module CableRoom
3
3
  module PortManagement
4
4
  extend ActiveSupport::Concern
5
5
 
6
- PORT_TIMEOUT = 30.seconds
6
+ # A port that the room has not heard from for this long is dropped. Any inbound message
7
+ # counts as hearing from it (see the receive_message callback below), and members only
8
+ # ping when they have been otherwise silent for RoomMember::PING_INTERVAL, so the longest
9
+ # gap a live member produces is 2 x PING_INTERVAL. Keep this comfortably above that: under
10
+ # load the room processes its queue late, and a reaped port is a lost student.
11
+ PORT_TIMEOUT = 45.seconds
7
12
 
8
13
  class_methods do
9
14
  def on_port_connected(...)
@@ -29,7 +34,13 @@ module CableRoom
29
34
  on_port_connected { reply({type: 'port_acknowledged' }) }
30
35
 
31
36
  set_callback(:receive_message, :around) do |_, blk|
32
- with_message_origin(message['mtok']) { blk.call }
37
+ with_message_origin(message['mtok']) do
38
+ # Every message from a known port is proof of life, not just port_ping — so a member
39
+ # that is actively sending never needs to ping (RoomMembership#ping!). Unknown origins
40
+ # (a port_connected creating its port) are touched when the port is created.
41
+ message_origin&.touch_activity!
42
+ blk.call
43
+ end
33
44
  end
34
45
 
35
46
  system_message_types(:port_connected, :port_disconnected, :port_ping)
@@ -57,6 +68,13 @@ module CableRoom
57
68
  { client_port: client_port || tag, **kwargs }
58
69
  end
59
70
 
71
+ # How many member ports are attached right now. Public, unlike `connected_clients`, because
72
+ # the Host reads it from outside the room's thread to total up `Host#open_ports`; a plain
73
+ # size is safe to read that way and a moment's staleness doesn't matter for a gauge.
74
+ def open_port_count
75
+ @_port_clients.size
76
+ end
77
+
60
78
  def reply(message = nil, **kwargs)
61
79
  raise ArgumentError, "Can only use reply when handling a message" unless message_origin
62
80
  raise ArgumentError, "Must provide message or block" unless message || block_given?
@@ -140,6 +158,29 @@ module CableRoom
140
158
  end
141
159
  end
142
160
 
161
+ # -- Snapshot and restore (see CableRoom::Snapshot) ----------------------------------------
162
+
163
+ def _snapshot_port_clients
164
+ @_port_clients.values.map(&:to_snapshot)
165
+ end
166
+
167
+ # Re-create every port exactly as it was, without running port_connected callbacks: the
168
+ # members are still there and were already acknowledged. A port whose user can't be found
169
+ # any more (its record was deleted) is dropped rather than failing the whole room; that
170
+ # member will be re-acknowledged when it next pings.
171
+ def _restore_port_clients(entries)
172
+ Array(entries).each do |entry|
173
+ client = PortClient.new(self, entry["token"])
174
+ begin
175
+ client.restore!(entry)
176
+ rescue ::ActiveJob::DeserializationError => e
177
+ logger.warn "Dropping port #{entry['token']} from the snapshot: #{e.message}"
178
+ next
179
+ end
180
+ @_port_clients[client.token] = client
181
+ end
182
+ end
183
+
143
184
  def check_port_inactivity
144
185
  return unless @_port_clients
145
186
 
@@ -197,6 +238,33 @@ module CableRoom
197
238
  def tag!(*tags)
198
239
  self[:tags].merge(tags.flatten.map(&:to_sym))
199
240
  end
241
+
242
+ SNAPSHOT_OWN_KEYS = %w[tags as last_seen_at].freeze
243
+
244
+ # This port as one `port_clients` entry of a CableRoom::Snapshot. `as` goes through the
245
+ # same serializer the member used to send it (a GlobalID for a record). Everything else on
246
+ # the port — what the member's `extra:` merged in, plus anything the room stored with
247
+ # `message_origin[:x] = ...` — travels under `metadata`, the same way, so records survive.
248
+ def to_snapshot
249
+ metadata = @metadata.to_h.except(*SNAPSHOT_OWN_KEYS)
250
+ {
251
+ token: token,
252
+ tags: self[:tags].map(&:to_s),
253
+ as: Snapshot.serialize_argument(self[:as]),
254
+ last_seen_at: Snapshot.encode_time(self[:last_seen_at]),
255
+ metadata: Snapshot.serialize_argument(metadata),
256
+ }
257
+ end
258
+
259
+ # The inverse of `to_snapshot`. `last_seen_at` is kept, not reset: the port is exactly as
260
+ # old as it was, and its member's next ping refreshes it as usual.
261
+ def restore!(entry)
262
+ merge!(Snapshot.deserialize_argument(entry["metadata"]))
263
+ tag!(entry["tags"])
264
+ self[:as] = Snapshot.deserialize_argument(entry["as"])
265
+ self[:last_seen_at] = Snapshot.decode_time(entry["last_seen_at"]) || self[:last_seen_at]
266
+ self
267
+ end
200
268
  end
201
269
  end
202
270
  end
@@ -59,7 +59,7 @@ module CableRoom
59
59
  protected
60
60
 
61
61
  def ping_watchdog
62
- @cable_channel.ping_watchdog
62
+ @runner.ping_watchdog
63
63
  end
64
64
 
65
65
  def check_reapers_now!
@@ -74,6 +74,39 @@ module CableRoom
74
74
  ping_watchdog
75
75
  end
76
76
 
77
+ # -- Snapshot and restore (see CableRoom::Snapshot) ----------------------------------------
78
+
79
+ # One entry per `reap_when`, in declaration order. `last_keep_at` is the wall-clock moment
80
+ # the reaper's grace period started counting from (nil until it has run once), so the
81
+ # deadline — `last_keep_at + grace` — is the same on whichever host restores it.
82
+ def _snapshot_reaper_state
83
+ self.class.reaper_checkers.each_with_index.map do |cfg, index|
84
+ state = _reaper_states[cfg] || {}
85
+ { key: cfg[:key]&.to_s, index: index, last_keep_at: Snapshot.encode_time(state[:last_keep_at]) }
86
+ end
87
+ end
88
+
89
+ # Runs before the startup callbacks, which add the timer to each state and leave the rest
90
+ # alone. A named reaper matches by name and an anonymous one by position, so a Room whose
91
+ # reapers changed between the two hosts' code versions keeps what still lines up and drops
92
+ # the rest.
93
+ def _restore_reaper_state(entries)
94
+ checkers = self.class.reaper_checkers
95
+ Array(entries).each do |entry|
96
+ cfg =
97
+ if entry["key"].present?
98
+ checkers.find { |c| c[:key].to_s == entry["key"] }
99
+ else
100
+ candidate = checkers[entry["index"].to_i]
101
+ candidate if candidate && candidate[:key].nil?
102
+ end
103
+ next unless cfg
104
+
105
+ last_keep_at = Snapshot.decode_time(entry["last_keep_at"])
106
+ (_reaper_states[cfg] ||= {})[:last_keep_at] = last_keep_at if last_keep_at
107
+ end
108
+ end
109
+
77
110
  private
78
111
 
79
112
  def _reaper_states
@@ -0,0 +1,82 @@
1
+ module CableRoom
2
+ module Room
3
+ # The room's side of a migration: turning itself into a CableRoom::Snapshot and coming back
4
+ # from one. The gem carries what it owns (ports, tags, users, reaper deadlines); a Room can
5
+ # carry its own state too with two optional hooks:
6
+ #
7
+ # class QuizRoom < CableRoom::Room::Base
8
+ # def snapshot_state
9
+ # { question_id: @question.id, answers: @answers } # JSON only; records by id
10
+ # end
11
+ #
12
+ # def restore_state(state)
13
+ # @question = Question.find(state[:question_id]) # indifferent access: :key or "key"
14
+ # @answers = state[:answers]
15
+ # end
16
+ # end
17
+ #
18
+ # A room that defines `restore_state` gets it instead of `startup` when it's restored, with
19
+ # whatever `snapshot_state` returned (nil if the room that was snapshotted had no
20
+ # `snapshot_state`). A room without `restore_state` just runs `startup` again and rebuilds its
21
+ # state the way it did the first time. Either way the `before_startup`/`after_startup`
22
+ # callbacks run as usual, except that the gem does not broadcast `room_opened`: the members
23
+ # were there the whole time and never saw the room go away. `restored?` tells a callback which
24
+ # case it's in.
25
+ module Snapshotting
26
+ extend ActiveSupport::Concern
27
+
28
+ # True for a room that was rebuilt from a snapshot rather than started fresh.
29
+ def restored?
30
+ @_restored == true
31
+ end
32
+
33
+ private
34
+
35
+ # Build the snapshot (see CableRoom::Snapshot for the shape). Only meaningful once the room
36
+ # is frozen: nothing else may be touching its state while this reads it.
37
+ def _snapshot
38
+ document = {
39
+ version: Snapshot::VERSION,
40
+ room_class: self.class.name,
41
+ key: Snapshot.serialize_argument(key),
42
+ # Carried forward so the host that adopts this room (Host#restore_room) gets the same
43
+ # tenant this one was given at provisioning, instead of falling back to a guess (see
44
+ # Runner#initialize). nil for a room that was never given one either.
45
+ tenant: @runner.tenant,
46
+ port_clients: _snapshot_port_clients,
47
+ user_state: _snapshot_user_state,
48
+ reaper_state: _snapshot_reaper_state,
49
+ app_state: _snapshot_app_state,
50
+ }
51
+
52
+ ActiveSupport::Notifications.instrument("room_snapshotted.cable_room", { room: self }) do
53
+ Snapshot.round_trip(document)
54
+ end
55
+ end
56
+
57
+ def _snapshot_app_state
58
+ return nil unless respond_to?(:snapshot_state, true)
59
+
60
+ state = snapshot_state
61
+ Snapshot.assert_json!(state, room: self)
62
+ state
63
+ end
64
+
65
+ # Rebuild from `snapshot`: gem state first, quietly (no port_connected or user_joined
66
+ # callbacks, since none of it is new), then the startup chain with `restore_state` in place
67
+ # of `startup`. Runs where `_startup` would, before the runner starts the periodic timers.
68
+ def _restore(snapshot)
69
+ snapshot = Snapshot.validate!(snapshot)
70
+ @_restored = true
71
+
72
+ _restore_port_clients(snapshot["port_clients"])
73
+ _restore_user_state(snapshot["user_state"])
74
+ _restore_reaper_state(snapshot["reaper_state"])
75
+
76
+ app_state = snapshot["app_state"]
77
+ app_state = app_state.with_indifferent_access if app_state.is_a?(Hash)
78
+ _startup(restoring: true, app_state: app_state)
79
+ end
80
+ end
81
+ end
82
+ end
@@ -36,13 +36,13 @@ module CableRoom
36
36
  # results back with `on_room_thread` over waiting for them.
37
37
  def async(&blk)
38
38
  room = self
39
- @cable_channel.post_work(async: true) { room.instance_exec(&blk) }
39
+ @runner.post_work(async: true) { room.instance_exec(&blk) }
40
40
  end
41
41
 
42
42
  # Queue the block back onto the Room's own thread, where touching Room state is safe again.
43
43
  def on_room_thread(&blk)
44
44
  room = self
45
- @cable_channel.post_work(async: false, silent: true) { room.instance_exec(&blk) }
45
+ @runner.post_work(async: false, silent: true) { room.instance_exec(&blk) }
46
46
  end
47
47
  end
48
48
  end
@@ -98,6 +98,33 @@ module CableRoom
98
98
  tags
99
99
  end
100
100
 
101
+ # -- Snapshot and restore (see CableRoom::Snapshot) ----------------------------------------
102
+
103
+ # The user map is carried on its own rather than rebuilt from the ports: a port whose join
104
+ # the tag policy refused has no user entry, and a rebuild would invent one.
105
+ def _snapshot_user_state
106
+ @_user_map_mutex.synchronize do
107
+ @_user_state_map.map do |user, usm|
108
+ { user: Snapshot.serialize_argument(user), port_tokens: usm[:port_tokens].to_a }
109
+ end
110
+ end
111
+ end
112
+
113
+ # No user_joined callbacks here: these users joined long ago, on the previous host.
114
+ def _restore_user_state(entries)
115
+ @_user_map_mutex.synchronize do
116
+ Array(entries).each do |entry|
117
+ begin
118
+ user = Snapshot.deserialize_argument(entry["user"])
119
+ rescue ::ActiveJob::DeserializationError => e
120
+ logger.warn "Dropping a user from the snapshot: #{e.message}"
121
+ next
122
+ end
123
+ @_user_state_map[user] = { port_tokens: Set.new(entry["port_tokens"]) }
124
+ end
125
+ end
126
+ end
127
+
101
128
  def _apply_port_scope(user: nil, tag: nil, **kwargs)
102
129
  if user && tag
103
130
  user_tags = all_user_tags(user) || []
@@ -7,9 +7,10 @@ module CableRoom
7
7
 
8
8
  autoload :Callbacks
9
9
  autoload :Threading
10
- autoload :ChannelAdapter
10
+ autoload :HostAdapter
11
11
 
12
12
  autoload :Lifecycle
13
+ autoload :Snapshotting
13
14
  autoload :Reaping
14
15
  autoload :InputHandling
15
16
 
@@ -20,8 +21,10 @@ module CableRoom
20
21
  autoload :Broadcasting
21
22
  end
22
23
 
24
+ # Every room running in this process. Asking never creates a Host, so a process that hosts
25
+ # no rooms (a web process in :remote) just gets an empty list.
23
26
  def self.locally_open_rooms
24
- ChannelTracker.instance.room_channels.map(&:room)
27
+ Host.current&.rooms || []
25
28
  end
26
29
  end
27
30
  end
@@ -0,0 +1,168 @@
1
+ require "cable_room"
2
+ require "active_support/tagged_logging"
3
+
4
+ module CableRoom
5
+ # Builds a Room without a Host, a live pubsub subscription, or a Redis lock, so the parts of a
6
+ # Room that are pure logic (authorization, port scoping, callbacks, reaping) can be tested
7
+ # directly and synchronously. Anything that needs real message delivery belongs in an e2e spec.
8
+ #
9
+ # Not loaded by `require "cable_room"` -- an app (or this gem's own suite) opts in with
10
+ # `require "cable_room/room_harness"`, typically from spec_helper.
11
+ #
12
+ # Stands in for CableRoom::Host::Runner.
13
+ class StubRoomRunner
14
+ attr_reader :logger, :state, :shutdown_reason, :watchdog_pings, :tenant
15
+
16
+ def initialize(tenant: nil)
17
+ @logger = ActiveSupport::TaggedLogging.new(Logger.new(IO::NULL))
18
+ @state = :started
19
+ @watchdog_pings = 0
20
+ @timers = []
21
+ @tenant = tenant
22
+ end
23
+
24
+ def ping_watchdog
25
+ @watchdog_pings += 1
26
+ end
27
+
28
+ # Record streams rather than subscribing, so startup can run without a live pubsub. There is
29
+ # nothing to wait for, so `on_live` runs at once.
30
+ def subscribe(stream, on_live: nil, &blk)
31
+ streams[stream] = blk
32
+ on_live&.call
33
+ end
34
+
35
+ def unsubscribe(stream)
36
+ streams.delete(stream)
37
+ end
38
+
39
+ def streams
40
+ @streams ||= {}
41
+ end
42
+
43
+ # Feed a message to whatever the room streamed from this port, the way pubsub would
44
+ def deliver_to_stream(stream, message)
45
+ streams.fetch(stream).call(message)
46
+ end
47
+
48
+ def initiate_shutdown(reason)
49
+ @shutdown_reason = reason
50
+ @state = :shutting_down
51
+ end
52
+
53
+ def stop!
54
+ @state = :dead
55
+ end
56
+
57
+ # Run inline so specs stay synchronous
58
+ def post_work(**_kwargs, &blk)
59
+ blk.call
60
+ end
61
+
62
+ # Record timers rather than scheduling them, so specs can fire them on demand
63
+ def start_periodic_timer(callback, every:)
64
+ timer = StubTimer.new(callback, every)
65
+ @timers << timer
66
+ timer
67
+ end
68
+
69
+ def timers
70
+ @timers.reject(&:shutdown?)
71
+ end
72
+
73
+ class StubTimer
74
+ attr_reader :interval
75
+
76
+ def initialize(callback, interval)
77
+ @callback = callback
78
+ @interval = interval
79
+ @shutdown = false
80
+ end
81
+
82
+ def fire!
83
+ @callback.call
84
+ end
85
+
86
+ def shutdown
87
+ @shutdown = true
88
+ end
89
+
90
+ def shutdown?
91
+ @shutdown
92
+ end
93
+ end
94
+ end
95
+
96
+ module RoomHarness
97
+ # A fully-initialized Room backed by a StubRoomRunner. Pass `started: true` to run the
98
+ # startup callbacks (which is what registers reaper timers and the inbound stream). Pass
99
+ # `tenant:` the way a member's provision request would (see RoomMembership#initialize) for a
100
+ # Room whose logic needs Apartment scoped correctly -- this switches into it for the duration
101
+ # of startup, the way PandaPal's ActionCable::Server::Worker hook does in production for a
102
+ # real Runner, so a Room can't tell the difference from here.
103
+ def build_room(room_class, key: "harness-key", started: false, tenant: nil)
104
+ runner = StubRoomRunner.new(tenant: tenant)
105
+ room = room_class.allocate
106
+ room.send(:initialize, runner, key)
107
+ runner.define_singleton_method(:room) { room }
108
+ with_room_tenant(runner) { room.send(:_startup) } if started
109
+ room
110
+ end
111
+
112
+ # A Room rebuilt from `snapshot` on a fresh StubRoomRunner, the way Host#restore_room would
113
+ # build it: gem state restored, then the startup chain with `restore_state` in place of
114
+ # `startup`. Pass the class explicitly so a spec reads as "restore a FooRoom", even though the
115
+ # snapshot names it too. `tenant` comes off the snapshot itself, same as Host#restore_room.
116
+ def restore_room(room_class, snapshot)
117
+ runner = StubRoomRunner.new(tenant: snapshot["tenant"])
118
+ room = room_class.allocate
119
+ room.send(:initialize, runner, Snapshot.key_for(snapshot))
120
+ runner.define_singleton_method(:room) { room }
121
+ with_room_tenant(runner) { room.send(:_restore, snapshot) }
122
+ room
123
+ end
124
+
125
+ def stub_runner_for(room)
126
+ room.instance_variable_get(:@runner)
127
+ end
128
+
129
+ # Push a message through the same callback chain the real stream handler uses.
130
+ # Returns false when authorization halted the chain, otherwise :handled.
131
+ def deliver(room, message)
132
+ message = message.deep_stringify_keys if message.is_a?(Hash)
133
+ Room.current_message = message
134
+ with_room_tenant(stub_runner_for(room)) do
135
+ room.send(:run_callbacks, :receive_message) do
136
+ room.send(:handle_received_message, message)
137
+ :handled
138
+ end
139
+ end
140
+ ensure
141
+ Room.current_message = nil
142
+ end
143
+
144
+ # Register a port on the room the way a real RoomMembership would, returning its token.
145
+ def connect_port(room, token: SecureRandom.hex(16), tags: [], as: nil)
146
+ message = { type: 'port_connected', mtok: token, tags: tags }
147
+ message[:extra] = ::ActiveJob::Arguments.serialize([{ as: as }]) if as
148
+ deliver(room, message)
149
+ token
150
+ end
151
+
152
+ def port_client(room, token)
153
+ room.send(:resolve_client_port, token)
154
+ end
155
+
156
+ private
157
+
158
+ # Stands in for PandaPal's ActionCable::Server::Worker hook, which is what actually switches
159
+ # Apartment before a Host thread's DB calls in production. A Room without a `tenant` (a
160
+ # single-tenant app, or a spec that doesn't need one) gets no-op behavior; the gem itself
161
+ # stays Apartment-optional either way.
162
+ def with_room_tenant(runner, &blk)
163
+ return blk.call unless runner.tenant && defined?(Apartment)
164
+
165
+ Apartment::Tenant.switch(runner.tenant, &blk)
166
+ end
167
+ end
168
+ end