cable_room 0.7.0.beta3 → 0.8.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.
@@ -10,48 +10,61 @@ 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
- #
14
- # Only a process that hosts rooms can do this: every process in :inline, and only
15
- # `cable_room server` in :remote. Anywhere else `Host.instance` raises Host::NotHosting
16
- # rather than letting a room start where it doesn't belong.
17
13
  def ensure(key = nil)
18
- Host.instance.ensure_room(self, key)
14
+ lock_key = room_port_key(key)
15
+ # One attempt: a held lock means the room is already running, and retrying (Redlock's
16
+ # default is 3 retries, 200-250 ms apart) would park the caller's ActionCable worker for
17
+ # ~0.7 s on every `create: true` member's ping.
18
+ lock_info = CableRoom.lock_manager.lock(lock_key, self::LOCK_DURATION.in_milliseconds, retry_count: 0)
19
+ return false unless lock_info
20
+
21
+ Host.instance.start_room(
22
+ self,
23
+ key,
24
+ lock_info,
25
+ watchdog_interval: self::WATCH_DOG_INTERVAL,
26
+ lock_duration: self::LOCK_DURATION,
27
+ )
28
+
29
+ true
30
+ rescue => e
31
+ CableRoom.lock_manager.unlock(lock_info) if lock_info
32
+ raise e
19
33
  end
20
34
 
21
35
  # 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.
36
+ # "RoomClass:key:port". Members and rooms both use this, so it has to stay stable. These
37
+ # are the names rooms had as ActionCable channels (`broadcasting_for`), so a rolling deploy
38
+ # and the browser side see no change. It's also the room's Redlock key.
23
39
  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(":")
40
+ full_key = [room_key]
41
+ full_key << port if port
42
+ stream_namer.broadcasting_for(full_key)
27
43
  end
28
44
 
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}`.
33
- 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
- end
37
-
38
- # Send a message to a room from anywhere in the app: same Bus channel a member uses
39
45
  def send_message(room_key, data, port: ROOM_IN_CHANNEL)
40
- CableRoom.bus.publish(inbound_channel(room_key, port), data)
46
+ ActionCable.server.broadcast(room_port_key(room_key, port), data)
41
47
  end
42
48
 
43
- # 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).
45
49
  def locally_running_instances
46
- Room.locally_open_rooms.select { |room| room.class == self }
50
+ Host.instance.runners.select do |runner|
51
+ runner.room_class == self
52
+ end.map(&:room)
47
53
  end
48
54
 
49
55
  private
50
56
 
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
57
+ # Names this room's streams the way it did when it was an ActionCable channel: through
58
+ # that channel class's `broadcasting_for`, so an app's override of it still applies.
59
+ # PandaPal's puts the Apartment tenant first, which keeps two tenants' rooms with the
60
+ # same key (and their Redlocks) apart. Never instantiated; it only answers `channel_name`.
61
+ def stream_namer
62
+ @stream_namer ||= begin
63
+ room_class = self
64
+ Class.new(ActionCable::Channel::Base) do
65
+ define_singleton_method(:channel_name) { room_class.name }
66
+ end
67
+ end
55
68
  end
56
69
  end
57
70
 
@@ -62,7 +75,6 @@ module CableRoom
62
75
  include HostAdapter
63
76
 
64
77
  include Lifecycle
65
- include Snapshotting
66
78
  include Reaping
67
79
  include InputHandling
68
80
 
@@ -73,7 +85,7 @@ module CableRoom
73
85
 
74
86
  attr_reader :key
75
87
 
76
- delegate :logger, :tenant, to: :@runner
88
+ delegate :logger, to: :@runner
77
89
 
78
90
  def initialize(runner, key = nil)
79
91
  super()
@@ -85,13 +97,6 @@ module CableRoom
85
97
  port_transmit(ROOM_OUT_CHANNEL, data)
86
98
  end
87
99
 
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.
91
- def port_transmit(port, data)
92
- broadcaster.broadcast(room_port_key(port), data)
93
- end
94
-
95
100
  protected
96
101
 
97
102
  def room_class
@@ -101,10 +106,6 @@ module CableRoom
101
106
  def room_port_key(sub_channel = nil)
102
107
  self.class.room_port_key(key, sub_channel)
103
108
  end
104
-
105
- def broadcaster
106
- Broadcaster.current
107
- end
108
109
  end
109
110
  end
110
111
  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
@@ -23,27 +23,22 @@ module CableRoom
23
23
  end
24
24
  end
25
25
 
26
- # The room's listening side of Ports: subscribe, through the Host, to the Bus channel
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.
26
+ # The room side of Ports#stream_port: listen on this room's inbound stream for `port`.
27
+ # `on_live` runs once the subscription is confirmed.
29
28
  def stream_port(port, auto_close: true, on_live: nil, &blk)
30
- @runner.subscribe(inbound_channel(port), on_live: on_live, &blk)
29
+ @runner.subscribe(room_port_key(port), coder: ActiveSupport::JSON, on_live: on_live, &blk)
31
30
  _streamed_ports << port if auto_close
32
31
  end
33
32
 
34
33
  def close_streamed_ports!
35
34
  _streamed_ports.each do |port|
36
- @runner.unsubscribe(inbound_channel(port))
35
+ @runner.unsubscribe(room_port_key(port))
37
36
  end
38
37
  _streamed_ports.clear
39
38
  end
40
39
 
41
40
  protected
42
41
 
43
- def inbound_channel(port)
44
- self.class.inbound_channel(key, port)
45
- end
46
-
47
42
  def start_periodic_timer(callback, every:)
48
43
  @runner.start_periodic_timer(-> { instance_exec(&callback) }, every: every)
49
44
  end
@@ -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
 
@@ -21,10 +20,8 @@ module CableRoom
21
20
  autoload :Broadcasting
22
21
  end
23
22
 
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.
26
23
  def self.locally_open_rooms
27
- Host.current&.rooms || []
24
+ Host.instance.rooms
28
25
  end
29
26
  end
30
27
  end