cable_room 0.6.2.beta1 → 0.7.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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +122 -0
  3. data/README.md +662 -48
  4. data/cable_room.gemspec +5 -2
  5. data/exe/cable_room +8 -0
  6. data/lib/cable_room/broadcaster.rb +116 -0
  7. data/lib/cable_room/bus.rb +372 -0
  8. data/lib/cable_room/cli.rb +237 -0
  9. data/lib/cable_room/config.rb +112 -0
  10. data/lib/cable_room/host/bus_inbound.rb +36 -0
  11. data/lib/cable_room/host/runner.rb +577 -0
  12. data/lib/cable_room/host/supervisor.rb +275 -0
  13. data/lib/cable_room/host/worker_pool.rb +37 -0
  14. data/lib/cable_room/host.rb +482 -0
  15. data/lib/cable_room/membership_store.rb +105 -0
  16. data/lib/cable_room/migration.rb +586 -0
  17. data/lib/cable_room/periodic_timer.rb +18 -0
  18. data/lib/cable_room/placement.rb +260 -0
  19. data/lib/cable_room/ports.rb +20 -50
  20. data/lib/cable_room/railtie.rb +3 -12
  21. data/lib/cable_room/room/base.rb +45 -39
  22. data/lib/cable_room/room/host_adapter.rb +52 -0
  23. data/lib/cable_room/room/lifecycle.rb +26 -9
  24. data/lib/cable_room/room/port_management.rb +70 -2
  25. data/lib/cable_room/room/reaping.rb +34 -1
  26. data/lib/cable_room/room/snapshotting.rb +82 -0
  27. data/lib/cable_room/room/threading.rb +2 -2
  28. data/lib/cable_room/room/user_management.rb +27 -0
  29. data/lib/cable_room/room.rb +5 -2
  30. data/lib/cable_room/room_harness.rb +168 -0
  31. data/lib/cable_room/room_member.rb +293 -69
  32. data/lib/cable_room/room_proxy_channel.rb +13 -2
  33. data/lib/cable_room/snapshot.rb +136 -0
  34. data/lib/cable_room/version.rb +1 -1
  35. data/lib/cable_room.rb +57 -2
  36. metadata +26 -9
  37. data/lib/cable_room/channel_base.rb +0 -247
  38. data/lib/cable_room/channel_tracker.rb +0 -130
  39. data/lib/cable_room/room/channel_adapter.rb +0 -18
data/lib/cable_room.rb CHANGED
@@ -8,10 +8,18 @@ require 'redlock'
8
8
  require 'rufus-scheduler'
9
9
 
10
10
  require_relative 'cable_room/railtie'
11
+ require_relative 'cable_room/config'
12
+ require_relative 'cable_room/broadcaster'
11
13
 
12
- require_relative 'cable_room/channel_base'
13
- require_relative 'cable_room/channel_tracker'
14
+ require_relative 'cable_room/bus'
15
+
16
+ require_relative 'cable_room/periodic_timer'
17
+ require_relative 'cable_room/snapshot'
18
+ require_relative 'cable_room/host'
19
+ require_relative 'cable_room/placement'
20
+ require_relative 'cable_room/migration'
14
21
  require_relative 'cable_room/ports'
22
+ require_relative 'cable_room/membership_store'
15
23
  require_relative 'cable_room/room_member'
16
24
  require_relative 'cable_room/room_proxy_channel'
17
25
  require_relative 'cable_room/room/'
@@ -36,6 +44,27 @@ module CableRoom
36
44
  warn "CableRoom.error_handler raised #{handler_error.class}: #{handler_error.message}"
37
45
  end
38
46
 
47
+ # The active CableRoom::Config. Built on first use with the defaults plus any env
48
+ # overrides, so an app that never calls `configure` still gets a valid config.
49
+ def config
50
+ @config ||= Config.new.apply_env_overrides.validate!
51
+ end
52
+
53
+ # Yields the config so an app can set the knobs, then re-applies the env overrides
54
+ # (CABLE_ROOM_HOST, CABLE_ROOM_BROADCASTER) and checks every value. Bad values raise
55
+ # here, at boot, instead of somewhere deep in a room later.
56
+ def configure
57
+ cfg = @config || Config.new
58
+ yield cfg if block_given?
59
+ @config = cfg.apply_env_overrides.validate!
60
+ end
61
+
62
+ # Drops the current config so the next `config` call rebuilds it, which also makes
63
+ # `Broadcaster.current` rebuild (and forget any `Broadcaster.current=` override). Meant for specs.
64
+ def reset_config!
65
+ @config = nil
66
+ end
67
+
39
68
  def redis_pool
