cable_room 0.7.0.beta3 → 0.8.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/README.md +0 -1287
- data/cable_room.gemspec +2 -5
- data/lib/cable_room/host/action_cable_inbound.rb +35 -0
- data/lib/cable_room/host/runner.rb +109 -391
- data/lib/cable_room/host/worker_pool.rb +27 -40
- data/lib/cable_room/host.rb +18 -364
- data/lib/cable_room/ports.rb +9 -19
- data/lib/cable_room/railtie.rb +11 -3
- data/lib/cable_room/room/base.rb +41 -40
- data/lib/cable_room/room/callbacks.rb +0 -22
- data/lib/cable_room/room/host_adapter.rb +4 -9
- data/lib/cable_room/room/lifecycle.rb +6 -23
- data/lib/cable_room/room/port_management.rb +0 -50
- data/lib/cable_room/room/reaping.rb +0 -33
- data/lib/cable_room/room/user_management.rb +0 -27
- data/lib/cable_room/room.rb +1 -4
- data/lib/cable_room/room_member.rb +84 -275
- data/lib/cable_room/room_proxy_channel.rb +2 -13
- data/lib/cable_room/version.rb +1 -1
- data/lib/cable_room.rb +0 -55
- metadata +7 -21
- data/CHANGELOG.md +0 -122
- data/exe/cable_room +0 -8
- data/lib/cable_room/broadcaster.rb +0 -116
- data/lib/cable_room/bus.rb +0 -372
- data/lib/cable_room/cli.rb +0 -237
- data/lib/cable_room/config.rb +0 -112
- data/lib/cable_room/host/bus_inbound.rb +0 -36
- data/lib/cable_room/host/supervisor.rb +0 -275
- data/lib/cable_room/membership_store.rb +0 -105
- data/lib/cable_room/migration.rb +0 -586
- data/lib/cable_room/placement.rb +0 -260
- data/lib/cable_room/room/snapshotting.rb +0 -82
- data/lib/cable_room/room_harness.rb +0 -168
- data/lib/cable_room/snapshot.rb +0 -136
data/lib/cable_room/placement.rb
DELETED
|
@@ -1,260 +0,0 @@
|
|
|
1
|
-
require 'concurrent'
|
|
2
|
-
require 'active_support/inflector'
|
|
3
|
-
|
|
4
|
-
module CableRoom
|
|
5
|
-
# The host side of provisioning. A `create: true` membership publishes a provision request on
|
|
6
|
-
# `Bus.provision_channel`; every Host runs one Placement, which hears every request and decides
|
|
7
|
-
# whether this host should be the one to start the room.
|
|
8
|
-
#
|
|
9
|
-
# The decision is a race weighted by load. Each host waits `provision_delay_ms` for every room
|
|
10
|
-
# it already runs (plus up to one room's worth of jitter, to break ties), then tries to take the
|
|
11
|
-
# room's Redlock through `Host#ensure_room`. The lightest host wakes first and usually wins;
|
|
12
|
-
# everyone else finds the lock held and does nothing. A host that is draining or shut down never
|
|
13
|
-
# claims. The same code runs in :inline and :remote — the only difference is which process the
|
|
14
|
-
# Host lives in.
|
|
15
|
-
#
|
|
16
|
-
# Requests arrive on the Bus subscriber thread, which must stay quick, so `handle` only checks
|
|
17
|
-
# and schedules; the wait and the claim run later on the Host's worker pool via a timer. That way
|
|
18
|
-
# a long wait never blocks other Bus traffic, and a slow room startup never blocks another claim.
|
|
19
|
-
#
|
|
20
|
-
# Specs make the race deterministic by passing `jitter: -> { 0 }` (or any callable returning
|
|
21
|
-
# 0..1) and a small `provision_delay_ms`; `delay_for(load)` is the only place the numbers meet.
|
|
22
|
-
class Placement
|
|
23
|
-
# Raised (and reported, never propagated) for a request this host can't act on: not a hash, an
|
|
24
|
-
# unknown room class, a class that isn't a Room, or a key that won't deserialize.
|
|
25
|
-
class InvalidRequest < ArgumentError; end
|
|
26
|
-
|
|
27
|
-
REQUEST_TYPE = "provision".freeze
|
|
28
|
-
|
|
29
|
-
attr_reader :host, :channel
|
|
30
|
-
|
|
31
|
-
# `jitter` returns a Float in 0..1 (defaults to `rand`); `executor` is where the claim runs
|
|
32
|
-
# (defaults to the Host's worker pool); `config` defaults to `CableRoom.config`.
|
|
33
|
-
def initialize(host, jitter: nil, executor: nil, config: nil)
|
|
34
|
-
@host = host
|
|
35
|
-
@jitter = jitter || -> { Kernel.rand }
|
|
36
|
-
@executor = executor
|
|
37
|
-
@config = config
|
|
38
|
-
@channel = Bus.provision_channel
|
|
39
|
-
|
|
40
|
-
@mutex = Mutex.new
|
|
41
|
-
@idle = ConditionVariable.new
|
|
42
|
-
@pending = {} # room identity => the scheduled claim, so one host never queues two claims for one room
|
|
43
|
-
@running = false # subscribed to the channel
|
|
44
|
-
@stopped = false # `stop` was called; no claim may go through until `start` again
|
|
45
|
-
@handle = nil
|
|
46
|
-
end
|
|
47
|
-
|
|
48
|
-
# Subscribe to the provision channel on the Host's inbound transport. Blocks until the Bus
|
|
49
|
-
# confirms, so a request published after this returns is heard. Calling it again while running
|
|
50
|
-
# re-subscribes (harmless; the Bus replaces the handler), which lets a Host re-arm its listener.
|
|
51
|
-
def start
|
|
52
|
-
@mutex.synchronize do
|
|
53
|
-
@running = true
|
|
54
|
-
@stopped = false
|
|
55
|
-
end
|
|
56
|
-
@handle = host.inbound.subscribe(channel) { |request| handle(request) }
|
|
57
|
-
self
|
|
58
|
-
end
|
|
59
|
-
|
|
60
|
-
# Stop hearing requests and drop every claim still waiting on its timer. Waits (up to `wait`
|
|
61
|
-
# seconds) for a claim that is already mid-attempt to finish, so nothing starts a room after
|
|
62
|
-
# this returns. The unsubscribe blocks until Redis confirms it, which doubles as a barrier: a
|
|
63
|
-
# request published before the stop has either been handled or is gone for good.
|
|
64
|
-
def stop(wait: 5)
|
|
65
|
-
was_running, handle = @mutex.synchronize do
|
|
66
|
-
@stopped = true
|
|
67
|
-
[@running, @handle].tap do
|
|
68
|
-
@running = false
|
|
69
|
-
@handle = nil
|
|
70
|
-
end
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
host.inbound.unsubscribe(channel, handle) if was_running
|
|
74
|
-
|
|
75
|
-
# A cancelled timer never runs its block (and so never runs the `finish` in it), so forget
|
|
76
|
-
# it here. A claim that already started keeps its entry until it finishes; wait for those.
|
|
77
|
-
tasks = @mutex.synchronize { @pending.to_a }
|
|
78
|
-
tasks.each { |room_id, task| finish(room_id) if task.cancel }
|
|
79
|
-
wait_idle(timeout: wait)
|
|
80
|
-
self
|
|
81
|
-
end
|
|
82
|
-
|
|
83
|
-
def running?
|
|
84
|
-
@mutex.synchronize { @running }
|
|
85
|
-
end
|
|
86
|
-
|
|
87
|
-
def stopped?
|
|
88
|
-
@mutex.synchronize { @stopped }
|
|
89
|
-
end
|
|
90
|
-
|
|
91
|
-
# How many claims are waiting on their timer or mid-attempt right now.
|
|
92
|
-
def pending_count
|
|
93
|
-
@mutex.synchronize { @pending.size }
|
|
94
|
-
end
|
|
95
|
-
|
|
96
|
-
def idle?
|
|
97
|
-
pending_count.zero?
|
|
98
|
-
end
|
|
99
|
-
|
|
100
|
-
# Block until every scheduled claim has finished (won, lost, or been cancelled). Returns true,
|
|
101
|
-
# or false if `timeout` passes first. Specs use this to observe a losing host give up.
|
|
102
|
-
def wait_idle(timeout: 5)
|
|
103
|
-
deadline = monotonic_now + timeout
|
|
104
|
-
@mutex.synchronize do
|
|
105
|
-
until @pending.empty?
|
|
106
|
-
remaining = deadline - monotonic_now
|
|
107
|
-
return false if remaining <= 0
|
|
108
|
-
@idle.wait(@mutex, remaining)
|
|
109
|
-
end
|
|
110
|
-
end
|
|
111
|
-
true
|
|
112
|
-
end
|
|
113
|
-
|
|
114
|
-
# One request off the Bus. Runs on the subscriber thread, so it does only the cheap checks
|
|
115
|
-
# (is this a room we know, do we already run it, are we allowed to claim) and then schedules
|
|
116
|
-
# the delayed claim. Returns the scheduled task, or nil when there is nothing to do.
|
|
117
|
-
def handle(request)
|
|
118
|
-
room_class, room_key = parse(request)
|
|
119
|
-
room_id = room_class.room_port_key(room_key)
|
|
120
|
-
|
|
121
|
-
return skip(room_id, "placement is stopped") if stopped?
|
|
122
|
-
return skip(room_id, "host is shutting down") if host.shutdown?
|
|
123
|
-
return skip(room_id, "host is draining") if host.draining?
|
|
124
|
-
return skip(room_id, "already running here") if running_here?(room_id)
|
|
125
|
-
|
|
126
|
-
load = host.open_rooms_count
|
|
127
|
-
delay = delay_for(load)
|
|
128
|
-
|
|
129
|
-
# A handoff request gets a claim of its own even while a member's plain request for the
|
|
130
|
-
# same room is pending: the plain claim may have lost the lock to the old host moments
|
|
131
|
-
# before it let go, and the handoff must not be skipped for it. The lock keeps them safe.
|
|
132
|
-
pending_key = request["handoff"] == true ? "#{room_id} (handoff)" : room_id
|
|
133
|
-
|
|
134
|
-
@mutex.synchronize do
|
|
135
|
-
return skip(room_id, "a claim is already pending here") if @pending.key?(pending_key)
|
|
136
|
-
|
|
137
|
-
task = Concurrent::ScheduledTask.new(delay, executor: executor) do
|
|
138
|
-
begin
|
|
139
|
-
claim(room_class, room_key, room_id, request, delay: delay, load: load)
|
|
140
|
-
ensure
|
|
141
|
-
finish(pending_key)
|
|
142
|
-
end
|
|
143
|
-
end
|
|
144
|
-
@pending[pending_key] = task
|
|
145
|
-
task.execute
|
|
146
|
-
end
|
|
147
|
-
rescue InvalidRequest => e
|
|
148
|
-
CableRoom.report_error(e, placement: self, request: request)
|
|
149
|
-
nil
|
|
150
|
-
end
|
|
151
|
-
|
|
152
|
-
# Seconds to wait before racing for a room, given how many rooms this host already runs:
|
|
153
|
-
# `provision_delay_ms × (load + jitter)`, jitter in 0..1. A host with more rooms always waits
|
|
154
|
-
# longer than one with fewer, and the jitter only decides the order between hosts with the same
|
|
155
|
-
# load. A host running nothing waits at most one `provision_delay_ms`.
|
|
156
|
-
def delay_for(load)
|
|
157
|
-
base = config.provision_delay_ms / 1000.0
|
|
158
|
-
base * (load + @jitter.call.to_f)
|
|
159
|
-
end
|
|
160
|
-
|
|
161
|
-
private
|
|
162
|
-
|
|
163
|
-
# The claim itself, after the delay, on the worker pool. Re-checks the cheap conditions first,
|
|
164
|
-
# since the world may have moved while we waited, then races for the Redlock. Losing is
|
|
165
|
-
# normal — it means a lighter host got there first — so it's only a debug line.
|
|
166
|
-
def claim(room_class, room_key, room_id, request, delay:, load:)
|
|
167
|
-
return if stopped?
|
|
168
|
-
return if host.shutdown? || host.draining?
|
|
169
|
-
return if running_here?(room_id)
|
|
170
|
-
|
|
171
|
-
payload = {
|
|
172
|
-
host: host,
|
|
173
|
-
room_class: room_class,
|
|
174
|
-
room_key: room_key,
|
|
175
|
-
delay: delay,
|
|
176
|
-
open_rooms: load,
|
|
177
|
-
request: request,
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
claimed = ActiveSupport::Notifications.instrument("provision_claimed.cable_room", payload) do |notification|
|
|
181
|
-
# `handoff: true` (with a `reason`) is a draining host offering a room to its peers. The
|
|
182
|
-
# race is the same — the lock decides — but the winner rebuilds the room from the offered
|
|
183
|
-
# snapshot and replays what the old host relayed instead of starting it from scratch (see
|
|
184
|
-
# CableRoom::Migration.adopt). A plain request starts the room fresh. A handoff request
|
|
185
|
-
# carries no `tenant` of its own -- the snapshot it adopts from carries it instead (see
|
|
186
|
-
# Room::Snapshotting#_snapshot), so `ensure_room` only needs it for the fresh-start path.
|
|
187
|
-
handoff = request["handoff"] == true
|
|
188
|
-
notification[:handoff] = handoff
|
|
189
|
-
notification[:claimed] = host.ensure_room(room_class, room_key, handoff_only: handoff, tenant: request["tenant"])
|
|
190
|
-
end
|
|
191
|
-
|
|
192
|
-
if claimed
|
|
193
|
-
logger.info "Claimed #{room_id} after #{(delay * 1000).round}ms with #{load} room(s) open"
|
|
194
|
-
else
|
|
195
|
-
logger.debug "Lost the race for #{room_id}: another host holds its lock"
|
|
196
|
-
end
|
|
197
|
-
rescue => e
|
|
198
|
-
logger.error "Failed to claim #{room_id}: #{e.class}: #{e.message}"
|
|
199
|
-
CableRoom.report_error(e, placement: self, room_class: room_class, room_key: room_key, request: request)
|
|
200
|
-
end
|
|
201
|
-
|
|
202
|
-
def finish(room_id)
|
|
203
|
-
@mutex.synchronize do
|
|
204
|
-
@pending.delete(room_id)
|
|
205
|
-
@idle.broadcast if @pending.empty?
|
|
206
|
-
end
|
|
207
|
-
end
|
|
208
|
-
|
|
209
|
-
def skip(room_id, why)
|
|
210
|
-
logger.debug "Ignoring provision request for #{room_id}: #{why}"
|
|
211
|
-
nil
|
|
212
|
-
end
|
|
213
|
-
|
|
214
|
-
def running_here?(room_id)
|
|
215
|
-
host.runners.any? { |runner| runner.room_class.room_port_key(runner.key) == room_id }
|
|
216
|
-
end
|
|
217
|
-
|
|
218
|
-
# Turn a decoded Bus message into a Room class and a key. Only `Room::Base` descendants may be
|
|
219
|
-
# provisioned: the class name comes off the wire, and `constantize` on an arbitrary string is
|
|
220
|
-
# not something a request should be able to do.
|
|
221
|
-
def parse(request)
|
|
222
|
-
raise InvalidRequest, "provision request must be a Hash (got #{request.class})" unless request.is_a?(Hash)
|
|
223
|
-
|
|
224
|
-
type = request["type"]
|
|
225
|
-
raise InvalidRequest, "expected a #{REQUEST_TYPE.inspect} request (got #{type.inspect})" unless type == REQUEST_TYPE
|
|
226
|
-
|
|
227
|
-
name = request["room_class"]
|
|
228
|
-
klass = name.is_a?(String) ? ActiveSupport::Inflector.safe_constantize(name) : nil
|
|
229
|
-
unless klass.is_a?(Class) && klass < Room::Base
|
|
230
|
-
raise InvalidRequest, "#{name.inspect} is not a CableRoom::Room::Base subclass"
|
|
231
|
-
end
|
|
232
|
-
|
|
233
|
-
[klass, deserialize_key(request["room_key"])]
|
|
234
|
-
end
|
|
235
|
-
|
|
236
|
-
# Keys travel the way `extra` does (ActiveJob's argument serializer), so a record key comes
|
|
237
|
-
# back as the record and a string comes back as a string.
|
|
238
|
-
def deserialize_key(serialized)
|
|
239
|
-
::ActiveJob::Arguments.deserialize(Array(serialized)).first
|
|
240
|
-
rescue StandardError => e
|
|
241
|
-
raise InvalidRequest, "room_key can't be deserialized: #{e.class}: #{e.message}"
|
|
242
|
-
end
|
|
243
|
-
|
|
244
|
-
def executor
|
|
245
|
-
@executor || host.worker_pool.executor
|
|
246
|
-
end
|
|
247
|
-
|
|
248
|
-
def config
|
|
249
|
-
@config || CableRoom.config
|
|
250
|
-
end
|
|
251
|
-
|
|
252
|
-
def logger
|
|
253
|
-
host.logger
|
|
254
|
-
end
|
|
255
|
-
|
|
256
|
-
def monotonic_now
|
|
257
|
-
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
258
|
-
end
|
|
259
|
-
end
|
|
260
|
-
end
|
|
@@ -1,82 +0,0 @@
|
|
|
1
|
-
module CableRoom
|
|
2
|
-
module Room
|
|
3
|
-
# The room's side of a migration: turning itself into a CableRoom::Snapshot and coming back
|
|
4
|
-
# from one. The gem carries what it owns (ports, tags, users, reaper deadlines); a Room can
|
|
5
|
-
# carry its own state too with two optional hooks:
|
|
6
|
-
#
|
|
7
|
-
# class QuizRoom < CableRoom::Room::Base
|
|
8
|
-
# def snapshot_state
|
|
9
|
-
# { question_id: @question.id, answers: @answers } # JSON only; records by id
|
|
10
|
-
# end
|
|
11
|
-
#
|
|
12
|
-
# def restore_state(state)
|
|
13
|
-
# @question = Question.find(state[:question_id]) # indifferent access: :key or "key"
|
|
14
|
-
# @answers = state[:answers]
|
|
15
|
-
# end
|
|
16
|
-
# end
|
|
17
|
-
#
|
|
18
|
-
# A room that defines `restore_state` gets it instead of `startup` when it's restored, with
|
|
19
|
-
# whatever `snapshot_state` returned (nil if the room that was snapshotted had no
|
|
20
|
-
# `snapshot_state`). A room without `restore_state` just runs `startup` again and rebuilds its
|
|
21
|
-
# state the way it did the first time. Either way the `before_startup`/`after_startup`
|
|
22
|
-
# callbacks run as usual, except that the gem does not broadcast `room_opened`: the members
|
|
23
|
-
# were there the whole time and never saw the room go away. `restored?` tells a callback which
|
|
24
|
-
# case it's in.
|
|
25
|
-
module Snapshotting
|
|
26
|
-
extend ActiveSupport::Concern
|
|
27
|
-
|
|
28
|
-
# True for a room that was rebuilt from a snapshot rather than started fresh.
|
|
29
|
-
def restored?
|
|
30
|
-
@_restored == true
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
private
|
|
34
|
-
|
|
35
|
-
# Build the snapshot (see CableRoom::Snapshot for the shape). Only meaningful once the room
|
|
36
|
-
# is frozen: nothing else may be touching its state while this reads it.
|
|
37
|
-
def _snapshot
|
|
38
|
-
document = {
|
|
39
|
-
version: Snapshot::VERSION,
|
|
40
|
-
room_class: self.class.name,
|
|
41
|
-
key: Snapshot.serialize_argument(key),
|
|
42
|
-
# Carried forward so the host that adopts this room (Host#restore_room) gets the same
|
|
43
|
-
# tenant this one was given at provisioning, instead of falling back to a guess (see
|
|
44
|
-
# Runner#initialize). nil for a room that was never given one either.
|
|
45
|
-
tenant: @runner.tenant,
|
|
46
|
-
port_clients: _snapshot_port_clients,
|
|
47
|
-
user_state: _snapshot_user_state,
|
|
48
|
-
reaper_state: _snapshot_reaper_state,
|
|
49
|
-
app_state: _snapshot_app_state,
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
ActiveSupport::Notifications.instrument("room_snapshotted.cable_room", { room: self }) do
|
|
53
|
-
Snapshot.round_trip(document)
|
|
54
|
-
end
|
|
55
|
-
end
|
|
56
|
-
|
|
57
|
-
def _snapshot_app_state
|
|
58
|
-
return nil unless respond_to?(:snapshot_state, true)
|
|
59
|
-
|
|
60
|
-
state = snapshot_state
|
|
61
|
-
Snapshot.assert_json!(state, room: self)
|
|
62
|
-
state
|
|
63
|
-
end
|
|
64
|
-
|
|
65
|
-
# Rebuild from `snapshot`: gem state first, quietly (no port_connected or user_joined
|
|
66
|
-
# callbacks, since none of it is new), then the startup chain with `restore_state` in place
|
|
67
|
-
# of `startup`. Runs where `_startup` would, before the runner starts the periodic timers.
|
|
68
|
-
def _restore(snapshot)
|
|
69
|
-
snapshot = Snapshot.validate!(snapshot)
|
|
70
|
-
@_restored = true
|
|
71
|
-
|
|
72
|
-
_restore_port_clients(snapshot["port_clients"])
|
|
73
|
-
_restore_user_state(snapshot["user_state"])
|
|
74
|
-
_restore_reaper_state(snapshot["reaper_state"])
|
|
75
|
-
|
|
76
|
-
app_state = snapshot["app_state"]
|
|
77
|
-
app_state = app_state.with_indifferent_access if app_state.is_a?(Hash)
|
|
78
|
-
_startup(restoring: true, app_state: app_state)
|
|
79
|
-
end
|
|
80
|
-
end
|
|
81
|
-
end
|
|
82
|
-
end
|
|
@@ -1,168 +0,0 @@
|
|
|
1
|
-
require "cable_room"
|
|
2
|
-
require "active_support/tagged_logging"
|
|
3
|
-
|
|
4
|
-
module CableRoom
|
|
5
|
-
# Builds a Room without a Host, a live pubsub subscription, or a Redis lock, so the parts of a
|
|
6
|
-
# Room that are pure logic (authorization, port scoping, callbacks, reaping) can be tested
|
|
7
|
-
# directly and synchronously. Anything that needs real message delivery belongs in an e2e spec.
|
|
8
|
-
#
|
|
9
|
-
# Not loaded by `require "cable_room"` -- an app (or this gem's own suite) opts in with
|
|
10
|
-
# `require "cable_room/room_harness"`, typically from spec_helper.
|
|
11
|
-
#
|
|
12
|
-
# Stands in for CableRoom::Host::Runner.
|
|
13
|
-
class StubRoomRunner
|
|
14
|
-
attr_reader :logger, :state, :shutdown_reason, :watchdog_pings, :tenant
|
|
15
|
-
|
|
16
|
-
def initialize(tenant: nil)
|
|
17
|
-
@logger = ActiveSupport::TaggedLogging.new(Logger.new(IO::NULL))
|
|
18
|
-
@state = :started
|
|
19
|
-
@watchdog_pings = 0
|
|
20
|
-
@timers = []
|
|
21
|
-
@tenant = tenant
|
|
22
|
-
end
|
|
23
|
-
|
|
24
|
-
def ping_watchdog
|
|
25
|
-
@watchdog_pings += 1
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
# Record streams rather than subscribing, so startup can run without a live pubsub. There is
|
|
29
|
-
# nothing to wait for, so `on_live` runs at once.
|
|
30
|
-
def subscribe(stream, on_live: nil, &blk)
|
|
31
|
-
streams[stream] = blk
|
|
32
|
-
on_live&.call
|
|
33
|
-
end
|
|
34
|
-
|
|
35
|
-
def unsubscribe(stream)
|
|
36
|
-
streams.delete(stream)
|
|
37
|
-
end
|
|
38
|
-
|
|
39
|
-
def streams
|
|
40
|
-
@streams ||= {}
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
# Feed a message to whatever the room streamed from this port, the way pubsub would
|
|
44
|
-
def deliver_to_stream(stream, message)
|
|
45
|
-
streams.fetch(stream).call(message)
|
|
46
|
-
end
|
|
47
|
-
|
|
48
|
-
def initiate_shutdown(reason)
|
|
49
|
-
@shutdown_reason = reason
|
|
50
|
-
@state = :shutting_down
|
|
51
|
-
end
|
|
52
|
-
|
|
53
|
-
def stop!
|
|
54
|
-
@state = :dead
|
|
55
|
-
end
|
|
56
|
-
|
|
57
|
-
# Run inline so specs stay synchronous
|
|
58
|
-
def post_work(**_kwargs, &blk)
|
|
59
|
-
blk.call
|
|
60
|
-
end
|
|
61
|
-
|
|
62
|
-
# Record timers rather than scheduling them, so specs can fire them on demand
|
|
63
|
-
def start_periodic_timer(callback, every:)
|
|
64
|
-
timer = StubTimer.new(callback, every)
|
|
65
|
-
@timers << timer
|
|
66
|
-
timer
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
def timers
|
|
70
|
-
@timers.reject(&:shutdown?)
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
class StubTimer
|
|
74
|
-
attr_reader :interval
|
|
75
|
-
|
|
76
|
-
def initialize(callback, interval)
|
|
77
|
-
@callback = callback
|
|
78
|
-
@interval = interval
|
|
79
|
-
@shutdown = false
|
|
80
|
-
end
|
|
81
|
-
|
|
82
|
-
def fire!
|
|
83
|
-
@callback.call
|
|
84
|
-
end
|
|
85
|
-
|
|
86
|
-
def shutdown
|
|
87
|
-
@shutdown = true
|
|
88
|
-
end
|
|
89
|
-
|
|
90
|
-
def shutdown?
|
|
91
|
-
@shutdown
|
|
92
|
-
end
|
|
93
|
-
end
|
|
94
|
-
end
|
|
95
|
-
|
|
96
|
-
module RoomHarness
|
|
97
|
-
# A fully-initialized Room backed by a StubRoomRunner. Pass `started: true` to run the
|
|
98
|
-
# startup callbacks (which is what registers reaper timers and the inbound stream). Pass
|
|
99
|
-
# `tenant:` the way a member's provision request would (see RoomMembership#initialize) for a
|
|
100
|
-
# Room whose logic needs Apartment scoped correctly -- this switches into it for the duration
|
|
101
|
-
# of startup, the way PandaPal's ActionCable::Server::Worker hook does in production for a
|
|
102
|
-
# real Runner, so a Room can't tell the difference from here.
|
|
103
|
-
def build_room(room_class, key: "harness-key", started: false, tenant: nil)
|
|
104
|
-
runner = StubRoomRunner.new(tenant: tenant)
|
|
105
|
-
room = room_class.allocate
|
|
106
|
-
room.send(:initialize, runner, key)
|
|
107
|
-
runner.define_singleton_method(:room) { room }
|
|
108
|
-
with_room_tenant(runner) { room.send(:_startup) } if started
|
|
109
|
-
room
|
|
110
|
-
end
|
|
111
|
-
|
|
112
|
-
# A Room rebuilt from `snapshot` on a fresh StubRoomRunner, the way Host#restore_room would
|
|
113
|
-
# build it: gem state restored, then the startup chain with `restore_state` in place of
|
|
114
|
-
# `startup`. Pass the class explicitly so a spec reads as "restore a FooRoom", even though the
|
|
115
|
-
# snapshot names it too. `tenant` comes off the snapshot itself, same as Host#restore_room.
|
|
116
|
-
def restore_room(room_class, snapshot)
|
|
117
|
-
runner = StubRoomRunner.new(tenant: snapshot["tenant"])
|
|
118
|
-
room = room_class.allocate
|
|
119
|
-
room.send(:initialize, runner, Snapshot.key_for(snapshot))
|
|
120
|
-
runner.define_singleton_method(:room) { room }
|
|
121
|
-
with_room_tenant(runner) { room.send(:_restore, snapshot) }
|
|
122
|
-
room
|
|
123
|
-
end
|
|
124
|
-
|
|
125
|
-
def stub_runner_for(room)
|
|
126
|
-
room.instance_variable_get(:@runner)
|
|
127
|
-
end
|
|
128
|
-
|
|
129
|
-
# Push a message through the same callback chain the real stream handler uses.
|
|
130
|
-
# Returns false when authorization halted the chain, otherwise :handled.
|
|
131
|
-
def deliver(room, message)
|
|
132
|
-
message = message.deep_stringify_keys if message.is_a?(Hash)
|
|
133
|
-
Room.current_message = message
|
|
134
|
-
with_room_tenant(stub_runner_for(room)) do
|
|
135
|
-
room.send(:run_callbacks, :receive_message) do
|
|
136
|
-
room.send(:handle_received_message, message)
|
|
137
|
-
:handled
|
|
138
|
-
end
|
|
139
|
-
end
|
|
140
|
-
ensure
|
|
141
|
-
Room.current_message = nil
|
|
142
|
-
end
|
|
143
|
-
|
|
144
|
-
# Register a port on the room the way a real RoomMembership would, returning its token.
|
|
145
|
-
def connect_port(room, token: SecureRandom.hex(16), tags: [], as: nil)
|
|
146
|
-
message = { type: 'port_connected', mtok: token, tags: tags }
|
|
147
|
-
message[:extra] = ::ActiveJob::Arguments.serialize([{ as: as }]) if as
|
|
148
|
-
deliver(room, message)
|
|
149
|
-
token
|
|
150
|
-
end
|
|
151
|
-
|
|
152
|
-
def port_client(room, token)
|
|
153
|
-
room.send(:resolve_client_port, token)
|
|
154
|
-
end
|
|
155
|
-
|
|
156
|
-
private
|
|
157
|
-
|
|
158
|
-
# Stands in for PandaPal's ActionCable::Server::Worker hook, which is what actually switches
|
|
159
|
-
# Apartment before a Host thread's DB calls in production. A Room without a `tenant` (a
|
|
160
|
-
# single-tenant app, or a spec that doesn't need one) gets no-op behavior; the gem itself
|
|
161
|
-
# stays Apartment-optional either way.
|
|
162
|
-
def with_room_tenant(runner, &blk)
|
|
163
|
-
return blk.call unless runner.tenant && defined?(Apartment)
|
|
164
|
-
|
|
165
|
-
Apartment::Tenant.switch(runner.tenant, &blk)
|
|
166
|
-
end
|
|
167
|
-
end
|
|
168
|
-
end
|
data/lib/cable_room/snapshot.rb
DELETED
|
@@ -1,136 +0,0 @@
|
|
|
1
|
-
module CableRoom
|
|
2
|
-
# The gem-owned picture of a running room, as one JSON document, so a room can be rebuilt on
|
|
3
|
-
# another host with its members none the wiser. `take` produces it from a frozen room (see
|
|
4
|
-
# Host::Runner#freeze!); `Host#restore_room` consumes it. Version 1 looks like this (string
|
|
5
|
-
# keys, because it has been through JSON):
|
|
6
|
-
#
|
|
7
|
-
# {
|
|
8
|
-
# "version" => 1,
|
|
9
|
-
# "room_class" => "QuizRoom",
|
|
10
|
-
# "key" => <room key>, # ActiveJob-serialized, so a record key travels as a GlobalID
|
|
11
|
-
# "port_clients" => [
|
|
12
|
-
# {
|
|
13
|
-
# "token" => "3f9a...",
|
|
14
|
-
# "tags" => ["admin"],
|
|
15
|
-
# "as" => <user or nil>, # ActiveJob-serialized (GlobalID for records)
|
|
16
|
-
# "last_seen_at" => "2026-08-27T18:02:11.123456Z",
|
|
17
|
-
# "metadata" => { ... } # everything else on the PortClient: what `extra:`
|
|
18
|
-
# } # merged in, plus anything the room set on it
|
|
19
|
-
# ],
|
|
20
|
-
# "user_state" => [{ "user" => <user>, "port_tokens" => ["3f9a..."] }],
|
|
21
|
-
# "reaper_state" => [{ "key" => "idle", "index" => 0, "last_keep_at" => "<ISO8601>" | nil }],
|
|
22
|
-
# "app_state" => <whatever the Room's snapshot_state returned, or nil>
|
|
23
|
-
# }
|
|
24
|
-
#
|
|
25
|
-
# `reaper_state` carries wall-clock times, not remaining durations, so a room restored on
|
|
26
|
-
# another host keeps the same deadline it had. `app_state` has to be JSON: plain hashes,
|
|
27
|
-
# arrays, strings, numbers, booleans, and nil. We check that here, at snapshot time, because a
|
|
28
|
-
# value that JSON would quietly turn into something else (a Time into a string, a record into
|
|
29
|
-
# its attributes) is a bug that would otherwise only show up in `restore_state` on some other
|
|
30
|
-
# machine.
|
|
31
|
-
module Snapshot
|
|
32
|
-
VERSION = 1
|
|
33
|
-
|
|
34
|
-
class Error < StandardError; end
|
|
35
|
-
|
|
36
|
-
# `snapshot_state` returned something JSON can't carry faithfully.
|
|
37
|
-
class NotSerializable < Error; end
|
|
38
|
-
|
|
39
|
-
# The snapshot was written by a gem version this one doesn't understand.
|
|
40
|
-
class UnknownVersion < Error; end
|
|
41
|
-
|
|
42
|
-
# The snapshot doesn't name a room class this process knows.
|
|
43
|
-
class UnknownRoomClass < Error; end
|
|
44
|
-
|
|
45
|
-
class << self
|
|
46
|
-
# The snapshot of `room`, already round-tripped through JSON, so what you get back is
|
|
47
|
-
# exactly what a restore will see after a trip through Redis.
|
|
48
|
-
def take(room)
|
|
49
|
-
room.send(:_snapshot)
|
|
50
|
-
end
|
|
51
|
-
|
|
52
|
-
# Check a snapshot before anything is built from it. Returns the snapshot with string keys,
|
|
53
|
-
# so callers can read it the same way whether it came straight from `take` or from Redis.
|
|
54
|
-
def validate!(snapshot)
|
|
55
|
-
snapshot = snapshot.to_h.deep_stringify_keys
|
|
56
|
-
version = snapshot["version"]
|
|
57
|
-
unless version == VERSION
|
|
58
|
-
raise UnknownVersion, "Snapshot version #{version.inspect} isn't supported (this gem writes version #{VERSION})"
|
|
59
|
-
end
|
|
60
|
-
|
|
61
|
-
snapshot
|
|
62
|
-
end
|
|
63
|
-
|
|
64
|
-
def room_class_for(snapshot)
|
|
65
|
-
name = snapshot["room_class"]
|
|
66
|
-
klass = name.to_s.safe_constantize
|
|
67
|
-
unless klass.is_a?(Class) && klass <= Room::Base
|
|
68
|
-
raise UnknownRoomClass, "Snapshot names #{name.inspect}, which isn't a CableRoom::Room::Base subclass here"
|
|
69
|
-
end
|
|
70
|
-
klass
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
def key_for(snapshot)
|
|
74
|
-
deserialize_argument(snapshot["key"])
|
|
75
|
-
end
|
|
76
|
-
|
|
77
|
-
# Encode and decode the whole document, so the result is the post-JSON shape (string keys,
|
|
78
|
-
# ISO8601 times) rather than the Ruby objects the room held.
|
|
79
|
-
def round_trip(document)
|
|
80
|
-
ActiveSupport::JSON.decode(ActiveSupport::JSON.encode(document))
|
|
81
|
-
end
|
|
82
|
-
|
|
83
|
-
# Walk `value` and raise NotSerializable at the first thing JSON can't carry unchanged. Hash
|
|
84
|
-
# keys may be symbols (they come back as strings, and restore_state gets an indifferent-access
|
|
85
|
-
# hash); symbol *values* are refused because they'd come back as plain strings.
|
|
86
|
-
def assert_json!(value, room:, path: "app_state")
|
|
87
|
-
case value
|
|
88
|
-
when nil, true, false, String, Integer
|
|
89
|
-
nil
|
|
90
|
-
when Float
|
|
91
|
-
raise_not_serializable(room, path, "#{value} isn't a finite number") unless value.finite?
|
|
92
|
-
when Hash
|
|
93
|
-
value.each do |k, v|
|
|
94
|
-
unless k.is_a?(String) || k.is_a?(Symbol)
|
|
95
|
-
raise_not_serializable(room, path, "has a #{k.class} key (#{k.inspect}); JSON object keys must be strings")
|
|
96
|
-
end
|
|
97
|
-
assert_json!(v, room: room, path: "#{path}[#{k.inspect}]")
|
|
98
|
-
end
|
|
99
|
-
when Array
|
|
100
|
-
value.each_with_index { |v, i| assert_json!(v, room: room, path: "#{path}[#{i}]") }
|
|
101
|
-
when Symbol
|
|
102
|
-
raise_not_serializable(room, path, "is the Symbol #{value.inspect}; JSON has no symbols, so restore_state would get a String back. Use a string")
|
|
103
|
-
else
|
|
104
|
-
raise_not_serializable(room, path, "is a #{value.class}, which JSON can't carry. Reduce it to hashes, arrays, strings, numbers, booleans, and nil (records by id)")
|
|
105
|
-
end
|
|
106
|
-
end
|
|
107
|
-
|
|
108
|
-
# Room keys and users go through the same serializer members use on the wire
|
|
109
|
-
# (RoomMembership#transmit_port_connected), so a record travels as its GlobalID and
|
|
110
|
-
# strings, numbers, and symbols come back as themselves.
|
|
111
|
-
def serialize_argument(value)
|
|
112
|
-
::ActiveJob::Arguments.serialize([value]).first
|
|
113
|
-
end
|
|
114
|
-
|
|
115
|
-
def deserialize_argument(value)
|
|
116
|
-
::ActiveJob::Arguments.deserialize([value]).first
|
|
117
|
-
end
|
|
118
|
-
|
|
119
|
-
def encode_time(time)
|
|
120
|
-
time&.getutc&.iso8601(6)
|
|
121
|
-
end
|
|
122
|
-
|
|
123
|
-
def decode_time(value)
|
|
124
|
-
return nil if value.blank?
|
|
125
|
-
value.is_a?(Time) ? value : Time.iso8601(value)
|
|
126
|
-
end
|
|
127
|
-
|
|
128
|
-
private
|
|
129
|
-
|
|
130
|
-
def raise_not_serializable(room, path, detail)
|
|
131
|
-
raise NotSerializable,
|
|
132
|
-
"#{room.class.name}[#{room.key.inspect}]: snapshot_state returned something that isn't JSON: #{path} #{detail}"
|
|
133
|
-
end
|
|
134
|
-
end
|
|
135
|
-
end
|
|
136
|
-
end
|