cable_room 0.6.2 → 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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +122 -0
  3. data/README.md +1190 -0
  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/lifecycle.rb +26 -9
  24. data/lib/cable_room/room/port_management.rb +57 -0
  25. data/lib/cable_room/room/reaping.rb +34 -1
  26. data/lib/cable_room/room/snapshotting.rb +78 -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_member.rb +260 -84
  31. data/lib/cable_room/room_proxy_channel.rb +13 -2
  32. data/lib/cable_room/snapshot.rb +136 -0
  33. data/lib/cable_room/version.rb +1 -1
  34. data/lib/cable_room.rb +57 -2
  35. metadata +25 -9
  36. data/lib/cable_room/channel_base.rb +0 -262
  37. data/lib/cable_room/channel_tracker.rb +0 -130
  38. data/lib/cable_room/room/channel_adapter.rb +0 -18
@@ -0,0 +1,258 @@
1
+ require 'concurrent'
2
+ require 'active_support/inflector'
3
+
4
+ module CableRoom
5
+ # The host side of provisioning. A `create: true` membership publishes a provision request on
6
+ # `Bus.provision_channel`; every Host runs one Placement, which hears every request and decides
7
+ # whether this host should be the one to start the room.
8
+ #
9
+ # The decision is a race weighted by load. Each host waits `provision_delay_ms` for every room
10
+ # it already runs (plus up to one room's worth of jitter, to break ties), then tries to take the
11
+ # room's Redlock through `Host#ensure_room`. The lightest host wakes first and usually wins;
12
+ # everyone else finds the lock held and does nothing. A host that is draining or shut down never
13
+ # claims. The same code runs in :inline and :remote — the only difference is which process the
14
+ # Host lives in.
15
+ #
16
+ # Requests arrive on the Bus subscriber thread, which must stay quick, so `handle` only checks
17
+ # and schedules; the wait and the claim run later on the Host's worker pool via a timer. That way
18
+ # a long wait never blocks other Bus traffic, and a slow room startup never blocks another claim.
19
+ #
20
+ # Specs make the race deterministic by passing `jitter: -> { 0 }` (or any callable returning
21
+ # 0..1) and a small `provision_delay_ms`; `delay_for(load)` is the only place the numbers meet.
22
+ class Placement
23
+ # Raised (and reported, never propagated) for a request this host can't act on: not a hash, an
24
+ # unknown room class, a class that isn't a Room, or a key that won't deserialize.
25
+ class InvalidRequest < ArgumentError; end
26
+
27
+ REQUEST_TYPE = "provision".freeze
28
+
29
+ attr_reader :host, :channel
30
+
31
+ # `jitter` returns a Float in 0..1 (defaults to `rand`); `executor` is where the claim runs
32
+ # (defaults to the Host's worker pool); `config` defaults to `CableRoom.config`.
33
+ def initialize(host, jitter: nil, executor: nil, config: nil)
34
+ @host = host
35
+ @jitter = jitter || -> { Kernel.rand }
36
+ @executor = executor
37
+ @config = config
38
+ @channel = Bus.provision_channel
39
+
40
+ @mutex = Mutex.new
41
+ @idle = ConditionVariable.new
42
+ @pending = {} # room identity => the scheduled claim, so one host never queues two claims for one room
43
+ @running = false # subscribed to the channel
44
+ @stopped = false # `stop` was called; no claim may go through until `start` again
45
+ @handle = nil
46
+ end
47
+
48
+ # Subscribe to the provision channel on the Host's inbound transport. Blocks until the Bus
49
+ # confirms, so a request published after this returns is heard. Calling it again while running
50
+ # re-subscribes (harmless; the Bus replaces the handler), which lets a Host re-arm its listener.
51
+ def start
52
+ @mutex.synchronize do
53
+ @running = true
54
+ @stopped = false
55
+ end
56
+ @handle = host.inbound.subscribe(channel) { |request| handle(request) }
57
+ self
58
+ end
59
+
60
+ # Stop hearing requests and drop every claim still waiting on its timer. Waits (up to `wait`
61
+ # seconds) for a claim that is already mid-attempt to finish, so nothing starts a room after
62
+ # this returns. The unsubscribe blocks until Redis confirms it, which doubles as a barrier: a
63
+ # request published before the stop has either been handled or is gone for good.
64
+ def stop(wait: 5)
65
+ was_running, handle = @mutex.synchronize do
66
+ @stopped = true
67
+ [@running, @handle].tap do
68
+ @running = false
69
+ @handle = nil
70
+ end
71
+ end
72
+
73
+ host.inbound.unsubscribe(channel, handle) if was_running
74
+
75
+ # A cancelled timer never runs its block (and so never runs the `finish` in it), so forget
76
+ # it here. A claim that already started keeps its entry until it finishes; wait for those.
77
+ tasks = @mutex.synchronize { @pending.to_a }
78
+ tasks.each { |room_id, task| finish(room_id) if task.cancel }
79
+ wait_idle(timeout: wait)
80
+ self
81
+ end
82
+
83
+ def running?
84
+ @mutex.synchronize { @running }
85
+ end
86
+
87
+ def stopped?
88
+ @mutex.synchronize { @stopped }
89
+ end
90
+
91
+ # How many claims are waiting on their timer or mid-attempt right now.
92
+ def pending_count
93
+ @mutex.synchronize { @pending.size }
94
+ end
95
+
96
+ def idle?
97
+ pending_count.zero?
98
+ end
99
+
100
+ # Block until every scheduled claim has finished (won, lost, or been cancelled). Returns true,
101
+ # or false if `timeout` passes first. Specs use this to observe a losing host give up.
102
+ def wait_idle(timeout: 5)
103
+ deadline = monotonic_now + timeout
104
+ @mutex.synchronize do
105
+ until @pending.empty?
106
+ remaining = deadline - monotonic_now
107
+ return false if remaining <= 0
108
+ @idle.wait(@mutex, remaining)
109
+ end
110
+ end
111
+ true
112
+ end
113
+
114
+ # One request off the Bus. Runs on the subscriber thread, so it does only the cheap checks
115
+ # (is this a room we know, do we already run it, are we allowed to claim) and then schedules
116
+ # the delayed claim. Returns the scheduled task, or nil when there is nothing to do.
117
+ def handle(request)
118
+ room_class, room_key = parse(request)
119
+ room_id = room_class.room_port_key(room_key)
120
+
121
+ return skip(room_id, "placement is stopped") if stopped?
122
+ return skip(room_id, "host is shutting down") if host.shutdown?
123
+ return skip(room_id, "host is draining") if host.draining?
124
+ return skip(room_id, "already running here") if running_here?(room_id)
125
+
126
+ load = host.open_rooms_count
127
+ delay = delay_for(load)
128
+
129
+ # A handoff request gets a claim of its own even while a member's plain request for the
130
+ # same room is pending: the plain claim may have lost the lock to the old host moments
131
+ # before it let go, and the handoff must not be skipped for it. The lock keeps them safe.
132
+ pending_key = request["handoff"] == true ? "#{room_id} (handoff)" : room_id
133
+
134
+ @mutex.synchronize do
135
+ return skip(room_id, "a claim is already pending here") if @pending.key?(pending_key)
136
+
137
+ task = Concurrent::ScheduledTask.new(delay, executor: executor) do
138
+ begin
139
+ claim(room_class, room_key, room_id, request, delay: delay, load: load)
140
+ ensure
141
+ finish(pending_key)
142
+ end
143
+ end
144
+ @pending[pending_key] = task
145
+ task.execute
146
+ end
147
+ rescue InvalidRequest => e
148
+ CableRoom.report_error(e, placement: self, request: request)
149
+ nil
150
+ end
151
+
152
+ # Seconds to wait before racing for a room, given how many rooms this host already runs:
153
+ # `provision_delay_ms × (load + jitter)`, jitter in 0..1. A host with more rooms always waits
154
+ # longer than one with fewer, and the jitter only decides the order between hosts with the same
155
+ # load. A host running nothing waits at most one `provision_delay_ms`.
156
+ def delay_for(load)
157
+ base = config.provision_delay_ms / 1000.0
158
+ base * (load + @jitter.call.to_f)
159
+ end
160
+
161
+ private
162
+
163
+ # The claim itself, after the delay, on the worker pool. Re-checks the cheap conditions first,
164
+ # since the world may have moved while we waited, then races for the Redlock. Losing is
165
+ # normal — it means a lighter host got there first — so it's only a debug line.
166
+ def claim(room_class, room_key, room_id, request, delay:, load:)
167
+ return if stopped?
168
+ return if host.shutdown? || host.draining?
169
+ return if running_here?(room_id)
170
+
171
+ payload = {
172
+ host: host,
173
+ room_class: room_class,
174
+ room_key: room_key,
175
+ delay: delay,
176
+ open_rooms: load,
177
+ request: request,
178
+ }
179
+
180
+ claimed = ActiveSupport::Notifications.instrument("provision_claimed.cable_room", payload) do |notification|
181
+ # `handoff: true` (with a `reason`) is a draining host offering a room to its peers. The
182
+ # race is the same — the lock decides — but the winner rebuilds the room from the offered
183
+ # snapshot and replays what the old host relayed instead of starting it from scratch (see
184
+ # CableRoom::Migration.adopt). A plain request starts the room fresh.
185
+ handoff = request["handoff"] == true
186
+ notification[:handoff] = handoff
187
+ notification[:claimed] = host.ensure_room(room_class, room_key, handoff_only: handoff)
188
+ end
189
+
190
+ if claimed
191
+ logger.info "Claimed #{room_id} after #{(delay * 1000).round}ms with #{load} room(s) open"
192
+ else
193
+ logger.debug "Lost the race for #{room_id}: another host holds its lock"
194
+ end
195
+ rescue => e
196
+ logger.error "Failed to claim #{room_id}: #{e.class}: #{e.message}"
197
+ CableRoom.report_error(e, placement: self, room_class: room_class, room_key: room_key, request: request)
198
+ end
199
+
200
+ def finish(room_id)
201
+ @mutex.synchronize do
202
+ @pending.delete(room_id)
203
+ @idle.broadcast if @pending.empty?
204
+ end
205
+ end
206
+
207
+ def skip(room_id, why)
208
+ logger.debug "Ignoring provision request for #{room_id}: #{why}"
209
+ nil
210
+ end
211
+
212
+ def running_here?(room_id)
213
+ host.runners.any? { |runner| runner.room_class.room_port_key(runner.key) == room_id }
214
+ end
215
+
216
+ # Turn a decoded Bus message into a Room class and a key. Only `Room::Base` descendants may be
217
+ # provisioned: the class name comes off the wire, and `constantize` on an arbitrary string is
218
+ # not something a request should be able to do.
219
+ def parse(request)
220
+ raise InvalidRequest, "provision request must be a Hash (got #{request.class})" unless request.is_a?(Hash)
221
+
222
+ type = request["type"]
223
+ raise InvalidRequest, "expected a #{REQUEST_TYPE.inspect} request (got #{type.inspect})" unless type == REQUEST_TYPE
224
+
225
+ name = request["room_class"]
226
+ klass = name.is_a?(String) ? ActiveSupport::Inflector.safe_constantize(name) : nil
227
+ unless klass.is_a?(Class) && klass < Room::Base
228
+ raise InvalidRequest, "#{name.inspect} is not a CableRoom::Room::Base subclass"
229
+ end
230
+
231
+ [klass, deserialize_key(request["room_key"])]
232
+ end
233
+
234
+ # Keys travel the way `extra` does (ActiveJob's argument serializer), so a record key comes
235
+ # back as the record and a string comes back as a string.
236
+ def deserialize_key(serialized)
237
+ ::ActiveJob::Arguments.deserialize(Array(serialized)).first
238
+ rescue StandardError => e
239
+ raise InvalidRequest, "room_key can't be deserialized: #{e.class}: #{e.message}"
240
+ end
241
+
242
+ def executor
243
+ @executor || host.worker_pool.executor
244
+ end
245
+
246
+ def config
247
+ @config || CableRoom.config
248
+ end
249
+
250
+ def logger
251
+ host.logger
252
+ end
253
+
254
+ def monotonic_now
255
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
256
+ end
257
+ end
258
+ end
@@ -1,4 +1,17 @@
1
1
  module CableRoom
