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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +122 -0
- data/README.md +1190 -0
- data/cable_room.gemspec +5 -2
- data/exe/cable_room +8 -0
- data/lib/cable_room/broadcaster.rb +116 -0
- data/lib/cable_room/bus.rb +372 -0
- data/lib/cable_room/cli.rb +237 -0
- data/lib/cable_room/config.rb +112 -0
- data/lib/cable_room/host/bus_inbound.rb +36 -0
- data/lib/cable_room/host/runner.rb +571 -0
- data/lib/cable_room/host/supervisor.rb +275 -0
- data/lib/cable_room/host/worker_pool.rb +37 -0
- data/lib/cable_room/host.rb +477 -0
- data/lib/cable_room/membership_store.rb +105 -0
- data/lib/cable_room/migration.rb +586 -0
- data/lib/cable_room/periodic_timer.rb +18 -0
- data/lib/cable_room/placement.rb +258 -0
- data/lib/cable_room/ports.rb +19 -11
- data/lib/cable_room/railtie.rb +3 -12
- data/lib/cable_room/room/base.rb +45 -39
- data/lib/cable_room/room/host_adapter.rb +52 -0
- data/lib/cable_room/room/lifecycle.rb +26 -9
- data/lib/cable_room/room/port_management.rb +57 -0
- data/lib/cable_room/room/reaping.rb +34 -1
- data/lib/cable_room/room/snapshotting.rb +78 -0
- data/lib/cable_room/room/threading.rb +2 -2
- data/lib/cable_room/room/user_management.rb +27 -0
- data/lib/cable_room/room.rb +5 -2
- data/lib/cable_room/room_member.rb +260 -84
- data/lib/cable_room/room_proxy_channel.rb +13 -2
- data/lib/cable_room/snapshot.rb +136 -0
- data/lib/cable_room/version.rb +1 -1
- data/lib/cable_room.rb +57 -2
- metadata +25 -9
- data/lib/cable_room/channel_base.rb +0 -262
- data/lib/cable_room/channel_tracker.rb +0 -130
- 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
|
data/lib/cable_room/ports.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/cable_room/railtie.rb
CHANGED
|
@@ -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::
|
|
21
|
-
chan.unsubscribe_from_channel
|
|
22
|
-
end
|
|
13
|
+
CableRoom::Host.current&.stop_all_rooms!
|
|
23
14
|
super
|
|
24
15
|
end
|
|
25
16
|
end
|
data/lib/cable_room/room/base.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
49
|
+
private
|
|
50
50
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
|
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,
|
|
76
|
+
delegate :logger, to: :@runner
|
|
78
77
|
|
|
79
|
-
def initialize(
|
|
78
|
+
def initialize(runner, key = nil)
|
|
80
79
|
super()
|
|
81
80
|
@key = key
|
|
82
|
-
@
|
|
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
|
|
100
|
-
|
|
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
|
-
|
|
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
|
-
@
|
|
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
|
-
@
|
|
35
|
+
@runner.initiate_shutdown(reason)
|
|
33
36
|
end
|
|
34
37
|
|
|
35
38
|
# Stops the room synchronously, ignoring remaining messages
|
|
36
39
|
def stop!
|
|
37
|
-
@
|
|
40
|
+
@runner.stop!
|
|
38
41
|
end
|
|
39
42
|
|
|
40
43
|
private
|
|
41
44
|
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
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
|