40
69
  require 'rediconn'
41
70
  @redis_pool ||= RediConn::RedisConnection.create(env_prefix: "CABLEROOM")
@@ -50,5 +79,31 @@ module CableRoom
50
79
  CableRoom.redis,
51
80
  ])
52
81
  end
82
+
83
+ # The process-wide Redis bus (see CableRoom::Bus). Built on first use.
84
+ def bus
85
+ @bus ||= Bus.new
86
+ end
87
+
88
+ # Call this in a child process right after `fork`, before it touches Redis or hosts a room.
89
+ # `cable_room server --workers N` does it for every worker (see Host::Supervisor).
90
+ #
91
+ # A forked child starts with copies of the parent's objects but only one thread, so anything
92
+ # that owns a socket or a thread is broken in the child: the Bus subscriber thread doesn't
93
+ # exist, its mutex may be held by nobody, and a Redis socket is shared with the parent, so
94
+ # both processes would read each other's replies. Dropping the memos means the next caller
95
+ # builds fresh ones. The connection pool and redis-client also notice the fork on their own
96
+ # (both hook `Process._fork` and drop inherited sockets), and Rails does the same for
97
+ # ActiveRecord, so those need nothing from us; this keeps the gem's rule simple: nothing
98
+ # Redis-backed survives a fork.
99
+ #
100
+ # The Host goes too. A parent that forks workers must never host rooms itself, and a Host
101
+ # copied from a parent would have no worker threads and no scheduler thread behind it.
102
+ def after_fork!
103
+ @bus = nil
104
+ @lock_manager = nil
105
+ @redis_pool = nil
106
+ Host.replace_current(nil)
107
+ end
53
108
  end
54
109
  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.6.2.beta1
4
+ version: 0.7.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,14 @@ dependencies:
77
77
  requirements:
78
78
  - - ">="
79
79
  - !ruby/object:Gem::Version
80
- version: '0'
81
- type: :development
80
+ version: '5.0'
81
+ type: :runtime
82
82
  prerelease: false
83
83
  version_requirements: !ruby/object:Gem::Requirement
84
84
  requirements:
85
85
  - - ">="
86
86
  - !ruby/object:Gem::Version
87
- version: '0'
87
+ version: '5.0'
88
88
  - !ruby/object:Gem::Dependency
89
89
  name: rspec
90
90
  requirement: !ruby/object:Gem::Requirement
@@ -101,32 +101,49 @@ dependencies:
101
101
  version: '3'
102
102
  email:
103
103
  - eknapp@instructure.com
104
- executables: []
104
+ executables:
105
+ - cable_room
105
106
  extensions: []
106
107
  extra_rdoc_files: []
107
108
  files:
109
+ - CHANGELOG.md
108
110
  - README.md
109
111
  - cable_room.gemspec
112
+ - exe/cable_room
110
113
  - lib/cable_room.rb
111
- - lib/cable_room/channel_base.rb
112
- - lib/cable_room/channel_tracker.rb
114
+ - lib/cable_room/broadcaster.rb
115
+ - lib/cable_room/bus.rb
116
+ - lib/cable_room/cli.rb
117
+ - lib/cable_room/config.rb
118
+ - lib/cable_room/host.rb
119
+ - lib/cable_room/host/bus_inbound.rb
120
+ - lib/cable_room/host/runner.rb
121
+ - lib/cable_room/host/supervisor.rb
122
+ - lib/cable_room/host/worker_pool.rb
123
+ - lib/cable_room/membership_store.rb
124
+ - lib/cable_room/migration.rb
125
+ - lib/cable_room/periodic_timer.rb
126
+ - lib/cable_room/placement.rb
113
127
  - lib/cable_room/ports.rb
114
128
  - lib/cable_room/railtie.rb
115
129
  - lib/cable_room/room.rb
116
130
  - lib/cable_room/room/base.rb
117
131
  - lib/cable_room/room/broadcasting.rb
118
132
  - lib/cable_room/room/callbacks.rb
119
- - lib/cable_room/room/channel_adapter.rb
133
+ - lib/cable_room/room/host_adapter.rb
120
134
  - lib/cable_room/room/input_handling.rb