2
+ # The `ports[...]` DSL shared by a Room and by a member's RoomMembership. A port is a named
3
+ # lane between a room and its members; `ports[:x] << msg` sends on it and `ports[:x].stream`
4
+ # listens on it. Which transport that touches depends on which side you're on, and each side
5
+ # implements the three methods below:
6
+ #
7
+ # * Member side (RoomMembership): sending publishes on the Bus channel for the room's port
8
+ # (member→room), listening is an ActionCable stream (room→member, fed by the broadcaster).
9
+ # * Room side (Room::Base and Room::HostAdapter): sending goes through the configured
10
+ # CableRoom::Broadcaster, listening subscribes on the Bus through the room's Host.
11
+ #
12
+ # So a member's `ports[:custom] << msg` and a room's `ports[:custom].stream` meet on the Bus,
13
+ # and a room's `ports[:custom] << msg` and a member's `ports[:custom].stream` meet on the
14
+ # broadcaster's streams. Neither side needs to know that.
2
15
  module Ports
3
16
  extend ActiveSupport::Concern
4
17
 
@@ -6,28 +19,23 @@ module CableRoom
6
19
  @ports_proxy ||= PortsProxy.new(self)
7
20
  end
8
21
 
22
+ # Listen on `port`, handing each decoded message to the block. Ports streamed with
23
+ # `auto_close: true` are stopped by `close_streamed_ports!`.
9
24
  def stream_port(port, auto_close: true, &blk)
