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.
@@ -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's inbound subscription is
13
- # registered asynchronously — and the room may live in another process entirely — so a
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
- protected
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
- super(port, data)
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.instance.shutdown?
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 = Host.instance.scheduler.schedule_in(@reannounce_delay.to_f) do
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&.unschedule
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 Host.instance.shutdown?
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
@@ -1,3 +1,3 @@
1
1
  module CableRoom
2
- VERSION = "0.8.0.beta1".freeze
2
+ VERSION = "0.8.0.beta2".freeze
3
3
  end
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.beta1
4
+ version: 0.8.0.beta2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ethan Knapp
8
- bindir: bin
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: '0'
81
- type: :development
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: '0'
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/action_cable_inbound.rb
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