cable_room 0.6.2 → 0.7.0.beta1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +122 -0
  3. data/README.md +1190 -0
  4. data/cable_room.gemspec +5 -2
  5. data/exe/cable_room +8 -0
  6. data/lib/cable_room/broadcaster.rb +116 -0
  7. data/lib/cable_room/bus.rb +372 -0
  8. data/lib/cable_room/cli.rb +237 -0
  9. data/lib/cable_room/config.rb +112 -0
  10. data/lib/cable_room/host/bus_inbound.rb +36 -0
  11. data/lib/cable_room/host/runner.rb +571 -0
  12. data/lib/cable_room/host/supervisor.rb +275 -0
  13. data/lib/cable_room/host/worker_pool.rb +37 -0
  14. data/lib/cable_room/host.rb +477 -0
  15. data/lib/cable_room/membership_store.rb +105 -0
  16. data/lib/cable_room/migration.rb +586 -0
  17. data/lib/cable_room/periodic_timer.rb +18 -0
  18. data/lib/cable_room/placement.rb +258 -0
  19. data/lib/cable_room/ports.rb +19 -11
  20. data/lib/cable_room/railtie.rb +3 -12
  21. data/lib/cable_room/room/base.rb +45 -39
  22. data/lib/cable_room/room/host_adapter.rb +52 -0
  23. data/lib/cable_room/room/lifecycle.rb +26 -9
  24. data/lib/cable_room/room/port_management.rb +57 -0
  25. data/lib/cable_room/room/reaping.rb +34 -1
  26. data/lib/cable_room/room/snapshotting.rb +78 -0
  27. data/lib/cable_room/room/threading.rb +2 -2
  28. data/lib/cable_room/room/user_management.rb +27 -0
  29. data/lib/cable_room/room.rb +5 -2
  30. data/lib/cable_room/room_member.rb +260 -84
  31. data/lib/cable_room/room_proxy_channel.rb +13 -2
  32. data/lib/cable_room/snapshot.rb +136 -0
  33. data/lib/cable_room/version.rb +1 -1
  34. data/lib/cable_room.rb +57 -2
  35. metadata +25 -9
  36. data/lib/cable_room/channel_base.rb +0 -262
  37. data/lib/cable_room/channel_tracker.rb +0 -130
  38. data/lib/cable_room/room/channel_adapter.rb +0 -18
@@ -0,0 +1,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