cable_room 0.7.0.beta3 → 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.
@@ -10,48 +10,57 @@ module CableRoom
10
10
  class << self
11
11
  # Start this room in the current process if nobody else is running it. Takes the room's
12
12
  # Redlock first, so exactly one process wins; returns false when the lock is held.
13
+ # `tenant` defaults to the caller's (see Host#ensure_room).
13
14
  #
14
15
  # Only a process that hosts rooms can do this: every process in :inline, and only
15
16
  # `cable_room server` in :remote. Anywhere else `Host.instance` raises Host::NotHosting
16
17
  # rather than letting a room start where it doesn't belong.
17
- def ensure(key = nil)
18
- Host.instance.ensure_room(self, key)
18
+ def ensure(key = nil, tenant: CableRoom.current_tenant)
19
+ Host.instance.ensure_room(self, key, tenant: tenant)
19
20
  end
20
21
 
21
22
  # The stream name for a room, or for one of its ports: "RoomClass:key" or
22
- # "RoomClass:key:port". Members and rooms both use this, so it has to be stable.
23
+ # "RoomClass:key:port". Members and rooms both use this, so it has to stay stable. These
24
+ # are the names rooms had as ActionCable channels (`broadcasting_for`), so a rolling deploy
25
+ # and the browser side see no change. It's also the room's Redlock key.
23
26
  def room_port_key(room_key, port = nil)
24
- parts = [name, room_key]
25
- parts << port if port
26
- parts.map { |part| serialize_key_part(part) }.join(":")
27
+ full_key = [room_key]
28
+ full_key << port if port
29
+ stream_namer.broadcasting_for(full_key)
27
30
  end
28
31
 
29
- # The Bus channel members publish on to reach a room's inbound port `port`, and the one
30
- # the room's Host subscribes to for it. `room_port_key(room_key)` ("RoomClass:key") is the
31
- # room's cluster-wide identity — it's already the Redlock key — so the main port lands on
32
- # the design's `cr:{room_key}:in` and a custom port on `cr:{room_key}:in:{port}`.
32
+ # The Bus channel members publish on to reach a room's inbound `port`, and the one the
33
+ # room's Host subscribes to for it: `cr:{room_port_key}:in` for the main port and
34
+ # `cr:{room_port_key}:in:{port}` for a custom one (see Bus.inbound_channel).
33
35
  def inbound_channel(room_key, port = ROOM_IN_CHANNEL)
34
- port = port.to_s == ROOM_IN_CHANNEL.to_s ? nil : serialize_key_part(port)
35
- Bus.inbound_channel(room_port_key(room_key), port)
36
+ port = port.to_s == ROOM_IN_CHANNEL.to_s ? nil : port.to_s
37
+ Bus.inbound_channel(self, room_key, port)
36
38
  end
37
39
 
38
- # Send a message to a room from anywhere in the app: same Bus channel a member uses
40
+ # Send a message to a room from anywhere in the app: the same Bus channel a member uses
39
41
  def send_message(room_key, data, port: ROOM_IN_CHANNEL)
40
42
  CableRoom.bus.publish(inbound_channel(room_key, port), data)
41
43
  end
42
44
 
43
45
  # Every instance of this room class running in this process. Empty when the process
44
- # hosts no rooms at all (a web process in :remote).
46
+ # hosts no rooms (a web process in :remote).
45
47
  def locally_running_instances
46
48
  Room.locally_open_rooms.select { |room| room.class == self }
47
49
  end
48
50
 
49
51
  private
50
52
 
51
- # Same rule ActionCable uses to name broadcasts: records go by their GlobalID, anything
52
- # else by `to_param`
53
- def serialize_key_part(part)
54
- part.respond_to?(:to_gid_param) ? part.to_gid_param : part.to_param
53
+ # Names this room's streams the way it did when it was an ActionCable channel: through
54
+ # that channel class's `broadcasting_for`, so an app's override of it still applies.
55
+ # PandaPal's puts the Apartment tenant first, which keeps two tenants' rooms with the
56
+ # same key (and their Redlocks) apart. Never instantiated; it only answers `channel_name`.
57
+ def stream_namer
58
+ @stream_namer ||= begin
59
+ room_class = self
60
+ Class.new(ActionCable::Channel::Base) do
61
+ define_singleton_method(:channel_name) { room_class.name }
62
+ end
63
+ end
55
64
  end
