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
data/cable_room.gemspec
CHANGED
|
@@ -18,14 +18,17 @@ Gem::Specification.new do |spec|
|
|
|
18
18
|
spec.summary = "Build live Rooms on top of ActionCable"
|
|
19
19
|
spec.homepage = "https://instructure.com"
|
|
20
20
|
|
|
21
|
-
spec.files = Dir["{app,config,db,lib}/**/*", "README.md", "*.gemspec"]
|
|
21
|
+
spec.files = Dir["{app,config,db,exe,lib}/**/*", "README.md", "CHANGELOG.md", "*.gemspec"]
|
|
22
|
+
spec.bindir = "exe"
|
|
23
|
+
spec.executables = ["cable_room"]
|
|
22
24
|
spec.require_paths = ['lib']
|
|
23
25
|
|
|
24
26
|
spec.add_dependency "rails", ">= 7.2", "< 9.0"
|
|
25
27
|
spec.add_dependency "rufus-scheduler", "~> 3.6"
|
|
26
28
|
spec.add_dependency "redlock", "~> 2.0"
|
|
27
29
|
spec.add_dependency "rediconn", "~> 0.1.2"
|
|
30
|
+
# rediconn builds pools of redis-rb clients but doesn't declare the gem; the Bus needs it too
|
|
31
|
+
spec.add_dependency "redis", ">= 5.0"
|
|
28
32
|
|
|
29
|
-
spec.add_development_dependency "redis"
|
|
30
33
|
spec.add_development_dependency 'rspec', '~> 3'
|
|
31
34
|
end
|
data/exe/cable_room
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Loads only the CLI, not the gem: `cable_room server` boots the Rails app itself, and the app's
|
|
5
|
+
# Gemfile is what should load CableRoom (so its Railtie runs at the usual time).
|
|
6
|
+
require "cable_room/cli"
|
|
7
|
+
|
|
8
|
+
exit CableRoom::CLI.new(ARGV).run
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CableRoom
|
|
4
|
+
# How a Room pushes messages out to its members. Every room→member message (`from_room`, tag,
|
|
5
|
+
# user, and `mtok` streams, plus `Room::Base.send_message`) goes through one of these, so
|
|
6
|
+
# swapping the transport is a config change, not a code change.
|
|
7
|
+
#
|
|
8
|
+
# `CableRoom.config.broadcaster` picks the implementation:
|
|
9
|
+
#
|
|
10
|
+
# :action_cable -> ActionCableBroadcaster (the default; today's behavior)
|
|
11
|
+
# :anycable -> AnyCableBroadcaster (needs the optional `anycable-rails` gem)
|
|
12
|
+
#
|
|
13
|
+
# Every broadcaster answers `broadcast(stream, payload)`. Specs can swap in a recording fake
|
|
14
|
+
# with `Broadcaster.current = fake`; `CableRoom.reset_config!` drops it again.
|
|
15
|
+
module Broadcaster
|
|
16
|
+
# Raised when the config asks for a broadcaster whose gem isn't in the bundle.
|
|
17
|
+
class MissingDependency < LoadError; end
|
|
18
|
+
|
|
19
|
+
class << self
|
|
20
|
+
# The broadcaster the current config selects. Built once per config object and reused,
|
|
21
|
+
# so `CableRoom.reset_config!` or `CableRoom.configure` picks up a fresh one on the next
|
|
22
|
+
# call without anyone having to remember to clear a cache.
|
|
23
|
+
def current
|
|
24
|
+
config = CableRoom.config
|
|
25
|
+
return @current if @current && @current_config.equal?(config)
|
|
26
|
+
|
|
27
|
+
@current_config = config
|
|
28
|
+
@current = build(config.broadcaster)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Replace the broadcaster for the life of the current config object. Meant for specs that
|
|
32
|
+
# need a fake without rspec-mocks; `CableRoom.reset_config!` builds a new config, which
|
|
33
|
+
# makes `current` rebuild from it and forget this one.
|
|
34
|
+
def current=(broadcaster)
|
|
35
|
+
@current_config = CableRoom.config
|
|
36
|
+
@current = broadcaster
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Builds the broadcaster named by `name`. Config already rejects unknown names, so the
|
|
40
|
+
# `else` branch only fires if someone bypasses config.
|
|
41
|
+
def build(name)
|
|
42
|
+
case name&.to_sym
|
|
43
|
+
when :action_cable
|
|
44
|
+
ActionCableBroadcaster.new
|
|
45
|
+
when :anycable
|
|
46
|
+
AnyCableBroadcaster.new
|
|
47
|
+
else
|
|
48
|
+
raise ArgumentError,
|
|
49
|
+
"CableRoom broadcaster must be one of #{Config::BROADCASTERS.map(&:inspect).join(', ')} " \
|
|
50
|
+
"(got #{name.inspect})"
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Publishes through the ActionCable server, which hands the message to whatever pubsub
|
|
56
|
+
# adapter the app configured (Redis, async, ...).
|
|
57
|
+
class ActionCableBroadcaster
|
|
58
|
+
def broadcast(stream, payload)
|
|
59
|
+
ActionCable.server.broadcast(stream, payload)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Publishes through `AnyCable.broadcast`, so anycable-go fans the message out to sockets.
|
|
64
|
+
# Stream names are the same ones ActionCableBroadcaster uses, so a member subscribed through
|
|
65
|
+
# anycable-go sees the same traffic it would through ActionCable.
|
|
66
|
+
#
|
|
67
|
+
# `anycable-rails` is optional at runtime: it's only loaded when this broadcaster is built, and
|
|
68
|
+
# a missing gem raises MissingDependency naming what to add.
|
|
69
|
+
#
|
|
70
|
+
# Payload encoding: `ActionCable.server.broadcast` runs every message through its coder
|
|
71
|
+
# (ActiveSupport::JSON by default) and hands the pubsub adapter a JSON string; anycable-rails's
|
|
72
|
+
# own ActionCable adapter forwards that string to `AnyCable.broadcast` untouched, and
|
|
73
|
+
# anycable-go expects the `data` field to be a pre-encoded string. So we encode here with the
|
|
74
|
+
# same coder, and a member decodes the message exactly as it would from ActionCable.
|
|
75
|
+
class AnyCableBroadcaster
|
|
76
|
+
CODER = ActiveSupport::JSON
|
|
77
|
+
GEM_NAME = "anycable-rails"
|
|
78
|
+
|
|
79
|
+
class << self
|
|
80
|
+
# Loads AnyCable on first use. `anycable` (a dependency of anycable-rails) is what defines
|
|
81
|
+
# `AnyCable.broadcast`; requiring it rather than `anycable-rails` avoids re-running the
|
|
82
|
+
# Rails integration in a process that already booted without it.
|
|
83
|
+
def ensure_loaded!
|
|
84
|
+
return if defined?(::AnyCable) && ::AnyCable.respond_to?(:broadcast)
|
|
85
|
+
|
|
86
|
+
begin
|
|
87
|
+
require "anycable"
|
|
88
|
+
rescue LoadError
|
|
89
|
+
raise MissingDependency,
|
|
90
|
+
"CableRoom broadcaster :anycable needs the #{GEM_NAME} gem. " \
|
|
91
|
+
"Add `gem \"#{GEM_NAME}\"` to your Gemfile, or set broadcaster to :action_cable."
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def initialize
|
|
97
|
+
self.class.ensure_loaded!
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def broadcast(stream, payload)
|
|
101
|
+
publish(stream, encode(payload))
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
protected
|
|
105
|
+
|
|
106
|
+
# The one call that leaves the process. Fakes override this to record instead of publish.
|
|
107
|
+
def publish(stream, encoded_payload)
|
|
108
|
+
::AnyCable.broadcast(stream, encoded_payload)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def encode(payload)
|
|
112
|
+
CODER.encode(payload)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
require 'set'
|
|
2
|
+
require 'active_support/json'
|
|
3
|
+
|
|
4
|
+
module CableRoom
|
|
5
|
+
# The Redis bus that carries member-to-room traffic in every mode: `to_room` messages,
|
|
6
|
+
# provision requests, and the handoff data a room leaves behind when it migrates to another
|
|
7
|
+
# host. It's a thin layer over the `CABLEROOM_*` Redis connection (see `CableRoom.redis_pool`)
|
|
8
|
+
# that adds three things:
|
|
9
|
+
#
|
|
10
|
+
# * One place that knows the channel and key names (`cr:{room_key}:in` and friends).
|
|
11
|
+
# * JSON encoding on the way in and decoding on the way out, using the same coder the room
|
|
12
|
+
# ports already use for ActionCable streams, so a bad payload fails at the publisher.
|
|
13
|
+
# * A single subscriber thread that owns a dedicated pub/sub connection and hands each
|
|
14
|
+
# decoded message to the handler registered for its channel.
|
|
15
|
+
#
|
|
16
|
+
# Ordering: Redis delivers messages on one channel in publish order, and one thread dispatches
|
|
17
|
+
# them one at a time, so a handler sees them in the order they were published. `subscribe`
|
|
18
|
+
# blocks until Redis confirms the subscription, so anything published after it returns is
|
|
19
|
+
# delivered.
|
|
20
|
+
#
|
|
21
|
+
# Handlers run on the subscriber thread. Keep them short (hand the work to a queue) and never
|
|
22
|
+
# block in them waiting on the bus itself. A handler is allowed to call `subscribe` and
|
|
23
|
+
# `unsubscribe`; those calls return without waiting when made from the subscriber thread,
|
|
24
|
+
# because the confirmation they'd wait for is delivered by that same thread.
|
|
25
|
+
class Bus
|
|
26
|
+
# Raised at the publisher when a payload can't be turned into JSON.
|
|
27
|
+
class EncodeError < ArgumentError; end
|
|
28
|
+
|
|
29
|
+
# Raised when Redis doesn't confirm a subscribe or unsubscribe within the timeout.
|
|
30
|
+
class TimeoutError < StandardError; end
|
|
31
|
+
|
|
32
|
+
KEY_PREFIX = "cr".freeze
|
|
33
|
+
DEFAULT_TIMEOUT = 5
|
|
34
|
+
|
|
35
|
+
class << self
|
|
36
|
+
# Pub/sub channel that a room's host listens on for member-to-room messages. A room's main
|
|
37
|
+
# inbound port has no suffix (`cr:{room_key}:in`); a custom inbound port gets one
|
|
38
|
+
# (`cr:{room_key}:in:{port}`), so every channel a room listens on shares one prefix.
|
|
39
|
+
# `Room::Base.inbound_channel` is what turns a room class, key, and port into these.
|
|
40
|
+
def inbound_channel(room_key, port = nil)
|
|
41
|
+
channel = room_channel(room_key, "in")
|
|
42
|
+
port.nil? ? channel : "#{channel}:#{port}"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Pub/sub channel every host listens on for "someone wants room X to exist" requests.
|
|
46
|
+
def provision_channel
|
|
47
|
+
"#{KEY_PREFIX}:provision"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Redis list where a migrating room's old host parks inbound messages until the new host
|
|
51
|
+
# has adopted the room.
|
|
52
|
+
def handoff_list(room_key)
|
|
53
|
+
room_channel(room_key, "handoff")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Redis string (with a TTL) holding the frozen room's snapshot during a migration.
|
|
57
|
+
def snapshot_key(room_key)
|
|
58
|
+
room_channel(room_key, "snapshot")
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Pub/sub channel the adopting host publishes on once it has taken over the room.
|
|
62
|
+
def adopted_channel(room_key)
|
|
63
|
+
room_channel(room_key, "adopted")
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def room_channel(room_key, suffix)
|
|
69
|
+
key = room_key.to_s
|
|
70
|
+
raise ArgumentError, "room_key can't be blank" if key.empty?
|
|
71
|
+
"#{KEY_PREFIX}:#{key}:#{suffix}"
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# `redis_pool` defaults to `CableRoom.redis_pool` (looked up lazily so specs can swap it).
|
|
76
|
+
def initialize(redis_pool: nil)
|
|
77
|
+
@redis_pool = redis_pool
|
|
78
|
+
|
|
79
|
+
# Everything below is guarded by @mutex and changes are announced on @changed
|
|
80
|
+
@mutex = Mutex.new
|
|
81
|
+
@changed = ConditionVariable.new
|
|
82
|
+
@handlers = {} # channel => handler; the channels we want to be subscribed to
|
|
83
|
+
@requested = Set.new # channels we've sent SUBSCRIBE for in the current session
|
|
84
|
+
@confirmed = Set.new # channels Redis has confirmed in the current session
|
|
85
|
+
@thread = nil # the subscriber thread, nil when no channels are wanted
|
|
86
|
+
@session_live = false # true while @subscriber_redis accepts commands from other threads
|
|
87
|
+
@subscriber_redis = nil
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# ---- Publisher and plain key operations --------------------------------------------------
|
|
91
|
+
|
|
92
|
+
# Publish a JSON-encodable payload. Returns the number of subscribers that received it.
|
|
93
|
+
def publish(channel, payload)
|
|
94
|
+
encoded = encode(payload)
|
|
95
|
+
redis { |r| r.publish(channel, encoded) }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Append one or more payloads to the end of a list. Returns the list's new length. With `ttl`
|
|
99
|
+
# (seconds or a Duration) the list's expiry is reset in the same round trip, so a list nobody
|
|
100
|
+
# deletes (a host that died mid-handoff) still goes away on its own.
|
|
101
|
+
def rpush(key, *payloads, ttl: nil)
|
|
102
|
+
encoded = payloads.map { |payload| encode(payload) }
|
|
103
|
+
return redis { |r| r.rpush(key, encoded) } unless ttl
|
|
104
|
+
|
|
105
|
+
seconds = ttl.to_i
|
|
106
|
+
raise ArgumentError, "ttl must be a positive number of seconds" unless seconds.positive?
|
|
107
|
+
length, = redis { |r| r.pipelined { |p| p.rpush(key, encoded); p.expire(key, seconds) } }
|
|
108
|
+
length
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# The whole list, oldest first, decoded.
|
|
112
|
+
def lrange(key)
|
|
113
|
+
redis { |r| r.lrange(key, 0, -1) }.map { |raw| decode(raw) }
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Delete keys. Returns how many existed.
|
|
117
|
+
def del(*keys)
|
|
118
|
+
redis { |r| r.del(*keys) }
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Store a payload under a key that expires after `ttl` (seconds or an
|
|
122
|
+
# ActiveSupport::Duration). Migration uses this for snapshots so a dead handoff cleans
|
|
123
|
+
# itself up.
|
|
124
|
+
def set(key, payload, ttl:)
|
|
125
|
+
seconds = ttl.to_i
|
|
126
|
+
raise ArgumentError, "ttl must be a positive number of seconds" unless seconds.positive?
|
|
127
|
+
encoded = encode(payload)
|
|
128
|
+
redis { |r| r.set(key, encoded, ex: seconds) }
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# The decoded payload stored under a key, or nil when there isn't one.
|
|
132
|
+
def get(key)
|
|
133
|
+
raw = redis { |r| r.get(key) }
|
|
134
|
+
raw.nil? ? nil : decode(raw)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# ---- Subscriber -------------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
# Register `handler` for `channel` and block until Redis confirms the subscription. The
|
|
140
|
+
# handler is called as `handler.call(message, channel)` with the decoded message on the
|
|
141
|
+
# subscriber thread. Subscribing again to the same channel replaces the handler.
|
|
142
|
+
#
|
|
143
|
+
# Raises TimeoutError (and forgets the handler) if the subscription isn't confirmed in time,
|
|
144
|
+
# which usually means Redis is unreachable.
|
|
145
|
+
def subscribe(channel, timeout: DEFAULT_TIMEOUT, &handler)
|
|
146
|
+
raise ArgumentError, "subscribe needs a block to handle messages" unless handler
|
|
147
|
+
channel = channel.to_s
|
|
148
|
+
|
|
149
|
+
@mutex.synchronize do
|
|
150
|
+
@handlers[channel] = handler
|
|
151
|
+
if @thread
|
|
152
|
+
reconcile
|
|
153
|
+
else
|
|
154
|
+
start_subscriber_thread
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
return true if on_subscriber_thread?
|
|
158
|
+
|
|
159
|
+
begin
|
|
160
|
+
wait_until(timeout, "subscription to #{channel}") { @confirmed.include?(channel) }
|
|
161
|
+
rescue TimeoutError
|
|
162
|
+
@handlers.delete(channel)
|
|
163
|
+
reconcile
|
|
164
|
+
raise
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
true
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Drop the handler for `channel` and block until Redis confirms the unsubscribe. When no
|
|
171
|
+
# channels are left the subscriber thread exits, and this also waits for that. Returns
|
|
172
|
+
# false if the channel wasn't subscribed.
|
|
173
|
+
def unsubscribe(channel, timeout: DEFAULT_TIMEOUT)
|
|
174
|
+
channel = channel.to_s
|
|
175
|
+
|
|
176
|
+
@mutex.synchronize do
|
|
177
|
+
return false unless @handlers.delete(channel)
|
|
178
|
+
reconcile
|
|
179
|
+
|
|
180
|
+
return true if on_subscriber_thread?
|
|
181
|
+
|
|
182
|
+
wait_until(timeout, "unsubscribe from #{channel}") do
|
|
183
|
+
!@confirmed.include?(channel) && (@thread.nil? || @handlers.any?)
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
true
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Unsubscribe from everything and wait for the subscriber thread to exit.
|
|
190
|
+
def shutdown(timeout: DEFAULT_TIMEOUT)
|
|
191
|
+
@mutex.synchronize do
|
|
192
|
+
@handlers.clear
|
|
193
|
+
reconcile
|
|
194
|
+
return true if on_subscriber_thread?
|
|
195
|
+
wait_until(timeout, "subscriber thread to exit") { @thread.nil? }
|
|
196
|
+
end
|
|
197
|
+
true
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Channels Redis has confirmed we're subscribed to right now.
|
|
201
|
+
def subscribed_channels
|
|
202
|
+
@mutex.synchronize { @confirmed.to_a }
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def subscribed?(channel)
|
|
206
|
+
@mutex.synchronize { @confirmed.include?(channel.to_s) }
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def subscriber_alive?
|
|
210
|
+
thread = @mutex.synchronize { @thread }
|
|
211
|
+
!!thread&.alive?
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
private
|
|
215
|
+
|
|
216
|
+
def redis_pool
|
|
217
|
+
@redis_pool || CableRoom.redis_pool
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def redis(&blk)
|
|
221
|
+
redis_pool.with(&blk)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def encode(payload)
|
|
225
|
+
ActiveSupport::JSON.encode(payload)
|
|
226
|
+
rescue StandardError => e
|
|
227
|
+
raise EncodeError, "payload can't be encoded as JSON: #{e.class}: #{e.message}"
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def decode(raw)
|
|
231
|
+
ActiveSupport::JSON.decode(raw)
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def on_subscriber_thread?
|
|
235
|
+
Thread.current == @thread
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# The subscriber needs a connection of its own: a pub/sub connection can't run other
|
|
239
|
+
# commands, and it lives as long as there are subscriptions, so it must not be a pooled
|
|
240
|
+
# one (a small pool would run dry). Borrow a pooled client just long enough to dup it,
|
|
241
|
+
# which gives an unconnected client with the same settings and no pool ties.
|
|
242
|
+
def build_subscriber_redis
|
|
243
|
+
redis(&:dup)
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def start_subscriber_thread
|
|
247
|
+
@thread = Thread.new { subscriber_loop }
|
|
248
|
+
@thread.name = "cable_room-bus-subscriber"
|
|
249
|
+
@thread.report_on_exception = false
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# Runs on the subscriber thread. Each pass opens one pub/sub session covering every wanted
|
|
253
|
+
# channel; a session ends when the last channel is unsubscribed (normal) or the connection
|
|
254
|
+
# fails (reported, then retried with backoff). The loop exits once nothing is wanted.
|
|
255
|
+
def subscriber_loop
|
|
256
|
+
backoff = 0.1
|
|
257
|
+
loop do
|
|
258
|
+
channels = @mutex.synchronize do
|
|
259
|
+
if @handlers.empty?
|
|
260
|
+
@thread = nil
|
|
261
|
+
@changed.broadcast
|
|
262
|
+
return
|
|
263
|
+
end
|
|
264
|
+
@handlers.keys
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
begin
|
|
268
|
+
run_session(channels)
|
|
269
|
+
backoff = 0.1
|
|
270
|
+
rescue StandardError => e
|
|
271
|
+
CableRoom.report_error(e, bus: self, channels: channels)
|
|
272
|
+
sleep backoff
|
|
273
|
+
backoff = [backoff * 2, 5].min
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def run_session(channels)
|
|
279
|
+
redis = build_subscriber_redis
|
|
280
|
+
@mutex.synchronize { @requested = Set.new(channels) }
|
|
281
|
+
|
|
282
|
+
# redis-rb blocks here until the subscription count drops to zero, calling back for every
|
|
283
|
+
# event. Other threads add and remove channels by sending commands on the same client.
|
|
284
|
+
redis.subscribe(*channels) do |on|
|
|
285
|
+
on.subscribe do |channel, _count|
|
|
286
|
+
@mutex.synchronize do
|
|
287
|
+
unless @session_live
|
|
288
|
+
# redis-rb has the pub/sub socket up once the first confirmation arrives, so from
|
|
289
|
+
# here on other threads may send SUBSCRIBE/UNSUBSCRIBE through it
|
|
290
|
+
@session_live = true
|
|
291
|
+
@subscriber_redis = redis
|
|
292
|
+
end
|
|
293
|
+
@confirmed << channel
|
|
294
|
+
reconcile
|
|
295
|
+
@changed.broadcast
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
on.unsubscribe do |channel, count|
|
|
300
|
+
@mutex.synchronize do
|
|
301
|
+
@confirmed.delete(channel)
|
|
302
|
+
@requested.delete(channel)
|
|
303
|
+
if count.zero?
|
|
304
|
+
# redis-rb closes the socket as soon as this callback returns, so stop other
|
|
305
|
+
# threads from writing to it. Anything wanted by then starts a fresh session.
|
|
306
|
+
@session_live = false
|
|
307
|
+
@subscriber_redis = nil
|
|
308
|
+
else
|
|
309
|
+
reconcile
|
|
310
|
+
end
|
|
311
|
+
@changed.broadcast
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
on.message do |channel, raw|
|
|
316
|
+
dispatch(channel, raw)
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
ensure
|
|
320
|
+
@mutex.synchronize do
|
|
321
|
+
@session_live = false
|
|
322
|
+
@subscriber_redis = nil
|
|
323
|
+
@requested.clear
|
|
324
|
+
@confirmed.clear
|
|
325
|
+
@changed.broadcast
|
|
326
|
+
end
|
|
327
|
+
redis&.close
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
# Bring the server-side subscription set in line with the handlers we hold. Only possible
|
|
331
|
+
# while a session is live; before that, the thread's first confirmation calls this to pick
|
|
332
|
+
# up anything added or removed while it was connecting. Caller must hold @mutex.
|
|
333
|
+
def reconcile
|
|
334
|
+
return unless @session_live
|
|
335
|
+
|
|
336
|
+
wanted = @handlers.keys
|
|
337
|
+
(wanted - @requested.to_a).each do |channel|
|
|
338
|
+
@requested << channel
|
|
339
|
+
@subscriber_redis.subscribe(channel)
|
|
340
|
+
end
|
|
341
|
+
(@requested.to_a - wanted).each do |channel|
|
|
342
|
+
@requested.delete(channel)
|
|
343
|
+
@subscriber_redis.unsubscribe(channel)
|
|
344
|
+
end
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
# Decode and hand one message to its handler. A handler that raises is reported and the
|
|
348
|
+
# thread carries on; one bad message must never take the bus down.
|
|
349
|
+
def dispatch(channel, raw)
|
|
350
|
+
handler = @mutex.synchronize { @handlers[channel] }
|
|
351
|
+
return unless handler # unsubscribed while the message was in flight
|
|
352
|
+
|
|
353
|
+
handler.call(decode(raw), channel)
|
|
354
|
+
rescue StandardError => e
|
|
355
|
+
CableRoom.report_error(e, bus: self, channel: channel, raw_message: raw)
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
# Wait on @changed until the block is true or the timeout passes. Caller must hold @mutex.
|
|
359
|
+
def wait_until(timeout, reason)
|
|
360
|
+
deadline = monotonic_now + timeout
|
|
361
|
+
until yield
|
|
362
|
+
remaining = deadline - monotonic_now
|
|
363
|
+
raise TimeoutError, "Timed out after #{timeout}s waiting for #{reason}" if remaining <= 0
|
|
364
|
+
@changed.wait(@mutex, remaining)
|
|
365
|
+
end
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
def monotonic_now
|
|
369
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
370
|
+
end
|
|
371
|
+
end
|
|
372
|
+
end
|