cable_room 0.7.0.beta3 → 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.
@@ -1,112 +1,78 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module CableRoom
4
- # Holds the app-wide settings for CableRoom. Get the current one with `CableRoom.config`
5
- # and change it with `CableRoom.configure`:
6
- #
7
- # CableRoom.configure do |c|
8
- # c.room_host = :remote # :inline | :remote
9
- # c.broadcaster = :anycable # :action_cable | :anycable
10
- # c.provision_delay_ms = 20
11
- # c.drain_timeout = 10.minutes
12
- # c.handoff_timeout = 10.seconds
13
- # end
14
- #
15
- # The env vars CABLE_ROOM_HOST and CABLE_ROOM_BROADCASTER override `room_host` and
16
- # `broadcaster`, even when the configure block sets them. That lets one build run in
17
- # both modes without a code change. With no configuration at all, every default keeps
18
- # today's behavior: rooms run inline and broadcast through ActionCable.
19
- class Config
20
- class InvalidOption < ArgumentError; end
21
-
22
- ROOM_HOSTS = %i[inline remote].freeze
23
- BROADCASTERS = %i[action_cable anycable].freeze
24
-
25
- ENV_OVERRIDES = {
26
- room_host: "CABLE_ROOM_HOST",
27
- broadcaster: "CABLE_ROOM_BROADCASTER",
28
- }.freeze
29
-
30
- DEFAULTS = {
31
- room_host: :inline,
32
- broadcaster: :action_cable,
33
- provision_delay_ms: 20,
34
- drain_timeout: 10.minutes,
35
- handoff_timeout: 10.seconds,
36
- }.freeze
37
-
38
- attr_reader :room_host, :broadcaster
39
- attr_accessor :provision_delay_ms, :drain_timeout, :handoff_timeout
40
-
41
- def initialize
42
- DEFAULTS.each { |name, value| instance_variable_set("@#{name}", value) }
4
+ class ConfigError < ArgumentError; end
5
+
6
+ ROOM_HOSTS = %i[inline remote].freeze
7
+ ROOM_HOST_ENV = "CABLE_ROOM_HOST"
8
+ DEFAULT_PROVISION_DELAY_MS = 20
9
+
10
+ class << self
11
+ # Where rooms run. `:inline` (the default) keeps them in the web process; `:remote` moves
12
+ # them to a dedicated host pool. Set it in an initializer:
13
+ #
14
+ # CableRoom.room_host = :remote
15
+ #
16
+ # The CABLE_ROOM_HOST env var (`inline` or `remote`, any case) wins over the setter, so one
17
+ # build can run in either mode without a code change. The env var is read once, on first
18
+ # use; the Railtie reads it at boot so a bad value fails there instead of mid-request.
19
+ def room_host
20
+ @room_host_from_env = room_host_from_env unless defined?(@room_host_from_env)
21
+ @room_host_from_env || @room_host || :inline
43
22
  end
44
23
 
45
- # Where rooms run. `:inline` keeps them in the web process; `:remote` moves them to a
46
- # dedicated host pool. Accepts a Symbol or String; unknown values raise right away.
24
+ # Accepts a Symbol or String; unknown values raise CableRoom::ConfigError
47
25
  def room_host=(value)
48
- @room_host = validate_choice!(:room_host, value, ROOM_HOSTS)
49
- end
50
-
51
- # How a room pushes messages to members. Accepts a Symbol or String; unknown values raise.
52
- def broadcaster=(value)
53
- @broadcaster = validate_choice!(:broadcaster, value, BROADCASTERS)
26
+ @room_host = cast_room_host(value) do
27
+ "CableRoom.room_host must be one of #{ROOM_HOSTS.map(&:inspect).join(', ')} (got #{value.inspect})"
28
+ end
54
29
  end
55
30
 
56
- # Rooms run inside every process that joins them (the web process in a Rails app).
57
31
  def inline?
58
32
  room_host == :inline
59
33
  end
60
34
 
61
- # Rooms run only in `cable_room server` processes; a web process never hosts one.
62
35
  def remote?
63
36
  room_host == :remote
64
37
  end
65
38
 
