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,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
|