10
- @cable_channel.stream_from(room_port_key(port), coder: ActiveSupport::JSON, &blk)
11
- _streamed_ports << port if auto_close
25
+ raise NotImplementedError, "#{self.class} must implement stream_port"
12
26
  end
13
27
 
14
28
  def close_streamed_ports!
15
- _streamed_ports.each do |port|
16
- @cable_channel.stop_stream_from(room_port_key(port))
17
- end
18
- _streamed_ports.clear
29
+ raise NotImplementedError, "#{self.class} must implement close_streamed_ports!"
19
30
  end
20
31
 
32
+ # Send `data` on `port`.
21
33
  def port_transmit(port, data)
22
- ActionCable.server.broadcast(room_port_key(port), data)
34
+ raise NotImplementedError, "#{self.class} must implement port_transmit"
23
35
  end
24
36
 
25
37
  protected
26
38
 
27
- def _room_channel_class
28
- room_class::Channel
29
- end
30
-
31
39
  def _streamed_ports
32
40
  @_streamed_ports ||= Set.new
33
41
  end
@@ -5,21 +5,12 @@ require "active_model/railtie"
5
5
 
6
6
  module CableRoom
7
7
  class Railtie < Rails::Railtie # :nodoc:
8
- rake_tasks do
9
- end
10
-
11
- console do |app|
12
- end
13
-
14
- runner do
15
- end
16
-
17
8
  initializer "cable_room.hook_action_cable_restart" do