66
- # Copies CABLE_ROOM_HOST and CABLE_ROOM_BROADCASTER onto this config. Blank or missing
67
- # variables leave the current value alone. `env` is injectable for specs.
68
- def apply_env_overrides(env = ENV)
69
- ENV_OVERRIDES.each do |name, var|
70
- value = env[var]
71
- next if value.nil? || value.strip.empty?
39
+ # How long a rooms Host waits, per room it already runs, before racing for a room a member
40
+ # asked for (see Host::Placement). The least-loaded Host wakes first and usually wins, so
41
+ # this is what spreads rooms across a pool. Milliseconds; defaults to 20. `nil` restores it.
42
+ def provision_delay_ms
43
+ @provision_delay_ms || DEFAULT_PROVISION_DELAY_MS
44
+ end
72
45
 
73
- public_send("#{name}=", value)
46
+ def provision_delay_ms=(value)
47
+ unless value.nil? || (value.is_a?(Numeric) && value >= 0)
48
+ raise ConfigError, "CableRoom.provision_delay_ms must be a number >= 0 (got #{value.inspect})"
74
49
  end
75
- self
76
- end
77
50
 
78
- # Checks every knob at once. The choice knobs are already checked by their setters, so
79
- # this mostly guards the timing knobs, which are plain accessors.
80
- def validate!
81
- validate_choice!(:room_host, room_host, ROOM_HOSTS)
82
- validate_choice!(:broadcaster, broadcaster, BROADCASTERS)
83
- validate_number!(:provision_delay_ms, provision_delay_ms, min: 0)
84
- validate_number!(:drain_timeout, drain_timeout, min: 0, exclusive: true)
85
- validate_number!(:handoff_timeout, handoff_timeout, min: 0, exclusive: true)
86
- self
51
+ @provision_delay_ms = value
87
52
  end
88
53
 
89
- def to_h
90
- DEFAULTS.keys.to_h { |name| [name, public_send(name)] }
54
+ # Forgets both the setter and the env var, so the next read starts over. For specs.
55
+ def reset_room_host!
56
+ remove_instance_variable(:@room_host) if defined?(@room_host)
57
+ remove_instance_variable(:@room_host_from_env) if defined?(@room_host_from_env)
91
58
  end
92
59
 
93
60
  private
94
61
 
95
- def validate_choice!(name, value, allowed)
96
- symbol = value.to_s.to_sym unless value.nil?
97
- return symbol if allowed.include?(symbol)
62
+ def room_host_from_env
63
+ value = ENV[ROOM_HOST_ENV]
64
+ return nil if value.nil? || value.strip.empty?
98
65
 
99
- raise InvalidOption,
100
- "CableRoom config: #{name} must be one of #{allowed.map(&:inspect).join(', ')} " \
101
- "(got #{value.inspect})"
66
+ cast_room_host(value.strip.downcase) do
67
+ "#{ROOM_HOST_ENV} must be one of #{ROOM_HOSTS.join(', ')} (got #{value.inspect})"
68
+ end
102
69
  end
103
70
 
104
- def validate_number!(name, value, min:, exclusive: false)
105
- ok = value.is_a?(Numeric) && (exclusive ? value > min : value >= min)
106
- return value if ok
71
+ def cast_room_host(value)
72
+ symbol = value.to_s.to_sym unless value.nil?
73
+ return symbol if ROOM_HOSTS.include?(symbol)
107
74
 
108
- bound = exclusive ? "greater than #{min}" : "at least #{min}"
109
- raise InvalidOption, "CableRoom config: #{name} must be a number #{bound} (got #{value.inspect})"
75
+ raise ConfigError, yield
110
76
  end
111
77
  end
112
78
  end
@@ -1,10 +1,8 @@
1
1
  module CableRoom
2
2
  class Host
3
3
  # How rooms receive messages: off the process-wide CableRoom::Bus (see `CableRoom.bus`), on
4
- # the channels `Room::Base.inbound_channel` names. This is the only room-side code that
5
- # touches the inbound transport, and it's the same in `:inline` and `:remote` mode — the only
6
- # difference is which process the Host lives in. Anything with these two methods can be handed
7
- # to `Host.new(inbound:)`.
4
+ # the channels `Bus.inbound_channel` names. This is the only room-side code that touches the
5
+ # inbound transport; anything with these two methods can be handed to `Host.new(inbound:)`.
8
6
  #
9
7
  # `subscribe` returns a handle that `unsubscribe` needs back. Handlers get the decoded message
10
8
  # on the Bus's single subscriber thread, in publish order, so they must be quick and hand the
@@ -12,22 +10,29 @@ module CableRoom
12
10
  # Redis confirms the subscription, so `on_live` runs on the caller's thread right before it
