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,586 @@
1
+ module CableRoom
2
+ # Moves one room from this host to a peer while its members stay connected. `Host#drain!`
3
+ # builds one per room and runs it; `Migration.adopt` is the receiving side, which a peer's
4
+ # `Host#ensure_room` runs when it wins the room's lock while a snapshot is on offer — usually
5
+ # because its Placement claimed our `handoff: true` provision request, but a member's own
6
+ # request for the room ends up there too, so nobody starts the room fresh underneath us.
7
+ #
8
+ # The old host's states, in order (`state`):
9
+ #
10
+ # :pending → :frozen the room stops taking work; inbound is relayed instead (freeze!)
11
+ # :frozen → :snapshotted the gem's state plus `snapshot_state` (Runner#snapshot)
12
+ # :snapshotted → :offered SET cr:{key}:snapshot (TTL 60 s), release the lock, publish
13
+ # provision(handoff) — any peer may now claim it
14
+ # :offered → :relaying a peer claimed the lock and said so (`claimed`)
15
+ # :relaying → :adopted the peer has restored the room and replayed the list (`adopted`)
16
+ # :adopted → :migrated markers, discard the room, tell the peer (`released`)
17
+ # :offered → :restarting no peer within handoff_timeout: take the lock back
18
+ # :restarting → :closed stop! with room_closed; members re-provision on their next ping
19
+ # anything → :closed a snapshot error, or a room that won't go quiet
20
+ # :offered → :abandoned a peer holds the lock but never finished; the room is theirs
21
+ # :pending → :gone the room died before it could be frozen
22
+ #
23
+ # From :frozen until the handoff completes, the old host stays subscribed to the room's inbound
24
+ # streams and RPUSHes every message to cr:{key}:handoff (`relay`), in arrival order, with a
25
+ # running `seq`. Members notice nothing: their streams are unchanged, and the new host picks up
26
+ # the room's broadcasts.
27
+ #
28
+ # == Why nothing is lost or doubled
29
+ #
30
+ # The gap the protocol has to close: the adopter can't subscribe the room's inbound streams at
31
+ # the very instant the old host stops relaying, so for a while both hear every message. Messages
32
+ # the adopter took from the list must not run again when they also arrive live; messages
33
+ # published before the adopter's subscribe must not be missed. Redis gives one fact to build
34
+ # on: everything published on one channel reaches every subscriber in the same order. So:
35
+ #
36
+ # 1. The adopter restores the room *holding* its inbound (Runner#restore! with hold_inbound):
37
+ # it subscribes, but every live message is held, not run. Then it replays the list as it
38
+ # stands (LRANGE 0..-1), and publishes `adopted`.
39
+ # 2. The old host, on `adopted`, publishes a marker message on each inbound stream it is
40
+ # subscribed to. The marker is published after the adopter's subscribe (adopted came
41
+ # after it), so the adopter receives the marker and every message published between its
42
+ # subscribe and the marker — and the old host receives that same sequence.
43
+ # 3. Every message on a stream *before* its marker was relayed to the list by the old host
44
+ # (it relays until it sees the marker), and every message *after* the marker reached the
45
+ # adopter live. The old host stops relaying that stream at the marker, then, once it has
46
+ # seen every marker, unsubscribes and publishes `released` with how many entries the list
47
+ # holds and which streams got a marker.
48
+ # 4. The adopter, on `released`, replays the list entries it hasn't yet (its first LRANGE up
49
+ # to `relayed`), then thaws the room: of the held live messages it drops, per marked
50
+ # stream, everything up to and including the marker (those came from the list) and runs
51
+ # the rest; a stream with no marker (the old host wasn't listening on it) runs in full.
52
+ # Runner#thaw! does the filtering and the switch to live under one lock, so no message can
53
+ # land in between.
54
+ #
55
+ # Within a stream the adopter runs: list entries in relay order (= publish order, since the old
56
+ # host relays from its single Bus thread as it receives), then live messages after the marker,
57
+ # in publish order. Every message is in exactly one of those two groups. Order across streams
58
+ # was never guaranteed and still isn't.
59
+ #
60
+ # If the old host dies after `adopted` and never publishes `released`, the adopter waits
61
+ # `handoff_timeout`, then replays whatever is in the list and runs everything it held: a few
62
+ # messages may run twice, none are lost except ones the dead host received and never relayed.
63
+ # Only a crash mid-handoff can do that.
64
+ class Migration
65
+ # The message the old host publishes on each inbound stream to mark where the list ends and
66
+ # live traffic begins. Never reaches a room's handler: the old host is frozen, the adopter
67
+ # filters it, and Runner#receive_inbound drops one that arrives anywhere else.
68
+ MARKER_TYPE = "cable_room:handoff_marker".freeze
69
+
70
+ SNAPSHOT_TTL = 60 # seconds; the design's number: a dead handoff cleans itself up
71
+
72
+ # A safety net only: the adopter deletes the list when it is done with it, so the TTL matters
73
+ # only when both hosts die mid-handoff.
74
+ HANDOFF_LIST_TTL = 10.minutes
75
+
76
+ FINAL_STATES = %i[migrated closed abandoned gone].freeze
77
+
78
+ class << self
79
+ def marker?(message)
80
+ message.is_a?(Hash) && message["type"] == MARKER_TYPE
81
+ end
82
+
83
+ # The receiving side: with the room's lock already claimed (`lock_info`) and the offered
84
+ # `snapshot` in hand, rebuild the room here, replay what the old host relayed, and take over
85
+ # its inbound (see the class comment for the ordering). `Host#ensure_room` calls this
86
+ # whenever it wins a lock while a snapshot is on offer — whether the request that led there
87
+ # was the old host's handoff or a member asking for the room — so a migrating room is never
88
+ # started fresh underneath the old host. Returns true once the room is live here. Errors are
89
+ # reported and answered with false (the lock released), never raised.
90
+ def adopt(host, room_class, room_key, lock_info:, snapshot:, handoff_timeout: nil)
91
+ Adoption.new(host, room_class, room_key, lock_info: lock_info, snapshot: snapshot, handoff_timeout: handoff_timeout).run
92
+ end
93
+ end
94
+
95
+ attr_reader :host, :runner, :reason, :state, :relayed_messages, :to_host, :error, :handoff_timeout
96
+
97
+ def initialize(host, runner, reason:, handoff_timeout: nil)
98
+ @host = host
99
+ @runner = runner
100
+ @reason = reason
101
+ @handoff_timeout = (handoff_timeout || CableRoom.config.handoff_timeout).to_f
102
+ @state = :pending
103
+ @relayed_messages = 0
104
+ @to_host = nil
105
+ @error = nil
106
+ @nonce = SecureRandom.hex(8)
107
+
108
+ @mutex = Mutex.new
109
+ @changed = ConditionVariable.new
110
+ @adopted = false
111
+ @marked_streams = []
112
+ @adopted_handle = nil
113
+ @started_at = nil
114
+ @finished_at = nil
115
+ end
116
+
117
+ def room
118
+ runner.room
119
+ end
120
+
121
+ def room_class
122
+ runner.room_class
123
+ end
124
+
125
+ def room_key
126
+ runner.key
127
+ end
128
+
129
+ # The room's cluster-wide identity ("RoomClass:key"): the lock key, and what the Bus names
130
+ # the handoff list, snapshot, and adopted channel after.
131
+ def room_id
132
+ room_class.room_port_key(room_key)
133
+ end
134
+
135
+ def finished?
136
+ FINAL_STATES.include?(state)
137
+ end
138
+
139
+ def migrated?
140
+ state == :migrated
141
+ end
142
+
143
+ # Seconds from freeze to the final state (or so far, while running).
144
+ def duration
145
+ return nil unless @started_at
146
+ (@finished_at || monotonic_now) - @started_at
147
+ end
148
+
149
+ # Run the whole handoff for this room, blocking. Never raises: whatever goes wrong, the room
150
+ # ends up either on a peer or closed with `room_closed`, and the error is reported. Returns
151
+ # the final state.
152
+ def run
153
+ @started_at = monotonic_now
154
+ logger.info "Migrating #{room_id} (#{reason})"
155
+
156
+ case freeze
157
+ when :gone then return finish(:gone)
158
+ when :closed then return finish(:closed)
159
+ end
160
+ snapshot = take_snapshot or return finish(:closed)
161
+ offer(snapshot)
162
+
163
+ case wait_for_adoption
164
+ when :adopted then complete_handoff
165
+ when :restarting then restart_room
166
+ when :abandoned then abandon
167
+ end
168
+ rescue => e
169
+ fail_with(e)
170
+ ensure
171
+ unsubscribe_adopted_channel
172
+ end
173
+
174
+ private
175
+
176
+ # -- The steps -----------------------------------------------------------------------------
177
+
178
+ # :pending → :frozen. A room that won't go quiet within handoff_timeout is closed instead
179
+ # (:closed): its members re-provision, and the drain moves on. One that died before we got
180
+ # to it is :gone.
181
+ def freeze
182
+ frozen = runner.freeze!(timeout: handoff_timeout) { |stream, message| relay(stream, message) }
183
+ unless frozen
184
+ return :gone if runner.state == :dead
185
+
186
+ logger.warn "#{room_id} would not freeze; closing it"
187
+ runner.stop!(reason: "Room could not be migrated: it did not go quiet")
188
+ return :closed
189
+ end
190
+
191
+ transition(:frozen)
192
+ :frozen
193
+ end
194
+
195
+ # :frozen → :snapshotted. A room whose `snapshot_state` isn't JSON (or raises) can't move;
196
+ # it closes with room_closed and the error is reported, and the drain moves on.
197
+ def take_snapshot
198
+ snapshot = runner.snapshot
199
+ transition(:snapshotted)
200
+ snapshot
201
+ rescue => e
202
+ @error = e
203
+ logger.error "#{room_id} can't be snapshotted (#{e.class}: #{e.message}); closing it"
204
+ CableRoom.report_error(e, migration: self, room: room, room_class: room_class, room_key: room_key)
205
+ runner.stop!(reason: "Room could not be migrated: #{e.class}")
206
+ nil
207
+ end
208
+
209
+ # :snapshotted → :offered. Listen for the peer first, so its `claimed` can't beat us to the
210
+ # channel; park the snapshot; let the lock go; ask the fleet. Our own Placement hears the
211
+ # request too and ignores it, since this host is draining.
212
+ def offer(snapshot)
213
+ @adopted_handle = host.inbound.subscribe(adopted_channel) { |message| on_adopted_channel(message) }
214
+ bus.set(snapshot_key, snapshot, ttl: SNAPSHOT_TTL)
215
+ runner.release_lock!
216
+ bus.publish(Bus.provision_channel, {
217
+ type: Placement::REQUEST_TYPE,
218
+ handoff: true,
219
+ reason: reason,
220
+ room_class: room_class.name,
221
+ room_key: ::ActiveJob::Arguments.serialize([room_key]),
222
+ snapshot_key: snapshot_key,
223
+ from_host: host.id,
224
+ requested_at: Time.current,
225
+ })
226
+ transition(:offered)
227
+ end
228
+
229
+ # Wait for `adopted`. When handoff_timeout passes without it, try to take the lock back: free
230
+ # means no peer is on the room (:restarting). Held means a peer claimed it and is still
231
+ # restoring, or died holding it — a dead peer's lock lapses within LOCK_DURATION, so keep
232
+ # waiting until even that would have happened, then leave the room to whoever holds the lock
233
+ # (:abandoned).
234
+ def wait_for_adoption
235
+ deadline = monotonic_now + handoff_timeout
236
+ hard_deadline = deadline + room_class::LOCK_DURATION.to_f + handoff_timeout
237
+
238
+ loop do
239
+ wait_until(deadline) { @adopted }
240
+ return :adopted if @mutex.synchronize { @adopted }
241
+
242
+ if runner.retake_lock!
243
+ logger.warn "No host adopted #{room_id} within #{handoff_timeout}s; restarting it"
244
+ transition(:restarting)
245
+ return :restarting
246
+ end
247
+
248
+ return :abandoned if monotonic_now >= hard_deadline
249
+
250
+ logger.info "#{room_id}: a peer holds the lock but hasn't finished adopting; waiting"
251
+ deadline = [monotonic_now + handoff_timeout, hard_deadline].min
252
+ end
253
+ end
254
+
255
+ # :adopted → :migrated. Mark every stream, wait for the markers to come back through `relay`
256
+ # (see the class comment), let go of the room, and tell the peer the list is complete.
257
+ def complete_handoff
258
+ streams = runner.subscribed_streams
259
+ marker = { type: MARKER_TYPE, nonce: @nonce, from_host: host.id }
260
+ streams.each { |stream| bus.publish(stream, marker) }
261
+
262
+ deadline = monotonic_now + handoff_timeout
263
+ wait_until(deadline) { (streams - @marked_streams).empty? }
264
+ missing = @mutex.synchronize { streams - @marked_streams }
265
+ unless missing.empty?
266
+ # The adopter can't tell list entries from live ones on these streams; it runs both, so
267
+ # a few messages may run twice there. Better than holding a dead host's room forever.
268
+ logger.warn "#{room_id}: no marker came back on #{missing.join(', ')} within #{handoff_timeout}s"
269
+ end
270
+
271
+ # Unsubscribes (waiting for Redis to confirm), so nothing is relayed after `released`. The
272
+ # lock was released at the offer and belongs to the peer now; discard! leaves it alone.
273
+ runner.discard!
274
+
275
+ marked, relayed = @mutex.synchronize { [@marked_streams.dup, @relayed_messages] }
276
+ bus.publish(adopted_channel, { type: "released", host: host.id, nonce: @nonce, relayed: relayed, streams: marked })
277
+ bus.del(snapshot_key)
278
+
279
+ finish(:migrated)
280
+ ActiveSupport::Notifications.instrument("room_migrated.cable_room", {
281
+ room: room,
282
+ room_class: room_class,
283
+ room_key: room_key,
284
+ duration: duration,
285
+ relayed_messages: relayed,
286
+ to_host: to_host,
287
+ from_host: host.id,
288
+ reason: reason,
289
+ })
290
+ logger.info "Migrated #{room_id} to #{to_host} in #{duration.round(3)}s, relaying #{relayed} message(s)"
291
+ state
292
+ end
293
+
294
+ # :restarting → :closed. We hold the lock again, so no late peer can claim the room. Stop it
295
+ # first (that unsubscribes, so nothing more lands in the list), then clean up the keys.
296
+ def restart_room
297
+ runner.stop!(reason: "Room could not be migrated: no host adopted it")
298
+ bus.del(snapshot_key, handoff_list)
299
+ finish(:closed)
300
+ end
301
+
302
+ # A peer took the lock and never said `adopted`. The room is theirs now, or will be free
303
+ # again when their lock lapses; either way it isn't ours. Leave the list for them (it has a
304
+ # TTL) and go quietly.
305
+ def abandon
306
+ error = RuntimeError.new("#{room_id}: a peer claimed the room but never adopted it; giving it up")
307
+ @error = error
308
+ CableRoom.report_error(error, migration: self, room: room, room_class: room_class, room_key: room_key)
309
+ runner.discard!
310
+ finish(:abandoned)
311
+ end
312
+
313
+ # A bug, or Redis failing mid-handoff. Whatever state the room is in, its members must not be
314
+ # left hanging on a frozen room: take the lock back if we let it go and close the room. If a
315
+ # peer holds the lock by now, the room is theirs and closing ours would tell its members the
316
+ # wrong thing; just drop it.
317
+ def fail_with(error)
318
+ @error = error
319
+ logger.error "Migrating #{room_id} failed: #{error.class}: #{error.message}"
320
+ CableRoom.report_error(error, migration: self, room: room, room_class: room_class, room_key: room_key)
321
+ begin
322
+ if runner.state == :dead
323
+ nil
324
+ elsif runner.lock_info || runner.retake_lock!
325
+ runner.stop!(reason: "Room could not be migrated: #{error.class}")
326
+ bus.del(snapshot_key, handoff_list)
327
+ else
328
+ runner.discard!
329
+ end
330
+ rescue => cleanup_error
331
+ CableRoom.report_error(cleanup_error, migration: self, room: room)
332
+ end
333
+ finish(:closed)
334
+ end
335
+
336
+ # -- On the Bus thread ---------------------------------------------------------------------
337
+
338
+ # The `freeze!` block: every inbound message while the room is frozen, on the Bus thread, in
339
+ # arrival order. Relayed to the list until the stream's marker comes back; from then on the
340
+ # adopter has that stream live. Our own marker is the signal that a stream is done. Keep
341
+ # this quick: one RPUSH.
342
+ def relay(stream, message)
343
+ if self.class.marker?(message)
344
+ return unless message["nonce"] == @nonce
345
+
346
+ @mutex.synchronize do
347
+ @marked_streams << stream unless @marked_streams.include?(stream)
348
+ @changed.broadcast
349
+ end
350
+ return
351
+ end
352
+
353
+ seq = @mutex.synchronize do
354
+ next nil if @marked_streams.include?(stream)
355
+ @relayed_messages += 1
356
+ end
357
+ return unless seq
358
+
359
+ bus.rpush(handoff_list, { seq: seq, stream: stream, message: message }, ttl: HANDOFF_LIST_TTL)
360
+ end
361
+
362
+ def on_adopted_channel(message)
363
+ return unless message.is_a?(Hash)
364
+
365
+ case message["type"]
366
+ when "claimed"
367
+ @mutex.synchronize do
368
+ @to_host = message["host"]
369
+ @state = :relaying if @state == :offered
370
+ @changed.broadcast
371
+ end
372
+ when "adopted"
373
+ @mutex.synchronize do
374
+ @to_host = message["host"]
375
+ @adopted = true
376
+ @state = :adopted unless finished?
377
+ @changed.broadcast
378
+ end
379
+ end
380
+ end
381
+
382
+ # -- Plumbing ------------------------------------------------------------------------------
383
+
384
+ def transition(new_state)
385
+ @mutex.synchronize { @state = new_state }
386
+ logger.debug "#{room_id}: #{new_state}"
387
+ end
388
+
389
+ def finish(new_state)
390
+ @finished_at = monotonic_now
391
+ transition(new_state)
392
+ new_state
393
+ end
394
+
395
+ # Block until the block is true (evaluated under the mutex) or `deadline` passes.
396
+ def wait_until(deadline)
397
+ @mutex.synchronize do
398
+ until yield
399
+ remaining = deadline - monotonic_now
400
+ break if remaining <= 0
401
+ @changed.wait(@mutex, remaining)
402
+ end
403
+ end
404
+ end
405
+
406
+ def unsubscribe_adopted_channel
407
+ handle = @adopted_handle
408
+ @adopted_handle = nil
409
+ host.inbound.unsubscribe(adopted_channel, handle) if handle
410
+ rescue => e
411
+ CableRoom.report_error(e, migration: self, room: room)
412
+ end
413
+
414
+ def handoff_list
415
+ Bus.handoff_list(room_id)
416
+ end
417
+
418
+ def snapshot_key
419
+ Bus.snapshot_key(room_id)
420
+ end
421
+
422
+ def adopted_channel
423
+ Bus.adopted_channel(room_id)
424
+ end
425
+
426
+ def bus
427
+ host.bus
428
+ end
429
+
430
+ def logger
431
+ host.logger
432
+ end
433
+
434
+ def monotonic_now
435
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
436
+ end
437
+
438
+ # The receiving side of one handoff. See the class comment on Migration for the ordering it
439
+ # relies on; the numbered steps there are the numbered comments here.
440
+ class Adoption
441
+ attr_reader :host, :room_class, :room_key, :lock_info, :snapshot
442
+
443
+ def initialize(host, room_class, room_key, lock_info:, snapshot:, handoff_timeout: nil)
444
+ @host = host
445
+ @room_class = room_class
446
+ @room_key = room_key
447
+ @lock_info = lock_info
448
+ @snapshot = snapshot
449
+ @handoff_timeout = (handoff_timeout || CableRoom.config.handoff_timeout).to_f
450
+ @mutex = Mutex.new
451
+ @changed = ConditionVariable.new
452
+ @released = nil
453
+ @handle = nil
454
+ end
455
+
456
+ def room_id
457
+ room_class.room_port_key(room_key)
458
+ end
459
+
460
+ def run
461
+ # Listen before saying anything, so `released` can't slip past us
462
+ @handle = host.inbound.subscribe(adopted_channel) { |message| on_adopted_channel(message) }
463
+ bus.publish(adopted_channel, { type: "claimed", host: host.id })
464
+
465
+ # (1) Restore holding inbound: subscribed, but nothing runs yet. On failure the lock is
466
+ # released for us (Host#restore_room) and the old host takes the room back.
467
+ runner = host.restore_room(snapshot, lock_info, hold_inbound: true)
468
+
469
+ first_batch = bus.lrange(handoff_list)
470
+ first_batch.each { |entry| runner.inject(entry["stream"], entry["message"]) }
471
+ bus.publish(adopted_channel, { type: "adopted", host: host.id })
472
+
473
+ # (2)+(3) happen on the old host. (4): wait for `released`, replay the rest of the list,
474
+ # and go live, dropping the held messages that the list already covered.
475
+ released = wait_for_released
476
+ rest = bus.lrange(handoff_list).drop(first_batch.size)
477
+ marked_streams = []
478
+ nonce = nil
479
+ if released
480
+ rest = rest.first([released["relayed"].to_i - first_batch.size, 0].max)
481
+ marked_streams = Array(released["streams"])
482
+ nonce = released["nonce"]
483
+ else
484
+ logger.warn "#{room_id}: the old host never said it released the room; running everything held " \
485
+ "(a few messages may run twice)"
486
+ end
487
+
488
+ runner.thaw! do |held|
489
+ rest.map { |entry| [entry["stream"], entry["message"]] } + live_after_markers(held, marked_streams, nonce)
490
+ end
491
+
492
+ bus.del(handoff_list)
493
+ logger.info "Adopted #{room_id} from #{released&.dig('host') || 'its old host'}: replayed " \
494
+ "#{first_batch.size + rest.size} relayed message(s)"
495
+ true
496
+ rescue => e
497
+ logger.error "Adopting #{room_id} failed: #{e.class}: #{e.message}"
498
+ CableRoom.report_error(e, adoption: self, room_class: room_class, room_key: room_key)
499
+ recover(runner)
500
+ ensure
501
+ host.inbound.unsubscribe(adopted_channel, @handle) if @handle
502
+ end
503
+
504
+ private
505
+
506
+ # The held live messages that the list didn't cover: on a marked stream, only what came
507
+ # after the marker; on an unmarked stream, everything. Markers themselves never run.
508
+ def live_after_markers(held, marked_streams, nonce)
509
+ seen_marker = {}
510
+ held.select do |stream, message|
511
+ if Migration.marker?(message)
512
+ seen_marker[stream] = true if nonce.nil? || message["nonce"] == nonce
513
+ false
514
+ elsif marked_streams.include?(stream)
515
+ seen_marker[stream]
516
+ else
517
+ true
518
+ end
519
+ end
520
+ end
521
+
522
+ def wait_for_released
523
+ deadline = monotonic_now + @handoff_timeout
524
+ @mutex.synchronize do
525
+ until @released
526
+ remaining = deadline - monotonic_now
527
+ break if remaining <= 0
528
+ @changed.wait(@mutex, remaining)
529
+ end
530
+ @released
531
+ end
532
+ end
533
+
534
+ def on_adopted_channel(message)
535
+ return unless message.is_a?(Hash) && message["type"] == "released"
536
+
537
+ @mutex.synchronize do
538
+ @released = message
539
+ @changed.broadcast
540
+ end
541
+ end
542
+
543
+ # Something broke. After the room was restored here it holds the lock and its members'
544
+ # ports, so it must not stay frozen: run it with everything it held (at least once beats
545
+ # never). If the failure came before or during the restore, make sure the lock is free
546
+ # (Host#restore_room has usually done it; unlocking twice is harmless) so the old host can
547
+ # take the room back, and answer false: nothing was claimed.
548
+ def recover(runner)
549
+ if runner.nil? || runner.state == :dead
550
+ CableRoom.lock_manager.unlock(lock_info)
551
+ return false
552
+ end
553
+
554
+ runner.thaw!
555
+ true
556
+ rescue => e
557
+ CableRoom.report_error(e, adoption: self, room_class: room_class, room_key: room_key)
558
+ false
559
+ end
560
+
561
+ def handoff_list
562
+ Bus.handoff_list(room_id)
563
+ end
564
+
565
+ def snapshot_key
566
+ Bus.snapshot_key(room_id)
567
+ end
568
+
569
+ def adopted_channel
570
+ Bus.adopted_channel(room_id)
571
+ end
572
+
573
+ def bus
574
+ host.bus
575
+ end
576
+
577
+ def logger
578
+ host.logger
579
+ end
580
+
581
+ def monotonic_now
582
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
583
+ end
584
+ end
585
+ end
586
+ end
@@ -0,0 +1,18 @@
1
+ module CableRoom
2
+ # A running `periodically` timer or reaper. Rooms stop timers with #shutdown, but Rufus jobs
3
+ # are cancelled with #unschedule. Adapt the one to the other here rather than adding #shutdown
4
+ # to Rufus::Scheduler::Job for the whole process.
5
+ class PeriodicTimer
6
+ attr_reader :job
7
+
8
+ delegate :unschedule, :scheduled?, :next_time, to: :job
9
+
10
+ def initialize(job)
11
+ @job = job
12
+ end
13
+
14
+ def shutdown
15
+ job.unschedule
16
+ end
17
+ end
18
+ end