18
9
  module ActionCableServerExtensions
10
+ # ActionCable restarts when the app reloads; stop every room with it. A process that
11
+ # hosts no rooms (a web process in :remote) has nothing to stop.
19
12
  def restart
20
- CableRoom::ChannelTracker.instance.each_room_channel do |chan|
21
- chan.unsubscribe_from_channel
22
- end
13
+ CableRoom::Host.current&.stop_all_rooms!
23
14
  super
24
15
  end
25
16
  end
@@ -8,52 +8,50 @@ module CableRoom
8
8
  WATCH_DOG_INTERVAL = 15.seconds
9
9
 
10
10
  class << self
11
+ # Start this room in the current process if nobody else is running it. Takes the room's
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.
11
17
  def ensure(key = nil)
12
- lock_key = room_port_key(key)
13
- lock_info = CableRoom.lock_manager.lock(lock_key, self::LOCK_DURATION.in_milliseconds)
14
- return false unless lock_info
15
-
16
- vchannel = self::Channel.new(
17
- lock_info,
18
- self,
19
- key,
20
- {
21
- watchdog_interval: self::WATCH_DOG_INTERVAL,
22
- lock_duration: self::LOCK_DURATION,
23
- }
24
- )
25
- vchannel.subscribe_to_channel
26
-
27
- true
28
- rescue => e
29
- CableRoom.lock_manager.unlock(lock_info) if lock_info
30
- raise e
18
+ Host.instance.ensure_room(self, key)
31
19
  end
