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,571 @@
|
|
|
1
|
+
module CableRoom
|
|
2
|
+
class Host
|
|
3
|
+
# Runs one Room for the Host. It holds everything that is per-room but not the room's own
|
|
4
|
+
# business: the ordered work queue, lifecycle state, the Redlock and watchdog, the inbound
|
|
5
|
+
# Bus subscriptions, and the periodic timers.
|
|
6
|
+
#
|
|
7
|
+
# A Room talks to its runner (`@runner`) for anything that touches threads or the outside
|
|
8
|
+
# world. The Room DSL (`shutdown!`, `stop!`, `async`, `on_room_thread`, `ports[]`,
|
|
9
|
+
# `periodically`) is built on this API.
|
|
10
|
+
#
|
|
11
|
+
# States: :initializing -> :starting -> :started -> :shutting_down -> :dead
|
|
12
|
+
#
|
|
13
|
+
# A room being migrated off this host takes a detour: :started -> :freezing -> :frozen, then
|
|
14
|
+
# either `discard!` (-> :dead, quietly, once another host has it) or `stop!` (-> :dead with
|
|
15
|
+
# room_closed, when nobody took it). See `freeze!`. A room arriving on this host by migration
|
|
16
|
+
# runs the detour backwards: `restore!(hold_inbound: true)` brings it up straight into :frozen,
|
|
17
|
+
# and `thaw!` moves it to :started once the handoff has replayed what the old host relayed.
|
|
18
|
+
class Runner
|
|
19
|
+
FROZEN_STATES = %i[freezing frozen].freeze
|
|
20
|
+
|
|
21
|
+
attr_reader :host, :room, :room_class, :key, :uuid, :tenant, :logger
|
|
22
|
+
|
|
23
|
+
# Monotonic time this runner was built. `Host#drain!` migrates rooms oldest first.
|
|
24
|
+
attr_reader :started_at
|
|
25
|
+
|
|
26
|
+
delegate :worker_pool, :inbound, to: :host
|
|
27
|
+
|
|
28
|
+
def initialize(host, room_class, key, lock_info, watchdog_interval:, lock_duration:)
|
|
29
|
+
@host = host
|
|
30
|
+
@room_class = room_class
|
|
31
|
+
@key = key
|
|
32
|
+
@lock_info = lock_info
|
|
33
|
+
@watchdog_interval = watchdog_interval
|
|
34
|
+
@lock_duration = lock_duration
|
|
35
|
+
@started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
36
|
+
|
|
37
|
+
# Used mainly for logs and being able to follow a specific Room instance
|
|
38
|
+
@uuid = SecureRandom.hex(6)
|
|
39
|
+
|
|
40
|
+
@mutex = Monitor.new
|
|
41
|
+
@work_finished = @mutex.new_cond
|
|
42
|
+
@current_state = :initializing
|
|
43
|
+
@processing_work = false
|
|
44
|
+
@work_queue = []
|
|
45
|
+
@held_inbound = []
|
|
46
|
+
@hold_from_start = false
|
|
47
|
+
@streams = {} # stream => the inbound transport's handle, needed to unsubscribe
|
|
48
|
+
@handlers = {} # stream => the room's handler, so a message can be fed in by hand (see `inject`)
|
|
49
|
+
@periodic_timers = []
|
|
50
|
+
|
|
51
|
+
@tenant = Apartment::Tenant.current if defined?(Apartment)
|
|
52
|
+
|
|
53
|
+
@logger = ActionCable::Connection::TaggedLoggerProxy.new(
|
|
54
|
+
host.logger,
|
|
55
|
+
tags: ["#{room_class.name} #{@uuid}"]
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
logger.info "Initializing new #{room_class.name}"
|
|
59
|
+
logger.info " UUID: #{@uuid}"
|
|
60
|
+
logger.info " Key: #{room_class.room_port_key(key)}"
|
|
61
|
+
|
|
62
|
+
@room = room_class.new(self, key)
|
|
63
|
+
host.track(self)
|
|
64
|
+
ping_watchdog
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def state
|
|
68
|
+
@current_state
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def frozen?
|
|
72
|
+
FROZEN_STATES.include?(state)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# The Redlock this runner holds, or nil once it has been released or handed off. Read-only:
|
|
76
|
+
# use `release_lock!` to let go of it.
|
|
77
|
+
def lock_info
|
|
78
|
+
@lock_info
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# -- Lifecycle ---------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
# Run the room's startup callbacks and start its class-level timers, on the calling thread.
|
|
84
|
+
# If startup fails the room is torn down and the error re-raised.
|
|
85
|
+
def start!
|
|
86
|
+
start_with { room.send(:_startup) }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Bring the room up from a CableRoom::Snapshot instead of from scratch: same as `start!`,
|
|
90
|
+
# except the room rebuilds its ports, users, reaper deadlines, and (through `restore_state`)
|
|
91
|
+
# its own state first, and doesn't tell its members it opened. The caller holds the room's
|
|
92
|
+
# lock already, the same as for `start!`.
|
|
93
|
+
#
|
|
94
|
+
# With `hold_inbound: true` the room comes up :frozen instead of :started: it subscribes its
|
|
95
|
+
# inbound streams as usual, but every message that arrives is held (see `held_inbound`) and
|
|
96
|
+
# its timers don't start until `thaw!`. This is how a migration's adopter takes a room: the
|
|
97
|
+
# messages the old host relayed have to run before anything that arrives live here.
|
|
98
|
+
def restore!(snapshot, hold_inbound: false)
|
|
99
|
+
@hold_from_start = hold_inbound
|
|
100
|
+
start_with(final_state: hold_inbound ? :frozen : :started) { room.send(:_restore, snapshot) }
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Stop right now, on the calling thread: run the room's shutdown callbacks and free
|
|
104
|
+
# everything. Work still on the queue is dropped. `initiate_shutdown` is the graceful version.
|
|
105
|
+
# `reason` is what members see in `room_closed`; nil keeps whatever the room already set.
|
|
106
|
+
#
|
|
107
|
+
# Only the state change happens under the mutex. Unsubscribing waits for the Bus to confirm,
|
|
108
|
+
# and the Bus thread needs this same mutex to queue an arriving message, so holding it here
|
|
109
|
+
# would deadlock the two whenever a message lands mid-shutdown.
|
|
110
|
+
def stop!(reason: nil)
|
|
111
|
+
@mutex.synchronize do
|
|
112
|
+
return if state == :dead
|
|
113
|
+
@current_state = :shutting_down
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
room.send(:_shutdown_reason=, reason) unless reason.nil?
|
|
117
|
+
unsubscribe_all
|
|
118
|
+
begin
|
|
119
|
+
with_executor { room.send(:_shutdown) }
|
|
120
|
+
ensure
|
|
121
|
+
terminate!
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Shut down gracefully: stop listening, then stop once everything already queued has run.
|
|
126
|
+
# `reason` reaches the members in `room_closed`.
|
|
127
|
+
def initiate_shutdown(reason)
|
|
128
|
+
@mutex.synchronize do
|
|
129
|
+
return if closed?
|
|
130
|
+
|
|
131
|
+
logger.info "Initiating shutdown: #{reason}"
|
|
132
|
+
room.send(:_shutdown_reason=, reason)
|
|
133
|
+
|
|
134
|
+
# The actual stop goes behind whatever is already waiting, so those messages still run
|
|
135
|
+
post_work(async: false) { stop! }
|
|
136
|
+
|
|
137
|
+
# From here on new messages are dropped at the queue (see `enqueue`), so nothing new
|
|
138
|
+
# lands after the stop even before the unsubscribe below is confirmed
|
|
139
|
+
@current_state = :shutting_down
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
unsubscribe_all
|
|
143
|
+
# A frozen room had stopped taking work off its queue; now that it's shutting down the
|
|
144
|
+
# stop posted above has to actually run
|
|
145
|
+
schedule_work
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Free everything without running the room's shutdown callbacks. Members hear nothing (no
|
|
149
|
+
# room_closed); the lock is released unless the caller asks to keep it (see `discard!`).
|
|
150
|
+
def terminate!(release_lock: true)
|
|
151
|
+
lock_info = @mutex.synchronize do
|
|
152
|
+
@current_state = :dead
|
|
153
|
+
stop_periodic_timers
|
|
154
|
+
@lock_info.tap { @lock_info = nil }
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Unsubscribe before releasing the lock: the next runner for this key (which needs the
|
|
158
|
+
# lock) must not subscribe to the same Bus channel before this one has let go of it.
|
|
159
|
+
unsubscribe_all
|
|
160
|
+
CableRoom.lock_manager.unlock(lock_info) if release_lock && lock_info
|
|
161
|
+
host.untrack(self)
|
|
162
|
+
lock_info
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# -- Migration ---------------------------------------------------------------------------
|
|
166
|
+
#
|
|
167
|
+
# The pieces the migration protocol (CableRoom::Migration) is built from, in the order it
|
|
168
|
+
# uses them: `freeze!`, `snapshot`, `release_lock!`, and finally `discard!` once another
|
|
169
|
+
# host has restored the room — or `stop!` if none did. Members never hear about any of it.
|
|
170
|
+
|
|
171
|
+
# Bring the room to a standstill so it can be snapshotted: stop the periodic timers, let
|
|
172
|
+
# whatever is already queued finish, and from then on hold inbound messages instead of
|
|
173
|
+
# handling them. The Bus subscription stays up, so the messages that arrive while the room
|
|
174
|
+
# is frozen still land here — in `held_inbound`, or with the block if one is given (called
|
|
175
|
+
# on the Bus thread with `(stream, message)`, so keep it quick), for the migration to relay
|
|
176
|
+
# to the new host. The lock is kept and keeps being renewed.
|
|
177
|
+
#
|
|
178
|
+
# Blocks until the room is quiet, or for `timeout` seconds (nil waits as long as it takes).
|
|
179
|
+
# Returns true once frozen, or false if the room died on the way (a queued message asked it
|
|
180
|
+
# to shut down, say) or the timeout passed — in which case the room is running again as if
|
|
181
|
+
# nothing happened, with the messages held meanwhile back on its queue, so the caller can
|
|
182
|
+
# still `stop!` it cleanly. A room that never goes quiet must not hang a drain.
|
|
183
|
+
def freeze!(timeout: nil, &hold)
|
|
184
|
+
@mutex.synchronize do
|
|
185
|
+
return false if closed?
|
|
186
|
+
return true if frozen?
|
|
187
|
+
|
|
188
|
+
logger.info "Freezing"
|
|
189
|
+
@current_state = :freezing
|
|
190
|
+
@hold_inbound = hold
|
|
191
|
+
stop_periodic_timers
|
|
192
|
+
# Nothing else may be running in this room once we return: wait for the queue to drain
|
|
193
|
+
# and the item in flight to finish. New inbound is already being held, so this ends.
|
|
194
|
+
deadline = timeout && monotonic_now + timeout
|
|
195
|
+
while state == :freezing && (@processing_work || @work_queue.any?)
|
|
196
|
+
remaining = deadline && deadline - monotonic_now
|
|
197
|
+
if remaining && remaining <= 0
|
|
198
|
+
logger.warn "Room did not go quiet within #{timeout}s; not freezing it"
|
|
199
|
+
resume_from_freeze
|
|
200
|
+
return false
|
|
201
|
+
end
|
|
202
|
+
@work_finished.wait(remaining)
|
|
203
|
+
end
|
|
204
|
+
return false unless state == :freezing
|
|
205
|
+
|
|
206
|
+
@current_state = :frozen
|
|
207
|
+
# Messages that arrived while the room was still going quiet were held, not handed to
|
|
208
|
+
# the block: had the freeze timed out they'd have to go back on the queue, and once
|
|
209
|
+
# relayed they'd be gone. Now that the freeze is final, hand them over first — under the
|
|
210
|
+
# mutex, so nothing arriving on the Bus thread can get ahead of them.
|
|
211
|
+
if hold
|
|
212
|
+
held = @held_inbound
|
|
213
|
+
@held_inbound = []
|
|
214
|
+
held.each { |stream, message| hold.call(stream, message) }
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
true
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Messages that arrived while frozen (and weren't handed to a `freeze!` block), as
|
|
221
|
+
# `[stream, message]` pairs in arrival order.
|
|
222
|
+
def held_inbound
|
|
223
|
+
@mutex.synchronize { @held_inbound.dup }
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# The streams this runner is subscribed to right now (Bus channel names).
|
|
227
|
+
def subscribed_streams
|
|
228
|
+
@mutex.synchronize { @streams.keys }
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Feed `message` into the room as if it had arrived on `stream`, behind whatever is already
|
|
232
|
+
# queued — even while the room is frozen, when a live message would be held instead. This is
|
|
233
|
+
# how a migration's adopter replays what the old host relayed: the messages go onto the
|
|
234
|
+
# queue in the order given and run once the room thaws. Returns false (and drops the
|
|
235
|
+
# message, with a warning) if the room doesn't listen on that stream.
|
|
236
|
+
def inject(stream, message)
|
|
237
|
+
handler = @mutex.synchronize { @handlers[String(stream)] }
|
|
238
|
+
unless handler
|
|
239
|
+
logger.warn "Dropping a relayed message for #{stream}: this room doesn't listen on it"
|
|
240
|
+
return false
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
post_work(async: false, silent: true) { handler.call(message) }
|
|
244
|
+
true
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Take a frozen room back to :started. The block gets the inbound held so far (as
|
|
248
|
+
# `[stream, message]` pairs, in arrival order) and returns the pairs to run, in order —
|
|
249
|
+
# a migration's adopter uses it to drop the ones it has already replayed from the handoff
|
|
250
|
+
# list. Those are queued (after anything `inject`ed before), the timers start, and the room
|
|
251
|
+
# runs again. Everything happens under the mutex, so no message can land between the block
|
|
252
|
+
# seeing the held list and the room going live: it is either in the list or queued after.
|
|
253
|
+
# Returns false if the room isn't frozen.
|
|
254
|
+
def thaw!
|
|
255
|
+
@mutex.synchronize do
|
|
256
|
+
return false unless state == :frozen
|
|
257
|
+
|
|
258
|
+
held = @held_inbound
|
|
259
|
+
@held_inbound = []
|
|
260
|
+
@hold_inbound = nil
|
|
261
|
+
@hold_from_start = false
|
|
262
|
+
to_run = block_given? ? yield(held) : held
|
|
263
|
+
to_run.each { |stream, message| inject(stream, message) }
|
|
264
|
+
@current_state = :started
|
|
265
|
+
start_periodic_timers
|
|
266
|
+
ping_watchdog
|
|
267
|
+
end
|
|
268
|
+
logger.info "Thawed"
|
|
269
|
+
schedule_work
|
|
270
|
+
true
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# The room's CableRoom::Snapshot. Only a frozen room can be snapshotted: that's the one
|
|
274
|
+
# state where nothing else is touching its state.
|
|
275
|
+
def snapshot
|
|
276
|
+
raise "#{room_class.name}[#{key}] must be frozen before it can be snapshotted (state: #{state})" unless state == :frozen
|
|
277
|
+
|
|
278
|
+
with_executor { Snapshot.take(room) }
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# Give the room's lock up while staying alive, so another host can claim the room and this
|
|
282
|
+
# one can keep relaying inbound until it has. Returns false if there was no lock to release.
|
|
283
|
+
def release_lock!
|
|
284
|
+
lock_info = @mutex.synchronize { @lock_info.tap { @lock_info = nil } }
|
|
285
|
+
return false unless lock_info
|
|
286
|
+
|
|
287
|
+
CableRoom.lock_manager.unlock(lock_info)
|
|
288
|
+
true
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# Take the room's lock again after `release_lock!`, when no other host claimed it. Returns
|
|
292
|
+
# false if someone else holds it (or this runner still holds it). With the lock back, `stop!`
|
|
293
|
+
# releases it the normal way.
|
|
294
|
+
def retake_lock!
|
|
295
|
+
return false if @lock_info
|
|
296
|
+
|
|
297
|
+
lock_info = CableRoom.lock_manager.lock(room_class.room_port_key(key), @lock_duration.in_milliseconds)
|
|
298
|
+
return false unless lock_info
|
|
299
|
+
|
|
300
|
+
@mutex.synchronize { @lock_info = lock_info }
|
|
301
|
+
true
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# Drop a room that now lives somewhere else: no shutdown callbacks, no room_closed, and the
|
|
305
|
+
# lock — if this runner still holds one — is left alone, since it may belong to the new host
|
|
306
|
+
# by now. Returns the lock_info that was still held, or nil, so the caller can decide.
|
|
307
|
+
def discard!
|
|
308
|
+
logger.info "Discarding (the room has moved)"
|
|
309
|
+
terminate!(release_lock: false)
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
# -- Watchdog and lock -------------------------------------------------------------------
|
|
313
|
+
|
|
314
|
+
def ping_watchdog
|
|
315
|
+
return if state == :dead
|
|
316
|
+
|
|
317
|
+
logger.debug "Ping watchdog"
|
|
318
|
+
@last_watchdog_ping_at = Time.current
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
# Called on every beat: renew the Redlock, and shut down if nobody has pinged the watchdog
|
|
322
|
+
# within the interval. Losing the lock stops the room immediately, since another process
|
|
323
|
+
# may already be running it.
|
|
324
|
+
def check_room_watchdog
|
|
325
|
+
lock_info = @mutex.synchronize do
|
|
326
|
+
return if closed?
|
|
327
|
+
@lock_info
|
|
328
|
+
end
|
|
329
|
+
return unless lock_info
|
|
330
|
+
|
|
331
|
+
relock = CableRoom.lock_manager.lock(lock_info[:resource], @lock_duration.in_milliseconds, extend: lock_info)
|
|
332
|
+
unless relock
|
|
333
|
+
logger.warn "Lost lock, shutting down"
|
|
334
|
+
stop!
|
|
335
|
+
return
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
# A frozen room is idle on purpose; its timers are stopped, so nobody pings the watchdog
|
|
339
|
+
return if frozen?
|
|
340
|
+
|
|
341
|
+
unless @last_watchdog_ping_at && @last_watchdog_ping_at > @watchdog_interval.ago
|
|
342
|
+
logger.warn "Watchdog timeout for room #{room_class.name}[#{key}], shutting down"
|
|
343
|
+
initiate_shutdown("Watchdog timeout")
|
|
344
|
+
end
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
def beat
|
|
348
|
+
post_work(async: true) { check_room_watchdog }
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
# -- Work --------------------------------------------------------------------------------
|
|
352
|
+
|
|
353
|
+
# Run `blk` for this room. `async: false` (the default) puts it on the room's own queue,
|
|
354
|
+
# where items run one at a time in arrival order. `async: true` runs it on the shared pool
|
|
355
|
+
# straight away, alongside whatever the room is doing. Posting to a room that is shutting
|
|
356
|
+
# down raises unless `silent: true`, in which case the work is dropped.
|
|
357
|
+
#
|
|
358
|
+
# Errors inside the work are reported (see `report_work_error`), never raised, so one bad
|
|
359
|
+
# message can't take the room down with it.
|
|
360
|
+
def post_work(async: false, silent: false, &blk)
|
|
361
|
+
work = proc do
|
|
362
|
+
worker_pool.invoke(blk, :call, connection: self)
|
|
363
|
+
rescue => e
|
|
364
|
+
report_work_error(e)
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
if async
|
|
368
|
+
worker_pool.executor.post(&work)
|
|
369
|
+
else
|
|
370
|
+
enqueue(work, silent: silent)
|
|
371
|
+
end
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
# Log the backtrace and hand the error to the application so the failure is still
|
|
375
|
+
# discoverable even though the room carries on.
|
|
376
|
+
def report_work_error(error)
|
|
377
|
+
logger.error "Error during work execution: #{error.class.name}: #{error.message}"
|
|
378
|
+
Array(error.backtrace).first(20).each { |line| logger.error " #{line}" }
|
|
379
|
+
|
|
380
|
+
CableRoom.report_error(
|
|
381
|
+
error,
|
|
382
|
+
room: room,
|
|
383
|
+
room_class: room_class,
|
|
384
|
+
room_key: key,
|
|
385
|
+
runner: self
|
|
386
|
+
)
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
# -- Inbound streams ---------------------------------------------------------------------
|
|
390
|
+
|
|
391
|
+
# Deliver every message published on `stream` to `handler`, on the room's own queue. The
|
|
392
|
+
# transport hands us decoded messages on its own thread; all we do there is queue, so one
|
|
393
|
+
# room's handler can't hold up another room's traffic. `on_live` runs once the transport
|
|
394
|
+
# confirms the subscription; anything published before that may be missed.
|
|
395
|
+
#
|
|
396
|
+
# The subscribe itself happens outside the mutex (it blocks until the Bus confirms, and the
|
|
397
|
+
# Bus thread needs the mutex to enqueue), so a room that stopped meanwhile undoes it.
|
|
398
|
+
def subscribe(stream, on_live: nil, &handler)
|
|
399
|
+
raise ArgumentError, "Block required" unless handler
|
|
400
|
+
|
|
401
|
+
stream = String(stream)
|
|
402
|
+
return if closed?
|
|
403
|
+
|
|
404
|
+
handle = inbound.subscribe(stream, on_live: on_live) do |message|
|
|
405
|
+
receive_inbound(stream, message, handler)
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
stopped = @mutex.synchronize do
|
|
409
|
+
closed? || (@streams[stream] = handle; @handlers[stream] = handler; false)
|
|
410
|
+
end
|
|
411
|
+
inbound.unsubscribe(stream, handle) if stopped
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
def unsubscribe(stream)
|
|
415
|
+
stream = String(stream)
|
|
416
|
+
handle = @mutex.synchronize { @handlers.delete(stream); @streams.delete(stream) }
|
|
417
|
+
inbound.unsubscribe(stream, handle) if handle
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
def unsubscribe_all
|
|
421
|
+
handles = @mutex.synchronize { @handlers.clear; @streams.to_a.tap { @streams.clear } }
|
|
422
|
+
handles.each { |stream, handle| inbound.unsubscribe(stream, handle) }
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
# -- Timers ------------------------------------------------------------------------------
|
|
426
|
+
|
|
427
|
+
# Run `callback` every `every`, on the room's queue, until the timer is shut down or the
|
|
428
|
+
# room stops. Returns a PeriodicTimer.
|
|
429
|
+
def start_periodic_timer(callback, every:)
|
|
430
|
+
raise "Attempt to start periodic timer on a dead room" if closed?
|
|
431
|
+
|
|
432
|
+
job = host.scheduler.schedule_every(every) do
|
|
433
|
+
post_work(async: false, silent: true) { callback.call }
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
PeriodicTimer.new(job)
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
private
|
|
440
|
+
|
|
441
|
+
def closed?
|
|
442
|
+
state == :dead || state == :shutting_down
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
# `start!` and `restore!` differ only in what the room does first; everything around it —
|
|
446
|
+
# the executor, the timers, tearing down on failure — is the same. A room restored with
|
|
447
|
+
# `hold_inbound` ends up :frozen with no timers running; `thaw!` starts them.
|
|
448
|
+
def start_with(final_state: :started)
|
|
449
|
+
@current_state = :starting
|
|
450
|
+
with_executor do
|
|
451
|
+
yield
|
|
452
|
+
start_periodic_timers unless final_state == :frozen
|
|
453
|
+
end
|
|
454
|
+
@current_state = final_state
|
|
455
|
+
rescue => e
|
|
456
|
+
terminate!
|
|
457
|
+
raise e
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
# A message from the Bus, on the Bus thread. Normally it's queued for the room; while the
|
|
461
|
+
# room is frozen it's held for the migration to relay instead. Decided under the mutex so a
|
|
462
|
+
# message can't slip onto the queue in the moment the room freezes.
|
|
463
|
+
#
|
|
464
|
+
# A room restored with `hold_inbound` holds from its very first subscribe, while it is still
|
|
465
|
+
# :starting: the adopter has to see everything that arrives before it thaws the room.
|
|
466
|
+
def receive_inbound(stream, message, handler)
|
|
467
|
+
disposition = @mutex.synchronize do
|
|
468
|
+
next :queue unless frozen? || @hold_from_start
|
|
469
|
+
# Only a fully frozen room relays; while still :freezing it holds (see `freeze!`)
|
|
470
|
+
next :relay if @hold_inbound && state == :frozen
|
|
471
|
+
|
|
472
|
+
@held_inbound << [stream, message]
|
|
473
|
+
:held
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
case disposition
|
|
477
|
+
when :queue
|
|
478
|
+
# A handoff marker is the migration protocol talking to itself (see Migration); it is
|
|
479
|
+
# never for the room. One can only reach a live room on a failure path, so drop it here.
|
|
480
|
+
return if Migration.marker?(message)
|
|
481
|
+
|
|
482
|
+
post_work(async: false, silent: true) { handler.call(message) }
|
|
483
|
+
when :relay then @hold_inbound.call(stream, message)
|
|
484
|
+
end
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
# Undo a freeze that didn't complete: the room runs on as if `freeze!` had never been
|
|
488
|
+
# called, with the messages held meanwhile queued in arrival order. Caller holds the mutex,
|
|
489
|
+
# and the state is :freezing, so nothing else is running in the room.
|
|
490
|
+
def resume_from_freeze
|
|
491
|
+
held = @held_inbound
|
|
492
|
+
@held_inbound = []
|
|
493
|
+
@hold_inbound = nil
|
|
494
|
+
@current_state = :started
|
|
495
|
+
held.each { |stream, message| inject(stream, message) }
|
|
496
|
+
start_periodic_timers
|
|
497
|
+
ping_watchdog
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
def monotonic_now
|
|
501
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
# Timers declared on the Room class with `periodically`
|
|
505
|
+
def start_periodic_timers
|
|
506
|
+
room_class.periodic_timers.each do |callback, every|
|
|
507
|
+
@periodic_timers << start_periodic_timer(-> { room.instance_exec(&callback) }, every: every)
|
|
508
|
+
end
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
def stop_periodic_timers
|
|
512
|
+
@periodic_timers.each(&:shutdown)
|
|
513
|
+
@periodic_timers.clear
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
def enqueue(work, silent:)
|
|
517
|
+
@mutex.synchronize do
|
|
518
|
+
if closed?
|
|
519
|
+
raise "Attempt to post work to dead or shutting down room" unless silent
|
|
520
|
+
return
|
|
521
|
+
end
|
|
522
|
+
|
|
523
|
+
@work_queue << work
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
schedule_work
|
|
527
|
+
end
|
|
528
|
+
|
|
529
|
+
# Take the next item off the queue and run it on the pool, but only if nothing from this
|
|
530
|
+
# room is running already. When it finishes, come back for the next one. This is the whole
|
|
531
|
+
# ordering guarantee: one thread per room at a time, in arrival order.
|
|
532
|
+
def schedule_work
|
|
533
|
+
@mutex.synchronize do
|
|
534
|
+
return if @processing_work
|
|
535
|
+
# A frozen room keeps its queue but runs nothing from it (while :freezing it still
|
|
536
|
+
# drains, so that `freeze!` can return to a quiet room)
|
|
537
|
+
return if state == :frozen
|
|
538
|
+
|
|
539
|
+
work = @work_queue.shift
|
|
540
|
+
return unless work
|
|
541
|
+
|
|
542
|
+
@processing_work = true
|
|
543
|
+
|
|
544
|
+
worker_pool.executor.post do
|
|
545
|
+
begin
|
|
546
|
+
work.call
|
|
547
|
+
ensure
|
|
548
|
+
@mutex.synchronize do
|
|
549
|
+
@processing_work = false
|
|
550
|
+
@work_finished.broadcast
|
|
551
|
+
end
|
|
552
|
+
schedule_work
|
|
553
|
+
end
|
|
554
|
+
end
|
|
555
|
+
end
|
|
556
|
+
end
|
|
557
|
+
|
|
558
|
+
# Room startup and shutdown used to run inside ActionCable's subscribe/unsubscribe
|
|
559
|
+
# callbacks, which Rails wraps in its executor (database connections, reloading, and so
|
|
560
|
+
# on). Keep that behavior. Nesting is fine: the executor yields straight through when it's
|
|
561
|
+
# already active on this thread.
|
|
562
|
+
def with_executor(&blk)
|
|
563
|
+
if defined?(Rails) && Rails.application
|
|
564
|
+
Rails.application.executor.wrap(&blk)
|
|
565
|
+
else
|
|
566
|
+
yield
|
|
567
|
+
end
|
|
568
|
+
end
|
|
569
|
+
end
|
|
570
|
+
end
|
|
571
|
+
end
|