13
11
  # returns, and anything published after that is delivered.
14
12
  class BusInbound
15
- # `bus` defaults to `CableRoom.bus` (looked up lazily so specs can swap it)
16
- def initialize(bus: nil)
13
+ # `bus` defaults to `CableRoom.bus` (looked up lazily so specs can swap it). `timeout` is how
14
+ # long `subscribe` and `unsubscribe` wait for Redis to confirm.
15
+ def initialize(bus: nil, timeout: Bus::DEFAULT_TIMEOUT)
17
16
  @bus = bus
17
+ @timeout = timeout
18
18
  end
19
19
 
20
20
  def subscribe(channel, on_live: nil, &on_message)
21
- bus.subscribe(channel) { |message, _channel| on_message.call(message) }
21
+ handle = ->(message, _channel) { on_message.call(message) }
22
+ bus.subscribe(channel, timeout: @timeout, &handle)
22
23
  on_live&.call
23
- on_message
24
+ handle
24
25
  end
25
26
 
26
- def unsubscribe(channel, _handle)
27
- bus.unsubscribe(channel)
27
+ # The Bus keeps one handler per channel, so only let go of the channel while it's still
28
+ # ours. A runner undoing a late subscribe (see Runner#subscribe) must not drop the handler
29
+ # of the next runner for the same room.
30
+ def unsubscribe(channel, handle)
31
+ bus.unsubscribe(channel, handler: handle, timeout: @timeout)
28
32
  end
29
33
 
30
- # The Bus behind this transport (the process-wide one unless a spec handed us another)
34
+ private
35
+
31
36
  def bus
32
37
  @bus || CableRoom.bus
33
38
  end