121
135
  - lib/cable_room/room/lifecycle.rb
122
136
  - lib/cable_room/room/port_management.rb
123
137
  - lib/cable_room/room/port_policies.rb
124
138
  - lib/cable_room/room/port_scoping.rb
125
139
  - lib/cable_room/room/reaping.rb
140
+ - lib/cable_room/room/snapshotting.rb
126
141
  - lib/cable_room/room/threading.rb
127
142
  - lib/cable_room/room/user_management.rb
143
+ - lib/cable_room/room_harness.rb
128
144
  - lib/cable_room/room_member.rb
129
145
  - lib/cable_room/room_proxy_channel.rb
146
+ - lib/cable_room/snapshot.rb
130
147
  - lib/cable_room/version.rb
131
148
  homepage: https://instructure.com
132
149
  licenses: []
@@ -1,247 +0,0 @@
1
- module CableRoom
2
- class ChannelBase < ActionCable::Channel::Base
3
- attr_reader :room, :tenant
4
- attr_reader :server, :logger
5
- delegate :event_loop, :worker_pool, :pubsub, to: :server
6
-
7
- def initialize(lock_info, room_class, key, config)
8
- # We don't really have a "connection" in the ActionCable sense, so
9
- # stuff in something that half looks like one
10
- super(DummyConnection.new(self), "Room[]", {})
11
-
12
- # Used mainly for logs and being able to follow a specific Room instance
13
- @uuid = SecureRandom.hex(6)
14
-
15
- @mutex = Monitor.new
16
- @lock_info = lock_info
17
- @current_state = :initializing
18
-
19
- @tenant = Apartment::Tenant.current if defined?(Apartment)
20
-
21
- @server = ChannelTracker.instance
22
- @logger = ActionCable::Connection::TaggedLoggerProxy.new(
23
- @server.logger,
24
- tags: ["#{room_class.name} #{@uuid}"]
25
- )
26
-
27
- logger.info "Initializing new #{room_class.name}"
28
- logger.info " UUID: #{@uuid}"
29
- logger.info " Key: #{room_class.room_port_key(key)}"
30
-
31
- @watchdog_interval = config[:watchdog_interval]
32
- @lock_duration = config[:lock_duration]
33
-
34
- @processing_work = false
35
- @work_queue = []
36
-
37
- @room = room_class.new(self, key)
38
- @server.track_room_channel self
39
- ping_watchdog
40
- end
41
-
42
- def self.channel_name
43
- module_parent.name
44
- end
45
-
46
- def stream_from(...)
47
- raise ArgumentError, "Block required" unless block_given?
48
- super
49
- end
50
-
51
- def state
52
- @current_state
53
- end
54
-
55
- after_subscribe do
56
- begin
57
- @current_state = :starting
58
- @room.send(:_startup)
59
- @current_state = :started
60
- rescue => e
61
- terminate!
62
- raise e
63
- end
64
- end
65
-
66
- after_unsubscribe do
67
- @mutex.synchronize do
68
- @current_state = :shutting_down
69
- stop_all_streams
70
- begin
71
- @room.send(:_shutdown)
72
- ensure
73
- terminate!
74
- end
75
- end
76
- end
77
-
78
- def ping_watchdog
79
- return if state == :dead
80
-
81
- logger.debug "Ping watchdog"
82
- @last_watchdog_ping_at = Time.current
83
- end
84
-
85
- def check_room_watchdog
86
- @mutex.synchronize do
87
- return if state == :dead || state == :shutting_down
88
- end
89
-
90
- relock = CableRoom.lock_manager.lock(@lock_info[:resource], @lock_duration.in_milliseconds, extend: @lock_info)
91
- unless relock
92
- logger.warn "Lost lock, shutting down"
93
- unsubscribe_from_channel
94
- return
95
- end
96
-
97
- unless @last_watchdog_ping_at && @last_watchdog_ping_at > @watchdog_interval.ago
98
- logger.warn "Watchdog timeout for room #{@room.class.name}[#{@room.key}], shutting down"
99
- initiate_shutdown("Watchdog timeout")
100
- return
101
- end
102
- end
103
-
104
- def initiate_shutdown(reason)
105
- @mutex.synchronize do
106
- return if @current_state == :dead || @current_state == :shutting_down
107
-
108
- logger.info "Initiating shutdown: #{reason}"
109
-
110
- # Stop streams immediately to prevent further messages from being added
111
- stop_all_streams
112
-
113
- # Append the final unsubscribe to the work queue so we can process remaining messages first
114
- post_work(async: false) do
115
- unsubscribe_from_channel
116
- end
117
-
118
- @current_state = :shutting_down
119
- end
120
- end
121
-
122
- def terminate!
123
- @mutex.synchronize do
124
- stop_all_streams
125
- @current_state = :dead
126
- CableRoom.lock_manager.unlock(@lock_info) if @lock_info
127
- server.untrack_room_channel self
128
- end
129
- end
130
-
131
- def _post_wrapped_work(async: false, silent: false, &blk)
132
- if async
133
- # Async stuff is mostly untracked - we just post it to the worker pool and forget about it
134
- worker_pool.executor.post(&blk)
135
- else
136
- @mutex.synchronize do
137
- if @current_state == :dead || @current_state == :shutting_down
138
- raise "Attempt to post work to dead or shutting down room" unless silent
139
- return
140
- end
141
- @work_queue << blk
142
- end
143
- schedule_work
144
- end
145
- end
146
-
147
- def post_work(**kwargs, &blk)
148
- _post_wrapped_work(**kwargs) do
149
- worker_pool.invoke(self, :instance_exec, connection: self, &blk)
150
- rescue => e
151
- report_work_error(e)
152
- end
153
- end
154
-
155
- # Work errors are swallowed so one bad message can't take the Room down with it. Log the
156
- # backtrace and hand the error to the application so the failure is still discoverable.
157
- def report_work_error(error)
158
- logger.error "Error during work execution: #{error.class.name}: #{error.message}"
159
- Array(error.backtrace).first(20).each { |line| logger.error " #{line}" }
160
-
161
- CableRoom.report_error(
162
- error,
163
- room: room,
164
- room_class: room&.class,
165
- room_key: room&.key,
166
- channel: self
167
- )
168
- end
169
-
170
- def beat
171
- post_work(async: true) do
172
- check_room_watchdog
173
- end
174
- end
175
-
176
- def transmit(*args)
177
- logger.info("Channel.transmit called, ignoring: #{args.inspect}")
178
- end
179
-
180
- protected
181
-
182
- def start_periodic_timer(callback, every:)
183
- raise "Attempt to start periodic timer on a dead room" if state == :dead || state == :shutting_down
184
-
185
- job = connection.server.scheduler.schedule_every(every) do
186
- post_work(async: false, silent: true) do
187
- instance_exec(&callback)
188
- end
189
- end
190
-
191
- PeriodicTimer.new(job)
192
- end
193
-
194
- def schedule_work
195
- @mutex.synchronize do
196
- return if @processing_work
197
-
198
- work = @work_queue.shift
199
- return unless work
200
-
201
- @processing_work = true
202
-
203
- worker_pool.executor.post do
204
- begin
205
- work.call
206
- ensure
207
- @mutex.synchronize do
208
- @processing_work = false
209
- end
210
- schedule_work
211
- end
212
- end
213
- end
214
- end
215
- end
216
-
217
- # ActionCable's `stop_periodic_timers` calls #shutdown on whatever #start_periodic_timer
218
- # returned, but Rufus jobs are cancelled with #unschedule. Adapt the one to the other here
219
- # rather than aliasing #shutdown onto Rufus::Scheduler::Job for the whole process.
220
- class PeriodicTimer
221
- attr_reader :job
222
-
223
- delegate :unschedule, :scheduled?, :next_time, to: :job
224
-
225
- def initialize(job)
226
- @job = job
227
- end
228
-
229
- def shutdown
230
- job.unschedule
231
- end
232
- end
233
-
234
- class DummyConnection
235
- attr_reader :channel
236
- delegate :server, :logger, :tenant, :transmit, :post_work, :_post_wrapped_work,
237
- :report_work_error, to: :channel
238
- delegate :event_loop, :pubsub, :worker_pool, to: :server
239
-
240
- attr_reader :identifiers
241
-
242
- def initialize(channel)
243
- @channel = channel
244
- @identifiers = []
245
- end
246
- end
247
- end
@@ -1,130 +0,0 @@
1
- module CableRoom
2
- class ChannelTracker
3
- def self.instance
4
- @instance ||= new
5
- end
6
-
7
- BEAT_INTERVAL = 5.seconds
8
-
9
- delegate :logger, :pubsub, :event_loop, :config, to: :cable_server
10
-
11
- attr_reader :room_channels, :scheduler
12
-
13
- def initialize
14
- @room_channels = Set.new
15
- @monitor = Monitor.new
16
-
17
- @scheduler = Rufus::Scheduler.new
18
-
19
- at_exit do
20
- logger.info "Shutting down CableRoom"
21
- shutdown!
22
- end
23
-
24
- scheduler.every(BEAT_INTERVAL) do
25
- each_room_channel do |chan|
26
- chan.beat
27
- end
28
- end
29
- end
30
-
31
- def worker_pool
32
- # TODO Pin Rooms to a specific thread so that they never have to worry about thread safety?
33
- @worker_pool || @monitor.synchronize { @worker_pool ||= ThreadPool.new(max_size: config.worker_pool_size) }
34
- end
35
-
36
- def track_room_channel(chan)
37
- raise "Cannot add Room after shutdown" if @shutdown
38
- @room_channels << chan
39
- end
40
-
41
- def untrack_room_channel(chan)
42
- @room_channels.delete(chan)
43
- end
44
-
45
- def shutdown?
46
- @shutdown
47
- end
48
-
49
- def shutdown!
50
- @monitor.synchronize do
51
- @shutdown = true
52
-
53
- scheduler.shutdown
54
-
55
- shutdown_rooms!
56
-
57
- worker_pool.executor.shutdown
58
- worker_pool.executor.wait_for_termination(5)
59
- worker_pool.executor.kill
60
- end
61
- end
62
-
63
- def shutdown_rooms!
64
- @monitor.synchronize do
65
- each_room_channel do |chan|
66
- chan.initiate_shutdown("Server shutting down")
67
- end
68
- end
69
-
70
- count = 0
71
- loop do
72
- break if @room_channels.empty? || count >= 15
73
- sleep 1
74
- count += 1
75
- end
76
- end
77
-
78
- def each_room_channel(&blk)
79
- @room_channels.dup.each(&blk)
80
- end
81
-
82
- private
83
-
84
- def cable_server
85
- ActionCable.server
86
- end
87
-
88
- class ThreadPool < ActionCable::Server::Worker
89
- set_callback :work, :around do |_, blk|
90
- pconn = ActionCable::Server::Worker.connection
91
- ActionCable::Server::Worker.connection = connection
92
- blk.call
93
- ensure
94
- ActionCable::Server::Worker.connection = pconn
95
- end
96
-
97
- # ActionCable's Worker#invoke reduces every exception to a log line and a no-argument
98
- # `handle_exception` call, which discards the error itself. Rooms run all of their work
99
- # through here, so report it properly instead.
100
- def invoke(receiver, method, *args, connection:, &block)
101
- work(connection) do
102
- receiver.send method, *args, &block
103
- rescue Exception => e
104
- if connection.respond_to?(:report_work_error)
105
- connection.report_work_error(e)
106
- else
107
- logger.error "There was an exception - #{e.class}(#{e.message})"
108
- logger.error Array(e.backtrace).join("\n")
109
- CableRoom.report_error(e, connection: connection)
110
- end
111
- end
112
- end
113
-
114
- def async_invoke(receiver, method, *args, connection: receiver, &block)
115
- # Instead of posting directly to the global pool, post to a dedicated queue for the room/"connection".
116
- # This makes each rooms so that they can be processed by at-most-one thread at a time, while still
117
- # allowing multiple rooms to be processed by the same thread-pool.
118
-
119
- # TODO Implement more of a round-robin approach so that no room can starve out others?
120
-
121
- # "connection" here really references the Channel, since "Connections" in this context don't really exist
122
-
123
- connection._post_wrapped_work(async: false) do
124
- invoke(receiver, method, *args, connection: connection, &block)
125
- end
126
- end
127
- end
128
-
129
- end
130
- end
@@ -1,18 +0,0 @@
1
- module CableRoom
2
- module Room
3
- module ChannelAdapter
4
- extend ActiveSupport::Concern
5
-
6
- Channel = CableRoom::ChannelBase
7
-
8
- class_methods do
9
- def inherited(subclass)
10
- # Descend from *this* class's Channel, so periodic timers and other channel-level
11
- # configuration survive multiple levels of subclassing
12
- subclass.const_set(:Channel, Class.new(self::Channel))
13
- super
14
- end
15
- end
16
- end
17
- end
18
- end