32
20
 
21
+ # 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.
33
23
  def room_port_key(room_key, port = nil)
34
- full_key = [room_key]
35
- full_key << port if port
36
- self::Channel.broadcasting_for(full_key)
24
+ parts = [name, room_key]
25
+ parts << port if port
26
+ parts.map { |part| serialize_key_part(part) }.join(":")
37
27
  end
38
28
 
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
39
  def send_message(room_key, data, port: ROOM_IN_CHANNEL)
40
- ActionCable.server.broadcast(room_port_key(room_key, port), data)
40
+ CableRoom.bus.publish(inbound_channel(room_key, port), data)
41
41
  end
42
42
 
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).
43
45
  def locally_running_instances
44
- ChannelTracker.instance.room_channels.select do |chan|
45
- chan.room_class == self
46
- end.map(&:room)
46
+ Room.locally_open_rooms.select { |room| room.class == self }
47
47
  end
48
48
 
49
- protected
49
+ private
50
50
 
51
- def periodically(method, every:, &blk)
52
- blk ||= method
53
- blk = -> { send(method) } unless blk.is_a?(Proc)
54
- self::Channel.periodically(every:) do
55
- room.instance_exec(&blk)
56
- end
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
55
  end
58
56
  end
59
57
 
@@ -61,9 +59,10 @@ module CableRoom
61
59
 
62
60
  include Callbacks
63
61
  include Threading
64
- include ChannelAdapter
62
+ include HostAdapter
65
63
 
66
64
  include Lifecycle
65
+ include Snapshotting
67
66
  include Reaping
68
67
  include InputHandling
69
68
 
@@ -74,18 +73,25 @@ module CableRoom
74
73
 
75
74
  attr_reader :key
76
75
 
77
- delegate :logger, :params, to: :@cable_channel
76
+ delegate :logger, to: :@runner
78
77
 
79
- def initialize(vchannel, key = nil)
78
+ def initialize(runner, key = nil)
80
79
  super()
81
80
  @key = key
82
- @cable_channel = vchannel
81
+ @runner = runner
83
82
  end
84
83
 
85
84
  def <<(data)
86
85
  port_transmit(ROOM_OUT_CHANNEL, data)
87
86
  end
88
87
 
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
+
89
95
  protected
90
96
 
91
97
  def room_class
@@ -96,8 +102,8 @@ module CableRoom
96
102
  self.class.room_port_key(key, sub_channel)
97
103
  end
98
104
 
99
- def start_periodic_timer(callback, every:)
100
- @cable_channel.send(:start_periodic_timer, -> { room.instance_exec(&callback) }, every:)
105
+ def broadcaster
106
+ Broadcaster.current
101
107
  end
102
108
  end
103
109
  end