56
65
  end
57
66
 
@@ -62,7 +71,6 @@ module CableRoom
62
71
  include HostAdapter
63
72
 
64
73
  include Lifecycle
65
- include Snapshotting
66
74
  include Reaping
67
75
  include InputHandling
68
76
 
@@ -73,7 +81,7 @@ module CableRoom
73
81
 
74
82
  attr_reader :key
75
83
 
76
- delegate :logger, :tenant, to: :@runner
84
+ delegate :logger, to: :@runner
77
85
 
78
86
  def initialize(runner, key = nil)
79
87
  super()
@@ -85,11 +93,11 @@ module CableRoom
85
93
  port_transmit(ROOM_OUT_CHANNEL, data)
86
94
  end
87
95
 
88
- # The room's sending side of Ports: room→member messages go out on the stream named by
89
- # `room_port_key(port)` through the configured CableRoom::Broadcaster. (The listening side
90
- # is in Room::HostAdapter.) Public because `ports[:x] << msg` reaches it through a PortProxy.
96
+ # The room's sending side of Ports: room→member messages go out as an ActionCable
97
+ # broadcast on the stream named by `room_port_key(port)`. (The listening side is in
98
+ # Room::HostAdapter.) Public because `ports[:x] << msg` reaches it through a PortProxy.
91
99
  def port_transmit(port, data)
92
- broadcaster.broadcast(room_port_key(port), data)
100
+ ActionCable.server.broadcast(room_port_key(port), data)
93
101
  end
94
102
 
95
103
  protected
@@ -101,10 +109,6 @@ module CableRoom
101
109
  def room_port_key(sub_channel = nil)
102
110
  self.class.room_port_key(key, sub_channel)
103
111
  end
104
-
105
- def broadcaster
106
- Broadcaster.current
107
- end
108
112
  end
109
113
  end
110
114
  end
@@ -7,7 +7,6 @@ module CableRoom
7
7
  included do
8
8
  define_callbacks :startup
9
9
  define_callbacks :shutdown
10
- define_callbacks :work
11
10
  end
12
11
 
13
12
  class_methods do
@@ -28,27 +27,6 @@ module CableRoom
28
27
  set_callback(:shutdown, :after, *methods, &block)
29
28
  end
30
29
  alias_method :on_shutdown, :after_shutdown
31
-
32
- # Wraps every piece of Room code that runs on a Host thread: startup/restore, message and
33
- # timer handlers, snapshot_state, and shutdown alike (see Host::Runner#with_room_context,
34
- # the one thing that ever triggers the :work callback chain). This is the seam for
35
- # anything that has to be true before a Room's own code runs on a given thread -- Apartment
36
- # switching first among them (see README's Multi-tenancy section).
37
- #
38
- # Define it once on your own shared base Room class (or reopen CableRoom::Room::Base
39
- # itself) to cover every Room; define it again on a specific Room subclass for one that
40
- # needs something different -- ordinary callback inheritance, nothing cable_room-specific.
41
- def before_work(*methods, &block)
42
- set_callback(:work, :before, *methods, &block)
43
- end
44
-
45
- def after_work(*methods, &block)
46
- set_callback(:work, :after, *methods, &block)
47
- end
48
-
49
- def around_work(*methods, &block)
50
- set_callback(:work, :around, *methods, &block)
51
- end
52
30
  end
53
31
  end
54
32
  end
@@ -25,7 +25,7 @@ module CableRoom
25
25
 
26
26
  # The room's listening side of Ports: subscribe, through the Host, to the Bus channel