@@ -0,0 +1,261 @@
1
+ require 'active_support/inflector'
2
+
3
+ module CableRoom
4
+ class Host
5
+ # The rooms side of provisioning. In :remote a `create: true` member can't start its room, so
6
+ # it publishes a provision request on `Bus.provision_channel` (see
7
+ # RoomMembership#request_remote_room). Every rooms Host runs one Placement, which hears every
8
+ # request and decides whether this Host should be the one to start the room.
9
+ #
10
+ # The decision is a race weighted by load. Each Host waits `CableRoom.provision_delay_ms` for
11
+ # every room it already runs, plus up to one room's worth of jitter to break ties, then tries
12
+ # the room's Redlock once through `Host#ensure_room`. The least-loaded Host wakes first and
13
+ # usually wins; the rest find the lock held and do nothing. Losing is normal and quiet.
14
+ #
15
+ # Requests arrive on the Bus subscriber thread, which must stay quick, and a Bus subscribe made
16
+ # from that thread doesn't wait for Redis to confirm it, so a room started there would take
17
+ # traffic before it's listening. So `handle` only validates and schedules; the wait and the
18
+ # claim run later on a small executor of Placement's own. It's bounded twice: CLAIM_THREADS
19
+ # claims run at once, and at most MAX_PENDING wait at a time (more are dropped; the member
20
+ # asks again on its next ping).
21
+ #
22
+ # The request carries the member's tenant, and the claim runs switched into it, so the lock
23
+ # key, the inbound channel, and the stream names come out the same as on the web side under a
24
+ # tenant-prefixing `broadcasting_for` (PandaPal's).
25
+ #
26
+ # Specs make the race deterministic with `jitter: -> { 0 }` (any callable returning 0..1).
27
+ class Placement
28
+ # A request this Host can't act on. Logged and dropped, never raised to the Bus.
29
+ class InvalidRequest < ArgumentError; end
30
+
31
+ REQUEST_TYPE = "provision".freeze
32
+
33
+ # How many claims run at once. A claim is mostly a Redlock call and, for the winner, room
34
+ # startup (a few Bus subscribes), so a couple of threads keep up with a burst.
35
+ CLAIM_THREADS = 2
36
+
37
+ # How many claims may wait on their timer at once. Past this, requests are dropped.
38
+ MAX_PENDING = 100
39
+
40
+ # Only something shaped like a constant name gets near `safe_constantize`
41
+ CLASS_NAME = /\A(::)?[A-Z]\w*(::[A-Z]\w*)*\z/
42
+
43
+ attr_reader :host, :channel
44
+
45
+ # `jitter` returns a Float in 0..1 (defaults to `rand`).
46
+ def initialize(host, jitter: nil)
47
+ @host = host
48
+ @jitter = jitter || -> { Kernel.rand }
49
+ @channel = Bus.provision_channel
50
+
51
+ # Everything below is guarded by @mutex, which is never held across a call into the Bus
52
+ @mutex = Mutex.new
53
+ @idle = ConditionVariable.new
54
+ @pending = {} # request identity => its scheduled claim, so one room never queues twice here
55
+ @stopped = true
56
+ @handle = nil
57
+ @executor = nil
58
+ end
59
+
60
+ # Subscribe to the provision channel on the Host's inbound transport. Blocks until Redis
61
+ # confirms, so a request published after this returns is heard. Calling it while running
62
+ # does nothing.
63
+ def start
64
+ @mutex.synchronize do
65
+ return self unless @stopped
66
+
67
+ @stopped = false
68
+ @executor = Concurrent::ThreadPoolExecutor.new(
69
+ name: "cable_room-placement",
70
+ min_threads: 0,
71
+ max_threads: CLAIM_THREADS,
72
+ max_queue: 0, # MAX_PENDING bounds what can be queued
73
+ )
74
+ end
75
+
76
+ handle = host.inbound.subscribe(channel) { |request| self.handle(request) }
77
+ @mutex.synchronize { @handle = handle }
78
+ self
79
+ rescue StandardError
80
+ stop
81
+ raise
82
+ end
83
+
84
+ # Stop hearing requests and drop every claim still waiting on its timer, then wait (up to
85
+ # `wait` seconds) for a claim already mid-attempt to finish, so nothing starts a room here
86
+ # after this returns.
87
+ def stop(wait: 5)
88
+ handle, executor = @mutex.synchronize do
89
+ return self if @stopped
90
+
91
+ @stopped = true
92
+ [@handle, @executor].tap do
93
+ @handle = nil
94
+ @executor = nil
95
+ end
96
+ end
97
+
98
+ host.inbound.unsubscribe(channel, handle) if handle
99
+
100
+ # A cancelled task never runs its block (and so never its `finish`), so forget it here
101
+ tasks = @mutex.synchronize { @pending.to_a }
102
+ tasks.each { |id, task| finish(id) if task.cancel }
103
+ wait_idle(timeout: wait)
104
+ executor&.shutdown
105
+ self
106
+ end
107
+
108
+ def running?
109
+ @mutex.synchronize { !@stopped }
110
+ end
111
+
112
+ # How many claims are waiting on their timer or mid-attempt right now
113
+ def pending_count
114
+ @mutex.synchronize { @pending.size }
115
+ end
116
+
117
+ # Block until every scheduled claim has finished (won, lost, or been cancelled). Returns
118
+ # false if `timeout` passes first.
119
+ def wait_idle(timeout: 5)
120
+ deadline = monotonic_now + timeout
121
+ @mutex.synchronize do
122
+ until @pending.empty?
123
+ remaining = deadline - monotonic_now
124
+ return false if remaining <= 0
125
+ @idle.wait(@mutex, remaining)
126
+ end
127
+ end
128
+ true
129
+ end
130
+
131
+ # One request off the Bus, on its subscriber thread. Only the cheap checks happen here; the
132
+ # delayed claim is scheduled on the executor. Returns the scheduled task, or nil when there
133
+ # is nothing to do.
134
+ def handle(request)
135
+ room_class, serialized_key, tenant = parse(request)
136
+ id = [room_class.name, serialized_key, tenant]
137
+
138
+ return skip(id, "host is shutting down") if host.shutdown?
139
+
140
+ load = host.open_rooms_count
141
+ delay = delay_for(load)
142
+
143
+ @mutex.synchronize do
144
+ return skip(id, "placement is stopped") if @stopped
145
+ return skip(id, "a claim is already pending here") if @pending.key?(id)
146
+ return skip(id, "#{MAX_PENDING} claims are already pending here") if @pending.size >= MAX_PENDING
147
+
148
+ task = Concurrent::ScheduledTask.new(delay, executor: @executor) do
149
+ claim(room_class, serialized_key, tenant, delay: delay, load: load)
150
+ ensure
151
+ finish(id)
152
+ end
153
+ @pending[id] = task
154
+ task.execute
155
+ end
156
+ rescue InvalidRequest => e
157
+ logger.warn "Dropping provision request: #{e.message}"
158
+ nil
159
+ end
160
+
161
+ # Seconds to wait before racing for a room, given how many rooms this Host already runs:
162
+ # `provision_delay_ms × (load + jitter)`, jitter in 0..1. A Host with more rooms always waits
163
+ # longer than one with fewer; the jitter only orders Hosts with the same load.
164
+ def delay_for(load)
165
+ CableRoom.provision_delay_ms / 1000.0 * (load + @jitter.call.to_f)
166
+ end
167
+
168
+ private
169
+
170
+ # After the delay, on the executor. Re-checks what may have changed while we waited, then
171
+ # tries the room's lock once. The key is deserialized here, in the tenant, because a record
172
+ # key is a database lookup.
173
+ def claim(room_class, serialized_key, tenant, delay:, load:)
174
+ return if stopped? || host.shutdown?
175
+
176
+ CableRoom.with_app_executor do
177
+ CableRoom.with_tenant(tenant) do
178
+ key = deserialize_key(serialized_key)
179
+ next if running_here?(room_class, key, tenant)
180
+
181
+ payload = { host: host, room_class: room_class, room_key: key, tenant: tenant, delay: delay, open_rooms: load }
182
+ claimed = ActiveSupport::Notifications.instrument("provision_claimed.cable_room", payload) do |p|
183
+ p[:claimed] = host.ensure_room(room_class, key, tenant: tenant)
184
+ end
185
+
186
+ name = "#{room_class.name}[#{key}]"
187
+ if claimed
188
+ logger.info "Claimed #{name} after #{(delay * 1000).round}ms with #{load} room(s) open"
189
+ else
190
+ logger.debug "Lost the race for #{name}: another host holds its lock"
191
+ end
192
+ end
193
+ end
194
+ rescue InvalidRequest => e
195
+ logger.warn "Dropping provision request: #{e.message}"
196
+ rescue StandardError => e
197
+ logger.error "Failed to claim #{room_class.name}: #{e.class}: #{e.message}"
198
+ CableRoom.report_error(e, placement: self, room_class: room_class, room_key: serialized_key, tenant: tenant)
199
+ end
200
+
201
+ def finish(id)
202
+ @mutex.synchronize do
203
+ @pending.delete(id)
204
+ @idle.broadcast if @pending.empty?
205
+ end
206
+ end
207
+
208
+ def skip(id, why)
209
+ logger.debug "Ignoring provision request for #{id.inspect}: #{why}"
210
+ nil
211
+ end
212
+
213
+ def stopped?
214
+ @mutex.synchronize { @stopped }
215
+ end
216
+
217
+ def running_here?(room_class, key, tenant)
218
+ host.runners.any? { |r| r.room_class == room_class && r.key == key && r.tenant == tenant }
219
+ end
220
+
221
+ # Check a decoded Bus message's shape and find its Room class. The class name comes off the
222
+ # wire, so only a CableRoom::Room::Base subclass is accepted: `constantize` on an arbitrary
223
+ # string is not something a request should be able to do.
224
+ def parse(request)
225
+ raise InvalidRequest, "not a Hash: #{request.inspect.truncate(200)}" unless request.is_a?(Hash)
226
+
227
+ type = request["type"]
228
+ raise InvalidRequest, "expected a #{REQUEST_TYPE.inspect} request (got #{type.inspect.truncate(100)})" unless type == REQUEST_TYPE
229
+
230
+ name = request["room_class"]
231
+ klass = ActiveSupport::Inflector.safe_constantize(name) if name.is_a?(String) && name.match?(CLASS_NAME)
232
+ unless klass.is_a?(Class) && klass < Room::Base
233
+ raise InvalidRequest, "#{name.inspect.truncate(100)} is not a CableRoom::Room::Base subclass"
234
+ end
235
+
236
+ # The key travels the way `extra` does (ActiveJob's argument serializer): a one-item list
237
+ key = request["room_key"]
238
+ raise InvalidRequest, "room_key must be a serialized one-item list (got #{key.inspect.truncate(100)})" unless key.is_a?(Array) && key.size == 1
239
+
240
+ tenant = request["tenant"]
241
+ raise InvalidRequest, "tenant must be a String or null (got #{tenant.inspect.truncate(100)})" unless tenant.nil? || tenant.is_a?(String)
242
+
243
+ [klass, key, tenant]
244
+ end
245
+
246
+ def deserialize_key(serialized)
247
+ ::ActiveJob::Arguments.deserialize(serialized).first
248
+ rescue StandardError => e
249
+ raise InvalidRequest, "room_key can't be deserialized: #{e.class}: #{e.message}"
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
261
+ end