cable_room 0.6.2.beta1 → 0.7.0.beta2
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 +662 -48
- 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 +577 -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 +482 -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 +260 -0
- data/lib/cable_room/ports.rb +20 -50
- 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 +70 -2
- data/lib/cable_room/room/reaping.rb +34 -1
- data/lib/cable_room/room/snapshotting.rb +82 -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_harness.rb +168 -0
- data/lib/cable_room/room_member.rb +293 -69
- 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 +26 -9
- data/lib/cable_room/channel_base.rb +0 -247
- data/lib/cable_room/channel_tracker.rb +0 -130
- data/lib/cable_room/room/channel_adapter.rb +0 -18
|
@@ -3,7 +3,12 @@ module CableRoom
|
|
|
3
3
|
module PortManagement
|
|
4
4
|
extend ActiveSupport::Concern
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
# A port that the room has not heard from for this long is dropped. Any inbound message
|
|
7
|
+
# counts as hearing from it (see the receive_message callback below), and members only
|
|
8
|
+
# ping when they have been otherwise silent for RoomMember::PING_INTERVAL, so the longest
|
|
9
|
+
# gap a live member produces is 2 x PING_INTERVAL. Keep this comfortably above that: under
|
|
10
|
+
# load the room processes its queue late, and a reaped port is a lost student.
|
|
11
|
+
PORT_TIMEOUT = 45.seconds
|
|
7
12
|
|
|
8
13
|
class_methods do
|
|
9
14
|
def on_port_connected(...)
|
|
@@ -29,7 +34,13 @@ module CableRoom
|
|
|
29
34
|
on_port_connected { reply({type: 'port_acknowledged' }) }
|
|
30
35
|
|
|
31
36
|
set_callback(:receive_message, :around) do |_, blk|
|
|
32
|
-
with_message_origin(message['mtok'])
|
|
37
|
+
with_message_origin(message['mtok']) do
|
|
38
|
+
# Every message from a known port is proof of life, not just port_ping — so a member
|
|
39
|
+
# that is actively sending never needs to ping (RoomMembership#ping!). Unknown origins
|
|
40
|
+
# (a port_connected creating its port) are touched when the port is created.
|
|
41
|
+
message_origin&.touch_activity!
|
|
42
|
+
blk.call
|
|
43
|
+
end
|
|
33
44
|
end
|
|
34
45
|
|
|
35
46
|
system_message_types(:port_connected, :port_disconnected, :port_ping)
|
|
@@ -57,6 +68,13 @@ module CableRoom
|
|
|
57
68
|
{ client_port: client_port || tag, **kwargs }
|
|
58
69
|
end
|
|
59
70
|
|
|
71
|
+
# How many member ports are attached right now. Public, unlike `connected_clients`, because
|
|
72
|
+
# the Host reads it from outside the room's thread to total up `Host#open_ports`; a plain
|
|
73
|
+
# size is safe to read that way and a moment's staleness doesn't matter for a gauge.
|
|
74
|
+
def open_port_count
|
|
75
|
+
@_port_clients.size
|
|
76
|
+
end
|
|
77
|
+
|
|
60
78
|
def reply(message = nil, **kwargs)
|
|
61
79
|
raise ArgumentError, "Can only use reply when handling a message" unless message_origin
|
|
62
80
|
raise ArgumentError, "Must provide message or block" unless message || block_given?
|
|
@@ -140,6 +158,29 @@ module CableRoom
|
|
|
140
158
|
end
|
|
141
159
|
end
|
|
142
160
|
|
|
161
|
+
# -- Snapshot and restore (see CableRoom::Snapshot) ----------------------------------------
|
|
162
|
+
|
|
163
|
+
def _snapshot_port_clients
|
|
164
|
+
@_port_clients.values.map(&:to_snapshot)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Re-create every port exactly as it was, without running port_connected callbacks: the
|
|
168
|
+
# members are still there and were already acknowledged. A port whose user can't be found
|
|
169
|
+
# any more (its record was deleted) is dropped rather than failing the whole room; that
|
|
170
|
+
# member will be re-acknowledged when it next pings.
|
|
171
|
+
def _restore_port_clients(entries)
|
|
172
|
+
Array(entries).each do |entry|
|
|
173
|
+
client = PortClient.new(self, entry["token"])
|
|
174
|
+
begin
|
|
175
|
+
client.restore!(entry)
|
|
176
|
+
rescue ::ActiveJob::DeserializationError => e
|
|
177
|
+
logger.warn "Dropping port #{entry['token']} from the snapshot: #{e.message}"
|
|
178
|
+
next
|
|
179
|
+
end
|
|
180
|
+
@_port_clients[client.token] = client
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
143
184
|
def check_port_inactivity
|
|
144
185
|
return unless @_port_clients
|
|
145
186
|
|
|
@@ -197,6 +238,33 @@ module CableRoom
|
|
|
197
238
|
def tag!(*tags)
|
|
198
239
|
self[:tags].merge(tags.flatten.map(&:to_sym))
|
|
199
240
|
end
|
|
241
|
+
|
|
242
|
+
SNAPSHOT_OWN_KEYS = %w[tags as last_seen_at].freeze
|
|
243
|
+
|
|
244
|
+
# This port as one `port_clients` entry of a CableRoom::Snapshot. `as` goes through the
|
|
245
|
+
# same serializer the member used to send it (a GlobalID for a record). Everything else on
|
|
246
|
+
# the port — what the member's `extra:` merged in, plus anything the room stored with
|
|
247
|
+
# `message_origin[:x] = ...` — travels under `metadata`, the same way, so records survive.
|
|
248
|
+
def to_snapshot
|
|
249
|
+
metadata = @metadata.to_h.except(*SNAPSHOT_OWN_KEYS)
|
|
250
|
+
{
|
|
251
|
+
token: token,
|
|
252
|
+
tags: self[:tags].map(&:to_s),
|
|
253
|
+
as: Snapshot.serialize_argument(self[:as]),
|
|
254
|
+
last_seen_at: Snapshot.encode_time(self[:last_seen_at]),
|
|
255
|
+
metadata: Snapshot.serialize_argument(metadata),
|
|
256
|
+
}
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# The inverse of `to_snapshot`. `last_seen_at` is kept, not reset: the port is exactly as
|
|
260
|
+
# old as it was, and its member's next ping refreshes it as usual.
|
|
261
|
+
def restore!(entry)
|
|
262
|
+
merge!(Snapshot.deserialize_argument(entry["metadata"]))
|
|
263
|
+
tag!(entry["tags"])
|
|
264
|
+
self[:as] = Snapshot.deserialize_argument(entry["as"])
|
|
265
|
+
self[:last_seen_at] = Snapshot.decode_time(entry["last_seen_at"]) || self[:last_seen_at]
|
|
266
|
+
self
|
|
267
|
+
end
|
|
200
268
|
end
|
|
201
269
|
end
|
|
202
270
|
end
|
|
@@ -59,7 +59,7 @@ module CableRoom
|
|
|
59
59
|
protected
|
|
60
60
|
|
|
61
61
|
def ping_watchdog
|
|
62
|
-
@
|
|
62
|
+
@runner.ping_watchdog
|
|
63
63
|
end
|
|
64
64
|
|
|
65
65
|
def check_reapers_now!
|
|
@@ -74,6 +74,39 @@ module CableRoom
|
|
|
74
74
|
ping_watchdog
|
|
75
75
|
end
|
|
76
76
|
|
|
77
|
+
# -- Snapshot and restore (see CableRoom::Snapshot) ----------------------------------------
|
|
78
|
+
|
|
79
|
+
# One entry per `reap_when`, in declaration order. `last_keep_at` is the wall-clock moment
|
|
80
|
+
# the reaper's grace period started counting from (nil until it has run once), so the
|
|
81
|
+
# deadline — `last_keep_at + grace` — is the same on whichever host restores it.
|
|
82
|
+
def _snapshot_reaper_state
|
|
83
|
+
self.class.reaper_checkers.each_with_index.map do |cfg, index|
|
|
84
|
+
state = _reaper_states[cfg] || {}
|
|
85
|
+
{ key: cfg[:key]&.to_s, index: index, last_keep_at: Snapshot.encode_time(state[:last_keep_at]) }
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Runs before the startup callbacks, which add the timer to each state and leave the rest
|
|
90
|
+
# alone. A named reaper matches by name and an anonymous one by position, so a Room whose
|
|
91
|
+
# reapers changed between the two hosts' code versions keeps what still lines up and drops
|
|
92
|
+
# the rest.
|
|
93
|
+
def _restore_reaper_state(entries)
|
|
94
|
+
checkers = self.class.reaper_checkers
|
|
95
|
+
Array(entries).each do |entry|
|
|
96
|
+
cfg =
|
|
97
|
+
if entry["key"].present?
|
|
98
|
+
checkers.find { |c| c[:key].to_s == entry["key"] }
|
|
99
|
+
else
|
|
100
|
+
candidate = checkers[entry["index"].to_i]
|
|
101
|
+
candidate if candidate && candidate[:key].nil?
|
|
102
|
+
end
|
|
103
|
+
next unless cfg
|
|
104
|
+
|
|
105
|
+
last_keep_at = Snapshot.decode_time(entry["last_keep_at"])
|
|
106
|
+
(_reaper_states[cfg] ||= {})[:last_keep_at] = last_keep_at if last_keep_at
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
77
110
|
private
|
|
78
111
|
|
|
79
112
|
def _reaper_states
|
|
@@ -0,0 +1,82 @@
|
|
|
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
|
|
@@ -36,13 +36,13 @@ module CableRoom
|
|
|
36
36
|
# results back with `on_room_thread` over waiting for them.
|
|
37
37
|
def async(&blk)
|
|
38
38
|
room = self
|
|
39
|
-
@
|
|
39
|
+
@runner.post_work(async: true) { room.instance_exec(&blk) }
|
|
40
40
|
end
|
|
41
41
|
|
|
42
42
|
# Queue the block back onto the Room's own thread, where touching Room state is safe again.
|
|
43
43
|
def on_room_thread(&blk)
|
|
44
44
|
room = self
|
|
45
|
-
@
|
|
45
|
+
@runner.post_work(async: false, silent: true) { room.instance_exec(&blk) }
|
|
46
46
|
end
|
|
47
47
|
end
|
|
48
48
|
end
|
|
@@ -98,6 +98,33 @@ module CableRoom
|
|
|
98
98
|
tags
|
|
99
99
|
end
|
|
100
100
|
|
|
101
|
+
# -- Snapshot and restore (see CableRoom::Snapshot) ----------------------------------------
|
|
102
|
+
|
|
103
|
+
# The user map is carried on its own rather than rebuilt from the ports: a port whose join
|
|
104
|
+
# the tag policy refused has no user entry, and a rebuild would invent one.
|
|
105
|
+
def _snapshot_user_state
|
|
106
|
+
@_user_map_mutex.synchronize do
|
|
107
|
+
@_user_state_map.map do |user, usm|
|
|
108
|
+
{ user: Snapshot.serialize_argument(user), port_tokens: usm[:port_tokens].to_a }
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# No user_joined callbacks here: these users joined long ago, on the previous host.
|
|
114
|
+
def _restore_user_state(entries)
|
|
115
|
+
@_user_map_mutex.synchronize do
|
|
116
|
+
Array(entries).each do |entry|
|
|
117
|
+
begin
|
|
118
|
+
user = Snapshot.deserialize_argument(entry["user"])
|
|
119
|
+
rescue ::ActiveJob::DeserializationError => e
|
|
120
|
+
logger.warn "Dropping a user from the snapshot: #{e.message}"
|
|
121
|
+
next
|
|
122
|
+
end
|
|
123
|
+
@_user_state_map[user] = { port_tokens: Set.new(entry["port_tokens"]) }
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
101
128
|
def _apply_port_scope(user: nil, tag: nil, **kwargs)
|
|
102
129
|
if user && tag
|
|
103
130
|
user_tags = all_user_tags(user) || []
|
data/lib/cable_room/room.rb
CHANGED
|
@@ -7,9 +7,10 @@ module CableRoom
|
|
|
7
7
|
|
|
8
8
|
autoload :Callbacks
|
|
9
9
|
autoload :Threading
|
|
10
|
-
autoload :
|
|
10
|
+
autoload :HostAdapter
|
|
11
11
|
|
|
12
12
|
autoload :Lifecycle
|
|
13
|
+
autoload :Snapshotting
|
|
13
14
|
autoload :Reaping
|
|
14
15
|
autoload :InputHandling
|
|
15
16
|
|
|
@@ -20,8 +21,10 @@ module CableRoom
|
|
|
20
21
|
autoload :Broadcasting
|
|
21
22
|
end
|
|
22
23
|
|
|
24
|
+
# Every room running in this process. Asking never creates a Host, so a process that hosts
|
|
25
|
+
# no rooms (a web process in :remote) just gets an empty list.
|
|
23
26
|
def self.locally_open_rooms
|
|
24
|
-
|
|
27
|
+
Host.current&.rooms || []
|
|
25
28
|
end
|
|
26
29
|
end
|
|
27
30
|
end
|
|
@@ -0,0 +1,168 @@
|
|
|
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
|