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.
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CableRoom
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
22
+ end
23
+
24
+ # Accepts a Symbol or String; unknown values raise CableRoom::ConfigError
25
+ def room_host=(value)
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
29
+ end
30
+
31
+ def inline?
32
+ room_host == :inline
33
+ end
34
+
35
+ def remote?
36
+ room_host == :remote
37
+ end
38
+
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
45
+
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})"
49
+ end
50
+
51
+ @provision_delay_ms = value
52
+ end
53
+
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)
58
+ end
59
+
60
+ private
61
+
62
+ def room_host_from_env
63
+ value = ENV[ROOM_HOST_ENV]
64
+ return nil if value.nil? || value.strip.empty?
65
+
66
+ cast_room_host(value.strip.downcase) do
67
+ "#{ROOM_HOST_ENV} must be one of #{ROOM_HOSTS.join(', ')} (got #{value.inspect})"
68
+ end
69
+ end
70
+
71
+ def cast_room_host(value)
72
+ symbol = value.to_s.to_sym unless value.nil?
73
+ return symbol if ROOM_HOSTS.include?(symbol)
74
+
75
+ raise ConfigError, yield
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,41 @@
1
+ module CableRoom
2
+ class Host
3
+ # How rooms receive messages: off the process-wide CableRoom::Bus (see `CableRoom.bus`), on
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:)`.
6
+ #
7
+ # `subscribe` returns a handle that `unsubscribe` needs back. Handlers get the decoded message
8
+ # on the Bus's single subscriber thread, in publish order, so they must be quick and hand the
9
+ # real work off; Host::Runner does that by queueing it on the room. `subscribe` blocks until
10
+ # Redis confirms the subscription, so `on_live` runs on the caller's thread right before it
11
+ # returns, and anything published after that is delivered.
12
+ class BusInbound
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)
16
+ @bus = bus
17
+ @timeout = timeout
18
+ end
19
+
20
+ def subscribe(channel, on_live: nil, &on_message)
21
+ handle = ->(message, _channel) { on_message.call(message) }
22
+ bus.subscribe(channel, timeout: @timeout, &handle)
23
+ on_live&.call
24
+ handle
25
+ end
26
+
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)
32
+ end
33
+
34
+ private
35
+
36
+ def bus
37
+ @bus || CableRoom.bus
38
+ end
39
+ end
40
+ end
41
+ 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
@@ -2,7 +2,7 @@ module CableRoom
2
2
  class Host
3
3
  # Runs one Room for the Host. It holds everything that is per-room but not the room's own
4
4
  # business: the ordered work queue, lifecycle state, the Redlock and watchdog, the inbound
5
- # stream subscriptions, and the periodic timers.
5
+ # Bus subscriptions, and the periodic timers.
6
6
  #
7
7
  # A Room talks to its runner (`@runner`) for anything that touches threads or the outside
8
8
  # world. The Room DSL (`shutdown!`, `stop!`, `async`, `on_room_thread`, `ports[]`,
@@ -20,7 +20,7 @@ module CableRoom
20
20
 
21
21
  delegate :worker_pool, :inbound, to: :host
22
22
 
23
- def initialize(host, room_class, key, lock_info, watchdog_interval:, lock_duration:)
23
+ def initialize(host, room_class, key, lock_info, watchdog_interval:, lock_duration:, tenant: nil)
24
24
  @host = host
25
25
  @room_class = room_class
26
26
  @key = key
@@ -42,8 +42,10 @@ module CableRoom
42
42
 
43
43
  # Nothing in the gem reads this, but the runner is the "connection" for its work items
44
44
  # (see WorkerPool), and PandaPal's Worker :work hook and broadcasting_for read
45
- # `connection.tenant` to pick the room's tenant. Don't remove it.
46
- @tenant = Apartment::Tenant.current if defined?(Apartment)
45
+ # `connection.tenant` to pick the room's tenant. Don't remove it. It's whatever the Host
46
+ # was told (see Host#ensure_room), never read from ambient state here: in a rooms process
47
+ # the thread building the runner has no request tenant of its own.
48
+ @tenant = tenant
47
49
 
48
50
  @logger = ActionCable::Connection::TaggedLoggerProxy.new(
49
51
  host.logger,
@@ -212,14 +214,14 @@ module CableRoom
212
214
 
213
215
  # -- Inbound streams ---------------------------------------------------------------------
214
216
 
215
- # Deliver every message published on `stream` to `handler`, on the room's own queue.
216
- # Decoding happens on the room's thread too, so a bad payload is reported like any other
217
- # work error instead of hurting the transport. `on_live` runs once the transport confirms
218
- # the subscription; anything published before that may be missed.
217
+ # Deliver every message published on `stream` to `handler`, on the room's own queue. The
218
+ # transport hands us decoded messages on its own thread; all we do there is queue, so one
219
+ # room's handler can't hold up another room's traffic. `on_live` runs once the transport
220
+ # confirms the subscription; anything published before that may be missed.
219
221
  #
220
222
  # The subscribe itself happens outside the mutex (see the class comment), so a room that
221
223
  # stopped meanwhile undoes it.
222
- def subscribe(stream, coder: ActiveSupport::JSON, on_live: nil, &handler)
224
+ def subscribe(stream, on_live: nil, &handler)
223
225
  raise ArgumentError, "Block required" unless handler
224
226
 
225
227
  stream = String(stream)
@@ -230,10 +232,8 @@ module CableRoom
230
232
  on_live&.call
231
233
  end
232
234
 
233
- handle = inbound.subscribe(stream, on_live: confirmed) do |raw|
234
- post_work(async: false, silent: true) do
235
- handler.call(coder ? coder.decode(raw) : raw)
236
- end
235
+ handle = inbound.subscribe(stream, on_live: confirmed) do |message|
236
+ post_work(async: false, silent: true) { handler.call(message) }
237
237
  end
238
238
 
239
239
  stopped = @mutex.synchronize do
@@ -329,15 +329,9 @@ module CableRoom
329
329
  end
330
330
 
331
331
  # Room startup and shutdown used to run inside ActionCable's subscribe/unsubscribe
332
- # callbacks, which Rails wraps in its executor (database connections, reloading, and so
333
- # on). Keep that behavior. Nesting is fine: the executor yields straight through when it's
334
- # already active on this thread.
332
+ # callbacks, which Rails wraps in its executor. Keep that behavior.
335
333
  def with_executor(&blk)
336
- if defined?(Rails) && Rails.application
337
- Rails.application.executor.wrap(&blk)
338
- else
339
- yield
340
- end
334
+ CableRoom.with_app_executor(&blk)
341
335
  end
342
336
  end
343
337
  end