cable_room 0.6.2.beta1 → 0.7.0.beta2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +122 -0
- data/README.md +662 -48
- 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 +577 -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 +482 -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 +260 -0
- data/lib/cable_room/ports.rb +20 -50
- 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 +70 -2
- data/lib/cable_room/room/reaping.rb +34 -1
- data/lib/cable_room/room/snapshotting.rb +82 -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_harness.rb +168 -0
- data/lib/cable_room/room_member.rb +293 -69
- 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 +26 -9
- data/lib/cable_room/channel_base.rb +0 -247
- data/lib/cable_room/channel_tracker.rb +0 -130
- data/lib/cable_room/room/channel_adapter.rb +0 -18
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CableRoom
|
|
4
|
+
class Host
|
|
5
|
+
# The parent process behind `cable_room server --workers N`. It forks N children, runs one
|
|
6
|
+
# block in each, replaces a child that dies, and passes SIGTERM and SIGINT on to every child
|
|
7
|
+
# before exiting itself. It never hosts a room, never opens Redis, and never starts a Bus:
|
|
8
|
+
# each child builds its own after the fork (`CableRoom.after_fork!`), so nothing with a
|
|
9
|
+
# socket or a thread behind it is ever shared between processes.
|
|
10
|
+
#
|
|
11
|
+
# Supervisor.new(count: 2, logger: logger) { |index| run_a_host(index) }.run
|
|
12
|
+
#
|
|
13
|
+
# The block is the whole child: it runs right after the fork and its return value (an Integer,
|
|
14
|
+
# or nil for 0) is the child's exit status. The child leaves with `exit!`, so `at_exit` hooks
|
|
15
|
+
# the parent registered before forking don't run a second time in every child (Resque does
|
|
16
|
+
# the same for its forked workers). A block that raises is logged and exits 1, which makes
|
|
17
|
+
# the supervisor replace it.
|
|
18
|
+
#
|
|
19
|
+
# Restarts: a child that dies is replaced. One that ran for at least `stable_after` seconds
|
|
20
|
+
# comes back at once; one that died sooner is treated as failing to boot and comes back
|
|
21
|
+
# after a delay that doubles from `first_backoff` up to `max_backoff`, so a broken app forks
|
|
22
|
+
# a few times a minute rather than thousands of times a second.
|
|
23
|
+
#
|
|
24
|
+
# Signals: SIGTERM and SIGINT to the parent are relayed to every live child and the parent
|
|
25
|
+
# then waits, with no time limit of its own, until they've all exited (a child migrating rooms
|
|
26
|
+
# may take a while; the container's `timeout -k` is the outer bound). A signal sent to one
|
|
27
|
+
# child touches only that child: it exits, and the parent replaces it. The handlers do
|
|
28
|
+
# nothing but write a byte to a pipe; the main loop reads the pipe, so no real work runs in
|
|
29
|
+
# signal context. SIGCHLD writes the same pipe to wake the loop when a child exits, and the
|
|
30
|
+
# loop also polls every `POLL_INTERVAL` seconds in case a wake-up is ever missed.
|
|
31
|
+
#
|
|
32
|
+
# If the parent itself dies without relaying anything (SIGKILL, a crash), each child notices
|
|
33
|
+
# through a second pipe (`watch_parent`) and stops as if it had been sent SIGTERM, so a dead
|
|
34
|
+
# supervisor never leaves orphaned workers hosting rooms.
|
|
35
|
+
class Supervisor
|
|
36
|
+
STOP_SIGNALS = %w[TERM INT].freeze
|
|
37
|
+
POLL_INTERVAL = 1
|
|
38
|
+
|
|
39
|
+
Worker = Struct.new(:index, :pid, :started_at)
|
|
40
|
+
|
|
41
|
+
attr_reader :count, :logger
|
|
42
|
+
|
|
43
|
+
def initialize(count:, logger:, stable_after: 5, first_backoff: 1, max_backoff: 30, &body)
|
|
44
|
+
raise ArgumentError, "count must be at least 1 (got #{count.inspect})" unless count.is_a?(Integer) && count >= 1
|
|
45
|
+
raise ArgumentError, "Supervisor needs a block to run in each worker" unless body
|
|
46
|
+
|
|
47
|
+
@count = count
|
|
48
|
+
@logger = logger
|
|
49
|
+
@stable_after = stable_after
|
|
50
|
+
@first_backoff = first_backoff
|
|
51
|
+
@max_backoff = max_backoff
|
|
52
|
+
@body = body
|
|
53
|
+
|
|
54
|
+
@workers = {} # index => Worker, for every live child
|
|
55
|
+
@restart_at = {} # index => monotonic time to fork a replacement
|
|
56
|
+
@backoff = {} # index => the delay used for that slot's last boot failure
|
|
57
|
+
@stopping = false
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Fork the workers and supervise them until a stop signal has arrived and every child has
|
|
61
|
+
# exited. Blocks. Returns the process exit status (0).
|
|
62
|
+
def run
|
|
63
|
+
@signal_reader, @signal_writer = IO.pipe
|
|
64
|
+
@lifeline_reader, @lifeline_writer = IO.pipe
|
|
65
|
+
previous_traps = install_traps
|
|
66
|
+
|
|
67
|
+
count.times { |index| start_worker(index) }
|
|
68
|
+
|
|
69
|
+
loop do
|
|
70
|
+
wait_for_wakeup
|
|
71
|
+
reap_exited_workers
|
|
72
|
+
break if @stopping && @workers.empty?
|
|
73
|
+
start_due_restarts unless @stopping
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
logger.info "all workers exited"
|
|
77
|
+
0
|
|
78
|
+
ensure
|
|
79
|
+
# If we're leaving for any reason other than "every child is gone" (a bug, say), don't
|
|
80
|
+
# orphan the children: tell them to stop too. Then hand the signals back.
|
|
81
|
+
relay("TERM") if @workers.any?
|
|
82
|
+
previous_traps&.each { |sig, handler| trap(sig, handler) }
|
|
83
|
+
[@signal_reader, @signal_writer, @lifeline_reader, @lifeline_writer].each { |io| io&.close }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Ask the supervisor to shut down as if `signal` had arrived: relay it to every worker and
|
|
87
|
+
# exit once they're gone. Safe from any thread and from a trap handler; the work happens on
|
|
88
|
+
# the thread running `run`.
|
|
89
|
+
def stop!(signal = "TERM")
|
|
90
|
+
wake(signal)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def stopping?
|
|
94
|
+
@stopping
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Pids of the children alive right now.
|
|
98
|
+
def worker_pids
|
|
99
|
+
@workers.values.map(&:pid)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
# ---- The parent ----------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
def install_traps
|
|
107
|
+
(STOP_SIGNALS + %w[CHLD]).to_h do |sig|
|
|
108
|
+
[sig, trap(sig) { wake(sig) }]
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Runs in signal context, so it does the one thing that's safe there: a non-blocking write
|
|
113
|
+
# to the pipe. A full pipe means plenty of wake-ups are already queued, so dropping is fine.
|
|
114
|
+
def wake(signal)
|
|
115
|
+
@signal_writer&.write_nonblock("#{signal}\n")
|
|
116
|
+
rescue IO::WaitWritable, IOError, Errno::EPIPE
|
|
117
|
+
nil
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Sleep until a signal or child exit wakes us, a restart is due, or the poll interval
|
|
121
|
+
# passes. Any stop signal on the pipe starts the shutdown.
|
|
122
|
+
def wait_for_wakeup
|
|
123
|
+
timeout = [POLL_INTERVAL, seconds_until_next_restart].compact.min
|
|
124
|
+
ready, = IO.select([@signal_reader], nil, nil, timeout)
|
|
125
|
+
return unless ready
|
|
126
|
+
|
|
127
|
+
pending = begin
|
|
128
|
+
@signal_reader.read_nonblock(4096)
|
|
129
|
+
rescue IO::WaitReadable, EOFError
|
|
130
|
+
""
|
|
131
|
+
end
|
|
132
|
+
pending.split("\n").each { |signal| begin_stopping(signal) if STOP_SIGNALS.include?(signal) }
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def begin_stopping(signal)
|
|
136
|
+
if @stopping
|
|
137
|
+
# A second Ctrl-C or TERM: pass it along again, in case a child missed the first
|
|
138
|
+
relay(signal)
|
|
139
|
+
return
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
@stopping = true
|
|
143
|
+
@restart_at.clear
|
|
144
|
+
logger.info "got SIG#{signal}, relaying to #{@workers.size} worker(s) and waiting for them"
|
|
145
|
+
relay(signal)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def relay(signal)
|
|
149
|
+
@workers.each_value do |worker|
|
|
150
|
+
Process.kill(signal, worker.pid)
|
|
151
|
+
rescue Errno::ESRCH
|
|
152
|
+
nil # already gone; the next reap logs it
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def reap_exited_workers
|
|
157
|
+
@workers.values.each do |worker|
|
|
158
|
+
status = begin
|
|
159
|
+
_, status = Process.wait2(worker.pid, Process::WNOHANG)
|
|
160
|
+
status
|
|
161
|
+
rescue Errno::ECHILD
|
|
162
|
+
:unknown # someone else reaped it; treat it as gone
|
|
163
|
+
end
|
|
164
|
+
next if status.nil?
|
|
165
|
+
|
|
166
|
+
@workers.delete(worker.index)
|
|
167
|
+
uptime = now - worker.started_at
|
|
168
|
+
reason = describe_exit(status)
|
|
169
|
+
|
|
170
|
+
if @stopping
|
|
171
|
+
logger.info "worker #{worker.index} (pid #{worker.pid}) #{reason} after #{uptime.round}s"
|
|
172
|
+
else
|
|
173
|
+
delay = restart_delay(worker.index, uptime)
|
|
174
|
+
@restart_at[worker.index] = now + delay
|
|
175
|
+
logger.warn "worker #{worker.index} (pid #{worker.pid}) #{reason} after #{uptime.round}s; " \
|
|
176
|
+
"restarting #{delay.zero? ? 'now' : "in #{delay}s"}"
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def describe_exit(status)
|
|
182
|
+
return "exited (status unknown)" if status == :unknown
|
|
183
|
+
return "killed by SIG#{Signal.signame(status.termsig)}" if status.signaled?
|
|
184
|
+
|
|
185
|
+
"exited with status #{status.exitstatus}"
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# A worker that lived a while and then died gets replaced right away. One that died
|
|
189
|
+
# almost immediately most likely can't boot, so wait, and wait longer each time it happens.
|
|
190
|
+
def restart_delay(index, uptime)
|
|
191
|
+
if uptime >= @stable_after
|
|
192
|
+
@backoff.delete(index)
|
|
193
|
+
0
|
|
194
|
+
else
|
|
195
|
+
@backoff[index] = @backoff[index] ? [@backoff[index] * 2, @max_backoff].min : @first_backoff
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def start_due_restarts
|
|
200
|
+
due = @restart_at.select { |_, at| at <= now }.keys
|
|
201
|
+
due.each do |index|
|
|
202
|
+
@restart_at.delete(index)
|
|
203
|
+
start_worker(index)
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def seconds_until_next_restart
|
|
208
|
+
next_at = @restart_at.values.min
|
|
209
|
+
next_at && [next_at - now, 0].max
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def start_worker(index)
|
|
213
|
+
pid = fork_worker(index)
|
|
214
|
+
@workers[index] = Worker.new(index, pid, now)
|
|
215
|
+
logger.info "forked worker #{index} (pid #{pid})"
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def now
|
|
219
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# ---- The child -----------------------------------------------------------------------
|
|
223
|
+
|
|
224
|
+
def fork_worker(index)
|
|
225
|
+
Process.fork do
|
|
226
|
+
status = 1
|
|
227
|
+
begin
|
|
228
|
+
# The child inherits the parent's traps and both ends of its wake-up pipe. Neither
|
|
229
|
+
# belongs here: a relayed SIGTERM must reach the body's own handler (or kill the
|
|
230
|
+
# child by default) instead of writing to a pipe nobody in this process reads.
|
|
231
|
+
(STOP_SIGNALS + %w[CHLD]).each { |sig| trap(sig, "DEFAULT") }
|
|
232
|
+
@signal_reader.close
|
|
233
|
+
@signal_writer.close
|
|
234
|
+
@workers = {}
|
|
235
|
+
|
|
236
|
+
# Give up our copy of the lifeline's write end first, so a sibling can never keep the
|
|
237
|
+
# pipe open after the parent is gone; then watch the read end for the parent's death
|
|
238
|
+
@lifeline_writer.close
|
|
239
|
+
watch_parent(@lifeline_reader)
|
|
240
|
+
|
|
241
|
+
CableRoom.after_fork!
|
|
242
|
+
|
|
243
|
+
result = @body.call(index)
|
|
244
|
+
status = result.is_a?(Integer) ? result : 0
|
|
245
|
+
rescue SystemExit => e
|
|
246
|
+
status = e.status
|
|
247
|
+
rescue SignalException
|
|
248
|
+
raise # let Ruby end the process with the signal, so the parent sees which one
|
|
249
|
+
rescue Exception => e # rubocop:disable Lint/RescueException -- the child must not outlive a broken body
|
|
250
|
+
logger.error "worker #{index} (pid #{Process.pid}) crashed: #{e.class}: #{e.message}\n #{e.backtrace&.first(10)&.join("\n ")}"
|
|
251
|
+
status = 1
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
$stdout.flush
|
|
255
|
+
$stderr.flush
|
|
256
|
+
exit!(status)
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# Once every child has closed its copy, the parent holds the only write end of the lifeline
|
|
261
|
+
# pipe, so a read here returns EOF exactly when the parent is gone: crashed, or SIGKILLed by
|
|
262
|
+
# the container's `timeout -k`. A worker must not carry on hosting rooms with nobody
|
|
263
|
+
# supervising it, so it stops itself the same way a relayed SIGTERM would stop it.
|
|
264
|
+
def watch_parent(reader)
|
|
265
|
+
thread = Thread.new do
|
|
266
|
+
reader.read(1)
|
|
267
|
+
Process.kill("TERM", Process.pid)
|
|
268
|
+
end
|
|
269
|
+
thread.name = "cable_room-supervisor-lifeline"
|
|
270
|
+
thread.report_on_exception = false
|
|
271
|
+
thread
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
module CableRoom
|
|
2
|
+
class Host
|
|
3
|
+
# The thread pool every room's work runs on. It's an ActionCable Worker so the `:work`
|
|
4
|
+
# callbacks Rails installs (the executor wrap, ActiveRecord log tagging) still apply to room
|
|
5
|
+
# work exactly as they did when rooms were channels.
|
|
6
|
+
#
|
|
7
|
+
# The "connection" passed around here is the room's Host::Runner. ActionCable's Worker was
|
|
8
|
+
# written for connections; rooms don't have one, but the runner fills the same role: it's
|
|
9
|
+
# the thing with a logger and an error reporter.
|
|
10
|
+
class WorkerPool < ActionCable::Server::Worker
|
|
11
|
+
set_callback :work, :around do |_, blk|
|
|
12
|
+
pconn = ActionCable::Server::Worker.connection
|
|
13
|
+
ActionCable::Server::Worker.connection = connection
|
|
14
|
+
blk.call
|
|
15
|
+
ensure
|
|
16
|
+
ActionCable::Server::Worker.connection = pconn
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# ActionCable's Worker#invoke reduces every exception to a log line and a no-argument
|
|
20
|
+
# `handle_exception` call, which discards the error itself. Rooms run all of their work
|
|
21
|
+
# through here, so report it properly instead.
|
|
22
|
+
def invoke(receiver, method, *args, connection:, &block)
|
|
23
|
+
work(connection) do
|
|
24
|
+
receiver.send method, *args, &block
|
|
25
|
+
rescue Exception => e
|
|
26
|
+
if connection.respond_to?(:report_work_error)
|
|
27
|
+
connection.report_work_error(e)
|
|
28
|
+
else
|
|
29
|
+
logger.error "There was an exception - #{e.class}(#{e.message})"
|
|
30
|
+
logger.error Array(e.backtrace).join("\n")
|
|
31
|
+
CableRoom.report_error(e, connection: connection)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|