@@ -0,0 +1,52 @@
1
+ module CableRoom
2
+ module Room
3
+ # The Room's side of its connection to the Host: which streams it listens on and which
4
+ # timers it runs. Everything here goes through the Room's runner (`@runner`, a Host::Runner).
5
+ module HostAdapter
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ # `[callback, every]` pairs declared with `periodically`. A class_attribute so subclasses
10
+ # inherit their parents' timers and add their own without touching the parent's list.
11
+ class_attribute :periodic_timers, instance_writer: false, instance_predicate: false, default: []
12
+ end
13
+
14
+ class_methods do
15
+ protected
16
+
17
+ # Run `method` (or the block) on the Room's thread every `every`, for as long as the
18
+ # Room is running
19
+ def periodically(method, every:, &blk)
20
+ blk ||= method
21
+ blk = -> { send(method) } unless blk.is_a?(Proc)
22
+ self.periodic_timers += [[blk, every]]
23
+ end
24
+ end
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.
29
+ def stream_port(port, auto_close: true, on_live: nil, &blk)
30
+ @runner.subscribe(inbound_channel(port), on_live: on_live, &blk)
31
+ _streamed_ports << port if auto_close
32
+ end
33
+
34
+ def close_streamed_ports!
35
+ _streamed_ports.each do |port|
36
+ @runner.unsubscribe(inbound_channel(port))
37
+ end
38
+ _streamed_ports.clear
39
+ end
40
+
41
+ protected
42
+
43
+ def inbound_channel(port)
44
+ self.class.inbound_channel(key, port)
45
+ end
46
+
47
+ def start_periodic_timer(callback, every:)
48
+ @runner.start_periodic_timer(-> { instance_exec(&callback) }, every: every)
49
+ end
50
+ end
51
+ end
52
+ end
@@ -5,8 +5,10 @@ module CableRoom
5
5
 
6
6
  included do
7
7
  after_startup do
8
- logger.info "Started"
9
- self << { type: 'room_opened' }
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?
10
12
  end
11
13
 
12
14
  before_shutdown do
@@ -22,27 +24,42 @@ module CableRoom
22
24
  protected
23
25
 
24
26
  def lifecycle_state
25
- @cable_channel.state
27
+ @runner.state
26
28
  end
27
29
 
28
- # Requests that the Room shut down gracefully, processing any pending messages
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.
29
32
  def shutdown!(reason = "Room requested shutdown")
30
33
  @shutdown_reason = reason
31
34
  logger.info "Shutdown requested: #{reason}"
32
- @cable_channel.initiate_shutdown(reason)
35
+ @runner.initiate_shutdown(reason)
33
36
  end
34
37
 
35
38
  # Stops the room synchronously, ignoring remaining messages
36
39
  def stop!
37
- @cable_channel.unsubscribe_from_channel
40
+ @runner.stop!
38
41
  end
39
42
 
40
43
  private
41
44
 
42
- def _startup
43
- ActiveSupport::Notifications.instrument("room_opened.cable_room", { room: self }) do
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
44
57
  run_callbacks :startup do
45
- startup if respond_to?(:startup)
58
+ if restoring && respond_to?(:restore_state, true)
59
+ restore_state(app_state)
60
+ else
61
+ startup if respond_to?(:startup)
62
+ end
46
63
  end
47
64
  end
48
65
  end
@@ -68,6 +68,13 @@ module CableRoom
68
68
  { client_port: client_port || tag, **kwargs }
69
69
  end
70
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
+
71
78
  def reply(message = nil, **kwargs)
72
79
  raise ArgumentError, "Can only use reply when handling a message" unless message_origin
73
80
  raise ArgumentError, "Must provide message or block" unless message || block_given?
@@ -151,6 +158,29 @@ module CableRoom
151
158
  end
152
159
  end
153
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
+
154
184
  def check_port_inactivity
155
185
  return unless @_port_clients
156
186
 
@@ -208,6 +238,33 @@ module CableRoom
208
238
  def tag!(*tags)
209
239
  self[:tags].merge(tags.flatten.map(&:to_sym))
210
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
211
268
  end
212
269
  end
213
270
  end