cable_room 0.8.0.beta1 → 0.8.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/cable_room.gemspec +9 -2
- data/exe/cable_room +8 -0
- data/lib/cable_room/bus.rb +342 -0
- data/lib/cable_room/cli.rb +266 -0
- data/lib/cable_room/config.rb +78 -0
- data/lib/cable_room/host/bus_inbound.rb +41 -0
- data/lib/cable_room/host/placement.rb +261 -0
- data/lib/cable_room/host/runner.rb +15 -21
- data/lib/cable_room/host/supervisor.rb +301 -0
- data/lib/cable_room/host.rb +126 -10
- data/lib/cable_room/ports.rb +19 -9
- data/lib/cable_room/railtie.rb +8 -2
- data/lib/cable_room/room/base.rb +27 -24
- data/lib/cable_room/room/host_adapter.rb +9 -4
- data/lib/cable_room/room.rb +3 -1
- data/lib/cable_room/room_member.rb +63 -8
- data/lib/cable_room/version.rb +1 -1
- data/lib/cable_room.rb +68 -0
- metadata +20 -7
- data/lib/cable_room/host/action_cable_inbound.rb +0 -35
|
@@ -9,8 +9,9 @@ module CableRoom
|
|
|
9
9
|
# ~30% of all room messages at 10 s with no skipping.
|
|
10
10
|
PING_INTERVAL = 15.seconds
|
|
11
11
|
|
|
12
|
-
# A dropped announcement is invisible to the port. The room
|
|
13
|
-
#
|
|
12
|
+
# A dropped announcement is invisible to the port. The room hears members on the Bus, and its
|
|
13
|
+
# subscription is live once the room has started, but the room may not have started yet, may
|
|
14
|
+
# live in another process, or the Bus may be reconnecting; Redis pub/sub doesn't queue, so a
|
|
14
15
|
# port_connected sent before the room is listening is discarded with no error. Re-announce on
|
|
15
16
|
# a doubling backoff from here until the room acknowledges, rather than waiting out a whole
|
|
16
17
|
# PING_INTERVAL. port_connected is idempotent on the room side (the port is merged,
|
|
@@ -178,8 +179,11 @@ module CableRoom
|
|
|
178
179
|
schedule_reannounce
|
|
179
180
|
end
|
|
180
181
|
|
|
181
|
-
|
|
182
|
+
# -- The member side of Ports ---------------------------------------------------------------
|
|
182
183
|
|
|
184
|
+
# Everything a member sends goes to its room, so it's published on the room's Bus channel for
|
|
185
|
+
# `port` (see Room::Base.inbound_channel). The room's Host is subscribed there and queues the
|
|
186
|
+
# message on the room. Public because `ports[:x] << msg` reaches it through a PortProxy.
|
|
183
187
|
def port_transmit(port, data, secure_context: false)
|
|
184
188
|
data[:mtok] = @token
|
|
185
189
|
|
|
@@ -191,18 +195,37 @@ module CableRoom
|
|
|
191
195
|
end
|
|
192
196
|
end
|
|
193
197
|
|
|
194
|
-
|
|
198
|
+
CableRoom.bus.publish(room_class.inbound_channel(@room_key, port), data)
|
|
195
199
|
# Pings don't count: a ping must never be the reason the next ping is skipped.
|
|
196
200
|
@last_transmit_at = monotonic_now unless type == :port_ping
|
|
197
201
|
end
|
|
198
202
|
|
|
203
|
+
# Room→member traffic arrives on ActionCable streams (the room broadcasts to them), so
|
|
204
|
+
# listening on a port is a plain stream_from on the member's channel.
|
|
205
|
+
def stream_port(port, auto_close: true, &blk)
|
|
206
|
+
@cable_channel.stream_from(room_port_key(port), coder: ActiveSupport::JSON, &blk)
|
|
207
|
+
_streamed_ports << port if auto_close
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def close_streamed_ports!
|
|
211
|
+
_streamed_ports.each do |port|
|
|
212
|
+
@cable_channel.stop_stream_from(room_port_key(port))
|
|
213
|
+
end
|
|
214
|
+
_streamed_ports.clear
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
protected
|
|
218
|
+
|
|
199
219
|
# Re-announce until the room acknowledges the port, doubling the wait each time. Handing off
|
|
200
220
|
# to ping! at PING_INTERVAL keeps a stuck membership from holding a timer open forever.
|
|
221
|
+
#
|
|
222
|
+
# The timer is a plain Concurrent::ScheduledTask, not the Host's scheduler: in :remote a web
|
|
223
|
+
# process has no Host, and the member side must never need one.
|
|
201
224
|
def schedule_reannounce
|
|
202
225
|
@mutex.synchronize do
|
|
203
226
|
cancel_reannounce
|
|
204
227
|
return if @has_established || @has_left
|
|
205
|
-
return if Host.
|
|
228
|
+
return if Host.current&.shutdown?
|
|
206
229
|
|
|
207
230
|
next_delay = @reannounce_delay ? @reannounce_delay * 2 : RoomMember::REANNOUNCE_INITIAL_DELAY
|
|
208
231
|
return if next_delay > RoomMember::PING_INTERVAL
|
|
@@ -213,7 +236,7 @@ module CableRoom
|
|
|
213
236
|
# membership rather than relying on `self`.
|
|
214
237
|
membership = self
|
|
215
238
|
channel = @cable_channel
|
|
216
|
-
@reannounce_job =
|
|
239
|
+
@reannounce_job = Concurrent::ScheduledTask.execute(@reannounce_delay.to_f) do
|
|
217
240
|
channel.connection.worker_pool.async_exec(channel, connection: channel.connection) do
|
|
218
241
|
membership.transmit_port_connected unless membership.connected? || membership.left?
|
|
219
242
|
end
|
|
@@ -223,7 +246,7 @@ module CableRoom
|
|
|
223
246
|
|
|
224
247
|
def cancel_reannounce
|
|
225
248
|
@mutex.synchronize do
|
|
226
|
-
@reannounce_job&.
|
|
249
|
+
@reannounce_job&.cancel
|
|
227
250
|
@reannounce_job = nil
|
|
228
251
|
end
|
|
229
252
|
end
|
|
@@ -317,12 +340,44 @@ module CableRoom
|
|
|
317
340
|
end
|
|
318
341
|
end
|
|
319
342
|
|
|
343
|
+
# Start the room if `create: true` asked for it and nobody runs it yet. Called at join and
|
|
344
|
+
# again on every ping, so a room whose host died comes back.
|
|
320
345
|
def maybe_provision_room
|
|
321
346
|
return if left?
|
|
322
347
|
return unless @allow_create
|
|
323
|
-
return if
|
|
348
|
+
return request_remote_room if CableRoom.remote?
|
|
349
|
+
return if Host.current&.shutdown?
|
|
324
350
|
|
|
325
351
|
@room_class.ensure(@room_key)
|
|
326
352
|
end
|
|
353
|
+
|
|
354
|
+
# In :remote this process never hosts rooms, so `create: true` asks the rooms Hosts to start
|
|
355
|
+
# it: a provision request on the Bus, which every rooms Host's Placement hears and races for,
|
|
356
|
+
# the least-loaded first (see Host::Placement). Called at join and on every ping, even once the
|
|
357
|
+
# room has acknowledged the port, the same as `ensure` on every ping in :inline: a rooms
|
|
358
|
+
# process that dies sends no room_closed, and this is what brings its rooms back within one
|
|
359
|
+
# ping. While the room runs it costs one publish per member per ping, and a single-attempt lock
|
|
360
|
+
# try on each rooms Host that isn't running it (the one that is skips the request).
|
|
361
|
+
#
|
|
362
|
+
# The request names our tenant, since a rooms process has no request tenant of its own: the
|
|
363
|
+
# Host that claims the room starts it there, so its lock, inbound channel, and stream names
|
|
364
|
+
# match the ones this member uses.
|
|
365
|
+
def request_remote_room
|
|
366
|
+
request = {
|
|
367
|
+
type: Host::Placement::REQUEST_TYPE,
|
|
368
|
+
room_class: room_class.name,
|
|
369
|
+
# Keys travel the way `extra` does, so a record key arrives on the host as the record
|
|
370
|
+
room_key: ::ActiveJob::Arguments.serialize([@room_key]),
|
|
371
|
+
tenant: CableRoom.current_tenant,
|
|
372
|
+
requested_at: Time.current,
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
ActiveSupport::Notifications.instrument(
|
|
376
|
+
"provision_requested.cable_room",
|
|
377
|
+
{ membership: self, room_class: room_class, room_key: @room_key, tenant: request[:tenant], request: request }
|
|
378
|
+
) do
|
|
379
|
+
CableRoom.bus.publish(Bus.provision_channel, request)
|
|
380
|
+
end
|
|
381
|
+
end
|
|
327
382
|
end
|
|
328
383
|
end
|
data/lib/cable_room/version.rb
CHANGED
data/lib/cable_room.rb
CHANGED
|
@@ -4,11 +4,15 @@ require 'active_support/time'
|
|
|
4
4
|
require 'active_support/core_ext/module'
|
|
5
5
|
|
|
6
6
|
require 'action_cable'
|
|
7
|
+
require 'concurrent'
|
|
7
8
|
require 'redlock'
|
|
8
9
|
require 'rufus-scheduler'
|
|
9
10
|
|
|
11
|
+
require_relative 'cable_room/config'
|
|
10
12
|
require_relative 'cable_room/railtie'
|
|
11
13
|
|
|
14
|
+
require_relative 'cable_room/bus'
|
|
15
|
+
|
|
12
16
|
require_relative 'cable_room/periodic_timer'
|
|
13
17
|
require_relative 'cable_room/host'
|
|
14
18
|
require_relative 'cable_room/ports'
|
|
@@ -18,6 +22,9 @@ require_relative 'cable_room/room/'
|
|
|
18
22
|
require_relative 'cable_room/version'
|
|
19
23
|
|
|
20
24
|
module CableRoom
|
|
25
|
+
BUS_LOCK = Mutex.new
|
|
26
|
+
private_constant :BUS_LOCK
|
|
27
|
+
|
|
21
28
|
class << self
|
|
22
29
|
# Called as `handler.call(error, context)` whenever work inside a Room raises. Rooms swallow
|
|
23
30
|
# exceptions so that one bad message can't kill the Room, which makes this the only way to
|
|
@@ -36,6 +43,31 @@ module CableRoom
|
|
|
36
43
|
warn "CableRoom.error_handler raised #{handler_error.class}: #{handler_error.message}"
|
|
37
44
|
end
|
|
38
45
|
|
|
46
|
+
# The Apartment tenant the calling thread is in, or nil in an app without Apartment
|
|
47
|
+
def current_tenant
|
|
48
|
+
Apartment::Tenant.current if defined?(Apartment)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Run the block switched into `tenant`. Does nothing extra when there's no tenant, no
|
|
52
|
+
# Apartment, or the thread is already in it, so an :inline join (which starts its room in its
|
|
53
|
+
# own tenant) never switches at all.
|
|
54
|
+
def with_tenant(tenant, &blk)
|
|
55
|
+
return yield if tenant.nil? || !defined?(Apartment) || Apartment::Tenant.current == tenant
|
|
56
|
+
|
|
57
|
+
Apartment::Tenant.switch(tenant, &blk)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Run the block inside the Rails app's executor (database connections, reloading, and so on),
|
|
61
|
+
# the way ActionCable wraps channel callbacks. Nesting is fine: the executor yields straight
|
|
62
|
+
# through when it's already active on this thread. Without Rails it just yields.
|
|
63
|
+
def with_app_executor(&blk)
|
|
64
|
+
if defined?(Rails) && Rails.application
|
|
65
|
+
Rails.application.executor.wrap(&blk)
|
|
66
|
+
else
|
|
67
|
+
yield
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
39
71
|
def redis_pool
|
|
40
72
|
require 'rediconn'
|
|
41
73
|
@redis_pool ||= RediConn::RedisConnection.create(env_prefix: "CABLEROOM")
|
|
@@ -50,5 +82,41 @@ module CableRoom
|
|
|
50
82
|
CableRoom.redis,
|
|
51
83
|
])
|
|
52
84
|
end
|
|
85
|
+
|
|
86
|
+
# The process-wide Redis bus (see CableRoom::Bus). Built on first use. Guarded so two threads
|
|
87
|
+
# racing to it can't each build one: a handler registered on the loser would be stranded.
|
|
88
|
+
def bus
|
|
89
|
+
@bus || BUS_LOCK.synchronize { @bus ||= Bus.new }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Call this in a child process right after `fork`, before it touches Redis or hosts a room.
|
|
93
|
+
# `cable_room server --workers N` does it in every worker (see Host::Supervisor); an app that
|
|
94
|
+
# forks for its own reasons (Puma's `on_worker_boot` with `preload_app!`, a job runner) should
|
|
95
|
+
# call it there too.
|
|
96
|
+
#
|
|
97
|
+
# A forked child starts with copies of the parent's objects but only the forking thread, so
|
|
98
|
+
# anything that owns a thread or a socket is broken in the child: the Bus subscriber thread
|
|
99
|
+
# doesn't exist, a mutex may be held by a thread that's gone, and a Redis socket is shared
|
|
100
|
+
# with the parent, so both processes would read each other's replies. This drops the memos,
|
|
101
|
+
# so the next caller builds fresh ones:
|
|
102
|
+
#
|
|
103
|
+
# * the Bus. The copy is only dropped, not shut down: its pub/sub socket is still the
|
|
104
|
+
# parent's, and anything sent on it (even a TLS close_notify) would land in the parent's
|
|
105
|
+
# session. GC closes the child's copy of the descriptor quietly.
|
|
106
|
+
# * the Redlock client, whose monitor may be held by a parent thread.
|
|
107
|
+
# * the Host, whose rooms, locks, and threads are the parent's. It's abandoned (see
|
|
108
|
+
# Host#abandon!) so its `at_exit` hook can't close the parent's rooms when the child exits.
|
|
109
|
+
#
|
|
110
|
+
# Pooled connections need nothing from us. `redis_pool` is RediConn's shared pool for the
|
|
111
|
+
# URL (the same object comes back if we rebuild it), and connection_pool drops the connections
|
|
112
|
+
# it inherited on its own (it hooks `Process._fork`); redis-client also reconnects any client
|
|
113
|
+
# it finds in a new pid. Rails does the same for ActiveRecord (`PoolConfig.discard_pools!`
|
|
114
|
+
# runs after every fork), and its pools reconnect lazily on first use.
|
|
115
|
+
def after_fork!
|
|
116
|
+
BUS_LOCK.synchronize { @bus = nil }
|
|
117
|
+
@lock_manager = nil
|
|
118
|
+
Host.replace_current(nil)&.abandon!
|
|
119
|
+
nil
|
|
120
|
+
end
|
|
53
121
|
end
|
|
54
122
|
end
|
metadata
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: cable_room
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.8.0.
|
|
4
|
+
version: 0.8.0.beta2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ethan Knapp
|
|
8
|
-
bindir:
|
|
8
|
+
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
10
|
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
@@ -77,14 +77,20 @@ dependencies:
|
|
|
77
77
|
requirements:
|
|
78
78
|
- - ">="
|
|
79
79
|
- !ruby/object:Gem::Version
|
|
80
|
-
version: '
|
|
81
|
-
|
|
80
|
+
version: '5'
|
|
81
|
+
- - "<"
|
|
82
|
+
- !ruby/object:Gem::Version
|
|
83
|
+
version: '7'
|
|
84
|
+
type: :runtime
|
|
82
85
|
prerelease: false
|
|
83
86
|
version_requirements: !ruby/object:Gem::Requirement
|
|
84
87
|
requirements:
|
|
85
88
|
- - ">="
|
|
86
89
|
- !ruby/object:Gem::Version
|
|
87
|
-
version: '
|
|
90
|
+
version: '5'
|
|
91
|
+
- - "<"
|
|
92
|
+
- !ruby/object:Gem::Version
|
|
93
|
+
version: '7'
|
|
88
94
|
- !ruby/object:Gem::Dependency
|
|
89
95
|
name: rspec
|
|
90
96
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -101,16 +107,23 @@ dependencies:
|
|
|
101
107
|
version: '3'
|
|
102
108
|
email:
|
|
103
109
|
- eknapp@instructure.com
|
|
104
|
-
executables:
|
|
110
|
+
executables:
|
|
111
|
+
- cable_room
|
|
105
112
|
extensions: []
|
|
106
113
|
extra_rdoc_files: []
|
|
107
114
|
files:
|
|
108
115
|
- README.md
|
|
109
116
|
- cable_room.gemspec
|
|
117
|
+
- exe/cable_room
|
|
110
118
|
- lib/cable_room.rb
|
|
119
|
+
- lib/cable_room/bus.rb
|
|
120
|
+
- lib/cable_room/cli.rb
|
|
121
|
+
- lib/cable_room/config.rb
|
|
111
122
|
- lib/cable_room/host.rb
|
|
112
|
-
- lib/cable_room/host/
|
|
123
|
+
- lib/cable_room/host/bus_inbound.rb
|
|
124
|
+
- lib/cable_room/host/placement.rb
|
|
113
125
|
- lib/cable_room/host/runner.rb
|
|
126
|
+
- lib/cable_room/host/supervisor.rb
|
|
114
127
|
- lib/cable_room/host/worker_pool.rb
|
|
115
128
|
- lib/cable_room/periodic_timer.rb
|
|
116
129
|
- lib/cable_room/ports.rb
|
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
module CableRoom
|
|
2
|
-
class Host
|
|
3
|
-
# How rooms receive messages: straight off ActionCable's pubsub adapter, on the same stream
|
|
4
|
-
# names members publish to (Room::Base.room_port_key). This is the only place the room side
|
|
5
|
-
# touches ActionCable for inbound traffic; anything with the same two methods can be handed to
|
|
6
|
-
# `Host.new(inbound:)`.
|
|
7
|
-
#
|
|
8
|
-
# `subscribe` returns a handle that `unsubscribe` needs back. Handlers receive the payload
|
|
9
|
-
# exactly as published (a JSON string, for ActionCable) on the adapter's own thread, so they
|
|
10
|
-
# must be quick and hand the real work off. Host::Runner does that by queueing it on the room.
|
|
11
|
-
# `on_live` runs on that same thread once the adapter confirms the subscription.
|
|
12
|
-
class ActionCableInbound
|
|
13
|
-
# Subscribes from the calling thread. ActionCable's own stream_from posts the subscribe to
|
|
14
|
-
# the event loop first, but the adapter registers asynchronously either way, so that extra
|
|
15
|
-
# hop only widens the window in which the room can't hear anything.
|
|
16
|
-
#
|
|
17
|
-
# The adapter calls `on_live` while holding its own subscriber-map lock, so it must never
|
|
18
|
-
# wait on anything that might be waiting on the adapter (a room's mutex, say).
|
|
19
|
-
def subscribe(stream, on_live: nil, &on_message)
|
|
20
|
-
server.pubsub.subscribe(stream, on_message, on_live)
|
|
21
|
-
on_message
|
|
22
|
-
end
|
|
23
|
-
|
|
24
|
-
def unsubscribe(stream, handle)
|
|
25
|
-
server.pubsub.unsubscribe(stream, handle)
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
private
|
|
29
|
-
|
|
30
|
-
def server
|
|
31
|
-
ActionCable.server
|
|
32
|
-
end
|
|
33
|
-
end
|
|
34
|
-
end
|
|
35
|
-
end
|