27
27
  # members publish on for `port` (see Room::Base.inbound_channel). `on_live` runs once the
28
- # subscription is confirmed, the same as it does for members.
28
+ # subscription is confirmed.
29
29
  def stream_port(port, auto_close: true, on_live: nil, &blk)
30
30
  @runner.subscribe(inbound_channel(port), on_live: on_live, &blk)
31
31
  _streamed_ports << port if auto_close
@@ -5,10 +5,8 @@ module CableRoom
5
5
 
6
6
  included do
7
7
  after_startup do
8
- logger.info restored? ? "Restored" : "Started"
9
- # A restored room's members were attached the whole time; telling them the room opened
10
- # would make every one of them re-announce for nothing.
11
- self << { type: 'room_opened' } unless restored?
8
+ logger.info "Started"
9
+ self << { type: 'room_opened' }
12
10
  end
13
11
 
14
12
  before_shutdown do
@@ -27,8 +25,7 @@ module CableRoom
27
25
  @runner.state
28
26
  end
29
27
 
30
- # Requests that the Room shut down gracefully, processing any pending messages. A frozen
31
- # room (mid-migration) shuts down the same way; that's the "no peer took it" path.
28
+ # Requests that the Room shut down gracefully, processing any pending messages
32
29
  def shutdown!(reason = "Room requested shutdown")
33
30
  @shutdown_reason = reason
34
31
  logger.info "Shutdown requested: #{reason}"
@@ -42,24 +39,10 @@ module CableRoom
42
39
 
43
40
  private
44
41
 
45
- # What `room_closed` will say. The runner sets it when the host, not the room, decides to
46
- # close (a server shutting down, a migration nobody adopted).
47
- def _shutdown_reason=(reason)
48
- @shutdown_reason = reason
49
- end
50
-
51
- # The startup chain. A restore (see Snapshotting#_restore) runs the same callbacks — they
52
- # are what subscribe the inbound port and start the reapers — but swaps `startup` for
53
- # `restore_state(app_state)` when the room defines it.
54
- def _startup(restoring: false, app_state: nil)
55
- event = restoring ? "room_restored.cable_room" : "room_opened.cable_room"
56
- ActiveSupport::Notifications.instrument(event, { room: self }) do
42
+ def _startup
43
+ ActiveSupport::Notifications.instrument("room_opened.cable_room", { room: self }) do
57
44
  run_callbacks :startup do
58
- if restoring && respond_to?(:restore_state, true)
59
- restore_state(app_state)
60
- else
61
- startup if respond_to?(:startup)
62
- end
45
+ startup if respond_to?(:startup)
63
46
  end
64
47
  end
65
48
  end
@@ -158,29 +158,6 @@ module CableRoom
158
158
  end
159
159
  end
160
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
-
184
161
  def check_port_inactivity
185
162
  return unless @_port_clients
186
163
 
@@ -238,33 +215,6 @@ module CableRoom
238
215
  def tag!(*tags)
239
216
  self[:tags].merge(tags.flatten.map(&:to_sym))
240
217
  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
268
218
  end
269
219
  end
270
220
  end
@@ -74,39 +74,6 @@ 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
-
110
77
  private
111
78
 
112
79
  def _reaper_states
@@ -98,33 +98,6 @@ 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
-
128
101
  def _apply_port_scope(user: nil, tag: nil, **kwargs)
129
102
  if user && tag
130
103
  user_tags = all_user_tags(user) || []
@@ -10,7 +10,6 @@ module CableRoom
10
10
  autoload :HostAdapter
11
11
 
12
12
  autoload :Lifecycle
13
- autoload :Snapshotting
14
13
  autoload :Reaping
15
14
  autoload :InputHandling
16
15
 
@@ -22,7 +21,7 @@ module CableRoom
22
21
  end
23
22
 
24
23
  # 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.
24
+ # no rooms (a web process in :remote) gets an empty list.
26
25
  def self.locally_open_rooms
27
26
  Host.current&.rooms || []
28
27
  end