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,477 @@
|
|
|
1
|
+
require 'socket'
|
|
2
|
+
require_relative 'host/worker_pool'
|
|
3
|
+
require_relative 'host/bus_inbound'
|
|
4
|
+
require_relative 'host/runner'
|
|
5
|
+
require_relative 'host/supervisor'
|
|
6
|
+
|
|
7
|
+
module CableRoom
|
|
8
|
+
# One Host per process. It owns every Room running here: the worker pool their work runs on,
|
|
9
|
+
# the scheduler behind `periodically` timers and the watchdog beat, the inbound transport they
|
|
10
|
+
# receive on, the list of open rooms, and shutting all of them down when the process exits.
|
|
11
|
+
#
|
|
12
|
+
# Each room gets a Host::Runner, which keeps that room's own ordered work queue. A room is only
|
|
13
|
+
# ever processed by one thread at a time, in arrival order. The Host is what lets many rooms
|
|
14
|
+
# share one pool of threads without breaking that promise.
|
|
15
|
+
class Host
|
|
16
|
+
BEAT_INTERVAL = 5.seconds
|
|
17
|
+
|
|
18
|
+
# Raised when a process that doesn't host rooms asks for its Host. With `room_host = :remote`
|
|
19
|
+
# only `cable_room server` hosts rooms; a web process must never start one.
|
|
20
|
+
class NotHosting < StandardError; end
|
|
21
|
+
|
|
22
|
+
NOT_HOSTING_MESSAGE =
|
|
23
|
+
"This process doesn't host rooms: CableRoom.config.room_host is :remote, so rooms run " \
|
|
24
|
+
"under `cable_room server`. Start rooms there (or call CableRoom::Host.start! first if this " \
|
|
25
|
+
"process really should host them).".freeze
|
|
26
|
+
|
|
27
|
+
@start_mutex = Mutex.new
|
|
28
|
+
|
|
29
|
+
class << self
|
|
30
|
+
# The Host this process runs, or nil when it runs none (a web process in :remote, or an
|
|
31
|
+
# :inline process that hasn't started a room yet). Use this for introspection and cleanup,
|
|
32
|
+
# where "no rooms here" is a normal answer.
|
|
33
|
+
def current
|
|
34
|
+
@current
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# The Host this process runs. In :inline every process hosts rooms, so the first caller
|
|
38
|
+
# builds it. In :remote only a process that declared itself a rooms host with `start!`
|
|
39
|
+
# (`cable_room server`) has one; anyone else gets NotHosting, so a room can never quietly
|
|
40
|
+
# start in the web tier because some code path reached for the Host.
|
|
41
|
+
def instance
|
|
42
|
+
current || (CableRoom.config.inline? ? start! : raise(NotHosting, NOT_HOSTING_MESSAGE))
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Make this process a rooms host. Builds the Host once and starts its provisioning listener,
|
|
46
|
+
# so this process hears `create: true` requests from then on; later calls return the same
|
|
47
|
+
# Host. `cable_room server` calls this explicitly; in :inline `instance` calls it for you.
|
|
48
|
+
def start!
|
|
49
|
+
@start_mutex.synchronize { @current ||= new.tap(&:start_placement) }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Swap this process's Host for `host` (nil allowed) and return the previous one. Only specs
|
|
53
|
+
# that play two process roles in one Ruby process should need this (see RoomHostHarness):
|
|
54
|
+
# the "web" side has to see no Host while another Host runs the rooms.
|
|
55
|
+
def replace_current(host)
|
|
56
|
+
@start_mutex.synchronize { @current.tap { @current = host } }
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# How many rooms `drain!` hands off at once before `drain_timeout`. Each handoff costs the
|
|
61
|
+
# adopting peer a full room restore (its startup chain, often database reads), so a host with
|
|
62
|
+
# hundreds of rooms must not offer them all in the same second; and each one holds a thread
|
|
63
|
+
# here for up to `handoff_timeout`. Four keeps a big host draining in well under the default
|
|
64
|
+
# ten minutes (a handoff normally takes well under a second) without swamping the fleet.
|
|
65
|
+
DRAIN_CONCURRENCY = 4
|
|
66
|
+
|
|
67
|
+
# What `drain!` returns: how the rooms ended up, and how long it took. `migrated` and
|
|
68
|
+
# `closed` are Migration objects (see their `state` and `room`).
|
|
69
|
+
DrainResult = Struct.new(:reason, :migrated, :closed, :duration, keyword_init: true) do
|
|
70
|
+
def rooms
|
|
71
|
+
migrated.size + closed.size
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
attr_reader :scheduler, :inbound, :placement
|
|
76
|
+
|
|
77
|
+
# True once this host has started shutting down for good and is handing its rooms off (see
|
|
78
|
+
# `drain!`). A draining host never claims a provision request, so it can't take on a room it
|
|
79
|
+
# would only have to hand off again.
|
|
80
|
+
attr_writer :draining
|
|
81
|
+
|
|
82
|
+
delegate :logger, to: :cable_server
|
|
83
|
+
|
|
84
|
+
class << self
|
|
85
|
+
# Register a block to run when a `drain!` finishes, with the Host and its DrainResult. An
|
|
86
|
+
# app that scales in through an ASG termination lifecycle hook completes the hook here
|
|
87
|
+
# (`complete-lifecycle-action`), once every room has moved or closed. Blocks run on the
|
|
88
|
+
# draining thread before `drain!` returns; one that raises is reported and doesn't stop
|
|
89
|
+
# the others.
|
|
90
|
+
def after_drain(&blk)
|
|
91
|
+
raise ArgumentError, "after_drain needs a block" unless blk
|
|
92
|
+
after_drain_callbacks << blk
|
|
93
|
+
blk
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def after_drain_callbacks
|
|
97
|
+
@after_drain_callbacks ||= []
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# `inbound` is how rooms receive messages: by default the process-wide Bus, so a room hears
|
|
102
|
+
# its members the same way whether it runs inside the web process or on a remote host.
|
|
103
|
+
def initialize(inbound: BusInbound.new)
|
|
104
|
+
@runners = Set.new
|
|
105
|
+
@monitor = Monitor.new
|
|
106
|
+
@inbound = inbound
|
|
107
|
+
@draining = false
|
|
108
|
+
@drain_result = nil
|
|
109
|
+
@drain_thread = nil
|
|
110
|
+
@migrations = []
|
|
111
|
+
@scheduler = Rufus::Scheduler.new
|
|
112
|
+
|
|
113
|
+
# A rooms host that leaves with rooms still open (a crash on the main thread, say; on a
|
|
114
|
+
# SIGTERM `cable_room server` has already drained by now, so this is a no-op) gives them
|
|
115
|
+
# the same chance to move as a planned shutdown would. In :inline there is no fleet of
|
|
116
|
+
# peers by design (see `drain!`), so exit closes rooms the way it always has.
|
|
117
|
+
at_exit do
|
|
118
|
+
logger.info "Shutting down CableRoom"
|
|
119
|
+
if CableRoom.config.remote? && !shutdown? && runners.any?
|
|
120
|
+
drain!(reason: "process exit")
|
|
121
|
+
else
|
|
122
|
+
shutdown!
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Every few seconds each room renews its Redlock and checks its watchdog
|
|
127
|
+
scheduler.every(BEAT_INTERVAL) do
|
|
128
|
+
each_runner(&:beat)
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def worker_pool
|
|
133
|
+
@worker_pool || @monitor.synchronize do
|
|
134
|
+
@worker_pool ||= WorkerPool.new(max_size: cable_server.config.worker_pool_size)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Start `room_class`'s room for `key` on this Host if nobody else is running it. Takes the
|
|
139
|
+
# room's Redlock first, so exactly one process cluster-wide wins; returns false when the lock
|
|
140
|
+
# is already held. This is the host-side way to bring a room up: `Room::Base.ensure` calls it
|
|
141
|
+
# on the process Host, Placement calls it for a provision request, and `cable_room server`
|
|
142
|
+
# (or a spec harness) can call it on a Host it owns without any member asking.
|
|
143
|
+
#
|
|
144
|
+
# If a snapshot of the room is on offer (another host is handing it off, see
|
|
145
|
+
# CableRoom::Migration), winning the lock means adopting it: the room comes back here with
|
|
146
|
+
# its members and state rather than starting fresh underneath the old host. The check is
|
|
147
|
+
# made after the lock is won, so it can't be raced. `handoff_only: true` (a handoff request)
|
|
148
|
+
# starts nothing when there is no snapshot to adopt — it has expired, or the old host took
|
|
149
|
+
# the room back — and lets the lock go.
|
|
150
|
+
def ensure_room(room_class, key = nil, handoff_only: false)
|
|
151
|
+
lock_key = room_class.room_port_key(key)
|
|
152
|
+
lock_info = CableRoom.lock_manager.lock(lock_key, room_class::LOCK_DURATION.in_milliseconds)
|
|
153
|
+
return false unless lock_info
|
|
154
|
+
|
|
155
|
+
snapshot = bus.get(Bus.snapshot_key(lock_key))
|
|
156
|
+
return Migration.adopt(self, room_class, key, lock_info: lock_info, snapshot: snapshot) if snapshot
|
|
157
|
+
|
|
158
|
+
if handoff_only
|
|
159
|
+
logger.info "No snapshot to adopt for #{lock_key}; letting the lock go"
|
|
160
|
+
CableRoom.lock_manager.unlock(lock_info)
|
|
161
|
+
return false
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
start_room(
|
|
165
|
+
room_class,
|
|
166
|
+
key,
|
|
167
|
+
lock_info,
|
|
168
|
+
watchdog_interval: room_class::WATCH_DOG_INTERVAL,
|
|
169
|
+
lock_duration: room_class::LOCK_DURATION,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
true
|
|
173
|
+
rescue => e
|
|
174
|
+
CableRoom.lock_manager.unlock(lock_info) if lock_info
|
|
175
|
+
raise e
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Start running a room here. The caller (`ensure_room`) has already taken the room's Redlock;
|
|
179
|
+
# the runner renews it from now on and releases it when the room dies.
|
|
180
|
+
def start_room(room_class, key, lock_info, watchdog_interval:, lock_duration:)
|
|
181
|
+
runner = Runner.new(self, room_class, key, lock_info, watchdog_interval:, lock_duration:)
|
|
182
|
+
runner.start!
|
|
183
|
+
runner
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Bring a room back from a CableRoom::Snapshot on this Host, the way a migration's adopter
|
|
187
|
+
# does. With no `lock_info`, takes the room's Redlock first and returns false if someone else
|
|
188
|
+
# holds it — the same rule as `ensure_room`. With one, the caller has already claimed the lock.
|
|
189
|
+
# Raises Snapshot::UnknownVersion or Snapshot::UnknownRoomClass before anything is built if
|
|
190
|
+
# the snapshot can't be used here. `hold_inbound: true` brings the room up frozen, holding
|
|
191
|
+
# its inbound until `Runner#thaw!` (see Runner#restore!).
|
|
192
|
+
#
|
|
193
|
+
# If the room fails to come up, the lock is released either way — the caller's too. A room
|
|
194
|
+
# that can't start here must not keep its key locked: the migration that handed it over
|
|
195
|
+
# takes the lock back and restarts it as soon as the lock is free.
|
|
196
|
+
def restore_room(snapshot, lock_info = nil, hold_inbound: false)
|
|
197
|
+
snapshot = Snapshot.validate!(snapshot)
|
|
198
|
+
room_class = Snapshot.room_class_for(snapshot)
|
|
199
|
+
key = Snapshot.key_for(snapshot)
|
|
200
|
+
lock_key = room_class.room_port_key(key)
|
|
201
|
+
|
|
202
|
+
if lock_info
|
|
203
|
+
unless lock_info[:resource] == lock_key
|
|
204
|
+
raise ArgumentError, "lock_info is for #{lock_info[:resource].inspect}, but the snapshot is #{lock_key}"
|
|
205
|
+
end
|
|
206
|
+
else
|
|
207
|
+
lock_info = CableRoom.lock_manager.lock(lock_key, room_class::LOCK_DURATION.in_milliseconds)
|
|
208
|
+
return false unless lock_info
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
runner = Runner.new(
|
|
212
|
+
self,
|
|
213
|
+
room_class,
|
|
214
|
+
key,
|
|
215
|
+
lock_info,
|
|
216
|
+
watchdog_interval: room_class::WATCH_DOG_INTERVAL,
|
|
217
|
+
lock_duration: room_class::LOCK_DURATION,
|
|
218
|
+
)
|
|
219
|
+
runner.restore!(snapshot, hold_inbound: hold_inbound)
|
|
220
|
+
runner
|
|
221
|
+
rescue => e
|
|
222
|
+
# `restore!` releases the lock itself when the room fails to come up; this covers a failure
|
|
223
|
+
# before the runner exists
|
|
224
|
+
CableRoom.lock_manager.unlock(lock_info) if lock_info && runner.nil?
|
|
225
|
+
raise e
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# A name for this host in migration traffic and instrumentation (`from_host`, `to_host`):
|
|
229
|
+
# machine, pid, and a few random characters, so two hosts in one process (a spec) differ too.
|
|
230
|
+
def id
|
|
231
|
+
@id ||= "#{Socket.gethostname}:#{Process.pid}:#{SecureRandom.hex(3)}"
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# The Bus this host's rooms listen on, for the migration's list and key operations. Falls
|
|
235
|
+
# back to the process-wide Bus when `inbound` isn't Bus-backed.
|
|
236
|
+
def bus
|
|
237
|
+
inbound.respond_to?(:bus) ? inbound.bus : CableRoom.bus
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def track(runner)
|
|
241
|
+
@monitor.synchronize do
|
|
242
|
+
raise "Cannot add Room after shutdown" if @shutdown
|
|
243
|
+
@runners << runner
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def untrack(runner)
|
|
248
|
+
@monitor.synchronize { @runners.delete(runner) }
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# A snapshot, so callers can iterate while rooms come and go
|
|
252
|
+
def runners
|
|
253
|
+
@monitor.synchronize { @runners.to_a }
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def rooms
|
|
257
|
+
runners.map(&:room)
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# How many member ports are attached to rooms on this Host right now. This is the load
|
|
261
|
+
# number the design scales the rooms pool on, so an app can publish it as a gauge. It reads
|
|
262
|
+
# each room's port table from outside the room's thread; a count that's a moment stale is
|
|
263
|
+
# fine for a metric.
|
|
264
|
+
def open_ports
|
|
265
|
+
rooms.sum(&:open_port_count)
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# How many rooms this Host runs right now. This is the load number provisioning weights its
|
|
269
|
+
# delay by: a host with more rooms waits longer before racing for a new one.
|
|
270
|
+
def open_rooms_count
|
|
271
|
+
@monitor.synchronize { @runners.size }
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def each_runner(&blk)
|
|
275
|
+
runners.each(&blk)
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def draining?
|
|
279
|
+
@draining
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# True once `drain!` has finished (every room moved or closed, and the host is shut down).
|
|
283
|
+
def drained?
|
|
284
|
+
!@drain_result.nil?
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# The result of the drain, or nil until `drain!` has finished.
|
|
288
|
+
def drain_result
|
|
289
|
+
@drain_result
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# Every CableRoom::Migration this host's drain has started, oldest room first. Each one's
|
|
293
|
+
# `state` says where it is; the list is complete once `drained?`.
|
|
294
|
+
def migrations
|
|
295
|
+
@monitor.synchronize { @migrations.dup }
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# Planned shutdown: move every room here to a peer host, then shut down. This is what SIGTERM
|
|
299
|
+
# means to `cable_room server` (see CableRoom::Migration for the per-room protocol):
|
|
300
|
+
#
|
|
301
|
+
# 1. Mark the host draining, so its Placement claims nothing more (a room it took now would
|
|
302
|
+
# only have to move again), and instrument `host_draining.cable_room`.
|
|
303
|
+
# 2. Hand rooms off oldest first, DRAIN_CONCURRENCY at a time. A room nobody adopts within
|
|
304
|
+
# `handoff_timeout` closes with `room_closed`; so does one that can't be snapshotted.
|
|
305
|
+
# 3. When `drain_timeout` passes with rooms still waiting their turn, offer all of them at
|
|
306
|
+
# once — the same path, just no longer throttled — so the drain ends within about one
|
|
307
|
+
# more `handoff_timeout` whatever the fleet does.
|
|
308
|
+
# 4. `shutdown!`, run the `Host.after_drain` callbacks, and return a DrainResult.
|
|
309
|
+
#
|
|
310
|
+
# Blocks until done. Runs once: a second call (from another thread, or `at_exit` after
|
|
311
|
+
# `cable_room server` has already drained) waits for the first and returns its result.
|
|
312
|
+
# `drain_timeout` and `handoff_timeout` default to the config's; specs pass short ones.
|
|
313
|
+
#
|
|
314
|
+
# In :inline the peers are the other web processes hosting rooms, if any. The design gives
|
|
315
|
+
# :inline no fleet of its own, so the gem doesn't drain there on exit (see `at_exit`); an app
|
|
316
|
+
# running several inline processes may still call this from its own signal handling.
|
|
317
|
+
def drain!(reason:, drain_timeout: nil, handoff_timeout: nil, concurrency: DRAIN_CONCURRENCY)
|
|
318
|
+
other = @monitor.synchronize do
|
|
319
|
+
return @drain_result if @drain_result
|
|
320
|
+
@drain_thread.tap { @drain_thread ||= Thread.current }
|
|
321
|
+
end
|
|
322
|
+
if other
|
|
323
|
+
other.join unless other == Thread.current
|
|
324
|
+
return @drain_result
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
drain_timeout ||= CableRoom.config.drain_timeout
|
|
328
|
+
handoff_timeout ||= CableRoom.config.handoff_timeout
|
|
329
|
+
started = monotonic_now
|
|
330
|
+
self.draining = true
|
|
331
|
+
|
|
332
|
+
queued = runners.sort_by(&:started_at)
|
|
333
|
+
migrations = queued.map { |runner| Migration.new(self, runner, reason: reason, handoff_timeout: handoff_timeout) }
|
|
334
|
+
@monitor.synchronize { @migrations = migrations }
|
|
335
|
+
logger.info "Draining #{migrations.size} room(s): #{reason}"
|
|
336
|
+
|
|
337
|
+
payload = { host: self, rooms: queued.map(&:room), reason: reason }
|
|
338
|
+
ActiveSupport::Notifications.instrument("host_draining.cable_room", payload) do |event|
|
|
339
|
+
run_migrations(migrations, concurrency: concurrency, deadline: started + drain_timeout.to_f)
|
|
340
|
+
event[:migrated] = migrations.count(&:migrated?)
|
|
341
|
+
event[:closed] = migrations.count { |m| !m.migrated? }
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
shutdown!
|
|
345
|
+
|
|
346
|
+
result = DrainResult.new(
|
|
347
|
+
reason: reason,
|
|
348
|
+
migrated: migrations.select(&:migrated?),
|
|
349
|
+
closed: migrations.reject(&:migrated?),
|
|
350
|
+
duration: monotonic_now - started,
|
|
351
|
+
)
|
|
352
|
+
logger.info "Drained: #{result.migrated.size} room(s) migrated, #{result.closed.size} closed, in #{result.duration.round(2)}s"
|
|
353
|
+
@monitor.synchronize { @drain_result = result }
|
|
354
|
+
|
|
355
|
+
self.class.after_drain_callbacks.each do |callback|
|
|
356
|
+
callback.call(self, result)
|
|
357
|
+
rescue => e
|
|
358
|
+
CableRoom.report_error(e, host: self, drain_result: result)
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
result
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
def shutdown?
|
|
365
|
+
@shutdown
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
# Start listening for provision requests (see CableRoom::Placement). `Host.start!` does this
|
|
369
|
+
# for the process Host; a Host built with `new` (a spec harness) calls it itself. Pass a
|
|
370
|
+
# Placement to use one with non-default settings; calling it again re-arms the current one.
|
|
371
|
+
def start_placement(placement = nil)
|
|
372
|
+
previous = @monitor.synchronize do
|
|
373
|
+
raise "Cannot start placement after shutdown" if @shutdown
|
|
374
|
+
@placement.tap { @placement = placement || @placement || Placement.new(self) }
|
|
375
|
+
end
|
|
376
|
+
previous.stop if previous && previous != @placement
|
|
377
|
+
@placement.start
|
|
378
|
+
@placement
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
# Stop listening and drop any claim still waiting. Nothing starts a room here after this.
|
|
382
|
+
def stop_placement
|
|
383
|
+
@placement&.stop
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
# Stop everything: the beat, every room (gracefully, with a bounded wait), then the pool.
|
|
387
|
+
# Runs once; later calls do nothing.
|
|
388
|
+
def shutdown!
|
|
389
|
+
@monitor.synchronize do
|
|
390
|
+
return if @shutdown
|
|
391
|
+
@shutdown = true
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
# Stop claiming first: a room started after this point would only be shut down again
|
|
395
|
+
stop_placement
|
|
396
|
+
scheduler.shutdown
|
|
397
|
+
shutdown_rooms!
|
|
398
|
+
worker_pool.executor.shutdown
|
|
399
|
+
worker_pool.executor.wait_for_termination(5)
|
|
400
|
+
worker_pool.executor.kill
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
# Ask every room to finish what it has queued and shut down, then give them up to
|
|
404
|
+
# `wait` seconds to do it
|
|
405
|
+
def shutdown_rooms!(wait: 15)
|
|
406
|
+
each_runner { |runner| runner.initiate_shutdown("Server shutting down") }
|
|
407
|
+
|
|
408
|
+
wait.times do
|
|
409
|
+
break if runners.empty?
|
|
410
|
+
sleep 1
|
|
411
|
+
end
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
# Stop every room right now, dropping whatever work is still queued. ActionCable's `restart`
|
|
415
|
+
# calls this when the app reloads.
|
|
416
|
+
def stop_all_rooms!
|
|
417
|
+
each_runner(&:stop!)
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
private
|
|
421
|
+
|
|
422
|
+
# Run the migrations `concurrency` at a time until `deadline`, then everything still waiting
|
|
423
|
+
# at once. Each runs on a thread of its own rather than the worker pool: a handoff blocks for
|
|
424
|
+
# up to `handoff_timeout` waiting on a peer, and the rooms still need the pool to run.
|
|
425
|
+
def run_migrations(migrations, concurrency:, deadline:)
|
|
426
|
+
queue = Queue.new
|
|
427
|
+
migrations.each { |migration| queue << migration }
|
|
428
|
+
next_migration = -> { queue.pop(true) rescue nil } # non-blocking: nil once the queue is empty
|
|
429
|
+
|
|
430
|
+
workers = Array.new([concurrency, migrations.size].min) do |i|
|
|
431
|
+
migration_thread("cable_room-drain-#{i}") do
|
|
432
|
+
while (migration = next_migration.call)
|
|
433
|
+
migration.run
|
|
434
|
+
end
|
|
435
|
+
end
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
workers.each do |thread|
|
|
439
|
+
remaining = deadline - monotonic_now
|
|
440
|
+
break if remaining <= 0
|
|
441
|
+
thread.join(remaining)
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
forced = []
|
|
445
|
+
while (migration = next_migration.call)
|
|
446
|
+
forced << migration
|
|
447
|
+
end
|
|
448
|
+
if forced.any?
|
|
449
|
+
logger.warn "drain_timeout passed with #{forced.size} room(s) still waiting; handing them all off now"
|
|
450
|
+
end
|
|
451
|
+
forced_threads = forced.map { |migration| migration_thread("cable_room-drain-forced") { migration.run } }
|
|
452
|
+
|
|
453
|
+
(workers + forced_threads).each(&:join)
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
def migration_thread(name)
|
|
457
|
+
thread = Thread.new do
|
|
458
|
+
yield
|
|
459
|
+
rescue => e
|
|
460
|
+
# Migration#run handles its own failures; this is for a bug in the loop itself, which
|
|
461
|
+
# must not take the whole drain down with it
|
|
462
|
+
CableRoom.report_error(e, host: self)
|
|
463
|
+
end
|
|
464
|
+
thread.name = name
|
|
465
|
+
thread.report_on_exception = false
|
|
466
|
+
thread
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
def monotonic_now
|
|
470
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
def cable_server
|
|
474
|
+
ActionCable.server
|
|
475
|
+
end
|
|
476
|
+
end
|
|
477
|
+
end
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
module CableRoom
|
|
2
|
+
# Where a channel keeps its RoomMemberships between calls. `RoomMember#_room_memberships` is one
|
|
3
|
+
# of these, and RoomMembership adds and removes itself from it.
|
|
4
|
+
#
|
|
5
|
+
# Under ActionCable the channel object lives as long as the socket, so an in-memory set is the
|
|
6
|
+
# whole story: this base class. Under anycable-rails every RPC call (subscribe, message,
|
|
7
|
+
# unsubscribe, disconnect) builds a fresh channel instance and never re-runs `subscribed`, so an
|
|
8
|
+
# in-memory set is empty by the time `hello` or `receive` arrive. The AnyCable subclass writes
|
|
9
|
+
# each membership's identity into the channel's AnyCable state, which anycable-go hands back on
|
|
10
|
+
# every call, and rebuilds the memberships from it on first use.
|
|
11
|
+
class MembershipStore
|
|
12
|
+
include Enumerable
|
|
13
|
+
|
|
14
|
+
def self.for(channel)
|
|
15
|
+
channel.anycable_channel? ? AnyCable.new(channel) : new(channel)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def initialize(channel)
|
|
19
|
+
@channel = channel
|
|
20
|
+
@memberships = Set.new
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def <<(membership)
|
|
24
|
+
memberships << membership
|
|
25
|
+
persist!
|
|
26
|
+
self
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def delete(membership)
|
|
30
|
+
memberships.delete(membership)
|
|
31
|
+
persist!
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def clear
|
|
35
|
+
memberships.clear
|
|
36
|
+
persist!
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def each(&blk)
|
|
40
|
+
memberships.each(&blk)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def to_a
|
|
44
|
+
memberships.to_a
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def empty?
|
|
48
|
+
memberships.empty?
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def size
|
|
52
|
+
memberships.size
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Write the memberships back to wherever they live. Call it after changing something about a
|
|
56
|
+
# membership that has to survive the call (RoomMembership#hello! does). In memory there is
|
|
57
|
+
# nothing to do.
|
|
58
|
+
def persist!; end
|
|
59
|
+
|
|
60
|
+
protected
|
|
61
|
+
|
|
62
|
+
def memberships
|
|
63
|
+
@memberships
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# The anycable-rails flavor: the memberships also live in the channel's AnyCable state
|
|
67
|
+
# (`state_attr_accessor`), as one JSON string of RoomMembership#persisted_identity records.
|
|
68
|
+
#
|
|
69
|
+
# One string rather than an array of hashes because anycable-rails runs every value in the
|
|
70
|
+
# state through its own serializer, which locates any GlobalID-looking string through the
|
|
71
|
+
# database. The `extra` a member joined with is ActiveJob-serialized and may well contain one,
|
|
72
|
+
# and the room is the only party that should deserialize it. Encoding the records ourselves
|
|
73
|
+
# keeps that serializer out of them.
|
|
74
|
+
class AnyCable < MembershipStore
|
|
75
|
+
STATE_ATTRIBUTE = :_cable_room_memberships
|
|
76
|
+
|
|
77
|
+
def persist!
|
|
78
|
+
# The accessor is private on the channel (see RoomMember) so it can't be performed as a
|
|
79
|
+
# channel action; `send` is deliberate.
|
|
80
|
+
@channel.send(:"#{STATE_ATTRIBUTE}=", ActiveSupport::JSON.encode(memberships.map(&:persisted_identity)))
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
protected
|
|
84
|
+
|
|
85
|
+
# Rebuild on first use, whatever the use is. The subscribe call that creates the first
|
|
86
|
+
# membership finds nothing to rebuild; every later call finds what subscribe (and hello)
|
|
87
|
+
# wrote. Restored memberships are added straight to the set: they exist already as far as
|
|
88
|
+
# anycable-go is concerned, so there is nothing to persist and no stream to open.
|
|
89
|
+
def memberships
|
|
90
|
+
restore! unless @restored
|
|
91
|
+
super
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def restore!
|
|
95
|
+
@restored = true
|
|
96
|
+
raw = @channel.send(STATE_ATTRIBUTE)
|
|
97
|
+
return if raw.blank?
|
|
98
|
+
|
|
99
|
+
ActiveSupport::JSON.decode(raw).each do |record|
|
|
100
|
+
@memberships << RoomMembership.restore(@channel, record)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|